From 2729789282ebbbbd37c7a44acc5822bf8bb22a60 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:11:22 -0500 Subject: [PATCH 001/138] fix(native): bound the cleanup stop batch and retry refused connects A 14-service retaining cleanup sent every container stop at once. Each stop holds one engine connection for up to its grace period, and the batch failed at the connect stage while the provider host held most of its descriptor limit, leaving the graph in cleanup-intent with containers running. The stop batch now runs through at most four workers that share one queue and are all joined before it returns. A connect that fails or times out sent no request, so it is retried with capped backoff inside the batch deadline and a 30-second window from that stop's first connect failure. Once one stop exhausts the window, later connect failures return at once. Response, timeout and transport failures are still never retried. A stop whose worker panicked or never ran is reported with the worker stage. The existing stop budget already covers fully serialized grace periods, so bounded dispatch fits it. A partial failure keeps the reached stops, and the next cleanup stops only the unreached instances. Refs HACK-1302 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 7 +- packages/runtime-core/README.md | 8 +- .../runtime-core/src/provider/engine/stop.rs | 631 +++++++++++++----- .../provider/graph/native/runtime/tests.rs | 88 +++ 4 files changed, 579 insertions(+), 155 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..91f8d94b0 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1638,8 +1638,11 @@ Stop failures can also include `stop_failures` version 1 with at most 32 distinc admitted service names and fixed stages: `connect_timeout`, `connect`, `timeout`, `response`, `worker`, `transport` or `deadline`. `response` covers a rejected or unreadable response; these stages describe the request failure, not whether the -container stopped. Every dispatched stop worker is joined before the failure is -returned. Container IDs, response bodies, transport messages and credentials are +container stopped. A batch sends at most four stops at a time and joins every +stop worker before the failure is returned. Only `connect` and `connect_timeout` +are retried inside the batch, because no request reached the engine; these stages +mean the engine stayed unreachable for that stop's 30-second retry window or the +batch deadline. Container IDs, response bodies, transport messages and credentials are omitted. The authenticated foreground client validates the private receipt and checks matching service membership and container generations before and after the request. Detail never grants retry or deletion authority. diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 7178928ec..681b43025 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -245,7 +245,13 @@ retire frontend start/ready/source artifacts or enable ordinary `up` takeover. Guarded native cleanup checks its retained authority before and after every engine observation, removal request and admitted stop batch, including failed -calls. The existing stop batch retains its concurrent HTTP-stop semantics. Cleanup +calls. The stop batch sends at most four HTTP stops at a time from one shared +queue and joins every worker, so 14 or more services do not open one engine +connection each at once. A connect that fails or times out sent no request, so the +batch retries it with capped backoff inside the batch deadline and a 30-second +window from that stop's first connect failure. Once one stop exhausts the window, +the batch treats the engine as unreachable and later connect failures return at +once. Any other stop failure is never retried. Cleanup checks authority before each journal publication. It also checks each private-environment absence and retirement call. A lost guard refuses before the next request or journal transition; an already attempted effect remains diff --git a/packages/runtime-core/src/provider/engine/stop.rs b/packages/runtime-core/src/provider/engine/stop.rs index 4b79d5cab..4e2b0ae91 100644 --- a/packages/runtime-core/src/provider/engine/stop.rs +++ b/packages/runtime-core/src/provider/engine/stop.rs @@ -1,7 +1,14 @@ //! Cleanup-only stop effects. Workers share transport, never the VM mutation lease. use super::*; use crate::error::StopFailureStage; -use std::{collections::BTreeSet, time::Instant}; +use std::{ + collections::BTreeSet, + sync::{ + Mutex, PoisonError, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Instant, +}; // Native recovery fixtures may route one immutable stop target to an owned // test socket. This never exists in a production build or changes the guest @@ -154,17 +161,48 @@ fn stop_budget(stops: &[(String, u64)]) -> Duration { Duration::from_secs(seconds.clamp(40, 540)) } +/// Stop requests in flight per batch. Each stop holds one engine connection for +/// up to its grace period. Dispatching all 14 stops of a qualification graph at +/// once failed at the connect stage while the provider host process held most of +/// its descriptor limit in lingering engine connections (HACK-1302). +/// [`stop_budget`] already covers fully serialized grace periods, so bounded +/// dispatch still fits the budget when every stop takes its full grace. +const STOP_CONCURRENCY: usize = 4; +/// A connect that failed or timed out sent no request bytes, so another attempt +/// is not a replay. Retries stay inside the batch deadline and inside this window +/// from a stop's first connect failure, which covers observed descriptor drain. +const CONNECT_RETRY_WINDOW: Duration = Duration::from_secs(30); +const CONNECT_BACKOFF_START: Duration = Duration::from_millis(25); +const CONNECT_BACKOFF_MAX: Duration = Duration::from_millis(500); + +#[derive(Clone, Copy)] +struct Policy { + concurrency: usize, + connect_window: Duration, + backoff_start: Duration, + backoff_max: Duration, +} + +const POLICY: Policy = Policy { + concurrency: STOP_CONCURRENCY, + connect_window: CONNECT_RETRY_WINDOW, + backoff_start: CONNECT_BACKOFF_START, + backoff_max: CONNECT_BACKOFF_MAX, +}; + +type Attempt = Result<(), (CandidateError, StopFailureStage)>; + fn batch( client: &Client, stops: &[(String, u64)], deadline: Instant, ) -> Result<(), StopBatchFailure> { validate(stops).map_err(StopBatchFailure::plain)?; - std::thread::scope(|scope| { - let mut workers = Vec::with_capacity(stops.len()); - let mut first_error = None; - let mut failures = Vec::new(); - for (id, grace) in stops { + // Test transport selection is thread-local to the caller, so resolve every + // target before any worker starts. + let targets = stops + .iter() + .map(|(id, grace)| { let client = client.clone(); #[cfg(all( test, @@ -173,66 +211,158 @@ fn batch( feature = "environment-launcher" ))] let client = test_socket::for_id(id).unwrap_or(client); - let path = format!("http://hack-local/v1.53/containers/{id}/stop?t={grace}"); - match std::thread::Builder::new().name("hack-container-stop".into()).spawn_scoped(scope, move || { - let remaining = deadline.checked_duration_since(Instant::now()) - .filter(|d| !d.is_zero()) - .ok_or_else(|| (failure("Container stop batch deadline expired; effects may be uncertain."), StopFailureStage::Deadline))?; - let response = client.post(path).timeout(remaining).send() - .map_err(|error| { - let stage = if error.is_connect() && error.is_timeout() { - StopFailureStage::ConnectTimeout - } else if error.is_connect() { - StopFailureStage::Connect - } else if error.is_timeout() { - StopFailureStage::Timeout - } else { - StopFailureStage::Transport - }; - (failure("Container stop failed or timed out; no stop was replayed and no forced deletion was authorized."), stage) - })?; - // A natural exit can race the preceding running observation. Only - // subsequent ownership-checked inspection establishes terminal state. - if response.status().as_u16() == 304 { - return Ok(()); - } - response_bytes(response).map(|_| ()).map_err(|error| (error, StopFailureStage::Response)) - }) { - Ok(worker) => workers.push((id.clone(), worker)), - Err(_) => { - first_error = Some(failure("Cannot start every container stop worker; partial effects require inspection.")); - failures.push(StopFailure { id: id.clone(), stage: StopFailureStage::Worker }); - break; - } - } - } - for (id, worker) in workers { - let result = join_worker(worker); - if let Err((error, stage)) = result { - if first_error.is_none() { - first_error = Some(error); - } - failures.push(StopFailure { id, stage }); - } - } - if let Some(error) = first_error { - failures.sort_by_key(|failure| stops.iter().position(|(id, _)| id == &failure.id)); - Err(StopBatchFailure { error, failures }) + ( + client, + format!("http://hack-local/v1.53/containers/{id}/stop?t={grace}"), + ) + }) + .collect::>(); + dispatch(stops, deadline, POLICY, |index, remaining| { + let (client, path) = &targets[index]; + send(client, path, remaining) + }) +} + +/// One HTTP stop. A non-connect failure is never retried: the request may have +/// reached the engine, and only later ownership-checked inspection decides. +fn send(client: &Client, path: &str, remaining: Duration) -> Attempt { + let response = client.post(path).timeout(remaining).send().map_err(|error| { + let stage = if error.is_connect() && error.is_timeout() { + StopFailureStage::ConnectTimeout + } else if error.is_connect() { + StopFailureStage::Connect + } else if error.is_timeout() { + StopFailureStage::Timeout } else { - Ok(()) + StopFailureStage::Transport + }; + ( + failure( + "Container stop failed or timed out; no stop was replayed and no forced deletion was authorized.", + ), + stage, + ) + })?; + // A natural exit can race the preceding running observation. Only + // subsequent ownership-checked inspection establishes terminal state. + if response.status().as_u16() == 304 { + return Ok(()); + } + response_bytes(response) + .map(|_| ()) + .map_err(|error| (error, StopFailureStage::Response)) +} + +/// Runs every stop through at most `policy.concurrency` workers that share one +/// queue, then joins them all, even after failures. Every stop gets exactly one +/// recorded outcome: a stop whose worker panicked, or that no worker could take, +/// is reported with the `worker` stage, because its effect is unknown. +fn dispatch( + stops: &[(String, u64)], + deadline: Instant, + policy: Policy, + send: S, +) -> Result<(), StopBatchFailure> +where + S: Fn(usize, Duration) -> Attempt + Sync, +{ + let next = AtomicUsize::new(0); + let unreachable = AtomicBool::new(false); + let outcomes = stops.iter().map(|_| Mutex::new(None)).collect::>(); + std::thread::scope(|scope| { + let workers = (0..policy.concurrency.clamp(1, stops.len().max(1))) + .map_while(|_| { + std::thread::Builder::new() + .name("hack-container-stop".into()) + .spawn_scoped(scope, || { + loop { + let index = next.fetch_add(1, Ordering::SeqCst); + let Some(slot) = outcomes.get(index) else { + break; + }; + let outcome = attempt(index, deadline, policy, &unreachable, &send); + *slot.lock().unwrap_or_else(PoisonError::into_inner) = Some(outcome); + } + }) + .ok() + }) + .collect::>(); + // A panicked worker leaves its current stop unrecorded; its siblings + // keep draining the queue. + for worker in workers { + let _ = worker.join(); } - }) + }); + let mut first_error = None; + let mut failures = Vec::new(); + for ((id, _), slot) in stops.iter().zip(outcomes) { + let outcome = slot.into_inner().unwrap_or_else(PoisonError::into_inner); + let (error, stage) = match outcome { + Some(Ok(())) => continue, + Some(Err(failed)) => failed, + None => ( + failure( + "Container stop worker failed or did not start; effects require inspection.", + ), + StopFailureStage::Worker, + ), + }; + first_error.get_or_insert(error); + failures.push(StopFailure { + id: id.clone(), + stage, + }); + } + match first_error { + Some(error) => Err(StopBatchFailure { error, failures }), + None => Ok(()), + } } -fn join_worker( - worker: std::thread::ScopedJoinHandle<'_, Result<(), (CandidateError, StopFailureStage)>>, -) -> Result<(), (CandidateError, StopFailureStage)> { - worker.join().unwrap_or_else(|_| { - Err(( - failure("Container stop worker failed; effects require inspection."), - StopFailureStage::Worker, - )) - }) +/// Sends one stop, retrying only connect-stage failures with capped backoff. +/// Once any stop exhausts its connect window, the engine is treated as +/// unreachable for this batch and later connect failures return at once. +fn attempt( + index: usize, + deadline: Instant, + policy: Policy, + unreachable: &AtomicBool, + send: &S, +) -> Attempt +where + S: Fn(usize, Duration) -> Attempt, +{ + let mut window = None; + let mut backoff = policy.backoff_start; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| { + ( + failure("Container stop batch deadline expired; effects may be uncertain."), + StopFailureStage::Deadline, + ) + })?; + match send(index, remaining) { + Err((error, stage)) + if matches!( + stage, + StopFailureStage::Connect | StopFailureStage::ConnectTimeout + ) => + { + let now = Instant::now(); + let until = (*window.get_or_insert(now + policy.connect_window)).min(deadline); + if unreachable.load(Ordering::SeqCst) || now + backoff >= until { + unreachable.store(true, Ordering::SeqCst); + return Err((error, stage)); + } + std::thread::sleep(backoff); + backoff = backoff.saturating_mul(2).min(policy.backoff_max); + } + outcome => return outcome, + } + } } #[cfg(test)] @@ -268,95 +398,144 @@ mod tests { assert_eq!(stop_budget(&largest), Duration::from_secs(540)); } + fn ids(count: usize) -> Vec<(String, u64)> { + (0..count).map(|n| (format!("{n:064x}"), 1)).collect() + } + + fn socket_path(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "hs-{label}-{}-{}.sock", + std::process::id(), + crate::node::now() + )) + } + + /// Reads one request head and returns the stop target index from its path. + fn read_stop(socket: &mut std::os::unix::net::UnixStream) -> usize { + socket.set_nonblocking(false).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + socket.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + assert!(request.len() <= 8192); + } + let line = std::str::from_utf8(&request) + .unwrap() + .lines() + .next() + .unwrap() + .to_owned(); + let target = line + .strip_prefix("POST /v1.53/containers/") + .and_then(|rest| rest.strip_suffix("/stop?t=1 HTTP/1.1")) + .unwrap_or_else(|| panic!("unexpected stop request {line}")); + usize::from_str_radix(target, 16).unwrap() + } + + fn reply(socket: &mut std::os::unix::net::UnixStream, status: &str, body: &str) { + socket + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + } + + fn quick(window: Duration) -> Policy { + Policy { + concurrency: STOP_CONCURRENCY, + connect_window: window, + backoff_start: Duration::from_millis(1), + backoff_max: Duration::from_millis(4), + } + } + + fn connect_failure(stage: StopFailureStage) -> Attempt { + Err((failure("value-free connect failure"), stage)) + } + #[test] - fn all_32_stops_arrive_before_replies_and_failure_joins_every_worker_without_retry() { + fn socket_batch_bounds_in_flight_stops_and_joins_every_worker_without_response_retry() { for fail in [false, true] { - let path = std::env::temp_dir().join(format!( - "hs-{}-{}-{fail}.sock", - std::process::id(), - crate::node::now() - )); + let path = socket_path(&format!("bound-{fail}")); let listener = UnixListener::bind(&path).unwrap(); listener.set_nonblocking(true).unwrap(); let server = std::thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(10); - let mut sockets = Vec::new(); - let mut targets = BTreeSet::new(); - while sockets.len() < 32 { + let deadline = Instant::now() + Duration::from_secs(20); + let mut seen = BTreeSet::new(); + let mut held = Vec::new(); + let mut peak = 0; + let mut last_accept = Instant::now(); + while seen.len() < 32 || !held.is_empty() { + assert!(Instant::now() < deadline, "stop batch stalled"); match listener.accept() { Ok((mut socket, _)) => { - socket.set_nonblocking(false).unwrap(); - socket - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); - let mut request = Vec::new(); - while !request.ends_with(b"\r\n\r\n") { - let mut byte = [0]; - socket.read_exact(&mut byte).unwrap(); - request.push(byte[0]); - assert!(request.len() <= 8192); + let index = read_stop(&mut socket); + assert!(seen.insert(index), "stop {index} was replayed"); + held.push((index, socket)); + peak = peak.max(held.len()); + last_accept = Instant::now(); + if held.len() < STOP_CONCURRENCY { + continue; + } + // A bounded client cannot open another stop before + // one of the held stops is answered. + std::thread::sleep(Duration::from_millis(100)); + if let Ok((mut extra, _)) = listener.accept() { + let index = read_stop(&mut extra); + held.push((index, extra)); + peak = peak.max(held.len()); } - let line = std::str::from_utf8(&request) - .unwrap() - .lines() - .next() - .unwrap() - .to_owned(); - assert!( - line.starts_with("POST /v1.53/containers/") - && line.ends_with("/stop?t=1 HTTP/1.1") - ); - assert!(targets.insert(line)); - sockets.push(socket); } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - assert!(Instant::now() < deadline, "Stop dispatch became sequential"); - std::thread::sleep(Duration::from_millis(2)); + if held.is_empty() || last_accept.elapsed() < Duration::from_millis(300) + { + std::thread::sleep(Duration::from_millis(2)); + continue; + } } Err(e) => panic!("{e}"), } + for (index, mut socket) in held.drain(..) { + if fail && (index == 0 || index == 31) { + reply(&mut socket, "500 Failed", "private-stop-canary"); + } else if index == 1 { + reply(&mut socket, "304 Not Modified", ""); + } else { + reply(&mut socket, "204 No Content", ""); + } + } } - for (index, socket) in sockets.iter_mut().enumerate() { - let status = if fail && (index == 0 || index == 31) { - "500 Failed" - } else if index == 1 { - "304 Not Modified" - } else { - "204 No Content" - }; - let body = if status == "500 Failed" { - "private-stop-canary" - } else { - "" - }; - socket.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).unwrap(); - } - 32 + (seen.len(), peak) }); let transport = Transport::new(&path, Duration::from_secs(10)).unwrap(); - let stops = (0..32) - .map(|n| (format!("{n:064x}"), 1)) - .collect::>(); + let stops = ids(32); let result = batch( &transport.client, &stops, - Instant::now() + Duration::from_secs(10), + Instant::now() + Duration::from_secs(20), ); assert_eq!(result.is_err(), fail); if fail { let failure = result.err().unwrap(); - assert_eq!(failure.failures.len(), 2); - assert!( - failure - .failures - .iter() - .all(|item| item.stage == StopFailureStage::Response) - ); - assert!( - failure - .failures - .iter() - .all(|item| stops.iter().any(|(id, _)| id == &item.id)) + let failed = failure + .failures + .iter() + .map(|item| (item.id.clone(), item.stage)) + .collect::>(); + assert_eq!( + failed, + vec![ + (stops[0].0.clone(), StopFailureStage::Response), + (stops[31].0.clone(), StopFailureStage::Response), + ] ); assert_eq!(failure.error.code, "engine_rejected"); assert!( @@ -365,23 +544,52 @@ mod tests { .contains("private-stop-canary") ); } - assert_eq!(server.join().unwrap(), 32); + assert_eq!(server.join().unwrap(), (32, STOP_CONCURRENCY)); std::fs::remove_file(path).unwrap(); } } #[test] - fn unavailable_socket_reports_connect_without_replaying() { - let path = std::env::temp_dir().join(format!("hs-missing-{}.sock", std::process::id())); + fn refused_connects_retry_until_the_engine_listens_and_each_stop_arrives_once() { + let path = socket_path("late"); + let bound = path.clone(); + let server = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + let listener = UnixListener::bind(&bound).unwrap(); + let mut seen = BTreeSet::new(); + while seen.len() < 14 { + let (mut socket, _) = listener.accept().unwrap(); + let index = read_stop(&mut socket); + assert!(seen.insert(index), "stop {index} was replayed"); + reply(&mut socket, "204 No Content", ""); + } + seen.len() + }); + let transport = Transport::new(&path, Duration::from_secs(10)).unwrap(); + batch( + &transport.client, + &ids(14), + Instant::now() + Duration::from_secs(10), + ) + .unwrap_or_else(|failure| panic!("{:?}", failure.error)); + assert_eq!(server.join().unwrap(), 14); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn unavailable_socket_reports_connect_after_retrying_only_within_the_deadline() { + let path = socket_path("missing"); let transport = Transport::new(&path, Duration::from_secs(1)).unwrap(); let id = "a".repeat(64); + let started = Instant::now(); let failure = batch( &transport.client, &[(id.clone(), 0)], - Instant::now() + Duration::from_secs(1), + Instant::now() + Duration::from_millis(400), ) .err() .unwrap(); + assert!(started.elapsed() < Duration::from_secs(3)); assert_eq!(failure.error.code, "engine_protocol"); assert_eq!(failure.failures.len(), 1); assert_eq!(failure.failures[0].id, id); @@ -389,8 +597,123 @@ mod tests { } #[test] - fn expired_batch_and_failed_worker_keep_fixed_stages() { - let path = std::env::temp_dir().join(format!("hs-deadline-{}.sock", std::process::id())); + fn connect_failures_for_some_of_fourteen_stops_retry_within_the_bound_without_replay() { + let stops = ids(14); + let attempts = (0..14).map(|_| AtomicUsize::new(0)).collect::>(); + let active = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + dispatch( + &stops, + Instant::now() + Duration::from_secs(10), + quick(Duration::from_secs(5)), + |index, _| { + let attempt = attempts[index].fetch_add(1, Ordering::SeqCst); + let now = active.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(5)); + active.fetch_sub(1, Ordering::SeqCst); + match index { + 2 | 5 | 9 if attempt < 3 => connect_failure(StopFailureStage::Connect), + 11 if attempt == 0 => connect_failure(StopFailureStage::ConnectTimeout), + _ => Ok(()), + } + }, + ) + .unwrap_or_else(|failure| panic!("{:?}", failure.error)); + let attempts = attempts + .iter() + .map(|count| count.load(Ordering::SeqCst)) + .collect::>(); + assert_eq!(attempts, vec![1, 1, 4, 1, 1, 4, 1, 1, 1, 4, 1, 2, 1, 1]); + assert!(peak.load(Ordering::SeqCst) <= STOP_CONCURRENCY); + assert!(peak.load(Ordering::SeqCst) > 1, "stops became sequential"); + } + + #[test] + fn persistent_connect_failures_record_only_unreachable_stops_as_resumable_progress() { + let stops = ids(14); + let attempts = (0..14).map(|_| AtomicUsize::new(0)).collect::>(); + let failure = dispatch( + &stops, + Instant::now() + Duration::from_secs(10), + quick(Duration::from_millis(50)), + |index, _| { + attempts[index].fetch_add(1, Ordering::SeqCst); + if index == 3 || index == 8 { + connect_failure(StopFailureStage::Connect) + } else { + Ok(()) + } + }, + ) + .err() + .unwrap(); + assert_eq!(failure.error.code, "engine_protocol"); + let failed = failure + .failures + .iter() + .map(|item| (item.id.clone(), item.stage)) + .collect::>(); + assert_eq!( + failed, + vec![ + (stops[3].0.clone(), StopFailureStage::Connect), + (stops[8].0.clone(), StopFailureStage::Connect), + ] + ); + for (index, count) in attempts.iter().enumerate() { + let count = count.load(Ordering::SeqCst); + if index == 3 || index == 8 { + assert!(count >= 1); + } else { + assert_eq!(count, 1, "stop {index} was replayed"); + } + } + } + + #[test] + fn non_connect_failures_are_never_retried_and_keep_their_stage() { + let stops = ids(6); + let attempts = (0..6).map(|_| AtomicUsize::new(0)).collect::>(); + let failure = dispatch( + &stops, + Instant::now() + Duration::from_secs(10), + quick(Duration::from_secs(5)), + |index, _| { + attempts[index].fetch_add(1, Ordering::SeqCst); + match index { + 1 => connect_failure(StopFailureStage::Timeout), + 2 => connect_failure(StopFailureStage::Transport), + 4 => connect_failure(StopFailureStage::Response), + _ => Ok(()), + } + }, + ) + .err() + .unwrap(); + assert!( + attempts + .iter() + .all(|count| count.load(Ordering::SeqCst) == 1) + ); + let stages = failure + .failures + .iter() + .map(|item| item.stage) + .collect::>(); + assert_eq!( + stages, + vec![ + StopFailureStage::Timeout, + StopFailureStage::Transport, + StopFailureStage::Response, + ] + ); + } + + #[test] + fn expired_batch_and_panicked_worker_keep_fixed_stages() { + let path = socket_path("deadline"); let transport = Transport::new(&path, Duration::from_secs(1)).unwrap(); let failure = batch( &transport.client, @@ -400,28 +723,32 @@ mod tests { .err() .unwrap(); assert_eq!(failure.failures[0].stage, StopFailureStage::Deadline); - std::thread::scope(|scope| { - let worker = scope.spawn(|| -> Result<(), (CandidateError, StopFailureStage)> { - panic!("private worker panic"); - }); - let (error, stage) = join_worker(worker).unwrap_err(); - assert_eq!(error.code, "engine_protocol"); - assert_eq!(stage, StopFailureStage::Worker); - assert!( - !serde_json::to_string(&error) - .unwrap() - .contains("private worker panic") - ); - }); + let stops = ids(8); + let failure = dispatch( + &stops, + Instant::now() + Duration::from_secs(10), + quick(Duration::from_secs(5)), + |index, _| { + assert!(index != 4, "private worker panic"); + Ok(()) + }, + ) + .err() + .unwrap(); + assert_eq!(failure.error.code, "engine_protocol"); + assert_eq!(failure.failures.len(), 1); + assert_eq!(failure.failures[0].id, stops[4].0); + assert_eq!(failure.failures[0].stage, StopFailureStage::Worker); + assert!( + !serde_json::to_string(&failure.error) + .unwrap() + .contains("private worker panic") + ); } #[test] fn accepted_stop_without_reply_reports_timeout_without_replay() { - let path = std::env::temp_dir().join(format!( - "hs-timeout-{}-{}.sock", - std::process::id(), - crate::node::now() - )); + let path = socket_path("timeout"); let listener = UnixListener::bind(&path).unwrap(); let server = std::thread::spawn(move || { let (_socket, _) = listener.accept().unwrap(); diff --git a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs index 26c4f7468..f8601d4ae 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs @@ -685,6 +685,9 @@ struct FakeState { fail_start: bool, fail_delete: bool, fail_stop: bool, + /// Stops every selected instance except the last N, which report a connect failure. + unreachable_stops: usize, + stop_selections: Vec, fail_network_create: bool, fail_network_after_apply: bool, fail_network_delete_after_apply: bool, @@ -1003,6 +1006,9 @@ impl Backend for Fake { assert_eq!(self.receipt().phase, Phase::StopIntent); let mut state = self.state.borrow_mut(); state.stop_batches += 1; + state.stop_selections.push(selected.len()); + let reached = selected.len().saturating_sub(state.unreachable_stops); + let (selected, unreachable) = selected.split_at(reached); if state.fail_stop { return Err(super::super::super::shutdown::stop_error( crate::provider::engine::StopBatchFailure { @@ -1031,6 +1037,21 @@ impl Backend for Fake { if let Some(canceled) = &state.cancel_after_stop { canceled.set(true); } + if !unreachable.is_empty() { + return Err(super::super::super::shutdown::stop_error( + crate::provider::engine::StopBatchFailure { + error: error("engine_protocol", "value-free connect refusal"), + failures: unreachable + .iter() + .map(|(id, _)| crate::provider::engine::StopFailure { + id: id.clone(), + stage: crate::error::StopFailureStage::Connect, + }) + .collect(), + }, + admitted, + )); + } Ok(()) } } @@ -1862,6 +1883,73 @@ fn native_stop_failure_keeps_reservation_and_maps_only_owned_ids_to_workload_nam assert_eq!(session.receipt.phase, Phase::Removed); } +fn fourteen_services() -> (Value, Value) { + let names = (0..14).map(|n| format!("svc{n:02}")).collect::>(); + let services = names + .iter() + .map(|name| { + ( + name.clone(), + json!({"image":image(),"command":{"exec":["/bin/echo","$EXACT"]}}), + ) + }) + .collect::>(); + let metadata = names + .iter() + .map(|name| (name.clone(), json!({}))) + .collect::>(); + ( + json!({"schema_version":1,"name":"fixture","services":services}), + Value::Object(metadata), + ) +} + +#[test] +fn fourteen_service_connect_failures_keep_resumable_progress_and_retry_only_unstopped() { + let (project, metadata) = fourteen_services(); + let fixture = Fixture::new(project); + let (graph, mut session) = fixture.session(fixture.prepared(metadata, &BTreeMap::new())); + execution::run(&graph, &mut session, RUN_BUDGET).unwrap(); + session.backend.state.borrow_mut().unreachable_stops = 3; + let error = cleanup_using(&session.backend, &mut session.receipt, &session.root).unwrap_err(); + assert_eq!(error.code, "engine_protocol"); + let detail = error.stop_failures.as_ref().unwrap(); + assert_eq!(detail.failures.len(), 3); + assert!(detail.failures.iter().all(|failure| failure.stage + == crate::error::StopFailureStage::Connect + && failure.service.starts_with("svc"))); + assert_eq!(session.backend.receipt().phase, Phase::StopIntent); + assert!( + !session + .backend + .state + .borrow() + .effects + .iter() + .any(|effect| effect.starts_with("delete:")) + ); + // The retry inspects every instance, stops only the three unreached ones, + // and completes cleanup without replaying the eleven committed stops. + session.backend.state.borrow_mut().unreachable_stops = 0; + cleanup_using(&session.backend, &mut session.receipt, &session.root).unwrap(); + assert_eq!(session.receipt.phase, Phase::Removed); + let state = session.backend.state.borrow(); + assert_eq!(state.stop_selections, vec![14, 3]); + let stops = state + .effects + .iter() + .filter(|effect| effect.starts_with("stop:")) + .collect::>(); + assert_eq!(stops.len(), 14); + assert_eq!( + stops + .iter() + .collect::>() + .len(), + 14 + ); +} + #[test] fn cleanup_owner_guard_fences_delete_after_stop_and_network_delete_after_container_removal() { for after_delete in [false, true] { From ca8f5e5891da46c849ec80219952773717c15704 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:35:39 -0500 Subject: [PATCH 002/138] fix(native): locate review port findings and name doctor next steps Native Compose review no longer aborts on an unsupported port. Each port or expose entry outside the modeled subset becomes an invalid_port error diagnostic at services..ports[] (or .expose[]), collected with the rest of the review; the entry is omitted from the plan so authored text is never echoed, and enrollment stays incompatible. Import preview reviews each environment entry on its own: only an interpolated or ambiguous entry is refused at its pointer, and literal siblings stay mapped. Doctor's compose backend check names the isolatedNetworks adaptation for an external network, warns (with nativeUpRefusals: ["basic_build"]) for a basic build that native image-project up refuses, and the config validate/plan input-family refusal now names the next step. Refs HACK-1907 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 11 +- docs/reference/native-config-import.md | 6 +- .../src/project/compose/service.rs | 212 +++++++++++------- .../runtime-core/tests/project_contract.rs | 68 +++++- src/commands/doctor.ts | 10 +- src/lib/doctor-compose-backend.ts | 52 ++++- src/lib/native-compose-backend-features.ts | 83 ++++++- src/lib/native-config-import-plan.ts | 99 +++++++- src/lib/native-project-inputs.ts | 10 +- tests/missing-project-error-code.test.ts | 3 +- ...ative-compose-backend-features-cli.test.ts | 27 +++ tests/native-compose-backend-features.test.ts | 68 ++++++ tests/native-config-import-plan.test.ts | 38 ++++ 13 files changed, 565 insertions(+), 122 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..953da5c67 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -237,7 +237,16 @@ authored native format. lists the closed names `external_network`, `external_volume`, `reserved_label`, `unsupported_build` and `foreign_platform`. On the native backend a found feature is a warning; on the Compose backend it is informational. Native doctor applies -the same `HACK_NATIVE_ADAPTATION` selection before it reports. +the same `HACK_NATIVE_ADAPTATION` selection before it reports. When it finds an +external network, the message also names the `isolatedNetworks` selection above, +which keeps that network's project on native. + +A basic build is not one of these features, but native image-project `up` still +refuses it, so doctor does not report it as ok. On the native backend the check +warns, names the general configuration refusal and the next step (preview the +authored native conversion with `hack config import --dry-run`, or select the +Compose backend), and its JSON adds `composeBackend.nativeUpRefusals: +["basic_build"]`. On the Compose backend the same note is informational. Publication control tokens are delivered to the relay through a one-shot inherited pipe (`--control-fd 0`), never through process arguments. The relay reads exactly diff --git a/docs/reference/native-config-import.md b/docs/reference/native-config-import.md index e94366406..294ba8b47 100644 --- a/docs/reference/native-config-import.md +++ b/docs/reference/native-config-import.md @@ -66,7 +66,11 @@ whitespace-only entrypoint explicitly clears the image entrypoint; empty or whitespace-only command refuses because the native command model cannot express Compose's explicit empty override. Outside argv and basic build paths, dollars in runtime strings and NUL in runtime values refuse rather than inheriting ambient environment. Environment list entries without `=`, duplicate -names, nulls and non-string values refuse. +names, nulls and non-string values refuse. Each environment entry is reviewed on its own: +only an interpolated or otherwise unsupported entry is refused, at its own pointer such as +`/services/web/environment/FROM_ENV` or `/services/web/environment/1`, while literal +siblings in the same map or list stay mapped. Any refused entry still makes the whole +preview incomplete. Every unknown field remains a refusal, including fields in inactive profiles. Advanced builds, volumes/bind mounts, other network shapes, ports, other labels, diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index 5cc51846a..f46142091 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -237,6 +237,10 @@ fn port_scalar(value: &Value) -> Result { _ => Err(problem("invalid_port", "Invalid port value.")), } } +/// Review every declared port entry. An entry outside the modeled subset becomes +/// an `invalid_port` error diagnostic that points at `services..ports[]` +/// (messages never echo the authored value), so one unsupported port neither +/// hides the rest of the review nor makes the plan enrollment compatible. fn ports( value: Option<&Value>, field: &str, @@ -245,91 +249,116 @@ fn ports( let Some(value) = value else { return Ok(vec![]); }; + let Some(values) = value.as_array() else { + diagnostics.push(Diagnostic::error( + "invalid_port", + field, + "Ports must be a list.", + )); + return Ok(vec![]); + }; let mut result = Vec::new(); - for value in value - .as_array() - .ok_or_else(|| problem("invalid_port", "Ports must be a list."))? - { - let (target, published, protocol, host) = if let Value::Object(m) = value { - keys( - m, - &["target", "published", "protocol", "host_ip"], - field, - diagnostics, - ); - ( - port_scalar( - m.get("target") - .ok_or_else(|| problem("invalid_port", "Port target is required."))?, - )?, - m.get("published").map(port_scalar).transpose()?, - m.get("protocol") - .map(string) - .transpose()? - .unwrap_or("tcp") - .to_owned(), - m.get("host_ip") - .map(string) - .transpose()? - .unwrap_or("0.0.0.0") - .to_owned(), - ) - } else { - let text = match value { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - _ => return Err(problem("invalid_port", "Invalid port form.")), - }; - let (binding, protocol) = text.split_once('/').unwrap_or((&text, "tcp")); - let parts: Vec<_> = binding.split(':').collect(); - match parts.as_slice() { - [target] => ( - port_number(target)?, - None, - protocol.into(), - "0.0.0.0".into(), - ), - [published, target] => ( - port_number(target)?, - Some(port_number(published)?), - protocol.into(), - "0.0.0.0".into(), - ), - [host, published, target] => ( - port_number(target)?, - Some(port_number(published)?), - protocol.into(), - host.to_string(), - ), - _ => return Err(problem("invalid_port", "Unsupported port syntax.")), - } - }; - if !["tcp", "udp"].contains(&protocol.as_str()) { - return Err(problem( + for (index, value) in values.iter().enumerate() { + match port(value, field, diagnostics) { + Ok(plan) => result.push(plan), + Err(error) if error.code == "invalid_port" => diagnostics.push(Diagnostic::error( "invalid_port", - "Only TCP and UDP ports are modeled.", - )); + &format!("{field}[{index}]"), + &error.message, + )), + Err(error) => return Err(error), } - if !["0.0.0.0", "127.0.0.1"].contains(&host.as_str()) { - diagnostics.push(Diagnostic::error("unsupported_host_binding",field,"Only explicit IPv4 loopback or an explicit proposal to restrict an all-interface binding is supported.")); - } - if host == "0.0.0.0" { - diagnostics.push(Diagnostic::warning("proposed_loopback_binding",field,"Enrollment proposes restricting the declared all-interface publication to 127.0.0.1. No port is bound by this checkpoint.")); - } - result.push(PortPlan { - target, - published, - protocol, - declared_host_ip: if ["0.0.0.0", "127.0.0.1"].contains(&host.as_str()) { - host - } else { - "[unsupported]".into() - }, - proposed_host_ip: "127.0.0.1".into(), - }); } Ok(result) } +fn port( + value: &Value, + field: &str, + diagnostics: &mut Vec, +) -> Result { + let (target, published, protocol, host) = if let Value::Object(m) = value { + keys( + m, + &["target", "published", "protocol", "host_ip"], + field, + diagnostics, + ); + ( + port_scalar( + m.get("target") + .ok_or_else(|| problem("invalid_port", "Port target is required."))?, + )?, + m.get("published").map(port_scalar).transpose()?, + m.get("protocol") + .map(string) + .transpose()? + .unwrap_or("tcp") + .to_owned(), + m.get("host_ip") + .map(string) + .transpose()? + .unwrap_or("0.0.0.0") + .to_owned(), + ) + } else { + let text = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => return Err(problem("invalid_port", "Invalid port form.")), + }; + let (binding, protocol) = text.split_once('/').unwrap_or((&text, "tcp")); + let parts: Vec<_> = binding.split(':').collect(); + match parts.as_slice() { + [target] => ( + port_number(target)?, + None, + protocol.into(), + "0.0.0.0".into(), + ), + [published, target] => ( + port_number(target)?, + Some(port_number(published)?), + protocol.into(), + "0.0.0.0".into(), + ), + [host, published, target] => ( + port_number(target)?, + Some(port_number(published)?), + protocol.into(), + host.to_string(), + ), + _ => { + return Err(problem( + "invalid_port", + "Unsupported port syntax. Use a literal [host_ip:]published:target[/protocol] entry; interpolation, ranges and IPv6 bindings are not supported.", + )); + } + } + }; + if !["tcp", "udp"].contains(&protocol.as_str()) { + return Err(problem( + "invalid_port", + "Only TCP and UDP ports are modeled.", + )); + } + if !["0.0.0.0", "127.0.0.1"].contains(&host.as_str()) { + diagnostics.push(Diagnostic::error("unsupported_host_binding",field,"Only explicit IPv4 loopback or an explicit proposal to restrict an all-interface binding is supported.")); + } + if host == "0.0.0.0" { + diagnostics.push(Diagnostic::warning("proposed_loopback_binding",field,"Enrollment proposes restricting the declared all-interface publication to 127.0.0.1. No port is bound by this checkpoint.")); + } + Ok(PortPlan { + target, + published, + protocol, + declared_host_ip: if ["0.0.0.0", "127.0.0.1"].contains(&host.as_str()) { + host + } else { + "[unsupported]".into() + }, + proposed_host_ip: "127.0.0.1".into(), + }) +} fn dependencies( value: Option<&Value>, field: &str, @@ -861,12 +890,25 @@ pub(super) fn compile( "Only Linux arm64/amd64 image platform declarations are modeled.", )); } - let exposed_ports = strings(m.get("expose"))?; - for value in &exposed_ports { - let (port, protocol) = value.split_once('/').unwrap_or((value, "tcp")); - port_number(port)?; - if !["tcp", "udp"].contains(&protocol) { - return Err(problem("invalid_port", "Invalid exposed port protocol.")); + // Invalid entries are reported by pointer and omitted from the plan so + // authored text (including interpolation defaults) is never echoed. + let mut exposed_ports = Vec::new(); + for (index, value) in strings(m.get("expose"))?.into_iter().enumerate() { + let (port, protocol) = value.split_once('/').unwrap_or((&value, "tcp")); + let message = match port_number(port) { + Err(error) => Some(error.message), + Ok(_) if !["tcp", "udp"].contains(&protocol) => { + Some("Invalid exposed port protocol.".into()) + } + Ok(_) => None, + }; + match message { + Some(message) => diagnostics.push(Diagnostic::error( + "invalid_port", + &format!("{field}.expose[{index}]"), + &message, + )), + None => exposed_ports.push(value), } } let stop_signal = optional_text(m.get("stop_signal"))?; diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 4028bef1d..59f1e62ca 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -823,8 +823,6 @@ fn invalid_limits_ports_commands_and_conflicting_owners_are_rejected() { " cpus: -1\n", " mem_limit: -1\n", " pids_limit: 0\n", - " ports: ['70000:80']\n", - " ports: ['8000-8001:80']\n", " environment: [A=one, A=two]\n", ] { let fixture = Fixture::new(&format!("{BASIC}{extra}")); @@ -833,6 +831,24 @@ fn invalid_limits_ports_commands_and_conflicting_owners_are_rejected() { "{extra}" ); } + for extra in [ + " ports: ['70000:80']\n", + " ports: ['8000-8001:80']\n", + " ports: ['80/sctp']\n", + " ports: ['[::1]:8080:80']\n", + ] { + let fixture = Fixture::new(&format!("{BASIC}{extra}")); + let plan = fixture.plan().plan; + assert!(!plan.enrollment_compatible, "{extra}"); + assert!( + plan.diagnostics.iter().any(|d| d.severity == "error" + && d.code == "invalid_port" + && d.field == "services.web.ports[0]"), + "{extra}: {:?}", + plan.diagnostics + ); + assert!(plan.services["web"].ports.is_empty(), "{extra}"); + } let fixture = Fixture::new( "services:\n web:\n image: alpine:3.21\n command: echo hello\n network_mode: none\n networks: [default]\n cpus: 1\n deploy: {resources: {limits: {cpus: '2'}}}\n", ); @@ -842,6 +858,54 @@ fn invalid_limits_ports_commands_and_conflicting_owners_are_rejected() { } } +/// An unsupported port is one located review finding, not a whole-review abort: +/// the review still reports every other problem, names the service and entry, +/// and never echoes the authored text (including interpolation defaults). +#[test] +fn unsupported_ports_are_located_diagnostics_collected_with_the_rest_of_the_review() { + let fixture = Fixture::new( + "services:\n web:\n image: alpine:3.21\n privileged: true\n ports: ['8080:80']\n expose: ['3000', '${EXPOSED:-4000}']\n debug:\n image: alpine:3.21\n profiles: [debug]\n ports: ['${BIND:-127.0.0.1}:9090:80', '9091:81', '${OTHER_BIND:-10.9.8.7}:9092:82']\n", + ); + let report = project::plan(&fixture.candidate, fixture.options()).unwrap(); + let plan = &report.plan; + assert!(!plan.enrollment_compatible); + let located: Vec<_> = plan + .diagnostics + .iter() + .filter(|d| d.code == "invalid_port") + .map(|d| (d.severity.as_str(), d.field.as_str())) + .collect(); + assert_eq!( + located, + vec![ + ("error", "services.debug.ports[0]"), + ("error", "services.debug.ports[2]"), + ("error", "services.web.expose[1]"), + ] + ); + assert!( + plan.diagnostics + .iter() + .any(|d| d.code == "privileged_service" && d.field == "services.web") + ); + assert!(!plan.services["debug"].active); + assert_eq!(plan.services["debug"].ports.len(), 1); + assert_eq!(plan.services["debug"].ports[0].published, Some(9091)); + assert_eq!(plan.services["web"].ports[0].published, Some(8080)); + assert_eq!(plan.services["web"].exposed_ports, vec!["3000"]); + let serialized = serde_json::to_string(&report).unwrap(); + for authored in ["10.9.8.7", "OTHER_BIND", "EXPOSED", "4000"] { + assert!(!serialized.contains(authored), "{authored}"); + } + assert_eq!( + project::enroll(&fixture.candidate, fixture.options(), &report.plan_id) + .unwrap_err() + .code, + "incompatible_compose" + ); + assert!(!fixture.candidate.state_root.exists()); +} + #[test] fn host_root_secret_mounts_symlink_escape_and_special_ignore_files_are_refused() { let fixture = Fixture::new(&format!("{BASIC} volumes: ['/:/host']\n")); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..7e090f663 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -144,7 +144,7 @@ import { repairNativeCaTrust, } from "../lib/native-ca-doctor.ts"; import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; -import { readComposeBackendFeatures } from "../lib/native-compose-backend-features.ts"; +import { readComposeBackendReading } from "../lib/native-compose-backend-features.ts"; import { describeHackLocalRoute, HACK_LOCAL_ROLLBACK_COMMAND, @@ -2945,11 +2945,13 @@ async function checkComposeBackendFeatures(opts: { message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, }; } + const reading = await readComposeBackendReading(ctx.composeFile, { + adaptationFile: opts.adaptationFile, + }); return composeBackendCheckResult({ backend: opts.backend, - features: await readComposeBackendFeatures(ctx.composeFile, { - adaptationFile: opts.adaptationFile, - }), + features: reading.features, + basicBuild: reading.basicBuild, }); } diff --git a/src/lib/doctor-compose-backend.ts b/src/lib/doctor-compose-backend.ts index da78f14b8..4fcf40568 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -1,15 +1,25 @@ import { + BASIC_BUILD_NEXT_STEP, COMPOSE_BACKEND_SELECTION, type ComposeBackendFeature, describeComposeBackendFeatures, + ISOLATED_NETWORKS_SELECTION, } from "./native-compose-backend-features.ts"; /** Doctor check id for the Compose-backend feature report. */ export const COMPOSE_BACKEND_CHECK = "compose backend features"; -/** Value-free JSON payload: closed feature names only. */ +/** Closed reasons native image-project `up` refuses a project that stays native-eligible. */ +export type NativeUpRefusal = "basic_build"; + +/** + * Value-free JSON payload: closed feature names only. `nativeUpRefusals` is + * present only when native image-project `up` refuses a project for a reason + * outside {@link ComposeBackendFeature}, such as a basic build. + */ export type ComposeBackendReport = { readonly features: readonly ComposeBackendFeature[]; + readonly nativeUpRefusals?: readonly NativeUpRefusal[]; }; /** @@ -19,19 +29,51 @@ export type ComposeBackendReport = { * * On the native backend a blocking feature is a warning, because native `up` * refuses the project; on the Compose backend it is informational, because - * the project already runs where those features are supported. + * the project already runs where those features are supported. A basic build + * is not a Compose-backend feature, but native image-project `up` still + * refuses it (HACK-1907), so on the native backend it is a warning too. + * Each warning names the next step: the Compose backend selection, the + * documented `isolatedNetworks` adaptation for an external network, or the + * authored native format for a build. */ export function composeBackendCheckResult(opts: { readonly backend: "native" | "compose"; readonly features: readonly ComposeBackendFeature[]; + readonly basicBuild?: boolean; }): { readonly name: string; readonly status: "ok" | "warn"; readonly message: string; readonly composeBackend: ComposeBackendReport; } { - const composeBackend = { features: [...opts.features] }; + const basicBuild = opts.basicBuild === true; + const composeBackend: ComposeBackendReport = { + features: [...opts.features], + ...(basicBuild ? { nativeUpRefusals: ["basic_build"] as const } : {}), + }; + const network = opts.features.includes("external_network") + ? ` ${ISOLATED_NETWORKS_SELECTION}.` + : ""; + const build = basicBuild + ? ` Its basic Compose build is also refused by native image-project up. ${BASIC_BUILD_NEXT_STEP}.` + : ""; if (opts.features.length === 0) { + if (basicBuild && opts.backend === "native") { + return { + name: COMPOSE_BACKEND_CHECK, + status: "warn", + message: `No Compose-only features found, but native image-project up refuses this project's basic Compose build with its general configuration refusal. ${BASIC_BUILD_NEXT_STEP}, or ${COMPOSE_BACKEND_SELECTION} to run it on the Compose backend.`, + composeBackend, + }; + } + if (basicBuild) { + return { + name: COMPOSE_BACKEND_CHECK, + status: "ok", + message: `No Compose-only features found; the basic Compose build runs on the Compose backend. Native image-project up refuses it: ${BASIC_BUILD_NEXT_STEP}.`, + composeBackend, + }; + } return { name: COMPOSE_BACKEND_CHECK, status: "ok", @@ -45,14 +87,14 @@ export function composeBackendCheckResult(opts: { return { name: COMPOSE_BACKEND_CHECK, status: "warn", - message: `${uses}, so native up refuses it and the project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} to run it there.`, + message: `${uses}, so native up refuses it and the project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} to run it there.${network}${build}`, composeBackend, }; } return { name: COMPOSE_BACKEND_CHECK, status: "ok", - message: `${uses}, so it stays on the Compose backend; native up refuses these features.`, + message: `${uses}, so it stays on the Compose backend; native up refuses these features.${network}${build}`, composeBackend, }; } diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 2afef1ed8..0121f807d 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -124,6 +124,17 @@ function isUnsupportedBuild(service: Record): boolean { ); } +/** + * A basic build is one the import preview's build mapper accepts for a service + * that names no image. It does not keep the project on the Compose backend, + * but native image-project `up` still refuses it with its general + * configuration refusal (`NATIVE_CONFIGURATION_REFUSAL`), because native builds + * need the authored native format. + */ +function isBasicBuild(service: Record): boolean { + return Object.hasOwn(service, "build") && !isUnsupportedBuild(service); +} + /** * Compose takes an absent or `null` `platform` as unset, and native review * does the same, so only a declared value other than the native platform is @@ -291,6 +302,56 @@ export async function readAdaptedIsolatedNetworks( return new Set(names); } +/** + * Whether any service, including one outside the selected profiles, declares a + * basic build (see {@link isBasicBuild}). Shapes this check does not recognize + * return false and stay with native review. + */ +export function hasBasicComposeBuild(compose: unknown): boolean { + if (!isRecord(compose)) { + return false; + } + return topLevelEntries(compose, "services") + .filter(isRecord) + .some(isBasicBuild); +} + +/** What doctor reports for one Compose file: closed names and flags only. */ +export type ComposeBackendReading = { + readonly features: ComposeBackendFeature[]; + /** A basic build that native image-project `up` refuses; see {@link hasBasicComposeBuild}. */ + readonly basicBuild: boolean; +}; + +/** + * Reads one Compose file once and reports both its Compose-backend features + * (after the explicit native adaptation, as {@link readComposeBackendFeatures}) + * and whether it declares a basic build. Read-only and value-free. + */ +export async function readComposeBackendReading( + composeFile: string, + opts?: { readonly adaptationFile?: string } +): Promise { + const compose = await readComposeDocument(composeFile); + return { + features: await classifyAdapted(compose, opts?.adaptationFile), + basicBuild: hasBasicComposeBuild(compose), + }; +} + +async function classifyAdapted( + compose: unknown, + adaptationFile: string | undefined +): Promise { + const features = findComposeBackendFeatures(compose); + if (adaptationFile === undefined || !features.includes("external_network")) { + return features; + } + return findComposeBackendFeatures(compose, { + isolatedNetworks: await readAdaptedIsolatedNetworks(adaptationFile), + }); +} + /** * Reads and classifies one Compose file after the explicit native adaptation. * Read-only: no lock, no write, no runtime request. A Compose file that is @@ -308,16 +369,7 @@ export async function readComposeBackendFeatures( opts?: { readonly adaptationFile?: string } ): Promise { const compose = await readComposeDocument(composeFile); - const features = findComposeBackendFeatures(compose); - if ( - opts?.adaptationFile === undefined || - !features.includes("external_network") - ) { - return features; - } - return findComposeBackendFeatures(compose, { - isolatedNetworks: await readAdaptedIsolatedNetworks(opts.adaptationFile), - }); + return await classifyAdapted(compose, opts?.adaptationFile); } /** Value-free phrase list, such as "a and b" or "a, b and c". */ @@ -335,6 +387,17 @@ export function describeComposeBackendFeatures( export const COMPOSE_BACKEND_SELECTION = "Set HACK_RUNTIME_BACKEND=compose (hack-next and hack-v5 accept it too)"; +/** + * The documented selection that keeps a project with an external network on + * native: native start replaces each listed network with an owned isolated one. + */ +export const ISOLATED_NETWORKS_SELECTION = + "An external network can stay on native instead: list it by name in `isolatedNetworks` of the `HACK_NATIVE_ADAPTATION` selection, and native start replaces it with an owned isolated network that does not join the external one"; + +/** The next step for a basic build, which native image-project `up` refuses. */ +export const BASIC_BUILD_NEXT_STEP = + "Native builds need the authored native format: preview the conversion with `hack config import --dry-run --path `"; + /** * The closed refusal for a project that needs the Compose backend: stable code * `E_NATIVE_PROJECT_UNSUPPORTED`, one line, and a `detail` that lists only the diff --git a/src/lib/native-config-import-plan.ts b/src/lib/native-config-import-plan.ts index 8d6259226..3b2de05b3 100644 --- a/src/lib/native-config-import-plan.ts +++ b/src/lib/native-config-import-plan.ts @@ -1067,34 +1067,69 @@ function restartPolicy(value: unknown): Record | undefined { }; } -function staticEnvironment( - value: unknown -): Record | undefined { - const entries: [string, unknown][] = []; +type EnvironmentReview = { + /** Static literal entries in authored order, keyed by their import pointer segment. */ + readonly accepted: readonly (readonly [ + segment: string | number, + key: string, + value: string, + ])[]; + /** Pointer segments of entries that cannot be represented as static literals. */ + readonly refused: readonly (string | number)[]; +}; + +/** + * Review each Compose environment entry on its own so one interpolated or + * ambiguous entry is refused by its pointer while literal siblings stay mapped. + * Returns undefined only when the container itself is not a map or list. + */ +function reviewEnvironment(value: unknown): EnvironmentReview | undefined { + const entries: [string | number, string, unknown][] = []; if (isRecord(value)) { - entries.push(...Object.entries(value)); + for (const [key, entry] of Object.entries(value)) { + entries.push([key, key, entry]); + } } else if (Array.isArray(value)) { - for (const item of value) { + for (const [index, item] of value.entries()) { if (typeof item !== "string" || !item.includes("=")) { - return undefined; + entries.push([index, "", undefined]); + continue; } const equal = item.indexOf("="); - entries.push([item.slice(0, equal), item.slice(equal + 1)]); + entries.push([index, item.slice(0, equal), item.slice(equal + 1)]); } } else { return undefined; } - const result: Record = Object.create(null); - for (const [key, entry] of entries) { + const accepted: [string | number, string, string][] = []; + const refused: (string | number)[] = []; + const seen = new Set(); + for (const [segment, key, entry] of entries) { if ( !KEY.test(key) || - Object.hasOwn(result, key) || + seen.has(key) || typeof entry !== "string" || entry.includes("$") || entry.includes("\0") ) { - return undefined; + refused.push(segment); + } else { + accepted.push([segment, key, entry]); } + seen.add(key); + } + return { accepted, refused }; +} + +function staticEnvironment( + value: unknown +): Record | undefined { + const review = reviewEnvironment(value); + if (!review || review.refused.length > 0) { + return undefined; + } + const result: Record = Object.create(null); + for (const [, key, entry] of review.accepted) { result[key] = { default: entry }; } return result; @@ -1406,6 +1441,43 @@ type WorkloadMappingContext = Pick & { readonly buildPreview: boolean; }; +/** + * When a Compose environment container is well formed but some entries are + * not static literals, refuse exactly those entries by pointer and keep the + * literal siblings mapped. The import stays incomplete; no candidate is exposed. + * Returns false when no per-entry refusal applies. + */ +function refuseEnvironmentEntries( + opts: Pick & { + readonly raw: unknown; + readonly pointer: string; + } +): boolean { + const review = reviewEnvironment(opts.raw); + if (!review || review.refused.length === 0) { + return false; + } + const target = `${opts.targetPointer}/environment`; + opts.mark("compose", opts.pointer, target, "managed_fallback"); + for (const [segment] of review.accepted) { + opts.mark( + "compose", + importPointer(opts.pointer, segment), + target, + "managed_fallback", + true + ); + } + for (const segment of review.refused) { + opts.refuse( + "compose", + importPointer(opts.pointer, segment), + "invalid_or_ambiguous_value" + ); + } + return true; +} + function mapServiceRule( opts: WorkloadMappingContext & { readonly service: Record; @@ -1428,6 +1500,9 @@ function mapServiceRule( opts.refuse("compose", pointer, "empty_command_unrepresentable"); return; } + if (key === "environment" && refuseEnvironmentEntries({ ...opts, pointer })) { + return; + } let value = SERVICE_RULES[key]?.(raw); if ( key === "pull_policy" && diff --git a/src/lib/native-project-inputs.ts b/src/lib/native-project-inputs.ts index 2275f3bb5..c2670d849 100644 --- a/src/lib/native-project-inputs.ts +++ b/src/lib/native-project-inputs.ts @@ -23,6 +23,14 @@ const NATIVE_LOCAL_FILENAME = "hack.local.json"; const MANAGED_ENV_FILENAME = /^hack\.env\.[a-z0-9]+(?:-[a-z0-9]+)*(?:\.local)?\.yaml$/; +/** + * Fixed refusal when the selected root is not a native project (no + * `.hack/hack.project.json`, or a Compose `.hack/hack.config.json` project). It names + * the next step without disclosing any path or authored value. + */ +export const NATIVE_INPUT_FAMILY_REFUSAL = + "Project-aware native validation requires an exact native input family: a project root with .hack/hack.project.json. For a Compose project (.hack/hack.config.json), preview its native conversion with `hack config import --dry-run --path `; values omitted."; + /** Fixed failures never disclose authored bytes, paths, or filesystem error text. */ export class NativeProjectInputError extends HackCliError { constructor(kind: "unsupported" | "unsafe") { @@ -33,7 +41,7 @@ export class NativeProjectInputError extends HackCliError { : "E_CONFIG_INVALID", message: kind === "unsupported" - ? "Project-aware native validation requires an exact native input family; values omitted." + ? NATIVE_INPUT_FAMILY_REFUSAL : "Native project inputs must be stable, readable regular files of at most 1 MiB in unredirected directories; values omitted.", }); this.name = "NativeProjectInputError"; diff --git a/tests/missing-project-error-code.test.ts b/tests/missing-project-error-code.test.ts index b5284c8f5..70aa43b2b 100644 --- a/tests/missing-project-error-code.test.ts +++ b/tests/missing-project-error-code.test.ts @@ -26,8 +26,9 @@ const STACK_FRAME = /^\s+at /m; const SPAWN_TIMEOUT_MS = 30_000; /** The target-inspect contract's own E_PROJECT_NOT_FOUND message. */ const TARGET_MESSAGE = "No hack project found at the selected location."; +/** The refusal names the next step: a native root, or import preview (HACK-1907). */ const NATIVE_VALIDATION_MESSAGE = - "Project-aware native validation requires an exact native input family; values omitted."; + "Project-aware native validation requires an exact native input family: a project root with .hack/hack.project.json. For a Compose project (.hack/hack.config.json), preview its native conversion with `hack config import --dry-run --path `; values omitted."; /** `DIR` in an argv is replaced with the empty fixture directory. */ type Argv = readonly string[]; diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index 34c16bb70..37adb0da9 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -547,6 +547,33 @@ test( SPAWN_TIMEOUT_MS ); +test( + "native doctor warns for a basic build that native up refuses", + async () => { + const f = await fixture({ services: { web: { build: ".." } } }); + const result = await cli(f, ["doctor", "--json"], {}); + const envelope: unknown = JSON.parse( + result.output.slice(result.output.indexOf("{")) + ); + expect(envelope).toMatchObject({ + data: { + checks: expect.arrayContaining([ + expect.objectContaining({ + id: "compose backend features", + status: "warn", + composeBackend: { + features: [], + nativeUpRefusals: ["basic_build"], + }, + }), + ]), + }, + }); + expect(result.output).toContain("hack config import --dry-run"); + }, + SPAWN_TIMEOUT_MS +); + test( "native up --detach keeps its one-line foreground-only refusal", async () => { diff --git a/tests/native-compose-backend-features.test.ts b/tests/native-compose-backend-features.test.ts index 34c766d22..04c437a57 100644 --- a/tests/native-compose-backend-features.test.ts +++ b/tests/native-compose-backend-features.test.ts @@ -12,6 +12,7 @@ import { HACK_OWNERSHIP_LABEL_NAMESPACES } from "../src/lib/hack-ownership-label import { composeBackendRefusal, findComposeBackendFeatures, + hasBasicComposeBuild, RESERVED_OWNERSHIP_LABEL_PREFIXES, readComposeBackendFeatures, refuseNativeComposeBackendFeatures, @@ -302,6 +303,73 @@ test("doctor reports the same features value-free for each backend", () => { } }); +test("doctor names the isolatedNetworks adaptation for an external network", () => { + for (const backend of ["native", "compose"] as const) { + const result = composeBackendCheckResult({ + backend, + features: ["external_network"], + }); + expect(result.message).toContain("`isolatedNetworks`"); + expect(result.message).toContain("`HACK_NATIVE_ADAPTATION`"); + expect(result.composeBackend).toEqual({ features: ["external_network"] }); + } + // Features with no native replacement do not suggest the adaptation. + expect( + composeBackendCheckResult({ + backend: "native", + features: ["external_volume"], + }).message + ).not.toContain("isolatedNetworks"); +}); + +test("doctor warns for a basic build that native image-project up refuses", () => { + const native = composeBackendCheckResult({ + backend: "native", + features: [], + basicBuild: true, + }); + expect(native).toMatchObject({ + status: "warn", + composeBackend: { features: [], nativeUpRefusals: ["basic_build"] }, + }); + expect(native.message).toContain("general configuration refusal"); + expect(native.message).toContain("hack config import --dry-run"); + expect(native.message).toContain("HACK_RUNTIME_BACKEND=compose"); + const compose = composeBackendCheckResult({ + backend: "compose", + features: [], + basicBuild: true, + }); + expect(compose.status).toBe("ok"); + expect(compose.message).toContain("hack config import --dry-run"); + const both = composeBackendCheckResult({ + backend: "native", + features: ["external_volume"], + basicBuild: true, + }); + expect(both.status).toBe("warn"); + expect(both.message).toContain("basic Compose build"); + expect(both.composeBackend.nativeUpRefusals).toEqual(["basic_build"]); + expect( + composeBackendCheckResult({ backend: "native", features: [] }) + .composeBackend + ).toEqual({ features: [] }); +}); + +test("basic build detection matches the import preview's basic form", () => { + for (const build of ["..", ".", { context: "../app" }, {}]) { + expect(hasBasicComposeBuild(services({ build }))).toBe(true); + } + for (const service of [ + { image: IMAGE }, + { build: { context: "..", args: { A: "1" } } }, + { build: "..", image: IMAGE }, + ]) { + expect(hasBasicComposeBuild(services(service))).toBe(false); + } + expect(hasBasicComposeBuild(null)).toBe(false); +}); + const ADAPTATION_REFUSAL = "Native project adaptation is invalid or conflicts with declared configuration; values omitted."; diff --git a/tests/native-config-import-plan.test.ts b/tests/native-config-import-plan.test.ts index fa62a850f..16e717f6e 100644 --- a/tests/native-config-import-plan.test.ts +++ b/tests/native-config-import-plan.test.ts @@ -681,6 +681,44 @@ test("dollar syntax outside command and entrypoint remains refused", () => { } }); +test.each([ + [ + "mapping", + { PLAIN: "1", FROM_ENV: `\${SOME_VAR:-${CANARY}}` }, + "/services/web/environment/PLAIN", + "/services/web/environment/FROM_ENV", + ], + [ + "list", + ["PLAIN=1", `FROM_ENV=\${SOME_VAR:-${CANARY}}`], + "/services/web/environment/0", + "/services/web/environment/1", + ], +])("environment %s refuses only the interpolated entry by its pointer", (_form, environment, literal, interpolated) => { + const result = map( + { name: "fixture" }, + { services: { web: { image: "fixture", environment } } } + ); + code(result, "invalid_or_ambiguous_value"); + const refused = result.report.fields.filter( + (field) => field.status === "refused" + ); + expect(refused.map((field) => [field.pointer, field.code])).toEqual([ + [interpolated, "invalid_or_ambiguous_value"], + ]); + const byPointer = (pointer: string) => + result.report.fields.find((field) => field.pointer === pointer); + expect(byPointer(literal)).toMatchObject({ + status: "normalized", + code: "managed_fallback", + target: "/services/web/environment", + }); + expect(byPointer("/services/web/environment")).toMatchObject({ + status: "normalized", + code: "managed_fallback", + }); +}); + test("raw provenance retains original spelling, escaped pointers and line positions", () => { const result = mapLegacyNativeImport({ configText: '{\n "name": "fixture",\n "defaultEnvConfig": "QA"\n}', From bd034afac7f0f22eab8de6d92ba3db28b22589ed Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:18:02 -0500 Subject: [PATCH 003/138] fix(env): require consent before env migration changes tracked files `hack doctor --migrate-env-config` deleted the committed hack.env.json and edited the tracked Compose file in non-interactive runs, because every step accepted its default. A run that can't prompt now refuses with E_INTERACTIVE_REQUIRED before any fix step writes, naming each git-tracked file it would delete or edit, unless --accept-tracked-env-changes is passed. Interactive runs list those files and ask per step; tracked legacy files get their own removal prompt. Native doctor now runs the same project-file migration instead of pointing to v4. The implicit migration (env exec, sessions, lifecycle env) reports each moved key value-free and removes a fully delivered, untracked legacy dotenv file that Compose no longer names, so no plaintext copy stays behind. Refusals no longer claim .hack/.env files exist. Refs HACK-1904 Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 34 +- docs/reference/cli.md | 1 + src/commands/doctor.ts | 284 +++++++++++++++-- src/commands/env.ts | 12 +- src/commands/project.ts | 12 +- src/commands/session.ts | 12 +- src/lib/project-env-config.ts | 235 +++++++++++++- tests/doctor-command.test.ts | 21 ++ tests/env-migration-tracked-consent.test.ts | 327 ++++++++++++++++++++ tests/env-set-local-contract.test.ts | 7 +- 10 files changed, 889 insertions(+), 56 deletions(-) create mode 100644 tests/env-migration-tracked-consent.test.ts diff --git a/docs/env.md b/docs/env.md index a61477d07..394e39152 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1091,6 +1091,37 @@ That migrates the repo into: Tracked layers receive secret-backed ciphertext, not legacy plaintext. +The migration runs the same way on the Compose and native backends: it changes project +files only, so native `hack doctor --migrate-env-config` needs no Docker and no native +runtime. + +`hack.env.json` and the Compose file are shared, git-tracked configuration, so the +migration never deletes or edits a git-tracked file without your consent. Interactively, +doctor lists the git-tracked files that may change before it migrates, and asks before it +removes `env_file` entries from a tracked Compose file and, separately, before it removes +git-tracked legacy files such as `hack.env.json`. A file whose git status can't be read +inside a git checkout counts as tracked. A run that can't prompt (no TTY, +`HACK_NO_INTERACTIVE`, or `--no-interactive`) refuses with `E_INTERACTIVE_REQUIRED` before +any doctor step writes, naming each tracked file it would delete or edit, unless you pass +`--accept-tracked-env-changes`: + +```bash +hack doctor --migrate-env-config --no-interactive --accept-tracked-env-changes +``` + +The list is conservative; with a contract it can include `hack.config.json`, which the +migration rewrites when it still holds legacy keys such as `defaultEnvConfig`, and a legacy +dotenv file the migration ends up keeping. Commit the changes so teammates get them. + +Commands that need env values, such as `hack env exec`, also migrate a +project that has `hack.env.json` and no env layers, without asking. That migration reports +each moved key, never its value, and removes a legacy `.hack/.env` or `.hack/.env.` +file only when every entry now lives in an env layer, Compose no longer names it as +`env_file`, and git doesn't track it, so no second plaintext copy stays behind. It keeps +`hack.env.json`, never edits the Compose file and never deletes a git-tracked file; it names +a kept file that Compose still lists. Run `hack doctor --migrate-env-config` for those +changes. Like doctor, it rewrites legacy keys in `hack.config.json`. + A repo that has no `.hack/hack.env.json` is migrated as well. Its values live only in `.hack/.env` and `.hack/.env.`, which are gitignored and may hold passwords or API keys, so the migration keeps them out of tracked files: each `.hack/.env` entry @@ -1300,7 +1331,8 @@ any secret backend it configures, is left unchanged. The migration changes proje only, so it runs even when Docker is not reachable; the other `--migrate-env-config` repairs still need Docker. As with any migration, `hack doctor` then offers to remove the legacy `.hack/.env*` files that Compose no longer references (a non-interactive run -accepts). Files Compose still names as `env_file` are offered only when the Compose repair +accepts for untracked files, and for git-tracked ones only with `--accept-tracked-env-changes`). +Files Compose still names as `env_file` are offered only when the Compose repair removed those references. Without `hack.env.json`, a legacy file that git tracks, or one whose status git cannot report in a git checkout, is never offered, because the migrated values now live only in this machine's local layers. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 050e7c675..9bb975a5c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1423,6 +1423,7 @@ hack doctor [options] | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--fix` | Attempt safe auto-remediations (network + CoreDNS + CA) | | `--migrate-env-config` | Migrate legacy hack.env.json/.env state to the new env config files | +| `--accept-tracked-env-changes` | With --migrate-env-config and no prompt, allow deleting or editing git-tracked files (hack.env.json, the Compose file) | | `--domain-migration preview|apply|rollback` | Preview, apply, or roll back only this project's hack.local migration | | `--json` | Output JSON (machine-readable) | | `--browser-url https://app.hack` | HTTPS origin manually tested in the browser (no path or credentials) | diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..cf15d0872 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -124,7 +124,11 @@ import { readHackEnvContract, } from "../lib/hack-env.ts"; import { normalizeHomeDir, resolveUserHomeDir } from "../lib/home-dir.ts"; -import { canPrompt, confirmSafe } from "../lib/interactivity.ts"; +import { + canPrompt, + confirmSafe, + requireInteractive, +} from "../lib/interactivity.ts"; import { findHackLocalCaCertPath } from "../lib/local-ca.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { @@ -173,15 +177,18 @@ import { HackDirGitignoreRefusalError, inspectLegacyComposeEnvFileReferences, inspectProjectEnvMaterialization, + isGitTrackedOrUnknownProjectPath, type LegacyComposeEnvFileReference, LegacyDotenvMigrationRefusalError, type LegacyProjectEnvMigration, migrateLegacyProjectEnv, ProjectEnvLayerPathConflictError, + planTrackedEnvMigrationChanges, projectEnvConfigExistsForMutation, removeLegacyProjectEnvArtifacts, repairLegacyComposeEnvFileReferences, resolveProjectEnvConfig, + type TrackedEnvMigrationChange, } from "../lib/project-env-config.ts"; import { inspectProjectLifecycleHygiene, @@ -279,6 +286,14 @@ const optMigrateEnvConfig = defineOption({ "Migrate legacy hack.env.json/.env state to the new env config files", } as const); +const optAcceptTrackedEnvChanges = defineOption({ + name: "acceptTrackedEnvChanges", + type: "boolean", + long: "--accept-tracked-env-changes", + description: + "With --migrate-env-config and no prompt, allow deleting or editing git-tracked files (hack.env.json, the Compose file)", +} as const); + const optBrowserUrl = defineOption({ name: "browserUrl", type: "string", @@ -307,6 +322,7 @@ const doctorOptions = [ optPath, optFix, optMigrateEnvConfig, + optAcceptTrackedEnvChanges, optDomainMigration, optJson, optBrowserUrl, @@ -567,6 +583,7 @@ const handleDoctor: CommandHandlerFor = async ({ json, fix: args.options.fix === true, migrateEnvConfig: args.options.migrateEnvConfig === true, + acceptTrackedEnvChanges: args.options.acceptTrackedEnvChanges === true, }); const native = resolveNativeRuntimeSelection(); if (native) { @@ -577,6 +594,7 @@ const handleDoctor: CommandHandlerFor = async ({ json, fix: args.options.fix === true, migrateEnvConfig: args.options.migrateEnvConfig === true, + acceptTrackedEnvChanges: args.options.acceptTrackedEnvChanges === true, }); } const results: TimedCheckResult[] = []; @@ -897,6 +915,7 @@ const handleDoctor: CommandHandlerFor = async ({ await runDoctorFix({ startDir, migrateEnvConfig: args.options.migrateEnvConfig === true, + acceptTrackedEnvChanges: args.options.acceptTrackedEnvChanges === true, }); note("Re-run: hack doctor", "doctor"); } @@ -967,12 +986,18 @@ async function handleNativeDoctor(opts: { readonly browser: ReturnType; readonly json: boolean; readonly fix: boolean; + /** + * Runs the same project-file env migration as Compose doctor after the + * checks. It changes project files only, so it needs no native runtime. + */ readonly migrateEnvConfig: boolean; + readonly acceptTrackedEnvChanges: boolean; }): Promise { if (opts.migrateEnvConfig) { - throw new CliUsageError( - "Native doctor does not run the Docker-era env migration; use an installed v4 invocation for that migration." - ); + await assertTrackedEnvMigrationConsent({ + startDir: opts.startDir, + accept: opts.acceptTrackedEnvChanges, + }); } const results: TimedCheckResult[] = []; const progress = createDoctorProgress({ json: opts.json }); @@ -1135,9 +1160,30 @@ async function handleNativeDoctor(opts: { "Native HTTPS" ); } + await maybeRunNativeDoctorEnvMigration(opts); return hasError ? 1 : 0; } +/** + * Native doctor's `--migrate-env-config`: the same project-file migration as + * Compose doctor, including its consent for git-tracked files. It needs no + * Docker and no native runtime. + */ +async function maybeRunNativeDoctorEnvMigration(opts: { + readonly startDir: string; + readonly migrateEnvConfig: boolean; + readonly acceptTrackedEnvChanges: boolean; +}): Promise { + if (!opts.migrateEnvConfig) { + return; + } + await maybeMigrateProjectEnvConfig({ + startDir: opts.startDir, + acceptTrackedChanges: opts.acceptTrackedEnvChanges, + }); + note("Re-run: hack doctor", "doctor"); +} + /** * The `native ca repair` check body. The spinner is released at the trust * confirmation and stays off through the administrator prompt and the sudo -n @@ -1344,12 +1390,18 @@ export function assertDoctorOptionCompatibility(opts: { readonly json: boolean; readonly fix: boolean; readonly migrateEnvConfig: boolean; + readonly acceptTrackedEnvChanges?: boolean; }): void { if (opts.json && (opts.fix || opts.migrateEnvConfig)) { throw new CliUsageError( "--json cannot be combined with --fix or --migrate-env-config because repair commands mutate state. Run the repair and JSON audit separately." ); } + if (opts.acceptTrackedEnvChanges === true && !opts.migrateEnvConfig) { + throw new CliUsageError( + "--accept-tracked-env-changes applies only to --migrate-env-config." + ); + } } type DoctorProgress = PausableProgress; @@ -3581,7 +3633,14 @@ async function readLegacyEnvDevHost(envFile: string): Promise { async function runDoctorFix(opts: { readonly startDir: string; readonly migrateEnvConfig: boolean; + readonly acceptTrackedEnvChanges: boolean; }): Promise { + if (opts.migrateEnvConfig) { + await assertTrackedEnvMigrationConsent({ + startDir: opts.startDir, + accept: opts.acceptTrackedEnvChanges, + }); + } const remediationPlan = await buildDoctorRemediationPlanLines({ startDir: opts.startDir, migrateEnvConfig: opts.migrateEnvConfig, @@ -3610,7 +3669,10 @@ async function runDoctorFix(opts: { // Env migration changes project files only, so it runs before the Docker // gate: an unreachable Docker never skips a requested migration. if (opts.migrateEnvConfig) { - await maybeMigrateProjectEnvConfig({ startDir: opts.startDir }); + await maybeMigrateProjectEnvConfig({ + startDir: opts.startDir, + acceptTrackedChanges: opts.acceptTrackedEnvChanges, + }); await maybeEnsureHackDirGitignore({ startDir: opts.startDir }); } @@ -3714,6 +3776,87 @@ function renderSkippedDoctorFixSteps(): void { ); } +/** `delete .hack/hack.env.json; edit .hack/docker-compose.yml`, relative to the project root. */ +function describeTrackedEnvMigrationChanges(opts: { + readonly projectRoot: string; + readonly changes: readonly TrackedEnvMigrationChange[]; +}): string { + return opts.changes + .map( + (change) => `${change.change} ${relative(opts.projectRoot, change.path)}` + ) + .join("; "); +} + +/** + * Refuses an env migration that would delete or edit git-tracked files when + * it cannot ask and `--accept-tracked-env-changes` was not passed. It runs + * before any doctor fix step writes, so the refusal changes nothing. + * Teammates share those files (the contract, the Compose file), so a run with + * no prompt never changes one on its own; an interactive run asks per step. + */ +export async function assertTrackedEnvMigrationConsent(opts: { + readonly startDir: string; + readonly accept: boolean; +}): Promise { + if (opts.accept || canPrompt()) { + return; + } + const project = await findDoctorLegacyProjectContext(opts.startDir); + if (!project) { + return; + } + const changes = await planTrackedEnvMigrationChanges({ + projectRoot: project.projectRoot, + projectDir: project.projectDir, + composeFile: project.composeFile, + }); + if (changes.length === 0) { + return; + } + requireInteractive({ + what: `confirm env migration changes to git-tracked files (${describeTrackedEnvMigrationChanges({ projectRoot: project.projectRoot, changes })})`, + hint: "Nothing was changed. Teammates share these files, so they change only with consent. Re-run interactively to confirm each change, or pass --accept-tracked-env-changes to apply them without a prompt.", + }); +} + +/** + * Confirms a step that deletes or edits a git-tracked file. Without a prompt it + * proceeds only with `--accept-tracked-env-changes`; otherwise it declines. + */ +async function confirmTrackedEnvChange(opts: { + readonly message: string; + readonly accept: boolean; +}): Promise { + return await confirmSafe({ + message: opts.message, + initialValue: true, + nonInteractive: opts.accept ? "accept-default" : "decline", + }); +} + +/** Confirms the Compose env_file repair, as a tracked change when git tracks the file. */ +async function confirmComposeEnvRepair(opts: { + readonly projectRoot: string; + readonly composeFile: string; + readonly acceptTrackedChanges: boolean; +}): Promise { + const message = + "Remove legacy .hack/.env* env_file references from compose now?"; + if ( + await isGitTrackedOrUnknownProjectPath({ + projectRoot: opts.projectRoot, + path: opts.composeFile, + }) + ) { + return await confirmTrackedEnvChange({ + message: `${message} (git tracks ${relative(opts.projectRoot, opts.composeFile)}; commit the change for teammates)`, + accept: opts.acceptTrackedChanges, + }); + } + return await doctorConfirm({ message, initialValue: true }); +} + function buildEnvMigrationNote(opts: { readonly migrated: LegacyProjectEnvMigration; readonly projectRoot: string; @@ -3740,9 +3883,11 @@ function buildEnvMigrationNote(opts: { /** Describes the env migration and asks whether to run it. */ async function confirmProjectEnvMigration(opts: { + readonly projectRoot: string; readonly projectDir: string; readonly contractPath: string; readonly hasContract: boolean; + readonly trackedChanges: readonly TrackedEnvMigrationChange[]; }): Promise { const { projectDir, contractPath, hasContract } = opts; note( @@ -3755,6 +3900,11 @@ async function confirmProjectEnvMigration(opts: { ? `- target: ${resolve(projectDir, "hack.env.default.yaml")}` : `- target: values go to the gitignored ${resolve(projectDir, "hack.env.local.yaml")} (and hack.env..local.yaml); ${resolve(projectDir, "hack.env.default.yaml")} is written with no values`, "- runtime: direct env injection by default; .hack/.env becomes compatibility-only", + ...(opts.trackedChanges.length > 0 + ? [ + `- git-tracked files that may change (deletes and Compose edits ask first): ${describeTrackedEnvMigrationChanges({ projectRoot: opts.projectRoot, changes: opts.trackedChanges })}`, + ] + : []), ].join("\n"), "env migration" ); @@ -3767,6 +3917,7 @@ async function confirmProjectEnvMigration(opts: { async function maybeMigrateProjectEnvConfig(opts: { readonly startDir: string; + readonly acceptTrackedChanges: boolean; }): Promise { const project = await findDoctorLegacyProjectContext(opts.startDir); if (!project) { @@ -3793,8 +3944,10 @@ async function maybeMigrateProjectEnvConfig(opts: { ) { note("Project already uses the new env config files.", "doctor"); await maybeRepairModernProjectComposeEnvReferences({ + projectRoot: project.projectRoot, composeFile: project.composeFile, projectDir: project.projectDir, + acceptTrackedChanges: opts.acceptTrackedChanges, }); return; } @@ -3824,9 +3977,15 @@ async function maybeMigrateProjectEnvConfig(opts: { if ( contract.parseError === undefined && !(await confirmProjectEnvMigration({ + projectRoot: project.projectRoot, projectDir: project.projectDir, contractPath, hasContract, + trackedChanges: await planTrackedEnvMigrationChanges({ + projectRoot: project.projectRoot, + projectDir: project.projectDir, + composeFile: project.composeFile, + }), })) ) { return; @@ -3868,10 +4027,10 @@ async function maybeMigrateProjectEnvConfig(opts: { }), "compose env repair" ); - const shouldRepairCompose = await doctorConfirm({ - message: - "Remove legacy .hack/.env* env_file references from compose now?", - initialValue: true, + const shouldRepairCompose = await confirmComposeEnvRepair({ + projectRoot: project.projectRoot, + composeFile: project.composeFile, + acceptTrackedChanges: opts.acceptTrackedChanges, }); if (shouldRepairCompose) { const repaired = await repairLegacyComposeEnvFileReferences({ @@ -3908,29 +4067,91 @@ async function maybeMigrateProjectEnvConfig(opts: { migrated, composeRepaired, }); - if (cleanupCandidates.length === 0) { - return; + await offerDoctorEnvCleanup({ + projectRoot: project.projectRoot, + paths: cleanupCandidates, + acceptTrackedChanges: opts.acceptTrackedChanges, + }); +} + +/** + * Offers the legacy env files a migration left for removal. Untracked files + * keep doctor's usual prompt; git-tracked ones get their own prompt and, + * without one, are removed only with `--accept-tracked-env-changes`. + */ +async function offerDoctorEnvCleanup(opts: { + readonly projectRoot: string; + readonly paths: readonly string[]; + readonly acceptTrackedChanges: boolean; +}): Promise { + const untracked: string[] = []; + const tracked: string[] = []; + for (const path of opts.paths) { + ((await isGitTrackedOrUnknownProjectPath({ + projectRoot: opts.projectRoot, + path, + })) + ? tracked + : untracked + ).push(path); } - note( - [ - "Legacy env artifacts still exist and can now be removed:", - ...cleanupCandidates.map((path) => `- ${path}`), - ].join("\n"), - "env cleanup" - ); - const shouldCleanup = await doctorConfirm({ - message: - "Remove legacy env files and obsolete project env config entries now?", - initialValue: true, - }); - if (!shouldCleanup) { - return; + if (untracked.length > 0) { + note( + [ + "Legacy env artifacts still exist and can now be removed:", + ...untracked.map((path) => `- ${path}`), + ].join("\n"), + "env cleanup" + ); + if ( + await doctorConfirm({ + message: + "Remove legacy env files and obsolete project env config entries now?", + initialValue: true, + }) + ) { + await removeDoctorEnvCleanup({ + projectRoot: opts.projectRoot, + paths: untracked, + }); + } + } + + if (tracked.length > 0) { + note( + [ + "These legacy env files are tracked by git and can now be removed. Teammates share them, so commit the removal together with the new env layers:", + ...tracked.map((path) => `- ${path}`), + ].join("\n"), + "env cleanup" + ); + if ( + await confirmTrackedEnvChange({ + message: "Remove these git-tracked legacy env files now?", + accept: opts.acceptTrackedChanges, + }) + ) { + await removeDoctorEnvCleanup({ + projectRoot: opts.projectRoot, + paths: tracked, + }); + } else { + note( + "Kept the git-tracked legacy env files. Remove them with `git rm` when teammates have migrated.", + "env cleanup" + ); + } } +} +async function removeDoctorEnvCleanup(opts: { + readonly projectRoot: string; + readonly paths: readonly string[]; +}): Promise { const removed = await removeLegacyProjectEnvArtifacts({ - paths: cleanupCandidates, - projectRoot: project.projectRoot, + paths: opts.paths, + projectRoot: opts.projectRoot, }); if (removed.length > 0) { note(`Removed ${removed.join(", ")}`, "env cleanup"); @@ -3970,8 +4191,10 @@ function buildComposeEnvRepairNote(opts: { } async function maybeRepairModernProjectComposeEnvReferences(opts: { + readonly projectRoot: string; readonly composeFile: string; readonly projectDir: string; + readonly acceptTrackedChanges: boolean; }): Promise { const references = await inspectLegacyComposeEnvFileReferences({ composeFile: opts.composeFile, @@ -3987,9 +4210,10 @@ async function maybeRepairModernProjectComposeEnvReferences(opts: { }), "compose env repair" ); - const shouldRepairCompose = await doctorConfirm({ - message: "Remove legacy .hack/.env* env_file references from compose now?", - initialValue: true, + const shouldRepairCompose = await confirmComposeEnvRepair({ + projectRoot: opts.projectRoot, + composeFile: opts.composeFile, + acceptTrackedChanges: opts.acceptTrackedChanges, }); if (!shouldRepairCompose) { note( diff --git a/src/commands/env.ts b/src/commands/env.ts index 6865d482d..6d250ac1f 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -80,6 +80,7 @@ import { discoverComposeServiceNames, ENV_KEY_INVALID_MESSAGE, findUnmigratedLegacyDotenv, + finishImplicitLegacyEnvMigration, hasUnmigratedLegacyDotenv, hydrateProjectEnvLocalLayer, materializeProjectEnv, @@ -968,10 +969,13 @@ async function maybeMigrateLegacyProjectEnv(input: { serviceNames, materialize: false, }); - if (migrated.legacyDetected && migrated.wroteFiles.length > 0) { - logger.info({ - message: `Migrated legacy env config to ${migrated.wroteFiles.join(", ")}`, - }); + const report = await finishImplicitLegacyEnvMigration({ + projectRoot: input.project.projectRoot, + projectDir: input.project.projectDir, + migrated, + }); + for (const line of report.lines) { + logger.info({ message: line }); } return migrated.legacyDetected; } diff --git a/src/commands/project.ts b/src/commands/project.ts index ff71f6a36..4b2e8eced 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -268,6 +268,7 @@ import { discoverComposeServiceNames, ensureHackDirGitignore, findUnmigratedLegacyDotenv, + finishImplicitLegacyEnvMigration, HACK_DIR_GITIGNORE_READ_ONLY_WARNING, hasUnmigratedLegacyDotenv, migrateLegacyProjectEnv, @@ -1319,10 +1320,13 @@ async function maybePromptLegacyProjectEnvMigration(opts: { serviceNames: opts.serviceNames, materialize: false, }); - if (migrated.wroteFiles.length > 0) { - logger.info({ - message: `Migrated env config: ${migrated.wroteFiles.join(", ")}`, - }); + const report = await finishImplicitLegacyEnvMigration({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + migrated, + }); + for (const line of report.lines) { + logger.info({ message: line }); } return migrated.legacyDetected; } diff --git a/src/commands/session.ts b/src/commands/session.ts index 5f61c9c60..429d08bc9 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -30,6 +30,7 @@ import { import { assertValidProjectEnvScopeName, discoverComposeServiceNames, + finishImplicitLegacyEnvMigration, migrateLegacyProjectEnv, projectEnvConfigExists, resolveProjectEnvConfig, @@ -1070,10 +1071,13 @@ async function maybeMigrateLegacySessionEnv(opts: { serviceNames, materialize: false, }); - if (migrated.wroteFiles.length > 0) { - logger.info({ - message: `Migrated legacy env config to ${migrated.wroteFiles.join(", ")}`, - }); + const report = await finishImplicitLegacyEnvMigration({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + migrated, + }); + for (const line of report.lines) { + logger.info({ message: line }); } } diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 8b7b58af5..1e8b2fd89 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -14,7 +14,14 @@ import { stat, unlink, } from "node:fs/promises"; -import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { + basename, + dirname, + isAbsolute, + relative, + resolve, + sep, +} from "node:path"; import { YAML } from "bun"; import { HACK_PROJECT_DIR_PRIMARY, @@ -5481,7 +5488,7 @@ export class LegacyDotenvMigrationRefusalError extends HackCliError { }) { super({ code: opts.code, - message: `Refusing to migrate the legacy .hack/.env files: ${opts.reason} ${opts.closing ?? "The legacy files are kept. Earlier migration writes may remain; check the layers and ignore rules before retrying."}`, + message: `Refusing to migrate the legacy env config: ${opts.reason} ${opts.closing ?? "The legacy files are kept. Earlier migration writes may remain; check the layers and ignore rules before retrying."}`, }); this.name = "LegacyDotenvMigrationRefusalError"; } @@ -5511,21 +5518,227 @@ async function filterUntrackedLegacyCleanupCandidates(opts: { }): Promise { const kept: string[] = []; for (const path of opts.paths) { - const tracked = await isGitTrackedProjectEnvPath({ - projectRoot: opts.projectRoot, - path, - }); - const untracked = - tracked === null - ? !(await isInsideGitCheckoutMarker({ startDir: opts.projectRoot })) - : !tracked; - if (untracked) { + if ( + !(await isGitTrackedOrUnknownProjectPath({ + projectRoot: opts.projectRoot, + path, + })) + ) { kept.push(path); } } return kept; } +/** + * Whether git tracks `path`, failing closed: a path whose status git cannot + * report counts as tracked when the project or the path is inside a git + * checkout, because it may be. A path outside every checkout, such as an + * encrypted backend file in the user's home, is not tracked. + */ +export async function isGitTrackedOrUnknownProjectPath(opts: { + readonly projectRoot: string; + readonly path: string; +}): Promise { + const tracked = await isGitTrackedProjectEnvPath(opts); + if (tracked !== null) { + return tracked; + } + const inside = relative(opts.projectRoot, opts.path); + const withinProject = !( + inside === ".." || + inside.startsWith(`..${sep}`) || + isAbsolute(inside) + ); + return await isInsideGitCheckoutMarker({ + startDir: withinProject ? opts.projectRoot : dirname(opts.path), + }); +} + +/** A git-tracked file the explicit env migration may change. */ +export type TrackedEnvMigrationChange = { + readonly path: string; + readonly change: "delete" | "edit"; +}; + +/** + * The git-tracked files `hack doctor --migrate-env-config` may delete or edit, + * found before it writes anything so a run that can't ask may refuse instead. + * + * With a contract and no env layers, the migration offers the contract and the + * legacy dotenv files it delivered for deletion, rewrites legacy keys in + * `hack.config.json`, and offers an encrypted backend file it no longer uses + * for deletion. In every case it offers to drop legacy `.hack/.env*` + * `env_file` entries from the Compose file. The list is conservative: a file + * the migration would keep, for example a dotenv file with an undelivered + * value, can still be listed. Without a contract, tracked legacy dotenv files + * are never offered for deletion, so none are listed. A state the migration + * itself refuses (an unusable layer entry or contract) lists nothing more; + * the migration reports it. + * + * Teammates share these files, so a change to one reaches them on the next + * commit. Paths are absolute and sorted; no file content is returned. + */ +export async function planTrackedEnvMigrationChanges(opts: { + readonly projectRoot: string; + readonly projectDir: string; + readonly composeFile: string; +}): Promise { + const candidates = new Map(); + const references = await inspectLegacyComposeEnvFileReferences({ + composeFile: opts.composeFile, + projectDir: opts.projectDir, + }).catch(() => []); + if (references.length > 0) { + candidates.set(opts.composeFile, "edit"); + } + const contract = await readHackEnvContract({ projectDir: opts.projectDir }); + const layersExist = await projectEnvConfigExistsForMutation({ + projectDir: opts.projectDir, + }).catch(() => true); + if (contract.exists && contract.parseError === undefined && !layersExist) { + candidates.set(contract.path, "delete"); + const overlayNames = await discoverLegacyOverlayNames({ + projectDir: opts.projectDir, + includeSymlinks: false, + }).catch(() => []); + for (const name of [ + PROJECT_ENV_FILENAME, + ...overlayNames.map((overlay) => `${PROJECT_ENV_FILENAME}.${overlay}`), + ]) { + candidates.set(resolve(opts.projectDir, name), "delete"); + } + const config = await planLegacyProjectConfigChanges({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + }); + if (config.changed) { + candidates.set(config.path, "edit"); + } + for (const path of config.cleanupCandidates) { + candidates.set(path, "delete"); + } + } + const changes: TrackedEnvMigrationChange[] = []; + for (const [path, change] of candidates) { + if ( + (await pathExists(path)) && + (await isGitTrackedOrUnknownProjectPath({ + projectRoot: opts.projectRoot, + path, + })) + ) { + changes.push({ path, change }); + } + } + return changes.sort((left, right) => left.path.localeCompare(right.path)); +} + +/** + * What {@link migrateLegacyProjectConfig} would change in `hack.config.json`, + * without writing it. + */ +async function planLegacyProjectConfigChanges(opts: { + readonly projectRoot: string; + readonly projectDir: string; +}): Promise<{ + readonly path: string; + readonly changed: boolean; + readonly cleanupCandidates: readonly string[]; +}> { + const path = resolve(opts.projectDir, PROJECT_CONFIG_FILENAME); + const text = await readTextFile(path).catch(() => null); + let parsed: unknown = null; + try { + parsed = text === null ? null : JSON.parse(text); + } catch { + parsed = null; + } + if (!isRecord(parsed)) { + return { path, changed: false, cleanupCandidates: [] }; + } + const planned = normalizeLegacyProjectTopLevelConfig({ + parsed, + projectRoot: opts.projectRoot, + }); + return { + path, + changed: planned.changed, + cleanupCandidates: planned.cleanupCandidates, + }; +} + +/** Outcome of {@link finishImplicitLegacyEnvMigration}. */ +export type ImplicitLegacyEnvMigrationReport = { + /** Legacy dotenv files removed because env layers now hold every entry. */ + readonly removed: readonly string[]; + /** Value-free report lines: written layers, moved keys, kept and removed files. */ + readonly lines: readonly string[]; +}; + +/** + * Finishes a contract migration that another command ran implicitly (such as + * `hack env exec`), the way doctor's cleanup does for untracked files. It + * removes a legacy `.hack/.env` or `.hack/.env.` file only when every + * entry in it now lives in an env layer, Compose no longer names it as + * `env_file`, and git does not track it (or, inside a checkout, cannot say), + * so no second plaintext copy stays behind. This step never removes or edits + * `hack.env.json`, the Compose file, an encrypted backend file or any other + * tracked file; `hack doctor --migrate-env-config` offers those. (The + * migration itself still rewrites legacy keys in `hack.config.json`.) + * + * The report names written paths and moved keys, never values. + */ +export async function finishImplicitLegacyEnvMigration(opts: { + readonly projectRoot: string; + readonly projectDir: string; + readonly migrated: LegacyProjectEnvMigration; +}): Promise { + const { migrated } = opts; + if (!migrated.legacyDetected || migrated.wroteFiles.length === 0) { + return { removed: [], lines: [] }; + } + const shown = (path: string) => relative(opts.projectRoot, path); + const lines = [ + `Migrated legacy env config to ${migrated.wroteFiles.map(shown).join(", ")}`, + ...migrated.movedPlainValues.map( + (moved) => `Moved ${moved.key} to ${shown(moved.path)} (values omitted).` + ), + ]; + const dotenvFiles = new Set([ + resolve(opts.projectDir, PROJECT_ENV_FILENAME), + ...migrated.migratedOverlays.map((overlay) => + resolve(opts.projectDir, `${PROJECT_ENV_FILENAME}.${overlay}`) + ), + ]); + const removable = await filterUntrackedLegacyCleanupCandidates({ + projectRoot: opts.projectRoot, + paths: migrated.cleanupCandidates.filter((path) => dotenvFiles.has(path)), + }); + const removed = await removeLegacyProjectEnvArtifacts({ + projectRoot: opts.projectRoot, + paths: removable, + }); + if (removed.length > 0) { + lines.push( + `Removed ${removed.map(shown).join(", ")}: env layers now hold every value ${removed.length === 1 ? "it" : "they"} set.` + ); + } + for (const retained of migrated.retainedEnvFiles) { + lines.push( + `Kept ${shown(retained.path)}: keys not preserved in env layers: ${retained.keys.join(", ")} (values omitted).` + ); + } + for (const path of migrated.blockedCleanupCandidates) { + if (dotenvFiles.has(path)) { + lines.push( + `Kept ${shown(path)}: Compose still names it as env_file. Run \`hack doctor --migrate-env-config\` to remove that reference.` + ); + } + } + return { removed, lines }; +} + /** * Legacy overlay names whose env layers would be the default layer or a * machine-local layer instead of an overlay (`.env.default`, `.env.local`, diff --git a/tests/doctor-command.test.ts b/tests/doctor-command.test.ts index cd4d3605d..0376b7266 100644 --- a/tests/doctor-command.test.ts +++ b/tests/doctor-command.test.ts @@ -170,6 +170,27 @@ test("doctor rejects mutating repair flags combined with json", () => { ).not.toThrow(); }); +test("doctor accepts tracked env changes only with the env migration (HACK-1904)", () => { + expect(() => + assertDoctorOptionCompatibility({ + json: false, + fix: true, + migrateEnvConfig: false, + acceptTrackedEnvChanges: true, + }) + ).toThrow( + "--accept-tracked-env-changes applies only to --migrate-env-config" + ); + expect(() => + assertDoctorOptionCompatibility({ + json: false, + fix: false, + migrateEnvConfig: true, + acceptTrackedEnvChanges: true, + }) + ).not.toThrow(); +}); + for (const form of ["dangling link", "malformed"] as const) { test(`doctor remediation plan says a ${form} contract refuses, and doesn't offer the migration`, async () => { const root = await createDoctorTestProject({ legacy: true }); diff --git a/tests/env-migration-tracked-consent.test.ts b/tests/env-migration-tracked-consent.test.ts new file mode 100644 index 000000000..4e31598f0 --- /dev/null +++ b/tests/env-migration-tracked-consent.test.ts @@ -0,0 +1,327 @@ +import { afterEach, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { runFixtureGit } from "./helpers/fixture-git.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * HACK-1904: the env migration never deletes or edits a git-tracked file + * without consent, native doctor runs the same migration as Compose doctor, + * and the implicit route reports moved keys and drops its plaintext copy. + * Every case uses a real temporary git repository and synthetic values. + */ + +const ENTRYPOINT = resolve(import.meta.dir, "..", "index.ts"); +const KEY = "SYNTH_KEY"; +const VALUE = ["synthetic", "value", "1904"].join("-"); +const CONTRACT = `${JSON.stringify({ + version: 1, + vars: [{ key: KEY, source: "plain_env", required: false }], +})}\n`; +const COMPOSE_WITH_ENV_FILE = + "services:\n api:\n image: alpine:3.20\n env_file: .env\n"; +const COMPOSE_PLAIN = "services:\n api:\n image: alpine:3.20\n"; +const CONFIG = `${JSON.stringify({ name: "hack-1904", dev_host: "hack-1904.hack" })}\n`; +const CLI_TEST_TIMEOUT_MS = 60_000; + +const roots: string[] = []; +const running = new Set(); + +afterEach(async () => { + const children = [...running]; + running.clear(); + for (const child of children) { + child.kill("SIGKILL"); + } + await Promise.all(children.map((child) => child.exited)); + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}, 30_000); + +type Project = { + readonly root: string; + readonly projectRoot: string; + readonly projectDir: string; +}; + +async function git(project: Project, args: readonly string[]) { + const result = await runFixtureGit({ cwd: project.projectRoot, args }); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout; +} + +/** A committed contract, config and Compose file, plus an untracked `.hack/.env`. */ +async function createProject(opts: { + readonly compose: string; +}): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), "hack-1904-"))); + roots.push(root); + const projectRoot = resolve(root, "repo"); + const projectDir = resolve(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true }); + await writeFile(resolve(projectDir, "docker-compose.yml"), opts.compose); + await writeFile(resolve(projectDir, "hack.config.json"), CONFIG); + await writeFile(resolve(projectDir, "hack.env.json"), CONTRACT); + const project = { root, projectRoot, projectDir }; + await git(project, ["init", "--quiet"]); + await git(project, ["add", ".hack"]); + await git(project, ["commit", "--quiet", "-m", "fixture"]); + await writeFile(resolve(projectDir, ".env"), `${KEY}=${VALUE}\n`); + return project; +} + +async function status(project: Project): Promise { + return ( + await git(project, ["status", "--porcelain", "--untracked-files=all"]) + ) + .split("\n") + .filter((line) => line.length > 0) + .sort(); +} + +async function runCli(opts: { + readonly project: Project; + readonly args: readonly string[]; + readonly env?: Readonly>; +}) { + const tools = join(opts.project.root, "tools"); + await mkdir(tools, { recursive: true }); + // Docker is unreachable; the migration changes project files only. + await writeFile(join(tools, "docker"), "#!/bin/sh\nexit 1\n", { + mode: 0o700, + }); + await writeFile(join(tools, "mutagen"), "#!/bin/sh\nexit 0\n", { + mode: 0o700, + }); + const proc = Bun.spawn([process.execPath, ENTRYPOINT, ...opts.args], { + cwd: opts.project.projectRoot, + env: privateCliEnv({ + root: opts.project.root, + env: { + PATH: `${tools}:/usr/bin:/bin`, + HACK_MUTAGEN_PATH: join(tools, "mutagen"), + HACK_HOME: join(opts.project.root, "hack-home"), + HACK_GLOBAL_CONFIG_PATH: join(opts.project.root, "hack-config.json"), + HACK_LOGGER: "console", + HACK_NO_INTERACTIVE: "1", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + NO_COLOR: "1", + ...opts.env, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + running.add(proc); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + running.delete(proc); + return { exitCode, output: `${stdout}\n${stderr}` }; +} + +const BACKENDS = { + compose: { HACK_RUNTIME_BACKEND: "compose" }, + native: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: "/nonexistent/hack-native", + HACK_NATIVE_HOME: "/nonexistent/native-home", + }, +} as const; + +function runDoctor(opts: { + readonly project: Project; + readonly backend: keyof typeof BACKENDS; + readonly accept: boolean; +}) { + return runCli({ + project: opts.project, + args: [ + "doctor", + "--migrate-env-config", + ...(opts.accept ? ["--accept-tracked-env-changes"] : []), + "--path", + opts.project.projectRoot, + ], + env: BACKENDS[opts.backend], + }); +} + +for (const backend of ["compose", "native"] as const) { + test( + `${backend} doctor without a prompt refuses to change tracked files and changes nothing`, + async () => { + const project = await createProject({ compose: COMPOSE_WITH_ENV_FILE }); + + const run = await runDoctor({ project, backend, accept: false }); + + expect(run.exitCode).toBe(1); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain("E_INTERACTIVE_REQUIRED"); + expect(run.output).toContain( + "git-tracked files (edit .hack/docker-compose.yml; delete .hack/hack.env.json)" + ); + expect(run.output).toContain("--accept-tracked-env-changes"); + expect(run.output).not.toContain("v4"); + expect(await status(project)).toEqual(["?? .hack/.env"]); + expect( + await readFile(join(project.projectDir, "hack.env.json"), "utf8") + ).toBe(CONTRACT); + expect( + await readFile(join(project.projectDir, "docker-compose.yml"), "utf8") + ).toBe(COMPOSE_WITH_ENV_FILE); + expect(await readFile(join(project.projectDir, ".env"), "utf8")).toBe( + `${KEY}=${VALUE}\n` + ); + }, + CLI_TEST_TIMEOUT_MS + ); + + test( + `${backend} doctor with --accept-tracked-env-changes migrates and changes the tracked files`, + async () => { + const project = await createProject({ compose: COMPOSE_WITH_ENV_FILE }); + + const run = await runDoctor({ project, backend, accept: true }); + + expect(run.output).not.toContain(VALUE); + expect(run.output).not.toContain("E_INTERACTIVE_REQUIRED"); + expect(run.output).toContain( + `Moved ${KEY} to .hack/hack.env.local.yaml (values omitted).` + ); + expect(run.output).toContain("tracked by git and can now be removed"); + expect(await status(project)).toEqual([ + " D .hack/hack.env.json", + " M .hack/docker-compose.yml", + "?? .hack/.gitignore", + "?? .hack/hack.env.default.yaml", + ]); + expect( + await readFile(join(project.projectDir, "hack.env.local.yaml"), "utf8") + ).toContain(VALUE); + // The plaintext copy is gone once Compose no longer names it. + await expect( + readFile(join(project.projectDir, ".env"), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + CLI_TEST_TIMEOUT_MS + ); +} + +test( + "doctor without a prompt still migrates when no tracked file would change", + async () => { + const project = await createProject({ compose: COMPOSE_PLAIN }); + // An untracked contract is this checkout's own file. + await git(project, ["rm", "--quiet", "--cached", ".hack/hack.env.json"]); + await git(project, ["commit", "--quiet", "-m", "untrack contract"]); + + const run = await runDoctor({ project, backend: "native", accept: false }); + + expect(run.exitCode, run.output).toBe(0); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain(`Moved ${KEY} to .hack/hack.env.local.yaml`); + expect(await status(project)).toEqual([ + "?? .hack/.gitignore", + "?? .hack/hack.env.default.yaml", + ]); + }, + CLI_TEST_TIMEOUT_MS +); + +function runImplicit(project: Project, env: Record = {}) { + return runCli({ + project, + args: ["env", "exec", "--path", project.projectRoot, "--", "true"], + env: { ...BACKENDS.native, ...env }, + }); +} + +test( + "implicit migration names moved keys, drops the plaintext copy and leaves tracked files alone", + async () => { + const project = await createProject({ compose: COMPOSE_PLAIN }); + + const run = await runImplicit(project); + + expect(run.exitCode, run.output).toBe(0); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain( + `Moved ${KEY} to .hack/hack.env.local.yaml (values omitted).` + ); + expect(run.output).toContain("Removed .hack/.env"); + expect(await status(project)).toEqual([ + "?? .hack/.gitignore", + "?? .hack/hack.env.default.yaml", + ]); + expect( + await readFile(join(project.projectDir, "hack.env.json"), "utf8") + ).toBe(CONTRACT); + expect( + await readFile(join(project.projectDir, "hack.env.local.yaml"), "utf8") + ).toContain(VALUE); + await expect( + readFile(join(project.projectDir, ".env"), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "implicit migration keeps a .env Compose still names and says why", + async () => { + const project = await createProject({ compose: COMPOSE_WITH_ENV_FILE }); + + const run = await runImplicit(project); + + expect(run.exitCode, run.output).toBe(0); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain(`Moved ${KEY} to .hack/hack.env.local.yaml`); + expect(run.output).toContain( + "Kept .hack/.env: Compose still names it as env_file." + ); + // The new managed ignore block covers .env, so status no longer lists it. + expect(await status(project)).toEqual([ + "?? .hack/.gitignore", + "?? .hack/hack.env.default.yaml", + ]); + expect(await readFile(join(project.projectDir, ".env"), "utf8")).toBe( + `${KEY}=${VALUE}\n` + ); + expect( + await readFile(join(project.projectDir, "docker-compose.yml"), "utf8") + ).toBe(COMPOSE_WITH_ENV_FILE); + }, + CLI_TEST_TIMEOUT_MS +); + +test( + "a shell-only contract value refusal doesn't claim a .hack/.env exists", + async () => { + const project = await createProject({ compose: COMPOSE_PLAIN }); + await rm(join(project.projectDir, ".env")); + + const run = await runImplicit(project, { [KEY]: VALUE }); + + expect(run.exitCode).not.toBe(0); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain( + "Refusing to migrate the legacy env config: the contract declares" + ); + expect(run.output).not.toContain(".hack/.env files"); + expect(await status(project)).toEqual([]); + }, + CLI_TEST_TIMEOUT_MS +); diff --git a/tests/env-set-local-contract.test.ts b/tests/env-set-local-contract.test.ts index 114413c20..7ac34e099 100644 --- a/tests/env-set-local-contract.test.ts +++ b/tests/env-set-local-contract.test.ts @@ -279,7 +279,7 @@ test( CLI_TEST_TIMEOUT_MS ); -async function runDoctor(project: Project) { +async function runDoctor(project: Project, extraArgs: readonly string[] = []) { const tools = join(project.root, "tools"); await mkdir(tools, { recursive: true }); await writeFile(join(tools, "bun"), "#!/bin/sh\necho 1.4.2\n", { @@ -300,6 +300,7 @@ async function runDoctor(project: Project) { "doctor", "--migrate-env-config", "--no-interactive", + ...extraArgs, "--path", project.projectRoot, ], @@ -376,7 +377,9 @@ for (const lateContract of [false, true]) { `LOCAL_KEY=${VALUE}\n` ); } - const run = await runDoctor(project); + // The contract and Compose file are tracked, so a run without a prompt + // changes them only with explicit consent (HACK-1904). + const run = await runDoctor(project, ["--accept-tracked-env-changes"]); expect(run.output).not.toContain(VALUE); expect(run.exitCode, run.output).toBe(0); const local = join(project.projectDir, "hack.env.local.yaml"); From 96a72529beb07e3247ce062e9b05a803e1977857 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:27:41 -0500 Subject: [PATCH 004/138] feat(native): accept optional dependencies, stdin/tty, inert route labels and nested defaults Native Compose review and execution now accept four Compose forms that previously refused the whole project: - depends_on..required: false. An optional edge to a service the selected profiles disable is skipped with optional_dependency_inactive, as in Compose. An active optional target is enforced like a required one (fail closed) and reported as optional_dependency_enforced. restart: true and missing targets keep their refusals. - stdin_open and tty. stdin_open keeps an open, unattached stdin (OpenStdin true, AttachStdin/StdinOnce/Tty false). tty is accepted, but no pseudo-terminal is allocated (terminal_not_allocated warning), so logs stay multiplexed. - caddy hostnames with caddy.tls internal and no caddy.reverse_proxy. The Compose router proxies nothing for this form, so native records it as inert: no route, hostname claim or relay, an inert_route_declaration warning, and graph admission accepts exactly those two labels. Runtime host metadata still advertises the hostnames, as on Compose, but the TS route map and additionalHostnames adaptation exclude inert services. Every other partial or extra caddy label set is still refused. - Nested interpolation defaults such as ${A:-${B:-}}. Inner :- and :+ expressions resolve recursively, up to four levels, within the shared 1 MiB budget. Unselected words are validated without requiring inputs. ?, unbraced $NAME and $(...) inside a word stay refused. A reference to a managed value of the same service now refuses with execution_environment_owner instead of resolving to a default or empty. New plan fields are omitted when unused, so existing plan IDs do not change. Older bundles refuse plans that use them (deny_unknown_fields). Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 37 ++ packages/runtime-core/src/main.rs | 6 +- .../runtime-core/src/project/compose/graph.rs | 14 +- .../src/project/compose/routing.rs | 80 +++- .../src/project/compose/service.rs | 41 +- .../runtime-core/src/project/execution.rs | 10 + .../src/project/hostname_change.rs | 19 + packages/runtime-core/src/project/inputs.rs | 145 +++++-- packages/runtime-core/src/project/mod.rs | 25 ++ .../runtime-core/src/provider/graph/config.rs | 101 +++++ .../tests/compose_fields_contract.rs | 380 ++++++++++++++++++ .../runtime-core/tests/project_contract.rs | 8 +- src/backends/native-project-adaptation.ts | 2 + src/backends/native-runtime-metadata.ts | 21 +- tests/native-project-adaptation.test.ts | 37 +- tests/native-project-start.test.ts | 38 ++ tests/native-runtime-metadata.test.ts | 56 +++ 17 files changed, 971 insertions(+), 49 deletions(-) create mode 100644 packages/runtime-core/tests/compose_fields_contract.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..f9a663cab 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -620,6 +620,43 @@ selectors. Credentials are not written to Compose or the project snapshot, and are not automatically renewed after expiry. Normal native foreground `hack up` uses these adapters within the capability limits described below. +### Compose fields with a native meaning + +Native review accepts these Compose fields. Where the native result differs from +Compose, review reports a warning; nothing is dropped silently. A plan that does not +use a field keeps its earlier plan ID. A plan or receipt that does use one is refused +by an older bundle, so clean up such a graph with the bundle that started it. + +- `depends_on..required: false`. When the selected profiles disable the + target, the edge is skipped with the warning `optional_dependency_inactive`, as in + Compose. When the target is active, native waits for its `condition` exactly as for + a required dependency and fails startup if the condition is never met. Compose may + only warn there, so review reports `optional_dependency_enforced`. A target that + does not exist is still `missing_dependency`, and `restart: true` is still refused. +- `stdin_open: true` keeps the container's stdin open, so it never reaches EOF. + No host input is attached, as with `hack up` on Compose. `tty: true` is accepted + but no pseudo-terminal is allocated: stdout and stderr stay separate, and review + reports `terminal_not_allocated`. Programs that need a real terminal (colors, + prompts) stay on the Compose backend. +- A `caddy` hostname label with `caddy.tls: internal` and no `caddy.reverse_proxy` + proxies nothing, so native treats it as inert: no route, hostname claim or relay is + created, and review reports `inert_route_declaration`. The hostnames still appear + in [runtime host metadata](../cli.md#runtime-host-metadata), as on Compose. Any + other partial or extra `caddy*` label set is still refused. Inert hostnames are + ordinary literals for a retaining hostname change, and + `HACK_NATIVE_ADAPTATION` `additionalHostnames` refuses an inert service. +- Nested interpolation defaults such as `${A:-${B:-fallback}}` resolve inner + expressions first, as Compose does, up to four levels and within the shared 1 MiB + expanded budget. Only the `:-` and `:+` operators are supported at every level; + `?`, unbraced `$NAME` and `$(...)` inside a default word are refused. A word that + is not selected is still checked, but its inputs are not required. +- A reference to a Hack-managed value of the same service, for example + `${TOKEN:-fallback}` when Hack delivers `TOKEN` to that service, refuses with + `execution_environment_owner`. Managed values are delivered privately and are + never interpolated into commands, users, health checks or other values, and a + default is never used in their place. Declare the key bare (`TOKEN:` or `- TOKEN`) + to receive the managed value. + ## Public image acquisition without Docker The candidate can fetch a **digest-pinned public Docker Hub image** without Docker, diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 0ab86ffde..e7a227e9b 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -1257,7 +1257,11 @@ fn project_command(candidate: &Candidate, arguments: &[&str]) -> Result<(), Cand println!(" build: {} using {}", build.context, build.dockerfile); } for (dependency, detail) in &service.dependencies { - println!(" depends on {dependency}: {}", detail.condition); + println!( + " depends on {dependency}: {}{}", + detail.condition, + if detail.required { "" } else { " (optional)" } + ); } for mount in &service.mounts { println!( diff --git a/packages/runtime-core/src/project/compose/graph.rs b/packages/runtime-core/src/project/compose/graph.rs index b7441c1c8..486e11a31 100644 --- a/packages/runtime-core/src/project/compose/graph.rs +++ b/packages/runtime-core/src/project/compose/graph.rs @@ -45,12 +45,24 @@ pub(super) fn validate( )); continue; }; - if service.active && !target.active { + if service.active && !target.active && condition.required { diagnostics.push(Diagnostic::error( "inactive_dependency", &field, "An active service depends on a service disabled by the selected profiles.", )); + } else if service.active && !target.active { + diagnostics.push(Diagnostic::warning( + "optional_dependency_inactive", + &field, + "An optional dependency is disabled by the selected profiles; the edge is skipped, as with Compose.", + )); + } else if service.active && !condition.required { + diagnostics.push(Diagnostic::warning( + "optional_dependency_enforced", + &field, + "An active optional dependency is waited for like a required one; startup fails if its condition is never met, where Compose may only warn.", + )); } if condition.condition == "service_healthy" && !target diff --git a/packages/runtime-core/src/project/compose/routing.rs b/packages/runtime-core/src/project/compose/routing.rs index 346040d43..4df037e67 100644 --- a/packages/runtime-core/src/project/compose/routing.rs +++ b/packages/runtime-core/src/project/compose/routing.rs @@ -7,6 +7,22 @@ pub(super) fn recognized(key: &str) -> bool { matches!(key, "caddy" | "caddy.reverse_proxy" | "caddy.tls") } +/// Keys of an inert declaration: hostnames and internal TLS, with no upstream. +pub(crate) fn recognized_inert(key: &str) -> bool { + matches!(key, "caddy" | "caddy.tls") +} + +/// A reviewed Caddy label declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Declaration { + /// Hostnames proxied to one service port. + Route(RoutingPlan), + /// Exactly `caddy` hostnames plus `caddy.tls: internal` and no + /// `caddy.reverse_proxy`. The Compose router serves such a site without + /// proxying anything to the service, so it carries no traffic to it. + Inert, +} + fn refused() -> CandidateError { problem( "invalid_routing_labels", @@ -17,7 +33,17 @@ fn refused() -> CandidateError { /// Reads only routing labels and leaves unrelated declarations untouched. Indexed /// Caddy sites/directives are deliberately outside this subset. No filesystem, /// environment interpolation, Caddy configuration or network effects occur here. +/// An inert declaration yields no route here; see [`declaration`]. pub(crate) fn parse(value: Option<&Value>) -> Result, CandidateError> { + Ok(match declaration(value)? { + Some(Declaration::Route(route)) => Some(route), + Some(Declaration::Inert) | None => None, + }) +} + +/// Like [`parse`], but distinguishes an inert hostname declaration from no +/// declaration. Every other partial, extra or indexed form is still refused. +pub(crate) fn declaration(value: Option<&Value>) -> Result, CandidateError> { let mut routing = BTreeMap::new(); let mut take = |key: &str, value: &Value| -> Result<(), CandidateError> { if !key.starts_with("caddy") { @@ -54,7 +80,10 @@ pub(crate) fn parse(value: Option<&Value>) -> Result, Candid if routing.is_empty() { return Ok(None); } - if routing.len() != 3 || routing.get("caddy.tls").map(String::as_str) != Some("internal") { + let inert = !routing.contains_key("caddy.reverse_proxy"); + if routing.len() != if inert { 2 } else { 3 } + || routing.get("caddy.tls").map(String::as_str) != Some("internal") + { return Err(refused()); } let mut hosts = BTreeSet::new(); @@ -65,6 +94,9 @@ pub(crate) fn parse(value: Option<&Value>) -> Result, Candid return Err(refused()); } } + if inert { + return Ok(Some(Declaration::Inert)); + } let port = routing .get("caddy.reverse_proxy") .and_then(|value| value.strip_prefix("{{upstreams ")) @@ -73,10 +105,10 @@ pub(crate) fn parse(value: Option<&Value>) -> Result, Candid .and_then(|value| value.parse::().ok()) .filter(|port| *port != 0) .ok_or_else(refused)?; - Ok(Some(RoutingPlan { + Ok(Some(Declaration::Route(RoutingPlan { hostnames: hosts.into_iter().collect(), port, - })) + }))) } #[cfg(test)] @@ -146,11 +178,20 @@ mod tests { #[test] fn refuses_partial_extra_indexed_and_duplicate_labels() { - for key in ["caddy", "caddy.reverse_proxy", "caddy.tls"] { + for key in ["caddy", "caddy.tls"] { let mut value = labels("search.example", "6980"); value.as_object_mut().unwrap().remove(key); assert!(parse(Some(&value)).is_err()); } + for value in [ + json!({"caddy.reverse_proxy":"{{upstreams 6980}}"}), + json!({"caddy.tls":"internal"}), + json!({"caddy":"search.example"}), + json!({"caddy":"search.example","caddy.tls":"external"}), + json!({"caddy":"search.example","caddy.tls":"internal","caddy.header":"x"}), + ] { + assert!(declaration(Some(&value)).is_err(), "{value}"); + } for key in [ "caddy.header", "caddy.0", @@ -173,6 +214,37 @@ mod tests { ); } + #[test] + fn hostnames_with_internal_tls_and_no_upstream_are_inert() { + let mut value = labels("Web.Example., web.example.localhost", "6980"); + value.as_object_mut().unwrap().remove("caddy.reverse_proxy"); + assert_eq!(declaration(Some(&value)).unwrap(), Some(Declaration::Inert)); + assert_eq!(parse(Some(&value)).unwrap(), None); + let list = json!(["caddy=web.example", "caddy.tls=internal", "other=value"]); + assert_eq!(declaration(Some(&list)).unwrap(), Some(Declaration::Inert)); + for hosts in [ + "", + "*.example", + "${HOST}", + "x.example,X.EXAMPLE.", + "127.0.0.1", + ] { + let value = json!({"caddy":hosts,"caddy.tls":"internal"}); + assert!(declaration(Some(&value)).is_err(), "{hosts}"); + } + let hosts = (0..9).map(|i| format!("h{i}.example")).collect::>(); + assert!( + declaration(Some( + &json!({"caddy":hosts.join(","),"caddy.tls":"internal"}) + )) + .is_err() + ); + assert!(matches!( + declaration(Some(&labels("web.example", "6980"))).unwrap(), + Some(Declaration::Route(_)) + )); + } + #[test] fn refuses_port_commands_interpolation_and_unsafe_hosts() { for port in ["0", "65536", "-1", "1 2", "${PORT}", "1}}; evil {{", ""] { diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index 5cc51846a..5c90ea638 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -347,6 +347,7 @@ fn dependencies( name.into(), DependencyPlan { condition: "service_started".into(), + required: true, }, ) .is_some() @@ -363,8 +364,13 @@ fn dependencies( identifier(name)?; let m = object(value)?; keys(m, &["condition", "required", "restart"], field, diagnostics); - if !boolean(m.get("required"), true)? || boolean(m.get("restart"), false)? { - diagnostics.push(Diagnostic::error("unsupported_dependency_option",field,"Optional or restart-propagating dependencies are not supported by this subset.")); + let required = boolean(m.get("required"), true)?; + if boolean(m.get("restart"), false)? { + diagnostics.push(Diagnostic::error( + "unsupported_dependency_option", + field, + "Restart-propagating dependencies are not supported by this subset.", + )); } let condition = m .get("condition") @@ -387,6 +393,7 @@ fn dependencies( name.clone(), DependencyPlan { condition: condition.into(), + required, }, ); } @@ -704,6 +711,8 @@ pub(super) fn compile( "stop_grace_period", "stop_signal", "privileged", + "stdin_open", + "tty", ], &field, diagnostics, @@ -776,9 +785,20 @@ pub(super) fn compile( return Err(problem("invalid_restart", "Unsupported restart policy.")); } let labels = redacted_map(m.get("labels"), false)?; - let parsed_routing = super::routing::parse(m.get("labels")); + let parsed_routing = super::routing::declaration(m.get("labels")); let routing_invalid = parsed_routing.is_err(); - let routing = parsed_routing.ok().flatten(); + let (routing, inert_route_labels) = match parsed_routing.ok().flatten() { + Some(super::routing::Declaration::Route(route)) => (Some(route), false), + Some(super::routing::Declaration::Inert) => (None, true), + None => (None, false), + }; + if inert_route_labels { + diagnostics.push(Diagnostic::warning( + "inert_route_declaration", + &format!("{field}.labels"), + "Route hostnames without caddy.reverse_proxy carry no traffic; no route, hostname claim or relay is created, as with the Compose router.", + )); + } if routing.is_some() { diagnostics.push(Diagnostic::warning( "route_binding_required", @@ -810,6 +830,7 @@ pub(super) fn compile( super::ownership_labels::is_hack_ownership_label(key) || (!(dependency_cache.is_some() && super::dependency_cache::recognized(key)) && !(routing.is_some() && super::routing::recognized(key)) + && !(inert_route_labels && super::routing::recognized_inert(key)) && ["caddy", "traefik", "hack", "com.docker.compose"] .iter() .any(|prefix| key == prefix || key.starts_with(&format!("{prefix}.")))) @@ -861,6 +882,16 @@ pub(super) fn compile( "Only Linux arm64/amd64 image platform declarations are modeled.", )); } + let stdin_open = boolean(m.get("stdin_open"), false)?; + let tty = boolean(m.get("tty"), false)?; + if tty { + diagnostics.push(Diagnostic::warning( + "terminal_not_allocated", + &format!("{field}.tty"), + "Native services run without a pseudo-terminal; stdout and stderr stay separate and no host input is attached.", + )); + } + let interactive = (stdin_open || tty).then_some(InteractivePlan { stdin_open, tty }); let exposed_ports = strings(m.get("expose"))?; for value in &exposed_ports { let (port, protocol) = value.split_once('/').unwrap_or((value, "tcp")); @@ -927,7 +958,7 @@ pub(super) fn compile( limits:limits(m,&field,diagnostics)?,read_only:boolean(m.get("read_only"),false)?,init:boolean(m.get("init"),false)?,restart, working_dir:m.get("working_dir").map(|v| safe_absolute(string(v)?)).transpose()?, user:m.get("user").map(|v| match v {Value::String(s)=>redacted(s),Value::Number(n)=>redacted(&n.to_string()),_=>Err(problem("invalid_user","User must be a string or numeric identifier."))}).transpose()?, - platform,labels,routing,dependency_cache,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal, + platform,labels,routing,dependency_cache,inert_route_labels,interactive,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal, }); } Ok(result) diff --git a/packages/runtime-core/src/project/execution.rs b/packages/runtime-core/src/project/execution.rs index 4fb9cc4d9..6fa835330 100644 --- a/packages/runtime-core/src/project/execution.rs +++ b/packages/runtime-core/src/project/execution.rs @@ -125,6 +125,16 @@ impl Graph { } let mut dependencies = BTreeMap::new(); for (dependency, condition) in &service.dependencies { + // Review admits only optional edges to inactive targets; an + // active optional target is enforced like a required one. + if !condition.required + && !plan + .services + .get(dependency) + .is_some_and(|target| target.active) + { + continue; + } dependencies.insert( dependency.clone(), match condition.condition.as_str() { diff --git a/packages/runtime-core/src/project/hostname_change.rs b/packages/runtime-core/src/project/hostname_change.rs index fdefac2b8..ffca94fc9 100644 --- a/packages/runtime-core/src/project/hostname_change.rs +++ b/packages/runtime-core/src/project/hostname_change.rs @@ -127,6 +127,25 @@ mod tests { assert!(fingerprint(b"services: {web: {image: alpine}}", after).is_none()); } + #[test] + fn inert_hostname_declarations_are_not_masked_as_reviewed_routes() { + let inert = |host: &str| { + format!( + "{BASE} worker:\n image: example:one\n labels: {{caddy: {host}, caddy.tls: internal}}\n" + ) + }; + let before = inert("worker.hack"); + assert!(fingerprint(before.as_bytes(), before.as_bytes()).is_some()); + let after = inert("worker.v5.hack.gy"); + // Only routed hostnames may change in place; an inert value stays literal. + assert_ne!( + fingerprint(before.as_bytes(), before.as_bytes()), + fingerprint(after.as_bytes(), after.as_bytes()) + ); + let only_inert = b"services: {worker: {labels: {caddy: worker.hack, caddy.tls: internal}}}"; + assert!(fingerprint(only_inert, only_inert).is_none()); + } + #[test] fn runtime_metadata_follows_hostnames_but_other_environment_still_counts() { let expected = fingerprint(BASE.as_bytes(), BASE.as_bytes()).unwrap(); diff --git a/packages/runtime-core/src/project/inputs.rs b/packages/runtime-core/src/project/inputs.rs index 96d3e25d3..70e5bccb3 100644 --- a/packages/runtime-core/src/project/inputs.rs +++ b/packages/runtime-core/src/project/inputs.rs @@ -90,7 +90,12 @@ fn registry_environment( struct Resolver<'a> { values: &'a BTreeMap, remaining: usize, + /// Names Hack delivers privately to the service being compiled. A reference + /// to one must never silently resolve to its default or to an empty value. + managed: std::collections::BTreeSet, } +/// Compose nesting such as `${A:-${B:-${C:-${D:-word}}}}` is bounded at four levels. +const MAX_INTERPOLATION_DEPTH: usize = 4; fn variable(name: &str) -> bool { let mut bytes = name.bytes(); bytes @@ -98,6 +103,38 @@ fn variable(name: &str) -> bool { .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_') } +fn unsupported_interpolation() -> CandidateError { + error( + "execution_interpolation", + "Only braced :+ and :- operators on explicit inputs, nested at most four levels, are supported.", + ) +} +/// Length of a braced expression body: `text` follows `${`; the body ends at the +/// `}` that closes it, counting nested `${` and skipping `$$` as review does. +fn braced_end(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut depth = 1usize; + let mut index = 0; + while index < bytes.len() { + if bytes[index..].starts_with(b"$$") { + index += 2; + continue; + } + if bytes[index..].starts_with(b"${") { + depth += 1; + index += 2; + continue; + } + if bytes[index] == b'}' { + depth -= 1; + if depth == 0 { + return Some(index); + } + } + index += 1; + } + None +} impl Resolver<'_> { fn charge(&mut self, text: &str) -> Result<(), CandidateError> { if text.contains('\0') || text.len() > self.remaining { @@ -109,6 +146,15 @@ impl Resolver<'_> { self.remaining -= text.len(); Ok(()) } + fn owned(&self, name: &str) -> Result<(), CandidateError> { + if self.managed.contains(name) { + return Err(error( + "execution_environment_owner", + "A Compose reference names a Hack-managed service input; managed values are delivered privately and never interpolated or replaced by defaults. Values omitted.", + )); + } + Ok(()) + } fn lookup(&self, name: &str) -> Result<&str, CandidateError> { if !variable(name) { return Err(error( @@ -116,6 +162,7 @@ impl Resolver<'_> { "Unsupported environment reference; use a valid variable name or supported braced literal-word operator.", )); } + self.owned(name)?; let value = self.values.get(name).ok_or_else(|| error("execution_environment_missing", "An explicitly required runtime input was not supplied; ambient environment is not consulted."))?; if value.len() > self.remaining || value.contains('\0') { return Err(error( @@ -125,40 +172,85 @@ impl Resolver<'_> { } Ok(value) } - fn expression(&self, expression: &str) -> Result { + /// Evaluate one braced body. `live` is false for a default/alternative word + /// Compose would not select: it is still validated and checked for managed + /// names, but missing inputs inside it are not required. + fn expression( + &mut self, + expression: &str, + depth: usize, + live: bool, + ) -> Result { let Some((name, suffix)) = expression.split_once(':') else { + if !live { + if !variable(expression) { + return Err(unsupported_interpolation()); + } + return self.owned(expression).map(|()| String::new()); + } return self.lookup(expression).map(str::to_owned); }; - let invalid = || { - error( - "execution_interpolation", - "Only literal-word :+ and :- operators on explicit inputs are supported; nested interpolation is refused.", - ) - }; if !variable(name) || suffix.is_empty() { - return Err(invalid()); + return Err(unsupported_interpolation()); } + self.owned(name)?; let (operator, word) = if let Some(word) = suffix.strip_prefix('+') { ("+", word) } else if let Some(word) = suffix.strip_prefix('-') { ("-", word) } else { - return Err(invalid()); + return Err(unsupported_interpolation()); }; - if word.contains(['$', '{', '}']) { - return Err(invalid()); - } - let value = if self.values.contains_key(name) { - self.lookup(name)? + let value = if live && self.values.contains_key(name) { + self.lookup(name)?.to_owned() } else { - "" + String::new() }; + let selected = matches!((operator, value.is_empty()), ("+", false) | ("-", true)); + let word = self.word(word, depth, live && selected)?; Ok(match (operator, value.is_empty()) { - ("+", false) | ("-", true) => word.to_owned(), + ("+", false) | ("-", true) => word, ("+", true) => String::new(), - _ => value.to_owned(), + _ => value, }) } + /// A default/alternative word: literal text, `$$` escapes and nested braced + /// expressions only. Unbraced references and command substitution are refused. + fn word(&mut self, text: &str, depth: usize, live: bool) -> Result { + let mut result = String::new(); + let mut rest = text; + loop { + let index = rest.find('$').unwrap_or(rest.len()); + let literal = &rest[..index]; + if literal.contains(['{', '}']) { + return Err(unsupported_interpolation()); + } + if live { + result.push_str(literal); + } + if index == rest.len() { + return Ok(result); + } + rest = &rest[index + 1..]; + if let Some(tail) = rest.strip_prefix('$') { + if live { + result.push('$'); + } + rest = tail; + continue; + } + let braced = rest + .strip_prefix('{') + .ok_or_else(unsupported_interpolation)?; + if depth >= MAX_INTERPOLATION_DEPTH { + return Err(unsupported_interpolation()); + } + let end = braced_end(braced).ok_or_else(unsupported_interpolation)?; + let value = self.expression(&braced[..end], depth + 1, live)?; + result.push_str(&value); + rest = &braced[end + 1..]; + } + } fn resolve(&mut self, text: &str) -> Result { let mut result = String::new(); let mut rest = text; @@ -172,22 +264,23 @@ impl Resolver<'_> { rest = tail; continue; } - let (name, tail) = if let Some(braced) = rest.strip_prefix('{') { - let end = braced.find('}').ok_or_else(|| { + let value = if let Some(braced) = rest.strip_prefix('{') { + let end = braced_end(braced).ok_or_else(|| { error("execution_interpolation", "Unclosed environment reference.") })?; - (&braced[..end], &braced[end + 1..]) + rest = &braced[end + 1..]; + self.expression(&braced[..end], 1, true)? } else { let end = rest .bytes() .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_') .count(); - (&rest[..end], &rest[end..]) + let name = &rest[..end]; + rest = &rest[end..]; + self.lookup(name)?.to_owned() }; - let value = self.expression(name)?; self.charge(&value)?; result.push_str(&value); - rest = tail; } self.charge(rest)?; result.push_str(rest); @@ -500,6 +593,7 @@ fn compile_inner( let mut resolver = Resolver { values: supplied, remaining: MAX_EXPANDED, + managed: Default::default(), }; if let Some(managed) = managed { for (name, values) in managed { @@ -536,6 +630,10 @@ fn compile_inner( .get(name) .and_then(Value::as_object) .ok_or_else(|| error("execution_services", "Reviewed service is missing."))?; + let private = managed.map(|values| values.get(name).unwrap_or(&empty)); + resolver.managed = private + .map(|values| values.keys().cloned().collect()) + .unwrap_or_default(); let health_test = if service.healthcheck.as_ref().is_some_and(|h| h.disabled) { Some(vec!["NONE".into()]) } else { @@ -551,7 +649,6 @@ fn compile_inner( Some(Value::Number(n)) => Some(resolver.resolve(&n.to_string())?), _ => return Err(error("execution_user", "Unsupported service user.")), }; - let private = managed.map(|values| values.get(name).unwrap_or(&empty)); let environment = registry_environment( raw.get("environment"), review diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index 043076259..eaf260180 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -122,6 +122,25 @@ pub struct PortPlan { #[serde(deny_unknown_fields)] pub struct DependencyPlan { pub condition: String, + /// Compose `required: false`: an optional edge whose inactive target is + /// dropped before execution. An active target is still waited for and its + /// condition enforced (fail closed). Omitted when true so existing plan IDs hold. + #[serde(default = "required_dependency", skip_serializing_if = "is_required")] + pub required: bool, +} +fn required_dependency() -> bool { + true +} +fn is_required(required: &bool) -> bool { + *required +} +/// Compose `stdin_open`/`tty` intent. Native services never attach host input +/// and never allocate a pseudo-terminal; only an open, unattached stdin is kept. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct InteractivePlan { + pub stdin_open: bool, + pub tty: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(deny_unknown_fields)] @@ -269,6 +288,12 @@ pub struct ServicePlan { pub routing: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dependency_cache: Option, + /// Exactly `caddy` hostnames plus `caddy.tls: internal`, with no upstream: + /// a declaration that carries no traffic, so no route, hostname claim or relay. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub inert_route_labels: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interactive: Option, pub logging: Option, pub stop_grace_period_nanos: Option, pub stop_signal: Option, diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 257424239..5bb4f6a01 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -188,6 +188,11 @@ pub(super) fn prepare_delivery( || !service.ports.is_empty() || service.labels.keys().any(|key| { !(routing_enrolled && service.routing.is_some() && routes::recognized(key)) + // Inert hostname declarations carry no traffic; they never + // reach the engine (graph labels are ownership-only). + && !(service.inert_route_labels + && service.routing.is_none() + && matches!(key.as_str(), "caddy" | "caddy.tls")) && !(service.dependency_cache.is_some() && matches!( key.as_str(), @@ -461,6 +466,19 @@ pub(super) fn prepare_delivery( if let Some(dir) = &service.working_dir { config["WorkingDir"] = json!(dir); } + // Compose `stdin_open` keeps stdin open so it never reaches EOF. Host + // input is never attached and no pseudo-terminal is allocated, even for + // `tty: true` (reviewed with a warning), so logs stay multiplexed. + if service + .interactive + .as_ref() + .is_some_and(|interactive| interactive.stdin_open) + { + config["OpenStdin"] = json!(true); + config["StdinOnce"] = json!(false); + config["AttachStdin"] = json!(false); + config["Tty"] = json!(false); + } if let Some(signal) = &service.stop_signal { config["StopSignal"] = json!(signal); } @@ -619,3 +637,86 @@ mod network_tests { ); } } + +#[cfg(test)] +mod compose_field_tests { + use super::*; + use crate::{Candidate, project::PlanOptions}; + use std::path::Path; + + fn prepared(document: &Value) -> Result { + let fixture = super::super::tests::Fixture::new(); + let candidate_root = super::super::tests::Fixture::new(); + let candidate = Candidate::discover(&candidate_root.0).unwrap(); + state::write(&fixture.0.join("compose.yaml"), document).unwrap(); + let options = || PlanOptions { + branch: None, + project: &fixture.0, + compose_file: Path::new("compose.yaml"), + profiles: &[], + }; + let review = project::plan(&candidate, options())?; + let inputs = + project::inputs::compile(&candidate, options(), &review.plan_id, &BTreeMap::new())?; + prepare( + inputs, + &BTreeMap::from([("web".into(), Condition::Started)]), + &"a".repeat(32), + &"b".repeat(32), + None, + ) + } + + fn document(web: Value) -> Value { + let mut service = json!({"image":format!("sha256:{}","a".repeat(64)),"command":["/bin/app"],"networks":["private"]}); + for (key, value) in web.as_object().unwrap() { + service[key] = value.clone(); + } + json!({"services":{"web":service},"networks":{"private":{"internal":true}}}) + } + + #[test] + fn stdin_open_keeps_unattached_stdin_and_never_allocates_a_terminal() { + for (fields, open) in [ + (json!({"stdin_open":true}), true), + (json!({"stdin_open":true,"tty":true}), true), + (json!({"tty":true}), false), + (json!({}), false), + ] { + let prepared = prepared(&document(fields.clone())).unwrap(); + let config = &prepared.configs["web"]; + if open { + assert_eq!(config["OpenStdin"], true, "{fields}"); + assert_eq!(config["StdinOnce"], false); + assert_eq!(config["AttachStdin"], false); + assert_eq!(config["Tty"], false); + } else { + for key in ["OpenStdin", "StdinOnce", "AttachStdin", "Tty"] { + assert!(config.get(key).is_none(), "{fields} {key}"); + } + } + } + } + + #[test] + fn inert_route_labels_are_admitted_without_a_route_and_partial_forms_refuse() { + let inert = prepared(&document( + json!({"labels":{"caddy":"web.example","caddy.tls":"internal"}}), + )) + .unwrap(); + assert!(inert.resources["container:web"].routing.is_none()); + let labels = inert.configs["web"]["Labels"].as_object().unwrap(); + assert!(labels.keys().all(|key| !key.starts_with("caddy"))); + for labels in [ + json!({"caddy":"web.example"}), + json!({"caddy.tls":"internal"}), + json!({"caddy":"web.example","caddy.tls":"internal","caddy.header":"x"}), + json!({"caddy":"web.example","caddy.tls":"internal","hack.owner":"x"}), + ] { + let error = prepared(&document(json!({ "labels": labels }))) + .err() + .unwrap(); + assert_eq!(error.code, "execution_incompatible", "{labels}"); + } + } +} diff --git a/packages/runtime-core/tests/compose_fields_contract.rs b/packages/runtime-core/tests/compose_fields_contract.rs new file mode 100644 index 000000000..972275518 --- /dev/null +++ b/packages/runtime-core/tests/compose_fields_contract.rs @@ -0,0 +1,380 @@ +//! Compose field semantics reviewed natively: optional dependencies, stdin/TTY +//! intent, inert route declarations and nested interpolation defaults. +use hack_runtime_core::{ + Candidate, + project::{ + self, PlanData, PlanOptions, PlanReport, + execution::{Condition, Graph}, + inputs, + }, +}; +use std::{ + collections::BTreeMap, + fs, + io::Read, + path::{Path, PathBuf}, +}; + +struct Fixture { + root: PathBuf, + project: PathBuf, + candidate: Candidate, +} +impl Fixture { + fn new(compose: &str) -> Self { + let mut bytes = [0; 16]; + fs::File::open("/dev/urandom") + .unwrap() + .read_exact(&mut bytes) + .unwrap(); + let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let root = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("hack-fields-{token}")); + fs::create_dir(&root).unwrap(); + let project = root.join("project"); + let checkout = root.join("candidate"); + fs::create_dir(&project).unwrap(); + fs::create_dir(&checkout).unwrap(); + fs::write(project.join("compose.yaml"), compose).unwrap(); + Self { + root, + project, + candidate: Candidate::discover(&checkout).unwrap(), + } + } + fn options<'a>(&'a self, profiles: &'a [String]) -> PlanOptions<'a> { + PlanOptions { + branch: None, + project: &self.project, + compose_file: Path::new("compose.yaml"), + profiles, + } + } + fn plan(&self) -> PlanReport { + project::plan(&self.candidate, self.options(&[])).unwrap() + } + fn plan_with(&self, profiles: &[String]) -> PlanReport { + project::plan(&self.candidate, self.options(profiles)).unwrap() + } + fn compile(&self, supplied: &[(&str, &str)]) -> Result { + let review = self.plan(); + let values = supplied + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + inputs::compile(&self.candidate, self.options(&[]), &review.plan_id, &values) + .map_err(|error| error.code.to_owned()) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).unwrap(); + } +} + +fn codes(plan: &PlanData) -> Vec<(&str, &str)> { + plan.diagnostics + .iter() + .map(|d| (d.code.as_str(), d.severity.as_str())) + .collect() +} + +/// Plans that do not use a new field keep their exact serialization, so their +/// plan IDs (a digest of that serialization) do not change after upgrade. +fn assert_legacy_serialization(report: &PlanReport) { + let text = serde_json::to_string(&report.plan).unwrap(); + for key in ["\"required\"", "\"inert_route_labels\"", "\"interactive\""] { + assert!(!text.contains(key), "{key}"); + } + let decoded: PlanData = serde_json::from_str(&text).unwrap(); + assert_eq!(serde_json::to_string(&decoded).unwrap(), text); +} + +const OPTIONAL: &str = "services:\n app:\n image: alpine:3.21\n depends_on:\n db:\n condition: service_healthy\n required: false\n cache:\n condition: service_started\n cache:\n image: alpine:3.21\n db:\n image: alpine:3.21\n profiles: [database]\n healthcheck:\n test: [CMD, 'true']\n"; + +#[test] +fn optional_dependency_on_an_inactive_service_is_skipped_with_a_warning() { + let fixture = Fixture::new(OPTIONAL); + let report = fixture.plan(); + assert!(report.plan.enrollment_compatible); + let edges = &report.plan.services["app"].dependencies; + assert!(!edges["db"].required); + assert!(edges["cache"].required); + assert!(codes(&report.plan).contains(&("optional_dependency_inactive", "warning"))); + let graph = Graph::from_plan( + &report.plan, + &BTreeMap::from([ + ("app".into(), Condition::Started), + ("cache".into(), Condition::Started), + ]), + ) + .unwrap(); + assert_eq!( + graph.services["app"].dependencies, + BTreeMap::from([("cache".into(), Condition::Started)]) + ); + let text = serde_json::to_string(&report.plan.services["app"].dependencies).unwrap(); + assert_eq!(text.matches("\"required\":false").count(), 1); + assert!(!text.contains("\"required\":true")); +} + +#[test] +fn optional_dependency_on_an_active_service_is_enforced_fail_closed() { + let fixture = Fixture::new(OPTIONAL); + let report = fixture.plan_with(&["database".into()]); + assert!(report.plan.enrollment_compatible); + assert!(codes(&report.plan).contains(&("optional_dependency_enforced", "warning"))); + let graph = Graph::from_plan( + &report.plan, + &BTreeMap::from([ + ("app".into(), Condition::Started), + ("cache".into(), Condition::Started), + ("db".into(), Condition::Healthy), + ]), + ) + .unwrap(); + assert_eq!(graph.services["app"].dependencies["db"], Condition::Healthy); +} + +#[test] +fn required_and_restart_dependency_rules_are_unchanged() { + let required = Fixture::new(&OPTIONAL.replace(" required: false\n", "")); + let report = required.plan(); + assert!(!report.plan.enrollment_compatible); + assert!(codes(&report.plan).contains(&("inactive_dependency", "error"))); + let restart = Fixture::new(&OPTIONAL.replace("required: false", "restart: true")); + assert!(codes(&restart.plan().plan).contains(&("unsupported_dependency_option", "error"))); + let missing = Fixture::new(&OPTIONAL.replace(" db:\n", " absent:\n")); + assert!(codes(&missing.plan().plan).contains(&("missing_dependency", "error"))); + let invalid = Fixture::new(&OPTIONAL.replace("required: false", "required: 'no'")); + assert!(project::plan(&invalid.candidate, invalid.options(&[])).is_err()); + let list = Fixture::new( + "services:\n app:\n image: alpine:3.21\n depends_on: [cache]\n cache:\n image: alpine:3.21\n", + ); + assert_legacy_serialization(&list.plan()); +} + +#[test] +fn stdin_open_and_tty_are_reviewed_and_tty_warns_about_the_missing_terminal() { + let fixture = Fixture::new( + "services:\n app:\n image: alpine:3.21\n stdin_open: true\n tty: true\n worker:\n image: alpine:3.21\n stdin_open: true\n quiet:\n image: alpine:3.21\n stdin_open: false\n tty: false\n", + ); + let report = fixture.plan(); + assert!(report.plan.enrollment_compatible); + let services = &report.plan.services; + let app = services["app"].interactive.as_ref().unwrap(); + assert!(app.stdin_open && app.tty); + let worker = services["worker"].interactive.as_ref().unwrap(); + assert!(worker.stdin_open && !worker.tty); + assert!(services["quiet"].interactive.is_none()); + let warnings: Vec<_> = report + .plan + .diagnostics + .iter() + .filter(|d| d.code == "terminal_not_allocated") + .collect(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].field, "services.app.tty"); + assert_eq!(warnings[0].severity, "warning"); + let invalid = Fixture::new("services:\n app:\n image: alpine:3.21\n tty: 'yes'\n"); + assert!(project::plan(&invalid.candidate, invalid.options(&[])).is_err()); + assert_legacy_serialization( + &Fixture::new("services:\n app:\n image: alpine:3.21\n stdin_open: false\n").plan(), + ); +} + +#[test] +fn hostnames_without_an_upstream_are_inert_and_partial_route_labels_still_refuse() { + let fixture = Fixture::new( + "services:\n worker:\n image: alpine:3.21\n labels:\n caddy: worker.example.test\n caddy.tls: internal\n other: kept\n", + ); + let report = fixture.plan(); + assert!(report.plan.enrollment_compatible); + let worker = &report.plan.services["worker"]; + assert!(worker.inert_route_labels); + assert!(worker.routing.is_none()); + assert!(codes(&report.plan).contains(&("inert_route_declaration", "warning"))); + assert!(!codes(&report.plan).contains(&("route_binding_required", "warning"))); + assert!( + !serde_json::to_string(&report) + .unwrap() + .contains("worker.example.test") + ); + for labels in [ + " caddy: worker.example.test\n", + " caddy.tls: internal\n", + " caddy: worker.example.test\n caddy.tls: external\n", + " caddy: worker.example.test\n caddy.tls: internal\n caddy.header: x\n", + " caddy: '*.example.test'\n caddy.tls: internal\n", + ] { + let partial = Fixture::new(&format!( + "services:\n worker:\n image: alpine:3.21\n labels:\n{labels}" + )); + let plan = project::plan(&partial.candidate, partial.options(&[])) + .unwrap() + .plan; + assert!(!plan.enrollment_compatible, "{labels}"); + assert!( + codes(&plan).contains(&("external_route_or_owner_label", "error")), + "{labels}" + ); + assert!(!plan.services["worker"].inert_route_labels); + } + let routed = Fixture::new( + "services:\n web:\n image: alpine:3.21\n labels:\n caddy: web.example.test\n caddy.reverse_proxy: '{{upstreams 3000}}'\n caddy.tls: internal\n", + ); + let report = routed.plan(); + assert!(report.plan.services["web"].routing.is_some()); + assert_legacy_serialization(&report); +} + +#[test] +fn nested_defaults_resolve_like_compose_within_depth_and_operator_bounds() { + let case = |expression: &str, supplied: &[(&str, &str)]| { + let fixture = Fixture::new(&format!( + "services:\n web:\n image: alpine:3.21\n command: [echo, '{expression}']\n" + )); + fixture + .compile(supplied) + .map(|compiled| compiled.services["web"].command.clone().unwrap()[1].clone()) + }; + let ok = |expression: &str, supplied: &[(&str, &str)], expected: &str| { + assert_eq!( + case(expression, supplied).as_deref(), + Ok(expected), + "{expression} {supplied:?}" + ); + }; + ok("${A:-${B:-}}", &[], ""); + ok("${A:-${B:-fallback}}", &[], "fallback"); + ok("${A:-${B:-fallback}}", &[("B", "inner")], "inner"); + ok( + "${A:-${B:-fallback}}", + &[("A", "outer"), ("B", "inner")], + "outer", + ); + ok( + "${A:-${B:-fallback}}", + &[("A", ""), ("B", "inner")], + "inner", + ); + ok("${A:-x${B:-y}z}", &[], "xyz"); + ok("${A:-$$x}", &[], "$x"); + ok("${A:+on-${B:-default}}", &[("A", "1")], "on-default"); + ok("${A:+on-${B:-default}}", &[], ""); + // An unselected word is validated but its inputs are not required. + ok("${A:-${B}}", &[("A", "set")], "set"); + ok("${A:+${B}}", &[], ""); + ok( + "pre-${A:-${B:-${C:-${D:-deep}}}}-post", + &[], + "pre-deep-post", + ); + for refused in [ + "${A:-${B:-${C:-${D:-${E:-x}}}}}", + "${A:-$B}", + "${A:-$(date)}", + "${A:-${B:?required}}", + "${A:-{x}", + "${A:-${B-x}}", + ] { + let fixture = Fixture::new(&format!( + "services:\n web:\n image: alpine:3.21\n command: [echo, '{refused}']\n" + )); + // Review accepts each form; only execution refuses it. + assert!( + project::plan(&fixture.candidate, fixture.options(&[])).is_ok(), + "{refused}" + ); + assert_eq!( + fixture.compile(&[]).err().as_deref(), + Some("execution_interpolation"), + "{refused}" + ); + } + assert_eq!( + case("${A:-${B}}", &[]).err().as_deref(), + Some("execution_environment_missing") + ); +} + +#[test] +fn nested_defaults_share_the_expanded_budget() { + let large = "x".repeat(600 * 1024); + let fixture = Fixture::new( + "services:\n web:\n image: alpine:3.21\n command: [echo, '${A:-${B:-}}', '${C:-${D:-}}']\n", + ); + assert!(fixture.compile(&[("B", &large)]).is_ok()); + assert_eq!( + fixture + .compile(&[("B", &large), ("D", &large)]) + .err() + .as_deref(), + Some("execution_input_budget") + ); +} + +#[test] +fn references_to_managed_service_inputs_refuse_instead_of_using_defaults() { + let canary = "synthetic-managed-canary"; + for (field, value) in [ + ("command", "[echo, '${TOKEN:-fallback}']"), + ("command", "[echo, '${OTHER:-${TOKEN:-}}']"), + ("command", "[echo, '${OTHER:+${TOKEN}}']"), + ("command", "[echo, '$TOKEN']"), + ("environment", "{TOKEN: null, LABEL: '${TOKEN:-fallback}'}"), + ("user", "'${TOKEN:-1000}'"), + ] { + let environment = if field == "environment" { + String::new() + } else { + " environment: [TOKEN]\n".into() + }; + let fixture = Fixture::new(&format!( + "services:\n web:\n image: alpine:3.21\n{environment} {field}: {value}\n other:\n image: alpine:3.21\n command: [echo, '${{TOKEN:-unmanaged-here}}']\n" + )); + let review = fixture.plan(); + let managed = BTreeMap::from([( + "web".to_owned(), + BTreeMap::from([("TOKEN".to_owned(), canary.to_owned())]), + )]); + let error = inputs::compile_scoped( + &fixture.candidate, + fixture.options(&[]), + &review.plan_id, + &BTreeMap::new(), + &managed, + ) + .err() + .unwrap(); + assert_eq!(error.code, "execution_environment_owner", "{field} {value}"); + assert!(!error.message.contains(canary)); + assert!(!error.message.contains("TOKEN")); + } + // A service that does not receive the managed input keeps Compose defaults. + let fixture = Fixture::new( + "services:\n web:\n image: alpine:3.21\n environment: [TOKEN]\n other:\n image: alpine:3.21\n command: [echo, '${TOKEN:-unmanaged-here}']\n", + ); + let review = fixture.plan(); + let managed = BTreeMap::from([( + "web".to_owned(), + BTreeMap::from([("TOKEN".to_owned(), canary.to_owned())]), + )]); + let compiled = inputs::compile_scoped( + &fixture.candidate, + fixture.options(&[]), + &review.plan_id, + &BTreeMap::new(), + &managed, + ) + .unwrap(); + assert_eq!( + compiled.executable.services["other"] + .command + .as_ref() + .unwrap(), + &["echo", "unmanaged-here"] + ); +} diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 4028bef1d..225bc91a3 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -1825,7 +1825,9 @@ fn scoped_environment_cannot_interpolate_into_any_executable_field() { ) .err() .unwrap(); - assert_eq!(failure.code, "execution_environment_missing", "{field}"); + // A reference to a managed name is refused as an ownership conflict + // (it is never looked up in the public map or replaced by a default). + assert_eq!(failure.code, "execution_environment_owner", "{field}"); assert!( !serde_json::to_string(&failure) .unwrap() @@ -2093,7 +2095,9 @@ fn extra_hosts_conditional_prefix_and_environment_default_use_only_explicit_inpu } for expression in [ "${INPUT:?required}", - "${INPUT:+${OTHER}}", + // Nested braced defaults are supported (see compose_fields_contract.rs); + // nested operators outside :- and :+ stay refused. + "${INPUT:+${OTHER:?required}}", "${INPUT:-$OTHER}", "${INPUT-fallback}", ] { diff --git a/src/backends/native-project-adaptation.ts b/src/backends/native-project-adaptation.ts index b1bcc6597..00b80dbfd 100644 --- a/src/backends/native-project-adaptation.ts +++ b/src/backends/native-project-adaptation.ts @@ -136,6 +136,8 @@ function addHostnames( !( isRecord(spec.labels) && typeof spec.labels.caddy === "string" && + // Hostnames without an upstream are inert: adding more would publish nothing. + typeof spec.labels["caddy.reverse_proxy"] === "string" && Array.isArray(value) && value.length > 0 && value.length <= 8 && diff --git a/src/backends/native-runtime-metadata.ts b/src/backends/native-runtime-metadata.ts index 5b0559e38..a2148c2cb 100644 --- a/src/backends/native-runtime-metadata.ts +++ b/src/backends/native-runtime-metadata.ts @@ -55,6 +55,25 @@ function routeHosts(labels: unknown): string[] { return hosts; } +/** + * True when the labels name an upstream (`caddy.reverse_proxy`). Hostnames + * declared without one are inert in native review, as with the Compose router: + * they still feed runtime host metadata but never become a reviewed route. + */ +function proxiesUpstream(labels: unknown): boolean { + if (isRecord(labels)) { + return Object.hasOwn(labels, "caddy.reverse_proxy"); + } + return ( + Array.isArray(labels) && + labels.some( + (entry) => + typeof entry === "string" && + (entry.split("=", 1)[0] ?? "").trim() === "caddy.reverse_proxy" + ) + ); +} + /** Compose interpolation treats `$` specially; `$$` is its literal escape. */ function literal(value: string): string { return value.replaceAll("$", () => "$$"); @@ -91,7 +110,7 @@ export function applyNativeRuntimeMetadata(opts: { throw refused(); } const hosts = routeHosts(service.labels); - if (hosts.length > 0) { + if (hosts.length > 0 && proxiesUpstream(service.labels)) { routes.set(name, hosts); } records.set(name, service); diff --git a/tests/native-project-adaptation.test.ts b/tests/native-project-adaptation.test.ts index c8edb9392..d0f5b754b 100644 --- a/tests/native-project-adaptation.test.ts +++ b/tests/native-project-adaptation.test.ts @@ -72,6 +72,20 @@ test("explicit native adaptation preserves original identity, private env and ex original ); }); +function expectRefused(original: NativeProjectInput, selection: unknown): void { + const bytes = original.normalizedComposeJson; + let refusal: unknown; + try { + adaptNativeProject({ input: original, selection }); + } catch (error: unknown) { + refusal = error; + } + expect(refusal).toMatchObject({ + code: "E_CONFIG_INVALID", + message: expect.stringContaining("values omitted"), + }); + expect(original.normalizedComposeJson).toBe(bytes); +} test("unsupported changes and conflicts refuse without mutating input", () => { const original = input(); const bytes = original.normalizedComposeJson; @@ -104,18 +118,19 @@ test("unsupported changes and conflicts refuse without mutating input", () => { { version: 1, additionalHostnames: { web: ["web.hack.gy"] } }, { version: 1, additionalHostnames: { web: ["*.hack.local"] } }, ]) { - let refusal: unknown; - try { - adaptNativeProject({ input: original, selection }); - } catch (error: unknown) { - refusal = error; - } - expect(refusal).toMatchObject({ - code: "E_CONFIG_INVALID", - message: expect.stringContaining("values omitted"), - }); - expect(original.normalizedComposeJson).toBe(bytes); + expectRefused(original, selection); } + // Hostnames without an upstream are inert; adding more would publish nothing. + const inert = JSON.parse(bytes); + inert.services.web.labels = { caddy: "web.hack.gy", "caddy.tls": "internal" }; + const inertInput = { + ...original, + normalizedComposeJson: JSON.stringify(inert), + }; + expectRefused(inertInput, { + version: 1, + additionalHostnames: { web: ["web.hack.local"] }, + }); const compose = JSON.parse(bytes); compose.services.web.healthcheck = { test: ["CMD", "true"] }; expect(() => diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..c0ee176af 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -1348,6 +1348,44 @@ test("readiness goals cover only services the reviewed plan marks active", () => ).toEqual([]); }); +test("an optional dependency on an inactive service adds no readiness goal", () => { + const image = `sha256:${"e".repeat(64)}`; + const specs = { + app: { + image, + depends_on: { db: { condition: "service_healthy", required: false } }, + }, + db: { + image, + profiles: ["database"], + healthcheck: { test: ["CMD", "true"] }, + }, + }; + const plan = (database: boolean) => ({ + enrollment_compatible: true, + services: { app: { active: true }, db: { active: database } }, + }); + expect( + nativeReadinessArgs({ + specs, + plan: plan(false), + initializers: new Set(), + routed: new Set(), + }) + ).toEqual(["--ready", "app=started"]); + // An active optional target is waited for like a required one. + expect( + readyGoals( + nativeReadinessArgs({ + specs, + plan: plan(true), + initializers: new Set(), + routed: new Set(), + }) + ).sort() + ).toEqual(["app=started", "db=healthy"]); +}); + async function profileStartup(profiles: readonly string[] | undefined) { const { opts } = await fixture(false); const prepare = opts.dependencies.prepare!; diff --git a/tests/native-runtime-metadata.test.ts b/tests/native-runtime-metadata.test.ts index 8504538f4..2ff7ab20b 100644 --- a/tests/native-runtime-metadata.test.ts +++ b/tests/native-runtime-metadata.test.ts @@ -289,6 +289,62 @@ test("native metadata matches the Compose backend's keys and values", () => { ]); }); +test("hostnames without an upstream keep Compose metadata but are not reviewed routes", () => { + const services = { + web: { + image: "example", + labels: { + caddy: "web.hack.local", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + }, + worker: { + image: "example", + labels: { caddy: "worker.hack.local", "caddy.tls": "internal" }, + }, + listed: { + image: "example", + labels: ["caddy=listed.hack.local", "caddy.tls=internal"], + }, + }; + const native = applyNativeRuntimeMetadata({ + input: input(services), + selection: SELECTION, + branch: null, + httpsPort: null, + }); + expect([...native.routes]).toEqual([["web", ["web.hack.local"]]]); + // Runtime host metadata still advertises inert hosts, as the Compose backend does. + const compose = buildRuntimeHostMetadataOverride({ + composeYamls: [JSON.stringify({ services })], + branch: null, + devHost: SELECTION.devHost, + aliasHost: SELECTION.aliasHost, + composeProject: SELECTION.projectName, + }); + const parsed = YAML.parse(compose ?? "") as { + services: Record }>; + }; + expect(environment(native.input, "worker")).toEqual( + parsed.services.worker?.environment ?? {} + ); + expect(environment(native.input, "worker").HACK_SERVICE_URL).toBe( + "https://worker.hack.local" + ); + // The reviewed plan has a route only for the proxied service. + verifyNativeRuntimeMetadataRoutes({ + plan: { + services: { + web: { active: true, routing: { hostnames: ["web.hack.local"] } }, + worker: { active: true, inert_route_labels: true }, + listed: { active: true, inert_route_labels: true }, + }, + }, + routes: native.routes, + }); +}); + test("reviewed routes must match the advertised hostnames for active services", () => { const routes = new Map([["web", ["b.hack", "a.hack"]]]); const plan = (hostnames: unknown, active = true) => ({ From 7c272b563086b8fff9583c6e07c8e1ce761f2c83 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:28:00 -0500 Subject: [PATCH 005/138] feat(native): add hack down --remove-data for stopped native projects Native down always retained data, and a Compose edit that changes graph resources left retained data that restore cannot adopt, with no frontend way to start fresh. `hack down --remove-data` (Compose parity: `down --volumes`) now discards it explicitly: - Native Compose-file projects only. Authored native-config projects and the Compose backend refuse the option as a usage error. - The project must be stopped; a running graph refuses and names `hack down` first. Removal runs no lifecycle hooks and never stops compute itself. - It asks once after a read-only check of the exact saved run; `--yes` skips the question and `--json` requires `--yes` (E_INTERACTIVE_REQUIRED before any runtime request otherwise). - Under the restart lock (a pending restart refuses), after the original frontend finalization, it requests `graph cleanup --remove-data`, which deletes only receipt-owned volumes behind a durable cleanup intent and releases shared dependency caches without deleting them. - The run mapping is retired only after the receipt is `removed`, every owned volume is recorded and observed absent and caches are only released. A replaced mapping is never removed. - Any failure keeps the mapping; a rerun resumes a durable cleanup intent (compute absent) or an already removed receipt without replaying deletion. Refusals carry at most a closed native code. Restore refusals that say retained data was not adopted now name the two remedies: revert the change, or run `hack down --remove-data`. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 12 +- docs/reference/cli.md | 3 +- src/backends/native-project-down.ts | 240 ++++++++++ src/backends/native-project-restore.ts | 11 +- src/commands/project-native-remove-data.ts | 59 +++ src/commands/project.ts | 72 ++- tests/native-project-remove-data.test.ts | 502 +++++++++++++++++++++ tests/native-refusal-cli.test.ts | 2 +- 8 files changed, 874 insertions(+), 27 deletions(-) create mode 100644 src/commands/project-native-remove-data.ts create mode 100644 tests/native-project-remove-data.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..4101792e0 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -830,7 +830,17 @@ resume, live inspection must still prove absent compute and present owned volume the existing restore selection pins the current boot and volume identities. Cancellation or timeout has an uncertain outcome and never replays runtime up or graph restore. It refuses an active, changed -or uncertain mapping rather than allocating a new data volume. Down hooks resolve +or uncertain mapping rather than allocating a new data volume. When a Compose edit changes +the graph's images, volumes or other resources, the retained data cannot be adopted and +`up` says so. Either revert the edit, or discard the data explicitly with +`hack down --remove-data` (Compose parity: `docker compose down --volumes`). It works only +on a stopped project: run `hack down` first. It asks for confirmation (`--yes` skips the +question; `--json` requires `--yes`), runs no lifecycle hooks, and asks the graph owner to +delete only the volumes that this run created. Shared dependency caches are released, not +deleted. The run mapping is retired only after every owned volume is confirmed absent, so +the next `up` starts a new run with new empty volumes. If removal is interrupted or +cannot be confirmed, the mapping is kept and running the same command again resumes from +the owner's durable cleanup intent. Output names no volumes or paths. Down hooks resolve fresh managed host values using the environment selection saved by `up`; hook output goes to stderr with `--json`. A before-hook failure prevents cleanup; an after-hook failure reports that graph cleanup already completed. Environment/profile/target changes and cache pruning remain unsupported. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 050e7c675..536d0bc5c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -191,7 +191,8 @@ hack down [options] | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--prune-caches` | After down, remove confirmed Compose-owned disposable cache volumes | -| `--yes` | Confirm --prune-caches without prompting | +| `--remove-data` | Native backend: delete a stopped project's owned data volumes so the next up starts fresh (asks first) | +| `--yes` | Confirm --prune-caches or --remove-data without prompting | | `--json` | Output JSON (machine-readable) | | `--recover` | Explicitly reconcile an interrupted native Compose stop while retaining data | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index 971514e04..bd41fe8be 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -13,13 +13,18 @@ import { } from "./native-project-finalization.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { recoverNativeInterruptedStartupCleanup } from "./native-project-interrupted-cleanup.ts"; +import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; import { loadNativeProjectRun, + loadNativeRestartIntent, type NativeProjectRun, type NativeProjectRunScope, + removeNativeProjectRun, + withNativeRestartLock, } from "./native-project-run.ts"; import { invokeNativeRuntime, + NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; @@ -249,3 +254,238 @@ export async function nativeDownEnvironment(opts: { ); } } + +const NATIVE_CODE = /^[a-z][a-z0-9_]{0,63}$/; + +/** Value-free: at most a closed native refusal code, never its diagnostic. */ +function removalRefused(cause?: unknown): Error { + const code = + cause instanceof NativeRuntimeRequestError && + cause.nativeCode !== undefined && + NATIVE_CODE.test(cause.nativeCode) + ? ` (${cause.nativeCode})` + : ""; + return new ClosedCliError( + `Native data removal is unconfirmed${code}; the run mapping is kept. Run \`hack down --remove-data\` again to resume from re-inspection.` + ); +} + +/** Thrown when the graph still runs: removal never stops compute itself. */ +function removalRequiresStop(): Error { + return new ClosedCliError( + "Native data removal requires a stopped project; run `hack down` first, then `hack down --remove-data`. Nothing was removed." + ); +} + +type RemovalStart = "retained" | "resume" | "removed"; + +function sameRun(receipt: Record, run: NativeProjectRun) { + return ( + receipt.run === run.run && + receipt.owner === run.owner && + receipt.namespace === run.namespace && + receipt.plan_id === run.planId && + isRecord(receipt.resources) + ); +} + +/** + * Classify the exact saved run before any removal. A retained stopped graph + * starts removal. A durable cleanup intent with all compute absent, or an + * already removed receipt, resumes an interrupted earlier removal. Anything + * else (running, starting, foreign or unreadable) refuses without effects. + */ +function removalStart(value: unknown, run: NativeProjectRun): RemovalStart { + if (confirmedNativeRetainedGraph(value, run)) { + return "retained"; + } + if ( + !( + isRecord(value) && + value.journal_incomplete === false && + isRecord(value.receipt) && + isRecord(value.observations) && + sameRun(value.receipt, run) + ) + ) { + throw removalRefused(); + } + const { receipt, observations } = value; + if (receipt.phase === "stopped-data-retained") { + // Stopped, but its resources no longer match the retained receipt. + throw removalRefused(); + } + if (receipt.phase !== "cleanup-intent" && receipt.phase !== "removed") { + throw removalRequiresStop(); + } + for (const resource of Object.values( + receipt.resources as Record + )) { + if (!isRecord(resource) || typeof resource.key !== "string") { + throw removalRefused(); + } + const observed = observations[`${resource.kind}:${resource.key}`]; + if ( + resource.kind === "container" && + !(isRecord(observed) && observed.state === "absent") + ) { + throw removalRequiresStop(); + } + } + return receipt.phase === "removed" ? "removed" : "resume"; +} + +/** + * Confirms the owner removed every graph-owned volume of the exact run: + * receipt `removed`, compute and networks absent, each owned volume recorded + * and observed absent, and each shared dependency cache only `released` + * (a graph never deletes a shared cache). + */ +function confirmRemoved(value: unknown, run: NativeProjectRun): void { + if ( + !( + isRecord(value) && + value.journal_incomplete === false && + isRecord(value.receipt) && + isRecord(value.observations) && + sameRun(value.receipt, run) && + value.receipt.phase === "removed" + ) + ) { + throw removalRefused(); + } + const { observations } = value; + for (const resource of Object.values( + value.receipt.resources as Record + )) { + if (!isRecord(resource) || typeof resource.key !== "string") { + throw removalRefused(); + } + const observed = observations[`${resource.kind}:${resource.key}`]; + const absent = isRecord(observed) && observed.state === "absent"; + if (resource.kind === "volume" && resource.cache !== undefined) { + if (resource.phase !== "released") { + throw removalRefused(); + } + continue; + } + if ( + !["container", "network", "volume"].includes(String(resource.kind)) || + resource.phase !== "absent" || + !absent + ) { + throw removalRefused(); + } + } +} + +/** + * Explicit retained-data removal for a stopped native Compose-file project + * (Compose parity: `down --volumes`). It never stops compute or runs hooks: + * a running graph refuses, so `hack down` must stop it first. Under the + * restart lock and after the original frontend's finalization, it asks the + * graph owner for `graph cleanup --remove-data`, which deletes only + * receipt-owned volumes behind a durable cleanup intent and releases shared + * dependency caches without deleting them. The run mapping is retired only + * after every owned volume is confirmed absent, so the next `up` starts a + * fresh run with new empty volumes. + * + * Failure keeps the mapping. A rerun resumes from re-inspection: a durable + * cleanup intent or an already removed receipt continues without replaying + * deletion decisions. Cancellation before the runtime request has no effect; + * after it the outcome is unknown and the mapping stays until a rerun + * confirms removal. + */ +export async function nativeProjectRemoveData(opts: { + readonly runtime: NativeRuntimeSelection; + readonly scope: NativeProjectRunScope; + /** + * Asked once, after a read-only check that the exact saved run is stopped + * and before any lock or effect. `false` keeps everything. + */ + readonly confirm?: () => Promise; + readonly invoke?: typeof invokeNativeRuntime; + readonly finalizationTimeoutMs?: number; +}) { + const invoke = opts.invoke ?? invokeNativeRuntime; + const inspect = (run: NativeProjectRun) => + inspectNativeProjectGraph({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + run: run.run, + invoke, + }); + const notStarted = { + backend: "native", + status: "not_started", + dataPreserved: true, + } as const; + const selected = await loadNativeProjectRun(opts.scope); + if (!selected) { + return notStarted; + } + removalStart(await inspect(selected), selected); + if (opts.confirm && !(await opts.confirm())) { + return { + backend: "native", + status: "kept", + run: selected.run, + dataPreserved: true, + } as const; + } + return await withNativeRestartLock(opts.scope, async () => { + if (await loadNativeRestartIntent(opts.scope)) { + throw new ClosedCliError( + "Native data removal refused: a native restart is pending; finish or recover it first. Nothing was removed." + ); + } + // The confirmed run must still be the saved one; never remove a replacement. + const run = await loadNativeProjectRun(opts.scope); + if (!run) { + return notStarted; + } + if (!isDeepStrictEqual(run, selected)) { + throw removalRefused(); + } + const start = removalStart(await inspect(run), run); + const finalization = await captureFinalization({ scope: opts.scope, run }); + try { + await waitNativeProjectFinalization({ + scope: opts.scope, + run, + token: finalization, + timeoutMs: opts.finalizationTimeoutMs, + }); + } catch { + throw finalizationRefused(); + } + try { + if (start !== "removed") { + await invoke({ + runtime: opts.runtime, + cwd: opts.scope.projectRoot, + args: [ + "graph", + "cleanup", + "--run-id", + run.run, + "--remove-data", + "--json", + ], + timeoutMs: 590_000, + }); + } + confirmRemoved(await inspect(run), run); + await removeNativeProjectRun({ ...opts.scope, expected: run }); + } catch (error) { + // Native diagnostics may carry private values; the mapping stays for a rerun. + throw removalRefused(error); + } + return { + backend: "native", + status: "removed", + run: run.run, + dataPreserved: false, + } as const; + }); +} diff --git a/src/backends/native-project-restore.ts b/src/backends/native-project-restore.ts index 67adc971c..42d532b70 100644 --- a/src/backends/native-project-restore.ts +++ b/src/backends/native-project-restore.ts @@ -10,6 +10,9 @@ import { } from "./native-runtime-client.ts"; const SHA = /^[a-f0-9]{64}$/; +/** Value-free refusal tail naming the two explicit remedies for a changed plan. */ +const NOT_ADOPTED = + "retained data was not adopted. Revert the change to keep that data, or run `hack down --remove-data` to discard it and start fresh."; /** Recheck native authority without deriving or translating any ownership identity. */ export function validateNativeRestoreSelection(opts: { readonly selected: unknown; @@ -28,7 +31,7 @@ export function validateNativeRestoreSelection(opts: { !SHA.test(selected.generation) ) { throw new ClosedCliError( - "Native restore selection changed; retained data was not adopted." + `Native restore selection changed; ${NOT_ADOPTED}` ); } return { generation: selected.generation }; @@ -82,13 +85,13 @@ export async function verifyNativeSourceCompatibility(opts: { ) ) { throw new ClosedCliError( - "Native shared-source compatibility changed; retained data was not adopted." + `Native shared-source compatibility changed; ${NOT_ADOPTED}` ); } const cached = nativeCacheInitializers(opts.review.report.plan).size > 0; if ((result.source_revision === null) !== !cached) { throw new ClosedCliError( - "Native shared-source compatibility changed; retained data was not adopted." + `Native shared-source compatibility changed; ${NOT_ADOPTED}` ); } return result.source_revision; @@ -129,7 +132,7 @@ export async function selectNativeProjectRestore(opts: { opts.review.retainedGeneration !== selection.generation ) { throw new ClosedCliError( - "Native restore selection changed; retained data was not adopted." + `Native restore selection changed; ${NOT_ADOPTED}` ); } const sourceRevision = nativeReviewNeedsCompatibility({ diff --git a/src/commands/project-native-remove-data.ts b/src/commands/project-native-remove-data.ts new file mode 100644 index 000000000..a7698e6cb --- /dev/null +++ b/src/commands/project-native-remove-data.ts @@ -0,0 +1,59 @@ +import { nativeProjectRemoveData } from "../backends/native-project-down.ts"; +import type { NativeProjectRunScope } from "../backends/native-project-run.ts"; +import type { NativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import { HackCliError } from "../lib/cli-result.ts"; +import { confirmSafe } from "../lib/interactivity.ts"; +import { logger } from "../ui/logger.ts"; + +const CONFIRM_HINT = + "Re-run with --remove-data --yes to confirm non-interactively."; + +/** + * `hack down --remove-data` on the native backend: explicit, confirmed + * deletion of a stopped project's graph-owned data volumes. Asks once unless + * `--yes` is given; `--json` without `--yes` refuses with + * `E_INTERACTIVE_REQUIRED` before any runtime request. Output names no + * volume, path or other authored value. + */ +export async function runNativeDownRemoveData(opts: { + readonly runtime: NativeRuntimeSelection; + readonly scope: NativeProjectRunScope; + readonly yes: boolean; + readonly json: boolean; + readonly remove?: typeof nativeProjectRemoveData; +}): Promise { + if (opts.json && !opts.yes) { + throw new HackCliError({ + code: "E_INTERACTIVE_REQUIRED", + message: `Removing native project data requires confirmation. ${CONFIRM_HINT}`, + }); + } + const result = await (opts.remove ?? nativeProjectRemoveData)({ + runtime: opts.runtime, + scope: opts.scope, + ...(opts.yes + ? {} + : { + confirm: () => + confirmSafe({ + message: + "Delete this project's stopped native data volumes? This cannot be undone; shared dependency caches are kept.", + initialValue: false, + nonInteractive: "fail", + hint: CONFIRM_HINT, + }), + }), + }); + if (opts.json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return 0; + } + const messages = { + removed: + "Native project data removed; the next `hack up` starts with new empty volumes.", + kept: "Nothing was removed; native project data is retained.", + not_started: "Native project is not started; there is no retained data.", + } as const; + logger.info({ message: messages[result.status] }); + return 0; +} diff --git a/src/commands/project.ts b/src/commands/project.ts index ff71f6a36..6f75ddffe 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -354,6 +354,7 @@ import { reconcileImplicitDownBranch, } from "./project-down-safety.ts"; import { runNativeProjectExec } from "./project-exec-native.ts"; +import { runNativeDownRemoveData } from "./project-native-remove-data.ts"; /** Regex for valid TLD/service/subdomain labels (lowercase alphanumeric with hyphens). */ const SLUG_LABEL_PATTERN = /^[a-z0-9][a-z0-9-]*$/; @@ -455,7 +456,7 @@ const optYes = defineOption({ name: "yes", type: "boolean", long: "--yes", - description: "Confirm --prune-caches without prompting", + description: "Confirm --prune-caches or --remove-data without prompting", } as const); const optRecoverFrontend = defineOption({ name: "recoverFrontend", @@ -509,6 +510,13 @@ const downOptions = [ optProfile, optTarget, optPruneCaches, + defineOption({ + name: "removeData", + type: "boolean", + long: "--remove-data", + description: + "Native backend: delete a stopped project's owned data volumes so the next up starts fresh (asks first)", + } as const), optYes, optJson, defineOption({ @@ -5971,6 +5979,7 @@ async function restartNativeImageProject({ target: undefined, json: false, pruneCaches: false, + removeData: false, yes: false, recoverNativeCompose: false, }, @@ -6616,6 +6625,26 @@ function refuseLegacyNativeRecovery(recover: boolean): void { } } +/** Native down accepts --yes only to confirm --remove-data. */ +function refuseNativeDownOptions(options: DownArgs["options"]): void { + const removeData = options.removeData === true; + if ( + [ + options.env, + options.profile, + options.target, + options.pruneCaches, + !removeData && options.yes, + ].some(Boolean) + ) { + throw new CliUsageError( + removeData + ? "Native down --remove-data does not support --env, --profile, --target or --prune-caches." + : "Native down preserves data and does not support --env, --profile, --target, --prune-caches or --yes (--yes confirms --remove-data)." + ); + } +} + async function handleDown({ ctx, args, @@ -6643,6 +6672,7 @@ async function handleDown({ unsupportedOptions: [ args.options.target, args.options.pruneCaches, + args.options.removeData, args.options.yes, ].some(Boolean), }); @@ -6655,19 +6685,7 @@ async function handleDown({ if (args.options.json) { setLoggerBackendOverride({ backend: "console" }); } - if ( - [ - args.options.env, - args.options.profile, - args.options.target, - args.options.pruneCaches, - args.options.yes, - ].some(Boolean) - ) { - throw new CliUsageError( - "Native down preserves data and does not support --env, --profile, --target, --prune-caches or --yes." - ); - } + refuseNativeDownOptions(args.options); const project = await resolveProjectForArgs({ ctx, pathOpt: args.options.path, @@ -6679,18 +6697,27 @@ async function handleDown({ branchOption: args.options.branch, noticeToStderr: args.options.json === true, }); - const cfg = await readProjectConfig(project); - if (cfg.parseError) { - throw new CliUsageError( - "Native down requires valid lifecycle configuration; values omitted." - ); - } const scope = { projectRoot: project.projectRoot, projectDir: project.projectDir, nativeHome: native.home, branch, }; + if (args.options.removeData) { + // Removal never stops compute or runs hooks; a running project refuses. + return await runNativeDownRemoveData({ + runtime: native, + scope, + yes: args.options.yes === true, + json: args.options.json === true, + }); + } + const cfg = await readProjectConfig(project); + if (cfg.parseError) { + throw new CliUsageError( + "Native down requires valid lifecycle configuration; values omitted." + ); + } let lifecycleEnv: Readonly> = {}; const hasHooks = Boolean( cfg.lifecycle?.down?.before?.length || cfg.lifecycle?.down?.after?.length @@ -6817,6 +6844,11 @@ async function runDownCommand({ noticeToStderr: json, }); const profiles = parseCsvList(args.options.profile); + if (args.options.removeData) { + throw new CliUsageError( + "--remove-data applies to the native backend only; Compose-backend down keeps named volumes." + ); + } const pruneCaches = args.options.pruneCaches === true; const confirmCachePrune = args.options.yes === true; if (confirmCachePrune && !pruneCaches) { diff --git a/tests/native-project-remove-data.test.ts b/tests/native-project-remove-data.test.ts new file mode 100644 index 000000000..517244181 --- /dev/null +++ b/tests/native-project-remove-data.test.ts @@ -0,0 +1,502 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { nativeProjectRemoveData } from "../src/backends/native-project-down.ts"; +import { + beginNativeProjectFinalization, + captureNativeProjectFinalization, +} from "../src/backends/native-project-finalization.ts"; +import { + loadNativeProjectRun, + removeNativeProjectRun, + saveNativeProjectRun, + saveNativeRestartIntent, +} from "../src/backends/native-project-run.ts"; +import { + type invokeNativeRuntime, + NativeRuntimeRequestError, +} from "../src/backends/native-runtime-client.ts"; +import { runNativeDownRemoveData } from "../src/commands/project-native-remove-data.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +const run = { + run: "a".repeat(32), + owner: "b".repeat(32), + namespace: "c".repeat(64), + planId: "d".repeat(64), +}; +const id = "e".repeat(64); +const cache = { + scope: "1".repeat(64), + fingerprint: "2".repeat(64), + image: `sha256:${"3".repeat(64)}`, +}; + +type Phase = + | "ready-observed" + | "stopped-data-retained" + | "cleanup-intent" + | "removed"; + +/** + * A synthetic `graph inspect` reply: one service, one owned data volume and + * one shared dependency cache. `remaining` keeps the owned volume visible. + */ +function graph(phase: Phase, opts: { readonly remaining?: boolean } = {}) { + const removed = phase === "removed"; + const running = phase === "ready-observed"; + return { + receipt: { + run: run.run, + owner: run.owner, + namespace: run.namespace, + plan_id: run.planId, + phase, + resources: { + "container:db": { + kind: "container", + key: "db", + id, + phase: running ? "started" : "absent", + }, + "volume:data": { + kind: "volume", + key: "data", + id: null, + phase: removed ? "absent" : "created", + }, + "volume:deps": { + kind: "volume", + key: "deps", + id: null, + cache, + phase: removed ? "released" : "created", + }, + }, + }, + journal_incomplete: false, + observations: { + "container:db": { state: running ? "running" : "absent" }, + "volume:data": { + state: removed && !opts.remaining ? "absent" : "present", + }, + "volume:deps": { state: "present" }, + }, + }; +} + +async function fixture(saved = true) { + const projectRoot = await realpath( + await mkdtemp(join(tmpdir(), "native-remove-data-")) + ); + roots.push(projectRoot); + const projectDir = join(projectRoot, ".hack"), + nativeHome = join(projectRoot, "candidate"); + await mkdir(projectDir); + await mkdir(nativeHome, { mode: 0o700 }); + const scope = { projectRoot, projectDir, nativeHome, branch: null }; + if (saved) { + await saveNativeProjectRun({ ...scope, run }); + } + const finalization = await beginNativeProjectFinalization({ scope, run }); + await finalization.complete(); + return { scope, runtime: { binary: "/not-invoked", home: nativeHome } }; +} + +/** Scripted runtime: inspect replies follow `phases`; cleanup may fail. */ +function runtime(opts: { + readonly phases: readonly Phase[]; + readonly cleanupFails?: boolean; + readonly remaining?: boolean; +}) { + const calls: string[][] = []; + const phases = [...opts.phases]; + const invoke = (async (request) => { + calls.push([...request.args]); + if (request.args[1] === "cleanup") { + if (opts.cleanupFails) { + throw new NativeRuntimeRequestError({ + message: "synthetic-private-diagnostic /secret/path", + nativeCode: "graph_cleanup_uncertain", + }); + } + return {}; + } + const phase = phases.length > 1 ? phases.shift()! : phases[0]!; + return graph(phase, { remaining: opts.remaining }); + }) as typeof invokeNativeRuntime; + const verbs = () => calls.map((args) => args[1]); + return { calls, invoke, verbs }; +} + +test("no saved run reports not started and requests nothing", async () => { + const opts = await fixture(false); + const r = runtime({ phases: ["stopped-data-retained"] }); + expect(await nativeProjectRemoveData({ ...opts, invoke: r.invoke })).toEqual({ + backend: "native", + status: "not_started", + dataPreserved: true, + }); + expect(r.calls).toEqual([]); +}); + +test("a running project refuses before confirmation or cleanup", async () => { + const opts = await fixture(); + const r = runtime({ phases: ["ready-observed"] }); + let asked = false; + await expectClosedRefusal( + nativeProjectRemoveData({ + ...opts, + invoke: r.invoke, + confirm: async () => { + asked = true; + return true; + }, + }), + "requires a stopped project; run `hack down` first" + ); + expect(asked).toBe(false); + expect(r.verbs()).toEqual(["inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("a declined confirmation keeps the data and the mapping", async () => { + const opts = await fixture(); + const r = runtime({ phases: ["stopped-data-retained"] }); + const result = await nativeProjectRemoveData({ + ...opts, + invoke: r.invoke, + confirm: async () => false, + }); + expect(result).toEqual({ + backend: "native", + status: "kept", + run: run.run, + dataPreserved: true, + }); + expect(r.verbs()).toEqual(["inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("a stopped project removes owned data, keeps caches and retires the mapping last", async () => { + const opts = await fixture(); + const r = runtime({ + phases: ["stopped-data-retained", "stopped-data-retained", "removed"], + }); + const result = await nativeProjectRemoveData({ ...opts, invoke: r.invoke }); + expect(result).toEqual({ + backend: "native", + status: "removed", + run: run.run, + dataPreserved: false, + }); + expect(r.calls).toEqual([ + ["graph", "inspect", "--run-id", run.run, "--json"], + ["graph", "inspect", "--run-id", run.run, "--json"], + ["graph", "cleanup", "--run-id", run.run, "--remove-data", "--json"], + ["graph", "inspect", "--run-id", run.run, "--json"], + ]); + expect(await loadNativeProjectRun(opts.scope)).toBeNull(); +}); + +test("a failed cleanup keeps the mapping and a rerun resumes the durable intent", async () => { + const opts = await fixture(); + const failed = runtime({ + phases: ["stopped-data-retained"], + cleanupFails: true, + }); + const refusal = await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke: failed.invoke }), + "Native data removal is unconfirmed (graph_cleanup_uncertain); the run mapping is kept" + ); + expect(refusal.message).not.toContain("secret"); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); + const resumed = runtime({ + phases: ["cleanup-intent", "cleanup-intent", "removed"], + }); + expect( + (await nativeProjectRemoveData({ ...opts, invoke: resumed.invoke })).status + ).toBe("removed"); + expect(resumed.verbs()).toEqual(["inspect", "inspect", "cleanup", "inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toBeNull(); +}); + +test("an already removed receipt retires the mapping without replaying cleanup", async () => { + const opts = await fixture(); + const r = runtime({ phases: ["removed"] }); + expect( + (await nativeProjectRemoveData({ ...opts, invoke: r.invoke })).status + ).toBe("removed"); + expect(r.verbs()).toEqual(["inspect", "inspect", "inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toBeNull(); +}); + +test("a volume that remains visible after cleanup keeps the mapping", async () => { + const opts = await fixture(); + const r = runtime({ + phases: ["stopped-data-retained", "stopped-data-retained", "removed"], + remaining: true, + }); + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke: r.invoke }), + "Native data removal is unconfirmed" + ); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("a shared cache reported deleted instead of released keeps the mapping", async () => { + const opts = await fixture(); + const removed = graph("removed"); + removed.receipt.resources["volume:deps"].phase = "absent"; + const replies: unknown[] = [ + graph("stopped-data-retained"), + graph("stopped-data-retained"), + removed, + ]; + const invoke = (async (request) => + request.args[1] === "cleanup" + ? {} + : replies.shift()) as typeof invokeNativeRuntime; + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke }), + "Native data removal is unconfirmed" + ); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("a stopped receipt whose volume is already missing refuses without cleanup", async () => { + const opts = await fixture(); + const stopped = graph("stopped-data-retained"); + stopped.observations["volume:data"] = { state: "absent" }; + const calls: string[] = []; + const invoke = (async (request) => { + calls.push(request.args[1]!); + return stopped; + }) as typeof invokeNativeRuntime; + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke }), + "Native data removal is unconfirmed; the run mapping is kept" + ); + expect(calls).toEqual(["inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("cleanup intent with compute still present refuses as running", async () => { + const opts = await fixture(); + const intent = graph("cleanup-intent"); + intent.observations["container:db"] = { state: "running" }; + const invoke = (async () => intent) as unknown as typeof invokeNativeRuntime; + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke }), + "requires a stopped project" + ); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("a foreign or malformed receipt refuses without cleanup", async () => { + const opts = await fixture(); + for (const reply of [ + null, + { ...graph("stopped-data-retained"), journal_incomplete: true }, + { + ...graph("removed"), + receipt: { ...graph("removed").receipt, owner: "f".repeat(32) }, + }, + ]) { + const calls: string[] = []; + const invoke = (async (request) => { + calls.push(request.args[1]!); + return reply; + }) as typeof invokeNativeRuntime; + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke }), + "Native data removal is unconfirmed" + ); + expect(calls).toEqual(["inspect"]); + } + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("a pending restart refuses under the restart lock", async () => { + const opts = await fixture(); + const finalization = await captureNativeProjectFinalization({ + scope: opts.scope, + run, + }); + await saveNativeRestartIntent({ + ...opts.scope, + intent: { phase: "prepared", run, finalization }, + }); + const r = runtime({ phases: ["stopped-data-retained"] }); + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke: r.invoke }), + "a native restart is pending" + ); + expect(r.verbs()).toEqual(["inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("a mapping replaced while confirming is never removed", async () => { + const opts = await fixture(); + const replacement = { ...run, run: "9".repeat(32) }; + const r = runtime({ phases: ["stopped-data-retained"] }); + await expectClosedRefusal( + nativeProjectRemoveData({ + ...opts, + invoke: r.invoke, + confirm: async () => { + await removeNativeProjectRun({ ...opts.scope, expected: run }); + await saveNativeProjectRun({ ...opts.scope, run: replacement }); + return true; + }, + }), + "Native data removal is unconfirmed" + ); + expect(r.verbs()).toEqual(["inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toEqual(replacement); +}); + +test("the command refuses --json without --yes before any runtime request", async () => { + let called = false; + const error = await runNativeDownRemoveData({ + runtime: { binary: "/not-invoked", home: "/not-used" }, + scope: { + projectRoot: "/not-used", + projectDir: "/not-used/.hack", + nativeHome: "/not-used", + branch: null, + }, + yes: false, + json: true, + remove: (async () => { + called = true; + throw new Error("unreachable"); + }) as typeof nativeProjectRemoveData, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toMatchObject({ code: "E_INTERACTIVE_REQUIRED" }); + expect(called).toBe(false); +}); + +const entrypoint = resolve("index.ts"); +const SPAWN_TIMEOUT_MS = 60_000; + +async function cliFixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-remove-data-cli-")) + ); + roots.push(root); + const project = join(root, "project"); + await mkdir(join(project, ".hack"), { recursive: true }); + await writeFile( + join(project, ".hack", "docker-compose.yml"), + JSON.stringify({ services: { db: { image: `sha256:${id}` } } }) + ); + await writeFile( + join(project, ".hack", "hack.config.json"), + JSON.stringify({ name: "remove-data-fixture" }) + ); + const nativeHome = join(root, "native-home"); + await mkdir(nativeHome, { mode: 0o700 }); + const calls = join(root, "calls"); + const binary = join(root, "native-runtime"); + await writeFile( + binary, + `#!/bin/sh +printf '%s\\n' "$*" >> '${calls}' +printf '%s' '{"code":"runtime_not_running"}' >&2 +exit 1 +`, + { mode: 0o700 } + ); + const cli = async (args: readonly string[], backend: string) => { + const child = Bun.spawn( + [process.execPath, entrypoint, "--path", project, ...args], + { + cwd: root, + env: privateCliEnv({ + root, + env: { + HACK_RUNTIME_BACKEND: backend, + HACK_NATIVE_BINARY: binary, + HACK_NATIVE_HOME: nativeHome, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT_MS - 5000, + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + const requested = await Bun.file(calls) + .text() + .catch(() => ""); + return { code, output: stdout + stderr, requested }; + }; + return { cli }; +} + +test( + "native down --remove-data --json needs --yes and an unstarted project requests nothing", + async () => { + const f = await cliFixture(); + const json = await f.cli(["down", "--remove-data", "--json"], "native"); + expect(json.code).toBe(1); + expect(json.output).toContain("E_INTERACTIVE_REQUIRED"); + expect(json.output).toContain("--remove-data --yes"); + // Without a saved run there is nothing to confirm or remove. + for (const args of [ + ["down", "--remove-data"], + ["down", "--remove-data", "--yes"], + ]) { + const result = await f.cli(args, "native"); + expect(result.code).toBe(0); + expect(result.output).toContain("Native project is not started"); + expect(result.requested).toBe(""); + } + }, + SPAWN_TIMEOUT_MS * 2 +); + +test( + "--remove-data combinations outside native removal are usage errors", + async () => { + const f = await cliFixture(); + const pruning = await f.cli( + ["down", "--remove-data", "--prune-caches", "--yes"], + "native" + ); + expect(pruning.code).not.toBe(0); + expect(pruning.output).toContain( + "Native down --remove-data does not support" + ); + const yesOnly = await f.cli(["down", "--yes"], "native"); + expect(yesOnly.code).not.toBe(0); + expect(yesOnly.output).toContain("--yes confirms --remove-data"); + const compose = await f.cli(["down", "--remove-data", "--yes"], "compose"); + expect(compose.code).not.toBe(0); + expect(compose.output).toContain( + "--remove-data applies to the native backend only" + ); + expect(compose.requested).toBe(""); + }, + SPAWN_TIMEOUT_MS * 2 +); diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts index a89d07381..70fa84f2d 100644 --- a/tests/native-refusal-cli.test.ts +++ b/tests/native-refusal-cli.test.ts @@ -385,7 +385,7 @@ test( const result = await cli({ fixture: f, args: ["up"], env: SHARED }); expectOneClosedLine( result, - "Native restore selection changed; retained data was not adopted." + "Native restore selection changed; retained data was not adopted. Revert the change to keep that data, or run `hack down --remove-data` to discard it and start fresh." ); expect((await f.calls()).at(-1)).toBe("graph restore-selection"); }, From 2fb711a21b3d1f7d56ed62afd27b93de15bb7c67 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:30:25 -0500 Subject: [PATCH 006/138] feat(native): read project adaptation and machine settings from files Native image-project up and restart now resolve every HACK_NATIVE_* selection from three places, once per command: - the committed .hack/native/adaptation.json, plus optional adaptation..json, holding the existing closed adaptation schema; - a per-machine settings file outside every project share, ${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json, for the share grant, AWS profile, host dependency files, HTTPS, prepared base and approved hosts (closed v1 schema, 0700 directory, 0600 owner-only regular file, refused inside the project root); - the existing variables, which still win per setting, whole-value. Per-env files are picked by the run's effective env inside native start and restart preflight, so fresh, retained and restarted runs select the same files. Without --env the Compose-backend pre-check counts a network as isolated only when every candidate adaptation isolates it. Native doctor adds a value-free "native settings" check: the origin of each setting (env, machine, project, unset) and closed warnings for configuration native does not apply (internal DNS/TLS, internal extra_hosts, routes without probes, ambient COMPOSE_PROFILES, an AWS region without a profile, unread .hack/native files, missing share grant). Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 8 + docs/guides/candidate-install.md | 4 + docs/guides/native-candidate.md | 136 +++- .../native-project-restart-preflight.ts | 31 +- src/backends/native-project-selection.ts | 42 +- src/backends/native-project-settings.ts | 620 ++++++++++++++++++ src/backends/native-project-start.ts | 33 +- src/commands/doctor.ts | 65 +- src/commands/project.ts | 61 +- src/lib/doctor-native-settings.ts | 297 +++++++++ src/lib/native-compose-backend-features.ts | 35 +- tests/native-project-restart.test.ts | 60 ++ tests/native-project-settings-cli.test.ts | 344 ++++++++++ tests/native-project-settings.test.ts | 471 +++++++++++++ tests/native-project-start.test.ts | 55 ++ 15 files changed, 2206 insertions(+), 56 deletions(-) create mode 100644 src/backends/native-project-settings.ts create mode 100644 src/lib/doctor-native-settings.ts create mode 100644 tests/native-project-settings-cli.test.ts create mode 100644 tests/native-project-settings.test.ts diff --git a/docs/cli.md b/docs/cli.md index fa60c1d5b..1d92665b0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,6 +36,14 @@ to compare a manually observed browser failure with a verified CLI HTTPS request See [macOS browser connectivity](guides/macos-browser-network.md) for supported observations, Local Network permission guidance and the required browser recheck. +On the native backend, `hack doctor` adds a `native settings` check. It reports where each native +selection comes from (`env`, `machine`, `project` or `unset`) without its value, and warns about +project configuration that native `up` refuses or does not apply. The committed +`.hack/native/adaptation.json` and the per-machine +`${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json` replace exported +`HACK_NATIVE_*` variables; a variable that is set still wins. See +[Native settings files](guides/native-candidate.md#native-settings-files). + Project names in the registry and `--project`/MCP selectors use the same lowercase, hyphenated key: for example, `my_app` selects `my-app`. Existing registry spellings remain readable aliases; a later registration of that checkout canonicalizes the diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 4a4f6f27c..5590c3449 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -51,6 +51,10 @@ verified candidate on the Compose backend, set `HACK_RUNTIME_BACKEND=compose`; s [Test both backends](#test-both-backends). Any other non-empty value refuses before the candidate starts. Explicit native project adaptation, dependency, AWS profile, routing, and source-sharing settings still reach the CLI. +So do the committed `.hack/native/adaptation.json` and the per-machine +`~/.config/hack/native//settings.json` +([native settings files](native-candidate.md#native-settings-files)), which do not +depend on the installed version. Arguments, standard input and output, exit codes, and signals reach the selected candidate. Concurrent observation and stop commands can accompany a foreground candidate command; selecting another bundle refuses while any launcher is active. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..f42876b90 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -189,7 +189,7 @@ Compose backend. Native support for them is planned after v5. | Compose feature | Kept on the Compose backend when | | --- | --- | -| External network | A top-level network sets `external: true` or `external: {name: ...}`. A reviewed `x-hack-isolated: true` replacement is not external: it creates an owned internal bridge. A network that the `HACK_NATIVE_ADAPTATION` selection (see [Frontend integration status](#frontend-integration-status)) lists in `isolatedNetworks` gets the same replacement, so it is not external either. | +| External network | A top-level network sets `external: true` or `external: {name: ...}`. A reviewed `x-hack-isolated: true` replacement is not external: it creates an owned internal bridge. A network that the selected adaptation (`HACK_NATIVE_ADAPTATION` or the committed `.hack/native/adaptation.json`; see [Native settings files](#native-settings-files)) lists in `isolatedNetworks` gets the same replacement, so it is not external either. | | External volume | A top-level volume sets `external: true` or `external: {name: ...}`. The native runtime only creates volumes it owns, and there is no native replacement for an external volume. | | Reserved ownership label | A service, network or volume label key starts with `com.docker.compose.`, `io.hack-local.`, `io.hack.native-config.`, `io.hack.native-file.` or `io.hack.storage-witness.`. Compose, the native runtime and native-config Compose use these labels to prove which resources they own. | | Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. | @@ -208,8 +208,11 @@ defaults such as `DOCKER_DEFAULT_PLATFORM`: a platform counts only when a servic sets its `platform` field. The native runtime's own review reports `extends` and `include` as unsupported fields. -The check applies the explicit `HACK_NATIVE_ADAPTATION` selection first, as native -start does. An unchanged Compose file whose external network is listed in +The check applies the selected adaptation first, as native start does: +`HACK_NATIVE_ADAPTATION`, else the committed `.hack/native/adaptation.json` or the +`adaptation..json` for `--env `. Without `--env`, a network counts as +isolated only when every committed adaptation that could apply isolates it, because +the effective env is not known yet. An unchanged Compose file whose external network is listed in `isolatedNetworks` therefore stays on native, and native start replaces that network with an owned isolated one. The selection is read only when the Compose file has an external network. If it cannot be read, is not version 1, or its @@ -237,7 +240,7 @@ authored native format. lists the closed names `external_network`, `external_volume`, `reserved_label`, `unsupported_build` and `foreign_platform`. On the native backend a found feature is a warning; on the Compose backend it is informational. Native doctor applies -the same `HACK_NATIVE_ADAPTATION` selection before it reports. +the same adaptation selection as native `up` without `--env` before it reports. Publication control tokens are delivered to the relay through a one-shot inherited pipe (`--control-fd 0`), never through process arguments. The relay reads exactly @@ -759,7 +762,8 @@ fresh ownership and runtime proof, even if containers still appear running. Unconfirmed ownership or an incomplete refresh requires inspection and explicit retaining recovery; command requests are never replayed. Foreground whole-project `hack up` additionally requires -`HACK_NATIVE_SHARED_SOURCE=1`: it exposes the exact +`HACK_NATIVE_SHARED_SOURCE=1` or `"sharedSource": true` in the +[machine settings](#native-settings-files): it exposes the exact project tree, including ignored files, with each mount's declared write mode. Use an explicitly prepared candidate home and the binary from a complete native bundle. It acquires public images, privately supplies managed environment values, runs @@ -1124,8 +1128,9 @@ identity and no listener. Torn records and sockets without complete ownership evidence remain unavailable; do not delete them to force allocation. Recovery of an already enrolled graph requires verified graph cleanup instead. -`HACK_NATIVE_DEPENDENCIES` selects an absolute path to a regular JSON file with -explicit host listeners. The CLI reads it after lifecycle hooks, so a hook can +`HACK_NATIVE_DEPENDENCIES`, or `dependencies` in the +[machine settings](#native-settings-files), selects a regular JSON file with +explicit host listeners; the variable takes an absolute path. The CLI reads it after lifecycle hooks, so a hook can prepare current listeners before selection. For example: ```json @@ -1314,9 +1319,10 @@ authority is inferred from a saved selection file. Image-only services omit grap shared-source mode; active project bind mounts still use the explicit project share. For projects whose checked-in Compose file needs explicit native health probes, -`HACK_NATIVE_ADAPTATION` selects an absolute JSON file. This leaves the source file -unchanged and preserves its identity in native review. Only the following bounded -adaptations are accepted: +an adaptation file adds them. Commit it as `.hack/native/adaptation.json` (see +[Native settings files](#native-settings-files)), or select any absolute JSON file +with `HACK_NATIVE_ADAPTATION`. This leaves the Compose file unchanged and preserves +its identity in native review. Only the following bounded adaptations are accepted: ```json { @@ -1353,6 +1359,116 @@ The layout participates in native dependency-cache identity. This shares compati workspace installations; it does not implement a cross-project package store or claim deduplication across different lockfiles. +### Native settings files + +Every native selection above can also come from a file, so a project runs with plain +`hack up` instead of a set of exported variables. There are two kinds of file, because +they carry different authority: + +| File | Holds | Where | Commit it? | +| --- | --- | --- | --- | +| Project adaptation | The adaptation above (`isolatedNetworks`, `httpProbes`, `additionalHostnames`, `additionalHostAliases`, `workspaceCache`) | `.hack/native/adaptation.json`, and optionally `.hack/native/adaptation..json` | Yes. Every checkout and worktree shares it. | +| Machine settings | Grants and host authority: the project share, the AWS profile, host dependency files, HTTPS, prepared base and approved hosts | `${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json` | Never. It is per machine. | + +The project adaptation has the same authority as the Compose file it adapts. It grants +nothing: its schema is the closed adaptation schema above, so a grant such as +`sharedSource` in it refuses with `E_CONFIG_INVALID`. Hack reads only +`adaptation.json` and `adaptation..json` from `.hack/native/`, where `` is +a lowercase env name. `hack doctor` warns about any other file there. + +The machine settings file lives outside the project because the project tree is +shared read/write with the native VM: a container could rewrite a grant stored in the +tree before the next `up`. `` is the project name Hack uses (the Compose +`name:`, else `name` in `hack.config.json`, else the directory name), lowercased with +other characters replaced by `-`. Linked worktrees and branch instances of one project +therefore share one machine file. Create it once per machine: + +```sh +dir="${XDG_CONFIG_HOME:-$HOME/.config}/hack/native/my-app" +mkdir -p -m 700 "$dir" +chmod 700 "$dir" +touch "$dir/settings.json" && chmod 600 "$dir/settings.json" +``` + +```json +{ + "version": 1, + "sharedSource": true, + "aws": {"profile": "dev-sso", "region": "us-east-1"}, + "dependencies": "dependencies.json", + "dependenciesByEnv": {"qa": "dependencies.qa.json"}, + "https": {"caddyBinary": "/opt/caddy/bin/caddy", "caddySha256": "", "port": 443}, + "preparedBase": {"mode": "prefer"}, + "allowHosts": ["registry.npmjs.org"] +} +``` + +Every field except `version` is optional. The fields match the variables: + +| Field | Variable it stands for | +| --- | --- | +| `sharedSource` (`true` or `false`) | `HACK_NATIVE_SHARED_SOURCE=1` | +| `aws.profile`, `aws.region` | `HACK_NATIVE_AWS_PROFILE`, `HACK_NATIVE_AWS_REGION` | +| `dependencies`, `dependenciesByEnv` | `HACK_NATIVE_DEPENDENCIES` | +| `https.caddyBinary`, `https.caddySha256`, `https.port` | `HACK_NATIVE_CADDY_BINARY`, `HACK_NATIVE_CADDY_SHA256`, `HACK_NATIVE_HTTPS_PORT` | +| `preparedBase.mode` (`prefer`, `require` or `off`), `preparedBase.store` | `HACK_NATIVE_PREPARED_BASE`, `HACK_NATIVE_PREPARED_BASE_STORE` | +| `allowHosts` (a list) | `HACK_NATIVE_ALLOW_HOSTS` (a comma-separated list) | + +`dependencies` and `dependenciesByEnv` name host dependency files (the format above) +in the same directory: plain file names, never paths. The machine settings do not +select the adaptation, the native binary or the native home. A `gcloud` field or any +other field refuses. + +Hack refuses the machine file with `E_CONFIG_INVALID` before any branch, lifecycle, +storage or runtime work unless all of these hold: + +- the directory is a real directory owned by you with mode `0700`; +- the directory is outside the project root, and the project root is not inside it; +- `settings.json` is a regular file owned by you with mode `0600`, not a symbolic or + hard link, at most 64 KiB, and unchanged while it is read; +- every field is valid, including a field that a variable overrides. + +A missing directory or file means no machine settings. + +**Precedence.** For each setting, a variable that is set wins entirely, even when it +is empty, then the machine settings, then the committed project file. Values are never +merged. For example, `HACK_NATIVE_ADAPTATION` replaces the committed adaptation, and +`HACK_NATIVE_SHARED_SOURCE=0` withholds a share that the machine file grants. A +non-empty `HACK_NATIVE_AWS_PROFILE` selects the AWS variables; `HACK_NATIVE_AWS_REGION` +without it is ignored, as before. Existing scripts that export the variables keep +their exact behavior. + +**Per-env files.** `adaptation..json` and `dependenciesByEnv.` replace the +base file for runs whose effective env is ``: the `--env` you pass, the env a +retained run recorded, or the project's default env. Otherwise the base file applies. +A fresh start, a retained start and a restart of one run therefore always select the +same files. + +**Identity and recovery.** Settings resolution only reads files. It writes no state, +so Ctrl-C needs no cleanup, and `down`, `ps`, `logs`, `exec` and `run` keep using the +saved run mapping without reading the settings. A file-selected run and a +variable-selected run with the same contents review the same native plan, so +`restart` retains the run either way. + +`hack doctor` on the native backend adds a `native settings` check. It reports where +each setting came from (`env`, `machine`, `project` or `unset`), never its value, and +the machine settings path with your home shown as `~`. With `--json`, the check's +`nativeSettings` holds `origins`, `adaptationFiles`, `machineSettings` and closed +`warnings`: + +| Warning | Meaning | +| --- | --- | +| `shared_source_not_granted` | Native `up` and `restart` refuse until the project share is granted. | +| `internal_dns_tls_not_applied` | `internal.dns` and `internal.tls` in `hack.config.json` are not applied on native, so containers cannot reach routes by their HTTPS names. Set both to `false` to silence it. | +| `internal_extra_hosts_not_applied` | `internal.extra_hosts` is not applied on native. Declare `additionalHostAliases` with host dependency bindings instead. | +| `routes_without_probes` | `routesWithoutProbes` routed services have no native HTTP probe after the adaptation for the default env; native `up` refuses them. | +| `compose_profiles_ignored` | `COMPOSE_PROFILES` is set in the environment, but native selects profiles only from `--profile`. Managed `COMPOSE_PROFILES` values are not inspected. | +| `aws_region_without_profile` | `HACK_NATIVE_AWS_REGION` is set without `HACK_NATIVE_AWS_PROFILE` and is ignored. | +| `unread_project_settings_files` | `.hack/native/` holds files other than `adaptation*.json`; Hack does not read them. | + +An invalid settings file makes the check an error with the same closed line native +`up` prints. The check runs no runtime request, credential export or network call. + ### Optional prepared base for a fresh pool A prepared base formats a **new** pool's disks from an independently verified snapshot of a diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..2ffbfaa67 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -36,7 +36,10 @@ import { type NativeProjectRunScope, normalizeNativeProfiles, } from "./native-project-run.ts"; -import { readNativeSelection } from "./native-project-selection.ts"; +import { + readNativeSelection, + selectNativeEnvFile, +} from "./native-project-selection.ts"; import { nativeRuntimeMetadataSelected, prepareNativeProjectServices, @@ -321,7 +324,11 @@ export async function preflightNativeRestart(opts: { /** Explicit frontend recovery may encounter a graph stopped before an intent was saved. */ readonly recoverStopped?: boolean; readonly adaptationFile?: string; + /** Per-env adaptations; the retained run's effective env selects as start does. */ + readonly adaptationFileByEnv?: Readonly>; readonly dependencyFile?: string; + /** Per-env dependency files; the retained run's effective env selects as start does. */ + readonly dependencyFileByEnv?: Readonly>; readonly allowedHosts?: readonly string[]; /** Same public metadata selection the replacement startup will review. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; @@ -361,9 +368,23 @@ export async function preflightNativeRestart(opts: { composeFile: opts.composeFile, envName: selected.envName, }); + const adaptationFile = selectNativeEnvFile({ + selection: { + file: opts.adaptationFile, + fileByEnv: opts.adaptationFileByEnv, + }, + envName: input.effectiveEnvName, + }); + const dependencyFile = selectNativeEnvFile({ + selection: { + file: opts.dependencyFile, + fileByEnv: opts.dependencyFileByEnv, + }, + envName: input.effectiveEnvName, + }); input = await deps.adapt({ input, - path: opts.adaptationFile, + path: adaptationFile, }); const reviewedBranch = await prepareNativeReviewBranch({ runtime: opts.runtime, @@ -392,15 +413,15 @@ export async function preflightNativeRestart(opts: { input = metadata?.input ?? input; const specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + dependencyFile !== undefined ); const dependencies = stoppedRetry ? await readNativeRestartDependencyIntent({ - path: opts.dependencyFile, + path: dependencyFile, services: Object.keys(specs), }) : await deps.dependencies({ - path: opts.dependencyFile, + path: dependencyFile, services: Object.keys(specs), discover: (selection) => discoverNativeHostDependency({ diff --git a/src/backends/native-project-selection.ts b/src/backends/native-project-selection.ts index 2cc8cf077..07fff2e87 100644 --- a/src/backends/native-project-selection.ts +++ b/src/backends/native-project-selection.ts @@ -11,8 +11,16 @@ function refused(): Error { "Native selection requires a bounded, unchanged regular JSON file; values omitted.", }); } -/** Selection files contain public intent, never credentials or durable process authority. */ -export async function readNativeSelection(path: string): Promise { +/** + * Selection files contain public intent, never credentials or durable process authority. + * + * `privateToUid` additionally requires the file to be owned by that user with no group + * or other permission bits, as per-machine native settings are. + */ +export async function readNativeSelection( + path: string, + opts?: { readonly privateToUid?: number } +): Promise { if (!isAbsolute(path)) { throw refused(); } @@ -27,7 +35,9 @@ export async function readNativeSelection(path: string): Promise { !before.isFile() || before.nlink !== 1 || before.size === 0 || - before.size > LIMIT + before.size > LIMIT || + (opts?.privateToUid !== undefined && + (before.uid !== opts.privateToUid || (before.mode & 0o077) !== 0)) ) { throw refused(); } @@ -54,3 +64,29 @@ export async function readNativeSelection(path: string): Promise { throw refused(); } } + +/** A base file plus per-env replacements; the env file, when present, replaces the base. */ +export interface NativeEnvSelectedFile { + readonly file?: string; + readonly fileByEnv?: Readonly>; +} + +/** + * The file that applies to a run's effective environment name. Native start and restart + * preflight call this with the prepared input's effective env, so a fresh start, a + * retained start and a restart of the same run always select the same file. + */ +export function selectNativeEnvFile(opts: { + readonly selection: NativeEnvSelectedFile; + readonly envName: string | null; +}): string | undefined { + const byEnv = opts.selection.fileByEnv; + if ( + opts.envName !== null && + byEnv !== undefined && + Object.hasOwn(byEnv, opts.envName) + ) { + return byEnv[opts.envName]; + } + return opts.selection.file; +} diff --git a/src/backends/native-project-settings.ts b/src/backends/native-project-settings.ts new file mode 100644 index 000000000..b7a5d3ddd --- /dev/null +++ b/src/backends/native-project-settings.ts @@ -0,0 +1,620 @@ +import { lstat, readdir, realpath } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, sep } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { sanitizeProjectSlug } from "../lib/project.ts"; +import { + type NativePreparedBase, + parseNativePreparedBase, +} from "./native-prepared-base.ts"; +import { + parseNativeAllowedHosts, + validateNativeAllowedHosts, +} from "./native-project-network.ts"; +import { + type NativeEnvSelectedFile, + readNativeSelection, + selectNativeEnvFile, +} from "./native-project-selection.ts"; +import { + type NativeHttpsSelection, + parseNativeHttpsSelection, +} from "./native-project-start.ts"; + +/** + * Native image-project settings from three places, resolved once per command. + * + * - **Project intent** is committed in the project: `.hack/native/adaptation.json` and + * optional `.hack/native/adaptation..json`. It has the same authority as the + * Compose file it adapts and grants nothing. + * - **Machine settings** live outside every project share, in + * `${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json`. They hold + * grants and host authority: the source-share grant, the AWS profile, host dependency + * files, HTTPS, prepared base and approved hosts. + * - **Environment variables** (`HACK_NATIVE_*`) override either, per setting. + * + * Precedence is per setting and whole-value: a variable that is set wins entirely, then + * the machine file, then the project file; nothing is merged. Resolution reads only and + * writes no state, so cancellation needs no cleanup. A file that is present but invalid + * refuses with one closed `E_CONFIG_INVALID` line; there is no fallback. + */ +export type NativeSettingOrigin = "env" | "machine" | "project" | "unset"; + +/** The settings `hack doctor` reports, in a fixed order. */ +export const NATIVE_SETTING_KEYS = [ + "adaptation", + "sharedSource", + "dependencies", + "allowHosts", + "preparedBase", + "https", + "aws", +] as const; +export type NativeSettingKey = (typeof NATIVE_SETTING_KEYS)[number]; + +/** The committed project settings directory inside `.hack/`. */ +export const NATIVE_PROJECT_SETTINGS_DIRNAME = "native"; +const ADAPTATION_FILE = /^adaptation(?:\.([a-z0-9]+(?:-[a-z0-9]+)*))?\.json$/; +const MACHINE_FILENAME = "settings.json"; +const MACHINE_KEYS = new Set([ + "version", + "sharedSource", + "dependencies", + "dependenciesByEnv", + "allowHosts", + "preparedBase", + "https", + "aws", +]); +const ENV_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const SIBLING_FILE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/; +const PROJECT_ENTRY_LIMIT = 64; +const ENV_OVERLAY_LIMIT = 32; + +type Environment = Readonly>; + +/** + * A variable that is present selects its setting, even when empty, so every existing + * script keeps its exact behavior (an empty path or list still refuses). + */ +function set(env: Environment, name: string): boolean { + return env[name] !== undefined; +} + +/** HOME and the AWS profile keep their non-empty meaning. */ +function nonEmpty(env: Environment, name: string): boolean { + const value = env[name]; + return value !== undefined && value !== ""; +} + +function projectRefused(): HackCliError { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native project settings in .hack/native/ must be a real directory of bounded regular adaptation files; values omitted.", + }); +} + +function machineRefused(detail: string): HackCliError { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: `Native machine settings ${detail}; values omitted.`, + }); +} + +function fieldRefused(key: string): HackCliError { + return machineRefused(`field ${key} is invalid`); +} + +/** The adaptation native start applies: one explicit file, or the committed files. */ +export interface NativeAdaptationSelection extends NativeEnvSelectedFile { + readonly origin: Exclude; +} + +/** + * Resolve the adaptation selection. `HACK_NATIVE_ADAPTATION`, when set, wins whole; + * otherwise `.hack/native/adaptation.json` and `adaptation..json` are discovered. + * Discovery lists names only; files are read with the bounded selection reader when a + * command applies them, which refuses links, hard links and changed files. + */ +export async function resolveNativeAdaptationSelection(opts: { + readonly env: Environment; + readonly projectDir: string; +}): Promise { + if (set(opts.env, "HACK_NATIVE_ADAPTATION")) { + return { + origin: "env", + file: opts.env.HACK_NATIVE_ADAPTATION, + fileByEnv: {}, + }; + } + const directory = join(opts.projectDir, NATIVE_PROJECT_SETTINGS_DIRNAME); + let names: string[]; + try { + const stats = await lstat(directory); + if (!stats.isDirectory()) { + throw projectRefused(); + } + names = await readdir(directory); + } catch (error: unknown) { + if (isRecord(error) && error.code === "ENOENT") { + return { origin: "unset", fileByEnv: {} }; + } + throw error instanceof HackCliError ? error : projectRefused(); + } + if (names.length > PROJECT_ENTRY_LIMIT) { + throw projectRefused(); + } + let file: string | undefined; + const fileByEnv: Record = {}; + for (const name of names.sort()) { + const match = ADAPTATION_FILE.exec(name); + if (!match) { + continue; + } + const envName = match[1]; + if (envName === undefined) { + file = join(directory, name); + } else { + fileByEnv[envName] = join(directory, name); + } + } + if (Object.keys(fileByEnv).length > ENV_OVERLAY_LIMIT) { + throw projectRefused(); + } + return { + origin: + file === undefined && Object.keys(fileByEnv).length === 0 + ? "unset" + : "project", + ...(file === undefined ? {} : { file }), + fileByEnv, + }; +} + +/** + * Every adaptation file a command could apply. With an explicit `--env` this is exactly + * the selected file; without one the effective env is not known until the managed env is + * prepared (it may be the project default or a retained run's env), so every candidate is + * returned, with `undefined` for "no adaptation". A caller that needs one answer for all + * candidates (the Compose-backend pre-check) must take the most restrictive one. + */ +export function nativeAdaptationCandidates(opts: { + readonly selection: NativeAdaptationSelection; + readonly requestedEnvName: string | null | undefined; +}): readonly (string | undefined)[] { + if (opts.requestedEnvName !== undefined) { + return [ + selectNativeEnvFile({ + selection: opts.selection, + envName: opts.requestedEnvName, + }), + ]; + } + return [ + opts.selection.file, + ...Object.values(opts.selection.fileByEnv ?? {}), + ]; +} + +/** Grants and host authority, resolved from the environment and machine settings. */ +export interface NativeHostSettings { + readonly sharedSource: boolean; + readonly dependencyFile?: string; + readonly dependencyFileByEnv?: Readonly>; + readonly allowedHosts: readonly string[]; + readonly preparedBase?: NativePreparedBase; + readonly https?: NativeHttpsSelection; + readonly aws?: { readonly profile: string; readonly region?: string }; + readonly origins: Readonly< + Record, NativeSettingOrigin> + >; + /** Canonical settings file path when one was read. */ + readonly machineFile?: string; +} + +/** + * The machine settings directory for a project, or undefined when no home is known. + * `XDG_CONFIG_HOME` counts only when absolute, as the XDG base directory rules say. + */ +export function nativeMachineSettingsDirectory(opts: { + readonly env: Environment; + readonly projectName: string; +}): string | undefined { + const xdg = opts.env.XDG_CONFIG_HOME; + let configHome: string | undefined; + if (xdg !== undefined && isAbsolute(xdg)) { + configHome = xdg; + } else { + const home = nonEmpty(opts.env, "HOME") ? opts.env.HOME : safeHomedir(); + configHome = + home !== undefined && isAbsolute(home) + ? join(home, ".config") + : undefined; + } + return configHome === undefined + ? undefined + : join(configHome, "hack", "native", sanitizeProjectSlug(opts.projectName)); +} + +function safeHomedir(): string | undefined { + try { + return homedir(); + } catch { + return undefined; + } +} + +function inside(parent: string, child: string): boolean { + const path = relative(parent, child); + return ( + path === "" || + !(path.startsWith(`..${sep}`) || path === ".." || isAbsolute(path)) + ); +} + +/** + * Locate and read the machine settings file. Absent directory or file means no machine + * settings. A present file requires a real, current-user-owned directory without group + * or other bits, outside the project root (so no project share can reach it), and an + * owner-only regular file; anything else refuses. + */ +async function readMachineSettings(opts: { + readonly env: Environment; + readonly projectName: string; + readonly projectRoot: string; + readonly uid: number | undefined; +}): Promise< + | { + readonly directory: string; + readonly file: string; + readonly value: unknown; + } + | undefined +> { + const directory = nativeMachineSettingsDirectory(opts); + if (directory === undefined) { + return undefined; + } + let stats: Awaited>; + try { + stats = await lstat(directory); + } catch (error: unknown) { + if (isRecord(error) && error.code === "ENOENT") { + return undefined; + } + throw machineRefused("directory is unreadable"); + } + const uid = opts.uid; + if ( + uid === undefined || + !stats.isDirectory() || + stats.uid !== uid || + (stats.mode & 0o077) !== 0 + ) { + throw machineRefused( + "directory must be a real directory owned by the current user with mode 0700" + ); + } + let canonical: string; + let root: string; + try { + canonical = await realpath(directory); + root = await realpath(opts.projectRoot); + } catch { + throw machineRefused("directory is unreadable"); + } + if (inside(root, canonical) || inside(canonical, root)) { + throw machineRefused("directory must be outside the project root"); + } + const file = join(canonical, MACHINE_FILENAME); + try { + await lstat(file); + } catch (error: unknown) { + if (isRecord(error) && error.code === "ENOENT") { + return undefined; + } + throw machineRefused("file is unreadable"); + } + let value: unknown; + try { + value = await readNativeSelection(file, { privateToUid: uid }); + } catch { + throw machineRefused( + "file must be a bounded owner-only (mode 0600) regular JSON file" + ); + } + return { directory: canonical, file, value }; +} + +function siblingFile(directory: string, value: unknown, key: string): string { + if ( + typeof value !== "string" || + !SIBLING_FILE.test(value) || + value === MACHINE_FILENAME + ) { + throw fieldRefused(key); + } + return join(directory, value); +} + +function machineDependencies( + directory: string, + settings: Record +) { + const file = + settings.dependencies === undefined + ? undefined + : siblingFile(directory, settings.dependencies, "dependencies"); + const byEnvValue = settings.dependenciesByEnv; + const fileByEnv: Record = {}; + if (byEnvValue !== undefined) { + if ( + !isRecord(byEnvValue) || + Object.keys(byEnvValue).length > ENV_OVERLAY_LIMIT + ) { + throw fieldRefused("dependenciesByEnv"); + } + for (const [envName, name] of Object.entries(byEnvValue)) { + if (!ENV_NAME.test(envName)) { + throw fieldRefused("dependenciesByEnv"); + } + fileByEnv[envName] = siblingFile(directory, name, "dependenciesByEnv"); + } + } + return { file, fileByEnv }; +} + +function machineAllowedHosts(value: unknown): readonly string[] { + if ( + !(Array.isArray(value) && value.every((host) => typeof host === "string")) + ) { + throw fieldRefused("allowHosts"); + } + try { + return validateNativeAllowedHosts(value); + } catch { + throw fieldRefused("allowHosts"); + } +} + +function machinePreparedBase(value: unknown): NativePreparedBase | undefined { + if ( + !( + isRecord(value) && + Object.keys(value).every((key) => key === "mode" || key === "store") && + typeof value.mode === "string" && + (value.store === undefined || typeof value.store === "string") + ) + ) { + throw fieldRefused("preparedBase"); + } + try { + return parseNativePreparedBase({ + HACK_NATIVE_PREPARED_BASE: value.mode, + HACK_NATIVE_PREPARED_BASE_STORE: value.store, + }); + } catch { + throw fieldRefused("preparedBase"); + } +} + +function machineHttps(value: unknown): NativeHttpsSelection | undefined { + if ( + !( + isRecord(value) && + Object.keys(value).every((key) => + ["caddyBinary", "caddySha256", "port"].includes(key) + ) && + typeof value.caddyBinary === "string" && + typeof value.caddySha256 === "string" && + typeof value.port === "number" && + Number.isSafeInteger(value.port) + ) + ) { + throw fieldRefused("https"); + } + try { + return parseNativeHttpsSelection({ + HACK_NATIVE_CADDY_BINARY: value.caddyBinary, + HACK_NATIVE_CADDY_SHA256: value.caddySha256, + HACK_NATIVE_HTTPS_PORT: String(value.port), + }); + } catch { + throw fieldRefused("https"); + } +} + +function machineAws(value: unknown): { + readonly profile: string; + readonly region?: string; +} { + if ( + !( + isRecord(value) && + Object.keys(value).every( + (key) => key === "profile" || key === "region" + ) && + typeof value.profile === "string" && + value.profile.length > 0 && + value.profile.length <= 128 && + (value.region === undefined || + (typeof value.region === "string" && + value.region.length > 0 && + value.region.length <= 64)) + ) + ) { + throw fieldRefused("aws"); + } + return { + profile: value.profile, + ...(value.region === undefined ? {} : { region: value.region }), + }; +} + +function validMachineRecord(value: unknown): Record { + if (!(isRecord(value) && value.version === 1)) { + throw machineRefused("require version 1"); + } + if (!Object.keys(value).every((key) => MACHINE_KEYS.has(key))) { + throw machineRefused("contain an unsupported field"); + } + if ( + value.sharedSource !== undefined && + typeof value.sharedSource !== "boolean" + ) { + throw fieldRefused("sharedSource"); + } + return value; +} + +type MachineValues = { + readonly sharedSource?: boolean; + readonly dependencies: ReturnType; + readonly allowHosts?: readonly string[]; + readonly preparedBase?: NativePreparedBase; + /** `preparedBase: {"mode":"off"}` is a machine choice that selects nothing. */ + readonly preparedBaseSet: boolean; + readonly https?: NativeHttpsSelection; + readonly aws?: { readonly profile: string; readonly region?: string }; +}; + +/** Validate every machine value, even ones a variable overrides. */ +function machineValues( + machine: { readonly directory: string; readonly value: unknown } | undefined +): MachineValues { + if (!machine) { + return { + dependencies: { file: undefined, fileByEnv: {} }, + preparedBaseSet: false, + }; + } + const settings = validMachineRecord(machine.value); + return { + ...(typeof settings.sharedSource === "boolean" + ? { sharedSource: settings.sharedSource } + : {}), + dependencies: machineDependencies(machine.directory, settings), + ...(settings.allowHosts === undefined + ? {} + : { allowHosts: machineAllowedHosts(settings.allowHosts) }), + ...(settings.preparedBase === undefined + ? {} + : { preparedBase: machinePreparedBase(settings.preparedBase) }), + preparedBaseSet: settings.preparedBase !== undefined, + ...(settings.https === undefined + ? {} + : { https: machineHttps(settings.https) }), + ...(settings.aws === undefined ? {} : { aws: machineAws(settings.aws) }), + }; +} + +/** Whole-value precedence for one setting: a set variable, else the machine value. */ +function pick(opts: { + readonly envSet: boolean; + readonly fromEnv: () => T; + readonly machineSet: boolean; + readonly machine: T; +}): { readonly value: T; readonly origin: NativeSettingOrigin } { + if (opts.envSet) { + return { value: opts.fromEnv(), origin: "env" }; + } + return { value: opts.machine, origin: opts.machineSet ? "machine" : "unset" }; +} + +/** + * Resolve grants and host authority for native `up` and `restart`. Every machine value + * is validated here even when a variable overrides it, so a broken file never waits for + * the day its override is removed. A non-empty `HACK_NATIVE_AWS_PROFILE` alone selects + * the AWS variables, as before; `HACK_NATIVE_AWS_REGION` without it is ignored. + */ +export async function resolveNativeHostSettings(opts: { + readonly env: Environment; + readonly projectName: string; + readonly projectRoot: string; + /** Defaults to the current process user; tests inject it. */ + readonly uid?: number; +}): Promise { + const env = opts.env; + const machine = await readMachineSettings({ + env, + projectName: opts.projectName, + projectRoot: opts.projectRoot, + uid: + opts.uid ?? + (typeof process.getuid === "function" ? process.getuid() : undefined), + }); + const values = machineValues(machine); + const sharedSource = pick({ + envSet: set(env, "HACK_NATIVE_SHARED_SOURCE"), + fromEnv: () => env.HACK_NATIVE_SHARED_SOURCE === "1", + machineSet: values.sharedSource !== undefined, + machine: values.sharedSource === true, + }); + const dependencies = pick<{ + readonly file?: string; + readonly fileByEnv: Readonly>; + }>({ + envSet: set(env, "HACK_NATIVE_DEPENDENCIES"), + fromEnv: () => ({ file: env.HACK_NATIVE_DEPENDENCIES, fileByEnv: {} }), + machineSet: + values.dependencies.file !== undefined || + Object.keys(values.dependencies.fileByEnv).length > 0, + machine: values.dependencies, + }); + const allowHosts = pick({ + envSet: set(env, "HACK_NATIVE_ALLOW_HOSTS"), + fromEnv: () => parseNativeAllowedHosts(env.HACK_NATIVE_ALLOW_HOSTS), + machineSet: values.allowHosts !== undefined, + machine: values.allowHosts ?? [], + }); + const preparedBase = pick({ + envSet: + set(env, "HACK_NATIVE_PREPARED_BASE") || + set(env, "HACK_NATIVE_PREPARED_BASE_STORE"), + fromEnv: () => parseNativePreparedBase(env), + machineSet: values.preparedBaseSet, + machine: values.preparedBase, + }); + const https = pick({ + envSet: + set(env, "HACK_NATIVE_CADDY_BINARY") || + set(env, "HACK_NATIVE_CADDY_SHA256") || + set(env, "HACK_NATIVE_HTTPS_PORT"), + fromEnv: () => parseNativeHttpsSelection(env), + machineSet: values.https !== undefined, + machine: values.https, + }); + const aws = pick({ + envSet: nonEmpty(env, "HACK_NATIVE_AWS_PROFILE"), + fromEnv: () => ({ + profile: env.HACK_NATIVE_AWS_PROFILE ?? "", + region: env.HACK_NATIVE_AWS_REGION, + }), + machineSet: values.aws !== undefined, + machine: values.aws, + }); + return { + sharedSource: sharedSource.value, + ...(dependencies.value.file === undefined + ? {} + : { dependencyFile: dependencies.value.file }), + dependencyFileByEnv: dependencies.value.fileByEnv, + allowedHosts: allowHosts.value, + ...(preparedBase.value === undefined + ? {} + : { preparedBase: preparedBase.value }), + ...(https.value === undefined ? {} : { https: https.value }), + ...(aws.value === undefined ? {} : { aws: aws.value }), + origins: { + sharedSource: sharedSource.origin, + dependencies: dependencies.origin, + allowHosts: allowHosts.origin, + preparedBase: preparedBase.origin, + https: https.origin, + aws: aws.origin, + }, + ...(machine ? { machineFile: machine.file } : {}), + }; +} diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..1fe9d707e 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -75,6 +75,7 @@ import { removeNativeProjectRun, saveNativeProjectRun, } from "./native-project-run.ts"; +import { selectNativeEnvFile } from "./native-project-selection.ts"; import { preflightNativeProjectSource } from "./native-project-source-preflight.ts"; import { invokeNativeRuntime, @@ -356,7 +357,7 @@ export function prepareNativeProjectServices( } /** Fixed text of the refusal when the project share was not granted. */ export const NATIVE_SHARED_SOURCE_REFUSAL = - "Native foreground up requires HACK_NATIVE_SHARED_SOURCE=1 to share this exact project, including ignored files. Set it to grant that share and run the command again."; + "Native foreground up requires HACK_NATIVE_SHARED_SOURCE=1 or sharedSource in the native machine settings to share this exact project, including ignored files. Grant that share and run the command again."; /** Keep image acquisition separate from graph admission and preserve cancellation between requests. */ async function pinNativeProjectImages(opts: { readonly runtime: NativeRuntimeSelection; @@ -900,7 +901,11 @@ export async function startNativeProject(opts: { readonly restore?: NativeProjectRun; readonly onReady?: () => Promise; readonly dependencyFile?: string; + /** Per-env dependency files; the entry for the run's effective env replaces `dependencyFile`. */ + readonly dependencyFileByEnv?: Readonly>; readonly adaptationFile?: string; + /** Per-env adaptations; the entry for the run's effective env replaces `adaptationFile`. */ + readonly adaptationFileByEnv?: Readonly>; readonly allowedHosts?: readonly string[]; readonly preparedBase?: NativePreparedBase; readonly https?: NativeHttpsSelection; @@ -996,9 +1001,25 @@ export async function startNativeProject(opts: { "Native retained environment selection changed; data was not replaced." ); } + // The effective env (requested, retained or project default) picks the per-env + // files, so a fresh start, a retained start and a restart select the same ones. + const selectedAdaptationFile = selectNativeEnvFile({ + selection: { + file: opts.adaptationFile, + fileByEnv: opts.adaptationFileByEnv, + }, + envName: input.effectiveEnvName, + }); + const selectedDependencyFile = selectNativeEnvFile({ + selection: { + file: opts.dependencyFile, + fileByEnv: opts.dependencyFileByEnv, + }, + envName: input.effectiveEnvName, + }); input = await prepareNativeProjectAdaptation({ input, - path: opts.adaptationFile, + path: selectedAdaptationFile, }); input = ( await prepareNativeReviewBranch({ @@ -1012,7 +1033,7 @@ export async function startNativeProject(opts: { ).input; let specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + selectedDependencyFile !== undefined ); // Refuse malformed or unacquirable references before hooks, VM or provider start. await admitNativeProjectImages({ @@ -1065,12 +1086,12 @@ export async function startNativeProject(opts: { input = (await deps.adaptAws({ input, ...selection.aws })).input; specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + selectedDependencyFile !== undefined ); } const dependencyDiscoveryDeadline = performance.now() + 60_000; const hostDependencies = await readNativeHostDependencies({ - path: opts.dependencyFile, + path: selectedDependencyFile, services: Object.keys(specs), discover: (selection) => discoverNativeHostDependency({ @@ -1159,7 +1180,7 @@ export async function startNativeProject(opts: { requireActiveStartup(controller.signal); specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + selectedDependencyFile !== undefined ); prepareNativeDependencyServices({ dependencies: hostDependencies, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..0d1b95fdb 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -12,6 +12,10 @@ import { parseNativeRunMappingRecoveryOptions, runNativeProjectMappingCommand, } from "../backends/native-project-mapping-command.ts"; +import { + nativeAdaptationCandidates, + resolveNativeAdaptationSelection, +} from "../backends/native-project-settings.ts"; import { type NativeRuntimeSelection, resolveNativeRuntimeSelection, @@ -103,6 +107,11 @@ import { untrackGeneratedFiles, } from "../lib/doctor-generated-files.ts"; import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts"; +import { + checkNativeSettings, + NATIVE_SETTINGS_CHECK, + type NativeSettingsReport, +} from "../lib/doctor-native-settings.ts"; import { findCrossCheckoutInstances, inspectWorktreeSecretKeys, @@ -235,6 +244,8 @@ interface CheckResult { readonly envKey?: EnvKeyInspection; /** Closed Compose-backend feature names only; never authored values. */ readonly composeBackend?: ComposeBackendReport; + /** Native setting origins and closed warnings only; never setting values. */ + readonly nativeSettings?: NativeSettingsReport; } const HACK_LOCAL_ROUTE_CHECK = "native hack.local route"; @@ -1112,10 +1123,17 @@ async function handleNativeDoctor(opts: { checkComposeBackendFeatures({ startDir: opts.startDir, backend: "native", - adaptationFile: process.env.HACK_NATIVE_ADAPTATION, }) ) ); + results.push( + await runCheck( + progress, + NATIVE_SETTINGS_CHECK, + () => checkNativeProjectSettings({ startDir: opts.startDir }), + { timeoutMs: 5000 } + ) + ); } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); @@ -1282,6 +1300,8 @@ export type DoctorJsonCheck = { readonly envKey?: EnvKeyInspection; /** Present only on the Compose-backend feature check: closed feature names. */ readonly composeBackend?: ComposeBackendReport; + /** Present only on the native settings check: origins and closed warnings. */ + readonly nativeSettings?: NativeSettingsReport; }; export type DoctorJsonData = { @@ -1320,6 +1340,9 @@ export function buildDoctorJsonData(opts: { ...(result.composeBackend ? { composeBackend: result.composeBackend } : {}), + ...(result.nativeSettings + ? { nativeSettings: result.nativeSettings } + : {}), })), summary: buildDoctorSummaryLines({ results: opts.results }), counts, @@ -2929,13 +2952,13 @@ export function envKeyCheckResult(inspection: EnvKeyInspection): CheckResult { /** * Reports which Compose features keep the current project on the Compose * backend (HACK-1291). Reads only the project's Compose file and, on the native - * backend, the `HACK_NATIVE_ADAPTATION` selection native up applies. An + * backend, the adaptation selection native up applies without `--env` + * (`HACK_NATIVE_ADAPTATION`, else the committed `.hack/native/` files). An * invalid selection is the same value-free error native up gives. */ async function checkComposeBackendFeatures(opts: { readonly startDir: string; readonly backend: "native" | "compose"; - readonly adaptationFile?: string; }): Promise { const ctx = await findProjectContext(opts.startDir); if (!ctx) { @@ -2945,14 +2968,48 @@ async function checkComposeBackendFeatures(opts: { message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, }; } + const adaptationCandidates = + opts.backend === "native" + ? nativeAdaptationCandidates({ + selection: await resolveNativeAdaptationSelection({ + env: process.env, + projectDir: ctx.projectDir, + }), + requestedEnvName: undefined, + }) + : undefined; return composeBackendCheckResult({ backend: opts.backend, features: await readComposeBackendFeatures(ctx.composeFile, { - adaptationFile: opts.adaptationFile, + adaptationCandidates, }), }); } +/** The `native settings` check: origins and closed warnings, never values. */ +async function checkNativeProjectSettings(opts: { + readonly startDir: string; +}): Promise { + const ctx = await findProjectContext(opts.startDir); + if (!ctx) { + return { + name: NATIVE_SETTINGS_CHECK, + status: "warn", + message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, + }; + } + const cfg = await readProjectConfig(ctx); + if (cfg.parseError) { + return { + name: NATIVE_SETTINGS_CHECK, + status: "error", + message: + "Native settings require valid project configuration; values omitted.", + }; + } + return await checkNativeSettings({ project: ctx, cfg, env: process.env }); +} + /** * 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/commands/project.ts b/src/commands/project.ts index ff71f6a36..86cdaf3a7 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -41,13 +41,11 @@ import { } from "../agents/onboarding-handoff.ts"; import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; -import { parseNativePreparedBase } from "../backends/native-prepared-base.ts"; import { nativeDownEnvironment, nativeProjectDown, } from "../backends/native-project-down.ts"; import { adoptNativeLifecycleCleanup } from "../backends/native-project-lifecycle.ts"; -import { parseNativeAllowedHosts } from "../backends/native-project-network.ts"; import { type NativeProjectPsResult, nativeProjectLogs, @@ -69,11 +67,13 @@ import { nativeRestartSelection, preflightNativeRestart, } from "../backends/native-project-restart-preflight.ts"; -import { preflightNativeProjectSource } from "../backends/native-project-source-preflight.ts"; import { - parseNativeHttpsSelection, - startNativeProject, -} from "../backends/native-project-start.ts"; + nativeAdaptationCandidates, + resolveNativeAdaptationSelection, + resolveNativeHostSettings, +} from "../backends/native-project-settings.ts"; +import { preflightNativeProjectSource } from "../backends/native-project-source-preflight.ts"; +import { startNativeProject } from "../backends/native-project-start.ts"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; import { composeRuntimeBackend } from "../backends/runtime-backend.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; @@ -5726,11 +5726,22 @@ async function handleNativeUp({ }); // Before branch, config, env, lifecycle, storage or runtime work. `down` // stays available for any graph a project already has. - // The explicit adaptation applies first: a network it isolates is not - // external here, exactly as native start normalizes it. + // The selected adaptation applies first: a network it isolates is not + // external here, exactly as native start normalizes it. Without --env every + // committed per-env adaptation is a candidate (see nativeAdaptationCandidates). + const requestedEnvName = resolveRequestedEnvName({ + envOption: args.options.env, + }); + const adaptation = await resolveNativeAdaptationSelection({ + env: process.env, + projectDir: project.projectDir, + }); await refuseNativeComposeBackendFeatures({ composeFile: project.composeFile, - adaptationFile: process.env.HACK_NATIVE_ADAPTATION, + adaptationCandidates: nativeAdaptationCandidates({ + selection: adaptation, + requestedEnvName, + }), restart, }); const branch = await resolveEffectiveBranchForCommand({ @@ -5744,6 +5755,11 @@ async function handleNativeUp({ ); } const projectName = await resolveComposeProjectName({ project, cfg }); + const host = await resolveNativeHostSettings({ + env: process.env, + projectName, + projectRoot: project.projectRoot, + }); const composeProject = resolveLifecycleComposeProjectName({ projectName, branch, @@ -5765,20 +5781,17 @@ async function handleNativeUp({ branch, }, composeFile: project.composeFile, - envName: resolveRequestedEnvName({ envOption: args.options.env }), + envName: requestedEnvName, profiles: parseCsvList(args.options.profile), - sharedSource: process.env.HACK_NATIVE_SHARED_SOURCE === "1", - dependencyFile: process.env.HACK_NATIVE_DEPENDENCIES, - adaptationFile: process.env.HACK_NATIVE_ADAPTATION, - allowedHosts: parseNativeAllowedHosts(process.env.HACK_NATIVE_ALLOW_HOSTS), - preparedBase: parseNativePreparedBase(process.env), - https: parseNativeHttpsSelection(process.env), - aws: process.env.HACK_NATIVE_AWS_PROFILE - ? { - profile: process.env.HACK_NATIVE_AWS_PROFILE, - region: process.env.HACK_NATIVE_AWS_REGION, - } - : undefined, + sharedSource: host.sharedSource, + dependencyFile: host.dependencyFile, + dependencyFileByEnv: host.dependencyFileByEnv, + adaptationFile: adaptation.file, + adaptationFileByEnv: adaptation.fileByEnv, + allowedHosts: host.allowedHosts, + preparedBase: host.preparedBase, + https: host.https, + aws: host.aws, runtimeMetadata: { devHost, aliasHost: resolveBranchAliasHost({ devHost, cfg }), @@ -5848,7 +5861,7 @@ async function handleNativeUp({ throw new HackCliError({ code: "E_PERMISSION", message: - "Native restart requires HACK_NATIVE_SHARED_SOURCE=1 before cleanup; nothing was stopped. Set it to grant the project share and run the command again.", + "Native restart requires HACK_NATIVE_SHARED_SOURCE=1 or sharedSource in the native machine settings before cleanup; nothing was stopped. Grant the project share and run the command again.", }); } try { @@ -5947,7 +5960,9 @@ async function restartNativeImageProject({ scope: startup.scope, composeFile: startup.composeFile, adaptationFile: startup.adaptationFile, + adaptationFileByEnv: startup.adaptationFileByEnv, dependencyFile: startup.dependencyFile, + dependencyFileByEnv: startup.dependencyFileByEnv, allowedHosts: startup.allowedHosts, runtimeMetadata: startup.runtimeMetadata, httpsPort: startup.https?.httpsPort ?? null, diff --git a/src/lib/doctor-native-settings.ts b/src/lib/doctor-native-settings.ts new file mode 100644 index 000000000..7d36d8cfd --- /dev/null +++ b/src/lib/doctor-native-settings.ts @@ -0,0 +1,297 @@ +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, relative, sep } from "node:path"; +import { YAML } from "bun"; +import { + readNativeSelection, + selectNativeEnvFile, +} from "../backends/native-project-selection.ts"; +import { + NATIVE_PROJECT_SETTINGS_DIRNAME, + NATIVE_SETTING_KEYS, + type NativeAdaptationSelection, + type NativeSettingKey, + type NativeSettingOrigin, + nativeMachineSettingsDirectory, + resolveNativeAdaptationSelection, + resolveNativeHostSettings, +} from "../backends/native-project-settings.ts"; +import { HackCliError } from "./cli-result.ts"; +import { resolveComposeProjectName } from "./compose-project-name.ts"; +import { readTextFile } from "./fs.ts"; +import { isRecord } from "./guards.ts"; +import { + type ProjectConfig, + type ProjectContext, + readProjectDefaultEnvConfig, +} from "./project.ts"; + +/** Doctor check id for the native settings report. */ +export const NATIVE_SETTINGS_CHECK = "native settings"; + +/** + * Closed warnings for configuration that native `up` would refuse or silently not + * apply. Each names a class of input, never an authored value or a service name. + */ +export type NativeSettingsWarning = + | "shared_source_not_granted" + | "internal_dns_tls_not_applied" + | "internal_extra_hosts_not_applied" + | "routes_without_probes" + | "compose_profiles_ignored" + | "aws_region_without_profile" + | "unread_project_settings_files"; + +/** Value-free JSON payload of the native settings check. */ +export type NativeSettingsReport = { + readonly origins: Readonly>; + /** Project-relative committed adaptation files, e.g. `.hack/native/adaptation.json`. */ + readonly adaptationFiles: readonly string[]; + /** The per-machine settings file, with the home directory shown as `~`. */ + readonly machineSettings: { + readonly path: string; + readonly present: boolean; + } | null; + readonly warnings: readonly NativeSettingsWarning[]; + /** Number of routed services without a native HTTP probe after adaptation. */ + readonly routesWithoutProbes?: number; +}; + +const WARNING_TEXT: Readonly> = { + shared_source_not_granted: + "the project share is not granted (set sharedSource in the machine settings or HACK_NATIVE_SHARED_SOURCE=1), so native up and restart refuse", + internal_dns_tls_not_applied: + "hack.config.json internal DNS and container CA trust are not applied on native, so containers cannot reach routes by their HTTPS names", + internal_extra_hosts_not_applied: + "hack.config.json internal.extra_hosts is not applied on native (declare additionalHostAliases with host dependency bindings instead)", + routes_without_probes: + "routed service(s) have no native HTTP probe (add httpProbes to the adaptation)", + compose_profiles_ignored: + "COMPOSE_PROFILES is ignored on native (pass --profile)", + aws_region_without_profile: + "HACK_NATIVE_AWS_REGION is ignored without HACK_NATIVE_AWS_PROFILE", + unread_project_settings_files: + "files in .hack/native/ other than adaptation*.json are not read (grants and host settings belong in the machine settings file)", +}; + +const ROUTE_LABELS = new Set(["caddy", "caddy.reverse_proxy", "caddy.tls"]); +const ADAPTATION_FILE = /^adaptation(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.json$/; + +type Environment = Readonly>; + +function labelKeys(labels: unknown): string[] { + if (isRecord(labels)) { + return Object.keys(labels); + } + if (Array.isArray(labels)) { + return labels + .filter((label): label is string => typeof label === "string") + .map((label) => label.split("=", 1)[0] ?? ""); + } + return []; +} + +/** Routed services in the raw Compose file that have no native probe after adaptation. */ +async function countRoutesWithoutProbes(opts: { + readonly composeFile: string; + readonly adaptationFile: string | undefined; +}): Promise { + const text = await readTextFile(opts.composeFile); + if (text === null) { + return 0; + } + let compose: unknown; + try { + compose = YAML.parse(text); + } catch { + return 0; + } + if (!(isRecord(compose) && isRecord(compose.services))) { + return 0; + } + let probes: Readonly> = {}; + if (opts.adaptationFile !== undefined) { + const selection = await readNativeSelection(opts.adaptationFile); + if (isRecord(selection) && isRecord(selection.httpProbes)) { + probes = selection.httpProbes; + } + } + let count = 0; + for (const [name, service] of Object.entries(compose.services)) { + if (!isRecord(service)) { + continue; + } + if (!labelKeys(service.labels).some((key) => ROUTE_LABELS.has(key))) { + continue; + } + const probed = + (isRecord(service.healthcheck) && + service.healthcheck["x-hack-http"] !== undefined) || + Object.hasOwn(probes, name); + if (!probed) { + count += 1; + } + } + return count; +} + +async function unreadProjectSettingsFiles( + projectDir: string +): Promise { + try { + const names = await readdir( + join(projectDir, NATIVE_PROJECT_SETTINGS_DIRNAME) + ); + return names.some((name) => !ADAPTATION_FILE.test(name)); + } catch { + return false; + } +} + +function displayPath(path: string, env: Environment): string { + let home = env.HOME; + if (home === undefined || home === "") { + try { + home = homedir(); + } catch { + return path; + } + } + const rest = relative(home, path); + return rest !== "" && !rest.startsWith("..") && !rest.startsWith(sep) + ? `~/${rest}` + : path; +} + +function adaptationFiles(opts: { + readonly selection: NativeAdaptationSelection; + readonly projectRoot: string; +}): string[] { + if (opts.selection.origin !== "project") { + return []; + } + return [opts.selection.file, ...Object.values(opts.selection.fileByEnv ?? {})] + .filter((file): file is string => file !== undefined) + .map((file) => relative(opts.projectRoot, file)); +} + +function describe(report: NativeSettingsReport): string { + const origins = NATIVE_SETTING_KEYS.map( + (key) => `${key}: ${report.origins[key]}` + ).join(", "); + const machine = report.machineSettings + ? ` Machine settings: ${report.machineSettings.path} (${report.machineSettings.present ? "read" : "absent"}).` + : ""; + const warnings = + report.warnings.length === 0 + ? "" + : ` Native: ${report.warnings + .map((warning) => + warning === "routes_without_probes" + ? `${report.routesWithoutProbes ?? 0} ${WARNING_TEXT[warning]}` + : WARNING_TEXT[warning] + ) + .join("; ")}.`; + return `Origins (${origins}).${machine}${warnings}`; +} + +/** + * Read-only native settings report for the current project: where each setting comes + * from (`env`, `machine`, `project` or `unset`), never its value, and warnings for + * configuration native start refuses or does not apply. It reads the same files native + * `up` resolves without `--env`, so an invalid file is the same closed refusal, reported + * as an error here. It runs no runtime request, export or network call. + */ +export async function checkNativeSettings(opts: { + readonly project: ProjectContext; + readonly cfg: ProjectConfig; + readonly env: Environment; + readonly uid?: number; +}): Promise<{ + readonly name: string; + readonly status: "ok" | "warn" | "error"; + readonly message: string; + readonly nativeSettings?: NativeSettingsReport; +}> { + const { project, cfg, env } = opts; + const projectName = await resolveComposeProjectName({ project, cfg }); + const machineDirectory = nativeMachineSettingsDirectory({ env, projectName }); + try { + const adaptation = await resolveNativeAdaptationSelection({ + env, + projectDir: project.projectDir, + }); + const host = await resolveNativeHostSettings({ + env, + projectName, + projectRoot: project.projectRoot, + uid: opts.uid, + }); + const defaultEnv = await readProjectDefaultEnvConfig({ + projectDir: project.projectDir, + }); + const routesWithoutProbes = await countRoutesWithoutProbes({ + composeFile: project.composeFile, + adaptationFile: selectNativeEnvFile({ + selection: adaptation, + envName: defaultEnv, + }), + }); + const warnings: NativeSettingsWarning[] = []; + if (!host.sharedSource) { + warnings.push("shared_source_not_granted"); + } + if (cfg.internal?.dns !== false || cfg.internal?.tls !== false) { + warnings.push("internal_dns_tls_not_applied"); + } + if (Object.keys(cfg.internal?.extraHosts ?? {}).length > 0) { + warnings.push("internal_extra_hosts_not_applied"); + } + if (routesWithoutProbes > 0) { + warnings.push("routes_without_probes"); + } + if ((env.COMPOSE_PROFILES ?? "") !== "") { + warnings.push("compose_profiles_ignored"); + } + if ( + (env.HACK_NATIVE_AWS_REGION ?? "") !== "" && + (env.HACK_NATIVE_AWS_PROFILE ?? "") === "" + ) { + warnings.push("aws_region_without_profile"); + } + if (await unreadProjectSettingsFiles(project.projectDir)) { + warnings.push("unread_project_settings_files"); + } + const nativeSettings: NativeSettingsReport = { + origins: { adaptation: adaptation.origin, ...host.origins }, + adaptationFiles: adaptationFiles({ + selection: adaptation, + projectRoot: project.projectRoot, + }), + machineSettings: + machineDirectory === undefined + ? null + : { + path: displayPath(join(machineDirectory, "settings.json"), env), + present: host.machineFile !== undefined, + }, + warnings, + ...(routesWithoutProbes > 0 ? { routesWithoutProbes } : {}), + }; + return { + name: NATIVE_SETTINGS_CHECK, + status: warnings.length > 0 ? "warn" : "ok", + message: describe(nativeSettings), + nativeSettings, + }; + } catch (error: unknown) { + return { + name: NATIVE_SETTINGS_CHECK, + status: "error", + message: + error instanceof HackCliError + ? `${error.code}: ${error.message}` + : "Native settings could not be read; values omitted.", + }; + } +} diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 2afef1ed8..1a42cdc95 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -305,19 +305,41 @@ export async function readAdaptedIsolatedNetworks( */ export async function readComposeBackendFeatures( composeFile: string, - opts?: { readonly adaptationFile?: string } + opts?: { + readonly adaptationFile?: string; + /** + * Every adaptation that could apply when the effective env is not yet known + * (`undefined` is "no adaptation"). A network counts as isolated only when + * every candidate isolates it, so the check never passes a project that the + * selected adaptation would leave external. Replaces `adaptationFile`. + */ + readonly adaptationCandidates?: readonly (string | undefined)[]; + } ): Promise { const compose = await readComposeDocument(composeFile); const features = findComposeBackendFeatures(compose); + const candidates = + opts?.adaptationCandidates ?? + (opts?.adaptationFile === undefined ? [] : [opts.adaptationFile]); if ( - opts?.adaptationFile === undefined || + candidates.length === 0 || + candidates.every((candidate) => candidate === undefined) || !features.includes("external_network") ) { return features; } - return findComposeBackendFeatures(compose, { - isolatedNetworks: await readAdaptedIsolatedNetworks(opts.adaptationFile), - }); + let isolated: ReadonlySet | undefined; + for (const candidate of new Set(candidates)) { + const names = + candidate === undefined + ? new Set() + : await readAdaptedIsolatedNetworks(candidate); + isolated = + isolated === undefined + ? names + : new Set([...isolated].filter((name) => names.has(name))); + } + return findComposeBackendFeatures(compose, { isolatedNetworks: isolated }); } /** Value-free phrase list, such as "a and b" or "a, b and c". */ @@ -361,10 +383,13 @@ export async function refuseNativeComposeBackendFeatures(opts: { readonly composeFile: string; /** The `HACK_NATIVE_ADAPTATION` path native start applies, if any. */ readonly adaptationFile?: string; + /** Every adaptation native start could apply; see {@link readComposeBackendFeatures}. */ + readonly adaptationCandidates?: readonly (string | undefined)[]; readonly restart: boolean; }): Promise { const features = await readComposeBackendFeatures(opts.composeFile, { adaptationFile: opts.adaptationFile, + adaptationCandidates: opts.adaptationCandidates, }); if (features.length > 0) { throw composeBackendRefusal({ features, restart: opts.restart }); diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index c6450adbe..fbae0ce83 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -395,6 +395,66 @@ test("preflight compares actual reviewed identity before cleanup eligibility", a ); }); +test("preflight selects the per-env adaptation and dependency files by the retained env", async () => { + const { preflightNativeRestart } = await import( + "../src/backends/native-project-restart-preflight.ts" + ); + const image = `sha256:${"9".repeat(64)}`; + const selected: Record = {}; + const preflight = (effectiveEnvName: string | null) => + preflightNativeRestart({ + runtime: { binary: "/unused", home: "/candidate" }, + scope, + composeFile: "/fixture/.hack/docker-compose.yml", + run, + adaptationFile: "/fixture/.hack/native/adaptation.json", + adaptationFileByEnv: { qa: "/fixture/.hack/native/adaptation.qa.json" }, + dependencyFile: "/machine/dependencies.json", + dependencyFileByEnv: { qa: "/machine/dependencies.qa.json" }, + dependencies: { + activeImages: async () => activeImageFixture(), + prepare: async () => ({ + originalSha256: "1".repeat(64), + environmentFiles: [], + serviceNames: ["app"], + normalizedComposeJson: JSON.stringify({ + services: { app: { image } }, + }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName, + }), + adapt: async ({ input: prepared, path }) => { + selected.adaptation = path; + return prepared; + }, + dependencies: async ({ path }) => { + selected.dependencies = path; + throw new ClosedCliError("stop after dependency selection"); + }, + invoke: async ({ args }) => + args[1] === "probe" ? { admitted: true } : { network: "internet" }, + review: async (options) => + await options.run({ + planId: run.planId, + namespace: run.namespace, + report: {}, + projectArgs: [], + }), + }, + }); + await expectClosedRefusal(preflight("qa"), "stop after dependency selection"); + expect(selected).toEqual({ + adaptation: "/fixture/.hack/native/adaptation.qa.json", + dependencies: "/machine/dependencies.qa.json", + }); + await expectClosedRefusal(preflight(null), "stop after dependency selection"); + expect(selected).toEqual({ + adaptation: "/fixture/.hack/native/adaptation.json", + dependencies: "/machine/dependencies.json", + }); +}); + test("preflight refuses a stale host listener before restart cleanup", async () => { const { preflightNativeRestart } = await import( "../src/backends/native-project-restart-preflight.ts" diff --git a/tests/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts new file mode 100644 index 000000000..f9cd6686b --- /dev/null +++ b/tests/native-project-settings-cli.test.ts @@ -0,0 +1,344 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { NATIVE_SHARED_SOURCE_REFUSAL } from "../src/backends/native-project-start.ts"; +import { privateCliEnv, privateCliHome } from "./helpers/private-cli-env.ts"; + +/** + * Native project settings through the real CLI: a committed + * `.hack/native/adaptation.json` and per-machine settings under the private + * HOME select what the `HACK_NATIVE_*` variables select, and `hack doctor` + * reports where each setting came from without its value. + * + * The stub runtime records every request and answers only the scripted + * `runtime check-project-share` reply, so nothing here starts a real runtime, + * Docker or a VM. Every name and value is synthetic. + */ + +const roots: string[] = []; +const entrypoint = resolve("index.ts"); +const SPAWN_TIMEOUT_MS = 60_000; +const IMAGE = `sha256:${"e".repeat(64)}`; +const PROJECT = "settings-cli-fixture"; +const SELECTION = + "this project runs on the Compose backend. Set HACK_RUNTIME_BACKEND=compose (hack-next and hack-v5 accept it too) and run the command again. Nothing was started or stopped."; +const EXTERNAL_COMPOSE = { + services: { web: { image: IMAGE, networks: ["shared-dev"] } }, + networks: { "shared-dev": { external: true } }, +}; +/** Reachable only after the Compose-backend check and the share grant passed. */ +const RUNTIME_REQUEST_LINE = + /^ERROR: Native runtime (request failed \(runtime_not_running\)|request timed out); inspect owned state before retrying\.$|^ERROR: Native runtime returned an invalid control response\.$/m; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture(compose: Record) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-settings-cli-")) + ); + roots.push(root); + const project = join(root, "project"); + const projectDir = join(project, ".hack"); + const nativeHome = join(root, "native-home"); + const runtimeDir = join(root, "runtime"); + const stub = join(root, "stub"); + await mkdir(projectDir, { recursive: true }); + await mkdir(nativeHome, { mode: 0o700 }); + await mkdir(runtimeDir); + await mkdir(stub); + await writeFile( + join(projectDir, "docker-compose.yml"), + JSON.stringify(compose) + ); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ name: PROJECT }) + ); + await writeFile(join(runtimeDir, "hack-relay-guest"), "synthetic relay"); + const binary = join(runtimeDir, "native-runtime"); + await writeFile( + binary, + `#!/bin/sh +printf '%s\\n' "$*" >> '${stub}/calls' +if [ -f '${stub}/'"$3-$4"'.out' ]; then cat '${stub}/'"$3-$4"'.out'; exit 0; fi +printf '%s' '{"code":"runtime_not_running"}' >&2 +exit 1 +`, + { mode: 0o700 } + ); + await writeFile( + join(stub, "runtime-check-project-share.out"), + JSON.stringify({ source_admitted: true, pool_initialized: true }) + ); + const calls = async () => + (await readFile(join(stub, "calls"), "utf8").catch(() => "")) + .split("\n") + .filter(Boolean) + .map((line) => line.split(" ").slice(2, 4).join(" ")); + const env = (extra: Record) => + privateCliEnv({ + root, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: binary, + HACK_NATIVE_HOME: nativeHome, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + ...extra, + }, + }); + const committed = async (name: string, value: unknown) => { + await mkdir(join(projectDir, "native"), { recursive: true }); + await writeFile(join(projectDir, "native", name), JSON.stringify(value)); + }; + const machine = async (value: unknown, mode = 0o600) => { + env({}); + const directory = join( + privateCliHome(root), + ".config", + "hack", + "native", + PROJECT + ); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const file = join(directory, "settings.json"); + await writeFile(file, JSON.stringify(value), { mode }); + await chmod(file, mode); + }; + return { root, project, calls, env, committed, machine }; +} + +type Fixture = Awaited>; + +async function cli( + f: Fixture, + args: readonly string[], + env: Record = {} +) { + const child = Bun.spawn( + [process.execPath, entrypoint, "--path", f.project, ...args], + { + cwd: f.root, + env: f.env(env), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT_MS - 5000, + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { code, output: stdout + stderr }; +} + +function errorLines(output: string): string[] { + return output.split("\n").filter((text) => text.startsWith("ERROR:")); +} + +test( + "a committed adaptation keeps an external network on native up without HACK_NATIVE_ADAPTATION", + async () => { + const f = await fixture(EXTERNAL_COMPOSE); + await f.committed("adaptation.json", { + version: 1, + isolatedNetworks: ["shared-dev"], + }); + const result = await cli(f, ["up"], { HACK_NATIVE_SHARED_SOURCE: "1" }); + expect(result.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(result.output).toMatch(RUNTIME_REQUEST_LINE); + }, + SPAWN_TIMEOUT_MS +); + +test( + "HACK_NATIVE_ADAPTATION replaces the committed adaptation whole", + async () => { + const f = await fixture(EXTERNAL_COMPOSE); + await f.committed("adaptation.json", { + version: 1, + isolatedNetworks: ["shared-dev"], + }); + const explicit = join(f.root, "explicit-adaptation.json"); + await writeFile(explicit, JSON.stringify({ version: 1 })); + const result = await cli(f, ["up"], { + HACK_NATIVE_SHARED_SOURCE: "1", + HACK_NATIVE_ADAPTATION: explicit, + }); + expect(errorLines(result.output)).toEqual([ + `ERROR: E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${SELECTION}`, + ]); + expect(await f.calls()).toEqual([]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "a per-env adaptation applies with its --env and is not assumed without one", + async () => { + const f = await fixture(EXTERNAL_COMPOSE); + await f.committed("adaptation.qa.json", { + version: 1, + isolatedNetworks: ["shared-dev"], + }); + const without = await cli(f, ["up"], { HACK_NATIVE_SHARED_SOURCE: "1" }); + expect(errorLines(without.output)).toEqual([ + `ERROR: E_NATIVE_PROJECT_UNSUPPORTED: Native \`hack up\` does not support an external Compose network; ${SELECTION}`, + ]); + expect(await f.calls()).toEqual([]); + const withEnv = await cli(f, ["up", "--env", "qa"], { + HACK_NATIVE_SHARED_SOURCE: "1", + }); + expect(withEnv.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + }, + SPAWN_TIMEOUT_MS +); + +test( + "an invalid committed adaptation refuses with the closed line before any request", + async () => { + const f = await fixture(EXTERNAL_COMPOSE); + await f.committed("adaptation.json", { + version: 1, + isolatedNetworks: "shared-dev", + }); + const result = await cli(f, ["up"], { HACK_NATIVE_SHARED_SOURCE: "1" }); + expect(errorLines(result.output)).toEqual([ + "ERROR: E_CONFIG_INVALID: Native project adaptation is invalid or conflicts with declared configuration; values omitted.", + ]); + expect(await f.calls()).toEqual([]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "machine settings grant the project share; without a grant up refuses before any request", + async () => { + const f = await fixture({ services: { web: { image: IMAGE } } }); + const refused = await cli(f, ["up"]); + expect(errorLines(refused.output)).toEqual([ + `ERROR: E_PERMISSION: ${NATIVE_SHARED_SOURCE_REFUSAL}`, + ]); + expect(await f.calls()).toEqual([]); + await f.machine({ version: 1, sharedSource: true }); + const granted = await cli(f, ["up"]); + expect(granted.output).not.toContain("E_PERMISSION"); + expect(granted.output).toMatch(RUNTIME_REQUEST_LINE); + // An explicit variable still wins over the machine grant. + const revoked = await cli(f, ["up"], { HACK_NATIVE_SHARED_SOURCE: "0" }); + expect(errorLines(revoked.output)).toEqual([ + `ERROR: E_PERMISSION: ${NATIVE_SHARED_SOURCE_REFUSAL}`, + ]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "a machine settings file readable by others refuses before any request", + async () => { + const f = await fixture({ services: { web: { image: IMAGE } } }); + await f.machine({ version: 1, sharedSource: true }, 0o644); + const result = await cli(f, ["up"]); + expect(errorLines(result.output)).toEqual([ + "ERROR: E_CONFIG_INVALID: Native machine settings file must be a bounded owner-only (mode 0600) regular JSON file; values omitted.", + ]); + expect(await f.calls()).toEqual([]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native doctor reports setting origins and native warnings, never values", + async () => { + const f = await fixture({ + services: { + web: { image: IMAGE, labels: { caddy: "web.example.test" } }, + api: { image: IMAGE, labels: { caddy: "api.example.test" } }, + }, + }); + await writeFile( + join(f.project, ".hack", "hack.config.json"), + JSON.stringify({ + name: PROJECT, + internal: { extra_hosts: { "upstream.example.test": "host-gateway" } }, + }) + ); + await f.committed("adaptation.json", { + version: 1, + httpProbes: { web: { port: 3000, path: "/health" } }, + }); + await f.committed("dependencies.json", { version: 1, dependencies: [] }); + await f.machine({ + version: 1, + sharedSource: true, + aws: { profile: "synthetic-secret-profile" }, + }); + const result = await cli(f, ["doctor", "--json"], { + HACK_NATIVE_AWS_REGION: "eu-west-1", + HACK_NATIVE_ALLOW_HOSTS: "registry.example.com", + }); + const envelope: unknown = JSON.parse( + result.output.slice(result.output.indexOf("{")) + ); + expect(envelope).toMatchObject({ + data: { + checks: expect.arrayContaining([ + expect.objectContaining({ + id: "native settings", + status: "warn", + nativeSettings: { + origins: { + adaptation: "project", + sharedSource: "machine", + dependencies: "unset", + allowHosts: "env", + preparedBase: "unset", + https: "unset", + aws: "machine", + }, + adaptationFiles: [join(".hack", "native", "adaptation.json")], + machineSettings: { + path: `~/.config/hack/native/${PROJECT}/settings.json`, + present: true, + }, + warnings: [ + "internal_dns_tls_not_applied", + "internal_extra_hosts_not_applied", + "routes_without_probes", + "aws_region_without_profile", + "unread_project_settings_files", + ], + routesWithoutProbes: 1, + }, + }), + ]), + }, + }); + for (const value of [ + "synthetic-secret-profile", + "registry.example.com", + "upstream.example.test", + "api.example.test", + f.root, + ]) { + expect(result.output).not.toContain(value); + } + }, + SPAWN_TIMEOUT_MS +); diff --git a/tests/native-project-settings.test.ts b/tests/native-project-settings.test.ts new file mode 100644 index 000000000..21ca82c40 --- /dev/null +++ b/tests/native-project-settings.test.ts @@ -0,0 +1,471 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + link, + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { selectNativeEnvFile } from "../src/backends/native-project-selection.ts"; +import { + nativeAdaptationCandidates, + nativeMachineSettingsDirectory, + resolveNativeAdaptationSelection, + resolveNativeHostSettings, +} from "../src/backends/native-project-settings.ts"; +import { readComposeBackendFeatures } from "../src/lib/native-compose-backend-features.ts"; +import { expectCodedRefusal } from "./helpers/closed-refusal.ts"; + +/** + * Native project settings: committed adaptation discovery, per-machine settings + * outside the project share, and per-setting environment precedence. Every + * fixture is synthetic and lives under a private temporary root; no test reads + * the contributor's HOME (each passes HOME and XDG_CONFIG_HOME explicitly). + */ + +const roots: string[] = []; +const SHA = "a".repeat(64); +const UID = typeof process.getuid === "function" ? process.getuid() : 0; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-project-settings-")) + ); + roots.push(root); + const projectRoot = join(root, "project"); + const projectDir = join(projectRoot, ".hack"); + const nativeDir = join(projectDir, "native"); + const home = join(root, "home"); + await mkdir(projectDir, { recursive: true }); + await mkdir(home, { mode: 0o700 }); + const env = { HOME: home, XDG_CONFIG_HOME: join(home, ".config") }; + const machineDir = join( + home, + ".config", + "hack", + "native", + "settings-fixture" + ); + async function machine(value: unknown, mode = 0o600): Promise { + await mkdir(machineDir, { recursive: true, mode: 0o700 }); + await chmod(machineDir, 0o700); + const file = join(machineDir, "settings.json"); + await writeFile(file, JSON.stringify(value), { mode }); + await chmod(file, mode); + return file; + } + async function committed(name: string, value: unknown): Promise { + await mkdir(nativeDir, { recursive: true }); + const file = join(nativeDir, name); + await writeFile(file, JSON.stringify(value)); + return file; + } + const host = (extra: Record = {}) => + resolveNativeHostSettings({ + env: { ...env, ...extra }, + projectName: "settings-fixture", + projectRoot, + uid: UID, + }); + return { + root, + projectRoot, + projectDir, + nativeDir, + home, + env, + machineDir, + machine, + committed, + host, + }; +} + +test("no committed files and no machine directory leave every setting unset", async () => { + const f = await fixture(); + expect( + await resolveNativeAdaptationSelection({ + env: f.env, + projectDir: f.projectDir, + }) + ).toEqual({ origin: "unset", fileByEnv: {} }); + const host = await f.host(); + expect(host).toEqual({ + sharedSource: false, + dependencyFileByEnv: {}, + allowedHosts: [], + origins: { + sharedSource: "unset", + dependencies: "unset", + allowHosts: "unset", + preparedBase: "unset", + https: "unset", + aws: "unset", + }, + }); +}); + +test("committed adaptation and per-env files are discovered; other names are not", async () => { + const f = await fixture(); + const base = await f.committed("adaptation.json", { version: 1 }); + const qa = await f.committed("adaptation.qa.json", { version: 1 }); + await f.committed("settings.json", { version: 1, sharedSource: true }); + await f.committed("adaptation.QA.json", { version: 1 }); + const selection = await resolveNativeAdaptationSelection({ + env: f.env, + projectDir: f.projectDir, + }); + expect(selection).toEqual({ + origin: "project", + file: base, + fileByEnv: { qa }, + }); + // The env-specific file replaces the base; other envs and no env use the base. + expect(selectNativeEnvFile({ selection, envName: "qa" })).toBe(qa); + expect(selectNativeEnvFile({ selection, envName: "staging" })).toBe(base); + expect(selectNativeEnvFile({ selection, envName: null })).toBe(base); + expect( + nativeAdaptationCandidates({ selection, requestedEnvName: "qa" }) + ).toEqual([qa]); + expect( + nativeAdaptationCandidates({ selection, requestedEnvName: undefined }) + ).toEqual([base, qa]); +}); + +test("a per-env file without a base applies only to its env", async () => { + const f = await fixture(); + const qa = await f.committed("adaptation.qa.json", { version: 1 }); + const selection = await resolveNativeAdaptationSelection({ + env: f.env, + projectDir: f.projectDir, + }); + expect(selectNativeEnvFile({ selection, envName: "qa" })).toBe(qa); + expect(selectNativeEnvFile({ selection, envName: null })).toBeUndefined(); + expect( + nativeAdaptationCandidates({ selection, requestedEnvName: undefined }) + ).toEqual([undefined, qa]); +}); + +test("HACK_NATIVE_ADAPTATION replaces the committed files whole", async () => { + const f = await fixture(); + await f.committed("adaptation.json", { version: 1 }); + await f.committed("adaptation.qa.json", { version: 1 }); + const explicit = join(f.root, "explicit.json"); + expect( + await resolveNativeAdaptationSelection({ + env: { ...f.env, HACK_NATIVE_ADAPTATION: explicit }, + projectDir: f.projectDir, + }) + ).toEqual({ origin: "env", file: explicit, fileByEnv: {} }); + // A set but empty variable still selects (and native start refuses it), as before. + expect( + await resolveNativeAdaptationSelection({ + env: { ...f.env, HACK_NATIVE_ADAPTATION: "" }, + projectDir: f.projectDir, + }) + ).toEqual({ origin: "env", file: "", fileByEnv: {} }); +}); + +test("a .hack/native that is not a real directory refuses", async () => { + const f = await fixture(); + const elsewhere = join(f.root, "elsewhere"); + await mkdir(elsewhere); + await symlink(elsewhere, f.nativeDir); + await expectCodedRefusal( + resolveNativeAdaptationSelection({ env: f.env, projectDir: f.projectDir }), + "E_CONFIG_INVALID", + "Native project settings in .hack/native/ must be a real directory" + ); + await rm(f.nativeDir); + await writeFile(f.nativeDir, "{}"); + await expectCodedRefusal( + resolveNativeAdaptationSelection({ env: f.env, projectDir: f.projectDir }), + "E_CONFIG_INVALID", + "Native project settings in .hack/native/ must be a real directory" + ); +}); + +test("the Compose-backend pre-check isolates a network only when every candidate does", async () => { + const f = await fixture(); + const compose = join(f.projectDir, "docker-compose.yml"); + await writeFile( + compose, + JSON.stringify({ + services: { web: { image: "registry.example/app:1" } }, + networks: { shared: { external: true } }, + }) + ); + const isolating = await f.committed("adaptation.json", { + version: 1, + isolatedNetworks: ["shared"], + }); + const other = await f.committed("adaptation.qa.json", { version: 1 }); + expect( + await readComposeBackendFeatures(compose, { + adaptationCandidates: [isolating], + }) + ).toEqual([]); + expect( + await readComposeBackendFeatures(compose, { + adaptationCandidates: [isolating, other], + }) + ).toEqual(["external_network"]); + expect( + await readComposeBackendFeatures(compose, { + adaptationCandidates: [isolating, undefined], + }) + ).toEqual(["external_network"]); + // A committed adaptation that is a symlink refuses as an explicit one does. + const target = join(f.root, "target.json"); + await writeFile( + target, + JSON.stringify({ version: 1, isolatedNetworks: ["shared"] }) + ); + await rm(isolating); + await symlink(target, isolating); + await expectCodedRefusal( + readComposeBackendFeatures(compose, { adaptationCandidates: [isolating] }), + "E_CONFIG_INVALID", + "Native selection requires a bounded, unchanged regular JSON file" + ); +}); + +test("machine settings supply every grant and host setting", async () => { + const f = await fixture(); + const file = await f.machine({ + version: 1, + sharedSource: true, + dependencies: "dependencies.json", + dependenciesByEnv: { qa: "dependencies.qa.json" }, + allowHosts: ["registry.example.com"], + preparedBase: { mode: "prefer" }, + https: { caddyBinary: "/opt/caddy/bin/caddy", caddySha256: SHA, port: 443 }, + aws: { profile: "synthetic-profile", region: "eu-west-1" }, + }); + const canonical = await realpath(f.machineDir); + expect(await f.host()).toEqual({ + sharedSource: true, + dependencyFile: join(canonical, "dependencies.json"), + dependencyFileByEnv: { qa: join(canonical, "dependencies.qa.json") }, + allowedHosts: ["registry.example.com"], + preparedBase: { mode: "prefer" }, + https: { + caddyBinary: "/opt/caddy/bin/caddy", + caddySha256: SHA, + httpsPort: 443, + }, + aws: { profile: "synthetic-profile", region: "eu-west-1" }, + origins: { + sharedSource: "machine", + dependencies: "machine", + allowHosts: "machine", + preparedBase: "machine", + https: "machine", + aws: "machine", + }, + machineFile: join(canonical, "settings.json"), + }); + expect(file).toBe(join(f.machineDir, "settings.json")); +}); + +test("a set variable wins its whole setting over the machine value", async () => { + const f = await fixture(); + await f.machine({ + version: 1, + sharedSource: true, + dependencies: "dependencies.json", + dependenciesByEnv: { qa: "dependencies.qa.json" }, + allowHosts: ["registry.example.com"], + preparedBase: { mode: "require" }, + https: { caddyBinary: "/opt/caddy/bin/caddy", caddySha256: SHA, port: 443 }, + aws: { profile: "machine-profile", region: "eu-west-1" }, + }); + const explicit = join(f.root, "deps.json"); + const host = await f.host({ + HACK_NATIVE_SHARED_SOURCE: "0", + HACK_NATIVE_DEPENDENCIES: explicit, + HACK_NATIVE_ALLOW_HOSTS: "mirror.example.org", + HACK_NATIVE_PREPARED_BASE: "off", + HACK_NATIVE_CADDY_BINARY: "/usr/local/bin/caddy", + HACK_NATIVE_CADDY_SHA256: SHA, + HACK_NATIVE_HTTPS_PORT: "8443", + HACK_NATIVE_AWS_PROFILE: "env-profile", + }); + expect(host).toMatchObject({ + sharedSource: false, + dependencyFile: explicit, + dependencyFileByEnv: {}, + allowedHosts: ["mirror.example.org"], + https: { caddyBinary: "/usr/local/bin/caddy", httpsPort: 8443 }, + aws: { profile: "env-profile", region: undefined }, + origins: { + sharedSource: "env", + dependencies: "env", + allowHosts: "env", + preparedBase: "env", + https: "env", + aws: "env", + }, + }); + expect(host.preparedBase).toBeUndefined(); + // HACK_NATIVE_AWS_REGION alone does not select the AWS variables. + expect((await f.host({ HACK_NATIVE_AWS_REGION: "us-west-2" })).aws).toEqual({ + profile: "machine-profile", + region: "eu-west-1", + }); +}); + +test("an invalid machine value refuses even when a variable overrides it", async () => { + const f = await fixture(); + await f.machine({ version: 1, allowHosts: ["*.example.com"] }); + await expectCodedRefusal( + f.host({ HACK_NATIVE_ALLOW_HOSTS: "registry.example.com" }), + "E_CONFIG_INVALID", + "Native machine settings field allowHosts is invalid; values omitted." + ); +}); + +test("machine settings refuse unsupported fields, grants in other shapes and partial HTTPS", async () => { + const cases: readonly [unknown, string][] = [ + [{ version: 2 }, "require version 1"], + [ + { version: 1, gcloud: { applicationDefault: true } }, + "contain an unsupported field", + ], + [ + { version: 1, isolatedNetworks: ["shared"] }, + "contain an unsupported field", + ], + [{ version: 1, sharedSource: "1" }, "field sharedSource is invalid"], + [ + { version: 1, dependencies: "../escape.json" }, + "field dependencies is invalid", + ], + [ + { version: 1, dependencies: "/abs/deps.json" }, + "field dependencies is invalid", + ], + [ + { version: 1, dependencies: "settings.json" }, + "field dependencies is invalid", + ], + [ + { version: 1, dependenciesByEnv: { QA: "deps.json" } }, + "field dependenciesByEnv is invalid", + ], + [ + { version: 1, https: { caddyBinary: "/opt/caddy", port: 443 } }, + "field https is invalid", + ], + [ + { version: 1, preparedBase: { mode: "always" } }, + "field preparedBase is invalid", + ], + [{ version: 1, aws: { region: "eu-west-1" } }, "field aws is invalid"], + ]; + for (const [value, text] of cases) { + const f = await fixture(); + await f.machine(value); + await expectCodedRefusal(f.host(), "E_CONFIG_INVALID", text); + } +}); + +test("machine settings require a private directory and an owner-only regular file", async () => { + const f = await fixture(); + await f.machine({ version: 1, sharedSource: true }, 0o644); + await expectCodedRefusal( + f.host(), + "E_CONFIG_INVALID", + "Native machine settings file must be a bounded owner-only (mode 0600) regular JSON file; values omitted." + ); + await chmod(join(f.machineDir, "settings.json"), 0o600); + await chmod(f.machineDir, 0o755); + await expectCodedRefusal( + f.host(), + "E_CONFIG_INVALID", + "Native machine settings directory must be a real directory owned by the current user with mode 0700; values omitted." + ); + await chmod(f.machineDir, 0o700); + // Another user's directory refuses (simulated with an injected uid). + await expectCodedRefusal( + resolveNativeHostSettings({ + env: f.env, + projectName: "settings-fixture", + projectRoot: f.projectRoot, + uid: UID + 1, + }), + "E_CONFIG_INVALID", + "owned by the current user" + ); + // A hard link or symlink to the settings file refuses. + const original = join(f.root, "settings-original.json"); + await writeFile(original, JSON.stringify({ version: 1 }), { mode: 0o600 }); + await rm(join(f.machineDir, "settings.json")); + await link(original, join(f.machineDir, "settings.json")); + await expectCodedRefusal(f.host(), "E_CONFIG_INVALID", "owner-only"); + await rm(join(f.machineDir, "settings.json")); + await symlink(original, join(f.machineDir, "settings.json")); + await expectCodedRefusal(f.host(), "E_CONFIG_INVALID", "owner-only"); +}); + +test("machine settings inside the project root refuse, so no project share can reach them", async () => { + const f = await fixture(); + const env = { HOME: f.home, XDG_CONFIG_HOME: join(f.projectRoot, "config") }; + const directory = nativeMachineSettingsDirectory({ + env, + projectName: "settings-fixture", + }); + expect(directory).toBe( + join(f.projectRoot, "config", "hack", "native", "settings-fixture") + ); + await mkdir(directory ?? "", { recursive: true, mode: 0o700 }); + await writeFile( + join(directory ?? "", "settings.json"), + JSON.stringify({ version: 1, sharedSource: true }), + { mode: 0o600 } + ); + await expectCodedRefusal( + resolveNativeHostSettings({ + env, + projectName: "settings-fixture", + projectRoot: f.projectRoot, + uid: UID, + }), + "E_CONFIG_INVALID", + "Native machine settings directory must be outside the project root; values omitted." + ); +}); + +test("the machine directory follows XDG_CONFIG_HOME, then HOME, and a sanitized project name", () => { + expect( + nativeMachineSettingsDirectory({ + env: { HOME: "/home/dev", XDG_CONFIG_HOME: "/cfg" }, + projectName: "My App", + }) + ).toBe("/cfg/hack/native/my-app"); + expect( + nativeMachineSettingsDirectory({ + env: { HOME: "/home/dev", XDG_CONFIG_HOME: "relative" }, + projectName: "app", + }) + ).toBe("/home/dev/.config/hack/native/app"); +}); + +test("a machine preparedBase of off is a machine choice that selects nothing", async () => { + const f = await fixture(); + await f.machine({ version: 1, preparedBase: { mode: "off" } }); + const host = await f.host(); + expect(host.preparedBase).toBeUndefined(); + expect(host.origins.preparedBase).toBe("machine"); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..219ba913a 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -2747,6 +2747,61 @@ test("startup and restart review use identical branch routes after adaptation", ).toBe("feature-a.app.hack.local, feature-a.app.hack.gy"); }); +test("startup applies the per-env adaptation for the run's effective env, else the base", async () => { + for (const [effectiveEnvName, expected] of [ + ["qa", "app.hack.local, qa.example.test"], + [null, "app.hack.local, base.example.test"], + ] as const) { + const { opts } = await fixture(false); + const prepare = opts.dependencies.prepare!; + const base = join(opts.scope.projectRoot, "adaptation.json"); + const qa = join(opts.scope.projectRoot, "adaptation.qa.json"); + await writeFile(opts.composeFile, "services: {}\n"); + await writeFile( + base, + JSON.stringify({ + version: 1, + additionalHostnames: { web: ["base.example.test"] }, + }) + ); + await writeFile( + qa, + JSON.stringify({ + version: 1, + additionalHostnames: { web: ["qa.example.test"] }, + }) + ); + opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + const compose = JSON.parse(input.normalizedComposeJson); + compose.services.web.labels = { + caddy: "app.hack.local", + "caddy.tls": "internal", + "caddy.reverse_proxy": "{{upstreams 3000}}", + }; + return { + ...input, + effectiveEnvName, + normalizedComposeJson: JSON.stringify(compose), + }; + }; + let caddy: unknown; + const before = async (input: NativeProjectInput) => { + caddy = JSON.parse(input.normalizedComposeJson).services.web.labels.caddy; + return await opts.before(); + }; + expect( + await startNativeProject({ + ...opts, + adaptationFile: base, + adaptationFileByEnv: { qa }, + before, + }) + ).toBe(0); + expect(caddy).toBe(expected); + } +}); + test("startup and restart review identical branch runtime metadata; legacy runs get none", async () => { const { opts } = await fixture(false); const review = opts.dependencies.review!; From f48aee807d2bba35f9751adb7d8a5456c77fc3ee Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:30:40 -0500 Subject: [PATCH 007/138] feat(native): deliver private credential files into environment allocations The private graph environment envelope gains version 2 with a `files` map: per service, one to four `{name, target, mode: "0400", content_b64}` members. Version 1 with files, version 2 without them, and malformed, duplicate or unknown member fields refuse, so an older runtime refuses file delivery instead of dropping it. Members are decoded into zeroized memory, bounded to 16 KiB each and to the existing 32 KiB per-service payload together with values, and staged in the same per-container tmpfs allocation as values.json: a root-only 0700 `files` directory with each member owned by the container's numeric UID at mode 0400. They are bound read-only at their declared absolute targets. Targets under /proc, /sys, /dev, /run and /var/run, nested or duplicate targets, and any compiled mount or tmpfs that equals, contains or sits under a target refuse before effects. Members live as long as the allocation and are retired with it by the existing cleanup. A service may receive files without values. It then keeps its own process (no launcher wrap) and needs only a numeric image or Compose user. Live owner restore and normalized serve-restore deliver fresh members; graph exec and one-off runs refuse envelopes carrying files. Errors and receipts stay value-free. Co-Authored-By: Claude Opus 5.5 --- docs/reference/v5-model-coverage.md | 1 + packages/runtime-core/README.md | 21 + packages/runtime-core/src/graph_cli.rs | 10 +- .../runtime-core/src/graph_cli/normalized.rs | 8 +- .../runtime-core/src/graph_cli/service_io.rs | 11 + .../runtime-core/src/provider/environment.rs | 539 +++++++++++++++++- .../src/provider/graph/foreground.rs | 28 +- .../native_test/same_boot_absent_relay.rs | 1 + .../provider/graph/foreground/redelivery.rs | 2 + .../src/provider/graph/image_process.rs | 30 + .../src/provider/graph/launcher.rs | 114 ++++ .../runtime-core/src/provider/graph/mod.rs | 128 ++++- .../src/provider/graph/native/runtime.rs | 8 +- .../src/provider/graph/normalized.rs | 26 +- .../src/provider/graph/one_off/runtime.rs | 1 + .../src/provider/graph/restore.rs | 32 +- .../src/provider/graph/startup/native_test.rs | 6 +- .../runtime-core/src/provider/graph/tests.rs | 116 ++++ .../src/provider/managed_environment.rs | 349 +++++++++++- 19 files changed, 1347 insertions(+), 84 deletions(-) diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..f01683fee 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -44,6 +44,7 @@ must be attached to the eventual exact integration revision. | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | | Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | +| Private environment allocation lifetime: staged values and private file members, crash between record and stage, retirement with the container | **Coverage gap:** no maintained model covers environment or credential delivery; file members share the existing allocation, intent record and retirement, so the state machine is unchanged | `packages/runtime-core/src/provider/environment.rs` (preparation bounds, staging layout, verification), `managed_environment.rs` (envelope v2 refusals, forwarding), `graph/launcher.rs` (read-only binds, overlap refusals) and `environment_recovery.rs` retirement tests | Live VM evidence that only the owning UID reads a member, writes fail read-only and the member is absent after cleanup; refresh or rotation of delivered files is not implemented. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | | Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 7178928ec..54a1d6d4e 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -903,6 +903,27 @@ construction; fresh admission revalidates project inputs. The existing guest tmp and startup launcher deliver values under the selected UID/GID. Cleanup retains value-free allocation identities and verifies the selected tmpfs has been retired. +Envelope `version: 2` additionally requires a nonempty `files` map from service name +to one to four members, each `{"name", "target", "mode": "0400", "content_b64"}`. A +version 1 envelope with `files`, a version 2 envelope without them, and unknown or +duplicate member fields refuse, so an older runtime refuses file delivery instead of +dropping it. Names use the service-name alphabet. Targets are absolute, normalized +container paths outside `/proc`, `/sys`, `/dev`, `/run` and `/var/run`; within one +service they are unique and never nest. Each member holds 1 to 16 KiB of decoded bytes, +and the encoded values plus every member of a service share the 32 KiB payload budget. +A service may receive files without values. Members are decoded into zeroized memory, +staged in the same per-container tmpfs allocation as `values.json` (directory `files`, +root-owned 0700; each member owned by the container's numeric UID with mode 0400), and +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 +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. + Expiry prevents new staging or execution; it does not revoke an already-running process's environment. Ordinary environment-bound restart/restore still require fresh-delivery support. This interface does not provide AWS credential acquisition, diff --git a/packages/runtime-core/src/graph_cli.rs b/packages/runtime-core/src/graph_cli.rs index 277c38e32..1f74b1d8f 100644 --- a/packages/runtime-core/src/graph_cli.rs +++ b/packages/runtime-core/src/graph_cli.rs @@ -1094,7 +1094,10 @@ pub fn command(candidate: &Candidate, args: &[&str]) -> Result Result bool { && bytes.all(|c| c.is_ascii_alphanumeric() || c == b'_') } +/// Absolute, normalized container path for a private file bind. Reserved runtime roots, +/// relative components and control characters are refused; no host path is involved. +pub(crate) fn file_target(value: &str) -> bool { + value.len() > 1 + && value.len() <= MAX_FILE_TARGET + && value.starts_with('/') + && value + .bytes() + .all(|c| (b' '..=b'~').contains(&c) && c != b'\\') + && value[1..] + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..") + && !RESERVED_FILE_ROOTS + .iter() + .any(|root| value == *root || within(value, root)) +} +/// True when `path` is strictly below directory `parent` (both absolute, normalized). +pub(crate) fn within(path: &str, parent: &str) -> bool { + parent == "/" + || path + .strip_prefix(parent) + .is_some_and(|rest| rest.starts_with('/')) +} + +/// One private file member: bytes live only in zeroized memory until staged mode 0400 in +/// the service allocation, then bind-mounted read-only at `target` for the container's +/// lifetime. Intentionally lacks Debug and Serialize; the name and target are public. +#[derive(Clone)] +pub struct ManagedFile { + name: String, + target: String, + content: zeroize::Zeroizing>, +} +impl ManagedFile { + /// Validates the member name (allocation-local file name), the container target and + /// the size bound. Errors name no value. + pub fn new( + name: &str, + target: &str, + content: zeroize::Zeroizing>, + ) -> Result { + if !self::name(name) + || !file_target(target) + || content.is_empty() + || content.len() > MAX_ENVIRONMENT_FILE_BYTES + { + return Err(error("environment_file")); + } + Ok(Self { + name: name.into(), + target: target.into(), + content, + }) + } + pub fn name(&self) -> &str { + &self.name + } + pub fn target(&self) -> &str { + &self.target + } + pub(crate) fn content(&self) -> &[u8] { + &self.content + } +} +/// Service-scoped private file members. +pub type ManagedFiles = BTreeMap>; +/// Borrowed private delivery input: service-scoped values and file members. Neither +/// part is Debug or Serialize; this is a parameter bundle, not a durable record. +#[derive(Clone, Copy)] +pub struct ManagedInput<'a> { + pub values: &'a BTreeMap>, + pub files: &'a ManagedFiles, +} + /// Intentionally lacks Debug/Serialize. Lifetime begins at preparation, not at delivery. pub struct PendingEnvironment { service: String, payload: zeroize::Zeroizing, + /// Whether the process is wrapped by the private launcher. A files-only service + /// keeps its own entrypoint; the staged `values.json` is then an unused `{}`. + wraps: bool, + files: Vec, uid: u32, gid: u32, deadline: Instant, @@ -90,6 +177,9 @@ pub struct EnvironmentLease { pub(super) slot: String, pub(super) incarnation: String, pub(super) boot: String, + /// Value-free `(member name, container target)` pairs staged in this allocation. + files: Vec<(String, String)>, + wraps: bool, deadline: Instant, } enum Binding { @@ -107,6 +197,14 @@ impl PendingEnvironment { pub(super) fn service(&self) -> &str { &self.service } + /// Whether launcher admission (absolute entrypoint, CMD health) applies. + pub(super) fn wraps_process(&self) -> bool { + self.wraps + } + /// Public container targets of this service's private file members. + pub(super) fn file_targets(&self) -> Vec<&str> { + self.files.iter().map(ManagedFile::target).collect() + } pub fn new( service: &str, @@ -127,6 +225,18 @@ impl PendingEnvironment { service: &str, values: &BTreeMap, deadline: Instant, + ) -> Result { + Self::until_with_files(service, values, &[], deadline) + } + + /// Prepare values plus private file members. Values may be empty only when at least + /// one file exists; file names and targets are unique, targets never nest, and the + /// encoded values plus all file bytes share `MAX_ENVIRONMENT_PAYLOAD`. + pub fn until_with_files( + service: &str, + values: &BTreeMap, + files: &[ManagedFile], + deadline: Instant, ) -> Result { let valid_deadline = || { deadline @@ -136,9 +246,23 @@ impl PendingEnvironment { if !valid_deadline() { return Err(error("environment_expired")); } - if !name(service) || values.is_empty() || values.len() > MAX_ENVIRONMENT_KEYS { + if !name(service) + || (values.is_empty() && files.is_empty()) + || values.len() > MAX_ENVIRONMENT_KEYS + || files.len() > MAX_ENVIRONMENT_FILES + { return Err(error("environment_input")); } + for (index, file) in files.iter().enumerate() { + if files[..index].iter().any(|other| { + other.name == file.name + || other.target == file.target + || within(&other.target, &file.target) + || within(&file.target, &other.target) + }) { + return Err(error("environment_file")); + } + } // Bound input before allocating its encoded copy. JSON escaping is checked separately. let mut bytes = 0usize; for (k, v) in values { @@ -153,10 +277,19 @@ impl PendingEnvironment { return Err(error("environment_budget")); } } + for file in files { + bytes = bytes + .checked_add(file.content.len()) + .ok_or_else(|| error("environment_budget"))?; + if bytes > MAX_ENVIRONMENT_PAYLOAD { + return Err(error("environment_budget")); + } + } let payload = zeroize::Zeroizing::new( serde_json::to_string(values).map_err(|_| error("environment_input"))?, ); - if payload.len() > MAX_ENVIRONMENT_PAYLOAD { + let file_bytes: usize = files.iter().map(|file| file.content.len()).sum(); + if payload.len() + file_bytes > MAX_ENVIRONMENT_PAYLOAD { return Err(error("environment_budget")); } if !valid_deadline() { @@ -165,6 +298,8 @@ impl PendingEnvironment { Ok(Self { service: service.into(), payload, + wraps: !values.is_empty(), + files: files.to_vec(), uid: 0, gid: 0, deadline, @@ -225,6 +360,8 @@ impl PendingEnvironment { }; #[cfg(not(feature = "native-config-plan"))] let Binding::Compose(graph) = binding; + // Built before the lease takes ownership of the service name. + let (layout, input) = self.staging_input(); let lease = EnvironmentLease { graph, #[cfg(feature = "native-config-plan")] @@ -235,6 +372,12 @@ impl PendingEnvironment { slot: format!("hack-env-lease-{}-{token}", guest.boot_id()), incarnation: guest.incarnation().into(), boot: guest.boot_id().into(), + files: self + .files + .iter() + .map(|file| (file.name.clone(), file.target.clone())) + .collect(), + wraps: self.wraps, deadline: self.deadline, }; #[cfg(feature = "native-config-plan")] @@ -247,7 +390,7 @@ impl PendingEnvironment { super::environment_recovery::record(candidate, &lease)?; let seconds = remaining(lease.deadline)?; let result = guest.execute( - &format!("{CAPACITY_FUNCTION}\n{STAGE}"), + &format!("{CAPACITY_FUNCTION}\n{FILES_FUNCTION}\n{STAGE}"), &[ &lease.slot, &lease.service, @@ -257,8 +400,9 @@ impl PendingEnvironment { &MAX_CONCURRENT_ALLOCATIONS.to_string(), &ALLOCATION_BYTES.to_string(), &MAX_ENVIRONMENT_PAYLOAD.to_string(), + &layout, ], - Some(&self.payload), + Some(&input), ); if !result .as_ref() @@ -281,6 +425,39 @@ impl PendingEnvironment { Ok(lease) } } +impl PendingEnvironment { + /// Guest staging input. Without files it is exactly the values payload (the original + /// layout). With files, `layout` names the values byte length and, per member, + /// `name:encoded-length:decoded-length`; stdin is the payload followed by each + /// member's standard base64 text. Lengths and names are public; bytes never reach argv. + fn staging_input(&self) -> (String, zeroize::Zeroizing) { + use base64::Engine as _; + if self.files.is_empty() { + return (String::new(), self.payload.clone()); + } + let encoded = |file: &ManagedFile| { + zeroize::Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(&file.content)) + }; + let mut layout = self.payload.len().to_string(); + let mut capacity = self.payload.len(); + for file in &self.files { + let text = encoded(file); + capacity += text.len(); + layout.push_str(&format!( + " {}:{}:{}", + file.name, + text.len(), + file.content.len() + )); + } + let mut input = zeroize::Zeroizing::new(String::with_capacity(capacity)); + input.push_str(&self.payload); + for file in &self.files { + input.push_str(&encoded(file)); + } + (layout, input) + } +} fn remaining(deadline: Instant) -> Result { // Round down the advisory guest window. The host deadline remains authoritative. deadline @@ -321,6 +498,12 @@ impl EnvironmentLease { return Err(error("environment_service")); } remaining(self.deadline)?; + let names = self + .files + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + .join(" "); let result = guest.execute( VERIFY, &[ @@ -329,6 +512,8 @@ impl EnvironmentLease { &self.uid.to_string(), &self.gid.to_string(), &MAX_ENVIRONMENT_PAYLOAD.to_string(), + &names, + &MAX_ENVIRONMENT_FILE_BYTES.to_string(), ], None, )?; @@ -338,6 +523,18 @@ impl EnvironmentLease { remaining(self.deadline)?; Ok(format!("/run/{}/values.json", self.slot)) } + /// Read-only binds for this allocation's verified file members, as + /// `(guest source, container target)`. Call only after `verified_path_with_guest`. + pub(super) fn file_mounts(&self) -> Vec<(String, String)> { + self.files + .iter() + .map(|(name, target)| (format!("/run/{}/files/{name}", self.slot), target.clone())) + .collect() + } + /// Whether the container process is wrapped by the private launcher. + pub(super) fn wraps_process(&self) -> bool { + self.wraps + } /// Cleanup is permitted after expiry and is retry-safe for an absent slot on the same boot. pub fn remove(&self, candidate: &Candidate) -> Result<(), CandidateError> { let guest = self.guest(candidate, true)?; @@ -383,6 +580,42 @@ capacity_check() { done } "#; +// Reads exactly the declared byte counts with single-byte `dd` reads, so no tool can +// consume a later member. Each member decodes from base64, must match its decoded length, +// and becomes mode 0400 for the container owner inside a root-only 0700 directory. +// Trailing input refuses. Names are validated here as well as in Rust. +const FILES_FUNCTION: &str = r#" +stage_files() { + set -f + stage_root=$1 + stage_owner=$2 + set -- $3 + case "$1" in ''|*[!0-9]*) return 1;; esac + dd bs=1 count="$1" of="$stage_root/values.json" 2>/dev/null + test "$(($(wc -c < "$stage_root/values.json")))" = "$1" + shift + test "$#" -ge 1 + test "$#" -le 4 + mkdir -m 700 "$stage_root/files" + for stage_entry in "$@"; do + stage_name=${stage_entry%%:*} + stage_rest=${stage_entry#*:} + stage_encoded=${stage_rest%%:*} + stage_size=${stage_rest#*:} + case "$stage_name" in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) return 1;; esac + case "$stage_encoded" in ''|*[!0-9]*) return 1;; esac + case "$stage_size" in ''|*[!0-9]*) return 1;; esac + stage_file="$stage_root/files/$stage_name" + test ! -e "$stage_file" + test ! -L "$stage_file" + dd bs=1 count="$stage_encoded" 2>/dev/null | base64 -d > "$stage_file" + test "$(($(wc -c < "$stage_file")))" = "$stage_size" + chmod 400 "$stage_file" + chown "$stage_owner" "$stage_file" + done + test "$(($(dd bs=1 count=1 2>/dev/null | wc -c)))" = 0 +} +"#; const CAPACITY: &str = r#" ( set -eu @@ -407,7 +640,11 @@ test "$(findmnt -n -o SOURCE --mountpoint "$root")" = "$1" printf '%s' "$2" > "$root/service" now=$(cut -d. -f1 /proc/uptime) printf '%s' "$((now + $3))" > "$root/expires" -cat > "$root/values.json" +if test -z "$9"; then + cat > "$root/values.json" +else + stage_files "$root" "$4:$5" "$9" +fi test "$(stat -c %s "$root/values.json")" -le "$8" chmod 400 "$root/service" "$root/expires" "$root/values.json" chown "$4:$5" "$root/expires" "$root/values.json" @@ -434,6 +671,24 @@ done test "$(cat "$root/service")" = "$2" test "$(cut -d. -f1 /proc/uptime)" -lt "$(cat "$root/expires")" test "$(stat -c %s "$root/values.json")" -le "$5" +if test -n "$6"; then + test ! -L "$root/files" + test -d "$root/files" + test "$(stat -c %u:%g:%a "$root/files")" = 0:0:700 + set -f + count=0 + for name in $6; do + count=$((count + 1)) + test ! -L "$root/files/$name" + test -f "$root/files/$name" + test "$(stat -c %u:%g:%a:%h "$root/files/$name")" = "$3:$4:400:1" + test "$(stat -c %s "$root/files/$name")" -le "$7" + done + test "$(ls -A "$root/files" | wc -l)" -eq "$count" +else + test ! -e "$root/files" + test ! -L "$root/files" +fi ) >/dev/null 2>&1 printf 'environment-verified-v1\n' "#; @@ -537,6 +792,280 @@ mod tests { assert!(output.stderr.is_empty()); } + fn member(name: &str, target: &str, bytes: &[u8]) -> ManagedFile { + ManagedFile::new(name, target, zeroize::Zeroizing::new(bytes.to_vec())).unwrap() + } + #[test] + fn file_members_validate_names_targets_and_size_without_values() { + let content = || zeroize::Zeroizing::new(b"synthetic-file-canary".to_vec()); + for (name, target) in [ + ("", "/config/a.json"), + ("../a", "/config/a.json"), + (".hidden", "/config/a.json"), + ("a/b", "/config/a.json"), + ("a", "relative"), + ("a", "/"), + ("a", "/run/a"), + ("a", "/var/run/a"), + ("a", "/proc"), + ("a", "/dev/a"), + ("a", "/sys/a"), + ("a", "/config/../a"), + ("a", "/config/./a"), + ("a", "/config//a"), + ("a", "/config/a/"), + ("a", "/config/a\\b"), + ("a", "/config/a\nb"), + ] { + let error = ManagedFile::new(name, target, content()).err().unwrap(); + assert_eq!(error.code, "environment_file"); + assert!(!error.message.contains("synthetic-file-canary")); + } + assert!(ManagedFile::new("a", "/config/a", zeroize::Zeroizing::new(Vec::new())).is_err()); + assert!( + ManagedFile::new( + "a", + "/config/a", + zeroize::Zeroizing::new(vec![b'x'; MAX_ENVIRONMENT_FILE_BYTES + 1]) + ) + .is_err() + ); + let largest = ManagedFile::new( + "a.json", + "/home/app/.config/tool/credential file.json", + zeroize::Zeroizing::new(vec![b'x'; MAX_ENVIRONMENT_FILE_BYTES]), + ) + .unwrap(); + assert_eq!( + largest.target(), + "/home/app/.config/tool/credential file.json" + ); + assert!(ManagedFile::new("a", "/runner/a", content()).is_ok()); + assert!(ManagedFile::new("a", "/varrun/a", content()).is_ok()); + } + #[test] + fn file_preparation_admits_files_only_services_and_bounds_the_shared_budget() { + let deadline = Instant::now() + Duration::from_secs(60); + let empty = BTreeMap::new(); + let values = BTreeMap::from([("TOKEN".into(), "synthetic-only".into())]); + let one = [member("adc.json", "/config/adc.json", b"{\"type\":\"x\"}")]; + let files_only = + PendingEnvironment::until_with_files("proxy", &empty, &one, deadline).unwrap(); + assert!(!files_only.wraps_process()); + assert_eq!(files_only.file_targets(), vec!["/config/adc.json"]); + assert_eq!(&*files_only.payload, "{}"); + let both = PendingEnvironment::until_with_files("web", &values, &one, deadline).unwrap(); + assert!(both.wraps_process()); + let plain = PendingEnvironment::until("web", &values, deadline).unwrap(); + assert!(plain.wraps_process() && plain.file_targets().is_empty()); + assert_eq!( + PendingEnvironment::until_with_files("web", &empty, &[], deadline) + .err() + .unwrap() + .code, + "environment_input" + ); + let five: Vec<_> = (0..5) + .map(|n| member(&format!("f{n}"), &format!("/config/f{n}"), b"x")) + .collect(); + assert!(PendingEnvironment::until_with_files("web", &empty, &five[..4], deadline).is_ok()); + assert!(PendingEnvironment::until_with_files("web", &empty, &five, deadline).is_err()); + for pair in [ + [ + member("a", "/config/a", b"x"), + member("a", "/config/b", b"x"), + ], + [ + member("a", "/config/a", b"x"), + member("b", "/config/a", b"x"), + ], + [member("a", "/config", b"x"), member("b", "/config/b", b"x")], + [ + member("a", "/config/b/c", b"x"), + member("b", "/config/b", b"x"), + ], + ] { + assert_eq!( + PendingEnvironment::until_with_files("web", &empty, &pair, deadline) + .err() + .unwrap() + .code, + "environment_file" + ); + } + let half = MAX_ENVIRONMENT_FILE_BYTES; + let full = [ + member("a", "/config/a", &vec![b'x'; half]), + member("b", "/config/b", &vec![b'x'; half - 2]), + ]; + assert!(PendingEnvironment::until_with_files("web", &empty, &full, deadline).is_ok()); + let error = PendingEnvironment::until_with_files("web", &values, &full, deadline) + .err() + .unwrap(); + assert_eq!(error.code, "environment_budget"); + assert!( + PendingEnvironment::until_with_files( + "web", + &empty, + &one, + Instant::now() - Duration::from_secs(1) + ) + .is_err() + ); + } + #[test] + fn staging_input_keeps_the_original_layout_without_files() { + let deadline = Instant::now() + Duration::from_secs(60); + let values = BTreeMap::from([("TOKEN".into(), "synthetic-only".into())]); + let plain = PendingEnvironment::until("web", &values, deadline).unwrap(); + let (layout, input) = plain.staging_input(); + assert!(layout.is_empty()); + assert_eq!(&*input, &*plain.payload); + let files = [ + member( + "a.json", + "/config/a.json", + b"{\"k\":\"synthetic-file-canary\"}", + ), + member("b", "/config/b", &[0, 255, 10, 13]), + ]; + let staged = + PendingEnvironment::until_with_files("web", &values, &files, deadline).unwrap(); + let (layout, input) = staged.staging_input(); + assert_eq!( + layout, + format!("{} a.json:40:29 b:8:4", staged.payload.len()) + ); + assert!(!layout.contains("synthetic")); + assert!(input.starts_with(&*staged.payload)); + assert!(!input[staged.payload.len()..].contains("synthetic-file-canary")); + } + /// Runs the guest member-staging function on the host shell against a scratch root. + /// `stat -c` is avoided there so the same body runs on macOS and the Linux guest. + fn stage_members(layout: &str, input: &[u8]) -> (bool, std::path::PathBuf) { + use std::{ + io::Write, + process::{Command, Stdio}, + }; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("hack-env-files-{}-{nonce}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let owner = format!("{}:{}", unsafe { libc::geteuid() }, unsafe { + libc::getegid() + }); + let script = format!( + "{FILES_FUNCTION}\n(\nset -eu\nstage_files \"$1\" \"$2\" \"$3\"\n) >/dev/null 2>&1\nprintf 'staged\\n'\n" + ); + let mut child = Command::new("/bin/sh") + .args([ + "-eu", + "-c", + &script, + "stage", + root.to_str().unwrap(), + &owner, + layout, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.stderr.is_empty()); + (output.stdout == b"staged\n", root) + } + #[test] + fn member_staging_reads_exact_lengths_and_refuses_malformed_input() { + use std::os::unix::fs::PermissionsExt; + let deadline = Instant::now() + Duration::from_secs(60); + let values = BTreeMap::from([("TOKEN".into(), "synthetic-only".into())]); + let binary: Vec = (0..=255).collect(); + let files = [ + member( + "a.json", + "/config/a.json", + b"{\"k\":\"synthetic-file-canary\"}\n", + ), + member("b.bin", "/config/b.bin", &binary), + ]; + let staged = + PendingEnvironment::until_with_files("web", &values, &files, deadline).unwrap(); + let (layout, input) = staged.staging_input(); + let (ok, root) = stage_members(&layout, input.as_bytes()); + assert!(ok); + assert_eq!( + std::fs::read(root.join("values.json")).unwrap(), + staged.payload.as_bytes() + ); + assert_eq!( + std::fs::read(root.join("files/a.json")).unwrap(), + b"{\"k\":\"synthetic-file-canary\"}\n" + ); + assert_eq!(std::fs::read(root.join("files/b.bin")).unwrap(), binary); + for name in ["files/a.json", "files/b.bin"] { + let mode = std::fs::metadata(root.join(name)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o400); + } + let directory = std::fs::metadata(root.join("files")) + .unwrap() + .permissions() + .mode(); + assert_eq!(directory & 0o777, 0o700); + let _ = std::fs::set_permissions( + root.join("files/a.json"), + std::fs::Permissions::from_mode(0o600), + ); + let _ = std::fs::set_permissions( + root.join("files/b.bin"), + std::fs::Permissions::from_mode(0o600), + ); + std::fs::remove_dir_all(&root).unwrap(); + let bytes = input.as_bytes(); + let decoded = files[0].content().len(); + let payload = staged.payload.len(); + for (layout, input) in [ + // Trailing, truncated and corrupted input. + (layout.clone(), [bytes, b"x"].concat()), + (layout.clone(), bytes[..bytes.len() - 1].to_vec()), + ( + layout.replacen(&format!(":{decoded} "), &format!(":{} ", decoded + 1), 1), + bytes.to_vec(), + ), + // Unsafe or missing names and lengths. + (layout.replacen("a.json", "../a", 1), bytes.to_vec()), + (layout.replacen("a.json", ".a", 1), bytes.to_vec()), + (format!("{payload}"), bytes[..payload].to_vec()), + ( + format!("x {}", &layout[layout.find(' ').unwrap() + 1..]), + bytes.to_vec(), + ), + ( + format!("{payload} a:1:1 b:1:1 c:1:1 d:1:1 e:1:1"), + bytes.to_vec(), + ), + ] { + let (ok, root) = stage_members(&layout, &input); + assert!(!ok, "{layout}"); + for name in ["files/a.json", "files/b.bin", "files/a", "files/b"] { + let _ = std::fs::set_permissions( + root.join(name), + std::fs::Permissions::from_mode(0o600), + ); + } + std::fs::remove_dir_all(&root).unwrap(); + } + } #[test] fn absolute_preparation_preserves_exact_deadline_and_rejects_expiry() { let values = BTreeMap::from([("TOKEN".into(), "synthetic-only".into())]); diff --git a/packages/runtime-core/src/provider/graph/foreground.rs b/packages/runtime-core/src/provider/graph/foreground.rs index d4c64b761..c24a47f32 100644 --- a/packages/runtime-core/src/provider/graph/foreground.rs +++ b/packages/runtime-core/src/provider/graph/foreground.rs @@ -79,7 +79,10 @@ pub fn serve_with_environment( candidate, options, runtime, - managed, + super::ManagedInput { + values: managed, + files: &super::ManagedFiles::new(), + }, deadline, &BTreeMap::new(), ) @@ -90,7 +93,7 @@ pub fn serve_with_routes( candidate: &Candidate, options: RunOptions<'_>, runtime: HostRelayRuntime, - managed: &BTreeMap>, + managed: super::ManagedInput<'_>, deadline: Instant, route_slots: &BTreeMap, ) -> Result { @@ -112,7 +115,7 @@ pub fn serve_normalized_with_routes( candidate: &Candidate, options: super::NormalizedRunOptions<'_>, runtime: HostRelayRuntime, - managed: &BTreeMap>, + managed: super::ManagedInput<'_>, deadline: Instant, route_slots: &BTreeMap, ) -> Result { @@ -132,7 +135,7 @@ pub fn serve_restore_normalized_with_routes( candidate: &Candidate, options: super::NormalizedRunOptions<'_>, runtime: HostRelayRuntime, - managed: &BTreeMap>, + managed: super::ManagedInput<'_>, deadline: Instant, route_slots: &BTreeMap, generation: &str, @@ -187,7 +190,7 @@ fn serve_input<'a>( candidate: &Candidate, options: RunOptions<'a>, mut runtime: HostRelayRuntime, - managed: &BTreeMap>, + managed: super::ManagedInput<'_>, deadline: Instant, route_slots: &BTreeMap, normalized: Option<(crate::project::NormalizedComposeOptions<'a>, Option<&str>)>, @@ -195,8 +198,10 @@ fn serve_input<'a>( if options.routing_enrolled != !route_slots.is_empty() { return Err(refused()); } + let input = managed; + let (managed, files) = (input.values, input.files); if let Some((compose, _)) = normalized { - super::compile_normalized_inputs( + let compiled = super::compile_normalized_inputs( candidate, &super::NormalizedRunOptions { run: redelivery::options(&options), @@ -204,10 +209,13 @@ fn serve_input<'a>( }, managed, )?; - } else if !managed.is_empty() { + if !files.is_empty() { + super::pending_environments(&compiled, files, deadline)?; + } + } else if !managed.is_empty() || !files.is_empty() { // Refuse invalid scoped ownership or payload before publishing an owner. // Runtime admission compiles again to detect intervening project changes. - super::compile_environment_inputs_until(candidate, &options, managed, deadline)?; + super::compile_environment_inputs_until(candidate, &options, managed, files, deadline)?; } let run_id = options.run_id.to_owned(); let fresh_generation = normalized.and_then(|(_, generation)| generation); @@ -236,7 +244,7 @@ fn serve_input<'a>( run: redelivery::options(&options), compose, }, - managed, + input, deadline, &mut runtime, generation, @@ -251,6 +259,7 @@ fn serve_input<'a>( }, &mut runtime, managed, + files, deadline, ) } else { @@ -259,6 +268,7 @@ fn serve_input<'a>( redelivery::options(&options), &mut runtime, managed, + files, deadline, ) }; diff --git a/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs b/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs index f0321a270..4bd89c357 100644 --- a/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs +++ b/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs @@ -71,6 +71,7 @@ fn owner_child() { }, &mut runtime, &BTreeMap::new(), + &graph::ManagedFiles::new(), Instant::now() + Duration::from_secs(90), ) .unwrap(); diff --git a/packages/runtime-core/src/provider/graph/foreground/redelivery.rs b/packages/runtime-core/src/provider/graph/foreground/redelivery.rs index a1a441e31..d2e3ac311 100644 --- a/packages/runtime-core/src/provider/graph/foreground/redelivery.rs +++ b/packages/runtime-core/src/provider/graph/foreground/redelivery.rs @@ -76,6 +76,7 @@ pub(super) fn restore( candidate, &replay, managed.values(), + managed.files(), managed.deadline(), )?; { @@ -115,6 +116,7 @@ pub(super) fn restore( candidate, replay, managed.values(), + managed.files(), managed.deadline(), runtime, ) diff --git a/packages/runtime-core/src/provider/graph/image_process.rs b/packages/runtime-core/src/provider/graph/image_process.rs index 76676e8ff..4e9fd6c25 100644 --- a/packages/runtime-core/src/provider/graph/image_process.rs +++ b/packages/runtime-core/src/provider/graph/image_process.rs @@ -114,10 +114,40 @@ pub(super) fn apply_private(create: &mut Value, image: &Value) -> Result<(), Can Ok(()) } +/// 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> { + if !image.is_object() || !create.is_object() { + return Err(malformed()); + } + let user = selected_user(image, create)?; + create["User"] = json!(user); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn file_owner_pins_numeric_image_user_without_changing_process() { + let image = json!({"Entrypoint":["entry"],"Cmd":["serve"],"User":"65532"}); + let mut create = json!({"Cmd":["serve","--flag"]}); + apply_file_owner(&mut create, &image).unwrap(); + assert_eq!(create, json!({"Cmd":["serve","--flag"],"User":"65532"})); + let mut explicit = json!({"User":"1001:1002"}); + apply_file_owner(&mut explicit, &image).unwrap(); + assert_eq!(explicit["User"], "1001:1002"); + let mut root = json!({}); + apply_file_owner(&mut root, &json!({})).unwrap(); + assert_eq!(root["User"], ""); + for user in ["app", "1:staff", "-1"] { + let mut create = json!({}); + assert!(apply_file_owner(&mut create, &json!({"User":user})).is_err()); + assert!(create.get("User").is_none()); + } + } + #[test] fn inherits_image_entrypoint_but_keeps_actual_compose_command() { let image = json!({"Entrypoint":["/usr/local/bin/docker-entrypoint.sh"],"Cmd":["node"]}); diff --git a/packages/runtime-core/src/provider/graph/launcher.rs b/packages/runtime-core/src/provider/graph/launcher.rs index 9064a6f3a..9d6a5c104 100644 --- a/packages/runtime-core/src/provider/graph/launcher.rs +++ b/packages/runtime-core/src/provider/graph/launcher.rs @@ -209,6 +209,63 @@ pub(super) fn attach(config: &mut Value, path: &str, launcher: &str) -> Result<( Ok(()) } +fn file_refused() -> CandidateError { + error( + "environment_file_mount", + "A private file target is reserved or overlaps another container mount; values omitted.", + ) +} +/// Container paths already claimed by compiled mounts and tmpfs entries. +fn claimed_targets(config: &Value) -> Vec { + let mut targets: Vec = config["HostConfig"]["Mounts"] + .as_array() + .into_iter() + .flatten() + .map(|mount| mount["Target"].as_str().unwrap_or("").to_owned()) + .collect(); + if let Some(tmpfs) = config["HostConfig"]["Tmpfs"].as_object() { + targets.extend(tmpfs.keys().cloned()); + } + targets +} +/// Private file targets must be reserved-free absolute paths that neither equal, contain +/// nor sit under any compiled mount or tmpfs target. A target that cannot be parsed refuses. +pub(super) fn validate_files(config: &Value, targets: &[&str]) -> Result<(), CandidateError> { + let claimed = claimed_targets(config); + for target in targets { + if !crate::provider::environment::file_target(target) + || claimed.iter().any(|other| { + other.is_empty() + || other == target + || crate::provider::environment::within(target, other) + || crate::provider::environment::within(other, target) + }) + { + return Err(file_refused()); + } + } + Ok(()) +} +/// Bind each verified allocation member read-only at its declared target. Called after +/// `attach` for wrapped services, so reserved launcher binds are also checked. +pub(super) fn attach_files( + config: &mut Value, + files: &[(String, String)], +) -> Result<(), CandidateError> { + if files.is_empty() { + return Ok(()); + } + let targets: Vec<&str> = files.iter().map(|(_, target)| target.as_str()).collect(); + validate_files(config, &targets)?; + let mounts = config["HostConfig"]["Mounts"] + .as_array_mut() + .ok_or_else(file_refused)?; + for (source, target) in files { + mounts.push(json!({"Type":"bind","Source":source,"Target":target,"ReadOnly":true,"BindOptions":{"Propagation":"rprivate"}})); + } + Ok(()) +} + #[cfg(not(feature = "environment-launcher"))] pub(super) fn publish(_: &Engine<'_>) -> Result { Err(error( @@ -304,6 +361,63 @@ mod tests { } } #[test] + fn private_files_bind_read_only_after_launcher_mounts_and_refuse_overlap() { + let mut value = config(); + value["HostConfig"]["Mounts"] = json!([{"Type":"volume","Target":"/data"}]); + attach(&mut value, "/run/slot/values.json", "/storage/launcher").unwrap(); + let files = vec![( + "/run/slot/files/credential.json".to_owned(), + "/config/credential.json".to_owned(), + )]; + attach_files(&mut value, &files).unwrap(); + let mounts = value["HostConfig"]["Mounts"].as_array().unwrap(); + assert_eq!( + mounts.last().unwrap(), + &json!({"Type":"bind","Source":"/run/slot/files/credential.json","Target":"/config/credential.json","ReadOnly":true,"BindOptions":{"Propagation":"rprivate"}}) + ); + // Files-only services keep their process; only the bind is added. + let mut plain = config(); + attach_files(&mut plain, &files).unwrap(); + assert_eq!(plain["Entrypoint"], config()["Entrypoint"]); + assert_eq!(plain["HostConfig"]["Mounts"].as_array().unwrap().len(), 1); + for (claimed, target) in [ + ("/config/credential.json", "/config/credential.json"), + ("/config", "/config/credential.json"), + ("/config/credential.json/inner", "/config/credential.json"), + ("/", "/config/credential.json"), + ("", "/config/credential.json"), + ] { + let mut value = config(); + value["HostConfig"]["Mounts"] = json!([{"Type":"bind","Target":claimed}]); + assert_eq!( + validate_files(&value, &[target]).err().unwrap().code, + "environment_file_mount" + ); + } + let mut value = config(); + value["HostConfig"]["Tmpfs"] = json!({"/tmp":"rw"}); + assert!(validate_files(&value, &["/tmp/credential.json"]).is_err()); + assert!(validate_files(&value, &["/tmpfile"]).is_ok()); + for target in [ + "/", + "relative", + "/run/credential", + "/var/run/credential", + "/proc/credential", + "/sys", + "/dev/shm/key", + "/config/../etc/passwd", + "/config//key", + "/config/key/", + "/config/./key", + ] { + assert!(validate_files(&config(), &[target]).is_err(), "{target}"); + } + let mut value = config(); + value["HostConfig"]["Mounts"] = json!(null); + assert!(attach_files(&mut value, &files).is_err()); + } + #[test] fn ambiguous_users_health_exec_and_mount_collisions_refuse_before_attachment() { for user in ["bun", "-1:2", "1:4294967295", "1:", "1:2:3"] { let mut value = config(); diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..83d5f7a95 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -143,6 +143,7 @@ pub use service_logs::{ ServiceLogOptions, ServiceLogResult, ServiceSelection, service_logs, service_selection, }; mod source; +pub use super::environment::{ManagedFile, ManagedFiles, ManagedInput}; use super::{engine::Engine, state}; use crate::{ Candidate, CandidateError, @@ -982,12 +983,18 @@ impl Driver for Session<'_, '_> { #[cfg(test)] self.fault_pause("after-environment-stage")?; let path = lease.verified_path_with_guest(self.engine.guest(), service)?; + let files = lease.file_mounts(); + let wraps = lease.wraps_process(); self.leases.insert(service.into(), lease); - launcher::attach( - self.configs.get_mut(service).expect("service"), - &path, - self.launcher.as_deref().expect("prepared launcher"), - )?; + let config = self.configs.get_mut(service).expect("service"); + if wraps { + launcher::attach( + config, + &path, + self.launcher.as_deref().expect("prepared launcher"), + )?; + } + launcher::attach_files(config, &files)?; } if let Some(startup) = &self.receipt.relay_startup { if let Some(selected) = startup.services.get(service) { @@ -1089,6 +1096,7 @@ pub fn run_with_host_dependencies( options, runtime, managed, + &ManagedFiles::new(), environment_deadline(lifetime)?, ) } @@ -1099,11 +1107,12 @@ pub fn run_with_host_dependencies_until( options: RunOptions<'_>, runtime: &mut HostRelayRuntime, managed: &BTreeMap>, + files: &ManagedFiles, deadline: std::time::Instant, ) -> Result { startup::Driver::check_cancelled(runtime)?; check_environment_deadline(deadline)?; - let (inputs, environments) = if managed.is_empty() { + let (inputs, environments) = if managed.is_empty() && files.is_empty() { ( project::inputs::compile( candidate, @@ -1119,7 +1128,7 @@ pub fn run_with_host_dependencies_until( BTreeMap::new(), ) } else { - compile_environment_inputs_until(candidate, &options, managed, deadline)? + compile_environment_inputs_until(candidate, &options, managed, files, deadline)? }; check_environment_deadline(deadline)?; startup::Driver::check_cancelled(runtime)?; @@ -1182,12 +1191,19 @@ fn compile_environment_inputs( managed: &BTreeMap>, lifetime: Duration, ) -> Result { - compile_environment_inputs_until(candidate, options, managed, environment_deadline(lifetime)?) + compile_environment_inputs_until( + candidate, + options, + managed, + &ManagedFiles::new(), + environment_deadline(lifetime)?, + ) } pub(super) fn compile_environment_inputs_until( candidate: &Candidate, options: &RunOptions<'_>, managed: &BTreeMap>, + files: &ManagedFiles, deadline: std::time::Instant, ) -> Result { if !cfg!(feature = "environment-launcher") { @@ -1209,17 +1225,80 @@ pub(super) fn compile_environment_inputs_until( options.non_secret_values, managed, )?; - let environments = scoped - .managed_environment - .iter() - .map(|(name, values)| { - super::environment::PendingEnvironment::until(name, values, deadline) - .map(|pending| (name.clone(), pending)) - }) - .collect::, _>>()?; + let environments = pending_environments(&scoped, files, deadline)?; check_environment_deadline(deadline)?; Ok((scoped.executable, environments)) } +/// Prepare one allocation per service with scoped values and/or private file members. +/// File members require an active reviewed service; they never interpolate or replace +/// Compose configuration. Values-only services keep the original launcher contract. +pub(super) fn pending_environments( + scoped: &project::inputs::ScopedExecutionInputs, + files: &ManagedFiles, + deadline: std::time::Instant, +) -> Result, CandidateError> { + if files.keys().any(|name| { + !scoped + .executable + .review + .plan + .services + .get(name) + .is_some_and(|service| service.active) + }) { + return Err(error( + "execution_environment_owner", + "Managed file members require active service ownership.", + )); + } + let empty = BTreeMap::new(); + scoped + .managed_environment + .keys() + .chain(files.keys()) + .collect::>() + .into_iter() + .map(|name| { + super::environment::PendingEnvironment::until_with_files( + name, + scoped.managed_environment.get(name).unwrap_or(&empty), + files.get(name).map_or(&[], Vec::as_slice), + deadline, + ) + .map(|pending| (name.clone(), pending)) + }) + .collect() +} +/// Launcher admission for wrapped services, owner identity for every private service, +/// and private-file target checks against the compiled container mounts. +pub(super) fn validate_private_delivery( + configs: &BTreeMap, + environments: &BTreeMap, +) -> Result<(), CandidateError> { + for (name, pending) in environments { + let config = &configs[name]; + if pending.wraps_process() { + launcher::validate(config)?; + } else { + launcher::identity(config)?; + } + launcher::validate_files(config, &pending.file_targets())?; + } + Ok(()) +} +/// 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( + environments: &BTreeMap, +) -> (BTreeSet, BTreeSet) { + let (wrapped, owners): (Vec<_>, Vec<_>) = environments + .iter() + .partition(|(_, pending)| pending.wraps_process()); + ( + wrapped.into_iter().map(|(name, _)| name.clone()).collect(), + owners.into_iter().map(|(name, _)| name.clone()).collect(), + ) +} fn run_inputs( candidate: &Candidate, @@ -1320,15 +1399,15 @@ fn run_inputs( } // 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); let expected_environment = verify_images( &engine, &prepared.resources, &mut prepared.configs, - &environments.keys().cloned().collect(), + &wrapped, + &file_owners, )?; - for name in environments.keys() { - launcher::validate(&prepared.configs[name])?; - } + validate_private_delivery(&prepared.configs, &environments)?; if let Some(driver) = startup.as_ref() { driver.check_cancelled()?; } @@ -1885,6 +1964,7 @@ pub fn restart(candidate: &Candidate, options: RunOptions<'_>) -> Result, configs: &mut BTreeMap, private_services: &BTreeSet, + file_owners: &BTreeSet, ) -> Result>, CandidateError> { let mut expected = BTreeMap::new(); for resource in resources.values().filter(|r| r.kind == Kind::Container) { @@ -2181,6 +2262,13 @@ fn verify_images( .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"], + )?; } expected.insert( resource.key.clone(), diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index 074c93ea7..a0786804c 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -1074,7 +1074,13 @@ pub(super) fn run_guarded( return Err(refused()); } let expected = if config.data.is_empty() { - verify_images(&engine, &config.resources, &mut config.configs, &private)? + verify_images( + &engine, + &config.resources, + &mut config.configs, + &private, + &BTreeSet::new(), + )? } else { for service in config.data_mounts.keys() { storage_process_policy(&config.configs[service])?; diff --git a/packages/runtime-core/src/provider/graph/normalized.rs b/packages/runtime-core/src/provider/graph/normalized.rs index d3c80adbf..a0e8ef15e 100644 --- a/packages/runtime-core/src/provider/graph/normalized.rs +++ b/packages/runtime-core/src/provider/graph/normalized.rs @@ -99,7 +99,14 @@ pub fn run_normalized( managed: &BTreeMap>, deadline: Instant, ) -> Result { - run_until(candidate, options, managed, deadline, None) + run_until( + candidate, + options, + managed, + &ManagedFiles::new(), + deadline, + None, + ) } #[cfg(target_os = "macos")] @@ -108,15 +115,17 @@ pub fn run_normalized_with_host_dependencies_until( options: NormalizedRunOptions<'_>, runtime: &mut HostRelayRuntime, managed: &BTreeMap>, + files: &ManagedFiles, deadline: Instant, ) -> Result { - run_until(candidate, options, managed, deadline, Some(runtime)) + run_until(candidate, options, managed, files, deadline, Some(runtime)) } fn run_until( candidate: &Candidate, options: NormalizedRunOptions<'_>, managed: &BTreeMap>, + files: &ManagedFiles, deadline: Instant, startup: Option<&mut dyn startup::Driver>, ) -> Result { @@ -125,20 +134,15 @@ fn run_until( } check_environment_deadline(deadline)?; let compiled = compile_normalized_inputs(candidate, &options, managed)?; - if !compiled.managed_environment.is_empty() && !cfg!(feature = "environment-launcher") { + if (!compiled.managed_environment.is_empty() || !files.is_empty()) + && !cfg!(feature = "environment-launcher") + { return Err(error( "environment_launcher_disabled", "Build with environment-launcher for private input delivery.", )); } - let environments = compiled - .managed_environment - .iter() - .map(|(name, values)| { - super::super::environment::PendingEnvironment::until(name, values, deadline) - .map(|v| (name.clone(), v)) - }) - .collect::, _>>()?; + let environments = pending_environments(&compiled, files, deadline)?; let identity = NormalizedInputIdentity { namespace: compiled.executable.review.plan.namespace.clone(), original_compose_sha256: options.compose.expected_compose_sha256.into(), diff --git a/packages/runtime-core/src/provider/graph/one_off/runtime.rs b/packages/runtime-core/src/provider/graph/one_off/runtime.rs index 3aced9bc7..861fabf36 100644 --- a/packages/runtime-core/src/provider/graph/one_off/runtime.rs +++ b/packages/runtime-core/src/provider/graph/one_off/runtime.rs @@ -62,6 +62,7 @@ pub fn begin( &BTreeMap::from([(key.clone(), resource.clone())]), &mut configs, &private, + &BTreeSet::new(), )?; let mut environments = BTreeMap::new(); if let Some(values) = values.filter(|v| !v.is_empty()) { diff --git a/packages/runtime-core/src/provider/graph/restore.rs b/packages/runtime-core/src/provider/graph/restore.rs index d75e4feab..9e7ded543 100644 --- a/packages/runtime-core/src/provider/graph/restore.rs +++ b/packages/runtime-core/src/provider/graph/restore.rs @@ -41,8 +41,13 @@ pub(crate) fn restore_with_environment_until( managed: &BTreeMap>, deadline: std::time::Instant, ) -> Result { - let (inputs, environments) = - compile_environment_inputs_until(candidate, &options, managed, deadline)?; + let (inputs, environments) = compile_environment_inputs_until( + candidate, + &options, + managed, + &ManagedFiles::new(), + deadline, + )?; restore_inputs(candidate, options, inputs, environments, true, None, None) } /// Retains the existing control-only foreground driver for owner checks at every @@ -52,11 +57,12 @@ pub(super) fn restore_with_foreground( candidate: &Candidate, options: RunOptions<'_>, managed: &BTreeMap>, + files: &ManagedFiles, deadline: std::time::Instant, runtime: &mut HostRelayRuntime, ) -> Result { let (inputs, environments) = - compile_environment_inputs_until(candidate, &options, managed, deadline)?; + compile_environment_inputs_until(candidate, &options, managed, files, deadline)?; restore_inputs( candidate, options, @@ -78,12 +84,13 @@ pub(super) struct FreshOwnerRestore<'a> { pub(super) fn restore_normalized_foreground( candidate: &Candidate, options: NormalizedRunOptions<'_>, - managed: &BTreeMap>, + managed: ManagedInput<'_>, deadline: std::time::Instant, runtime: &mut HostRelayRuntime, generation: &str, verify_publication: &dyn Fn() -> Result<(), CandidateError>, ) -> Result { + let (managed, files) = (managed.values, managed.files); startup::Driver::check_cancelled(runtime)?; check_environment_deadline(deadline)?; let compiled = compile_normalized_inputs(candidate, &options, managed)?; @@ -92,14 +99,7 @@ pub(super) fn restore_normalized_foreground( original_compose_sha256: options.compose.expected_compose_sha256.into(), normalized_compose_sha256: compiled.executable.review.plan.compose_sha256.clone(), }; - let environments = compiled - .managed_environment - .iter() - .map(|(name, values)| { - super::super::environment::PendingEnvironment::until(name, values, deadline) - .map(|pending| (name.clone(), pending)) - }) - .collect::, _>>()?; + let environments = pending_environments(&compiled, files, deadline)?; check_environment_deadline(deadline)?; restore_inputs( candidate, @@ -405,11 +405,13 @@ fn restore_inputs( if !environments.is_empty() { super::super::environment::preflight_capacity(engine.guest(), environments.len())?; } + let (wrapped, file_owners) = private_services(&environments); let expected_environment = verify_images( &engine, &prepared.resources, &mut prepared.configs, - &environments.keys().cloned().collect(), + &wrapped, + &file_owners, )?; for resource in receipt.resources.values() { let present = inspect_resource(&engine, &receipt, resource)?.is_some(); @@ -434,9 +436,7 @@ fn restore_inputs( } } } - for name in environments.keys() { - launcher::validate(&prepared.configs[name])?; - } + validate_private_delivery(&prepared.configs, &environments)?; if let Some(fresh) = &fresh { check_environment_deadline(fresh.deadline)?; #[cfg(target_os = "macos")] diff --git a/packages/runtime-core/src/provider/graph/startup/native_test.rs b/packages/runtime-core/src/provider/graph/startup/native_test.rs index 53b5782c1..f430c3b9a 100644 --- a/packages/runtime-core/src/provider/graph/startup/native_test.rs +++ b/packages/runtime-core/src/provider/graph/startup/native_test.rs @@ -609,6 +609,7 @@ fn qualify_active_rebind(refreshable: bool, cancel_after_fence: bool) { }, &mut runtime, &BTreeMap::new(), + &crate::provider::graph::ManagedFiles::new(), Instant::now() + Duration::from_secs(60), ) .unwrap(); @@ -791,7 +792,10 @@ fn qualify_active_rebind(refreshable: bool, cancel_after_fence: bool) { compose, run: options(&fixture.0, &plan.plan_id, &run, &readiness, &public), }, - &BTreeMap::new(), + crate::provider::graph::ManagedInput { + values: &BTreeMap::new(), + files: &crate::provider::graph::ManagedFiles::new(), + }, Instant::now() + Duration::from_secs(60), &mut fresh, &restored_generation, diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..28e214b71 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -1788,6 +1788,122 @@ fn public_environment_composes_with_private_delivery_without_metadata_leakage() assert!(!candidate.state_root.exists()); } +#[test] +fn private_file_members_need_active_services_and_bind_beside_compiled_mounts() { + use crate::provider::environment::ManagedFile; + let fixture = Fixture::new(); + let home = Fixture::new(); + let candidate = Candidate::discover(&home.0).unwrap(); + let document = json!({"services":{ + "web":{"image":format!("sha256:{}", "a".repeat(64)),"read_only":true,"network_mode":"none", + "user":"1001:1002","entrypoint":["/bin/app"],"environment":{"TOKEN":null, + "CREDENTIAL_FILE":"/config/credential.json"}}, + "proxy":{"image":format!("sha256:{}", "b".repeat(64)),"read_only":true,"network_mode":"none", + "user":"65532","environment":{"CREDENTIAL_FILE":"/config/credential.json"}}, + "idle":{"image":format!("sha256:{}", "c".repeat(64)),"network_mode":"none","profiles":["later"]} + }}); + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let managed = BTreeMap::from([( + "web".into(), + BTreeMap::from([("TOKEN".into(), "synthetic-private-canary".into())]), + )]); + let scoped = project::inputs::compile_scoped( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + &managed, + ) + .unwrap(); + let member = || { + ManagedFile::new( + "credential.json", + "/config/credential.json", + zeroize::Zeroizing::new(b"synthetic-file-canary".to_vec()), + ) + .unwrap() + }; + let deadline = std::time::Instant::now() + Duration::from_secs(60); + let files = ManagedFiles::from([ + ("web".into(), vec![member()]), + ("proxy".into(), vec![member()]), + ]); + let environments = pending_environments(&scoped, &files, deadline).unwrap(); + assert_eq!( + environments.keys().collect::>(), + vec!["proxy", "web"] + ); + assert!(environments["web"].wraps_process()); + assert!(!environments["proxy"].wraps_process()); + let (wrapped, owners) = private_services(&environments); + assert_eq!(wrapped, BTreeSet::from(["web".to_owned()])); + assert_eq!(owners, BTreeSet::from(["proxy".to_owned()])); + for absent in ["idle", "unknown"] { + let error = pending_environments( + &scoped, + &ManagedFiles::from([(absent.into(), vec![member()])]), + deadline, + ) + .err() + .unwrap(); + assert_eq!(error.code, "execution_environment_owner"); + } + let mut prepared = config::prepare_delivery( + scoped.executable, + &BTreeMap::from([ + ("web".into(), Condition::Started), + ("proxy".into(), Condition::Started), + ]), + &token(), + &token(), + None, + config::DeliveryOptions { + environment: true, + ..Default::default() + }, + ) + .unwrap(); + // Files-only services need only a numeric owner, not launcher admission. + validate_private_delivery(&prepared.configs, &environments).unwrap(); + let proxy = prepared.configs.get_mut("proxy").unwrap(); + let process = (proxy["Entrypoint"].clone(), proxy["Cmd"].clone()); + launcher::attach_files( + proxy, + &[( + "/run/slot/files/credential.json".into(), + "/config/credential.json".into(), + )], + ) + .unwrap(); + assert_eq!((proxy["Entrypoint"].clone(), proxy["Cmd"].clone()), process); + assert!( + proxy["Env"] + .as_array() + .unwrap() + .contains(&json!("CREDENTIAL_FILE=/config/credential.json")) + ); + assert!( + !serde_json::to_string(&prepared.configs) + .unwrap() + .contains("synthetic") + ); + // A compiled mount at or around a declared target refuses before any effect. + let web = prepared.configs.get_mut("web").unwrap(); + web["HostConfig"]["Mounts"] + .as_array_mut() + .unwrap() + .push(json!({"Type":"volume","Target":"/config"})); + assert_eq!( + validate_private_delivery(&prepared.configs, &environments) + .err() + .unwrap() + .code, + "environment_file_mount" + ); + assert!(!candidate.state_root.exists()); +} + #[test] fn dependency_cache_graphs_share_only_verified_binding_and_pin_restore_targets() { let fixture = Fixture::new(); diff --git a/packages/runtime-core/src/provider/managed_environment.rs b/packages/runtime-core/src/provider/managed_environment.rs index 9a709069f..f424eb189 100644 --- a/packages/runtime-core/src/provider/managed_environment.rs +++ b/packages/runtime-core/src/provider/managed_environment.rs @@ -2,11 +2,15 @@ use super::private_deadline::Deadline; use crate::{ CandidateError, - provider::environment::{MAX_ENVIRONMENT_KEYS, MAX_MANAGED_SERVICES, PendingEnvironment}, + provider::environment::{ + MAX_ENVIRONMENT_FILES, MAX_ENVIRONMENT_KEYS, MAX_MANAGED_SERVICES, ManagedFile, + ManagedFiles, PendingEnvironment, + }, }; use serde::{ - Deserialize, Deserializer, Serialize, - de::{self, MapAccess, Visitor}, + Deserialize, Deserializer, Serialize, Serializer, + de::{self, MapAccess, SeqAccess, Visitor}, + ser::{SerializeMap, SerializeSeq, SerializeStruct}, }; use std::{ collections::BTreeMap, @@ -25,15 +29,26 @@ enum Scope<'a> { OneOff(&'a str), } impl Scope<'_> { - fn accepts(self, services: &Services) -> bool { + /// One-off delivery never carries file members until that path is qualified. + fn accepts(self, services: &Services, files: Option<&FileServices>) -> bool { match self { - Self::Application => !services.0.is_empty(), + Self::Application => !services.0.is_empty() || files.is_some(), Self::OneOff(service) => { - super::environment::name(service) && services.0.keys().all(|name| name == service) + files.is_none() + && super::environment::name(service) + && services.0.keys().all(|name| name == service) } } } } +/// Version 1 carries values only. Version 2 additionally requires a nonempty `files` +/// map, so a v1-only receiver refuses file delivery instead of dropping it. +fn version_matches(version: u8, files: Option<&FileServices>) -> bool { + match files { + None => version == 1, + Some(_) => version == 2, + } +} fn refused() -> CandidateError { CandidateError::new( "graph_environment_input", @@ -58,12 +73,17 @@ pub struct Managed { plan: String, run: String, values: Values, + files: ManagedFiles, deadline: Instant, } impl Managed { pub fn values(&self) -> &Values { &self.values } + /// Service-scoped private file members (envelope version 2); empty for version 1. + pub fn files(&self) -> &ManagedFiles { + &self.files + } pub fn deadline(&self) -> Instant { self.deadline } @@ -155,6 +175,120 @@ impl<'de> Deserialize<'de> for Services { deserializer.deserialize_map(Entries) } } +/// `{name, target, mode: "0400", content_b64}`; unknown or duplicate fields refuse. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WireFile { + name: String, + target: String, + mode: String, + content_b64: Secret, +} +fn decode_file(wire: WireFile) -> Result { + use base64::Engine as _; + let invalid = || E::custom("invalid file member"); + if wire.mode != "0400" { + return Err(invalid()); + } + let text = wire.content_b64.0.as_bytes(); + // Decode into fixed zeroized storage so no unerased partial copy is left behind. + let mut bytes = Zeroizing::new(vec![0; text.len() / 4 * 3 + 3]); + let used = base64::engine::general_purpose::STANDARD + .decode_slice(text, bytes.as_mut_slice()) + .map_err(|_| invalid())?; + bytes.truncate(used); + ManagedFile::new(&wire.name, &wire.target, bytes).map_err(|_| invalid()) +} +#[derive(Default)] +struct ServiceFiles(Vec); +impl<'de> Deserialize<'de> for ServiceFiles { + fn deserialize>(deserializer: D) -> Result { + struct Members; + impl<'de> Visitor<'de> for Members { + type Value = ServiceFiles; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a bounded nonempty file member list") + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut files = ServiceFiles::default(); + while let Some(wire) = seq.next_element::()? { + if files.0.len() >= MAX_ENVIRONMENT_FILES { + return Err(de::Error::custom("invalid file members")); + } + files.0.push(decode_file(wire)?); + } + if files.0.is_empty() { + return Err(de::Error::custom("invalid file members")); + } + Ok(files) + } + } + deserializer.deserialize_seq(Members) + } +} +/// A present `files` map must be nonempty with unique, bounded service names. +struct FileServices(ManagedFiles); +impl<'de> Deserialize<'de> for FileServices { + fn deserialize>(deserializer: D) -> Result { + struct Entries; + impl<'de> Visitor<'de> for Entries { + type Value = FileServices; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a bounded nonempty unique service file map") + } + fn visit_map>(self, mut map: A) -> Result { + let mut files = ManagedFiles::new(); + while let Some((name, members)) = map.next_entry::()? { + if files.len() >= MAX_MANAGED_SERVICES || files.contains_key(&name) { + return Err(de::Error::custom("invalid service file map")); + } + files.insert(name, members.0); + } + if files.is_empty() { + return Err(de::Error::custom("invalid service file map")); + } + Ok(FileServices(files)) + } + } + deserializer.deserialize_map(Entries) + } +} +/// Serializes members with zeroized base64 text for authenticated forwarding only. +struct WireFiles<'a>(&'a ManagedFiles); +impl Serialize for WireFiles<'_> { + fn serialize(&self, serializer: S) -> Result { + struct Members<'a>(&'a [ManagedFile]); + struct Member<'a>(&'a ManagedFile); + impl Serialize for Member<'_> { + fn serialize(&self, serializer: S) -> Result { + use base64::Engine as _; + let text = Zeroizing::new( + base64::engine::general_purpose::STANDARD.encode(self.0.content()), + ); + let mut member = serializer.serialize_struct("WireFile", 4)?; + member.serialize_field("name", self.0.name())?; + member.serialize_field("target", self.0.target())?; + member.serialize_field("mode", "0400")?; + member.serialize_field("content_b64", text.as_str())?; + member.end() + } + } + impl Serialize for Members<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut seq = serializer.serialize_seq(Some(self.0.len()))?; + for file in self.0 { + seq.serialize_element(&Member(file))?; + } + seq.end() + } + } + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (service, files) in self.0 { + map.serialize_entry(service, &Members(files))?; + } + map.end() + } +} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Envelope { @@ -163,6 +297,8 @@ struct Envelope { run: String, lifetime_seconds: u64, services: Services, + #[serde(default)] + files: Option, } fn hex(value: &str, length: usize) -> bool { value.len() == length @@ -181,13 +317,13 @@ fn parse_scoped( return Err(refused()); } let envelope: Envelope = serde_json::from_slice(bytes).map_err(|_| refused())?; - if envelope.version != 1 + if !version_matches(envelope.version, envelope.files.as_ref()) || !hex(&envelope.plan, 64) || envelope.plan != expected_plan || !hex(&envelope.run, 32) || envelope.run != run || !(1..=300).contains(&envelope.lifetime_seconds) - || !scope.accepts(&envelope.services) + || !scope.accepts(&envelope.services, envelope.files.as_ref()) { return Err(refused()); } @@ -198,21 +334,42 @@ fn parse_scoped( .checked_duration_since(Instant::now()) .filter(|d| !d.is_zero()) .ok_or_else(refused)?; - finish(envelope.services, deadline, envelope.plan, envelope.run) + finish( + envelope.services, + envelope.files, + deadline, + envelope.plan, + envelope.run, + ) } fn finish( mut services: Services, + files: Option, deadline: Instant, plan: String, run: String, ) -> Result { - for (service, values) in &services.0 { - PendingEnvironment::until(service, &values.0, deadline).map_err(|_| refused())?; + let files = files.map(|files| files.0).unwrap_or_default(); + let empty = BTreeMap::new(); + let names: std::collections::BTreeSet<&String> = + services.0.keys().chain(files.keys()).collect(); + if names.len() > MAX_MANAGED_SERVICES { + return Err(refused()); + } + for service in names { + PendingEnvironment::until_with_files( + service, + services.0.get(service).map_or(&empty, |values| &values.0), + files.get(service).map_or(&[], Vec::as_slice), + deadline, + ) + .map_err(|_| refused())?; } let mut managed = Managed { plan, run, values: BTreeMap::new(), + files, deadline, }; for (service, values) in &mut services.0 { @@ -232,6 +389,8 @@ struct Forwarded { run: String, deadline_nanos: u64, services: Services, + #[serde(default)] + files: Option, } impl Managed { @@ -253,15 +412,19 @@ impl Managed { run: &'a str, deadline_nanos: u64, services: &'a Values, + #[serde(skip_serializing_if = "Option::is_none")] + files: Option>, } + let files = (!self.files.is_empty()).then_some(WireFiles(&self.files)); let document = Borrowed { - version: 1, + version: if files.is_some() { 2 } else { 1 }, plan, run, deadline_nanos: Deadline::from_instant(self.deadline) .map_err(|_| refused())? .nanos(), services: &self.values, + files, }; // Fixed storage prevents serializer growth from leaving prior secret copies. let mut bytes = Zeroizing::new(vec![0; MAX_INPUT_BYTES]); @@ -305,19 +468,25 @@ fn receive_forwarded_scoped( return Err(refused()); } let envelope: Forwarded = serde_json::from_slice(bytes).map_err(|_| refused())?; - if envelope.version != 1 + if !version_matches(envelope.version, envelope.files.as_ref()) || !hex(&envelope.plan, 64) || envelope.plan != expected_plan || !hex(&envelope.run, 32) || envelope.run != run - || !scope.accepts(&envelope.services) + || !scope.accepts(&envelope.services, envelope.files.as_ref()) { return Err(refused()); } let deadline = Deadline::from_nanos(envelope.deadline_nanos) .and_then(|deadline| deadline.to_instant()) .map_err(|_| refused())?; - finish(envelope.services, deadline, envelope.plan, envelope.run) + finish( + envelope.services, + envelope.files, + deadline, + envelope.plan, + envelope.run, + ) } pub fn receive(fd: OwnedFd, expected_plan: &str, run: &str) -> Result { @@ -371,14 +540,14 @@ fn parse_native( || !hex(&input.run, 32) || input.run != run || !(1..=300).contains(&input.lifetime_seconds) - || !Scope::Application.accepts(&input.services) + || !Scope::Application.accepts(&input.services, None) { return Err(refused()); } let deadline = started .checked_add(Duration::from_secs(input.lifetime_seconds)) .ok_or_else(refused)?; - finish(input.services, deadline, input.review, input.run).map(NativeManaged) + finish(input.services, None, deadline, input.review, input.run).map(NativeManaged) } /// Receive bounded private native values against their exact native review/run and original ingress clock. #[cfg(feature = "native-config-plan")] @@ -758,6 +927,7 @@ mod tests { let managed = Managed { plan: "a".repeat(64), run: "b".repeat(32), + files: ManagedFiles::new(), values: (0..32) .map(|n| { ( @@ -886,6 +1056,151 @@ mod tests { ); rejected(&serde_json::to_vec(&value).unwrap()); } + fn file_document() -> serde_json::Value { + // base64 of {"k":"synthetic-file-canary"} + let mut value = document(); + value["version"] = json!(2); + value["files"] = json!({ + "web": [{"name":"credential.json","target":"/config/credential.json","mode":"0400","content_b64":"eyJrIjoic3ludGhldGljLWZpbGUtY2FuYXJ5In0="}], + "proxy": [{"name":"adc.json","target":"/config/adc.json","mode":"0400","content_b64":"eyJrIjoic3ludGhldGljLWZpbGUtY2FuYXJ5In0="}], + }); + value + } + fn file_rejected(value: &serde_json::Value) { + let error = decode(&serde_json::to_vec(value).unwrap(), Instant::now()) + .err() + .unwrap(); + assert_eq!(error.code, "graph_environment_input"); + assert!(!error.message.contains("synthetic")); + } + #[test] + fn version_two_delivers_file_members_including_files_only_services() { + let managed = decode( + &serde_json::to_vec(&file_document()).unwrap(), + Instant::now(), + ) + .unwrap(); + assert_eq!(managed.values()["web"]["TOKEN"], "synthetic-private-canary"); + assert!(!managed.values().contains_key("proxy")); + let web = &managed.files()["web"][0]; + assert_eq!(web.name(), "credential.json"); + assert_eq!(web.target(), "/config/credential.json"); + assert_eq!(web.content(), br#"{"k":"synthetic-file-canary"}"#); + assert_eq!(managed.files()["proxy"][0].target(), "/config/adc.json"); + let mut only = file_document(); + only["services"] = json!({}); + let managed = decode(&serde_json::to_vec(&only).unwrap(), Instant::now()).unwrap(); + assert!(managed.values().is_empty()); + assert_eq!(managed.files().len(), 2); + } + #[test] + fn file_members_require_version_two_and_exact_member_shape() { + let mut v1 = file_document(); + v1["version"] = json!(1); + file_rejected(&v1); + let mut v2 = document(); + v2["version"] = json!(2); + file_rejected(&v2); + let member = file_document()["files"]["web"][0].clone(); + for (key, value) in [ + ("mode", json!("0444")), + ("mode", json!(256)), + ("content_b64", json!("not base64!")), + ("content_b64", json!("")), + ( + "content_b64", + json!("eyJrIjoic3ludGhldGljLWZpbGUtY2FuYXJ5In0"), + ), + ("name", json!("../escape")), + ("target", json!("/run/hack-environment.json")), + ("target", json!("relative")), + ("unknown", json!("synthetic")), + ] { + let mut changed = file_document(); + let mut bad = member.clone(); + bad[key] = value; + changed["files"]["web"] = json!([bad]); + file_rejected(&changed); + } + let mut missing = file_document(); + missing["files"]["web"][0] + .as_object_mut() + .unwrap() + .remove("mode"); + file_rejected(&missing); + for files in [ + json!({}), + json!({"web": []}), + json!({"web": (0..5).map(|n| { let mut m = member.clone(); m["name"] = json!(format!("f{n}")); m["target"] = json!(format!("/config/f{n}")); m }).collect::>()}), + json!({"web": [member.clone(), member.clone()]}), + json!({"unknown service!": [member.clone()]}), + json!({"web": {"credential.json": member.clone()}}), + ] { + let mut changed = file_document(); + changed["files"] = files; + file_rejected(&changed); + } + let text = serde_json::to_string(&file_document()).unwrap(); + let duplicate = text.replacen("\"files\":", "\"files\":{},\"files\":", 1); + rejected(duplicate.as_bytes()); + let duplicate_field = text.replacen( + "\"mode\":\"0400\"", + "\"mode\":\"0400\",\"mode\":\"0400\"", + 1, + ); + rejected(duplicate_field.as_bytes()); + let mut oversized = file_document(); + oversized["files"]["web"][0]["content_b64"] = json!(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + vec![b'x'; super::super::environment::MAX_ENVIRONMENT_FILE_BYTES + 1] + )); + file_rejected(&oversized); + } + #[test] + fn one_off_scope_refuses_file_members() { + let mut value = file_document(); + value["files"] = json!({"web": value["files"]["web"].clone()}); + assert!( + decode_one_off(&serde_json::to_vec(&value).unwrap(), "web") + .err() + .is_some_and(|error| error.code == "graph_environment_input") + ); + } + #[test] + #[cfg(feature = "environment-launcher")] + fn forwarded_file_members_round_trip_as_version_two_only() { + let plan = "a".repeat(64); + let run = "b".repeat(32); + let managed = decode( + &serde_json::to_vec(&file_document()).unwrap(), + Instant::now(), + ) + .unwrap(); + let bytes = managed.forward(&plan, &run).unwrap(); + let wire: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(wire["version"], 2); + assert_eq!(wire["files"]["web"][0]["mode"], "0400"); + let received = receive_forwarded(&bytes, &plan, &run).unwrap(); + assert_eq!(received.values(), managed.values()); + for (service, files) in managed.files() { + let other = &received.files()[service]; + assert_eq!(files.len(), other.len()); + for (left, right) in files.iter().zip(other) { + assert_eq!(left.name(), right.name()); + assert_eq!(left.target(), right.target()); + assert_eq!(left.content(), right.content()); + } + } + let mut downgraded = wire.clone(); + downgraded["version"] = json!(1); + assert!(receive_forwarded(&serde_json::to_vec(&downgraded).unwrap(), &plan, &run).is_err()); + assert!(receive_forwarded_for_one_off(&bytes, &plan, &run, "web").is_err()); + let plain = decode(&serde_json::to_vec(&document()).unwrap(), Instant::now()).unwrap(); + let wire: serde_json::Value = + serde_json::from_slice(&plain.forward(&plan, &run).unwrap()).unwrap(); + assert_eq!(wire["version"], 1); + assert!(wire.get("files").is_none()); + } #[test] fn malformed_and_excessive_service_values_refuse() { for values in [ From 3439c82d1c4c3c68d1b80b5c54147940d910d053 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:30:44 -0500 Subject: [PATCH 008/138] feat(native): accept a compact services list in host dependency files A host dependency selection may now be version 2, where one entry names services: [...] instead of a single service. Each listed service expands to its own version 1 entry with the same binding, ports, aliases and host authority, in order, and the expanded entries are then validated exactly as version 1: the 128-binding limit counts expanded entries, duplicates refuse, and an entry sets exactly one of service and services. Version 1 files are unchanged and both forms give the same dependency plan. Startup, restart preflight and the stopped-restart intent reader all expand before validation. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 25 ++++ src/backends/native-project-dependencies.ts | 78 +++++++++- .../native-project-restart-preflight.ts | 5 +- tests/native-project-dependencies.test.ts | 139 +++++++++++++++++- 4 files changed, 242 insertions(+), 5 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..d3a49f493 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1144,6 +1144,31 @@ prepare current listeners before selection. For example: Replace the example PID with the actual listener process. Up to 128 service-specific bindings and eight exact aliases per binding are supported, within 32 listening transports shared with the pool's route bridges. + +When several services need the same binding, version 2 lets one entry list them +with `services` instead of repeating the entry for each `service`: + +```json +{ + "version": 2, + "dependencies": [{ + "services": ["web", "worker", "api"], + "binding": "search", + "guest_port": 443, + "aliases": ["search.example.com"], + "host_executable": "/usr/local/bin/tunnel", + "host_port": 8443 + }] +} +``` + +Hack expands each such entry into one version 1 entry per listed service, in order, +with the same binding, ports, aliases and host authority, and then validates the +result exactly as version 1. The limits count expanded entries: at most 128 bindings +in all. An entry sets exactly one of `service` and `services`; a service listed twice, +or a binding that another entry already grants to the same service, refuses as a +duplicate. Version 1 files keep working unchanged, and both forms of the same +bindings produce the same dependency plan. For a listener started or rotated by a lifecycle hook, replace `host_pid` with `"host_executable": "/absolute/path/to/the/listener-binary"`. Hack discovers the current PID after the hooks and again before restart cleanup, requiring one diff --git a/src/backends/native-project-dependencies.ts b/src/backends/native-project-dependencies.ts index a0e05d5c6..6b430d923 100644 --- a/src/backends/native-project-dependencies.ts +++ b/src/backends/native-project-dependencies.ts @@ -26,6 +26,9 @@ const FIELDS = new Set([ "host_supervisor_depth", ]); +const LIMIT = 128; +const V2_FIELDS = new Set([...FIELDS, "services"]); + export type NativeHostDependency = { readonly service: string; readonly binding: string; @@ -127,12 +130,79 @@ function requireSameEndpointPolicy(opts: { opts.policies.set(opts.endpoint, policy); } +function expandedEntries(entry: unknown): Record[] { + if ( + !(isRecord(entry) && Object.keys(entry).every((key) => V2_FIELDS.has(key))) + ) { + throw refused(); + } + const hasService = Object.hasOwn(entry, "service"); + if (hasService === Object.hasOwn(entry, "services")) { + throw refused(); + } + if (hasService) { + return [entry]; + } + const services = entry.services; + if ( + !( + Array.isArray(services) && + services.length > 0 && + services.length <= LIMIT && + services.every((service) => typeof service === "string") && + new Set(services).size === services.length + ) + ) { + throw refused(); + } + const shared = Object.fromEntries( + Object.entries(entry).filter(([key]) => key !== "services") + ); + return services.map((service: string) => ({ ...shared, service })); +} + +/** + * Expand a version 2 selection into the version 1 form; other values pass unchanged. + * + * Version 2 lets one entry name `services: [...]` instead of one `service`. Each + * listed service becomes its own version 1 entry with the same binding, ports, aliases + * and host authority, in entry and list order. Version 1 validation then applies to the + * expanded entries, so its limits (at most 128 bindings) count expanded entries, and a + * service listed twice for one binding refuses as a duplicate. An entry may set exactly + * one of `service` and `services`. + */ +export function expandNativeHostDependencySelection(value: unknown): unknown { + if (!(isRecord(value) && value.version === 2)) { + return value; + } + if ( + !( + Object.keys(value).every((key) => + ["version", "dependencies"].includes(key) + ) && + Array.isArray(value.dependencies) && + value.dependencies.length > 0 && + value.dependencies.length <= LIMIT + ) + ) { + throw refused(); + } + const dependencies: Record[] = []; + for (const entry of value.dependencies) { + dependencies.push(...expandedEntries(entry)); + if (dependencies.length > LIMIT) { + throw refused(); + } + } + return { version: 1, dependencies }; +} + /** Native dependency-plan pins the live process/listener identity after this structural check. */ export function parseNativeHostDependencies(opts: { readonly value: unknown; readonly services: readonly string[]; }): NativeHostDependency[] { - const value = opts.value; + const value = expandNativeHostDependencySelection(opts.value); if ( !( isRecord(value) && @@ -142,7 +212,7 @@ export function parseNativeHostDependencies(opts: { ) && Array.isArray(value.dependencies) && value.dependencies.length > 0 && - value.dependencies.length <= 128 + value.dependencies.length <= LIMIT ) ) { throw refused(); @@ -249,7 +319,9 @@ export async function readNativeHostDependencies(opts: { return []; } try { - const value = await readNativeSelection(opts.path); + const value = expandNativeHostDependencySelection( + await readNativeSelection(opts.path) + ); if (!(isRecord(value) && Array.isArray(value.dependencies))) { throw refused(); } diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..bf4d7ec75 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -8,6 +8,7 @@ import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; import { discoverNativeHostDependency, + expandNativeHostDependencySelection, type NativeHostDependency, parseNativeHostDependencies, prepareNativeDependencyServices, @@ -148,7 +149,9 @@ async function readNativeRestartDependencyIntent(opts: { return []; } try { - const value = await readNativeSelection(opts.path); + const value = expandNativeHostDependencySelection( + await readNativeSelection(opts.path) + ); if (!(isRecord(value) && Array.isArray(value.dependencies))) { throw new ClosedCliError("Invalid dependency intent"); } diff --git a/tests/native-project-dependencies.test.ts b/tests/native-project-dependencies.test.ts index 3dc259894..4fba114e7 100644 --- a/tests/native-project-dependencies.test.ts +++ b/tests/native-project-dependencies.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { discoverNativeHostDependency, + expandNativeHostDependencySelection, parseNativeHostDependencies, prepareNativeDependencyServices, readNativeHostDependencies, @@ -316,7 +317,8 @@ test("ambiguous, undeclared, wildcard or credential-bearing dependencies refuse } for (const value of [ null, - { version: 2, dependencies: [binding()] }, + // Version 2 is the compact services form (below); other versions refuse. + { version: 3, dependencies: [binding()] }, { version: 1, dependencies: [binding()], @@ -484,3 +486,138 @@ test("logical grant bounds and endpoint identity remain independent of transport ]).map((entry) => entry.slot) ).toEqual([0, 1]); }); + +test("a version 2 entry with services expands to the same plan as version 1 entries", () => { + const { service: _web, ...shared } = binding(); + const services = ["web", "worker", "api"]; + const v1 = parseNativeHostDependencies({ + value: { + version: 1, + dependencies: [ + ...services.map((service) => binding(service)), + binding("web", "content"), + ], + }, + services, + }); + const v2 = parseNativeHostDependencies({ + value: { + version: 2, + dependencies: [{ ...shared, services }, binding("web", "content")], + }, + services, + }); + expect(v2).toEqual(v1); + // Shared listeners still get one transport per service, as in version 1. + expect(v2.map((entry) => [entry.service, entry.slot])).toEqual([ + ["web", 0], + ["worker", 0], + ["api", 0], + ["web", 1], + ]); + // Version 1 values pass through unchanged. + const value = { version: 1, dependencies: [binding()] }; + expect(expandNativeHostDependencySelection(value)).toBe(value); +}); + +test("version 2 limits and duplicate checks apply after expansion", () => { + const { service: _web, ...shared } = binding(); + const many = Array.from({ length: 65 }, (_, index) => `svc-${index}`); + const refusals: unknown[] = [ + // 130 expanded bindings exceed the 128-binding limit. + { + version: 2, + dependencies: [ + { ...shared, services: many }, + { + ...shared, + binding: "other", + aliases: ["other.example.com"], + services: many, + }, + ], + }, + // A service listed twice for one binding is a duplicate binding. + { version: 2, dependencies: [{ ...shared, services: ["web", "web"] }] }, + // The same binding from a services list and a single entry is a duplicate. + { + version: 2, + dependencies: [{ ...shared, services: ["web", "worker"] }, binding()], + }, + // Exactly one of service and services. + { + version: 2, + dependencies: [{ ...shared, service: "web", services: ["worker"] }], + }, + { version: 2, dependencies: [shared] }, + { version: 2, dependencies: [{ ...shared, services: [] }] }, + { version: 2, dependencies: [{ ...shared, services: "web" }] }, + { version: 2, dependencies: [{ ...shared, services: ["web"], extra: 1 }] }, + { version: 2, dependencies: [] }, + { + version: 2, + dependencies: [binding()], + password: "synthetic-do-not-print", + }, + { version: 3, dependencies: [binding()] }, + ]; + for (const value of refusals) { + expectCodedThrow( + () => + parseNativeHostDependencies({ + value, + services: ["web", "worker", ...many], + }), + "E_CONFIG_INVALID", + "values omitted" + ); + } + expect( + parseNativeHostDependencies({ + value: { + version: 2, + dependencies: [ + { ...shared, services: many.slice(0, 64) }, + { + ...shared, + binding: "other", + aliases: ["other.example.com"], + services: many.slice(0, 64), + }, + ], + }, + services: many, + }) + ).toHaveLength(128); +}); + +test("a version 2 file discovers one PID per shared executable listener", async () => { + const root = await mkdtemp(join(tmpdir(), "native-dependencies-v2-")); + roots.push(root); + const path = join(root, "selection.json"); + const executable = "/usr/local/bin/synthetic-tunnel"; + const { service: _web, host_pid: _pid, ...shared } = binding(); + await writeFile( + path, + JSON.stringify({ + version: 2, + dependencies: [ + { ...shared, host_executable: executable, services: ["web", "worker"] }, + ], + }) + ); + const calls: unknown[] = []; + const result = await readNativeHostDependencies({ + path, + services: ["web", "worker"], + discover: async (request) => { + calls.push(request); + return { host_pid: 456, endpoint_fingerprint: "a".repeat(64) }; + }, + }); + expect(calls).toEqual([{ hostPort: 8443, executable }]); + expect(result.map((entry) => [entry.service, entry.host_pid])).toEqual([ + ["web", 456], + ["worker", 456], + ]); +}); From c039ff9a65c1ea44ca9b04a94a124f81df48e392 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:16:25 -0500 Subject: [PATCH 009/138] feat(native): admit covered image volumes and warn on data-service risks Compose-file native graphs refused every image that declares a VOLUME, which blocked stock database images even when a named volume covered the path. The graph now admits an image VOLUME when exactly one explicit, whole (no subpath) named volume owned by the same receipt is mounted at each declared target. Missing, duplicate, bind, subpath, tmpfs and dependency-cache covers refuse with graph_image_volume_uncovered before any container is created; foreign OS, architecture or image identity still refuse with graph_image. Start, restart, restore and one-off share the predicate through verify_images. It reads only reviewed config, adds no capability and does not change plan or receipt identities. The per-graph volume budget rises from 8 to 16 (dependency caches still count, since each volume costs per-graph inspection and cleanup work; caches keep their separate 64-volume owner limit). Native doctor adds a warning-only "native data services" check: native_data_owner_handoff_unqualified for a service that runs a published image's own entrypoint (no build, no entrypoint override) with a named data volume and no explicit user:, and native_retained_data_mutable_image for one on a latest or untagged image without a digest. Reports carry codes and service names only. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 15 + packages/runtime-core/README.md | 17 + .../runtime-core/src/provider/graph/config.rs | 2 +- .../src/provider/graph/image_volumes.rs | 265 +++++++++++++++ .../runtime-core/src/provider/graph/mod.rs | 15 +- .../runtime-core/src/provider/graph/tests.rs | 83 +++++ src/commands/doctor.ts | 35 ++ src/lib/native-compose-backend-features.ts | 6 +- src/lib/native-data-advisories.ts | 254 ++++++++++++++ tests/native-data-advisories.test.ts | 318 ++++++++++++++++++ 10 files changed, 1002 insertions(+), 8 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/image_volumes.rs create mode 100644 src/lib/native-data-advisories.ts create mode 100644 tests/native-data-advisories.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..c56cb8a46 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -611,6 +611,21 @@ layer. Removing the owned container discards that layer; use named volumes for durable data. Source bind mounts still require read-only publication. Writable container layers do not imply writable source synchronization. +An image that declares `VOLUME` (for example a database image) runs natively only +when the service mounts a named volume at each declared path, as in +`db-data:/var/lib/postgresql/data`. Hack never creates an anonymous volume: a declared +path without exactly one whole named volume of the project refuses with +`graph_image_volume_uncovered`, and a bind mount, a `volume.subpath` mount or a +dependency-cache volume at that path does not count. On first use the engine copies +the image directory's contents, owner and mode into the new, empty volume; later runs +mount the retained data unchanged. Native containers drop all capabilities, so an +image entrypoint that starts as root and hands the data directory to another user +(`chown` and then a user switch) fails. Set an explicit numeric `user:` that matches +the image's data user (look it up in the image's `/etc/passwd`); the entrypoint then +skips the handoff. `hack doctor` warns about this case and about data services on +mutable image tags. A native graph can own at most 16 named volumes, dependency +caches included. + The normal-CLI integration helpers prepare modern environment overlays and worktree inheritance in memory, with null declarations in public normalized configuration. An explicit AWS profile adapter can replace the recognized diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 7178928ec..a7e72a25a 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1156,6 +1156,23 @@ other ownership or image-populated volume content need a separate explicit contr Root-only volume mounts without subpaths keep their existing behavior. Native workspace installation and engine subpath confinement require separate qualification. +An image that declares `VOLUME` targets is admitted on a Compose-file graph only when +each declared target is covered by exactly one explicit mount, and that mount is a +whole (no subpath) named volume owned by the same graph receipt. A missing, duplicate, +bind, subpath, tmpfs or dependency-cache cover refuses with +`graph_image_volume_uncovered` before any container is created; a foreign OS, +architecture or image identity still refuses with `graph_image`. Start, restart, +restore and one-off share the same predicate, and it reads only reviewed +configuration, so plan and receipt identities do not change. The engine therefore +never allocates an anonymous volume for a graph. Because these covers have no subpath, +Docker copy-up gives a freshly created, empty owned volume the image directory's +contents, owner and mode; a retained, non-empty volume is mounted unchanged. No +capability is added: an image entrypoint that starts as root and changes ownership +(`chown` followed by a user switch) still fails under the dropped capability set, so +declare an explicit numeric `user:` matching the image's data user instead. A graph +may own at most 16 volumes, dependency caches included, beside 32 networks and 32 +services. + Fresh `graph run` or `graph serve` may explicitly select `--release-initializer-cache ` (repeatable). The service must be a reviewed dependency-cache bootstrap initializer with completed readiness. This optional diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 257424239..77e6e9f80 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -283,7 +283,7 @@ pub(super) fn prepare_delivery( { return Err(error( "graph_budget", - "Graph exceeds 32 networks, eight volumes, four CPUs or the development guest memory reservation budget.", + "Graph exceeds 32 networks, 16 volumes, four CPUs or the development guest memory reservation budget.", )); } let mut resources = BTreeMap::new(); diff --git a/packages/runtime-core/src/provider/graph/image_volumes.rs b/packages/runtime-core/src/provider/graph/image_volumes.rs new file mode 100644 index 000000000..abed19758 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/image_volumes.rs @@ -0,0 +1,265 @@ +//! Admission of image-declared `VOLUME` targets on Compose-file graphs. +//! +//! An image `VOLUME` makes the engine allocate an anonymous volume unless an explicit +//! mount already occupies that target. Graphs never create anonymous storage, so each +//! declared target must be covered by exactly one explicit mount, and that mount must be +//! a whole (no subpath) graph-owned named volume of this receipt. Dependency-cache +//! volumes have consumers outside the graph and never cover an image volume: copy-up +//! into a shared cache would let one image's files reach other consumers. +//! +//! This is a pure predicate over already-reviewed configuration. It reads no engine +//! state, adds no capability, changes no ownership and does not alter receipt or plan +//! identity. Start, restart, restore and one-off all reach it through `verify_images`. + +use super::{CandidateError, Kind, Resource, error}; +use serde_json::Value; +use std::{collections::BTreeMap, path::Path}; + +/// Closed refusal for an image volume that lacks exactly one owned named cover. +pub(super) const UNCOVERED: &str = "graph_image_volume_uncovered"; + +/// Same lexical form the Compose review gives mount targets (`fields::safe_absolute`). +fn normalized(target: &str) -> String { + Path::new(target) + .components() + .collect::() + .to_string_lossy() + .into_owned() +} + +fn uncovered() -> CandidateError { + error( + UNCOVERED, + "An image-declared volume is not covered by exactly one owned named volume mount.", + ) +} + +/// Admit `image_volumes` (the image `Config.Volumes` value) for one compiled container +/// configuration. Null or an empty object passes. Otherwise every declared target needs +/// an empty options object and exactly one mount at the same normalized target; that +/// mount must be a writable-or-read-only `volume` mount without a subpath whose source +/// is a non-cache volume resource of this graph. +pub(super) fn admit( + image_volumes: &Value, + config: &Value, + resources: &BTreeMap, +) -> Result<(), CandidateError> { + if image_volumes.is_null() { + return Ok(()); + } + let declared = image_volumes.as_object().ok_or_else(uncovered)?; + if declared.is_empty() { + return Ok(()); + } + let mounts = config["HostConfig"]["Mounts"] + .as_array() + .ok_or_else(uncovered)?; + for (target, options) in declared { + if !options.as_object().is_some_and(|object| object.is_empty()) || !target.starts_with('/') + { + return Err(uncovered()); + } + let target = normalized(target); + let mut covering = mounts.iter().filter(|mount| { + mount["Target"] + .as_str() + .is_some_and(|value| normalized(value) == target) + }); + let (Some(mount), None) = (covering.next(), covering.next()) else { + return Err(uncovered()); + }; + let source = mount["Source"].as_str(); + let owned = resources.values().any(|resource| { + resource.kind == Kind::Volume + && resource.cache.is_none() + && Some(resource.name.as_str()) == source + }); + if mount["Type"] != "volume" || !mount["VolumeOptions"]["Subpath"].is_null() || !owned { + return Err(uncovered()); + } + } + // A bare `Binds` entry or tmpfs could also occupy a declared target outside Mounts. + // Compose-file graphs never emit them for volume targets; refuse if they do. + let tmpfs_covers = |target: &str| { + config["HostConfig"]["Tmpfs"] + .as_object() + .is_some_and(|tmpfs| tmpfs.keys().any(|key| normalized(key) == target)) + }; + if config["HostConfig"]["Binds"] + .as_array() + .is_some_and(|binds| !binds.is_empty()) + || declared + .keys() + .any(|target| tmpfs_covers(&normalized(target))) + { + return Err(uncovered()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn volume(key: &str, name: &str, cache: bool) -> Resource { + let mut resource: Resource = serde_json::from_value(json!({ + "kind":"volume","key":key,"name":name,"id":null,"image":null,"phase":"reserved" + })) + .unwrap(); + if cache { + resource.cache = Some(super::super::dependency_cache::CacheBinding { + scope: "c".repeat(64), + fingerprint: "d".repeat(64), + image: format!("sha256:{}", "e".repeat(64)), + }); + } + resource + } + + fn resources() -> BTreeMap { + let run = "a".repeat(32); + BTreeMap::from([ + ( + "volume:data".into(), + volume("data", &format!("hkg-{run}-volume-0"), false), + ), + ( + "volume:other".into(), + volume("other", &format!("hkg-{run}-volume-1"), false), + ), + ]) + } + + fn owned(index: usize) -> String { + format!("hkg-{}-volume-{index}", "a".repeat(32)) + } + + fn config(mounts: Value) -> Value { + json!({"HostConfig":{"Mounts":mounts}}) + } + + fn mount(source: &str, target: &str) -> Value { + json!({"Type":"volume","Source":source,"Target":target,"ReadOnly":false}) + } + + fn refused(result: Result<(), CandidateError>) { + assert_eq!(result.unwrap_err().code, UNCOVERED); + } + + #[test] + fn absent_or_empty_image_volumes_pass_without_mounts() { + for declared in [Value::Null, json!({})] { + admit(&declared, &config(json!([])), &resources()).unwrap(); + } + } + + #[test] + fn one_owned_named_mount_covers_each_declared_target() { + let declared = json!({"/var/lib/data":{},"/srv/state/":{}}); + let mounts = json!([ + mount(&owned(0), "/var/lib/data"), + mount(&owned(1), "/srv/state"), + {"Type":"bind","Source":"/source","Target":"/app","ReadOnly":true} + ]); + admit(&declared, &config(mounts), &resources()).unwrap(); + let mut read_only = mount(&owned(0), "/var/lib/data"); + read_only["ReadOnly"] = json!(true); + admit( + &json!({"/var/lib/data":{}}), + &config(json!([read_only])), + &resources(), + ) + .unwrap(); + } + + #[test] + fn uncovered_and_ambiguous_targets_refuse() { + let declared = json!({"/var/lib/data":{}}); + // No mount, a mount elsewhere, and a nested mount do not cover the target. + refused(admit(&declared, &config(json!([])), &resources())); + refused(admit( + &declared, + &config(json!([mount(&owned(0), "/var/lib/data/inner")])), + &resources(), + )); + refused(admit(&declared, &json!({"HostConfig":{}}), &resources())); + // Two mounts at the same target, even of different types, are ambiguous. + refused(admit( + &declared, + &config(json!([ + mount(&owned(0), "/var/lib/data"), + mount(&owned(1), "/var/lib/data/") + ])), + &resources(), + )); + // A second declared target without its own cover still refuses. + refused(admit( + &json!({"/var/lib/data":{},"/var/log/data":{}}), + &config(json!([mount(&owned(0), "/var/lib/data")])), + &resources(), + )); + } + + #[test] + fn bind_subpath_foreign_and_cache_covers_refuse() { + let declared = json!({"/var/lib/data":{}}); + refused(admit( + &declared, + &config( + json!([{"Type":"bind","Source":"/source","Target":"/var/lib/data","ReadOnly":false}]), + ), + &resources(), + )); + let mut subpath = mount(&owned(0), "/var/lib/data"); + subpath["VolumeOptions"] = json!({"Subpath":"pg","NoCopy":true}); + refused(admit(&declared, &config(json!([subpath])), &resources())); + // A volume that is not a resource of this graph is never adopted as a cover. + refused(admit( + &declared, + &config(json!([mount( + &format!("hkg-{}-volume-0", "b".repeat(32)), + "/var/lib/data" + )])), + &resources(), + )); + refused(admit( + &declared, + &config(json!([mount("anonymous", "/var/lib/data")])), + &resources(), + )); + let mut with_cache = resources(); + let cache = volume("deps", &format!("hack-cache-v5-{}", "d".repeat(64)), true); + let cache_name = cache.name.clone(); + assert_eq!(cache.cache.as_ref().unwrap().name(), cache_name); + with_cache.insert("volume:deps".into(), cache); + refused(admit( + &declared, + &config(json!([mount(&cache_name, "/var/lib/data")])), + &with_cache, + )); + } + + #[test] + fn non_empty_options_malformed_values_and_side_channels_refuse() { + let covered = config(json!([mount(&owned(0), "/var/lib/data")])); + refused(admit( + &json!({"/var/lib/data":{"driver":"x"}}), + &covered, + &resources(), + )); + refused(admit( + &json!({"/var/lib/data":null}), + &covered, + &resources(), + )); + refused(admit(&json!({"relative":{}}), &covered, &resources())); + refused(admit(&json!(["/var/lib/data"]), &covered, &resources())); + let mut binds = covered.clone(); + binds["HostConfig"]["Binds"] = json!(["/x:/y"]); + refused(admit(&json!({"/var/lib/data":{}}), &binds, &resources())); + let mut tmpfs = covered; + tmpfs["HostConfig"]["Tmpfs"] = json!({"/var/lib/data/":"rw"}); + refused(admit(&json!({"/var/lib/data":{}}), &tmpfs, &resources())); + } +} diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..acc748b2e 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -98,6 +98,7 @@ mod config; mod dependency_cache; mod image_environment; mod image_process; +mod image_volumes; #[cfg(feature = "native-config-plan")] pub mod native; pub use cleanup_enrollment::{Phase as RelayCleanupPhase, RelayCleanup}; @@ -172,7 +173,7 @@ const MAX_MEMORY_BYTES: u64 = 4 * 1024 * 1024 * 1024; const GUEST_MEMORY_RESERVE_BYTES: u64 = 512 * 1024 * 1024; const MAX_TOTAL_MEMORY_BYTES: u64 = super::Profile::Development.memory_mib() as u64 * 1024 * 1024 - GUEST_MEMORY_RESERVE_BYTES; -const MAX_VOLUMES: usize = 8; +const MAX_VOLUMES: usize = 16; fn error(code: &'static str, message: &str) -> CandidateError { CandidateError::new(code, message) @@ -2165,15 +2166,19 @@ fn verify_images( if image["Id"].as_str() != resource.image.as_deref() || image["Os"] != "linux" || image["Architecture"] != "arm64" - || image["Config"]["Volumes"] - .as_object() - .is_some_and(|v| !v.is_empty()) { return Err(error( "graph_image", - "Pinned image architecture or implicit volumes are incompatible.", + "Pinned image identity, OS or architecture is incompatible.", )); } + // Image VOLUME targets are admitted only when covered by exactly one owned + // named mount; the engine never allocates anonymous storage for a graph. + image_volumes::admit( + &image["Config"]["Volumes"], + &configs[&resource.key], + resources, + )?; if private_services.contains(&resource.key) { image_process::apply_private( configs diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..1b5963a8f 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -342,6 +342,89 @@ fn omitted_service_limits_share_pool_and_explicit_caps_remain_enforced() { assert!(!candidate.state_root.exists()); } #[test] +fn lowered_named_volume_covers_image_volume_and_subpath_or_missing_cover_refuses() { + let fixture = Fixture::new(); + let candidate_root = Fixture::new(); + let candidate = Candidate::discover(&candidate_root.0).unwrap(); + let image = format!("sha256:{}", "a".repeat(64)); + let declared = json!({"/var/lib/data":{}}); + for (mounts, admitted) in [ + (json!(["state:/var/lib/data/"]), true), + (json!(["state:/var/lib/other"]), false), + ( + json!([{"type":"volume","source":"state","target":"/var/lib/data","volume":{"subpath":"db"}}]), + false, + ), + ] { + state::write( + &fixture.0.join("compose.yaml"), + &json!({"services":{"db":{"image":image,"user":"999:999","network_mode":"none","volumes":mounts}},"volumes":{"state":{}}}), + ) + .unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ) + .unwrap(); + let goals = BTreeMap::from([("db".to_string(), Condition::Started)]); + let prepared = + config::prepare(inputs, &goals, &"a".repeat(32), &"b".repeat(32), None).unwrap(); + assert_eq!(prepared.configs["db"]["User"], "999:999"); + let result = image_volumes::admit(&declared, &prepared.configs["db"], &prepared.resources); + if admitted { + result.unwrap(); + } else { + assert_eq!(result.unwrap_err().code, image_volumes::UNCOVERED); + } + } + assert!(!candidate.state_root.exists()); +} +#[test] +fn graph_volume_budget_admits_sixteen_named_volumes_and_refuses_seventeen() { + let fixture = Fixture::new(); + let candidate_root = Fixture::new(); + let candidate = Candidate::discover(&candidate_root.0).unwrap(); + let image = format!("sha256:{}", "a".repeat(64)); + for (count, accepted) in [(MAX_VOLUMES, true), (MAX_VOLUMES + 1, false)] { + assert_eq!(MAX_VOLUMES, 16); + let mounts: Vec = (0..count).map(|i| format!("data-{i}:/data/{i}")).collect(); + let volumes: serde_json::Map = (0..count) + .map(|i| (format!("data-{i}"), json!({}))) + .collect(); + state::write( + &fixture.0.join("compose.yaml"), + &json!({"services":{"job":{"image":image,"network_mode":"none","command":["true"],"volumes":mounts}},"volumes":volumes}), + ) + .unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ) + .unwrap(); + let goals = BTreeMap::from([("job".to_string(), Condition::Completed)]); + let result = config::prepare(inputs, &goals, &"a".repeat(32), &"b".repeat(32), None); + if !accepted { + assert_eq!(result.err().unwrap().code, "graph_budget"); + continue; + } + let prepared = result.unwrap(); + let owned = prepared + .resources + .values() + .filter(|resource| resource.kind == Kind::Volume) + .count(); + assert_eq!(owned, count); + assert!(resource_counts_fit(&prepared.resources)); + } + assert!(!candidate.state_root.exists()); +} +#[test] fn larger_graphs_fit_by_resources_and_receipts_keep_matching_bounds() { let fixture = Fixture::new(); let candidate_root = Fixture::new(); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..46e5b3521 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -145,6 +145,12 @@ import { } from "../lib/native-ca-doctor.ts"; import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; import { readComposeBackendFeatures } from "../lib/native-compose-backend-features.ts"; +import { + NATIVE_DATA_CHECK, + type NativeDataReport, + nativeDataCheckResult, + readNativeDataAdvisories, +} from "../lib/native-data-advisories.ts"; import { describeHackLocalRoute, HACK_LOCAL_ROLLBACK_COMMAND, @@ -235,6 +241,8 @@ interface CheckResult { readonly envKey?: EnvKeyInspection; /** Closed Compose-backend feature names only; never authored values. */ readonly composeBackend?: ComposeBackendReport; + /** Closed native data advisory codes and service names only. */ + readonly nativeData?: NativeDataReport; } const HACK_LOCAL_ROUTE_CHECK = "native hack.local route"; @@ -1116,6 +1124,11 @@ async function handleNativeDoctor(opts: { }) ) ); + results.push( + await runCheck(progress, NATIVE_DATA_CHECK, () => + checkNativeDataAdvisories({ startDir: opts.startDir }) + ) + ); } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); @@ -1282,6 +1295,8 @@ export type DoctorJsonCheck = { readonly envKey?: EnvKeyInspection; /** Present only on the Compose-backend feature check: closed feature names. */ readonly composeBackend?: ComposeBackendReport; + /** Present only on the native data-service check: closed codes and service names. */ + readonly nativeData?: NativeDataReport; }; export type DoctorJsonData = { @@ -1320,6 +1335,7 @@ export function buildDoctorJsonData(opts: { ...(result.composeBackend ? { composeBackend: result.composeBackend } : {}), + ...(result.nativeData ? { nativeData: result.nativeData } : {}), })), summary: buildDoctorSummaryLines({ results: opts.results }), counts, @@ -2953,6 +2969,25 @@ async function checkComposeBackendFeatures(opts: { }); } +/** + * Warning-only native data-service advisories (image entrypoint ownership + * handoff and mutable tags on services with named data volumes). Reads only + * the project's Compose file; never blocks native up. + */ +async function checkNativeDataAdvisories(opts: { + readonly startDir: string; +}): Promise { + const ctx = await findProjectContext(opts.startDir); + if (!ctx) { + return { + name: NATIVE_DATA_CHECK, + status: "warn", + message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, + }; + } + return nativeDataCheckResult(await readNativeDataAdvisories(ctx.composeFile)); +} + /** * 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-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 2afef1ed8..4ff6878ba 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -220,7 +220,9 @@ export function findComposeBackendFeatures( * oversized, a symlink or not parseable returns `undefined`, so the existing * native input refusal still reports it. */ -async function readComposeDocument(composeFile: string): Promise { +export async function readNativeComposeDocument( + composeFile: string +): Promise { let text: string; try { const file = await open( @@ -307,7 +309,7 @@ export async function readComposeBackendFeatures( composeFile: string, opts?: { readonly adaptationFile?: string } ): Promise { - const compose = await readComposeDocument(composeFile); + const compose = await readNativeComposeDocument(composeFile); const features = findComposeBackendFeatures(compose); if ( opts?.adaptationFile === undefined || diff --git a/src/lib/native-data-advisories.ts b/src/lib/native-data-advisories.ts new file mode 100644 index 000000000..d4def29de --- /dev/null +++ b/src/lib/native-data-advisories.ts @@ -0,0 +1,254 @@ +import { isRecord } from "./guards.ts"; +import { readNativeComposeDocument } from "./native-compose-backend-features.ts"; + +/** + * Advisory (warning-only) findings about data services on the native backend. + * They never block `up`: native review and runtime admission stay the + * authority. Doctor reports them so a data service that is likely to fail, or + * to strand its retained data, is visible before startup. + * + * - `native_data_owner_handoff_unqualified`: the service runs a published + * image's own entrypoint (no `build` and no `entrypoint:` override), keeps + * data in a named volume and declares no `user:`. Native containers drop + * every capability, so an image entrypoint that starts as root, changes the + * data directory owner and then switches user (as many database images do) + * fails. An explicit `user:` matching the image's data user avoids that + * handoff; it also acknowledges a service that really runs as root. Image + * metadata is not read here, so this is advice, not a verdict. + * - `native_retained_data_mutable_image`: the service keeps data in a named + * volume and names its image by a mutable tag (`latest`, or no tag at all) + * without a digest. Restoring retained data requires the same image content + * ID, so a refreshed tag can strand that data until it is reverted or + * explicitly removed. + * + * The set is closed and ordered. Reports name only these codes and service + * names; never an image reference, volume name, path or other authored value. + */ +export const NATIVE_DATA_ADVISORIES = [ + "native_data_owner_handoff_unqualified", + "native_retained_data_mutable_image", +] as const; + +/** One advisory code from {@link NATIVE_DATA_ADVISORIES}. */ +export type NativeDataAdvisoryCode = (typeof NATIVE_DATA_ADVISORIES)[number]; + +/** One advisory with the sorted service names it applies to. */ +export type NativeDataAdvisory = { + readonly code: NativeDataAdvisoryCode; + readonly services: readonly string[]; +}; + +/** Doctor check id for the native data-service advisories. */ +export const NATIVE_DATA_CHECK = "native data services"; + +/** Value-free JSON payload: closed codes and service names only. */ +export type NativeDataReport = { + readonly advisories: readonly NativeDataAdvisory[]; +}; + +const DEPENDENCY_CACHE_LABEL = "hack.dependencies.cache-volume"; + +/** Compose reads a source starting with `.`, `/` or `~` as a host path. */ +const HOST_PATH_SOURCE = /^[./~]/; + +function labelValue(labels: unknown, key: string): string | undefined { + if (isRecord(labels)) { + const value = labels[key]; + return typeof value === "string" ? value : undefined; + } + if (Array.isArray(labels)) { + for (const label of labels) { + if (typeof label === "string" && label.startsWith(`${key}=`)) { + return label.slice(key.length + 1); + } + } + } + return; +} + +/** + * Compose named-volume sources: a short-syntax source without a path prefix, + * or a long-syntax `type: volume` source. Bind paths, anonymous volumes and + * interpolated values are not named volumes here. + */ +function namedVolumeSources(service: Record): string[] { + const mounts = Array.isArray(service.volumes) ? service.volumes : []; + const names: string[] = []; + for (const mount of mounts) { + let source: unknown; + if (typeof mount === "string") { + const separator = mount.indexOf(":"); + source = separator > 0 ? mount.slice(0, separator) : undefined; + } else if (isRecord(mount) && mount.type === "volume") { + source = mount.source; + } + if ( + typeof source === "string" && + source.length > 0 && + !HOST_PATH_SOURCE.test(source) && + !source.includes("/") && + !source.includes("$") + ) { + names.push(source); + } + } + return names; +} + +/** + * Volumes that hold disposable or shared dependency content, not service + * data: `x-hack-disposable-cache` volumes and every volume a service selects + * with the `hack.dependencies.cache-volume` label. + */ +function cacheVolumes( + services: readonly Record[], + volumes: Record +): Set { + const caches = new Set(); + for (const [name, volume] of Object.entries(volumes)) { + if (isRecord(volume) && volume["x-hack-disposable-cache"] !== undefined) { + caches.add(name); + } + } + for (const service of services) { + const cache = labelValue(service.labels, DEPENDENCY_CACHE_LABEL); + if (cache !== undefined) { + caches.add(cache); + } + } + return caches; +} + +/** + * A tag is mutable here when the reference has no digest and its tag is + * `latest` or absent. Content IDs and interpolated references are not judged. + */ +function isMutableImage(image: unknown): boolean { + if (typeof image !== "string" || image.length === 0) { + return false; + } + if ( + image.includes("$") || + image.includes("@") || + image.startsWith("sha256:") + ) { + return false; + } + const name = image.slice(image.lastIndexOf("/") + 1); + const separator = name.lastIndexOf(":"); + return separator < 0 || name.slice(separator + 1) === "latest"; +} + +function hasExplicitUser(service: Record): boolean { + const user = service.user; + return ( + (typeof user === "string" && user.trim().length > 0) || + typeof user === "number" + ); +} + +/** + * Classifies a parsed Compose document. Every service is inspected, including + * services outside the selected profiles, as native review does. A service + * that is built locally (`build`) is judged for neither advisory. Returns + * only advisories with at least one service, in + * {@link NATIVE_DATA_ADVISORIES} order, with sorted unique service names. + */ +export function findNativeDataAdvisories( + compose: unknown +): NativeDataAdvisory[] { + if (!(isRecord(compose) && isRecord(compose.services))) { + return []; + } + const entries = Object.entries(compose.services).filter( + (entry): entry is [string, Record] => isRecord(entry[1]) + ); + const declared = isRecord(compose.volumes) ? compose.volumes : {}; + const caches = cacheVolumes( + entries.map(([, service]) => service), + declared + ); + const found: Record> = { + native_data_owner_handoff_unqualified: new Set(), + native_retained_data_mutable_image: new Set(), + }; + for (const [name, service] of entries) { + const data = namedVolumeSources(service).some( + (source) => Object.hasOwn(declared, source) && !caches.has(source) + ); + if (!data) { + continue; + } + const imageEntrypoint = + !Object.hasOwn(service, "build") && + (service.entrypoint === undefined || service.entrypoint === null); + if (imageEntrypoint && !hasExplicitUser(service)) { + found.native_data_owner_handoff_unqualified.add(name); + } + if (!Object.hasOwn(service, "build") && isMutableImage(service.image)) { + found.native_retained_data_mutable_image.add(name); + } + } + return NATIVE_DATA_ADVISORIES.filter((code) => found[code].size > 0).map( + (code) => ({ code, services: [...found[code]].sort() }) + ); +} + +/** + * Reads and classifies one Compose file with the bounds native start uses. + * Read-only: no lock, write or runtime request. A missing, oversized, + * symlinked or unparseable file has no advisories; native start still reports + * it. + */ +export async function readNativeDataAdvisories( + composeFile: string +): Promise { + return findNativeDataAdvisories(await readNativeComposeDocument(composeFile)); +} + +const ADVICE: Readonly> = { + native_data_owner_handoff_unqualified: + "named data volume with no explicit user:. Native containers drop all capabilities, so an image entrypoint that starts as root and hands the data directory to another user fails; set user: to the image's numeric data UID:GID, or 0:0 for a service that really runs as root", + native_retained_data_mutable_image: + "named data volume on an image tag that can change (latest or no tag). Retained data restores only with the same image, so pin a digest (image@sha256:...) or a fixed version tag", +}; + +/** + * Doctor result for the native data-service advisories. Warning when any + * advisory applies, otherwise ok. Read-only and value-free apart from service + * names. + */ +export function nativeDataCheckResult( + advisories: readonly NativeDataAdvisory[] +): { + readonly name: string; + readonly status: "ok" | "warn"; + readonly message: string; + readonly nativeData: NativeDataReport; +} { + const nativeData = { + advisories: advisories.map((advisory) => ({ + code: advisory.code, + services: [...advisory.services], + })), + }; + if (advisories.length === 0) { + return { + name: NATIVE_DATA_CHECK, + status: "ok", + message: + "No data-service advisories; native up still reviews images and volumes when it starts.", + nativeData, + }; + } + const lines = advisories.map( + (advisory) => + `${advisory.code} (${advisory.services.join(", ")}): ${ADVICE[advisory.code]}` + ); + return { + name: NATIVE_DATA_CHECK, + status: "warn", + message: `${lines.join(". ")}.`, + nativeData, + }; +} diff --git a/tests/native-data-advisories.test.ts b/tests/native-data-advisories.test.ts new file mode 100644 index 000000000..d4f75a8a4 --- /dev/null +++ b/tests/native-data-advisories.test.ts @@ -0,0 +1,318 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + findNativeDataAdvisories, + NATIVE_DATA_CHECK, + nativeDataCheckResult, + readNativeDataAdvisories, +} from "../src/lib/native-data-advisories.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const PINNED = `example/database:16@sha256:${"a".repeat(64)}`; +const roots: string[] = []; +const entrypoint = resolve("index.ts"); +const SPAWN_TIMEOUT_MS = 60_000; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +function project( + services: Record, + volumes: Record = { data: {} } +) { + return { services, volumes }; +} + +test("no named data volume means no advisory", () => { + expect(findNativeDataAdvisories(null)).toEqual([]); + expect(findNativeDataAdvisories({ services: [] })).toEqual([]); + expect( + findNativeDataAdvisories( + project({ + web: { image: "example/web:latest", volumes: ["./src:/app:ro"] }, + tmp: { image: "example/web", volumes: ["/var/cache"] }, + undeclared: { image: "example/web", volumes: ["other:/data"] }, + interpolated: { image: "example/web", volumes: ["${DATA}:/data"] }, + }) + ) + ).toEqual([]); +}); + +test("a data service without user and on a mutable tag gets both advisories", () => { + for (const image of [ + "example/database", + "example/database:latest", + "registry.example:5000/team/database", + ]) { + expect( + findNativeDataAdvisories( + project({ db: { image, volumes: ["data:/var/lib/data"] } }) + ) + ).toEqual([ + { code: "native_data_owner_handoff_unqualified", services: ["db"] }, + { code: "native_retained_data_mutable_image", services: ["db"] }, + ]); + } +}); + +test("long-syntax and subpath named mounts count as data", () => { + expect( + findNativeDataAdvisories( + project({ + b: { + image: PINNED, + volumes: [ + { type: "volume", source: "data", target: "/data", volume: {} }, + ], + }, + a: { + image: PINNED, + volumes: [ + { + type: "volume", + source: "data", + target: "/srv", + volume: { subpath: "a" }, + }, + ], + }, + }) + ) + ).toEqual([ + { code: "native_data_owner_handoff_unqualified", services: ["a", "b"] }, + ]); +}); + +test("explicit user, pinned image, fixed tag or local build clear advisories", () => { + expect( + findNativeDataAdvisories( + project({ + numeric: { image: PINNED, user: "999:999", volumes: ["data:/d"] }, + root: { image: "example/db:16.4", user: "0:0", volumes: ["data:/d"] }, + number: { + image: `sha256:${"b".repeat(64)}`, + user: 70, + volumes: ["data:/d"], + }, + built: { build: ".", user: "1000", volumes: ["data:/d"] }, + }) + ) + ).toEqual([]); + expect( + findNativeDataAdvisories( + project({ + empty: { image: PINNED, user: " ", volumes: ["data:/d"] }, + }) + ) + ).toEqual([ + { code: "native_data_owner_handoff_unqualified", services: ["empty"] }, + ]); +}); + +test("an entrypoint override or local build is not an image handoff", () => { + expect( + findNativeDataAdvisories( + project({ + overridden: { + image: PINNED, + entrypoint: ["/usr/local/bin/server"], + volumes: ["data:/d"], + }, + emptied: { image: PINNED, entrypoint: [], volumes: ["data:/d"] }, + built: { build: { context: "." }, volumes: ["data:/d"] }, + }) + ) + ).toEqual([]); + expect( + findNativeDataAdvisories( + project({ + inherited: { image: PINNED, entrypoint: null, volumes: ["data:/d"] }, + }) + ) + ).toEqual([ + { code: "native_data_owner_handoff_unqualified", services: ["inherited"] }, + ]); +}); + +test("dependency-cache and disposable-cache volumes are not data", () => { + expect( + findNativeDataAdvisories( + project( + { + install: { + image: "example/installer:latest", + labels: { "hack.dependencies.cache-volume": "deps" }, + volumes: ["deps:/deps"], + }, + app: { + image: "example/app", + labels: ["unrelated=1"], + volumes: ["deps:/deps:ro", "scratch:/cache"], + }, + listed: { + image: "example/app", + labels: ["hack.dependencies.cache-volume=listed-deps"], + volumes: ["listed-deps:/deps"], + }, + }, + { + deps: {}, + "listed-deps": {}, + scratch: { "x-hack-disposable-cache": [".cache"] }, + } + ) + ) + ).toEqual([]); +}); + +test("the doctor result is a warning with codes and service names only", () => { + const ok = nativeDataCheckResult([]); + expect(ok).toMatchObject({ + name: NATIVE_DATA_CHECK, + status: "ok", + nativeData: { advisories: [] }, + }); + const advisories = findNativeDataAdvisories( + project( + { + db: { image: "example/private-db", volumes: ["secret-volume:/srv/x"] }, + }, + { "secret-volume": {} } + ) + ); + const warn = nativeDataCheckResult(advisories); + expect(warn.status).toBe("warn"); + expect(warn.nativeData.advisories.map((a) => a.code)).toEqual([ + "native_data_owner_handoff_unqualified", + "native_retained_data_mutable_image", + ]); + expect(warn.message).toContain("native_data_owner_handoff_unqualified (db)"); + expect(warn.message).toContain("user:"); + expect(warn.message).toContain("pin a digest"); + for (const value of ["example/private-db", "secret-volume", "/srv/x"]) { + expect(warn.message).not.toContain(value); + expect(JSON.stringify(warn.nativeData)).not.toContain(value); + } +}); + +async function fixture(compose: Record) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-data-advisories-")) + ); + roots.push(root); + const projectRoot = join(root, "project"); + const projectDir = join(projectRoot, ".hack"); + const nativeHome = join(root, "native-home"); + const runtimeDir = join(root, "runtime"); + await mkdir(projectDir, { recursive: true }); + await mkdir(nativeHome, { mode: 0o700 }); + await mkdir(runtimeDir); + const composeFile = join(projectDir, "docker-compose.yml"); + await writeFile(composeFile, JSON.stringify(compose)); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ name: "data-advisory-fixture" }) + ); + await writeFile(join(runtimeDir, "hack-relay-guest"), "synthetic relay"); + const binary = join(runtimeDir, "native-runtime"); + // Every runtime request refuses; doctor must still report the static check. + await writeFile( + binary, + `#!/bin/sh +printf '%s' '{"code":"runtime_not_running"}' >&2 +exit 1 +`, + { mode: 0o700 } + ); + const env = privateCliEnv({ + root, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: binary, + HACK_NATIVE_HOME: nativeHome, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + }, + }); + return { root, projectRoot, composeFile, env }; +} + +test("readNativeDataAdvisories reads the Compose file with native bounds", async () => { + const f = await fixture( + project({ db: { image: PINNED, volumes: ["data:/d"] } }) + ); + expect(await readNativeDataAdvisories(f.composeFile)).toEqual([ + { code: "native_data_owner_handoff_unqualified", services: ["db"] }, + ]); + expect(await readNativeDataAdvisories(join(f.root, "missing.yml"))).toEqual( + [] + ); +}); + +test( + "native doctor reports data advisories in JSON without authored values", + async () => { + const f = await fixture( + project( + { db: { image: "example/private-db", volumes: ["private-data:/x/y"] } }, + { "private-data": {} } + ) + ); + const child = Bun.spawn( + [ + process.execPath, + entrypoint, + "--path", + f.projectRoot, + "doctor", + "--json", + ], + { + cwd: f.root, + env: f.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT_MS - 5000, + } + ); + const [stdout, stderr] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + const output = stdout + stderr; + const envelope: unknown = JSON.parse(output.slice(output.indexOf("{"))); + expect(envelope).toMatchObject({ + data: { + checks: expect.arrayContaining([ + expect.objectContaining({ + id: NATIVE_DATA_CHECK, + status: "warn", + nativeData: { + advisories: [ + { + code: "native_data_owner_handoff_unqualified", + services: ["db"], + }, + { + code: "native_retained_data_mutable_image", + services: ["db"], + }, + ], + }, + }), + ]), + }, + }); + expect(output).not.toContain("example/private-db"); + expect(output).not.toContain("private-data"); + }, + SPAWN_TIMEOUT_MS +); From dbcefdc01b8c4cff1a5fac96bdbf43f9b31e9faf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:38:11 -0500 Subject: [PATCH 010/138] feat(native): pin a BuildKit client and scan Dockerfile image references Groundwork for native image builds; no command builds images yet and native up still refuses build services. - provider-pins.json pins buildx 0.34.1 (darwin-arm64, Developer ID signed) by SHA-256. `runtime prepare-builder --archive ` checks the pin before creating any state, copies the file into private candidate state, rehashes the owned copy, verifies its code signature and records a tree digest. An existing tree is re-verified and reused; an interrupted preparation is retained and refused. `runtime probe` reports `builder_prepared` and refuses a changed tree. - provider/build_scan.rs is a pure scanner that reports the images a build of the selected target can pull (FROM, COPY --from, RUN --mount from=, ONBUILD triggers of internal stages). It follows the Dockerfile frontend embedded in the pinned engine: directives, escape token, continuations, heredoc bodies, global ARG substitution with literal build-argument overrides, stage names and indexes, target reachability. It refuses what it cannot resolve exactly (syntax directives, variable modifiers, quotes, uncertain platform arguments, non-arm64 platforms, stage/image name collisions, unknown instructions and flags, non-Docker-Hub references, size and count bounds) and never echoes Dockerfile text or values. Refs HACK-1270 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 14 + packages/runtime-core/README.md | 3 + packages/runtime-core/provider-pins.json | 6 + packages/runtime-core/src/main.rs | 7 + .../runtime-core/src/provider/artifact.rs | 312 ++++- .../runtime-core/src/provider/build_scan.rs | 1166 +++++++++++++++++ .../src/provider/build_scan/tests.rs | 540 ++++++++ packages/runtime-core/src/provider/mod.rs | 20 + 8 files changed, 2067 insertions(+), 1 deletion(-) create mode 100644 packages/runtime-core/src/provider/build_scan.rs create mode 100644 packages/runtime-core/src/provider/build_scan/tests.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..a220a37f0 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -443,6 +443,20 @@ Provider setup is separate and explicit. Obtain the exact archives identified in ./hack-native --candidate-root /absolute/private/candidate-home runtime probe --json ``` +The pins also name a BuildKit client (`buildx`, `darwin-arm64`) for future native image +builds. Native `up` still refuses `build:` services and nothing runs this client yet. +Preparing it is optional: + +```sh +./hack-native --candidate-root /absolute/private/candidate-home runtime prepare-builder --archive /absolute/docker-buildx +``` + +The command takes the exact release file named in `provider-pins.json`. It checks +the SHA-256 before it changes any state, then copies the file into private candidate +state and checks its code signature. If a prepared client exists, the command checks +it again and does not replace it. If the prepared tree changes, `runtime probe` +refuses; it reports `builder_prepared` when the client is present and unchanged. + Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with regular, singly linked mode-`0600` files. Acquire the matching public package bytes; diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 7178928ec..9633d7895 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -477,6 +477,9 @@ pinned package preparation, ownership, resource admission, lifecycle commands, a The default private build includes native HTTP graph probes (local `_docs/docs/plans/v5/native-http-probe-20260915.md`) and requires Zig 0.15.2 plus a host C compiler. [Provider pins](provider-pins.json) record the exact package inputs; the engine runs inside the VM. +The pinned host BuildKit client (`runtime prepare-builder`) and the pure Dockerfile +image-reference scanner (`provider/build_scan.rs`) prepare for native image builds; +no command builds images yet. Source binaries embed their build checkout. If a copied Cargo target directory retains a different checkout identity, use a fresh `--target-dir` for checks and diff --git a/packages/runtime-core/provider-pins.json b/packages/runtime-core/provider-pins.json index 59d34ed86..c8a964b69 100644 --- a/packages/runtime-core/provider-pins.json +++ b/packages/runtime-core/provider-pins.json @@ -12,5 +12,11 @@ "platform": "linux-arm64", "url": "https://download.docker.com/linux/static/stable/aarch64/docker-29.5.2.tgz", "archive_sha256": "d1d9cb857c32c596ea96a9ca6b25d13621a97c83511e667868a33a320b2a707f" + }, + "buildx": { + "version": "0.34.1", + "platform": "darwin-arm64", + "url": "https://github.com/docker/buildx/releases/download/v0.34.1/buildx-v0.34.1.darwin-arm64", + "archive_sha256": "e5040acdaac1a349de84c0e7a80c861a368e0d141bf7260e1fd9a74b16749477" } } diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 0ab86ffde..c52f4e015 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -94,6 +94,7 @@ Usage: hack-local graph serve|run ... --shared-source hack-local runtime prepare --archive hack-local runtime prepare-engine --archive + hack-local runtime prepare-builder --archive hack-local runtime prepare-network-tools --directory hack-local runtime up --profile research|development --prepared-base prefer|require [--prepared-base-store ] [--json] hack-local runtime prepared-base build --profile research|development [--base-id ] [--store ] [--json] @@ -929,6 +930,12 @@ fn run() -> Result<(), CandidateError> { Path::new(archive), )?)?; } + ["runtime", "prepare-builder", "--archive", archive] => { + print_json(&hack_runtime_core::provider::prepare_builder( + &discover_candidate(&requested)?, + Path::new(archive), + )?)?; + } [ "runtime", "ensure-image", diff --git a/packages/runtime-core/src/provider/artifact.rs b/packages/runtime-core/src/provider/artifact.rs index 771d3eddf..1be865b98 100644 --- a/packages/runtime-core/src/provider/artifact.rs +++ b/packages/runtime-core/src/provider/artifact.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs::{self, File}; use std::io::Read; -use std::os::unix::fs::MetadataExt; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -261,6 +261,151 @@ pub fn verify_engine(candidate: &Candidate) -> Result Ok(artifact) } +/// Pinned standalone BuildKit client for native image builds. +/// +/// The release asset is a single Developer ID signed `darwin-arm64` executable that runs +/// on the host against the guest engine's forwarded private socket. Its bytes are pinned +/// by SHA-256 in `provider-pins.json`; preparation copies it into owned private state, so +/// the caller's file is never executed, and every later use re-verifies the prepared tree. +pub const BUILDER_SHA256: &str = "e5040acdaac1a349de84c0e7a80c861a368e0d141bf7260e1fd9a74b16749477"; +pub const BUILDER_VERSION: &str = "0.34.1"; +const BUILDER: BinaryPin = BinaryPin { + label: "BuildKit client", + version: BUILDER_VERSION, + sha256: BUILDER_SHA256, + directory: "buildx-0.34.1-darwin-arm64", + executable: "docker-buildx", + record: "buildx.json", + staging: "buildx-installing", + verify_signature: true, +}; + +/// One pinned single-file provider executable and where its prepared tree lives. +struct BinaryPin { + label: &'static str, + version: &'static str, + sha256: &'static str, + directory: &'static str, + executable: &'static str, + record: &'static str, + staging: &'static str, + verify_signature: bool, +} + +fn binary_root(candidate: &Candidate, pin: &BinaryPin) -> PathBuf { + candidate.state_root.join("providers").join(pin.directory) +} + +/// Prepare the pinned BuildKit client from a caller-supplied copy of its release asset. +/// +/// No state is created unless the file matches the pin. An existing prepared tree is +/// re-verified and reused, never overwritten. An interrupted preparation leaves its +/// private staging directory in place and refuses until it is inspected. +pub fn prepare_builder(candidate: &Candidate, binary: &Path) -> Result { + if !cfg!(all(target_os = "macos", target_arch = "aarch64")) { + return Err(CandidateError::new( + "unsupported_host", + "This pinned artifact requires Apple Silicon macOS.", + )); + } + prepare_binary(candidate, binary, &BUILDER) +} + +/// Verify the prepared BuildKit client before any use. +pub fn verify_builder(candidate: &Candidate) -> Result { + verify_binary(candidate, &BUILDER) +} + +fn binary_mismatch(pin: &BinaryPin, detail: &str) -> CandidateError { + CandidateError::new( + "artifact_digest_mismatch", + format!("{} {detail}", pin.label), + ) +} + +fn prepare_binary( + candidate: &Candidate, + source: &Path, + pin: &BinaryPin, +) -> Result { + // A FIFO or device would block or stream; only a regular file is hashed. + if !fs::metadata(source).map_err(io)?.is_file() || digest(source)? != pin.sha256 { + return Err(binary_mismatch( + pin, + "file does not match the pinned SHA-256.", + )); + } + let _lock = state::Lock::acquire(&candidate.state_root.join("run/smolvm"))?; + let providers = candidate.state_root.join("providers"); + state::private_directory(&providers)?; + if binary_root(candidate, pin).try_exists().map_err(io)? { + return verify_binary(candidate, pin); + } + let staging = providers.join(pin.staging); + state::private_directory(&staging)?; + let prepared = staging.join(pin.directory); + // Non-recursive and exclusive: a leftover from an interrupted run is retained, not reused. + fs::DirBuilder::new() + .mode(0o700) + .create(&prepared) + .map_err(io)?; + let owned = prepared.join(pin.executable); + let mut input = File::open(source).map_err(io)?; + let mut output = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o700) + .open(&owned) + .map_err(io)?; + std::io::copy(&mut input, &mut output).map_err(io)?; + output.sync_all().map_err(io)?; + drop(output); + // Rehash the owned copy to close source replacement between the check and the copy. + if digest(&owned)? != pin.sha256 { + return Err(binary_mismatch( + pin, + "file changed during preparation; staging retained.", + )); + } + fs::set_permissions(&owned, fs::Permissions::from_mode(0o700)).map_err(io)?; + if pin.verify_signature { + run( + clean_command(Path::new("/usr/bin/codesign")) + .args(["--verify", "--strict"]) + .arg(&owned), + Duration::from_secs(10), + )?; + } + let artifact = Artifact { + version: pin.version.into(), + archive_sha256: pin.sha256.into(), + tree_sha256: tree_digest(&prepared)?, + }; + fs::rename(&prepared, binary_root(candidate, pin)).map_err(io)?; + state::write(&providers.join(pin.record), &artifact)?; + // Only the now-empty staging directory; never recursive. + let _ = fs::remove_dir(&staging); + Ok(artifact) +} + +fn verify_binary(candidate: &Candidate, pin: &BinaryPin) -> Result { + let root = binary_root(candidate, pin); + reject_aliased_state(&root)?; + let artifact: Artifact = state::read(&candidate.state_root.join("providers").join(pin.record))?; + let executable = fs::symlink_metadata(root.join(pin.executable)).map_err(io)?; + if artifact.version != pin.version + || artifact.archive_sha256 != pin.sha256 + || !executable.is_file() + || artifact.tree_sha256 != tree_digest(&root)? + { + return Err(binary_mismatch( + pin, + "prepared tree changed; refusing execution.", + )); + } + Ok(artifact) +} + #[cfg(test)] mod tests { use super::*; @@ -286,6 +431,171 @@ mod tests { assert_eq!(pins["smolvm"]["archive_sha256"], ARCHIVE_SHA256); assert_eq!(pins["docker"]["version"], ENGINE_VERSION); assert_eq!(pins["docker"]["archive_sha256"], ENGINE_SHA256); + const PLATFORM: &str = "darwin-arm64"; + assert_eq!(pins["buildx"]["version"], BUILDER_VERSION); + assert_eq!(pins["buildx"]["platform"], PLATFORM); + assert_eq!(pins["buildx"]["archive_sha256"], BUILDER_SHA256); + assert_eq!( + pins["buildx"]["url"], + format!( + "https://github.com/docker/buildx/releases/download/v{BUILDER_VERSION}/buildx-v{BUILDER_VERSION}.{PLATFORM}" + ) + ); + assert_eq!( + BUILDER.directory, + format!("buildx-{BUILDER_VERSION}-{PLATFORM}") + ); + assert_eq!(BUILDER.version, BUILDER_VERSION); + assert_eq!(BUILDER.sha256, BUILDER_SHA256); + } + fn test_pin(bytes: &[u8]) -> BinaryPin { + let sha256: &'static str = + Box::leak(format!("{:x}", Sha256::digest(bytes)).into_boxed_str()); + BinaryPin { + label: "Test client", + version: "1.2.3", + sha256, + directory: "test-client-1.2.3", + executable: "test-client", + record: "test-client.json", + staging: "test-client-installing", + verify_signature: false, + } + } + #[test] + fn a_builder_binary_that_misses_the_pin_creates_no_state() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let untrusted = root.join("untrusted-client"); + fs::write(&untrusted, b"not the pinned client").unwrap(); + let error = prepare_binary(&candidate, &untrusted, &BUILDER).unwrap_err(); + assert_eq!(error.code, "artifact_digest_mismatch"); + let directory = root.join("not-a-file"); + fs::create_dir(&directory).unwrap(); + let error = prepare_binary(&candidate, &directory, &BUILDER).unwrap_err(); + assert_eq!(error.code, "artifact_digest_mismatch"); + assert!(!candidate.state_root.exists()); + assert!(verify_builder(&candidate).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn a_prepared_pinned_binary_is_reused_and_any_tree_change_refuses_execution() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let bytes = b"#!/bin/sh\nexit 0\n"; + let pin = test_pin(bytes); + let source = root.join("release-asset"); + fs::write(&source, bytes).unwrap(); + let prepared = prepare_binary(&candidate, &source, &pin).unwrap(); + assert_eq!(prepared.version, "1.2.3"); + assert_eq!(prepared.archive_sha256, pin.sha256); + let executable = binary_root(&candidate, &pin).join(pin.executable); + let metadata = fs::symlink_metadata(&executable).unwrap(); + assert_eq!(metadata.mode() & 0o777, 0o700); + assert_eq!(fs::read(&executable).unwrap(), bytes); + // The caller's file is copied, never moved or linked. + assert_eq!(fs::read(&source).unwrap(), bytes); + assert_ne!(metadata.ino(), fs::metadata(&source).unwrap().ino()); + assert!( + !candidate + .state_root + .join("providers") + .join(pin.staging) + .exists() + ); + // Preparing again verifies and reuses the existing tree. + let again = prepare_binary(&candidate, &source, &pin).unwrap(); + assert_eq!(again.tree_sha256, prepared.tree_sha256); + assert_eq!( + verify_binary(&candidate, &pin).unwrap().tree_sha256, + prepared.tree_sha256 + ); + + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let error = verify_binary(&candidate, &pin).unwrap_err(); + assert_eq!(error.code, "artifact_digest_mismatch"); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + verify_binary(&candidate, &pin).unwrap(); + + fs::write(&executable, b"#!/bin/sh\nexit 1\n").unwrap(); + assert_eq!( + verify_binary(&candidate, &pin).unwrap_err().code, + "artifact_digest_mismatch" + ); + // A changed tree is never repaired by a later prepare. + assert_eq!( + prepare_binary(&candidate, &source, &pin).unwrap_err().code, + "artifact_digest_mismatch" + ); + fs::write(&executable, bytes).unwrap(); + verify_binary(&candidate, &pin).unwrap(); + + let extra = binary_root(&candidate, &pin).join("plugin"); + fs::write(&extra, b"extra").unwrap(); + assert!(verify_binary(&candidate, &pin).is_err()); + fs::remove_file(&extra).unwrap(); + + let other_version = BinaryPin { + version: "1.2.4", + ..test_pin(bytes) + }; + assert_eq!( + verify_binary(&candidate, &other_version).unwrap_err().code, + "artifact_digest_mismatch" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn a_replaced_prepared_executable_or_aliased_root_refuses() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let bytes = b"pinned client"; + let pin = test_pin(bytes); + let source = root.join("release-asset"); + fs::write(&source, bytes).unwrap(); + prepare_binary(&candidate, &source, &pin).unwrap(); + let prepared = binary_root(&candidate, &pin); + let executable = prepared.join(pin.executable); + fs::remove_file(&executable).unwrap(); + std::os::unix::fs::symlink(&source, &executable).unwrap(); + assert!(verify_binary(&candidate, &pin).is_err()); + fs::remove_file(&executable).unwrap(); + fs::write(&executable, bytes).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + verify_binary(&candidate, &pin).unwrap(); + + let moved = root.join("moved"); + fs::rename(&prepared, &moved).unwrap(); + std::os::unix::fs::symlink(&moved, &prepared).unwrap(); + assert_eq!( + verify_binary(&candidate, &pin).unwrap_err().code, + "aliased_state" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn an_interrupted_preparation_is_retained_and_refused() { + let root = fixture(); + let candidate = Candidate::discover(&root).unwrap(); + let bytes = b"pinned client"; + let pin = test_pin(bytes); + let source = root.join("release-asset"); + fs::write(&source, bytes).unwrap(); + let leftover = candidate + .state_root + .join("providers") + .join(pin.staging) + .join(pin.directory); + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&leftover) + .unwrap(); + fs::write(leftover.join(pin.executable), b"partial").unwrap(); + assert!(prepare_binary(&candidate, &source, &pin).is_err()); + assert_eq!(fs::read(leftover.join(pin.executable)).unwrap(), b"partial"); + assert!(!binary_root(&candidate, &pin).exists()); + fs::remove_dir_all(root).unwrap(); } #[test] fn unexpected_large_sparse_files_are_rejected_before_hashing_gigabytes() { diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs new file mode 100644 index 000000000..c7544e0d1 --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -0,0 +1,1166 @@ +//! Pure Dockerfile image-reference scanner for native image builds. +//! +//! A native build pins every image it may pull by digest before BuildKit runs, so it +//! must know those images first. This module reads a Dockerfile, never executes it, +//! and reports the images a build of the selected target can pull: `FROM`, +//! `COPY --from=`, `RUN --mount=...,from=`, and the same forms inside +//! `ONBUILD` triggers of the Dockerfile's own stages. It follows the Dockerfile +//! frontend built into the pinned engine (BuildKit 0.30): +//! +//! - physical lines, comments, parser directives (`escape`), line continuations and +//! heredoc bodies of `RUN`, `COPY` and `ADD` are read as the frontend reads them; +//! - `FROM` names and `--platform` values expand `$NAME`/`${NAME}` from the global +//! `ARG`s declared before the first `FROM`, overridden by the build's literal +//! arguments, plus the frontend's platform arguments for a `linux/arm64` build; +//! - `COPY --from` and `RUN --mount` `from=` values are literal (the frontend refuses +//! expansion there); a value naming a stage, or a `COPY --from` stage index, is a +//! stage, `scratch` is empty, and anything else is an image; +//! - only stages reachable from the target contribute images. +//! +//! Whatever the scanner cannot resolve exactly refuses instead of guessing: a frontend +//! selected by `# syntax=` (the frontend itself would be pulled), `${NAME:-...}` and +//! other modifiers, quotes or escapes in references, unknown or uncertain variables, +//! a platform other than `linux/arm64`, an image whose name equals a stage name (a +//! pinned context of that name would replace the stage), duplicate stage names, and +//! unknown instructions or flags. Every image must be a public Docker Hub reference. +//! +//! Messages carry line numbers only; they never echo Dockerfile text or argument +//! values. Not covered here: `ONBUILD` triggers stored in a base image's own +//! configuration, which the build step must inspect on the pinned base images. +use super::image_reference::{ImageReference, NamedReference, normalize}; +use crate::CandidateError; +use std::collections::{BTreeMap, BTreeSet}; + +/// Largest Dockerfile the scanner reads. +pub const MAX_DOCKERFILE_BYTES: usize = 1024 * 1024; +/// Most distinct images one build may pin. +pub const MAX_IMAGE_REFERENCES: usize = 32; +/// Most build stages one Dockerfile may declare. +pub const MAX_STAGES: usize = 256; + +/// Platform arguments whose values the scanner knows for a `linux/arm64` build. +const KNOWN_PLATFORM_ARGS: [(&str, &str); 4] = [ + ("BUILDOS", "linux"), + ("BUILDARCH", "arm64"), + ("TARGETOS", "linux"), + ("TARGETARCH", "arm64"), +]; +/// Frontend-defined arguments whose exact text the scanner does not assume. +const UNCERTAIN_FRONTEND_ARGS: [&str; 8] = [ + "BUILDPLATFORM", + "BUILDOSVERSION", + "BUILDVARIANT", + "TARGETPLATFORM", + "TARGETOSVERSION", + "TARGETVARIANT", + "TARGETSTAGE", + "SOURCE_DATE_EPOCH", +]; +const PLATFORM_VARIABLES: [&str; 4] = [ + "$BUILDPLATFORM", + "${BUILDPLATFORM}", + "$TARGETPLATFORM", + "${TARGETPLATFORM}", +]; +const KNOWN_INSTRUCTIONS: [&str; 18] = [ + "add", + "arg", + "cmd", + "copy", + "entrypoint", + "env", + "expose", + "from", + "healthcheck", + "label", + "maintainer", + "onbuild", + "run", + "shell", + "stopsignal", + "user", + "volume", + "workdir", +]; + +/// One scan: the Dockerfile bytes, the build target and the build's literal arguments. +pub struct ScanRequest<'a> { + pub dockerfile: &'a [u8], + /// Stage name to build; `None` builds the last stage. + pub target: Option<&'a str>, + /// Literal build arguments. Values are used for substitution only and never reported. + pub args: &'a BTreeMap, +} + +/// A base image the build may pull. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BaseImage { + /// The familiar name the frontend matches named build contexts against, for + /// example `node:24` or `library/alpine@sha256:...` as `alpine@sha256:...`. + pub context_name: String, + /// The normalized public Docker Hub reference. + pub reference: NamedReference, +} + +impl BaseImage { + /// The Docker Hub `namespace/repository` path. + pub fn repository(&self) -> &str { + &self.reference.path + } +} + +/// One declared build stage. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Stage { + pub name: Option, + /// Whether building the target needs this stage. + pub reachable: bool, +} + +/// The scan result: stages, the selected target and the distinct images in first-use order. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DockerfileScan { + pub stages: Vec, + pub target: usize, + pub images: Vec, +} + +fn refusal(line: Option, message: &str) -> CandidateError { + let message = match line { + Some(line) => format!("Dockerfile line {line}: {message}"), + None => format!("Dockerfile: {message}"), + }; + CandidateError::new("native_build_dockerfile", message) +} + +/// Scan a Dockerfile for the images a native build of `request.target` can pull. +pub fn scan(request: &ScanRequest<'_>) -> Result { + validate_arguments(request.args)?; + if request.dockerfile.len() > MAX_DOCKERFILE_BYTES { + return Err(refusal(None, "exceeds the 1 MiB scan limit.")); + } + let text = + std::str::from_utf8(request.dockerfile).map_err(|_| refusal(None, "is not UTF-8 text."))?; + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + refuse_frontend_selection(text)?; + let parsed = parse(text)?; + resolve(&parsed, request) +} + +fn validate_arguments(args: &BTreeMap) -> Result<(), CandidateError> { + for name in args.keys() { + let reserved = name.starts_with("BUILDKIT_") + || KNOWN_PLATFORM_ARGS.iter().any(|(known, _)| known == name) + || UNCERTAIN_FRONTEND_ARGS.contains(&name.as_str()); + if reserved { + return Err(CandidateError::new( + "native_build_argument", + "Build arguments may not set BuildKit frontend or platform arguments.", + )); + } + } + Ok(()) +} + +/// Refuse anything the frontend would treat as a request for another frontend: +/// `# syntax=` or `// syntax=` in the leading comment block (after an optional +/// shebang) and a JSON document. Broader than the frontend on purpose. +fn refuse_frontend_selection(text: &str) -> Result<(), CandidateError> { + if text.trim_start().starts_with('{') { + return Err(refusal(None, "JSON-form Dockerfiles are not supported.")); + } + let mut lines = text.split('\n').peekable(); + if lines.peek().is_some_and(|line| line.starts_with("#!")) { + lines.next(); + } + for line in lines { + let line = line.trim_start(); + let Some(rest) = line.strip_prefix('#').or_else(|| line.strip_prefix("//")) else { + break; + }; + let rest = rest.trim_start(); + let key_end = rest + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + if rest[..key_end].eq_ignore_ascii_case("syntax") + && rest[key_end..].trim_start().starts_with('=') + { + return Err(refusal( + None, + "a `# syntax=` frontend directive is not supported for native builds.", + )); + } + } + Ok(()) +} + +/// A use of `COPY --from` or `RUN --mount=...,from=` with its literal value. +#[derive(Debug)] +struct Use { + line: usize, + value: String, + /// `COPY --from` accepts a stage index; mount `from=` does not. + index_allowed: bool, +} + +#[derive(Debug)] +struct RawStage { + line: usize, + base: String, + platform: Option, + name: Option, + uses: Vec, + onbuild: Vec, +} + +#[derive(Debug, Default)] +struct Parsed { + /// Global `ARG` declarations before the first `FROM`: name and raw default. + meta_args: Vec<(String, Option)>, + stages: Vec, +} + +/// The `escape` directive from the leading directive block, as the frontend reads it. +fn escape_token(lines: &[&str]) -> Result { + let mut seen = BTreeSet::new(); + let mut escape = '\\'; + for (index, raw) in lines.iter().enumerate() { + let line = trim_newline(raw).trim_start(); + let Some(rest) = line.strip_prefix('#') else { + break; + }; + let Some((key, value)) = directive(rest.trim_start()) else { + break; + }; + let key = key.to_ascii_lowercase(); + if !matches!(key.as_str(), "syntax" | "escape" | "check") { + break; + } + if !seen.insert(key.clone()) { + return Err(refusal(Some(index + 1), "a parser directive is repeated.")); + } + if key == "escape" { + escape = match value { + "\\" => '\\', + "`" => '`', + _ => return Err(refusal(Some(index + 1), "invalid escape directive.")), + }; + } + } + Ok(escape) +} + +/// `^([a-zA-Z][a-zA-Z0-9]*)\s*=\s*(.+?)\s*$` +fn directive(text: &str) -> Option<(&str, &str)> { + let first = text.chars().next()?; + if !first.is_ascii_alphabetic() { + return None; + } + let key_end = text + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(text.len()); + let rest = text[key_end..].trim_start().strip_prefix('=')?; + let value = rest.trim(); + (!value.is_empty()).then_some((&text[..key_end], value)) +} + +fn trim_newline(line: &str) -> &str { + line.trim_end_matches(['\r', '\n']) +} + +/// The frontend's continuation rule: a trailing escape token (optionally followed by +/// spaces or tabs) continues the line unless the token itself is escaped. +fn trim_continuation(line: &str, escape: char) -> (&str, bool) { + let trimmed = line.trim_end_matches([' ', '\t']); + if let Some(before) = trimmed.strip_suffix(escape) { + if before.is_empty() { + return ("", false); + } + if !before.ends_with(escape) { + return (before, false); + } + } + (line, true) +} + +fn is_frontend_whitespace(c: char) -> bool { + matches!(c, '\t' | '\u{b}' | '\u{c}' | '\r' | ' ') +} + +struct Instruction { + command: String, + flags: Vec, + args: String, +} + +/// Split `COMMAND [--flags] args` the way the frontend's `splitCommand` does. +fn split_instruction(line: &str, escape: char) -> Instruction { + let line = line.trim(); + let (command, rest) = match line.find(is_frontend_whitespace) { + Some(at) => ( + &line[..at], + line[at..].trim_start_matches(is_frontend_whitespace), + ), + None => (line, ""), + }; + let (args, flags) = if rest.is_empty() { + (String::new(), Vec::new()) + } else { + extract_flags(rest, escape) + }; + Instruction { + command: command.to_ascii_lowercase(), + flags, + args: args.trim().to_string(), + } +} + +/// Port of the frontend's `extractBuilderFlags`: leading `--name[=value]` words with +/// quotes removed and escapes applied; returns the remaining text and the flag words. +fn extract_flags(line: &str, escape: char) -> (String, Vec) { + #[derive(PartialEq)] + enum Phase { + Spaces, + Word, + Quote, + } + let chars: Vec<(usize, char)> = line.char_indices().collect(); + let mut words = Vec::new(); + let mut phase = Phase::Spaces; + let mut word = String::new(); + let mut quote = '\0'; + let mut blank_ok = false; + let mut pos = 0; + loop { + let at_end = pos == chars.len(); + if phase == Phase::Spaces { + if at_end { + break; + } + let ch = chars[pos].1; + if ch.is_whitespace() { + pos += 1; + continue; + } + if ch != '-' || pos + 1 == chars.len() || chars[pos + 1].1 != '-' { + return (line[chars[pos].0..].to_string(), words); + } + phase = Phase::Word; + } + if at_end { + if word != "--" && (blank_ok || !word.is_empty()) { + words.push(word); + } + break; + } + let mut ch = chars[pos].1; + if phase == Phase::Word { + if ch.is_whitespace() { + phase = Phase::Spaces; + if word == "--" { + return (line[chars[pos].0..].to_string(), words); + } + if blank_ok || !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + word.clear(); + blank_ok = false; + pos += 1; + continue; + } + if ch == '\'' || ch == '"' { + quote = ch; + blank_ok = true; + phase = Phase::Quote; + pos += 1; + continue; + } + if ch == escape { + if pos + 1 == chars.len() { + pos += 1; + continue; + } + pos += 1; + ch = chars[pos].1; + } + word.push(ch); + pos += 1; + continue; + } + // Phase::Quote + if ch == quote { + phase = Phase::Word; + pos += 1; + continue; + } + if ch == escape { + if pos + 1 == chars.len() { + phase = Phase::Word; + pos += 1; + continue; + } + pos += 1; + ch = chars[pos].1; + } + word.push(ch); + pos += 1; + } + (String::new(), words) +} + +#[derive(Clone, Copy, PartialEq)] +enum FlagKind { + Value, + Bool, + Repeated, +} + +/// Parse flag words against an instruction's flag set; returns values by flag name. +/// Unknown, duplicated or malformed flags refuse, as the frontend would fail on them. +fn parse_flags( + words: &[String], + allowed: &[(&str, FlagKind)], + line: usize, +) -> Result>, CandidateError> { + let mut values: BTreeMap> = BTreeMap::new(); + for word in words { + let Some(body) = word.strip_prefix("--") else { + return Err(refusal(Some(line), "unsupported instruction flag.")); + }; + let (name, value) = match body.split_once('=') { + Some((name, value)) => (name, Some(value)), + None => (body, None), + }; + let Some((_, kind)) = allowed.iter().find(|(known, _)| *known == name) else { + return Err(refusal(Some(line), "unsupported instruction flag.")); + }; + let entry = values.entry(name.to_string()).or_default(); + let malformed = match kind { + FlagKind::Value => value.is_none() || !entry.is_empty(), + FlagKind::Bool => { + !entry.is_empty() + || value.is_some_and(|v| !matches!(v, "true" | "false" | "1" | "0")) + } + FlagKind::Repeated => value.is_none(), + }; + if malformed { + return Err(refusal( + Some(line), + "malformed or repeated instruction flag.", + )); + } + entry.push(value.unwrap_or("").to_string()); + } + Ok(values) +} + +const FROM_FLAGS: [(&str, FlagKind); 1] = [("platform", FlagKind::Value)]; +const COPY_FLAGS: [(&str, FlagKind); 6] = [ + ("chown", FlagKind::Value), + ("from", FlagKind::Value), + ("chmod", FlagKind::Value), + ("link", FlagKind::Bool), + ("exclude", FlagKind::Repeated), + ("parents", FlagKind::Bool), +]; +const ADD_FLAGS: [(&str, FlagKind); 7] = [ + ("chown", FlagKind::Value), + ("chmod", FlagKind::Value), + ("link", FlagKind::Bool), + ("keep-git-dir", FlagKind::Bool), + ("checksum", FlagKind::Value), + ("unpack", FlagKind::Bool), + ("exclude", FlagKind::Repeated), +]; +const RUN_FLAGS: [(&str, FlagKind); 4] = [ + ("mount", FlagKind::Repeated), + ("network", FlagKind::Value), + ("security", FlagKind::Value), + ("device", FlagKind::Repeated), +]; + +/// A literal `--from`/`from=` value: anything the frontend would expand or unquote refuses. +fn literal_source(value: &str, escape: char, line: usize) -> Result { + if value.is_empty() + || value + .chars() + .any(|c| matches!(c, '$' | '\'' | '"' | '\\' | '`') || c == escape || c.is_whitespace()) + { + return Err(refusal( + Some(line), + "a build source must be a literal stage or image name without variables or quotes.", + )); + } + Ok(value.to_string()) +} + +/// Image and stage uses of one `COPY`/`ADD`/`RUN` instruction. +fn instruction_uses( + instruction: &Instruction, + escape: char, + line: usize, +) -> Result, CandidateError> { + let mut uses = Vec::new(); + match instruction.command.as_str() { + "copy" => { + let flags = parse_flags(&instruction.flags, ©_FLAGS, line)?; + if let Some(value) = flags.get("from").and_then(|v| v.first()) { + uses.push(Use { + line, + value: literal_source(value, escape, line)?, + index_allowed: true, + }); + } + } + "add" => { + parse_flags(&instruction.flags, &ADD_FLAGS, line)?; + } + "run" => { + let flags = parse_flags(&instruction.flags, &RUN_FLAGS, line)?; + for mount in flags.get("mount").into_iter().flatten() { + if mount.contains('"') { + return Err(refusal( + Some(line), + "quoted mount options are not supported.", + )); + } + for field in mount.split(',') { + if let Some((key, value)) = field.split_once('=') { + if key.eq_ignore_ascii_case("from") { + uses.push(Use { + line, + value: literal_source(value, escape, line)?, + index_allowed: false, + }); + } + } + } + } + } + _ => {} + } + Ok(uses) +} + +/// Whether the frontend parses these arguments as a JSON string array (exec form). +fn json_form(args: &str, line: usize) -> Result { + let args = args.trim_start(); + if !args.starts_with('[') { + return Ok(false); + } + match serde_json::from_str::>(args) { + Ok(values) if values.iter().all(serde_json::Value::is_string) => Ok(true), + Ok(_) => Err(refusal( + Some(line), + "a JSON-form argument list must contain strings.", + )), + Err(_) => Ok(false), + } +} + +/// Shell words with quotes and escapes kept raw, as the heredoc detector lexes a line. +fn raw_words(line: &str) -> Option> { + let mut words = Vec::new(); + let mut word = String::new(); + let mut in_word = false; + let mut chars = line.chars(); + while let Some(c) = chars.next() { + match c { + c if c.is_whitespace() => { + if in_word { + words.push(std::mem::take(&mut word)); + in_word = false; + } + } + '\\' => { + word.push(c); + word.push(chars.next()?); + in_word = true; + } + '\'' => { + word.push(c); + loop { + let next = chars.next()?; + word.push(next); + if next == '\'' { + break; + } + } + in_word = true; + } + '"' => { + word.push(c); + loop { + let next = chars.next()?; + word.push(next); + if next == '\\' { + word.push(chars.next()?); + } else if next == '"' { + break; + } + } + in_word = true; + } + _ => { + word.push(c); + in_word = true; + } + } + } + if in_word { + words.push(word); + } + Some(words) +} + +/// A heredoc word, `^(\d*)<<(-?)\s*([^<]*)$`: its terminator and whether tabs are chomped. +fn heredoc(word: &str, line: usize) -> Result, CandidateError> { + let rest = word.trim_start_matches(|c: char| c.is_ascii_digit()); + let Some(rest) = rest.strip_prefix("<<") else { + return Ok(None); + }; + let (rest, chomp) = match rest.strip_prefix('-') { + Some(rest) => (rest, true), + None => (rest, false), + }; + let rest = rest.trim_start_matches(['\t', '\n', '\u{c}', '\r', ' ']); + if rest.is_empty() || rest.contains('<') { + return Ok(None); + } + if rest.contains('\\') { + return Err(refusal( + Some(line), + "escaped heredoc delimiters are not supported.", + )); + } + let mut name = String::new(); + let mut quote = None; + for c in rest.chars() { + match (quote, c) { + (None, '\'' | '"') => quote = Some(c), + (Some(open), c) if c == open => quote = None, + (None, c) if c.is_whitespace() => return Ok(None), + _ => name.push(c), + } + } + if quote.is_some() || name.is_empty() { + return Err(refusal(Some(line), "unrecognized heredoc delimiter.")); + } + Ok(Some((name, chomp))) +} + +fn heredoc_capable( + instruction: &Instruction, + escape: char, + line: usize, +) -> Result { + let inner; + let instruction = if instruction.command == "onbuild" { + inner = split_instruction(&instruction.args, escape); + &inner + } else { + instruction + }; + Ok( + matches!(instruction.command.as_str(), "run" | "copy" | "add") + && !json_form(&instruction.args, line)?, + ) +} + +fn parse(text: &str) -> Result { + let lines: Vec<&str> = text.split('\n').collect(); + let escape = escape_token(&lines)?; + let mut parsed = Parsed::default(); + let mut index = 0; + while index < lines.len() { + let start = index + 1; + let first = trim_newline(lines[index]).trim_start(); + index += 1; + if first.starts_with('#') { + continue; + } + let (first, mut end) = trim_continuation(first, escape); + if end && first.is_empty() { + continue; + } + let mut logical = first.to_string(); + while !end && index < lines.len() { + let raw = trim_newline(lines[index]); + index += 1; + let trimmed = raw.trim_start(); + if trimmed.starts_with('#') || trimmed.is_empty() { + continue; + } + let (part, done) = trim_continuation(raw, escape); + logical.push_str(part); + end = done; + } + let instruction = split_instruction(&logical, escape); + if heredoc_capable(&instruction, escape, start)? && logical.contains("<<") { + let words = + raw_words(&logical).ok_or_else(|| refusal(Some(start), "unterminated quote."))?; + for word in words { + let Some((terminator, chomp)) = heredoc(&word, start)? else { + continue; + }; + let mut terminated = false; + while index < lines.len() { + let body = trim_newline(lines[index]); + index += 1; + let body = if chomp { + body.trim_start_matches('\t') + } else { + body + }; + if body == terminator { + terminated = true; + break; + } + } + if !terminated { + return Err(refusal(Some(start), "unterminated heredoc.")); + } + } + } + dispatch(&mut parsed, instruction, escape, start)?; + } + if parsed.stages.is_empty() { + return Err(refusal(None, "declares no build stage.")); + } + Ok(parsed) +} + +fn dispatch( + parsed: &mut Parsed, + instruction: Instruction, + escape: char, + line: usize, +) -> Result<(), CandidateError> { + let command = instruction.command.as_str(); + if !KNOWN_INSTRUCTIONS.contains(&command) { + return Err(refusal(Some(line), "unknown instruction.")); + } + if parsed.stages.is_empty() && command != "arg" && command != "from" { + return Err(refusal(Some(line), "only ARG may precede the first FROM.")); + } + match command { + "from" => { + let flags = parse_flags(&instruction.flags, &FROM_FLAGS, line)?; + let words: Vec<&str> = instruction + .args + .split(is_frontend_whitespace) + .filter(|w| !w.is_empty()) + .collect(); + let name = match words.as_slice() { + [_] => None, + [_, keyword, name] if keyword.eq_ignore_ascii_case("as") => { + let name = name.to_ascii_lowercase(); + let valid = name.starts_with(|c: char| c.is_ascii_lowercase()) + && name.chars().all(|c| { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || matches!(c, '-' | '_' | '.') + }); + if !valid { + return Err(refusal(Some(line), "invalid build stage name.")); + } + Some(name) + } + _ => return Err(refusal(Some(line), "FROM must name one image or stage.")), + }; + if name + .as_ref() + .is_some_and(|name| parsed.stages.iter().any(|s| s.name.as_ref() == Some(name))) + { + return Err(refusal(Some(line), "duplicate build stage name.")); + } + if parsed.stages.len() == MAX_STAGES { + return Err(refusal(Some(line), "declares more than 256 build stages.")); + } + parsed.stages.push(RawStage { + line, + base: words[0].to_string(), + platform: flags.get("platform").and_then(|v| v.first()).cloned(), + name, + uses: Vec::new(), + onbuild: Vec::new(), + }); + } + "arg" => { + let words = argument_words(&instruction.args, escape); + if words.is_empty() { + return Err(refusal(Some(line), "ARG requires a name.")); + } + for word in words { + let (name, value) = match word.split_once('=') { + Some((name, value)) => (name.to_string(), Some(value.to_string())), + None => (word, None), + }; + if name.is_empty() { + return Err(refusal(Some(line), "ARG requires a name.")); + } + if parsed.stages.is_empty() { + parsed.meta_args.push((name, value)); + } + } + } + "onbuild" => { + if !instruction.flags.is_empty() { + return Err(refusal(Some(line), "unsupported instruction flag.")); + } + let inner = split_instruction(&instruction.args, escape); + if !KNOWN_INSTRUCTIONS.contains(&inner.command.as_str()) + || matches!(inner.command.as_str(), "onbuild" | "from" | "maintainer") + { + return Err(refusal(Some(line), "unsupported ONBUILD trigger.")); + } + let uses = instruction_uses(&inner, escape, line)?; + if let Some(stage) = parsed.stages.last_mut() { + stage.onbuild.extend(uses); + } + } + _ => { + let uses = instruction_uses(&instruction, escape, line)?; + if let Some(stage) = parsed.stages.last_mut() { + stage.uses.extend(uses); + } + } + } + Ok(()) +} + +/// Port of the frontend's `parseWords`: whitespace-separated words, quotes kept, +/// the escape token keeping the character it escapes. +fn argument_words(text: &str, escape: char) -> Vec { + let mut words = Vec::new(); + let mut word = String::new(); + let mut started = false; + let mut quote: Option = None; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match quote { + None if c.is_whitespace() => { + if started { + words.push(std::mem::take(&mut word)); + started = false; + } + } + None => { + started = true; + if c == '\'' || c == '"' { + quote = Some(c); + word.push(c); + } else if c == escape { + if let Some(next) = chars.next() { + word.push(c); + word.push(next); + } + } else { + word.push(c); + } + } + Some(open) => { + if c == open { + quote = None; + word.push(c); + } else if c == escape && open != '\'' { + if let Some(next) = chars.next() { + word.push(c); + word.push(next); + } + } else { + word.push(c); + } + } + } + } + if started { + words.push(word); + } + words +} + +/// A global argument value: known text, or present with text the scanner cannot know. +type Env = BTreeMap>; + +/// Expand `$NAME` and `${NAME}`. Any other `$` form, quote, escape, unknown or +/// uncertain variable yields `None`. +fn expand(word: &str, env: &Env) -> Option { + let mut out = String::new(); + let mut chars = word.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '$' => { + let braced = chars.peek() == Some(&'{'); + if braced { + chars.next(); + } + let mut name = String::new(); + while let Some(&next) = chars.peek() { + if next.is_ascii_alphanumeric() || next == '_' { + name.push(next); + chars.next(); + } else { + break; + } + } + if braced && chars.next() != Some('}') { + return None; + } + if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + return None; + } + out.push_str(env.get(&name)?.as_deref()?); + } + '\'' | '"' | '\\' | '`' => return None, + _ => out.push(c), + } + } + Some(out) +} + +/// The value of a global `ARG` default: quotes removed, `$NAME`/`${NAME}` expanded. +/// Anything else is recorded as uncertain and refuses only when a reference uses it. +fn argument_value(raw: &str, env: &Env) -> Option { + let mut out = String::new(); + let mut rest = raw; + while let Some(c) = rest.chars().next() { + match c { + '\'' => { + let body = &rest[1..]; + let end = body.find('\'')?; + out.push_str(&body[..end]); + rest = &body[end + 1..]; + } + '"' => { + let body = &rest[1..]; + let end = body.find('"')?; + out.push_str(&expand(&body[..end], env)?); + rest = &body[end + 1..]; + } + _ => { + let end = rest.find(['\'', '"']).unwrap_or(rest.len()); + out.push_str(&expand(&rest[..end], env)?); + rest = &rest[end..]; + } + } + } + Some(out) +} + +fn global_env(parsed: &Parsed, args: &BTreeMap) -> Env { + let mut env: Env = KNOWN_PLATFORM_ARGS + .iter() + .map(|(name, value)| ((*name).to_string(), Some((*value).to_string()))) + .collect(); + for name in UNCERTAIN_FRONTEND_ARGS { + env.insert(name.to_string(), None); + } + for (name, default) in &parsed.meta_args { + if let Some(value) = args.get(name) { + env.insert(name.clone(), Some(value.clone())); + } else if let Some(default) = default { + let value = argument_value(default, &env); + env.insert(name.clone(), value); + } + } + // The frontend rewrites this one from the resolved build epoch. + env.insert("SOURCE_DATE_EPOCH".into(), None); + env +} + +#[derive(Clone, Debug)] +enum Source { + Scratch, + Stage(usize), + Image(String), +} + +fn platform_supported(raw: &str, env: &Env) -> bool { + PLATFORM_VARIABLES.contains(&raw) + || expand(raw, env) + .is_some_and(|value| matches!(value.as_str(), "linux/arm64" | "linux/arm64/v8")) +} + +fn stage_by_name(parsed: &Parsed, name: &str) -> Option { + let name = name.to_ascii_lowercase(); + parsed + .stages + .iter() + .position(|stage| stage.name.as_deref() == Some(name.as_str())) +} + +fn use_source(parsed: &Parsed, source: &Use) -> Result { + let value = source.value.as_str(); + if source.index_allowed && value.bytes().all(|b| b.is_ascii_digit()) { + return value + .parse::() + .ok() + .filter(|index| *index < parsed.stages.len()) + .map(Source::Stage) + .ok_or_else(|| refusal(Some(source.line), "invalid build stage index.")); + } + if source.index_allowed && value.starts_with(['+', '-']) { + return Err(refusal(Some(source.line), "invalid build stage index.")); + } + if let Some(index) = stage_by_name(parsed, value) { + return Ok(Source::Stage(index)); + } + if value == "scratch" { + return Ok(Source::Scratch); + } + Ok(Source::Image(value.to_string())) +} + +fn base_image(name: &str, line: usize) -> Result { + match normalize(name) { + Ok(ImageReference::Named(reference)) if reference.docker_hub_repository().is_some() => { + let path = reference + .path + .strip_prefix("library/") + .unwrap_or(&reference.path); + let mut familiar = path.to_string(); + if let Some(tag) = &reference.tag { + familiar.push(':'); + familiar.push_str(tag); + } + if let Some(digest) = &reference.digest { + familiar.push('@'); + familiar.push_str(digest); + } + let context_name = familiar + .strip_suffix(":latest") + .map_or(familiar.clone(), str::to_string); + Ok(BaseImage { + context_name, + reference, + }) + } + _ => Err(CandidateError::new( + "native_build_base_image", + format!( + "Dockerfile line {line}: build images must be public Docker Hub [namespace/]repository[:tag][@sha256] references." + ), + )), + } +} + +fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result { + let env = global_env(parsed, request.args); + // Each stage's base as the frontend registers it: an earlier stage of that name, + // `scratch`, or an image. `None` marks a name the scanner cannot expand. + let mut bases: Vec> = Vec::with_capacity(parsed.stages.len()); + for (index, stage) in parsed.stages.iter().enumerate() { + let base = expand(&stage.base, &env) + .filter(|name| !name.is_empty()) + .map(|name| { + if name == "scratch" { + Source::Scratch + } else if let Some(earlier) = parsed.stages[..index] + .iter() + .position(|s| s.name.as_deref() == Some(name.as_str())) + { + Source::Stage(earlier) + } else { + Source::Image(name) + } + }); + bases.push(base); + } + let target = match request.target { + Some(target) => stage_by_name(parsed, target) + .ok_or_else(|| refusal(None, "the build target stage is not declared."))?, + None => parsed.stages.len() - 1, + }; + + let mut reachable = vec![false; parsed.stages.len()]; + let mut pending = vec![target]; + let mut images: Vec<(String, usize)> = Vec::new(); + while let Some(index) = pending.pop() { + if std::mem::replace(&mut reachable[index], true) { + continue; + } + let stage = &parsed.stages[index]; + if let Some(platform) = &stage.platform { + if !platform_supported(platform, &env) { + return Err(refusal( + Some(stage.line), + "native builds support only the linux/arm64 platform.", + )); + } + } + let mut sources = Vec::new(); + match &bases[index] { + Some(base) => sources.push((base.clone(), stage.line)), + None => { + return Err(refusal( + Some(stage.line), + "the FROM name uses a variable the scanner cannot resolve.", + )); + } + } + for used in &stage.uses { + sources.push((use_source(parsed, used)?, used.line)); + } + // ONBUILD triggers of every ancestor stage run in this stage (a superset of the + // frontend's inheritance, so no trigger image is missed). + let mut ancestor = &bases[index]; + while let Some(Source::Stage(parent)) = ancestor { + for used in &parsed.stages[*parent].onbuild { + sources.push((use_source(parsed, used)?, used.line)); + } + ancestor = &bases[*parent]; + } + for (source, line) in sources { + match source { + Source::Scratch => {} + Source::Stage(next) => pending.push(next), + Source::Image(name) => images.push((name, line)), + } + } + } + + let mut admitted: Vec = Vec::new(); + let mut lines: Vec<(usize, BaseImage)> = Vec::new(); + for (name, line) in images { + lines.push((line, base_image(&name, line)?)); + } + lines.sort_by_key(|(line, _)| *line); + for (line, image) in lines { + if parsed + .stages + .iter() + .any(|stage| stage.name.as_deref() == Some(image.context_name.as_str())) + { + return Err(refusal( + Some(line), + "an image name equals a build stage name; rename the stage.", + )); + } + if !admitted + .iter() + .any(|known| known.reference == image.reference) + { + if admitted.len() == MAX_IMAGE_REFERENCES { + return Err(refusal(None, "uses more than 32 distinct images.")); + } + admitted.push(image); + } + } + Ok(DockerfileScan { + stages: parsed + .stages + .iter() + .zip(reachable) + .map(|(stage, reachable)| Stage { + name: stage.name.clone(), + reachable, + }) + .collect(), + target, + images: admitted, + }) +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs new file mode 100644 index 000000000..f6cabc792 --- /dev/null +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -0,0 +1,540 @@ +use super::*; + +fn scan_with( + dockerfile: &str, + target: Option<&str>, + args: &[(&str, &str)], +) -> Result { + let args: BTreeMap = args + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + scan(&ScanRequest { + dockerfile: dockerfile.as_bytes(), + target, + args: &args, + }) +} + +fn images(dockerfile: &str) -> Vec { + names(&scan_with(dockerfile, None, &[]).unwrap()) +} + +fn names(scan: &DockerfileScan) -> Vec { + scan.images.iter().map(|i| i.context_name.clone()).collect() +} + +fn code(dockerfile: &str) -> &'static str { + scan_with(dockerfile, None, &[]).unwrap_err().code +} + +#[test] +fn a_multi_stage_build_reports_base_images_and_not_stages() { + let dockerfile = "\ +ARG RUNTIME_VERSION=24 +FROM example/toolchain:1.2 AS build +WORKDIR /src +COPY . . +RUN make + +FROM node:${RUNTIME_VERSION}-slim AS runtime +COPY --from=build /src/out /app +COPY --from=busybox:1.37 /bin/busybox /bin/busybox +CMD [\"node\", \"/app/index.js\"] +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!( + names(&scan), + ["example/toolchain:1.2", "node:24-slim", "busybox:1.37"] + ); + assert_eq!(scan.target, 1); + assert!(scan.stages.iter().all(|stage| stage.reachable)); + assert_eq!(scan.images[1].repository(), "library/node"); + assert_eq!(scan.images[1].reference.tag.as_deref(), Some("24-slim")); +} + +#[test] +fn literal_build_arguments_override_global_defaults_and_chain() { + let dockerfile = "\ +ARG DISTRO=bookworm +ARG BASE=debian:${DISTRO} +FROM $BASE +"; + assert_eq!(images(dockerfile), ["debian:bookworm"]); + let scan = scan_with(dockerfile, None, &[("DISTRO", "trixie")]).unwrap(); + assert_eq!(names(&scan), ["debian:trixie"]); + // An argument overrides the whole default, verbatim. + let scan = scan_with(dockerfile, None, &[("BASE", "alpine:3.22")]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22"]); + // Quoted defaults lose their quotes as the frontend does. + assert_eq!( + images("ARG V=\"3.22\"\nARG R='alpine'\nFROM ${R}:$V\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn stage_args_do_not_reach_from_lines() { + // A stage ARG is not a global ARG, so the second FROM cannot resolve it. + let dockerfile = "FROM alpine:3.22 AS one\nARG TAG=1\nFROM busybox:$TAG\n"; + assert_eq!(code(dockerfile), "native_build_dockerfile"); + // An undeclared build argument does not become a global ARG. + assert!(scan_with("FROM busybox:$TAG\n", None, &[("TAG", "1")]).is_err()); +} + +#[test] +fn platform_arguments_known_for_arm64_resolve_and_uncertain_ones_refuse() { + assert_eq!( + images("FROM example/tool:${TARGETOS}-${TARGETARCH}\n"), + ["example/tool:linux-arm64"] + ); + assert_eq!( + code("FROM example/tool:${TARGETVARIANT}\n"), + "native_build_dockerfile" + ); + assert_eq!( + code("ARG SOURCE_DATE_EPOCH=1\nFROM example/tool:$SOURCE_DATE_EPOCH\n"), + "native_build_dockerfile" + ); +} + +#[test] +fn variable_modifiers_quotes_and_unknown_variables_refuse() { + for dockerfile in [ + "ARG V=1\nFROM alpine:${V:-3}\n", + "ARG V=1\nFROM alpine:${V:+3}\n", + "FROM alpine:$UNDECLARED\n", + "ARG V\nFROM alpine:$V\n", + "FROM \"alpine\"\n", + "ARG V=1\nFROM alpine:${V\n", + "ARG V=${OTHER:-x}\nFROM alpine:$V\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An unresolvable default is harmless while nothing uses it. + assert_eq!( + images("ARG V=${OTHER:-x}\nFROM alpine:3.22\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn copy_from_resolves_stage_names_indexes_and_images() { + let dockerfile = "\ +FROM alpine:3.22 AS Base +FROM busybox:1.37 +COPY --from=BASE /a /a +COPY --from=0 /b /b +COPY --from=scratch /c /c +COPY --from=example/assets:7 /d /d +COPY --link --chmod=0644 --from=example/assets:7 /e /e +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!( + names(&scan), + ["alpine:3.22", "busybox:1.37", "example/assets:7"] + ); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); + for invalid in ["COPY --from=9 /a /a", "COPY --from=-1 /a /a"] { + assert_eq!( + code(&format!("FROM alpine:3.22\n{invalid}\n")), + "native_build_dockerfile" + ); + } +} + +#[test] +fn from_values_are_literal_as_the_frontend_requires() { + for line in [ + "COPY --from=$IMAGE /a /a", + "COPY --from=${IMAGE} /a /a", + "COPY --from=\"$IMAGE\" /a /a", + "COPY --from=a\\$b /a /a", + "RUN --mount=type=bind,from=$IMAGE,target=/x true", + ] { + let dockerfile = format!("ARG IMAGE=alpine:3.22\nFROM busybox:1.37\n{line}\n"); + assert_eq!(code(&dockerfile), "native_build_dockerfile", "{line}"); + } +} + +#[test] +fn run_mounts_resolve_stage_names_and_images_but_not_indexes() { + let dockerfile = "\ +FROM golang:1.25 AS deps +FROM busybox:1.37 +RUN --mount=type=cache,target=/cache \\ + --mount=type=bind,from=deps,source=/go,target=/go \\ + --mount=type=bind,FROM=example/data:2,target=/data \\ + --mount=type=bind,from=0,target=/zero \\ + true +"; + assert_eq!( + images(dockerfile), + ["golang:1.25", "busybox:1.37", "example/data:2", "0"] + ); + // Quotes around the whole flag are removed before the mount options are read. + assert_eq!( + images("FROM busybox:1.37\nRUN --mount=\"type=bind,from=example/data:2,target=/d\" true\n"), + ["busybox:1.37", "example/data:2"] + ); +} + +#[test] +fn only_stages_reachable_from_the_target_contribute_images() { + let dockerfile = "\ +FROM example/lint:1 AS lint +FROM golang:1.25 AS build +COPY --from=example/proto:3 /p /p +FROM build AS test +RUN go test ./... +FROM gcr.io/distroless/static AS release +COPY --from=build /out /out +"; + let scan = scan_with(dockerfile, Some("TEST"), &[]).unwrap(); + assert_eq!(names(&scan), ["golang:1.25", "example/proto:3"]); + assert_eq!(scan.target, 2); + assert_eq!( + scan.stages.iter().map(|s| s.reachable).collect::>(), + [false, true, true, false] + ); + // The default target is the last stage, whose base is not Docker Hub. + assert_eq!( + scan_with(dockerfile, None, &[]).unwrap_err().code, + "native_build_base_image" + ); + assert_eq!( + scan_with(dockerfile, Some("missing"), &[]) + .unwrap_err() + .code, + "native_build_dockerfile" + ); +} + +#[test] +fn a_later_stage_name_used_by_an_earlier_from_is_an_image_and_refuses_as_ambiguous() { + // The frontend matches FROM only against earlier stages, so `FROM base` here is the + // image `library/base`; a pinned context named `base` would replace the stage. + let dockerfile = "FROM base AS first\nFROM alpine:3.22 AS base\nFROM first\n"; + assert_eq!(code(dockerfile), "native_build_dockerfile"); + assert_eq!(code("FROM node AS node\n"), "native_build_dockerfile"); +} + +#[test] +fn duplicate_or_invalid_stage_names_refuse() { + assert_eq!( + code("FROM alpine:3.22 AS a\nFROM busybox:1.37 AS A\n"), + "native_build_dockerfile" + ); + assert_eq!(code("FROM alpine:3.22 AS 1st\n"), "native_build_dockerfile"); + assert_eq!(code("FROM alpine:3.22 AS\n"), "native_build_dockerfile"); +} + +#[test] +fn onbuild_triggers_of_internal_base_stages_are_scanned() { + let dockerfile = "\ +FROM alpine:3.22 AS template +ONBUILD COPY --from=example/plugin:5 /p /p +ONBUILD RUN --mount=type=bind,from=example/tools:6,target=/t true +FROM template AS child +FROM child +"; + assert_eq!( + images(dockerfile), + ["alpine:3.22", "example/plugin:5", "example/tools:6"] + ); + // A trigger in the target stage itself only affects images built from it. + assert_eq!( + images("FROM alpine:3.22\nONBUILD COPY --from=example/plugin:5 /p /p\n"), + ["alpine:3.22"] + ); + for trigger in [ + "ONBUILD FROM alpine:3.22", + "ONBUILD ONBUILD RUN true", + "ONBUILD BOGUS x", + ] { + assert_eq!( + code(&format!("FROM alpine:3.22\n{trigger}\n")), + "native_build_dockerfile" + ); + } +} + +#[test] +fn platform_flags_must_name_arm64_or_the_build_platforms() { + for accepted in [ + "--platform=linux/arm64", + "--platform=linux/arm64/v8", + "--platform=$BUILDPLATFORM", + "--platform=${TARGETPLATFORM}", + ] { + assert_eq!( + images(&format!("FROM {accepted} alpine:3.22\n")), + ["alpine:3.22"], + "{accepted}" + ); + } + assert_eq!( + images("ARG ARCH=arm64\nFROM --platform=linux/${ARCH} alpine:3.22\n"), + ["alpine:3.22"] + ); + for refused in [ + "--platform=linux/amd64", + "--platform=${BUILDPLATFORM:-linux/amd64}", + "--platform=$UNSET", + "--platform", + ] { + assert_eq!( + code(&format!("FROM {refused} alpine:3.22\n")), + "native_build_dockerfile", + "{refused}" + ); + } + // An unreachable stage's platform does not matter. + let dockerfile = "FROM --platform=linux/amd64 alpine:3.22 AS other\nFROM busybox:1.37\n"; + assert_eq!(images(dockerfile), ["busybox:1.37"]); +} + +#[test] +fn frontend_selection_directives_refuse() { + for dockerfile in [ + "# syntax=docker/dockerfile:1\nFROM alpine:3.22\n", + "#SYNTAX = example/frontend\nFROM alpine:3.22\n", + "#!/usr/bin/env buildctl\n# syntax=example/frontend\nFROM alpine:3.22\n", + "// syntax=example/frontend\nFROM alpine:3.22\n", + "# check=error=true\n# syntax=example/frontend\nFROM alpine:3.22\n", + "{\"syntax\": \"example/frontend\"}", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // An ordinary comment that mentions syntax later in the file is fine. + assert_eq!( + images("FROM alpine:3.22\n# syntax=example/frontend\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn escape_directive_changes_continuations() { + let dockerfile = "# escape=`\nFROM alpine:3.22 `\n AS base\nRUN echo C:\\path\nFROM base\n"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22"]); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); + assert_eq!( + code("# escape=x\nFROM alpine:3.22\n"), + "native_build_dockerfile" + ); + assert_eq!( + code("# escape=`\n# escape=\\\nFROM alpine:3.22\n"), + "native_build_dockerfile" + ); + // A directive after an instruction or blank line is only a comment. + assert_eq!( + images("\n# escape=`\nFROM alpine:3.22 \\\n AS base\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn continuations_skip_comments_and_blank_lines() { + let dockerfile = "\ +FROM \\ +# a comment inside the instruction +\x20\x20\x20 + alpine:3.22 \\ + AS base\r +COPY \\ + --from=example/assets:7 \\ + /a /a +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + assert_eq!(names(&scan), ["alpine:3.22", "example/assets:7"]); + assert_eq!(scan.stages[0].name.as_deref(), Some("base")); +} + +#[test] +fn heredoc_bodies_are_not_instructions() { + let dockerfile = "\ +FROM alpine:3.22 +RUN < /x && <<\"MORE\" cat > /y +\tFROM example/still-body:1 +\tDONE +FROM example/also-body:2 +MORE +COPY <>() + .join("\n"); + assert_eq!(images(&within).len(), MAX_IMAGE_REFERENCES); + + let stages: String = (0..=MAX_STAGES).map(|_| "FROM alpine:3.22\n").collect(); + assert_eq!(code(&stages), "native_build_dockerfile"); +} + +#[test] +fn reserved_build_arguments_refuse_before_reading() { + for name in [ + "BUILDKIT_SYNTAX", + "BUILDKIT_INLINE_CACHE", + "TARGETARCH", + "BUILDPLATFORM", + "TARGETSTAGE", + ] { + let error = scan_with("FROM alpine:3.22\n", None, &[(name, "x")]).unwrap_err(); + assert_eq!(error.code, "native_build_argument", "{name}"); + } +} + +#[test] +fn messages_never_echo_dockerfile_text_or_argument_values() { + let secret = "do-not-echo-7f3a"; + for (dockerfile, args) in [ + (format!("FROM ghcr.io/{secret}/app:1\n"), vec![]), + (format!("ARG V={secret}\nFROM alpine:${{V:-x}}\n"), vec![]), + ( + "ARG V\nFROM ghcr.io/$V:1\n".to_string(), + vec![("V", secret)], + ), + (format!("FROM alpine:3.22\n{secret} instruction\n"), vec![]), + ( + format!("FROM alpine:3.22\nCOPY --from=${secret} / /\n"), + vec![], + ), + ] { + let error = scan_with(&dockerfile, None, &args).unwrap_err(); + assert!(!error.message.contains(secret), "{}", error.message); + assert!(!error.message.contains("ghcr"), "{}", error.message); + } +} diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index e9ff94e64..5a7337fee 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -3,6 +3,7 @@ pub mod admission; mod agent; mod artifact; mod bridge; +pub mod build_scan; mod config_audit; mod dependency_socket; pub use bridge::BridgeIntent; @@ -143,6 +144,8 @@ pub struct Status { pub lifecycle_qualified: bool, pub artifact_prepared: bool, pub engine_prepared: bool, + /// The optional pinned BuildKit client; verified whenever its record exists. + pub builder_prepared: bool, pub memory_admission: admission::Admission, pub runtime_effects: [&'static str; 0], } @@ -164,11 +167,20 @@ pub fn probe(candidate: &Candidate) -> Result { if engine_prepared { artifact::verify_engine(candidate)?; } + let builder_prepared = candidate + .state_root + .join("providers/buildx.json") + .try_exists() + .map_err(state::io)?; + if builder_prepared { + artifact::verify_builder(candidate)?; + } Ok(Status { checkpoint: "WU02-provider-preflight", lifecycle_qualified: false, artifact_prepared: prepared, engine_prepared, + builder_prepared, memory_admission: admission::probe(&candidate.checkout)?, runtime_effects: [], }) @@ -185,5 +197,13 @@ pub fn prepare_engine( artifact::prepare_engine(candidate, archive) } +/// Prepare the pinned host BuildKit client from its exact release asset. +pub fn prepare_builder( + candidate: &Candidate, + binary: &Path, +) -> Result { + artifact::prepare_builder(candidate, binary) +} + #[cfg(test)] mod graph_probe_test; From 0d95e98b41e276418fa1e224cce1264b78eb36e9 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:04:56 -0500 Subject: [PATCH 011/138] feat(native): apply project-wide host aliases with declarative host dependencies Native up and restart now declare the effective internal.extra_hosts and checkout-local extra-host aliases on every service, as the Compose override does, and require a reviewed host dependency binding for every profile-active (service, alias) pair. An alias without a binding, a non-host-gateway target, an inexact name or a conflicting service mapping refuses value-free instead of being silently ignored. Lifecycle hooks that change the aliases refuse after hook cleanup. Host dependency selections gain a declarative version 2 form: one entry per binding applies to every active service that declares its aliases (or to listed services) and expands to version 1 bindings before discovery, review and the existing limits. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 43 +++ src/backends/native-project-dependencies.ts | 136 +++++++- src/backends/native-project-internal-hosts.ts | 304 ++++++++++++++++++ .../native-project-restart-preflight.ts | 26 +- src/backends/native-project-start.ts | 25 ++ src/commands/project.ts | 39 +++ tests/native-project-dependencies.test.ts | 192 +++++++++++ tests/native-project-internal-hosts.test.ts | 265 +++++++++++++++ tests/native-project-start.test.ts | 175 ++++++++++ 9 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 src/backends/native-project-internal-hosts.ts create mode 100644 tests/native-project-internal-hosts.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..78cd59e36 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1307,6 +1307,49 @@ stopped run and its volumes remain available for a corrected retry. Selected services receive `init: true` when omitted because the guest relay launcher requires a reaping init process. An explicit `init: false` is refused, not replaced. +Project-wide host aliases from `internal.extra_hosts` in `hack.config.json`, plus +checkout-local `hack internal extra-hosts` entries, follow the Compose backend: +native `up` and `restart` declare each one on every service as a `host-gateway` +extra host. Native has no general host gateway, so every profile-active service +must then have a selected binding for each alias it declares. A missing binding +refuses with `E_NATIVE_PROJECT_UNSUPPORTED` ("Native host alias has no reviewed +host dependency binding; configuration was not dropped."), before any runtime +work; with no dependency selection at all it refuses before lifecycle hooks. +An entry whose target is not `host-gateway` (for example a fixed address), a name +that is not an exact lowercase DNS name, or a service that already maps the name +to a different target also refuses. Earlier native builds ignored these entries, +which let a container resolve the public name instead of the local listener. +Native reads these aliases before lifecycle hooks, because they are part of the +reviewed configuration; unlike the Compose backend, a hook that changes them makes +`up` refuse after hook cleanup. Projects without project-wide aliases are unchanged. +Unlike a Compose host gateway, which reaches every host port, a service reaches +each alias only on the one guest port its binding selects. + +To avoid repeating one listener for every service, a version 2 selection names +each binding once: + +```json +{ + "version": 2, + "dependencies": [{ + "binding": "search", + "aliases": ["search.example.com"], + "guest_port": 443, + "host_port": 8443, + "host_executable": "/absolute/path/to/the/listener-binary" + }] +} +``` + +Without `services`, the entry applies to every profile-active service that +declares one of its aliases, and each service receives only the aliases it +declares. `"services": ["web", "worker"]` limits it to those services, each of +which must declare at least one of the aliases. The CLI expands the entry into +the version 1 bindings shown above before discovery and review. The same listener +authority applies to every expanded binding, and the 128-binding and 32-transport +limits apply after expansion. A repeated binding name, an alias no selected service +declares, a per-entry `service` key or an unknown or inactive service refuses. + Fresh pools reserve the number of distinct dependency transports from that selection. Existing pool capacity cannot be silently widened. The selection is copied into the temporary exact-plan dependency request and removed when foreground startup exits. No durable listener diff --git a/src/backends/native-project-dependencies.ts b/src/backends/native-project-dependencies.ts index a0e05d5c6..638eb5481 100644 --- a/src/backends/native-project-dependencies.ts +++ b/src/backends/native-project-dependencies.ts @@ -127,6 +127,135 @@ function requireSameEndpointPolicy(opts: { opts.policies.set(opts.endpoint, policy); } +const DECLARATIVE_FIELDS = new Set([ + "binding", + "services", + "guest_port", + "aliases", + "host_pid", + "host_port", + "host_executable", + "host_supervisor_depth", +]); + +function declaredServices(opts: { + readonly entry: Record; + readonly hostAliases: Readonly>; +}): readonly string[] { + const selected = opts.entry.services; + if (selected === undefined) { + return Object.keys(opts.hostAliases); + } + if ( + !( + Array.isArray(selected) && + selected.length > 0 && + selected.length <= 128 && + new Set(selected).size === selected.length && + selected.every( + (service) => + typeof service === "string" && + Object.hasOwn(opts.hostAliases, service) + ) + ) + ) { + throw refused(); + } + return selected as string[]; +} + +type DeclarativeEntry = Record & { + readonly binding: string; + readonly aliases: string[]; +}; +function declarativeEntry(entry: unknown): entry is DeclarativeEntry { + return ( + isRecord(entry) && + Object.keys(entry).every((key) => DECLARATIVE_FIELDS.has(key)) && + typeof entry.binding === "string" && + Array.isArray(entry.aliases) && + entry.aliases.length > 0 && + entry.aliases.length <= 8 && + entry.aliases.every((alias) => typeof alias === "string") && + new Set(entry.aliases).size === entry.aliases.length + ); +} +function expandDeclarativeEntry(opts: { + readonly entry: DeclarativeEntry; + readonly hostAliases: Readonly>; +}): Record[] { + const { services: _services, ...authority } = opts.entry; + const used = new Set(); + const result: Record[] = []; + for (const service of declaredServices(opts)) { + const declared = new Set(opts.hostAliases[service]); + const aliases = opts.entry.aliases.filter((alias) => declared.has(alias)); + if (aliases.length === 0) { + if (opts.entry.services !== undefined) { + throw refused(); + } + continue; + } + for (const alias of aliases) { + used.add(alias); + } + result.push({ ...authority, service, aliases }); + } + if (used.size !== opts.entry.aliases.length) { + throw refused(); + } + return result; +} + +/** + * Expand a declarative (version 2) selection into version 1 bindings. + * + * A version 2 entry names one binding once, without a `service`. It applies to + * the listed `services`, or, when `services` is omitted, to every + * profile-active service that declares one of its aliases as a host-gateway + * extra host (including project-wide internal aliases). Each expanded binding + * carries only the aliases that service declares. A listed service that + * declares none of the aliases, an alias no selected service declares, or a + * repeated binding name refuses. The same listener authority (PID or + * executable and depth) applies to every expanded binding, so endpoint + * sharing and the 128-binding and 32-transport limits are enforced after + * expansion by {@link parseNativeHostDependencies}. Version 1 values pass + * through unchanged. + */ +export function compileNativeHostDependencySelection(opts: { + readonly value: unknown; + readonly hostAliases?: Readonly>; +}): unknown { + const value = opts.value; + if (!(isRecord(value) && value.version === 2)) { + return value; + } + const hostAliases = opts.hostAliases; + if ( + !( + hostAliases && + Object.keys(value).every((key) => + ["version", "dependencies"].includes(key) + ) && + Array.isArray(value.dependencies) && + value.dependencies.length > 0 && + value.dependencies.length <= 128 + ) + ) { + throw refused(); + } + const bindings = new Set(); + const dependencies: Record[] = []; + for (const entry of value.dependencies) { + if (!declarativeEntry(entry) || bindings.has(entry.binding)) { + throw refused(); + } + bindings.add(entry.binding); + dependencies.push(...expandDeclarativeEntry({ entry, hostAliases })); + } + return { version: 1, dependencies }; +} + /** Native dependency-plan pins the live process/listener identity after this structural check. */ export function parseNativeHostDependencies(opts: { readonly value: unknown; @@ -237,6 +366,8 @@ export function parseNativeHostDependencies(opts: { export async function readNativeHostDependencies(opts: { readonly path?: string; readonly services: readonly string[]; + /** Host-gateway aliases per profile-active service, for version 2 selections. */ + readonly hostAliases?: Readonly>; readonly discover?: (selection: { readonly hostPort: number; readonly executable: string; @@ -249,7 +380,10 @@ export async function readNativeHostDependencies(opts: { return []; } try { - const value = await readNativeSelection(opts.path); + const value = compileNativeHostDependencySelection({ + value: await readNativeSelection(opts.path), + hostAliases: opts.hostAliases, + }); if (!(isRecord(value) && Array.isArray(value.dependencies))) { throw refused(); } diff --git a/src/backends/native-project-internal-hosts.ts b/src/backends/native-project-internal-hosts.ts new file mode 100644 index 000000000..a267653d6 --- /dev/null +++ b/src/backends/native-project-internal-hosts.ts @@ -0,0 +1,304 @@ +import { HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import type { NativeHostDependency } from "./native-project-dependencies.ts"; +import type { NativeProjectInput } from "./native-project-input.ts"; + +/** + * Project-wide host aliases (`internal.extra_hosts` plus checkout-local + * `hack internal extra-hosts` entries) on the native backend. + * + * The Compose backend renders every entry into every service. Native has no + * general host gateway: a `host-gateway` alias only resolves through a pinned + * host dependency binding. Native therefore declares every entry on every + * service, exactly as Compose does, and then requires a reviewed binding for + * each active (service, alias) pair. An entry that native cannot honor is + * refused instead of being dropped, because a dropped alias would let a + * container resolve the public name and reach a different host. + */ + +const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const NUMERIC_HOST = /^[0-9.]+$/; +const SEPARATOR = /[:=]/; +const MAX_ALIASES = 32; + +/** Fixed text when a declared host alias has no reviewed dependency binding. */ +export const NATIVE_UNBOUND_HOST_ALIAS_REFUSAL = + "Native host alias has no reviewed host dependency binding; configuration was not dropped. Select a host dependency binding for every service that declares the alias."; + +/** Fixed text when a project-wide host alias cannot be expressed natively. */ +export const NATIVE_INTERNAL_HOST_ALIAS_REFUSAL = + "Native up supports internal extra_hosts only as exact host-gateway aliases with reviewed host dependency bindings; configuration was not dropped."; + +function unbound(): Error { + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: NATIVE_UNBOUND_HOST_ALIAS_REFUSAL, + }); +} +function unsupported(): Error { + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: NATIVE_INTERNAL_HOST_ALIAS_REFUSAL, + }); +} +function conflict(): Error { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native host alias selection conflicts with declared configuration; values omitted.", + }); +} + +/** Same exact-name rule native dependency selections apply to aliases. */ +function exactAlias(value: string): boolean { + return ( + value.length <= 253 && + value !== "localhost" && + !value.endsWith(".localhost") && + value !== "localhost.localdomain" && + !NUMERIC_HOST.test(value) && + value.split(".").every((label) => LABEL.test(label)) + ); +} + +/** + * Split one Compose `extra_hosts` value into (name, target) pairs, using the + * first `:` or `=` like the native review does. Returns undefined for a shape + * native review would refuse; callers leave that refusal to review. + */ +function extraHostEntries( + value: unknown +): (readonly [string, string])[] | undefined { + if (value === undefined) { + return []; + } + if (isRecord(value)) { + const entries: (readonly [string, string])[] = []; + for (const [name, target] of Object.entries(value)) { + if (typeof target !== "string") { + return undefined; + } + entries.push([name, target]); + } + return entries; + } + if (!Array.isArray(value)) { + return undefined; + } + const entries: (readonly [string, string])[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + return undefined; + } + const match = SEPARATOR.exec(entry); + if (!match) { + return undefined; + } + entries.push([entry.slice(0, match.index), entry.slice(match.index + 1)]); + } + return entries; +} + +/** Validate project-wide aliases before any service is changed. */ +function internalAliasNames(hosts: Readonly>): string[] { + const names = Object.keys(hosts); + if (names.length > MAX_ALIASES) { + throw unsupported(); + } + for (const name of names) { + if (hosts[name] !== "host-gateway" || !exactAlias(name)) { + throw unsupported(); + } + } + return names.sort(); +} + +function declareOnService( + service: Record, + aliases: readonly string[] +): void { + const entries = extraHostEntries(service.extra_hosts); + if (!entries) { + throw conflict(); + } + const declared = new Map(); + for (const [name, target] of entries) { + declared.set(name, target); + } + const added: string[] = []; + for (const alias of aliases) { + const target = declared.get(alias); + if (target === undefined) { + added.push(alias); + } else if (target !== "host-gateway") { + throw conflict(); + } + } + if (added.length === 0) { + return; + } + if (entries.length + added.length > MAX_ALIASES) { + throw conflict(); + } + if (isRecord(service.extra_hosts)) { + const map: Record = { ...service.extra_hosts }; + for (const alias of added) { + Object.defineProperty(map, alias, { + value: "host-gateway", + enumerable: true, + writable: true, + configurable: true, + }); + } + service.extra_hosts = map; + return; + } + service.extra_hosts = [ + ...((service.extra_hosts as unknown[] | undefined) ?? []), + ...added.map((alias) => `${alias}:host-gateway`), + ]; +} + +/** + * Declare the effective project-wide aliases on every service of the public + * normalized Compose, as the Compose backend's internal override does. + * Declarations grant nothing: each active pair still needs a pinned binding, + * which {@link requireNativeHostAliasBindings} checks after lifecycle hooks. + * With no dependency selection at all, refuse here, before hooks or runtime + * work. An empty map leaves the input byte-identical. + */ +export function applyNativeInternalHostAliases(opts: { + readonly input: NativeProjectInput; + readonly hosts?: Readonly>; + readonly hasDependencySelection: boolean; +}): NativeProjectInput { + if (opts.hosts === undefined || Object.keys(opts.hosts).length === 0) { + return opts.input; + } + const aliases = internalAliasNames(opts.hosts); + if (!opts.hasDependencySelection) { + throw unbound(); + } + const compose: unknown = JSON.parse(opts.input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw conflict(); + } + for (const service of Object.values(compose.services)) { + if (!isRecord(service)) { + throw conflict(); + } + declareOnService(service, aliases); + } + return { ...opts.input, normalizedComposeJson: JSON.stringify(compose) }; +} + +function activeService( + spec: Readonly>, + profiles: readonly string[] +): boolean { + const declared = spec.profiles; + if (declared === undefined) { + return true; + } + return ( + Array.isArray(declared) && + (declared.length === 0 || + declared.some( + (profile) => typeof profile === "string" && profiles.includes(profile) + )) + ); +} + +/** + * The exact `host-gateway` aliases each profile-active service declares, + * keyed by service. Inactive services are never scheduled and receive no + * bindings, so they need none. Malformed declarations are left to review. + */ +export function nativeServiceHostAliases(opts: { + readonly services: Readonly< + Record>> + >; + readonly profiles: readonly string[]; +}): Record { + const result: Record = {}; + for (const [name, spec] of Object.entries(opts.services)) { + if (!activeService(spec, opts.profiles)) { + continue; + } + const aliases = (extraHostEntries(spec.extra_hosts) ?? []) + .filter(([, target]) => target === "host-gateway") + .map(([alias]) => alias); + Object.defineProperty(result, name, { + value: aliases, + enumerable: true, + writable: true, + configurable: true, + }); + } + return result; +} + +/** + * Fail closed when an active service declares a host-gateway alias that no + * selected binding of that service covers. Native admission later enforces + * exact equality as well; this refuses earlier, before runtime effects, with + * a value-free line. + */ +export function requireNativeHostAliasBindings(opts: { + readonly dependencies: readonly NativeHostDependency[]; + readonly hostAliases: Readonly>; +}): void { + const bound = new Set( + opts.dependencies.flatMap((dependency) => + dependency.aliases.map((alias) => `${dependency.service}\0${alias}`) + ) + ); + for (const [service, aliases] of Object.entries(opts.hostAliases)) { + for (const alias of aliases) { + if (!bound.has(`${service}\0${alias}`)) { + throw unbound(); + } + } + } +} + +/** Fixed text when lifecycle hooks changed project-wide aliases after review input. */ +export const NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL = + "Native up cannot apply internal extra_hosts changed by lifecycle hooks; nothing was started. Run the command again."; + +function sameHosts( + left: Readonly>, + right: Readonly> +): boolean { + const keys = Object.keys(left); + return ( + keys.length === Object.keys(right).length && + keys.every((key) => Object.hasOwn(right, key) && right[key] === left[key]) + ); +} + +/** + * The Compose backend re-reads project-wide aliases after `up.before` hooks. + * Native declares them before review, so a hook that changes them refuses + * (after its cleanup) instead of starting with stale or dropped aliases. + */ +export async function requireNativeInternalExtraHostsUnchanged(opts: { + readonly reviewed: Readonly>; + readonly current: () => Promise>>; + readonly cleanup: () => Promise; +}): Promise { + let current: Readonly> | undefined; + try { + current = await opts.current(); + } catch { + current = undefined; + } + if (current !== undefined && sameHosts(opts.reviewed, current)) { + return; + } + await opts.cleanup(); + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL, + }); +} diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..e90682e44 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -7,6 +7,7 @@ import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; import { + compileNativeHostDependencySelection, discoverNativeHostDependency, type NativeHostDependency, parseNativeHostDependencies, @@ -15,6 +16,11 @@ import { } from "./native-project-dependencies.ts"; import { prepareNativeProjectInput } from "./native-project-input.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; +import { + applyNativeInternalHostAliases, + nativeServiceHostAliases, + requireNativeHostAliasBindings, +} from "./native-project-internal-hosts.ts"; import { validateNativeAllowedHosts } from "./native-project-network.ts"; import { nativeReviewNeedsCompatibility, @@ -143,12 +149,16 @@ async function verifyNativeRestartListeners(opts: { async function readNativeRestartDependencyIntent(opts: { readonly path?: string; readonly services: readonly string[]; + readonly hostAliases: Readonly>; }): Promise { if (opts.path === undefined) { return []; } try { - const value = await readNativeSelection(opts.path); + const value = compileNativeHostDependencySelection({ + value: await readNativeSelection(opts.path), + hostAliases: opts.hostAliases, + }); if (!(isRecord(value) && Array.isArray(value.dependencies))) { throw new ClosedCliError("Invalid dependency intent"); } @@ -322,6 +332,8 @@ export async function preflightNativeRestart(opts: { readonly recoverStopped?: boolean; readonly adaptationFile?: string; readonly dependencyFile?: string; + /** Same project-wide host aliases the replacement startup declares. */ + readonly internalExtraHosts?: Readonly>; readonly allowedHosts?: readonly string[]; /** Same public metadata selection the replacement startup will review. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; @@ -365,6 +377,11 @@ export async function preflightNativeRestart(opts: { input, path: opts.adaptationFile, }); + input = applyNativeInternalHostAliases({ + input, + hosts: opts.internalExtraHosts, + hasDependencySelection: opts.dependencyFile !== undefined, + }); const reviewedBranch = await prepareNativeReviewBranch({ runtime: opts.runtime, scope: opts.scope, @@ -394,14 +411,20 @@ export async function preflightNativeRestart(opts: { input, opts.dependencyFile !== undefined ); + const hostAliases = nativeServiceHostAliases({ + services: specs, + profiles: selected.profiles, + }); const dependencies = stoppedRetry ? await readNativeRestartDependencyIntent({ path: opts.dependencyFile, services: Object.keys(specs), + hostAliases, }) : await deps.dependencies({ path: opts.dependencyFile, services: Object.keys(specs), + hostAliases, discover: (selection) => discoverNativeHostDependency({ runtime: opts.runtime, @@ -411,6 +434,7 @@ export async function preflightNativeRestart(opts: { invoke: deps.invoke, }), }); + requireNativeHostAliasBindings({ dependencies, hostAliases }); prepareNativeDependencyServices({ dependencies, services: specs }); const activeImages = stoppedRetry ? undefined diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..5c9898108 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -41,6 +41,11 @@ import { prepareNativeProjectInput, } from "./native-project-input.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; +import { + applyNativeInternalHostAliases, + nativeServiceHostAliases, + requireNativeHostAliasBindings, +} from "./native-project-internal-hosts.ts"; import { NativePartialShutdownError, recoverNativeInterruptedStartupCleanup, @@ -901,6 +906,12 @@ export async function startNativeProject(opts: { readonly onReady?: () => Promise; readonly dependencyFile?: string; readonly adaptationFile?: string; + /** + * Effective project-wide host aliases (`internal.extra_hosts` and + * checkout-local entries). Declared on every service like the Compose + * override; each active pair then requires a reviewed dependency binding. + */ + readonly internalExtraHosts?: Readonly>; readonly allowedHosts?: readonly string[]; readonly preparedBase?: NativePreparedBase; readonly https?: NativeHttpsSelection; @@ -1000,6 +1011,11 @@ export async function startNativeProject(opts: { input, path: opts.adaptationFile, }); + input = applyNativeInternalHostAliases({ + input, + hosts: opts.internalExtraHosts, + hasDependencySelection: opts.dependencyFile !== undefined, + }); input = ( await prepareNativeReviewBranch({ runtime: opts.runtime, @@ -1069,9 +1085,14 @@ export async function startNativeProject(opts: { ); } const dependencyDiscoveryDeadline = performance.now() + 60_000; + const hostAliases = nativeServiceHostAliases({ + services: specs, + profiles, + }); const hostDependencies = await readNativeHostDependencies({ path: opts.dependencyFile, services: Object.keys(specs), + hostAliases, discover: (selection) => discoverNativeHostDependency({ runtime: opts.runtime, @@ -1086,6 +1107,10 @@ export async function startNativeProject(opts: { }), }); requireActiveStartup(controller.signal); + requireNativeHostAliasBindings({ + dependencies: hostDependencies, + hostAliases, + }); prepareNativeDependencyServices({ dependencies: hostDependencies, services: specs, diff --git a/src/commands/project.ts b/src/commands/project.ts index ff71f6a36..4c9486274 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -46,6 +46,7 @@ import { nativeDownEnvironment, nativeProjectDown, } from "../backends/native-project-down.ts"; +import { requireNativeInternalExtraHostsUnchanged } from "../backends/native-project-internal-hosts.ts"; import { adoptNativeLifecycleCleanup } from "../backends/native-project-lifecycle.ts"; import { parseNativeAllowedHosts } from "../backends/native-project-network.ts"; import { @@ -2916,6 +2917,31 @@ async function resolveInternalComposeOverride(opts: { }); } +/** + * The same project-wide aliases the Compose override renders (static + * `internal.extra_hosts` plus checkout-local entries), for native start. An + * unreadable local map refuses with a closed line instead of being skipped. + */ +async function resolveNativeInternalExtraHosts(opts: { + readonly project: Awaited>; + readonly cfg: Awaited>; +}): Promise> { + try { + return ( + await resolveInternalExtraHosts({ + ...opts.project, + staticHosts: resolveInternalSettings(opts.cfg).extraHosts ?? undefined, + }) + ).hosts; + } catch { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native up requires a readable internal extra_hosts selection; values omitted.", + }); + } +} + function shouldBuildInternalOverride(opts: { readonly internal: ReturnType; readonly managedExtraHosts: Record; @@ -5756,6 +5782,10 @@ async function handleNativeUp({ }; const envFingerprints = createNativeEnvFingerprintSession(envFingerprintScope); + const internalExtraHosts = await resolveNativeInternalExtraHosts({ + project, + cfg, + }); const startup: Parameters[0] = { runtime: native, scope: { @@ -5770,6 +5800,7 @@ async function handleNativeUp({ sharedSource: process.env.HACK_NATIVE_SHARED_SOURCE === "1", dependencyFile: process.env.HACK_NATIVE_DEPENDENCIES, adaptationFile: process.env.HACK_NATIVE_ADAPTATION, + internalExtraHosts, allowedHosts: parseNativeAllowedHosts(process.env.HACK_NATIVE_ALLOW_HOSTS), preparedBase: parseNativePreparedBase(process.env), https: parseNativeHttpsSelection(process.env), @@ -5807,6 +5838,13 @@ async function handleNativeUp({ message: "Native up lifecycle preparation failed; values omitted.", }); } + // Native declares these before review; a hook cannot change them now. + await requireNativeInternalExtraHostsUnchanged({ + reviewed: internalExtraHosts, + current: async () => + await resolveNativeInternalExtraHosts({ project, cfg }), + cleanup, + }); // The native backend launches services after this returns: refuse when a // concurrent down already released this instance's shared helpers. await assertNativeSharedHelpersCurrent({ lifecycle, cleanup }); @@ -5948,6 +5986,7 @@ async function restartNativeImageProject({ composeFile: startup.composeFile, adaptationFile: startup.adaptationFile, dependencyFile: startup.dependencyFile, + internalExtraHosts: startup.internalExtraHosts, allowedHosts: startup.allowedHosts, runtimeMetadata: startup.runtimeMetadata, httpsPort: startup.https?.httpsPort ?? null, diff --git a/tests/native-project-dependencies.test.ts b/tests/native-project-dependencies.test.ts index 3dc259894..08f70f6eb 100644 --- a/tests/native-project-dependencies.test.ts +++ b/tests/native-project-dependencies.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + compileNativeHostDependencySelection, discoverNativeHostDependency, parseNativeHostDependencies, prepareNativeDependencyServices, @@ -484,3 +485,194 @@ test("logical grant bounds and endpoint identity remain independent of transport ]).map((entry) => entry.slot) ).toEqual([0, 1]); }); + +const declaredAliases = { + web: ["api.example.test", "auth.example.test"], + worker: ["api.example.test"], + db: [], +}; +function declarative(entry: Record = {}) { + return { + binding: "api", + guest_port: 443, + host_port: 8443, + host_executable: "/usr/local/bin/synthetic-tunnel", + host_supervisor_depth: 2, + aliases: ["api.example.test", "auth.example.test"], + ...entry, + }; +} +function compile(dependencies: unknown[], hostAliases = declaredAliases) { + return compileNativeHostDependencySelection({ + value: { version: 2, dependencies }, + hostAliases, + }); +} + +test("a declarative binding expands to every service that declares its aliases", () => { + const authority = { + binding: "api", + guest_port: 443, + host_port: 8443, + host_executable: "/usr/local/bin/synthetic-tunnel", + host_supervisor_depth: 2, + }; + expect(compile([declarative()])).toEqual({ + version: 1, + dependencies: [ + { + ...authority, + service: "web", + aliases: ["api.example.test", "auth.example.test"], + }, + { ...authority, service: "worker", aliases: ["api.example.test"] }, + ], + }); + expect( + compile([ + declarative({ services: ["worker"], aliases: ["api.example.test"] }), + ]) + ).toEqual({ + version: 1, + dependencies: [ + { ...authority, service: "worker", aliases: ["api.example.test"] }, + ], + }); + const v1 = { version: 1, dependencies: [binding()] }; + expect( + compileNativeHostDependencySelection({ + value: v1, + hostAliases: declaredAliases, + }) + ).toBe(v1); +}); + +test("declarative bindings refuse unused, ambiguous or unscoped authority before discovery", () => { + for (const value of [ + { version: 2, dependencies: [] }, + { version: 2, dependencies: [declarative()], extra: true }, + { version: 2 }, + { + version: 2, + dependencies: Array.from({ length: 129 }, (_, i) => + declarative({ binding: `b${i}` }) + ), + }, + ]) { + expectCodedThrow( + () => + compileNativeHostDependencySelection({ + value, + hostAliases: declaredAliases, + }), + "E_CONFIG_INVALID", + "values omitted" + ); + } + for (const dependencies of [ + // The same binding twice. + [declarative(), declarative()], + // A listed service declares none of the aliases. + [declarative({ services: ["db"] })], + // An alias no selected service declares. + [declarative({ aliases: ["api.example.test", "other.example.test"] })], + [declarative({ services: ["worker"] })], + // Unknown, inactive, repeated or empty service scopes. + [declarative({ services: ["missing"] })], + [declarative({ services: ["web", "web"] })], + [declarative({ services: [] })], + [declarative({ services: "web" })], + // A per-service field or unknown key. + [declarative({ service: "web" })], + [declarative({ value: "synthetic-do-not-print" })], + [declarative({ aliases: [] })], + [declarative({ aliases: ["api.example.test", "api.example.test"] })], + ]) { + const error = expectCodedThrow( + () => compile(dependencies), + "E_CONFIG_INVALID", + "values omitted" + ); + expect(error.message).not.toContain("synthetic"); + } + expectCodedThrow( + () => + compileNativeHostDependencySelection({ + value: { version: 2, dependencies: [declarative()] }, + }), + "E_CONFIG_INVALID", + "values omitted" + ); +}); + +test("limits apply after declarative expansion and one discovery serves every expanded binding", async () => { + const root = await mkdtemp(join(tmpdir(), "native-dependencies-v2-")); + roots.push(root); + const path = join(root, "selection.json"); + const many = Object.fromEntries( + Array.from({ length: 129 }, (_, i) => [`s${i}`, ["api.example.test"]]) + ); + await writeFile( + path, + JSON.stringify({ + version: 2, + dependencies: [declarative({ aliases: ["api.example.test"] })], + }) + ); + await expectCodedRefusal( + readNativeHostDependencies({ + path, + services: Object.keys(many), + hostAliases: many, + discover: async () => ({ + host_pid: 456, + endpoint_fingerprint: "e".repeat(64), + }), + }), + "E_CONFIG_INVALID", + "values omitted" + ); + const discovered: unknown[] = []; + await writeFile( + path, + JSON.stringify({ version: 2, dependencies: [declarative()] }) + ); + const selected = await readNativeHostDependencies({ + path, + services: ["web", "worker", "db"], + hostAliases: declaredAliases, + discover: async (selection) => { + discovered.push(selection); + return { host_pid: 456, endpoint_fingerprint: "e".repeat(64) }; + }, + }); + expect(discovered).toEqual([ + { executable: "/usr/local/bin/synthetic-tunnel", hostPort: 8443 }, + ]); + expect( + selected.map(({ service, slot, host_pid, aliases }) => ({ + service, + slot, + host_pid, + aliases, + })) + ).toEqual([ + { + service: "web", + slot: 0, + host_pid: 456, + aliases: ["api.example.test", "auth.example.test"], + }, + { + service: "worker", + slot: 0, + host_pid: 456, + aliases: ["api.example.test"], + }, + ]); + await expectCodedRefusal( + readNativeHostDependencies({ path, services: ["web", "worker", "db"] }), + "E_CONFIG_INVALID", + "values omitted" + ); +}); diff --git a/tests/native-project-internal-hosts.test.ts b/tests/native-project-internal-hosts.test.ts new file mode 100644 index 000000000..27386a9ef --- /dev/null +++ b/tests/native-project-internal-hosts.test.ts @@ -0,0 +1,265 @@ +import { expect, test } from "bun:test"; +import type { NativeHostDependency } from "../src/backends/native-project-dependencies.ts"; +import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import { + applyNativeInternalHostAliases, + NATIVE_INTERNAL_HOST_ALIAS_REFUSAL, + NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL, + NATIVE_UNBOUND_HOST_ALIAS_REFUSAL, + nativeServiceHostAliases, + requireNativeHostAliasBindings, + requireNativeInternalExtraHostsUnchanged, +} from "../src/backends/native-project-internal-hosts.ts"; +import { + expectCodedRefusal, + expectCodedThrow, +} from "./helpers/closed-refusal.ts"; + +function input(services: Record): NativeProjectInput { + return { + originalSha256: "d".repeat(64), + normalizedComposeJson: JSON.stringify({ services }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + environmentFiles: [], + effectiveEnvName: null, + serviceNames: Object.keys(services), + }; +} +function services(result: NativeProjectInput): Record { + return JSON.parse(result.normalizedComposeJson).services; +} +function binding(service: string, aliases: string[]): NativeHostDependency { + return { + service, + binding: "api", + slot: 0, + guest_port: 443, + aliases, + host_pid: 123, + host_port: 8443, + }; +} + +test("no project-wide aliases leaves the normalized input byte-identical", () => { + const original = input({ web: { image: "nginx" } }); + for (const hosts of [undefined, {}]) { + expect( + applyNativeInternalHostAliases({ + input: original, + hosts, + hasDependencySelection: false, + }) + ).toBe(original); + } +}); + +test("project-wide aliases are declared on every service like the Compose override", () => { + const result = applyNativeInternalHostAliases({ + input: input({ + web: { image: "nginx" }, + worker: { + image: "nginx", + extra_hosts: ["own.example.test:host-gateway"], + }, + map: { + image: "nginx", + extra_hosts: { "api.example.test": "host-gateway" }, + }, + later: { image: "nginx", profiles: ["later"] }, + }), + hosts: { + "api.example.test": "host-gateway", + "auth.example.test": "host-gateway", + }, + hasDependencySelection: true, + }); + expect(services(result)).toEqual({ + web: { + image: "nginx", + extra_hosts: [ + "api.example.test:host-gateway", + "auth.example.test:host-gateway", + ], + }, + worker: { + image: "nginx", + extra_hosts: [ + "own.example.test:host-gateway", + "api.example.test:host-gateway", + "auth.example.test:host-gateway", + ], + }, + map: { + image: "nginx", + extra_hosts: { + "api.example.test": "host-gateway", + "auth.example.test": "host-gateway", + }, + }, + later: { + image: "nginx", + profiles: ["later"], + extra_hosts: [ + "api.example.test:host-gateway", + "auth.example.test:host-gateway", + ], + }, + }); +}); + +test("project-wide aliases without any dependency selection refuse instead of being ignored", () => { + expectCodedThrow( + () => + applyNativeInternalHostAliases({ + input: input({ web: { image: "nginx" } }), + hosts: { "api.example.test": "host-gateway" }, + hasDependencySelection: false, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_UNBOUND_HOST_ALIAS_REFUSAL + ); +}); + +test("project-wide aliases native cannot honor refuse value-free", () => { + for (const hosts of [ + { "api.example.test": "10.0.0.5" }, + { "api.example.test": "Host-Gateway" }, + { "API.example.test": "host-gateway" }, + { "*.example.test": "host-gateway" }, + { localhost: "host-gateway" }, + { "app.localhost": "host-gateway" }, + { "127.0.0.1": "host-gateway" }, + Object.fromEntries( + Array.from({ length: 33 }, (_, i) => [ + `h${i}.example.test`, + "host-gateway", + ]) + ), + ]) { + const error = expectCodedThrow( + () => + applyNativeInternalHostAliases({ + input: input({ web: { image: "nginx" } }), + hosts, + hasDependencySelection: true, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_INTERNAL_HOST_ALIAS_REFUSAL + ); + expect(error.message).not.toContain("example"); + expect(error.message).not.toContain("10.0.0.5"); + } +}); + +test("a service that maps the alias to another target conflicts", () => { + for (const extra_hosts of [ + ["api.example.test:10.0.0.5"], + ["api.example.test=10.0.0.5"], + { "api.example.test": "10.0.0.5" }, + "api.example.test:host-gateway", + Array.from({ length: 32 }, (_, i) => `h${i}.example.test:host-gateway`), + ]) { + expectCodedThrow( + () => + applyNativeInternalHostAliases({ + input: input({ web: { image: "nginx", extra_hosts } }), + hosts: { "api.example.test": "host-gateway" }, + hasDependencySelection: true, + }), + "E_CONFIG_INVALID", + "Native host alias selection conflicts with declared configuration; values omitted." + ); + } +}); + +test("host aliases are collected only for profile-active services", () => { + expect( + nativeServiceHostAliases({ + services: { + web: { extra_hosts: ["api.example.test:host-gateway"] }, + eq: { extra_hosts: ["eq.example.test=host-gateway"] }, + map: { + extra_hosts: { + "map.example.test": "host-gateway", + "fixed.example.test": "10.0.0.5", + }, + }, + plain: {}, + later: { + profiles: ["later"], + extra_hosts: ["api.example.test:host-gateway"], + }, + selected: { + profiles: ["debug"], + extra_hosts: ["api.example.test:host-gateway"], + }, + }, + profiles: ["debug"], + }) + ).toEqual({ + web: ["api.example.test"], + eq: ["eq.example.test"], + map: ["map.example.test"], + plain: [], + selected: ["api.example.test"], + }); +}); + +test("every active declared alias needs a binding of that same service", () => { + const hostAliases = { + web: ["api.example.test", "auth.example.test"], + worker: ["api.example.test"], + plain: [], + }; + requireNativeHostAliasBindings({ + dependencies: [ + binding("web", ["api.example.test", "auth.example.test"]), + binding("worker", ["api.example.test"]), + ], + hostAliases, + }); + for (const dependencies of [ + [], + [binding("web", ["api.example.test", "auth.example.test"])], + [ + binding("web", ["api.example.test"]), + binding("worker", ["api.example.test", "auth.example.test"]), + ], + ]) { + expectCodedThrow( + () => requireNativeHostAliasBindings({ dependencies, hostAliases }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_UNBOUND_HOST_ALIAS_REFUSAL + ); + } +}); + +test("aliases changed by lifecycle hooks refuse after hook cleanup", async () => { + const reviewed = { "api.example.test": "host-gateway" }; + let cleaned = 0; + const cleanup = async () => { + cleaned++; + }; + await requireNativeInternalExtraHostsUnchanged({ + reviewed, + current: async () => ({ "api.example.test": "host-gateway" }), + cleanup, + }); + expect(cleaned).toBe(0); + for (const current of [ + async () => ({}), + async () => ({ "api.example.test": "10.0.0.5" }), + async () => ({ ...reviewed, "auth.example.test": "host-gateway" }), + async () => { + throw new Error("synthetic unreadable map"); + }, + ]) { + await expectCodedRefusal( + requireNativeInternalExtraHostsUnchanged({ reviewed, current, cleanup }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL + ); + } + expect(cleaned).toBe(4); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..07b49770b 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -3349,3 +3349,178 @@ test("restart preflight refuses runtime host metadata that differs from reviewed "Native runtime metadata" ); }); + +async function internalHostsFixture() { + const { opts, events } = await fixture(false); + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + const compose = JSON.parse(input.normalizedComposeJson); + compose.services.worker = { ...compose.services.web }; + compose.services.later = { ...compose.services.web, profiles: ["later"] }; + return { ...input, normalizedComposeJson: JSON.stringify(compose) }; + }; + const path = join(opts.scope.projectRoot, "host-selection.json"); + const internalExtraHosts = { "api.example.test": "host-gateway" }; + return { opts, events, path, internalExtraHosts }; +} + +test("project-wide host aliases are declared like Compose and bound by one declarative selection", async () => { + const { opts, path, internalExtraHosts } = await internalHostsFixture(); + await writeFile( + path, + JSON.stringify({ + version: 2, + dependencies: [ + { + binding: "api", + aliases: ["api.example.test"], + guest_port: 443, + host_port: 8443, + host_pid: 123, + }, + ], + }) + ); + const reviewed: NativeProjectInput[] = []; + const review = opts.dependencies.review!; + opts.dependencies.review = async (request) => { + reviewed.push(request.input); + return await review(request); + }; + const plans: unknown[] = []; + const invoke = opts.dependencies.invoke!; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "dependency-plan") { + plans.push( + JSON.parse(await readFile(String(request.args[3]), "utf8")).dependencies + ); + } + return await invoke(request); + }; + expect( + await startNativeProject({ + ...opts, + dependencyFile: path, + internalExtraHosts, + }) + ).toBe(0); + const services = JSON.parse(reviewed[0]?.normalizedComposeJson ?? "{}") + .services as Record>; + for (const name of ["web", "worker", "later"]) { + expect(services[name]?.extra_hosts).toEqual([ + "api.example.test:host-gateway", + ]); + } + // The inactive profile service is declared but never bound. + const binding = { + binding: "api", + slot: 0, + guest_port: 443, + host_port: 8443, + host_pid: 123, + aliases: ["api.example.test"], + }; + expect(plans).toEqual([ + [ + { ...binding, service: "web" }, + { ...binding, service: "worker" }, + ], + ]); + expect(services.web?.init).toBe(true); + expect(services.later?.init).toBeUndefined(); + + reviewed.length = 0; + await preflightNativeRestart({ + runtime: opts.runtime, + scope: opts.scope, + composeFile: opts.composeFile, + dependencyFile: path, + internalExtraHosts, + 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, + invoke: async (request) => { + if (request.args[1] === "dependency-plan") { + plans.push( + JSON.parse(await readFile(String(request.args[3]), "utf8")) + .dependencies + ); + return { + plan_id: "a".repeat(64), + dependency_plan_id: "f".repeat(64), + }; + } + return await restartPreflightInvoke(request); + }, + }, + }); + expect(reviewed).toHaveLength(1); + // Restart reviews the same declarations and bindings startup admitted. + expect( + JSON.parse(reviewed[0]?.normalizedComposeJson ?? "{}").services + ).toEqual(services); + expect(plans[1]).toEqual(plans[0]); +}); + +test("a project-wide alias without a binding for every active service refuses before runtime effects", async () => { + const { opts, events, path, internalExtraHosts } = + await internalHostsFixture(); + await writeFile( + path, + JSON.stringify({ + version: 1, + dependencies: [ + { + service: "web", + binding: "api", + aliases: ["api.example.test"], + guest_port: 443, + host_port: 8443, + host_pid: 123, + }, + ], + }) + ); + const error = await startNativeProject({ + ...opts, + dependencyFile: path, + internalExtraHosts, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect(String((error as Error).message)).toContain( + "Native host alias has no reviewed host dependency binding" + ); + expect(String((error as Error).message)).not.toContain("example"); + expect(events).toContain("before"); + expect(events).toContain("cleanup"); + expect(events).not.toContain("runtime up"); + expect(events).not.toContain("graph dependency-plan"); +}); + +test("project-wide aliases without a dependency selection refuse before hooks instead of being ignored", async () => { + const { opts, events, internalExtraHosts } = await internalHostsFixture(); + const error = await startNativeProject({ + ...opts, + internalExtraHosts, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect(String((error as Error).message)).toContain( + "configuration was not dropped" + ); + expect(events).not.toContain("before"); + expect(events).not.toContain("runtime up"); + // Without project-wide aliases the same project still starts unchanged. + expect(await startNativeProject({ ...opts, internalExtraHosts: {} })).toBe(0); +}); From 5b67485e067843e182ebbd71aad7426fecd25024 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:20:36 -0500 Subject: [PATCH 012/138] feat(native): run down hooks when foreground up is stopped Ctrl-C or SIGTERM on a ready native Compose-import graph whose up.after completed now runs down.before, graph cleanup, lifecycle-process retirement and down.after, matching hack down on the Compose backend and the authored native path. A second interrupt cancels a running down hook and forces cleanup. A failed down.before still stops the graph and is reported as E_LIFECYCLE_FAILED. Ctrl-C during up.after runs no down hook. A private value-free journal beside the run mapping (run, writer generation, phase; compare-and-swap under a lock) records intent before each hook and known completion after it exits. hack down skips completed phases, runs owed ones once after the frontend's finalization, and refuses unknown completion without replay; the next up refuses it too. Projects without down hooks keep the attached graph owner and write no journal. Adds the native-foreground-down-hooks TLA+ model (1,246 states) with a journal-guard negative control, unknown-generation and owe-withdrawal controls, and stop and crash-recovery reachability controls. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 36 +- docs/reference/v5-model-coverage.md | 2 + scripts/lib/tla-runtime-models.ts | 77 +++ src/backends/native-project-down-hooks.ts | 553 ++++++++++++++++++ src/backends/native-project-down.ts | 59 +- .../native-project-foreground-stop.ts | 125 ++++ src/backends/native-project-process.ts | 15 +- src/backends/native-project-run.ts | 20 +- src/backends/native-project-start.ts | 74 ++- src/commands/project.ts | 122 ++-- tests/models/tla/README.md | 51 ++ .../DownHooks.tla | 261 +++++++++ .../complete-reachable.cfg | 8 + .../native-foreground-down-hooks/negative.cfg | 7 + .../owed-after-interrupt.cfg | 7 + .../native-foreground-down-hooks/positive.cfg | 7 + .../recovery-reachable.cfg | 8 + .../unknown-generation.cfg | 7 + tests/native-project-down-hooks.test.ts | 512 ++++++++++++++++ tests/native-project-down-journal.test.ts | 254 ++++++++ tests/native-project-process.test.ts | 72 +++ tests/native-project-start-stop-hooks.test.ts | 345 +++++++++++ 22 files changed, 2561 insertions(+), 61 deletions(-) create mode 100644 src/backends/native-project-down-hooks.ts create mode 100644 src/backends/native-project-foreground-stop.ts create mode 100644 tests/models/tla/native-foreground-down-hooks/DownHooks.tla create mode 100644 tests/models/tla/native-foreground-down-hooks/complete-reachable.cfg create mode 100644 tests/models/tla/native-foreground-down-hooks/negative.cfg create mode 100644 tests/models/tla/native-foreground-down-hooks/owed-after-interrupt.cfg create mode 100644 tests/models/tla/native-foreground-down-hooks/positive.cfg create mode 100644 tests/models/tla/native-foreground-down-hooks/recovery-reachable.cfg create mode 100644 tests/models/tla/native-foreground-down-hooks/unknown-generation.cfg create mode 100644 tests/native-project-down-hooks.test.ts create mode 100644 tests/native-project-down-journal.test.ts create mode 100644 tests/native-project-start-stop-hooks.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..1fd216660 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -767,6 +767,34 @@ lifecycle hooks, and publishes the run mapping after native readiness. Ctrl-C wa for owned cleanup and keeps the stopped run mapping and named volumes after confirming cleanup. The VM remains available until explicit runtime shutdown. +Ctrl-C (or SIGTERM) on a ready graph whose `up.after` completed stops the project the +way `hack down` does on the Compose backend: `down.before` runs first, then graph +cleanup, then lifecycle-process retirement, then `down.after`. Hooks use the +environment selection of the running `up`. A second Ctrl-C cancels a running down +hook and proceeds to cleanup; a canceled `down.before` is not followed by +`down.after`. A failed `down.before` still stops the graph, because you asked to +exit, and the command reports `E_LIFECYCLE_FAILED`; a failed `down.after` reports +the same after cleanup was confirmed. Ctrl-C while `up.after` is still running stops +the graph without any down hook. With down hooks declared, the graph owner runs in +its own process group, so the terminal's interrupt reaches only the `hack` frontend, +which decides when the owner stops. + +A private, value-free journal beside the run mapping records an intent before each +down hook starts and its known completion after the hook's process exits. It holds +only the run identity, a random writer identity and a phase, never commands, values +or output. A later `hack down` reads it: completed (or failed) phases are not rerun; +phases still owed, for example after the frontend was killed before or between the +hooks, run once; a phase that started but whose completion is unknown (the frontend +or `down` died while it ran) refuses with `E_LIFECYCLE_FAILED` "Native down hook +completion is unknown; inspect before retrying." and nothing is replayed. That +refusal also blocks the next `up` before any hook. After checking what the +interrupted hook did, remove `.hack/.internal/native-runs/*.down-hooks.json` to +clear it. A `hack down` from another terminal and the frontend's Ctrl-C claim the +same journal record, so concurrent stops run each hook at most once. A new `up` +supersedes any other leftover record; it does not run an earlier generation's owed +hooks. Projects without down hooks keep the previous stop behavior and write no +journal. + Without that grant, `up` and `restart` refuse with `E_PERMISSION` before any stop or runtime request; set it and run the command again. @@ -798,9 +826,11 @@ HTTPS. A normal `restart` kept its run and Redis data after a source edit. This not establish every application workflow, crash recovery or comparative performance. Native `down` requests retaining cleanup from the graph owner and verifies container absence while keeping the exact run mapping. It then retires that branch's owned host -lifecycle processes before running after hooks. A previously recovered stopped graph -also retires remaining owned host processes without replaying hooks or guest cleanup; -uncertain ownership refuses retirement and leaves the retained mapping intact. +lifecycle processes before running after hooks. A stopped graph (after a foreground +stop or a previous recovery) also retires remaining owned host processes without +replaying guest cleanup; it runs only down hooks the journal above still owes, once, +after the original frontend's finalization is acknowledged. Uncertain ownership +refuses retirement and leaves the retained mapping intact. Before capturing live bridge helpers for cleanup, the owner retires an exited helper only after confirming its current guest boot, run, container, network and reservation. An interrupted retirement requires a fresh exit observation or an diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..b2525e351 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -43,6 +43,7 @@ must be attached to the eventual exact integration revision. | Settled retained Compose route-attempt retirement; pinned references; interrupted deletion | **Coverage gap:** filesystem journal compaction is not modeled; native-route-stop covers a different stop protocol | Instance mutation lock and exact surviving claim witnesses in [journal compaction](../../src/lib/native-compose-route-claims.ts); [claim regressions](../../tests/native-compose-route-claims.test.ts) and [retained lifecycle integration](../../tests/native-compose-adoption-routing-generation.test.ts) | 24 successful operations keep two attempt directories and constant Linux descriptors; four integrated lifecycle operations check directory counts. Injected unlink failures cover same-store and fresh-store cleanup retries. Assumes cooperative same-user writers, the caller's mutation lock and working filesystem synchronization. No process-kill/power-loss durability proof or bound on unresolved/over-budget histories. | | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | +| Foreground Ctrl-C down hooks: concurrent `down`, second interrupt, crash between or during hooks, stop during `up.after`, next generation | native-foreground-down-hooks | Value-free compare-and-swap journal in [down hooks](../../src/backends/native-project-down-hooks.ts), foreground orchestration in [foreground stop](../../src/backends/native-project-foreground-stop.ts) and `hack down` in [native down](../../src/backends/native-project-down.ts); [journal](../../tests/native-project-down-hooks.test.ts), [foreground](../../tests/native-project-start-stop-hooks.test.ts) and [down](../../tests/native-project-down-journal.test.ts) regressions | Durable writes are atomic in the model; real fsync/power loss, a stale journal lock after a crash inside the lock, and the live process-group behavior of the graph owner on a host are not covered. One generation and at most two `down` invocations are explored. | | Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | @@ -66,6 +67,7 @@ liveness/reachability controls have their own documented assumptions. | [native-frontend-recovery](../../tests/models/tla/native-frontend-recovery/Recovery.tla) | 602 | 10 | | [native-storage-witness](../../tests/models/tla/native-storage-witness/Witness.tla) | 352 | 19 | | [native-file-material](../../tests/models/tla/native-file-material/Material.tla) | 8,388 | 12 | +| [native-foreground-down-hooks](../../tests/models/tla/native-foreground-down-hooks/DownHooks.tla) | 1,246 | 6 | | [native-route-stop](../../tests/models/tla/native-route-stop/Stop.tla) | 202 | 6 | | [registry-writer](../../tests/models/tla/registry-writer/RegistryWriter.tla) | 7,721 | 2 | | [absent-publication-recovery](../../tests/models/tla/absent-publication-recovery/Absent.tla) | 247 | 5 | diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..05e54827f 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -688,6 +688,83 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "native-foreground-down-hooks", + module: "DownHooks", + states: 1246, + invariant: "Safe", + action: "DStart", + fields: [ + "unsafeBefore = TRUE", + "beforeOpen = 2", + 'jState = "beforeStarted"', + 'jGen = "D"', + ], + alternativeWitnesses: [ + { + action: "FInterrupt", + fields: [ + "unsafeBefore = TRUE", + "beforeOpen = 2", + 'jState = "beforeStarted"', + 'jGen = "F"', + ], + }, + ], + additionalControls: [ + { + name: "unknown-generation", + negative: true, + action: "NewUp", + fields: ["unsafeGen = TRUE", "newGen = TRUE", 'jState = "absent"'], + }, + { + name: "owed-after-interrupt", + negative: true, + action: "FInterruptAtPublish", + fields: [ + "unsafeOwe = TRUE", + "upInterrupted = TRUE", + 'jState = "owed"', + 'jGen = "F"', + ], + }, + { + name: "complete-reachable", + negative: true, + invariant: "NeverComplete", + action: "FAfterDone", + fields: [ + 'jState = "complete"', + "afterDone = TRUE", + "unsafeAfter = FALSE", + ], + alternativeWitnesses: [ + { + action: "DAfterDone", + fields: [ + 'jState = "complete"', + "afterDone = TRUE", + "unsafeAfter = FALSE", + ], + }, + ], + }, + { + name: "recovery-reachable", + negative: true, + invariant: "NeverRecovered", + action: "DAfterDone", + fields: [ + 'fpc = "dead"', + 'jState = "complete"', + 'jGen = "D"', + "unsafeBefore = FALSE", + "unsafeAfter = FALSE", + ], + }, + ], + }, { name: "native-route-stop", module: "Stop", diff --git a/src/backends/native-project-down-hooks.ts b/src/backends/native-project-down-hooks.ts new file mode 100644 index 000000000..291e7e72e --- /dev/null +++ b/src/backends/native-project-down-hooks.ts @@ -0,0 +1,553 @@ +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, rename, rmdir, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { + type NativeProjectRun, + type NativeProjectRunScope, + nativeProjectDownHookPaths, +} from "./native-project-run.ts"; + +/** + * Value-free journal of a native Compose-import generation's `down.before` and + * `down.after` hooks, kept beside the run mapping so a foreground Ctrl-C and a + * later `hack down` neither skip nor double-run them. + * + * One record per project branch, replaced by compare-and-swap under a private + * lock. It holds only the run id, a random writer generation and a state; no + * command, environment value or output is persisted. An intent record precedes + * every hook spawn and known completion follows the hook's exit, so a record + * left in a `*-started` state means the hook's outcome is unknown. Unknown + * completion is never replayed: it blocks later downs and a new generation + * until it is inspected. Modeled by `tests/models/tla/native-foreground-down-hooks`. + */ +export type NativeDownHookState = + | "owed" + | "before-started" + | "before-failed" + | "after-owed" + | "after-started" + | "complete"; +export type NativeDownHookRecord = { + readonly run: string; + readonly generation: string; + readonly state: NativeDownHookState; +}; + +const KIND = "hack.native-project-down-hooks"; +const HEX32 = /^[a-f0-9]{32}$/; +const LIMIT = 1024; +const LOCK_ATTEMPTS = 80; +const LOCK_DELAY_MS = 25; +const STATES: readonly NativeDownHookState[] = [ + "owed", + "before-started", + "before-failed", + "after-owed", + "after-started", + "complete", +]; + +export const NATIVE_DOWN_HOOK_UNKNOWN = + "Native down hook completion is unknown; inspect before retrying."; + +function unknownCompletion(): HackCliError { + return new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: NATIVE_DOWN_HOOK_UNKNOWN, + }); +} +function changed(): ClosedCliError { + return new ClosedCliError( + "Native down hook journal is unsafe or changed; inspect native state before retrying. No hook was replayed." + ); +} +function hasCode(error: unknown, code: string): boolean { + return isRecord(error) && error.code === code; +} +function started(record: NativeDownHookRecord | null): boolean { + return ( + record?.state === "before-started" || record?.state === "after-started" + ); +} +function same( + left: NativeDownHookRecord | null, + right: NativeDownHookRecord | null +): boolean { + return ( + left === right || + (left !== null && + right !== null && + left.run === right.run && + left.generation === right.generation && + left.state === right.state) + ); +} +function parse(value: unknown): NativeDownHookRecord { + if ( + !isRecord(value) || + Object.keys(value).sort().join() !== "generation,kind,run,state,version" || + value.version !== 1 || + value.kind !== KIND || + typeof value.run !== "string" || + !HEX32.test(value.run) || + typeof value.generation !== "string" || + !HEX32.test(value.generation) || + typeof value.state !== "string" || + !STATES.includes(value.state as NativeDownHookState) + ) { + throw changed(); + } + return { + run: value.run, + generation: value.generation, + state: value.state as NativeDownHookState, + }; +} +async function readRecord(path: string): Promise { + let fd: Awaited>; + try { + fd = await open( + path, + constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW + ); + } catch (error) { + if (hasCode(error, "ENOENT")) { + return null; + } + throw changed(); + } + try { + const stat = await fd.stat(); + if ( + !stat.isFile() || + stat.nlink !== 1 || + stat.size > LIMIT || + (stat.mode & 0o777) !== 0o600 + ) { + throw changed(); + } + const buffer = Buffer.alloc(LIMIT + 1); + const { bytesRead } = await fd.read(buffer, 0, buffer.length, 0); + if (bytesRead !== stat.size) { + throw changed(); + } + try { + return parse( + JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + buffer.subarray(0, bytesRead) + ) + ) + ); + } catch { + throw changed(); + } + } finally { + await fd.close(); + } +} +async function writeTemp(path: string, text: string): Promise { + const fd = await open( + path, + constants.O_CREAT | + constants.O_EXCL | + constants.O_WRONLY | + constants.O_NOFOLLOW, + 0o600 + ); + try { + await fd.writeFile(text); + await fd.sync(); + } finally { + await fd.close(); + } +} +async function syncDirectory(path: string): Promise { + const fd = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + await fd.sync(); + } finally { + await fd.close(); + } +} +async function acquire(lock: string): Promise { + for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) { + try { + await mkdir(lock, { mode: 0o700 }); + return; + } catch (error) { + if (!hasCode(error, "EEXIST")) { + throw changed(); + } + } + await Bun.sleep(LOCK_DELAY_MS); + } + // A concurrent writer holds it, or an interrupted one left it: never break it. + throw changed(); +} + +/** Read the current record; missing storage means no record. */ +export async function loadNativeDownHookRecord( + scope: NativeProjectRunScope +): Promise { + let paths: Awaited>; + try { + paths = await nativeProjectDownHookPaths(scope, false); + } catch (error) { + if (hasCode(error, "ENOENT")) { + return null; + } + throw changed(); + } + return await readRecord(paths.file); +} + +/** + * Replace `expected` with `next` (null removes the record) only when the + * current record equals `expected`. Returns false when it changed meanwhile. + * The record and its directory are synchronized before this returns. + */ +export async function replaceNativeDownHookRecord( + scope: NativeProjectRunScope, + expected: NativeDownHookRecord | null, + next: NativeDownHookRecord | null +): Promise { + let paths: Awaited>; + try { + paths = await nativeProjectDownHookPaths(scope, next !== null); + } catch (error) { + if (expected === null && next === null && hasCode(error, "ENOENT")) { + return true; + } + throw changed(); + } + await acquire(paths.lock); + const temp = join( + paths.root, + `down-hooks-${randomBytes(16).toString("hex")}.tmp` + ); + try { + if (!same(await readRecord(paths.file), expected)) { + return false; + } + if (next === null) { + if (expected !== null) { + await unlink(paths.file); + } + } else { + await writeTemp( + temp, + JSON.stringify({ + version: 1, + kind: KIND, + run: next.run, + generation: next.generation, + state: next.state, + }) + ); + await rename(temp, paths.file); + } + await syncDirectory(paths.root); + return true; + } catch (error) { + if (error instanceof ClosedCliError) { + throw error; + } + throw changed(); + } finally { + await unlink(temp).catch(() => undefined); + await rmdir(paths.lock).catch(() => undefined); + } +} + +function generation(): string { + return randomBytes(16).toString("hex"); +} + +/** + * Up admission, after the retained run's frontend finalization is confirmed and + * before any hook: a started (unknown) phase of an earlier generation refuses; + * any other leftover record is superseded by this generation. + */ +export async function admitNativeDownHookGeneration( + scope: NativeProjectRunScope +): Promise { + const current = await loadNativeDownHookRecord(scope); + if (current === null) { + return; + } + if (started(current)) { + throw unknownCompletion(); + } + if (!(await replaceNativeDownHookRecord(scope, current, null))) { + throw changed(); + } +} + +/** Outcome of a journaled hook phase; a crash mid-hook leaves its intent record. */ +export type NativeDownHookOutcome = + | "skipped" + | "succeeded" + | "failed" + | "canceled"; + +/** Runs one phase's commands; resolves to the exit status after the child exited. */ +export type NativeDownHookCommands = () => Promise; + +export type NativeForegroundDownHooks = { + /** + * After a successful `up.after`, as the last step before readiness is + * published: owe the down hooks when no record exists (a concurrent + * `hack down` may already own one). An interrupt observed by then took the + * forced stop path, so the owe is withdrawn: Ctrl-C during `up.after` never + * runs down hooks. + */ + readonly owe: (interrupted: () => boolean) => Promise; + /** First stop request on a ready graph, before graph cleanup. */ + readonly before: ( + commands: NativeDownHookCommands, + canceled: () => boolean + ) => Promise; + /** After confirmed graph cleanup and lifecycle-process retirement. */ + readonly after: ( + commands: NativeDownHookCommands, + canceled: () => boolean + ) => Promise; +}; + +async function journaled(opts: { + readonly scope: NativeProjectRunScope; + readonly claimed: NativeDownHookRecord; + readonly commands: NativeDownHookCommands; + readonly canceled: () => boolean; + readonly settle: (code: number, canceled: boolean) => NativeDownHookRecord; +}): Promise { + let code: number; + try { + code = await opts.commands(); + } catch { + // The runner throws only before a spawn or after the child exited. + code = 1; + } + const canceled = opts.canceled(); + if ( + !(await replaceNativeDownHookRecord( + opts.scope, + opts.claimed, + opts.settle(code, canceled) + )) + ) { + throw changed(); + } + if (canceled) { + return "canceled"; + } + return code === 0 ? "succeeded" : "failed"; +} + +/** + * The foreground frontend's journal for one generation. Each transition is a + * compare-and-swap on this generation's own record, so a concurrent + * `hack down` that claimed the record wins and this frontend skips the phase. + */ +export function createNativeForegroundDownHooks(opts: { + readonly scope: NativeProjectRunScope; + readonly run: string; +}): NativeForegroundDownHooks { + const own = generation(); + const record = (state: NativeDownHookState): NativeDownHookRecord => ({ + run: opts.run, + generation: own, + state, + }); + if (!HEX32.test(opts.run)) { + throw changed(); + } + return Object.freeze({ + async owe(interrupted) { + if ( + (await replaceNativeDownHookRecord(opts.scope, null, record("owed"))) && + interrupted() + ) { + await replaceNativeDownHookRecord(opts.scope, record("owed"), null); + } + }, + async before(commands, canceled) { + const claimed = record("before-started"); + if ( + !(await replaceNativeDownHookRecord( + opts.scope, + record("owed"), + claimed + )) + ) { + return "skipped"; + } + return await journaled({ + scope: opts.scope, + claimed, + commands, + canceled, + settle: (code, interrupted) => + record(code === 0 && !interrupted ? "after-owed" : "before-failed"), + }); + }, + async after(commands, canceled) { + const claimed = record("after-started"); + if ( + !(await replaceNativeDownHookRecord( + opts.scope, + record("after-owed"), + claimed + )) + ) { + return "skipped"; + } + return await journaled({ + scope: opts.scope, + claimed, + commands, + canceled, + settle: () => record("complete"), + }); + }, + }); +} + +export type NativeDownHookSession = { + /** Runs `down.before` when owed; `commands` rejects when the hook failed. */ + readonly before: (commands: () => Promise) => Promise; + /** Runs `down.after` when owed; known completion is never rerun. */ + readonly after: (commands: () => Promise) => Promise; +}; + +/** + * `hack down`'s view of the journal for the saved run. A started (unknown) + * phase refuses before any hook. On a live graph an owed record, or no record + * when the project declares down hooks, is claimed; a stopped graph runs only + * what is still owed (after a frontend crash, or a down.after the frontend + * never reached). A known `down.before` failure restores the claimed record so + * an explicit later down may retry it, as on the Compose backend. + */ +export async function openNativeDownHookSession(opts: { + readonly scope: NativeProjectRunScope; + readonly run: NativeProjectRun; + readonly graphStopped: boolean; + readonly configured: boolean; +}): Promise { + const loaded = await loadNativeDownHookRecord(opts.scope); + if (started(loaded)) { + throw unknownCompletion(); + } + // A record of another run is stale: it never authorizes this run's hooks. + const current = loaded?.run === opts.run.run ? loaded : null; + const own = generation(); + const record = (state: NativeDownHookState): NativeDownHookRecord => ({ + run: opts.run.run, + generation: own, + state, + }); + const claimBefore = + current?.state === "owed" || + (!opts.graphStopped && current === null && opts.configured); + let afterFrom: NativeDownHookRecord | null = + current?.state === "after-owed" ? current : null; + let beforeDone = false; + const session: NativeDownHookSession = { + async before(commands) { + if (!claimBefore || beforeDone) { + return; + } + beforeDone = true; + const claimed = record("before-started"); + if (!(await replaceNativeDownHookRecord(opts.scope, loaded, claimed))) { + throw changed(); + } + try { + await commands(); + } catch (error) { + if (!(await replaceNativeDownHookRecord(opts.scope, claimed, loaded))) { + throw changed(); + } + throw error; + } + const owed = record("after-owed"); + if (!(await replaceNativeDownHookRecord(opts.scope, claimed, owed))) { + throw changed(); + } + afterFrom = owed; + }, + async after(commands) { + if (afterFrom === null) { + return; + } + const from = afterFrom; + afterFrom = null; + const claimed = record("after-started"); + if (!(await replaceNativeDownHookRecord(opts.scope, from, claimed))) { + // The frontend finished this phase meanwhile; never run it twice. + const now = await loadNativeDownHookRecord(opts.scope); + if (now?.run === opts.run.run && now.state === "complete") { + return; + } + throw started(now) ? unknownCompletion() : changed(); + } + let failure: unknown; + try { + await commands(); + } catch (error) { + failure = error ?? new Error("Native down after hook failed."); + } + // Known completion, successful or not, is final for this generation. + if ( + !(await replaceNativeDownHookRecord( + opts.scope, + claimed, + record("complete") + )) + ) { + throw changed(); + } + if (failure !== undefined) { + throw failure; + } + }, + }; + return Object.freeze(session); +} + +/** + * The foreground stop's report once every cleanup completed. A canceled phase + * (second interrupt) keeps the interrupted exit status; a failed phase is a + * lifecycle failure, and a journal refusal surfaces unchanged. + */ +export function nativeForegroundStopFailure(opts: { + readonly before: NativeDownHookOutcome; + /** The after phase's outcome, or the journal refusal it raised. */ + readonly after: unknown; +}): unknown { + if (opts.before === "failed") { + return new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: + "Native down before hook failed during the foreground stop; the graph was stopped and data retained. A later down does not rerun it.", + }); + } + if (opts.after === "failed") { + return new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: + "Native down after hook failed; native graph cleanup was confirmed and data retained.", + }); + } + if ( + typeof opts.after === "string" && + ["skipped", "succeeded", "canceled"].includes(opts.after) + ) { + return undefined; + } + return opts.after; +} diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index 971514e04..4ba05666a 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -6,6 +6,7 @@ import { resolveProjectEnvConfig, selectProjectEnvValuesForExecutionTarget, } from "../lib/project-env-config.ts"; +import { openNativeDownHookSession } from "./native-project-down-hooks.ts"; import { captureNativeProjectFinalization, type NativeProjectFinalizationToken, @@ -96,6 +97,22 @@ export async function nativeProjectDown(opts: { readonly beforeCleanup?: (run: NativeProjectRun) => Promise; readonly retireHostProcesses?: (run: NativeProjectRun) => Promise; readonly after?: (run: NativeProjectRun) => Promise; + /** + * Journaled `down.before`/`down.after` (see `native-project-down-hooks.ts`). + * On a live graph they run around cleanup as before. On a graph that a + * foreground stop or recovery already stopped, only phases still owed run, + * once, after the original frontend's finalization. `run` rejects when the + * phase's commands fail. `configured` says whether the project declares + * down hooks; without a journal record nothing runs when it is false. + */ + readonly hooks?: { + readonly configured: boolean; + readonly run: (phase: { + readonly phase: "before" | "after"; + readonly run: NativeProjectRun; + readonly graphStopped: boolean; + }) => Promise; + }; readonly invoke?: typeof invokeNativeRuntime; /** Only the restart coordinator may defer to its already captured exact wait/recovery. */ readonly deferFinalization?: boolean; @@ -137,6 +154,25 @@ export async function nativeProjectDown(opts: { throw finalizationRefused(); } }; + const hooks = opts.hooks; + const owedAfterStop = async () => { + if (!hooks) { + return; + } + // Read only after finalization: the frontend may still be finishing them. + const session = await openNativeDownHookSession({ + scope: opts.scope, + run, + graphStopped: true, + configured: hooks.configured, + }); + await session.before(() => + hooks.run({ phase: "before", run, graphStopped: true }) + ); + await session.after(() => + hooks.run({ phase: "after", run, graphStopped: true }) + ); + }; const recovered = await recoverNativeInterruptedStartupCleanup({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, @@ -148,6 +184,7 @@ export async function nativeProjectDown(opts: { verify(recovered, run, true); await opts.retireHostProcesses?.(run); await wait(); + await owedAfterStop(); return { backend: "native", status: "stopped", @@ -160,10 +197,12 @@ export async function nativeProjectDown(opts: { isRecord(initial.receipt) && initial.receipt.phase === "stopped-data-retained" ) { - // Recovery already stopped compute. User hooks and guest cleanup must not replay. + // Recovery or a foreground stop already stopped compute. Guest cleanup and + // completed hooks must not replay; only journaled owed phases run once. verify(initial, run, true); await opts.retireHostProcesses?.(run); await wait(); + await owedAfterStop(); return { backend: "native", status: "stopped", @@ -171,7 +210,20 @@ export async function nativeProjectDown(opts: { dataPreserved: true, } as const; } + const session = hooks + ? await openNativeDownHookSession({ + scope: opts.scope, + run, + graphStopped: false, + configured: hooks.configured, + }) + : undefined; await opts.before?.(run); + if (hooks && session) { + await session.before(() => + hooks.run({ phase: "before", run, graphStopped: false }) + ); + } // Recheck after hooks, which may run arbitrary user-authorized commands. verify(await inspect(), run, false); if ( @@ -193,6 +245,11 @@ export async function nativeProjectDown(opts: { verify(await inspect(), run, true); await opts.retireHostProcesses?.(run); await opts.after?.(run); + if (hooks && session) { + await session.after(() => + hooks.run({ phase: "after", run, graphStopped: true }) + ); + } await wait(); return { backend: "native", diff --git a/src/backends/native-project-foreground-stop.ts b/src/backends/native-project-foreground-stop.ts new file mode 100644 index 000000000..976f0037d --- /dev/null +++ b/src/backends/native-project-foreground-stop.ts @@ -0,0 +1,125 @@ +import { + createNativeForegroundDownHooks, + type NativeDownHookOutcome, + nativeForegroundStopFailure, +} from "./native-project-down-hooks.ts"; +import type { NativeProjectRunScope } from "./native-project-run.ts"; + +/** + * Stop requests of a native foreground `up`. The first SIGINT/SIGTERM (or the + * caller's signal) aborts `signal`; a second one, or the caller's force + * signal, also aborts `force`, which cancels a running down hook and forces + * the graph owner to stop. + */ +export function nativeForegroundStopSignals(opts: { + readonly signal?: AbortSignal; + readonly forceSignal?: AbortSignal; +}) { + const controller = new AbortController(); + const force = new AbortController(); + let interruptedExit = 130; + const cancel = () => (controller.signal.aborted ? force : controller).abort(); + const terminate = () => { + interruptedExit = 143; + cancel(); + }; + const forced = () => { + controller.abort(); + force.abort(); + }; + process.on("SIGINT", cancel); + process.on("SIGTERM", terminate); + opts.signal?.addEventListener("abort", cancel, { once: true }); + opts.forceSignal?.addEventListener("abort", forced, { once: true }); + return { + controller, + force: force.signal, + interruptedExit: () => interruptedExit, + /** Apply stop requests that predate registration. */ + admitPending: () => { + if (opts.signal?.aborted) { + cancel(); + } + if (opts.forceSignal?.aborted) { + forced(); + } + }, + dispose: () => { + process.off("SIGINT", cancel); + process.off("SIGTERM", terminate); + opts.signal?.removeEventListener("abort", cancel); + opts.forceSignal?.removeEventListener("abort", forced); + }, + }; +} + +export type NativeForegroundStopCommands = { + readonly before: (signal: AbortSignal) => Promise; + readonly after: (signal: AbortSignal) => Promise; +}; + +/** + * Down hooks of one foreground generation, matching `hack down` on Compose: + * on the first stop request of a ready graph `down.before` runs, then graph + * cleanup, lifecycle-process retirement and `down.after`, each recorded in + * the value-free journal so a later `hack down` neither skips nor reruns them. + */ +export function createNativeForegroundStop(opts: { + readonly scope: NativeProjectRunScope; + readonly run: string; + readonly commands: NativeForegroundStopCommands; + readonly force: AbortSignal; + readonly journal?: typeof createNativeForegroundDownHooks; +}) { + const journal = (opts.journal ?? createNativeForegroundDownHooks)({ + scope: opts.scope, + run: opts.run, + }); + const canceled = () => opts.force.aborted; + let before: NativeDownHookOutcome = "skipped"; + let refusal: unknown; + return { + /** + * Graph-owner options: run `down.before` before the owner is asked to stop. + * The owner runs in its own process group so the terminal's interrupt + * reaches only this frontend. A failed hook still stops the graph because + * the user asked to exit; final graph inspection stays the cleanup + * authority, as without hooks. + */ + serve: { + forceSignal: opts.force, + requireGroupSettlement: false, + beforeStop: async () => { + try { + before = await journal.before( + () => opts.commands.before(opts.force), + canceled + ); + } catch (error) { + refusal = error; + } + return true; + }, + }, + owe: journal.owe, + /** + * After confirmed graph cleanup and lifecycle-process retirement, before + * the frontend's finalization acknowledgement: run this generation's owed + * `down.after`. Resolves to the stop's failure to report, if any. + */ + finish: async (graphCleanupConfirmed: boolean): Promise => { + if (refusal !== undefined) { + return refusal; + } + const after = graphCleanupConfirmed + ? await journal + .after(() => opts.commands.after(opts.force), canceled) + .catch((error: unknown) => error) + : "skipped"; + return nativeForegroundStopFailure({ before, after }); + }, + }; +} +export type NativeForegroundStop = ReturnType< + typeof createNativeForegroundStop +>; diff --git a/src/backends/native-project-process.ts b/src/backends/native-project-process.ts index 50b3bf91b..27d14ee59 100644 --- a/src/backends/native-project-process.ts +++ b/src/backends/native-project-process.ts @@ -45,6 +45,12 @@ type ProcessOptions = { /** Original owner only: emitted after captured exit/drain and group absence. */ readonly onSettled?: () => undefined | Promise; readonly forceSignal?: AbortSignal; + /** + * With `beforeStop` or `liveStopOwner`, settlement requires the owner's + * detached group to be absent (default). The Compose-import foreground keeps + * its final graph inspection as cleanup authority and passes false. + */ + readonly requireGroupSettlement?: boolean; readonly onStopFailure?: () => void; /** Durable capture in the existing frontend owner, before private input delivery. */ readonly onGroup?: (group: number) => Promise; @@ -478,13 +484,20 @@ async function serveGraphProcess( } catch { // Observation must never replace the startup result or cleanup authority. } - if (opts.beforeStop || opts.liveStopOwner) { + if (requiresGroupSettlement(opts)) { await requireGroupAbsent(child.pid); } await opts.onSettled?.(); } } +function requiresGroupSettlement(opts: ProcessOptions): boolean { + return ( + (opts.beforeStop !== undefined || opts.liveStopOwner !== undefined) && + opts.requireGroupSettlement !== false + ); +} + /** An attached finite-hook owner must not retire while its captured detached group is present. * Unknown permission or lifetime is a refusal, never a signal to a later/reused group. */ diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index 5ae8a1a50..598ea364e 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -245,7 +245,7 @@ async function verifyIgnore(ignore: string) { async function paths( opts: NativeProjectRunScope, create: boolean, - kind: "run" | "restart" = "run" + kind: "run" | "restart" | "down-hooks" = "run" ) { const identity = await scope(opts); const internal = join(identity.projectDir, ".internal"); @@ -280,7 +280,7 @@ async function paths( const key = createHash("sha256") .update(JSON.stringify(identity.branch)) .digest("hex"); - const suffix = kind === "restart" ? ".restart" : ""; + const suffix = kind === "run" ? "" : `.${kind}`; return { identity, root, @@ -949,6 +949,22 @@ export async function recoverNativeProjectRunFilesystem(opts: { await releaseHeldDirectory(restartLock, restartHeld); } } +/** + * Private paths of the value-free down-hook journal beside the run mapping, + * under the same scope, directory and ignore-file checks. Missing storage + * surfaces as the raw ENOENT error; callers treat it as "no journal". + */ +export async function nativeProjectDownHookPaths( + opts: NativeProjectRunScope, + create: boolean +): Promise<{ + readonly root: string; + readonly file: string; + readonly lock: string; +}> { + const p = await paths(opts, create, "down-hooks"); + return { root: p.root, file: p.file, lock: p.lock }; +} /** Establish excluded metadata before source identity is reviewed. */ export async function prepareNativeProjectRunStorage( opts: NativeProjectRunScope diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..19a13fbc2 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -29,11 +29,21 @@ import { prepareNativeDependencyServices, readNativeHostDependencies, } from "./native-project-dependencies.ts"; +import { + admitNativeDownHookGeneration, + createNativeForegroundDownHooks, +} from "./native-project-down-hooks.ts"; import { beginNativeProjectFinalization, captureNativeProjectFinalization, waitNativeProjectFinalization, } from "./native-project-finalization.ts"; +import { + createNativeForegroundStop, + type NativeForegroundStop, + type NativeForegroundStopCommands, + nativeForegroundStopSignals, +} from "./native-project-foreground-stop.ts"; import { isNativeHttpsProbePath } from "./native-project-https.ts"; import { admitNativeProjectImages } from "./native-project-image-admission.ts"; import { @@ -245,6 +255,12 @@ export function reviewedNativeHttpsRoutes( type Hooks = { readonly cleanup: () => Promise; readonly ready?: () => Promise; + /** + * The project's `down.before`/`down.after` commands, present only when it + * declares them. Each resolves to the exit status after its child exited; + * the signal cancels a running phase (a second interrupt). + */ + readonly stop?: NativeForegroundStopCommands; }; type Dependencies = { prepare: typeof prepareNativeProjectInput; @@ -265,6 +281,8 @@ type Dependencies = { finalization: typeof beginNativeProjectFinalization; captureFinalization: typeof captureNativeProjectFinalization; waitFinalization: typeof waitNativeProjectFinalization; + admitDownHooks: typeof admitNativeDownHookGeneration; + downHooks: typeof createNativeForegroundDownHooks; }; const DEFAULTS: Dependencies = { prepare: prepareNativeProjectInput, @@ -286,6 +304,8 @@ const DEFAULTS: Dependencies = { finalization: beginNativeProjectFinalization, captureFinalization: captureNativeProjectFinalization, waitFinalization: waitNativeProjectFinalization, + admitDownHooks: admitNativeDownHookGeneration, + downHooks: createNativeForegroundDownHooks, }; /** Fixed text of the native foreground configuration refusal. */ export const NATIVE_CONFIGURATION_REFUSAL = @@ -920,6 +940,8 @@ export async function startNativeProject(opts: { readonly fingerprint: ComposeEnvFingerprintRecord; }) => Promise; readonly signal?: AbortSignal; + /** A second stop request: cancels a running down hook and forces cleanup. */ + readonly forceSignal?: AbortSignal; readonly dependencies?: Partial; }): Promise { const requestedProfiles = normalizeNativeProfiles(opts.profiles); @@ -966,6 +988,9 @@ export async function startNativeProject(opts: { }); requireActiveStartup(opts.signal); } + // A down hook of an earlier generation with unknown completion blocks this + // one before any hook runs; any other leftover record is superseded. + await deps.admitDownHooks(opts.scope); const profiles = selection.profiles; await preflightNativeProjectSource({ runtime: opts.runtime, @@ -1038,17 +1063,11 @@ export async function startNativeProject(opts: { const artifactHash = createHash("sha256") .update(new Uint8Array(await artifactFile.arrayBuffer())) .digest("hex"); - const controller = new AbortController(); - let interruptedExit = 130; - const cancel = () => controller.abort(); - const terminate = () => { - interruptedExit = 143; - cancel(); - }; - process.on("SIGINT", cancel); - process.on("SIGTERM", terminate); - opts.signal?.addEventListener("abort", cancel, { once: true }); + const stopSignals = nativeForegroundStopSignals(opts); + const controller = stopSignals.controller; let hooks: Hooks | undefined; + let foregroundStop: NativeForegroundStop | undefined; + let failed = false; let finalization: | Awaited> | undefined; @@ -1056,9 +1075,7 @@ export async function startNativeProject(opts: { let httpsCleanupConfirmed = false; let httpsAcquisitionFailure: unknown; try { - if (opts.signal?.aborted) { - cancel(); - } + stopSignals.admitPending(); requireActiveStartup(controller.signal); hooks = await opts.before(input); if (selection.aws) { @@ -1222,6 +1239,16 @@ export async function startNativeProject(opts: { }); const directory = await mkdtemp(join(tmpdir(), "hack-native-start-")); const run = runSelection.run; + const stop = hooks?.stop + ? createNativeForegroundStop({ + scope: opts.scope, + run, + commands: hooks.stop, + force: stopSignals.force, + journal: deps.downHooks, + }) + : undefined; + foregroundStop = stop; let mapping: NativeProjectRun | undefined; let https: | Awaited> @@ -1303,6 +1330,8 @@ export async function startNativeProject(opts: { privateInput: delivery.payload, startupTimeoutMs: 300_000, signal: controller.signal, + // Down hooks run on the first stop request of a ready graph. + ...stop?.serve, onExitDiagnostic: (diagnostic) => { nativeExitCode = diagnostic.nativeCode; }, @@ -1400,6 +1429,8 @@ export async function startNativeProject(opts: { } await hooks?.ready?.(); await opts.onReady?.(); + // Last: only microtasks separate this from readiness. + await stop?.owe(() => controller.signal.aborted); }, }); } catch (error) { @@ -1438,7 +1469,7 @@ export async function startNativeProject(opts: { }); return foregroundExitCode({ aborted: controller.signal.aborted, - interruptedExit, + interruptedExit: stopSignals.interruptedExit(), failure: serveFailure, infrastructureFailure: httpsFailure, code, @@ -1462,14 +1493,16 @@ export async function startNativeProject(opts: { }, }); } catch (error) { + failed = true; throw httpsCleanupFailure(httpsAcquisitionFailure, error); } finally { - process.off("SIGINT", cancel); - process.off("SIGTERM", terminate); - opts.signal?.removeEventListener("abort", cancel); + stopSignals.dispose(); await hooks?.cleanup().catch((error: unknown) => { throw httpsCleanupFailure(httpsAcquisitionFailure, error); }); + // down.after follows confirmed cleanup and lifecycle-process retirement, + // and precedes the finalization acknowledgement `down` waits for. + const stopFailure = await foregroundStop?.finish(graphCleanupConfirmed); await acknowledgeFinalization( finalization, graphCleanupConfirmed, @@ -1477,5 +1510,12 @@ export async function startNativeProject(opts: { ).catch((error: unknown) => { throw httpsCleanupFailure(httpsAcquisitionFailure, error); }); + reportForegroundStopFailure(stopFailure, failed); + } +} +/** A failed stop hook replaces the interrupted status, never a startup error. */ +function reportForegroundStopFailure(failure: unknown, failed: boolean): void { + if (failure !== undefined && !failed) { + throw failure; } } diff --git a/src/commands/project.ts b/src/commands/project.ts index ff71f6a36..6bfc21bc9 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1747,6 +1747,52 @@ async function maybePromptToFixMissingEnv(opts: { return true; } +/** One finite lifecycle command; the signal sends SIGTERM to its shell. */ +async function runLifecycleChild(opts: { + readonly command: string; + readonly cwd: string; + readonly env: Record; + readonly output: "stdout" | "stderr"; + readonly projectDir: string; + readonly composeProject: string; + readonly service: string; + readonly signal?: AbortSignal; +}): Promise { + if (opts.signal?.aborted) { + return 130; + } + const proc = Bun.spawn(["sh", "-c", opts.command], { + cwd: opts.cwd, + env: opts.env, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + const cancel = () => proc.kill("SIGTERM"); + opts.signal?.addEventListener("abort", cancel, { once: true }); + const stdoutTask = streamLifecycleCommandOutput({ + stream: proc.stdout, + output: opts.output, + projectDir: opts.projectDir, + composeProject: opts.composeProject, + service: opts.service, + }); + const stderrTask = streamLifecycleCommandOutput({ + stream: proc.stderr, + output: "stderr", + projectDir: opts.projectDir, + composeProject: opts.composeProject, + service: opts.service, + }); + try { + const code = await proc.exited; + return opts.signal?.aborted ? 130 : code; + } finally { + opts.signal?.removeEventListener("abort", cancel); + await Promise.all([stdoutTask, stderrTask]); + } +} + async function runLifecycleCommands(opts: { readonly title: string; readonly commands: readonly ProjectLifecycleCommand[] | undefined; @@ -1760,6 +1806,8 @@ async function runLifecycleCommands(opts: { readonly index: number; readonly serviceName: string; }) => Promise; + /** Cancels the running command (SIGTERM) and the rest; the result is then 130. */ + readonly signal?: AbortSignal; }): Promise { const commands = opts.commands ?? []; for (const [index, cmd] of commands.entries()) { @@ -1810,32 +1858,17 @@ async function runLifecycleCommands(opts: { }, }); - const proc = Bun.spawn(["sh", "-c", cmd.command], { + const exitCode = await runLifecycleChild({ + command: cmd.command, cwd, env: await mergeLifecycleCommandEnv(opts.env), - stdin: "inherit", - stdout: "pipe", - stderr: "pipe", - }); - - const stdoutTask = streamLifecycleCommandOutput({ - stream: proc.stdout, output: opts.routeStdoutToStderr ? "stderr" : "stdout", projectDir: opts.projectDir, composeProject: opts.composeProject, service: serviceName, - }); - const stderrTask = streamLifecycleCommandOutput({ - stream: proc.stderr, - output: "stderr", - projectDir: opts.projectDir, - composeProject: opts.composeProject, - service: serviceName, + signal: opts.signal, }); - const exitCode = await proc.exited; - await Promise.all([stdoutTask, stderrTask]); - await appendLifecycleLogRecord({ projectDir: opts.projectDir, composeProject: opts.composeProject, @@ -5810,8 +5843,23 @@ async function handleNativeUp({ // The native backend launches services after this returns: refuse when a // concurrent down already released this instance's shared helpers. await assertNativeSharedHelpersCurrent({ lifecycle, cleanup }); + // Ctrl-C runs these as `hack down` would (journaled, never replayed). + const down = cfg.lifecycle?.down; + const stopPhase = (phase: "before" | "after") => (signal: AbortSignal) => + runLifecycleCommands({ + title: `Lifecycle (native down ${phase})`, + commands: down?.[phase], + projectRoot: project.projectRoot, + env: input.lifecycleHostEnvironment, + projectDir: project.projectDir, + composeProject, + signal, + }); return { cleanup, + ...(down?.before?.length || down?.after?.length + ? { stop: { before: stopPhase("before"), after: stopPhase("after") } } + : {}), ready: async () => { await assertNativeSharedHelpersCurrent({ lifecycle, @@ -6691,7 +6739,7 @@ async function handleDown({ nativeHome: native.home, branch, }; - let lifecycleEnv: Readonly> = {}; + let lifecycleEnv: Readonly> | undefined; const hasHooks = Boolean( cfg.lifecycle?.down?.before?.length || cfg.lifecycle?.down?.after?.length ); @@ -6702,19 +6750,19 @@ async function handleDown({ projectName, branch, }); - const hook = async (phase: "before" | "after") => { + const hook = async (phase: "before" | "after", graphStopped: boolean) => { const code = await runLifecycleCommands({ title: `Lifecycle (native down ${phase})`, commands: cfg.lifecycle?.down?.[phase], projectRoot: project.projectRoot, projectDir: project.projectDir, composeProject, - env: lifecycleEnv, + env: lifecycleEnv ?? {}, routeStdoutToStderr: args.options.json === true, }); if (code !== 0) { throw new Error( - `Native down ${phase} hook failed (exit ${code})${phase === "after" ? "; native graph cleanup was confirmed and data retained" : "; cleanup was not requested"}.` + `Native down ${phase} hook failed (exit ${code})${graphStopped ? "; native graph cleanup was confirmed and data retained" : "; cleanup was not requested"}.` ); } }; @@ -6731,26 +6779,26 @@ async function handleDown({ composeProject, }); }, - before: async (run) => { - if (!hasHooks) { - return; - } - lifecycleEnv = await nativeDownEnvironment({ - scope, - run, - serviceNames: await readComposeServiceNames(project.composeFile), - }); - await hook("before"); + // Journaled: a foreground Ctrl-C may already have run them, or left + // them owed after a crash; unknown completion refuses. + hooks: { + configured: hasHooks, + run: async ({ phase, run, graphStopped }) => { + if (!hasHooks) { + return; + } + lifecycleEnv ??= await nativeDownEnvironment({ + scope, + run, + serviceNames: await readComposeServiceNames(project.composeFile), + }); + await hook(phase, graphStopped); + }, }, // Only a committed down (its before hook succeeded and the ownership // and finalization rechecks passed) may supersede a concurrent up. beforeCleanup: () => markLifecycleSharedHelpersStopping({ cfg, composeProject }), - after: async () => { - if (hasHooks) { - await hook("after"); - } - }, }); if (args.options.json) { process.stdout.write(`${JSON.stringify(result)}\n`); diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..d785e724e 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -281,6 +281,57 @@ Implementation regressions and the registered `native-config-routing` Docker scenario supply separate evidence. The model does not prove those checks, browser access, performance or whole NC03 completion. +## Native foreground down hooks + +`native-foreground-down-hooks/DownHooks.tla` checks the value-free down-hook journal +that a native Compose-import foreground `up` shares with `hack down`. One generation +whose project declares down hooks starts with the frontend running `up.after`. The +frontend owes the hooks, publishes readiness, and on the first interrupt claims its +owed record, runs `down.before`, stops the graph, retires lifecycle processes and runs +`down.after`. A second interrupt cancels a running `down.before` (known, unsuccessful +completion). The graph can also end by itself. One `hack down` client may run, and be +retried once, from another terminal: on a live graph it claims an owed record, or no +record (an older frontend, or one still in `up.after`); on a stopped graph it waits for +the frontend's finalization and runs only what is still owed. A known `down.before` +failure of `down` restores the claimed record so an explicit retry may run it, as on +Compose. Either process may crash at any step; a crash keeps the durable record, so a +started phase becomes unknown. A later `up` (`NewUp`) supersedes any record except a +started one. The journal is one record replaced by compare-and-swap; the model treats +each replacement as atomic. + +The positive configuration explores **1,246 distinct states**. `Safe` requires that +`down.before` never starts while another start is running or unknown, or after a known +success; that `down.after` starts once, only after the graph stopped and a known +successful `down.before`; that a started-unknown phase blocks a new generation; and +that an interrupt during `up.after` never runs, or leaves owed, the frontend's down +hooks. No fairness or eventual cleanup is asserted. + +The main negative control removes the journal guard (claims ignore the record) and +must fail `Safe` when `DStart` or `FInterrupt` starts a second concurrent +`down.before`. `unknown-generation` removes the started-phase refusal and must fail at +`NewUp` with `unsafeGen = TRUE`. `owed-after-interrupt` removes the owe withdrawal and +must fail at `FInterruptAtPublish` with the frontend's owed record left behind. +`complete-reachable` and `recovery-reachable` assert `NeverComplete` and +`NeverRecovered` and must fail at a safe `FAfterDone`/`DAfterDone`, and at `DAfterDone` +after a frontend crash: the positive model does not pass by disabling the stop or the +crash-recovery path. + +| Model action | Implementation boundary under `src/backends/` | +| --- | --- | +| `FOwe` / `FPublish` / `FInterruptAtPublish` | `native-project-start.ts` calls the journal's `owe` as the last step of readiness publication; `native-project-down-hooks.ts` withdraws it when an interrupt was observed. | +| `FUpAfterInterrupted` | `native-project-process.ts` takes the forced stop path for a stop request before readiness; no `beforeStop`. | +| `FInterrupt` / `FBeforeFinish` / `FCancelBefore` | `beforeStop` from `native-project-foreground-stop.ts`; the second interrupt aborts the force signal that cancels the hook (`runLifecycleCommands` in `src/commands/project.ts`). | +| `FCleanup` / `FAfter` / `FAfterDone` | Confirmed foreground cleanup, `hooks.cleanup` and then the journal's `after`, before the finalization acknowledgement. | +| `DStart` / `DBeforeFinish` / `DCleanup` / `DAfter` / `DAfterDone` | `openNativeDownHookSession` in `native-project-down-hooks.ts`, used by `native-project-down.ts`; the stopped path reads the journal only after `waitNativeProjectFinalization`. | +| `NewUp` | `admitNativeDownHookGeneration`, called before `up.before`. | +| `FCrash` / `DCrash` / `OrphanStops` | Process death leaves the record; a detached graph owner may finish stopping or be stopped by recovery. | + +The model does not cover real fsync or power-loss durability, a journal lock left by a +crash inside the lock (it refuses closed and needs inspection), multiple generations, +more than two `down` invocations, environment resolution, or the graph owner's process +group on a real host. Ordinary tests map its counterexamples: concurrent claim, +second-interrupt cancellation, failure, crash recovery and unknown refusal. + ## Projects registry writer ownership `registry-writer/RegistryWriter.tla` models two one-shot writers, two independent diff --git a/tests/models/tla/native-foreground-down-hooks/DownHooks.tla b/tests/models/tla/native-foreground-down-hooks/DownHooks.tla new file mode 100644 index 000000000..dd2f06a5d --- /dev/null +++ b/tests/models/tla/native-foreground-down-hooks/DownHooks.tla @@ -0,0 +1,261 @@ +----------------------------- MODULE DownHooks ----------------------------- +EXTENDS Naturals +CONSTANTS EnforceJournal, EnforceUnknownBlock, RevertOwed +VARIABLES graph, jState, jGen, fpc, dpc, dRuns, dSaved, interrupts, + upInterrupted, beforeOpen, beforeOk, afterOpen, afterDone, fHooks, + newGen, unsafeBefore, unsafeAfter, unsafeGen, unsafeOwe +vars == <> +journal == <> +counters == <> +flags == <> + +\* One native Compose-import generation whose project declares down hooks. +\* The frontend F has authenticated readiness and is running up.after. One +\* `hack down` client D may run (and be retried once) from another terminal. +\* The journal is one value-free record (state, owner generation) replaced by +\* compare-and-swap under a private lock; "absent" is no record. +Init == /\ graph = "upAfter" /\ jState = "absent" /\ jGen = "none" + /\ fpc = "upAfter" /\ dpc = "idle" /\ dRuns = 0 + /\ dSaved = <<"absent", "none">> /\ interrupts = 0 + /\ upInterrupted = FALSE /\ beforeOpen = 0 /\ beforeOk = FALSE + /\ afterOpen = 0 /\ afterDone = FALSE /\ fHooks = FALSE + /\ newGen = FALSE /\ unsafeBefore = FALSE /\ unsafeAfter = FALSE + /\ unsafeGen = FALSE /\ unsafeOwe = FALSE + +Live == graph \in {"upAfter", "ready"} +Started == jState \in {"beforeStarted", "afterStarted"} +DActive == dpc \in {"before", "cleanup", "after", "afterRunning"} + +\* An intent record precedes each hook spawn. A start is unsafe when another +\* start is still running or unknown, or a known success already exists. +StartBefore(g) == + /\ jState' = "beforeStarted" /\ jGen' = g + /\ beforeOpen' = beforeOpen + 1 + /\ unsafeBefore' = (unsafeBefore \/ beforeOpen > 0 \/ beforeOk) +\* down.after requires Removed (stopped) and a known successful down.before. +StartAfter(g) == + /\ jState' = "afterStarted" /\ jGen' = g + /\ afterOpen' = afterOpen + 1 + /\ unsafeAfter' = (unsafeAfter \/ afterOpen > 0 \/ afterDone + \/ graph # "stopped" \/ ~beforeOk) + +\* ---------------------------------------------------------------- frontend +\* up.after succeeded: owe the down hooks only when no record exists (a +\* concurrent down may already own one). Readiness is published afterwards. +FOwe == + /\ fpc = "upAfter" /\ ~newGen + /\ graph' = IF graph = "upAfter" THEN "ready" ELSE graph + /\ fpc' = "publishing" + /\ IF jState = "absent" \/ ~EnforceJournal + THEN jState' = "owed" /\ jGen' = "F" + ELSE UNCHANGED journal + /\ UNCHANGED <> +FPublish == + /\ fpc = "publishing" /\ fpc' = "serving" + /\ UNCHANGED <> +\* An interrupt observed before readiness publication takes the forced stop; +\* the frontend withdraws its own owed record (compare-and-swap). A record a +\* concurrent down claimed meanwhile belongs to that down, not the frontend. +FInterruptAtPublish == + /\ fpc = "publishing" /\ interrupts = 0 + /\ interrupts' = 1 /\ upInterrupted' = TRUE /\ fpc' = "stopping" + /\ IF RevertOwed /\ jState = "owed" /\ jGen = "F" + THEN jState' = "absent" /\ jGen' = "none" + ELSE UNCHANGED journal + /\ unsafeOwe' = (jState' = "owed" /\ jGen' = "F") + /\ UNCHANGED <> +\* Ctrl-C while up.after runs: the forced stop never owes or runs down hooks. +FUpAfterInterrupted == + /\ fpc = "upAfter" /\ interrupts = 0 /\ ~newGen + /\ interrupts' = 1 /\ upInterrupted' = TRUE /\ fpc' = "stopping" + /\ UNCHANGED <> +\* First Ctrl-C on a ready graph: claim the frontend's own owed record. +FInterrupt == + /\ fpc = "serving" /\ interrupts = 0 /\ ~newGen + /\ interrupts' = 1 + /\ IF (jState = "owed" /\ jGen = "F") \/ ~EnforceJournal + THEN /\ StartBefore("F") /\ fHooks' = TRUE /\ fpc' = "before" + /\ UNCHANGED <> + ELSE /\ fpc' = "stopping" + /\ UNCHANGED <> + /\ UNCHANGED <> +\* Known completion: success owes down.after; failure is terminal for this +\* generation and the stop still proceeds because the user asked to exit. +FBeforeDone(ok) == + /\ beforeOpen' = beforeOpen - 1 /\ beforeOk' = (beforeOk \/ ok) + /\ IF jState = "beforeStarted" /\ jGen = "F" + THEN jState' = (IF ok THEN "afterOwed" ELSE "beforeFailed") + /\ UNCHANGED jGen + ELSE UNCHANGED journal + /\ fpc' = "stopping" +FBeforeFinish == + /\ fpc = "before" /\ \E ok \in BOOLEAN : FBeforeDone(ok) + /\ UNCHANGED <> +\* A second Ctrl-C cancels the running hook: known, unsuccessful completion. +FCancelBefore == + /\ fpc = "before" /\ interrupts = 1 + /\ interrupts' = 2 /\ FBeforeDone(FALSE) + /\ UNCHANGED <> +\* The graph can also end by itself or be stopped by D while F serves. +FGraphEnds == + /\ fpc = "serving" /\ ~newGen + /\ graph' = "stopped" /\ fpc' = "after" + /\ UNCHANGED <> +\* Exact native cleanup confirmed and lifecycle processes stopped. +FCleanup == + /\ fpc = "stopping" + /\ graph' = "stopped" /\ fpc' = "after" + /\ UNCHANGED <> +FAfter == + /\ fpc = "after" /\ graph = "stopped" + /\ IF IF EnforceJournal THEN jState = "afterOwed" /\ jGen = "F" ELSE fHooks + THEN /\ StartAfter("F") /\ fHooks' = TRUE /\ fpc' = "afterRunning" + /\ UNCHANGED <> + ELSE /\ fpc' = "done" + /\ UNCHANGED <> + /\ UNCHANGED <> +\* Known completion of down.after, successful or not, is never rerun. +FAfterDone == + /\ fpc = "afterRunning" + /\ afterOpen' = afterOpen - 1 /\ afterDone' = TRUE + /\ IF jState = "afterStarted" /\ jGen = "F" + THEN jState' = "complete" /\ UNCHANGED jGen + ELSE UNCHANGED journal + /\ fpc' = "done" + /\ UNCHANGED <> +\* A crash keeps the durable record; a started phase becomes unknown. +FCrash == + /\ fpc \in {"upAfter", "publishing", "serving", "before", "stopping", + "after", "afterRunning"} + /\ fpc' = "dead" + /\ UNCHANGED <> +\* A dead frontend's graph may finish stopping, or be stopped by recovery. +OrphanStops == + /\ fpc = "dead" /\ graph # "stopped" /\ ~DActive + /\ graph' = "stopped" + /\ UNCHANGED <> + +\* ------------------------------------------------------------- hack down +\* D claims an owed record, or no record for a live graph (an older frontend +\* or one still in up.after). A started record is unknown and refuses. +\* A stopped graph is read only after the frontend's finalization completed. +DClaims == + IF ~EnforceJournal THEN TRUE + ELSE jState = "owed" \/ (Live /\ jState = "absent") +DStart == + /\ ~DActive /\ dRuns < 2 /\ ~newGen + /\ Live \/ fpc \in {"done", "dead"} + /\ dRuns' = dRuns + 1 + /\ IF EnforceJournal /\ Started + THEN /\ dpc' = "refused" + /\ UNCHANGED <> + ELSE IF DClaims + THEN /\ StartBefore("D") /\ dSaved' = journal /\ dpc' = "before" + /\ UNCHANGED <> + ELSE /\ dpc' = IF Live THEN "cleanup" + ELSE IF jState = "afterOwed" THEN "after" ELSE "done" + /\ UNCHANGED <> + /\ UNCHANGED <> +\* Success owes down.after. A failure leaves cleanup unrequested and restores +\* the claimed record, so an explicit later down may retry a known failure. +DBeforeFinish == + /\ dpc = "before" /\ beforeOpen' = beforeOpen - 1 + /\ \E ok \in BOOLEAN : + IF ok + THEN /\ beforeOk' = TRUE + /\ IF jState = "beforeStarted" /\ jGen = "D" + THEN jState' = "afterOwed" /\ UNCHANGED jGen + ELSE UNCHANGED journal + /\ dpc' = IF graph = "stopped" THEN "after" ELSE "cleanup" + ELSE /\ UNCHANGED beforeOk + /\ IF jState = "beforeStarted" /\ jGen = "D" + THEN jState' = dSaved[1] /\ jGen' = dSaved[2] + ELSE UNCHANGED journal + /\ dpc' = "failed" + /\ UNCHANGED <> +DCleanup == + /\ dpc = "cleanup" + /\ graph' = "stopped" /\ dpc' = "after" + /\ UNCHANGED <> +DAfter == + /\ dpc = "after" /\ graph = "stopped" + /\ IF jState = "afterOwed" \/ ~EnforceJournal + THEN /\ StartAfter("D") /\ dpc' = "afterRunning" + /\ UNCHANGED <> + ELSE /\ dpc' = "done" + /\ UNCHANGED <> + /\ UNCHANGED <> +DAfterDone == + /\ dpc = "afterRunning" + /\ afterOpen' = afterOpen - 1 /\ afterDone' = TRUE + /\ IF jState = "afterStarted" /\ jGen = "D" + THEN jState' = "complete" /\ UNCHANGED jGen + ELSE UNCHANGED journal + /\ dpc' = "done" + /\ UNCHANGED <> +DCrash == + /\ DActive /\ dpc' = "dead" + /\ UNCHANGED <> + +\* --------------------------------------------------------- next generation +\* A later up supersedes any record except a started (unknown) phase. +NewUp == + /\ ~newGen /\ graph = "stopped" /\ fpc \in {"done", "dead"} /\ ~DActive + /\ EnforceUnknownBlock => ~Started + /\ newGen' = TRUE /\ unsafeGen' = Started + /\ jState' = "absent" /\ jGen' = "none" + /\ UNCHANGED <> + +Next == FOwe \/ FPublish \/ FInterruptAtPublish \/ FUpAfterInterrupted \/ FInterrupt \/ FBeforeFinish + \/ FCancelBefore \/ FGraphEnds \/ FCleanup \/ FAfter \/ FAfterDone + \/ FCrash \/ OrphanStops \/ DStart \/ DBeforeFinish \/ DCleanup + \/ DAfter \/ DAfterDone \/ DCrash \/ NewUp +Spec == Init /\ [][Next]_vars + +States == {"absent", "owed", "beforeStarted", "beforeFailed", "afterOwed", + "afterStarted", "complete"} +TypeOK == /\ graph \in {"upAfter", "ready", "stopped"} + /\ jState \in States /\ jGen \in {"none", "F", "D"} + /\ fpc \in {"upAfter", "publishing", "serving", "before", "stopping", + "after", "afterRunning", "done", "dead"} + /\ dpc \in {"idle", "before", "cleanup", "after", "afterRunning", + "done", "failed", "refused", "dead"} + /\ dRuns \in 0..2 /\ dSaved \in States \X {"none", "F", "D"} + /\ interrupts \in 0..2 /\ upInterrupted \in BOOLEAN + /\ beforeOpen \in 0..3 /\ beforeOk \in BOOLEAN + /\ afterOpen \in 0..2 /\ afterDone \in BOOLEAN /\ fHooks \in BOOLEAN + /\ newGen \in BOOLEAN /\ unsafeBefore \in BOOLEAN + /\ unsafeAfter \in BOOLEAN /\ unsafeGen \in BOOLEAN + /\ unsafeOwe \in BOOLEAN +\* down.before starts at most once except to retry a known failure; +\* down.after only after Removed and a known successful down.before, once; +\* a started-unknown phase blocks a new generation; +\* Ctrl-C during up.after never runs, or leaves owed, the frontend's down hooks. +Safe == /\ ~unsafeBefore /\ ~unsafeAfter /\ ~unsafeGen /\ ~unsafeOwe + /\ (upInterrupted => ~fHooks) +NeverComplete == ~(jState = "complete" /\ afterDone) +\* Reachability: after a frontend crash, down runs the owed hooks once. +NeverRecovered == ~(fpc = "dead" /\ jState = "complete" /\ jGen = "D") +============================================================================= diff --git a/tests/models/tla/native-foreground-down-hooks/complete-reachable.cfg b/tests/models/tla/native-foreground-down-hooks/complete-reachable.cfg new file mode 100644 index 000000000..ff67f77c4 --- /dev/null +++ b/tests/models/tla/native-foreground-down-hooks/complete-reachable.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceJournal = TRUE + EnforceUnknownBlock = TRUE + RevertOwed = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe + NeverComplete +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-foreground-down-hooks/negative.cfg b/tests/models/tla/native-foreground-down-hooks/negative.cfg new file mode 100644 index 000000000..26a0207ce --- /dev/null +++ b/tests/models/tla/native-foreground-down-hooks/negative.cfg @@ -0,0 +1,7 @@ +CONSTANTS EnforceJournal = FALSE + EnforceUnknownBlock = TRUE + RevertOwed = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-foreground-down-hooks/owed-after-interrupt.cfg b/tests/models/tla/native-foreground-down-hooks/owed-after-interrupt.cfg new file mode 100644 index 000000000..5aee0a2b7 --- /dev/null +++ b/tests/models/tla/native-foreground-down-hooks/owed-after-interrupt.cfg @@ -0,0 +1,7 @@ +CONSTANTS EnforceJournal = TRUE + EnforceUnknownBlock = TRUE + RevertOwed = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-foreground-down-hooks/positive.cfg b/tests/models/tla/native-foreground-down-hooks/positive.cfg new file mode 100644 index 000000000..c7cb89e36 --- /dev/null +++ b/tests/models/tla/native-foreground-down-hooks/positive.cfg @@ -0,0 +1,7 @@ +CONSTANTS EnforceJournal = TRUE + EnforceUnknownBlock = TRUE + RevertOwed = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-foreground-down-hooks/recovery-reachable.cfg b/tests/models/tla/native-foreground-down-hooks/recovery-reachable.cfg new file mode 100644 index 000000000..3cfdf17b6 --- /dev/null +++ b/tests/models/tla/native-foreground-down-hooks/recovery-reachable.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceJournal = TRUE + EnforceUnknownBlock = TRUE + RevertOwed = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe + NeverRecovered +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-foreground-down-hooks/unknown-generation.cfg b/tests/models/tla/native-foreground-down-hooks/unknown-generation.cfg new file mode 100644 index 000000000..531376594 --- /dev/null +++ b/tests/models/tla/native-foreground-down-hooks/unknown-generation.cfg @@ -0,0 +1,7 @@ +CONSTANTS EnforceJournal = TRUE + EnforceUnknownBlock = FALSE + RevertOwed = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/native-project-down-hooks.test.ts b/tests/native-project-down-hooks.test.ts new file mode 100644 index 000000000..6a9bddf66 --- /dev/null +++ b/tests/native-project-down-hooks.test.ts @@ -0,0 +1,512 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + admitNativeDownHookGeneration, + createNativeForegroundDownHooks, + loadNativeDownHookRecord, + NATIVE_DOWN_HOOK_UNKNOWN, + type NativeDownHookRecord, + nativeForegroundStopFailure, + openNativeDownHookSession, + replaceNativeDownHookRecord, +} from "../src/backends/native-project-down-hooks.ts"; +import { + type NativeProjectRun, + type NativeProjectRunScope, + nativeProjectDownHookPaths, + prepareNativeProjectRunStorage, +} from "../src/backends/native-project-run.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +const run: NativeProjectRun = { + run: "a".repeat(32), + owner: "b".repeat(32), + namespace: "c".repeat(64), + planId: "d".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, +}; +async function fixture(): Promise { + const projectRoot = await realpath( + await mkdtemp(join(tmpdir(), "native-down-hooks-")) + ); + roots.push(projectRoot); + const projectDir = join(projectRoot, ".hack"); + const nativeHome = join(projectRoot, "candidate"); + await mkdir(projectDir); + await mkdir(nativeHome, { mode: 0o700 }); + const scope = { projectRoot, projectDir, nativeHome, branch: null }; + await prepareNativeProjectRunStorage(scope); + return scope; +} +const record = ( + state: NativeDownHookRecord["state"], + generation = "e".repeat(32) +): NativeDownHookRecord => ({ run: run.run, generation, state }); +async function expectUnknown(promise: Promise): Promise { + const error = await promise.then( + () => undefined, + (failure: unknown) => failure + ); + expect(error).toBeInstanceOf(HackCliError); + expect((error as HackCliError).code).toBe("E_LIFECYCLE_FAILED"); + expect((error as Error).message).toBe(NATIVE_DOWN_HOOK_UNKNOWN); +} +function phases() { + const calls: string[] = []; + return { + calls, + before: (code = 0) => { + calls.push("down.before"); + return Promise.resolve(code); + }, + after: (code = 0) => { + calls.push("down.after"); + return Promise.resolve(code); + }, + }; +} + +test("the journal is a private value-free record replaced by compare-and-swap", async () => { + const scope = await fixture(); + expect(await loadNativeDownHookRecord(scope)).toBeNull(); + expect(await replaceNativeDownHookRecord(scope, null, record("owed"))).toBe( + true + ); + // A writer holding a stale expectation loses and changes nothing. + expect( + await replaceNativeDownHookRecord(scope, null, record("complete")) + ).toBe(false); + expect(await loadNativeDownHookRecord(scope)).toEqual(record("owed")); + const paths = await nativeProjectDownHookPaths(scope, false); + expect(JSON.parse(await readFile(paths.file, "utf8"))).toEqual({ + version: 1, + kind: "hack.native-project-down-hooks", + ...record("owed"), + }); + expect((await stat(paths.file)).mode & 0o777).toBe(0o600); + // Only the mapping directory's own files remain: no temp file or lock. + expect( + (await readdir(paths.root)).filter((name) => name.includes("down-hooks")) + ).toEqual([paths.file.slice(paths.root.length + 1)]); + expect(await replaceNativeDownHookRecord(scope, record("owed"), null)).toBe( + true + ); + expect(await loadNativeDownHookRecord(scope)).toBeNull(); +}); + +test("a tampered or widened record refuses as a closed integrity failure", async () => { + const scope = await fixture(); + await replaceNativeDownHookRecord(scope, null, record("owed")); + const { file } = await nativeProjectDownHookPaths(scope, false); + await chmod(file, 0o644); + await expect(loadNativeDownHookRecord(scope)).rejects.toThrow( + "Native down hook journal is unsafe or changed" + ); + await chmod(file, 0o600); + await Bun.write( + file, + JSON.stringify({ + version: 1, + kind: "hack.native-project-down-hooks", + ...record("owed"), + command: "synthetic", + }) + ); + await chmod(file, 0o600); + await expect(loadNativeDownHookRecord(scope)).rejects.toThrow( + "Native down hook journal is unsafe or changed" + ); +}); + +test("Ctrl-C on a ready graph runs down.before then down.after once", async () => { + const scope = await fixture(); + const hooks = createNativeForegroundDownHooks({ scope, run: run.run }); + const p = phases(); + await hooks.owe(() => false); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("owed"); + expect( + await hooks.before( + () => p.before(), + () => false + ) + ).toBe("succeeded"); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("after-owed"); + expect( + await hooks.after( + () => p.after(), + () => false + ) + ).toBe("succeeded"); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("complete"); + // A repeated stop request or finish never reruns a known phase. + expect( + await hooks.before( + () => p.before(), + () => false + ) + ).toBe("skipped"); + expect( + await hooks.after( + () => p.after(), + () => false + ) + ).toBe("skipped"); + expect(p.calls).toEqual(["down.before", "down.after"]); + // The stopped graph's later down runs nothing. + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: true, + configured: true, + }); + await session.before(async () => { + p.calls.push("down.before"); + }); + await session.after(async () => { + p.calls.push("down.after"); + }); + expect(p.calls).toEqual(["down.before", "down.after"]); +}); + +test("a failed or canceled down.before still stops, never owes down.after and is not rerun by down", async () => { + for (const [code, canceled, outcome] of [ + [1, false, "failed"], + [130, true, "canceled"], + ] as const) { + const scope = await fixture(); + const hooks = createNativeForegroundDownHooks({ scope, run: run.run }); + const p = phases(); + await hooks.owe(() => false); + expect( + await hooks.before( + () => p.before(code), + () => canceled + ) + ).toBe(outcome); + expect((await loadNativeDownHookRecord(scope))?.state).toBe( + "before-failed" + ); + expect( + await hooks.after( + () => p.after(), + () => false + ) + ).toBe("skipped"); + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: true, + configured: true, + }); + await session.before(async () => { + p.calls.push("down.before"); + }); + await session.after(async () => { + p.calls.push("down.after"); + }); + expect(p.calls).toEqual(["down.before"]); + } +}); + +test("an interrupt observed before readiness publication withdraws the owe", async () => { + const scope = await fixture(); + const hooks = createNativeForegroundDownHooks({ scope, run: run.run }); + await hooks.owe(() => true); + expect(await loadNativeDownHookRecord(scope)).toBeNull(); + const p = phases(); + expect( + await hooks.before( + () => p.before(), + () => false + ) + ).toBe("skipped"); + expect(p.calls).toEqual([]); +}); + +test("a frontend that crashed after up.after leaves owed hooks that down runs once", async () => { + const scope = await fixture(); + await createNativeForegroundDownHooks({ scope, run: run.run }).owe( + () => false + ); + const calls: string[] = []; + const once = async () => { + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: true, + configured: true, + }); + await session.before(async () => { + calls.push("down.before"); + }); + await session.after(async () => { + calls.push("down.after"); + }); + }; + await once(); + await once(); + expect(calls).toEqual(["down.before", "down.after"]); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("complete"); +}); + +test("a frontend that crashed between down hooks leaves only down.after owed", async () => { + const scope = await fixture(); + await replaceNativeDownHookRecord(scope, null, record("after-owed")); + const calls: string[] = []; + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: true, + configured: true, + }); + await session.before(async () => { + calls.push("down.before"); + }); + await session.after(async () => { + calls.push("down.after"); + }); + expect(calls).toEqual(["down.after"]); +}); + +test("a crash during a down hook is unknown: down and a new generation refuse without replay", async () => { + for (const state of ["before-started", "after-started"] as const) { + const scope = await fixture(); + await replaceNativeDownHookRecord(scope, null, record(state)); + for (const graphStopped of [true, false]) { + await expectUnknown( + openNativeDownHookSession({ + scope, + run, + graphStopped, + configured: true, + }) + ); + } + await expectUnknown(admitNativeDownHookGeneration(scope)); + expect(await loadNativeDownHookRecord(scope)).toEqual(record(state)); + } +}); + +test("a new generation supersedes any record that is not started", async () => { + for (const state of [ + "owed", + "before-failed", + "after-owed", + "complete", + ] as const) { + const scope = await fixture(); + await replaceNativeDownHookRecord(scope, null, record(state)); + await admitNativeDownHookGeneration(scope); + expect(await loadNativeDownHookRecord(scope)).toBeNull(); + } + // No storage yet means nothing to admit. + const empty = await realpath( + await mkdtemp(join(tmpdir(), "native-down-hooks-")) + ); + roots.push(empty); + await mkdir(join(empty, ".hack")); + await admitNativeDownHookGeneration({ + projectRoot: empty, + projectDir: join(empty, ".hack"), + nativeHome: empty, + branch: null, + }); +}); + +test("down on a live graph claims the frontend's owed hooks, and the frontend's Ctrl-C then skips them", async () => { + const scope = await fixture(); + const frontend = createNativeForegroundDownHooks({ scope, run: run.run }); + await frontend.owe(() => false); + const calls: string[] = []; + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: false, + configured: true, + }); + await session.before(async () => { + calls.push("down.before"); + }); + const p = phases(); + expect( + await frontend.before( + () => p.before(), + () => false + ) + ).toBe("skipped"); + await session.after(async () => { + calls.push("down.after"); + }); + expect( + await frontend.after( + () => p.after(), + () => false + ) + ).toBe("skipped"); + expect(calls).toEqual(["down.before", "down.after"]); + expect(p.calls).toEqual([]); +}); + +test("down during up.after owns the hooks; the frontend's later owe does not", async () => { + const scope = await fixture(); + const calls: string[] = []; + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: false, + configured: true, + }); + await session.before(async () => { + calls.push("down.before"); + }); + await session.after(async () => { + calls.push("down.after"); + }); + const frontend = createNativeForegroundDownHooks({ scope, run: run.run }); + await frontend.owe(() => false); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("complete"); + const p = phases(); + expect( + await frontend.before( + () => p.before(), + () => false + ) + ).toBe("skipped"); + expect(calls).toEqual(["down.before", "down.after"]); +}); + +test("a live down whose down.before fails restores the record so an explicit retry may run it", async () => { + const scope = await fixture(); + const frontend = createNativeForegroundDownHooks({ scope, run: run.run }); + await frontend.owe(() => false); + const owed = await loadNativeDownHookRecord(scope); + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: false, + configured: true, + }); + await expect( + session.before(() => Promise.reject(new Error("synthetic hook failure"))) + ).rejects.toThrow("synthetic hook failure"); + expect(await loadNativeDownHookRecord(scope)).toEqual(owed); +}); + +test("a known down.after failure is final and reported", async () => { + const scope = await fixture(); + await replaceNativeDownHookRecord(scope, null, record("after-owed")); + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: true, + configured: true, + }); + await expect( + session.after(() => Promise.reject(new Error("synthetic after failure"))) + ).rejects.toThrow("synthetic after failure"); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("complete"); +}); + +test("legacy or hook-free graphs keep today's behavior without a record", async () => { + const scope = await fixture(); + const calls: string[] = []; + for (const [graphStopped, configured] of [ + [true, true], + [false, false], + ] as const) { + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped, + configured, + }); + await session.before(async () => { + calls.push("down.before"); + }); + await session.after(async () => { + calls.push("down.after"); + }); + } + expect(calls).toEqual([]); + expect(await loadNativeDownHookRecord(scope)).toBeNull(); + // A configured live graph without a record (older frontend) runs both once. + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: false, + configured: true, + }); + await session.before(async () => { + calls.push("down.before"); + }); + await session.after(async () => { + calls.push("down.after"); + }); + expect(calls).toEqual(["down.before", "down.after"]); +}); + +test("a record of another run never authorizes this run's hooks", async () => { + const scope = await fixture(); + await replaceNativeDownHookRecord(scope, null, { + ...record("owed"), + run: "f".repeat(32), + }); + const calls: string[] = []; + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: true, + configured: true, + }); + await session.before(async () => { + calls.push("down.before"); + }); + await session.after(async () => { + calls.push("down.after"); + }); + expect(calls).toEqual([]); +}); + +test("stop reports keep the interrupted status for cancellation and name failures without values", () => { + expect( + nativeForegroundStopFailure({ before: "succeeded", after: "succeeded" }) + ).toBeUndefined(); + expect( + nativeForegroundStopFailure({ before: "canceled", after: "skipped" }) + ).toBeUndefined(); + const before = nativeForegroundStopFailure({ + before: "failed", + after: "skipped", + }); + expect(before).toBeInstanceOf(HackCliError); + expect((before as HackCliError).code).toBe("E_LIFECYCLE_FAILED"); + expect(String(before)).toContain("does not rerun it"); + const after = nativeForegroundStopFailure({ + before: "succeeded", + after: "failed", + }); + expect(String(after)).toContain("cleanup was confirmed"); + const refusal = new Error("synthetic refusal"); + expect( + nativeForegroundStopFailure({ before: "succeeded", after: refusal }) + ).toBe(refusal); +}); diff --git a/tests/native-project-down-journal.test.ts b/tests/native-project-down-journal.test.ts new file mode 100644 index 000000000..e7735c233 --- /dev/null +++ b/tests/native-project-down-journal.test.ts @@ -0,0 +1,254 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { nativeProjectDown } from "../src/backends/native-project-down.ts"; +import { + createNativeForegroundDownHooks, + loadNativeDownHookRecord, + NATIVE_DOWN_HOOK_UNKNOWN, + replaceNativeDownHookRecord, +} from "../src/backends/native-project-down-hooks.ts"; +import { beginNativeProjectFinalization } from "../src/backends/native-project-finalization.ts"; +import { + type NativeProjectRunScope, + saveNativeProjectRun, +} from "../src/backends/native-project-run.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +// `hack down` honors the foreground stop's down-hook journal: owed phases run +// once, completed ones are skipped and unknown completion refuses. + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +const run = { + run: "a".repeat(32), + owner: "b".repeat(32), + namespace: "c".repeat(64), + planId: "d".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, +}; +function snapshot(stopped: boolean) { + return { + receipt: { + ...run, + plan_id: run.planId, + phase: stopped ? "stopped-data-retained" : "ready-observed", + resources: { + "container:web": { + kind: "container", + key: "web", + id: "e".repeat(64), + phase: "started", + }, + }, + }, + journal_incomplete: false, + observations: { + "container:web": { state: stopped ? "absent" : "running" }, + }, + }; +} +async function fixture(): Promise<{ + readonly scope: NativeProjectRunScope; + readonly runtime: { readonly binary: string; readonly home: string }; +}> { + const projectRoot = await realpath( + await mkdtemp(join(tmpdir(), "native-down-journal-")) + ); + roots.push(projectRoot); + const projectDir = join(projectRoot, ".hack"), + nativeHome = join(projectRoot, "candidate"); + await mkdir(projectDir); + await mkdir(nativeHome, { mode: 0o700 }); + const scope = { projectRoot, projectDir, nativeHome, branch: null }; + await saveNativeProjectRun({ ...scope, run }); + const finalization = await beginNativeProjectFinalization({ scope, run }); + await finalization.complete(); + return { scope, runtime: { binary: "/not-invoked", home: nativeHome } }; +} +function recorder(stopped: boolean) { + const calls: string[] = []; + let cleaned = stopped; + return { + calls, + hooks: { + configured: true, + run: async (phase: { phase: string; graphStopped: boolean }) => { + calls.push(`${phase.phase}${phase.graphStopped ? ":stopped" : ""}`); + }, + }, + retireHostProcesses: async () => { + calls.push("retire-host"); + }, + invoke: async (request: { readonly args: readonly string[] }) => { + calls.push(request.args[1] ?? ""); + if (request.args[1] === "cleanup") { + cleaned = true; + return {}; + } + return snapshot(cleaned); + }, + }; +} + +test("down on a stopped graph runs hooks owed by a crashed frontend once, after finalization", async () => { + const opts = await fixture(); + await createNativeForegroundDownHooks({ + scope: opts.scope, + run: run.run, + }).owe(() => false); + const first = recorder(true); + expect((await nativeProjectDown({ ...opts, ...first })).status).toBe( + "stopped" + ); + expect(first.calls).toEqual([ + "inspect", + "retire-host", + "before:stopped", + "after:stopped", + ]); + const second = recorder(true); + await nativeProjectDown({ ...opts, ...second }); + expect(second.calls).toEqual(["inspect", "retire-host"]); +}); + +test("down on a stopped graph refuses unknown hook completion without replaying it", async () => { + const opts = await fixture(); + await replaceNativeDownHookRecord(opts.scope, null, { + run: run.run, + generation: "f".repeat(32), + state: "before-started", + }); + const recorded = recorder(true); + await expect(nativeProjectDown({ ...opts, ...recorded })).rejects.toThrow( + NATIVE_DOWN_HOOK_UNKNOWN + ); + expect(recorded.calls).not.toContain("before:stopped"); + expect((await loadNativeDownHookRecord(opts.scope))?.state).toBe( + "before-started" + ); +}); + +test("down on a live graph refuses unknown completion before hooks or cleanup", async () => { + const opts = await fixture(); + await replaceNativeDownHookRecord(opts.scope, null, { + run: run.run, + generation: "f".repeat(32), + state: "after-started", + }); + const recorded = recorder(false); + await expect(nativeProjectDown({ ...opts, ...recorded })).rejects.toThrow( + NATIVE_DOWN_HOOK_UNKNOWN + ); + expect(recorded.calls).toEqual(["inspect"]); +}); + +test("down on a live graph skips a down.before the frontend already completed", async () => { + const opts = await fixture(); + const frontend = createNativeForegroundDownHooks({ + scope: opts.scope, + run: run.run, + }); + await frontend.owe(() => false); + await frontend.before( + async () => 0, + () => false + ); + const recorded = recorder(false); + await nativeProjectDown({ ...opts, ...recorded }); + expect(recorded.calls).toEqual([ + "inspect", + "inspect", + "cleanup", + "inspect", + "retire-host", + // down.after always follows confirmed cleanup. + "after:stopped", + ]); + expect((await loadNativeDownHookRecord(opts.scope))?.state).toBe("complete"); +}); + +test("hack down runs a crashed frontend's owed hooks once through the real CLI", async () => { + const opts = await fixture(); + await writeFile( + join(opts.scope.projectDir, "hack.config.json"), + JSON.stringify({ + name: "fixture", + lifecycle: { + down: { + before: ["echo synthetic-down-before"], + after: ["echo synthetic-down-after"], + }, + }, + }) + ); + await writeFile( + join(opts.scope.projectDir, "docker-compose.yml"), + "services:\n web:\n image: fixture\n" + ); + const binary = join(opts.scope.projectRoot, "fake-native"); + await writeFile( + binary, + `#!${process.execPath}\nconsole.log(${JSON.stringify(JSON.stringify(snapshot(true)))})`, + { mode: 0o700 } + ); + await createNativeForegroundDownHooks({ + scope: opts.scope, + run: run.run, + }).owe(() => false); + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-down-journal-client-")) + ); + roots.push(root); + const down = async () => { + const child = Bun.spawn( + [ + process.execPath, + "index.ts", + "down", + "--json", + "--path", + opts.scope.projectRoot, + ], + { + env: privateCliEnv({ + root, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_HOME: opts.scope.nativeHome, + HACK_NATIVE_BINARY: binary, + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const timer = setTimeout(() => child.kill("SIGKILL"), 15_000); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, code }; + } finally { + clearTimeout(timer); + } + }; + const first = await down(); + expect(first.code).toBe(0); + expect(JSON.parse(first.stdout)).toMatchObject({ status: "stopped" }); + expect(first.stderr).toContain("synthetic-down-before"); + expect(first.stderr).toContain("synthetic-down-after"); + const second = await down(); + expect(second.code).toBe(0); + expect(second.stderr).not.toContain("synthetic-down-before"); + expect(second.stderr).not.toContain("synthetic-down-after"); +}, 40_000); diff --git a/tests/native-project-process.test.ts b/tests/native-project-process.test.ts index 6eefb7c95..028291f5b 100644 --- a/tests/native-project-process.test.ts +++ b/tests/native-project-process.test.ts @@ -520,3 +520,75 @@ test( }, CASE_TIMEOUT_MS ); + +test("a stop request after readiness runs beforeStop before the detached owner is asked to stop", async () => { + const opts = await fixture(` + process.on("SIGTERM", async () => { + const seen = await Bun.file(${JSON.stringify("BEFORE")}).exists(); + await Bun.write(${JSON.stringify("MARKER")}, seen ? "before-seen" : "before-missing"); + process.exit(0); + }); + console.log(JSON.stringify({kind:"graph_foreground_ready",run:"${run}"})); + await Bun.sleep(30000); + `); + const before = join(opts.projectRoot, "before-ran"); + const marker = join(opts.projectRoot, "owner-stopped"); + const file = join(opts.projectRoot, "fake.ts"); + await Bun.write( + file, + (await Bun.file(file).text()) + .replace("BEFORE", before) + .replace("MARKER", marker) + ); + const controller = new AbortController(); + const force = new AbortController(); + const code = await serveNativeProjectGraph({ + ...opts, + signal: controller.signal, + forceSignal: force.signal, + requireGroupSettlement: false, + beforeStop: async () => { + await Bun.write(before, "ran"); + return true; + }, + onReady: async () => { + setTimeout(() => controller.abort(), 20); + }, + }); + expect(code).toBe(0); + expect(await Bun.file(marker).text()).toBe("before-seen"); +}, 15_000); + +test("a second stop request during beforeStop forces the owner to stop", async () => { + const opts = await fixture(` + process.on("SIGTERM", () => process.exit(0)); + console.log(JSON.stringify({kind:"graph_foreground_ready",run:"${run}"})); + await Bun.sleep(30000); + `); + const controller = new AbortController(); + const force = new AbortController(); + let canceled = false; + await expectClosedRefusal( + serveNativeProjectGraph({ + ...opts, + signal: controller.signal, + forceSignal: force.signal, + requireGroupSettlement: false, + beforeStop: async () => { + setTimeout(() => force.abort(), 20); + await new Promise((resolve) => + force.signal.addEventListener("abort", () => resolve(), { + once: true, + }) + ); + canceled = true; + return true; + }, + onReady: async () => { + setTimeout(() => controller.abort(), 20); + }, + }), + "interrupted" + ); + expect(canceled).toBe(true); +}, 15_000); diff --git a/tests/native-project-start-stop-hooks.test.ts b/tests/native-project-start-stop-hooks.test.ts new file mode 100644 index 000000000..19042ebab --- /dev/null +++ b/tests/native-project-start-stop-hooks.test.ts @@ -0,0 +1,345 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadNativeDownHookRecord, + NATIVE_DOWN_HOOK_UNKNOWN, + replaceNativeDownHookRecord, +} from "../src/backends/native-project-down-hooks.ts"; +import { startNativeProject } from "../src/backends/native-project-start.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; + +// Foreground Ctrl-C runs the project's down hooks around graph cleanup, as +// `hack down` does on Compose. The fake graph owner below follows the serve +// contract: a stop request after readiness first awaits `beforeStop`; one +// observed before readiness publication takes the forced path without it. + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +type Options = Parameters[0]; +type Serve = NonNullable["serve"]>; +type ServeOptions = Parameters[0]; + +async function fixture(opts: { + readonly downBefore?: (signal: AbortSignal) => Promise; + readonly downAfter?: (signal: AbortSignal) => Promise; + readonly upAfter?: () => Promise; + /** Called once the fake owner is ready and waiting for a stop request. */ + readonly whenServing?: (serve: ServeOptions) => void; +}) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-stop-hooks-")) + ); + roots.push(root); + await mkdir(join(root, ".hack")); + await writeFile(join(root, "hack-relay-guest"), "synthetic test artifact"); + const events: string[] = []; + let run = ""; + let ready = false; + const planId = "a".repeat(64), + namespace = "b".repeat(64), + owner = "c".repeat(32); + const scope = { + projectRoot: root, + projectDir: join(root, ".hack"), + nativeHome: root, + branch: null, + }; + const serve: Serve = async (args) => { + run = args.run; + ready = true; + try { + await args.onReady(); + const forced = args.signal?.aborted === true; + opts.whenServing?.(args); + if (!args.signal?.aborted) { + await new Promise((resolve) => + args.signal?.addEventListener("abort", () => resolve(), { + once: true, + }) + ); + } + events.push("stop requested"); + if (!forced && args.beforeStop) { + expect(args.requireGroupSettlement).toBe(false); + await args.beforeStop(); + } + events.push("owner stop"); + return 0; + } finally { + ready = false; + } + }; + const startup: Options = { + runtime: { binary: join(root, "hack-local"), home: root }, + scope, + composeFile: join(root, ".hack/docker-compose.yml"), + sharedSource: true, + before: async () => { + events.push("up.before"); + return { + cleanup: async () => { + events.push("lifecycle stopped"); + }, + ready: async () => { + events.push("up.after"); + await opts.upAfter?.(); + }, + stop: { + before: + opts.downBefore ?? + (async () => { + events.push("down.before"); + return 0; + }), + after: + opts.downAfter ?? + (async () => { + events.push("down.after"); + return 0; + }), + }, + }; + }, + dependencies: { + prepareStorage: async () => {}, + retainedImages: async () => new Map(), + load: async () => null, + loadRestart: async () => null, + save: async () => { + events.push("save"); + }, + remove: async () => { + events.push("remove"); + }, + prepare: async () => ({ + originalSha256: "d".repeat(64), + normalizedComposeJson: JSON.stringify({ + services: { web: { image: `sha256:${"e".repeat(64)}` } }, + }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + environmentFiles: [], + effectiveEnvName: null, + serviceNames: ["web"], + }), + review: async (request) => + request.run({ + planId, + namespace, + projectArgs: ["--project", root], + report: { + plan: { + enrollment_compatible: true, + services: { web: { active: true } }, + }, + }, + }), + invoke: async (request) => { + if (request.args[1] === "check-project-share") { + return { source_admitted: true, pool_initialized: false }; + } + if (request.args[1] === "admit-image") { + return { reference: request.args[3], kind: "docker_hub" }; + } + if (request.args[1] === "dependency-plan") { + return { dependency_plan_id: "f".repeat(64) }; + } + if (request.args[1] === "inspect") { + events.push("graph inspect"); + return { + journal_incomplete: false, + receipt: { + run, + owner, + namespace, + plan_id: planId, + phase: ready ? "ready-observed" : "stopped-data-retained", + resources: { + "container:web": { + kind: "container", + key: "web", + id: "f".repeat(64), + phase: "started", + }, + }, + }, + observations: { + "container:web": { state: ready ? "running" : "absent" }, + }, + }; + } + return {}; + }, + serve, + }, + }; + return { startup, events, scope }; +} + +/** Deliver a real process signal event to the frontend's own listener only. */ +function interrupt(): void { + expect(process.listenerCount("SIGINT")).toBe(1); + process.emit("SIGINT"); +} + +test("Ctrl-C runs down.before, graph cleanup, lifecycle retirement and down.after in order", async () => { + const { startup, events, scope } = await fixture({ + whenServing: () => queueMicrotask(interrupt), + }); + expect(await startNativeProject(startup)).toBe(130); + const stop = events.slice(events.indexOf("stop requested")); + expect(stop).toEqual([ + "stop requested", + "down.before", + "owner stop", + "graph inspect", + "lifecycle stopped", + "down.after", + ]); + expect(events.filter((event) => event.startsWith("down."))).toHaveLength(2); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("complete"); + expect(process.listenerCount("SIGINT")).toBe(0); +}); + +test("a second Ctrl-C cancels a running down.before, still stops and never runs down.after", async () => { + let canceled = false; + const { startup, events, scope } = await fixture({ + whenServing: () => queueMicrotask(interrupt), + downBefore: async (signal) => { + events.push("down.before"); + interrupt(); + await new Promise((resolve) => + signal.aborted + ? resolve() + : signal.addEventListener("abort", () => resolve(), { once: true }) + ); + canceled = signal.aborted; + return 130; + }, + }); + expect(await startNativeProject(startup)).toBe(130); + expect(canceled).toBe(true); + expect(events).toContain("owner stop"); + expect(events).toContain("lifecycle stopped"); + expect(events).not.toContain("down.after"); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("before-failed"); +}); + +test("a failed down.before still stops the graph and reports a lifecycle failure", async () => { + const { startup, events, scope } = await fixture({ + whenServing: () => queueMicrotask(interrupt), + downBefore: async () => { + events.push("down.before"); + return 3; + }, + }); + const failure = await startNativeProject(startup).then( + () => undefined, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(HackCliError); + expect((failure as HackCliError).code).toBe("E_LIFECYCLE_FAILED"); + expect(String(failure)).toContain("graph was stopped and data retained"); + expect(events).toContain("owner stop"); + expect(events).toContain("lifecycle stopped"); + expect(events).not.toContain("down.after"); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("before-failed"); +}); + +test("a failed down.after is reported after confirmed cleanup and is not rerun", async () => { + const { startup, events, scope } = await fixture({ + whenServing: () => queueMicrotask(interrupt), + downAfter: async () => { + events.push("down.after"); + return 4; + }, + }); + await expect(startNativeProject(startup)).rejects.toThrow( + "Native down after hook failed; native graph cleanup was confirmed" + ); + expect(events.at(-1)).toBe("down.after"); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("complete"); +}); + +test("Ctrl-C during up.after never runs or owes down hooks", async () => { + const { startup, events, scope } = await fixture({ + upAfter: async () => interrupt(), + }); + expect(await startNativeProject(startup)).toBe(130); + expect(events.filter((event) => event.startsWith("down."))).toEqual([]); + expect(events).toContain("lifecycle stopped"); + expect(await loadNativeDownHookRecord(scope)).toBeNull(); +}); + +test("a graph that ends without a stop request leaves its down hooks owed for down", async () => { + const { startup, events, scope } = await fixture({}); + // The owner exits on its own: no interrupt reaches the frontend. + const run = startNativeProject({ + ...startup, + dependencies: { + ...startup.dependencies, + serve: async (args) => { + const original = startup.dependencies?.serve; + if (!original) { + throw new Error("missing fake owner"); + } + const controller = new AbortController(); + // Stop the fake owner without a frontend stop request. + queueMicrotask(() => controller.abort()); + return await original({ + ...args, + signal: controller.signal, + beforeStop: undefined, + }); + }, + }, + }); + expect(await run).toBe(0); + expect(events.filter((event) => event.startsWith("down."))).toEqual([]); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("owed"); +}); + +test("unknown down hook completion of an earlier generation refuses before any hook", async () => { + const { startup, events, scope } = await fixture({}); + await replaceNativeDownHookRecord(scope, null, { + run: "1".repeat(32), + generation: "2".repeat(32), + state: "before-started", + }); + const failure = await startNativeProject(startup).then( + () => undefined, + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(HackCliError); + expect((failure as Error).message).toBe(NATIVE_DOWN_HOOK_UNKNOWN); + expect(events).toEqual([]); +}); + +test("projects without down hooks keep the attached owner and need no journal", async () => { + const { startup, events, scope } = await fixture({ + whenServing: (serve) => { + expect(serve.beforeStop).toBeUndefined(); + expect(serve.forceSignal).toBeUndefined(); + queueMicrotask(interrupt); + }, + }); + const before = startup.before; + expect( + await startNativeProject({ + ...startup, + before: async (input) => { + const hooks = await before(input); + return { cleanup: hooks.cleanup, ready: hooks.ready }; + }, + }) + ).toBe(130); + expect(events.filter((event) => event.startsWith("down."))).toEqual([]); + expect(await loadNativeDownHookRecord(scope)).toBeNull(); +}); From fb36288ab030d75bec4065f4bbcdd74c3920346b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:42:26 -0500 Subject: [PATCH 013/138] test(native): use a neutral container path in file-member fixtures The privacy check flags Linux home paths; the fixture only needs a nested absolute target with a space. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/provider/environment.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/provider/environment.rs b/packages/runtime-core/src/provider/environment.rs index 43badba5e..5f565a1f2 100644 --- a/packages/runtime-core/src/provider/environment.rs +++ b/packages/runtime-core/src/provider/environment.rs @@ -832,13 +832,13 @@ mod tests { ); let largest = ManagedFile::new( "a.json", - "/home/app/.config/tool/credential file.json", + "/srv/app/.config/tool/credential file.json", zeroize::Zeroizing::new(vec![b'x'; MAX_ENVIRONMENT_FILE_BYTES]), ) .unwrap(); assert_eq!( largest.target(), - "/home/app/.config/tool/credential file.json" + "/srv/app/.config/tool/credential file.json" ); assert!(ManagedFile::new("a", "/runner/a", content()).is_ok()); assert!(ManagedFile::new("a", "/varrun/a", content()).is_ok()); From 901d3e3a26f4e7182f4621abaca9574d84aa295f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:46:23 -0500 Subject: [PATCH 014/138] feat(native): add a gcloud ADC file-mode adapter and AWS expiry warnings HACK_NATIVE_GCLOUD_ADC=file selects a gcloud Application Default Credentials adapter on the native Compose foreground. It runs after the login hooks, beside the AWS adapter. Services that bind ~/.config/gcloud/application_default_credentials.json read-only (short or long syntax; ~, ${HOME}, $HOME or the absolute home) lose that mount from public Compose. They receive the same bytes as a private mode-0400 file member at the authored target through the version 2 private envelope. The adapter reads only the fixed home ADC path: no symlink, owned by the user, not group- or world-writable, 1 to 16 KiB of UTF-8 JSON, unchanged during the read. It admits authorized_user and impersonated user credentials and refuses service_account keys, external accounts and any credential_source. GOOGLE_APPLICATION_CREDENTIALS must equal the target; CLOUDSDK_CONFIG, metadata-host and token-file selectors refuse. One `gcloud auth application-default print-access-token` proves the login (output discarded); an ADC change during startup refuses. The run mapping records only gcloud {mode: "file"}. Restart and retained restore keep it, and exec rechecks selectors without delivering anything. The AWS and gcloud adapters now share one private CLI helper: clean environment, stdout only with a 64 KiB cap, own process group, SIGKILL on a 30 s timeout, zero-filled buffers, no retry. AWS credentials stay a startup snapshot. The foreground now prints one value-free stderr line ten minutes before expiry and one at expiry, naming only the service count and the local time. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 53 +- src/backends/native-aws-environment.ts | 77 +-- src/backends/native-aws-expiry.ts | 102 ++++ src/backends/native-exec-environment.ts | 13 + src/backends/native-gcloud-environment.ts | 517 ++++++++++++++++++ src/backends/native-private-cli.ts | 102 ++++ src/backends/native-project-input.ts | 14 + .../native-project-restart-preflight.ts | 6 + src/backends/native-project-run.ts | 12 + src/backends/native-project-start.ts | 125 ++++- src/commands/project.ts | 2 + tests/native-aws-expiry.test.ts | 105 ++++ tests/native-exec-environment.test.ts | 36 ++ tests/native-gcloud-environment.test.ts | 463 ++++++++++++++++ tests/native-private-cli.test.ts | 95 ++++ tests/native-project-restart.test.ts | 4 + tests/native-project-run.test.ts | 35 ++ tests/native-project-start.test.ts | 93 ++++ 18 files changed, 1781 insertions(+), 73 deletions(-) create mode 100644 src/backends/native-aws-expiry.ts create mode 100644 src/backends/native-gcloud-environment.ts create mode 100644 src/backends/native-private-cli.ts create mode 100644 tests/native-aws-expiry.test.ts create mode 100644 tests/native-gcloud-environment.test.ts create mode 100644 tests/native-private-cli.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..1b1b98dd9 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -968,7 +968,58 @@ service-scoped AWS adapter after the normal login hook. It removes only recogniz AWS mounts/selectors and supplies temporary credentials through private stdin. Expired or unsupported profiles fail startup; credentials are not persisted or refreshed automatically. Unrecognized mounts and other unsupported bindings still -refuse admission. +refuse admission. Because the exported credentials are a startup snapshot, the +foreground prints one value-free line to stderr ten minutes before they expire and +another at expiry: `AWS credentials delivered to N services expire at HH:MM; run +hack restart to renew.` `hack exec` keeps exporting fresh values for each request. + +### gcloud Application Default Credentials (file mode) + +For a project whose services bind the gcloud ADC file read-only, for example +`~/.config/gcloud/application_default_credentials.json:/config/adc.json:ro`, +explicit `HACK_NATIVE_GCLOUD_ADC=file` applies the gcloud adapter after the normal +login hook, beside the AWS adapter. Accepted sources are that path written with +`~`, `${HOME}`, `$HOME` or the absolute home directory, in short syntax with `:ro` +or long syntax `{type: bind, source, target, read_only: true}` with no other keys. +The target must be an absolute container file path outside `/proc`, `/sys`, `/dev`, +`/run` and `/var/run` that no other mount or tmpfs of the service equals, contains +or sits under. Writable or directory mounts, other gcloud paths and two matches in +one service refuse; so does selecting the adapter when no service matches. +`GOOGLE_APPLICATION_CREDENTIALS`, when declared, must equal the mount target and +stays public. `CLOUDSDK_CONFIG`, `CLOUDSDK_AUTH_ACCESS_TOKEN_FILE`, +`CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE`, `GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES`, +`GCE_METADATA_HOST` and `GCE_METADATA_IP` in a selected service refuse. Ambient +`GOOGLE_APPLICATION_CREDENTIALS` and `CLOUDSDK_CONFIG` on the host are not consulted. + +The adapter reads only `/.config/gcloud/application_default_credentials.json`: +a regular file (not a symlink) owned by you, not group- or world-writable, 1 to 16 KiB +of UTF-8 JSON, unchanged during the read. It admits `authorized_user` credentials and +`impersonated_service_account` credentials whose source is `authorized_user`. +`service_account` key files are refused, as are `external_account` types and any +`credential_source`, whose host files, URLs or executables do not exist in the guest. +It then runs `gcloud auth application-default print-access-token` once with prompts +disabled, a clean environment and a 30-second limit, and discards the token unread. +A missing `gcloud` refuses with a message naming the CLI; a failed mint or an ADC +file that changed during startup refuses. Other refusals use one closed line with +values omitted. + +Each selected service then loses the bind mount from public Compose and receives +the same bytes as a private file member through the version 2 private envelope. The +runtime stages it mode 0400 for the container's numeric user in that container's +environment allocation and binds it read-only at the authored target. A service +with no other private values keeps its own entrypoint and health check; only a +numeric image or Compose user is required. + +File mode places the ADC refresh token inside the VM for the container's lifetime. +That is no wider than the Compose bind mount it replaces: only the selected services +receive it, as a read-only snapshot owned by the container user, retired with the +container. Google client libraries refresh access tokens from it over normal +egress. Host re-login or revocation is not propagated; run `hack restart` to deliver +a fresh snapshot. The run mapping records only `gcloud: {"mode": "file"}`; restart +and retained restore keep that selection and deliver a fresh copy into new +allocations. `hack exec` delivers nothing new: the file is already in the +container, and exec only rechecks the mount and selectors. One-off `run` does not +apply the adapter. A mapping with a gcloud selection refuses on earlier candidates. New immutable source captures use manifest schema 2: the revision binds both selected content and its selection receipt. This prevents an ignored mountpoint diff --git a/src/backends/native-aws-environment.ts b/src/backends/native-aws-environment.ts index 4c93af566..426caf23a 100644 --- a/src/backends/native-aws-environment.ts +++ b/src/backends/native-aws-environment.ts @@ -1,11 +1,10 @@ -import { spawn } from "node:child_process"; import { join } from "node:path"; import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveUserHomeDir } from "../lib/home-dir.ts"; +import { runNativePrivateCli } from "./native-private-cli.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; -const MAX_OUTPUT = 64 * 1024; const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; const PROFILE = /^[A-Za-z0-9_+=,.@-]{1,128}$/; const REGION = /^[a-z]{2}(?:-[a-z]+)+-\d+$/; @@ -31,72 +30,22 @@ export type NativeAwsExportSource = (opts: { }) => Promise<{ readonly credentials: unknown; readonly region?: string }>; /** Only stdout is captured privately; AWS diagnostics and ambient AWS values are omitted. */ -function awsCommand(args: readonly string[]): Promise { - return new Promise((resolve, reject) => { - const env: Record = { +async function awsCommand(args: readonly string[]): Promise { + const output = await runNativePrivateCli({ + executable: "aws", + args, + environment: { AWS_EC2_METADATA_DISABLED: "true", AWS_CLI_AUTO_PROMPT: "off", AWS_PAGER: "", - }; - for (const key of ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]) { - const value = process.env[key]; - if (value !== undefined) { - env[key] = value; - } - } - const child = spawn("aws", [...args], { - env, - detached: true, - stdio: ["ignore", "pipe", "ignore"], - }); - const chunks: Buffer[] = []; - let size = 0; - let settled = false; - const stop = () => { - // The unreaped direct child starts a new POSIX group owned by this request. - if ( - child.pid !== undefined && - child.exitCode === null && - child.signalCode === null - ) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - // Already exited; never use a persisted PID or retry the AWS request. - } - } - child.stdout?.destroy(); - }; - const finish = (ok: boolean) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - stop(); - if (ok) { - resolve(Buffer.concat(chunks).toString("utf8")); - } else { - reject(refused()); - } - for (const chunk of chunks) { - chunk.fill(0); - } - }; - const timer = setTimeout(() => finish(false), 30_000); - child.on("error", () => finish(false)); - child.stdout?.on("error", () => finish(false)); - child.stdout?.on("data", (chunk: Buffer) => { - size += chunk.byteLength; - if (size > MAX_OUTPUT) { - chunk.fill(0); - finish(false); - return; - } - chunks.push(chunk); - }); - child.on("close", (code) => finish(code === 0)); + }, + refused, }); + try { + return output.toString("utf8"); + } finally { + output.fill(0); + } } const exportAwsProfile: NativeAwsExportSource = async ({ diff --git a/src/backends/native-aws-expiry.ts b/src/backends/native-aws-expiry.ts new file mode 100644 index 000000000..73778df23 --- /dev/null +++ b/src/backends/native-aws-expiry.ts @@ -0,0 +1,102 @@ +/** Lead time for the advance warning before delivered AWS credentials expire. */ +export const NATIVE_AWS_EXPIRY_LEAD_MS = 10 * 60_000; +/** Timers above this delay are not scheduled (platform timer limit). */ +const MAX_TIMER_MS = 2_147_483_647; + +/** Opaque timer handle; unreferenced when the platform supports it. */ +type Timer = unknown; +function unref(timer: Timer): void { + if ( + typeof timer === "object" && + timer !== null && + "unref" in timer && + typeof timer.unref === "function" + ) { + timer.unref(); + } +} +export type NativeAwsExpiryClock = { + readonly now: () => number; + readonly setTimer: (callback: () => void, delayMs: number) => Timer; + readonly clearTimer: (timer: Timer) => void; +}; +const SYSTEM_CLOCK: NativeAwsExpiryClock = { + now: () => Date.now(), + setTimer: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimer: (timer) => + clearTimeout(timer as Parameters[0]), +}; + +function clock(expiry: number): string { + const date = new Date(expiry); + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +/** + * Value-free warning lines for a foreground run whose AWS credentials were + * delivered once at startup. Only the service count and local expiry time appear. + */ +export function nativeAwsExpiryMessage(opts: { + readonly services: number; + readonly expiry: number; + readonly expired: boolean; +}): string { + const subject = `AWS credentials delivered to ${opts.services} ${opts.services === 1 ? "service" : "services"}`; + return opts.expired + ? `${subject} expired at ${clock(opts.expiry)}; run hack restart to renew.` + : `${subject} expire at ${clock(opts.expiry)}; run hack restart to renew.`; +} + +/** + * Schedule one warning ten minutes before `expiry` and one at `expiry`. A + * warning whose time has already passed at scheduling is written immediately + * (only the most recent one). Timers are unreferenced and the returned function + * cancels any that have not fired. This changes no ownership or runtime state + * and does not renew credentials. + */ +export function scheduleNativeAwsExpiryWarnings(opts: { + readonly expiry: string; + readonly services: number; + readonly write: (line: string) => void; + readonly clock?: NativeAwsExpiryClock; +}): () => void { + const time = opts.clock ?? SYSTEM_CLOCK; + const expiry = Date.parse(opts.expiry); + if (!Number.isFinite(expiry) || opts.services < 1) { + return () => undefined; + } + const timers: Timer[] = []; + const now = time.now(); + const schedule = (at: number, expired: boolean) => { + const emit = () => + opts.write( + nativeAwsExpiryMessage({ services: opts.services, expiry, expired }) + ); + const delay = at - now; + if (delay > MAX_TIMER_MS) { + return; + } + if (delay <= 0) { + emit(); + return; + } + const timer = time.setTimer(emit, delay); + unref(timer); + timers.push(timer); + }; + if (expiry <= now) { + schedule(expiry, true); + } else { + if (expiry - NATIVE_AWS_EXPIRY_LEAD_MS > now) { + schedule(expiry - NATIVE_AWS_EXPIRY_LEAD_MS, false); + } else { + schedule(now, false); + } + schedule(expiry, true); + } + return () => { + for (const timer of timers.splice(0)) { + time.clearTimer(timer); + } + }; +} diff --git a/src/backends/native-exec-environment.ts b/src/backends/native-exec-environment.ts index ac0ed64f7..60c476709 100644 --- a/src/backends/native-exec-environment.ts +++ b/src/backends/native-exec-environment.ts @@ -1,6 +1,7 @@ import { basename } from "node:path"; import { HackCliError } from "../lib/cli-result.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; +import { adaptNativeGcloudEnvironment } from "./native-gcloud-environment.ts"; import { prepareNativeProjectInput } from "./native-project-input.ts"; import type { NativeProjectRun, @@ -28,6 +29,7 @@ export async function prepareNativeExecEnvironment(opts: { readonly service: string; readonly prepare?: typeof prepareNativeProjectInput; readonly adaptAws?: typeof adaptNativeAwsEnvironment; + readonly adaptGcloud?: typeof adaptNativeGcloudEnvironment; }): Promise>> { if ( !( @@ -74,6 +76,17 @@ export async function prepareNativeExecEnvironment(opts: { }) ).input; } + if (opts.run.gcloud) { + // Files already live in the running container; exec only rechecks the + // mount and selectors so its values match startup. Nothing is re-delivered. + input = ( + await (opts.adaptGcloud ?? adaptNativeGcloudEnvironment)({ + input, + mode: opts.run.gcloud.mode, + deliver: false, + }) + ).input; + } return input.managedEnvironment[opts.service] ?? {}; } catch (error) { throw new HackCliError({ diff --git a/src/backends/native-gcloud-environment.ts b/src/backends/native-gcloud-environment.ts new file mode 100644 index 000000000..340678234 --- /dev/null +++ b/src/backends/native-gcloud-environment.ts @@ -0,0 +1,517 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { join } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { resolveUserHomeDir } from "../lib/home-dir.ts"; +import { runNativePrivateCli } from "./native-private-cli.ts"; +import type { + NativeManagedFile, + NativeProjectInput, +} from "./native-project-input.ts"; + +const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; +const MAX_ADC_BYTES = 16 * 1024; +const MAX_TARGET = 1024; +const MEMBER = "gcloud-adc.json"; +const ADC_RELATIVE = ".config/gcloud/application_default_credentials.json"; +const SELECTOR = "GOOGLE_APPLICATION_CREDENTIALS"; +/** Host-path, metadata and executable selectors that would bypass the delivered file. */ +const REFUSED_SELECTORS = [ + "CLOUDSDK_CONFIG", + "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE", + "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE", + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES", + "GCE_METADATA_HOST", + "GCE_METADATA_IP", +]; +const RESERVED_ROOTS = ["/proc", "/sys", "/dev", "/run", "/var/run"]; +const PRINTABLE = /^[\x20-\x7e]+$/; + +/** Supported gcloud adapter modes. `file` delivers the ADC file itself. */ +export type NativeGcloudMode = "file"; +export type NativeGcloudSelection = { readonly mode: NativeGcloudMode }; + +function refused(): Error { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native gcloud adaptation refused: verify application-default login, the supported read-only ADC mount and selectors; values omitted.", + }); +} +/** Distinct refusal for an absent CLI so the next step is clear; carries no output. */ +class MissingGcloudCli extends HackCliError {} +function missingCli(): Error { + return new MissingGcloudCli({ + code: "E_CONFIG_INVALID", + message: + "Native gcloud adaptation refused: application-default credentials require the gcloud CLI; values omitted.", + }); +} + +/** Parse the explicit selector value; only `file` is accepted. */ +export function parseNativeGcloudSelection( + value: string | undefined +): NativeGcloudSelection | undefined { + if (value === undefined || value === "") { + return; + } + if (value !== "file") { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "HACK_NATIVE_GCLOUD_ADC accepts only `file`.", + }); + } + return { mode: "file" }; +} + +function within(path: string, parent: string): boolean { + return parent === "/" || path.startsWith(`${parent}/`); +} +/** Absolute, normalized container file path outside reserved runtime roots. */ +function validTarget(target: unknown): target is string { + return ( + typeof target === "string" && + target.length > 1 && + target.length <= MAX_TARGET && + target.startsWith("/") && + PRINTABLE.test(target) && + !target.includes("\\") && + target + .slice(1) + .split("/") + .every((part) => part !== "" && part !== "." && part !== "..") && + !RESERVED_ROOTS.some((root) => target === root || within(target, root)) + ); +} + +function mentionsAdc(value: unknown): boolean { + return ( + typeof value === "string" && + (value.includes("gcloud") || + value.includes("application_default_credentials")) + ); +} + +function shortAdcMount( + value: string, + sources: ReadonlySet +): string | undefined { + if (!mentionsAdc(value)) { + return; + } + const [source, target, mode, extra] = value.split(":"); + if ( + !(source && sources.has(source) && validTarget(target)) || + mode !== "ro" || + extra !== undefined + ) { + throw refused(); + } + return target; +} +function longAdcMount( + value: Record, + sources: ReadonlySet +): string | undefined { + if (!(mentionsAdc(value.source) || mentionsAdc(value.target))) { + return; + } + if ( + typeof value.source !== "string" || + !sources.has(value.source) || + !validTarget(value.target) || + value.type !== "bind" || + value.read_only !== true || + Object.keys(value).some( + (key) => !["type", "source", "target", "read_only"].includes(key) + ) + ) { + throw refused(); + } + return value.target; +} +/** Returns the container target of a recognized ADC mount, undefined for an unrelated mount. */ +function adcMount(value: unknown, home: string): string | undefined { + const sources = new Set([ + `~/${ADC_RELATIVE}`, + `\${HOME}/${ADC_RELATIVE}`, + `$HOME/${ADC_RELATIVE}`, + join(home, ADC_RELATIVE), + ]); + if (typeof value === "string") { + return shortAdcMount(value, sources); + } + if (!isRecord(value)) { + throw refused(); + } + return longAdcMount(value, sources); +} + +function mountTarget(mount: unknown): string { + if (typeof mount === "string") { + const parts = mount.split(":"); + return (parts.length === 1 ? parts[0] : parts[1]) ?? ""; + } + return isRecord(mount) && typeof mount.target === "string" + ? mount.target + : ""; +} +function tmpfsEntries(value: unknown): readonly unknown[] { + if (typeof value === "string") { + return [value]; + } + return Array.isArray(value) ? value : []; +} +/** Container targets claimed by other mounts and tmpfs entries; "" marks an unparsable one. */ +function otherTargets( + service: Record, + skip: unknown +): string[] { + const volumes = Array.isArray(service.volumes) ? service.volumes : []; + return [ + ...volumes.filter((mount) => mount !== skip).map(mountTarget), + ...tmpfsEntries(service.tmpfs).map((entry) => + typeof entry === "string" ? (entry.split(":")[0] ?? "") : "" + ), + ]; +} + +function serviceEnvironment(value: unknown): Record { + if (value === undefined) { + return {}; + } + if (isRecord(value)) { + return { ...value }; + } + if (!Array.isArray(value)) { + throw refused(); + } + const output: Record = {}; + for (const entry of value) { + if (typeof entry !== "string") { + throw refused(); + } + const equal = entry.indexOf("="); + const key = equal < 0 ? entry : entry.slice(0, equal); + if (!KEY.test(key) || Object.hasOwn(output, key)) { + throw refused(); + } + output[key] = equal < 0 ? null : entry.slice(equal + 1); + } + return output; +} + +/** + * `GOOGLE_APPLICATION_CREDENTIALS`, when declared, must name exactly the mount + * target; it is kept as a public value. Selectors that point elsewhere refuse. + */ +function applySelectors( + environment: Record, + privateValues: Record, + target: string +): void { + for (const key of REFUSED_SELECTORS) { + if (Object.hasOwn(environment, key) || Object.hasOwn(privateValues, key)) { + throw refused(); + } + } + const declared = Object.hasOwn(environment, SELECTOR); + const managed = Object.hasOwn(privateValues, SELECTOR); + if (managed) { + if (privateValues[SELECTOR] !== target || environment[SELECTOR] !== null) { + throw refused(); + } + delete privateValues[SELECTOR]; + environment[SELECTOR] = target; + } else if (declared && environment[SELECTOR] !== target) { + throw refused(); + } +} + +type Prepared = { + readonly compose: Record & { + services: Record; + }; + readonly managed: Record>; + readonly targets: ReadonlyMap; +}; + +/** The single recognized ADC mount of one service, or undefined when it has none. */ +function serviceAdcMount( + service: Record, + home: string +): { readonly mount: unknown; readonly target: string } | undefined { + if (service.volumes === undefined) { + return; + } + if (!Array.isArray(service.volumes)) { + throw refused(); + } + const matches = service.volumes.flatMap((mount) => { + const target = adcMount(mount, home); + return target === undefined ? [] : [{ mount, target }]; + }); + if (matches.length > 1) { + throw refused(); + } + return matches[0]; +} +function claimed( + service: Record, + match: { readonly mount: unknown; readonly target: string }, + existing: readonly NativeManagedFile[] +): boolean { + const { target } = match; + return ( + otherTargets(service, match.mount).some( + (other) => + other === "" || + other === target || + within(target, other) || + within(other, target) + ) || existing.some((file) => file.target === target || file.name === MEMBER) + ); +} + +function prepareServices(input: NativeProjectInput, home: string): Prepared { + const compose: unknown = JSON.parse(input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw refused(); + } + const services = compose.services; + const managed: Record> = { + ...input.managedEnvironment, + }; + const targets = new Map(); + for (const [name, service] of Object.entries(services)) { + if (!isRecord(service)) { + throw refused(); + } + const match = serviceAdcMount(service, home); + if (!match) { + continue; + } + if (claimed(service, match, input.managedFiles?.[name] ?? [])) { + throw refused(); + } + service.volumes = Array.isArray(service.volumes) + ? service.volumes.filter((mount) => mount !== match.mount) + : []; + const environment = serviceEnvironment(service.environment); + const privateValues = { ...managed[name] }; + applySelectors(environment, privateValues, match.target); + service.environment = environment; + if (Object.keys(privateValues).length > 0) { + managed[name] = privateValues; + } else { + delete managed[name]; + } + targets.set(name, match.target); + } + if (targets.size === 0) { + throw refused(); + } + return { compose: { ...compose, services }, managed, targets }; +} + +type FileIdentity = string; +function identity(stat: { + dev: number; + ino: number; + size: number; + mtimeMs: number; + ctimeMs: number; +}): FileIdentity { + return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); +} + +/** + * Read the fixed host ADC file: no symlink final component, a regular file owned + * by this user, not group- or world-writable, 1..16 KiB, unchanged during the read. + */ +async function readAdc( + path: string +): Promise<{ readonly bytes: Buffer; readonly identity: FileIdentity }> { + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const before = await file.stat(); + if ( + !before.isFile() || + before.uid !== process.getuid?.() || + (before.mode & 0o022) !== 0 || + before.size < 1 || + before.size > MAX_ADC_BYTES + ) { + throw refused(); + } + const buffer = Buffer.alloc(MAX_ADC_BYTES + 1); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + const after = await file.stat(); + if (bytesRead !== before.size || identity(after) !== identity(before)) { + buffer.fill(0); + throw refused(); + } + const bytes = Buffer.from(buffer.subarray(0, bytesRead)); + buffer.fill(0); + return { bytes, identity: identity(before) }; + } finally { + await file.close(); + } +} +async function currentIdentity(path: string): Promise { + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + return identity(await file.stat()); + } finally { + await file.close(); + } +} + +function hasCredentialSource(value: unknown, depth = 0): boolean { + if (depth > 16) { + return true; + } + if (Array.isArray(value)) { + return value.some((entry) => hasCredentialSource(entry, depth + 1)); + } + if (!isRecord(value)) { + return false; + } + return Object.entries(value).some( + ([key, entry]) => + key === "credential_source" || hasCredentialSource(entry, depth + 1) + ); +} +function authorizedUser(value: unknown): boolean { + return ( + isRecord(value) && + value.type === "authorized_user" && + ["client_id", "client_secret", "refresh_token"].every( + (key) => typeof value[key] === "string" && value[key] !== "" + ) + ); +} + +/** + * Admit only user credentials. `service_account` key files are refused by default + * (long-lived private keys); external-account types refuse because their + * credential sources name host files, URLs or executables absent in the guest. + */ +export function nativeGcloudCredentialType(bytes: Buffer): string { + let value: unknown; + try { + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + throw refused(); + } + if (!isRecord(value) || hasCredentialSource(value)) { + throw refused(); + } + if (authorizedUser(value)) { + return "authorized_user"; + } + if ( + value.type === "impersonated_service_account" && + typeof value.service_account_impersonation_url === "string" && + value.service_account_impersonation_url.startsWith("https://") && + authorizedUser(value.source_credentials) + ) { + return "impersonated_service_account"; + } + throw refused(); +} + +/** Liveness proof: one access-token mint whose output is discarded unread. */ +export type NativeGcloudMint = () => Promise; +const mintAccessToken: NativeGcloudMint = async () => { + const token = await runNativePrivateCli({ + executable: "gcloud", + args: ["auth", "application-default", "print-access-token"], + environment: { CLOUDSDK_CORE_DISABLE_PROMPTS: "1" }, + refused, + missing: missingCli, + }); + token.fill(0); +}; + +/** + * Explicit gcloud Application Default Credentials adaptation, file mode. + * + * Only services whose authored Compose declares a recognized read-only bind of + * `~/.config/gcloud/application_default_credentials.json` are selected. Their + * mount is removed from public Compose and the same bytes are delivered as a + * private mode-0400 file at the authored target, read-only, for the container's + * lifetime. That places the ADC refresh token in the VM; it is no wider than the + * Compose bind mount it replaces and is a startup snapshot: host re-login or + * revocation is not propagated until restart. + * + * With `deliver: false` only recognition and selector checks run (no host read, + * no gcloud call, no files); exec and other value-free rechecks use this. + * The receipt names the mode, admitted types and services only. + */ +export async function adaptNativeGcloudEnvironment(opts: { + readonly input: NativeProjectInput; + readonly mode: NativeGcloudMode; + readonly deliver?: boolean; + readonly homeDirectory?: string; + readonly mint?: NativeGcloudMint; +}): Promise<{ + readonly input: NativeProjectInput; + readonly receipt: { + readonly mode: NativeGcloudMode; + readonly types: readonly string[]; + readonly services: readonly string[]; + }; +}> { + let bytes: Buffer | undefined; + try { + if (opts.mode !== "file") { + throw refused(); + } + const home = opts.homeDirectory ?? resolveUserHomeDir(); + const { compose, managed, targets } = prepareServices(opts.input, home); + const services = [...targets.keys()].sort(); + const output = { + ...opts.input, + normalizedComposeJson: JSON.stringify(compose), + managedEnvironment: managed, + }; + if (opts.deliver === false) { + return { input: output, receipt: { mode: "file", types: [], services } }; + } + const path = join(home, ADC_RELATIVE); + const read = await readAdc(path); + bytes = read.bytes; + const type = nativeGcloudCredentialType(bytes); + await (opts.mint ?? mintAccessToken)(); + if ((await currentIdentity(path)) !== read.identity) { + throw refused(); + } + const files: Record = { + ...opts.input.managedFiles, + }; + for (const [name, target] of targets) { + files[name] = [ + ...(files[name] ?? []), + { name: MEMBER, target, content: bytes }, + ]; + } + return { + input: { ...output, managedFiles: files }, + receipt: { mode: "file", types: [type], services }, + }; + } catch (error) { + bytes?.fill(0); + if (error instanceof MissingGcloudCli) { + throw error; + } + throw refused(); + } +} diff --git a/src/backends/native-private-cli.ts b/src/backends/native-private-cli.ts new file mode 100644 index 000000000..93045fb93 --- /dev/null +++ b/src/backends/native-private-cli.ts @@ -0,0 +1,102 @@ +import { spawn } from "node:child_process"; + +const MAX_OUTPUT = 64 * 1024; +const TIMEOUT_MS = 30_000; +const INHERITED = ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"] as const; + +/** + * Run one credential CLI request and return its stdout privately. + * + * Contract shared by the native credential adapters: + * - The child gets a clean environment: only PATH, HOME, TMPDIR, USER and LOGNAME + * from this process plus the caller's fixed `environment`. Ambient credential + * selectors (AWS_*, CLOUDSDK_*, GOOGLE_*) never reach it. + * - stdin and stderr are ignored; only stdout is captured, capped at 64 KiB. + * - The child leads its own process group. Timeout (30 s), oversize output, + * a stream error or settlement kills that group with SIGKILL; a persisted PID + * is never used and the request is never retried. + * - Captured chunks are zero-filled after copying. The caller owns the returned + * buffer and must zero-fill it when done. + * + * Every failure rejects with `refused()`, except a missing executable, which + * rejects with `missing()` when supplied. Neither error carries output. + */ +export function runNativePrivateCli(opts: { + readonly executable: string; + readonly args: readonly string[]; + readonly environment: Readonly>; + readonly refused: () => Error; + readonly missing?: () => Error; + readonly timeoutMs?: number; +}): Promise { + return new Promise((resolve, reject) => { + const env: Record = { ...opts.environment }; + for (const key of INHERITED) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + const child = spawn(opts.executable, [...opts.args], { + env, + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }); + const chunks: Buffer[] = []; + let size = 0; + let settled = false; + const stop = () => { + // The unreaped direct child starts a new POSIX group owned by this request. + if ( + child.pid !== undefined && + child.exitCode === null && + child.signalCode === null + ) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Already exited; never use a persisted PID or retry the request. + } + } + child.stdout?.destroy(); + }; + const finish = (outcome: "ok" | "refused" | "missing") => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + stop(); + if (outcome === "ok") { + resolve(Buffer.concat(chunks)); + } else { + reject( + outcome === "missing" && opts.missing + ? opts.missing() + : opts.refused() + ); + } + for (const chunk of chunks) { + chunk.fill(0); + } + }; + const timer = setTimeout( + () => finish("refused"), + opts.timeoutMs ?? TIMEOUT_MS + ); + child.on("error", (error: NodeJS.ErrnoException) => + finish(error.code === "ENOENT" ? "missing" : "refused") + ); + child.stdout?.on("error", () => finish("refused")); + child.stdout?.on("data", (chunk: Buffer) => { + size += chunk.byteLength; + if (size > MAX_OUTPUT) { + chunk.fill(0); + finish("refused"); + return; + } + chunks.push(chunk); + }); + child.on("close", (code) => finish(code === 0 ? "ok" : "refused")); + }); +} diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index ab83234d9..53defecad 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -12,6 +12,16 @@ import { const LIMIT = 256 * 1024; const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** + * One private file member delivered into a service's environment allocation and + * bound read-only at `target`. `content` is private; never log or persist it. + */ +export type NativeManagedFile = { + readonly name: string; + readonly target: string; + readonly content: Buffer; +}; + export type NativeProjectInput = { readonly originalSha256: string; readonly normalizedComposeJson: string; @@ -19,6 +29,10 @@ export type NativeProjectInput = { readonly managedEnvironment: Readonly< Record>> >; + /** Private file members by service (credential adapters only); never logged. */ + readonly managedFiles?: Readonly< + Record + >; /** Private lifecycle environment; not container environment. */ readonly lifecycleHostEnvironment: Readonly>; readonly effectiveEnvName: string | null; diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..6acddc89c 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; +import { adaptNativeGcloudEnvironment } from "./native-gcloud-environment.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; import { discoverNativeHostDependency, @@ -55,6 +56,7 @@ const DEFAULTS = { prepare: prepareNativeProjectInput, adapt: prepareNativeProjectAdaptation, aws: adaptNativeAwsEnvironment, + gcloud: adaptNativeGcloudEnvironment, dependencies: readNativeHostDependencies, invoke: invokeNativeRuntime, review: withNativeProjectReview, @@ -224,6 +226,7 @@ export function nativeRestartSelection(opts: { envName: run.effectiveEnvName, profiles: run.profiles, aws: run.aws ?? undefined, + gcloud: run.gcloud ?? undefined, }; } @@ -381,6 +384,9 @@ export async function preflightNativeRestart(opts: { if (selected.aws) { input = (await deps.aws({ input, ...selected.aws })).input; } + if (selected.gcloud) { + input = (await deps.gcloud({ input, mode: selected.gcloud.mode })).input; + } const metadata = runtimeMetadata ? applyNativeRuntimeMetadata({ input, diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index 5ae8a1a50..4a6007b97 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -43,6 +43,8 @@ export type NativeProjectRun = { readonly profiles?: readonly string[]; /** Public startup selector only; null means no AWS adaptation. Never credentials. */ readonly aws?: { readonly profile: string; readonly region?: string } | null; + /** Public gcloud ADC adapter selector; absent means none. Never credential data. */ + readonly gcloud?: { readonly mode: "file" }; /** * Present when startup reviewed public runtime host metadata into this graph. * Retained restores must add it again; absent legacy graphs never gain it. @@ -61,6 +63,13 @@ function refused(): Error { "Native project run mapping is unsafe, changed, or owned by another run; inspect native state before recovery." ); } +function validGcloud(value: unknown): boolean { + return ( + isRecord(value) && + Object.keys(value).join() === "mode" && + value.mode === "file" + ); +} function validAws(value: unknown): boolean { return ( value === null || @@ -118,7 +127,10 @@ function valid(value: unknown): value is NativeProjectRun { "aws,effectiveEnvName,namespace,owner,planId,run", "aws,effectiveEnvName,namespace,owner,planId,profiles,run", "aws,effectiveEnvName,namespace,owner,planId,profiles,run,runtimeMetadata", + "aws,effectiveEnvName,gcloud,namespace,owner,planId,profiles,run", + "aws,effectiveEnvName,gcloud,namespace,owner,planId,profiles,run,runtimeMetadata", ].includes(Object.keys(value).sort().join()) && + (!Object.hasOwn(value, "gcloud") || validGcloud(value.gcloud)) && (!Object.hasOwn(value, "runtimeMetadata") || value.runtimeMetadata === true) && (!Object.hasOwn(value, "aws") || validAws(value.aws)) && diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..0b2e8dde9 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -9,6 +9,11 @@ import { } from "../lib/compose-env-fingerprint.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; +import { scheduleNativeAwsExpiryWarnings } from "./native-aws-expiry.ts"; +import { + adaptNativeGcloudEnvironment, + type NativeGcloudSelection, +} from "./native-gcloud-environment.ts"; import { acquireNativeHttpsLease, NativeHttpsLeaseAcquisitionError, @@ -250,6 +255,9 @@ type Dependencies = { prepare: typeof prepareNativeProjectInput; prepareStorage: typeof prepareNativeProjectRunStorage; adaptAws: typeof adaptNativeAwsEnvironment; + adaptGcloud: typeof adaptNativeGcloudEnvironment; + /** Value-free operator notices (for example credential expiry); stderr by default. */ + warn: (line: string) => void; review: typeof withNativeProjectReview; selectReview: typeof selectNativeProjectReviewIdentity; retainedImages: typeof selectNativeRetainedImages; @@ -270,6 +278,10 @@ const DEFAULTS: Dependencies = { prepare: prepareNativeProjectInput, prepareStorage: prepareNativeProjectRunStorage, adaptAws: adaptNativeAwsEnvironment, + adaptGcloud: adaptNativeGcloudEnvironment, + warn: (line) => { + process.stderr.write(`${line}\n`); + }, review: withNativeProjectReview, selectReview: selectNativeProjectReviewIdentity, retainedImages: selectNativeRetainedImages, @@ -582,23 +594,96 @@ function foregroundExitCode(opts: { } return opts.code; } -function environmentDelivery( +/** + * Credential adapters run after lifecycle hooks so a login hook can refresh + * host credentials first. Returns the same input object when none is selected. + * AWS values are a startup snapshot, so expiry warnings are scheduled; the + * caller cancels them when the foreground exits. + */ +async function adaptNativeCredentials(opts: { + readonly input: NativeProjectInput; + readonly selection: { + readonly aws?: { readonly profile: string; readonly region?: string }; + readonly gcloud?: NativeGcloudSelection; + }; + readonly deps: Pick; +}): Promise<{ + readonly input: NativeProjectInput; + readonly cancelExpiry?: () => void; +}> { + let { input } = opts; + let cancelExpiry: (() => void) | undefined; + if (opts.selection.aws) { + const adapted = await opts.deps.adaptAws({ + input, + ...opts.selection.aws, + }); + input = adapted.input; + cancelExpiry = scheduleNativeAwsExpiryWarnings({ + expiry: adapted.receipt.expiry, + services: adapted.receipt.services.length, + write: opts.deps.warn, + }); + } + if (opts.selection.gcloud) { + try { + input = ( + await opts.deps.adaptGcloud({ + input, + mode: opts.selection.gcloud.mode, + }) + ).input; + } catch (error) { + cancelExpiry?.(); + throw error; + } + } + return { input, ...(cancelExpiry ? { cancelExpiry } : {}) }; +} + +/** + * Private envelope for `graph serve --environment-stdin`. Version 1 carries values + * only; version 2 adds service-scoped file members and is sent only when files + * exist, so a runtime without file support refuses instead of dropping them. + */ +export function nativeEnvironmentDelivery( input: NativeProjectInput, plan: string, run: string ): { flags: string[]; payload?: Buffer } { - if (Object.keys(input.managedEnvironment).length === 0) { + const files = Object.entries(input.managedFiles ?? {}).filter( + ([, members]) => members.length > 0 + ); + if ( + Object.keys(input.managedEnvironment).length === 0 && + files.length === 0 + ) { return { flags: [] }; } return { flags: ["--environment-stdin"], payload: Buffer.from( JSON.stringify({ - version: 1, + version: files.length > 0 ? 2 : 1, plan, run, lifetime_seconds: 300, services: input.managedEnvironment, + ...(files.length > 0 + ? { + files: Object.fromEntries( + files.map(([service, members]) => [ + service, + members.map((member) => ({ + name: member.name, + target: member.target, + mode: "0400", + content_b64: member.content.toString("base64"), + })), + ]) + ), + } + : {}), }) ), }; @@ -695,6 +780,7 @@ function retainedStartupSelection(opts: { readonly envName?: string | null; readonly profiles?: readonly string[]; readonly aws?: { readonly profile: string; readonly region?: string }; + readonly gcloud?: NativeGcloudSelection; }) { const { run } = opts; if ( @@ -707,16 +793,20 @@ function retainedStartupSelection(opts: { JSON.stringify(run.profiles)) || (opts.aws !== undefined && (opts.aws.profile !== run.aws?.profile || - opts.aws.region !== run.aws?.region)) + opts.aws.region !== run.aws?.region)) || + // Like the AWS selector, an omitted gcloud selection keeps the retained one + // (which delivers a fresh file); a different explicit selection refuses. + (opts.gcloud !== undefined && opts.gcloud.mode !== run.gcloud?.mode) ) { throw new ClosedCliError( - "Native up cannot change the retained run's environment, profiles or AWS profile; data was not replaced." + "Native up cannot change the retained run's environment, profiles, AWS profile or gcloud selection; data was not replaced." ); } return { envName: run.effectiveEnvName, profiles: run.profiles, aws: run.aws ?? undefined, + gcloud: run.gcloud ?? undefined, }; } async function selectNativeStartup(opts: { @@ -727,6 +817,7 @@ async function selectNativeStartup(opts: { readonly profiles?: readonly string[]; readonly requestedProfiles: readonly string[]; readonly aws?: { readonly profile: string; readonly region?: string }; + readonly gcloud?: NativeGcloudSelection; readonly load: typeof loadNativeProjectRun; readonly invoke: typeof invokeNativeRuntime; readonly signal?: AbortSignal; @@ -739,11 +830,13 @@ async function selectNativeStartup(opts: { envName: opts.envName, profiles: opts.profiles, aws: opts.aws, + gcloud: opts.gcloud, }) : { envName: opts.envName, profiles: opts.requestedProfiles, aws: opts.aws, + gcloud: opts.gcloud, }; let retainedFlags: string[] = []; if (retained) { @@ -789,6 +882,7 @@ function selectedMapping(opts: { readonly effectiveEnvName: string | null; readonly profiles: readonly string[]; readonly aws?: { readonly profile: string; readonly region?: string }; + readonly gcloud?: NativeGcloudSelection; readonly runtimeMetadata: boolean; }): NativeProjectRun { return { @@ -801,6 +895,9 @@ function selectedMapping(opts: { ...(opts.aws.region ? { region: opts.aws.region } : {}), } : null, + // Written only when selected, so mappings without it stay readable by + // earlier candidates; a mapping that carries it refuses there. + ...(opts.gcloud ? { gcloud: { mode: opts.gcloud.mode } } : {}), ...(opts.runtimeMetadata ? { runtimeMetadata: true as const } : {}), }; } @@ -905,6 +1002,8 @@ export async function startNativeProject(opts: { readonly preparedBase?: NativePreparedBase; readonly https?: NativeHttpsSelection; readonly aws?: { readonly profile: string; readonly region?: string }; + /** Explicit gcloud Application Default Credentials adapter selection. */ + readonly gcloud?: NativeGcloudSelection; /** Public instance identity delivered as runtime host metadata; omitted delivers none. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; readonly before: (input: NativeProjectInput) => Promise; @@ -943,6 +1042,7 @@ export async function startNativeProject(opts: { profiles: opts.profiles, requestedProfiles, aws: opts.aws, + gcloud: opts.gcloud, load: deps.load, invoke: deps.invoke, signal: opts.signal, @@ -1055,14 +1155,21 @@ export async function startNativeProject(opts: { let graphCleanupConfirmed = false; let httpsCleanupConfirmed = false; let httpsAcquisitionFailure: unknown; + let cancelAwsExpiry: (() => void) | undefined; try { if (opts.signal?.aborted) { cancel(); } requireActiveStartup(controller.signal); hooks = await opts.before(input); - if (selection.aws) { - input = (await deps.adaptAws({ input, ...selection.aws })).input; + const credentials = await adaptNativeCredentials({ + input, + selection, + deps, + }); + cancelAwsExpiry = credentials.cancelExpiry; + if (credentials.input !== input) { + input = credentials.input; specs = prepareNativeProjectServices( input, opts.dependencyFile !== undefined @@ -1267,7 +1374,7 @@ export async function startNativeProject(opts: { run, invoke: deps.invoke, }); - const delivery = environmentDelivery(input, review.planId, run); + const delivery = nativeEnvironmentDelivery(input, review.planId, run); let code = 1; let serveFailure: unknown; let nativeExitCode: string | undefined; @@ -1323,6 +1430,7 @@ export async function startNativeProject(opts: { effectiveEnvName: input.effectiveEnvName, profiles, aws: selection.aws, + gcloud: selection.gcloud, runtimeMetadata: metadata !== undefined, }); // Enrollment must exist before taking a shared lease: release @@ -1464,6 +1572,7 @@ export async function startNativeProject(opts: { } catch (error) { throw httpsCleanupFailure(httpsAcquisitionFailure, error); } finally { + cancelAwsExpiry?.(); process.off("SIGINT", cancel); process.off("SIGTERM", terminate); opts.signal?.removeEventListener("abort", cancel); diff --git a/src/commands/project.ts b/src/commands/project.ts index ff71f6a36..42b879b14 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -41,6 +41,7 @@ import { } from "../agents/onboarding-handoff.ts"; import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; +import { parseNativeGcloudSelection } from "../backends/native-gcloud-environment.ts"; import { parseNativePreparedBase } from "../backends/native-prepared-base.ts"; import { nativeDownEnvironment, @@ -5779,6 +5780,7 @@ async function handleNativeUp({ region: process.env.HACK_NATIVE_AWS_REGION, } : undefined, + gcloud: parseNativeGcloudSelection(process.env.HACK_NATIVE_GCLOUD_ADC), runtimeMetadata: { devHost, aliasHost: resolveBranchAliasHost({ devHost, cfg }), diff --git a/tests/native-aws-expiry.test.ts b/tests/native-aws-expiry.test.ts new file mode 100644 index 000000000..0a51a94bb --- /dev/null +++ b/tests/native-aws-expiry.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "bun:test"; +import { + NATIVE_AWS_EXPIRY_LEAD_MS, + nativeAwsExpiryMessage, + scheduleNativeAwsExpiryWarnings, +} from "../src/backends/native-aws-expiry.ts"; + +function fakeClock(start: number) { + let now = start; + let next = 0; + const pending = new Map void }>(); + return { + clock: { + now: () => now, + setTimer: (run: () => void, delay: number) => { + const id = (next += 1); + pending.set(id, { at: now + delay, run }); + return { id }; + }, + clearTimer: (timer: unknown) => { + pending.delete((timer as { id: number }).id); + }, + }, + advance(ms: number) { + now += ms; + for (const [id, timer] of [...pending]) { + if (timer.at <= now) { + pending.delete(id); + timer.run(); + } + } + }, + pending: () => pending.size, + }; +} +const start = Date.parse("2026-09-20T12:00:00Z"); +const expiry = new Date(start + 60 * 60_000).toISOString(); + +test("warns once ten minutes before expiry and once at expiry, value-free", () => { + const time = fakeClock(start); + const lines: string[] = []; + scheduleNativeAwsExpiryWarnings({ + expiry, + services: 3, + write: (line) => lines.push(line), + clock: time.clock, + }); + time.advance(60 * 60_000 - NATIVE_AWS_EXPIRY_LEAD_MS - 1); + expect(lines).toEqual([]); + time.advance(1); + expect(lines).toEqual([ + nativeAwsExpiryMessage({ + services: 3, + expiry: Date.parse(expiry), + expired: false, + }), + ]); + expect(lines[0]).toMatch( + /^AWS credentials delivered to 3 services expire at \d\d:\d\d; run hack restart to renew\.$/ + ); + time.advance(NATIVE_AWS_EXPIRY_LEAD_MS); + expect(lines).toHaveLength(2); + expect(lines[1]).toMatch( + /^AWS credentials delivered to 3 services expired at \d\d:\d\d; run hack restart to renew\.$/ + ); + expect(time.pending()).toBe(0); +}); + +test("cancellation clears pending warnings and near expiry warns immediately", () => { + const time = fakeClock(start); + const lines: string[] = []; + const cancel = scheduleNativeAwsExpiryWarnings({ + expiry, + services: 1, + write: (line) => lines.push(line), + clock: time.clock, + }); + expect(time.pending()).toBe(2); + cancel(); + expect(time.pending()).toBe(0); + time.advance(2 * 60 * 60_000); + expect(lines).toEqual([]); + const soon = fakeClock(start); + scheduleNativeAwsExpiryWarnings({ + expiry: new Date(start + 5 * 60_000).toISOString(), + services: 1, + write: (line) => lines.push(line), + clock: soon.clock, + }); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("delivered to 1 service expire at"); + soon.advance(5 * 60_000); + expect(lines).toHaveLength(2); + for (const invalid of ["not-a-date", ""]) { + expect( + scheduleNativeAwsExpiryWarnings({ + expiry: invalid, + services: 1, + write: (line) => lines.push(line), + clock: soon.clock, + })() + ).toBeUndefined(); + } + expect(lines).toHaveLength(2); +}); diff --git a/tests/native-exec-environment.test.ts b/tests/native-exec-environment.test.ts index 15c6b7409..74042b744 100644 --- a/tests/native-exec-environment.test.ts +++ b/tests/native-exec-environment.test.ts @@ -167,3 +167,39 @@ test("fresh exec uses only saved AWS selection and omits adapter failure details }) ).rejects.toThrow("Values omitted"); }); +test("fresh exec with a gcloud selection rechecks selectors only and delivers no files", async () => { + const opts = await fixture(); + const run: NativeProjectRun = { ...opts.run, gcloud: { mode: "file" } }; + let calls = 0; + const values = await prepareNativeExecEnvironment({ + ...opts, + run, + adaptGcloud: async (request) => { + calls += 1; + expect(request.mode).toBe("file"); + expect(request.deliver).toBe(false); + return { + input: request.input, + receipt: { mode: "file", types: [], services: ["web"] }, + }; + }, + }); + expect(calls).toBe(1); + expect(values).toEqual({ VALUE: "selected" }); + // Without a saved selection the adapter is never consulted. + await prepareNativeExecEnvironment({ + ...opts, + adaptGcloud: async () => { + throw new Error("must not run"); + }, + }); + await expect( + prepareNativeExecEnvironment({ + ...opts, + run, + adaptGcloud: async () => { + throw new Error("synthetic-private-detail"); + }, + }) + ).rejects.toThrow("Values omitted"); +}); diff --git a/tests/native-gcloud-environment.test.ts b/tests/native-gcloud-environment.test.ts new file mode 100644 index 000000000..b374d2c7b --- /dev/null +++ b/tests/native-gcloud-environment.test.ts @@ -0,0 +1,463 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + adaptNativeGcloudEnvironment, + nativeGcloudCredentialType, + parseNativeGcloudSelection, +} from "../src/backends/native-gcloud-environment.ts"; +import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import { nativeEnvironmentDelivery } from "../src/backends/native-project-start.ts"; + +const ADC = ".config/gcloud/application_default_credentials.json"; +const REFUSAL = + "Native gcloud adaptation refused: verify application-default login, the supported read-only ADC mount and selectors; values omitted."; +const user = { + type: "authorized_user", + client_id: "synthetic-client-id", + client_secret: "synthetic-client-secret", + refresh_token: "synthetic-refresh-token", + quota_project_id: "synthetic-project", +}; +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +/** A scratch home directory; the real HOME is never read. */ +async function home(content: unknown = user, mode = 0o600): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), "native-gcloud-"))); + roots.push(root); + await mkdir(join(root, ".config/gcloud"), { recursive: true }); + const text = typeof content === "string" ? content : JSON.stringify(content); + await writeFile(join(root, ADC), text, { mode }); + await chmod(join(root, ADC), mode); + return root; +} +function input( + services: Record, + managedEnvironment: Record> = {} +): NativeProjectInput { + return { + originalSha256: "a".repeat(64), + normalizedComposeJson: JSON.stringify({ services }), + managedEnvironment, + lifecycleHostEnvironment: {}, + effectiveEnvName: null, + environmentFiles: [], + serviceNames: Object.keys(services), + }; +} +const standard = () => + input( + { + api: { + image: "sha256:api", + volumes: [ + "${HOME}/.config/gcloud/application_default_credentials.json:/srv/app/.config/gcloud/application_default_credentials.json:ro", + "../src:/app:ro", + ], + environment: { + GOOGLE_APPLICATION_CREDENTIALS: + "/srv/app/.config/gcloud/application_default_credentials.json", + GOOGLE_CLOUD_PROJECT: "public-project", + SECRET: null, + }, + }, + proxy: { + image: "sha256:proxy", + volumes: [ + { + type: "bind", + source: "~/.config/gcloud/application_default_credentials.json", + target: "/config/adc.json", + read_only: true, + }, + ], + environment: ["GOOGLE_APPLICATION_CREDENTIALS"], + }, + other: { image: "sha256:other", volumes: ["data:/data"] }, + }, + { + api: { SECRET: "synthetic-app-secret" }, + proxy: { GOOGLE_APPLICATION_CREDENTIALS: "/config/adc.json" }, + } + ); + +test("file mode replaces only recognized read-only ADC binds with private file members", async () => { + const root = await home(); + let mints = 0; + const original = standard(); + const before = JSON.stringify(original); + const result = await adaptNativeGcloudEnvironment({ + input: original, + mode: "file", + homeDirectory: root, + mint: async () => { + mints += 1; + }, + }); + expect(JSON.stringify(original)).toBe(before); + expect(mints).toBe(1); + const compose = JSON.parse(result.input.normalizedComposeJson); + expect(compose.services.api.volumes).toEqual(["../src:/app:ro"]); + expect(compose.services.proxy.volumes).toEqual([]); + expect(compose.services.other).toEqual({ + image: "sha256:other", + volumes: ["data:/data"], + }); + expect(compose.services.api.environment).toEqual({ + GOOGLE_APPLICATION_CREDENTIALS: + "/srv/app/.config/gcloud/application_default_credentials.json", + GOOGLE_CLOUD_PROJECT: "public-project", + SECRET: null, + }); + // A managed selector becomes the public mount target; files-only services keep no values. + expect(compose.services.proxy.environment).toEqual({ + GOOGLE_APPLICATION_CREDENTIALS: "/config/adc.json", + }); + expect(result.input.managedEnvironment).toEqual({ + api: { SECRET: "synthetic-app-secret" }, + }); + const files = result.input.managedFiles ?? {}; + expect(Object.keys(files).sort()).toEqual(["api", "proxy"]); + expect(files.api?.map(({ name, target }) => ({ name, target }))).toEqual([ + { + name: "gcloud-adc.json", + target: "/srv/app/.config/gcloud/application_default_credentials.json", + }, + ]); + expect(files.proxy?.[0]?.target).toBe("/config/adc.json"); + expect(JSON.parse(files.proxy?.[0]?.content.toString() ?? "")).toEqual(user); + expect(result.receipt).toEqual({ + mode: "file", + types: ["authorized_user"], + services: ["api", "proxy"], + }); + const receipt = JSON.stringify(result.receipt); + expect(receipt).not.toContain("synthetic"); + expect(result.input.normalizedComposeJson).not.toContain("synthetic-refresh"); +}); + +test("version two envelope carries files only when members exist", async () => { + const root = await home(); + const adapted = await adaptNativeGcloudEnvironment({ + input: standard(), + mode: "file", + homeDirectory: root, + mint: async () => undefined, + }); + const delivery = nativeEnvironmentDelivery( + adapted.input, + "p".repeat(64), + "r".repeat(32) + ); + expect(delivery.flags).toEqual(["--environment-stdin"]); + const envelope = JSON.parse(delivery.payload?.toString() ?? ""); + expect(envelope.version).toBe(2); + expect(envelope.services).toEqual({ + api: { SECRET: "synthetic-app-secret" }, + }); + expect(envelope.files.proxy).toEqual([ + { + name: "gcloud-adc.json", + target: "/config/adc.json", + mode: "0400", + content_b64: Buffer.from(JSON.stringify(user)).toString("base64"), + }, + ]); + const plain = nativeEnvironmentDelivery( + standard(), + "p".repeat(64), + "r".repeat(32) + ); + const v1 = JSON.parse(plain.payload?.toString() ?? ""); + expect(v1.version).toBe(1); + expect(Object.hasOwn(v1, "files")).toBe(false); + expect( + nativeEnvironmentDelivery( + input({ web: {} }), + "p".repeat(64), + "r".repeat(32) + ) + ).toEqual({ flags: [] }); +}); + +test("unsupported, writable, directory and duplicate ADC mounts refuse before reading", async () => { + const root = await home(); + let mints = 0; + const mint = async () => { + mints += 1; + }; + const source = "~/.config/gcloud/application_default_credentials.json"; + for (const volumes of [ + [`${source}:/config/adc.json`], + [`${source}:/config/adc.json:rw`], + [`${source}:/config/adc.json:ro:extra`], + ["~/.config/gcloud:/root/.config/gcloud:ro"], + ["/opt/other/application_default_credentials.json:/config/adc.json:ro"], + [`${source}:relative/adc.json:ro`], + [`${source}:/run/adc.json:ro`], + [`${source}:/var/run/adc.json:ro`], + [`${source}:/proc/adc.json:ro`], + [`${source}:/config/../adc.json:ro`], + [`${source}:/config/adc.json:ro`, `${source}:/other/adc.json:ro`], + [`${source}:/config/adc.json:ro`, "data:/config"], + [`${source}:/config/adc.json:ro`, "/host:/config/adc.json/inner"], + [{ type: "bind", source, target: "/config/adc.json" }], + [{ type: "bind", source, target: "/config/adc.json", read_only: false }], + [ + { + type: "bind", + source, + target: "/config/adc.json", + read_only: true, + bind: { propagation: "rprivate" }, + }, + ], + [{ type: "volume", source, target: "/config/adc.json", read_only: true }], + [42], + ]) { + await expect( + adaptNativeGcloudEnvironment({ + input: input({ app: { image: "x", volumes } }), + mode: "file", + homeDirectory: root, + mint, + }) + ).rejects.toThrow(REFUSAL); + } + await expect( + adaptNativeGcloudEnvironment({ + input: input({ app: { image: "x", volumes: ["../src:/app:ro"] } }), + mode: "file", + homeDirectory: root, + mint, + }) + ).rejects.toThrow(REFUSAL); + await expect( + adaptNativeGcloudEnvironment({ + input: input({ + app: { + image: "x", + volumes: [`${source}:/tmp/adc.json:ro`], + tmpfs: ["/tmp"], + }, + }), + mode: "file", + homeDirectory: root, + mint, + }) + ).rejects.toThrow(REFUSAL); + expect(mints).toBe(0); +}); + +test("selectors that point elsewhere or bypass the delivered file refuse", async () => { + const root = await home(); + const volumes = [ + "~/.config/gcloud/application_default_credentials.json:/config/adc.json:ro", + ]; + for (const [environment, managed] of [ + [{ GOOGLE_APPLICATION_CREDENTIALS: "/elsewhere.json" }, {}], + [{ GOOGLE_APPLICATION_CREDENTIALS: null }, {}], + [ + { GOOGLE_APPLICATION_CREDENTIALS: null }, + { GOOGLE_APPLICATION_CREDENTIALS: "/elsewhere.json" }, + ], + [{ CLOUDSDK_CONFIG: "/config" }, {}], + [{ GCE_METADATA_HOST: "metadata.internal" }, {}], + [{ GCE_METADATA_IP: "192.0.2.1" }, {}], + [{ CLOUDSDK_AUTH_ACCESS_TOKEN_FILE: "/token" }, {}], + [{ CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE: "/other.json" }, {}], + [{ GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES: "1" }, {}], + [{ PLAIN: null }, { CLOUDSDK_CONFIG: "synthetic-private" }], + ] as const) { + await expect( + adaptNativeGcloudEnvironment({ + input: input( + { app: { image: "x", volumes, environment } }, + Object.keys(managed).length > 0 ? { app: { ...managed } } : {} + ), + mode: "file", + homeDirectory: root, + mint: async () => undefined, + }) + ).rejects.toThrow(REFUSAL); + } + // Without any declared selector the mount alone is accepted unchanged. + const accepted = await adaptNativeGcloudEnvironment({ + input: input({ app: { image: "x", volumes } }), + mode: "file", + homeDirectory: root, + mint: async () => undefined, + }); + expect(accepted.receipt.services).toEqual(["app"]); +}); + +test("credential types admit user credentials and refuse keys and external accounts", () => { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)); + expect(nativeGcloudCredentialType(encode(user))).toBe("authorized_user"); + expect( + nativeGcloudCredentialType( + encode({ + type: "impersonated_service_account", + service_account_impersonation_url: + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/synthetic:generateAccessToken", + source_credentials: user, + delegates: [], + }) + ) + ).toBe("impersonated_service_account"); + for (const value of [ + { type: "service_account", private_key: "synthetic-key" }, + { type: "external_account", credential_source: { file: "/token" } }, + { type: "external_account_authorized_user", refresh_token: "x" }, + { ...user, client_secret: "" }, + { ...user, refresh_token: 1 }, + { type: "authorized_user" }, + { + ...user, + nested: { credential_source: { executable: { command: "x" } } }, + }, + { + type: "impersonated_service_account", + service_account_impersonation_url: "http://insecure", + source_credentials: user, + }, + { + type: "impersonated_service_account", + service_account_impersonation_url: "https://iam.example", + source_credentials: { type: "service_account" }, + }, + [user], + "text", + ]) { + expect(() => nativeGcloudCredentialType(encode(value))).toThrow(REFUSAL); + } + expect(() => + nativeGcloudCredentialType(Buffer.from([0x7b, 0xff, 0x7d])) + ).toThrow(REFUSAL); + expect(() => nativeGcloudCredentialType(Buffer.from("{"))).toThrow(REFUSAL); +}); + +test("unsafe or missing host ADC files refuse without calling gcloud", async () => { + const volumes = [ + "~/.config/gcloud/application_default_credentials.json:/config/adc.json:ro", + ]; + const attempt = async (root: string) => { + let mints = 0; + await expect( + adaptNativeGcloudEnvironment({ + input: input({ app: { image: "x", volumes } }), + mode: "file", + homeDirectory: root, + mint: async () => { + mints += 1; + }, + }) + ).rejects.toThrow(REFUSAL); + expect(mints).toBe(0); + }; + await attempt(await home(user, 0o660)); + await attempt(await home(user, 0o602)); + await attempt(await home("x".repeat(16 * 1024 + 1))); + await attempt(await home("")); + await attempt(await home({ type: "service_account", private_key: "k" })); + const empty = await realpath(await mkdtemp(join(tmpdir(), "native-gcloud-"))); + roots.push(empty); + await attempt(empty); + const linked = await realpath( + await mkdtemp(join(tmpdir(), "native-gcloud-")) + ); + roots.push(linked); + const target = await home(); + await mkdir(join(linked, ".config/gcloud"), { recursive: true }); + await symlink(join(target, ADC), join(linked, ADC)); + await attempt(linked); + // Readable but not writable by others is accepted. + const readable = await adaptNativeGcloudEnvironment({ + input: input({ app: { image: "x", volumes } }), + mode: "file", + homeDirectory: await home(user, 0o644), + mint: async () => undefined, + }); + expect(readable.receipt.types).toEqual(["authorized_user"]); +}); + +test("a failed mint or an ADC change during startup refuses with no files", async () => { + const volumes = [ + "~/.config/gcloud/application_default_credentials.json:/config/adc.json:ro", + ]; + const root = await home(); + await expect( + adaptNativeGcloudEnvironment({ + input: input({ app: { image: "x", volumes } }), + mode: "file", + homeDirectory: root, + mint: async () => { + throw new Error("synthetic-token-output reauth required"); + }, + }) + ).rejects.toThrow(REFUSAL); + const error = await adaptNativeGcloudEnvironment({ + input: input({ app: { image: "x", volumes } }), + mode: "file", + homeDirectory: root, + mint: async () => { + // A re-login replaces the file while the start is in progress. + await rm(join(root, ADC)); + await writeFile(join(root, ADC), JSON.stringify(user), { mode: 0o600 }); + }, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe(REFUSAL); + expect((error as Error).message).not.toContain("synthetic"); +}); + +test("selector-only recheck rewrites Compose identically without reading or minting", async () => { + const root = await home(); + const full = await adaptNativeGcloudEnvironment({ + input: standard(), + mode: "file", + homeDirectory: root, + mint: async () => undefined, + }); + await rm(join(root, ADC)); + const recheck = await adaptNativeGcloudEnvironment({ + input: standard(), + mode: "file", + deliver: false, + homeDirectory: root, + mint: async () => { + throw new Error("must not mint"); + }, + }); + expect(recheck.input.normalizedComposeJson).toBe( + full.input.normalizedComposeJson + ); + expect(recheck.input.managedEnvironment).toEqual( + full.input.managedEnvironment + ); + expect(recheck.input.managedFiles).toBeUndefined(); +}); + +test("only the explicit file selector is accepted", () => { + expect(parseNativeGcloudSelection(undefined)).toBeUndefined(); + expect(parseNativeGcloudSelection("")).toBeUndefined(); + expect(parseNativeGcloudSelection("file")).toEqual({ mode: "file" }); + for (const value of ["token", "FILE", "1", "true"]) { + expect(() => parseNativeGcloudSelection(value)).toThrow( + "HACK_NATIVE_GCLOUD_ADC accepts only `file`." + ); + } +}); diff --git a/tests/native-private-cli.test.ts b/tests/native-private-cli.test.ts new file mode 100644 index 000000000..2fa5735be --- /dev/null +++ b/tests/native-private-cli.test.ts @@ -0,0 +1,95 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runNativePrivateCli } from "../src/backends/native-private-cli.ts"; + +const roots: string[] = []; +const savedPath = process.env.PATH; +const savedProfile = process.env.AWS_PROFILE; +afterEach(async () => { + process.env.PATH = savedPath; + if (savedProfile === undefined) { + Reflect.deleteProperty(process.env, "AWS_PROFILE"); + } else { + process.env.AWS_PROFILE = savedProfile; + } + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +/** A scratch PATH directory holding one fake CLI; no real credential CLI is run. */ +async function fakeCli(body: string): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), "native-cli-"))); + roots.push(root); + const path = join(root, "fake-credential-cli"); + await writeFile(path, `#!/bin/sh\n${body}\n`); + await chmod(path, 0o755); + process.env.PATH = `${root}:/usr/bin:/bin`; + return root; +} +class Refused extends Error {} +class Missing extends Error {} +const call = (environment: Record = {}, timeoutMs?: number) => + runNativePrivateCli({ + executable: "fake-credential-cli", + args: ["one", "two words"], + environment, + refused: () => new Refused("refused"), + missing: () => new Missing("missing"), + ...(timeoutMs ? { timeoutMs } : {}), + }); + +test("captures stdout only with a clean environment and the caller's fixed keys", async () => { + process.env.AWS_PROFILE = "ambient-should-not-leak"; + await fakeCli( + 'printf "%s|%s|" "$1" "$2"; env | sort | grep -v "^PWD=\\|^SHLVL=\\|^_=" | tr "\\n" ","; echo diagnostic >&2' + ); + const output = await call({ FIXED: "1" }); + const text = output.toString(); + output.fill(0); + expect(text.startsWith("one|two words|")).toBe(true); + expect(text).toContain("FIXED=1"); + expect(text).toContain("PATH="); + expect(text).not.toContain("AWS_PROFILE"); + expect(text).not.toContain("diagnostic"); +}); + +test("nonzero exit, oversized output and a missing executable refuse distinctly", async () => { + await fakeCli("echo partial; exit 3"); + await expect(call()).rejects.toBeInstanceOf(Refused); + await fakeCli("head -c 70000 /dev/zero"); + await expect(call()).rejects.toBeInstanceOf(Refused); + const root = await realpath(await mkdtemp(join(tmpdir(), "native-cli-"))); + roots.push(root); + process.env.PATH = root; + await expect(call()).rejects.toBeInstanceOf(Missing); +}); + +test("timeout kills the request's whole process group and never retries", async () => { + const root = await fakeCli( + 'sleep 30 & echo $! > "$(dirname "$0")/child"; echo started >> "$(dirname "$0")/runs"; wait' + ); + const started = performance.now(); + await expect(call({}, 2000)).rejects.toBeInstanceOf(Refused); + expect(performance.now() - started).toBeLessThan(10_000); + const child = Number((await readFile(join(root, "child"), "utf8")).trim()); + let alive = true; + for (let attempt = 0; attempt < 50 && alive; attempt += 1) { + try { + process.kill(child, 0); + await Bun.sleep(20); + } catch { + alive = false; + } + } + expect(alive).toBe(false); + expect((await readFile(join(root, "runs"), "utf8")).trim()).toBe("started"); +}); diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index c6450adbe..f74cca921 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -297,7 +297,11 @@ test("legacy unknown selections refuse and omitted flags preserve saved values", envName: "qa", profiles: ["app"], aws: undefined, + gcloud: undefined, }); + expect( + nativeRestartSelection({ run: { ...run, gcloud: { mode: "file" } } }).gcloud + ).toEqual({ mode: "file" }); expect(() => nativeRestartSelection({ run: { ...run, profiles: undefined } }) ).toThrow("legacy"); diff --git a/tests/native-project-run.test.ts b/tests/native-project-run.test.ts index ace1adadb..dc6885d74 100644 --- a/tests/native-project-run.test.ts +++ b/tests/native-project-run.test.ts @@ -222,6 +222,41 @@ test("AWS startup selection roundtrips without admitting credentials or ambient } }); +test("gcloud file-mode selector roundtrips only as an exact public mode", async () => { + const opts = await fixture(); + const selected = { + ...run, + effectiveEnvName: "qa", + aws: null, + profiles: [], + gcloud: { mode: "file" as const }, + }; + await save({ ...opts, run: selected }); + expect(await load(opts)).toEqual(selected); + await remove({ ...opts, expected: selected }); + for (const gcloud of [ + null, + { mode: "token" }, + { mode: "file", account: "synthetic-rejected" }, + { refresh_token: "synthetic-rejected" }, + ]) { + const opts = await fixture(); + await expect( + save({ + ...opts, + run: { + ...run, + effectiveEnvName: null, + aws: null, + profiles: [], + gcloud, + } as any, + }) + ).rejects.toThrow(); + expect(await load(opts)).toBeNull(); + } +}); + test("profile selectors roundtrip canonically while legacy absence stays unknown", async () => { const opts = await fixture(); const selected = { diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..5cab25a1c 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -785,6 +785,99 @@ test("AWS profile export follows native login hooks and precedes runtime effects expect(events).toEqual(["before", "cleanup"]); }); +test("gcloud ADC delivery follows hooks, sends file members privately and persists only the mode", async () => { + const { opts, events } = await fixture(); + opts.dependencies.adaptGcloud = async ({ input, mode }) => { + expect(mode).toBe("file"); + events.push("gcloud"); + return { + input: { + ...input, + managedFiles: { + web: [ + { + name: "gcloud-adc.json", + target: "/config/adc.json", + content: Buffer.from("synthetic-adc-bytes"), + }, + ], + }, + }, + receipt: { mode, types: ["authorized_user"], services: ["web"] }, + }; + }; + const serve = opts.dependencies.serve; + let envelope: Record | undefined; + opts.dependencies.serve = async (call) => { + envelope = JSON.parse(new TextDecoder().decode(call.privateInput)); + expect(call.args.join(" ")).not.toContain("synthetic-adc-bytes"); + return await serve!(call); + }; + let saved: NativeProjectRun | undefined; + opts.dependencies.save = async ({ run }) => { + saved = run; + }; + opts.dependencies.remove = async ({ expected }) => { + expect(saved).toBe(expected); + }; + expect(await startNativeProject({ ...opts, gcloud: { mode: "file" } })).toBe( + 0 + ); + expect(events.indexOf("gcloud")).toBeGreaterThan(events.indexOf("before")); + expect(events.indexOf("gcloud")).toBeLessThan(events.indexOf("runtime up")); + expect(envelope?.version).toBe(2); + expect(envelope?.files).toEqual({ + web: [ + { + name: "gcloud-adc.json", + target: "/config/adc.json", + mode: "0400", + content_b64: Buffer.from("synthetic-adc-bytes").toString("base64"), + }, + ], + }); + expect(saved?.gcloud).toEqual({ mode: "file" }); + expect(JSON.stringify(saved)).not.toContain("synthetic"); + // A retained graph without a gcloud selection cannot gain one. + events.length = 0; + opts.dependencies.load = async () => ({ + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }); + await expect( + startNativeProject({ ...opts, gcloud: { mode: "file" } }) + ).rejects.toThrow("cannot change the retained run"); + expect(events).toEqual([]); +}); + +test("AWS delivery warns value-free before expiry and clears its timers on exit", async () => { + const { opts } = await fixture(); + const lines: string[] = []; + opts.dependencies.warn = (line) => lines.push(line); + opts.dependencies.adaptAws = async ({ input, profile }) => ({ + input, + receipt: { + profile, + // Already inside the warning window, so the notice is immediate. + expiry: new Date(Date.now() + 5 * 60_000).toISOString(), + services: ["web", "worker"], + }, + }); + expect( + await startNativeProject({ ...opts, aws: { profile: "qa-test" } }) + ).toBe(0); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch( + /^AWS credentials delivered to 2 services expire at \d\d:\d\d; run hack restart to renew\.$/ + ); + expect(lines[0]).not.toContain("qa-test"); +}); + test("reviewed dependency caches bind published source and successful completion readiness", async () => { const { opts } = await fixture(false); const review = opts.dependencies.review, From 2e4a2c9e3283ae946b56be6b5661864f0b5b14c1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:38:53 -0500 Subject: [PATCH 015/138] fix(native): accept the one-shot label and name the Compose rerun for adopt The native Compose review refused Hack's own `hack.service.one-shot: "true"` label as an ownership label, while `hack config import --dry-run` accepts it as a native job. The review now consumes that exact key and literal value as a completion role: the plan marks the service `one_shot`, the frontend gives it a `completed` readiness goal, and the graph driver admits the marker only with completion readiness and adds no container label. Ownership namespaces and every other `hack.*` key stay refused; an invalid value, an unconditional restart policy or routing labels on a one-shot refuse with their own codes. `hack config adopt`, including `--dry-run`, still requires the Compose backend because it reads that backend's existing containers and volumes. Its refusal on another backend now names the rerun, for example `HACK_RUNTIME_BACKEND=compose hack config adopt --dry-run`. Refs HACK-1906 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 22 +++- docs/reference/cli.md | 2 +- docs/reference/native-compose-adoption.md | 8 ++ .../runtime-core/src/project/compose/mod.rs | 1 + .../src/project/compose/one_shot.rs | 119 ++++++++++++++++++ .../src/project/compose/service.rs | 36 +++++- packages/runtime-core/src/project/mod.rs | 5 + .../runtime-core/src/provider/graph/config.rs | 7 ++ .../runtime-core/src/provider/graph/tests.rs | 42 +++++++ .../runtime-core/tests/project_contract.rs | 96 ++++++++++++++ src/backends/native-project-start.ts | 15 ++- src/commands/config-adopt.ts | 12 +- src/lib/native-compose-backend-features.ts | 5 +- src/lib/native-compose-selection.ts | 13 +- tests/native-compose-selection.test.ts | 19 ++- tests/native-project-start.test.ts | 42 +++++++ tests/native-refusal-cli.test.ts | 21 ++++ 17 files changed, 450 insertions(+), 15 deletions(-) create mode 100644 packages/runtime-core/src/project/compose/one_shot.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..077282e79 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -228,7 +228,27 @@ selection; see [Test both backends](https://github.com/hack-dance/hack/blob/next Other labels keep their native behavior. Routing (`caddy*`) and dependency cache (`hack.dependencies.*`) labels are reviewed as before, and other ordinary labels -are refused with the general native configuration refusal. A basic build is also +are refused with the general native configuration refusal. + +Hack's own one-shot marker is not an ownership label. A service labeled exactly +`hack.service.one-shot: "true"` (or `hack.service.one-shot=true` in list form), +the label [`hack config import --dry-run`](../reference/native-config-import.md) +reports as `compose_one_shot_job`, is reviewed natively as a one-shot: the review +plan marks it `one_shot`, and native `up` waits for it to exit successfully +instead of waiting for it to keep running. The marker adds no container label. +Only that exact key is recognized. Any other `hack.*` key, including another +`hack.service.*` key, is still refused (`external_route_or_owner_label` in the +native review), and a marker next to a reserved ownership label does not exempt +it. The native review also refuses a marker whose value is not the string +`true` (`invalid_one_shot_label`), a one-shot with `restart: always` or +`unless-stopped` (`nonterminating_one_shot`), and a one-shot that also declares +routing labels (`one_shot_route_conflict`). + +`hack config adopt`, including `--dry-run`, still requires the Compose backend, +because it reads and adopts the Compose backend's existing containers and +volumes. On the native backend it refuses before reading anything and names the +rerun: `HACK_RUNTIME_BACKEND=compose hack config adopt --dry-run`. See +[Legacy Compose adoption](../reference/native-compose-adoption.md). A basic build is also refused by native image-project `up`, with the general refusal: it needs the authored native format. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 050e7c675..baf51eadb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -713,7 +713,7 @@ Explicitly adopt a stopped, verified existing Compose instance hack config adopt [options] ``` -Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher. Verified linked Git checkouts retain explicit existing identities. Local inheritance, generated overrides and container recreation remain unsupported. +Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher and the Compose backend, also for --dry-run; on another backend, rerun with HACK_RUNTIME_BACKEND=compose. Verified linked Git checkouts retain explicit existing identities. Local inheritance, generated overrides and container recreation remain unsupported. ### Options diff --git a/docs/reference/native-compose-adoption.md b/docs/reference/native-compose-adoption.md index 3ca26e60b..9fa9c1d1a 100644 --- a/docs/reference/native-compose-adoption.md +++ b/docs/reference/native-compose-adoption.md @@ -8,6 +8,14 @@ instance to native authored selection while retaining its original resources. existing-resource counts without writing private state or active inputs. Neither path copies data, creates replacement volumes, relabels resources or changes engines. +`hack config adopt`, including `--dry-run`, requires the Compose backend: the +preview reads the Compose backend's existing containers and volumes, and adoption +hands them to native configuration on that backend. With another +`HACK_RUNTIME_BACKEND` (such as the `hack-next` default, `native`), both refuse +with `E_NATIVE_PROJECT_UNSUPPORTED` before reading anything and name the rerun, +for example `HACK_RUNTIME_BACKEND=compose hack config adopt --dry-run`. The +selected runtime is not changed. + ## Authored identity and storage intent The owner shares the [import preview](native-config-import.md) source acquisition: diff --git a/packages/runtime-core/src/project/compose/mod.rs b/packages/runtime-core/src/project/compose/mod.rs index be2935aa0..94715d755 100644 --- a/packages/runtime-core/src/project/compose/mod.rs +++ b/packages/runtime-core/src/project/compose/mod.rs @@ -3,6 +3,7 @@ use serde_json::{Map, Value}; mod dependency_cache; mod fields; mod graph; +mod one_shot; mod ownership_labels; pub(super) mod routing; mod service; diff --git a/packages/runtime-core/src/project/compose/one_shot.rs b/packages/runtime-core/src/project/compose/one_shot.rs new file mode 100644 index 000000000..c2967d0ab --- /dev/null +++ b/packages/runtime-core/src/project/compose/one_shot.rs @@ -0,0 +1,119 @@ +//! Hack's authored one-shot role marker, `hack.service.one-shot: "true"`. +//! +//! This exact key is the only `hack.service.*` label the review consumes. It +//! grants a successful-completion readiness role and nothing else: it is not +//! ownership evidence, it installs no route and it adds no resource label. Any +//! other `hack.*` key, and every Hack ownership namespace, stays refused by the +//! service label review. +use serde_json::Value; + +/// The documented one-shot label key, matching `hack config import`. +pub(super) const LABEL: &str = "hack.service.one-shot"; + +/// What a service's labels say about the one-shot marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Declaration { + /// The marker key is absent. + Absent, + /// The marker key carries the exact literal string `true`. + OneShot, + /// The marker key is present with any other value or form. + Invalid, +} + +/// Whether `key` is the one-shot marker key itself (not a prefix match). +pub(super) fn recognized(key: &str) -> bool { + key == LABEL +} + +/// Classify the marker in either Compose label form. Only the literal string +/// value `true` (map form) or the entry `hack.service.one-shot=true` (list form) +/// declares a one-shot service; booleans, other strings and a bare key do not. +/// Label shape errors are reported by the generic label review, not here. +pub(super) fn parse(labels: Option<&Value>) -> Declaration { + let value = match labels { + Some(Value::Object(map)) => match map.get(LABEL) { + None => return Declaration::Absent, + Some(Value::String(value)) => Some(value.as_str()), + Some(_) => None, + }, + Some(Value::Array(entries)) => { + let mut found = None; + for entry in entries.iter().filter_map(Value::as_str) { + let (key, value) = entry + .split_once('=') + .map_or((entry, None), |(key, value)| (key, Some(value))); + if key == LABEL { + found = Some(value); + } + } + match found { + None => return Declaration::Absent, + Some(value) => value, + } + } + _ => return Declaration::Absent, + }; + if value == Some("true") { + Declaration::OneShot + } else { + Declaration::Invalid + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn exact_map_and_list_forms_declare_a_one_shot() { + assert_eq!( + parse(Some(&json!({"hack.service.one-shot": "true"}))), + Declaration::OneShot + ); + assert_eq!( + parse(Some(&json!(["hack.service.one-shot=true"]))), + Declaration::OneShot + ); + assert_eq!( + parse(Some( + &json!({"other": "x", "hack.service.one-shot": "true"}) + )), + Declaration::OneShot + ); + } + + #[test] + fn other_values_and_forms_are_invalid_not_one_shot() { + for labels in [ + json!({"hack.service.one-shot": "false"}), + json!({"hack.service.one-shot": "TRUE"}), + json!({"hack.service.one-shot": " true"}), + json!({"hack.service.one-shot": true}), + json!({"hack.service.one-shot": null}), + json!(["hack.service.one-shot"]), + json!(["hack.service.one-shot=1"]), + ] { + assert_eq!(parse(Some(&labels)), Declaration::Invalid, "{labels}"); + } + } + + #[test] + fn absent_and_near_miss_keys_are_not_the_marker() { + for labels in [ + json!({}), + json!([]), + json!({"hack.service.one-shot.extra": "true"}), + json!({"hack.service": "true"}), + json!({"hack.service.oneshot": "true"}), + json!(["hack.service.one-shotx=true"]), + ] { + assert_eq!(parse(Some(&labels)), Declaration::Absent, "{labels}"); + } + assert_eq!(parse(None), Declaration::Absent); + assert!(recognized(LABEL)); + assert!(!recognized("hack.service.one-shot.extra")); + assert!(!recognized("hack.service.")); + } +} diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index 5cc51846a..8c0abcd66 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -803,13 +803,43 @@ pub(super) fn compile( } diagnostics.push(Diagnostic::warning("dependency_cache_binding_required", &format!("{field}.labels"), "Dependency cache execution requires a verified source revision, pinned installer image and shared cache binding; declaration alone is not a completed install.")); } + // Hack's own one-shot marker is a completion role, not ownership: only + // its exact key is exempt below, and only its literal `true` value is a + // declaration. Other `hack.*` keys keep the ownership refusal. + let one_shot = match super::one_shot::parse(m.get("labels")) { + super::one_shot::Declaration::Absent => false, + super::one_shot::Declaration::OneShot => true, + super::one_shot::Declaration::Invalid => { + diagnostics.push(Diagnostic::error( + "invalid_one_shot_label", + &format!("{field}.labels"), + "The one-shot label accepts only the literal string value true; values omitted.", + )); + false + } + }; + if one_shot && ["always", "unless-stopped"].contains(&restart.as_str()) { + diagnostics.push(Diagnostic::error( + "nonterminating_one_shot", + &format!("{field}.restart"), + "A one-shot service cannot use an unconditional restart policy.", + )); + } + if one_shot && routing.is_some() { + diagnostics.push(Diagnostic::error( + "one_shot_route_conflict", + &format!("{field}.labels"), + "A one-shot service finishes, so it cannot also declare local HTTPS routing.", + )); + } if routing_invalid || labels.keys().any(|key| { - // Hack's ownership namespaces are never exempt: no routing or - // dependency cache declaration may carry ownership evidence. + // Hack's ownership namespaces are never exempt: no routing, + // dependency cache or one-shot declaration may carry ownership evidence. super::ownership_labels::is_hack_ownership_label(key) || (!(dependency_cache.is_some() && super::dependency_cache::recognized(key)) && !(routing.is_some() && super::routing::recognized(key)) + && !super::one_shot::recognized(key) && ["caddy", "traefik", "hack", "com.docker.compose"] .iter() .any(|prefix| key == prefix || key.starts_with(&format!("{prefix}.")))) @@ -927,7 +957,7 @@ pub(super) fn compile( limits:limits(m,&field,diagnostics)?,read_only:boolean(m.get("read_only"),false)?,init:boolean(m.get("init"),false)?,restart, working_dir:m.get("working_dir").map(|v| safe_absolute(string(v)?)).transpose()?, user:m.get("user").map(|v| match v {Value::String(s)=>redacted(s),Value::Number(n)=>redacted(&n.to_string()),_=>Err(problem("invalid_user","User must be a string or numeric identifier."))}).transpose()?, - platform,labels,routing,dependency_cache,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal, + platform,labels,routing,dependency_cache,one_shot,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal, }); } Ok(result) diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index 043076259..6ee0150a9 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -269,6 +269,11 @@ pub struct ServicePlan { pub routing: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dependency_cache: Option, + /// The service declared Hack's exact `hack.service.one-shot: "true"` role + /// marker: it must finish successfully, so its readiness goal is completion. + /// Omitted when false, so plans without the marker keep their identity. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub one_shot: bool, pub logging: Option, pub stop_grace_period_nanos: Option, pub stop_signal: Option, diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 257424239..717ceffae 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -148,6 +148,12 @@ pub(super) fn prepare_delivery( "Dependency cache initializers require successful completion readiness.", )); } + if service.one_shot && graph.services[name].ready != Condition::Completed { + return Err(error( + "graph_one_shot_readiness", + "One-shot services require successful completion readiness.", + )); + } } if graph.services.len() > MAX_SERVICES { return Err(error( @@ -188,6 +194,7 @@ pub(super) fn prepare_delivery( || !service.ports.is_empty() || service.labels.keys().any(|key| { !(routing_enrolled && service.routing.is_some() && routes::recognized(key)) + && !(service.one_shot && key == "hack.service.one-shot") && !(service.dependency_cache.is_some() && matches!( key.as_str(), diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..ea1528fec 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -1255,6 +1255,48 @@ fn owned_graph_process_loss_live() -> Result<(), CandidateError> { result } +/// Hack's exact one-shot marker reaches the driver as a completion role: it is +/// admitted only with completion readiness and adds no authored container label. +#[test] +fn driver_admits_one_shot_marker_only_with_completion_readiness() { + let fixture = Fixture::new(); + let candidate_root = Fixture::new(); + let candidate = Candidate::discover(&candidate_root.0).unwrap(); + let image = format!("sha256:{}", "a".repeat(64)); + let mut document = compose(&image, "marker", false); + document["services"]["init"]["labels"] = json!({"hack.service.one-shot": "true"}); + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + assert!(review.plan.services["init"].one_shot); + let prepare = |ready: Condition| { + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ) + .unwrap(); + let mut goals = goals(); + goals.insert("init".into(), ready); + config::prepare(inputs, &goals, &"a".repeat(32), &"b".repeat(32), None) + }; + assert_eq!( + prepare(Condition::Started).err().unwrap().code, + "graph_one_shot_readiness" + ); + let prepared = prepare(Condition::Completed).unwrap(); + let labels = prepared.configs["init"]["Labels"].as_object().unwrap(); + assert!(!labels.contains_key("hack.service.one-shot")); + assert!(labels.keys().all(|key| key.starts_with("io.hack-local."))); + // Another `hack.service.*` key is not the marker and stays outside the driver. + document["services"]["init"]["labels"] = + json!({"hack.service.one-shot": "true", "hack.service.other": "true"}); + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + assert!(!review.plan.enrollment_compatible); + assert!(!candidate.state_root.exists()); +} + #[test] fn driver_refuses_managed_inputs_even_when_public_environment_is_empty() { let fixture = Fixture::new(); diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 4028bef1d..718735174 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -620,6 +620,102 @@ fn hack_ownership_label_namespaces_block_enrollment() { assert!(fixture.plan().plan.enrollment_compatible); } +/// Hack's documented one-shot marker (`hack.service.one-shot: "true"`, the +/// label `hack config import` maps to a native job) is a completion role, not +/// an ownership or router label. Only that exact key and literal value are +/// consumed; ownership namespaces next to it and every other `hack.*` key stay +/// refused, so the marker never widens the `hack.*` reservation. +#[test] +fn hack_one_shot_marker_is_a_completion_role_not_an_owner_label() { + const INIT: &str = "services:\n init:\n image: alpine:3.20\n command: [\"true\"]\n"; + let owner_label = |fixture: &Fixture| { + fixture + .codes() + .iter() + .any(|code| code == "external_route_or_owner_label") + }; + for labels in [ + " labels: {hack.service.one-shot: \"true\"}\n", + " labels: ['hack.service.one-shot=true']\n", + ] { + let fixture = Fixture::new(&format!("{INIT}{labels}")); + let plan = fixture.plan(); + assert!(!owner_label(&fixture), "{labels}"); + assert!(plan.plan.enrollment_compatible, "{labels}"); + assert!(plan.plan.services["init"].one_shot, "{labels}"); + } + // Without the marker the plan carries no one-shot field, so existing plan + // identities are unchanged. + let plain = Fixture::new(INIT); + assert!(!plain.plan().plan.services["init"].one_shot); + assert!( + !serde_json::to_string(&plain.plan()) + .unwrap() + .contains("one_shot") + ); + // Ownership namespaces next to the marker are never exempt. + for key in [ + "com.docker.compose.project", + "io.hack-local.owner", + "io.hack.native-config.owner", + "io.hack.native-file.token", + "io.hack.storage-witness.carrier", + ] { + let fixture = Fixture::new(&format!( + "{INIT} labels:\n hack.service.one-shot: 'true'\n '{key}': owner-canary\n" + )); + let plan = fixture.plan(); + assert!(owner_label(&fixture), "{key}"); + assert!(!plan.plan.enrollment_compatible, "{key}"); + assert!( + !serde_json::to_string(&plan) + .unwrap() + .contains("owner-canary") + ); + } + // Any other `hack.*` key, including siblings of the marker, keeps the + // ownership refusal: only the exact marker key is recognized. + for key in [ + "hack.x", + "hack.service.other", + "hack.service.one-shot.extra", + "hack.service", + ] { + let fixture = Fixture::new(&format!("{INIT} labels: {{'{key}': 'true'}}\n")); + assert!(owner_label(&fixture), "{key}"); + assert!(!fixture.plan().plan.services["init"].one_shot, "{key}"); + } + // The marker only accepts the literal string `true`. + for labels in [ + " labels: {hack.service.one-shot: 'false'}\n", + " labels: {hack.service.one-shot: true}\n", + " labels: ['hack.service.one-shot']\n", + ] { + let fixture = Fixture::new(&format!("{INIT}{labels}")); + let plan = fixture.plan(); + assert!( + fixture.codes().contains(&"invalid_one_shot_label".into()), + "{labels}" + ); + assert!(!plan.plan.services["init"].one_shot, "{labels}"); + assert!(!plan.plan.enrollment_compatible, "{labels}"); + } + // A one-shot must be able to finish and cannot serve a route. + let restarting = Fixture::new(&format!( + "{INIT} restart: always\n labels: {{hack.service.one-shot: 'true'}}\n" + )); + assert!( + restarting + .codes() + .contains(&"nonterminating_one_shot".into()) + ); + let routed = Fixture::new(&format!( + "{INIT} labels:\n hack.service.one-shot: 'true'\n caddy: init.hack\n caddy.reverse_proxy: '{{{{upstreams 3000}}}}'\n caddy.tls: internal\n" + )); + assert!(routed.codes().contains(&"one_shot_route_conflict".into())); + assert!(!routed.plan().plan.enrollment_compatible); +} + #[test] fn explicit_external_network_replacement_is_owned_internal_and_review_bound() { let original = diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..d651c7082 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -394,13 +394,24 @@ async function pinNativeProjectImages(opts: { spec.image = ensured.image_id; } } +/** Services whose review carries Hack's exact one-shot marker as `one_shot: true`. */ +function reviewedOneShots( + reviewed: Readonly> +): string[] { + return Object.entries(reviewed) + .filter(([, service]) => isRecord(service) && service.one_shot === true) + .map(([name]) => name); +} + /** * Build `--ready` goals for exactly the services the reviewed plan marks active. * * Compose services outside the selected profiles stay in the normalized specs (they are * still reviewed and pinned), but the runtime does not schedule them and refuses readiness * goals for inactive or unknown services. Dependency edges declared by inactive services - * are ignored so they cannot raise an active service's readiness goal. + * are ignored so they cannot raise an active service's readiness goal. A service the + * review marks `one_shot` (Hack's exact `hack.service.one-shot: "true"` label) must + * finish successfully, so its goal is completion, as for dependency cache initializers. */ export function nativeReadinessArgs(opts: { readonly specs: Readonly>>>; @@ -439,7 +450,7 @@ export function nativeReadinessArgs(opts: { } } } - for (const name of opts.initializers) { + for (const name of [...reviewedOneShots(reviewed), ...opts.initializers]) { ready[name] = "completed"; } for (const name of opts.routed) { diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index e18670dcb..3550134da 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -31,7 +31,7 @@ const spec = defineCommand({ summary: "Explicitly adopt a stopped, verified existing Compose instance", group: "Project", description: - "Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher. Verified linked Git checkouts retain explicit existing identities. Local inheritance, generated overrides and container recreation remain unsupported.", + "Qualifies a strict static legacy subset with exact existing data volumes. --dry-run reports fields without writes. Adoption journals the stopped format switch and holds original files for rollback; retained-container commands never create replacement data. Requires an upgraded launcher and the Compose backend, also for --dry-run; on another backend, rerun with HACK_RUNTIME_BACKEND=compose. Verified linked Git checkouts retain explicit existing identities. Local inheritance, generated overrides and container recreation remain unsupported.", options: [ defineOption({ name: "stop", @@ -372,7 +372,15 @@ async function apply(opts: { } export const configAdoptCommand = withHandler(spec, async ({ ctx, args }) => { - requireNativeComposeBackend({ backend: process.env.HACK_RUNTIME_BACKEND }); + // The preview reads the Compose backend's existing containers and volumes, + // and adoption hands them to native configuration on that backend, so both + // refuse another runtime selection and name the explicit rerun. + requireNativeComposeBackend({ + backend: process.env.HACK_RUNTIME_BACKEND, + command: args.options.dryRun + ? "hack config adopt --dry-run" + : "hack config adopt", + }); if (args.options.stop && args.options.rollback) { throw new CliUsageError("Use --stop separately from --rollback."); } diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 2afef1ed8..f674c0704 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -44,8 +44,9 @@ export type ComposeBackendFeature = (typeof COMPOSE_BACKEND_FEATURES)[number]; * source of truth for the ownership labels Hack itself writes. * * Other labels, including Hack's routing (`caddy*`), dependency cache - * (`hack.dependencies.*`) and one-shot markers, are not reserved: native `up` - * reviews them as it did before. + * (`hack.dependencies.*`) and one-shot (`hack.service.one-shot`) markers, are not + * reserved: native `up` reviews them itself. The native review accepts only the + * exact one-shot key, as a completion role, and still refuses other `hack.*` keys. */ export const RESERVED_OWNERSHIP_LABEL_PREFIXES = [ "com.docker.compose.", diff --git a/src/lib/native-compose-selection.ts b/src/lib/native-compose-selection.ts index 5807e7051..2c729c925 100644 --- a/src/lib/native-compose-selection.ts +++ b/src/lib/native-compose-selection.ts @@ -64,15 +64,22 @@ async function selectRegisteredNativeInputs(opts: { }); } -/** Authored format and runtime selection are independent; no implicit fallback. */ +/** + * Authored format and runtime selection are independent; no implicit fallback. + * The refusal names the explicit selection to rerun with. `command`, when given, + * is a public example command line (no user values) shown after that selection. + */ export function requireNativeComposeBackend(opts: { readonly backend: string | undefined; + readonly command?: string; }): void { if (opts.backend !== undefined && opts.backend !== "compose") { + const rerun = opts.command + ? `, for example: HACK_RUNTIME_BACKEND=compose ${opts.command}` + : "."; throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: - "Native project configuration currently requires the Compose backend. The selected runtime was not changed.", + message: `Native project configuration currently requires the Compose backend. The selected runtime was not changed. Rerun with HACK_RUNTIME_BACKEND=compose${rerun}`, }); } } diff --git a/tests/native-compose-selection.test.ts b/tests/native-compose-selection.test.ts index 3169d83c6..8994a3432 100644 --- a/tests/native-compose-selection.test.ts +++ b/tests/native-compose-selection.test.ts @@ -138,7 +138,24 @@ test("authored format never silently changes an explicitly selected runtime", () } for (const backend of ["native", "smol", "", "COMPOSE"]) { expect(() => requireNativeComposeBackend({ backend })).toThrow( - "Native project configuration currently requires the Compose backend" + "Native project configuration currently requires the Compose backend. The selected runtime was not changed. Rerun with HACK_RUNTIME_BACKEND=compose." ); } }); + +test("the backend refusal names the exact rerun for a read-only preview", () => { + expect(() => + requireNativeComposeBackend({ + backend: "native", + command: "hack config adopt --dry-run", + }) + ).toThrow( + "Rerun with HACK_RUNTIME_BACKEND=compose, for example: HACK_RUNTIME_BACKEND=compose hack config adopt --dry-run" + ); + expect(() => + requireNativeComposeBackend({ + backend: "compose", + command: "hack config adopt --dry-run", + }) + ).not.toThrow(); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..3dd5369cf 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -1348,6 +1348,48 @@ test("readiness goals cover only services the reviewed plan marks active", () => ).toEqual([]); }); +test("a reviewed one-shot service waits for successful completion", () => { + const image = `sha256:${"e".repeat(64)}`; + const specs = { + init: { image, healthcheck: { test: ["CMD", "true"] } }, + seed: { image, profiles: ["debug"] }, + web: { image }, + }; + const plan = (oneShot: unknown) => ({ + enrollment_compatible: true, + services: { + init: { active: true, one_shot: oneShot }, + // An inactive one-shot gets no goal: the runtime refuses inactive goals. + seed: { active: false, one_shot: true }, + web: { active: true }, + }, + }); + // The review's typed `one_shot`, not the label text, selects completion; + // it overrides the healthcheck default a long-running service would get. + expect( + readyGoals( + nativeReadinessArgs({ + specs, + plan: plan(true), + initializers: new Set(), + routed: new Set(), + }) + ).sort() + ).toEqual(["init=completed", "web=started"]); + for (const value of [false, "true", undefined]) { + expect( + readyGoals( + nativeReadinessArgs({ + specs, + plan: plan(value), + initializers: new Set(), + routed: new Set(), + }) + ).sort() + ).toEqual(["init=healthy", "web=started"]); + } +}); + async function profileStartup(profiles: readonly string[] | undefined) { const { opts } = await fixture(false); const prepare = opts.dependencies.prepare!; diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts index a89d07381..d57a9e1a1 100644 --- a/tests/native-refusal-cli.test.ts +++ b/tests/native-refusal-cli.test.ts @@ -219,6 +219,27 @@ const stoppedGraph = { observations: { "container:web": { state: "absent" } }, }; +test( + "config adopt --dry-run on the native backend names the exact Compose rerun before any request", + async () => { + const f = await fixture(); + const before = await readdir(f.projectDir); + for (const [args, command] of [ + [["config", "adopt", "--dry-run"], "hack config adopt --dry-run"], + [["config", "adopt"], "hack config adopt"], + ] as const) { + const result = await cli({ fixture: f, args }); + expectOneClosedLine( + result, + `E_NATIVE_PROJECT_UNSUPPORTED: Native project configuration currently requires the Compose backend. The selected runtime was not changed. Rerun with HACK_RUNTIME_BACKEND=compose, for example: HACK_RUNTIME_BACKEND=compose ${command}` + ); + } + expect(await f.calls()).toEqual([]); + expect(await readdir(f.projectDir)).toEqual(before); + }, + SPAWN_TIMEOUT_MS +); + test( "native-project-run: an unbounded profile is one coded line before any request", async () => { From da25b897a06b571295a818f804593073f7c6dfb4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:13:00 -0500 Subject: [PATCH 016/138] test(native): scope port default redaction assertion to diagnostics --- packages/runtime-core/tests/project_contract.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 59f1e62ca..cda3775ba 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -894,9 +894,13 @@ fn unsupported_ports_are_located_diagnostics_collected_with_the_rest_of_the_revi assert_eq!(plan.services["web"].ports[0].published, Some(8080)); assert_eq!(plan.services["web"].exposed_ports, vec!["3000"]); let serialized = serde_json::to_string(&report).unwrap(); - for authored in ["10.9.8.7", "OTHER_BIND", "EXPOSED", "4000"] { + for authored in ["10.9.8.7", "OTHER_BIND", "EXPOSED", "${EXPOSED:-4000}"] { assert!(!serialized.contains(authored), "{authored}"); } + // Report paths and hashes may contain numeric substrings by coincidence. + // The authored default must stay out of diagnostic text specifically. + let diagnostics = serde_json::to_string(&plan.diagnostics).unwrap(); + assert!(!diagnostics.contains("4000")); assert_eq!( project::enroll(&fixture.candidate, fixture.options(), &report.plan_id) .unwrap_err() From dc9e0b439d9fa05ab5a0fbcf1b689c1183eff85b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:21:47 -0500 Subject: [PATCH 017/138] fix(native): admit the exact one-shot marker at native startup admission prepareNativeProjectServices refused every service labeled hack.service.one-shot before the native review could classify it, so the reviewed completion goal was unreachable through hack up. The frontend label allowlist now admits only the exact key with the literal value "true" (map form) or the exact entry hack.service.one-shot=true (list form), matching the native review. Other values, bare keys, sibling keys and ownership labels still refuse. Co-Authored-By: Claude Opus 5.5 --- src/backends/native-project-routing.ts | 29 ++++++-- tests/native-project-routing.test.ts | 26 ++++++++ tests/native-project-start.test.ts | 91 ++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 5 deletions(-) diff --git a/src/backends/native-project-routing.ts b/src/backends/native-project-routing.ts index bb78fb0e9..c48255603 100644 --- a/src/backends/native-project-routing.ts +++ b/src/backends/native-project-routing.ts @@ -10,26 +10,45 @@ function refused(): Error { "Native routes require reviewed Caddy labels and existing matching native HTTP health probes; no probe or route was invented.", }); } -function labelKeys(labels: unknown): string[] | undefined { +/** + * Hack's authored one-shot marker. Only this exact key with the literal value `true` + * (map form) or the exact entry `hack.service.one-shot=true` (list form) is admitted, + * matching the native review; the review owns its completion role and conflicts. + */ +const ONE_SHOT_LABEL = "hack.service.one-shot"; +function labelEntries( + labels: unknown +): (readonly [string, unknown])[] | undefined { if (labels === undefined) { return []; } if (isRecord(labels)) { - return Object.keys(labels); + return Object.entries(labels); } if ( Array.isArray(labels) && labels.every((label) => typeof label === "string") ) { - return labels.map((label: string) => label.split("=", 1)[0] ?? ""); + return labels.map((label: string) => { + const separator = label.indexOf("="); + return separator < 0 + ? [label, undefined] + : [label.slice(0, separator), label.slice(separator + 1)]; + }); } return undefined; } +function labelKeys(labels: unknown): string[] | undefined { + return labelEntries(labels)?.map(([key]) => key); +} /** Values and complete routing semantics remain native review's responsibility. */ export function hasOnlyNativeSupportedLabels(labels: unknown): boolean { return ( - labelKeys(labels)?.every( - (key) => ROUTE_LABELS.has(key) || hasOnlyNativeCacheLabels({ [key]: "" }) + labelEntries(labels)?.every( + ([key, value]) => + ROUTE_LABELS.has(key) || + hasOnlyNativeCacheLabels({ [key]: "" }) || + (key === ONE_SHOT_LABEL && value === "true") ) ?? false ); } diff --git a/tests/native-project-routing.test.ts b/tests/native-project-routing.test.ts index 24088cbff..a66525386 100644 --- a/tests/native-project-routing.test.ts +++ b/tests/native-project-routing.test.ts @@ -57,6 +57,32 @@ test("supported routing and cache label keys remain bounded without rewriting va ) ).toThrow(); }); +test("only the exact one-shot marker joins the supported label keys", () => { + expect( + hasOnlyNativeSupportedLabels({ "hack.service.one-shot": "true" }) + ).toBe(true); + expect(hasOnlyNativeSupportedLabels(["hack.service.one-shot=true"])).toBe( + true + ); + for (const nearMiss of [ + { "hack.service.one-shot": "false" }, + { "hack.service.one-shot": "TRUE" }, + { "hack.service.one-shot": true }, + { "hack.service.one-shot": "" }, + { "hack.service.one-shot.extra": "true" }, + { "hack.service.oneshot": "true" }, + { "hack.service.other": "true" }, + { "hack.x": "true" }, + { "io.hack-local.owner": "true", "hack.service.one-shot": "true" }, + ["hack.service.one-shot"], + ["hack.service.one-shot="], + ["hack.service.one-shot=false"], + ["hack.service.one-shot=true=extra"], + ["hack.service.one-shot.extra=true"], + ]) { + expect(hasOnlyNativeSupportedLabels(nearMiss)).toBe(false); + } +}); test("active reviewed routes request pool allocation with matching existing probes", () => { const opts = { plan: { diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 3dd5369cf..d9b02746e 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -511,6 +511,47 @@ test.each([ }); }); +test.each([ + { labels: { "hack.service.one-shot": "true" } }, + { labels: ["hack.service.one-shot=true"] }, +])("native admission accepts Hack's exact one-shot marker %j", ({ labels }) => { + const specs = prepareNativeProjectServices({ + normalizedComposeJson: JSON.stringify({ + services: { init: { image: "busybox", command: ["true"], labels } }, + }), + } as NativeProjectInput); + // The label is preserved for the native review; it is not rewritten here. + expect(specs.init?.labels).toEqual(labels); +}); + +test.each([ + { labels: { "hack.service.one-shot": "false" } }, + { labels: { "hack.service.one-shot": true } }, + { labels: { "hack.service.one-shot.extra": "true" } }, + { labels: { "hack.service.other": "true" } }, + { labels: ["hack.service.one-shot"] }, + { labels: ["hack.service.one-shot=yes"] }, +])("a near-miss one-shot label %j is a coded configuration refusal", ({ + labels, +}) => { + const refusal = (() => { + try { + prepareNativeProjectServices({ + normalizedComposeJson: JSON.stringify({ + services: { init: { image: "busybox", labels } }, + }), + } as NativeProjectInput); + } catch (error: unknown) { + return error; + } + })(); + expect(refusal).toBeInstanceOf(HackCliError); + expect(refusal).toMatchObject({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: NATIVE_CONFIGURATION_REFUSAL, + }); +}); + test("a canceled start reports cancellation, not the configuration refusal", async () => { const { opts, events } = await fixture(); const refusal = await startNativeProject({ @@ -1438,6 +1479,56 @@ async function profileStartup(profiles: readonly string[] | undefined) { }; } +test.each([ + { labels: { "hack.service.one-shot": "true" } }, + { labels: ["hack.service.one-shot=true"] }, +])("native up admits a labeled one-shot and waits for its completion %j", async ({ + labels, +}) => { + const { opts } = await fixture(false); + const prepare = opts.dependencies.prepare!; + const review = opts.dependencies.review!; + const serve = opts.dependencies.serve!; + const image = `sha256:${"e".repeat(64)}`; + let serveArgs: readonly string[] = []; + opts.dependencies.prepare = async (call) => ({ + ...(await prepare(call)), + normalizedComposeJson: JSON.stringify({ + services: { + init: { image, command: ["true"], labels }, + web: { image }, + }, + }), + serviceNames: ["init", "web"], + }); + opts.dependencies.review = async (call) => + await review({ + ...call, + run: async (checked) => + call.run({ + ...checked, + report: { + plan: { + enrollment_compatible: true, + services: { + init: { active: true, one_shot: true }, + web: { active: true }, + }, + }, + }, + }), + }); + opts.dependencies.serve = async (call) => { + serveArgs = call.args; + return await serve(call); + }; + expect(await startNativeProject(opts)).toBe(0); + expect(readyGoals(serveArgs).sort()).toEqual([ + "init=completed", + "web=started", + ]); +}); + test("native up omits routed and unrouted inactive-profile services from readiness", async () => { const started = await profileStartup(undefined); expect(started.reviewed).not.toContain("debug"); From eeaf50fc9463b342d7e5c3c4a9d5826903492dd7 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:22:18 -0500 Subject: [PATCH 018/138] fix(native): bound nested default words by the expanded budget Nested default and alternative words were built in full before the top-level resolve charged them, so repeated or nested references could allocate far past the 1 MiB budget (multiplying per nesting level) before execution_input_budget refused. Each literal, escape and nested value is now checked against the remaining budget before it is appended; the budget is still charged once, when the finished value is appended at top level. Tests: a peak-heap contract proves single-level and nested repetition refuse without building past the budget, and an exact-fit case proves nested output is charged exactly once. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/project/inputs.rs | 20 ++- .../tests/compose_fields_contract.rs | 24 +++ .../tests/interpolation_budget_contract.rs | 142 ++++++++++++++++++ 3 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 packages/runtime-core/tests/interpolation_budget_contract.rs diff --git a/packages/runtime-core/src/project/inputs.rs b/packages/runtime-core/src/project/inputs.rs index 70e5bccb3..4dbaf18f9 100644 --- a/packages/runtime-core/src/project/inputs.rs +++ b/packages/runtime-core/src/project/inputs.rs @@ -146,6 +146,20 @@ impl Resolver<'_> { self.remaining -= text.len(); Ok(()) } + /// Append one piece of a default/alternative word. Nested words are built + /// before the top-level `resolve` charges them, so every intermediate is + /// bounded by the remaining budget here; the budget itself is charged once, + /// when `resolve` appends the finished value. + fn append(&self, result: &mut String, piece: &str) -> Result<(), CandidateError> { + if piece.len() > self.remaining.saturating_sub(result.len()) { + return Err(error( + "execution_input_budget", + "Executable values contain NUL or exceed the 1 MiB expanded budget.", + )); + } + result.push_str(piece); + Ok(()) + } fn owned(&self, name: &str) -> Result<(), CandidateError> { if self.managed.contains(name) { return Err(error( @@ -226,7 +240,7 @@ impl Resolver<'_> { return Err(unsupported_interpolation()); } if live { - result.push_str(literal); + self.append(&mut result, literal)?; } if index == rest.len() { return Ok(result); @@ -234,7 +248,7 @@ impl Resolver<'_> { rest = &rest[index + 1..]; if let Some(tail) = rest.strip_prefix('$') { if live { - result.push('$'); + self.append(&mut result, "$")?; } rest = tail; continue; @@ -247,7 +261,7 @@ impl Resolver<'_> { } let end = braced_end(braced).ok_or_else(unsupported_interpolation)?; let value = self.expression(&braced[..end], depth + 1, live)?; - result.push_str(&value); + self.append(&mut result, &value)?; rest = &braced[end + 1..]; } } diff --git a/packages/runtime-core/tests/compose_fields_contract.rs b/packages/runtime-core/tests/compose_fields_contract.rs index 972275518..629a0051f 100644 --- a/packages/runtime-core/tests/compose_fields_contract.rs +++ b/packages/runtime-core/tests/compose_fields_contract.rs @@ -316,6 +316,30 @@ fn nested_defaults_share_the_expanded_budget() { ); } +#[test] +fn nested_default_words_charge_the_budget_exactly_once() { + // `echo` (4 bytes) + a nested word of two 400 KiB values + a second + // top-level value fill the 1 MiB budget exactly. A double charge of nested + // output would refuse the exact fit; one more byte must refuse. + let fixture = Fixture::new( + "services:\n web:\n image: alpine:3.21\n command: [echo, '${A:-${B}${B}}', '${C:-${D}}']\n", + ); + let half = "x".repeat(400 * 1024); + let fill = "y".repeat(1024 * 1024 - 4 - 2 * half.len()); + let compiled = fixture.compile(&[("B", &half), ("D", &fill)]).unwrap(); + let command = compiled.services["web"].command.clone().unwrap(); + assert_eq!(command[1].len(), 2 * half.len()); + assert_eq!(command[2], fill); + let over = format!("{fill}y"); + assert_eq!( + fixture + .compile(&[("B", &half), ("D", &over)]) + .err() + .as_deref(), + Some("execution_input_budget") + ); +} + #[test] fn references_to_managed_service_inputs_refuse_instead_of_using_defaults() { let canary = "synthetic-managed-canary"; diff --git a/packages/runtime-core/tests/interpolation_budget_contract.rs b/packages/runtime-core/tests/interpolation_budget_contract.rs new file mode 100644 index 000000000..7a289571d --- /dev/null +++ b/packages/runtime-core/tests/interpolation_budget_contract.rs @@ -0,0 +1,142 @@ +//! Nested Compose default words are bounded by the expanded-input budget while +//! they are built, not only after the outer expression returns. This binary +//! holds a single test because it measures process-wide peak heap use. +use hack_runtime_core::{ + Candidate, + project::{self, PlanOptions, inputs}, +}; +use std::{ + alloc::{GlobalAlloc, Layout, System}, + collections::BTreeMap, + fs, + io::Read, + path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, +}; + +struct Peak; +static CURRENT: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); +impl Peak { + fn grow(bytes: usize) { + let now = CURRENT.fetch_add(bytes, Ordering::SeqCst) + bytes; + PEAK.fetch_max(now, Ordering::SeqCst); + } + fn reset() -> usize { + let now = CURRENT.load(Ordering::SeqCst); + PEAK.store(now, Ordering::SeqCst); + now + } +} +// SAFETY: every call is forwarded unchanged to the system allocator; only +// atomic counters are updated, which never allocate. +unsafe impl GlobalAlloc for Peak { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let pointer = unsafe { System.alloc(layout) }; + if !pointer.is_null() { + Self::grow(layout.size()); + } + pointer + } + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + unsafe { System.dealloc(pointer, layout) }; + CURRENT.fetch_sub(layout.size(), Ordering::SeqCst); + } + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, size: usize) -> *mut u8 { + let moved = unsafe { System.realloc(pointer, layout, size) }; + if !moved.is_null() { + if size >= layout.size() { + Self::grow(size - layout.size()); + } else { + CURRENT.fetch_sub(layout.size() - size, Ordering::SeqCst); + } + } + moved + } +} +#[global_allocator] +static ALLOCATOR: Peak = Peak; + +struct Fixture { + root: PathBuf, + project: PathBuf, + candidate: Candidate, +} +impl Fixture { + fn new(compose: &str) -> Self { + let mut bytes = [0; 16]; + fs::File::open("/dev/urandom") + .unwrap() + .read_exact(&mut bytes) + .unwrap(); + let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let root = std::env::temp_dir() + .canonicalize() + .unwrap() + .join(format!("hack-budget-{token}")); + fs::create_dir(&root).unwrap(); + let project = root.join("project"); + let checkout = root.join("candidate"); + fs::create_dir(&project).unwrap(); + fs::create_dir(&checkout).unwrap(); + fs::write(project.join("compose.yaml"), compose).unwrap(); + Self { + root, + project, + candidate: Candidate::discover(&checkout).unwrap(), + } + } + /// Compile with `values`, returning the error code and the peak heap growth + /// observed during compilation. + fn compile(&self, values: &BTreeMap) -> (Option, usize) { + let options = PlanOptions { + branch: None, + project: &self.project, + compose_file: Path::new("compose.yaml"), + profiles: &[], + }; + let review = project::plan(&self.candidate, options).unwrap(); + let options = PlanOptions { + branch: None, + project: &self.project, + compose_file: Path::new("compose.yaml"), + profiles: &[], + }; + let baseline = Peak::reset(); + let result = inputs::compile(&self.candidate, options, &review.plan_id, values); + let peak = PEAK.load(Ordering::SeqCst) - baseline; + (result.err().map(|error| error.code.to_owned()), peak) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).unwrap(); + } +} + +#[test] +fn nested_default_words_refuse_before_building_past_the_budget() { + const MIB: usize = 1024 * 1024; + let values: BTreeMap = [("B".to_owned(), "x".repeat(600 * 1024))] + .into_iter() + .collect(); + // One level: 64 copies of a 600 KiB value would build ~37.5 MiB before the + // top-level charge refused it. + let repeated = "${B}".repeat(64); + let fixture = Fixture::new(&format!( + "services:\n web:\n image: alpine:3.21\n command: [echo, '${{A:-{repeated}}}']\n" + )); + let (code, peak) = fixture.compile(&values); + assert_eq!(code.as_deref(), Some("execution_input_budget")); + assert!(peak < 8 * MIB, "single-level word peaked at {peak} bytes"); + // Repeated nesting multiplies per level (64 * 8 * 8 copies, ~2.3 GiB here, + // from a 17 KiB file) unless each intermediate word is bounded as built. + let inner = format!("${{C:-{repeated}}}").repeat(8); + let middle = format!("${{D:-{inner}}}").repeat(8); + let fixture = Fixture::new(&format!( + "services:\n web:\n image: alpine:3.21\n command: [echo, '${{A:-{middle}}}']\n" + )); + let (code, peak) = fixture.compile(&values); + assert_eq!(code.as_deref(), Some("execution_input_budget")); + assert!(peak < 8 * MIB, "nested word peaked at {peak} bytes"); +} From 108d80efc5aab5fcac67209f14037b5d663ab01b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:22:30 -0500 Subject: [PATCH 019/138] fix(native): verify one-off image volumes against parent storage A one-off job keeps the parent service's mounts, but its image check saw only the new job container, so an image VOLUME covered by the parent's owned named volume was always refused with graph_image_volume_uncovered. Verify against the parent receipt's volume resources plus the job container; parent containers are still not re-inspected. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/graph/one_off/config.rs | 59 +++++++++++++++++++ .../src/provider/graph/one_off/runtime.rs | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/graph/one_off/config.rs b/packages/runtime-core/src/provider/graph/one_off/config.rs index 5b9fd3667..3ccd81bce 100644 --- a/packages/runtime-core/src/provider/graph/one_off/config.rs +++ b/packages/runtime-core/src/provider/graph/one_off/config.rs @@ -61,6 +61,23 @@ pub(super) fn prepare( Ok(config) } +/// Resources against which one-off images are verified: the parent receipt's volumes +/// (the storage `prepare` keeps) plus the new job container. Parent containers are +/// left out, so `verify_images` still inspects only the job's own image. +pub(super) fn verification_resources( + receipt: &Receipt, + key: &str, + resource: &Resource, +) -> BTreeMap { + receipt + .resources + .iter() + .filter(|(_, owned)| owned.kind == Kind::Volume) + .map(|(name, owned)| (name.clone(), owned.clone())) + .chain([(key.to_owned(), resource.clone())]) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -97,4 +114,46 @@ mod tests { wrapped["HostConfig"]["Mounts"][0]["Target"] = json!("/run/hack-environment.json"); assert!(prepare(&wrapped, &receipt, &resource, &[], None).is_err()); } + + #[test] + fn job_image_volume_is_covered_by_the_parent_owned_named_volume() { + use crate::provider::graph::image_volumes::{UNCOVERED, admit}; + let run = "a".repeat(32); + let volume = format!("hkg-{run}-volume-0"); + let receipt: Receipt = serde_json::from_value(json!({"version":1,"run":run,"owner":"b".repeat(32),"namespace":"c".repeat(64),"plan_id":"d".repeat(64),"phase":"ready-observed","readiness":{},"resources":{ + "volume:data":{"kind":"volume","key":"data","name":volume,"id":"vol-id","image":null,"phase":"observed"}, + "container:db":{"kind":"container","key":"db","name":format!("hkg-{run}-container-0"),"id":"ctr-id","image":"sha256:pin","phase":"observed"} + }})).unwrap(); + let key = "container:job-123"; + let resource: Resource = serde_json::from_value(json!({"kind":"container","key":"job-123","name":format!("hkg-{run}-container-1"),"id":null,"image":"sha256:pin","phase":"reserved"})).unwrap(); + let template = json!({"Image":"sha256:pin","HostConfig":{"Mounts":[{"Type":"volume","Source":volume,"Target":"/var/lib/data","ReadOnly":false}]}}); + let config = prepare(&template, &receipt, &resource, &["migrate".into()], None).unwrap(); + let resources = verification_resources(&receipt, key, &resource); + // Only the job container is verified; parent containers are not re-inspected. + let containers: Vec<_> = resources + .iter() + .filter(|(_, r)| r.kind == Kind::Container) + .map(|(k, _)| k.as_str()) + .collect(); + assert_eq!(containers, [key]); + let declared = json!({"/var/lib/data":{}}); + admit(&declared, &config, &resources).unwrap(); + // Without the parent volumes the same covered target was refused. + let job_only = BTreeMap::from([(key.to_owned(), resource.clone())]); + assert_eq!( + admit(&declared, &config, &job_only).unwrap_err().code, + UNCOVERED + ); + // A declared target the parent storage does not cover still refuses. + assert_eq!( + admit( + &json!({"/var/lib/data":{},"/var/log/data":{}}), + &config, + &resources + ) + .unwrap_err() + .code, + UNCOVERED + ); + } } diff --git a/packages/runtime-core/src/provider/graph/one_off/runtime.rs b/packages/runtime-core/src/provider/graph/one_off/runtime.rs index 3aced9bc7..0e3b30fc9 100644 --- a/packages/runtime-core/src/provider/graph/one_off/runtime.rs +++ b/packages/runtime-core/src/provider/graph/one_off/runtime.rs @@ -59,7 +59,7 @@ pub fn begin( }; let expected_environment = verify_images( &engine, - &BTreeMap::from([(key.clone(), resource.clone())]), + &config::verification_resources(&original, &key, &resource), &mut configs, &private, )?; From fc26dd91cb999c15b581dd51b28b4cc69d62f7a1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:24:42 -0500 Subject: [PATCH 020/138] feat(native): review interpolated port defaults and IPv6 loopback ports Native Compose review now parses port entries that use one interpolation per component with a literal default, such as "${BIND:-127.0.0.1}:${PORT:-8108}:8108", instead of reporting them as invalid. Review stays value-free: native publication uses the literal default, warns with interpolated_port_default at services..ports[], and records only the variable names (published_reference, host_ip_reference; omitted when unused, so literal plans keep their identity). A reference without a literal default, a nested default, an interpolated target or protocol, or a reference spanning components is a located unresolved_port_binding error, so the rest of the review still runs and a profile-gated service no longer blocks the default plan. Short-form splitting follows Docker's port-spec rule after interpolation, so bracketed and bare IPv6 hosts parse. ::1 is kept, :: is narrowed to ::1, and a specific non-loopback host stays an unsupported_host_binding error, now located at its entry with a clearer message. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 20 + .../runtime-core/src/project/compose/mod.rs | 1 + .../runtime-core/src/project/compose/ports.rs | 514 ++++++++++++++++++ .../src/project/compose/service.rs | 133 +---- packages/runtime-core/src/project/mod.rs | 6 + .../runtime-core/tests/project_contract.rs | 71 ++- 6 files changed, 602 insertions(+), 143 deletions(-) create mode 100644 packages/runtime-core/src/project/compose/ports.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 953da5c67..655b40f71 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -232,6 +232,26 @@ are refused with the general native configuration refusal. A basic build is also refused by native image-project `up`, with the general refusal: it needs the authored native format. +Native review reads Compose `ports` without reading environment values. Each +entry may be short (`[host_ip:][published:]target[/protocol]`, with an IPv6 host +in brackets such as `[::1]:8080:80`) or long (`target`, `published`, `host_ip`, +`protocol`). The host IP and published port may each be one interpolation with a +literal default, such as `"${SEARCH_BIND:-127.0.0.1}:${SEARCH_PORT:-8108}:8108"`. +Native always uses the literal default, even when the variable is set, and warns +with `interpolated_port_default`; the plan records only the variable names. A +reference without a literal default (`${PORT}`, `${PORT:?message}`), a nested +default, an interpolated target or protocol, or one reference that spans several +components is an `unresolved_port_binding` error at +`services..ports[]`. The review still reports every other finding, +and a service in an unselected profile does not block the plan. + +Native publishes only on host loopback. `127.0.0.1` and `::1` are kept. A bare +port or an all-interface binding (`0.0.0.0`, `::`) is narrowed to `127.0.0.1` or +`::1`, with a `proposed_loopback_binding` warning. Compose would bind every +interface in that case; native does not expose development services to the +network. A specific non-loopback address, such as a LAN address, is an +`unsupported_host_binding` error. Keep such a project on the Compose backend. + `hack doctor` reports the same features for the current project in its `compose backend features` check. With `--json`, the check's `composeBackend.features` lists the closed names `external_network`, `external_volume`, `reserved_label`, diff --git a/packages/runtime-core/src/project/compose/mod.rs b/packages/runtime-core/src/project/compose/mod.rs index be2935aa0..e87125c1f 100644 --- a/packages/runtime-core/src/project/compose/mod.rs +++ b/packages/runtime-core/src/project/compose/mod.rs @@ -4,6 +4,7 @@ mod dependency_cache; mod fields; mod graph; mod ownership_labels; +mod ports; pub(super) mod routing; mod service; use fields::*; diff --git a/packages/runtime-core/src/project/compose/ports.rs b/packages/runtime-core/src/project/compose/ports.rs new file mode 100644 index 000000000..86de55285 --- /dev/null +++ b/packages/runtime-core/src/project/compose/ports.rs @@ -0,0 +1,514 @@ +//! Review of Compose `ports` entries. +//! +//! Review is value-free: it never reads ambient environment values. A port +//! component may therefore use interpolation only in the form +//! `${NAME:-literal}` or `${NAME-literal}`, and native publication always uses +//! the declared literal default. The plan records the variable name (never a +//! value) and a warning says that ambient values are not read. A reference with +//! no literal default, a nested or non-literal default, or a reference that +//! spans more than one component is a located `unresolved_port_binding` +//! error, so the rest of the review still runs and the plan is not +//! enrollment compatible. +//! +//! Short-form splitting follows Docker's port-spec rule after interpolation: +//! the last component is the target, the one before it is the published port, +//! and anything before that is the host IP (optionally in IPv6 brackets). +//! +//! Host binding: native publishes only on host loopback. `127.0.0.1` and `::1` +//! are kept. An all-interface binding (`0.0.0.0`, `::` or no host IP) is +//! narrowed to the matching loopback address with a warning. Any other host +//! address is refused (`unsupported_host_binding`): binding a LAN interface +//! would expose a development service to the network. +use super::super::*; +use super::fields::*; +use serde_json::Value; + +pub(super) fn port_number(value: &str) -> Result { + value.parse::().ok().filter(|p| *p > 0).ok_or_else(|| { + problem( + "invalid_port", + "Ports must be literal integers from 1 to 65535; ranges are not supported.", + ) + }) +} + +const UNRESOLVED: &str = "Port interpolation needs one literal default per component, such as ${NAME:-8080}; native publication never reads ambient values."; +const TARGET_LITERAL: &str = + "Port targets must be literal; only the host IP and published port may use ${NAME:-literal}."; + +/// Review every declared port entry. An entry outside the modeled subset becomes +/// an error diagnostic that points at `services..ports[]` (messages +/// never echo the authored value), so one unsupported port neither hides the +/// rest of the review nor makes the plan enrollment compatible. +pub(super) fn ports( + value: Option<&Value>, + field: &str, + diagnostics: &mut Vec, +) -> Result, CandidateError> { + let Some(value) = value else { + return Ok(vec![]); + }; + let Some(values) = value.as_array() else { + diagnostics.push(Diagnostic::error( + "invalid_port", + field, + "Ports must be a list.", + )); + return Ok(vec![]); + }; + let mut result = Vec::new(); + for (index, value) in values.iter().enumerate() { + let located = format!("{field}[{index}]"); + match port(value, field, &located, diagnostics) { + Ok(plan) => result.push(plan), + Err(error) if ["invalid_port", "unresolved_port_binding"].contains(&error.code) => { + diagnostics.push(Diagnostic::error(error.code, &located, &error.message)) + } + Err(error) => return Err(error), + } + } + Ok(result) +} + +/// One port component after review: the literal native uses, and the variable +/// name when the literal came from a `${NAME:-literal}` default. +#[derive(Debug, PartialEq, Eq)] +struct Component { + literal: String, + reference: Option, +} + +impl Component { + fn literal(text: &str) -> Self { + Self { + literal: text.into(), + reference: None, + } + } +} + +/// A reference expanded into `text` at `start..end`. +struct Expanded { + name: String, + start: usize, + end: usize, +} + +/// Expand every `${NAME:-literal}` / `${NAME-literal}` to its literal default. +/// `$$` is a literal `$`. Any other reference form is unresolved. +fn expand(value: &str) -> Result<(String, Vec), CandidateError> { + let bytes = value.as_bytes(); + let mut text = String::new(); + let mut references = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'$' { + let next = value[index..] + .find('$') + .map_or(value.len(), |offset| index + offset); + text.push_str(&value[index..next]); + index = next; + continue; + } + if bytes.get(index + 1) == Some(&b'$') { + text.push('$'); + index += 2; + continue; + } + if bytes.get(index + 1) != Some(&b'{') { + return Err(problem("unresolved_port_binding", UNRESOLVED)); + } + let body_start = index + 2; + let close = value[body_start..] + .find('}') + .map(|offset| body_start + offset) + .ok_or_else(|| problem("unresolved_port_binding", UNRESOLVED))?; + let body = &value[body_start..close]; + let name_end = body + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(body.len()); + let name = &body[..name_end]; + let default = body[name_end..] + .strip_prefix(":-") + .or_else(|| body[name_end..].strip_prefix('-')); + let valid_name = name + .as_bytes() + .first() + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_'); + match default { + Some(default) if valid_name && !default.contains(['$', '{']) => { + let start = text.len(); + text.push_str(default); + references.push(Expanded { + name: name.into(), + start, + end: text.len(), + }); + index = close + 1; + } + _ => return Err(problem("unresolved_port_binding", UNRESOLVED)), + } + } + Ok((text, references)) +} + +/// Review one scalar that must be exactly one component. +fn component(value: &str) -> Result { + let (text, references) = expand(value)?; + components(&text, &references, &[(0, text.len())]).map(|mut parts| parts.remove(0)) +} + +/// Split expanded text into the given byte ranges. A component is either fully +/// literal or exactly one whole reference; a reference that is partial, mixed +/// with literal text or spans a separator is unresolved. +fn components( + text: &str, + references: &[Expanded], + ranges: &[(usize, usize)], +) -> Result, CandidateError> { + for reference in references { + let whole = ranges + .iter() + .any(|(start, end)| reference.start == *start && reference.end == *end); + // An empty default leaves an empty component, which the port number + // check refuses afterwards. + if !whole { + return Err(problem("unresolved_port_binding", UNRESOLVED)); + } + } + Ok(ranges + .iter() + .map(|(start, end)| Component { + literal: text[*start..*end].into(), + reference: references + .iter() + .find(|r| r.start == *start && r.end == *end) + .map(|r| r.name.clone()), + }) + .collect()) +} + +/// Short form `[host_ip:][published:]target[/protocol]` after expansion. +fn short_form( + value: &str, +) -> Result<(Component, Option, String, Component), CandidateError> { + let (text, references) = expand(value)?; + let (binding_end, protocol) = match text.rfind('/') { + Some(slash) => (slash, text[slash + 1..].to_owned()), + None => (text.len(), "tcp".to_owned()), + }; + if references.iter().any(|r| r.end > binding_end) { + return Err(problem("unresolved_port_binding", UNRESOLVED)); + } + let separators: Vec = text[..binding_end] + .match_indices(':') + .map(|(index, _)| index) + .collect(); + let ranges: Vec<(usize, usize)> = match separators.as_slice() { + [] => vec![(0, binding_end)], + [one] => vec![(0, *one), (one + 1, binding_end)], + [.., host_end, published_end] => vec![ + (0, *host_end), + (host_end + 1, *published_end), + (published_end + 1, binding_end), + ], + }; + let mut parts = components(&text, &references, &ranges)?; + let target = parts.pop().expect("one range at least"); + let published = parts.pop(); + let host = parts.pop().unwrap_or_else(|| Component::literal("0.0.0.0")); + Ok((host, published, protocol, target)) +} + +fn literal_target(component: Component) -> Result { + if component.reference.is_some() { + return Err(problem("unresolved_port_binding", TARGET_LITERAL)); + } + port_number(&component.literal) +} + +fn scalar_component(value: &Value) -> Result { + match value { + Value::String(s) => component(s), + Value::Number(n) => Ok(Component::literal(&n.to_string())), + _ => Err(problem("invalid_port", "Invalid port value.")), + } +} + +/// Strip IPv6 brackets, as Docker's port-spec parser does. +fn host_address(text: &str) -> &str { + text.strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .unwrap_or(text) +} + +fn port( + value: &Value, + field: &str, + located: &str, + diagnostics: &mut Vec, +) -> Result { + let (host, published, protocol, target) = if let Value::Object(m) = value { + keys( + m, + &["target", "published", "protocol", "host_ip"], + field, + diagnostics, + ); + let target = literal_target(scalar_component( + m.get("target") + .ok_or_else(|| problem("invalid_port", "Port target is required."))?, + )?)?; + let protocol = m + .get("protocol") + .map(string) + .transpose()? + .unwrap_or("tcp") + .to_owned(); + if protocol.contains('$') { + return Err(problem("unresolved_port_binding", UNRESOLVED)); + } + ( + m.get("host_ip") + .map(|v| string(v).and_then(component)) + .transpose()? + .unwrap_or_else(|| Component::literal("0.0.0.0")), + m.get("published").map(scalar_component).transpose()?, + protocol, + target, + ) + } else { + let text = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => return Err(problem("invalid_port", "Invalid port form.")), + }; + let (host, published, protocol, target) = short_form(&text)?; + (host, published, protocol, literal_target(target)?) + }; + if !["tcp", "udp"].contains(&protocol.as_str()) { + return Err(problem( + "invalid_port", + "Only TCP and UDP ports are modeled.", + )); + } + let published_reference = published.as_ref().and_then(|p| p.reference.clone()); + let published = published.map(|p| port_number(&p.literal)).transpose()?; + let host_ip_reference = host.reference.clone(); + let address = host_address(&host.literal); + // Keep the historical field and text for literal entries so their plan + // identity does not change. + match address { + "127.0.0.1" | "::1" => {} + "0.0.0.0" => diagnostics.push(Diagnostic::warning("proposed_loopback_binding",field,"Enrollment proposes restricting the declared all-interface publication to 127.0.0.1. No port is bound by this checkpoint.")), + "::" => diagnostics.push(Diagnostic::warning( + "proposed_loopback_binding", + located, + "Enrollment proposes restricting the declared all-interface IPv6 publication to ::1.", + )), + // An entry with this error is never enrollment compatible, so its + // location and text are free to be precise. + _ => diagnostics.push(Diagnostic::error( + "unsupported_host_binding", + located, + "Native publishes ports only on host loopback (127.0.0.1 or ::1) or narrows an all-interface binding to it; a specific non-loopback host address is refused because it would expose the service to the network.", + )), + } + if published_reference.is_some() || host_ip_reference.is_some() { + diagnostics.push(Diagnostic::warning( + "interpolated_port_default", + located, + "Native publication uses the declared literal default; ambient values are not read.", + )); + } + let (declared_host_ip, proposed_host_ip) = match address { + "0.0.0.0" | "127.0.0.1" => (address.to_owned(), "127.0.0.1"), + "::" | "::1" => (address.to_owned(), "::1"), + _ => ("[unsupported]".to_owned(), "127.0.0.1"), + }; + Ok(PortPlan { + target, + published, + protocol, + declared_host_ip, + proposed_host_ip: proposed_host_ip.into(), + published_reference, + host_ip_reference, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn review(value: Value) -> (Result, Vec) { + let mut diagnostics = Vec::new(); + let result = port( + &value, + "services.s.ports", + "services.s.ports[0]", + &mut diagnostics, + ); + (result, diagnostics) + } + + fn accepted(value: Value) -> (PortPlan, Vec) { + let (result, diagnostics) = review(value.clone()); + let plan = result.unwrap_or_else(|error| panic!("{value}: {}", error.code)); + assert!( + diagnostics.iter().all(|d| d.severity == "warning"), + "{value}: {diagnostics:?}" + ); + (plan, diagnostics.into_iter().map(|d| d.code).collect()) + } + + fn refused(value: Value) -> String { + review(value.clone()) + .0 + .map(|_| ()) + .expect_err(&value.to_string()) + .code + .to_owned() + } + + #[test] + fn interpolated_defaults_publish_their_literal_and_record_only_names() { + let (plan, codes) = accepted(json!("${A_BIND:-127.0.0.1}:${A_PORT:-8108}:8108")); + assert_eq!( + (plan.declared_host_ip.as_str(), plan.published, plan.target), + ("127.0.0.1", Some(8108), 8108) + ); + assert_eq!(plan.host_ip_reference.as_deref(), Some("A_BIND")); + assert_eq!(plan.published_reference.as_deref(), Some("A_PORT")); + assert_eq!(codes, vec!["interpolated_port_default"]); + + let (plan, codes) = accepted(json!("${A_PORT-8108}:8108/udp")); + assert_eq!( + (plan.published, plan.protocol.as_str()), + (Some(8108), "udp") + ); + assert_eq!(plan.host_ip_reference, None); + assert_eq!( + codes, + vec!["proposed_loopback_binding", "interpolated_port_default"] + ); + + let (plan, codes) = accepted( + json!({"target": 80, "published": "${P:-8080}", "host_ip": "${B:-127.0.0.1}"}), + ); + assert_eq!(plan.published, Some(8080)); + assert_eq!(plan.published_reference.as_deref(), Some("P")); + assert_eq!(plan.host_ip_reference.as_deref(), Some("B")); + assert_eq!(codes, vec!["interpolated_port_default"]); + } + + #[test] + /// Plan identity is the hash of the serialized plan, diagnostics included. + /// Literal entries must serialize exactly as before the interpolation + /// fields existed, with the same warning field and text. + fn literal_entries_keep_their_plan_fields() { + let (result, diagnostics) = review(json!("8108:8108")); + assert_eq!( + serde_json::to_string(&result.unwrap()).unwrap(), + r#"{"target":8108,"published":8108,"protocol":"tcp","declared_host_ip":"0.0.0.0","proposed_host_ip":"127.0.0.1"}"# + ); + assert_eq!( + serde_json::to_string(&diagnostics).unwrap(), + r#"[{"severity":"warning","code":"proposed_loopback_binding","field":"services.s.ports","message":"Enrollment proposes restricting the declared all-interface publication to 127.0.0.1. No port is bound by this checkpoint."}]"# + ); + let (plan, codes) = accepted(json!("8108")); + assert_eq!((plan.published, plan.target), (None, 8108)); + assert_eq!(codes, vec!["proposed_loopback_binding"]); + let (plan, codes) = accepted(json!("127.0.0.1:8787:8787")); + assert!(codes.is_empty()); + assert_eq!( + serde_json::to_string(&plan).unwrap(), + r#"{"target":8787,"published":8787,"protocol":"tcp","declared_host_ip":"127.0.0.1","proposed_host_ip":"127.0.0.1"}"# + ); + } + + #[test] + fn ipv6_loopback_and_all_interface_bindings_map_to_ipv6_loopback() { + for value in [ + json!("[::1]:8080:80"), + json!("::1:8080:80"), + json!({"target": 80, "published": 8080, "host_ip": "::1"}), + ] { + let (plan, codes) = accepted(value); + assert_eq!( + ( + plan.declared_host_ip.as_str(), + plan.proposed_host_ip.as_str() + ), + ("::1", "::1") + ); + assert!(codes.is_empty()); + } + let (plan, codes) = accepted(json!("[::]:8080:80")); + assert_eq!(plan.proposed_host_ip, "::1"); + assert_eq!(codes, vec!["proposed_loopback_binding"]); + } + + #[test] + fn unresolvable_or_ambiguous_interpolation_is_a_located_refusal() { + for value in [ + json!("${A_PORT}:80"), + json!("$A_PORT:80"), + json!("${A_PORT:?required}:80"), + json!("${A_PORT:+8080}:80"), + json!("${A:-${B:-8080}}:80"), + json!("${A:-8080:80}"), + json!("1${A:-080}:80"), + json!("8080:${T:-80}"), + json!("8080:80/${P:-tcp}"), + json!("${A:-8080"), + json!({"target": "${T:-80}", "published": 8080}), + json!({"target": 80, "published": "${P}"}), + ] { + assert_eq!(refused(value.clone()), "unresolved_port_binding", "{value}"); + } + } + + #[test] + fn invalid_values_stay_invalid_and_foreign_hosts_are_refused() { + for value in [ + json!("70000:80"), + json!("8000-8001:80"), + json!("80/sctp"), + json!("${A:-}:80"), + json!("${A:-x}:80"), + ] { + assert_eq!(refused(value.clone()), "invalid_port", "{value}"); + } + let (result, diagnostics) = review(json!("${B:-10.9.8.7}:9092:82")); + let plan = result.unwrap(); + assert_eq!(plan.declared_host_ip, "[unsupported]"); + assert!( + diagnostics + .iter() + .any(|d| d.code == "unsupported_host_binding" + && d.severity == "error" + && d.field == "services.s.ports[0]") + ); + } + + #[test] + fn messages_never_echo_authored_values() { + let canary = "C4NARY_VALUE"; + for value in [ + json!(format!("${{{canary}}}:80")), + json!(format!("${{X:-{canary}}}:80")), + json!(format!("${{X:-10.1.2.3}}:${{Y:-{canary}}}:80")), + ] { + let mut diagnostics = Vec::new(); + let _ = ports(Some(&json!([value])), "services.s.ports", &mut diagnostics); + let text = serde_json::to_string(&diagnostics).unwrap(); + assert!( + !text.contains(canary) && !text.contains("10.1.2.3"), + "{text}" + ); + assert!(!diagnostics.is_empty()); + } + } +} diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index f46142091..5c7645938 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -1,5 +1,6 @@ use super::super::*; use super::fields::*; +use super::ports::{port_number, ports}; use serde_json::{Map, Value}; use std::{collections::BTreeSet, fs, os::unix::fs::MetadataExt}; @@ -227,138 +228,6 @@ fn mounts( } Ok(result) } -fn port_number(value: &str) -> Result { - value.parse::().ok().filter(|p| *p>0).ok_or_else(|| problem("invalid_port", "Ports must be literal integers from 1 to 65535; ranges/interpolation are not supported.")) -} -fn port_scalar(value: &Value) -> Result { - match value { - Value::String(s) => port_number(s), - Value::Number(n) => port_number(&n.to_string()), - _ => Err(problem("invalid_port", "Invalid port value.")), - } -} -/// Review every declared port entry. An entry outside the modeled subset becomes -/// an `invalid_port` error diagnostic that points at `services..ports[]` -/// (messages never echo the authored value), so one unsupported port neither -/// hides the rest of the review nor makes the plan enrollment compatible. -fn ports( - value: Option<&Value>, - field: &str, - diagnostics: &mut Vec, -) -> Result, CandidateError> { - let Some(value) = value else { - return Ok(vec![]); - }; - let Some(values) = value.as_array() else { - diagnostics.push(Diagnostic::error( - "invalid_port", - field, - "Ports must be a list.", - )); - return Ok(vec![]); - }; - let mut result = Vec::new(); - for (index, value) in values.iter().enumerate() { - match port(value, field, diagnostics) { - Ok(plan) => result.push(plan), - Err(error) if error.code == "invalid_port" => diagnostics.push(Diagnostic::error( - "invalid_port", - &format!("{field}[{index}]"), - &error.message, - )), - Err(error) => return Err(error), - } - } - Ok(result) -} -fn port( - value: &Value, - field: &str, - diagnostics: &mut Vec, -) -> Result { - let (target, published, protocol, host) = if let Value::Object(m) = value { - keys( - m, - &["target", "published", "protocol", "host_ip"], - field, - diagnostics, - ); - ( - port_scalar( - m.get("target") - .ok_or_else(|| problem("invalid_port", "Port target is required."))?, - )?, - m.get("published").map(port_scalar).transpose()?, - m.get("protocol") - .map(string) - .transpose()? - .unwrap_or("tcp") - .to_owned(), - m.get("host_ip") - .map(string) - .transpose()? - .unwrap_or("0.0.0.0") - .to_owned(), - ) - } else { - let text = match value { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - _ => return Err(problem("invalid_port", "Invalid port form.")), - }; - let (binding, protocol) = text.split_once('/').unwrap_or((&text, "tcp")); - let parts: Vec<_> = binding.split(':').collect(); - match parts.as_slice() { - [target] => ( - port_number(target)?, - None, - protocol.into(), - "0.0.0.0".into(), - ), - [published, target] => ( - port_number(target)?, - Some(port_number(published)?), - protocol.into(), - "0.0.0.0".into(), - ), - [host, published, target] => ( - port_number(target)?, - Some(port_number(published)?), - protocol.into(), - host.to_string(), - ), - _ => { - return Err(problem( - "invalid_port", - "Unsupported port syntax. Use a literal [host_ip:]published:target[/protocol] entry; interpolation, ranges and IPv6 bindings are not supported.", - )); - } - } - }; - if !["tcp", "udp"].contains(&protocol.as_str()) { - return Err(problem( - "invalid_port", - "Only TCP and UDP ports are modeled.", - )); - } - if !["0.0.0.0", "127.0.0.1"].contains(&host.as_str()) { - diagnostics.push(Diagnostic::error("unsupported_host_binding",field,"Only explicit IPv4 loopback or an explicit proposal to restrict an all-interface binding is supported.")); - } - if host == "0.0.0.0" { - diagnostics.push(Diagnostic::warning("proposed_loopback_binding",field,"Enrollment proposes restricting the declared all-interface publication to 127.0.0.1. No port is bound by this checkpoint.")); - } - Ok(PortPlan { - target, - published, - protocol, - declared_host_ip: if ["0.0.0.0", "127.0.0.1"].contains(&host.as_str()) { - host - } else { - "[unsupported]".into() - }, - proposed_host_ip: "127.0.0.1".into(), - }) -} fn dependencies( value: Option<&Value>, field: &str, diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index 043076259..8fa3f5cec 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -117,6 +117,12 @@ pub struct PortPlan { pub protocol: String, pub declared_host_ip: String, pub proposed_host_ip: String, + /// Variable name whose literal default supplied `published`; never a value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub published_reference: Option, + /// Variable name whose literal default supplied the host IP; never a value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_ip_reference: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(deny_unknown_fields)] diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index cda3775ba..cfada40a6 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -835,7 +835,6 @@ fn invalid_limits_ports_commands_and_conflicting_owners_are_rejected() { " ports: ['70000:80']\n", " ports: ['8000-8001:80']\n", " ports: ['80/sctp']\n", - " ports: ['[::1]:8080:80']\n", ] { let fixture = Fixture::new(&format!("{BASIC}{extra}")); let plan = fixture.plan().plan; @@ -864,7 +863,7 @@ fn invalid_limits_ports_commands_and_conflicting_owners_are_rejected() { #[test] fn unsupported_ports_are_located_diagnostics_collected_with_the_rest_of_the_review() { let fixture = Fixture::new( - "services:\n web:\n image: alpine:3.21\n privileged: true\n ports: ['8080:80']\n expose: ['3000', '${EXPOSED:-4000}']\n debug:\n image: alpine:3.21\n profiles: [debug]\n ports: ['${BIND:-127.0.0.1}:9090:80', '9091:81', '${OTHER_BIND:-10.9.8.7}:9092:82']\n", + "services:\n web:\n image: alpine:3.21\n privileged: true\n ports: ['8080:80']\n expose: ['3000', '${EXPOSED:-4000}']\n debug:\n image: alpine:3.21\n profiles: [debug]\n ports: ['${BIND}:9090:80', '9091:81', '${OTHER_BIND:-10.9.8.7}:9092:82', '${DEBUG_PORT:-9093}:${TARGET:-83}']\n", ); let report = project::plan(&fixture.candidate, fixture.options()).unwrap(); let plan = &report.plan; @@ -872,15 +871,16 @@ fn unsupported_ports_are_located_diagnostics_collected_with_the_rest_of_the_revi let located: Vec<_> = plan .diagnostics .iter() - .filter(|d| d.code == "invalid_port") - .map(|d| (d.severity.as_str(), d.field.as_str())) + .filter(|d| d.severity == "error" && d.field.contains('[')) + .map(|d| (d.code.as_str(), d.field.as_str())) .collect(); assert_eq!( located, vec![ - ("error", "services.debug.ports[0]"), - ("error", "services.debug.ports[2]"), - ("error", "services.web.expose[1]"), + ("invalid_port", "services.web.expose[1]"), + ("unresolved_port_binding", "services.debug.ports[0]"), + ("unresolved_port_binding", "services.debug.ports[3]"), + ("unsupported_host_binding", "services.debug.ports[2]"), ] ); assert!( @@ -889,18 +889,32 @@ fn unsupported_ports_are_located_diagnostics_collected_with_the_rest_of_the_revi .any(|d| d.code == "privileged_service" && d.field == "services.web") ); assert!(!plan.services["debug"].active); - assert_eq!(plan.services["debug"].ports.len(), 1); - assert_eq!(plan.services["debug"].ports[0].published, Some(9091)); + let debug_ports = &plan.services["debug"].ports; + assert_eq!(debug_ports.len(), 2); + assert_eq!(debug_ports[0].published, Some(9091)); + assert_eq!(debug_ports[1].declared_host_ip, "[unsupported]"); + assert_eq!( + debug_ports[1].host_ip_reference.as_deref(), + Some("OTHER_BIND") + ); assert_eq!(plan.services["web"].ports[0].published, Some(8080)); assert_eq!(plan.services["web"].exposed_ports, vec!["3000"]); let serialized = serde_json::to_string(&report).unwrap(); - for authored in ["10.9.8.7", "OTHER_BIND", "EXPOSED", "${EXPOSED:-4000}"] { + for authored in [ + "10.9.8.7", + "EXPOSED", + "${EXPOSED:-4000}", + "DEBUG_PORT", + "TARGET", + ] { assert!(!serialized.contains(authored), "{authored}"); } // Report paths and hashes may contain numeric substrings by coincidence. // The authored default must stay out of diagnostic text specifically. let diagnostics = serde_json::to_string(&plan.diagnostics).unwrap(); - assert!(!diagnostics.contains("4000")); + for authored in ["4000", "BIND", "9093"] { + assert!(!diagnostics.contains(authored), "{authored}"); + } assert_eq!( project::enroll(&fixture.candidate, fixture.options(), &report.plan_id) .unwrap_err() @@ -910,6 +924,41 @@ fn unsupported_ports_are_located_diagnostics_collected_with_the_rest_of_the_revi assert!(!fixture.candidate.state_root.exists()); } +/// A profile-gated service whose ports use literal interpolation defaults no +/// longer aborts the review: the default profile plan is enrollment compatible, +/// records the variable names only, and selecting the profile still leaves +/// publication to graph admission. +#[test] +fn interpolated_port_defaults_review_to_their_literal_and_keep_literal_plans_stable() { + let literal = Fixture::new(&format!("{BASIC} ports: ['127.0.0.1:8108:8108']\n")); + let literal_report = project::plan(&literal.candidate, literal.options()).unwrap(); + assert!(literal_report.plan.enrollment_compatible); + let literal_text = serde_json::to_string(&literal_report.plan).unwrap(); + assert!( + !literal_text.contains("published_reference") + && !literal_text.contains("host_ip_reference") + ); + + let fixture = Fixture::new( + "services:\n web:\n image: alpine:3.21\n command: [echo, hello]\n search:\n image: alpine:3.21\n profiles: [local-search]\n ports: ['${SEARCH_BIND:-127.0.0.1}:${SEARCH_PORT:-8108}:8108']\n", + ); + let report = project::plan(&fixture.candidate, fixture.options()).unwrap(); + let plan = &report.plan; + assert!(plan.enrollment_compatible, "{:?}", plan.diagnostics); + assert!(!plan.services["search"].active); + let port = &plan.services["search"].ports[0]; + assert_eq!( + (port.declared_host_ip.as_str(), port.published, port.target), + ("127.0.0.1", Some(8108), 8108) + ); + assert_eq!(port.host_ip_reference.as_deref(), Some("SEARCH_BIND")); + assert_eq!(port.published_reference.as_deref(), Some("SEARCH_PORT")); + assert!(plan.diagnostics.iter().any(|d| d.severity == "warning" + && d.code == "interpolated_port_default" + && d.field == "services.search.ports[0]")); + assert!(!fixture.candidate.state_root.exists()); +} + #[test] fn host_root_secret_mounts_symlink_escape_and_special_ignore_files_are_refused() { let fixture = Fixture::new(&format!("{BASIC} volumes: ['/:/host']\n")); From cbb45524b53ea6781df835356edb577b2db727a3 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:26:34 -0500 Subject: [PATCH 021/138] fix(env): keep .hack/.env while Compose interpolates a service-only key A contract key with `services: [...]` migrates only to a service scope, and service values never reach Compose interpolation; Hack passes Compose only global values under their own names. Compose reads `.hack/.env` for interpolation on its own, so deleting it after the migration dropped a `${KEY}` value that launches still needed. The migration now keeps `.hack/.env` when a service-only key in it is named in the Compose file or another env_file it lists (or is a COMPOSE_ setting), and says why in both the implicit and doctor reports, without values. Compose input this check can't fully read keeps the file. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 10 +- src/commands/doctor.ts | 3 +- src/lib/project-env-config.ts | 166 ++++++++++++++++++-- tests/env-legacy-service-scope.test.ts | 18 ++- tests/env-migration-tracked-consent.test.ts | 75 ++++++++- 5 files changed, 248 insertions(+), 24 deletions(-) diff --git a/docs/env.md b/docs/env.md index 394e39152..08c5e857b 100644 --- a/docs/env.md +++ b/docs/env.md @@ -1117,9 +1117,10 @@ Commands that need env values, such as `hack env exec`, also migrate a project that has `hack.env.json` and no env layers, without asking. That migration reports each moved key, never its value, and removes a legacy `.hack/.env` or `.hack/.env.` file only when every entry now lives in an env layer, Compose no longer names it as -`env_file`, and git doesn't track it, so no second plaintext copy stays behind. It keeps +`env_file`, Compose doesn't interpolate a key from it that the env layers now set only for a +service, and git doesn't track it, so no second plaintext copy stays behind. It keeps `hack.env.json`, never edits the Compose file and never deletes a git-tracked file; it names -a kept file that Compose still lists. Run `hack doctor --migrate-env-config` for those +a kept file and says why. Run `hack doctor --migrate-env-config` for those changes. Like doctor, it rewrites legacy keys in `hack.config.json`. A repo that has no `.hack/hack.env.json` is migrated as well. Its values live only in @@ -1341,6 +1342,11 @@ Doctor offers legacy dotenv files for cleanup only when every parsed entry was delivered to an env layer. Files containing undeclared or undelivered entries stay on disk; the cleanup report lists their key names and omits their values. This also applies to untracked overlays such as `.env.bak` and `.env.example`. +Doctor also keeps `.hack/.env` when the contract scopes a key in it to services +(`services: [...]`) and the Compose file or another `env_file` it lists names that key, +for example in `${KEY}`. Compose reads `.hack/.env` for interpolation on its own, and +service values don't reach interpolation, so removing the file would drop that value. +To remove the file, set the key without `--service` or export it in your shell. Migration refuses a legacy `.env.default` overlay before writing env layers, because it would overwrite the base `hack.env.default.yaml` layer. Rename that legacy overlay and update its project references before retrying. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index cf15d0872..8f7790f2c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -170,6 +170,7 @@ import { import { runProjectDomainCommand } from "../lib/project-domain-command.ts"; import { canMigrateCheckoutLegacyDotenv, + describeRetainedLegacyEnvFile, discoverComposeServiceNames, ensureHackDirGitignore, HACK_DIR_GITIGNORE_READ_ONLY_WARNING, @@ -4014,7 +4015,7 @@ async function maybeMigrateProjectEnvConfig(opts: { for (const retained of migrated.retainedEnvFiles) { note( - `Kept ${retained.path}: keys not preserved in env layers: ${retained.keys.join(", ")} (values omitted).`, + describeRetainedLegacyEnvFile({ shownPath: retained.path, retained }), "env cleanup" ); } diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 1e8b2fd89..c8bb08f67 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -5144,12 +5144,34 @@ export type LegacyProjectEnvMigration = { readonly cleanupCandidates: readonly string[]; readonly blockedCleanupCandidates: readonly string[]; readonly composeEnvFileReferences: readonly LegacyComposeEnvFileReference[]; - readonly retainedEnvFiles: readonly { - readonly path: string; - readonly keys: readonly string[]; - }[]; + readonly retainedEnvFiles: readonly RetainedLegacyEnvFile[]; }; +/** + * A legacy dotenv file the migration keeps, and why: + * - `undelivered`: env layers don't hold these keys with the file's values. + * - `compose_interpolation`: Compose interpolates these keys, which the env + * layers now set only for a service. Compose reads `.hack/.env` on its own, + * and Hack passes it only global values, so removing the file would drop them. + */ +export type RetainedLegacyEnvFile = { + readonly path: string; + readonly keys: readonly string[]; + readonly reason: "undelivered" | "compose_interpolation"; +}; + +/** Value-free line saying why a legacy env file was kept. */ +export function describeRetainedLegacyEnvFile(opts: { + readonly shownPath: string; + readonly retained: RetainedLegacyEnvFile; +}): string { + const keys = opts.retained.keys.join(", "); + if (opts.retained.reason === "compose_interpolation") { + return `Kept ${opts.shownPath}: Compose interpolates ${keys}, which the env layers now set only for a service, and service values don't reach interpolation (values omitted). To remove the file, set ${opts.retained.keys.length === 1 ? "that key" : "those keys"} without --service or export ${opts.retained.keys.length === 1 ? "it" : "them"} in your shell.`; + } + return `Kept ${opts.shownPath}: keys not preserved in env layers: ${keys} (values omitted).`; +} + /** * Migrates a legacy v2 `.env`-based project env layout to the v3 * `hack.env.*.yaml` config system. @@ -5456,6 +5478,15 @@ export async function migrateLegacyProjectEnv(opts: { configCleanupCandidates: configCleanup.cleanupCandidates, deliveredValues, composeEnvFileReferences, + composeInterpolatedKeys: new Map([ + [ + resolve(opts.projectDir, PROJECT_ENV_FILENAME), + await composeInterpolatedServiceOnlyKeys({ + projectDir: opts.projectDir, + resolvedValues: baseResolved.values, + }), + ], + ]), }); return { @@ -5726,7 +5757,10 @@ export async function finishImplicitLegacyEnvMigration(opts: { } for (const retained of migrated.retainedEnvFiles) { lines.push( - `Kept ${shown(retained.path)}: keys not preserved in env layers: ${retained.keys.join(", ")} (values omitted).` + describeRetainedLegacyEnvFile({ + shownPath: shown(retained.path), + retained, + }) ); } for (const path of migrated.blockedCleanupCandidates) { @@ -8235,6 +8269,100 @@ async function readLegacyDotenvCleanupEntries(opts: { return parseDotEnv(source.text); } +/** + * Keys from the base `.hack/.env` that the contract migration moves only into + * service scopes and that Compose may interpolate. Compose reads `.hack/.env` + * from its project directory for interpolation on its own, while Hack passes + * Compose only global values under their own names, so after the migration + * that file is the only interpolation source for these keys. + * + * A key counts when its name appears anywhere in the Compose file or another + * `env_file` it names (Compose expands variables there too), or when it is one + * of Compose's own `COMPOSE_*` settings. Matching the name anywhere keeps the + * file more often than it must, which is the safe direction. When the Compose + * input can't be fully read here (unreadable, not one YAML document, or using + * `include` or `extends`), every service-only key counts. Key names only. + */ +async function composeInterpolatedServiceOnlyKeys(opts: { + readonly projectDir: string; + readonly resolvedValues: readonly { + readonly key: string; + readonly value: string | null; + readonly services: readonly string[] | null; + }[]; +}): Promise> { + // Mirrors buildMigratedValues: no services, or a blank or `global` entry, reaches the global scope. + const serviceOnly = opts.resolvedValues + .filter( + (entry) => + entry.value !== null && + entry.services !== null && + entry.services.length > 0 && + entry.services.every( + (scope) => scope.trim().length > 0 && scope !== "global" + ) + ) + .map((entry) => entry.key); + if (serviceOnly.length === 0) { + return new Set(); + } + const composeFile = resolve(opts.projectDir, PROJECT_COMPOSE_FILENAME); + const composeRead = await readOptionalTextFile(composeFile); + if (composeRead.kind === "absent") { + return new Set(); + } + const parsed = + composeRead.kind === "text" ? await readComposeFile({ composeFile }) : null; + const services = parsed ? (getRecord(parsed, "services") ?? {}) : {}; + if ( + composeRead.kind !== "text" || + (parsed === null && composeRead.text.trim().length > 0) || + (parsed !== null && "include" in parsed) || + composeServiceEntries(services).some(([, service]) => "extends" in service) + ) { + return new Set(serviceOnly); + } + const searchText = `${composeRead.text}\n${await composeOtherEnvFileText({ + projectDir: opts.projectDir, + services, + })}`; + return new Set( + serviceOnly.filter( + // Keys match PROJECT_ENV_KEY_PATTERN, so they are safe in a pattern. + (key) => + COMPOSE_OWN_KEY_PATTERN.test(key) || + new RegExp(`(?>; + readonly delivered: ReadonlyMap; + readonly interpolated: ReadonlySet | undefined; +}): RetainedLegacyEnvFile | null { + const undelivered = Object.entries(opts.entries) + .filter(([key, value]) => opts.delivered.get(key) !== value) + .map(([key]) => key) + .sort(); + if (undelivered.length > 0) { + return { path: opts.path, keys: undelivered, reason: "undelivered" }; + } + const interpolated = Object.keys(opts.entries) + .filter((key) => opts.interpolated?.has(key) === true) + .sort(); + if (interpolated.length > 0) { + return { + path: opts.path, + keys: interpolated, + reason: "compose_interpolation", + }; + } + return null; +} + export async function collectLegacyProjectEnvCleanupCandidates(opts: { readonly projectRoot: string; readonly projectDir: string; @@ -8246,13 +8374,16 @@ export async function collectLegacyProjectEnvCleanupCandidates(opts: { >; readonly configCleanupCandidates: readonly string[]; readonly composeEnvFileReferences: readonly LegacyComposeEnvFileReference[]; + /** + * Per legacy file, keys Compose still reads from that file for + * interpolation that no env layer delivers to it. A file that sets one is + * kept even when env layers hold all its values. + */ + readonly composeInterpolatedKeys?: ReadonlyMap>; }): Promise<{ readonly allowed: readonly string[]; readonly blocked: readonly string[]; - readonly retainedEnvFiles: readonly { - readonly path: string; - readonly keys: readonly string[]; - }[]; + readonly retainedEnvFiles: readonly RetainedLegacyEnvFile[]; }> { const candidates = new Set(); candidates.add(resolve(opts.projectDir, PROJECT_ENV_CONTRACT_FILENAME)); @@ -8271,7 +8402,7 @@ export async function collectLegacyProjectEnvCleanupCandidates(opts: { const blockedPaths = new Set( opts.composeEnvFileReferences.map((reference) => reference.resolvedPath) ); - const retainedEnvFiles: { path: string; keys: string[] }[] = []; + const retainedEnvFiles: RetainedLegacyEnvFile[] = []; const allowed: string[] = []; const blocked: string[] = []; for (const path of candidates) { @@ -8280,13 +8411,14 @@ export async function collectLegacyProjectEnvCleanupCandidates(opts: { // must never promote a file holding undelivered values into cleanup. const delivered = opts.deliveredValues.get(path); if (delivered) { - const entries = await readLegacyDotenvCleanupEntries({ path }); - const keys = Object.entries(entries) - .filter(([key, value]) => delivered.get(key) !== value) - .map(([key]) => key) - .sort(); - if (keys.length > 0) { - retainedEnvFiles.push({ path, keys }); + const retained = retainLegacyEnvFile({ + path, + entries: await readLegacyDotenvCleanupEntries({ path }), + delivered, + interpolated: opts.composeInterpolatedKeys?.get(path), + }); + if (retained) { + retainedEnvFiles.push(retained); continue; } } diff --git a/tests/env-legacy-service-scope.test.ts b/tests/env-legacy-service-scope.test.ts index e1a704abb..152f94d5b 100644 --- a/tests/env-legacy-service-scope.test.ts +++ b/tests/env-legacy-service-scope.test.ts @@ -4547,7 +4547,10 @@ test("in git, a tracked local layer still in the working tree gets the tracked-l test("cleanup keeps a legacy file holding a value its env doesn't receive, naming the key, and offers one it does", async () => { const repo = await writeTeammateCheckout(); const envPath = resolve(repo.projectDir, ".env"); - const collect = (delivered: string | null) => + const collect = ( + delivered: string | null, + interpolated: readonly string[] = [] + ) => collectLegacyProjectEnvCleanupCandidates({ projectRoot: repo.projectRoot, projectDir: repo.projectDir, @@ -4561,11 +4564,12 @@ test("cleanup keeps a legacy file holding a value its env doesn't receive, namin ]), configCleanupCandidates: [], composeEnvFileReferences: [], + composeInterpolatedKeys: new Map([[envPath, new Set(interpolated)]]), }); const undelivered = await collect(null); expect(undelivered.retainedEnvFiles).toEqual([ - { path: envPath, keys: ["KEPT_SETTING"] }, + { path: envPath, keys: ["KEPT_SETTING"], reason: "undelivered" }, ]); expect(undelivered.allowed).not.toContain(envPath); expect(undelivered.blocked).not.toContain(envPath); @@ -4573,12 +4577,20 @@ test("cleanup keeps a legacy file holding a value its env doesn't receive, namin const different = await collect("another-value"); expect(different.retainedEnvFiles).toEqual([ - { path: envPath, keys: ["KEPT_SETTING"] }, + { path: envPath, keys: ["KEPT_SETTING"], reason: "undelivered" }, ]); const delivered = await collect(KEPT_VALUE); expect(delivered.retainedEnvFiles).toEqual([]); expect(delivered.allowed).toContain(envPath); + + // Delivered only to a service, a key Compose interpolates still needs the file. + const interpolated = await collect(KEPT_VALUE, ["KEPT_SETTING"]); + expect(interpolated.retainedEnvFiles).toEqual([ + { path: envPath, keys: ["KEPT_SETTING"], reason: "compose_interpolation" }, + ]); + expect(interpolated.allowed).not.toContain(envPath); + expect(JSON.stringify(interpolated)).not.toContain(KEPT_VALUE); }); test("a migrated legacy file whose values its env now receives isn't retained, and waits on its Compose listing", async () => { diff --git a/tests/env-migration-tracked-consent.test.ts b/tests/env-migration-tracked-consent.test.ts index 4e31598f0..d5cdbe925 100644 --- a/tests/env-migration-tracked-consent.test.ts +++ b/tests/env-migration-tracked-consent.test.ts @@ -29,6 +29,12 @@ const CONTRACT = `${JSON.stringify({ const COMPOSE_WITH_ENV_FILE = "services:\n api:\n image: alpine:3.20\n env_file: .env\n"; const COMPOSE_PLAIN = "services:\n api:\n image: alpine:3.20\n"; +// Compose interpolates the key from `.hack/.env`, which it reads on its own. +const COMPOSE_INTERPOLATES_KEY = `services:\n api:\n image: "alpine:\${${KEY}:-3.20}"\n`; +const SERVICE_SCOPED_CONTRACT = `${JSON.stringify({ + version: 1, + vars: [{ key: KEY, source: "plain_env", required: false, services: ["api"] }], +})}\n`; const CONFIG = `${JSON.stringify({ name: "hack-1904", dev_host: "hack-1904.hack" })}\n`; const CLI_TEST_TIMEOUT_MS = 60_000; @@ -62,6 +68,7 @@ async function git(project: Project, args: readonly string[]) { /** A committed contract, config and Compose file, plus an untracked `.hack/.env`. */ async function createProject(opts: { readonly compose: string; + readonly contract?: string; }): Promise { const root = await realpath(await mkdtemp(join(tmpdir(), "hack-1904-"))); roots.push(root); @@ -70,7 +77,10 @@ async function createProject(opts: { await mkdir(projectDir, { recursive: true }); await writeFile(resolve(projectDir, "docker-compose.yml"), opts.compose); await writeFile(resolve(projectDir, "hack.config.json"), CONFIG); - await writeFile(resolve(projectDir, "hack.env.json"), CONTRACT); + await writeFile( + resolve(projectDir, "hack.env.json"), + opts.contract ?? CONTRACT + ); const project = { root, projectRoot, projectDir }; await git(project, ["init", "--quiet"]); await git(project, ["add", ".hack"]); @@ -307,6 +317,69 @@ test( CLI_TEST_TIMEOUT_MS ); +for (const route of ["implicit", "doctor"] as const) { + test( + `${route} migration keeps a .env whose service-scoped key Compose interpolates`, + async () => { + const project = await createProject({ + compose: COMPOSE_INTERPOLATES_KEY, + contract: SERVICE_SCOPED_CONTRACT, + }); + + const run = + route === "implicit" + ? await runImplicit(project) + : await runDoctor({ project, backend: "native", accept: true }); + + expect(run.exitCode, run.output).toBe(0); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain( + `Moved ${KEY} to .hack/hack.env.local.yaml (values omitted).` + ); + // Doctor wraps notes in a box and names absolute paths, so compare words. + const words = run.output.replace(/[\s\u2502]+/g, " "); + expect(words).toContain( + `Compose interpolates ${KEY}, which the env layers now set only for a service, and service values don't reach interpolation (values omitted).` + ); + if (route === "implicit") { + expect(run.output).toContain( + `Kept .hack/.env: Compose interpolates ${KEY}` + ); + } + expect(words).not.toContain("Removed .hack/.env"); + expect(words).not.toContain("can now be removed: - "); + // Service values don't reach interpolation, so Compose still needs this copy. + expect(await readFile(join(project.projectDir, ".env"), "utf8")).toBe( + `${KEY}=${VALUE}\n` + ); + expect( + await readFile(join(project.projectDir, "hack.env.local.yaml"), "utf8") + ).toContain(VALUE); + }, + CLI_TEST_TIMEOUT_MS + ); +} + +test( + "a service-scoped key Compose doesn't interpolate still drops the plaintext copy", + async () => { + const project = await createProject({ + compose: COMPOSE_PLAIN, + contract: SERVICE_SCOPED_CONTRACT, + }); + + const run = await runImplicit(project); + + expect(run.exitCode, run.output).toBe(0); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain("Removed .hack/.env"); + await expect( + readFile(join(project.projectDir, ".env"), "utf8") + ).rejects.toMatchObject({ code: "ENOENT" }); + }, + CLI_TEST_TIMEOUT_MS +); + test( "a shell-only contract value refusal doesn't claim a .hack/.env exists", async () => { From 3d40b5bd44d278be12e08a10bd3c57fa9984a66c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:28:20 -0500 Subject: [PATCH 022/138] fix(native): recover abandoned removal locks and release them on cancel `hack down --remove-data` took the shared native operation lock as a bare directory, so a removal killed while waiting for finalization or cleanup left a lock every rerun refused, and SIGINT/SIGTERM bypassed its release. Removal now links a complete owner record (operation, pid, nonce) into the same lock directory. A rerun reclaims the lock only when that recorded owner is gone (ESRCH), under a separate recovery guard that re-reads the owner, following the projects-registry lock protocol. Live, reused or inaccessible owners, owner-less locks (restart, filesystem recovery) and a busy guard still refuse. The command turns SIGINT/SIGTERM into an abort signal that kills and awaits the owned runtime request and ends the finalization wait, so the lock is released before the command fails. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 6 +- src/backends/native-project-down.ts | 34 ++- src/backends/native-project-finalization.ts | 4 +- src/backends/native-project-inspect.ts | 2 + src/backends/native-project-removal-lock.ts | 194 +++++++++++++++++ src/backends/native-project-run.ts | 13 +- src/commands/project-native-remove-data.ts | 47 +++-- tests/native-project-remove-data.test.ts | 221 +++++++++++++++++++- 8 files changed, 492 insertions(+), 29 deletions(-) create mode 100644 src/backends/native-project-removal-lock.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 4101792e0..bcb55b4c5 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -840,7 +840,11 @@ delete only the volumes that this run created. Shared dependency caches are rele deleted. The run mapping is retired only after every owned volume is confirmed absent, so the next `up` starts a new run with new empty volumes. If removal is interrupted or cannot be confirmed, the mapping is kept and running the same command again resumes from -the owner's durable cleanup intent. Output names no volumes or paths. Down hooks resolve +the owner's durable cleanup intent. Ctrl-C (SIGINT) or SIGTERM ends the owned runtime +request and releases the operation lock before the command fails. If the process is +killed outright, the rerun reclaims the abandoned lock only after its recorded process has +exited; a live owner, or a lock held by restart or recovery, still refuses. Output names +no volumes or paths. Down hooks resolve fresh managed host values using the environment selection saved by `up`; hook output goes to stderr with `--json`. A before-hook failure prevents cleanup; an after-hook failure reports that graph cleanup already completed. Environment/profile/target changes and cache pruning remain unsupported. diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index bd41fe8be..33fe5464f 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -13,6 +13,7 @@ import { } from "./native-project-finalization.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { recoverNativeInterruptedStartupCleanup } from "./native-project-interrupted-cleanup.ts"; +import { withNativeRemovalLock } from "./native-project-removal-lock.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; import { loadNativeProjectRun, @@ -20,7 +21,6 @@ import { type NativeProjectRun, type NativeProjectRunScope, removeNativeProjectRun, - withNativeRestartLock, } from "./native-project-run.ts"; import { invokeNativeRuntime, @@ -270,6 +270,18 @@ function removalRefused(cause?: unknown): Error { ); } +/** Canceled before the cleanup request: nothing was requested or removed. */ +function removalCanceled(): Error { + return new ClosedCliError( + "Native data removal was canceled before any cleanup request; nothing was removed and the run mapping is kept." + ); +} +function requireActiveRemoval(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw removalCanceled(); + } +} + /** Thrown when the graph still runs: removal never stops compute itself. */ function removalRequiresStop(): Error { return new ClosedCliError( @@ -392,9 +404,12 @@ function confirmRemoved(value: unknown, run: NativeProjectRun): void { * * Failure keeps the mapping. A rerun resumes from re-inspection: a durable * cleanup intent or an already removed receipt continues without replaying - * deletion decisions. Cancellation before the runtime request has no effect; - * after it the outcome is unknown and the mapping stays until a rerun - * confirms removal. + * deletion decisions. Cancellation (`signal`) before the runtime request has + * no effect; after it the owned request is killed and awaited, the outcome is + * unknown and the mapping stays until a rerun confirms removal. Either way + * the lock is released before this returns. A process killed outright leaves + * the lock behind; the rerun reclaims it once the recorded owner has exited + * (see {@link withNativeRemovalLock}). */ export async function nativeProjectRemoveData(opts: { readonly runtime: NativeRuntimeSelection; @@ -406,14 +421,18 @@ export async function nativeProjectRemoveData(opts: { readonly confirm?: () => Promise; readonly invoke?: typeof invokeNativeRuntime; readonly finalizationTimeoutMs?: number; + /** Ordinary cancellation (SIGINT/SIGTERM in the command). */ + readonly signal?: AbortSignal; }) { const invoke = opts.invoke ?? invokeNativeRuntime; + const { signal } = opts; const inspect = (run: NativeProjectRun) => inspectNativeProjectGraph({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, run: run.run, invoke, + signal, }); const notStarted = { backend: "native", @@ -433,7 +452,8 @@ export async function nativeProjectRemoveData(opts: { dataPreserved: true, } as const; } - return await withNativeRestartLock(opts.scope, async () => { + requireActiveRemoval(signal); + return await withNativeRemovalLock(opts.scope, async () => { if (await loadNativeRestartIntent(opts.scope)) { throw new ClosedCliError( "Native data removal refused: a native restart is pending; finish or recover it first. Nothing was removed." @@ -455,10 +475,13 @@ export async function nativeProjectRemoveData(opts: { run, token: finalization, timeoutMs: opts.finalizationTimeoutMs, + signal, }); } catch { + requireActiveRemoval(signal); throw finalizationRefused(); } + requireActiveRemoval(signal); try { if (start !== "removed") { await invoke({ @@ -473,6 +496,7 @@ export async function nativeProjectRemoveData(opts: { "--json", ], timeoutMs: 590_000, + signal, }); } confirmRemoved(await inspect(run), run); diff --git a/src/backends/native-project-finalization.ts b/src/backends/native-project-finalization.ts index fd949b136..4b9ab307c 100644 --- a/src/backends/native-project-finalization.ts +++ b/src/backends/native-project-finalization.ts @@ -494,6 +494,8 @@ export async function waitNativeProjectFinalization(opts: { readonly run: NativeProjectRun; readonly token: NativeProjectFinalizationToken; readonly timeoutMs?: number; + /** Abandons this read-only wait; it changes no finalization state. */ + readonly signal?: AbortSignal; }): Promise { const timeout = opts.timeoutMs ?? 150_000; if ( @@ -522,7 +524,7 @@ export async function waitNativeProjectFinalization(opts: { ) { return; } - if (performance.now() >= end) { + if (performance.now() >= end || opts.signal?.aborted) { throw refused(); } await Bun.sleep(Math.min(100, Math.max(1, end - performance.now()))); diff --git a/src/backends/native-project-inspect.ts b/src/backends/native-project-inspect.ts index a7b9e74cb..b6b0e0a8f 100644 --- a/src/backends/native-project-inspect.ts +++ b/src/backends/native-project-inspect.ts @@ -11,6 +11,7 @@ export async function inspectNativeProjectGraph(opts: { readonly projectRoot: string; readonly run: string; readonly invoke?: typeof invokeNativeRuntime; + readonly signal?: AbortSignal; }): Promise { const deadline = performance.now() + 2000; for (;;) { @@ -20,6 +21,7 @@ export async function inspectNativeProjectGraph(opts: { cwd: opts.projectRoot, args: ["graph", "inspect", "--run-id", opts.run, "--json"], timeoutMs: 30_000, + signal: opts.signal, }); } catch (error) { if ( diff --git a/src/backends/native-project-removal-lock.ts b/src/backends/native-project-removal-lock.ts new file mode 100644 index 000000000..b4fcfa167 --- /dev/null +++ b/src/backends/native-project-removal-lock.ts @@ -0,0 +1,194 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { link, lstat, mkdir, open, rmdir, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { + type NativeProjectRunScope, + nativeRestartOperationLock, +} from "./native-project-run.ts"; + +const OWNER_FILE = "owner"; +const OWNER_LIMIT = 128; +const OWNER_PATTERN = /^remove-data\n([1-9]\d{0,9})\n[a-f0-9-]{36}\n$/; + +function busy(): Error { + return new ClosedCliError( + "Native data removal refused: a native restart, recovery or removal holds the operation lock, or an interrupted one left it without proof that its owner exited; inspect it before recovery. Nothing was removed." + ); +} + +function hasCode(error: unknown, code: string): boolean { + return isRecord(error) && error.code === code; +} + +/** Only ESRCH proves exit; live, reused or inaccessible pids keep the lock. */ +function exited(pid: number): boolean { + try { + process.kill(pid, 0); + return false; + } catch (error) { + return hasCode(error, "ESRCH"); + } +} + +/** Exclusive `mkdir`; EEXIST reports an occupied path, anything else refuses. */ +async function enter(path: string): Promise { + try { + await mkdir(path, { mode: 0o700 }); + return true; + } catch (error) { + if (hasCode(error, "EEXIST")) { + return false; + } + throw busy(); + } +} + +/** + * The complete removal owner inside a private lock directory, or null for + * anything else: no owner (restart, filesystem recovery, an interrupted + * publish), a foreign or malformed record, or an unsafe path. + */ +async function readOwner( + lock: string +): Promise<{ readonly bytes: string; readonly pid: number } | null> { + const uid = process.getuid?.(); + const held = await lstat(lock).catch(() => null); + if ( + !held?.isDirectory() || + held.uid !== uid || + (held.mode & 0o777) !== 0o700 + ) { + return null; + } + const fd = await open( + join(lock, OWNER_FILE), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ).catch(() => null); + if (!fd) { + return null; + } + try { + const info = await fd.stat(); + if ( + !info.isFile() || + info.uid !== uid || + (info.mode & 0o077) !== 0 || + info.size < 1 || + info.size > OWNER_LIMIT + ) { + return null; + } + const buffer = Buffer.alloc(OWNER_LIMIT + 1); + const { bytesRead } = await fd.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead).toString("utf8"); + const pid = Number(OWNER_PATTERN.exec(bytes)?.[1]); + return Number.isSafeInteger(pid) && pid > 0 ? { bytes, pid } : null; + } finally { + await fd.close(); + } +} + +/** Retire an abandoned removal lock only after its recorded owner exited. */ +async function recoverExitedRemoval(lock: string): Promise { + // This observation narrows guard traffic; it never authorizes removal alone. + const observed = await readOwner(lock); + if (!(observed && exited(observed.pid))) { + return; + } + const guard = `${lock}.recovery`; + if (!(await enter(guard))) { + return; + } + try { + // Re-read inside the guard: another reclaimer may have retired it first. + const owner = await readOwner(lock); + if (owner?.bytes === observed.bytes && exited(owner.pid)) { + await unlink(join(lock, OWNER_FILE)); + await rmdir(lock); + } + } finally { + await rmdir(guard); + } +} + +async function releaseIfOwned(lock: string, bytes: string): Promise { + if ((await readOwner(lock))?.bytes === bytes) { + await unlink(join(lock, OWNER_FILE)); + await rmdir(lock); + } +} + +async function writeOwner(path: string, bytes: string): Promise { + const fd = await open( + path, + constants.O_CREAT | + constants.O_EXCL | + constants.O_WRONLY | + constants.O_NOFOLLOW, + 0o600 + ); + try { + await fd.writeFile(bytes); + await fd.sync(); + } finally { + await fd.close(); + } +} + +/** + * Hold the native operation lock for data removal. It is the same directory + * that restart and filesystem recovery take with `mkdir`, so every holder, + * including older binaries, still excludes the others. Removal also + * hard-links a complete owner record (operation, pid and nonce) into it. + * + * Recovery follows the projects-registry lock protocol: age is not + * authority. Only ESRCH for the recorded pid permits reclamation, under a + * separate `.recovery` guard that re-reads the owner first. A live, + * reused or inaccessible pid, a missing or malformed owner, and a lock held by + * restart or filesystem recovery (which record no owner) all still refuse. + * A removal killed between `mkdir` and publishing its owner leaves an + * owner-less lock that is never reclaimed, and the guard is never + * age-reclaimed; both require inspection. + * + * Reclaiming is sound for removal because the lock guards no effect that a + * dead owner can still be performing unseen: the runtime serializes graph + * cleanup under its own lease and durable cleanup intent, and the rerun + * re-inspects before requesting cleanup or retiring the mapping. + */ +export async function withNativeRemovalLock( + scope: NativeProjectRunScope, + action: () => Promise +): Promise { + const { root, lock } = await nativeRestartOperationLock(scope); + const bytes = `remove-data\n${process.pid}\n${randomUUID()}\n`; + const staging = join(root, `${randomUUID()}.tmp`); + await writeOwner(staging, bytes); + try { + if (!(await enter(lock))) { + await recoverExitedRemoval(lock).catch(() => undefined); + if (!(await enter(lock))) { + throw busy(); + } + } + try { + await link(staging, join(lock, OWNER_FILE)); + } catch { + await rmdir(lock); + throw busy(); + } + } finally { + await unlink(staging).catch((error: unknown) => { + if (!hasCode(error, "ENOENT")) { + throw error; + } + }); + } + try { + return await action(); + } finally { + await releaseIfOwned(lock, bytes); + } +} diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index 5ae8a1a50..e0d7d3e80 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -294,13 +294,22 @@ export type NativeRestartIntent = { readonly run: NativeProjectRun; readonly finalization: NativeProjectFinalizationToken; }; +/** + * The private directory and the operation lock path shared by restart, + * filesystem recovery and data removal. Creates the excluded directory. + */ +export async function nativeRestartOperationLock( + opts: NativeProjectRunScope +): Promise<{ readonly root: string; readonly lock: string }> { + const p = await paths(opts, true, "restart"); + return { root: p.root, lock: `${p.lock}.operation` }; +} /** Serialize cleanup and replacement hooks; uncertain abandoned ownership requires inspection. */ export async function withNativeRestartLock( opts: NativeProjectRunScope, action: (release: () => Promise) => Promise ): Promise { - const p = await paths(opts, true, "restart"); - const lock = `${p.lock}.operation`; + const { lock } = await nativeRestartOperationLock(opts); try { await mkdir(lock, { mode: 0o700 }); } catch { diff --git a/src/commands/project-native-remove-data.ts b/src/commands/project-native-remove-data.ts index a7698e6cb..eb147a19d 100644 --- a/src/commands/project-native-remove-data.ts +++ b/src/commands/project-native-remove-data.ts @@ -13,7 +13,9 @@ const CONFIRM_HINT = * deletion of a stopped project's graph-owned data volumes. Asks once unless * `--yes` is given; `--json` without `--yes` refuses with * `E_INTERACTIVE_REQUIRED` before any runtime request. Output names no - * volume, path or other authored value. + * volume, path or other authored value. SIGINT/SIGTERM cancel the removal: + * its owned runtime request is settled and the operation lock released + * before the command fails. */ export async function runNativeDownRemoveData(opts: { readonly runtime: NativeRuntimeSelection; @@ -28,22 +30,33 @@ export async function runNativeDownRemoveData(opts: { message: `Removing native project data requires confirmation. ${CONFIRM_HINT}`, }); } - const result = await (opts.remove ?? nativeProjectRemoveData)({ - runtime: opts.runtime, - scope: opts.scope, - ...(opts.yes - ? {} - : { - confirm: () => - confirmSafe({ - message: - "Delete this project's stopped native data volumes? This cannot be undone; shared dependency caches are kept.", - initialValue: false, - nonInteractive: "fail", - hint: CONFIRM_HINT, - }), - }), - }); + const controller = new AbortController(); + const cancel = () => controller.abort(); + process.on("SIGINT", cancel); + process.on("SIGTERM", cancel); + let result: Awaited>; + try { + result = await (opts.remove ?? nativeProjectRemoveData)({ + runtime: opts.runtime, + scope: opts.scope, + signal: controller.signal, + ...(opts.yes + ? {} + : { + confirm: () => + confirmSafe({ + message: + "Delete this project's stopped native data volumes? This cannot be undone; shared dependency caches are kept.", + initialValue: false, + nonInteractive: "fail", + hint: CONFIRM_HINT, + }), + }), + }); + } finally { + process.off("SIGINT", cancel); + process.off("SIGTERM", cancel); + } if (opts.json) { process.stdout.write(`${JSON.stringify(result)}\n`); return 0; diff --git a/tests/native-project-remove-data.test.ts b/tests/native-project-remove-data.test.ts index 517244181..4bbfe13cf 100644 --- a/tests/native-project-remove-data.test.ts +++ b/tests/native-project-remove-data.test.ts @@ -1,5 +1,12 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { + lstat, + mkdir, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { nativeProjectRemoveData } from "../src/backends/native-project-down.ts"; @@ -9,6 +16,7 @@ import { } from "../src/backends/native-project-finalization.ts"; import { loadNativeProjectRun, + nativeRestartOperationLock, removeNativeProjectRun, saveNativeProjectRun, saveNativeRestartIntent, @@ -95,7 +103,7 @@ function graph(phase: Phase, opts: { readonly remaining?: boolean } = {}) { }; } -async function fixture(saved = true) { +async function fixture(saved = true, finalized = true) { const projectRoot = await realpath( await mkdtemp(join(tmpdir(), "native-remove-data-")) ); @@ -109,7 +117,9 @@ async function fixture(saved = true) { await saveNativeProjectRun({ ...scope, run }); } const finalization = await beginNativeProjectFinalization({ scope, run }); - await finalization.complete(); + if (finalized) { + await finalization.complete(); + } return { scope, runtime: { binary: "/not-invoked", home: nativeHome } }; } @@ -346,6 +356,211 @@ test("a pending restart refuses under the restart lock", async () => { expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); +async function lockPresent(scope: Parameters[0]) { + const { lock } = await nativeRestartOperationLock(scope); + return await lstat(lock).then( + () => true, + () => false + ); +} + +/** Run a TypeScript child that imports repository modules by absolute path. */ +async function child(source: string, input: unknown) { + const dir = await realpath( + await mkdtemp(join(tmpdir(), "native-remove-data-child-")) + ); + roots.push(dir); + const script = join(dir, "child.ts"); + await writeFile(script, source); + const proc = Bun.spawn([process.execPath, script, JSON.stringify(input)], { + env: { ...process.env, HACK_DAEMON_AUTOSTART_DISABLE: "1" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const reader = proc.stdout.getReader(); + let seen = ""; + const waitFor = async (marker: string) => { + while (!seen.includes(marker)) { + const chunk = await reader.read(); + if (chunk.done) { + throw new Error(`child exited before ${marker}`); + } + seen += new TextDecoder().decode(chunk.value); + } + }; + return { proc, waitFor }; +} + +const source = (path: string) => JSON.stringify(resolve("src", path)); + +test("a live removal owner refuses; once it is killed the rerun reclaims the lock and finishes", async () => { + const opts = await fixture(); + const holder = await child( + `import { withNativeRemovalLock } from ${source("backends/native-project-removal-lock.ts")}; +setInterval(() => undefined, 1000); +await withNativeRemovalLock(JSON.parse(process.argv[2]), async () => { + process.stdout.write("held\\n"); + await new Promise(() => undefined); +}); +`, + opts.scope + ); + try { + await holder.waitFor("held"); + const live = runtime({ phases: ["removed"] }); + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke: live.invoke }), + "holds the operation lock" + ); + expect(live.verbs()).toEqual(["inspect"]); + } finally { + holder.proc.kill("SIGKILL"); + await holder.proc.exited; + } + // The killed owner ran no finally: its lock is abandoned, not released. + expect(await lockPresent(opts.scope)).toBe(true); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); + const rerun = runtime({ phases: ["removed"] }); + expect( + (await nativeProjectRemoveData({ ...opts, invoke: rerun.invoke })).status + ).toBe("removed"); + expect(rerun.verbs()).toEqual(["inspect", "inspect", "inspect"]); + expect(await loadNativeProjectRun(opts.scope)).toBeNull(); + expect(await lockPresent(opts.scope)).toBe(false); +}); + +test("locks without proof that a removal owner exited are never reclaimed", async () => { + const exited = Bun.spawn(["true"]); + await exited.exited; + const dead = exited.pid; + const nonce = `${"0".repeat(8)}-0000-0000-0000-${"0".repeat(12)}`; + const cases: Record Promise> = { + // Restart and filesystem recovery hold the directory with no owner. + "owner-less": async (lock) => { + await mkdir(lock, { mode: 0o700 }); + }, + "foreign operation": async (lock) => { + await mkdir(lock, { mode: 0o700 }); + await writeFile(join(lock, "owner"), `restart\n${dead}\n${nonce}\n`, { + mode: 0o600, + }); + }, + "recovery guard busy": async (lock) => { + await mkdir(lock, { mode: 0o700 }); + await writeFile(join(lock, "owner"), `remove-data\n${dead}\n${nonce}\n`, { + mode: 0o600, + }); + await mkdir(`${lock}.recovery`, { mode: 0o700 }); + }, + }; + for (const [name, plant] of Object.entries(cases)) { + const opts = await fixture(); + const { lock } = await nativeRestartOperationLock(opts.scope); + await plant(lock); + const r = runtime({ phases: ["removed"] }); + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke: r.invoke }), + "holds the operation lock" + ); + expect({ name, verbs: r.verbs() }).toEqual({ name, verbs: ["inspect"] }); + expect(await lockPresent(opts.scope)).toBe(true); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); + } +}); + +test("cancellation during cleanup settles the owned request and releases the lock", async () => { + const opts = await fixture(); + const controller = new AbortController(); + const phases: Phase[] = ["stopped-data-retained", "stopped-data-retained"]; + const verbs: string[] = []; + const invoke = (async (request) => { + verbs.push(request.args[1]!); + if (request.args[1] !== "cleanup") { + return graph(phases.shift() ?? "cleanup-intent"); + } + expect(request.signal).toBe(controller.signal); + controller.abort(); + // The runtime client kills and awaits its child, then reports the cancellation. + throw new NativeRuntimeRequestError({ + message: "Native runtime request was canceled.", + }); + }) as typeof invokeNativeRuntime; + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke, signal: controller.signal }), + "Native data removal is unconfirmed; the run mapping is kept" + ); + expect(verbs).toEqual(["inspect", "inspect", "cleanup"]); + expect(await lockPresent(opts.scope)).toBe(false); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); + const resumed = runtime({ + phases: ["cleanup-intent", "cleanup-intent", "removed"], + }); + expect( + (await nativeProjectRemoveData({ ...opts, invoke: resumed.invoke })).status + ).toBe("removed"); +}); + +test("cancellation while waiting for finalization requests no cleanup and releases the lock", async () => { + const opts = await fixture(true, false); + const controller = new AbortController(); + const r = runtime({ phases: ["stopped-data-retained"] }); + const removal = nativeProjectRemoveData({ + ...opts, + invoke: r.invoke, + signal: controller.signal, + finalizationTimeoutMs: 60_000, + }); + await Bun.sleep(200); + controller.abort(); + await expectClosedRefusal(removal, "canceled before any cleanup request"); + expect(r.verbs()).toEqual(["inspect", "inspect"]); + expect(await lockPresent(opts.scope)).toBe(false); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + +test("SIGINT during cleanup fails the command after releasing the lock", async () => { + const opts = await fixture(); + const removal = await child( + `import { nativeProjectRemoveData } from ${source("backends/native-project-down.ts")}; +import { runNativeDownRemoveData } from ${source("commands/project-native-remove-data.ts")}; +const input = JSON.parse(process.argv[2]); +setInterval(() => undefined, 1000); +const invoke = async (request) => { + if (request.args[1] !== "cleanup") { + return input.reply; + } + process.stdout.write("cleanup\\n"); + return await new Promise((_, reject) => + request.signal?.addEventListener("abort", () => reject(new Error("canceled"))) + ); +}; +try { + await runNativeDownRemoveData({ + ...input, + yes: true, + json: true, + remove: (o) => nativeProjectRemoveData({ ...o, invoke }), + }); + process.exit(0); +} catch (error) { + process.stderr.write(error.message); + process.exit(3); +} +`, + { ...opts, reply: graph("stopped-data-retained") } + ); + await removal.waitFor("cleanup"); + removal.proc.kill("SIGINT"); + expect(await removal.proc.exited).toBe(3); + expect(await new Response(removal.proc.stderr).text()).toContain( + "Native data removal is unconfirmed" + ); + expect(await lockPresent(opts.scope)).toBe(false); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); +}); + test("a mapping replaced while confirming is never removed", async () => { const opts = await fixture(); const replacement = { ...run, run: "9".repeat(32) }; From abcf36e08a099d3e62e309594e0fcb30d70c07ef Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:30:41 -0500 Subject: [PATCH 023/138] fix(native): keep disposable-cache volumes in data-service advisories x-hack-disposable-cache names regenerable directories inside a volume, and an empty list is valid; everything outside those directories is retained data. Doctor no longer treats such a volume as a cache, so a database volume with a declared cache directory keeps both advisories. Only hack.dependencies.cache-volume volumes are excluded. Co-Authored-By: Claude Opus 5.5 --- src/lib/native-data-advisories.ts | 21 +++++++-------------- tests/native-data-advisories.test.ts | 28 +++++++++++++++++++++------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/lib/native-data-advisories.ts b/src/lib/native-data-advisories.ts index d4def29de..60a10ddd0 100644 --- a/src/lib/native-data-advisories.ts +++ b/src/lib/native-data-advisories.ts @@ -96,20 +96,16 @@ function namedVolumeSources(service: Record): string[] { } /** - * Volumes that hold disposable or shared dependency content, not service - * data: `x-hack-disposable-cache` volumes and every volume a service selects - * with the `hack.dependencies.cache-volume` label. + * Volumes that hold shared dependency content, not service data: every volume + * a service selects with the `hack.dependencies.cache-volume` label. A volume + * that declares `x-hack-disposable-cache` stays data: that extension names + * regenerable directories inside the volume (possibly none), and everything + * outside them is retained. */ function cacheVolumes( - services: readonly Record[], - volumes: Record + services: readonly Record[] ): Set { const caches = new Set(); - for (const [name, volume] of Object.entries(volumes)) { - if (isRecord(volume) && volume["x-hack-disposable-cache"] !== undefined) { - caches.add(name); - } - } for (const service of services) { const cache = labelValue(service.labels, DEPENDENCY_CACHE_LABEL); if (cache !== undefined) { @@ -164,10 +160,7 @@ export function findNativeDataAdvisories( (entry): entry is [string, Record] => isRecord(entry[1]) ); const declared = isRecord(compose.volumes) ? compose.volumes : {}; - const caches = cacheVolumes( - entries.map(([, service]) => service), - declared - ); + const caches = cacheVolumes(entries.map(([, service]) => service)); const found: Record> = { native_data_owner_handoff_unqualified: new Set(), native_retained_data_mutable_image: new Set(), diff --git a/tests/native-data-advisories.test.ts b/tests/native-data-advisories.test.ts index d4f75a8a4..c90e311e3 100644 --- a/tests/native-data-advisories.test.ts +++ b/tests/native-data-advisories.test.ts @@ -139,7 +139,7 @@ test("an entrypoint override or local build is not an image handoff", () => { ]); }); -test("dependency-cache and disposable-cache volumes are not data", () => { +test("dependency-cache volumes are not data", () => { expect( findNativeDataAdvisories( project( @@ -152,7 +152,7 @@ test("dependency-cache and disposable-cache volumes are not data", () => { app: { image: "example/app", labels: ["unrelated=1"], - volumes: ["deps:/deps:ro", "scratch:/cache"], + volumes: ["deps:/deps:ro"], }, listed: { image: "example/app", @@ -160,16 +160,30 @@ test("dependency-cache and disposable-cache volumes are not data", () => { volumes: ["listed-deps:/deps"], }, }, - { - deps: {}, - "listed-deps": {}, - scratch: { "x-hack-disposable-cache": [".cache"] }, - } + { deps: {}, "listed-deps": {} } ) ) ).toEqual([]); }); +test("a volume with disposable-cache directories is still data", () => { + // `x-hack-disposable-cache` names regenerable directories inside the volume; + // everything outside them is retained, and an empty list names none. + for (const directories of [[".cache"], []]) { + expect( + findNativeDataAdvisories( + project( + { db: { image: "example/database", volumes: ["data:/var/lib/db"] } }, + { data: { "x-hack-disposable-cache": directories } } + ) + ) + ).toEqual([ + { code: "native_data_owner_handoff_unqualified", services: ["db"] }, + { code: "native_retained_data_mutable_image", services: ["db"] }, + ]); + } +}); + test("the doctor result is a warning with codes and service names only", () => { const ok = nativeDataCheckResult([]); expect(ok).toMatchObject({ From 4fc5b5ecbb223b162ae82311c929f27bf70103c9 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:30:46 -0500 Subject: [PATCH 024/138] fix(native): skip condition checks for dropped optional dependencies An optional dependency on a profile-disabled service is dropped from the execution graph, but review still applied its condition requirements: a service_healthy edge without a target healthcheck raised missing_healthcheck, and a service_completed_successfully edge to an always-restarting target raised nonterminating_completion_dependency. Either made the whole plan enrollment-incompatible although the target is never started or awaited. Review now stops after the optional_dependency_inactive warning for such edges; active edges and required edges to disabled targets are validated as before. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/project/compose/graph.rs | 3 ++ .../tests/compose_fields_contract.rs | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/runtime-core/src/project/compose/graph.rs b/packages/runtime-core/src/project/compose/graph.rs index 486e11a31..de4071dce 100644 --- a/packages/runtime-core/src/project/compose/graph.rs +++ b/packages/runtime-core/src/project/compose/graph.rs @@ -57,6 +57,9 @@ pub(super) fn validate( &field, "An optional dependency is disabled by the selected profiles; the edge is skipped, as with Compose.", )); + // The execution graph drops this edge, so its target is never + // started or awaited: its condition requirements do not apply. + continue; } else if service.active && !condition.required { diagnostics.push(Diagnostic::warning( "optional_dependency_enforced", diff --git a/packages/runtime-core/tests/compose_fields_contract.rs b/packages/runtime-core/tests/compose_fields_contract.rs index 629a0051f..559726db3 100644 --- a/packages/runtime-core/tests/compose_fields_contract.rs +++ b/packages/runtime-core/tests/compose_fields_contract.rs @@ -138,6 +138,44 @@ fn optional_dependency_on_an_active_service_is_enforced_fail_closed() { assert_eq!(graph.services["app"].dependencies["db"], Condition::Healthy); } +#[test] +fn inactive_optional_dependencies_skip_condition_requirements_that_active_ones_keep() { + // A healthy dependency without a healthcheck, and a completion dependency + // that always restarts: both refuse when the target runs, and neither + // applies when an optional edge to a profile-disabled target is dropped. + let healthy = "services:\n app:\n image: alpine:3.21\n depends_on:\n db:\n condition: service_healthy\n required: false\n db:\n image: alpine:3.21\n profiles: [database]\n"; + let completed = "services:\n app:\n image: alpine:3.21\n depends_on:\n migrate:\n condition: service_completed_successfully\n required: false\n migrate:\n image: alpine:3.21\n restart: always\n profiles: [database]\n"; + for (compose, code) in [ + (healthy, "missing_healthcheck"), + (completed, "nonterminating_completion_dependency"), + ] { + let fixture = Fixture::new(compose); + let inactive = fixture.plan(); + assert!(inactive.plan.enrollment_compatible, "{code}"); + let found = codes(&inactive.plan); + assert!( + found.contains(&("optional_dependency_inactive", "warning")), + "{code}" + ); + assert!(!found.contains(&(code, "error")), "{code}"); + let graph = Graph::from_plan( + &inactive.plan, + &BTreeMap::from([("app".into(), Condition::Started)]), + ) + .unwrap(); + assert!(graph.services["app"].dependencies.is_empty(), "{code}"); + let active = fixture.plan_with(&["database".into()]); + assert!(!active.plan.enrollment_compatible, "{code}"); + assert!(codes(&active.plan).contains(&(code, "error")), "{code}"); + // A required edge to the same disabled target still refuses. + let required = Fixture::new(&compose.replace(" required: false\n", "")); + assert!( + codes(&required.plan().plan).contains(&("inactive_dependency", "error")), + "{code}" + ); + } +} + #[test] fn required_and_restart_dependency_rules_are_unchanged() { let required = Fixture::new(&OPTIONAL.replace(" required: false\n", "")); From 8f72922e8db9dea29b0d7df371d6d6841676148d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:33:10 -0500 Subject: [PATCH 025/138] feat(native): give Compose-file containers Docker's default capabilities Native Compose-file graph containers ran with every capability dropped, so stock root entrypoints that chown their data directory and then switch users (database images) failed on first start. Compose-file services now get exactly moby's default capability set for the pinned engine (docker-29.5.2), lowered explicitly as CapDrop ALL plus the effective CapAdd set, with no-new-privileges kept. cap_add and cap_drop follow Docker's normalization and precedence. cap_add accepts the default names plus NET_ADMIN, NET_BROADCAST, SYS_NICE, IPC_LOCK and IPC_OWNER; review refuses ALL and capabilities that act on the shared VM kernel or on Hack's in-container helpers. privileged stays refused. Services without capability fields keep their existing plan identity. Authored native services, the persistent-data process policy and Hack's helper containers keep every capability dropped. The guide documents the security model: the development VM is the isolation boundary. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 64 ++- packages/runtime-core/README.md | 5 +- .../runtime-core/src/project/capabilities.rs | 425 ++++++++++++++++++ .../src/project/compose/service.rs | 10 +- packages/runtime-core/src/project/mod.rs | 13 + .../runtime-core/src/provider/graph/config.rs | 16 +- .../src/provider/graph/startup.rs | 4 +- .../src/provider/graph/startup/tests.rs | 32 ++ .../runtime-core/src/provider/graph/tests.rs | 153 ++++++- .../runtime-core/tests/project_contract.rs | 27 ++ 10 files changed, 730 insertions(+), 19 deletions(-) create mode 100644 packages/runtime-core/src/project/capabilities.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..484f69264 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -611,6 +611,63 @@ layer. Removing the owned container discards that layer; use named volumes for durable data. Source bind mounts still require read-only publication. Writable container layers do not imply writable source synchronization. +### Container capabilities and the security model + +On the native backend, the development VM is the isolation boundary between +project code and your Mac. Containers inside the VM are a compatibility layer: they +give each service the process environment it would have under Docker, not a second +security sandbox. This matches how the Compose backend runs on Docker. + +Compose-file services therefore get Docker's default Linux capability set for +the pinned engine (Docker 29.5.2): `CHOWN`, `DAC_OVERRIDE`, `FSETID`, `FOWNER`, +`MKNOD`, `NET_RAW`, `SETGID`, `SETUID`, `SETFCAP`, `SETPCAP`, `NET_BIND_SERVICE`, +`SYS_CHROOT`, `KILL` and `AUDIT_WRITE`. Stock images that start as root, change the +owner of their data directory and then switch to an unprivileged user (most +database images do this) work without a `user:` override. `no-new-privileges` +stays on, so a process cannot gain capabilities through setuid or file-capability +binaries. `privileged: true` is refused. + +`cap_add` and `cap_drop` follow Docker's rules. Names are case-insensitive, and the +`CAP_` prefix is optional. `cap_drop` removes names from the default set, and +`cap_add` is applied after it, so a name in both lists is kept. `cap_drop: [ALL]` +keeps only the names in `cap_add`. `cap_drop` accepts any capability the engine +knows. Unknown names are refused, as Docker refuses them. Interpolated names are +not resolved and are refused as unknown. + +`cap_add` accepts the default names plus a closed set whose effect stays inside +the container's own namespaces or cgroup limits: `NET_ADMIN` and `NET_BROADCAST` +(the container's own network namespace), `SYS_NICE` (scheduling and NUMA policy, +often declared for database images), `IPC_LOCK` (memory locking, still charged to +the memory limit) and `IPC_OWNER` (the container's own IPC namespace). Plan review +refuses every other addition with `capability_refused`, including `cap_add: [ALL]`. +One VM can host several projects and worktrees, and Hack runs its own helpers +inside service containers. These refused capabilities would act on that shared +kernel or on those helpers: + +- `SYS_ADMIN`, `SYS_MODULE`, `SYS_RAWIO`, `DAC_READ_SEARCH` and `BPF` are known + container escape or kernel attack primitives. +- `SYS_TIME`, `SYS_PACCT`, `SYSLOG`, `PERFMON`, `AUDIT_CONTROL`, `AUDIT_READ`, + `MAC_ADMIN`, `MAC_OVERRIDE`, `WAKE_ALARM` and `BLOCK_SUSPEND` act on the VM as a + whole: its clock, accounting, kernel log, performance events, audit or security + modules, or power management. +- `SYS_PTRACE` would let an application trace Hack's in-container helpers (the + dependency relay and the HTTP probe), bypassing the kernel's ptrace restrictions. +- `LINUX_IMMUTABLE` could make files in Hack-owned volumes impossible to remove + during cleanup. +- `SYS_RESOURCE`, `SYS_BOOT`, `SYS_TTY_CONFIG`, `LEASE` and `CHECKPOINT_RESTORE` + have no reviewed development use case yet. + +Hack always writes the effective set explicitly (`CapDrop: ["ALL"]` plus `CapAdd`), +rather than relying on the engine's implicit default. Startup compares that exact +set against the inspected container. Authored native services, persistent-data +initializers and Hack's helper containers keep every capability dropped. + +A stopped graph whose containers were created by an earlier candidate (with every +capability dropped) differs from this profile. `graph restart` on such a graph +refuses with `graph_config_mismatch`; it never adopts the old containers. Normal +`hack restart` and `hack up` recreate the containers around the retained volumes, +so they use the new profile. + The normal-CLI integration helpers prepare modern environment overlays and worktree inheritance in memory, with null declarations in public normalized configuration. An explicit AWS profile adapter can replace the recognized @@ -701,9 +758,10 @@ source revision. Cache initializer services read that immutable publication thro read-only source mounts; their named cache volumes remain writable. This prevents concurrent host edits from changing the inputs halfway through installation. Other services use the direct host mount with their declared read/write mode. -Because virtiofs preserves host ownership, those services receive `DAC_OVERRIDE` -in addition to the otherwise empty capability set. This allows root processes to -access the explicitly shared tree; it does not bypass a read-only mount. +Because virtiofs preserves host ownership, those services keep `DAC_OVERRIDE` +(part of the default capability set) even when `cap_drop` removes it. This allows +root processes to access the explicitly shared tree; it does not bypass a read-only +mount. An installer that needs to modify source files is not supported in this mode yet. Restart after dependency-input changes with a new review and publication. Moving or deleting a project prevents further activation, but does not prevent diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 84c58a6df..7946cbc4a 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1083,8 +1083,9 @@ Aliasless bindings retain `127.0.0.1`. Named bindings use `127.0.0.(slot+2)` and publish those exact addresses through container extra hosts. Applications keep the original hostname and selected port; the relay does not terminate TLS or rewrite Host/SNI. Separate bindings can therefore use the same destination port. Ports below -1024 require an isolated graph network namespace, dropped capabilities and the -container-only `net.ipv4.ip_unprivileged_port_start=0` setting. An isolated native +1024 require an isolated graph network namespace, the explicitly lowered capability +profile (`CapDrop: ALL` plus the reviewed `CapAdd` set, never an implicit engine +default) and the container-only `net.ipv4.ip_unprivileged_port_start=0` setting. An isolated native fixture verifies nonroot port 443, original Host/SNI, certificate rejection and cleanup. Actual application hostname/TLS acceptance remains open. diff --git a/packages/runtime-core/src/project/capabilities.rs b/packages/runtime-core/src/project/capabilities.rs new file mode 100644 index 000000000..949c3da41 --- /dev/null +++ b/packages/runtime-core/src/project/capabilities.rs @@ -0,0 +1,425 @@ +//! Linux capability profile for Compose-file graph containers. +//! +//! Compose-file services receive the Docker engine's default capability set, adjusted +//! by `cap_add` and `cap_drop` with Docker's own precedence rules, so stock images that +//! start as root and then drop privileges (database entrypoints that `chown` their data +//! directory before switching users) behave as they do under Docker. The development VM +//! is the isolation boundary between project code and the host; `no-new-privileges` +//! stays on, and `privileged` stays refused. +//! +//! Lowering never relies on the engine's implicit default. It always emits +//! `CapDrop: ["ALL"]` plus the explicit effective set in `CapAdd`, so the request, the +//! inspected container and the existing `CapDrop` guards describe one exact profile. +//! +//! The VM can host several projects and worktrees at once, and Hack places its own +//! helper processes inside service containers. `cap_add` therefore accepts only +//! capabilities whose effect stays inside the container's own namespaces and cgroup +//! limits. Capabilities that act on the shared VM kernel, its clock, accounting, audit +//! or security modules, that are known container escape primitives, or that would let +//! an application inspect Hack-owned helpers are refused during review. +use super::{CapabilityPlan, Diagnostic}; +use crate::CandidateError; +use serde_json::Value; +use std::collections::BTreeSet; + +/// Docker/moby's default container capability set (`daemon/pkg/oci/caps/defaults.go`) +/// for the pinned engine (`docker-29.5.2`, see `provider-pins.json`), in moby order. +/// Changing the pinned engine requires re-checking this list. +pub const DOCKER_DEFAULT: [&str; 14] = [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_FSETID", + "CAP_FOWNER", + "CAP_MKNOD", + "CAP_NET_RAW", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETFCAP", + "CAP_SETPCAP", + "CAP_NET_BIND_SERVICE", + "CAP_SYS_CHROOT", + "CAP_KILL", + "CAP_AUDIT_WRITE", +]; + +/// Capabilities outside the default set that `cap_add` may grant. Each is scoped to +/// the container's own network or IPC namespace, or bounded by its cgroup limits. +pub const ADMITTED_ADDITIONS: [&str; 5] = [ + // Interfaces, routes and firewall rules of the container's own network namespace. + "CAP_NET_ADMIN", + "CAP_NET_BROADCAST", + // Scheduling and NUMA policy; commonly declared for database images. + "CAP_SYS_NICE", + // Memory locking, still charged to the container's memory limit. + "CAP_IPC_LOCK", + // IPC objects in the container's own IPC namespace. + "CAP_IPC_OWNER", +]; + +/// Every capability name the pinned engine knows (Linux capabilities 0 through 40). +/// A name outside this list is refused as unknown, as Docker refuses it. +pub const KNOWN: [&str; 41] = [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_DAC_READ_SEARCH", + "CAP_FOWNER", + "CAP_FSETID", + "CAP_KILL", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETPCAP", + "CAP_LINUX_IMMUTABLE", + "CAP_NET_BIND_SERVICE", + "CAP_NET_BROADCAST", + "CAP_NET_ADMIN", + "CAP_NET_RAW", + "CAP_IPC_LOCK", + "CAP_IPC_OWNER", + "CAP_SYS_MODULE", + "CAP_SYS_RAWIO", + "CAP_SYS_CHROOT", + "CAP_SYS_PTRACE", + "CAP_SYS_PACCT", + "CAP_SYS_ADMIN", + "CAP_SYS_BOOT", + "CAP_SYS_NICE", + "CAP_SYS_RESOURCE", + "CAP_SYS_TIME", + "CAP_SYS_TTY_CONFIG", + "CAP_MKNOD", + "CAP_LEASE", + "CAP_AUDIT_WRITE", + "CAP_AUDIT_CONTROL", + "CAP_SETFCAP", + "CAP_MAC_OVERRIDE", + "CAP_MAC_ADMIN", + "CAP_SYSLOG", + "CAP_WAKE_ALARM", + "CAP_BLOCK_SUSPEND", + "CAP_AUDIT_READ", + "CAP_PERFMON", + "CAP_BPF", + "CAP_CHECKPOINT_RESTORE", +]; + +/// Docker's magic value that selects every capability. +pub const ALL: &str = "ALL"; + +/// The bounded number of entries in one `cap_add` or `cap_drop` list. +const MAX_ENTRIES: usize = 64; + +/// Normalize like Docker's `NormalizeLegacyCapabilities`: upper-case, add the +/// `CAP_` prefix and keep `ALL` as the magic value. Unknown names return `None`. +fn normalize(value: &str) -> Option { + let upper = value.to_ascii_uppercase(); + if upper == ALL { + return Some(upper); + } + let name = if upper.starts_with("CAP_") { + upper + } else { + format!("CAP_{upper}") + }; + KNOWN.contains(&name.as_str()).then_some(name) +} + +fn admitted_addition(name: &str) -> bool { + DOCKER_DEFAULT.contains(&name) || ADMITTED_ADDITIONS.contains(&name) +} + +fn list(value: Option<&Value>) -> Result, CandidateError> { + let invalid = || { + super::problem( + "invalid_capability_list", + "cap_add and cap_drop must be lists of at most 64 capability names.", + ) + }; + let values = match value { + None | Some(Value::Null) => return Ok(Vec::new()), + Some(Value::Array(values)) if values.len() <= MAX_ENTRIES => values, + _ => return Err(invalid()), + }; + values + .iter() + .map(|value| value.as_str().map(str::to_owned).ok_or_else(invalid)) + .collect() +} + +/// Review `cap_add` and `cap_drop`. Unknown and refused names become error +/// diagnostics, so enrollment and execution refuse the whole plan. Absent or empty +/// lists return `None`, which keeps existing plan identities unchanged. +pub(super) fn review( + add: Option<&Value>, + drop: Option<&Value>, + field: &str, + diagnostics: &mut Vec, +) -> Result, CandidateError> { + let mut reviewed = [BTreeSet::new(), BTreeSet::new()]; + for (index, (key, value)) in [("cap_add", add), ("cap_drop", drop)] + .into_iter() + .enumerate() + { + let entries = list(value)?; + for (position, entry) in entries.iter().enumerate() { + let path = format!("{field}.{key}[{position}]"); + let Some(name) = normalize(entry) else { + diagnostics.push(Diagnostic::error( + "unknown_capability", + &path, + "Capability names must be Linux capabilities known to the pinned engine (with or without the CAP_ prefix) or ALL; interpolated names are not resolved.", + )); + continue; + }; + if key == "cap_add" && name == ALL { + diagnostics.push(Diagnostic::error( + "capability_refused", + &path, + "cap_add: ALL grants every capability, including ones that act on the shared development VM; list the needed capabilities instead.", + )); + continue; + } + if key == "cap_add" && !admitted_addition(&name) { + diagnostics.push(Diagnostic::error( + "capability_refused", + &path, + "This capability can act on the shared development VM kernel or on Hack-owned helpers in the container; native services accept only Docker's default set plus NET_ADMIN, NET_BROADCAST, SYS_NICE, IPC_LOCK and IPC_OWNER.", + )); + continue; + } + reviewed[index].insert(name); + } + } + let [add, drop] = reviewed; + if add.is_empty() && drop.is_empty() { + return Ok(None); + } + Ok(Some(CapabilityPlan { + add: add.into_iter().collect(), + drop: drop.into_iter().collect(), + })) +} + +/// The effective capability set with Docker's `TweakCapabilities` precedence: no +/// adjustments keep the defaults; `cap_drop: ALL` keeps only `cap_add`; otherwise +/// `cap_drop` removes from the defaults and `cap_add` is applied after it, so a name in +/// both lists is kept. `cap_add: ALL` never reaches here because review refuses it. +/// The result is sorted, deduplicated and uses canonical `CAP_` names. +/// +/// Returns an error if a plan carries a name review would have refused, so a +/// hand-edited or older plan can never widen the profile at lowering time. +pub fn effective(plan: Option<&CapabilityPlan>) -> Result, CandidateError> { + let refused = || { + CandidateError::new( + "graph_capabilities", + "A reviewed capability adjustment is outside the admitted native profile.", + ) + }; + let mut result: BTreeSet = DOCKER_DEFAULT.iter().map(|&n| n.to_owned()).collect(); + let Some(plan) = plan else { + return Ok(result.into_iter().collect()); + }; + if plan + .add + .iter() + .any(|name| name == ALL || !admitted_addition(name)) + || plan + .drop + .iter() + .any(|name| name != ALL && !KNOWN.contains(&name.as_str())) + { + return Err(refused()); + } + if plan.drop.iter().any(|name| name == ALL) { + result.clear(); + } else { + for name in &plan.drop { + result.remove(name); + } + } + result.extend(plan.add.iter().cloned()); + Ok(result.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn reviewed(add: Value, drop: Value) -> (Option, Vec) { + let mut diagnostics = Vec::new(); + let plan = review(Some(&add), Some(&drop), "services.db", &mut diagnostics).unwrap(); + ( + plan, + diagnostics + .into_iter() + .map(|d| format!("{}:{}", d.code, d.field)) + .collect(), + ) + } + + fn plan(add: &[&str], drop: &[&str]) -> CapabilityPlan { + CapabilityPlan { + add: add.iter().map(|&n| n.to_owned()).collect(), + drop: drop.iter().map(|&n| n.to_owned()).collect(), + } + } + + fn sorted(names: &[&str]) -> Vec { + let mut names: Vec = names.iter().map(|&n| n.to_owned()).collect(); + names.sort(); + names + } + + #[test] + fn default_profile_is_exactly_the_pinned_engine_default_set() { + assert_eq!(effective(None).unwrap(), sorted(&DOCKER_DEFAULT)); + assert_eq!(effective(None).unwrap().len(), 14); + // Each default and admitted name is a known capability exactly once. + let known: BTreeSet<_> = KNOWN.iter().collect(); + assert_eq!(known.len(), KNOWN.len()); + assert!(DOCKER_DEFAULT.iter().all(|name| known.contains(name))); + assert!(ADMITTED_ADDITIONS.iter().all(|name| known.contains(name))); + assert!( + ADMITTED_ADDITIONS + .iter() + .all(|name| !DOCKER_DEFAULT.contains(name)) + ); + // Escape primitives and VM-wide capabilities are never part of the profile. + for name in [ + "CAP_SYS_ADMIN", + "CAP_SYS_MODULE", + "CAP_SYS_RAWIO", + "CAP_DAC_READ_SEARCH", + "CAP_SYS_PTRACE", + "CAP_SYS_TIME", + "CAP_BPF", + "CAP_PERFMON", + "CAP_LINUX_IMMUTABLE", + ] { + assert!(!admitted_addition(name), "{name}"); + } + } + + #[test] + fn review_normalizes_like_docker_and_keeps_absent_lists_out_of_the_plan() { + let mut diagnostics = Vec::new(); + assert!( + review(None, None, "services.db", &mut diagnostics) + .unwrap() + .is_none() + ); + assert!( + review( + Some(&json!([])), + Some(&Value::Null), + "services.db", + &mut diagnostics + ) + .unwrap() + .is_none() + ); + assert!(diagnostics.is_empty()); + let (plan, codes) = reviewed( + json!(["net_admin", "CAP_SYS_NICE", "sys_nice", "chown"]), + json!(["all", "cap_mknod"]), + ); + assert!(codes.is_empty(), "{codes:?}"); + assert_eq!( + plan.unwrap(), + self::plan( + &["CAP_CHOWN", "CAP_NET_ADMIN", "CAP_SYS_NICE"], + &["ALL", "CAP_MKNOD"] + ) + ); + } + + #[test] + fn review_refuses_unknown_all_and_out_of_profile_additions() { + let (plan, codes) = reviewed( + json!([ + "ALL", + "SYS_ADMIN", + "SYS_PTRACE", + "NOT_A_CAP", + "${CAP}", + "NET_ADMIN" + ]), + json!(["SYS_ADMIN", "BOGUS"]), + ); + assert_eq!( + codes, + [ + "capability_refused:services.db.cap_add[0]", + "capability_refused:services.db.cap_add[1]", + "capability_refused:services.db.cap_add[2]", + "unknown_capability:services.db.cap_add[3]", + "unknown_capability:services.db.cap_add[4]", + "unknown_capability:services.db.cap_drop[1]", + ] + ); + // Dropping any known capability only narrows the profile. + assert_eq!( + plan.unwrap(), + self::plan(&["CAP_NET_ADMIN"], &["CAP_SYS_ADMIN"]) + ); + for invalid in [json!("NET_ADMIN"), json!([1]), json!(vec!["KILL"; 65])] { + let mut diagnostics = Vec::new(); + assert_eq!( + review(Some(&invalid), None, "services.db", &mut diagnostics) + .unwrap_err() + .code, + "invalid_capability_list" + ); + } + } + + #[test] + fn effective_set_follows_docker_tweak_precedence() { + let defaults = sorted(&DOCKER_DEFAULT); + // Drop removes from the defaults; add applies after drop. + let mut expected = defaults.clone(); + expected.retain(|name| name != "CAP_NET_RAW" && name != "CAP_MKNOD"); + expected.push("CAP_NET_ADMIN".into()); + expected.sort(); + assert_eq!( + effective(Some(&plan( + &["CAP_NET_ADMIN"], + &["CAP_NET_RAW", "CAP_MKNOD"] + ))) + .unwrap(), + expected + ); + // A name in both lists is kept, as Docker applies cap_add last. + assert_eq!( + effective(Some(&plan(&["CAP_CHOWN"], &["CAP_CHOWN"]))).unwrap(), + defaults + ); + // Drop ALL keeps only the explicit additions. + assert_eq!( + effective(Some(&plan(&["CAP_CHOWN", "CAP_SETUID"], &["ALL"]))).unwrap(), + ["CAP_CHOWN", "CAP_SETUID"] + ); + assert!(effective(Some(&plan(&[], &["ALL"]))).unwrap().is_empty()); + // Redundant default additions change nothing; dropping an absent cap is a no-op. + assert_eq!( + effective(Some(&plan(&["CAP_KILL"], &["CAP_SYS_ADMIN"]))).unwrap(), + defaults + ); + } + + #[test] + fn effective_refuses_plans_that_bypassed_review() { + for plan in [ + plan(&["ALL"], &[]), + plan(&["CAP_SYS_ADMIN"], &[]), + plan(&["CAP_SYS_PTRACE"], &["ALL"]), + plan(&["NET_ADMIN"], &[]), + plan(&[], &["BOGUS"]), + ] { + assert_eq!( + effective(Some(&plan)).unwrap_err().code, + "graph_capabilities" + ); + } + } +} diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index 5cc51846a..fad424f5e 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -704,6 +704,8 @@ pub(super) fn compile( "stop_grace_period", "stop_signal", "privileged", + "cap_add", + "cap_drop", ], &field, diagnostics, @@ -715,6 +717,12 @@ pub(super) fn compile( "Privileged containers cannot be enrolled.", )); } + let capabilities = super::super::capabilities::review( + m.get("cap_add"), + m.get("cap_drop"), + &field, + diagnostics, + )?; let image = image(m.get("image"), diagnostics, &format!("{field}.image"))?; let build = build( project, @@ -927,7 +935,7 @@ pub(super) fn compile( limits:limits(m,&field,diagnostics)?,read_only:boolean(m.get("read_only"),false)?,init:boolean(m.get("init"),false)?,restart, working_dir:m.get("working_dir").map(|v| safe_absolute(string(v)?)).transpose()?, user:m.get("user").map(|v| match v {Value::String(s)=>redacted(s),Value::Number(n)=>redacted(&n.to_string()),_=>Err(problem("invalid_user","User must be a string or numeric identifier."))}).transpose()?, - platform,labels,routing,dependency_cache,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal, + platform,labels,routing,dependency_cache,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal,capabilities, }); } Ok(result) diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index 043076259..3ca9961f6 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -1,5 +1,6 @@ //! WU03 Compose review and metadata-only enrollment. These plans cannot execute services. mod adapter; +pub mod capabilities; pub use adapter::{NormalizedComposeOptions, plan_normalized}; mod compose; mod enrollment; @@ -272,6 +273,18 @@ pub struct ServicePlan { pub logging: Option, pub stop_grace_period_nanos: Option, pub stop_signal: Option, + /// Reviewed `cap_add`/`cap_drop` adjustments to the engine default capability set. + /// Omitted when the service declares neither, so existing plan identities hold. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capabilities: Option, +} +/// Normalized Compose capability adjustments (`CAP_` names, or `ALL` in `drop`). +/// Lowering applies Docker's precedence; see [`capabilities::effective`]. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CapabilityPlan { + pub add: Vec, + pub drop: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(deny_unknown_fields)] diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 257424239..6129f3f65 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -4,6 +4,9 @@ use std::collections::BTreeSet; /// Common bounded container isolation. Input-family lowering owns all overrides; /// this does not select an image, grant ownership or authorize engine effects. +/// Every capability is dropped here; only Compose-file lowering adds the explicit +/// Docker default profile (`project::capabilities`). Authored native services and +/// their persistent-data policy keep this empty set. pub(super) fn container_base(image: &str, labels: Value) -> Value { json!({"Image":image,"Labels":labels,"HostConfig":{ "NetworkMode":"none","Memory":0,"NanoCpus":0, @@ -402,15 +405,22 @@ pub(super) fn prepare_delivery( if service.read_only { config["HostConfig"]["Tmpfs"] = json!({"/tmp":"rw,noexec,nosuid,size=16777216"}); } + // Compose-file services get Docker's default capability set adjusted by the + // reviewed cap_add/cap_drop, emitted explicitly after CapDrop ALL so the request + // never depends on the engine's implicit default. no-new-privileges stays set. + let mut capabilities = project::capabilities::effective(service.capabilities.as_ref())?; // Virtiofs preserves the host uid. Root services need DAC_OVERRIDE to - // traverse/write an explicitly shared tree owned by that uid. Read-only - // mounts still enforce read-only access; no other capabilities are added. + // traverse/write an explicitly shared tree owned by that uid, so it is kept + // even when cap_drop removes it. Read-only mounts still enforce read-only access. if source.is_some_and(|s| s.binding.shared.is_some()) && service.dependency_cache.is_none() && service.mounts.iter().any(|m| m.kind == "bind") + && !capabilities.iter().any(|name| name == "CAP_DAC_OVERRIDE") { - config["HostConfig"]["CapAdd"] = json!(["DAC_OVERRIDE"]); + capabilities.push("CAP_DAC_OVERRIDE".into()); + capabilities.sort(); } + config["HostConfig"]["CapAdd"] = json!(capabilities); // Only explicitly compiled public values enter engine metadata. Bare/null // managed inputs are delivered separately by the private launcher. // Omit Env when empty so image defaults remain the engine's responsibility. diff --git a/packages/runtime-core/src/provider/graph/startup.rs b/packages/runtime-core/src/provider/graph/startup.rs index 2321ccfe1..15a03aa2c 100644 --- a/packages/runtime-core/src/provider/graph/startup.rs +++ b/packages/runtime-core/src/provider/graph/startup.rs @@ -298,7 +298,9 @@ pub(super) fn attach( return Err(dependency_hosts::refused()); } // Docker applies this only inside the isolated container network namespace. - // The helper retains the application's numeric UID and all capabilities stay dropped. + // The helper retains the application's numeric UID. CapDrop ALL proves the + // capability set is the explicit lowered profile (CapAdd), never an implicit + // engine default; this adds no capability. config["HostConfig"]["Sysctls"] = json!({"net.ipv4.ip_unprivileged_port_start":"0"}); } for mount in config["HostConfig"]["Mounts"] diff --git a/packages/runtime-core/src/provider/graph/startup/tests.rs b/packages/runtime-core/src/provider/graph/startup/tests.rs index 8cc2f45e3..b619ff9af 100644 --- a/packages/runtime-core/src/provider/graph/startup/tests.rs +++ b/packages/runtime-core/src/provider/graph/startup/tests.rs @@ -563,3 +563,35 @@ fn shared_listener_receipt_retains_72_grants_on_six_exact_transports() { } assert!(!excessive.valid(&receipt)); } + +#[test] +fn low_port_binding_requires_the_explicit_lowered_capability_profile() { + let mut selected = service(); + selected.bindings.get_mut("default").unwrap().port = 443; + let profile = json!(crate::project::capabilities::effective(None).unwrap()); + let mut value = config(); + value["HostConfig"]["NetworkMode"] = json!("none"); + value["HostConfig"]["CapDrop"] = json!(["ALL"]); + value["HostConfig"]["CapAdd"] = profile.clone(); + attach(&mut value, &selected, "/storage/start", "/storage/helper").unwrap(); + // The sysctl is the only change; the reviewed capability profile is untouched. + assert_eq!(value["HostConfig"]["CapAdd"], profile); + assert_eq!(value["HostConfig"]["CapDrop"], json!(["ALL"])); + assert_eq!( + value["HostConfig"]["Sysctls"], + json!({"net.ipv4.ip_unprivileged_port_start":"0"}) + ); + // An implicit engine default (no CapDrop) is not the lowered profile. + let mut implicit = config(); + implicit["HostConfig"]["NetworkMode"] = json!("none"); + implicit["HostConfig"]["CapAdd"] = profile; + assert!( + attach( + &mut implicit, + &selected, + "/storage/start", + "/storage/helper" + ) + .is_err() + ); +} diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..38c54a5bc 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -138,6 +138,11 @@ fn driver_profile_preserves_isolation_and_refuses_unqualified_inputs() { json!({"/tmp":"rw,noexec,nosuid,size=16777216"}) ); assert_eq!(host["CapDrop"], json!(["ALL"])); + // Compose-file services receive Docker's default set explicitly. + assert_eq!( + host["CapAdd"], + json!(project::capabilities::effective(None).unwrap()) + ); assert_eq!(host["SecurityOpt"], json!(["no-new-privileges"])); assert!(host.get("Privileged").is_none()); let mut changed = host.clone(); @@ -156,6 +161,117 @@ fn driver_profile_preserves_isolation_and_refuses_unqualified_inputs() { } assert!(!candidate.state_root.exists()); } +#[test] +fn compose_capabilities_lower_the_docker_default_profile_with_adjustments() { + let fixture = Fixture::new(); + let home = Fixture::new(); + let candidate = Candidate::discover(&home.0).unwrap(); + let defaults = project::capabilities::effective(None).unwrap(); + let without = |names: &[&str]| -> Vec { + defaults + .iter() + .filter(|name| !names.contains(&name.as_str())) + .cloned() + .collect() + }; + let mut with_admin = without(&["CAP_NET_RAW"]); + with_admin.push("CAP_NET_ADMIN".into()); + with_admin.sort(); + for (add, drop, expected) in [ + (None, None, Some(defaults.clone())), + (Some(json!([])), Some(json!([])), Some(defaults.clone())), + ( + Some(json!(["net_admin"])), + Some(json!(["NET_RAW"])), + Some(with_admin), + ), + ( + Some(json!(["CHOWN", "SETUID", "SETGID"])), + Some(json!(["ALL"])), + Some(vec![ + "CAP_CHOWN".to_owned(), + "CAP_SETGID".into(), + "CAP_SETUID".into(), + ]), + ), + (Some(json!(["SYS_ADMIN"])), None, None), + (Some(json!(["SYS_PTRACE"])), None, None), + (Some(json!(["ALL"])), None, None), + (None, Some(json!(["NOT_A_CAPABILITY"])), None), + ] { + let mut document = compose(&format!("sha256:{}", "a".repeat(64)), "marker", false); + if let Some(add) = &add { + document["services"]["web"]["cap_add"] = add.clone(); + } + if let Some(drop) = &drop { + document["services"]["web"]["cap_drop"] = drop.clone(); + } + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + // Omitted and empty adjustments add nothing to the reviewed plan, so plans + // without capability fields keep their existing identity. + let declared = add.as_ref().is_some_and(|v| v != &json!([])) + || drop.as_ref().is_some_and(|v| v != &json!([])); + assert_eq!( + serde_json::to_value(&review.plan.services["web"]) + .unwrap() + .get("capabilities") + .is_some(), + declared && expected.is_some() + ); + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ); + let Some(expected) = expected else { + // Refused capabilities block the reviewed plan before any lowering. + assert!(!review.plan.enrollment_compatible, "{add:?} {drop:?}"); + assert!(review.plan.diagnostics.iter().any(|d| { + d.severity == "error" + && ["capability_refused", "unknown_capability"].contains(&d.code.as_str()) + })); + assert!(inputs.is_err()); + continue; + }; + let prepared = config::prepare( + inputs.unwrap(), + &goals(), + &"a".repeat(32), + &"b".repeat(32), + None, + ) + .unwrap(); + let host = &prepared.configs["web"]["HostConfig"]; + assert_eq!(host["CapDrop"], json!(["ALL"])); + assert_eq!(host["CapAdd"], json!(expected)); + assert_eq!(host["SecurityOpt"], json!(["no-new-privileges"])); + assert!(host.get("Privileged").is_none()); + // Services without adjustments keep the default profile in the same graph. + assert_eq!( + prepared.configs["init"]["HostConfig"]["CapAdd"], + json!(defaults) + ); + // Inspection that lost a capability, or gained one, is a config mismatch. + let mut observed = host.clone(); + observed["CapAdd"] = json!(defaults[1..]); + if expected.len() == defaults.len() { + assert_eq!( + mismatch(host, &observed, "HostConfig").as_deref(), + Some("HostConfig.CapAdd") + ); + } + observed["CapAdd"] = Value::Null; + if !expected.is_empty() { + assert_eq!( + mismatch(host, &observed, "HostConfig").as_deref(), + Some("HostConfig.CapAdd") + ); + } + } +} + #[test] fn compose_pid_limit_is_opt_in_and_explicit_limits_remain_verified() { let fixture = Fixture::new(); @@ -2205,6 +2321,12 @@ fn cleanup_identity_is_independent_of_declared_root_mode() { #[test] fn shared_source_preserves_app_writes_but_freezes_cache_installer_inputs() { + for drop_all in [false, true] { + shared_source_case(drop_all); + } +} + +fn shared_source_case(drop_all: bool) { let fixture = Fixture::new(); let home = Fixture::new(); let candidate = Candidate::discover(&home.0).unwrap(); @@ -2220,6 +2342,12 @@ fn shared_source_preserves_app_writes_but_freezes_cache_installer_inputs() { "depends_on":{"deps":{"condition":"service_completed_successfully"}}}, "reader":{"image":image,"network_mode":"none","entrypoint":["/bin/true"],"command":[],"volumes":[".:/app:ro"]} },"volumes":{"deps":{}}}); + let mut document = document; + if drop_all { + for name in ["deps", "app", "reader"] { + document["services"][name]["cap_drop"] = json!(["ALL"]); + } + } fs::write(fixture.0.join("compose.yaml"), document.to_string()).unwrap(); let review = project::plan(&candidate, fixture.options()).unwrap(); let snapshot = project::snapshot::capture_plan(&review.plan).unwrap(); @@ -2267,15 +2395,22 @@ fn shared_source_preserves_app_writes_but_freezes_cache_installer_inputs() { assert_eq!(mounts("app")["Source"], path); assert_eq!(mounts("app")["ReadOnly"], false); assert_eq!(mounts("reader")["ReadOnly"], true); - assert_eq!( - prepared.configs["app"]["HostConfig"]["CapAdd"], - json!(["DAC_OVERRIDE"]) - ); - assert_eq!( - prepared.configs["reader"]["HostConfig"]["CapAdd"], - json!(["DAC_OVERRIDE"]) - ); - assert!(prepared.configs["deps"]["HostConfig"]["CapAdd"].is_null()); + // Shared-source services and the frozen installer all receive the Compose + // default profile, which already includes DAC_OVERRIDE. With cap_drop: ALL, only + // services writing through the shared host tree keep DAC_OVERRIDE. + let defaults = json!(project::capabilities::effective(None).unwrap()); + for name in ["app", "reader", "deps"] { + let expected = match (drop_all, name) { + (false, _) => defaults.clone(), + (true, "deps") => json!([]), + (true, _) => json!(["CAP_DAC_OVERRIDE"]), + }; + assert_eq!(prepared.configs[name]["HostConfig"]["CapAdd"], expected); + assert_eq!( + prepared.configs[name]["HostConfig"]["CapDrop"], + json!(["ALL"]) + ); + } assert_eq!(mounts("deps")["ReadOnly"], true); assert_eq!( mounts("deps")["Source"], diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 4028bef1d..81a5818dc 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -491,6 +491,9 @@ fn wrong_stale_and_wu01_preview_ids_are_rejected_before_enrollment() { fn unsupported_privileged_external_and_unknown_fields_block_enrollment() { for (extra, code) in [ (" privileged: true\n", "privileged_service"), + (" cap_add: [SYS_ADMIN]\n", "capability_refused"), + (" cap_add: [ALL]\n", "capability_refused"), + (" cap_drop: [NOT_A_CAPABILITY]\n", "unknown_capability"), (" devices: [/dev/null]\n", "unsupported_field"), (" network_mode: host\n", "unsupported_network_mode"), ( @@ -523,6 +526,30 @@ fn unsupported_privileged_external_and_unknown_fields_block_enrollment() { assert!(fixture.codes().contains(&"external_network".into())); } +#[test] +fn compose_capability_adjustments_are_reviewed_into_the_plan() { + let fixture = Fixture::new(&format!( + "{BASIC} cap_add: [net_admin, CAP_SYS_NICE]\n cap_drop: [ALL]\n privileged: false\n" + )); + let plan = fixture.plan(); + assert!( + plan.plan.diagnostics.iter().all(|d| d.severity != "error"), + "{:?}", + plan.plan.diagnostics + ); + let capabilities = plan.plan.services["web"].capabilities.as_ref().unwrap(); + assert_eq!(capabilities.add, ["CAP_NET_ADMIN", "CAP_SYS_NICE"]); + assert_eq!(capabilities.drop, ["ALL"]); + // Plans without capability fields carry no capability entry. + let basic = Fixture::new(BASIC).plan(); + assert!(basic.plan.services["web"].capabilities.is_none()); + assert!( + !serde_json::to_string(&basic.plan) + .unwrap() + .contains("capabilities") + ); +} + /// The candidate's own label review rejects Hack's ownership namespaces (the /// list `HACK_OWNERSHIP_LABEL_NAMESPACES` declares in TypeScript), so a /// project cannot carry ownership evidence even when the TypeScript classifier From c88d84dfa1cb99501522fa2894c9e4b3206dd875 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:33:23 -0500 Subject: [PATCH 026/138] fix(native): resolve overridden platform arguments before accepting them A global `ARG BUILDPLATFORM=...` or `ARG TARGETPLATFORM=...` default replaces the frontend's platform value, and the frontend expands `--platform` against the global arguments. The scanner accepted `--platform=$BUILDPLATFORM` by spelling alone, so such a stage could select another platform. The spelling is now accepted only while no global default overrides the argument; an overridden one must expand to linux/arm64 like any other value. A bare global ARG still keeps the frontend's value. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 32 ++++++++++++++----- .../src/provider/build_scan/tests.rs | 27 ++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index c7544e0d1..059285bff 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -56,11 +56,12 @@ const UNCERTAIN_FRONTEND_ARGS: [&str; 8] = [ "TARGETSTAGE", "SOURCE_DATE_EPOCH", ]; -const PLATFORM_VARIABLES: [&str; 4] = [ - "$BUILDPLATFORM", - "${BUILDPLATFORM}", - "$TARGETPLATFORM", - "${TARGETPLATFORM}", +/// `--platform` spellings that name a frontend platform argument, with its name. +const PLATFORM_VARIABLES: [(&str, &str); 4] = [ + ("$BUILDPLATFORM", "BUILDPLATFORM"), + ("${BUILDPLATFORM}", "BUILDPLATFORM"), + ("$TARGETPLATFORM", "TARGETPLATFORM"), + ("${TARGETPLATFORM}", "TARGETPLATFORM"), ]; const KNOWN_INSTRUCTIONS: [&str; 18] = [ "add", @@ -976,8 +977,23 @@ enum Source { Image(String), } -fn platform_supported(raw: &str, env: &Env) -> bool { - PLATFORM_VARIABLES.contains(&raw) +/// Whether a reachable stage's `--platform` selects `linux/arm64`. +/// +/// `$BUILDPLATFORM` and `$TARGETPLATFORM` select it only while they keep the +/// frontend's value. A global `ARG` default replaces that value (the frontend +/// expands `--platform` against the global arguments), so an overridden one must +/// itself expand to `linux/arm64`. A bare global `ARG` keeps the frontend's value. +fn platform_supported(raw: &str, env: &Env, parsed: &Parsed) -> bool { + let frontend_value = PLATFORM_VARIABLES + .iter() + .find(|(spelling, _)| *spelling == raw) + .is_some_and(|(_, name)| { + !parsed + .meta_args + .iter() + .any(|(declared, default)| declared == name && default.is_some()) + }); + frontend_value || expand(raw, env) .is_some_and(|value| matches!(value.as_str(), "linux/arm64" | "linux/arm64/v8")) } @@ -1082,7 +1098,7 @@ fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result Date: Thu, 5 Nov 2026 17:34:07 -0500 Subject: [PATCH 027/138] fix(storage): re-pin native file and storage helper to oven/bun:1.4.4-slim The native file and storage helper image was the last place next still shipped Bun 1.4.2. Catalog the registry-derived oven/bun:1.4.4-slim index, platform manifests, configs and Bun/libc byte pins for linux/arm64 and linux/amd64, and use the 1.4.4-slim tag for new VM file preparation. Selection refuses any catalog entry whose Bun version is not current. Saved storage records and VM file manifests written under the earlier 1.4.2 helper still parse, so saved stop and recovery stay available. That artifact is no longer cataloged: storage enrolled under it refuses verification as artifact drift and is never relabeled or re-seeded. Refs HACK-1758 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +- docs/reference/native-compose-benchmark.md | 3 +- .../native-compose-storage-xattr-carrier.md | 21 +++- docs/reference/native-config-files.md | 10 +- ...compose-storage-witness-docker-artifact.ts | 34 +++--- ...e-compose-storage-witness-xattr-carrier.ts | 22 +++- src/lib/native-compose-vm-file-owner.ts | 3 +- src/lib/native-compose-vm-file-protocol.ts | 8 +- tests/e2e/README.md | 7 +- .../helpers/native-compose-storage-docker.ts | 2 +- tests/helpers/native-compose-vm-docker.ts | 11 +- ...storage-carrier-journal-completion.test.ts | 2 +- ...ive-compose-storage-command-reader.test.ts | 2 +- ...ve-compose-storage-witness-command.test.ts | 2 +- ...compose-storage-witness-dependency.test.ts | 104 +++++++++++++++++- ...ive-compose-storage-witness-docker.test.ts | 9 +- ...ompose-storage-witness-xattr-owner.test.ts | 7 +- 17 files changed, 200 insertions(+), 52 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d62f283a..305c4cb83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,10 +328,9 @@ jobs: docker pull alpine:3.20 docker pull alpine:3.22 docker pull node:24.22.1-bookworm-slim + # Also the cataloged native file and storage helper image + # (src/lib/native-compose-storage-witness-docker-artifact.ts). docker pull oven/bun:1.4.4-slim - # The qualified native file and storage helper artifact stays on this image - # until its catalog pins are requalified (src/lib/native-compose-vm-file-protocol.ts). - docker pull oven/bun:1.4.2-slim docker pull postgres:17.6-alpine docker pull lucaslorentz/caddy-docker-proxy:2.10.0-alpine - uses: dtolnay/rust-toolchain@1.97.1 diff --git a/docs/reference/native-compose-benchmark.md b/docs/reference/native-compose-benchmark.md index c68ca4a63..138c6f9f9 100644 --- a/docs/reference/native-compose-benchmark.md +++ b/docs/reference/native-compose-benchmark.md @@ -22,7 +22,8 @@ Actual trials require a separately qualified compiled CLI and companion compiler their exact SHA-256 hashes and qualified source revision. Reserve the Docker test slot before starting: the flag acknowledges coordination; it cannot detect every other engine user. There must be no competing stateful tests. The harness requires -an existing local Unix Docker endpoint, cached `oven/bun:1.4.2-slim`, and the exact +an existing local Unix Docker endpoint, the cached `oven/bun:1.4.2-slim` workload image +and `oven/bun:1.4.4-slim` storage helper image, and the exact already-running proxy with curl. By default that proxy must expose standard HTTP port 80. The explicit fixture mode below accepts a separately qualified proxy with no published ports. The harness does not pull images, start/repair global services, diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index 03119f216..8716e7a21 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -9,7 +9,7 @@ before hooks, intent or volume effects. PostgreSQL data roots, filesystem crash durability and fresh-install acquisition of the pinned image remain unqualified gates; synthetic controls do not qualify that runtime behavior. -Storage startup requires the exact cached Linux arm64 or amd64 Bun 1.4.2 helper +Storage startup requires the exact cached Linux arm64 or amd64 Bun 1.4.4 helper image selected by the daemon architecture before any authored hook, enrollment intent or volume effect. The command never pulls a dependency or falls back to emulation. Uncached first-install dependency acquisition remains unsupported; the @@ -49,7 +49,7 @@ is part of this source. fixed bundled artifact. Qualification must bind this entry with its strict codec and kernel adapter; an exact declared local Linux image, Bun executable, libc and program digest must be checked before any carrier effect. The -dependency is an explicitly selected local `oven/bun:1.4.2-slim` image. A tag +dependency is an explicitly selected local `oven/bun:1.4.4-slim` image. A tag alone is not an artifact pin. Bun FFI remains experimental; source inspection and mocked ports do not qualify its behavior. @@ -276,12 +276,12 @@ on the admitted shell platforms; acceptance is at most 4096 bytes per stream. Unknown child disposition retains descriptors and the pending journal rather than starting competing cleanup. No child redirects through a reopened named path. -The declared dependencies use the fixed Linux arm64 and amd64 Bun 1.4.2 image index -`sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61`. +The declared dependencies use the fixed Linux arm64 and amd64 Bun 1.4.4 image index +`sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4`. The packaged helper pins its bytes and the qualified Bun/libc bytes separately. Dependency/platform checks precede volume effects; every helper uses `--pull never`. Each architecture has separate manifest/config identities and Bun/libc byte pins. -Inspection and create use the immutable `oven/bun@sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61` +Inspection and create use the immutable `oven/bun@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4` repository reference. Classic Engine stores resolve this reference to the selected config ID; containerd stores may expose the index or manifest ID. The response must retain one of the three qualified IDs and the exact selected platform. @@ -289,6 +289,17 @@ The carrier policy requires that same configured reference and `--pull never`; it never retries another image, tag or platform. There is no image download, emulation fallback or platform substitution. Uncached first installation remains outside this qualification. +The pins were derived from registry bytes, without a local engine: the OCI index, +each platform manifest and config, and every layer blob were checked against their +digests, then `/usr/local/bin/bun` and the architecture's `libc.so.6` were hashed +from the extracted layers. Matching pins do not qualify the helper's runtime +behavior on a daemon. + +Saved receipts, intents, references and carrier journals written under the earlier +Bun 1.4.2 artifact still parse, so saved stop, inspection and explicit recovery stay +available. That artifact is no longer cataloged: storage enrolled under it refuses +verification as artifact drift. It is never relabeled, re-seeded or adopted by the +new helper; succession for such storage needs its own qualified contract. Maintain the packaged source with `bun scripts/generate-native-storage-witness-helper.ts --check` or `--write`; the generator refuses a changed artifact pin rather than approving new bytes. diff --git a/docs/reference/native-config-files.md b/docs/reference/native-config-files.md index 47949a5c4..df4fa35f2 100644 --- a/docs/reference/native-config-files.md +++ b/docs/reference/native-config-files.md @@ -159,7 +159,7 @@ without launching a later child or inferring reaping from engine absence. The VM client preserves the selected `docker` command name for multiplexer clients while checking that its physical executable and selection remain unchanged. The VM path requires an explicit eligible local Unix Docker selection and already -cached workload images plus `oven/bun:1.4.2-slim`. It neither pulls nor builds an +cached workload images plus `oven/bun:1.4.4-slim`. It neither pulls nor builds an image. Images declaring volumes and workload pull policy `always` refuse. Hack pins each physical workload image and its original `Config.User`; it does not rewrite the app command or user. An omitted grant UID/GID defaults to `0:0` only @@ -222,16 +222,18 @@ lifecycle/recovery qualification remain separate gates for this VM transport. ## Requirements The file material VM path and native Compose storage witness verification both -run a fixed helper from the cataloged `oven/bun:1.4.2-slim` image. That exact +run a fixed helper from the cataloged `oven/bun:1.4.4-slim` image. That exact image must already be in the selected daemon's local image store: the owner never pulls, builds, emulates or substitutes another tag or platform, and storage admission inspects and creates with `--pull never`. Before the first file-bearing or storage-bearing native command on a daemon, pull it once: ```sh -docker pull oven/bun:1.4.2-slim +docker pull oven/bun:1.4.4-slim ``` The daemon's own architecture selects the cataloged linux/arm64 or linux/amd64 -artifact; other daemons refuse. A tag is not a pin: storage admission checks the +artifact; other daemons refuse. A VM file generation prepared with the earlier +`oven/bun:1.4.2-slim` helper keeps its own pinned helper image for restore and +retirement; new preparation uses only the current tag. A tag is not a pin: storage admission checks the exact cataloged image identity and refuses a tag that resolves to other bytes. diff --git a/src/lib/native-compose-storage-witness-docker-artifact.ts b/src/lib/native-compose-storage-witness-docker-artifact.ts index 626e56590..f3c81846a 100644 --- a/src/lib/native-compose-storage-witness-docker-artifact.ts +++ b/src/lib/native-compose-storage-witness-docker-artifact.ts @@ -3,23 +3,28 @@ import { isRecord } from "./guards.ts"; import { NATIVE_STORAGE_WITNESS_HELPER } from "./native-compose-storage-witness-helper-bundle.ts"; import { captureNativeComposeStorageXattrArtifact, + NATIVE_STORAGE_HELPER_BUN_VERSION, type NativeComposeStorageXattrArtifact, nativeComposeStorageXattrArtifactValid, } from "./native-compose-storage-witness-xattr-carrier.ts"; import { refuseNativeComposeStorageXattr as refuse } from "./native-compose-storage-witness-xattr-codec.ts"; /** Explicit cached dependency; the owner never pulls or builds this image. Other - * platforms require their own artifact/ABI/volume qualification before admission. */ + * platforms require their own artifact/ABI/volume qualification before admission. + * Pins for `oven/bun:1.4.4-slim` were derived from registry bytes: the OCI index, + * each platform manifest and config, and every layer blob verified against its + * digest, then `/usr/local/bin/bun` and the architecture's `libc.so.6` hashed from + * the extracted layers. A tag alone is not a pin. */ export const NATIVE_STORAGE_DOCKER_ARTIFACT: NativeComposeStorageXattrArtifact = Object.freeze({ version: 1, imageId: - "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", + "sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4", platform: "linux/arm64", - bunVersion: "1.4.2", - bunHash: "616f267a34278ff5ac282df37ffdfba1d7141f4f6926bca99af2cd6ef3ad32b1", + bunVersion: NATIVE_STORAGE_HELPER_BUN_VERSION, + bunHash: "354c2b049c716aca07b2e5cddb887928ac58950675d1258bb6312083917793b6", libcHash: - "9a325944115b36e74077786b29531963d96cd55cd6c82f11750a352cdb58fab0", + "70fa4c4cf57eabeb96097cce47582d02379c4cd1de3db3e98ade4dd7089a1b13", helperHash: "9d9c322ee35f43e047a41fd4901926789673a072c1e0bbf9030a106e7050ba5f", kernelAbi: 1, @@ -53,29 +58,29 @@ export const NATIVE_STORAGE_DOCKER_DEPENDENCIES: Dependencies = Object.freeze({ artifact: NATIVE_STORAGE_DOCKER_ARTIFACT, imageIds: Object.freeze([ NATIVE_STORAGE_DOCKER_ARTIFACT.imageId, - "sha256:5c51cee225076d3c7db2150683141476298062489de4660f2d1729e522641f91", - "sha256:1cb8f81099813a0ec61f99b69348f6594e188cf1689c20a0a53a5ddd23b37708", + "sha256:b0d169a695f779087d0fe8c08b5b17f0d3a927dceac2a087c87a1351340c6565", + "sha256:f545ab0408208347c62fad5822a4621a4b7fc3a7da43a1dea66dc17335535b7e", ]), }), "linux/amd64": Object.freeze({ artifact: Object.freeze({ version: 1, imageId: - "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", + "sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4", platform: "linux/amd64", - bunVersion: "1.4.2", + bunVersion: NATIVE_STORAGE_HELPER_BUN_VERSION, bunHash: - "a83d263767d839e4d2649ca8e35d07159c7afc99afdc96d731ced29e056dda0c", + "10702245766b225e70bbf6235fd6b47214fd94b4760d00ffaed8ab0df57ac235", libcHash: - "fa430b8f298f817a266046af84a77533185ad6fc4406c7d3787b5a0a0c207826", + "9792e3cbb541c8f44c7acf5f14f4022ea62998ecc787d326bed4d8b6547dfd92", helperHash: "9d9c322ee35f43e047a41fd4901926789673a072c1e0bbf9030a106e7050ba5f", kernelAbi: 1, }), imageIds: Object.freeze([ - "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", - "sha256:debbe76858f2e398d2937c1eceeb82c571ac1fcd78aadf00e9634578ac2b5ef7", - "sha256:808ae5d87c721a0c738c37d17ae4837367baa0c9c4353f5ea1762802b0fb6392", + "sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4", + "sha256:13b43f8bb2c63f5d89c56205c4279b85620363296a00ada33ff7ad568f3688e7", + "sha256:bca1fb36a1a809a0aec806c0a239364f4873ead989e7f069e0a47f17e0a83cfa", ]), }), }); @@ -109,6 +114,7 @@ export function selectNativeComposeStorageDockerDependency(opts: { Array.isArray(dependency.imageIds) ) || dependency.artifact.platform !== platform || + dependency.artifact.bunVersion !== NATIVE_STORAGE_HELPER_BUN_VERSION || dependency.artifact.helperHash !== NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash || dependency.imageIds.length !== 3 || diff --git a/src/lib/native-compose-storage-witness-xattr-carrier.ts b/src/lib/native-compose-storage-witness-xattr-carrier.ts index 83a2b36eb..fdca9138d 100644 --- a/src/lib/native-compose-storage-witness-xattr-carrier.ts +++ b/src/lib/native-compose-storage-witness-xattr-carrier.ts @@ -20,12 +20,28 @@ const TOKEN = /^[a-f0-9]{32}$/; const IMAGE = /^sha256:[a-f0-9]{64}$/; const ENGINE = /^[a-zA-Z0-9][a-zA-Z0-9:-]{0,127}$/; +/** Bun version of the currently cataloged helper image. */ +export const NATIVE_STORAGE_HELPER_BUN_VERSION = "1.4.4"; +/** Every helper Bun version a released CLI may have written into a saved receipt, + * intent, reference or carrier journal. Earlier versions stay parseable so saved + * stop, inspection and recovery keep working; only the current version is + * cataloged, so storage enrolled under an earlier artifact refuses verification + * as artifact drift instead of being relabeled or silently re-enrolled. */ +export type NativeComposeStorageHelperBunVersion = + | "1.4.2" + | typeof NATIVE_STORAGE_HELPER_BUN_VERSION; +function helperBunVersion( + value: unknown +): value is NativeComposeStorageHelperBunVersion { + return value === "1.4.2" || value === NATIVE_STORAGE_HELPER_BUN_VERSION; +} + /** Required private pins. A well-shaped pin is not an artifact/runtime qualification. */ export type NativeComposeStorageXattrArtifact = { readonly version: 1; readonly imageId: string; readonly platform: "linux/arm64" | "linux/amd64"; - readonly bunVersion: "1.4.2"; + readonly bunVersion: NativeComposeStorageHelperBunVersion; readonly bunHash: string; readonly libcHash: string; readonly helperHash: string; @@ -42,7 +58,7 @@ export function nativeComposeStorageXattrArtifactValid( ) && value.version === 1 && value.kernelAbi === 1 && - value.bunVersion === "1.4.2" && + helperBunVersion(value.bunVersion) && (value.platform === "linux/arm64" || value.platform === "linux/amd64") && typeof value.imageId === "string" && IMAGE.test(value.imageId) && @@ -64,7 +80,7 @@ export function captureNativeComposeStorageXattrArtifact( version: 1, imageId: value.imageId, platform: value.platform, - bunVersion: "1.4.2", + bunVersion: value.bunVersion, bunHash: value.bunHash, libcHash: value.libcHash, helperHash: value.helperHash, diff --git a/src/lib/native-compose-vm-file-owner.ts b/src/lib/native-compose-vm-file-owner.ts index 8e29d49da..34fc2e49d 100644 --- a/src/lib/native-compose-vm-file-owner.ts +++ b/src/lib/native-compose-vm-file-owner.ts @@ -51,6 +51,7 @@ import { createNativeComposeVmFileClient } from "./native-compose-vm-file-client import { NATIVE_COMPOSE_VM_FILE_IMAGE, NATIVE_COMPOSE_VM_FILE_LIMIT, + NATIVE_COMPOSE_VM_FILE_SAVED_IMAGES, NATIVE_COMPOSE_VM_FILES_EXTENSION, parseVmFileFacts, parseVmFileJournal, @@ -830,7 +831,7 @@ async function state(document: Document) { const helper = parseImage(raw.helper); requireValue( helper.workload === "" && - helper.reference === NATIVE_COMPOSE_VM_FILE_IMAGE && + NATIVE_COMPOSE_VM_FILE_SAVED_IMAGES.includes(helper.reference) && helper.id === raw.observer.image ); const images: Image[] = []; diff --git a/src/lib/native-compose-vm-file-protocol.ts b/src/lib/native-compose-vm-file-protocol.ts index 933d04724..3a1d2b007 100644 --- a/src/lib/native-compose-vm-file-protocol.ts +++ b/src/lib/native-compose-vm-file-protocol.ts @@ -3,7 +3,13 @@ import { refuseNativeComposeFile } from "./native-compose-file-bytes.ts"; import { keys } from "./native-compose-private-state.ts"; export const NATIVE_COMPOSE_VM_FILES_EXTENSION = "x-hack-native-vm-files"; -export const NATIVE_COMPOSE_VM_FILE_IMAGE = "oven/bun:1.4.2-slim"; +/** Helper image tag for new VM file preparation. The owner never pulls it. */ +export const NATIVE_COMPOSE_VM_FILE_IMAGE = "oven/bun:1.4.4-slim"; +/** Helper tags a released CLI may have recorded in a saved VM file manifest. A + * saved generation keeps its own pinned helper image ID for restore, readiness + * and retirement; only new preparation uses the current tag. */ +export const NATIVE_COMPOSE_VM_FILE_SAVED_IMAGES: readonly string[] = + Object.freeze(["oven/bun:1.4.2-slim", NATIVE_COMPOSE_VM_FILE_IMAGE]); export const NATIVE_COMPOSE_VM_FILE_LIMIT = 2 * 1024 * 1024; /** Docker omits optional empty image fields. Default absence only, retaining * every present value for the image decoder's type and policy checks. */ diff --git a/tests/e2e/README.md b/tests/e2e/README.md index fab8f8c11..88b648121 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -136,7 +136,7 @@ define those two spellings. The explicitly selected `native-config-vm-files` scenario exercises ordinary new containers with protected VM-native material; it does not adopt existing binds. -It uses only the cached helper image `oven/bun:1.4.2-slim`, two linked worktrees, no published ports +It uses only the cached helper image `oven/bun:1.4.4-slim`, two linked worktrees, no published ports and no image build or pull. Application-side checks independently require exact 0400/default-root and 0600/explicit-numeric owner identity, actual owner reads, distinct-UID `EACCES`, privileged write-open `EROFS`, exact single-file mounts and @@ -398,9 +398,8 @@ HACK_E2E_CLI_BIN=./dist/hack HACK_E2E_DOCKER=1 HACK_E2E_REQUIRE_DOCKER=1 bun tes ## Native file qualification `native-config-files` requires the current compiled CLI, its matching compiler and -cached `oven/bun:1.4.4-slim` (the workload image) plus `oven/bun:1.4.2-slim` (the qualified -native file and storage helper image, which stays on Bun 1.4.2 until its catalog -pins are requalified). It pins the existing image, uses no builds or published +cached `oven/bun:1.4.4-slim`, which is both the workload image and the cataloged +native file and storage helper image. It pins the existing image, uses no builds or published ports, and does not pull or activate DNS/trust. The primary and two linked worktrees deliver distinct binary configs, encrypted managed secrets and empty files through literal dollar-containing targets and a private runtime home outside the checkout. diff --git a/tests/helpers/native-compose-storage-docker.ts b/tests/helpers/native-compose-storage-docker.ts index 9b3089c12..1e151c832 100644 --- a/tests/helpers/native-compose-storage-docker.ts +++ b/tests/helpers/native-compose-storage-docker.ts @@ -10,7 +10,7 @@ const writeRows=async(path,rows)=>await Bun.write(path,JSON.stringify(rows)); const argument=key=>storageArgs[storageArgs.indexOf(key)+1]; const emit=value=>console.log(JSON.stringify(value)); const engineId="fixture-engine"; -const imageId="sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61"; +const imageId="sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4"; const imageReference="oven/bun@"+imageId; const labels=()=>Object.fromEntries(storageArgs.flatMap((value,index)=>value==="--label"?[storageArgs[index+1].split(/=(.*)/s).slice(0,2)]:[])); const volumeMount=volume=>({Type:"volume",Name:volume.name,Source:"/var/lib/docker/volumes/"+volume.name+"/_data",Destination:"/storage/"+volume.storage,Driver:"local",RW:true,Propagation:""}); diff --git a/tests/helpers/native-compose-vm-docker.ts b/tests/helpers/native-compose-vm-docker.ts index 167659ff0..d3778f4a7 100644 --- a/tests/helpers/native-compose-vm-docker.ts +++ b/tests/helpers/native-compose-vm-docker.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { appendFile, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { + NATIVE_COMPOSE_VM_FILE_IMAGE, VM_FILE_IMAGE_FORMAT, VM_FILE_OBSERVER_PROGRAM, VM_FILE_VERIFY_PROGRAM, @@ -267,16 +268,20 @@ if (args[0] === "synthetic-program") { } if (args[0] === "image") { exact(["image", "inspect", "--format", VM_FILE_IMAGE_FORMAT, args[4]]); - if (!["oven/bun:1.4.2-slim", "synthetic/reader:1"].includes(args[4] ?? "")) { + if ( + ![NATIVE_COMPOSE_VM_FILE_IMAGE, "synthetic/reader:1"].includes( + args[4] ?? "" + ) + ) { refuse(); } console.log( JSON.stringify({ - id: args[4] === "oven/bun:1.4.2-slim" ? helper : app, + id: args[4] === NATIVE_COMPOSE_VM_FILE_IMAGE ? helper : app, user: (await has("named-user")) ? "named" : "", volumes: null, labels: - args[4] === "oven/bun:1.4.2-slim" + args[4] === NATIVE_COMPOSE_VM_FILE_IMAGE ? (await has("image-label-collision")) ? { "io.hack.native-file.role": "foreign" } : { "org.opencontainers.image.title": "Bun synthetic" } diff --git a/tests/native-compose-storage-carrier-journal-completion.test.ts b/tests/native-compose-storage-carrier-journal-completion.test.ts index f397534dc..71db627c9 100644 --- a/tests/native-compose-storage-carrier-journal-completion.test.ts +++ b/tests/native-compose-storage-carrier-journal-completion.test.ts @@ -79,7 +79,7 @@ async function fixture(recordCreated = true) { kernelAbi: 1, platform: "linux/arm64", imageId: `sha256:${"e".repeat(64)}`, - bunVersion: "1.4.2", + bunVersion: "1.4.4", bunHash: "e".repeat(64), libcHash: "f".repeat(64), helperHash: "e".repeat(64), diff --git a/tests/native-compose-storage-command-reader.test.ts b/tests/native-compose-storage-command-reader.test.ts index d2bd1fac1..b3257eb49 100644 --- a/tests/native-compose-storage-command-reader.test.ts +++ b/tests/native-compose-storage-command-reader.test.ts @@ -514,7 +514,7 @@ test( version: 1, imageId: `sha256:${"a".repeat(64)}`, platform: "linux/arm64", - bunVersion: "1.4.2", + bunVersion: "1.4.4", bunHash: "a".repeat(64), libcHash: "b".repeat(64), helperHash: "c".repeat(64), diff --git a/tests/native-compose-storage-witness-command.test.ts b/tests/native-compose-storage-witness-command.test.ts index b20ba2e3b..6cc277262 100644 --- a/tests/native-compose-storage-witness-command.test.ts +++ b/tests/native-compose-storage-witness-command.test.ts @@ -343,7 +343,7 @@ test("source CLI reports and explicitly stops saved resources for unknown enroll version: 1, imageId: `sha256:${"a".repeat(64)}`, platform: "linux/arm64", - bunVersion: "1.4.2", + bunVersion: "1.4.4", bunHash: "b".repeat(64), libcHash: "c".repeat(64), helperHash: "e".repeat(64), diff --git a/tests/native-compose-storage-witness-dependency.test.ts b/tests/native-compose-storage-witness-dependency.test.ts index 64b75d69a..29ad82fe7 100644 --- a/tests/native-compose-storage-witness-dependency.test.ts +++ b/tests/native-compose-storage-witness-dependency.test.ts @@ -6,6 +6,15 @@ import { nativeComposeStorageDockerImageReference, selectNativeComposeStorageDockerDependency, } from "../src/lib/native-compose-storage-witness-docker-artifact.ts"; +import { + captureNativeComposeStorageXattrArtifact, + NATIVE_STORAGE_HELPER_BUN_VERSION, + nativeComposeStorageXattrArtifactValid, +} from "../src/lib/native-compose-storage-witness-xattr-carrier.ts"; +import { + NATIVE_COMPOSE_VM_FILE_IMAGE, + NATIVE_COMPOSE_VM_FILE_SAVED_IMAGES, +} from "../src/lib/native-compose-vm-file-protocol.ts"; const engineId = "fixture-engine"; const daemon = { id: engineId, os: "linux", arch: "arm64" }; @@ -77,20 +86,20 @@ test.each([ expect(selected.artifact).toEqual({ version: 1, imageId: - "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", + "sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4", platform: "linux/amd64", - bunVersion: "1.4.2", - bunHash: "a83d263767d839e4d2649ca8e35d07159c7afc99afdc96d731ced29e056dda0c", + bunVersion: "1.4.4", + bunHash: "10702245766b225e70bbf6235fd6b47214fd94b4760d00ffaed8ab0df57ac235", libcHash: - "fa430b8f298f817a266046af84a77533185ad6fc4406c7d3787b5a0a0c207826", + "9792e3cbb541c8f44c7acf5f14f4022ea62998ecc787d326bed4d8b6547dfd92", helperHash: "9d9c322ee35f43e047a41fd4901926789673a072c1e0bbf9030a106e7050ba5f", kernelAbi: 1, }); expect(selected.imageIds).toEqual([ selected.artifact.imageId, - "sha256:debbe76858f2e398d2937c1eceeb82c571ac1fcd78aadf00e9634578ac2b5ef7", - "sha256:808ae5d87c721a0c738c37d17ae4837367baa0c9c4353f5ea1762802b0fb6392", + "sha256:13b43f8bb2c63f5d89c56205c4279b85620363296a00ada33ff7ad568f3688e7", + "sha256:bca1fb36a1a809a0aec806c0a239364f4873ead989e7f069e0a47f17e0a83cfa", ]); expect(Object.isFrozen(selected.artifact)).toBe(true); expect(Object.isFrozen(selected.imageIds)).toBe(true); @@ -223,3 +232,86 @@ test("AMD cached image admits only its three identities and exact architecture", ).rejects.toThrow("values omitted"); } }); + +test("ARM catalog pins the registry-derived Bun 1.4.4 slim byte records", () => { + expect(NATIVE_STORAGE_HELPER_BUN_VERSION).toBe("1.4.4"); + expect(NATIVE_STORAGE_DOCKER_ARTIFACT).toEqual({ + version: 1, + imageId: + "sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4", + platform: "linux/arm64", + bunVersion: "1.4.4", + bunHash: "354c2b049c716aca07b2e5cddb887928ac58950675d1258bb6312083917793b6", + libcHash: + "70fa4c4cf57eabeb96097cce47582d02379c4cd1de3db3e98ade4dd7089a1b13", + helperHash: + "9d9c322ee35f43e047a41fd4901926789673a072c1e0bbf9030a106e7050ba5f", + kernelAbi: 1, + }); + expect(NATIVE_STORAGE_DOCKER_DEPENDENCIES["linux/arm64"]?.imageIds).toEqual([ + NATIVE_STORAGE_DOCKER_ARTIFACT.imageId, + "sha256:b0d169a695f779087d0fe8c08b5b17f0d3a927dceac2a087c87a1351340c6565", + "sha256:f545ab0408208347c62fad5822a4621a4b7fc3a7da43a1dea66dc17335535b7e", + ]); + // The VM file path runs from the same cached helper image by its tag. + expect(NATIVE_COMPOSE_VM_FILE_IMAGE).toBe("oven/bun:1.4.4-slim"); +}); + +// The Bun 1.4.2 slim record an earlier CLI may have written into saved storage state. +const earlier = Object.freeze({ + version: 1, + imageId: + "sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", + platform: "linux/arm64", + bunVersion: "1.4.2", + bunHash: "616f267a34278ff5ac282df37ffdfba1d7141f4f6926bca99af2cd6ef3ad32b1", + libcHash: "9a325944115b36e74077786b29531963d96cd55cd6c82f11750a352cdb58fab0", + helperHash: NATIVE_STORAGE_DOCKER_ARTIFACT.helperHash, + kernelAbi: 1, +} as const); + +test("an earlier saved helper pin stays parseable without relabeling, but is never selected or run", () => { + expect(nativeComposeStorageXattrArtifactValid(earlier)).toBe(true); + expect(captureNativeComposeStorageXattrArtifact(earlier)).toEqual(earlier); + expect(() => nativeComposeStorageDockerImageReference(earlier)).toThrow( + "values omitted" + ); + for (const artifact of [ + earlier, + // Current image identity with the earlier Bun label still refuses. + { ...NATIVE_STORAGE_DOCKER_ARTIFACT, bunVersion: "1.4.2" as const }, + ]) { + expect(() => + selectNativeComposeStorageDockerDependency({ + daemon, + engineId, + dependencies: { + "linux/arm64": { + artifact, + imageIds: [ + artifact.imageId, + `sha256:${"d".repeat(64)}`, + `sha256:${"e".repeat(64)}`, + ], + }, + }, + }) + ).toThrow("values omitted"); + } + for (const bunVersion of ["1.4.3", "1.4.4 ", "1.4", 1.4]) { + expect( + nativeComposeStorageXattrArtifactValid({ + ...NATIVE_STORAGE_DOCKER_ARTIFACT, + bunVersion, + }) + ).toBe(false); + } +}); + +test("saved VM file manifests accept only the cataloged current and earlier helper tags", () => { + expect(NATIVE_COMPOSE_VM_FILE_SAVED_IMAGES).toEqual([ + "oven/bun:1.4.2-slim", + NATIVE_COMPOSE_VM_FILE_IMAGE, + ]); + expect(Object.isFrozen(NATIVE_COMPOSE_VM_FILE_SAVED_IMAGES)).toBe(true); +}); diff --git a/tests/native-compose-storage-witness-docker.test.ts b/tests/native-compose-storage-witness-docker.test.ts index 09b4830c0..b0bc3bc0e 100644 --- a/tests/native-compose-storage-witness-docker.test.ts +++ b/tests/native-compose-storage-witness-docker.test.ts @@ -92,7 +92,10 @@ test("cached image prerequisite emits complete sparse JSON before carrier effect }); test("cached image prerequisite refuses the faithful truncated sparse output", async () => { - const truncated = `{"id":"${NATIVE_STORAGE_DOCKER_ARTIFACT.imageId}","os":"linux","arch":"arm64","volumes":null\n`; + // Recorded bytes from the earlier Bun 1.4.2 helper image; truncation alone + // must refuse before any identity comparison. + const truncated = + '{"id":"sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61","os":"linux","arch":"arm64","volumes":null\n'; expect(Buffer.byteLength(truncated)).toBe(123); expect(createHash("sha256").update(truncated).digest("hex")).toBe( "aea01511888599e3e1cacfc66c146dadb3ffdec207e1a620fe0136c2bca72ad8" @@ -912,7 +915,9 @@ test.each([ }); test.each([ NATIVE_STORAGE_DOCKER_ARTIFACT.imageId, + "oven/bun:1.4.4-slim", "oven/bun:1.4.2-slim", + "oven/bun@sha256:cb3bbbb08e13a4a2ff400f24c7a2a1d5efa83f6ef8544d52d95a519631e2fc61", `foreign/bun@${NATIVE_STORAGE_DOCKER_ARTIFACT.imageId}`, `oven/bun@sha256:${"f".repeat(64)}`, ])("configured image must remain the exact immutable repository reference: %s", (configImage) => { @@ -1361,7 +1366,7 @@ test("an exhausted xattr carrier lifetime refuses with the storage budget reason version: 1, imageId: `sha256:${"a".repeat(64)}`, platform: "linux/arm64", - bunVersion: "1.4.2", + bunVersion: "1.4.4", bunHash: "b".repeat(64), libcHash: "c".repeat(64), helperHash: "e".repeat(64), diff --git a/tests/native-compose-storage-witness-xattr-owner.test.ts b/tests/native-compose-storage-witness-xattr-owner.test.ts index fa66ecfaa..4afd0fe03 100644 --- a/tests/native-compose-storage-witness-xattr-owner.test.ts +++ b/tests/native-compose-storage-witness-xattr-owner.test.ts @@ -81,7 +81,7 @@ const artifact: NativeComposeStorageXattrArtifact = { version: 1, imageId: `sha256:${"a".repeat(64)}`, platform: "linux/arm64", - bunVersion: "1.4.2", + bunVersion: "1.4.4", bunHash: "b".repeat(64), libcHash: "c".repeat(64), helperHash: "e".repeat(64), @@ -3054,6 +3054,7 @@ test.each([ "engine", "birth", "artifact", + "earlier-bun", "owner", "generation", "pending", @@ -3083,6 +3084,10 @@ test.each([ if (failure === "artifact") { intent.artifact.helperHash = "f".repeat(64); } + if (failure === "earlier-bun") { + // An earlier helper pin still parses, but never verifies as the current one. + intent.artifact.bunVersion = "1.4.2"; + } if (failure === "owner") { intent.ownerToken = "f".repeat(32); } From c0b67ac237a8bfcf2fd5203f90729f57e7f5f473 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:35:50 -0500 Subject: [PATCH 028/138] fix(native): re-read host alias inputs after hooks and refuse unreadable maps The native post-hook alias check passed the configuration snapshot read before `up.before`, so a hook that edited `internal.extra_hosts` in hack.config.json compared stale static aliases and startup proceeded. The check now re-reads and validates project configuration itself (`requireNativeProjectInternalExtraHostsUnchanged` takes no snapshot) and refuses after hook cleanup when the effective aliases changed. `readLocalExtraHosts` collapsed every read error into an empty map, so an existing but unreadable checkout-local `.internal/extra-hosts.json` silently dropped configured aliases. It now uses `readOptionalTextFile`: an absent map is empty, an unreadable one refuses with `E_CONFIG_INVALID`. This also covers Compose `up` and the `hack internal extra-hosts` commands, which no longer overwrite a map they could not read. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 + docs/guides/native-candidate.md | 5 +- src/backends/native-project-internal-hosts.ts | 62 ++++++++ src/commands/project.ts | 36 +---- src/lib/internal-extra-hosts.ts | 27 +++- tests/native-project-internal-hosts.test.ts | 143 +++++++++++++++++- 6 files changed, 239 insertions(+), 36 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 39d6bed97..d7506c1bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,6 +90,8 @@ When `internal.dns` / `internal.tls` are enabled, `hack up` writes a Compose ove as a side effect of internal trust - injects `extra_hosts` mappings for `*.hack` → current Caddy IP (for runtimes that ignore custom DNS) - merges any repo-local `.hack/.internal/extra-hosts.json` entries (for host tunnels / dynamic domains) + (an absent file is empty; one that exists but cannot be read refuses with `E_CONFIG_INVALID` + instead of dropping its aliases) This lets containers use the same `https://*.hack` hostnames as the host machine. If Caddy’s IP changes, `hack status`, `hack doctor`, and the TUI will warn; `hack restart` refreshes the mapping. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 78cd59e36..343c68b74 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1321,7 +1321,10 @@ to a different target also refuses. Earlier native builds ignored these entries, which let a container resolve the public name instead of the local listener. Native reads these aliases before lifecycle hooks, because they are part of the reviewed configuration; unlike the Compose backend, a hook that changes them makes -`up` refuse after hook cleanup. Projects without project-wide aliases are unchanged. +`up` refuse after hook cleanup. The check after hooks reads `hack.config.json` and +the checkout-local alias map again, so an edit to either is caught. A local alias map +that exists but cannot be read refuses with `E_CONFIG_INVALID` instead of being +treated as empty. Projects without project-wide aliases are unchanged. Unlike a Compose host gateway, which reaches every host port, a service reaches each alias only on the one guest port its binding selects. diff --git a/src/backends/native-project-internal-hosts.ts b/src/backends/native-project-internal-hosts.ts index a267653d6..1c10a3441 100644 --- a/src/backends/native-project-internal-hosts.ts +++ b/src/backends/native-project-internal-hosts.ts @@ -1,5 +1,11 @@ import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; +import { + type ProjectConfig, + type ProjectContext, + readProjectConfig, +} from "../lib/project.ts"; import type { NativeHostDependency } from "./native-project-dependencies.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; @@ -277,6 +283,43 @@ function sameHosts( ); } +/** Fixed text when native up cannot read its project-wide alias inputs. */ +export const NATIVE_INTERNAL_HOSTS_UNREADABLE_REFUSAL = + "Native up requires a readable internal extra_hosts selection; values omitted."; + +/** + * The same project-wide aliases the Compose override renders (static + * `internal.extra_hosts` plus checkout-local entries), for native start. + * + * Without `cfg`, project configuration is read and validated again, so a + * caller that runs after lifecycle hooks observes what the hooks left behind. + * Invalid configuration or an unreadable local map refuses with a closed line + * instead of being skipped. + */ +export async function resolveNativeInternalExtraHosts(opts: { + readonly project: ProjectContext; + readonly cfg?: ProjectConfig; +}): Promise> { + try { + const cfg = opts.cfg ?? (await readProjectConfig(opts.project)); + if (cfg.parseError) { + throw new Error("invalid project configuration"); + } + return ( + await resolveInternalExtraHosts({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + staticHosts: cfg.internal?.extraHosts, + }) + ).hosts; + } catch { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: NATIVE_INTERNAL_HOSTS_UNREADABLE_REFUSAL, + }); + } +} + /** * The Compose backend re-reads project-wide aliases after `up.before` hooks. * Native declares them before review, so a hook that changes them refuses @@ -302,3 +345,22 @@ export async function requireNativeInternalExtraHostsUnchanged(opts: { message: NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL, }); } + +/** + * Post-hook form of {@link requireNativeInternalExtraHostsUnchanged} for a + * project checkout: re-reads project configuration and the checkout-local + * alias maps, so an `up.before` hook that edits either refuses after cleanup. + * It deliberately takes no configuration snapshot. + */ +export async function requireNativeProjectInternalExtraHostsUnchanged(opts: { + readonly project: ProjectContext; + readonly reviewed: Readonly>; + readonly cleanup: () => Promise; +}): Promise { + await requireNativeInternalExtraHostsUnchanged({ + reviewed: opts.reviewed, + current: async () => + await resolveNativeInternalExtraHosts({ project: opts.project }), + cleanup: opts.cleanup, + }); +} diff --git a/src/commands/project.ts b/src/commands/project.ts index 4c9486274..faa3ce8be 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -46,7 +46,10 @@ import { nativeDownEnvironment, nativeProjectDown, } from "../backends/native-project-down.ts"; -import { requireNativeInternalExtraHostsUnchanged } from "../backends/native-project-internal-hosts.ts"; +import { + requireNativeProjectInternalExtraHostsUnchanged, + resolveNativeInternalExtraHosts, +} from "../backends/native-project-internal-hosts.ts"; import { adoptNativeLifecycleCleanup } from "../backends/native-project-lifecycle.ts"; import { parseNativeAllowedHosts } from "../backends/native-project-network.ts"; import { @@ -2917,31 +2920,6 @@ async function resolveInternalComposeOverride(opts: { }); } -/** - * The same project-wide aliases the Compose override renders (static - * `internal.extra_hosts` plus checkout-local entries), for native start. An - * unreadable local map refuses with a closed line instead of being skipped. - */ -async function resolveNativeInternalExtraHosts(opts: { - readonly project: Awaited>; - readonly cfg: Awaited>; -}): Promise> { - try { - return ( - await resolveInternalExtraHosts({ - ...opts.project, - staticHosts: resolveInternalSettings(opts.cfg).extraHosts ?? undefined, - }) - ).hosts; - } catch { - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: - "Native up requires a readable internal extra_hosts selection; values omitted.", - }); - } -} - function shouldBuildInternalOverride(opts: { readonly internal: ReturnType; readonly managedExtraHosts: Record; @@ -5839,10 +5817,10 @@ async function handleNativeUp({ }); } // Native declares these before review; a hook cannot change them now. - await requireNativeInternalExtraHostsUnchanged({ + // This re-reads hack.config.json, not the pre-hook `cfg` snapshot. + await requireNativeProjectInternalExtraHostsUnchanged({ + project, reviewed: internalExtraHosts, - current: async () => - await resolveNativeInternalExtraHosts({ project, cfg }), cleanup, }); // The native backend launches services after this returns: refuse when a diff --git a/src/lib/internal-extra-hosts.ts b/src/lib/internal-extra-hosts.ts index d9a2d505f..13e86cddc 100644 --- a/src/lib/internal-extra-hosts.ts +++ b/src/lib/internal-extra-hosts.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; -import { readTextFile } from "./fs.ts"; +import { HackCliError } from "./cli-result.ts"; +import { readOptionalTextFile } from "./fs.ts"; import { isRecord } from "./guards.ts"; import { resolvePrimaryLocalProjectDir, @@ -10,15 +11,31 @@ export function resolveExtraHostsPath(projectDir: string): string { return resolve(projectDir, ".internal", "extra-hosts.json"); } -/** Null is a checkout-local tombstone hiding a primary alias. */ +/** Fixed text when a checkout-local alias map exists but cannot be read. */ +export const UNREADABLE_EXTRA_HOSTS_MAP = + "Cannot read the internal extra-hosts map; aliases were not dropped."; + +/** + * Null is a checkout-local tombstone hiding a primary alias. + * + * An absent map is empty. A map that exists but cannot be read (a directory, + * a dangling link, no read permission) refuses with `E_CONFIG_INVALID`, so a + * configured alias is never silently omitted or overwritten. + */ export async function readLocalExtraHosts( path: string ): Promise> { - const text = await readTextFile(path); - if (!text) { + const file = await readOptionalTextFile(path); + if (file.kind === "unreadable") { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: UNREADABLE_EXTRA_HOSTS_MAP, + }); + } + if (file.kind === "absent" || !file.text) { return {}; } - const parsed: unknown = JSON.parse(text); + const parsed: unknown = JSON.parse(file.text); if (!isRecord(parsed)) { throw new Error("Invalid internal extra-hosts map"); } diff --git a/tests/native-project-internal-hosts.test.ts b/tests/native-project-internal-hosts.test.ts index 27386a9ef..127bf3a8b 100644 --- a/tests/native-project-internal-hosts.test.ts +++ b/tests/native-project-internal-hosts.test.ts @@ -1,15 +1,28 @@ -import { expect, test } from "bun:test"; +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { NativeHostDependency } from "../src/backends/native-project-dependencies.ts"; import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; import { applyNativeInternalHostAliases, NATIVE_INTERNAL_HOST_ALIAS_REFUSAL, NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL, + NATIVE_INTERNAL_HOSTS_UNREADABLE_REFUSAL, NATIVE_UNBOUND_HOST_ALIAS_REFUSAL, nativeServiceHostAliases, requireNativeHostAliasBindings, requireNativeInternalExtraHostsUnchanged, + requireNativeProjectInternalExtraHostsUnchanged, + resolveNativeInternalExtraHosts, } from "../src/backends/native-project-internal-hosts.ts"; +import { + readLocalExtraHosts, + resolveExtraHostsPath, + resolveInternalExtraHosts, + UNREADABLE_EXTRA_HOSTS_MAP, +} from "../src/lib/internal-extra-hosts.ts"; +import { type ProjectContext, readProjectConfig } from "../src/lib/project.ts"; import { expectCodedRefusal, expectCodedThrow, @@ -263,3 +276,131 @@ test("aliases changed by lifecycle hooks refuse after hook cleanup", async () => } expect(cleaned).toBe(4); }); + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +/** A plain (non-Git) project checkout, so no primary aliases are inherited. */ +async function projectFixture(config: unknown): Promise { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-internal-hosts-")) + ); + roots.push(root); + const projectDir = join(root, ".hack"); + await mkdir(join(projectDir, ".internal"), { recursive: true }); + const configFile = join(projectDir, "hack.config.json"); + await writeFile(configFile, JSON.stringify(config)); + return { + projectRoot: root, + projectDirName: ".hack", + projectDir, + composeFile: join(projectDir, "docker-compose.yml"), + envFile: join(projectDir, ".env"), + configFile, + }; +} + +test("a hook that edits hack.config.json extra_hosts refuses after cleanup", async () => { + const config = { + name: "hosts-fixture", + internal: { extra_hosts: { "api.example.test": "host-gateway" } }, + }; + const project = await projectFixture(config); + // Startup resolves the reviewed aliases from the configuration it read + // before `up.before` ran. + const cfg = await readProjectConfig(project); + const reviewed = await resolveNativeInternalExtraHosts({ project, cfg }); + expect(reviewed).toEqual({ "api.example.test": "host-gateway" }); + let cleaned = 0; + const cleanup = async () => { + cleaned++; + }; + await requireNativeProjectInternalExtraHostsUnchanged({ + project, + reviewed, + cleanup, + }); + expect(cleaned).toBe(0); + + for (const edited of [ + JSON.stringify({ + ...config, + internal: { + extra_hosts: { + "api.example.test": "host-gateway", + "auth.example.test": "host-gateway", + }, + }, + }), + JSON.stringify({ ...config, internal: {} }), + "{ not json", + ]) { + // The hook rewrites the file; the stale `cfg` snapshot still matches. + await writeFile(project.configFile, edited); + expect(await resolveNativeInternalExtraHosts({ project, cfg })).toEqual( + reviewed + ); + await expectCodedRefusal( + requireNativeProjectInternalExtraHostsUnchanged({ + project, + reviewed, + cleanup, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL + ); + } + expect(cleaned).toBe(3); +}); + +test("an unreadable checkout-local alias map refuses; an absent one is empty", async () => { + const project = await projectFixture({ + name: "hosts-fixture", + internal: { extra_hosts: { "api.example.test": "host-gateway" } }, + }); + const path = resolveExtraHostsPath(project.projectDir); + expect(await readLocalExtraHosts(path)).toEqual({}); + expect(await resolveNativeInternalExtraHosts({ project })).toEqual({ + "api.example.test": "host-gateway", + }); + + // A directory exists but cannot be read as a file, even when tests run as + // root (unlike a mode-000 file). + await mkdir(path); + await expectCodedRefusal( + readLocalExtraHosts(path), + "E_CONFIG_INVALID", + UNREADABLE_EXTRA_HOSTS_MAP + ); + await expectCodedRefusal( + resolveInternalExtraHosts(project), + "E_CONFIG_INVALID", + UNREADABLE_EXTRA_HOSTS_MAP + ); + await expectCodedRefusal( + resolveNativeInternalExtraHosts({ project }), + "E_CONFIG_INVALID", + NATIVE_INTERNAL_HOSTS_UNREADABLE_REFUSAL + ); + // A post-hook map that became unreadable refuses rather than dropping it. + await rm(path, { recursive: true }); + const reviewed = await resolveNativeInternalExtraHosts({ project }); + await mkdir(path); + let cleaned = 0; + await expectCodedRefusal( + requireNativeProjectInternalExtraHostsUnchanged({ + project, + reviewed, + cleanup: async () => { + cleaned++; + }, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_INTERNAL_HOSTS_CHANGED_REFUSAL + ); + expect(cleaned).toBe(1); +}); From 3d50abe4d91519e407ed375c1fa01dad8bcfa71a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:37:53 -0500 Subject: [PATCH 029/138] fix(native): cancel foreground down hooks by owned process group A down hook run by a foreground Ctrl-C now leads its own process group with stdin closed. Canceling it (a second interrupt) sends SIGTERM to the whole group, then SIGKILL once the shell exits or after a two-second grace, and output draining after cancellation is bounded by the same grace. A hook such as `sleep 600; echo finished` no longer leaves its child running with the output pipes open, which blocked the forced stop and the journal settlement. Hooks run by `hack down` keep the caller's process group and terminal stdin. The frontend's stop listeners now stay installed until down.after and the finalization acknowledgement settle (disposed in an enclosing finally), so a further interrupt cancels a running down.after through the force signal instead of terminating the frontend with an after-started journal record. Real-process regressions cover shell plus sleep child cancellation, SIGTERM-ignoring descendants, an escaped process holding the pipes, the non-owned path, and a second Ctrl-C during a real down.after through startNativeProject. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 7 +- src/backends/native-project-start.ts | 37 ++-- src/commands/project.ts | 54 +++--- src/lib/lifecycle-command-child.ts | 119 ++++++++++++ tests/lifecycle-command-child.test.ts | 178 ++++++++++++++++++ tests/native-project-start-stop-hooks.test.ts | 70 +++++++ 6 files changed, 416 insertions(+), 49 deletions(-) create mode 100644 src/lib/lifecycle-command-child.ts create mode 100644 tests/lifecycle-command-child.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 1fd216660..cd20630e3 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -777,7 +777,12 @@ exit, and the command reports `E_LIFECYCLE_FAILED`; a failed `down.after` report the same after cleanup was confirmed. Ctrl-C while `up.after` is still running stops the graph without any down hook. With down hooks declared, the graph owner runs in its own process group, so the terminal's interrupt reaches only the `hack` frontend, -which decides when the owner stops. +which decides when the owner stops. Each down hook run by Ctrl-C also runs in its own +process group with stdin closed. Canceling it signals the whole group (SIGTERM, then +SIGKILL once the hook's shell exits or after a two-second grace), and output from a +canceled hook is drained for at most that grace, so a child such as `sleep` in +`sleep 600; echo finished` cannot keep the stop waiting. The frontend keeps handling +interrupts until `down.after` and its finalization acknowledgement are settled. A private, value-free journal beside the run mapping records an intent before each down hook starts and its known completion after the hook's process exits. It holds diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 19a13fbc2..e621e7ca3 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -1496,21 +1496,28 @@ export async function startNativeProject(opts: { failed = true; throw httpsCleanupFailure(httpsAcquisitionFailure, error); } finally { - stopSignals.dispose(); - await hooks?.cleanup().catch((error: unknown) => { - throw httpsCleanupFailure(httpsAcquisitionFailure, error); - }); - // down.after follows confirmed cleanup and lifecycle-process retirement, - // and precedes the finalization acknowledgement `down` waits for. - const stopFailure = await foregroundStop?.finish(graphCleanupConfirmed); - await acknowledgeFinalization( - finalization, - graphCleanupConfirmed, - httpsCleanupConfirmed - ).catch((error: unknown) => { - throw httpsCleanupFailure(httpsAcquisitionFailure, error); - }); - reportForegroundStopFailure(stopFailure, failed); + // The stop listeners stay installed until down.after and the finalization + // acknowledgement settle: a further interrupt then cancels the running + // hook through the force signal instead of terminating this frontend + // with the hook's journal record or the acknowledgement unsettled. + try { + await hooks?.cleanup().catch((error: unknown) => { + throw httpsCleanupFailure(httpsAcquisitionFailure, error); + }); + // down.after follows confirmed cleanup and lifecycle-process retirement, + // and precedes the finalization acknowledgement `down` waits for. + const stopFailure = await foregroundStop?.finish(graphCleanupConfirmed); + await acknowledgeFinalization( + finalization, + graphCleanupConfirmed, + httpsCleanupConfirmed + ).catch((error: unknown) => { + throw httpsCleanupFailure(httpsAcquisitionFailure, error); + }); + reportForegroundStopFailure(stopFailure, failed); + } finally { + stopSignals.dispose(); + } } } /** A failed stop hook replaces the interrupted status, never a startup error. */ diff --git a/src/commands/project.ts b/src/commands/project.ts index 6bfc21bc9..b3b6f19db 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -214,6 +214,7 @@ import { resolveHackEnv, upsertDotEnvValue } from "../lib/hack-env.ts"; import { canPrompt, requireInteractive } from "../lib/interactivity.ts"; import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; import { parseJsonLines } from "../lib/json-lines.ts"; +import { runLifecycleCommandChild } from "../lib/lifecycle-command-child.ts"; import { appendLifecycleLogRecord, readLifecycleState, @@ -1747,8 +1748,11 @@ async function maybePromptToFixMissingEnv(opts: { return true; } -/** One finite lifecycle command; the signal sends SIGTERM to its shell. */ -async function runLifecycleChild(opts: { +/** + * One finite lifecycle command with its output logged. The signal cancels the + * command's whole owned process group (see {@link runLifecycleCommandChild}). + */ +function runLifecycleChild(opts: { readonly command: string; readonly cwd: string; readonly env: Record; @@ -1758,39 +1762,20 @@ async function runLifecycleChild(opts: { readonly service: string; readonly signal?: AbortSignal; }): Promise { - if (opts.signal?.aborted) { - return 130; - } - const proc = Bun.spawn(["sh", "-c", opts.command], { + return runLifecycleCommandChild({ + command: opts.command, cwd: opts.cwd, env: opts.env, - stdin: "inherit", - stdout: "pipe", - stderr: "pipe", - }); - const cancel = () => proc.kill("SIGTERM"); - opts.signal?.addEventListener("abort", cancel, { once: true }); - const stdoutTask = streamLifecycleCommandOutput({ - stream: proc.stdout, - output: opts.output, - projectDir: opts.projectDir, - composeProject: opts.composeProject, - service: opts.service, - }); - const stderrTask = streamLifecycleCommandOutput({ - stream: proc.stderr, - output: "stderr", - projectDir: opts.projectDir, - composeProject: opts.composeProject, - service: opts.service, + signal: opts.signal, + drain: (stream, source) => + streamLifecycleCommandOutput({ + stream, + output: source === "stdout" ? opts.output : "stderr", + projectDir: opts.projectDir, + composeProject: opts.composeProject, + service: opts.service, + }), }); - try { - const code = await proc.exited; - return opts.signal?.aborted ? 130 : code; - } finally { - opts.signal?.removeEventListener("abort", cancel); - await Promise.all([stdoutTask, stderrTask]); - } } async function runLifecycleCommands(opts: { @@ -1806,7 +1791,10 @@ async function runLifecycleCommands(opts: { readonly index: number; readonly serviceName: string; }) => Promise; - /** Cancels the running command (SIGTERM) and the rest; the result is then 130. */ + /** + * Cancels the running command's owned process group and the rest; the + * result is then 130. + */ readonly signal?: AbortSignal; }): Promise { const commands = opts.commands ?? []; diff --git a/src/lib/lifecycle-command-child.ts b/src/lib/lifecycle-command-child.ts new file mode 100644 index 000000000..ab376c722 --- /dev/null +++ b/src/lib/lifecycle-command-child.ts @@ -0,0 +1,119 @@ +/** SIGTERM-to-SIGKILL grace, and the output-drain bound after cancellation. */ +const CANCEL_GRACE_MS = 2000; + +/** A pipe reader whose pending read can be ended without the writer closing. */ +function interruptible(stream: ReadableStream) { + const reader = stream.getReader(); + return { + stream: new ReadableStream({ + async pull(controller) { + const chunk = await reader.read(); + if (chunk.done) { + controller.close(); + } else { + controller.enqueue(chunk.value); + } + }, + cancel: (reason) => reader.cancel(reason), + }), + stop: () => { + reader.cancel().catch(() => undefined); + }, + }; +} + +/** + * Run one finite lifecycle command with `sh -c` and drain its output. + * + * Without a signal the command keeps the caller's process group and terminal + * stdin, as lifecycle hooks always have, and its output is drained until the + * pipes close. + * + * With a signal the command owns a process-group cancellation boundary. The + * shell leads a new process group, so the terminal's interrupt reaches only + * the caller, which decides when to cancel; stdin is closed because a + * background group cannot read the terminal. Abort sends SIGTERM to the whole + * group, then SIGKILL once the shell exits or the grace period ends, so a + * descendant such as `sleep` in `sleep 600; echo finished` cannot outlive the + * cancellation. Output draining after cancellation is bounded by the same + * grace period, so a process that escaped the group and still holds the pipes + * cannot delay a forced stop. The listener stays installed until draining + * finishes. The result is 130 when the signal aborted before the shell exited. + */ +export async function runLifecycleCommandChild(opts: { + readonly command: string; + readonly cwd: string; + readonly env: Record; + readonly signal?: AbortSignal; + readonly drain: ( + stream: ReadableStream, + source: "stdout" | "stderr" + ) => Promise; + /** Test seam; defaults to {@link CANCEL_GRACE_MS}. */ + readonly graceMs?: number; +}): Promise { + const signal = opts.signal; + if (signal?.aborted) { + return 130; + } + const owned = signal !== undefined; + const proc = Bun.spawn(["sh", "-c", opts.command], { + cwd: opts.cwd, + env: opts.env, + stdin: owned ? "ignore" : "inherit", + stdout: "pipe", + stderr: "pipe", + detached: owned, + }); + const stdout = interruptible(proc.stdout); + const stderr = interruptible(proc.stderr); + const drained = Promise.all([ + opts.drain(stdout.stream, "stdout"), + opts.drain(stderr.stream, "stderr"), + ]); + // Awaited below; observed now so an early drain failure is not unhandled. + drained.catch(() => undefined); + const graceMs = opts.graceMs ?? CANCEL_GRACE_MS; + const signalGroup = (name: NodeJS.Signals): void => { + try { + process.kill(-proc.pid, name); + } catch { + // The owned group already exited. + } + }; + let exited = false; + let canceled = false; + let kill: ReturnType | undefined; + let release: ReturnType | undefined; + const cancel = (): void => { + if (canceled) { + return; + } + canceled = true; + signalGroup(exited ? "SIGKILL" : "SIGTERM"); + if (!exited) { + kill = setTimeout(() => signalGroup("SIGKILL"), graceMs); + } + release = setTimeout(() => { + stdout.stop(); + stderr.stop(); + }, graceMs); + }; + signal?.addEventListener("abort", cancel, { once: true }); + try { + const code = await proc.exited; + exited = true; + const aborted = signal?.aborted === true; + if (canceled) { + // The shell is gone; descendants must not outlive a forced stop. + clearTimeout(kill); + signalGroup("SIGKILL"); + } + await drained; + return aborted ? 130 : code; + } finally { + signal?.removeEventListener("abort", cancel); + clearTimeout(kill); + clearTimeout(release); + } +} diff --git a/tests/lifecycle-command-child.test.ts b/tests/lifecycle-command-child.test.ts new file mode 100644 index 000000000..d776f7670 --- /dev/null +++ b/tests/lifecycle-command-child.test.ts @@ -0,0 +1,178 @@ +import { afterEach, expect, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { runLifecycleCommandChild } from "../src/lib/lifecycle-command-child.ts"; +import { readLinesFromStream } from "../src/ui/lines.ts"; + +// Real `sh -c` children: cancellation must reach the whole owned process +// group, not only the shell, and a forced stop must not wait on output pipes. + +/** + * Shells (with their process groups and children) and escaped processes these + * tests started; killed even when an assertion fails first. + */ +const owned: { readonly id: number; readonly group: boolean }[] = []; +function kill(target: number): void { + try { + process.kill(target, "SIGKILL"); + } catch { + // Already gone. + } +} +afterEach(() => { + for (const { id, group } of owned.splice(0)) { + if (group) { + kill(-id); + // Without an owned group, the shell's children are only reachable by parent. + Bun.spawnSync(["pkill", "-KILL", "-P", `${id}`]); + } + kill(id); + } +}); + +function gone(group: number): boolean { + try { + process.kill(-group, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + +async function settledGroup(group: number): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + if (gone(group)) { + return true; + } + await Bun.sleep(25); + } + return false; +} + +/** Collect output lines; resolve `marker` with the first line starting with it. */ +function collector(marker: string) { + const lines: string[] = []; + let found: (line: string) => void = () => undefined; + const first = new Promise((resolve) => { + found = resolve; + }); + return { + lines, + first, + drain: async (stream: ReadableStream) => { + for await (const line of readLinesFromStream(stream)) { + lines.push(line); + if (line.startsWith(marker)) { + found(line); + } + } + }, + }; +} + +function field(line: string): number { + const value = Number(line.split(" ")[1]); + expect(Number.isSafeInteger(value) && value > 1).toBe(true); + return value; +} + +test( + "cancel stops the shell and its sleep child together and does not wait for output", + async () => { + const output = collector("group "); + const controller = new AbortController(); + const started = performance.now(); + const result = runLifecycleCommandChild({ + command: + 'echo "group $$ $(ps -o pgid= -p $$ | tr -d " ")"; sleep 30; echo finished', + cwd: tmpdir(), + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + signal: controller.signal, + drain: output.drain, + }); + const line = await output.first; + const group = field(line); + owned.push({ id: group, group: true }); + controller.abort(); + expect(await result).toBe(130); + expect(performance.now() - started).toBeLessThan(1500); + // The shell led its own process group, and no member outlived the cancel. + expect(line.split(" ")[2]).toBe(String(group)); + expect(await settledGroup(group)).toBe(true); + expect(output.lines).not.toContain("finished"); + }, + { timeout: 10_000 } +); + +test( + "descendants that ignore SIGTERM are killed after the grace period", + async () => { + const output = collector("group "); + const controller = new AbortController(); + const result = runLifecycleCommandChild({ + command: "trap '' TERM; echo \"group $$\"; sleep 30; echo finished", + cwd: tmpdir(), + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + signal: controller.signal, + graceMs: 200, + drain: output.drain, + }); + const group = field(await output.first); + owned.push({ id: group, group: true }); + const canceled = performance.now(); + controller.abort(); + expect(await result).toBe(130); + expect(performance.now() - canceled).toBeLessThan(1500); + expect(await settledGroup(group)).toBe(true); + expect(output.lines).not.toContain("finished"); + }, + { timeout: 10_000 } +); + +test.skipIf(!Bun.which("perl"))( + "output draining after cancel is bounded when an escaped process holds the pipes", + async () => { + const output = collector("escaped "); + const controller = new AbortController(); + const result = runLifecycleCommandChild({ + // The perl child starts its own session, so the group signal misses it + // while it keeps the inherited stdout pipe open. + command: + 'perl -MPOSIX -e \'POSIX::setsid(); $| = 1; print "escaped $$ ", getppid(), "\\n"; sleep 30\' & sleep 30', + cwd: tmpdir(), + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + signal: controller.signal, + graceMs: 200, + drain: output.drain, + }); + const line = await output.first; + owned.push( + { id: field(line), group: false }, + { id: Number(line.split(" ")[2]), group: true } + ); + const canceled = performance.now(); + controller.abort(); + expect(await result).toBe(130); + expect(performance.now() - canceled).toBeLessThan(1500); + }, + { timeout: 10_000 } +); + +test( + "without a signal the command keeps the caller's process group and its exit status", + async () => { + const output = collector("group "); + const caller = Bun.spawnSync(["ps", "-o", "pgid=", "-p", `${process.pid}`]) + .stdout.toString() + .trim(); + const code = await runLifecycleCommandChild({ + command: + 'echo "group $(ps -o pgid= -p $$ | tr -d " ")"; echo done >&2; exit 7', + cwd: tmpdir(), + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + drain: output.drain, + }); + expect(code).toBe(7); + expect(output.lines).toEqual([`group ${caller}`, "done"]); + }, + { timeout: 10_000 } +); diff --git a/tests/native-project-start-stop-hooks.test.ts b/tests/native-project-start-stop-hooks.test.ts index 19042ebab..65dbe3d51 100644 --- a/tests/native-project-start-stop-hooks.test.ts +++ b/tests/native-project-start-stop-hooks.test.ts @@ -9,6 +9,8 @@ import { } from "../src/backends/native-project-down-hooks.ts"; import { startNativeProject } from "../src/backends/native-project-start.ts"; import { HackCliError } from "../src/lib/cli-result.ts"; +import { runLifecycleCommandChild } from "../src/lib/lifecycle-command-child.ts"; +import { readLinesFromStream } from "../src/ui/lines.ts"; // Foreground Ctrl-C runs the project's down hooks around graph cleanup, as // `hack down` does on Compose. The fake graph owner below follows the serve @@ -16,7 +18,16 @@ import { HackCliError } from "../src/lib/cli-result.ts"; // observed before readiness publication takes the forced path without it. const roots: string[] = []; +/** Hook process groups started by real-process tests; killed even on failure. */ +const groups: number[] = []; afterEach(async () => { + for (const group of groups.splice(0)) { + try { + process.kill(-group, "SIGKILL"); + } catch { + // Already gone. + } + } await Promise.all( roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) ); @@ -183,6 +194,20 @@ async function fixture(opts: { return { startup, events, scope }; } +async function groupGone(group: number): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + try { + process.kill(-group, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") { + return true; + } + } + await Bun.sleep(25); + } + return false; +} + /** Deliver a real process signal event to the frontend's own listener only. */ function interrupt(): void { expect(process.listenerCount("SIGINT")).toBe(1); @@ -232,6 +257,51 @@ test("a second Ctrl-C cancels a running down.before, still stops and never runs expect((await loadNativeDownHookRecord(scope))?.state).toBe("before-failed"); }); +test( + "a second Ctrl-C during a real down.after hook cancels its process group and settles the journal", + async () => { + const lines: string[] = []; + let group = 0; + let result = -1; + const { startup, events, scope } = await fixture({ + whenServing: () => queueMicrotask(interrupt), + downAfter: async (signal) => { + events.push("down.after"); + result = await runLifecycleCommandChild({ + command: 'echo "group $$"; sleep 30; echo finished', + cwd: tmpdir(), + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + signal, + drain: async (stream) => { + for await (const line of readLinesFromStream(stream)) { + lines.push(line); + if (line.startsWith("group ")) { + group = Number(line.split(" ")[1]); + groups.push(group); + // The frontend still owns the stop listeners here. + interrupt(); + } + } + }, + }); + return result; + }, + }); + const started = performance.now(); + expect(await startNativeProject(startup)).toBe(130); + expect(performance.now() - started).toBeLessThan(5000); + expect(result).toBe(130); + expect(group).toBeGreaterThan(1); + expect(await groupGone(group)).toBe(true); + expect(lines).not.toContain("finished"); + expect(events.at(-1)).toBe("down.after"); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("complete"); + expect(process.listenerCount("SIGINT")).toBe(0); + expect(process.listenerCount("SIGTERM")).toBe(0); + }, + { timeout: 15_000 } +); + test("a failed down.before still stops the graph and reports a lifecycle failure", async () => { const { startup, events, scope } = await fixture({ whenServing: () => queueMicrotask(interrupt), From fd35088fff1d63774a61127c26c9f85c835399c4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:41:57 -0500 Subject: [PATCH 030/138] fix(native): refuse read-only covers for image volumes Engine copy-up seeds an empty named volume from the image's VOLUME directory before a read-only mount applies, so a read-only cover still lets the image write into that volume, which another service may share. Image VOLUME targets now need a writable owned named-volume cover; a read-only or malformed ReadOnly cover refuses with graph_image_volume_uncovered for services and one-off jobs alike. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 4 +- packages/runtime-core/README.md | 8 ++-- .../src/provider/graph/image_volumes.rs | 40 +++++++++++++------ .../src/provider/graph/one_off/config.rs | 8 ++++ .../runtime-core/src/provider/graph/tests.rs | 1 + 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index c56cb8a46..9d9531dbf 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -615,8 +615,8 @@ An image that declares `VOLUME` (for example a database image) runs natively onl when the service mounts a named volume at each declared path, as in `db-data:/var/lib/postgresql/data`. Hack never creates an anonymous volume: a declared path without exactly one whole named volume of the project refuses with -`graph_image_volume_uncovered`, and a bind mount, a `volume.subpath` mount or a -dependency-cache volume at that path does not count. On first use the engine copies +`graph_image_volume_uncovered`, and a read-only (`:ro`) mount, a bind mount, a +`volume.subpath` mount or a dependency-cache volume at that path does not count. On first use the engine copies the image directory's contents, owner and mode into the new, empty volume; later runs mount the retained data unchanged. Native containers drop all capabilities, so an image entrypoint that starts as root and hands the data directory to another user diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index a7e72a25a..c28a922c9 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1158,15 +1158,17 @@ workspace installation and engine subpath confinement require separate qualifica An image that declares `VOLUME` targets is admitted on a Compose-file graph only when each declared target is covered by exactly one explicit mount, and that mount is a -whole (no subpath) named volume owned by the same graph receipt. A missing, duplicate, -bind, subpath, tmpfs or dependency-cache cover refuses with +whole (no subpath), writable named volume owned by the same graph receipt. A missing, +duplicate, read-only, bind, subpath, tmpfs or dependency-cache cover refuses with `graph_image_volume_uncovered` before any container is created; a foreign OS, architecture or image identity still refuses with `graph_image`. Start, restart, restore and one-off share the same predicate, and it reads only reviewed configuration, so plan and receipt identities do not change. The engine therefore never allocates an anonymous volume for a graph. Because these covers have no subpath, Docker copy-up gives a freshly created, empty owned volume the image directory's -contents, owner and mode; a retained, non-empty volume is mounted unchanged. No +contents, owner and mode; a retained, non-empty volume is mounted unchanged. Copy-up +runs before a read-only mount applies, so a read-only cover would still receive image +content, which is why it refuses. No capability is added: an image entrypoint that starts as root and changes ownership (`chown` followed by a user switch) still fails under the dropped capability set, so declare an explicit numeric `user:` matching the image's data user instead. A graph diff --git a/packages/runtime-core/src/provider/graph/image_volumes.rs b/packages/runtime-core/src/provider/graph/image_volumes.rs index abed19758..7e334ad45 100644 --- a/packages/runtime-core/src/provider/graph/image_volumes.rs +++ b/packages/runtime-core/src/provider/graph/image_volumes.rs @@ -3,7 +3,9 @@ //! An image `VOLUME` makes the engine allocate an anonymous volume unless an explicit //! mount already occupies that target. Graphs never create anonymous storage, so each //! declared target must be covered by exactly one explicit mount, and that mount must be -//! a whole (no subpath) graph-owned named volume of this receipt. Dependency-cache +//! a whole (no subpath), writable, graph-owned named volume of this receipt. A read-only +//! cover would still be seeded from image contents by engine copy-up before the read-only +//! mount applies, so it refuses rather than give the image an undeclared write. Dependency-cache //! volumes have consumers outside the graph and never cover an image volume: copy-up //! into a shared cache would let one image's files reach other consumers. //! @@ -37,8 +39,9 @@ fn uncovered() -> CandidateError { /// Admit `image_volumes` (the image `Config.Volumes` value) for one compiled container /// configuration. Null or an empty object passes. Otherwise every declared target needs /// an empty options object and exactly one mount at the same normalized target; that -/// mount must be a writable-or-read-only `volume` mount without a subpath whose source -/// is a non-cache volume resource of this graph. +/// mount must be a writable `volume` mount without a subpath whose source is a non-cache +/// volume resource of this graph. A read-only cover refuses: copy-up would still write +/// image contents into an empty volume before the read-only mount takes effect. pub(super) fn admit( image_volumes: &Value, config: &Value, @@ -74,7 +77,12 @@ pub(super) fn admit( && resource.cache.is_none() && Some(resource.name.as_str()) == source }); - if mount["Type"] != "volume" || !mount["VolumeOptions"]["Subpath"].is_null() || !owned { + let writable = matches!(mount["ReadOnly"], Value::Null | Value::Bool(false)); + if mount["Type"] != "volume" + || !mount["VolumeOptions"]["Subpath"].is_null() + || !writable + || !owned + { return Err(uncovered()); } } @@ -163,14 +171,22 @@ mod tests { {"Type":"bind","Source":"/source","Target":"/app","ReadOnly":true} ]); admit(&declared, &config(mounts), &resources()).unwrap(); - let mut read_only = mount(&owned(0), "/var/lib/data"); - read_only["ReadOnly"] = json!(true); - admit( - &json!({"/var/lib/data":{}}), - &config(json!([read_only])), - &resources(), - ) - .unwrap(); + } + + #[test] + fn read_only_or_malformed_read_only_covers_refuse() { + // Copy-up seeds an empty volume from the image before a read-only mount + // applies, so a read-only owned cover is not admitted. + let declared = json!({"/var/lib/data":{}}); + let mut writable = mount(&owned(0), "/var/lib/data"); + admit(&declared, &config(json!([writable.clone()])), &resources()).unwrap(); + writable.as_object_mut().unwrap().remove("ReadOnly"); + admit(&declared, &config(json!([writable])), &resources()).unwrap(); + for read_only in [json!(true), json!("false"), json!(0)] { + let mut cover = mount(&owned(0), "/var/lib/data"); + cover["ReadOnly"] = read_only; + refused(admit(&declared, &config(json!([cover])), &resources())); + } } #[test] diff --git a/packages/runtime-core/src/provider/graph/one_off/config.rs b/packages/runtime-core/src/provider/graph/one_off/config.rs index 3ccd81bce..3e07becf0 100644 --- a/packages/runtime-core/src/provider/graph/one_off/config.rs +++ b/packages/runtime-core/src/provider/graph/one_off/config.rs @@ -155,5 +155,13 @@ mod tests { .code, UNCOVERED ); + // A read-only parent cover refuses for the job as it does for the service. + let mut read_only = template.clone(); + read_only["HostConfig"]["Mounts"][0]["ReadOnly"] = json!(true); + let config = prepare(&read_only, &receipt, &resource, &[], None).unwrap(); + assert_eq!( + admit(&declared, &config, &resources).unwrap_err().code, + UNCOVERED + ); } } diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index 1b5963a8f..e83a653bf 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -351,6 +351,7 @@ fn lowered_named_volume_covers_image_volume_and_subpath_or_missing_cover_refuses for (mounts, admitted) in [ (json!(["state:/var/lib/data/"]), true), (json!(["state:/var/lib/other"]), false), + (json!(["state:/var/lib/data:ro"]), false), ( json!([{"type":"volume","source":"state","target":"/var/lib/data","volume":{"subpath":"db"}}]), false, From 78a22c2b2c86c034285965c59f5f9231e7d9bf82 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:43:49 -0500 Subject: [PATCH 031/138] fix(native): bound argument expansion and require plain ARG names Global ARG defaults expand eagerly, so repeating `ARG A=$A$A` doubled the value each time and a few hundred bytes of Dockerfile could exhaust memory. Expansion now checks a 4 KiB cap before each append, and all held global values share a 256 KiB budget. A default past either cap is unresolved and refuses only when a reference uses it, so an oversized platform override still refuses. ARG names that are not plain identifiers are refused. The frontend would unquote, unescape or expand such a name, so a literal compare could miss an override of BUILDPLATFORM or TARGETPLATFORM. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 75 ++++++++++++++----- .../src/provider/build_scan/tests.rs | 69 +++++++++++++++++ 2 files changed, 127 insertions(+), 17 deletions(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index 059285bff..6e1059041 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -37,6 +37,10 @@ pub const MAX_DOCKERFILE_BYTES: usize = 1024 * 1024; pub const MAX_IMAGE_REFERENCES: usize = 32; /// Most build stages one Dockerfile may declare. pub const MAX_STAGES: usize = 256; +/// Largest expanded global argument value, `FROM` name or `--platform` value. +const MAX_ARGUMENT_BYTES: usize = 4096; +/// Most bytes of expanded global argument values held at once. +const MAX_GLOBAL_ARGUMENT_BYTES: usize = 256 * 1024; /// Platform arguments whose values the scanner knows for a `linux/arm64` build. const KNOWN_PLATFORM_ARGS: [(&str, &str); 4] = [ @@ -800,6 +804,14 @@ fn dispatch( if name.is_empty() { return Err(refusal(Some(line), "ARG requires a name.")); } + // The frontend would unquote, unescape or expand any other name, so a + // literal compare could miss an override of a platform argument. + if !is_identifier(&name) { + return Err(refusal( + Some(line), + "ARG names must be plain [A-Za-z_][A-Za-z0-9_]* identifiers.", + )); + } if parsed.stages.is_empty() { parsed.meta_args.push((name, value)); } @@ -881,12 +893,27 @@ fn argument_words(text: &str, escape: char) -> Vec { words } +fn is_identifier(name: &str) -> bool { + name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + /// A global argument value: known text, or present with text the scanner cannot know. type Env = BTreeMap>; -/// Expand `$NAME` and `${NAME}`. Any other `$` form, quote, escape, unknown or -/// uncertain variable yields `None`. -fn expand(word: &str, env: &Env) -> Option { +/// Append `piece` to `out` unless that would pass `cap` bytes. Checked before the +/// append, so an oversized value never allocates past the cap. +fn push_bounded(out: &mut String, piece: &str, cap: usize) -> Option<()> { + if out.len().checked_add(piece.len())? > cap { + return None; + } + out.push_str(piece); + Some(()) +} + +/// Expand `$NAME` and `${NAME}` into at most `cap` bytes. Any other `$` form, quote, +/// escape, unknown or uncertain variable, or a result over `cap` yields `None`. +fn expand(word: &str, env: &Env, cap: usize) -> Option { let mut out = String::new(); let mut chars = word.chars().peekable(); while let Some(c) = chars.next() { @@ -911,37 +938,39 @@ fn expand(word: &str, env: &Env) -> Option { if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { return None; } - out.push_str(env.get(&name)?.as_deref()?); + push_bounded(&mut out, env.get(&name)?.as_deref()?, cap)?; } '\'' | '"' | '\\' | '`' => return None, - _ => out.push(c), + _ => push_bounded(&mut out, c.encode_utf8(&mut [0; 4]), cap)?, } } Some(out) } -/// The value of a global `ARG` default: quotes removed, `$NAME`/`${NAME}` expanded. -/// Anything else is recorded as uncertain and refuses only when a reference uses it. -fn argument_value(raw: &str, env: &Env) -> Option { +/// The value of a global `ARG` default, in at most `cap` bytes: quotes removed, +/// `$NAME`/`${NAME}` expanded. Anything else, including a value over `cap`, is +/// recorded as uncertain and refuses only when a reference uses it. +fn argument_value(raw: &str, env: &Env, cap: usize) -> Option { let mut out = String::new(); let mut rest = raw; while let Some(c) = rest.chars().next() { + let remaining = cap - out.len(); match c { '\'' => { let body = &rest[1..]; let end = body.find('\'')?; - out.push_str(&body[..end]); + push_bounded(&mut out, &body[..end], cap)?; rest = &body[end + 1..]; } '"' => { let body = &rest[1..]; let end = body.find('"')?; - out.push_str(&expand(&body[..end], env)?); + out.push_str(&expand(&body[..end], env, remaining)?); rest = &body[end + 1..]; } _ => { let end = rest.find(['\'', '"']).unwrap_or(rest.len()); - out.push_str(&expand(&rest[..end], env)?); + out.push_str(&expand(&rest[..end], env, remaining)?); rest = &rest[end..]; } } @@ -949,6 +978,11 @@ fn argument_value(raw: &str, env: &Env) -> Option { Some(out) } +/// The global arguments `FROM` names and `--platform` values expand against. +/// +/// Defaults expand eagerly, so `ARG A=$A$A` repeated would double without bound. +/// Each expanded default is capped at [`MAX_ARGUMENT_BYTES`] and all held values at +/// [`MAX_GLOBAL_ARGUMENT_BYTES`]; a default past either cap is uncertain. fn global_env(parsed: &Parsed, args: &BTreeMap) -> Env { let mut env: Env = KNOWN_PLATFORM_ARGS .iter() @@ -957,12 +991,19 @@ fn global_env(parsed: &Parsed, args: &BTreeMap) -> Env { for name in UNCERTAIN_FRONTEND_ARGS { env.insert(name.to_string(), None); } + let mut held: usize = KNOWN_PLATFORM_ARGS.iter().map(|(_, v)| v.len()).sum(); for (name, default) in &parsed.meta_args { - if let Some(value) = args.get(name) { - env.insert(name.clone(), Some(value.clone())); + let value = if let Some(value) = args.get(name) { + Some(value.clone()) } else if let Some(default) = default { - let value = argument_value(default, &env); - env.insert(name.clone(), value); + let cap = MAX_ARGUMENT_BYTES.min(MAX_GLOBAL_ARGUMENT_BYTES.saturating_sub(held)); + argument_value(default, &env, cap) + } else { + continue; + }; + held += value.as_ref().map_or(0, String::len); + if let Some(Some(old)) = env.insert(name.clone(), value) { + held -= old.len(); } } // The frontend rewrites this one from the resolved build epoch. @@ -994,7 +1035,7 @@ fn platform_supported(raw: &str, env: &Env, parsed: &Parsed) -> bool { .any(|(declared, default)| declared == name && default.is_some()) }); frontend_value - || expand(raw, env) + || expand(raw, env, MAX_ARGUMENT_BYTES) .is_some_and(|value| matches!(value.as_str(), "linux/arm64" | "linux/arm64/v8")) } @@ -1067,7 +1108,7 @@ fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result> = Vec::with_capacity(parsed.stages.len()); for (index, stage) in parsed.stages.iter().enumerate() { - let base = expand(&stage.base, &env) + let base = expand(&stage.base, &env, MAX_ARGUMENT_BYTES) .filter(|name| !name.is_empty()) .map(|name| { if name == "scratch" { diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 334e6b87d..2a895d55e 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -321,6 +321,75 @@ fn overridden_platform_arguments_must_resolve_to_arm64() { ); } +#[test] +fn arg_names_must_be_plain_identifiers() { + // The frontend would unquote, unescape or expand these names, so a literal + // compare could miss an override of BUILDPLATFORM or TARGETPLATFORM. + for dockerfile in [ + "ARG \"BUILDPLATFORM\"=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG 'TARGETPLATFORM'=linux/amd64\nFROM --platform=$TARGETPLATFORM alpine:3.22\n", + "ARG BUILD\\PLATFORM=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG X=BUILDPLATFORM\nARG $X=linux/amd64\nFROM --platform=$BUILDPLATFORM alpine:3.22\n", + "ARG ${X}\nFROM alpine:3.22\n", + "ARG 1V=1\nFROM alpine:3.22\n", + "ARG my-arg\nFROM alpine:3.22\n", + // Stage arguments too. + "FROM alpine:3.22\nARG \"V\"=1\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + assert_eq!( + images("ARG _V2=3.22 v_3\nFROM alpine:$_V2\nARG STAGE_ARG=1\n"), + ["alpine:3.22"] + ); +} + +#[test] +fn expanded_argument_values_are_bounded() { + // 13 doublings make 8 KiB, past the 4 KiB value cap: unresolved, so a reference + // using it refuses as a variable the scanner cannot resolve. + let doubled = |times: usize, from: &str| { + let mut dockerfile = "ARG A=x\n".to_string(); + dockerfile.push_str(&"ARG A=$A$A\n".repeat(times)); + dockerfile.push_str(from); + dockerfile + }; + assert_eq!( + code(&doubled(13, "FROM alpine:$A\n")), + "native_build_dockerfile" + ); + // Under the cap the value still resolves. + let scan = scan_with(&doubled(6, "FROM example/tool:$A\n"), None, &[]).unwrap(); + assert_eq!(names(&scan), [format!("example/tool:{}", "x".repeat(64))]); + // An overridden platform argument past the cap is unresolved and still refuses. + assert_eq!( + code(&doubled( + 13, + "ARG BUILDPLATFORM=$A\nFROM --platform=$BUILDPLATFORM alpine:3.22\n" + )), + "native_build_dockerfile" + ); +} + +#[test] +fn doubling_argument_defaults_stay_bounded() { + // Without a cap, 40 doublings would need a 1 TiB value. + let mut dockerfile = "ARG A=x\n".to_string(); + dockerfile.push_str(&"ARG A=$A$A\n".repeat(40)); + dockerfile.push_str("FROM alpine:3.22\n"); + assert_eq!(images(&dockerfile), ["alpine:3.22"]); + // Distinct copies of a capped value share one 256 KiB budget: the first copies + // resolve (and fail as over-long references), later ones are unresolved. + let mut dockerfile = format!("ARG A={}\n", "x".repeat(4000)); + for index in 0..100 { + dockerfile.push_str(&format!("ARG B{index}=$A\n")); + } + let with = |from: &str| format!("{dockerfile}{from}"); + assert_eq!(code(&with("FROM alpine:$B0\n")), "native_build_base_image"); + assert_eq!(code(&with("FROM alpine:$B99\n")), "native_build_dockerfile"); + assert_eq!(images(&with("FROM alpine:3.22\n")), ["alpine:3.22"]); +} + #[test] fn frontend_selection_directives_refuse() { for dockerfile in [ From 25fc0e0157b4157933539d6c8dbcedc431e828e6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:45:43 -0500 Subject: [PATCH 032/138] fix(env): keep .hack/.env when Compose env inputs can't be checked The interpolation retention check searched only env_file paths it could read literally. A variable env_file path such as "${VAR:-./api.env}" resolved to a missing file and was skipped, so a service-only key that file interpolates didn't count and `.hack/.env` could still be removed. The check now applies the existing uncheckableComposeEnvInputs guard (variable or home-relative env_file paths, include, extends, merge tags, repeated entries, env_file formats) and treats an unreadable listed env_file the same way: every service-only key counts, so the file stays. Co-Authored-By: Claude Opus 5.5 --- src/lib/project-env-config.ts | 55 ++++++++++++++------- tests/env-migration-tracked-consent.test.ts | 30 +++++++++++ 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index c8bb08f67..227bc2aa3 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -5973,13 +5973,14 @@ function assertNoComposeToolKeys(opts: { /** * Text of each non-legacy `env_file`, because Compose expands variables inside - * those files too. An absent file adds nothing; an unreadable one was refused - * by {@link composeEnvFileKeyConflicts}. + * those files too. An absent file adds nothing. An unreadable one returns null, + * so a caller can't mistake it for a file that names nothing; the refusal path + * already refused it in {@link composeEnvFileKeyConflicts}. */ async function composeOtherEnvFileText(opts: { readonly projectDir: string; readonly services: Record; -}): Promise { +}): Promise { const paths = new Set( composeServiceEntries(opts.services).flatMap(([, rawService]) => readComposeEnvFilePaths({ rawService }) @@ -5996,7 +5997,10 @@ async function composeOtherEnvFileText(opts: { const texts: string[] = []; for (const path of paths) { const read = await readOptionalTextFile(path); - if (read.kind !== "absent" && read.kind !== "unreadable") { + if (read.kind === "unreadable") { + return null; + } + if (read.kind === "text") { texts.push(read.text); } } @@ -6279,10 +6283,12 @@ async function assertComposeEnvironmentKeepsPrecedence(opts: { projectDir: opts.projectDir, }); const referenced = composeReferencedOverlayKeys({ - searchText: `${JSON.stringify(parsed)}\n${await composeOtherEnvFileText({ - projectDir: opts.projectDir, - services, - })}`, + searchText: `${JSON.stringify(parsed)}\n${ + (await composeOtherEnvFileText({ + projectDir: opts.projectDir, + services, + })) ?? "" + }`, legacyValues: opts.legacyValues, defaultOverlay, }); @@ -8280,8 +8286,10 @@ async function readLegacyDotenvCleanupEntries(opts: { * `env_file` it names (Compose expands variables there too), or when it is one * of Compose's own `COMPOSE_*` settings. Matching the name anywhere keeps the * file more often than it must, which is the safe direction. When the Compose - * input can't be fully read here (unreadable, not one YAML document, or using - * `include` or `extends`), every service-only key counts. Key names only. + * input can't be fully read here (an unreadable file, not one YAML document, or + * an input {@link uncheckableComposeEnvInputs} names, such as `include`, + * `extends` or an `env_file` path with a variable), every service-only key + * counts. Key names only. */ async function composeInterpolatedServiceOnlyKeys(opts: { readonly projectDir: string; @@ -8313,19 +8321,28 @@ async function composeInterpolatedServiceOnlyKeys(opts: { } const parsed = composeRead.kind === "text" ? await readComposeFile({ composeFile }) : null; - const services = parsed ? (getRecord(parsed, "services") ?? {}) : {}; + if (composeRead.kind !== "text" || parsed === null) { + return composeRead.kind === "text" && composeRead.text.trim().length === 0 + ? new Set() + : new Set(serviceOnly); + } + const services = getRecord(parsed, "services") ?? {}; + const otherEnvFileText = await composeOtherEnvFileText({ + projectDir: opts.projectDir, + services, + }); if ( - composeRead.kind !== "text" || - (parsed === null && composeRead.text.trim().length > 0) || - (parsed !== null && "include" in parsed) || - composeServiceEntries(services).some(([, service]) => "extends" in service) + otherEnvFileText === null || + uncheckableComposeEnvInputs({ + projectDir: opts.projectDir, + composeText: composeRead.text, + parsed, + services, + }).length > 0 ) { return new Set(serviceOnly); } - const searchText = `${composeRead.text}\n${await composeOtherEnvFileText({ - projectDir: opts.projectDir, - services, - })}`; + const searchText = `${composeRead.text}\n${otherEnvFileText}`; return new Set( serviceOnly.filter( // Keys match PROJECT_ENV_KEY_PATTERN, so they are safe in a pattern. diff --git a/tests/env-migration-tracked-consent.test.ts b/tests/env-migration-tracked-consent.test.ts index d5cdbe925..56e07d768 100644 --- a/tests/env-migration-tracked-consent.test.ts +++ b/tests/env-migration-tracked-consent.test.ts @@ -360,6 +360,36 @@ for (const route of ["implicit", "doctor"] as const) { ); } +test( + "implicit migration keeps a .env when a variable env_file path hides what Compose interpolates", + async () => { + // The Compose file never names the key; the env_file it selects through a + // variable does, and this check can't tell which file that is. + const project = await createProject({ + compose: + 'services:\n api:\n image: alpine:3.20\n env_file: "${API_ENV_FILE:-./api.env}"\n', + contract: SERVICE_SCOPED_CONTRACT, + }); + await writeFile( + join(project.projectDir, "api.env"), + `IMAGE_TAG=\${${KEY}}\n` + ); + + const run = await runImplicit(project); + + expect(run.exitCode, run.output).toBe(0); + expect(run.output).not.toContain(VALUE); + expect(run.output).toContain( + `Kept .hack/.env: Compose interpolates ${KEY}, which the env layers now set only for a service` + ); + expect(run.output).not.toContain("Removed .hack/.env"); + expect(await readFile(join(project.projectDir, ".env"), "utf8")).toBe( + `${KEY}=${VALUE}\n` + ); + }, + CLI_TEST_TIMEOUT_MS +); + test( "a service-scoped key Compose doesn't interpolate still drops the plaintext copy", async () => { From 29a8f53c0d216951c5c950dd287e428ab15c2b7f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:49:14 -0500 Subject: [PATCH 033/138] fix(native): keep the env selection without managed env files; validate adaptations in doctor Native input preparation now reports the requested or project default env even when the project has no hack.env.* file. Start records it and restart preflight reads it back, so adaptation..json and dependenciesByEnv are selected for that env instead of silently falling back to the base files. Fresh exec and down lifecycle hooks of such a run deliver no values rather than refusing, since there is no managed env file to select. The doctor native settings check now applies every candidate adaptation file (base and per-env) to the Compose file with the validator native up uses, and counts probes from the adapted result. An unsupported version, a port 0 probe or a probe for an unknown service is reported as the same E_CONFIG_INVALID line instead of a passing check. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 9 +- src/backends/native-exec-environment.ts | 3 + src/backends/native-project-down.ts | 4 +- src/backends/native-project-input.ts | 16 +++- src/lib/doctor-native-settings.ts | 107 ++++++++++++++++++------ tests/native-exec-environment.test.ts | 14 ++++ tests/native-project-down.test.ts | 13 +++ tests/native-project-input.test.ts | 20 +++++ tests/native-project-restart.test.ts | 64 ++++++++++++++ tests/native-project-settings.test.ts | 67 +++++++++++++++ tests/native-project-start.test.ts | 72 +++++++++++++++- 11 files changed, 355 insertions(+), 34 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index f42876b90..fccd25434 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1440,9 +1440,9 @@ their exact behavior. **Per-env files.** `adaptation..json` and `dependenciesByEnv.` replace the base file for runs whose effective env is ``: the `--env` you pass, the env a -retained run recorded, or the project's default env. Otherwise the base file applies. -A fresh start, a retained start and a restart of one run therefore always select the -same files. +retained run recorded, or the project's default env. This holds even when the project +has no `hack.env.*` files. Otherwise the base file applies. A fresh start, a retained +start and a restart of one run therefore always select the same files. **Identity and recovery.** Settings resolution only reads files. It writes no state, so Ctrl-C needs no cleanup, and `down`, `ps`, `logs`, `exec` and `run` keep using the @@ -1467,7 +1467,8 @@ the machine settings path with your home shown as `~`. With `--json`, the check' | `unread_project_settings_files` | `.hack/native/` holds files other than `adaptation*.json`; Hack does not read them. | An invalid settings file makes the check an error with the same closed line native -`up` prints. The check runs no runtime request, credential export or network call. +`up` prints. Each adaptation file (the base and every `adaptation..json`) is +checked against the Compose file with the validator native `up` uses. The check runs no runtime request, credential export or network call. ### Optional prepared base for a fresh pool diff --git a/src/backends/native-exec-environment.ts b/src/backends/native-exec-environment.ts index ac0ed64f7..2ea741222 100644 --- a/src/backends/native-exec-environment.ts +++ b/src/backends/native-exec-environment.ts @@ -55,8 +55,11 @@ export async function prepareNativeExecEnvironment(opts: { throw new SelectionChanged(); } const selected = opts.run.effectiveEnvName; + // A run started without any managed env file records its env only to select the + // per-env native files; with still no file there are no values to deliver. if ( selected !== null && + input.environmentFiles.length > 0 && !input.environmentFiles.some((path) => [ `hack.env.${selected}.yaml`, diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index 971514e04..cc5f645e5 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -221,9 +221,7 @@ export async function nativeDownEnvironment(opts: { serviceNames: opts.serviceNames, }); if (!resolved) { - if (opts.run.effectiveEnvName !== null) { - throw refused(); - } + // No managed env file at all: a recorded env only selected per-env native files. return {}; } const selected = opts.run.effectiveEnvName; diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index ab83234d9..1d12dcc3e 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -6,6 +6,7 @@ import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveProjectEnvConfig, + resolveProjectEnvSelection, selectProjectEnvValuesForExecutionTarget, } from "../lib/project-env-config.ts"; @@ -21,6 +22,10 @@ export type NativeProjectInput = { >; /** Private lifecycle environment; not container environment. */ readonly lifecycleHostEnvironment: Readonly>; + /** + * The requested env, else the project default env, else `null`. It is set even when + * no managed env file exists, because it also selects the per-env native files. + */ readonly effectiveEnvName: string | null; readonly environmentFiles: readonly string[]; readonly serviceNames: readonly string[]; @@ -172,6 +177,15 @@ export async function prepareNativeProjectInput(opts: { if (Buffer.byteLength(normalizedComposeJson) > LIMIT) { throw refused(); } + // Without managed env files there are no values, but the selection still names + // the env that picks `adaptation..json` and per-env dependencies. + const selection = + resolved?.selection ?? + (await resolveProjectEnvSelection({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + envName: opts.envName, + })); return { originalSha256: createHash("sha256").update(bytes).digest("hex"), normalizedComposeJson, @@ -183,7 +197,7 @@ export async function prepareNativeProjectInput(opts: { target: "host", }) : {}, - effectiveEnvName: resolved?.selection.effectiveEnv ?? null, + effectiveEnvName: selection.effectiveEnv, environmentFiles: resolved?.files ?? [], serviceNames, }; diff --git a/src/lib/doctor-native-settings.ts b/src/lib/doctor-native-settings.ts index 7d36d8cfd..de2743053 100644 --- a/src/lib/doctor-native-settings.ts +++ b/src/lib/doctor-native-settings.ts @@ -2,6 +2,8 @@ import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join, relative, sep } from "node:path"; import { YAML } from "bun"; +import { adaptNativeProject } from "../backends/native-project-adaptation.ts"; +import type { NativeProjectInput } from "../backends/native-project-input.ts"; import { readNativeSelection, selectNativeEnvFile, @@ -12,6 +14,7 @@ import { type NativeAdaptationSelection, type NativeSettingKey, type NativeSettingOrigin, + nativeAdaptationCandidates, nativeMachineSettingsDirectory, resolveNativeAdaptationSelection, resolveNativeHostSettings, @@ -91,33 +94,56 @@ function labelKeys(labels: unknown): string[] { return []; } -/** Routed services in the raw Compose file that have no native probe after adaptation. */ -async function countRoutesWithoutProbes(opts: { - readonly composeFile: string; - readonly adaptationFile: string | undefined; -}): Promise { - const text = await readTextFile(opts.composeFile); +async function readRawCompose( + composeFile: string +): Promise | undefined> { + const text = await readTextFile(composeFile); if (text === null) { - return 0; + return undefined; } let compose: unknown; try { compose = YAML.parse(text); } catch { - return 0; + return undefined; } - if (!(isRecord(compose) && isRecord(compose.services))) { + return isRecord(compose) && isRecord(compose.services) ? compose : undefined; +} + +/** + * Apply one adaptation file with the validator native `up` uses, to the raw Compose + * file. It refuses with the same closed `E_CONFIG_INVALID` line as `up`. Only the + * public Compose shape is adapted; managed env values are never resolved here. + */ +async function adaptRawCompose(opts: { + readonly compose: Readonly>; + readonly adaptationFile: string; +}): Promise> { + const input: NativeProjectInput = { + originalSha256: "", + normalizedComposeJson: JSON.stringify(opts.compose), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: null, + environmentFiles: [], + serviceNames: isRecord(opts.compose.services) + ? Object.keys(opts.compose.services) + : [], + }; + const adapted = adaptNativeProject({ + input, + selection: await readNativeSelection(opts.adaptationFile), + }); + return JSON.parse(adapted.normalizedComposeJson); +} + +/** Routed services that have no native HTTP probe in an adapted Compose file. */ +function countRoutesWithoutProbes(compose: Record): number { + if (!isRecord(compose.services)) { return 0; } - let probes: Readonly> = {}; - if (opts.adaptationFile !== undefined) { - const selection = await readNativeSelection(opts.adaptationFile); - if (isRecord(selection) && isRecord(selection.httpProbes)) { - probes = selection.httpProbes; - } - } let count = 0; - for (const [name, service] of Object.entries(compose.services)) { + for (const service of Object.values(compose.services)) { if (!isRecord(service)) { continue; } @@ -125,9 +151,8 @@ async function countRoutesWithoutProbes(opts: { continue; } const probed = - (isRecord(service.healthcheck) && - service.healthcheck["x-hack-http"] !== undefined) || - Object.hasOwn(probes, name); + isRecord(service.healthcheck) && + service.healthcheck["x-hack-http"] !== undefined; if (!probed) { count += 1; } @@ -135,6 +160,40 @@ async function countRoutesWithoutProbes(opts: { return count; } +/** + * Validate every adaptation file `up` could apply (the base and each per-env file), + * then count routed services without a probe after the default-env adaptation. + * An unreadable Compose file counts nothing; the Compose checks report it. + */ +async function adaptedRoutesWithoutProbes(opts: { + readonly composeFile: string; + readonly adaptation: NativeAdaptationSelection; + readonly defaultEnv: string | null; +}): Promise { + const compose = await readRawCompose(opts.composeFile); + if (compose === undefined) { + return 0; + } + const defaultFile = selectNativeEnvFile({ + selection: opts.adaptation, + envName: opts.defaultEnv, + }); + let adapted = compose; + for (const adaptationFile of nativeAdaptationCandidates({ + selection: opts.adaptation, + requestedEnvName: undefined, + })) { + if (adaptationFile === undefined) { + continue; + } + const result = await adaptRawCompose({ compose, adaptationFile }); + if (adaptationFile === defaultFile) { + adapted = result; + } + } + return countRoutesWithoutProbes(adapted); +} + async function unreadProjectSettingsFiles( projectDir: string ): Promise { @@ -230,12 +289,10 @@ export async function checkNativeSettings(opts: { const defaultEnv = await readProjectDefaultEnvConfig({ projectDir: project.projectDir, }); - const routesWithoutProbes = await countRoutesWithoutProbes({ + const routesWithoutProbes = await adaptedRoutesWithoutProbes({ composeFile: project.composeFile, - adaptationFile: selectNativeEnvFile({ - selection: adaptation, - envName: defaultEnv, - }), + adaptation, + defaultEnv, }); const warnings: NativeSettingsWarning[] = []; if (!host.sharedSource) { diff --git a/tests/native-exec-environment.test.ts b/tests/native-exec-environment.test.ts index 15c6b7409..e3379f7c3 100644 --- a/tests/native-exec-environment.test.ts +++ b/tests/native-exec-environment.test.ts @@ -99,6 +99,20 @@ test("fresh exec refuses missing selectors or deleted overlays before AWS export }); expect(calls).toBe(0); }); +test("fresh exec of a run started without managed env files delivers no values", async () => { + const opts = await fixture(); + for (const name of ["default", "qa", "other"]) { + await rm(join(opts.scope.projectDir, `hack.env.${name}.yaml`)); + } + // The recorded env selected only the per-env native files at startup. + expect(await prepareNativeExecEnvironment(opts)).toEqual({}); + expect( + await prepareNativeExecEnvironment({ + ...opts, + run: { ...opts.run, effectiveEnvName: null }, + }) + ).toEqual({}); +}); test("fresh exec names a changed selection apart from an unresolvable environment", async () => { const opts = await fixture(); // The service this exec selects is no longer in the configuration. diff --git a/tests/native-project-down.test.ts b/tests/native-project-down.test.ts index 79c1e8648..4daadb21e 100644 --- a/tests/native-project-down.test.ts +++ b/tests/native-project-down.test.ts @@ -592,6 +592,19 @@ test("down environment preserves explicit base and named overlay while legacy re "legacy" ); }); +test("down environment of a run started without managed env files is empty", async () => { + const opts = await fixture(false); + // The recorded env selected only the per-env native files at startup. + for (const effectiveEnvName of ["qa", null]) { + expect( + await nativeDownEnvironment({ + scope: opts.scope, + run: { ...run, effectiveEnvName }, + serviceNames: ["web"], + }) + ).toEqual({}); + } +}); test("before failure prevents cleanup; after failure retains the stopped mapping", async () => { for (const phase of ["before", "after"] as const) { const opts = await fixture(); diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index 8c04394d2..8ce4176de 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -141,6 +141,26 @@ test("legacy dotenv refuses and unmanaged public projects preserve original fiel }); }); +test("the effective env is the requested or default env even without managed env files", async () => { + const opts = await fixture({ services: { api: { image: "bun:1" } } }); + const effective = async (envName?: string | null) => { + const input = await prepareNativeProjectInput({ ...opts, envName }); + expect(input.environmentFiles).toEqual([]); + expect(input.managedEnvironment).toEqual({}); + return input.effectiveEnvName; + }; + expect(await effective()).toBeNull(); + expect(await effective("qa")).toBe("qa"); + await writeFile( + join(opts.projectDir, "hack.config.json"), + JSON.stringify({ name: "fixture", env: { defaultOverlay: "qa" } }) + ); + expect(await effective()).toBe("qa"); + expect(await effective("stage")).toBe("stage"); + // An explicit base selection still overrides the project default. + expect(await effective(null)).toBeNull(); +}); + test("linked worktree inherits primary local environment and observes checkout overrides", async () => { const oldCI = process.env.CI; const oldMode = process.env.HACK_EXECUTION_MODE; diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index fbae0ce83..c3c5b93e6 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -455,6 +455,70 @@ test("preflight selects the per-env adaptation and dependency files by the retai }); }); +test("preflight keeps the retained env's per-env files without managed env files", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-restart-env-")) + ); + try { + const projectDir = join(root, ".hack"); + await mkdir(projectDir); + const composeFile = join(projectDir, "docker-compose.yml"); + await writeFile( + composeFile, + JSON.stringify({ + services: { app: { image: `sha256:${"9".repeat(64)}` } }, + }) + ); + const selected: Record = {}; + const preflight = (effectiveEnvName: string | null) => + preflightNativeRestart({ + runtime: { binary: "/unused", home: "/candidate" }, + scope: { ...scope, projectRoot: root, projectDir }, + composeFile, + run: { ...run, effectiveEnvName }, + adaptationFile: "/fixture/.hack/native/adaptation.json", + adaptationFileByEnv: { qa: "/fixture/.hack/native/adaptation.qa.json" }, + dependencyFile: "/machine/dependencies.json", + dependencyFileByEnv: { qa: "/machine/dependencies.qa.json" }, + dependencies: { + activeImages: async () => activeImageFixture(), + adapt: async ({ input: prepared, path }) => { + selected.adaptation = path; + selected.env = prepared.effectiveEnvName ?? undefined; + return prepared; + }, + dependencies: async ({ path }) => { + selected.dependencies = path; + throw new ClosedCliError("stop after dependency selection"); + }, + invoke: async ({ args }) => + args[1] === "probe" ? { admitted: true } : { network: "internet" }, + }, + }); + // The project has no hack.env.* file, so the recorded env names only the files. + await expectClosedRefusal( + preflight("qa"), + "stop after dependency selection" + ); + expect(selected).toEqual({ + adaptation: "/fixture/.hack/native/adaptation.qa.json", + env: "qa", + dependencies: "/machine/dependencies.qa.json", + }); + await expectClosedRefusal( + preflight(null), + "stop after dependency selection" + ); + expect(selected).toEqual({ + adaptation: "/fixture/.hack/native/adaptation.json", + env: undefined, + dependencies: "/machine/dependencies.json", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("preflight refuses a stale host listener before restart cleanup", async () => { const { preflightNativeRestart } = await import( "../src/backends/native-project-restart-preflight.ts" diff --git a/tests/native-project-settings.test.ts b/tests/native-project-settings.test.ts index 21ca82c40..590f325c1 100644 --- a/tests/native-project-settings.test.ts +++ b/tests/native-project-settings.test.ts @@ -18,7 +18,9 @@ import { resolveNativeAdaptationSelection, resolveNativeHostSettings, } from "../src/backends/native-project-settings.ts"; +import { checkNativeSettings } from "../src/lib/doctor-native-settings.ts"; import { readComposeBackendFeatures } from "../src/lib/native-compose-backend-features.ts"; +import { defaultProjectOwnership } from "../src/lib/project.ts"; import { expectCodedRefusal } from "./helpers/closed-refusal.ts"; /** @@ -469,3 +471,68 @@ test("a machine preparedBase of off is a machine choice that selects nothing", a expect(host.preparedBase).toBeUndefined(); expect(host.origins.preparedBase).toBe("machine"); }); + +test("doctor validates every adaptation as native up does before counting probes", async () => { + const f = await fixture(); + const composeFile = join(f.projectDir, "docker-compose.yml"); + await writeFile( + composeFile, + JSON.stringify({ + services: { + web: { + image: `sha256:${SHA}`, + labels: { caddy: "web.example.test" }, + }, + }, + }) + ); + await f.machine({ version: 1, sharedSource: true }); + const doctor = () => + checkNativeSettings({ + project: { + projectRoot: f.projectRoot, + projectDirName: ".hack", + projectDir: f.projectDir, + composeFile, + envFile: join(f.projectDir, ".env"), + configFile: join(f.projectDir, "hack.config.json"), + }, + cfg: { + name: "settings-fixture", + internal: { dns: false, tls: false }, + ownership: defaultProjectOwnership(), + }, + env: f.env, + uid: UID, + }); + const refusal = + "E_CONFIG_INVALID: Native project adaptation is invalid or conflicts with declared configuration; values omitted."; + await f.committed("adaptation.json", { + version: 1, + httpProbes: { web: { port: 3000, path: "/health" } }, + }); + expect(await doctor()).toMatchObject({ status: "ok" }); + expect((await doctor()).nativeSettings?.routesWithoutProbes).toBeUndefined(); + for (const invalid of [ + { version: 2, httpProbes: { web: { port: 3000, path: "/health" } } }, + { version: 1, httpProbes: { web: { port: 0, path: "/" } } }, + { version: 1, httpProbes: { missing: { port: 3000, path: "/health" } } }, + ]) { + await f.committed("adaptation.json", invalid); + expect(await doctor()).toEqual({ + name: "native settings", + status: "error", + message: refusal, + }); + } + // A per-env file is validated too, though only the default env's file is counted. + await f.committed("adaptation.json", { + version: 1, + httpProbes: { web: { port: 3000, path: "/health" } }, + }); + await f.committed("adaptation.qa.json", { + version: 1, + httpProbes: { web: { port: 0, path: "/" } }, + }); + expect(await doctor()).toMatchObject({ status: "error", message: refusal }); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 219ba913a..1d6574500 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -19,7 +19,10 @@ import { captureNativeProjectFinalization, waitNativeProjectFinalization, } from "../src/backends/native-project-finalization.ts"; -import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import { + type NativeProjectInput, + prepareNativeProjectInput, +} from "../src/backends/native-project-input.ts"; import { preflightNativeRestart } from "../src/backends/native-project-restart-preflight.ts"; import { selectNativeRetainedImages } from "../src/backends/native-project-retained-images.ts"; import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; @@ -2802,6 +2805,73 @@ test("startup applies the per-env adaptation for the run's effective env, else t } }); +test("startup without managed env files selects and records the requested or default env", async () => { + for (const [envName, defaultOverlay] of [ + ["qa", undefined], + [undefined, "qa"], + ] as const) { + const { opts } = await fixture(false); + const base = join(opts.scope.projectRoot, "adaptation.json"); + const qa = join(opts.scope.projectRoot, "adaptation.qa.json"); + await writeFile( + opts.composeFile, + JSON.stringify({ + services: { + web: { + image: `sha256:${"e".repeat(64)}`, + labels: { + caddy: "app.hack.local", + "caddy.tls": "internal", + "caddy.reverse_proxy": "{{upstreams 3000}}", + }, + }, + }, + }) + ); + await writeFile( + join(opts.scope.projectDir, "hack.config.json"), + JSON.stringify({ + name: "fixture", + ...(defaultOverlay === undefined ? {} : { env: { defaultOverlay } }), + }) + ); + for (const [file, host] of [ + [base, "base.example.test"], + [qa, "qa.example.test"], + ] as const) { + await writeFile( + file, + JSON.stringify({ version: 1, additionalHostnames: { web: [host] } }) + ); + } + // The real input preparation, so the project has no hack.env.* file at all. + opts.dependencies.prepare = prepareNativeProjectInput; + const save = opts.dependencies.save!; + const recorded: (string | null | undefined)[] = []; + opts.dependencies.save = async (request) => { + recorded.push(request.run.effectiveEnvName); + return await save(request); + }; + let caddy: unknown; + const before = async (input: NativeProjectInput) => { + caddy = JSON.parse(input.normalizedComposeJson).services.web.labels.caddy; + return await opts.before(); + }; + expect( + await startNativeProject({ + ...opts, + ...(envName === undefined ? {} : { envName }), + adaptationFile: base, + adaptationFileByEnv: { qa }, + before, + }) + ).toBe(0); + expect(caddy).toBe("app.hack.local, qa.example.test"); + expect(recorded.length).toBeGreaterThan(0); + expect(new Set(recorded)).toEqual(new Set(["qa"])); + } +}); + test("startup and restart review identical branch runtime metadata; legacy runs get none", async () => { const { opts } = await fixture(false); const review = opts.dependencies.review!; From 932e577ee82e4d45a36895f39238fa2c4f2f9c4a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:49:14 -0500 Subject: [PATCH 034/138] feat(native): share many project roots in one development pool A native development pool now holds a bounded share set: the primary root that created it plus up to seven additional project roots or worktrees. Roots join or leave only through a journaled stopped-pool update (`runtime project-share add|remove`) that reuses the stopped network extension's ownership proof and record-before-owner commit, and takes effect at the next boot. A running pool's mounts never change; `up` for a root outside the set refuses with `project_share_extension_required`. Each graph selects only the share whose root equals its reviewed source, boot checks every share live and audits the full mount set, removal refuses while any retained graph names the share, and legacy single-root recovery paths refuse on multi-share pools. Single-root owner receipts keep their exact bytes. Adds the multi-root-share TLA+ model (55 states, four guard-removal controls) and documents the share set. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 24 +- docs/reference/v5-model-coverage.md | 2 + packages/runtime-core/README.md | 4 + packages/runtime-core/src/main.rs | 31 ++ .../runtime-core/src/provider/config_audit.rs | 2 +- .../graph/absent_publication_cleanup.rs | 2 + .../src/provider/graph/host_pin_recovery.rs | 4 + .../src/provider/graph/native/source.rs | 8 +- .../runtime-core/src/provider/graph/source.rs | 18 +- .../provider/graph/source_device_rebind.rs | 4 + .../runtime-core/src/provider/lifecycle.rs | 86 ++-- .../src/provider/lifecycle/host_filesystem.rs | 6 + packages/runtime-core/src/provider/mod.rs | 4 +- .../src/provider/network_update.rs | 62 ++- .../src/provider/project_share.rs | 152 ++++++- .../runtime-core/src/provider/share_update.rs | 414 ++++++++++++++++++ .../src/provider/share_update/tests.rs | 390 +++++++++++++++++ packages/runtime-core/src/provider/state.rs | 26 +- scripts/lib/tla-runtime-models.ts | 47 ++ tests/models/tla/README.md | 28 ++ tests/models/tla/multi-root-share/Shares.tla | 93 ++++ .../models/tla/multi-root-share/negative.cfg | 16 + .../tla/multi-root-share/owner-first.cfg | 16 + .../models/tla/multi-root-share/positive.cfg | 16 + .../multi-root-share/referenced-removal.cfg | 16 + .../tla/multi-root-share/unfenced-boot.cfg | 16 + 26 files changed, 1426 insertions(+), 61 deletions(-) create mode 100644 packages/runtime-core/src/provider/share_update.rs create mode 100644 packages/runtime-core/src/provider/share_update/tests.rs create mode 100644 tests/models/tla/multi-root-share/Shares.tla create mode 100644 tests/models/tla/multi-root-share/negative.cfg create mode 100644 tests/models/tla/multi-root-share/owner-first.cfg create mode 100644 tests/models/tla/multi-root-share/positive.cfg create mode 100644 tests/models/tla/multi-root-share/referenced-removal.cfg create mode 100644 tests/models/tla/multi-root-share/unfenced-boot.cfg diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..ccd3a676e 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -694,7 +694,29 @@ This shares the entire approved project, including ignored local files, with gue read/write access. Filtered source publication remains the default. Do not use the unfiltered mode for a checkout whose local files must remain hidden from its services. Only the exact canonical project root is accepted; home/credential directories, -aliased roots, and changes to a retained pool's mount intent are refused. +aliased roots, and changes to a running pool's mount intent are refused. + +One development pool can host several project roots and linked worktrees. The +first root is the pool's primary share. Add another root to a **stopped** pool with +`runtime project-share add --project-share /absolute/other-project --unfiltered-source --json`; +the share is mounted at the next boot. A running pool's mounts never change, so +`up` for a root outside the set refuses with `project_share_extension_required` +until the pool is stopped and the root is added. Remove an additional root (for +example a deleted worktree) from a stopped pool with +`runtime project-share remove --project-share /absolute/old-worktree --json`; removal +refuses with `project_share_in_use` while any retained graph still names that +share, and the primary share is never removed. A pool holds at most eight shares. +Boot refuses with `project_share_stale` while any share's root moved, changed or +was removed. Every update is journaled like the stopped network extension, and +startup refuses while the journal is unfinished. + +Projects in one pool are separated the way Compose projects in one Docker VM are: +each graph binds only its own root's share (bind paths stay under that share), +graph namespaces derive from the canonical root and branch, cleanup acts on one +run's own labelled resources, and resource admission is one budget per pool. The +VM boundary between projects that separate pools gave no longer applies inside one +pool. Legacy device-rebind and host-pin recovery stay single-root and refuse on a +pool with additional shares. Shared-source graphs with dependency caches still require a freshly published source revision. Cache initializer services read that immutable publication through diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..f52136738 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -44,6 +44,7 @@ must be attached to the eventual exact integration revision. | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | | Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | +| One pool, many project roots: share-set changes, boot mounts, per-project share selection, removal of a referenced share | multi-root-share | `provider::share_update` (journaled stopped-pool add/remove, record before owner), `project_share::{check_request, check_boot, validate_set}`, `OwnedGuest::project_share_for`; `share_update` and `project_share` unit tests | Real provider boot with an updated mount record, virtio-fs device limits and live multi-project graphs on a host VM. Cross-project cleanup is abstracted to one run's own resources. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | | Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. | @@ -73,6 +74,7 @@ liveness/reachability controls have their own documented assumptions. | [shared-https-lifetime](../../tests/models/tla/shared-https-lifetime/SharedHttps.tla) | 64 | 2 | | [previous-boot-shared-https](../../tests/models/tla/previous-boot-shared-https/ArchiveHttps.tla) | 92 | 4 | | [stopped-pool-startup](../../tests/models/tla/stopped-pool-startup/Resume.tla) | 71 | 3 | +| [multi-root-share](../../tests/models/tla/multi-root-share/Shares.tla) | 55 | 5 | | [dependency-rebind](../../tests/models/tla/dependency-rebind/Rebind.tla) | 21 | 10 | | [relay-lifecycle-barrier](../../tests/models/tla/relay-lifecycle-barrier/Barrier.tla) | 1,590 | 2 | | [relay-authorization](../../tests/models/tla/relay-authorization/Authorization.tla) | 7 | 2 | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 7178928ec..d9ffcbb1e 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -88,6 +88,10 @@ This does not create an immutable snapshot or publish a new source revision. Source consumption requires the provider pool to already contain the exact explicitly approved unfiltered project share. The consumer never approves that whole-tree writable share, changes pool mounts or enrolls a project implicitly. +A pool's share set holds its primary root plus up to seven additional roots; a +graph selects only the share whose root equals its reviewed source. The set +changes only through the journaled stopped-pool `runtime project-share add|remove` +update (`provider::share_update`, modeled by `tests/models/tla/multi-root-share`). Individual workload binds are read-only and `rprivate`. Selection pins the source root, every selected path and its ancestors by device, inode, type, UID and full mode; aliases, hardlinked selected files and permission changes refuse. Startup diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 0ab86ffde..457a8e66f 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -91,6 +91,8 @@ Usage: hack-local runtime up --profile research|development [--bridge-sockets <1..32> | --minimum-bridge-sockets <1..32>] [--dependency-sockets <1..32> | --minimum-dependency-sockets <1..32>] [--json] hack-local runtime up --profile development --project-share --unfiltered-source [--json] hack-local runtime check-project-share --project-share --unfiltered-source --json + hack-local runtime project-share add --project-share --unfiltered-source --json + hack-local runtime project-share remove --project-share --json hack-local graph serve|run ... --shared-source hack-local runtime prepare --archive hack-local runtime prepare-engine --archive @@ -404,6 +406,35 @@ fn run() -> Result<(), CandidateError> { &share, )?)?; } + [ + "runtime", + "project-share", + "add", + "--project-share", + project, + "--unfiltered-source", + "--json", + ] => { + let share = + hack_runtime_core::provider::ProjectShareIntent::approve(Path::new(project), true)?; + print_json(&hack_runtime_core::provider::add_project_share( + &discover_candidate(&requested)?, + &share, + )?)?; + } + [ + "runtime", + "project-share", + "remove", + "--project-share", + project, + "--json", + ] => { + print_json(&hack_runtime_core::provider::remove_project_share( + &discover_candidate(&requested)?, + Path::new(project), + )?)?; + } ["runtime", "up", arguments @ ..] if arguments.contains(&"--bridge-sockets") || arguments.contains(&"--minimum-bridge-sockets") diff --git a/packages/runtime-core/src/provider/config_audit.rs b/packages/runtime-core/src/provider/config_audit.rs index e6d6cbb08..03744409b 100644 --- a/packages/runtime-core/src/provider/config_audit.rs +++ b/packages/runtime-core/src/provider/config_audit.rs @@ -119,7 +119,7 @@ fn verify_mounts( "/opt/hack-engine", true ])]; - if let Some(share) = &owner.project_share { + for share in owner.project_shares() { share.validate_receipt()?; mounts.push(share.database_mount()); } diff --git a/packages/runtime-core/src/provider/graph/absent_publication_cleanup.rs b/packages/runtime-core/src/provider/graph/absent_publication_cleanup.rs index 93c5f7369..332b51828 100644 --- a/packages/runtime-core/src/provider/graph/absent_publication_cleanup.rs +++ b/packages/runtime-core/src/provider/graph/absent_publication_cleanup.rs @@ -316,6 +316,8 @@ fn current_owner( || owner.storage.as_ref() != Some(&inspection.storage) || owner.overlay.as_ref() != Some(&inspection.overlay) || owner.project_share != inspection.project_share + // Legacy migration is defined only for a single-root pool. + || !owner.additional_project_shares.is_empty() || pool.ino() != inspection.pool_inode || pool.dev() != inspection.new_device { diff --git a/packages/runtime-core/src/provider/graph/host_pin_recovery.rs b/packages/runtime-core/src/provider/graph/host_pin_recovery.rs index 0d7d26bde..a64e4366a 100644 --- a/packages/runtime-core/src/provider/graph/host_pin_recovery.rs +++ b/packages/runtime-core/src/provider/graph/host_pin_recovery.rs @@ -542,6 +542,10 @@ fn select_content( { return Err(refused()); } + // Legacy host-pin recovery is defined only for a single-root pool. + if !owner.additional_project_shares.is_empty() { + return Err(refused()); + } if let Some(share) = &owner.project_share { share.validate()?; } diff --git a/packages/runtime-core/src/provider/graph/native/source.rs b/packages/runtime-core/src/provider/graph/native/source.rs index 00d14aa0f..22d1b81b1 100644 --- a/packages/runtime-core/src/provider/graph/native/source.rs +++ b/packages/runtime-core/src/provider/graph/native/source.rs @@ -364,7 +364,11 @@ pub(super) fn prepare( selected: &Selection, ) -> Result { selected.verify()?; - let share = engine.guest().project_share().ok_or_else(refused)?; + // Select only this graph's own root; other shares in the pool are never bound. + let share = engine + .guest() + .project_share_for(&selected.root) + .ok_or_else(refused)?; share.validate().map_err(|_| refused())?; let binding = selected.bind(share)?; verify(engine, &binding, true)?; @@ -400,7 +404,7 @@ fn verify_using( if let Some(deadline) = deadline { crate::provider::managed_environment::remaining_until(deadline)?; } - if engine.guest().project_share() != Some(&binding.share) { + if !engine.guest().has_project_share(&binding.share) { return Err(refused()); } if active { diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 11aa9ed0c..7fd86f73c 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -225,12 +225,16 @@ pub(super) fn prepare_mode( if !shared { return prepare(candidate, engine, plan, revision, live); } - let share = engine.guest().project_share().ok_or_else(|| { - error( - "graph_shared_source", - "Runtime has no approved project share.", - ) - })?; + // Select only this plan's own root: a pool may share other projects' roots too. + let share = engine + .guest() + .project_share_for(&plan.source) + .ok_or_else(|| { + error( + "graph_shared_source", + "Runtime has no approved project share for this project root.", + ) + })?; let selected = shared_paths(engine, plan, share)?; let (manifest, archive_sha256) = if let Some(revision) = revision { let publication = @@ -519,7 +523,7 @@ fn prepare_shared_review( .shared .as_ref() .ok_or_else(|| error("graph_shared_source", "Retained project share is missing."))?; - if engine.guest().project_share() != Some(share) { + if !engine.guest().has_project_share(share) { return Err(error( "graph_shared_source", "Retained project share changed.", diff --git a/packages/runtime-core/src/provider/graph/source_device_rebind.rs b/packages/runtime-core/src/provider/graph/source_device_rebind.rs index 088c02618..b4c64c12a 100644 --- a/packages/runtime-core/src/provider/graph/source_device_rebind.rs +++ b/packages/runtime-core/src/provider/graph/source_device_rebind.rs @@ -283,6 +283,8 @@ pub(in crate::provider) fn https_devices( || witness.owner != owner.token || sha256(&owner_pin.bytes) != witness.current_owner_raw_sha256 || owner.project_share.as_ref() != Some(&witness.current_share) + // Legacy device rebind is defined only for a single-root pool. + || !owner.additional_project_shares.is_empty() || owner.guest_boot_id.as_deref() != Some(witness.current_guest_boot.as_str()) || lifecycle::host_filesystem::host_boot_micros()? != witness.host_boot_micros || witness.old_share.device == witness.current_share.device @@ -498,6 +500,7 @@ fn validate_current( || lifecycle::host_filesystem::host_boot_micros()? != witness.host_boot_micros || engine.guest().boot_id() != witness.current_guest_boot || engine.guest().project_share() != Some(&witness.current_share) + || !engine.guest().single_project_share() { return Err(refused()); } @@ -590,6 +593,7 @@ fn selected_witness( || receipt.phase != "stopped-data-retained" || sha256(&state_bytes) != proof.completed_stopped_sha256 || engine.guest().project_share() != Some(¤t_share) + || !engine.guest().single_project_share() || lifecycle::host_filesystem::host_boot_micros()? != proof.host_boot_micros || engine.guest().boot_id() != proof.current_guest_boot { diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 9c3a20650..7ebdae6e9 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -344,6 +344,7 @@ impl<'a> ObservedGuest<'a> { || current.guest_boot_id != self.owner.guest_boot_id || current.network != self.owner.network || current.project_share != self.owner.project_share + || current.additional_project_shares != self.owner.additional_project_shares || current.application_bridge != self.owner.application_bridge || current.dependency_sockets != self.owner.dependency_sockets || current.process != self.owner.process @@ -429,9 +430,24 @@ impl<'a> OwnedGuest<'a> { }) } + /// The pool's primary share. Device-rebind recovery is defined only for single-root + /// pools and refuses when additional shares exist. pub(super) fn project_share(&self) -> Option<&super::ProjectShareIntent> { self.owner.project_share.as_ref() } + /// Whether the pool's additional shares are empty (a single-root pool). + pub(super) fn single_project_share(&self) -> bool { + self.owner.additional_project_shares.is_empty() + } + /// The exact share this pool approved for one project root. A graph binds only its + /// own root's share; another project's share is never selected for it. + pub(super) fn project_share_for(&self, project: &Path) -> Option<&super::ProjectShareIntent> { + self.owner.project_share_for(project) + } + /// Exact membership of one retained share in the pool's current share set. + pub(super) fn has_project_share(&self, share: &super::ProjectShareIntent) -> bool { + self.owner.project_shares().contains(&share) + } pub(super) fn before_effect(&self) -> Result<(), CandidateError> { if let Some(guard) = &self.guard { guard.before_effect(self.owner.profile, || operating_observation(&self.owner))?; @@ -654,7 +670,7 @@ fn audit_boot(candidate: &Candidate, owner: &Owner) -> Result<(), CandidateError )?; owner.network.verify_resources(&config["resources"])?; let mut mounts = json!([{"source":artifact::engine_root(candidate),"target":"/opt/hack-engine","read_only":true,"staged":false}]); - if let Some(share) = &owner.project_share { + for share in owner.project_shares() { share.validate_receipt()?; mounts .as_array_mut() @@ -723,13 +739,14 @@ pub fn check_project_share( requested.validate()?; reject_aliased_state(&root(candidate))?; super::network_update::require_complete(candidate)?; + super::share_update::require_complete(candidate)?; let initialized = root(candidate) .join("owner.json") .try_exists() .map_err(io)?; if initialized { let owner = Owner::load(candidate)?; - super::project_share::check_request(owner.project_share.as_ref(), Some(requested))?; + super::project_share::check_request(&owner.project_shares(), Some(requested))?; } else { require_fresh_source_pool(candidate)?; } @@ -1075,6 +1092,7 @@ fn start_pool( let requested = bridge_request.initial(); let dependencies = dependency_request.initial(); super::network_update::require_complete(candidate)?; + super::share_update::require_complete(candidate)?; if let Some(share) = &project_share { share.validate()?; } @@ -1089,30 +1107,33 @@ fn start_pool( super::BridgeIntent::new(intent.slots)?; } reject_aliased_state(&root(candidate))?; - let (existing, existing_network, existing_dependencies, existing_share) = if root(candidate) - .join("owner.json") - .try_exists() - .map_err(io)? - { - let owner = Owner::load(candidate)?; - super::project_share::check_request(owner.project_share.as_ref(), project_share.as_ref())?; - bridge_request.check(owner.application_bridge)?; - super::network_intent::check_request(&owner.network, network.as_ref())?; - dependency_request.check(owner.dependency_sockets)?; - ( - owner.application_bridge, - owner.network.clone(), - owner.dependency_sockets, - owner.project_share.clone(), - ) - } else { - ( - requested, - network.unwrap_or_default(), - dependencies, - project_share.clone(), - ) - }; + let (existing, existing_network, existing_dependencies, existing_share, existing_additional) = + if root(candidate) + .join("owner.json") + .try_exists() + .map_err(io)? + { + let owner = Owner::load(candidate)?; + super::project_share::check_request(&owner.project_shares(), project_share.as_ref())?; + bridge_request.check(owner.application_bridge)?; + super::network_intent::check_request(&owner.network, network.as_ref())?; + dependency_request.check(owner.dependency_sockets)?; + ( + owner.application_bridge, + owner.network.clone(), + owner.dependency_sockets, + owner.project_share.clone(), + owner.additional_project_shares.clone(), + ) + } else { + ( + requested, + network.unwrap_or_default(), + dependencies, + project_share.clone(), + Vec::new(), + ) + }; super::dependency_socket::check_capacity(existing, existing_dependencies)?; if existing.is_some() && !cfg!(feature = "native-stream-relay") { return Err(CandidateError::new( @@ -1176,6 +1197,7 @@ fn start_pool( guard.verify(candidate)?; } super::network_update::require_complete(candidate)?; + super::share_update::require_complete(candidate)?; let fresh_owner = match fs::symlink_metadata(root(candidate).join("owner.json")) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, Ok(_) => false, @@ -1194,8 +1216,10 @@ fn start_pool( existing_dependencies, existing_share.clone(), )?; - super::project_share::check_request(owner.project_share.as_ref(), existing_share.as_ref())?; - if owner.project_share != existing_share { + super::project_share::check_request(&owner.project_shares(), project_share.as_ref())?; + if owner.project_share != existing_share + || owner.additional_project_shares != existing_additional + { return Err(CandidateError::new( "project_share", "Project share changed before the operation lock.", @@ -1266,6 +1290,8 @@ fn start_pool( } verify_disks(candidate, &owner)?; } + // The provider mounts every share at boot: each must still be its exact approved root. + super::project_share::check_boot(&owner.project_shares())?; prepare_rootfs(candidate, &mut owner)?; if !owner.created { // A reserve-qualified start never reaches `machine create`. @@ -1299,7 +1325,7 @@ fn start_pool( "--volume".into(), mount, ]; - if let Some(share) = &owner.project_share { + for share in owner.project_shares() { share.validate()?; arguments.extend(["--volume".into(), share.argument()]); } @@ -2252,7 +2278,7 @@ mod tests { let other = super::super::ProjectShareIntent::approve(&second, true).unwrap(); assert_eq!( check_project_share(&candidate, &other).unwrap_err().code, - "project_share" + "project_share_extension_required" ); assert_eq!( fs::read(root(&candidate).join("owner.json")).unwrap(), diff --git a/packages/runtime-core/src/provider/lifecycle/host_filesystem.rs b/packages/runtime-core/src/provider/lifecycle/host_filesystem.rs index 2b7ad0ddb..22f32d6c7 100644 --- a/packages/runtime-core/src/provider/lifecycle/host_filesystem.rs +++ b/packages/runtime-core/src/provider/lifecycle/host_filesystem.rs @@ -179,6 +179,12 @@ fn selection( "Only a common device-number change with unchanged disk inode, size and UUID is accepted", )); } + // Legacy device-number recovery is defined only for a single-root pool. + if !owner.additional_project_shares.is_empty() { + return Err(refused( + "Device-number recovery supports only a pool with a single project share", + )); + } let mut next = owner.clone(); if let Some(share) = &owner.project_share { let observed = diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index e9ff94e64..3f103c1a2 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -80,7 +80,9 @@ mod network_intent; mod network_update; pub use network_update::{enable_internet, extend_network}; mod project_share; -pub use project_share::ProjectShareIntent; +pub use project_share::{MAX_PROJECT_SHARES, ProjectShareIntent}; +mod share_update; +pub use share_update::{add_project_share, remove_project_share}; mod network_tools; pub mod prepared_base; mod prepared_inventory; diff --git a/packages/runtime-core/src/provider/network_update.rs b/packages/runtime-core/src/provider/network_update.rs index 226878420..be0eed51b 100644 --- a/packages/runtime-core/src/provider/network_update.rs +++ b/packages/runtime-core/src/provider/network_update.rs @@ -28,7 +28,7 @@ fn refused() -> CandidateError { fn root(candidate: &Candidate) -> PathBuf { candidate.state_root.join("run/smolvm") } -fn database(candidate: &Candidate) -> PathBuf { +pub(super) fn database(candidate: &Candidate) -> PathBuf { #[cfg(target_os = "macos")] let relative = "home/Library/Application Support/smolvm/server/smolvm.db"; #[cfg(not(target_os = "macos"))] @@ -57,6 +57,13 @@ pub(super) fn require_complete(candidate: &Candidate) -> Result<(), CandidateErr Ok(()) } fn file_identity(path: &Path) -> Result<(u64, u64), CandidateError> { + file_identity_with(path, refused) +} +/// Exact private file identity; `refused` names the calling update's closed code. +pub(super) fn file_identity_with( + path: &Path, + refused: fn() -> CandidateError, +) -> Result<(u64, u64), CandidateError> { let m = fs::symlink_metadata(path).map_err(|_| refused())?; if m.len() > 16 * 1024 * 1024 || !m.is_file() @@ -69,6 +76,17 @@ fn file_identity(path: &Path) -> Result<(u64, u64), CandidateError> { Ok((m.dev(), m.ino())) } fn stopped(candidate: &Candidate, owner: &Owner) -> Result { + stopped_with(candidate, owner, refused) +} +/// Prove a created pool is stopped and its provider released: no live retained process, +/// verified registry and disks, the provider VM lock held exclusively, and no disk handles. +/// The returned lock must be held through both commits of a stopped-pool update. +pub(super) fn stopped_with( + candidate: &Candidate, + owner: &Owner, + refused: fn() -> CandidateError, +) -> Result { + let file_identity = |path: &Path| file_identity_with(path, refused); if ![ "stopped", "stopped-before-engine", @@ -285,6 +303,13 @@ fn validate_sides(j: &Journal, owner: &Value, record: &Value) -> Result<(), Cand Ok(()) } fn verify_record_process(owner: &Owner, record: &Value) -> Result<(), CandidateError> { + verify_record_process_with(owner, record, refused) +} +pub(super) fn verify_record_process_with( + owner: &Owner, + record: &Value, + refused: fn() -> CandidateError, +) -> Result<(), CandidateError> { let process = owner.process.as_ref().ok_or_else(refused)?; if record["pid"] != json!(process.pid) || record["pid_start_time"] != json!(process.start_micros) @@ -294,7 +319,28 @@ fn verify_record_process(owner: &Owner, record: &Value) -> Result<(), CandidateE Ok(()) } fn apply_database(path: &Path, machine: &str, j: &Journal) -> Result<(), CandidateError> { - if file_identity(path)? != j.database_identity { + apply_record( + path, + machine, + j.database_identity, + &j.record_before, + &j.record_after, + refused, + ) +} +/// Move this pool's single provider record from exactly `before` to exactly `after` +/// inside one immediate transaction. Repeating after a commit is a no-op; any other +/// record, row count or database replacement refuses without writes. +pub(super) fn apply_record( + path: &Path, + machine: &str, + database_identity: (u64, u64), + record_before: &Value, + record_after: &Value, + refused: fn() -> CandidateError, +) -> Result<(), CandidateError> { + let file_identity = |path: &Path| file_identity_with(path, refused); + if file_identity(path)? != database_identity { return Err(refused()); } // Existing audited schema only. No CREATE, replacement, or unrelated row writes. @@ -326,14 +372,14 @@ fn apply_database(path: &Path, machine: &str, j: &Journal) -> Result<(), Candida return Err(refused()); } let current: Value = serde_json::from_slice(&bytes).map_err(|_| refused())?; - if current != j.record_before && current != j.record_after { + if current != *record_before && current != *record_after { return Err(refused()); } - if file_identity(path)? != j.database_identity { + if file_identity(path)? != database_identity { return Err(refused()); } - if current == j.record_before { - let bytes = serde_json::to_vec(&j.record_after).map_err(|_| refused())?; + if current == *record_before { + let bytes = serde_json::to_vec(record_after).map_err(|_| refused())?; if bytes.len() > 65536 { return Err(refused()); } @@ -348,7 +394,7 @@ fn apply_database(path: &Path, machine: &str, j: &Journal) -> Result<(), Candida } } transaction.commit().map_err(|_| refused())?; - if file_identity(path)? != j.database_identity { + if file_identity(path)? != database_identity { return Err(refused()); } Ok(()) @@ -372,6 +418,8 @@ fn update_network( hosts.sort(); reject_aliased_state(&root(candidate))?; let _lock = state::Lock::acquire(&root(candidate))?; + // Stopped-pool updates never interleave: one journal owns the provider record at a time. + super::share_update::require_complete(candidate)?; let owner = Owner::load(candidate)?; let _vm_lock = stopped(candidate, &owner)?; let path = root(candidate).join("network-update.json"); diff --git a/packages/runtime-core/src/provider/project_share.rs b/packages/runtime-core/src/provider/project_share.rs index 534ebeb3c..29fc4a80d 100644 --- a/packages/runtime-core/src/provider/project_share.rs +++ b/packages/runtime-core/src/provider/project_share.rs @@ -133,18 +133,85 @@ impl ProjectShareIntent { json!([self.project, self.guest_path, false]) } } +/// Upper bound for one pool's share set: the primary share plus additional roots. +/// Each share is one provider filesystem device for the life of the machine. +pub const MAX_PROJECT_SHARES: usize = 8; + +fn extension_required() -> CandidateError { + CandidateError::new( + "project_share_extension_required", + "This pool does not share this project root. Add it to the stopped pool with `runtime project-share add`; a running pool's mounts are never changed.", + ) +} + +/// Offline shape of a pool share set. Every share keeps its own exact receipt and +/// guest path; no two shares name the same root or guest mount point. Additional +/// shares exist only beside a primary share, which defines the pool as a +/// development source pool. +pub(super) fn validate_set( + primary: Option<&ProjectShareIntent>, + additional: &[ProjectShareIntent], +) -> Result<(), CandidateError> { + if primary.is_none() && !additional.is_empty() { + return Err(refused()); + } + let shares: Vec<_> = primary.into_iter().chain(additional).collect(); + if shares.len() > MAX_PROJECT_SHARES { + return Err(refused()); + } + for (index, share) in shares.iter().enumerate() { + share.validate_receipt()?; + if shares[..index] + .iter() + .any(|other| other.project == share.project || other.guest_path == share.guest_path) + { + return Err(refused()); + } + } + Ok(()) +} + +/// Admit a requested share only when it is already an exact member of the pool's +/// share set. A root outside the set is refused with a distinct code: the set changes +/// only through the explicit stopped-pool extension, never as a side effect of `up`. +/// Without a request every share is checked live, as before; with a request, the +/// requested share is checked live and the others offline, so a moved or deleted +/// neighbor root cannot block an unrelated project on a running pool. Boot checks +/// every share live before the provider starts. pub(super) fn check_request( - existing: Option<&ProjectShareIntent>, + existing: &[&ProjectShareIntent], requested: Option<&ProjectShareIntent>, ) -> Result<(), CandidateError> { + for share in existing { + share.validate_receipt()?; + } if let Some(requested) = requested { requested.validate()?; - if existing != Some(requested) { - return Err(refused()); + if !existing.contains(&requested) { + return Err(if existing.is_empty() { + refused() + } else { + extension_required() + }); + } + } else { + for share in existing { + share.validate()?; } } - if let Some(existing) = existing { - existing.validate()?; + Ok(()) +} + +/// Every share must still name its exact approved directory before the provider boots +/// with it; a stale additional share is removed explicitly, never skipped. +pub(super) fn check_boot(existing: &[&ProjectShareIntent]) -> Result<(), CandidateError> { + for share in existing { + share.validate().map_err(|_| { + CandidateError::new( + "project_share_stale", + "A shared project root moved, changed or was removed. Remove that additional share from the stopped pool with `runtime project-share remove` before booting.", + ) + })?; } Ok(()) } @@ -473,9 +540,76 @@ mod tests { let second = Fixture::new(); let a = ProjectShareIntent::approve(&first.0, true).unwrap(); let b = ProjectShareIntent::approve(&second.0, true).unwrap(); - assert!(check_request(Some(&a), Some(&a)).is_ok()); - assert!(check_request(Some(&a), None).is_ok()); - assert!(check_request(Some(&a), Some(&b)).is_err()); - assert!(check_request(None, Some(&a)).is_err()); + assert!(check_request(&[&a], Some(&a)).is_ok()); + assert!(check_request(&[&a], None).is_ok()); + assert_eq!( + check_request(&[&a], Some(&b)).unwrap_err().code, + "project_share_extension_required" + ); + assert_eq!( + check_request(&[], Some(&a)).unwrap_err().code, + "project_share" + ); + // Membership is exact: a forged receipt for the same root is not a member. + let mut forged = b.clone(); + forged.inode += 1; + assert!(check_request(&[&a, &b], Some(&forged)).is_err()); + assert!(check_request(&[&a, &b], Some(&b)).is_ok()); + } + + #[test] + fn project_share_set_is_bounded_unique_and_requires_a_primary() { + let fixtures: Vec<_> = (0..MAX_PROJECT_SHARES + 1) + .map(|_| Fixture::new()) + .collect(); + let shares: Vec<_> = fixtures + .iter() + .map(|f| ProjectShareIntent::approve(&f.0, true).unwrap()) + .collect(); + assert!(validate_set(None, &[]).is_ok()); + assert!(validate_set(Some(&shares[0]), &[]).is_ok()); + assert!(validate_set(Some(&shares[0]), &shares[1..MAX_PROJECT_SHARES]).is_ok()); + assert!(validate_set(Some(&shares[0]), &shares[1..]).is_err()); + assert!(validate_set(None, &shares[1..2]).is_err()); + assert!(validate_set(Some(&shares[0]), &[shares[0].clone()]).is_err()); + let mut same_guest = shares[1].clone(); + same_guest.guest_path = shares[0].guest_path.clone(); + assert!(validate_set(Some(&shares[0]), &[same_guest]).is_err()); + } + + #[test] + fn project_share_running_neighbor_removal_does_not_block_other_roots_but_blocks_boot() { + let first = Fixture::new(); + let second = Fixture::new(); + let a = ProjectShareIntent::approve(&first.0, true).unwrap(); + let b = ProjectShareIntent::approve(&second.0, true).unwrap(); + let moved = Fixture::new(); + fs::rename(&second.0, moved.0.join("gone")).unwrap(); + assert!(check_request(&[&a, &b], Some(&a)).is_ok()); + assert!(check_request(&[&a, &b], Some(&b)).is_err()); + assert!(check_request(&[&a, &b], None).is_err()); + assert_eq!( + check_boot(&[&a, &b]).unwrap_err().code, + "project_share_stale" + ); + assert!(check_boot(&[&a]).is_ok()); + } + + #[test] + fn project_share_bind_paths_never_cross_into_a_neighbor_share() { + let first = Fixture::new(); + let second = Fixture::new(); + let a = ProjectShareIntent::approve(&first.0, true).unwrap(); + let b = ProjectShareIntent::approve(&second.0, true).unwrap(); + assert_ne!(a.guest_path, b.guest_path); + for source in [ + b.guest_path.as_str(), + second.0.to_str().unwrap(), + "../", + "/mnt/hack-projects", + ] { + assert!(a.bind_path(source).is_err(), "accepted {source}"); + } + assert!(a.bind_path(".").unwrap().starts_with(&a.guest_path)); } } diff --git a/packages/runtime-core/src/provider/share_update.rs b/packages/runtime-core/src/provider/share_update.rs new file mode 100644 index 000000000..0c8959b17 --- /dev/null +++ b/packages/runtime-core/src/provider/share_update.rs @@ -0,0 +1,414 @@ +//! Explicit stopped-pool project share set changes. +//! +//! A development pool shares one primary project root from creation. Further roots +//! join the same pool only through this journaled update, applied while the pool is +//! stopped and its provider released, and take effect at the next boot. A running +//! pool's mounts never change. The journal owns both sides of the provider-record and +//! owner commit, exactly as the stopped network extension does, and ordinary startup +//! cannot cross an unfinished update. +//! +//! Isolation between projects in one pool: each graph binds only the share whose root +//! equals its own reviewed source (`project_share_for`), every bind path stays under +//! that share's guest path, graph namespaces are derived from the canonical root, and +//! cleanup acts on one run's labelled resources. Removing a share is refused while any +//! retained graph receipt still names it. +use super::{ + ProjectShareIntent, config_audit, network_update, + project_share::{self, MAX_PROJECT_SHARES}, + state::{self, Owner}, +}; +use crate::{Candidate, CandidateError, reject_aliased_state}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + fs::{self, File}, + io::Read, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; + +fn refused() -> CandidateError { + CandidateError::new( + "project_share_update", + "Stopped project share update is incomplete, not applicable, or its owned inputs changed; repeat the same update only after resolving ownership.", + ) +} +fn root(candidate: &Candidate) -> PathBuf { + candidate.state_root.join("run/smolvm") +} +const JOURNAL: &str = "share-update.json"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum Operation { + Add, + Remove, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Journal { + version: u32, + operation: Operation, + share: ProjectShareIntent, + database_identity: (u64, u64), + owner_before: Value, + owner_after: Value, + record_before: Value, + record_after: Value, +} + +/// Startup, source admission and other stopped-pool updates refuse while a share +/// update journal (or its staging file) exists. +pub(super) fn require_complete(candidate: &Candidate) -> Result<(), CandidateError> { + for name in [JOURNAL, "share-update.pending"] { + match fs::symlink_metadata(root(candidate).join(name)) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + _ => return Err(refused()), + } + } + Ok(()) +} + +fn owner_value(owner: &Owner) -> Result { + serde_json::to_value(owner).map_err(|_| refused()) +} + +/// The provider record's mount list for an owner: the read-only engine mount the +/// record already carries, then every share in set order. +fn record_mounts(record: &Value, owner: &Owner) -> Result { + let engine = record["mounts"] + .as_array() + .and_then(|mounts| mounts.first()) + .ok_or_else(refused)? + .clone(); + let mut mounts = vec![engine]; + mounts.extend( + owner + .project_shares() + .into_iter() + .map(|s| s.database_mount()), + ); + Ok(Value::Array(mounts)) +} + +/// Pure plan: the exact owner and record after one add or remove. Only the share set +/// and the record's mount list change; every other owner and record field is kept. +fn plan( + owner: &Owner, + record: Value, + operation: Operation, + share: &ProjectShareIntent, + database_identity: (u64, u64), +) -> Result { + let primary = owner.project_share.as_ref().ok_or_else(refused)?; + project_share::validate_set(Some(primary), &owner.additional_project_shares)?; + share.validate_receipt()?; + if record_mounts(&record, owner)? != record["mounts"] { + return Err(refused()); + } + let mut after = owner.clone(); + match operation { + Operation::Add => { + if owner + .project_shares() + .iter() + .any(|s| s.project == share.project || s.guest_path == share.guest_path) + || owner.project_shares().len() >= MAX_PROJECT_SHARES + { + return Err(refused()); + } + after.additional_project_shares.push(share.clone()); + } + Operation::Remove => { + // The primary share defines the pool; it is never removed here. + let index = owner + .additional_project_shares + .iter() + .position(|s| s == share) + .ok_or_else(refused)?; + after.additional_project_shares.remove(index); + } + } + project_share::validate_set( + after.project_share.as_ref(), + &after.additional_project_shares, + )?; + let mut record_after = record.clone(); + record_after["mounts"] = record_mounts(&record, &after)?; + Ok(Journal { + version: 1, + operation, + share: share.clone(), + database_identity, + owner_before: owner_value(owner)?, + owner_after: owner_value(&after)?, + record_before: record, + record_after, + }) +} + +/// A journal is valid only if replanning its `before` sides yields exactly its `after`. +fn validate(j: &Journal) -> Result<(Owner, Owner), CandidateError> { + let before: Owner = serde_json::from_value(j.owner_before.clone()).map_err(|_| refused())?; + let after: Owner = serde_json::from_value(j.owner_after.clone()).map_err(|_| refused())?; + if j.version != 1 { + return Err(refused()); + } + let expected = plan( + &before, + j.record_before.clone(), + j.operation, + &j.share, + j.database_identity, + )?; + if expected.owner_after != j.owner_after || expected.record_after != j.record_after { + return Err(refused()); + } + Ok((before, after)) +} + +/// Recovery accepts only the ordered sides: the record commits before the owner. +fn validate_sides(j: &Journal, owner: &Value, record: &Value) -> Result<(), CandidateError> { + if (owner != &j.owner_before && owner != &j.owner_after) + || (record != &j.record_before && record != &j.record_after) + || (owner == &j.owner_after && record == &j.record_before) + { + return Err(refused()); + } + Ok(()) +} + +const RECEIPT_LIMIT: u64 = 4 * 1024 * 1024; +const MAX_ENTRIES: usize = 4096; +const MAX_DEPTH: usize = 3; + +/// Conservative retained-graph reference check before a share is removed. Any graph +/// record (normalized or native, any phase, including pending journals) that names the +/// share's exact guest path or host root keeps the share; the caller cleans or archives +/// that project's graphs first. A false positive or an unreadable inventory only refuses. +fn require_unreferenced( + candidate: &Candidate, + share: &ProjectShareIntent, +) -> Result<(), CandidateError> { + let needles = [ + share.guest_path.as_bytes().to_vec(), + serde_json::to_vec(&share.project).map_err(|_| refused())?, + ]; + let mut budget = MAX_ENTRIES; + for parent in ["run/graphs", "run/native-graphs"] { + let parent = candidate.state_root.join(parent); + match fs::symlink_metadata(&parent) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err(refused()), + Ok(_) => state::check_private_directory(&parent)?, + } + scan(&parent, 0, &needles, &mut budget)?; + } + Ok(()) +} + +fn scan( + directory: &Path, + depth: usize, + needles: &[Vec], + budget: &mut usize, +) -> Result<(), CandidateError> { + for entry in fs::read_dir(directory).map_err(|_| refused())? { + *budget = budget.checked_sub(1).ok_or_else(refused)?; + let path = entry.map_err(|_| refused())?.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| refused())?; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.file_type().is_symlink() { + return Err(refused()); + } + if metadata.is_dir() { + if depth >= MAX_DEPTH { + return Err(refused()); + } + scan(&path, depth + 1, needles, budget)?; + continue; + } + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !metadata.is_file() || !(name.ends_with(".json") || name.ends_with(".pending")) { + continue; + } + if metadata.len() > RECEIPT_LIMIT { + return Err(refused()); + } + let mut bytes = Vec::new(); + File::open(&path) + .and_then(|f| f.take(RECEIPT_LIMIT + 1).read_to_end(&mut bytes)) + .map_err(|_| refused())?; + if needles + .iter() + .any(|needle| bytes.windows(needle.len()).any(|w| w == needle.as_slice())) + { + return Err(CandidateError::new( + "project_share_in_use", + "A retained graph still names this project share; clean or archive that project's graphs before removing its share.", + )); + } + } + Ok(()) +} + +/// Add one exactly approved project root to an owned, stopped development pool. The +/// new share is mounted at the next boot; nothing is booted, restarted or replaced. +pub fn add_project_share( + candidate: &Candidate, + share: &ProjectShareIntent, +) -> Result { + share.validate()?; + update(candidate, Operation::Add, share.clone()) +} + +/// Remove one additional project root from an owned, stopped development pool. The +/// root may already be moved or deleted; it is matched by its exact recorded path. +pub fn remove_project_share( + candidate: &Candidate, + project: &Path, +) -> Result { + if !project.is_absolute() { + return Err(refused()); + } + reject_aliased_state(&root(candidate))?; + let owner = Owner::load(candidate)?; + let share = match fs::symlink_metadata(root(candidate).join(JOURNAL)) { + // An interrupted removal names its own share; resume exactly that one. + Ok(_) => { + let j: Journal = state::read(&root(candidate).join(JOURNAL))?; + if j.operation != Operation::Remove || j.share.project != project { + return Err(refused()); + } + j.share + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => owner + .additional_project_shares + .iter() + .find(|s| s.project == project) + .cloned() + .ok_or_else(refused)?, + Err(_) => return Err(refused()), + }; + update(candidate, Operation::Remove, share) +} + +fn update( + candidate: &Candidate, + operation: Operation, + share: ProjectShareIntent, +) -> Result { + reject_aliased_state(&root(candidate))?; + let _lock = state::Lock::acquire(&root(candidate))?; + network_update::require_complete(candidate)?; + let owner = Owner::load(candidate)?; + let path = root(candidate).join(JOURNAL); + let journal_present = match fs::symlink_metadata(&path) { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => return Err(refused()), + }; + if !journal_present { + let present = owner.project_shares().contains(&&share); + if operation == Operation::Add && present { + return Ok(result(&owner, operation, &share, false)); + } + } + let _vm_lock = network_update::stopped_with(candidate, &owner, refused)?; + if operation == Operation::Remove { + require_unreferenced(candidate, &share)?; + } + let db = network_update::database(candidate); + let record = config_audit::read_database(&db, &owner.machine)?; + network_update::verify_record_process_with(&owner, &record, refused)?; + let j: Journal = if journal_present { + network_update::file_identity_with(&path, refused)?; + state::read(&path)? + } else { + require_complete(candidate)?; + config_audit::verify_value(candidate, &owner, &record)?; + let j = plan( + &owner, + record.clone(), + operation, + &share, + network_update::file_identity_with(&db, refused)?, + )?; + state::write(&path, &j)?; + j + }; + let (before, after) = validate(&j)?; + let current_owner = owner_value(&owner)?; + if j.operation != operation || j.share != share { + return Err(refused()); + } + validate_sides(&j, ¤t_owner, &record)?; + config_audit::verify_value(candidate, &before, &j.record_before)?; + config_audit::verify_value(candidate, &after, &j.record_after)?; + if owner_value(&Owner::load(candidate)?)? != current_owner { + return Err(refused()); + } + super::lifecycle::verify_disks(candidate, &owner)?; + network_update::apply_record( + &db, + &owner.machine, + j.database_identity, + &j.record_before, + &j.record_after, + refused, + )?; + config_audit::verify_value( + candidate, + &after, + &config_audit::read_database(&db, &owner.machine)?, + )?; + super::lifecycle::verify_disks(candidate, &owner)?; + // A fully written interrupted owner staging file is removable only if exact. + let pending = root(candidate).join("owner.pending"); + match fs::symlink_metadata(&pending) { + Ok(_) => { + network_update::file_identity_with(&pending, refused)?; + let staged: Value = state::read(&pending)?; + if staged != j.owner_after { + return Err(refused()); + } + fs::remove_file(&pending).map_err(|_| refused())?; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(refused()), + } + if owner_value(&Owner::load(candidate)?)? != current_owner { + return Err(refused()); + } + if current_owner != j.owner_after { + after.save(candidate)?; + } + let saved = Owner::load(candidate)?; + config_audit::verify(candidate, &saved)?; + fs::remove_file(&path).map_err(|_| refused())?; + File::open(root(candidate)) + .and_then(|f| f.sync_all()) + .map_err(|_| refused())?; + Ok(result(&saved, operation, &share, true)) +} + +fn result(owner: &Owner, operation: Operation, share: &ProjectShareIntent, changed: bool) -> Value { + json!({ + "kind": match operation { + Operation::Add => "project_share_added", + Operation::Remove => "project_share_removed", + }, + "owner": owner.token, + "machine": owner.machine, + "project": share.project, + "changed": changed, + "shares": owner.project_shares().len(), + "share_limit": MAX_PROJECT_SHARES, + "restarted": false, + "disks_preserved": true, + "effective": "next-boot", + }) +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/share_update/tests.rs b/packages/runtime-core/src/provider/share_update/tests.rs new file mode 100644 index 000000000..dbdfc63f8 --- /dev/null +++ b/packages/runtime-core/src/provider/share_update/tests.rs @@ -0,0 +1,390 @@ +use super::*; +use rusqlite::Connection; +use std::os::unix::fs::{DirBuilderExt, symlink}; + +static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +struct Fixture { + directory: PathBuf, + db: PathBuf, + owner: Owner, + record: Value, + second: ProjectShareIntent, + third: ProjectShareIntent, +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } +} +fn project(directory: &Path, name: &str) -> ProjectShareIntent { + let path = directory.join(name); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + fs::write(path.join("package.json"), "{}").unwrap(); + ProjectShareIntent::approve(&path, true).unwrap() +} +fn fixture() -> Fixture { + let directory = std::env::temp_dir().canonicalize().unwrap().join(format!( + "share-update-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .unwrap(); + let primary = project(&directory, "first-app"); + let second = project(&directory, "second-app"); + let third = project(&directory, "third-app"); + let db = directory.join("provider.db"); + let record = json!({ + "name": "fixture", + "mounts": [["/engine", "/opt/hack-engine", true], primary.database_mount()], + "unrelated": {"preserved": true}, + }); + let connection = Connection::open(&db).unwrap(); + connection + .execute( + "CREATE TABLE vms(name TEXT PRIMARY KEY, data BLOB NOT NULL)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO vms VALUES('fixture', ?1)", + [serde_json::to_vec(&record).unwrap()], + ) + .unwrap(); + drop(connection); + let owner: Owner = serde_json::from_value(json!({ + "version":1,"checkout":"/fixture","token":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","machine":"fixture", + "short_home":"/fixture","created":true,"phase":"stopped","process":null, + "storage":null,"overlay":null,"guest_boot_id":null,"daemon_pid":null,"daemon_start":null, + "rootfs_digest":null,"profile":"development","network":"internet", + "project_share": primary, + })) + .unwrap(); + Fixture { + directory, + db, + owner, + record, + second, + third, + } +} +fn identity(f: &Fixture) -> (u64, u64) { + network_update::file_identity_with(&f.db, refused).unwrap() +} + +#[test] +fn single_root_owner_receipts_keep_their_exact_bytes() { + let f = fixture(); + let value = owner_value(&f.owner).unwrap(); + assert!(value.get("additional_project_shares").is_none()); + let reloaded: Owner = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(owner_value(&reloaded).unwrap(), value); + assert_eq!(f.owner.project_shares().len(), 1); +} + +#[test] +fn add_plans_only_the_share_set_and_record_mounts() { + let f = fixture(); + let j = plan( + &f.owner, + f.record.clone(), + Operation::Add, + &f.second, + identity(&f), + ) + .unwrap(); + let (before, after) = validate(&j).unwrap(); + assert_eq!(before, f.owner); + assert_eq!( + after.project_shares(), + vec![f.owner.project_share.as_ref().unwrap(), &f.second] + ); + assert_eq!(after.project_share_for(&f.second.project), Some(&f.second)); + assert_eq!(j.record_after["unrelated"], f.record["unrelated"]); + assert_eq!(j.record_after["mounts"][0], f.record["mounts"][0]); + assert_eq!(j.record_after["mounts"][1], f.record["mounts"][1]); + assert_eq!(j.record_after["mounts"][2], f.second.database_mount()); + // Any other owner or record change makes the journal invalid. + let mut forged = plan( + &f.owner, + f.record.clone(), + Operation::Add, + &f.second, + identity(&f), + ) + .unwrap(); + forged.owner_after["machine"] = json!("foreign"); + assert!(validate(&forged).is_err()); + forged.owner_after["machine"] = json!("fixture"); + forged.record_after["unrelated"] = json!(false); + assert!(validate(&forged).is_err()); + forged.record_after["unrelated"] = f.record["unrelated"].clone(); + forged.record_after["mounts"][2] = f.third.database_mount(); + assert!(validate(&forged).is_err()); +} + +#[test] +fn add_refuses_duplicates_unshared_pools_full_sets_and_drifted_records() { + let f = fixture(); + let primary = f.owner.project_share.clone().unwrap(); + assert!( + plan( + &f.owner, + f.record.clone(), + Operation::Add, + &primary, + identity(&f) + ) + .is_err() + ); + let mut unshared = f.owner.clone(); + unshared.project_share = None; + let mut bare = f.record.clone(); + bare["mounts"] = json!([["/engine", "/opt/hack-engine", true]]); + assert!(plan(&unshared, bare, Operation::Add, &f.second, identity(&f)).is_err()); + let mut drifted = f.record.clone(); + drifted["mounts"] = json!([["/engine", "/opt/hack-engine", true]]); + assert!(plan(&f.owner, drifted, Operation::Add, &f.second, identity(&f)).is_err()); + let mut full = f.owner.clone(); + let mut record = f.record.clone(); + for index in 1..MAX_PROJECT_SHARES { + let share = project(&f.directory, &format!("filler-{index}")); + record["mounts"] + .as_array_mut() + .unwrap() + .push(share.database_mount()); + full.additional_project_shares.push(share); + } + assert!(plan(&full, record, Operation::Add, &f.second, identity(&f)).is_err()); +} + +#[test] +fn remove_never_takes_the_primary_and_keeps_other_shares_in_order() { + let f = fixture(); + let primary = f.owner.project_share.clone().unwrap(); + let add = |owner: &Owner, record: &Value, share: &ProjectShareIntent| { + let j = plan(owner, record.clone(), Operation::Add, share, identity(&f)).unwrap(); + (validate(&j).unwrap().1, j.record_after) + }; + let (two, record) = add(&f.owner, &f.record, &f.second); + let (three, record) = add(&two, &record, &f.third); + assert!( + plan( + &three, + record.clone(), + Operation::Remove, + &primary, + identity(&f) + ) + .is_err() + ); + let j = plan( + &three, + record.clone(), + Operation::Remove, + &f.second, + identity(&f), + ) + .unwrap(); + let (_, after) = validate(&j).unwrap(); + assert_eq!(after.project_shares(), vec![&primary, &f.third]); + assert_eq!(j.record_after["mounts"][2], f.third.database_mount()); + assert_eq!(j.record_after["mounts"].as_array().unwrap().len(), 3); + // Removal matches the exact recorded receipt, never a look-alike. + let mut forged = f.third.clone(); + forged.inode += 1; + assert!(plan(&three, record, Operation::Remove, &forged, identity(&f)).is_err()); +} + +#[test] +fn interrupted_update_resumes_only_ordered_sides_without_repeating_effects() { + let f = fixture(); + let j = plan( + &f.owner, + f.record.clone(), + Operation::Add, + &f.second, + identity(&f), + ) + .unwrap(); + let path = f.directory.join("journal.json"); + state::write(&path, &j).unwrap(); + let saved: Journal = state::read(&path).unwrap(); + validate_sides(&saved, &saved.owner_before, &saved.record_before).unwrap(); + network_update::apply_record( + &f.db, + "fixture", + saved.database_identity, + &saved.record_before, + &saved.record_after, + refused, + ) + .unwrap(); + let current = config_audit::read_database(&f.db, "fixture").unwrap(); + assert_eq!(current, saved.record_after); + // Crash after the record commit: the owner may still be `before`. + validate_sides(&saved, &saved.owner_before, ¤t).unwrap(); + // Repeating the record commit is a no-op. + network_update::apply_record( + &f.db, + "fixture", + saved.database_identity, + &saved.record_before, + &saved.record_after, + refused, + ) + .unwrap(); + validate_sides(&saved, &saved.owner_after, ¤t).unwrap(); + // The owner never commits ahead of the record. + assert!(validate_sides(&saved, &saved.owner_after, &saved.record_before).is_err()); + let mut running = saved.owner_before.clone(); + running["phase"] = json!("running"); + assert!(validate_sides(&saved, &running, ¤t).is_err()); +} + +#[test] +fn foreign_record_and_replaced_database_refuse_without_writes() { + let f = fixture(); + let j = plan( + &f.owner, + f.record.clone(), + Operation::Add, + &f.second, + identity(&f), + ) + .unwrap(); + let connection = Connection::open(&f.db).unwrap(); + let mut changed = f.record.clone(); + changed["unrelated"] = json!(false); + connection + .execute( + "UPDATE vms SET data=?1", + [serde_json::to_vec(&changed).unwrap()], + ) + .unwrap(); + drop(connection); + let apply = || { + network_update::apply_record( + &f.db, + "fixture", + j.database_identity, + &j.record_before, + &j.record_after, + refused, + ) + }; + assert_eq!(apply().unwrap_err().code, "project_share_update"); + assert_eq!( + config_audit::read_database(&f.db, "fixture").unwrap(), + changed + ); + let replacement = f.directory.join("replacement.db"); + fs::copy(&f.db, &replacement).unwrap(); + fs::rename(&replacement, &f.db).unwrap(); + assert!(apply().is_err()); +} + +#[test] +fn unfinished_or_aliased_share_journal_fences_startup_and_network_updates() { + let f = fixture(); + let mut candidate = Candidate::discover(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap(); + candidate.state_root = f.directory.clone(); + fs::create_dir_all(root(&candidate)).unwrap(); + require_complete(&candidate).unwrap(); + for name in [JOURNAL, "share-update.pending"] { + let path = root(&candidate).join(name); + fs::write(&path, b"incomplete").unwrap(); + assert!(require_complete(&candidate).is_err()); + fs::remove_file(&path).unwrap(); + symlink("missing", &path).unwrap(); + assert!(require_complete(&candidate).is_err()); + fs::remove_file(path).unwrap(); + } +} + +#[test] +fn running_or_unproven_pool_refuses_before_any_effect() { + let f = fixture(); + let candidate = Candidate::discover(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap(); + let mut owner = f.owner.clone(); + assert_eq!( + network_update::stopped_with(&candidate, &owner, refused) + .unwrap_err() + .code, + "project_share_update" + ); + owner.phase = "running".into(); + assert!(network_update::stopped_with(&candidate, &owner, refused).is_err()); + assert_eq!( + config_audit::read_database(&f.db, "fixture").unwrap(), + f.record + ); +} + +#[test] +fn removal_refuses_while_any_retained_graph_names_the_share() { + let f = fixture(); + let mut candidate = Candidate::discover(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap(); + candidate.state_root = f.directory.join("state"); + fs::DirBuilder::new() + .mode(0o700) + .recursive(true) + .create(candidate.state_root.join("run")) + .unwrap(); + assert!(require_unreferenced(&candidate, &f.second).is_ok()); + for parent in ["run/graphs", "run/native-graphs"] { + let run = candidate.state_root.join(parent).join("a".repeat(32)); + fs::DirBuilder::new() + .mode(0o700) + .recursive(true) + .create(&run) + .unwrap(); + // Another project's retained graph never blocks this share. + fs::write( + run.join("state.json"), + serde_json::to_vec(&json!({"source":{"shared": f.third}})).unwrap(), + ) + .unwrap(); + assert!(require_unreferenced(&candidate, &f.second).is_ok()); + fs::write( + run.join("state.pending"), + serde_json::to_vec(&json!({"source":{"shared": f.second}})).unwrap(), + ) + .unwrap(); + assert_eq!( + require_unreferenced(&candidate, &f.second) + .unwrap_err() + .code, + "project_share_in_use" + ); + fs::remove_file(run.join("state.pending")).unwrap(); + let nested = run.join("transport"); + fs::DirBuilder::new().mode(0o700).create(&nested).unwrap(); + fs::write(nested.join("binding.json"), f.second.guest_path.as_bytes()).unwrap(); + assert_eq!( + require_unreferenced(&candidate, &f.second) + .unwrap_err() + .code, + "project_share_in_use" + ); + fs::remove_dir_all(&nested).unwrap(); + // An aliased record is never followed. + symlink(run.join("state.json"), run.join("alias.json")).unwrap(); + assert_eq!( + require_unreferenced(&candidate, &f.second) + .unwrap_err() + .code, + "project_share_update" + ); + fs::remove_file(run.join("alias.json")).unwrap(); + } +} diff --git a/packages/runtime-core/src/provider/state.rs b/packages/runtime-core/src/provider/state.rs index 093f4ea02..b419b1015 100644 --- a/packages/runtime-core/src/provider/state.rs +++ b/packages/runtime-core/src/provider/state.rs @@ -215,6 +215,11 @@ impl Default for ReclamationPolicy { pub struct Owner { #[serde(default, skip_serializing_if = "Option::is_none")] pub project_share: Option, + /// Further project roots shared into the same development pool, in admission order. + /// Added or removed only by the journaled stopped-pool share update; empty for + /// single-root pools, whose receipt bytes are unchanged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub additional_project_shares: Vec, #[serde(default)] pub network: super::NetworkIntent, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -308,9 +313,10 @@ impl Owner { } let owner: Self = read(&root.join("owner.json"))?; owner.network.validate()?; - if let Some(share) = &owner.project_share { - share.validate_receipt()?; - } + super::project_share::validate_set( + owner.project_share.as_ref(), + &owner.additional_project_shares, + )?; if owner.created && matches!(&owner.network, super::NetworkIntent::ApprovedHosts { cidrs, .. } if cidrs.is_empty()) { @@ -470,6 +476,7 @@ impl Owner { let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); let owner = Self { project_share, + additional_project_shares: Vec::new(), network, profile, application_bridge, @@ -515,6 +522,19 @@ impl Owner { } Ok(owner) } + /// The pool's whole share set: the primary share first, then additional roots. + pub fn project_shares(&self) -> Vec<&super::ProjectShareIntent> { + self.project_share + .iter() + .chain(&self.additional_project_shares) + .collect() + } + /// The exact share approved for one project root, if this pool shares it. + pub fn project_share_for(&self, project: &Path) -> Option<&super::ProjectShareIntent> { + self.project_shares() + .into_iter() + .find(|share| share.project == project) + } pub fn real_data_dir(&self, candidate: &Candidate) -> Result { let relative = self .data_dir() diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..917a373c2 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1073,6 +1073,53 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "multi-root-share", + module: "Shares", + states: 55, + invariant: "OwnShareOnly", + action: "StartGraph", + fields: [ + 'graph = [a |-> "none", b |-> "live", c |-> "none"]', + 'bound = [a |-> "none", b |-> "a", c |-> "none"]', + 'mounted = {"a"}', + ], + additionalControls: [ + { + name: "unfenced-boot", + negative: true, + invariant: "RunningMountsAudited", + action: "Boot", + fields: [ + 'journal = [op |-> "add", p |-> "b"]', + 'phase = "running"', + 'mounted = {"a"}', + ], + }, + { + name: "owner-first", + negative: true, + invariant: "OwnerNeverAhead", + action: "CommitOwner", + fields: [ + 'journal = [op |-> "add", p |-> "b"]', + 'recordSet = {"a"}', + 'ownerSet = {"a", "b"}', + ], + }, + { + name: "referenced-removal", + negative: true, + invariant: "ReferencedSharesKept", + action: "CommitRecord", + fields: [ + 'graph = [a |-> "none", b |-> "retained", c |-> "none"]', + 'journal = [op |-> "remove", p |-> "b"]', + 'recordSet = {"a"}', + ], + }, + ], + }, { name: "dependency-rebind", module: "Rebind", diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..243d4b9cd 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -1049,3 +1049,31 @@ Closing the residual needs a mutation fence rather than a timeout: target tmux mutations by session ID (`#{session_id}`, never reused by a server) captured with the token read. That fence, and full zellij support for shared-helper lifecycle sessions, are tracked in HACK-1849 and are not in this slice. + +## Multi-root pool shares + +`multi-root-share/Shares.tla` models one development pool that hosts several +project roots. The share set is the pool's mount intent. It changes only through +the journaled stopped-pool update in `provider::share_update`: the provider record +commits before the owner receipt, and startup refuses while the journal exists. +Boot mounts exactly the audited set. A graph binds only the share whose root equals +its own reviewed source, and a share stays while any graph of that project is live +or retained. + +The positive configuration (three roots, a two-share bound) exhausts **55 distinct +states**. Four guard-removal controls must fail at the named action with the exact +same-state witness: `negative` (a graph may select any mounted share) fails +`OwnShareOnly` at `StartGraph`; `unfenced-boot` fails `RunningMountsAudited` at +`Boot`; `owner-first` fails `OwnerNeverAhead` at `CommitOwner`; and +`referenced-removal` fails `ReferencedSharesKept` at `CommitRecord`. + +| Model operation | Source boundary | +| --- | --- | +| `BeginAdd` / `BeginRemove` | `share_update::plan`: owned stopped pool (`network_update::stopped_with`), primary share kept, bounded set, removal refused while a graph record names the share (`require_unreferenced`). | +| `CommitRecord` / `CommitOwner` / `Finish` | `network_update::apply_record`, then the owner save, then journal removal; `validate_sides` refuses an owner ahead of its record. | +| `Boot` | `share_update::require_complete`, `project_share::check_boot` and `config_audit` mount verification before `machine start`. | +| `StartGraph` | `OwnedGuest::project_share_for(plan.source)` in shared source selection; `ProjectShareIntent::bind_path` keeps every bind under that share. | + +The model does not cover provider behavior: that the pinned provider mounts an +updated record at the next boot, virtio-fs device limits, or live multi-project +graphs on a host VM. Cleanup is abstracted to one run's own resources. diff --git a/tests/models/tla/multi-root-share/Shares.tla b/tests/models/tla/multi-root-share/Shares.tla new file mode 100644 index 000000000..cf5724aee --- /dev/null +++ b/tests/models/tla/multi-root-share/Shares.tla @@ -0,0 +1,93 @@ +------------------------------ MODULE Shares ------------------------------ +(* One development pool hosts several project roots. The share set is the *) +(* pool's mount intent: it changes only through a journaled stopped-pool *) +(* update (provider record first, then owner receipt), boot mounts exactly the *) +(* audited set, each graph binds only its own root's share, and a share stays *) +(* while any graph of that project is live or retained. *) +EXTENDS Naturals, FiniteSets +CONSTANTS Projects, Primary, MaxShares, + EnforceFence, EnforceOrder, EnforceOwnShare, EnforceUnreferenced +VARIABLES phase, ownerSet, recordSet, journal, mounted, graph, bound +vars == <> +None == "none" +Ops == {"add", "remove"} +\* An absent journal is the idle record, so every journal value is comparable. +Idle == [op |-> "idle", p |-> Primary] +Journals == {Idle} \cup [op : Ops, p : Projects] +Apply(set, j) == IF j.op = "add" THEN set \cup {j.p} ELSE set \ {j.p} + +Init == /\ phase = "stopped" /\ ownerSet = {Primary} /\ recordSet = {Primary} + /\ journal = Idle /\ mounted = {} + /\ graph = [p \in Projects |-> "none"] /\ bound = [p \in Projects |-> None] + +\* Startup and source admission refuse while a journal exists (require_complete), +\* and boot audits owner receipt against the provider record (config_audit). +Boot == /\ phase = "stopped" + /\ (EnforceFence => (journal = Idle /\ ownerSet = recordSet)) + /\ phase' = "running" /\ mounted' = recordSet + /\ UNCHANGED <> +\* Stopping keeps graph receipts as retained data; no mount survives. +Stop == /\ phase = "running" + /\ phase' = "stopped" /\ mounted' = {} + /\ graph' = [p \in Projects |-> IF graph[p] = "live" THEN "retained" ELSE graph[p]] + /\ UNCHANGED <> +\* The stopped update plans one add or remove (share_update::plan). +BeginAdd(p) == /\ phase = "stopped" /\ journal = Idle /\ p \notin ownerSet + /\ Cardinality(ownerSet) < MaxShares + /\ journal' = [op |-> "add", p |-> p] + /\ UNCHANGED <> +BeginRemove(p) == /\ phase = "stopped" /\ journal = Idle /\ p \in ownerSet + /\ p # Primary + /\ (EnforceUnreferenced => graph[p] = "none") + /\ journal' = [op |-> "remove", p |-> p] + /\ UNCHANGED <> +CommitRecord == /\ phase = "stopped" /\ journal # Idle + /\ recordSet = ownerSet + /\ recordSet' = Apply(recordSet, journal) + /\ UNCHANGED <> +CommitOwner == /\ phase = "stopped" /\ journal # Idle + /\ ownerSet # Apply(ownerSet, journal) + /\ (EnforceOrder => recordSet = Apply(ownerSet, journal)) + /\ ownerSet' = Apply(ownerSet, journal) + /\ UNCHANGED <> +Finish == /\ journal # Idle + /\ ownerSet = recordSet /\ ownerSet = Apply(ownerSet, journal) + /\ journal' = Idle + /\ UNCHANGED <> +\* A graph selects the share whose root equals its reviewed source. +StartGraph(p, s) == /\ phase = "running" /\ graph[p] \in {"none", "retained"} + /\ s \in mounted + /\ (EnforceOwnShare => s = p) + /\ graph' = [graph EXCEPT ![p] = "live"] + /\ bound' = [bound EXCEPT ![p] = s] + /\ UNCHANGED <> +\* Cleanup acts on one run's own labelled resources only. +CleanGraph(p) == /\ graph[p] # "none" + /\ graph' = [graph EXCEPT ![p] = "none"] + /\ bound' = [bound EXCEPT ![p] = None] + /\ UNCHANGED <> + +Next == \/ Boot \/ Stop \/ CommitRecord \/ CommitOwner \/ Finish + \/ \E p \in Projects : BeginAdd(p) \/ BeginRemove(p) \/ CleanGraph(p) + \/ \E p, s \in Projects : StartGraph(p, s) +Spec == Init /\ [][Next]_vars + +TypeOK == /\ phase \in {"stopped", "running"} + /\ ownerSet \subseteq Projects /\ recordSet \subseteq Projects + /\ mounted \subseteq Projects /\ journal \in Journals + /\ graph \in [Projects -> {"none", "live", "retained"}] + /\ bound \in [Projects -> Projects \cup {None}] +\* A running pool's mounts are exactly the audited, unchanging share set. +RunningMountsAudited == phase = "running" => + (mounted = ownerSet /\ mounted = recordSet /\ journal = Idle) +\* A live graph binds only its own root, and that share is mounted. +OwnShareOnly == \A p \in Projects : graph[p] = "live" => (bound[p] = p /\ p \in mounted) +\* A retained or live graph keeps its share in both owner and provider record. +ReferencedSharesKept == \A p \in Projects : + graph[p] # "none" => (p \in ownerSet /\ p \in recordSet) +\* The owner receipt never commits a share change the provider record lacks. +OwnerNeverAhead == journal # Idle => + ~(ownerSet # recordSet /\ ownerSet = Apply(recordSet, journal)) +PrimaryKept == Primary \in ownerSet /\ Primary \in recordSet +Bounded == Cardinality(ownerSet) <= MaxShares /\ Cardinality(recordSet) <= MaxShares +============================================================================= diff --git a/tests/models/tla/multi-root-share/negative.cfg b/tests/models/tla/multi-root-share/negative.cfg new file mode 100644 index 000000000..c8658e9bd --- /dev/null +++ b/tests/models/tla/multi-root-share/negative.cfg @@ -0,0 +1,16 @@ +CONSTANTS Projects = {"a", "b", "c"} + Primary = "a" + MaxShares = 2 + EnforceFence = TRUE + EnforceOrder = TRUE + EnforceOwnShare = FALSE + EnforceUnreferenced = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + RunningMountsAudited + OwnShareOnly + ReferencedSharesKept + OwnerNeverAhead + PrimaryKept + Bounded +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/multi-root-share/owner-first.cfg b/tests/models/tla/multi-root-share/owner-first.cfg new file mode 100644 index 000000000..86fbb8b06 --- /dev/null +++ b/tests/models/tla/multi-root-share/owner-first.cfg @@ -0,0 +1,16 @@ +CONSTANTS Projects = {"a", "b", "c"} + Primary = "a" + MaxShares = 2 + EnforceFence = TRUE + EnforceOrder = FALSE + EnforceOwnShare = TRUE + EnforceUnreferenced = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + RunningMountsAudited + OwnShareOnly + ReferencedSharesKept + OwnerNeverAhead + PrimaryKept + Bounded +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/multi-root-share/positive.cfg b/tests/models/tla/multi-root-share/positive.cfg new file mode 100644 index 000000000..0200cd855 --- /dev/null +++ b/tests/models/tla/multi-root-share/positive.cfg @@ -0,0 +1,16 @@ +CONSTANTS Projects = {"a", "b", "c"} + Primary = "a" + MaxShares = 2 + EnforceFence = TRUE + EnforceOrder = TRUE + EnforceOwnShare = TRUE + EnforceUnreferenced = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + RunningMountsAudited + OwnShareOnly + ReferencedSharesKept + OwnerNeverAhead + PrimaryKept + Bounded +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/multi-root-share/referenced-removal.cfg b/tests/models/tla/multi-root-share/referenced-removal.cfg new file mode 100644 index 000000000..80d51fc0a --- /dev/null +++ b/tests/models/tla/multi-root-share/referenced-removal.cfg @@ -0,0 +1,16 @@ +CONSTANTS Projects = {"a", "b", "c"} + Primary = "a" + MaxShares = 2 + EnforceFence = TRUE + EnforceOrder = TRUE + EnforceOwnShare = TRUE + EnforceUnreferenced = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK + RunningMountsAudited + OwnShareOnly + ReferencedSharesKept + OwnerNeverAhead + PrimaryKept + Bounded +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/multi-root-share/unfenced-boot.cfg b/tests/models/tla/multi-root-share/unfenced-boot.cfg new file mode 100644 index 000000000..d701a3a95 --- /dev/null +++ b/tests/models/tla/multi-root-share/unfenced-boot.cfg @@ -0,0 +1,16 @@ +CONSTANTS Projects = {"a", "b", "c"} + Primary = "a" + MaxShares = 2 + EnforceFence = FALSE + EnforceOrder = TRUE + EnforceOwnShare = TRUE + EnforceUnreferenced = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + RunningMountsAudited + OwnShareOnly + ReferencedSharesKept + OwnerNeverAhead + PrimaryKept + Bounded +CHECK_DEADLOCK FALSE From 77747bd2ed77512a3303e014a01ef84c028b936f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:49:49 -0500 Subject: [PATCH 035/138] fix(native): deliver private file members only to profile-active services Credential adapters normalize every authored service, including services outside the selected profiles, so public Compose stays the same across profile selections. The runtime refuses file members owned by inactive services, so an ADC mount on an optional, disabled service blocked the whole selected graph. The private environment envelope now carries file members only for services the reviewed plan marks active; mount normalization is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/backends/native-project-start.ts | 38 +++++++++-- tests/native-gcloud-environment.test.ts | 89 +++++++++++++++++++++---- 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 0b2e8dde9..7f951771f 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -645,14 +645,33 @@ async function adaptNativeCredentials(opts: { * Private envelope for `graph serve --environment-stdin`. Version 1 carries values * only; version 2 adds service-scoped file members and is sent only when files * exist, so a runtime without file support refuses instead of dropping them. + * + * Adapters normalize every authored service, including those outside the selected + * profiles, so public Compose stays identical across profile selections. File + * members are delivered only for services the reviewed plan marks active: the + * runtime refuses file ownership for inactive services, so an optional disabled + * service must not block the selected graph. */ -export function nativeEnvironmentDelivery( - input: NativeProjectInput, - plan: string, - run: string -): { flags: string[]; payload?: Buffer } { +export function nativeEnvironmentDelivery(opts: { + readonly input: NativeProjectInput; + readonly planId: string; + readonly reviewedPlan: unknown; + readonly run: string; +}): { flags: string[]; payload?: Buffer } { + const { input, planId: plan, run } = opts; + const reviewed = + isRecord(opts.reviewedPlan) && isRecord(opts.reviewedPlan.services) + ? opts.reviewedPlan.services + : {}; const files = Object.entries(input.managedFiles ?? {}).filter( - ([, members]) => members.length > 0 + ([service, members]) => { + const reviewedService = reviewed[service]; + return ( + members.length > 0 && + isRecord(reviewedService) && + reviewedService.active === true + ); + } ); if ( Object.keys(input.managedEnvironment).length === 0 && @@ -1374,7 +1393,12 @@ export async function startNativeProject(opts: { run, invoke: deps.invoke, }); - const delivery = nativeEnvironmentDelivery(input, review.planId, run); + const delivery = nativeEnvironmentDelivery({ + input, + planId: review.planId, + reviewedPlan: review.report.plan, + run, + }); let code = 1; let serveFailure: unknown; let nativeExitCode: string | undefined; diff --git a/tests/native-gcloud-environment.test.ts b/tests/native-gcloud-environment.test.ts index b374d2c7b..0322f7ee7 100644 --- a/tests/native-gcloud-environment.test.ts +++ b/tests/native-gcloud-environment.test.ts @@ -58,6 +58,22 @@ function input( serviceNames: Object.keys(services), }; } +/** Delivery against a reviewed plan whose `active` flags are given per service. */ +function deliver( + value: NativeProjectInput, + active: Record +): ReturnType { + return nativeEnvironmentDelivery({ + input: value, + planId: "p".repeat(64), + reviewedPlan: { + services: Object.fromEntries( + Object.entries(active).map(([name, flag]) => [name, { active: flag }]) + ), + }, + run: "r".repeat(32), + }); +} const standard = () => input( { @@ -157,11 +173,7 @@ test("version two envelope carries files only when members exist", async () => { homeDirectory: root, mint: async () => undefined, }); - const delivery = nativeEnvironmentDelivery( - adapted.input, - "p".repeat(64), - "r".repeat(32) - ); + const delivery = deliver(adapted.input, { api: true, proxy: true }); expect(delivery.flags).toEqual(["--environment-stdin"]); const envelope = JSON.parse(delivery.payload?.toString() ?? ""); expect(envelope.version).toBe(2); @@ -176,19 +188,68 @@ test("version two envelope carries files only when members exist", async () => { content_b64: Buffer.from(JSON.stringify(user)).toString("base64"), }, ]); - const plain = nativeEnvironmentDelivery( - standard(), - "p".repeat(64), - "r".repeat(32) - ); + const plain = deliver(standard(), { api: true, proxy: true }); const v1 = JSON.parse(plain.payload?.toString() ?? ""); expect(v1.version).toBe(1); expect(Object.hasOwn(v1, "files")).toBe(false); + expect(deliver(input({ web: {} }), { web: true })).toEqual({ flags: [] }); +}); + +test("file members are delivered only for profile-active services; normalization is unchanged", async () => { + const root = await home(); + const authored = standard(); + const compose = JSON.parse(authored.normalizedComposeJson); + compose.services.proxy.profiles = ["tools"]; + const optional = { + ...authored, + normalizedComposeJson: JSON.stringify(compose), + }; + const adapted = await adaptNativeGcloudEnvironment({ + input: optional, + mode: "file", + homeDirectory: root, + mint: async () => undefined, + }); + // The inactive service is still normalized exactly as when it is selected. + const normalized = JSON.parse(adapted.input.normalizedComposeJson); + expect(normalized.services.proxy.volumes).toEqual([]); + expect(normalized.services.proxy.environment).toEqual({ + GOOGLE_APPLICATION_CREDENTIALS: "/config/adc.json", + }); + expect(Object.keys(adapted.input.managedFiles ?? {}).sort()).toEqual([ + "api", + "proxy", + ]); + const delivery = deliver(adapted.input, { + api: true, + proxy: false, + other: true, + }); + const envelope = JSON.parse(delivery.payload?.toString() ?? ""); + expect(envelope.version).toBe(2); + expect(Object.keys(envelope.files)).toEqual(["api"]); + expect(envelope.files.api).toEqual([ + { + name: "gcloud-adc.json", + target: "/srv/app/.config/gcloud/application_default_credentials.json", + mode: "0400", + content_b64: Buffer.from(JSON.stringify(user)).toString("base64"), + }, + ]); + // A service missing from the reviewed plan is treated as inactive too. + const unreviewed = deliver(adapted.input, { api: true }); + expect( + Object.keys(JSON.parse(unreviewed.payload?.toString() ?? "").files) + ).toEqual(["api"]); + // When no active service owns a file member the envelope stays version 1. + const valuesOnly = deliver(adapted.input, { api: false, proxy: false }); + const v1 = JSON.parse(valuesOnly.payload?.toString() ?? ""); + expect(v1.version).toBe(1); + expect(Object.hasOwn(v1, "files")).toBe(false); expect( - nativeEnvironmentDelivery( - input({ web: {} }), - "p".repeat(64), - "r".repeat(32) + deliver( + { ...adapted.input, managedEnvironment: {} }, + { api: false, proxy: false } ) ).toEqual({ flags: [] }); }); From 2583e9f190c67e6aea2667465d75c7281f48024d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:49:15 -0500 Subject: [PATCH 036/138] feat(native): acquire images from any OCI registry with bounded budgets Native image acquisition now accepts any OCI distribution registry, not only public Docker Hub, and replaces the fixed 256 MiB gzip-only cap with a configurable, bounded budget. - Registries: Docker Hub plus any registry host over HTTPS, with nested repository paths. Local registries (loopback names, non-public IPv4 literals including numeric spellings, single-label and .local hosts) refuse unless named with --allow-local-registry [http://]host[:port]; http:// is the only way to use plain HTTP. - Authentication: requests start anonymous; a 401 challenge gets an anonymous Bearer token first, then the host user's existing Docker credentials (credHelpers, credsStore, auths auth/username/identitytoken), read-only. Credentials and tokens stay in zeroizing memory, go only to the registry origin or its token realm, and never appear in output or errors. - Content: OCI and Docker v2 indexes and manifests; gzip, zstd (multi and skippable frames, via pure-Rust ruzstd) and uncompressed layers; foreign, non-distributable and external-URL layers refuse. Every object is checked against its descriptor digest and size while streaming to disk. - Budgets: per-image archive budget (default 2 GiB, 16 MiB to 8 GiB), decoded budget five times that, cache budget (default 16 GiB, at most 256 GiB) and 64 cache entries. An image over the budget or the cache's remainder refuses before any layer transfer. - Archives are validated in one streaming pass that hashes the validated bytes, and the engine upload withholds its tail until the re-read stream hashes identically, so memory no longer scales with archive size. - hack up passes HACK_NATIVE_IMAGE_BUDGET_MIB, HACK_NATIVE_IMAGE_CACHE_MIB, HACK_NATIVE_LOCAL_REGISTRIES and DOCKER_CONFIG as explicit flags and gives ensure-image the 10-minute request bound. Linux ARM64 selection, digest pinning, exclusive publication, cache no-prune and loader content-ID checks are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 122 +- docs/guides/prereleases.md | 7 +- docs/reference/native-authored-commands.md | 2 +- packages/runtime-core/Cargo.lock | 18 +- packages/runtime-core/Cargo.toml | 1 + packages/runtime-core/src/main.rs | 48 +- packages/runtime-core/src/provider/engine.rs | 9 +- .../runtime-core/src/provider/image_ensure.rs | 248 +++- .../runtime-core/src/provider/image_load.rs | 699 ++++++--- .../runtime-core/src/provider/image_policy.rs | 218 +++ .../src/provider/image_reference.rs | 6 +- packages/runtime-core/src/provider/mod.rs | 4 +- .../src/provider/registry_credentials.rs | 505 +++++++ .../src/provider/registry_image.rs | 1301 +++++++++++------ .../src/provider/registry_image_test.rs | 1117 ++++++++++++++ packages/runtime-core/tests/local_contract.rs | 27 +- src/backends/native-image-policy.ts | 112 ++ .../native-project-image-admission.ts | 20 +- .../native-project-restart-preflight.ts | 14 +- src/backends/native-project-start.ts | 14 +- tests/native-image-policy.test.ts | 130 ++ tests/native-project-start.test.ts | 10 + tests/native-refusal-cli.test.ts | 10 +- tests/prerelease-plan.test.ts | 2 +- 24 files changed, 3867 insertions(+), 777 deletions(-) create mode 100644 packages/runtime-core/src/provider/image_policy.rs create mode 100644 packages/runtime-core/src/provider/registry_credentials.rs create mode 100644 packages/runtime-core/src/provider/registry_image_test.rs create mode 100644 src/backends/native-image-policy.ts create mode 100644 tests/native-image-policy.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..88f90315d 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -620,14 +620,16 @@ selectors. Credentials are not written to Compose or the project snapshot, and are not automatically renewed after expiry. Normal native foreground `hack up` uses these adapters within the capability limits described below. -## Public image acquisition without Docker +## Registry image acquisition without Docker -The candidate can fetch a **digest-pinned public Docker Hub image** without Docker, -OrbStack, registry credentials, or a running guest: +The candidate can fetch a **digest-pinned image from any OCI distribution registry** +without Docker, OrbStack or a running guest: Docker Hub, GitHub Container Registry, +Google Artifact Registry and Container Registry, Quay, Amazon ECR, and private +registries on their own host names. ```sh ./hack-native --candidate-root /absolute/private/candidate-home runtime fetch-image \ - --reference '[namespace/]repository@sha256:REVIEWED_MANIFEST_DIGEST' \ + --reference '[registry/]repository@sha256:REVIEWED_MANIFEST_DIGEST' \ --archive /absolute/private/new-image.tar ``` @@ -635,15 +637,15 @@ For an existing mutable image declaration, resolve it explicitly first: ```sh ./hack-native --candidate-root /absolute/private/candidate-home runtime resolve-image \ - --reference '[namespace/]repository:tag' --json + --reference '[registry/]repository:tag' --json ``` Image references are normalized as the Docker CLI does before any request: `alpine` is `docker.io/library/alpine:latest`, `alpine:3.21` is `docker.io/library/alpine:3.21`, and `user/img` is `docker.io/user/img:latest`. -`index.docker.io` becomes `docker.io`. Tags, digests, other registries and -ports are kept as written; only public Docker Hub `namespace/repository` -references are acquired. Repository names must be lowercase, and a normalized +`index.docker.io` becomes `docker.io`. Tags, digests, registries, ports and +nested repository paths are kept as written; Docker Hub references must name a +`namespace/repository`. Repository names must be lowercase, and a normalized name is limited to 255 bytes. Uppercase digests and malformed registry ports are refused. Check a reference without network, credentials or candidate state: @@ -652,38 +654,93 @@ refused. Check a reference without network, credentials or candidate state: --reference 'alpine:3.21' --json ``` -It returns the canonical `reference` and a `kind` of `docker_hub` or -`image_id`; any other registry refuses with `registry_image`. Native foreground -`hack up` runs this admission for every authored service image before startup -hooks, the VM or any provider starts. Compose plan review applies the same -syntax rules, keeps the authored image text in the reviewed plan, and does not -change existing plan identities. +It returns the canonical `reference` and a `kind` of `docker_hub`, `registry` or +`image_id`; an unacquirable reference refuses with `registry_image`. Native +foreground `hack up` runs this admission for every authored service image before +startup hooks, the VM or any provider starts. Compose plan review applies the +same syntax rules, keeps the authored image text in the reviewed plan, and does +not change existing plan identities. + +### Local registries + +A registry on this host or its local network is refused unless it is named +exactly: `localhost` and `*.localhost`, loopback, private, link-local, CGNAT and +multicast IPv4 literals (including numeric spellings such as `0x7f.0.0.1`), +single-label hosts such as `registry:5000`, and mDNS `.local` names. Allow one +with `--allow-local-registry [http://]host[:port]` (repeatable) on `admit-image`, +`resolve-image`, `fetch-image` and `ensure-image`. Without `http://` the registry +must serve HTTPS; with it, plain HTTP is used for that registry only. Native +`hack up` reads a comma-separated list from `HACK_NATIVE_LOCAL_REGISTRIES`, for +example `HACK_NATIVE_LOCAL_REGISTRIES=http://localhost:5000`. + +### Registry credentials + +Requests start anonymous. When a registry answers `401`, the candidate first +requests an anonymous Bearer token, then uses the host user's existing Docker +credentials, in the Docker CLI's order: a `credHelpers` entry for the registry, +then `credsStore`, then an `auths` entry (`auth`, `username`/`password` or +`identitytoken`). Helpers run as `docker-credential-NAME get` from `PATH`; a +helper may show its own approval prompt, such as a keychain prompt, and is +stopped after 60 seconds. The configuration is `~/.docker/config.json`, or +`--docker-config DIR` (native `hack up` passes `DOCKER_CONFIG` when set). + +Credential lookup is read-only: nothing is logged in, stored, erased or rewritten. +Credentials and tokens stay in memory for one acquisition, are sent only to the +registry's own origin or its token endpoint, never to a redirected blob host, +and never appear in output, receipts or errors. Docker Hub tokens come only from +`auth.docker.io`. Other token endpoints must be the registry origin or a public +HTTPS host. Run `docker login` (or your registry's helper setup) yourself before +acquiring a private image. + +### Content, budgets and limits Omitting the tag selects `latest`. Resolution returns `pinned_reference`, `source_digest`, `manifest_digest`, `image_id` and `platform`; it downloads only bounded metadata and verifies one unambiguous Linux ARM64 image configuration. Pass the returned **pinned reference** to `fetch-image`, not the original tag. Resolution creates no archive or runtime state and does not prove that all image -layers fit the importer limits or that the application starts. A missing public -tag/object reports `registry_image_not_found`; no replacement image is selected. +layers fit the budget or that the application starts. A missing tag or object +reports `registry_image_not_found`; no replacement image is selected. The archive must not already exist. The result reports `source_digest`, the selected Linux ARM64 `manifest_digest`, config `image_id`, `archive_sha256`, and archive size. Pass that exact archive, hash and image ID to `runtime load-image` after preparing the candidate guest. Fetching and importing remain separate operations. -The fetch verifies the pinned index/manifest, the selected ARM64 manifest, every -compressed blob and config digest, and the expanded layer identities. It supports -only gzip layers, at most 128 layers, 256 MiB archived and 2 GiB expanded. HTTPS -requests have a 60-second limit within a 300-second acquisition deadline; redirects -are restricted to known Docker Hub HTTPS blob origins, with no bearer forwarding to -CDNs. It never reads Docker credentials or proxy settings. Mutable tags passed directly to fetch, private -registries, other registries and unsupported platforms fail closed. No implicit -image refresh occurs. Keep the archive only as long as needed for owned imports; -fetch does not install a background cache or cleanup daemon. The complete archive is synced and published without replacing an existing path. -A failed write leaves no final archive; process interruption may retain a hidden -temporary file beside the requested output. A directory-sync failure reports that -a complete archive was published but durability could not be confirmed. +The fetch accepts OCI and Docker v2 indexes and manifests. It verifies the pinned +index or manifest, the selected ARM64 manifest, the config and every layer against +its descriptor digest and size while streaming them to disk, then validates the +assembled archive again before publishing it. Layers may be gzip, zstd (including +multiple and skippable frames) or uncompressed tar, at most 128 per image; foreign, +non-distributable and external-URL layers refuse. The importer checks each decoded +layer against the config's `diff_ids`. + +Sizes are bounded by an explicit, configurable budget instead of a fixed cap: + +| Budget | Default | Range | Flag | `hack up` setting | +| --- | --- | --- | --- | --- | +| Compressed archive per image | 2 GiB | 16 MiB to 8 GiB | `--image-budget-mib N` | `HACK_NATIVE_IMAGE_BUDGET_MIB` | +| Decoded content per image | 5 times the archive budget | follows the archive budget | none | none | +| Image cache total | 16 GiB | 16 MiB to 256 GiB, at least one image budget | `--image-cache-mib N` | `HACK_NATIVE_IMAGE_CACHE_MIB` | + +The manifest states every layer size, so an image larger than the archive budget +or the cache's remaining budget refuses before any layer transfer or disk write. +`load-image` applies the same flags. The whole acquisition has a 360-second +deadline; each layer request allows at least 1 MiB/s. Native `hack up` gives one +`ensure-image` request 10 minutes for acquisition and import, and a completed +archive is reused on the next attempt. Redirects stay on the registry origin, +Docker Hub's known blob hosts, or (for other registries) public HTTPS hosts on +port 443; local destinations and embedded credentials refuse. Proxy settings are +not used. TLS uses the bundled public root certificates, so a registry signed by +a private certificate authority is not reachable yet. Host names are classified +before connecting, not after DNS resolution. + +No implicit image refresh occurs. Keep the archive only as long as needed for owned +imports; fetch does not install a background cache or cleanup daemon. The complete +archive is synced, validated and published without replacing an existing path. +A failed write or verification leaves no final archive; process interruption may +retain a hidden temporary file beside the requested output. A directory-sync failure +reports that a complete archive was published but durability could not be confirmed. ### Explicit writable development source @@ -946,12 +1003,15 @@ restored one-off remain open. An uncertain shutdown retains its mapping and evid for recovery; do not delete managed state to retry. After starting an owned native pool, `runtime ensure-image --reference REF --json` -resolves a public Docker Hub tag (including shorthand such as `alpine:3.21`) or +resolves a registry tag (including Docker Hub shorthand such as `alpine:3.21`) or accepts an explicit digest, reuses the verified archive cache, and imports through the existing content-ID-checked loader. Tags are resolved on each call; a warm explicit digest needs no registry request. -The cache is limited to 16 archives and 2 GiB; an incomplete or conflicting entry -requires inspection, and the command never prunes existing data. This command +It accepts the [image policy flags](#registry-image-acquisition-without-docker). +The cache holds at most 64 archives within its configured budget (16 GiB by +default); an incomplete or conflicting entry requires inspection, and the command +never prunes existing data. Lowering a budget keeps existing entries readable, but +the loader refuses an archive larger than the current per-image budget. This command is a startup building block, not evidence of complete normal `hack up` support. Explicit Compose `shm_size` supports positive sizes up to 1 GiB, including diff --git a/docs/guides/prereleases.md b/docs/guides/prereleases.md index c951ed42a..dffdc4ae3 100644 --- a/docs/guides/prereleases.md +++ b/docs/guides/prereleases.md @@ -45,9 +45,10 @@ These limits still apply to a packaged candidate: - Named users or groups for private environment delivery. They refuse instead of running as root. See the private delivery notes at the end of [explicit cleanup after a dead foreground owner](native-candidate.md#explicit-cleanup-after-a-dead-foreground-owner). -- Private registries, registries other than public Docker Hub, mutable tags passed - directly to `fetch-image`, and images without a Linux ARM64 variant. See - [public image acquisition without Docker](native-candidate.md#public-image-acquisition-without-docker). +- Local registries without an explicit opt-in, registries signed by a private + certificate authority, mutable tags passed directly to `fetch-image`, images over + the configured image budget, and images without a Linux ARM64 variant. See + [registry image acquisition without Docker](native-candidate.md#registry-image-acquisition-without-docker). - Copied or relocated candidate pools. See [manual candidate upgrade and rollback](native-candidate.md#manual-candidate-upgrade-and-rollback). - Downgrading onto newer state: an older candidate that refuses state written by diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 121d5293e..2ccb5f4ca 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -13,7 +13,7 @@ to use the [Compose command owner](native-compose-commands.md). This bounded native consumer requires each service's `image` to be an immutable `sha256:` image **content ID**, such as the `image_id` returned by a successful -[`runtime ensure-image`](../guides/native-candidate.md#public-image-acquisition-without-docker) +[`runtime ensure-image`](../guides/native-candidate.md#registry-image-acquisition-without-docker) acquisition. Keep that acquisition's pinned-reference and archive evidence. A registry tag or `repository@sha256:manifest-digest` is not the content ID. The native planner (`graph native plan` and `frontend-plan`) refuses it with the same diff --git a/packages/runtime-core/Cargo.lock b/packages/runtime-core/Cargo.lock index 3683026d1..156bcafff 100644 --- a/packages/runtime-core/Cargo.lock +++ b/packages/runtime-core/Cargo.lock @@ -391,6 +391,7 @@ dependencies = [ "notify", "reqwest", "rusqlite", + "ruzstd", "serde", "serde_json", "serde_yaml_ng", @@ -907,7 +908,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1103,6 +1104,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ruzstd" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1512,6 +1522,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + [[package]] name = "typenum" version = "1.20.1" diff --git a/packages/runtime-core/Cargo.toml b/packages/runtime-core/Cargo.toml index 2a64d1b25..4379e2abf 100644 --- a/packages/runtime-core/Cargo.toml +++ b/packages/runtime-core/Cargo.toml @@ -53,6 +53,7 @@ caseless = "=0.2.2" unicode-normalization = "=0.1.25" notify = "=8.2.0" flate2 = { version = "=1.1.10", default-features = false, features = ["rust_backend"] } +ruzstd = "=0.8.2" [profile.release] lto = "thin" diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 0ab86ffde..a0c8329f2 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -100,11 +100,12 @@ Usage: hack-local runtime prepared-base verify --base-id [--store ] [--json] hack-local runtime prepared-base status --profile research|development [--store ] [--json] hack-local runtime prepared-base remove --base-id [--store ] [--json] - hack-local runtime admit-image --reference <[namespace/]repository[:tag][@sha256:digest]|sha256:image-id> --json - hack-local runtime ensure-image --reference <[namespace/]repository[:tag]|[namespace/]repository@sha256:digest> --json - hack-local runtime resolve-image --reference <[namespace/]repository[:tag]> --json - hack-local runtime fetch-image --reference <[namespace/]repository@sha256:digest> --archive [--json] - hack-local runtime load-image --archive --sha256 --image-id + hack-local runtime admit-image --reference <[registry/]repository[:tag][@sha256:digest]|sha256:image-id> --json [IMAGE-POLICY] + hack-local runtime ensure-image --reference <[registry/]repository[:tag]|[registry/]repository@sha256:digest> --json [IMAGE-POLICY] + hack-local runtime resolve-image --reference <[registry/]repository[:tag]> --json [IMAGE-POLICY] + hack-local runtime fetch-image --reference <[registry/]repository@sha256:digest> --archive [--json] [IMAGE-POLICY] + hack-local runtime load-image --archive --sha256 --image-id [IMAGE-POLICY] + IMAGE-POLICY: [--image-budget-mib N] [--image-cache-mib N] [--allow-local-registry [http://]host[:port]]... [--docker-config ] hack-local runtime network internet --json hack-local runtime up --profile development --internet --json hack-local runtime network extend --allow-host [--allow-host ] --json @@ -935,16 +936,26 @@ fn run() -> Result<(), CandidateError> { "--reference", reference, "--json", + policy @ .., ] => { print_json(&hack_runtime_core::provider::image_ensure::ensure( &discover_candidate(&requested)?, reference, + &image_policy(policy)?, )?)?; } - ["runtime", "admit-image", "--reference", reference, "--json"] => { + [ + "runtime", + "admit-image", + "--reference", + reference, + "--json", + policy @ .., + ] => { // Pure: no candidate discovery, network, credentials or runtime effects. print_json(&hack_runtime_core::provider::registry_image::admit( reference, + &image_policy(policy)?, )?)?; } [ @@ -953,9 +964,11 @@ fn run() -> Result<(), CandidateError> { "--reference", reference, "--json", + policy @ .., ] => { print_json(&hack_runtime_core::provider::registry_image::resolve( reference, + &image_policy(policy)?, )?)?; } [ @@ -965,19 +978,14 @@ fn run() -> Result<(), CandidateError> { reference, "--archive", archive, - ] - | [ - "runtime", - "fetch-image", - "--reference", - reference, - "--archive", - archive, - "--json", + rest @ .., ] => { + let policy = image_policy(rest.strip_prefix(&["--json"]).unwrap_or(rest))?; print_json(&hack_runtime_core::provider::registry_image::acquire( reference, Path::new(archive), + &policy, + policy.archive_limit, )?)?; } [ @@ -989,12 +997,14 @@ fn run() -> Result<(), CandidateError> { digest, "--image-id", image, + policy @ .., ] => { - print_json(&hack_runtime_core::provider::load_image( + print_json(&hack_runtime_core::provider::load_image_with( &discover_candidate(&requested)?, Path::new(archive), digest, image, + &image_policy(policy)?, )?)?; } ["runtime", "host-filesystem-recovery"] @@ -1498,6 +1508,12 @@ fn print_sync_event( Ok(()) } +fn image_policy( + flags: &[&str], +) -> Result { + hack_runtime_core::provider::image_policy::ImagePolicy::from_flags(flags) +} + fn print_json(value: &impl Serialize) -> Result<(), CandidateError> { println!( "{}", diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index e0299cd9c..d8c4fbb10 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -256,7 +256,13 @@ impl<'a> Engine<'a> { serde_json::from_slice(&bytes) .map_err(|_| failure("Malformed engine response; values omitted.")) } - pub(super) fn load_image_archive(&self, archive: Vec) -> Result<(), CandidateError> { + /// Stream one verified archive to the engine's loader. `timeout` bounds the whole + /// upload and import; callers scale it with the archive size. + pub(super) fn load_image_archive( + &self, + archive: reqwest::blocking::Body, + timeout: Duration, + ) -> Result<(), CandidateError> { if self.cleanup_only { return Err(failure("A cleanup connection cannot load images.")); } @@ -267,6 +273,7 @@ impl<'a> Engine<'a> { .client .post("http://hack-local/v1.53/images/load?quiet=true") .header("Content-Type", "application/x-tar") + .timeout(timeout) .body(archive) .send() .map_err(|_| { diff --git a/packages/runtime-core/src/provider/image_ensure.rs b/packages/runtime-core/src/provider/image_ensure.rs index fe2c06af1..fbd5f72c3 100644 --- a/packages/runtime-core/src/provider/image_ensure.rs +++ b/packages/runtime-core/src/provider/image_ensure.rs @@ -1,5 +1,9 @@ //! Candidate-owned bounded image acquisition cache; import remains image_load's operation. -use super::{engine, image_load, registry_image, state}; +use super::{ + engine, image_load, + image_policy::{ARCHIVE_CEILING, CACHE_CEILING, ImagePolicy}, + registry_image, state, +}; use crate::{Candidate, CandidateError, reject_aliased_state}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -9,9 +13,10 @@ use std::{ os::unix::fs::{MetadataExt, OpenOptionsExt}, path::{Path, PathBuf}, }; -const MAX_ARCHIVES: usize = 16; -const MAX_ARCHIVE: u64 = 256 * 1024 * 1024; -const MAX_CACHE: u64 = 2 * 1024 * 1024 * 1024; +/// Content-addressed archives one candidate's cache may hold. +const MAX_ARCHIVES: usize = 64; +/// A new slot needs at least this much remaining cache budget. +const MIN_SLOT: u64 = 1024 * 1024; fn refused(message: &str) -> CandidateError { CandidateError::new("image_cache", message) } @@ -28,9 +33,10 @@ fn hex(value: &str) -> bool { fn key(value: &str) -> String { format!("{:x}", Sha256::digest(value.as_bytes())) } +/// Canonical pinned form by syntax alone, so recorded entries stay readable when the +/// policy that admitted their registry changes; new acquisition admits under the policy. fn canonical(value: &str) -> Result { - let (repo, pin) = registry_image::reference(value)?; - Ok(format!("docker.io/{repo}@{pin}")) + registry_image::pinned_syntax(value).map(|(canonical, _)| canonical) } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -51,7 +57,7 @@ impl CacheReceipt { || !hex(&self.archive_sha256) || !self.image_id.strip_prefix("sha256:").is_some_and(hex) || self.archive_bytes == 0 - || self.archive_bytes > MAX_ARCHIVE + || self.archive_bytes > ARCHIVE_CEILING { return Err(refused( "Conflicting image cache receipt; no entry was adopted or removed.", @@ -93,7 +99,7 @@ fn archive_size(path: &Path) -> Result { || m.uid() != current_uid() || m.mode() & 0o077 != 0 || m.len() == 0 - || m.len() > MAX_ARCHIVE + || m.len() > ARCHIVE_CEILING { return Err(refused( "Unsafe image archive cache entry; no files were removed.", @@ -146,7 +152,7 @@ fn usage(candidate: &Candidate, root: &Path) -> Result<(usize, u64), CandidateEr count += 1; if count > MAX_ARCHIVES || !name.to_str().is_some_and(hex) { return Err(refused( - "Image cache permits 16 content-addressed archives and 2 GiB total; unexpected entries refuse admission.", + "Image cache permits 64 content-addressed archives; unexpected entries refuse admission.", )); } let dir = entry.path(); @@ -158,9 +164,9 @@ fn usage(candidate: &Candidate, root: &Path) -> Result<(usize, u64), CandidateEr bytes = bytes .checked_add(inspect_entry(candidate, &dir, &receipt.reference)?.archive_bytes) .ok_or_else(|| refused("Image cache size overflow."))?; - if bytes > MAX_CACHE { + if bytes > CACHE_CEILING { return Err(refused( - "Image cache exceeds its 2 GiB budget; no files were removed.", + "Image cache exceeds its fixed ceiling; no files were removed.", )); } } @@ -176,34 +182,45 @@ pub struct EnsuredImage { } /// Resolve tags on every call, reuse their exact immutable archive when available, /// and validate/import via the owned guest's existing loader. The engine must already -/// be running before acquisition. A cache miss reserves one 256 MiB slot within a -/// 16-archive/2 GiB budget; this operation never prunes or adopts conflicting files. -/// Runtime ownership is checked again by load_image after network work. -pub fn ensure(candidate: &Candidate, reference: &str) -> Result { - // Validate input before touching runtime state; tags are checked by resolve after - // the engine preflight, and pinned references never make a mutable resolution call. +/// be running before acquisition. A cache miss acquires at most the policy's per-image +/// budget and the cache's remaining budget (at most 64 archives); an image whose +/// manifest exceeds that refuses before any layer transfer. This operation never prunes +/// or adopts conflicting files. Runtime ownership is checked again by the loader after +/// network work. +pub fn ensure( + candidate: &Candidate, + reference: &str, + policy: &ImagePolicy, +) -> Result { + // Validate input, including registry admission, before touching runtime state; tags + // are resolved after the engine preflight, and pinned references never make a + // mutable resolution call. if reference.contains('@') { - canonical(reference)?; + registry_image::pinned(reference, policy)?; } else { - registry_image::tagged_reference(reference)?; + registry_image::tagged(reference, policy)?; } engine::info(candidate)?; let pinned = if reference.contains('@') { - canonical(reference)? + registry_image::pinned(reference, policy)?.canonical() } else { - registry_image::resolve(reference)?.pinned_reference + registry_image::resolve(reference, policy)?.pinned_reference }; ensure_pinned( candidate, &pinned, - registry_image::acquire, - |candidate, path, sha, image| image_load::load(candidate, path, sha, image).map(|_| ()), + policy, + |reference, path, limit| registry_image::acquire(reference, path, policy, limit), + |candidate, path, sha, image| { + image_load::load_with(candidate, path, sha, image, policy).map(|_| ()) + }, ) } fn ensure_pinned( candidate: &Candidate, reference: &str, - acquire: impl FnOnce(&str, &Path) -> Result, + policy: &ImagePolicy, + acquire: impl FnOnce(&str, &Path, u64) -> Result, load: impl FnOnce(&Candidate, &Path, &str, &str) -> Result<(), CandidateError>, ) -> Result { let reference = canonical(reference)?; @@ -215,21 +232,24 @@ fn ensure_pinned( let receipt = if reused { inspect_entry(candidate, &dir, &reference)? } else { - if count >= MAX_ARCHIVES || bytes > MAX_CACHE - MAX_ARCHIVE { + let limit = policy + .archive_limit + .min(policy.cache_limit.saturating_sub(bytes)); + if count >= MAX_ARCHIVES || limit < MIN_SLOT { return Err(refused( - "Image cache admission requires a free 256 MiB slot within 16 archives and 2 GiB total; no automatic deletion.", + "Image cache admission requires a free slot within 64 archives and the configured cache budget; no automatic deletion.", )); } state::private_directory(&dir)?; - let acquired = match acquire(&reference, &dir.join("image.tar")) { + let acquired = match acquire(&reference, &dir.join("image.tar"), limit) { Ok(value) => value, Err(error) => { let _ = fs::remove_dir(&dir); return Err(error); } }; - let (_, pin) = registry_image::reference(&reference)?; - if acquired.source_digest != pin.as_str() { + let (_, pin) = registry_image::pinned_syntax(&reference)?; + if acquired.source_digest != pin.as_str() || acquired.archive_bytes > limit { return Err(refused( "Acquired image does not match the requested immutable reference.", )); @@ -299,11 +319,12 @@ mod tests { fn acquire( reference: &str, path: &Path, + _limit: u64, ) -> Result { fs::write(path, b"fixture").unwrap(); fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); Ok(registry_image::AcquiredImage { - source_digest: registry_image::reference(reference)?.1, + source_digest: registry_image::pinned_syntax(reference)?.1, manifest_digest: format!("sha256:{}", "b".repeat(64)), image_id: format!("sha256:{}", "c".repeat(64)), archive_sha256: key("fixture"), @@ -319,9 +340,10 @@ mod tests { let result = ensure_pinned( &f.1, &pin(1), - |reference, path| { + &ImagePolicy::default(), + |reference, path, limit| { acquired.set(acquired.get() + 1); - acquire(reference, path) + acquire(reference, path, limit) }, |_, path, sha, image| { loaded.set(loaded.get() + 1); @@ -341,27 +363,52 @@ mod tests { fn cache_count_budget_refuses_without_deleting_existing_archives() { let f = Fixture::new(); for n in 0..MAX_ARCHIVES { - ensure_pinned(&f.1, &pin(n), acquire, |_, _, _, _| Ok(())).unwrap(); + ensure_pinned( + &f.1, + &pin(n), + &ImagePolicy::default(), + acquire, + |_, _, _, _| Ok(()), + ) + .unwrap(); } let error = ensure_pinned( &f.1, &pin(99), - |_, _| panic!("must not acquire"), + &ImagePolicy::default(), + |_, _, _| panic!("must not acquire"), |_, _, _, _| panic!("must not load"), ) .unwrap_err(); - assert!(error.message.contains("16 archives")); + assert!(error.message.contains("64 archives")); assert_eq!( usage(&f.1, &f.1.state_root.join("artifacts/images")).unwrap(), - (16, 112) + (64, 448) ); } #[test] - fn aggregate_byte_budget_refuses_a_new_slot_without_pruning() { + fn aggregate_byte_budget_bounds_each_acquisition_and_refuses_a_full_cache_without_pruning() { let f = Fixture::new(); - for n in 0..8 { + let slot = 4 * MIN_SLOT; + let policy = ImagePolicy { + archive_limit: slot, + cache_limit: 2 * slot + MIN_SLOT / 2, + ..ImagePolicy::default() + }; + let mut limits = Vec::new(); + for n in 0..2 { let reference = pin(n); - ensure_pinned(&f.1, &reference, acquire, |_, _, _, _| Ok(())).unwrap(); + ensure_pinned( + &f.1, + &reference, + &policy, + |reference, path, limit| { + limits.push(limit); + acquire(reference, path, limit) + }, + |_, _, _, _| Ok(()), + ) + .unwrap(); let dir = f.1.state_root .join("artifacts/images") @@ -370,31 +417,80 @@ mod tests { .write(true) .open(dir.join("image.tar")) .unwrap() - .set_len(MAX_ARCHIVE) + .set_len(slot) .unwrap(); let mut receipt = read_receipt(&dir.join("receipt.json")).unwrap(); - receipt.archive_bytes = MAX_ARCHIVE; + receipt.archive_bytes = slot; state::write(&dir.join("receipt.json"), &receipt).unwrap(); } + // Each acquisition is offered the per-image budget or the cache's remainder. + assert_eq!(limits, [slot, slot]); assert_eq!( usage(&f.1, &f.1.state_root.join("artifacts/images")).unwrap(), - (8, MAX_CACHE) + (2, 2 * slot) ); assert!( ensure_pinned( &f.1, &pin(99), - |_, _| panic!("budget refuses fetch"), + &policy, + |_, _, _| panic!("budget refuses fetch"), |_, _, _, _| panic!("no load") ) .is_err() ); + // Budgets below the recorded entries keep them readable: cache admission does not + // reject them, and the loader applies its own per-image budget on import. + let lowered = ImagePolicy { + archive_limit: 2 * MIN_SLOT, + cache_limit: 2 * MIN_SLOT, + ..ImagePolicy::default() + }; + assert!( + ensure_pinned( + &f.1, + &pin(0), + &lowered, + |_, _, _| panic!("reuse"), + |_, _, _, _| Ok(()) + ) + .unwrap() + .archive_reused + ); + // A larger cache budget offers only its remainder, and an acquisition that + // reports more than it was offered is refused rather than recorded; + // its unreceipted slot is left for explicit inspection. + let wider = ImagePolicy { + cache_limit: 2 * slot + 2 * MIN_SLOT, + ..policy.clone() + }; + let error = ensure_pinned( + &f.1, + &pin(98), + &wider, + |reference, path, limit| { + assert_eq!(limit, 2 * MIN_SLOT); + let mut acquired = acquire(reference, path, limit)?; + acquired.archive_bytes = limit + 1; + Ok(acquired) + }, + |_, _, _, _| panic!("no load"), + ) + .unwrap_err(); + assert_eq!(error.code, "image_cache"); } #[test] fn conflicting_and_unreceipted_cache_entries_refuse_before_load() { let f = Fixture::new(); let reference = pin(1); - ensure_pinned(&f.1, &reference, acquire, |_, _, _, _| Ok(())).unwrap(); + ensure_pinned( + &f.1, + &reference, + &ImagePolicy::default(), + acquire, + |_, _, _, _| Ok(()), + ) + .unwrap(); let dir = f.1.state_root .join("artifacts/images") @@ -404,7 +500,8 @@ mod tests { ensure_pinned( &f.1, &reference, - |_, _| panic!("no acquire"), + &ImagePolicy::default(), + |_, _, _| panic!("no acquire"), |_, _, _, _| panic!("no load") ) .is_err() @@ -414,7 +511,8 @@ mod tests { ensure_pinned( &f.1, &reference, - |_, _| panic!("no acquire"), + &ImagePolicy::default(), + |_, _, _| panic!("no acquire"), |_, _, _, _| panic!("no load") ) .is_err() @@ -429,7 +527,8 @@ mod tests { ensure_pinned( &f.1, &reference, - |_, _| Err(refused("synthetic fetch failure")), + &ImagePolicy::default(), + |_, _, _| Err(refused("synthetic fetch failure")), |_, _, _, _| panic!("no load") ) .is_err() @@ -441,15 +540,20 @@ mod tests { .exists() ); assert!( - ensure_pinned(&f.1, &reference, acquire, |_, _, _, _| Err(refused( - "synthetic import failure" - ))) + ensure_pinned( + &f.1, + &reference, + &ImagePolicy::default(), + acquire, + |_, _, _, _| Err(refused("synthetic import failure")) + ) .is_err() ); let receipt = ensure_pinned( &f.1, &reference, - |_, _| panic!("reuse complete archive"), + &ImagePolicy::default(), + |_, _, _| panic!("reuse complete archive"), |_, _, _, _| Ok(()), ) .unwrap(); @@ -458,19 +562,35 @@ mod tests { #[test] fn invalid_input_or_absent_runtime_never_allocates_archive_cache() { let f = Fixture::new(); - assert!(ensure(&f.1, "untrusted/thing:bad tag").is_err()); - assert!(ensure(&f.1, &pin(1)).is_err()); - assert!(ensure(&f.1, "fixture/image:latest").is_err()); - // Docker Hub shorthand passes reference admission and stops at the absent - // runtime; an unacquirable registry refuses before any runtime check. - assert_ne!( - ensure(&f.1, "alpine:3.21").unwrap_err().code, - "registry_image" - ); - assert_eq!( - ensure(&f.1, "ghcr.io/fixture/image:1").unwrap_err().code, - "registry_image" - ); + let policy = ImagePolicy::default(); + assert!(ensure(&f.1, "untrusted/thing:bad tag", &policy).is_err()); + assert!(ensure(&f.1, &pin(1), &policy).is_err()); + assert!(ensure(&f.1, "fixture/image:latest", &policy).is_err()); + // Docker Hub shorthand and other registries pass reference admission and stop at + // the absent runtime; a local registry that is not allowed refuses before any + // runtime check. + for admitted in [ + "alpine:3.21", + "ghcr.io/fixture/image:1", + "registry.test:5000/a/b/c:1", + ] { + assert_ne!( + ensure(&f.1, admitted, &policy).unwrap_err().code, + "registry_image", + "{admitted}" + ); + } + for local in [ + "localhost:5000/fixture:1", + "127.0.0.1:5000/fixture:1", + "registry:5000/fixture:1", + ] { + assert_eq!( + ensure(&f.1, local, &policy).unwrap_err().code, + "registry_image", + "{local}" + ); + } assert!(!f.1.state_root.join("artifacts/images").exists()); } #[test] diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index b49e9781e..b6ec9b0f2 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -1,22 +1,44 @@ //! Explicit, bounded local image archives; no registry credentials or Docker contexts. -use super::{engine::Engine, state}; +//! +//! An archive is validated in one streaming pass that also hashes every byte, so the +//! validated bytes are exactly the bytes whose SHA-256 the caller expected. The upload +//! to the engine re-reads the file through a reader that withholds its final bytes until +//! the complete stream has hashed to the same value; a file changed after validation +//! cannot complete an import. Memory use is bounded by small metadata members, not by +//! archive size. +use super::{engine::Engine, image_policy::ImagePolicy, state}; use crate::{Candidate, CandidateError}; use flate2::read::MultiGzDecoder; use reqwest::Method; +use ruzstd::decoding::{ + StreamingDecoder, + errors::{FrameDecoderError, ReadFrameHeaderError}, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, fs::OpenOptions, - io::{Cursor, Read, Seek}, + io::{BufRead, BufReader, Read, Seek}, os::unix::fs::{MetadataExt, OpenOptionsExt}, path::{Component, Path, PathBuf}, time::Duration, }; -const MAX_ARCHIVE: u64 = 256 * 1024 * 1024; -const MAX_EXPANDED: u64 = 2 * 1024 * 1024 * 1024; +/// Members at most this large whose content is raw JSON are retained for metadata checks. +const MAX_METADATA_MEMBER: u64 = 1024 * 1024; +/// Total retained metadata bytes per archive. +const MAX_RETAINED_METADATA: usize = 4 * 1024 * 1024; +/// Upload and import time allowed beyond the size-scaled transfer time. +const LOAD_BASE_TIMEOUT: Duration = Duration::from_secs(40); +/// Assumed slowest acceptable engine import rate when scaling the load timeout. +const LOAD_BYTES_PER_SECOND: u64 = 8 * 1024 * 1024; +/// Ceiling for one load request, so `ensure-image` stays inside its caller's bound. +const LOAD_MAX_TIMEOUT: Duration = Duration::from_secs(220); +/// Bytes the upload reader withholds until the whole archive has hashed correctly. +const HELD_TAIL: usize = 64 * 1024; + fn error(message: &str) -> CandidateError { CandidateError::new("image_archive", message) } @@ -58,22 +80,88 @@ fn verify_digest( Ok(total) } -fn read_verified_archive( - file: &mut (impl Read + Seek), - expected: &str, -) -> Result, CandidateError> { - file.rewind() - .map_err(|_| error("Cannot rewind the image archive."))?; - let mut bytes = Vec::new(); - file.take(MAX_ARCHIVE + 1) - .read_to_end(&mut bytes) - .map_err(|_| error("Cannot read the image archive."))?; - // Retain the original descriptor and verify the bytes actually being validated/imported. - // A changed file between the streaming check and this read cannot reuse the earlier hash. - if bytes.len() as u64 > MAX_ARCHIVE || hash(&bytes) != expected { - return Err(error("Image archive digest mismatch.")); +/// Hashes and counts every byte read through it, refusing more than `limit`. +struct Hashing { + inner: R, + digest: Sha256, + total: u64, + limit: u64, +} +impl Read for Hashing { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buffer)?; + self.total += n as u64; + if self.total > self.limit { + return Err(std::io::Error::other("archive limit")); + } + self.digest.update(&buffer[..n]); + Ok(n) + } +} + +/// The upload side of an import: releases bytes only while at least [`HELD_TAIL`] bytes +/// remain withheld, and releases the tail only after the complete stream has the expected +/// length and SHA-256. A changed file therefore fails the upload before its tar ends. +struct HeldTail { + inner: R, + digest: Sha256, + total: u64, + expected_len: u64, + expected: String, + held: Vec, + verified: bool, +} +impl HeldTail { + fn new(inner: R, expected_len: u64, expected: &str) -> Self { + Self { + inner, + digest: Sha256::new(), + total: 0, + expected_len, + expected: expected.to_owned(), + held: Vec::new(), + verified: false, + } + } +} +impl Read for HeldTail { + fn read(&mut self, out: &mut [u8]) -> std::io::Result { + if out.is_empty() { + return Ok(0); + } + loop { + let releasable = if self.verified { + self.held.len() + } else { + self.held.len().saturating_sub(HELD_TAIL) + }; + if releasable > 0 { + let n = releasable.min(out.len()); + out[..n].copy_from_slice(&self.held[..n]); + self.held.drain(..n); + return Ok(n); + } + if self.verified { + return Ok(0); + } + let mut buffer = [0_u8; 64 * 1024]; + let n = self.inner.read(&mut buffer)?; + if n == 0 { + let actual = format!("{:x}", std::mem::take(&mut self.digest).finalize()); + if self.total != self.expected_len || actual != self.expected { + return Err(std::io::Error::other("image archive changed")); + } + self.verified = true; + continue; + } + self.total += n as u64; + if self.total > self.expected_len { + return Err(std::io::Error::other("image archive changed")); + } + self.digest.update(&buffer[..n]); + self.held.extend_from_slice(&buffer[..n]); + } } - Ok(bytes) } #[derive(Deserialize)] @@ -84,69 +172,180 @@ struct Manifest { repo_tags: Option>, } -fn archive_member(bytes: &[u8], name: &str, limit: u64) -> Result, CandidateError> { - let mut archive = tar::Archive::new(Cursor::new(bytes)); - for entry in archive - .entries() - .map_err(|_| error("Invalid image tar archive."))? - { - let entry = entry.map_err(|_| error("Invalid image tar entry."))?; - if entry - .path() - .map_err(|_| error("Invalid image archive path."))? - .as_ref() - == Path::new(name) - { - if entry.size() > limit { - return Err(error("Image metadata exceeds its limit.")); +/// Feed the decoded content of one member to `sink`, detecting compression from its +/// first bytes as the engine's loader does: gzip, zstd (including multiple and skippable +/// frames) or an uncompressed tar. Returns true when the member was not compressed. +fn decode_member( + member: impl Read, + mut sink: impl FnMut(&[u8]) -> Result<(), CandidateError>, +) -> Result { + let mut member = BufReader::with_capacity(64 * 1024, member); + let head = member + .fill_buf() + .map_err(|_| error("Cannot read an image archive member."))?; + let (gzip, zstd) = ( + head.starts_with(&[0x1f, 0x8b]), + head.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]), + ); + let mut buffer = vec![0_u8; 64 * 1024]; + let mut pump = |mut reader: Box| -> Result<(), CandidateError> { + loop { + let n = reader + .read(&mut buffer) + .map_err(|_| error("Cannot decode the image layer."))?; + if n == 0 { + return Ok(()); } - let mut result = Vec::new(); - entry - .take(limit + 1) - .read_to_end(&mut result) - .map_err(|_| error("Cannot read image metadata."))?; - if result.len() as u64 > limit { - return Err(error("Image metadata exceeds its limit.")); + sink(&buffer[..n])?; + } + }; + if gzip { + return pump(Box::new(MultiGzDecoder::new(member))).map(|()| false); + } + if !zstd { + return pump(Box::new(member)).map(|()| true); + } + loop { + if member + .fill_buf() + .map_err(|_| error("Cannot read an image archive member."))? + .is_empty() + { + return Ok(false); + } + match StreamingDecoder::new(&mut member) { + Ok(decoder) => pump(Box::new(decoder))?, + Err(FrameDecoderError::ReadFrameHeaderError(ReadFrameHeaderError::SkipFrame { + length, + .. + })) => { + let skipped = std::io::copy( + &mut (&mut member).take(u64::from(length)), + &mut std::io::sink(), + ) + .map_err(|_| error("Cannot decode the image layer."))?; + if skipped != u64::from(length) { + return Err(error("Cannot decode the image layer.")); + } } - return Ok(result); + Err(_) => return Err(error("Cannot decode the image layer.")), } } - Err(error("Image archive member is missing.")) } -fn validate_archive(bytes: &[u8], image: &str, max_expanded: u64) -> Result<(), CandidateError> { - if !image.strip_prefix("sha256:").is_some_and(hex) || bytes.len() as u64 > MAX_ARCHIVE { +/// One validated member: the SHA-256 of its decoded content and, for small raw JSON +/// members, the content itself. +struct Member { + decoded: String, + retained: Option>, +} + +/// Validate one single-image tarball in a single streaming pass and return its length. +/// +/// The stream must hash to `expected_sha` within `archive_limit` bytes. Members must be +/// a flat set of unique regular files named by exactly one `manifest.json` entry; the +/// config must hash to `image` and target Linux ARM64; each layer's decoded content +/// must match the config's `diff_ids`; total decoded bytes stay within `expanded_limit`. +fn validate_stream( + reader: impl Read, + expected_sha: &str, + image: &str, + archive_limit: u64, + expanded_limit: u64, +) -> Result { + if !image.strip_prefix("sha256:").is_some_and(hex) || !hex(expected_sha) { return Err(error("Invalid image identity or archive size.")); } - let mut names = BTreeSet::new(); - let mut archive = tar::Archive::new(Cursor::new(bytes)); - for entry in archive - .entries() - .map_err(|_| error("Invalid image archive."))? + let mut hashing = Hashing { + inner: reader, + digest: Sha256::new(), + total: 0, + limit: archive_limit, + }; + let mut members = BTreeMap::new(); + let mut expanded = 0_u64; + let mut retained_total = 0_usize; { - let entry = entry.map_err(|_| error("Invalid image tar entry."))?; - let path = entry - .path() - .map_err(|_| error("Invalid image archive path."))?; - let name = path - .to_str() - .ok_or_else(|| error("Image archive paths must be UTF-8."))?; - if !entry.header().entry_type().is_file() - || name.len() > 256 - || name.chars().any(char::is_control) - || path.components().count() != 1 - || !path.components().all(|p| matches!(p, Component::Normal(_))) - || !names.insert(name.to_owned()) - || names.len() > 130 + let mut archive = tar::Archive::new(&mut hashing); + for entry in archive + .entries() + .map_err(|_| error("Invalid image archive."))? { - return Err(error( - "Only a flat, single-image tarball with unique regular members is supported.", - )); + let entry = entry.map_err(|_| error("Invalid image tar entry."))?; + let path = entry + .path() + .map_err(|_| error("Invalid image archive path."))? + .into_owned(); + let name = path + .to_str() + .ok_or_else(|| error("Image archive paths must be UTF-8."))? + .to_owned(); + if !entry.header().entry_type().is_file() + || name.len() > 256 + || name.chars().any(char::is_control) + || path.components().count() != 1 + || !path.components().all(|p| matches!(p, Component::Normal(_))) + || members.contains_key(&name) + || members.len() >= 130 + { + return Err(error( + "Only a flat, single-image tarball with unique regular members is supported.", + )); + } + let small = entry.size() <= MAX_METADATA_MEMBER; + let mut digest = Sha256::new(); + let mut retained = small.then(Vec::new); + let mut first = true; + let mut raw_json = false; + let uncompressed = decode_member(entry, |bytes| { + expanded += bytes.len() as u64; + if expanded > expanded_limit { + return Err(error("Image expansion exceeds its configured budget.")); + } + digest.update(bytes); + if first { + first = false; + raw_json = matches!(bytes.first(), Some(b'{' | b'[')); + } + if let Some(kept) = retained.as_mut().filter(|_| raw_json) { + kept.extend_from_slice(bytes); + if kept.len() as u64 > MAX_METADATA_MEMBER { + retained = None; + } + } + Ok(()) + })?; + let retained = retained.filter(|kept| uncompressed && raw_json && !kept.is_empty()); + if let Some(kept) = &retained { + retained_total += kept.len(); + if retained_total > MAX_RETAINED_METADATA { + return Err(error("Image archive metadata exceeds its limit.")); + } + } + members.insert( + name, + Member { + decoded: format!("sha256:{:x}", digest.finalize()), + retained, + }, + ); } } - let manifest: Vec = - serde_json::from_slice(&archive_member(bytes, "manifest.json", 64 * 1024)?) - .map_err(|_| error("Invalid image manifest."))?; + // Bytes after the end-of-archive marker are hashed too; the expected digest covers them. + std::io::copy(&mut hashing, &mut std::io::sink()) + .map_err(|_| error("Image archive exceeds its byte limit."))?; + if format!("{:x}", hashing.digest.finalize()) != expected_sha { + return Err(error("Image archive digest mismatch.")); + } + let metadata = |name: &str, limit: usize| { + members + .get(name) + .and_then(|m| m.retained.as_deref()) + .filter(|bytes| bytes.len() <= limit) + .ok_or_else(|| error("Image archive metadata member is missing or too large.")) + }; + let manifest: Vec = serde_json::from_slice(metadata("manifest.json", 64 * 1024)?) + .map_err(|_| error("Invalid image manifest."))?; let [manifest] = manifest.as_slice() else { return Err(error("An archive must contain exactly one image.")); }; @@ -159,21 +358,26 @@ fn validate_archive(bytes: &[u8], image: &str, max_expanded: u64) -> Result<(), { return Err(error("Image manifest exceeds its limits.")); } - let expected: BTreeSet<_> = std::iter::once("manifest.json".to_owned()) - .chain(std::iter::once(manifest.config.clone())) - .chain(manifest.layers.iter().cloned()) + let expected: BTreeSet<_> = std::iter::once("manifest.json") + .chain(std::iter::once(manifest.config.as_str())) + .chain(manifest.layers.iter().map(String::as_str)) .collect(); - if expected != names || expected.len() != manifest.layers.len() + 2 { + if expected.len() != manifest.layers.len() + 2 + || !expected + .iter() + .copied() + .eq(members.keys().map(String::as_str)) + { return Err(error("Image manifest membership is inconsistent.")); } - let config_bytes = archive_member(bytes, &manifest.config, 1024 * 1024)?; - if format!("sha256:{}", hash(&config_bytes)) != image { + let config_bytes = metadata(&manifest.config, MAX_METADATA_MEMBER as usize)?; + if format!("sha256:{}", hash(config_bytes)) != image { return Err(error( "Image configuration does not match the expected content ID.", )); } let config: Value = - serde_json::from_slice(&config_bytes).map_err(|_| error("Invalid image configuration."))?; + serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; if config["architecture"] != "arm64" || config["os"] != "linux" { return Err(error( "Image architecture is incompatible with this provider.", @@ -183,52 +387,38 @@ fn validate_archive(bytes: &[u8], image: &str, max_expanded: u64) -> Result<(), .as_array() .filter(|v| v.len() == manifest.layers.len()) .ok_or_else(|| error("Image layer identities are missing."))?; - let mut expanded = 0_u64; - let mut archive = tar::Archive::new(Cursor::new(bytes)); - for entry in archive - .entries() - .map_err(|_| error("Invalid image archive."))? - { - let entry = entry.map_err(|_| error("Invalid image layer."))?; - let path = entry - .path() - .map_err(|_| error("Invalid image layer path."))?; - let Some(index) = manifest - .layers - .iter() - .position(|p| Path::new(p) == path.as_ref()) - else { - continue; - }; - let remaining = max_expanded - .checked_sub(expanded) - .ok_or_else(|| error("Image expansion exceeds 2 GiB."))?; - // This narrow loader accepts the compressed layers emitted by crane tarball. - let mut decoder = MultiGzDecoder::new(entry).take(remaining + 1); - let mut digest = Sha256::new(); - let mut buffer = [0_u8; 64 * 1024]; - loop { - let n = decoder - .read(&mut buffer) - .map_err(|_| error("Cannot decode the image layer."))?; - if n == 0 { - break; - } - expanded += n as u64; - if expanded > max_expanded { - return Err(error("Image expansion exceeds 2 GiB.")); - } - digest.update(&buffer[..n]); - } - if diffs[index] != format!("sha256:{:x}", digest.finalize()) { + for (layer, diff) in manifest.layers.iter().zip(diffs) { + if members.get(layer).map(|m| m.decoded.as_str()) != diff.as_str() { return Err(error("Expanded image layer identity is incorrect.")); } } - Ok(()) + Ok(hashing.total) } -pub(super) fn validate_download(bytes: &[u8], image: &str) -> Result<(), CandidateError> { - validate_archive(bytes, image, MAX_EXPANDED) +/// Validate an archive file the caller just wrote, under the policy's budgets. +pub(super) fn validate_file( + path: &Path, + expected_sha: &str, + image: &str, + policy: &ImagePolicy, +) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|_| error("Cannot open the image archive."))?; + validate_stream( + file, + expected_sha, + image, + policy.archive_limit, + policy.expanded_limit, + ) +} + +/// The bound for one engine load request of `bytes`. +fn load_timeout(bytes: u64) -> Duration { + (LOAD_BASE_TIMEOUT + Duration::from_secs(bytes / LOAD_BYTES_PER_SECOND)).min(LOAD_MAX_TIMEOUT) } #[derive(Serialize, Deserialize)] @@ -292,13 +482,38 @@ fn connect_for_import( Engine::connect(candidate) } +/// Import one explicit archive under the default image budgets. pub fn load( candidate: &Candidate, path: &Path, expected_sha: &str, image: &str, ) -> Result { - load_waiting(candidate, path, expected_sha, image, IMPORT_LEASE_WAIT) + load_with( + candidate, + path, + expected_sha, + image, + &ImagePolicy::default(), + ) +} + +/// Import one explicit archive whose size and expansion fit `policy`'s budgets. +pub fn load_with( + candidate: &Candidate, + path: &Path, + expected_sha: &str, + image: &str, + policy: &ImagePolicy, +) -> Result { + load_waiting( + candidate, + path, + expected_sha, + image, + policy, + IMPORT_LEASE_WAIT, + ) } fn load_waiting( @@ -306,6 +521,7 @@ fn load_waiting( path: &Path, expected_sha: &str, image: &str, + policy: &ImagePolicy, lease_wait: Duration, ) -> Result { if !hex(expected_sha) || !image.strip_prefix("sha256:").is_some_and(hex) { @@ -319,12 +535,12 @@ fn load_waiting( let metadata = file .metadata() .map_err(|_| error("Cannot inspect the image archive."))?; - if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > MAX_ARCHIVE { + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > policy.archive_limit { return Err(error( - "Image input must be a singly linked regular file no larger than 256 MiB.", + "Image input must be a singly linked regular file within the configured image budget.", )); } - if verify_digest(&mut file, expected_sha, MAX_ARCHIVE)? != metadata.len() { + if verify_digest(&mut file, expected_sha, policy.archive_limit)? != metadata.len() { return Err(error("Image archive size changed during verification.")); } let engine = connect_for_import(candidate, lease_wait)?; @@ -371,11 +587,31 @@ fn load_waiting( )); } } - let bytes = read_verified_archive(&mut file, expected_sha)?; - validate_archive(&bytes, image, MAX_EXPANDED)?; + // Validate exactly the bytes that hash to the expected value, then stream the same file; + // the upload withholds its tail until the re-read stream hashes identically. + file.rewind() + .map_err(|_| error("Cannot rewind the image archive."))?; + let length = validate_stream( + &mut file, + expected_sha, + image, + policy.archive_limit, + policy.expanded_limit, + )?; + if length != metadata.len() { + return Err(error("Image archive size changed during verification.")); + } if !inspect(&engine, image)? { + file.rewind() + .map_err(|_| error("Cannot rewind the image archive."))?; + let upload = file + .try_clone() + .map_err(|_| error("Cannot read the image archive."))?; state::write(&path, &receipt)?; - let result = engine.load_image_archive(bytes); + let result = engine.load_image_archive( + reqwest::blocking::Body::sized(HeldTail::new(upload, length, expected_sha), length), + load_timeout(length), + ); // A lost response is reconciled only by independent content-ID readback. if !inspect(&engine, image)? { result?; @@ -388,19 +624,55 @@ fn load_waiting( } #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; - use std::io::Write; + use std::io::{Cursor, Write}; - fn fixture(architecture: &str, extra: bool, incorrect_diff: bool) -> (Vec, String) { - let payload = vec![b'x'; 2048]; - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); - encoder.write_all(&payload).unwrap(); - let layer = encoder.finish().unwrap(); + #[derive(Clone, Copy)] + pub(crate) enum Layer { + Gzip, + Zstd, + /// Two zstd frames with a skippable frame between them. + ZstdFrames, + Uncompressed, + } + + pub(crate) fn compress(kind: Layer, payload: &[u8]) -> Vec { + use ruzstd::encoding::{CompressionLevel, compress_to_vec}; + match kind { + Layer::Gzip => { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(payload).unwrap(); + encoder.finish().unwrap() + } + Layer::Zstd => compress_to_vec(payload, CompressionLevel::Fastest), + Layer::ZstdFrames => { + let (first, second) = payload.split_at(payload.len() / 2); + let mut bytes = compress_to_vec(first, CompressionLevel::Fastest); + bytes.extend_from_slice(&0x184D_2A50_u32.to_le_bytes()); + bytes.extend_from_slice(&3_u32.to_le_bytes()); + bytes.extend_from_slice(b"pad"); + bytes.extend(compress_to_vec(second, CompressionLevel::Uncompressed)); + bytes + } + Layer::Uncompressed => payload.to_vec(), + } + } + + /// A docker-archive tarball with one layer. Returns the archive and its config content ID. + pub(crate) fn archive( + kind: Layer, + payload: &[u8], + architecture: &str, + extra: bool, + incorrect_diff: bool, + ) -> (Vec, String) { + let layer = compress(kind, payload); let diff = if incorrect_diff { "0".repeat(64) } else { - hash(&payload) + hash(payload) }; let config = serde_json::to_vec( &serde_json::json!({"architecture":architecture,"os":"linux", @@ -409,33 +681,42 @@ mod tests { .unwrap(); let image = format!("sha256:{}", hash(&config)); let manifest = serde_json::to_vec( - &serde_json::json!([{"Config":"config.json","Layers":["layer.tar.gz"],"RepoTags":[]}]), + &serde_json::json!([{"Config":"config.json","Layers":["layer"],"RepoTags":[]}]), ) .unwrap(); let mut archive = tar::Builder::new(Vec::new()); - for (name, bytes) in [ + let mut members = vec![ ("config.json", config.as_slice()), - ("layer.tar.gz", layer.as_slice()), + ("layer", layer.as_slice()), ("manifest.json", manifest.as_slice()), - ] { + ]; + if extra { + members.push(("extra", b"x".as_slice())); + } + for (name, bytes) in members { let mut header = tar::Header::new_gnu(); header.set_size(bytes.len() as u64); header.set_mode(0o644); header.set_cksum(); archive.append_data(&mut header, name, bytes).unwrap(); } - if extra { - let mut header = tar::Header::new_gnu(); - header.set_size(1); - header.set_mode(0o644); - header.set_cksum(); - archive - .append_data(&mut header, "extra", b"x".as_slice()) - .unwrap(); - } (archive.into_inner().unwrap(), image) } + fn fixture(architecture: &str, extra: bool, incorrect_diff: bool) -> (Vec, String) { + archive( + Layer::Gzip, + &[b'x'; 2048], + architecture, + extra, + incorrect_diff, + ) + } + + fn validate(bytes: &[u8], image: &str, expanded: u64) -> Result { + validate_stream(Cursor::new(bytes), &hash(bytes), image, 1 << 30, expanded) + } + #[test] fn streamed_verification_bounds_reads_and_detects_changed_import_bytes() { struct Chunked { @@ -462,21 +743,96 @@ mod tests { assert!(reader.largest_request <= 64 * 1024); assert!(verify_digest(&mut Cursor::new(&bytes), &expected, 199_999).is_err()); assert!(verify_digest(&mut Cursor::new(&bytes), &"0".repeat(64), 200_000).is_err()); - let mut cursor = reader.inner; - assert_eq!( - read_verified_archive(&mut cursor, &expected).unwrap(), - bytes - ); - cursor.get_mut()[0] = 8; - assert!(read_verified_archive(&mut cursor, &expected).is_err()); - cursor.get_mut().truncate(10); - assert!(read_verified_archive(&mut cursor, &expected).is_err()); + // Validation hashes the very bytes it validates: a changed or oversized stream + // refuses even when its members are individually valid. let (archive, image) = fixture("arm64", false, false); let expected = hash(&archive); - let mut input = Cursor::new(archive); - verify_digest(&mut input, &expected, MAX_ARCHIVE).unwrap(); - let verified = read_verified_archive(&mut input, &expected).unwrap(); - validate_archive(&verified, &image, MAX_EXPANDED).unwrap(); + let length = archive.len() as u64; + assert_eq!( + validate_stream(Cursor::new(&archive), &expected, &image, length, 1 << 20).unwrap(), + length + ); + assert!( + validate_stream( + Cursor::new(&archive), + &expected, + &image, + length - 1, + 1 << 20 + ) + .is_err() + ); + let mut trailing = archive.clone(); + trailing.extend_from_slice(&[0; 512]); + assert!( + validate_stream(Cursor::new(&trailing), &expected, &image, 1 << 20, 1 << 20).is_err() + ); + assert!( + validate_stream( + Cursor::new(&archive), + &"0".repeat(64), + &image, + length, + 1 << 20 + ) + .is_err() + ); + } + + #[test] + fn upload_withholds_its_tail_until_the_whole_stream_hashes_as_expected() { + let bytes: Vec = (0..300_000_u32).map(|n| n as u8).collect(); + let expected = hash(&bytes); + let mut uploaded = Vec::new(); + HeldTail::new(Cursor::new(&bytes), bytes.len() as u64, &expected) + .read_to_end(&mut uploaded) + .unwrap(); + assert_eq!(uploaded, bytes); + for changed in [ + { + let mut changed = bytes.clone(); + changed[299_999] ^= 1; + changed + }, + bytes[..299_000].to_vec(), + [bytes.as_slice(), b"x"].concat(), + ] { + let mut reader = HeldTail::new(Cursor::new(&changed), bytes.len() as u64, &expected); + let mut released = Vec::new(); + let mut buffer = [0_u8; 4096]; + let failure = loop { + match reader.read(&mut buffer) { + Ok(0) => panic!("a changed stream must not end cleanly"), + Ok(n) => released.extend_from_slice(&buffer[..n]), + Err(e) => break e, + } + }; + assert_eq!(failure.to_string(), "image archive changed"); + // The withheld tail, which holds the tar end-of-archive blocks, never left. + assert!(released.len() + HELD_TAIL <= changed.len().max(bytes.len())); + } + } + + #[test] + fn gzip_zstd_multi_frame_and_uncompressed_layers_validate_by_decoded_identity() { + let payload: Vec = (0..20_000_u32).map(|n| (n % 251) as u8).collect(); + for kind in [ + Layer::Gzip, + Layer::Zstd, + Layer::ZstdFrames, + Layer::Uncompressed, + ] { + let (bytes, image) = archive(kind, &payload, "arm64", false, false); + validate(&bytes, &image, 1 << 20).unwrap(); + let (bytes, image) = archive(kind, &payload, "arm64", false, true); + assert!(validate(&bytes, &image, 1 << 20).is_err()); + } + // A corrupt zstd frame refuses instead of being treated as raw content. + let mut corrupt = compress(Layer::Zstd, &payload); + let last = corrupt.len() - 2; + corrupt[last] ^= 0xff; + corrupt.truncate(corrupt.len() - 1); + assert!(decode_member(Cursor::new(corrupt), |_| Ok(())).is_err()); } #[test] @@ -518,15 +874,16 @@ mod tests { #[test] fn archive_rejects_extra_members_wrong_architecture_digest_and_expansion() { let (bytes, image) = fixture("arm64", false, false); - assert!(validate_archive(&bytes, &image, 2048).is_ok()); - assert!(validate_archive(&bytes, &image, 1024).is_err()); - assert!(validate_archive(&bytes, &format!("sha256:{}", "0".repeat(64)), 4096).is_err()); + // Every decoded member counts: the 2048-byte layer plus its small metadata. + assert!(validate(&bytes, &image, 4096).is_ok()); + assert!(validate(&bytes, &image, 2048).is_err()); + assert!(validate(&bytes, &format!("sha256:{}", "0".repeat(64)), 4096).is_err()); for (bytes, image) in [ fixture("amd64", false, false), fixture("arm64", true, false), fixture("arm64", false, true), ] { - assert!(validate_archive(&bytes, &image, 4096).is_err()); + assert!(validate(&bytes, &image, 4096).is_err()); } } @@ -605,6 +962,7 @@ mod tests { &path, &sha, &image, + &ImagePolicy::default(), Duration::from_millis(300), ) .err() @@ -622,9 +980,16 @@ mod tests { drop(held); }); let started = Instant::now(); - let admitted = load_waiting(&pool.candidate, &path, &sha, &image, Duration::from_secs(5)) - .err() - .unwrap(); + let admitted = load_waiting( + &pool.candidate, + &path, + &sha, + &image, + &ImagePolicy::default(), + Duration::from_secs(5), + ) + .err() + .unwrap(); release.join().unwrap(); assert_ne!(admitted.code, "provider_busy", "{admitted:?}"); assert!(started.elapsed() >= Duration::from_millis(200)); diff --git a/packages/runtime-core/src/provider/image_policy.rs b/packages/runtime-core/src/provider/image_policy.rs new file mode 100644 index 000000000..e7c13d9a5 --- /dev/null +++ b/packages/runtime-core/src/provider/image_policy.rs @@ -0,0 +1,218 @@ +//! Bounded, explicit image acquisition policy: byte budgets, local registry opt-ins and +//! the Docker configuration directory that may supply registry credentials. +//! +//! Every value is supplied by the caller (CLI flags); nothing is read from the process +//! environment, so a test or embedding never inherits a user's settings implicitly. +//! Budgets are configurable within fixed hard ceilings, so a policy can relax the +//! defaults for large development images but never remove the bound. +use crate::CandidateError; +use std::path::PathBuf; + +const MIB: u64 = 1024 * 1024; +/// Default compressed archive budget for one image: fits ordinary language and database +/// development images (typically 50 MiB to 1 GiB compressed). +pub const DEFAULT_ARCHIVE_LIMIT: u64 = 2 * 1024 * MIB; +/// Fixed upper bound for any configured per-image archive budget and for any archive the +/// cache or loader accepts. +pub const ARCHIVE_CEILING: u64 = 8 * 1024 * MIB; +/// Expanded layer bytes may be this many times the archive budget. +const EXPANSION_FACTOR: u64 = 5; +/// Default total bytes of archives retained in one candidate's image cache. +pub const DEFAULT_CACHE_LIMIT: u64 = 16 * 1024 * MIB; +/// Fixed upper bound for a configured cache budget. +pub const CACHE_CEILING: u64 = 256 * 1024 * MIB; +/// Smallest configurable archive budget; smaller values cannot hold real images. +const MIN_ARCHIVE_LIMIT: u64 = 16 * MIB; +/// Local registry opt-ins a single request may name. +const MAX_LOCAL_REGISTRIES: usize = 16; + +fn invalid(message: &str) -> CandidateError { + CandidateError::new("image_policy", message) +} + +/// One explicitly allowed local registry and the transport it uses. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalRegistry { + /// Exact `host[:port]` as it appears in normalized image references. + pub domain: String, + /// True only when the opt-in was written as `http://host[:port]`. + pub plain_http: bool, +} + +#[derive(Clone, Debug)] +pub struct ImagePolicy { + /// Compressed archive budget for one image. + pub archive_limit: u64, + /// Total expanded (decompressed) bytes across one archive's members. + pub expanded_limit: u64, + /// Total archive bytes the candidate's image cache may retain. + pub cache_limit: u64, + /// Registries that are refused as local unless named here. + pub local_registries: Vec, + /// Docker configuration directory for read-only credential lookup. `None` means + /// `$HOME/.docker`; the directory is read only when a registry challenges a request. + pub docker_config: Option, + /// Directories searched for `docker-credential-*` helpers. `None` means `PATH`. + pub(crate) helper_path: Option>, +} + +impl Default for ImagePolicy { + fn default() -> Self { + Self { + archive_limit: DEFAULT_ARCHIVE_LIMIT, + expanded_limit: DEFAULT_ARCHIVE_LIMIT * EXPANSION_FACTOR, + cache_limit: DEFAULT_CACHE_LIMIT, + local_registries: Vec::new(), + docker_config: None, + helper_path: None, + } + } +} + +fn mebibytes(value: &str, min: u64, max: u64) -> Result { + let parsed = + (!value.is_empty() && value.len() <= 8 && value.bytes().all(|b| b.is_ascii_digit())) + .then(|| value.parse::().ok()) + .flatten() + .and_then(|mib| mib.checked_mul(MIB)) + .filter(|bytes| (min..=max).contains(bytes)); + parsed.ok_or_else(|| { + invalid("Image budget must be a whole number of MiB within its fixed bounds.") + }) +} + +impl ImagePolicy { + /// The local registry opt-in for `domain`, if any. + pub fn local_registry(&self, domain: &str) -> Option<&LocalRegistry> { + self.local_registries.iter().find(|r| r.domain == domain) + } + + /// Parse trailing policy flags of an image command. Accepted flags, each at most once + /// except `--allow-local-registry`: + /// + /// - `--image-budget-mib N`: per-image compressed archive budget (16..=8192 MiB); + /// the expanded budget is five times this value. + /// - `--image-cache-mib N`: total cache budget (16..=262144 MiB, at least the image budget). + /// - `--allow-local-registry [http://]HOST[:PORT]`: admit one local registry by exact + /// domain; the `http://` prefix selects plain HTTP, otherwise HTTPS is required. + /// - `--docker-config DIR`: absolute Docker configuration directory for credentials. + pub fn from_flags(flags: &[&str]) -> Result { + let mut policy = Self::default(); + let (mut budget, mut cache, mut config) = (false, false, false); + let mut rest = flags; + while let [flag, value, tail @ ..] = rest { + match *flag { + "--image-budget-mib" if !budget => { + budget = true; + policy.archive_limit = mebibytes(value, MIN_ARCHIVE_LIMIT, ARCHIVE_CEILING)?; + policy.expanded_limit = policy.archive_limit * EXPANSION_FACTOR; + } + "--image-cache-mib" if !cache => { + cache = true; + policy.cache_limit = mebibytes(value, MIN_ARCHIVE_LIMIT, CACHE_CEILING)?; + } + "--allow-local-registry" + if policy.local_registries.len() < MAX_LOCAL_REGISTRIES => + { + let (domain, plain_http) = match value.strip_prefix("http://") { + Some(domain) => (domain, true), + None => (*value, false), + }; + if super::registry_image::local_domain_syntax(domain).is_err() + || policy.local_registry(domain).is_some() + { + return Err(invalid( + "A local registry opt-in must be a unique [http://]host[:port].", + )); + } + policy.local_registries.push(LocalRegistry { + domain: domain.to_owned(), + plain_http, + }); + } + "--docker-config" if !config => { + config = true; + let path = PathBuf::from(value); + if !path.is_absolute() || value.len() > 1024 { + return Err(invalid( + "The Docker configuration directory must be an absolute path.", + )); + } + policy.docker_config = Some(path); + } + _ => return Err(invalid("Unsupported or repeated image policy flag.")), + } + rest = tail; + } + if !rest.is_empty() { + return Err(invalid("Image policy flags require a value.")); + } + if policy.cache_limit < policy.archive_limit { + return Err(invalid( + "The image cache budget must be at least one image budget.", + )); + } + Ok(policy) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_fit_development_images_and_flags_stay_within_fixed_ceilings() { + let policy = ImagePolicy::from_flags(&[]).unwrap(); + assert_eq!(policy.archive_limit, DEFAULT_ARCHIVE_LIMIT); + assert_eq!(policy.expanded_limit, 5 * DEFAULT_ARCHIVE_LIMIT); + assert_eq!(policy.cache_limit, DEFAULT_CACHE_LIMIT); + assert!(policy.local_registries.is_empty() && policy.docker_config.is_none()); + let policy = ImagePolicy::from_flags(&[ + "--image-budget-mib", + "4096", + "--image-cache-mib", + "65536", + "--allow-local-registry", + "http://localhost:5000", + "--allow-local-registry", + "registry.local:443", + "--docker-config", + "/fixture/docker", + ]) + .unwrap(); + assert_eq!(policy.archive_limit, 4096 * MIB); + assert_eq!(policy.expanded_limit, 5 * 4096 * MIB); + assert_eq!(policy.cache_limit, 65536 * MIB); + assert!(policy.local_registry("localhost:5000").unwrap().plain_http); + assert!( + !policy + .local_registry("registry.local:443") + .unwrap() + .plain_http + ); + assert!(policy.local_registry("localhost").is_none()); + for flags in [ + &["--image-budget-mib", "8193"][..], + &["--image-budget-mib", "15"], + &["--image-budget-mib", "-1"], + &["--image-budget-mib", "1e3"], + &["--image-budget-mib", "64", "--image-budget-mib", "64"], + &["--image-cache-mib", "262145"], + &["--image-budget-mib", "4096", "--image-cache-mib", "1024"], + &["--allow-local-registry", "https://localhost:5000"], + &["--allow-local-registry", "localhost:5000/path"], + &["--allow-local-registry", "user:secret@localhost"], + &[ + "--allow-local-registry", + "localhost", + "--allow-local-registry", + "localhost", + ], + &["--docker-config", "relative/docker"], + &["--docker-config"], + &["--unknown", "1"], + ] { + assert!(ImagePolicy::from_flags(flags).is_err(), "{flags:?}"); + } + } +} diff --git a/packages/runtime-core/src/provider/image_reference.rs b/packages/runtime-core/src/provider/image_reference.rs index c3b9392ba..0fd49030d 100644 --- a/packages/runtime-core/src/provider/image_reference.rs +++ b/packages/runtime-core/src/provider/image_reference.rs @@ -79,8 +79,8 @@ impl NamedReference { value } - /// The `namespace/repository` path when this is a public Docker Hub reference - /// the anonymous acquisition path supports, otherwise `None`. + /// The `namespace/repository` path when this is a Docker Hub reference with the + /// two-component path Docker Hub serves, otherwise `None`. pub fn docker_hub_repository(&self) -> Option<&str> { (self.domain == DOCKER_HUB && self.path.len() <= 200 && self.path.split('/').count() == 2) .then_some(self.path.as_str()) @@ -156,7 +156,7 @@ fn path_component(value: &str) -> Result<(), ReferenceError> { } /// Lowercase DNS-style host labels, optionally followed by a decimal port in 1..=65535. -fn domain(value: &str) -> Result<(), ReferenceError> { +pub(crate) fn domain(value: &str) -> Result<(), ReferenceError> { let (host, port) = match value.split_once(':') { Some((host, port)) => (host, Some(port)), None => (value, None), diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index e9ff94e64..03209ff5e 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -35,6 +35,7 @@ pub mod https_recovery; mod identity; pub mod image_ensure; mod image_load; +pub mod image_policy; pub mod image_reference; pub mod managed_environment; #[cfg(feature = "native-config-plan")] @@ -43,6 +44,7 @@ mod native_environment; pub mod native_input; pub mod private_deadline; pub mod private_input; +mod registry_credentials; pub mod registry_image; pub mod relay_auth; pub mod relay_client; @@ -73,7 +75,7 @@ pub mod storage_usage; mod test_executable; #[cfg(all(test, unix))] mod test_socket; -pub use image_load::load as load_image; +pub use image_load::{load as load_image, load_with as load_image_with}; mod lifecycle; pub use lifecycle::host_filesystem; mod network_intent; diff --git a/packages/runtime-core/src/provider/registry_credentials.rs b/packages/runtime-core/src/provider/registry_credentials.rs new file mode 100644 index 000000000..d8e454921 --- /dev/null +++ b/packages/runtime-core/src/provider/registry_credentials.rs @@ -0,0 +1,505 @@ +//! Read-only registry credential lookup through the host user's existing Docker +//! configuration, following the Docker CLI's order: +//! +//! 1. a `credHelpers` entry for the registry runs `docker-credential- get`; +//! 2. otherwise a `credsStore` runs that helper; +//! 3. otherwise an `auths` entry supplies `auth`, `username`/`password` or `identitytoken`. +//! +//! Nothing is written: no login, store, erase or configuration change. Values stay in +//! zeroizing memory for one acquisition and are never logged, persisted or included in +//! errors; helper stderr is discarded because helpers may print secrets there. +use super::image_policy::ImagePolicy; +use crate::CandidateError; +use base64::Engine as _; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + fs::File, + io::{Read, Write}, + os::unix::fs::PermissionsExt, + path::PathBuf, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; +use zeroize::{Zeroize, Zeroizing}; + +const MAX_CONFIG: u64 = 1024 * 1024; +const MAX_HELPER_OUTPUT: u64 = 64 * 1024; +/// Helpers may wait on a native approval such as a keychain prompt; that wait is bounded. +const HELPER_TIMEOUT: Duration = Duration::from_secs(60); +/// The server address the Docker CLI uses for Docker Hub credentials. +const DOCKER_HUB_SERVER: &str = "https://index.docker.io/v1/"; + +fn error(message: &str) -> CandidateError { + CandidateError::new("registry_credentials", message) +} + +/// One credential for one registry, held only in zeroizing memory. +pub(super) enum Credential { + Basic { + username: Zeroizing, + password: Zeroizing, + }, + /// An OAuth2 refresh token (`identitytoken`, or a helper's `` username). + IdentityToken(Zeroizing), +} + +impl Credential { + /// `Basic` authorization value for a registry or token realm, when this credential has one. + pub(super) fn basic_header(&self) -> Option> { + match self { + Self::Basic { username, password } => { + let joined = Zeroizing::new(format!("{}:{}", username.as_str(), password.as_str())); + Some(Zeroizing::new(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(joined.as_bytes()) + ))) + } + Self::IdentityToken(_) => None, + } + } +} + +#[derive(Deserialize)] +struct Config { + #[serde(default)] + auths: BTreeMap, + #[serde(rename = "credsStore")] + creds_store: Option, + #[serde(rename = "credHelpers", default)] + cred_helpers: BTreeMap, +} + +#[derive(Deserialize)] +struct AuthEntry { + auth: Option, + username: Option, + password: Option, + identitytoken: Option, +} + +impl Drop for AuthEntry { + fn drop(&mut self) { + for value in [ + &mut self.auth, + &mut self.username, + &mut self.password, + &mut self.identitytoken, + ] + .into_iter() + .flatten() + { + value.zeroize(); + } + } +} + +#[derive(Deserialize)] +struct HelperReply { + #[serde(rename = "Username", default)] + username: String, + #[serde(rename = "Secret", default)] + secret: String, +} + +impl Drop for HelperReply { + fn drop(&mut self) { + self.username.zeroize(); + self.secret.zeroize(); + } +} + +/// The hostnames a Docker configuration key may name for `domain`, as the Docker CLI +/// converts keys: scheme and any path are ignored. +fn matches(key: &str, domain: &str) -> bool { + let host = key + .strip_prefix("https://") + .or_else(|| key.strip_prefix("http://")) + .unwrap_or(key); + let host = host.split('/').next().unwrap_or_default(); + if domain == "docker.io" { + matches!( + host, + "index.docker.io" | "docker.io" | "registry-1.docker.io" + ) + } else { + host == domain + } +} + +fn server_address(domain: &str) -> &str { + if domain == "docker.io" { + DOCKER_HUB_SERVER + } else { + domain + } +} + +fn config_path(policy: &ImagePolicy) -> Option { + let directory = match &policy.docker_config { + Some(directory) => directory.clone(), + None => PathBuf::from(std::env::var_os("HOME")?).join(".docker"), + }; + Some(directory.join("config.json")) +} + +fn read_config(policy: &ImagePolicy) -> Result, CandidateError> { + let Some(path) = config_path(policy) else { + return Ok(None); + }; + let file = match File::open(&path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(error("Cannot read the Docker configuration file.")), + }; + let metadata = file + .metadata() + .map_err(|_| error("Cannot inspect the Docker configuration file."))?; + if !metadata.is_file() || metadata.len() > MAX_CONFIG { + return Err(error( + "The Docker configuration must be a regular file no larger than 1 MiB.", + )); + } + let mut bytes = Zeroizing::new(Vec::new()); + file.take(MAX_CONFIG + 1) + .read_to_end(&mut bytes) + .map_err(|_| error("Cannot read the Docker configuration file."))?; + if bytes.len() as u64 > MAX_CONFIG { + return Err(error("The Docker configuration exceeds 1 MiB.")); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| error("The Docker configuration file is not valid JSON.")) +} + +fn helper_name(name: &str) -> Result<&str, CandidateError> { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + { + return Err(error( + "The configured Docker credential helper name is invalid.", + )); + } + Ok(name) +} + +fn helper_executable(policy: &ImagePolicy, name: &str) -> Result { + let directories = match &policy.helper_path { + Some(directories) => directories.clone(), + None => std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect()) + .unwrap_or_default(), + }; + directories + .into_iter() + .filter(|directory| directory.is_absolute()) + .map(|directory| directory.join(format!("docker-credential-{name}"))) + .find(|path| { + std::fs::metadata(path) + .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + }) + .ok_or_else(|| error("The configured Docker credential helper was not found on PATH.")) +} + +/// Run `docker-credential- get` for one server address. Helper output is bounded and +/// its stderr discarded; a helper that does not finish within the bound is killed. +fn run_helper( + policy: &ImagePolicy, + name: &str, + server: &str, +) -> Result, CandidateError> { + let executable = helper_executable(policy, helper_name(name)?)?; + let mut child = Command::new(executable) + .arg("get") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| error("Cannot start the Docker credential helper."))?; + let written = child + .stdin + .take() + .map(|mut stdin| stdin.write_all(server.as_bytes())); + let stdout = child.stdout.take(); + let reader = std::thread::spawn(move || { + let mut bytes = Zeroizing::new(Vec::new()); + if let Some(stdout) = stdout { + let _ = stdout.take(MAX_HELPER_OUTPUT + 1).read_to_end(&mut bytes); + } + bytes + }); + let deadline = Instant::now() + HELPER_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err(error( + "The Docker credential helper did not finish in time.", + )); + } + } + }; + let output = reader + .join() + .map_err(|_| error("Cannot read the Docker credential helper reply."))?; + if output.len() as u64 > MAX_HELPER_OUTPUT { + return Err(error( + "The Docker credential helper reply exceeds its limit.", + )); + } + if !status.success() { + // The helper protocol reports an absent entry as a failure with this message on stdout. + if output + .windows(b"credentials not found".len()) + .any(|w| w.eq_ignore_ascii_case(b"credentials not found")) + { + return Ok(None); + } + return Err(error( + "The Docker credential helper failed; its output was discarded.", + )); + } + if written.is_some_and(|result| result.is_err()) { + return Err(error( + "Cannot send the server address to the Docker credential helper.", + )); + } + let reply: HelperReply = serde_json::from_slice(&output) + .map_err(|_| error("The Docker credential helper reply is invalid."))?; + if reply.secret.is_empty() { + return Ok(None); + } + Ok(Some(if reply.username == "" { + Credential::IdentityToken(Zeroizing::new(reply.secret.clone())) + } else { + Credential::Basic { + username: Zeroizing::new(reply.username.clone()), + password: Zeroizing::new(reply.secret.clone()), + } + })) +} + +fn file_credential(entry: &AuthEntry) -> Result, CandidateError> { + if let Some(token) = entry.identitytoken.as_deref().filter(|t| !t.is_empty()) { + return Ok(Some(Credential::IdentityToken(Zeroizing::new( + token.to_owned(), + )))); + } + if let Some(auth) = entry.auth.as_deref().filter(|a| !a.is_empty()) { + let decoded = Zeroizing::new( + base64::engine::general_purpose::STANDARD + .decode(auth.trim()) + .map_err(|_| error("A Docker configuration auth entry is invalid."))?, + ); + let text = std::str::from_utf8(&decoded) + .map_err(|_| error("A Docker configuration auth entry is invalid."))?; + let (username, password) = text + .split_once(':') + .ok_or_else(|| error("A Docker configuration auth entry is invalid."))?; + return Ok(Some(Credential::Basic { + username: Zeroizing::new(username.to_owned()), + password: Zeroizing::new(password.to_owned()), + })); + } + match (entry.username.as_deref(), entry.password.as_deref()) { + (Some(username), Some(password)) if !password.is_empty() => Ok(Some(Credential::Basic { + username: Zeroizing::new(username.to_owned()), + password: Zeroizing::new(password.to_owned()), + })), + _ => Ok(None), + } +} + +/// The host user's credential for `domain` (`host[:port]`, or `docker.io`), or `None` when +/// the configuration names none. Called only after a registry challenges a request. +pub(super) fn lookup( + policy: &ImagePolicy, + domain: &str, +) -> Result, CandidateError> { + let Some(config) = read_config(policy)? else { + return Ok(None); + }; + if let Some((_, helper)) = config + .cred_helpers + .iter() + .find(|(key, _)| matches(key, domain)) + { + return run_helper(policy, helper, server_address(domain)); + } + if let Some(store) = config.creds_store.as_deref().filter(|s| !s.is_empty()) { + return run_helper(policy, store, server_address(domain)); + } + // Prefer an exact key; otherwise the first converted key that names the registry. + let entry = config + .auths + .get(server_address(domain)) + .or_else(|| config.auths.get(domain)) + .or_else(|| { + config + .auths + .iter() + .find(|(key, _)| matches(key, domain)) + .map(|(_, entry)| entry) + }); + entry.map_or(Ok(None), file_credential) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use std::{fs, os::unix::fs::OpenOptionsExt}; + + /// A private fixture directory with a Docker configuration and fake helpers. The real + /// user configuration, keychain and helpers are never consulted. + pub(crate) struct DockerFixture { + pub(crate) root: PathBuf, + } + + impl DockerFixture { + pub(crate) fn new(config: &serde_json::Value) -> Self { + let root = crate::private_test_root(&std::env::temp_dir(), "hkg-docker-config"); + fs::write( + root.join("config.json"), + serde_json::to_vec(config).unwrap(), + ) + .unwrap(); + Self { root } + } + + pub(crate) fn helper(&self, name: &str, script: &str) { + let path = self.root.join(format!("docker-credential-{name}")); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o700) + .open(&path) + .unwrap(); + file.write_all(format!("#!/bin/sh\n{script}\n").as_bytes()) + .unwrap(); + } + + pub(crate) fn policy(&self) -> ImagePolicy { + ImagePolicy { + docker_config: Some(self.root.clone()), + helper_path: Some(vec![self.root.clone()]), + ..ImagePolicy::default() + } + } + } + + impl Drop for DockerFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn basic(credential: Option) -> String { + credential.unwrap().basic_header().unwrap().to_string() + } + + fn encoded(value: &str) -> String { + base64::engine::general_purpose::STANDARD.encode(value) + } + + #[test] + fn file_entries_match_like_the_docker_cli_and_absent_entries_are_anonymous() { + let f = DockerFixture::new(&serde_json::json!({ + "auths": { + "https://index.docker.io/v1/": {"auth": encoded("hub-user:hub-fixture")}, + "https://registry.test:5000/v2/": {"username": "u", "password": "p"}, + "token.test": {"identitytoken": "refresh-fixture"}, + }, + "unrelated": {"kept": true} + })); + let policy = f.policy(); + assert_eq!( + basic(lookup(&policy, "docker.io").unwrap()), + format!("Basic {}", encoded("hub-user:hub-fixture")) + ); + assert_eq!( + basic(lookup(&policy, "registry.test:5000").unwrap()), + format!("Basic {}", encoded("u:p")) + ); + assert!(matches!( + lookup(&policy, "token.test").unwrap(), + Some(Credential::IdentityToken(token)) if token.as_str() == "refresh-fixture" + )); + // A different port or host is a different registry. + assert!(lookup(&policy, "registry.test").unwrap().is_none()); + assert!(lookup(&policy, "other.test").unwrap().is_none()); + let missing = ImagePolicy { + docker_config: Some(f.root.join("absent")), + ..ImagePolicy::default() + }; + assert!(lookup(&missing, "docker.io").unwrap().is_none()); + } + + #[test] + fn helpers_take_precedence_and_report_absent_entries_without_failing() { + let f = DockerFixture::new(&serde_json::json!({ + "auths": {"helper.test": {"auth": encoded("file:ignored")}}, + "credHelpers": {"helper.test": "fixture", "missing.test": "absent", "bad.test": "../x"}, + "credsStore": "store" + })); + // The helper receives exactly the server address on stdin. + f.helper( + "fixture", + r#"[ "$1" = get ] || exit 3; read -r server || true; [ "$server" = helper.test ] || exit 4; printf '{"ServerURL":"helper.test","Username":"u","Secret":"s"}'; echo "leaked secret" >&2"#, + ); + f.helper( + "store", + r#"read -r server || true; if [ "$server" = "https://index.docker.io/v1/" ]; then printf '{"Username":"","Secret":"refresh"}'; else echo "credentials not found in native keychain"; exit 1; fi"#, + ); + let policy = f.policy(); + assert_eq!( + basic(lookup(&policy, "helper.test").unwrap()), + format!("Basic {}", encoded("u:s")) + ); + assert!(matches!( + lookup(&policy, "docker.io").unwrap(), + Some(Credential::IdentityToken(token)) if token.as_str() == "refresh" + )); + assert!(lookup(&policy, "ghcr.io").unwrap().is_none()); + assert_eq!( + lookup(&policy, "missing.test").err().unwrap().code, + "registry_credentials" + ); + assert_eq!( + lookup(&policy, "bad.test").err().unwrap().code, + "registry_credentials" + ); + } + + #[test] + fn failing_helpers_and_malformed_configuration_refuse_without_echoing_values() { + let f = DockerFixture::new(&serde_json::json!({"credsStore": "fails"})); + f.helper( + "fails", + "echo PRIVATE_CANARY; echo PRIVATE_CANARY >&2; exit 2", + ); + let failure = lookup(&f.policy(), "registry.test").err().unwrap(); + assert!(!failure.message.contains("PRIVATE_CANARY")); + fs::write( + f.root.join("config.json"), + b"{\"auths\":{\"a.test\":{\"auth\":\"%%\"}}}", + ) + .unwrap(); + let failure = lookup(&f.policy(), "a.test").err().unwrap(); + assert!(!failure.message.contains("%%")); + fs::write(f.root.join("config.json"), b"PRIVATE_CANARY").unwrap(); + assert!( + !lookup(&f.policy(), "a.test") + .err() + .unwrap() + .message + .contains("PRIVATE_CANARY") + ); + } +} diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 19c02c89f..5ad8bf1d8 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -1,8 +1,31 @@ -//! Anonymous, digest-pinned Docker Hub acquisition. No daemon, credential store or VM effects. +//! Digest-pinned image acquisition from any OCI distribution registry, without a Docker +//! daemon or VM effects. +//! +//! - **Registries.** Docker Hub, any public or private registry host reached over HTTPS, +//! and local registries (loopback names, non-public IPv4 literals, single-label and +//! `.local` hosts) only when the policy names them exactly. A `http://` opt-in is the +//! only way to use plain HTTP. +//! - **Authentication.** Requests start anonymous. A `401` challenge is answered with an +//! anonymous Bearer token first, and only then with the host user's existing Docker +//! credentials (read-only; see `registry_credentials`). Credentials and tokens stay in +//! zeroizing memory for one acquisition, are sent only to the registry origin or its +//! token realm, and never appear in output or errors. +//! - **Content.** OCI and Docker v2 indexes and manifests; gzip, zstd and uncompressed +//! layers. Every manifest, config and layer is checked against its descriptor digest +//! and size while streaming, and the assembled archive is validated again before it is +//! published. Archive and expansion sizes stay within the policy's budgets. +//! - **Platform.** One unambiguous Linux ARM64 image. use super::image_load; +use super::image_policy::ImagePolicy; use super::image_reference::{ImageReference, NamedReference, normalize}; +use super::registry_credentials::{self, Credential}; use crate::CandidateError; -use reqwest::{Url, blocking::Client, header::ACCEPT, redirect::Policy}; +use reqwest::{ + StatusCode, Url, + blocking::{Client, Response}, + header::{ACCEPT, AUTHORIZATION, HeaderValue, LOCATION, WWW_AUTHENTICATE}, + redirect::Policy, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ @@ -15,9 +38,33 @@ use std::{ }; use zeroize::Zeroizing; -const MAX_ARCHIVE: usize = 256 * 1024 * 1024; const MAX_METADATA: usize = 1024 * 1024; +const MAX_TOKEN_RESPONSE: usize = 64 * 1024; +const MAX_TOKEN: usize = 32 * 1024; +const MAX_LAYERS: usize = 128; +/// Whole-acquisition deadline. It stays below the CLI's ten-minute bound for +/// `ensure-image` with room for the import that follows. +const ACQUISITION_DEADLINE: Duration = Duration::from_secs(360); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +/// Slowest blob transfer rate a request timeout allows for before the deadline applies. +const MIN_BLOB_BYTES_PER_SECOND: u64 = 1024 * 1024; +const DOCKER_HUB: &str = "docker.io"; +const DOCKER_HUB_ORIGIN: &str = "https://registry-1.docker.io"; +const DOCKER_HUB_REALM: &str = "https://auth.docker.io/token"; const ACCEPT_MANIFEST: &str = "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"; +const INDEX_TYPES: [&str; 2] = [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", +]; +const MANIFEST_TYPES: [&str; 2] = [ + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +]; +const CONFIG_TYPES: [&str; 2] = [ + "application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", +]; + fn error(message: &str) -> CandidateError { CandidateError::new("registry_image", message) } @@ -31,33 +78,176 @@ fn valid_digest(value: &str) -> bool { .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) }) } -/// Normalize to a Docker Hub reference; other registries and image IDs refuse. -fn docker_hub(value: &str) -> Result { + +fn ipv4(host: &str) -> Option<[u8; 4]> { + let mut octets = [0_u8; 4]; + let mut parts = host.split('.'); + for octet in &mut octets { + let part = parts.next()?; + if part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + *octet = part.parse().ok()?; + } + parts.next().is_none().then_some(octets) +} + +/// Hosts that are not public registries: loopback names, non-public IPv4 literals, +/// single-label names and mDNS `.local` names. They resolve on this host or its network, +/// so acquisition refuses them unless the policy names the registry explicitly. +pub(super) fn local_host(host: &str) -> bool { + if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") { + return true; + } + if let Some([a, b, ..]) = ipv4(host) { + return a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..128).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..32).contains(&b)) + || (a == 192 && b == 168) + || a >= 224; + } + !host.contains('.') +} + +/// The reference grammar's `host[:port]` syntax, for local registry opt-ins. +pub(super) fn local_domain_syntax(domain: &str) -> Result<(), ()> { + super::image_reference::domain(domain).map_err(|_| ()) +} + +/// Where one registry is reached. +#[derive(Clone, Debug)] +pub(super) struct Registry { + /// Normalized reference domain, for example `docker.io` or `registry.test:5000`. + domain: String, + /// `scheme://host[:port]` of the distribution API. + origin: Url, +} + +impl Registry { + fn docker_hub(&self) -> bool { + self.domain == DOCKER_HUB + } +} + +fn registry(named: &NamedReference, policy: &ImagePolicy) -> Result { + if named.domain == DOCKER_HUB { + if named.docker_hub_repository().is_none() { + return Err(error( + "Docker Hub references must name a [namespace/]repository.", + )); + } + return Ok(Registry { + domain: DOCKER_HUB.into(), + origin: Url::parse(DOCKER_HUB_ORIGIN).map_err(|_| error("Invalid registry origin."))?, + }); + } + let host = named.domain.split(':').next().unwrap_or_default(); + let opt_in = policy.local_registry(&named.domain); + if local_host(host) && opt_in.is_none() { + return Err(error( + "Local registries are refused unless explicitly allowed for native image acquisition.", + )); + } + let scheme = if opt_in.is_some_and(|r| r.plain_http) { + "http" + } else { + "https" + }; + let origin = Url::parse(&format!("{scheme}://{}", named.domain)) + .map_err(|_| error("Invalid registry origin."))?; + // URL parsing normalizes numeric host forms (for example hexadecimal IPv4), so the + // parsed host is classified too. + if origin.host_str().is_none_or(local_host) && opt_in.is_none() { + return Err(error( + "Local registries are refused unless explicitly allowed for native image acquisition.", + )); + } + Ok(Registry { + domain: named.domain.clone(), + origin, + }) +} + +fn named(value: &str) -> Result { match normalize(value) { - Ok(ImageReference::Named(named)) if named.docker_hub_repository().is_some() => Ok(named), - _ => Err(error( - "Only public Docker Hub [namespace/]repository[:tag][@sha256] references are supported.", + Ok(ImageReference::Named(named)) => Ok(named), + Ok(ImageReference::ImageId(_)) => Err(error( + "A registry repository reference is required, not an image ID.", )), + Err(reason) => Err(error(reason.message())), + } +} + +/// A digest-pinned registry reference ready for acquisition. +pub(super) struct Pinned { + registry: Registry, + path: String, + digest: String, +} + +impl Pinned { + /// Canonical `domain/path@sha256:...`; any tag beside the digest is dropped. + pub(super) fn canonical(&self) -> String { + format!("{}/{}@{}", self.registry.domain, self.path, self.digest) } } -/// Normalized Docker Hub `namespace/repository` and its required sha256 pin. -/// Docker shorthand such as `alpine@sha256:...` expands to `library/alpine`. -pub(super) fn reference(value: &str) -> Result<(String, String), CandidateError> { - let pinned = || error("A public Docker Hub reference pinned with @sha256 is required."); + +/// Canonical `domain/path@digest` for a pinned reference, by syntax alone. It does not +/// admit the registry, so recorded cache entries stay readable under any policy. +pub(super) fn pinned_syntax(value: &str) -> Result<(String, String), CandidateError> { + let pinned = || error("A registry reference pinned with @sha256 is required."); if !value.contains('@') { return Err(pinned()); } - let named = docker_hub(value)?; - let pin = named.digest.ok_or_else(pinned)?; - Ok((named.path, pin)) + let named = named(value)?; + let digest = named.digest.ok_or_else(pinned)?; + Ok((format!("{}/{}@{digest}", named.domain, named.path), digest)) +} + +/// A pinned reference whose registry the policy admits. Docker shorthand such as +/// `alpine@sha256:...` expands to `docker.io/library/alpine`. +pub(super) fn pinned(value: &str, policy: &ImagePolicy) -> Result { + pinned_syntax(value)?; + let named = named(value)?; + Ok(Pinned { + registry: registry(&named, policy)?, + path: named.path, + digest: named.digest.unwrap_or_default(), + }) } + +/// Registry, repository path and tag for mutable resolution. `alpine` is +/// `docker.io/library/alpine:latest`. +pub(super) fn tagged( + value: &str, + policy: &ImagePolicy, +) -> Result<(Registry, String, String), CandidateError> { + if value.contains('@') { + return Err(error( + "Tag resolution requires a repository and optional tag, not a digest.", + )); + } + let named = named(value)?; + let registry = registry(&named, policy)?; + Ok(( + registry, + named.path, + named.tag.unwrap_or_else(|| "latest".into()), + )) +} + #[derive(Clone, Deserialize)] struct Descriptor { digest: String, size: u64, - #[serde(rename = "mediaType")] + #[serde(rename = "mediaType", default)] media_type: String, platform: Option, + #[serde(default)] + urls: Vec, } #[derive(Clone, Deserialize)] struct Platform { @@ -69,6 +259,8 @@ struct Platform { struct Manifest { #[serde(rename = "schemaVersion")] schema_version: u32, + #[serde(rename = "mediaType")] + media_type: Option, #[serde(default)] manifests: Vec, config: Option, @@ -84,6 +276,16 @@ fn parse_manifest(bytes: &[u8], expected: &str) -> Result Result<&Descriptor, CandidateError> { @@ -100,6 +302,11 @@ fn arm64(m: &Manifest) -> Result<&Descriptor, CandidateError> { if selected.next().is_some() { return Err(error("Pinned index has ambiguous Linux ARM64 images.")); } + if !first.media_type.is_empty() && !MANIFEST_TYPES.contains(&first.media_type.as_str()) { + return Err(error( + "The Linux ARM64 index entry is not an image manifest.", + )); + } Ok(first) } fn bounded(mut reader: impl Read, limit: usize) -> Result, CandidateError> { @@ -114,7 +321,8 @@ fn bounded(mut reader: impl Read, limit: usize) -> Result, CandidateErro } Ok(bytes) } -fn redirect_allowed(url: &Url) -> bool { +/// Docker Hub serves blobs only from these HTTPS origins. +fn docker_hub_redirect_allowed(url: &Url) -> bool { url.scheme() == "https" && url.port_or_known_default() == Some(443) && url.username().is_empty() @@ -126,93 +334,415 @@ fn redirect_allowed(url: &Url) -> bool { || h.ends_with(".r2.cloudflarestorage.com") }) } -struct Fetch { +fn same_origin(a: &Url, b: &Url) -> bool { + a.scheme() == b.scheme() + && a.host_str() == b.host_str() + && a.port_or_known_default() == b.port_or_known_default() +} +/// Redirect policy: the registry's own origin, Docker Hub's known blob origins, or for +/// other registries any public HTTPS origin on port 443 (registries commonly serve blobs +/// from object storage). Local destinations and embedded credentials always refuse. +fn redirect_allowed(registry: &Registry, url: &Url) -> bool { + if !url.username().is_empty() || url.password().is_some() { + return false; + } + if same_origin(®istry.origin, url) { + return true; + } + if registry.docker_hub() { + return docker_hub_redirect_allowed(url); + } + url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && url + .host_str() + .is_some_and(|host| !host.starts_with('[') && !local_host(host)) +} + +/// A token realm must be the registry origin itself, or a public HTTPS endpoint; Docker +/// Hub's realm is fixed. +fn realm_allowed(registry: &Registry, realm: &Url) -> bool { + if !realm.username().is_empty() || realm.password().is_some() { + return false; + } + if registry.docker_hub() { + return realm.as_str() == DOCKER_HUB_REALM; + } + same_origin(®istry.origin, realm) + || (realm.scheme() == "https" + && realm + .host_str() + .is_some_and(|h| !local_host(h) && !h.starts_with('['))) +} + +/// One `WWW-Authenticate` challenge: scheme and parameters. +struct Challenge { + bearer: bool, + realm: Option, + service: Option, +} + +fn parse_challenge(header: &str) -> Result { + let invalid = || error("Unsupported registry authentication challenge."); + let (scheme, rest) = header.trim().split_once(' ').unwrap_or((header.trim(), "")); + let bearer = if scheme.eq_ignore_ascii_case("bearer") { + true + } else if scheme.eq_ignore_ascii_case("basic") { + false + } else { + return Err(invalid()); + }; + let (mut realm, mut service) = (None, None); + let mut chars = rest.chars().peekable(); + loop { + while chars.peek().is_some_and(|c| *c == ',' || c.is_whitespace()) { + chars.next(); + } + if chars.peek().is_none() { + break; + } + let key: String = chars.by_ref().take_while(|c| *c != '=').collect(); + let mut value = String::new(); + if chars.peek() == Some(&'"') { + chars.next(); + loop { + match chars.next().ok_or_else(invalid)? { + '"' => break, + '\\' => value.push(chars.next().ok_or_else(invalid)?), + c => value.push(c), + } + } + } else { + while let Some(c) = chars.peek().copied().filter(|c| *c != ',') { + value.push(c); + chars.next(); + } + } + if value.len() > 2048 { + return Err(invalid()); + } + match key.trim().to_ascii_lowercase().as_str() { + "realm" => realm = Some(value), + "service" => service = Some(value), + _ => {} + } + } + Ok(Challenge { + bearer, + realm, + service, + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Stage { + Anonymous, + AnonymousToken, + Credentialed, +} + +struct Fetch<'a> { client: Client, - token: Zeroizing, + registry: Registry, + path: String, + policy: &'a ImagePolicy, + /// Complete `Authorization` value sent to the registry origin only. + authorization: Option>, + stage: Stage, deadline: Instant, } -impl Fetch { - fn bytes( - &self, - mut url: Url, - limit: usize, - authenticated: bool, - ) -> Result, CandidateError> { + +impl<'a> Fetch<'a> { + fn new( + registry: &Registry, + path: &str, + policy: &'a ImagePolicy, + ) -> Result { + let client = Client::builder() + .https_only(registry.origin.scheme() == "https") + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|_| error("Cannot initialize registry TLS client."))?; + Ok(Self { + client, + registry: registry.clone(), + path: path.to_owned(), + policy, + authorization: None, + stage: Stage::Anonymous, + deadline: Instant::now() + ACQUISITION_DEADLINE, + }) + } + + fn remaining(&self) -> Result { + self.deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| error("Registry acquisition deadline exceeded.")) + } + + fn url(&self, kind: &str, reference: &str) -> Result { + self.registry + .origin + .join(&format!("/v2/{}/{kind}/{reference}", self.path)) + .map_err(|_| error("Invalid registry object address.")) + } + + /// One request with redirects followed under [`redirect_allowed`]. Authorization is + /// attached only while the request targets the registry origin. + fn send(&self, mut url: Url, timeout: Duration) -> Result { for hop in 0..=4 { - let remaining = self - .deadline - .checked_duration_since(Instant::now()) - .ok_or_else(|| error("Registry acquisition deadline exceeded."))?; let mut request = self .client .get(url.clone()) - .timeout(remaining.min(Duration::from_secs(60))) + .timeout(self.remaining()?.min(timeout)) .header(ACCEPT, ACCEPT_MANIFEST); - // Never forward even anonymous bearer credentials to a blob CDN. - if authenticated && url.host_str() == Some("registry-1.docker.io") { - request = request.bearer_auth(self.token.as_str()); + if let Some(value) = self + .authorization + .as_ref() + .filter(|_| same_origin(&self.registry.origin, &url)) + { + let mut header = HeaderValue::from_str(value) + .map_err(|_| error("Invalid registry authorization."))?; + header.set_sensitive(true); + request = request.header(AUTHORIZATION, header); } let response = request .send() - .map_err(|_| error("Registry HTTPS request failed."))?; - if response.status().is_redirection() { - if hop == 4 { - return Err(error("Registry redirect limit exceeded.")); - } - let location = response + .map_err(|_| error("Registry request failed or timed out."))?; + if !response.status().is_redirection() { + return Ok(response); + } + if hop == 4 { + break; + } + let location = response + .headers() + .get(LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| error("Invalid registry redirect."))?; + let next = url + .join(location) + .map_err(|_| error("Invalid registry redirect."))?; + if !redirect_allowed(&self.registry, &next) { + return Err(error("Registry redirect destination is not supported.")); + } + url = next; + } + Err(error("Registry redirect limit exceeded.")) + } + + /// A successful response for `url`, answering at most two authentication challenges: + /// anonymously first, then with the host user's Docker credentials. + fn get(&mut self, url: Url, timeout: Duration) -> Result { + loop { + let response = self.send(url.clone(), timeout)?; + let status = response.status(); + if status == StatusCode::UNAUTHORIZED && self.stage != Stage::Credentialed { + let challenge = response .headers() - .get(reqwest::header::LOCATION) + .get(WWW_AUTHENTICATE) .and_then(|v| v.to_str().ok()) - .ok_or_else(|| error("Invalid registry redirect."))?; - let next = url - .join(location) - .map_err(|_| error("Invalid registry redirect."))?; - if !redirect_allowed(&next) { - return Err(error("Registry redirect destination is not supported.")); - } - url = next; + .ok_or_else(|| error("Registry requires authentication without a challenge.")) + .and_then(parse_challenge)?; + self.authorize(&challenge)?; continue; } - if response.status() == reqwest::StatusCode::NOT_FOUND { - return Err(CandidateError::new( + return match status { + s if s.is_success() => Ok(response), + StatusCode::NOT_FOUND => Err(CandidateError::new( "registry_image_not_found", - "The public registry image or requested tag was not found.", - )); + "The registry image or requested tag was not found.", + )), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(error( + "The registry refused access with the Docker credentials configured for it.", + )), + StatusCode::TOO_MANY_REQUESTS => Err(error( + "The registry rate-limited this request; retry later or configure credentials.", + )), + _ => Err(error("The registry request was refused.")), + }; + } + } + + /// Advance one authentication stage for `challenge`. + fn authorize(&mut self, challenge: &Challenge) -> Result<(), CandidateError> { + let credentialed = self.stage == Stage::AnonymousToken || !challenge.bearer; + let credential = if credentialed { + self.stage = Stage::Credentialed; + Some( + registry_credentials::lookup(self.policy, &self.registry.domain)?.ok_or_else( + || { + error( + "The registry requires credentials; none are configured for it in the Docker configuration.", + ) + }, + )?, + ) + } else { + self.stage = Stage::AnonymousToken; + None + }; + if !challenge.bearer { + let basic = credential + .as_ref() + .and_then(Credential::basic_header) + .ok_or_else(|| error("The registry requires username and password credentials."))?; + self.authorization = Some(basic); + return Ok(()); + } + let realm = challenge + .realm + .as_deref() + .and_then(|r| Url::parse(r).ok()) + .filter(|r| realm_allowed(&self.registry, r)) + .ok_or_else(|| error("The registry token endpoint is not supported."))?; + let token = match self.token(&realm, challenge.service.as_deref(), credential.as_ref()) { + Err(failure) if credential.is_none() && failure.code == "registry_token_refused" => { + // An endpoint that refuses anonymous tokens needs credentials. + return self.authorize(challenge); } - if !response.status().is_success() { - return Err(error( - "Public registry request was refused; no local credentials were used.", - )); + other => other?, + }; + self.authorization = Some(Zeroizing::new(format!("Bearer {}", token.as_str()))); + Ok(()) + } + + fn token( + &self, + realm: &Url, + service: Option<&str>, + credential: Option<&Credential>, + ) -> Result, CandidateError> { + let scope = format!("repository:{}:pull", self.path); + let request = match credential { + Some(Credential::IdentityToken(refresh)) => { + let mut form = vec![ + ("grant_type", "refresh_token"), + ("client_id", "hack"), + ("scope", scope.as_str()), + ("refresh_token", refresh.as_str()), + ]; + if let Some(service) = service { + form.push(("service", service)); + } + self.client.post(realm.clone()).form(&form) } - if response.content_length().is_some_and(|n| n > limit as u64) { - return Err(error("Registry response exceeds its byte limit.")); + basic => { + let mut url = realm.clone(); + { + let mut query = url.query_pairs_mut(); + if let Some(service) = service { + query.append_pair("service", service); + } + query.append_pair("scope", &scope); + } + let mut request = self.client.get(url); + if let Some(value) = basic.and_then(Credential::basic_header) { + let mut header = HeaderValue::from_str(&value) + .map_err(|_| error("Invalid registry credentials."))?; + header.set_sensitive(true); + request = request.header(AUTHORIZATION, header); + } + request } - return bounded(response, limit); + }; + let response = request + .timeout(self.remaining()?.min(REQUEST_TIMEOUT)) + .send() + .map_err(|_| error("Registry token request failed or timed out."))?; + if matches!( + response.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ) { + return Err(CandidateError::new( + if credential.is_some() { + "registry_image" + } else { + "registry_token_refused" + }, + "The registry token endpoint refused the Docker credentials configured for it.", + )); } - Err(error("Registry redirect limit exceeded.")) + if !response.status().is_success() { + return Err(error("The registry token endpoint refused the request.")); + } + #[derive(Deserialize)] + struct Token { + token: Option, + access_token: Option, + } + let bytes = Zeroizing::new(bounded(response, MAX_TOKEN_RESPONSE)?); + let mut parsed: Token = serde_json::from_slice(&bytes) + .map_err(|_| error("Invalid registry token response."))?; + let token = Zeroizing::new( + parsed + .token + .take() + .filter(|t| !t.is_empty()) + .or_else(|| parsed.access_token.take()) + .unwrap_or_default(), + ); + if let Some(mut other) = parsed.access_token.take() { + zeroize::Zeroize::zeroize(&mut other); + } + if token.is_empty() + || token.len() > MAX_TOKEN + || token.bytes().any(|b| b.is_ascii_control() || b == b' ') + { + return Err(error("Invalid registry token.")); + } + Ok(token) } - fn object( - &self, - repo: &str, - kind: &str, - d: &Descriptor, - limit: usize, - ) -> Result, CandidateError> { - if !valid_digest(&d.digest) || d.size == 0 || d.size > limit as u64 { + + fn manifest(&mut self, reference: &str) -> Result, CandidateError> { + let url = self.url("manifests", reference)?; + let response = self.get(url, REQUEST_TIMEOUT)?; + if response + .content_length() + .is_some_and(|n| n > MAX_METADATA as u64) + { + return Err(error("Registry response exceeds its byte limit.")); + } + bounded(response, MAX_METADATA) + } + + fn checked(d: &Descriptor, limit: u64) -> Result<(), CandidateError> { + if !valid_digest(&d.digest) || d.size == 0 || d.size > limit || !d.urls.is_empty() { return Err(error( "Registry descriptor exceeds its identity or size limits.", )); } - let url = Url::parse(&format!( - "https://registry-1.docker.io/v2/{repo}/{kind}/{}", - d.digest - )) - .map_err(|_| error("Invalid registry object address."))?; - let bytes = self.bytes(url, d.size as usize, true)?; + Ok(()) + } + + /// One small object (manifest or config) verified against its descriptor. + fn object(&mut self, kind: &str, d: &Descriptor) -> Result, CandidateError> { + Self::checked(d, MAX_METADATA as u64)?; + let response = self.get(self.url(kind, &d.digest)?, REQUEST_TIMEOUT)?; + let bytes = bounded(response, d.size as usize)?; verify_object(d, &bytes)?; Ok(bytes) } + + /// A layer blob as a reader that enforces its descriptor's exact size and digest. + fn blob(&mut self, d: &Descriptor) -> Result, CandidateError> { + let timeout = REQUEST_TIMEOUT + Duration::from_secs(d.size / MIN_BLOB_BYTES_PER_SECOND); + let response = self.get(self.url("blobs", &d.digest)?, timeout)?; + if response.content_length().is_some_and(|n| n != d.size) { + return Err(error("Registry object digest or size mismatch.")); + } + Ok(Verified { + inner: response, + digest: Sha256::new(), + total: 0, + size: d.size, + expected: d.digest.clone(), + }) + } } + fn verify_object(descriptor: &Descriptor, bytes: &[u8]) -> Result<(), CandidateError> { if bytes.len() as u64 != descriptor.size || digest(bytes) != descriptor.digest { return Err(error("Registry object digest or size mismatch.")); @@ -220,23 +750,69 @@ fn verify_object(descriptor: &Descriptor, bytes: &[u8]) -> Result<(), CandidateE Ok(()) } -fn append( - builder: &mut tar::Builder>, - name: &str, - bytes: &[u8], -) -> Result<(), CandidateError> { +/// Streams exactly `size` bytes and fails at their end unless they hash to `expected`; +/// a short, long or altered blob therefore cannot complete its archive member. +struct Verified { + inner: R, + digest: Sha256, + total: u64, + size: u64, + expected: String, +} +impl Read for Verified { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buffer)?; + self.total += n as u64; + if self.total > self.size { + return Err(std::io::Error::other("blob exceeds its descriptor size")); + } + if n == 0 { + let actual = format!("sha256:{:x}", std::mem::take(&mut self.digest).finalize()); + if self.total != self.size || actual != self.expected { + return Err(std::io::Error::other("blob digest or size mismatch")); + } + return Ok(0); + } + self.digest.update(&buffer[..n]); + Ok(n) + } +} + +/// Hashes and counts the archive as it is written. +struct HashingWriter { + inner: W, + digest: Sha256, + total: u64, + limit: u64, +} +impl Write for HashingWriter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + let n = self.inner.write(buffer)?; + self.total += n as u64; + if self.total > self.limit { + return Err(std::io::Error::other("archive exceeds its budget")); + } + self.digest.update(&buffer[..n]); + Ok(n) + } + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +fn header(size: u64) -> tar::Header { let mut h = tar::Header::new_gnu(); - h.set_size(bytes.len() as u64); + h.set_size(size); h.set_mode(0o600); h.set_cksum(); - builder - .append_data(&mut h, name, bytes) - .map_err(|_| error("Cannot assemble image archive."))?; - if builder.get_ref().len() > MAX_ARCHIVE { - return Err(error("Image archive exceeds 256 MiB.")); - } - Ok(()) + h } + +/// Tar bytes for one member of `size`: header plus data padded to 512. +fn member_bytes(size: u64) -> u64 { + 512 + size.div_ceil(512) * 512 +} + #[derive(Debug, Serialize)] pub struct AcquiredImage { pub source_digest: String, @@ -245,79 +821,39 @@ pub struct AcquiredImage { pub archive_sha256: String, pub archive_bytes: u64, } -fn authenticated_fetch(repo: &str) -> Result { - let mut fetch = Fetch { - client: Client::builder() - .https_only(true) - .no_proxy() - .redirect(Policy::none()) - .connect_timeout(Duration::from_secs(10)) - .build() - .map_err(|_| error("Cannot initialize registry TLS client."))?, - token: Zeroizing::new(String::new()), - deadline: Instant::now() + Duration::from_secs(300), - }; - let mut token_url = - Url::parse("https://auth.docker.io/token").map_err(|_| error("Invalid token endpoint."))?; - token_url - .query_pairs_mut() - .append_pair("service", "registry.docker.io") - .append_pair("scope", &format!("repository:{repo}:pull")); - #[derive(Deserialize)] - struct Token { - token: String, - } - let token_bytes = Zeroizing::new(fetch.bytes(token_url, 64 * 1024, false)?); - let token: Token = serde_json::from_slice(&token_bytes) - .map_err(|_| error("Invalid anonymous registry token response."))?; - if token.token.len() > 32 * 1024 || token.token.bytes().any(|b| b.is_ascii_control()) { - return Err(error("Invalid anonymous registry token.")); - } - fetch.token = Zeroizing::new(token.token); - Ok(fetch) -} - -/// Normalized Docker Hub `namespace/repository` and tag for mutable resolution. -/// Shorthand expands as Docker does: `alpine` is `library/alpine:latest`. -pub(super) fn tagged_reference(value: &str) -> Result<(String, String), CandidateError> { - if value.contains('@') { - return Err(error( - "Tag resolution requires a repository and optional tag, not a digest.", - )); - } - let named = docker_hub(value)?; - let tag = named.tag.unwrap_or_else(|| "latest".into()); - Ok((named.path, tag)) -} /// The admitted form of one authored image reference. #[derive(Debug, Serialize)] pub struct AdmittedImage { /// Canonical normalized reference, for example `docker.io/library/alpine:3.21`. pub reference: String, - /// `image_id` for an already-local content ID, `docker_hub` for an acquirable reference. + /// `image_id` for a local content ID, `docker_hub` for Docker Hub, `registry` for any + /// other admitted registry. pub kind: &'static str, } -/// Pure admission for one authored image reference. It normalizes Docker shorthand -/// and refuses anything the provider could not later acquire. No network, credential, -/// candidate state or runtime effect occurs, so a caller can refuse before any VM or -/// provider starts. Acquisition re-normalizes with the same function. -pub fn admit(value: &str) -> Result { +/// Pure admission for one authored image reference. It normalizes Docker shorthand and +/// refuses anything the provider could not later acquire under `policy`, such as a local +/// registry that is not explicitly allowed. No network, credential, candidate state or +/// runtime effect occurs, so a caller can refuse before any VM or provider starts. +/// Acquisition re-normalizes with the same rules. +pub fn admit(value: &str, policy: &ImagePolicy) -> Result { match normalize(value) { Ok(ImageReference::ImageId(id)) => Ok(AdmittedImage { reference: id, kind: "image_id", }), - Ok(ImageReference::Named(named)) if named.docker_hub_repository().is_some() => { + Ok(ImageReference::Named(named)) => { + let registry = registry(&named, policy)?; Ok(AdmittedImage { reference: named.canonical(), - kind: "docker_hub", + kind: if registry.docker_hub() { + "docker_hub" + } else { + "registry" + }, }) } - Ok(ImageReference::Named(_)) => Err(error( - "Only public Docker Hub references or sha256 image IDs are supported; other registries are not acquired.", - )), Err(reason) => Err(error(reason.message())), } } @@ -331,37 +867,55 @@ pub struct ResolvedImage { pub platform: &'static str, } -/// Resolve public mutable metadata once and return an explicit immutable identity. -/// No image layers, archive, runtime state or local credentials are accessed. A later -/// fetch must use pinned_reference: reusing the original tag would discard this review. -pub fn resolve(reference_text: &str) -> Result { - let (repo, tag) = tagged_reference(reference_text)?; - let (repo, tag) = (repo.as_str(), tag.as_str()); - let fetch = authenticated_fetch(repo)?; - let url = Url::parse(&format!( - "https://registry-1.docker.io/v2/{repo}/manifests/{tag}" - )) - .map_err(|_| error("Invalid manifest address."))?; - let initial = fetch.bytes(url, MAX_METADATA, true)?; - let source_digest = digest(&initial); - let mut manifest = parse_manifest(&initial, &source_digest)?; - let mut manifest_digest = source_digest.clone(); +/// The selected single-image manifest and its digest, from an index or a manifest. +fn select( + fetch: &mut Fetch<'_>, + initial: &[u8], + source_digest: &str, +) -> Result<(Manifest, String), CandidateError> { + let mut manifest = parse_manifest(initial, source_digest)?; + let mut manifest_digest = source_digest.to_owned(); if !manifest.manifests.is_empty() { - let selected = arm64(&manifest)?; + let selected = arm64(&manifest)?.clone(); manifest_digest = selected.digest.clone(); - let bytes = fetch.object(repo, "manifests", selected, MAX_METADATA)?; + let bytes = fetch.object("manifests", &selected)?; manifest = parse_manifest(&bytes, &manifest_digest)?; } - if !manifest.manifests.is_empty() || manifest.layers.is_empty() || manifest.layers.len() > 128 { + if !manifest.manifests.is_empty() + || manifest.layers.is_empty() + || manifest.layers.len() > MAX_LAYERS + { return Err(error("Only a bounded single-image manifest is supported.")); } let config = manifest .config + .as_ref() .ok_or_else(|| error("Image config descriptor is missing."))?; - let config_bytes = fetch.object(repo, "blobs", &config, MAX_METADATA)?; + if !config.media_type.is_empty() && !CONFIG_TYPES.contains(&config.media_type.as_str()) { + return Err(error("The manifest does not describe a container image.")); + } + Ok((manifest, manifest_digest)) +} + +/// Resolve mutable metadata once and return an explicit immutable identity. No image +/// layers, archive or runtime state are accessed; credentials are read only if the +/// registry challenges. A later fetch must use `pinned_reference`. +pub fn resolve( + reference_text: &str, + policy: &ImagePolicy, +) -> Result { + let (registry, path, tag) = tagged(reference_text, policy)?; + let mut fetch = Fetch::new(®istry, &path, policy)?; + let initial = fetch.manifest(&tag)?; + let source_digest = digest(&initial); + let (manifest, manifest_digest) = select(&mut fetch, &initial, &source_digest)?; + let config = manifest + .config + .ok_or_else(|| error("Image config descriptor is missing."))?; + let config_bytes = fetch.object("blobs", &config)?; verify_platform(&config_bytes)?; Ok(ResolvedImage { - pinned_reference: format!("docker.io/{repo}@{source_digest}"), + pinned_reference: format!("{}/{path}@{source_digest}", registry.domain), source_digest, manifest_digest, image_id: config.digest, @@ -382,85 +936,126 @@ fn verify_platform(config_bytes: &[u8]) -> Result<(), CandidateError> { Ok(()) } -/// Acquire one public pinned Linux ARM64 image into a new explicit archive. The output -/// must not exist. A failure never imports an image or changes candidate runtime state. -/// HTTPS requests have a 60s bound and the complete acquisition a 300s deadline. -/// SIGINT/process cancellation stops acquisition; no child transfer outlives the caller. -pub fn acquire(reference_text: &str, output: &Path) -> Result { - let (repo, pin) = reference(reference_text)?; - let (repo, pin) = (repo.as_str(), pin.as_str()); +fn layer_extension(media_type: &str) -> Result<&'static str, CandidateError> { + match media_type { + "application/vnd.oci.image.layer.v1.tar+gzip" + | "application/vnd.docker.image.rootfs.diff.tar.gzip" => Ok("tar.gz"), + "application/vnd.oci.image.layer.v1.tar+zstd" => Ok("tar.zst"), + "application/vnd.oci.image.layer.v1.tar" + | "application/vnd.docker.image.rootfs.diff.tar" => Ok("tar"), + _ => Err(error( + "Only distributable gzip, zstd or uncompressed image layers are supported.", + )), + } +} + +/// Acquire one pinned Linux ARM64 image into a new explicit archive of at most `limit` +/// bytes (and the policy's per-image budget). The output must not exist. A failure never +/// imports an image or changes candidate runtime state, and leaves no final archive. +/// The complete acquisition has a 360-second deadline; process cancellation stops it. +pub fn acquire( + reference_text: &str, + output: &Path, + policy: &ImagePolicy, + limit: u64, +) -> Result { + let pinned = pinned(reference_text, policy)?; + let limit = limit.min(policy.archive_limit); if output.exists() { return Err(error("Image archive output already exists.")); } - let fetch = authenticated_fetch(repo)?; - let url = Url::parse(&format!( - "https://registry-1.docker.io/v2/{repo}/manifests/{pin}" - )) - .map_err(|_| error("Invalid manifest address."))?; - let initial = fetch.bytes(url, MAX_METADATA, true)?; - let mut manifest = parse_manifest(&initial, pin)?; - let mut manifest_digest = pin.to_owned(); - if !manifest.manifests.is_empty() { - let selected = arm64(&manifest)?; - manifest_digest = selected.digest.clone(); - let bytes = fetch.object(repo, "manifests", selected, MAX_METADATA)?; - manifest = parse_manifest(&bytes, &manifest_digest)?; - } - if !manifest.manifests.is_empty() || manifest.layers.is_empty() || manifest.layers.len() > 128 { - return Err(error("Only a bounded single-image manifest is supported.")); - } + let mut fetch = Fetch::new(&pinned.registry, &pinned.path, policy)?; + let initial = fetch.manifest(&pinned.digest)?; + let (manifest, manifest_digest) = select(&mut fetch, &initial, &pinned.digest)?; let config = manifest .config + .clone() .ok_or_else(|| error("Image config descriptor is missing."))?; - let config_bytes = fetch.object(repo, "blobs", &config, MAX_METADATA)?; - let mut builder = tar::Builder::new(Vec::new()); - let config_name = format!("{}.json", config.digest.trim_start_matches("sha256:")); - append(&mut builder, &config_name, &config_bytes)?; + let config_bytes = fetch.object("blobs", &config)?; + verify_platform(&config_bytes)?; let mut names = Vec::new(); + let mut planned = member_bytes(config.size) + member_bytes(64 * 1024) + 1024; for (i, layer) in manifest.layers.iter().enumerate() { - if !matches!( - layer.media_type.as_str(), - "application/vnd.oci.image.layer.v1.tar+gzip" - | "application/vnd.docker.image.rootfs.diff.tar.gzip" - ) { - return Err(error("Only distributable gzip image layers are supported.")); - } - let remaining = MAX_ARCHIVE - .saturating_sub(builder.get_ref().len()) - .saturating_sub(4096); - let bytes = fetch.object(repo, "blobs", layer, remaining)?; - let name = format!("layer-{i}.tar.gz"); - append(&mut builder, &name, &bytes)?; - names.push(name); + names.push(format!("layer-{i}.{}", layer_extension(&layer.media_type)?)); + Fetch::checked(layer, super::image_policy::ARCHIVE_CEILING)?; + planned = planned.saturating_add(member_bytes(layer.size)); } + // The manifest states every size, so an image beyond the budget refuses before any + // layer transfer or disk write. + if planned > limit { + return Err(error( + "The image exceeds the configured image budget or the remaining image cache budget.", + )); + } + let config_name = format!("{}.json", config.digest.trim_start_matches("sha256:")); + let deadline = fetch.deadline; let manifest_bytes = serde_json::to_vec( &serde_json::json!([{"Config":config_name,"Layers":names,"RepoTags":[]}]), ) .map_err(|_| error("Cannot encode image archive metadata."))?; - append(&mut builder, "manifest.json", &manifest_bytes)?; - let bytes = builder - .into_inner() - .map_err(|_| error("Cannot finish image archive."))?; - image_load::validate_download(&bytes, &config.digest)?; - if Instant::now() >= fetch.deadline { - return Err(error("Registry acquisition deadline exceeded.")); - } - let result = AcquiredImage { - source_digest: pin.into(), + let written = publish( + output, + |file| { + let mut builder = tar::Builder::new(HashingWriter { + inner: file, + digest: Sha256::new(), + total: 0, + limit, + }); + let assemble = || error("Cannot assemble the image archive within its budget."); + builder + .append_data( + &mut header(config.size), + &config_name, + config_bytes.as_slice(), + ) + .map_err(|_| assemble())?; + for (layer, name) in manifest.layers.iter().zip(&names) { + let mut blob = fetch.blob(layer)?; + builder + .append_data(&mut header(layer.size), name, &mut blob) + .map_err(|_| { + error("A registry layer transfer failed or did not match its digest and size.") + })?; + } + builder + .append_data( + &mut header(manifest_bytes.len() as u64), + "manifest.json", + manifest_bytes.as_slice(), + ) + .map_err(|_| assemble())?; + let writer = builder.into_inner().map_err(|_| assemble())?; + Ok((format!("{:x}", writer.digest.finalize()), writer.total)) + }, + |temporary, (sha, total)| { + if Instant::now() >= deadline { + return Err(error("Registry acquisition deadline exceeded.")); + } + if image_load::validate_file(temporary, sha, &config.digest, policy)? != *total { + return Err(error( + "The assembled image archive changed before publication.", + )); + } + Ok(()) + }, + )?; + Ok(AcquiredImage { + source_digest: pinned.digest, manifest_digest, image_id: config.digest, - archive_sha256: format!("{:x}", Sha256::digest(&bytes)), - archive_bytes: bytes.len() as u64, - }; - publish(output, |file| file.write_all(&bytes))?; - Ok(result) + archive_sha256: written.0, + archive_bytes: written.1, + }) } -/// A complete synced inode is linked into place without replacing a competing output. -fn publish( +/// Write a new exclusive temporary file, sync and verify it, then link it into place +/// without replacing a competing output. +fn publish( output: &Path, - write: impl FnOnce(&mut File) -> std::io::Result<()>, -) -> Result<(), CandidateError> { + write: impl FnOnce(&mut File) -> Result, + verify: impl FnOnce(&Path, &T) -> Result<(), CandidateError>, +) -> Result { static NEXT: AtomicU64 = AtomicU64::new(0); let parent = output .parent() @@ -479,9 +1074,10 @@ fn publish( .open(&temporary) .map_err(|_| error("Cannot exclusively create the temporary image archive."))?; let result = (|| { - write(&mut file) - .and_then(|_| file.sync_all()) + let value = write(&mut file)?; + file.sync_all() .map_err(|_| error("Image archive write failed before publication."))?; + verify(&temporary, &value)?; fs::hard_link(&temporary, output).map_err(|_| { error("Cannot publish the image archive without replacing an existing output.") })?; @@ -491,7 +1087,7 @@ fn publish( File::open(parent) .and_then(|directory| directory.sync_all()) .map_err(|_| error("Complete image archive published; directory sync failed."))?; - Ok(()) + Ok(value) })(); // Only the exclusive temporary path created by this call is ever removed. if result.is_err() { @@ -501,252 +1097,5 @@ fn publish( } #[cfg(test)] -mod tests { - use super::*; - #[test] - fn publication_preserves_existing_output_and_failed_writes_leave_no_final_file() { - static NEXT_TEST: AtomicU64 = AtomicU64::new(0); - let root = std::env::temp_dir().join(format!( - "hack-image-publish-{}-{}", - std::process::id(), - NEXT_TEST.fetch_add(1, Ordering::Relaxed) - )); - fs::create_dir(&root).unwrap(); - let output = root.join("image.tar"); - fs::write(&output, b"existing").unwrap(); - assert!(publish(&output, |file| file.write_all(b"replacement")).is_err()); - assert_eq!(fs::read(&output).unwrap(), b"existing"); - fs::remove_file(&output).unwrap(); - assert!( - publish(&output, |file| { - file.write_all(b"partial")?; - Err(std::io::Error::other("injected write failure")) - }) - .is_err() - ); - assert!(!output.exists()); - assert_eq!(fs::read_dir(&root).unwrap().count(), 0); - publish(&output, |file| file.write_all(b"complete")).unwrap(); - assert_eq!(fs::read(&output).unwrap(), b"complete"); - assert_eq!(fs::read_dir(&root).unwrap().count(), 1); - fs::remove_dir_all(root).unwrap(); - } - #[test] - fn mutable_resolution_is_explicit_and_rejects_non_repository_inputs() { - let owned = |repo: &str, tag: &str| (repo.to_owned(), tag.to_owned()); - assert_eq!( - tagged_reference("bitnami/redis").unwrap(), - owned("bitnami/redis", "latest") - ); - assert_eq!( - tagged_reference("docker.io/chromedp/headless-shell:stable").unwrap(), - owned("chromedp/headless-shell", "stable") - ); - // Ordinary Docker Hub shorthand expands to the official library namespace. - assert_eq!( - tagged_reference("alpine:3.21").unwrap(), - owned("library/alpine", "3.21") - ); - assert_eq!( - tagged_reference("redis").unwrap(), - owned("library/redis", "latest") - ); - for value in [ - "Redis", - "ghcr.io/a/b:tag", - "localhost:5000/a:tag", - "a/b/c:tag", - format!("sha256:{}", "e".repeat(64)).as_str(), - "host.test/a/b:tag", - "https://docker.io/a/b:tag", - "a/b@sha256:bad", - "a/b:", - "a/b:-bad", - "a/b:foo?bar", - "a/b:foo/bar", - "a/..:tag", - "a/b:PRIVATE CANARY", - ] { - assert!(tagged_reference(value).is_err(), "{value}"); - } - assert!(reference("bitnami/redis:latest").is_err()); - } - #[test] - fn resolution_requires_unambiguous_linux_arm64_and_actual_config_architecture() { - let bytes=br#"{"schemaVersion":2,"manifests":[{"digest":"sha256:unused","size":10,"mediaType":"manifest","platform":{"os":"linux","architecture":"amd64"}}]}"#; - assert!(arm64(&parse_manifest(bytes, &digest(bytes)).unwrap()).is_err()); - for bytes in [ - br#"{"os":"linux","architecture":"arm64"}"#.as_slice(), - br#"{"os":"linux","architecture":"arm64","variant":"v8"}"#.as_slice(), - ] { - assert!(verify_platform(bytes).is_ok()); - } - for bytes in [ - br#"{"os":"linux","architecture":"amd64"}"#.as_slice(), - br#"{"os":"linux","architecture":"arm64","variant":"v9"}"#.as_slice(), - br#"{"os":"darwin","architecture":"arm64"}"#.as_slice(), - ] { - assert!(verify_platform(bytes).is_err()); - } - // Duplicate supported entries remain ambiguous, even when their digests agree. - let descriptor = serde_json::json!({"digest":"sha256:unused","size":10,"mediaType":"manifest","platform":{"os":"linux","architecture":"arm64"}}); - let bytes = serde_json::to_vec( - &serde_json::json!({"schemaVersion":2,"manifests":[descriptor.clone(),descriptor]}), - ) - .unwrap(); - assert!(arm64(&parse_manifest(&bytes, &digest(&bytes)).unwrap()).is_err()); - } - #[test] - fn only_public_digest_pinned_repository_references_are_accepted() { - let pin = format!("sha256:{}", "a".repeat(64)); - for prefix in [ - "imbios/bun-node", - "imbios/bun-node:1.3.14-24-alpine", - "docker.io/imbios/bun-node", - ] { - assert_eq!( - reference(&format!("{prefix}@{pin}")).unwrap(), - ("imbios/bun-node".to_owned(), pin.clone()) - ); - } - for prefix in ["alpine", "alpine:3.21", "docker.io/alpine"] { - assert_eq!( - reference(&format!("{prefix}@{pin}")).unwrap(), - ("library/alpine".to_owned(), pin.clone()) - ); - } - for value in [ - format!("ghcr.io/a/b@{pin}").as_str(), - format!("localhost:5000/a@{pin}").as_str(), - format!("Imbios/bun-node@{pin}").as_str(), - "imbios/bun-node:latest", - "https://docker.io/a/b", - "user:secret@docker.io/a/b", - "example.com/a/b@sha256:bad", - "../bad@sha256:bad", - ] { - assert!(reference(value).is_err()); - } - } - #[test] - fn admission_normalizes_hub_shorthand_and_refuses_unacquirable_references() { - let pin = format!("sha256:{}", "a".repeat(64)); - let id = format!("sha256:{}", "e".repeat(64)); - for (input, reference, kind) in [ - ( - "alpine", - "docker.io/library/alpine:latest".to_owned(), - "docker_hub", - ), - ( - "alpine:3.21", - "docker.io/library/alpine:3.21".to_owned(), - "docker_hub", - ), - ( - "user/img", - "docker.io/user/img:latest".to_owned(), - "docker_hub", - ), - ( - &*format!("alpine@{pin}"), - format!("docker.io/library/alpine@{pin}"), - "docker_hub", - ), - (id.as_str(), id.clone(), "image_id"), - ] { - let admitted = admit(input).unwrap(); - assert_eq!( - (admitted.reference.as_str(), admitted.kind), - (reference.as_str(), kind) - ); - } - for input in [ - "", - "PRIVATE_CANARY", - "ghcr.io/private-canary/app:v1", - "localhost:5000/private-canary", - "private-canary/nested/app", - "private-canary@sha256:bad", - ] { - let failure = admit(input).unwrap_err(); - assert_eq!(failure.code, "registry_image"); - assert!(!failure.message.to_ascii_lowercase().contains("canary")); - } - } - #[test] - fn redirects_cannot_downgrade_or_reach_unrelated_or_local_origins() { - for url in [ - "https://registry-1.docker.io/v2/", - "https://production.cloudflare.docker.com/blob?opaque=private", - "https://docker-images-prod.abc.r2.cloudflarestorage.com/blob", - ] { - assert!(redirect_allowed(&Url::parse(url).unwrap())); - } - for url in [ - "http://production.cloudflare.docker.com/blob", - "https://127.0.0.1/blob", - "https://registry-1.docker.io:444/blob", - "https://registry-1.docker.io.evil.test/blob", - "https://user:secret@registry-1.docker.io/blob", - ] { - assert!(!redirect_allowed(&Url::parse(url).unwrap())); - } - } - #[test] - fn manifests_bind_exact_bytes_and_select_one_arm64_variant() { - let descriptor = serde_json::json!({"mediaType":"application/vnd.oci.image.manifest.v1+json","size":12,"digest":format!("sha256:{}","a".repeat(64)),"platform":{"os":"linux","architecture":"arm64","variant":"v8"}}); - let bytes = - serde_json::to_vec(&serde_json::json!({"schemaVersion":2,"manifests":[descriptor]})) - .unwrap(); - let mut m = parse_manifest(&bytes, &digest(&bytes)).unwrap(); - assert!(arm64(&m).is_ok()); - assert!(parse_manifest(&bytes, &format!("sha256:{}", "0".repeat(64))).is_err()); - m.manifests.push(m.manifests[0].clone()); - assert!(arm64(&m).is_err()); - m.manifests.clear(); - assert!(arm64(&m).is_err()); - assert!(bounded(&b"12345"[..], 4).is_err()); - assert_eq!(bounded(&b"1234"[..], 4).unwrap(), b"1234"); - } - #[test] - fn compressed_objects_require_both_exact_size_and_digest() { - let mut descriptor = Descriptor { - digest: digest(b"compressed fixture"), - size: 18, - media_type: String::new(), - platform: None, - }; - assert!(verify_object(&descriptor, b"compressed fixture").is_ok()); - assert!(verify_object(&descriptor, b"compressed fixturE").is_err()); - descriptor.size = 17; - assert!(verify_object(&descriptor, b"compressed fixture").is_err()); - } - #[test] - fn invalid_reference_refuses_without_creating_archive() { - let output = - std::env::temp_dir().join(format!("hack-registry-invalid-{}.tar", std::process::id())); - assert!(acquire("PRIVATE_CANARY", &output).is_err()); - assert!(!output.exists()); - assert!( - !acquire("PRIVATE_CANARY", &output) - .unwrap_err() - .message - .contains("PRIVATE_CANARY") - ); - } - #[test] - #[ignore = "Explicit public Docker Hub acquisition; no runtime import"] - fn pinned_event_agent_image_downloads_without_docker() { - let path = std::env::var("HACK_REGISTRY_TEST_ARCHIVE").expect("explicit new archive path"); - let receipt=acquire("imbios/bun-node:1.3.14-24-alpine@sha256:744fb75f37bbe5cca4ba5e9645c7e4454adb58e17752caf53dd61924123297dc",Path::new(&path)).unwrap(); - let bytes = std::fs::read(&path).unwrap(); - assert_eq!(bytes.len() as u64, receipt.archive_bytes); - assert_eq!( - format!("{:x}", Sha256::digest(&bytes)), - receipt.archive_sha256 - ); - image_load::validate_download(&bytes, &receipt.image_id).unwrap(); - println!("{}", serde_json::to_string(&receipt).unwrap()); - } -} +#[path = "registry_image_test.rs"] +mod tests; diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs new file mode 100644 index 000000000..5a023cc04 --- /dev/null +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -0,0 +1,1117 @@ +//! Registry acquisition tests against an in-process fake OCI distribution registry on +//! loopback. The fake registry is admitted only through an explicit `http://` local +//! registry opt-in; no test reaches a public registry, the user's Docker configuration, +//! a credential store or a runtime. +use super::*; +use crate::provider::image_load::tests::{Layer, compress}; +use crate::provider::image_policy::LocalRegistry; +use crate::provider::registry_credentials::tests::DockerFixture; +use base64::Engine as _; +use std::{ + collections::BTreeMap, + io::{BufRead, BufReader}, + net::{TcpListener, TcpStream}, + path::PathBuf, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering as AtomicOrdering}, + }, + thread::JoinHandle, +}; + +const REPOSITORY: &str = "team/sub/app"; +const USER: &str = "fixture-user"; +const PASSWORD: &str = "fixture-password"; + +#[derive(Clone, Debug)] +struct Request { + method: String, + path: String, + authorization: Option, + body: String, +} + +struct Reply { + status: u16, + headers: Vec<(String, String)>, + body: Vec, +} + +impl Reply { + fn new(status: u16, body: impl Into>) -> Self { + Self { + status, + headers: Vec::new(), + body: body.into(), + } + } + fn header(mut self, name: &str, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Auth { + None, + /// Bearer realm on the registry origin; anonymous tokens cannot pull. + Bearer, + /// Basic authentication on every registry request. + Basic, +} + +#[derive(Clone)] +struct Content { + manifests: BTreeMap)>, + blobs: BTreeMap>, + tag: String, + index_digest: String, + manifest_digest: String, + config_digest: String, + layer_digests: Vec, +} + +fn descriptor(media_type: &str, bytes: &[u8]) -> serde_json::Value { + serde_json::json!({"mediaType": media_type, "digest": digest(bytes), "size": bytes.len()}) +} + +/// One multi-platform image: a zstd and a gzip layer for Linux ARM64, a decoy AMD64 +/// manifest and an attestation-style entry, indexed under tag `v1`. +fn content() -> Content { + let payloads: Vec> = vec![ + (0..40_000_u32).map(|n| (n % 241) as u8).collect(), + (0..30_000_u32).map(|n| (n % 13) as u8).collect(), + ]; + let layers = [ + ( + "application/vnd.oci.image.layer.v1.tar+zstd", + compress(Layer::Zstd, &payloads[0]), + ), + ( + "application/vnd.docker.image.rootfs.diff.tar.gzip", + compress(Layer::Gzip, &payloads[1]), + ), + ]; + let config = serde_json::to_vec(&serde_json::json!({ + "architecture": "arm64", "os": "linux", + "rootfs": {"type": "layers", "diff_ids": payloads.iter().map(|p| digest(p)).collect::>()} + })) + .unwrap(); + let manifest = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": descriptor("application/vnd.oci.image.config.v1+json", &config), + "layers": layers.iter().map(|(t, b)| descriptor(t, b)).collect::>(), + })) + .unwrap(); + let decoy = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": descriptor("application/vnd.oci.image.config.v1+json", b"{}"), + "layers": [descriptor("application/vnd.oci.image.layer.v1.tar+gzip", b"decoy")], + })) + .unwrap(); + let entry = |bytes: &[u8], architecture: &str| { + let mut value = descriptor("application/vnd.oci.image.manifest.v1+json", bytes); + value["platform"] = serde_json::json!({"os": if architecture == "unknown" {"unknown"} else {"linux"}, "architecture": architecture}); + value + }; + let index = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [entry(&decoy, "amd64"), entry(&manifest, "arm64"), entry(&decoy, "unknown")], + })) + .unwrap(); + let mut content = Content { + manifests: BTreeMap::new(), + blobs: BTreeMap::new(), + tag: "v1".into(), + index_digest: digest(&index), + manifest_digest: digest(&manifest), + config_digest: digest(&config), + layer_digests: layers.iter().map(|(_, b)| digest(b)).collect(), + }; + for (media_type, bytes) in [ + ("application/vnd.oci.image.index.v1+json", index), + ("application/vnd.oci.image.manifest.v1+json", manifest), + ("application/vnd.oci.image.manifest.v1+json", decoy), + ] { + content + .manifests + .insert(digest(&bytes), (media_type.into(), bytes)); + } + content.blobs.insert(digest(&config), config); + for (_, bytes) in layers { + content.blobs.insert(digest(&bytes), bytes); + } + content +} + +struct Registry { + port: u16, + log: Arc>>, + stop: Arc, + thread: Option>, +} + +fn read_request(stream: &TcpStream) -> Option { + let mut reader = BufReader::new(stream.try_clone().ok()?); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_owned(); + let path = parts.next()?.to_owned(); + let (mut authorization, mut length) = (None, 0_usize); + loop { + let mut header = String::new(); + reader.read_line(&mut header).ok()?; + let header = header.trim_end(); + if header.is_empty() { + break; + } + let (name, value) = header.split_once(':')?; + match name.to_ascii_lowercase().as_str() { + "authorization" => authorization = Some(value.trim().to_owned()), + "content-length" => length = value.trim().parse().ok()?, + _ => {} + } + } + let mut body = vec![0; length]; + reader.read_exact(&mut body).ok()?; + Some(Request { + method, + path, + authorization, + body: String::from_utf8(body).ok()?, + }) +} + +impl Registry { + fn start(content: Content, auth: Auth, tamper: bool) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let (thread_log, thread_stop) = (log.clone(), stop.clone()); + let thread = std::thread::spawn(move || { + for stream in listener.incoming() { + if thread_stop.load(AtomicOrdering::SeqCst) { + return; + } + let Ok(mut stream) = stream else { continue }; + let Some(request) = read_request(&stream) else { + continue; + }; + thread_log.lock().unwrap().push(request.clone()); + let reply = respond(&content, auth, tamper, port, &request); + let mut head = format!( + "HTTP/1.1 {} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n", + reply.status, + reply.body.len() + ); + for (name, value) in &reply.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str("\r\n"); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&reply.body); + } + }); + Self { + port, + log, + stop, + thread: Some(thread), + } + } + + fn domain(&self) -> String { + format!("127.0.0.1:{}", self.port) + } + + fn policy(&self, docker: &DockerFixture) -> ImagePolicy { + ImagePolicy { + local_registries: vec![LocalRegistry { + domain: self.domain(), + plain_http: true, + }], + ..docker.policy() + } + } + + fn requests(&self) -> Vec { + self.log.lock().unwrap().clone() + } +} + +impl Drop for Registry { + fn drop(&mut self) { + self.stop.store(true, AtomicOrdering::SeqCst); + let _ = TcpStream::connect(("127.0.0.1", self.port)); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn basic_value() -> String { + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{USER}:{PASSWORD}")) + ) +} + +fn respond(content: &Content, auth: Auth, tamper: bool, port: u16, request: &Request) -> Reply { + let authorization = request.authorization.as_deref(); + if request.path.starts_with("/token") { + // Anonymous tokens are issued but cannot pull; credentials earn a pull token. + return match authorization { + None => Reply::new(200, r#"{"token":"anonymous-token"}"#), + Some(value) if value == basic_value() => { + Reply::new(200, r#"{"access_token":"pull-token"}"#) + } + Some(_) => Reply::new(401, "{}"), + }; + } + let authorized = match auth { + Auth::None => true, + Auth::Bearer => authorization == Some("Bearer pull-token"), + Auth::Basic => authorization == Some(basic_value().as_str()), + }; + if !authorized && !request.path.starts_with("/storage/") { + let challenge = match auth { + Auth::Bearer => format!( + r#"Bearer realm="http://127.0.0.1:{port}/token",service="fixture-registry",scope="repository:{REPOSITORY}:pull""# + ), + _ => r#"Basic realm="fixture""#.to_owned(), + }; + return Reply::new(401, "{}").header("WWW-Authenticate", challenge); + } + let prefix = format!("/v2/{REPOSITORY}/"); + if let Some(reference) = request + .path + .strip_prefix(&prefix) + .and_then(|p| p.strip_prefix("manifests/")) + { + let key = if reference == content.tag { + content.index_digest.as_str() + } else { + reference + }; + return match content.manifests.get(key) { + Some((media_type, bytes)) => { + Reply::new(200, bytes.clone()).header("Content-Type", media_type.clone()) + } + None => Reply::new(404, "{}"), + }; + } + if let Some(blob) = request + .path + .strip_prefix(&prefix) + .and_then(|p| p.strip_prefix("blobs/")) + { + // Layers are served through a same-origin storage redirect, as many registries do. + if content.layer_digests.iter().any(|d| d == blob) { + return Reply::new(307, "").header("Location", format!("/storage/{blob}")); + } + return match content.blobs.get(blob) { + Some(bytes) => Reply::new(200, bytes.clone()), + None => Reply::new(404, "{}"), + }; + } + if let Some(blob) = request.path.strip_prefix("/storage/") { + return match content.blobs.get(blob) { + Some(bytes) if tamper => { + let mut bytes = bytes.clone(); + let last = bytes.len() - 1; + bytes[last] ^= 1; + Reply::new(200, bytes) + } + Some(bytes) => Reply::new(200, bytes.clone()), + None => Reply::new(404, "{}"), + }; + } + Reply::new(404, "{}") +} + +struct Output(PathBuf); +impl Output { + fn new() -> Self { + Self(crate::private_test_root( + &std::env::temp_dir(), + "hkg-registry-out", + )) + } + fn archive(&self) -> PathBuf { + self.0.join("image.tar") + } + fn entries(&self) -> usize { + fs::read_dir(&self.0).unwrap().count() + } +} +impl Drop for Output { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn empty_docker() -> DockerFixture { + DockerFixture::new(&serde_json::json!({})) +} + +#[test] +fn anonymous_registry_index_streams_zstd_and_gzip_layers_into_a_validated_archive() { + let content = content(); + let registry = Registry::start(content.clone(), Auth::None, false); + let docker = empty_docker(); + let policy = registry.policy(&docker); + let tagged = format!("{}/{REPOSITORY}:v1", registry.domain()); + let admitted = admit(&tagged, &policy).unwrap(); + assert_eq!( + (admitted.reference.as_str(), admitted.kind), + (tagged.as_str(), "registry") + ); + let resolved = resolve(&tagged, &policy).unwrap(); + assert_eq!( + resolved.pinned_reference, + format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ) + ); + assert_eq!(resolved.manifest_digest, content.manifest_digest); + assert_eq!(resolved.image_id, content.config_digest); + let output = Output::new(); + let acquired = acquire( + &resolved.pinned_reference, + &output.archive(), + &policy, + policy.archive_limit, + ) + .unwrap(); + assert_eq!(acquired.source_digest, content.index_digest); + assert_eq!(acquired.manifest_digest, content.manifest_digest); + assert_eq!(acquired.image_id, content.config_digest); + let bytes = fs::read(output.archive()).unwrap(); + assert_eq!(bytes.len() as u64, acquired.archive_bytes); + assert_eq!( + format!("{:x}", Sha256::digest(&bytes)), + acquired.archive_sha256 + ); + assert_eq!( + image_load::validate_file( + &output.archive(), + &acquired.archive_sha256, + &acquired.image_id, + &policy + ) + .unwrap(), + acquired.archive_bytes + ); + // Only the final archive remains, the decoy platform was never fetched, and no + // request carried credentials to a registry that never asked for them. + assert_eq!(output.entries(), 1); + let requests = registry.requests(); + assert!( + requests + .iter() + .all(|r| r.authorization.is_none() && r.method == "GET") + ); + let decoy = content + .manifests + .keys() + .find(|d| **d != content.index_digest && **d != content.manifest_digest) + .unwrap(); + assert!(!requests.iter().any(|r| r.path.contains(decoy.as_str()))); + // An existing output is never replaced. + assert!( + acquire( + &resolved.pinned_reference, + &output.archive(), + &policy, + policy.archive_limit + ) + .is_err() + ); + assert_eq!(fs::read(output.archive()).unwrap(), bytes); +} + +#[test] +fn bearer_challenges_try_an_anonymous_token_before_the_users_docker_credentials() { + let content = content(); + let registry = Registry::start(content.clone(), Auth::Bearer, false); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + // Without configured credentials the challenge refuses with a value-free message. + let docker = empty_docker(); + let output = Output::new(); + let failure = acquire( + &pinned, + &output.archive(), + ®istry.policy(&docker), + u64::MAX, + ) + .unwrap_err(); + assert_eq!(failure.code, "registry_image"); + assert!( + failure.message.contains("requires credentials"), + "{failure:?}" + ); + assert_eq!(output.entries(), 0); + // With an `auths` entry the second token request carries Basic credentials, and only + // registry-origin requests carry the resulting bearer token. + let docker = DockerFixture::new(&serde_json::json!({"auths": { + registry.domain(): {"auth": base64::engine::general_purpose::STANDARD.encode(format!("{USER}:{PASSWORD}"))} + }})); + let acquired = acquire( + &pinned, + &output.archive(), + ®istry.policy(&docker), + u64::MAX, + ) + .unwrap(); + assert_eq!(acquired.image_id, content.config_digest); + let tokens: Vec<_> = registry + .requests() + .into_iter() + .filter(|r| r.path.starts_with("/token")) + .collect(); + assert!(tokens.len() >= 3); + assert!(tokens.iter().all(|r| { + r.path + .contains("scope=repository%3Ateam%2Fsub%2Fapp%3Apull") + && r.path.contains("service=fixture-registry") + })); + let last = tokens.len() - 2..; + assert_eq!(tokens[last.start].authorization, None); + assert_eq!( + tokens[last.start + 1].authorization.as_deref(), + Some(basic_value().as_str()) + ); + let failure = format!("{failure:?}{acquired:?}"); + assert!(!failure.contains(PASSWORD) && !failure.contains("pull-token")); +} + +#[test] +fn identity_tokens_use_the_oauth_refresh_grant_and_basic_challenges_use_helpers() { + let content = content(); + let registry = Registry::start(content.clone(), Auth::Basic, false); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + let docker = + DockerFixture::new(&serde_json::json!({"credHelpers": {registry.domain(): "fixture"}})); + docker.helper( + "fixture", + &format!(r#"read -r server || true; printf '{{"ServerURL":"%s","Username":"{USER}","Secret":"{PASSWORD}"}}' "$server""#), + ); + let output = Output::new(); + acquire( + &pinned, + &output.archive(), + ®istry.policy(&docker), + u64::MAX, + ) + .unwrap(); + // Wrong helper credentials refuse after one credentialed attempt. + let docker = DockerFixture::new(&serde_json::json!({"credsStore": "wrong"})); + docker.helper("wrong", r#"printf '{"Username":"u","Secret":"not-it"}'"#); + let output = Output::new(); + let failure = acquire( + &pinned, + &output.archive(), + ®istry.policy(&docker), + u64::MAX, + ) + .unwrap_err(); + assert!(failure.message.contains("refused access"), "{failure:?}"); + assert!(!failure.message.contains("not-it")); + assert_eq!(output.entries(), 0); + + // A Bearer realm with an identity token gets a form POST refresh grant. + let registry = Registry::start(content.clone(), Auth::Bearer, false); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + let docker = DockerFixture::new( + &serde_json::json!({"auths": {registry.domain(): {"identitytoken": "refresh-fixture"}}}), + ); + let output = Output::new(); + // The fixture realm grants pull tokens only for Basic credentials, so the refresh + // grant is refused; the request shape is what is checked here. + assert!( + acquire( + &pinned, + &output.archive(), + ®istry.policy(&docker), + u64::MAX + ) + .is_err() + ); + let grant = registry + .requests() + .into_iter() + .find(|r| r.method == "POST") + .unwrap(); + assert!(grant.path.starts_with("/token")); + assert!(grant.body.contains("grant_type=refresh_token")); + assert!(grant.body.contains("refresh_token=refresh-fixture")); + assert!(grant.body.contains("service=fixture-registry")); +} + +#[test] +fn the_budget_refuses_before_any_layer_transfer_and_tampered_layers_publish_nothing() { + let content = content(); + let registry = Registry::start(content.clone(), Auth::None, false); + let docker = empty_docker(); + let policy = registry.policy(&docker); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + let output = Output::new(); + let failure = acquire(&pinned, &output.archive(), &policy, 16 * 1024).unwrap_err(); + assert!(failure.message.contains("budget"), "{failure:?}"); + assert_eq!(output.entries(), 0); + let requests = registry.requests(); + assert!(!requests.iter().any(|r| { + content + .layer_digests + .iter() + .any(|d| r.path.contains(d.as_str())) + })); + + let registry = Registry::start(content.clone(), Auth::None, true); + let policy = registry.policy(&docker); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + let failure = acquire(&pinned, &output.archive(), &policy, u64::MAX).unwrap_err(); + assert!(failure.message.contains("digest"), "{failure:?}"); + assert_eq!(output.entries(), 0); + // A manifest that does not hash to its pin refuses too. + let wrong = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.manifest_digest.replace('a', "b").replace('1', "2") + ); + assert!(acquire(&wrong, &output.archive(), &policy, u64::MAX).is_err()); + assert_eq!(output.entries(), 0); +} + +#[test] +fn local_registries_refuse_without_an_explicit_opt_in_and_no_request_is_made() { + let content = content(); + let registry = Registry::start(content.clone(), Auth::None, false); + let docker = empty_docker(); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + let output = Output::new(); + for policy in [ + docker.policy(), + // An HTTPS opt-in does not permit plain HTTP to the same registry. + ImagePolicy { + local_registries: vec![LocalRegistry { + domain: registry.domain(), + plain_http: false, + }], + ..docker.policy() + }, + ] { + let refused = acquire(&pinned, &output.archive(), &policy, u64::MAX); + assert!(refused.is_err()); + } + let failure = admit(&pinned, &docker.policy()).unwrap_err(); + assert_eq!(failure.code, "registry_image"); + assert!(!failure.message.contains(®istry.domain())); + assert!( + resolve( + &format!("{}/{REPOSITORY}:v1", registry.domain()), + &docker.policy() + ) + .is_err() + ); + assert!(registry.requests().is_empty()); + assert_eq!(output.entries(), 0); +} + +#[test] +fn local_hosts_are_classified_before_any_request() { + for host in [ + "localhost", + "app.localhost", + "printer.local", + "registry", + "127.0.0.1", + "127.1.2.3", + "0.0.0.0", + "10.1.2.3", + "100.64.0.1", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "224.0.0.1", + "2130706433", + ] { + assert!(local_host(host), "{host}"); + } + for host in [ + "ghcr.io", + "registry.example.com", + "registry.internal.example", + "172.32.0.1", + "100.128.0.1", + "8.8.8.8", + ] { + assert!(!local_host(host), "{host}"); + } + let policy = ImagePolicy::default(); + let pin = format!("sha256:{}", "a".repeat(64)); + // Numeric host spellings normalize to loopback before classification. + for reference in [ + format!("0x7f.0.0.1/a@{pin}"), + format!("127.0.0.1:5000/a@{pin}"), + format!("localhost/a@{pin}"), + format!("registry:5000/a@{pin}"), + ] { + assert_eq!( + pinned(&reference, &policy).err().unwrap().code, + "registry_image", + "{reference}" + ); + } +} + +#[test] +fn redirects_and_token_realms_cannot_downgrade_or_reach_local_origins() { + let policy = ImagePolicy::default(); + let pin = format!("sha256:{}", "a".repeat(64)); + let hub = pinned(&format!("alpine@{pin}"), &policy).unwrap().registry; + let other = pinned(&format!("ghcr.io/org/app@{pin}"), &policy) + .unwrap() + .registry; + let url = |value: &str| Url::parse(value).unwrap(); + for value in [ + "https://registry-1.docker.io/v2/", + "https://production.cloudflare.docker.com/blob?opaque=private", + "https://docker-images-prod.abc.r2.cloudflarestorage.com/blob", + ] { + assert!(redirect_allowed(&hub, &url(value)), "{value}"); + } + for value in [ + "http://production.cloudflare.docker.com/blob", + "https://127.0.0.1/blob", + "https://registry-1.docker.io:444/blob", + "https://registry-1.docker.io.evil.test/blob", + "https://user:secret@registry-1.docker.io/blob", + "https://storage.example.com/blob", + ] { + assert!(!redirect_allowed(&hub, &url(value)), "{value}"); + } + for value in [ + "https://ghcr.io/v2/org/app/blobs/x", + "https://pkg-storage.example.com/blob?signature=opaque", + "https://203.0.113.5/blob", + ] { + assert!(redirect_allowed(&other, &url(value)), "{value}"); + } + for value in [ + "http://pkg-storage.example.com/blob", + "https://pkg-storage.example.com:8443/blob", + "https://localhost/blob", + "https://10.0.0.5/blob", + "https://169.254.169.254/latest", + "https://0x7f.0.0.1/blob", + "https://[::1]/blob", + "https://user:secret@pkg-storage.example.com/blob", + "https://metadata/blob", + ] { + assert!(!redirect_allowed(&other, &url(value)), "{value}"); + } + assert!(realm_allowed(&hub, &url("https://auth.docker.io/token"))); + assert!(!realm_allowed(&hub, &url("https://auth.example.com/token"))); + assert!(realm_allowed(&other, &url("https://ghcr.io/token"))); + assert!(realm_allowed( + &other, + &url("https://auth.example.com/token") + )); + for value in [ + "http://auth.example.com/token", + "https://localhost/token", + "https://10.0.0.1/token", + "https://user:pw@auth.example.com/token", + ] { + assert!(!realm_allowed(&other, &url(value)), "{value}"); + } +} + +#[test] +fn authentication_challenges_parse_quoted_parameters() { + let challenge = parse_challenge( + r#"Bearer realm="https://auth.example.com/token",service="registry.example.com",scope="repository:a/b:pull,push""#, + ) + .unwrap(); + assert!(challenge.bearer); + assert_eq!( + challenge.realm.as_deref(), + Some("https://auth.example.com/token") + ); + assert_eq!(challenge.service.as_deref(), Some("registry.example.com")); + let challenge = parse_challenge(r#"basic realm="x\"y""#).unwrap(); + assert!(!challenge.bearer); + assert_eq!(challenge.realm.as_deref(), Some("x\"y")); + for value in ["Negotiate abc", "Bearer realm=\"unterminated", ""] { + assert!(parse_challenge(value).is_err(), "{value}"); + } +} + +#[test] +fn mutable_resolution_is_explicit_and_rejects_non_repository_inputs() { + let policy = ImagePolicy::default(); + let resolved = |value: &str| { + let (registry, path, tag) = tagged(value, &policy).unwrap(); + (registry.domain, path, tag) + }; + let owned = + |domain: &str, path: &str, tag: &str| (domain.to_owned(), path.to_owned(), tag.to_owned()); + assert_eq!( + resolved("bitnami/redis"), + owned("docker.io", "bitnami/redis", "latest") + ); + assert_eq!( + resolved("docker.io/chromedp/headless-shell:stable"), + owned("docker.io", "chromedp/headless-shell", "stable") + ); + assert_eq!( + resolved("alpine:3.21"), + owned("docker.io", "library/alpine", "3.21") + ); + assert_eq!(resolved("ghcr.io/a/b:tag"), owned("ghcr.io", "a/b", "tag")); + assert_eq!( + resolved("registry.example.com:8443/team/sub/app"), + owned("registry.example.com:8443", "team/sub/app", "latest") + ); + for value in [ + "Redis", + "localhost:5000/a:tag", + "a/b/c:tag", + format!("sha256:{}", "e".repeat(64)).as_str(), + "https://docker.io/a/b:tag", + "a/b@sha256:bad", + "a/b:", + "a/b:-bad", + "a/b:foo?bar", + "a/b:foo/bar", + "a/..:tag", + "a/b:PRIVATE CANARY", + ] { + assert!(tagged(value, &policy).is_err(), "{value}"); + } + assert!(pinned("bitnami/redis:latest", &policy).is_err()); +} + +#[test] +fn resolution_requires_unambiguous_linux_arm64_and_actual_config_architecture() { + let bytes = br#"{"schemaVersion":2,"manifests":[{"digest":"sha256:unused","size":10,"mediaType":"application/vnd.oci.image.manifest.v1+json","platform":{"os":"linux","architecture":"amd64"}}]}"#; + assert!(arm64(&parse_manifest(bytes, &digest(bytes)).unwrap()).is_err()); + for bytes in [ + br#"{"os":"linux","architecture":"arm64"}"#.as_slice(), + br#"{"os":"linux","architecture":"arm64","variant":"v8"}"#.as_slice(), + ] { + assert!(verify_platform(bytes).is_ok()); + } + for bytes in [ + br#"{"os":"linux","architecture":"amd64"}"#.as_slice(), + br#"{"os":"linux","architecture":"arm64","variant":"v9"}"#.as_slice(), + br#"{"os":"darwin","architecture":"arm64"}"#.as_slice(), + ] { + assert!(verify_platform(bytes).is_err()); + } + let descriptor = serde_json::json!({"digest":"sha256:unused","size":10,"mediaType":"application/vnd.oci.image.manifest.v1+json","platform":{"os":"linux","architecture":"arm64"}}); + let bytes = serde_json::to_vec( + &serde_json::json!({"schemaVersion":2,"manifests":[descriptor.clone(),descriptor]}), + ) + .unwrap(); + assert!(arm64(&parse_manifest(&bytes, &digest(&bytes)).unwrap()).is_err()); + // A nested index or a non-image artifact is not an ARM64 image manifest. + let nested = serde_json::json!({"digest":"sha256:unused","size":10,"mediaType":"application/vnd.oci.image.index.v1+json","platform":{"os":"linux","architecture":"arm64"}}); + let bytes = + serde_json::to_vec(&serde_json::json!({"schemaVersion":2,"manifests":[nested]})).unwrap(); + assert!(arm64(&parse_manifest(&bytes, &digest(&bytes)).unwrap()).is_err()); + let bytes = br#"{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","config":{"digest":"sha256:unused","size":1}}"#; + assert!(parse_manifest(bytes, &digest(bytes)).is_err()); +} + +#[test] +fn layer_media_types_cover_gzip_zstd_and_uncompressed_and_refuse_foreign_layers() { + for (media_type, extension) in [ + ("application/vnd.oci.image.layer.v1.tar+gzip", "tar.gz"), + ( + "application/vnd.docker.image.rootfs.diff.tar.gzip", + "tar.gz", + ), + ("application/vnd.oci.image.layer.v1.tar+zstd", "tar.zst"), + ("application/vnd.oci.image.layer.v1.tar", "tar"), + ] { + assert_eq!(layer_extension(media_type).unwrap(), extension); + } + for media_type in [ + "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip", + "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip", + "application/vnd.oci.image.config.v1+json", + "", + ] { + assert!(layer_extension(media_type).is_err(), "{media_type}"); + } + let mut descriptor = Descriptor { + digest: digest(b"x"), + size: 1, + media_type: String::new(), + platform: None, + urls: vec!["https://example.com/layer".into()], + }; + assert!(Fetch::checked(&descriptor, 10).is_err()); + descriptor.urls.clear(); + assert!(Fetch::checked(&descriptor, 10).is_ok()); + assert!(Fetch::checked(&descriptor, 0).is_err()); +} + +#[test] +fn pinned_references_normalize_and_keep_any_registry_path() { + let policy = ImagePolicy::default(); + let pin = format!("sha256:{}", "a".repeat(64)); + for (input, canonical) in [ + ( + format!("imbios/bun-node:1.3-alpine@{pin}"), + format!("docker.io/imbios/bun-node@{pin}"), + ), + ( + format!("alpine@{pin}"), + format!("docker.io/library/alpine@{pin}"), + ), + ( + format!("docker.io/alpine:3.21@{pin}"), + format!("docker.io/library/alpine@{pin}"), + ), + ( + format!("ghcr.io/org/app:v1@{pin}"), + format!("ghcr.io/org/app@{pin}"), + ), + ( + format!("registry.example.com:8443/team/sub/app@{pin}"), + format!("registry.example.com:8443/team/sub/app@{pin}"), + ), + ] { + let value = pinned(&input, &policy).unwrap(); + assert_eq!(value.canonical(), canonical); + assert_eq!(value.digest, pin); + assert_eq!(pinned_syntax(&input).unwrap(), (canonical, pin.clone())); + } + // Recorded local entries stay readable by syntax under any policy. + assert!(pinned_syntax(&format!("localhost:5000/a@{pin}")).is_ok()); + for value in [ + format!("Imbios/bun-node@{pin}"), + "imbios/bun-node:latest".to_owned(), + "https://docker.io/a/b".to_owned(), + "user:secret@docker.io/a/b".to_owned(), + "example.com/a/b@sha256:bad".to_owned(), + "../bad@sha256:bad".to_owned(), + format!("docker.io/a/b/c@{pin}"), + ] { + assert!(pinned(&value, &policy).is_err(), "{value}"); + } +} + +#[test] +fn admission_normalizes_references_and_refuses_unacquirable_ones_without_echoing_them() { + let policy = ImagePolicy::default(); + let pin = format!("sha256:{}", "a".repeat(64)); + let id = format!("sha256:{}", "e".repeat(64)); + for (input, reference, kind) in [ + ( + "alpine", + "docker.io/library/alpine:latest".to_owned(), + "docker_hub", + ), + ( + "alpine:3.21", + "docker.io/library/alpine:3.21".to_owned(), + "docker_hub", + ), + ( + "user/img", + "docker.io/user/img:latest".to_owned(), + "docker_hub", + ), + ( + &*format!("alpine@{pin}"), + format!("docker.io/library/alpine@{pin}"), + "docker_hub", + ), + ( + "ghcr.io/org/app:v1", + "ghcr.io/org/app:v1".to_owned(), + "registry", + ), + ( + "quay.io/org/app", + "quay.io/org/app:latest".to_owned(), + "registry", + ), + ( + "registry.example.com/a/b/c:1", + "registry.example.com/a/b/c:1".to_owned(), + "registry", + ), + (id.as_str(), id.clone(), "image_id"), + ] { + let admitted = admit(input, &policy).unwrap(); + assert_eq!( + (admitted.reference.as_str(), admitted.kind), + (reference.as_str(), kind), + "{input}" + ); + } + let allowed = + ImagePolicy::from_flags(&["--allow-local-registry", "http://localhost:5000"]).unwrap(); + assert_eq!( + admit("localhost:5000/private-canary", &allowed) + .unwrap() + .kind, + "registry" + ); + for input in [ + "", + "PRIVATE_CANARY", + "localhost:5000/private-canary", + "private-canary/nested/app", + "private-canary@sha256:bad", + "10.0.0.5:5000/private-canary", + ] { + let failure = admit(input, &policy).unwrap_err(); + assert_eq!(failure.code, "registry_image"); + assert!(!failure.message.to_ascii_lowercase().contains("canary")); + } +} + +#[test] +fn publication_preserves_existing_output_and_failed_writes_leave_no_final_file() { + let root = Output::new(); + let output = root.archive(); + fs::write(&output, b"existing").unwrap(); + let write = |bytes: &'static [u8]| { + move |file: &mut File| file.write_all(bytes).map_err(|_| error("write")) + }; + assert!(publish(&output, write(b"replacement"), |_, _| Ok(())).is_err()); + assert_eq!(fs::read(&output).unwrap(), b"existing"); + fs::remove_file(&output).unwrap(); + assert!( + publish( + &output, + |file| { + file.write_all(b"partial").unwrap(); + Err::<(), _>(error("injected write failure")) + }, + |_, _| Ok(()) + ) + .is_err() + ); + assert!(!output.exists()); + // A verification failure after the write also publishes nothing. + assert!(publish(&output, write(b"complete"), |_, _| Err(error("injected"))).is_err()); + assert_eq!(root.entries(), 0); + publish(&output, write(b"complete"), |path, _| { + assert_eq!(fs::read(path).unwrap(), b"complete"); + Ok(()) + }) + .unwrap(); + assert_eq!(fs::read(&output).unwrap(), b"complete"); + assert_eq!(root.entries(), 1); +} + +#[test] +fn compressed_objects_require_both_exact_size_and_digest() { + let mut descriptor = Descriptor { + digest: digest(b"compressed fixture"), + size: 18, + media_type: String::new(), + platform: None, + urls: Vec::new(), + }; + assert!(verify_object(&descriptor, b"compressed fixture").is_ok()); + assert!(verify_object(&descriptor, b"compressed fixturE").is_err()); + let read = |bytes: &[u8], descriptor: &Descriptor| { + let mut out = Vec::new(); + Verified { + inner: bytes, + digest: Sha256::new(), + total: 0, + size: descriptor.size, + expected: descriptor.digest.clone(), + } + .read_to_end(&mut out) + .map(|_| out) + }; + assert_eq!( + read(b"compressed fixture", &descriptor).unwrap(), + b"compressed fixture" + ); + assert!(read(b"compressed fixturE", &descriptor).is_err()); + assert!(read(b"compressed fixture!", &descriptor).is_err()); + descriptor.size = 17; + assert!(verify_object(&descriptor, b"compressed fixture").is_err()); + assert!(read(b"compressed fixture", &descriptor).is_err()); + let mut bytes = b"compressed fixture".to_vec(); + bytes.pop(); + descriptor.size = 18; + assert!(read(&bytes, &descriptor).is_err()); + assert!(bounded(&b"12345"[..], 4).is_err()); + assert_eq!(bounded(&b"1234"[..], 4).unwrap(), b"1234"); +} + +#[test] +fn invalid_reference_refuses_without_creating_archive() { + let root = Output::new(); + let output = root.archive(); + let failure = + acquire("PRIVATE_CANARY", &output, &ImagePolicy::default(), u64::MAX).unwrap_err(); + assert!(!output.exists()); + assert!(!failure.message.contains("PRIVATE_CANARY")); +} + +#[test] +#[ignore = "Explicit public Docker Hub acquisition; no runtime import"] +fn pinned_event_agent_image_downloads_without_docker() { + let path = std::env::var("HACK_REGISTRY_TEST_ARCHIVE").expect("explicit new archive path"); + let policy = ImagePolicy::default(); + let receipt = acquire("imbios/bun-node:1.3.14-24-alpine@sha256:744fb75f37bbe5cca4ba5e9645c7e4454adb58e17752caf53dd61924123297dc", Path::new(&path), &policy, policy.archive_limit).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.len() as u64, receipt.archive_bytes); + assert_eq!( + format!("{:x}", Sha256::digest(&bytes)), + receipt.archive_sha256 + ); + image_load::validate_file( + Path::new(&path), + &receipt.archive_sha256, + &receipt.image_id, + &policy, + ) + .unwrap(); + println!("{}", serde_json::to_string(&receipt).unwrap()); +} diff --git a/packages/runtime-core/tests/local_contract.rs b/packages/runtime-core/tests/local_contract.rs index aaccb03e0..53ea3374e 100644 --- a/packages/runtime-core/tests/local_contract.rs +++ b/packages/runtime-core/tests/local_contract.rs @@ -42,13 +42,38 @@ fn image_admission_normalizes_shorthand_without_candidate_state_or_network() { let admitted: Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(admitted["reference"], "docker.io/library/alpine:3.21"); assert_eq!(admitted["kind"], "docker_hub"); - for reference in ["Private-Canary", "ghcr.io/private-canary/app:1"] { + let output = admit("ghcr.io/private-canary/app:1"); + assert_eq!(output.status.code(), Some(0)); + let admitted: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(admitted["kind"], "registry"); + for reference in ["Private-Canary", "localhost:5000/private-canary:1"] { let output = admit(reference); assert_eq!(output.status.code(), Some(2)); let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); assert_eq!(failure["code"], "registry_image"); assert!(!String::from_utf8_lossy(&output.stderr).contains("anary")); } + // A local registry is admitted only by an explicit opt-in flag; malformed policy + // flags refuse with their own code. + let with_policy = |flags: &[&str]| { + let mut args = vec![ + "runtime", + "admit-image", + "--reference", + "localhost:5000/private-canary:1", + "--json", + ]; + args.extend_from_slice(flags); + invoke(&fixture, &args) + }; + let output = with_policy(&["--allow-local-registry", "http://localhost:5000"]); + assert_eq!(output.status.code(), Some(0)); + let admitted: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(admitted["kind"], "registry"); + let output = with_policy(&["--image-budget-mib", "huge"]); + assert_eq!(output.status.code(), Some(2)); + let failure: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(failure["code"], "image_policy"); // Admission never creates candidate or home state. for directory in [fixture.0.clone(), fixture.directory("fake-home")] { let mut names: Vec<_> = std::fs::read_dir(&directory) diff --git a/src/backends/native-image-policy.ts b/src/backends/native-image-policy.ts new file mode 100644 index 000000000..af9d4417d --- /dev/null +++ b/src/backends/native-image-policy.ts @@ -0,0 +1,112 @@ +import { isAbsolute } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; + +/** Fixed bounds the native runtime also enforces (`image_policy.rs`). */ +const MIN_BUDGET_MIB = 16; +const MAX_BUDGET_MIB = 8192; +const MAX_CACHE_MIB = 262_144; +const MAX_LOCAL_REGISTRIES = 16; +const DEFAULT_BUDGET_MIB = 2048; +const DEFAULT_CACHE_MIB = 16_384; +const MEBIBYTES = /^[0-9]{1,8}$/; +const LOCAL_REGISTRY = /^(?:http:\/\/)?[a-z0-9.-]+(?::[0-9]{1,5})?$/; + +/** + * Image acquisition time for one `runtime ensure-image` request: registry transfer plus + * import. The runtime bounds acquisition (360 s) and import (220 s) inside it. + */ +export const NATIVE_IMAGE_ENSURE_TIMEOUT_MS = 600_000; + +function invalid(message: string): HackCliError { + return new HackCliError({ code: "E_CONFIG_INVALID", message }); +} + +function mebibytes(opts: { + readonly name: string; + readonly value: string; + readonly max: number; +}): string { + const parsed = MEBIBYTES.test(opts.value) ? Number(opts.value) : Number.NaN; + if (!(parsed >= MIN_BUDGET_MIB && parsed <= opts.max)) { + throw invalid( + `${opts.name} must be a whole number of MiB from ${MIN_BUDGET_MIB} to ${opts.max}.` + ); + } + return String(parsed); +} + +/** + * Native image policy flags derived from the caller's environment, appended to the + * runtime's `admit-image` and `ensure-image` requests. The runtime child receives only a + * fixed system environment, so these settings travel as explicit flags: + * + * - `HACK_NATIVE_IMAGE_BUDGET_MIB`: per-image compressed archive budget (default 2048). + * - `HACK_NATIVE_IMAGE_CACHE_MIB`: total image cache budget (default 16384). + * - `HACK_NATIVE_LOCAL_REGISTRIES`: comma-separated `[http://]host[:port]` registries that + * are otherwise refused as local; `http://` selects plain HTTP. + * - `DOCKER_CONFIG`: absolute Docker configuration directory for read-only credential + * lookup (default `~/.docker`). + * + * Values are not secrets. Invalid values throw a value-free `E_CONFIG_INVALID` before + * any runtime request. + */ +export function nativeImagePolicyArgs( + env: Readonly> = process.env +): string[] { + const args: string[] = []; + const budget = env.HACK_NATIVE_IMAGE_BUDGET_MIB?.trim(); + if (budget) { + args.push( + "--image-budget-mib", + mebibytes({ + name: "HACK_NATIVE_IMAGE_BUDGET_MIB", + value: budget, + max: MAX_BUDGET_MIB, + }) + ); + } + const cache = env.HACK_NATIVE_IMAGE_CACHE_MIB?.trim(); + if (cache) { + args.push( + "--image-cache-mib", + mebibytes({ + name: "HACK_NATIVE_IMAGE_CACHE_MIB", + value: cache, + max: MAX_CACHE_MIB, + }) + ); + } + const budgetMib = budget ? Number(budget) : DEFAULT_BUDGET_MIB; + const cacheMib = cache ? Number(cache) : DEFAULT_CACHE_MIB; + if (cacheMib < budgetMib) { + throw invalid( + "HACK_NATIVE_IMAGE_CACHE_MIB must be at least HACK_NATIVE_IMAGE_BUDGET_MIB." + ); + } + const registries = (env.HACK_NATIVE_LOCAL_REGISTRIES ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if ( + registries.length > MAX_LOCAL_REGISTRIES || + new Set(registries).size !== registries.length || + !registries.every((entry) => LOCAL_REGISTRY.test(entry)) + ) { + throw invalid( + `HACK_NATIVE_LOCAL_REGISTRIES must list at most ${MAX_LOCAL_REGISTRIES} unique [http://]host[:port] entries separated by commas.` + ); + } + for (const registry of registries) { + args.push("--allow-local-registry", registry); + } + const dockerConfig = env.DOCKER_CONFIG?.trim(); + if (dockerConfig) { + if (!isAbsolute(dockerConfig) || dockerConfig.length > 1024) { + throw invalid( + "DOCKER_CONFIG must be an absolute directory for native image credentials." + ); + } + args.push("--docker-config", dockerConfig); + } + return args; +} diff --git a/src/backends/native-project-image-admission.ts b/src/backends/native-project-image-admission.ts index 3e032ef2f..835792fd9 100644 --- a/src/backends/native-project-image-admission.ts +++ b/src/backends/native-project-image-admission.ts @@ -1,5 +1,6 @@ import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { nativeImagePolicyArgs } from "./native-image-policy.ts"; import { invokeNativeRuntime, NativeRuntimeRequestError, @@ -7,7 +8,7 @@ import { } from "./native-runtime-client.ts"; const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; -const KINDS = new Set(["docker_hub", "image_id"]); +const KINDS = new Set(["docker_hub", "registry", "image_id"]); /** Admission is pure in the runtime, so each request is short and bounded. */ const ADMISSION_TIMEOUT_MS = 10_000; @@ -17,7 +18,7 @@ function refused(): HackCliError { return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", message: - "Native project image reference refused before runtime startup: use a public Docker Hub reference (shorthand such as alpine:3.21 is accepted) or a sha256 image ID; no VM, provider or image acquisition was started.", + "Native project image reference refused before runtime startup: use a Docker Hub or OCI registry reference (shorthand such as alpine:3.21 is accepted; local registries need HACK_NATIVE_LOCAL_REGISTRIES) or a sha256 image ID; no VM, provider or image acquisition was started.", }); } @@ -40,7 +41,8 @@ function canceled(): ClosedCliError { * The native runtime owns reference normalization (`runtime admit-image`), so * this adapter only collects distinct references and checks the closed response. * Content IDs need no registry and are not sent. Admission does not pin or - * rewrite images: acquisition later re-normalizes with the same runtime rule. + * rewrite images: acquisition later re-normalizes with the same runtime rule and + * the same image policy flags (local registry opt-ins and budgets). * An unsupported reference throws a value-free `E_NATIVE_PROJECT_UNSUPPORTED` * refusal; a malformed reply or cancellation throws a value-free closed error. * Other runtime request failures propagate unchanged. @@ -51,8 +53,11 @@ export async function admitNativeProjectImages(opts: { readonly specs: Readonly>>>; readonly signal?: AbortSignal; readonly invoke?: typeof invokeNativeRuntime; + /** Source of image policy settings; defaults to the process environment. */ + readonly env?: Readonly>; }): Promise { const invoke = opts.invoke ?? invokeNativeRuntime; + const policy = nativeImagePolicyArgs(opts.env); const references = new Set(); for (const spec of Object.values(opts.specs)) { if (typeof spec.image !== "string") { @@ -71,7 +76,14 @@ export async function admitNativeProjectImages(opts: { admitted = await invoke({ runtime: opts.runtime, cwd: opts.projectRoot, - args: ["runtime", "admit-image", "--reference", reference, "--json"], + args: [ + "runtime", + "admit-image", + "--reference", + reference, + "--json", + ...policy, + ], timeoutMs: ADMISSION_TIMEOUT_MS, signal: opts.signal, }); diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..adda37585 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -5,6 +5,10 @@ import { dirname, join } from "node:path"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; +import { + NATIVE_IMAGE_ENSURE_TIMEOUT_MS, + nativeImagePolicyArgs, +} from "./native-image-policy.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; import { discoverNativeHostDependency, @@ -296,7 +300,15 @@ async function pinRestartImages(opts: { const pinned = await opts.invoke({ runtime: opts.runtime, cwd: opts.projectRoot, - args: ["runtime", "ensure-image", "--reference", image, "--json"], + args: [ + "runtime", + "ensure-image", + "--reference", + image, + "--json", + ...nativeImagePolicyArgs(), + ], + timeoutMs: NATIVE_IMAGE_ENSURE_TIMEOUT_MS, }); if ( !isRecord(pinned) || diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..c3540d915 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -15,6 +15,10 @@ import { type NativeHttpsLeaseIdentity, recoverNativeHttpsLease, } from "./native-https-owner.ts"; +import { + NATIVE_IMAGE_ENSURE_TIMEOUT_MS, + nativeImagePolicyArgs, +} from "./native-image-policy.ts"; import { type NativePreparedBase, preparedBaseArguments, @@ -382,7 +386,15 @@ async function pinNativeProjectImages(opts: { const ensured = await opts.invoke({ runtime: opts.runtime, cwd: opts.projectRoot, - args: ["runtime", "ensure-image", "--reference", image, "--json"], + args: [ + "runtime", + "ensure-image", + "--reference", + image, + "--json", + ...nativeImagePolicyArgs(), + ], + timeoutMs: NATIVE_IMAGE_ENSURE_TIMEOUT_MS, }); if ( !isRecord(ensured) || diff --git a/tests/native-image-policy.test.ts b/tests/native-image-policy.test.ts new file mode 100644 index 000000000..63229637d --- /dev/null +++ b/tests/native-image-policy.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test"; +import { nativeImagePolicyArgs } from "../src/backends/native-image-policy.ts"; +import { admitNativeProjectImages } from "../src/backends/native-project-image-admission.ts"; +import type { invokeNativeRuntime } from "../src/backends/native-runtime-client.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; + +const runtime = { binary: "/fixture/hack-native", home: "/fixture/home" }; + +test("image policy flags come only from the named settings and default to none", () => { + expect(nativeImagePolicyArgs({})).toEqual([]); + expect( + nativeImagePolicyArgs({ + HACK_NATIVE_IMAGE_BUDGET_MIB: " 4096 ", + HACK_NATIVE_IMAGE_CACHE_MIB: "65536", + HACK_NATIVE_LOCAL_REGISTRIES: + "http://localhost:5000, registry.local:8443,,", + DOCKER_CONFIG: "/fixture/docker", + UNRELATED: "ignored", + }) + ).toEqual([ + "--image-budget-mib", + "4096", + "--image-cache-mib", + "65536", + "--allow-local-registry", + "http://localhost:5000", + "--allow-local-registry", + "registry.local:8443", + "--docker-config", + "/fixture/docker", + ]); +}); + +test("invalid image policy settings refuse with a value-free configuration error", () => { + for (const env of [ + { HACK_NATIVE_IMAGE_BUDGET_MIB: "8193" }, + { HACK_NATIVE_IMAGE_BUDGET_MIB: "15" }, + { HACK_NATIVE_IMAGE_BUDGET_MIB: "2GiB" }, + { HACK_NATIVE_IMAGE_CACHE_MIB: "262145" }, + { + HACK_NATIVE_IMAGE_BUDGET_MIB: "4096", + HACK_NATIVE_IMAGE_CACHE_MIB: "1024", + }, + { HACK_NATIVE_IMAGE_BUDGET_MIB: "32768" }, + { HACK_NATIVE_LOCAL_REGISTRIES: "https://localhost:5000" }, + { HACK_NATIVE_LOCAL_REGISTRIES: "user:PRIVATE_CANARY@localhost" }, + { HACK_NATIVE_LOCAL_REGISTRIES: "localhost,localhost" }, + { HACK_NATIVE_LOCAL_REGISTRIES: "localhost:5000/path" }, + { DOCKER_CONFIG: "relative/PRIVATE_CANARY" }, + ]) { + let failure: unknown; + try { + nativeImagePolicyArgs(env); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(HackCliError); + expect((failure as HackCliError).code).toBe("E_CONFIG_INVALID"); + expect(String(failure)).not.toContain("PRIVATE_CANARY"); + } +}); + +test("admission accepts any admitted registry kind and forwards the policy flags", async () => { + const requests: string[][] = []; + const invoke = (async (request) => { + requests.push([...request.args]); + const reference = String(request.args[3]); + return { + reference, + kind: reference.startsWith("ghcr.io/") ? "registry" : "docker_hub", + }; + }) as typeof invokeNativeRuntime; + await admitNativeProjectImages({ + runtime, + projectRoot: "/fixture/project", + specs: { + web: { image: "ghcr.io/org/app:1" }, + worker: { image: "alpine:3.21" }, + pinned: { image: `sha256:${"e".repeat(64)}` }, + }, + invoke, + env: { HACK_NATIVE_LOCAL_REGISTRIES: "http://localhost:5000" }, + }); + expect(requests).toEqual([ + [ + "runtime", + "admit-image", + "--reference", + "ghcr.io/org/app:1", + "--json", + "--allow-local-registry", + "http://localhost:5000", + ], + [ + "runtime", + "admit-image", + "--reference", + "alpine:3.21", + "--json", + "--allow-local-registry", + "http://localhost:5000", + ], + ]); +}); + +test("a refused local registry names the opt-in setting without echoing the reference", async () => { + const invoke = (async () => { + throw new NativeRuntimeRequestError({ + message: "Local registries are refused unless explicitly allowed.", + nativeCode: "registry_image", + }); + }) as typeof invokeNativeRuntime; + let failure: unknown; + try { + await admitNativeProjectImages({ + runtime, + projectRoot: "/fixture/project", + specs: { web: { image: "localhost:5000/private-canary:1" } }, + invoke, + env: {}, + }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(HackCliError); + expect((failure as HackCliError).code).toBe("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(String(failure)).toContain("HACK_NATIVE_LOCAL_REGISTRIES"); + expect(String(failure)).not.toContain("private-canary"); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..b6ee10b9b 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -14,6 +14,7 @@ import { NativeHttpsLeaseAcquisitionError, } from "../src/backends/native-https-owner.ts"; import { NativeHttpsStartupError } from "../src/backends/native-https-startup-failure.ts"; +import { NATIVE_IMAGE_ENSURE_TIMEOUT_MS } from "../src/backends/native-image-policy.ts"; import { beginNativeProjectFinalization, captureNativeProjectFinalization, @@ -2968,6 +2969,15 @@ test("Docker Hub shorthand is admitted once per distinct reference before the ru } if (request.args[1] === "ensure-image") { order.push(`ensure ${request.args[3]}`); + // Acquisition plus import gets the runtime's whole bounded request budget. + expect(request.timeoutMs).toBe(NATIVE_IMAGE_ENSURE_TIMEOUT_MS); + expect(request.args.slice(0, 5)).toEqual([ + "runtime", + "ensure-image", + "--reference", + String(request.args[3]), + "--json", + ]); return { image_id: id }; } return await invoke(request); diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts index a89d07381..27390a0c9 100644 --- a/tests/native-refusal-cli.test.ts +++ b/tests/native-refusal-cli.test.ts @@ -458,12 +458,12 @@ test( SPAWN_TIMEOUT_MS ); -/** A project whose only service names a registry image the candidate refuses. */ +/** A project whose only service names a local registry the candidate refuses without an opt-in. */ const REGISTRY_COMPOSE = JSON.stringify({ - services: { web: { image: "ghcr.io/example/app:1" } }, + services: { web: { image: "localhost:5000/example/app:1" } }, }); const IMAGE_REFUSAL = - "Native project image reference refused before runtime startup: use a public Docker Hub reference (shorthand such as alpine:3.21 is accepted) or a sha256 image ID; no VM, provider or image acquisition was started."; + "Native project image reference refused before runtime startup: use a Docker Hub or OCI registry reference (shorthand such as alpine:3.21 is accepted; local registries need HACK_NATIVE_LOCAL_REGISTRIES) or a sha256 image ID; no VM, provider or image acquisition was started."; test( "native-project-image-admission: a refused image reference is one coded line", @@ -476,7 +476,7 @@ test( result, `E_NATIVE_PROJECT_UNSUPPORTED: ${IMAGE_REFUSAL}` ); - expect(result.output).not.toContain("ghcr.io"); + expect(result.output).not.toContain("localhost:5000"); expect((await f.calls()).at(-1)).toBe("runtime admit-image"); }, SPAWN_TIMEOUT_MS @@ -493,7 +493,7 @@ test( result, "Native runtime returned an invalid image admission response; no VM, provider or image acquisition was started." ); - expect(result.output).not.toContain("ghcr.io"); + expect(result.output).not.toContain("localhost:5000"); expect((await f.calls()).at(-1)).toBe("runtime admit-image"); }, SPAWN_TIMEOUT_MS diff --git a/tests/prerelease-plan.test.ts b/tests/prerelease-plan.test.ts index 2fee69ffe..9aba1c5a9 100644 --- a/tests/prerelease-plan.test.ts +++ b/tests/prerelease-plan.test.ts @@ -1565,7 +1565,7 @@ describe("published support list", () => { "pruning dependency caches", "modify source files", "Named users or groups", - "registries other than public Docker Hub", + "Local registries without an explicit opt-in", "Copied or relocated", "Downgrading onto newer state", ]) { From cb423239cb4bf4211c29edc63e38826d8a5971ea Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:50:53 -0500 Subject: [PATCH 037/138] fix(native): refuse retained-data removal of a run restored meanwhile `hack down --remove-data` checked the stopped state in the frontend, but a `hack up` in another terminal could restore the same run before the cleanup request arrived. The runtime's foreground cleanup then fell back to the live owner, which stops the restored run and deletes its data. `graph cleanup --remove-data --retained-only` now enforces the stopped state at the runtime boundary. A foreground graph is removed only under the exact retired or dead owner guard, which holds the run's operation lock that a restoring owner must take before publishing; it never falls back to the live-owner request and refuses with `graph_cleanup_not_retained`. A graph without a foreground owner checks its receipt phase under the mutation lease that restore also needs. The frontend always sends the flag. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 6 ++- packages/runtime-core/src/graph_cli.rs | 22 +++++++- .../src/provider/graph/foreground.rs | 31 +++++++++-- .../src/provider/graph/foreground/tests.rs | 36 +++++++++++++ .../runtime-core/src/provider/graph/mod.rs | 27 ++++++++++ packages/runtime-core/tests/local_contract.rs | 12 +++++ src/backends/native-project-down.ts | 19 ++++--- tests/native-project-remove-data.test.ts | 54 ++++++++++++++++++- 8 files changed, 193 insertions(+), 14 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index bcb55b4c5..3b85a335f 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -837,7 +837,11 @@ the graph's images, volumes or other resources, the retained data cannot be adop on a stopped project: run `hack down` first. It asks for confirmation (`--yes` skips the question; `--json` requires `--yes`), runs no lifecycle hooks, and asks the graph owner to delete only the volumes that this run created. Shared dependency caches are released, not -deleted. The run mapping is retired only after every owned volume is confirmed absent, so +deleted. The request uses `graph cleanup --remove-data --retained-only`: the runtime +re-checks that the graph is still stopped while holding the lock a restore also needs, so if `hack up` in +another terminal restored the run in the meantime, removal refuses +(`graph_cleanup_not_retained`) instead of stopping it, and nothing is deleted. The run +mapping is retired only after every owned volume is confirmed absent, so the next `up` starts a new run with new empty volumes. If removal is interrupted or cannot be confirmed, the mapping is kept and running the same command again resumes from the owner's durable cleanup intent. Ctrl-C (SIGINT) or SIGTERM ends the owned runtime diff --git a/packages/runtime-core/src/graph_cli.rs b/packages/runtime-core/src/graph_cli.rs index 277c38e32..8d25728ae 100644 --- a/packages/runtime-core/src/graph_cli.rs +++ b/packages/runtime-core/src/graph_cli.rs @@ -630,6 +630,7 @@ pub fn command(candidate: &Candidate, args: &[&str]) -> Result Result Result { + // Stopped-only data removal never reaches a live owner, so a graph + // restored after the caller confirmed it stopped is never stopped by it. + if retained_only && !remove_data { + return Err(invalid()); + } #[cfg(target_os = "macos")] if graph::inspect(candidate, run)? .receipt .relay_startup .is_some() { - return graph::foreground::cleanup_request(candidate, run, remove_data); + return if retained_only { + graph::foreground::remove_retained_data(candidate, run) + } else { + graph::foreground::cleanup_request(candidate, run, remove_data) + }; + } + if retained_only { + return encode(graph::remove_retained_data(candidate, run)?); } encode(graph::cleanup(candidate, run, remove_data)?) } diff --git a/packages/runtime-core/src/provider/graph/foreground.rs b/packages/runtime-core/src/provider/graph/foreground.rs index d4c64b761..25f2509f3 100644 --- a/packages/runtime-core/src/provider/graph/foreground.rs +++ b/packages/runtime-core/src/provider/graph/foreground.rs @@ -495,16 +495,37 @@ pub fn cleanup_request( remove_data: bool, ) -> Result { if remove_data { - if let Some(guard) = transport::Retired::acquire(candidate, run)? { - return retained_data::remove(candidate, run, guard); - } - if let Ok(guard) = transport::DeadOwner::acquire(candidate, run) { - return retained_data::remove_recovered(candidate, run, guard); + if let Some(removed) = remove_without_live_owner(candidate, run)? { + return Ok(removed); } } request(candidate, run, Some(remove_data)) } +/// Remove data only under the exact guard of a retired or dead owner. Each +/// guard holds the run's operation lock, which a restoring owner must take +/// before it publishes, and removal itself admits only a stopped receipt. +fn remove_without_live_owner( + candidate: &Candidate, + run: &str, +) -> Result, CandidateError> { + if let Some(guard) = transport::Retired::acquire(candidate, run)? { + return retained_data::remove(candidate, run, guard).map(Some); + } + if let Ok(guard) = transport::DeadOwner::acquire(candidate, run) { + return retained_data::remove_recovered(candidate, run, guard).map(Some); + } + Ok(None) +} + +/// Stopped-only data removal (`graph cleanup --remove-data --retained-only`). +/// Unlike [`cleanup_request`] it never falls back to the live-owner request: +/// a live owner, including one restored after the caller confirmed the graph +/// stopped, refuses before any request reaches it, so nothing is stopped. +pub fn remove_retained_data(candidate: &Candidate, run: &str) -> Result { + remove_without_live_owner(candidate, run)?.ok_or_else(super::not_retained) +} + /// Status is None; cleanup is Some(remove_data). Missing/stale owners refuse; this /// never substitutes ordinary graph cleanup or kills an observed PID. pub fn request( diff --git a/packages/runtime-core/src/provider/graph/foreground/tests.rs b/packages/runtime-core/src/provider/graph/foreground/tests.rs index 919ff4278..912f33847 100644 --- a/packages/runtime-core/src/provider/graph/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/foreground/tests.rs @@ -533,3 +533,39 @@ fn dead_owner_selection_holds_lock_and_rejects_live_or_changed_records() { drop(guard); fs::remove_dir_all(path).unwrap(); } +#[test] +fn stopped_only_removal_never_requests_an_owner_restored_after_inspection() { + let (_fixture, candidate, run) = fixture(); + let path = root(&candidate, &run); + // The confirmed stopped run's owner retired; a restore then published a live owner. + let mut first = Publication::bind(&candidate, &run).unwrap(); + first.finish().unwrap(); + drop(first); + let mut restored = Publication::bind_retired(&candidate, &run).unwrap(); + assert_eq!( + remove_retained_data(&candidate, &run).unwrap_err().code, + "graph_cleanup_not_retained" + ); + // No request reached the restored owner, so it stopped nothing and deleted nothing. + assert!(restored.accept().unwrap().is_none()); + // Control: the general cleanup request does fall back to that live owner. + std::thread::scope(|scope| { + let sender = scope.spawn(|| cleanup_request(&candidate, &run, true)); + let deadline = Instant::now() + Duration::from_secs(5); + let mut server = loop { + if let Some(server) = restored.accept().unwrap() { + break server; + } + assert!(Instant::now() < deadline, "cleanup request never arrived"); + std::thread::sleep(Duration::from_millis(5)); + }; + let request: WireRequest = + transport::read(&mut server, Duration::from_secs(5), 64 * 1024).unwrap(); + assert_eq!(request.remove_data, Some(true)); + drop(server); + assert!(sender.join().unwrap().is_err()); + }); + restored.finish().unwrap(); + drop(restored); + fs::remove_dir_all(path).unwrap(); +} diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..efd1903ff 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -177,6 +177,12 @@ const MAX_VOLUMES: usize = 8; fn error(code: &'static str, message: &str) -> CandidateError { CandidateError::new(code, message) } +fn not_retained() -> CandidateError { + error( + "graph_cleanup_not_retained", + "Stopped-only data removal refused: the graph is not stopped with retained data. Nothing was stopped or removed.", + ) +} fn hex(value: &str, len: usize) -> bool { value.len() == len && value @@ -1644,6 +1650,27 @@ pub fn cleanup( Ok(receipt) } +/// Stopped-only data removal for a graph without a foreground owner. The phase +/// is checked under the same mutation lease that restore needs, so a run that +/// was restored or started since the caller's inspection refuses instead of +/// having its compute stopped. A durable cleanup intent or an already removed +/// receipt resumes. +pub fn remove_retained_data(candidate: &Candidate, run: &str) -> Result { + let engine = Engine::connect_cleanup(candidate)?; + let (receipt, root) = load(candidate, &engine, run)?; + if receipt.relay_startup.is_some() + || !["stopped-data-retained", "cleanup-intent", "removed"].contains(&receipt.phase.as_str()) + { + return Err(not_retained()); + } + cleanup_enrollment::ordinary_mutation(&root, &receipt)?; + let receipt = cleanup_owned(candidate, &engine, receipt, &root, true)?; + if let Some(containers) = environment::bound_containers(&receipt) { + environment::compact_history(candidate, &engine, &receipt.run, &containers); + } + Ok(receipt) +} + // The caller retains the VM mutation lease through this entire effect and any // additional relay confirmation. Never reconnect from inside this function. fn cleanup_owned( diff --git a/packages/runtime-core/tests/local_contract.rs b/packages/runtime-core/tests/local_contract.rs index aaccb03e0..1bbbc032e 100644 --- a/packages/runtime-core/tests/local_contract.rs +++ b/packages/runtime-core/tests/local_contract.rs @@ -548,6 +548,18 @@ fn graph_cli_rejects_duplicate_and_cross_action_flags_before_runtime_access() { ], vec!["graph", "inspect", "--run-id", "a", "--remove-data"], vec!["graph", "cleanup", "--run-id", "a", "--run-id", "b"], + // Stopped-only removal is a cleanup refinement and requires --remove-data. + vec!["graph", "cleanup", "--run-id", "a", "--retained-only"], + vec![ + "graph", + "cleanup", + "--run-id", + "a", + "--remove-data", + "--retained-only", + "--retained-only", + ], + vec!["graph", "inspect", "--run-id", "a", "--retained-only"], vec![ "graph", "run", diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index 33fe5464f..96bd72917 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -395,12 +395,16 @@ function confirmRemoved(value: unknown, run: NativeProjectRun): void { * Explicit retained-data removal for a stopped native Compose-file project * (Compose parity: `down --volumes`). It never stops compute or runs hooks: * a running graph refuses, so `hack down` must stop it first. Under the - * restart lock and after the original frontend's finalization, it asks the - * graph owner for `graph cleanup --remove-data`, which deletes only - * receipt-owned volumes behind a durable cleanup intent and releases shared - * dependency caches without deleting them. The run mapping is retired only - * after every owned volume is confirmed absent, so the next `up` starts a - * fresh run with new empty volumes. + * native operation lock and after the original frontend's finalization, it + * asks the graph owner for `graph cleanup --remove-data --retained-only`, + * which deletes only receipt-owned volumes behind a durable cleanup intent + * and releases shared dependency caches without deleting them. + * `--retained-only` makes the owner re-check the stopped state atomically, so + * a `hack up` elsewhere that restored the run after these frontend checks + * makes it refuse instead of stopping that run; the frontend lock alone cannot + * exclude that restore. The run mapping is retired only after every owned + * volume is confirmed absent, so the next `up` starts a fresh run with new + * empty volumes. * * Failure keeps the mapping. A rerun resumes from re-inspection: a durable * cleanup intent or an already removed receipt continues without replaying @@ -493,6 +497,9 @@ export async function nativeProjectRemoveData(opts: { "--run-id", run.run, "--remove-data", + // Atomic stopped-only gate: the owner refuses a run restored since + // the checks above instead of stopping it and deleting its data. + "--retained-only", "--json", ], timeoutMs: 590_000, diff --git a/tests/native-project-remove-data.test.ts b/tests/native-project-remove-data.test.ts index 4bbfe13cf..93d2200ff 100644 --- a/tests/native-project-remove-data.test.ts +++ b/tests/native-project-remove-data.test.ts @@ -213,7 +213,15 @@ test("a stopped project removes owned data, keeps caches and retires the mapping expect(r.calls).toEqual([ ["graph", "inspect", "--run-id", run.run, "--json"], ["graph", "inspect", "--run-id", run.run, "--json"], - ["graph", "cleanup", "--run-id", run.run, "--remove-data", "--json"], + [ + "graph", + "cleanup", + "--run-id", + run.run, + "--remove-data", + "--retained-only", + "--json", + ], ["graph", "inspect", "--run-id", run.run, "--json"], ]); expect(await loadNativeProjectRun(opts.scope)).toBeNull(); @@ -561,6 +569,50 @@ try { expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); +test("a restore landing between the stopped check and cleanup is refused by the owner: nothing is stopped or deleted", async () => { + const opts = await fixture(); + // Models the runtime: the general cleanup path reaches a live restored owner, + // which stops it and deletes its data; `--retained-only` refuses instead. + const world = { restored: false, running: false, dataPresent: true }; + let inspections = 0; + const invoke = (async (request) => { + if (request.args[1] === "cleanup") { + if (!world.restored) { + world.dataPresent = false; + return {}; + } + if (!request.args.includes("--retained-only")) { + world.running = false; + world.dataPresent = false; + return {}; + } + throw new NativeRuntimeRequestError({ + message: "synthetic-private-diagnostic", + nativeCode: "graph_cleanup_not_retained", + }); + } + inspections += 1; + if (world.restored) { + return graph("ready-observed"); + } + if (inspections === 2) { + // `hack up` in another terminal restores the same run right after + // removal's stopped check under the lock, before its cleanup request. + world.restored = true; + world.running = true; + } + return graph("stopped-data-retained"); + }) as typeof invokeNativeRuntime; + await expectClosedRefusal( + nativeProjectRemoveData({ ...opts, invoke }), + "Native data removal is unconfirmed (graph_cleanup_not_retained); the run mapping is kept" + ); + expect(inspections).toBe(2); + expect(world).toEqual({ restored: true, running: true, dataPresent: true }); + expect(await loadNativeProjectRun(opts.scope)).toEqual(run); + expect(await lockPresent(opts.scope)).toBe(false); +}); + test("a mapping replaced while confirming is never removed", async () => { const opts = await fixture(); const replacement = { ...run, run: "9".repeat(32) }; From 78c82247b24ffc96dab027368de3ac75e9437581 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:52:04 -0500 Subject: [PATCH 038/138] feat(native): add a new project root to the stopped shared pool on up Native source admission now handles `project_share_extension_required`: when the shared pool is stopped, `up` adds the root through the runtime's journaled `runtime project-share add` (same unfiltered-source consent as the first root) and re-admits it before any lifecycle hook. When the pool is running, `up` refuses before hooks and names the stop-then-up path, because a running pool's mounts never change. Status and share-update receipts must be exact; any other refusal propagates unchanged. Documents that one installed native home now serves every project root and worktree through one pool and one HTTPS router. Co-Authored-By: Claude Opus 5.5 --- docs/guides/candidate-install.md | 5 + docs/guides/native-candidate.md | 15 +- .../native-project-source-preflight.ts | 97 +++++++++- tests/native-project-source-preflight.test.ts | 167 +++++++++++++++++- 4 files changed, 261 insertions(+), 23 deletions(-) diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 4a4f6f27c..6d632addf 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -82,6 +82,11 @@ bundled `hack-cli` directly or create a private `HACK_HOME` to test Compose. | Native (default) | `"$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | The version's private `cli-home` and `native-home` | The candidate's native routing, set up through [native candidate preparation](native-candidate.md) | | Compose | `HACK_RUNTIME_BACKEND=compose "$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | Your own `HACK_HOME` (default `~/.hack`) and `HACK_GLOBAL_CONFIG_PATH` | Your existing global Caddy, CoreDNS and local CA | +On the native backend, every project root and linked worktree uses the version's +one `native-home`, so they share one VM pool and one HTTPS router, as Compose +projects share one Docker VM and the global Caddy. A root joins the pool while the +pool is stopped; see [one pool, several project roots](native-candidate.md#explicit-writable-development-source). + With `HACK_RUNTIME_BACKEND=compose`, `hack-next` verifies the selected bundle as usual and then runs its `hack-cli` on the Compose backend. It keeps your Hack home, so `https://.hack` routes, container DNS and CA trust use the global diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index ccd3a676e..d5b238a25 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -696,12 +696,15 @@ unfiltered mode for a checkout whose local files must remain hidden from its ser Only the exact canonical project root is accepted; home/credential directories, aliased roots, and changes to a running pool's mount intent are refused. -One development pool can host several project roots and linked worktrees. The -first root is the pool's primary share. Add another root to a **stopped** pool with -`runtime project-share add --project-share /absolute/other-project --unfiltered-source --json`; -the share is mounted at the next boot. A running pool's mounts never change, so -`up` for a root outside the set refuses with `project_share_extension_required` -until the pool is stopped and the root is added. Remove an additional root (for +One development pool can host several project roots and linked worktrees, so one +installed native home serves every project on the machine. The first root is the +pool's primary share. Native `up` in another root adds that root itself when the +pool is **stopped**, before any lifecycle hook runs, with the same unfiltered-source +consent the first root gave; the share is mounted at the next boot. The explicit +form is `runtime project-share add --project-share /absolute/other-project --unfiltered-source --json`. +A running pool's mounts never change: `up` in a root outside the set refuses before +hooks and says to stop the projects using the pool (`down` in each), stop the pool +(`runtime down --json` on the native home), and run `up` again. Remove an additional root (for example a deleted worktree) from a stopped pool with `runtime project-share remove --project-share /absolute/old-worktree --json`; removal refuses with `project_share_in_use` while any retained graph still names that diff --git a/src/backends/native-project-source-preflight.ts b/src/backends/native-project-source-preflight.ts index b5a61bb0e..f38210151 100644 --- a/src/backends/native-project-source-preflight.ts +++ b/src/backends/native-project-source-preflight.ts @@ -2,37 +2,118 @@ import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { invokeNativeRuntime, + NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; -/** Reject incompatible source pools before hooks; runtime up rechecks before effects. */ +/** Native code: the pool exists but does not share this exact project root yet. */ +export const NATIVE_PROJECT_SHARE_EXTENSION_REQUIRED = + "project_share_extension_required"; + +/** + * Reject incompatible source pools before hooks; runtime up rechecks before effects. + * + * One pool hosts several project roots. When the pool does not share this root yet, + * a stopped pool gains it through the runtime's journaled stopped-pool share update + * (mounted at the next boot), with the same explicit unfiltered-source consent the + * first root's `up` gives. A running pool's mounts never change: that case refuses + * before hooks with the steps that make the root shareable. + */ export async function preflightNativeProjectSource(opts: { readonly runtime: NativeRuntimeSelection; readonly projectRoot: string; readonly signal?: AbortSignal; readonly invoke?: typeof invokeNativeRuntime; }): Promise { - const result = await (opts.invoke ?? invokeNativeRuntime)({ + const invoke = opts.invoke ?? invokeNativeRuntime; + const check = () => + invoke({ + runtime: opts.runtime, + cwd: opts.runtime.home, + args: [ + "runtime", + "check-project-share", + "--project-share", + opts.projectRoot, + "--unfiltered-source", + "--json", + ], + timeoutMs: 5000, + signal: opts.signal, + }); + let result: unknown; + try { + result = await check(); + } catch (error) { + if ( + !(error instanceof NativeRuntimeRequestError) || + error.nativeCode !== NATIVE_PROJECT_SHARE_EXTENSION_REQUIRED + ) { + throw error; + } + await addNativeProjectShare({ ...opts, invoke }); + result = await check(); + } + if ( + !isRecord(result) || + result.source_admitted !== true || + typeof result.pool_initialized !== "boolean" + ) { + throw new ClosedCliError( + "Native source admission returned an invalid receipt; startup hooks were not run." + ); + } +} + +async function addNativeProjectShare(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly signal?: AbortSignal; + readonly invoke: typeof invokeNativeRuntime; +}): Promise { + const status = await opts.invoke({ + runtime: opts.runtime, + cwd: opts.runtime.home, + args: ["runtime", "status", "--json"], + timeoutMs: 5000, + signal: opts.signal, + }); + if (!isRecord(status) || typeof status.phase !== "string") { + throw new ClosedCliError( + "Native runtime status returned an invalid receipt; the project share was not changed and startup hooks were not run." + ); + } + if (status.phase === "running" || status.process_alive === true) { + throw new ClosedCliError( + "The running native pool does not share this project root, and a running pool's mounts never change. " + + "Stop the native projects that use this pool (`down` in each), stop the pool (`runtime down --json` on this native home), then run `up` here again: " + + "the stopped pool adds this root and shares it from the next boot. Startup hooks were not run." + ); + } + const added = await opts.invoke({ runtime: opts.runtime, cwd: opts.runtime.home, args: [ "runtime", - "check-project-share", + "project-share", + "add", "--project-share", opts.projectRoot, "--unfiltered-source", "--json", ], - timeoutMs: 5000, + timeoutMs: 30_000, signal: opts.signal, }); if ( - !isRecord(result) || - result.source_admitted !== true || - typeof result.pool_initialized !== "boolean" + !isRecord(added) || + added.kind !== "project_share_added" || + added.project !== opts.projectRoot || + added.restarted !== false || + added.disks_preserved !== true ) { throw new ClosedCliError( - "Native source admission returned an invalid receipt; startup hooks were not run." + "Native project share update returned an invalid receipt; startup hooks were not run." ); } } diff --git a/tests/native-project-source-preflight.test.ts b/tests/native-project-source-preflight.test.ts index 517336a8e..1b0cec94d 100644 --- a/tests/native-project-source-preflight.test.ts +++ b/tests/native-project-source-preflight.test.ts @@ -1,8 +1,34 @@ import { expect, test } from "bun:test"; -import { preflightNativeProjectSource } from "../src/backends/native-project-source-preflight.ts"; +import { + NATIVE_PROJECT_SHARE_EXTENSION_REQUIRED, + preflightNativeProjectSource, +} from "../src/backends/native-project-source-preflight.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; import { ClosedCliError } from "../src/lib/cli-result.ts"; const runtime = { binary: "/candidate/hack-native", home: "/candidate/home" }; +const checkArgs = (root: string) => [ + "runtime", + "check-project-share", + "--project-share", + root, + "--unfiltered-source", + "--json", +]; +const extensionRequired = () => + new NativeRuntimeRequestError({ + message: + "Native runtime request failed (project_share_extension_required).", + nativeCode: NATIVE_PROJECT_SHARE_EXTENSION_REQUIRED, + }); +const added = (project: string) => ({ + kind: "project_share_added", + project, + changed: true, + restarted: false, + disks_preserved: true, + effective: "next-boot", +}); test("source admission asks the selected runtime to validate the exact root", async () => { await preflightNativeProjectSource({ @@ -10,14 +36,7 @@ test("source admission asks the selected runtime to validate the exact root", as projectRoot: "/worktrees/branch/project", invoke: async (request) => { expect(request.runtime).toEqual(runtime); - expect(request.args).toEqual([ - "runtime", - "check-project-share", - "--project-share", - "/worktrees/branch/project", - "--unfiltered-source", - "--json", - ]); + expect(request.args).toEqual(checkArgs("/worktrees/branch/project")); expect(request.timeoutMs).toBe(5000); return { source_admitted: true, pool_initialized: false }; }, @@ -51,3 +70,133 @@ test("invalid and refused source admission never counts as startup permission", }) ).rejects.toThrow("source pool mismatch"); }); + +test("a stopped shared pool adds another project root before hooks, then re-admits it", async () => { + const calls: string[][] = []; + let shared = false; + await preflightNativeProjectSource({ + runtime, + projectRoot: "/work/second-app", + invoke: async (request) => { + calls.push([...request.args]); + expect(request.runtime).toEqual(runtime); + expect(request.cwd).toBe(runtime.home); + if (request.args[1] === "check-project-share") { + if (!shared) { + throw extensionRequired(); + } + return { source_admitted: true, pool_initialized: true }; + } + if (request.args[1] === "status") { + return { phase: "stopped", process_alive: false }; + } + shared = true; + return added("/work/second-app"); + }, + }); + expect(calls).toEqual([ + checkArgs("/work/second-app"), + ["runtime", "status", "--json"], + [ + "runtime", + "project-share", + "add", + "--project-share", + "/work/second-app", + "--unfiltered-source", + "--json", + ], + checkArgs("/work/second-app"), + ]); +}); + +test("a running shared pool never changes mounts and explains the stop-then-up path", async () => { + for (const status of [ + { phase: "running", process_alive: true }, + { phase: "process-exited", process_alive: true }, + ]) { + const calls: string[][] = []; + const refusal = await preflightNativeProjectSource({ + runtime, + projectRoot: "/work/second-app", + invoke: async (request) => { + calls.push([...request.args]); + if (request.args[1] === "check-project-share") { + throw extensionRequired(); + } + return status; + }, + }).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(ClosedCliError); + expect(String(refusal)).toContain("mounts never change"); + expect(String(refusal)).toContain("Startup hooks were not run"); + expect(calls.some((args) => args[1] === "project-share")).toBe(false); + } +}); + +test("share update and status receipts must be exact before admission continues", async () => { + for (const [status, receipt] of [ + [null, added("/work/second-app")], + [{ phase: "stopped", process_alive: false }, null], + [{ phase: "stopped", process_alive: false }, added("/work/other")], + [ + { phase: "stopped", process_alive: false }, + { ...added("/work/second-app"), restarted: true }, + ], + [ + { phase: "stopped", process_alive: false }, + { ...added("/work/second-app"), kind: "project_share_removed" }, + ], + ] as const) { + let checks = 0; + const refusal = await preflightNativeProjectSource({ + runtime, + projectRoot: "/work/second-app", + invoke: async (request) => { + if (request.args[1] === "check-project-share") { + checks += 1; + throw extensionRequired(); + } + return request.args[1] === "status" ? status : receipt; + }, + }).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(ClosedCliError); + expect(String(refusal)).toContain("invalid receipt"); + expect(checks).toBe(1); + } +}); + +test("other native refusals and a refused share update propagate unchanged", async () => { + const foreign = new NativeRuntimeRequestError({ + message: "Native runtime request failed (project_share).", + nativeCode: "project_share", + }); + await expect( + preflightNativeProjectSource({ + runtime, + projectRoot: "/project", + invoke: async () => { + throw foreign; + }, + }) + ).rejects.toBe(foreign); + const update = new NativeRuntimeRequestError({ + message: "Native runtime request failed (project_share_update).", + nativeCode: "project_share_update", + }); + await expect( + preflightNativeProjectSource({ + runtime, + projectRoot: "/project", + invoke: async (request) => { + if (request.args[1] === "check-project-share") { + throw extensionRequired(); + } + if (request.args[1] === "status") { + return { phase: "stopped", process_alive: false }; + } + throw update; + }, + }) + ).rejects.toBe(update); +}); From 98f79ba67c4468385b5a1600ab5d327d38e5ef00 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:53:51 -0500 Subject: [PATCH 039/138] fix(native): canonicalize service host aliases before matching bindings Native review compiles service-declared extra_hosts names with the runtime's normalize_hostname semantics (one trailing dot removed, ASCII lowercased), but the new early binding check matched raw spellings. A working declaration such as `API.Example.:host-gateway` with a binding for `api.example` was newly refused as unbound. Service aliases are now canonicalized the same way before matching (names the check cannot canonicalize, such as `${...}` references, are left to admission, which still enforces exact equality). Project-wide alias declaration compares by the same canonical name, so it no longer adds a second spelling that the runtime would reject as a duplicate. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 5 +- src/backends/native-project-internal-hosts.ts | 51 ++++++++++--- tests/native-project-internal-hosts.test.ts | 72 +++++++++++++++++++ 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 343c68b74..ec4f09296 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1317,7 +1317,10 @@ host dependency binding; configuration was not dropped."), before any runtime work; with no dependency selection at all it refuses before lifecycle hooks. An entry whose target is not `host-gateway` (for example a fixed address), a name that is not an exact lowercase DNS name, or a service that already maps the name -to a different target also refuses. Earlier native builds ignored these entries, +to a different target also refuses. Service-declared names are matched in the +form native review compiles them to (one trailing dot removed, lowercased), so a +service that declares `API.Example.` is covered by a binding for `api.example` and +does not receive a second spelling. Earlier native builds ignored these entries, which let a container resolve the public name instead of the local listener. Native reads these aliases before lifecycle hooks, because they are part of the reviewed configuration; unlike the Compose backend, a hook that changes them makes diff --git a/src/backends/native-project-internal-hosts.ts b/src/backends/native-project-internal-hosts.ts index 1c10a3441..2330a18cc 100644 --- a/src/backends/native-project-internal-hosts.ts +++ b/src/backends/native-project-internal-hosts.ts @@ -67,6 +67,32 @@ function exactAlias(value: string): boolean { ); } +const ASCII = /^[\x20-\x7e]*$/; + +/** + * The name native review compiles a service-declared extra host to, with the + * runtime's `normalize_hostname` semantics: strip one trailing dot, lowercase + * ASCII, and require an exact DNS name (not an address). Returns null for a + * name this check cannot canonicalize (for example an `${...}` reference the + * runtime resolves later); native admission still enforces exact alias and + * binding equality for those. + */ +function canonicalHostName(value: string): string | null { + if (!ASCII.test(value)) { + return null; + } + const name = (value.endsWith(".") ? value.slice(0, -1) : value).toLowerCase(); + if ( + name.length === 0 || + name.length > 253 || + NUMERIC_HOST.test(name) || + !name.split(".").every((label) => LABEL.test(label)) + ) { + return null; + } + return name; +} + /** * Split one Compose `extra_hosts` value into (name, target) pairs, using the * first `:` or `=` like the native review does. Returns undefined for a shape @@ -127,9 +153,11 @@ function declareOnService( if (!entries) { throw conflict(); } + // Compare by the compiled name: `API.Example.` and `api.example` are one + // runtime alias, so adding the second spelling would duplicate it. const declared = new Map(); for (const [name, target] of entries) { - declared.set(name, target); + declared.set(canonicalHostName(name) ?? name, target); } const added: string[] = []; for (const alias of aliases) { @@ -216,9 +244,12 @@ function activeService( } /** - * The exact `host-gateway` aliases each profile-active service declares, - * keyed by service. Inactive services are never scheduled and receive no - * bindings, so they need none. Malformed declarations are left to review. + * The `host-gateway` aliases each profile-active service declares, keyed by + * service, in the canonical form native review compiles them to (see + * {@link canonicalHostName}), so a declared `API.Example.` matches a binding + * for `api.example`. Inactive services are never scheduled and receive no + * bindings, so they need none. Malformed or not-yet-resolved declarations are + * left to review and admission. */ export function nativeServiceHostAliases(opts: { readonly services: Readonly< @@ -231,11 +262,15 @@ export function nativeServiceHostAliases(opts: { if (!activeService(spec, opts.profiles)) { continue; } - const aliases = (extraHostEntries(spec.extra_hosts) ?? []) - .filter(([, target]) => target === "host-gateway") - .map(([alias]) => alias); + const aliases = new Set(); + for (const [alias, target] of extraHostEntries(spec.extra_hosts) ?? []) { + const canonical = canonicalHostName(alias); + if (target === "host-gateway" && canonical !== null) { + aliases.add(canonical); + } + } Object.defineProperty(result, name, { - value: aliases, + value: [...aliases], enumerable: true, writable: true, configurable: true, diff --git a/tests/native-project-internal-hosts.test.ts b/tests/native-project-internal-hosts.test.ts index 127bf3a8b..d9e67ca41 100644 --- a/tests/native-project-internal-hosts.test.ts +++ b/tests/native-project-internal-hosts.test.ts @@ -219,6 +219,78 @@ test("host aliases are collected only for profile-active services", () => { }); }); +test("service aliases are canonicalized like native review before matching bindings", () => { + const hostAliases = nativeServiceHostAliases({ + services: { + web: { extra_hosts: ["API.Example.:host-gateway"] }, + map: { extra_hosts: { "Search.EXAMPLE.test.": "host-gateway" } }, + both: { + extra_hosts: ["api.example:host-gateway", "API.EXAMPLE.=host-gateway"], + }, + // Resolved by the runtime later; admission checks these exactly. + later: { extra_hosts: ["${ALIAS}:host-gateway", "1.2.3.4:host-gateway"] }, + }, + profiles: [], + }); + expect(hostAliases).toEqual({ + web: ["api.example"], + map: ["search.example.test"], + both: ["api.example"], + later: [], + }); + requireNativeHostAliasBindings({ + dependencies: [ + binding("web", ["api.example"]), + binding("map", ["search.example.test"]), + binding("both", ["api.example"]), + ], + hostAliases, + }); + // A genuinely different name is still unbound. + expectCodedThrow( + () => + requireNativeHostAliasBindings({ + dependencies: [binding("web", ["api.example.test"])], + hostAliases: { web: hostAliases.web ?? [] }, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_UNBOUND_HOST_ALIAS_REFUSAL + ); +}); + +test("a project-wide alias another spelling already declares is not duplicated", () => { + const result = applyNativeInternalHostAliases({ + input: input({ + web: { image: "nginx", extra_hosts: ["API.Example.Test.:host-gateway"] }, + map: { + image: "nginx", + extra_hosts: { "Api.example.test": "host-gateway" }, + }, + }), + hosts: { "api.example.test": "host-gateway" }, + hasDependencySelection: true, + }); + expect(services(result)).toEqual({ + web: { image: "nginx", extra_hosts: ["API.Example.Test.:host-gateway"] }, + map: { + image: "nginx", + extra_hosts: { "Api.example.test": "host-gateway" }, + }, + }); + expectCodedThrow( + () => + applyNativeInternalHostAliases({ + input: input({ + web: { image: "nginx", extra_hosts: ["API.Example.Test.:10.0.0.5"] }, + }), + hosts: { "api.example.test": "host-gateway" }, + hasDependencySelection: true, + }), + "E_CONFIG_INVALID", + "Native host alias selection conflicts with declared configuration; values omitted." + ); +}); + test("every active declared alias needs a binding of that same service", () => { const hostAliases = { web: ["api.example.test", "auth.example.test"], From 10df82ba17e0ae82776a81cfd66f647a709b761d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:55:47 -0500 Subject: [PATCH 040/138] fix(native): keep the connect stage when the deadline passes during backoff A stop whose connect attempts all failed reported the `deadline` stage ("effects may be uncertain") whenever the batch deadline expired during the backoff sleep, as happens when a loaded host oversleeps. No attempt sent request bytes, so the stop now keeps its last connect stage. The backoff pause is injectable so a test can oversleep deterministically. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/engine/stop.rs | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/provider/engine/stop.rs b/packages/runtime-core/src/provider/engine/stop.rs index 4e2b0ae91..447fffc07 100644 --- a/packages/runtime-core/src/provider/engine/stop.rs +++ b/packages/runtime-core/src/provider/engine/stop.rs @@ -181,6 +181,9 @@ struct Policy { connect_window: Duration, backoff_start: Duration, backoff_max: Duration, + /// Waits between connect attempts. Tests substitute a scheduler that + /// oversleeps, as a loaded host does. + pause: fn(Duration), } const POLICY: Policy = Policy { @@ -188,6 +191,7 @@ const POLICY: Policy = Policy { connect_window: CONNECT_RETRY_WINDOW, backoff_start: CONNECT_BACKOFF_START, backoff_max: CONNECT_BACKOFF_MAX, + pause: std::thread::sleep, }; type Attempt = Result<(), (CandidateError, StopFailureStage)>; @@ -322,6 +326,8 @@ where /// Sends one stop, retrying only connect-stage failures with capped backoff. /// Once any stop exhausts its connect window, the engine is treated as /// unreachable for this batch and later connect failures return at once. +/// When the deadline passes during a backoff, the stop keeps its last connect +/// stage: no attempt sent request bytes, so its effect is known, not uncertain. fn attempt( index: usize, deadline: Instant, @@ -334,16 +340,19 @@ where { let mut window = None; let mut backoff = policy.backoff_start; + let mut last_connect = None; loop { - let remaining = deadline + let Some(remaining) = deadline .checked_duration_since(Instant::now()) .filter(|remaining| !remaining.is_zero()) - .ok_or_else(|| { + else { + return Err(last_connect.unwrap_or_else(|| { ( failure("Container stop batch deadline expired; effects may be uncertain."), StopFailureStage::Deadline, ) - })?; + })); + }; match send(index, remaining) { Err((error, stage)) if matches!( @@ -357,7 +366,8 @@ where unreachable.store(true, Ordering::SeqCst); return Err((error, stage)); } - std::thread::sleep(backoff); + last_connect = Some((error, stage)); + (policy.pause)(backoff); backoff = backoff.saturating_mul(2).min(policy.backoff_max); } outcome => return outcome, @@ -454,6 +464,7 @@ mod tests { connect_window: window, backoff_start: Duration::from_millis(1), backoff_max: Duration::from_millis(4), + pause: std::thread::sleep, } } @@ -671,6 +682,33 @@ mod tests { } } + #[test] + fn deadline_passing_during_connect_backoff_keeps_the_connect_stage() { + fn oversleep(backoff: Duration) { + std::thread::sleep(backoff + Duration::from_millis(80)); + } + let stops = ids(1); + let attempts = AtomicUsize::new(0); + let failure = dispatch( + &stops, + Instant::now() + Duration::from_millis(40), + Policy { + pause: oversleep, + ..quick(Duration::from_secs(5)) + }, + |_, _| { + attempts.fetch_add(1, Ordering::SeqCst); + connect_failure(StopFailureStage::ConnectTimeout) + }, + ) + .err() + .unwrap(); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + assert_eq!(failure.error.code, "engine_protocol"); + assert_eq!(failure.failures.len(), 1); + assert_eq!(failure.failures[0].stage, StopFailureStage::ConnectTimeout); + } + #[test] fn non_connect_failures_are_never_retried_and_keep_their_stage() { let stops = ids(6); From fe503d425a3bbe2feda1601f53eba3d25bd17c46 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:56:10 -0500 Subject: [PATCH 041/138] fix(native): await a concurrent down.before before foreground cleanup When another terminal's hack down holds this run's down.before claim (before-started) at the foreground's first Ctrl-C, the frontend no longer treats the failed compare-and-swap as a completed phase and lets graph cleanup interrupt that hook. It announces the wait and polls the journal until the other claim settles: a known success or a superseding record is skipped, and a known failure that restored the frontend's owed record is claimed and run once by the frontend. A second Ctrl-C (the force signal) ends the wait and forces the stop, leaving the other hook's settlement to its owner. A phase already settled is still skipped without waiting. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 6 +- src/backends/native-project-down-hooks.ts | 40 +++++- .../native-project-foreground-stop.ts | 14 ++- tests/native-project-down-hooks.test.ts | 119 +++++++++++++++++- 4 files changed, 169 insertions(+), 10 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index cd20630e3..7fd4b559a 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -795,7 +795,11 @@ completion is unknown; inspect before retrying." and nothing is replayed. That refusal also blocks the next `up` before any hook. After checking what the interrupted hook did, remove `.hack/.internal/native-runs/*.down-hooks.json` to clear it. A `hack down` from another terminal and the frontend's Ctrl-C claim the -same journal record, so concurrent stops run each hook at most once. A new `up` +same journal record, so concurrent stops run each hook at most once. If that `hack +down` is still running `down.before` when you press Ctrl-C, the frontend says so and +waits for the hook to finish before it stops the graph; it runs `down.before` itself +only if the other attempt failed and handed the record back. Press Ctrl-C again to +stop without waiting. A new `up` supersedes any other leftover record; it does not run an earlier generation's owed hooks. Projects without down hooks keep the previous stop behavior and write no journal. diff --git a/src/backends/native-project-down-hooks.ts b/src/backends/native-project-down-hooks.ts index 291e7e72e..16d0fe803 100644 --- a/src/backends/native-project-down-hooks.ts +++ b/src/backends/native-project-down-hooks.ts @@ -41,6 +41,8 @@ const HEX32 = /^[a-f0-9]{32}$/; const LIMIT = 1024; const LOCK_ATTEMPTS = 80; const LOCK_DELAY_MS = 25; +/** Poll interval while another stop's `down.before` claim is in progress. */ +const CLAIM_POLL_MS = 100; const STATES: readonly NativeDownHookState[] = [ "owed", "before-started", @@ -306,10 +308,18 @@ export type NativeForegroundDownHooks = { * runs down hooks. */ readonly owe: (interrupted: () => boolean) => Promise; - /** First stop request on a ready graph, before graph cleanup. */ + /** + * First stop request on a ready graph, before graph cleanup. While another + * stop (a concurrent `hack down`) holds this run's `down.before` claim, it + * waits for that hook's settlement, so graph cleanup never overtakes it, + * calling `waiting` once; a second interrupt (`canceled`) ends the wait and + * forces the stop. It then claims the phase if the other stop restored it + * after a known failure, and otherwise skips it. + */ readonly before: ( commands: NativeDownHookCommands, - canceled: () => boolean + canceled: () => boolean, + waiting?: () => void ) => Promise; /** After confirmed graph cleanup and lifecycle-process retirement. */ readonly after: ( @@ -351,7 +361,8 @@ async function journaled(opts: { /** * The foreground frontend's journal for one generation. Each transition is a * compare-and-swap on this generation's own record, so a concurrent - * `hack down` that claimed the record wins and this frontend skips the phase. + * `hack down` that claimed the record wins and this frontend skips the phase + * (after awaiting a `down.before` that is still running there). */ export function createNativeForegroundDownHooks(opts: { readonly scope: NativeProjectRunScope; @@ -375,16 +386,33 @@ export function createNativeForegroundDownHooks(opts: { await replaceNativeDownHookRecord(opts.scope, record("owed"), null); } }, - async before(commands, canceled) { + async before(commands, canceled, waiting) { const claimed = record("before-started"); - if ( + let notified = false; + while ( !(await replaceNativeDownHookRecord( opts.scope, record("owed"), claimed )) ) { - return "skipped"; + const current = await loadNativeDownHookRecord(opts.scope); + if ( + current?.run !== opts.run || + current.generation === own || + current.state !== "before-started" + ) { + // Settled (or superseded) elsewhere: never run it twice. + return "skipped"; + } + if (canceled()) { + return "canceled"; + } + if (!notified) { + notified = true; + waiting?.(); + } + await Bun.sleep(CLAIM_POLL_MS); } return await journaled({ scope: opts.scope, diff --git a/src/backends/native-project-foreground-stop.ts b/src/backends/native-project-foreground-stop.ts index 976f0037d..dd0c71a0d 100644 --- a/src/backends/native-project-foreground-stop.ts +++ b/src/backends/native-project-foreground-stop.ts @@ -53,6 +53,10 @@ export function nativeForegroundStopSignals(opts: { }; } +/** Shown while the first stop waits for a concurrent `hack down`'s hook. */ +export const NATIVE_DOWN_BEFORE_WAIT = + "Another hack down is running down.before; waiting for it before stopping the graph. Press Ctrl-C again to force the stop."; + export type NativeForegroundStopCommands = { readonly before: (signal: AbortSignal) => Promise; readonly after: (signal: AbortSignal) => Promise; @@ -70,7 +74,11 @@ export function createNativeForegroundStop(opts: { readonly commands: NativeForegroundStopCommands; readonly force: AbortSignal; readonly journal?: typeof createNativeForegroundDownHooks; + /** Where the wait for another stop's `down.before` is announced. */ + readonly notice?: (message: string) => void; }) { + const notice = + opts.notice ?? ((message: string) => process.stderr.write(`${message}\n`)); const journal = (opts.journal ?? createNativeForegroundDownHooks)({ scope: opts.scope, run: opts.run, @@ -80,7 +88,8 @@ export function createNativeForegroundStop(opts: { let refusal: unknown; return { /** - * Graph-owner options: run `down.before` before the owner is asked to stop. + * Graph-owner options: run `down.before` (or await a concurrent + * `hack down`'s running one) before the owner is asked to stop. * The owner runs in its own process group so the terminal's interrupt * reaches only this frontend. A failed hook still stops the graph because * the user asked to exit; final graph inspection stays the cleanup @@ -93,7 +102,8 @@ export function createNativeForegroundStop(opts: { try { before = await journal.before( () => opts.commands.before(opts.force), - canceled + canceled, + () => notice(NATIVE_DOWN_BEFORE_WAIT) ); } catch (error) { refusal = error; diff --git a/tests/native-project-down-hooks.test.ts b/tests/native-project-down-hooks.test.ts index 6a9bddf66..8e90c9c60 100644 --- a/tests/native-project-down-hooks.test.ts +++ b/tests/native-project-down-hooks.test.ts @@ -21,6 +21,10 @@ import { openNativeDownHookSession, replaceNativeDownHookRecord, } from "../src/backends/native-project-down-hooks.ts"; +import { + createNativeForegroundStop, + NATIVE_DOWN_BEFORE_WAIT, +} from "../src/backends/native-project-foreground-stop.ts"; import { type NativeProjectRun, type NativeProjectRunScope, @@ -347,12 +351,18 @@ test("down on a live graph claims the frontend's owed hooks, and the frontend's calls.push("down.before"); }); const p = phases(); + let waited = false; + // A settled phase is skipped at once, without waiting. expect( await frontend.before( () => p.before(), - () => false + () => false, + () => { + waited = true; + } ) ).toBe("skipped"); + expect(waited).toBe(false); await session.after(async () => { calls.push("down.after"); }); @@ -366,6 +376,113 @@ test("down on a live graph claims the frontend's owed hooks, and the frontend's expect(p.calls).toEqual([]); }); +/** A live `hack down` whose down.before is held running until released. */ +async function runningDownBefore(scope: NativeProjectRunScope) { + const session = await openNativeDownHookSession({ + scope, + run, + graphStopped: false, + configured: true, + }); + let release: (failure?: Error) => void = () => undefined; + const held = new Promise((resolve, reject) => { + release = (failure) => (failure ? reject(failure) : resolve()); + }); + const done = session + .before(() => held) + .then( + () => "succeeded", + (error: unknown) => error + ); + for (let attempt = 0; attempt < 40; attempt++) { + if ((await loadNativeDownHookRecord(scope))?.state === "before-started") { + return { release, done }; + } + await Bun.sleep(10); + } + throw new Error("the concurrent down never claimed down.before"); +} +function pending(promise: Promise): Promise { + const marker = Symbol("pending"); + return Promise.race([promise, Bun.sleep(300).then(() => marker)]).then( + (value) => value === marker + ); +} + +test("the first Ctrl-C waits for a concurrent down's running down.before, then skips it", async () => { + const scope = await fixture(); + const frontend = createNativeForegroundDownHooks({ scope, run: run.run }); + await frontend.owe(() => false); + const other = await runningDownBefore(scope); + const p = phases(); + let waits = 0; + const stop = frontend.before( + () => p.before(), + () => false, + () => { + waits++; + } + ); + // Graph cleanup must not overtake the other terminal's running hook. + expect(await pending(stop)).toBe(true); + expect(waits).toBe(1); + other.release(); + expect(await other.done).toBe("succeeded"); + expect(await stop).toBe("skipped"); + expect(p.calls).toEqual([]); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("after-owed"); +}); + +test("a second Ctrl-C while waiting for a concurrent down.before forces the stop", async () => { + const scope = await fixture(); + const frontend = createNativeForegroundDownHooks({ scope, run: run.run }); + await frontend.owe(() => false); + const other = await runningDownBefore(scope); + const force = new AbortController(); + const notices: string[] = []; + const p = phases(); + const stop = createNativeForegroundStop({ + scope, + run: run.run, + commands: { before: () => p.before(), after: () => p.after() }, + force: force.signal, + journal: () => frontend, + notice: (message) => notices.push(message), + }); + const beforeStop = stop.serve.beforeStop(); + expect(await pending(beforeStop)).toBe(true); + expect(notices).toEqual([NATIVE_DOWN_BEFORE_WAIT]); + const forced = performance.now(); + force.abort(); + expect(await beforeStop).toBe(true); + expect(performance.now() - forced).toBeLessThan(1000); + expect(p.calls).toEqual([]); + // The other terminal's hook still owns its own settlement. + expect((await loadNativeDownHookRecord(scope))?.state).toBe("before-started"); + other.release(); + expect(await other.done).toBe("succeeded"); + expect(await stop.finish(true)).toBeUndefined(); + expect(p.calls).toEqual([]); +}); + +test("a waiting frontend runs down.before once after a concurrent down's known failure restored it", async () => { + const scope = await fixture(); + const frontend = createNativeForegroundDownHooks({ scope, run: run.run }); + await frontend.owe(() => false); + const other = await runningDownBefore(scope); + const p = phases(); + const stop = frontend.before( + () => p.before(), + () => false + ); + expect(await pending(stop)).toBe(true); + other.release(new Error("synthetic hook failure")); + expect(String(await other.done)).toContain("synthetic hook failure"); + expect(await stop).toBe("succeeded"); + expect(p.calls).toEqual(["down.before"]); + expect((await loadNativeDownHookRecord(scope))?.state).toBe("after-owed"); +}); + test("down during up.after owns the hooks; the frontend's later owe does not", async () => { const scope = await fixture(); const calls: string[] = []; From a300f2496723501ec72b05261cbd15fa76250b87 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:36:40 -0500 Subject: [PATCH 042/138] feat(native): build images in the guest engine with runtime build-image Adds the runtime operation that builds one Compose-style image inside the native VM's engine with the pinned host BuildKit client, and the isolated build network it needs. Native up does not call it yet. - Egress pools give dockerd a fixed default bridge (10.213.255.0/24, icc=false) instead of --bridge=none; isolated pools keep none. guest-build-network.sh isolates it after the engine starts (drops build traffic to the guest, the host gateway and link-local metadata; allows DNS to the guest resolvers) and writes a boot-bound readiness marker. The engine client now refuses any container create that does not name an owned network, none or a container namespace, and any connect to the default bridge, so only BuildKit RUN steps can use it. - runtime build-image --request --json: paths resolve inside the project without symlinks; the Dockerfile is read (never run) and scanned; every base image resolves to a digest-pinned manifest and is handed to BuildKit as a named build context; base images with stored ONBUILD triggers refuse. The build key covers namespace (project root + branch), paths, Dockerfile and .dockerignore bytes, target, salted argument hashes and builder/engine versions; context contents are not hashed (Compose parity). - Argument values reach the client only through its environment (--build-arg NAME); names that would change the client's own environment refuse. Labels carry a digest of the pool incarnation, never the token. - The provider lease is held only for admission and adoption, never while BuildKit runs. A shared build fence makes runtime down refuse instead of stopping the engine under a build. A per-key lock serializes builds; a building receipt found under it is a dead attempt and is recorded indeterminate before building again. - Adoption requires the same incarnation, boot and engine as admission, the metadata's config digest, linux/arm64, exactly the owned labels, no other reserved ownership labels and no image VOLUMEs. Reuse requires a verified receipt from the same checkout, incarnation and engine whose image still inspects that way. - SIGINT/SIGTERM/SIGHUP and the time budget (default 30 min, max 2 h) stop the client's whole process group (SIGTERM, then SIGKILL after 10 s). Refs HACK-1270 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 29 + packages/runtime-core/README.md | 11 +- packages/runtime-core/src/main.rs | 8 + packages/runtime-core/src/project/mod.rs | 1 - .../runtime-core/src/provider/artifact.rs | 5 + packages/runtime-core/src/provider/engine.rs | 67 + .../src/provider/guest-build-network.sh | 33 + .../runtime-core/src/provider/guest-daemon.sh | 13 +- .../runtime-core/src/provider/image_build.rs | 1141 +++++++++++++++++ .../src/provider/image_build/tests.rs | 744 +++++++++++ .../runtime-core/src/provider/lifecycle.rs | 17 + packages/runtime-core/src/provider/mod.rs | 1 + .../src/provider/registry_image.rs | 102 +- 13 files changed, 2157 insertions(+), 15 deletions(-) create mode 100644 packages/runtime-core/src/provider/guest-build-network.sh create mode 100644 packages/runtime-core/src/provider/image_build.rs create mode 100644 packages/runtime-core/src/provider/image_build/tests.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index a220a37f0..74caf19b2 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -457,6 +457,35 @@ state and checks its code signature. If a prepared client exists, the command ch it again and does not replace it. If the prepared tree changes, `runtime probe` refuses; it reports `builder_prepared` when the client is present and unchanged. +`runtime build-image --request --json` builds one image in the running +runtime's engine with that client. It is a building block: native `up` still refuses +`build:` services and does not call it. The request is a private JSON file (a singly +linked, mode-`0600` file owned by you), because build-argument values are in it: + +```json +{"version": 1, "project_root": "/absolute/project", "context": "app", + "dockerfile": "Dockerfile", "target": null, "args": {"NAME": "literal"}, + "policy": "missing", "timeout_seconds": 1800} +``` + +- The context and Dockerfile must be inside the project and reached without symlinks. + The Dockerfile is read, never run, to find every image it can pull. Each image must + be a public Docker Hub reference; it is pinned to a digest before the build starts. + Base images with stored `ONBUILD` triggers refuse. +- Argument values reach the client only through its environment, never its command + line, receipts or output. Names that would change the client's own environment + (`PATH`, `HOME`, `DOCKER_*`, `BUILDX_*`, `BUILDKIT_*`, proxies, locale, Git and SSH + settings) refuse. +- `missing` reuses a verified image for the same build key without running the client; + `rebuild` always builds. The key covers the Dockerfile and `.dockerignore` bytes, + target, arguments and versions, not the context's other files (as with Compose). +- The runtime must have network egress and the isolated build network that a runtime + started by this version sets up. An older runtime refuses with + `native_build_runtime_restart`: stop it and start it again. +- `runtime down` refuses while a build runs. Ctrl-C or SIGTERM cancels the build and + stops the client's whole process group; so does the time budget (default 30 minutes, + at most 2 hours). + Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with regular, singly linked mode-`0600` files. Acquire the matching public package bytes; diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 9633d7895..db8af2a41 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -478,8 +478,15 @@ The default private build includes native HTTP graph probes (local `_docs/docs/p and requires Zig 0.15.2 plus a host C compiler. [Provider pins](provider-pins.json) record the exact package inputs; the engine runs inside the VM. The pinned host BuildKit client (`runtime prepare-builder`) and the pure Dockerfile -image-reference scanner (`provider/build_scan.rs`) prepare for native image builds; -no command builds images yet. +image-reference scanner (`provider/build_scan.rs`) back `runtime build-image` +(`provider/image_build.rs`), which builds one Compose-style image in the guest engine +from a private request file. Its build key, receipts, leases, the `runtime down` +build fence, cancellation and timeout rules are documented in that module. Egress +pools give dockerd a fixed default bridge (`10.213.255.0/24`, `icc=false`) that only +BuildKit `RUN` steps use; `guest-build-network.sh` isolates it from the guest, the host +gateway and link-local metadata, and the engine client refuses any container create +that does not name an owned network or `none`. Native `up` does not call the build +operation yet. Source binaries embed their build checkout. If a copied Cargo target directory retains a different checkout identity, use a fresh `--target-dir` for checks and diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index c52f4e015..a10acd084 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -102,6 +102,7 @@ Usage: hack-local runtime prepared-base status --profile research|development [--store ] [--json] hack-local runtime prepared-base remove --base-id [--store ] [--json] hack-local runtime admit-image --reference <[namespace/]repository[:tag][@sha256:digest]|sha256:image-id> --json + hack-local runtime build-image --request --json hack-local runtime ensure-image --reference <[namespace/]repository[:tag]|[namespace/]repository@sha256:digest> --json hack-local runtime resolve-image --reference <[namespace/]repository[:tag]> --json hack-local runtime fetch-image --reference <[namespace/]repository@sha256:digest> --archive [--json] @@ -948,6 +949,13 @@ fn run() -> Result<(), CandidateError> { reference, )?)?; } + ["runtime", "build-image", "--request", request, "--json"] => { + // Build output streams to stderr; only the result is printed on stdout. + print_json(&hack_runtime_core::provider::image_build::build_image( + &discover_candidate(&requested)?, + Path::new(request), + )?)?; + } ["runtime", "admit-image", "--reference", reference, "--json"] => { // Pure: no candidate discovery, network, credentials or runtime effects. print_json(&hack_runtime_core::provider::registry_image::admit( diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index 043076259..0d6b6b04c 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -23,7 +23,6 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; pub use enrollment::{EnrollmentReceipt, enroll, status, status_with_branch}; -#[cfg(feature = "native-config-plan")] pub(crate) use source::resolve as resolve_source; pub use source::{SourceEntry, SourceSelection}; diff --git a/packages/runtime-core/src/provider/artifact.rs b/packages/runtime-core/src/provider/artifact.rs index 1be865b98..b68405f09 100644 --- a/packages/runtime-core/src/provider/artifact.rs +++ b/packages/runtime-core/src/provider/artifact.rs @@ -311,6 +311,11 @@ pub fn prepare_builder(candidate: &Candidate, binary: &Path) -> Result PathBuf { + binary_root(candidate, &BUILDER).join(BUILDER.executable) +} + /// Verify the prepared BuildKit client before any use. pub fn verify_builder(candidate: &Candidate) -> Result { verify_binary(candidate, &BUILDER) diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index e0299cd9c..bbb9ac86f 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -94,6 +94,7 @@ impl Transport { { return Err(failure("Invalid private engine request path.")); } + require_owned_network(&method, path, body)?; let requires_admission = ![Method::GET, Method::HEAD, Method::DELETE].contains(&method); let mut request = self .client @@ -127,6 +128,40 @@ impl Transport { } } +/// Every container Hack creates names its network explicitly: an owned network, `none` +/// or another container's namespace. Egress pools give the engine a default bridge that +/// only BuildKit `RUN` steps may use (guest-daemon.sh); an unset, `default`, `bridge` +/// or `host` mode, or a connect to the default bridge, would put a container on it or +/// in the guest root namespace, so both refuse before dispatch. +fn require_owned_network( + method: &Method, + path: &str, + body: Option<&Value>, +) -> Result<(), CandidateError> { + if *method != Method::POST { + return Ok(()); + } + let route = path.split('?').next().unwrap_or(path); + let refused = || { + CandidateError::new( + "engine_network_refused", + "Containers must name an owned network or none; the engine default bridge is reserved for image builds.", + ) + }; + if route.ends_with("/containers/create") { + let mode = body + .and_then(|body| body["HostConfig"]["NetworkMode"].as_str()) + .unwrap_or(""); + if mode.is_empty() || ["default", "bridge", "host"].contains(&mode) { + return Err(refused()); + } + } + if route.ends_with("/networks/bridge/connect") { + return Err(refused()); + } + Ok(()) +} + // One minimum budget reaches dispatch. A later explicit deadline cannot replace // an earlier mutation-admission deadline; cleanup admission exemptions stay above. fn request_timeout( @@ -691,6 +726,38 @@ mod tests { use std::io::Write; use std::os::unix::net::UnixListener; + #[test] + fn containers_must_name_their_network_and_never_join_the_build_bridge() { + let create = |mode: Value| { + require_owned_network( + &Method::POST, + "/v1.53/containers/create?name=x", + Some(&serde_json::json!({"HostConfig": {"NetworkMode": mode}})), + ) + }; + for refused in [ + Value::Null, + serde_json::json!(""), + serde_json::json!("default"), + serde_json::json!("bridge"), + serde_json::json!("host"), + serde_json::json!(7), + ] { + assert_eq!(create(refused).unwrap_err().code, "engine_network_refused"); + } + for allowed in ["none", "hack-local-owned", "container:abc"] { + assert!(create(serde_json::json!(allowed)).is_ok()); + } + assert!(require_owned_network(&Method::POST, "/v1.53/containers/create", None).is_err()); + assert!( + require_owned_network(&Method::POST, "/v1.53/networks/bridge/connect", None).is_err() + ); + assert!( + require_owned_network(&Method::POST, "/v1.53/networks/owned/connect", None).is_ok() + ); + assert!(require_owned_network(&Method::GET, "/v1.53/networks/bridge", None).is_ok()); + } + #[test] fn explicit_request_budget_never_extends_mutation_admission() { let now = Instant::now(); diff --git a/packages/runtime-core/src/provider/guest-build-network.sh b/packages/runtime-core/src/provider/guest-build-network.sh new file mode 100644 index 000000000..c78c09ed7 --- /dev/null +++ b/packages/runtime-core/src/provider/guest-build-network.sh @@ -0,0 +1,33 @@ +set -eu +# Isolate the build bridge (docker0) that only BuildKit `RUN` steps use. Docker's +# own isolation chains already drop docker0 <-> owned project bridges; these rules +# also drop build traffic to the guest itself (relays, sockets, the engine), to +# the host gateway and to link-local metadata, while allowing DNS to the guest's +# configured resolvers. External reach is then only what the VM boundary allows. +# Arguments: network mode, guest boot ID. The marker binds the rules to this boot. +case "${1-}" in + isolated) exit 0 ;; + approved-hosts|internet|host-gateway) ;; + *) exit 64 ;; +esac +test -d /sys/class/net/docker0 +marker=/run/hack-local/build-network +for chain in HACK-BUILD-FORWARD HACK-BUILD-INPUT; do + iptables -N "$chain" 2>/dev/null || iptables -F "$chain" +done +for resolver in $(awk '$1 == "nameserver" { print $2 }' /etc/resolv.conf); do + case "$resolver" in *:*|127.*) continue ;; esac + for protocol in udp tcp; do + iptables -A HACK-BUILD-FORWARD -d "$resolver" -p "$protocol" --dport 53 -j RETURN + iptables -A HACK-BUILD-INPUT -d "$resolver" -p "$protocol" --dport 53 -j ACCEPT + done +done +iptables -A HACK-BUILD-FORWARD -d 100.96.0.1 -j DROP +iptables -A HACK-BUILD-FORWARD -d 169.254.0.0/16 -j DROP +iptables -A HACK-BUILD-INPUT -j DROP +iptables -C DOCKER-USER -i docker0 -j HACK-BUILD-FORWARD 2>/dev/null || + iptables -I DOCKER-USER 1 -i docker0 -j HACK-BUILD-FORWARD +iptables -C INPUT -i docker0 -j HACK-BUILD-INPUT 2>/dev/null || + iptables -I INPUT 1 -i docker0 -j HACK-BUILD-INPUT +printf 'build-network-v1 %s\n' "$2" > "$marker.pending" +mv "$marker.pending" "$marker" diff --git a/packages/runtime-core/src/provider/guest-daemon.sh b/packages/runtime-core/src/provider/guest-daemon.sh index 68fc19f53..017c004af 100644 --- a/packages/runtime-core/src/provider/guest-daemon.sh +++ b/packages/runtime-core/src/provider/guest-daemon.sh @@ -1,12 +1,17 @@ set -eu +# Egress pools give dockerd a default bridge with a fixed range outside Docker's +# default address pools. Only BuildKit `RUN` steps use it: graph containers always +# name an owned network or `none`, and the engine client refuses any other create. +# guest-build-network.sh isolates it after the engine starts. Isolated pools keep +# no default bridge, so builds refuse there. case "${1-}" in - isolated) gateway_rules=false; set -- ;; - approved-hosts|internet) gateway_rules=true; set -- ;; - host-gateway) gateway_rules=true; set -- --host-gateway-ip=100.96.0.1 ;; + isolated) gateway_rules=false; set -- --bridge=none ;; + approved-hosts|internet) gateway_rules=true; set -- --bip=10.213.255.1/24 --icc=false ;; + host-gateway) gateway_rules=true; set -- --bip=10.213.255.1/24 --icc=false --host-gateway-ip=100.96.0.1 ;; *) exit 64 ;; esac dockerd --storage-driver=overlay2 --data-root=/var/lib/docker \ - --bridge=none --iptables="$gateway_rules" --ip6tables=false \ + --iptables="$gateway_rules" --ip6tables=false \ --ip-forward="$gateway_rules" --ip-masq="$gateway_rules" "$@" \ --exec-root=/run/hack-local/exec --pidfile=/run/hack-local/docker.pid \ --host=unix:///run/hack-local/docker.sock --log-level=warn \ diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs new file mode 100644 index 000000000..ff6268d46 --- /dev/null +++ b/packages/runtime-core/src/provider/image_build.rs @@ -0,0 +1,1141 @@ +//! Native Compose `build:` images: `runtime build-image`. +//! +//! One operation builds one image for one build key inside the guest engine, through +//! the pinned host BuildKit client (`docker-buildx`, docker driver) talking to the +//! engine's verified forwarded socket. The built image lands in the guest image store, +//! where graph admission already looks, and the caller pins the service to the content +//! ID it returns, so review, receipts and restore treat it like a pulled image. +//! +//! Authority and ownership: +//! +//! - The build key binds the local inputs: namespace (project root and branch), context +//! and Dockerfile paths, the Dockerfile and `.dockerignore` bytes, target, salted +//! argument hashes, platform, and the builder and engine versions. Context file +//! contents are not hashed, as with Compose: an existing image is reused until an +//! explicit rebuild. Base-image tags resolve only when a build runs. +//! - Every image the Dockerfile can pull (see [`super::build_scan`]) resolves to a digest +//! before BuildKit runs and is handed to it as a named build context, so BuildKit pulls +//! only those exact manifests. Base images with stored `ONBUILD` triggers refuse. +//! - The provider lease is held only for short admission and adoption steps, never while +//! BuildKit runs. A shared build fence (`run/image-builds/active.lock`), taken under the +//! lease and held for the whole operation, makes `runtime down` refuse instead of +//! stopping the engine under a running build. +//! - A receipt `run/image-builds//.json` records the attempt. Only a +//! `content-id-verified` receipt from the same checkout, provider incarnation and +//! engine, whose image still inspects with exactly the owned labels, is reused. A +//! `building` receipt found under the per-key lock belongs to a dead attempt (the lock +//! is released only when its holder exits): it is recorded `indeterminate` and the +//! image is built again. Build outputs are content addressed and adoption always +//! rechecks labels and incarnation, so no operator action is needed. +//! - Argument values never appear in argv, receipts, messages or output: the client +//! receives `--build-arg NAME` and reads the value from its private environment. +//! - Nothing here removes images, prunes caches or adopts an image whose labels differ. +use super::{ + artifact, + build_scan::{self, BaseImage}, + registry_image::{self, BuildBase}, + state, +}; +use crate::{Candidate, CandidateError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::Read, + os::fd::AsRawFd, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + os::unix::process::CommandExt, + path::{Path, PathBuf}, + process::{Child, Stdio}, + sync::atomic::{AtomicBool, Ordering}, + time::{Duration, Instant}, +}; + +#[cfg(test)] +mod tests; + +/// The default bridge range guest-daemon.sh gives egress pools; only builds use it. +pub(super) const BUILD_NETWORK_SUBNET: &str = "10.213.255.0/24"; +const BUILD_NETWORK_MARKER: &str = "build-network-v1"; +/// Repository for every Hack-built image; review never accepts it as an authored image. +pub const BUILD_REPOSITORY: &str = "hack-local-build"; +const KIND: &str = "build-image"; +const DEFAULT_TIMEOUT_SECONDS: u64 = 30 * 60; +const MAX_TIMEOUT_SECONDS: u64 = 2 * 60 * 60; +const CANCEL_GRACE: Duration = Duration::from_secs(10); +const POLL: Duration = Duration::from_millis(50); +const LEASE_WAIT: Duration = Duration::from_secs(5); +const MAX_REQUEST_BYTES: u64 = 192 * 1024; +const MAX_RECEIPT_BYTES: u64 = 16 * 1024; +const MAX_METADATA_BYTES: u64 = 1024 * 1024; +const MAX_IGNORE_BYTES: u64 = 1024 * 1024; +const MAX_ARGS: usize = 64; +const MAX_ARG_BYTES: usize = 64 * 1024; +/// Labels a Dockerfile (or a base image) must not set on an image Hack runs: the same +/// reserved ownership namespaces the frontend refuses on authored services. +const RESERVED_LABEL_PREFIXES: [&str; 5] = [ + "com.docker.compose.", + "io.hack-local.", + "io.hack.native-config.", + "io.hack.native-file.", + "io.hack.storage-witness.", +]; + +fn refusal(code: &'static str, message: &str) -> CandidateError { + CandidateError::new(code, message) +} +fn invalid(message: &str) -> CandidateError { + refusal("native_build_request", message) +} +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn hex64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn image_id(value: &str) -> bool { + value.strip_prefix("sha256:").is_some_and(hex64) +} + +/// When the caller wants a fresh build. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Policy { + /// Reuse a verified image for this build key; build only when it is missing. + Missing, + /// Always build (BuildKit's cache keeps unchanged steps cheap) and re-resolve bases. + Rebuild, +} + +/// The private request file. It is read only from a singly linked 0600 file owned by +/// this user, because argument values are in it. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuildRequest { + pub version: u32, + /// Exact canonical project root; the namespace is derived from it and `branch`. + pub project_root: PathBuf, + #[serde(default)] + pub branch: Option, + /// Build context, relative to the project root. + pub context: String, + /// Dockerfile, relative to the context. + #[serde(default = "default_dockerfile")] + pub dockerfile: String, + #[serde(default)] + pub target: Option, + /// Literal build arguments. Values are secrets for every output purpose. + #[serde(default)] + pub args: BTreeMap, + pub policy: Policy, + #[serde(default)] + pub timeout_seconds: Option, +} +fn default_dockerfile() -> String { + "Dockerfile".into() +} + +/// The result of one `runtime build-image`. +#[derive(Debug, Serialize)] +pub struct BuildOutcome { + pub image_id: String, + pub build_key: String, + pub tag: String, + /// True when a verified image was reused without running the builder. + pub reused: bool, + /// Named build context to the pinned reference it resolved to, for this image. + pub base_pins: BTreeMap, +} + +/// Receipt phases. `building` with no live holder of the key lock is a dead attempt. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum Phase { + Building, + ContentIdVerified, + Failed, + Canceled, + Indeterminate, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Receipt { + version: u32, + checkout: PathBuf, + namespace: String, + build_key: String, + provider_incarnation: String, + provider_boot_id: String, + engine_id: String, + phase: Phase, + #[serde(default, skip_serializing_if = "Option::is_none")] + image_id: Option, + base_pins: BTreeMap, +} + +/// The pool identity a build is admitted against and must find unchanged at adoption. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct PoolIdentity { + pub incarnation: String, + pub boot_id: String, + pub engine_id: String, + /// Host path of the verified forwarded engine socket. + pub socket: PathBuf, +} + +/// The effects a build needs from the provider. The live implementation holds the +/// provider lease inside `Lease`; tests substitute a fake pool and a fake builder. +pub(super) trait BuildPool { + type Lease; + /// Take the provider lease, verify the running pool, its build network and engine. + fn lease(&self) -> Result<(Self::Lease, PoolIdentity), CandidateError>; + /// Inspect one image by content ID or tag; `None` when the engine reports it absent. + fn inspect(&self, lease: &Self::Lease, image: &str) -> Result, CandidateError>; + /// Resolve one base image to its pinned manifest. Network only; no lease is held. + fn resolve(&self, base: &BaseImage) -> Result; + /// The verified builder executable. + fn builder(&self) -> Result; +} + +/// Validated local inputs and the build key derived from them. +#[derive(Debug)] +pub(super) struct Inputs { + namespace: String, + key: String, + context: PathBuf, + dockerfile: PathBuf, + target: Option, + args: BTreeMap, + images: Vec, + policy: Policy, + timeout: Duration, +} + +impl Inputs { + fn tag(&self) -> String { + format!( + "{BUILD_REPOSITORY}/{}:{}", + &self.namespace[..12], + &self.key[..32] + ) + } +} + +/// The non-secret owner value an image carries: a digest of the pool incarnation, so +/// the incarnation itself never reaches the client's argv. +pub(super) fn owner_label(incarnation: &str) -> String { + sha256_hex(format!("hack-native-build-owner-v1\0{incarnation}").as_bytes())[..32].to_owned() +} + +fn labels(inputs: &Inputs, identity: &PoolIdentity) -> BTreeMap<&'static str, String> { + BTreeMap::from([ + ("io.hack-local.build-key", inputs.key.clone()), + ("io.hack-local.kind", KIND.to_owned()), + ("io.hack-local.namespace", inputs.namespace.clone()), + ("io.hack-local.owner", owner_label(&identity.incarnation)), + ]) +} + +/// The namespace builds for one project root and branch belong to. Worktrees and +/// branches sharing one pool get distinct namespaces, so images never cross them. +pub(super) fn namespace(root: &Path, branch: Option<&str>) -> String { + let mut digest = Sha256::new(); + digest.update(b"hack-native-build-namespace-v1\0"); + digest.update(root.as_os_str().as_encoded_bytes()); + digest.update(b"\0"); + digest.update(branch.unwrap_or("").as_bytes()); + format!("{:x}", digest.finalize()) +} + +/// Client environment the build must not let an argument name override, plus names +/// the client itself interprets (proxies would redirect the client's own traffic). +fn reserved_argument(name: &str) -> bool { + const EXACT: [&str; 6] = ["PATH", "HOME", "LANG", "TMPDIR", "USER", "SHELL"]; + const PREFIXES: [&str; 3] = ["DOCKER_", "BUILDX_", "BUILDKIT_"]; + let upper = name.to_ascii_uppercase(); + EXACT.contains(&upper.as_str()) + || PREFIXES.iter().any(|prefix| upper.starts_with(prefix)) + || upper.ends_with("_PROXY") + || upper.starts_with("LC_") + || upper.starts_with("GIT_") + || upper.starts_with("SSH_") +} + +fn valid_argument_name(name: &str) -> bool { + let mut bytes = name.bytes(); + name.len() <= 128 + && bytes + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_') +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.as_bytes()[0].is_ascii_alphanumeric() + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) +} + +fn valid_branch(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn validate_request(request: &BuildRequest) -> Result<(), CandidateError> { + if request.version != 1 { + return Err(invalid("Unsupported build request version.")); + } + if !request.project_root.is_absolute() { + return Err(invalid("The project root must be an absolute path.")); + } + if request.branch.as_deref().is_some_and(|b| !valid_branch(b)) { + return Err(invalid( + "The branch selector must be a lowercase DNS label.", + )); + } + if request + .target + .as_deref() + .is_some_and(|t| !valid_identifier(t)) + { + return Err(invalid("Invalid build target name.")); + } + if request.args.len() > MAX_ARGS { + return Err(refusal( + "native_build_argument", + "A native build accepts at most 64 arguments.", + )); + } + let mut total = 0_usize; + for (name, value) in &request.args { + if !valid_argument_name(name) { + return Err(refusal( + "native_build_argument", + "Build argument names must match [A-Za-z_][A-Za-z0-9_]* and be at most 128 bytes.", + )); + } + if reserved_argument(name) { + return Err(refusal( + "native_build_argument", + "Build argument names may not set the build client's own environment (PATH, HOME, DOCKER_*, BUILDX_*, BUILDKIT_*, proxies, locale, Git or SSH settings).", + )); + } + if value.bytes().any(|b| b == 0) { + return Err(refusal( + "native_build_argument", + "Build argument values may not contain NUL bytes.", + )); + } + total += name.len() + value.len(); + } + if total > MAX_ARG_BYTES { + return Err(refusal( + "native_build_argument", + "Build arguments exceed 64 KiB in total.", + )); + } + let timeout = request.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS); + if timeout == 0 || timeout > MAX_TIMEOUT_SECONDS { + return Err(invalid( + "The build timeout must be between 1 and 7200 seconds.", + )); + } + Ok(()) +} + +/// Read one optional bounded regular file without following a final symlink. +fn read_bounded_file(path: &Path, limit: u64) -> Result>, CandidateError> { + let file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => { + return Err(refusal( + "native_build_source", + "A build input is a symlink or cannot be opened.", + )); + } + }; + let metadata = file.metadata().map_err(state::io)?; + if !metadata.is_file() || metadata.nlink() != 1 || metadata.len() > limit { + return Err(refusal( + "native_build_source", + "Build inputs must be singly linked regular files within their size limits.", + )); + } + let mut bytes = Vec::new(); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(state::io)?; + if bytes.len() as u64 > limit { + return Err(refusal( + "native_build_source", + "A build input grew beyond its size limit.", + )); + } + Ok(Some(bytes)) +} + +/// The ignore files BuildKit applies to this context: a Dockerfile-specific +/// `.dockerignore` wins over the context's `.dockerignore`. +fn ignore_digests(context: &Path, dockerfile: &Path) -> Result { + let mut specific = dockerfile.as_os_str().to_owned(); + specific.push(".dockerignore"); + let digest = |path: &Path| -> Result { + Ok(read_bounded_file(path, MAX_IGNORE_BYTES)? + .map_or(Value::Null, |bytes| Value::String(sha256_hex(&bytes)))) + }; + Ok(serde_json::json!({ + "context": digest(&context.join(".dockerignore"))?, + "dockerfile": digest(Path::new(&specific))?, + })) +} + +/// Validate the request, resolve paths inside the project without following symlinks, +/// read and scan the Dockerfile and derive the build key. Pure local reads. +pub(super) fn prepare(request: BuildRequest) -> Result { + validate_request(&request)?; + let root = fs::canonicalize(&request.project_root).map_err(|_| { + refusal( + "native_build_source", + "The project root does not exist or cannot be inspected.", + ) + })?; + if root != request.project_root || !root.is_dir() { + return Err(refusal( + "native_build_source", + "The project root must be an exact canonical directory.", + )); + } + let context = crate::project::resolve_source(&root, &root, &request.context, false)?; + if !fs::symlink_metadata(&context).is_ok_and(|m| m.is_dir()) { + return Err(refusal( + "native_build_source", + "The build context must be an existing project directory.", + )); + } + let dockerfile = crate::project::resolve_source(&root, &context, &request.dockerfile, false)?; + let bytes = read_bounded_file(&dockerfile, build_scan::MAX_DOCKERFILE_BYTES as u64)? + .ok_or_else(|| { + refusal( + "native_build_source", + "The Dockerfile must be an existing regular project file.", + ) + })?; + let scan = build_scan::scan(&build_scan::ScanRequest { + dockerfile: &bytes, + target: request.target.as_deref(), + args: &request.args, + })?; + let namespace = namespace(&root, request.branch.as_deref()); + let relative = |path: &Path, base: &Path| -> String { + path.strip_prefix(base) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() + }; + let salted_args: BTreeMap<&str, String> = request + .args + .iter() + .map(|(name, value)| { + let mut digest = Sha256::new(); + digest.update(namespace.as_bytes()); + digest.update(b"\0"); + digest.update(name.as_bytes()); + digest.update(b"\0"); + digest.update(value.as_bytes()); + (name.as_str(), format!("{:x}", digest.finalize())) + }) + .collect(); + let key_input = serde_json::json!({ + "v": 1, + "namespace": namespace, + "context": relative(&context, &root), + "dockerfile": relative(&dockerfile, &context), + "dockerfile_sha256": sha256_hex(&bytes), + "dockerignore": ignore_digests(&context, &dockerfile)?, + "target": request.target, + "args": salted_args, + "platform": "linux/arm64", + "builder": {"buildx": artifact::BUILDER_VERSION, "engine": artifact::ENGINE_VERSION}, + }); + let key = sha256_hex( + &serde_json::to_vec(&key_input).map_err(|_| invalid("Cannot encode the build key."))?, + ); + Ok(Inputs { + namespace, + key, + context, + dockerfile, + target: request.target, + args: request.args, + images: scan.images, + policy: request.policy, + timeout: Duration::from_secs(request.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS)), + }) +} + +fn builds_root(candidate: &Candidate) -> PathBuf { + candidate.state_root.join("run/image-builds") +} + +/// A cooperative `flock` on a private lock file, shared or exclusive, never blocking. +struct Flock(File); +impl Flock { + fn open(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(state::io)?; + let metadata = file.metadata().map_err(state::io)?; + // SAFETY: geteuid has no preconditions. + if !metadata.is_file() + || metadata.nlink() != 1 + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o077 != 0 + { + return Err(refusal("foreign_state", "Unsafe image build lock.")); + } + Ok(file) + } + fn try_lock(file: File, operation: libc::c_int) -> Result, CandidateError> { + // SAFETY: `file` is a live owned descriptor; flock retains no pointers. + if unsafe { libc::flock(file.as_raw_fd(), operation | libc::LOCK_NB) } == 0 { + return Ok(Some(Self(file))); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EWOULDBLOCK) { + Ok(None) + } else { + Err(state::io(error)) + } + } +} +impl Drop for Flock { + fn drop(&mut self) { + // SAFETY: the descriptor is live until this value drops. + unsafe { + libc::flock(self.0.as_raw_fd(), libc::LOCK_UN); + } + } +} + +/// Refuse to stop the engine while a build operation is admitted. The caller holds the +/// provider lease: a build takes its shared fence only under that lease, so a build +/// cannot start between this check and the caller's effect. +pub(super) fn require_no_active_build(candidate: &Candidate) -> Result<(), CandidateError> { + let path = builds_root(candidate).join("active.lock"); + if !path.try_exists().map_err(state::io)? { + return Ok(()); + } + match Flock::try_lock(Flock::open(&path)?, libc::LOCK_EX)? { + Some(_probe) => Ok(()), + None => Err(refusal( + "provider_busy", + "A native image build is running; let it finish or cancel it, then stop the runtime.", + )), + } +} + +fn read_receipt(path: &Path) -> Result, CandidateError> { + if !path.try_exists().map_err(state::io)? { + return Ok(None); + } + state::read_bounded(path, MAX_RECEIPT_BYTES).map(Some) +} + +fn label_violation(config_labels: &Value, expected: &BTreeMap<&'static str, String>) -> bool { + let labels = match config_labels { + Value::Null => return true, + Value::Object(labels) => labels, + _ => return true, + }; + for (key, value) in expected { + if labels.get(*key).and_then(Value::as_str) != Some(value.as_str()) { + return true; + } + } + labels.keys().any(|key| { + RESERVED_LABEL_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + && !expected.contains_key(key.as_str()) + }) +} + +/// Whether an inspected image is exactly the one this build key owns. +fn owned_image( + inspected: &Value, + id: &str, + expected: &BTreeMap<&'static str, String>, +) -> Result<(), CandidateError> { + if inspected["Id"] != id || inspected["Architecture"] != "arm64" || inspected["Os"] != "linux" { + return Err(refusal( + "native_build_image", + "The built image identity or platform differs from the build result.", + )); + } + if label_violation(&inspected["Config"]["Labels"], expected) { + return Err(refusal( + "native_build_reserved_label", + "The built image lacks its ownership labels or carries other reserved ownership labels (com.docker.compose.*, io.hack-local.*, io.hack.native-config.*, io.hack.native-file.*, io.hack.storage-witness.*).", + )); + } + match &inspected["Config"]["Volumes"] { + Value::Null => Ok(()), + Value::Object(volumes) if volumes.is_empty() => Ok(()), + _ => Err(refusal( + "native_build_image_volumes", + "The built image declares VOLUME paths; native services refuse implicit image volumes.", + )), + } +} + +/// Run one build operation end to end. See the module documentation for the protocol. +/// `canceled` reports a pending cancellation request; it is checked before each +/// unleased step and while the client runs. +pub(super) fn run( + candidate: &Candidate, + pool: &P, + inputs: &Inputs, + canceled: &dyn Fn() -> bool, +) -> Result { + let canceled_early = || { + refusal( + "native_build_canceled", + "The image build was canceled before the build client started; no image was adopted.", + ) + }; + let root = builds_root(candidate); + state::private_directory(&root)?; + let directory = root.join(&inputs.namespace); + let receipt_path = directory.join(format!("{}.json", inputs.key)); + // Step 1, under the lease: fence, key lock and the reuse decision. + let (lease, identity) = pool.lease()?; + let _fence = Flock::try_lock(Flock::open(&root.join("active.lock"))?, libc::LOCK_SH)? + .ok_or_else(|| { + refusal( + "provider_busy", + "The runtime is stopping; no build was admitted.", + ) + })?; + state::private_directory(&directory)?; + let _key_lock = Flock::try_lock( + Flock::open(&directory.join(format!("{}.lock", inputs.key)))?, + libc::LOCK_EX, + )? + .ok_or_else(|| { + refusal( + "native_build_busy", + "Another operation is building this image; wait for it and retry.", + ) + })?; + let expected = labels(inputs, &identity); + let previous = read_receipt(&receipt_path)?; + if let Some(previous) = &previous { + if previous.version != 1 + || previous.namespace != inputs.namespace + || previous.build_key != inputs.key + { + return Err(refusal( + "native_build_receipt", + "A build receipt does not match its path; no image was adopted or replaced.", + )); + } + if inputs.policy == Policy::Missing + && previous.phase == Phase::ContentIdVerified + && previous.checkout == candidate.checkout + && previous.provider_incarnation == identity.incarnation + && previous.engine_id == identity.engine_id + { + if let Some(id) = previous.image_id.as_deref().filter(|id| image_id(id)) { + if let Some(inspected) = pool.inspect(&lease, id)? { + if owned_image(&inspected, id, &expected).is_ok() { + if previous.provider_boot_id != identity.boot_id { + let mut current = previous.clone(); + current.provider_boot_id = identity.boot_id.clone(); + state::write(&receipt_path, ¤t)?; + } + return Ok(BuildOutcome { + image_id: id.to_owned(), + build_key: inputs.key.clone(), + tag: inputs.tag(), + reused: true, + base_pins: previous.base_pins.clone(), + }); + } + } + } + } + if previous.phase == Phase::Building { + // The key lock proves no live holder: that attempt died mid-build. + let mut dead = previous.clone(); + dead.phase = Phase::Indeterminate; + state::write(&receipt_path, &dead)?; + } + } + drop(lease); + + // Step 2, unleased: resolve every base image to a pinned manifest. + let mut pins = BTreeMap::new(); + for base in &inputs.images { + if canceled() { + return Err(canceled_early()); + } + let resolved = pool.resolve(base)?; + if !image_id(&resolved.source_digest) + || !resolved + .pinned_reference + .ends_with(&format!("@{}", resolved.source_digest)) + { + return Err(refusal( + "native_build_base_image", + "A build base image did not resolve to a pinned manifest.", + )); + } + pins.insert(base.context_name.clone(), resolved.pinned_reference); + } + let builder = pool.builder()?; + if canceled() { + return Err(canceled_early()); + } + + // Step 3, under the lease: same pool, then record the attempt. + let (lease, current) = pool.lease()?; + if current != identity { + return Err(refusal( + "runtime_changed", + "The runtime changed before the build started; no build was run.", + )); + } + let mut receipt = Receipt { + version: 1, + checkout: candidate.checkout.clone(), + namespace: inputs.namespace.clone(), + build_key: inputs.key.clone(), + provider_incarnation: identity.incarnation.clone(), + provider_boot_id: identity.boot_id.clone(), + engine_id: identity.engine_id.clone(), + phase: Phase::Building, + image_id: None, + base_pins: pins.clone(), + }; + state::write(&receipt_path, &receipt)?; + drop(lease); + + // Step 4, unleased: run the client in its own process group. + let scratch = directory.join(format!("{}.client", inputs.key)); + state::private_directory(&scratch)?; + let metadata = scratch.join("metadata.json"); + match fs::remove_file(&metadata) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(state::io(error)), + } + let ended = supervise( + command( + &builder, &scratch, &metadata, inputs, &identity, &pins, &expected, + )?, + inputs.timeout, + canceled, + ); + let ended = match ended { + Ok(ended) => ended, + Err(error) => { + receipt.phase = Phase::Indeterminate; + state::write(&receipt_path, &receipt)?; + return Err(error); + } + }; + match ended { + Ended::Exited(true) => {} + Ended::Exited(false) => { + receipt.phase = Phase::Failed; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "native_build_failed", + "The image build failed; its output is shown above. No image was adopted.", + )); + } + Ended::Canceled => { + receipt.phase = Phase::Canceled; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "native_build_canceled", + "The image build was canceled; no image was adopted.", + )); + } + Ended::TimedOut => { + receipt.phase = Phase::Canceled; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "native_build_timeout", + "The image build exceeded its time budget and was stopped; no image was adopted.", + )); + } + } + + // Step 5, under the lease: the same pool and engine, then adopt by content ID. + let (lease, current) = pool.lease()?; + if current != identity { + receipt.phase = Phase::Indeterminate; + state::write(&receipt_path, &receipt)?; + return Err(refusal( + "runtime_changed", + "The runtime changed during the build; the result was not adopted.", + )); + } + let adopted = built_image_id(&metadata).and_then(|id| { + let inspected = pool.inspect(&lease, &id)?.ok_or_else(|| { + refusal( + "native_build_image", + "The built image is absent from the engine.", + ) + })?; + owned_image(&inspected, &id, &expected)?; + Ok(id) + }); + let id = match adopted { + Ok(id) => id, + Err(error) => { + // A refused or missing result is a failed build; an engine or transport + // error leaves the outcome unknown. + receipt.phase = if error.code.starts_with("native_build_") { + Phase::Failed + } else { + Phase::Indeterminate + }; + state::write(&receipt_path, &receipt)?; + return Err(error); + } + }; + receipt.phase = Phase::ContentIdVerified; + receipt.image_id = Some(id.clone()); + state::write(&receipt_path, &receipt)?; + drop(lease); + Ok(BuildOutcome { + image_id: id, + build_key: inputs.key.clone(), + tag: inputs.tag(), + reused: false, + base_pins: pins, + }) +} + +/// The image config digest the client reports for the built image. +fn built_image_id(metadata: &Path) -> Result { + let bytes = read_bounded_file(metadata, MAX_METADATA_BYTES)?.ok_or_else(|| { + refusal( + "native_build_image", + "The build client reported no image metadata.", + ) + })?; + let value: Value = serde_json::from_slice(&bytes).map_err(|_| { + refusal( + "native_build_image", + "The build client wrote malformed image metadata.", + ) + })?; + let id = value["containerimage.config.digest"] + .as_str() + .filter(|id| image_id(id)) + .ok_or_else(|| { + refusal( + "native_build_image", + "The build metadata has no image content ID.", + ) + })?; + Ok(id.to_owned()) +} + +/// The client command: argv carries only paths, names and non-secret labels; argument +/// values reach the client through its environment. +fn command( + builder: &Path, + scratch: &Path, + metadata: &Path, + inputs: &Inputs, + identity: &PoolIdentity, + pins: &BTreeMap, + labels: &BTreeMap<&'static str, String>, +) -> Result { + let config = scratch.join("docker-config"); + let buildx = scratch.join("buildx"); + state::private_directory(&config)?; + state::private_directory(&buildx)?; + let mut command = super::process::clean_command(builder); + command + .current_dir(scratch) + .env("HOME", scratch) + .env("TMPDIR", scratch) + .env( + "DOCKER_HOST", + format!("unix://{}", identity.socket.display()), + ) + // An empty private client configuration: no contexts, credential helpers or + // proxies from the user's Docker setup. + .env("DOCKER_CONFIG", &config) + .env("BUILDX_CONFIG", &buildx) + .env("BUILDX_NO_DEFAULT_ATTESTATIONS", "1") + .env("DOCKER_CLI_HINTS", "false") + .args(["build", "--progress=plain", "--platform", "linux/arm64"]) + .args(["--provenance=false", "--sbom=false", "--load"]) + .arg("--file") + .arg(&inputs.dockerfile) + .arg("--metadata-file") + .arg(metadata) + .args(["--tag", &inputs.tag()]); + if let Some(target) = &inputs.target { + command.args(["--target", target]); + } + for (name, value) in &inputs.args { + command.args(["--build-arg", name]).env(name, value); + } + for (name, reference) in pins { + command + .arg("--build-context") + .arg(format!("{name}=docker-image://{reference}")); + } + for (name, value) in labels { + command.arg("--label").arg(format!("{name}={value}")); + } + command + .arg(&inputs.context) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .process_group(0); + Ok(command) +} + +#[derive(Debug, Eq, PartialEq)] +enum Ended { + Exited(bool), + Canceled, + TimedOut, +} + +static CANCEL_REQUESTED: AtomicBool = AtomicBool::new(false); +extern "C" fn request_cancel(_: libc::c_int) { + CANCEL_REQUESTED.store(true, Ordering::Release); +} + +/// SIGINT, SIGTERM and SIGHUP request cancellation for the whole operation; the +/// previous dispositions return when this guard drops. +struct CancelSignals(Vec<(libc::c_int, libc::sigaction)>); +impl CancelSignals { + fn install() -> Result { + CANCEL_REQUESTED.store(false, Ordering::Release); + let mut previous_actions = Vec::new(); + for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] { + // SAFETY: sigaction structs are plain C storage, zero-initialized before use. + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + // SAFETY: as above; the kernel fills it with the previous disposition. + let mut previous: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = request_cancel as *const () as usize; + // SAFETY: valid writable mask storage. + unsafe { libc::sigemptyset(&mut action.sa_mask) }; + // SAFETY: both pointers reference live storage for the duration of the call. + if unsafe { libc::sigaction(signal, &action, &mut previous) } != 0 { + return Err(state::io(std::io::Error::last_os_error())); + } + previous_actions.push((signal, previous)); + } + Ok(Self(previous_actions)) + } +} +impl Drop for CancelSignals { + fn drop(&mut self) { + for (signal, previous) in &self.0 { + // SAFETY: restores a disposition the kernel returned for this signal. + unsafe { + libc::sigaction(*signal, previous, std::ptr::null_mut()); + } + } + } +} + +/// Spawn the client and wait for it, honoring cancellation and the time budget. On +/// either, the whole process group gets SIGTERM, then SIGKILL after a grace period. +/// The group is signaled only while its leader is unreaped, so its ID is still ours. +fn supervise( + mut command: std::process::Command, + timeout: Duration, + canceled: &dyn Fn() -> bool, +) -> Result { + let mut child = command.spawn().map_err(|_| { + refusal( + "native_build_client", + "The pinned build client could not be started.", + ) + })?; + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child.try_wait().map_err(state::io)? { + return Ok(Ended::Exited(status.success())); + } + let cancel = canceled(); + if cancel || Instant::now() >= deadline { + stop_group(&mut child)?; + return Ok(if cancel { + Ended::Canceled + } else { + Ended::TimedOut + }); + } + std::thread::sleep(POLL); + } +} + +fn stop_group(child: &mut Child) -> Result<(), CandidateError> { + let group = child.id() as libc::pid_t; + // SAFETY: the leader is unreaped, so this process group ID cannot be reused yet. + unsafe { libc::killpg(group, libc::SIGTERM) }; + let grace = Instant::now() + CANCEL_GRACE; + while Instant::now() < grace { + if child.try_wait().map_err(state::io)?.is_some() { + return Ok(()); + } + std::thread::sleep(POLL); + } + // SAFETY: still unreaped (checked above), so the group ID is still ours. + unsafe { libc::killpg(group, libc::SIGKILL) }; + child.wait().map_err(state::io)?; + Ok(()) +} + +/// The live pool: the candidate's running provider and its pinned builder. +struct LivePool<'a> { + candidate: &'a Candidate, +} + +#[cfg(target_os = "macos")] +fn connect(candidate: &Candidate) -> Result, CandidateError> { + super::engine::Engine::connect_until(candidate, Instant::now() + LEASE_WAIT, || false) +} +#[cfg(not(target_os = "macos"))] +fn connect(candidate: &Candidate) -> Result, CandidateError> { + let _ = LEASE_WAIT; + super::engine::Engine::connect(candidate) +} + +/// Require the isolated build bridge this boot set up (guest-daemon.sh and +/// guest-build-network.sh). A pool started by an older Hack has neither; restarting the +/// runtime is the only remedy, and nothing here restarts the engine. +fn verify_build_network(engine: &super::engine::Engine<'_>) -> Result<(), CandidateError> { + if matches!( + engine.guest().network_intent(), + super::NetworkIntent::Isolated + ) { + return Err(refusal( + "native_build_isolated", + "Native image builds need network egress; this runtime was started isolated.", + )); + } + let restart = || { + refusal( + "native_build_runtime_restart", + "This native runtime has no isolated build network; stop the native runtime and run `hack up` again.", + ) + }; + let bridge = match engine.request(reqwest::Method::GET, "/v1.53/networks/bridge", None) { + Ok(bridge) => bridge, + Err(error) if error.code == "engine_not_found" => return Err(restart()), + Err(error) => return Err(error), + }; + if bridge["IPAM"]["Config"][0]["Subnet"] != BUILD_NETWORK_SUBNET + || bridge["Options"]["com.docker.network.bridge.enable_icc"] != "false" + { + return Err(restart()); + } + let boot = engine.guest().boot_id().to_owned(); + let observed = engine + .guest() + .execute( + &format!( + "test \"$(cat /run/hack-local/build-network)\" = \"{BUILD_NETWORK_MARKER} $1\"\n\ + iptables -C DOCKER-USER -i docker0 -j HACK-BUILD-FORWARD\n\ + iptables -C INPUT -i docker0 -j HACK-BUILD-INPUT\n\ + printf 'build-network-ready\\n'" + ), + &[&boot], + None, + ) + .map_err(|_| restart())?; + if observed != "build-network-ready\n" { + return Err(restart()); + } + Ok(()) +} + +impl<'a> BuildPool for LivePool<'a> { + type Lease = super::engine::Engine<'a>; + fn lease(&self) -> Result<(Self::Lease, PoolIdentity), CandidateError> { + let engine = connect(self.candidate)?; + verify_build_network(&engine)?; + let info = engine.request(reqwest::Method::GET, "/v1.53/info", None)?; + let engine_id = info["ID"] + .as_str() + .filter(|id| { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_graphic()) + }) + .ok_or_else(|| refusal("engine_protocol", "The engine reported no usable identity."))? + .to_owned(); + let identity = PoolIdentity { + incarnation: engine.guest().incarnation().to_owned(), + boot_id: engine.guest().boot_id().to_owned(), + engine_id, + socket: engine.guest().engine_socket()?, + }; + Ok((engine, identity)) + } + fn inspect(&self, lease: &Self::Lease, image: &str) -> Result, CandidateError> { + match lease.request( + reqwest::Method::GET, + &format!("/v1.53/images/{image}/json"), + None, + ) { + Ok(value) => Ok(Some(value)), + Err(error) if error.code == "engine_not_found" => Ok(None), + Err(error) => Err(error), + } + } + fn resolve(&self, base: &BaseImage) -> Result { + registry_image::resolve_build_base(&base.reference) + } + fn builder(&self) -> Result { + artifact::verify_builder(self.candidate)?; + Ok(artifact::builder_executable(self.candidate)) + } +} + +/// `runtime build-image --request `: build or reuse one native image. +pub fn build_image(candidate: &Candidate, request: &Path) -> Result { + let request: BuildRequest = state::read_bounded(request, MAX_REQUEST_BYTES)?; + let inputs = prepare(request)?; + let _signals = CancelSignals::install()?; + run(candidate, &LivePool { candidate }, &inputs, &|| { + CANCEL_REQUESTED.load(Ordering::Acquire) + }) +} diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs new file mode 100644 index 000000000..4b209401b --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -0,0 +1,744 @@ +//! `runtime build-image` against a fake pool and a fake build client. +//! +//! The fake client is `/bin/sh` reading a script named `build` from the client's private +//! working directory, so no test writes and then executes a fresh binary. It records its +//! argv, the one build-argument variable it was given, and a call count. +use super::*; +use std::{cell::RefCell, os::unix::fs::PermissionsExt}; + +const IMAGE: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PIN: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const SECRET: &str = "literal-value-that-must-not-leak"; + +struct Fixture { + root: PathBuf, + candidate: Candidate, + project: PathBuf, +} +impl Fixture { + fn new() -> Self { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let root = crate::test_root::private_test_root(&tmp, "hack-image-build"); + let checkout = root.join("checkout"); + state::private_directory(&checkout).unwrap(); + let candidate = Candidate::discover(&checkout).unwrap(); + let project = root.join("project"); + fs::create_dir(&project).unwrap(); + fs::create_dir(project.join("app")).unwrap(); + fs::write( + project.join("app/Dockerfile"), + "ARG BASE=alpine:3.20\nFROM ${BASE}\nARG GREETING\nRUN echo hi\n", + ) + .unwrap(); + fs::write(project.join("app/main.txt"), "one").unwrap(); + Self { + root, + candidate, + project, + } + } + fn request(&self, policy: Policy) -> BuildRequest { + BuildRequest { + version: 1, + project_root: self.project.clone(), + branch: None, + context: "app".into(), + dockerfile: "Dockerfile".into(), + target: None, + args: BTreeMap::from([("GREETING".to_owned(), SECRET.to_owned())]), + policy, + timeout_seconds: Some(30), + } + } + fn inputs(&self, policy: Policy) -> Inputs { + prepare(self.request(policy)).unwrap() + } + fn scratch(&self, inputs: &Inputs) -> PathBuf { + builds_root(&self.candidate) + .join(&inputs.namespace) + .join(format!("{}.client", inputs.key)) + } + fn receipt(&self, inputs: &Inputs) -> Receipt { + state::read( + &builds_root(&self.candidate) + .join(&inputs.namespace) + .join(format!("{}.json", inputs.key)), + ) + .unwrap() + } + /// Install the fake client for this build key. `mode` selects its behavior. + fn client(&self, inputs: &Inputs, mode: &str) { + let scratch = self.scratch(inputs); + state::private_directory(&scratch).unwrap(); + let script = format!( + r#"set -eu +count=$(cat calls 2>/dev/null || echo 0) +echo $((count + 1)) > calls +printf '%s\n' "$@" > argv +printf '%s' "${{GREETING-}}" > greeting +meta= +previous= +for argument do + if [ "$previous" = --metadata-file ]; then meta=$argument; fi + previous=$argument +done +case {mode} in + ok) printf '{{"containerimage.config.digest":"{IMAGE}"}}' > "$meta" ;; + fail) exit 3 ;; + nometa) ;; + sleep) sleep 30 & echo $! > child; wait ;; +esac +"# + ); + fs::write(scratch.join("build"), script).unwrap(); + fs::set_permissions(scratch.join("build"), fs::Permissions::from_mode(0o600)).unwrap(); + } + fn calls(&self, inputs: &Inputs) -> u32 { + fs::read_to_string(self.scratch(inputs).join("calls")) + .map(|v| v.trim().parse().unwrap()) + .unwrap_or(0) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn identity(incarnation: &str) -> PoolIdentity { + PoolIdentity { + incarnation: incarnation.into(), + boot_id: "boot-1".into(), + engine_id: "engine-1".into(), + socket: PathBuf::from("/nonexistent/docker.sock"), + } +} + +/// A fake pool: identities returned per lease in order (the last repeats), images by ID. +struct FakePool { + identities: RefCell>, + images: RefCell>, + leases: RefCell, + resolve_error: bool, +} +impl FakePool { + fn new(incarnation: &str) -> Self { + Self { + identities: RefCell::new(vec![identity(incarnation)]), + images: RefCell::new(BTreeMap::new()), + leases: RefCell::new(0), + resolve_error: false, + } + } + /// Register the image the fake client "builds", with the labels the build expects. + fn built(&self, inputs: &Inputs, incarnation: &str, extra: Value) { + let mut labels = serde_json::to_value(labels(inputs, &identity(incarnation))).unwrap(); + if let Value::Object(more) = extra { + labels.as_object_mut().unwrap().extend(more); + } + self.images.borrow_mut().insert( + IMAGE.into(), + serde_json::json!({"Id": IMAGE, "Architecture": "arm64", "Os": "linux", + "Config": {"Labels": labels, "Volumes": null}}), + ); + } +} +impl BuildPool for FakePool { + type Lease = (); + fn lease(&self) -> Result<((), PoolIdentity), CandidateError> { + *self.leases.borrow_mut() += 1; + let mut identities = self.identities.borrow_mut(); + let current = if identities.len() > 1 { + identities.remove(0) + } else { + identities[0].clone() + }; + Ok(((), current)) + } + fn inspect(&self, _: &(), image: &str) -> Result, CandidateError> { + Ok(self.images.borrow().get(image).cloned()) + } + fn resolve(&self, base: &BaseImage) -> Result { + if self.resolve_error { + return Err(CandidateError::new("registry_image", "offline")); + } + Ok(BuildBase { + pinned_reference: format!("docker.io/{}@{PIN}", base.repository()), + source_digest: PIN.into(), + image_id: IMAGE.into(), + }) + } + fn builder(&self) -> Result { + Ok(PathBuf::from("/bin/sh")) + } +} + +fn never() -> bool { + false +} + +#[test] +fn cold_build_passes_values_only_through_the_environment_and_records_the_result() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + let outcome = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert_eq!(outcome.image_id, IMAGE); + assert!(!outcome.reused); + assert_eq!( + outcome.base_pins, + BTreeMap::from([( + "alpine:3.20".to_owned(), + format!("docker.io/library/alpine@{PIN}") + )]) + ); + let scratch = fixture.scratch(&inputs); + let argv = fs::read_to_string(scratch.join("argv")).unwrap(); + assert!(!argv.contains(SECRET), "argument value reached argv"); + let lines: Vec<&str> = argv.lines().collect(); + assert!(lines.windows(2).any(|w| w == ["--build-arg", "GREETING"])); + assert!(lines.windows(2).any(|w| w + == [ + "--build-context", + &format!("alpine:3.20=docker-image://docker.io/library/alpine@{PIN}") + ])); + assert!(lines.windows(2).any(|w| w == ["--platform", "linux/arm64"])); + assert!(lines.contains(&"--provenance=false")); + assert!( + lines + .windows(2) + .any(|w| w == ["--label", "io.hack-local.kind=build-image"]) + ); + assert!( + !argv.contains("pool-1"), + "the pool incarnation reached argv" + ); + assert_eq!( + fs::read_to_string(scratch.join("greeting")).unwrap(), + SECRET + ); + let receipt = fixture.receipt(&inputs); + assert_eq!(receipt.phase, Phase::ContentIdVerified); + assert_eq!(receipt.image_id.as_deref(), Some(IMAGE)); + let text = fs::read_to_string( + builds_root(&fixture.candidate) + .join(&inputs.namespace) + .join(format!("{}.json", inputs.key)), + ) + .unwrap(); + assert!(!text.contains(SECRET), "argument value reached the receipt"); +} + +#[test] +fn verified_image_is_reused_without_running_the_client() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let again = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert!(again.reused); + assert_eq!(again.image_id, IMAGE); + assert_eq!(fixture.calls(&inputs), 1); + // An explicit rebuild runs the client again. + let rebuild = fixture.inputs(Policy::Rebuild); + let rebuilt = run(&fixture.candidate, &pool, &rebuild, &never).unwrap(); + assert!(!rebuilt.reused); + assert_eq!(fixture.calls(&inputs), 2); +} + +#[test] +fn a_new_incarnation_or_a_missing_or_relabeled_image_is_built_again() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + + // Storage recreated: the old receipt is never trusted. + let recreated = FakePool::new("pool-2"); + recreated.built(&inputs, "pool-2", serde_json::json!({})); + assert!( + !run(&fixture.candidate, &recreated, &inputs, &never) + .unwrap() + .reused + ); + assert_eq!(fixture.calls(&inputs), 2); + + // Image removed from the engine. + recreated.images.borrow_mut().clear(); + let error = run(&fixture.candidate, &recreated, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_image"); + assert_eq!(fixture.calls(&inputs), 3); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Failed); + + // Same ID with labels that are not ours is never adopted. + recreated.built(&inputs, "pool-1", serde_json::json!({})); + let error = run(&fixture.candidate, &recreated, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_reserved_label"); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Failed); +} + +#[test] +fn reserved_labels_and_image_volumes_refuse_adoption() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built( + &inputs, + "pool-1", + serde_json::json!({"io.hack-local.graph": "forged"}), + ); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_reserved_label"); + assert!(!error.message.contains("forged")); + + pool.built( + &inputs, + "pool-1", + serde_json::json!({"com.docker.compose.project": "x"}), + ); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_reserved_label" + ); + + pool.built(&inputs, "pool-1", serde_json::json!({"org.example": "ok"})); + pool.images.borrow_mut().get_mut(IMAGE).unwrap()["Config"]["Volumes"] = + serde_json::json!({"/data": {}}); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_image_volumes" + ); + + pool.images.borrow_mut().get_mut(IMAGE).unwrap()["Config"]["Volumes"] = Value::Null; + pool.images.borrow_mut().get_mut(IMAGE).unwrap()["Architecture"] = "amd64".into(); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_image" + ); +} + +#[test] +fn client_failure_and_missing_metadata_adopt_nothing() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + fixture.client(&inputs, "fail"); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_failed"); + let receipt = fixture.receipt(&inputs); + assert_eq!(receipt.phase, Phase::Failed); + assert!(receipt.image_id.is_none()); + + fixture.client(&inputs, "nometa"); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_image"); +} + +fn process_alive(pid: &str) -> bool { + let pid: libc::pid_t = pid.trim().parse().unwrap(); + // SAFETY: signal 0 only probes existence. + unsafe { libc::kill(pid, 0) == 0 } +} + +#[test] +fn timeout_and_cancellation_stop_the_whole_client_group() { + let fixture = Fixture::new(); + let mut request = fixture.request(Policy::Missing); + request.timeout_seconds = Some(1); + let inputs = prepare(request).unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "sleep"); + let started = Instant::now(); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_timeout"); + assert!(started.elapsed() < Duration::from_secs(20)); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Canceled); + let child = fs::read_to_string(fixture.scratch(&inputs).join("child")).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while process_alive(&child) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!process_alive(&child), "a client group member survived"); + + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "sleep"); + fs::remove_file(fixture.scratch(&inputs).join("child")).unwrap(); + let scratch = fixture.scratch(&inputs); + let cancel = move || scratch.join("child").exists(); + let error = run(&fixture.candidate, &pool, &inputs, &cancel).unwrap_err(); + assert_eq!(error.code, "native_build_canceled"); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Canceled); + let child = fs::read_to_string(fixture.scratch(&inputs).join("child")).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while process_alive(&child) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!process_alive(&child)); + + // A request canceled before the client starts never runs it. + let error = run(&fixture.candidate, &pool, &inputs, &|| true).unwrap_err(); + assert_eq!(error.code, "native_build_canceled"); +} + +#[test] +fn a_dead_attempt_is_recorded_indeterminate_and_built_again() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + fixture.client(&inputs, "ok"); + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let path = directory.join(format!("{}.json", inputs.key)); + let leftover = Receipt { + version: 1, + checkout: fixture.candidate.checkout.clone(), + namespace: inputs.namespace.clone(), + build_key: inputs.key.clone(), + provider_incarnation: "pool-1".into(), + provider_boot_id: "boot-1".into(), + engine_id: "engine-1".into(), + phase: Phase::Building, + image_id: None, + base_pins: BTreeMap::new(), + }; + state::write(&path, &leftover).unwrap(); + // Even with an adoptable image present, the dead attempt's receipt is not trusted. + let outcome = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert!(!outcome.reused); + assert_eq!(fixture.receipt(&inputs).phase, Phase::ContentIdVerified); + + // A receipt that names another key is never adopted or replaced. + let mut foreign = leftover; + foreign.build_key = "0".repeat(64); + fs::remove_file(&path).unwrap(); + state::write(&path, &foreign).unwrap(); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_receipt" + ); +} + +#[test] +fn the_pool_must_be_unchanged_between_admission_and_adoption() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + let mut rebooted = identity("pool-1"); + rebooted.boot_id = "boot-2".into(); + *pool.identities.borrow_mut() = vec![identity("pool-1"), identity("pool-1"), rebooted]; + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "runtime_changed"); + assert_eq!(fixture.receipt(&inputs).phase, Phase::Indeterminate); + + // A change before the client starts runs nothing. + let pool = FakePool::new("pool-1"); + *pool.identities.borrow_mut() = vec![identity("pool-1"), identity("pool-9")]; + let calls = fixture.calls(&inputs); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "runtime_changed" + ); + assert_eq!(fixture.calls(&inputs), calls); +} + +#[test] +fn the_key_lock_and_the_down_fence_refuse_instead_of_waiting() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let held = Flock::try_lock( + Flock::open(&directory.join(format!("{}.lock", inputs.key))).unwrap(), + libc::LOCK_EX, + ) + .unwrap() + .unwrap(); + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "native_build_busy" + ); + drop(held); + + assert!(require_no_active_build(&fixture.candidate).is_ok()); + let build = Flock::try_lock( + Flock::open(&builds_root(&fixture.candidate).join("active.lock")).unwrap(), + libc::LOCK_SH, + ) + .unwrap() + .unwrap(); + assert_eq!( + require_no_active_build(&fixture.candidate) + .unwrap_err() + .code, + "provider_busy" + ); + drop(build); + assert!(require_no_active_build(&fixture.candidate).is_ok()); +} + +#[test] +fn base_resolution_failures_run_nothing() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let mut pool = FakePool::new("pool-1"); + pool.resolve_error = true; + assert_eq!( + run(&fixture.candidate, &pool, &inputs, &never) + .unwrap_err() + .code, + "registry_image" + ); + assert_eq!(fixture.calls(&inputs), 0); +} + +fn code(request: BuildRequest) -> &'static str { + prepare(request).unwrap_err().code +} + +#[test] +fn requests_refuse_unsafe_paths_arguments_and_bounds() { + let fixture = Fixture::new(); + let base = || fixture.request(Policy::Missing); + + let mut request = base(); + request.version = 2; + assert_eq!(code(request), "native_build_request"); + let mut request = base(); + request.project_root = PathBuf::from("relative"); + assert_eq!(code(request), "native_build_request"); + let mut request = base(); + request.project_root = fixture.project.join("app/.."); + assert_eq!(code(request), "native_build_source"); + let mut request = base(); + request.context = "../outside".into(); + assert_eq!(code(request), "source_escape"); + let mut request = base(); + request.dockerfile = "../../Dockerfile".into(); + assert_eq!(code(request), "source_escape"); + std::os::unix::fs::symlink(fixture.project.join("app"), fixture.project.join("link")).unwrap(); + let mut request = base(); + request.context = "link".into(); + assert_eq!(code(request), "source_symlink"); + let mut request = base(); + request.context = "missing".into(); + assert_eq!(code(request), "missing_source"); + let mut request = base(); + request.branch = Some("Not_A_Label".into()); + assert_eq!(code(request), "native_build_request"); + let mut request = base(); + request.target = Some("-bad".into()); + assert_eq!(code(request), "native_build_request"); + for timeout in [0, MAX_TIMEOUT_SECONDS + 1] { + let mut request = base(); + request.timeout_seconds = Some(timeout); + assert_eq!(code(request), "native_build_request"); + } + for name in [ + "PATH", + "home", + "DOCKER_HOST", + "BUILDX_CONFIG", + "BUILDKIT_SYNTAX", + "HTTPS_PROXY", + "no_proxy", + "LC_ALL", + "GIT_DIR", + "SSH_AUTH_SOCK", + "1BAD", + "bad-name", + "", + ] { + let mut request = base(); + request.args = BTreeMap::from([(name.to_owned(), "x".to_owned())]); + assert_eq!(code(request), "native_build_argument", "{name}"); + } + let mut request = base(); + request.args = BTreeMap::from([("GREETING".to_owned(), "a\0b".to_owned())]); + assert_eq!(code(request), "native_build_argument"); + let mut request = base(); + request.args = (0..=MAX_ARGS) + .map(|i| (format!("A{i}"), "x".into())) + .collect(); + assert_eq!(code(request), "native_build_argument"); + let mut request = base(); + request.args = BTreeMap::from([("BIG".to_owned(), "x".repeat(MAX_ARG_BYTES))]); + assert_eq!(code(request), "native_build_argument"); + // Refusals never echo argument values. + let mut request = base(); + request.args = BTreeMap::from([("DOCKER_HOST".to_owned(), SECRET.to_owned())]); + assert!(!prepare(request).unwrap_err().message.contains(SECRET)); +} + +#[test] +fn the_build_key_binds_local_inputs_but_not_context_contents() { + let fixture = Fixture::new(); + let key = |request: BuildRequest| prepare(request).unwrap().key; + let original = key(fixture.request(Policy::Missing)); + assert_eq!(original, key(fixture.request(Policy::Rebuild))); + + fs::write(fixture.project.join("app/main.txt"), "two").unwrap(); + assert_eq!(original, key(fixture.request(Policy::Missing))); + + let mut request = fixture.request(Policy::Missing); + request.args.insert("GREETING".into(), "other".into()); + assert_ne!(original, key(request)); + let mut request = fixture.request(Policy::Missing); + request.branch = Some("feature".into()); + let branched = prepare(request).unwrap(); + assert_ne!(original, branched.key); + assert_ne!( + branched.namespace, + fixture.inputs(Policy::Missing).namespace + ); + + fs::write(fixture.project.join("app/.dockerignore"), "*.txt\n").unwrap(); + let ignored = key(fixture.request(Policy::Missing)); + assert_ne!(original, ignored); + fs::write( + fixture.project.join("app/Dockerfile.dockerignore"), + "*.md\n", + ) + .unwrap(); + assert_ne!(ignored, key(fixture.request(Policy::Missing))); + + fs::write( + fixture.project.join("app/Dockerfile"), + "FROM alpine:3.21\nRUN echo hi\n", + ) + .unwrap(); + assert_ne!(original, key(fixture.request(Policy::Missing))); + + let inputs = fixture.inputs(Policy::Missing); + let tag = inputs.tag(); + assert!(tag.starts_with("hack-local-build/")); + assert_eq!(tag.len(), "hack-local-build/".len() + 12 + 1 + 32); +} + +#[test] +fn dockerfile_and_ignore_inputs_must_be_plain_files() { + let fixture = Fixture::new(); + std::os::unix::fs::symlink( + fixture.project.join("app/main.txt"), + fixture.project.join("app/.dockerignore"), + ) + .unwrap(); + assert_eq!( + code(fixture.request(Policy::Missing)), + "native_build_source" + ); + fs::remove_file(fixture.project.join("app/.dockerignore")).unwrap(); + fs::hard_link( + fixture.project.join("app/Dockerfile"), + fixture.project.join("Dockerfile.copy"), + ) + .unwrap(); + assert_eq!( + code(fixture.request(Policy::Missing)), + "native_build_source" + ); +} + +#[test] +fn owner_labels_never_carry_the_incarnation() { + let label = owner_label("0123456789abcdef0123456789abcdef"); + assert_eq!(label.len(), 32); + assert!(!label.contains("0123456789abcdef")); + assert_ne!(label, owner_label("another")); +} + +/// Run guest-daemon.sh with `dockerd` replaced by a shell function that records its argv. +fn daemon_arguments(mode: &str) -> (bool, String) { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let root = crate::test_root::private_test_root(&tmp, "hack-guest-daemon"); + let script = format!( + "dockerd() {{ printf '%s\\n' \"$@\" > '{}/argv'; }}\n{}", + root.display(), + include_str!("../guest-daemon.sh") + ); + let status = std::process::Command::new("/bin/sh") + .args(["-c", &script, "daemon-test", mode]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + let argv = fs::read_to_string(root.join("argv")).unwrap_or_default(); + fs::remove_dir_all(&root).unwrap(); + (status.code() == Some(64), argv) +} + +#[test] +fn only_egress_pools_get_the_fixed_build_bridge() { + let (refused, argv) = daemon_arguments("isolated"); + assert!(!refused); + assert!(argv.lines().any(|l| l == "--bridge=none")); + assert!(!argv.contains("--bip")); + for mode in ["internet", "approved-hosts", "host-gateway"] { + let (refused, argv) = daemon_arguments(mode); + assert!(!refused, "{mode}"); + let lines: Vec<&str> = argv.lines().collect(); + assert!(lines.contains(&"--bip=10.213.255.1/24"), "{mode}"); + assert!(lines.contains(&"--icc=false"), "{mode}"); + assert!(!lines.contains(&"--bridge=none"), "{mode}"); + assert!(lines.contains(&"--iptables=true"), "{mode}"); + assert_eq!( + lines.contains(&"--host-gateway-ip=100.96.0.1"), + mode == "host-gateway" + ); + } + let (refused, argv) = daemon_arguments("bridge"); + assert!(refused); + assert!(argv.is_empty()); + assert_eq!(BUILD_NETWORK_SUBNET, "10.213.255.0/24"); +} + +#[test] +fn build_network_script_parses_and_ignores_isolated_pools() { + let script = include_str!("../guest-build-network.sh"); + let run = |args: &[&str]| { + std::process::Command::new("/bin/sh") + .args(["-c", script, "network-test"]) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .code() + }; + assert!( + std::process::Command::new("/bin/sh") + .args(["-n", "-c", script]) + .status() + .unwrap() + .success() + ); + assert_eq!(run(&["isolated", "boot"]), Some(0)); + assert_eq!(run(&["bridge", "boot"]), Some(64)); + assert_eq!(run(&[]), Some(64)); + assert!(script.contains(BUILD_NETWORK_MARKER)); +} diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 9c3a20650..2b0f272d7 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -1658,6 +1658,21 @@ exit 43"#, } owner.save(candidate)?; engine_ping(&socket(candidate, owner, "docker.sock")?)?; + if network_mode != "isolated" { + // Build-bridge isolation is not a startup requirement: graph containers never + // join the default bridge. A failure leaves no readiness marker, and native + // image builds refuse on that pool (see `image_build::verify_build_network`). + let _ = guest( + candidate, + owner, + include_str!("guest-build-network.sh"), + &[ + network_mode, + owner.guest_boot_id.as_deref().expect("verified boot"), + ], + false, + ); + } phase(candidate, owner, "running")?; status(candidate) } @@ -1817,6 +1832,8 @@ pub fn down(candidate: &Candidate) -> Result { } artifact::verify(candidate)?; let _lock = state::Lock::acquire(&root(candidate))?; + // Builds take their shared fence only under this lease, so none can start after this. + super::image_build::require_no_active_build(candidate)?; let mut owner = Owner::load(candidate)?; super::hostname_authority::managed::stop(candidate, &owner)?; super::publication::release(candidate, &owner.token, None)?; diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index 5a7337fee..c3e1a3f97 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -34,6 +34,7 @@ pub mod hostname_authority; pub mod http_probe; pub mod https_recovery; mod identity; +pub mod image_build; pub mod image_ensure; mod image_load; pub mod image_reference; diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 19c02c89f..735101f2b 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -336,14 +336,26 @@ pub struct ResolvedImage { /// fetch must use pinned_reference: reusing the original tag would discard this review. pub fn resolve(reference_text: &str) -> Result { let (repo, tag) = tagged_reference(reference_text)?; - let (repo, tag) = (repo.as_str(), tag.as_str()); + resolve_manifest(&repo, &tag, None).map(|(resolved, _)| resolved) +} + +/// Resolve one repository at a tag or digest to a Linux ARM64 image and its config bytes. +/// `expected_source` binds a digest-pinned request to the manifest actually returned. +fn resolve_manifest( + repo: &str, + reference: &str, + expected_source: Option<&str>, +) -> Result<(ResolvedImage, Vec), CandidateError> { let fetch = authenticated_fetch(repo)?; let url = Url::parse(&format!( - "https://registry-1.docker.io/v2/{repo}/manifests/{tag}" + "https://registry-1.docker.io/v2/{repo}/manifests/{reference}" )) .map_err(|_| error("Invalid manifest address."))?; let initial = fetch.bytes(url, MAX_METADATA, true)?; let source_digest = digest(&initial); + if expected_source.is_some_and(|expected| expected != source_digest) { + return Err(error("Registry manifest digest or size mismatch.")); + } let mut manifest = parse_manifest(&initial, &source_digest)?; let mut manifest_digest = source_digest.clone(); if !manifest.manifests.is_empty() { @@ -360,12 +372,66 @@ pub fn resolve(reference_text: &str) -> Result { .ok_or_else(|| error("Image config descriptor is missing."))?; let config_bytes = fetch.object(repo, "blobs", &config, MAX_METADATA)?; verify_platform(&config_bytes)?; - Ok(ResolvedImage { - pinned_reference: format!("docker.io/{repo}@{source_digest}"), - source_digest, - manifest_digest, - image_id: config.digest, - platform: "linux/arm64", + Ok(( + ResolvedImage { + pinned_reference: format!("docker.io/{repo}@{source_digest}"), + source_digest, + manifest_digest, + image_id: config.digest, + platform: "linux/arm64", + }, + config_bytes, + )) +} + +/// A base image a native build may pull, pinned to the exact manifest the registry +/// served when the build was admitted. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct BuildBase { + /// `docker.io/@`: the only form handed to the builder. + pub pinned_reference: String, + /// Digest of the manifest or index the reference resolved to. + pub source_digest: String, + /// Content ID of the selected Linux ARM64 image. + pub image_id: String, +} + +/// Resolve one normalized base-image reference for a native build. A tag resolves once +/// to its current manifest; a digest-pinned reference must resolve to exactly that pin. +/// Either way the image must offer exactly one Linux ARM64 image, and its configuration +/// must declare no `ONBUILD` triggers: a stored trigger could pull images the Dockerfile +/// scan never saw. No layers, credentials or runtime state are touched. +pub fn resolve_build_base(named: &NamedReference) -> Result { + let repo = named.docker_hub_repository().ok_or_else(|| { + error("Only public Docker Hub [namespace/]repository[:tag][@sha256] references are supported.") + })?; + let (reference, expected) = match (&named.digest, &named.tag) { + (Some(pin), _) => (pin.as_str(), Some(pin.as_str())), + (None, Some(tag)) => (tag.as_str(), None), + (None, None) => ("latest", None), + }; + let (resolved, config) = resolve_manifest(repo, reference, expected)?; + if declares_onbuild(&config)? { + return Err(CandidateError::new( + "native_build_base_image", + "A build base image declares ONBUILD triggers; native builds refuse stored triggers.", + )); + } + Ok(BuildBase { + pinned_reference: resolved.pinned_reference, + source_digest: resolved.source_digest, + image_id: resolved.image_id, + }) +} + +/// Whether an image configuration carries stored `ONBUILD` triggers. +fn declares_onbuild(config_bytes: &[u8]) -> Result { + let config: serde_json::Value = + serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; + Ok(match &config["config"]["OnBuild"] { + serde_json::Value::Null => false, + serde_json::Value::Array(triggers) => !triggers.is_empty(), + _ => return Err(error("Invalid image configuration.")), }) } fn verify_platform(config_bytes: &[u8]) -> Result<(), CandidateError> { @@ -503,6 +569,26 @@ fn publish( #[cfg(test)] mod tests { use super::*; + #[test] + fn stored_onbuild_triggers_are_detected_and_malformed_configs_refuse() { + assert!(!declares_onbuild(br#"{"config":{}}"#).unwrap()); + assert!(!declares_onbuild(br#"{"config":{"OnBuild":null}}"#).unwrap()); + assert!(!declares_onbuild(br#"{"config":{"OnBuild":[]}}"#).unwrap()); + assert!(declares_onbuild(br#"{"config":{"OnBuild":["COPY --from=x / /"]}}"#).unwrap()); + assert!(declares_onbuild(br#"{"config":{"OnBuild":"RUN x"}}"#).is_err()); + assert!(declares_onbuild(b"not json").is_err()); + } + + #[test] + fn build_bases_accept_only_docker_hub_references() { + let named = |value: &str| match normalize(value).unwrap() { + ImageReference::Named(named) => named, + ImageReference::ImageId(_) => unreachable!(), + }; + let error = resolve_build_base(&named("ghcr.io/example/app:1")).unwrap_err(); + assert_eq!(error.code, "registry_image"); + } + #[test] fn publication_preserves_existing_output_and_failed_writes_leave_no_final_file() { static NEXT_TEST: AtomicU64 = AtomicU64::new(0); From e5eb57778f22c3c83ffa23e335c3d5c5b1c5f35d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:50:18 -0500 Subject: [PATCH 043/138] test(native): keep the build cancellation test valid on a loaded host The fake client now publishes its child PID atomically, cancellation waits for that published PID, and the timeout case checks the child only when the client forked it within the one-second budget. Refs HACK-1270 Co-Authored-By: Claude Opus 5.5 --- .../src/provider/image_build/tests.rs | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index 4b209401b..eeec6f91b 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -86,7 +86,7 @@ case {mode} in ok) printf '{{"containerimage.config.digest":"{IMAGE}"}}' > "$meta" ;; fail) exit 3 ;; nometa) ;; - sleep) sleep 30 & echo $! > child; wait ;; + sleep) sleep 30 & echo $! > child.pending; mv child.pending child; wait ;; esac "# ); @@ -348,6 +348,14 @@ fn client_failure_and_missing_metadata_adopt_nothing() { assert_eq!(error.code, "native_build_image"); } +fn wait_dead(pid: &str) { + let deadline = Instant::now() + Duration::from_secs(30); + while process_alive(pid) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!process_alive(pid), "a client group member survived"); +} + fn process_alive(pid: &str) -> bool { let pid: libc::pid_t = pid.trim().parse().unwrap(); // SAFETY: signal 0 only probes existence. @@ -367,27 +375,22 @@ fn timeout_and_cancellation_stop_the_whole_client_group() { assert_eq!(error.code, "native_build_timeout"); assert!(started.elapsed() < Duration::from_secs(20)); assert_eq!(fixture.receipt(&inputs).phase, Phase::Canceled); - let child = fs::read_to_string(fixture.scratch(&inputs).join("child")).unwrap(); - let deadline = Instant::now() + Duration::from_secs(10); - while process_alive(&child) && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(20)); + // On a loaded host the client may not have forked its child within the budget. + let child = fixture.scratch(&inputs).join("child"); + if let Ok(pid) = fs::read_to_string(&child) { + wait_dead(&pid); } - assert!(!process_alive(&child), "a client group member survived"); let inputs = fixture.inputs(Policy::Missing); fixture.client(&inputs, "sleep"); - fs::remove_file(fixture.scratch(&inputs).join("child")).unwrap(); - let scratch = fixture.scratch(&inputs); - let cancel = move || scratch.join("child").exists(); + let _ = fs::remove_file(&child); + let marker = child.clone(); + let cancel = move || marker.exists(); let error = run(&fixture.candidate, &pool, &inputs, &cancel).unwrap_err(); assert_eq!(error.code, "native_build_canceled"); assert_eq!(fixture.receipt(&inputs).phase, Phase::Canceled); - let child = fs::read_to_string(fixture.scratch(&inputs).join("child")).unwrap(); - let deadline = Instant::now() + Duration::from_secs(10); - while process_alive(&child) && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(20)); - } - assert!(!process_alive(&child)); + // Cancellation was requested only once the child existed (published atomically). + wait_dead(&fs::read_to_string(&child).unwrap()); // A request canceled before the client starts never runs it. let error = run(&fixture.candidate, &pool, &inputs, &|| true).unwrap_err(); From 64ac8dab940746f38534ac33a1f1fb86ad6a84d9 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:56:38 -0500 Subject: [PATCH 044/138] feat(native): admit build storage, prune build cache and reclaim superseded build images - A cold native build needs at least 6 GiB free on the guest storage disk, or twice the last verified image of the same key; otherwise it refuses with native_build_storage before any receipt or client effect. Nothing is deleted to make room. Receipts record the verified image size. - After each verified build, BuildKit cache is pruned to 8 GiB through the engine's build-prune endpoint (cache records only). A failed prune leaves the build result in place. - runtime reclaim-build-images --json removes, without force, only build images that carry exactly this pool's ownership labels, that no build receipt names as current, and that no retained graph state (graphs, native graphs, archive) mentions. It runs under the provider lease, refuses while a build is admitted, and removes nothing when the graph state scan meets a symlink, special file, unreadable entry or bound. The engine's own refusal keeps images any container uses. Every kept image is reported with its reason. - New TLA+ model tests/models/tla/native-build-retention (6,181 states) with three guard-removal controls: reclaim ignoring retained references, adoption without the label/incarnation check, down ignoring the build fence. Registered in the runtime model contracts and coverage reference. Refs HACK-1270 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 12 + docs/reference/v5-model-coverage.md | 2 + packages/runtime-core/README.md | 6 +- packages/runtime-core/src/main.rs | 8 + .../runtime-core/src/provider/image_build.rs | 92 +++- .../src/provider/image_build/reclaim.rs | 423 ++++++++++++++++++ .../src/provider/image_build/tests.rs | 133 +++++- scripts/lib/tla-runtime-models.ts | 36 ++ tests/models/tla/README.md | 24 + .../native-build-retention/BuildRetention.tla | 115 +++++ .../down-under-build.cfg | 4 + .../foreign-adoption.cfg | 4 + .../tla/native-build-retention/negative.cfg | 4 + .../tla/native-build-retention/positive.cfg | 4 + 14 files changed, 862 insertions(+), 5 deletions(-) create mode 100644 packages/runtime-core/src/provider/image_build/reclaim.rs create mode 100644 tests/models/tla/native-build-retention/BuildRetention.tla create mode 100644 tests/models/tla/native-build-retention/down-under-build.cfg create mode 100644 tests/models/tla/native-build-retention/foreign-adoption.cfg create mode 100644 tests/models/tla/native-build-retention/negative.cfg create mode 100644 tests/models/tla/native-build-retention/positive.cfg diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 74caf19b2..9f4f986fb 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -485,6 +485,18 @@ linked, mode-`0600` file owned by you), because build-argument values are in it: - `runtime down` refuses while a build runs. Ctrl-C or SIGTERM cancels the build and stops the client's whole process group; so does the time budget (default 30 minutes, at most 2 hours). +- A cold build needs at least 6 GiB free on the runtime's storage disk, or twice the + last image of the same build if that is larger; otherwise it refuses with + `native_build_storage` and deletes nothing. After each successful build, BuildKit's + cache is pruned to 8 GiB. The cache holds only build steps; pruning never removes + images, containers or volumes. + +`runtime reclaim-build-images --json` removes build images that a newer build of the +same key replaced. It keeps every image a build receipt names as current, every image +that any retained run's saved state mentions, every image a container still uses (the +engine refuses those), and every image whose ownership labels are not exactly this +runtime's. It refuses while a build runs, and removes nothing if it cannot read all +saved run state. The report lists removed images and why each other one was kept. Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..973afee48 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -44,6 +44,7 @@ must be attached to the eventual exact integration revision. | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | | Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | +| Native image builds: unleased build versus engine stop, crash mid-build, storage recreation, foreign or forged images, reclamation versus retained graph references | native-build-retention | `provider::image_build` (shared build fence, per-key lock, incarnation/boot/engine recheck and label check at adoption, dead-attempt recording) and `image_build::reclaim` (owned labels, current receipts, retained graph state scan, engine non-force removal); `image_build` regressions | One key and namespace; an orphaned client after a host crash is not modeled (the fence is released with its holder); real BuildKit, engine removal semantics and guest storage are host evidence. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | | Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. | @@ -86,6 +87,7 @@ liveness/reachability controls have their own documented assumptions. | [fence-pending](../../tests/models/tla/fence-pending/Fence.tla) | 7 | 2 | | [relay-staging](../../tests/models/tla/relay-staging/Stage.tla) | 9 | 2 | | [relay-fence](../../tests/models/tla/relay-fence/RelayFence.tla) | 16 | 2 | +| [native-build-retention](../../tests/models/tla/native-build-retention/BuildRetention.tla) | 6,181 | 4 | | [shared-helper-instance](../../tests/models/tla/shared-helper-instance/Instance.tla) | 7,649 | 11 | | [restore-history](../../tests/models/tla/restore-history/RestoreHistory.tla) | See named result contract | 2 | | [graph-admission](../../tests/models/tla/graph-admission/Admission.tla) | See named result contract | 2 | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index db8af2a41..aca2f2762 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -485,7 +485,11 @@ build fence, cancellation and timeout rules are documented in that module. Egres pools give dockerd a fixed default bridge (`10.213.255.0/24`, `icc=false`) that only BuildKit `RUN` steps use; `guest-build-network.sh` isolates it from the guest, the host gateway and link-local metadata, and the engine client refuses any container create -that does not name an owned network or `none`. Native `up` does not call the build +that does not name an owned network or `none`. Cold builds need free guest storage +(at least 6 GiB, or twice the key's last image), successful builds prune BuildKit +cache to 8 GiB, and `runtime reclaim-build-images` removes only owned, superseded, +unreferenced images (`provider/image_build/reclaim.rs`; model +`tests/models/tla/native-build-retention`). Native `up` does not call the build operation yet. Source binaries embed their build checkout. If a copied Cargo target directory diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index a10acd084..5b9fdcb7f 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -103,6 +103,7 @@ Usage: hack-local runtime prepared-base remove --base-id [--store ] [--json] hack-local runtime admit-image --reference <[namespace/]repository[:tag][@sha256:digest]|sha256:image-id> --json hack-local runtime build-image --request --json + hack-local runtime reclaim-build-images --json hack-local runtime ensure-image --reference <[namespace/]repository[:tag]|[namespace/]repository@sha256:digest> --json hack-local runtime resolve-image --reference <[namespace/]repository[:tag]> --json hack-local runtime fetch-image --reference <[namespace/]repository@sha256:digest> --archive [--json] @@ -956,6 +957,13 @@ fn run() -> Result<(), CandidateError> { Path::new(request), )?)?; } + ["runtime", "reclaim-build-images", "--json"] => { + print_json( + &hack_runtime_core::provider::image_build::reclaim::reclaim_build_images( + &discover_candidate(&requested)?, + )?, + )?; + } ["runtime", "admit-image", "--reference", reference, "--json"] => { // Pure: no candidate discovery, network, credentials or runtime effects. print_json(&hack_runtime_core::provider::registry_image::admit( diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index ff6268d46..bd305f905 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -53,6 +53,7 @@ use std::{ time::{Duration, Instant}, }; +pub mod reclaim; #[cfg(test)] mod tests; @@ -73,6 +74,13 @@ const MAX_METADATA_BYTES: u64 = 1024 * 1024; const MAX_IGNORE_BYTES: u64 = 1024 * 1024; const MAX_ARGS: usize = 64; const MAX_ARG_BYTES: usize = 64 * 1024; +const GIB: u64 = 1024 * 1024 * 1024; +/// A cold build needs this much free guest storage, or twice the key's last verified +/// image size when that is larger. Admission refuses; it never deletes to make room. +const STORAGE_FLOOR: u64 = 6 * GIB; +/// BuildKit cache kept after each successful build. The cache in this engine is created +/// only by Hack builds and is regenerable; pruning never touches images or volumes. +pub(super) const CACHE_BUDGET: u64 = 8 * GIB; /// Labels a Dockerfile (or a base image) must not set on an image Hack runs: the same /// reserved ownership namespaces the frontend refuses on authored services. const RESERVED_LABEL_PREFIXES: [&str; 5] = [ @@ -150,6 +158,10 @@ pub struct BuildOutcome { pub reused: bool, /// Named build context to the pinned reference it resolved to, for this image. pub base_pins: BTreeMap, + /// Build cache bytes the post-build prune reclaimed; `None` when no prune ran or + /// it failed (the build result stands either way). + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_reclaimed_bytes: Option, } /// Receipt phases. `building` with no live holder of the key lock is a dead attempt. @@ -176,6 +188,9 @@ pub(super) struct Receipt { phase: Phase, #[serde(default, skip_serializing_if = "Option::is_none")] image_id: Option, + /// Engine-reported size of the verified image, for the next cold build's admission. + #[serde(default, skip_serializing_if = "Option::is_none")] + image_bytes: Option, base_pins: BTreeMap, } @@ -201,6 +216,17 @@ pub(super) trait BuildPool { fn resolve(&self, base: &BaseImage) -> Result; /// The verified builder executable. fn builder(&self) -> Result; + /// Free bytes on the guest storage disk that holds images and build cache. + fn storage_available(&self, lease: &Self::Lease) -> Result; + /// Prune BuildKit cache down to `max_used` bytes; returns the bytes reclaimed. + fn prune_cache(&self, lease: &Self::Lease, max_used: u64) -> Result; +} + +/// Free storage a cold build of a key requires. +pub(super) fn storage_required(previous_image_bytes: Option) -> u64 { + previous_image_bytes + .map_or(0, |bytes| bytes.saturating_mul(2)) + .max(STORAGE_FLOOR) } /// Validated local inputs and the build key derived from them. @@ -683,6 +709,7 @@ pub(super) fn run( tag: inputs.tag(), reused: true, base_pins: previous.base_pins.clone(), + cache_reclaimed_bytes: None, }); } } @@ -695,6 +722,7 @@ pub(super) fn run( state::write(&receipt_path, &dead)?; } } + let previous_bytes = previous.as_ref().and_then(|receipt| receipt.image_bytes); drop(lease); // Step 2, unleased: resolve every base image to a pinned manifest. @@ -729,6 +757,12 @@ pub(super) fn run( "The runtime changed before the build started; no build was run.", )); } + if pool.storage_available(&lease)? < storage_required(previous_bytes) { + return Err(refusal( + "native_build_storage", + "The native runtime's storage disk has too little free space for this build (at least 6 GiB, or twice the last image of this build). Inspect it with `runtime guest-disk-usage` and remove unused images or data; nothing was deleted.", + )); + } let mut receipt = Receipt { version: 1, checkout: candidate.checkout.clone(), @@ -739,6 +773,7 @@ pub(super) fn run( engine_id: identity.engine_id.clone(), phase: Phase::Building, image_id: None, + image_bytes: None, base_pins: pins.clone(), }; state::write(&receipt_path, &receipt)?; @@ -814,10 +849,10 @@ pub(super) fn run( ) })?; owned_image(&inspected, &id, &expected)?; - Ok(id) + Ok((id, inspected["Size"].as_u64())) }); - let id = match adopted { - Ok(id) => id, + let (id, image_bytes) = match adopted { + Ok(adopted) => adopted, Err(error) => { // A refused or missing result is a failed build; an engine or transport // error leaves the outcome unknown. @@ -832,7 +867,10 @@ pub(super) fn run( }; receipt.phase = Phase::ContentIdVerified; receipt.image_id = Some(id.clone()); + receipt.image_bytes = image_bytes; state::write(&receipt_path, &receipt)?; + // The verified image is recorded first; a failed prune leaves only extra cache. + let cache_reclaimed_bytes = pool.prune_cache(&lease, CACHE_BUDGET).ok(); drop(lease); Ok(BuildOutcome { image_id: id, @@ -840,6 +878,7 @@ pub(super) fn run( tag: inputs.tag(), reused: false, base_pins: pins, + cache_reclaimed_bytes, }) } @@ -1128,6 +1167,53 @@ impl<'a> BuildPool for LivePool<'a> { artifact::verify_builder(self.candidate)?; Ok(artifact::builder_executable(self.candidate)) } + fn storage_available(&self, lease: &Self::Lease) -> Result { + guest_storage_available(lease) + } + fn prune_cache(&self, lease: &Self::Lease, max_used: u64) -> Result { + prune_build_cache(lease, max_used) + } +} + +/// Free bytes on the guest `/storage` ext4 disk, which holds `/var/lib/docker`. +pub(super) fn guest_storage_available( + engine: &super::engine::Engine<'_>, +) -> Result { + let output = + engine + .guest() + .execute("df -Pk /storage | awk 'NR == 2 { print $4 }'", &[], None)?; + output + .trim() + .parse::() + .ok() + .and_then(|kib| kib.checked_mul(1024)) + .ok_or_else(|| { + refusal( + "guest_protocol", + "The guest reported no usable free-space value.", + ) + }) +} + +/// Prune the engine's BuildKit cache to at most `max_used` bytes. Cache records only: +/// the build-prune endpoint never removes images, containers or volumes, and BuildKit +/// keeps records an active build uses. +pub(super) fn prune_build_cache( + engine: &super::engine::Engine<'_>, + max_used: u64, +) -> Result { + let value = engine.request( + reqwest::Method::POST, + &format!("/v1.53/build/prune?max-used-space={max_used}"), + None, + )?; + value["SpaceReclaimed"].as_u64().ok_or_else(|| { + refusal( + "engine_protocol", + "The engine reported no reclaimed build-cache size.", + ) + }) } /// `runtime build-image --request `: build or reuse one native image. diff --git a/packages/runtime-core/src/provider/image_build/reclaim.rs b/packages/runtime-core/src/provider/image_build/reclaim.rs new file mode 100644 index 000000000..e903240a8 --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/reclaim.rs @@ -0,0 +1,423 @@ +//! `runtime reclaim-build-images`: remove superseded Hack-built images and trim cache. +//! +//! An image is removed only when every one of these holds; anything else is retained +//! and reported: +//! +//! - it carries exactly this pool's build ownership labels (kind, owner digest of the +//! current incarnation, a namespace and a build key) and no other reserved labels; +//! - no build receipt of any namespace names it as its verified image; +//! - no retained graph state (`run/graphs`, `run/native-graphs`, `run/graph-archive`) +//! mentions its content ID anywhere, whatever the graph's phase or branch; +//! - the engine agrees to remove it without force, which it refuses while any +//! container, running or stopped, uses the image. +//! +//! The operation runs under the provider lease and refuses while a build is admitted, +//! so no build can adopt an image between the reference scan and the removal. If the +//! reference scan cannot read every retained graph record safely, nothing is removed. +//! BuildKit cache is then pruned to the build cache budget; that never removes images. +use super::{ + BuildPool, CACHE_BUDGET, LivePool, Phase, RESERVED_LABEL_PREFIXES, Receipt, builds_root, hex64, + image_id, owner_label, refusal, require_no_active_build, +}; +use crate::{Candidate, CandidateError}; +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, OpenOptions}, + io::Read, + os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, + path::Path, +}; + +const MAX_SCAN_ENTRIES: usize = 20_000; +const MAX_SCAN_DEPTH: usize = 8; +const MAX_SCAN_FILE: u64 = 16 * 1024 * 1024; +const MAX_BUILD_IMAGES: usize = 4096; + +/// Why an owned-looking image was kept. +#[derive(Debug, Serialize, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +#[serde(rename_all = "kebab-case")] +pub enum Retained { + /// Its labels are not exactly this pool's build ownership labels. + NotOwned, + /// A build receipt names it as a key's current image. + Current, + /// Retained graph state references it. + Referenced, + /// The engine refused removal (in use by a container) or the outcome is unknown. + EngineRefused, +} + +#[derive(Debug, Serialize)] +pub struct ReclaimReport { + pub removed: Vec, + pub retained: BTreeMap, + /// Build cache bytes reclaimed; `None` when the prune failed (images unaffected). + pub cache_reclaimed_bytes: Option, +} + +/// Engine effects reclamation needs beyond a build pool. +pub(super) trait ReclaimPool: BuildPool { + /// Images labeled `io.hack-local.kind=build-image`, as the engine lists them. + fn build_images(&self, lease: &Self::Lease) -> Result, CandidateError>; + /// Remove one image by content ID without force. `Ok(false)` when the engine refuses. + fn remove(&self, lease: &Self::Lease, image: &str) -> Result; +} + +fn unprovable() -> CandidateError { + refusal( + "native_build_reclaim_unprovable", + "Retained graph state could not be read completely; no build image was removed.", + ) +} + +/// Every `sha256:<64 hex>` token in the bytes. +fn image_tokens(bytes: &[u8], into: &mut BTreeSet) { + const PREFIX: &[u8] = b"sha256:"; + let mut index = 0; + while let Some(offset) = bytes[index..] + .windows(PREFIX.len()) + .position(|window| window == PREFIX) + { + let start = index + offset + PREFIX.len(); + if let Some(hex) = bytes.get(start..start + 64) + && let Ok(hex) = std::str::from_utf8(hex) + && hex64(hex) + && !bytes.get(start + 64).is_some_and(|b| b.is_ascii_hexdigit()) + { + into.insert(format!("sha256:{hex}")); + } + index = start; + } +} + +/// Collect every image ID retained graph state mentions. Symlinks, special files, +/// unreadable entries or exceeded bounds refuse: reclamation needs a complete scan. +pub(super) fn referenced_images(candidate: &Candidate) -> Result, CandidateError> { + let mut found = BTreeSet::new(); + let mut entries = 0; + for name in ["run/graphs", "run/native-graphs", "run/graph-archive"] { + let root = candidate.state_root.join(name); + match fs::symlink_metadata(&root) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err(unprovable()), + Ok(metadata) if !metadata.is_dir() => return Err(unprovable()), + Ok(_) => {} + } + scan_directory(&root, 0, &mut entries, &mut found)?; + } + Ok(found) +} + +fn scan_directory( + directory: &Path, + depth: usize, + entries: &mut usize, + found: &mut BTreeSet, +) -> Result<(), CandidateError> { + if depth > MAX_SCAN_DEPTH { + return Err(unprovable()); + } + for entry in fs::read_dir(directory).map_err(|_| unprovable())? { + *entries += 1; + if *entries > MAX_SCAN_ENTRIES { + return Err(unprovable()); + } + let path = entry.map_err(|_| unprovable())?.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| unprovable())?; + if metadata.is_dir() { + scan_directory(&path, depth + 1, entries, found)?; + } else if metadata.is_file() { + if metadata.len() > MAX_SCAN_FILE { + return Err(unprovable()); + } + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&path) + .map_err(|_| unprovable())?; + let opened = file.metadata().map_err(|_| unprovable())?; + if !opened.is_file() || (opened.dev(), opened.ino()) != (metadata.dev(), metadata.ino()) + { + return Err(unprovable()); + } + let mut bytes = Vec::new(); + file.take(MAX_SCAN_FILE + 1) + .read_to_end(&mut bytes) + .map_err(|_| unprovable())?; + if bytes.len() as u64 > MAX_SCAN_FILE { + return Err(unprovable()); + } + image_tokens(&bytes, found); + } else if !metadata.file_type().is_socket() { + // Sockets carry no records; anything else is unexpected in graph state. + return Err(unprovable()); + } + } + Ok(()) +} + +/// Image IDs that build receipts of every namespace record as verified. +fn current_images(candidate: &Candidate) -> Result, CandidateError> { + let root = builds_root(candidate); + let mut current = BTreeSet::new(); + let namespaces = match fs::read_dir(&root) { + Ok(namespaces) => namespaces, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current), + Err(_) => return Err(unprovable()), + }; + for namespace in namespaces { + let namespace = namespace.map_err(|_| unprovable())?.path(); + let name = namespace.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let metadata = fs::symlink_metadata(&namespace).map_err(|_| unprovable())?; + if name == "active.lock" && metadata.is_file() { + continue; + } + if !hex64(name) || !metadata.is_dir() { + return Err(unprovable()); + } + for entry in fs::read_dir(&namespace).map_err(|_| unprovable())? { + let path = entry.map_err(|_| unprovable())?.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let receipt: Receipt = + crate::provider::state::read_bounded(&path, super::MAX_RECEIPT_BYTES) + .map_err(|_| unprovable())?; + if receipt.phase == Phase::ContentIdVerified + && let Some(id) = receipt.image_id + { + current.insert(id); + } + } + } + Ok(current) +} + +/// Whether listed labels are exactly this pool's build ownership labels. +fn owned_by(labels: &Value, owner: &str) -> bool { + let Some(labels) = labels.as_object() else { + return false; + }; + let text = |key: &str| labels.get(key).and_then(Value::as_str); + let reserved_extra = labels.keys().any(|key| { + RESERVED_LABEL_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + && ![ + "io.hack-local.build-key", + "io.hack-local.kind", + "io.hack-local.namespace", + "io.hack-local.owner", + ] + .contains(&key.as_str()) + }); + text("io.hack-local.kind") == Some(super::KIND) + && text("io.hack-local.owner") == Some(owner) + && text("io.hack-local.namespace").is_some_and(hex64) + && text("io.hack-local.build-key").is_some_and(hex64) + && !reserved_extra +} + +/// The removal decision for each listed image, before any engine effect. +pub(super) fn plan( + images: &[Value], + owner: &str, + current: &BTreeSet, + referenced: &BTreeSet, +) -> Result<(Vec, BTreeMap), CandidateError> { + if images.len() > MAX_BUILD_IMAGES { + return Err(refusal( + "native_build_reclaim_bounds", + "Too many build images to reclaim in one operation; nothing was removed.", + )); + } + let mut remove = Vec::new(); + let mut retained = BTreeMap::new(); + for image in images { + let id = image["Id"] + .as_str() + .filter(|id| image_id(id)) + .ok_or_else(|| { + refusal( + "engine_protocol", + "The engine listed a build image without a content ID.", + ) + })? + .to_owned(); + let reason = if !owned_by(&image["Labels"], owner) { + Some(Retained::NotOwned) + } else if current.contains(&id) { + Some(Retained::Current) + } else if referenced.contains(&id) { + Some(Retained::Referenced) + } else { + None + }; + match reason { + Some(reason) => { + retained.insert(id, reason); + } + None => remove.push(id), + } + } + remove.sort(); + remove.dedup(); + Ok((remove, retained)) +} + +pub(super) fn run( + candidate: &Candidate, + pool: &P, +) -> Result { + let (lease, identity) = pool.lease()?; + require_no_active_build(candidate)?; + // Under the lease no build can start, adopt or write a receipt. + let current = current_images(candidate)?; + let referenced = referenced_images(candidate)?; + let images = pool.build_images(&lease)?; + let (candidates, mut retained) = plan( + &images, + &owner_label(&identity.incarnation), + ¤t, + &referenced, + )?; + let mut removed = Vec::new(); + for id in candidates { + match pool.remove(&lease, &id) { + Ok(true) => removed.push(id), + Ok(false) | Err(_) => { + retained.insert(id, Retained::EngineRefused); + } + } + } + let cache_reclaimed_bytes = pool.prune_cache(&lease, CACHE_BUDGET).ok(); + Ok(ReclaimReport { + removed, + retained, + cache_reclaimed_bytes, + }) +} + +impl ReclaimPool for LivePool<'_> { + fn build_images(&self, lease: &Self::Lease) -> Result, CandidateError> { + // `{"label":["io.hack-local.kind=build-image"]}`, percent-encoded. + let value = lease.request( + reqwest::Method::GET, + "/v1.53/images/json?all=true&filters=%7B%22label%22%3A%5B%22io.hack-local.kind%3Dbuild-image%22%5D%7D", + None, + )?; + value + .as_array() + .cloned() + .ok_or_else(|| refusal("engine_protocol", "The engine image list is not an array.")) + } + fn remove(&self, lease: &Self::Lease, image: &str) -> Result { + match lease.request( + reqwest::Method::DELETE, + &format!("/v1.53/images/{image}?force=false&noprune=false"), + None, + ) { + Ok(_) => Ok(true), + Err(error) if error.code == "engine_not_found" => Ok(true), + Err(error) if error.code == "engine_rejected" => Ok(false), + Err(error) => Err(error), + } + } +} + +/// `runtime reclaim-build-images`: see the module documentation. +pub fn reclaim_build_images(candidate: &Candidate) -> Result { + run(candidate, &LivePool { candidate }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const C: &str = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const D: &str = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + fn image(id: &str, owner: &str) -> Value { + serde_json::json!({"Id": id, "Labels": { + "io.hack-local.kind": "build-image", + "io.hack-local.owner": owner, + "io.hack-local.namespace": "1".repeat(64), + "io.hack-local.build-key": "2".repeat(64), + }}) + } + + #[test] + fn only_unreferenced_superseded_images_of_this_pool_are_removal_candidates() { + let owner = owner_label("pool"); + let mut forged = image(D, &owner); + forged["Labels"]["io.hack-local.graph"] = "x".into(); + let images = [ + image(A, &owner), + image(B, &owner), + image(C, &owner), + forged, + image( + "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + &owner_label("other-pool"), + ), + ]; + let current = BTreeSet::from([B.to_owned()]); + let referenced = BTreeSet::from([C.to_owned()]); + let (remove, retained) = plan(&images, &owner, ¤t, &referenced).unwrap(); + assert_eq!(remove, vec![A.to_owned()]); + assert_eq!(retained[B], Retained::Current); + assert_eq!(retained[C], Retained::Referenced); + assert_eq!(retained[D], Retained::NotOwned); + assert_eq!(retained.len(), 4); + assert!( + plan( + &[serde_json::json!({"Id": "bad"})], + &owner, + ¤t, + &referenced + ) + .is_err() + ); + } + + #[test] + fn image_tokens_find_exact_content_ids_only() { + let mut found = BTreeSet::new(); + image_tokens( + format!("{{\"image\":\"{A}\"}} x{B}f sha256:short {C}").as_bytes(), + &mut found, + ); + // B is followed by another hex digit, so it is not an exact ID. + assert_eq!(found, BTreeSet::from([A.to_owned(), C.to_owned()])); + } + + #[test] + fn retained_graph_state_is_scanned_completely_or_not_at_all() { + let tmp = std::env::temp_dir().canonicalize().unwrap(); + let root = crate::test_root::private_test_root(&tmp, "hack-build-reclaim"); + let checkout = root.join("checkout"); + crate::provider::state::private_directory(&checkout).unwrap(); + let candidate = Candidate::discover(&checkout).unwrap(); + assert!(referenced_images(&candidate).unwrap().is_empty()); + let run = candidate.state_root.join("run/native-graphs/r1/nested"); + crate::provider::state::private_directory(&run).unwrap(); + fs::write(run.join("receipt.json"), format!("{{\"image\":\"{A}\"}}")).unwrap(); + let archive = candidate.state_root.join("run/graph-archive"); + crate::provider::state::private_directory(&archive).unwrap(); + fs::write(archive.join("old.json"), format!("[\"{B}\"]")).unwrap(); + assert_eq!( + referenced_images(&candidate).unwrap(), + BTreeSet::from([A.to_owned(), B.to_owned()]) + ); + std::os::unix::fs::symlink(run.join("receipt.json"), archive.join("alias")).unwrap(); + assert_eq!( + referenced_images(&candidate).unwrap_err().code, + "native_build_reclaim_unprovable" + ); + fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index eeec6f91b..054cef1b0 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -116,18 +116,26 @@ fn identity(incarnation: &str) -> PoolIdentity { /// A fake pool: identities returned per lease in order (the last repeats), images by ID. struct FakePool { + removable: RefCell>, + removed: RefCell>, identities: RefCell>, images: RefCell>, leases: RefCell, resolve_error: bool, + available: RefCell, + prunes: RefCell>, } impl FakePool { fn new(incarnation: &str) -> Self { Self { + removable: RefCell::new(BTreeMap::new()), + removed: RefCell::new(Vec::new()), identities: RefCell::new(vec![identity(incarnation)]), images: RefCell::new(BTreeMap::new()), leases: RefCell::new(0), resolve_error: false, + available: RefCell::new(64 * GIB), + prunes: RefCell::new(Vec::new()), } } /// Register the image the fake client "builds", with the labels the build expects. @@ -139,7 +147,7 @@ impl FakePool { self.images.borrow_mut().insert( IMAGE.into(), serde_json::json!({"Id": IMAGE, "Architecture": "arm64", "Os": "linux", - "Config": {"Labels": labels, "Volumes": null}}), + "Size": 5 * GIB, "Config": {"Labels": labels, "Volumes": null}}), ); } } @@ -171,6 +179,13 @@ impl BuildPool for FakePool { fn builder(&self) -> Result { Ok(PathBuf::from("/bin/sh")) } + fn storage_available(&self, _: &()) -> Result { + Ok(*self.available.borrow()) + } + fn prune_cache(&self, _: &(), max_used: u64) -> Result { + self.prunes.borrow_mut().push(max_used); + Ok(7) + } } fn never() -> bool { @@ -417,6 +432,7 @@ fn a_dead_attempt_is_recorded_indeterminate_and_built_again() { engine_id: "engine-1".into(), phase: Phase::Building, image_id: None, + image_bytes: None, base_pins: BTreeMap::new(), }; state::write(&path, &leftover).unwrap(); @@ -745,3 +761,118 @@ fn build_network_script_parses_and_ignores_isolated_pools() { assert_eq!(run(&[]), Some(64)); assert!(script.contains(BUILD_NETWORK_MARKER)); } + +#[test] +fn cold_builds_need_free_storage_and_successful_builds_prune_the_cache() { + assert_eq!(storage_required(None), 6 * GIB); + assert_eq!(storage_required(Some(GIB)), 6 * GIB); + assert_eq!(storage_required(Some(5 * GIB)), 10 * GIB); + assert_eq!(storage_required(Some(u64::MAX)), u64::MAX); + + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + *pool.available.borrow_mut() = 6 * GIB - 1; + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_storage"); + assert_eq!(fixture.calls(&inputs), 0); + + *pool.available.borrow_mut() = 6 * GIB; + let outcome = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert_eq!(outcome.cache_reclaimed_bytes, Some(7)); + assert_eq!(*pool.prunes.borrow(), vec![CACHE_BUDGET]); + assert_eq!(fixture.receipt(&inputs).image_bytes, Some(5 * GIB)); + + // The next cold build of this key needs twice the last verified image. + let rebuild = fixture.inputs(Policy::Rebuild); + let error = run(&fixture.candidate, &pool, &rebuild, &never).unwrap_err(); + assert_eq!(error.code, "native_build_storage"); + // A warm reuse needs no free-space headroom. + let reused = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert!(reused.reused); + assert_eq!(pool.prunes.borrow().len(), 1); +} + +impl reclaim::ReclaimPool for FakePool { + fn build_images(&self, _: &()) -> Result, CandidateError> { + Ok(self + .images + .borrow() + .values() + .map( + |image| serde_json::json!({"Id": image["Id"], "Labels": image["Config"]["Labels"]}), + ) + .collect()) + } + fn remove(&self, _: &(), image: &str) -> Result { + let allowed = self.removable.borrow().get(image).copied().unwrap_or(true); + if allowed { + self.removed.borrow_mut().push(image.to_owned()); + self.images.borrow_mut().remove(image); + } + Ok(allowed) + } +} + +#[test] +fn reclamation_keeps_current_referenced_in_use_and_foreign_images() { + const OLD: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; + const KEPT: &str = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + const BUSY: &str = "sha256:3333333333333333333333333333333333333333333333333333333333333333"; + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "ok"); + let pool = FakePool::new("pool-1"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let current = pool.images.borrow()[IMAGE].clone(); + for id in [OLD, KEPT, BUSY] { + let mut superseded = current.clone(); + superseded["Id"] = id.into(); + pool.images.borrow_mut().insert(id.into(), superseded); + } + pool.removable.borrow_mut().insert(BUSY.into(), false); + let graphs = fixture.candidate.state_root.join("run/graphs/0123"); + state::private_directory(&graphs).unwrap(); + fs::write( + graphs.join("receipt.json"), + format!("{{\"image\":\"{KEPT}\"}}"), + ) + .unwrap(); + + // Nothing is removed while a build is admitted. + let build = Flock::try_lock( + Flock::open(&builds_root(&fixture.candidate).join("active.lock")).unwrap(), + libc::LOCK_SH, + ) + .unwrap() + .unwrap(); + assert_eq!( + reclaim::run(&fixture.candidate, &pool).unwrap_err().code, + "provider_busy" + ); + drop(build); + + let report = reclaim::run(&fixture.candidate, &pool).unwrap(); + assert_eq!(report.removed, vec![OLD.to_owned()]); + assert_eq!(report.retained[IMAGE], reclaim::Retained::Current); + assert_eq!(report.retained[KEPT], reclaim::Retained::Referenced); + assert_eq!(report.retained[BUSY], reclaim::Retained::EngineRefused); + assert_eq!(report.cache_reclaimed_bytes, Some(7)); + // Another pool's images are never this pool's to remove. + let other = FakePool::new("pool-2"); + other + .images + .borrow_mut() + .extend(pool.images.borrow().clone()); + let report = reclaim::run(&fixture.candidate, &other).unwrap(); + assert!(report.removed.is_empty()); + assert!( + report + .retained + .values() + .all(|r| *r == reclaim::Retained::NotOwned) + ); +} diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..1f6360c2f 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1440,6 +1440,42 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "native-build-retention", + module: "BuildRetention", + states: 6181, + invariant: "NoReclaimReferenced", + action: "Reclaim", + fields: ["refs = {1}", "removed = 1", "fence = FALSE"], + additionalControls: [ + { + name: "foreign-adoption", + negative: true, + invariant: "NoForeignAdoption", + action: "CompleteBuild", + fields: [ + 'receipt = [phase |-> "verified", image |-> 2, inc |-> 1]', + "owner = <<1, 0, 0>>", + ], + alternativeWitnesses: [ + { + action: "CompleteBuild", + fields: [ + 'receipt = [phase |-> "verified", image |-> 1, inc |-> 1]', + "owner = <<0, 1, 0>>", + ], + }, + ], + }, + { + name: "down-under-build", + negative: true, + invariant: "DownNotUnderBuild", + action: "Down", + fields: ['build = "running"', "engineUp = FALSE", "fence = TRUE"], + }, + ], + }, { name: "relay-fence", module: "RelayFence", diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..2ebe1b8f4 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -1049,3 +1049,27 @@ Closing the residual needs a mutation fence rather than a timeout: target tmux mutations by session ID (`#{session_id}`, never reused by a server) captured with the token read. That fence, and full zellij support for shared-helper lifecycle sessions, are tracked in HACK-1849 and are not in this slice. + +## Native image build retention + +`native-build-retention/BuildRetention.tla` follows `provider::image_build` and its +`reclaim` module for one build key in one namespace, with three content IDs and two +storage incarnations. The positive configuration exhausts **6,181 distinct states**. + +| Model operation | Source boundary | +| --- | --- | +| `BeginBuild` | Under the provider lease: shared build fence, per-key lock, `building` receipt. | +| `RunBuild` / `ForeignImage` | Unleased BuildKit run writing an image with this pool's labels; a forged or foreign image may appear at any time. | +| `CompleteBuild` | Under the lease: the reported ID is adopted only with this incarnation's labels and the admitted incarnation; otherwise `failed`. | +| `Cancel` / `CrashHost` / `RecordDeadAttempt` | Cancellation and host death release the fence; a `building` receipt under the key lock is recorded `indeterminate`. | +| `Down` / `RecreateStorage` | `runtime down` refuses while the fence is held; recreated storage is a new incarnation with an empty engine. | +| `StartGraph` / `RemoveContainers` / `Restore` | Graphs use only a verified image of this incarnation and keep a reference to it; data-retaining cleanup removes containers but not the reference; restore never builds. | +| `Reclaim` / `PruneCache` | Owned, non-current, unreferenced, unused images only, with no build admitted; cache pruning never touches images. | + +Three controls each remove one guard and must fail at the named action: +`negative` (reclaim ignores retained references) fails `NoReclaimReferenced` at +`Reclaim`; `foreign-adoption` (adoption without the label/incarnation check) fails +`NoForeignAdoption` at `CompleteBuild`; `down-under-build` (down ignores the fence) +fails `DownNotUnderBuild` at `Down`. The model does not cover an orphaned build client +that outlives a crashed host process, real BuildKit or engine behavior, receipt +durability, or more than one key and namespace. diff --git a/tests/models/tla/native-build-retention/BuildRetention.tla b/tests/models/tla/native-build-retention/BuildRetention.tla new file mode 100644 index 000000000..f9a459253 --- /dev/null +++ b/tests/models/tla/native-build-retention/BuildRetention.tla @@ -0,0 +1,115 @@ +------------------------- MODULE BuildRetention ------------------------- +(***************************************************************************) +(* Native image build retention: `provider::image_build` and its *) +(* `reclaim` module. One build key, one namespace, a bounded supply of *) +(* content IDs. A build is admitted under the provider lease (fence and *) +(* `building` receipt), runs unleased, and is adopted under the lease only *) +(* when the reported image carries this incarnation's labels and the *) +(* incarnation is the one admitted. `runtime down` stops the engine only *) +(* when no build holds the shared fence. Reclamation removes only owned, *) +(* non-current images that no retained graph state references; the engine *) +(* itself refuses images a container uses. Unsafe* constants remove one *) +(* guard each for the negative controls. *) +(***************************************************************************) +EXTENDS Naturals, FiniteSets +CONSTANTS UnsafeReclaim, UnsafeAdopt, UnsafeDown +VARIABLES incarnation, engineUp, engine, owner, next, receipt, build, + fence, refs, used, removed +vars == <> +Ids == 1..3 +Phases == {"none", "building", "verified", "failed", "canceled", + "indeterminate"} + +Init == /\ incarnation = 1 /\ engineUp = TRUE /\ engine = {} + /\ owner = [i \in Ids |-> 0] /\ next = 1 + /\ receipt = [phase |-> "none", image |-> 0, inc |-> 0] + /\ build = "idle" /\ fence = FALSE + /\ refs = {} /\ used = {} /\ removed = 0 + +(* Step 1: under the lease, take the fence and record the attempt. *) +BeginBuild == /\ engineUp /\ build = "idle" + /\ build' = "running" /\ fence' = TRUE + /\ receipt' = [phase |-> "building", image |-> 0, inc |-> incarnation] + /\ UNCHANGED <> +(* Step 4, unleased: BuildKit writes a fresh image with this pool's labels. *) +RunBuild == /\ build = "running" /\ engineUp /\ next \in Ids + /\ engine' = engine \cup {next} + /\ owner' = [owner EXCEPT ![next] = incarnation] + /\ next' = next + 1 /\ build' = "done" + /\ UNCHANGED <> +(* A forged or foreign image appears in the engine (Dockerfile labels, other pool). *) +ForeignImage == /\ engineUp /\ next \in Ids + /\ engine' = engine \cup {next} /\ next' = next + 1 + /\ UNCHANGED <> +(* Step 5: the reported ID may be any present image; only an owned one under *) +(* the admitted incarnation is adopted. *) +CompleteBuild == /\ build = "done" /\ engineUp + /\ \E reported \in engine : + receipt' = IF UnsafeAdopt \/ + (owner[reported] = incarnation /\ receipt.inc = incarnation) + THEN [phase |-> "verified", image |-> reported, inc |-> receipt.inc] + ELSE [receipt EXCEPT !.phase = "failed"] + /\ build' = "idle" /\ fence' = FALSE + /\ UNCHANGED <> +Cancel == /\ build \in {"running", "done"} + /\ build' = "idle" /\ fence' = FALSE + /\ receipt' = [receipt EXCEPT !.phase = "canceled"] + /\ UNCHANGED <> +(* hack-native dies: the flock-backed fence and key lock are released; the *) +(* receipt stays `building` and the next attempt records it indeterminate. *) +CrashHost == /\ build # "idle" /\ build' = "idle" /\ fence' = FALSE + /\ UNCHANGED <> +RecordDeadAttempt == /\ build = "idle" /\ receipt.phase = "building" + /\ receipt' = [receipt EXCEPT !.phase = "indeterminate"] + /\ UNCHANGED <> +Down == /\ engineUp /\ (UnsafeDown \/ ~fence) + /\ engineUp' = FALSE + /\ UNCHANGED <> +Up == /\ ~engineUp /\ engineUp' = TRUE + /\ UNCHANGED <> +(* The storage disk is recreated: a new incarnation with an empty engine. *) +RecreateStorage == /\ ~engineUp /\ build = "idle" /\ incarnation = 1 + /\ incarnation' = 2 /\ engine' = {} /\ used' = {} + /\ UNCHANGED <> +(* A graph starts from the verified image of this incarnation and retains a *) +(* reference to it; its containers use it until removed. *) +StartGraph == /\ engineUp /\ receipt.phase = "verified" + /\ receipt.inc = incarnation /\ receipt.image \in engine + /\ owner[receipt.image] = incarnation + /\ refs' = refs \cup {receipt.image} /\ used' = used \cup {receipt.image} + /\ UNCHANGED <> +(* Data-retaining cleanup removes containers but keeps the graph reference. *) +RemoveContainers == /\ used # {} /\ used' = {} + /\ UNCHANGED <> +(* Restore uses retained references only and never builds. *) +Restore == /\ engineUp /\ build = "idle" /\ refs \cap engine # {} + /\ used' = used \cup (refs \cap engine) + /\ UNCHANGED <> +Reclaim == /\ engineUp /\ ~fence + /\ \E image \in engine : + /\ owner[image] = incarnation + /\ ~(receipt.phase = "verified" /\ receipt.image = image) + /\ (UnsafeReclaim \/ image \notin refs) + /\ image \notin used + /\ engine' = engine \ {image} /\ removed' = image + /\ UNCHANGED <> +PruneCache == /\ engineUp /\ UNCHANGED vars +Next == BeginBuild \/ RunBuild \/ ForeignImage \/ CompleteBuild \/ Cancel + \/ CrashHost \/ RecordDeadAttempt \/ Down \/ Up \/ RecreateStorage + \/ StartGraph \/ RemoveContainers \/ Restore \/ Reclaim \/ PruneCache +Spec == Init /\ [][Next]_vars + +TypeOK == /\ incarnation \in {1, 2} /\ engineUp \in BOOLEAN + /\ engine \subseteq Ids /\ owner \in [Ids -> 0..2] /\ next \in 1..4 + /\ receipt.phase \in Phases /\ receipt.image \in 0..3 /\ receipt.inc \in 0..2 + /\ build \in {"idle", "running", "done"} /\ fence \in BOOLEAN + /\ refs \subseteq Ids /\ used \subseteq Ids /\ removed \in 0..3 +(* An image retained graph state references is never reclaimed. *) +NoReclaimReferenced == removed = 0 \/ removed \notin refs +(* A verified receipt names an image produced under its own incarnation. *) +NoForeignAdoption == receipt.phase = "verified" => owner[receipt.image] = receipt.inc +(* The engine never stops under a build its operation still controls. *) +DownNotUnderBuild == build = "running" => engineUp +Safe == NoReclaimReferenced /\ NoForeignAdoption /\ DownNotUnderBuild +============================================================================= diff --git a/tests/models/tla/native-build-retention/down-under-build.cfg b/tests/models/tla/native-build-retention/down-under-build.cfg new file mode 100644 index 000000000..e5a514866 --- /dev/null +++ b/tests/models/tla/native-build-retention/down-under-build.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = FALSE UnsafeAdopt = FALSE UnsafeDown = TRUE +INVARIANT TypeOK +INVARIANT DownNotUnderBuild diff --git a/tests/models/tla/native-build-retention/foreign-adoption.cfg b/tests/models/tla/native-build-retention/foreign-adoption.cfg new file mode 100644 index 000000000..694d3bbf3 --- /dev/null +++ b/tests/models/tla/native-build-retention/foreign-adoption.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = FALSE UnsafeAdopt = TRUE UnsafeDown = FALSE +INVARIANT TypeOK +INVARIANT NoForeignAdoption diff --git a/tests/models/tla/native-build-retention/negative.cfg b/tests/models/tla/native-build-retention/negative.cfg new file mode 100644 index 000000000..595325be5 --- /dev/null +++ b/tests/models/tla/native-build-retention/negative.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = TRUE UnsafeAdopt = FALSE UnsafeDown = FALSE +INVARIANT TypeOK +INVARIANT NoReclaimReferenced diff --git a/tests/models/tla/native-build-retention/positive.cfg b/tests/models/tla/native-build-retention/positive.cfg new file mode 100644 index 000000000..28191ce8d --- /dev/null +++ b/tests/models/tla/native-build-retention/positive.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS UnsafeReclaim = FALSE UnsafeAdopt = FALSE UnsafeDown = FALSE +INVARIANT TypeOK +INVARIANT Safe From 7784059409eef9c019c4d5fbc260d78aefb71578 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:57:15 -0500 Subject: [PATCH 045/138] feat(native): publish Compose ports on host loopback through owned bridges Native foreground up now publishes reviewed Compose ports of active services on host 127.0.0.1 instead of refusing every ports entry with graph_subset. Admission (Rust is the authority): only the foreground owner (graph serve and serve-restore) admits ports, as TCP on 127.0.0.1 with an explicit host port, on a project network, at most 16 per graph, unique host ports. UDP, engine-assigned host ports and IPv6 loopback publication are refused with graph_port_publication. graph run and other non-foreground paths keep refusing ports with graph_subset. Admitted intent is recorded on the container resource (loopback_ports, omitted when empty) and checked when a receipt is loaded; one-off jobs and native-config graphs never carry it. Publication: after routes, the foreground owner reserves one application bridge per published port (bridge assignments gain an optional loopback_port, so a service may own a route bridge and several port bridges), starts the guest relay to the container's target port, and launches the existing TCP publisher for the host port. The bridge is bound to a port endpoint of a running, ready owned container, so traffic never reaches a replaced incarnation. publish-bridge accepts only the reviewed host port and target for a loopback bridge. Before binding, under the provider lease, a loopback connect probe refuses a host port that already accepts connections (published_port_in_use); the publisher binds with SO_REUSEADDR, which BSD would otherwise let share loopback traffic with another process's wildcard listener. A TCP publication is observed through its owned control socket and process identity. Stop and recovery reuse the existing paths: bridges::release_run retires host listeners before containers stop, and dead-owner cleanup retires publication entries only after proving the owner is gone. Frontend: prepareNativeProjectServices no longer refuses ports; bridge capacity counts routes plus declared ports (at most 32); and after review, before graph admission, an occupied published host port is refused with E_CONFIG_INVALID. TLA+: new loopback-port-publication model (1008 states) with negative controls for recovery without the dead-owner check, no loopback probe, no endpoint identity check and acknowledging stop before closing the listener. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 29 ++ packages/runtime-core/src/graph_cli.rs | 1 + .../src/provider/graph/bridges.rs | 288 ++++++++++++++++-- .../src/provider/graph/bridges/cleanup.rs | 5 + .../src/provider/graph/cache_provenance.rs | 2 + .../runtime-core/src/provider/graph/config.rs | 73 ++++- .../src/provider/graph/environment/tests.rs | 1 + .../provider/graph/environment_crash_test.rs | 1 + .../foreground/native_test/partial_stop.rs | 1 + .../native_test/same_boot_absent_relay.rs | 1 + .../provider/graph/foreground/publishers.rs | 214 +++++++++++-- .../provider/graph/foreground/redelivery.rs | 1 + .../src/provider/graph/host_relay.rs | 1 + .../provider/graph/host_relay/live_tests.rs | 3 + .../src/provider/graph/hostname_change.rs | 1 + .../runtime-core/src/provider/graph/mod.rs | 72 +++++ .../src/provider/graph/native/journal.rs | 2 + .../src/provider/graph/normalized.rs | 1 + .../src/provider/graph/one_off.rs | 1 + .../provider/graph/one_off/normalization.rs | 1 + .../src/provider/graph/one_off/runtime.rs | 1 + .../src/provider/graph/restore.rs | 3 + .../provider/graph/restore_history/tests.rs | 1 + .../src/provider/graph/shutdown.rs | 1 + .../provider/graph/shutdown/native_test.rs | 1 + .../graph/shutdown/native_test/integrity.rs | 1 + .../runtime-core/src/provider/graph/source.rs | 1 + .../src/provider/graph/startup/native_test.rs | 1 + .../src/provider/graph/startup_test.rs | 1 + .../src/provider/graph/storage_inventory.rs | 1 + .../runtime-core/src/provider/graph/tests.rs | 157 ++++++++++ .../graph/volume_subpaths/native_test.rs | 1 + .../runtime-core/src/provider/publication.rs | 91 +++++- scripts/lib/tla-runtime-models.ts | 44 +++ src/backends/native-project-ports.ts | 131 ++++++++ src/backends/native-project-start.ts | 17 +- tests/models/tla/README.md | 6 + .../LoopbackPort.tla | 139 +++++++++ .../ack-before-close.cfg | 8 + .../loopback-port-publication/negative.cfg | 8 + .../loopback-port-publication/no-probe.cfg | 8 + .../loopback-port-publication/positive.cfg | 8 + .../stale-target.cfg | 8 + tests/native-project-ports.test.ts | 91 ++++++ tests/native-project-start.test.ts | 10 +- 45 files changed, 1371 insertions(+), 67 deletions(-) create mode 100644 src/backends/native-project-ports.ts create mode 100644 tests/models/tla/loopback-port-publication/LoopbackPort.tla create mode 100644 tests/models/tla/loopback-port-publication/ack-before-close.cfg create mode 100644 tests/models/tla/loopback-port-publication/negative.cfg create mode 100644 tests/models/tla/loopback-port-publication/no-probe.cfg create mode 100644 tests/models/tla/loopback-port-publication/positive.cfg create mode 100644 tests/models/tla/loopback-port-publication/stale-target.cfg create mode 100644 tests/native-project-ports.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 655b40f71..1d313a48e 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -252,6 +252,35 @@ interface in that case; native does not expose development services to the network. A specific non-loopback address, such as a LAN address, is an `unsupported_host_binding` error. Keep such a project on the Compose backend. +Native foreground `hack up` publishes the reviewed ports of active services on +host `127.0.0.1`. Each published port is forwarded through an owned application +bridge to the container's target port, after the graph is ready. The rules: + +- Only TCP with an explicit host port is published. UDP, an engine-assigned host + port (a bare `"3000"`), and IPv6 loopback (`::1`) publication are refused with + `graph_port_publication`; keep those projects on the Compose backend. +- One graph publishes at most 16 ports. Routes and published ports share the + pool's 32 application bridge sockets. +- Two active services, two graphs in the same pool, or a graph and any other + host process cannot hold the same host port. Before graph admission, and again + under the provider lease just before binding, native refuses a host port that + already accepts connections on `127.0.0.1` (`published_port_in_use`, or + `E_CONFIG_INVALID` from the frontend check). The other listener is left + untouched. +- A connection is forwarded only to the container incarnation the bridge was + started for. A replaced container does not receive traffic from an older + publication. +- Stopping the foreground owner (Ctrl-C) closes each host listener before its + bridge and the containers stop, then retires the publication. Dead-owner + cleanup retires a publication only after proving its owner is gone. A + retaining restart publishes the same ports again. +- `graph run` and other non-foreground paths do not publish ports and keep + refusing them with `graph_subset`. + +The loopback check is point-in-time. A process that binds the same port on all +interfaces between the check and native's own bind can still share loopback +traffic with it; stop such listeners before `hack up`. + `hack doctor` reports the same features for the current project in its `compose backend features` check. With `--json`, the check's `composeBackend.features` lists the closed names `external_network`, `external_volume`, `reserved_label`, diff --git a/packages/runtime-core/src/graph_cli.rs b/packages/runtime-core/src/graph_cli.rs index 277c38e32..359996c72 100644 --- a/packages/runtime-core/src/graph_cli.rs +++ b/packages/runtime-core/src/graph_cli.rs @@ -987,6 +987,7 @@ pub fn command(candidate: &Candidate, args: &[&str]) -> Result, + /// Set for a loopback TCP publication bridge: the reviewed host port and the + /// container target it forwards to. Absent for route and legacy bridges. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loopback_port: Option, } #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -55,7 +59,9 @@ fn validate(store: &Store, owner: &str, capacity: u8) -> Result<(), CandidateErr || !hex(&a.run, 32) || a.service.is_empty() || a.service.len() > 128 - || !services.insert((&a.run, &a.service)) + || !services.insert((&a.run, &a.service, a.loopback_port.map(|p| p.host_port))) + || a.loopback_port + .is_some_and(|p| p.host_port == 0 || p.target == 0) || !hex(&a.generation, 64) || !hex(&a.container_id, 64) || !hex(&a.network_id, 64) @@ -146,6 +152,16 @@ fn read_owner_registry( Ok(store) } +/// The endpoint a bridge forwards to: the route/probe endpoint, or for a +/// loopback publication the container's endpoint at that publication's target. +fn bridge_endpoint<'s>(snapshot: &'s Snapshot, a: &Assignment) -> Option<&'s GuestEndpoint> { + match a.loopback_port { + None => snapshot.guest_endpoints.get(&a.service), + Some(port) => snapshot + .port_endpoints + .get(&port_endpoint_key(&a.service, port.target)), + } +} fn matches_endpoint(a: &Assignment, e: &GuestEndpoint, boot: &str) -> bool { a.boot_id == boot && a.generation == e.generation @@ -239,11 +255,11 @@ fn select_route_slots( .collect::>(); let mut selected = BTreeMap::new(); for (service, choice) in requested { - if store - .slots - .values() - .any(|assignment| assignment.run == run && assignment.service == *service) - { + if store.slots.values().any(|assignment| { + assignment.run == run + && assignment.service == *service + && assignment.loopback_port.is_none() + }) { return Err(error( "bridge_slot_busy", "The service is already reserved; no reassignment was attempted.", @@ -309,6 +325,7 @@ fn reserve_selected( boot_id: boot_id.into(), phase: "reserved".into(), relay: None, + loopback_port: None, }; store.slots.insert(slot, assignment.clone()); reserved.insert(service, ReservedRoute { slot, assignment }); @@ -363,6 +380,117 @@ pub(in crate::provider::graph) fn reserve_routes( &snapshot.guest_endpoints, ) } +/// Reserve one bridge per declared loopback publication against one validated +/// graph snapshot, under the provider lease. Refusal writes no assignments. +/// Keys are reviewed host ports. +#[cfg(target_os = "macos")] +pub(in crate::provider::graph) fn reserve_loopback_ports( + candidate: &Candidate, + receipt: &Receipt, +) -> Result, CandidateError> { + let engine = Engine::connect_cleanup(candidate)?; + let capacity = engine + .guest() + .bridge_intent() + .ok_or_else(|| { + error( + "bridge_unavailable", + "The pool has no application bridge capacity.", + ) + })? + .slots; + let snapshot = inspect_using(candidate, &engine, &receipt.run)?; + if snapshot.receipt.owner != receipt.owner + || snapshot.receipt.plan_id != receipt.plan_id + || snapshot.receipt.namespace != receipt.namespace + || snapshot.receipt.phase != "ready-observed" + { + return Err(error( + "bridge_generation_stale", + "The reviewed graph changed before bridge reservation.", + )); + } + let store = load_store(candidate, &engine, false)?; + reserve_loopback_selected( + candidate, + store, + capacity, + &snapshot, + engine.guest().boot_id(), + ) +} + +#[cfg(any(test, target_os = "macos"))] +fn reserve_loopback_selected( + candidate: &Candidate, + mut store: Store, + capacity: u8, + snapshot: &Snapshot, + boot_id: &str, +) -> Result, CandidateError> { + let run = &snapshot.receipt.run; + let mut used = store + .slots + .keys() + .copied() + .collect::>(); + let mut reserved = BTreeMap::new(); + for resource in snapshot + .receipt + .resources + .values() + .filter(|r| r.kind == Kind::Container) + { + for port in &resource.loopback_ports { + if store.slots.values().any(|a| { + a.run == *run + && a.service == resource.key + && a.loopback_port.map(|p| p.host_port) == Some(port.host_port) + }) { + return Err(error( + "bridge_slot_busy", + "The published port is already reserved; no reassignment was attempted.", + )); + } + let endpoint = snapshot + .port_endpoints + .get(&port_endpoint_key(&resource.key, port.target)) + .ok_or_else(|| { + error( + "bridge_endpoint_unavailable", + "A published port requires a running, ready owned container endpoint.", + ) + })?; + let slot = (0..capacity).find(|slot| !used.contains(slot)).ok_or_else(|| { + error( + "bridge_capacity_exhausted", + "No free application bridge socket remains in this pool; no ports were reserved.", + ) + })?; + used.insert(slot); + let assignment = Assignment { + reservation: probes::token()?, + run: run.clone(), + service: resource.key.clone(), + generation: endpoint.generation.clone(), + container_id: endpoint.container_id.clone(), + network_id: endpoint.network_id.clone(), + boot_id: boot_id.into(), + phase: "reserved".into(), + relay: None, + loopback_port: Some(*port), + }; + store.slots.insert(slot, assignment.clone()); + reserved.insert(port.host_port, ReservedRoute { slot, assignment }); + } + } + if reserved.is_empty() { + return Ok(reserved); + } + validate(&store, &store.owner, capacity)?; + save(candidate, &store)?; + Ok(reserved) +} pub fn reserve_bridge( candidate: &Candidate, options: ReserveBridgeOptions<'_>, @@ -408,10 +536,9 @@ pub fn reserve_bridge( } let mut store = load_store(candidate, &engine, false)?; if store.slots.contains_key(&options.slot) - || store - .slots - .values() - .any(|a| a.run == options.run && a.service == options.service) + || store.slots.values().any(|a| { + a.run == options.run && a.service == options.service && a.loopback_port.is_none() + }) { return Err(error( "bridge_slot_busy", @@ -428,6 +555,7 @@ pub fn reserve_bridge( boot_id: engine.guest().boot_id().into(), phase: "reserved".into(), relay: None, + loopback_port: None, }; store.slots.insert(options.slot, assignment.clone()); save(candidate, &store)?; @@ -457,9 +585,7 @@ pub fn start_bridge( })? .clone(); let snapshot = inspect_using(candidate, &engine, run)?; - let endpoint = snapshot - .guest_endpoints - .get(&a.service) + let endpoint = bridge_endpoint(&snapshot, &a) .filter(|e| matches_endpoint(&a, e, engine.guest().boot_id())) .ok_or_else(|| { error( @@ -479,9 +605,7 @@ pub fn start_bridge( as u32; let start = relay::target_start(&engine, pid)?; let checked = inspect_using(candidate, &engine, run)?; - if !checked - .guest_endpoints - .get(&a.service) + if !bridge_endpoint(&checked, &a) .is_some_and(|e| matches_endpoint(&a, e, engine.guest().boot_id())) { return Err(error( @@ -509,9 +633,7 @@ pub fn start_bridge( save(candidate, &store)?; relay::operate(&engine, slot, &store.slots[&slot], "start", Some(&input))?; let checked = inspect_using(candidate, &engine, run)?; - if !checked - .guest_endpoints - .get(&a.service) + if !bridge_endpoint(&checked, &a) .is_some_and(|e| matches_endpoint(&a, e, engine.guest().boot_id())) { stop_slot(candidate, &engine, &mut store, slot)?; @@ -524,6 +646,27 @@ pub fn start_bridge( save(candidate, &store)?; Ok(store.slots[&slot].clone()) } +/// A loopback publication bridge authorizes exactly its reviewed host port over +/// TCP, forwarding to its reviewed target; never hostnames or another port. +fn validate_loopback_publication( + resource: &Resource, + loopback: LoopbackPort, + upstream_port: u16, + host_port: Option, + hostnames: &[String], +) -> Result<(), CandidateError> { + if !resource.loopback_ports.contains(&loopback) + || upstream_port != loopback.target + || host_port != Some(loopback.host_port) + || !hostnames.is_empty() + { + return Err(error( + "graph_port_publication", + "Publication differs from the graph's declared loopback port.", + )); + } + Ok(()) +} /// Declared routes authorize only their complete hostname set over Unix /// publication. Legacy un-enrolled services retain the existing publication API. fn validate_route_publication( @@ -585,9 +728,7 @@ pub fn publish_bridge( }) .ok_or_else(invalid)?; let snapshot = inspect_using(candidate, &engine, run)?; - if !snapshot - .guest_endpoints - .get(&a.service) + if !bridge_endpoint(&snapshot, a) .is_some_and(|e| matches_endpoint(a, e, engine.guest().boot_id())) || relay::operate(&engine, slot, a, "inspect", None)? != "running" { @@ -599,12 +740,16 @@ pub fn publish_bridge( .get(&format!("container:{}", a.service)) .filter(|resource| resource.kind == Kind::Container && resource.key == a.service) .ok_or_else(invalid)?; - validate_route_publication( - resource.routing.as_ref(), - a.relay.as_ref().ok_or_else(invalid)?.port, - port, - hostnames, - )?; + let upstream_port = a.relay.as_ref().ok_or_else(invalid)?.port; + match a.loopback_port { + Some(loopback) => { + validate_loopback_publication(resource, loopback, upstream_port, port, hostnames)?; + super::super::publication::require_loopback_port_free(loopback.host_port)?; + } + None => { + validate_route_publication(resource.routing.as_ref(), upstream_port, port, hostnames)? + } + } let upstream = engine .guest() .engine_socket()? @@ -692,7 +837,7 @@ pub(super) fn inspect_bridges_using( .iter() .filter(|(_, a)| a.run == run) .map(|(slot, a)| { - let current = snapshot.guest_endpoints.get(&a.service) + let current = bridge_endpoint(&snapshot, a) .is_some_and(|e| matches_endpoint(a, e, engine.guest().boot_id())); let status = if a.relay.is_some() && a.boot_id == engine.guest().boot_id() && a.phase != "stopped" { relay::operate(engine, *slot, a, "inspect", None)? @@ -817,6 +962,7 @@ mod tests { slots: BTreeMap::from([( 0, Assignment { + loopback_port: None, reservation: "a".repeat(32), run: "b".repeat(32), service: "web".into(), @@ -831,6 +977,90 @@ mod tests { } } #[test] + fn loopback_bridges_coexist_with_routes_but_own_one_host_port_each() { + let mut store = fixture(); + let loopback = |host_port, target| { + let mut a = store.slots[&0].clone(); + a.reservation = format!("{host_port:032x}"); + a.loopback_port = Some(LoopbackPort { host_port, target }); + a + }; + let first = loopback(8787, 3000); + let second = loopback(8788, 3000); + store.slots.insert(1, first.clone()); + store.slots.insert(2, second); + validate(&store, "owner", 4).unwrap(); + // The route bridge for the same service still counts as that service's route. + let requests = BTreeMap::from([("web".into(), RouteSlot::Auto)]); + assert_eq!( + select_route_slots(&store, &requests, 4, &"b".repeat(32)) + .unwrap_err() + .code, + "bridge_slot_busy" + ); + store.slots.remove(&0); + assert_eq!( + select_route_slots(&store, &requests, 4, &"b".repeat(32)).unwrap()["web"], + 0 + ); + let mut duplicate = first.clone(); + duplicate.reservation = "f".repeat(32); + store.slots.insert(3, duplicate); + assert!(validate(&store, "owner", 4).is_err()); + store.slots.remove(&3); + let mut zero = first; + zero.reservation = "e".repeat(32); + zero.loopback_port = Some(LoopbackPort { + host_port: 9999, + target: 0, + }); + store.slots.insert(3, zero); + assert!(validate(&store, "owner", 4).is_err()); + } + #[test] + fn loopback_publication_authorizes_only_its_reviewed_port_and_target() { + let port = LoopbackPort { + host_port: 8787, + target: 3000, + }; + let resource = Resource { + routing: None, + loopback_ports: vec![port], + networks: None, + outbound: false, + cache: None, + cache_provenance: None, + disposable_caches: Vec::new(), + kind: Kind::Container, + key: "web".into(), + name: "owned-web".into(), + id: None, + image: None, + phase: "running".into(), + }; + validate_loopback_publication(&resource, port, 3000, Some(8787), &[]).unwrap(); + for (upstream, host, hostnames) in [ + (3001, Some(8787), vec![]), + (3000, Some(8788), vec![]), + (3000, None, vec![]), + (3000, Some(8787), vec!["web.example".to_owned()]), + ] { + assert_eq!( + validate_loopback_publication(&resource, port, upstream, host, &hostnames) + .unwrap_err() + .code, + "graph_port_publication" + ); + } + let undeclared = LoopbackPort { + host_port: 9090, + target: 3000, + }; + assert!( + validate_loopback_publication(&resource, undeclared, 3000, Some(9090), &[]).is_err() + ); + } + #[test] fn automatic_routes_allocate_distinct_free_slots_without_stealing_foreign_owners() { let store = fixture(); let requests = BTreeMap::from([ diff --git a/packages/runtime-core/src/provider/graph/bridges/cleanup.rs b/packages/runtime-core/src/provider/graph/bridges/cleanup.rs index 5a445a588..9308d3e11 100644 --- a/packages/runtime-core/src/provider/graph/bridges/cleanup.rs +++ b/packages/runtime-core/src/provider/graph/bridges/cleanup.rs @@ -970,6 +970,7 @@ mod tests { selected.boot = "11111111-1111-1111-1111-111111111111".into(); selected.capacity = 3; let assignment = Assignment { + loopback_port: None, reservation: "d".repeat(32), run: selected.run.clone(), service: "web".into(), @@ -1252,6 +1253,7 @@ mod tests { selection.previous_boot = Some(previous.into()); selection.capacity = 2; let assignment = Assignment { + loopback_port: None, reservation: "d".repeat(32), run: selection.run.clone(), service: "web".into(), @@ -1396,6 +1398,7 @@ mod tests { 0, Selected { assignment: Assignment { + loopback_port: None, reservation: "d".repeat(32), run: current.run.clone(), service: "web".into(), @@ -1453,6 +1456,7 @@ mod tests { current.previous_boot = Some(old_boot.into()); current.capacity = 1; let assignment = Assignment { + loopback_port: None, reservation: "d".repeat(32), run: current.run.clone(), service: "web".into(), @@ -1543,6 +1547,7 @@ mod tests { prior.boot = old_boot.into(); prior.capacity = 1; let assignment = Assignment { + loopback_port: None, reservation: "d".repeat(32), run: prior.run.clone(), service: "web".into(), diff --git a/packages/runtime-core/src/provider/graph/cache_provenance.rs b/packages/runtime-core/src/provider/graph/cache_provenance.rs index 766d55e72..5dc6cc873 100644 --- a/packages/runtime-core/src/provider/graph/cache_provenance.rs +++ b/packages/runtime-core/src/provider/graph/cache_provenance.rs @@ -276,6 +276,7 @@ mod tests { } fn container() -> Resource { Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, @@ -368,6 +369,7 @@ mod tests { image: format!("sha256:{}", "d".repeat(64)), }; let volume = Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 257424239..87a91a6c2 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -112,6 +112,73 @@ pub(super) struct DeliveryOptions { pub environment: bool, pub dependency_hosts: bool, pub routing_enrolled: bool, + pub loopback_ports: bool, +} +/// Most loopback publications one graph may own. Each uses one application +/// bridge slot and one host listener, shared with route bridges. +pub(super) const MAX_LOOPBACK_PORTS: usize = 16; + +fn port_refused(message: &str) -> CandidateError { + error("graph_port_publication", message) +} + +/// Admit reviewed Compose `ports` of active services as owned host-loopback TCP +/// publications. Only the foreground owner, which starts and retires the host +/// publishers, admits them; every other path keeps refusing `ports` with +/// `graph_subset`. Review already narrowed all-interface bindings to loopback. +pub(super) fn loopback_publications( + plan: &project::PlanData, + enabled: bool, +) -> Result>, CandidateError> { + let mut result = BTreeMap::new(); + if !enabled { + return Ok(result); + } + let mut host_ports = BTreeSet::new(); + for (name, service) in plan.services.iter().filter(|(_, s)| s.active) { + let mut ports = Vec::new(); + for port in &service.ports { + if port.protocol != "tcp" { + return Err(port_refused( + "Native publishes only TCP ports; keep UDP publication on the Compose backend.", + )); + } + if port.proposed_host_ip != "127.0.0.1" { + return Err(port_refused( + "Native publishes ports only on IPv4 loopback (127.0.0.1); IPv6 loopback publication is not available yet.", + )); + } + let Some(host_port) = port.published else { + return Err(port_refused( + "Native requires an explicit published host port; an engine-assigned host port is not modeled.", + )); + }; + if service.network_mode != "project-networks" || service.networks.is_empty() { + return Err(port_refused( + "A published port requires a service attached to a project network.", + )); + } + if !host_ports.insert(host_port) { + return Err(port_refused( + "Two active publications request the same host port.", + )); + } + ports.push(LoopbackPort { + host_port, + target: port.target, + }); + } + ports.sort(); + if !ports.is_empty() { + result.insert(name.clone(), ports); + } + } + if host_ports.len() > MAX_LOOPBACK_PORTS { + return Err(port_refused( + "At most 16 published ports fit one native graph.", + )); + } + Ok(result) } pub(super) fn prepare_delivery( inputs: ExecutionInputs, @@ -125,6 +192,7 @@ pub(super) fn prepare_delivery( environment: delivery, dependency_hosts, routing_enrolled, + loopback_ports, } = options; if inputs.requires_managed_environment() && !delivery { return Err(unsupported()); @@ -179,13 +247,14 @@ pub(super) fn prepare_delivery( } else { BTreeMap::new() }; + let publications = loopback_publications(plan, loopback_ports)?; let mut networks = BTreeSet::new(); let mut volumes = BTreeSet::new(); let mut total_memory = 0u64; let mut total_cpus = 0f64; for service in plan.services.values().filter(|s| s.active) { if service.restart != "no" - || !service.ports.is_empty() + || (!service.ports.is_empty() && !loopback_ports) || service.labels.keys().any(|key| { !(routing_enrolled && service.routing.is_some() && routes::recognized(key)) && !(service.dependency_cache.is_some() @@ -305,6 +374,7 @@ pub(super) fn prepare_delivery( } let resource = Resource { routing: None, + loopback_ports: Vec::new(), networks: None, outbound: kind == Kind::Network && !plan.networks[&key].internal, cache: cache.cloned(), @@ -330,6 +400,7 @@ pub(super) fn prepare_delivery( let image = service.image.as_ref().expect("checked image"); let resource = Resource { routing: service.routing.clone(), + loopback_ports: publications.get(name).cloned().unwrap_or_default(), networks: Some(service.networks.clone()), outbound: false, cache: None, diff --git a/packages/runtime-core/src/provider/graph/environment/tests.rs b/packages/runtime-core/src/provider/graph/environment/tests.rs index 990348365..9a7c7c086 100644 --- a/packages/runtime-core/src/provider/graph/environment/tests.rs +++ b/packages/runtime-core/src/provider/graph/environment/tests.rs @@ -139,6 +139,7 @@ fn container_attachment_records_ownership_before_create_and_omits_values_from_me state::private_directory(&root).unwrap(); let engine = Engine::connect(&candidate).unwrap(); let resource = Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, diff --git a/packages/runtime-core/src/provider/graph/environment_crash_test.rs b/packages/runtime-core/src/provider/graph/environment_crash_test.rs index 6c7544f0b..4120dfe88 100644 --- a/packages/runtime-core/src/provider/graph/environment_crash_test.rs +++ b/packages/runtime-core/src/provider/graph/environment_crash_test.rs @@ -38,6 +38,7 @@ fn execute( shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: project(), expected_plan: &plan.plan_id, source_revision: None, diff --git a/packages/runtime-core/src/provider/graph/foreground/native_test/partial_stop.rs b/packages/runtime-core/src/provider/graph/foreground/native_test/partial_stop.rs index bcc5f3ae5..8d5519e99 100644 --- a/packages/runtime-core/src/provider/graph/foreground/native_test/partial_stop.rs +++ b/packages/runtime-core/src/provider/graph/foreground/native_test/partial_stop.rs @@ -52,6 +52,7 @@ fn run_options<'a>( shared_source: false, release_initializer_cache: BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(project_path), expected_plan: plan, source_revision: None, diff --git a/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs b/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs index f0321a270..4d96f8927 100644 --- a/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs +++ b/packages/runtime-core/src/provider/graph/foreground/native_test/same_boot_absent_relay.rs @@ -61,6 +61,7 @@ fn owner_child() { shared_source: false, release_initializer_cache: BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(&project), expected_plan: &plan.plan_id, source_revision: None, diff --git a/packages/runtime-core/src/provider/graph/foreground/publishers.rs b/packages/runtime-core/src/provider/graph/foreground/publishers.rs index 134efb8ff..7b0ffccd7 100644 --- a/packages/runtime-core/src/provider/graph/foreground/publishers.rs +++ b/packages/runtime-core/src/provider/graph/foreground/publishers.rs @@ -21,6 +21,15 @@ struct Owned { run: String, reservation: String, hostnames: Vec, + /// Reviewed host port of a loopback TCP publication; `None` for a route. + port: Option, +} + +/// What one publisher child serves: a route's hostname set over Unix, or one +/// reviewed loopback TCP port. +enum Frontend<'a> { + Hostnames(&'a [String]), + Port(u16), } #[derive(Default)] @@ -34,7 +43,7 @@ fn command( run: &str, slot: u8, reservation: &str, - hostnames: &[String], + frontend: Frontend<'_>, ) -> Command { let mut command = Command::new(executable); command @@ -46,9 +55,17 @@ fn command( .arg(checkout) .args(["graph", "publish-bridge", "--run-id", run, "--slot"]) .arg(slot.to_string()) - .args(["--expect-reservation", reservation, "--unix"]); - for hostname in hostnames { - command.args(["--hostname", hostname]); + .args(["--expect-reservation", reservation]); + match frontend { + Frontend::Hostnames(hostnames) => { + command.arg("--unix"); + for hostname in hostnames { + command.args(["--hostname", hostname]); + } + } + Frontend::Port(port) => { + command.arg("--port").arg(port.to_string()); + } } command } @@ -99,27 +116,137 @@ impl Publishers { { return Err(refused()); } - if routes.is_empty() { + let loopback = receipt + .resources + .values() + .flat_map(|resource| resource.loopback_ports.iter()) + .count(); + if loopback > graph::config::MAX_LOOPBACK_PORTS { + return Err(refused()); + } + if routes.is_empty() && loopback == 0 { return Ok(()); } + let executable = std::env::current_exe().map_err(|_| refused())?; + if !routes.is_empty() { + self.start_routes( + candidate, + receipt, + signals, + deadline, + slots, + &routes, + &executable, + )?; + } + if loopback > 0 { + self.start_loopback(candidate, receipt, signals, deadline, &executable)?; + } + self.verify_live(candidate) + } + + /// Loopback TCP publications start after routes, each through its own + /// reserved bridge. A refusal leaves earlier publishers owned by this set + /// for the caller's enrolled cleanup, as for routes. + fn start_loopback( + &mut self, + candidate: &Candidate, + receipt: &Receipt, + signals: &signals::Events, + deadline: Instant, + executable: &Path, + ) -> Result<(), CandidateError> { + check_deadline(signals, deadline)?; + let reserved = super::super::bridges::reserve_loopback_ports(candidate, receipt)?; + for (port, selected) in reserved { + check_deadline(signals, deadline)?; + let assignment = &selected.assignment; + graph::start_bridge( + candidate, + &receipt.run, + selected.slot, + &assignment.reservation, + )?; + check_deadline(signals, deadline)?; + let child = command( + executable, + &candidate.checkout, + &receipt.run, + selected.slot, + &assignment.reservation, + Frontend::Port(port), + ) + .spawn() + .map_err(|_| refused())?; + self.children.push(Owned { + child, + run: receipt.run.clone(), + reservation: assignment.reservation.clone(), + hostnames: Vec::new(), + port: Some(port), + }); + self.await_published(candidate, signals, deadline)?; + } + Ok(()) + } + + fn await_published( + &mut self, + candidate: &Candidate, + signals: &signals::Events, + deadline: Instant, + ) -> Result<(), CandidateError> { + let owned = self.children.last().ok_or_else(refused)?; + signals.watch_child(&owned.child)?; + loop { + check_deadline(signals, deadline)?; + if self + .children + .last_mut() + .ok_or_else(refused)? + .child + .try_wait() + .map_err(|_| refused())? + .is_some() + { + return Err(refused()); + } + let owned = self.children.last().ok_or_else(refused)?; + if published(candidate, owned).is_ok() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[allow(clippy::too_many_arguments)] + fn start_routes( + &mut self, + candidate: &Candidate, + receipt: &Receipt, + signals: &signals::Events, + deadline: Instant, + slots: &BTreeMap, + routes: &BTreeMap<&String, &crate::project::RoutingPlan>, + executable: &Path, + ) -> Result<(), CandidateError> { check_deadline(signals, deadline)?; let reserved = super::super::bridges::reserve_routes(candidate, receipt, slots)?; - let executable = std::env::current_exe().map_err(|_| refused())?; for (service, route) in routes { check_deadline(signals, deadline)?; - let selected = reserved.get(service).ok_or_else(refused)?; + let selected = reserved.get(*service).ok_or_else(refused)?; let slot = selected.slot; let assignment = &selected.assignment; check_deadline(signals, deadline)?; graph::start_bridge(candidate, &receipt.run, slot, &assignment.reservation)?; check_deadline(signals, deadline)?; let child = command( - &executable, + executable, &candidate.checkout, &receipt.run, slot, &assignment.reservation, - &route.hostnames, + Frontend::Hostnames(&route.hostnames), ) .spawn() .map_err(|_| refused())?; @@ -128,30 +255,11 @@ impl Publishers { run: receipt.run.clone(), reservation: assignment.reservation.clone(), hostnames: route.hostnames.clone(), + port: None, }); - let owned = self.children.last().ok_or_else(refused)?; - signals.watch_child(&owned.child)?; - loop { - check_deadline(signals, deadline)?; - if self - .children - .last_mut() - .ok_or_else(refused)? - .child - .try_wait() - .map_err(|_| refused())? - .is_some() - { - return Err(refused()); - } - let owned = self.children.last().ok_or_else(refused)?; - if published(candidate, owned).is_ok() { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } + self.await_published(candidate, signals, deadline)?; } - self.verify_live(candidate) + Ok(()) } /// Point-in-time publisher ownership, not guest health or TLS request proof. @@ -201,6 +309,14 @@ fn check_deadline(signals: &signals::Events, deadline: Instant) -> Result<(), Ca } fn published(candidate: &Candidate, owned: &Owned) -> Result<(), CandidateError> { + if let Some(port) = owned.port { + let value = publication::lookup_port(candidate, &owned.run, &owned.reservation, port)?; + return if value["state"] == "publication-observed" && value["port"] == port { + Ok(()) + } else { + Err(refused()) + }; + } for hostname in &owned.hostnames { let value = publication::lookup_hostname(candidate, hostname)?; if !matches_publication(&value, hostname, &owned.run, &owned.reservation) { @@ -254,7 +370,7 @@ mod tests { "run", 3, "reservation", - &["one.example".into(), "two.example".into()], + Frontend::Hostnames(&["one.example".into(), "two.example".into()]), ); let args = command .get_args() @@ -283,4 +399,38 @@ mod tests { assert_eq!(command.get_program(), "/candidate"); assert_eq!(command.get_envs().count(), 0); } + + #[test] + fn loopback_child_argv_names_only_its_reviewed_port() { + let command = command( + Path::new("/candidate"), + Path::new("/checkout"), + "run", + 4, + "reservation", + Frontend::Port(8787), + ); + let args = command + .get_args() + .map(|arg| arg.to_str().unwrap()) + .collect::>(); + assert_eq!( + args, + [ + "--candidate-root", + "/checkout", + "graph", + "publish-bridge", + "--run-id", + "run", + "--slot", + "4", + "--expect-reservation", + "reservation", + "--port", + "8787" + ] + ); + assert_eq!(command.get_envs().count(), 0); + } } diff --git a/packages/runtime-core/src/provider/graph/foreground/redelivery.rs b/packages/runtime-core/src/provider/graph/foreground/redelivery.rs index a1a441e31..7a2f114c2 100644 --- a/packages/runtime-core/src/provider/graph/foreground/redelivery.rs +++ b/packages/runtime-core/src/provider/graph/foreground/redelivery.rs @@ -24,6 +24,7 @@ pub(super) fn options<'a>(original: &RunOptions<'a>) -> RunOptions<'a> { shared_source: original.shared_source, release_initializer_cache: original.release_initializer_cache.clone(), routing_enrolled: original.routing_enrolled, + loopback_ports: original.loopback_ports, project: crate::project::PlanOptions { branch: original.project.branch, project: original.project.project, diff --git a/packages/runtime-core/src/provider/graph/host_relay.rs b/packages/runtime-core/src/provider/graph/host_relay.rs index fc1c221f6..e4521e4c6 100644 --- a/packages/runtime-core/src/provider/graph/host_relay.rs +++ b/packages/runtime-core/src/provider/graph/host_relay.rs @@ -1005,6 +1005,7 @@ mod tests { fn fixture() -> (Receipt, Resource, Value) { let receipt: Receipt = serde_json::from_value(json!({"version":1,"run":"a".repeat(32),"owner":"b".repeat(32),"namespace":"c".repeat(64),"plan_id":"d".repeat(64),"phase":"ready-observed","readiness":{},"resources":{}})).unwrap(); let resource = Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, diff --git a/packages/runtime-core/src/provider/graph/host_relay/live_tests.rs b/packages/runtime-core/src/provider/graph/host_relay/live_tests.rs index 80d8d93bb..4d07423f7 100644 --- a/packages/runtime-core/src/provider/graph/host_relay/live_tests.rs +++ b/packages/runtime-core/src/provider/graph/host_relay/live_tests.rs @@ -215,6 +215,7 @@ fn owned_graph_registration_live() -> Result<(), CandidateError> { shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(), expected_plan: &review.plan_id, source_revision: None, @@ -397,6 +398,7 @@ fn owned_graph_registration_live() -> Result<(), CandidateError> { shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(), expected_plan: &review.plan_id, source_revision: None, @@ -464,6 +466,7 @@ fn owned_graph_registration_live() -> Result<(), CandidateError> { shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(), expected_plan: &review.plan_id, source_revision: None, diff --git a/packages/runtime-core/src/provider/graph/hostname_change.rs b/packages/runtime-core/src/provider/graph/hostname_change.rs index 80a3382ab..be2021c8e 100644 --- a/packages/runtime-core/src/provider/graph/hostname_change.rs +++ b/packages/runtime-core/src/provider/graph/hostname_change.rs @@ -223,6 +223,7 @@ pub(super) mod tests { selection_sha256: snapshot.receipt().selection_sha256.clone(), }; let resource = |key: &str, kind, routing| Resource { + loopback_ports: Vec::new(), routing, networks: None, outbound: false, diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..788d72d20 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -209,12 +209,23 @@ impl Kind { } } } +/// One reviewed Compose `127.0.0.1:host_port:target/tcp` publication. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoopbackPort { + pub host_port: u16, + pub target: u16, +} #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Resource { /// Explicitly enrolled local HTTPS intent, not a claim of active publication. #[serde(default, skip_serializing_if = "Option::is_none")] pub routing: Option, + /// Admitted Compose `ports` published on host IPv4 loopback by the + /// foreground owner. Intent only; publication is a separate owned effect. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub loopback_ports: Vec, /// Ordered logical attachments; absent only on legacy container receipts or non-containers. #[serde(default, skip_serializing_if = "Option::is_none")] pub networks: Option>, @@ -276,8 +287,17 @@ pub struct Snapshot { pub interrupted_start_cleanup_incomplete: bool, pub observations: BTreeMap, pub guest_endpoints: BTreeMap, + /// Ready containers' loopback publication targets, keyed by + /// [`port_endpoint_key`]. Independent of HTTP probes: a published port + /// needs a running (and, when declared, healthy) owned container. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub port_endpoints: BTreeMap, pub storage_references: Value, } +/// Service names are Compose identifiers and never contain `:`. +pub(super) fn port_endpoint_key(service: &str, target: u16) -> String { + format!("{service}:{target}") +} fn labels(owner: &str, run: &str, namespace: &str, plan: &str, resource: &Resource) -> Value { json!({"io.hack-local.owner":owner,"io.hack-local.graph":run,"io.hack-local.namespace":namespace,"io.hack-local.plan":plan,"io.hack-local.kind":resource.kind.word(),"io.hack-local.resource":resource.key}) } @@ -446,6 +466,7 @@ fn same_resource_bindings( && old.cache == resource.cache && old.outbound == resource.outbound && old.routing == resource.routing + && old.loopback_ports == resource.loopback_ports && (old.networks == resource.networks // Old receipts predate attachment persistence and admitted at most // one network. The caller also requires the unchanged plan identity. @@ -455,6 +476,22 @@ fn same_resource_bindings( }) } +/// Receipt-load bound for loopback publication intent: containers only, unique +/// non-zero host ports graph-wide, and the admission budget. +fn loopback_ports_valid(resources: &BTreeMap) -> bool { + let mut host_ports = BTreeSet::new(); + resources.values().all(|resource| { + (resource.loopback_ports.is_empty() || resource.kind == Kind::Container) + && resource + .loopback_ports + .windows(2) + .all(|pair| pair[0] < pair[1]) + && resource.loopback_ports.iter().all(|port| { + port.host_port != 0 && port.target != 0 && host_ports.insert(port.host_port) + }) + }) && host_ports.len() <= config::MAX_LOOPBACK_PORTS +} + fn network_bindings_valid(resources: &BTreeMap) -> bool { let network_count = resources .values() @@ -551,6 +588,7 @@ fn load_at( resource.kind != Kind::Container || !routes::valid_intent(route) }) }) + || !loopback_ports_valid(&receipt.resources) || ![ "preparing", "ready-observed", @@ -639,6 +677,9 @@ pub struct RunOptions<'a> { pub release_initializer_cache: BTreeSet, /// Admit reviewed route intent; this does not itself publish an HTTPS route. pub routing_enrolled: bool, + /// Admit reviewed loopback TCP `ports`. Only the foreground owner, which + /// starts and retires the host publishers, may set this. + pub loopback_ports: bool, pub project: PlanOptions<'a>, pub expected_plan: &'a str, pub source_revision: Option<&'a str>, @@ -1307,6 +1348,7 @@ fn run_inputs( environment: !environments.is_empty(), dependency_hosts: startup.is_some(), routing_enrolled: options.routing_enrolled, + loopback_ports: options.loopback_ports, }, )?; initializer_cache::validate_selection( @@ -1570,6 +1612,7 @@ fn inspect_using( networks.insert(key.clone(), inspect_resource(engine, &receipt, resource)?); } let mut guest_endpoints = BTreeMap::new(); + let mut port_endpoints = BTreeMap::new(); for (key, resource) in &receipt.resources { let inspected = if resource.kind == Kind::Network { networks.get(key).expect("network inspected").clone() @@ -1604,6 +1647,34 @@ fn inspect_using( )?, ); } + if !journal_incomplete + && receipt.phase == "ready-observed" + && resource.id.is_some() + && !resource.loopback_ports.is_empty() + && matches!( + observation, + Observation::Running { + health: Health::Healthy | Health::None + } + ) + && endpoint_network(&receipt, &v, &networks)?.is_some() + { + for port in &resource.loopback_ports { + port_endpoints.insert( + port_endpoint_key(&resource.key, port.target), + endpoints::resolve_attached( + &v, + &networks, + port.target, + &endpoints::EndpointScope { + receipt: &receipt, + boot: engine.guest().boot_id(), + service: &resource.key, + }, + )?, + ); + } + } serde_json::to_value(observation) .map_err(|_| error("graph_state", "Cannot encode observation."))? } @@ -1621,6 +1692,7 @@ fn inspect_using( interrupted_start_cleanup_incomplete, observations, guest_endpoints, + port_endpoints, storage_references, }) } diff --git a/packages/runtime-core/src/provider/graph/native/journal.rs b/packages/runtime-core/src/provider/graph/native/journal.rs index 75908f5f4..6f91fcc2e 100644 --- a/packages/runtime-core/src/provider/graph/native/journal.rs +++ b/packages/runtime-core/src/provider/graph/native/journal.rs @@ -324,6 +324,7 @@ impl Receipt { || network.name != format!("hkn-{run}-network-{index}") || network.image.is_some() || network.routing.is_some() + || !network.loopback_ports.is_empty() || network.networks.is_some() || network.outbound == *internal || network.cache.is_some() @@ -393,6 +394,7 @@ impl Receipt { .as_deref() .is_none_or(|image| !image_id(image)) || resource.routing.is_some() + || !resource.loopback_ports.is_empty() || resource.networks.as_ref() != Some(&expected_networks) || resource.outbound || resource.cache.is_some() diff --git a/packages/runtime-core/src/provider/graph/normalized.rs b/packages/runtime-core/src/provider/graph/normalized.rs index d3c80adbf..16c97544e 100644 --- a/packages/runtime-core/src/provider/graph/normalized.rs +++ b/packages/runtime-core/src/provider/graph/normalized.rs @@ -327,6 +327,7 @@ mod tests { shared_source: false, release_initializer_cache: BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: PlanOptions { branch: None, project: &project, diff --git a/packages/runtime-core/src/provider/graph/one_off.rs b/packages/runtime-core/src/provider/graph/one_off.rs index 2bd4990b4..8a7db1816 100644 --- a/packages/runtime-core/src/provider/graph/one_off.rs +++ b/packages/runtime-core/src/provider/graph/one_off.rs @@ -194,6 +194,7 @@ pub(super) fn recovery_selection( || resource.key != service || resource.name != intent.container_name || resource.routing.is_some() + || !resource.loopback_ports.is_empty() || resource.cache.is_some() || intent .container_id diff --git a/packages/runtime-core/src/provider/graph/one_off/normalization.rs b/packages/runtime-core/src/provider/graph/one_off/normalization.rs index 204e8d21c..fd2e5aaac 100644 --- a/packages/runtime-core/src/provider/graph/one_off/normalization.rs +++ b/packages/runtime-core/src/provider/graph/one_off/normalization.rs @@ -48,6 +48,7 @@ fn projection(before: &Receipt, job: &JobIntent) -> Result Receipt { resources: BTreeMap::from([( "container:web".into(), Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, diff --git a/packages/runtime-core/src/provider/graph/shutdown.rs b/packages/runtime-core/src/provider/graph/shutdown.rs index 011b2139b..f25ac1651 100644 --- a/packages/runtime-core/src/provider/graph/shutdown.rs +++ b/packages/runtime-core/src/provider/graph/shutdown.rs @@ -404,6 +404,7 @@ mod tests { fn fixture() -> (Resource, Value) { let resource = Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, diff --git a/packages/runtime-core/src/provider/graph/shutdown/native_test.rs b/packages/runtime-core/src/provider/graph/shutdown/native_test.rs index 2269616ed..75c52ff44 100644 --- a/packages/runtime-core/src/provider/graph/shutdown/native_test.rs +++ b/packages/runtime-core/src/provider/graph/shutdown/native_test.rs @@ -113,6 +113,7 @@ fn owned_graph_shutdown_signals_and_escalation() { shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(&fixture.0), expected_plan: &report.plan_id, source_revision: None, diff --git a/packages/runtime-core/src/provider/graph/shutdown/native_test/integrity.rs b/packages/runtime-core/src/provider/graph/shutdown/native_test/integrity.rs index f9292701a..90de66e78 100644 --- a/packages/runtime-core/src/provider/graph/shutdown/native_test/integrity.rs +++ b/packages/runtime-core/src/provider/graph/shutdown/native_test/integrity.rs @@ -357,6 +357,7 @@ fn owned_named_volume_integrity_survives_container_restart() { shared_source: false, release_initializer_cache: BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(&fixture.0), expected_plan: &plan.plan_id, source_revision: None, diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 11aa9ed0c..479eb318b 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -879,6 +879,7 @@ mod tests { resources: BTreeMap::from([( "container:web".into(), Resource { + loopback_ports: Vec::new(), kind: Kind::Container, key: "web".into(), name: format!("hkg-{run}-container-0"), diff --git a/packages/runtime-core/src/provider/graph/startup/native_test.rs b/packages/runtime-core/src/provider/graph/startup/native_test.rs index 53b5782c1..996ce62b1 100644 --- a/packages/runtime-core/src/provider/graph/startup/native_test.rs +++ b/packages/runtime-core/src/provider/graph/startup/native_test.rs @@ -305,6 +305,7 @@ fn options<'a>( shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: PlanOptions { branch: None, project: fixture, diff --git a/packages/runtime-core/src/provider/graph/startup_test.rs b/packages/runtime-core/src/provider/graph/startup_test.rs index d35270fa6..e5ed59601 100644 --- a/packages/runtime-core/src/provider/graph/startup_test.rs +++ b/packages/runtime-core/src/provider/graph/startup_test.rs @@ -20,6 +20,7 @@ fn options<'a>( shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: PlanOptions { branch: None, project: fixture, diff --git a/packages/runtime-core/src/provider/graph/storage_inventory.rs b/packages/runtime-core/src/provider/graph/storage_inventory.rs index 03c224c03..2fc85e742 100644 --- a/packages/runtime-core/src/provider/graph/storage_inventory.rs +++ b/packages/runtime-core/src/provider/graph/storage_inventory.rs @@ -202,6 +202,7 @@ mod tests { image: format!("sha256:{}", "c".repeat(64)), }; let resource = Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..d3cc821a4 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -1013,6 +1013,7 @@ fn fault_child() -> Result<(), CandidateError> { shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: options(), expected_plan: &review.plan_id, source_revision: None, @@ -1318,6 +1319,7 @@ fn native_http_graph_readiness_restart_restore_and_supervisor_loss() -> Result<( shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: fixture.options(), expected_plan: &review.plan_id, source_revision: None, @@ -1438,6 +1440,7 @@ kill -KILL "$1" shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: fixture.options(), expected_plan: &review.plan_id, source_revision: None, @@ -1640,6 +1643,7 @@ fn native_http_preserves_scoped_nonroot_delivery_and_fresh_restore() -> Result<( shared_source: false, release_initializer_cache: std::collections::BTreeSet::new(), routing_enrolled: false, + loopback_ports: false, project: fixture.options(), expected_plan: &review.plan_id, source_revision: None, @@ -1877,6 +1881,7 @@ fn dependency_cache_graphs_share_only_verified_binding_and_pin_restore_targets() #[test] fn outbound_network_is_pinned_and_requires_explicit_pool_capability() { let resource = Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: true, @@ -2019,6 +2024,7 @@ fn endpoint_selects_service_primary_not_unrelated_first_graph_network() { receipt.resources.insert( format!("network:{key}"), Resource { + loopback_ports: Vec::new(), routing: None, networks: None, outbound: false, @@ -2300,3 +2306,154 @@ fn shared_source_preserves_app_writes_but_freezes_cache_installer_inputs() { .is_err() ); } + +#[test] +fn loopback_ports_are_admitted_only_for_the_foreground_owner() { + let fixture = Fixture::new(); + let candidate_root = Fixture::new(); + let candidate = Candidate::discover(&candidate_root.0).unwrap(); + let image = format!("sha256:{}", "a".repeat(64)); + let prepare = |document: &Value, loopback_ports: bool| { + state::write(&fixture.0.join("compose.yaml"), document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + )?; + config::prepare_delivery( + inputs, + &goals(), + &"a".repeat(32), + &"b".repeat(32), + None, + config::DeliveryOptions { + loopback_ports, + ..Default::default() + }, + ) + }; + let mut published = compose(&image, "marker", false); + published["services"]["web"]["ports"] = json!(["127.0.0.1:8787:3000", "8788:3000"]); + // Every non-foreground path keeps refusing `ports`. + assert_eq!( + prepare(&published, false).err().map(|e| e.code), + Some("graph_subset") + ); + let prepared = prepare(&published, true).unwrap(); + assert_eq!( + prepared.resources["container:web"].loopback_ports, + vec![ + LoopbackPort { + host_port: 8787, + target: 3000 + }, + LoopbackPort { + host_port: 8788, + target: 3000 + } + ] + ); + assert!( + prepared.resources["container:init"] + .loopback_ports + .is_empty() + ); + // Publication is an owned host effect, never an engine port binding. + let host = &prepared.configs["web"]["HostConfig"]; + assert!(host.get("PortBindings").is_none() && host.get("PublishAllPorts").is_none()); + for (ports, check) in [ + (json!(["127.0.0.1:8787:3000/udp"]), "web"), + (json!(["[::1]:8787:3000"]), "web"), + (json!(["3000"]), "web"), + (json!(["8787:3000"]), "check"), + ] { + let mut document = compose(&image, "marker", false); + document["services"]["web"]["ports"] = json!(["8787:3000"]); + if check == "web" { + document["services"]["web"]["ports"] = ports.clone(); + } else { + document["services"][check]["ports"] = ports.clone(); + } + let code = prepare(&document, true).err().map(|e| e.code); + // Duplicate host ports across services are a review-time conflict. + let expected = if check == "web" { + "graph_port_publication" + } else { + "execution_incompatible" + }; + assert_eq!(code, Some(expected), "{ports}"); + } + let mut many = compose(&image, "marker", false); + many["services"]["web"]["ports"] = json!( + (0..17) + .map(|offset| format!("{}:3000", 9000 + offset)) + .collect::>() + ); + let review_of_many = { + state::write(&fixture.0.join("compose.yaml"), &many).unwrap(); + project::plan(&candidate, fixture.options()).unwrap() + }; + assert!( + review_of_many.plan.enrollment_compatible, + "{:?}", + review_of_many.plan.diagnostics + ); + assert_eq!( + prepare(&many, true).err().map(|e| e.code), + Some("graph_port_publication") + ); + assert!(!candidate.state_root.exists()); +} + +#[test] +fn receipt_loopback_intent_is_bounded_unique_and_container_only() { + let container = |key: &str, ports: Vec| Resource { + routing: None, + loopback_ports: ports, + networks: None, + outbound: false, + cache: None, + cache_provenance: None, + disposable_caches: Vec::new(), + kind: Kind::Container, + key: key.into(), + name: format!("owned-{key}"), + id: None, + image: None, + phase: "absent".into(), + }; + let port = |host_port, target| LoopbackPort { host_port, target }; + let resources = |entries: Vec| { + entries + .into_iter() + .map(|r| (format!("container:{}", r.key), r)) + .collect::>() + }; + assert!(loopback_ports_valid(&resources(vec![ + container("web", vec![port(8080, 80), port(8081, 80)]), + container("api", vec![port(9090, 90)]), + ]))); + for invalid in [ + vec![container("web", vec![port(8081, 80), port(8080, 80)])], + vec![container("web", vec![port(0, 80)])], + vec![container("web", vec![port(8080, 0)])], + vec![ + container("web", vec![port(8080, 80)]), + container("api", vec![port(8080, 90)]), + ], + vec![container( + "web", + (0..17).map(|offset| port(9000 + offset, 80)).collect(), + )], + ] { + assert!(!loopback_ports_valid(&resources(invalid))); + } + let mut network = container("net", vec![port(8080, 80)]); + network.kind = Kind::Network; + assert!(!loopback_ports_valid(&BTreeMap::from([( + "network:net".into(), + network + )]))); +} diff --git a/packages/runtime-core/src/provider/graph/volume_subpaths/native_test.rs b/packages/runtime-core/src/provider/graph/volume_subpaths/native_test.rs index 2d96a9e2f..8e729a8ae 100644 --- a/packages/runtime-core/src/provider/graph/volume_subpaths/native_test.rs +++ b/packages/runtime-core/src/provider/graph/volume_subpaths/native_test.rs @@ -33,6 +33,7 @@ fn launch(candidate: &Candidate, project: &Path, run_id: &str) -> Result Result<(), CandidateError> { identity::verify(&e.process, &observed, &e.process.executable, unsafe { libc::geteuid() })?; - if !e.unix || !verify_directory(e)? || !e.binary.as_ref().is_some_and(|b| b.ready) { + if !verify_directory(e)? || !e.binary.as_ref().is_some_and(|b| b.ready) { return Err(error()); } let file = OpenOptions::new() @@ -603,7 +603,12 @@ fn observe_publication(e: &Entry) -> Result<(), CandidateError> { if format!("{:x}", Sha256::digest(bytes)) != e.digest { return Err(error()); } - check_socket(e, "frontend", "HKPF1", &e.reservation, false)?; + // A TCP frontend has no filesystem identity. The publisher binds its + // control socket only after the frontend listener is bound and listening, + // so an owned control socket also proves the TCP listener exists. + if e.unix { + check_socket(e, "frontend", "HKPF1", &e.reservation, false)?; + } check_socket(e, "control", "HKPC1", &e.token, false)?; // A second observation refuses death or identity change during filesystem checks. let after = identity::observe(e.process.pid)?; @@ -653,6 +658,62 @@ pub fn lookup_hostname(c: &Candidate, value: &str) -> Result Result { + if !hex(run, 32) || !hex(reservation, 32) || port == 0 { + return Err(error()); + } + let owner = state::Owner::load(c)?; + let entries = load(c, &owner.token)?; + let entry = entries + .get(reservation) + .filter(|e| !e.unix && e.port == port && e.run == run) + .ok_or_else(error)?; + observe_publication(entry)?; + let after_owner = state::Owner::load(c)?; + let after = load(c, &after_owner.token)?; + verify_claim_snapshot(entry, &after, &after_owner.token)?; + Ok( + serde_json::json!({"host":"127.0.0.1","port":port,"run":entry.run, + "reservation":entry.reservation,"state":"publication-observed", + "scope":"point-in-time-host-publication"}), + ) +} + +fn port_in_use() -> CandidateError { + CandidateError::new( + "published_port_in_use", + "A published host port is already accepting connections on loopback; stop the other listener or change the published port. Nothing was published.", + ) +} + +/// Refuse a loopback publication whose host port already accepts loopback +/// connections. The publisher binds 127.0.0.1 with `SO_REUSEADDR`, which BSD +/// lets coexist with another process's wildcard listener on the same port; +/// that would silently take loopback traffic from it. A connect probe sees +/// exactly that case (and an exact 127.0.0.1 or dual-stack listener). +/// Point-in-time only: the publisher's own exclusive bind is the final check. +pub fn require_loopback_port_free(port: u16) -> Result<(), CandidateError> { + use std::net::{Ipv4Addr, SocketAddr, TcpStream}; + if port == 0 { + return Err(error()); + } + let address = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + match TcpStream::connect_timeout(&address, std::time::Duration::from_millis(250)) { + Ok(_) => Err(port_in_use()), + Err(error) if error.kind() == std::io::ErrorKind::ConnectionRefused => Ok(()), + // A loopback connect that neither connects nor is refused is not + // evidence that the port is free. + Err(_) => Err(port_in_use()), + } +} + pub(super) struct Launch<'a> { pub owner: &'a str, pub run: &'a str, @@ -779,6 +840,32 @@ pub(super) fn launch(c: &Candidate, options: Launch<'_>) -> Result<(), Candidate Err(state::io(failure)) } +#[cfg(test)] +mod port_probe_tests { + use super::*; + use std::net::{Ipv4Addr, TcpListener}; + + #[test] + fn loopback_probe_refuses_exact_and_wildcard_listeners_and_accepts_a_free_port() { + let exact = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = exact.local_addr().unwrap().port(); + assert_eq!( + require_loopback_port_free(port).unwrap_err().code, + "published_port_in_use" + ); + drop(exact); + require_loopback_port_free(port).unwrap(); + // A wildcard listener would share loopback traffic under SO_REUSEADDR. + let wildcard = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap(); + let port = wildcard.local_addr().unwrap().port(); + assert_eq!( + require_loopback_port_free(port).unwrap_err().code, + "published_port_in_use" + ); + assert!(require_loopback_port_free(0).is_err()); + } +} + #[cfg(all(test, target_os = "macos"))] mod tests { use super::*; diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..ba52ef3ba 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1199,6 +1199,50 @@ const contracts: readonly ModelContract[] = [ action: "Recover", fields: ["alive = TRUE", "recoveredLive = TRUE"], }, + { + // Loopback TCP publication of Compose `ports`: one listener per host + // port, no forwarding to a replaced container, no recovery of a live + // owner, no listener after acknowledged stop; see tests/models/tla/README.md. + name: "loopback-port-publication", + module: "LoopbackPort", + states: 1008, + invariant: "Safe", + action: "Recover", + fields: ["retiredLive = TRUE", "alive = [a |-> TRUE, b |-> TRUE]"], + additionalControls: [ + { + name: "no-probe", + negative: true, + action: "Publish", + fields: ["shared = TRUE", 'holders = {"a", "foreign"}'], + // TLC's two workers may report either owner as the second listener. + alternativeWitnesses: [ + { + action: "Publish", + fields: ["shared = TRUE", 'holders = {"b", "foreign"}'], + }, + ], + }, + { + name: "stale-target", + negative: true, + action: "Forward", + fields: ["staleForward = TRUE", "target = [a |-> 0, b |-> 0]"], + }, + { + name: "ack-before-close", + negative: true, + action: "Retire", + fields: ["ackedListening = TRUE", 'holders = {"a"}'], + alternativeWitnesses: [ + { + action: "Retire", + fields: ["ackedListening = TRUE", 'holders = {"b"}'], + }, + ], + }, + ], + }, { name: "publication", module: "Publication", diff --git a/src/backends/native-project-ports.ts b/src/backends/native-project-ports.ts new file mode 100644 index 000000000..ce1ae8061 --- /dev/null +++ b/src/backends/native-project-ports.ts @@ -0,0 +1,131 @@ +import { connect } from "node:net"; +import { HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; + +/** + * Native loopback TCP publication of Compose `ports`. + * + * The native runtime is the authority: it admits `ports` only for the + * foreground owner, only as TCP on 127.0.0.1 with an explicit host port, and + * checks every host port again under its provider lease before it binds. This + * module only sizes the bridge pool and refuses an occupied host port before + * graph admission, so a collision does not cost a full startup. + */ + +/** Most bridge sockets one pool request may ask for (routes plus ports). */ +const MAX_BRIDGE_SOCKETS = 32; + +function unsupported(): Error { + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: + "Native port publication supports at most 32 routes and published ports together; keep larger projects on the Compose backend.", + }); +} + +/** + * Count declared `ports` entries in every service, including inactive + * profiles. A conservative upper bound used only to size bridge capacity + * before review; unused capacity is harmless. + */ +export function nativePortPublicationCount( + specs: Readonly>>> +): number { + let count = 0; + for (const spec of Object.values(specs)) { + if (Array.isArray(spec.ports)) { + count += spec.ports.length; + } + } + return count; +} + +/** Bridge sockets for routes plus published ports, bounded by the pool limit. */ +export function nativeBridgeSocketCount(opts: { + readonly routes: number; + readonly ports: number; +}): number { + const total = opts.routes + opts.ports; + if (total > MAX_BRIDGE_SOCKETS) { + throw unsupported(); + } + return total; +} + +/** + * Host ports the reviewed plan publishes for active services. Only explicit + * IPv4 loopback TCP publications are returned; the runtime refuses any other + * active `ports` entry at admission. + */ +export function reviewedNativeLoopbackPorts(plan: unknown): number[] { + if (!(isRecord(plan) && isRecord(plan.services))) { + return []; + } + const ports = new Set(); + for (const service of Object.values(plan.services)) { + if ( + !( + isRecord(service) && + service.active === true && + Array.isArray(service.ports) + ) + ) { + continue; + } + for (const port of service.ports) { + if ( + isRecord(port) && + port.protocol === "tcp" && + port.proposed_host_ip === "127.0.0.1" && + Number.isInteger(port.published) && + Number(port.published) > 0 && + Number(port.published) <= 65_535 + ) { + ports.add(Number(port.published)); + } + } + } + return [...ports].sort((a, b) => a - b); +} + +/** Resolve true when something accepts a TCP connection on 127.0.0.1:port. */ +export function loopbackPortAccepts( + port: number, + timeoutMs = 250 +): Promise { + return new Promise((resolve) => { + const socket = connect({ host: "127.0.0.1", port }); + const finish = (accepting: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(accepting); + }; + socket.setTimeout(timeoutMs, () => finish(true)); + socket.once("connect", () => finish(true)); + socket.once("error", (error: NodeJS.ErrnoException) => { + // Only a refused connection proves no loopback listener. Anything else + // is not evidence that the port is free. + finish(error.code !== "ECONNREFUSED"); + }); + }); +} + +/** + * Refuse before graph admission when a published host port already accepts + * loopback connections. The existing listener is left untouched. This is a + * point-in-time check; the runtime repeats it under its lease and its own + * exclusive bind is final. + */ +export async function checkNativeLoopbackPorts( + ports: readonly number[], + accepts: (port: number) => Promise = loopbackPortAccepts +): Promise { + for (const port of ports) { + if (await accepts(port)) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: `Published host port ${port} is already in use on 127.0.0.1; the existing listener was left untouched. Stop it or change the published port.`, + }); + } + } +} diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..c968ac7a6 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -46,6 +46,12 @@ import { recoverNativeInterruptedStartupCleanup, } from "./native-project-interrupted-cleanup.ts"; import { validateNativeAllowedHosts } from "./native-project-network.ts"; +import { + checkNativeLoopbackPorts, + nativeBridgeSocketCount, + nativePortPublicationCount, + reviewedNativeLoopbackPorts, +} from "./native-project-ports.ts"; import { serveNativeProjectGraph } from "./native-project-process.ts"; import { selectNativeProjectRestore } from "./native-project-restore.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; @@ -343,7 +349,6 @@ export function prepareNativeProjectServices( typeof value.image !== "string" || value.build !== undefined || (value.extra_hosts !== undefined && !hasHostDependencies) || - value.ports !== undefined || value.logging !== undefined || (value.restart !== undefined && value.restart !== "no") || !hasOnlyNativeSupportedLabels(value.labels) @@ -1023,7 +1028,10 @@ export async function startNativeProject(opts: { invoke: deps.invoke, }); requireActiveStartup(opts.signal); - const bridgeCapacity = nativeBridgeCapacity(specs); + const bridgeCapacity = nativeBridgeSocketCount({ + routes: nativeBridgeCapacity(specs), + ports: nativePortPublicationCount(specs), + }); const artifact = join(dirname(opts.runtime.binary), "hack-relay-guest"); const artifactFile = Bun.file(artifact); if ( @@ -1206,6 +1214,11 @@ export async function startNativeProject(opts: { specs, capacity: bridgeCapacity, }); + // Refuse an occupied published host port before graph admission. + await checkNativeLoopbackPorts( + reviewedNativeLoopbackPorts(review.report.plan) + ); + requireActiveStartup(controller.signal); const runSelection = await selectNativeProjectRestore({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..8ec3c8df8 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -752,6 +752,7 @@ have canonical copies and were not duplicated. | authority-lifetime | 9 | Authority cannot outlive completed runtime shutdown; removing the startup lease permits a late bind | `hostname_authority.rs` startup lock and shutdown | | publication-journal | 50 | Recovery cannot adopt a live writer's pending entry; unsafe recovery violates that condition | `publication.rs` journal recovery | | publication | 16 | A live native publication retains its intent, directory and guest; early retirement breaks ownership | `publication.rs`, `publication_stage.rs` | +| loopback-port-publication | 1008 | One listener per published host port, forwarding only to the pinned container incarnation, recovery only for a dead owner, and no listener after acknowledged stop. Negative controls: recovery without the dead-owner check (`negative`), no loopback probe (`no-probe`), no endpoint identity check (`stale-target`), acknowledging stop before closing the listener (`ack-before-close`) | `publication.rs` (`require_loopback_port_free`, `release`), `graph/bridges.rs` loopback bridges, `graph/foreground/publishers.rs` | | fence-first | 24 | First recovery needs terminal prior state, no occupied slot, and complete evidence; ignoring occupancy fails | `graph/relay-fence.sh` initial recovery | | fence-pending | 7 | Recover only permitted pending transitions; accepting launching-to-stopped leaves a live process | `graph/relay-fence.sh` pending recovery | | relay-staging | 9 | Discarded preparation cannot contain a live child; discarding an authorized launch fails | `publication_stage.rs` and `graph/relay-fence.sh` launch ordering | @@ -763,6 +764,11 @@ other models use finite phase/boolean sets. No fairness or eventual-progress cla is made. The first-fence model checks an admission predicate, not an interleaving proof. Journal `skipped` is constrained by the recovery guard and its negative control specifically tests live-writer recovery, not every corruption condition. +Loopback port publication uses two owners and one foreign listener for one host +port, and one container replacement per publication. Publish is one step: the +real interval between the loopback probe and the publisher's own bind is not +modeled, so a foreign wildcard listener that binds inside it is a documented +residual risk rather than a checked property. OS process identity, file locks, fsync, symlink races, host mounts, credentials, network behavior and actual crash recovery require implementation tests and live qualification. A passing abstract model does not close those runtime gates. diff --git a/tests/models/tla/loopback-port-publication/LoopbackPort.tla b/tests/models/tla/loopback-port-publication/LoopbackPort.tla new file mode 100644 index 000000000..26f32c3af --- /dev/null +++ b/tests/models/tla/loopback-port-publication/LoopbackPort.tla @@ -0,0 +1,139 @@ +---------------- MODULE LoopbackPort ---------------- +(* Loopback TCP publication of one reviewed Compose host port. + + Owners "a" and "b" are foreground graph owners that both declare the same + host port; "foreign" is an unrelated host process. An owner publishes by + recording its publication entry and binding the host listener (one step: + `publication::launch` writes the entry before exec, and the real probe/bind + window is not modeled). The listener forwards through a bridge pinned to the + container incarnation that was current when it was published. + + Guards, each turned off by one negative control: + - EnforceProbe: refuse when any listener already accepts on loopback + (`publication::require_loopback_port_free`, TS preflight). + - EnforceIdentity: forward only to the pinned incarnation (bridge endpoint + generation and pinned relay target). + - EnforceOrder: close the host listener before retiring and acknowledging + stop (`publication::release` before guest bridge stop). + - EnforceDeadOwner: recovery retires an entry only for a dead owner + (dead-owner cleanup, `publisher::stop` by exact identity). *) +EXTENDS Naturals, FiniteSets +CONSTANTS EnforceProbe, EnforceIdentity, EnforceOrder, EnforceDeadOwner +Owners == {"a", "b"} +Entries == {"none", "bound", "stopping", "retired"} +VARIABLES holders, entry, alive, incarnation, target, + shared, staleForward, retiredLive, ackedListening +vars == <> +others == <> + +TypeOK == + /\ holders \subseteq (Owners \cup {"foreign"}) + /\ entry \in [Owners -> Entries] + /\ alive \in [Owners -> BOOLEAN] + /\ incarnation \in [Owners -> 0..1] + /\ target \in [Owners -> 0..1] + /\ {shared, staleForward, retiredLive, ackedListening} \subseteq BOOLEAN + +Init == + /\ holders = {} + /\ entry = [o \in Owners |-> "none"] + /\ alive = [o \in Owners |-> TRUE] + /\ incarnation = [o \in Owners |-> 0] + /\ target = [o \in Owners |-> 0] + /\ shared = FALSE /\ staleForward = FALSE + /\ retiredLive = FALSE /\ ackedListening = FALSE + +(* An unrelated wildcard listener appears or goes away. *) +ForeignBind == + /\ holders = {} + /\ holders' = {"foreign"} + /\ UNCHANGED <> +ForeignRelease == + /\ "foreign" \in holders + /\ holders' = holders \ {"foreign"} + /\ UNCHANGED <> + +(* The pool journal admits one live entry per host port across owners. *) +Publish(o) == + /\ alive[o] + /\ entry[o] \in {"none", "retired"} + /\ \A p \in Owners \ {o} : entry[p] \in {"none", "retired"} + /\ o \notin holders + /\ EnforceProbe => holders = {} + /\ shared' = (shared \/ holders # {}) + /\ holders' = holders \cup {o} + /\ entry' = [entry EXCEPT ![o] = "bound"] + /\ target' = [target EXCEPT ![o] = incarnation[o]] + /\ UNCHANGED <> + +(* The container is replaced (restart or recreate) while published. *) +Replace(o) == + /\ incarnation[o] = 0 + /\ incarnation' = [incarnation EXCEPT ![o] = 1] + /\ UNCHANGED <> + +(* A host connection is forwarded through the bridge. *) +Forward(o) == + /\ o \in holders + /\ entry[o] = "bound" + /\ EnforceIdentity => target[o] = incarnation[o] + /\ staleForward' = (staleForward \/ target[o] # incarnation[o]) + /\ UNCHANGED <> + +(* Ordered stop by the live owner: close the listener, then retire. *) +CloseListener(o) == + /\ alive[o] + /\ o \in holders + /\ entry[o] = "bound" + /\ holders' = holders \ {o} + /\ entry' = [entry EXCEPT ![o] = "stopping"] + /\ UNCHANGED <> +Retire(o) == + /\ alive[o] + /\ entry[o] \in IF EnforceOrder THEN {"stopping"} ELSE {"bound", "stopping"} + /\ EnforceOrder => o \notin holders + /\ ackedListening' = (ackedListening \/ o \in holders) + /\ entry' = [entry EXCEPT ![o] = "retired"] + /\ incarnation' = [incarnation EXCEPT ![o] = 0] + /\ UNCHANGED <> + +(* The owner dies; its publisher child may survive it. *) +Crash(o) == + /\ alive[o] + /\ alive' = [alive EXCEPT ![o] = FALSE] + /\ UNCHANGED <> +PublisherExit(o) == + /\ o \in holders + /\ holders' = holders \ {o} + /\ UNCHANGED <> + +(* Dead-owner cleanup stops the exact publisher, then retires the entry. *) +Recover(o) == + /\ entry[o] \in {"bound", "stopping"} + /\ EnforceDeadOwner => ~alive[o] + /\ retiredLive' = (retiredLive \/ alive[o]) + /\ holders' = holders \ {o} + /\ entry' = [entry EXCEPT ![o] = "retired"] + /\ UNCHANGED <> + +Next == + \/ ForeignBind \/ ForeignRelease + \/ \E o \in Owners : + \/ Publish(o) \/ Replace(o) \/ Forward(o) \/ CloseListener(o) + \/ Retire(o) \/ Crash(o) \/ PublisherExit(o) \/ Recover(o) + +(* Every owner listener is journaled, and the four safety properties hold: + one listener per host port, no forwarding to a replaced incarnation, no + retirement of a live owner's publication, no listener after acknowledged + stop. *) +ListenerJournaled == \A o \in Owners : o \in holders => entry[o] \in {"bound", "stopping"} +OneListener == Cardinality(holders) <= 1 +Safe == + /\ ListenerJournaled + /\ OneListener + /\ ~shared /\ ~staleForward /\ ~retiredLive /\ ~ackedListening + +Spec == Init /\ [][Next]_vars +==================================================== diff --git a/tests/models/tla/loopback-port-publication/ack-before-close.cfg b/tests/models/tla/loopback-port-publication/ack-before-close.cfg new file mode 100644 index 000000000..8f6bf4c02 --- /dev/null +++ b/tests/models/tla/loopback-port-publication/ack-before-close.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceProbe = TRUE + EnforceIdentity = TRUE + EnforceOrder = FALSE + EnforceDeadOwner = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/loopback-port-publication/negative.cfg b/tests/models/tla/loopback-port-publication/negative.cfg new file mode 100644 index 000000000..e05a5a058 --- /dev/null +++ b/tests/models/tla/loopback-port-publication/negative.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceProbe = TRUE + EnforceIdentity = TRUE + EnforceOrder = TRUE + EnforceDeadOwner = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/loopback-port-publication/no-probe.cfg b/tests/models/tla/loopback-port-publication/no-probe.cfg new file mode 100644 index 000000000..8c6f490fd --- /dev/null +++ b/tests/models/tla/loopback-port-publication/no-probe.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceProbe = FALSE + EnforceIdentity = TRUE + EnforceOrder = TRUE + EnforceDeadOwner = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/loopback-port-publication/positive.cfg b/tests/models/tla/loopback-port-publication/positive.cfg new file mode 100644 index 000000000..ba04d5a77 --- /dev/null +++ b/tests/models/tla/loopback-port-publication/positive.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceProbe = TRUE + EnforceIdentity = TRUE + EnforceOrder = TRUE + EnforceDeadOwner = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/loopback-port-publication/stale-target.cfg b/tests/models/tla/loopback-port-publication/stale-target.cfg new file mode 100644 index 000000000..1cc243853 --- /dev/null +++ b/tests/models/tla/loopback-port-publication/stale-target.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceProbe = TRUE + EnforceIdentity = FALSE + EnforceOrder = TRUE + EnforceDeadOwner = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/native-project-ports.test.ts b/tests/native-project-ports.test.ts new file mode 100644 index 000000000..6b37d287b --- /dev/null +++ b/tests/native-project-ports.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from "bun:test"; +import { createServer, type Server } from "node:net"; +import { + checkNativeLoopbackPorts, + loopbackPortAccepts, + nativeBridgeSocketCount, + nativePortPublicationCount, + reviewedNativeLoopbackPorts, +} from "../src/backends/native-project-ports.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; + +function listen(host: string): Promise<{ server: Server; port: number }> { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, host, () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("no address")); + return; + } + resolve({ server, port: address.port }); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +test("bridge sizing counts every declared port entry, including inactive profiles", () => { + const specs = { + web: { image: "web", ports: ["127.0.0.1:8787:8787", "8080:80"] }, + search: { image: "search", profiles: ["search"], ports: [{ target: 1 }] }, + worker: { image: "worker" }, + }; + expect(nativePortPublicationCount(specs)).toBe(3); + expect(nativeBridgeSocketCount({ routes: 2, ports: 3 })).toBe(5); + expect(nativeBridgeSocketCount({ routes: 16, ports: 16 })).toBe(32); + expect(() => nativeBridgeSocketCount({ routes: 17, ports: 16 })).toThrow( + HackCliError + ); +}); + +test("only active reviewed IPv4 loopback TCP publications are preflighted", () => { + const plan = { + services: { + web: { + active: true, + ports: [ + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: 8787 }, + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: 8080 }, + { protocol: "udp", proposed_host_ip: "127.0.0.1", published: 5353 }, + { protocol: "tcp", proposed_host_ip: "::1", published: 9000 }, + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: null }, + ], + }, + search: { + active: false, + ports: [ + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: 8108 }, + ], + }, + }, + }; + expect(reviewedNativeLoopbackPorts(plan)).toEqual([8080, 8787]); + expect(reviewedNativeLoopbackPorts(undefined)).toEqual([]); +}); + +test("an occupied published port is a coded refusal that leaves the listener alone", async () => { + const probed: number[] = []; + const failure = await checkNativeLoopbackPorts([8080, 8787], (port) => { + probed.push(port); + return Promise.resolve(port === 8787); + }).catch((error: unknown) => error); + expect(probed).toEqual([8080, 8787]); + expect(failure).toBeInstanceOf(HackCliError); + expect(failure).toMatchObject({ code: "E_CONFIG_INVALID" }); + expect(String((failure as Error).message)).toContain("8787"); + await checkNativeLoopbackPorts([8080], () => Promise.resolve(false)); +}); + +test("the loopback probe sees exact and wildcard listeners and a released port", async () => { + const exact = await listen("127.0.0.1"); + expect(await loopbackPortAccepts(exact.port)).toBe(true); + await close(exact.server); + expect(await loopbackPortAccepts(exact.port)).toBe(false); + const wildcard = await listen("0.0.0.0"); + expect(await loopbackPortAccepts(wildcard.port)).toBe(true); + await close(wildcard.server); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..ff046e6d0 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -488,9 +488,17 @@ test("foreground partial shutdown is reported without selecting failed-start rec expect(events).not.toContain("remove"); }); +test("published ports pass frontend preparation; the runtime admits them", () => { + const services = prepareNativeProjectServices({ + normalizedComposeJson: JSON.stringify({ + services: { web: { image: "busybox", ports: ["127.0.0.1:8080:80"] } }, + }), + } as NativeProjectInput); + expect(services.web?.ports).toEqual(["127.0.0.1:8080:80"]); +}); + test.each([ { build: "." }, - { ports: ["8080:80"] }, { restart: "always" }, ])("an unsupported service setting %j is a coded configuration refusal", (setting) => { const refusal = (() => { From 9bd0dd07942c4d70a88936afbccb93c7f1c3aa12 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:54:55 -0500 Subject: [PATCH 046/138] feat(native): owner-scoped host dependencies and automatic listener refresh Host dependency selections can name the Hack-owned process that runs a listener instead of a PID or executable path: `host_lifecycle` (a persistent up.before hook or lifecycle.processes entry in the instance's tmux lifecycle session) or `host_shared_helper` (an owned, ready lifecycle.sharedHelpers claim held by this instance). After hooks, the frontend proves the owner from its own ownership records, asks the new native `graph dependency-discover --host-port P --ancestor-pid A` for the one listener that is the owner or its descendant, re-proves the owner, and selects the listener as an executable selection at the discovered depth. Native review now accepts `host_ancestor_pid` / `host_ancestor_start_micros` and refuses any other anchor at that depth, so refresh follows replacements only beneath the owner. Adopted, detached or foreign listeners refuse with a closed message. The foreground graph owner also watches every admitted replaceable listener for exit (EVFILT_PROC, no scanning) and runs automatic refresh single-flight, retrying pre-effect refusals after a bounded 1..30 s backoff. A rebind that wrote its journal and did not complete closes automatic refresh for that owner; nothing is replayed. New TLA+ model dependency-auto-refresh with a negative control. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 42 +- docs/reference/v5-model-coverage.md | 3 +- packages/runtime-core/README.md | 9 + packages/runtime-core/src/graph_cli.rs | 24 +- packages/runtime-core/src/graph_cli/relay.rs | 90 ++- packages/runtime-core/src/main.rs | 1 + .../src/provider/graph/foreground.rs | 42 +- .../graph/foreground/dependency_refresh.rs | 219 ++++++ .../src/provider/graph/foreground/signals.rs | 124 +++- .../src/provider/graph/foreground/tests.rs | 22 + .../src/provider/graph/startup/runtime.rs | 27 + .../provider/graph/startup/runtime/rebind.rs | 18 + .../src/provider/host_endpoint.rs | 159 ++++- .../src/provider/host_endpoint/tests.rs | 118 ++++ scripts/lib/tla-runtime-models.ts | 14 + src/backends/native-dependency-owners.ts | 270 ++++++++ src/backends/native-project-dependencies.ts | 398 +++++++++-- .../native-project-restart-preflight.ts | 30 +- src/backends/native-project-start.ts | 20 + src/commands/project.ts | 7 + src/lib/shared-host-lifecycle.ts | 29 + tests/models/tla/README.md | 24 + .../dependency-auto-refresh/AutoRefresh.tla | 87 +++ .../tla/dependency-auto-refresh/negative.cfg | 6 + .../tla/dependency-auto-refresh/positive.cfg | 6 + tests/native-dependency-owners.test.ts | 628 ++++++++++++++++++ tests/native-project-start.test.ts | 74 +++ 27 files changed, 2390 insertions(+), 101 deletions(-) create mode 100644 src/backends/native-dependency-owners.ts create mode 100644 tests/models/tla/dependency-auto-refresh/AutoRefresh.tla create mode 100644 tests/models/tla/dependency-auto-refresh/negative.cfg create mode 100644 tests/models/tla/dependency-auto-refresh/positive.cfg create mode 100644 tests/native-dependency-owners.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..33325fad1 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1168,6 +1168,34 @@ Bindings sharing one listening transport must select the same executable, depth and reviewed ownership policy. A replaced ancestor, changed chain, adopted listener or inconsistent shared selection refuses before relay grants change. +A listener that Hack itself starts can instead be selected by its owner, with no +PID, path or depth in the file. Replace the authority fields with exactly one of: + +- `"host_lifecycle": "NAME"`: a persistent `up.before` hook (its `name`, or + `hook-N` when unnamed) or a `lifecycle.processes` entry of this instance. Hack + reads the pane PID from its lifecycle state and requires the live tmux session + to carry the recorded ownership token and that pane to be alive. zellij + sessions record no pane PID and refuse. +- `"host_shared_helper": "NAME"`: an enabled `lifecycle.sharedHelpers` entry + whose claim is `owned` and `ready`, lists this instance as a holder, and whose + PID and birth time are live. An adopted helper refuses. + +After the hooks, Hack asks native discovery for the one same-user exclusive IPv4 +loopback listener on `host_port` and requires it to be the owner process or its +descendant within eight ancestry links; it then proves the owner again. A +listener outside the owner's tree refuses with "Native host dependency listener +does not run under its selected owner"; this includes a listener the hook only +adopted, or one that detached (for example with `ssh -f`), even if it was started +by the hook. Use a `host_executable` selection for those. A listener deeper than +the owner is reviewed as an executable selection at the discovered depth whose +final ancestor must be exactly that owner process (PID and native start time), so +a refresh follows replacements only beneath it. An owner that is itself the +listener is a fixed-PID selection without replacement authority. Bindings sharing +a host port must name the same owner. The owner names never reach native review, +and the frontend never persists the resolved PIDs. Restart preflight resolves the +same owners against the running instance; a cleaned retry validates the intent +only and resolves owners after its hooks. + Startup allows up to 60 seconds total for persistent hooks to create all selected listeners, with cancellable waits and bounded read-only discovery requests. It retries only native endpoint-identity refusals; malformed selections and other @@ -1183,7 +1211,8 @@ With a packaged candidate, invoke it as: ./hack-native --candidate-root /absolute/private/candidate-home graph refresh-dependencies --run-id RUN_ID --json ``` -Only `host_executable` selections can follow a replacement, using the original +Only `host_executable` selections (including owner-scoped selections deeper than +their owner) can follow a replacement, using the original executable, host port and unchanged same-user ancestor selected by `host_supervisor_depth`, retaining the reviewed intermediate executable chain. A matching executable or port alone is insufficient. Missing or ambiguous listeners, @@ -1280,8 +1309,15 @@ Subprocess text and unknown codes remain omitted. These diagnostics do not confi whether refresh effects occurred or authorize replay; the command is not dispatched. `ps` and `logs` remain observations and do not request refresh. Authenticated relay traffic reports a stale endpoint to the foreground owner, which attempts bounded -refresh without idle polling. The affected application request can fail; it is -never replayed, and later traffic can use a verified replacement. The explicit +refresh without idle polling. The owner also watches each admitted replaceable +listener process for exit (a kernel exit notice, not a scan), so a replaced +listener is refreshed before the next request reaches it. Attempts run one at a +time. When no replacement is listening yet, the refusal happens before any +effect, and the owner retries after 1, 2, 4, 8, 15 and 30 seconds; after that only +a new stale notice re-arms it. Once a rebind has written its journal and not +completed, the owner never retries automatically. The affected application +request can fail; it is never replayed, and later traffic can use a verified +replacement. The explicit operation is also available before traffic. Refresh does not renew startup environment allocations. A lost reply or interrupted transition is never replayed. Partial transitions remain fenced and refuse new admission; inspect owned state and diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..4b86e5b1d 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -43,7 +43,7 @@ must be attached to the eventual exact integration revision. | Settled retained Compose route-attempt retirement; pinned references; interrupted deletion | **Coverage gap:** filesystem journal compaction is not modeled; native-route-stop covers a different stop protocol | Instance mutation lock and exact surviving claim witnesses in [journal compaction](../../src/lib/native-compose-route-claims.ts); [claim regressions](../../tests/native-compose-route-claims.test.ts) and [retained lifecycle integration](../../tests/native-compose-adoption-routing-generation.test.ts) | 24 successful operations keep two attempt directories and constant Linux descriptors; four integrated lifecycle operations check directory counts. Injected unlink failures cover same-store and fresh-store cleanup retries. Assumes cooperative same-user writers, the caller's mutation lock and working filesystem synchronization. No process-kill/power-loss durability proof or bound on unresolved/over-budget histories. | | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | -| Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | +| Dependency rebind, automatic refresh, incomplete initializer, terminal reuse | dependency-rebind, dependency-auto-refresh, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | | Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. | @@ -74,6 +74,7 @@ liveness/reachability controls have their own documented assumptions. | [previous-boot-shared-https](../../tests/models/tla/previous-boot-shared-https/ArchiveHttps.tla) | 92 | 4 | | [stopped-pool-startup](../../tests/models/tla/stopped-pool-startup/Resume.tla) | 71 | 3 | | [dependency-rebind](../../tests/models/tla/dependency-rebind/Rebind.tla) | 21 | 10 | +| [dependency-auto-refresh](../../tests/models/tla/dependency-auto-refresh/AutoRefresh.tla) | 141 | 2 | | [relay-lifecycle-barrier](../../tests/models/tla/relay-lifecycle-barrier/Barrier.tla) | 1,590 | 2 | | [relay-authorization](../../tests/models/tla/relay-authorization/Authorization.tla) | 7 | 2 | | [mcp-startup-cancellation](../../tests/models/tla/mcp-startup-cancellation/Cancellation.tla) | 10 | 2 | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 84c58a6df..a8b45b159 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -914,6 +914,15 @@ selection. Discovery refuses a missing, ambiguous, wildcard, shared or wrong-executable listener. It does not authorize a port by itself; `dependency-plan` and `serve` recapture the selected process and socket generation. +For a listener started by a process the frontend owns, `graph dependency-discover +--host-port PORT --ancestor-pid PID --json` returns the one same-user exclusive +listener on that port only when it is the selected owner process or descends from +it within eight ancestry links, with its executable, depth and the owner's native +start time. A listener outside that tree refuses as `host_endpoint_owner`; a +missing or ambiguous one as `host_endpoint_identity`. The frontend then selects +`host_executable` and `host_supervisor_depth` plus `host_ancestor_pid` and +`host_ancestor_start_micros`; review refuses any other anchor at that depth. + Run `graph serve` with the usual `graph run` project/file/plan/run/readiness options, plus `--dependencies FILE --expect-dependencies ID`. It remains foreground, prints one compact `graph_foreground_ready` JSON line after startup, and prints its final diff --git a/packages/runtime-core/src/graph_cli.rs b/packages/runtime-core/src/graph_cli.rs index 277c38e32..e5fd2d3a8 100644 --- a/packages/runtime-core/src/graph_cli.rs +++ b/packages/runtime-core/src/graph_cli.rs @@ -20,7 +20,7 @@ use std::{collections::BTreeMap, path::Path, time::Duration}; fn invalid() -> CandidateError { CandidateError::new( "graph_arguments", - "Use graph run|restart|restore with --project, --file, --expect-plan, --run-id and --ready service=started|healthy|completed; planning actions accept optional --branch . Source mounts may select --source-revision. Fresh run/serve may explicitly select --release-initializer-cache service for quiescent guest page/dentry cache release (package contents retained); explicit --live-source binds directory mounts to that initial acknowledged workspace revision; inspect/reconcile/cleanup/archive/export/reconcile-export/prune require --run-id. Cleanup alone may use --remove-data. Dead-owner recover-cleanup and same-boot recover-live-owner require --expect-receipt; retire-recovered-publisher requires --expect-owner. A missing publisher lock requires inspect-missing-publication-lock --run-id and explicitly selected retire-missing-publication-lock --run-id --expect-selection --accept-quiesced-candidate-home. Foreground serve may opt into --auto-dependency-slots for pool-wide transport allocation; dependency-reservations inspects claims and recover-dependency-reservation --run-id --expect-reservation releases a verified dead pre-admission claim. Foreground serve additionally requires --dependencies and --expect-dependencies and accepts explicit --environment-stdin and --route-slot service=index|auto for reviewed local routes; dependency-plan requires --dependencies; dependency-discover requires --host-port and --executable; owner-status requires --run-id. Fresh foreground owner-restore requires exactly --run-id, --expect-plan, --expect-generation and --environment-stdin, plus optional --json. Bridge reservation requires --run-id, --service, --slot and --expect-generation; start/release require --run-id, --slot and --expect-reservation. bridges/reconcile-bridges require --run-id. Foreground publish-bridge requires --run-id, --slot, --expect-reservation and exactly one of --port or --unix (no --json); --unix accepts up to eight --hostname claims; unpublish-bridge requires --run-id and --expect-reservation.", + "Use graph run|restart|restore with --project, --file, --expect-plan, --run-id and --ready service=started|healthy|completed; planning actions accept optional --branch . Source mounts may select --source-revision. Fresh run/serve may explicitly select --release-initializer-cache service for quiescent guest page/dentry cache release (package contents retained); explicit --live-source binds directory mounts to that initial acknowledged workspace revision; inspect/reconcile/cleanup/archive/export/reconcile-export/prune require --run-id. Cleanup alone may use --remove-data. Dead-owner recover-cleanup and same-boot recover-live-owner require --expect-receipt; retire-recovered-publisher requires --expect-owner. A missing publisher lock requires inspect-missing-publication-lock --run-id and explicitly selected retire-missing-publication-lock --run-id --expect-selection --accept-quiesced-candidate-home. Foreground serve may opt into --auto-dependency-slots for pool-wide transport allocation; dependency-reservations inspects claims and recover-dependency-reservation --run-id --expect-reservation releases a verified dead pre-admission claim. Foreground serve additionally requires --dependencies and --expect-dependencies and accepts explicit --environment-stdin and --route-slot service=index|auto for reviewed local routes; dependency-plan requires --dependencies; dependency-discover requires --host-port and exactly one of --executable or --ancestor-pid; owner-status requires --run-id. Fresh foreground owner-restore requires exactly --run-id, --expect-plan, --expect-generation and --environment-stdin, plus optional --json. Bridge reservation requires --run-id, --service, --slot and --expect-generation; start/release require --run-id, --slot and --expect-reservation. bridges/reconcile-bridges require --run-id. Foreground publish-bridge requires --run-id, --slot, --expect-reservation and exactly one of --port or --unix (no --json); --unix accepts up to eight --hostname claims; unpublish-bridge requires --run-id and --expect-reservation.", ) } pub fn command(candidate: &Candidate, args: &[&str]) -> Result { @@ -564,6 +564,28 @@ pub fn command(candidate: &Candidate, args: &[&str]) -> Result().map_err(|_| invalid())?; + let ancestor = args[3].parse::().map_err(|_| invalid())?; + #[cfg(target_os = "macos")] + { + return relay::discover_descendant(port, ancestor); + } + #[cfg(not(target_os = "macos"))] + { + let _ = (port, ancestor); + return Err(CandidateError::new( + "unsupported_host", + "Graph dependency discovery requires macOS.", + )); + } + } if *action == "dependency-discover" { let (port, executable) = match *args { ["--host-port", port, "--executable", executable] diff --git a/packages/runtime-core/src/graph_cli/relay.rs b/packages/runtime-core/src/graph_cli/relay.rs index 37856710b..693d21ebd 100644 --- a/packages/runtime-core/src/graph_cli/relay.rs +++ b/packages/runtime-core/src/graph_cli/relay.rs @@ -48,6 +48,13 @@ struct Binding { skip_serializing_if = "Option::is_none" )] host_supervisor_depth: Option, + /// Owner-scoped selections pin the exact supervisor the frontend resolved from + /// its own ownership record. Both fields are present together, with an + /// executable selection; review refuses any other anchor at that depth. + #[serde(default, skip_serializing_if = "Option::is_none")] + host_ancestor_pid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + host_ancestor_start_micros: Option, } fn supervisor_depth<'de, D: serde::Deserializer<'de>>(decoder: D) -> Result, D::Error> { u8::deserialize(decoder).map(Some) @@ -107,6 +114,13 @@ impl Selection { }) && b.host_supervisor_depth .is_none_or(|depth| (1..=8).contains(&depth) && b.host_executable.is_some()) + && match (b.host_ancestor_pid, b.host_ancestor_start_micros) { + (None, None) => true, + (Some(pid), Some(start)) => { + pid > 1 && start > 0 && b.host_executable.is_some() + } + _ => false, + } && keys.insert((&b.service, &b.binding)) && slots .insert( @@ -116,6 +130,8 @@ impl Selection { b.host_port, &b.host_executable, b.host_supervisor_depth.unwrap_or(1), + b.host_ancestor_pid, + b.host_ancestor_start_micros, ), ) .is_none_or(|prior| { @@ -125,6 +141,8 @@ impl Selection { b.host_port, &b.host_executable, b.host_supervisor_depth.unwrap_or(1), + b.host_ancestor_pid, + b.host_ancestor_start_micros, ) }) && service_slots.insert((&b.service, b.slot)) @@ -174,12 +192,19 @@ impl Selection { .host_executable .as_ref() .map(|executable| { - graph::RefreshPolicy::capture( + let policy = graph::RefreshPolicy::capture( &endpoint, executable, binding.host_port, binding.host_supervisor_depth.unwrap_or(1), - ) + )?; + if let (Some(pid), Some(start)) = ( + binding.host_ancestor_pid, + binding.host_ancestor_start_micros, + ) { + policy.require_supervisor(pid, start)?; + } + Ok::<_, CandidateError>(policy) }) .transpose()?; let fingerprint = endpoint.fingerprint()?; @@ -221,6 +246,20 @@ pub(super) fn discover(executable: &Path, port: u16) -> Result Result { + let found = HostEndpoint::discover_descendant(port, ancestor_pid)?; + Ok(json!({ + "host_pid": found.pid, + "endpoint_fingerprint": found.fingerprint, + "executable": found.executable, + "supervisor_depth": found.depth, + "ancestor_pid": ancestor_pid, + "ancestor_start_micros": found.ancestor_start_micros, + })) +} pub(super) fn plan(path: &Path) -> Result { let selection = Selection::read(path)?; let (digest, _) = selection.capture()?; @@ -382,6 +421,53 @@ mod tests { assert!(Selection::parse(&serde_json::to_vec(&selected).unwrap()).is_err()); } #[test] + fn owner_anchor_requires_both_fields_and_an_executable_selection() { + let mut selected = value(); + selected["dependencies"].as_array_mut().unwrap().truncate(1); + selected["dependencies"][0]["host_executable"] = json!("/reviewed/tunnel"); + selected["dependencies"][0]["host_supervisor_depth"] = json!(3); + let parsed = Selection::parse(&serde_json::to_vec(&selected).unwrap()).unwrap(); + // An anchorless selection serializes exactly as before. + assert!( + !serde_json::to_string(&parsed) + .unwrap() + .contains("host_ancestor") + ); + selected["dependencies"][0]["host_ancestor_pid"] = json!(4242); + assert!(Selection::parse(&serde_json::to_vec(&selected).unwrap()).is_err()); + selected["dependencies"][0]["host_ancestor_start_micros"] = + json!(1_700_000_000_000_000_u64); + assert!(Selection::parse(&serde_json::to_vec(&selected).unwrap()).is_ok()); + for (key, bad) in [ + ("host_ancestor_pid", json!(1)), + ("host_ancestor_pid", json!(-4)), + ("host_ancestor_start_micros", json!(0)), + ("host_ancestor_start_micros", json!("1")), + ] { + let mut document = selected.clone(); + document["dependencies"][0][key] = bad; + assert!( + Selection::parse(&serde_json::to_vec(&document).unwrap()).is_err(), + "{key}" + ); + } + let mut fixed = selected.clone(); + let entry = fixed["dependencies"][0].as_object_mut().unwrap(); + entry.remove("host_executable"); + entry.remove("host_supervisor_depth"); + assert!(Selection::parse(&serde_json::to_vec(&fixed).unwrap()).is_err()); + // Bindings sharing one transport must pin the same owner. + let mut shared = selected["dependencies"][0].clone(); + shared["service"] = json!("worker"); + selected["dependencies"] + .as_array_mut() + .unwrap() + .push(shared); + assert!(Selection::parse(&serde_json::to_vec(&selected).unwrap()).is_ok()); + selected["dependencies"][1]["host_ancestor_pid"] = json!(4343); + assert!(Selection::parse(&serde_json::to_vec(&selected).unwrap()).is_err()); + } + #[test] fn review_digest_binds_configuration_and_live_listener_generation() { use std::net::TcpListener; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 0ab86ffde..6c439072d 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -50,6 +50,7 @@ Usage: hack-local graph dependency-reservations [--json] hack-local graph recover-dependency-reservation --run-id <32-hex> --expect-reservation [--json] hack-local graph dependency-discover --host-port --executable [--json] + hack-local graph dependency-discover --host-port --ancestor-pid [--json] hack-local graph serve --project --file --expect-plan --run-id <32-hex> --ready ... --dependencies --expect-dependencies [--source-revision ] [--live-source] [--profile ] [--timeout-seconds ] [--json] hack-local graph owner-status --run-id <32-hex> [--json] hack-local graph refresh-dependencies --run-id <32-hex> [--json] diff --git a/packages/runtime-core/src/provider/graph/foreground.rs b/packages/runtime-core/src/provider/graph/foreground.rs index d4c64b761..a2315dce0 100644 --- a/packages/runtime-core/src/provider/graph/foreground.rs +++ b/packages/runtime-core/src/provider/graph/foreground.rs @@ -308,16 +308,45 @@ fn serve_input<'a>( .map_err(|_| refused())?; drop(stdout); let mut queued = None; + let mut automatic = dependency_refresh::AutomaticRefresh::default(); + let mut watches = dependency_refresh::ListenerWatches::default(); + if watches.sync(&signals, &runtime) { + automatic.stale(Instant::now()); + } loop { - if queued.is_none() && signals.wait()? { - cleanup_attempted = true; - return runtime.cleanup(candidate, false); + if queued.is_none() { + match signals.wait_until(automatic.deadline())? { + signals::Wake::Signal => { + cleanup_attempted = true; + return runtime.cleanup(candidate, false); + } + // A watched refreshable listener exited: a stale notice + // without traffic. Its replacement may still be starting. + signals::Wake::ListenerExit => automatic.stale(Instant::now()), + signals::Wake::Ready | signals::Wake::Timeout => {} + } } if queued.is_none() && runtime.drain_dependency_notifications()? { // Authenticated traffic reports drift once; there is no idle scan. - // A refusal preserves the graph and leaves changed grants closed. - let _ = + automatic.stale(Instant::now()); + } + if queued.is_none() && automatic.take_due(Instant::now()) { + // One attempt at a time. A refusal preserves the graph and leaves + // changed grants closed; only a pre-effect refusal is retried, + // after a bounded backoff, and an unconfirmed rebind never is. + let result = dependency_refresh::automatic(candidate, &mut runtime, &receipt.run, &signals); + let outcome = dependency_refresh::automatic_outcome( + &result, + runtime.dependency_rebind_unconfirmed(), + ); + automatic.finish(outcome, Instant::now()); + if outcome == dependency_refresh::AutomaticOutcome::Refreshed + && watches.sync(&signals, &runtime) + { + automatic.stale(Instant::now()); + } + continue; } let (mut stream, request) = if let Some(pending) = queued.take() { pending @@ -352,6 +381,9 @@ fn serve_input<'a>( Err(error) => json!({"ok":false,"run":receipt.run,"code":error.code}), }; let _ = transport::write(&mut stream, &response, Duration::from_secs(5)); + if response["ok"] == true && watches.sync(&signals, &runtime) { + automatic.stale(Instant::now()); + } continue; } if let Some(job) = request.job { diff --git a/packages/runtime-core/src/provider/graph/foreground/dependency_refresh.rs b/packages/runtime-core/src/provider/graph/foreground/dependency_refresh.rs index 273c4292e..15594bad6 100644 --- a/packages/runtime-core/src/provider/graph/foreground/dependency_refresh.rs +++ b/packages/runtime-core/src/provider/graph/foreground/dependency_refresh.rs @@ -99,3 +99,222 @@ pub(super) fn automatic( signals.pending() || Instant::now() >= deadline }) } + +/// Bounded retry delays after a refresh that refused before any effect, for +/// example because a replacement listener is not listening yet. About one +/// minute in total; afterwards only a new stale notice re-arms refresh. +pub(super) const AUTOMATIC_RETRY_DELAYS: [Duration; 6] = [ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), + Duration::from_secs(15), + Duration::from_secs(30), +]; + +/// Outcome of one automatic refresh attempt, as the scheduler sees it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AutomaticOutcome { + /// The owner verified a refresh or an unchanged graph. + Refreshed, + /// Refused before the rebind journal: nothing changed, retry is safe. + RefusedBeforeEffects, + /// The rebind journal was written and the attempt did not complete, or the + /// refusal is not a known pre-effect one: never try again automatically. + Unconfirmed, +} + +/// Classify an automatic attempt. Only refusals whose effects are known not +/// to have started may be retried; everything else closes automatic refresh. +pub(super) fn automatic_outcome( + result: &Result, + rebind_unconfirmed: bool, +) -> AutomaticOutcome { + match result { + Ok(_) => AutomaticOutcome::Refreshed, + Err(_) if rebind_unconfirmed => AutomaticOutcome::Unconfirmed, + Err(error) + if matches!( + error.code, + "graph_dependency_refresh_refused" | "host_endpoint_identity" | "provider_busy" + ) => + { + AutomaticOutcome::RefusedBeforeEffects + } + Err(_) => AutomaticOutcome::Unconfirmed, + } +} + +/// Single-flight schedule for owner-side automatic dependency refresh. +/// +/// A stale notice (authenticated traffic drift or a watched listener's exit) +/// arms one attempt now. A pre-effect refusal re-arms it after a bounded +/// backoff; a verified refresh clears the backoff; an unconfirmed outcome +/// closes the schedule for this owner's lifetime, so no rebind is replayed. +/// Notices during a pending backoff do not reset or shorten it. The foreground +/// loop runs at most one attempt at a time and never polls without a notice. +#[derive(Debug, Default)] +pub(super) struct AutomaticRefresh { + due: Option, + retries: usize, + closed: bool, +} +impl AutomaticRefresh { + pub(super) fn stale(&mut self, now: Instant) { + if !self.closed && self.due.is_none() { + self.due = Some(now); + } + } + pub(super) fn deadline(&self) -> Option { + self.due + } + /// Consume a due attempt. The caller must run exactly one attempt and then + /// report it with [`Self::finish`]. + pub(super) fn take_due(&mut self, now: Instant) -> bool { + if self.due.is_some_and(|due| due <= now) { + self.due = None; + return true; + } + false + } + pub(super) fn finish(&mut self, outcome: AutomaticOutcome, now: Instant) { + match outcome { + AutomaticOutcome::Refreshed => self.retries = 0, + AutomaticOutcome::RefusedBeforeEffects => { + if let Some(delay) = AUTOMATIC_RETRY_DELAYS.get(self.retries) { + self.due = Some(now + *delay); + self.retries += 1; + } else { + self.retries = 0; + } + } + AutomaticOutcome::Unconfirmed => { + self.closed = true; + self.due = None; + } + } + } + #[cfg(test)] + pub(super) fn closed(&self) -> bool { + self.closed + } +} + +/// Exit watches for the admitted refreshable listeners. Watches follow the +/// current admitted endpoints after every verified refresh; a listener that is +/// already gone when watched counts as a stale notice. A watch is only a +/// trigger: failing to register one never fails the owner, because +/// authenticated traffic still reports drift. +#[derive(Default)] +pub(super) struct ListenerWatches { + watched: BTreeSet, +} +impl ListenerWatches { + pub(super) fn sync(&mut self, signals: &signals::Events, runtime: &HostRelayRuntime) -> bool { + let current = runtime.refreshable_listener_identities(); + let pids: BTreeSet = current.iter().map(|identity| identity.pid).collect(); + for stale in self.watched.difference(&pids) { + let _ = signals.unwatch_listener_exit(*stale); + } + let mut gone = false; + for identity in ¤t { + if matches!(signals.watch_listener_exit(identity), Ok(false)) { + gone = true; + } + } + self.watched = pids; + gone + } +} + +#[cfg(test)] +mod automatic_tests { + use super::*; + + fn refused(code: &'static str) -> Result { + Err(CandidateError::new(code, "synthetic refusal")) + } + + #[test] + fn only_pre_effect_refusals_are_retryable() { + assert_eq!( + automatic_outcome(&Ok(json!({"ok": true})), false), + AutomaticOutcome::Refreshed + ); + for code in [ + "graph_dependency_refresh_refused", + "host_endpoint_identity", + "provider_busy", + ] { + assert_eq!( + automatic_outcome(&refused(code), false), + AutomaticOutcome::RefusedBeforeEffects, + "{code}" + ); + // Once the rebind journal exists, the same code is not pre-effect. + assert_eq!( + automatic_outcome(&refused(code), true), + AutomaticOutcome::Unconfirmed, + "{code}" + ); + } + for code in [ + "graph_dependency_rebind_launch", + "graph_dependency_rebind_cleanup", + "graph_owner_recovery", + "graph_cancelled", + ] { + assert_eq!( + automatic_outcome(&refused(code), false), + AutomaticOutcome::Unconfirmed, + "{code}" + ); + } + } + + #[test] + fn schedule_is_single_flight_bounded_and_never_replays_unconfirmed() { + let start = Instant::now(); + let mut schedule = AutomaticRefresh::default(); + assert!(!schedule.take_due(start)); + schedule.stale(start); + assert!(schedule.take_due(start)); + // While an attempt runs no second attempt is due, even with new notices. + schedule.stale(start); + assert_eq!(schedule.deadline(), Some(start)); + assert!(schedule.take_due(start)); + let mut now = start; + let mut attempts = 1; + schedule.finish(AutomaticOutcome::RefusedBeforeEffects, now); + for delay in AUTOMATIC_RETRY_DELAYS.iter().skip(1) { + let due = schedule.deadline().unwrap(); + // A notice during backoff neither resets nor shortens it. + schedule.stale(now); + assert_eq!(schedule.deadline(), Some(due)); + assert!(!schedule.take_due(due - Duration::from_millis(1))); + now = due; + assert!(schedule.take_due(now)); + attempts += 1; + schedule.finish(AutomaticOutcome::RefusedBeforeEffects, now); + assert_eq!(schedule.deadline(), Some(now + *delay)); + } + now = schedule.deadline().unwrap(); + assert!(schedule.take_due(now)); + attempts += 1; + schedule.finish(AutomaticOutcome::RefusedBeforeEffects, now); + // Exhausted: bounded, and nothing is scheduled without a new notice. + assert_eq!(attempts, AUTOMATIC_RETRY_DELAYS.len() + 1); + assert_eq!(schedule.deadline(), None); + schedule.stale(now); + assert!(schedule.take_due(now)); + schedule.finish(AutomaticOutcome::Refreshed, now); + assert_eq!(schedule.deadline(), None); + schedule.stale(now); + assert!(schedule.take_due(now)); + schedule.finish(AutomaticOutcome::Unconfirmed, now); + assert!(schedule.closed()); + schedule.stale(now); + assert_eq!(schedule.deadline(), None); + assert!(!schedule.take_due(now + Duration::from_secs(3600))); + } +} diff --git a/packages/runtime-core/src/provider/graph/foreground/signals.rs b/packages/runtime-core/src/provider/graph/foreground/signals.rs index f7da29916..90a2c4d67 100644 --- a/packages/runtime-core/src/provider/graph/foreground/signals.rs +++ b/packages/runtime-core/src/provider/graph/foreground/signals.rs @@ -3,7 +3,20 @@ use super::{CandidateError, refused, transport::Publication}; use std::{ os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd}, sync::atomic::{AtomicBool, Ordering}, + time::Instant, }; +/// Marks EVFILT_PROC registrations for host dependency listeners. Publisher +/// child watches keep a null tag, and their exit remains fatal to the owner. +const LISTENER_EXIT: usize = 1; +/// One foreground wake. `Ready` covers every readable descriptor (control +/// listener and dependency notices); the caller re-reads them non-blockingly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::provider::graph) enum Wake { + Signal, + Ready, + ListenerExit, + Timeout, +} static OWNED: AtomicBool = AtomicBool::new(false); static PENDING: AtomicBool = AtomicBool::new(false); extern "C" fn notified(_: libc::c_int) { @@ -135,6 +148,79 @@ impl Events { pub fn pending(&self) -> bool { PENDING.load(Ordering::Acquire) } + /// Watch one admitted host dependency listener for exit, without adopting or + /// signalling it. The identity is rechecked after registration, so a PID that + /// already exited (or was reused) reports `false`: treat it as already stale. + pub fn watch_listener_exit( + &self, + identity: &crate::provider::identity::ProcessIdentity, + ) -> Result { + let change = libc::kevent { + ident: usize::try_from(identity.pid).map_err(|_| refused())?, + filter: libc::EVFILT_PROC, + flags: libc::EV_ADD | libc::EV_ENABLE | libc::EV_ONESHOT, + fflags: libc::NOTE_EXIT, + data: 0, + udata: LISTENER_EXIT as *mut libc::c_void, + }; + // SAFETY: owned queue and initialized change remain valid for this call. + let result = unsafe { + libc::kevent( + self.queue.as_raw_fd(), + &change, + 1, + std::ptr::null_mut(), + 0, + std::ptr::null(), + ) + }; + if result < 0 { + return match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Ok(false), + _ => Err(refused()), + }; + } + let current = crate::provider::identity::observe(identity.pid); + if current.as_ref().ok() != Some(identity) { + // The PID is gone or reused: stale either way. The one-shot watch + // can only fire for that PID, so a failed removal is harmless. + let _ = self.unwatch_listener_exit(identity.pid); + return Ok(false); + } + Ok(true) + } + /// Remove a listener watch that is no longer admitted. An already delivered + /// one-shot watch or an exited process is not an error. + pub fn unwatch_listener_exit(&self, pid: i32) -> Result<(), CandidateError> { + let change = libc::kevent { + ident: usize::try_from(pid).map_err(|_| refused())?, + filter: libc::EVFILT_PROC, + flags: libc::EV_DELETE, + fflags: libc::NOTE_EXIT, + data: 0, + udata: std::ptr::null_mut(), + }; + // SAFETY: owned queue and initialized change remain valid for this call. + let result = unsafe { + libc::kevent( + self.queue.as_raw_fd(), + &change, + 1, + std::ptr::null_mut(), + 0, + std::ptr::null(), + ) + }; + if result < 0 + && !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ENOENT | libc::ESRCH) + ) + { + return Err(refused()); + } + Ok(()) + } /// The runtime retains this notification descriptor for this queue's lifetime. pub fn watch_read(&self, descriptor: BorrowedFd<'_>) -> Result<(), CandidateError> { let change = libc::kevent { @@ -161,14 +247,30 @@ impl Events { } Ok(()) } + /// Untimed wait: `true` for a termination signal. The compose foreground + /// loop uses [`Self::wait_until`] for its bounded refresh retries. + #[cfg(any(test, feature = "native-config-plan"))] pub fn wait(&self) -> Result { + Ok(self.wait_until(None)? == Wake::Signal) + } + /// Wait for one event, or until `deadline` (a bounded retry time; never an + /// idle poll). Publisher child exit remains an error; a watched dependency + /// listener's exit is reported as `ListenerExit`. + pub fn wait_until(&self, deadline: Option) -> Result { loop { if PENDING.load(Ordering::Acquire) { - return Ok(true); + return Ok(Wake::Signal); } + let timeout = deadline.map(|deadline| { + let remaining = deadline.saturating_duration_since(Instant::now()); + libc::timespec { + tv_sec: remaining.as_secs() as libc::time_t, + tv_nsec: remaining.subsec_nanos() as libc::c_long, + } + }); // SAFETY: kevent is plain C output storage filled by the kernel. let mut event: libc::kevent = unsafe { std::mem::zeroed() }; - // SAFETY: queue and writable single event remain live, no changelist. + // SAFETY: queue, writable single event and optional timeout remain live. let count = unsafe { libc::kevent( self.queue.as_raw_fd(), @@ -176,11 +278,13 @@ impl Events { 0, &mut event, 1, - std::ptr::null(), + timeout + .as_ref() + .map_or(std::ptr::null(), |timeout| timeout as *const libc::timespec), ) }; if PENDING.load(Ordering::Acquire) { - return Ok(true); + return Ok(Wake::Signal); } if count < 0 { if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { @@ -188,13 +292,23 @@ impl Events { } return Err(refused()); } + if count == 0 && deadline.is_some() { + return Ok(Wake::Timeout); + } if count != 1 || event.flags & libc::EV_ERROR != 0 { return Err(refused()); } if event.filter == libc::EVFILT_PROC { + if event.udata as usize == LISTENER_EXIT { + return Ok(Wake::ListenerExit); + } return Err(refused()); } - return Ok(event.filter == libc::EVFILT_SIGNAL); + return Ok(if event.filter == libc::EVFILT_SIGNAL { + Wake::Signal + } else { + Wake::Ready + }); } } } diff --git a/packages/runtime-core/src/provider/graph/foreground/tests.rs b/packages/runtime-core/src/provider/graph/foreground/tests.rs index 919ff4278..c961859a2 100644 --- a/packages/runtime-core/src/provider/graph/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/foreground/tests.rs @@ -469,6 +469,28 @@ fn retained_publisher_exit_wakes_owner_and_retired_watch_does_not() { let mut notice = [0]; std::io::Read::read_exact(&mut receiver, &mut notice).unwrap(); assert_eq!(notice, [b'D']); + // A watched dependency listener's exit wakes the owner as a stale notice, + // not as a publisher failure, and a bounded retry deadline times out. + let mut listener = spawn(); + let identity = crate::provider::identity::observe(listener.id() as i32).unwrap(); + assert!(events.watch_listener_exit(&identity).unwrap()); + assert_eq!( + events + .wait_until(Some(Instant::now() + Duration::from_millis(20))) + .unwrap(), + signals::Wake::Timeout + ); + listener.kill().unwrap(); + assert_eq!( + events + .wait_until(Some(Instant::now() + Duration::from_secs(10))) + .unwrap(), + signals::Wake::ListenerExit + ); + listener.wait().unwrap(); + // An exited or reused identity is already stale and leaves no watch. + assert!(!events.watch_listener_exit(&identity).unwrap()); + events.unwatch_listener_exit(identity.pid).unwrap(); let _client = Pin::load(&candidate, &run).unwrap().connect().unwrap(); assert!(!events.wait().unwrap()); drop(events); diff --git a/packages/runtime-core/src/provider/graph/startup/runtime.rs b/packages/runtime-core/src/provider/graph/startup/runtime.rs index 5a032d372..e8d330c98 100644 --- a/packages/runtime-core/src/provider/graph/startup/runtime.rs +++ b/packages/runtime-core/src/provider/graph/startup/runtime.rs @@ -98,6 +98,9 @@ pub struct HostRelayRuntime { run: Option, selected_run: Option, startup_cancelled: Option bool>, + /// Sticky: a dependency rebind wrote its journal and did not complete. Its + /// effects are unconfirmed, so automatic refresh must never try again. + rebind_unconfirmed: bool, // Dropped after the managed listeners: only verified cleanup releases capacity. dependency_reservation: Option, } @@ -513,6 +516,7 @@ impl HostRelayRuntime { run: None, selected_run: selected_run.map(str::to_owned), startup_cancelled: None, + rebind_unconfirmed: false, dependency_reservation, }) } @@ -533,6 +537,29 @@ impl HostRelayRuntime { pub fn drain_dependency_notifications(&self) -> Result { self.managed.drain_notifications() } + /// True once any dependency rebind reached its journal without completing. + /// Callers that retry automatically must stop: the outcome is uncertain. + pub fn dependency_rebind_unconfirmed(&self) -> bool { + self.rebind_unconfirmed + } + /// Host PIDs of the currently admitted listeners that may follow a + /// replacement (executable or owner-scoped selections). Fixed-PID + /// selections carry no replacement authority and are omitted. This is a + /// selection of what to watch for exit, never authority to refresh. + #[cfg(target_os = "macos")] + pub fn refreshable_listener_identities( + &self, + ) -> Vec { + let mut identities: Vec<_> = self + .dependencies + .values() + .filter(|dependency| dependency.refresh.is_some()) + .map(|dependency| dependency.endpoint.process_identity()) + .collect(); + identities.sort_by_key(|identity| identity.pid); + identities.dedup(); + identities + } /// Stop only recorded listener generations, then use enrolled retirement and /// graph cleanup. A failed stop or uncertain journal remains retained. pub(in crate::provider::graph) fn admission_started(&self) -> bool { diff --git a/packages/runtime-core/src/provider/graph/startup/runtime/rebind.rs b/packages/runtime-core/src/provider/graph/startup/runtime/rebind.rs index c50bf2960..9047b7416 100644 --- a/packages/runtime-core/src/provider/graph/startup/runtime/rebind.rs +++ b/packages/runtime-core/src/provider/graph/startup/runtime/rebind.rs @@ -67,6 +67,21 @@ impl RefreshPolicy { }) } + /// Require that the captured anchor is exactly the owner process the caller + /// selected (by PID and native start time). Used for owner-scoped selections + /// (a lifecycle pane or shared-helper process), so review cannot pin some other + /// ancestor at the same depth after the tree changed. + pub fn require_supervisor(&self, pid: i32, start_micros: u64) -> Result<(), CandidateError> { + if pid <= 1 + || start_micros == 0 + || self.supervisor.pid != pid + || self.supervisor.start_micros != start_micros + { + return Err(rejected()); + } + Ok(()) + } + /// Binds native review to the selected anchor and intermediate executable /// chain as well as depth. Contains no process arguments or credentials. pub fn review_fingerprint(&self) -> Result { @@ -690,6 +705,8 @@ impl HostRelayRuntime { }) .collect(), }; + // From the first journal write on, a failure leaves effects unconfirmed. + self.rebind_unconfirmed = true; state::write(&root.join(JOURNAL), &journal)?; let mut fences: Vec<(u8, SlotFence)> = Vec::new(); let result = (|| { @@ -926,6 +943,7 @@ impl HostRelayRuntime { } return Err(original); } + self.rebind_unconfirmed = false; result } } diff --git a/packages/runtime-core/src/provider/host_endpoint.rs b/packages/runtime-core/src/provider/host_endpoint.rs index 334420a7a..7f9ff3089 100644 --- a/packages/runtime-core/src/provider/host_endpoint.rs +++ b/packages/runtime-core/src/provider/host_endpoint.rs @@ -202,35 +202,13 @@ impl HostEndpoint { pub fn discover(executable: &Path, port: u16) -> Result<(i32, String), CandidateError> { #[cfg(target_os = "macos")] { - const LIMIT: usize = 8192; - const PROC_ALL_PIDS: u32 = 1; if port == 0 || !executable.is_absolute() { return Err(refused()); } let expected = executable.canonicalize().map_err(|_| refused())?; - let mut pids = [0_i32; LIMIT]; - // SAFETY: libproc receives a writable fixed-size PID array and retains no pointer. - let bytes = unsafe { - libc::proc_listpids( - PROC_ALL_PIDS, - 0, - pids.as_mut_ptr().cast(), - std::mem::size_of_val(&pids) as i32, - ) - }; - if bytes <= 0 - || bytes as usize >= std::mem::size_of_val(&pids) - || bytes as usize % std::mem::size_of::() != 0 - { - return Err(refused()); - } let uid = unsafe { libc::geteuid() }; let mut found = None; - for pid in pids[..bytes as usize / std::mem::size_of::()] - .iter() - .copied() - .filter(|pid| *pid > 1) - { + for pid in listed_pids()? { let Ok(process) = super::identity::observe(pid) else { continue; }; @@ -257,6 +235,80 @@ impl HostEndpoint { } } + /// Discover the one same-user exclusive loopback listener on `port` and prove + /// that it is the selected owner process or descends from it within eight + /// same-user ancestry links. The owner is selected by the caller's own + /// ownership record (a lifecycle pane or shared-helper claim), never by port, + /// argv or a common-ancestor search. A missing or ambiguous listener refuses + /// as `host_endpoint_identity`; a listener outside the owner's process tree + /// (an adopted, daemonized or foreign listener) refuses as `host_endpoint_owner`. + /// The result is selection evidence only: native review pins it again. + pub fn discover_descendant( + port: u16, + ancestor_pid: i32, + ) -> Result { + #[cfg(target_os = "macos")] + { + if port == 0 || ancestor_pid <= 1 { + return Err(refused()); + } + let uid = unsafe { libc::geteuid() }; + let ancestor = super::identity::observe(ancestor_pid).map_err(|_| owner_refused())?; + if ancestor.uid != uid { + return Err(owner_refused()); + } + let mut found = None; + for pid in listed_pids()? { + let Ok(process) = super::identity::observe(pid) else { + continue; + }; + if process.uid != uid { + continue; + } + let Ok(endpoint) = Self::capture(pid, port) else { + continue; + }; + if found.is_some() { + return Err(refused()); + } + found = Some(endpoint); + } + let endpoint = found.ok_or_else(refused)?; + let process = endpoint.process_identity(); + let depth = if process.pid == ancestor.pid { + if process != ancestor { + return Err(owner_refused()); + } + 0 + } else { + descendant_depth(&process, &ancestor)? + }; + super::identity::verify( + &ancestor, + &super::identity::observe(ancestor.pid).map_err(|_| owner_refused())?, + &ancestor.executable, + uid, + ) + .map_err(|_| owner_refused())?; + let fingerprint = endpoint.fingerprint()?; + Ok(DescendantListener { + pid: process.pid, + fingerprint, + executable: process.executable, + depth, + ancestor_start_micros: ancestor.start_micros, + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (port, ancestor_pid); + Err(CandidateError::new( + "unsupported_host", + "Native host endpoint discovery requires macOS.", + )) + } + } + /// Validate the reviewed executable again when the graph captures a PID. pub fn require_executable(&self, executable: &Path) -> Result<(), CandidateError> { #[cfg(target_os = "macos")] @@ -542,6 +594,67 @@ impl PendingConnection { } } +/// A listener found beneath an explicitly selected owner process. `depth` is the +/// number of ancestry links from the listener to the owner (0 when the owner is +/// the listener itself). Contains no argv, environment or credential values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DescendantListener { + pub pid: i32, + pub fingerprint: String, + pub executable: PathBuf, + pub depth: u8, + pub ancestor_start_micros: u64, +} + +#[cfg(target_os = "macos")] +fn owner_refused() -> CandidateError { + CandidateError::new( + "host_endpoint_owner", + "The selected listener is not owned by the selected host process.", + ) +} + +/// Same-user PIDs above launchd from one bounded libproc snapshot. +#[cfg(target_os = "macos")] +fn listed_pids() -> Result, CandidateError> { + const LIMIT: usize = 8192; + const PROC_ALL_PIDS: u32 = 1; + let mut pids = vec![0_i32; LIMIT]; + let size = std::mem::size_of_val(pids.as_slice()); + // SAFETY: libproc receives a writable fixed-size PID array and retains no pointer. + let bytes = + unsafe { libc::proc_listpids(PROC_ALL_PIDS, 0, pids.as_mut_ptr().cast(), size as i32) }; + if bytes <= 0 || bytes as usize >= size || bytes as usize % std::mem::size_of::() != 0 { + return Err(refused()); + } + pids.truncate(bytes as usize / std::mem::size_of::()); + pids.retain(|pid| *pid > 1); + Ok(pids) +} + +/// The smallest ancestry depth (1..=8) at which `ancestor` is found. Each probe is +/// a complete double-checked lineage snapshot; a chain that ends, changes user or +/// reaches launchd before the owner refuses rather than searching elsewhere. +#[cfg(target_os = "macos")] +fn descendant_depth( + process: &super::identity::ProcessIdentity, + ancestor: &super::identity::ProcessIdentity, +) -> Result { + for depth in 1..=8_u8 { + let Ok(lineage) = super::identity::lineage(process, depth) else { + return Err(owner_refused()); + }; + if lineage.last() == Some(ancestor) { + return Ok(depth); + } + if lineage.iter().any(|parent| parent.pid == ancestor.pid) { + // Same PID with a different identity: the owner was replaced. + return Err(owner_refused()); + } + } + Err(owner_refused()) +} + #[cfg(target_os = "macos")] mod pending; diff --git a/packages/runtime-core/src/provider/host_endpoint/tests.rs b/packages/runtime-core/src/provider/host_endpoint/tests.rs index c99a32494..20e16eceb 100644 --- a/packages/runtime-core/src/provider/host_endpoint/tests.rs +++ b/packages/runtime-core/src/provider/host_endpoint/tests.rs @@ -898,3 +898,121 @@ fn closed_accepted_descriptor_cannot_prove_peer_identity() { "host_endpoint_timeout" ); } + +#[cfg(target_os = "macos")] +#[test] +fn owner_scoped_discovery_requires_a_listener_in_the_owner_tree() { + // Discovery scans every same-user process; keep the fixture away from parallel forks. + isolated_endpoint_fixture("provider::host_endpoint::tests::owner_scoped_discovery_child"); +} + +#[cfg(target_os = "macos")] +#[test] +#[ignore = "isolated discovery fixture launched by owner_scoped_discovery_requires_a_listener_in_the_owner_tree"] +fn owner_scoped_discovery_child() { + use std::process::{Child, Command, Stdio}; + struct OwnedChild(Child); + impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let own = super::super::identity::observe(std::process::id() as i32).unwrap(); + // The owner itself may be the listener (depth 0). + let (listener, endpoint) = fixture(); + let local = listener.local_addr().unwrap().port(); + let found = HostEndpoint::discover_descendant(local, own.pid).unwrap(); + assert_eq!(found.pid, own.pid); + assert_eq!(found.depth, 0); + assert_eq!(found.ancestor_start_micros, own.start_micros); + assert_eq!(found.fingerprint, endpoint.fingerprint().unwrap()); + drop(listener); + assert_eq!( + HostEndpoint::discover_descendant(local, own.pid) + .unwrap_err() + .code, + "host_endpoint_identity" + ); + + let executable = std::env::current_exe().unwrap().canonicalize().unwrap(); + let port = crate::provider::test_socket::unallocated_tcp_port(); + let mut child = OwnedChild( + Command::new(&executable) + .args([ + "--ignored", + "--exact", + "provider::host_endpoint::tests::discovery_listener_child", + "--test-threads=1", + ]) + .env("HACK_DISCOVERY_LISTENER_PORT", port.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + LOADED_EVENT_HANG_GUARD; + let found = loop { + if let Ok(found) = HostEndpoint::discover_descendant(port, own.pid) { + break found; + } + assert!( + child.0.try_wait().unwrap().is_none(), + "listener child exited" + ); + assert!( + Instant::now() < deadline, + "listener child was not discovered" + ); + thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(found.pid, child.0.id() as i32); + assert_eq!(found.depth, 1); + assert_eq!(found.executable, executable); + assert_eq!(found.ancestor_start_micros, own.start_micros); + + // Review pins exactly the resolved owner at the discovered depth. + let captured = HostEndpoint::capture(found.pid, port).unwrap(); + let policy = + crate::provider::graph::RefreshPolicy::capture(&captured, &executable, port, found.depth) + .unwrap(); + policy + .require_supervisor(own.pid, own.start_micros) + .unwrap(); + assert!( + policy + .require_supervisor(own.pid, own.start_micros + 1) + .is_err() + ); + assert!( + policy + .require_supervisor(found.pid, own.start_micros) + .is_err() + ); + + // A live same-user process that did not start the listener is not its owner. + let mut unrelated = OwnedChild( + Command::new("/bin/sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let unrelated_pid = unrelated.0.id() as i32; + assert_eq!( + HostEndpoint::discover_descendant(port, unrelated_pid) + .unwrap_err() + .code, + "host_endpoint_owner" + ); + assert!(HostEndpoint::discover_descendant(0, own.pid).is_err()); + assert!(HostEndpoint::discover_descendant(port, 1).is_err()); + unrelated.0.kill().unwrap(); + let _ = unrelated.0.wait(); + child.0.kill().unwrap(); + let _ = child.0.wait(); + assert!(HostEndpoint::discover_descendant(port, own.pid).is_err()); +} diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..7826c4fe5 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1137,6 +1137,20 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "dependency-auto-refresh", + module: "AutoRefresh", + states: 141, + invariant: "NoReplayAfterUnconfirmed", + action: "Start", + fields: [ + "replayed = TRUE", + "unconfirmed = TRUE", + "closed = FALSE", + 'journal = "none"', + "attempts = 2", + ], + }, { name: "relay-lifecycle-barrier", module: "Barrier", diff --git a/src/backends/native-dependency-owners.ts b/src/backends/native-dependency-owners.ts new file mode 100644 index 000000000..ba87e7989 --- /dev/null +++ b/src/backends/native-dependency-owners.ts @@ -0,0 +1,270 @@ +import { HackCliError } from "../lib/cli-result.ts"; +import { + type LifecycleStateEntry, + readLifecycleState, +} from "../lib/lifecycle-runtime.ts"; +import { + type ProjectSharedHelperConfig, + resolveSharedHelperHolder, + resolveSharedHelperStateDir, +} from "../lib/project-shared-helpers.ts"; +import { readOwnedSharedHostProcess } from "../lib/shared-host-lifecycle.ts"; +import { runMuxCommand } from "../mux/mux-backend.ts"; +import { createTmuxBackend } from "../mux/tmux-backend.ts"; + +/** + * Owner selectors for native host dependencies (`host_lifecycle` and + * `host_shared_helper` in a dependency selection). + * + * Ownership contract: + * - The owner is a process Hack itself started for this instance and still + * records: a persistent `up.before` hook or `lifecycle.processes` entry in the + * instance's tmux lifecycle session (pane PID, under a live session whose + * ownership token matches the lifecycle state), or a `lifecycle.sharedHelpers` + * entry with an `owned`, `ready` claim that lists this instance as a holder and + * whose PID and birth time are live. Adopted helpers, zellij sessions (no pane + * PID), sessions owned by another token and unknown names refuse. + * - Resolution yields only a PID anchor. Native discovery then requires the + * listener to be that process or its descendant, and native review pins the + * anchor's native start time. `confirm` repeats the ownership proof after + * discovery, so a pane or helper that exited (and whose PID could be reused) + * between the two observations refuses instead of granting a stranger. + * - Nothing here starts, adopts or signals a process, and refusals are closed + * messages that never include names, paths, PIDs or argv. + */ +export type NativeDependencyOwner = { + readonly kind: "lifecycle" | "sharedHelper"; + readonly name: string; +}; + +export type NativeDependencyOwnerProof = { + readonly pid: number; + /** Re-prove the same live owner; throws the closed owner refusal. */ + readonly confirm: () => Promise; +}; + +export type NativeDependencyOwnerResolver = ( + owner: NativeDependencyOwner +) => Promise; + +/** Fixed text: the named owner is not a live process Hack started for this instance. */ +export const NATIVE_DEPENDENCY_OWNER_UNAVAILABLE = + "Native host dependency owner is not a live lifecycle process or owned shared helper that Hack started for this instance; values omitted."; + +/** Fixed text: the listener exists but is not in the owner's process tree. */ +export const NATIVE_DEPENDENCY_OWNER_MISMATCH = + "Native host dependency listener does not run under its selected owner; adopted, detached or foreign listeners are refused. Values omitted."; + +export function nativeDependencyOwnerUnavailable(): HackCliError { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: NATIVE_DEPENDENCY_OWNER_UNAVAILABLE, + }); +} + +export function nativeDependencyOwnerMismatch(): HackCliError { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: NATIVE_DEPENDENCY_OWNER_MISMATCH, + }); +} + +/** True for the two closed owner refusals, which callers may surface unchanged. */ +export function isNativeDependencyOwnerRefusal(error: unknown): boolean { + return ( + error instanceof HackCliError && + (error.message === NATIVE_DEPENDENCY_OWNER_UNAVAILABLE || + error.message === NATIVE_DEPENDENCY_OWNER_MISMATCH) + ); +} + +type Pane = { readonly pid: number; readonly dead: boolean }; + +export type NativeDependencyOwnerReaders = { + readonly readState: (opts: { + readonly projectDir: string; + }) => Promise; + readonly readOwnerToken: (sessionName: string) => Promise; + /** Live panes of one lifecycle window; null when tmux cannot answer. */ + readonly readPanes: (opts: { + readonly sessionName: string; + readonly windowName: string; + }) => Promise; + readonly readHelper: (opts: { + readonly helper: ProjectSharedHelperConfig; + readonly holder: string; + }) => Promise<{ readonly pid: number; readonly birth: string } | null>; +}; + +async function readTmuxPanes(opts: { + readonly sessionName: string; + readonly windowName: string; +}): Promise { + const result = await runMuxCommand( + [ + "tmux", + "list-panes", + "-t", + `${opts.sessionName}:${opts.windowName}`, + "-F", + "#{pane_pid} #{pane_dead}", + ], + { stdin: "ignore" } + ); + if (result.exitCode !== 0) { + return null; + } + const panes: Pane[] = []; + for (const line of result.stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + const [pid, dead, extra] = trimmed.split(" "); + const parsed = Number(pid); + if ( + extra !== undefined || + !Number.isSafeInteger(parsed) || + parsed <= 1 || + (dead !== "0" && dead !== "1") + ) { + return null; + } + panes.push({ pid: parsed, dead: dead === "1" }); + } + return panes; +} + +const SYSTEM_READERS: NativeDependencyOwnerReaders = { + readState: readLifecycleState, + readOwnerToken: async (sessionName) => + (await createTmuxBackend().readLifecycleOwnerToken?.({ + name: sessionName, + })) ?? null, + readPanes: readTmuxPanes, + readHelper: ({ helper, holder }) => + readOwnedSharedHostProcess({ + spec: { + name: helper.name, + stateDir: resolveSharedHelperStateDir({ + name: helper.name, + port: helper.port, + }), + }, + holder, + }), +}; + +function lifecycleAnchor(opts: { + readonly entries: readonly LifecycleStateEntry[]; + readonly composeProject: string; + readonly name: string; +}) { + const entry = opts.entries.find( + (candidate) => candidate.composeProject === opts.composeProject + ); + if (!entry || entry.backend !== "tmux" || !entry.ownershipToken) { + throw nativeDependencyOwnerUnavailable(); + } + const matches = entry.processes.filter( + (process) => process.name === opts.name + ); + const process = matches[0]; + if (matches.length !== 1 || process?.panePid === undefined) { + throw nativeDependencyOwnerUnavailable(); + } + return { + sessionName: entry.sessionName, + ownershipToken: entry.ownershipToken, + windowName: process.windowName, + pid: process.panePid, + }; +} + +/** + * Resolve owner selectors for one instance. `sharedHelpers` is the instance's + * parsed `lifecycle.sharedHelpers`; a name that is not configured and enabled + * there refuses. + */ +export function createNativeDependencyOwnerResolver(opts: { + readonly projectDir: string; + readonly composeProject: string; + readonly sharedHelpers: readonly ProjectSharedHelperConfig[]; + readonly readers?: Partial; +}): NativeDependencyOwnerResolver { + const readers = { ...SYSTEM_READERS, ...opts.readers }; + const lifecycle = async ( + name: string + ): Promise => { + const prove = async () => { + const anchor = lifecycleAnchor({ + entries: await readers.readState({ projectDir: opts.projectDir }), + composeProject: opts.composeProject, + name, + }); + if ( + (await readers.readOwnerToken(anchor.sessionName)) !== + anchor.ownershipToken + ) { + throw nativeDependencyOwnerUnavailable(); + } + const panes = await readers.readPanes({ + sessionName: anchor.sessionName, + windowName: anchor.windowName, + }); + const pane = panes?.[0]; + if (panes?.length !== 1 || pane?.pid !== anchor.pid || pane.dead) { + throw nativeDependencyOwnerUnavailable(); + } + return anchor; + }; + const first = await prove(); + return { + pid: first.pid, + confirm: async () => { + const again = await prove(); + if ( + again.pid !== first.pid || + again.ownershipToken !== first.ownershipToken || + again.sessionName !== first.sessionName || + again.windowName !== first.windowName + ) { + throw nativeDependencyOwnerUnavailable(); + } + }, + }; + }; + const sharedHelper = async ( + name: string + ): Promise => { + const helpers = opts.sharedHelpers.filter( + (candidate) => candidate.name === name && candidate.enabled + ); + const helper = helpers[0]; + if (helpers.length !== 1 || !helper) { + throw nativeDependencyOwnerUnavailable(); + } + const holder = resolveSharedHelperHolder(opts.composeProject); + const prove = async () => { + const owned = await readers.readHelper({ helper, holder }); + if (!owned) { + throw nativeDependencyOwnerUnavailable(); + } + return owned; + }; + const first = await prove(); + return { + pid: first.pid, + confirm: async () => { + const again = await prove(); + if (again.pid !== first.pid || again.birth !== first.birth) { + throw nativeDependencyOwnerUnavailable(); + } + }, + }; + }; + return (owner) => + owner.kind === "lifecycle" + ? lifecycle(owner.name) + : sharedHelper(owner.name); +} diff --git a/src/backends/native-project-dependencies.ts b/src/backends/native-project-dependencies.ts index a0e05d5c6..38a9f3a71 100644 --- a/src/backends/native-project-dependencies.ts +++ b/src/backends/native-project-dependencies.ts @@ -2,7 +2,12 @@ import { isAbsolute } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; - +import { + isNativeDependencyOwnerRefusal, + type NativeDependencyOwner, + type NativeDependencyOwnerResolver, + nativeDependencyOwnerMismatch, +} from "./native-dependency-owners.ts"; import { readNativeSelection } from "./native-project-selection.ts"; import { invokeNativeRuntime, @@ -24,7 +29,22 @@ const FIELDS = new Set([ "host_port", "host_executable", "host_supervisor_depth", + "host_ancestor_pid", + "host_ancestor_start_micros", + "host_lifecycle", + "host_shared_helper", ]); +/** Resolved authority; a selection file names an owner instead of these. */ +const RESOLVED_OWNER_FIELDS = [ + "host_ancestor_pid", + "host_ancestor_start_micros", +] as const; +const OWNER_AUTHORITY_FIELDS = [ + "host_pid", + "host_executable", + "host_supervisor_depth", + ...RESOLVED_OWNER_FIELDS, +] as const; export type NativeHostDependency = { readonly service: string; @@ -36,6 +56,9 @@ export type NativeHostDependency = { readonly host_port: number; readonly host_executable?: string; readonly host_supervisor_depth?: number; + /** Owner-scoped selections: the exact owner process native review must pin. */ + readonly host_ancestor_pid?: number; + readonly host_ancestor_start_micros?: number; }; /** The guest relay launcher requires a reaping init process; honor an explicit refusal. */ @@ -96,6 +119,25 @@ function validSupervisorDepth(entry: Record): boolean { ); } +function validAncestor(entry: Record): boolean { + if ( + entry.host_ancestor_pid === undefined && + entry.host_ancestor_start_micros === undefined + ) { + return true; + } + return ( + entry.host_executable !== undefined && + typeof entry.host_ancestor_pid === "number" && + Number.isSafeInteger(entry.host_ancestor_pid) && + entry.host_ancestor_pid > 1 && + entry.host_ancestor_pid <= 2_147_483_647 && + typeof entry.host_ancestor_start_micros === "number" && + Number.isSafeInteger(entry.host_ancestor_start_micros) && + entry.host_ancestor_start_micros > 0 + ); +} + function hostAuthority(entry: Record) { return { ...(typeof entry.host_executable === "string" @@ -104,9 +146,90 @@ function hostAuthority(entry: Record) { ...(typeof entry.host_supervisor_depth === "number" ? { host_supervisor_depth: entry.host_supervisor_depth } : {}), + ...(typeof entry.host_ancestor_pid === "number" && + typeof entry.host_ancestor_start_micros === "number" + ? { + host_ancestor_pid: entry.host_ancestor_pid, + host_ancestor_start_micros: entry.host_ancestor_start_micros, + } + : {}), }; } +const OWNER_NAME = /^[^\s\p{Cc}](?:[^\p{Cc}]{0,126}[^\s\p{Cc}])?$/u; + +/** + * The owner selector a selection entry names, `undefined` when it names none, + * or `null` when the entry is invalid: both selectors, an empty or control- + * character name, or an owner combined with explicit or resolved authority. + */ +function ownerSelector( + entry: Record +): NativeDependencyOwner | undefined | null { + const lifecycle = entry.host_lifecycle; + const helper = entry.host_shared_helper; + if (lifecycle === undefined && helper === undefined) { + return undefined; + } + const name = lifecycle ?? helper; + if ( + (lifecycle !== undefined && helper !== undefined) || + typeof name !== "string" || + !OWNER_NAME.test(name) || + OWNER_AUTHORITY_FIELDS.some((field) => entry[field] !== undefined) + ) { + return null; + } + return { kind: lifecycle === undefined ? "sharedHelper" : "lifecycle", name }; +} + +/** + * Validate a selection file's complete public intent without live authority: + * owner and executable entries receive a provisional PID that never reaches + * native review. Resolved owner fields are refused here: only owner + * resolution and native discovery may supply them. Entries sharing a host + * port must name the same owner selector. + */ +export function provisionalNativeHostDependencies(opts: { + readonly value: unknown; + readonly services: readonly string[]; +}): NativeHostDependency[] { + const value = opts.value; + if (!(isRecord(value) && Array.isArray(value.dependencies))) { + throw refused(); + } + const owners = new Map(); + const dependencies = value.dependencies.map((entry) => { + if (!isRecord(entry)) { + return entry; + } + const owner = ownerSelector(entry); + if ( + owner === null || + RESOLVED_OWNER_FIELDS.some((field) => entry[field] !== undefined) + ) { + throw refused(); + } + const selected = JSON.stringify(owner ?? null); + const prior = owners.get(entry.host_port); + if (prior !== undefined && prior !== selected) { + throw refused(); + } + owners.set(entry.host_port, selected); + if (owner) { + const { host_lifecycle, host_shared_helper, ...rest } = entry; + return { ...rest, host_pid: 2 }; + } + return entry.host_executable === undefined + ? entry + : { ...entry, host_pid: entry.host_pid ?? 2 }; + }); + return parseNativeHostDependencies({ + value: { ...value, dependencies }, + services: opts.services, + }); +} + function requireSameEndpointPolicy(opts: { readonly policies: Map; readonly endpoint: string; @@ -118,6 +241,8 @@ function requireSameEndpointPolicy(opts: { : [ opts.authority.host_executable, opts.authority.host_supervisor_depth ?? 1, + opts.authority.host_ancestor_pid ?? null, + opts.authority.host_ancestor_start_micros ?? null, ] ); const prior = opts.policies.get(opts.endpoint); @@ -177,6 +302,8 @@ export function parseNativeHostDependencies(opts: { entry.host_executable.length <= 1024 && !entry.host_executable.includes("\0"))) && validSupervisorDepth(entry) && + validAncestor(entry) && + ownerSelector(entry) !== null && Array.isArray(entry.aliases) && entry.aliases.length > 0 && entry.aliases.length <= 8 && @@ -233,10 +360,68 @@ export function parseNativeHostDependencies(opts: { }); } -/** Read after lifecycle hooks so a hook can supply current listener PIDs; never persist authority. */ -export async function readNativeHostDependencies(opts: { - readonly path?: string; - readonly services: readonly string[]; +/** Live owner-scoped discovery evidence; native review pins it again. */ +export type NativeOwnedDiscovery = { + readonly host_pid: number; + readonly endpoint_fingerprint: string; + readonly executable: string; + readonly supervisor_depth: number; + readonly ancestor_pid: number; + readonly ancestor_start_micros: number; +}; + +function validPid(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value > 1 && + value <= 2_147_483_647 + ); +} + +/** + * Turn one owner's live discovery into explicit authority. A listener deeper + * than the owner follows replacement beneath exactly that owner process (its + * PID and native start time are pinned); the owner itself listening is a fixed + * PID selection with no replacement authority. + */ +function ownedAuthority(opts: { + readonly found: NativeOwnedDiscovery; + readonly ownerPid: number; +}): Record { + const found = opts.found; + if ( + !( + validPid(found.host_pid) && + FINGERPRINT.test(found.endpoint_fingerprint) && + typeof found.executable === "string" && + isAbsolute(found.executable) && + found.executable.length <= 1024 && + !found.executable.includes("\0") && + Number.isInteger(found.supervisor_depth) && + found.supervisor_depth >= 0 && + found.supervisor_depth <= 8 && + found.ancestor_pid === opts.ownerPid && + Number.isSafeInteger(found.ancestor_start_micros) && + found.ancestor_start_micros > 0 && + (found.supervisor_depth === 0) === (found.host_pid === opts.ownerPid) + ) + ) { + throw refused(); + } + if (found.supervisor_depth === 0) { + return { host_pid: found.host_pid }; + } + return { + host_pid: found.host_pid, + host_executable: found.executable, + host_supervisor_depth: found.supervisor_depth, + host_ancestor_pid: opts.ownerPid, + host_ancestor_start_micros: found.ancestor_start_micros, + }; +} + +type NativeDependencyDiscovery = { readonly discover?: (selection: { readonly hostPort: number; readonly executable: string; @@ -244,63 +429,121 @@ export async function readNativeHostDependencies(opts: { readonly host_pid: number; readonly endpoint_fingerprint: string; }>; -}): Promise { + /** Resolves `host_lifecycle` / `host_shared_helper` owners; absent refuses them. */ + readonly owners?: NativeDependencyOwnerResolver; + readonly discoverOwned?: (selection: { + readonly hostPort: number; + readonly ancestorPid: number; + }) => Promise; +}; + +/** One live discovery per owner (or executable) and host port, in entry order. */ +function createDependencyResolution(opts: NativeDependencyDiscovery) { + const resolved = new Map(); + const owned = new Map>(); + const ownedEntry = async ( + entry: Record, + owner: NativeDependencyOwner + ) => { + if (!(opts.owners && opts.discoverOwned)) { + throw refused(); + } + const hostPort = Number(entry.host_port); + const key = `${owner.kind}\0${owner.name}\0${hostPort}`; + let authority = owned.get(key); + if (authority === undefined) { + const proof = await opts.owners(owner); + const found = await opts.discoverOwned({ + hostPort, + ancestorPid: proof.pid, + }); + authority = ownedAuthority({ found, ownerPid: proof.pid }); + // The owner must still be the same live process after discovery. + await proof.confirm(); + owned.set(key, authority); + } + const { host_lifecycle, host_shared_helper, ...rest } = entry; + return { ...rest, ...authority }; + }; + const executableEntry = async (entry: Record) => { + if (!opts.discover) { + throw refused(); + } + const executable = String(entry.host_executable); + const hostPort = Number(entry.host_port); + const key = `${executable}\0${hostPort}`; + let hostPid = resolved.get(key); + if (hostPid === undefined) { + const discovered = await opts.discover({ executable, hostPort }); + if ( + !( + validPid(discovered.host_pid) && + FINGERPRINT.test(discovered.endpoint_fingerprint) + ) + ) { + throw refused(); + } + hostPid = discovered.host_pid; + resolved.set(key, hostPid); + } + return { ...entry, host_pid: hostPid }; + }; + return async (entry: unknown): Promise => { + if (!isRecord(entry)) { + return entry; + } + const owner = ownerSelector(entry); + if (owner) { + return await ownedEntry(entry, owner); + } + return entry.host_executable === undefined + ? entry + : await executableEntry(entry); + }; +} + +/** Closed refusal for discovery failures; owner refusals keep their own text. */ +function dependencyRefusal(error: unknown): Error { + if (isNativeDependencyOwnerRefusal(error) && error instanceof Error) { + return error; + } + if ( + error instanceof NativeRuntimeRequestError && + error.nativeCode === "host_endpoint_owner" + ) { + return nativeDependencyOwnerMismatch(); + } + return refused(); +} + +/** Read after lifecycle hooks so a hook can supply current listener PIDs; never persist authority. */ +export async function readNativeHostDependencies( + opts: NativeDependencyDiscovery & { + readonly path?: string; + readonly services: readonly string[]; + } +): Promise { if (opts.path === undefined) { return []; } try { const value = await readNativeSelection(opts.path); + // Validate the complete intent before invoking native process discovery. + provisionalNativeHostDependencies({ value, services: opts.services }); if (!(isRecord(value) && Array.isArray(value.dependencies))) { throw refused(); } - // Validate the complete intent before invoking native process discovery. - const provisional = { - ...value, - dependencies: value.dependencies.map((entry) => - isRecord(entry) && entry.host_executable !== undefined - ? { ...entry, host_pid: entry.host_pid ?? 2 } - : entry - ), - }; - parseNativeHostDependencies({ - value: provisional, - services: opts.services, - }); - const resolved = new Map(); + const resolve = createDependencyResolution(opts); const dependencies: unknown[] = []; for (const entry of value.dependencies) { - if (!isRecord(entry) || entry.host_executable === undefined) { - dependencies.push(entry); - continue; - } - if (!opts.discover) { - throw refused(); - } - const executable = String(entry.host_executable); - const hostPort = Number(entry.host_port); - const key = `${executable}\0${hostPort}`; - let hostPid = resolved.get(key); - if (hostPid === undefined) { - const discovered = await opts.discover({ executable, hostPort }); - if ( - !Number.isSafeInteger(discovered.host_pid) || - discovered.host_pid <= 1 || - discovered.host_pid > 2_147_483_647 || - !FINGERPRINT.test(discovered.endpoint_fingerprint) - ) { - throw refused(); - } - hostPid = discovered.host_pid; - resolved.set(key, hostPid); - } - dependencies.push({ ...entry, host_pid: hostPid }); + dependencies.push(await resolve(entry)); } return parseNativeHostDependencies({ value: { ...value, dependencies }, services: opts.services, }); - } catch { - throw refused(); + } catch (error) { + throw dependencyRefusal(error); } } @@ -399,3 +642,64 @@ export async function discoverNativeHostDependency(opts: { } } } + +/** + * Discover the listener on `hostPort` beneath a resolved owner process. Like + * executable discovery it waits only on endpoint-identity refusals (the owner + * may still be starting its listener); a listener outside the owner's tree + * refuses immediately. The caller re-proves ownership after this returns. + */ +export async function discoverNativeOwnedHostDependency(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly hostPort: number; + readonly ancestorPid: number; + readonly invoke?: typeof invokeNativeRuntime; + readonly wait?: NativeDependencyWait; +}): Promise { + while (true) { + const remaining = remainingDiscoveryTime(opts.wait); + try { + const result = await (opts.invoke ?? invokeNativeRuntime)({ + runtime: opts.runtime, + cwd: opts.projectRoot, + ...(remaining === undefined + ? {} + : { timeoutMs: Math.max(1, Math.min(2000, Math.ceil(remaining))) }), + args: [ + "graph", + "dependency-discover", + "--host-port", + String(opts.hostPort), + "--ancestor-pid", + String(opts.ancestorPid), + "--json", + ], + }); + remainingDiscoveryTime(opts.wait); + if ( + !isRecord(result) || + typeof result.host_pid !== "number" || + typeof result.endpoint_fingerprint !== "string" || + typeof result.executable !== "string" || + typeof result.supervisor_depth !== "number" || + typeof result.ancestor_pid !== "number" || + typeof result.ancestor_start_micros !== "number" + ) { + throw refused(); + } + return { + host_pid: result.host_pid, + endpoint_fingerprint: result.endpoint_fingerprint, + executable: result.executable, + supervisor_depth: result.supervisor_depth, + ancestor_pid: result.ancestor_pid, + ancestor_start_micros: result.ancestor_start_micros, + }; + } catch (error) { + if (!(await waitForNextDiscovery({ wait: opts.wait, error }))) { + throw error; + } + } + } +} diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..de48fa75f 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -5,12 +5,14 @@ import { dirname, join } from "node:path"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; +import type { NativeDependencyOwnerResolver } from "./native-dependency-owners.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; import { discoverNativeHostDependency, + discoverNativeOwnedHostDependency, type NativeHostDependency, - parseNativeHostDependencies, prepareNativeDependencyServices, + provisionalNativeHostDependencies, readNativeHostDependencies, } from "./native-project-dependencies.ts"; import { prepareNativeProjectInput } from "./native-project-input.ts"; @@ -148,20 +150,9 @@ async function readNativeRestartDependencyIntent(opts: { return []; } try { - const value = await readNativeSelection(opts.path); - if (!(isRecord(value) && Array.isArray(value.dependencies))) { - throw new ClosedCliError("Invalid dependency intent"); - } - return parseNativeHostDependencies({ + return provisionalNativeHostDependencies({ services: opts.services, - value: { - ...value, - dependencies: value.dependencies.map((entry) => - isRecord(entry) && entry.host_executable !== undefined - ? { ...entry, host_pid: entry.host_pid ?? 2 } - : entry - ), - }, + value: await readNativeSelection(opts.path), }); } catch { throw new ClosedCliError( @@ -322,6 +313,8 @@ export async function preflightNativeRestart(opts: { readonly recoverStopped?: boolean; readonly adaptationFile?: string; readonly dependencyFile?: string; + /** Resolves owner-scoped dependency selections against the running instance. */ + readonly dependencyOwners?: NativeDependencyOwnerResolver; readonly allowedHosts?: readonly string[]; /** Same public metadata selection the replacement startup will review. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; @@ -410,6 +403,15 @@ export async function preflightNativeRestart(opts: { executable: selection.executable, invoke: deps.invoke, }), + owners: opts.dependencyOwners, + discoverOwned: (selection) => + discoverNativeOwnedHostDependency({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + hostPort: selection.hostPort, + ancestorPid: selection.ancestorPid, + invoke: deps.invoke, + }), }); prepareNativeDependencyServices({ dependencies, services: specs }); const activeImages = stoppedRetry diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..5080787fc 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -9,6 +9,7 @@ import { } from "../lib/compose-env-fingerprint.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; +import type { NativeDependencyOwnerResolver } from "./native-dependency-owners.ts"; import { acquireNativeHttpsLease, NativeHttpsLeaseAcquisitionError, @@ -26,6 +27,7 @@ import { } from "./native-project-cache.ts"; import { discoverNativeHostDependency, + discoverNativeOwnedHostDependency, prepareNativeDependencyServices, readNativeHostDependencies, } from "./native-project-dependencies.ts"; @@ -900,6 +902,11 @@ export async function startNativeProject(opts: { readonly restore?: NativeProjectRun; readonly onReady?: () => Promise; readonly dependencyFile?: string; + /** + * Resolves `host_lifecycle` / `host_shared_helper` owners after lifecycle + * hooks; without it such selections refuse. + */ + readonly dependencyOwners?: NativeDependencyOwnerResolver; readonly adaptationFile?: string; readonly allowedHosts?: readonly string[]; readonly preparedBase?: NativePreparedBase; @@ -1084,6 +1091,19 @@ export async function startNativeProject(opts: { signal: controller.signal, }, }), + owners: opts.dependencyOwners, + discoverOwned: (selection) => + discoverNativeOwnedHostDependency({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + hostPort: selection.hostPort, + ancestorPid: selection.ancestorPid, + invoke: deps.invoke, + wait: { + deadlineMs: dependencyDiscoveryDeadline, + signal: controller.signal, + }, + }), }); requireActiveStartup(controller.signal); prepareNativeDependencyServices({ diff --git a/src/commands/project.ts b/src/commands/project.ts index 0e641d9dd..eb219bd08 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -41,6 +41,7 @@ import { } from "../agents/onboarding-handoff.ts"; import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; +import { createNativeDependencyOwnerResolver } from "../backends/native-dependency-owners.ts"; import { parseNativePreparedBase } from "../backends/native-prepared-base.ts"; import { nativeDownEnvironment, @@ -5794,6 +5795,11 @@ async function handleNativeUp({ profiles: parseCsvList(args.options.profile), sharedSource: process.env.HACK_NATIVE_SHARED_SOURCE === "1", dependencyFile: process.env.HACK_NATIVE_DEPENDENCIES, + dependencyOwners: createNativeDependencyOwnerResolver({ + projectDir: project.projectDir, + composeProject, + sharedHelpers: cfg.lifecycle?.sharedHelpers ?? [], + }), adaptationFile: process.env.HACK_NATIVE_ADAPTATION, allowedHosts: parseNativeAllowedHosts(process.env.HACK_NATIVE_ALLOW_HOSTS), preparedBase: parseNativePreparedBase(process.env), @@ -5973,6 +5979,7 @@ async function restartNativeImageProject({ composeFile: startup.composeFile, adaptationFile: startup.adaptationFile, dependencyFile: startup.dependencyFile, + dependencyOwners: startup.dependencyOwners, allowedHosts: startup.allowedHosts, runtimeMetadata: startup.runtimeMetadata, httpsPort: startup.https?.httpsPort ?? null, diff --git a/src/lib/shared-host-lifecycle.ts b/src/lib/shared-host-lifecycle.ts index dad499c48..47f81702c 100644 --- a/src/lib/shared-host-lifecycle.ts +++ b/src/lib/shared-host-lifecycle.ts @@ -556,6 +556,35 @@ export function isSharedHostProcessRunning( const isLive = isSharedHostProcessRunning; +/** + * Read-only view of a helper Hack started and still owns: a strict `owned` + * claim in phase `ready` that records `holder`, whose PID and birth time are + * live in a usable census. Takes no lock and signals nothing. Adopted, + * starting, stale, unreadable or uncertain claims return null, so a caller can + * use the PID only as an ownership anchor that it rechecks later. + */ +export async function readOwnedSharedHostProcess(opts: { + readonly spec: Pick; + readonly holder: string; + readonly processes?: SharedHostProcesses; +}): Promise<{ readonly pid: number; readonly birth: string } | null> { + const read = await readClaim(opts.spec); + if ( + read.kind !== "claim" || + read.claim.ownership !== "owned" || + read.claim.phase !== "ready" || + !read.claim.holders.includes(opts.holder) + ) { + return null; + } + const census = await readCensus(opts.processes); + if (census === null) { + return null; + } + const identity = { pid: read.claim.pid, birth: read.claim.birth }; + return isLive(census, identity) ? identity : null; +} + /** * Exit of a signalled tree member is observed only when its PID is gone from * the census or is a zombie. The same PID running with another birth time is diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..8c9da4b21 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -595,6 +595,30 @@ behavior, repeated jobs, multiple slots and real one-off traffic remain separate implementation and runtime gates. The existing Rebind model covers original-target drain/revocation; this model checks the later commit-before-new-grant boundary. +## Automatic dependency refresh + +`dependency-auto-refresh/AutoRefresh.tla` checks the foreground owner's +schedule for automatic host dependency refresh. Its positive configuration +(`MaxRetries = 2`, `MaxAttempts = 5`) exhausts 141 distinct states. A stale +notice (traffic drift or a watched listener exit) arms one attempt; attempts are +single-flight; a refusal before the rebind journal re-arms after a bounded +backoff; a completed rebind clears it; and a failure after the journal closes +the schedule. The negative control removes the close guard (`EnforceClose = +FALSE`) and must fail `NoReplayAfterUnconfirmed` at `Start` with a second attempt +after an unconfirmed rebind. + +| Model operation | Source boundary | +| --- | --- | +| `ListenerExits` / `Traffic` | `foreground/signals.rs` `watch_listener_exit` and `Wake::ListenerExit`; relay owner identity notices drained in `foreground.rs` | +| `Start` / `RefuseBeforeEffects` | `foreground/dependency_refresh.rs` `AutomaticRefresh` (`take_due`, `finish`, `AUTOMATIC_RETRY_DELAYS`) and `automatic_outcome` | +| `WriteJournal` / `FailAfterJournal` | `startup/runtime/rebind.rs` sets the sticky `rebind_unconfirmed` flag before the first journal write; only a completed refresh clears it | +| `Complete` | verified `refresh_dependencies` result; `ListenerWatches::sync` re-watches the admitted endpoints | + +The rebind protocol itself is `dependency-rebind/Rebind.tla`. This model does +not prove the kqueue exit delivery, timer accuracy, provider lease acquisition +or that a replacement listener appears; it abstracts the listener as live, gone +or replaced, and the retry delays as an ordered bound. + ## Stopped retained-pool startup `stopped-pool-startup/Resume.tla` checks one explicit startup from shape-valid diff --git a/tests/models/tla/dependency-auto-refresh/AutoRefresh.tla b/tests/models/tla/dependency-auto-refresh/AutoRefresh.tla new file mode 100644 index 000000000..1d5d6ec3c --- /dev/null +++ b/tests/models/tla/dependency-auto-refresh/AutoRefresh.tla @@ -0,0 +1,87 @@ +------------------------- MODULE AutoRefresh ------------------------- +(* Owner-side automatic host dependency refresh (foreground graph owner). + A stale notice (authenticated traffic drift or a watched listener's exit) + arms one attempt. Attempts are single-flight. A refusal before the rebind + journal is retried after a bounded backoff; a verified refresh clears the + backoff; a rebind that wrote its journal and did not complete closes the + schedule, so no uncertain rebind is ever replayed. The dependency-rebind + protocol itself (fence, retire, register, commit) is Rebind.tla. *) +EXTENDS Naturals +CONSTANT EnforceClose, MaxRetries, MaxAttempts +VARIABLES listener, armed, inFlight, journal, retries, closed, + unconfirmed, replayed, attempts +vars == <> + +Init == /\ listener = "live" /\ armed = FALSE /\ inFlight = 0 + /\ journal = "none" /\ retries = 0 /\ closed = FALSE + /\ unconfirmed = FALSE /\ replayed = FALSE /\ attempts = 0 + +\* A stale notice arms an attempt unless one is already scheduled or closed. +Arm == IF closed \/ armed THEN armed ELSE TRUE + +\* The pinned listener exits (watched EVFILT_PROC notice). +ListenerExits == /\ listener = "live" + /\ listener' = "gone" /\ armed' = Arm + /\ UNCHANGED <> +\* Authenticated traffic reaches the stale endpoint and reports drift. +Traffic == /\ listener # "live" + /\ armed' = Arm + /\ UNCHANGED <> +\* The host owner starts a replacement listener beneath the pinned supervisor. +Replace == /\ listener = "gone" /\ listener' = "replaced" + /\ UNCHANGED <> + +Start == /\ armed /\ inFlight = 0 /\ attempts < MaxAttempts + /\ (EnforceClose => ~closed) + /\ armed' = FALSE /\ inFlight' = 1 /\ journal' = "none" + /\ attempts' = attempts + 1 + /\ replayed' = (replayed \/ unconfirmed) + /\ UNCHANGED <> + +\* No replacement yet: refused before the journal; bounded backoff re-arms. +RefuseBeforeEffects == + /\ inFlight = 1 /\ journal = "none" /\ listener = "gone" + /\ inFlight' = 0 + /\ IF retries < MaxRetries + THEN /\ armed' = TRUE /\ retries' = retries + 1 + ELSE /\ armed' = armed /\ retries' = 0 + /\ UNCHANGED <> + +WriteJournal == /\ inFlight = 1 /\ journal = "none" /\ listener = "replaced" + /\ journal' = "written" + /\ UNCHANGED <> + +Complete == /\ inFlight = 1 /\ journal = "written" + /\ journal' = "completed" /\ inFlight' = 0 /\ retries' = 0 + /\ listener' = "live" + /\ UNCHANGED <> + +\* Any failure after the journal leaves the outcome unconfirmed. +FailAfterJournal == + /\ inFlight = 1 /\ journal = "written" + /\ journal' = "failed" /\ inFlight' = 0 /\ unconfirmed' = TRUE + /\ IF EnforceClose + THEN /\ closed' = TRUE /\ armed' = FALSE + ELSE /\ closed' = closed /\ armed' = TRUE + /\ UNCHANGED <> + +Next == ListenerExits \/ Traffic \/ Replace \/ Start \/ RefuseBeforeEffects + \/ WriteJournal \/ Complete \/ FailAfterJournal +Spec == Init /\ [][Next]_vars + +TypeOK == /\ listener \in {"live", "gone", "replaced"} + /\ armed \in BOOLEAN /\ inFlight \in 0..1 + /\ journal \in {"none", "written", "completed", "failed"} + /\ retries \in 0..MaxRetries /\ closed \in BOOLEAN + /\ unconfirmed \in BOOLEAN /\ replayed \in BOOLEAN + /\ attempts \in 0..MaxAttempts +SingleFlight == inFlight <= 1 +BoundedBackoff == retries <= MaxRetries +NoReplayAfterUnconfirmed == ~replayed +ClosedAfterUnconfirmed == (EnforceClose /\ unconfirmed) => (closed /\ ~armed) +====================================================================== diff --git a/tests/models/tla/dependency-auto-refresh/negative.cfg b/tests/models/tla/dependency-auto-refresh/negative.cfg new file mode 100644 index 000000000..f2481f9aa --- /dev/null +++ b/tests/models/tla/dependency-auto-refresh/negative.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +CONSTANT EnforceClose = FALSE +CONSTANT MaxRetries = 2 +CONSTANT MaxAttempts = 5 +INVARIANTS TypeOK SingleFlight BoundedBackoff NoReplayAfterUnconfirmed ClosedAfterUnconfirmed +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/dependency-auto-refresh/positive.cfg b/tests/models/tla/dependency-auto-refresh/positive.cfg new file mode 100644 index 000000000..ef82b8c0d --- /dev/null +++ b/tests/models/tla/dependency-auto-refresh/positive.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +CONSTANT EnforceClose = TRUE +CONSTANT MaxRetries = 2 +CONSTANT MaxAttempts = 5 +INVARIANTS TypeOK SingleFlight BoundedBackoff NoReplayAfterUnconfirmed ClosedAfterUnconfirmed +CHECK_DEADLOCK FALSE diff --git a/tests/native-dependency-owners.test.ts b/tests/native-dependency-owners.test.ts new file mode 100644 index 000000000..00a3c3fc7 --- /dev/null +++ b/tests/native-dependency-owners.test.ts @@ -0,0 +1,628 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createNativeDependencyOwnerResolver, + NATIVE_DEPENDENCY_OWNER_MISMATCH, + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE, + type NativeDependencyOwner, + type NativeDependencyOwnerReaders, +} from "../src/backends/native-dependency-owners.ts"; +import { + discoverNativeOwnedHostDependency, + type NativeOwnedDiscovery, + provisionalNativeHostDependencies, + readNativeHostDependencies, +} from "../src/backends/native-project-dependencies.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; +import type { LifecycleStateEntry } from "../src/lib/lifecycle-runtime.ts"; +import type { ProjectSharedHelperConfig } from "../src/lib/project-shared-helpers.ts"; +import { + readOwnedSharedHostProcess, + type SharedHostProcesses, +} from "../src/lib/shared-host-lifecycle.ts"; +import { expectCodedRefusal } from "./helpers/closed-refusal.ts"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +async function scratch(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +const OWNER_PID = 4100; +const START = 1_700_000_000_123_456; +const FINGERPRINT = "f".repeat(64); + +function ownedEntry( + service: string, + owner: Record = { host_lifecycle: "tunnel" } +) { + return { + service, + binding: "search", + guest_port: 443, + host_port: 8443, + aliases: ["search.example.com"], + ...owner, + }; +} + +async function selection(dependencies: unknown[]): Promise { + const root = await scratch("native-dependency-owners-"); + const path = join(root, "selection.json"); + await writeFile(path, JSON.stringify({ version: 1, dependencies })); + return path; +} + +function discovered( + overrides: Partial = {} +): NativeOwnedDiscovery { + return { + host_pid: 4321, + endpoint_fingerprint: FINGERPRINT, + executable: "/usr/local/bin/synthetic-tunnel", + supervisor_depth: 3, + ancestor_pid: OWNER_PID, + ancestor_start_micros: START, + ...overrides, + }; +} + +function recordingOwners(opts: { readonly confirmFails?: boolean } = {}) { + const events: string[] = []; + return { + events, + owners: async (owner: NativeDependencyOwner) => { + events.push(`resolve:${owner.kind}:${owner.name}`); + return { + pid: OWNER_PID, + confirm: async () => { + events.push("confirm"); + if (opts.confirmFails) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: NATIVE_DEPENDENCY_OWNER_UNAVAILABLE, + }); + } + }, + }; + }, + }; +} + +test("a lifecycle owner resolves one listener beneath it and pins the owner for review", async () => { + const path = await selection([ownedEntry("web"), ownedEntry("worker")]); + const { events, owners } = recordingOwners(); + const result = await readNativeHostDependencies({ + path, + services: ["web", "worker"], + owners, + discoverOwned: async (request) => { + events.push(`discover:${request.hostPort}:${request.ancestorPid}`); + return discovered(); + }, + }); + // One resolution and discovery for the shared listener; ownership is + // re-proved only after discovery. + expect(events).toEqual([ + "resolve:lifecycle:tunnel", + `discover:8443:${OWNER_PID}`, + "confirm", + ]); + expect(result).toHaveLength(2); + for (const entry of result) { + expect(entry).toEqual({ + service: entry.service, + binding: "search", + slot: 0, + guest_port: 443, + host_port: 8443, + host_pid: 4321, + host_executable: "/usr/local/bin/synthetic-tunnel", + host_supervisor_depth: 3, + host_ancestor_pid: OWNER_PID, + host_ancestor_start_micros: START, + aliases: ["search.example.com"], + }); + // Owner names never reach native review. + expect(Object.keys(entry)).not.toContain("host_lifecycle"); + } +}); + +test("an owner that is itself the listener becomes a fixed PID with no replacement authority", async () => { + const path = await selection([ + ownedEntry("web", { host_shared_helper: "search-proxy" }), + ]); + const { events, owners } = recordingOwners(); + const [entry] = await readNativeHostDependencies({ + path, + services: ["web"], + owners, + discoverOwned: async () => + discovered({ host_pid: OWNER_PID, supervisor_depth: 0 }), + }); + expect(events[0]).toBe("resolve:sharedHelper:search-proxy"); + expect(entry?.host_pid).toBe(OWNER_PID); + expect(entry?.host_executable).toBeUndefined(); + expect(entry?.host_supervisor_depth).toBeUndefined(); + expect(entry?.host_ancestor_pid).toBeUndefined(); +}); + +test("owner refusals keep their closed text; other failures stay generic", async () => { + const path = await selection([ownedEntry("web")]); + await expectCodedRefusal( + readNativeHostDependencies({ + path, + services: ["web"], + owners: recordingOwners({ confirmFails: true }).owners, + discoverOwned: async () => discovered(), + }), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE + ); + await expectCodedRefusal( + readNativeHostDependencies({ + path, + services: ["web"], + owners: recordingOwners().owners, + discoverOwned: async () => { + throw new NativeRuntimeRequestError({ + message: "Native runtime request failed (host_endpoint_owner)", + nativeCode: "host_endpoint_owner", + }); + }, + }), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_MISMATCH + ); + // Without an owner resolver (for example an unsupported caller) owner + // selections refuse rather than falling back to another authority. + await expectCodedRefusal( + readNativeHostDependencies({ path, services: ["web"] }), + "E_CONFIG_INVALID", + "values omitted" + ); + for (const result of [ + discovered({ ancestor_pid: OWNER_PID + 1 }), + discovered({ supervisor_depth: 9 }), + discovered({ supervisor_depth: 0 }), + discovered({ host_pid: OWNER_PID }), + discovered({ executable: "relative/tunnel" }), + discovered({ endpoint_fingerprint: "short" }), + discovered({ ancestor_start_micros: 0 }), + ]) { + const { events, owners } = recordingOwners(); + await expectCodedRefusal( + readNativeHostDependencies({ + path, + services: ["web"], + owners, + discoverOwned: async () => result, + }), + "E_CONFIG_INVALID", + "Native host dependencies require a bounded explicit listener selection" + ); + expect(events).not.toContain("confirm"); + } +}); + +test("selection files name an owner instead of authority and cannot supply resolved fields", async () => { + const invalid: unknown[][] = [ + [{ ...ownedEntry("web"), host_shared_helper: "proxy" }], + [{ ...ownedEntry("web"), host_pid: 123 }], + [{ ...ownedEntry("web"), host_executable: "/usr/local/bin/tunnel" }], + [{ ...ownedEntry("web"), host_supervisor_depth: 2 }], + [{ ...ownedEntry("web"), host_ancestor_pid: 4100 }], + [ + { + ...ownedEntry("web", {}), + host_executable: "/usr/local/bin/tunnel", + host_ancestor_pid: 4100, + host_ancestor_start_micros: START, + }, + ], + [ownedEntry("web", { host_lifecycle: "" })], + [ownedEntry("web", { host_lifecycle: " padded" })], + [ownedEntry("web", { host_lifecycle: "line\nbreak" })], + // Bindings sharing a host port must name the same owner. + [ + ownedEntry("web"), + { + ...ownedEntry("worker", { host_lifecycle: "other" }), + binding: "other", + }, + ], + [ + ownedEntry("web"), + { + ...ownedEntry("worker", {}), + host_executable: "/usr/local/bin/tunnel", + }, + ], + ]; + for (const dependencies of invalid) { + expect(() => + provisionalNativeHostDependencies({ + value: { version: 1, dependencies }, + services: ["web", "worker"], + }) + ).toThrow("values omitted"); + let resolved = false; + await expectCodedRefusal( + readNativeHostDependencies({ + path: await selection(dependencies), + services: ["web", "worker"], + owners: async () => { + resolved = true; + throw new Error("must not resolve"); + }, + discoverOwned: async () => discovered(), + }), + "E_CONFIG_INVALID", + "values omitted" + ); + expect(resolved).toBe(false); + } + // The intent alone validates with a provisional PID for restart retries. + expect( + provisionalNativeHostDependencies({ + value: { + version: 1, + dependencies: [ownedEntry("web"), ownedEntry("worker")], + }, + services: ["web", "worker"], + }).map((entry) => [entry.service, entry.slot]) + ).toEqual([ + ["web", 0], + ["worker", 0], + ]); +}); + +test("owned discovery waits for a starting listener but never for a foreign one", async () => { + const base = { + runtime: { binary: "/synthetic/native", home: "/synthetic/home" }, + projectRoot: "/synthetic/project", + hostPort: 8443, + ancestorPid: OWNER_PID, + }; + const args: string[][] = []; + let calls = 0; + const result = await discoverNativeOwnedHostDependency({ + ...base, + invoke: async (request) => { + args.push([...request.args]); + if (++calls === 1) { + throw new NativeRuntimeRequestError({ + message: "Native runtime request failed (host_endpoint_identity)", + nativeCode: "host_endpoint_identity", + }); + } + return discovered(); + }, + wait: { + deadlineMs: performance.now() + 2000, + signal: new AbortController().signal, + }, + }); + expect(result).toEqual(discovered()); + expect(args[0]).toEqual([ + "graph", + "dependency-discover", + "--host-port", + "8443", + "--ancestor-pid", + String(OWNER_PID), + "--json", + ]); + calls = 0; + await expect( + discoverNativeOwnedHostDependency({ + ...base, + invoke: async () => { + calls += 1; + throw new NativeRuntimeRequestError({ + message: "Native runtime request failed (host_endpoint_owner)", + nativeCode: "host_endpoint_owner", + }); + }, + wait: { + deadlineMs: performance.now() + 2000, + signal: new AbortController().signal, + }, + }) + ).rejects.toThrow("host_endpoint_owner"); + expect(calls).toBe(1); +}); + +function lifecycleState( + overrides: Partial = {} +): LifecycleStateEntry { + return { + composeProject: "demo--feature", + projectName: "demo", + branch: "feature", + sessionName: "hack-demo--feature", + backend: "tmux", + ownershipToken: "token-a", + processes: [ + { + name: "tunnel", + windowName: "tunnel", + logPath: "/synthetic/lifecycle.log", + panePid: OWNER_PID, + }, + ], + updatedAt: "2026-11-05T00:00:00.000Z", + ...overrides, + }; +} + +function lifecycleReaders(state: { + entries: LifecycleStateEntry[]; + token: string | null; + panes: { pid: number; dead: boolean }[] | null; +}): Partial { + return { + readState: async () => state.entries, + readOwnerToken: async () => state.token, + readPanes: async () => state.panes, + }; +} + +test("lifecycle owners require a live pane in this instance's owned tmux session", async () => { + const state = { + entries: [lifecycleState()], + token: "token-a" as string | null, + panes: [{ pid: OWNER_PID, dead: false }] as + | { pid: number; dead: boolean }[] + | null, + }; + const resolve = createNativeDependencyOwnerResolver({ + projectDir: "/synthetic/project/.hack", + composeProject: "demo--feature", + sharedHelpers: [], + readers: lifecycleReaders(state), + }); + const proof = await resolve({ kind: "lifecycle", name: "tunnel" }); + expect(proof.pid).toBe(OWNER_PID); + await proof.confirm(); + // The pane exited and its PID could be reused: confirmation refuses. + state.panes = [{ pid: OWNER_PID, dead: true }]; + await expectCodedRefusal( + proof.confirm(), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE + ); + // A restarted window under the same name is a different owner. + state.panes = [{ pid: OWNER_PID + 7, dead: false }]; + state.entries = [ + lifecycleState({ + processes: [ + { + name: "tunnel", + windowName: "tunnel", + logPath: "/synthetic/lifecycle.log", + panePid: OWNER_PID + 7, + }, + ], + }), + ]; + await expectCodedRefusal( + proof.confirm(), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE + ); + + const refusals: { + entries: LifecycleStateEntry[]; + token: string | null; + panes: { pid: number; dead: boolean }[] | null; + name?: string; + }[] = [ + { entries: [], token: "token-a", panes: [{ pid: OWNER_PID, dead: false }] }, + { + entries: [lifecycleState({ composeProject: "demo" })], + token: "token-a", + panes: [{ pid: OWNER_PID, dead: false }], + }, + { + entries: [lifecycleState({ backend: "zellij" })], + token: "token-a", + panes: [{ pid: OWNER_PID, dead: false }], + }, + { + entries: [lifecycleState({ ownershipToken: undefined })], + token: null, + panes: [{ pid: OWNER_PID, dead: false }], + }, + { + entries: [lifecycleState()], + token: "token-b", + panes: [{ pid: OWNER_PID, dead: false }], + }, + { entries: [lifecycleState()], token: "token-a", panes: null }, + { + entries: [lifecycleState()], + token: "token-a", + panes: [ + { pid: OWNER_PID, dead: false }, + { pid: OWNER_PID + 1, dead: false }, + ], + }, + { + entries: [lifecycleState()], + token: "token-a", + panes: [{ pid: OWNER_PID, dead: false }], + name: "other", + }, + { + entries: [ + lifecycleState({ + processes: [ + { + name: "tunnel", + windowName: "tunnel", + logPath: "/synthetic/lifecycle.log", + }, + ], + }), + ], + token: "token-a", + panes: [{ pid: OWNER_PID, dead: false }], + }, + ]; + for (const refusal of refusals) { + const resolver = createNativeDependencyOwnerResolver({ + projectDir: "/synthetic/project/.hack", + composeProject: "demo--feature", + sharedHelpers: [], + readers: lifecycleReaders(refusal), + }); + await expectCodedRefusal( + resolver({ kind: "lifecycle", name: refusal.name ?? "tunnel" }), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE + ); + } +}); + +function helperConfig( + overrides: Partial = {} +): ProjectSharedHelperConfig { + return { + name: "search-proxy", + enabled: true, + command: ["/usr/local/bin/synthetic-proxy"], + port: 18_443, + identityPath: "/__hack/identity", + upstream: "https://search.example.com", + env: [], + readyTimeoutMs: 1000, + ...overrides, + }; +} + +test("shared helper owners require an owned, live claim held by this instance", async () => { + let owned: { pid: number; birth: string } | null = { + pid: OWNER_PID, + birth: "Thu Nov 5 10:00:00 2026", + }; + const holders: string[] = []; + const resolve = createNativeDependencyOwnerResolver({ + projectDir: "/synthetic/project/.hack", + composeProject: "demo--feature", + sharedHelpers: [helperConfig()], + readers: { + readHelper: async ({ helper, holder }) => { + holders.push(`${helper.name}:${holder}`); + return owned; + }, + }, + }); + const proof = await resolve({ kind: "sharedHelper", name: "search-proxy" }); + expect(proof.pid).toBe(OWNER_PID); + expect(holders).toEqual(["search-proxy:demo--feature"]); + owned = { pid: OWNER_PID, birth: "Thu Nov 5 10:00:09 2026" }; + await expectCodedRefusal( + proof.confirm(), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE + ); + owned = null; + await expectCodedRefusal( + resolve({ kind: "sharedHelper", name: "search-proxy" }), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE + ); + for (const sharedHelpers of [ + [], + [helperConfig({ enabled: false })], + [helperConfig({ name: "other" })], + ]) { + const resolver = createNativeDependencyOwnerResolver({ + projectDir: "/synthetic/project/.hack", + composeProject: "demo--feature", + sharedHelpers, + readers: { readHelper: async () => ({ pid: OWNER_PID, birth: "b" }) }, + }); + await expectCodedRefusal( + resolver({ kind: "sharedHelper", name: "search-proxy" }), + "E_CONFIG_INVALID", + NATIVE_DEPENDENCY_OWNER_UNAVAILABLE + ); + } +}); + +test("the owned-helper reader accepts only a ready owned claim with a live birth", async () => { + const stateDir = join(await scratch("native-owner-helper-"), "state"); + await mkdir(stateDir, { recursive: true, mode: 0o700 }); + const spec = { name: "search-proxy", stateDir }; + const claimPath = join(stateDir, "search-proxy.claim.json"); + const birth = "Thu Nov 5 10:00:00 2026"; + const write = async (value: unknown) => { + await writeFile(claimPath, JSON.stringify(value), { mode: 0o600 }); + await chmod(claimPath, 0o600); + }; + const claim = { + version: 1, + helper: "search-proxy", + port: 18_443, + ownership: "owned", + phase: "ready", + pid: OWNER_PID, + birth, + holders: ["demo--feature"], + }; + const self = { + pid: process.pid, + parentPid: 1, + birth: "Thu Nov 5 09:00:00 2026", + zombie: false, + }; + let rows = [self, { pid: OWNER_PID, parentPid: 1, birth, zombie: false }]; + const processes: SharedHostProcesses = { + census: async () => rows, + signalTree: async () => { + throw new Error("the reader never signals"); + }, + }; + const read = (holder = "demo--feature") => + readOwnedSharedHostProcess({ spec, holder, processes }); + expect(await read()).toBeNull(); + await write(claim); + expect(await read()).toEqual({ pid: OWNER_PID, birth }); + expect(await read("demo--other")).toBeNull(); + rows = [self, { pid: OWNER_PID, parentPid: 1, birth, zombie: true }]; + expect(await read()).toBeNull(); + rows = [ + self, + { pid: OWNER_PID, parentPid: 1, birth: "later", zombie: false }, + ]; + expect(await read()).toBeNull(); + rows = [{ pid: OWNER_PID, parentPid: 1, birth, zombie: false }]; + // A census that did not observe this process is unusable, not "absent". + expect(await read()).toBeNull(); + rows = [self, { pid: OWNER_PID, parentPid: 1, birth, zombie: false }]; + await write({ ...claim, phase: "starting" }); + expect(await read()).toBeNull(); + await write({ + version: 1, + helper: "search-proxy", + port: 18_443, + ownership: "adopted", + holders: ["demo--feature"], + }); + expect(await read()).toBeNull(); + await write(claim); + await chmod(claimPath, 0o644); + expect(await read()).toBeNull(); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..c3d751bad 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -1034,6 +1034,80 @@ test("host listener discovery after hooks binds the current executable-pinned PI expect(await startNativeProject({ ...opts, dependencyFile: path })).toBe(0); }); +test("an owner-scoped selection resolves its owner after hooks and pins it for review", async () => { + const { opts, events } = await fixture(false); + const path = join(opts.scope.projectRoot, "host-selection.json"); + await writeFile( + path, + JSON.stringify({ + version: 1, + dependencies: [ + { + service: "web", + binding: "search", + guest_port: 443, + host_port: 8443, + host_lifecycle: "tunnel", + aliases: ["search.example.com"], + }, + ], + }) + ); + const owners: string[] = []; + const invoke = opts.dependencies.invoke!; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "dependency-discover") { + expect(events).toContain("before"); + expect(request.args.slice(2)).toEqual([ + "--host-port", + "8443", + "--ancestor-pid", + "4100", + "--json", + ]); + return { + host_pid: 4321, + endpoint_fingerprint: "e".repeat(64), + executable: "/usr/local/bin/synthetic-tunnel", + supervisor_depth: 2, + ancestor_pid: 4100, + ancestor_start_micros: 1_700_000_000_000_001, + }; + } + if (request.args[1] === "dependency-plan") { + const selection = JSON.parse( + await readFile(String(request.args[3]), "utf8") + ); + expect(selection.dependencies[0]).toMatchObject({ + host_pid: 4321, + host_executable: "/usr/local/bin/synthetic-tunnel", + host_supervisor_depth: 2, + host_ancestor_pid: 4100, + host_ancestor_start_micros: 1_700_000_000_000_001, + }); + expect(selection.dependencies[0].host_lifecycle).toBeUndefined(); + } + return await invoke(request); + }; + expect( + await startNativeProject({ + ...opts, + dependencyFile: path, + dependencyOwners: async (owner) => { + owners.push(`${owner.kind}:${owner.name}`); + expect(events).toContain("before"); + return { + pid: 4100, + confirm: async () => { + owners.push("confirm"); + }, + }; + }, + }) + ).toBe(0); + expect(owners).toEqual(["lifecycle:tunnel", "confirm"]); +}); + test("a delayed hook listener gates runtime effects and cancellation cleans hooks", async () => { for (const cancel of [false, true]) { const { opts, events } = await fixture(false); From 62f6112b378225de2ffb5082976398c1c151cb12 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:00:58 -0500 Subject: [PATCH 047/138] fix(native): refuse a build stage named scratch BuildKit links a later `FROM scratch` to an earlier stage of that name instead of the empty image. The scanner treated it as empty, so it missed that stage's image and skipped its platform check. A stage alias of `scratch` (in any case) is now refused; plain `FROM scratch` is still the empty image. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 8 ++++++++ .../src/provider/build_scan/tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index 6e1059041..eb6d08844 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -769,6 +769,14 @@ fn dispatch( if !valid { return Err(refusal(Some(line), "invalid build stage name.")); } + // The frontend links a later `FROM scratch` (or `--from=scratch`) to + // a stage of that name instead of the empty image. + if name == "scratch" { + return Err(refusal( + Some(line), + "a build stage may not be named scratch.", + )); + } Some(name) } _ => return Err(refusal(Some(line), "FROM must name one image or stage.")), diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 2a895d55e..d3736e975 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -390,6 +390,25 @@ fn doubling_argument_defaults_stay_bounded() { assert_eq!(images(&with("FROM alpine:3.22\n")), ["alpine:3.22"]); } +#[test] +fn a_stage_may_not_be_named_scratch() { + // The frontend links `FROM scratch` to an earlier stage of that name, so the + // stage's image and platform would apply. + for dockerfile in [ + "FROM alpine:3.22 AS scratch\nFROM scratch\n", + "FROM --platform=linux/amd64 alpine:3.22 AS Scratch\nFROM scratch\n", + "FROM alpine:3.22 AS SCRATCH\nFROM busybox:1.37\nCOPY --from=scratch / /\n", + ] { + assert_eq!(code(dockerfile), "native_build_dockerfile", "{dockerfile}"); + } + // Plain `scratch` is still the empty image. + assert!(images("FROM scratch\n").is_empty()); + assert_eq!( + images("FROM alpine:3.22 AS build\nFROM scratch\nCOPY --from=build / /\n"), + ["alpine:3.22"] + ); +} + #[test] fn frontend_selection_directives_refuse() { for dockerfile in [ From ba79617415e36406c3e8939a55fca1f58767483e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:59:32 -0500 Subject: [PATCH 048/138] feat(native): opt-in linux/amd64 services under Rosetta 2 in the VM The native VM runs Linux arm64. A HACK_NATIVE_ADAPTATION `amd64Emulation` selection (project `default` plus per-service modes `native`, `prefer-native`, `amd64`) now lets services run linux/amd64 images under Rosetta 2 x86_64 translation in the SmolVM guest. Multi-arch images stay arm64 under `prefer-native`; amd64-only images and explicit `amd64` services run emulated. Nothing changes without the selection. Runtime: - `runtime up --amd64-emulation` records Owner.amd64_emulation before any provider effect, creates fresh pools with `--rosetta`, updates stopped pools with `machine update --rosetta`, refuses a running pool without translation (amd64_emulation_restart_required), and refuses hosts without the Rosetta Linux runtime (amd64_emulation_unavailable). - Every boot audits the stored machine setting, running config and the guest binfmt_misc `rosetta` registration against the recorded intent; pools without translation must have no registration. - `runtime ensure-image --emulation native|prefer-native|amd64` selects the platform by policy, confirms it from the image config, caches amd64 archives under their own address and imports them only into a translation pool. - The reviewed `x-hack-amd64-emulation: true` service field gates a declared linux/amd64 platform and amd64 image admission; it never applies to a declared arm64 platform. Frontend: the adaptation writes the field, start and restart pin with the service policy and request a translation pool, the Compose-backend gate stops reporting foreign_platform for opted-in linux/amd64 services, and native doctor adds a value-free `amd64 emulation` warning. Adds the pool-emulation TLA+ model with two negative controls. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 68 +++- docs/reference/v5-model-coverage.md | 1 + packages/runtime-core/src/main.rs | 39 +- .../src/project/compose/service.rs | 14 +- packages/runtime-core/src/project/mod.rs | 5 + .../runtime-core/src/provider/config_audit.rs | 50 +++ .../src/provider/graph/cache_provenance.rs | 2 + .../runtime-core/src/provider/graph/config.rs | 10 +- .../src/provider/graph/environment/tests.rs | 1 + .../src/provider/graph/host_relay.rs | 1 + .../src/provider/graph/hostname_change.rs | 1 + .../runtime-core/src/provider/graph/mod.rs | 21 +- .../src/provider/graph/native/mod.rs | 2 + .../src/provider/graph/native/runtime.rs | 3 +- .../provider/graph/restore_history/tests.rs | 1 + .../src/provider/graph/shutdown.rs | 1 + .../runtime-core/src/provider/graph/source.rs | 1 + .../src/provider/graph/storage_inventory.rs | 1 + .../runtime-core/src/provider/graph/tests.rs | 57 +++ .../src/provider/guest-translation.sh | 26 ++ .../runtime-core/src/provider/image_ensure.rs | 254 ++++++++++-- .../runtime-core/src/provider/image_load.rs | 102 ++++- .../src/provider/image_platform.rs | 153 ++++++++ .../runtime-core/src/provider/lifecycle.rs | 145 ++++++- packages/runtime-core/src/provider/mod.rs | 1 + .../src/provider/prepared_start.rs | 3 + .../src/provider/registry_image.rs | 219 +++++++++-- packages/runtime-core/src/provider/state.rs | 8 + packages/runtime-core/src/runtime_up_cli.rs | 41 +- .../runtime-core/tests/project_contract.rs | 41 ++ scripts/lib/tla-runtime-models.ts | 28 ++ src/backends/native-project-adaptation.ts | 10 +- src/backends/native-project-input.ts | 6 + src/backends/native-project-platform.ts | 204 ++++++++++ .../native-project-restart-preflight.ts | 16 +- src/backends/native-project-start.ts | 19 +- src/commands/doctor.ts | 52 ++- src/lib/doctor-native-emulation.ts | 65 ++++ src/lib/native-compose-backend-features.ts | 114 +++++- tests/models/tla/README.md | 34 ++ tests/models/tla/pool-emulation/Emulation.tla | 65 ++++ tests/models/tla/pool-emulation/negative.cfg | 6 + tests/models/tla/pool-emulation/positive.cfg | 7 + .../tla/pool-emulation/running-upgrade.cfg | 6 + tests/native-project-platform.test.ts | 368 ++++++++++++++++++ tests/native-project-start.test.ts | 99 +++++ 46 files changed, 2243 insertions(+), 128 deletions(-) create mode 100644 packages/runtime-core/src/provider/guest-translation.sh create mode 100644 packages/runtime-core/src/provider/image_platform.rs create mode 100644 src/backends/native-project-platform.ts create mode 100644 src/lib/doctor-native-emulation.ts create mode 100644 tests/models/tla/pool-emulation/Emulation.tla create mode 100644 tests/models/tla/pool-emulation/negative.cfg create mode 100644 tests/models/tla/pool-emulation/positive.cfg create mode 100644 tests/models/tla/pool-emulation/running-upgrade.cfg create mode 100644 tests/native-project-platform.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..84e7bb7ed 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -193,7 +193,7 @@ Compose backend. Native support for them is planned after v5. | External volume | A top-level volume sets `external: true` or `external: {name: ...}`. The native runtime only creates volumes it owns, and there is no native replacement for an external volume. | | Reserved ownership label | A service, network or volume label key starts with `com.docker.compose.`, `io.hack-local.`, `io.hack.native-config.`, `io.hack.native-file.` or `io.hack.storage-witness.`. Compose, the native runtime and native-config Compose use these labels to prove which resources they own. | | Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. | -| Non-native platform | A service sets a `platform` other than `linux/arm64` or `linux/arm64/v8`, for example `linux/amd64`. The native VM runs Linux arm64 only. A missing or `null` `platform` is unset and passes. | +| Non-native platform | A service sets a `platform` other than `linux/arm64` or `linux/arm64/v8`, for example `linux/amd64`. The native VM runs Linux arm64. A missing or `null` `platform` is unset and passes. `linux/amd64` passes only for a service that the `HACK_NATIVE_ADAPTATION` selection opts in to [amd64 emulation](#amd64-emulation-with-rosetta-2); every other platform value stays on the Compose backend. | Native `hack up` and `hack restart` check for these features after selecting the project and before branch, environment, lifecycle, storage or runtime work. They @@ -211,11 +211,12 @@ sets its `platform` field. The native runtime's own review reports `extends` and The check applies the explicit `HACK_NATIVE_ADAPTATION` selection first, as native start does. An unchanged Compose file whose external network is listed in `isolatedNetworks` therefore stays on native, and native start replaces that -network with an owned isolated one. The selection is read only when the Compose -file has an external network. If it cannot be read, is not version 1, or its -`isolatedNetworks` is not a list of unique names, the command refuses with the -same one-line `E_CONFIG_INVALID` message native start gives, before any runtime -request. +network with an owned isolated one. A `linux/amd64` service that `amd64Emulation` +opts in stays on native the same way. The selection is read only when the Compose +file has an external network or a non-native platform. If it cannot be read, is not +version 1, its `isolatedNetworks` is not a list of unique names, or its +`amd64Emulation` is invalid, the command refuses with the same one-line +`E_CONFIG_INVALID` message native start gives, before any runtime request. To run such a project, select the Compose backend: @@ -685,6 +686,15 @@ A failed write leaves no final archive; process interruption may retain a hidden temporary file beside the requested output. A directory-sync failure reports that a complete archive was published but durability could not be confirmed. +`runtime ensure-image --reference REF [--emulation native|prefer-native|amd64] --json` +resolves, caches and imports one image into the running pool. Without `--emulation` +(or with `native`) it admits Linux arm64 only. `prefer-native` selects arm64 when the +index publishes it and otherwise the exact `linux/amd64` image; `amd64` selects only +`linux/amd64`. The result's `platform` names the imported platform, which the image +configuration must confirm. An amd64 archive is cached under its own address, never +reused for arm64, and imports only into a pool booted with translation; see +[amd64 emulation with Rosetta 2](#amd64-emulation-with-rosetta-2). + ### Explicit writable development source An isolated development pool can opt into a direct host project mount with @@ -1329,6 +1339,10 @@ adaptations are accepted: "volume": "node_modules", "root": "/app", "workspaces": ["apps/web", "packages/db"] + }, + "amd64Emulation": { + "default": "prefer-native", + "services": {"legacy-worker": "amd64", "db": "native"} } } ``` @@ -1353,6 +1367,48 @@ The layout participates in native dependency-cache identity. This shares compati workspace installations; it does not implement a cross-project package store or claim deduplication across different lockfiles. +### amd64 emulation with Rosetta 2 + +The native VM is Linux arm64. On an Apple silicon Mac with Rosetta 2 installed +(`softwareupdate --install-rosetta`), `amd64Emulation` lets selected services run +`linux/amd64` images under Rosetta 2 x86_64 translation inside the VM. It is off unless +the selection opts a service in, and each service takes one mode: + +| Mode | Image platform | +| --- | --- | +| `native` (default) | Linux arm64 only. A declared `platform: linux/amd64` keeps the project on the Compose backend. | +| `prefer-native` | The arm64 variant when the image publishes one, so multi-arch images stay native even when the service declares `linux/amd64`. An image that publishes only `linux/amd64` runs under emulation. | +| `amd64` | The `linux/amd64` variant under emulation, even when the image also publishes arm64. Use it when the workload needs x86_64 binaries, for example prebuilt native modules. | + +`default` sets the mode for every service without its own entry in `services`; omit it +to keep `native`. A named service must exist, a service that declares a Linux arm64 +`platform` cannot opt in, and an unknown mode refuses with `E_CONFIG_INVALID`. Only the +exact `linux/amd64` platform is emulated; microarchitecture variants such as `v3` and +other platforms are never selected. + +When any service opts in, native `up` requests a pool with translation +(`runtime up --amd64-emulation`). A fresh pool is created with SmolVM's `--rosetta`; +a stopped pool is updated with `machine update --rosetta` before it starts; a running +pool without translation refuses with `amd64_emulation_restart_required` and nothing is +stopped. Stop every native project that uses the pool, then start again. A pool keeps +translation once it has it, so other projects sharing it are unaffected: their services +still resolve arm64 images. Without Rosetta 2 the start refuses with +`amd64_emulation_unavailable` before any VM effect. + +Every boot audits translation against the pool's recorded intent: the provider's +stored machine setting, its running configuration, and the guest's `binfmt_misc` +`rosetta` registration (the x86_64 ELF match with the fixed `rosetta-wrapper` +interpreter). A pool without translation must have no such registration. The image +cache keeps amd64 archives separate from arm64 ones, and the runtime admits an amd64 +image only for an opted-in service (`x-hack-amd64-emulation: true` in the reviewed +Compose service, which the adaptation writes) in a pool booted with translation. A +service that is not opted in still refuses a declared `linux/amd64` in native review. + +Emulated services are slower than native ones and can behave differently from arm64 +builds. `hack doctor` on the native backend reports an `amd64 emulation` warning with +the number of opted-in services in each mode (counts only, no service names), and +names the Rosetta 2 install command when it is missing. + ### Optional prepared base for a fresh pool A prepared base formats a **new** pool's disks from an independently verified snapshot of a diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..9ce851620 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -86,6 +86,7 @@ liveness/reachability controls have their own documented assumptions. | [fence-pending](../../tests/models/tla/fence-pending/Fence.tla) | 7 | 2 | | [relay-staging](../../tests/models/tla/relay-staging/Stage.tla) | 9 | 2 | | [relay-fence](../../tests/models/tla/relay-fence/RelayFence.tla) | 16 | 2 | +| [pool-emulation](../../tests/models/tla/pool-emulation/Emulation.tla) | 13 | 3 | | [shared-helper-instance](../../tests/models/tla/shared-helper-instance/Instance.tla) | 7,649 | 11 | | [restore-history](../../tests/models/tla/restore-history/RestoreHistory.tla) | See named result contract | 2 | | [graph-admission](../../tests/models/tla/graph-admission/Admission.tla) | See named result contract | 2 | diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 0ab86ffde..2a7c81ed7 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -101,7 +101,8 @@ Usage: hack-local runtime prepared-base status --profile research|development [--store ] [--json] hack-local runtime prepared-base remove --base-id [--store ] [--json] hack-local runtime admit-image --reference <[namespace/]repository[:tag][@sha256:digest]|sha256:image-id> --json - hack-local runtime ensure-image --reference <[namespace/]repository[:tag]|[namespace/]repository@sha256:digest> --json + hack-local runtime ensure-image --reference <[namespace/]repository[:tag]|[namespace/]repository@sha256:digest> [--emulation native|prefer-native|amd64] --json + hack-local runtime up ... --amd64-emulation [--json] hack-local runtime resolve-image --reference <[namespace/]repository[:tag]> --json hack-local runtime fetch-image --reference <[namespace/]repository@sha256:digest> --archive [--json] hack-local runtime load-image --archive --sha256 --image-id @@ -413,7 +414,8 @@ fn run() -> Result<(), CandidateError> { || arguments.contains(&"--internet") || arguments.contains(&"--project-share") || arguments.contains(&"--unfiltered-source") - || arguments.contains(&"--prepared-base") => + || arguments.contains(&"--prepared-base") + || arguments.contains(&"--amd64-emulation") => { let options = runtime_up_cli::parse(arguments)?; let share = options @@ -434,8 +436,12 @@ fn run() -> Result<(), CandidateError> { dependencies: options.dependencies, minimum_dependencies: options.minimum_dependencies, }; - let started = if let Some((mode, store)) = &options.prepared { - let request = prepared_base_request(&candidate, *mode, store.as_deref())?; + let request = options + .prepared + .as_ref() + .map(|(mode, store)| prepared_base_request(&candidate, *mode, store.as_deref())) + .transpose()?; + let started = if request.is_some() || options.amd64_emulation { hack_runtime_core::provider::up_with_prepared_base( &candidate, options.profile, @@ -444,7 +450,8 @@ fn run() -> Result<(), CandidateError> { share, hack_runtime_core::provider::prepared_start::Start { retained, - prepared: Some(&request), + prepared: request.as_ref(), + amd64_emulation: options.amd64_emulation, }, ) } else { @@ -941,6 +948,28 @@ fn run() -> Result<(), CandidateError> { reference, )?)?; } + [ + "runtime", + "ensure-image", + "--reference", + reference, + "--emulation", + policy, + "--json", + ] => { + let policy = hack_runtime_core::provider::image_platform::PlatformPolicy::parse(policy) + .ok_or_else(|| { + CandidateError::new( + "invalid_arguments", + "Use runtime ensure-image --reference REF [--emulation native|prefer-native|amd64] --json.", + ) + })?; + print_json(&hack_runtime_core::provider::image_ensure::ensure_with( + &discover_candidate(&requested)?, + reference, + policy, + )?)?; + } ["runtime", "admit-image", "--reference", reference, "--json"] => { // Pure: no candidate discovery, network, credentials or runtime effects. print_json(&hack_runtime_core::provider::registry_image::admit( diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index 5cc51846a..d9f829188 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -699,6 +699,7 @@ pub(super) fn compile( "working_dir", "user", "platform", + "x-hack-amd64-emulation", "labels", "logging", "stop_grace_period", @@ -861,6 +862,17 @@ pub(super) fn compile( "Only Linux arm64/amd64 image platform declarations are modeled.", )); } + let amd64_emulation = boolean(m.get("x-hack-amd64-emulation"), false)?; + if amd64_emulation + && platform + .as_deref() + .is_some_and(|v| ["linux/arm64", "linux/arm64/v8"].contains(&v)) + { + return Err(problem( + "unsupported_platform", + "x-hack-amd64-emulation cannot apply to a service that declares a Linux arm64 platform.", + )); + } let exposed_ports = strings(m.get("expose"))?; for value in &exposed_ports { let (port, protocol) = value.split_once('/').unwrap_or((value, "tcp")); @@ -927,7 +939,7 @@ pub(super) fn compile( limits:limits(m,&field,diagnostics)?,read_only:boolean(m.get("read_only"),false)?,init:boolean(m.get("init"),false)?,restart, working_dir:m.get("working_dir").map(|v| safe_absolute(string(v)?)).transpose()?, user:m.get("user").map(|v| match v {Value::String(s)=>redacted(s),Value::Number(n)=>redacted(&n.to_string()),_=>Err(problem("invalid_user","User must be a string or numeric identifier."))}).transpose()?, - platform,labels,routing,dependency_cache,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal, + platform,amd64_emulation,labels,routing,dependency_cache,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal, }); } Ok(result) diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index 043076259..e9b4324e9 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -264,6 +264,11 @@ pub struct ServicePlan { pub working_dir: Option, pub user: Option, pub platform: Option, + /// Explicit `x-hack-amd64-emulation: true` opt-in: this service may run a `linux/amd64` + /// image under the pool's Rosetta 2 translation, and may declare `platform: linux/amd64`. + /// Without it only Linux arm64 is admitted. It never applies to a declared arm64 platform. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub amd64_emulation: bool, pub labels: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub routing: Option, diff --git a/packages/runtime-core/src/provider/config_audit.rs b/packages/runtime-core/src/provider/config_audit.rs index e6d6cbb08..1a52e18a7 100644 --- a/packages/runtime-core/src/provider/config_audit.rs +++ b/packages/runtime-core/src/provider/config_audit.rs @@ -28,6 +28,9 @@ pub(super) fn verify_value( record: &Value, ) -> Result<(), CandidateError> { verify_mounts(candidate, owner, record)?; + if record_amd64_emulation(record)? != owner.amd64_emulation { + return Err(invalid()); + } verify_record( record, &owner.machine, @@ -94,6 +97,9 @@ pub(super) fn pin_created_network( let database = home.join(".local/share/smolvm/server/smolvm.db"); let record = read_database(&database, &owner.machine)?; verify_mounts(candidate, owner, &record)?; + if record_amd64_emulation(&record)? != owner.amd64_emulation { + return Err(invalid()); + } let network = owner.network.pin(&record)?; verify_record( &record, @@ -109,6 +115,31 @@ pub(super) fn pin_created_network( Ok(()) } +/// SmolVM's stored `rosetta` setting. The pinned default record may omit it (disabled); any +/// value other than a boolean is refused. +fn record_amd64_emulation(record: &Value) -> Result { + match record.get("rosetta") { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(invalid()), + } +} + +/// Whether this pool's stored machine record enables Rosetta translation for its next start. +pub(super) fn recorded_amd64_emulation( + candidate: &Candidate, + owner: &Owner, +) -> Result { + let home = candidate.state_root.join("run/smolvm/home"); + #[cfg(target_os = "macos")] + let database = home.join("Library/Application Support/smolvm/server/smolvm.db"); + #[cfg(not(target_os = "macos"))] + let database = home.join(".local/share/smolvm/server/smolvm.db"); + let record = read_database(&database, &owner.machine)?; + verify_mounts(candidate, owner, &record)?; + record_amd64_emulation(&record) +} + fn verify_mounts( candidate: &Candidate, owner: &Owner, @@ -511,6 +542,25 @@ mod tests { } } #[test] + fn stored_rosetta_setting_is_a_closed_boolean_with_a_disabled_default() { + // The pinned default record stores `rosetta: null`; `machine update --rosetta` stores true. + let mut record = fixture(); + assert!(!record_amd64_emulation(&record).unwrap()); + record["rosetta"] = Value::Null; + assert!(!record_amd64_emulation(&record).unwrap()); + record["rosetta"] = json!(false); + assert!(!record_amd64_emulation(&record).unwrap()); + record["rosetta"] = json!(true); + assert!(record_amd64_emulation(&record).unwrap()); + for value in [json!("true"), json!(1), json!({})] { + record["rosetta"] = value; + assert_eq!( + record_amd64_emulation(&record).unwrap_err().code, + "unaudited_provider_config" + ); + } + } + #[test] fn bridge_mapping_must_exactly_match_durable_intent() { let intent = Some(super::super::BridgeIntent::new(2).unwrap()); let mut record = fixture(); diff --git a/packages/runtime-core/src/provider/graph/cache_provenance.rs b/packages/runtime-core/src/provider/graph/cache_provenance.rs index 766d55e72..7fa31602e 100644 --- a/packages/runtime-core/src/provider/graph/cache_provenance.rs +++ b/packages/runtime-core/src/provider/graph/cache_provenance.rs @@ -288,6 +288,7 @@ mod tests { id: Some("a".repeat(64)), image: Some(format!("sha256:{}", "b".repeat(64))), phase: "started".into(), + amd64_emulation: false, } } fn terminal() -> Value { @@ -380,6 +381,7 @@ mod tests { id: None, image: None, phase: "created".into(), + amd64_emulation: false, }; let mut receipt = Receipt { normalized_input: None, diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 257424239..9305f206f 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -199,10 +199,10 @@ pub(super) fn prepare_delivery( }) || service.logging.is_some() || service.image.as_deref().is_none_or(|v| !image_id(v)) - || service - .platform - .as_deref() - .is_some_and(|p| !["linux/arm64", "linux/arm64/v8"].contains(&p)) + || service.platform.as_deref().is_some_and(|p| { + !(["linux/arm64", "linux/arm64/v8"].contains(&p) + || (p == "linux/amd64" && service.amd64_emulation)) + }) { return Err(unsupported()); } @@ -319,6 +319,7 @@ pub(super) fn prepare_delivery( id: None, image: None, phase: "reserved".into(), + amd64_emulation: false, }; resources.insert(format!("{}:{key}", kind.word()), resource); } @@ -341,6 +342,7 @@ pub(super) fn prepare_delivery( id: None, image: Some(image.clone()), phase: "reserved".into(), + amd64_emulation: service.amd64_emulation, }; let labels = labels( owner, diff --git a/packages/runtime-core/src/provider/graph/environment/tests.rs b/packages/runtime-core/src/provider/graph/environment/tests.rs index 990348365..4808d6ec0 100644 --- a/packages/runtime-core/src/provider/graph/environment/tests.rs +++ b/packages/runtime-core/src/provider/graph/environment/tests.rs @@ -151,6 +151,7 @@ fn container_attachment_records_ownership_before_create_and_omits_values_from_me id: None, image: Some(image.clone()), phase: "reserved".into(), + amd64_emulation: false, }; let mut receipt = Receipt { normalized_input: None, diff --git a/packages/runtime-core/src/provider/graph/host_relay.rs b/packages/runtime-core/src/provider/graph/host_relay.rs index fc1c221f6..04ce27e4a 100644 --- a/packages/runtime-core/src/provider/graph/host_relay.rs +++ b/packages/runtime-core/src/provider/graph/host_relay.rs @@ -1017,6 +1017,7 @@ mod tests { id: Some("e".repeat(64)), image: Some(format!("sha256:{}", "f".repeat(64))), phase: "started".into(), + amd64_emulation: false, }; let value = json!({"Id":resource.id,"Image":resource.image,"State":{"Running":true,"StartedAt":"2026-09-17T00:00:00Z"}}); (receipt, resource, value) diff --git a/packages/runtime-core/src/provider/graph/hostname_change.rs b/packages/runtime-core/src/provider/graph/hostname_change.rs index 80a3382ab..378d74b0d 100644 --- a/packages/runtime-core/src/provider/graph/hostname_change.rs +++ b/packages/runtime-core/src/provider/graph/hostname_change.rs @@ -235,6 +235,7 @@ pub(super) mod tests { id: None, image: None, phase: "absent".into(), + amd64_emulation: false, }; let mut volume = resource("data", Kind::Volume, None); volume.id = Some("retained-volume-id".into()); diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..caa894c81 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -235,6 +235,10 @@ pub struct Resource { pub id: Option, pub image: Option, pub phase: String, + /// The service opted in to running a `linux/amd64` image under Rosetta translation. + /// Admission still requires an amd64 image to come from a translation-enabled pool. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub amd64_emulation: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -2146,6 +2150,20 @@ fn check_cache_capacity(engine: &Engine<'_>, owner: &str) -> Result<(), Candidat Ok(()) } +/// An image is admitted for exactly the platform its service may run: Linux arm64, or Linux +/// amd64 only for an opted-in service in a pool whose audited boot enabled x86_64 translation. +pub(super) fn admitted_architecture( + engine: &Engine<'_>, + resource: &Resource, + image: &Value, +) -> bool { + image["Os"] == "linux" + && (image["Architecture"] == "arm64" + || (image["Architecture"] == "amd64" + && resource.amd64_emulation + && engine.guest().amd64_emulation())) +} + fn verify_images( engine: &Engine<'_>, resources: &BTreeMap, @@ -2163,8 +2181,7 @@ fn verify_images( None, )?; if image["Id"].as_str() != resource.image.as_deref() - || image["Os"] != "linux" - || image["Architecture"] != "arm64" + || !admitted_architecture(engine, resource, &image) || image["Config"]["Volumes"] .as_object() .is_some_and(|v| !v.is_empty()) diff --git a/packages/runtime-core/src/provider/graph/native/mod.rs b/packages/runtime-core/src/provider/graph/native/mod.rs index 741f3e072..405f9c152 100644 --- a/packages/runtime-core/src/provider/graph/native/mod.rs +++ b/packages/runtime-core/src/provider/graph/native/mod.rs @@ -162,6 +162,7 @@ fn configuration_with_source( id: None, image: None, phase: "reserved".into(), + amd64_emulation: false, }; network_names.insert(key.clone(), network.name.clone()); resources.insert(format!("network:{key}"), network); @@ -190,6 +191,7 @@ fn configuration_with_source( id: None, image: Some(workload.image.clone()), phase: "reserved".into(), + amd64_emulation: false, }; let mut config = config::container_base(&workload.image, labels(owner, review, &resource)); if workload.source_mount.is_some() { diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index 074c93ea7..2ed874fe9 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -1742,8 +1742,7 @@ fn verify_native_images( )?; let config = configs.get_mut(&resource.key).ok_or_else(refused)?; if image["Id"].as_str() != resource.image.as_deref() - || image["Os"] != "linux" - || image["Architecture"] != "arm64" + || !crate::provider::graph::admitted_architecture(engine, resource, &image) { return Err(refused()); } diff --git a/packages/runtime-core/src/provider/graph/restore_history/tests.rs b/packages/runtime-core/src/provider/graph/restore_history/tests.rs index 32fbedc88..bcbc826a1 100644 --- a/packages/runtime-core/src/provider/graph/restore_history/tests.rs +++ b/packages/runtime-core/src/provider/graph/restore_history/tests.rs @@ -32,6 +32,7 @@ fn receipt(index: usize) -> Receipt { id: Some(format!("{index:064x}")), image: None, phase: "absent".into(), + amd64_emulation: false, }, )]), } diff --git a/packages/runtime-core/src/provider/graph/shutdown.rs b/packages/runtime-core/src/provider/graph/shutdown.rs index 011b2139b..34216d420 100644 --- a/packages/runtime-core/src/provider/graph/shutdown.rs +++ b/packages/runtime-core/src/provider/graph/shutdown.rs @@ -416,6 +416,7 @@ mod tests { id: Some("a".repeat(64)), image: Some(format!("sha256:{}", "b".repeat(64))), phase: "started".into(), + amd64_emulation: false, }; let value = json!({"Id":resource.id,"Config":{"StopTimeout":10,"StopSignal":"SIGTERM"}, "State":{"Running":true,"Status":"running","Pid":42,"Paused":false, diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 11aa9ed0c..49c7c6e41 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -891,6 +891,7 @@ mod tests { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + amd64_emulation: false, }, )]), }; diff --git a/packages/runtime-core/src/provider/graph/storage_inventory.rs b/packages/runtime-core/src/provider/graph/storage_inventory.rs index 03c224c03..667f6b774 100644 --- a/packages/runtime-core/src/provider/graph/storage_inventory.rs +++ b/packages/runtime-core/src/provider/graph/storage_inventory.rs @@ -214,6 +214,7 @@ mod tests { id: None, image: None, phase: "created".into(), + amd64_emulation: false, }; let volume = json!({"name":resource.name,"resource":"deps","observed_state":"present","classification":"referenced_by_graph"}); let mut references = BTreeMap::new(); diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..908b6df23 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -157,6 +157,61 @@ fn driver_profile_preserves_isolation_and_refuses_unqualified_inputs() { assert!(!candidate.state_root.exists()); } #[test] +fn declared_amd64_requires_the_explicit_emulation_opt_in() { + let fixture = Fixture::new(); + let home = Fixture::new(); + let candidate = Candidate::discover(&home.0).unwrap(); + for (platform, opted, admitted) in [ + (None, false, true), + (Some("linux/arm64"), false, true), + (Some("linux/amd64"), false, false), + (Some("linux/amd64"), true, true), + (None, true, true), + ] { + let mut document = compose(&format!("sha256:{}", "a".repeat(64)), "marker", false); + if let Some(platform) = platform { + document["services"]["web"]["platform"] = json!(platform); + } + if opted { + document["services"]["web"]["x-hack-amd64-emulation"] = json!(true); + } + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ) + .unwrap(); + match config::prepare(inputs, &goals(), &"a".repeat(32), &"b".repeat(32), None) { + Ok(prepared) => { + assert!(admitted, "{platform:?} {opted}"); + let web = prepared + .resources + .values() + .find(|resource| resource.key == "web" && resource.kind == Kind::Container) + .unwrap(); + // Only the opted-in service may later admit an amd64 image; the others stay + // arm64-only regardless of what the engine holds. + assert_eq!(web.amd64_emulation, opted); + assert!( + prepared + .resources + .values() + .filter(|resource| resource.key != "web") + .all(|resource| !resource.amd64_emulation) + ); + } + Err(error) => { + assert!(!admitted, "{platform:?} {opted}"); + assert_eq!(error.code, "graph_subset"); + } + } + } + assert!(!candidate.state_root.exists()); +} +#[test] fn compose_pid_limit_is_opt_in_and_explicit_limits_remain_verified() { let fixture = Fixture::new(); let home = Fixture::new(); @@ -1889,6 +1944,7 @@ fn outbound_network_is_pinned_and_requires_explicit_pool_capability() { id: None, image: None, phase: "reserved".into(), + amd64_emulation: false, }; let resources = BTreeMap::from([("network:default".into(), resource)]); assert!(check_network_request(&super::super::NetworkIntent::Isolated, &resources).is_err()); @@ -2031,6 +2087,7 @@ fn endpoint_selects_service_primary_not_unrelated_first_graph_network() { id: Some(id.clone()), image: None, phase: "created".into(), + amd64_emulation: false, }, ); networks.insert(format!("network:{key}"), Some(json!({"Id":id,"Name":name,"Containers":{"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee":{"EndpointID":"f".repeat(64),"IPv4Address":"172.20.0.2/16"}}}))); diff --git a/packages/runtime-core/src/provider/guest-translation.sh b/packages/runtime-core/src/provider/guest-translation.sh new file mode 100644 index 000000000..f13378f7b --- /dev/null +++ b/packages/runtime-core/src/provider/guest-translation.sh @@ -0,0 +1,26 @@ +set -eu +# Confirms the guest's x86_64 translation matches the pool's audited intent before the engine +# starts. SmolVM 1.14.3's agent mounts the host Rosetta runtime (virtiofs tag `rosetta`) at +# /mnt/rosetta and registers it with binfmt_misc as `rosetta`, matching x86_64 ELF with the +# fix-binary flag so container mount namespaces use the interpreter opened at registration. +test "$(cat /storage/.hack-local-owner)" = "$1" +test "$(cat /proc/sys/kernel/random/boot_id)" = "$2" +entry=/proc/sys/fs/binfmt_misc/rosetta +case "$3" in +enabled) + test -f "$entry" + test "$(sed -n 1p "$entry")" = enabled + grep -qx 'interpreter /usr/bin/rosetta-wrapper' "$entry" + grep -qx 'flags: F' "$entry" + grep -qx 'magic 7f454c4602010100000000000000000002003e00' "$entry" + test "$(findmnt -n -o FSTYPE --mountpoint /mnt/rosetta)" = virtiofs + printf 'amd64-translation-enabled-v1\n' + ;; +disabled) + test ! -e "$entry" + printf 'amd64-translation-disabled-v1\n' + ;; +*) + exit 2 + ;; +esac diff --git a/packages/runtime-core/src/provider/image_ensure.rs b/packages/runtime-core/src/provider/image_ensure.rs index fe2c06af1..566ee8a2d 100644 --- a/packages/runtime-core/src/provider/image_ensure.rs +++ b/packages/runtime-core/src/provider/image_ensure.rs @@ -1,4 +1,5 @@ //! Candidate-owned bounded image acquisition cache; import remains image_load's operation. +use super::image_platform::{ImagePlatform, PlatformPolicy}; use super::{engine, image_load, registry_image, state}; use crate::{Candidate, CandidateError, reject_aliased_state}; use serde::{Deserialize, Serialize}; @@ -28,6 +29,17 @@ fn hex(value: &str) -> bool { fn key(value: &str) -> String { format!("{:x}", Sha256::digest(value.as_bytes())) } +/// Cache address of one pinned reference for one platform. An arm64 entry keeps its original +/// address; an emulated platform of the same index is a separate entry, never a reuse. +fn entry_key(reference: &str, platform: ImagePlatform) -> String { + match platform { + ImagePlatform::LinuxArm64 => key(reference), + ImagePlatform::LinuxAmd64 => key(&format!("{reference}\n{}", platform.name())), + } +} +fn native(platform: &ImagePlatform) -> bool { + *platform == ImagePlatform::LinuxArm64 +} fn canonical(value: &str) -> Result { let (repo, pin) = registry_image::reference(value)?; Ok(format!("docker.io/{repo}@{pin}")) @@ -41,6 +53,8 @@ struct CacheReceipt { archive_sha256: String, image_id: String, archive_bytes: u64, + #[serde(default, skip_serializing_if = "native")] + platform: ImagePlatform, } impl CacheReceipt { fn validate(&self, candidate: &Candidate, reference: &str) -> Result<(), CandidateError> { @@ -105,6 +119,7 @@ fn inspect_entry( candidate: &Candidate, dir: &Path, reference: &str, + platform: ImagePlatform, ) -> Result { state::check_private_directory(dir)?; let mut count = 0; @@ -129,6 +144,11 @@ fn inspect_entry( } let receipt: CacheReceipt = read_receipt(&dir.join("receipt.json"))?; receipt.validate(candidate, reference)?; + if receipt.platform != platform { + return Err(refused( + "Image cache entry platform differs from its address.", + )); + } if archive_size(&dir.join("image.tar"))? != receipt.archive_bytes { return Err(refused("Cached archive size differs from its receipt.")); } @@ -152,11 +172,13 @@ fn usage(candidate: &Candidate, root: &Path) -> Result<(usize, u64), CandidateEr let dir = entry.path(); state::check_private_directory(&dir)?; let receipt: CacheReceipt = read_receipt(&dir.join("receipt.json"))?; - if key(&receipt.reference) != name.to_string_lossy() { + if entry_key(&receipt.reference, receipt.platform) != name.to_string_lossy() { return Err(refused("Image cache address conflicts with its receipt.")); } bytes = bytes - .checked_add(inspect_entry(candidate, &dir, &receipt.reference)?.archive_bytes) + .checked_add( + inspect_entry(candidate, &dir, &receipt.reference, receipt.platform)?.archive_bytes, + ) .ok_or_else(|| refused("Image cache size overflow."))?; if bytes > MAX_CACHE { return Err(refused( @@ -173,6 +195,8 @@ pub struct EnsuredImage { pub archive_sha256: String, pub archive_reused: bool, pub phase: &'static str, + /// The platform loaded; `linux/amd64` runs only under the pool's x86_64 translation. + pub platform: ImagePlatform, } /// Resolve tags on every call, reuse their exact immutable archive when available, /// and validate/import via the owned guest's existing loader. The engine must already @@ -180,6 +204,17 @@ pub struct EnsuredImage { /// 16-archive/2 GiB budget; this operation never prunes or adopts conflicting files. /// Runtime ownership is checked again by load_image after network work. pub fn ensure(candidate: &Candidate, reference: &str) -> Result { + ensure_with(candidate, reference, PlatformPolicy::Native) +} + +/// [`ensure`] under an explicit platform policy. `Native` is the default and admits only +/// Linux arm64. An opted-in policy may select Linux amd64, which is cached separately and +/// imported only into a pool booted with x86_64 translation (`image_load::load_for`). +pub fn ensure_with( + candidate: &Candidate, + reference: &str, + policy: PlatformPolicy, +) -> Result { // Validate input before touching runtime state; tags are checked by resolve after // the engine preflight, and pinned references never make a mutable resolution call. if reference.contains('@') { @@ -188,46 +223,79 @@ pub fn ensure(candidate: &Candidate, reference: &str) -> Result PlatformPolicy::Amd64, + _ => PlatformPolicy::Native, + }; + (resolved.pinned_reference, exact) }; ensure_pinned( candidate, &pinned, - registry_image::acquire, - |candidate, path, sha, image| image_load::load(candidate, path, sha, image).map(|_| ()), + policy, + registry_image::acquire_with, + |candidate, path, sha, image, platform| { + image_load::load_for(candidate, path, sha, image, platform).map(|_| ()) + }, ) } fn ensure_pinned( candidate: &Candidate, reference: &str, - acquire: impl FnOnce(&str, &Path) -> Result, - load: impl FnOnce(&Candidate, &Path, &str, &str) -> Result<(), CandidateError>, + policy: PlatformPolicy, + acquire: impl FnOnce( + &str, + &Path, + PlatformPolicy, + ) -> Result, + load: impl FnOnce(&Candidate, &Path, &str, &str, ImagePlatform) -> Result<(), CandidateError>, ) -> Result { let reference = canonical(reference)?; let root = candidate.state_root.join("artifacts/images"); let _lock = state::Lock::acquire(&root)?; let (count, bytes) = usage(candidate, &root)?; - let dir = root.join(key(&reference)); - let reused = dir.try_exists().map_err(state::io)?; - let receipt = if reused { - inspect_entry(candidate, &dir, &reference)? + // The first complete entry in policy order is reused: a published arm64 entry always + // wins over an emulated one under `PreferNative`. + let mut existing = None; + for platform in policy.candidates() { + let dir = root.join(entry_key(&reference, *platform)); + if dir.try_exists().map_err(state::io)? { + existing = Some((dir, *platform)); + break; + } + } + let reused = existing.is_some(); + let (dir, receipt) = if let Some((dir, platform)) = existing { + let receipt = inspect_entry(candidate, &dir, &reference, platform)?; + (dir, receipt) } else { if count >= MAX_ARCHIVES || bytes > MAX_CACHE - MAX_ARCHIVE { return Err(refused( "Image cache admission requires a free 256 MiB slot within 16 archives and 2 GiB total; no automatic deletion.", )); } + // Acquire into the first candidate's slot; a different confirmed platform moves the + // complete entry to its own address below, still under the cache lock. + let first = policy.candidates()[0]; + let mut dir = root.join(entry_key(&reference, first)); state::private_directory(&dir)?; - let acquired = match acquire(&reference, &dir.join("image.tar")) { + let acquired = match acquire(&reference, &dir.join("image.tar"), policy) { Ok(value) => value, Err(error) => { let _ = fs::remove_dir(&dir); return Err(error); } }; + if !policy.candidates().contains(&acquired.platform) { + return Err(refused( + "Acquired image platform is outside the requested platform policy.", + )); + } let (_, pin) = registry_image::reference(&reference)?; if acquired.source_digest != pin.as_str() { return Err(refused( @@ -241,13 +309,24 @@ fn ensure_pinned( archive_sha256: acquired.archive_sha256, image_id: acquired.image_id, archive_bytes: acquired.archive_bytes, + platform: acquired.platform, }; receipt.validate(candidate, &reference)?; if archive_size(&dir.join("image.tar"))? != receipt.archive_bytes { return Err(refused("New archive differs from its acquisition receipt.")); } state::write(&dir.join("receipt.json"), &receipt)?; - receipt + if acquired.platform != first { + let target = root.join(entry_key(&reference, acquired.platform)); + if target.try_exists().map_err(state::io)? { + return Err(refused( + "Conflicting image cache entry; no entry was adopted or removed.", + )); + } + fs::rename(&dir, &target).map_err(state::io)?; + dir = target; + } + (dir, receipt) }; // Even a warm cache goes through the loader's hash, ownership, validation receipt, // and content-ID/architecture readback; cached metadata alone never proves import. @@ -256,6 +335,7 @@ fn ensure_pinned( &dir.join("image.tar"), &receipt.archive_sha256, &receipt.image_id, + receipt.platform, )?; Ok(EnsuredImage { pinned_reference: reference, @@ -263,6 +343,7 @@ fn ensure_pinned( archive_sha256: receipt.archive_sha256, archive_reused: reused, phase: "content-id-verified", + platform: receipt.platform, }) } @@ -299,6 +380,7 @@ mod tests { fn acquire( reference: &str, path: &Path, + policy: PlatformPolicy, ) -> Result { fs::write(path, b"fixture").unwrap(); fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); @@ -308,6 +390,7 @@ mod tests { image_id: format!("sha256:{}", "c".repeat(64)), archive_sha256: key("fixture"), archive_bytes: 7, + platform: policy.candidates()[0], }) } #[test] @@ -319,11 +402,12 @@ mod tests { let result = ensure_pinned( &f.1, &pin(1), - |reference, path| { + PlatformPolicy::Native, + |reference, path, policy| { acquired.set(acquired.get() + 1); - acquire(reference, path) + acquire(reference, path, policy) }, - |_, path, sha, image| { + |_, path, sha, image, _| { loaded.set(loaded.get() + 1); assert_eq!(fs::read(path).unwrap(), b"fixture"); assert_eq!(sha, key("fixture")); @@ -341,13 +425,21 @@ mod tests { fn cache_count_budget_refuses_without_deleting_existing_archives() { let f = Fixture::new(); for n in 0..MAX_ARCHIVES { - ensure_pinned(&f.1, &pin(n), acquire, |_, _, _, _| Ok(())).unwrap(); + ensure_pinned( + &f.1, + &pin(n), + PlatformPolicy::Native, + acquire, + |_, _, _, _, _| Ok(()), + ) + .unwrap(); } let error = ensure_pinned( &f.1, &pin(99), - |_, _| panic!("must not acquire"), - |_, _, _, _| panic!("must not load"), + PlatformPolicy::Native, + |_, _, _| panic!("must not acquire"), + |_, _, _, _, _| panic!("must not load"), ) .unwrap_err(); assert!(error.message.contains("16 archives")); @@ -361,7 +453,14 @@ mod tests { let f = Fixture::new(); for n in 0..8 { let reference = pin(n); - ensure_pinned(&f.1, &reference, acquire, |_, _, _, _| Ok(())).unwrap(); + ensure_pinned( + &f.1, + &reference, + PlatformPolicy::Native, + acquire, + |_, _, _, _, _| Ok(()), + ) + .unwrap(); let dir = f.1.state_root .join("artifacts/images") @@ -384,8 +483,9 @@ mod tests { ensure_pinned( &f.1, &pin(99), - |_, _| panic!("budget refuses fetch"), - |_, _, _, _| panic!("no load") + PlatformPolicy::Native, + |_, _, _| panic!("budget refuses fetch"), + |_, _, _, _, _| panic!("no load") ) .is_err() ); @@ -394,7 +494,14 @@ mod tests { fn conflicting_and_unreceipted_cache_entries_refuse_before_load() { let f = Fixture::new(); let reference = pin(1); - ensure_pinned(&f.1, &reference, acquire, |_, _, _, _| Ok(())).unwrap(); + ensure_pinned( + &f.1, + &reference, + PlatformPolicy::Native, + acquire, + |_, _, _, _, _| Ok(()), + ) + .unwrap(); let dir = f.1.state_root .join("artifacts/images") @@ -404,8 +511,9 @@ mod tests { ensure_pinned( &f.1, &reference, - |_, _| panic!("no acquire"), - |_, _, _, _| panic!("no load") + PlatformPolicy::Native, + |_, _, _| panic!("no acquire"), + |_, _, _, _, _| panic!("no load") ) .is_err() ); @@ -414,8 +522,9 @@ mod tests { ensure_pinned( &f.1, &reference, - |_, _| panic!("no acquire"), - |_, _, _, _| panic!("no load") + PlatformPolicy::Native, + |_, _, _| panic!("no acquire"), + |_, _, _, _, _| panic!("no load") ) .is_err() ); @@ -429,8 +538,9 @@ mod tests { ensure_pinned( &f.1, &reference, - |_, _| Err(refused("synthetic fetch failure")), - |_, _, _, _| panic!("no load") + PlatformPolicy::Native, + |_, _, _| Err(refused("synthetic fetch failure")), + |_, _, _, _, _| panic!("no load") ) .is_err() ); @@ -441,21 +551,93 @@ mod tests { .exists() ); assert!( - ensure_pinned(&f.1, &reference, acquire, |_, _, _, _| Err(refused( - "synthetic import failure" - ))) + ensure_pinned( + &f.1, + &reference, + PlatformPolicy::Native, + acquire, + |_, _, _, _, _| Err(refused("synthetic import failure")) + ) .is_err() ); let receipt = ensure_pinned( &f.1, &reference, - |_, _| panic!("reuse complete archive"), - |_, _, _, _| Ok(()), + PlatformPolicy::Native, + |_, _, _| panic!("reuse complete archive"), + |_, _, _, _, _| Ok(()), ) .unwrap(); assert!(receipt.archive_reused); } #[test] + fn emulated_platform_entries_are_separate_and_prefer_native_reuses_arm64_first() { + let f = Fixture::new(); + let root = f.1.state_root.join("artifacts/images"); + let amd64_only = |reference: &str, path: &Path, policy: PlatformPolicy| { + let mut acquired = acquire(reference, path, policy)?; + acquired.platform = ImagePlatform::LinuxAmd64; + Ok(acquired) + }; + // An amd64-only index acquired under PreferNative moves to its own amd64 address + // and loads as amd64. + let loaded = Cell::new(None); + let result = ensure_pinned( + &f.1, + &pin(1), + PlatformPolicy::PreferNative, + amd64_only, + |_, _, _, _, platform| { + loaded.set(Some(platform)); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(result.platform, ImagePlatform::LinuxAmd64); + assert_eq!(loaded.get(), Some(ImagePlatform::LinuxAmd64)); + assert!(!root.join(key(&pin(1))).exists()); + assert!( + root.join(entry_key(&pin(1), ImagePlatform::LinuxAmd64)) + .exists() + ); + // The native default never reuses or loads the emulated entry. + let native = ensure_pinned( + &f.1, + &pin(1), + PlatformPolicy::Native, + acquire, + |_, _, _, _, platform| { + assert_eq!(platform, ImagePlatform::LinuxArm64); + Ok(()) + }, + ) + .unwrap(); + assert!(!native.archive_reused); + // With both entries present, PreferNative reuses arm64. + let preferred = ensure_pinned( + &f.1, + &pin(1), + PlatformPolicy::PreferNative, + |_, _, _| panic!("reuse complete archive"), + |_, _, _, _, _| Ok(()), + ) + .unwrap(); + assert!(preferred.archive_reused); + assert_eq!(preferred.platform, ImagePlatform::LinuxArm64); + assert_eq!(usage(&f.1, &root).unwrap().0, 2); + // An acquisition outside the policy is refused. + assert!( + ensure_pinned( + &f.1, + &pin(2), + PlatformPolicy::Native, + amd64_only, + |_, _, _, _, _| panic!("no load") + ) + .is_err() + ); + } + #[test] fn invalid_input_or_absent_runtime_never_allocates_archive_cache() { let f = Fixture::new(); assert!(ensure(&f.1, "untrusted/thing:bad tag").is_err()); diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index b49e9781e..77818da02 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -1,5 +1,5 @@ //! Explicit, bounded local image archives; no registry credentials or Docker contexts. -use super::{engine::Engine, state}; +use super::{engine::Engine, image_platform::ImagePlatform, state}; use crate::{Candidate, CandidateError}; use flate2::read::MultiGzDecoder; use reqwest::Method; @@ -114,7 +114,18 @@ fn archive_member(bytes: &[u8], name: &str, limit: u64) -> Result, Candi Err(error("Image archive member is missing.")) } +#[cfg(test)] fn validate_archive(bytes: &[u8], image: &str, max_expanded: u64) -> Result<(), CandidateError> { + validate_archive_for(bytes, image, max_expanded, ImagePlatform::LinuxArm64) +} + +/// Validates one flat image archive whose configuration targets exactly `platform`. +fn validate_archive_for( + bytes: &[u8], + image: &str, + max_expanded: u64, + platform: ImagePlatform, +) -> Result<(), CandidateError> { if !image.strip_prefix("sha256:").is_some_and(hex) || bytes.len() as u64 > MAX_ARCHIVE { return Err(error("Invalid image identity or archive size.")); } @@ -174,7 +185,7 @@ fn validate_archive(bytes: &[u8], image: &str, max_expanded: u64) -> Result<(), } let config: Value = serde_json::from_slice(&config_bytes).map_err(|_| error("Invalid image configuration."))?; - if config["architecture"] != "arm64" || config["os"] != "linux" { + if config["architecture"] != platform.architecture() || config["os"] != "linux" { return Err(error( "Image architecture is incompatible with this provider.", )); @@ -227,8 +238,17 @@ fn validate_archive(bytes: &[u8], image: &str, max_expanded: u64) -> Result<(), Ok(()) } +#[cfg(test)] pub(super) fn validate_download(bytes: &[u8], image: &str) -> Result<(), CandidateError> { - validate_archive(bytes, image, MAX_EXPANDED) + validate_download_for(bytes, image, ImagePlatform::LinuxArm64) +} + +pub(super) fn validate_download_for( + bytes: &[u8], + image: &str, + platform: ImagePlatform, +) -> Result<(), CandidateError> { + validate_archive_for(bytes, image, MAX_EXPANDED, platform) } #[derive(Serialize, Deserialize)] @@ -253,11 +273,15 @@ fn reusable_validation(previous: &ImageReceipt, requested: &ImageReceipt) -> boo && previous.image_id == requested.image_id } -fn inspect(engine: &Engine<'_>, image: &str) -> Result { +fn inspect( + engine: &Engine<'_>, + image: &str, + platform: ImagePlatform, +) -> Result { match engine.request(Method::GET, &format!("/v1.53/images/{image}/json"), None) { Ok(value) if value["Id"] == image - && value["Architecture"] == "arm64" + && value["Architecture"] == platform.architecture() && value["Os"] == "linux" => { Ok(true) @@ -298,7 +322,33 @@ pub fn load( expected_sha: &str, image: &str, ) -> Result { - load_waiting(candidate, path, expected_sha, image, IMPORT_LEASE_WAIT) + load_for( + candidate, + path, + expected_sha, + image, + ImagePlatform::LinuxArm64, + ) +} + +/// [`load`] for an archive of `platform`. An emulated platform imports only into a pool +/// whose audited boot enabled x86_64 translation; the archive and readback are both bound to +/// that platform. +pub(super) fn load_for( + candidate: &Candidate, + path: &Path, + expected_sha: &str, + image: &str, + platform: ImagePlatform, +) -> Result { + load_waiting( + candidate, + path, + expected_sha, + image, + IMPORT_LEASE_WAIT, + platform, + ) } fn load_waiting( @@ -307,6 +357,7 @@ fn load_waiting( expected_sha: &str, image: &str, lease_wait: Duration, + platform: ImagePlatform, ) -> Result { if !hex(expected_sha) || !image.strip_prefix("sha256:").is_some_and(hex) { return Err(error("Expected archive and image hashes are required.")); @@ -328,6 +379,9 @@ fn load_waiting( return Err(error("Image archive size changed during verification.")); } let engine = connect_for_import(candidate, lease_wait)?; + if platform.emulated() && !engine.guest().amd64_emulation() { + return Err(super::lifecycle::amd64_emulation_not_enabled()); + } let directory = candidate.state_root.join("run/image-loads"); state::private_directory(&directory)?; let path = directory.join(format!("{expected_sha}.json")); @@ -353,7 +407,7 @@ fn load_waiting( // The complete archive hash was checked above. Only this validator's completed, // identity-bound receipt can avoid expanding the same layers again; engine presence // and architecture are still checked on every invocation, including after reboot. - if reusable_validation(&old, &receipt) && inspect(&engine, image)? { + if reusable_validation(&old, &receipt) && inspect(&engine, image, platform)? { if old.provider_boot_id == receipt.provider_boot_id { return Ok(old); } @@ -363,7 +417,7 @@ fn load_waiting( } if old.phase == "loading" && old.provider_boot_id == receipt.provider_boot_id - && !inspect(&engine, image)? + && !inspect(&engine, image, platform)? { return Err(CandidateError::new( "image_load_indeterminate", @@ -372,12 +426,12 @@ fn load_waiting( } } let bytes = read_verified_archive(&mut file, expected_sha)?; - validate_archive(&bytes, image, MAX_EXPANDED)?; - if !inspect(&engine, image)? { + validate_archive_for(&bytes, image, MAX_EXPANDED, platform)?; + if !inspect(&engine, image, platform)? { state::write(&path, &receipt)?; let result = engine.load_image_archive(bytes); // A lost response is reconciled only by independent content-ID readback. - if !inspect(&engine, image)? { + if !inspect(&engine, image, platform)? { result?; return Err(error("The expected image is absent after load.")); } @@ -530,6 +584,18 @@ mod tests { } } + #[test] + fn an_amd64_archive_validates_only_for_the_selected_amd64_platform() { + let amd64 = ImagePlatform::LinuxAmd64; + let (bytes, image) = fixture("amd64", false, false); + assert!(validate_archive_for(&bytes, &image, 4096, amd64).is_ok()); + assert!(validate_download_for(&bytes, &image, ImagePlatform::LinuxArm64).is_err()); + let (bytes, image) = fixture("arm64", false, false); + assert!(validate_archive_for(&bytes, &image, 4096, amd64).is_err()); + let (bytes, image) = fixture("amd64", false, true); + assert!(validate_archive_for(&bytes, &image, 4096, amd64).is_err()); + } + /// A candidate whose provider looks running to every check before the lease. Its recorded /// process is synthetic, so identity verification under an acquired lease always fails: /// an attempt that got past the lease is visible without any engine being reached. @@ -606,6 +672,7 @@ mod tests { &sha, &image, Duration::from_millis(300), + ImagePlatform::LinuxArm64, ) .err() .unwrap(); @@ -622,9 +689,16 @@ mod tests { drop(held); }); let started = Instant::now(); - let admitted = load_waiting(&pool.candidate, &path, &sha, &image, Duration::from_secs(5)) - .err() - .unwrap(); + let admitted = load_waiting( + &pool.candidate, + &path, + &sha, + &image, + Duration::from_secs(5), + ImagePlatform::LinuxArm64, + ) + .err() + .unwrap(); release.join().unwrap(); assert_ne!(admitted.code, "provider_busy", "{admitted:?}"); assert!(started.elapsed() >= Duration::from_millis(200)); diff --git a/packages/runtime-core/src/provider/image_platform.rs b/packages/runtime-core/src/provider/image_platform.rs new file mode 100644 index 000000000..3335b3a4a --- /dev/null +++ b/packages/runtime-core/src/provider/image_platform.rs @@ -0,0 +1,153 @@ +//! Linux image platforms the guest can run, and the explicit policy that selects one. +//! +//! The guest is Linux arm64. `linux/amd64` runs only under Rosetta 2 x86_64 translation in a +//! pool that was booted with it (`Owner::amd64_emulation`), and only for a service that opted +//! in. Selection never substitutes silently: without an opt-in policy only arm64 is admitted. +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// One concrete image platform. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImagePlatform { + #[default] + #[serde(rename = "linux/arm64")] + LinuxArm64, + #[serde(rename = "linux/amd64")] + LinuxAmd64, +} + +impl ImagePlatform { + /// The OCI `os/architecture` name, as Compose `platform` writes it. + pub fn name(self) -> &'static str { + match self { + Self::LinuxArm64 => "linux/arm64", + Self::LinuxAmd64 => "linux/amd64", + } + } + + /// The architecture Docker reports for a loaded image of this platform. + pub fn architecture(self) -> &'static str { + match self { + Self::LinuxArm64 => "arm64", + Self::LinuxAmd64 => "amd64", + } + } + + /// Whether running this platform requires Rosetta translation in the guest. + pub fn emulated(self) -> bool { + self == Self::LinuxAmd64 + } + + /// Whether an index or config platform names exactly this platform. arm64 accepts an + /// absent or `v8` variant; amd64 accepts only an absent variant, so microarchitecture + /// levels such as `v3` are never chosen implicitly. + pub fn matches(self, os: &str, architecture: &str, variant: Option<&str>) -> bool { + os == "linux" + && architecture == self.architecture() + && match self { + Self::LinuxArm64 => variant.is_none_or(|v| v == "v8"), + Self::LinuxAmd64 => variant.is_none(), + } + } + + /// Whether an image configuration document targets exactly this platform. + pub fn matches_config(self, config: &Value) -> bool { + let variant = match config.get("variant") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.as_str()), + Some(_) => return false, + }; + match (config["os"].as_str(), config["architecture"].as_str()) { + (Some(os), Some(architecture)) => self.matches(os, architecture, variant), + _ => false, + } + } +} + +/// How a service's image platform is chosen. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PlatformPolicy { + /// Linux arm64 only (the default, and the only choice without an opt-in). + #[default] + Native, + /// Linux arm64 when the image publishes it, otherwise Linux amd64 under emulation. + PreferNative, + /// Linux amd64 under emulation, even when the image also publishes arm64. + Amd64, +} + +impl PlatformPolicy { + /// `native`, `prefer-native` or `amd64`, as `runtime ensure-image --emulation` takes them. + pub fn parse(value: &str) -> Option { + match value { + "native" => Some(Self::Native), + "prefer-native" => Some(Self::PreferNative), + "amd64" => Some(Self::Amd64), + _ => None, + } + } + + /// Platforms in preference order. + pub fn candidates(self) -> &'static [ImagePlatform] { + match self { + Self::Native => &[ImagePlatform::LinuxArm64], + Self::PreferNative => &[ImagePlatform::LinuxArm64, ImagePlatform::LinuxAmd64], + Self::Amd64 => &[ImagePlatform::LinuxAmd64], + } + } + + /// Whether this policy may select an emulated platform. + pub fn may_emulate(self) -> bool { + self != Self::Native + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn platforms_match_exact_linux_architectures_and_variants() { + let arm = ImagePlatform::LinuxArm64; + let amd = ImagePlatform::LinuxAmd64; + assert!(arm.matches("linux", "arm64", None)); + assert!(arm.matches("linux", "arm64", Some("v8"))); + assert!(!arm.matches("linux", "arm64", Some("v9"))); + assert!(!arm.matches("darwin", "arm64", None)); + assert!(amd.matches("linux", "amd64", None)); + assert!(!amd.matches("linux", "amd64", Some("v3"))); + assert!(!amd.matches("linux", "arm64", None)); + assert!(amd.matches_config(&json!({"os":"linux","architecture":"amd64"}))); + assert!(!amd.matches_config(&json!({"os":"linux","architecture":"amd64","variant":"v2"}))); + assert!(!amd.matches_config(&json!({"os":"linux","architecture":"amd64","variant":1}))); + assert!(arm.matches_config(&json!({"os":"linux","architecture":"arm64","variant":"v8"}))); + assert!(!arm.matches_config(&json!({"os":"linux","architecture":"amd64"}))); + assert_eq!(serde_json::to_value(amd).unwrap(), json!("linux/amd64")); + assert!(amd.emulated() && !arm.emulated()); + } + + #[test] + fn policies_parse_closed_names_and_order_native_first() { + assert_eq!( + PlatformPolicy::parse("native"), + Some(PlatformPolicy::Native) + ); + assert_eq!( + PlatformPolicy::parse("prefer-native"), + Some(PlatformPolicy::PreferNative) + ); + assert_eq!(PlatformPolicy::parse("amd64"), Some(PlatformPolicy::Amd64)); + assert_eq!(PlatformPolicy::parse("linux/amd64"), None); + assert_eq!( + PlatformPolicy::Native.candidates(), + &[ImagePlatform::LinuxArm64] + ); + assert_eq!( + PlatformPolicy::PreferNative.candidates(), + &[ImagePlatform::LinuxArm64, ImagePlatform::LinuxAmd64] + ); + assert!(!PlatformPolicy::Native.may_emulate()); + assert!(PlatformPolicy::PreferNative.may_emulate()); + } +} diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 9c3a20650..0e7beea98 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -346,6 +346,7 @@ impl<'a> ObservedGuest<'a> { || current.project_share != self.owner.project_share || current.application_bridge != self.owner.application_bridge || current.dependency_sockets != self.owner.dependency_sockets + || current.amd64_emulation != self.owner.amd64_emulation || current.process != self.owner.process { return Err(CandidateError::new( @@ -432,6 +433,10 @@ impl<'a> OwnedGuest<'a> { pub(super) fn project_share(&self) -> Option<&super::ProjectShareIntent> { self.owner.project_share.as_ref() } + /// Whether this audited boot runs with Rosetta 2 x86_64 translation. + pub(super) fn amd64_emulation(&self) -> bool { + self.owner.amd64_emulation + } pub(super) fn before_effect(&self) -> Result<(), CandidateError> { if let Some(guard) = &self.guard { guard.before_effect(self.owner.profile, || operating_observation(&self.owner))?; @@ -634,6 +639,80 @@ impl<'a> OwnedGuest<'a> { Ok(result) } } +/// The host's Rosetta 2 Linux runtime, which SmolVM shares into the guest for x86_64 +/// translation. Installed by `softwareupdate --install-rosetta` on Apple silicon. +pub const HOST_ROSETTA_RUNTIME: &str = "/Library/Apple/usr/libexec/oah/RosettaLinux/rosetta"; + +/// Whether this host can provide Rosetta 2 translation to the guest: Apple silicon macOS +/// with the system-owned Rosetta Linux runtime installed. Read-only. +pub fn host_translation_available() -> bool { + if !(cfg!(target_os = "macos") && cfg!(target_arch = "aarch64")) { + return false; + } + fs::symlink_metadata(HOST_ROSETTA_RUNTIME).is_ok_and(|metadata| { + metadata.is_file() + && metadata.uid() == 0 + && metadata.mode() & 0o022 == 0 + && metadata.mode() & 0o111 != 0 + }) +} + +fn require_host_translation() -> Result<(), CandidateError> { + if host_translation_available() { + return Ok(()); + } + Err(CandidateError::new( + "amd64_emulation_unavailable", + "linux/amd64 services need Rosetta 2 on an Apple silicon Mac. Install it with `softwareupdate --install-rosetta`, or run the arm64 image variant. Nothing was started.", + )) +} + +/// The refusal for an emulated platform in a pool booted without x86_64 translation. +pub(super) fn amd64_emulation_not_enabled() -> CandidateError { + CandidateError::new( + "amd64_emulation_unavailable", + "This native VM pool was booted without x86_64 translation; a linux/amd64 image cannot run in it.", + ) +} + +/// A running pool cannot gain translation without a restart, and running workloads are never +/// stopped implicitly to add it. +fn check_amd64_emulation_request(owner: &Owner, requested: bool) -> Result<(), CandidateError> { + if requested && !owner.amd64_emulation && owner.phase == "running" { + return Err(CandidateError::new( + "amd64_emulation_restart_required", + "The running native VM pool was started without x86_64 translation. Stop every native project that uses it (`hack down`), then start again; nothing was stopped or started.", + )); + } + Ok(()) +} + +/// Record translation intent before its provider effect, then make the stopped machine match. +/// A created machine whose provider record lacks translation is updated with `machine update +/// --rosetta`; an interrupted update is repeated on the next start, and boot refuses any +/// record, running config or guest registration that differs from the recorded intent. +fn enable_amd64_emulation( + candidate: &Candidate, + owner: &mut Owner, + requested: bool, +) -> Result<(), CandidateError> { + if requested && !owner.amd64_emulation { + owner.amd64_emulation = true; + owner.save(candidate)?; + } + if owner.amd64_emulation { + require_host_translation()?; + if owner.created && !super::config_audit::recorded_amd64_emulation(candidate, owner)? { + invoke( + candidate, + owner, + &["machine", "update", "--name", &owner.machine, "--rosetta"], + )?; + } + } + Ok(()) +} + fn audit_boot(candidate: &Candidate, owner: &Owner) -> Result<(), CandidateError> { super::config_audit::verify(candidate, owner)?; super::dependency_socket::verify(candidate, owner)?; @@ -668,7 +747,7 @@ fn audit_boot(candidate: &Candidate, owner: &Owner) -> Result<(), CandidateError || config["resources"]["memory_mib"] != owner.profile.memory_mib() || config["resources"]["gpu"] != false || config["resources"]["cuda"] != false - || config["resources"]["rosetta"] != false + || config["resources"]["rosetta"] != owner.amd64_emulation || config["resources"]["storage_gib"] != owner.profile.storage_gib() || config["resources"]["overlay_gib"] != owner.profile.overlay_gib() || config["forkable"] != false @@ -1023,6 +1102,7 @@ fn up_selected( super::prepared_start::Start { retained, prepared: None, + amd64_emulation: false, }, ) } @@ -1059,6 +1139,9 @@ fn start_pool( start: super::prepared_start::Start<'_>, ) -> Result { let retained = start.retained; + if start.amd64_emulation { + require_host_translation()?; + } #[cfg(target_os = "macos")] let retained_guard = retained .map(|(run, selection)| { @@ -1095,6 +1178,7 @@ fn start_pool( .map_err(io)? { let owner = Owner::load(candidate)?; + check_amd64_emulation_request(&owner, start.amd64_emulation)?; super::project_share::check_request(owner.project_share.as_ref(), project_share.as_ref())?; bridge_request.check(owner.application_bridge)?; super::network_intent::check_request(&owner.network, network.as_ref())?; @@ -1222,6 +1306,7 @@ fn start_pool( "Capacity profile changed before the operation lock was acquired.", )); } + check_amd64_emulation_request(&owner, start.amd64_emulation)?; if fresh_owner { super::graph::initialize_owner_registry(candidate, &owner)?; } else { @@ -1266,6 +1351,7 @@ fn start_pool( } verify_disks(candidate, &owner)?; } + enable_amd64_emulation(candidate, &mut owner, start.amd64_emulation)?; prepare_rootfs(candidate, &mut owner)?; if !owner.created { // A reserve-qualified start never reaches `machine create`. @@ -1299,6 +1385,9 @@ fn start_pool( "--volume".into(), mount, ]; + if owner.amd64_emulation { + arguments.push("--rosetta".into()); + } if let Some(share) = &owner.project_share { share.validate()?; arguments.extend(["--volume".into(), share.argument()]); @@ -1549,6 +1638,24 @@ fn finish_boot( } owner.guest_boot_id = Some(boot.to_owned()); owner.save(candidate)?; + let translation = if owner.amd64_emulation { + "enabled" + } else { + "disabled" + }; + let observed = guest( + candidate, + owner, + include_str!("guest-translation.sh"), + &[&owner.token, boot, translation], + false, + )?; + if observed != format!("amd64-translation-{translation}-v1\n") { + return Err(CandidateError::new( + "amd64_emulation", + "Guest x86_64 translation differs from the pool's audited intent; engine startup refused.", + )); + } if let Some(intent) = owner.dependency_sockets { let paths = intent.paths(&owner.short_home); let mut args = vec![owner.token.as_str(), boot]; @@ -2381,6 +2488,41 @@ mod tests { )); } + #[test] + fn amd64_emulation_is_never_added_to_a_running_pool_and_is_never_dropped() { + let owner = |phase: &str, emulation: bool| -> Owner { + serde_json::from_value(json!({ + "version":1,"checkout":"/fixture","token":"fixture","machine":"fixture", + "short_home":"/fixture","created":true,"phase":phase,"process":null, + "storage":null,"overlay":null,"guest_boot_id":null, + "daemon_pid":null,"daemon_start":null,"rootfs_digest":null, + "amd64_emulation":emulation + })) + .unwrap() + }; + let refused = check_amd64_emulation_request(&owner("running", false), true).unwrap_err(); + assert_eq!(refused.code, "amd64_emulation_restart_required"); + for (phase, emulation, requested) in [ + ("running", false, false), + ("running", true, true), + ("running", true, false), + ("stopped", false, true), + ("initializing", false, true), + ] { + check_amd64_emulation_request(&owner(phase, emulation), requested).unwrap(); + } + // A pool without translation keeps its original receipt shape. + let saved = serde_json::to_value(owner("stopped", false)).unwrap(); + assert!(saved.get("amd64_emulation").is_none()); + let saved = serde_json::to_value(owner("stopped", true)).unwrap(); + assert_eq!(saved["amd64_emulation"], json!(true)); + // Detection is read-only and only ever true on Apple silicon macOS. + if !(cfg!(target_os = "macos") && cfg!(target_arch = "aarch64")) { + assert!(!host_translation_available()); + } + let _ = host_translation_available(); + } + #[test] fn provider_command_enforces_strict_floor_for_public_egress_modes() { let candidate = Candidate::discover(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap(); @@ -2756,6 +2898,7 @@ printf 'verified-cache\n' super::super::prepared_start::Start { retained: None, prepared: Some(&request), + amd64_emulation: false, }, ) .unwrap_err(); diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index e9ff94e64..bbb69cb5b 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -35,6 +35,7 @@ pub mod https_recovery; mod identity; pub mod image_ensure; mod image_load; +pub mod image_platform; pub mod image_reference; pub mod managed_environment; #[cfg(feature = "native-config-plan")] diff --git a/packages/runtime-core/src/provider/prepared_start.rs b/packages/runtime-core/src/provider/prepared_start.rs index 85db49fd0..6bb045f1a 100644 --- a/packages/runtime-core/src/provider/prepared_start.rs +++ b/packages/runtime-core/src/provider/prepared_start.rs @@ -76,6 +76,9 @@ pub struct Start<'a> { pub retained: Option<(&'a str, &'a str)>, /// Prepared-base request for a fresh pool; `None` keeps the stock templates. pub prepared: Option<&'a Request>, + /// Require Rosetta 2 x86_64 translation (`runtime up --amd64-emulation`). A pool that + /// already has it keeps it either way. + pub amd64_emulation: bool, } /// Where a pool's first-start templates come from. diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 19c02c89f..5261ea124 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -1,5 +1,6 @@ //! Anonymous, digest-pinned Docker Hub acquisition. No daemon, credential store or VM effects. use super::image_load; +use super::image_platform::{ImagePlatform, PlatformPolicy}; use super::image_reference::{ImageReference, NamedReference, normalize}; use crate::CandidateError; use reqwest::{Url, blocking::Client, header::ACCEPT, redirect::Policy}; @@ -86,21 +87,41 @@ fn parse_manifest(bytes: &[u8], expected: &str) -> Result Result<&Descriptor, CandidateError> { - let mut selected = m.manifests.iter().filter(|d| { - d.platform.as_ref().is_some_and(|p| { - p.os == "linux" - && p.architecture == "arm64" - && p.variant.as_deref().is_none_or(|v| v == "v8") - }) - }); - let first = selected - .next() - .ok_or_else(|| error("Pinned index has no Linux ARM64 image."))?; - if selected.next().is_some() { - return Err(error("Pinned index has ambiguous Linux ARM64 images.")); - } - Ok(first) +/// The index entry for the first platform of `policy` that the index publishes exactly once. +/// A platform listed twice is ambiguous and refuses rather than falling through to the next. +fn select( + m: &Manifest, + policy: PlatformPolicy, +) -> Result<(&Descriptor, ImagePlatform), CandidateError> { + for platform in policy.candidates() { + let mut selected = m.manifests.iter().filter(|d| { + d.platform + .as_ref() + .is_some_and(|p| platform.matches(&p.os, &p.architecture, p.variant.as_deref())) + }); + let Some(first) = selected.next() else { + continue; + }; + if selected.next().is_some() { + return Err(match platform { + ImagePlatform::LinuxArm64 => { + error("Pinned index has ambiguous Linux ARM64 images.") + } + ImagePlatform::LinuxAmd64 => { + error("Pinned index has ambiguous Linux AMD64 images.") + } + }); + } + return Ok((first, *platform)); + } + Err(unavailable(policy)) +} +fn unavailable(policy: PlatformPolicy) -> CandidateError { + match policy { + PlatformPolicy::Native => error("Pinned index has no Linux ARM64 image."), + PlatformPolicy::PreferNative => error("Pinned index has no Linux ARM64 or AMD64 image."), + PlatformPolicy::Amd64 => error("Pinned index has no Linux AMD64 image."), + } } fn bounded(mut reader: impl Read, limit: usize) -> Result, CandidateError> { let mut bytes = Vec::new(); @@ -244,6 +265,8 @@ pub struct AcquiredImage { pub image_id: String, pub archive_sha256: String, pub archive_bytes: u64, + /// The platform selected by policy and confirmed by the image configuration. + pub platform: ImagePlatform, } fn authenticated_fetch(repo: &str) -> Result { let mut fetch = Fetch { @@ -335,6 +358,15 @@ pub struct ResolvedImage { /// No image layers, archive, runtime state or local credentials are accessed. A later /// fetch must use pinned_reference: reusing the original tag would discard this review. pub fn resolve(reference_text: &str) -> Result { + resolve_with(reference_text, PlatformPolicy::Native) +} + +/// [`resolve`] under an explicit platform policy. Only an opted-in policy can select +/// `linux/amd64`; the selected platform is part of the returned identity. +pub fn resolve_with( + reference_text: &str, + policy: PlatformPolicy, +) -> Result { let (repo, tag) = tagged_reference(reference_text)?; let (repo, tag) = (repo.as_str(), tag.as_str()); let fetch = authenticated_fetch(repo)?; @@ -346,8 +378,10 @@ pub fn resolve(reference_text: &str) -> Result { let source_digest = digest(&initial); let mut manifest = parse_manifest(&initial, &source_digest)?; let mut manifest_digest = source_digest.clone(); + let mut indexed = None; if !manifest.manifests.is_empty() { - let selected = arm64(&manifest)?; + let (selected, platform) = select(&manifest, policy)?; + indexed = Some(platform); manifest_digest = selected.digest.clone(); let bytes = fetch.object(repo, "manifests", selected, MAX_METADATA)?; manifest = parse_manifest(&bytes, &manifest_digest)?; @@ -359,27 +393,39 @@ pub fn resolve(reference_text: &str) -> Result { .config .ok_or_else(|| error("Image config descriptor is missing."))?; let config_bytes = fetch.object(repo, "blobs", &config, MAX_METADATA)?; - verify_platform(&config_bytes)?; + let platform = config_platform(&config_bytes, policy, indexed)?; Ok(ResolvedImage { pinned_reference: format!("docker.io/{repo}@{source_digest}"), source_digest, manifest_digest, image_id: config.digest, - platform: "linux/arm64", + platform: platform.name(), }) } -fn verify_platform(config_bytes: &[u8]) -> Result<(), CandidateError> { +/// The platform the actual image configuration targets. An index selection must be +/// confirmed by the configuration; a single manifest is admitted only for a platform the +/// policy allows. +fn config_platform( + config_bytes: &[u8], + policy: PlatformPolicy, + indexed: Option, +) -> Result { let config: serde_json::Value = serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; - if config["os"] != "linux" - || config["architecture"] != "arm64" - || config.get("variant").is_some_and(|v| v != "v8") - { - return Err(error( - "Image configuration does not target supported Linux ARM64.", - )); - } - Ok(()) + let allowed: &[ImagePlatform] = match &indexed { + Some(platform) => std::slice::from_ref(platform), + None => policy.candidates(), + }; + allowed + .iter() + .copied() + .find(|platform| platform.matches_config(&config)) + .ok_or_else(|| match policy { + PlatformPolicy::Native => { + error("Image configuration does not target supported Linux ARM64.") + } + _ => error("Image configuration does not target the selected Linux platform."), + }) } /// Acquire one public pinned Linux ARM64 image into a new explicit archive. The output @@ -387,6 +433,16 @@ fn verify_platform(config_bytes: &[u8]) -> Result<(), CandidateError> { /// HTTPS requests have a 60s bound and the complete acquisition a 300s deadline. /// SIGINT/process cancellation stops acquisition; no child transfer outlives the caller. pub fn acquire(reference_text: &str, output: &Path) -> Result { + acquire_with(reference_text, output, PlatformPolicy::Native) +} + +/// [`acquire`] under an explicit platform policy; the archive is validated for the platform +/// that was selected and confirmed by the image configuration. +pub fn acquire_with( + reference_text: &str, + output: &Path, + policy: PlatformPolicy, +) -> Result { let (repo, pin) = reference(reference_text)?; let (repo, pin) = (repo.as_str(), pin.as_str()); if output.exists() { @@ -400,8 +456,10 @@ pub fn acquire(reference_text: &str, output: &Path) -> Result Result Result= fetch.deadline { return Err(error("Registry acquisition deadline exceeded.")); } @@ -451,6 +510,7 @@ pub fn acquire(reference_text: &str, output: &Path) -> Result| { + let mut platform = serde_json::json!({"os":"linux","architecture":architecture}); + if let Some(variant) = variant { + platform["variant"] = serde_json::json!(variant); + } + serde_json::json!({"mediaType":"manifest","size":10,"digest":format!("sha256:{}", digit.to_string().repeat(64)),"platform":platform}) + }; + let index = |entries: Vec| { + let bytes = + serde_json::to_vec(&serde_json::json!({"schemaVersion":2,"manifests":entries})) + .unwrap(); + parse_manifest(&bytes, &digest(&bytes)).unwrap() + }; + let multi = index(vec![ + entry("amd64", 'a', None), + entry("arm64", 'b', Some("v8")), + ]); + let amd64_only = index(vec![ + entry("amd64", 'a', None), + entry("amd64", 'c', Some("v3")), + ]); + // A multi-architecture image stays native under every policy except an explicit amd64. + for policy in [PlatformPolicy::Native, PlatformPolicy::PreferNative] { + let (selected, platform) = select(&multi, policy).unwrap(); + assert_eq!( + (selected.digest.as_bytes()[7], platform), + (b'b', ImagePlatform::LinuxArm64) + ); + } + let (selected, platform) = select(&multi, PlatformPolicy::Amd64).unwrap(); + assert_eq!( + (selected.digest.as_bytes()[7], platform), + (b'a', ImagePlatform::LinuxAmd64) + ); + // An amd64-only image needs an opt-in; microarchitecture variants are never chosen. + assert_eq!( + select(&amd64_only, PlatformPolicy::Native) + .map(drop) + .unwrap_err() + .message, + "Pinned index has no Linux ARM64 image." + ); + for policy in [PlatformPolicy::PreferNative, PlatformPolicy::Amd64] { + let (selected, platform) = select(&amd64_only, policy).unwrap(); + assert_eq!( + (selected.digest.as_bytes()[7], platform), + (b'a', ImagePlatform::LinuxAmd64) + ); + } + let duplicate = index(vec![entry("amd64", 'a', None), entry("amd64", 'c', None)]); + assert!(select(&duplicate, PlatformPolicy::PreferNative).is_err()); + assert!( + select( + &index(vec![entry("arm64", 'b', None)]), + PlatformPolicy::Amd64 + ) + .is_err() + ); + // The configuration must confirm the index selection, and a single manifest is + // admitted only for a platform the policy allows. + let amd = br#"{"os":"linux","architecture":"amd64"}"#.as_slice(); + let arm = br#"{"os":"linux","architecture":"arm64"}"#.as_slice(); + assert_eq!( + config_platform(amd, PlatformPolicy::PreferNative, None).unwrap(), + ImagePlatform::LinuxAmd64 + ); + assert_eq!( + config_platform(arm, PlatformPolicy::PreferNative, None).unwrap(), + ImagePlatform::LinuxArm64 + ); + assert!(config_platform(amd, PlatformPolicy::Native, None).is_err()); + assert!(config_platform(arm, PlatformPolicy::Amd64, None).is_err()); + assert!( + config_platform( + arm, + PlatformPolicy::PreferNative, + Some(ImagePlatform::LinuxAmd64) + ) + .is_err() + ); } #[test] fn only_public_digest_pinned_repository_references_are_accepted() { @@ -700,12 +844,13 @@ mod tests { serde_json::to_vec(&serde_json::json!({"schemaVersion":2,"manifests":[descriptor]})) .unwrap(); let mut m = parse_manifest(&bytes, &digest(&bytes)).unwrap(); - assert!(arm64(&m).is_ok()); + let native = PlatformPolicy::Native; + assert!(select(&m, native).is_ok()); assert!(parse_manifest(&bytes, &format!("sha256:{}", "0".repeat(64))).is_err()); m.manifests.push(m.manifests[0].clone()); - assert!(arm64(&m).is_err()); + assert!(select(&m, native).is_err()); m.manifests.clear(); - assert!(arm64(&m).is_err()); + assert!(select(&m, native).is_err()); assert!(bounded(&b"12345"[..], 4).is_err()); assert_eq!(bounded(&b"1234"[..], 4).unwrap(), b"1234"); } diff --git a/packages/runtime-core/src/provider/state.rs b/packages/runtime-core/src/provider/state.rs index 093f4ea02..7f7725198 100644 --- a/packages/runtime-core/src/provider/state.rs +++ b/packages/runtime-core/src/provider/state.rs @@ -221,6 +221,13 @@ pub struct Owner { pub application_bridge: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dependency_sockets: Option, + /// Rosetta 2 x86_64 translation for this pool's VM, so opted-in `linux/amd64` services + /// can run. Recorded before the provider effect that enables it (`machine create + /// --rosetta`, or `machine update --rosetta` on a stopped pool) and never cleared + /// implicitly; every boot audits the provider record, running config and guest + /// registration against it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub amd64_emulation: bool, pub version: u32, pub checkout: PathBuf, pub token: String, @@ -474,6 +481,7 @@ impl Owner { profile, application_bridge, dependency_sockets, + amd64_emulation: false, version: 1, checkout: candidate.checkout.clone(), machine: format!("hack-{}", &token[..12]), diff --git a/packages/runtime-core/src/runtime_up_cli.rs b/packages/runtime-core/src/runtime_up_cli.rs index 9e0f97257..bc6d23b9f 100644 --- a/packages/runtime-core/src/runtime_up_cli.rs +++ b/packages/runtime-core/src/runtime_up_cli.rs @@ -18,12 +18,14 @@ pub struct Options { pub retained: Option<(String, String)>, /// `--prepared-base MODE` and optional `--prepared-base-store ABS` for a fresh pool. pub prepared: Option<(Mode, Option)>, + /// `--amd64-emulation`: require Rosetta 2 x86_64 translation in the pool's VM. + pub amd64_emulation: bool, } fn invalid() -> CandidateError { CandidateError::new( "invalid_arguments", - "Use runtime up --profile research|development with --bridge-sockets or --minimum-bridge-sockets, and/or --dependency-sockets or --minimum-dependency-sockets, each once, repeatable --allow-host HOST, plus optional --json. Direct project sharing requires --profile development --project-share PATH --unfiltered-source together; it exposes the exact project tree without snapshot exclusions. A fresh pool may request a prepared base with --prepared-base prefer|require and optional absolute --prepared-base-store PATH.", + "Use runtime up --profile research|development with --bridge-sockets or --minimum-bridge-sockets, and/or --dependency-sockets or --minimum-dependency-sockets, each once, repeatable --allow-host HOST, plus optional --json. Direct project sharing requires --profile development --project-share PATH --unfiltered-source together; it exposes the exact project tree without snapshot exclusions. A fresh pool may request a prepared base with --prepared-base prefer|require and optional absolute --prepared-base-store PATH. --amd64-emulation requires Rosetta 2 x86_64 translation for opted-in linux/amd64 services.", ) } @@ -42,6 +44,7 @@ pub fn parse(args: &[&str]) -> Result { let mut retained_selection = None; let mut prepared = None; let mut prepared_store = None; + let mut amd64_emulation = false; let mut args = args.iter(); while let Some(key) = args.next() { match *key { @@ -73,6 +76,7 @@ pub fn parse(args: &[&str]) -> Result { prepared_store = Some(path); } "--unfiltered-source" if !unfiltered_source => unfiltered_source = true, + "--amd64-emulation" if !amd64_emulation => amd64_emulation = true, "--internet" if !internet => internet = true, "--allow-host" => hosts.push(args.next().ok_or_else(invalid)?.to_string()), "--json" if !json => json = true, @@ -136,7 +140,8 @@ pub fn parse(args: &[&str]) -> Result { && hosts.is_empty() && !internet && project_share.is_none() - && prepared.is_none()) + && prepared.is_none() + && !amd64_emulation) { return Err(invalid()); } @@ -157,6 +162,7 @@ pub fn parse(args: &[&str]) -> Result { project_share, retained, prepared: prepared.map(|mode| (mode, prepared_store)), + amd64_emulation, }) } @@ -180,6 +186,37 @@ mod tests { assert_eq!(options.prepared, Some((Mode::Require, None))); } + #[test] + fn amd64_emulation_is_an_explicit_once_only_flag() { + let options = parse(&[ + "--profile", + "development", + "--internet", + "--amd64-emulation", + ]) + .unwrap(); + assert!(options.amd64_emulation); + assert!( + parse(&["--profile", "development", "--amd64-emulation"]) + .unwrap() + .amd64_emulation + ); + assert!( + !parse(&["--profile", "development", "--internet"]) + .unwrap() + .amd64_emulation + ); + assert!( + parse(&[ + "--profile", + "development", + "--amd64-emulation", + "--amd64-emulation" + ]) + .is_err() + ); + } + #[test] fn prepared_base_is_an_explicit_mode_with_an_optional_absolute_store() { let options = parse(&["--profile", "development", "--prepared-base", "prefer"]).unwrap(); diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 4028bef1d..f0a434c13 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -523,6 +523,47 @@ fn unsupported_privileged_external_and_unknown_fields_block_enrollment() { assert!(fixture.codes().contains(&"external_network".into())); } +/// `x-hack-amd64-emulation` is a reviewed opt-in, not unexecuted extension metadata. It is +/// recorded in the plan only when true, and it can never override a declared arm64 platform. +#[test] +fn amd64_emulation_opt_in_is_reviewed_and_never_overrides_declared_arm64() { + let plain = Fixture::new(BASIC).plan(); + assert!(!plain.plan.services["web"].amd64_emulation); + let opted = Fixture::new(&format!( + "{BASIC} platform: linux/amd64\n x-hack-amd64-emulation: true\n" + )); + let report = opted.plan(); + let service = &report.plan.services["web"]; + assert!(service.amd64_emulation); + assert_eq!(service.platform.as_deref(), Some("linux/amd64")); + assert!( + !report + .plan + .diagnostics + .iter() + .any(|d| d.code == "compose_extension_metadata") + ); + // Absent and false serialize identically, so existing plan identities are unchanged. + let off = Fixture::new(&format!("{BASIC} x-hack-amd64-emulation: false\n")).plan(); + for report in [&plain, &off] { + let service = serde_json::to_value(&report.plan.services["web"]).unwrap(); + assert!(service.get("amd64_emulation").is_none()); + } + for platform in ["linux/arm64", "linux/arm64/v8"] { + let conflicting = Fixture::new(&format!( + "{BASIC} platform: {platform}\n x-hack-amd64-emulation: true\n" + )); + assert_eq!( + project::plan(&conflicting.candidate, conflicting.options()) + .unwrap_err() + .code, + "unsupported_platform" + ); + } + let invalid = Fixture::new(&format!("{BASIC} x-hack-amd64-emulation: yes\n")); + assert!(project::plan(&invalid.candidate, invalid.options()).is_err()); +} + /// The candidate's own label review rejects Hack's ownership namespaces (the /// list `HACK_OWNERSHIP_LABEL_NAMESPACES` declares in TypeScript), so a /// project cannot carry ownership evidence even when the TypeScript classifier diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..a645504bc 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1449,6 +1449,34 @@ const contracts: readonly ModelContract[] = [ action: "Launch", fields: ["retired = <<1>>", "alive = 1"], }, + { + name: "pool-emulation", + module: "Emulation", + states: 13, + invariant: "NoUntranslatedAmd64", + action: "StartAmd64", + fields: [ + "amd64 = TRUE", + "intent = TRUE", + "config = FALSE", + "registered = FALSE", + "engine = TRUE", + ], + additionalControls: [ + { + name: "running-upgrade", + negative: true, + invariant: "EngineMatchesIntent", + action: "RequestRunning", + fields: [ + 'phase = "running"', + "intent = TRUE", + "config = FALSE", + "engine = TRUE", + ], + }, + ], + }, ]; export const runtimeModels = contracts.map((contract) => ({ diff --git a/src/backends/native-project-adaptation.ts b/src/backends/native-project-adaptation.ts index b1bcc6597..f50100137 100644 --- a/src/backends/native-project-adaptation.ts +++ b/src/backends/native-project-adaptation.ts @@ -2,6 +2,7 @@ import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { addNativeHostAliases } from "./native-project-host-aliases.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; +import { applyNativeAmd64Emulation } from "./native-project-platform.ts"; import { readNativeSelection } from "./native-project-selection.ts"; import { applyNativeWorkspaceCache } from "./native-workspace-cache.ts"; @@ -12,6 +13,7 @@ const SELECTION_KEYS = new Set([ "additionalHostnames", "additionalHostAliases", "workspaceCache", + "amd64Emulation", ]); const PROBE_KEYS = new Set([ "port", @@ -194,11 +196,17 @@ export function adaptNativeProject(opts: { services: compose.services, selection: selection.additionalHostAliases, }); + const amd64Emulation = applyNativeAmd64Emulation({ + services: compose.services, + selection: selection.amd64Emulation, + }); const normalizedComposeJson = JSON.stringify(compose); if (Buffer.byteLength(normalizedComposeJson) > 256 * 1024) { throw refused(); } - return { ...opts.input, normalizedComposeJson }; + return Object.keys(amd64Emulation).length > 0 + ? { ...opts.input, normalizedComposeJson, amd64Emulation } + : { ...opts.input, normalizedComposeJson }; } export async function prepareNativeProjectAdaptation(opts: { diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index ab83234d9..c7480db6c 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -8,6 +8,7 @@ import { resolveProjectEnvConfig, selectProjectEnvValuesForExecutionTarget, } from "../lib/project-env-config.ts"; +import type { NativeEmulationPolicy } from "./native-project-platform.ts"; const LIMIT = 256 * 1024; const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -24,6 +25,11 @@ export type NativeProjectInput = { readonly effectiveEnvName: string | null; readonly environmentFiles: readonly string[]; readonly serviceNames: readonly string[]; + /** + * Emulation policy of each service the native adaptation opted in to `linux/amd64` + * (`amd64Emulation`); absent when none did. See `native-project-platform.ts`. + */ + readonly amd64Emulation?: Readonly>; }; function refused(): Error { diff --git a/src/backends/native-project-platform.ts b/src/backends/native-project-platform.ts new file mode 100644 index 000000000..cfb2b3a4b --- /dev/null +++ b/src/backends/native-project-platform.ts @@ -0,0 +1,204 @@ +import { constants } from "node:fs"; +import { access, lstat } from "node:fs/promises"; +import { HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; + +/** + * Explicit `linux/amd64` emulation for native services (Rosetta 2 in the VM). + * + * The native VM is Linux arm64. A service runs a `linux/amd64` image only when the + * `HACK_NATIVE_ADAPTATION` selection opts it in through `amd64Emulation`, per service + * or with a project default: + * + * ```json + * { "version": 1, + * "amd64Emulation": { "default": "prefer-native", "services": { "worker": "amd64" } } } + * ``` + * + * - `native` (the default): Linux arm64 only. A declared `platform: linux/amd64` keeps + * the project on the Compose backend (`foreign_platform`). + * - `prefer-native`: the arm64 variant when the image publishes one, so multi-arch + * images stay native; an amd64-only image runs under emulation. + * - `amd64`: the amd64 variant under emulation, even when arm64 is published. + * + * An opted-in service gets the reviewed `x-hack-amd64-emulation: true` Compose field. + * The runtime admits an amd64 image only for such a service and only in a pool booted + * with x86_64 translation, so a missed selection fails closed instead of substituting. + */ +export type NativePlatformMode = "native" | "prefer-native" | "amd64"; + +/** A mode that may select an emulated platform; `native` services carry no policy. */ +export type NativeEmulationPolicy = Exclude; + +/** The reviewed Compose service field the runtime reads as the opt-in. */ +export const AMD64_EMULATION_FIELD = "x-hack-amd64-emulation"; + +/** The host Rosetta 2 Linux runtime SmolVM shares into the guest. */ +export const HOST_ROSETTA_RUNTIME = + "/Library/Apple/usr/libexec/oah/RosettaLinux/rosetta"; + +const MODES = new Set(["native", "prefer-native", "amd64"]); +const SELECTION_KEYS = new Set(["default", "services"]); +const ARM64 = new Set(["linux/arm64", "linux/arm64/v8"]); + +function refused(): Error { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native project adaptation is invalid or conflicts with declared configuration; values omitted.", + }); +} + +function mode(value: unknown): NativePlatformMode { + if (typeof value !== "string" || !MODES.has(value)) { + throw refused(); + } + return value as NativePlatformMode; +} + +/** + * Per-service modes from one `amd64Emulation` selection value. Every named service + * must exist; unknown keys and modes refuse with `E_CONFIG_INVALID`. Services without + * an entry take the explicit project `default`, or `native`. + */ +export function selectNativePlatformModes(opts: { + readonly selection: unknown; + readonly services: readonly string[]; +}): ReadonlyMap { + const modes = new Map(); + if (opts.selection === undefined) { + for (const name of opts.services) { + modes.set(name, "native"); + } + return modes; + } + const selection = opts.selection; + if ( + !( + isRecord(selection) && + Object.keys(selection).every((key) => SELECTION_KEYS.has(key)) + ) + ) { + throw refused(); + } + const fallback = + selection.default === undefined ? "native" : mode(selection.default); + const named = selection.services ?? {}; + if (!isRecord(named) || Object.keys(named).length > 64) { + throw refused(); + } + for (const name of Object.keys(named)) { + if (!opts.services.includes(name)) { + throw refused(); + } + } + for (const name of opts.services) { + modes.set(name, Object.hasOwn(named, name) ? mode(named[name]) : fallback); + } + return modes; +} + +/** + * Applies the selection to normalized Compose services in place and returns the policy + * of each opted-in service. A service that declares a Linux arm64 platform cannot opt in. + */ +export function applyNativeAmd64Emulation(opts: { + readonly services: Record; + readonly selection: unknown; +}): Readonly> { + const modes = selectNativePlatformModes({ + selection: opts.selection, + services: Object.keys(opts.services), + }); + const policies: Record = {}; + for (const [name, selected] of modes) { + if (selected === "native") { + continue; + } + const spec = opts.services[name]; + if (!isRecord(spec)) { + throw refused(); + } + if (typeof spec.platform === "string" && ARM64.has(spec.platform)) { + throw refused(); + } + spec[AMD64_EMULATION_FIELD] = true; + policies[name] = selected; + } + return policies; +} + +/** `runtime ensure-image` arguments for one service's policy. */ +export function nativeEnsureImageArgs( + image: string, + policy: NativeEmulationPolicy | undefined +): string[] { + return [ + "runtime", + "ensure-image", + "--reference", + image, + ...(policy ? ["--emulation", policy] : []), + "--json", + ]; +} + +/** + * `runtime up` arguments: any opted-in service, active or not, needs a translation pool, + * because every declared service image is pinned before review. + */ +export function nativeAmd64EmulationUpArgs( + policies: Readonly> | undefined +): string[] { + return policies && Object.keys(policies).length > 0 + ? ["--amd64-emulation"] + : []; +} + +/** + * Whether an `ensure-image` reply's `platform` fits the request. A reply without a + * platform is Linux arm64. `amd64` must return `linux/amd64`, `prefer-native` either + * platform, and a service without a policy only Linux arm64; anything else is + * unconfirmed. + */ +export function ensuredPlatformAccepted( + value: unknown, + policy: NativeEmulationPolicy | undefined +): boolean { + const platform = value ?? "linux/arm64"; + switch (policy) { + case undefined: + return platform === "linux/arm64"; + case "amd64": + return platform === "linux/amd64"; + default: + return platform === "linux/arm64" || platform === "linux/amd64"; + } +} + +/** + * Whether this host can run emulated services: Apple silicon macOS with the system + * Rosetta 2 Linux runtime installed. Read-only; the runtime repeats a stricter check. + */ +export async function hostRosettaAvailable(opts?: { + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly path?: string; +}): Promise { + const platform = opts?.platform ?? process.platform; + const arch = opts?.arch ?? process.arch; + if (platform !== "darwin" || arch !== "arm64") { + return false; + } + const path = opts?.path ?? HOST_ROSETTA_RUNTIME; + try { + const metadata = await lstat(path); + if (!metadata.isFile()) { + return false; + } + await access(path, constants.X_OK); + return true; + } catch { + return false; + } +} diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..6fcb64352 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -16,6 +16,11 @@ import { import { prepareNativeProjectInput } from "./native-project-input.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { validateNativeAllowedHosts } from "./native-project-network.ts"; +import { + ensuredPlatformAccepted, + type NativeEmulationPolicy, + nativeEnsureImageArgs, +} from "./native-project-platform.ts"; import { nativeReviewNeedsCompatibility, verifyNativeSourceCompatibility, @@ -280,6 +285,7 @@ async function pinRestartImages(opts: { readonly projectRoot: string; readonly specs: Record>; readonly retainedImages: ReadonlyMap; + readonly amd64Emulation?: Readonly>; readonly invoke: typeof invokeNativeRuntime; }): Promise { for (const [name, spec] of Object.entries(opts.specs)) { @@ -293,15 +299,20 @@ async function pinRestartImages(opts: { continue; } // Owned cache acquisition grants no authority to stop the current graph. + const policy = + opts.amd64Emulation && Object.hasOwn(opts.amd64Emulation, name) + ? opts.amd64Emulation[name] + : undefined; const pinned = await opts.invoke({ runtime: opts.runtime, cwd: opts.projectRoot, - args: ["runtime", "ensure-image", "--reference", image, "--json"], + args: nativeEnsureImageArgs(image, policy), }); if ( !isRecord(pinned) || typeof pinned.image_id !== "string" || - !IMAGE.test(pinned.image_id) + !IMAGE.test(pinned.image_id) || + !ensuredPlatformAccepted(pinned.platform, policy) ) { throw new ClosedCliError( "Native restart image selection failed before cleanup." @@ -435,6 +446,7 @@ export async function preflightNativeRestart(opts: { projectRoot: opts.scope.projectRoot, specs, retainedImages, + amd64Emulation: input.amd64Emulation, invoke: deps.invoke, }); const compose = JSON.parse(input.normalizedComposeJson); diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..f9f262cbd 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -46,6 +46,12 @@ import { recoverNativeInterruptedStartupCleanup, } from "./native-project-interrupted-cleanup.ts"; import { validateNativeAllowedHosts } from "./native-project-network.ts"; +import { + ensuredPlatformAccepted, + type NativeEmulationPolicy, + nativeAmd64EmulationUpArgs, + nativeEnsureImageArgs, +} from "./native-project-platform.ts"; import { serveNativeProjectGraph } from "./native-project-process.ts"; import { selectNativeProjectRestore } from "./native-project-restore.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; @@ -363,6 +369,8 @@ async function pinNativeProjectImages(opts: { readonly projectRoot: string; readonly specs: Record>; readonly retainedImages: ReadonlyMap; + /** Opted-in services' platform policies; every other service resolves Linux arm64. */ + readonly amd64Emulation?: Readonly>; readonly signal: AbortSignal; readonly invoke: typeof invokeNativeRuntime; }): Promise { @@ -379,15 +387,20 @@ async function pinNativeProjectImages(opts: { if (IMAGE.test(image)) { continue; } + const policy = + opts.amd64Emulation && Object.hasOwn(opts.amd64Emulation, name) + ? opts.amd64Emulation[name] + : undefined; const ensured = await opts.invoke({ runtime: opts.runtime, cwd: opts.projectRoot, - args: ["runtime", "ensure-image", "--reference", image, "--json"], + args: nativeEnsureImageArgs(image, policy), }); if ( !isRecord(ensured) || typeof ensured.image_id !== "string" || - !IMAGE.test(ensured.image_id) + !IMAGE.test(ensured.image_id) || + !ensuredPlatformAccepted(ensured.platform, policy) ) { throw unconfirmed("the runtime image selection"); } @@ -1123,6 +1136,7 @@ export async function startNativeProject(opts: { ? allowedHosts.flatMap((host) => ["--allow-host", host]) : ["--internet"]), ...preparedBaseArguments(opts.preparedBase), + ...nativeAmd64EmulationUpArgs(input.amd64Emulation), "--json", ], signal: controller.signal, @@ -1177,6 +1191,7 @@ export async function startNativeProject(opts: { projectRoot: opts.scope.projectRoot, specs, retainedImages, + amd64Emulation: input.amd64Emulation, signal: controller.signal, invoke: deps.invoke, }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..e9280cae9 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -12,6 +12,7 @@ import { parseNativeRunMappingRecoveryOptions, runNativeProjectMappingCommand, } from "../backends/native-project-mapping-command.ts"; +import { hostRosettaAvailable } from "../backends/native-project-platform.ts"; import { type NativeRuntimeSelection, resolveNativeRuntimeSelection, @@ -103,6 +104,11 @@ import { untrackGeneratedFiles, } from "../lib/doctor-generated-files.ts"; import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts"; +import { + AMD64_EMULATION_CHECK, + type Amd64EmulationReport, + amd64EmulationCheckResult, +} from "../lib/doctor-native-emulation.ts"; import { findCrossCheckoutInstances, inspectWorktreeSecretKeys, @@ -144,7 +150,10 @@ import { repairNativeCaTrust, } from "../lib/native-ca-doctor.ts"; import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; -import { readComposeBackendFeatures } from "../lib/native-compose-backend-features.ts"; +import { + readComposeBackendFeatures, + readNativePlatformModes, +} from "../lib/native-compose-backend-features.ts"; import { describeHackLocalRoute, HACK_LOCAL_ROLLBACK_COMMAND, @@ -235,6 +244,8 @@ interface CheckResult { readonly envKey?: EnvKeyInspection; /** Closed Compose-backend feature names only; never authored values. */ readonly composeBackend?: ComposeBackendReport; + /** Counts of services opted in to amd64 emulation; never service names. */ + readonly amd64Emulation?: Amd64EmulationReport; } const HACK_LOCAL_ROUTE_CHECK = "native hack.local route"; @@ -1116,6 +1127,14 @@ async function handleNativeDoctor(opts: { }) ) ); + results.push( + await runCheck(progress, AMD64_EMULATION_CHECK, () => + checkNativeAmd64Emulation({ + startDir: opts.startDir, + adaptationFile: process.env.HACK_NATIVE_ADAPTATION, + }) + ) + ); } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); @@ -1282,6 +1301,8 @@ export type DoctorJsonCheck = { readonly envKey?: EnvKeyInspection; /** Present only on the Compose-backend feature check: closed feature names. */ readonly composeBackend?: ComposeBackendReport; + /** Present only on the native amd64 emulation check: counts only. */ + readonly amd64Emulation?: Amd64EmulationReport; }; export type DoctorJsonData = { @@ -1320,6 +1341,9 @@ export function buildDoctorJsonData(opts: { ...(result.composeBackend ? { composeBackend: result.composeBackend } : {}), + ...(result.amd64Emulation + ? { amd64Emulation: result.amd64Emulation } + : {}), })), summary: buildDoctorSummaryLines({ results: opts.results }), counts, @@ -2953,6 +2977,32 @@ async function checkComposeBackendFeatures(opts: { }); } +/** + * Reports native services opted in to `linux/amd64` emulation and whether this + * host provides Rosetta 2. Reads only the Compose file and the + * `HACK_NATIVE_ADAPTATION` selection; counts only, no service names. + */ +async function checkNativeAmd64Emulation(opts: { + readonly startDir: string; + readonly adaptationFile?: string; +}): Promise { + const ctx = await findProjectContext(opts.startDir); + if (!ctx) { + return { + name: AMD64_EMULATION_CHECK, + status: "warn", + message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, + }; + } + return amd64EmulationCheckResult({ + modes: await readNativePlatformModes({ + composeFile: ctx.composeFile, + adaptationFile: opts.adaptationFile, + }), + hostRosetta: await hostRosettaAvailable(), + }); +} + /** * 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/doctor-native-emulation.ts b/src/lib/doctor-native-emulation.ts new file mode 100644 index 000000000..a17f8a2c4 --- /dev/null +++ b/src/lib/doctor-native-emulation.ts @@ -0,0 +1,65 @@ +import type { NativePlatformMode } from "../backends/native-project-platform.ts"; + +/** Doctor check id for native `linux/amd64` emulation. */ +export const AMD64_EMULATION_CHECK = "amd64 emulation"; + +/** Value-free JSON payload: counts only, never service names or image references. */ +export type Amd64EmulationReport = { + /** Services that always run `linux/amd64` under emulation (`amd64`). */ + readonly amd64: number; + /** Services emulated only when their image publishes no arm64 variant. */ + readonly preferNative: number; + /** Whether the host provides the Rosetta 2 Linux runtime. */ + readonly hostRosetta: boolean; +}; + +/** + * Doctor result for the native `amd64Emulation` selection. Messages are value-free + * like the Compose-backend report: they never repeat an authored platform string. + * Emulation is always a warning: emulated services are slower and can behave + * differently from arm64 builds. + * A missing Rosetta 2 runtime is a warning that names the fix, because native up + * refuses opted-in services on that host before any VM effect. + */ +export function amd64EmulationCheckResult(opts: { + readonly modes: ReadonlyMap; + readonly hostRosetta: boolean; +}): { + readonly name: string; + readonly status: "ok" | "warn"; + readonly message: string; + readonly amd64Emulation: Amd64EmulationReport; +} { + const modes = [...opts.modes.values()]; + const report = { + amd64: modes.filter((mode) => mode === "amd64").length, + preferNative: modes.filter((mode) => mode === "prefer-native").length, + hostRosetta: opts.hostRosetta, + }; + const total = report.amd64 + report.preferNative; + if (total === 0) { + return { + name: AMD64_EMULATION_CHECK, + status: "ok", + message: + "No service opts in to amd64 (x86_64) emulation; native services run Linux arm64.", + amd64Emulation: report, + }; + } + const services = total === 1 ? "1 service opts" : `${total} services opt`; + const scope = `${services} in to amd64 (x86_64) emulation (${report.amd64} always, ${report.preferNative} only when the image has no arm64 variant)`; + if (!opts.hostRosetta) { + return { + name: AMD64_EMULATION_CHECK, + status: "warn", + message: `${scope}, but Rosetta 2 is not installed, so native up refuses them. Install it with \`softwareupdate --install-rosetta\` on an Apple silicon Mac, or remove the amd64Emulation selection to use arm64 images.`, + amd64Emulation: report, + }; + } + return { + name: AMD64_EMULATION_CHECK, + status: "warn", + message: `${scope}. They run under Rosetta 2 in the native VM, which is slower and can differ from arm64 builds; prefer images that publish an arm64 variant.`, + amd64Emulation: report, + }; +} diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 2afef1ed8..6f0777c5d 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -1,5 +1,9 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; +import { + type NativePlatformMode, + selectNativePlatformModes, +} from "../backends/native-project-platform.ts"; import { readNativeSelection } from "../backends/native-project-selection.ts"; import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; @@ -58,8 +62,12 @@ function reservedNamespaceList(): string { return `${names.slice(0, -1).join(", ")} or ${names.at(-1)}`; } -/** The native VM runs Linux arm64; registry admission selects only this platform. */ +/** + * The native VM runs Linux arm64; registry admission selects only this platform unless + * a service opts in to `linux/amd64` emulation through the native adaptation. + */ const NATIVE_PLATFORMS = new Set(["linux/arm64", "linux/arm64/v8"]); +const EMULATED_PLATFORM = "linux/amd64"; /** The same bound native image-project input applies to the Compose file. */ const COMPOSE_LIMIT = 256 * 1024; @@ -128,12 +136,20 @@ function isUnsupportedBuild(service: Record): boolean { * Compose takes an absent or `null` `platform` as unset, and native review * does the same, so only a declared value other than the native platform is * foreign. Environment defaults such as `DOCKER_DEFAULT_PLATFORM` are not read. + * `linux/amd64` is not foreign for a service the native adaptation opted in to + * emulation; every other value stays foreign. */ -function isForeignPlatform(service: Record): boolean { +function isForeignPlatform( + service: Record, + emulated: boolean +): boolean { const platform = service.platform; if (platform === undefined || platform === null) { return false; } + if (emulated && platform === EMULATED_PLATFORM) { + return false; + } return !(typeof platform === "string" && NATIVE_PLATFORMS.has(platform)); } @@ -174,6 +190,11 @@ export function findComposeBackendFeatures( * {@link readAdaptedIsolatedNetworks}. */ readonly isolatedNetworks?: ReadonlySet; + /** + * Service names a valid native adaptation opts in to `linux/amd64` + * emulation; see {@link readAdaptedEmulatedServices}. + */ + readonly emulatedServices?: ReadonlySet; } ): ComposeBackendFeature[] { if (!isRecord(compose)) { @@ -208,7 +229,16 @@ export function findComposeBackendFeatures( if (services.some(isUnsupportedBuild)) { found.add("unsupported_build"); } - if (services.some(isForeignPlatform)) { + const emulated = opts?.emulatedServices ?? new Set(); + const named = isRecord(compose.services) + ? Object.entries(compose.services) + : []; + if ( + named.some( + ([name, service]) => + isRecord(service) && isForeignPlatform(service, emulated.has(name)) + ) + ) { found.add("foreign_platform"); } return COMPOSE_BACKEND_FEATURES.filter((feature) => found.has(feature)); @@ -291,6 +321,29 @@ export async function readAdaptedIsolatedNetworks( return new Set(names); } +/** + * Reads the service names a `HACK_NATIVE_ADAPTATION` selection opts in to + * `linux/amd64` emulation (`amd64Emulation`, with mode `prefer-native` or + * `amd64`). The same closed `E_CONFIG_INVALID` refusal applies as native start + * gives, including for a service name the Compose file does not declare. + */ +export async function readAdaptedEmulatedServices( + adaptationFile: string, + services: readonly string[] +): Promise> { + const selection = await readNativeSelection(adaptationFile); + if (!isRecord(selection) || selection.version !== 1) { + throw adaptationRefused(); + } + const modes = selectNativePlatformModes({ + selection: selection.amd64Emulation, + services, + }); + return new Set( + [...modes].filter(([, mode]) => mode !== "native").map(([name]) => name) + ); +} + /** * Reads and classifies one Compose file after the explicit native adaptation. * Read-only: no lock, no write, no runtime request. A Compose file that is @@ -298,10 +351,10 @@ export async function readAdaptedIsolatedNetworks( * existing native input refusal still reports it. * * `adaptationFile` is the `HACK_NATIVE_ADAPTATION` path native start applies. - * It is read only when the unadapted file has an external network, so a - * project without one keeps its existing order of refusals. An invalid - * adaptation refuses with `E_CONFIG_INVALID` (see - * {@link readAdaptedIsolatedNetworks}). + * It is read only when the unadapted file has an external network or a + * foreign platform, so a project without either keeps its existing order of + * refusals. An invalid adaptation refuses with `E_CONFIG_INVALID` (see + * {@link readAdaptedIsolatedNetworks} and {@link readAdaptedEmulatedServices}). */ export async function readComposeBackendFeatures( composeFile: string, @@ -309,14 +362,51 @@ export async function readComposeBackendFeatures( ): Promise { const compose = await readComposeDocument(composeFile); const features = findComposeBackendFeatures(compose); - if ( - opts?.adaptationFile === undefined || - !features.includes("external_network") - ) { + const adaptationFile = opts?.adaptationFile; + const network = features.includes("external_network"); + const platform = features.includes("foreign_platform"); + if (adaptationFile === undefined || !(network || platform)) { return features; } + const services = + isRecord(compose) && isRecord(compose.services) + ? Object.keys(compose.services) + : []; return findComposeBackendFeatures(compose, { - isolatedNetworks: await readAdaptedIsolatedNetworks(opts.adaptationFile), + isolatedNetworks: network + ? await readAdaptedIsolatedNetworks(adaptationFile) + : undefined, + emulatedServices: platform + ? await readAdaptedEmulatedServices(adaptationFile, services) + : undefined, + }); +} + +/** + * Each declared service's native platform mode after the `HACK_NATIVE_ADAPTATION` + * selection, for doctor reporting. Read-only. Without a selection every service is + * `native`; an unreadable Compose file has no services. An invalid selection refuses + * with the same `E_CONFIG_INVALID` native start gives. + */ +export async function readNativePlatformModes(opts: { + readonly composeFile: string; + readonly adaptationFile?: string; +}): Promise> { + const compose = await readComposeDocument(opts.composeFile); + const services = + isRecord(compose) && isRecord(compose.services) + ? Object.keys(compose.services) + : []; + if (opts.adaptationFile === undefined) { + return selectNativePlatformModes({ selection: undefined, services }); + } + const selection = await readNativeSelection(opts.adaptationFile); + if (!isRecord(selection) || selection.version !== 1) { + throw adaptationRefused(); + } + return selectNativePlatformModes({ + selection: selection.amd64Emulation, + services, }); } diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..798343576 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -595,6 +595,40 @@ behavior, repeated jobs, multiple slots and real one-off traffic remain separate implementation and runtime gates. The existing Rebind model covers original-target drain/revocation; this model checks the later commit-before-new-grant boundary. +## Pool amd64 emulation + +`pool-emulation/Emulation.tla` checks the Rosetta 2 translation intent of one shared +native VM pool. The positive control explores 13 states. A start that needs +translation records `Owner.amd64_emulation` before any provider effect; `machine +create` passes `--rosetta` exactly when it is recorded, and a stopped pool whose +stored setting lacks it is updated (`machine update --rosetta`). An interrupted update +is the absence of that step and is repeated by the next start. A foreign actor may +flip the stored setting while the pool is stopped. Each boot runs the stored setting; +the engine starts only when the stored setting, running configuration and guest +`binfmt_misc` registration all equal the recorded intent, otherwise the boot is +refused. An amd64 container needs the audited pool's intent, and a running pool +without translation refuses a request for it instead of changing intent. + +Removing the boot audit must fail `NoUntranslatedAmd64` in one `StartAmd64` state +with `amd64 = TRUE`, `intent = TRUE`, `config = FALSE`, `registered = FALSE` and +`engine = TRUE`. Allowing a running pool to gain intent must fail +`EngineMatchesIntent` in one `RequestRunning` state with `phase = "running"`, +`intent = TRUE`, `config = FALSE` and `engine = TRUE`. Both controls require TLC +exit 12, the named action and same-state witness. + +| Model action | Implementation boundary | +| --- | --- | +| `RecordIntent` | `provider/lifecycle.rs::enable_amd64_emulation` saves `Owner.amd64_emulation` before any provider command | +| `Create` / `Update` | `start_pool` adds `--rosetta` to `machine create`; `enable_amd64_emulation` runs `machine update --rosetta` when `config_audit::recorded_amd64_emulation` is false | +| `ForeignRecord` | A stopped machine changed outside Hack; `config_audit::verify_value` compares the stored `rosetta` setting with the owner | +| `Boot` / `StartEngine` / `RefuseBoot` | `audit_boot` (running config `resources.rosetta`), then `guest-translation.sh` in `finish_boot` before the engine starts | +| `RequestRunning` | `check_amd64_emulation_request` refuses `amd64_emulation_restart_required` | +| `StartAmd64` | `graph::admitted_architecture` (opted-in service and `OwnedGuest::amd64_emulation`), and `image_load::load_for` for amd64 archives | + +The model omits image selection, the registry and Docker's own platform handling; +those are covered by `registry_image`, `image_ensure` and graph admission unit tests. +Actual Rosetta execution in the guest needs host evidence. + ## Stopped retained-pool startup `stopped-pool-startup/Resume.tla` checks one explicit startup from shape-valid diff --git a/tests/models/tla/pool-emulation/Emulation.tla b/tests/models/tla/pool-emulation/Emulation.tla new file mode 100644 index 000000000..dac49289e --- /dev/null +++ b/tests/models/tla/pool-emulation/Emulation.tla @@ -0,0 +1,65 @@ +---------------------------- MODULE Emulation ---------------------------- +\* One shared native VM pool and its Rosetta 2 x86_64 translation intent. +\* intent: Owner.amd64_emulation (recorded before any provider effect). +\* record: SmolVM's stored machine setting (`--rosetta` at create, `machine update`). +\* config/registered: the running config and guest binfmt_misc registration of a boot. +\* A foreign actor may change the stored setting while the pool is stopped. +EXTENDS Naturals +CONSTANTS EnforceAudit, EnforceRunningRefusal +VARIABLES phase, intent, record, config, registered, engine, amd64 +vars == <> +Phases == {"absent", "stopped", "booting", "running"} +TypeOK == /\ phase \in Phases + /\ intent \in BOOLEAN /\ record \in BOOLEAN /\ config \in BOOLEAN + /\ registered \in BOOLEAN /\ engine \in BOOLEAN /\ amd64 \in BOOLEAN +Init == /\ phase = "absent" /\ intent = FALSE /\ record = FALSE /\ config = FALSE + /\ registered = FALSE /\ engine = FALSE /\ amd64 = FALSE +\* `enable_amd64_emulation`: a start that needs translation records intent first. +RecordIntent == /\ phase \in {"absent", "stopped"} /\ ~intent + /\ intent' = TRUE + /\ UNCHANGED <> +\* `machine create` passes `--rosetta` exactly when intent is recorded. +Create == /\ phase = "absent" + /\ phase' = "stopped" /\ record' = intent + /\ UNCHANGED <> +\* `machine update --rosetta` on a created, stopped machine whose record lacks it. +\* Interruption is the absence of this step; it is repeated by the next start. +Update == /\ phase = "stopped" /\ intent /\ ~record + /\ record' = TRUE + /\ UNCHANGED <> +ForeignRecord == /\ phase = "stopped" + /\ record' = ~record + /\ UNCHANGED <> +\* `machine start`: the provider boots its stored setting; the agent registers it. +Boot == /\ phase = "stopped" + /\ phase' = "booting" /\ config' = record /\ registered' = record + /\ UNCHANGED <> +Matches == record = intent /\ config = intent /\ registered = intent +\* `config_audit::verify`, `audit_boot` and `guest-translation.sh` before engine start. +StartEngine == /\ phase = "booting" /\ (EnforceAudit => Matches) + /\ phase' = "running" /\ engine' = TRUE + /\ UNCHANGED <> +RefuseBoot == /\ phase = "booting" /\ ~Matches + /\ phase' = "stopped" /\ config' = FALSE /\ registered' = FALSE + /\ UNCHANGED <> +\* A start that needs translation on a running pool without it. +RequestRunning == /\ phase = "running" /\ ~intent /\ ~EnforceRunningRefusal + /\ intent' = TRUE + /\ UNCHANGED <> +\* Graph admission: an opted-in amd64 image needs the audited pool's intent. +StartAmd64 == /\ phase = "running" /\ engine /\ intent /\ ~amd64 + /\ amd64' = TRUE + /\ UNCHANGED <> +Stop == /\ phase = "running" + /\ phase' = "stopped" /\ engine' = FALSE /\ amd64' = FALSE + /\ config' = FALSE /\ registered' = FALSE + /\ UNCHANGED <> +Next == RecordIntent \/ Create \/ Update \/ ForeignRecord \/ Boot \/ StartEngine + \/ RefuseBoot \/ RequestRunning \/ StartAmd64 \/ Stop +Spec == Init /\ [][Next]_vars +\* An amd64 container only ever runs with translation actually present. +NoUntranslatedAmd64 == amd64 => (config /\ registered) +\* A started engine runs exactly the recorded intent: no missing translation for an +\* emulating pool, and no unrequested translation in a pool that never opted in. +EngineMatchesIntent == engine => (config = intent /\ registered = intent) +============================================================================= diff --git a/tests/models/tla/pool-emulation/negative.cfg b/tests/models/tla/pool-emulation/negative.cfg new file mode 100644 index 000000000..023d4b0ad --- /dev/null +++ b/tests/models/tla/pool-emulation/negative.cfg @@ -0,0 +1,6 @@ +CONSTANTS EnforceAudit = FALSE + EnforceRunningRefusal = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + NoUntranslatedAmd64 +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/pool-emulation/positive.cfg b/tests/models/tla/pool-emulation/positive.cfg new file mode 100644 index 000000000..34cdc1280 --- /dev/null +++ b/tests/models/tla/pool-emulation/positive.cfg @@ -0,0 +1,7 @@ +CONSTANTS EnforceAudit = TRUE + EnforceRunningRefusal = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + NoUntranslatedAmd64 + EngineMatchesIntent +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/pool-emulation/running-upgrade.cfg b/tests/models/tla/pool-emulation/running-upgrade.cfg new file mode 100644 index 000000000..750997b56 --- /dev/null +++ b/tests/models/tla/pool-emulation/running-upgrade.cfg @@ -0,0 +1,6 @@ +CONSTANTS EnforceAudit = TRUE + EnforceRunningRefusal = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK + EngineMatchesIntent +CHECK_DEADLOCK FALSE diff --git a/tests/native-project-platform.test.ts b/tests/native-project-platform.test.ts new file mode 100644 index 000000000..60a73a2e0 --- /dev/null +++ b/tests/native-project-platform.test.ts @@ -0,0 +1,368 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmod, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { adaptNativeProject } from "../src/backends/native-project-adaptation.ts"; +import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import { + AMD64_EMULATION_FIELD, + applyNativeAmd64Emulation, + ensuredPlatformAccepted, + hostRosettaAvailable, + nativeAmd64EmulationUpArgs, + nativeEnsureImageArgs, + selectNativePlatformModes, +} from "../src/backends/native-project-platform.ts"; +import { + AMD64_EMULATION_CHECK, + amd64EmulationCheckResult, +} from "../src/lib/doctor-native-emulation.ts"; +import { + findComposeBackendFeatures, + readAdaptedEmulatedServices, + readComposeBackendFeatures, + readNativePlatformModes, +} from "../src/lib/native-compose-backend-features.ts"; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function scratch(): Promise { + const root = await mkdtemp(join(tmpdir(), "hack-native-platform-")); + roots.push(root); + return root; +} + +function input(services: Record): NativeProjectInput { + return { + originalSha256: "a".repeat(64), + normalizedComposeJson: JSON.stringify({ services }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: null, + environmentFiles: [], + serviceNames: Object.keys(services), + }; +} + +function expectInvalid(run: () => unknown): void { + let failure: unknown; + try { + run(); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: "E_CONFIG_INVALID" }); + expect(String(failure)).not.toContain("private-canary"); +} + +test("platform modes default to native and honor an explicit project default and per-service modes", () => { + const services = ["web", "worker", "cache"]; + expect([ + ...selectNativePlatformModes({ selection: undefined, services }), + ]).toEqual([ + ["web", "native"], + ["worker", "native"], + ["cache", "native"], + ]); + expect([ + ...selectNativePlatformModes({ + selection: { + default: "prefer-native", + services: { worker: "amd64", cache: "native" }, + }, + services, + }), + ]).toEqual([ + ["web", "prefer-native"], + ["worker", "amd64"], + ["cache", "native"], + ]); + expect([ + ...selectNativePlatformModes({ + selection: { services: { worker: "amd64" } }, + services, + }), + ]).toEqual([ + ["web", "native"], + ["worker", "amd64"], + ["cache", "native"], + ]); +}); + +test("invalid emulation selections refuse closed without echoing values", () => { + const services = ["web"]; + for (const selection of [ + null, + [], + "amd64", + { default: "linux/amd64" }, + { default: "x86_64" }, + { services: { "private-canary": "amd64" } }, + { services: { web: "emulate" } }, + { services: [] }, + { services: { web: "amd64" }, mode: "amd64" }, + ]) { + expectInvalid(() => selectNativePlatformModes({ selection, services })); + } +}); + +test("only opted-in services get the reviewed runtime field, and declared arm64 cannot opt in", () => { + const services: Record = { + legacy: { image: "example/legacy:1", platform: "linux/amd64" }, + tool: { image: "example/tool:1" }, + native: { image: "example/native:1" }, + }; + const policies = applyNativeAmd64Emulation({ + services, + selection: { + default: "prefer-native", + services: { legacy: "amd64", native: "native" }, + }, + }); + expect(policies).toEqual({ legacy: "amd64", tool: "prefer-native" }); + expect(services.legacy).toMatchObject({ + platform: "linux/amd64", + [AMD64_EMULATION_FIELD]: true, + }); + expect(services.tool).toMatchObject({ [AMD64_EMULATION_FIELD]: true }); + expect(services.native).toEqual({ image: "example/native:1" }); + for (const platform of ["linux/arm64", "linux/arm64/v8"]) { + expectInvalid(() => + applyNativeAmd64Emulation({ + services: { web: { image: "example/web:1", platform } }, + selection: { default: "prefer-native" }, + }) + ); + } + expect( + applyNativeAmd64Emulation({ + services: { web: { image: "example/web:1", platform: "linux/arm64" } }, + selection: { default: "native" }, + }) + ).toEqual({}); +}); + +test("native adaptation carries emulation policies only when a service opts in", () => { + const original = input({ + legacy: { image: "example/legacy:1", platform: "linux/amd64" }, + web: { image: "example/web:1" }, + }); + const plain = adaptNativeProject({ + input: original, + selection: { version: 1 }, + }); + expect(plain.amd64Emulation).toBeUndefined(); + expect(JSON.parse(plain.normalizedComposeJson).services.legacy).toEqual({ + image: "example/legacy:1", + platform: "linux/amd64", + }); + const adapted = adaptNativeProject({ + input: original, + selection: { + version: 1, + amd64Emulation: { services: { legacy: "prefer-native" } }, + }, + }); + expect(adapted.amd64Emulation).toEqual({ legacy: "prefer-native" }); + const compose = JSON.parse(adapted.normalizedComposeJson); + expect(compose.services.legacy[AMD64_EMULATION_FIELD]).toBe(true); + expect(compose.services.web[AMD64_EMULATION_FIELD]).toBeUndefined(); + // The original input is not modified. + expect(JSON.parse(original.normalizedComposeJson).services.legacy).toEqual({ + image: "example/legacy:1", + platform: "linux/amd64", + }); + expectInvalid(() => + adaptNativeProject({ + input: original, + selection: { + version: 1, + amd64Emulation: { services: { missing: "amd64" } }, + }, + }) + ); +}); + +test("runtime arguments request emulation only for opted-in services", () => { + expect(nativeEnsureImageArgs("redis", undefined)).toEqual([ + "runtime", + "ensure-image", + "--reference", + "redis", + "--json", + ]); + expect(nativeEnsureImageArgs("example/a:1", "prefer-native")).toEqual([ + "runtime", + "ensure-image", + "--reference", + "example/a:1", + "--emulation", + "prefer-native", + "--json", + ]); + expect(nativeAmd64EmulationUpArgs(undefined)).toEqual([]); + expect(nativeAmd64EmulationUpArgs({})).toEqual([]); + expect(nativeAmd64EmulationUpArgs({ web: "amd64" })).toEqual([ + "--amd64-emulation", + ]); + // Replies are confirmed against the request: amd64 only for an opted-in service. + expect(ensuredPlatformAccepted(undefined, undefined)).toBe(true); + expect(ensuredPlatformAccepted("linux/arm64", undefined)).toBe(true); + expect(ensuredPlatformAccepted("linux/arm64", "amd64")).toBe(false); + expect(ensuredPlatformAccepted("linux/amd64", "amd64")).toBe(true); + expect(ensuredPlatformAccepted("linux/arm64", "prefer-native")).toBe(true); + expect(ensuredPlatformAccepted("linux/amd64", undefined)).toBe(false); + expect(ensuredPlatformAccepted("linux/amd64", "prefer-native")).toBe(true); + expect(ensuredPlatformAccepted("linux/386", "amd64")).toBe(false); + expect(ensuredPlatformAccepted(1, "amd64")).toBe(false); +}); + +test("host Rosetta detection requires Apple silicon macOS and an executable regular runtime", async () => { + const root = await scratch(); + const runtime = join(root, "rosetta"); + await writeFile(runtime, "#!/bin/sh\n"); + await chmod(runtime, 0o755); + const link = join(root, "link"); + await symlink(runtime, link); + const plain = join(root, "plain"); + await writeFile(plain, "data"); + await chmod(plain, 0o644); + const apple = { platform: "darwin" as const, arch: "arm64" }; + expect(await hostRosettaAvailable({ ...apple, path: runtime })).toBe(true); + expect(await hostRosettaAvailable({ ...apple, path: link })).toBe(false); + expect(await hostRosettaAvailable({ ...apple, path: plain })).toBe(false); + expect( + await hostRosettaAvailable({ ...apple, path: join(root, "missing") }) + ).toBe(false); + expect( + await hostRosettaAvailable({ + platform: "darwin", + arch: "x64", + path: runtime, + }) + ).toBe(false); + expect( + await hostRosettaAvailable({ + platform: "linux", + arch: "arm64", + path: runtime, + }) + ).toBe(false); +}); + +test("the feature gate accepts linux/amd64 only for opted-in services", () => { + const compose = { + services: { + legacy: { image: "example/legacy:1", platform: "linux/amd64" }, + web: { image: "example/web:1", platform: "linux/arm64" }, + }, + }; + expect(findComposeBackendFeatures(compose)).toEqual(["foreign_platform"]); + expect( + findComposeBackendFeatures(compose, { + emulatedServices: new Set(["web"]), + }) + ).toEqual(["foreign_platform"]); + expect( + findComposeBackendFeatures(compose, { + emulatedServices: new Set(["legacy"]), + }) + ).toEqual([]); + // Other foreign platforms stay foreign even when opted in. + expect( + findComposeBackendFeatures( + { services: { legacy: { image: "x", platform: "linux/386" } } }, + { emulatedServices: new Set(["legacy"]) } + ) + ).toEqual(["foreign_platform"]); +}); + +test("the gate and doctor read the same adaptation file as native start", async () => { + const root = await scratch(); + const composeFile = join(root, "docker-compose.yml"); + await writeFile( + composeFile, + "services:\n legacy:\n image: example/legacy:1\n platform: linux/amd64\n web:\n image: example/web:1\n" + ); + const adaptationFile = join(root, "adaptation.json"); + expect(await readComposeBackendFeatures(composeFile)).toEqual([ + "foreign_platform", + ]); + await writeFile( + adaptationFile, + JSON.stringify({ + version: 1, + amd64Emulation: { services: { legacy: "prefer-native" } }, + }) + ); + expect( + await readComposeBackendFeatures(composeFile, { adaptationFile }) + ).toEqual([]); + expect([ + ...(await readAdaptedEmulatedServices(adaptationFile, ["legacy", "web"])), + ]).toEqual(["legacy"]); + expect([ + ...(await readNativePlatformModes({ composeFile, adaptationFile })), + ]).toEqual([ + ["legacy", "prefer-native"], + ["web", "native"], + ]); + expect([...(await readNativePlatformModes({ composeFile }))]).toEqual([ + ["legacy", "native"], + ["web", "native"], + ]); + await writeFile( + adaptationFile, + JSON.stringify({ + version: 1, + amd64Emulation: { services: { "private-canary": "amd64" } }, + }) + ); + await expect( + readComposeBackendFeatures(composeFile, { adaptationFile }) + ).rejects.toMatchObject({ code: "E_CONFIG_INVALID" }); + await expect( + readNativePlatformModes({ composeFile, adaptationFile }) + ).rejects.toMatchObject({ code: "E_CONFIG_INVALID" }); +}); + +test("doctor warns for emulated services with counts only and names the Rosetta fix", () => { + const none = amd64EmulationCheckResult({ + modes: new Map([["web", "native"]]), + hostRosetta: false, + }); + expect(none).toMatchObject({ + name: AMD64_EMULATION_CHECK, + status: "ok", + amd64Emulation: { amd64: 0, preferNative: 0, hostRosetta: false }, + }); + const modes = new Map([ + ["private-canary-a", "amd64"], + ["private-canary-b", "prefer-native"], + ["web", "native"], + ] as const); + const ready = amd64EmulationCheckResult({ modes, hostRosetta: true }); + expect(ready.status).toBe("warn"); + expect(ready.message).toContain( + "2 services opt in to amd64 (x86_64) emulation" + ); + expect(ready.message).toContain("Rosetta 2"); + expect(ready.amd64Emulation).toEqual({ + amd64: 1, + preferNative: 1, + hostRosetta: true, + }); + const missing = amd64EmulationCheckResult({ modes, hostRosetta: false }); + expect(missing.status).toBe("warn"); + expect(missing.message).toContain("softwareupdate --install-rosetta"); + for (const result of [none, ready, missing]) { + expect(JSON.stringify(result)).not.toContain("private-canary"); + expect(result.message).not.toContain("linux/amd64"); + } +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..0cf2b5d1a 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -3002,6 +3002,105 @@ test("a runtime failure during image admission keeps its own diagnostic and star expect(events).toEqual([]); }); +test("opted-in amd64 services boot a translation pool and pin with their own platform policy", async () => { + const { opts } = await fixture(false); + const id = `sha256:${"e".repeat(64)}`; + withImages(opts, { + legacy: "example/amd64-only:1", + tool: "example/multi-arch:2", + cache: "redis", + }); + const adaptationFile = join(opts.scope.projectRoot, "adaptation.json"); + await writeFile( + adaptationFile, + JSON.stringify({ + version: 1, + amd64Emulation: { + default: "prefer-native", + services: { legacy: "amd64", cache: "native" }, + }, + }) + ); + let reviewed: Record> = {}; + opts.dependencies.review = async (request) => { + reviewed = JSON.parse(request.input.normalizedComposeJson).services; + return await request.run({ + planId: "a".repeat(64), + namespace: "b".repeat(64), + projectArgs: ["--project", request.projectRoot], + report: { plan: { enrollment_compatible: true, services: {} } }, + }); + }; + const invoke = opts.dependencies.invoke!; + const up: string[][] = []; + const ensured: string[][] = []; + opts.dependencies.invoke = async (request) => { + if (request.args[0] === "runtime" && request.args[1] === "up") { + up.push([...request.args]); + } + if (request.args[1] === "ensure-image") { + ensured.push([...request.args]); + return { + image_id: id, + platform: request.args.includes("amd64") + ? "linux/amd64" + : "linux/arm64", + }; + } + return await invoke(request); + }; + expect(await startNativeProject({ ...opts, adaptationFile })).toBe(0); + expect(up).toHaveLength(1); + expect(up[0]).toContain("--amd64-emulation"); + expect(ensured).toEqual([ + [ + "runtime", + "ensure-image", + "--reference", + "example/amd64-only:1", + "--emulation", + "amd64", + "--json", + ], + [ + "runtime", + "ensure-image", + "--reference", + "example/multi-arch:2", + "--emulation", + "prefer-native", + "--json", + ], + ["runtime", "ensure-image", "--reference", "redis", "--json"], + ]); + // Only opted-in services carry the reviewed runtime opt-in. + expect(reviewed.legacy?.["x-hack-amd64-emulation"]).toBe(true); + expect(reviewed.tool?.["x-hack-amd64-emulation"]).toBe(true); + expect(reviewed.cache?.["x-hack-amd64-emulation"]).toBeUndefined(); +}); + +test("without an emulation selection the runtime pool and image pins stay arm64-only", async () => { + const { opts } = await fixture(false); + withImages(opts, { web: "alpine:3.21" }); + const invoke = opts.dependencies.invoke!; + const calls: string[][] = []; + opts.dependencies.invoke = async (request) => { + if ( + request.args[0] === "runtime" && + (request.args[1] === "up" || request.args[1] === "ensure-image") + ) { + calls.push([...request.args]); + } + if (request.args[1] === "ensure-image") { + return { image_id: `sha256:${"e".repeat(64)}` }; + } + return await invoke(request); + }; + expect(await startNativeProject(opts)).toBe(0); + expect(calls.flat()).not.toContain("--amd64-emulation"); + expect(calls.flat()).not.toContain("--emulation"); +}); + test("retained startup reviews the saved content ID instead of a newly resolved mutable tag", async () => { const { opts, events } = await fixture(false); const saved = { From a671d8c74c6a1977bd8097d77066e3e36ed74ce4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:02:08 -0500 Subject: [PATCH 049/138] docs(runtime-core): record the amd64_emulation owner field Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 84c58a6df..a84970b34 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -628,14 +628,16 @@ compatibility window beyond what follows. whether it understands a receipt. - **Some fields are always written.** `previous_guest_boot_id`, `reclamation`, `profile` and `network` are written on every save, as `null` or their default - value when unset. `clean_stop`, `project_share`, `application_bridge` and - `dependency_sockets` are written only when set. + value when unset. `clean_stop`, `project_share`, `application_bridge`, + `dependency_sockets` and `amd64_emulation` are written only when set + (`amd64_emulation` only when `true`). Schema history: every current field except `clean_stop` was present in `v5.0.0-next.1`. `clean_stop` was added by [#344](https://github.com/hack-dance/hack/pull/344) and first shipped in `v5.0.0-next.6`. The owner schema is unchanged from `v5.0.0-next.6` through -`v5.0.0-next.8` and the current `next` branch. What this means in practice: +`v5.0.0-next.8`. `amd64_emulation` (Rosetta 2 x86_64 translation for the pool's VM) +was added after `v5.0.0-next.8`. What this means in practice: - **Upgrade works.** A `next.6` or later binary reads receipts written by `next.1` through `next.5`. Those receipts have no clean-stop marker, so their stopped pools @@ -652,6 +654,11 @@ Schema history: every current field except `clean_stop` was present in whose last stop was not a completed clean `down`. The [candidate guide](../../docs/guides/native-candidate.md#manual-candidate-upgrade-and-rollback) treats a prior binary refusing newer state as an unsupported downgrade. Keep that state as it is and do not edit the receipt. +- **A translation pool cannot be downgraded.** A pool started with + `runtime up --amd64-emulation` records `amd64_emulation: true` before any provider + effect and keeps it. Binaries from before that field refuse its receipt with + `invalid_receipt`; they would also refuse its provider configuration, which now + enables Rosetta. Pools that never opted in write no new field and stay readable. - **Future fields follow the same rule.** Under this code, adding an always-written field means every earlier binary refuses every receipt the new binary saves. Adding a field that is written only when set means earlier binaries refuse only the From ae6f01f874c3f15f9cbd0412f2a3e339450f24d0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:04:40 -0500 Subject: [PATCH 050/138] fix(native): record runs started without managed env files The run mapping gains an optional unmanagedEnvironment marker, written only when startup selected a named env while the project had no managed env file. Fresh exec and down lifecycle hooks deliver empty values only for such runs. A run started with managed env files, or a legacy mapping without the marker, still refuses when its selected overlay is gone, including when every managed env file was deleted. Co-Authored-By: Claude Opus 5.5 --- src/backends/native-exec-environment.ts | 10 +++++-- src/backends/native-project-down.ts | 9 +++++- src/backends/native-project-run.ts | 11 +++++++ src/backends/native-project-start.ts | 7 +++++ tests/native-exec-environment.test.ts | 14 +++++++-- tests/native-project-down.test.ts | 20 ++++++++++--- tests/native-project-run.test.ts | 22 ++++++++++++++ tests/native-project-start.test.ts | 39 ++++++++++++++++++++----- 8 files changed, 113 insertions(+), 19 deletions(-) diff --git a/src/backends/native-exec-environment.ts b/src/backends/native-exec-environment.ts index 2ea741222..7751d8fa7 100644 --- a/src/backends/native-exec-environment.ts +++ b/src/backends/native-exec-environment.ts @@ -55,11 +55,15 @@ export async function prepareNativeExecEnvironment(opts: { throw new SelectionChanged(); } const selected = opts.run.effectiveEnvName; - // A run started without any managed env file records its env only to select the - // per-env native files; with still no file there are no values to deliver. + // A run recorded as started without any managed env file used its env only to + // select the per-env native files; with still no file there are no values. Any + // other run whose selected overlay is gone has a changed selection. + const unmanaged = + opts.run.unmanagedEnvironment === true && + input.environmentFiles.length === 0; if ( selected !== null && - input.environmentFiles.length > 0 && + !unmanaged && !input.environmentFiles.some((path) => [ `hack.env.${selected}.yaml`, diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index cc5f645e5..a5da72621 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -221,7 +221,14 @@ export async function nativeDownEnvironment(opts: { serviceNames: opts.serviceNames, }); if (!resolved) { - // No managed env file at all: a recorded env only selected per-env native files. + // Only a run recorded as started without managed env files may find none now; + // for any other named selection the overlay disappeared. + if ( + opts.run.effectiveEnvName !== null && + opts.run.unmanagedEnvironment !== true + ) { + throw refused(); + } return {}; } const selected = opts.run.effectiveEnvName; diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index 5ae8a1a50..5e61e5a5d 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -48,6 +48,12 @@ export type NativeProjectRun = { * Retained restores must add it again; absent legacy graphs never gain it. */ readonly runtimeMetadata?: true; + /** + * Present only when startup selected a named env while the project had no managed + * env file, so that env selected only the per-env native files. Absent mappings + * started with managed env files (or are legacy): a later missing file refuses. + */ + readonly unmanagedEnvironment?: true; }; export type NativeProjectRunScope = { readonly projectRoot: string; @@ -118,9 +124,14 @@ function valid(value: unknown): value is NativeProjectRun { "aws,effectiveEnvName,namespace,owner,planId,run", "aws,effectiveEnvName,namespace,owner,planId,profiles,run", "aws,effectiveEnvName,namespace,owner,planId,profiles,run,runtimeMetadata", + "aws,effectiveEnvName,namespace,owner,planId,profiles,run,unmanagedEnvironment", + "aws,effectiveEnvName,namespace,owner,planId,profiles,run,runtimeMetadata,unmanagedEnvironment", ].includes(Object.keys(value).sort().join()) && (!Object.hasOwn(value, "runtimeMetadata") || value.runtimeMetadata === true) && + (!Object.hasOwn(value, "unmanagedEnvironment") || + (value.unmanagedEnvironment === true && + typeof value.effectiveEnvName === "string")) && (!Object.hasOwn(value, "aws") || validAws(value.aws)) && (!Object.hasOwn(value, "profiles") || validProfiles(value.profiles)) && (!Object.hasOwn(value, "effectiveEnvName") || diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 1fe9d707e..410f0e7f8 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -791,6 +791,8 @@ function selectedMapping(opts: { readonly profiles: readonly string[]; readonly aws?: { readonly profile: string; readonly region?: string }; readonly runtimeMetadata: boolean; + /** Startup found no managed env file at all. */ + readonly unmanagedEnvironment: boolean; }): NativeProjectRun { return { ...opts.ready, @@ -803,6 +805,10 @@ function selectedMapping(opts: { } : null, ...(opts.runtimeMetadata ? { runtimeMetadata: true as const } : {}), + // Only a named env needs the distinction; a base selection delivers no values either way. + ...(opts.unmanagedEnvironment && opts.effectiveEnvName !== null + ? { unmanagedEnvironment: true as const } + : {}), }; } /** @@ -1345,6 +1351,7 @@ export async function startNativeProject(opts: { profiles, aws: selection.aws, runtimeMetadata: metadata !== undefined, + unmanagedEnvironment: input.environmentFiles.length === 0, }); // Enrollment must exist before taking a shared lease: release // requires independent proof of this exact graph's cleanup. diff --git a/tests/native-exec-environment.test.ts b/tests/native-exec-environment.test.ts index e3379f7c3..85bbbb5df 100644 --- a/tests/native-exec-environment.test.ts +++ b/tests/native-exec-environment.test.ts @@ -99,13 +99,21 @@ test("fresh exec refuses missing selectors or deleted overlays before AWS export }); expect(calls).toBe(0); }); -test("fresh exec of a run started without managed env files delivers no values", async () => { +test("fresh exec delivers no values only for a run recorded without managed env files", async () => { const opts = await fixture(); for (const name of ["default", "qa", "other"]) { await rm(join(opts.scope.projectDir, `hack.env.${name}.yaml`)); } - // The recorded env selected only the per-env native files at startup. - expect(await prepareNativeExecEnvironment(opts)).toEqual({}); + // Started with hack.env.qa.yaml (or a legacy mapping): every file deleted since. + await expect(prepareNativeExecEnvironment(opts)).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: expect.stringContaining("selection changed since startup"), + }); + // Recorded as started without any managed env file: the env selected only files. + const unmanaged = { ...opts.run, unmanagedEnvironment: true as const }; + expect( + await prepareNativeExecEnvironment({ ...opts, run: unmanaged }) + ).toEqual({}); expect( await prepareNativeExecEnvironment({ ...opts, diff --git a/tests/native-project-down.test.ts b/tests/native-project-down.test.ts index 4daadb21e..fa6dcd4d2 100644 --- a/tests/native-project-down.test.ts +++ b/tests/native-project-down.test.ts @@ -592,14 +592,26 @@ test("down environment preserves explicit base and named overlay while legacy re "legacy" ); }); -test("down environment of a run started without managed env files is empty", async () => { +test("down environment is empty only for a run recorded without managed env files", async () => { const opts = await fixture(false); - // The recorded env selected only the per-env native files at startup. - for (const effectiveEnvName of ["qa", null]) { + // No managed env file exists. A named selection recorded as managed (or legacy) + // means its overlay disappeared. + await expectClosedRefusal( + nativeDownEnvironment({ + scope: opts.scope, + run: { ...run, effectiveEnvName: "qa" }, + serviceNames: ["web"], + }), + "unavailable" + ); + for (const selected of [ + { effectiveEnvName: "qa", unmanagedEnvironment: true as const }, + { effectiveEnvName: null }, + ]) { expect( await nativeDownEnvironment({ scope: opts.scope, - run: { ...run, effectiveEnvName }, + run: { ...run, ...selected }, serviceNames: ["web"], }) ).toEqual({}); diff --git a/tests/native-project-run.test.ts b/tests/native-project-run.test.ts index ace1adadb..09e180112 100644 --- a/tests/native-project-run.test.ts +++ b/tests/native-project-run.test.ts @@ -247,6 +247,28 @@ test("profile selectors roundtrip canonically while legacy absence stays unknown expect((await load(opts))?.profiles).toBeUndefined(); }); +test("the unmanaged environment marker roundtrips only beside a named env", async () => { + const opts = await fixture(); + const selected = { + ...run, + effectiveEnvName: "qa", + aws: null, + profiles: [], + unmanagedEnvironment: true as const, + }; + await save({ ...opts, run: selected }); + expect(await load(opts)).toEqual(selected); + await remove({ ...opts, expected: selected }); + for (const invalid of [ + { ...selected, unmanagedEnvironment: false }, + { ...selected, effectiveEnvName: null }, + { ...run, effectiveEnvName: "qa", unmanagedEnvironment: true }, + ]) { + await expect(save({ ...opts, run: invalid as never })).rejects.toThrow(); + expect(await load(opts)).toBeNull(); + } +}); + test("restart intent survives mapping retirement and refuses mismatched removal", async () => { const { loadNativeRestartIntent, diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 1d6574500..fde1d6fa2 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -2805,12 +2805,27 @@ test("startup applies the per-env adaptation for the run's effective env, else t } }); -test("startup without managed env files selects and records the requested or default env", async () => { - for (const [envName, defaultOverlay] of [ - ["qa", undefined], - [undefined, "qa"], +test("startup records the requested or default env and marks runs without managed env files", async () => { + for (const [envName, defaultOverlay, managed] of [ + ["qa", undefined, false], + [undefined, "qa", false], + ["qa", undefined, true], ] as const) { const { opts } = await fixture(false); + if (managed) { + // Value-free managed env files: the run is recorded as managed. + for (const name of ["default", "qa"]) { + await writeFile( + join(opts.scope.projectDir, `hack.env.${name}.yaml`), + JSON.stringify({ + version: 1, + environment: name, + secretsprovider: "project_key", + values: { global: {} }, + }) + ); + } + } const base = join(opts.scope.projectRoot, "adaptation.json"); const qa = join(opts.scope.projectRoot, "adaptation.qa.json"); await writeFile( @@ -2844,12 +2859,15 @@ test("startup without managed env files selects and records the requested or def JSON.stringify({ version: 1, additionalHostnames: { web: [host] } }) ); } - // The real input preparation, so the project has no hack.env.* file at all. + // The real input preparation, so the unmanaged runs have no hack.env.* file. opts.dependencies.prepare = prepareNativeProjectInput; const save = opts.dependencies.save!; - const recorded: (string | null | undefined)[] = []; + const recorded: unknown[] = []; opts.dependencies.save = async (request) => { - recorded.push(request.run.effectiveEnvName); + recorded.push({ + effectiveEnvName: request.run.effectiveEnvName, + unmanagedEnvironment: request.run.unmanagedEnvironment, + }); return await save(request); }; let caddy: unknown; @@ -2868,7 +2886,12 @@ test("startup without managed env files selects and records the requested or def ).toBe(0); expect(caddy).toBe("app.hack.local, qa.example.test"); expect(recorded.length).toBeGreaterThan(0); - expect(new Set(recorded)).toEqual(new Set(["qa"])); + for (const mapping of recorded) { + expect(mapping).toEqual({ + effectiveEnvName: "qa", + unmanagedEnvironment: managed ? undefined : true, + }); + } } }); From 271bd6212fd4882bcd3c0ff5491b5f56bb0bd584 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:56:50 -0500 Subject: [PATCH 051/138] feat(native): run Compose restart policies through the guest engine Native Compose-file graphs refused any `restart` other than `no` at graph admission. Admit `always`, `unless-stopped`, `on-failure` and `on-failure:N` and lower them to the guest engine's HostConfig.RestartPolicy, so restart timing, backoff, retry counting and the manual-stop rule follow Docker. - The admitted policy is recorded on the container resource (serde default, omitted when absent, so existing receipts and plan IDs are unchanged) and is part of the retained resource binding checked by restart, restore and hostname change. - Stop wins: every owned stop (graph cleanup and interrupted-start recovery) first disarms the engine policy with `update RestartPolicy=no`, confirmed by a fresh ownership-verified inspection, then stops. The stop preflight accepts the engine `restarting` state only for a receipt-admitted policy, and terminal evidence still requires neither running nor restarting. A policy the receipt never admitted refuses without effect. The disarm is a reducing effect and is allowed on cleanup connections, like stop. - A pending restart is observed as `Observation::Restarting`: it satisfies no readiness goal and is not a failure; an exhausted policy is an ordinary exit. - Incarnation identity stays container ID plus StartedAt. Routed, native HTTP probe, host-dependency, managed-environment and dependency-cache services refuse restart policies with `graph_restart_policy`, because their guards are bound to one incarnation. One-shot completion services accept only `on-failure[:N]`. - Interrupted-start cleanup accepts RestartCount > 0 only for a receipt-admitted policy. - The TS frontend passes the policy to the runtime graph instead of refusing it, and cleanup diagnostics count `restarting` as live compute. - New TLA+ model service-restart-policy (134 states) with negative controls for missing disarm, unproved terminal state and stale incarnation use. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 24 + docs/reference/v5-model-coverage.md | 2 + .../runtime-core/src/project/execution.rs | 12 +- packages/runtime-core/src/project/inputs.rs | 12 +- .../runtime-core/src/provider/engine/stop.rs | 27 + .../src/provider/graph/cache_provenance.rs | 2 + .../runtime-core/src/provider/graph/config.rs | 26 +- .../src/provider/graph/environment/tests.rs | 1 + .../src/provider/graph/host_relay.rs | 1 + .../src/provider/graph/hostname_change.rs | 1 + .../graph/interrupted_start_cleanup.rs | 12 +- .../interrupted_start_cleanup/recovery.rs | 7 + .../runtime-core/src/provider/graph/mod.rs | 28 + .../src/provider/graph/native/mod.rs | 2 + .../runtime-core/src/provider/graph/probes.rs | 11 + .../src/provider/graph/restart_policy.rs | 509 ++++++++++++++++++ .../provider/graph/restore_history/tests.rs | 1 + .../src/provider/graph/shutdown.rs | 92 +++- .../runtime-core/src/provider/graph/source.rs | 1 + .../src/provider/graph/storage_inventory.rs | 1 + .../runtime-core/src/provider/graph/tests.rs | 194 +++++++ scripts/lib/tla-runtime-models.ts | 52 ++ .../native-project-cleanup-command.ts | 3 + src/backends/native-project-start.ts | 4 +- .../tla/service-restart-policy/README.md | 52 ++ .../service-restart-policy/RestartPolicy.tla | 132 +++++ .../completion-reachable.cfg | 9 + .../tla/service-restart-policy/negative.cfg | 8 + .../tla/service-restart-policy/positive.cfg | 8 + .../stale-generation.cfg | 8 + .../unproved-terminal.cfg | 8 + tests/native-project-cleanup-command.test.ts | 15 + tests/native-project-start.test.ts | 17 +- 33 files changed, 1267 insertions(+), 15 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/restart_policy.rs create mode 100644 tests/models/tla/service-restart-policy/README.md create mode 100644 tests/models/tla/service-restart-policy/RestartPolicy.tla create mode 100644 tests/models/tla/service-restart-policy/completion-reachable.cfg create mode 100644 tests/models/tla/service-restart-policy/negative.cfg create mode 100644 tests/models/tla/service-restart-policy/positive.cfg create mode 100644 tests/models/tla/service-restart-policy/stale-generation.cfg create mode 100644 tests/models/tla/service-restart-policy/unproved-terminal.cfg diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..f487946da 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -611,6 +611,30 @@ layer. Removing the owned container discards that layer; use named volumes for durable data. Source bind mounts still require read-only publication. Writable container layers do not imply writable source synchronization. +A service `restart` policy (`always`, `unless-stopped`, `on-failure` or +`on-failure:N`) becomes the guest engine's own restart policy, so restart timing, +backoff, retry counting and the manual-stop rule follow Docker. `no` or omission +keeps automatic restarts off. A one-shot service with `service_completed_successfully` +readiness accepts only `on-failure[:N]`, which retries until it succeeds or its +retries run out; `always` and `unless-stopped` refuse there, as in Compose. While +the engine waits to start the next incarnation, `hack ps` reports the service as +`restarting`, and it satisfies no readiness goal. A policy-restarted container +keeps its container ID but gets a new start time, so Hack treats it as a new +incarnation: any proof bound to the previous one refuses rather than adopting it. +For that reason the graph refuses a restart policy, with `graph_restart_policy`, +on routed services, services with a native HTTP probe, services with host +dependencies, services that receive managed environment values (delivery is +single-use and time-bounded) and dependency-cache initializers. Remove the +`restart` key from those services, or keep them on the Compose backend. + +Every owned stop first sets the engine policy back to `no` and confirms that with a +fresh inspection, then requests the stop. A restart that was already pending is +cancelled by the stop, and cleanup still requires an observation that is neither +running nor restarting before it deletes anything. A guest engine restart or VM +reboot while a graph is still owned restarts its `always` and `unless-stopped` +containers, as Docker does; owned cleanup or recovery stops and removes them. +`hack run` one-off containers never inherit the service restart policy. + The normal-CLI integration helpers prepare modern environment overlays and worktree inheritance in memory, with null declarations in public normalized configuration. An explicit AWS profile adapter can replace the recognized diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..b9064a9c5 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -43,6 +43,7 @@ must be attached to the eventual exact integration revision. | Settled retained Compose route-attempt retirement; pinned references; interrupted deletion | **Coverage gap:** filesystem journal compaction is not modeled; native-route-stop covers a different stop protocol | Instance mutation lock and exact surviving claim witnesses in [journal compaction](../../src/lib/native-compose-route-claims.ts); [claim regressions](../../tests/native-compose-route-claims.test.ts) and [retained lifecycle integration](../../tests/native-compose-adoption-routing-generation.test.ts) | 24 successful operations keep two attempt directories and constant Linux descriptors; four integrated lifecycle operations check directory counts. Injected unlink failures cover same-store and fresh-store cleanup retries. Assumes cooperative same-user writers, the caller's mutation lock and working filesystem synchronization. No process-kill/power-loss durability proof or bound on unresolved/over-budget histories. | | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | +| Engine restart policies: stop versus pending restart, guest engine restart, stale incarnations, terminal proof | service-restart-policy | Receipt-admitted policy, disarm-before-stop and `restarting` stop preflight in `shutdown.rs`; admission refusals for incarnation-bound service classes in `restart_policy.rs`; Rust unit tests for admission, encoding, disarm and stop preflight | Real guest engine behavior (crash restart, stop during backoff, disarm before deletion) needs native host evidence; several services, backoff timing and retry counting are not modeled. | | Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | @@ -85,6 +86,7 @@ liveness/reachability controls have their own documented assumptions. | [fence-first](../../tests/models/tla/fence-first/First.tla) | 24 | 2 | | [fence-pending](../../tests/models/tla/fence-pending/Fence.tla) | 7 | 2 | | [relay-staging](../../tests/models/tla/relay-staging/Stage.tla) | 9 | 2 | +| [service-restart-policy](../../tests/models/tla/service-restart-policy/RestartPolicy.tla) | 134 | 5 | | [relay-fence](../../tests/models/tla/relay-fence/RelayFence.tla) | 16 | 2 | | [shared-helper-instance](../../tests/models/tla/shared-helper-instance/Instance.tla) | 7,649 | 11 | | [restore-history](../../tests/models/tla/restore-history/RestoreHistory.tla) | See named result contract | 2 | diff --git a/packages/runtime-core/src/project/execution.rs b/packages/runtime-core/src/project/execution.rs index 4fb9cc4d9..ccbe7e1cd 100644 --- a/packages/runtime-core/src/project/execution.rs +++ b/packages/runtime-core/src/project/execution.rs @@ -27,9 +27,17 @@ pub enum Health { #[serde(tag = "state", rename_all = "snake_case")] pub enum Observation { Created, - Running { health: Health }, - Exited { code: i64 }, + Running { + health: Health, + }, + Exited { + code: i64, + }, Dead, + /// The engine will start a new incarnation under an admitted restart policy. + /// It satisfies no readiness goal and is not a failure: an exhausted policy + /// is observed later as an ordinary exit. + Restarting, } impl Observation { fn satisfies(self, condition: Condition) -> bool { diff --git a/packages/runtime-core/src/project/inputs.rs b/packages/runtime-core/src/project/inputs.rs index 96d3e25d3..32ae0904e 100644 --- a/packages/runtime-core/src/project/inputs.rs +++ b/packages/runtime-core/src/project/inputs.rs @@ -24,11 +24,17 @@ pub struct ScopedExecutionInputs { pub struct ExecutionInputs { pub review: PlanReport, pub services: BTreeMap, - managed_environment_required: bool, + /// Services that receive at least one managed value through private delivery. + managed_environment_services: std::collections::BTreeSet, } impl ExecutionInputs { pub fn requires_managed_environment(&self) -> bool { - self.managed_environment_required + !self.managed_environment_services.is_empty() + } + + /// Whether this service's process depends on single-incarnation managed delivery. + pub fn service_requires_managed_environment(&self, service: &str) -> bool { + self.managed_environment_services.contains(service) } } fn error(code: &'static str, message: &str) -> CandidateError { @@ -599,7 +605,7 @@ fn compile_inner( executable: ExecutionInputs { review, services, - managed_environment_required: !managed_environment.is_empty(), + managed_environment_services: managed_environment.keys().cloned().collect(), }, managed_environment, }) diff --git a/packages/runtime-core/src/provider/engine/stop.rs b/packages/runtime-core/src/provider/engine/stop.rs index 4b79d5cab..05a91d0d1 100644 --- a/packages/runtime-core/src/provider/engine/stop.rs +++ b/packages/runtime-core/src/provider/engine/stop.rs @@ -128,6 +128,33 @@ impl Engine<'_> { .map_err(|failure| failure.error) } + /// Disarm an owned container's engine restart policy before a stop. This is + /// a reducing effect like stop: it never allocates or starts anything, so it + /// is permitted on cleanup connections. The caller has inspected ownership and + /// must reinspect afterward; a transport success is not proof of the change. + pub(in crate::provider) fn disarm_restart_policy( + &self, + id: &str, + ) -> Result<(), CandidateError> { + if id.len() != 64 + || !id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(failure( + "Restart policy update requires an immutable container ID.", + )); + } + self.guest.verify()?; + let result = self.transport.request( + Method::POST, + &format!("/v1.53/containers/{id}/update"), + Some(&serde_json::json!({"RestartPolicy":{"Name":"no","MaximumRetryCount":0}})), + ); + self.guest.verify()?; + result.map(|_| ()) + } + pub(in crate::provider) fn stop_containers_diagnosed( &self, stops: &[(String, u64)], diff --git a/packages/runtime-core/src/provider/graph/cache_provenance.rs b/packages/runtime-core/src/provider/graph/cache_provenance.rs index 766d55e72..2519c816f 100644 --- a/packages/runtime-core/src/provider/graph/cache_provenance.rs +++ b/packages/runtime-core/src/provider/graph/cache_provenance.rs @@ -282,6 +282,7 @@ mod tests { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Container, key: "deps".into(), name: "owned".into(), @@ -374,6 +375,7 @@ mod tests { cache: Some(cache.clone()), cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Volume, key: "data".into(), name: cache.name(), diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 257424239..3f1a17598 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -88,7 +88,7 @@ fn retain_ownership( fn unsupported() -> CandidateError { error( "graph_subset", - "Graph driver requires pinned images, explicit managed environment delivery, internal networks, named volumes and no automatic restart; build, unbound/writable source, port, label and logging overrides remain gated.", + "Graph driver requires pinned images, explicit managed environment delivery, internal networks and named volumes; build, unbound/writable source, port, label and logging overrides remain gated.", ) } pub(super) fn prepare( @@ -179,13 +179,29 @@ pub(super) fn prepare_delivery( } else { BTreeMap::new() }; + // Restart policies lower to the engine's own policy only for service classes + // whose guards do not depend on a single container incarnation. + let mut restart_policies = BTreeMap::new(); + for (name, service) in plan.services.iter().filter(|(_, s)| s.active) { + if let Some(policy) = super::restart_policy::admit(&super::restart_policy::Admission { + service, + ready: graph.services[name].ready, + managed_environment: inputs.service_requires_managed_environment(name), + host_dependencies: !service.extra_hosts.is_empty() + || inputs + .services + .get(name) + .is_some_and(|values| !values.extra_hosts.is_empty()), + })? { + restart_policies.insert(name.clone(), policy); + } + } let mut networks = BTreeSet::new(); let mut volumes = BTreeSet::new(); let mut total_memory = 0u64; let mut total_cpus = 0f64; for service in plan.services.values().filter(|s| s.active) { - if service.restart != "no" - || !service.ports.is_empty() + if !service.ports.is_empty() || service.labels.keys().any(|key| { !(routing_enrolled && service.routing.is_some() && routes::recognized(key)) && !(service.dependency_cache.is_some() @@ -310,6 +326,7 @@ pub(super) fn prepare_delivery( cache: cache.cloned(), cache_provenance: None, disposable_caches, + restart_policy: None, kind, key: key.clone(), name: cache.map_or_else( @@ -335,6 +352,7 @@ pub(super) fn prepare_delivery( cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: restart_policies.get(name).copied(), kind: Kind::Container, key: name.clone(), name: format!("hkg-{run}-container-{index}"), @@ -374,6 +392,8 @@ pub(super) fn prepare_delivery( // Writable roots are container-owned layers, not writable source binds. // Their contents are discarded when the container is removed; durable data uses named volumes. let mut config = container_base(image, labels); + config["HostConfig"]["RestartPolicy"] = + super::restart_policy::RestartPolicy::engine(resource.restart_policy.as_ref()); for (field, value) in [ ("NetworkMode", json!(network)), ("Memory", json!(service.limits.memory_bytes.unwrap_or(0))), diff --git a/packages/runtime-core/src/provider/graph/environment/tests.rs b/packages/runtime-core/src/provider/graph/environment/tests.rs index 990348365..855c9b5a5 100644 --- a/packages/runtime-core/src/provider/graph/environment/tests.rs +++ b/packages/runtime-core/src/provider/graph/environment/tests.rs @@ -145,6 +145,7 @@ fn container_attachment_records_ownership_before_create_and_omits_values_from_me cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Container, key: "job".into(), name: format!("hkg-{run}-container-0"), diff --git a/packages/runtime-core/src/provider/graph/host_relay.rs b/packages/runtime-core/src/provider/graph/host_relay.rs index fc1c221f6..5fcdf3b3c 100644 --- a/packages/runtime-core/src/provider/graph/host_relay.rs +++ b/packages/runtime-core/src/provider/graph/host_relay.rs @@ -1011,6 +1011,7 @@ mod tests { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Container, key: "web".into(), name: "owned-web".into(), diff --git a/packages/runtime-core/src/provider/graph/hostname_change.rs b/packages/runtime-core/src/provider/graph/hostname_change.rs index 80a3382ab..99f810756 100644 --- a/packages/runtime-core/src/provider/graph/hostname_change.rs +++ b/packages/runtime-core/src/provider/graph/hostname_change.rs @@ -229,6 +229,7 @@ pub(super) mod tests { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind, key: key.into(), name: format!("owned-{key}"), diff --git a/packages/runtime-core/src/provider/graph/interrupted_start_cleanup.rs b/packages/runtime-core/src/provider/graph/interrupted_start_cleanup.rs index e4ebcbd85..fabb1d574 100644 --- a/packages/runtime-core/src/provider/graph/interrupted_start_cleanup.rs +++ b/packages/runtime-core/src/provider/graph/interrupted_start_cleanup.rs @@ -275,7 +275,10 @@ fn failed_start( let state = &value["State"]; let prepared = shutdown::prepare(resource, &value)?; let restart_count = value["RestartCount"].as_u64().ok_or_else(refused)?; - if restart_count != 0 { + // Only a receipt-admitted engine policy can explain a restarted + // incarnation; any other restart is a foreign effect. + let restarted = restart_count != 0; + if restarted && !restart_policy::admits_restarting(resource) { return Err(refused()); } if state["Status"] == "created" @@ -292,9 +295,10 @@ fn failed_start( failed = Some((key.clone(), resource.id.clone().ok_or_else(refused)?)); } else if prepared.running { if resource.phase != "started" - || !state["FinishedAt"] - .as_str() - .is_some_and(|s| s.starts_with("0001-")) + || (!restarted + && !state["FinishedAt"] + .as_str() + .is_some_and(|s| s.starts_with("0001-"))) { return Err(refused()); } diff --git a/packages/runtime-core/src/provider/graph/interrupted_start_cleanup/recovery.rs b/packages/runtime-core/src/provider/graph/interrupted_start_cleanup/recovery.rs index f39495924..11c2ed160 100644 --- a/packages/runtime-core/src/provider/graph/interrupted_start_cleanup/recovery.rs +++ b/packages/runtime-core/src/provider/graph/interrupted_start_cleanup/recovery.rs @@ -240,6 +240,13 @@ fn advance_stop( if key == proof.selected.failed_key { failed_created(resource, &value)?; } + if !journal.pending { + // Disarming is idempotent and reducing, so it precedes the journaled + // stop and never creates a pending effect of its own. + fence(proof, coordinator, journal)?; + shutdown::disarm_one(&proof.engine, &proof.current, resource)?; + } + let value = inspect_resource(&proof.engine, &proof.current, resource)?.ok_or_else(refused)?; if stop_decision(resource, &value, journal.pending)? == StopDecision::IssueOneStop { let prepared = shutdown::prepare(resource, &value)?; journal.pending = true; diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..6af1f9393 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -109,6 +109,7 @@ pub use host_relay::{ HostRelayService, cleanup_with_relay, confirm_relay_cleanup, resume_relay_cleanup, }; mod relay; +mod restart_policy; pub use bridges::{ ReserveBridgeOptions, RouteSlot, inspect_bridges, publish_bridge, reconcile_bridges, release_bridge, reserve_bridge, start_bridge, @@ -229,6 +230,10 @@ pub struct Resource { /// Fixed at admission; only these exact paths may be explicitly quarantined. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub disposable_caches: Vec, + /// Admitted engine restart policy of a container; absent means `no`. + /// Only this receipt field authorizes engine restart states during stop. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub restart_policy: Option, pub kind: Kind, pub key: String, pub name: String, @@ -387,6 +392,15 @@ fn verify_resource_identity( } fn observation(value: &Value) -> Result { let state = &value["State"]; + // A restarting container keeps its previous exit facts (including OOM) while + // the engine waits to start the next incarnation. Callers decide whether the + // receipt admits this state; see `probes::observe`. + if state["Restarting"] == true { + if state["Running"] != true || state["Pid"] != 0 || state["Dead"] == true { + return Err(error("graph_state", "Unexpected owned service state.")); + } + return Ok(Observation::Restarting); + } if state["Dead"] == true || state["OOMKilled"] == true { return Ok(Observation::Dead); } @@ -446,6 +460,7 @@ fn same_resource_bindings( && old.cache == resource.cache && old.outbound == resource.outbound && old.routing == resource.routing + && old.restart_policy == resource.restart_policy && (old.networks == resource.networks // Old receipts predate attachment persistence and admitted at most // one network. The caller also requires the unchanged plan identity. @@ -551,6 +566,19 @@ fn load_at( resource.kind != Kind::Container || !routes::valid_intent(route) }) }) + // Restart authority exists only on containers, never alongside an + // incarnation-bound route or a completion goal that cannot retry. + || receipt.resources.values().any(|resource| { + resource.restart_policy.as_ref().is_some_and(|policy| { + resource.kind != Kind::Container + || !policy.valid() + || resource.routing.is_some() + || resource.cache.is_some() + || receipt.probes.contains_key(&resource.key) + || (receipt.readiness.get(&resource.key) == Some(&Condition::Completed) + && !matches!(policy, restart_policy::RestartPolicy::OnFailure { .. })) + }) + }) || ![ "preparing", "ready-observed", diff --git a/packages/runtime-core/src/provider/graph/native/mod.rs b/packages/runtime-core/src/provider/graph/native/mod.rs index 741f3e072..0b4c14409 100644 --- a/packages/runtime-core/src/provider/graph/native/mod.rs +++ b/packages/runtime-core/src/provider/graph/native/mod.rs @@ -156,6 +156,7 @@ fn configuration_with_source( cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Network, key: key.clone(), name: format!("hkn-{}-network-{index}", review.scope().run), @@ -184,6 +185,7 @@ fn configuration_with_source( cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Container, key: name.clone(), name: format!("hkn-{}-container-{index}", review.scope().run), diff --git a/packages/runtime-core/src/provider/graph/probes.rs b/packages/runtime-core/src/provider/graph/probes.rs index ae48c5798..f778afb83 100644 --- a/packages/runtime-core/src/provider/graph/probes.rs +++ b/packages/runtime-core/src/provider/graph/probes.rs @@ -204,6 +204,17 @@ pub(super) fn observe( value: &Value, ) -> Result { let observed = observation(value)?; + if observed == Observation::Restarting + && !receipt + .resources + .get(&format!("container:{name}")) + .is_some_and(super::restart_policy::admits_restarting) + { + return Err(error( + "graph_state", + "Owned service is restarting without an admitted restart policy.", + )); + } let Some(p) = receipt.probes.get(name) else { return Ok(observed); }; diff --git a/packages/runtime-core/src/provider/graph/restart_policy.rs b/packages/runtime-core/src/provider/graph/restart_policy.rs new file mode 100644 index 000000000..3ca88ee67 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/restart_policy.rs @@ -0,0 +1,509 @@ +//! Engine restart policies for owned Compose-file graph services. +//! +//! A reviewed `restart` value other than `no` lowers to the guest engine's own +//! `HostConfig.RestartPolicy`, so restart timing, backoff, retry counting and the +//! manual-stop rule follow Docker semantics. The admitted policy is recorded on +//! the container resource; that receipt field, never an inspected value alone, +//! authorizes the extra engine states a restarting container can show. +//! +//! Ownership invariants this module keeps: +//! - Stop wins: every owned stop first disarms the engine policy (`update` to +//! `no`) and then requests an ordinary stop. A pending restart that races the +//! stop is cancelled by the engine; the caller still requires a fresh terminal +//! observation with `Restarting == false` before any deletion. +//! - Incarnation identity stays container ID plus `StartedAt`. A restart keeps +//! the ID and changes `StartedAt`, so every existing incarnation-bound proof +//! (endpoint generations, relay targets, completed-generation records) refuses +//! a restarted container instead of silently adopting it. Services whose +//! publication or delivery is bound to one incarnation (routes, HTTP probes, +//! host dependencies, managed environment delivery, dependency caches) are +//! refused at admission until they can republish per incarnation. +use super::{CandidateError, Kind, Resource, error, hex}; +use crate::project::{ServicePlan, execution::Condition}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Docker accepts a signed 32-bit retry count; zero means unbounded. +const MAX_RETRY_COUNT: u32 = i32::MAX as u32; + +/// An admitted non-`no` Compose restart policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "Encoded", into = "Encoded")] +pub enum RestartPolicy { + Always, + UnlessStopped, + OnFailure { + /// Zero is Docker's unbounded `on-failure`. + maximum_retry_count: u32, + }, +} + +/// Closed receipt encoding: a retry count exists exactly for `on-failure`. +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Encoded { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + maximum_retry_count: Option, +} + +impl TryFrom for RestartPolicy { + type Error = &'static str; + + fn try_from(value: Encoded) -> Result { + let policy = match (value.name.as_str(), value.maximum_retry_count) { + ("always", None) => Self::Always, + ("unless-stopped", None) => Self::UnlessStopped, + ("on-failure", Some(maximum_retry_count)) => Self::OnFailure { + maximum_retry_count, + }, + _ => return Err("invalid restart policy"), + }; + if !policy.valid() { + return Err("invalid restart policy"); + } + Ok(policy) + } +} + +impl From for Encoded { + fn from(value: RestartPolicy) -> Self { + let (name, maximum_retry_count) = match value { + RestartPolicy::Always => ("always", None), + RestartPolicy::UnlessStopped => ("unless-stopped", None), + RestartPolicy::OnFailure { + maximum_retry_count, + } => ("on-failure", Some(maximum_retry_count)), + }; + Self { + name: name.into(), + maximum_retry_count, + } + } +} + +impl RestartPolicy { + /// Parse a reviewed Compose value. `no` is `None`; anything else outside the + /// Compose grammar is refused rather than defaulted. + pub(super) fn parse(value: &str) -> Result, CandidateError> { + let invalid = || error("graph_restart_policy", "Unsupported restart policy."); + Ok(Some(match value { + "no" => return Ok(None), + "always" => Self::Always, + "unless-stopped" => Self::UnlessStopped, + "on-failure" => Self::OnFailure { + maximum_retry_count: 0, + }, + other => { + let count = other + .strip_prefix("on-failure:") + .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + .and_then(|n| n.parse::().ok()) + .filter(|n| (1..=MAX_RETRY_COUNT).contains(n)) + .ok_or_else(invalid)?; + Self::OnFailure { + maximum_retry_count: count, + } + } + })) + } + + pub(super) fn valid(&self) -> bool { + match self { + Self::Always | Self::UnlessStopped => true, + Self::OnFailure { + maximum_retry_count, + } => *maximum_retry_count <= MAX_RETRY_COUNT, + } + } + + /// Engine request value. Unconditional policies always carry a zero count. + pub(super) fn engine(policy: Option<&Self>) -> Value { + match policy { + None => json!({"Name":"no"}), + Some(Self::Always) => json!({"Name":"always","MaximumRetryCount":0}), + Some(Self::UnlessStopped) => json!({"Name":"unless-stopped","MaximumRetryCount":0}), + Some(Self::OnFailure { + maximum_retry_count, + }) => json!({"Name":"on-failure","MaximumRetryCount":maximum_retry_count}), + } + } +} + +fn refused(message: &'static str) -> CandidateError { + error("graph_restart_policy", message) +} + +/// The facts admission needs beyond the reviewed service itself. +pub(super) struct Admission<'a> { + pub service: &'a ServicePlan, + pub ready: Condition, + pub managed_environment: bool, + pub host_dependencies: bool, +} + +/// Admit a declared policy for one active service, or refuse it with a fixed, +/// value-free reason. Admission never weakens an incarnation-bound guard: those +/// service classes refuse instead of running without their guard. +pub(super) fn admit(admission: &Admission<'_>) -> Result, CandidateError> { + let Some(policy) = RestartPolicy::parse(&admission.service.restart)? else { + return Ok(None); + }; + let service = admission.service; + if admission.ready == Condition::Completed && !matches!(policy, RestartPolicy::OnFailure { .. }) + { + return Err(refused( + "Completion readiness cannot use an unconditional restart policy.", + )); + } + if service.dependency_cache.is_some() { + return Err(refused( + "Dependency cache initializers cannot use an automatic restart policy.", + )); + } + if service.routing.is_some() { + return Err(refused( + "Routed services cannot yet use an automatic restart policy: route publication is bound to one container incarnation.", + )); + } + if service + .healthcheck + .as_ref() + .is_some_and(|health| health.native_http.is_some()) + { + return Err(refused( + "Native HTTP probes cannot yet use an automatic restart policy: the probe is bound to one container incarnation.", + )); + } + if admission.host_dependencies { + return Err(refused( + "Services with host dependencies cannot yet use an automatic restart policy: relays are bound to one container incarnation.", + )); + } + if admission.managed_environment { + return Err(refused( + "Services with managed environment delivery cannot yet use an automatic restart policy: delivery is single-incarnation and time-bounded.", + )); + } + Ok(Some(policy)) +} + +/// Engine `RestartPolicy.Name` of an ownership-verified inspection. Docker +/// reports an omitted policy as an empty name, which means `no`. +fn observed_name(inspected: &Value) -> Result<&str, CandidateError> { + match inspected["HostConfig"]["RestartPolicy"]["Name"].as_str() { + Some("") | Some("no") => Ok("no"), + Some(name @ ("always" | "unless-stopped" | "on-failure")) => Ok(name), + _ => Err(refused("Owned container restart policy is unknown.")), + } +} + +/// Decide whether an owned container needs its engine policy disarmed before a +/// stop. A policy the receipt never admitted is a foreign change and refuses. +/// Returns the immutable container ID to disarm. +pub(super) fn disarm_target( + resource: &Resource, + inspected: &Value, +) -> Result, CandidateError> { + if resource.kind != Kind::Container { + return Ok(None); + } + let id = inspected["Id"] + .as_str() + .filter(|id| hex(id, 64)) + .ok_or_else(|| refused("Owned container identity is missing."))?; + if resource + .id + .as_deref() + .is_some_and(|recorded| recorded != id) + { + return Err(refused("Owned container identity changed.")); + } + let name = observed_name(inspected)?; + if name == "no" { + return Ok(None); + } + if resource.restart_policy.is_none() { + return Err(refused( + "Owned container has a restart policy this graph never admitted.", + )); + } + Ok(Some(id.into())) +} + +/// Require that a fresh observation after disarming shows no engine policy. +pub(super) fn verify_disarmed(inspected: &Value) -> Result<(), CandidateError> { + if observed_name(inspected)? != "no" { + return Err(refused( + "Owned container restart policy was not disarmed; stop was not requested.", + )); + } + Ok(()) +} + +/// Whether a resource's receipt admits the engine `restarting` state. +pub(super) fn admits_restarting(resource: &Resource) -> bool { + resource.kind == Kind::Container && resource.restart_policy.is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resource(policy: Option) -> Resource { + Resource { + routing: None, + networks: Some(Vec::new()), + outbound: false, + cache: None, + cache_provenance: None, + disposable_caches: Vec::new(), + restart_policy: policy, + kind: Kind::Container, + key: "worker".into(), + name: "hkg-run-container-0".into(), + id: Some("a".repeat(64)), + image: Some(format!("sha256:{}", "b".repeat(64))), + phase: "started".into(), + } + } + + fn inspected(name: &str) -> Value { + json!({"Id":"a".repeat(64),"HostConfig":{"RestartPolicy":{"Name":name,"MaximumRetryCount":0}}}) + } + + #[test] + fn parses_the_compose_grammar_and_refuses_everything_else() { + assert_eq!(RestartPolicy::parse("no").unwrap(), None); + assert_eq!( + RestartPolicy::parse("always").unwrap(), + Some(RestartPolicy::Always) + ); + assert_eq!( + RestartPolicy::parse("unless-stopped").unwrap(), + Some(RestartPolicy::UnlessStopped) + ); + assert_eq!( + RestartPolicy::parse("on-failure").unwrap(), + Some(RestartPolicy::OnFailure { + maximum_retry_count: 0 + }) + ); + assert_eq!( + RestartPolicy::parse("on-failure:3").unwrap(), + Some(RestartPolicy::OnFailure { + maximum_retry_count: 3 + }) + ); + for value in [ + "", + "No", + "on-failure:", + "on-failure:0", + "on-failure:-1", + "on-failure:+2", + "on-failure:2147483648", + "on-failure: 2", + "unless_stopped", + ] { + assert_eq!( + RestartPolicy::parse(value).unwrap_err().code, + "graph_restart_policy", + "{value}" + ); + } + } + + #[test] + fn engine_values_follow_docker_restart_policy_fields() { + assert_eq!(RestartPolicy::engine(None), json!({"Name":"no"})); + assert_eq!( + RestartPolicy::engine(Some(&RestartPolicy::UnlessStopped)), + json!({"Name":"unless-stopped","MaximumRetryCount":0}) + ); + assert_eq!( + RestartPolicy::engine(Some(&RestartPolicy::OnFailure { + maximum_retry_count: 5 + })), + json!({"Name":"on-failure","MaximumRetryCount":5}) + ); + } + + #[test] + fn receipt_encoding_is_closed_and_round_trips() { + for policy in [ + RestartPolicy::Always, + RestartPolicy::UnlessStopped, + RestartPolicy::OnFailure { + maximum_retry_count: 7, + }, + ] { + let value = serde_json::to_value(policy).unwrap(); + assert_eq!( + serde_json::from_value::(value).unwrap(), + policy + ); + } + assert_eq!( + serde_json::to_value(RestartPolicy::OnFailure { + maximum_retry_count: 2 + }) + .unwrap(), + json!({"name":"on-failure","maximum_retry_count":2}) + ); + for value in [ + json!({"name":"no"}), + json!({"name":"always","maximum_retry_count":1}), + json!({"name":"on-failure"}), + json!({"name":"on-failure","maximum_retry_count":1,"extra":true}), + json!({"name":"on-failure","maximum_retry_count":2147483648_u64}), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + } + + /// A synthetic internal worker in the real public plan shape. + fn service(restart: &str) -> ServicePlan { + serde_json::from_value(json!({"active":true,"profiles":[],"dependencies":{},"mounts":[], + "environment":{},"environment_files":[],"environment_precedence":"test","ports":[], + "exposed_ports":[],"networks":["default"],"network_mode":"project-networks","limits":{}, + "read_only":true,"init":false,"restart":restart,"labels":{}, + "healthcheck":{"disabled":false,"test":{"form":"exec","arguments":[],"review_field":"services.worker.healthcheck.test"}}})) + .unwrap() + } + + fn admit_with( + service: &ServicePlan, + ready: Condition, + managed_environment: bool, + host_dependencies: bool, + ) -> Result, CandidateError> { + admit(&Admission { + service, + ready, + managed_environment, + host_dependencies, + }) + } + + #[test] + fn admits_docker_policies_for_long_running_and_retrying_one_shot_services() { + for (value, expected) in [ + ("no", None), + ("always", Some(RestartPolicy::Always)), + ("unless-stopped", Some(RestartPolicy::UnlessStopped)), + ( + "on-failure:4", + Some(RestartPolicy::OnFailure { + maximum_retry_count: 4, + }), + ), + ] { + for ready in [Condition::Started, Condition::Healthy] { + assert_eq!( + admit_with(&service(value), ready, false, false).unwrap(), + expected + ); + } + } + // One-shot jobs keep Docker's retry-until-success semantics. + assert_eq!( + admit_with(&service("on-failure"), Condition::Completed, false, false).unwrap(), + Some(RestartPolicy::OnFailure { + maximum_retry_count: 0 + }) + ); + for value in ["always", "unless-stopped"] { + assert_eq!( + admit_with(&service(value), Condition::Completed, false, false) + .unwrap_err() + .code, + "graph_restart_policy" + ); + } + } + + #[test] + fn refuses_incarnation_bound_service_classes_instead_of_dropping_guards() { + let plain = service("unless-stopped"); + assert!(admit_with(&plain, Condition::Started, true, false).is_err()); + assert!(admit_with(&plain, Condition::Started, false, true).is_err()); + let mut routed = plain.clone(); + routed.routing = Some( + serde_json::from_value(json!({"hostnames":["worker.example.test"],"port":3000})) + .unwrap(), + ); + assert!(admit_with(&routed, Condition::Healthy, false, false).is_err()); + let mut probed = plain.clone(); + probed.healthcheck = Some( + serde_json::from_value( + json!({"disabled":false,"native_http":{"port":3000,"path":"/health", + "interval_ms":100,"timeout_ms":500,"retries":3,"start_period_ms":0}}), + ) + .unwrap(), + ); + assert!(admit_with(&probed, Condition::Healthy, false, false).is_err()); + let mut cache = service("on-failure"); + cache.dependency_cache = Some( + serde_json::from_value(json!({"volume":"deps","lockfiles":["bun.lock"], + "lockfiles_explicit":true,"runtime_files":[],"bootstrap":true})) + .unwrap(), + ); + assert!(admit_with(&cache, Condition::Completed, false, false).is_err()); + // The same classes without a restart policy are unaffected. + for (mut plan, ready) in [ + (routed, Condition::Healthy), + (probed, Condition::Healthy), + (cache, Condition::Completed), + ] { + plan.restart = "no".into(); + assert_eq!(admit_with(&plan, ready, true, true).unwrap(), None); + } + for error in [ + admit_with(&plain, Condition::Started, true, false).unwrap_err(), + admit_with(&plain, Condition::Started, false, true).unwrap_err(), + ] { + assert_eq!(error.code, "graph_restart_policy"); + } + } + + #[test] + fn disarm_requires_receipt_admission_for_any_engine_policy() { + let admitted = resource(Some(RestartPolicy::UnlessStopped)); + let legacy = resource(None); + assert_eq!(disarm_target(&legacy, &inspected("no")).unwrap(), None); + assert_eq!(disarm_target(&legacy, &inspected("")).unwrap(), None); + assert_eq!(disarm_target(&admitted, &inspected("no")).unwrap(), None); + assert_eq!( + disarm_target(&admitted, &inspected("unless-stopped")).unwrap(), + Some("a".repeat(64)) + ); + // A disarmed policy can differ from the admitted one; any live engine + // policy on an admitted container is disarmed before the stop. + assert_eq!( + disarm_target(&admitted, &inspected("always")).unwrap(), + Some("a".repeat(64)) + ); + assert_eq!( + disarm_target(&legacy, &inspected("always")) + .unwrap_err() + .code, + "graph_restart_policy" + ); + assert!(disarm_target(&admitted, &inspected("sometimes")).is_err()); + assert!(disarm_target(&admitted, &json!({"Id":"a".repeat(64)})).is_err()); + let mut foreign = inspected("always"); + foreign["Id"] = json!("c".repeat(64)); + assert!(disarm_target(&admitted, &foreign).is_err()); + } + + #[test] + fn disarm_verification_requires_a_fresh_no_policy() { + assert!(verify_disarmed(&inspected("no")).is_ok()); + assert!(verify_disarmed(&inspected("")).is_ok()); + for name in ["always", "unless-stopped", "on-failure", "other"] { + assert!(verify_disarmed(&inspected(name)).is_err(), "{name}"); + } + assert!(verify_disarmed(&json!({})).is_err()); + } +} diff --git a/packages/runtime-core/src/provider/graph/restore_history/tests.rs b/packages/runtime-core/src/provider/graph/restore_history/tests.rs index 32fbedc88..4f21b192a 100644 --- a/packages/runtime-core/src/provider/graph/restore_history/tests.rs +++ b/packages/runtime-core/src/provider/graph/restore_history/tests.rs @@ -26,6 +26,7 @@ fn receipt(index: usize) -> Receipt { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Container, key: "web".into(), name: "owned-web".into(), diff --git a/packages/runtime-core/src/provider/graph/shutdown.rs b/packages/runtime-core/src/provider/graph/shutdown.rs index 011b2139b..2540698b0 100644 --- a/packages/runtime-core/src/provider/graph/shutdown.rs +++ b/packages/runtime-core/src/provider/graph/shutdown.rs @@ -78,6 +78,10 @@ pub(super) fn stop_owned( { return Err(refused()); } + // Stop wins against engine restarts: disarm every admitted policy and + // confirm it with a fresh observation before the first stop request. A + // restart already pending in the engine is cancelled by the stop itself. + disarm_owned(engine, receipt)?; let mut selected = Vec::new(); for resource in receipt .resources @@ -120,6 +124,41 @@ pub(super) fn stop_owned( state::write(&path, &evidence) } +/// Disarm the engine restart policy of every owned container that still has one. +/// Each target is ownership-verified by inspection before and after the update. +/// Idempotent: an already disarmed or absent container needs no effect. +pub(super) fn disarm_owned(engine: &Engine<'_>, receipt: &Receipt) -> Result<(), CandidateError> { + for resource in receipt + .resources + .values() + .filter(|r| r.kind == Kind::Container) + { + disarm_one(engine, receipt, resource)?; + } + Ok(()) +} + +/// Disarm one owned container. The receipt-admitted policy is the authority; +/// an engine policy the receipt never admitted refuses without any effect. +pub(super) fn disarm_one( + engine: &Engine<'_>, + receipt: &Receipt, + resource: &Resource, +) -> Result<(), CandidateError> { + let Some(value) = inspect_resource(engine, receipt, resource)? else { + return Ok(()); + }; + let Some(id) = super::restart_policy::disarm_target(resource, &value)? else { + return Ok(()); + }; + engine.disarm_restart_policy(&id)?; + let value = inspect_resource(engine, receipt, resource)?.ok_or_else(refused)?; + if value["Id"].as_str() != Some(id.as_str()) { + return Err(refused()); + } + super::restart_policy::verify_disarmed(&value) +} + pub(super) fn stop_error( failure: StopBatchFailure, admitted: &BTreeMap<&str, &str>, @@ -252,8 +291,16 @@ pub(super) fn prepare(resource: &Resource, inspected: &Value) -> Result Result {} + (true, Some("restarting"), 0) if restarting => {} (false, Some("exited"), 0) => {} // Docker may assign a nonzero exit code when startup fails before the // container ever runs. Preserve that failure without requesting a stop. @@ -410,6 +458,7 @@ mod tests { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Container, key: "service".into(), name: "owned-service".into(), @@ -465,6 +514,47 @@ mod tests { assert!(terminal(&resource, &value, true).is_err()); } + fn restarting(value: &mut Value) { + value["State"]["Restarting"] = json!(true); + value["State"]["Status"] = json!("restarting"); + value["State"]["Pid"] = json!(0); + value["State"]["ExitCode"] = json!(137); + // The engine keeps the previous incarnation's exit facts while waiting. + value["State"]["OOMKilled"] = json!(true); + } + + #[test] + fn admitted_restart_policy_makes_pending_restart_a_stop_target() { + let (mut resource, mut value) = fixture(); + restarting(&mut value); + // Without receipt admission a restarting container stays uncertain. + assert!(prepare(&resource, &value).is_err()); + resource.restart_policy = Some(super::super::restart_policy::RestartPolicy::UnlessStopped); + let prepared = prepare(&resource, &value).unwrap(); + assert!(prepared.running); + assert_eq!(prepared.grace_seconds, 10); + // A stop cannot be confirmed while the engine still reports a pending restart. + assert!(terminal(&resource, &value, true).is_err()); + for (field, invalid) in [ + ("Running", json!(false)), + ("Status", json!("running")), + ("Pid", json!(42)), + ("Paused", json!(true)), + ("Dead", json!(true)), + ] { + let mut changed = value.clone(); + changed["State"][field] = invalid; + assert!(prepare(&resource, &changed).is_err(), "{field}"); + } + // After the stop wins, the ordinary terminal proof applies unchanged. + value["State"]["Restarting"] = json!(false); + value["State"]["Running"] = json!(false); + value["State"]["Status"] = json!("exited"); + let evidence = terminal(&resource, &value, true).unwrap(); + assert_eq!(evidence.exit_code, 137); + assert!(evidence.stop_requested); + } + #[test] fn retains_exit_and_oom_evidence_without_claiming_graceful_exit() { let (resource, mut value) = fixture(); diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 11aa9ed0c..f9958e2ff 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -891,6 +891,7 @@ mod tests { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, }, )]), }; diff --git a/packages/runtime-core/src/provider/graph/storage_inventory.rs b/packages/runtime-core/src/provider/graph/storage_inventory.rs index 03c224c03..428f12b06 100644 --- a/packages/runtime-core/src/provider/graph/storage_inventory.rs +++ b/packages/runtime-core/src/provider/graph/storage_inventory.rs @@ -208,6 +208,7 @@ mod tests { cache: Some(cache.clone()), cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Volume, key: "deps".into(), name: cache.name(), diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..80884a41b 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -157,6 +157,196 @@ fn driver_profile_preserves_isolation_and_refuses_unqualified_inputs() { assert!(!candidate.state_root.exists()); } #[test] +fn compose_restart_policies_lower_to_engine_policies_with_receipt_authority() { + let fixture = Fixture::new(); + let home = Fixture::new(); + let candidate = Candidate::discover(&home.0).unwrap(); + for (web, init, expected) in [ + (None, None, Ok((json!({"Name":"no"}), None))), + ( + Some("unless-stopped"), + Some("on-failure:2"), + Ok(( + json!({"Name":"unless-stopped","MaximumRetryCount":0}), + Some(restart_policy::RestartPolicy::UnlessStopped), + )), + ), + ( + Some("always"), + Some("no"), + Ok(( + json!({"Name":"always","MaximumRetryCount":0}), + Some(restart_policy::RestartPolicy::Always), + )), + ), + ( + Some("on-failure:3"), + None, + Ok(( + json!({"Name":"on-failure","MaximumRetryCount":3}), + Some(restart_policy::RestartPolicy::OnFailure { + maximum_retry_count: 3, + }), + )), + ), + // A completion goal cannot use an unconditional policy (review and graph). + (None, Some("unless-stopped"), Err(())), + ] { + let mut document = compose(&format!("sha256:{}", "a".repeat(64)), "marker", false); + if let Some(value) = web { + document["services"]["web"]["restart"] = json!(value); + } + if let Some(value) = init { + document["services"]["init"]["restart"] = json!(value); + } + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let compiled = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ); + let result = compiled.and_then(|inputs| { + config::prepare(inputs, &goals(), &"a".repeat(32), &"b".repeat(32), None) + }); + let Ok((policy, admitted)) = expected else { + assert!(result.is_err(), "{web:?} {init:?}"); + continue; + }; + let prepared = result.unwrap(); + assert_eq!( + prepared.configs["web"]["HostConfig"]["RestartPolicy"], + policy + ); + assert_eq!(prepared.resources["container:web"].restart_policy, admitted); + // `check` never declared a policy and keeps Docker's `no`. + assert_eq!( + prepared.configs["check"]["HostConfig"]["RestartPolicy"], + json!({"Name":"no"}) + ); + assert!( + prepared.resources["container:check"] + .restart_policy + .is_none() + ); + let init_policy = prepared.resources["container:init"].restart_policy; + assert_eq!( + init_policy, + (init == Some("on-failure:2")).then_some(restart_policy::RestartPolicy::OnFailure { + maximum_retry_count: 2 + }) + ); + // The admitted policy is part of the retained resource binding: a replay + // under a different policy cannot adopt these containers. + let mut changed = prepared.resources.clone(); + changed.get_mut("container:web").unwrap().restart_policy = if admitted.is_some() { + None + } else { + Some(restart_policy::RestartPolicy::Always) + }; + assert!(same_resource_bindings( + &prepared.resources, + &prepared.resources + )); + assert!(!same_resource_bindings(&changed, &prepared.resources)); + // Receipt encoding omits an absent policy so existing receipts are unchanged. + let encoded = serde_json::to_value(&prepared.resources["container:check"]).unwrap(); + assert!(encoded.get("restart_policy").is_none()); + } +} +#[test] +fn compose_restart_policy_refuses_managed_delivery_and_host_dependencies() { + let fixture = Fixture::new(); + let home = Fixture::new(); + let candidate = Candidate::discover(&home.0).unwrap(); + let image = format!("sha256:{}", "a".repeat(64)); + let document = json!({"services":{"worker":{ + "image":image, "read_only":true, "network_mode":"none", "init":true, + "entrypoint":["/bin/worker"], "restart":"unless-stopped", + "environment":{"TOKEN":null} + }}}); + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let managed = BTreeMap::from([( + "worker".into(), + BTreeMap::from([("TOKEN".into(), "synthetic-restart-canary".into())]), + )]); + let scoped = project::inputs::compile_scoped( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + &managed, + ) + .unwrap(); + let error = config::prepare_delivery( + scoped.executable, + &BTreeMap::from([("worker".into(), Condition::Started)]), + &token(), + &token(), + None, + config::DeliveryOptions { + environment: true, + ..Default::default() + }, + ) + .err() + .unwrap(); + assert_eq!(error.code, "graph_restart_policy"); + assert!(!error.message.contains("synthetic-restart-canary")); + let document = json!({"services":{"worker":{ + "image":image, "read_only":true, "init":true, "entrypoint":["/bin/worker"], + "restart":"always", "networks":["private"], + "extra_hosts":["db.example.test:host-gateway"] + }},"networks":{"private":{"internal":true}}}); + state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); + let review = project::plan(&candidate, fixture.options()).unwrap(); + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ) + .unwrap(); + let error = config::prepare_delivery( + inputs, + &BTreeMap::from([("worker".into(), Condition::Started)]), + &token(), + &token(), + None, + config::DeliveryOptions { + dependency_hosts: true, + ..Default::default() + }, + ) + .err() + .unwrap(); + assert_eq!(error.code, "graph_restart_policy"); +} +#[test] +fn restarting_observation_requires_receipt_admission() { + let restarting = json!({"State":{"Running":true,"Restarting":true,"Status":"restarting", + "Pid":0,"Dead":false,"OOMKilled":true,"ExitCode":137}}); + assert_eq!(observation(&restarting).unwrap(), Observation::Restarting); + for (field, value) in [ + ("Running", json!(false)), + ("Pid", json!(9)), + ("Dead", json!(true)), + ] { + let mut changed = restarting.clone(); + changed["State"][field] = value; + assert!(observation(&changed).is_err(), "{field}"); + } + // OOM evidence of a previous incarnation is not a terminal Dead state while + // the engine is restarting; once restarts stop it is an ordinary failure. + let mut stopped = restarting.clone(); + stopped["State"]["Restarting"] = json!(false); + stopped["State"]["Running"] = json!(false); + stopped["State"]["Status"] = json!("exited"); + assert_eq!(observation(&stopped).unwrap(), Observation::Dead); +} +#[test] fn compose_pid_limit_is_opt_in_and_explicit_limits_remain_verified() { let fixture = Fixture::new(); let home = Fixture::new(); @@ -401,6 +591,7 @@ fn larger_graphs_fit_by_resources_and_receipts_keep_matching_bounds() { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind, ..template.clone() }, @@ -419,6 +610,7 @@ fn larger_graphs_fit_by_resources_and_receipts_keep_matching_bounds() { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind, ..template.clone() }, @@ -1883,6 +2075,7 @@ fn outbound_network_is_pinned_and_requires_explicit_pool_capability() { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Network, key: "default".into(), name: "owned-network".into(), @@ -2025,6 +2218,7 @@ fn endpoint_selects_service_primary_not_unrelated_first_graph_network() { cache: None, cache_provenance: None, disposable_caches: Vec::new(), + restart_policy: None, kind: Kind::Network, key: key.into(), name: name.into(), diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..6e194f10a 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1440,6 +1440,58 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "service-restart-policy", + module: "RestartPolicy", + states: 134, + invariant: "NoIncarnationAfterStop", + action: "DaemonRestart", + fields: [ + 'phase = "stopping"', + 'policy = "armed"', + 'engine = "running"', + "lateStart = TRUE", + ], + additionalControls: [ + { + name: "unproved-terminal", + negative: true, + invariant: "TerminalProof", + action: "ObserveTerminal", + fields: [ + 'phase = "stopped"', + 'engine = "running"', + "cancelled = TRUE", + "unsafeTerminal = TRUE", + ], + }, + { + name: "stale-generation", + negative: true, + invariant: "NoStaleIncarnation", + action: "Use", + fields: [ + 'phase = "serving"', + 'engine = "restarting"', + "bound = 1", + "staleUse = TRUE", + ], + }, + { + name: "completion-reachable", + negative: true, + invariant: "NeverRemoved", + action: "Remove", + fields: [ + 'phase = "removed"', + 'engine = "absent"', + 'policy = "no"', + "unsafeTerminal = FALSE", + "lateStart = FALSE", + ], + }, + ], + }, { name: "relay-fence", module: "RelayFence", diff --git a/src/backends/native-project-cleanup-command.ts b/src/backends/native-project-cleanup-command.ts index cc92a4273..d32600741 100644 --- a/src/backends/native-project-cleanup-command.ts +++ b/src/backends/native-project-cleanup-command.ts @@ -80,6 +80,9 @@ function containerState( case "created": case "absent": return value; + // A pending engine restart is live compute that cleanup must still stop. + case "restarting": + return "running"; default: throw refused(); } diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..0b90cc43d 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -345,7 +345,9 @@ export function prepareNativeProjectServices( (value.extra_hosts !== undefined && !hasHostDependencies) || value.ports !== undefined || value.logging !== undefined || - (value.restart !== undefined && value.restart !== "no") || + // Restart policies are admitted (or refused with a fixed reason) by the + // runtime graph, which owns stop/restart interaction; only the type is checked here. + (value.restart !== undefined && typeof value.restart !== "string") || !hasOnlyNativeSupportedLabels(value.labels) ) { throw refused(); diff --git a/tests/models/tla/service-restart-policy/README.md b/tests/models/tla/service-restart-policy/README.md new file mode 100644 index 000000000..0f5c1bf62 --- /dev/null +++ b/tests/models/tla/service-restart-policy/README.md @@ -0,0 +1,52 @@ +# Service restart policy + +`RestartPolicy.tla` models one owned Compose-file graph container whose admitted +`restart` policy is lowered to the guest engine's own `HostConfig.RestartPolicy` +(`packages/runtime-core/src/provider/graph/restart_policy.rs`). It is a contract +model of the stop/restart interaction, not an implementation-refinement proof. + +## Properties + +1. `NoStaleIncarnation`: a generation-bound use (endpoint, exec or relay target) + never acts on an incarnation other than the one it observed. An incarnation is + the container ID plus `StartedAt`; a restart keeps the ID and changes + `StartedAt`. +2. `NoIncarnationAfterStop`: after the owner's stop request, no new incarnation + starts, including across a guest engine restart or VM reboot. +3. `TerminalProof`: terminal stop evidence is recorded only while `Running` and + `Restarting` are both false. + +## Source mapping + +| Model action | Source boundary | +| --- | --- | +| `Exit` / `EngineRestart` | Engine policy behavior. A scheduled restart fires even after a policy update; only the stop's restart-manager cancel prevents it. The graph observes the pending state as `Observation::Restarting` (`provider/graph/mod.rs` `observation`), admitted only with a receipt policy (`probes::observe`). | +| `DaemonRestart` | Guest engine restore: an armed `always` container restarts even after a manual stop. | +| `Bind` / `Use` / `RefuseStale` | Existing generation checks: endpoint generations hash `StartedAt` (`endpoints.rs`), bridge relays bind target PID and start ticks (`bridges.rs`), completed and relay services compare `StartedAt` (`startup/runtime.rs`). Admission refuses restart policies for routed, HTTP-probed, host-dependency, managed-environment and dependency-cache services, whose guards cannot yet rebind per incarnation. | +| `Disarm` | `shutdown::disarm_owned` / `disarm_one`: `update RestartPolicy=no` on every receipt-admitted container, confirmed by a fresh ownership-verified inspection, before any stop request. A policy the receipt never admitted refuses. | +| `RequestStop` / `EngineStop` | `shutdown::stop_owned` batch stop; `shutdown::prepare` accepts the engine `restarting` state only for a receipt-admitted policy. | +| `ObserveTerminal` / `RefuseNonTerminal` | `shutdown::terminal` requires a fresh observation that is neither running nor restarting before evidence is written. | +| `Remove` | `cleanup_owned_fenced` deletes without force after terminal evidence. | + +## Controls + +The positive configuration exhausts 134 distinct states with `MaxIncarnation = 3` +and at most one engine restart. Each negative control removes one guard and must +fail its property with the exact same-state witness: + +- `negative.cfg` (no disarm): `DaemonRestart` after the stop request starts a new + incarnation (`NoIncarnationAfterStop`). +- `unproved-terminal.cfg` (stop transport success taken as proof): + `ObserveTerminal` records evidence while the engine still runs (`TerminalProof`). +- `stale-generation.cfg` (no generation recheck): `Use` acts on a restarting + container's old incarnation (`NoStaleIncarnation`). +- `completion-reachable.cfg`: cleanup reaches removal with every guard enforced. + +## Omissions + +The model does not cover transport timeouts or uncertain stop outcomes (existing +shutdown evidence and its tests own those), several services, the engine's backoff +timing or `on-failure` retry counting, durability of the receipt, or the real guest +engine. Real-engine behavior (a crashing `unless-stopped` service restarted by the +guest engine, a stop during restart backoff, and disarm before deletion) still +needs host evidence from a native foreground test on a prepared candidate VM. diff --git a/tests/models/tla/service-restart-policy/RestartPolicy.tla b/tests/models/tla/service-restart-policy/RestartPolicy.tla new file mode 100644 index 000000000..628a36566 --- /dev/null +++ b/tests/models/tla/service-restart-policy/RestartPolicy.tla @@ -0,0 +1,132 @@ +--------------------------- MODULE RestartPolicy --------------------------- +EXTENDS Naturals, TLC +\* One owned Compose-file graph container whose admitted restart policy is +\* lowered to the guest engine. The engine may crash, restart it, or be +\* restarted itself while the foreground owner serves, disarms, stops, proves +\* terminal state and removes it. See README.md for the source mapping. +CONSTANTS EnforceDisarm, EnforceTerminal, EnforceGeneration, MaxIncarnation +VARIABLES engine, policy, cancelled, incarnation, phase, bound, + daemonRestarts, lateStart, unsafeTerminal, staleUse +vars == <> + +\* Owner phases after `serving` are all after the durable cleanup intent. +StopRequested == phase \in {"stopping", "stopped", "removed"} +Present == engine # "absent" + +Init == /\ engine = "running" /\ policy = "armed" /\ cancelled = FALSE + /\ incarnation = 1 /\ phase = "serving" /\ bound = 0 + /\ daemonRestarts = 0 /\ lateStart = FALSE + /\ unsafeTerminal = FALSE /\ staleUse = FALSE + +\* ---- Engine ---------------------------------------------------------------- +\* An application exit. An armed, uncancelled policy may schedule a restart +\* (always/unless-stopped, or on-failure with retries left) or leave the +\* container exited (on-failure success or exhausted retries). +Exit == /\ engine = "running" + /\ engine' \in IF policy = "armed" /\ ~cancelled + THEN {"restarting", "exited"} ELSE {"exited"} + /\ UNCHANGED <> +\* A scheduled restart fires even if the policy was updated meanwhile; only a +\* stop (restart-manager cancel) prevents it. Each start is a new incarnation. +EngineRestart == /\ engine = "restarting" /\ ~cancelled + /\ incarnation < MaxIncarnation + /\ engine' = "running" /\ incarnation' = incarnation + 1 + /\ lateStart' = (lateStart \/ StopRequested) + /\ UNCHANGED <> +\* Guest engine restart or VM reboot. Restore restarts an armed container even +\* after a manual stop (Docker `always`); a disarmed one stays exited. +DaemonRestart == /\ Present /\ daemonRestarts = 0 + /\ daemonRestarts' = 1 /\ cancelled' = FALSE + /\ IF policy = "armed" /\ incarnation < MaxIncarnation + THEN /\ engine' = "running" + /\ incarnation' = incarnation + 1 + /\ lateStart' = (lateStart \/ StopRequested) + ELSE /\ engine' = "exited" + /\ UNCHANGED <> + /\ UNCHANGED <> + +\* ---- Owner ----------------------------------------------------------------- +\* A generation-bound consumer (endpoint, exec, relay target) binds to the +\* currently observed incarnation: container ID plus StartedAt. +Bind == /\ phase = "serving" /\ engine = "running" /\ bound # incarnation + /\ bound' = incarnation + /\ UNCHANGED <> +\* Use re-verifies the bound generation at the effect boundary. +Use == /\ phase = "serving" /\ bound # 0 + /\ (EnforceGeneration => (bound = incarnation /\ engine = "running")) + /\ staleUse' = (staleUse \/ bound # incarnation \/ engine # "running") + /\ UNCHANGED <> +\* A changed generation refuses; a fresh Bind is required. +RefuseStale == /\ phase = "serving" /\ bound # 0 + /\ (bound # incarnation \/ engine # "running") + /\ bound' = 0 + /\ UNCHANGED <> +\* Cleanup intent, then `update RestartPolicy=no` confirmed by reinspection. +\* Retries after a refused proof repeat the idempotent disarm. +Disarm == /\ phase \in {"serving", "retry"} + /\ policy' = IF EnforceDisarm THEN "no" ELSE policy + /\ phase' = "disarmed" /\ bound' = 0 + /\ UNCHANGED <> +\* The stop request cancels any pending restart (Docker restart-manager cancel) +\* and marks the container manually stopped. The process exit is separate. +RequestStop == /\ phase = "disarmed" + /\ phase' = "stopping" /\ cancelled' = TRUE + /\ UNCHANGED <> +EngineStop == /\ cancelled /\ engine \in {"running", "restarting"} + /\ engine' = "exited" + /\ UNCHANGED <> +\* Terminal evidence needs a fresh observation with Running and Restarting +\* both false; transport success of the stop is not proof. +Terminal == engine \in {"exited", "absent"} +ObserveTerminal == /\ phase = "stopping" + /\ (EnforceTerminal => Terminal) + /\ phase' = "stopped" + /\ unsafeTerminal' = (unsafeTerminal \/ ~Terminal) + /\ UNCHANGED <> +\* A non-terminal observation refuses and keeps evidence for a retry. +RefuseNonTerminal == /\ phase \in {"stopping", "stopped"} /\ ~Terminal + /\ phase' = "retry" + /\ UNCHANGED <> +\* Docker refuses to remove a running container without force; Hack never forces. +Remove == /\ phase = "stopped" /\ engine = "exited" + /\ engine' = "absent" /\ phase' = "removed" + /\ UNCHANGED <> + +Next == Exit \/ EngineRestart \/ DaemonRestart \/ Bind \/ Use \/ RefuseStale + \/ Disarm \/ RequestStop \/ EngineStop \/ ObserveTerminal + \/ RefuseNonTerminal \/ Remove +Spec == Init /\ [][Next]_vars + +TypeOK == /\ engine \in {"running", "restarting", "exited", "absent"} + /\ policy \in {"armed", "no"} /\ cancelled \in BOOLEAN + /\ incarnation \in 1..MaxIncarnation /\ bound \in 0..MaxIncarnation + /\ phase \in {"serving", "disarmed", "stopping", "stopped", "retry", + "removed"} + /\ daemonRestarts \in 0..1 /\ lateStart \in BOOLEAN + /\ unsafeTerminal \in BOOLEAN /\ staleUse \in BOOLEAN +\* 1. No generation-bound use targets a stale incarnation. +NoStaleIncarnation == ~staleUse +\* 2. After the stop request no new incarnation starts. +NoIncarnationAfterStop == ~lateStart +\* 3. Terminal evidence is recorded only while Running and Restarting are false. +TerminalProof == ~unsafeTerminal +Safe == NoStaleIncarnation /\ NoIncarnationAfterStop /\ TerminalProof +\* Control: cleanup completion is reachable in the positive configuration. +NeverRemoved == phase # "removed" +============================================================================= diff --git a/tests/models/tla/service-restart-policy/completion-reachable.cfg b/tests/models/tla/service-restart-policy/completion-reachable.cfg new file mode 100644 index 000000000..60b80652e --- /dev/null +++ b/tests/models/tla/service-restart-policy/completion-reachable.cfg @@ -0,0 +1,9 @@ +CONSTANTS EnforceDisarm = TRUE + EnforceTerminal = TRUE + EnforceGeneration = TRUE + MaxIncarnation = 3 +SPECIFICATION Spec +INVARIANTS TypeOK + Safe + NeverRemoved +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/service-restart-policy/negative.cfg b/tests/models/tla/service-restart-policy/negative.cfg new file mode 100644 index 000000000..75fd57345 --- /dev/null +++ b/tests/models/tla/service-restart-policy/negative.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceDisarm = FALSE + EnforceTerminal = TRUE + EnforceGeneration = TRUE + MaxIncarnation = 3 +SPECIFICATION Spec +INVARIANTS TypeOK + NoIncarnationAfterStop +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/service-restart-policy/positive.cfg b/tests/models/tla/service-restart-policy/positive.cfg new file mode 100644 index 000000000..07920dde4 --- /dev/null +++ b/tests/models/tla/service-restart-policy/positive.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceDisarm = TRUE + EnforceTerminal = TRUE + EnforceGeneration = TRUE + MaxIncarnation = 3 +SPECIFICATION Spec +INVARIANTS TypeOK + Safe +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/service-restart-policy/stale-generation.cfg b/tests/models/tla/service-restart-policy/stale-generation.cfg new file mode 100644 index 000000000..f8ed01fac --- /dev/null +++ b/tests/models/tla/service-restart-policy/stale-generation.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceDisarm = TRUE + EnforceTerminal = TRUE + EnforceGeneration = FALSE + MaxIncarnation = 3 +SPECIFICATION Spec +INVARIANTS TypeOK + NoStaleIncarnation +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/service-restart-policy/unproved-terminal.cfg b/tests/models/tla/service-restart-policy/unproved-terminal.cfg new file mode 100644 index 000000000..d05e52c2a --- /dev/null +++ b/tests/models/tla/service-restart-policy/unproved-terminal.cfg @@ -0,0 +1,8 @@ +CONSTANTS EnforceDisarm = TRUE + EnforceTerminal = FALSE + EnforceGeneration = TRUE + MaxIncarnation = 3 +SPECIFICATION Spec +INVARIANTS TypeOK + TerminalProof +CHECK_DEADLOCK FALSE diff --git a/tests/native-project-cleanup-command.test.ts b/tests/native-project-cleanup-command.test.ts index b1c7d7525..da166ef1e 100644 --- a/tests/native-project-cleanup-command.test.ts +++ b/tests/native-project-cleanup-command.test.ts @@ -93,6 +93,21 @@ test("cleanup doctor inspects one mapped branch without requesting recovery and expect(JSON.stringify(result)).not.toContain(opts.scope.projectRoot); }); +test("a pending engine restart counts as running compute that cleanup must stop", async () => { + const opts = await fixture(); + const restarting = snapshot(); + Object.assign(restarting.observations, { + "container:ws": { state: "restarting" }, + }); + const result = await inspectNativeProjectCleanup({ + ...opts, + invoke: async () => restarting, + }); + expect(result).toMatchObject({ + counts: { running: 1, exited: 1, created: 0, absent: 0 }, + }); +}); + test("legacy hints and missing volumes report observations without inventing recovery authority", async () => { const opts = await fixture(); const legacy = snapshot(); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..e82fdceac 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -491,7 +491,7 @@ test("foreground partial shutdown is reported without selecting failed-start rec test.each([ { build: "." }, { ports: ["8080:80"] }, - { restart: "always" }, + { restart: 1 }, ])("an unsupported service setting %j is a coded configuration refusal", (setting) => { const refusal = (() => { try { @@ -511,6 +511,21 @@ test.each([ }); }); +test.each([ + "no", + "always", + "unless-stopped", + "on-failure", + "on-failure:3", +])("restart policy %s is left to runtime graph admission", (restart) => { + const services = prepareNativeProjectServices({ + normalizedComposeJson: JSON.stringify({ + services: { worker: { image: "busybox", restart } }, + }), + } as NativeProjectInput); + expect(services.worker?.restart).toBe(restart); +}); + test("a canceled start reports cancellation, not the configuration refusal", async () => { const { opts, events } = await fixture(); const refusal = await startNativeProject({ From 3de5fd1cef514387cd60a38cf06a684f64d35cc1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:05:47 -0500 Subject: [PATCH 052/138] fix(native): report foreground progress, busy ps and input causes Foreground native image `up` now prints its startup steps, one ready line after the graph is ready and the run mapping is published, and the stop sequence after Ctrl-C, as attached Compose `up` does. `ps` (and the `logs` pre-read) retry brief provider lease contention. A lease held for the whole bounded read, as a starting foreground `up` holds it, is an ordinary `runtime_busy` state (exit 0) instead of a `provider_busy` failure. Exited containers show their exit code. Native input refusals name their cause and, where known, the service and Compose field. A missing project env key for secret managed env reports that (E_CONFIG_INVALID) instead of the catch-all invalid-Compose refusal. Refs HACK-1915 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 28 ++++- src/backends/native-project-input.ts | 123 +++++++++++++++++----- src/backends/native-project-inspect.ts | 20 +++- src/backends/native-project-observe.ts | 35 +++++-- src/backends/native-project-start.ts | 49 +++++++++ src/commands/project.ts | 54 +++++++++- src/lib/project-env-config.ts | 12 ++- tests/native-project-input.test.ts | 62 ++++++++++++ tests/native-project-observe.test.ts | 135 +++++++++++++++++++++++++ tests/native-project-start.test.ts | 68 +++++++++++++ 10 files changed, 539 insertions(+), 47 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..6f5fda312 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -262,6 +262,16 @@ Native `up` and `restart` also stay in the foreground in every project: keep it open, use `hack ps` and `hack logs` from another terminal, and press Ctrl-C to stop it. For detached startup, use the Compose backend. +Foreground image-project `up` prints each startup step (input, runtime, images, +services) and one ready line, `Project is ready (SERVICES)`, after the graph is +ready and its run mapping is published. Ctrl-C prints the stop sequence and +`Native project stopped.` once owned cleanup is confirmed. Until the ready line, +startup holds the runtime's exclusive provider lease, so graph admission cannot +interleave with another operation. A `hack ps` from another terminal during that +window waits briefly, then reports that the runtime is busy (`runtime_busy`, +exit 0) instead of failing. After the ready line, `hack ps` and `hack logs` run +alongside the foreground command. + ## Manual candidate upgrade and rollback Manual candidate bundles are selected by their full path. The separate @@ -744,15 +754,20 @@ Explicit `HACK_RUNTIME_BACKEND=native` with absolute `HACK_NATIVE_BINARY` and `hack logs SERVICE --no-follow`. They verify the project/branch run mapping against the current native graph. Logs do not support following or Loki options. A project with no admitted mapping reports not started. `hack ps` keeps container -observations separate from the last graph receipt. For a ready foreground graph, +observations separate from the last graph receipt; an exited container's state +shows its exit code, as in Compose (`exited (1)`). For a ready foreground graph, it also checks the live owner: `owner_unconfirmed` means the owner could not be authenticated, while `runtime_degraded` means its runtime check failed. A stopped candidate runtime is an ordinary state for `ps`: with a mapping present it exits 0 and reports `runtime_not_running` (no phase, no service rows), and text output prints "Native runtime is not running; no services are running." +A provider lease held for the whole bounded read, for example by a starting +foreground `up`, is also an ordinary state: `ps` exits 0 and reports +`runtime_busy` (no phase, no service rows). Reads retry brief lease contention. `hack ps --json` prints the standard `{ok: true, data}` envelope; `data` holds -`backend: "native"`, `status` (`not_started`, `runtime_not_running`, `observed`, -`owner_unconfirmed` or `runtime_degraded`), `run`, `phase` and `items`. Bounded +`backend: "native"`, `status` (`not_started`, `runtime_not_running`, +`runtime_busy`, `observed`, `owner_unconfirmed` or `runtime_degraded`), `run`, +`phase` and `items`. Bounded logs refuse a stopped runtime with `E_DOCKER_UNAVAILABLE` and exit 1. Native `exec` and `run` refresh admitted host dependencies once before command selection and managed environment preparation. They proceed only with fresh ownership and runtime proof, even if containers still appear running. @@ -779,7 +794,12 @@ form: run `hack up` in the foreground in its own terminal and use `hack ps` and `hack logs` from another. Routed services and host dependencies require the explicit native selections described below. Unsupported Compose or environment input (for example a service `env_file`, or a project `.env` without managed env) and -unreviewed route labels refuse with `E_NATIVE_PROJECT_UNSUPPORTED`; invalid branch +unreviewed route labels refuse with `E_NATIVE_PROJECT_UNSUPPORTED`. The input +refusal names its cause and, where known, the service and Compose field (for +example `service api field env_file`), never values or file contents. Secret +managed env without a project env key refuses with `E_CONFIG_INVALID` and names the +recovery actions; other unreadable managed env refuses with `E_CONFIG_INVALID` +without layer excerpts. Invalid branch hosts, project adaptations, AWS selections and changed fresh-exec environments refuse with `E_CONFIG_INVALID`, as do an unbounded `--profile` selection, a malformed `HACK_NATIVE_DEPENDENCIES` listener selection and an occupied HTTPS port. A runtime diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index ab83234d9..88ae79fee 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { + PROJECT_ENV_SECRET_KEY_ENV, + ProjectEnvKeyMissingError, resolveProjectEnvConfig, selectProjectEnvValuesForExecutionTarget, } from "../lib/project-env-config.ts"; @@ -26,23 +28,66 @@ export type NativeProjectInput = { readonly serviceNames: readonly string[]; }; -function refused(): Error { - return new HackCliError({ - code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: - "Native project input refused: unsupported or invalid Compose/environment configuration; values omitted", +const SERVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; + +/** + * Closed input refusal naming the cause and, where known, the service and Compose + * field. Service names print only when they are plain Compose identifiers; values, + * file contents and parser excerpts never do. + */ +class NativeProjectInputRefusal extends HackCliError {} + +function refused( + cause = "unsupported or invalid Compose/environment configuration", + code: + | "E_NATIVE_PROJECT_UNSUPPORTED" + | "E_CONFIG_INVALID" = "E_NATIVE_PROJECT_UNSUPPORTED" +): Error { + return new NativeProjectInputRefusal({ + code, + message: `Native project input refused: ${cause}; values omitted`, }); } +function serviceLabel(name: string): string { + return SERVICE_NAME.test(name) ? `service ${name}` : "a service"; +} + +function composeFileRefused(): Error { + return refused( + "the Compose file must be a regular, non-empty file of at most 256 KiB that does not change while it is read" + ); +} + +/** + * Env resolution refusals keep their cause without values. A missing project + * secret key names the next action; any other failure stays generic because env + * layer diagnostics may quote file contents. + */ +function environmentRefused(error: unknown): Error { + if (error instanceof ProjectEnvKeyMissingError) { + return refused( + `Hack managed env has secret entries but the project env key is missing. Run "hack env add --secret ..." to generate it, restore the key file, or set ${PROJECT_ENV_SECRET_KEY_ENV}`, + "E_CONFIG_INVALID" + ); + } + return refused( + 'Hack managed env could not be read or decrypted. Run "hack env list" to inspect it', + "E_CONFIG_INVALID" + ); +} + async function readCompose(path: string): Promise { const file = await open( path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW - ); + ).catch(() => { + throw composeFileRefused(); + }); try { const before = await file.stat(); if (!before.isFile() || before.size === 0 || before.size > LIMIT) { - throw refused(); + throw composeFileRefused(); } const buffer = Buffer.alloc(LIMIT + 1); const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); @@ -53,7 +98,7 @@ async function readCompose(path: string): Promise { before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ) { - throw refused(); + throw composeFileRefused(); } return buffer.subarray(0, bytesRead); } finally { @@ -61,25 +106,29 @@ async function readCompose(path: string): Promise { } } -function environment(value: unknown): Record { +function environment(value: unknown, service: string): Record { if (value === undefined) { return {}; } if (isRecord(value)) { return { ...value }; } + const invalid = () => + refused( + `${serviceLabel(service)} field environment must be a mapping or a list of unique NAME or NAME=value entries` + ); if (!Array.isArray(value)) { - throw refused(); + throw invalid(); } const result: Record = {}; for (const entry of value) { if (typeof entry !== "string") { - throw refused(); + throw invalid(); } const equal = entry.indexOf("="); const key = equal < 0 ? entry : entry.slice(0, equal); if (!KEY.test(key) || Object.hasOwn(result, key)) { - throw refused(); + throw invalid(); } result[key] = equal < 0 ? null : entry.slice(equal + 1); } @@ -106,7 +155,7 @@ function normalizeManagedEnvironment( for (const name of Object.keys(services)) { const service = services[name]; if (!isRecord(service)) { - throw refused(); + throw refused(`${serviceLabel(name)} must be a mapping`); } const managed = resolved ? selectProjectEnvValuesForExecutionTarget({ @@ -118,10 +167,13 @@ function normalizeManagedEnvironment( if (Object.keys(managed).length === 0) { continue; } - const normalized = environment(service.environment); + const normalized = environment(service.environment, name); for (const key of Object.keys(managed)) { if (!KEY.test(key)) { - throw refused(); + throw refused( + `Hack managed env for ${serviceLabel(name)} has a variable name that is not a valid environment name`, + "E_CONFIG_INVALID" + ); } normalized[key] = null; } @@ -144,25 +196,43 @@ export async function prepareNativeProjectInput(opts: { }): Promise { try { const bytes = await readCompose(opts.composeFile); - const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - const compose: unknown = Bun.YAML.parse(text); + let compose: unknown; + try { + compose = Bun.YAML.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes) + ); + } catch { + throw refused("the Compose file is not valid UTF-8 YAML"); + } if (!(isRecord(compose) && isRecord(compose.services))) { - throw refused(); + throw refused("the Compose file has no services mapping"); } const serviceNames = Object.keys(compose.services); // Refuse before resolving/decrypting any values. Never read env_file contents. - for (const service of Object.values(compose.services)) { - if (!isRecord(service) || Object.hasOwn(service, "env_file")) { - throw refused(); + for (const [name, service] of Object.entries(compose.services)) { + if (!isRecord(service)) { + throw refused(`${serviceLabel(name)} must be a mapping`); + } + if (Object.hasOwn(service, "env_file")) { + throw refused( + `${serviceLabel(name)} field env_file is not supported by native up; move its variables into Hack managed env with hack env set --service` + ); } } - const resolved = await resolveProjectEnvConfig({ ...opts, serviceNames }); + const resolved = await resolveProjectEnvConfig({ + ...opts, + serviceNames, + }).catch((error: unknown) => { + throw environmentRefused(error); + }); if ( !resolved && ((await exists(join(opts.projectDir, ".env"))) || (await exists(join(opts.projectRoot, ".env")))) ) { - throw refused(); + throw refused( + "a project .env file is present without Hack managed env; native up does not read .env files. Migrate it with hack doctor --migrate-env-config or remove it" + ); } const managedEnvironment = normalizeManagedEnvironment( compose.services, @@ -170,7 +240,7 @@ export async function prepareNativeProjectInput(opts: { ); const normalizedComposeJson = JSON.stringify(compose); if (Buffer.byteLength(normalizedComposeJson) > LIMIT) { - throw refused(); + throw refused("the normalized Compose project exceeds 256 KiB"); } return { originalSha256: createHash("sha256").update(bytes).digest("hex"), @@ -187,7 +257,8 @@ export async function prepareNativeProjectInput(opts: { environmentFiles: resolved?.files ?? [], serviceNames, }; - } catch { - throw refused(); + } catch (error: unknown) { + // Only this module's closed refusals carry a cause; anything else stays generic. + throw error instanceof NativeProjectInputRefusal ? error : refused(); } } diff --git a/src/backends/native-project-inspect.ts b/src/backends/native-project-inspect.ts index a7b9e74cb..b7b150a36 100644 --- a/src/backends/native-project-inspect.ts +++ b/src/backends/native-project-inspect.ts @@ -1,10 +1,24 @@ import { invokeNativeRuntime, + NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; const BUSY = "Native runtime request failed (provider_busy); inspect owned state before retrying."; + +/** + * True when a native request was refused only because another operation holds + * the provider lease, such as a starting foreground `up`. Nothing was admitted. + */ +export function isNativeProviderBusy(error: unknown): boolean { + return ( + (error instanceof NativeRuntimeRequestError && + error.nativeCode === "provider_busy" && + error.nativeCauseCode === undefined) || + (error instanceof Error && error.message === BUSY) + ); +} /** Concurrent cleanup observers share a provider lease. Retry only read-only lease contention. */ export async function inspectNativeProjectGraph(opts: { readonly runtime: NativeRuntimeSelection; @@ -22,11 +36,7 @@ export async function inspectNativeProjectGraph(opts: { timeoutMs: 30_000, }); } catch (error) { - if ( - !(error instanceof Error) || - error.message !== BUSY || - performance.now() >= deadline - ) { + if (!isNativeProviderBusy(error) || performance.now() >= deadline) { throw error; } await Bun.sleep(50); diff --git a/src/backends/native-project-observe.ts b/src/backends/native-project-observe.ts index aaa1d91b3..38e275da1 100644 --- a/src/backends/native-project-observe.ts +++ b/src/backends/native-project-observe.ts @@ -1,5 +1,9 @@ import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { + inspectNativeProjectGraph, + isNativeProviderBusy, +} from "./native-project-inspect.ts"; import { loadNativeProjectRun, type NativeProjectRun, @@ -42,11 +46,14 @@ function runtimeNotRunning(): HackCliError { /** * Native ps result. `runtime_not_running` means the project has an owned run * mapping but the candidate runtime is stopped: nothing was observed, so - * `phase` is null and `items` is empty. + * `phase` is null and `items` is empty. `runtime_busy` means another operation + * held the exclusive provider lease for the whole bounded read (a starting + * foreground `up` holds it until its graph is ready): nothing was observed either. */ export type NativeProjectPsStatus = | "not_started" | "runtime_not_running" + | "runtime_busy" | "observed" | "owner_unconfirmed" | "runtime_degraded"; @@ -128,17 +135,30 @@ function containerItem( exitCode: typeof observation.code === "number" ? observation.code : null, }; } +/** Read-only inspection retries brief provider lease contention, never effects. */ async function inspect(opts: Options, run: NativeProjectRun) { return inspected( - await (opts.invoke ?? invokeNativeRuntime)({ + await inspectNativeProjectGraph({ runtime: opts.runtime, - cwd: opts.scope.projectRoot, - args: ["graph", "inspect", "--run-id", run.run, "--json"], - timeoutMs: 30_000, + projectRoot: opts.scope.projectRoot, + run: run.run, + invoke: opts.invoke, }), run ); } +/** Ordinary states in which nothing was observed; every other refusal rethrows. */ +function unobservedStatus( + error: unknown +): "runtime_not_running" | "runtime_busy" { + if (isNativeRuntimeNotRunning(error)) { + return "runtime_not_running"; + } + if (isNativeProviderBusy(error)) { + return "runtime_busy"; + } + throw error; +} /** * Historical receipt phase is separated from current per-service observations. * A stopped candidate runtime is an ordinary state for this read: it reports @@ -161,12 +181,9 @@ export async function nativeProjectPs( try { snapshot = await inspect(opts, run); } catch (error: unknown) { - if (!isNativeRuntimeNotRunning(error)) { - throw error; - } return { backend: "native", - status: "runtime_not_running", + status: unobservedStatus(error), run: run.run, phase: null, items: [], diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..e40645462 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -890,6 +890,40 @@ async function requireAcknowledgedRetainedFinalization(opts: { } } /** Explicit unfiltered project sharing; foreground only. Native refusal never falls back to Compose. */ +/** + * Value-free foreground progress for the terminal. `services` are the reviewed + * service names. Observation only: a throwing observer never changes startup. + */ +export type NativeStartupProgress = + | { readonly phase: "input" | "runtime" | "images" | "stopping" | "stopped" } + | { + readonly phase: "services" | "ready"; + readonly services: readonly string[]; + }; + +function progressReporter( + observe: ((event: NativeStartupProgress) => void) | undefined +): (event: NativeStartupProgress) => void { + return (event) => { + try { + observe?.(event); + } catch { + // Progress is advisory terminal output. + } + }; +} + +/** After confirmed cleanup, an interrupted foreground graph has stopped. */ +function reportStopped( + progress: (event: NativeStartupProgress) => void, + signal: AbortSignal, + infrastructureFailure: Error | undefined +): void { + if (signal.aborted && !infrastructureFailure) { + progress({ phase: "stopped" }); + } +} + export async function startNativeProject(opts: { readonly runtime: NativeRuntimeSelection; readonly scope: NativeProjectRunScope; @@ -920,8 +954,11 @@ export async function startNativeProject(opts: { readonly fingerprint: ComposeEnvFingerprintRecord; }) => Promise; readonly signal?: AbortSignal; + /** Foreground progress and the ready line; see {@link NativeStartupProgress}. */ + readonly onProgress?: (event: NativeStartupProgress) => void; readonly dependencies?: Partial; }): Promise { + const progress = progressReporter(opts.onProgress); const requestedProfiles = normalizeNativeProfiles(opts.profiles); requireActiveStartup(opts.signal); const allowedHosts = validateNativeAllowedHosts(opts.allowedHosts); @@ -967,6 +1004,7 @@ export async function startNativeProject(opts: { requireActiveStartup(opts.signal); } const profiles = selection.profiles; + progress({ phase: "input" }); await preflightNativeProjectSource({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, @@ -1045,6 +1083,11 @@ export async function startNativeProject(opts: { interruptedExit = 143; cancel(); }; + controller.signal.addEventListener( + "abort", + () => progress({ phase: "stopping" }), + { once: true } + ); process.on("SIGINT", cancel); process.on("SIGTERM", terminate); opts.signal?.addEventListener("abort", cancel, { once: true }); @@ -1096,6 +1139,7 @@ export async function startNativeProject(opts: { load: deps.load, }); requireActiveStartup(controller.signal); + progress({ phase: "runtime" }); await deps.invoke({ runtime: opts.runtime, cwd: opts.scope.projectRoot, @@ -1172,6 +1216,7 @@ export async function startNativeProject(opts: { restore, invoke: deps.invoke, }); + progress({ phase: "images" }); await pinNativeProjectImages({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, @@ -1271,6 +1316,8 @@ export async function startNativeProject(opts: { let code = 1; let serveFailure: unknown; let nativeExitCode: string | undefined; + const services = Object.keys(specs).sort(); + progress({ phase: "services", services }); try { code = await deps.serve({ runtime: opts.runtime, @@ -1400,6 +1447,7 @@ export async function startNativeProject(opts: { } await hooks?.ready?.(); await opts.onReady?.(); + progress({ phase: "ready", services }); }, }); } catch (error) { @@ -1436,6 +1484,7 @@ export async function startNativeProject(opts: { scope: opts.scope, remove: deps.remove, }); + reportStopped(progress, controller.signal, httpsFailure); return foregroundExitCode({ aborted: controller.signal.aborted, interruptedExit, diff --git a/src/commands/project.ts b/src/commands/project.ts index 0e641d9dd..647f9ddec 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -71,6 +71,7 @@ import { } from "../backends/native-project-restart-preflight.ts"; import { preflightNativeProjectSource } from "../backends/native-project-source-preflight.ts"; import { + type NativeStartupProgress, parseNativeHttpsSelection, startNativeProject, } from "../backends/native-project-start.ts"; @@ -5718,6 +5719,44 @@ async function runRemoteLifecycleCommand(opts: { }); } +/** + * Foreground native progress, matching Compose's attached `up`: steps while the + * graph starts, one ready line, then the stop sequence after Ctrl-C. + */ +function reportNativeStartupProgress(event: NativeStartupProgress): void { + switch (event.phase) { + case "input": + logger.step({ message: "Preparing native project input…" }); + return; + case "runtime": + logger.step({ message: "Starting the native runtime…" }); + return; + case "images": + logger.step({ message: "Acquiring service images…" }); + return; + case "services": + logger.step({ + message: `Starting services: ${event.services.join(", ")}…`, + }); + return; + case "ready": + logger.success({ + message: `Project is ready (${event.services.join(", ")}). Use hack ps and hack logs from another terminal; press Ctrl-C to stop.`, + }); + return; + case "stopping": + logger.step({ + message: "Stopping the native project; waiting for owned cleanup…", + }); + return; + case "stopped": + logger.info({ message: "Native project stopped." }); + return; + default: + return; + } +} + async function handleNativeUp({ ctx, args, @@ -5813,6 +5852,7 @@ async function handleNativeUp({ await envFingerprints.record( createNativeEnvFingerprintEntry({ owner: "image", run, fingerprint }) ), + onProgress: reportNativeStartupProgress, before: async (input) => { const lifecycle = await runLifecycleUpBeforeAndProcesses({ title: "Lifecycle (native up before)", @@ -8424,6 +8464,13 @@ async function maybeManageProjectLogsAfterDown(opts: { }); } +/** Compose-style state cell: an exited container shows its exit code. */ +function nativePsState(item: NativeProjectPsResult["items"][number]): string { + return item.state === "exited" && item.exitCode !== null + ? `exited (${item.exitCode})` + : item.state; +} + /** * Native ps output. `--json` prints the standard `{ok: true, data}` envelope * whose `data` is the native ps result, matching authored native ps. @@ -8442,6 +8489,11 @@ async function displayNativePs( logger.info({ message: "Native runtime is not running; no services are running.", }); + } else if (result.status === "runtime_busy") { + logger.info({ + message: + "Native runtime is busy with another operation, such as a starting hack up; service state is available once it finishes or reports ready.", + }); } else if (result.status === "owner_unconfirmed") { logger.warn({ message: @@ -8458,7 +8510,7 @@ async function displayNativePs( rows: result.items.map((item) => [ item.service, item.container ?? "", - item.state, + nativePsState(item), item.health ?? "", ]), }); diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 8b7b58af5..58f64d30d 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -146,7 +146,7 @@ const PROJECT_ENV_IV_BYTES = 12; const PROJECT_ENV_TAG_BYTES = 16; /** Decoded ciphertext bound; a value never exceeds its 1 MiB env layer. */ const PROJECT_ENV_CIPHERTEXT_LIMIT = NATIVE_CONFIG_INPUT_LIMIT; -const PROJECT_ENV_SECRET_KEY_ENV = "HACK_ENV_SECRET_KEY"; +export const PROJECT_ENV_SECRET_KEY_ENV = "HACK_ENV_SECRET_KEY"; export const PROJECT_ENV_HOST_SCOPE = "host" as const; type ProjectEnvScalar = string | number | boolean; @@ -4116,13 +4116,21 @@ async function resolveProjectEnvKey(opts: { const searchedPaths = [keyPath, sharedKeyPath] .filter((value): value is string => typeof value === "string") .join(" or "); - throw new Error( + throw new ProjectEnvKeyMissingError( `Missing project env key at ${searchedPaths}. Run "hack env add --secret ..." to generate it, restore the key file, or set ${PROJECT_ENV_SECRET_KEY_ENV}.` ); } return null; } +/** + * Secret env entries exist but no project env key was found. The message names + * the searched key paths and the next action; it never carries key or env values. + */ +export class ProjectEnvKeyMissingError extends Error { + override readonly name = "ProjectEnvKeyMissingError"; +} + /** Native reads preserve this owner's local/shared/primary/environment key priority without mutation. */ async function resolveNativeProjectEnvKey(opts: { readonly projectRoot: string; diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index 8c04394d2..3a0b6c494 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { prepareNativeProjectInput } from "../src/backends/native-project-input.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; import { restoreEnv } from "./helpers/env.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -207,3 +208,64 @@ test("linked worktree inherits primary local environment and observes checkout o restoreEnv("HACK_EXECUTION_MODE", oldMode); } }); +test("input refusals name the cause, service and field without values", async () => { + const opts = await fixture({ + services: { api: { env_file: "private-source", image: "bun:1" } }, + }); + await expect(prepareNativeProjectInput(opts)).rejects.toMatchObject({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: expect.stringContaining("service api field env_file"), + }); + await writeFile( + opts.composeFile, + JSON.stringify({ + services: { + api: { image: "bun:1", environment: ["OK=1", "OK=private-dup"] }, + }, + }) + ); + await env(opts.projectDir, "default", { global: { TOKEN: "managed" } }); + const duplicate = await prepareNativeProjectInput(opts).catch( + (error: unknown) => error + ); + expect(duplicate).toBeInstanceOf(HackCliError); + expect((duplicate as HackCliError).code).toBe("E_NATIVE_PROJECT_UNSUPPORTED"); + expect((duplicate as HackCliError).message).toContain( + "service api field environment" + ); + expect((duplicate as HackCliError).message).not.toContain("private-dup"); + await writeFile(opts.composeFile, "services: [unclosed"); + await expect(prepareNativeProjectInput(opts)).rejects.toMatchObject({ + message: expect.stringContaining("not valid UTF-8 YAML"), + }); + await writeFile(opts.composeFile, '{"name":"no-services"}'); + await expect(prepareNativeProjectInput(opts)).rejects.toMatchObject({ + message: expect.stringContaining("no services mapping"), + }); + await rm(opts.composeFile); + await expect(prepareNativeProjectInput(opts)).rejects.toMatchObject({ + message: expect.stringContaining("Compose file must be a regular"), + }); +}); +test("a missing project env key is reported as such, not as invalid Compose", async () => { + const oldKey = process.env.HACK_ENV_SECRET_KEY; + restoreEnv("HACK_ENV_SECRET_KEY", undefined); + try { + const opts = await fixture({ services: { api: { image: "bun:1" } } }); + await env(opts.projectDir, "default", { + global: { TOKEN: { secure: "synthetic-ciphertext-canary" } }, + }); + const error = await prepareNativeProjectInput(opts).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(HackCliError); + const { code, message } = error as HackCliError; + expect(code).toBe("E_CONFIG_INVALID"); + expect(message).toContain("project env key is missing"); + expect(message).toContain("HACK_ENV_SECRET_KEY"); + expect(message).not.toContain("unsupported or invalid Compose"); + expect(message).not.toContain("synthetic-ciphertext-canary"); + } finally { + restoreEnv("HACK_ENV_SECRET_KEY", oldKey); + } +}); diff --git a/tests/native-project-observe.test.ts b/tests/native-project-observe.test.ts index df71d9e98..72ac736c1 100644 --- a/tests/native-project-observe.test.ts +++ b/tests/native-project-observe.test.ts @@ -510,3 +510,138 @@ test("logs refuse a stopped runtime with a closed, coded error", async () => { }) ).rejects.toThrow("(engine_protocol)"); }); +test("ps reports a busy runtime after bounded lease retries instead of failing", async () => { + const opts = await fixture(); + let calls = 0; + const started = performance.now(); + expect( + await nativeProjectPs({ + ...opts, + invoke: () => { + calls++; + return Promise.reject(nativeRefusal("provider_busy")); + }, + }) + ).toEqual({ + backend: "native", + status: "runtime_busy", + run: run.run, + phase: null, + items: [], + }); + // Read-only inspection retried lease contention for its bounded budget only. + expect(calls).toBeGreaterThan(1); + expect(performance.now() - started).toBeLessThan(10_000); +}); +test("ps and logs observe after brief lease contention without failing", async () => { + const opts = await fixture(); + let busy = 1; + const invoke = async (request: { args: readonly string[] }) => { + if (request.args[1] === "inspect" && busy-- > 0) { + throw nativeRefusal("provider_busy"); + } + if (request.args[1] === "inspect") { + return snapshot(); + } + return { container: id, stdout: "out\n", stderr: "", truncated: false }; + }; + expect(await nativeProjectPs({ ...opts, invoke })).toMatchObject({ + status: "observed", + items: [{ service: "web", state: "running" }], + }); + busy = 1; + expect( + await nativeProjectLogs({ + ...opts, + invoke, + service: "web", + follow: false, + tail: 20, + }) + ).toMatchObject({ stdout: "out\n" }); +}); +test("plain native ps explains a busy runtime and shows exit codes like Compose", async () => { + const { writeFile, chmod } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + const opts = await fixture(); + await writeFile( + join(opts.scope.projectDir, "hack.config.json"), + '{"name":"native-observe"}' + ); + await writeFile( + join(opts.scope.projectDir, "docker-compose.yml"), + "services:\n web:\n image: example:public\n" + ); + const exited = snapshot(); + exited.observations["container:web"] = { + state: "exited", + code: 3, + } as never; + const binary = join(opts.scope.projectRoot, "fake-native"); + const marker = join(opts.scope.projectRoot, "busy"); + await writeFile( + binary, + `#!/bin/sh +if [ -e '${marker}' ]; then + echo '{"code":"provider_busy","message":"Another candidate operation holds the provider lock."}' >&2 + exit 2 +fi +if [ "$3" = graph ] && [ "$4" = inspect ]; then + cat <<'JSON' +${JSON.stringify(exited)} +JSON + exit 0 +fi +exit 2 +` + ); + await chmod(binary, 0o700); + const ps = async () => { + const child = Bun.spawn( + [ + process.execPath, + resolve(import.meta.dir, "../index.ts"), + "ps", + "--path", + opts.scope.projectRoot, + ], + { + env: { + PATH: "/usr/bin:/bin", + HOME: opts.scope.projectRoot, + HACK_HOME: join(opts.scope.projectRoot, "isolated-global"), + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: binary, + HACK_NATIVE_HOME: opts.scope.nativeHome, + HACK_LOGGER: "console", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + }, + stdout: "pipe", + stderr: "pipe", + } + ); + const timer = setTimeout(() => child.kill("SIGKILL"), 20_000); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, code }; + } finally { + clearTimeout(timer); + child.kill("SIGKILL"); + await child.exited; + } + }; + const observed = await ps(); + expect(observed.code).toBe(0); + expect(observed.stdout).toContain("exited (3)"); + await writeFile(marker, ""); + const busy = await ps(); + expect(busy.code).toBe(0); + expect(busy.stderr).toContain( + "Native runtime is busy with another operation" + ); + expect(busy.stderr).not.toContain("provider_busy"); +}, 60_000); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..89d9cea2b 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -753,6 +753,74 @@ test("cancellation after readiness retains mapping when data is preserved", asyn expect(events.at(-1)).toBe("cleanup"); }); +test("foreground reports progress, one ready line after publication, and the stop sequence", async () => { + const { opts, events } = await fixture(); + const controller = new AbortController(); + const original = opts.dependencies.serve; + opts.dependencies.serve = async (args) => { + await original?.(args); + controller.abort(); + throw new Error("cancelled after ready"); + }; + const progress: string[] = []; + expect( + await startNativeProject({ + ...opts, + signal: controller.signal, + onProgress: (event) => { + events.push(`progress:${event.phase}`); + progress.push( + "services" in event + ? `${event.phase}:${event.services.join(",")}` + : event.phase + ); + }, + }) + ).toBe(130); + expect(progress).toEqual([ + "input", + "runtime", + "images", + "services:web", + "ready:web", + "stopping", + "stopped", + ]); + // The ready line follows mapping publication and the lifecycle ready hook. + expect(events.indexOf("progress:ready")).toBeGreaterThan( + events.indexOf("save") + ); + expect(events.indexOf("progress:ready")).toBeGreaterThan( + events.indexOf("ready") + ); +}); +test("a throwing progress observer never changes foreground startup", async () => { + const { opts } = await fixture(); + expect( + await startNativeProject({ + ...opts, + onProgress: () => { + throw new Error("observer failure"); + }, + }) + ).toBe(0); +}); +test("a startup failure before readiness prints no ready or stopped line", async () => { + const { opts } = await fixture(); + opts.dependencies.serve = async () => { + throw new Error("startup failed"); + }; + const progress: string[] = []; + await expect( + startNativeProject({ + ...opts, + onProgress: (event) => progress.push(event.phase), + }) + ).rejects.toThrow(); + expect(progress).not.toContain("ready"); + expect(progress).not.toContain("stopped"); +}); + test("projects without managed values omit the private environment envelope", async () => { const { opts, events } = await fixture(false); expect(await startNativeProject(opts)).toBe(0); From f51e4262f072b53322aeb914b9aea98653a65308 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:09:43 -0500 Subject: [PATCH 053/138] feat(native): build Compose build services in native up behind HACK_NATIVE_BUILDS Native foreground up can now build Compose `build:` services in the native runtime when HACK_NATIVE_BUILDS=1 is set. Without it, a build service keeps refusing before any hook or VM effect, now with a fixed message that names the variable. - Pure admission before hooks or VM start: `build: ` or a mapping of context, dockerfile, target and literal args (mapping of strings or numbers, or KEY=VALUE list). Compose path rules (context relative to the Compose file, Dockerfile relative to the context), inside the project. Interpolation, host-variable inheritance, null values, other build keys, remote contexts and image+build refuse; values are never shown. - After runtime up, each distinct build runs once through runtime build-image with its output streamed to stderr; every service with that build is pinned to the returned content ID before image pinning and review. The request file is private (0600, removed after use). Cancellation sends SIGTERM so the runtime stops its build client group, then SIGKILL after a grace period. - HACK_NATIVE_REBUILD=1 asks for a rebuild. Restore of an unchanged retained run uses retained images and never builds (a missing one refuses); hack restart never builds and refuses before stopping anything when a build service has no running or retained image. Refs HACK-1270 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 39 +- packages/runtime-core/README.md | 4 +- src/backends/native-project-builds.ts | 561 ++++++++++++++++++ .../native-project-image-admission.ts | 31 +- .../native-project-restart-preflight.ts | 27 +- src/backends/native-project-start.ts | 90 ++- ...ative-compose-backend-features-cli.test.ts | 18 +- tests/native-project-builds.test.ts | 331 +++++++++++ 8 files changed, 1066 insertions(+), 35 deletions(-) create mode 100644 src/backends/native-project-builds.ts create mode 100644 tests/native-project-builds.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 9f4f986fb..41191b5be 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -228,9 +228,33 @@ selection; see [Test both backends](https://github.com/hack-dance/hack/blob/next Other labels keep their native behavior. Routing (`caddy*`) and dependency cache (`hack.dependencies.*`) labels are reviewed as before, and other ordinary labels -are refused with the general native configuration refusal. A basic build is also -refused by native image-project `up`, with the general refusal: it needs the -authored native format. +are refused with the general native configuration refusal. + +Native image-project `up` builds Compose `build:` services only when you opt in +with `HACK_NATIVE_BUILDS=1` (and the usual `HACK_NATIVE_SHARED_SOURCE=1`). Without +it, a `build:` service refuses before any hook or VM effect and names the variable. +With it: + +- Accepted forms are `build: ` and a mapping of `context`, `dockerfile`, + `target` and `args`. Paths follow Compose (the context is relative to the Compose + file, the Dockerfile to the context) and must stay inside the project. `args` may be + a mapping of literal strings or numbers, or a list of `KEY=VALUE` literals. Any `$` + interpolation, a list item without `=` (host variable inheritance), `null`, other + build keys (`secrets`, `ssh`, `cache_from`, `platforms`, `network` and the rest), + remote contexts and `image` together with `build` refuse. Values are never shown. +- After the runtime is up, each distinct build runs once with + `runtime build-image` (below), and its output streams to your terminal. Every + service with that build is then pinned to the built content ID, so review, readiness + and restore treat it like an image service. +- An existing image for the same build inputs is reused without building. Set + `HACK_NATIVE_REBUILD=1` to build again; base-image tags then resolve again too. + Edits to other files in the context do not trigger a rebuild, as with Compose. +- Restoring an unchanged retained run uses the retained images and never builds; if + one is missing, `up` refuses and asks you to start fresh. `hack restart` also never + builds: it reuses the running or retained image, or refuses before stopping anything. +- Services in profiles you did not select are built too, because the native review + pins every declared service. Native builds are `linux/arm64` only; + `platform: linux/amd64` is still refused. `hack doctor` reports the same features for the current project in its `compose backend features` check. With `--json`, the check's `composeBackend.features` @@ -443,9 +467,8 @@ Provider setup is separate and explicit. Obtain the exact archives identified in ./hack-native --candidate-root /absolute/private/candidate-home runtime probe --json ``` -The pins also name a BuildKit client (`buildx`, `darwin-arm64`) for future native image -builds. Native `up` still refuses `build:` services and nothing runs this client yet. -Preparing it is optional: +The pins also name a BuildKit client (`buildx`, `darwin-arm64`) for native image +builds. Prepare it before using `HACK_NATIVE_BUILDS=1`; it is optional otherwise: ```sh ./hack-native --candidate-root /absolute/private/candidate-home runtime prepare-builder --archive /absolute/docker-buildx @@ -458,8 +481,8 @@ it again and does not replace it. If the prepared tree changes, `runtime probe` refuses; it reports `builder_prepared` when the client is present and unchanged. `runtime build-image --request --json` builds one image in the running -runtime's engine with that client. It is a building block: native `up` still refuses -`build:` services and does not call it. The request is a private JSON file (a singly +runtime's engine with that client. Native `up` calls it for `build:` services when +`HACK_NATIVE_BUILDS=1` is set; you can also run it directly. The request is a private JSON file (a singly linked, mode-`0600` file owned by you), because build-argument values are in it: ```json diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index aca2f2762..3f650f695 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -489,8 +489,8 @@ that does not name an owned network or `none`. Cold builds need free guest stora (at least 6 GiB, or twice the key's last image), successful builds prune BuildKit cache to 8 GiB, and `runtime reclaim-build-images` removes only owned, superseded, unreferenced images (`provider/image_build/reclaim.rs`; model -`tests/models/tla/native-build-retention`). Native `up` does not call the build -operation yet. +`tests/models/tla/native-build-retention`). Native `up` calls the build operation +for Compose `build:` services only when `HACK_NATIVE_BUILDS=1` is set. Source binaries embed their build checkout. If a copied Cargo target directory retains a different checkout identity, use a fresh `--target-dir` for checks and diff --git a/src/backends/native-project-builds.ts b/src/backends/native-project-builds.ts new file mode 100644 index 000000000..369b65e2a --- /dev/null +++ b/src/backends/native-project-builds.ts @@ -0,0 +1,561 @@ +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { + NativeRuntimeRequestError, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +/** + * Native Compose `build:` services (feature-gated by `HACK_NATIVE_BUILDS=1`). + * + * Admission is pure and runs before any hook or VM effect: it accepts the Compose + * forms the native runtime builds (`build: ` or a mapping of `context`, + * `dockerfile`, `target` and literal `args`) and refuses everything else with a + * closed, coded refusal. After the runtime is up, each distinct build runs once + * through `runtime build-image`; the service is then pinned to the returned content + * ID, so review, graph admission, receipts and restore treat it as an image + * service. Restore and restart never build: they use the retained content ID or + * refuse before any graph effect. + */ + +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; +const BUILD_KEY = /^[a-f0-9]{64}$/; +const ARGUMENT = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const BUILD_KEYS = new Set(["context", "dockerfile", "target", "args"]); +const MAX_ARGS = 64; +/** The runtime's own budget is 30 minutes; the client waits a little longer. */ +const BUILD_TIMEOUT_SECONDS = 30 * 60; +/** After SIGTERM the runtime stops its build client within ten seconds. */ +const CANCEL_GRACE_MS = 20_000; +const MAX_STDOUT = 64 * 1024; +const ERROR_CODE = /^[a-z][a-z0-9_]{0,63}$/; + +/** Whether native `up` may build Compose `build:` services. */ +export function nativeBuildsEnabled( + env: Readonly> = process.env +): boolean { + return env.HACK_NATIVE_BUILDS === "1"; +} + +/** Whether `HACK_NATIVE_REBUILD=1` asks every build to run again. */ +export function nativeRebuildRequested( + env: Readonly> = process.env +): boolean { + return env.HACK_NATIVE_REBUILD === "1"; +} + +/** One admitted build: paths relative to the project root and the context. */ +export interface NativeBuildRequest { + readonly context: string; + readonly dockerfile: string; + readonly target: string | null; + /** Literal values. Never log, print or persist outside the private request file. */ + readonly args: Readonly>; +} + +/** Fixed refusal for a `build:` service while native builds are not enabled. */ +export const NATIVE_BUILDS_DISABLED_REFUSAL = + "Native foreground up builds Compose build services only with HACK_NATIVE_BUILDS=1; set it to build them in the native runtime. No hook, VM or build was started."; + +function refused(detail: string): HackCliError { + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `Native foreground up cannot build this service: ${detail} Values were omitted; no hook, VM or build was started.`, + }); +} + +function literal(value: unknown, what: string): string { + if (typeof value !== "string" || value.length === 0 || value.includes("$")) { + throw refused( + `${what} must be a literal non-empty string without Compose interpolation.` + ); + } + return value; +} + +function argumentEntries(value: unknown): [string, unknown][] { + if (isRecord(value)) { + return Object.entries(value); + } + if (!Array.isArray(value)) { + throw refused("build args must be a mapping or a list."); + } + return value.map((item: unknown): [string, unknown] => { + if (typeof item !== "string" || !item.includes("=")) { + throw refused( + "list-form build args must be KEY=VALUE literals; inheriting a host variable is not supported." + ); + } + const equal = item.indexOf("="); + return [item.slice(0, equal), item.slice(equal + 1)]; + }); +} + +function buildArguments(value: unknown): Record { + if (value === undefined) { + return {}; + } + const entries = argumentEntries(value); + if (entries.length > MAX_ARGS) { + throw refused("a native build accepts at most 64 build args."); + } + const result: Record = {}; + for (const [name, raw] of entries) { + if (!ARGUMENT.test(name) || Object.hasOwn(result, name)) { + throw refused("build arg names must be unique identifiers."); + } + const text = + typeof raw === "number" && Number.isFinite(raw) ? String(raw) : raw; + if (typeof text !== "string" || text.includes("$")) { + // Env-derived values would be baked into image history. + throw refused( + "build arg values must be literal strings or numbers; interpolation and null values are not supported." + ); + } + result[name] = text; + } + return result; +} + +/** The declared build fields with Compose defaults, before path checks. */ +function buildFields(build: unknown): NativeBuildRequest { + if (typeof build === "string") { + return { + context: literal(build, "the build context"), + dockerfile: "Dockerfile", + target: null, + args: {}, + }; + } + if (!isRecord(build)) { + throw refused("build must be a context path or a mapping."); + } + if (Object.keys(build).some((key) => !BUILD_KEYS.has(key))) { + throw refused( + "only build context, dockerfile, target and literal args are supported." + ); + } + const target = + build.target === undefined + ? null + : literal(build.target, "the build target"); + if (target !== null && !TARGET.test(target)) { + throw refused("the build target must be a stage name."); + } + return { + context: + build.context === undefined + ? "." + : literal(build.context, "the build context"), + dockerfile: + build.dockerfile === undefined + ? "Dockerfile" + : literal(build.dockerfile, "the Dockerfile path"), + target, + args: buildArguments(build.args), + }; +} + +/** + * Admit one service's `build` declaration, or return `undefined` when it has none. + * Paths follow Compose: the context is relative to the Compose file's directory and + * the Dockerfile is relative to the context. The result is project-relative; the + * runtime resolves it again without following symlinks. + */ +export function admitNativeProjectBuild(opts: { + readonly service: Readonly>; + readonly projectRoot: string; + readonly composeFile: string; +}): NativeBuildRequest | undefined { + const build = opts.service.build; + if (build === undefined) { + return; + } + if (Object.hasOwn(opts.service, "image")) { + throw refused("a service may not declare both image and build."); + } + const { context, dockerfile, target, args } = buildFields(build); + if (context.includes("://") || context.startsWith("git@")) { + throw refused("remote build contexts are not supported."); + } + if (isAbsolute(dockerfile)) { + throw refused("the Dockerfile must be relative to the build context."); + } + const absolute = resolve(dirname(opts.composeFile), context); + const root = resolve(opts.projectRoot); + const inside = relative(root, absolute); + if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { + throw refused("the build context must be inside the project."); + } + return { context: inside === "" ? "." : inside, dockerfile, target, args }; +} + +/** + * Admit every build service of a normalized Compose document. Throws the closed + * refusal for any unsupported form, and when builds are not enabled. + */ +export function admitNativeProjectBuilds(opts: { + readonly services: Readonly< + Record>> + >; + readonly projectRoot: string; + readonly composeFile: string; + readonly enabled: boolean; +}): ReadonlyMap { + const builds = new Map(); + for (const [name, service] of Object.entries(opts.services)) { + const request = admitNativeProjectBuild({ ...opts, service }); + if (request === undefined) { + continue; + } + if (!opts.enabled) { + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: NATIVE_BUILDS_DISABLED_REFUSAL, + }); + } + builds.set(name, request); + } + return builds; +} + +/** The result of one `runtime build-image`, checked against its closed shape. */ +export interface NativeBuiltImage { + readonly imageId: string; + readonly buildKey: string; + readonly reused: boolean; +} + +function parseBuilt(value: unknown): NativeBuiltImage { + if ( + !( + isRecord(value) && + typeof value.image_id === "string" && + IMAGE_ID.test(value.image_id) && + typeof value.build_key === "string" && + BUILD_KEY.test(value.build_key) && + typeof value.reused === "boolean" + ) + ) { + throw new ClosedCliError( + "Native runtime returned an invalid image build result; values omitted." + ); + } + return { + imageId: value.image_id, + buildKey: value.build_key, + reused: value.reused, + }; +} + +/** + * Forward build output to stderr as it arrives, holding back a final structured + * runtime error line so it can be returned instead of printed. + */ +async function forwardBuildOutput( + stream: ReadableStream, + write: (text: string) => void +): Promise { + const decoder = new TextDecoder(); + let pending = ""; + let held: string | undefined; + const emit = (line: string) => { + if (held !== undefined) { + write(`${held}\n`); + held = undefined; + } + if (line.startsWith('{"code":')) { + held = line; + } else { + write(`${line}\n`); + } + }; + const reader = stream.getReader(); + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) { + break; + } + pending += decoder.decode(chunk, { stream: true }); + let newline = pending.indexOf("\n"); + while (newline >= 0) { + emit(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + newline = pending.indexOf("\n"); + } + } + pending += decoder.decode(); + if (pending.length > 0) { + emit(pending); + } + return held; +} + +function failureCode(line: string | undefined): string | undefined { + if (line === undefined) { + return; + } + try { + const value: unknown = JSON.parse(line); + return isRecord(value) && + typeof value.code === "string" && + ERROR_CODE.test(value.code) + ? value.code + : undefined; + } catch { + return; + } +} + +function failureMessage(line: string | undefined): string | undefined { + try { + const value: unknown = line === undefined ? undefined : JSON.parse(line); + return isRecord(value) && typeof value.message === "string" + ? value.message.slice(0, 512) + : undefined; + } catch { + return; + } +} + +/** + * Run `runtime build-image` with build output streamed to stderr. Cancellation sends + * SIGTERM so the runtime can stop its build client's process group, then SIGKILL + * after a grace period. The request file path is the only argument; it carries the + * argument values and is private to this invocation. + */ +export async function invokeNativeBuild(opts: { + readonly runtime: NativeRuntimeSelection; + readonly cwd: string; + readonly requestFile: string; + readonly signal?: AbortSignal; + readonly write?: (text: string) => void; +}): Promise { + if (opts.signal?.aborted) { + throw new ClosedCliError( + "Native foreground up was canceled; values omitted." + ); + } + const child = Bun.spawn( + [ + opts.runtime.binary, + "--candidate-root", + opts.runtime.home, + "runtime", + "build-image", + "--request", + opts.requestFile, + "--json", + ], + { + cwd: opts.cwd, + env: systemEnvironment(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + let killTimer: ReturnType | undefined; + let canceled = false; + const cancel = () => { + canceled = true; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), CANCEL_GRACE_MS); + }; + opts.signal?.addEventListener("abort", cancel, { once: true }); + const outer = setTimeout(cancel, (BUILD_TIMEOUT_SECONDS + 60) * 1000); + try { + const [stdout, held, code] = await Promise.all([ + new Response(child.stdout).arrayBuffer(), + forwardBuildOutput( + child.stderr, + opts.write ?? ((text) => process.stderr.write(text)) + ), + child.exited, + ]); + if (canceled) { + throw new ClosedCliError( + "Native foreground up was canceled during an image build; no image was adopted." + ); + } + if (code !== 0) { + const nativeCode = failureCode(held); + throw new NativeRuntimeRequestError({ + message: + failureMessage(held) ?? + "Native image build failed; its output is shown above.", + nativeCode, + }); + } + if (stdout.byteLength > MAX_STDOUT) { + throw new ClosedCliError( + "Native runtime returned an oversized image build result; values omitted." + ); + } + return parseBuilt(JSON.parse(new TextDecoder().decode(stdout))); + } finally { + opts.signal?.removeEventListener("abort", cancel); + clearTimeout(outer); + clearTimeout(killTimer); + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } +} + +/** The same minimal process environment ordinary runtime requests receive. */ +function systemEnvironment(): Record { + const env: Record = {}; + for (const key of ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + return env; +} + +/** A refusal when a retained run's built image is gone: restore never builds. */ +function retainedMissing(service: string): ClosedCliError { + return new ClosedCliError( + `The retained built image for ${service} was removed; start fresh instead of restoring. Nothing was built.` + ); +} + +/** + * Build (or reuse) every admitted build service and pin it to its content ID. + * Identical declarations build once. Retained content IDs always win, and a + * restore with a missing retained image refuses instead of building. + */ +export async function buildNativeProjectImages(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly branch: string | null | undefined; + readonly builds: ReadonlyMap; + readonly specs: Record>; + readonly retainedImages: ReadonlyMap; + readonly restore: boolean; + readonly rebuild: boolean; + readonly signal: AbortSignal; + readonly invoke?: typeof invokeNativeBuild; +}): Promise { + const pending = pendingBuilds(opts); + if (pending.size === 0) { + return; + } + await runPendingBuilds({ ...opts, pending }); +} + +type PendingBuild = { request: NativeBuildRequest; services: string[] }; + +/** Pin retained images, refuse a restore without one, and group identical builds. */ +function pendingBuilds(opts: { + readonly builds: ReadonlyMap; + readonly specs: Record>; + readonly retainedImages: ReadonlyMap; + readonly restore: boolean; +}): Map { + const pending = new Map(); + for (const [service, request] of opts.builds) { + const spec = opts.specs[service]; + if (!spec) { + throw new ClosedCliError( + "Native build services changed during startup; values omitted." + ); + } + const retained = opts.retainedImages.get(service); + if (retained !== undefined) { + pin(spec, retained); + continue; + } + // A retained run selects retained images only while its configuration is + // unchanged; then a build service without one refuses instead of building. + if (opts.restore && opts.retainedImages.size > 0) { + throw retainedMissing(service); + } + const identity = JSON.stringify([ + request.context, + request.dockerfile, + request.target, + Object.entries(request.args).sort(([a], [b]) => (a < b ? -1 : 1)), + ]); + const entry = pending.get(identity) ?? { request, services: [] }; + entry.services.push(service); + pending.set(identity, entry); + } + return pending; +} + +async function runPendingBuilds(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly branch: string | null | undefined; + readonly pending: ReadonlyMap; + readonly specs: Record>; + readonly rebuild: boolean; + readonly signal: AbortSignal; + readonly invoke?: typeof invokeNativeBuild; +}): Promise { + const invoke = opts.invoke ?? invokeNativeBuild; + // The runtime requires the exact canonical root; it also derives the namespace. + const projectRoot = await realpath(opts.projectRoot); + const directory = await mkdtemp(join(tmpdir(), "hack-native-build-")); + try { + let index = 0; + for (const { request, services } of opts.pending.values()) { + if (opts.signal.aborted) { + throw new ClosedCliError( + "Native foreground up was canceled; values omitted." + ); + } + const requestFile = join(directory, `request-${index}.json`); + index += 1; + await writeFile( + requestFile, + JSON.stringify({ + version: 1, + project_root: projectRoot, + branch: opts.branch ?? null, + context: request.context, + dockerfile: request.dockerfile, + target: request.target, + args: request.args, + policy: opts.rebuild ? "rebuild" : "missing", + timeout_seconds: BUILD_TIMEOUT_SECONDS, + }), + { mode: 0o600, flag: "wx" } + ); + const built = await invoke({ + runtime: opts.runtime, + cwd: opts.projectRoot, + requestFile, + signal: opts.signal, + }); + // The request carries argument values; remove it as soon as it is consumed. + await rm(requestFile, { force: true }); + for (const service of services) { + const spec = opts.specs[service]; + if (spec) { + pin(spec, built.imageId); + } + } + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +function pin(spec: Record, image: string): void { + if (!IMAGE_ID.test(image)) { + throw new ClosedCliError( + "Native build image selection is invalid; values omitted." + ); + } + spec.image = image; + Reflect.deleteProperty(spec, "build"); +} diff --git a/src/backends/native-project-image-admission.ts b/src/backends/native-project-image-admission.ts index 3e032ef2f..b589ea3df 100644 --- a/src/backends/native-project-image-admission.ts +++ b/src/backends/native-project-image-admission.ts @@ -34,6 +34,27 @@ function canceled(): ClosedCliError { ); } +/** Distinct registry references to admit; content IDs and admitted builds need none. */ +function imageReferences( + specs: Readonly>>> +): Set { + const references = new Set(); + for (const spec of Object.values(specs)) { + // An admitted build service has no image yet; its base images are pinned + // by the runtime build itself. + if (spec.image === undefined && spec.build !== undefined) { + continue; + } + if (typeof spec.image !== "string") { + throw refused(); + } + if (!IMAGE_ID.test(spec.image)) { + references.add(spec.image); + } + } + return references; +} + /** * Admit every authored service image before any VM, provider or image effect. * @@ -53,15 +74,7 @@ export async function admitNativeProjectImages(opts: { readonly invoke?: typeof invokeNativeRuntime; }): Promise { const invoke = opts.invoke ?? invokeNativeRuntime; - const references = new Set(); - for (const spec of Object.values(opts.specs)) { - if (typeof spec.image !== "string") { - throw refused(); - } - if (!IMAGE_ID.test(spec.image)) { - references.add(spec.image); - } - } + const references = imageReferences(opts.specs); for (const reference of references) { if (opts.signal?.aborted) { throw canceled(); diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cd7ac3ce4..550881f8d 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -6,6 +6,10 @@ import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; +import { + admitNativeProjectBuilds, + nativeBuildsEnabled, +} from "./native-project-builds.ts"; import { discoverNativeHostDependency, type NativeHostDependency, @@ -288,6 +292,11 @@ async function pinRestartImages(opts: { spec.image = retainedImage; continue; } + if (spec.image === undefined && spec.build !== undefined) { + throw new ClosedCliError( + `Native restart reuses built images and never builds; no running or retained image was found for ${name}. Run hack up to build it; nothing was stopped.` + ); + } const image = String(spec.image); if (IMAGE.test(image)) { continue; @@ -390,9 +399,25 @@ export async function preflightNativeRestart(opts: { }) : undefined; input = metadata?.input ?? input; + // Restart never builds: an admitted build service must resolve to the image the + // current or retained graph already runs (see pinRestartImages). const specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + opts.dependencyFile !== undefined, + new Set( + admitNativeProjectBuilds({ + services: Object.fromEntries( + Object.entries( + JSON.parse(input.normalizedComposeJson).services ?? {} + ).filter((entry): entry is [string, Record] => + isRecord(entry[1]) + ) + ), + projectRoot: opts.scope.projectRoot, + composeFile: opts.composeFile, + enabled: nativeBuildsEnabled(), + }).keys() + ) ); const dependencies = stoppedRetry ? await readNativeRestartDependencyIntent({ diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..d9e89b982 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -20,6 +20,14 @@ import { preparedBaseArguments, } from "./native-prepared-base.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; +import { + admitNativeProjectBuilds, + buildNativeProjectImages, + invokeNativeBuild, + type NativeBuildRequest, + nativeBuildsEnabled, + nativeRebuildRequested, +} from "./native-project-builds.ts"; import { nativeSharedSourceFlags, nativeStartCacheSource, @@ -258,6 +266,7 @@ type Dependencies = { recoverHttps: typeof recoverNativeHttpsLease; cleanupTemporary: (directory: string) => Promise; invoke: typeof invokeNativeRuntime; + build: typeof invokeNativeBuild; load: typeof loadNativeProjectRun; loadRestart: typeof loadNativeRestartIntent; save: typeof saveNativeProjectRun; @@ -279,6 +288,7 @@ const DEFAULTS: Dependencies = { cleanupTemporary: (directory) => rm(directory, { recursive: true, force: true }), invoke: invokeNativeRuntime, + build: invokeNativeBuild, load: loadNativeProjectRun, loadRestart: loadNativeRestartIntent, save: saveNativeProjectRun, @@ -328,9 +338,59 @@ function requireActiveStartup(signal: AbortSignal | undefined): void { throw canceled(); } } +function admitStartBuilds( + input: NativeProjectInput, + opts: { + readonly scope: { readonly projectRoot: string }; + readonly composeFile: string; + } +): ReadonlyMap { + return admitNativeProjectBuilds({ + services: composeServices(input), + projectRoot: opts.scope.projectRoot, + composeFile: opts.composeFile, + enabled: nativeBuildsEnabled(), + }); +} + +/** A service names an image, or is an admitted build that names none yet. */ +function imageOrAdmittedBuild( + name: string, + value: Readonly>, + builds: ReadonlySet +): boolean { + if (value.build === undefined) { + return typeof value.image === "string"; + } + return builds.has(name) && value.image === undefined; +} + +/** The normalized Compose services, or the closed configuration refusal. */ +function composeServices( + input: NativeProjectInput +): Record> { + const compose: unknown = JSON.parse(input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw refused(); + } + const services: Record> = {}; + for (const [name, value] of Object.entries(compose.services)) { + if (!isRecord(value)) { + throw refused(); + } + services[name] = value; + } + return services; +} + +/** + * `builds` names the services whose Compose `build` was admitted + * (`admitNativeProjectBuilds`); only those may omit `image` until they are built. + */ export function prepareNativeProjectServices( input: NativeProjectInput, - hasHostDependencies = false + hasHostDependencies = false, + builds: ReadonlySet = new Set() ): Record> { const compose: unknown = JSON.parse(input.normalizedComposeJson); if (!(isRecord(compose) && isRecord(compose.services))) { @@ -339,9 +399,7 @@ export function prepareNativeProjectServices( const result: Record> = {}; for (const [name, value] of Object.entries(compose.services)) { if ( - !isRecord(value) || - typeof value.image !== "string" || - value.build !== undefined || + !(isRecord(value) && imageOrAdmittedBuild(name, value, builds)) || (value.extra_hosts !== undefined && !hasHostDependencies) || value.ports !== undefined || value.logging !== undefined || @@ -1010,9 +1068,13 @@ export async function startNativeProject(opts: { phase: "before-runtime", }) ).input; + // Pure build admission before hooks, VM or provider start; refuses when disabled. + const builds = admitStartBuilds(input, opts); + const buildServices = new Set(builds.keys()); let specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + opts.dependencyFile !== undefined, + buildServices ); // Refuse malformed or unacquirable references before hooks, VM or provider start. await admitNativeProjectImages({ @@ -1065,7 +1127,8 @@ export async function startNativeProject(opts: { input = (await deps.adaptAws({ input, ...selection.aws })).input; specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + opts.dependencyFile !== undefined, + buildServices ); } const dependencyDiscoveryDeadline = performance.now() + 60_000; @@ -1159,7 +1222,8 @@ export async function startNativeProject(opts: { requireActiveStartup(controller.signal); specs = prepareNativeProjectServices( input, - opts.dependencyFile !== undefined + opts.dependencyFile !== undefined, + buildServices ); prepareNativeDependencyServices({ dependencies: hostDependencies, @@ -1172,6 +1236,18 @@ export async function startNativeProject(opts: { restore, invoke: deps.invoke, }); + await buildNativeProjectImages({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + branch: opts.scope.branch, + builds, + specs, + retainedImages, + restore: Boolean(restore), + rebuild: nativeRebuildRequested(), + signal: controller.signal, + invoke: deps.build, + }); await pinNativeProjectImages({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index 34c16bb70..0ef0e5a26 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -10,6 +10,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative, resolve } from "node:path"; +import { NATIVE_BUILDS_DISABLED_REFUSAL } from "../src/backends/native-project-builds.ts"; import { NATIVE_CONFIGURATION_REFUSAL } from "../src/backends/native-project-start.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -325,16 +326,17 @@ test( test( "control: ordinary labels and a basic build keep today's native refusal", async () => { - for (const service of [ - { image: IMAGE, labels: { "com.example.team": "web" } }, - { build: ".." }, - ]) { + for (const [service, refusal] of [ + [ + { image: IMAGE, labels: { "com.example.team": "web" } }, + NATIVE_CONFIGURATION_REFUSAL, + ], + // A basic build stays refused unless native builds are explicitly enabled. + [{ build: ".." }, NATIVE_BUILDS_DISABLED_REFUSAL], + ] as const) { const f = await fixture({ services: { web: service } }); const result = await cli(f, ["up"]); - expectOneClosedLine( - result, - `E_NATIVE_PROJECT_UNSUPPORTED: ${NATIVE_CONFIGURATION_REFUSAL}` - ); + expectOneClosedLine(result, `E_NATIVE_PROJECT_UNSUPPORTED: ${refusal}`); expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); // The check passed: native up went on to its own source preflight. expect(await f.calls()).toEqual(["runtime check-project-share"]); diff --git a/tests/native-project-builds.test.ts b/tests/native-project-builds.test.ts new file mode 100644 index 000000000..a77e5c060 --- /dev/null +++ b/tests/native-project-builds.test.ts @@ -0,0 +1,331 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + admitNativeProjectBuild, + admitNativeProjectBuilds, + buildNativeProjectImages, + invokeNativeBuild, + NATIVE_BUILDS_DISABLED_REFUSAL, + type NativeBuildRequest, + nativeBuildsEnabled, +} from "../src/backends/native-project-builds.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; + +const IMAGE = `sha256:${"a".repeat(64)}`; +const RETAINED = `sha256:${"b".repeat(64)}`; +const KEY = "c".repeat(64); +const VALUE = "synthetic-literal-value"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); +async function root(): Promise { + const path = await mkdtemp(join(tmpdir(), "hack-native-builds-")); + roots.push(path); + return path; +} + +function admit( + service: Record, + composeFile = "/p/.hack/compose.yaml" +) { + return admitNativeProjectBuild({ service, projectRoot: "/p", composeFile }); +} + +test("admitted build forms follow Compose paths and literal arguments", () => { + expect(admit({ build: ".." })).toEqual({ + context: ".", + dockerfile: "Dockerfile", + target: null, + args: {}, + }); + expect( + admit({ + build: { + context: "../app", + dockerfile: "docker/Dockerfile.dev", + target: "base", + args: { VERSION: "1.2", RETRIES: 3 }, + }, + }) + ).toEqual({ + context: "app", + dockerfile: "docker/Dockerfile.dev", + target: "base", + args: { VERSION: "1.2", RETRIES: "3" }, + }); + expect(admit({ build: { args: ["A=1", "B=x=y"] } })?.args).toEqual({ + A: "1", + B: "x=y", + }); + expect(admit({ image: "busybox" })).toBeUndefined(); +}); + +test.each([ + [{ image: "busybox", build: "." }, "both image and build"], + [{ build: { context: ".", ssh: ["default"] } }, "only build context"], + [ + { build: { context: ".", platforms: ["linux/arm64"] } }, + "only build context", + ], + [{ build: { args: { A: "${HOST_VALUE}" } } }, "interpolation"], + [{ build: { args: { A: null } } }, "interpolation and null"], + [{ build: { args: ["INHERITED"] } }, "KEY=VALUE"], + [{ build: { args: { "bad-name": "x" } } }, "unique identifiers"], + [{ build: "$CONTEXT" }, "interpolation"], + [{ build: "../.." }, "inside the project"], + [{ build: "https://example.invalid/repo.git" }, "remote build contexts"], + [ + { build: { dockerfile: "/etc/Dockerfile" } }, + "relative to the build context", + ], + [{ build: { target: "-x" } }, "stage name"], + [{ build: 7 }, "context path or a mapping"], +])("unsupported build %j refuses before any effect", (service, text) => { + const error = (() => { + try { + admit(service); + } catch (caught: unknown) { + return caught; + } + })(); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toMatchObject({ code: "E_NATIVE_PROJECT_UNSUPPORTED" }); + expect(String((error as Error).message)).toContain(text); + expect(String((error as Error).message)).not.toContain("HOST_VALUE"); +}); + +test("builds refuse unless explicitly enabled, and projects without builds are unaffected", () => { + const services = { web: { build: "." }, db: { image: "postgres:17" } }; + expect(() => + admitNativeProjectBuilds({ + services, + projectRoot: "/p", + composeFile: "/p/compose.yaml", + enabled: false, + }) + ).toThrow(NATIVE_BUILDS_DISABLED_REFUSAL); + expect( + admitNativeProjectBuilds({ + services: { db: { image: "postgres:17" } }, + projectRoot: "/p", + composeFile: "/p/compose.yaml", + enabled: false, + }).size + ).toBe(0); + expect([ + ...admitNativeProjectBuilds({ + services, + projectRoot: "/p", + composeFile: "/p/compose.yaml", + enabled: true, + }).keys(), + ]).toEqual(["web"]); + expect(nativeBuildsEnabled({ HACK_NATIVE_BUILDS: "1" })).toBe(true); + expect(nativeBuildsEnabled({ HACK_NATIVE_BUILDS: "true" })).toBe(false); +}); + +const runtime = { + binary: "/unused/hack-native", + home: "/unused/home", +} as never; +const request = ( + args: Record = { A: VALUE } +): NativeBuildRequest => ({ + context: "app", + dockerfile: "Dockerfile", + target: null, + args, +}); + +test("identical builds run once, pin every service and keep the request private", async () => { + const project = await root(); + const specs: Record> = { + one: { build: "app" }, + two: { build: "app" }, + other: { build: { context: "app", target: "dev" } }, + }; + const seen: { body: Record; mode: number; path: string }[] = + []; + await buildNativeProjectImages({ + runtime, + projectRoot: project, + branch: "feature", + builds: new Map([ + ["one", request()], + ["two", request()], + ["other", { ...request(), target: "dev" }], + ]), + specs, + retainedImages: new Map(), + restore: false, + rebuild: false, + signal: new AbortController().signal, + invoke: async ({ requestFile }) => { + seen.push({ + body: JSON.parse(await readFile(requestFile, "utf8")), + mode: (await stat(requestFile)).mode & 0o777, + path: requestFile, + }); + return { imageId: IMAGE, buildKey: KEY, reused: false }; + }, + }); + expect(seen).toHaveLength(2); + expect(seen[0]?.mode).toBe(0o600); + expect(seen[0]?.body).toMatchObject({ + version: 1, + branch: "feature", + context: "app", + policy: "missing", + args: { A: VALUE }, + }); + expect(seen[1]?.body.target).toBe("dev"); + for (const { path } of seen) { + expect(await Bun.file(path).exists()).toBe(false); + } + for (const spec of Object.values(specs)) { + expect(spec).toEqual({ image: IMAGE }); + } +}); + +test("retained images win, and restore never builds a missing one", async () => { + const project = await root(); + const calls: string[] = []; + const invoke = async () => { + calls.push("build"); + return { imageId: IMAGE, buildKey: KEY, reused: true }; + }; + const base = { + runtime, + projectRoot: project, + branch: null, + rebuild: false, + signal: new AbortController().signal, + invoke, + }; + const specs = { web: { build: "app" } as Record }; + await buildNativeProjectImages({ + ...base, + builds: new Map([["web", request()]]), + specs, + retainedImages: new Map([["web", RETAINED]]), + restore: true, + }); + expect(specs.web).toEqual({ image: RETAINED }); + expect(calls).toEqual([]); + + const missing = await buildNativeProjectImages({ + ...base, + builds: new Map([["worker", request()]]), + specs: { worker: { build: "app" } }, + retainedImages: new Map([["web", RETAINED]]), + restore: true, + }).catch((error: unknown) => error); + expect(String(missing)).toContain("Nothing was built"); + expect(calls).toEqual([]); + + // A retained run whose configuration changed selects no retained images and builds. + await buildNativeProjectImages({ + ...base, + builds: new Map([["worker", request()]]), + specs: { worker: { build: "app" } }, + retainedImages: new Map(), + restore: true, + }); + expect(calls).toEqual(["build"]); +}); + +test("an explicit rebuild asks the runtime to build again", async () => { + const project = await root(); + let policy: unknown; + await buildNativeProjectImages({ + runtime, + projectRoot: project, + branch: null, + builds: new Map([["web", request()]]), + specs: { web: { build: "app" } }, + retainedImages: new Map(), + restore: false, + rebuild: true, + signal: new AbortController().signal, + invoke: async ({ requestFile }) => { + policy = JSON.parse(await readFile(requestFile, "utf8")).policy; + return { imageId: IMAGE, buildKey: KEY, reused: false }; + }, + }); + expect(policy).toBe("rebuild"); +}); + +async function fakeRuntime( + script: string +): Promise<{ binary: string; home: string }> { + const directory = await root(); + const binary = join(directory, "hack-native"); + await writeFile(binary, `#!/bin/sh\n${script}\n`, { mode: 0o700 }); + await chmod(binary, 0o700); + const home = join(directory, "home"); + await mkdir(home); + return { binary, home }; +} + +test("build output streams through while the runtime's closed error is returned", async () => { + const fake = await fakeRuntime( + `printf 'step 1\\nstep 2\\n' >&2\nprintf '{"code":"native_build_failed","message":"The image build failed."}\\n' >&2\nexit 2` + ); + const written: string[] = []; + const error = await invokeNativeBuild({ + runtime: fake as never, + cwd: fake.home, + requestFile: join(fake.home, "request.json"), + write: (text) => written.push(text), + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(NativeRuntimeRequestError); + expect(error).toMatchObject({ nativeCode: "native_build_failed" }); + expect(written.join("")).toBe("step 1\nstep 2\n"); + + const ok = await fakeRuntime( + `printf 'progress\\n' >&2\nprintf '{"image_id":"${IMAGE}","build_key":"${KEY}","tag":"t","reused":false,"base_pins":{}}'` + ); + const built = await invokeNativeBuild({ + runtime: ok as never, + cwd: ok.home, + requestFile: join(ok.home, "request.json"), + write: () => undefined, + }); + expect(built).toEqual({ imageId: IMAGE, buildKey: KEY, reused: false }); +}); + +test("cancellation sends SIGTERM so the runtime can stop its build client", async () => { + const fake = await fakeRuntime( + `trap 'printf terminated > "$0.signal"; exit 143' TERM\nprintf 'started\\n' >&2\nwhile :; do sleep 0.05; done` + ); + const controller = new AbortController(); + const pending = invokeNativeBuild({ + runtime: fake as never, + cwd: fake.home, + requestFile: join(fake.home, "request.json"), + signal: controller.signal, + write: (text) => { + if (text.includes("started")) { + controller.abort(); + } + }, + }).catch((caught: unknown) => caught); + const error = await pending; + expect(String(error)).toContain("canceled during an image build"); + expect(await readFile(`${fake.binary}.signal`, "utf8")).toBe("terminated"); +}); From df46386669f0233022afaea2637a26138f7600fe Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:21:20 -0500 Subject: [PATCH 054/138] fix(native): scaffold native-startable init and release the HOME alias on down With the native backend selected, `hack init` now writes a scaffold that native `hack up` accepts: the shared `hack-dev` network keeps `external: true` and adds the reviewed `x-hack-isolated: true` replacement, and each routed service gets a `healthcheck.x-hack-http` probe on its route port. Docker Compose ignores both extension fields, so the file still starts on the Compose backend. Init prints only commands the selected backend accepts: on native, the foreground `HACK_NATIVE_SHARED_SOURCE=1 hack up` and `hack open`, with no `--detach` or `hack global install`. The Compose scaffold and next steps are byte-for-byte unchanged. A clean `runtime down` now removes the temporary `/private/tmp/hkl-` HOME alias after it records the stopped pool, and a repeated down on a clean stop removes one an earlier version kept. `Owner::load` accepts a missing alias only for a clean stop (`stopped` plus the clean-stop marker, no pending owner update); every other receipt keeps the strict `provider_home_missing` refusal. `runtime up`, `runtime recover` and the approved-host update restore the exact alias under the pool lock before using it, creating it exclusively after rechecking the receipt. The native candidate guide now names the Alpine v3.19 main/aarch64 source of the pinned APKs and documents the `x-hack-http` fields from the runtime's schema and the guest probe's behavior. Refs HACK-1916 Co-Authored-By: Claude Opus 5.5 --- docs/guides/init-project.md | 14 + docs/guides/native-candidate.md | 71 ++++- .../runtime-core/src/provider/lifecycle.rs | 31 +- .../src/provider/lifecycle/prepared_boot.rs | 10 +- .../provider/lifecycle/short_home/tests.rs | 171 +++++++++++ .../src/provider/network_update.rs | 2 + packages/runtime-core/src/provider/state.rs | 89 +++++- src/commands/project.ts | 88 +++++- src/init/compose.ts | 87 +++++- tests/init-native-backend.test.ts | 288 ++++++++++++++++++ 10 files changed, 819 insertions(+), 32 deletions(-) create mode 100644 tests/init-native-backend.test.ts diff --git a/docs/guides/init-project.md b/docs/guides/init-project.md index 6cb9aedf7..6870b91e5 100644 --- a/docs/guides/init-project.md +++ b/docs/guides/init-project.md @@ -37,6 +37,20 @@ Notes: - `hack open` resolves the routed URL via the global proxy and prefers the OAuth alias when `oauth.enabled` is true. Set `open.prefer` or pass `--prefer auto|alias|dev` to override. +With the native backend selected (`HACK_RUNTIME_BACKEND=native`, which the +`hack-next` launcher sets), `hack init` writes a scaffold native `hack up` starts: +- The shared `hack-dev` network keeps `external: true` and adds + `x-hack-isolated: true`. Docker Compose still joins the external network; native + creates an owned bridge under that name instead. +- Each routed service gets a `healthcheck.x-hack-http` probe on its route port with + path `/`. Change the path to your app's health endpoint if `/` does not answer + `2xx`. See [Native HTTP readiness probe](native-candidate.md#native-http-readiness-probe). +- The next steps it prints are native ones: `HACK_NATIVE_SHARED_SOURCE=1 hack up` + in the foreground (native refuses `--detach`), then `hack open` from another + terminal. It does not suggest `hack global install`, which native refuses. + +Without that selection, the Compose scaffold and its next steps are unchanged. + Optional: - `hack logs --pretty` for log tailing. - `hack tui` for the interactive dashboard. diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..286e5cf0b 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -292,8 +292,19 @@ complete bundle to change the candidate. binary refusing newer state is an unsupported downgrade, not a successful rollback. Preserve that state; do not rewrite receipts or adopt another home's data. -Host cleanup or a reboot can remove the temporary `/private/tmp/hkl-` HOME -alias while the private candidate home and disks remain intact. Normal commands +The provider runs with a short temporary HOME alias, `/private/tmp/hkl-`, a +symlink to `/.hack-local/run/smolvm/home` that keeps its Unix socket paths +within the macOS length limit. A clean `runtime down` removes this alias after it +records the stopped pool, so a stopped pool leaves nothing outside its candidate +home. `runtime down` on a cleanly stopped pool that still has its alias (for example, +one stopped by an earlier version) removes it too. The next `runtime up`, `runtime +recover` or approved-host update restores the exact alias under the pool lock before +using it; an existing path at that name is checked and never replaced. Observation +commands such as `runtime status` accept the released alias and create nothing. +Other stop results, such as a failed boot's stop, keep the alias as before. + +Host cleanup or a reboot can also remove the alias. For a clean stop this is the same +released state. For any other receipt, normal commands report `provider_home_missing`; they do not initialize another pool or recreate the alias during observation. Explicit `runtime recover --json` can restore only that absent exact alias, after verifying the receipt-bound dead provider, both recorded @@ -446,7 +457,12 @@ Provider setup is separate and explicit. Obtain the exact archives identified in Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with regular, singly linked mode-`0600` files. Acquire the matching public package bytes; -the command never downloads packages or accepts substitute versions: +the command never downloads packages or accepts substitute versions. + +The pinned files come from the Alpine **v3.19** `main` repository for `aarch64` +(`https://dl-cdn.alpinelinux.org/alpine/v3.19/main/aarch64/`). Other Alpine +releases, such as v3.20, publish packages with the same file names but different +bytes; those fail the hash check below. | File | SHA-256 | | --- | --- | @@ -1040,7 +1056,8 @@ an exact capacity or a minimum. The combined physical transport limit still appl to the pool's actual capacities, including any spare slots. A minimum does not resize an existing pool or guarantee that enough slots are currently free. Each routed service -must already declare a matching `healthcheck.x-hack-http`; command healthchecks +must already declare a matching `healthcheck.x-hack-http` (see +[Native HTTP readiness probe](#native-http-readiness-probe)); command healthchecks and missing probes are not replaced or inferred. Native review still validates hostnames, ports, networks and labels. Foreground graph ownership supervises and retires Unix route publishers. Without explicit HTTPS selection, this enrollment does not start a hostname authority @@ -1313,6 +1330,52 @@ dependency request and removed when foreground startup exits. No durable listene authority is inferred from a saved selection file. Image-only services omit graph shared-source mode; active project bind mounts still use the explicit project share. +#### Native HTTP readiness probe + +`healthcheck.x-hack-http` declares the readiness probe native startup uses for a +service. It must be the only key in that service's `healthcheck`: it cannot be +combined with `test`, `disable`, `interval`, `timeout`, `start_period`, +`start_interval` or `retries`. Docker Compose ignores the extension field. All six +fields are required, and no other field is accepted: + +| Field | Type and range | Meaning | +| --- | --- | --- | +| `port` | integer, 1–65535 | Container port the probe connects to on the container's loopback address (`127.0.0.1`). For a routed service it must equal the port in `caddy.reverse_proxy: "{{upstreams PORT}}"`. | +| `path` | string, starts with `/`, at most 512 bytes | Request path. Only printable ASCII without spaces is accepted; `#` is refused. The probe sends `GET ` with `Host: 127.0.0.1:`. | +| `interval_ms` | integer, 10–3600000 | Time from the start of one request to the start of the next. | +| `timeout_ms` | integer, 1–60000 | Limit for connecting, sending the request and reading the response headers. | +| `retries` | integer, 1–100 | Consecutive failed samples that make the service unhealthy. | +| `start_period_ms` | integer, 0–3600000 | Startup grace. Failures in this period do not count until the first success. | + +A sample succeeds only when the response status line is `HTTP/1.1` or `HTTP/1.0` +with a `2xx` status. A redirect (`3xx`) or any other status is a failure, so point +`path` at an endpoint that answers `2xx` without a redirect. A service is starting +until its first success, healthy after a success, and unhealthy after `retries` +consecutive counted failures. Failures count once the service has succeeded once or +the start period has ended; one success resets the count. A sample older than +`interval_ms + timeout_ms + 1000` milliseconds counts as unhealthy. + +```yaml +services: + web: + labels: + caddy: web.hack.local + caddy.reverse_proxy: "{{upstreams 3000}}" + caddy.tls: internal + healthcheck: + x-hack-http: + port: 3000 + path: /health + interval_ms: 1000 + timeout_ms: 2000 + retries: 60 + start_period_ms: 120000 +``` + +`hack init` writes this probe with path `/` and the values above for each routed +service when the native backend is selected. The `httpProbes` adaptation below +accepts only `port` and `path` and fills the other four fields with the same values. + For projects whose checked-in Compose file needs explicit native health probes, `HACK_NATIVE_ADAPTATION` selects an absolute JSON file. This leaves the source file unchanged and preserves its identity in native review. Only the following bounded diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 9c3a20650..fadc92a59 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -1167,6 +1167,9 @@ fn start_pool( || admission_pool::probe(candidate, profile, &admission_owner), )?; samples.extend(fresh_samples); + // A clean stop released its temporary HOME alias; restore it under the lease before the + // release checks probe its sockets or the provider runs with it as HOME. + restore_released_short_home(candidate)?; // Under the lease: the selection is rechecked. A stopped pool keeps the reserve only if // a sample needs it, and then its provider release (VM lock, disk handles, listeners, // provider configuration) is proven; otherwise this start uses the fresh budget. @@ -1830,7 +1833,11 @@ pub fn down(candidate: &Candidate) -> Result { && initial.process_alive != Some(true) { // These phases are written only before a start or after proven absence; no recorded - // provider (`None`) is as stopped as a dead one. + // provider (`None`) is as stopped as a dead one. A clean stop left by an earlier + // `down` that kept its alias releases it now. + if owner.short_home_releasable() { + owner.release_short_home(candidate)?; + } return status(candidate); } verify_live(candidate, &owner)?; @@ -1933,10 +1940,30 @@ sync"#, finish_stopped(candidate, &mut owner, "stopped-after-engine-failure")?; } else { finish_quiesced_stop(candidate, &mut owner)?; + // The stopped receipt is written; the temporary `/private/tmp` HOME alias is not + // needed again until the next start restores it. + owner.release_short_home(candidate)?; } status(candidate) } +/// Restore the HOME alias a clean `down` released, under the caller's pool lock. Any other +/// receipt, or one whose alias is present, is left as [`Owner::load`] checked it. +pub(super) fn restore_released_short_home(candidate: &Candidate) -> Result<(), CandidateError> { + if !root(candidate) + .join("owner.json") + .try_exists() + .map_err(io)? + { + return Ok(()); + } + let owner = Owner::load(candidate)?; + if owner.short_home_releasable() { + owner.restore_released_short_home(candidate)?; + } + Ok(()) +} + /// `down`'s completion after the guest acknowledged flush and shutdown of a quiesced engine /// and the provider exited: the clean-stop marker names this boot and is written with the /// `stopped` phase once the provider's release is proven. If any proof fails, nothing is @@ -2127,6 +2154,8 @@ pub fn recover(candidate: &Candidate) -> Result { return Ok(initial); } let lock = state::Lock::acquire(&root(candidate))?; + // Recovery rewrites the phase, which needs the alias a clean stop released. + restore_released_short_home(candidate)?; let mut owner = Owner::load(candidate)?; let Some(process) = owner.process.as_ref() else { // A start interrupted before the provider identity was recorded; see `interrupted`. diff --git a/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs b/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs index b0b608385..021103c88 100644 --- a/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs +++ b/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs @@ -156,8 +156,14 @@ impl Work { &["machine", "delete", "--name", &owner.machine, "--force"], )?; } - // `Owner::load` proved the alias points at this root's home. - fs::remove_file(&owner.short_home).map_err(state::io)?; + // `Owner::load` proved the alias points at this root's home, or that a + // clean stop already released it. + match fs::remove_file(&owner.short_home) { + Err(error) if error.kind() != ErrorKind::NotFound => { + return Err(state::io(error)); + } + _ => {} + } } Err(failure) => { // The receipt is saved just before its alias is created, and nothing external diff --git a/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs b/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs index 29555947b..33eae3b9a 100644 --- a/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs +++ b/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs @@ -406,3 +406,174 @@ fn vm_lock_release_child() { drop(listener); } } + +/// HACK-1916: a clean `down` releases the temporary HOME alias, so a stopped pool leaves +/// nothing under `/private/tmp`, and the next mutating operation restores it exactly. +impl Pool { + /// The receipt `down` writes after a quiesced stop: `stopped` plus the clean-stop marker. + fn cleanly_stopped(mut self) -> Self { + self.owner.guest_boot_id = Some("00000000-0000-0000-0000-000000000000".into()); + self.owner.daemon_pid = Some(1); + self.owner.daemon_start = Some(1); + self.owner.phase = "stopped".into(); + self.owner.clean_stop = state::CleanStop::of(&self.owner); + assert!(self.owner.short_home_releasable()); + self.owner.save(&self.candidate).unwrap(); + self + } + fn alias_absent(&self) -> bool { + matches!( + fs::symlink_metadata(&self.owner.short_home), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) + } +} + +#[test] +fn clean_stop_releases_the_alias_and_the_next_mutation_restores_it_exactly() { + let pool = Pool::new().cleanly_stopped(); + let before = pool.receipt(); + pool.owner.release_short_home(&pool.candidate).unwrap(); + assert!(pool.alias_absent()); + assert_eq!(pool.receipt(), before); + // Observation accepts the released alias and creates nothing. + assert_eq!(Owner::load(&pool.candidate).unwrap(), pool.owner); + assert_eq!(status(&pool.candidate).unwrap().phase, "stopped"); + assert!(pool.alias_absent()); + // Releasing again is a no-op. + pool.owner.release_short_home(&pool.candidate).unwrap(); + assert!(pool.alias_absent()); + + let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); + super::super::restore_released_short_home(&pool.candidate).unwrap(); + assert_eq!( + fs::read_link(&pool.owner.short_home).unwrap(), + root(&pool.candidate).join("home") + ); + let inode = fs::symlink_metadata(&pool.owner.short_home).unwrap().ino(); + // A present exact alias is checked, never replaced. + super::super::restore_released_short_home(&pool.candidate).unwrap(); + assert_eq!( + fs::symlink_metadata(&pool.owner.short_home).unwrap().ino(), + inode + ); + assert_eq!(pool.receipt(), before); + drop(lock); +} + +#[test] +fn only_a_clean_stop_may_lack_its_alias() { + // Every other phase keeps the strict missing-HOME refusal and explicit recovery. + // `stopped` without the clean-stop marker is an unclean or uncertain stop. + for phase in ["stopped", "running", "stopping", "recovered-unclean"] { + let mut pool = Pool::new(); + pool.owner.phase = phase.into(); + pool.owner.save(&pool.candidate).unwrap(); + assert_eq!( + pool.owner + .release_short_home(&pool.candidate) + .unwrap_err() + .code, + "recovery_required" + ); + assert!(!pool.alias_absent()); + pool.remove_alias(); + let before = pool.receipt(); + assert_eq!( + Owner::load(&pool.candidate).unwrap_err().code, + "provider_home_missing" + ); + let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); + assert_eq!( + super::super::restore_released_short_home(&pool.candidate) + .unwrap_err() + .code, + "provider_home_missing" + ); + drop(lock); + pool.unchanged(&before); + } + // A pending owner update keeps even a clean stop strict. + let pool = Pool::new().cleanly_stopped(); + pool.owner.release_short_home(&pool.candidate).unwrap(); + let pending = root(&pool.candidate).join("owner.pending"); + fs::write(&pending, b"interrupted update").unwrap(); + assert_eq!( + Owner::load(&pool.candidate).unwrap_err().code, + "provider_home_missing" + ); + assert!(pool.alias_absent()); + assert_eq!(fs::read(&pending).unwrap(), b"interrupted update"); +} + +#[test] +fn a_released_alias_path_taken_by_anything_else_is_never_adopted_or_replaced() { + let pool = Pool::new().cleanly_stopped(); + pool.owner.release_short_home(&pool.candidate).unwrap(); + let before = pool.receipt(); + let foreign = pool.directory.join("foreign"); + state::private_directory(&foreign).unwrap(); + std::os::unix::fs::symlink(&foreign, &pool.owner.short_home).unwrap(); + assert_eq!( + Owner::load(&pool.candidate).unwrap_err().code, + "foreign_state" + ); + assert_eq!( + pool.owner + .restore_released_short_home(&pool.candidate) + .unwrap_err() + .code, + "foreign_state" + ); + assert_eq!( + pool.owner + .release_short_home(&pool.candidate) + .unwrap_err() + .code, + "foreign_state" + ); + assert_eq!(fs::read_link(&pool.owner.short_home).unwrap(), foreign); + assert_eq!(fs::read_dir(&foreign).unwrap().count(), 0); + assert_eq!(pool.receipt(), before); + fs::remove_file(&pool.owner.short_home).unwrap(); + + // A receipt that changed after it was observed restores nothing. + let observed = pool.owner.clone(); + let mut changed = pool.owner.clone(); + changed.phase = "recovered-unclean".into(); + changed.clean_stop = None; + changed.save(&pool.candidate).unwrap(); + let changed_receipt = pool.receipt(); + assert_eq!( + observed + .restore_released_short_home(&pool.candidate) + .unwrap_err() + .code, + "provider_home_missing" + ); + assert!(pool.alias_absent()); + assert_eq!(pool.receipt(), changed_receipt); +} + +#[test] +fn recovery_of_a_released_clean_stop_restores_the_alias_first() { + isolated("released_recovery_child"); +} + +#[test] +#[ignore = "isolated release-check fixture launched by recovery_of_a_released_clean_stop_restores_the_alias_first"] +fn released_recovery_child() { + let pool = Pool::new().cleanly_stopped(); + pool.owner.release_short_home(&pool.candidate).unwrap(); + let recovered = crate::provider::recover(&pool.candidate).unwrap(); + assert_eq!(recovered.phase, "recovered-unclean"); + assert_eq!( + fs::read_link(&pool.owner.short_home).unwrap(), + root(&pool.candidate).join("home") + ); + // The recovered receipt needs its alias, and has it. + assert_eq!( + Owner::load(&pool.candidate).unwrap().phase, + "recovered-unclean" + ); +} diff --git a/packages/runtime-core/src/provider/network_update.rs b/packages/runtime-core/src/provider/network_update.rs index 226878420..59b5b12a9 100644 --- a/packages/runtime-core/src/provider/network_update.rs +++ b/packages/runtime-core/src/provider/network_update.rs @@ -372,6 +372,8 @@ fn update_network( hosts.sort(); reject_aliased_state(&root(candidate))?; let _lock = state::Lock::acquire(&root(candidate))?; + // The update stages owner writes, which need the alias a clean stop released. + lifecycle::restore_released_short_home(candidate)?; let owner = Owner::load(candidate)?; let _vm_lock = stopped(candidate, &owner)?; let path = root(candidate).join("network-update.json"); diff --git a/packages/runtime-core/src/provider/state.rs b/packages/runtime-core/src/provider/state.rs index 093f4ea02..3622f83cc 100644 --- a/packages/runtime-core/src/provider/state.rs +++ b/packages/runtime-core/src/provider/state.rs @@ -295,16 +295,15 @@ impl Owner { ) -> Result { let root = candidate.state_root.join("run/smolvm"); reject_aliased_state(&root)?; - if allow_missing { - match fs::symlink_metadata(root.join("owner.pending")) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - _ => { - return Err(CandidateError::new( - "recovery_required", - "Provider owner update is pending or unobservable; HOME alias was not restored.", - )); - } - } + let no_pending_update = matches!( + fs::symlink_metadata(root.join("owner.pending")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ); + if allow_missing && !no_pending_update { + return Err(CandidateError::new( + "recovery_required", + "Provider owner update is pending or unobservable; HOME alias was not restored.", + )); } let owner: Self = read(&root.join("owner.json"))?; owner.network.validate()?; @@ -343,10 +342,78 @@ impl Owner { "Provider owner identity does not match this checkout.", )); } - owner.check_short_home(candidate, allow_missing)?; + // A clean stop's alias may be absent: `down` releases it, and the next mutating + // operation restores it under the pool lock before using it. + let released = owner.short_home_releasable() && no_pending_update; + owner.check_short_home(candidate, allow_missing || released)?; check_private_directory(&root.join("home"))?; Ok(owner) } + + /// Whether this receipt is a clean stop: the `stopped` phase with the clean-stop marker, + /// which only `down` writes, after proving provider and socket absence, in one receipt + /// write. Only such a receipt may exist without its temporary HOME alias. Any other + /// phase write clears the marker, so every later phase needs the alias again. + pub(super) fn short_home_releasable(&self) -> bool { + self.phase == "stopped" && self.clean_stop.is_some() + } + + /// Remove the temporary `/private/tmp` HOME alias of a clean stop, so a stopped pool + /// leaves nothing outside its candidate home. Called by `down` under the pool lock + /// after its stopped receipt is written. The alias is removed only when it is exactly + /// this receipt's own link; an absent alias is already released. A replaced alias is + /// refused and kept. + pub(super) fn release_short_home(&self, candidate: &Candidate) -> Result<(), CandidateError> { + if !self.short_home_releasable() { + return Err(CandidateError::new( + "recovery_required", + "Only a clean stop releases the provider HOME alias.", + )); + } + match fs::symlink_metadata(&self.short_home) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(io(error)), + Ok(_) => {} + } + self.check_short_home(candidate, false)?; + // `/private/tmp` is sticky: only this user can unlink or rename its own link, so the + // link checked above is the one removed. + match fs::remove_file(&self.short_home) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(io(error)), + _ => Ok(()), + } + } + + /// Restore the alias `down` released, before a mutating operation uses it. Called under + /// the pool lock. A present alias is checked, not replaced; an absent one is created + /// exclusively after the durable receipt is rechecked, as in explicit recovery. + pub(super) fn restore_released_short_home( + &self, + candidate: &Candidate, + ) -> Result<(), CandidateError> { + match fs::symlink_metadata(&self.short_home) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io(error)), + Ok(_) => return self.check_short_home(candidate, false), + } + if !self.short_home_releasable() || Self::load(candidate)? != *self { + return Err(CandidateError::new( + "foreign_state", + "Provider owner changed before HOME alias restoration; no alias was restored.", + )); + } + std::os::unix::fs::symlink( + candidate.state_root.join("run/smolvm/home"), + &self.short_home, + ) + .map_err(|_| { + CandidateError::new( + "socket_alias_collision", + "Cannot exclusively restore provider HOME alias; no existing path was replaced.", + ) + })?; + self.check_short_home(candidate, false) + } /// The only permitted alias is the exact receipt-bound HOME for Unix sockets. fn check_short_home( &self, diff --git a/src/commands/project.ts b/src/commands/project.ts index 0e641d9dd..5c5e14219 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -119,7 +119,11 @@ import { PROJECT_ENV_FILENAME, } from "../constants.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; -import { renderCompose, TODO_IMAGE_SENTINEL } from "../init/compose.ts"; +import { + type InitComposeBackend, + renderCompose, + TODO_IMAGE_SENTINEL, +} from "../init/compose.ts"; import type { ServiceCandidate } from "../init/discovery.ts"; import { discoverRepo } from "../init/discovery.ts"; import { @@ -3850,6 +3854,7 @@ export async function handleInit({ const repoRoot = await findRepoRootForInit(startDir); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + const backend = resolveInitComposeBackend(); if (!io.canPrompt()) { requireInteractive({ @@ -3942,6 +3947,7 @@ export async function handleInit({ projectSlug: slug, candidates: discovery.candidates, oauth: { enabled: oauth.enabled, tld: oauth.tld }, + backend, }, }); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); @@ -4010,6 +4016,7 @@ export async function handleInit({ ...(io.agentPrompts ? { prompts: io.agentPrompts } : {}), }); + const next = initNextSteps({ backend, devHost }); note( [ `Wrote: ${HACK_PROJECT_DIR_PRIMARY}/${PROJECT_COMPOSE_FILENAME}`, @@ -4018,10 +4025,9 @@ export async function handleInit({ `Wrote: ${HACK_PROJECT_DIR_PRIMARY}/${PROJECT_ENV_CONFIG_DEFAULT_FILENAME}`, "", "Next:", - " hack up", - " hack open", + ...next.commands.map((command) => ` ${command}`), "", - `Check DNS/TLS for ${devHost}; hack global install configures managed global domains (needs sudo).`, + next.routing, ].join("\n"), "Initialized" ); @@ -4050,6 +4056,7 @@ async function handleInitAuto({ const startDir = resolveStartDir(ctx, args.options.path); const repoRoot = await findRepoRootForInit(startDir); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); + const backend = resolveInitComposeBackend(); const slug = resolveInitSlug({ repoRoot, @@ -4110,12 +4117,14 @@ async function handleInitAuto({ projectSlug: slug, candidates: discovery.candidates, oauth, + backend, }) : await buildManualComposeAuto({ repoRoot, devHost, projectSlug: slug, oauth, + backend, }); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); await ensureDir(hackDir); @@ -4179,11 +4188,12 @@ async function handleInitAuto({ logger.success({ message: `Initialized ${HACK_PROJECT_DIR_PRIMARY}/ for ${slug}`, }); + const next = initNextSteps({ backend, devHost }); logger.info({ - message: "Next: hack up --detach && hack open", + message: `Next: ${next.autoCommand}`, }); logger.info({ - message: `Check DNS/TLS for ${devHost}; hack global install configures managed global domains (needs sudo).`, + message: next.routing, }); if (withValue) { @@ -4370,6 +4380,51 @@ export interface ComposeWizardInput { readonly enabled: boolean; readonly tld: string; }; + /** Backend the scaffold targets; omitted means `compose`. */ + readonly backend?: InitComposeBackend; +} + +/** + * The backend `hack init` scaffolds for. Native is chosen only by the same + * explicit `HACK_RUNTIME_BACKEND=native` selection `hack up` reads (the + * `hack-next` launcher sets it); anything else keeps the Compose scaffold. + */ +export function resolveInitComposeBackend( + env: Readonly> = process.env +): InitComposeBackend { + return env.HACK_RUNTIME_BACKEND === "native" ? "native" : "compose"; +} + +/** + * Follow-up commands `hack init` prints, limited to ones the selected backend + * accepts. Native `up` runs in the foreground only, needs the explicit + * project-share grant, and refuses `--detach`; `hack global install` refuses + * on native, where routes use the optional native HTTPS frontend instead. + */ +export function initNextSteps(opts: { + readonly backend: InitComposeBackend; + readonly devHost: string; +}): { + readonly commands: readonly string[]; + readonly autoCommand: string; + readonly routing: string; +} { + if (opts.backend === "native") { + const up = "HACK_NATIVE_SHARED_SOURCE=1 hack up"; + return { + commands: [ + `${up} (foreground; keep it running)`, + "hack open (from another terminal)", + ], + autoCommand: `${up} (foreground; keep it running), then hack open from another terminal`, + routing: `Routes for ${opts.devHost} need the optional native HTTPS frontend (HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256, HACK_NATIVE_HTTPS_PORT); check one with hack doctor --browser-url https://${opts.devHost}.`, + }; + } + return { + commands: ["hack up", "hack open"], + autoCommand: "hack up --detach && hack open", + routing: `Check DNS/TLS for ${opts.devHost}; hack global install configures managed global domains (needs sudo).`, + }; } function normalizeOauthTld(raw: string): string { @@ -4884,6 +4939,7 @@ async function buildDiscoveredCompose( detections: backingServices, existingComposeFiles, }), + backend: input.backend ?? "compose", }); } @@ -5057,6 +5113,7 @@ export async function buildDiscoveredComposeAuto( detections: backingServices, existingComposeFiles, }), + backend: input.backend ?? "compose", }); } @@ -5068,6 +5125,7 @@ interface ManualComposeWizardInput { readonly enabled: boolean; readonly tld: string; }; + readonly backend?: InitComposeBackend; } function validateRepoRelativeWorkingDir( @@ -5222,7 +5280,11 @@ async function buildManualCompose( oauth: input.oauth, }); - return renderCompose({ name: input.projectSlug, services }); + return renderCompose({ + name: input.projectSlug, + services, + backend: input.backend ?? "compose", + }); } function buildManualComposeAuto(input: ManualComposeWizardInput): string { @@ -5244,7 +5306,11 @@ function buildManualComposeAuto(input: ManualComposeWizardInput): string { oauth: input.oauth, }); - return renderCompose({ name: input.projectSlug, services }); + return renderCompose({ + name: input.projectSlug, + services, + backend: input.backend ?? "compose", + }); } function formatCandidateLabel(c: ServiceCandidate): string { @@ -5358,9 +5424,10 @@ function buildServicesFromDrafts(opts: { const labels = new Map(); const networks = d.role === "http" ? ["hack-dev", "default"] : []; + const httpPort = d.role === "http" ? (d.port ?? 3000) : undefined; - if (d.role === "http") { - const port = d.port ?? 3000; + if (httpPort !== undefined) { + const port = httpPort; const host = d.subdomain && d.subdomain.length > 0 ? `${d.subdomain}.${opts.devHost}` @@ -5382,6 +5449,7 @@ function buildServicesFromDrafts(opts: { env, labels, networks, + ...(httpPort === undefined ? {} : { httpPort }), ...(d.comments && d.comments.length > 0 ? { comments: d.comments } : {}), }; }); diff --git a/src/init/compose.ts b/src/init/compose.ts index b9cebebe1..f53b8127e 100644 --- a/src/init/compose.ts +++ b/src/init/compose.ts @@ -17,6 +17,30 @@ export const TODO_IMAGE_PLACEHOLDER = "alpine:3"; export type PlannedServiceRole = "http" | "internal"; +/** + * Runtime backend an init scaffold targets. `compose` is the v4 Docker + * Compose output, unchanged. `native` writes the same services in the form + * the native backend starts: the shared ingress network becomes the reviewed + * `x-hack-isolated` replacement, and each routed service gets the native HTTP + * readiness probe its route requires. Docker Compose ignores both extension + * fields, so a native scaffold still starts on the Compose backend. + */ +export type InitComposeBackend = "compose" | "native"; + +/** + * Starting values for a scaffolded native HTTP probe. They match the + * `HACK_NATIVE_ADAPTATION` `httpProbes` defaults: one-second samples, a + * two-second request timeout, 60 failed samples to unhealthy, and a + * two-minute start period for a dev server's first build. + */ +export const INIT_NATIVE_HTTP_PROBE = { + path: "/", + interval_ms: 1000, + timeout_ms: 2000, + retries: 60, + start_period_ms: 120_000, +} as const; + export interface PlannedService { readonly name: string; // docker compose service key readonly role: PlannedServiceRole; @@ -26,6 +50,11 @@ export interface PlannedService { readonly env: ReadonlyMap; readonly labels: ReadonlyMap; readonly networks: readonly string[]; + /** + * Container port the service's Caddy route proxies to. Set for routed + * (`http`) services; a native scaffold probes this port. + */ + readonly httpPort?: number; /** * Comment lines injected immediately above this service's ` :` key * in the rendered YAML (e.g. a runtime-mismatch TODO). Rendered as `# ` @@ -45,12 +74,19 @@ export interface ComposePlan { * for per-service comments. */ readonly headerComments?: readonly string[]; + /** Backend the scaffold targets (default `compose`). */ + readonly backend?: InitComposeBackend; } export function renderCompose(plan: ComposePlan): string { + const native = plan.backend === "native"; const services: Record = {}; + const planned = native + ? plan.services.map(withNativeProbeComment) + : plan.services; - for (const svc of plan.services) { + for (const svc of planned) { + const probePort = native ? nativeProbePort(svc) : undefined; services[svc.name] = { image: svc.image === TODO_IMAGE_SENTINEL ? TODO_IMAGE_PLACEHOLDER : svc.image, @@ -60,6 +96,13 @@ export function renderCompose(plan: ComposePlan): string { ...(svc.env.size > 0 ? { environment: recordFromMap(svc.env) } : {}), ...(svc.labels.size > 0 ? { labels: recordFromMap(svc.labels) } : {}), ...(svc.networks.length > 0 ? { networks: [...svc.networks] } : {}), + ...(probePort === undefined + ? {} + : { + healthcheck: { + "x-hack-http": { port: probePort, ...INIT_NATIVE_HTTP_PROBE }, + }, + }), }; } @@ -67,7 +110,11 @@ export function renderCompose(plan: ComposePlan): string { ...(plan.name ? { name: plan.name } : {}), services, networks: { - [DEFAULT_INGRESS_NETWORK]: { external: true }, + // Native start refuses an external network it would have to join; the + // reviewed replacement creates an owned bridge under the same name. + [DEFAULT_INGRESS_NETWORK]: native + ? { external: true, "x-hack-isolated": true } + : { external: true }, }, }; @@ -76,7 +123,7 @@ export function renderCompose(plan: ComposePlan): string { const withServiceComments = injectServiceComments({ yaml: cleaned, - services: plan.services, + services: planned, }); return injectHeaderComments({ @@ -85,6 +132,32 @@ export function renderCompose(plan: ComposePlan): string { }); } +/** + * The port a native scaffold probes: the routed service's proxied port. A + * service without a route port gets no probe, because native requires probes + * only for routes and never invents one for an unrouted service. + */ +function nativeProbePort(svc: PlannedService): number | undefined { + return svc.role === "http" && svc.httpPort !== undefined + ? svc.httpPort + : undefined; +} + +function withNativeProbeComment(svc: PlannedService): PlannedService { + const port = nativeProbePort(svc); + if (port === undefined) { + return svc; + } + return { + ...svc, + comments: [ + ...(svc.comments ?? []), + `Native readiness: GET ${INIT_NATIVE_HTTP_PROBE.path} on port ${port} inside the container must return 2xx.`, + "Point healthcheck.x-hack-http.path at your app's health endpoint if / does not.", + ], + }; +} + /** * Prefixes each line with `# ` to form a YAML comment block; blank input * lines become bare `#` lines (no trailing space). @@ -164,12 +237,18 @@ type ComposeServiceSpec = { readonly environment?: Record; readonly labels?: Record; readonly networks?: readonly string[]; + readonly healthcheck?: { + readonly "x-hack-http": Readonly>; + }; }; type ComposeFileSpec = { readonly name?: string; readonly services: Record; - readonly networks: Record; + readonly networks: Record< + string, + { readonly external: boolean; readonly "x-hack-isolated"?: boolean } + >; }; function recordFromMap( diff --git a/tests/init-native-backend.test.ts b/tests/init-native-backend.test.ts new file mode 100644 index 000000000..5b65eeb20 --- /dev/null +++ b/tests/init-native-backend.test.ts @@ -0,0 +1,288 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { YAML } from "bun"; + +import { + buildDiscoveredComposeAuto, + initNextSteps, + resolveInitComposeBackend, +} from "../src/commands/project.ts"; +import { + type ComposePlan, + INIT_NATIVE_HTTP_PROBE, + renderCompose, +} from "../src/init/compose.ts"; +import { discoverRepo } from "../src/init/discovery.ts"; +import { findComposeBackendFeatures } from "../src/lib/native-compose-backend-features.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * HACK-1916: `hack init` with the native backend selected writes a project + * native `up` accepts and prints only commands that backend accepts. The + * Compose scaffold and its next steps stay byte-for-byte as before. + */ + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const tempDirs = new Set(); + +afterEach(async () => { + for (const dir of tempDirs) { + await rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +async function createTempDir(): Promise { + // realpath avoids /var vs /private/var mismatches in registry paths. + const dir = await realpath( + await mkdtemp(join(tmpdir(), "hack-init-native-")) + ); + tempDirs.add(dir); + return dir; +} + +type ParsedCompose = { + services: Record< + string, + { + labels?: Record; + healthcheck?: { "x-hack-http"?: Record }; + networks?: string[]; + } + >; + networks: Record>; +}; + +function parseCompose(yaml: string): ParsedCompose { + return YAML.parse(yaml) as ParsedCompose; +} + +function upstreamPort(labels: Record | undefined): number { + const match = /\{\{upstreams (\d+)\}\}/.exec( + labels?.["caddy.reverse_proxy"] ?? "" + ); + if (!match?.[1]) { + throw new Error("routed service has no upstream port"); + } + return Number(match[1]); +} + +const PLAN: ComposePlan = { + name: "synthetic", + services: [ + { + name: "web", + role: "http", + image: "imbios/bun-node:latest", + workingDir: "/app", + command: "bun run dev -- --port 4100 --host 0.0.0.0", + env: new Map(), + labels: new Map([ + ["caddy", "synthetic.hack.local"], + ["caddy.reverse_proxy", "{{upstreams 4100}}"], + ["caddy.tls", "internal"], + ]), + networks: ["hack-dev", "default"], + httpPort: 4100, + }, + { + name: "worker", + role: "internal", + image: "imbios/bun-node:latest", + workingDir: "/app", + command: "bun run worker", + env: new Map(), + labels: new Map(), + networks: [], + }, + ], +}; + +test("resolveInitComposeBackend selects native only for an explicit native backend", () => { + expect(resolveInitComposeBackend({ HACK_RUNTIME_BACKEND: "native" })).toBe( + "native" + ); + expect(resolveInitComposeBackend({ HACK_RUNTIME_BACKEND: "compose" })).toBe( + "compose" + ); + expect(resolveInitComposeBackend({})).toBe("compose"); +}); + +test("the Compose scaffold is unchanged when the backend is compose or omitted", () => { + const omitted = renderCompose(PLAN); + expect(renderCompose({ ...PLAN, backend: "compose" })).toBe(omitted); + expect(omitted).not.toContain("x-hack"); + expect(omitted).not.toContain("healthcheck"); + expect(parseCompose(omitted).networks["hack-dev"]).toEqual({ + external: true, + }); + expect(findComposeBackendFeatures(parseCompose(omitted))).toEqual([ + "external_network", + ]); +}); + +test("the native scaffold isolates the ingress network and probes each route at its upstream port", () => { + const yaml = renderCompose({ ...PLAN, backend: "native" }); + const parsed = parseCompose(yaml); + + // Still external for Compose, which ignores the extension; native replaces it. + expect(parsed.networks["hack-dev"]).toEqual({ + external: true, + "x-hack-isolated": true, + }); + expect(findComposeBackendFeatures(parsed)).toEqual([]); + + expect(parsed.services.web?.healthcheck).toEqual({ + "x-hack-http": { port: 4100, ...INIT_NATIVE_HTTP_PROBE }, + }); + // Native never invents a probe for an unrouted service. + expect(parsed.services.worker?.healthcheck).toBeUndefined(); + expect(yaml).toMatch( + /# Native readiness: GET \/ on port 4100 inside the container must return 2xx\.\n(.*\n)? {2}web:/ + ); + expect(renderCompose({ ...PLAN, backend: "native" })).toBe(yaml); +}); + +test("a discovered native scaffold gives every routed service a probe on its own route port", async () => { + const dir = await createTempDir(); + const repoRoot = join(dir, "repo"); + await mkdir(join(repoRoot, "apps/web"), { recursive: true }); + await mkdir(join(repoRoot, "apps/api"), { recursive: true }); + await writeFile( + join(repoRoot, "package.json"), + JSON.stringify({ name: "root", private: true, workspaces: ["apps/*"] }) + ); + await writeFile( + join(repoRoot, "apps/web/package.json"), + JSON.stringify({ name: "web", scripts: { dev: "vite" } }) + ); + await writeFile( + join(repoRoot, "apps/api/package.json"), + JSON.stringify({ name: "api", scripts: { dev: "bun index.ts" } }) + ); + const discovery = await discoverRepo(repoRoot); + const parsed = parseCompose( + await buildDiscoveredComposeAuto({ + repoRoot, + devHost: "synthetic.hack.local", + projectSlug: "synthetic", + candidates: discovery.candidates, + oauth: { enabled: false, tld: "gy" }, + backend: "native", + }) + ); + + const routed = Object.entries(parsed.services).filter( + ([, service]) => service.labels?.caddy !== undefined + ); + expect(routed.length).toBeGreaterThan(0); + for (const [, service] of routed) { + expect(service.healthcheck?.["x-hack-http"]?.port).toBe( + upstreamPort(service.labels) + ); + } + expect(findComposeBackendFeatures(parsed)).toEqual([]); +}); + +test("native next steps name only commands the native backend accepts", () => { + const native = initNextSteps({ + backend: "native", + devHost: "synthetic.hack.local", + }); + const text = [...native.commands, native.autoCommand, native.routing].join( + "\n" + ); + expect(text).toContain("HACK_NATIVE_SHARED_SOURCE=1 hack up"); + expect(text).not.toContain("--detach"); + expect(text).not.toContain("global install"); + expect(native.routing).toContain( + "hack doctor --browser-url https://synthetic.hack.local" + ); + + expect( + initNextSteps({ backend: "compose", devHost: "synthetic.hack.local" }) + ).toEqual({ + commands: ["hack up", "hack open"], + autoCommand: "hack up --detach && hack open", + routing: + "Check DNS/TLS for synthetic.hack.local; hack global install configures managed global domains (needs sudo).", + }); +}); + +async function runInitAuto(opts: { + readonly root: string; + readonly repo: string; + readonly backend: string | undefined; +}): Promise<{ exitCode: number; output: string }> { + const proc = Bun.spawn({ + cmd: [ + process.execPath, + join(REPO_ROOT, "index.ts"), + "init", + "--auto", + "--no-discovery", + "--name", + "synthetic", + "--path", + opts.repo, + ], + cwd: opts.repo, + env: privateCliEnv({ + root: opts.root, + env: { + HACK_HOME: join(opts.root, "hack-home"), + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + NO_COLOR: "1", + HACK_RUNTIME_BACKEND: opts.backend, + }, + }), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, output: `${stdout}\n${stderr}` }; +} + +test("hack init --auto on the native backend writes a native-startable scaffold and native next steps", async () => { + const root = await createTempDir(); + const repo = join(root, "repo"); + await mkdir(repo, { recursive: true }); + + const result = await runInitAuto({ root, repo, backend: "native" }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("HACK_NATIVE_SHARED_SOURCE=1 hack up"); + expect(result.output).not.toContain("--detach"); + expect(result.output).not.toContain("global install"); + + const parsed = parseCompose( + await Bun.file(join(repo, ".hack", "docker-compose.yml")).text() + ); + expect(findComposeBackendFeatures(parsed)).toEqual([]); + expect(parsed.services.app?.healthcheck?.["x-hack-http"]?.port).toBe( + upstreamPort(parsed.services.app?.labels) + ); +}, 30_000); + +test("hack init --auto on the Compose backend keeps the Compose scaffold and next steps", async () => { + const root = await createTempDir(); + const repo = join(root, "repo"); + await mkdir(repo, { recursive: true }); + + const result = await runInitAuto({ root, repo, backend: undefined }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("Next: hack up --detach && hack open"); + expect(result.output).toContain("hack global install configures"); + + const yaml = await Bun.file(join(repo, ".hack", "docker-compose.yml")).text(); + expect(yaml).not.toContain("x-hack"); + expect(findComposeBackendFeatures(parseCompose(yaml))).toEqual([ + "external_network", + ]); +}, 30_000); From bbd6bb08ecc5c6765d2677ad409128964647668d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:26:17 -0500 Subject: [PATCH 055/138] fix(native): detect zstd layers that start with a skippable frame Initial compression detection now treats the little-endian skippable frame magic 0x184D2A50..=0x184D2A5F as zstd, so such a layer is decoded and hashed by its content instead of as raw bytes. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/image_load.rs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index b6ec9b0f2..64f03f8d0 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -183,10 +183,13 @@ fn decode_member( let head = member .fill_buf() .map_err(|_| error("Cannot read an image archive member."))?; - let (gzip, zstd) = ( - head.starts_with(&[0x1f, 0x8b]), - head.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]), - ); + let gzip = head.starts_with(&[0x1f, 0x8b]); + // A zstd stream starts with a frame or a skippable frame (magic 0x184D2A50 through + // 0x184D2A5F); both are little-endian. + let zstd = head.first_chunk::<4>().is_some_and(|magic| { + let magic = u32::from_le_bytes(*magic); + magic == 0xFD2F_B528 || (0x184D_2A50..=0x184D_2A5F).contains(&magic) + }); let mut buffer = vec![0_u8; 64 * 1024]; let mut pump = |mut reader: Box| -> Result<(), CandidateError> { loop { @@ -634,6 +637,8 @@ pub(super) mod tests { Zstd, /// Two zstd frames with a skippable frame between them. ZstdFrames, + /// A skippable frame before the first zstd frame. + ZstdLeadingSkippable, Uncompressed, } @@ -656,6 +661,13 @@ pub(super) mod tests { bytes.extend(compress_to_vec(second, CompressionLevel::Uncompressed)); bytes } + Layer::ZstdLeadingSkippable => { + let mut bytes = 0x184D_2A5F_u32.to_le_bytes().to_vec(); + bytes.extend_from_slice(&4_u32.to_le_bytes()); + bytes.extend_from_slice(b"meta"); + bytes.extend(compress_to_vec(payload, CompressionLevel::Fastest)); + bytes + } Layer::Uncompressed => payload.to_vec(), } } @@ -820,6 +832,7 @@ pub(super) mod tests { Layer::Gzip, Layer::Zstd, Layer::ZstdFrames, + Layer::ZstdLeadingSkippable, Layer::Uncompressed, ] { let (bytes, image) = archive(kind, &payload, "arm64", false, false); @@ -827,6 +840,19 @@ pub(super) mod tests { let (bytes, image) = archive(kind, &payload, "arm64", false, true); assert!(validate(&bytes, &image, 1 << 20).is_err()); } + // A leading skippable frame marks the member as zstd: it decodes to the payload + // instead of being hashed as raw bytes. + let mut decoded = Vec::new(); + let uncompressed = decode_member( + Cursor::new(compress(Layer::ZstdLeadingSkippable, &payload)), + |bytes| { + decoded.extend_from_slice(bytes); + Ok(()) + }, + ) + .unwrap(); + assert!(!uncompressed); + assert_eq!(decoded, payload); // A corrupt zstd frame refuses instead of being treated as raw content. let mut corrupt = compress(Layer::Zstd, &payload); let last = corrupt.len() - 2; From 127883eb3d2c69f02ac4490e2ae9fd54e15f6560 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:29:24 -0500 Subject: [PATCH 056/138] fix(native): bound credential helper stdout drain independently of descendants A helper that backgrounds a descendant inheriting stdout could exit while the reader blocked on EOF, so the unconditional join and the timeout path's join hung past the 60 s bound. The helper now runs in its own process group, stdout is read by a detached reader whose chunks are collected with deadlines, a helper that does not exit in time has its whole group killed, and an exited helper gets a 2 s drain grace before the reader is abandoned. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/registry_credentials.rs | 173 ++++++++++++++++-- 1 file changed, 155 insertions(+), 18 deletions(-) diff --git a/packages/runtime-core/src/provider/registry_credentials.rs b/packages/runtime-core/src/provider/registry_credentials.rs index d8e454921..1bea1e535 100644 --- a/packages/runtime-core/src/provider/registry_credentials.rs +++ b/packages/runtime-core/src/provider/registry_credentials.rs @@ -16,9 +16,10 @@ use std::{ collections::BTreeMap, fs::File, io::{Read, Write}, - os::unix::fs::PermissionsExt, + os::unix::{fs::PermissionsExt, process::CommandExt}, path::PathBuf, process::{Command, Stdio}, + sync::mpsc, time::{Duration, Instant}, }; use zeroize::{Zeroize, Zeroizing}; @@ -27,6 +28,9 @@ const MAX_CONFIG: u64 = 1024 * 1024; const MAX_HELPER_OUTPUT: u64 = 64 * 1024; /// Helpers may wait on a native approval such as a keychain prompt; that wait is bounded. const HELPER_TIMEOUT: Duration = Duration::from_secs(60); +/// After the helper exits, how long its stdout may take to close. A descendant that keeps +/// the pipe open cannot extend the wait beyond this. +const HELPER_DRAIN_GRACE: Duration = Duration::from_secs(2); /// The server address the Docker CLI uses for Docker Hub credentials. const DOCKER_HUB_SERVER: &str = "https://index.docker.io/v1/"; @@ -204,51 +208,111 @@ fn helper_executable(policy: &ImagePolicy, name: &str) -> Result>>, + output: &mut Zeroizing>, + until: Instant, +) -> bool { + loop { + let wait = until.saturating_duration_since(Instant::now()); + match chunks.recv_timeout(wait) { + Ok(chunk) => { + let room = (MAX_HELPER_OUTPUT as usize + 1).saturating_sub(output.len()); + output.extend_from_slice(&chunk[..chunk.len().min(room)]); + if output.len() as u64 > MAX_HELPER_OUTPUT { + return true; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => return false, + Err(mpsc::RecvTimeoutError::Disconnected) => return true, + } + } +} + /// Run `docker-credential- get` for one server address. Helper output is bounded and -/// its stderr discarded; a helper that does not finish within the bound is killed. +/// its stderr discarded. +/// +/// Every wait is bounded by `timeout`, independently of the helper's descendants: the +/// helper runs in its own process group, and stdout is drained by a detached reader whose +/// chunks are collected with deadlines. A helper that does not exit in time has its whole +/// process group killed. A helper that exits while a descendant still holds stdout gets a +/// short drain grace; its reply is whatever it wrote before exiting, and the reader is +/// abandoned until the descendant closes the pipe. fn run_helper( policy: &ImagePolicy, name: &str, server: &str, + timeout: Duration, ) -> Result, CandidateError> { let executable = helper_executable(policy, helper_name(name)?)?; + let deadline = Instant::now() + timeout; let mut child = Command::new(executable) .arg("get") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) + .process_group(0) .spawn() .map_err(|_| error("Cannot start the Docker credential helper."))?; let written = child .stdin .take() .map(|mut stdin| stdin.write_all(server.as_bytes())); - let stdout = child.stdout.take(); - let reader = std::thread::spawn(move || { - let mut bytes = Zeroizing::new(Vec::new()); - if let Some(stdout) = stdout { - let _ = stdout.take(MAX_HELPER_OUTPUT + 1).read_to_end(&mut bytes); - } - bytes - }); - let deadline = Instant::now() + HELPER_TIMEOUT; + let (sender, chunks) = mpsc::channel(); + if let Some(mut stdout) = child.stdout.take() { + std::thread::spawn(move || { + let mut buffer = Zeroizing::new([0_u8; 8192]); + let mut total = 0_u64; + while total <= MAX_HELPER_OUTPUT { + match stdout.read(buffer.as_mut()) { + Ok(0) | Err(_) => break, + Ok(n) => { + total += n as u64; + if sender.send(Zeroizing::new(buffer[..n].to_vec())).is_err() { + break; + } + } + } + } + }); + } + // Reserved up front so collection never reallocates and leaves unzeroized copies. + let mut output = Zeroizing::new(Vec::with_capacity(MAX_HELPER_OUTPUT as usize + 1)); + let mut closed = false; let status = loop { match child.try_wait() { Ok(Some(status)) => break status, - Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(None) if Instant::now() < deadline => { + let poll = Instant::now() + Duration::from_millis(20); + if closed { + std::thread::sleep(poll.saturating_duration_since(Instant::now())); + } else { + closed = drain(&chunks, &mut output, poll); + } + } _ => { + // The child is not reaped yet, so its pid still names its process group. + if let Ok(group) = i32::try_from(child.id()) { + // SAFETY: killpg only sends a signal to the helper's own group. + unsafe { libc::killpg(group, libc::SIGKILL) }; + } let _ = child.kill(); let _ = child.wait(); - let _ = reader.join(); return Err(error( "The Docker credential helper did not finish in time.", )); } } }; - let output = reader - .join() - .map_err(|_| error("Cannot read the Docker credential helper reply."))?; + if !closed { + drain( + &chunks, + &mut output, + deadline.min(Instant::now() + HELPER_DRAIN_GRACE), + ); + } if output.len() as u64 > MAX_HELPER_OUTPUT { return Err(error( "The Docker credential helper reply exceeds its limit.", @@ -331,10 +395,10 @@ pub(super) fn lookup( .iter() .find(|(key, _)| matches(key, domain)) { - return run_helper(policy, helper, server_address(domain)); + return run_helper(policy, helper, server_address(domain), HELPER_TIMEOUT); } if let Some(store) = config.creds_store.as_deref().filter(|s| !s.is_empty()) { - return run_helper(policy, store, server_address(domain)); + return run_helper(policy, store, server_address(domain), HELPER_TIMEOUT); } // Prefer an exact key; otherwise the first converted key that names the registry. let entry = config @@ -477,6 +541,79 @@ pub(super) mod tests { ); } + /// The pid a fixture helper recorded for its background descendant. + fn descendant(f: &DockerFixture, name: &str) -> i32 { + let path = f.root.join(format!("docker-credential-{name}.pid")); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(pid) = fs::read_to_string(&path) + .ok() + .and_then(|text| text.trim().parse().ok()) + { + return pid; + } + assert!(Instant::now() < deadline, "helper recorded no descendant"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + fn alive(pid: i32) -> bool { + // SAFETY: signal 0 only checks that the fixture process exists. + unsafe { libc::kill(pid, 0) == 0 } + } + + #[test] + fn helper_descendants_holding_stdout_cannot_extend_the_bounded_wait() { + // The helper replies and exits while a background descendant keeps stdout open. + let f = DockerFixture::new(&serde_json::json!({"credsStore": "lingers"})); + f.helper( + "lingers", + r#"read -r server || true; printf '{"Username":"u","Secret":"s"}'; sleep 30 & echo $! > "$0.pid""#, + ); + let started = Instant::now(); + let credential = lookup(&f.policy(), "registry.test").unwrap(); + let elapsed = started.elapsed(); + let pid = descendant(&f, "lingers"); + let still_holding = alive(pid); + // SAFETY: the fixture's own background sleep is stopped. + unsafe { libc::kill(pid, libc::SIGKILL) }; + assert_eq!(basic(credential), format!("Basic {}", encoded("u:s"))); + assert!(still_holding, "the descendant must still hold stdout"); + assert!( + elapsed < HELPER_DRAIN_GRACE + Duration::from_secs(3), + "{elapsed:?}" + ); + + // A helper that never exits is stopped at its bound with its whole process group, + // even while a descendant holds stdout. + let f = DockerFixture::new(&serde_json::json!({"credsStore": "hangs"})); + f.helper("hangs", r#"sleep 30 & echo $! > "$0.pid"; sleep 30"#); + let started = Instant::now(); + let failure = run_helper( + &f.policy(), + "hangs", + "registry.test", + Duration::from_secs(1), + ) + .err() + .unwrap(); + let elapsed = started.elapsed(); + assert!( + failure.message.contains("did not finish in time"), + "{failure:?}" + ); + assert!(elapsed < Duration::from_secs(5), "{elapsed:?}"); + let pid = descendant(&f, "hangs"); + let deadline = Instant::now() + Duration::from_secs(5); + while alive(pid) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let survived = alive(pid); + // SAFETY: cleanup of the fixture's own background sleep if the group kill missed it. + unsafe { libc::kill(pid, libc::SIGKILL) }; + assert!(!survived, "the helper's process group must be killed"); + } + #[test] fn failing_helpers_and_malformed_configuration_refuse_without_echoing_values() { let f = DockerFixture::new(&serde_json::json!({"credsStore": "fails"})); From 24ce5a8df777eab304d07768ba34824129b29dbd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:26:25 -0500 Subject: [PATCH 057/138] fix(native): reconcile stopped graphs after Compose edits After the first native `up`, any Compose edit left the instance unstartable (`graph_source_compatibility`), and `down` did not unblock it. `up` now reconciles the same run, as `docker compose up` does: - every container and network is recreated from the new review, so changed commands, images, environment and added or removed services take effect; - a retained named volume whose key the new plan declares is reattached under its recorded name after the existing generation, label and presence checks; - a newly declared volume gets an unused `reserved` name in this run and is created exclusively, never adopted; - a retained volume the plan no longer declares stays owned and recorded until explicit data removal. Run, owner, namespace and the ownership plan on every label never change, so another instance's data cannot be selected. Graphs with dependency caches, live source or a pending source-device rebind keep their narrower proofs. Restart of a stopped graph now points to `hack up`. Adds the native-plan-reconcile TLA+ model (747,656 states) with negative controls for cross-instance adoption, skipped generation fencing and dropped undeclared data. Refs HACK-1914 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 12 + docs/reference/v5-model-coverage.md | 3 +- packages/runtime-core/README.md | 16 +- .../src/provider/graph/foreground.rs | 3 +- .../runtime-core/src/provider/graph/mod.rs | 23 +- .../src/provider/graph/plan_reconcile.rs | 805 ++++++++++++++++++ .../src/provider/graph/restore.rs | 134 ++- scripts/lib/tla-runtime-models.ts | 22 + .../native-project-retained-images.ts | 10 + src/backends/native-project-retained.ts | 6 +- tests/models/tla/README.md | 34 + .../tla/native-plan-reconcile/Reconcile.tla | 177 ++++ .../native-plan-reconcile/drop-orphans.cfg | 5 + .../tla/native-plan-reconcile/negative.cfg | 5 + .../tla/native-plan-reconcile/positive.cfg | 5 + .../tla/native-plan-reconcile/replacement.cfg | 5 + tests/native-project-restore.test.ts | 36 + tests/native-project-retained-images.test.ts | 53 ++ 18 files changed, 1326 insertions(+), 28 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/plan_reconcile.rs create mode 100644 tests/models/tla/native-plan-reconcile/Reconcile.tla create mode 100644 tests/models/tla/native-plan-reconcile/drop-orphans.cfg create mode 100644 tests/models/tla/native-plan-reconcile/negative.cfg create mode 100644 tests/models/tla/native-plan-reconcile/positive.cfg create mode 100644 tests/models/tla/native-plan-reconcile/replacement.cfg diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 11cc4f194..0456fe546 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -567,6 +567,18 @@ resolved again for those services. Missing images still refuse native admission; this selection never replaces generation, source or resource checks. Changing the original Compose file uses normal image resolution and the existing compatibility rules, rather than silently adopting an old image for a new declaration. + +Editing the Compose file of a stopped graph and running `hack up` again reconciles +the same run, as `docker compose up` does. Every container and network is +recreated from the new review, so changed commands, images, environment and added +or removed services take effect. A named volume whose key the run already retains +is reattached under its recorded name after the same label and identity checks; a +newly declared volume is created fresh; a retained volume the new file no longer +declares stays owned and unmounted until `hack down --remove-data` (Compose also +keeps project volumes). Retained data is only ever reattached to the run and +namespace that created it. Graphs with dependency caches, live source or a pending +source-device rebind keep the narrower rules below. `hack restart` of a stopped +graph refuses and points to `hack up`. For a shared-source graph admitted with a retained compatibility contract, ordinary source-content edits may change the reviewed plan ID: restart checks the stable execution, exclusion, mount and dependency-cache inputs before cleanup and again diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..c3dc3e2be 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -39,7 +39,7 @@ must be attached to the eventual exact integration revision. | Exclusive claims; competing writers; partial owner publication | graph-admission, registry-writer, publication-journal, missing-publication-lock, native-frontend-recovery | Admission/registry locks, pending publication and exact saved owner; related runtime ownership tests | Real filesystems, boot/incarnation identity and unsupported writers remain independent. | | Canceled/expired startup; reader cancellation versus EOF; producer/group uncertainty | pre-ready-retirement, authority-lifetime, native-file-material | Native graph finalizer/onSettled, exact Removed, hard-signal freshness, host stop/close; local pre-Ready retained/removed regressions, including separate live/foreign/hard-canceled fresh-teardown-admission assertions (HACK-1801) | Physical guest acceptance; current root aggregate; explicit executable unknown host-close control; async proof-to-stop and partial binding-retirement crash windows; post-stop source/session replacement or hard cancellation before Retire (Retire does not recheck these inputs; Safe has no post-stop interference predicate). | | Crash between reserve, publish, sync and release; stale recovery | native-frontend-recovery, publication, publication-journal, native-storage-witness | Durable intent/enrolled/released records, fresh lease and expected binding; recovery and sync-failure tests | Fsync guarantees, process-kill windows, real restarted provider observations. No missing-path authority. | -| Data lifetime independent of compute; replaced/aliased metadata | native-persistent-enrollment, native-storage-witness, native-file-material | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. | +| Data lifetime independent of compute; replaced/aliased metadata; retained-data handoff across Compose plan changes | native-persistent-enrollment, native-storage-witness, native-file-material, native-plan-reconcile | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests; `provider/graph/plan_reconcile.rs` projection tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. | | Settled retained Compose route-attempt retirement; pinned references; interrupted deletion | **Coverage gap:** filesystem journal compaction is not modeled; native-route-stop covers a different stop protocol | Instance mutation lock and exact surviving claim witnesses in [journal compaction](../../src/lib/native-compose-route-claims.ts); [claim regressions](../../tests/native-compose-route-claims.test.ts) and [retained lifecycle integration](../../tests/native-compose-adoption-routing-generation.test.ts) | 24 successful operations keep two attempt directories and constant Linux descriptors; four integrated lifecycle operations check directory counts. Injected unlink failures cover same-store and fresh-store cleanup retries. Assumes cooperative same-user writers, the caller's mutation lock and working filesystem synchronization. No process-kill/power-loss durability proof or bound on unresolved/over-budget histories. | | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | @@ -61,6 +61,7 @@ liveness/reachability controls have their own documented assumptions. | Model | Positive distinct states | Configurations | | --- | ---: | ---: | +| [native-plan-reconcile](../../tests/models/tla/native-plan-reconcile/Reconcile.tla) | 747,656 | 4 | | [pre-ready-retirement](../../tests/models/tla/pre-ready-retirement/Retirement.tla) | 10,965 | 8 | | [native-persistent-enrollment](../../tests/models/tla/native-persistent-enrollment/Enrollment.tla) | 38 | 6 | | [native-frontend-recovery](../../tests/models/tla/native-frontend-recovery/Recovery.tla) | 602 | 10 | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 84c58a6df..048982f95 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1394,11 +1394,23 @@ changed or uncertain mapping refuses before new graph effects. Earlier candidate builds removed the mapping on down; their retained volumes are not guessed or silently adopted by a fresh up and require explicit owned-state recovery. +When the reviewed plan changed and the strict replay layout no longer applies, +`serve-restore` reconciles instead (`provider/graph/plan_reconcile.rs`): after the +same generation, absence and label checks, compute is prepared exactly as a fresh +admission would, retained volumes are projected by logical key onto their recorded +names, newly declared volumes receive unused `reserved` names in this run and are +created exclusively, and retained volumes the plan no longer declares stay in the +receipt. Run, owner, namespace and the ownership plan on every label are unchanged. +`graph source-compatibility` reports `"reconcile": true` for this path. Receipts +with dependency caches, live source or a pending source-device rebind keep the +hostname/shared-source proofs. A volume still `reserved` in a stopped receipt was +never created; selection requires it absent and restore creates it. + Selection binds the receipt, current VM boot and verified current volume observations. Restoration rechecks these before effects, requires the old owner publication and lock to be retired, and verifies the historical cleanup -acknowledgment in its original boot context. A changed plan, missing volume, -active owner, or stale selection is refused. The prior stopped receipt is archived +acknowledgment in its original boot context. A changed plan outside the +reconciliation path above, missing volume, active owner, or stale selection is refused. The prior stopped receipt is archived before fresh dependency ownership is admitted. This does not enable file-based `graph restart`/`restore` or normalized live-owner redelivery, and does not replay initializer cache-release effects. Native same-run restoration and cross-boot diff --git a/packages/runtime-core/src/provider/graph/foreground.rs b/packages/runtime-core/src/provider/graph/foreground.rs index d4c64b761..0646cd2d0 100644 --- a/packages/runtime-core/src/provider/graph/foreground.rs +++ b/packages/runtime-core/src/provider/graph/foreground.rs @@ -159,7 +159,8 @@ pub fn restore_selection(candidate: &Candidate, run: &str) -> Result { one_off::record_effect(&self.root, key, &self.receipt.resources[key]) } fn create_resources(&mut self, retain_data: bool) -> Result<(), CandidateError> { + // Restore reattaches every settled volume. A volume still `reserved` never + // received a create intent; it is created exclusively, never adopted. let keys: Vec<_> = self .receipt .resources @@ -793,7 +796,7 @@ impl Session<'_, '_> { for key in keys { self.check_cancelled()?; let resource = self.receipt.resources[&key].clone(); - if retain_data && resource.kind == Kind::Volume { + if retain_data && plan_reconcile::retained_volume(&resource) { if inspect_resource(&self.engine, &self.receipt, &resource)?.is_none() { return Err(error( "graph_data_missing", @@ -1514,6 +1517,24 @@ pub fn source_compatibility( { return Err(refused()); } + #[cfg(target_os = "macos")] + let rebinding = source_rebind.is_some(); + #[cfg(not(target_os = "macos"))] + let rebinding = false; + if plan_reconcile::applies(&receipt, plan_id, plan, normalized, rebinding) { + // Compute is recreated from this review; retained data is reattached by + // verified identity at admission, which repeats this proof under the lease. + plan_reconcile::prepare(plan, &receipt, normalized)?; + return Ok(json!({ + "run": receipt.run, + "owner": receipt.owner, + "namespace": receipt.namespace, + "plan": receipt.plan_id, + "reviewed_plan": plan_id, + "source_revision": Value::Null, + "reconcile": true, + })); + } let cached = plan .services .values() diff --git a/packages/runtime-core/src/provider/graph/plan_reconcile.rs b/packages/runtime-core/src/provider/graph/plan_reconcile.rs new file mode 100644 index 000000000..526151433 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/plan_reconcile.rs @@ -0,0 +1,805 @@ +//! Compose-parity reconciliation of a stopped graph to a changed reviewed plan. +//! +//! A stopped, data-retaining graph has no compute left: restore has already +//! confirmed the absence of every recorded container and network. Reconciliation +//! therefore recreates all compute from the freshly reviewed plan, exactly as a +//! fresh admission would, and changes only how named data is selected: +//! +//! * a declared volume whose logical key this run already retains is reattached +//! under its recorded name, after the caller's label/identity observation; +//! * a newly declared volume gets a fresh name in this run's namespace and is +//! created through the ordinary exclusive-create path (never adoption); +//! * a retained volume the new plan no longer declares stays recorded and owned +//! (Compose also keeps project volumes of removed declarations); explicit +//! data removal still reaches it; +//! * containers and networks of removed services are dropped from the receipt only +//! because their absence was confirmed before this projection. +//! +//! Ownership never moves: run, owner, namespace and the ownership plan recorded on +//! every label are unchanged, so another instance's data cannot be selected. +//! Dependency caches, live source and source-device rebinding keep their narrower +//! dedicated proofs and are not eligible here. +use super::*; + +fn refused(message: &str) -> CandidateError { + error("graph_plan_reconcile", message) +} + +/// Retained named data is any recorded volume that may have been created. A +/// volume still `reserved` never received a create intent, so it holds no data. +pub(super) fn retained_volume(resource: &Resource) -> bool { + resource.kind == Kind::Volume && resource.phase != "reserved" +} + +fn volume_prefix(run: &str) -> String { + format!("hkg-{run}-volume-") +} + +fn active_volume_keys(plan: &project::PlanData) -> BTreeSet { + plan.services + .values() + .filter(|service| service.active) + .flat_map(|service| service.mounts.iter()) + .filter(|mount| mount.kind == "volume") + .map(|mount| mount.source.clone()) + .collect() +} + +/// Whether the plan or receipt uses machinery that has its own narrower proof. +fn eligible(receipt: &Receipt, plan: &project::PlanData) -> bool { + receipt.normalized_input.is_some() + && receipt.initializer_cache_release.is_empty() + && receipt + .resources + .values() + .all(|resource| resource.cache.is_none() && resource.cache_provenance.is_none()) + && receipt + .source + .as_ref() + .is_none_or(|binding| binding.cache_scope.is_none() && binding.live.is_none()) + && plan + .services + .values() + .all(|service| !service.active || service.dependency_cache.is_none()) +} + +/// The ordinary strict replay derives volume names from the sorted declared set. +/// Once a reconciliation reattached data out of that order, or retains data no +/// longer declared, only this projection can restore the graph again. +fn ordinary_layout(receipt: &Receipt, plan: &project::PlanData) -> bool { + let declared = active_volume_keys(plan); + let recorded = receipt + .resources + .values() + .filter(|resource| resource.kind == Kind::Volume) + .map(|resource| (resource.key.clone(), resource.name.clone())) + .collect::>(); + recorded.len() == declared.len() + && declared.iter().enumerate().all(|(index, key)| { + recorded.get(key) == Some(&format!("{}{index}", volume_prefix(&receipt.run))) + }) +} + +/// Selects reconciliation for a reviewed plan that the strict replay path cannot +/// restore. Unchanged reviews with the ordinary layout keep strict replay; +/// graphs with dependency caches, live source or a pending source-device rebind +/// keep their dedicated hostname/shared-source proofs. +pub(super) fn applies( + receipt: &Receipt, + plan_id: &str, + plan: &project::PlanData, + next: &NormalizedInputIdentity, + source_rebind: bool, +) -> bool { + !source_rebind + && eligible(receipt, plan) + && !(receipt.plan_id == plan_id + && receipt.normalized_input.as_ref() == Some(next) + && ordinary_layout(receipt, plan)) +} + +/// Read-only proof of a reconciliation selection. Admission repeats it under the +/// provider lease and binds it to the exact retained generation before effects. +pub(super) struct Verified { + run: String, + owner: String, + namespace: String, + ownership_plan: String, + previous: NormalizedInputIdentity, + next: NormalizedInputIdentity, +} + +/// Next-attempt resources. Newly declared volumes are `reserved`, so restore +/// creates them exclusively; every other volume is reattached retained data. +pub(super) struct Projection { + pub resources: BTreeMap, +} + +/// Pure checks shared by compatibility preflight and restore admission. Callers +/// still observe every retained volume's labels and data identity themselves. +pub(super) fn prepare( + plan: &project::PlanData, + receipt: &Receipt, + next: &NormalizedInputIdentity, +) -> Result { + let previous = receipt + .normalized_input + .clone() + .ok_or_else(|| refused("Only normalized graphs can be reconciled."))?; + if !eligible(receipt, plan) { + return Err(refused( + "Dependency caches, live source and cache-scoped source use their dedicated restore proofs.", + )); + } + if !next.matches_plan(plan) + || plan.namespace != receipt.namespace + || next.namespace != receipt.namespace + || !previous.valid(&receipt.namespace) + { + return Err(refused( + "Reconciliation requires the same project instance and its reviewed input.", + )); + } + if !matches!( + receipt.phase.as_str(), + "ready-observed" | "stopped-data-retained" + ) { + return Err(refused( + "Reconciliation requires completed startup or acknowledged data retention.", + )); + } + let prefix = volume_prefix(&receipt.run); + for (key, resource) in &receipt.resources { + if key != &format!("{}:{}", resource.kind.word(), resource.key) { + return Err(refused("Retained resource keys are inconsistent.")); + } + if resource.kind != Kind::Volume { + continue; + } + if !resource + .name + .strip_prefix(&prefix) + .is_some_and(|index| index.parse::().is_ok_and(|n| n < 64)) + || !matches!(resource.phase.as_str(), "created" | "reserved") + { + return Err(refused( + "Retained data is not this run's settled owned volume; it was not reattached.", + )); + } + } + Ok(Verified { + run: receipt.run.clone(), + owner: receipt.owner.clone(), + namespace: receipt.namespace.clone(), + ownership_plan: receipt.plan_id.clone(), + previous, + next: next.clone(), + }) +} + +impl Verified { + fn matches_receipt(&self, receipt: &Receipt) -> bool { + self.run == receipt.run + && self.owner == receipt.owner + && self.namespace == receipt.namespace + && self.ownership_plan == receipt.plan_id + && receipt.normalized_input.as_ref() == Some(&self.previous) + } + + /// The fresh owner's selection must name this proof's reviewed input and the + /// exact retained generation it observed; nothing else is re-derived. + pub(super) fn verify_fresh( + &self, + receipt: &Receipt, + fresh: &NormalizedInputIdentity, + selected: &str, + observed: &str, + ) -> Result<(), CandidateError> { + if !self.matches_receipt(receipt) + || fresh != &self.next + || !hex(selected, 64) + || selected != observed + || receipt.phase != "stopped-data-retained" + { + return Err(error( + "graph_restore_refused", + "Stopped restore selection or normalized input changed; no graph effects were started.", + )); + } + Ok(()) + } + + /// Project freshly prepared compute onto this run's retained data. Labels keep + /// the ownership plan; mounts are rewritten from prepared to final volume names. + pub(super) fn project( + &self, + prepared: &mut config::Prepared, + receipt: &Receipt, + ) -> Result { + if !self.matches_receipt(receipt) || prepared.namespace != receipt.namespace { + return Err(refused("Reconciliation ownership changed.")); + } + let prefix = volume_prefix(&receipt.run); + let retained = receipt + .resources + .iter() + .filter(|(_, resource)| retained_volume(resource)) + .collect::>(); + let mut used = retained + .values() + .map(|resource| resource.name.clone()) + .collect::>(); + let mut renamed = BTreeMap::new(); + let mut resources = BTreeMap::new(); + for (key, resource) in &prepared.resources { + if resource.kind != Kind::Volume { + resources.insert(key.clone(), resource.clone()); + continue; + } + if resource.cache.is_some() { + return Err(refused( + "Dependency cache volumes use their dedicated restore proof.", + )); + } + let selected = if let Some(old) = retained.get(key) { + if old.key != resource.key || old.disposable_caches != resource.disposable_caches { + return Err(refused( + "A retained volume's disposable-cache declaration changed; it was not reattached.", + )); + } + (*old).clone() + } else { + let name = (0..64) + .map(|index| format!("{prefix}{index}")) + .find(|name| !used.contains(name)) + .ok_or_else(|| refused("No owned volume name is available."))?; + used.insert(name.clone()); + Resource { + name, + id: None, + phase: "reserved".into(), + ..resource.clone() + } + }; + if renamed + .insert(resource.name.clone(), selected.name.clone()) + .is_some() + { + return Err(refused("Prepared volume names are not unique.")); + } + resources.insert(key.clone(), selected); + } + // Retained data a changed plan no longer declares stays owned and recorded. + for (key, resource) in &retained { + resources + .entry((*key).clone()) + .or_insert_with(|| (*resource).clone()); + } + if !resource_counts_fit(&resources) || !network_bindings_valid(&resources) { + return Err(error( + "graph_budget", + "Reconciled graph exceeds eight retained and declared volumes; remove unused data explicitly.", + )); + } + for (name, config) in &mut prepared.configs { + let resource = resources + .get(&format!("container:{name}")) + .ok_or_else(|| refused("Reconciled container identity is missing."))?; + if config["Labels"] + != labels( + &receipt.owner, + &receipt.run, + &receipt.namespace, + &prepared.plan_id, + resource, + ) + { + return Err(refused("Reconciled labels differ from expected ownership.")); + } + config["Labels"] = labels( + &receipt.owner, + &receipt.run, + &receipt.namespace, + &receipt.plan_id, + resource, + ); + if let Some(mounts) = config["HostConfig"]["Mounts"].as_array_mut() { + for mount in mounts.iter_mut().filter(|m| m["Type"] == "volume") { + let source = mount["Source"] + .as_str() + .and_then(|name| renamed.get(name)) + .ok_or_else(|| refused("A mount names an unknown volume."))?; + mount["Source"] = json!(source); + } + } + } + prepared.plan_id = receipt.plan_id.clone(); + // Later admission checks read the same projected bindings as the receipt. + prepared.resources = resources.clone(); + Ok(Projection { resources }) + } + + /// Replace in-memory next-attempt state after history retention; the fresh + /// owner's admission persists it. Ownership identity is never changed. + pub(super) fn apply( + &self, + receipt: &mut Receipt, + projection: Projection, + readiness: &BTreeMap, + source: Option, + ) -> Result<(), CandidateError> { + if !self.matches_receipt(receipt) || receipt.phase != "stopped-data-retained" { + return Err(refused("Reconciliation ownership changed.")); + } + receipt.normalized_input = Some(self.next.clone()); + receipt.readiness = readiness.clone(); + receipt.source = source; + receipt.resources = projection.resources; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + const RUN: &str = "abababababababababababababababab"; + + struct Project { + _fixture: super::super::tests::Fixture, + candidate: Candidate, + root: std::path::PathBuf, + } + + impl Project { + fn new() -> Self { + let fixture = super::super::tests::Fixture::new(); + let root = fixture.0.join("project"); + fs::create_dir(&root).unwrap(); + let candidate = Candidate::discover(&fixture.0).unwrap(); + Self { + _fixture: fixture, + candidate, + root, + } + } + + fn review(&self, document: Value) -> (project::PlanReport, NormalizedInputIdentity) { + let bytes = serde_json::to_vec(&document).unwrap(); + fs::write(self.root.join("compose.yaml"), &bytes).unwrap(); + let report = project::plan( + &self.candidate, + project::PlanOptions { + branch: None, + project: &self.root, + compose_file: Path::new("compose.yaml"), + profiles: &[], + }, + ) + .unwrap(); + let identity = NormalizedInputIdentity { + namespace: report.plan.namespace.clone(), + original_compose_sha256: report + .plan + .original_compose_sha256 + .clone() + .unwrap_or_else(|| report.plan.compose_sha256.clone()), + normalized_compose_sha256: report.plan.compose_sha256.clone(), + }; + (report, identity) + } + + fn prepared( + &self, + document: Value, + ) -> ( + project::PlanReport, + NormalizedInputIdentity, + config::Prepared, + ) { + let (report, identity) = self.review(document); + let inputs = project::inputs::compile( + &self.candidate, + project::PlanOptions { + branch: None, + project: &self.root, + compose_file: Path::new("compose.yaml"), + profiles: &[], + }, + &report.plan_id, + &BTreeMap::new(), + ) + .unwrap(); + let readiness = report + .plan + .services + .iter() + .filter(|(_, service)| service.active) + .map(|(name, _)| (name.clone(), Condition::Started)) + .collect(); + let prepared = config::prepare(inputs, &readiness, RUN, &"b".repeat(32), None).unwrap(); + (report, identity, prepared) + } + } + + fn added(projection: &Projection) -> BTreeSet { + projection + .resources + .iter() + .filter(|(_, resource)| resource.kind == Kind::Volume && !retained_volume(resource)) + .map(|(key, _)| key.clone()) + .collect() + } + + fn same(left: &Resource, right: &Resource) -> bool { + serde_json::to_value(left).unwrap() == serde_json::to_value(right).unwrap() + } + + fn image(tag: char) -> String { + format!("sha256:{}", tag.to_string().repeat(64)) + } + + fn service(command: &str, tag: char, volumes: &[&str]) -> Value { + json!({ + "image": image(tag), + "network_mode": "none", + "entrypoint": ["/bin/app"], + "command": [command], + "volumes": volumes.iter().map(|v| format!("{v}:/data/{v}")).collect::>(), + }) + } + + fn compose(services: Value, volumes: &[&str]) -> Value { + json!({ + "services": services, + "volumes": volumes.iter().map(|v| (v.to_string(), json!({}))).collect::>(), + }) + } + + /// Stopped receipt exactly as the first admission of `document` recorded it: + /// containers confirmed absent, volumes created under the ownership plan. + fn stopped(prepared: &config::Prepared, identity: &NormalizedInputIdentity) -> Receipt { + let mut resources = prepared.resources.clone(); + for resource in resources.values_mut() { + resource.phase = if resource.kind == Kind::Volume { + "created" + } else { + "absent" + } + .into(); + } + Receipt { + startup_failure: None, + normalized_input: Some(identity.clone()), + relay_startup: None, + relay_cleanup: None, + probes: BTreeMap::new(), + version: 1, + run: RUN.into(), + owner: "b".repeat(32), + namespace: prepared.namespace.clone(), + plan_id: prepared.plan_id.clone(), + phase: "stopped-data-retained".into(), + environment_attached: false, + initializer_cache_release: BTreeMap::new(), + source: None, + readiness: BTreeMap::new(), + resources, + } + } + + fn mounts(prepared: &config::Prepared, service: &str) -> Vec { + prepared.configs[service]["HostConfig"]["Mounts"] + .as_array() + .unwrap() + .iter() + .map(|m| m["Source"].as_str().unwrap().to_owned()) + .collect() + } + + fn reconcile( + project: &Project, + receipt: &Receipt, + next: Value, + ) -> (config::Prepared, Projection) { + let (report, identity, mut prepared) = project.prepared(next); + assert!(applies( + receipt, + &report.plan_id, + &report.plan, + &identity, + false + )); + let proof = prepare(&report.plan, receipt, &identity).unwrap(); + let projection = proof.project(&mut prepared, receipt).unwrap(); + assert_eq!( + prepared.plan_id, receipt.plan_id, + "ownership plan is retained" + ); + assert_eq!(prepared.resources.len(), projection.resources.len()); + for (key, resource) in &projection.resources { + assert!( + same(resource, &prepared.resources[key]), + "{key} is projected" + ); + } + for (name, config) in &prepared.configs { + assert_eq!( + config["Labels"]["io.hack-local.plan"], receipt.plan_id, + "{name} keeps ownership labels" + ); + assert_eq!(config["Labels"]["io.hack-local.graph"], RUN); + } + let mut applied = receipt.clone(); + let snapshot = Projection { + resources: projection.resources.clone(), + }; + proof + .apply(&mut applied, snapshot, &BTreeMap::new(), None) + .unwrap(); + assert_eq!(applied.normalized_input.as_ref(), Some(&identity)); + assert_eq!(applied.plan_id, receipt.plan_id); + assert_eq!(applied.run, receipt.run); + (prepared, projection) + } + + fn base(project: &Project) -> (Receipt, config::Prepared) { + let (report, identity, prepared) = project.prepared(compose( + json!({"db": service("serve", 'a', &["data"]), "web": service("web", 'a', &[])}), + &["data"], + )); + let receipt = stopped(&prepared, &identity); + assert!(!applies( + &receipt, + &report.plan_id, + &report.plan, + &identity, + false + )); + (receipt, prepared) + } + + #[test] + fn changed_command_image_and_environment_reattach_the_same_volume() { + let project = Project::new(); + let (receipt, original) = base(&project); + let data = receipt.resources["volume:data"].name.clone(); + let mut command = service("serve --changed", 'a', &["data"]); + let mut environment = service("serve", 'a', &["data"]); + environment["environment"] = json!({"MODE": "changed"}); + for db in [ + command.take(), + service("serve", 'c', &["data"]), + environment, + ] { + let (prepared, projection) = reconcile( + &project, + &receipt, + compose( + json!({"db": db, "web": service("web", 'a', &[])}), + &["data"], + ), + ); + assert!(added(&projection).is_empty()); + assert!(same( + &projection.resources["volume:data"], + &receipt.resources["volume:data"] + )); + assert_eq!(mounts(&prepared, "db"), vec![data.clone()]); + assert_eq!( + projection.resources["container:db"].phase, "reserved", + "changed compute is recreated" + ); + } + assert_eq!(mounts(&original, "db"), vec![data]); + } + + #[test] + fn added_and_removed_services_recreate_compute_and_keep_data() { + let project = Project::new(); + let (receipt, _) = base(&project); + let (_, added) = reconcile( + &project, + &receipt, + compose( + json!({ + "db": service("serve", 'a', &["data"]), + "web": service("web", 'a', &[]), + "worker": service("work", 'a', &[]), + }), + &["data"], + ), + ); + assert!(added.resources.contains_key("container:worker")); + let (_, removed) = reconcile( + &project, + &receipt, + compose(json!({"db": service("serve", 'a', &["data"])}), &["data"]), + ); + assert!(!removed.resources.contains_key("container:web")); + assert!(same( + &removed.resources["volume:data"], + &receipt.resources["volume:data"] + )); + } + + #[test] + fn changed_volume_set_reattaches_by_key_creates_fresh_names_and_keeps_orphans() { + let project = Project::new(); + let (receipt, _) = base(&project); + let retained = receipt.resources["volume:data"].clone(); + // `cache` sorts first, so ordinary indexing would have reused data's name. + let (prepared, projection) = reconcile( + &project, + &receipt, + compose( + json!({"db": service("serve", 'a', &["cache", "data"])}), + &["cache", "data"], + ), + ); + assert!(same(&projection.resources["volume:data"], &retained)); + let fresh = &projection.resources["volume:cache"]; + assert_ne!(fresh.name, retained.name); + assert!(fresh.name.starts_with(&volume_prefix(RUN))); + assert_eq!(fresh.phase, "reserved"); + assert_eq!( + added(&projection), + BTreeSet::from(["volume:cache".to_owned()]) + ); + assert_eq!( + mounts(&prepared, "db"), + vec![fresh.name.clone(), retained.name.clone()] + ); + // Dropping the declaration keeps the retained data recorded and unmounted. + let (prepared, projection) = reconcile( + &project, + &receipt, + compose(json!({"db": service("serve", 'a', &[])}), &[]), + ); + assert!(same(&projection.resources["volume:data"], &retained)); + assert!(mounts(&prepared, "db").is_empty()); + // A later reconciled receipt without the ordinary layout stays on this path. + let mut later = receipt.clone(); + later.resources = projection.resources; + let (report, identity) = + project.review(compose(json!({"db": service("serve", 'a', &[])}), &[])); + later.normalized_input = Some(identity.clone()); + later.plan_id = report.plan_id.clone(); + assert!(applies( + &later, + &report.plan_id, + &report.plan, + &identity, + false + )); + } + + #[test] + fn another_instance_or_foreign_data_is_never_reattached() { + let project = Project::new(); + let (receipt, _) = base(&project); + let next = compose( + json!({"db": service("serve --changed", 'a', &["data"])}), + &["data"], + ); + let (report, identity, _) = project.prepared(next.clone()); + // Another project instance: same keys, different namespace. + let mut other = receipt.clone(); + other.namespace = "9".repeat(64); + if let Some(previous) = other.normalized_input.as_mut() { + previous.namespace = other.namespace.clone(); + } + assert_eq!( + prepare(&report.plan, &other, &identity).err().unwrap().code, + "graph_plan_reconcile" + ); + // A recorded volume name outside this run's owned names. + let mut foreign = receipt.clone(); + foreign.resources.get_mut("volume:data").unwrap().name = + format!("hkg-{}-volume-0", "c".repeat(32)); + assert_eq!( + prepare(&report.plan, &foreign, &identity) + .err() + .unwrap() + .code, + "graph_plan_reconcile" + ); + // Uncertain creation is never treated as settled data. + let mut uncertain = receipt.clone(); + uncertain.resources.get_mut("volume:data").unwrap().phase = "create-intent".into(); + assert!(prepare(&report.plan, &uncertain, &identity).is_err()); + // A proof is bound to its receipt's ownership and cannot be replayed elsewhere. + let (_, _, mut prepared) = project.prepared(next); + let proof = prepare(&report.plan, &receipt, &identity).unwrap(); + let mut moved = receipt.clone(); + moved.owner = "d".repeat(32); + assert!(proof.project(&mut prepared, &moved).is_err()); + assert!( + proof + .verify_fresh(&moved, &identity, &"1".repeat(64), &"1".repeat(64)) + .is_err() + ); + assert!( + proof + .verify_fresh(&receipt, &identity, &"1".repeat(64), &"2".repeat(64)) + .is_err() + ); + proof + .verify_fresh(&receipt, &identity, &"1".repeat(64), &"1".repeat(64)) + .unwrap(); + } + + #[test] + fn caches_live_source_and_rebind_keep_their_dedicated_paths() { + let project = Project::new(); + let (receipt, _) = base(&project); + let (report, identity) = project.review(compose( + json!({"db": service("serve --changed", 'a', &["data"])}), + &["data"], + )); + assert!(!applies( + &receipt, + &report.plan_id, + &report.plan, + &identity, + true + )); + let mut cached = receipt.clone(); + cached + .initializer_cache_release + .insert("db".into(), initializer_cache::Record::selected()); + assert!(!applies( + &cached, + &report.plan_id, + &report.plan, + &identity, + false + )); + assert!(prepare(&report.plan, &cached, &identity).is_err()); + let mut legacy = receipt; + legacy.normalized_input = None; + assert!(!applies( + &legacy, + &report.plan_id, + &report.plan, + &identity, + false + )); + } + + #[test] + fn restore_selection_binds_settled_data_and_never_adopts_a_reserved_name() { + let project = Project::new(); + let (receipt, _) = base(&project); + let (_, projection) = reconcile( + &project, + &receipt, + compose( + json!({"db": service("serve", 'a', &["cache", "data"])}), + &["cache", "data"], + ), + ); + let mut next = receipt.clone(); + next.resources = projection.resources; + let present = |_: &Resource| { + Ok(Some( + json!({"inspected": {"Name": "x"}, "directory": "1:2"}), + )) + }; + let selected = super::super::restore::observed_volumes_with(&next, |resource| { + if retained_volume(resource) { + present(resource) + } else { + Ok(None) + } + }) + .unwrap(); + assert_eq!(selected.keys().collect::>(), vec!["volume:data"]); + let adopted = super::super::restore::observed_volumes_with(&next, present).unwrap_err(); + assert_eq!(adopted.code, "graph_resource_exists"); + let missing = + super::super::restore::observed_volumes_with(&next, |_| Ok(None)).unwrap_err(); + assert_eq!(missing.code, "graph_data_missing"); + } +} diff --git a/packages/runtime-core/src/provider/graph/restore.rs b/packages/runtime-core/src/provider/graph/restore.rs index d75e4feab..511e80a05 100644 --- a/packages/runtime-core/src/provider/graph/restore.rs +++ b/packages/runtime-core/src/provider/graph/restore.rs @@ -174,6 +174,16 @@ pub(super) fn observed_volumes_with( if resource.kind != Kind::Volume { continue; } + if !plan_reconcile::retained_volume(resource) { + // Never created: it holds no data, and an existing name is not adopted. + if observe(resource)?.is_some() { + return Err(error( + "graph_resource_exists", + "A never-created volume name already resolves; it will not be adopted.", + )); + } + continue; + } let projection = observe(resource)? .ok_or_else(|| error("graph_data_missing", "Retained volume is missing."))?; let directory = projection["directory"] @@ -256,7 +266,26 @@ fn restore_inputs( "Source-device continuity requires a new foreground restore owner.", )); } - let hostname_change = if let Some(fresh) = &fresh { + // A changed reviewed plan that strict replay cannot restore is reconciled: + // compute is recreated from the review and retained data reattached by identity. + let reconcile = if let Some(fresh) = &fresh { + #[cfg(target_os = "macos")] + let rebinding = source_rebind.is_some(); + #[cfg(not(target_os = "macos"))] + let rebinding = false; + plan_reconcile::applies( + &receipt, + &inputs.review.plan_id, + &inputs.review.plan, + &fresh.identity, + rebinding, + ) + .then(|| plan_reconcile::prepare(&inputs.review.plan, &receipt, &fresh.identity)) + .transpose()? + } else { + None + }; + let hostname_change = if let Some(fresh) = fresh.as_ref().filter(|_| reconcile.is_none()) { #[cfg(target_os = "macos")] let source_receipt = source_rebind .as_ref() @@ -289,7 +318,9 @@ fn restore_inputs( restore_generation_with_source_rebind(&engine, &receipt, source_rebind.as_ref())?; #[cfg(not(target_os = "macos"))] let generation = restore_generation(&engine, &receipt)?; - if let Some(change) = &hostname_change { + if let Some(proof) = &reconcile { + proof.verify_fresh(&receipt, &fresh.identity, fresh.generation, &generation)?; + } else if let Some(change) = &hostname_change { verify_fresh_change( &receipt, &inputs.review.plan_id, @@ -320,7 +351,7 @@ fn restore_inputs( if root.join("state.pending").exists() || root.join("state.pending").is_symlink() || receipt.phase != "stopped-data-retained" - || receipt.readiness != *options.readiness + || (reconcile.is_none() && receipt.readiness != *options.readiness) { return Err(error( "graph_restore_refused", @@ -334,7 +365,24 @@ fn restore_inputs( .map_or(&receipt, |selected| selected.source_receipt()); #[cfg(not(target_os = "macos"))] let source_receipt = &receipt; - let ordinary_source = if hostname_change.is_none() { + let ordinary_source = if reconcile.is_some() { + // Exactly what a fresh admission of this review would select. Caches are + // not eligible here, so no retained cache publication may be named. + if options.source_revision.is_some() || options.live_source { + return Err(error( + "graph_plan_reconcile", + "Reconciliation selects no retained cache publication or live source.", + )); + } + source::prepare_mode( + candidate, + &engine, + &inputs.review.plan, + None, + false, + options.shared_source, + )? + } else if hostname_change.is_none() { source::prepare_replay( &engine, &inputs, @@ -384,21 +432,44 @@ fn restore_inputs( routing_enrolled: options.routing_enrolled, }, )?; - if let Some(change) = &hostname_change { - config::retain_hostname_change_ownership(&mut prepared, &receipt, change)?; + let projection = if let Some(proof) = &reconcile { + Some(proof.project(&mut prepared, &receipt)?) } else { - config::retain_replay_ownership(&mut prepared, &receipt)?; - } - if prepared.namespace != receipt.namespace - || !hostname_change.as_ref().map_or_else( - || same_resource_bindings(&prepared.resources, &receipt.resources), - |change| change.matches_resources(&prepared.resources, &receipt), - ) - { - return Err(error( - "graph_receipt", - "Restore resources differ from the reviewed graph.", - )); + if let Some(change) = &hostname_change { + config::retain_hostname_change_ownership(&mut prepared, &receipt, change)?; + } else { + config::retain_replay_ownership(&mut prepared, &receipt)?; + } + if prepared.namespace != receipt.namespace + || !hostname_change.as_ref().map_or_else( + || same_resource_bindings(&prepared.resources, &receipt.resources), + |change| change.matches_resources(&prepared.resources, &receipt), + ) + { + return Err(error( + "graph_receipt", + "Restore resources differ from the reviewed graph.", + )); + } + None + }; + if let Some(projection) = &projection { + // Routes of the reconciled graph are preflighted like a hostname change; + // foreground launch rechecks every claim atomically. + let hostnames: BTreeSet = projection + .resources + .values() + .filter_map(|resource| resource.routing.as_ref()) + .flat_map(|route| route.hostnames.iter().cloned()) + .collect(); + if !hostnames.is_empty() { + super::super::publication::check_hostname_change( + candidate, + &receipt.owner, + &receipt.run, + &hostnames, + )?; + } } check_network_intent(&engine, &prepared.resources)?; admission::check(candidate, &engine, Some(options.run_id), &prepared.configs)?; @@ -414,12 +485,18 @@ fn restore_inputs( for resource in receipt.resources.values() { let present = inspect_resource(&engine, &receipt, resource)?.is_some(); if resource.kind == Kind::Volume { - if !present { + if plan_reconcile::retained_volume(resource) && !present { return Err(error( "graph_data_missing", "Retained volume is missing; restore will not recreate it.", )); } + if !plan_reconcile::retained_volume(resource) && present { + return Err(error( + "graph_resource_exists", + "A never-created volume name already resolves; it will not be adopted.", + )); + } } else { let mut by_name = resource.clone(); by_name.id = None; @@ -507,12 +584,25 @@ fn restore_inputs( if let Some(change) = &hostname_change { change.apply(&mut receipt)?; } + let reconciled = projection.is_some(); + if let (Some(proof), Some(projection)) = (&reconcile, projection) { + proof.apply( + &mut receipt, + projection, + options.readiness, + ordinary_source + .as_ref() + .map(|inputs| inputs.binding.clone()), + )?; + } receipt.startup_failure = None; receipt.probes = probes::fresh(&engine, &prepared.configs, prepared.probes)?; receipt.phase = "restoring".into(); - for (key, resource) in &mut receipt.resources { - if resource.kind != Kind::Volume { - *resource = prepared.resources[key].clone(); + if !reconciled { + for (key, resource) in &mut receipt.resources { + if resource.kind != Kind::Volume { + *resource = prepared.resources[key].clone(); + } } } let mut session = Session { diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..661fd8634 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -40,6 +40,28 @@ const laterIncompleteMissingLockStages = [ "owner", ] as const; const contracts: readonly ModelContract[] = [ + { + name: "native-plan-reconcile", + module: "Reconcile", + states: 747_656, + invariant: "NoForeignAttach", + action: "Start", + fields: ["foreignAttach = TRUE", "unrecordedAttach = FALSE"], + additionalControls: [ + { + name: "replacement", + negative: true, + invariant: "AttachSelected", + fields: ["unrecordedAttach = TRUE", "foreignAttach = FALSE"], + }, + { + name: "drop-orphans", + negative: true, + invariant: "RetainedDataRecorded", + fields: ["lostData = TRUE", "foreignAttach = FALSE"], + }, + ], + }, { name: "pre-ready-retirement", module: "Retirement", diff --git a/src/backends/native-project-retained-images.ts b/src/backends/native-project-retained-images.ts index 2469043d6..95032a1e1 100644 --- a/src/backends/native-project-retained-images.ts +++ b/src/backends/native-project-retained-images.ts @@ -102,6 +102,16 @@ function imagesFromReceipt(opts: { } function activeReceipt(value: unknown, saved: NativeProjectRun) { + if ( + isRecord(value) && + isRecord(value.receipt) && + value.receipt.phase === "stopped-data-retained" && + value.receipt.run === saved.run + ) { + throw new ClosedCliError( + "Native restart needs an active graph; this project is stopped with data retained. Run hack up to start it, including after Compose edits." + ); + } if ( !isRecord(value) || value.journal_incomplete !== false || diff --git a/src/backends/native-project-retained.ts b/src/backends/native-project-retained.ts index eebdb2d92..577d49d29 100644 --- a/src/backends/native-project-retained.ts +++ b/src/backends/native-project-retained.ts @@ -45,9 +45,13 @@ export function confirmedNativeRetainedGraph( } expected.add(key); const observed = observations[key]; + // A `reserved` volume never received a create intent: it holds no data and + // must be absent. Every other volume is settled retained data. + const settledData = + resource.kind === "volume" && resource.phase !== "reserved"; if ( !isRecord(observed) || - observed.state !== (resource.kind === "volume" ? "present" : "absent") + observed.state !== (settledData ? "present" : "absent") ) { return false; } diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..708fcad0b 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -639,6 +639,40 @@ represents an observed abort check, not arbitrary OS signal delivery; cancellati does not reverse an already-admitted boot. There is no fairness or eventual-start claim, and a model pass is not native startup qualification. +## Retained-data handoff across plan changes + +`native-plan-reconcile/Reconcile.tla` checks how a stopped native graph hands its +named data to the next admission after a Compose edit. Two interchangeable +instances (checked up to permutation), two logical volume keys and three volume +identities explore 747,656 distinct states. Every admission recreates compute from +the current plan, so command, image and environment edits are abstracted; the model +varies only the declared volume set. A declared key that the run already records is +reattached; a newly declared key gets an exclusively created volume; recorded data +the plan no longer declares stays recorded and unmounted until explicit removal. + +| Model action | Implementation boundary | +| --- | --- | +| `Edit` | Compose edit between ups; a new review and normalized identity | +| `Select` | `provider/graph/foreground.rs::restore_selection` generation over the receipt and observed volume identities | +| `Replace` / `Substitute` | Volume replaced between selection and admission, or a foreign-labeled volume under a recorded name | +| `Admit` | `provider/graph/restore.rs::restore_inputs`: `plan_reconcile::Verified::verify_fresh` generation recheck and label/presence checks under the provider lease | +| `CreateDeclared` | `plan_reconcile::Verified::project` fresh `reserved` names, then `Session::create_resources` exclusive create | +| `Start` / `Stop` / `RemoveData` | Container creation with projected mounts; ordinary stop keeps volumes; `down --remove-data` deletes recorded volumes | + +The `negative` control adopts any live volume with a declared key and must fail +`NoForeignAttach` in a `Start` state with `foreignAttach = TRUE`. The `replacement` +control skips the generation recheck and must fail `AttachSelected` with +`unrecordedAttach = TRUE`. The `drop-orphans` control forgets undeclared retained +data and must fail `RetainedDataRecorded` with `lostData = TRUE`. + +Assumptions: admission through startup holds the provider mutation lease, so no +replacement happens inside it. A replacement with copied ownership labels before +selection is indistinguishable from the recorded volume by name and labels, as in +the implementation; the generation fence protects only the selection-to-admission +window. Dependency caches, live source and source-device rebinding keep their own +proofs and are outside this model. It does not prove engine mount behavior, crash +durability of the receipt, image resolution or application data contents. + ## Graph admission `graph-admission/Admission.tla` checks the smallest admission race: two clients diff --git a/tests/models/tla/native-plan-reconcile/Reconcile.tla b/tests/models/tla/native-plan-reconcile/Reconcile.tla new file mode 100644 index 000000000..327d3db93 --- /dev/null +++ b/tests/models/tla/native-plan-reconcile/Reconcile.tla @@ -0,0 +1,177 @@ +---------------------------- MODULE Reconcile ---------------------------- +(* Retained named-data handoff across Compose plan changes for stopped native + graphs. Each instance owns one run receipt mapping logical volume keys to + recorded volume identities. Compute is always recreated from the current + plan; only named data is selected. Broken constants are negative controls. *) +EXTENDS Naturals, FiniteSets, TLC +CONSTANTS Instances, Keys, MaxVolumes, + ReattachByKey, \* adopt any live volume with the declared key + SkipGeneration, \* admit without rechecking the selected identities + DropOrphans \* forget retained data the new plan no longer declares +Vols == 1..MaxVolumes +None == 0 +VARIABLES phase, plan, receipt, at, owner, vkey, live, data, attached, + selection, fresh, foreignAttach, unrecordedAttach, lostData +vars == <> +NoKeys == [k \in Keys |-> None] + +Init == /\ phase = [i \in Instances |-> "stopped"] + /\ plan = [i \in Instances |-> Keys] + /\ receipt = [i \in Instances |-> NoKeys] + /\ at = [i \in Instances |-> NoKeys] + /\ owner = [v \in Vols |-> None] + /\ vkey = [v \in Vols |-> "none"] + /\ live = [v \in Vols |-> FALSE] + /\ data = [v \in Vols |-> FALSE] + /\ attached = [i \in Instances |-> {}] + /\ selection = [i \in Instances |-> NoKeys] + /\ fresh = 1 + /\ foreignAttach = FALSE /\ unrecordedAttach = FALSE /\ lostData = FALSE + +\* A Compose edit changes only the declared volume set here; command, image and +\* environment edits change compute, which every admission recreates anyway. +Edit(i, p) == /\ p # plan[i] + /\ plan' = [plan EXCEPT ![i] = p] + /\ UNCHANGED <> + +\* restore-selection: bind the stopped receipt to the exact observed identities. +Select(i) == /\ phase[i] = "stopped" + /\ phase' = [phase EXCEPT ![i] = "selected"] + /\ selection' = [selection EXCEPT ![i] = at[i]] + /\ UNCHANGED <> + +\* Between selection and admission an outside actor replaces the volume behind a +\* recorded name, even with copied ownership labels: the generation fence's case. +\* Admission through startup holds the provider mutation lease, which excludes it. +Replace(i, k) == /\ phase[i] = "selected" + /\ at[i][k] # None /\ fresh <= MaxVolumes + /\ at' = [at EXCEPT ![i][k] = fresh] + /\ owner' = [owner EXCEPT ![fresh] = i] + /\ vkey' = [vkey EXCEPT ![fresh] = k] + /\ live' = [live EXCEPT ![at[i][k]] = FALSE, ![fresh] = TRUE] + /\ fresh' = fresh + 1 + /\ UNCHANGED <> + +\* While stopped, another instance's labeled volume appears under a recorded name. +\* Label verification at selection and admission must refuse it. +Substitute(i, j, k) == /\ phase[i] = "stopped" /\ j # i + /\ at[i][k] # None /\ fresh <= MaxVolumes + /\ at' = [at EXCEPT ![i][k] = fresh] + /\ owner' = [owner EXCEPT ![fresh] = j] + /\ vkey' = [vkey EXCEPT ![fresh] = k] + /\ live' = [live EXCEPT ![at[i][k]] = FALSE, ![fresh] = TRUE] + /\ fresh' = fresh + 1 + /\ UNCHANGED <> + +\* Admission rechecks the selected generation and every retained volume's presence. +Admit(i) == /\ phase[i] = "selected" + /\ IF (SkipGeneration \/ at[i] = selection[i]) + /\ \A k \in Keys : receipt[i][k] # None => + live[at[i][k]] /\ owner[at[i][k]] = i + THEN phase' = [phase EXCEPT ![i] = "admitting"] + ELSE phase' = [phase EXCEPT ![i] = "stopped"] + /\ UNCHANGED <> + +\* A newly declared key gets a fresh owned volume through exclusive create. +CreateDeclared(i, k) == + /\ phase[i] = "admitting" /\ k \in plan[i] /\ receipt[i][k] = None + /\ IF ReattachByKey /\ \E v \in Vols : live[v] /\ vkey[v] = k + THEN LET v == CHOOSE v \in Vols : live[v] /\ vkey[v] = k IN + /\ receipt' = [receipt EXCEPT ![i][k] = v] + /\ at' = [at EXCEPT ![i][k] = v] + /\ UNCHANGED <> + ELSE /\ fresh <= MaxVolumes + /\ receipt' = [receipt EXCEPT ![i][k] = fresh] + /\ at' = [at EXCEPT ![i][k] = fresh] + /\ owner' = [owner EXCEPT ![fresh] = i] + /\ vkey' = [vkey EXCEPT ![fresh] = k] + /\ live' = [live EXCEPT ![fresh] = TRUE] + /\ fresh' = fresh + 1 + /\ UNCHANGED <> + +\* Startup mounts exactly the declared keys; retained undeclared data stays recorded. +Start(i) == + /\ phase[i] = "admitting" + /\ \A k \in plan[i] : receipt[i][k] # None + /\ LET mounted == {at[i][k] : k \in plan[i]} + kept == IF DropOrphans + THEN [k \in Keys |-> IF k \in plan[i] THEN receipt[i][k] ELSE None] + ELSE receipt[i] + dropped == {at[i][k] : k \in {k \in Keys : kept[k] = None /\ receipt[i][k] # None}} + IN /\ attached' = [attached EXCEPT ![i] = mounted] + /\ receipt' = [receipt EXCEPT ![i] = kept] + /\ at' = [at EXCEPT ![i] = [k \in Keys |-> IF kept[k] = None THEN None ELSE at[i][k]]] + /\ phase' = [phase EXCEPT ![i] = "running"] + /\ foreignAttach' = (foreignAttach \/ \E v \in mounted : owner[v] # i) + /\ unrecordedAttach' = (unrecordedAttach \/ + \E k \in plan[i] : selection[i][k] # None /\ at[i][k] # selection[i][k]) + /\ lostData' = (lostData \/ \E v \in dropped : live[v] /\ data[v]) + /\ UNCHANGED <> + +\* Failure before startup completes: cleanup keeps every created volume recorded. +Fail(i) == /\ phase[i] \in {"selected", "admitting"} + /\ phase' = [phase EXCEPT ![i] = "stopped"] + /\ UNCHANGED <> + +Write(i) == /\ phase[i] = "running" /\ attached[i] # {} + /\ \E v \in attached[i] : ~data[v] + /\ data' = [v \in Vols |-> data[v] \/ v \in attached[i]] + /\ UNCHANGED <> + +\* Ctrl-C or down: compute is removed, all recorded data is retained. +Stop(i) == /\ phase[i] = "running" + /\ phase' = [phase EXCEPT ![i] = "stopped"] + /\ attached' = [attached EXCEPT ![i] = {}] + /\ UNCHANGED <> + +\* Explicit data removal is the only intended deletion of retained volumes. +RemoveData(i) == /\ phase[i] = "stopped" + /\ \E k \in Keys : receipt[i][k] # None + /\ live' = [v \in Vols |-> live[v] /\ \A k \in Keys : at[i][k] # v] + /\ data' = [v \in Vols |-> data[v] /\ \A k \in Keys : at[i][k] # v] + /\ receipt' = [receipt EXCEPT ![i] = NoKeys] + /\ at' = [at EXCEPT ![i] = NoKeys] + /\ UNCHANGED <> + +Next == \E i \in Instances : + \/ \E p \in SUBSET Keys : Edit(i, p) + \/ Select(i) \/ Admit(i) \/ Start(i) \/ Fail(i) + \/ Write(i) \/ Stop(i) \/ RemoveData(i) + \/ \E k \in Keys : Replace(i, k) \/ CreateDeclared(i, k) + \/ \E j \in Instances, k \in Keys : Substitute(i, j, k) +Spec == Init /\ [][Next]_vars +\* Instances are interchangeable model values; checked up to their permutation. +InstanceSymmetry == Permutations(Instances) + +TypeOK == /\ phase \in [Instances -> {"stopped", "selected", "admitting", "running"}] + /\ plan \in [Instances -> SUBSET Keys] + /\ receipt \in [Instances -> [Keys -> 0..MaxVolumes]] + /\ at \in [Instances -> [Keys -> 0..MaxVolumes]] + /\ owner \in [Vols -> {None} \cup Instances] + /\ live \in [Vols -> BOOLEAN] /\ data \in [Vols -> BOOLEAN] + /\ attached \in [Instances -> SUBSET Vols] + /\ fresh \in 1..MaxVolumes + 1 + +\* Data is mounted only by the instance that owns it. +NoForeignAttach == /\ ~foreignAttach + /\ \A i \in Instances : \A v \in attached[i] : owner[v] = i +\* Retained data is mounted only under the identity bound at selection; data +\* first declared by this admission is the exclusively created volume. +AttachSelected == /\ ~unrecordedAttach + /\ \A i \in Instances : \A v \in attached[i] : \E k \in Keys : at[i][k] = v +\* Retained data stays recorded by its owner until explicit removal. +RetainedDataRecorded == /\ ~lostData + /\ \A v \in Vols : (live[v] /\ data[v]) => + \E k \in Keys : at[owner[v]][k] = v +============================================================================= diff --git a/tests/models/tla/native-plan-reconcile/drop-orphans.cfg b/tests/models/tla/native-plan-reconcile/drop-orphans.cfg new file mode 100644 index 000000000..59dd39981 --- /dev/null +++ b/tests/models/tla/native-plan-reconcile/drop-orphans.cfg @@ -0,0 +1,5 @@ +SPECIFICATION Spec +SYMMETRY InstanceSymmetry +CONSTANTS Instances = {i1, i2} Keys = {"a", "b"} MaxVolumes = 3 + ReattachByKey = FALSE SkipGeneration = FALSE DropOrphans = TRUE +INVARIANTS TypeOK NoForeignAttach AttachSelected RetainedDataRecorded diff --git a/tests/models/tla/native-plan-reconcile/negative.cfg b/tests/models/tla/native-plan-reconcile/negative.cfg new file mode 100644 index 000000000..ae752f0ee --- /dev/null +++ b/tests/models/tla/native-plan-reconcile/negative.cfg @@ -0,0 +1,5 @@ +SPECIFICATION Spec +SYMMETRY InstanceSymmetry +CONSTANTS Instances = {i1, i2} Keys = {"a", "b"} MaxVolumes = 3 + ReattachByKey = TRUE SkipGeneration = FALSE DropOrphans = FALSE +INVARIANTS TypeOK NoForeignAttach AttachSelected RetainedDataRecorded diff --git a/tests/models/tla/native-plan-reconcile/positive.cfg b/tests/models/tla/native-plan-reconcile/positive.cfg new file mode 100644 index 000000000..13e57d581 --- /dev/null +++ b/tests/models/tla/native-plan-reconcile/positive.cfg @@ -0,0 +1,5 @@ +SPECIFICATION Spec +SYMMETRY InstanceSymmetry +CONSTANTS Instances = {i1, i2} Keys = {"a", "b"} MaxVolumes = 3 + ReattachByKey = FALSE SkipGeneration = FALSE DropOrphans = FALSE +INVARIANTS TypeOK NoForeignAttach AttachSelected RetainedDataRecorded diff --git a/tests/models/tla/native-plan-reconcile/replacement.cfg b/tests/models/tla/native-plan-reconcile/replacement.cfg new file mode 100644 index 000000000..f4643fbc3 --- /dev/null +++ b/tests/models/tla/native-plan-reconcile/replacement.cfg @@ -0,0 +1,5 @@ +SPECIFICATION Spec +SYMMETRY InstanceSymmetry +CONSTANTS Instances = {i1, i2} Keys = {"a", "b"} MaxVolumes = 3 + ReattachByKey = FALSE SkipGeneration = TRUE DropOrphans = FALSE +INVARIANTS TypeOK NoForeignAttach AttachSelected RetainedDataRecorded diff --git a/tests/native-project-restore.test.ts b/tests/native-project-restore.test.ts index c1dbee853..4eb41a5dd 100644 --- a/tests/native-project-restore.test.ts +++ b/tests/native-project-restore.test.ts @@ -187,3 +187,39 @@ test("legacy review refuses a different valid restore generation", async () => { }) ).rejects.toThrow("restore selection changed"); }); + +test("an edited Compose plan restores the same run after the native reconcile proof", async () => { + const review = { + ...options.review, + planId: "f".repeat(64), + report: { plan: { services: { web: { active: true } } } }, + }; + const calls: string[] = []; + const result = await selectNativeProjectRestore({ + ...options, + review, + restore: saved, + invoke: async ({ args }) => { + calls.push(args[1] ?? ""); + if (args[1] === "restore-selection") { + return selected; + } + return { + run: saved.run, + owner: saved.owner, + namespace: saved.namespace, + plan: saved.planId, + reviewed_plan: review.planId, + source_revision: null, + reconcile: true, + }; + }, + }); + expect(calls).toEqual(["restore-selection", "source-compatibility"]); + // The ownership plan stays the run's; the reviewed plan is admitted under it. + expect(result).toEqual({ + run: saved.run, + restoring: true, + flags: ["--expect-generation", selected.generation], + }); +}); diff --git a/tests/native-project-retained-images.test.ts b/tests/native-project-retained-images.test.ts index 39129db46..816bc0359 100644 --- a/tests/native-project-retained-images.test.ts +++ b/tests/native-project-retained-images.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { confirmedNativeRetainedGraph } from "../src/backends/native-project-retained.ts"; import { selectNativeActiveImages, selectNativeRetainedImages, @@ -406,3 +407,55 @@ test("malformed provenance or image and mismatched resource keys refuse reuse", ); } }); + +test("a reconciled stopped graph's never-created volume must be absent, settled data present", () => { + const baseline = observation(); + const reconciled = { + ...baseline, + receipt: { + ...baseline.receipt, + resources: { + ...baseline.receipt.resources, + "volume:added": { kind: "volume", key: "added", phase: "reserved" }, + }, + }, + observations: { + ...baseline.observations, + "volume:added": { state: "absent" }, + }, + }; + expect(confirmedNativeRetainedGraph(reconciled, saved)).toBe(true); + expect( + confirmedNativeRetainedGraph( + { + ...reconciled, + observations: { + ...reconciled.observations, + "volume:added": { state: "present" }, + }, + }, + saved + ) + ).toBe(false); + expect( + confirmedNativeRetainedGraph( + { + ...reconciled, + observations: { + ...reconciled.observations, + "volume:data": { state: "absent" }, + }, + }, + saved + ) + ).toBe(false); +}); +test("restart of a stopped graph points at up instead of reporting image drift", async () => { + await expectClosedRefusal( + selectNativeActiveImages({ + ...options, + invoke: async () => observation(), + }), + "stopped with data retained. Run hack up" + ); +}); From 1453ee6b7af16f46f01656972f420268b6d219ed Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:37:10 -0500 Subject: [PATCH 058/138] fix(native): keep registry credentials and connections on admitted origins Authentication challenges: a 401 or 403 is acted on only when the final response URL is the registry origin. A redirected storage host that refuses access now fails with registry_redirect_refused and never triggers a token or credential request. Docker credentials (Basic header or identity refresh token) are sent only to Docker Hub's fixed realm, a realm on the registry origin, or HTTPS on port 443 at the registry's own host name or a parent domain of it; any other realm refuses with registry_realm_refused before credentials are read. Anonymous token requests to a public HTTPS realm are unchanged. Addresses: the registry client now resolves every name through a guarded resolver that refuses loopback, private (RFC 1918, ULA), link-local, CGNAT, multicast, unspecified and reserved addresses, including IPv4-mapped, compatible, NAT64 and 6to4 forms, unless the name is the host of an explicitly allowed local registry. One client serves the registry origin, redirects and token realms, so the policy holds for the address actually connected to. IP literals, which bypass the resolver, are checked before each request. Refusals report registry_local_address. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/Cargo.lock | 1 + packages/runtime-core/Cargo.toml | 1 + .../runtime-core/src/provider/image_policy.rs | 4 + .../src/provider/registry_image.rs | 350 ++++++++++++++++-- .../src/provider/registry_image_test.rs | 330 ++++++++++++++++- 5 files changed, 649 insertions(+), 37 deletions(-) diff --git a/packages/runtime-core/Cargo.lock b/packages/runtime-core/Cargo.lock index 156bcafff..30e0708d0 100644 --- a/packages/runtime-core/Cargo.lock +++ b/packages/runtime-core/Cargo.lock @@ -397,6 +397,7 @@ dependencies = [ "serde_yaml_ng", "sha2", "tar", + "tokio", "unicode-normalization", "zeroize", ] diff --git a/packages/runtime-core/Cargo.toml b/packages/runtime-core/Cargo.toml index 4379e2abf..4d3208a33 100644 --- a/packages/runtime-core/Cargo.toml +++ b/packages/runtime-core/Cargo.toml @@ -54,6 +54,7 @@ unicode-normalization = "=0.1.25" notify = "=8.2.0" flate2 = { version = "=1.1.10", default-features = false, features = ["rust_backend"] } ruzstd = "=0.8.2" +tokio = { version = "1", default-features = false, features = ["rt"] } [profile.release] lto = "thin" diff --git a/packages/runtime-core/src/provider/image_policy.rs b/packages/runtime-core/src/provider/image_policy.rs index e7c13d9a5..0c2d09dd5 100644 --- a/packages/runtime-core/src/provider/image_policy.rs +++ b/packages/runtime-core/src/provider/image_policy.rs @@ -54,6 +54,9 @@ pub struct ImagePolicy { pub docker_config: Option, /// Directories searched for `docker-credential-*` helpers. `None` means `PATH`. pub(crate) helper_path: Option>, + /// Host-name lookup for registry connections. `None` means the system resolver; only + /// tests replace it. + pub(crate) host_lookup: Option, } impl Default for ImagePolicy { @@ -65,6 +68,7 @@ impl Default for ImagePolicy { local_registries: Vec::new(), docker_config: None, helper_path: None, + host_lookup: None, } } } diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 5ad8bf1d8..d53b5be1f 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -8,8 +8,15 @@ //! - **Authentication.** Requests start anonymous. A `401` challenge is answered with an //! anonymous Bearer token first, and only then with the host user's existing Docker //! credentials (read-only; see `registry_credentials`). Credentials and tokens stay in -//! zeroizing memory for one acquisition, are sent only to the registry origin or its -//! token realm, and never appear in output or errors. +//! zeroizing memory for one acquisition and never appear in output or errors. Only a +//! challenge from the registry origin itself is answered, and credentials go only to +//! the registry origin or a token realm on the registry's own host or a parent domain +//! of it (Docker Hub: `auth.docker.io`). +//! - **Addresses.** Local and private destinations are refused by the address actually +//! connected to, not only by host name: a custom resolver refuses names that resolve +//! to loopback, private, link-local, CGNAT, multicast or unspecified addresses +//! (including IPv4-mapped forms), and IP literals are checked before each request. +//! Only an explicitly allowed local registry's own origin may reach such addresses. //! - **Content.** OCI and Docker v2 indexes and manifests; gzip, zstd and uncompressed //! layers. Every manifest, config and layer is checked against its descriptor digest //! and size while streaming, and the assembled archive is validated again before it is @@ -23,6 +30,7 @@ use crate::CandidateError; use reqwest::{ StatusCode, Url, blocking::{Client, Response}, + dns::{Addrs, Name, Resolve, Resolving}, header::{ACCEPT, AUTHORIZATION, HeaderValue, LOCATION, WWW_AUTHENTICATE}, redirect::Policy, }; @@ -31,9 +39,13 @@ use sha2::{Digest, Sha256}; use std::{ fs::{self, File, OpenOptions}, io::{Read, Write}, + net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs}, os::unix::fs::OpenOptionsExt, path::Path, - sync::atomic::{AtomicU64, Ordering}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; use zeroize::Zeroizing; @@ -92,26 +104,154 @@ fn ipv4(host: &str) -> Option<[u8; 4]> { parts.next().is_none().then_some(octets) } +fn public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, ..] = ip.octets(); + !(a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..128).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..32).contains(&b)) + || (a == 192 && b == 168) + || a >= 224) +} + +/// Whether `ip` is a public unicast address. Loopback, unspecified, private (RFC 1918 and +/// IPv6 ULA `fc00::/7`), link-local (`169.254.0.0/16`, `fe80::/10`), deprecated +/// site-local (`fec0::/10`), CGNAT (`100.64.0.0/10`), multicast, broadcast and reserved +/// addresses are not, nor are IPv6 forms that embed such an IPv4 address (mapped, +/// compatible, NAT64 `64:ff9b::/96` and 6to4 `2002::/16`). +pub(super) fn public_ip(ip: IpAddr) -> bool { + let ip = match ip { + IpAddr::V4(ip) => return public_ipv4(ip), + IpAddr::V6(ip) => ip, + }; + let segments = ip.segments(); + let embedded = |high: u16, low: u16| { + Ipv4Addr::new((high >> 8) as u8, high as u8, (low >> 8) as u8, low as u8) + }; + if let Some(v4) = ip.to_ipv4_mapped() { + return public_ipv4(v4); + } + if segments[..6] == [0; 6] { + // `::`, `::1` and the deprecated IPv4-compatible `::a.b.c.d`. + return !ip.is_unspecified() + && !ip.is_loopback() + && public_ipv4(embedded(segments[6], segments[7])); + } + if segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0] { + return public_ipv4(embedded(segments[6], segments[7])); + } + if segments[0] == 0x2002 { + return public_ipv4(embedded(segments[1], segments[2])); + } + !(ip.is_multicast() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] & 0xffc0) == 0xfec0) +} + /// Hosts that are not public registries: loopback names, non-public IPv4 literals, /// single-label names and mDNS `.local` names. They resolve on this host or its network, -/// so acquisition refuses them unless the policy names the registry explicitly. +/// so acquisition refuses them unless the policy names the registry explicitly. Public +/// names that merely resolve to local addresses are refused by [`GuardedResolver`]. pub(super) fn local_host(host: &str) -> bool { if host == "localhost" || host.ends_with(".localhost") || host.ends_with(".local") { return true; } - if let Some([a, b, ..]) = ipv4(host) { - return a == 0 - || a == 10 - || a == 127 - || (a == 100 && (64..128).contains(&b)) - || (a == 169 && b == 254) - || (a == 172 && (16..32).contains(&b)) - || (a == 192 && b == 168) - || a >= 224; + if let Some(octets) = ipv4(host) { + return !public_ipv4(Ipv4Addr::from(octets)); } !host.contains('.') } +/// Host-name lookup used by [`GuardedResolver`]; tests inject a fixed table so no real +/// DNS is consulted. +pub(crate) type LookupFn = dyn Fn(&str) -> std::io::Result> + Send + Sync; + +/// An injectable host-name lookup for the registry client. +#[derive(Clone)] +pub(crate) struct HostLookup(pub(crate) Arc); + +impl std::fmt::Debug for HostLookup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HostLookup") + } +} + +fn system_lookup(host: &str) -> std::io::Result> { + Ok((host, 0) + .to_socket_addrs()? + .map(|address| address.ip()) + .collect()) +} + +/// A resolution refused because the name resolved to a local or private address. +#[derive(Debug)] +struct LocalAddressRefused; + +impl std::fmt::Display for LocalAddressRefused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("registry host resolved to a local or private address") + } +} + +impl std::error::Error for LocalAddressRefused {} + +fn local_address_refused() -> CandidateError { + CandidateError::new( + "registry_local_address", + "A registry, redirect or token host resolved to a local or private network address; allow a local registry explicitly with --allow-local-registry.", + ) +} + +/// The registry client's resolver. Every connection the client makes (registry origin, +/// redirects and token realms) resolves through it, so the local-address policy holds +/// for the address actually connected to. A name that resolves to any non-public address +/// is refused as a whole, except the host of an explicitly allowed local registry. +struct GuardedResolver { + /// Lowercase host name of the explicitly allowed local registry being fetched. + allowed_host: Option, + lookup: Arc, +} + +impl Resolve for GuardedResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_ascii_lowercase(); + let allowed = self.allowed_host.as_deref() == Some(host.as_str()); + let lookup = self.lookup.clone(); + Box::pin(async move { + let addresses = tokio::task::spawn_blocking(move || lookup(&host)).await??; + if addresses.is_empty() { + return Err("registry host has no addresses".into()); + } + if !allowed && !addresses.iter().all(|ip| public_ip(*ip)) { + return Err(Box::new(LocalAddressRefused) as _); + } + let addresses: Addrs = Box::new( + addresses + .into_iter() + .map(|ip| SocketAddr::new(ip, 0)) + .collect::>() + .into_iter(), + ); + Ok(addresses) + }) + } +} + +/// A transport failure, naming a local-address refusal when the resolver made one. +fn transport_error(failure: &reqwest::Error, message: &str) -> CandidateError { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(failure); + while let Some(cause) = source { + if cause.is::() { + return local_address_refused(); + } + source = cause.source(); + } + error(message) +} + /// The reference grammar's `host[:port]` syntax, for local registry opt-ins. pub(super) fn local_domain_syntax(domain: &str) -> Result<(), ()> { super::image_reference::domain(domain).map_err(|_| ()) @@ -124,6 +264,9 @@ pub(super) struct Registry { domain: String, /// `scheme://host[:port]` of the distribution API. origin: Url, + /// True when the policy explicitly allows this registry as a local registry; only + /// then may its own origin reach local or private addresses. + local: bool, } impl Registry { @@ -142,6 +285,7 @@ fn registry(named: &NamedReference, policy: &ImagePolicy) -> Result Result bool { || h.ends_with(".r2.cloudflarestorage.com") }) } +/// The IP address of a URL whose host is an IP literal. +fn literal_ip(url: &Url) -> Option { + let host = url.host_str()?; + host.strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host) + .parse() + .ok() +} + +/// The lowercase host name of a URL whose host is a domain, not an IP literal. +fn domain_host(url: &Url) -> Option<&str> { + url.host_str().filter(|_| literal_ip(url).is_none()) +} + fn same_origin(a: &Url, b: &Url) -> bool { a.scheme() == b.scheme() && a.host_str() == b.host_str() && a.port_or_known_default() == b.port_or_known_default() } + +#[cfg(test)] +thread_local! { + /// Loopback fixture origins that tests treat as public HTTPS hosts, so redirect and + /// realm handling for foreign hosts can be exercised without TLS or public networks. + pub(super) static TEST_PUBLIC_ORIGINS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +fn test_public_origin(_url: &Url) -> bool { + #[cfg(test)] + { + TEST_PUBLIC_ORIGINS.with(|origins| origins.borrow().iter().any(|o| same_origin(o, _url))) + } + #[cfg(not(test))] + { + false + } +} + +/// A public HTTPS origin on port 443 without embedded credentials: not a local name, an +/// IPv6 literal or a non-public IPv4 literal. +fn public_https_origin(url: &Url) -> bool { + if test_public_origin(url) { + return true; + } + url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none() + && url + .host_str() + .is_some_and(|host| !host.starts_with('[') && !local_host(host)) +} /// Redirect policy: the registry's own origin, Docker Hub's known blob origins, or for /// other registries any public HTTPS origin on port 443 (registries commonly serve blobs /// from object storage). Local destinations and embedded credentials always refuse. @@ -352,15 +546,12 @@ fn redirect_allowed(registry: &Registry, url: &Url) -> bool { if registry.docker_hub() { return docker_hub_redirect_allowed(url); } - url.scheme() == "https" - && url.port_or_known_default() == Some(443) - && url - .host_str() - .is_some_and(|host| !host.starts_with('[') && !local_host(host)) + public_https_origin(url) } -/// A token realm must be the registry origin itself, or a public HTTPS endpoint; Docker -/// Hub's realm is fixed. +/// A token realm may receive an anonymous token request when it is the registry origin +/// itself or a public HTTPS endpoint; Docker Hub's realm is fixed. See +/// [`credential_realm_allowed`] for realms that may receive credentials. fn realm_allowed(registry: &Registry, realm: &Url) -> bool { if !realm.username().is_empty() || realm.password().is_some() { return false; @@ -368,11 +559,36 @@ fn realm_allowed(registry: &Registry, realm: &Url) -> bool { if registry.docker_hub() { return realm.as_str() == DOCKER_HUB_REALM; } - same_origin(®istry.origin, realm) - || (realm.scheme() == "https" - && realm - .host_str() - .is_some_and(|h| !local_host(h) && !h.starts_with('['))) + same_origin(®istry.origin, realm) || public_https_origin(realm) +} + +/// A token realm that may receive the host user's Docker credentials (a Basic header or +/// an identity refresh token). Docker Hub's realm is fixed to `auth.docker.io`. For other +/// registries the realm must be the registry origin, or HTTPS on port 443 at the +/// registry's own host name or a parent domain of it with at least two labels (for +/// example `gitlab.com` for `registry.gitlab.com`): whoever controls a parent domain can +/// already direct the registry's own name. Sibling hosts, unrelated hosts and IP +/// literals never receive credentials; without a public-suffix list a shared registrable +/// domain cannot be proven, so it is not assumed. +fn credential_realm_allowed(registry: &Registry, realm: &Url) -> bool { + if !realm_allowed(registry, realm) { + return false; + } + if registry.docker_hub() || same_origin(®istry.origin, realm) { + return true; + } + let (Some(realm_host), Some(registry_host)) = + (domain_host(realm), domain_host(®istry.origin)) + else { + return false; + }; + realm.scheme() == "https" + && realm.port_or_known_default() == Some(443) + && realm_host.contains('.') + && (registry_host == realm_host + || registry_host + .strip_suffix(realm_host) + .is_some_and(|prefix| prefix.ends_with('.'))) } /// One `WWW-Authenticate` challenge: scheme and parameters. @@ -458,10 +674,23 @@ impl<'a> Fetch<'a> { path: &str, policy: &'a ImagePolicy, ) -> Result { + let resolver = GuardedResolver { + allowed_host: registry + .local + .then(|| domain_host(®istry.origin).map(str::to_ascii_lowercase)) + .flatten(), + lookup: policy + .host_lookup + .as_ref() + .map_or_else(|| Arc::new(system_lookup) as Arc, |l| l.0.clone()), + }; + // One client serves every request of the acquisition (registry origin, redirects + // and token realms), so the resolver and the destination check cover them all. let client = Client::builder() .https_only(registry.origin.scheme() == "https") .no_proxy() .redirect(Policy::none()) + .dns_resolver(Arc::new(resolver)) .connect_timeout(Duration::from_secs(10)) .build() .map_err(|_| error("Cannot initialize registry TLS client."))?; @@ -489,10 +718,33 @@ impl<'a> Fetch<'a> { .map_err(|_| error("Invalid registry object address.")) } + /// Whether a request may be sent to `url` by address: an IP literal must be public + /// (the resolver is not consulted for literals), except an explicitly allowed local + /// registry's own origin. That registry's host name is accepted only at its origin. + fn destination_allowed(&self, url: &Url) -> bool { + if test_public_origin(url) { + return true; + } + let origin = same_origin(&self.registry.origin, url); + if self.registry.local && origin { + return true; + } + match (literal_ip(url), url.host_str()) { + (Some(ip), _) => public_ip(ip), + (None, Some(host)) => { + origin || !self.registry.local || self.registry.origin.host_str() != Some(host) + } + (None, None) => false, + } + } + /// One request with redirects followed under [`redirect_allowed`]. Authorization is /// attached only while the request targets the registry origin. fn send(&self, mut url: Url, timeout: Duration) -> Result { for hop in 0..=4 { + if !self.destination_allowed(&url) { + return Err(local_address_refused()); + } let mut request = self .client .get(url.clone()) @@ -510,7 +762,7 @@ impl<'a> Fetch<'a> { } let response = request .send() - .map_err(|_| error("Registry request failed or timed out."))?; + .map_err(|e| transport_error(&e, "Registry request failed or timed out."))?; if !response.status().is_redirection() { return Ok(response); } @@ -534,11 +786,21 @@ impl<'a> Fetch<'a> { } /// A successful response for `url`, answering at most two authentication challenges: - /// anonymously first, then with the host user's Docker credentials. + /// anonymously first, then with the host user's Docker credentials. Only a challenge + /// from the registry origin itself is answered; a redirected host that refuses access + /// fails without any token or credential request. fn get(&mut self, url: Url, timeout: Duration) -> Result { loop { let response = self.send(url.clone(), timeout)?; let status = response.status(); + if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) + && !same_origin(&self.registry.origin, response.url()) + { + return Err(CandidateError::new( + "registry_redirect_refused", + "A redirected registry storage host refused access; authentication is answered only for the registry origin.", + )); + } if status == StatusCode::UNAUTHORIZED && self.stage != Stage::Credentialed { let challenge = response .headers() @@ -566,9 +828,29 @@ impl<'a> Fetch<'a> { } } - /// Advance one authentication stage for `challenge`. + /// Advance one authentication stage for `challenge`, which came from the registry + /// origin. Credentials are looked up only after the realm that would receive them + /// passes [`credential_realm_allowed`]. fn authorize(&mut self, challenge: &Challenge) -> Result<(), CandidateError> { let credentialed = self.stage == Stage::AnonymousToken || !challenge.bearer; + let realm = if challenge.bearer { + let realm = challenge + .realm + .as_deref() + .and_then(|r| Url::parse(r).ok()) + .filter(|r| realm_allowed(&self.registry, r)) + .ok_or_else(|| error("The registry token endpoint is not supported."))?; + if credentialed && !credential_realm_allowed(&self.registry, &realm) { + self.stage = Stage::Credentialed; + return Err(CandidateError::new( + "registry_realm_refused", + "The registry token endpoint is not on the registry's own host or a parent domain, so Docker credentials are not sent to it.", + )); + } + Some(realm) + } else { + None + }; let credential = if credentialed { self.stage = Stage::Credentialed; Some( @@ -592,12 +874,7 @@ impl<'a> Fetch<'a> { self.authorization = Some(basic); return Ok(()); } - let realm = challenge - .realm - .as_deref() - .and_then(|r| Url::parse(r).ok()) - .filter(|r| realm_allowed(&self.registry, r)) - .ok_or_else(|| error("The registry token endpoint is not supported."))?; + let realm = realm.ok_or_else(|| error("The registry token endpoint is not supported."))?; let token = match self.token(&realm, challenge.service.as_deref(), credential.as_ref()) { Err(failure) if credential.is_none() && failure.code == "registry_token_refused" => { // An endpoint that refuses anonymous tokens needs credentials. @@ -615,6 +892,9 @@ impl<'a> Fetch<'a> { service: Option<&str>, credential: Option<&Credential>, ) -> Result, CandidateError> { + if !self.destination_allowed(realm) { + return Err(local_address_refused()); + } let scope = format!("repository:{}:pull", self.path); let request = match credential { Some(Credential::IdentityToken(refresh)) => { @@ -651,7 +931,7 @@ impl<'a> Fetch<'a> { let response = request .timeout(self.remaining()?.min(REQUEST_TIMEOUT)) .send() - .map_err(|_| error("Registry token request failed or timed out."))?; + .map_err(|e| transport_error(&e, "Registry token request failed or timed out."))?; if matches!( response.status(), StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs index 5a023cc04..97c6b0edf 100644 --- a/packages/runtime-core/src/provider/registry_image_test.rs +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -10,7 +10,7 @@ use base64::Engine as _; use std::{ collections::BTreeMap, io::{BufRead, BufReader}, - net::{TcpListener, TcpStream}, + net::{IpAddr, TcpListener, TcpStream}, path::PathBuf, sync::{ Arc, Mutex, @@ -186,8 +186,17 @@ fn read_request(stream: &TcpStream) -> Option { }) } +/// A fake server's reply to one request, given the server's own port. +type Handler = Box Reply + Send>; + impl Registry { fn start(content: Content, auth: Auth, tamper: bool) -> Self { + Self::serve(Box::new(move |request, port| { + respond(&content, auth, tamper, port, request) + })) + } + + fn serve(handler: Handler) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let log = Arc::new(Mutex::new(Vec::new())); @@ -203,7 +212,7 @@ impl Registry { continue; }; thread_log.lock().unwrap().push(request.clone()); - let reply = respond(&content, auth, tamper, port, &request); + let reply = handler(&request, port); let mut head = format!( "HTTP/1.1 {} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n", reply.status, @@ -229,6 +238,10 @@ impl Registry { format!("127.0.0.1:{}", self.port) } + fn origin(&self) -> String { + format!("http://{}", self.domain()) + } + fn policy(&self, docker: &DockerFixture) -> ImagePolicy { ImagePolicy { local_registries: vec![LocalRegistry { @@ -760,6 +773,319 @@ fn redirects_and_token_realms_cannot_downgrade_or_reach_local_origins() { } } +/// Treats `origins` as public HTTPS hosts for this test thread, standing in for foreign +/// storage and token hosts that the loopback fixtures cannot serve over TLS. +struct PublicOrigins; +impl PublicOrigins { + fn new(origins: &[&Registry]) -> Self { + TEST_PUBLIC_ORIGINS.with(|list| { + *list.borrow_mut() = origins + .iter() + .map(|r| Url::parse(&r.origin()).unwrap()) + .collect(); + }); + Self + } +} +impl Drop for PublicOrigins { + fn drop(&mut self) { + TEST_PUBLIC_ORIGINS.with(|list| list.borrow_mut().clear()); + } +} + +fn credentials_for(registry: &Registry) -> DockerFixture { + DockerFixture::new(&serde_json::json!({"auths": { + registry.domain(): {"auth": base64::engine::general_purpose::STANDARD.encode(format!("{USER}:{PASSWORD}"))} + }})) +} + +#[test] +fn a_redirected_storage_host_challenge_never_reaches_a_token_realm_or_credentials() { + let content = content(); + // The foreign storage host refuses every request with a Bearer realm it controls. + let storage = Registry::serve(Box::new(|request, port| { + if request.path.starts_with("/token") { + return Reply::new(200, r#"{"token":"storage-token"}"#); + } + Reply::new(401, "{}").header( + "WWW-Authenticate", + format!(r#"Bearer realm="http://127.0.0.1:{port}/token",service="storage""#), + ) + })); + let storage_origin = storage.origin(); + let served = content.clone(); + let registry = Registry::serve(Box::new(move |request, port| { + let layer = request + .path + .strip_prefix(&format!("/v2/{REPOSITORY}/blobs/")) + .filter(|blob| served.layer_digests.iter().any(|d| d == blob)); + match layer { + Some(blob) => { + Reply::new(307, "").header("Location", format!("{storage_origin}/storage/{blob}")) + } + None => respond(&served, Auth::None, false, port, request), + } + })); + let _public = PublicOrigins::new(&[&storage]); + let docker = credentials_for(®istry); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + let output = Output::new(); + let failure = acquire( + &pinned, + &output.archive(), + ®istry.policy(&docker), + u64::MAX, + ) + .unwrap_err(); + assert_eq!(failure.code, "registry_redirect_refused", "{failure:?}"); + assert_eq!(output.entries(), 0); + // The storage host saw one unauthenticated blob request and no token request; the + // registry's own realm was never asked either. + let requests = storage.requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + assert!(requests[0].path.starts_with("/storage/") && requests[0].authorization.is_none()); + assert!( + registry + .requests() + .iter() + .all(|r| !r.path.starts_with("/token") && r.authorization.is_none()) + ); +} + +#[test] +fn a_foreign_token_realm_gets_anonymous_requests_but_never_the_users_credentials() { + let content = content(); + // A foreign realm that would grant a pull token for the user's credentials. + let realm = Registry::serve(Box::new(|request, _| { + match request.authorization.as_deref() { + None => Reply::new(200, r#"{"token":"anonymous-token"}"#), + Some(_) => Reply::new(200, r#"{"token":"pull-token"}"#), + } + })); + let realm_origin = realm.origin(); + let served = content.clone(); + let registry = Registry::serve(Box::new(move |request, port| { + if request.authorization.as_deref() == Some("Bearer pull-token") { + return respond(&served, Auth::None, false, port, request); + } + Reply::new(401, "{}").header( + "WWW-Authenticate", + format!(r#"Bearer realm="{realm_origin}/token",service="fixture-registry""#), + ) + })); + let _public = PublicOrigins::new(&[&realm]); + let pinned = format!( + "{}/{REPOSITORY}@{}", + registry.domain(), + content.index_digest + ); + for docker in [ + credentials_for(®istry), + DockerFixture::new( + &serde_json::json!({"auths": {registry.domain(): {"identitytoken": "refresh-fixture"}}}), + ), + ] { + let output = Output::new(); + let failure = acquire( + &pinned, + &output.archive(), + ®istry.policy(&docker), + u64::MAX, + ) + .unwrap_err(); + assert_eq!(failure.code, "registry_realm_refused", "{failure:?}"); + assert_eq!(output.entries(), 0); + } + let requests = realm.requests(); + assert!(!requests.is_empty()); + assert!( + requests + .iter() + .all(|r| r.method == "GET" && r.authorization.is_none() && !r.body.contains("refresh")), + "{requests:?}" + ); +} + +#[test] +fn credentialed_realms_are_limited_to_the_registry_host_and_its_parent_domains() { + let policy = ImagePolicy::default(); + let pin = format!("sha256:{}", "a".repeat(64)); + let registry = |reference: &str| { + pinned(&format!("{reference}/org/app@{pin}"), &policy) + .unwrap() + .registry + }; + let url = |value: &str| Url::parse(value).unwrap(); + let hub = pinned(&format!("alpine@{pin}"), &policy).unwrap().registry; + assert!(credential_realm_allowed(&hub, &url(DOCKER_HUB_REALM))); + let gitlab = registry("registry.gitlab.com"); + for value in [ + "https://registry.gitlab.com/jwt/auth", + "https://gitlab.com/jwt/auth", + ] { + assert!(credential_realm_allowed(&gitlab, &url(value)), "{value}"); + } + let ported = registry("registry.example.com:8443"); + assert!(credential_realm_allowed( + &ported, + &url("https://registry.example.com:8443/token") + )); + assert!(credential_realm_allowed( + &ported, + &url("https://example.com/token") + )); + for value in [ + // Anonymous requests may go to any public realm; credentials may not. + "https://auth.gitlab.com/jwt/auth", + "https://evil.example/token", + "https://gitlab.com.evil.example/token", + "https://com/token", + "https://gitlab.com:8443/jwt/auth", + "http://gitlab.com/jwt/auth", + "https://203.0.113.5/token", + ] { + assert!(!credential_realm_allowed(&gitlab, &url(value)), "{value}"); + } + assert!(realm_allowed(&gitlab, &url("https://evil.example/token"))); +} + +#[test] +fn public_names_resolving_to_local_addresses_refuse_unless_the_registry_is_allowed() { + let content = content(); + let registry = Registry::start(content.clone(), Auth::None, false); + let table: BTreeMap<&str, IpAddr> = [ + ("registry.example.test", "127.0.0.1"), + ("private.example.test", "10.0.0.7"), + ("mapped.example.test", "::ffff:127.0.0.1"), + ("ula.example.test", "fd00::1"), + ] + .into_iter() + .map(|(name, ip)| (name, ip.parse().unwrap())) + .collect(); + let lookup = HostLookup(Arc::new(move |name| { + table + .get(name) + .map(|ip| vec![*ip]) + .ok_or_else(|| std::io::Error::other("unknown fixture host")) + })); + let docker = empty_docker(); + let policy = |opt_in: Option| ImagePolicy { + host_lookup: Some(lookup.clone()), + local_registries: opt_in + .map(|domain| LocalRegistry { + domain, + plain_http: true, + }) + .into_iter() + .collect(), + ..docker.policy() + }; + for host in [ + "registry.example.test", + "private.example.test", + "mapped.example.test", + "ula.example.test", + ] { + let reference = format!("{host}:{}/{REPOSITORY}:v1", registry.port); + // The name looks public, so admission (which does no resolution) accepts it. + assert_eq!(admit(&reference, &policy(None)).unwrap().kind, "registry"); + let failure = resolve(&reference, &policy(None)).unwrap_err(); + assert_eq!( + failure.code, "registry_local_address", + "{host}: {failure:?}" + ); + // An opt-in for a different registry does not allow this one. + let other = policy(Some(format!("other.example.test:{}", registry.port))); + assert_eq!( + resolve(&reference, &other).unwrap_err().code, + "registry_local_address" + ); + } + assert!(registry.requests().is_empty()); + // An explicit opt-in allows exactly that registry, through the injected lookup. + let domain = format!("registry.example.test:{}", registry.port); + let allowed = policy(Some(domain.clone())); + let resolved = resolve(&format!("{domain}/{REPOSITORY}:v1"), &allowed).unwrap(); + assert_eq!(resolved.image_id, content.config_digest); + assert!(!registry.requests().is_empty()); +} + +#[test] +fn ip_literals_are_checked_by_address_before_any_request() { + for ip in [ + "127.0.0.1", + "10.0.0.1", + "100.64.0.1", + "169.254.169.254", + "172.16.0.1", + "192.168.0.1", + "0.0.0.0", + "224.0.0.1", + "255.255.255.255", + "::", + "::1", + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "::127.0.0.1", + "64:ff9b::a00:1", + "2002:7f00:1::", + "fc00::1", + "fd12:3456::1", + "fe80::1", + "fec0::1", + "ff02::1", + ] { + assert!(!public_ip(ip.parse().unwrap()), "{ip}"); + } + for ip in [ + "8.8.8.8", + "203.0.113.5", + "2606:4700::1111", + "::ffff:8.8.8.8", + "2002:808:808::", + ] { + assert!(public_ip(ip.parse().unwrap()), "{ip}"); + } + let policy = ImagePolicy::default(); + let pin = format!("sha256:{}", "a".repeat(64)); + let public = pinned(&format!("ghcr.io/org/app@{pin}"), &policy).unwrap(); + let fetch = Fetch::new(&public.registry, &public.path, &policy).unwrap(); + let url = |value: &str| Url::parse(value).unwrap(); + for value in [ + "https://127.0.0.1/blob", + "https://[::1]/blob", + "https://[::ffff:127.0.0.1]/blob", + "https://[fd00::1]/blob", + "https://10.0.0.1/token", + ] { + assert!(!fetch.destination_allowed(&url(value)), "{value}"); + } + for value in ["https://ghcr.io/v2/", "https://203.0.113.5/blob"] { + assert!(fetch.destination_allowed(&url(value)), "{value}"); + } + // A local opt-in allows its own origin only, not its host on another port. + let allowed = + ImagePolicy::from_flags(&["--allow-local-registry", "http://127.0.0.1:5000"]).unwrap(); + let local = pinned(&format!("127.0.0.1:5000/org/app@{pin}"), &allowed).unwrap(); + let fetch = Fetch::new(&local.registry, &local.path, &allowed).unwrap(); + assert!(fetch.destination_allowed(&url("http://127.0.0.1:5000/v2/"))); + assert!(!fetch.destination_allowed(&url("http://127.0.0.1:5001/v2/"))); + let allowed = + ImagePolicy::from_flags(&["--allow-local-registry", "registry.corp.example:5000"]).unwrap(); + let named = pinned( + &format!("registry.corp.example:5000/org/app@{pin}"), + &allowed, + ) + .unwrap(); + let fetch = Fetch::new(&named.registry, &named.path, &allowed).unwrap(); + assert!(fetch.destination_allowed(&url("https://registry.corp.example:5000/v2/"))); + assert!(!fetch.destination_allowed(&url("https://registry.corp.example/blob"))); +} + #[test] fn authentication_challenges_parse_quoted_parameters() { let challenge = parse_challenge( From 525a06716065a1aed65ded274cf2c5a6615cf8d8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:37:11 -0500 Subject: [PATCH 059/138] docs(native): describe registry origin, realm and address rules Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 88f90315d..da56471d4 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -666,7 +666,13 @@ not change existing plan identities. A registry on this host or its local network is refused unless it is named exactly: `localhost` and `*.localhost`, loopback, private, link-local, CGNAT and multicast IPv4 literals (including numeric spellings such as `0x7f.0.0.1`), -single-label hosts such as `registry:5000`, and mDNS `.local` names. Allow one +single-label hosts such as `registry:5000`, and mDNS `.local` names. The same +rule applies to the address a connection actually uses: a public-looking name +that resolves to a loopback, private (including IPv6 `fc00::/7`), link-local, +CGNAT, multicast or unspecified address, or an IPv4-mapped form of one, is +refused with `registry_local_address`. This covers the registry, its redirects +and its token endpoint. A private registry on an internal network therefore +also needs an explicit opt-in. Allow one with `--allow-local-registry [http://]host[:port]` (repeatable) on `admit-image`, `resolve-image`, `fetch-image` and `ensure-image`. Without `http://` the registry must serve HTTPS; with it, plain HTTP is used for that registry only. Native @@ -681,16 +687,23 @@ credentials, in the Docker CLI's order: a `credHelpers` entry for the registry, then `credsStore`, then an `auths` entry (`auth`, `username`/`password` or `identitytoken`). Helpers run as `docker-credential-NAME get` from `PATH`; a helper may show its own approval prompt, such as a keychain prompt, and is -stopped after 60 seconds. The configuration is `~/.docker/config.json`, or +stopped with its process group after 60 seconds. Processes a helper leaves +running cannot extend that wait. The configuration is `~/.docker/config.json`, or `--docker-config DIR` (native `hack up` passes `DOCKER_CONFIG` when set). Credential lookup is read-only: nothing is logged in, stored, erased or rewritten. Credentials and tokens stay in memory for one acquisition, are sent only to the registry's own origin or its token endpoint, never to a redirected blob host, -and never appear in output, receipts or errors. Docker Hub tokens come only from -`auth.docker.io`. Other token endpoints must be the registry origin or a public -HTTPS host. Run `docker login` (or your registry's helper setup) yourself before -acquiring a private image. +and never appear in output, receipts or errors. Only a `401` from the registry +origin itself is answered; a redirected blob host that refuses access fails with +`registry_redirect_refused` and no token or credential request. Docker Hub +tokens come only from `auth.docker.io`. For other registries, an anonymous token +may come from the registry origin or a public HTTPS host, but Docker credentials +are sent only to a token endpoint on the registry origin, or over HTTPS on port +443 at the registry's own host name or a parent domain of it (for example +`gitlab.com` for `registry.gitlab.com`). Any other token endpoint refuses with +`registry_realm_refused` before credentials are read. Run `docker login` (or +your registry's helper setup) yourself before acquiring a private image. ### Content, budgets and limits @@ -733,7 +746,8 @@ Docker Hub's known blob hosts, or (for other registries) public HTTPS hosts on port 443; local destinations and embedded credentials refuse. Proxy settings are not used. TLS uses the bundled public root certificates, so a registry signed by a private certificate authority is not reachable yet. Host names are classified -before connecting, not after DNS resolution. +before connecting, and resolved addresses are checked again before each +connection (see [Local registries](#local-registries)). No implicit image refresh occurs. Keep the archive only as long as needed for owned imports; fetch does not install a background cache or cleanup daemon. The complete From 4f6841a47349507c901066d6041b440bc8c675ab Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:41:28 -0500 Subject: [PATCH 060/138] fix(native): keep whitespace after the heredoc operator in one word BuildKit's shell lexer keeps tabs, carriage returns and spaces after an unquoted `<<` in the same word, so `RUN true << CMD` starts a heredoc. The scanner split `<<` from the delimiter, missed the heredoc, and read its body as instructions, so a body line like `FROM scratch` could hide the real stage's image. The heredoc word lexer now absorbs that whitespace the same way. `<<- CMD` is still two words and no heredoc, as in BuildKit. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 18 ++++++++++++++- .../src/provider/build_scan/tests.rs | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index eb6d08844..6cea9b519 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -564,13 +564,29 @@ fn json_form(args: &str, line: usize) -> Result { } /// Shell words with quotes and escapes kept raw, as the heredoc detector lexes a line. +/// +/// Like the frontend's lexer, an unquoted `<<` keeps the tabs, carriage returns and +/// spaces after it in the same word, so `<< EOF` is one heredoc word. A `-` stops +/// that, so `<<- EOF` is two words and not a heredoc. fn raw_words(line: &str) -> Option> { let mut words = Vec::new(); let mut word = String::new(); let mut in_word = false; - let mut chars = line.chars(); + let mut chars = line.chars().peekable(); while let Some(c) = chars.next() { match c { + '<' if chars.peek() == Some(&'<') => { + chars.next(); + word.push_str("<<"); + while let Some(&space) = chars.peek() { + if !matches!(space, '\t' | '\r' | ' ') { + break; + } + word.push(space); + chars.next(); + } + in_word = true; + } c if c.is_whitespace() => { if in_word { words.push(std::mem::take(&mut word)); diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index d3736e975..15ca05438 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -501,6 +501,29 @@ COPY --from=0 / / ); } +#[test] +fn whitespace_after_the_heredoc_operator_keeps_the_heredoc() { + // The frontend lexes `<< CMD` as one heredoc word, so the body is not a stage. + for dockerfile in [ + "FROM alpine:3.22\nRUN true << CMD\nFROM scratch\nCMD\n", + "FROM alpine:3.22\nRUN cat <<\t\"EOF\" > /x\nFROM scratch\nEOF\n", + "FROM alpine:3.22\nCOPY << 'FILE' /etc/x\nFROM scratch\nFILE\n", + "FROM alpine:3.22\nRUN true < Date: Thu, 5 Nov 2026 18:42:20 -0500 Subject: [PATCH 061/138] fix(native): report service exits, runtime reasons and interleaved logs Foreground native `up` now reports each service container that stops ("SERVICE exited with code N"), as attached Compose `up` does, and ends with exit 0 through the ordinary owned stop once every service has stopped. It polls a new lock-free `graph observe`, which reads container state without the provider lease, so it never contends with ps, logs or exec. Runtime errors mark compile-time literal messages as fixed (`message_fixed`) and can name the admitted service. The CLI prints only those value-free messages and validated service names; formatted messages stay omitted. `graph inspect` refuses `graph_unreserved` when no journal exists and no foreground owner is published. A settled foreground start refused before admission then reports that nothing was retained instead of "exit cleanup is unconfirmed". Log tails keep whole lines, newest first, in engine order: `graph logs` returns `lines` interleaving stdout and stderr by time, and `hack logs --no-follow` prints them in that order. Text `project plan` exits non-zero when it reports an error diagnostic; JSON keeps exit 0 with the report as the result. Refs HACK-1915 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 31 +++ packages/runtime-core/src/error.rs | 86 +++++- .../src/error_stop_diagnostics.rs | 2 +- packages/runtime-core/src/graph_cli.rs | 11 + packages/runtime-core/src/lib.rs | 2 +- packages/runtime-core/src/main.rs | 23 +- packages/runtime-core/src/node/journal.rs | 7 +- .../runtime-core/src/project/execution.rs | 7 +- packages/runtime-core/src/project/inputs.rs | 2 +- packages/runtime-core/src/project/mod.rs | 2 +- packages/runtime-core/src/provider/agent.rs | 2 +- .../src/provider/dependency_socket.rs | 2 +- packages/runtime-core/src/provider/engine.rs | 255 +++++++++++++++++- .../src/provider/graph/foreground/tests.rs | 31 +++ .../runtime-core/src/provider/graph/mod.rs | 83 +++++- .../src/provider/graph/native/foreground.rs | 4 +- .../persistent_data/enrollment/tests.rs | 3 +- .../runtime-core/src/provider/graph/relay.rs | 2 +- .../src/provider/graph/service_logs.rs | 13 +- .../runtime-core/src/provider/graph/tests.rs | 5 +- .../src/provider/graph_probe_test.rs | 2 +- .../runtime-core/src/provider/image_ensure.rs | 2 +- .../runtime-core/src/provider/image_load.rs | 2 +- .../runtime-core/src/provider/lifecycle.rs | 5 + .../src/provider/lifecycle/prepared_boot.rs | 2 +- .../src/provider/network_tools.rs | 8 +- .../src/provider/prepared_base.rs | 2 +- .../src/provider/prepared_start.rs | 2 +- .../src/provider/prepared_store.rs | 2 +- .../src/provider/registry_image.rs | 2 +- .../src/provider/source_failure_test.rs | 2 +- .../runtime-core/src/provider/source_job.rs | 2 +- .../src/provider/source_job_test.rs | 2 +- .../src/provider/source_output_test.rs | 2 +- .../runtime-core/src/provider/source_probe.rs | 2 +- .../runtime-core/src/provider/source_sync.rs | 2 +- .../src/provider/source_watch_test.rs | 2 +- .../src/provider/supervisor_loss_test.rs | 2 +- .../runtime-core/tests/project_contract.rs | 43 +++ src/backends/native-project-observe.ts | 40 +++ src/backends/native-project-process.ts | 14 +- src/backends/native-project-start.ts | 124 ++++++++- src/backends/native-runtime-client.ts | 82 +++++- src/backends/native-service-exit-monitor.ts | 152 +++++++++++ src/commands/project.ts | 29 +- tests/native-project-observe.test.ts | 44 +++ tests/native-project-start.test.ts | 101 +++++++ tests/native-runtime-client.test.ts | 31 +++ tests/native-service-exit-monitor.test.ts | 139 ++++++++++ 49 files changed, 1344 insertions(+), 73 deletions(-) create mode 100644 src/backends/native-service-exit-monitor.ts create mode 100644 tests/native-service-exit-monitor.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 6f5fda312..ad3b8b55a 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -272,6 +272,21 @@ window waits briefly, then reports that the runtime is busy (`runtime_busy`, exit 0) instead of failing. After the ready line, `hack ps` and `hack logs` run alongside the foreground command. +While the project runs, foreground `up` reports each service container that stops, +as attached Compose `up` does (`SERVICE exited with code N`), including one-shot +services. It keeps running while any service runs; once every service container has +stopped, it prints that, stops the graph through the ordinary owned cleanup and exits +0, like Compose `up` without `--abort-on-container-exit`. The monitor reads +`graph observe`, which takes no provider lease, so it never contends with `ps`, `logs` +or `exec`. + +Runtime refusals print their code and, when the runtime marks its message as a fixed +literal that cannot carry values, that message and the service it concerns (for +example `Service web: Pinned image architecture or implicit volumes are +incompatible.`). Formatted runtime messages stay omitted. A start refused before the +runtime reserved any graph state reports that nothing was retained instead of +unconfirmed cleanup. + ## Manual candidate upgrade and rollback Manual candidate bundles are selected by their full path. The separate @@ -521,6 +536,22 @@ For an existing owned graph, bounded service diagnostics use its run identifier: Both commands select the current container and recheck its boot, receipt generation and ownership before reading or executing. Logs include exited services, default to 200 lines (maximum 1,000), and return bounded, UTF-8-lossy text with a truncation flag. +`lines` lists the retained lines in engine order, so stdout and stderr interleave by +time; `stdout` and `stderr` hold the same lines per stream. Retention keeps whole +lines, newest first, within 32 KiB of JSON text and sets `truncated` when older lines +were dropped. Only a single line longer than that budget is cut. `hack logs SERVICE +--no-follow` prints `lines` in that order, each to its own stream. + +`graph observe --run-id RUN_ID --json` reports each service container's current state +(`running` with the engine's health, `exited` with its code, `dead`, `created`, +`absent` or `unknown`) without taking the provider lease. It only reads, checks each +recorded container's ownership, runs no probes and grants no authority; foreground +`hack up` polls it to report service exits. + +`project plan` without `--json` exits non-zero (`plan_diagnostics`) after printing a +report that contains an error diagnostic. With `--json` it exits 0 and the report is +the result: `diagnostics` and `enrollment_compatible` carry the outcome, because Hack +plans original input that its normalization adapts afterwards. Exec requires a running service and returns Base64 stdout/stderr to preserve arbitrary bytes, an exit code, and a truncation flag. The CLI also exits with the command's exit code. Retained exec output is limited to 1 MiB per stream. diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index d50acc6a4..b52fff955 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -29,28 +29,108 @@ pub struct StopFailuresDiagnostic { pub failures: Vec, } +/// Error text plus whether it was fixed at compile time. A `&'static str` literal +/// cannot carry a runtime value (a path, identifier, OS error or input text); +/// formatted or borrowed text can, so only literals are marked fixed. +pub struct ErrorMessage { + text: String, + fixed: bool, +} + +impl From<&'static str> for ErrorMessage { + fn from(text: &'static str) -> Self { + Self { + text: text.to_owned(), + fixed: true, + } + } +} + +impl From for ErrorMessage { + fn from(text: String) -> Self { + Self { text, fixed: false } + } +} + #[derive(Debug, Serialize)] pub struct CandidateError { pub code: &'static str, pub message: String, + /// True only when `message` is a compile-time literal. Callers may show such a + /// message to users; any other message may contain runtime values and must stay + /// internal. Changing `message` after construction must clear this flag. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub message_fixed: bool, + /// Admitted Compose service the error concerns, when one is known. It is a + /// validated graph identifier, never an engine ID or a value. + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub cause_code: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub stop_failures: Option, + /// Boxed: rarely present, and it keeps every `Result<_, CandidateError>` small. + pub stop_failures: Option>, } impl CandidateError { - pub fn new(code: &'static str, message: impl Into) -> Self { + pub fn new(code: &'static str, message: impl Into) -> Self { + let message = message.into(); Self { code, - message: message.into(), + message: message.text, + message_fixed: message.fixed, + service: None, cause_code: None, stop_failures: None, } } + /// Name the admitted service; anything that is not a plain service + /// identifier is dropped rather than echoed. + pub fn with_service(mut self, service: &str) -> Self { + let valid = !service.is_empty() + && service.len() <= 128 + && service + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')); + self.service = valid.then(|| service.into()); + self + } + pub fn with_cause_code(mut self, code: String) -> Self { self.cause_code = Some(code); self } } + +#[cfg(test)] +mod tests { + use super::CandidateError; + + #[test] + fn only_literal_messages_are_marked_fixed() { + let fixed = + serde_json::to_value(CandidateError::new("graph_image", "Fixed text.")).unwrap(); + assert_eq!(fixed["message_fixed"], true); + let path = "/private/value-canary"; + let formatted = serde_json::to_value(CandidateError::new( + "provider_state", + format!("{path} failed"), + )) + .unwrap(); + assert!(formatted.get("message_fixed").is_none()); + assert!(formatted.get("service").is_none()); + } + + #[test] + fn only_plain_service_identifiers_are_named() { + let named = CandidateError::new("graph_image", "Fixed text.").with_service("web-1.api_x"); + assert_eq!(named.service.as_deref(), Some("web-1.api_x")); + // Large errors are boxed by convention; keep the shared error small. + assert!(std::mem::size_of::() <= 128); + for invalid in ["", "../web", "web\n", "web=VALUE", &"a".repeat(129)] { + let error = CandidateError::new("graph_image", "Fixed text.").with_service(invalid); + assert!(error.service.is_none(), "{invalid:?}"); + } + } +} diff --git a/packages/runtime-core/src/error_stop_diagnostics.rs b/packages/runtime-core/src/error_stop_diagnostics.rs index 5ec14d0d3..28b1a8230 100644 --- a/packages/runtime-core/src/error_stop_diagnostics.rs +++ b/packages/runtime-core/src/error_stop_diagnostics.rs @@ -23,7 +23,7 @@ impl StopFailuresDiagnostic { impl CandidateError { pub(crate) fn with_stop_failures(mut self, detail: StopFailuresDiagnostic) -> Self { if detail.valid() { - self.stop_failures = Some(detail); + self.stop_failures = Some(Box::new(detail)); } self } diff --git a/packages/runtime-core/src/graph_cli.rs b/packages/runtime-core/src/graph_cli.rs index 277c38e32..3b7ff145b 100644 --- a/packages/runtime-core/src/graph_cli.rs +++ b/packages/runtime-core/src/graph_cli.rs @@ -601,6 +601,7 @@ pub fn command(candidate: &Candidate, args: &[&str]) -> Result Result graph::reconcile_export(candidate, run), "inspect" => serde_json::to_value(graph::inspect(candidate, run)?) .map_err(|_| CandidateError::new("graph_output", "Cannot encode graph snapshot.")), + "observe" => { + #[cfg(target_os = "macos")] + { + graph::observe(candidate, run) + } + #[cfg(not(target_os = "macos"))] + { + Err(invalid()) + } + } "export" => serde_json::to_value(graph::export(candidate, run)?) .map_err(|_| CandidateError::new("graph_output", "Cannot encode graph export.")), "archive" => encode(graph::archive(candidate, run)?), diff --git a/packages/runtime-core/src/lib.rs b/packages/runtime-core/src/lib.rs index 07c33c9d0..6479c7fa2 100644 --- a/packages/runtime-core/src/lib.rs +++ b/packages/runtime-core/src/lib.rs @@ -18,7 +18,7 @@ pub const CANDIDATE_VERSION: &str = "5.0.0-dev.6"; mod error; mod error_stop_diagnostics; -pub use error::CandidateError; +pub use error::{CandidateError, ErrorMessage}; #[cfg(all(test, feature = "native-config-plan"))] mod test_budget; #[cfg(test)] diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 0ab86ffde..d1b31df40 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -22,7 +22,7 @@ Usage: hack-local project publish-source --project --file --expect-plan [--profile ] [--reconcile] [--json] hack-local project verify-source --project --file --expect-plan [--profile ] [--json] hack-local graph run|restart|restore --project --file --expect-plan --run-id <32-hex> --ready ... [--source-revision ] [--live-source] [--profile ] [--timeout-seconds ] [--json] - hack-local graph inspect|reconcile|archive|export|reconcile-export|prune --run-id <32-hex> [--json] + hack-local graph inspect|observe|reconcile|archive|export|reconcile-export|prune --run-id <32-hex> [--json] hack-local graph cleanup --run-id <32-hex> [--remove-data] [--json] hack-local graph native plan --source-file [--json] hack-local graph native run|serve --source-file --expect-review <64-hex> [--environment-stdin] [--timeout-seconds <1..300>] [--json] @@ -1358,11 +1358,32 @@ fn project_command(candidate: &Candidate, arguments: &[&str]) -> Result<(), Cand println!( "Planning has no effects. Enrollment saves metadata only; project execution remains gated." ); + // A human review that reports errors fails like `compose config` does. + // JSON keeps exit 0: the report is the result, and its consumers read + // `diagnostics` and `enrollment_compatible` (Hack plans original input + // that normalization later adapts). + plan_text_result(&report.plan.diagnostics)?; } } Ok(()) } +/// Text-mode `project plan` exits non-zero when any diagnostic is an error. +fn plan_text_result( + diagnostics: &[hack_runtime_core::project::Diagnostic], +) -> Result<(), CandidateError> { + if diagnostics + .iter() + .any(|finding| finding.severity == "error") + { + return Err(CandidateError::new( + "plan_diagnostics", + "The project plan reported error diagnostics; review them above.", + )); + } + Ok(()) +} + /// Capture the reviewed artifact shape identically for immutable publication and /// mutable synchronization, including excluded-volume mountpoint placeholders. fn capture_plan_source( diff --git a/packages/runtime-core/src/node/journal.rs b/packages/runtime-core/src/node/journal.rs index aa8b782d3..74ae4c070 100644 --- a/packages/runtime-core/src/node/journal.rs +++ b/packages/runtime-core/src/node/journal.rs @@ -1043,7 +1043,8 @@ mod tests { serde_json::to_value(&failure).unwrap(), serde_json::json!({ "code": "journal_busy", - "message": "Node journal is busy; retry with the same operation ID." + "message": "Node journal is busy; retry with the same operation ID.", + "message_fixed": true }) ); assert_eq!(operations(&node), 0); @@ -1082,7 +1083,7 @@ mod tests { assert!(started.elapsed() >= BUSY_TIMEOUT); assert_eq!( serde_json::to_value(&failure).unwrap(), - serde_json::json!({"code": "journal_busy", "message": BUSY_HINT}) + serde_json::json!({"code": "journal_busy", "message": BUSY_HINT, "message_fixed": true}) ); assert_eq!(snapshot(&supervisor, &id), before); @@ -1112,7 +1113,7 @@ mod tests { .unwrap_err(); assert_eq!( serde_json::to_value(&failure).unwrap(), - serde_json::json!({"code": "journal_busy", "message": BUSY_HINT}) + serde_json::json!({"code": "journal_busy", "message": BUSY_HINT, "message_fixed": true}) ); blocker.connection.execute_batch("ROLLBACK").unwrap(); assert_eq!( diff --git a/packages/runtime-core/src/project/execution.rs b/packages/runtime-core/src/project/execution.rs index 4fb9cc4d9..8360545f2 100644 --- a/packages/runtime-core/src/project/execution.rs +++ b/packages/runtime-core/src/project/execution.rs @@ -94,7 +94,7 @@ pub trait Driver { fn start(&mut self, service: &str) -> Result<(), CandidateError>; fn observe(&mut self, service: &str) -> Result; } -fn error(code: &'static str, message: &str) -> CandidateError { +fn error(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } impl Graph { @@ -250,7 +250,7 @@ fn inspect( if state.failed() { return Err(error( "graph_service_failed", - &match state { + match state { Observation::Exited { code } => format!( "Owned graph service {name} exited with code {code}; no further services will start." ), @@ -261,7 +261,8 @@ fn inspect( "Owned graph service {name} is unhealthy; no further services will start." ), }, - )); + ) + .with_service(name)); } Ok(state) } diff --git a/packages/runtime-core/src/project/inputs.rs b/packages/runtime-core/src/project/inputs.rs index 96d3e25d3..49a7f0e3d 100644 --- a/packages/runtime-core/src/project/inputs.rs +++ b/packages/runtime-core/src/project/inputs.rs @@ -31,7 +31,7 @@ impl ExecutionInputs { self.managed_environment_required } } -fn error(code: &'static str, message: &str) -> CandidateError { +fn error(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } const MAX_EXPANDED: usize = 1024 * 1024; diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index 043076259..e29c2e0f7 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -60,7 +60,7 @@ impl Diagnostic { } } } -fn problem(code: &'static str, message: &str) -> CandidateError { +fn problem(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } diff --git a/packages/runtime-core/src/provider/agent.rs b/packages/runtime-core/src/provider/agent.rs index 1aed6bdba..827353614 100644 --- a/packages/runtime-core/src/provider/agent.rs +++ b/packages/runtime-core/src/provider/agent.rs @@ -9,7 +9,7 @@ use std::path::Path; use std::time::{Duration, Instant}; const MAX_FRAME: usize = 64 * 1024; -fn failure(message: impl Into) -> CandidateError { +fn failure(message: impl Into) -> CandidateError { CandidateError::new("agent_protocol_uncertain", message) } diff --git a/packages/runtime-core/src/provider/dependency_socket.rs b/packages/runtime-core/src/provider/dependency_socket.rs index 93b86b15f..c13765c70 100644 --- a/packages/runtime-core/src/provider/dependency_socket.rs +++ b/packages/runtime-core/src/provider/dependency_socket.rs @@ -66,7 +66,7 @@ pub struct DependencySocketObservation { pub application_ready: bool, } -fn refused(message: &str) -> CandidateError { +fn refused(message: impl Into) -> CandidateError { CandidateError::new("dependency_socket", message) } diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index e0299cd9c..8d532ca97 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -197,7 +197,7 @@ fn rejection_category(bytes: &[u8]) -> &'static str { "unclassified" } -fn failure(message: &str) -> CandidateError { +fn failure(message: impl Into) -> CandidateError { CandidateError::new("engine_protocol", message) } @@ -387,21 +387,35 @@ impl<'a> Engine<'a> { pub(super) fn logs(&self, id: &str) -> Result<(String, String, bool), CandidateError> { self.logs_with_tail(id, "all") } + /// Bounded per-stream tail for callers that keep the streams apart. Built from + /// the same whole-line, newest-first selection as [`Self::logs_tail_lines`]. + #[cfg(feature = "native-config-plan")] pub(super) fn logs_tail( &self, id: &str, tail: u16, ) -> Result<(String, String, bool), CandidateError> { + let tail = self.logs_tail_lines(id, tail)?; + let (stdout, stderr) = tail.split(); + Ok((stdout, stderr, tail.truncated)) + } + /// Bounded tail in engine order, which interleaves stdout and stderr by time. + /// Whole lines are kept newest first; see [`LogTail`]. + pub(super) fn logs_tail_lines(&self, id: &str, tail: u16) -> Result { if !(1..=1000).contains(&tail) { return Err(failure("Container log tail must be from 1 to 1000.")); } - self.logs_with_tail(id, &tail.to_string()) + let bytes = self.logs_bytes(id, &tail.to_string())?; + decode_log_lines(&bytes) } fn logs_with_tail( &self, id: &str, tail: &str, ) -> Result<(String, String, bool), CandidateError> { + decode_logs(&self.logs_bytes(id, tail)?) + } + fn logs_bytes(&self, id: &str, tail: &str) -> Result, CandidateError> { if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(failure("Invalid container log identity.")); } @@ -411,9 +425,8 @@ impl<'a> Engine<'a> { &format!("/v1.53/containers/{id}/logs?stdout=true&stderr=true&tail={tail}"), None, )?; - let result = decode_logs(&bytes)?; self.guest.verify()?; - Ok(result) + Ok(bytes) } pub(super) fn guest(&self) -> &OwnedGuest<'a> { &self.guest @@ -581,6 +594,18 @@ impl<'a> Observer<'a> { Ok(Self { guest, transport }) } + pub(super) fn incarnation(&self) -> &str { + self.guest.incarnation() + } + + /// One read-only engine GET, with runtime identity verified on both sides. + pub(super) fn get(&self, path: &str) -> Result { + self.guest.verify()?; + let value = self.transport.request(Method::GET, path, None)?; + self.guest.verify()?; + Ok(value) + } + pub(super) fn storage_usage(&self) -> Result { self.info()?; self.guest.verify()?; @@ -660,6 +685,161 @@ fn decode_logs(bytes: &[u8]) -> Result<(String, String, bool), CandidateError> { Ok((stdout, stderr, truncated)) } +/// Output stream of one log line. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LogStream { + Stdout, + Stderr, +} + +/// One log line in engine order. `text` keeps its trailing newline when the +/// engine supplied one; only the final line of a stream can lack it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct LogLine { + pub stream: LogStream, + pub text: String, +} + +/// A bounded log tail. Lines are whole and in engine order, so stdout and stderr +/// interleave by time. When the selection exceeds [`LOG_TAIL_BUDGET`] JSON bytes, +/// the oldest whole lines are dropped and `truncated` is set; a line is cut only +/// when it alone exceeds the budget (or [`LOG_LINE_LIMIT`] while decoding), and +/// that also sets `truncated`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogTail { + pub lines: Vec, + pub truncated: bool, +} + +impl LogTail { + /// Per-stream text of the same retained lines. + pub fn split(&self) -> (String, String) { + let mut stdout = String::new(); + let mut stderr = String::new(); + for line in &self.lines { + match line.stream { + LogStream::Stdout => stdout.push_str(&line.text), + LogStream::Stderr => stderr.push_str(&line.text), + } + } + (stdout, stderr) + } +} + +/// JSON-encoded budget for the retained text of one tail, across both streams. +pub const LOG_TAIL_BUDGET: usize = 32 * 1024; +/// Raw bytes kept for one line while decoding; the response body is bounded too. +pub const LOG_LINE_LIMIT: usize = 64 * 1024; +/// Approximate JSON overhead of one line entry (`{"stream":"stderr","text":""},`). +const LOG_LINE_OVERHEAD: usize = 32; + +fn json_text_cost(character: char) -> usize { + match character { + '"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2, + c if c <= '\u{1f}' => 6, + c => c.len_utf8(), + } +} + +fn decode_log_lines(bytes: &[u8]) -> Result { + struct Entry { + stream: LogStream, + bytes: Vec, + clipped: bool, + } + let mut entries: Vec = Vec::new(); + // Index of the unterminated line each stream is still filling. + let mut open: [Option; 2] = [None, None]; + let mut offset = 0; + while offset < bytes.len() { + let header = bytes + .get(offset..offset + 8) + .ok_or_else(|| failure("Truncated container log header."))?; + if ![1, 2].contains(&header[0]) || header[1..4] != [0, 0, 0] { + return Err(failure("Invalid container log stream.")); + } + let length = u32::from_be_bytes(header[4..8].try_into().expect("length field")) as usize; + offset += 8; + let end = offset + .checked_add(length) + .ok_or_else(|| failure("Invalid container log length."))?; + let mut rest = bytes + .get(offset..end) + .ok_or_else(|| failure("Truncated container log payload."))?; + offset = end; + let (slot, stream) = if header[0] == 1 { + (0, LogStream::Stdout) + } else { + (1, LogStream::Stderr) + }; + while !rest.is_empty() { + let (chunk, complete) = match rest.iter().position(|byte| *byte == b'\n') { + Some(index) => (&rest[..=index], true), + None => (rest, false), + }; + rest = &rest[chunk.len()..]; + let index = *open[slot].get_or_insert_with(|| { + entries.push(Entry { + stream, + bytes: Vec::new(), + clipped: false, + }); + entries.len() - 1 + }); + let entry = &mut entries[index]; + let keep = chunk + .len() + .min(LOG_LINE_LIMIT.saturating_sub(entry.bytes.len())); + entry.bytes.extend_from_slice(&chunk[..keep]); + if keep < chunk.len() { + entry.clipped = true; + if complete { + entry.bytes.push(b'\n'); + } + } + if complete { + open[slot] = None; + } + } + } + let mut truncated = entries.iter().any(|entry| entry.clipped); + let mut lines = Vec::new(); + let mut used = 0; + for entry in entries.iter().rev() { + let text = String::from_utf8_lossy(&entry.bytes); + let cost = LOG_LINE_OVERHEAD + text.chars().map(json_text_cost).sum::(); + if used + cost <= LOG_TAIL_BUDGET { + used += cost; + lines.push(LogLine { + stream: entry.stream, + text: text.into_owned(), + }); + continue; + } + truncated = true; + if lines.is_empty() { + // The newest line alone exceeds the budget: keep its start. + let mut clipped = String::new(); + let mut cost = LOG_LINE_OVERHEAD; + for character in text.chars() { + cost += json_text_cost(character); + if cost > LOG_TAIL_BUDGET { + break; + } + clipped.push(character); + } + lines.push(LogLine { + stream: entry.stream, + text: clipped, + }); + } + break; + } + lines.reverse(); + Ok(LogTail { lines, truncated }) +} + fn bounded_log_text(bytes: &[u8], truncated: &mut bool) -> String { let text = String::from_utf8_lossy(bytes); // JSON escaping and invalid UTF-8 replacement must not overflow the node frame. @@ -768,6 +948,73 @@ mod tests { assert!(serde_json::to_vec(&stdout).unwrap().len() <= 16386); } + #[test] + fn log_tails_interleave_by_engine_order_and_keep_whole_newest_lines() { + let frame = |stream: u8, payload: &[u8]| { + let mut bytes = vec![stream, 0, 0, 0]; + bytes.extend((payload.len() as u32).to_be_bytes()); + bytes.extend(payload); + bytes + }; + let line = |stream, text: &str| LogLine { + stream, + text: text.into(), + }; + // Engine order interleaves streams; a line split across frames stays whole. + let mut bytes = frame(1, b"one\n"); + bytes.extend(frame(2, b"warn\n")); + bytes.extend(frame(1, b"tw")); + bytes.extend(frame(2, b"err\n")); + bytes.extend(frame(1, b"o\nlast")); + let tail = decode_log_lines(&bytes).unwrap(); + assert_eq!( + tail.lines, + vec![ + line(LogStream::Stdout, "one\n"), + line(LogStream::Stderr, "warn\n"), + line(LogStream::Stdout, "two\n"), + line(LogStream::Stderr, "err\n"), + line(LogStream::Stdout, "last"), + ] + ); + assert!(!tail.truncated); + assert_eq!( + tail.split(), + ("one\ntwo\nlast".into(), "warn\nerr\n".into()) + ); + assert!(decode_log_lines(&bytes[..bytes.len() - 1]).is_err()); + assert!(decode_log_lines(&[3, 0, 0, 0, 0, 0, 0, 0]).is_err()); + + // Over budget: the oldest whole lines are dropped, newest kept, never cut. + let mut bytes = Vec::new(); + for index in 0..2000 { + let stream = if index % 3 == 0 { 2 } else { 1 }; + bytes.extend(frame(stream, format!("line {index:04} \"x\"\n").as_bytes())); + } + let tail = decode_log_lines(&bytes).unwrap(); + assert!(tail.truncated); + assert_eq!(tail.lines.last().unwrap().text, "line 1999 \"x\"\n"); + assert!(tail.lines.len() < 2000); + assert!( + tail.lines + .iter() + .all(|line| { line.text.starts_with("line ") && line.text.ends_with("\"x\"\n") }) + ); + let first: usize = tail.lines[0].text[5..9].parse().unwrap(); + assert_eq!(first + tail.lines.len(), 2000); + let encoded = serde_json::to_vec(&tail.lines).unwrap().len(); + assert!(encoded <= LOG_TAIL_BUDGET, "{encoded}"); + + // Only a single line larger than the whole budget is cut, and that is explicit. + let tail = decode_log_lines(&frame(1, &vec![b'y'; 100_000])).unwrap(); + assert!(tail.truncated); + assert_eq!(tail.lines.len(), 1); + assert!(serde_json::to_vec(&tail.lines).unwrap().len() <= LOG_TAIL_BUDGET); + let tail = decode_log_lines(&frame(2, &vec![0; 20_000])).unwrap(); + assert!(tail.truncated); + assert!(serde_json::to_vec(&tail.lines).unwrap().len() <= LOG_TAIL_BUDGET); + } + #[test] fn rejection_classification_never_returns_response_values() { assert_eq!( diff --git a/packages/runtime-core/src/provider/graph/foreground/tests.rs b/packages/runtime-core/src/provider/graph/foreground/tests.rs index 919ff4278..e5a0f7ccd 100644 --- a/packages/runtime-core/src/provider/graph/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/foreground/tests.rs @@ -533,3 +533,34 @@ fn dead_owner_selection_holds_lock_and_rejects_live_or_changed_records() { drop(guard); fs::remove_dir_all(path).unwrap(); } +#[test] +fn inspect_names_only_an_absent_unpublished_attempt_as_unreserved() { + // HACK-1915: a foreground start refused before admission reserved nothing and + // must be distinguishable from retained or damaged graph state. + let (_fixture, candidate, run) = fixture(); + let code = |candidate: &Candidate| match super::super::inspect(candidate, &run) { + Ok(_) => "inspected", + Err(error) => error.code, + }; + assert_eq!(code(&candidate), "graph_unreserved"); + + // A published foreground owner is evidence, never "unreserved". + let mut publication = Publication::bind(&candidate, &run).unwrap(); + assert_ne!(code(&candidate), "graph_unreserved"); + publication.finish().unwrap(); + drop(publication); + assert_eq!(code(&candidate), "graph_unreserved"); + + // Any journal (active, archived or consumed) keeps ordinary receipt validation. + for path in [ + super::super::directory(&candidate, &run).unwrap(), + super::super::archive::path(&candidate, &run).unwrap(), + super::super::retention::consumed(&candidate, &run).unwrap(), + ] { + crate::provider::state::private_directory(&path).unwrap(); + assert_ne!(code(&candidate), "graph_unreserved", "{}", path.display()); + fs::remove_dir(&path).unwrap(); + } + assert_eq!(code(&candidate), "graph_unreserved"); + fs::remove_dir_all(root(&candidate, &run)).unwrap(); +} diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..e13c27471 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -174,7 +174,7 @@ const MAX_TOTAL_MEMORY_BYTES: u64 = super::Profile::Development.memory_mib() as u64 * 1024 * 1024 - GUEST_MEMORY_RESERVE_BYTES; const MAX_VOLUMES: usize = 8; -fn error(code: &'static str, message: &str) -> CandidateError { +fn error(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } fn hex(value: &str, len: usize) -> bool { @@ -1455,11 +1455,89 @@ fn endpoint_network<'a>( } /// Read-only recovery inspection; historical ready phase is not a current health assertion. +/// Read-only inspection of one owned graph attempt. +/// +/// A run whose active, archived and consumed journals are all exactly absent and +/// whose foreground owner is not published refuses `graph_unreserved` before any +/// guest access. Absence is a point-in-time observation: only a caller that +/// separately proved its own consumer settled may treat it as "never reserved" +/// (for example a foreground start refused before admission). Every other journal +/// state keeps the ordinary receipt validation. pub fn inspect(candidate: &Candidate, run: &str) -> Result { + #[cfg(target_os = "macos")] + if unreserved(candidate, run)? { + return Err(error( + "graph_unreserved", + "No runtime state was reserved for this graph attempt: no journal exists and no foreground owner is published. Nothing was created, repaired or adopted.", + )); + } let engine = Engine::connect_cleanup(candidate)?; inspect_using(candidate, &engine, run) } +/// Lock-free current service states for one owned graph attempt. +/// +/// For observers that must not contend with an operation holding the provider +/// lease, such as a foreground `up` reporting service exits while `exec` runs. +/// It takes no lease, issues only engine GETs, verifies each recorded container's +/// ownership labels and identity, runs no probes and grants no authority: the +/// result is a point-in-time observation (`running` health is the engine's own). +#[cfg(target_os = "macos")] +pub fn observe(candidate: &Candidate, run: &str) -> Result { + let observer = super::engine::Observer::connect(candidate)?; + let (receipt, _) = load_at(directory(candidate, run)?, run, observer.incarnation())?; + let mut services = serde_json::Map::new(); + for resource in receipt + .resources + .values() + .filter(|resource| resource.kind == Kind::Container) + { + let state = match resource.id.as_deref() { + None => json!({"state":"absent"}), + Some(id) => match observer.get(&format!("/v1.53/containers/{id}/json")) { + Ok(value) => { + verify_resource_identity(&receipt, resource, &value)?; + match observation(&value) { + Ok(observed) => serde_json::to_value(observed).map_err(|_| { + error("graph_output", "Cannot encode graph observation.") + })?, + // A restarting or paused container is neither running nor stopped. + Err(_) => json!({"state":"unknown"}), + } + } + Err(error) if error.code == "engine_not_found" => json!({"state":"absent"}), + Err(error) => return Err(error), + }, + }; + services.insert(resource.key.clone(), state); + } + Ok(json!({"run":receipt.run,"phase":receipt.phase,"services":services})) +} + +/// A foreground start binds its publication before it creates the journal, so +/// the journal is checked on both sides of the publication check. +#[cfg(target_os = "macos")] +fn unreserved(candidate: &Candidate, run: &str) -> Result { + let absent = |path: &std::path::Path| match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Ok(_) => Ok(false), + Err(error) => Err(state::io(error)), + }; + let journals_absent = || -> Result { + Ok(absent(&directory(candidate, run)?)? + && absent(&archive::path(candidate, run)?)? + && absent(&retention::consumed(candidate, run)?)?) + }; + if !journals_absent()? { + return Ok(false); + } + let publication = foreground::transport::root(candidate, run)?; + if !(absent(&publication.join("owner.json"))? && absent(&publication.join("control.sock"))?) { + return Ok(false); + } + journals_absent() +} + fn unchanged_normalized_review( receipt: &Receipt, plan_id: &str, @@ -2172,7 +2250,8 @@ fn verify_images( return Err(error( "graph_image", "Pinned image architecture or implicit volumes are incompatible.", - )); + ) + .with_service(&resource.key)); } if private_services.contains(&resource.key) { image_process::apply_private( diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index 21b87ce94..a25c3d255 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -397,7 +397,7 @@ pub fn serve_with_storage_tool( }; let result = result.unwrap_or_else(|error| Outcome::Refused { code: error.code.into(), - stop_failures: error.stop_failures, + stop_failures: error.stop_failures.map(|detail| *detail), }); let cleaned = matches!(result, Outcome::Cleaned { .. }); let reply = Reply { @@ -559,7 +559,7 @@ fn request_selected( } = reply.result { let mut error = refused().with_cause_code(code); - error.stop_failures = stop_failures; + error.stop_failures = stop_failures.map(Box::new); return Err(error); } serde_json::to_value(reply).map_err(|_| refused()) diff --git a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs index 362ec4583..b68a4190c 100644 --- a/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/persistent_data/enrollment/tests.rs @@ -332,7 +332,8 @@ fn adapter_errors_are_normalized_before_return_including_ambiguous_original_crea serde_json::to_value(&error).unwrap(), json!({ "code": "native_persistent_data_enrollment", - "message": "Persistent data enrollment is incomplete, ambiguous or changed; retained data was not adopted or deleted." + "message": "Persistent data enrollment is incomplete, ambiguous or changed; retained data was not adopted or deleted.", + "message_fixed": true }) ); assert!(!serde_json::to_string(&error).unwrap().contains("private-")); diff --git a/packages/runtime-core/src/provider/graph/relay.rs b/packages/runtime-core/src/provider/graph/relay.rs index 48aa6c22e..f11dc7369 100644 --- a/packages/runtime-core/src/provider/graph/relay.rs +++ b/packages/runtime-core/src/provider/graph/relay.rs @@ -344,7 +344,7 @@ fn check_observation_stage(output: &str) -> Result<(), CandidateError> { if output == format!("relay-observation-refused {}\n", index + 1) { return Err(error( "graph_bridge_observation", - &format!( + format!( "Bridge observation refused at stage {} ({stage}); no guest mutation was performed.", index + 1 ), diff --git a/packages/runtime-core/src/provider/graph/service_logs.rs b/packages/runtime-core/src/provider/graph/service_logs.rs index a44fdd59d..02c56a178 100644 --- a/packages/runtime-core/src/provider/graph/service_logs.rs +++ b/packages/runtime-core/src/provider/graph/service_logs.rs @@ -1,5 +1,6 @@ //! Explicit bounded application log reads. Output is returned to the caller, never journaled. use super::{Engine, Kind, Resource, error, hex, inspect_resource, load, service_exec_generation}; +use crate::provider::engine::LogLine; use crate::{Candidate, CandidateError}; use serde::Serialize; @@ -15,8 +16,11 @@ pub struct ServiceLogOptions<'a> { #[derive(Debug, Serialize)] pub struct ServiceLogResult { pub container: String, + /// The retained lines of each stream, kept for callers that read them apart. pub stdout: String, pub stderr: String, + /// The same retained lines in engine order (interleaved by time); whole lines only. + pub lines: Vec, pub truncated: bool, } @@ -129,7 +133,8 @@ fn selected(resource: &Resource, options: &ServiceLogOptions<'_>) -> Result<(), /// Read an explicitly selected service, including exited services, under the VM lease. /// Immutable identity and ownership are checked before and after reading. Application /// logs may contain sensitive values; callers must not persist them as diagnostic receipts. -/// Text is UTF-8-lossy and capped at 16 KiB of JSON text per stream; truncation is explicit. +/// Text is UTF-8-lossy whole lines, newest kept within the engine's bounded tail +/// budget; truncation is explicit. pub fn service_logs( candidate: &Candidate, options: &ServiceLogOptions<'_>, @@ -151,15 +156,17 @@ pub fn service_logs( if inspect_resource(&engine, &receipt, resource)?.is_none() { return Err(refused()); } - let (stdout, stderr, truncated) = engine.logs_tail(options.expected_container, options.tail)?; + let tail = engine.logs_tail_lines(options.expected_container, options.tail)?; if inspect_resource(&engine, &receipt, resource)?.is_none() { return Err(refused()); } + let (stdout, stderr) = tail.split(); Ok(ServiceLogResult { container: options.expected_container.to_owned(), stdout, stderr, - truncated, + lines: tail.lines, + truncated: tail.truncated, }) } diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index b45dc5d7f..478cf2e7f 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -479,7 +479,10 @@ fn cli(candidate: &Candidate, args: &[&str]) -> Result { } return Err(error( "graph_test_cli", - value["code"].as_str().unwrap_or("unknown CLI failure"), + value["code"] + .as_str() + .unwrap_or("unknown CLI failure") + .to_owned(), )); } serde_json::from_slice(&output.stdout).map_err(|_| error("graph_test", "Invalid CLI receipt.")) diff --git a/packages/runtime-core/src/provider/graph_probe_test.rs b/packages/runtime-core/src/provider/graph_probe_test.rs index 3c18559ba..5abf41675 100644 --- a/packages/runtime-core/src/provider/graph_probe_test.rs +++ b/packages/runtime-core/src/provider/graph_probe_test.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; use std::{path::Path, time::Duration}; -fn error(message: &str) -> CandidateError { +fn error(message: impl Into) -> CandidateError { CandidateError::new("graph_probe", message) } diff --git a/packages/runtime-core/src/provider/image_ensure.rs b/packages/runtime-core/src/provider/image_ensure.rs index fe2c06af1..6d8815690 100644 --- a/packages/runtime-core/src/provider/image_ensure.rs +++ b/packages/runtime-core/src/provider/image_ensure.rs @@ -12,7 +12,7 @@ use std::{ const MAX_ARCHIVES: usize = 16; const MAX_ARCHIVE: u64 = 256 * 1024 * 1024; const MAX_CACHE: u64 = 2 * 1024 * 1024 * 1024; -fn refused(message: &str) -> CandidateError { +fn refused(message: impl Into) -> CandidateError { CandidateError::new("image_cache", message) } fn current_uid() -> u32 { diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index b49e9781e..e75c4bc47 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -17,7 +17,7 @@ use std::{ const MAX_ARCHIVE: u64 = 256 * 1024 * 1024; const MAX_EXPANDED: u64 = 2 * 1024 * 1024 * 1024; -fn error(message: &str) -> CandidateError { +fn error(message: impl Into) -> CandidateError { CandidateError::new("image_archive", message) } fn hex(value: &str) -> bool { diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 9c3a20650..a946b1a8b 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -360,6 +360,11 @@ impl<'a> ObservedGuest<'a> { self.verify()?; socket(self.candidate, &self.owner, "docker.sock") } + + /// The observed runtime incarnation; receipts name the incarnation that owns them. + pub(super) fn incarnation(&self) -> &str { + &self.owner.token + } } pub(super) struct OwnedGuest<'a> { diff --git a/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs b/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs index b0b608385..77243a0f9 100644 --- a/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs +++ b/packages/runtime-core/src/provider/lifecycle/prepared_boot.rs @@ -38,7 +38,7 @@ use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -fn error(code: &'static str, message: impl Into) -> CandidateError { +fn error(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } diff --git a/packages/runtime-core/src/provider/network_tools.rs b/packages/runtime-core/src/provider/network_tools.rs index 48d8b0176..815fb8665 100644 --- a/packages/runtime-core/src/provider/network_tools.rs +++ b/packages/runtime-core/src/provider/network_tools.rs @@ -28,7 +28,7 @@ const PACKAGES: [(&str, &str); 4] = [ "f0accefde240ece6722479b46cb014d7d2f745af7d796e8eec3ced53571e1088", ), ]; -fn error(message: &str) -> CandidateError { +fn error(message: impl Into) -> CandidateError { CandidateError::new("network_tools", message) } fn read_archive(path: &Path, expected: &str) -> Result, CandidateError> { @@ -112,7 +112,7 @@ fn prepare_packages( fs::DirBuilder::new() .mode(0o700) .create(&staging) - .map_err(|e| error(&format!("Cannot create network-tools staging: {e}")))?; + .map_err(|e| error(format!("Cannot create network-tools staging: {e}")))?; for ((name, _), bytes) in packages.iter().zip(archives) { let mut file = OpenOptions::new() .write(true) @@ -120,7 +120,7 @@ fn prepare_packages( .mode(0o600) .custom_flags(libc::O_NOFOLLOW) .open(staging.join(name)) - .map_err(|e| error(&format!("Cannot create staged archive: {e}")))?; + .map_err(|e| error(format!("Cannot create staged archive: {e}")))?; file.write_all(&bytes).map_err(state::io)?; file.sync_all().map_err(state::io)?; } @@ -131,7 +131,7 @@ fn prepare_packages( .and_then(|directory| directory.sync_all()) .map_err(state::io)?; fs::rename(&staging, &target) - .map_err(|e| error(&format!("Cannot publish network-tools directory: {e}")))?; + .map_err(|e| error(format!("Cannot publish network-tools directory: {e}")))?; File::open(&providers) .and_then(|directory| directory.sync_all()) .map_err(state::io)?; diff --git a/packages/runtime-core/src/provider/prepared_base.rs b/packages/runtime-core/src/provider/prepared_base.rs index 6d6436123..c93c773f6 100644 --- a/packages/runtime-core/src/provider/prepared_base.rs +++ b/packages/runtime-core/src/provider/prepared_base.rs @@ -84,7 +84,7 @@ const RECORD: &str = "prepared-base.json"; const PENDING: &str = "prepared-base.json.pending"; const TEMPLATE_DIR: &str = "home/.smolvm"; -fn error(code: &'static str, message: impl Into) -> CandidateError { +fn error(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } diff --git a/packages/runtime-core/src/provider/prepared_start.rs b/packages/runtime-core/src/provider/prepared_start.rs index 85db49fd0..0c89fe200 100644 --- a/packages/runtime-core/src/provider/prepared_start.rs +++ b/packages/runtime-core/src/provider/prepared_start.rs @@ -37,7 +37,7 @@ const SEED: &str = "prepared-seed.json"; const TEMPLATE_DIR: &str = "home/.smolvm"; const LIMIT: u64 = 64 * 1024; -fn error(code: &'static str, message: impl Into) -> CandidateError { +fn error(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } diff --git a/packages/runtime-core/src/provider/prepared_store.rs b/packages/runtime-core/src/provider/prepared_store.rs index d9d6fb120..ff5cdedbe 100644 --- a/packages/runtime-core/src/provider/prepared_store.rs +++ b/packages/runtime-core/src/provider/prepared_store.rs @@ -26,7 +26,7 @@ const LOCK: &str = "store.lock"; const VERIFIED: &str = ".verified"; const LIMIT: u64 = 256 * 1024; -fn error(code: &'static str, message: impl Into) -> CandidateError { +fn error(code: &'static str, message: impl Into) -> CandidateError { CandidateError::new(code, message) } diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 19c02c89f..6e2b340ed 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -18,7 +18,7 @@ use zeroize::Zeroizing; const MAX_ARCHIVE: usize = 256 * 1024 * 1024; const MAX_METADATA: usize = 1024 * 1024; const ACCEPT_MANIFEST: &str = "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json"; -fn error(message: &str) -> CandidateError { +fn error(message: impl Into) -> CandidateError { CandidateError::new("registry_image", message) } fn digest(bytes: &[u8]) -> String { diff --git a/packages/runtime-core/src/provider/source_failure_test.rs b/packages/runtime-core/src/provider/source_failure_test.rs index d594570ec..f16cd7ee6 100644 --- a/packages/runtime-core/src/provider/source_failure_test.rs +++ b/packages/runtime-core/src/provider/source_failure_test.rs @@ -4,7 +4,7 @@ use crate::{Candidate, CandidateError, project}; use serde_json::json; use std::{fs, path::Path}; -fn failure(message: &str) -> CandidateError { +fn failure(message: impl Into) -> CandidateError { CandidateError::new("source_failure_test", message) } diff --git a/packages/runtime-core/src/provider/source_job.rs b/packages/runtime-core/src/provider/source_job.rs index 94a3d98eb..9e0012428 100644 --- a/packages/runtime-core/src/provider/source_job.rs +++ b/packages/runtime-core/src/provider/source_job.rs @@ -35,7 +35,7 @@ fn hex(value: &str) -> bool { .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } -fn error(message: &str) -> CandidateError { +fn error(message: impl Into) -> CandidateError { CandidateError::new("source_job", message) } diff --git a/packages/runtime-core/src/provider/source_job_test.rs b/packages/runtime-core/src/provider/source_job_test.rs index 3aae710a0..6dae28f98 100644 --- a/packages/runtime-core/src/provider/source_job_test.rs +++ b/packages/runtime-core/src/provider/source_job_test.rs @@ -16,7 +16,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -fn failure(message: &str) -> CandidateError { +fn failure(message: impl Into) -> CandidateError { CandidateError::new("source_job_test", message) } fn now() -> u64 { diff --git a/packages/runtime-core/src/provider/source_output_test.rs b/packages/runtime-core/src/provider/source_output_test.rs index 8c3c8a623..778c52780 100644 --- a/packages/runtime-core/src/provider/source_output_test.rs +++ b/packages/runtime-core/src/provider/source_output_test.rs @@ -9,7 +9,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -fn failure(message: &str) -> CandidateError { +fn failure(message: impl Into) -> CandidateError { CandidateError::new("source_output_test", message) } diff --git a/packages/runtime-core/src/provider/source_probe.rs b/packages/runtime-core/src/provider/source_probe.rs index 95fc1b064..7b189ee98 100644 --- a/packages/runtime-core/src/provider/source_probe.rs +++ b/packages/runtime-core/src/provider/source_probe.rs @@ -23,7 +23,7 @@ pub struct ProbeReceipt { pub failure_code: Option, } -fn problem(message: &str) -> CandidateError { +fn problem(message: impl Into) -> CandidateError { CandidateError::new("source_probe", message) } fn hex(value: &str, length: usize) -> bool { diff --git a/packages/runtime-core/src/provider/source_sync.rs b/packages/runtime-core/src/provider/source_sync.rs index 46f2ec003..e0253930c 100644 --- a/packages/runtime-core/src/provider/source_sync.rs +++ b/packages/runtime-core/src/provider/source_sync.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; const MAX_RECORD: u64 = 16 * 1024 * 1024; -fn error(message: &str) -> CandidateError { +fn error(message: impl Into) -> CandidateError { CandidateError::new("source_sync", message) } fn hex(value: &str, length: usize) -> bool { diff --git a/packages/runtime-core/src/provider/source_watch_test.rs b/packages/runtime-core/src/provider/source_watch_test.rs index 11cd99739..fdd1e22a4 100644 --- a/packages/runtime-core/src/provider/source_watch_test.rs +++ b/packages/runtime-core/src/provider/source_watch_test.rs @@ -24,7 +24,7 @@ printf '%s\n' "$value" > /output/value.next printf '%s\n' "$1" >> /output/events "#; -fn fail(message: &str) -> CandidateError { +fn fail(message: impl Into) -> CandidateError { CandidateError::new("watch_observer", message) } diff --git a/packages/runtime-core/src/provider/supervisor_loss_test.rs b/packages/runtime-core/src/provider/supervisor_loss_test.rs index 024aa0f15..78a717ed5 100644 --- a/packages/runtime-core/src/provider/supervisor_loss_test.rs +++ b/packages/runtime-core/src/provider/supervisor_loss_test.rs @@ -15,7 +15,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -fn failure(message: &str) -> CandidateError { +fn failure(message: impl Into) -> CandidateError { CandidateError::new("supervisor_loss_test", message) } fn now() -> u64 { diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index 4028bef1d..20cd0eafc 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -2510,3 +2510,46 @@ fn disposable_cache_declarations_are_exact_volume_relative_and_review_bound() { "invalid_disposable_cache" ); } + +#[test] +fn text_plan_exits_non_zero_on_error_diagnostics_and_json_keeps_the_report() { + let compiled = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap(); + let plan = |fixture: &Fixture, json: bool| { + let mut args = vec![ + "--candidate-root".to_owned(), + compiled.to_str().unwrap().to_owned(), + "project".into(), + "plan".into(), + "--project".into(), + fixture.project.to_str().unwrap().to_owned(), + "--file".into(), + "compose.yaml".into(), + ]; + if json { + args.push("--json".into()); + } + Command::new(env!("CARGO_BIN_EXE_hack-runtime-candidate")) + .env_clear() + .env("PATH", "") + .args(args) + .output() + .unwrap() + }; + let clean = Fixture::new(BASIC); + assert!(plan(&clean, false).status.success()); + let failing = Fixture::new(&format!("{BASIC} privileged: true\n")); + let text = plan(&failing, false); + assert!(!text.status.success()); + let stdout = String::from_utf8(text.stdout).unwrap(); + assert!(stdout.contains("error [privileged_service]"), "{stdout}"); + let refusal: serde_json::Value = serde_json::from_slice(&text.stderr).unwrap(); + assert_eq!(refusal["code"], "plan_diagnostics"); + assert_eq!(refusal["message_fixed"], true); + let json = plan(&failing, true); + assert!(json.status.success()); + let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap(); + assert_eq!(report["plan"]["enrollment_compatible"], false); +} diff --git a/src/backends/native-project-observe.ts b/src/backends/native-project-observe.ts index 38e275da1..c4e685249 100644 --- a/src/backends/native-project-observe.ts +++ b/src/backends/native-project-observe.ts @@ -286,6 +286,7 @@ export async function nativeProjectLogs( ) { throw refused(); } + const lines = logLines(result.lines, result.stdout, result.stderr); const after = await inspect(opts, run); if ( after.items.find((item) => item.service === opts.service)?.container !== @@ -300,10 +301,49 @@ export async function nativeProjectLogs( container: service.container, stdout: result.stdout, stderr: result.stderr, + lines, truncated: result.truncated, }; } +/** One retained log line in engine (time) order. */ +export type NativeLogLine = { + readonly stream: "stdout" | "stderr"; + readonly text: string; +}; + +/** + * The runtime's whole lines in engine order. A runtime without `lines` returns + * the streams apart, so their order is unknown: keep stdout before stderr. + */ +function logLines( + value: unknown, + stdout: string, + stderr: string +): NativeLogLine[] { + if (value === undefined) { + return [ + ...(stdout ? [{ stream: "stdout" as const, text: stdout }] : []), + ...(stderr ? [{ stream: "stderr" as const, text: stderr }] : []), + ]; + } + if (!Array.isArray(value) || value.length > 16_384) { + throw refused(); + } + return value.map((line: unknown) => { + if ( + !( + isRecord(line) && + (line.stream === "stdout" || line.stream === "stderr") && + typeof line.text === "string" + ) + ) { + throw refused(); + } + return { stream: line.stream, text: line.text }; + }); +} + /** Refuse before any legacy lifecycle effects until the native operation is wired. */ export function requireComposeOperationAvailable( operation: "down" | "restart" | "run" | "exec", diff --git a/src/backends/native-project-process.ts b/src/backends/native-project-process.ts index 50b3bf91b..a46a63fd8 100644 --- a/src/backends/native-project-process.ts +++ b/src/backends/native-project-process.ts @@ -14,6 +14,7 @@ import { invokeNativeRuntime, NATIVE_EXIT_DRAIN_BOUND_MS, type NativeRuntimeSelection, + nativeFailureNote, readNativeFailureCodes, rethrowNativeInputFailure, writeNativePrivateInput, @@ -446,9 +447,7 @@ async function serveGraphProcess( nativeCode: failure, }); if (timedOut || canceled || !ready) { - throw new ClosedCliError( - `Native graph startup was interrupted or failed${failure ? ` (${failure})` : ""}; inspect owned state before retrying.` - ); + throw interruptedStartup(await failureCodes); } return code; } finally { @@ -485,6 +484,15 @@ async function serveGraphProcess( } } +/** Closed startup failure with the runtime's bounded code and value-free note. */ +function interruptedStartup( + failure: Awaited> +): Error { + return new ClosedCliError( + `Native graph startup was interrupted or failed${failure ? ` (${failure.code})` : ""}; inspect owned state before retrying.${failure ? nativeFailureNote(failure) : ""}` + ); +} + /** An attached finite-hook owner must not retire while its captured detached group is present. * Unknown permission or lifetime is a refusal, never a signal to a later/reused group. */ diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index e40645462..854d6bd84 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -78,6 +78,7 @@ import { import { preflightNativeProjectSource } from "./native-project-source-preflight.ts"; import { invokeNativeRuntime, + NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; import { @@ -85,6 +86,10 @@ import { type NativeRuntimeMetadataSelection, verifyNativeRuntimeMetadataRoutes, } from "./native-runtime-metadata.ts"; +import { + monitorNativeServiceExits, + type NativeServiceExit, +} from "./native-service-exit-monitor.ts"; const SHA = /^[a-f0-9]{64}$/; const OWNER = /^[a-f0-9]{32}$/; @@ -265,6 +270,8 @@ type Dependencies = { finalization: typeof beginNativeProjectFinalization; captureFinalization: typeof captureNativeProjectFinalization; waitFinalization: typeof waitNativeProjectFinalization; + /** Poll interval of the ready graph's service exit monitor. */ + exitMonitorIntervalMs: number; }; const DEFAULTS: Dependencies = { prepare: prepareNativeProjectInput, @@ -286,6 +293,7 @@ const DEFAULTS: Dependencies = { finalization: beginNativeProjectFinalization, captureFinalization: captureNativeProjectFinalization, waitFinalization: waitNativeProjectFinalization, + exitMonitorIntervalMs: 2000, }; /** Fixed text of the native foreground configuration refusal. */ export const NATIVE_CONFIGURATION_REFUSAL = @@ -604,20 +612,50 @@ function environmentDelivery( }; } const SAFE_STARTUP_DIAGNOSTIC = - /^(Native (?:graph startup (?:failed|was interrupted or failed)|HTTPS verification failed)) \(([A-Za-z0-9_]{1,80})\); (?:inspect owned state before retrying\.|peer values omitted\.)$/; -/** Preserve only the fixed diagnostic envelope emitted by the native clients. */ + /^(Native (?:graph startup (?:failed|was interrupted or failed)|HTTPS verification failed)) \(([A-Za-z0-9_]{1,80})\); (?:inspect owned state before retrying\.|peer values omitted\.)( Service [A-Za-z0-9_.-]{1,128}: [\x20-\x7e]{1,400}| Runtime: [\x20-\x7e]{1,400}| Service: [A-Za-z0-9_.-]{1,128}\.)?$/; +/** + * Preserve only the fixed diagnostic envelope emitted by the native clients, + * including the value-free runtime note they append (see `nativeFailureNote`). + */ +function startupDiagnostic(startupFailure: unknown): string { + if (startupFailure === undefined) { + return ""; + } + const message = startupFailure instanceof Error ? startupFailure.message : ""; + const diagnostic = SAFE_STARTUP_DIAGNOSTIC.exec(message); + if (!diagnostic) { + return " Startup diagnostic: STARTUP_FAILURE (details omitted)."; + } + return ` Startup diagnostic: ${diagnostic[1]} (${diagnostic[2]}).${diagnostic[3] ?? ""}`; +} function cleanupUnconfirmed( startupFailure?: unknown, nativeCode?: string ): Error { - const message = startupFailure instanceof Error ? startupFailure.message : ""; - const diagnostic = SAFE_STARTUP_DIAGNOSTIC.exec(message); - const startup = - startupFailure === undefined - ? "" - : ` Startup diagnostic: ${diagnostic ? `${diagnostic[1]} (${diagnostic[2]})` : "STARTUP_FAILURE (details omitted)"}.`; return new ClosedCliError( - `Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying.${startup}${nativeCode ? ` Native exit diagnostic: ${nativeCode}.` : ""}` + `Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying.${startupDiagnostic(startupFailure)}${nativeCode ? ` Native exit diagnostic: ${nativeCode}.` : ""}` + ); +} +/** + * The settled foreground start failed and fresh inspection proves this run never + * reserved runtime state (no journal, no published owner). Nothing is retained, + * so report the startup failure itself instead of uncertain cleanup. + */ +function unreservedStartupFailure( + startupFailure: unknown, + inspectionFailure: unknown +): Error | undefined { + if ( + startupFailure === undefined || + !( + inspectionFailure instanceof NativeRuntimeRequestError && + inspectionFailure.nativeCode === "graph_unreserved" + ) + ) { + return; + } + return new ClosedCliError( + `Native graph startup failed before any runtime state was reserved; nothing was retained.${startupDiagnostic(startupFailure)}` ); } function requireConfirmedCleanup( @@ -895,11 +933,20 @@ async function requireAcknowledgedRetainedFinalization(opts: { * service names. Observation only: a throwing observer never changes startup. */ export type NativeStartupProgress = - | { readonly phase: "input" | "runtime" | "images" | "stopping" | "stopped" } + | { + readonly phase: + | "input" + | "runtime" + | "images" + | "all-stopped" + | "stopping" + | "stopped"; + } | { readonly phase: "services" | "ready"; readonly services: readonly string[]; - }; + } + | ({ readonly phase: "service-exited" } & NativeServiceExit); function progressReporter( observe: ((event: NativeStartupProgress) => void) | undefined @@ -913,6 +960,36 @@ function progressReporter( }; } +/** + * Watch a ready foreground graph for stopped services, as attached Compose `up` + * does. When every service has stopped, `up` ends like Compose: request the + * ordinary owned stop and exit 0. Returns the monitor's stop function. + */ +function watchServiceExits(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly run: string; + readonly intervalMs: number; + readonly invoke: typeof invokeNativeRuntime; + readonly progress: (event: NativeStartupProgress) => void; + readonly onAllStopped: () => void; +}): () => Promise { + const controller = new AbortController(); + const monitoring = monitorNativeServiceExits({ + ...opts, + signal: controller.signal, + onExit: (exit) => opts.progress({ phase: "service-exited", ...exit }), + onAllStopped: () => { + opts.progress({ phase: "all-stopped" }); + opts.onAllStopped(); + }, + }).catch(() => undefined); + return async () => { + controller.abort(); + await monitoring; + }; +} + /** After confirmed cleanup, an interrupted foreground graph has stopped. */ function reportStopped( progress: (event: NativeStartupProgress) => void, @@ -1318,6 +1395,7 @@ export async function startNativeProject(opts: { let nativeExitCode: string | undefined; const services = Object.keys(specs).sort(); progress({ phase: "services", services }); + let stopWatching: (() => Promise) | undefined; try { code = await deps.serve({ runtime: opts.runtime, @@ -1448,20 +1526,38 @@ export async function startNativeProject(opts: { await hooks?.ready?.(); await opts.onReady?.(); progress({ phase: "ready", services }); + stopWatching = watchServiceExits({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + run, + intervalMs: deps.exitMonitorIntervalMs, + invoke: deps.invoke, + progress, + onAllStopped: () => { + interruptedExit = 0; + controller.abort(); + }, + }); }, }); } catch (error) { serveFailure = error; } finally { delivery.payload?.fill(0); + await stopWatching?.(); } let final: unknown; try { final = await invokeInspect(); } catch (inspectionFailure) { - throw cleanupUnconfirmed( - serveFailure ?? inspectionFailure, - nativeExitCode + // deps.serve settles only after the owned child exited, so the + // consumer that could still reserve state is gone. + throw ( + unreservedStartupFailure(serveFailure, inspectionFailure) ?? + cleanupUnconfirmed( + serveFailure ?? inspectionFailure, + nativeExitCode + ) ); } const confirmed = await confirmForegroundCleanup({ diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 1a64ca6e0..5bbc79e37 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -16,9 +16,62 @@ const NATIVE_HASH = /^[a-f0-9]{64}$/; interface NativeFailure { readonly code: string; readonly causeCode?: string; + /** Runtime message the runtime marked as a compile-time literal (value-free). */ + readonly message?: string; + /** Admitted service the runtime named, as a plain service identifier. */ + readonly service?: string; readonly stopFailures?: readonly NativeStopFailure[]; } +const FIXED_MESSAGE = /^[\x20-\x7e]{1,400}$/; + +/** + * Keep a runtime message only when the runtime marked it fixed (a literal that + * cannot carry runtime values) and it is one bounded printable line. + */ +function fixedNativeMessage( + value: Record +): string | undefined { + return value.message_fixed === true && + typeof value.message === "string" && + FIXED_MESSAGE.test(value.message) + ? value.message + : undefined; +} + +/** The fixed message and validated service a runtime failure carries, if any. */ +function nativeFailureDetail(value: Record): { + message?: string; + service?: string; +} { + const message = fixedNativeMessage(value); + const service = + typeof value.service === "string" && NATIVE_SERVICE.test(value.service) + ? value.service + : undefined; + return { + ...(message ? { message } : {}), + ...(service ? { service } : {}), + }; +} + +/** + * Value-free runtime detail appended after a closed native failure sentence: + * the fixed runtime message and the service it concerns, when known. + */ +export function nativeFailureNote(detail: { + readonly message?: string; + readonly service?: string; +}): string { + if (detail.service && detail.message) { + return ` Service ${detail.service}: ${detail.message}`; + } + if (detail.message) { + return ` Runtime: ${detail.message}`; + } + return detail.service ? ` Service: ${detail.service}.` : ""; +} + /** * Structured native codes remain safe to inspect without exposing subprocess * diagnostics. The message is closed, so the CLI prints it without a stack. @@ -26,17 +79,24 @@ interface NativeFailure { export class NativeRuntimeRequestError extends ClosedCliError { readonly nativeCode: string | undefined; readonly nativeCauseCode: string | undefined; + /** Fixed (value-free) runtime message; see {@link nativeFailureNote}. */ + readonly nativeMessage: string | undefined; + readonly nativeService: string | undefined; readonly nativeStopFailures: readonly NativeStopFailure[] | undefined; constructor(opts: { readonly message: string; readonly nativeCode?: string; readonly nativeCauseCode?: string; + readonly nativeMessage?: string; + readonly nativeService?: string; readonly nativeStopFailures?: readonly NativeStopFailure[]; }) { super(opts.message); this.nativeCode = opts.nativeCode; this.nativeCauseCode = opts.nativeCauseCode; + this.nativeMessage = opts.nativeMessage; + this.nativeService = opts.nativeService; this.nativeStopFailures = opts.nativeStopFailures; } } @@ -475,7 +535,7 @@ function nativeFailureMessage(failure: NativeFailure | undefined): string { const code = failure ? ` (${failure.code}${failure.causeCode ? `: ${failure.causeCode}` : ""})` : ""; - return `Native runtime request failed${code}; inspect owned state before retrying.${nativeStopFailureSummary(failure?.stopFailures)}`; + return `Native runtime request failed${code}; inspect owned state before retrying.${failure ? nativeFailureNote(failure) : ""}${nativeStopFailureSummary(failure?.stopFailures)}`; } function completionResponse( @@ -495,6 +555,8 @@ function completionResponse( : nativeFailureMessage(failure), nativeCode: timedOut ? undefined : failure?.code, nativeCauseCode: timedOut ? undefined : failure?.causeCode, + nativeMessage: timedOut ? undefined : failure?.message, + nativeService: timedOut ? undefined : failure?.service, nativeStopFailures: timedOut ? undefined : failure?.stopFailures, }); } @@ -647,6 +709,7 @@ async function readNativeFailure( return { code: value.code, ...(causeCode ? { causeCode } : {}), + ...nativeFailureDetail(value), ...(["engine_protocol", "engine_rejected", "engine_not_found"].includes( value.code === "graph_owner_recovery" && typeof value.cause_code === "string" @@ -672,17 +735,30 @@ export async function readNativeFailureCode( return (await readNativeFailure(stream, signal))?.code; } -/** Like {@link readNativeFailureCode}, plus the bounded cause code it allows. */ +/** + * Like {@link readNativeFailureCode}, plus the bounded cause code it allows and + * the value-free detail of {@link nativeFailureNote}. + */ export async function readNativeFailureCodes( stream: ReadableStream, signal?: AbortSignal -): Promise<{ readonly code: string; readonly causeCode?: string } | undefined> { +): Promise< + | { + readonly code: string; + readonly causeCode?: string; + readonly message?: string; + readonly service?: string; + } + | undefined +> { const failure = await readNativeFailure(stream, signal); return failure === undefined ? undefined : { code: failure.code, ...(failure.causeCode ? { causeCode: failure.causeCode } : {}), + ...(failure.message ? { message: failure.message } : {}), + ...(failure.service ? { service: failure.service } : {}), }; } diff --git a/src/backends/native-service-exit-monitor.ts b/src/backends/native-service-exit-monitor.ts new file mode 100644 index 000000000..b2d43dd72 --- /dev/null +++ b/src/backends/native-service-exit-monitor.ts @@ -0,0 +1,152 @@ +import { isRecord } from "../lib/guards.ts"; +import { + invokeNativeRuntime, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +const SERVICE = /^[A-Za-z0-9_.-]{1,128}$/; +const STOPPED = new Set(["exited", "dead", "absent"]); + +/** A service container that stopped while the foreground graph was ready. */ +export type NativeServiceExit = + | { + readonly service: string; + readonly state: "exited"; + readonly code: number; + } + | { readonly service: string; readonly state: "dead" | "absent" }; + +type ServiceStates = ReadonlyMap; + +/** + * Parse a lock-free `graph observe` reply for exactly this run. Anything else + * yields no observation; the monitor is advisory and never grants authority. + */ +function observed(value: unknown, run: string): ServiceStates | undefined { + if ( + !(isRecord(value) && value.run === run && isRecord(value.services)) || + value.phase !== "ready-observed" + ) { + return; + } + const states = new Map(); + for (const [service, observation] of Object.entries(value.services)) { + if ( + !( + SERVICE.test(service) && + isRecord(observation) && + typeof observation.state === "string" + ) + ) { + return; + } + states.set(service, { + state: observation.state, + ...(Number.isSafeInteger(observation.code) + ? { code: observation.code as number } + : {}), + }); + } + return states; +} + +function exitOf( + service: string, + current: { state: string; code?: number } +): NativeServiceExit | undefined { + if (current.state === "exited" && current.code !== undefined) { + return { service, state: "exited", code: current.code }; + } + if (current.state === "dead" || current.state === "absent") { + return { service, state: current.state }; + } + return; +} + +/** + * Report service containers that stop while a foreground graph is ready, as + * attached Compose `up` does ("SERVICE exited with code N"). Each stop is + * reported once per running-to-stopped transition. When every service + * container has stopped, `onAllStopped` runs once: Compose `up` then ends. + * + * Polls `graph observe`, which takes no provider lease, so `exec`, `logs` + * and `ps` from another terminal never contend with it. Observation failures + * are skipped; this monitor never stops, restarts or repairs anything itself. + * Stop it by aborting `signal`; the returned promise settles after the last poll. + */ +export async function monitorNativeServiceExits(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly run: string; + readonly signal: AbortSignal; + readonly intervalMs: number; + readonly invoke?: typeof invokeNativeRuntime; + readonly onExit: (exit: NativeServiceExit) => void; + readonly onAllStopped: () => void; +}): Promise { + const invoke = opts.invoke ?? invokeNativeRuntime; + const previous = new Map(); + while (!opts.signal.aborted) { + await waitOrAbort(opts.intervalMs, opts.signal); + if (opts.signal.aborted) { + return; + } + let states: ServiceStates | undefined; + try { + states = observed( + await invoke({ + runtime: opts.runtime, + cwd: opts.projectRoot, + args: ["graph", "observe", "--run-id", opts.run, "--json"], + timeoutMs: 15_000, + signal: opts.signal, + }), + opts.run + ); + } catch { + states = undefined; + } + if (!states || opts.signal.aborted) { + continue; + } + reportTransitions(states, previous, opts.onExit); + if ( + states.size > 0 && + [...states.values()].every((current) => STOPPED.has(current.state)) + ) { + opts.onAllStopped(); + return; + } + } +} + +/** Report each running-to-stopped transition once; remember the new states. */ +function reportTransitions( + states: ServiceStates, + previous: Map, + onExit: (exit: NativeServiceExit) => void +): void { + for (const [service, current] of states) { + const before = previous.get(service); + previous.set(service, current.state); + if (before === current.state || (before && STOPPED.has(before))) { + continue; + } + const exit = exitOf(service, current); + if (exit) { + onExit(exit); + } + } +} + +function waitOrAbort(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + signal.addEventListener("abort", done, { once: true }); + }); +} diff --git a/src/commands/project.ts b/src/commands/project.ts index 647f9ddec..c0175832e 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -76,6 +76,7 @@ import { startNativeProject, } from "../backends/native-project-start.ts"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import type { NativeServiceExit } from "../backends/native-service-exit-monitor.ts"; import { composeRuntimeBackend } from "../backends/runtime-backend.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { @@ -5719,6 +5720,16 @@ async function runRemoteLifecycleCommand(opts: { }); } +/** Compose-style line for a service that stopped while `up` runs. */ +function nativeServiceExitMessage(exit: NativeServiceExit): string { + if (exit.state === "exited") { + return `${exit.service} exited with code ${exit.code}`; + } + return exit.state === "dead" + ? `${exit.service} died (dead or out of memory)` + : `${exit.service} container was removed`; +} + /** * Foreground native progress, matching Compose's attached `up`: steps while the * graph starts, one ready line, then the stop sequence after Ctrl-C. @@ -5744,6 +5755,14 @@ function reportNativeStartupProgress(event: NativeStartupProgress): void { message: `Project is ready (${event.services.join(", ")}). Use hack ps and hack logs from another terminal; press Ctrl-C to stop.`, }); return; + case "service-exited": + logger.warn({ message: nativeServiceExitMessage(event) }); + return; + case "all-stopped": + logger.info({ + message: "All services have stopped; ending native up as Compose does.", + }); + return; case "stopping": logger.step({ message: "Stopping the native project; waiting for owned cleanup…", @@ -9569,10 +9588,14 @@ async function handleNativeLogs({ process.stdout.write(`${JSON.stringify(result)}\n`); } else { process.stderr.write( - `Native logs: ${result.service}${result.truncated ? " (truncated)" : ""}\n` + `Native logs: ${result.service}${result.truncated ? " (truncated; older lines omitted)" : ""}\n` ); - process.stdout.write(result.stdout); - process.stderr.write(result.stderr); + // Engine order interleaves the streams by time; each keeps its own descriptor. + for (const line of result.lines) { + (line.stream === "stdout" ? process.stdout : process.stderr).write( + line.text + ); + } } return 0; } diff --git a/tests/native-project-observe.test.ts b/tests/native-project-observe.test.ts index 72ac736c1..8e2126e1c 100644 --- a/tests/native-project-observe.test.ts +++ b/tests/native-project-observe.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + type NativeLogLine, nativeProjectLogs, nativeProjectPs, } from "../src/backends/native-project-observe.ts"; @@ -645,3 +646,46 @@ exit 2 ); expect(busy.stderr).not.toContain("provider_busy"); }, 60_000); +test("logs keep the runtime's interleaved whole lines and refuse malformed ones", async () => { + const opts = await fixture(); + const lines: NativeLogLine[] = [ + { stream: "stdout", text: "starting\n" }, + { stream: "stderr", text: "warning\n" }, + { stream: "stdout", text: "ready\n" }, + ]; + const reply = + (extra: Record) => + async (request: { args: readonly string[] }) => + request.args[1] === "logs" + ? { + container: id, + stdout: "starting\nready\n", + stderr: "warning\n", + truncated: false, + ...extra, + } + : snapshot(); + const logs = (invoke: ReturnType) => + nativeProjectLogs({ + ...opts, + invoke, + service: "web", + tail: 20, + follow: false, + }); + expect((await logs(reply({ lines }))).lines).toEqual(lines); + // An older runtime without engine order keeps stdout before stderr. + expect((await logs(reply({}))).lines).toEqual([ + { stream: "stdout", text: "starting\nready\n" }, + { stream: "stderr", text: "warning\n" }, + ]); + for (const malformed of [ + "not-a-list", + [{ stream: "stdin", text: "x" }], + [{ stream: "stdout", text: 1 }], + ]) { + await expect(logs(reply({ lines: malformed }))).rejects.toThrow( + "observation refused" + ); + } +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 89d9cea2b..7ff960795 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -1867,6 +1867,107 @@ test("final inspection failure reports uncertainty without echoing arbitrary fai expect(events).not.toContain("remove"); }); +test("a refusal before admission reports that nothing was retained, with the runtime reason", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + opts.dependencies.serve = async () => { + throw new ClosedCliError( + "Native graph startup was interrupted or failed (graph_image); inspect owned state before retrying. Service web: Pinned image architecture or implicit volumes are incompatible." + ); + }; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "inspect") { + throw new NativeRuntimeRequestError({ + message: + "Native runtime request failed (graph_unreserved); inspect owned state before retrying.", + nativeCode: "graph_unreserved", + }); + } + return invoke(request); + }; + const failure = await startNativeProject(opts).catch( + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(ClosedCliError); + expect((failure as Error).message).toBe( + "Native graph startup failed before any runtime state was reserved; nothing was retained. Startup diagnostic: Native graph startup was interrupted or failed (graph_image). Service web: Pinned image architecture or implicit volumes are incompatible." + ); + expect(events).not.toContain("save"); + expect(events).not.toContain("remove"); + expect(events.at(-1)).toBe("cleanup"); +}); + +test("an unreserved inspection without a settled startup failure stays uncertain", async () => { + const { opts } = await fixture(); + const invoke = opts.dependencies.invoke!; + let inspections = 0; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "inspect" && ++inspections === 2) { + throw new NativeRuntimeRequestError({ + message: "unreserved", + nativeCode: "graph_unreserved", + }); + } + return invoke(request); + }; + const failure = await startNativeProject(opts).catch( + (error: unknown) => error + ); + expect((failure as Error).message).toContain("cleanup is unconfirmed"); +}); + +test("foreground up reports a stopped service and ends like Compose once all services stopped", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + let run = ""; + opts.dependencies.exitMonitorIntervalMs = 1; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "observe") { + events.push("graph observe"); + return { + run, + phase: "ready-observed", + services: { web: { state: "exited", code: 2 } }, + }; + } + return invoke(request); + }; + const serve = opts.dependencies.serve!; + opts.dependencies.serve = async (args) => { + run = args.run; + return await serve({ + ...args, + onReady: async () => { + await args.onReady(); + await new Promise((resolve) => + args.signal?.addEventListener("abort", resolve, { once: true }) + ); + }, + }); + }; + const progress: string[] = []; + expect( + await startNativeProject({ + ...opts, + onProgress: (event) => + progress.push( + event.phase === "service-exited" + ? `${event.service}:${event.state}:${"code" in event ? event.code : ""}` + : event.phase + ), + }) + ).toBe(0); + expect(progress.slice(progress.indexOf("ready"))).toEqual([ + "ready", + "web:exited:2", + "all-stopped", + "stopping", + "stopped", + ]); + expect(events).toContain("graph observe"); + expect(events.at(-1)).toBe("cleanup"); +}); + test("HTTPS route paths come only from reviewed native probes and conflicts refuse", () => { const service = (path: string) => ({ routing: { hostnames: ["web.example.com"] }, diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 75d8afd01..7489ca38d 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -147,6 +147,8 @@ async function fixture() { binary, `#!${process.execPath} if (process.argv.includes("hang")) await Bun.sleep(60_000); +if (process.argv.includes("fixed-message")) { console.error(JSON.stringify({code:"graph_image",message:"Pinned image architecture or implicit volumes are incompatible.",message_fixed:true,service:"web"})); process.exit(2); } +if (process.argv.includes("unsafe-fixed")) { console.error(JSON.stringify({code:"graph_image",message:"synthetic-secret-diagnostic\\nsecond line",message_fixed:true,service:"../synthetic-secret-diagnostic"})); process.exit(2); } if (process.argv.includes("structured")) { console.error(JSON.stringify({code:"source_conflict",message:"synthetic-secret-diagnostic"})); process.exit(23); } if (process.argv.includes("foreground-stopped")) { console.error(JSON.stringify({code:"native_graph_foreground",cause_code:"runtime_not_running",message:"synthetic-secret-diagnostic"})); process.exit(23); } if (process.argv.includes("foreground-other")) { console.error(JSON.stringify({code:"native_graph_foreground",cause_code:"engine_protocol",message:"synthetic-secret-diagnostic"})); process.exit(23); } @@ -921,6 +923,35 @@ test("an unparseable control response is a closed refusal", async () => { ); }); +test("a runtime message marked fixed is shown with its validated service", async () => { + const runtime = await fixture(); + const error: unknown = await invokeNativeRuntime({ + runtime, + cwd: runtime.home, + args: ["fixed-message"], + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(NativeRuntimeRequestError); + expect((error as Error).message).toBe( + "Native runtime request failed (graph_image); inspect owned state before retrying. Service web: Pinned image architecture or implicit volumes are incompatible." + ); + expect(error).toHaveProperty("nativeCode", "graph_image"); + expect(error).toHaveProperty( + "nativeMessage", + "Pinned image architecture or implicit volumes are incompatible." + ); + expect(error).toHaveProperty("nativeService", "web"); + // A fixed flag never admits a multi-line message or a non-identifier service. + const unsafe: unknown = await invokeNativeRuntime({ + runtime, + cwd: runtime.home, + args: ["unsafe-fixed"], + }).catch((caught: unknown) => caught); + expect((unsafe as Error).message).toBe( + "Native runtime request failed (graph_image); inspect owned state before retrying." + ); + expect(String(unsafe)).not.toContain("synthetic-secret-diagnostic"); +}); + test("structured failures expose only a bounded code, never their message", async () => { const runtime = await fixture(); await expect( diff --git a/tests/native-service-exit-monitor.test.ts b/tests/native-service-exit-monitor.test.ts new file mode 100644 index 000000000..7165e5804 --- /dev/null +++ b/tests/native-service-exit-monitor.test.ts @@ -0,0 +1,139 @@ +import { expect, test } from "bun:test"; +import { + monitorNativeServiceExits, + type NativeServiceExit, +} from "../src/backends/native-service-exit-monitor.ts"; + +const run = "a".repeat(32); +const runtime = { binary: "/not-invoked", home: "/not-invoked" }; + +function replies(sequence: unknown[]) { + const calls: string[] = []; + let index = 0; + const invoke = async (request: { args: readonly string[] }) => { + calls.push(request.args.join(" ")); + const reply = sequence[Math.min(index++, sequence.length - 1)]; + if (reply instanceof Error) { + throw reply; + } + return reply; + }; + return { calls, invoke }; +} +function observed(services: Record, phase = "ready-observed") { + return { run, phase, services }; +} + +test("reports each stop once, like Compose, and ends when every service stopped", async () => { + const { calls, invoke } = replies([ + observed({ init: { state: "exited", code: 0 }, web: { state: "running" } }), + new Error("transient observation failure"), + observed({ init: { state: "exited", code: 0 }, web: { state: "running" } }), + observed({ + init: { state: "exited", code: 0 }, + web: { state: "exited", code: 3 }, + }), + ]); + const exits: NativeServiceExit[] = []; + let stopped = 0; + const controller = new AbortController(); + await monitorNativeServiceExits({ + runtime, + projectRoot: "/fixture", + run, + signal: controller.signal, + intervalMs: 1, + invoke, + onExit: (exit) => exits.push(exit), + onAllStopped: () => stopped++, + }); + expect(exits).toEqual([ + { service: "init", state: "exited", code: 0 }, + { service: "web", state: "exited", code: 3 }, + ]); + expect(stopped).toBe(1); + // Only the lock-free observation is read; nothing is stopped or repaired. + expect(new Set(calls)).toEqual( + new Set([`graph observe --run-id ${run} --json`]) + ); +}); + +test("a restarted service that stops again is reported again; others keep up running", async () => { + const { invoke } = replies([ + observed({ web: { state: "exited", code: 1 }, db: { state: "running" } }), + observed({ web: { state: "running" }, db: { state: "running" } }), + observed({ web: { state: "dead" }, db: { state: "running" } }), + observed({ web: { state: "dead" }, db: { state: "running" } }), + ]); + const exits: NativeServiceExit[] = []; + const controller = new AbortController(); + let polls = 0; + await monitorNativeServiceExits({ + runtime, + projectRoot: "/fixture", + run, + signal: controller.signal, + intervalMs: 1, + invoke: async (request) => { + if (++polls >= 6) { + controller.abort(); + } + return await invoke(request); + }, + onExit: (exit) => exits.push(exit), + onAllStopped: () => { + throw new Error("db is still running"); + }, + }); + expect(exits).toEqual([ + { service: "web", state: "exited", code: 1 }, + { service: "web", state: "dead" }, + ]); +}); + +test("foreign, non-ready or malformed observations are ignored and abort stops promptly", async () => { + const { invoke } = replies([ + { + run: "b".repeat(32), + phase: "ready-observed", + services: { web: { state: "exited", code: 1 } }, + }, + observed({ web: { state: "exited", code: 1 } }, "cleanup-intent"), + observed({ "../web": { state: "exited", code: 1 } }), + observed({ web: "exited" }), + ]); + const controller = new AbortController(); + const exits: NativeServiceExit[] = []; + let polls = 0; + await monitorNativeServiceExits({ + runtime, + projectRoot: "/fixture", + run, + signal: controller.signal, + intervalMs: 1, + invoke: async (request) => { + if (++polls === 4) { + controller.abort(); + } + return await invoke(request); + }, + onExit: (exit) => exits.push(exit), + onAllStopped: () => undefined, + }); + expect(exits).toEqual([]); + const idle = new AbortController(); + const started = performance.now(); + const pending = monitorNativeServiceExits({ + runtime, + projectRoot: "/fixture", + run, + signal: idle.signal, + intervalMs: 60_000, + invoke, + onExit: () => undefined, + onAllStopped: () => undefined, + }); + idle.abort(); + await pending; + expect(performance.now() - started).toBeLessThan(5000); +}); From f5b2cbc5e61ba664a580a40269785d6f19a165a6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:44:21 -0500 Subject: [PATCH 062/138] fix(native): reuse a running pool when its primary root moved Owner::create_with_project_share live-validated the passed primary share before checking for an existing owner, so `up` for an additional root on a running pool failed when the primary root had been moved or deleted. Reuse now loads the existing owner without live-checking the recorded primary; the requested share is still checked live by check_request, and boot still checks every share live. A cold pool still refuses a missing root. Also gate OwnedGuest::project_share and single_project_share to macOS: their only callers (device-rebind recovery) are macOS-only, so non-macOS clippy failed with dead_code under -D warnings. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/lifecycle.rs | 90 ++++++++++++++++++- packages/runtime-core/src/provider/state.rs | 10 ++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 7ebdae6e9..8c1139026 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -431,11 +431,13 @@ impl<'a> OwnedGuest<'a> { } /// The pool's primary share. Device-rebind recovery is defined only for single-root - /// pools and refuses when additional shares exist. + /// pools and refuses when additional shares exist. That recovery is macOS-only. + #[cfg(target_os = "macos")] pub(super) fn project_share(&self) -> Option<&super::ProjectShareIntent> { self.owner.project_share.as_ref() } /// Whether the pool's additional shares are empty (a single-root pool). + #[cfg(target_os = "macos")] pub(super) fn single_project_share(&self) -> bool { self.owner.additional_project_shares.is_empty() } @@ -2292,6 +2294,92 @@ mod tests { } } + #[cfg(target_os = "macos")] + #[test] + fn running_pool_reuse_ignores_a_removed_primary_root_but_boot_refuses() { + let directory = std::env::temp_dir().canonicalize().unwrap().join(format!( + "hack-share-reuse-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + crate::next_test_scratch() + )); + state::private_directory(&directory).unwrap(); + struct Remove(std::path::PathBuf); + impl Drop for Remove { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _remove = Remove(directory.clone()); + let approve = |name: &str| { + let project = directory.join(name); + state::private_directory(&project).unwrap(); + fs::write(project.join("package.json"), "{}").unwrap(); + super::super::ProjectShareIntent::approve(&project, true).unwrap() + }; + let primary = approve("first-app"); + let second = approve("second-app"); + let pool = directory.join("pool"); + state::private_directory(&pool).unwrap(); + let candidate = Candidate::discover(&pool).unwrap(); + let create = |candidate: &Candidate, share: &super::super::ProjectShareIntent| { + Owner::create_with_project_share( + candidate, + super::super::Profile::Development, + None, + super::super::NetworkIntent::Internet, + None, + Some(share.clone()), + ) + }; + let mut owner = create(&candidate, &primary).unwrap(); + struct Unlink(std::path::PathBuf); + impl Drop for Unlink { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } + } + let _unlink = Unlink(owner.short_home.clone()); + owner.additional_project_shares.push(second.clone()); + owner.phase = "running".into(); + owner.save(&candidate).unwrap(); + let before = fs::read(root(&candidate).join("owner.json")).unwrap(); + + // The primary root disappears while the pool runs. + fs::remove_dir_all(&primary.project).unwrap(); + let reused = create(&candidate, &primary).unwrap(); + assert_eq!(reused.project_share.as_ref(), Some(&primary)); + assert_eq!(reused.additional_project_shares, vec![second.clone()]); + assert_eq!(reused.phase, "running"); + assert_eq!( + fs::read(root(&candidate).join("owner.json")).unwrap(), + before + ); + // The second root is still admitted against the reused owner; its own project is + // not blocked by the stale neighbor. + assert!( + super::super::project_share::check_request(&reused.project_shares(), Some(&second)) + .is_ok() + ); + // Booting with the whole set still checks every root live. + assert_eq!( + super::super::project_share::check_boot(&reused.project_shares()) + .unwrap_err() + .code, + "project_share_stale" + ); + + // A cold pool never records a share whose root is missing. + let cold_pool = directory.join("cold-pool"); + state::private_directory(&cold_pool).unwrap(); + let cold = Candidate::discover(&cold_pool).unwrap(); + assert!(create(&cold, &primary).is_err()); + assert!(!root(&cold).join("owner.json").exists()); + } + #[test] fn cleanup_operation_lease_waits_then_revalidates_without_replaying_effects() { let root = std::fs::canonicalize(std::env::temp_dir()) diff --git a/packages/runtime-core/src/provider/state.rs b/packages/runtime-core/src/provider/state.rs index b419b1015..018390dd2 100644 --- a/packages/runtime-core/src/provider/state.rs +++ b/packages/runtime-core/src/provider/state.rs @@ -446,15 +446,19 @@ impl Owner { dependency_sockets: Option, project_share: Option, ) -> Result { - if let Some(share) = &project_share { - share.validate()?; - } super::dependency_socket::check_capacity(application_bridge, dependency_sockets)?; let root = candidate.state_root.join("run/smolvm"); let owner_path = root.join("owner.json"); if owner_path.try_exists().map_err(io)? { + // Reuse never live-checks the recorded primary root: callers compare the loaded + // share set with the one they read, the requested share is checked live by + // `check_request`, and boot checks every share live. A moved or deleted primary + // root must not block another project's share on a running pool. return Self::load(candidate); } + if let Some(share) = &project_share { + share.validate()?; + } for name in ["home", "tmp", "docker-config"] { private_directory(&root.join(name))?; } From 3f8fe6963ded0eb1916f1489203116131508257b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:45:24 -0500 Subject: [PATCH 063/138] docs(native): record the stop batch scheduler as a model-coverage gap Co-Authored-By: Claude Opus 5.5 --- docs/reference/v5-model-coverage.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..5bd65f807 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -41,6 +41,7 @@ must be attached to the eventual exact integration revision. | Crash between reserve, publish, sync and release; stale recovery | native-frontend-recovery, publication, publication-journal, native-storage-witness | Durable intent/enrolled/released records, fresh lease and expected binding; recovery and sync-failure tests | Fsync guarantees, process-kill windows, real restarted provider observations. No missing-path authority. | | Data lifetime independent of compute; replaced/aliased metadata | native-persistent-enrollment, native-storage-witness, native-file-material | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. | | Settled retained Compose route-attempt retirement; pinned references; interrupted deletion | **Coverage gap:** filesystem journal compaction is not modeled; native-route-stop covers a different stop protocol | Instance mutation lock and exact surviving claim witnesses in [journal compaction](../../src/lib/native-compose-route-claims.ts); [claim regressions](../../tests/native-compose-route-claims.test.ts) and [retained lifecycle integration](../../tests/native-compose-adoption-routing-generation.test.ts) | 24 successful operations keep two attempt directories and constant Linux descriptors; four integrated lifecycle operations check directory counts. Injected unlink failures cover same-store and fresh-store cleanup retries. Assumes cooperative same-user writers, the caller's mutation lock and working filesystem synchronization. No process-kill/power-loss durability proof or bound on unresolved/over-budget histories. | +| Graph cleanup container stop batch: bounded in-flight stops, shared worker queue, connect-stage retry, batch-wide unreachable state, deadline expiry, worker panic | **Coverage gap:** no maintained model covers scheduling inside the stop batch; native-route-stop covers a different stop protocol | `dispatch` and `attempt` in [the stop batch](../../packages/runtime-core/src/provider/engine/stop.rs): at most 4 stops in flight, every worker joined; only `connect`/`connect_timeout` failures retry (25 ms doubling to 500 ms, within the batch deadline and 30 s of a stop's first connect failure); once one stop exhausts its window, later connect failures return at once; a deadline reached during backoff keeps the connect stage; `response`, `timeout` and `transport` never retry. Regressions in the same file's `provider::engine::stop` tests (bounded in-flight socket server, late socket, missing socket, 14-stop retry counts, persistent connect failures, oversleeping backoff, non-retried stages, worker panic) and `fourteen_service_connect_failures_keep_resumable_progress_and_retry_only_unstopped` | Tests cover exact attempt counts and the in-flight bound for fixed interleavings; no model explores all worker schedules or proves at most one delivered request per stop. Assumes a failed connect sent no request bytes and that the stop budget covers fully serialized grace periods. Graph-level resume after a partial batch is covered by tests only. Host qualification of the descriptor-pressure root cause (HACK-1302) remains open. | | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | | Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | From 30548e1ee98d50aa3f832f9b2cc56176b2efd98d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:55:27 -0500 Subject: [PATCH 064/138] fix(native): refuse every non-global registry destination address public_ip is now fail-closed: IPv4 refuses every IANA special-purpose block (including benchmarking 198.18.0.0/15, documentation, 192.0.0.0/24, the deprecated 6to4 relay block and 240.0.0.0/4), and IPv6 must be in global unicast 2000::/3 outside 2001::/23, 2001:db8::/32 and 3fff::/20. Mapped, NAT64 64:ff9b::/96 and 6to4 addresses are public only when the embedded IPv4 address is; local-use NAT64 64:ff9b:1::/48, discard 100::/64 and IPv4-compatible addresses are refused. The resolver, the literal destination check and host classification share it. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 9 ++- .../src/provider/registry_image.rs | 75 +++++++++++-------- .../src/provider/registry_image_test.rs | 47 +++++++++++- 3 files changed, 92 insertions(+), 39 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index da56471d4..4cc5d40c6 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -668,9 +668,12 @@ exactly: `localhost` and `*.localhost`, loopback, private, link-local, CGNAT and multicast IPv4 literals (including numeric spellings such as `0x7f.0.0.1`), single-label hosts such as `registry:5000`, and mDNS `.local` names. The same rule applies to the address a connection actually uses: a public-looking name -that resolves to a loopback, private (including IPv6 `fc00::/7`), link-local, -CGNAT, multicast or unspecified address, or an IPv4-mapped form of one, is -refused with `registry_local_address`. This covers the registry, its redirects +that resolves to any address that is not globally reachable unicast is refused +with `registry_local_address`. That includes loopback, private (including IPv6 +`fc00::/7`), link-local, CGNAT, benchmarking (`198.18.0.0/15`), documentation, +multicast, reserved and other special-purpose blocks, local-use NAT64 +(`64:ff9b:1::/48`), and IPv6 forms that embed such an IPv4 address. IP literals +follow the same rule. This covers the registry, its redirects and its token endpoint. A private registry on an internal network therefore also needs an explicit opt-in. Allow one with `--allow-local-registry [http://]host[:port]` (repeatable) on `admit-image`, diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index d53b5be1f..b1ebb9c3a 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -14,9 +14,11 @@ //! of it (Docker Hub: `auth.docker.io`). //! - **Addresses.** Local and private destinations are refused by the address actually //! connected to, not only by host name: a custom resolver refuses names that resolve -//! to loopback, private, link-local, CGNAT, multicast or unspecified addresses -//! (including IPv4-mapped forms), and IP literals are checked before each request. -//! Only an explicitly allowed local registry's own origin may reach such addresses. +//! to any address that is not globally reachable unicast (loopback, private, +//! link-local, CGNAT, benchmarking, documentation, multicast, reserved and every other +//! special-purpose block, including embedded IPv4 forms), and IP literals are checked +//! before each request. Only an explicitly allowed local registry's own origin may +//! reach such addresses. //! - **Content.** OCI and Docker v2 indexes and manifests; gzip, zstd and uncompressed //! layers. Every manifest, config and layer is checked against its descriptor digest //! and size while streaming, and the assembled archive is validated again before it is @@ -104,51 +106,58 @@ fn ipv4(host: &str) -> Option<[u8; 4]> { parts.next().is_none().then_some(octets) } +/// Whether `ip` is a globally reachable IPv4 unicast address. This is an allowlist in +/// spirit: every IANA special-purpose block is refused, including blocks with globally +/// reachable members (such as `192.0.0.9`), so classification fails closed. fn public_ipv4(ip: Ipv4Addr) -> bool { - let [a, b, ..] = ip.octets(); - !(a == 0 - || a == 10 - || a == 127 - || (a == 100 && (64..128).contains(&b)) - || (a == 169 && b == 254) - || (a == 172 && (16..32).contains(&b)) - || (a == 192 && b == 168) - || a >= 224) + let [a, b, c, _] = ip.octets(); + !(a == 0 // 0.0.0.0/8 "this network" + || a == 10 // 10.0.0.0/8 private + || (a == 100 && (64..128).contains(&b)) // 100.64.0.0/10 shared (CGNAT) + || a == 127 // 127.0.0.0/8 loopback + || (a == 169 && b == 254) // 169.254.0.0/16 link-local + || (a == 172 && (16..32).contains(&b)) // 172.16.0.0/12 private + || (a == 192 && b == 0 && c == 0) // 192.0.0.0/24 IETF protocol assignments + || (a == 192 && b == 0 && c == 2) // 192.0.2.0/24 documentation + || (a == 192 && b == 88 && c == 99) // 192.88.99.0/24 deprecated 6to4 relay + || (a == 192 && b == 168) // 192.168.0.0/16 private + || (a == 198 && (18..20).contains(&b)) // 198.18.0.0/15 benchmarking + || (a == 198 && b == 51 && c == 100) // 198.51.100.0/24 documentation + || (a == 203 && b == 0 && c == 113) // 203.0.113.0/24 documentation + || a >= 224) // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, broadcast } -/// Whether `ip` is a public unicast address. Loopback, unspecified, private (RFC 1918 and -/// IPv6 ULA `fc00::/7`), link-local (`169.254.0.0/16`, `fe80::/10`), deprecated -/// site-local (`fec0::/10`), CGNAT (`100.64.0.0/10`), multicast, broadcast and reserved -/// addresses are not, nor are IPv6 forms that embed such an IPv4 address (mapped, -/// compatible, NAT64 `64:ff9b::/96` and 6to4 `2002::/16`). +/// Whether `ip` is a globally reachable unicast address. IPv6 must be in global unicast +/// `2000::/3`, outside IETF protocol assignments `2001::/23` (Teredo, benchmarking, +/// ORCHID and others, all refused), documentation `2001:db8::/32` and `3fff::/20`. +/// Forms that embed an IPv4 address (mapped `::ffff:0:0/96`, NAT64 `64:ff9b::/96`, +/// 6to4 `2002::/16`) are public only when the embedded address is. Everything else is +/// refused, including `::`, `::1`, IPv4-compatible `::/96`, discard `100::/64`, local-use +/// NAT64 `64:ff9b:1::/48`, ULA `fc00::/7`, link-local `fe80::/10` and multicast `ff00::/8`. pub(super) fn public_ip(ip: IpAddr) -> bool { let ip = match ip { IpAddr::V4(ip) => return public_ipv4(ip), IpAddr::V6(ip) => ip, }; - let segments = ip.segments(); + let s = ip.segments(); let embedded = |high: u16, low: u16| { - Ipv4Addr::new((high >> 8) as u8, high as u8, (low >> 8) as u8, low as u8) + let [a, b] = high.to_be_bytes(); + let [c, d] = low.to_be_bytes(); + public_ipv4(Ipv4Addr::new(a, b, c, d)) }; if let Some(v4) = ip.to_ipv4_mapped() { return public_ipv4(v4); } - if segments[..6] == [0; 6] { - // `::`, `::1` and the deprecated IPv4-compatible `::a.b.c.d`. - return !ip.is_unspecified() - && !ip.is_loopback() - && public_ipv4(embedded(segments[6], segments[7])); + if s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] { + return embedded(s[6], s[7]); } - if segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0] { - return public_ipv4(embedded(segments[6], segments[7])); + if s[0] == 0x2002 { + return embedded(s[1], s[2]); } - if segments[0] == 0x2002 { - return public_ipv4(embedded(segments[1], segments[2])); - } - !(ip.is_multicast() - || (segments[0] & 0xfe00) == 0xfc00 - || (segments[0] & 0xffc0) == 0xfe80 - || (segments[0] & 0xffc0) == 0xfec0) + (s[0] & 0xe000) == 0x2000 + && !(s[0] == 0x2001 && s[1] < 0x0200) + && !(s[0] == 0x2001 && s[1] == 0x0db8) + && !(s[0] == 0x3fff && s[1] < 0x1000) } /// Hosts that are not public registries: loopback names, non-public IPv4 literals, diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs index 97c6b0edf..b2ad0326a 100644 --- a/packages/runtime-core/src/provider/registry_image_test.rs +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -679,6 +679,8 @@ fn local_hosts_are_classified_before_any_request() { "172.31.255.255", "192.168.1.1", "224.0.0.1", + "198.18.0.1", + "203.0.113.5", "2130706433", ] { assert!(local_host(host), "{host}"); @@ -739,7 +741,7 @@ fn redirects_and_token_realms_cannot_downgrade_or_reach_local_origins() { for value in [ "https://ghcr.io/v2/org/app/blobs/x", "https://pkg-storage.example.com/blob?signature=opaque", - "https://203.0.113.5/blob", + "https://8.8.8.8/blob", ] { assert!(redirect_allowed(&other, &url(value)), "{value}"); } @@ -750,6 +752,8 @@ fn redirects_and_token_realms_cannot_downgrade_or_reach_local_origins() { "https://10.0.0.5/blob", "https://169.254.169.254/latest", "https://0x7f.0.0.1/blob", + "https://198.18.0.1/blob", + "https://203.0.113.5/blob", "https://[::1]/blob", "https://user:secret@pkg-storage.example.com/blob", "https://metadata/blob", @@ -962,6 +966,9 @@ fn public_names_resolving_to_local_addresses_refuse_unless_the_registry_is_allow ("private.example.test", "10.0.0.7"), ("mapped.example.test", "::ffff:127.0.0.1"), ("ula.example.test", "fd00::1"), + ("bench.example.test", "198.18.0.1"), + ("nat64.example.test", "64:ff9b:1::a00:1"), + ("docs.example.test", "2001:db8::1"), ] .into_iter() .map(|(name, ip)| (name, ip.parse().unwrap())) @@ -989,6 +996,9 @@ fn public_names_resolving_to_local_addresses_refuse_unless_the_registry_is_allow "private.example.test", "mapped.example.test", "ula.example.test", + "bench.example.test", + "nat64.example.test", + "docs.example.test", ] { let reference = format!("{host}:{}/{REPOSITORY}:v1", registry.port); // The name looks public, so admission (which does no resolution) accepts it. @@ -1038,14 +1048,42 @@ fn ip_literals_are_checked_by_address_before_any_request() { "fe80::1", "fec0::1", "ff02::1", + // Special-purpose blocks that are not private but not globally reachable. + "0.1.2.3", + "192.0.0.9", + "192.0.2.1", + "192.88.99.1", + "198.18.0.1", + "198.19.255.255", + "198.51.100.1", + "203.0.113.5", + "240.0.0.1", + "::8.8.8.8", + "64:ff9b:1::808:808", + "64:ff9b::c612:1", + "100::1", + "2001::1", + "2001:2::1", + "2001:1ff::1", + "2001:db8::1", + "2002:c612:1::", + "3fff::1", + "4000::1", + "::ffff:198.18.0.1", ] { assert!(!public_ip(ip.parse().unwrap()), "{ip}"); } for ip in [ "8.8.8.8", - "203.0.113.5", + "1.1.1.1", + "198.17.255.255", + "198.20.0.1", "2606:4700::1111", + "2a00:1450::1", + "2001:200::1", + "3ffe::1", "::ffff:8.8.8.8", + "64:ff9b::808:808", "2002:808:808::", ] { assert!(public_ip(ip.parse().unwrap()), "{ip}"); @@ -1061,10 +1099,13 @@ fn ip_literals_are_checked_by_address_before_any_request() { "https://[::ffff:127.0.0.1]/blob", "https://[fd00::1]/blob", "https://10.0.0.1/token", + "https://198.18.0.1/blob", + "https://[64:ff9b:1::a00:1]/blob", + "https://[2001:db8::1]/blob", ] { assert!(!fetch.destination_allowed(&url(value)), "{value}"); } - for value in ["https://ghcr.io/v2/", "https://203.0.113.5/blob"] { + for value in ["https://ghcr.io/v2/", "https://8.8.8.8/blob"] { assert!(fetch.destination_allowed(&url(value)), "{value}"); } // A local opt-in allows its own origin only, not its host on another port. From be6f194388dcdfe87901e670d3664d55bc8bfd5a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:56:13 -0500 Subject: [PATCH 065/138] fix(native)!: bind machine settings to listed project roots; validate AWS selectors Machine settings are keyed by project name, which any checkout can claim, so another checkout with the same name could reuse a trusted project's sharedSource grant and run its own lifecycle hooks on the host. A machine record now must list the canonical project roots it grants in "projectRoots". Before any other field is read, a record without the field, or one that does not list the current canonical root, refuses with E_PERMISSION and names how to grant the checkout. Listed roots are canonicalized, so symbolic links match, and missing entries are skipped. AWS selectors from the machine file and from HACK_NATIVE_AWS_PROFILE / HACK_NATIVE_AWS_REGION are now checked at settings resolution with the same validator native AWS adaptation uses, so doctor reports them and native up refuses before image admission or lifecycle hooks. BREAKING CHANGE: a native machine settings file without "projectRoots" no longer grants anything. Add the absolute project root (and any linked worktree to grant) to "projectRoots". Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 3 +- docs/guides/native-candidate.md | 23 +++- src/backends/native-aws-environment.ts | 19 +++- src/backends/native-project-settings.ts | 101 ++++++++++++++--- tests/native-project-settings-cli.test.ts | 59 +++++++++- tests/native-project-settings.test.ts | 125 +++++++++++++++++++++- 6 files changed, 308 insertions(+), 22 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 1d92665b0..0b64a2b85 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -41,7 +41,8 @@ selection comes from (`env`, `machine`, `project` or `unset`) without its value, project configuration that native `up` refuses or does not apply. The committed `.hack/native/adaptation.json` and the per-machine `${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json` replace exported -`HACK_NATIVE_*` variables; a variable that is set still wins. See +`HACK_NATIVE_*` variables; a variable that is set still wins. The machine file applies +only to the project roots listed in its `projectRoots`. See [Native settings files](guides/native-candidate.md#native-settings-files). Project names in the registry and `--project`/MCP selectors use the same lowercase, diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index fccd25434..94b42177b 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1381,7 +1381,14 @@ shared read/write with the native VM: a container could rewrite a grant stored i tree before the next `up`. `` is the project name Hack uses (the Compose `name:`, else `name` in `hack.config.json`, else the directory name), lowercased with other characters replaced by `-`. Linked worktrees and branch instances of one project -therefore share one machine file. Create it once per machine: +therefore find the same machine file. + +Any checkout can claim a project name, so the file grants only the checkouts it lists +in `projectRoots`: absolute paths of project roots (at most 16). Hack compares the +canonical path of the current project root with the canonical path of each entry, so a +symbolic link to a listed root works. An entry that no longer exists is skipped. Branch +instances share their checkout's root; list each linked worktree you want to grant. +Create the file once per machine: ```sh dir="${XDG_CONFIG_HOME:-$HOME/.config}/hack/native/my-app" @@ -1393,6 +1400,7 @@ touch "$dir/settings.json" && chmod 600 "$dir/settings.json" ```json { "version": 1, + "projectRoots": ["/absolute/path/to/my-app"], "sharedSource": true, "aws": {"profile": "dev-sso", "region": "us-east-1"}, "dependencies": "dependencies.json", @@ -1403,7 +1411,8 @@ touch "$dir/settings.json" && chmod 600 "$dir/settings.json" } ``` -Every field except `version` is optional. The fields match the variables: +Every field except `version` and `projectRoots` is optional. The other fields match +the variables: | Field | Variable it stands for | | --- | --- | @@ -1426,7 +1435,15 @@ storage or runtime work unless all of these hold: - the directory is outside the project root, and the project root is not inside it; - `settings.json` is a regular file owned by you with mode `0600`, not a symbolic or hard link, at most 64 KiB, and unchanged while it is read; -- every field is valid, including a field that a variable overrides. +- every field is valid, including a field that a variable overrides. `aws.profile` + and `aws.region` must be selectors that native AWS adaptation accepts, and so must + `HACK_NATIVE_AWS_PROFILE` and `HACK_NATIVE_AWS_REGION`. + +Before it reads any other field, Hack refuses the file with `E_PERMISSION` when +`projectRoots` is missing or does not list the current project root. The refusal +happens before any lifecycle hook runs, even when variables override every setting. +A file written before `projectRoots` existed therefore grants nothing: add the +project root to it to grant that checkout again. A missing directory or file means no machine settings. diff --git a/src/backends/native-aws-environment.ts b/src/backends/native-aws-environment.ts index 4c93af566..1e11ac447 100644 --- a/src/backends/native-aws-environment.ts +++ b/src/backends/native-aws-environment.ts @@ -9,6 +9,20 @@ const MAX_OUTPUT = 64 * 1024; const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; const PROFILE = /^[A-Za-z0-9_+=,.@-]{1,128}$/; const REGION = /^[a-z]{2}(?:-[a-z]+)+-\d+$/; + +/** + * Whether an AWS profile and optional region are selectors native AWS adaptation + * accepts. Settings resolution uses it so an invalid selector refuses before startup. + */ +export function isNativeAwsSelector(opts: { + readonly profile: string; + readonly region?: string; +}): boolean { + return ( + PROFILE.test(opts.profile) && + (opts.region === undefined || REGION.test(opts.region)) + ); +} const SELECTORS = ["AWS_PROFILE", "AWS_DEFAULT_PROFILE"]; const CREDENTIAL_KEYS = [ "AWS_ACCESS_KEY_ID", @@ -327,10 +341,7 @@ export async function adaptNativeAwsEnvironment(opts: { }; }> { try { - if ( - !PROFILE.test(opts.profile) || - (opts.region !== undefined && !REGION.test(opts.region)) - ) { + if (!isNativeAwsSelector(opts)) { throw refused(); } const compose: unknown = JSON.parse(opts.input.normalizedComposeJson); diff --git a/src/backends/native-project-settings.ts b/src/backends/native-project-settings.ts index b7a5d3ddd..bd0695ab6 100644 --- a/src/backends/native-project-settings.ts +++ b/src/backends/native-project-settings.ts @@ -4,6 +4,7 @@ import { isAbsolute, join, relative, sep } from "node:path"; import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { sanitizeProjectSlug } from "../lib/project.ts"; +import { isNativeAwsSelector } from "./native-aws-environment.ts"; import { type NativePreparedBase, parseNativePreparedBase, @@ -31,7 +32,9 @@ import { * - **Machine settings** live outside every project share, in * `${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json`. They hold * grants and host authority: the source-share grant, the AWS profile, host dependency - * files, HTTPS, prepared base and approved hosts. + * files, HTTPS, prepared base and approved hosts. Because any checkout can claim a + * project name, a record applies only to the canonical project roots it lists in + * `projectRoots`; any other checkout is refused with `E_PERMISSION`. * - **Environment variables** (`HACK_NATIVE_*`) override either, per setting. * * Precedence is per setting and whole-value: a variable that is set wins entirely, then @@ -59,6 +62,7 @@ const ADAPTATION_FILE = /^adaptation(?:\.([a-z0-9]+(?:-[a-z0-9]+)*))?\.json$/; const MACHINE_FILENAME = "settings.json"; const MACHINE_KEYS = new Set([ "version", + "projectRoots", "sharedSource", "dependencies", "dependenciesByEnv", @@ -70,6 +74,8 @@ const MACHINE_KEYS = new Set([ const ENV_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SIBLING_FILE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/; const PROJECT_ENTRY_LIMIT = 64; +const PROJECT_ROOT_LIMIT = 16; +const CONTROL = /[\x00-\x1f\x7f]/; const ENV_OVERLAY_LIMIT = 32; type Environment = Readonly>; @@ -107,6 +113,60 @@ function fieldRefused(key: string): HackCliError { return machineRefused(`field ${key} is invalid`); } +function machineNotGranted(detail: string): HackCliError { + return new HackCliError({ + code: "E_PERMISSION", + message: `Native machine settings ${detail}. To grant this checkout, add its absolute path to "projectRoots" in \${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json; values omitted.`, + }); +} + +/** + * The machine record is keyed by project name, which any checkout can claim. Refuse it, + * before any other field is read, unless `projectRoots` lists this canonical project + * root. Listed roots are canonicalized too, so a symbolic link to a granted root (or a + * granted path that is itself a link) matches; a root that no longer exists is skipped. + */ +async function requireMachineProjectRoot(opts: { + readonly value: unknown; + readonly projectRoot: string; +}): Promise { + if (!(isRecord(opts.value) && opts.value.version === 1)) { + throw machineRefused("require version 1"); + } + const roots = opts.value.projectRoots; + if (roots === undefined) { + throw machineNotGranted("are not bound to a project root"); + } + if ( + !( + Array.isArray(roots) && + roots.length > 0 && + roots.length <= PROJECT_ROOT_LIMIT && + roots.every( + (root) => + typeof root === "string" && + root.length <= 4096 && + isAbsolute(root) && + !CONTROL.test(root) + ) + ) + ) { + throw fieldRefused("projectRoots"); + } + for (const root of roots as readonly string[]) { + let canonical: string; + try { + canonical = await realpath(root); + } catch { + continue; + } + if (canonical === opts.projectRoot) { + return; + } + } + throw machineNotGranted("do not grant this project root"); +} + /** The adaptation native start applies: one explicit file, or the committed files. */ export interface NativeAdaptationSelection extends NativeEnvSelectedFile { readonly origin: Exclude; @@ -258,7 +318,8 @@ function inside(parent: string, child: string): boolean { * Locate and read the machine settings file. Absent directory or file means no machine * settings. A present file requires a real, current-user-owned directory without group * or other bits, outside the project root (so no project share can reach it), and an - * owner-only regular file; anything else refuses. + * owner-only regular file whose `projectRoots` grants this project root; anything else + * refuses. */ async function readMachineSettings(opts: { readonly env: Environment; @@ -325,6 +386,7 @@ async function readMachineSettings(opts: { "file must be a bounded owner-only (mode 0600) regular JSON file" ); } + await requireMachineProjectRoot({ value, projectRoot: root }); return { directory: canonical, file, value }; } @@ -437,12 +499,11 @@ function machineAws(value: unknown): { (key) => key === "profile" || key === "region" ) && typeof value.profile === "string" && - value.profile.length > 0 && - value.profile.length <= 128 && - (value.region === undefined || - (typeof value.region === "string" && - value.region.length > 0 && - value.region.length <= 64)) + (value.region === undefined || typeof value.region === "string") && + isNativeAwsSelector({ + profile: value.profile, + ...(value.region === undefined ? {} : { region: value.region }), + }) ) ) { throw fieldRefused("aws"); @@ -510,6 +571,25 @@ function machineValues( }; } +/** The AWS variables, refused here when native AWS adaptation would refuse them. */ +function envAws(env: Environment): { + readonly profile: string; + readonly region?: string; +} { + const selected = { + profile: env.HACK_NATIVE_AWS_PROFILE ?? "", + region: env.HACK_NATIVE_AWS_REGION, + }; + if (!isNativeAwsSelector(selected)) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "HACK_NATIVE_AWS_PROFILE or HACK_NATIVE_AWS_REGION is not a valid AWS selector; values omitted.", + }); + } + return selected; +} + /** Whole-value precedence for one setting: a set variable, else the machine value. */ function pick(opts: { readonly envSet: boolean; @@ -588,10 +668,7 @@ export async function resolveNativeHostSettings(opts: { }); const aws = pick({ envSet: nonEmpty(env, "HACK_NATIVE_AWS_PROFILE"), - fromEnv: () => ({ - profile: env.HACK_NATIVE_AWS_PROFILE ?? "", - region: env.HACK_NATIVE_AWS_REGION, - }), + fromEnv: () => envAws(env), machineSet: values.aws !== undefined, machine: values.aws, }); diff --git a/tests/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts index f9cd6686b..f04c69d5f 100644 --- a/tests/native-project-settings-cli.test.ts +++ b/tests/native-project-settings-cli.test.ts @@ -6,6 +6,7 @@ import { readFile, realpath, rm, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -116,7 +117,15 @@ exit 1 await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700); const file = join(directory, "settings.json"); - await writeFile(file, JSON.stringify(value), { mode }); + // Granted to this checkout's root unless the record names its own roots. + const bound = + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !Object.hasOwn(value, "projectRoots") + ? { ...value, projectRoots: [project] } + : value; + await writeFile(file, JSON.stringify(bound), { mode }); await chmod(file, mode); }; return { root, project, calls, env, committed, machine }; @@ -249,6 +258,54 @@ test( SPAWN_TIMEOUT_MS ); +test( + "a machine grant applies only to its project root; another checkout of the same name runs no hook", + async () => { + const f = await fixture({ services: { web: { image: IMAGE } } }); + await f.machine({ version: 1, sharedSource: true }); + // A second checkout claims the same project name and authors an up hook. + const other = join(f.root, "other-checkout"); + const marker = join(f.root, "hook-ran"); + await mkdir(join(other, ".hack"), { recursive: true }); + await writeFile( + join(other, ".hack", "docker-compose.yml"), + JSON.stringify({ services: { web: { image: IMAGE } } }) + ); + await writeFile( + join(other, ".hack", "hack.config.json"), + JSON.stringify({ + name: PROJECT, + lifecycle: { up: { before: [`touch '${marker}'`] } }, + }) + ); + const refused = await cli({ ...f, project: other }, ["up"]); + expect(errorLines(refused.output)).toEqual([ + 'ERROR: E_PERMISSION: Native machine settings do not grant this project root. To grant this checkout, add its absolute path to "projectRoots" in ${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json; values omitted.', + ]); + expect(await Bun.file(marker).exists()).toBe(false); + expect(await f.calls()).toEqual([]); + // The granted root, also through a symbolic link, still reaches the runtime. + const linked = join(f.root, "linked-project"); + await symlink(f.project, linked); + const granted = await cli({ ...f, project: linked }, ["up"]); + expect(granted.output).not.toContain("E_PERMISSION"); + expect(granted.output).toMatch(RUNTIME_REQUEST_LINE); + // A record without a binding grants nothing. + await f.machine({ + version: 1, + sharedSource: true, + projectRoots: undefined, + }); + const unbound = await cli(f, ["up"]); + expect(errorLines(unbound.output)).toEqual([ + expect.stringMatching( + /^ERROR: E_PERMISSION: Native machine settings are not bound to a project root\./ + ), + ]); + }, + SPAWN_TIMEOUT_MS +); + test( "a machine settings file readable by others refuses before any request", async () => { diff --git a/tests/native-project-settings.test.ts b/tests/native-project-settings.test.ts index 590f325c1..31bfacbcb 100644 --- a/tests/native-project-settings.test.ts +++ b/tests/native-project-settings.test.ts @@ -59,11 +59,19 @@ async function fixture() { "native", "settings-fixture" ); + /** Writes a machine record, granted to this project root unless it says otherwise. */ async function machine(value: unknown, mode = 0o600): Promise { await mkdir(machineDir, { recursive: true, mode: 0o700 }); await chmod(machineDir, 0o700); const file = join(machineDir, "settings.json"); - await writeFile(file, JSON.stringify(value), { mode }); + const bound = + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !Object.hasOwn(value, "projectRoots") + ? { ...value, projectRoots: [projectRoot] } + : value; + await writeFile(file, JSON.stringify(bound), { mode }); await chmod(file, mode); return file; } @@ -449,6 +457,105 @@ test("machine settings inside the project root refuse, so no project share can r ); }); +test("machine settings apply only to a canonical project root they list", async () => { + const f = await fixture(); + // Another checkout that claims the same project name. + const other = join(f.root, "other-checkout"); + await mkdir(join(other, ".hack"), { recursive: true }); + const linked = join(f.root, "linked-project"); + await symlink(f.projectRoot, linked); + const hostAt = (projectRoot: string) => + resolveNativeHostSettings({ + env: f.env, + projectName: "settings-fixture", + projectRoot, + uid: UID, + }); + const notGranted = + 'Native machine settings do not grant this project root. To grant this checkout, add its absolute path to "projectRoots" in ${XDG_CONFIG_HOME:-$HOME/.config}/hack/native//settings.json; values omitted.'; + await f.machine({ version: 1, sharedSource: true }); + expect((await hostAt(f.projectRoot)).sharedSource).toBe(true); + // A symbolic link to the granted root resolves to it. + expect((await hostAt(linked)).sharedSource).toBe(true); + await expectCodedRefusal(hostAt(other), "E_PERMISSION", notGranted); + // A listed link is canonicalized too, and a listed root that is gone is skipped. + await f.machine({ + version: 1, + sharedSource: true, + projectRoots: [join(f.root, "removed-worktree"), linked], + }); + expect((await hostAt(f.projectRoot)).sharedSource).toBe(true); + await expectCodedRefusal(hostAt(other), "E_PERMISSION", notGranted); + // An explicitly listed second checkout is granted. + await f.machine({ + version: 1, + sharedSource: true, + projectRoots: [f.projectRoot, other], + }); + expect((await hostAt(other)).sharedSource).toBe(true); + // A record without a binding grants nothing, even where variables override it. + await f.machine({ version: 1, sharedSource: true, projectRoots: undefined }); + await expectCodedRefusal( + hostAt(f.projectRoot), + "E_PERMISSION", + "Native machine settings are not bound to a project root." + ); + await expectCodedRefusal( + f.host({ HACK_NATIVE_SHARED_SOURCE: "1" }), + "E_PERMISSION", + "not bound to a project root" + ); + for (const projectRoots of [[], ["relative/root"], "/not/a/list"]) { + await f.machine({ version: 1, sharedSource: true, projectRoots }); + await expectCodedRefusal( + f.host(), + "E_CONFIG_INVALID", + "Native machine settings field projectRoots is invalid; values omitted." + ); + } +}); + +test("AWS selectors refuse at settings resolution as native AWS adaptation would", async () => { + const f = await fixture(); + for (const aws of [ + { profile: "dev profile" }, + { profile: "dev", region: "invalid" }, + { profile: "dev", region: "" }, + { profile: "" }, + ]) { + await f.machine({ version: 1, aws }); + await expectCodedRefusal( + f.host(), + "E_CONFIG_INVALID", + "Native machine settings field aws is invalid; values omitted." + ); + // A variable override does not excuse the invalid machine value. + await expectCodedRefusal( + f.host({ HACK_NATIVE_AWS_PROFILE: "env-profile" }), + "E_CONFIG_INVALID", + "field aws is invalid" + ); + } + await f.machine({ version: 1, aws: { profile: "dev", region: "eu-west-1" } }); + expect((await f.host()).aws).toEqual({ profile: "dev", region: "eu-west-1" }); + const envRefusal = + "HACK_NATIVE_AWS_PROFILE or HACK_NATIVE_AWS_REGION is not a valid AWS selector; values omitted."; + for (const env of [ + { HACK_NATIVE_AWS_PROFILE: "dev profile" }, + { HACK_NATIVE_AWS_PROFILE: "dev", HACK_NATIVE_AWS_REGION: "invalid" }, + ]) { + await expectCodedRefusal(f.host(env), "E_CONFIG_INVALID", envRefusal); + } + expect( + ( + await f.host({ + HACK_NATIVE_AWS_PROFILE: "env-profile", + HACK_NATIVE_AWS_REGION: "us-west-2", + }) + ).aws + ).toEqual({ profile: "env-profile", region: "us-west-2" }); +}); + test("the machine directory follows XDG_CONFIG_HOME, then HOME, and a sanitized project name", () => { expect( nativeMachineSettingsDirectory({ @@ -535,4 +642,20 @@ test("doctor validates every adaptation as native up does before counting probes httpProbes: { web: { port: 0, path: "/" } }, }); expect(await doctor()).toMatchObject({ status: "error", message: refusal }); + await rm(join(f.nativeDir, "adaptation.qa.json")); + expect(await doctor()).toMatchObject({ status: "ok" }); + // Machine settings refusals are reported the same way. + await f.machine({ version: 1, sharedSource: true, aws: { profile: "a b" } }); + expect(await doctor()).toMatchObject({ + status: "error", + message: + "E_CONFIG_INVALID: Native machine settings field aws is invalid; values omitted.", + }); + await f.machine({ version: 1, sharedSource: true, projectRoots: ["/"] }); + expect(await doctor()).toMatchObject({ + status: "error", + message: expect.stringMatching( + /^E_PERMISSION: Native machine settings do not grant this project root\./ + ), + }); }); From 431180e8b84428caab0890b9d9694f72fc581e13 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:57:41 -0500 Subject: [PATCH 066/138] fix(native): retain JSON metadata that starts with whitespace Metadata retention decided raw JSON from the first byte of the first decoded chunk, so a config or manifest.json starting with legal JSON whitespace was discarded and a valid image refused. The decision now waits for the first byte that is not space, tab, CR or LF, across chunk boundaries, and the original bytes are retained so the content ID check stays exact. Compression detection also reads the full four-byte magic even when a member arrives in short reads. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/image_load.rs | 109 ++++++++++++++++-- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index 64f03f8d0..c1382e721 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -176,13 +176,22 @@ struct Manifest { /// first bytes as the engine's loader does: gzip, zstd (including multiple and skippable /// frames) or an uncompressed tar. Returns true when the member was not compressed. fn decode_member( - member: impl Read, + mut member: impl Read, mut sink: impl FnMut(&[u8]) -> Result<(), CandidateError>, ) -> Result { - let mut member = BufReader::with_capacity(64 * 1024, member); - let head = member - .fill_buf() - .map_err(|_| error("Cannot read an image archive member."))?; + // The magic is read in full even when the member arrives in short reads. + let mut magic = [0_u8; 4]; + let mut filled = 0; + while filled < magic.len() { + match member.read(&mut magic[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return Err(error("Cannot read an image archive member.")), + } + } + let head = &magic[..filled]; + let mut member = BufReader::with_capacity(64 * 1024, head.chain(member)); let gzip = head.starts_with(&[0x1f, 0x8b]); // A zstd stream starts with a frame or a skippable frame (magic 0x184D2A50 through // 0x184D2A5F); both are little-endian. @@ -298,19 +307,25 @@ fn validate_stream( let small = entry.size() <= MAX_METADATA_MEMBER; let mut digest = Sha256::new(); let mut retained = small.then(Vec::new); - let mut first = true; - let mut raw_json = false; + // Decided by the first byte that is not JSON whitespace, in whichever chunk it + // arrives; the original bytes, whitespace included, are retained for identity. + let mut raw_json: Option = None; let uncompressed = decode_member(entry, |bytes| { expanded += bytes.len() as u64; if expanded > expanded_limit { return Err(error("Image expansion exceeds its configured budget.")); } digest.update(bytes); - if first { - first = false; - raw_json = matches!(bytes.first(), Some(b'{' | b'[')); + if raw_json.is_none() { + raw_json = bytes + .iter() + .find(|b| !matches!(b, b' ' | b'\t' | b'\r' | b'\n')) + .map(|b| matches!(b, b'{' | b'[')); } - if let Some(kept) = retained.as_mut().filter(|_| raw_json) { + if raw_json == Some(false) { + retained = None; + } + if let Some(kept) = retained.as_mut() { kept.extend_from_slice(bytes); if kept.len() as u64 > MAX_METADATA_MEMBER { retained = None; @@ -318,7 +333,7 @@ fn validate_stream( } Ok(()) })?; - let retained = retained.filter(|kept| uncompressed && raw_json && !kept.is_empty()); + let retained = retained.filter(|_| uncompressed && raw_json == Some(true)); if let Some(kept) = &retained { retained_total += kept.len(); if retained_total > MAX_RETAINED_METADATA { @@ -861,6 +876,76 @@ pub(super) mod tests { assert!(decode_member(Cursor::new(corrupt), |_| Ok(())).is_err()); } + /// Reads at most `step` bytes per call, so members arrive in many small chunks. + struct Trickle<'a> { + bytes: &'a [u8], + step: usize, + } + impl Read for Trickle<'_> { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let n = buffer.len().min(self.step).min(self.bytes.len()); + buffer[..n].copy_from_slice(&self.bytes[..n]); + self.bytes = &self.bytes[n..]; + Ok(n) + } + } + + #[test] + fn metadata_with_leading_json_whitespace_is_retained_with_its_original_bytes() { + let payload: Vec = (0..4096_u32).map(|n| (n % 7) as u8).collect(); + let layer = compress(Layer::Gzip, &payload); + let mut config = b" \t\r\n ".to_vec(); + config.extend( + serde_json::to_vec(&serde_json::json!({"architecture":"arm64","os":"linux", + "rootfs":{"type":"layers","diff_ids":[format!("sha256:{}", hash(&payload))]}})) + .unwrap(), + ); + config.extend_from_slice(b"\n"); + // The content ID covers the whitespace: identity uses the original bytes. + let image = format!("sha256:{}", hash(&config)); + let mut manifest = b"\n\n".to_vec(); + manifest.extend( + serde_json::to_vec( + &serde_json::json!([{"Config":"config.json","Layers":["layer"],"RepoTags":[]}]), + ) + .unwrap(), + ); + let mut builder = tar::Builder::new(Vec::new()); + for (name, bytes) in [ + ("config.json", config.as_slice()), + ("layer", layer.as_slice()), + ("manifest.json", manifest.as_slice()), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, name, bytes).unwrap(); + } + let bytes = builder.into_inner().unwrap(); + assert_eq!( + validate(&bytes, &image, 1 << 20).unwrap(), + bytes.len() as u64 + ); + // One- and three-byte reads split the whitespace and the compression magic + // across chunk boundaries. + for step in [1, 3] { + let reader = Trickle { + bytes: &bytes, + step, + }; + assert_eq!( + validate_stream(reader, &hash(&bytes), &image, 1 << 30, 1 << 20).unwrap(), + bytes.len() as u64, + "step {step}" + ); + } + // The identity is still exact: the same JSON without the whitespace is a + // different content ID. + let trimmed = format!("sha256:{}", hash(config.trim_ascii())); + assert!(validate(&bytes, &trimmed, 1 << 20).is_err()); + } + #[test] fn validation_cache_requires_completed_versioned_exact_identity() { let value = serde_json::json!({ From f73bc4b46d8cacf86250d2fd004d6cd93fa8c5ef Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:01:36 -0500 Subject: [PATCH 067/138] fix(native): recover interrupted share adds and release removed graph shares An interrupted `runtime project-share add` whose root was later deleted or recreated could never finish: the CLI re-approved the root live, and the leftover journal fenced startup, network updates and removal. An add now resumes an existing journal from its recorded share without a live root check; a new add still approves and checks the root live, and boot still refuses the stale share until it is removed. Share removal treated every native graph receipt as a reference, including the Removed receipt that cleanup keeps, so a cleaned root's share could never be removed. A native run whose journal passes the strict same-owner read, has no pending staging or data-tool intent, and is Removed now no longer counts; any other or unreadable receipt still blocks. The multi-root-share TLA+ negative controls now check two roots. With a third symmetric root, TLC's two workers could report the same violation for either additional root, so the registered exact witness was flaky. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/main.rs | 5 +- .../src/provider/graph/native/journal.rs | 9 ++ .../src/provider/graph/native/mod.rs | 15 ++++ .../provider/graph/native/runtime/tests.rs | 48 +++++++++++ .../runtime-core/src/provider/share_update.rs | 74 +++++++++++++--- .../src/provider/share_update/tests.rs | 85 +++++++++++++++++-- scripts/lib/tla-runtime-models.ts | 6 +- tests/models/tla/README.md | 8 +- tests/models/tla/multi-root-share/Shares.tla | 6 +- .../models/tla/multi-root-share/negative.cfg | 2 +- .../tla/multi-root-share/owner-first.cfg | 2 +- .../multi-root-share/referenced-removal.cfg | 2 +- .../tla/multi-root-share/unfenced-boot.cfg | 2 +- 13 files changed, 236 insertions(+), 28 deletions(-) diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 457a8e66f..8ec5c3561 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -415,11 +415,10 @@ fn run() -> Result<(), CandidateError> { "--unfiltered-source", "--json", ] => { - let share = - hack_runtime_core::provider::ProjectShareIntent::approve(Path::new(project), true)?; print_json(&hack_runtime_core::provider::add_project_share( &discover_candidate(&requested)?, - &share, + Path::new(project), + true, )?)?; } [ diff --git a/packages/runtime-core/src/provider/graph/native/journal.rs b/packages/runtime-core/src/provider/graph/native/journal.rs index 75908f5f4..3ce6adfe7 100644 --- a/packages/runtime-core/src/provider/graph/native/journal.rs +++ b/packages/runtime-core/src/provider/graph/native/journal.rs @@ -685,6 +685,15 @@ pub(super) fn read_recovery( String::from_utf8(bytes).map_err(|_| refused())?, )) } +/// Strict same-owner read of one run's journal, independent of the current boot: +/// private ancestors, no pending staging file, the exact wire schema and owner. +pub(super) fn load_owned( + candidate: &Candidate, + run: &str, + owner: &str, +) -> Result<(Receipt, PathBuf), CandidateError> { + load_validated(candidate, run, Some(owner)) +} fn load_validated( candidate: &Candidate, run: &str, diff --git a/packages/runtime-core/src/provider/graph/native/mod.rs b/packages/runtime-core/src/provider/graph/native/mod.rs index 741f3e072..fa217b1c5 100644 --- a/packages/runtime-core/src/provider/graph/native/mod.rs +++ b/packages/runtime-core/src/provider/graph/native/mod.rs @@ -16,6 +16,21 @@ pub(super) use runtime::reservations_except; pub use runtime::run_with_storage_tool; pub use runtime::{Snapshot, cleanup, inspect, run}; +/// True only when this pool owner's run has fully retired: its journal passes the +/// strict same-owner read, no staging file or data-tool intent remains, and its phase +/// is `Removed`, which already requires every owned resource to be removed. Such a run +/// no longer uses the project share its source binding names. Any other state, +/// including an unreadable or foreign journal, is not retired. +pub(in crate::provider) fn retired( + candidate: &Candidate, + run: &str, + owner: &str, +) -> Result { + let (receipt, root) = journal::load_owned(candidate, run, owner)?; + persistent_data::tool::require_idle(&root)?; + Ok(receipt.phase == Phase::Removed) +} + pub(in crate::provider) fn environment_binding( candidate: &Candidate, owner: &str, diff --git a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs index 26c4f7468..d83441f6e 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime/tests.rs @@ -1081,6 +1081,54 @@ fn source_and_storage_receipt_wire_families_refuse_cross_fields() { assert!(serde_json::from_value::(wire).is_err()); } +#[test] +fn only_a_fully_removed_own_receipt_releases_its_project_share_for_removal() { + let fixture = source_fixture(); + let share = crate::provider::ProjectShareIntent::approve(&fixture.project, true).unwrap(); + let unreferenced = |owner: &str| { + crate::provider::share_update::require_unreferenced(&fixture.candidate, owner, &share) + }; + let (graph, mut session) = + fixture.session(fixture.prepared(json!({"web":{}}), &BTreeMap::new())); + execution::run(&graph, &mut session, RUN_BUDGET).unwrap(); + assert!(session.receipt.source.is_some()); + assert!(!retired(&fixture.candidate, RUN, OWNER).unwrap()); + // A live source-bearing receipt keeps its share. + assert_eq!( + unreferenced(OWNER).unwrap_err().code, + "project_share_in_use" + ); + cleanup_using(&session.backend, &mut session.receipt, &session.root).unwrap(); + assert_eq!(session.receipt.phase, Phase::Removed); + assert!(retired(&fixture.candidate, RUN, OWNER).unwrap()); + // The retained Removed receipt still names the share but no longer uses it. + let state = fs::read(session.root.join("state.json")).unwrap(); + assert!( + state + .windows(share.guest_path.len()) + .any(|w| w == share.guest_path.as_bytes()) + ); + unreferenced(OWNER).unwrap(); + // Another owner's receipt, or a pending journal update, is never proven retired. + assert!(!retired_or_false(&fixture.candidate, RUN, &"d".repeat(32))); + assert_eq!( + unreferenced(&"d".repeat(32)).unwrap_err().code, + "project_share_in_use" + ); + fs::write(session.root.join("state.pending"), &state).unwrap(); + assert!(!retired_or_false(&fixture.candidate, RUN, OWNER)); + assert_eq!( + unreferenced(OWNER).unwrap_err().code, + "project_share_in_use" + ); + fs::remove_file(session.root.join("state.pending")).unwrap(); + unreferenced(OWNER).unwrap(); +} + +fn retired_or_false(candidate: &Candidate, run: &str, owner: &str) -> bool { + retired(candidate, run, owner).unwrap_or(false) +} + #[test] fn live_source_controller_preserves_edits_and_cleans_only_original_owned_resources_after_move() { let fixture = source_fixture(); diff --git a/packages/runtime-core/src/provider/share_update.rs b/packages/runtime-core/src/provider/share_update.rs index 0c8959b17..a8bbbfba1 100644 --- a/packages/runtime-core/src/provider/share_update.rs +++ b/packages/runtime-core/src/provider/share_update.rs @@ -11,7 +11,8 @@ //! equals its own reviewed source (`project_share_for`), every bind path stays under //! that share's guest path, graph namespaces are derived from the canonical root, and //! cleanup acts on one run's labelled resources. Removing a share is refused while any -//! retained graph receipt still names it. +//! retained graph receipt still names it, unless that receipt is a native run this pool +//! owner proves fully removed. use super::{ ProjectShareIntent, config_audit, network_update, project_share::{self, MAX_PROJECT_SHARES}, @@ -186,9 +187,13 @@ const MAX_DEPTH: usize = 3; /// Conservative retained-graph reference check before a share is removed. Any graph /// record (normalized or native, any phase, including pending journals) that names the /// share's exact guest path or host root keeps the share; the caller cleans or archives -/// that project's graphs first. A false positive or an unreadable inventory only refuses. -fn require_unreferenced( +/// that project's graphs first. The one exception is a native run of this pool owner +/// whose journal proves it fully removed (`graph::native::retired`): cleanup keeps +/// that receipt in place, and it no longer uses any share. A false positive or an +/// unreadable inventory only refuses. +pub(super) fn require_unreferenced( candidate: &Candidate, + owner: &str, share: &ProjectShareIntent, ) -> Result<(), CandidateError> { let needles = [ @@ -196,23 +201,37 @@ fn require_unreferenced( serde_json::to_vec(&share.project).map_err(|_| refused())?, ]; let mut budget = MAX_ENTRIES; - for parent in ["run/graphs", "run/native-graphs"] { + for (parent, native) in [("run/graphs", false), ("run/native-graphs", true)] { let parent = candidate.state_root.join(parent); match fs::symlink_metadata(&parent) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, Err(_) => return Err(refused()), Ok(_) => state::check_private_directory(&parent)?, } - scan(&parent, 0, &needles, &mut budget)?; + let retired = |run: &Path| native && retired_native_run(candidate, owner, run); + scan(&parent, 0, &needles, &mut budget, &retired)?; } Ok(()) } +#[cfg(feature = "native-config-plan")] +fn retired_native_run(candidate: &Candidate, owner: &str, run: &Path) -> bool { + run.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|run| super::graph::native::retired(candidate, run, owner).unwrap_or(false)) +} +/// Without native support this build cannot prove a native run retired. +#[cfg(not(feature = "native-config-plan"))] +fn retired_native_run(_: &Candidate, _: &str, _: &Path) -> bool { + false +} + fn scan( directory: &Path, depth: usize, needles: &[Vec], budget: &mut usize, + retired: &dyn Fn(&Path) -> bool, ) -> Result<(), CandidateError> { for entry in fs::read_dir(directory).map_err(|_| refused())? { *budget = budget.checked_sub(1).ok_or_else(refused)?; @@ -222,10 +241,13 @@ fn scan( return Err(refused()); } if metadata.is_dir() { + if depth == 0 && retired(&path) { + continue; + } if depth >= MAX_DEPTH { return Err(refused()); } - scan(&path, depth + 1, needles, budget)?; + scan(&path, depth + 1, needles, budget, retired)?; continue; } let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); @@ -254,12 +276,44 @@ fn scan( /// Add one exactly approved project root to an owned, stopped development pool. The /// new share is mounted at the next boot; nothing is booted, restarted or replaced. +/// `unfiltered_source` is the caller's explicit consent to share the whole root. pub fn add_project_share( candidate: &Candidate, - share: &ProjectShareIntent, + project: &Path, + unfiltered_source: bool, ) -> Result { - share.validate()?; - update(candidate, Operation::Add, share.clone()) + let share = addition(candidate, project, unfiltered_source)?; + update(candidate, Operation::Add, share) +} + +/// The share one add operates on. An interrupted addition names its own exact share: +/// it resumes with that recorded receipt without checking the root live, because the +/// root may since have moved, been deleted or been recreated with another identity. +/// Completing it keeps the journal from fencing the pool forever; boot then refuses the +/// stale share (`project_share_stale`) until it is removed explicitly. A new addition +/// approves the root and checks it live. +fn addition( + candidate: &Candidate, + project: &Path, + unfiltered_source: bool, +) -> Result { + reject_aliased_state(&root(candidate))?; + match fs::symlink_metadata(root(candidate).join(JOURNAL)) { + Ok(_) => { + let j: Journal = state::read(&root(candidate).join(JOURNAL))?; + if !unfiltered_source || j.operation != Operation::Add || j.share.project != project { + return Err(refused()); + } + j.share.validate_receipt()?; + Ok(j.share) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let share = ProjectShareIntent::approve(project, unfiltered_source)?; + share.validate()?; + Ok(share) + } + Err(_) => Err(refused()), + } } /// Remove one additional project root from an owned, stopped development pool. The @@ -316,7 +370,7 @@ fn update( } let _vm_lock = network_update::stopped_with(candidate, &owner, refused)?; if operation == Operation::Remove { - require_unreferenced(candidate, &share)?; + require_unreferenced(candidate, &owner.token, &share)?; } let db = network_update::database(candidate); let record = config_audit::read_database(&db, &owner.machine)?; diff --git a/packages/runtime-core/src/provider/share_update/tests.rs b/packages/runtime-core/src/provider/share_update/tests.rs index dbdfc63f8..e6c0e6606 100644 --- a/packages/runtime-core/src/provider/share_update/tests.rs +++ b/packages/runtime-core/src/provider/share_update/tests.rs @@ -340,7 +340,7 @@ fn removal_refuses_while_any_retained_graph_names_the_share() { .recursive(true) .create(candidate.state_root.join("run")) .unwrap(); - assert!(require_unreferenced(&candidate, &f.second).is_ok()); + assert!(require_unreferenced(&candidate, &f.owner.token, &f.second).is_ok()); for parent in ["run/graphs", "run/native-graphs"] { let run = candidate.state_root.join(parent).join("a".repeat(32)); fs::DirBuilder::new() @@ -354,14 +354,14 @@ fn removal_refuses_while_any_retained_graph_names_the_share() { serde_json::to_vec(&json!({"source":{"shared": f.third}})).unwrap(), ) .unwrap(); - assert!(require_unreferenced(&candidate, &f.second).is_ok()); + assert!(require_unreferenced(&candidate, &f.owner.token, &f.second).is_ok()); fs::write( run.join("state.pending"), serde_json::to_vec(&json!({"source":{"shared": f.second}})).unwrap(), ) .unwrap(); assert_eq!( - require_unreferenced(&candidate, &f.second) + require_unreferenced(&candidate, &f.owner.token, &f.second) .unwrap_err() .code, "project_share_in_use" @@ -371,7 +371,7 @@ fn removal_refuses_while_any_retained_graph_names_the_share() { fs::DirBuilder::new().mode(0o700).create(&nested).unwrap(); fs::write(nested.join("binding.json"), f.second.guest_path.as_bytes()).unwrap(); assert_eq!( - require_unreferenced(&candidate, &f.second) + require_unreferenced(&candidate, &f.owner.token, &f.second) .unwrap_err() .code, "project_share_in_use" @@ -380,7 +380,7 @@ fn removal_refuses_while_any_retained_graph_names_the_share() { // An aliased record is never followed. symlink(run.join("state.json"), run.join("alias.json")).unwrap(); assert_eq!( - require_unreferenced(&candidate, &f.second) + require_unreferenced(&candidate, &f.owner.token, &f.second) .unwrap_err() .code, "project_share_update" @@ -388,3 +388,78 @@ fn removal_refuses_while_any_retained_graph_names_the_share() { fs::remove_file(run.join("alias.json")).unwrap(); } } + +#[test] +fn interrupted_addition_resumes_its_recorded_share_after_the_root_is_removed() { + let f = fixture(); + let mut candidate = Candidate::discover(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap(); + candidate.state_root = f.directory.join("state"); + fs::DirBuilder::new() + .mode(0o700) + .recursive(true) + .create(root(&candidate)) + .unwrap(); + // A new addition checks its root live. + assert_eq!( + addition(&candidate, &f.third.project, true).unwrap(), + f.third + ); + assert!(addition(&candidate, &f.third.project, false).is_err()); + let j = plan( + &f.owner, + f.record.clone(), + Operation::Add, + &f.second, + identity(&f), + ) + .unwrap(); + state::write(&root(&candidate).join(JOURNAL), &j).unwrap(); + // The added root disappears before the interrupted update is repeated. + fs::remove_dir_all(&f.second.project).unwrap(); + assert!(f.second.validate().is_err()); + assert_eq!( + addition(&candidate, &f.second.project, true).unwrap(), + f.second + ); + // A recreated directory has another identity; the recorded receipt still wins. + fs::DirBuilder::new() + .mode(0o700) + .create(&f.second.project) + .unwrap(); + fs::write(f.second.project.join("package.json"), "{}").unwrap(); + assert_ne!( + ProjectShareIntent::approve(&f.second.project, true).unwrap(), + f.second + ); + assert_eq!( + addition(&candidate, &f.second.project, true).unwrap(), + f.second + ); + // Only the journal's own root and consent resume it. + assert!(addition(&candidate, &f.third.project, true).is_err()); + assert!(addition(&candidate, &f.second.project, false).is_err()); + // The completed addition boots only after the stale share is removed, and that + // removal plans offline from the recorded receipt. + let (_, after) = validate(&j).unwrap(); + assert_eq!( + project_share::check_boot(&after.project_shares()) + .unwrap_err() + .code, + "project_share_stale" + ); + let removal = plan( + &after, + j.record_after.clone(), + Operation::Remove, + &f.second, + identity(&f), + ) + .unwrap(); + let (_, removed) = validate(&removal).unwrap(); + assert_eq!(removed, f.owner); + assert_eq!(removal.record_after, f.record); + // Without a journal, a missing root is never added. + fs::remove_file(root(&candidate).join(JOURNAL)).unwrap(); + fs::remove_dir_all(&f.third.project).unwrap(); + assert!(addition(&candidate, &f.third.project, true).is_err()); +} diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 917a373c2..ccca3e180 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1080,8 +1080,8 @@ const contracts: readonly ModelContract[] = [ invariant: "OwnShareOnly", action: "StartGraph", fields: [ - 'graph = [a |-> "none", b |-> "live", c |-> "none"]', - 'bound = [a |-> "none", b |-> "a", c |-> "none"]', + 'graph = [a |-> "none", b |-> "live"]', + 'bound = [a |-> "none", b |-> "a"]', 'mounted = {"a"}', ], additionalControls: [ @@ -1113,7 +1113,7 @@ const contracts: readonly ModelContract[] = [ invariant: "ReferencedSharesKept", action: "CommitRecord", fields: [ - 'graph = [a |-> "none", b |-> "retained", c |-> "none"]', + 'graph = [a |-> "none", b |-> "retained"]', 'journal = [op |-> "remove", p |-> "b"]', 'recordSet = {"a"}', ], diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index 243d4b9cd..92608b8ef 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -1065,13 +1065,17 @@ states**. Four guard-removal controls must fail at the named action with the exa same-state witness: `negative` (a graph may select any mounted share) fails `OwnShareOnly` at `StartGraph`; `unfenced-boot` fails `RunningMountsAudited` at `Boot`; `owner-first` fails `OwnerNeverAhead` at `CommitOwner`; and -`referenced-removal` fails `ReferencedSharesKept` at `CommitRecord`. +`referenced-removal` fails `ReferencedSharesKept` at `CommitRecord`. The controls +check two roots (`a`, `b`): with a third, symmetric root TLC's two workers may +report the same violation for either additional root, so the exact witness would +not be deterministic. | Model operation | Source boundary | | --- | --- | -| `BeginAdd` / `BeginRemove` | `share_update::plan`: owned stopped pool (`network_update::stopped_with`), primary share kept, bounded set, removal refused while a graph record names the share (`require_unreferenced`). | +| `BeginAdd` / `BeginRemove` | `share_update::plan`: owned stopped pool (`network_update::stopped_with`), primary share kept, bounded set, removal refused while a graph record names the share (`require_unreferenced`). An interrupted add resumes from its recorded share without a live root check (`addition`). | | `CommitRecord` / `CommitOwner` / `Finish` | `network_update::apply_record`, then the owner save, then journal removal; `validate_sides` refuses an owner ahead of its record. | | `Boot` | `share_update::require_complete`, `project_share::check_boot` and `config_audit` mount verification before `machine start`. | +| `CleanGraph` | One run's own cleanup; a native receipt that the pool owner proves `Removed` (`graph::native::retired`) no longer counts as a reference. | | `StartGraph` | `OwnedGuest::project_share_for(plan.source)` in shared source selection; `ProjectShareIntent::bind_path` keeps every bind under that share. | The model does not cover provider behavior: that the pinned provider mounts an diff --git a/tests/models/tla/multi-root-share/Shares.tla b/tests/models/tla/multi-root-share/Shares.tla index cf5724aee..643aeffa7 100644 --- a/tests/models/tla/multi-root-share/Shares.tla +++ b/tests/models/tla/multi-root-share/Shares.tla @@ -41,6 +41,9 @@ BeginRemove(p) == /\ phase = "stopped" /\ journal = Idle /\ p \in ownerSet /\ (EnforceUnreferenced => graph[p] = "none") /\ journal' = [op |-> "remove", p |-> p] /\ UNCHANGED <> +\* An unfinished journal resumes from its own recorded share: no guard requires the +\* root to still exist, so a moved or recreated root cannot fence the pool forever +\* (share_update::addition). Boot's live check is outside this abstraction. CommitRecord == /\ phase = "stopped" /\ journal # Idle /\ recordSet = ownerSet /\ recordSet' = Apply(recordSet, journal) @@ -61,7 +64,8 @@ StartGraph(p, s) == /\ phase = "running" /\ graph[p] \in {"none", "retained"} /\ graph' = [graph EXCEPT ![p] = "live"] /\ bound' = [bound EXCEPT ![p] = s] /\ UNCHANGED <> -\* Cleanup acts on one run's own labelled resources only. +\* Cleanup acts on one run's own labelled resources only. A native receipt the +\* pool owner proves `Removed` is "none" here: it no longer references its share. CleanGraph(p) == /\ graph[p] # "none" /\ graph' = [graph EXCEPT ![p] = "none"] /\ bound' = [bound EXCEPT ![p] = None] diff --git a/tests/models/tla/multi-root-share/negative.cfg b/tests/models/tla/multi-root-share/negative.cfg index c8658e9bd..3955830b4 100644 --- a/tests/models/tla/multi-root-share/negative.cfg +++ b/tests/models/tla/multi-root-share/negative.cfg @@ -1,4 +1,4 @@ -CONSTANTS Projects = {"a", "b", "c"} +CONSTANTS Projects = {"a", "b"} Primary = "a" MaxShares = 2 EnforceFence = TRUE diff --git a/tests/models/tla/multi-root-share/owner-first.cfg b/tests/models/tla/multi-root-share/owner-first.cfg index 86fbb8b06..6c93b40ee 100644 --- a/tests/models/tla/multi-root-share/owner-first.cfg +++ b/tests/models/tla/multi-root-share/owner-first.cfg @@ -1,4 +1,4 @@ -CONSTANTS Projects = {"a", "b", "c"} +CONSTANTS Projects = {"a", "b"} Primary = "a" MaxShares = 2 EnforceFence = TRUE diff --git a/tests/models/tla/multi-root-share/referenced-removal.cfg b/tests/models/tla/multi-root-share/referenced-removal.cfg index 80d51fc0a..44a5afd77 100644 --- a/tests/models/tla/multi-root-share/referenced-removal.cfg +++ b/tests/models/tla/multi-root-share/referenced-removal.cfg @@ -1,4 +1,4 @@ -CONSTANTS Projects = {"a", "b", "c"} +CONSTANTS Projects = {"a", "b"} Primary = "a" MaxShares = 2 EnforceFence = TRUE diff --git a/tests/models/tla/multi-root-share/unfenced-boot.cfg b/tests/models/tla/multi-root-share/unfenced-boot.cfg index d701a3a95..ba287b621 100644 --- a/tests/models/tla/multi-root-share/unfenced-boot.cfg +++ b/tests/models/tla/multi-root-share/unfenced-boot.cfg @@ -1,4 +1,4 @@ -CONSTANTS Projects = {"a", "b", "c"} +CONSTANTS Projects = {"a", "b"} Primary = "a" MaxShares = 2 EnforceFence = FALSE From e177712713aa853628c2c6059e24bdaa72ed9825 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:01:46 -0500 Subject: [PATCH 068/138] fix(models): accept every reachable restart-policy witness TLC's two-worker search does not always report the shortest trace. The service-restart-policy controls pinned one witness each, so the unproved-terminal control failed whenever TLC reported the pending-restart case (engine "restarting") instead of the running case, and the stale-generation control could fail the same way on an exited or restarted container. Each control now accepts every first violation the model can reach, and nothing else: unproved terminal evidence while running or restarting, a late incarnation from DaemonRestart in the stopping or stopped phase, and a stale Use for each reachable bound/engine pair. The invariants, configs and model are unchanged. A contract test pins the accepted and rejected witnesses. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/tla-runtime-models.ts | 48 ++++++++ .../tla/service-restart-policy/README.md | 14 ++- tests/tla-runtime-models.test.ts | 105 ++++++++++++++++++ 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 6e194f10a..bc3d548bb 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -39,6 +39,20 @@ const laterIncompleteMissingLockStages = [ "socket", "owner", ] as const; +// TLC's two-worker search does not always report the shortest trace, so the +// restart-policy controls accept every first violation the model can reach. +// A stale use is a bound incarnation that is older than the current one, or +// equal to it while the engine is no longer running. +const restartPolicyStaleUses = [ + { engine: "restarting", bound: 1 }, + { engine: "exited", bound: 1 }, + { engine: "running", bound: 1 }, + { engine: "restarting", bound: 2 }, + { engine: "exited", bound: 2 }, + { engine: "running", bound: 2 }, + { engine: "restarting", bound: 3 }, + { engine: "exited", bound: 3 }, +] as const; const contracts: readonly ModelContract[] = [ { name: "pre-ready-retirement", @@ -1452,6 +1466,19 @@ const contracts: readonly ModelContract[] = [ 'engine = "running"', "lateStart = TRUE", ], + // DaemonRestart needs a present container, so a stop-requested phase is + // either stopping or, after terminal proof, stopped. + alternativeWitnesses: [ + { + action: "DaemonRestart", + fields: [ + 'phase = "stopped"', + 'policy = "armed"', + 'engine = "running"', + "lateStart = TRUE", + ], + }, + ], additionalControls: [ { name: "unproved-terminal", @@ -1464,6 +1491,18 @@ const contracts: readonly ModelContract[] = [ "cancelled = TRUE", "unsafeTerminal = TRUE", ], + // A non-terminal engine is either running or has a pending restart. + alternativeWitnesses: [ + { + action: "ObserveTerminal", + fields: [ + 'phase = "stopped"', + 'engine = "restarting"', + "cancelled = TRUE", + "unsafeTerminal = TRUE", + ], + }, + ], }, { name: "stale-generation", @@ -1476,6 +1515,15 @@ const contracts: readonly ModelContract[] = [ "bound = 1", "staleUse = TRUE", ], + alternativeWitnesses: restartPolicyStaleUses.slice(1).map((use) => ({ + action: "Use", + fields: [ + 'phase = "serving"', + `engine = "${use.engine}"`, + `bound = ${use.bound}`, + "staleUse = TRUE", + ], + })), }, { name: "completion-reachable", diff --git a/tests/models/tla/service-restart-policy/README.md b/tests/models/tla/service-restart-policy/README.md index 0f5c1bf62..25cbb4fe9 100644 --- a/tests/models/tla/service-restart-policy/README.md +++ b/tests/models/tla/service-restart-policy/README.md @@ -32,14 +32,18 @@ model of the stop/restart interaction, not an implementation-refinement proof. The positive configuration exhausts 134 distinct states with `MaxIncarnation = 3` and at most one engine restart. Each negative control removes one guard and must -fail its property with the exact same-state witness: +fail its property with a same-state witness. TLC's two-worker search does not +always report the shortest trace, so each control accepts every first violation +the model can reach, and nothing else: - `negative.cfg` (no disarm): `DaemonRestart` after the stop request starts a new - incarnation (`NoIncarnationAfterStop`). + incarnation (`NoIncarnationAfterStop`), in the `stopping` or `stopped` phase. - `unproved-terminal.cfg` (stop transport success taken as proof): - `ObserveTerminal` records evidence while the engine still runs (`TerminalProof`). -- `stale-generation.cfg` (no generation recheck): `Use` acts on a restarting - container's old incarnation (`NoStaleIncarnation`). + `ObserveTerminal` records evidence while the engine is still running or has a + pending restart (`TerminalProof`). +- `stale-generation.cfg` (no generation recheck): `Use` acts on a bound + incarnation that is older than the current one, or on the current one after + it stopped running (`NoStaleIncarnation`). - `completion-reachable.cfg`: cleanup reaches removal with every guard enforced. ## Omissions diff --git a/tests/tla-runtime-models.test.ts b/tests/tla-runtime-models.test.ts index 2b90588d8..0a8cf17ac 100644 --- a/tests/tla-runtime-models.test.ts +++ b/tests/tla-runtime-models.test.ts @@ -435,3 +435,108 @@ test("previous-boot HTTPS archive controls accept only model-derived move witnes ).toBe(false); } }); + +test("restart-policy controls accept every reachable first violation only", () => { + const model = runtimeModels.find( + (entry) => entry.name === "service-restart-policy" + ); + expect(model).toBeDefined(); + const lateStart = (phase: string) => + tlaWitness({ + invariant: "NoIncarnationAfterStop", + action: "DaemonRestart", + fields: [ + `phase = "${phase}"`, + 'policy = "armed"', + 'engine = "running"', + "lateStart = TRUE", + ], + }); + for (const [phase, accepted] of [ + ["stopping", true], + ["stopped", true], + ["disarmed", false], + ["retry", false], + ["serving", false], + ] as const) { + expect( + model?.verify({ negative: true, exitCode: 12, output: lateStart(phase) }) + ).toBe(accepted); + } + + const control = (name: string) => + model?.additionalControls.find((entry) => entry.name === name); + const unprovedTerminal = control("unproved-terminal"); + expect(unprovedTerminal).toBeDefined(); + const terminal = (engine: string) => + tlaWitness({ + invariant: "TerminalProof", + action: "ObserveTerminal", + fields: [ + 'phase = "stopped"', + `engine = "${engine}"`, + "cancelled = TRUE", + "unsafeTerminal = TRUE", + ], + }); + for (const [engine, accepted] of [ + ["running", true], + ["restarting", true], + ["exited", false], + ["absent", false], + ] as const) { + expect( + unprovedTerminal?.verify({ + negative: true, + exitCode: 12, + output: terminal(engine), + }) + ).toBe(accepted); + } + expect( + unprovedTerminal?.verify({ + negative: true, + exitCode: 12, + output: terminal("restarting").replace( + "unsafeTerminal = TRUE", + "unsafeTerminal = FALSE" + ), + }) + ).toBe(false); + + const staleGeneration = control("stale-generation"); + expect(staleGeneration).toBeDefined(); + const staleUse = (engine: string, bound: number, phase = "serving") => + tlaWitness({ + invariant: "NoStaleIncarnation", + action: "Use", + fields: [ + `phase = "${phase}"`, + `engine = "${engine}"`, + `bound = ${bound}`, + "staleUse = TRUE", + ], + }); + for (const engine of ["running", "restarting", "exited"]) { + for (const bound of [1, 2, 3]) { + // A bound current incarnation that is still running is not stale. + const accepted = !(engine === "running" && bound === 3); + expect( + staleGeneration?.verify({ + negative: true, + exitCode: 12, + output: staleUse(engine, bound), + }) + ).toBe(accepted); + } + } + for (const invalid of [ + staleUse("exited", 0), + staleUse("exited", 1, "disarmed"), + staleUse("exited", 1).replace(" Date: Thu, 5 Nov 2026 19:04:06 -0500 Subject: [PATCH 069/138] fix(native): keep the HOME alias reserved on down and never use a squatted one Removing the /private/tmp/hkl-* alias on a clean `runtime down` let another local user create an entry at that name, after which every restore failed with socket_alias_collision. - A clean `down` keeps its alias, so the name stays owned by this pool. It removes only the provider socket files the stop proved closed, through the real data directory, and only sockets owned by this user. - When host cleanup removed a clean stop's alias and another entry (a file, a directory, another link) now holds the path, the entry is never followed, replaced or removed. Under the pool lock the receipt records a fresh random /private/tmp/hkl-* alias, which is then created exclusively. `runtime up` and `runtime recover` do this before their first strict owner read. - A pool with dependency sockets refuses a fresh alias, because its provider record pins those socket paths under the original alias. - Receipt validation accepts any /private/tmp/hkl-<12 hex> alias instead of only the token-derived name. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 30 +- .../runtime-core/src/provider/lifecycle.rs | 107 +++++-- .../provider/lifecycle/short_home/tests.rs | 289 +++++++++++++++--- packages/runtime-core/src/provider/state.rs | 256 +++++++++++----- 4 files changed, 518 insertions(+), 164 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 286e5cf0b..f381251ea 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -294,19 +294,23 @@ complete bundle to change the candidate. The provider runs with a short temporary HOME alias, `/private/tmp/hkl-`, a symlink to `/.hack-local/run/smolvm/home` that keeps its Unix socket paths -within the macOS length limit. A clean `runtime down` removes this alias after it -records the stopped pool, so a stopped pool leaves nothing outside its candidate -home. `runtime down` on a cleanly stopped pool that still has its alias (for example, -one stopped by an earlier version) removes it too. The next `runtime up`, `runtime -recover` or approved-host update restores the exact alias under the pool lock before -using it; an existing path at that name is checked and never replaced. Observation -commands such as `runtime status` accept the released alias and create nothing. -Other stop results, such as a failed boot's stop, keep the alias as before. - -Host cleanup or a reboot can also remove the alias. For a clean stop this is the same -released state. For any other receipt, normal commands -report `provider_home_missing`; they do not initialize another pool or recreate the -alias during observation. Explicit `runtime recover --json` can restore only that +within the macOS length limit. A clean `runtime down` keeps this alias, so no other +local user can take its name while the pool is stopped. It removes only the provider +socket files the stop proved closed, through the real data directory. + +Host cleanup or a reboot can still remove the alias. For a clean stop, observation +commands such as `runtime status` accept the absent alias and create nothing, and the +next `runtime up`, `runtime recover` or approved-host update restores the exact alias +under the pool lock before using it. If another entry (a file, a directory, another +user's link) holds that path by then, it is never followed, replaced or removed: the +pool records a fresh random `/private/tmp/hkl-*` alias in its receipt and creates +that one instead. A pool with dependency sockets refuses a fresh alias with +`socket_alias_collision`, because its provider record pins those socket paths under the +original alias; remove the entry at that path and retry. + +For any other receipt with an absent alias, normal commands report +`provider_home_missing`; they do not initialize another pool or recreate the alias +during observation. Explicit `runtime recover --json` can restore only that absent exact alias, after verifying the receipt-bound dead provider, both recorded disks, free VM lock, closed disk handles, and no active provider command. Existing files, directories, foreign links, a live or reused PID, and uncertain ownership diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index fadc92a59..b1aea4500 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -1058,6 +1058,9 @@ fn start_pool( project_share: Option, start: super::prepared_start::Start<'_>, ) -> Result { + // A clean stop whose alias path another entry holds is moved to a fresh alias before + // any strict owner read below refuses it. + restore_released_short_home_before_use(candidate)?; let retained = start.retained; #[cfg(target_os = "macos")] let retained_guard = retained @@ -1167,8 +1170,8 @@ fn start_pool( || admission_pool::probe(candidate, profile, &admission_owner), )?; samples.extend(fresh_samples); - // A clean stop released its temporary HOME alias; restore it under the lease before the - // release checks probe its sockets or the provider runs with it as HOME. + // Recheck a clean stop's alias under the lease before the release checks probe its + // sockets or the provider runs with it as HOME. restore_released_short_home(candidate)?; // Under the lease: the selection is rechecked. A stopped pool keeps the reserve only if // a sample needs it, and then its provider release (VM lock, disk handles, listeners, @@ -1833,11 +1836,7 @@ pub fn down(candidate: &Candidate) -> Result { && initial.process_alive != Some(true) { // These phases are written only before a start or after proven absence; no recorded - // provider (`None`) is as stopped as a dead one. A clean stop left by an earlier - // `down` that kept its alias releases it now. - if owner.short_home_releasable() { - owner.release_short_home(candidate)?; - } + // provider (`None`) is as stopped as a dead one. return status(candidate); } verify_live(candidate, &owner)?; @@ -1940,15 +1939,18 @@ sync"#, finish_stopped(candidate, &mut owner, "stopped-after-engine-failure")?; } else { finish_quiesced_stop(candidate, &mut owner)?; - // The stopped receipt is written; the temporary `/private/tmp` HOME alias is not - // needed again until the next start restores it. - owner.release_short_home(candidate)?; + // The stopped receipt is written. The `/private/tmp` HOME alias stays reserved for + // this pool, so no other user can take its name; only the provider sockets the + // stop proved closed are removed. + remove_closed_sockets(candidate, &owner)?; } status(candidate) } -/// Restore the HOME alias a clean `down` released, under the caller's pool lock. Any other -/// receipt, or one whose alias is present, is left as [`Owner::load`] checked it. +/// Restore a clean stop's HOME alias under the caller's pool lock, when host cleanup removed +/// it or another entry holds its path (see [`Owner::restore_released_short_home`]). Any +/// other receipt, or one whose alias is present and owned, is left as [`Owner::load`] +/// checks it. pub(super) fn restore_released_short_home(candidate: &Candidate) -> Result<(), CandidateError> { if !root(candidate) .join("owner.json") @@ -1957,13 +1959,77 @@ pub(super) fn restore_released_short_home(candidate: &Candidate) -> Result<(), C { return Ok(()); } - let owner = Owner::load(candidate)?; + let mut owner = Owner::load_for_alias_restore(candidate)?; if owner.short_home_releasable() { owner.restore_released_short_home(candidate)?; } Ok(()) } +/// Before a mutating operation's first strict owner read: a clean stop whose alias path +/// another entry holds, which [`Owner::load`] refuses, moves to a fresh alias under the +/// startup lease, which is then released. Every other state takes no lease here and is +/// left to the operation's own checks; an absent alias is restored later under its lock. +fn restore_released_short_home_before_use(candidate: &Candidate) -> Result<(), CandidateError> { + reject_aliased_state(&root(candidate))?; + if !root(candidate) + .join("owner.json") + .try_exists() + .map_err(io)? + { + return Ok(()); + } + let Ok(owner) = Owner::load_for_alias_restore(candidate) else { + // Refusals are reported by the operation's strict read. + return Ok(()); + }; + if !owner.short_home_releasable() || Owner::load(candidate).is_ok() { + return Ok(()); + } + let _lease = startup_lease(&root(candidate), STARTUP_LEASE_WAIT)?; + restore_released_short_home(candidate) +} + +/// The provider sockets [`sockets_absent`] checks, by file name in the data directory. +fn owned_socket_names(owner: &Owner) -> Vec { + let mut sockets = vec!["agent.sock".to_owned(), "docker.sock".to_owned()]; + if let Some(bridge) = owner.application_bridge { + sockets.extend(bridge.guest_paths().into_iter().map(|path| { + Path::new(&path) + .file_name() + .expect("fixed socket name") + .to_string_lossy() + .into_owned() + })); + } + sockets +} + +/// After a clean stop proved that no owned provider socket accepts connections, remove the +/// socket files left in the private data directory. The path is the real data directory, +/// never the alias. Only sockets owned by this user are removed; any other entry is kept. +fn remove_closed_sockets(candidate: &Candidate, owner: &Owner) -> Result<(), CandidateError> { + use std::os::unix::fs::FileTypeExt; + let data = owner.real_data_dir(candidate)?; + for name in owned_socket_names(owner) { + let path = data.join(name); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(io(error)), + }; + // SAFETY: geteuid has no preconditions. + if !metadata.file_type().is_socket() || metadata.uid() != unsafe { libc::geteuid() } { + continue; + } + match fs::remove_file(&path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(io(error)), + _ => {} + } + } + Ok(()) +} + /// `down`'s completion after the guest acknowledged flush and shutdown of a quiesced engine /// and the provider exited: the clean-stop marker names this boot and is written with the /// `stopped` phase once the provider's release is proven. If any proof fails, nothing is @@ -2110,17 +2176,7 @@ fn finish_absent_locked( /// No owned provider socket accepts connections. Read-only: connecting never creates one. fn sockets_absent(owner: &Owner) -> Result<(), CandidateError> { - let mut sockets = vec!["agent.sock".to_owned(), "docker.sock".to_owned()]; - if let Some(bridge) = owner.application_bridge { - sockets.extend(bridge.guest_paths().into_iter().map(|path| { - Path::new(&path) - .file_name() - .expect("fixed socket name") - .to_string_lossy() - .into_owned() - })); - } - for name in sockets { + for name in owned_socket_names(owner) { match UnixStream::connect(owner.data_dir().join(name)) { Ok(_) => { return Err(CandidateError::new( @@ -2143,6 +2199,7 @@ fn sockets_absent(owner: &Owner) -> Result<(), CandidateError> { } pub fn recover(candidate: &Candidate) -> Result { + restore_released_short_home_before_use(candidate)?; let initial = match status(candidate) { Ok(initial) => initial, Err(error) if error.code == "provider_home_missing" => { @@ -2154,7 +2211,7 @@ pub fn recover(candidate: &Candidate) -> Result { return Ok(initial); } let lock = state::Lock::acquire(&root(candidate))?; - // Recovery rewrites the phase, which needs the alias a clean stop released. + // Recovery rewrites the phase, which needs a clean stop's alias restored. restore_released_short_home(candidate)?; let mut owner = Owner::load(candidate)?; let Some(process) = owner.process.as_ref() else { diff --git a/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs b/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs index 33eae3b9a..e76b03f59 100644 --- a/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs +++ b/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs @@ -138,6 +138,13 @@ impl Pool { impl Drop for Pool { fn drop(&mut self) { let _ = fs::remove_file(&self.owner.short_home); + // A fresh alias a restore recorded, if the test did not reach its own cleanup. + let home = root(&self.candidate).join("home"); + if let Ok(current) = state::read::(&root(&self.candidate).join("owner.json")) + && fs::read_link(¤t.short_home).is_ok_and(|target| target == home) + { + let _ = fs::remove_file(¤t.short_home); + } fs::remove_dir_all(&self.directory).unwrap(); } } @@ -407,8 +414,10 @@ fn vm_lock_release_child() { } } -/// HACK-1916: a clean `down` releases the temporary HOME alias, so a stopped pool leaves -/// nothing under `/private/tmp`, and the next mutating operation restores it exactly. +/// HACK-1916: a clean `down` keeps its temporary HOME alias reserved, so no other user can +/// take the name while the pool is stopped. Host cleanup can still remove it; then the next +/// mutating operation restores it, and a path another entry took meanwhile is never used: +/// the receipt moves to a fresh alias instead. impl Pool { /// The receipt `down` writes after a quiesced stop: `stopped` plus the clean-stop marker. fn cleanly_stopped(mut self) -> Self { @@ -427,21 +436,126 @@ impl Pool { Err(error) if error.kind() == std::io::ErrorKind::NotFound ) } + fn alias_inode(&self) -> u64 { + fs::symlink_metadata(&self.owner.short_home).unwrap().ino() + } +} + +/// The entry another user could leave at a released alias path, and how it is checked. +#[derive(Clone, Copy, Debug)] +enum Squatter { + File, + Directory, + Link, +} +impl Squatter { + const ALL: [Self; 3] = [Self::File, Self::Directory, Self::Link]; + /// Place the entry, and a decoy directory a link points at. + fn place(self, pool: &Pool) -> PathBuf { + let decoy = pool.directory.join("decoy"); + state::private_directory(&decoy).unwrap(); + match self { + Self::File => fs::write(&pool.owner.short_home, b"foreign marker").unwrap(), + Self::Directory => fs::create_dir(&pool.owner.short_home).unwrap(), + Self::Link => std::os::unix::fs::symlink(&decoy, &pool.owner.short_home).unwrap(), + } + decoy + } + fn untouched(self, path: &std::path::Path, inode: u64, decoy: &std::path::Path) { + let metadata = fs::symlink_metadata(path).unwrap(); + assert_eq!(metadata.ino(), inode, "{self:?} entry was replaced"); + match self { + Self::File => assert_eq!(fs::read(path).unwrap(), b"foreign marker"), + Self::Directory => assert_eq!(fs::read_dir(path).unwrap().count(), 0), + Self::Link => assert_eq!(fs::read_link(path).unwrap(), decoy), + } + // Nothing was created through the entry. + assert_eq!(fs::read_dir(decoy).unwrap().count(), 0); + } + fn remove(path: &std::path::Path) { + if fs::symlink_metadata(path).unwrap().is_dir() { + fs::remove_dir(path).unwrap(); + } else { + fs::remove_file(path).unwrap(); + } + } } #[test] -fn clean_stop_releases_the_alias_and_the_next_mutation_restores_it_exactly() { +fn a_clean_stop_keeps_its_alias_reserved_and_restart_reuses_it() { let pool = Pool::new().cleanly_stopped(); let before = pool.receipt(); - pool.owner.release_short_home(&pool.candidate).unwrap(); - assert!(pool.alias_absent()); + let inode = pool.alias_inode(); + assert_eq!(Owner::load(&pool.candidate).unwrap(), pool.owner); + assert_eq!(status(&pool.candidate).unwrap().phase, "stopped"); + // Holding the pool lock proves the pre-start check takes no lease for an owned alias. + let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); + super::super::restore_released_short_home_before_use(&pool.candidate).unwrap(); + super::super::restore_released_short_home(&pool.candidate).unwrap(); + drop(lock); + assert_eq!(pool.alias_inode(), inode); + assert_eq!( + fs::read_link(&pool.owner.short_home).unwrap(), + root(&pool.candidate).join("home") + ); assert_eq!(pool.receipt(), before); - // Observation accepts the released alias and creates nothing. +} + +#[test] +fn a_clean_stop_removes_only_its_closed_sockets_and_keeps_the_alias() { + use std::os::unix::fs::FileTypeExt; + let pool = Pool::new().cleanly_stopped(); + let inode = pool.alias_inode(); + // A provider exit can leave its socket files behind after the listeners close. They + // are bound through the short alias, as the provider binds them. + for name in ["agent.sock", "docker.sock"] { + drop(UnixListener::bind(pool.owner.data_dir().join(name)).unwrap()); + assert!( + fs::symlink_metadata(pool.data().join(name)) + .unwrap() + .file_type() + .is_socket() + ); + } + super::super::remove_closed_sockets(&pool.candidate, &pool.owner).unwrap(); + for name in ["agent.sock", "docker.sock"] { + assert!(!pool.data().join(name).exists()); + } + assert_eq!(pool.alias_inode(), inode); + // Anything that is not an owned socket is kept, and a link is never followed. + let target = pool.owner.short_home.join("target.sock"); + drop(UnixListener::bind(&target).unwrap()); + std::os::unix::fs::symlink(&target, pool.data().join("agent.sock")).unwrap(); + fs::write(pool.data().join("docker.sock"), b"not a socket").unwrap(); + super::super::remove_closed_sockets(&pool.candidate, &pool.owner).unwrap(); + assert_eq!( + fs::read_link(pool.data().join("agent.sock")).unwrap(), + target + ); + assert!( + fs::symlink_metadata(&target) + .unwrap() + .file_type() + .is_socket() + ); + assert_eq!( + fs::read(pool.data().join("docker.sock")).unwrap(), + b"not a socket" + ); + assert_eq!(pool.alias_inode(), inode); +} + +#[test] +fn a_clean_stop_whose_alias_host_cleanup_removed_is_restored_exactly() { + let pool = Pool::new().cleanly_stopped(); + pool.remove_alias(); + let before = pool.receipt(); + // Observation accepts the absent alias and creates nothing. assert_eq!(Owner::load(&pool.candidate).unwrap(), pool.owner); assert_eq!(status(&pool.candidate).unwrap().phase, "stopped"); assert!(pool.alias_absent()); - // Releasing again is a no-op. - pool.owner.release_short_home(&pool.candidate).unwrap(); + // The pre-start check leaves an absent alias to the restore under the lease. + super::super::restore_released_short_home_before_use(&pool.candidate).unwrap(); assert!(pool.alias_absent()); let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); @@ -450,13 +564,10 @@ fn clean_stop_releases_the_alias_and_the_next_mutation_restores_it_exactly() { fs::read_link(&pool.owner.short_home).unwrap(), root(&pool.candidate).join("home") ); - let inode = fs::symlink_metadata(&pool.owner.short_home).unwrap().ino(); + let inode = pool.alias_inode(); // A present exact alias is checked, never replaced. super::super::restore_released_short_home(&pool.candidate).unwrap(); - assert_eq!( - fs::symlink_metadata(&pool.owner.short_home).unwrap().ino(), - inode - ); + assert_eq!(pool.alias_inode(), inode); assert_eq!(pool.receipt(), before); drop(lock); } @@ -469,14 +580,6 @@ fn only_a_clean_stop_may_lack_its_alias() { let mut pool = Pool::new(); pool.owner.phase = phase.into(); pool.owner.save(&pool.candidate).unwrap(); - assert_eq!( - pool.owner - .release_short_home(&pool.candidate) - .unwrap_err() - .code, - "recovery_required" - ); - assert!(!pool.alias_absent()); pool.remove_alias(); let before = pool.receipt(); assert_eq!( @@ -495,7 +598,7 @@ fn only_a_clean_stop_may_lack_its_alias() { } // A pending owner update keeps even a clean stop strict. let pool = Pool::new().cleanly_stopped(); - pool.owner.release_short_home(&pool.candidate).unwrap(); + pool.remove_alias(); let pending = root(&pool.candidate).join("owner.pending"); fs::write(&pending, b"interrupted update").unwrap(); assert_eq!( @@ -507,38 +610,110 @@ fn only_a_clean_stop_may_lack_its_alias() { } #[test] -fn a_released_alias_path_taken_by_anything_else_is_never_adopted_or_replaced() { +fn a_squatted_alias_is_never_used_and_the_receipt_moves_to_a_fresh_alias() { + for squatter in Squatter::ALL { + let pool = Pool::new().cleanly_stopped(); + pool.remove_alias(); + let decoy = squatter.place(&pool); + let squatted = pool.owner.short_home.clone(); + let inode = pool.alias_inode(); + let before = pool.receipt(); + // Strict reads refuse the entry and never follow it; observation changes nothing. + assert_eq!( + Owner::load(&pool.candidate).unwrap_err().code, + "foreign_state" + ); + assert_eq!(status(&pool.candidate).unwrap_err().code, "foreign_state"); + assert_eq!(pool.receipt(), before); + + let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); + super::super::restore_released_short_home(&pool.candidate).unwrap(); + drop(lock); + let restored = Owner::load(&pool.candidate).unwrap(); + assert_ne!(restored.short_home, squatted, "{squatter:?}"); + assert_eq!(restored.short_home.parent(), squatted.parent()); + assert_eq!( + fs::read_link(&restored.short_home).unwrap(), + root(&pool.candidate).join("home") + ); + // Only the alias changed: same clean stop, same disks, same machine. + let mut expected = pool.owner.clone(); + expected.short_home = restored.short_home.clone(); + assert_eq!(restored, expected); + assert!(restored.short_home_releasable()); + assert_eq!(status(&pool.candidate).unwrap().phase, "stopped"); + squatter.untouched(&squatted, inode, &decoy); + // The fresh alias is reused as the reserved alias from now on. + let reserved = fs::symlink_metadata(&restored.short_home).unwrap().ino(); + let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); + super::super::restore_released_short_home(&pool.candidate).unwrap(); + drop(lock); + assert_eq!( + fs::symlink_metadata(&restored.short_home).unwrap().ino(), + reserved + ); + fs::remove_file(&restored.short_home).unwrap(); + Squatter::remove(&squatted); + } +} + +#[test] +fn a_squatted_alias_is_moved_before_start_reads_the_owner() { let pool = Pool::new().cleanly_stopped(); - pool.owner.release_short_home(&pool.candidate).unwrap(); - let before = pool.receipt(); - let foreign = pool.directory.join("foreign"); - state::private_directory(&foreign).unwrap(); - std::os::unix::fs::symlink(&foreign, &pool.owner.short_home).unwrap(); + pool.remove_alias(); + let decoy = Squatter::Link.place(&pool); + let squatted = pool.owner.short_home.clone(); + let inode = pool.alias_inode(); + // `runtime up` calls this before its first strict owner read, with no lease held. + super::super::restore_released_short_home_before_use(&pool.candidate).unwrap(); + let restored = Owner::load(&pool.candidate).unwrap(); + assert_ne!(restored.short_home, squatted); assert_eq!( - Owner::load(&pool.candidate).unwrap_err().code, - "foreign_state" + fs::read_link(&restored.short_home).unwrap(), + root(&pool.candidate).join("home") ); + Squatter::Link.untouched(&squatted, inode, &decoy); + fs::remove_file(&restored.short_home).unwrap(); + Squatter::remove(&squatted); +} + +#[test] +fn a_squatted_alias_of_a_pool_with_pinned_dependency_sockets_is_refused_unchanged() { + let mut pool = Pool::new(); + pool.owner.dependency_sockets = Some(crate::provider::DependencySocketIntent::new(1).unwrap()); + let mut pool = pool.cleanly_stopped(); + pool.remove_alias(); + let decoy = Squatter::Link.place(&pool); + let inode = pool.alias_inode(); + let before = pool.receipt(); + let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); assert_eq!( - pool.owner - .restore_released_short_home(&pool.candidate) + super::super::restore_released_short_home(&pool.candidate) .unwrap_err() .code, - "foreign_state" + "socket_alias_collision" ); + drop(lock); + assert_eq!(pool.receipt(), before); + Squatter::Link.untouched(&pool.owner.short_home, inode, &decoy); + Squatter::remove(&pool.owner.short_home); + // Removing the entry lets the next restore create the original alias. + let lock = state::Lock::acquire(&root(&pool.candidate)).unwrap(); + super::super::restore_released_short_home(&pool.candidate).unwrap(); + drop(lock); assert_eq!( - pool.owner - .release_short_home(&pool.candidate) - .unwrap_err() - .code, - "foreign_state" + fs::read_link(&pool.owner.short_home).unwrap(), + root(&pool.candidate).join("home") ); - assert_eq!(fs::read_link(&pool.owner.short_home).unwrap(), foreign); - assert_eq!(fs::read_dir(&foreign).unwrap().count(), 0); + pool.owner = Owner::load(&pool.candidate).unwrap(); assert_eq!(pool.receipt(), before); - fs::remove_file(&pool.owner.short_home).unwrap(); +} - // A receipt that changed after it was observed restores nothing. - let observed = pool.owner.clone(); +#[test] +fn a_receipt_that_changed_after_it_was_observed_restores_nothing() { + let pool = Pool::new().cleanly_stopped(); + pool.remove_alias(); + let mut observed = pool.owner.clone(); let mut changed = pool.owner.clone(); changed.phase = "recovered-unclean".into(); changed.clean_stop = None; @@ -564,7 +739,7 @@ fn recovery_of_a_released_clean_stop_restores_the_alias_first() { #[ignore = "isolated release-check fixture launched by recovery_of_a_released_clean_stop_restores_the_alias_first"] fn released_recovery_child() { let pool = Pool::new().cleanly_stopped(); - pool.owner.release_short_home(&pool.candidate).unwrap(); + pool.remove_alias(); let recovered = crate::provider::recover(&pool.candidate).unwrap(); assert_eq!(recovered.phase, "recovered-unclean"); assert_eq!( @@ -577,3 +752,29 @@ fn released_recovery_child() { "recovered-unclean" ); } + +#[test] +fn recovery_of_a_squatted_clean_stop_uses_a_fresh_alias() { + isolated("squatted_recovery_child"); +} + +#[test] +#[ignore = "isolated release-check fixture launched by recovery_of_a_squatted_clean_stop_uses_a_fresh_alias"] +fn squatted_recovery_child() { + let pool = Pool::new().cleanly_stopped(); + pool.remove_alias(); + let decoy = Squatter::Link.place(&pool); + let squatted = pool.owner.short_home.clone(); + let inode = pool.alias_inode(); + let recovered = crate::provider::recover(&pool.candidate).unwrap(); + assert_eq!(recovered.phase, "recovered-unclean"); + let owner = Owner::load(&pool.candidate).unwrap(); + assert_ne!(owner.short_home, squatted); + assert_eq!( + fs::read_link(&owner.short_home).unwrap(), + root(&pool.candidate).join("home") + ); + Squatter::Link.untouched(&squatted, inode, &decoy); + fs::remove_file(&owner.short_home).unwrap(); + Squatter::remove(&squatted); +} diff --git a/packages/runtime-core/src/provider/state.rs b/packages/runtime-core/src/provider/state.rs index 3622f83cc..df8aabd06 100644 --- a/packages/runtime-core/src/provider/state.rs +++ b/packages/runtime-core/src/provider/state.rs @@ -11,6 +11,17 @@ pub fn io(error: std::io::Error) -> CandidateError { CandidateError::new("provider_state", error.to_string()) } +fn missing_alias() -> CandidateError { + CandidateError::new( + "provider_home_missing", + "Temporary provider HOME alias is absent. Use runtime recover to verify the stopped pool before restoring it.", + ) +} + +fn replaced_alias() -> CandidateError { + CandidateError::new("foreign_state", "Short provider HOME alias was replaced.") +} + pub fn private_directory(path: &Path) -> Result<(), CandidateError> { reject_aliased_state(path)?; fs::DirBuilder::new() @@ -276,9 +287,50 @@ impl CleanStop { } } +/// What occupies a receipt's temporary HOME alias path, observed with `lstat` only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Alias { + /// This user's symlink to this pool's private provider home. + Owned, + Missing, + /// Anything else: another user's entry, a file, a directory or another link. It is + /// never followed, adopted, replaced or removed. + Foreign, +} + +/// The only accepted alias shape: `/private/tmp/hkl-` and 12 lowercase hex digits. Pool +/// creation derives the name from the token; a squatted name is replaced by a fresh one. +fn valid_short_home(path: &Path) -> bool { + path.parent() == Some(Path::new("/private/tmp")) + && path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix("hkl-")) + .is_some_and(|suffix| { + suffix.len() == 12 + && suffix + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) +} + +/// A fresh random alias path. Creating it is still exclusive; a collision tries another. +fn fresh_short_home() -> Result { + let mut bytes = [0_u8; 6]; + File::open("/dev/urandom") + .map_err(io)? + .read_exact(&mut bytes) + .map_err(io)?; + let suffix: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + Ok(Path::new("/private/tmp").join(format!("hkl-{suffix}"))) +} + +/// Attempts at a fresh alias name before a squatted restore gives up. +const FRESH_ALIAS_ATTEMPTS: usize = 4; + impl Owner { pub fn load(candidate: &Candidate) -> Result { - Self::load_with_short_home(candidate, false) + Self::load_with_short_home(candidate, false, false) } /// Explicit recovery may inspect a missing temporary alias, never a replaced one. @@ -286,12 +338,20 @@ impl Owner { pub(super) fn load_for_short_home_recovery( candidate: &Candidate, ) -> Result { - Self::load_with_short_home(candidate, true) + Self::load_with_short_home(candidate, true, false) + } + + /// Only for [`Owner::restore_released_short_home`] under the pool lock: a clean stop + /// whose alias path another entry holds loads, so it can move to a fresh alias. The + /// returned receipt's alias must not be used until that restore succeeds. + pub(super) fn load_for_alias_restore(candidate: &Candidate) -> Result { + Self::load_with_short_home(candidate, false, true) } fn load_with_short_home( candidate: &Candidate, allow_missing: bool, + allow_squatted: bool, ) -> Result { let root = candidate.state_root.join("run/smolvm"); reject_aliased_state(&root)?; @@ -334,117 +394,153 @@ impl Owner { || owner.token.len() != 32 || !owner.token.bytes().all(|b| b.is_ascii_hexdigit()) || owner.machine != format!("hack-{}", &owner.token[..12]) - || owner.short_home - != Path::new("/private/tmp").join(format!("hkl-{}", &owner.token[..12])) + || !valid_short_home(&owner.short_home) { return Err(CandidateError::new( "foreign_state", "Provider owner identity does not match this checkout.", )); } - // A clean stop's alias may be absent: `down` releases it, and the next mutating - // operation restores it under the pool lock before using it. + // A clean stop's alias may be absent, after host cleanup of `/private/tmp`; the next + // mutating operation restores it under the pool lock before using it. let released = owner.short_home_releasable() && no_pending_update; - owner.check_short_home(candidate, allow_missing || released)?; + match owner.alias(candidate)? { + Alias::Owned => {} + Alias::Missing if allow_missing || released => {} + Alias::Missing => return Err(missing_alias()), + Alias::Foreign if released && allow_squatted => {} + Alias::Foreign if released => { + return Err(CandidateError::new( + "foreign_state", + "Temporary provider HOME alias path is held by another entry, which is never used. Run runtime up or runtime recover to restore the stopped pool under a new alias.", + )); + } + Alias::Foreign => return Err(replaced_alias()), + } check_private_directory(&root.join("home"))?; Ok(owner) } /// Whether this receipt is a clean stop: the `stopped` phase with the clean-stop marker, /// which only `down` writes, after proving provider and socket absence, in one receipt - /// write. Only such a receipt may exist without its temporary HOME alias. Any other - /// phase write clears the marker, so every later phase needs the alias again. + /// write. Only such a receipt may exist without its temporary HOME alias, or move to a + /// fresh alias. Any other phase write clears the marker, so every later phase needs + /// the alias again. pub(super) fn short_home_releasable(&self) -> bool { self.phase == "stopped" && self.clean_stop.is_some() } - /// Remove the temporary `/private/tmp` HOME alias of a clean stop, so a stopped pool - /// leaves nothing outside its candidate home. Called by `down` under the pool lock - /// after its stopped receipt is written. The alias is removed only when it is exactly - /// this receipt's own link; an absent alias is already released. A replaced alias is - /// refused and kept. - pub(super) fn release_short_home(&self, candidate: &Candidate) -> Result<(), CandidateError> { - if !self.short_home_releasable() { - return Err(CandidateError::new( - "recovery_required", - "Only a clean stop releases the provider HOME alias.", - )); - } - match fs::symlink_metadata(&self.short_home) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(io(error)), - Ok(_) => {} - } - self.check_short_home(candidate, false)?; - // `/private/tmp` is sticky: only this user can unlink or rename its own link, so the - // link checked above is the one removed. - match fs::remove_file(&self.short_home) { - Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(io(error)), - _ => Ok(()), - } - } - - /// Restore the alias `down` released, before a mutating operation uses it. Called under - /// the pool lock. A present alias is checked, not replaced; an absent one is created - /// exclusively after the durable receipt is rechecked, as in explicit recovery. + /// Restore the HOME alias of a clean stop before a mutating operation uses it. Called + /// under the pool lock. A clean `down` keeps its alias, so this acts only after host + /// cleanup removed it, or when another entry took its path: + /// + /// - the exact owned alias is kept as it is; + /// - an absent alias is created exclusively after the durable receipt is rechecked; + /// - an alias path held by anything else (another user's entry, a file, a directory, + /// another link) is never followed, replaced or removed. The receipt records a fresh + /// random alias first, and then that alias is created exclusively. + /// + /// A pool with dependency sockets refuses a fresh alias: its provider record pins + /// those socket paths under the original alias. pub(super) fn restore_released_short_home( - &self, + &mut self, candidate: &Candidate, ) -> Result<(), CandidateError> { - match fs::symlink_metadata(&self.short_home) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io(error)), - Ok(_) => return self.check_short_home(candidate, false), + let alias = self.alias(candidate)?; + if alias == Alias::Owned { + return Ok(()); } - if !self.short_home_releasable() || Self::load(candidate)? != *self { + if !self.short_home_releasable() || Self::load_for_alias_restore(candidate)? != *self { return Err(CandidateError::new( "foreign_state", "Provider owner changed before HOME alias restoration; no alias was restored.", )); } - std::os::unix::fs::symlink( - candidate.state_root.join("run/smolvm/home"), - &self.short_home, - ) - .map_err(|_| { - CandidateError::new( + if alias == Alias::Missing { + match std::os::unix::fs::symlink(self.home(candidate), &self.short_home) { + Ok(()) => return self.check_short_home(candidate, false), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + // Another entry took the path between the check and the creation. + if self.alias(candidate)? == Alias::Owned { + return Ok(()); + } + } + Err(_) => { + return Err(CandidateError::new( + "socket_alias_collision", + "Cannot exclusively restore provider HOME alias; no existing path was replaced.", + )); + } + } + } + self.move_to_fresh_short_home(candidate) + } + + /// Record a fresh alias, then create it exclusively. A crash in between leaves a clean + /// stop whose recorded alias is absent, which the next restore creates. + fn move_to_fresh_short_home(&mut self, candidate: &Candidate) -> Result<(), CandidateError> { + if self.dependency_sockets.is_some() { + return Err(CandidateError::new( "socket_alias_collision", - "Cannot exclusively restore provider HOME alias; no existing path was replaced.", - ) - })?; - self.check_short_home(candidate, false) + "Provider HOME alias path is held by another entry, and this pool's provider record pins its dependency socket paths there. Nothing was followed or replaced; remove that entry, then retry.", + )); + } + for _ in 0..FRESH_ALIAS_ATTEMPTS { + let mut next = self.clone(); + next.short_home = fresh_short_home()?; + if next.short_home == self.short_home { + continue; + } + next.save(candidate)?; + *self = next; + match std::os::unix::fs::symlink(self.home(candidate), &self.short_home) { + Ok(()) => return self.check_short_home(candidate, false), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(io(error)), + } + } + Err(CandidateError::new( + "socket_alias_collision", + "Cannot exclusively create a fresh provider HOME alias; no existing path was replaced.", + )) } - /// The only permitted alias is the exact receipt-bound HOME for Unix sockets. - fn check_short_home( - &self, - candidate: &Candidate, - allow_missing: bool, - ) -> Result<(), CandidateError> { + + fn home(&self, candidate: &Candidate) -> PathBuf { + candidate.state_root.join("run/smolvm/home") + } + + /// Classify the alias path with `lstat`. A link is read, never followed. + fn alias(&self, candidate: &Candidate) -> Result { let alias = match fs::symlink_metadata(&self.short_home) { Ok(alias) => alias, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return if allow_missing { - Ok(()) - } else { - Err(CandidateError::new( - "provider_home_missing", - "Temporary provider HOME alias is absent. Use runtime recover to verify the stopped pool before restoring it.", - )) - }; + return Ok(Alias::Missing); } Err(error) => return Err(io(error)), }; - if !alias.file_type().is_symlink() - || alias.uid() != unsafe { libc::geteuid() } - || fs::read_link(&self.short_home).map_err(io)? - != candidate.state_root.join("run/smolvm/home") + // SAFETY: geteuid has no preconditions. + if alias.file_type().is_symlink() + && alias.uid() == unsafe { libc::geteuid() } + && fs::read_link(&self.short_home).map_err(io)? == self.home(candidate) { - return Err(CandidateError::new( - "foreign_state", - "Short provider HOME alias was replaced.", - )); + Ok(Alias::Owned) + } else { + Ok(Alias::Foreign) + } + } + + /// The only permitted alias is the exact receipt-bound HOME for Unix sockets. + fn check_short_home( + &self, + candidate: &Candidate, + allow_missing: bool, + ) -> Result<(), CandidateError> { + match self.alias(candidate)? { + Alias::Owned => Ok(()), + Alias::Missing if allow_missing => Ok(()), + Alias::Missing => Err(missing_alias()), + Alias::Foreign => Err(replaced_alias()), } - Ok(()) } /// Called only while the lifecycle owns the pool and VM locks and has audited absence. @@ -460,11 +556,7 @@ impl Owner { "Provider owner changed during HOME recovery; no alias was restored.", )); } - std::os::unix::fs::symlink( - candidate.state_root.join("run/smolvm/home"), - &self.short_home, - ) - .map_err(|_| { + std::os::unix::fs::symlink(self.home(candidate), &self.short_home).map_err(|_| { CandidateError::new( "socket_alias_collision", "Cannot exclusively restore provider HOME alias; no existing path was replaced.", From ae1fba0a7fe958ee3ffc789a1ac08b98380d22ea Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:09:34 -0500 Subject: [PATCH 070/138] fix(native): requalify reserve restart after enabling amd64 emulation A clean stopped pool that qualifies on the retained-disk reserve kept a selection naming its original owner. Recording amd64_emulation on that pool then failed the pre-boot recheck with admission_owner_changed. The restart authority now follows exactly that transition under the same lease: the receipt may differ from the selection only by gaining the intent, and the full leased recheck (receipt, release and provider configuration audit) runs on the rebound receipt after machine update. Any other owner change still refuses. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/lifecycle.rs | 20 ++- .../src/provider/lifecycle/admission_pool.rs | 37 +++- .../lifecycle/admission_pool/tests.rs | 165 ++++++++++++++++-- 3 files changed, 204 insertions(+), 18 deletions(-) diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 0e7beea98..a8505a4e9 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -695,8 +695,11 @@ fn enable_amd64_emulation( candidate: &Candidate, owner: &mut Owner, requested: bool, + restart: &mut admission_pool::Restart<'_>, + lock: &state::Lock, ) -> Result<(), CandidateError> { - if requested && !owner.amd64_emulation { + let enabled = requested && !owner.amd64_emulation; + if enabled { owner.amd64_emulation = true; owner.save(candidate)?; } @@ -710,6 +713,11 @@ fn enable_amd64_emulation( )?; } } + if enabled { + // A reserve-qualified restart follows this authorized intent change, rechecked on + // the provider's updated configuration, instead of refusing it as a changed owner. + restart.requalify_amd64_emulation(candidate, lock)?; + } Ok(()) } @@ -1327,7 +1335,7 @@ fn start_pool( return status(candidate); } // Only a clean stopped selection continues, as a restart of its verified retained disks. - let restart = admission_pool::restart_only(candidate, settled, &owner, &lock)?; + let mut restart = admission_pool::restart_only(candidate, settled, &owner, &lock)?; if ![ "initializing", "stopped", @@ -1351,7 +1359,13 @@ fn start_pool( } verify_disks(candidate, &owner)?; } - enable_amd64_emulation(candidate, &mut owner, start.amd64_emulation)?; + enable_amd64_emulation( + candidate, + &mut owner, + start.amd64_emulation, + &mut restart, + &lock, + )?; prepare_rootfs(candidate, &mut owner)?; if !owner.created { // A reserve-qualified start never reaches `machine create`. diff --git a/packages/runtime-core/src/provider/lifecycle/admission_pool.rs b/packages/runtime-core/src/provider/lifecycle/admission_pool.rs index e1ec90a86..45fb250cd 100644 --- a/packages/runtime-core/src/provider/lifecycle/admission_pool.rs +++ b/packages/runtime-core/src/provider/lifecycle/admission_pool.rs @@ -387,8 +387,7 @@ pub(super) fn settle<'a>( /// The admission basis this start settled under the startup lease. Only [`settle`] issues /// it, and [`restart_only`] needs it. -#[derive(Clone, Copy)] -pub(super) struct Settled<'a>(&'a Choice); +pub(super) struct Settled<'a>(&'a mut Choice); impl Settled<'_> { pub(super) fn selected(&self) -> Option<&Selection> { @@ -398,7 +397,7 @@ impl Settled<'_> { /// Authority for create or restart effects and for the pre-boot admission. Only /// [`restart_only`] issues it. -pub(super) struct Restart<'a>(&'a Choice); +pub(super) struct Restart<'a>(&'a mut Choice); /// Before a restart effect under the startup lease: a selection continues only as a /// restart of its clean stopped pool's verified retained disks. @@ -422,6 +421,36 @@ pub(super) fn restart_only<'a>( Ok(Restart(choice)) } +impl Restart<'_> { + /// Follow the one owner change a restart itself authorizes: `runtime up + /// --amd64-emulation` records the translation intent on the stopped pool (and stores it + /// in the provider with `machine update --rosetta`) after [`restart_only`] and before + /// the pre-boot admission, under the same startup lease. + /// + /// A start without a selection needs nothing. A stopped selection is rebound only when + /// the current receipt differs from the selected one by gaining that intent and nothing + /// else; the full leased recheck (receipt and release checks, including the provider + /// configuration audit against the new intent) then runs on the rebound receipt. Any + /// other change still refuses `admission_owner_changed`. + pub(super) fn requalify_amd64_emulation( + &mut self, + candidate: &Candidate, + lease: &state::Lock, + ) -> Result<(), CandidateError> { + let Some(selected) = self.0.selection.as_mut() else { + return Ok(()); + }; + let current = Owner::load(candidate)?; + let mut expected = selected.owner.clone(); + expected.amd64_emulation = true; + if !selected.is_stopped() || selected.owner.amd64_emulation || current != expected { + return Err(changed()); + } + selected.owner = current; + selected.reverify_leased(candidate, lease) + } +} + /// Authority to run `machine create`: only a start without a reserve selection allocates /// new disks. pub(super) struct FreshCreate(()); @@ -458,7 +487,7 @@ pub(super) enum BootGate<'a> { pub(super) fn admit_boot<'a>( candidate: &Candidate, profile: Profile, - restart: &Restart<'a>, + restart: &'a Restart<'_>, baseline_swapouts: Option, ) -> Result, CandidateError> { admit_boot_with(candidate, restart.0, baseline_swapouts, |owned| { diff --git a/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs b/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs index 2a1fe219a..6375ee203 100644 --- a/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs +++ b/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs @@ -1121,12 +1121,11 @@ fn a_reserve_selection_only_restarts_and_never_creates() { fn reserve_restart_only_child() { let _serial = serial(); let pool = stopped_pool(); - let choice = select(&pool.candidate, Profile::Development).unwrap(); + let mut choice = select(&pool.candidate, Profile::Development).unwrap(); let lease = lease(&pool); let owner = Owner::load(&pool.candidate).unwrap(); // Each case stands for a start whose basis `settle` already settled under the lease. - let settled = Settled(&choice); - let restart = restart_only(&pool.candidate, settled, &owner, &lease).unwrap(); + let restart = restart_only(&pool.candidate, Settled(&mut choice), &owner, &lease).unwrap(); assert_eq!( fresh_create(&restart).err().unwrap().code, "admission_owner_changed" @@ -1135,7 +1134,7 @@ fn reserve_restart_only_child() { let mut uncreated = owner.clone(); uncreated.created = false; assert_eq!( - restart_only(&pool.candidate, settled, &uncreated, &lease) + restart_only(&pool.candidate, Settled(&mut choice), &uncreated, &lease) .err() .unwrap() .code, @@ -1144,7 +1143,7 @@ fn reserve_restart_only_child() { let mut other = owner.clone(); other.guest_boot_id = None; assert_eq!( - restart_only(&pool.candidate, settled, &other, &lease) + restart_only(&pool.candidate, Settled(&mut choice), &other, &lease) .err() .unwrap() .code, @@ -1153,7 +1152,7 @@ fn reserve_restart_only_child() { // The provider's release is proven under the lease before any restart effect. let held = exclusive_lock(&pool.data().join("vm.lock")); assert_eq!( - restart_only(&pool.candidate, settled, &owner, &lease) + restart_only(&pool.candidate, Settled(&mut choice), &owner, &lease) .err() .unwrap() .code, @@ -1164,27 +1163,171 @@ fn reserve_restart_only_child() { // A live selection never restarts or creates. let running = Pool::new(); - let choice = select(&running.candidate, Profile::Development).unwrap(); + let mut choice = select(&running.candidate, Profile::Development).unwrap(); let lease = state::Lock::acquire(&root(&running.candidate)).unwrap(); let owner = Owner::load(&running.candidate).unwrap(); assert_eq!( - restart_only(&running.candidate, Settled(&choice), &owner, &lease) + restart_only(&running.candidate, Settled(&mut choice), &owner, &lease) .err() .unwrap() .code, "admission_owner_changed" ); - assert!(fresh_create(&Restart(&choice)).is_err()); + assert!(fresh_create(&Restart(&mut choice)).is_err()); // Without a selection, the unchanged fresh path may create. - let fresh = Choice { + let mut fresh = Choice { selection: None, refusal: None, }; - let restart = restart_only(&running.candidate, Settled(&fresh), &owner, &lease).unwrap(); + let restart = restart_only(&running.candidate, Settled(&mut fresh), &owner, &lease).unwrap(); fresh_create(&restart).unwrap(); } +/// `machine update --rosetta`: the provider stores the translation setting in this pool's +/// machine record. +fn provider_rosetta(pool: &Pool, enabled: bool) { + let connection = rusqlite::Connection::open(provider_database(pool)).unwrap(); + let data: Vec = connection + .query_row( + "SELECT data FROM vms WHERE name = ?1", + [&pool.owner.machine], + |row| row.get(0), + ) + .unwrap(); + let mut record: Value = serde_json::from_slice(&data).unwrap(); + record["rosetta"] = Value::Bool(enabled); + connection + .execute( + "UPDATE vms SET data = ?1 WHERE name = ?2", + rusqlite::params![serde_json::to_vec(&record).unwrap(), pool.owner.machine], + ) + .unwrap(); +} + +#[test] +fn a_reserve_restart_follows_only_the_authorized_amd64_emulation_change() { + isolated("reserve_amd64_emulation_child"); +} + +#[test] +#[ignore = "isolated release-check fixture launched by a_reserve_restart_follows_only_the_authorized_amd64_emulation_change"] +fn reserve_amd64_emulation_child() { + let _serial = serial(); + let pool = stopped_pool(); + let lease = lease(&pool); + let original = Owner::load(&pool.candidate).unwrap(); + assert!(!original.amd64_emulation); + // Record the intent as `runtime up --amd64-emulation` does after the restart gate. + let record_intent = |provider: bool, change: &dyn Fn(&mut Owner)| { + let mut owner = original.clone(); + owner.amd64_emulation = true; + change(&mut owner); + owner.save(&pool.candidate).unwrap(); + provider_rosetta(&pool, provider); + }; + let reset = || { + original.save(&pool.candidate).unwrap(); + provider_rosetta(&pool, false); + }; + + // Free disk between the host reserve and the fresh budget: the start keeps its reserve + // selection under the lease. + let mut choice = select(&pool.candidate, Profile::Development).unwrap(); + let mut samples = window(admission::OwnedPool::Stopped, &[30 * GIB, 30 * GIB]); + let settled = settle(&pool.candidate, &mut choice, &lease, &mut samples).unwrap(); + assert!(settled.selected().is_some_and(Selection::is_stopped)); + let mut restart = restart_only(&pool.candidate, settled, &original, &lease).unwrap(); + record_intent(true, &|_| {}); + // The selection still names the receipt before the intent: unrequalified, the + // pre-boot admission refuses the authorized change as a changed owner. + assert_eq!( + admit_boot_with(&pool.candidate, restart.0, Some(7), |owned| { + Ok(sample(owned, true, Some(7))) + }) + .err() + .unwrap() + .code, + "admission_owner_changed" + ); + restart + .requalify_amd64_emulation(&pool.candidate, &lease) + .unwrap(); + // Still a restart of the retained disks: never a create. + assert_eq!( + fresh_create(&restart).err().unwrap().code, + "admission_owner_changed" + ); + let mut asked = None; + let gate = admit_boot_with(&pool.candidate, restart.0, Some(7), |owned| { + asked = Some(owned); + Ok(sample(owned, true, Some(7))) + }) + .unwrap(); + assert_eq!(asked, Some(admission::OwnedPool::Stopped)); + let BootGate::Admitted(boot) = gate else { + panic!("requalified reserve pre-boot sample admitted") + }; + boot.release(&pool.candidate, &lease).unwrap(); + reset(); + + // Each refusal starts from a reserve restart of the unchanged receipt. + let refused = |provider: bool, change: &dyn Fn(&mut Owner)| { + let mut choice = select(&pool.candidate, Profile::Development).unwrap(); + let mut restart = + restart_only(&pool.candidate, Settled(&mut choice), &original, &lease).unwrap(); + record_intent(provider, change); + let code = restart + .requalify_amd64_emulation(&pool.candidate, &lease) + .err() + .unwrap() + .code; + reset(); + code + }; + // Any other owner change beside the intent is still a changed owner. + assert_eq!( + refused(true, &|owner| owner.guest_boot_id = None), + "admission_owner_changed" + ); + assert_eq!( + refused(true, &|owner| owner.phase = "booting".into()), + "admission_owner_changed" + ); + // No intent change at all is not this transition. + assert_eq!( + refused(false, &|owner| owner.amd64_emulation = false), + "admission_owner_changed" + ); + // The retained-disk release checks still run on the rebound receipt: a provider whose + // stored configuration lacks translation is not this pool's audited configuration. + assert_eq!(refused(false, &|_| {}), "retained_reserve_foreign_owner"); + // An active provider still refuses. + let held = exclusive_lock(&pool.data().join("vm.lock")); + let mut choice = select(&pool.candidate, Profile::Development).unwrap(); + let mut restart = Restart(&mut choice); + record_intent(true, &|_| {}); + assert_eq!( + restart + .requalify_amd64_emulation(&pool.candidate, &lease) + .err() + .unwrap() + .code, + "retained_reserve_active_owner" + ); + drop(held); + reset(); + + // Without a selection, the fresh path needs no requalification. + let mut fresh = Choice { + selection: None, + refusal: None, + }; + Restart(&mut fresh) + .requalify_amd64_emulation(&pool.candidate, &lease) + .unwrap(); +} + /// Qualifying samples on `owned`'s basis, each observing `free` bytes of free disk. fn window(owned: admission::OwnedPool, free: &[u64]) -> Vec { free.iter() From 910441660393086b54a4e4da9a813dcf2d440d77 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:09:44 -0500 Subject: [PATCH 071/138] fix(native): resolve the published platform before reusing an amd64 cache entry Under prefer-native, a digest-pinned multi-platform image whose only cached archive was amd64 (left by an explicit amd64 acquisition) reused that archive, so the service ran under emulation because of cache history. A later preference's cache entry is now reused only after the pinned image's own selection under the policy is resolved from its manifest (no layers). When it publishes arm64, arm64 is acquired natively into its own entry instead. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/image_ensure.rs | 155 +++++++++++++++++- .../src/provider/image_platform.rs | 8 + .../src/provider/registry_image.rs | 25 +++ 3 files changed, 181 insertions(+), 7 deletions(-) diff --git a/packages/runtime-core/src/provider/image_ensure.rs b/packages/runtime-core/src/provider/image_ensure.rs index 566ee8a2d..98b80a650 100644 --- a/packages/runtime-core/src/provider/image_ensure.rs +++ b/packages/runtime-core/src/provider/image_ensure.rs @@ -234,16 +234,20 @@ pub fn ensure_with( }; (resolved.pinned_reference, exact) }; - ensure_pinned( + ensure_pinned_resolving( candidate, &pinned, policy, + registry_image::pinned_platform, registry_image::acquire_with, |candidate, path, sha, image, platform| { image_load::load_for(candidate, path, sha, image, platform).map(|_| ()) }, ) } +/// [`ensure_pinned_resolving`] for a cache that must never need to resolve the published +/// platform: every case it covers has its preferred entry cached, or none. +#[cfg(test)] fn ensure_pinned( candidate: &Candidate, reference: &str, @@ -254,20 +258,61 @@ fn ensure_pinned( PlatformPolicy, ) -> Result, load: impl FnOnce(&Candidate, &Path, &str, &str, ImagePlatform) -> Result<(), CandidateError>, +) -> Result { + ensure_pinned_resolving( + candidate, + reference, + policy, + |_, _| panic!("must not resolve the published platform"), + acquire, + load, + ) +} +/// Ensure one pinned reference under `policy`. `resolve` names the platform the pinned image +/// itself selects under a policy; it is consulted only when the cache holds a later +/// preference but not an earlier one. +fn ensure_pinned_resolving( + candidate: &Candidate, + reference: &str, + policy: PlatformPolicy, + mut resolve: impl FnMut(&str, PlatformPolicy) -> Result, + acquire: impl FnOnce( + &str, + &Path, + PlatformPolicy, + ) -> Result, + load: impl FnOnce(&Candidate, &Path, &str, &str, ImagePlatform) -> Result<(), CandidateError>, ) -> Result { let reference = canonical(reference)?; let root = candidate.state_root.join("artifacts/images"); let _lock = state::Lock::acquire(&root)?; let (count, bytes) = usage(candidate, &root)?; - // The first complete entry in policy order is reused: a published arm64 entry always - // wins over an emulated one under `PreferNative`. + // A cached entry is reused only for the platform the pinned image itself selects under + // the policy. The first preference is published whenever its entry exists. A later + // preference's entry (amd64 under `PreferNative`, perhaps left by an explicit `Amd64` + // acquisition) does not show the earlier one is absent, so the published platform is + // resolved first; a published arm64 image is then acquired natively instead. + let mut policy = policy; let mut existing = None; - for platform in policy.candidates() { + for (preference, platform) in policy.candidates().iter().enumerate() { let dir = root.join(entry_key(&reference, *platform)); - if dir.try_exists().map_err(state::io)? { - existing = Some((dir, *platform)); - break; + if !dir.try_exists().map_err(state::io)? { + continue; + } + if preference > 0 { + let published = resolve(&reference, policy)?; + if !policy.candidates().contains(&published) { + return Err(refused( + "Resolved image platform is outside the requested platform policy.", + )); + } + if published != *platform { + policy = PlatformPolicy::exact(published); + break; + } } + existing = Some((dir, *platform)); + break; } let reused = existing.is_some(); let (dir, receipt) = if let Some((dir, platform)) = existing { @@ -638,6 +683,102 @@ mod tests { ); } #[test] + fn prefer_native_resolves_the_published_platform_before_reusing_a_cached_amd64_entry() { + let f = Fixture::new(); + let root = f.1.state_root.join("artifacts/images"); + // An explicit amd64 acquisition of a multi-platform index caches only amd64. + let amd64 = ensure_pinned( + &f.1, + &pin(1), + PlatformPolicy::Amd64, + acquire, + |_, _, _, _, platform| { + assert_eq!(platform, ImagePlatform::LinuxAmd64); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(amd64.platform, ImagePlatform::LinuxAmd64); + assert!(!root.join(key(&pin(1))).exists()); + // PreferNative resolves the index first: it publishes arm64, which is acquired + // natively instead of reusing the emulated entry. + let resolved = Cell::new(0); + let asked = Cell::new(None); + let preferred = ensure_pinned_resolving( + &f.1, + &pin(1), + PlatformPolicy::PreferNative, + |reference, policy| { + resolved.set(resolved.get() + 1); + assert_eq!(reference, pin(1)); + assert_eq!(policy, PlatformPolicy::PreferNative); + Ok(ImagePlatform::LinuxArm64) + }, + |reference, path, policy| { + asked.set(Some(policy)); + acquire(reference, path, policy) + }, + |_, _, _, _, platform| { + assert_eq!(platform, ImagePlatform::LinuxArm64); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(resolved.get(), 1); + assert_eq!(asked.get(), Some(PlatformPolicy::Native)); + assert!(!preferred.archive_reused); + assert_eq!(preferred.platform, ImagePlatform::LinuxArm64); + assert_eq!(usage(&f.1, &root).unwrap().0, 2); + // With arm64 cached, nothing needs resolving. + let warm = ensure_pinned( + &f.1, + &pin(1), + PlatformPolicy::PreferNative, + |_, _, _| panic!("reuse complete archive"), + |_, _, _, _, _| Ok(()), + ) + .unwrap(); + assert!(warm.archive_reused); + assert_eq!(warm.platform, ImagePlatform::LinuxArm64); + + // An amd64-only image resolves to amd64, so its cached entry is reused. + ensure_pinned( + &f.1, + &pin(2), + PlatformPolicy::Amd64, + acquire, + |_, _, _, _, _| Ok(()), + ) + .unwrap(); + let reused = ensure_pinned_resolving( + &f.1, + &pin(2), + PlatformPolicy::PreferNative, + |_, _| Ok(ImagePlatform::LinuxAmd64), + |_, _, _| panic!("reuse complete archive"), + |_, _, _, _, platform| { + assert_eq!(platform, ImagePlatform::LinuxAmd64); + Ok(()) + }, + ) + .unwrap(); + assert!(reused.archive_reused); + assert_eq!(reused.platform, ImagePlatform::LinuxAmd64); + // A failed resolution refuses before any acquisition or load. + assert!( + ensure_pinned_resolving( + &f.1, + &pin(2), + PlatformPolicy::PreferNative, + |_, _| Err(refused("synthetic resolution failure")), + |_, _, _| panic!("no acquire"), + |_, _, _, _, _| panic!("no load"), + ) + .is_err() + ); + assert_eq!(usage(&f.1, &root).unwrap().0, 3); + } + #[test] fn invalid_input_or_absent_runtime_never_allocates_archive_cache() { let f = Fixture::new(); assert!(ensure(&f.1, "untrusted/thing:bad tag").is_err()); diff --git a/packages/runtime-core/src/provider/image_platform.rs b/packages/runtime-core/src/provider/image_platform.rs index 3335b3a4a..3cf460625 100644 --- a/packages/runtime-core/src/provider/image_platform.rs +++ b/packages/runtime-core/src/provider/image_platform.rs @@ -100,6 +100,14 @@ impl PlatformPolicy { pub fn may_emulate(self) -> bool { self != Self::Native } + + /// The policy that admits exactly `platform`. + pub fn exact(platform: ImagePlatform) -> Self { + match platform { + ImagePlatform::LinuxArm64 => Self::Native, + ImagePlatform::LinuxAmd64 => Self::Amd64, + } + } } #[cfg(test)] diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 5261ea124..6172fcaec 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -428,6 +428,31 @@ fn config_platform( }) } +/// The platform `policy` selects for one pinned reference: the index entry it prefers, or a +/// single-image manifest's configured platform. Only metadata is fetched; no layer or +/// archive is read, and acquisition still confirms the selection against the configuration. +pub(super) fn pinned_platform( + reference_text: &str, + policy: PlatformPolicy, +) -> Result { + let (repo, pin) = reference(reference_text)?; + let (repo, pin) = (repo.as_str(), pin.as_str()); + let fetch = authenticated_fetch(repo)?; + let url = Url::parse(&format!( + "https://registry-1.docker.io/v2/{repo}/manifests/{pin}" + )) + .map_err(|_| error("Invalid manifest address."))?; + let manifest = parse_manifest(&fetch.bytes(url, MAX_METADATA, true)?, pin)?; + if !manifest.manifests.is_empty() { + return select(&manifest, policy).map(|(_, platform)| platform); + } + let config = manifest + .config + .ok_or_else(|| error("Image config descriptor is missing."))?; + let config_bytes = fetch.object(repo, "blobs", &config, MAX_METADATA)?; + config_platform(&config_bytes, policy, None) +} + /// Acquire one public pinned Linux ARM64 image into a new explicit archive. The output /// must not exist. A failure never imports an image or changes candidate runtime state. /// HTTPS requests have a 60s bound and the complete acquisition a 300s deadline. From 78439d238e1b671ffcac3dd586aa44e97175ee9a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:09:45 -0500 Subject: [PATCH 072/138] fix(native): bind amd64 emulation policy to reviewed plans and retained images Both emulation modes wrote the same reviewed boolean, and restart and restored up reused a retained image keyed only on the original Compose hash. Changing a service from prefer-native to amd64 kept its old (possibly arm64) content ID. The reviewed x-hack-amd64-emulation field now carries the policy (prefer-native or amd64), so the modes are distinct plans and graph resources record it. Retained and active image reuse is bound to the recorded policy: a service whose current policy differs is selected again under the new policy. A receipt value that is not a reviewed policy refuses. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 14 ++- .../src/project/compose/service.rs | 16 ++- packages/runtime-core/src/project/mod.rs | 23 +++- .../src/provider/graph/cache_provenance.rs | 4 +- .../runtime-core/src/provider/graph/config.rs | 4 +- .../src/provider/graph/environment/tests.rs | 2 +- .../src/provider/graph/host_relay.rs | 2 +- .../src/provider/graph/hostname_change.rs | 2 +- .../runtime-core/src/provider/graph/mod.rs | 11 +- .../src/provider/graph/native/mod.rs | 4 +- .../provider/graph/restore_history/tests.rs | 2 +- .../src/provider/graph/shutdown.rs | 2 +- .../runtime-core/src/provider/graph/source.rs | 2 +- .../src/provider/graph/storage_inventory.rs | 2 +- .../runtime-core/src/provider/graph/tests.rs | 13 +- .../runtime-core/tests/project_contract.rs | 67 +++++++---- src/backends/native-project-platform.ts | 30 ++++- .../native-project-restart-preflight.ts | 8 +- .../native-project-retained-images.ts | 29 ++++- src/backends/native-project-start.ts | 7 +- tests/native-project-platform.test.ts | 9 +- tests/native-project-restart.test.ts | 111 ++++++++++++++++++ tests/native-project-retained-images.test.ts | 66 +++++++++++ tests/native-project-start.test.ts | 91 +++++++++++++- 24 files changed, 446 insertions(+), 75 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 84e7bb7ed..e12d7b533 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1399,10 +1399,16 @@ Every boot audits translation against the pool's recorded intent: the provider's stored machine setting, its running configuration, and the guest's `binfmt_misc` `rosetta` registration (the x86_64 ELF match with the fixed `rosetta-wrapper` interpreter). A pool without translation must have no such registration. The image -cache keeps amd64 archives separate from arm64 ones, and the runtime admits an amd64 -image only for an opted-in service (`x-hack-amd64-emulation: true` in the reviewed -Compose service, which the adaptation writes) in a pool booted with translation. A -service that is not opted in still refuses a declared `linux/amd64` in native review. +cache keeps amd64 archives separate from arm64 ones. Under `prefer-native`, a cached +amd64 archive is reused only after the pinned image is confirmed not to publish arm64. +The runtime admits an amd64 image only for an opted-in service +(`x-hack-amd64-emulation: prefer-native` or `amd64` in the reviewed Compose service, +which the adaptation writes) in a pool booted with translation. A service that is not +opted in still refuses a declared `linux/amd64` in native review. + +The mode is part of the reviewed plan. Restart and restored `up` reuse a service's +retained image only when the retained graph recorded the same mode for it; after a mode +change the service's image is selected again under the new mode. Emulated services are slower than native ones and can behave differently from arm64 builds. `hack doctor` on the native backend reports an `amd64 emulation` warning with diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index d9f829188..d440e56b2 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -862,8 +862,20 @@ pub(super) fn compile( "Only Linux arm64/amd64 image platform declarations are modeled.", )); } - let amd64_emulation = boolean(m.get("x-hack-amd64-emulation"), false)?; - if amd64_emulation + let amd64_emulation = match m.get("x-hack-amd64-emulation") { + None => None, + Some(value) => Some(match value.as_str() { + Some("prefer-native") => Amd64Emulation::PreferNative, + Some("amd64") => Amd64Emulation::Amd64, + _ => { + return Err(problem( + "invalid_compose_field", + "x-hack-amd64-emulation must be prefer-native or amd64.", + )); + } + }), + }; + if amd64_emulation.is_some() && platform .as_deref() .is_some_and(|v| ["linux/arm64", "linux/arm64/v8"].contains(&v)) diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index e9b4324e9..d85ea2bbe 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -236,6 +236,18 @@ pub struct RoutingPlan { pub port: u16, } +/// A service's reviewed `x-hack-amd64-emulation` policy. The value is part of the plan, so +/// the two modes are distinct reviewed plans and retained images can be bound to one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Amd64Emulation { + /// Linux arm64 when the image publishes it, otherwise `linux/amd64` under translation. + #[serde(rename = "prefer-native")] + PreferNative, + /// `linux/amd64` under translation, even when the image also publishes arm64. + #[serde(rename = "amd64")] + Amd64, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(deny_unknown_fields)] pub struct ServicePlan { @@ -264,11 +276,12 @@ pub struct ServicePlan { pub working_dir: Option, pub user: Option, pub platform: Option, - /// Explicit `x-hack-amd64-emulation: true` opt-in: this service may run a `linux/amd64` - /// image under the pool's Rosetta 2 translation, and may declare `platform: linux/amd64`. - /// Without it only Linux arm64 is admitted. It never applies to a declared arm64 platform. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub amd64_emulation: bool, + /// Explicit `x-hack-amd64-emulation` opt-in and its reviewed platform policy: this + /// service may run a `linux/amd64` image under the pool's Rosetta 2 translation, and may + /// declare `platform: linux/amd64`. Without it only Linux arm64 is admitted. It never + /// applies to a declared arm64 platform. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amd64_emulation: Option, pub labels: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub routing: Option, diff --git a/packages/runtime-core/src/provider/graph/cache_provenance.rs b/packages/runtime-core/src/provider/graph/cache_provenance.rs index 7fa31602e..d3d616bc3 100644 --- a/packages/runtime-core/src/provider/graph/cache_provenance.rs +++ b/packages/runtime-core/src/provider/graph/cache_provenance.rs @@ -288,7 +288,7 @@ mod tests { id: Some("a".repeat(64)), image: Some(format!("sha256:{}", "b".repeat(64))), phase: "started".into(), - amd64_emulation: false, + amd64_emulation: None, } } fn terminal() -> Value { @@ -381,7 +381,7 @@ mod tests { id: None, image: None, phase: "created".into(), - amd64_emulation: false, + amd64_emulation: None, }; let mut receipt = Receipt { normalized_input: None, diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 9305f206f..9868bdd52 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -201,7 +201,7 @@ pub(super) fn prepare_delivery( || service.image.as_deref().is_none_or(|v| !image_id(v)) || service.platform.as_deref().is_some_and(|p| { !(["linux/arm64", "linux/arm64/v8"].contains(&p) - || (p == "linux/amd64" && service.amd64_emulation)) + || (p == "linux/amd64" && service.amd64_emulation.is_some())) }) { return Err(unsupported()); @@ -319,7 +319,7 @@ pub(super) fn prepare_delivery( id: None, image: None, phase: "reserved".into(), - amd64_emulation: false, + amd64_emulation: None, }; resources.insert(format!("{}:{key}", kind.word()), resource); } diff --git a/packages/runtime-core/src/provider/graph/environment/tests.rs b/packages/runtime-core/src/provider/graph/environment/tests.rs index 4808d6ec0..59a79142e 100644 --- a/packages/runtime-core/src/provider/graph/environment/tests.rs +++ b/packages/runtime-core/src/provider/graph/environment/tests.rs @@ -151,7 +151,7 @@ fn container_attachment_records_ownership_before_create_and_omits_values_from_me id: None, image: Some(image.clone()), phase: "reserved".into(), - amd64_emulation: false, + amd64_emulation: None, }; let mut receipt = Receipt { normalized_input: None, diff --git a/packages/runtime-core/src/provider/graph/host_relay.rs b/packages/runtime-core/src/provider/graph/host_relay.rs index 04ce27e4a..63faad46d 100644 --- a/packages/runtime-core/src/provider/graph/host_relay.rs +++ b/packages/runtime-core/src/provider/graph/host_relay.rs @@ -1017,7 +1017,7 @@ mod tests { id: Some("e".repeat(64)), image: Some(format!("sha256:{}", "f".repeat(64))), phase: "started".into(), - amd64_emulation: false, + amd64_emulation: None, }; let value = json!({"Id":resource.id,"Image":resource.image,"State":{"Running":true,"StartedAt":"2026-09-17T00:00:00Z"}}); (receipt, resource, value) diff --git a/packages/runtime-core/src/provider/graph/hostname_change.rs b/packages/runtime-core/src/provider/graph/hostname_change.rs index 378d74b0d..530c145db 100644 --- a/packages/runtime-core/src/provider/graph/hostname_change.rs +++ b/packages/runtime-core/src/provider/graph/hostname_change.rs @@ -235,7 +235,7 @@ pub(super) mod tests { id: None, image: None, phase: "absent".into(), - amd64_emulation: false, + amd64_emulation: None, }; let mut volume = resource("data", Kind::Volume, None); volume.id = Some("retained-volume-id".into()); diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index caa894c81..1bf6185de 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -235,10 +235,11 @@ pub struct Resource { pub id: Option, pub image: Option, pub phase: String, - /// The service opted in to running a `linux/amd64` image under Rosetta translation. - /// Admission still requires an amd64 image to come from a translation-enabled pool. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub amd64_emulation: bool, + /// The service's reviewed opt-in policy for running a `linux/amd64` image under Rosetta + /// translation. Admission still requires an amd64 image to come from a + /// translation-enabled pool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amd64_emulation: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -2160,7 +2161,7 @@ pub(super) fn admitted_architecture( image["Os"] == "linux" && (image["Architecture"] == "arm64" || (image["Architecture"] == "amd64" - && resource.amd64_emulation + && resource.amd64_emulation.is_some() && engine.guest().amd64_emulation())) } diff --git a/packages/runtime-core/src/provider/graph/native/mod.rs b/packages/runtime-core/src/provider/graph/native/mod.rs index 405f9c152..ea0947305 100644 --- a/packages/runtime-core/src/provider/graph/native/mod.rs +++ b/packages/runtime-core/src/provider/graph/native/mod.rs @@ -162,7 +162,7 @@ fn configuration_with_source( id: None, image: None, phase: "reserved".into(), - amd64_emulation: false, + amd64_emulation: None, }; network_names.insert(key.clone(), network.name.clone()); resources.insert(format!("network:{key}"), network); @@ -191,7 +191,7 @@ fn configuration_with_source( id: None, image: Some(workload.image.clone()), phase: "reserved".into(), - amd64_emulation: false, + amd64_emulation: None, }; let mut config = config::container_base(&workload.image, labels(owner, review, &resource)); if workload.source_mount.is_some() { diff --git a/packages/runtime-core/src/provider/graph/restore_history/tests.rs b/packages/runtime-core/src/provider/graph/restore_history/tests.rs index bcbc826a1..a48365106 100644 --- a/packages/runtime-core/src/provider/graph/restore_history/tests.rs +++ b/packages/runtime-core/src/provider/graph/restore_history/tests.rs @@ -32,7 +32,7 @@ fn receipt(index: usize) -> Receipt { id: Some(format!("{index:064x}")), image: None, phase: "absent".into(), - amd64_emulation: false, + amd64_emulation: None, }, )]), } diff --git a/packages/runtime-core/src/provider/graph/shutdown.rs b/packages/runtime-core/src/provider/graph/shutdown.rs index 34216d420..3fbe01dd2 100644 --- a/packages/runtime-core/src/provider/graph/shutdown.rs +++ b/packages/runtime-core/src/provider/graph/shutdown.rs @@ -416,7 +416,7 @@ mod tests { id: Some("a".repeat(64)), image: Some(format!("sha256:{}", "b".repeat(64))), phase: "started".into(), - amd64_emulation: false, + amd64_emulation: None, }; let value = json!({"Id":resource.id,"Config":{"StopTimeout":10,"StopSignal":"SIGTERM"}, "State":{"Running":true,"Status":"running","Pid":42,"Paused":false, diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 49c7c6e41..aa05fedac 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -891,7 +891,7 @@ mod tests { cache: None, cache_provenance: None, disposable_caches: Vec::new(), - amd64_emulation: false, + amd64_emulation: None, }, )]), }; diff --git a/packages/runtime-core/src/provider/graph/storage_inventory.rs b/packages/runtime-core/src/provider/graph/storage_inventory.rs index 667f6b774..f9aaa33ee 100644 --- a/packages/runtime-core/src/provider/graph/storage_inventory.rs +++ b/packages/runtime-core/src/provider/graph/storage_inventory.rs @@ -214,7 +214,7 @@ mod tests { id: None, image: None, phase: "created".into(), - amd64_emulation: false, + amd64_emulation: None, }; let volume = json!({"name":resource.name,"resource":"deps","observed_state":"present","classification":"referenced_by_graph"}); let mut references = BTreeMap::new(); diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index 908b6df23..da2c5a1a8 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -173,7 +173,7 @@ fn declared_amd64_requires_the_explicit_emulation_opt_in() { document["services"]["web"]["platform"] = json!(platform); } if opted { - document["services"]["web"]["x-hack-amd64-emulation"] = json!(true); + document["services"]["web"]["x-hack-amd64-emulation"] = json!("prefer-native"); } state::write(&fixture.0.join("compose.yaml"), &document).unwrap(); let review = project::plan(&candidate, fixture.options()).unwrap(); @@ -194,13 +194,16 @@ fn declared_amd64_requires_the_explicit_emulation_opt_in() { .unwrap(); // Only the opted-in service may later admit an amd64 image; the others stay // arm64-only regardless of what the engine holds. - assert_eq!(web.amd64_emulation, opted); + assert_eq!( + web.amd64_emulation, + opted.then_some(crate::project::Amd64Emulation::PreferNative) + ); assert!( prepared .resources .values() .filter(|resource| resource.key != "web") - .all(|resource| !resource.amd64_emulation) + .all(|resource| resource.amd64_emulation.is_none()) ); } Err(error) => { @@ -1944,7 +1947,7 @@ fn outbound_network_is_pinned_and_requires_explicit_pool_capability() { id: None, image: None, phase: "reserved".into(), - amd64_emulation: false, + amd64_emulation: None, }; let resources = BTreeMap::from([("network:default".into(), resource)]); assert!(check_network_request(&super::super::NetworkIntent::Isolated, &resources).is_err()); @@ -2087,7 +2090,7 @@ fn endpoint_selects_service_primary_not_unrelated_first_graph_network() { id: Some(id.clone()), image: None, phase: "created".into(), - amd64_emulation: false, + amd64_emulation: None, }, ); networks.insert(format!("network:{key}"), Some(json!({"Id":id,"Name":name,"Containers":{"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee":{"EndpointID":"f".repeat(64),"IPv4Address":"172.20.0.2/16"}}}))); diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index f0a434c13..e05691af6 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -523,35 +523,49 @@ fn unsupported_privileged_external_and_unknown_fields_block_enrollment() { assert!(fixture.codes().contains(&"external_network".into())); } -/// `x-hack-amd64-emulation` is a reviewed opt-in, not unexecuted extension metadata. It is -/// recorded in the plan only when true, and it can never override a declared arm64 platform. +/// `x-hack-amd64-emulation` is a reviewed opt-in, not unexecuted extension metadata. Its +/// policy (`prefer-native` or `amd64`) is recorded in the plan, so the two modes are distinct +/// plans; it is absent otherwise, and it can never override a declared arm64 platform. #[test] fn amd64_emulation_opt_in_is_reviewed_and_never_overrides_declared_arm64() { let plain = Fixture::new(BASIC).plan(); - assert!(!plain.plan.services["web"].amd64_emulation); - let opted = Fixture::new(&format!( - "{BASIC} platform: linux/amd64\n x-hack-amd64-emulation: true\n" - )); - let report = opted.plan(); - let service = &report.plan.services["web"]; - assert!(service.amd64_emulation); - assert_eq!(service.platform.as_deref(), Some("linux/amd64")); - assert!( - !report - .plan - .diagnostics - .iter() - .any(|d| d.code == "compose_extension_metadata") - ); - // Absent and false serialize identically, so existing plan identities are unchanged. - let off = Fixture::new(&format!("{BASIC} x-hack-amd64-emulation: false\n")).plan(); - for report in [&plain, &off] { - let service = serde_json::to_value(&report.plan.services["web"]).unwrap(); - assert!(service.get("amd64_emulation").is_none()); + assert_eq!(plain.plan.services["web"].amd64_emulation, None); + let opted = Fixture::new(BASIC); + let mut plans = Vec::new(); + for (mode, policy) in [ + ("prefer-native", project::Amd64Emulation::PreferNative), + ("amd64", project::Amd64Emulation::Amd64), + ] { + fs::write( + opted.project.join("compose.yaml"), + format!("{BASIC} platform: linux/amd64\n x-hack-amd64-emulation: {mode}\n"), + ) + .unwrap(); + let report = opted.plan(); + let service = &report.plan.services["web"]; + assert_eq!(service.amd64_emulation, Some(policy)); + assert_eq!(service.platform.as_deref(), Some("linux/amd64")); + assert_eq!( + serde_json::to_value(service).unwrap()["amd64_emulation"], + mode + ); + assert!( + !report + .plan + .diagnostics + .iter() + .any(|d| d.code == "compose_extension_metadata") + ); + plans.push(report.plan_id); } + // A changed policy is a changed reviewed plan of the same project. + assert_ne!(plans[0], plans[1]); + // Absent serializes nothing, so existing plan identities are unchanged. + let service = serde_json::to_value(&plain.plan.services["web"]).unwrap(); + assert!(service.get("amd64_emulation").is_none()); for platform in ["linux/arm64", "linux/arm64/v8"] { let conflicting = Fixture::new(&format!( - "{BASIC} platform: {platform}\n x-hack-amd64-emulation: true\n" + "{BASIC} platform: {platform}\n x-hack-amd64-emulation: amd64\n" )); assert_eq!( project::plan(&conflicting.candidate, conflicting.options()) @@ -560,8 +574,11 @@ fn amd64_emulation_opt_in_is_reviewed_and_never_overrides_declared_arm64() { "unsupported_platform" ); } - let invalid = Fixture::new(&format!("{BASIC} x-hack-amd64-emulation: yes\n")); - assert!(project::plan(&invalid.candidate, invalid.options()).is_err()); + // Only the two policies are reviewed values. + for value in ["yes", "true", "false", "native"] { + let invalid = Fixture::new(&format!("{BASIC} x-hack-amd64-emulation: {value}\n")); + assert!(project::plan(&invalid.candidate, invalid.options()).is_err()); + } } /// The candidate's own label review rejects Hack's ownership namespaces (the diff --git a/src/backends/native-project-platform.ts b/src/backends/native-project-platform.ts index cfb2b3a4b..2798746ae 100644 --- a/src/backends/native-project-platform.ts +++ b/src/backends/native-project-platform.ts @@ -21,7 +21,9 @@ import { isRecord } from "../lib/guards.ts"; * images stay native; an amd64-only image runs under emulation. * - `amd64`: the amd64 variant under emulation, even when arm64 is published. * - * An opted-in service gets the reviewed `x-hack-amd64-emulation: true` Compose field. + * An opted-in service gets the reviewed `x-hack-amd64-emulation` Compose field, whose + * value is its policy (`prefer-native` or `amd64`), so the two modes are distinct + * reviewed plans and retained images are bound to the mode they were selected under. * The runtime admits an amd64 image only for such a service and only in a pool booted * with x86_64 translation, so a missed selection fails closed instead of substituting. */ @@ -122,12 +124,36 @@ export function applyNativeAmd64Emulation(opts: { if (typeof spec.platform === "string" && ARM64.has(spec.platform)) { throw refused(); } - spec[AMD64_EMULATION_FIELD] = true; + spec[AMD64_EMULATION_FIELD] = selected; policies[name] = selected; } return policies; } +/** One service's policy from the adaptation's opted-in policies; `undefined` is native. */ +export function nativeEmulationPolicy( + policies: Readonly> | undefined, + service: string +): NativeEmulationPolicy | undefined { + return policies && Object.hasOwn(policies, service) + ? policies[service] + : undefined; +} + +/** + * The reviewed policy a graph receipt recorded for one container resource + * (`amd64_emulation`, absent for a native service), or `null` when the value is not a + * reviewed policy. + */ +export function receiptEmulationPolicy( + value: unknown +): NativeEmulationPolicy | undefined | null { + if (value === undefined) { + return; + } + return value === "prefer-native" || value === "amd64" ? value : null; +} + /** `runtime ensure-image` arguments for one service's policy. */ export function nativeEnsureImageArgs( image: string, diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 6fcb64352..969435a93 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -19,6 +19,7 @@ import { validateNativeAllowedHosts } from "./native-project-network.ts"; import { ensuredPlatformAccepted, type NativeEmulationPolicy, + nativeEmulationPolicy, nativeEnsureImageArgs, } from "./native-project-platform.ts"; import { @@ -299,10 +300,7 @@ async function pinRestartImages(opts: { continue; } // Owned cache acquisition grants no authority to stop the current graph. - const policy = - opts.amd64Emulation && Object.hasOwn(opts.amd64Emulation, name) - ? opts.amd64Emulation[name] - : undefined; + const policy = nativeEmulationPolicy(opts.amd64Emulation, name); const pinned = await opts.invoke({ runtime: opts.runtime, cwd: opts.projectRoot, @@ -429,6 +427,7 @@ export async function preflightNativeRestart(opts: { runtime: opts.runtime, projectRoot: opts.scope.projectRoot, originalSha256: input.originalSha256, + amd64Emulation: input.amd64Emulation, restore: opts.run, invoke: deps.invoke, }); @@ -437,6 +436,7 @@ export async function preflightNativeRestart(opts: { runtime: opts.runtime, projectRoot: opts.scope.projectRoot, originalSha256: input.originalSha256, + amd64Emulation: input.amd64Emulation, restore: opts.run, invoke: deps.invoke, }) diff --git a/src/backends/native-project-retained-images.ts b/src/backends/native-project-retained-images.ts index 2469043d6..b61700555 100644 --- a/src/backends/native-project-retained-images.ts +++ b/src/backends/native-project-retained-images.ts @@ -1,6 +1,11 @@ import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; +import { + type NativeEmulationPolicy, + nativeEmulationPolicy, + receiptEmulationPolicy, +} from "./native-project-platform.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; import { type NativeActiveReviewProof, @@ -16,16 +21,27 @@ const SHA = /^[a-f0-9]{64}$/; const IMAGE = /^sha256:[a-f0-9]{64}$/; const SERVICE = /^[a-zA-Z0-9_.-]{1,128}$/; +/** Opted-in services' current platform policies; every other service is native. */ +type CurrentEmulation = + | Readonly> + | undefined; + /** * Restore unchanged declarations with their authenticated content IDs. Resolving * a mutable tag again could change compute around retained data. This observation * grants no restore authority: generation, input, source and resource checks still * run in native code at admission. Changed original input keeps normal resolution. + * + * A retained image is also bound to the platform policy it was selected under (the + * receipt's reviewed `amd64_emulation`): a service whose current policy differs is + * omitted, so it is selected again under the new policy instead of keeping, for + * example, an arm64 image after its policy changed to `amd64`. */ export async function selectNativeRetainedImages(opts: { readonly runtime: NativeRuntimeSelection; readonly projectRoot: string; readonly originalSha256: string; + readonly amd64Emulation?: CurrentEmulation; readonly restore?: NativeProjectRun; readonly invoke?: typeof invokeNativeRuntime; }): Promise> { @@ -56,6 +72,7 @@ export async function selectNativeRetainedImages(opts: { function imagesFromReceipt(opts: { readonly originalSha256: string; + readonly amd64Emulation?: CurrentEmulation; readonly restore: NativeProjectRun; readonly receipt: Readonly>; }): ReadonlyMap { @@ -86,16 +103,24 @@ function imagesFromReceipt(opts: { if (!isRecord(resource) || resource.kind !== "container") { continue; } + const retainedPolicy = receiptEmulationPolicy(resource.amd64_emulation); if ( typeof resource.key !== "string" || key !== `container:${resource.key}` || typeof resource.image !== "string" || - !IMAGE.test(resource.image) + !IMAGE.test(resource.image) || + retainedPolicy === null ) { throw new ClosedCliError( "Native retained image identity is invalid; no images were resolved." ); } + if ( + retainedPolicy !== + nativeEmulationPolicy(opts.amd64Emulation, resource.key) + ) { + continue; + } images.set(resource.key, resource.image); } return images; @@ -129,6 +154,8 @@ export async function selectNativeActiveImages(opts: { readonly runtime: NativeRuntimeSelection; readonly projectRoot: string; readonly originalSha256: string; + /** Binds reuse to each service's current policy, as for retained images. */ + readonly amd64Emulation?: CurrentEmulation; readonly restore: NativeProjectRun; readonly invoke?: typeof invokeNativeRuntime; }): Promise<{ diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index f9f262cbd..427121414 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -50,6 +50,7 @@ import { ensuredPlatformAccepted, type NativeEmulationPolicy, nativeAmd64EmulationUpArgs, + nativeEmulationPolicy, nativeEnsureImageArgs, } from "./native-project-platform.ts"; import { serveNativeProjectGraph } from "./native-project-process.ts"; @@ -387,10 +388,7 @@ async function pinNativeProjectImages(opts: { if (IMAGE.test(image)) { continue; } - const policy = - opts.amd64Emulation && Object.hasOwn(opts.amd64Emulation, name) - ? opts.amd64Emulation[name] - : undefined; + const policy = nativeEmulationPolicy(opts.amd64Emulation, name); const ensured = await opts.invoke({ runtime: opts.runtime, cwd: opts.projectRoot, @@ -1183,6 +1181,7 @@ export async function startNativeProject(opts: { runtime: opts.runtime, projectRoot: opts.scope.projectRoot, originalSha256: input.originalSha256, + amd64Emulation: input.amd64Emulation, restore, invoke: deps.invoke, }); diff --git a/tests/native-project-platform.test.ts b/tests/native-project-platform.test.ts index 60a73a2e0..aa7677f31 100644 --- a/tests/native-project-platform.test.ts +++ b/tests/native-project-platform.test.ts @@ -126,11 +126,14 @@ test("only opted-in services get the reviewed runtime field, and declared arm64 }, }); expect(policies).toEqual({ legacy: "amd64", tool: "prefer-native" }); + // The reviewed field carries the policy, so the two modes are distinct plans. expect(services.legacy).toMatchObject({ platform: "linux/amd64", - [AMD64_EMULATION_FIELD]: true, + [AMD64_EMULATION_FIELD]: "amd64", + }); + expect(services.tool).toMatchObject({ + [AMD64_EMULATION_FIELD]: "prefer-native", }); - expect(services.tool).toMatchObject({ [AMD64_EMULATION_FIELD]: true }); expect(services.native).toEqual({ image: "example/native:1" }); for (const platform of ["linux/arm64", "linux/arm64/v8"]) { expectInvalid(() => @@ -171,7 +174,7 @@ test("native adaptation carries emulation policies only when a service opts in", }); expect(adapted.amd64Emulation).toEqual({ legacy: "prefer-native" }); const compose = JSON.parse(adapted.normalizedComposeJson); - expect(compose.services.legacy[AMD64_EMULATION_FIELD]).toBe(true); + expect(compose.services.legacy[AMD64_EMULATION_FIELD]).toBe("prefer-native"); expect(compose.services.web[AMD64_EMULATION_FIELD]).toBeUndefined(); // The original input is not modified. expect(JSON.parse(original.normalizedComposeJson).services.legacy).toEqual({ diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index c6450adbe..388f00ff5 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -1414,6 +1414,117 @@ test("active legacy restart preserves adapted labels and rechecks authenticated } }); +test("active restart selects an image again when the service's emulation policy changed", async () => { + const oldImage = `sha256:${"7".repeat(64)}`; + const newImage = `sha256:${"8".repeat(64)}`; + for (const retainedPolicy of ["prefer-native", "amd64"] as const) { + const f = fixture(); + const ensured: string[][] = []; + let reviewed: Record> = {}; + const result = restartNativeProject({ + ...f.options, + preflight: async () => + await preflightNativeRestart({ + runtime: { binary: "/unused", home: "/candidate" }, + scope, + composeFile: "/fixture/.hack/docker-compose.yml", + run, + dependencies: { + // The adaptation now selects `amd64` for app; redis stays native. + prepare: async () => ({ + originalSha256: "1".repeat(64), + environmentFiles: [], + serviceNames: ["app", "redis"], + normalizedComposeJson: JSON.stringify({ + services: { + app: { + image: "app:latest", + "x-hack-amd64-emulation": "amd64", + }, + redis: { image: "redis:latest" }, + }, + }), + amd64Emulation: { app: "amd64" }, + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: "qa", + }), + adapt: async ({ input }) => input, + dependencies: async () => [], + invoke: async ({ args }) => { + switch (args[1]) { + case "status": + return { network: "internet" }; + case "probe": + return { admitted: true }; + case "inspect": { + const observed = activeImageObservation(oldImage); + return { + ...observed, + receipt: { + ...observed.receipt, + resources: { + "container:app": { + ...observed.receipt.resources["container:app"], + amd64_emulation: retainedPolicy, + }, + "container:redis": { + kind: "container", + key: "redis", + id: "4".repeat(64), + image: oldImage, + }, + }, + }, + }; + } + case "run-selection": + return { ok: true, ...activeImageFixture().proof }; + case "ensure-image": + ensured.push([...args]); + return { image_id: newImage, platform: "linux/amd64" }; + default: + throw new Error("Unexpected effect"); + } + }, + review: async (options) => { + reviewed = JSON.parse( + options.input.normalizedComposeJson + ).services; + return await options.run({ + planId: run.planId, + namespace: run.namespace, + report: {}, + projectArgs: [], + }); + }, + }, + }), + }); + expect(await result).toBe(0); + // The image retained under `prefer-native` (possibly arm64) is not reused for the + // explicit amd64 request; the unchanged policy and the native service keep theirs. + const changed = retainedPolicy !== "amd64"; + expect(reviewed.app?.image).toBe(changed ? newImage : oldImage); + expect(reviewed.redis?.image).toBe(oldImage); + expect(ensured).toEqual( + changed + ? [ + [ + "runtime", + "ensure-image", + "--reference", + "app:latest", + "--emulation", + "amd64", + "--json", + ], + ] + : [] + ); + } +}); + test("active restart preserves admitted images despite moved tags and refuses changed input or generation before cleanup", async () => { const oldImage = `sha256:${"7".repeat(64)}`; const newImage = `sha256:${"8".repeat(64)}`; diff --git a/tests/native-project-retained-images.test.ts b/tests/native-project-retained-images.test.ts index 39129db46..963fff503 100644 --- a/tests/native-project-retained-images.test.ts +++ b/tests/native-project-retained-images.test.ts @@ -60,6 +60,54 @@ test("unchanged retained input selects verified content IDs without resolving ta ]); }); +function withRetainedPolicy>( + observed: T, + policy: unknown +): T { + return { + ...observed, + receipt: { + ...observed.receipt, + resources: { + ...observed.receipt.resources, + "container:web": { + ...observed.receipt.resources["container:web"], + amd64_emulation: policy, + }, + }, + }, + }; +} +test("retained images are reused only under the platform policy they were selected with", async () => { + const cases = [ + // [retained receipt policy, current policy, reused] + [undefined, undefined, true], + ["prefer-native", "prefer-native", true], + ["amd64", "amd64", true], + ["prefer-native", "amd64", false], + ["amd64", "prefer-native", false], + [undefined, "amd64", false], + ["prefer-native", undefined, false], + ] as const; + for (const [retained, current, reused] of cases) { + const selected = await selectNativeRetainedImages({ + ...options, + amd64Emulation: current === undefined ? undefined : { web: current }, + invoke: async () => withRetainedPolicy(observation(), retained), + }); + expect([...selected]).toEqual(reused ? [["web", image]] : []); + } + // A receipt value that is not a reviewed policy refuses. + for (const invalid of [true, false, "native", null]) { + await expectClosedRefusal( + selectNativeRetainedImages({ + ...options, + invoke: async () => withRetainedPolicy(observation(), invalid), + }), + "Native retained image identity is invalid; no images were resolved." + ); + } +}); function activeObservation() { const baseline = observation(); return { @@ -118,6 +166,24 @@ test("active images use fresh native authority, including completed initializers "run-selection", ]); }); +test("active images are not reused after a service's platform policy changed", async () => { + for (const [retained, current] of [ + ["prefer-native", "amd64"], + ["amd64", "amd64"], + ] as const) { + const selected = await selectNativeActiveImages({ + ...options, + amd64Emulation: { web: current }, + invoke: async ({ args }) => + args[1] === "inspect" + ? withRetainedPolicy(activeObservation(), retained) + : activeSelection(), + }); + expect([...selected.images]).toEqual( + retained === current ? [["web", image]] : [] + ); + } +}); test("active edited or legacy input receives no old image pins", async () => { const legacy = activeObservation(); const { normalized_input: _normalized, ...receipt } = legacy.receipt; diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 0cf2b5d1a..8468367b5 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -3074,8 +3074,8 @@ test("opted-in amd64 services boot a translation pool and pin with their own pla ["runtime", "ensure-image", "--reference", "redis", "--json"], ]); // Only opted-in services carry the reviewed runtime opt-in. - expect(reviewed.legacy?.["x-hack-amd64-emulation"]).toBe(true); - expect(reviewed.tool?.["x-hack-amd64-emulation"]).toBe(true); + expect(reviewed.legacy?.["x-hack-amd64-emulation"]).toBe("amd64"); + expect(reviewed.tool?.["x-hack-amd64-emulation"]).toBe("prefer-native"); expect(reviewed.cache?.["x-hack-amd64-emulation"]).toBeUndefined(); }); @@ -3162,6 +3162,93 @@ test("retained startup reviews the saved content ID instead of a newly resolved expect(await startNativeProject({ ...opts, restore: saved })).toBe(0); }); +test("retained startup selects an image again when the service's emulation policy changed", async () => { + const saved = { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + }; + const retained = `sha256:${"e".repeat(64)}`; + const selected = `sha256:${"9".repeat(64)}`; + for (const retainedPolicy of ["prefer-native", "amd64"] as const) { + const { opts } = await fixture(false); + const stopped = stoppedGraph(saved); + const observed = { + ...stopped, + receipt: { + ...stopped.receipt, + normalized_input: { + namespace: saved.namespace, + original_compose_sha256: "d".repeat(64), + normalized_compose_sha256: "f".repeat(64), + }, + resources: { + "container:web": { + ...stopped.receipt.resources["container:web"], + image: retained, + amd64_emulation: retainedPolicy, + }, + }, + }, + }; + opts.dependencies.retainedImages = selectNativeRetainedImages; + const prepare = opts.dependencies.prepare!; + // The adaptation now selects `amd64` for web. + opts.dependencies.prepare = async (request) => ({ + ...(await prepare(request)), + normalizedComposeJson: JSON.stringify({ + services: { + web: { image: "redis:latest", "x-hack-amd64-emulation": "amd64" }, + }, + }), + amd64Emulation: { web: "amd64" }, + }); + const invoke = opts.dependencies.invoke!; + const ensured: string[][] = []; + let beforeReview = true; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "ensure-image") { + ensured.push([...request.args]); + return { image_id: selected, platform: "linux/amd64" }; + } + if (request.args[1] === "inspect" && beforeReview) { + return observed; + } + if (request.args[1] === "restore-selection") { + return { ...saved, plan: saved.planId, generation: "2".repeat(64) }; + } + return await invoke(request); + }; + const review = opts.dependencies.review!; + let reviewedImage: unknown; + opts.dependencies.review = async (request) => { + beforeReview = false; + reviewedImage = JSON.parse(request.input.normalizedComposeJson).services + .web.image; + return await review(request); + }; + expect(await startNativeProject({ ...opts, restore: saved })).toBe(0); + const changed = retainedPolicy !== "amd64"; + expect(reviewedImage).toBe(changed ? selected : retained); + expect(ensured).toEqual( + changed + ? [ + [ + "runtime", + "ensure-image", + "--reference", + "redis:latest", + "--emulation", + "amd64", + "--json", + ], + ] + : [] + ); + } +}); + test("failed HTTPS intent preserves startup classification when cleanup cannot find the endpoint", async () => { const { opts, events } = await httpsFixture(); let acquireCalls = 0, From 355ae227f4cb232697159c26ef970c11049aaa88 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:10:45 -0500 Subject: [PATCH 073/138] fix(native): preserve adopt operation in backend rerun hints --- src/commands/config-adopt.ts | 20 +++++++++++++++++--- tests/native-refusal-cli.test.ts | 7 +++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/commands/config-adopt.ts b/src/commands/config-adopt.ts index 3550134da..db96f433d 100644 --- a/src/commands/config-adopt.ts +++ b/src/commands/config-adopt.ts @@ -371,15 +371,29 @@ async function apply(opts: { } } +/** Preserve operation modes without echoing user-supplied values in refusal text. */ +function adoptRerunCommand(opts: { + readonly dryRun?: boolean; + readonly rollback?: boolean; + readonly recover?: boolean; + readonly stop?: boolean; +}): string { + return [ + "hack config adopt", + ...(opts.dryRun ? ["--dry-run"] : []), + ...(opts.rollback ? ["--rollback"] : []), + ...(opts.recover ? ["--recover"] : []), + ...(opts.stop ? ["--stop"] : []), + ].join(" "); +} + export const configAdoptCommand = withHandler(spec, async ({ ctx, args }) => { // The preview reads the Compose backend's existing containers and volumes, // and adoption hands them to native configuration on that backend, so both // refuse another runtime selection and name the explicit rerun. requireNativeComposeBackend({ backend: process.env.HACK_RUNTIME_BACKEND, - command: args.options.dryRun - ? "hack config adopt --dry-run" - : "hack config adopt", + command: adoptRerunCommand(args.options), }); if (args.options.stop && args.options.rollback) { throw new CliUsageError("Use --stop separately from --rollback."); diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts index d57a9e1a1..5b2df11d1 100644 --- a/tests/native-refusal-cli.test.ts +++ b/tests/native-refusal-cli.test.ts @@ -227,6 +227,13 @@ test( for (const [args, command] of [ [["config", "adopt", "--dry-run"], "hack config adopt --dry-run"], [["config", "adopt"], "hack config adopt"], + [["config", "adopt", "--rollback"], "hack config adopt --rollback"], + [["config", "adopt", "--recover"], "hack config adopt --recover"], + [["config", "adopt", "--stop"], "hack config adopt --stop"], + [ + ["config", "adopt", "--recover", "--rollback"], + "hack config adopt --rollback --recover", + ], ] as const) { const result = await cli({ fixture: f, args }); expectOneClosedLine( From 0776feb68792ea973f1a72c010c0d92b92458bab Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:12:36 -0500 Subject: [PATCH 074/138] test(native): keep the moved root's inode allocated before recreating it The interrupted-addition test deleted the added root and then recreated it, expecting a different inode. ext4 reuses the freed inode immediately, so on Linux the recreated directory matched the recorded share and the test's `assert_ne!` failed. Move the root aside instead, so its inode stays allocated while the replacement is created, then cover the deleted-root resume separately. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/share_update/tests.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/provider/share_update/tests.rs b/packages/runtime-core/src/provider/share_update/tests.rs index e6c0e6606..f2a08621f 100644 --- a/packages/runtime-core/src/provider/share_update/tests.rs +++ b/packages/runtime-core/src/provider/share_update/tests.rs @@ -414,8 +414,11 @@ fn interrupted_addition_resumes_its_recorded_share_after_the_root_is_removed() { ) .unwrap(); state::write(&root(&candidate).join(JOURNAL), &j).unwrap(); - // The added root disappears before the interrupted update is repeated. - fs::remove_dir_all(&f.second.project).unwrap(); + // The added root moves away before the interrupted update is repeated. Moving it + // (rather than deleting it) keeps its inode allocated, so the recreated directory + // below is guaranteed another identity; filesystems such as ext4 reuse a freed inode. + let moved = f.directory.join("second-app-moved"); + fs::rename(&f.second.project, &moved).unwrap(); assert!(f.second.validate().is_err()); assert_eq!( addition(&candidate, &f.second.project, true).unwrap(), @@ -435,6 +438,13 @@ fn interrupted_addition_resumes_its_recorded_share_after_the_root_is_removed() { addition(&candidate, &f.second.project, true).unwrap(), f.second ); + // A deleted root resumes the same way. + fs::remove_dir_all(&f.second.project).unwrap(); + fs::remove_dir_all(&moved).unwrap(); + assert_eq!( + addition(&candidate, &f.second.project, true).unwrap(), + f.second + ); // Only the journal's own root and consent resume it. assert!(addition(&candidate, &f.third.project, true).is_err()); assert!(addition(&candidate, &f.second.project, false).is_err()); From 53d0dad46a0d736e838e9f375a6435ce3cb120ba Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:20:57 -0500 Subject: [PATCH 075/138] fix(native): keep authored log streams within their bound The authored per-stream logs tail now came from the whole-line selection, which may keep up to 32 KiB of JSON text on one stream. The authored reply validators still refuse a stream over 16 KiB of JSON text, so one long log line made `hack logs` refuse instead of returning bounded output. Each stream is now bounded to that limit in the engine adapter: older whole lines are dropped first, and a newest line longer than the bound keeps its most recent characters. Either drop sets `truncated`. The Rust validator uses the same constant. Also gate the lease-free observer's `incarnation` and `get` to macOS, where their only caller lives, and gate the per-stream tail to its macOS callers. Linux clippy refused them as dead code. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 5 +- packages/runtime-core/src/provider/engine.rs | 115 +++++++++++++++++- .../src/provider/graph/native/foreground.rs | 5 +- .../provider/graph/native/foreground/tests.rs | 32 +++++ .../runtime-core/src/provider/lifecycle.rs | 1 + tests/native-authored-project-logs.test.ts | 18 +++ 6 files changed, 168 insertions(+), 8 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 6b3c720f3..fcf95a043 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -579,7 +579,10 @@ and ownership before reading or executing. Logs include exited services, default time; `stdout` and `stderr` hold the same lines per stream. Retention keeps whole lines, newest first, within 32 KiB of JSON text and sets `truncated` when older lines were dropped. Only a single line longer than that budget is cut. `hack logs SERVICE ---no-follow` prints `lines` in that order, each to its own stream. +--no-follow` prints `lines` in that order, each to its own stream. Authored projects +return only `stdout` and `stderr`, and each stream also stays within 16 KiB of JSON +text: older whole lines go first, and a newest line longer than that keeps its most +recent bytes. `graph observe --run-id RUN_ID --json` reports each service container's current state (`running` with the engine's health, `exited` with its code, `dead`, `created`, diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index 8d532ca97..62151ad3e 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -388,16 +388,18 @@ impl<'a> Engine<'a> { self.logs_with_tail(id, "all") } /// Bounded per-stream tail for callers that keep the streams apart. Built from - /// the same whole-line, newest-first selection as [`Self::logs_tail_lines`]. - #[cfg(feature = "native-config-plan")] + /// the same whole-line, newest-first selection as [`Self::logs_tail_lines`]; + /// each stream also stays within [`LOG_STREAM_BUDGET`] (see [`stream_tail`]). + #[cfg(all(feature = "native-config-plan", any(target_os = "macos", test)))] pub(super) fn logs_tail( &self, id: &str, tail: u16, ) -> Result<(String, String, bool), CandidateError> { - let tail = self.logs_tail_lines(id, tail)?; - let (stdout, stderr) = tail.split(); - Ok((stdout, stderr, tail.truncated)) + if !(1..=1000).contains(&tail) { + return Err(failure("Container log tail must be from 1 to 1000.")); + } + stream_tail(&self.logs_bytes(id, &tail.to_string())?) } /// Bounded tail in engine order, which interleaves stdout and stderr by time. /// Whole lines are kept newest first; see [`LogTail`]. @@ -594,11 +596,14 @@ impl<'a> Observer<'a> { Ok(Self { guest, transport }) } + /// Only the lease-free graph observation uses these, and it is macOS-only. + #[cfg(target_os = "macos")] pub(super) fn incarnation(&self) -> &str { self.guest.incarnation() } /// One read-only engine GET, with runtime identity verified on both sides. + #[cfg(target_os = "macos")] pub(super) fn get(&self, path: &str) -> Result { self.guest.verify()?; let value = self.transport.request(Method::GET, path, None)?; @@ -840,6 +845,51 @@ fn decode_log_lines(bytes: &[u8]) -> Result { Ok(LogTail { lines, truncated }) } +/// JSON-encoded budget of one stream's text in the per-stream logs contract, +/// including its quotes. The authored logs validators refuse a larger stream. +#[cfg(any(test, all(feature = "native-config-plan", target_os = "macos")))] +pub const LOG_STREAM_BUDGET: usize = 16 * 1024 + 2; + +/// Per-stream tail: the whole-line, newest-first selection of [`decode_log_lines`], +/// with each stream bounded to [`LOG_STREAM_BUDGET`] by [`newest_stream_text`]. +#[cfg(any(test, all(feature = "native-config-plan", target_os = "macos")))] +pub(in crate::provider) fn stream_tail( + bytes: &[u8], +) -> Result<(String, String, bool), CandidateError> { + let tail = decode_log_lines(bytes)?; + let (stdout, stderr) = tail.split(); + let mut truncated = tail.truncated; + let stdout = newest_stream_text(&stdout, &mut truncated); + let stderr = newest_stream_text(&stderr, &mut truncated); + Ok((stdout, stderr, truncated)) +} + +/// Keep the newest text of one stream within [`LOG_STREAM_BUDGET`]. Older whole +/// lines are dropped first; only a newest line that alone exceeds the budget is +/// cut, keeping its most recent characters. Any drop sets `truncated`. +#[cfg(any(test, all(feature = "native-config-plan", target_os = "macos")))] +fn newest_stream_text(text: &str, truncated: &mut bool) -> String { + let mut encoded = 2; + let mut start = text.len(); + for (index, character) in text.char_indices().rev() { + encoded += json_text_cost(character); + if encoded > LOG_STREAM_BUDGET { + *truncated = true; + let kept = &text[start..]; + // Start at a line boundary when a whole newer line follows the cut. + if !text[..start].ends_with('\n') + && let Some(newline) = kept.find('\n') + && newline + 1 < kept.len() + { + return kept[newline + 1..].to_owned(); + } + return kept.to_owned(); + } + start = index; + } + text.to_owned() +} + fn bounded_log_text(bytes: &[u8], truncated: &mut bool) -> String { let text = String::from_utf8_lossy(bytes); // JSON escaping and invalid UTF-8 replacement must not overflow the node frame. @@ -1015,6 +1065,61 @@ mod tests { assert!(serde_json::to_vec(&tail.lines).unwrap().len() <= LOG_TAIL_BUDGET); } + #[test] + fn per_stream_tails_stay_within_the_authored_logs_stream_bound() { + let frame = |stream: u8, payload: &[u8]| { + let mut bytes = vec![stream, 0, 0, 0]; + bytes.extend((payload.len() as u32).to_be_bytes()); + bytes.extend(payload); + bytes + }; + let encoded = |text: &str| serde_json::to_vec(text).unwrap().len(); + // One 20,000-byte stdout line interleaved with stderr fits the combined tail + // but not one stream: it keeps its most recent bytes, exactly at the bound. + let mut line = vec![b'x'; 20_000]; + line.push(b'\n'); + let mut bytes = frame(2, b"warn one\n"); + bytes.extend(frame(1, &line[..7_000])); + bytes.extend(frame(2, b"warn two\n")); + bytes.extend(frame(1, &line[7_000..])); + assert!(!decode_log_lines(&bytes).unwrap().truncated); + let (stdout, stderr, truncated) = stream_tail(&bytes).unwrap(); + assert!(truncated); + assert_eq!(stdout, format!("{}\n", "x".repeat(16_382))); + assert_eq!(encoded(&stdout), LOG_STREAM_BUDGET); + assert_eq!(stderr, "warn one\nwarn two\n"); + + // A newer whole line follows: the older line that no longer fits is dropped. + bytes.extend(frame(1, b"done\n")); + let (stdout, stderr, truncated) = stream_tail(&bytes).unwrap(); + assert!(truncated); + assert_eq!( + (stdout.as_str(), stderr.as_str()), + ("done\n", "warn one\nwarn two\n") + ); + + // Escaping and multi-byte text are budgeted as encoded JSON on char boundaries. + let mut text = "é".repeat(5_000).into_bytes(); + text.extend(vec![0; 2_000]); + text.extend("ü".repeat(5_000).into_bytes()); + let (stdout, _, truncated) = stream_tail(&frame(1, &text)).unwrap(); + assert!(truncated && stdout.ends_with('ü')); + assert!( + encoded(&stdout) <= LOG_STREAM_BUDGET, + "{}", + encoded(&stdout) + ); + + // Within the bound, both streams are unchanged and not marked truncated. + let mut bytes = frame(1, b"one\n"); + bytes.extend(frame(2, b"err\n")); + bytes.extend(frame(1, b"two")); + assert_eq!( + stream_tail(&bytes).unwrap(), + ("one\ntwo".into(), "err\n".into(), false) + ); + } + #[test] fn rejection_classification_never_returns_response_values() { assert_eq!( diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index a25c3d255..4b9462592 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -1,5 +1,6 @@ //! Explicit native foreground lifetime; dead ownership never authorizes adoption or replay. use super::*; +use crate::provider::engine::LOG_STREAM_BUDGET; use crate::provider::graph::foreground::{signals, transport}; use serde::{Deserialize, Serialize}; use std::{cell::Cell, io::Write}; @@ -190,11 +191,11 @@ impl Reply { || serde_json::to_vec(&logs.stdout) .map_err(|_| refused())? .len() - > 16386 + > LOG_STREAM_BUDGET || serde_json::to_vec(&logs.stderr) .map_err(|_| refused())? .len() - > 16386 + > LOG_STREAM_BUDGET { return Err(refused()); } diff --git a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs index 3c7061a1c..c4baa6efd 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs @@ -999,6 +999,38 @@ fn finite_logs_wire_authenticates_the_live_native_owner_and_exact_member() { .validate(&receipt, Action::Status, None, None, None) .is_err() ); + // The engine's per-stream tail of one 20,000-byte line is a valid reply; + // one more retained byte on a stream is refused. + let mut long = Vec::from([1, 0, 0, 0]); + long.extend(20_001_u32.to_be_bytes()); + long.extend([b'x'; 20_000]); + long.push(b'\n'); + let (stdout, stderr, truncated) = crate::provider::engine::stream_tail(&long).unwrap(); + let tail_reply = |stdout: String| Reply { + version: 2, + kind: ReplyKind::NativeGraphControlReply, + run: RUN.into(), + review: receipt.review.review_id().into(), + result: Outcome::Logs { + logs: runtime::ServiceLogs { + receipt: receipt.clone(), + service: "web".into(), + container: receipt.resources["container:web"].id.clone().unwrap(), + stdout, + stderr: stderr.clone(), + truncated, + }, + }, + }; + assert!(truncated); + tail_reply(stdout.clone()) + .validate(&receipt, Action::Logs, input.logs.as_ref(), None, None) + .unwrap(); + assert!( + tail_reply(format!("x{stdout}")) + .validate(&receipt, Action::Logs, input.logs.as_ref(), None, None) + .is_err() + ); transport::write(&mut stream, &reply, Duration::from_secs(2)).unwrap(); assert_eq!(caller.join().unwrap()["result"]["logs"]["stdout"], "line\n"); }); diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index a946b1a8b..015f7ff33 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -362,6 +362,7 @@ impl<'a> ObservedGuest<'a> { } /// The observed runtime incarnation; receipts name the incarnation that owns them. + #[cfg(target_os = "macos")] pub(super) fn incarnation(&self) -> &str { &self.owner.token } diff --git a/tests/native-authored-project-logs.test.ts b/tests/native-authored-project-logs.test.ts index 3245f8eca..ca41b1f98 100644 --- a/tests/native-authored-project-logs.test.ts +++ b/tests/native-authored-project-logs.test.ts @@ -251,6 +251,24 @@ test.each([ ).rejects.toThrow(); }); }); +test("the runtime per-stream tail of one long line stays within the stream bound", async () => { + return owned(async () => { + const f = await fixture(); + const value = structuredClone(f.reply); + // The runtime keeps the newest 16,382 bytes of a 20,000-byte line (16,386 + // JSON bytes), interleaved with a short stderr tail. + const bounded = `${"x".repeat(16_382)}\n`; + value.result.logs.stdout = bounded; + value.result.logs.stderr = "warn one\nwarn two\n"; + expect(parseNativeAuthoredLogs(value, f.receipt, "web")).toMatchObject({ + stdout: bounded, + stderr: "warn one\nwarn two\n", + truncated: true, + }); + value.result.logs.stdout = `x${bounded}`; + expect(() => parseNativeAuthoredLogs(value, f.receipt, "web")).toThrow(); + }); +}); test.each([ "container", "service", From 8216321a10e1a3fe54c7232e906db77e81e15b47 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:23:39 -0500 Subject: [PATCH 076/138] test(native): spawn remove-data child processes with a private env The real-process lock and SIGINT tests spawned their child with the runner's environment, which the spawn env isolation guard refuses. Build the child env with `privateCliEnv` so it gets a private HOME and only the allow-listed variables. Co-Authored-By: Claude Opus 5.5 --- tests/native-project-remove-data.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/native-project-remove-data.test.ts b/tests/native-project-remove-data.test.ts index 93d2200ff..27581e1db 100644 --- a/tests/native-project-remove-data.test.ts +++ b/tests/native-project-remove-data.test.ts @@ -381,7 +381,7 @@ async function child(source: string, input: unknown) { const script = join(dir, "child.ts"); await writeFile(script, source); const proc = Bun.spawn([process.execPath, script, JSON.stringify(input)], { - env: { ...process.env, HACK_DAEMON_AUTOSTART_DISABLE: "1" }, + env: privateCliEnv({ root: dir }), stdin: "ignore", stdout: "pipe", stderr: "pipe", From 49905921c7cc75568d96ddcf7287e3b1542a64dc Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:27:30 -0500 Subject: [PATCH 077/138] fix(native): keep the managed env guard when resuming retained runs A retained or restored run that recorded a named env and started with managed env files now refuses, before any hook, when the selected overlay is gone, including when every managed env file was deleted. Only a run recorded with the unmanaged marker may find none. Restart preflight refuses the same case before cleanup, and a retained run is never relabeled unmanaged in its new mapping. Fresh exec, down and start share one availability check. Co-Authored-By: Claude Opus 5.5 --- src/backends/native-exec-environment.ts | 27 +++------ src/backends/native-project-down.ts | 30 ++++------ .../native-project-restart-preflight.ts | 11 ++++ src/backends/native-project-run.ts | 35 +++++++++++- src/backends/native-project-start.ts | 50 +++++++++++++---- tests/native-project-restart.test.ts | 23 +++++++- tests/native-project-start.test.ts | 56 +++++++++++++++++++ 7 files changed, 180 insertions(+), 52 deletions(-) diff --git a/src/backends/native-exec-environment.ts b/src/backends/native-exec-environment.ts index 7751d8fa7..92a8145e7 100644 --- a/src/backends/native-exec-environment.ts +++ b/src/backends/native-exec-environment.ts @@ -1,10 +1,10 @@ -import { basename } from "node:path"; import { HackCliError } from "../lib/cli-result.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { prepareNativeProjectInput } from "./native-project-input.ts"; -import type { - NativeProjectRun, - NativeProjectRunScope, +import { + type NativeProjectRun, + type NativeProjectRunScope, + nativeRunEnvironmentAvailable, } from "./native-project-run.ts"; /** Marks a selection that no longer matches the run's recorded startup selection. */ @@ -54,22 +54,11 @@ export async function prepareNativeExecEnvironment(opts: { ) { throw new SelectionChanged(); } - const selected = opts.run.effectiveEnvName; - // A run recorded as started without any managed env file used its env only to - // select the per-env native files; with still no file there are no values. Any - // other run whose selected overlay is gone has a changed selection. - const unmanaged = - opts.run.unmanagedEnvironment === true && - input.environmentFiles.length === 0; if ( - selected !== null && - !unmanaged && - !input.environmentFiles.some((path) => - [ - `hack.env.${selected}.yaml`, - `hack.env.${selected}.local.yaml`, - ].includes(basename(path)) - ) + !nativeRunEnvironmentAvailable({ + run: opts.run, + environmentFiles: input.environmentFiles, + }) ) { throw new SelectionChanged(); } diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index a5da72621..93bd18687 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -1,4 +1,3 @@ -import { basename } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; @@ -17,6 +16,7 @@ import { loadNativeProjectRun, type NativeProjectRun, type NativeProjectRunScope, + nativeRunEnvironmentAvailable, } from "./native-project-run.ts"; import { invokeNativeRuntime, @@ -220,29 +220,19 @@ export async function nativeDownEnvironment(opts: { envName: opts.run.effectiveEnvName, serviceNames: opts.serviceNames, }); - if (!resolved) { - // Only a run recorded as started without managed env files may find none now; - // for any other named selection the overlay disappeared. - if ( - opts.run.effectiveEnvName !== null && - opts.run.unmanagedEnvironment !== true - ) { - throw refused(); - } - return {}; - } - const selected = opts.run.effectiveEnvName; + // Only a run recorded as started without managed env files may find none now; + // for any other named selection a missing overlay disappeared. if ( - selected !== null && - !resolved.files.some((path) => - [ - `hack.env.${selected}.yaml`, - `hack.env.${selected}.local.yaml`, - ].includes(basename(path)) - ) + !nativeRunEnvironmentAvailable({ + run: opts.run, + environmentFiles: resolved?.files ?? [], + }) ) { throw refused(); } + if (!resolved) { + return {}; + } return selectProjectEnvValuesForExecutionTarget({ resolved, scopeName: "global", diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 2ffbfaa67..5c8053c56 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -34,6 +34,7 @@ import { import { type NativeProjectRun, type NativeProjectRunScope, + nativeRunEnvironmentAvailable, normalizeNativeProfiles, } from "./native-project-run.ts"; import { @@ -368,6 +369,16 @@ export async function preflightNativeRestart(opts: { composeFile: opts.composeFile, envName: selected.envName, }); + if ( + !nativeRunEnvironmentAvailable({ + run: opts.run, + environmentFiles: input.environmentFiles, + }) + ) { + throw new ClosedCliError( + "Native restart environment selection changed since startup; restart was refused. The current graph was not stopped." + ); + } const adaptationFile = selectNativeEnvFile({ selection: { file: opts.adaptationFile, diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index 5e61e5a5d..e363a26ea 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -10,7 +10,7 @@ import { rmdir, unlink, } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { basename, join, relative } from "node:path"; import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { @@ -55,6 +55,39 @@ export type NativeProjectRun = { */ readonly unmanagedEnvironment?: true; }; +/** + * Whether the managed env files a run's recorded selection needs are still present. + * A base selection needs none. A named selection needs its overlay + * (`hack.env..yaml` or `hack.env..local.yaml`), except for a run recorded + * as started without any managed env file while there still is none. A legacy + * mapping without a recorded selection never qualifies. + */ +export function nativeRunEnvironmentAvailable(opts: { + readonly run: Pick< + NativeProjectRun, + "effectiveEnvName" | "unmanagedEnvironment" + >; + readonly environmentFiles: readonly string[]; +}): boolean { + const selected = opts.run.effectiveEnvName; + if (selected === undefined) { + return false; + } + if (selected === null) { + return true; + } + if ( + opts.run.unmanagedEnvironment === true && + opts.environmentFiles.length === 0 + ) { + return true; + } + return opts.environmentFiles.some((path) => + [`hack.env.${selected}.yaml`, `hack.env.${selected}.local.yaml`].includes( + basename(path) + ) + ); +} export type NativeProjectRunScope = { readonly projectRoot: string; readonly projectDir: string; diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 410f0e7f8..5f481965d 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -70,6 +70,7 @@ import { loadNativeRestartIntent, type NativeProjectRun, type NativeProjectRunScope, + nativeRunEnvironmentAvailable, normalizeNativeProfiles, prepareNativeProjectRunStorage, removeNativeProjectRun, @@ -785,6 +786,38 @@ async function selectNativeStartup(opts: { const restore = opts.restore ?? retained ?? undefined; return { retained, restore, selection, retainedFlags }; } +/** + * Before any hook, refuse a retained run whose recorded env selection changed: its + * env must resolve the same, and it keeps the managed env files it started with. Only + * a run recorded as started without them may still find none, and a retained run never + * becomes unmanaged after the fact. A legacy restore without a recorded selection keeps + * its earlier behavior. Returns whether the new mapping records no managed env files. + */ +function requireRetainedEnvironment(opts: { + /** The retained env a resumed (not restored) run must still resolve to. */ + readonly retainedSelection: string | null | undefined; + readonly restore: NativeProjectRun | undefined; + readonly input: NativeProjectInput; +}): boolean { + const { restore, input } = opts; + if ( + (opts.retainedSelection !== undefined && + input.effectiveEnvName !== opts.retainedSelection) || + (restore?.effectiveEnvName !== undefined && + !nativeRunEnvironmentAvailable({ + run: restore, + environmentFiles: input.environmentFiles, + })) + ) { + throw new ClosedCliError( + "Native retained environment selection changed; data was not replaced." + ); + } + return ( + input.environmentFiles.length === 0 && + (restore === undefined || restore.unmanagedEnvironment === true) + ); +} function selectedMapping(opts: { readonly ready: NativeProjectRun; readonly effectiveEnvName: string | null; @@ -998,15 +1031,12 @@ export async function startNativeProject(opts: { envByService: input.managedEnvironment, }) : undefined; - if ( - retained && - !opts.restore && - input.effectiveEnvName !== selection.envName - ) { - throw new ClosedCliError( - "Native retained environment selection changed; data was not replaced." - ); - } + const unmanagedEnvironment = requireRetainedEnvironment({ + retainedSelection: + retained && !opts.restore ? selection.envName : undefined, + restore, + input, + }); // The effective env (requested, retained or project default) picks the per-env // files, so a fresh start, a retained start and a restart select the same ones. const selectedAdaptationFile = selectNativeEnvFile({ @@ -1351,7 +1381,7 @@ export async function startNativeProject(opts: { profiles, aws: selection.aws, runtimeMetadata: metadata !== undefined, - unmanagedEnvironment: input.environmentFiles.length === 0, + unmanagedEnvironment, }); // Enrollment must exist before taking a shared lease: release // requires independent proof of this exact graph's cleanup. diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index c3c5b93e6..50980d62f 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -41,6 +41,8 @@ const run = { effectiveEnvName: "qa", profiles: ["app"], aws: null, + // The stubbed input preparation reports no managed env file. + unmanagedEnvironment: true as const, }; const token = { version: 1 as const, @@ -470,12 +472,19 @@ test("preflight keeps the retained env's per-env files without managed env files }) ); const selected: Record = {}; - const preflight = (effectiveEnvName: string | null) => + const preflight = ( + effectiveEnvName: string | null, + startedWithManagedFiles = false + ) => preflightNativeRestart({ runtime: { binary: "/unused", home: "/candidate" }, scope: { ...scope, projectRoot: root, projectDir }, composeFile, - run: { ...run, effectiveEnvName }, + run: { + ...run, + effectiveEnvName, + unmanagedEnvironment: startedWithManagedFiles ? undefined : true, + }, adaptationFile: "/fixture/.hack/native/adaptation.json", adaptationFileByEnv: { qa: "/fixture/.hack/native/adaptation.qa.json" }, dependencyFile: "/machine/dependencies.json", @@ -514,6 +523,16 @@ test("preflight keeps the retained env's per-env files without managed env files env: undefined, dependencies: "/machine/dependencies.json", }); + // A run that started with managed env files refuses once they are all gone, + // before any adaptation, dependency or cleanup step. + for (const key of Object.keys(selected)) { + delete selected[key]; + } + await expectClosedRefusal( + preflight("qa", true), + "Native restart environment selection changed since startup; restart was refused. The current graph was not stopped." + ); + expect(selected).toEqual({}); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index fde1d6fa2..c419ac9af 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -42,6 +42,7 @@ import { type ComposeEnvFingerprintRecord, evaluateComposeServiceEnvFreshness, } from "../src/lib/compose-env-fingerprint.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -2895,6 +2896,61 @@ test("startup records the requested or default env and marks runs without manage } }); +test("a retained run that started with managed env files refuses before hooks once they are gone", async () => { + const qaFile = "/fixture/.hack/hack.env.qa.yaml"; + for (const [unmanaged, environmentFiles, expected] of [ + [false, [], "refused"], + [true, [], "unmanaged"], + [false, [qaFile], "managed"], + ] as const) { + const { opts, events } = await fixture(false); + const restore: NativeProjectRun = { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: "qa", + profiles: [], + aws: null, + ...(unmanaged ? { unmanagedEnvironment: true as const } : {}), + }; + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => ({ + ...(await prepare(request)), + effectiveEnvName: "qa", + environmentFiles, + }); + const invoke = opts.dependencies.invoke!; + opts.dependencies.invoke = async (request) => + request.args[1] === "restore-selection" + ? { ...restore, plan: restore.planId, generation: "2".repeat(64) } + : await invoke(request); + const saved: NativeProjectRun[] = []; + opts.dependencies.save = async (request) => { + saved.push(request.run); + }; + const started = startNativeProject({ ...opts, restore }); + if (expected === "refused") { + await expectClosedRefusal( + started, + "Native retained environment selection changed; data was not replaced." + ); + expect(events).not.toContain("before"); + expect(saved).toEqual([]); + continue; + } + expect(await started).toBe(0); + expect(events).toContain("before"); + expect(saved.length).toBeGreaterThan(0); + for (const mapping of saved) { + // A managed run is never relabeled unmanaged; an unmanaged one keeps its marker. + expect(mapping.unmanagedEnvironment).toBe( + expected === "unmanaged" ? true : undefined + ); + } + } +}); + test("startup and restart review identical branch runtime metadata; legacy runs get none", async () => { const { opts } = await fixture(false); const review = opts.dependencies.review!; From 33293eaadd7b92b463c599d3ce81512a7ce4714a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:34:46 -0500 Subject: [PATCH 078/138] test(models): accept every reachable first violation for multi-root-share TLC's two-worker search does not always report the shortest trace. CI reported referenced-removal at an 11-state trace where root a's graph was also retained, which the single registered witness (a's graph "none") rejected. Each multi-root-share control now accepts exactly the first violations its model can reach, as complete states: 9 for negative, 12 for unfenced-boot, 4 for owner-first and 2 for referenced-removal. They were enumerated from each control's complete state graph restricted to steps from states that still satisfy the invariant. No invariant, guard or bound changed. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/tla-runtime-models.ts | 115 +++++++++++++++++++++++------- tests/models/tla/README.md | 9 ++- 2 files changed, 97 insertions(+), 27 deletions(-) diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index ccca3e180..4dcfb443e 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -39,6 +39,93 @@ const laterIncompleteMissingLockStages = [ "socket", "owner", ] as const; +// TLC's two-worker search does not always report the shortest trace: a deeper +// first violation can be checked first. Each multi-root-share control therefore +// accepts exactly the first violations its model can reach, as complete states +// (graph a/b, bound a/b, recordSet, ownerSet, mounted, journal op/p, phase; a set +// is written as its sorted members). The lists were enumerated from each control's +// complete state graph restricted to steps from states that still satisfy the +// invariant. The first entry is the shortest witness. +type SharesFirstViolation = readonly [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, +]; +const sharesSet = (members: string): string => + `{${[...members].map((member) => `"${member}"`).join(", ")}}`; +const sharesFields = ([ + graphA, + graphB, + boundA, + boundB, + recordSet, + ownerSet, + mounted, + op, + p, + phase, +]: SharesFirstViolation): readonly string[] => [ + `graph = [a |-> "${graphA}", b |-> "${graphB}"]`, + `recordSet = ${sharesSet(recordSet)}`, + `journal = [op |-> "${op}", p |-> "${p}"]`, + `bound = [a |-> "${boundA}", b |-> "${boundB}"]`, + `phase = "${phase}"`, + `mounted = ${sharesSet(mounted)}`, + `ownerSet = ${sharesSet(ownerSet)}`, +]; +const sharesWitnesses = ( + action: string, + [first, ...others]: readonly [SharesFirstViolation, ...SharesFirstViolation[]] +) => ({ + action, + fields: sharesFields(first), + alternativeWitnesses: others.map((state) => ({ + action, + fields: sharesFields(state), + })), +}); +const sharesOwnShareViolations = [ + ["none", "live", "none", "a", "a", "a", "a", "idle", "a", "running"], + ["live", "live", "a", "a", "ab", "ab", "ab", "idle", "a", "running"], + ["live", "live", "b", "b", "ab", "ab", "ab", "idle", "a", "running"], + ["live", "live", "a", "a", "a", "a", "a", "idle", "a", "running"], + ["live", "none", "b", "none", "ab", "ab", "ab", "idle", "a", "running"], + ["live", "retained", "b", "b", "ab", "ab", "ab", "idle", "a", "running"], + ["none", "live", "none", "a", "ab", "ab", "ab", "idle", "a", "running"], + ["retained", "live", "a", "a", "ab", "ab", "ab", "idle", "a", "running"], + ["retained", "live", "a", "a", "a", "a", "a", "idle", "a", "running"], +] as const; +const sharesUnfencedBootViolations = [ + ["none", "none", "none", "none", "a", "a", "a", "add", "b", "running"], + ["none", "none", "none", "none", "ab", "ab", "ab", "add", "b", "running"], + ["none", "none", "none", "none", "ab", "a", "ab", "add", "b", "running"], + ["none", "none", "none", "none", "ab", "ab", "ab", "remove", "b", "running"], + ["none", "none", "none", "none", "a", "ab", "a", "remove", "b", "running"], + ["none", "none", "none", "none", "a", "a", "a", "remove", "b", "running"], + ["retained", "none", "a", "none", "ab", "ab", "ab", "add", "b", "running"], + ["retained", "none", "a", "none", "ab", "a", "ab", "add", "b", "running"], + ["retained", "none", "a", "none", "ab", "ab", "ab", "remove", "b", "running"], + ["retained", "none", "a", "none", "a", "a", "a", "add", "b", "running"], + ["retained", "none", "a", "none", "a", "ab", "a", "remove", "b", "running"], + ["retained", "none", "a", "none", "a", "a", "a", "remove", "b", "running"], +] as const; +const sharesOwnerFirstViolations = [ + ["none", "none", "none", "none", "a", "ab", "", "add", "b", "stopped"], + ["none", "none", "none", "none", "ab", "a", "", "remove", "b", "stopped"], + ["retained", "none", "a", "none", "ab", "a", "", "remove", "b", "stopped"], + ["retained", "none", "a", "none", "a", "ab", "", "add", "b", "stopped"], +] as const; +const sharesReferencedRemovalViolations = [ + ["none", "retained", "none", "b", "a", "ab", "", "remove", "b", "stopped"], + ["retained", "retained", "a", "b", "a", "ab", "", "remove", "b", "stopped"], +] as const; const contracts: readonly ModelContract[] = [ { name: "pre-ready-retirement", @@ -1078,45 +1165,25 @@ const contracts: readonly ModelContract[] = [ module: "Shares", states: 55, invariant: "OwnShareOnly", - action: "StartGraph", - fields: [ - 'graph = [a |-> "none", b |-> "live"]', - 'bound = [a |-> "none", b |-> "a"]', - 'mounted = {"a"}', - ], + ...sharesWitnesses("StartGraph", sharesOwnShareViolations), additionalControls: [ { name: "unfenced-boot", negative: true, invariant: "RunningMountsAudited", - action: "Boot", - fields: [ - 'journal = [op |-> "add", p |-> "b"]', - 'phase = "running"', - 'mounted = {"a"}', - ], + ...sharesWitnesses("Boot", sharesUnfencedBootViolations), }, { name: "owner-first", negative: true, invariant: "OwnerNeverAhead", - action: "CommitOwner", - fields: [ - 'journal = [op |-> "add", p |-> "b"]', - 'recordSet = {"a"}', - 'ownerSet = {"a", "b"}', - ], + ...sharesWitnesses("CommitOwner", sharesOwnerFirstViolations), }, { name: "referenced-removal", negative: true, invariant: "ReferencedSharesKept", - action: "CommitRecord", - fields: [ - 'graph = [a |-> "none", b |-> "retained"]', - 'journal = [op |-> "remove", p |-> "b"]', - 'recordSet = {"a"}', - ], + ...sharesWitnesses("CommitRecord", sharesReferencedRemovalViolations), }, ], }, diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index 92608b8ef..29d16d420 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -1066,9 +1066,12 @@ same-state witness: `negative` (a graph may select any mounted share) fails `OwnShareOnly` at `StartGraph`; `unfenced-boot` fails `RunningMountsAudited` at `Boot`; `owner-first` fails `OwnerNeverAhead` at `CommitOwner`; and `referenced-removal` fails `ReferencedSharesKept` at `CommitRecord`. The controls -check two roots (`a`, `b`): with a third, symmetric root TLC's two workers may -report the same violation for either additional root, so the exact witness would -not be deterministic. +check two roots (`a`, `b`), so no symmetric third root can stand in for `b`. +TLC's two-worker search also does not always report the shortest trace, so each +control accepts exactly the first violations its model can reach (9, 12, 4 and 2 +complete states). They were enumerated from each control's complete state graph, +restricted to steps from states that still satisfy the invariant, and are listed in +`scripts/lib/tla-runtime-models.ts`. | Model operation | Source boundary | | --- | --- | From dd581459d65281ee873bef91104a8b648b15edcf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:41:06 -0500 Subject: [PATCH 079/138] fix(native): deliver managed env to active services and explain startup refusals Native foreground startup had three failures for ordinary projects: - Managed values were delivered to every authored service, so a project with a service outside the selected profiles failed with execution_environment_owner. Delivery now uses only the reviewed, profile-active service set; normalization still declares the keys on every service so the reviewed plan is identical across profiles. - Every HACK_NATIVE_DEPENDENCIES problem printed one generic line. A refusal now names the reason and the entry by position, service, binding, aliases and ports (each only after it validates), including a listener that never appears before the discovery deadline. Host PIDs, executables and unrecognized field contents are never printed. - A fresh start that failed before publishing its run mapping left an invisible retained graph with its own volumes. After confirmed compute cleanup, up now removes that unpublished graph's data and confirms the removed receipt; shared caches are only released, the startup failure is still reported, and unconfirmed removal prints the run and the explicit removal request. Retained, restored and mapping-attempted runs keep their data. Refs HACK-1918 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 20 +- src/backends/native-project-dependencies.ts | 347 +++++++++++++++----- src/backends/native-project-failed-start.ts | 109 ++++++ src/backends/native-project-input.ts | 42 +++ src/backends/native-project-start.ts | 27 +- tests/native-project-dependencies.test.ts | 135 ++++++++ tests/native-project-failed-start.test.ts | 97 ++++++ tests/native-project-input.test.ts | 76 ++++- tests/native-project-restart.test.ts | 4 +- tests/native-project-start.test.ts | 192 ++++++++++- tests/native-refusal-cli.test.ts | 2 +- 11 files changed, 953 insertions(+), 98 deletions(-) create mode 100644 src/backends/native-project-failed-start.ts create mode 100644 tests/native-project-failed-start.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 3e80a518a..7a6df9bd1 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -805,6 +805,19 @@ It acquires public images, privately supplies managed environment values, runs lifecycle hooks, and publishes the run mapping after native readiness. Ctrl-C waits for owned cleanup and keeps the stopped run mapping and named volumes after confirming cleanup. The VM remains available until explicit runtime shutdown. +Managed values are declared on every authored service, so the reviewed plan is the +same for every profile selection, but they are delivered only to the services the +reviewed plan marks active. A service outside the selected profiles receives none. + +A fresh start that fails or is interrupted before it publishes its run mapping +leaves no run that `ps`, `down` or a later `up` can select; the next fresh start +creates a new run with new volumes. After confirming compute cleanup, `up` therefore +removes that unpublished graph's volumes with `graph cleanup --remove-data` and +confirms the removed receipt; shared dependency caches are only released. It still +reports the original startup failure. A retained or restored run, and a run whose +mapping write was attempted (its reply may have been lost), keep their data. When +removal is not confirmed, `up` prints one line naming the run and the explicit +`graph cleanup --run-id RUN --remove-data --json` request, and keeps the graph. Without that grant, `up` and `restart` refuse with `E_PERMISSION` before any stop or runtime request; set it and run the command again. @@ -1210,7 +1223,12 @@ listener or inconsistent shared selection refuses before relay grants change. Startup allows up to 60 seconds total for persistent hooks to create all selected listeners, with cancellable waits and bounded read-only discovery requests. It retries only native endpoint-identity refusals; malformed selections and other -failures stop immediately. A listener must pass the complete ownership checks +failures stop immediately. A refusal is one `E_CONFIG_INVALID` line that names the +reason and the affected entry by position, service, binding, aliases, guest port and +host port, each shown only after it validates. For example, a listener that never +appears on the selected port reports `no listener owned by host_executable answered +on host_port before the discovery deadline`. Host PIDs, executable paths and the +contents of unrecognized fields are never printed. A listener must pass the complete ownership checks before any VM or graph effects. Restart preflight does not wait or stop the graph when its current selection cannot be verified. diff --git a/src/backends/native-project-dependencies.ts b/src/backends/native-project-dependencies.ts index a0e05d5c6..5bd5a0ca9 100644 --- a/src/backends/native-project-dependencies.ts +++ b/src/backends/native-project-dependencies.ts @@ -60,13 +60,153 @@ export function prepareNativeDependencyServices(opts: { } } -/** The private listener selection (or its discovery) did not validate. */ -function refused(): Error { - return new HackCliError({ +const SAFE_FIELD = /^[A-Za-z0-9_]{1,64}$/; +const SAFE_NATIVE_CODE = /^[a-z0-9_]{1,80}$/; + +const OWN_REFUSALS = new WeakSet(); +/** + * The private listener selection (or its discovery) did not validate. The + * message names the problem and the public parts of the affected binding + * (service, binding, aliases and ports) only after they validate; host PIDs, + * executables and unrecognized field contents are never echoed. + */ +function refused(reason: string, context?: string): Error { + const error = new HackCliError({ code: "E_CONFIG_INVALID", - message: - "Native host dependencies require a bounded explicit listener selection; values omitted.", + message: `Native host dependencies refused${context ? ` for ${context}` : ""}: ${reason}; listener values omitted.`, }); + OWN_REFUSALS.add(error); + return error; +} +/** Discovery refusals keep their reason so a caller can add the binding context. */ +const DISCOVERY_REASONS = new WeakMap(); +function refusedDiscovery(reason: string): Error { + const error = refused(reason); + DISCOVERY_REASONS.set(error, reason); + return error; +} +function discoveryReason(error: unknown): string | undefined { + if (error instanceof Error && DISCOVERY_REASONS.has(error)) { + return DISCOVERY_REASONS.get(error); + } + if (error instanceof NativeRuntimeRequestError) { + return error.nativeCode && SAFE_NATIVE_CODE.test(error.nativeCode) + ? `listener discovery failed (${error.nativeCode})` + : "listener discovery request failed"; + } + return undefined; +} +const FILE_REASON = + "HACK_NATIVE_DEPENDENCIES must name an absolute path to a bounded, unchanged regular JSON file"; +const MISSING_LISTENER_REASON = + "no listener owned by host_executable answered on host_port before the discovery deadline"; +const SHAPE_REASON = + "the selection must be {version: 1, dependencies: [1..128 entries]} with no other fields"; + +/** Value-free label of one selection entry: only validated public fields. */ +function describeEntry(entry: unknown, index: number): string { + const parts: string[] = []; + if (isRecord(entry)) { + if (typeof entry.service === "string" && SERVICE.test(entry.service)) { + parts.push(`service ${entry.service}`); + } + if (typeof entry.binding === "string" && BINDING.test(entry.binding)) { + parts.push(`binding ${entry.binding}`); + } + if (Array.isArray(entry.aliases)) { + const aliases = entry.aliases.filter(hostname).slice(0, 8); + if (aliases.length > 0) { + parts.push(`aliases ${aliases.join(",")}`); + } + } + if (port(entry.guest_port)) { + parts.push(`guest port ${entry.guest_port}`); + } + if (port(entry.host_port)) { + parts.push(`host port ${entry.host_port}`); + } + } + return `dependency #${index + 1}${parts.length > 0 ? ` (${parts.join(", ")})` : ""}`; +} + +function hostPidProblem(entry: Record): string | undefined { + if ( + typeof entry.host_pid === "number" && + Number.isSafeInteger(entry.host_pid) && + entry.host_pid > 1 && + entry.host_pid <= 2_147_483_647 + ) { + return undefined; + } + return entry.host_pid === undefined + ? "host_pid or host_executable is required" + : "host_pid must be a process ID greater than 1"; +} + +function hostExecutableProblem( + entry: Record +): string | undefined { + if ( + entry.host_executable !== undefined && + !( + typeof entry.host_executable === "string" && + isAbsolute(entry.host_executable) && + entry.host_executable.length <= 1024 && + !entry.host_executable.includes("\0") + ) + ) { + return "host_executable must be an absolute path of at most 1024 bytes"; + } + if (!validSupervisorDepth(entry)) { + return "host_supervisor_depth must be an integer 1..8 and requires host_executable"; + } + return undefined; +} + +/** First structural problem of one entry, in declaration order, or undefined. */ +function entryProblem( + entry: unknown, + services: readonly string[] +): string | undefined { + if (!isRecord(entry)) { + return "each dependency must be an object"; + } + const unknown = Object.keys(entry).find((key) => !FIELDS.has(key)); + if (unknown !== undefined) { + return SAFE_FIELD.test(unknown) + ? `unsupported field ${unknown}` + : "unsupported field"; + } + if (typeof entry.service !== "string" || !SERVICE.test(entry.service)) { + return "service must name a Compose service"; + } + if (!services.includes(entry.service)) { + return "service is not declared in the Compose file"; + } + if (typeof entry.binding !== "string" || !BINDING.test(entry.binding)) { + return "binding must be 1..128 characters of a-z, 0-9, '.', '_' or '-'"; + } + if (!port(entry.guest_port)) { + return "guest_port must be an integer 1..65535"; + } + if (!port(entry.host_port)) { + return "host_port must be an integer 1..65535"; + } + const host = hostPidProblem(entry) ?? hostExecutableProblem(entry); + if (host !== undefined) { + return host; + } + if ( + !( + Array.isArray(entry.aliases) && + entry.aliases.length > 0 && + entry.aliases.length <= 8 && + entry.aliases.every(hostname) + ) + ) { + return "aliases must list 1..8 lowercase DNS hostnames that are not localhost or numeric"; + } + return undefined; } function port(value: unknown): value is number { return ( @@ -111,6 +251,7 @@ function requireSameEndpointPolicy(opts: { readonly policies: Map; readonly endpoint: string; readonly authority: ReturnType; + readonly context: string; }): void { const policy = JSON.stringify( opts.authority.host_executable === undefined @@ -122,7 +263,10 @@ function requireSameEndpointPolicy(opts: { ); const prior = opts.policies.get(opts.endpoint); if (prior !== undefined && prior !== policy) { - throw refused(); + throw refused( + "the same host listener is selected with different host_executable policies", + opts.context + ); } opts.policies.set(opts.endpoint, policy); } @@ -145,7 +289,7 @@ export function parseNativeHostDependencies(opts: { value.dependencies.length <= 128 ) ) { - throw refused(); + throw refused(SHAPE_REASON); } const keys = new Set(); const aliases = new Set(); @@ -155,45 +299,29 @@ export function parseNativeHostDependencies(opts: { >(); const endpointPolicies = new Map(); let nextSlot = 0; - return value.dependencies.map((entry) => { - if ( - !( - isRecord(entry) && - Object.keys(entry).every((key) => FIELDS.has(key)) && - typeof entry.service === "string" && - SERVICE.test(entry.service) && - opts.services.includes(entry.service) && - typeof entry.binding === "string" && - BINDING.test(entry.binding) && - port(entry.guest_port) && - port(entry.host_port) && - typeof entry.host_pid === "number" && - Number.isSafeInteger(entry.host_pid) && - entry.host_pid > 1 && - entry.host_pid <= 2_147_483_647 && - (entry.host_executable === undefined || - (typeof entry.host_executable === "string" && - isAbsolute(entry.host_executable) && - entry.host_executable.length <= 1024 && - !entry.host_executable.includes("\0"))) && - validSupervisorDepth(entry) && - Array.isArray(entry.aliases) && - entry.aliases.length > 0 && - entry.aliases.length <= 8 && - entry.aliases.every(hostname) - ) - ) { - throw refused(); + return value.dependencies.map((candidate: unknown, index) => { + const context = describeEntry(candidate, index); + const problem = entryProblem(candidate, opts.services); + if (problem !== undefined) { + throw refused(problem, context); } + const entry = candidate as Record & { + readonly service: string; + readonly binding: string; + readonly guest_port: number; + readonly host_port: number; + readonly host_pid: number; + readonly aliases: string[]; + }; const key = `${entry.service}:${entry.binding}`; if (keys.has(key)) { - throw refused(); + throw refused("the binding is declared twice for this service", context); } keys.add(key); for (const alias of entry.aliases) { const selected = `${entry.service}:${alias}`; if (aliases.has(selected)) { - throw refused(); + throw refused("an alias is declared twice for this service", context); } aliases.add(selected); } @@ -205,6 +333,7 @@ export function parseNativeHostDependencies(opts: { policies: endpointPolicies, endpoint, authority, + context, }); const selectedService = entry.service; const available = transports.get(endpoint) ?? []; @@ -213,7 +342,10 @@ export function parseNativeHostDependencies(opts: { ); if (!transport) { if (nextSlot >= 32) { - throw refused(); + throw refused( + "the selection needs more than 32 distinct host listener slots", + context + ); } transport = { slot: nextSlot++, services: new Set() }; available.push(transport); @@ -249,59 +381,89 @@ export async function readNativeHostDependencies(opts: { return []; } try { - const value = await readNativeSelection(opts.path); - if (!(isRecord(value) && Array.isArray(value.dependencies))) { - throw refused(); + return await readSelectedHostDependencies({ ...opts, path: opts.path }); + } catch (error) { + if (error instanceof Error && OWN_REFUSALS.has(error)) { + throw error; } - // Validate the complete intent before invoking native process discovery. - const provisional = { - ...value, - dependencies: value.dependencies.map((entry) => - isRecord(entry) && entry.host_executable !== undefined - ? { ...entry, host_pid: entry.host_pid ?? 2 } - : entry - ), - }; - parseNativeHostDependencies({ - value: provisional, - services: opts.services, - }); - const resolved = new Map(); - const dependencies: unknown[] = []; - for (const entry of value.dependencies) { - if (!isRecord(entry) || entry.host_executable === undefined) { - dependencies.push(entry); - continue; - } - if (!opts.discover) { - throw refused(); + throw refused("the selection could not be validated"); + } +} + +async function readSelectedHostDependencies(opts: { + readonly path: string; + readonly services: readonly string[]; + readonly discover?: Parameters< + typeof readNativeHostDependencies + >[0]["discover"]; +}): Promise { + let value: unknown; + try { + value = await readNativeSelection(opts.path); + } catch { + throw refused(FILE_REASON); + } + if (!(isRecord(value) && Array.isArray(value.dependencies))) { + throw refused(SHAPE_REASON); + } + // Validate the complete intent before invoking native process discovery. + const provisional = { + ...value, + dependencies: value.dependencies.map((entry) => + isRecord(entry) && entry.host_executable !== undefined + ? { ...entry, host_pid: entry.host_pid ?? 2 } + : entry + ), + }; + parseNativeHostDependencies({ + value: provisional, + services: opts.services, + }); + const resolved = new Map(); + const dependencies: unknown[] = []; + for (const [index, entry] of value.dependencies.entries()) { + if (!isRecord(entry) || entry.host_executable === undefined) { + dependencies.push(entry); + continue; + } + const context = describeEntry(entry, index); + if (!opts.discover) { + throw refused( + "host_executable selection needs listener discovery, which this command does not perform; select host_pid instead", + context + ); + } + const executable = String(entry.host_executable); + const hostPort = Number(entry.host_port); + const key = `${executable}\0${hostPort}`; + let hostPid = resolved.get(key); + if (hostPid === undefined) { + let discovered: Awaited>>; + try { + discovered = await opts.discover({ executable, hostPort }); + } catch (error) { + throw refused( + discoveryReason(error) ?? "listener discovery failed", + context + ); } - const executable = String(entry.host_executable); - const hostPort = Number(entry.host_port); - const key = `${executable}\0${hostPort}`; - let hostPid = resolved.get(key); - if (hostPid === undefined) { - const discovered = await opts.discover({ executable, hostPort }); - if ( - !Number.isSafeInteger(discovered.host_pid) || - discovered.host_pid <= 1 || - discovered.host_pid > 2_147_483_647 || - !FINGERPRINT.test(discovered.endpoint_fingerprint) - ) { - throw refused(); - } - hostPid = discovered.host_pid; - resolved.set(key, hostPid); + if ( + !Number.isSafeInteger(discovered.host_pid) || + discovered.host_pid <= 1 || + discovered.host_pid > 2_147_483_647 || + !FINGERPRINT.test(discovered.endpoint_fingerprint) + ) { + throw refused("listener discovery returned an invalid reply", context); } - dependencies.push({ ...entry, host_pid: hostPid }); + hostPid = discovered.host_pid; + resolved.set(key, hostPid); } - return parseNativeHostDependencies({ - value: { ...value, dependencies }, - services: opts.services, - }); - } catch { - throw refused(); + dependencies.push({ ...entry, host_pid: hostPid }); } + return parseNativeHostDependencies({ + value: { ...value, dependencies }, + services: opts.services, + }); } type NativeDependencyWait = { @@ -316,8 +478,11 @@ function remainingDiscoveryTime( return undefined; } const remaining = wait.deadlineMs - performance.now(); - if (wait.signal.aborted || !Number.isFinite(remaining) || remaining <= 0) { - throw refused(); + if (wait.signal.aborted) { + throw refusedDiscovery("listener discovery was cancelled"); + } + if (!Number.isFinite(remaining) || remaining <= 0) { + throw refusedDiscovery(MISSING_LISTENER_REASON); } return remaining; } @@ -340,7 +505,7 @@ async function waitForNextDiscovery(opts: { signal: opts.wait.signal, }); } catch { - throw refused(); + throw refusedDiscovery("listener discovery was cancelled"); } return true; } @@ -386,7 +551,7 @@ export async function discoverNativeHostDependency(opts: { typeof result.host_pid !== "number" || typeof result.endpoint_fingerprint !== "string" ) { - throw refused(); + throw refusedDiscovery("listener discovery returned an invalid reply"); } return { host_pid: result.host_pid, diff --git a/src/backends/native-project-failed-start.ts b/src/backends/native-project-failed-start.ts new file mode 100644 index 000000000..eaf382ea1 --- /dev/null +++ b/src/backends/native-project-failed-start.ts @@ -0,0 +1,109 @@ +import { isRecord } from "../lib/guards.ts"; +import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; +import { + invokeNativeRuntime, + NativeRuntimeRequestError, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +const SAFE_CODE = /^[a-z0-9_]{1,80}$/; + +export type NativeFailedStartDiscard = + | "removed" + | "retained" + | "not_applicable"; + +function stoppedWithData(value: unknown, run: string, owner: string): boolean { + return ( + isRecord(value) && + value.journal_incomplete === false && + isRecord(value.receipt) && + value.receipt.run === run && + value.receipt.owner === owner && + value.receipt.phase === "stopped-data-retained" + ); +} + +function removed(value: unknown, run: string, owner: string): boolean { + return ( + isRecord(value) && + value.journal_incomplete === false && + isRecord(value.receipt) && + value.receipt.run === run && + value.receipt.owner === owner && + value.receipt.phase === "removed" + ); +} + +/** + * Remove the retained data of a fresh foreground start that failed before it + * published a run mapping. + * + * Such a graph is unreachable: `ps`, `down`, `logs` and a later `up` select + * runs only through the published mapping, and a later fresh start always + * creates a new run with new volumes. Without this step every failed attempt + * would leave another invisible graph holding its own volumes. Shared + * dependency caches are only released by the runtime, never destroyed. + * + * Only a fresh start (no retained or restored run) whose mapping was never + * written or attempted qualifies, after confirmed compute cleanup. Removal failure never replaces the startup result: the caller receives + * "retained" and a value-free notice naming the run and the explicit removal. + */ +export async function discardUnpublishedNativeStart(opts: { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly run: string; + readonly owner: string; + readonly final: unknown; + /** A retained or restored run keeps its data; only fresh runs qualify. */ + readonly restored: boolean; + /** Any mapping write, even one whose reply was lost, keeps the data. */ + readonly mappingAttempted: boolean; + readonly invoke?: typeof invokeNativeRuntime; + readonly warn?: (line: string) => void; +}): Promise { + if ( + opts.restored || + opts.mappingAttempted || + !stoppedWithData(opts.final, opts.run, opts.owner) + ) { + return "not_applicable"; + } + const invoke = opts.invoke ?? invokeNativeRuntime; + try { + await invoke({ + runtime: opts.runtime, + cwd: opts.projectRoot, + args: [ + "graph", + "cleanup", + "--run-id", + opts.run, + "--remove-data", + "--json", + ], + timeoutMs: 590_000, + }); + const after = await inspectNativeProjectGraph({ + runtime: opts.runtime, + projectRoot: opts.projectRoot, + run: opts.run, + invoke, + }); + if (removed(after, opts.run, opts.owner)) { + return "removed"; + } + throw new Error("removal unconfirmed"); + } catch (error) { + const code = + error instanceof NativeRuntimeRequestError && + error.nativeCode && + SAFE_CODE.test(error.nativeCode) + ? ` (${error.nativeCode})` + : ""; + (opts.warn ?? ((line) => process.stderr.write(`${line}\n`)))( + `Native startup failed before publishing run ${opts.run}; its stopped graph and volumes were retained because automatic removal was not confirmed${code}. Remove them with the native runtime's \`graph cleanup --run-id ${opts.run} --remove-data --json\`.` + ); + return "retained"; + } +} diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index ab83234d9..b4052be4b 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -131,6 +131,48 @@ function normalizeManagedEnvironment( return managedEnvironment; } +/** + * Services the reviewed native plan marks active for the selected profiles. A + * malformed plan, or a service absent from it, selects nothing. + */ +export function nativeReviewedActiveServices( + reviewedPlan: unknown +): ReadonlySet { + const services = + isRecord(reviewedPlan) && isRecord(reviewedPlan.services) + ? reviewedPlan.services + : {}; + return new Set( + Object.entries(services) + .filter(([, service]) => isRecord(service) && service.active === true) + .map(([name]) => name) + ); +} + +/** + * Restrict private delivery to the reviewed, profile-active service set. + * + * Normalization declares managed keys on every authored service, including + * services outside the selected profiles, so public Compose and the reviewed + * plan stay identical across profile selections. The runtime refuses managed + * values for a service it will not schedule, so an optional disabled service + * must not receive them and block the selected graph. + */ +export function selectActiveNativeProjectInput(opts: { + readonly input: NativeProjectInput; + readonly reviewedPlan: unknown; +}): NativeProjectInput { + const active = nativeReviewedActiveServices(opts.reviewedPlan); + return { + ...opts.input, + managedEnvironment: Object.fromEntries( + Object.entries(opts.input.managedEnvironment).filter(([service]) => + active.has(service) + ) + ), + }; +} + /** * Prepare public normalization and separate private delivery without generating files. * Unrelated Compose fields remain intact for native capability admission to review; diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..62b8dc273 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -29,6 +29,7 @@ import { prepareNativeDependencyServices, readNativeHostDependencies, } from "./native-project-dependencies.ts"; +import { discardUnpublishedNativeStart } from "./native-project-failed-start.ts"; import { beginNativeProjectFinalization, captureNativeProjectFinalization, @@ -39,6 +40,7 @@ import { admitNativeProjectImages } from "./native-project-image-admission.ts"; import { type NativeProjectInput, prepareNativeProjectInput, + selectActiveNativeProjectInput, } from "./native-project-input.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { @@ -265,6 +267,7 @@ type Dependencies = { finalization: typeof beginNativeProjectFinalization; captureFinalization: typeof captureNativeProjectFinalization; waitFinalization: typeof waitNativeProjectFinalization; + discardFailedStart: typeof discardUnpublishedNativeStart; }; const DEFAULTS: Dependencies = { prepare: prepareNativeProjectInput, @@ -286,6 +289,7 @@ const DEFAULTS: Dependencies = { finalization: beginNativeProjectFinalization, captureFinalization: captureNativeProjectFinalization, waitFinalization: waitNativeProjectFinalization, + discardFailedStart: discardUnpublishedNativeStart, }; /** Fixed text of the native foreground configuration refusal. */ export const NATIVE_CONFIGURATION_REFUSAL = @@ -1223,6 +1227,8 @@ export async function startNativeProject(opts: { const directory = await mkdtemp(join(tmpdir(), "hack-native-start-")); const run = runSelection.run; let mapping: NativeProjectRun | undefined; + // Set before any mapping write: a lost reply may still have published it. + let mappingAttempted = false; let https: | Awaited> | undefined; @@ -1267,7 +1273,14 @@ export async function startNativeProject(opts: { run, invoke: deps.invoke, }); - const delivery = environmentDelivery(input, review.planId, run); + const delivery = environmentDelivery( + selectActiveNativeProjectInput({ + input, + reviewedPlan: review.report.plan, + }), + review.planId, + run + ); let code = 1; let serveFailure: unknown; let nativeExitCode: string | undefined; @@ -1345,6 +1358,7 @@ export async function startNativeProject(opts: { httpsIntent = identity; // This is an ownership mapping, not a readiness signal. // Recovery must be able to select the run after a lost reply. + mappingAttempted = true; await deps.save({ ...opts.scope, run: persistedMapping, @@ -1383,6 +1397,7 @@ export async function startNativeProject(opts: { throw httpsFailure; } if (!mapping) { + mappingAttempted = true; await deps.save({ ...opts.scope, run: persistedMapping, @@ -1436,6 +1451,16 @@ export async function startNativeProject(opts: { scope: opts.scope, remove: deps.remove, }); + await deps.discardFailedStart({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + run, + owner: confirmed.owned.owner, + final, + restored: restore !== undefined, + mappingAttempted, + invoke: deps.invoke, + }); return foregroundExitCode({ aborted: controller.signal.aborted, interruptedExit, diff --git a/tests/native-project-dependencies.test.ts b/tests/native-project-dependencies.test.ts index 3dc259894..4bb2b31de 100644 --- a/tests/native-project-dependencies.test.ts +++ b/tests/native-project-dependencies.test.ts @@ -484,3 +484,138 @@ test("logical grant bounds and endpoint identity remain independent of transport ]).map((entry) => entry.slot) ).toEqual([0, 1]); }); + +function refusalMessage(action: () => unknown): string { + try { + action(); + } catch (error: unknown) { + expect(error).toHaveProperty("code", "E_CONFIG_INVALID"); + return (error as Error).message; + } + throw new Error("expected a refusal"); +} + +test("selection refusals name the binding, alias, port and reason without listener values", () => { + const cases: [unknown[], string][] = [ + [ + [{ ...binding(), guest_port: 70_000 }], + "dependency #1 (service web, binding search, aliases search.example.com, host port 8443): guest_port must be an integer 1..65535", + ], + [ + [{ ...binding(), host_port: 0 }], + "dependency #1 (service web, binding search, aliases search.example.com, guest port 443): host_port must be an integer 1..65535", + ], + [ + [binding(), { ...binding("missing", "db") }], + "dependency #2 (service missing, binding db, aliases db.example.com, guest port 443, host port 8443): service is not declared in the Compose file", + ], + [ + [binding(), binding()], + "dependency #2 (service web, binding search, aliases search.example.com, guest port 443, host port 8443): the binding is declared twice for this service", + ], + [ + [{ ...binding(), aliases: ["localhost"] }], + "dependency #1 (service web, binding search, guest port 443, host port 8443): aliases must list 1..8 lowercase DNS hostnames", + ], + [ + [{ ...binding(), host_pid: 1 }], + "host_pid must be a process ID greater than 1", + ], + [ + [{ ...binding(), token: "synthetic-do-not-print" }], + "unsupported field token", + ], + [ + [{ ...binding(), "synthetic do-not-print": 1 }], + "): unsupported field; listener values omitted.", + ], + ]; + for (const [entries, expected] of cases) { + const message = refusalMessage(() => parse(entries)); + expect(message).toStartWith("Native host dependencies refused for "); + expect(message).toContain(expected); + expect(message).toEndWith("; listener values omitted."); + expect(message).not.toContain("synthetic-do-not-print"); + expect(message).not.toContain("do-not-print"); + // Host process identity is never echoed. + expect(message).not.toContain("123"); + } + expect( + refusalMessage(() => + parseNativeHostDependencies({ + value: { version: 2, dependencies: [binding()] }, + services: ["web"], + }) + ) + ).toBe( + "Native host dependencies refused: the selection must be {version: 1, dependencies: [1..128 entries]} with no other fields; listener values omitted." + ); +}); + +test("a listener missing past the discovery deadline names its binding and port", async () => { + const root = await mkdtemp(join(tmpdir(), "native-dependencies-")); + roots.push(root); + const path = join(root, "selection.json"); + const { host_pid: _pid, ...selected } = binding(); + await writeFile( + path, + JSON.stringify({ + version: 1, + dependencies: [ + { ...selected, host_executable: "/synthetic/private-tunnel" }, + ], + }) + ); + const wait = { + deadlineMs: performance.now() + 20, + signal: new AbortController().signal, + }; + const failure = await readNativeHostDependencies({ + path, + services: ["web"], + discover: async ({ executable, hostPort }) => + await discoverNativeHostDependency({ + ...discovery, + executable, + hostPort, + wait, + invoke: async () => { + throw unavailableListener(); + }, + }), + }).catch((error: unknown) => error); + expect(failure).toHaveProperty("code", "E_CONFIG_INVALID"); + expect((failure as Error).message).toBe( + "Native host dependencies refused for dependency #1 (service web, binding search, aliases search.example.com, guest port 443, host port 8443): no listener owned by host_executable answered on host_port before the discovery deadline; listener values omitted." + ); + expect((failure as Error).message).not.toContain("private-tunnel"); + + const coded = await readNativeHostDependencies({ + path, + services: ["web"], + discover: async () => { + throw new NativeRuntimeRequestError({ + message: "synthetic-do-not-print", + nativeCode: "candidate_ownership", + }); + }, + }).catch((error: unknown) => error); + expect((coded as Error).message).toContain( + "host port 8443): listener discovery failed (candidate_ownership); listener values omitted." + ); + expect((coded as Error).message).not.toContain("synthetic-do-not-print"); +}); + +test("an unreadable selection file is named without echoing its contents", async () => { + const root = await mkdtemp(join(tmpdir(), "native-dependencies-")); + roots.push(root); + const path = join(root, "selection.json"); + await writeFile(path, "synthetic-do-not-print"); + const failure = await readNativeHostDependencies({ + path, + services: ["web"], + }).catch((error: unknown) => error); + expect((failure as Error).message).toBe( + "Native host dependencies refused: HACK_NATIVE_DEPENDENCIES must name an absolute path to a bounded, unchanged regular JSON file; listener values omitted." + ); +}); diff --git a/tests/native-project-failed-start.test.ts b/tests/native-project-failed-start.test.ts new file mode 100644 index 000000000..f22f2bd49 --- /dev/null +++ b/tests/native-project-failed-start.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { discardUnpublishedNativeStart } from "../src/backends/native-project-failed-start.ts"; +import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; + +const run = "1".repeat(32); +const owner = "c".repeat(32); +function graph(phase: string, extra: Record = {}) { + return { + journal_incomplete: false, + receipt: { run, owner, phase, ...extra }, + observations: {}, + }; +} +function options( + overrides: Partial[0]> = {} +) { + const requests: string[] = []; + const notices: string[] = []; + let removed = false; + const opts: Parameters[0] = { + runtime: { binary: "/synthetic/native", home: "/synthetic/home" }, + projectRoot: "/synthetic/project", + run, + owner, + final: graph("stopped-data-retained"), + restored: false, + mappingAttempted: false, + warn: (line) => notices.push(line), + invoke: async (request) => { + requests.push(request.args.join(" ")); + if (request.args[1] === "cleanup") { + removed = true; + return {}; + } + return graph(removed ? "removed" : "stopped-data-retained"); + }, + ...overrides, + }; + return { opts, requests, notices }; +} + +test("an unpublished fresh graph is removed and confirmed by fresh inspection", async () => { + const { opts, requests, notices } = options(); + expect(await discardUnpublishedNativeStart(opts)).toBe("removed"); + expect(requests).toEqual([ + `graph cleanup --run-id ${run} --remove-data --json`, + `graph inspect --run-id ${run} --json`, + ]); + expect(notices).toEqual([]); +}); + +test("retained, restored, attempted or other graphs are never removed", async () => { + for (const overrides of [ + { restored: true }, + { mappingAttempted: true }, + { final: graph("removed") }, + { final: graph("cleanup-intent") }, + { final: graph("stopped-data-retained", { owner: "d".repeat(32) }) }, + { final: graph("stopped-data-retained", { run: "2".repeat(32) }) }, + { final: { ...graph("stopped-data-retained"), journal_incomplete: true } }, + { final: null }, + ]) { + const { opts, requests, notices } = options(overrides); + expect(await discardUnpublishedNativeStart(opts)).toBe("not_applicable"); + expect(requests).toEqual([]); + expect(notices).toEqual([]); + } +}); + +test("unconfirmed removal retains the graph and names the run and explicit removal", async () => { + const failing = options({ + invoke: async () => { + throw new NativeRuntimeRequestError({ + message: "synthetic-do-not-print", + nativeCode: "graph_relay_enrollment", + }); + }, + }); + expect(await discardUnpublishedNativeStart(failing.opts)).toBe("retained"); + expect(failing.notices).toHaveLength(1); + expect(failing.notices[0]).toContain(run); + expect(failing.notices[0]).toContain("(graph_relay_enrollment)"); + expect(failing.notices[0]).toContain( + `graph cleanup --run-id ${run} --remove-data --json` + ); + expect(failing.notices[0]).not.toContain("synthetic-do-not-print"); + + // A cleanup reply without a removed receipt is not removal. + const unconfirmed = options({ + invoke: async () => graph("stopped-data-retained"), + }); + expect(await discardUnpublishedNativeStart(unconfirmed.opts)).toBe( + "retained" + ); + expect(unconfirmed.notices).toHaveLength(1); + expect(unconfirmed.notices[0]).not.toContain("("); +}); diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index 8c04394d2..1ae1b4e39 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -11,7 +11,11 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { prepareNativeProjectInput } from "../src/backends/native-project-input.ts"; +import { + nativeReviewedActiveServices, + prepareNativeProjectInput, + selectActiveNativeProjectInput, +} from "../src/backends/native-project-input.ts"; import { restoreEnv } from "./helpers/env.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -207,3 +211,73 @@ test("linked worktree inherits primary local environment and observes checkout o restoreEnv("HACK_EXECUTION_MODE", oldMode); } }); + +test("managed values reach only the reviewed profile-active services", async () => { + // Synthetic shape: an always-on service and an optional profile service, + // both receiving one project-wide managed value. + const opts = await fixture({ + services: { + app: { image: "alpine:3", entrypoint: ["/bin/sleep"], command: ["60"] }, + extra: { + profiles: ["extra"], + image: "alpine:3", + entrypoint: ["/bin/sleep"], + command: ["60"], + }, + }, + }); + await env(opts.projectDir, "default", { + global: { GREETING: "synthetic-hello" }, + }); + const input = await prepareNativeProjectInput(opts); + // Normalization still declares the key on every authored service, so the + // reviewed plan is identical whichever profiles are selected. + const parsed = JSON.parse(input.normalizedComposeJson); + expect(parsed.services.app.environment).toEqual({ GREETING: null }); + expect(parsed.services.extra.environment).toEqual({ GREETING: null }); + expect(Object.keys(input.managedEnvironment).sort()).toEqual([ + "app", + "extra", + ]); + + const inactive = selectActiveNativeProjectInput({ + input, + reviewedPlan: { + services: { app: { active: true }, extra: { active: false } }, + }, + }); + expect(inactive.managedEnvironment).toEqual({ + app: { GREETING: "synthetic-hello" }, + }); + expect(inactive.normalizedComposeJson).toBe(input.normalizedComposeJson); + expect(inactive.serviceNames).toEqual(input.serviceNames); + + const selected = selectActiveNativeProjectInput({ + input, + reviewedPlan: { + services: { app: { active: true }, extra: { active: true } }, + }, + }); + expect(Object.keys(selected.managedEnvironment).sort()).toEqual([ + "app", + "extra", + ]); +}); + +test("an absent or malformed reviewed plan delivers no managed values", () => { + for (const reviewedPlan of [ + undefined, + null, + {}, + { services: [] }, + { services: { app: { active: "true" } } }, + { services: { app: null } }, + ]) { + expect(nativeReviewedActiveServices(reviewedPlan).size).toBe(0); + } + expect([ + ...nativeReviewedActiveServices({ + services: { app: { active: true }, other: { active: false } }, + }), + ]).toEqual(["app"]); +}); diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index c6450adbe..e825ee359 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -858,7 +858,7 @@ test("frontend recovery on an active graph still requires live host listeners", }), }), "E_CONFIG_INVALID", - "bounded explicit listener selection; values omitted" + "Native host dependencies refused for dependency #1" ); expect(listener.calls).toEqual([ "runtime status", @@ -1225,7 +1225,7 @@ test("active restart still refuses absent or wrong-executable listeners before c }, }), "E_CONFIG_INVALID", - "bounded explicit listener selection; values omitted" + "Native host dependencies refused for dependency #1" ); expect(listener.calls).toEqual([ "runtime status", diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..78fbb5d17 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -14,6 +14,7 @@ import { NativeHttpsLeaseAcquisitionError, } from "../src/backends/native-https-owner.ts"; import { NativeHttpsStartupError } from "../src/backends/native-https-startup-failure.ts"; +import { discardUnpublishedNativeStart } from "../src/backends/native-project-failed-start.ts"; import { beginNativeProjectFinalization, captureNativeProjectFinalization, @@ -57,6 +58,8 @@ async function fixture(withEnvironment = true) { const events: string[] = []; let run = ""; let ready = false; + // A retained-data removal request makes later inspections report removal. + let dataRemoved = false; const planId = "a".repeat(64), namespace = "b".repeat(64), owner = "c".repeat(32); @@ -118,7 +121,17 @@ async function fixture(withEnvironment = true) { if (opts.args[1] === "dependency-plan") { return { dependency_plan_id: "f".repeat(64) }; } + if (opts.args[1] === "cleanup" && opts.args.includes("--remove-data")) { + dataRemoved = true; + return {}; + } if (opts.args[1] === "inspect") { + let phase = "stopped-data-retained"; + if (ready) { + phase = "ready-observed"; + } else if (dataRemoved) { + phase = "removed"; + } return { journal_incomplete: false, receipt: { @@ -126,7 +139,7 @@ async function fixture(withEnvironment = true) { owner, namespace, plan_id: planId, - phase: ready ? "ready-observed" : "stopped-data-retained", + phase, resources: { "container:web": { kind: "container", @@ -377,6 +390,7 @@ test("failed startup recovers exact pending cleanup and still reports the startu const invoke = opts.dependencies.invoke!; let selectedRun = ""; let recovered = false; + let removed = false; const requests: string[] = []; opts.dependencies.serve = async (request) => { selectedRun = request.run; @@ -385,6 +399,11 @@ test("failed startup recovers exact pending cleanup and still reports the startu ); }; opts.dependencies.invoke = async (request) => { + if (request.args[1] === "cleanup") { + requests.push(request.args.join(" ")); + removed = true; + return {}; + } if (request.args[1] === "inspect-interrupted-start-cleanup") { requests.push(request.args[1]); return { @@ -425,6 +444,9 @@ test("failed startup recovers exact pending cleanup and still reports the startu receipt: { ...value.receipt, relay_cleanup: { phase: "pending" } }, }; } + if (removed) { + value.receipt.phase = "removed"; + } return value; } return invoke(request); @@ -432,16 +454,127 @@ test("failed startup recovers exact pending cleanup and still reports the startu await expect(startNativeProject(opts)).rejects.toThrow( "Native graph startup failed (engine_rejected)" ); + // The unpublished fresh graph's data is removed only after recovery + // confirmed compute cleanup; the startup failure is still reported. expect(requests).toEqual([ "inspect", "inspect-interrupted-start-cleanup", "recover-interrupted-start-cleanup", "inspect", + `graph cleanup --run-id ${selectedRun} --remove-data --json`, + "inspect", ]); expect(events).not.toContain("ready"); expect(events).not.toContain("save"); expect(events.at(-1)).toBe("cleanup"); }); +function failingFreshServe( + opts: Awaited>["opts"], + requests: string[] +) { + const invoke = opts.dependencies.invoke!; + let selectedRun = ""; + let removed = false; + opts.dependencies.serve = async (request) => { + selectedRun = request.run; + throw new Error( + "Native graph startup failed (graph_startup_application_failed); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "cleanup") { + requests.push(request.args.join(" ")); + removed = true; + return {}; + } + if (request.args[1] === "inspect") { + requests.push("inspect"); + const value = stoppedGraph({ + run: selectedRun, + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + }); + value.receipt.phase = removed ? "removed" : "stopped-data-retained"; + return value; + } + return await invoke(request); + }; + return () => selectedRun; +} + +test("a failed fresh start removes its unpublished graph data and keeps the startup failure", async () => { + const { opts, events } = await fixture(); + const requests: string[] = []; + const selectedRun = failingFreshServe(opts, requests); + await expect(startNativeProject(opts)).rejects.toThrow( + "graph_startup_application_failed" + ); + expect(requests).toEqual([ + "inspect", + `graph cleanup --run-id ${selectedRun()} --remove-data --json`, + "inspect", + ]); + expect(events).not.toContain("save"); + expect(events).not.toContain("remove"); + expect(events.at(-1)).toBe("cleanup"); +}); + +test("a failed fresh start whose data removal is unconfirmed still reports the startup failure", async () => { + const { opts } = await fixture(); + const requests: string[] = []; + failingFreshServe(opts, requests); + const notices: string[] = []; + opts.dependencies.discardFailedStart = async (request) => + await discardUnpublishedNativeStart({ + ...request, + warn: (line) => notices.push(line), + }); + const invoke = opts.dependencies.invoke!; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "cleanup") { + throw new NativeRuntimeRequestError({ + message: "synthetic-do-not-print", + nativeCode: "graph_cleanup_uncertain", + }); + } + return await invoke(request); + }; + await expect(startNativeProject(opts)).rejects.toThrow( + "graph_startup_application_failed" + ); + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("(graph_cleanup_uncertain)"); + expect(notices[0]).toContain("--remove-data"); + expect(notices[0]).not.toContain("synthetic-do-not-print"); +}); + +test("published, attempted or retained runs never have their data removed", async () => { + // A successful start publishes its mapping before the graph stops. + const published = await fixture(); + const seen: string[] = []; + const invoke = published.opts.dependencies.invoke!; + published.opts.dependencies.invoke = async (request) => { + seen.push(request.args.join(" ")); + return await invoke(request); + }; + expect(await startNativeProject(published.opts)).toBe(0); + // A mapping write with a lost reply may still have published the run. + const attempted = await fixture(); + attempted.opts.dependencies.save = async () => { + throw new Error("synthetic lost mapping reply"); + }; + const attemptedInvoke = attempted.opts.dependencies.invoke!; + attempted.opts.dependencies.invoke = async (request) => { + seen.push(request.args.join(" ")); + return await attemptedInvoke(request); + }; + await expect(startNativeProject(attempted.opts)).rejects.toThrow( + "synthetic lost mapping reply" + ); + expect(seen.some((args) => args.includes("--remove-data"))).toBe(false); +}); + test("foreground partial shutdown is reported without selecting failed-start recovery", async () => { const { opts, events } = await fixture(); const invoke = opts.dependencies.invoke!; @@ -760,6 +893,63 @@ test("projects without managed values omit the private environment envelope", as expect(events).not.toContain("remove"); }); +test("managed values are delivered only to reviewed profile-active services", async () => { + const { opts } = await fixture(); + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + return { + ...input, + normalizedComposeJson: JSON.stringify({ + services: { + web: { + image: `sha256:${"e".repeat(64)}`, + environment: { GREETING: null }, + }, + extra: { + image: `sha256:${"e".repeat(64)}`, + profiles: ["extra"], + environment: { GREETING: null }, + }, + }, + }), + managedEnvironment: { + web: { GREETING: "synthetic-secret" }, + extra: { GREETING: "synthetic-inactive" }, + }, + serviceNames: ["web", "extra"], + }; + }; + const review = opts.dependencies.review!; + opts.dependencies.review = async (request) => + await review({ + ...request, + run: (reviewed) => + request.run({ + ...reviewed, + report: { + plan: { + enrollment_compatible: true, + services: { web: { active: true }, extra: { active: false } }, + }, + }, + }), + }); + let delivered: unknown; + const serve = opts.dependencies.serve!; + opts.dependencies.serve = async (request) => { + delivered = JSON.parse(new TextDecoder().decode(request.privateInput)); + return await serve(request); + }; + expect(await startNativeProject(opts)).toBe(0); + expect(delivered).toMatchObject({ + version: 1, + services: { web: { GREETING: "synthetic-secret" } }, + }); + expect(JSON.stringify(delivered)).not.toContain("extra"); + expect(JSON.stringify(delivered)).not.toContain("synthetic-inactive"); +}); + test("AWS profile export follows native login hooks and precedes runtime effects", async () => { const { opts, events } = await fixture(); opts.dependencies.adaptAws = async ({ input, profile }) => { diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts index a89d07381..d30a396a2 100644 --- a/tests/native-refusal-cli.test.ts +++ b/tests/native-refusal-cli.test.ts @@ -284,7 +284,7 @@ test( }); expectOneClosedLine( result, - "E_CONFIG_INVALID: Native host dependencies require a bounded explicit listener selection; values omitted." + "E_CONFIG_INVALID: Native host dependencies refused: the selection must be {version: 1, dependencies: [1..128 entries]} with no other fields; listener values omitted." ); expect(await f.calls()).toEqual(["runtime check-project-share"]); }, From 4668ef1741f62b62359b93f34f6a0d8c527d13cc Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:45:19 -0500 Subject: [PATCH 080/138] fix(runtime): classify a finished initializer by its own exit, not as a lost listener A service with host-dependency bindings that exits quickly, such as an initializer other services wait on with service_completed_successfully, could fail startup with graph_startup_running_listener_lost. The relay transport inside the container ends with the service, but the engine can record the container's own exit only afterwards, so the first inspection still reported it running. When a relay transport has exited and the container still reports running, startup now re-inspects it (read-only, cancellable) for up to 10 seconds before classifying. A completed service that exits 0 is accepted, a crashing service reports its own failure, and only a container that keeps running past the window is a lost listener. Refs HACK-1918 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 7 + .../src/provider/graph/startup/runtime.rs | 125 ++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 3e80a518a..e12e573c7 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1266,6 +1266,13 @@ exit observation before cleanup, including exit code zero. A successful no command, environment or application log values. Older bundles may refuse the new zero-exit evidence; use the owning bundle for diagnosis and retaining cleanup. +A host-dependency listener's transport usually ends before the engine records the +container's own exit, for example when a quick initializer finishes. Startup then +re-inspects the container for up to 10 seconds before it classifies the exit, so a +`completed` service that exits 0 is accepted and a crashing service reports its own +exit. Only a container that still runs after that window reports +`graph_startup_running_listener_lost`. + A Docker container can remain `created` with a nonzero error when its first start fails before a process runs. Retaining shutdown accepts this state only with verified ownership, no running PID, no pause/restart/dead state and no OOM flag. diff --git a/packages/runtime-core/src/provider/graph/startup/runtime.rs b/packages/runtime-core/src/provider/graph/startup/runtime.rs index 5a032d372..139aad584 100644 --- a/packages/runtime-core/src/provider/graph/startup/runtime.rs +++ b/packages/runtime-core/src/provider/graph/startup/runtime.rs @@ -122,6 +122,32 @@ fn validate_start_phase(resource_phase: &str, graph_phase: &str) -> Result<(), C } Ok(()) } +/// Upper bound for the engine to record a container's own exit after its relay +/// transport has already reported exit. A container still running after this +/// window has genuinely lost its listener. +const LISTENER_EXIT_SETTLE: Duration = Duration::from_secs(10); +const LISTENER_EXIT_POLL: Duration = Duration::from_millis(25); + +/// The relay transport is never container-death evidence: when the service's +/// main process exits, the relay inside the container ends with it, but the +/// engine can record the container's own exit only afterwards, which can take +/// seconds on a loaded VM. +/// Re-inspect while the container still reports running, up to `deadline`, so a +/// completed initializer or a crashing service is classified by its own exit +/// instead of as a lost listener. Each inspection is read-only and checks +/// cancellation first; the last observation is returned either way. +fn settle_exited_transport( + mut observed: Value, + deadline: Instant, + poll: Duration, + mut inspect: impl FnMut() -> Result, +) -> Result { + while observed["State"]["Running"] == true && Instant::now() < deadline { + std::thread::sleep(poll.min(deadline.saturating_duration_since(Instant::now()))); + observed = inspect()?; + } + Ok(observed) +} fn verify_exited_listener( condition: Option<&Condition>, observed: &Value, @@ -691,6 +717,7 @@ impl Driver for HostRelayRuntime { } } } + let cancelled = self.startup_cancelled; for ((name, _), child) in &mut self.children { if child.poll_exit()?.is_some() { let resource = receipt @@ -698,6 +725,20 @@ impl Driver for HostRelayRuntime { .get(&format!("container:{name}")) .ok_or_else(refused)?; let observed = inspect_resource(engine, receipt, resource)?.ok_or_else(refused)?; + let observed = settle_exited_transport( + observed, + Instant::now() + LISTENER_EXIT_SETTLE, + LISTENER_EXIT_POLL, + || { + if cancelled.is_some_and(|check| check()) { + return Err(error( + "graph_cancelled", + "Foreground startup was cancelled; owned cleanup is required.", + )); + } + inspect_resource(engine, receipt, resource)?.ok_or_else(refused) + }, + )?; let observation = probes::observe(engine, receipt, name, &observed)?; if observation.failed() { // The relay often exits with its application. Preserve the owned @@ -1069,6 +1110,90 @@ mod route_tests { assert!(!absent.exists()); } #[test] + fn transport_exit_waits_for_the_container_to_record_its_own_exit() { + // A quick-exit initializer: its relay transport ends first, and the + // engine still reports the container running for a few inspections. + let running = json!({"State":{"Running":true,"ExitCode":0,"OOMKilled":false}}); + let exited = json!({"State":{"Running":false,"ExitCode":0,"OOMKilled":false}}); + let mut inspections = 0; + let settled = settle_exited_transport( + running.clone(), + Instant::now() + Duration::from_secs(30), + Duration::from_millis(1), + || { + inspections += 1; + Ok(if inspections < 3 { + running.clone() + } else { + exited.clone() + }) + }, + ) + .unwrap(); + assert_eq!(inspections, 3); + verify_exited_listener(Some(&Condition::Completed), &settled).unwrap(); + // Without settling, the same first observation is a lost listener. + assert_eq!( + verify_exited_listener(Some(&Condition::Completed), &running) + .unwrap_err() + .code, + "graph_startup_running_listener_lost" + ); + // A crashing service is classified by its own exit, not as lost. + let crashed = json!({"State":{"Running":false,"ExitCode":3,"OOMKilled":false}}); + let settled = settle_exited_transport( + running.clone(), + Instant::now() + Duration::from_secs(30), + Duration::from_millis(1), + || Ok(crashed.clone()), + ) + .unwrap(); + assert_eq!(settled["State"]["ExitCode"], 3); + } + #[test] + fn transport_exit_with_a_container_that_keeps_running_is_still_lost() { + let running = json!({"State":{"Running":true,"ExitCode":0,"OOMKilled":false}}); + let mut inspections = 0; + let started = Instant::now(); + let settled = settle_exited_transport( + running.clone(), + Instant::now() + Duration::from_millis(60), + Duration::from_millis(5), + || { + inspections += 1; + Ok(running.clone()) + }, + ) + .unwrap(); + assert!(inspections >= 1); + assert!(started.elapsed() >= Duration::from_millis(60)); + assert_eq!( + verify_exited_listener(Some(&Condition::Completed), &settled) + .unwrap_err() + .code, + "graph_startup_running_listener_lost" + ); + // An already exited container is never re-inspected. + let exited = json!({"State":{"Running":false,"ExitCode":0,"OOMKilled":false}}); + let settled = settle_exited_transport( + exited.clone(), + Instant::now() + Duration::from_secs(30), + Duration::from_millis(1), + || panic!("an exited container needs no further inspection"), + ) + .unwrap(); + assert_eq!(settled, exited); + // Cancellation and inspection refusals stop the wait. + let refused = settle_exited_transport( + running, + Instant::now() + Duration::from_secs(30), + Duration::from_millis(1), + || Err(error("graph_cancelled", "cancelled")), + ) + .unwrap_err(); + assert_eq!(refused.code, "graph_cancelled"); + } + #[test] fn exited_listener_accepts_only_successful_completed_service() { let exited = json!({"State":{"Running":false,"ExitCode":0,"OOMKilled":false}}); verify_exited_listener(Some(&Condition::Completed), &exited).unwrap(); From 420a9d8e9087bd7176bb8599d5de1a77ce33908e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:46:06 -0500 Subject: [PATCH 081/138] fix(native): audit large provider descriptor tables and own failed startups The provider disk audit listed at most 4,096 descriptors of the VM process and refused when the table filled the buffer. A provider sharing a project source holds a host descriptor per guest-open file, so ordinary dev servers and file watchers exceeded it and every runtime operation, including startup, observation and cleanup, refused with unaudited_provider_config. The audit now sizes its buffer from the kernel estimate and doubles it while replies fill it, up to a 1,048,576-record ceiling; a table that cannot be listed completely is still refused, and the owned disks must still be positively observed with unchanged identities. A foreground Compose startup that failed after its graph started resources and could not confirm cleanup left them with no project owner: ps and down reported "not started" because the run mapping is published only at readiness. The frontend now publishes the authenticated failed run as the project's run mapping, with a finalization lifetime as readiness does, so ps, down and doctor --native-cleanup inspect select it. Nothing is stopped or replayed; an unauthenticated receipt or an existing mapping records nothing. A new native-startup-owner TLA+ model checks the ownership rule and keeps the refused-inspection gap as an explicit control. Refs HACK-1917 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 7 + docs/reference/v5-model-coverage.md | 2 + .../runtime-core/src/provider/disk_audit.rs | 203 +++++++++++++++--- scripts/lib/tla-runtime-models.ts | 26 +++ src/backends/native-project-start.ts | 146 +++++++++++-- tests/models/tla/README.md | 15 ++ .../models/tla/native-startup-owner/Owner.tla | 70 ++++++ .../tla/native-startup-owner/negative.cfg | 7 + .../tla/native-startup-owner/positive.cfg | 7 + .../refused-inspection.cfg | 8 + tests/native-project-start.test.ts | 159 +++++++++++++- 11 files changed, 610 insertions(+), 40 deletions(-) create mode 100644 tests/models/tla/native-startup-owner/Owner.tla create mode 100644 tests/models/tla/native-startup-owner/negative.cfg create mode 100644 tests/models/tla/native-startup-owner/positive.cfg create mode 100644 tests/models/tla/native-startup-owner/refused-inspection.cfg diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 3e80a518a..534196fb3 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -805,6 +805,13 @@ It acquires public images, privately supplies managed environment values, runs lifecycle hooks, and publishes the run mapping after native readiness. Ctrl-C waits for owned cleanup and keeps the stopped run mapping and named volumes after confirming cleanup. The VM remains available until explicit runtime shutdown. +If startup fails after the graph started resources and the frontend cannot +confirm their cleanup, it publishes the authenticated failed run as the project's +run mapping before exiting, so `hack ps`, `hack down` and +`hack doctor --native-cleanup inspect` select that run instead of reporting the +project as not started; nothing is stopped or replayed to do so. When the +receipt itself cannot be authenticated, nothing is recorded and the runtime +journal keeps the graph for explicit recovery. Without that grant, `up` and `restart` refuse with `E_PERMISSION` before any stop or runtime request; set it and run the command again. diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 9f229fe79..59adf4534 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -37,6 +37,7 @@ must be attached to the eventual exact integration revision. | Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification | | --- | --- | --- | --- | | Exclusive claims; competing writers; partial owner publication | graph-admission, registry-writer, publication-journal, missing-publication-lock, native-frontend-recovery | Admission/registry locks, pending publication and exact saved owner; related runtime ownership tests | Real filesystems, boot/incarnation identity and unsupported writers remain independent. | +| Foreground Compose startup failing after resources started; unconfirmed cleanup; unauthenticated final inspection | native-startup-owner | `recordFailedStartupOwner` in [native-project-start](../../src/backends/native-project-start.ts) publishes the authenticated failed run with a finalization lifetime; [start regressions](../../tests/native-project-start.test.ts) cover recording, an unauthenticated receipt and a retained mapping | A refused final inspection still leaves only the runtime journal; crash between finalization registration and mapping publication; physical guest acceptance. | | Canceled/expired startup; reader cancellation versus EOF; producer/group uncertainty | pre-ready-retirement, authority-lifetime, native-file-material | Native graph finalizer/onSettled, exact Removed, hard-signal freshness, host stop/close; local pre-Ready retained/removed regressions, including separate live/foreign/hard-canceled fresh-teardown-admission assertions (HACK-1801) | Physical guest acceptance; current root aggregate; explicit executable unknown host-close control; async proof-to-stop and partial binding-retirement crash windows; post-stop source/session replacement or hard cancellation before Retire (Retire does not recheck these inputs; Safe has no post-stop interference predicate). | | Crash between reserve, publish, sync and release; stale recovery | native-frontend-recovery, publication, publication-journal, native-storage-witness | Durable intent/enrolled/released records, fresh lease and expected binding; recovery and sync-failure tests | Fsync guarantees, process-kill windows, real restarted provider observations. No missing-path authority. | | Data lifetime independent of compute; replaced/aliased metadata | native-persistent-enrollment, native-storage-witness, native-file-material | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. | @@ -72,6 +73,7 @@ liveness/reachability controls have their own documented assumptions. | [missing-publication-lock](../../tests/models/tla/missing-publication-lock/MissingLock.tla) | 96 | 7 | | [shared-https-lifetime](../../tests/models/tla/shared-https-lifetime/SharedHttps.tla) | 64 | 2 | | [previous-boot-shared-https](../../tests/models/tla/previous-boot-shared-https/ArchiveHttps.tla) | 92 | 4 | +| [native-startup-owner](../../tests/models/tla/native-startup-owner/Owner.tla) | 32 | 3 | | [stopped-pool-startup](../../tests/models/tla/stopped-pool-startup/Resume.tla) | 71 | 3 | | [dependency-rebind](../../tests/models/tla/dependency-rebind/Rebind.tla) | 21 | 10 | | [relay-lifecycle-barrier](../../tests/models/tla/relay-lifecycle-barrier/Barrier.tla) | 1,590 | 2 | diff --git a/packages/runtime-core/src/provider/disk_audit.rs b/packages/runtime-core/src/provider/disk_audit.rs index 8ffd78f1b..02e799aa6 100644 --- a/packages/runtime-core/src/provider/disk_audit.rs +++ b/packages/runtime-core/src/provider/disk_audit.rs @@ -5,6 +5,22 @@ use super::identity::ProcessIdentity; use crate::CandidateError; use std::path::Path; +/// First descriptor-list capacity when the kernel's sizing estimate is unusable. +#[cfg(any(target_os = "macos", test))] +const INITIAL_FDS: usize = 4096; +/// Hard ceiling on listed descriptor records (8 bytes each, so 8 MiB at most). +/// +/// The audit proves that the owned provider process holds the expected disk +/// images; every other descriptor is only classified and skipped. A provider that +/// shares a project source holds one host descriptor per guest-open shared file +/// (dev servers, file watchers, `tail -f`), so ordinary projects exceed a few +/// thousand. The listing therefore grows to the process's actual table and only +/// refuses above this ceiling, which is above the default macOS per-process limit +/// (`kern.maxfilesperproc`). A table that cannot be listed completely is refused: +/// a truncated listing is never treated as evidence. +#[cfg(any(target_os = "macos", test))] +const MAX_FDS: usize = 1 << 20; + fn unavailable() -> CandidateError { CandidateError::new( "unaudited_provider_config", @@ -33,7 +49,6 @@ pub(super) fn verify( file: FileInfo, vnode: libc::vnode_info_path, } - const MAX_FDS: usize = 4096; const VNODE_PATH_INFO: i32 = 2; if expected.is_empty() || expected.len() > 2 { return Err(unavailable()); @@ -50,27 +65,9 @@ pub(super) fn verify( Ok((metadata.dev(), metadata.ino())) }) .collect::, CandidateError>>()?; - let mut entries = vec![ - libc::proc_fdinfo { - proc_fd: 0, - proc_fdtype: 0 - }; - MAX_FDS - ]; - let bytes = std::mem::size_of_val(entries.as_slice()); - // SAFETY: initialized, aligned buffer holds exactly MAX_FDS native records. - let read = unsafe { - libc::proc_pidinfo( - process.pid, - libc::PROC_PIDLISTFDS, - 0, - entries.as_mut_ptr().cast(), - bytes as i32, - ) - }; - let count = record_count(read, bytes, std::mem::size_of::())?; + let entries = list_descriptors(process.pid)?; let mut found = vec![false; expected.len()]; - for entry in &entries[..count] { + for entry in &entries { if entry.proc_fdtype != libc::PROX_FDTYPE_VNODE as u32 { continue; } @@ -125,6 +122,62 @@ pub(super) fn verify( Ok(()) } +/// Complete descriptor table of `pid`, or a refusal. The buffer starts from the +/// kernel's sizing estimate and doubles while the reply fills it, up to `MAX_FDS`. +#[cfg(target_os = "macos")] +fn list_descriptors(pid: i32) -> Result, CandidateError> { + let size = std::mem::size_of::(); + // SAFETY: a null buffer with zero size only asks for the kernel's estimate. + let estimate = + unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDLISTFDS, 0, std::ptr::null_mut(), 0) }; + let mut capacity = initial_capacity(estimate, size); + loop { + let mut entries = vec![ + libc::proc_fdinfo { + proc_fd: 0, + proc_fdtype: 0 + }; + capacity + ]; + let bytes = std::mem::size_of_val(entries.as_slice()); + let bytes_arg = i32::try_from(bytes).map_err(|_| unavailable())?; + // SAFETY: initialized, aligned buffer holds exactly `capacity` native records. + let read = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDLISTFDS, + 0, + entries.as_mut_ptr().cast(), + bytes_arg, + ) + }; + match record_count(read, bytes, size)? { + Some(count) => { + entries.truncate(count); + return Ok(entries); + } + None => capacity = next_capacity(capacity).ok_or_else(unavailable)?, + } + } +} + +/// Records for the first listing: the kernel estimate (table size plus slack) +/// with headroom for descriptors opened before the listing, within the bounds. +#[cfg(any(target_os = "macos", test))] +fn initial_capacity(estimate: i32, size: usize) -> usize { + usize::try_from(estimate) + .ok() + .filter(|bytes| *bytes > 0) + .map_or(INITIAL_FDS, |bytes| bytes / size + 64) + .clamp(INITIAL_FDS, MAX_FDS) +} + +/// Doubled capacity after a full reply, or `None` once the ceiling was listed. +#[cfg(any(target_os = "macos", test))] +fn next_capacity(current: usize) -> Option { + (current < MAX_FDS).then(|| current.saturating_mul(2).min(MAX_FDS)) +} + #[cfg(not(target_os = "macos"))] pub(super) fn verify( _: &ProcessIdentity, @@ -134,12 +187,20 @@ pub(super) fn verify( Err(unavailable()) } +/// Complete record count, `None` for a reply that filled the buffer (possibly +/// truncated, so the caller must list again with more room), or a refusal. #[cfg(any(target_os = "macos", test))] -fn record_count(read: i32, capacity: usize, size: usize) -> Result { - if read <= 0 || read as usize >= capacity || read as usize % size != 0 { +fn record_count(read: i32, capacity: usize, size: usize) -> Result, CandidateError> { + let Some(read) = usize::try_from(read).ok().filter(|read| *read > 0) else { + return Err(unavailable()); + }; + if read % size != 0 || read > capacity { return Err(unavailable()); } - Ok(read as usize / size) + if read == capacity { + return Ok(None); + } + Ok(Some(read / size)) } #[cfg(any(target_os = "macos", test))] @@ -216,6 +277,7 @@ mod tests { std::mem::size_of_val(entries.as_slice()), std::mem::size_of::(), ) + .unwrap() .unwrap(); assert!( entries[..count] @@ -251,11 +313,98 @@ mod tests { } #[test] - fn incomplete_or_full_descriptor_buffers_are_refused() { - for read in [-1, 0, 7, 32, 40] { + fn incomplete_descriptor_buffers_are_refused_and_full_ones_grow() { + for read in [-1, 0, 7, 33, 40] { assert!(record_count(read, 32, 8).is_err()); } - assert_eq!(record_count(24, 32, 8).unwrap(), 3); + assert_eq!(record_count(24, 32, 8).unwrap(), Some(3)); + // A reply that fills the buffer may be truncated: list again, never trust it. + assert_eq!(record_count(32, 32, 8).unwrap(), None); + } + + #[test] + fn descriptor_capacity_follows_the_table_within_a_hard_ceiling() { + assert_eq!(initial_capacity(-1, 8), INITIAL_FDS); + assert_eq!(initial_capacity(0, 8), INITIAL_FDS); + assert_eq!(initial_capacity(100 * 8, 8), INITIAL_FDS); + assert_eq!(initial_capacity(5_200 * 8, 8), 5_264); + assert_eq!(initial_capacity(i32::MAX, 8), MAX_FDS); + assert_eq!(next_capacity(INITIAL_FDS), Some(2 * INITIAL_FDS)); + assert_eq!(next_capacity(MAX_FDS - 1), Some(MAX_FDS)); + assert_eq!(next_capacity(MAX_FDS), None); + let mut capacity = INITIAL_FDS; + let mut listings = 1; + while let Some(next) = next_capacity(capacity) { + capacity = next; + listings += 1; + } + assert_eq!(capacity, MAX_FDS); + assert!(listings <= 9, "growth is bounded"); + } + + /// HACK-1917: a provider sharing a project source holds a host descriptor for + /// every guest-open file. More than 4,096 unrelated descriptors (here with the + /// disks opened last, at the highest numbers) must not hide the owned disks. + #[cfg(target_os = "macos")] + #[test] + fn owned_disks_are_found_among_more_than_four_thousand_descriptors() { + use std::io::{BufRead, BufReader, Write}; + use std::process::{Command, Stdio}; + struct Child(std::process::Child); + impl Drop for Child { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let root = std::env::temp_dir().join(format!( + "hack-audit-many-{}-{}", + std::process::id(), + crate::next_test_scratch() + )); + std::fs::create_dir(&root).unwrap(); + let root = root.canonicalize().unwrap(); + let shared = root.join("shared-source-file"); + std::fs::write(&shared, b"source").unwrap(); + let paths = [root.join("storage.raw"), root.join("overlay.raw")]; + for path in &paths { + std::fs::write(path, b"owned").unwrap(); + } + let mut child = Child( + Command::new("/bin/zsh") + .args([ + "-fc", + "ulimit -n 8192 || exit 3; integer i=0; while (( i < 5000 )); do exec {fd}<\"$1\"; (( i++ )); done; exec {a}<\"$2\" {b}<\"$3\"; print ready; read -r answer", + "audit", + ]) + .arg(&shared) + .args(&paths) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut line = String::new(); + BufReader::new(child.0.stdout.take().unwrap()) + .read_line(&mut line) + .unwrap(); + assert_eq!(line, "ready\n"); + let pid = child.0.id() as i32; + assert!(list_descriptors(pid).unwrap().len() > 5_000); + let process = identity::observe(pid).unwrap(); + verify(&process, &process.executable, &paths).unwrap(); + let missing = root.join("missing.raw"); + std::fs::write(&missing, b"not open").unwrap(); + assert!(verify(&process, &process.executable, &[missing]).is_err()); + child + .0 + .stdin + .as_mut() + .unwrap() + .write_all(b"done\n") + .unwrap(); + child.0.wait().unwrap(); + std::fs::remove_dir_all(root).unwrap(); } #[cfg(target_os = "macos")] #[test] diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 3da1f022a..05c5fd28e 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -1042,6 +1042,32 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "native-startup-owner", + module: "Owner", + states: 32, + invariant: "NoUnownedResources", + action: "ExitUnconfirmed", + fields: [ + 'mapping = "none"', + 'phase = "exited"', + 'receipt = "authenticated"', + 'resources = "present"', + ], + additionalControls: [ + { + name: "refused-inspection", + negative: true, + invariant: "NoUnrecordedRefusal", + fields: [ + 'mapping = "none"', + 'phase = "exited"', + 'receipt = "refused"', + 'resources = "present"', + ], + }, + ], + }, { name: "stopped-pool-startup", module: "Resume", diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..eefabb134 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -88,6 +88,7 @@ import { const SHA = /^[a-f0-9]{64}$/; const OWNER = /^[a-f0-9]{32}$/; +const RUN = OWNER; const IMAGE = /^sha256:[a-f0-9]{64}$/; const BOOT_ID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/; export type NativeHttpsSelection = { @@ -608,7 +609,8 @@ const SAFE_STARTUP_DIAGNOSTIC = /** Preserve only the fixed diagnostic envelope emitted by the native clients. */ function cleanupUnconfirmed( startupFailure?: unknown, - nativeCode?: string + nativeCode?: string, + recordedRun?: string ): Error { const message = startupFailure instanceof Error ? startupFailure.message : ""; const diagnostic = SAFE_STARTUP_DIAGNOSTIC.exec(message); @@ -616,8 +618,12 @@ function cleanupUnconfirmed( startupFailure === undefined ? "" : ` Startup diagnostic: ${diagnostic ? `${diagnostic[1]} (${diagnostic[2]})` : "STARTUP_FAILURE (details omitted)"}.`; + const recorded = + recordedRun !== undefined && RUN.test(recordedRun) + ? ` The failed run ${recordedRun} is recorded as this project's run mapping; hack ps, hack down and hack doctor --native-cleanup inspect select it.` + : ""; return new ClosedCliError( - `Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying.${startup}${nativeCode ? ` Native exit diagnostic: ${nativeCode}.` : ""}` + `Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying.${startup}${nativeCode ? ` Native exit diagnostic: ${nativeCode}.` : ""}${recorded}` ); } function requireConfirmedCleanup( @@ -679,6 +685,106 @@ async function confirmForegroundCleanup(opts: { return { final, owned }; } +/** + * HACK-1917: a fresh startup that failed after the graph started resources, and + * whose cleanup this frontend cannot confirm, must not leave them without a + * project owner. Publish the authenticated receipt identity as the project's + * run mapping, with a frontend finalization lifetime exactly as readiness + * publishes one, so ps, down and `doctor --native-cleanup inspect` select the + * retained run instead of reporting the project as not started. Nothing is + * stopped or replayed here. A receipt that cannot be authenticated, an existing + * or retained mapping, or any refusal records nothing and keeps the original + * failure; the runtime journal still holds the graph for explicit recovery. + */ +async function recordFailedStartupOwner(opts: { + readonly final: unknown; + readonly run: string; + readonly namespace: string; + readonly planId: string; + readonly scope: NativeProjectRunScope; + readonly selection: Omit[0], "ready">; + readonly finalization: + | Awaited> + | undefined; + readonly deps: Pick; +}): Promise< + | { + readonly mapping: NativeProjectRun; + readonly finalization: Awaited< + ReturnType + >; + } + | undefined +> { + let owned: NativeProjectRun; + try { + owned = authoritative(opts.final, opts.run, opts.namespace, opts.planId); + } catch { + return undefined; + } + const mapping = selectedMapping({ ...opts.selection, ready: owned }); + try { + const finalization = + opts.finalization ?? + (await opts.deps.finalization({ + scope: opts.scope, + run: mapping, + httpsPort: null, + })); + await opts.deps.save({ ...opts.scope, run: mapping }); + return { mapping, finalization }; + } catch { + return undefined; + } +} + +type FailedStartupOwner = NonNullable< + Awaited> +>; +/** Confirm foreground cleanup; when it is unconfirmed, record an unowned fresh run. */ +async function confirmCleanupOrRecordOwner(opts: { + readonly confirm: Parameters[0]; + /** A published or retained mapping already owns the run; nothing is recorded. */ + readonly existing: NativeProjectRun | undefined; + readonly scope: NativeProjectRunScope; + readonly selection: Parameters< + typeof recordFailedStartupOwner + >[0]["selection"]; + readonly finalization: Parameters< + typeof recordFailedStartupOwner + >[0]["finalization"]; + readonly deps: Pick; + readonly onRecorded: (recorded: FailedStartupOwner) => void; +}): Promise>> { + try { + return await confirmForegroundCleanup(opts.confirm); + } catch (cleanupFailure) { + const recorded = opts.existing + ? undefined + : await recordFailedStartupOwner({ + final: opts.confirm.final, + run: opts.confirm.run, + namespace: opts.confirm.namespace, + planId: opts.confirm.planId, + scope: opts.scope, + selection: opts.selection, + finalization: opts.finalization, + deps: opts.deps, + }); + if (!recorded) { + throw cleanupFailure; + } + opts.onRecorded(recorded); + throw cleanupFailure instanceof NativePartialShutdownError + ? cleanupFailure + : cleanupUnconfirmed( + opts.confirm.startupFailure, + opts.confirm.nativeCode, + opts.confirm.run + ); + } +} + async function refusePendingFreshStart( restore: NativeProjectRun | undefined, scope: NativeProjectRunScope, @@ -1416,16 +1522,32 @@ export async function startNativeProject(opts: { nativeExitCode ); } - const confirmed = await confirmForegroundCleanup({ - final, - runtime: opts.runtime, - projectRoot: opts.scope.projectRoot, - run, - namespace: review.namespace, - planId: restore?.planId ?? review.planId, - invoke: deps.invoke, - startupFailure: serveFailure, - nativeCode: nativeExitCode, + const confirmed = await confirmCleanupOrRecordOwner({ + confirm: { + final, + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + run, + namespace: review.namespace, + planId: restore?.planId ?? review.planId, + invoke: deps.invoke, + startupFailure: serveFailure, + nativeCode: nativeExitCode, + }, + existing: mapping ?? expectedMapping, + scope: opts.scope, + selection: { + effectiveEnvName: input.effectiveEnvName, + profiles, + aws: selection.aws, + runtimeMetadata: metadata !== undefined, + }, + finalization, + deps, + onRecorded: (recorded) => { + mapping = recorded.mapping; + finalization = recorded.finalization; + }, }); final = confirmed.final; graphCleanupConfirmed = true; diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index dcc19f977..165b8acfc 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -597,6 +597,21 @@ drain/revocation; this model checks the later commit-before-new-grant boundary. ## Stopped retained-pool startup +`native-startup-owner/Owner.tla` checks project ownership after a foreground +Compose startup fails (HACK-1917). The positive control explores 32 states. +Readiness publishes the run mapping. A failure before readiness lets the graph +owner's retaining cleanup either complete or be refused; the frontend then makes +one read-only inspection that authenticates the exact receipt or is refused. +`NoUnownedResources` requires resources that outlive the frontend under an +authenticated receipt to have a published mapping; the `negative` control (no +recording on failure) violates it with an unowned started graph. `NoUnprovenOwner` +allows only readiness or an authenticated receipt to publish. The +`refused-inspection` control violates `NoUnrecordedRefusal` to keep the remaining +gap explicit: when inspection itself is refused, nothing can be authenticated and +only the runtime journal retains the graph. The model does not cover crashes +between finalization registration and mapping publication, concurrent starts, or +the later dead-owner recovery, which other models and runtime tests own. + `stopped-pool-startup/Resume.tla` checks one explicit startup from shape-valid stopped owner/retained receipt evidence. The positive control explores 71 states. Offline preflight records owner and receipt generations without granting graph diff --git a/tests/models/tla/native-startup-owner/Owner.tla b/tests/models/tla/native-startup-owner/Owner.tla new file mode 100644 index 000000000..1add58ebd --- /dev/null +++ b/tests/models/tla/native-startup-owner/Owner.tla @@ -0,0 +1,70 @@ +----------------------------- MODULE Owner ----------------------------- +\* HACK-1917: a foreground startup that fails after its graph started owned +\* resources must leave them owned by the project. Readiness publishes the run +\* mapping; a failure before readiness whose cleanup the frontend cannot confirm +\* publishes the authenticated receipt's identity instead. Recording never stops, +\* replays or retires anything. +EXTENDS TLC +CONSTANTS RecordFailedOwner +VARIABLES phase, resources, mapping, receipt, cleaned, readied +vars == <> +TypeOK == + /\ phase \in {"idle", "serving", "ready", "failed", "exited"} + /\ resources \in {"none", "present", "absent"} + /\ mapping \in {"none", "published"} + /\ receipt \in {"unknown", "authenticated", "refused"} + /\ cleaned \in BOOLEAN + /\ readied \in BOOLEAN +Init == /\ phase = "idle" /\ resources = "none" /\ mapping = "none" + /\ receipt = "unknown" /\ cleaned = FALSE /\ readied = FALSE +Serve == /\ phase = "idle" /\ phase' = "serving" + /\ UNCHANGED <> +\* Admission created owned containers, networks or relay state. +StartResources == /\ phase = "serving" /\ resources = "none" + /\ resources' = "present" + /\ UNCHANGED <> +Ready == /\ phase = "serving" /\ resources = "present" + /\ phase' = "ready" /\ mapping' = "published" /\ readied' = TRUE + /\ UNCHANGED <> +Fail == /\ phase \in {"serving", "ready"} /\ phase' = "failed" + /\ UNCHANGED <> +\* The graph owner's retaining cleanup either completes or is refused (for +\* example when the provider cannot be audited); refusal leaves resources. +OwnerCleanup == /\ phase = "failed" /\ receipt = "unknown" + /\ resources = "present" /\ resources' = "absent" + /\ UNCHANGED <> +\* The frontend's final read-only inspection authenticates the exact receipt +\* (run, owner, namespace, plan, complete journal) or is refused. +Inspect == /\ phase = "failed" /\ receipt = "unknown" + /\ receipt' \in {"authenticated", "refused"} + /\ UNCHANGED <> +Confirm == /\ phase = "failed" /\ receipt = "authenticated" + /\ resources # "present" /\ ~cleaned /\ cleaned' = TRUE + /\ UNCHANGED <> +ExitConfirmed == /\ phase = "failed" /\ cleaned /\ phase' = "exited" + /\ UNCHANGED <> +\* Unconfirmed cleanup: record the authenticated owner when no mapping exists. +ExitUnconfirmed == + /\ phase = "failed" /\ receipt # "unknown" /\ ~cleaned + /\ ~(receipt = "authenticated" /\ resources # "present") + /\ phase' = "exited" + /\ mapping' = IF RecordFailedOwner /\ mapping = "none" + /\ receipt = "authenticated" + THEN "published" ELSE mapping + /\ UNCHANGED <> +Next == Serve \/ StartResources \/ Ready \/ Fail \/ OwnerCleanup \/ Inspect + \/ Confirm \/ ExitConfirmed \/ ExitUnconfirmed +Spec == Init /\ [][Next]_vars +\* Resources that outlive the frontend under an authenticated receipt are owned. +NoUnownedResources == + (phase = "exited" /\ resources = "present" /\ receipt = "authenticated") + => mapping = "published" +\* Only readiness or an authenticated receipt may publish a mapping. +NoUnprovenOwner == mapping = "published" => (readied \/ receipt = "authenticated") +\* A confirmed cleanup never leaves resources behind. +NoFalseCleanup == cleaned => resources # "present" +\* Coverage gap control: with a refused inspection nothing can be authenticated, +\* so the runtime journal alone retains the graph. Expected to be violated. +NoUnrecordedRefusal == + (phase = "exited" /\ resources = "present") => mapping = "published" +============================================================================= diff --git a/tests/models/tla/native-startup-owner/negative.cfg b/tests/models/tla/native-startup-owner/negative.cfg new file mode 100644 index 000000000..00c61ab73 --- /dev/null +++ b/tests/models/tla/native-startup-owner/negative.cfg @@ -0,0 +1,7 @@ +CONSTANTS RecordFailedOwner = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnownedResources + NoUnprovenOwner + NoFalseCleanup +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-startup-owner/positive.cfg b/tests/models/tla/native-startup-owner/positive.cfg new file mode 100644 index 000000000..c35b5867c --- /dev/null +++ b/tests/models/tla/native-startup-owner/positive.cfg @@ -0,0 +1,7 @@ +CONSTANTS RecordFailedOwner = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnownedResources + NoUnprovenOwner + NoFalseCleanup +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/native-startup-owner/refused-inspection.cfg b/tests/models/tla/native-startup-owner/refused-inspection.cfg new file mode 100644 index 000000000..7a478e430 --- /dev/null +++ b/tests/models/tla/native-startup-owner/refused-inspection.cfg @@ -0,0 +1,8 @@ +CONSTANTS RecordFailedOwner = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK + NoUnownedResources + NoUnprovenOwner + NoFalseCleanup + NoUnrecordedRefusal +CHECK_DEADLOCK FALSE diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..2068a5518 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -20,9 +20,14 @@ import { waitNativeProjectFinalization, } from "../src/backends/native-project-finalization.ts"; import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import { nativeProjectPs } from "../src/backends/native-project-observe.ts"; import { preflightNativeRestart } from "../src/backends/native-project-restart-preflight.ts"; import { selectNativeRetainedImages } from "../src/backends/native-project-retained-images.ts"; -import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; +import { + loadNativeProjectRun, + type NativeProjectRun, + saveNativeProjectRun, +} from "../src/backends/native-project-run.ts"; import { NATIVE_CONFIGURATION_REFUSAL, NATIVE_SHARED_SOURCE_REFUSAL, @@ -442,6 +447,158 @@ test("failed startup recovers exact pending cleanup and still reports the startu expect(events).not.toContain("save"); expect(events.at(-1)).toBe("cleanup"); }); +/** A started graph whose foreground failed before Ready and could not clean up. */ +function failedStartupFixture( + fixtureValue: Awaited>, + receipt: { readonly journalIncomplete?: boolean } = {} +) { + const { opts, events } = fixtureValue; + const invoke = opts.dependencies.invoke!; + const selected = { run: "" }; + opts.dependencies.serve = async (request) => { + selected.run = request.run; + throw new Error( + "Native graph startup was interrupted or failed (unaudited_provider_config); inspect owned state before retrying." + ); + }; + opts.dependencies.save = async (request) => { + events.push("save"); + await saveNativeProjectRun(request); + }; + opts.dependencies.finalization = async (request) => { + events.push("finalization"); + const lifetime = await beginNativeProjectFinalization(request); + return { + ...lifetime, + complete: async () => { + events.push("finalization-complete"); + await lifetime.complete(); + }, + }; + }; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "inspect") { + events.push("graph inspect"); + return { + journal_incomplete: receipt.journalIncomplete === true, + receipt: { + run: selected.run, + owner: "c".repeat(32), + namespace: "b".repeat(64), + plan_id: "a".repeat(64), + phase: "ready-observed", + relay_startup: { control_only: true }, + resources: { + "container:web": { + kind: "container", + key: "web", + id: "f".repeat(64), + phase: "started", + }, + }, + }, + observations: { "container:web": { state: "running" } }, + }; + } + if (request.args[1] === "owner-status") { + throw new Error("synthetic dead foreground owner"); + } + return await invoke(request); + }; + return selected; +} + +test("a post-start failure whose cleanup is unconfirmed records the run for ps and down", async () => { + const value = await fixture(false); + const { opts, events } = value; + const selected = failedStartupFixture(value); + const failure = await startNativeProject(opts).catch( + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(ClosedCliError); + const message = (failure as Error).message; + expect(message).toContain("cleanup is unconfirmed"); + expect(message).toContain("(unaudited_provider_config)"); + expect(message).toContain( + `The failed run ${selected.run} is recorded as this project's run mapping` + ); + // Readiness never happened: no hooks ran, and nothing claims finalization. + expect(events).not.toContain("ready"); + expect(events).not.toContain("remove"); + expect(events).not.toContain("finalization-complete"); + expect(events.indexOf("finalization")).toBeGreaterThan(-1); + expect(events.indexOf("finalization")).toBeLessThan(events.indexOf("save")); + expect(events.at(-1)).toBe("cleanup"); + const mapping = { + run: selected.run, + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }; + expect(await loadNativeProjectRun(opts.scope)).toEqual(mapping); + // ps selects the recorded run and reports its stale owner instead of "not started". + const ps = await nativeProjectPs({ + runtime: opts.runtime, + scope: opts.scope, + invoke: opts.dependencies.invoke, + }); + expect(ps).toMatchObject({ + status: "owner_unconfirmed", + run: selected.run, + phase: "ready-observed", + }); + expect(ps.items).toEqual([ + expect.objectContaining({ service: "web", state: "running" }), + ]); +}); + +test("a post-start failure with an unauthenticated receipt records no owner", async () => { + const value = await fixture(false); + const { opts, events } = value; + failedStartupFixture(value, { journalIncomplete: true }); + const failure = await startNativeProject(opts).catch( + (error: unknown) => error + ); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).not.toContain("is recorded"); + expect(events).not.toContain("finalization"); + expect(events).not.toContain("save"); + expect(await loadNativeProjectRun(opts.scope)).toBeNull(); +}); + +test("a retained run's post-start failure keeps its existing mapping unchanged", async () => { + const { opts, events, saved } = await retainedFinalizationFixture(); + await acknowledgeRetainedFrontend(opts.scope, saved); + const invoke = opts.dependencies.invoke!; + opts.dependencies.serve = async () => { + throw new Error( + "Native graph startup failed (engine_rejected); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if ( + request.args[1] === "inspect" && + events.includes("graph dependency-plan") + ) { + return { + ...stoppedGraph(saved), + observations: { "container:web": { state: "running" } }, + }; + } + return await invoke(request); + }; + opts.dependencies.save = async () => { + events.push("save"); + }; + await expect(startNativeProject(opts)).rejects.toThrow( + "cleanup is unconfirmed" + ); + expect(events).not.toContain("save"); +}); + test("foreground partial shutdown is reported without selecting failed-start recovery", async () => { const { opts, events } = await fixture(); const invoke = opts.dependencies.invoke!; From a40260c742c2fcaee5797870e79d426f1cce09a2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:15:17 -0500 Subject: [PATCH 082/138] feat(native): report failed services' last output on startup failure When a native service fails during startup, cleanup removes its container and logs, and only the exit code survived. The foreground owner now reads a bounded tail of each failed service before that cleanup (the recorded startup failure first, then other failed services by name, at most three; Docker's last 64 lines, at most 4 KiB of whole lines per stream) and sends it only in the foreground failure reply on stderr. It is never written to the receipt, journal or any file, so persisted failure evidence stays value-free. The frontend accepts only the bounded versioned contract, redacts every managed environment value of every service (substring match for values of four or more characters, JSON-escaped and per-line forms, whole-token match for shorter values, terminal escapes removed) and appends the redacted tail to the startup failure report. It never saves the tail or the report. CandidateError diagnostics are boxed so error results stay small. Refs HACK-1918 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 27 ++ packages/runtime-core/src/error.rs | 34 +- .../src/error_stop_diagnostics.rs | 2 +- packages/runtime-core/src/provider/engine.rs | 20 ++ .../src/provider/graph/foreground.rs | 3 + .../runtime-core/src/provider/graph/mod.rs | 2 + .../src/provider/graph/native/foreground.rs | 4 +- .../src/provider/graph/startup_logs.rs | 334 ++++++++++++++++++ src/backends/native-project-process.ts | 32 +- src/backends/native-project-start.ts | 92 +++-- src/backends/native-runtime-client.ts | 105 ++++-- src/backends/native-startup-logs.ts | 186 ++++++++++ tests/native-project-process.test.ts | 70 ++++ tests/native-project-start.test.ts | 68 ++++ tests/native-startup-logs.test.ts | 115 ++++++ 15 files changed, 1027 insertions(+), 67 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/startup_logs.rs create mode 100644 src/backends/native-startup-logs.ts create mode 100644 tests/native-startup-logs.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 3e80a518a..cee654573 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1266,6 +1266,33 @@ exit observation before cleanup, including exit code zero. A successful no command, environment or application log values. Older bundles may refuse the new zero-exit evidence; use the owning bundle for diagnosis and retaining cleanup. +Startup cleanup removes a failed service's container and its logs. Before that +cleanup, the foreground owner reads a bounded tail of each failed service: the +recorded startup failure first, then other failed services by name, at most three. +Each tail holds Docker's last 64 lines of the service, with at most 4 KiB of +whole lines per stream (stdout and stderr), so at most 8 KiB per service. A line +is never cut; a single line over the bound is omitted and the report says lines +were omitted. The tail travels only in the foreground failure reply. The runtime +never writes it to the receipt, journal or any other file, so persisted failure +evidence stays value-free. + +The frontend still holds the managed environment values it delivered. It redacts +all of them, for every service, from the tail before printing it under the +startup failure report, and it never saves the tail or the report: + +- Terminal escape sequences are removed first. +- A value of four or more characters is replaced as `[REDACTED]` wherever it + occurs, even inside a longer word. Its JSON-escaped form and each line of a + multi-line value that is four or more characters long, with and without + surrounding whitespace, are replaced too. +- A value of one to three characters is replaced only as a whole token: not next + to a letter, digit or underscore. Values like `1` or `on` therefore do not + shred unrelated output. +- Other control characters except tab and newline are shown as U+FFFD. + +Redaction covers only managed values. Application output can still hold other +sensitive data, such as values a service read from its own files. + A Docker container can remain `created` with a nonzero error when its first start fails before a process runs. Retaining shutdown accepts this state only with verified ownership, no running PID, no pause/restart/dead state and no OOM flag. diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index d50acc6a4..2e9f3132b 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -1,4 +1,6 @@ -//! Shared value-free error contract for host runtime and guest adapters. +//! Shared error contract for host runtime and guest adapters. Every field is +//! value-free except the transient `startup_logs` of a foreground startup failure +//! reply, which is application output and is never persisted. use serde::{Deserialize, Serialize}; /// Value-free stop detail. Service names are admitted graph identifiers, never @@ -29,14 +31,41 @@ pub struct StopFailuresDiagnostic { pub failures: Vec, } +/// Bounded application output of one failed service, captured before startup +/// cleanup removes its container. It may contain managed values, so it is a +/// transient part of the foreground failure reply only: never journaled, written +/// to a receipt or copied into persisted failure evidence. Consumers must redact +/// managed values before display. Each stream holds whole lines only. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StartupLogTail { + pub service: String, + pub stdout: String, + pub stderr: String, + /// Some of the requested tail lines were omitted to stay within the byte bound. + pub truncated: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StartupLogsDiagnostic { + pub version: u8, + pub services: Vec, +} + #[derive(Debug, Serialize)] pub struct CandidateError { pub code: &'static str, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub cause_code: Option, + /// Boxed, like `startup_logs`, to keep every `Result<_, CandidateError>` small. + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_failures: Option>, + /// Transient application output for a failed foreground startup; see + /// [`StartupLogTail`]. Never persist an error carrying it. #[serde(skip_serializing_if = "Option::is_none")] - pub stop_failures: Option, + pub startup_logs: Option>, } impl CandidateError { @@ -46,6 +75,7 @@ impl CandidateError { message: message.into(), cause_code: None, stop_failures: None, + startup_logs: None, } } diff --git a/packages/runtime-core/src/error_stop_diagnostics.rs b/packages/runtime-core/src/error_stop_diagnostics.rs index 5ec14d0d3..28b1a8230 100644 --- a/packages/runtime-core/src/error_stop_diagnostics.rs +++ b/packages/runtime-core/src/error_stop_diagnostics.rs @@ -23,7 +23,7 @@ impl StopFailuresDiagnostic { impl CandidateError { pub(crate) fn with_stop_failures(mut self, detail: StopFailuresDiagnostic) -> Self { if detail.valid() { - self.stop_failures = Some(detail); + self.stop_failures = Some(Box::new(detail)); } self } diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index e0299cd9c..8b1faad36 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -397,6 +397,26 @@ impl<'a> Engine<'a> { } self.logs_with_tail(id, &tail.to_string()) } + /// Raw multiplexed frames of a container's last `tail` lines, within the 4 MiB + /// response bound. Unlike [`Self::logs_tail`] this keeps the end of each stream; + /// the caller decodes and bounds it and must never persist the bytes. + #[cfg(target_os = "macos")] + pub(super) fn logs_tail_frames(&self, id: &str, tail: u16) -> Result, CandidateError> { + if !(1..=1000).contains(&tail) { + return Err(failure("Container log tail must be from 1 to 1000.")); + } + if id.len() != 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(failure("Invalid container log identity.")); + } + self.guest.verify()?; + let bytes = self.transport.request_bytes( + Method::GET, + &format!("/v1.53/containers/{id}/logs?stdout=true&stderr=true&tail={tail}"), + None, + )?; + self.guest.verify()?; + Ok(bytes) + } fn logs_with_tail( &self, id: &str, diff --git a/packages/runtime-core/src/provider/graph/foreground.rs b/packages/runtime-core/src/provider/graph/foreground.rs index d4c64b761..a691a75a0 100644 --- a/packages/runtime-core/src/provider/graph/foreground.rs +++ b/packages/runtime-core/src/provider/graph/foreground.rs @@ -266,6 +266,9 @@ fn serve_input<'a>( let receipt = match attempt { Ok(receipt) => receipt, Err(error) => { + // Read failed services' bounded output before cleanup removes their + // containers. It travels only in this reply and is never persisted. + let error = error.with_startup_logs(super::startup_logs::capture(candidate, &run_id)); if fresh_generation.is_some() && !runtime.admission_started() { let engine = Engine::connect_cleanup(candidate)?; let (retained, root) = super::load(candidate, &engine, &run_id)?; diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 88667572b..518cb0bbe 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -63,6 +63,8 @@ pub use dependency_hosts::dependency_address; pub mod foreground; mod startup; mod startup_failure; +#[cfg(any(target_os = "macos", test))] +mod startup_logs; #[cfg(target_os = "macos")] pub use startup::{Dependency, HostRelayRuntime, RefreshPolicy}; mod archive; diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index 21b87ce94..a25c3d255 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -397,7 +397,7 @@ pub fn serve_with_storage_tool( }; let result = result.unwrap_or_else(|error| Outcome::Refused { code: error.code.into(), - stop_failures: error.stop_failures, + stop_failures: error.stop_failures.map(|detail| *detail), }); let cleaned = matches!(result, Outcome::Cleaned { .. }); let reply = Reply { @@ -559,7 +559,7 @@ fn request_selected( } = reply.result { let mut error = refused().with_cause_code(code); - error.stop_failures = stop_failures; + error.stop_failures = stop_failures.map(Box::new); return Err(error); } serde_json::to_value(reply).map_err(|_| refused()) diff --git a/packages/runtime-core/src/provider/graph/startup_logs.rs b/packages/runtime-core/src/provider/graph/startup_logs.rs new file mode 100644 index 000000000..6411114fd --- /dev/null +++ b/packages/runtime-core/src/provider/graph/startup_logs.rs @@ -0,0 +1,334 @@ +//! Transient bounded output of failed services for a foreground startup failure. +//! +//! Startup cleanup removes failed containers and their logs, and persisted failure +//! evidence ([`super::startup_failure`]) stays value-free. Before that cleanup the +//! foreground owner reads a bounded tail of each failed service and attaches it to +//! its failure reply on stderr only. The tail is never journaled, written to a +//! receipt or copied into any file: application output may hold managed values, +//! which the frontend redacts before display. +//! +//! Bounds: at most [`MAX_SERVICES`] services, Docker's last [`MAX_LINES`] lines of +//! each (stdout and stderr together), and [`MAX_STREAM_BYTES`] of whole lines per +//! stream. A line is never cut, so a value cannot be split across the boundary +//! where a redaction would miss it; a line longer than the byte bound is omitted. +use super::{Receipt, hex}; +use crate::{ + CandidateError, + error::{StartupLogTail, StartupLogsDiagnostic}, + project::execution::Observation, +}; +use std::collections::BTreeMap; + +/// Failed services reported in one reply; the recorded startup failure comes first. +pub(super) const MAX_SERVICES: usize = 3; +/// Lines requested from the engine for each service, both streams together. +pub(super) const MAX_LINES: u16 = 64; +/// UTF-8 bytes of whole lines kept for each stream, so at most 8 KiB per service. +pub(super) const MAX_STREAM_BYTES: usize = 4 * 1024; + +impl CandidateError { + /// Attach a transient tail. Only the foreground failure reply may carry it. + pub(crate) fn with_startup_logs(mut self, logs: Option) -> Self { + if let Some(logs) = logs.filter(valid) { + self.startup_logs = Some(Box::new(logs)); + } + self + } +} + +fn valid(logs: &StartupLogsDiagnostic) -> bool { + logs.version == 1 + && (1..=MAX_SERVICES).contains(&logs.services.len()) + && logs.services.iter().enumerate().all(|(index, tail)| { + service_name(&tail.service) + && !logs.services[..index] + .iter() + .any(|other| other.service == tail.service) + && tail.stdout.len() <= MAX_STREAM_BYTES + && tail.stderr.len() <= MAX_STREAM_BYTES + }) +} + +fn service_name(name: &str) -> bool { + (1..=128).contains(&name.len()) + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) +} + +/// Select up to [`MAX_SERVICES`] owned containers: the recorded startup failure +/// (including a required listener's zero exit), then other failed observations in +/// service-name order. Returns `(service, container ID)` pairs. +pub(super) fn select( + receipt: &Receipt, + observed: &BTreeMap, +) -> Vec<(String, String)> { + let owned = |service: &str| { + receipt + .resources + .get(&format!("container:{service}")) + .and_then(|resource| resource.id.clone()) + .filter(|id| hex(id, 64) && service_name(service)) + }; + let mut selected: Vec<(String, String)> = Vec::new(); + if let Some(failure) = &receipt.startup_failure + && observed.contains_key(&failure.service) + && owned(&failure.service).as_deref() == Some(failure.container.as_str()) + { + selected.push((failure.service.clone(), failure.container.clone())); + } + for (service, observation) in observed { + if selected.len() >= MAX_SERVICES { + break; + } + if observation.failed() && !selected.iter().any(|(name, _)| name == service) { + if let Some(id) = owned(service) { + selected.push((service.clone(), id)); + } + } + } + selected.truncate(MAX_SERVICES); + selected +} + +/// Decode Docker's multiplexed log frames and bound each stream. Malformed frames +/// yield no tail rather than a partial guess. +pub(super) fn tail(service: &str, frames: &[u8]) -> Option { + let (stdout, stderr) = demux(frames)?; + let (stdout, out_truncated) = bound(&stdout); + let (stderr, err_truncated) = bound(&stderr); + Some(StartupLogTail { + service: service.into(), + stdout, + stderr, + truncated: out_truncated || err_truncated, + }) +} + +fn demux(mut frames: &[u8]) -> Option<(Vec, Vec)> { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + while !frames.is_empty() { + let header = frames.get(..8)?; + if ![1, 2].contains(&header[0]) || header[1..4] != [0, 0, 0] { + return None; + } + let size = u32::from_be_bytes(header[4..8].try_into().ok()?) as usize; + let payload = frames.get(8..8usize.checked_add(size)?)?; + if header[0] == 1 { + stdout.extend_from_slice(payload); + } else { + stderr.extend_from_slice(payload); + } + frames = &frames[8 + size..]; + } + Some((stdout, stderr)) +} + +/// Keep the last whole lines within [`MAX_LINES`] and [`MAX_STREAM_BYTES`]. A final +/// line without a newline is complete: the process wrote it before exiting. +fn bound(bytes: &[u8]) -> (String, bool) { + let text = String::from_utf8_lossy(bytes); + let body = text.strip_suffix('\n').unwrap_or(&text); + if body.is_empty() { + return (String::new(), false); + } + let mut kept: Vec<&str> = Vec::new(); + let mut size = 0; + let mut truncated = false; + for line in body.rsplit('\n') { + let line = line.strip_suffix('\r').unwrap_or(line); + let cost = line.len() + usize::from(!kept.is_empty()); + if kept.len() == usize::from(MAX_LINES) || size + cost > MAX_STREAM_BYTES { + truncated = true; + break; + } + kept.push(line); + size += cost; + } + kept.reverse(); + (kept.join("\n"), truncated) +} + +/// Read the tails of failed services of `run` before cleanup removes them. Every +/// failure is ignored: the original startup error and cleanup stay authoritative. +/// The engine lease is released before returning. +#[cfg(target_os = "macos")] +pub(super) fn capture(candidate: &crate::Candidate, run: &str) -> Option { + let engine = super::Engine::connect_cleanup(candidate).ok()?; + let (receipt, _) = super::load(candidate, &engine, run).ok()?; + let mut observed = BTreeMap::new(); + for (key, resource) in &receipt.resources { + let Some(service) = key.strip_prefix("container:") else { + continue; + }; + if resource.kind != super::Kind::Container || resource.id.is_none() { + continue; + } + // Inspection rechecks owned labels and immutable identity before any read. + if let Ok(Some(value)) = super::inspect_resource(&engine, &receipt, resource) + && let Ok(observation) = super::observation(&value) + { + observed.insert(service.to_owned(), observation); + } + } + let services: Vec<_> = select(&receipt, &observed) + .into_iter() + .filter_map(|(service, id)| { + let frames = engine.logs_tail_frames(&id, MAX_LINES).ok()?; + tail(&service, &frames) + }) + .collect(); + (!services.is_empty()).then_some(StartupLogsDiagnostic { + version: 1, + services, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn frame(stream: u8, payload: &str) -> Vec { + let mut bytes = vec![stream, 0, 0, 0]; + bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + bytes.extend_from_slice(payload.as_bytes()); + bytes + } + fn receipt() -> Receipt { + let container = |key: &str, id: char| json!({"key":key,"kind":"container","name":format!("hkg-{key}"),"id":id.to_string().repeat(64),"phase":"started","image":format!("sha256:{}", "f".repeat(64))}); + serde_json::from_value(json!({ + "version":1,"run":"a".repeat(32),"owner":"b".repeat(32),"namespace":"c".repeat(64), + "plan_id":"d".repeat(64),"phase":"failed-retained", + "readiness":{"api":"started","db":"healthy","init":"completed","web":"started","worker":"started"}, + "resources":{ + "container:api":container("api", '1'), + "container:db":container("db", '2'), + "container:init":container("init", '3'), + "container:web":container("web", '4'), + "container:worker":container("worker", '5') + } + })) + .unwrap() + } + + #[test] + fn reply_carries_only_the_last_whole_lines_within_bounds() { + let mut frames = Vec::new(); + for line in 0..100 { + frames.extend(frame(1, &format!("out {line}\n"))); + } + frames.extend(frame(2, "first error\r\nfinal error without newline")); + let tail = tail("web", &frames).unwrap(); + let lines: Vec<_> = tail.stdout.lines().collect(); + assert_eq!(lines.len(), usize::from(MAX_LINES)); + assert_eq!(lines[0], "out 36"); + assert_eq!(lines.last(), Some(&"out 99")); + assert_eq!(tail.stderr, "first error\nfinal error without newline"); + assert!(tail.truncated); + + let long = format!("{}\n", "x".repeat(1000)); + let frames: Vec = (0..10).flat_map(|_| frame(1, &long)).collect(); + let tail = super::tail("web", &frames).unwrap(); + assert!(tail.stdout.len() <= MAX_STREAM_BYTES); + assert_eq!(tail.stdout.lines().count(), 4); + assert!(tail.stdout.lines().all(|line| line.len() == 1000)); + assert!(tail.truncated); + + // A line beyond the byte bound is omitted whole, never cut. + let tail = super::tail("web", &frame(2, &"y".repeat(MAX_STREAM_BYTES + 1))).unwrap(); + assert_eq!(tail.stderr, ""); + assert!(tail.truncated); + + let error = CandidateError::new("graph_startup_application_failed", "Startup failed.") + .with_startup_logs(Some(StartupLogsDiagnostic { + version: 1, + services: vec![super::tail("web", &frame(2, "synthetic failure\n")).unwrap()], + })); + let reply: serde_json::Value = serde_json::to_value(&error).unwrap(); + assert_eq!(reply["code"], "graph_startup_application_failed"); + assert_eq!( + reply["startup_logs"], + json!({"version":1,"services":[{"service":"web","stdout":"","stderr":"synthetic failure","truncated":false}]}) + ); + } + + #[test] + fn malformed_frames_and_unbounded_replies_are_refused() { + assert!(tail("web", &[3, 0, 0, 0, 0, 0, 0, 0]).is_none()); + assert!(tail("web", &[1, 0, 0, 0, 0, 0, 0, 9, b'x']).is_none()); + assert!(tail("web", &[1, 0, 0]).is_none()); + assert_eq!(tail("web", &[]).unwrap().stdout, ""); + let one = |service: &str, stdout: String| StartupLogTail { + service: service.into(), + stdout, + stderr: String::new(), + truncated: false, + }; + for services in [ + vec![], + vec![one("web", "x".repeat(MAX_STREAM_BYTES + 1))], + vec![one("../web", String::new())], + vec![one("web", String::new()), one("web", String::new())], + (0..=MAX_SERVICES) + .map(|n| one(&format!("s{n}"), String::new())) + .collect(), + ] { + let error = CandidateError::new("graph_budget", "Startup failed.").with_startup_logs( + Some(StartupLogsDiagnostic { + version: 1, + services, + }), + ); + assert!(error.startup_logs.is_none()); + } + assert!( + !serde_json::to_string(&CandidateError::new("graph_budget", "Startup failed.")) + .unwrap() + .contains("startup_logs") + ); + } + + #[test] + fn recorded_failure_leads_a_bounded_failed_selection() { + let mut receipt = receipt(); + super::super::startup_failure::record(&mut receipt, "web", Observation::Exited { code: 0 }) + .unwrap(); + let observed = BTreeMap::from([ + ("api".to_string(), Observation::Exited { code: 3 }), + ("db".to_string(), Observation::Dead), + ("init".to_string(), Observation::Exited { code: 0 }), + ("web".to_string(), Observation::Exited { code: 0 }), + ("worker".to_string(), Observation::Exited { code: 1 }), + ]); + let selected = select(&receipt, &observed); + assert_eq!( + selected, + vec![ + ("web".to_string(), "4".repeat(64)), + ("api".to_string(), "1".repeat(64)), + ("db".to_string(), "2".repeat(64)), + ] + ); + // A completed initializer and a removed container are not failures to tail. + let observed = BTreeMap::from([("init".to_string(), Observation::Exited { code: 0 })]); + assert!(select(&receipt, &observed).is_empty()); + } + + #[test] + fn persisted_failure_evidence_cannot_hold_application_output() { + let mut receipt = receipt(); + super::super::startup_failure::record(&mut receipt, "api", Observation::Exited { code: 3 }) + .unwrap(); + let mut persisted = serde_json::to_value(&receipt).unwrap(); + assert!(!persisted.to_string().contains("startup_logs")); + persisted["startup_failure"]["stderr"] = json!("synthetic output"); + assert!(serde_json::from_value::(persisted.clone()).is_err()); + persisted["startup_failure"] + .as_object_mut() + .unwrap() + .remove("stderr"); + persisted["startup_logs"] = json!({"version":1,"services":[]}); + assert!(serde_json::from_value::(persisted).is_err()); + } +} diff --git a/src/backends/native-project-process.ts b/src/backends/native-project-process.ts index 50b3bf91b..c362af16e 100644 --- a/src/backends/native-project-process.ts +++ b/src/backends/native-project-process.ts @@ -14,10 +14,11 @@ import { invokeNativeRuntime, NATIVE_EXIT_DRAIN_BOUND_MS, type NativeRuntimeSelection, - readNativeFailureCodes, + readNativeStartupFailure, rethrowNativeInputFailure, writeNativePrivateInput, } from "./native-runtime-client.ts"; +import type { NativeStartupLogTail } from "./native-startup-logs.ts"; const RUN = /^[a-f0-9]{32}$/; const MAX_OUTPUT = 16 * 1024 * 1024; @@ -38,6 +39,13 @@ type ProcessOptions = { readonly startupTimeoutMs: number; readonly signal?: AbortSignal; readonly onExitDiagnostic?: (diagnostic: NativeExitDiagnostic) => void; + /** + * Failed services' bounded output from a startup failure reply, before the + * failure is thrown. It may contain managed values: the callback must redact + * them before retaining or printing anything and must never persist it. + * Without a callback the output is discarded. + */ + readonly onStartupLogs?: (logs: readonly NativeStartupLogTail[]) => void; /** Finite frontend hooks intercept the first stop; failed hooks retain the live owner. */ readonly beforeStop?: () => Promise; /** No-host frontend stop reuses this original foreground owner; no hook permit. */ @@ -339,7 +347,7 @@ async function serveGraphProcess( opts.exitDrainBoundMs ?? NATIVE_EXIT_DRAIN_BOUND_MS ); }); - const failureCodes = readNativeFailureCodes(child.stderr, drain.signal); + const failureCodes = readNativeStartupFailure(child.stderr, drain.signal); const failureCode = failureCodes.then((failure) => failure?.code); let ready = false; let canceled = false; @@ -440,6 +448,7 @@ async function serveGraphProcess( }); const code = await child.exited; const failure = await failureCode; + await reportStartupLogs(opts, ready, failureCodes); rethrowNativeInputFailure(inputFailure, { interrupted: timedOut || canceled, code, @@ -485,6 +494,25 @@ async function serveGraphProcess( } } +/** Pass a failed startup's bounded output to the caller; ready owners report none. */ +async function reportStartupLogs( + opts: ProcessOptions, + ready: boolean, + failure: Promise< + { readonly startupLogs?: readonly NativeStartupLogTail[] } | undefined + > +): Promise { + const logs = (await failure)?.startupLogs; + if (ready || !logs) { + return; + } + try { + opts.onStartupLogs?.(logs); + } catch { + // Observation must never replace the startup result or cleanup authority. + } +} + /** An attached finite-hook owner must not retire while its captured detached group is present. * Unknown permission or lifetime is a refusal, never a signal to a later/reused group. */ diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 57a3d7105..045de08be 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -85,6 +85,7 @@ import { type NativeRuntimeMetadataSelection, verifyNativeRuntimeMetadataRoutes, } from "./native-runtime-metadata.ts"; +import { formatNativeStartupLogReport } from "./native-startup-logs.ts"; const SHA = /^[a-f0-9]{64}$/; const OWNER = /^[a-f0-9]{32}$/; @@ -603,6 +604,17 @@ function environmentDelivery( ), }; } +/** + * Append the redacted output of failed services to the final startup failure + * report. Applied after diagnostics that parse the original closed message; the + * error keeps its class so recovery handling is unchanged. + */ +function withStartupLogReport(error: unknown, report: string): unknown { + if (report !== "" && error instanceof Error) { + error.message = `${error.message}\n${report}`; + } + return error; +} const SAFE_STARTUP_DIAGNOSTIC = /^(Native (?:graph startup (?:failed|was interrupted or failed)|HTTPS verification failed)) \(([A-Za-z0-9_]{1,80})\); (?:inspect owned state before retrying\.|peer values omitted\.)$/; /** Preserve only the fixed diagnostic envelope emitted by the native clients. */ @@ -1271,6 +1283,8 @@ export async function startNativeProject(opts: { let code = 1; let serveFailure: unknown; let nativeExitCode: string | undefined; + // Redacted at receipt; the raw tail is never retained or persisted. + let startupLogReport = ""; try { code = await deps.serve({ runtime: opts.runtime, @@ -1306,6 +1320,12 @@ export async function startNativeProject(opts: { onExitDiagnostic: (diagnostic) => { nativeExitCode = diagnostic.nativeCode; }, + onStartupLogs: (logs) => { + startupLogReport = formatNativeStartupLogReport({ + logs, + managedEnvironment: input.managedEnvironment, + }); + }, onReady: async () => { const readyMapping = authoritative( await invokeInspect(), @@ -1407,42 +1427,46 @@ export async function startNativeProject(opts: { } finally { delivery.payload?.fill(0); } - let final: unknown; try { - final = await invokeInspect(); - } catch (inspectionFailure) { - throw cleanupUnconfirmed( - serveFailure ?? inspectionFailure, - nativeExitCode - ); + let final: unknown; + try { + final = await invokeInspect(); + } catch (inspectionFailure) { + throw cleanupUnconfirmed( + serveFailure ?? inspectionFailure, + nativeExitCode + ); + } + const confirmed = await confirmForegroundCleanup({ + final, + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + run, + namespace: review.namespace, + planId: restore?.planId ?? review.planId, + invoke: deps.invoke, + startupFailure: serveFailure, + nativeCode: nativeExitCode, + }); + final = confirmed.final; + graphCleanupConfirmed = true; + await retireRemovedMapping({ + final, + mapping, + owned: confirmed.owned, + scope: opts.scope, + remove: deps.remove, + }); + return foregroundExitCode({ + aborted: controller.signal.aborted, + interruptedExit, + failure: serveFailure, + infrastructureFailure: httpsFailure, + code, + }); + } catch (error) { + throw withStartupLogReport(error, startupLogReport); } - const confirmed = await confirmForegroundCleanup({ - final, - runtime: opts.runtime, - projectRoot: opts.scope.projectRoot, - run, - namespace: review.namespace, - planId: restore?.planId ?? review.planId, - invoke: deps.invoke, - startupFailure: serveFailure, - nativeCode: nativeExitCode, - }); - final = confirmed.final; - graphCleanupConfirmed = true; - await retireRemovedMapping({ - final, - mapping, - owned: confirmed.owned, - scope: opts.scope, - remove: deps.remove, - }); - return foregroundExitCode({ - aborted: controller.signal.aborted, - interruptedExit, - failure: serveFailure, - infrastructureFailure: httpsFailure, - code, - }); } finally { httpsClosing = true; try { diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 1a64ca6e0..67d52f89d 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -2,6 +2,11 @@ import { isAbsolute } from "node:path"; import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { nativeAuthoredExecOptions } from "./native-authored-exec-options.ts"; +import { + NATIVE_STARTUP_FAILURE_REPLY_BYTES, + type NativeStartupLogTail, + readNativeStartupLogs, +} from "./native-startup-logs.ts"; import { type NativeStopFailure, nativeStopFailureSummary, @@ -17,6 +22,7 @@ interface NativeFailure { readonly code: string; readonly causeCode?: string; readonly stopFailures?: readonly NativeStopFailure[]; + readonly startupLogs?: readonly NativeStartupLogTail[]; } /** @@ -604,10 +610,46 @@ function nativeFailureCause(input: { : undefined; } +/** Validate a parsed stderr reply; bounded startup logs only when the caller accepts them. */ +function nativeFailureFromReply( + value: unknown, + startupLogs: boolean +): NativeFailure | undefined { + if ( + !( + isRecord(value) && + typeof value.code === "string" && + ERROR_CODE.test(value.code) + ) + ) { + return; + } + const causeCode = nativeFailureCause({ + code: value.code, + cause: value.cause_code, + }); + return { + code: value.code, + ...(causeCode ? { causeCode } : {}), + ...(["engine_protocol", "engine_rejected", "engine_not_found"].includes( + value.code === "graph_owner_recovery" && + typeof value.cause_code === "string" + ? value.cause_code + : value.code + ) + ? { stopFailures: readNativeStopFailures(value.stop_failures) } + : {}), + ...(startupLogs && value.startup_logs !== undefined + ? { startupLogs: readNativeStartupLogs(value.startup_logs) } + : {}), + }; +} + /** Only bounded native error codes leave this boundary, never stderr messages. */ async function readNativeFailure( stream: ReadableStream, - signal?: AbortSignal + signal?: AbortSignal, + limit = 8192 ): Promise { const reader = stream.getReader(); const cancel = () => { @@ -627,36 +669,17 @@ async function readNativeFailure( break; } bytes += result.value.byteLength; - if (bytes <= 8192) { + if (bytes <= limit) { text += decoder.decode(result.value, { stream: true }); } } - if (bytes > 8192) { + if (bytes > limit) { return; } - const value: unknown = JSON.parse(text + decoder.decode()); - if ( - isRecord(value) && - typeof value.code === "string" && - ERROR_CODE.test(value.code) - ) { - const causeCode = nativeFailureCause({ - code: value.code, - cause: value.cause_code, - }); - return { - code: value.code, - ...(causeCode ? { causeCode } : {}), - ...(["engine_protocol", "engine_rejected", "engine_not_found"].includes( - value.code === "graph_owner_recovery" && - typeof value.cause_code === "string" - ? value.cause_code - : value.code - ) - ? { stopFailures: readNativeStopFailures(value.stop_failures) } - : {}), - }; - } + return nativeFailureFromReply( + JSON.parse(text + decoder.decode()), + limit > 8192 + ); } catch { return; } finally { @@ -686,6 +709,36 @@ export async function readNativeFailureCodes( }; } +/** + * Like {@link readNativeFailureCodes} for a foreground startup reply, which may + * also carry failed services' bounded output. That output can contain managed + * values: the caller must redact it before display and never persist it. + */ +export async function readNativeStartupFailure( + stream: ReadableStream, + signal?: AbortSignal +): Promise< + | { + readonly code: string; + readonly causeCode?: string; + readonly startupLogs?: readonly NativeStartupLogTail[]; + } + | undefined +> { + const failure = await readNativeFailure( + stream, + signal, + NATIVE_STARTUP_FAILURE_REPLY_BYTES + ); + return failure === undefined + ? undefined + : { + code: failure.code, + ...(failure.causeCode ? { causeCode: failure.causeCode } : {}), + ...(failure.startupLogs ? { startupLogs: failure.startupLogs } : {}), + }; +} + /** Capture delivery failure while the caller drains and reaps the receiver, never replaying input. */ export async function writeNativePrivateInput( sink: Pick | number | null | undefined, diff --git a/src/backends/native-startup-logs.ts b/src/backends/native-startup-logs.ts new file mode 100644 index 000000000..7c8b6de7e --- /dev/null +++ b/src/backends/native-startup-logs.ts @@ -0,0 +1,186 @@ +import { isRecord } from "../lib/guards.ts"; + +/** + * Bounded output of one failed service from a native foreground startup failure + * reply. It is application output and may contain managed values: it exists only + * in memory, is redacted by {@link formatNativeStartupLogReport} before display + * and is never written to disk. The runtime keeps whole lines only. + */ +export interface NativeStartupLogTail { + readonly service: string; + readonly stdout: string; + readonly stderr: string; + readonly truncated: boolean; +} + +const SERVICE = /^[A-Za-z0-9_.-]{1,128}$/; +/** Runtime contract: at most this many services, each stream at most these bytes. */ +export const NATIVE_STARTUP_LOG_SERVICES = 3; +export const NATIVE_STARTUP_LOG_STREAM_BYTES = 4096; +/** Lines the runtime requests per service, stdout and stderr together. */ +export const NATIVE_STARTUP_LOG_LINES = 64; +/** + * Stderr bytes accepted for a reply carrying tails: three services of two 4 KiB + * streams, even if every character needs a six-byte JSON escape, plus the envelope. + */ +export const NATIVE_STARTUP_FAILURE_REPLY_BYTES = 256 * 1024; +/** Values shorter than this are redacted only as whole tokens; see {@link redactNativeStartupLog}. */ +export const NATIVE_STARTUP_LOG_SUBSTRING_MIN = 4; +const MARKER = "[REDACTED]"; + +/** Accept only the versioned, bounded runtime contract; anything else is dropped whole. */ +export function readNativeStartupLogs( + value: unknown +): readonly NativeStartupLogTail[] | undefined { + if ( + !isRecord(value) || + Object.keys(value).length !== 2 || + value.version !== 1 || + !Array.isArray(value.services) || + value.services.length < 1 || + value.services.length > NATIVE_STARTUP_LOG_SERVICES + ) { + return; + } + const names = new Set(); + const result: NativeStartupLogTail[] = []; + for (const tail of value.services) { + if ( + !isRecord(tail) || + Object.keys(tail).length !== 4 || + typeof tail.service !== "string" || + !SERVICE.test(tail.service) || + names.has(tail.service) || + !boundedStream(tail.stdout) || + !boundedStream(tail.stderr) || + typeof tail.truncated !== "boolean" + ) { + return; + } + names.add(tail.service); + result.push({ + service: tail.service, + stdout: tail.stdout, + stderr: tail.stderr, + truncated: tail.truncated, + }); + } + return result; +} + +function boundedStream(value: unknown): value is string { + return ( + typeof value === "string" && + Buffer.byteLength(value) <= NATIVE_STARTUP_LOG_STREAM_BYTES + ); +} + +// CSI and OSC terminal sequences. They are removed before matching so styling +// inserted inside a printed value cannot hide it from redaction. +const ESCAPE_SEQUENCE = + /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; +const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g; +const SYNTAX = /[\\^$.*+?()[\]{}|/]/g; +const LINE_BREAK = /\r?\n/; + +function needles(values: Iterable): { + readonly substrings: readonly string[]; + readonly tokens: readonly string[]; +} { + const substrings = new Set(); + const tokens = new Set(); + const long = (value: string) => + value.length >= NATIVE_STARTUP_LOG_SUBSTRING_MIN; + for (const raw of values) { + const value = raw.replace(ESCAPE_SEQUENCE, ""); + if (value === "") { + continue; + } + (long(value) ? substrings : tokens).add(value); + // An application can print a value JSON-escaped, and a tail can start or end + // inside a multi-line value. Lines are matched only when long enough, so a + // short line such as `}` never shreds output. + const escaped = JSON.stringify(value).slice(1, -1); + (long(escaped) ? substrings : tokens).add(escaped); + for (const line of value.split(LINE_BREAK)) { + for (const form of [line, line.trim()]) { + if (long(form)) { + substrings.add(form); + } + } + } + } + const order = (a: string, b: string) => + b.length - a.length || (a < b ? -1 : 1); + return { + substrings: [...substrings].sort(order), + tokens: [...tokens].sort(order), + }; +} + +/** + * Redact every managed value from application output, conservatively: + * + * - Terminal escape sequences are removed first. + * - Each value of at least {@link NATIVE_STARTUP_LOG_SUBSTRING_MIN} characters is + * replaced wherever it occurs, including inside longer words. So is its + * JSON-escaped form and each line of a multi-line value that is long enough, + * with and without surrounding whitespace. Longer values are replaced first. + * - A shorter value (one to three characters) is replaced only where it stands as + * a whole token, not next to a letter, digit or underscore, so a value such as + * `1` or `on` cannot shred unrelated output. + * - Remaining control characters except tab and newline become U+FFFD. + */ +export function redactNativeStartupLog( + text: string, + values: Iterable +): string { + const { substrings, tokens } = needles(values); + let result = text.replace(ESCAPE_SEQUENCE, ""); + for (const value of substrings) { + result = result.split(value).join(MARKER); + } + for (const value of tokens) { + const pattern = new RegExp( + `(?>> + >; +}): string { + if (opts.logs.length === 0) { + return ""; + } + const values = Object.values(opts.managedEnvironment).flatMap((service) => + Object.values(service) + ); + const sections = opts.logs.map((tail) => { + const streams = (["stdout", "stderr"] as const) + .filter((stream) => tail[stream] !== "") + .map( + (stream) => + `--- ${tail.service} ${stream} ---\n${redactNativeStartupLog(tail[stream], values)}` + ); + if (streams.length === 0) { + streams.push(`--- ${tail.service} ---\n(no output)`); + } + if (tail.truncated) { + streams.push("(lines over the size bound were omitted)"); + } + return streams.join("\n"); + }); + return `Last output of failed services (at most ${NATIVE_STARTUP_LOG_LINES} lines each; managed values redacted; not saved):\n${sections.join("\n")}`; +} diff --git a/tests/native-project-process.test.ts b/tests/native-project-process.test.ts index 6eefb7c95..262033516 100644 --- a/tests/native-project-process.test.ts +++ b/tests/native-project-process.test.ts @@ -136,6 +136,76 @@ test("structured startup failure exposes its code without stderr values", async expect(failure).not.toContain("synthetic-private-detail"); }); +test("a startup failure passes failed services' bounded output only to its callback", async () => { + const reply = JSON.stringify({ + code: "graph_startup_application_failed", + message: "synthetic-private-detail", + startup_logs: { + version: 1, + services: [ + { + service: "web", + stdout: "", + stderr: "boot failed: synthetic-secret\u001b[31m", + truncated: false, + }, + ], + }, + }); + const opts = await fixture( + `console.error(${JSON.stringify(reply)}); process.exit(2);` + ); + const received: unknown[] = []; + let failure = ""; + try { + await serveNativeProjectGraph({ + ...opts, + onStartupLogs: (logs) => { + received.push(logs); + }, + onReady: async () => { + throw new Error("must not become ready"); + }, + }); + } catch (error) { + failure = String(error); + } + expect(received).toEqual([ + [ + { + service: "web", + stdout: "", + stderr: "boot failed: synthetic-secret\u001b[31m", + truncated: false, + }, + ], + ]); + // The thrown message keeps its closed form; display is the caller's job. + expect(failure).toContain("graph_startup_application_failed"); + expect(failure).not.toContain("synthetic"); + expect(failure).not.toContain("boot failed"); + + const malformed = await fixture( + `console.error(${JSON.stringify( + JSON.stringify({ + code: "graph_budget", + startup_logs: { version: 1, services: [{ service: "web" }] }, + }) + )}); process.exit(2);` + ); + const ignored: unknown[] = []; + await expect( + serveNativeProjectGraph({ + ...malformed, + onStartupLogs: (logs) => { + ignored.push(logs); + }, + onReady: async () => {}, + }) + ).rejects.toThrow("graph_budget"); + expect(ignored).toEqual([]); +}); + test("cancellation preserves the owner's graceful cleanup beyond five seconds", async () => { const opts = await fixture(` process.on("SIGTERM", async () => { diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4d9d33176..b603b8ae4 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, + readdir, readFile, realpath, rm, @@ -1158,6 +1159,73 @@ test("cancellation after successful listener discovery still prevents runtime ad expect(events).toEqual(["before", "cleanup"]); }); +async function filesUnder(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true, recursive: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)); +} + +test("failed services' output is printed with managed values redacted and never saved", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + let selectedRun = ""; + const saved: unknown[] = []; + opts.dependencies.save = async (request) => { + saved.push(request); + }; + opts.dependencies.serve = async (request) => { + selectedRun = request.run; + request.onStartupLogs?.([ + { + service: "web", + stdout: "listening", + stderr: "boot failed: rejected TOKEN=synthetic-secret", + truncated: false, + }, + ]); + throw new ClosedCliError( + "Native graph startup was interrupted or failed (graph_startup_application_failed); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => + request.args[1] === "inspect" + ? stoppedGraph({ + run: selectedRun, + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }) + : invoke(request); + let message = ""; + try { + await startNativeProject(opts); + } catch (error) { + expect(error).toBeInstanceOf(ClosedCliError); + message = (error as Error).message; + } + expect(message).toBe( + [ + "Native graph startup was interrupted or failed (graph_startup_application_failed); inspect owned state before retrying.", + "Last output of failed services (at most 64 lines each; managed values redacted; not saved):", + "--- web stdout ---", + "listening", + "--- web stderr ---", + "boot failed: rejected TOKEN=[REDACTED]", + ].join("\n") + ); + expect(events.at(-1)).toBe("cleanup"); + expect(JSON.stringify(saved)).not.toContain("boot failed"); + for (const file of await filesUnder(opts.scope.projectRoot)) { + const text = await readFile(file, "utf8"); + expect(text).not.toContain("boot failed"); + expect(text).not.toContain("synthetic-secret"); + } +}); + test("retained startup failure preserves its diagnostic and never removes the run mapping", async () => { const { opts, events } = await fixture(false); const serve = opts.dependencies.serve!; diff --git a/tests/native-startup-logs.test.ts b/tests/native-startup-logs.test.ts new file mode 100644 index 000000000..4cfad26f4 --- /dev/null +++ b/tests/native-startup-logs.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from "bun:test"; +import { + formatNativeStartupLogReport, + NATIVE_STARTUP_LOG_STREAM_BYTES, + readNativeStartupLogs, + redactNativeStartupLog, +} from "../src/backends/native-startup-logs.ts"; + +const tail = (overrides: Record = {}) => ({ + service: "web", + stdout: "", + stderr: "", + truncated: false, + ...overrides, +}); + +test("only the versioned bounded reply contract is accepted, whole or not at all", () => { + expect( + readNativeStartupLogs({ version: 1, services: [tail({ stdout: "ok" })] }) + ).toEqual([tail({ stdout: "ok" })]); + for (const value of [ + undefined, + { version: 2, services: [tail()] }, + { version: 1, services: [] }, + { version: 1, services: [tail(), tail()] }, + { + version: 1, + services: ["a", "b", "c", "d"].map((service) => tail({ service })), + }, + { version: 1, services: [tail({ service: "../web" })] }, + { version: 1, services: [tail({ extra: true })] }, + { version: 1, services: [tail({ truncated: "no" })] }, + { + version: 1, + services: [ + tail({ stderr: "x".repeat(NATIVE_STARTUP_LOG_STREAM_BYTES + 1) }), + ], + }, + { version: 1, services: [tail()], extra: 1 }, + ]) { + expect(readNativeStartupLogs(value)).toBeUndefined(); + } +}); + +test("every managed value is redacted, including substrings, escaped forms and lines", () => { + const key = "-----BEGIN KEY-----\nc3ludGhldGlj\n-----END KEY-----"; + const values = ["synthetic-secret", "s3cr3t-token", key]; + const text = [ + "token=synthetic-secret", + "prefix-synthetic-secretsuffix", + 'json {"value":"-----BEGIN KEY-----\\nc3ludGhldGlj\\n-----END KEY-----"}', + "partial c3ludGhldGlj", + "styled \u001b[31ms3cr3t\u001b[0m-token", + ].join("\n"); + const redacted = redactNativeStartupLog(text, values); + for (const value of ["synthetic-secret", "c3ludGhldGlj", "s3cr3t"]) { + expect(redacted).not.toContain(value); + } + expect(redacted).toContain("token=[REDACTED]"); + expect(redacted).toContain("prefix-[REDACTED]suffix"); + expect(redacted).toContain("partial [REDACTED]"); + expect(redacted).not.toContain("\u001b"); +}); + +test("values shorter than four characters are redacted only as whole tokens", () => { + const redacted = redactNativeStartupLog( + "FLAG=on at 1 of 10 retries; connection 1a2 ok; mode on.", + ["on", "1"] + ); + expect(redacted).toBe( + "FLAG=[REDACTED] at [REDACTED] of 10 retries; connection 1a2 ok; mode [REDACTED]." + ); + // Short lines of a multi-line value are never matched on their own. + expect(redactNativeStartupLog("done {a:1}", ["{\na:1\n}"])).toBe( + "done {a:1}" + ); +}); + +test("remaining control characters cannot reach the terminal", () => { + expect(redactNativeStartupLog("bell\u0007 tab\tback\b", [])).toBe( + "bell� tab\tback�" + ); +}); + +test("the report redacts values of every service and names omitted lines", () => { + const report = formatNativeStartupLogReport({ + logs: [ + tail({ + stdout: "starting", + stderr: "error: invalid token synthetic-secret", + truncated: true, + }), + tail({ service: "worker" }), + ], + managedEnvironment: { + api: { TOKEN: "synthetic-secret" }, + web: { MODE: "" }, + }, + }); + expect(report).toBe( + [ + "Last output of failed services (at most 64 lines each; managed values redacted; not saved):", + "--- web stdout ---", + "starting", + "--- web stderr ---", + "error: invalid token [REDACTED]", + "(lines over the size bound were omitted)", + "--- worker ---", + "(no output)", + ].join("\n") + ); + expect( + formatNativeStartupLogReport({ logs: [], managedEnvironment: {} }) + ).toBe(""); +}); From 8b82f367e51e3bc50a45c04dc0e97a560c20fe24 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:57:27 -0500 Subject: [PATCH 083/138] fix(native): move a squatted alias before the public up reads the profile `runtime up` without a profile read the owner receipt strictly to find the pool's profile before `start_pool` could move a squatted clean-stop alias to a fresh one, so it still refused with foreign_state. Run the same pre-use restore first. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/lifecycle.rs | 3 +++ .../provider/lifecycle/short_home/tests.rs | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index b1aea4500..69b013c05 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -823,6 +823,9 @@ pub fn status(candidate: &Candidate) -> Result { pub fn up(candidate: &Candidate) -> Result { reject_aliased_state(&root(candidate))?; + // The profile lookup below is a strict owner read; a squatted clean-stop alias must + // move to a fresh alias first, as in `start_pool`. + restore_released_short_home_before_use(candidate)?; let profile = if root(candidate) .join("owner.json") .try_exists() diff --git a/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs b/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs index e76b03f59..86b49b844 100644 --- a/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs +++ b/packages/runtime-core/src/provider/lifecycle/short_home/tests.rs @@ -677,6 +677,28 @@ fn a_squatted_alias_is_moved_before_start_reads_the_owner() { Squatter::remove(&squatted); } +#[test] +fn public_up_moves_a_squatted_alias_before_its_profile_lookup() { + let pool = Pool::new().cleanly_stopped(); + pool.remove_alias(); + let decoy = Squatter::Link.place(&pool); + let squatted = pool.owner.short_home.clone(); + let inode = pool.alias_inode(); + // No provider artifacts exist in this fixture, so `up` stops after its strict owner + // reads; it must not stop at the squatted alias. + let error = crate::provider::up(&pool.candidate).unwrap_err(); + assert_ne!(error.code, "foreign_state", "{}", error.message); + let restored = Owner::load(&pool.candidate).unwrap(); + assert_ne!(restored.short_home, squatted); + assert_eq!( + fs::read_link(&restored.short_home).unwrap(), + root(&pool.candidate).join("home") + ); + Squatter::Link.untouched(&squatted, inode, &decoy); + fs::remove_file(&restored.short_home).unwrap(); + Squatter::remove(&squatted); +} + #[test] fn a_squatted_alias_of_a_pool_with_pinned_dependency_sockets_is_refused_unchanged() { let mut pool = Pool::new(); From a20da6df6193ba6d67d66bba4c5cfc32e8997b48 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:58:32 -0500 Subject: [PATCH 084/138] fix(native): let an interrupted translation update recover on the reserve A reserve-qualified stopped pool whose receipt recorded amd64_emulation before `machine update --rosetta` completed failed its leased release check as retained_reserve_foreign_owner, so the retry never reached the repair. Release checks before the restart's provider effects now accept exactly that pending direction (receipt records translation, provider does not yet), with the record otherwise this pool's audited configuration. Every other release check is unchanged, and the boot-time recheck stays exact. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/config_audit.rs | 24 ++++++ .../src/provider/lifecycle/admission_pool.rs | 35 ++++++++- .../lifecycle/admission_pool/tests.rs | 75 +++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/provider/config_audit.rs b/packages/runtime-core/src/provider/config_audit.rs index 1a52e18a7..664aab806 100644 --- a/packages/runtime-core/src/provider/config_audit.rs +++ b/packages/runtime-core/src/provider/config_audit.rs @@ -22,6 +22,30 @@ pub(super) fn verify(candidate: &Candidate, owner: &Owner) -> Result<(), Candida verify_value(candidate, owner, &record) } +/// [`verify`] for a stopped pool before its restart repairs translation: it also accepts the +/// one pending transition an interrupted `runtime up --amd64-emulation` leaves, where the +/// receipt already records the intent but the provider has not stored `rosetta` yet. The +/// record must then be exactly this pool's audited configuration without translation. The +/// reverse (a provider with translation the receipt does not record) is still refused, and +/// the strict [`verify`] runs again after `machine update --rosetta` and before boot. +pub(super) fn verify_before_translation_repair( + candidate: &Candidate, + owner: &Owner, +) -> Result<(), CandidateError> { + let home = candidate.state_root.join("run/smolvm/home"); + #[cfg(target_os = "macos")] + let database = home.join("Library/Application Support/smolvm/server/smolvm.db"); + #[cfg(not(target_os = "macos"))] + let database = home.join(".local/share/smolvm/server/smolvm.db"); + let record = read_database(&database, &owner.machine)?; + if owner.amd64_emulation && !record_amd64_emulation(&record)? { + let mut pending = owner.clone(); + pending.amd64_emulation = false; + return verify_value(candidate, &pending, &record); + } + verify_value(candidate, owner, &record) +} + pub(super) fn verify_value( candidate: &Candidate, owner: &Owner, diff --git a/packages/runtime-core/src/provider/lifecycle/admission_pool.rs b/packages/runtime-core/src/provider/lifecycle/admission_pool.rs index 45fb250cd..a44961983 100644 --- a/packages/runtime-core/src/provider/lifecycle/admission_pool.rs +++ b/packages/runtime-core/src/provider/lifecycle/admission_pool.rs @@ -237,16 +237,34 @@ pub(super) fn stopped(candidate: &Candidate, owner: &Owner) -> Result<(), Stoppe /// machine (free VM lock, no open disk handle, no owned listener) and the provider's /// persisted configuration is still this pool's audited one. The VM lock is released on /// return; the provider's own `machine start` reacquires it. +/// +/// Before the restart's translation repair ([`Audit::BeforeRepair`]), the provider may still +/// lack the `rosetta` setting the receipt records after an interrupted `machine update`; the +/// restart repeats that update. Every later release check audits the configuration exactly. fn released( candidate: &Candidate, owner: &Owner, lease: &state::Lock, + audit: Audit, ) -> Result<(), StoppedRefusal> { let check = |error: CandidateError| StoppedRefusal::from_check(&error); lease.verify_path(&root(candidate)).map_err(check)?; lock_absent_disks_within(candidate, owner, disk_handles::RELEASE_HANDLE_WAIT).map_err(check)?; sockets_absent(owner).map_err(check)?; - config_audit::verify(candidate, owner).map_err(check) + match audit { + Audit::BeforeRepair => config_audit::verify_before_translation_repair(candidate, owner), + Audit::Exact => config_audit::verify(candidate, owner), + } + .map_err(check) +} + +/// How a release check audits the provider's persisted configuration. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Audit { + /// Before any restart effect: a pending translation update is accepted (see [`released`]). + BeforeRepair, + /// After the restart's provider effects: exactly the receipt's configuration. + Exact, } fn changed() -> CandidateError { @@ -290,10 +308,19 @@ impl Selection { &self, candidate: &Candidate, lease: &state::Lock, + ) -> Result<(), CandidateError> { + self.reverify_leased_with(candidate, lease, Audit::Exact) + } + + fn reverify_leased_with( + &self, + candidate: &Candidate, + lease: &state::Lock, + audit: Audit, ) -> Result<(), CandidateError> { self.reverify(candidate)?; if self.basis == Basis::Stopped { - released(candidate, &self.owner, lease).map_err(StoppedRefusal::error)?; + released(candidate, &self.owner, lease, audit).map_err(StoppedRefusal::error)?; // The receipt cannot have changed under the lease, but bind it after the // release probes as well. self.reverify(candidate)?; @@ -378,7 +405,7 @@ pub(super) fn settle<'a>( samples.iter_mut().for_each(admission::rebase_fresh); choice.selection = None; } else { - selected.reverify_leased(candidate, lease)?; + selected.reverify_leased_with(candidate, lease, Audit::BeforeRepair)?; } } } @@ -417,7 +444,7 @@ pub(super) fn restart_only<'a>( "Reserve-qualified ownership no longer names its selected pool; no create or boot was admitted.", )); } - selected.reverify_leased(candidate, lease)?; + selected.reverify_leased_with(candidate, lease, Audit::BeforeRepair)?; Ok(Restart(choice)) } diff --git a/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs b/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs index 6375ee203..8b0ab39c0 100644 --- a/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs +++ b/packages/runtime-core/src/provider/lifecycle/admission_pool/tests.rs @@ -1328,6 +1328,81 @@ fn reserve_amd64_emulation_child() { .unwrap(); } +#[test] +fn an_interrupted_translation_update_recovers_on_the_reserve() { + isolated("interrupted_translation_update_child"); +} + +#[test] +#[ignore = "isolated release-check fixture launched by an_interrupted_translation_update_recovers_on_the_reserve"] +fn interrupted_translation_update_child() { + let _serial = serial(); + let pool = stopped_pool(); + let lease = lease(&pool); + // `runtime up --amd64-emulation` saved the intent; `machine update --rosetta` did not + // complete, so the provider still stores no translation. + let mut owner = Owner::load(&pool.candidate).unwrap(); + owner.amd64_emulation = true; + owner.save(&pool.candidate).unwrap(); + let reserve = || window(admission::OwnedPool::Stopped, &[30 * GIB, 30 * GIB]); + + // The retry settles on the reserve and passes the restart gate, so it reaches the repair. + let mut choice = select(&pool.candidate, Profile::Development).unwrap(); + let mut samples = reserve(); + let settled = settle(&pool.candidate, &mut choice, &lease, &mut samples).unwrap(); + assert!(settled.selected().is_some_and(Selection::is_stopped)); + let restart = restart_only(&pool.candidate, settled, &owner, &lease).unwrap(); + let admitted = |restart: &Restart<'_>| match admit_boot_with( + &pool.candidate, + restart.0, + Some(7), + |owned| Ok(sample(owned, true, Some(7))), + ) + .unwrap() + { + BootGate::Admitted(boot) => boot.release(&pool.candidate, &lease), + BootGate::Refused(_) => panic!("reserve pre-boot sample admitted"), + }; + // Boot stays exact: without the repeated update, `machine start` is not permitted. + assert_eq!( + admitted(&restart).err().unwrap().code, + "retained_reserve_foreign_owner" + ); + // The restart repeats `machine update --rosetta`; then the exact audit passes. + provider_rosetta(&pool, true); + admitted(&restart).unwrap(); + + // Only that pending direction is accepted: a provider with translation the receipt does + // not record is still foreign before any restart effect. + let original = Owner::load(&pool.candidate).unwrap(); + let mut unrecorded = original.clone(); + unrecorded.amd64_emulation = false; + unrecorded.save(&pool.candidate).unwrap(); + let mut choice = select(&pool.candidate, Profile::Development).unwrap(); + let mut samples = reserve(); + assert_eq!( + settle(&pool.candidate, &mut choice, &lease, &mut samples) + .err() + .unwrap() + .code, + "retained_reserve_foreign_owner" + ); + // The other release checks still run with a pending update. + owner.save(&pool.candidate).unwrap(); + provider_rosetta(&pool, false); + let held = exclusive_lock(&pool.data().join("vm.lock")); + let mut choice = select(&pool.candidate, Profile::Development).unwrap(); + let mut samples = reserve(); + assert_eq!( + settle(&pool.candidate, &mut choice, &lease, &mut samples) + .err() + .unwrap() + .code, + "retained_reserve_active_owner" + ); + drop(held); +} + /// Qualifying samples on `owned`'s basis, each observing `free` bytes of free disk. fn window(owned: admission::OwnedPool, free: &[u64]) -> Vec { free.iter() From 7ad87cbc206ed22c6da3b1106b54bb2d26dd2605 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:58:34 -0500 Subject: [PATCH 085/138] fix(native): refuse arm64 images for services whose reviewed policy is amd64 Image admission accepted any Linux arm64 image, so a service using an already-local arm64 content ID under the amd64 policy ran natively. Admission now follows the reviewed policy: arm64 for native and prefer-native services, amd64 for opted-in services in a translation pool. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/graph/mod.rs | 28 ++++++++++++---- .../runtime-core/src/provider/graph/tests.rs | 33 +++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 1bf6185de..a6a5065d1 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -2151,18 +2151,34 @@ fn check_cache_capacity(engine: &Engine<'_>, owner: &str) -> Result<(), Candidat Ok(()) } -/// An image is admitted for exactly the platform its service may run: Linux arm64, or Linux -/// amd64 only for an opted-in service in a pool whose audited boot enabled x86_64 translation. +/// An image is admitted for exactly the platform its service's reviewed policy may run: +/// Linux arm64 for a native or `prefer-native` service, and Linux amd64 for an opted-in +/// service (either policy) only in a pool whose audited boot enabled x86_64 translation. An +/// `amd64` service never runs an arm64 image natively, even a local content ID. pub(super) fn admitted_architecture( engine: &Engine<'_>, resource: &Resource, image: &Value, ) -> bool { + admitted_platform( + image, + resource.amd64_emulation, + engine.guest().amd64_emulation(), + ) +} + +fn admitted_platform( + image: &Value, + policy: Option, + translation: bool, +) -> bool { + use crate::project::Amd64Emulation; image["Os"] == "linux" - && (image["Architecture"] == "arm64" - || (image["Architecture"] == "amd64" - && resource.amd64_emulation.is_some() - && engine.guest().amd64_emulation())) + && match (image["Architecture"].as_str(), policy) { + (Some("arm64"), None | Some(Amd64Emulation::PreferNative)) => true, + (Some("amd64"), Some(_)) => translation, + _ => false, + } } fn verify_images( diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index da2c5a1a8..c996a4dff 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -156,6 +156,39 @@ fn driver_profile_preserves_isolation_and_refuses_unqualified_inputs() { } assert!(!candidate.state_root.exists()); } +#[test] +fn image_architecture_admission_follows_the_reviewed_emulation_policy() { + use crate::project::Amd64Emulation::{Amd64, PreferNative}; + let arm64 = json!({"Os": "linux", "Architecture": "arm64"}); + let amd64 = json!({"Os": "linux", "Architecture": "amd64"}); + for translation in [false, true] { + // arm64 runs natively unless the reviewed policy requires amd64, whatever the + // image's origin (a pinned or an already-local content ID). + assert!(admitted_platform(&arm64, None, translation)); + assert!(admitted_platform(&arm64, Some(PreferNative), translation)); + assert!(!admitted_platform(&arm64, Some(Amd64), translation)); + // amd64 needs an opted-in service and a translation pool. + assert!(!admitted_platform(&amd64, None, translation)); + assert_eq!( + admitted_platform(&amd64, Some(PreferNative), translation), + translation + ); + assert_eq!( + admitted_platform(&amd64, Some(Amd64), translation), + translation + ); + } + for other in [ + json!({"Os": "darwin", "Architecture": "arm64"}), + json!({"Os": "linux", "Architecture": "riscv64"}), + json!({"Os": "linux"}), + ] { + for policy in [None, Some(PreferNative), Some(Amd64)] { + assert!(!admitted_platform(&other, policy, true)); + } + } +} + #[test] fn declared_amd64_requires_the_explicit_emulation_opt_in() { let fixture = Fixture::new(); From cb0334917d7534f141a0903a3c9620e81c339b52 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:59:06 -0500 Subject: [PATCH 086/138] fix(native): release only validated archive blocks during image upload The upload withheld only the final 64 KiB of the file, so an archive with more trailing padding after its end-of-archive marker released the complete logical tar before the whole-file digest was checked; a file changed after validation could finish an import before the error. Validation now records a SHA-256 per 1 MiB block, and the upload reader releases a block only after it matches the validated block at that position. The final block is released only after the stream ends at the validated length with the expected digest, so a consumer never sees a changed byte or an end of archive before verification, and a mismatch surfaces as an error rather than a clean end. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/image_load.rs | 289 +++++++++++++----- 1 file changed, 216 insertions(+), 73 deletions(-) diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index c1382e721..734032b30 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -36,8 +36,9 @@ const LOAD_BASE_TIMEOUT: Duration = Duration::from_secs(40); const LOAD_BYTES_PER_SECOND: u64 = 8 * 1024 * 1024; /// Ceiling for one load request, so `ensure-image` stays inside its caller's bound. const LOAD_MAX_TIMEOUT: Duration = Duration::from_secs(220); -/// Bytes the upload reader withholds until the whole archive has hashed correctly. -const HELD_TAIL: usize = 64 * 1024; +/// Validation records a SHA-256 per block of this many archive bytes; the upload releases +/// a block only after it matches. Archives are at most 8 GiB, so at most 8192 digests. +const VERIFY_BLOCK: usize = 1024 * 1024; fn error(message: &str) -> CandidateError { CandidateError::new("image_archive", message) @@ -80,12 +81,41 @@ fn verify_digest( Ok(total) } -/// Hashes and counts every byte read through it, refusing more than `limit`. +/// Hashes and counts every byte read through it, refusing more than `limit`, and records +/// one SHA-256 per [`VERIFY_BLOCK`] bytes. struct Hashing { inner: R, digest: Sha256, total: u64, limit: u64, + block: Sha256, + block_len: usize, + blocks: Vec<[u8; 32]>, +} +impl Hashing { + fn new(inner: R, limit: u64) -> Self { + Self { + inner, + digest: Sha256::new(), + total: 0, + limit, + block: Sha256::new(), + block_len: 0, + blocks: Vec::new(), + } + } + + /// The digests of every complete block and the final partial block. + fn finish(mut self) -> (String, u64, Vec<[u8; 32]>) { + if self.block_len > 0 { + self.blocks.push(self.block.finalize().into()); + } + ( + format!("{:x}", self.digest.finalize()), + self.total, + self.blocks, + ) + } } impl Read for Hashing { fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { @@ -95,72 +125,110 @@ impl Read for Hashing { return Err(std::io::Error::other("archive limit")); } self.digest.update(&buffer[..n]); + let mut rest = &buffer[..n]; + while !rest.is_empty() { + let take = rest.len().min(VERIFY_BLOCK - self.block_len); + self.block.update(&rest[..take]); + self.block_len += take; + rest = &rest[take..]; + if self.block_len == VERIFY_BLOCK { + self.blocks + .push(std::mem::take(&mut self.block).finalize().into()); + self.block_len = 0; + } + } Ok(n) } } -/// The upload side of an import: releases bytes only while at least [`HELD_TAIL`] bytes -/// remain withheld, and releases the tail only after the complete stream has the expected -/// length and SHA-256. A changed file therefore fails the upload before its tar ends. +/// A validated archive: its length and the SHA-256 of each [`VERIFY_BLOCK`] of it. +pub(super) struct Validated { + pub(super) length: u64, + blocks: Vec<[u8; 32]>, +} + +/// The upload side of an import. Every byte it releases was validated: a block is read +/// in full and released only after it matches the digest validation recorded for that +/// position, and the final block is released only after the stream has ended at the +/// validated length with the expected whole-archive SHA-256. A file changed after +/// validation therefore fails the upload before any changed byte, and before any end of +/// archive, reaches the consumer; trailing padding cannot expose a changed tar early. struct HeldTail { inner: R, digest: Sha256, total: u64, expected_len: u64, expected: String, + blocks: Vec<[u8; 32]>, + next_block: usize, held: Vec, + offset: usize, verified: bool, } impl HeldTail { - fn new(inner: R, expected_len: u64, expected: &str) -> Self { + fn new(inner: R, validated: &Validated, expected: &str) -> Self { Self { inner, digest: Sha256::new(), total: 0, - expected_len, + expected_len: validated.length, expected: expected.to_owned(), - held: Vec::new(), + blocks: validated.blocks.clone(), + next_block: 0, + held: Vec::with_capacity(VERIFY_BLOCK), + offset: 0, verified: false, } } + + /// Read and check the next block; on the last block, also check the stream end. + fn load(&mut self) -> std::io::Result<()> { + let changed = || std::io::Error::other("image archive changed"); + self.held.clear(); + self.offset = 0; + (&mut self.inner) + .take(VERIFY_BLOCK as u64) + .read_to_end(&mut self.held)?; + self.total += self.held.len() as u64; + let expected = self.blocks.get(self.next_block).ok_or_else(changed)?; + if self.held.is_empty() || Sha256::digest(&self.held).as_slice() != expected { + return Err(changed()); + } + self.digest.update(&self.held); + self.next_block += 1; + if self.next_block == self.blocks.len() { + let mut extra = [0_u8; 1]; + let longer = loop { + match self.inner.read(&mut extra) { + Ok(n) => break n > 0, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + }; + let actual = format!("{:x}", std::mem::take(&mut self.digest).finalize()); + if longer || self.total != self.expected_len || actual != self.expected { + return Err(changed()); + } + self.verified = true; + } + Ok(()) + } } impl Read for HeldTail { fn read(&mut self, out: &mut [u8]) -> std::io::Result { if out.is_empty() { return Ok(0); } - loop { - let releasable = if self.verified { - self.held.len() - } else { - self.held.len().saturating_sub(HELD_TAIL) - }; - if releasable > 0 { - let n = releasable.min(out.len()); - out[..n].copy_from_slice(&self.held[..n]); - self.held.drain(..n); - return Ok(n); - } + if self.offset == self.held.len() { if self.verified { return Ok(0); } - let mut buffer = [0_u8; 64 * 1024]; - let n = self.inner.read(&mut buffer)?; - if n == 0 { - let actual = format!("{:x}", std::mem::take(&mut self.digest).finalize()); - if self.total != self.expected_len || actual != self.expected { - return Err(std::io::Error::other("image archive changed")); - } - self.verified = true; - continue; - } - self.total += n as u64; - if self.total > self.expected_len { - return Err(std::io::Error::other("image archive changed")); - } - self.digest.update(&buffer[..n]); - self.held.extend_from_slice(&buffer[..n]); + self.load()?; } + let n = (self.held.len() - self.offset).min(out.len()); + out[..n].copy_from_slice(&self.held[self.offset..self.offset + n]); + self.offset += n; + Ok(n) } } @@ -253,27 +321,34 @@ struct Member { } /// Validate one single-image tarball in a single streaming pass and return its length. +fn validate_stream( + reader: impl Read, + expected_sha: &str, + image: &str, + archive_limit: u64, + expanded_limit: u64, +) -> Result { + validated(reader, expected_sha, image, archive_limit, expanded_limit).map(|v| v.length) +} + +/// Validate one single-image tarball in a single streaming pass, recording its length +/// and per-block digests for a verified re-read. /// /// The stream must hash to `expected_sha` within `archive_limit` bytes. Members must be /// a flat set of unique regular files named by exactly one `manifest.json` entry; the /// config must hash to `image` and target Linux ARM64; each layer's decoded content /// must match the config's `diff_ids`; total decoded bytes stay within `expanded_limit`. -fn validate_stream( +fn validated( reader: impl Read, expected_sha: &str, image: &str, archive_limit: u64, expanded_limit: u64, -) -> Result { +) -> Result { if !image.strip_prefix("sha256:").is_some_and(hex) || !hex(expected_sha) { return Err(error("Invalid image identity or archive size.")); } - let mut hashing = Hashing { - inner: reader, - digest: Sha256::new(), - total: 0, - limit: archive_limit, - }; + let mut hashing = Hashing::new(reader, archive_limit); let mut members = BTreeMap::new(); let mut expanded = 0_u64; let mut retained_total = 0_usize; @@ -352,7 +427,8 @@ fn validate_stream( // Bytes after the end-of-archive marker are hashed too; the expected digest covers them. std::io::copy(&mut hashing, &mut std::io::sink()) .map_err(|_| error("Image archive exceeds its byte limit."))?; - if format!("{:x}", hashing.digest.finalize()) != expected_sha { + let (actual, length, blocks) = hashing.finish(); + if actual != expected_sha { return Err(error("Image archive digest mismatch.")); } let metadata = |name: &str, limit: usize| { @@ -410,7 +486,7 @@ fn validate_stream( return Err(error("Expanded image layer identity is incorrect.")); } } - Ok(hashing.total) + Ok(Validated { length, blocks }) } /// Validate an archive file the caller just wrote, under the policy's budgets. @@ -606,16 +682,18 @@ fn load_waiting( } } // Validate exactly the bytes that hash to the expected value, then stream the same file; - // the upload withholds its tail until the re-read stream hashes identically. + // the upload releases only re-read blocks that match the validated ones, and its final + // block only once the re-read stream hashes identically. file.rewind() .map_err(|_| error("Cannot rewind the image archive."))?; - let length = validate_stream( + let validated = validated( &mut file, expected_sha, image, policy.archive_limit, policy.expanded_limit, )?; + let length = validated.length; if length != metadata.len() { return Err(error("Image archive size changed during verification.")); } @@ -627,7 +705,7 @@ fn load_waiting( .map_err(|_| error("Cannot read the image archive."))?; state::write(&path, &receipt)?; let result = engine.load_image_archive( - reqwest::blocking::Body::sized(HeldTail::new(upload, length, expected_sha), length), + reqwest::blocking::Body::sized(HeldTail::new(upload, &validated, expected_sha), length), load_timeout(length), ); // A lost response is reconciled only by independent content-ID readback. @@ -806,40 +884,105 @@ pub(super) mod tests { ); } + /// Block digests for `bytes`, as validation records them. + fn recorded(bytes: &[u8]) -> Validated { + let mut hashing = Hashing::new(Cursor::new(bytes), u64::MAX); + std::io::copy(&mut hashing, &mut std::io::sink()).unwrap(); + let (_, length, blocks) = hashing.finish(); + Validated { length, blocks } + } + + /// Everything a consumer receives from `reader` before it fails. + fn drain_until_failure(mut reader: impl Read) -> (Vec, std::io::Error) { + let mut released = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => panic!("a changed stream must not end cleanly"), + Ok(n) => released.extend_from_slice(&buffer[..n]), + Err(e) => return (released, e), + } + } + } + #[test] - fn upload_withholds_its_tail_until_the_whole_stream_hashes_as_expected() { - let bytes: Vec = (0..300_000_u32).map(|n| n as u8).collect(); + fn upload_releases_only_verified_blocks_and_withholds_its_end_until_verified() { + let bytes: Vec = (0..(2 * VERIFY_BLOCK + 300_000) as u32) + .map(|n| (n % 253) as u8) + .collect(); let expected = hash(&bytes); + let validated = recorded(&bytes); + assert_eq!(validated.blocks.len(), 3); let mut uploaded = Vec::new(); - HeldTail::new(Cursor::new(&bytes), bytes.len() as u64, &expected) + HeldTail::new(Cursor::new(&bytes), &validated, &expected) .read_to_end(&mut uploaded) .unwrap(); assert_eq!(uploaded, bytes); - for changed in [ - { - let mut changed = bytes.clone(); - changed[299_999] ^= 1; - changed - }, - bytes[..299_000].to_vec(), - [bytes.as_slice(), b"x"].concat(), + let flip = |at: usize| { + let mut changed = bytes.clone(); + changed[at] ^= 1; + changed + }; + for (changed, released_blocks) in [ + (flip(10), 0), + (flip(VERIFY_BLOCK + 5), 1), + (flip(bytes.len() - 1), 2), + (bytes[..bytes.len() - 1000].to_vec(), 2), + ([bytes.as_slice(), b"x"].concat(), 2), + (bytes[..VERIFY_BLOCK].to_vec(), 1), ] { - let mut reader = HeldTail::new(Cursor::new(&changed), bytes.len() as u64, &expected); - let mut released = Vec::new(); - let mut buffer = [0_u8; 4096]; - let failure = loop { - match reader.read(&mut buffer) { - Ok(0) => panic!("a changed stream must not end cleanly"), - Ok(n) => released.extend_from_slice(&buffer[..n]), - Err(e) => break e, - } - }; + let (released, failure) = + drain_until_failure(HeldTail::new(Cursor::new(&changed), &validated, &expected)); assert_eq!(failure.to_string(), "image archive changed"); - // The withheld tail, which holds the tar end-of-archive blocks, never left. - assert!(released.len() + HELD_TAIL <= changed.len().max(bytes.len())); + // Only whole validated blocks left, never a changed byte, and never the final + // block before the stream end was verified. + assert_eq!(released, bytes[..released_blocks * VERIFY_BLOCK]); } } + #[test] + fn trailing_padding_cannot_expose_a_changed_archive_before_verification() { + let (archive, image) = fixture("arm64", false, false); + // More padding after the end-of-archive marker than one block, so the marker is + // far from the physical end of the file. + let mut padded = archive.clone(); + padded.resize(archive.len() + 2 * VERIFY_BLOCK, 0); + let expected = hash(&padded); + let validated = + validated(Cursor::new(&padded), &expected, &image, 1 << 30, 1 << 20).unwrap(); + assert_eq!(validated.length, padded.len() as u64); + // Unchanged, a tar consumer reads the archive to its end. + let reader = HeldTail::new(Cursor::new(&padded), &validated, &expected); + let names: Vec = tar::Archive::new(reader) + .entries() + .unwrap() + .map(|e| e.unwrap().path().unwrap().display().to_string()) + .collect(); + assert_eq!(names, ["config.json", "layer", "manifest.json"]); + // A member changed after validation: the consumer sees no byte and no end. + let mut changed = padded.clone(); + changed[1024 + 600] ^= 1; + let (released, failure) = + drain_until_failure(HeldTail::new(Cursor::new(&changed), &validated, &expected)); + assert_eq!(failure.to_string(), "image archive changed"); + assert!(released.is_empty()); + let reader = HeldTail::new(Cursor::new(&changed), &validated, &expected); + let mut consumer = tar::Archive::new(reader); + let failed = consumer + .entries() + .map_or(true, |mut entries| entries.any(|entry| entry.is_err())); + assert!(failed, "a tar consumer must fail, not end cleanly"); + // A change only in the trailing padding releases just validated bytes (the + // original tar and its marker) and then fails instead of ending. + let mut changed = padded.clone(); + let last = changed.len() - 1; + changed[last] = 1; + let (released, failure) = + drain_until_failure(HeldTail::new(Cursor::new(&changed), &validated, &expected)); + assert_eq!(failure.to_string(), "image archive changed"); + assert_eq!(released, padded[..2 * VERIFY_BLOCK]); + } + #[test] fn gzip_zstd_multi_frame_and_uncompressed_layers_validate_by_decoded_identity() { let payload: Vec = (0..20_000_u32).map(|n| (n % 251) as u8).collect(); From 4ddf956ed55980373133975b475c1b1a51bb60f7 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:01:47 -0500 Subject: [PATCH 087/138] fix(native): wait for starting loopback port endpoints within the startup deadline `started` readiness can finish while an image or explicit healthcheck is still `starting`. Loopback port reservation then found no committed port endpoint and refused at once, so foreground startup failed and cleaned up a valid graph. Reservation now reports `bridge_endpoint_pending` for a running container whose health is starting, and the foreground publisher retries only that refusal until the startup deadline or a signal. Every other missing endpoint still refuses immediately, and no refusal writes registry assignments. Reservation takes the receipt, port endpoints and observations instead of the snapshot, so its portable test compiles on Linux and the `reserve_loopback_selected` dead-code clippy failure is gone. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/graph/bridges.rs | 132 ++++++++++++++++-- .../provider/graph/foreground/publishers.rs | 75 +++++++++- 2 files changed, 193 insertions(+), 14 deletions(-) diff --git a/packages/runtime-core/src/provider/graph/bridges.rs b/packages/runtime-core/src/provider/graph/bridges.rs index 8ba2c26ce..fb67dd008 100644 --- a/packages/runtime-core/src/provider/graph/bridges.rs +++ b/packages/runtime-core/src/provider/graph/bridges.rs @@ -415,31 +415,49 @@ pub(in crate::provider::graph) fn reserve_loopback_ports( candidate, store, capacity, - &snapshot, + &snapshot.receipt, + &snapshot.port_endpoints, + &snapshot.observations, engine.guest().boot_id(), ) } +/// A container whose own healthcheck is still `starting` has no committed port +/// endpoint yet, but may still become ready within the caller's startup deadline; +/// `started` readiness does not wait for health. Every other missing endpoint is +/// a refusal the caller must not wait out. +#[cfg(any(test, target_os = "macos"))] +fn loopback_endpoint_pending(observation: Option<&Value>) -> bool { + observation.and_then(|value| serde_json::from_value::(value.clone()).ok()) + == Some(Observation::Running { + health: Health::Starting, + }) +} + +/// Keys of `port_endpoints` are [`port_endpoint_key`]s; `observations` are keyed +/// like `receipt.resources`. Refusal writes no assignments. A missing endpoint +/// is `bridge_endpoint_pending` only while its container's health is starting. #[cfg(any(test, target_os = "macos"))] fn reserve_loopback_selected( candidate: &Candidate, mut store: Store, capacity: u8, - snapshot: &Snapshot, + receipt: &Receipt, + port_endpoints: &BTreeMap, + observations: &BTreeMap, boot_id: &str, ) -> Result, CandidateError> { - let run = &snapshot.receipt.run; + let run = &receipt.run; let mut used = store .slots .keys() .copied() .collect::>(); let mut reserved = BTreeMap::new(); - for resource in snapshot - .receipt + for (key, resource) in receipt .resources - .values() - .filter(|r| r.kind == Kind::Container) + .iter() + .filter(|(_, r)| r.kind == Kind::Container) { for port in &resource.loopback_ports { if store.slots.values().any(|a| { @@ -452,14 +470,20 @@ fn reserve_loopback_selected( "The published port is already reserved; no reassignment was attempted.", )); } - let endpoint = snapshot - .port_endpoints + let endpoint = port_endpoints .get(&port_endpoint_key(&resource.key, port.target)) .ok_or_else(|| { - error( - "bridge_endpoint_unavailable", - "A published port requires a running, ready owned container endpoint.", - ) + if loopback_endpoint_pending(observations.get(key)) { + error( + "bridge_endpoint_pending", + "A published port waits for its container's healthcheck; no ports were reserved.", + ) + } else { + error( + "bridge_endpoint_unavailable", + "A published port requires a running, ready owned container endpoint.", + ) + } })?; let slot = (0..capacity).find(|slot| !used.contains(slot)).ok_or_else(|| { error( @@ -1109,6 +1133,88 @@ mod tests { ); } #[test] + fn loopback_reservation_waits_only_for_starting_health_and_commits_atomically() { + let fixture_root = super::super::tests::Fixture::new(); + let candidate = Candidate::discover(&fixture_root.0).unwrap(); + let owner = "a".repeat(32); + let run = "f".repeat(32); + let boot = "12345678-1234-1234-1234-123456789abc"; + initialize_registry(&candidate, &owner, 3).unwrap(); + let mut store = fixture(); + store.owner = owner.clone(); + save(&candidate, &store).unwrap(); + let before = fs::read(root(&candidate).join("state.json")).unwrap(); + let receipt: Receipt = serde_json::from_value(json!({ + "version":1,"run":run,"owner":owner, + "namespace":"c".repeat(64),"plan_id":"d".repeat(64), + "phase":"ready-observed","readiness":{"web":"started"}, + "resources":{"container:web":{ + "kind":"container","key":"web","name":"owned-web","id":"d".repeat(64), + "phase":"started","loopback_ports":[{"host_port":8787,"target":3000}] + }} + })) + .unwrap(); + let reserve = |endpoints: &BTreeMap, observation: Value| { + reserve_loopback_selected( + &candidate, + read_owner_registry(&candidate, &owner, 3, false).unwrap(), + 3, + &receipt, + endpoints, + &BTreeMap::from([("container:web".to_owned(), observation)]), + boot, + ) + }; + let none = BTreeMap::new(); + // `started` readiness can complete while an image healthcheck is starting: + // that is the one refusal the foreground publisher waits out. + for (observation, code) in [ + ( + json!({"state":"running","health":"starting"}), + "bridge_endpoint_pending", + ), + ( + json!({"state":"running","health":"unhealthy"}), + "bridge_endpoint_unavailable", + ), + ( + json!({"state":"exited","code":1}), + "bridge_endpoint_unavailable", + ), + (json!({"state":"absent"}), "bridge_endpoint_unavailable"), + ] { + assert_eq!(reserve(&none, observation).unwrap_err().code, code); + assert_eq!( + fs::read(root(&candidate).join("state.json")).unwrap(), + before + ); + } + let endpoints = BTreeMap::from([( + port_endpoint_key("web", 3000), + GuestEndpoint { + generation: "c".repeat(64), + container_id: "d".repeat(64), + network_id: "e".repeat(64), + address: "172.17.0.2".parse().unwrap(), + port: 3000, + scope: "guest-only", + reachability: "not-probed", + }, + )]); + let reserved = reserve(&endpoints, json!({"state":"running","health":"healthy"})).unwrap(); + assert_eq!(reserved[&8787].slot, 1); + let persisted = read_owner_registry(&candidate, &owner, 3, false).unwrap(); + assert_eq!( + persisted.slots[&1].loopback_port, + Some(LoopbackPort { + host_port: 8787, + target: 3000 + }) + ); + assert_eq!(persisted.slots[&1].run, run); + assert_eq!(persisted.slots[&0].run, "b".repeat(32)); + } + #[test] fn batch_commit_is_atomic_across_late_endpoint_failure_and_success() { let fixture_root = super::super::tests::Fixture::new(); let candidate = Candidate::discover(&fixture_root.0).unwrap(); diff --git a/packages/runtime-core/src/provider/graph/foreground/publishers.rs b/packages/runtime-core/src/provider/graph/foreground/publishers.rs index 7b0ffccd7..25ee23341 100644 --- a/packages/runtime-core/src/provider/graph/foreground/publishers.rs +++ b/packages/runtime-core/src/provider/graph/foreground/publishers.rs @@ -157,7 +157,15 @@ impl Publishers { executable: &Path, ) -> Result<(), CandidateError> { check_deadline(signals, deadline)?; - let reserved = super::super::bridges::reserve_loopback_ports(candidate, receipt)?; + // `started` readiness can finish while an image healthcheck is still + // starting; wait for those endpoints within the same startup deadline. + let reserved = retry_pending_endpoint( + || super::super::bridges::reserve_loopback_ports(candidate, receipt), + || { + std::thread::sleep(PENDING_ENDPOINT_POLL); + check_deadline(signals, deadline) + }, + )?; for (port, selected) in reserved { check_deadline(signals, deadline)?; let assignment = &selected.assignment; @@ -300,6 +308,23 @@ impl Publishers { } } +const PENDING_ENDPOINT_POLL: Duration = Duration::from_millis(100); + +/// Repeats `attempt` only while it refuses with `bridge_endpoint_pending`; each +/// refusal wrote nothing. `wait` bounds the loop: its error ends it unchanged. +/// Every other outcome, success or refusal, returns at once. +fn retry_pending_endpoint( + mut attempt: impl FnMut() -> Result, + mut wait: impl FnMut() -> Result<(), CandidateError>, +) -> Result { + loop { + match attempt() { + Err(refusal) if refusal.code == "bridge_endpoint_pending" => wait()?, + outcome => return outcome, + } + } +} + fn check_deadline(signals: &signals::Events, deadline: Instant) -> Result<(), CandidateError> { if signals.pending() || Instant::now() >= deadline { Err(refused()) @@ -433,4 +458,52 @@ mod tests { ); assert_eq!(command.get_envs().count(), 0); } + + #[test] + fn pending_endpoint_waits_within_the_bound_but_other_refusals_do_not() { + let pending = || CandidateError::new("bridge_endpoint_pending", "pending"); + let mut attempts = 0; + let mut waits = 0; + let reserved = retry_pending_endpoint( + || { + attempts += 1; + if attempts < 3 { + Err(pending()) + } else { + Ok(attempts) + } + }, + || { + waits += 1; + Ok(()) + }, + ) + .unwrap(); + assert_eq!((reserved, waits), (3, 2)); + + let mut waits = 0; + let expired = retry_pending_endpoint( + || Err::<(), _>(pending()), + || { + waits += 1; + if waits < 2 { Ok(()) } else { Err(refused()) } + }, + ) + .unwrap_err(); + assert_eq!((expired.code, waits), ("graph_route_publisher", 2)); + + let mut attempts = 0; + let unavailable = retry_pending_endpoint( + || { + attempts += 1; + Err::<(), _>(CandidateError::new("bridge_endpoint_unavailable", "gone")) + }, + || panic!("a permanent refusal must not wait"), + ) + .unwrap_err(); + assert_eq!( + (unavailable.code, attempts), + ("bridge_endpoint_unavailable", 1) + ); + } } From d2a2080e43a527a48f333d16b0050976bad3c289 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:06:39 -0500 Subject: [PATCH 088/138] test(native): name an owned network in engine admission controls The default-bridge guard refuses a container create that names no network before admission is consulted, so the dispatch-level admission controls (built only with environment-launcher) now send NetworkMode none. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/engine/admission_tests.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/runtime-core/src/provider/engine/admission_tests.rs b/packages/runtime-core/src/provider/engine/admission_tests.rs index 3fcf9ad19..a46a489b2 100644 --- a/packages/runtime-core/src/provider/engine/admission_tests.rs +++ b/packages/runtime-core/src/provider/engine/admission_tests.rs @@ -72,6 +72,12 @@ fn empty_admission() -> super::super::managed_environment::Managed { managed } +/// Every create Hack dispatches names its network; the engine guard refuses one that +/// does not before admission is consulted, so these controls name `none`. +fn owned_create() -> Value { + serde_json::json!({"HostConfig": {"NetworkMode": "none"}}) +} + fn expire(deadline: Instant) { while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { std::thread::sleep(remaining + Duration::from_millis(1)); @@ -129,7 +135,11 @@ fn empty_admission_expiring_before_create_sends_no_request() { let transport = fixture.transport(managed.deadline(), Duration::from_millis(250)); expire(managed.deadline()); let failure = transport - .request(Method::POST, "/v1.53/containers/create?name=owned", None) + .request( + Method::POST, + "/v1.53/containers/create?name=owned", + Some(&owned_create()), + ) .unwrap_err(); assert_eq!(failure.code, "graph_environment_input"); fixture.assert_no_request(); @@ -183,7 +193,11 @@ fn empty_admission_expiring_after_create_blocks_start_but_allows_owned_delete() None, ); transport - .request(Method::POST, "/v1.53/containers/create?name=owned", None) + .request( + Method::POST, + "/v1.53/containers/create?name=owned", + Some(&owned_create()), + ) .unwrap(); create.join().unwrap(); let id = "1".repeat(64); @@ -243,7 +257,11 @@ fn in_flight_mutation_cannot_renew_empty_admission_transport_budget() { // The request was dispatched while valid, but its response arrives after expiry. // Without the remaining-lifetime timeout it would succeed under the 3s default. let failure = transport - .request(Method::POST, "/v1.53/containers/create?name=owned", None) + .request( + Method::POST, + "/v1.53/containers/create?name=owned", + Some(&owned_create()), + ) .unwrap_err(); assert_eq!(failure.code, "engine_protocol"); assert!(failure.message.contains("effect may be uncertain")); From a0c58c89b56b271d1394d6faad4e01c4b92d4055 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:06:43 -0500 Subject: [PATCH 089/138] fix(native): build from the scanned Dockerfile and filter build output The build client now reads a private copy of the Dockerfile bytes that were scanned and hashed, with the effective ignore rules written beside it, instead of reopening the project paths while bases resolve. Its stderr is piped through a filter that redacts every build-argument value, strips terminal control characters, cuts long lines and bounds total output before it reaches the caller, and stops writing before the operation returns. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 10 +- .../runtime-core/src/provider/image_build.rs | 117 +++++-- .../src/provider/image_build/output.rs | 309 ++++++++++++++++++ .../src/provider/image_build/tests.rs | 96 +++++- 4 files changed, 510 insertions(+), 22 deletions(-) create mode 100644 packages/runtime-core/src/provider/image_build/output.rs diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 74caf19b2..a8f5b187d 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -471,11 +471,17 @@ linked, mode-`0600` file owned by you), because build-argument values are in it: - The context and Dockerfile must be inside the project and reached without symlinks. The Dockerfile is read, never run, to find every image it can pull. Each image must be a public Docker Hub reference; it is pinned to a digest before the build starts. - Base images with stored `ONBUILD` triggers refuse. + Base images with stored `ONBUILD` triggers refuse. The client builds from a private + copy of the Dockerfile and ignore rules that were scanned, so editing either file + during the build has no effect on it. - Argument values reach the client only through its environment, never its command - line, receipts or output. Names that would change the client's own environment + line or receipts. Names that would change the client's own environment (`PATH`, `HOME`, `DOCKER_*`, `BUILDX_*`, `BUILDKIT_*`, proxies, locale, Git and SSH settings) refuse. +- Build output is shown on stderr after filtering: every argument value is replaced + with `[redacted]`, terminal control characters are removed, lines are cut at 16 KiB + and output stops after 4 MiB. A value that a Dockerfile transforms before printing + it (for example, by encoding it) is not detected. - `missing` reuses a verified image for the same build key without running the client; `rebuild` always builds. The key covers the Dockerfile and `.dockerignore` bytes, target, arguments and versions, not the context's other files (as with Compose). diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index ff6268d46..d276e3ce7 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -27,8 +27,12 @@ //! is released only when its holder exits): it is recorded `indeterminate` and the //! image is built again. Build outputs are content addressed and adoption always //! rechecks labels and incarnation, so no operator action is needed. -//! - Argument values never appear in argv, receipts, messages or output: the client -//! receives `--build-arg NAME` and reads the value from its private environment. +//! - Argument values never appear in argv, receipts or messages: the client receives +//! `--build-arg NAME` and reads the value from its private environment. Its output +//! reaches the caller only through [`output::Filter`], which redacts every value and +//! strips terminal control sequences, within fixed bounds. +//! - The client builds from a private copy of the Dockerfile and ignore bytes that were +//! scanned and hashed, never from the project paths again. //! - Nothing here removes images, prunes caches or adopts an image whose labels differ. use super::{ artifact, @@ -43,7 +47,7 @@ use sha2::{Digest, Sha256}; use std::{ collections::BTreeMap, fs::{self, File, OpenOptions}, - io::Read, + io::{Read, Write}, os::fd::AsRawFd, os::unix::fs::{MetadataExt, OpenOptionsExt}, os::unix::process::CommandExt, @@ -53,6 +57,7 @@ use std::{ time::{Duration, Instant}, }; +mod output; #[cfg(test)] mod tests; @@ -201,15 +206,24 @@ pub(super) trait BuildPool { fn resolve(&self, base: &BaseImage) -> Result; /// The verified builder executable. fn builder(&self) -> Result; + /// Where the client's filtered output goes. + fn output(&self) -> Box; } /// Validated local inputs and the build key derived from them. +/// +/// The Dockerfile and its effective ignore file are held as the exact bytes that were +/// scanned and hashed: the client builds from a private copy of them, never from the +/// mutable project paths, so a file edited while bases resolve cannot reach BuildKit. #[derive(Debug)] pub(super) struct Inputs { namespace: String, key: String, context: PathBuf, - dockerfile: PathBuf, + dockerfile: Vec, + /// The ignore rules BuildKit applies: the Dockerfile-specific file if present, else + /// the context's `.dockerignore`, else empty (no exclusions). + ignore: Vec, target: Option, args: BTreeMap, images: Vec, @@ -395,17 +409,34 @@ fn read_bounded_file(path: &Path, limit: u64) -> Result>, Candida /// The ignore files BuildKit applies to this context: a Dockerfile-specific /// `.dockerignore` wins over the context's `.dockerignore`. -fn ignore_digests(context: &Path, dockerfile: &Path) -> Result { - let mut specific = dockerfile.as_os_str().to_owned(); - specific.push(".dockerignore"); - let digest = |path: &Path| -> Result { - Ok(read_bounded_file(path, MAX_IGNORE_BYTES)? - .map_or(Value::Null, |bytes| Value::String(sha256_hex(&bytes)))) - }; - Ok(serde_json::json!({ - "context": digest(&context.join(".dockerignore"))?, - "dockerfile": digest(Path::new(&specific))?, - })) +struct IgnoreFiles { + context: Option>, + dockerfile: Option>, +} +impl IgnoreFiles { + fn read(context: &Path, dockerfile: &Path) -> Result { + let mut specific = dockerfile.as_os_str().to_owned(); + specific.push(".dockerignore"); + Ok(Self { + context: read_bounded_file(&context.join(".dockerignore"), MAX_IGNORE_BYTES)?, + dockerfile: read_bounded_file(Path::new(&specific), MAX_IGNORE_BYTES)?, + }) + } + fn digests(&self) -> Value { + let digest = |bytes: &Option>| { + bytes + .as_deref() + .map_or(Value::Null, |bytes| Value::String(sha256_hex(bytes))) + }; + serde_json::json!({ + "context": digest(&self.context), + "dockerfile": digest(&self.dockerfile), + }) + } + /// The rules in force. An empty file excludes nothing, exactly like an absent one. + fn effective(self) -> Vec { + self.dockerfile.or(self.context).unwrap_or_default() + } } /// Validate the request, resolve paths inside the project without following symlinks, @@ -463,13 +494,14 @@ pub(super) fn prepare(request: BuildRequest) -> Result { (name.as_str(), format!("{:x}", digest.finalize())) }) .collect(); + let ignore = IgnoreFiles::read(&context, &dockerfile)?; let key_input = serde_json::json!({ "v": 1, "namespace": namespace, "context": relative(&context, &root), "dockerfile": relative(&dockerfile, &context), "dockerfile_sha256": sha256_hex(&bytes), - "dockerignore": ignore_digests(&context, &dockerfile)?, + "dockerignore": ignore.digests(), "target": request.target, "args": salted_args, "platform": "linux/arm64", @@ -482,7 +514,8 @@ pub(super) fn prepare(request: BuildRequest) -> Result { namespace, key, context, - dockerfile, + dockerfile: bytes, + ignore: ignore.effective(), target: request.target, args: request.args, images: scan.images, @@ -759,6 +792,8 @@ pub(super) fn run( )?, inputs.timeout, canceled, + output::Filter::new(inputs.args.values().map(String::as_str)), + pool.output(), ); let ended = match ended { Ok(ended) => ended, @@ -869,6 +904,37 @@ fn built_image_id(metadata: &Path) -> Result { Ok(id.to_owned()) } +/// Write the scanned Dockerfile bytes and the effective ignore rules into a private +/// directory the client reads them from. The client applies `.dockerignore` +/// beside the Dockerfile in preference to the context's `.dockerignore`, so writing the +/// effective rules there (empty when there are none) keeps the hashed ignore semantics +/// without reading either project file again. +fn stage_dockerfile(scratch: &Path, inputs: &Inputs) -> Result { + let directory = scratch.join("dockerfile"); + state::private_directory(&directory)?; + let dockerfile = directory.join("Dockerfile"); + write_private(&dockerfile, &inputs.dockerfile)?; + write_private(&directory.join("Dockerfile.dockerignore"), &inputs.ignore)?; + Ok(dockerfile) +} + +/// Replace one private file with exactly these bytes, never following a symlink. +fn write_private(path: &Path, bytes: &[u8]) -> Result<(), CandidateError> { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(state::io(error)), + } + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(state::io)?; + file.write_all(bytes).map_err(state::io) +} + /// The client command: argv carries only paths, names and non-secret labels; argument /// values reach the client through its environment. fn command( @@ -884,6 +950,7 @@ fn command( let buildx = scratch.join("buildx"); state::private_directory(&config)?; state::private_directory(&buildx)?; + let dockerfile = stage_dockerfile(scratch, inputs)?; let mut command = super::process::clean_command(builder); command .current_dir(scratch) @@ -902,7 +969,7 @@ fn command( .args(["build", "--progress=plain", "--platform", "linux/arm64"]) .args(["--provenance=false", "--sbom=false", "--load"]) .arg("--file") - .arg(&inputs.dockerfile) + .arg(&dockerfile) .arg("--metadata-file") .arg(metadata) .args(["--tag", &inputs.tag()]); @@ -924,7 +991,8 @@ fn command( .arg(&inputs.context) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::inherit()) + // Output reaches the caller only through `output::Filter`. + .stderr(Stdio::piped()) .process_group(0); Ok(command) } @@ -979,10 +1047,14 @@ impl Drop for CancelSignals { /// Spawn the client and wait for it, honoring cancellation and the time budget. On /// either, the whole process group gets SIGTERM, then SIGKILL after a grace period. /// The group is signaled only while its leader is unreaped, so its ID is still ours. +/// The client's stderr passes through `filter` to `sink`; forwarding ends, bounded, +/// before this returns, so nothing from the client follows the operation's result. fn supervise( mut command: std::process::Command, timeout: Duration, canceled: &dyn Fn() -> bool, + filter: output::Filter, + sink: Box, ) -> Result { let mut child = command.spawn().map_err(|_| { refusal( @@ -990,6 +1062,10 @@ fn supervise( "The pinned build client could not be started.", ) })?; + let _forwarder = child + .stderr + .take() + .map(|stderr| output::Forwarder::spawn(stderr, filter, sink)); let deadline = Instant::now() + timeout; loop { if let Some(status) = child.try_wait().map_err(state::io)? { @@ -1128,6 +1204,9 @@ impl<'a> BuildPool for LivePool<'a> { artifact::verify_builder(self.candidate)?; Ok(artifact::builder_executable(self.candidate)) } + fn output(&self) -> Box { + Box::new(std::io::stderr()) + } } /// `runtime build-image --request `: build or reuse one native image. diff --git a/packages/runtime-core/src/provider/image_build/output.rs b/packages/runtime-core/src/provider/image_build/output.rs new file mode 100644 index 000000000..9a4fbf215 --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/output.rs @@ -0,0 +1,309 @@ +//! The build client's output boundary. +//! +//! The client's stderr is a pipe read here, never inherited. Each line is redacted of +//! every build-argument value (the request treats them all as secrets), stripped of +//! terminal control characters and escape sequences, cut at a fixed length, and counted +//! against a fixed budget before it is written to the caller's stream. Redaction is +//! literal: a Dockerfile that transforms a value before printing it (encoding, slicing) +//! is not detected. +use std::{ + io::{Read, Write}, + sync::{Arc, Mutex, mpsc}, + thread::JoinHandle, + time::Duration, +}; + +pub(super) const REDACTED: &str = "[redacted]"; +const LINE_CUT: &str = " [line truncated]"; +const OUTPUT_CUT: &str = "[build output truncated]\n"; +/// Longest forwarded line; the rest of a longer line is dropped. +const MAX_LINE_BYTES: usize = 16 * 1024; +/// Total forwarded bytes; later output is drained and dropped. +const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024; +/// How long forwarding may continue after the client exits (a process outside its group +/// can hold the pipe open). After that, nothing more is written. +const DRAIN_WAIT: Duration = Duration::from_secs(2); + +/// Line filter: redaction, terminal sanitizing and bounds. +pub(super) struct Filter { + /// Literal byte strings to redact, longest first. + needles: Vec>, + forwarded: usize, + exhausted: bool, +} + +/// Keep tabs and printable characters; drop every other control character, which +/// removes the ESC that starts any terminal escape sequence. +fn sanitize(bytes: &[u8]) -> Vec { + String::from_utf8_lossy(bytes) + .chars() + .filter(|c| *c == '\t' || !c.is_control()) + .collect::() + .into_bytes() +} + +fn redact(bytes: &[u8], needles: &[Vec]) -> Vec { + let mut out = Vec::with_capacity(bytes.len()); + let mut at = 0; + 'scan: while at < bytes.len() { + for needle in needles { + if bytes[at..].starts_with(needle) { + out.extend_from_slice(REDACTED.as_bytes()); + at += needle.len(); + continue 'scan; + } + } + out.push(bytes[at]); + at += 1; + } + out +} + +impl Filter { + /// Redact each value and each of its lines, raw and as sanitizing would leave them, + /// so a value spanning lines or containing control characters is still caught. + pub(super) fn new<'a>(values: impl IntoIterator) -> Self { + let mut needles = Vec::new(); + for value in values { + let parts = std::iter::once(value).chain(value.split(['\n', '\r'])); + for part in parts { + needles.push(part.as_bytes().to_vec()); + needles.push(sanitize(part.as_bytes())); + } + } + needles.retain(|needle| !needle.is_empty()); + needles.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b))); + needles.dedup(); + Self { + needles, + forwarded: 0, + exhausted: false, + } + } + + /// The bytes to forward for one line (without its terminator). `cut` marks a line + /// longer than the line bound whose remainder is dropped: a trailing partial match + /// of any value is removed too, so no prefix of a value survives the cut. + pub(super) fn line(&mut self, line: &[u8], cut: bool) -> Vec { + if self.exhausted { + return Vec::new(); + } + let mut raw = line; + let mut partial = false; + if cut { + let tail = self + .needles + .iter() + .filter_map(|needle| { + (1..needle.len().min(raw.len() + 1)) + .rev() + .find(|k| raw.ends_with(&needle[..*k])) + }) + .max() + .unwrap_or(0); + raw = &raw[..raw.len() - tail]; + partial = tail > 0; + } + let mut out = redact(&sanitize(&redact(raw, &self.needles)), &self.needles); + if partial { + out.extend_from_slice(REDACTED.as_bytes()); + } + if cut { + out.extend_from_slice(LINE_CUT.as_bytes()); + } + out.push(b'\n'); + if self.forwarded + out.len() > MAX_OUTPUT_BYTES { + self.exhausted = true; + return OUTPUT_CUT.as_bytes().to_vec(); + } + self.forwarded += out.len(); + out + } + + /// Split a byte stream into bounded lines and filter each one. + fn pump(&mut self, mut source: impl Read, mut emit: impl FnMut(&[u8]) -> bool) { + let mut line = Vec::new(); + let mut skipping = false; + let mut chunk = [0_u8; 8192]; + loop { + let size = match source.read(&mut chunk) { + Ok(0) => break, + Ok(size) => size, + // The cancellation handlers are installed without SA_RESTART. + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + }; + for &byte in &chunk[..size] { + if byte == b'\n' { + if !skipping && !emit(&self.line(&line, false)) { + return; + } + line.clear(); + skipping = false; + } else if !skipping { + line.push(byte); + if line.len() == MAX_LINE_BYTES { + if !emit(&self.line(&line, true)) { + return; + } + line.clear(); + skipping = true; + } + } + } + } + if !line.is_empty() && !skipping { + emit(&self.line(&line, false)); + } + } +} + +type Sink = Arc>>>; + +/// Forwards one pipe through a [`Filter`] on its own thread. Dropping it waits a +/// bounded time for the pipe to close, then closes the sink so no later write lands. +pub(super) struct Forwarder { + sink: Sink, + done: mpsc::Receiver<()>, + thread: Option>, +} + +impl Forwarder { + pub(super) fn spawn( + source: impl Read + Send + 'static, + mut filter: Filter, + sink: Box, + ) -> Self { + let sink: Sink = Arc::new(Mutex::new(Some(sink))); + let shared = Arc::clone(&sink); + let (sender, done) = mpsc::channel(); + let thread = std::thread::spawn(move || { + filter.pump(source, |bytes| { + let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner()); + let Some(sink) = guard.as_mut() else { + return false; + }; + // A failing caller stream must not stall the client; keep draining. + let _ = sink.write_all(bytes).and_then(|()| sink.flush()); + true + }); + let _ = sender.send(()); + }); + Self { + sink, + done, + thread: Some(thread), + } + } +} + +impl Drop for Forwarder { + fn drop(&mut self) { + let finished = self.done.recv_timeout(DRAIN_WAIT).is_ok(); + self.sink.lock().unwrap_or_else(|e| e.into_inner()).take(); + if let Some(thread) = self.thread.take().filter(|_| finished) { + let _ = thread.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filtered(values: &[&str], input: &[u8]) -> String { + let mut filter = Filter::new(values.iter().copied()); + let mut out = Vec::new(); + filter.pump(input, |bytes| { + out.extend_from_slice(bytes); + true + }); + String::from_utf8(out).unwrap() + } + + #[test] + fn values_are_redacted_even_across_lines_and_control_characters() { + let out = filtered( + &["s3cret", "two\nlines", "es\x1bc"], + b"#5 s3cret and s3cret\nhead two\nlines tail\nraw es\x1bc and es[c\n", + ); + assert!(!out.contains("s3cret") && !out.contains("two") && !out.contains("lines")); + assert_eq!(out.matches(REDACTED).count(), 5, "{out}"); + assert!(!out.contains("esc"), "{out}"); + assert!(!out.contains('\x1b')); + } + + #[test] + fn terminal_control_sequences_are_stripped_and_tabs_kept() { + let out = filtered( + &[], + b"\x1b[2J\x1b]0;title\x07ok\tdone\r\n\x00\x7f\xc2\x9bend", + ); + assert_eq!(out, "[2J]0;titleok\tdone\nend\n"); + assert!( + out.bytes() + .all(|b| b == b'\t' || b == b'\n' || !b.is_ascii_control()) + ); + } + + #[test] + fn long_lines_are_cut_without_leaving_a_value_prefix() { + let secret = "abcdefgh"; + let mut input = vec![b'x'; MAX_LINE_BYTES - 3]; + input.extend_from_slice(b"abcdefgh-rest-of-line\nnext\n"); + let out = filtered(&[secret], &input); + let first = out.lines().next().unwrap(); + assert!( + first.ends_with(&format!("{REDACTED}{LINE_CUT}")), + "{first:?}" + ); + assert!(!first.contains("abc")); + assert!(!out.contains("rest-of-line")); + assert!(out.ends_with("next\n")); + } + + #[test] + fn output_beyond_the_budget_is_dropped_with_one_notice() { + let line = vec![b'y'; 1023]; + let mut input = Vec::new(); + for _ in 0..(MAX_OUTPUT_BYTES / 1024 + 10) { + input.extend_from_slice(&line); + input.push(b'\n'); + } + let out = filtered(&[], &input); + assert!(out.len() <= MAX_OUTPUT_BYTES + OUTPUT_CUT.len()); + assert!(out.ends_with(OUTPUT_CUT)); + assert_eq!(out.matches(OUTPUT_CUT).count(), 1); + } + + #[test] + fn a_held_pipe_does_not_outlive_the_forwarder() { + let (reader, writer) = std::os::unix::net::UnixStream::pair().unwrap(); + let captured = Arc::new(Mutex::new(Vec::new())); + struct Shared(Arc>>); + impl Write for Shared { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let forwarder = Forwarder::spawn( + reader, + Filter::new(["v"]), + Box::new(Shared(Arc::clone(&captured))), + ); + (&writer).write_all(b"before v\n").unwrap(); + let started = std::time::Instant::now(); + while captured.lock().unwrap().is_empty() { + assert!(started.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(5)); + } + drop(forwarder); + (&writer).write_all(b"after\n").unwrap(); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!(&*captured.lock().unwrap(), b"before [redacted]\n"); + } +} diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index eeec6f91b..7dde84ed0 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -4,7 +4,11 @@ //! working directory, so no test writes and then executes a fresh binary. It records its //! argv, the one build-argument variable it was given, and a call count. use super::*; -use std::{cell::RefCell, os::unix::fs::PermissionsExt}; +use std::{ + cell::RefCell, + os::unix::fs::PermissionsExt, + sync::{Arc, Mutex}, +}; const IMAGE: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const PIN: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -77,13 +81,20 @@ echo $((count + 1)) > calls printf '%s\n' "$@" > argv printf '%s' "${{GREETING-}}" > greeting meta= +file= previous= for argument do if [ "$previous" = --metadata-file ]; then meta=$argument; fi + if [ "$previous" = --file ]; then file=$argument; fi previous=$argument done +cat "$file" > seen-dockerfile +cat "$file.dockerignore" > seen-dockerignore case {mode} in ok) printf '{{"containerimage.config.digest":"{IMAGE}"}}' > "$meta" ;; + leak) + printf '#5 0.1 value=%s\n\033[31mred\033[0m\033]0;title\007\n' "$GREETING" >&2 + printf '{{"containerimage.config.digest":"{IMAGE}"}}' > "$meta" ;; fail) exit 3 ;; nometa) ;; sleep) sleep 30 & echo $! > child.pending; mv child.pending child; wait ;; @@ -120,6 +131,7 @@ struct FakePool { images: RefCell>, leases: RefCell, resolve_error: bool, + output: Arc>>, } impl FakePool { fn new(incarnation: &str) -> Self { @@ -128,6 +140,7 @@ impl FakePool { images: RefCell::new(BTreeMap::new()), leases: RefCell::new(0), resolve_error: false, + output: Arc::default(), } } /// Register the image the fake client "builds", with the labels the build expects. @@ -171,6 +184,21 @@ impl BuildPool for FakePool { fn builder(&self) -> Result { Ok(PathBuf::from("/bin/sh")) } + fn output(&self) -> Box { + Box::new(Captured(Arc::clone(&self.output))) + } +} + +/// The caller's stream as the fake pool sees it. +struct Captured(Arc>>); +impl Write for Captured { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } } fn never() -> bool { @@ -231,6 +259,72 @@ fn cold_build_passes_values_only_through_the_environment_and_records_the_result( assert!(!text.contains(SECRET), "argument value reached the receipt"); } +#[test] +fn client_output_reaches_the_caller_redacted_and_without_terminal_controls() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "leak"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let output = String::from_utf8(pool.output.lock().unwrap().clone()).unwrap(); + assert_eq!(output, "#5 0.1 value=[redacted]\n[31mred[0m]0;title\n"); + assert!(!output.contains(SECRET)); +} + +#[test] +fn the_client_builds_the_scanned_dockerfile_and_ignore_rules_not_later_edits() { + let fixture = Fixture::new(); + let app = fixture.project.join("app"); + let scanned = fs::read(app.join("Dockerfile")).unwrap(); + fs::write(app.join(".dockerignore"), "*.txt\n").unwrap(); + let inputs = fixture.inputs(Policy::Missing); + // Edits after the scan (while bases resolve) must not reach the client. + fs::write(app.join("Dockerfile"), "FROM unpinned:latest\nRUN id\n").unwrap(); + fs::write(app.join(".dockerignore"), "!*\n").unwrap(); + fs::write(app.join("Dockerfile.dockerignore"), "main.txt\n").unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let scratch = fixture.scratch(&inputs); + assert_eq!(fs::read(scratch.join("seen-dockerfile")).unwrap(), scanned); + assert_eq!( + fs::read_to_string(scratch.join("seen-dockerignore")).unwrap(), + "*.txt\n" + ); + let argv = fs::read_to_string(scratch.join("argv")).unwrap(); + let lines: Vec<&str> = argv.lines().collect(); + let file = lines + .windows(2) + .find(|w| w[0] == "--file") + .map(|w| PathBuf::from(w[1])) + .unwrap(); + assert!(file.starts_with(&scratch), "the client read a project path"); + + // A Dockerfile-specific ignore file wins; with none at all, the private rules are + // empty, so a context .dockerignore created later is never applied. + let inputs = fixture.inputs(Policy::Rebuild); + fs::remove_file(app.join("Dockerfile.dockerignore")).unwrap(); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap_or_else(|e| panic!("{e:?}")); + assert_eq!( + fs::read_to_string(fixture.scratch(&inputs).join("seen-dockerignore")).unwrap(), + "main.txt\n" + ); + fs::remove_file(app.join(".dockerignore")).unwrap(); + let inputs = fixture.inputs(Policy::Rebuild); + fs::write(app.join(".dockerignore"), "*\n").unwrap(); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap_or_else(|e| panic!("{e:?}")); + assert_eq!( + fs::read(fixture.scratch(&inputs).join("seen-dockerignore")).unwrap(), + b"" + ); +} + #[test] fn verified_image_is_reused_without_running_the_client() { let fixture = Fixture::new(); From 7ba0c59189ef9ce3a1638e553e2cecbbbdac7d92 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:11:18 -0500 Subject: [PATCH 090/138] fix(native): never prune parents when reclaiming and keep the last verified image size Reclamation now deletes each candidate with noprune=true, so an untagged parent is never removed without passing the ownership, current-receipt and reference checks itself. A build attempt that verifies no image (failed, canceled, interrupted) now carries the last verified image size forward, so its retry is admitted against twice that size rather than the floor. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 3 ++- .../runtime-core/src/provider/image_build.rs | 9 +++++--- .../src/provider/image_build/reclaim.rs | 22 ++++++++++++++----- .../src/provider/image_build/tests.rs | 12 ++++++++++ 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index a80767d12..12f490bea 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -501,7 +501,8 @@ linked, mode-`0600` file owned by you), because build-argument values are in it: same key replaced. It keeps every image a build receipt names as current, every image that any retained run's saved state mentions, every image a container still uses (the engine refuses those), and every image whose ownership labels are not exactly this -runtime's. It refuses while a build runs, and removes nothing if it cannot read all +runtime's. Each removal deletes that one image only, never its untagged parent +images. It refuses while a build runs, and removes nothing if it cannot read all saved run state. The report lists removed images and why each other one was kept. Before startup, network-tool preparation additionally requires all four pinned diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index e919fc47a..2a0ba3fb0 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -193,7 +193,8 @@ pub(super) struct Receipt { phase: Phase, #[serde(default, skip_serializing_if = "Option::is_none")] image_id: Option, - /// Engine-reported size of the verified image, for the next cold build's admission. + /// Engine-reported size of the last verified image of this key, for the next cold + /// build's admission. Attempts that verify no image carry it forward unchanged. #[serde(default, skip_serializing_if = "Option::is_none")] image_bytes: Option, base_pins: BTreeMap, @@ -806,7 +807,9 @@ pub(super) fn run( engine_id: identity.engine_id.clone(), phase: Phase::Building, image_id: None, - image_bytes: None, + // Failed, canceled or interrupted attempts keep the last verified size, so a + // retry is admitted against it rather than the floor. + image_bytes: previous_bytes, base_pins: pins.clone(), }; state::write(&receipt_path, &receipt)?; @@ -902,7 +905,7 @@ pub(super) fn run( }; receipt.phase = Phase::ContentIdVerified; receipt.image_id = Some(id.clone()); - receipt.image_bytes = image_bytes; + receipt.image_bytes = image_bytes.or(receipt.image_bytes); state::write(&receipt_path, &receipt)?; // The verified image is recorded first; a failed prune leaves only extra cache. let cache_reclaimed_bytes = pool.prune_cache(&lease, CACHE_BUDGET).ok(); diff --git a/packages/runtime-core/src/provider/image_build/reclaim.rs b/packages/runtime-core/src/provider/image_build/reclaim.rs index e903240a8..4e19e718d 100644 --- a/packages/runtime-core/src/provider/image_build/reclaim.rs +++ b/packages/runtime-core/src/provider/image_build/reclaim.rs @@ -9,7 +9,8 @@ //! - no retained graph state (`run/graphs`, `run/native-graphs`, `run/graph-archive`) //! mentions its content ID anywhere, whatever the graph's phase or branch; //! - the engine agrees to remove it without force, which it refuses while any -//! container, running or stopped, uses the image. +//! container, running or stopped, uses the image. Untagged parent images are never +//! pruned along with it (`noprune=true`); each must pass these checks itself. //! //! The operation runs under the provider lease and refuses while a build is admitted, //! so no build can adopt an image between the reference scan and the removal. If the @@ -314,11 +315,7 @@ impl ReclaimPool for LivePool<'_> { .ok_or_else(|| refusal("engine_protocol", "The engine image list is not an array.")) } fn remove(&self, lease: &Self::Lease, image: &str) -> Result { - match lease.request( - reqwest::Method::DELETE, - &format!("/v1.53/images/{image}?force=false&noprune=false"), - None, - ) { + match lease.request(reqwest::Method::DELETE, &removal_path(image), None) { Ok(_) => Ok(true), Err(error) if error.code == "engine_not_found" => Ok(true), Err(error) if error.code == "engine_rejected" => Ok(false), @@ -327,6 +324,12 @@ impl ReclaimPool for LivePool<'_> { } } +/// Remove exactly one image: never forced, and never its untagged parents +/// (`noprune=true`), so every image the engine deletes has passed [`plan`] itself. +fn removal_path(image: &str) -> String { + format!("/v1.53/images/{image}?force=false&noprune=true") +} + /// `runtime reclaim-build-images`: see the module documentation. pub fn reclaim_build_images(candidate: &Candidate) -> Result { run(candidate, &LivePool { candidate }) @@ -384,6 +387,13 @@ mod tests { ); } + #[test] + fn removal_never_forces_or_prunes_untagged_parents() { + let path = removal_path(A); + assert_eq!(path, format!("/v1.53/images/{A}?force=false&noprune=true")); + assert!(!path.contains("noprune=false") && !path.contains("force=true")); + } + #[test] fn image_tokens_find_exact_content_ids_only() { let mut found = BTreeSet::new(); diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index f7d898df7..bb2b083eb 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -887,6 +887,18 @@ fn cold_builds_need_free_storage_and_successful_builds_prune_the_cache() { let reused = run(&fixture.candidate, &pool, &inputs, &never).unwrap(); assert!(reused.reused); assert_eq!(pool.prunes.borrow().len(), 1); + + // A rebuild that fails verifies no image: the last verified size still applies. + *pool.available.borrow_mut() = 10 * GIB; + fixture.client(&rebuild, "fail"); + let error = run(&fixture.candidate, &pool, &rebuild, &never).unwrap_err(); + assert_eq!(error.code, "native_build_failed"); + let receipt = fixture.receipt(&rebuild); + assert_eq!(receipt.phase, Phase::Failed); + assert_eq!(receipt.image_bytes, Some(5 * GIB)); + *pool.available.borrow_mut() = 6 * GIB; + let error = run(&fixture.candidate, &pool, &rebuild, &never).unwrap_err(); + assert_eq!(error.code, "native_build_storage"); } impl reclaim::ReclaimPool for FakePool { From 297df5a0edea31891d9ed2781f5e81570632c3bb Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:14:41 -0500 Subject: [PATCH 091/138] fix(native): drop the build declaration when restart pins a built image Restart now removes a service's build declaration when it adopts the running or retained content ID, matching startup's build pinning, so an unchanged project's restart review equals the saved image-only plan instead of tripping the source compatibility check. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 6 +- .../native-project-restart-preflight.ts | 3 + tests/native-project-restart.test.ts | 77 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index ab743ccfd..8a8fd1d97 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -243,9 +243,9 @@ With it: build keys (`secrets`, `ssh`, `cache_from`, `platforms`, `network` and the rest), remote contexts and `image` together with `build` refuse. Values are never shown. - After the runtime is up, each distinct build runs once with - `runtime build-image` (below), and its output streams to your terminal. Every - service with that build is then pinned to the built content ID, so review, readiness - and restore treat it like an image service. + `runtime build-image` (below), and its filtered output streams to your terminal. + Every service with that build is then pinned to the built content ID, so review, + readiness and restore treat it like an image service. - An existing image for the same build inputs is reused without building. Set `HACK_NATIVE_REBUILD=1` to build again; base-image tags then resolve again too. Edits to other files in the context do not trigger a rebuild, as with Compose. diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 550881f8d..53309f28b 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -290,6 +290,9 @@ async function pinRestartImages(opts: { const retainedImage = opts.retainedImages.get(name); if (retainedImage) { spec.image = retainedImage; + // Startup pins a built image the same way: the content ID replaces the build + // declaration, so the restart review matches the saved image-only plan. + Reflect.deleteProperty(spec, "build"); continue; } if (spec.image === undefined && spec.build !== undefined) { diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index c6450adbe..c0f2452db 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -947,6 +947,83 @@ test("stopped recovery reviews retained image IDs and resolves tags only after o } }); +test("restart pins a build service to its retained image and drops the build declaration", async () => { + const listener = await listenerIntentFixture(); + const previous = process.env.HACK_NATIVE_BUILDS; + process.env.HACK_NATIVE_BUILDS = "1"; + try { + let reviewed: Record | undefined; + const builtImage = `sha256:${"7".repeat(64)}`; + await preflightNativeRestart({ + ...listener.options, + recoverStopped: true, + dependencies: { + ...listener.options.dependencies, + retainedImages: selectNativeRetainedImages, + prepare: async () => ({ + originalSha256: "1".repeat(64), + environmentFiles: [], + serviceNames: ["app"], + normalizedComposeJson: JSON.stringify({ + services: { app: { build: { context: "..", args: { A: "1" } } } }, + }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: "qa", + }), + invoke: async (options) => { + if (options.args[1] === "inspect") { + const observed = stoppedRetainedGraph(); + return { + ...observed, + receipt: { + ...observed.receipt, + normalized_input: { + namespace: run.namespace, + original_compose_sha256: "1".repeat(64), + normalized_compose_sha256: "3".repeat(64), + }, + resources: { + default: observed.receipt.resources.default, + data: observed.receipt.resources.data, + "container:app": { + kind: "container", + key: "app", + image: builtImage, + }, + }, + }, + }; + } + if (options.args[1] === "ensure-image") { + throw new Error("restart must not resolve or build images"); + } + return await listener.options.dependencies!.invoke!(options); + }, + review: async (review) => { + reviewed = JSON.parse(review.input.normalizedComposeJson).services + .app; + return await review.run({ + planId: run.planId, + namespace: run.namespace, + report: {}, + projectArgs: [], + }); + }, + }, + }); + expect(reviewed?.image).toBe(builtImage); + expect(reviewed).not.toHaveProperty("build"); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(process.env, "HACK_NATIVE_BUILDS"); + } else { + process.env.HACK_NATIVE_BUILDS = previous; + } + await rm(listener.directory, { recursive: true, force: true }); + } +}); + test("invalid retained image provenance refuses stopped preflight before tag resolution", async () => { const listener = await listenerIntentFixture(); try { From 1ea5f339e4f6a1d9e89256a0bab65ff628c5547b Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:19:39 -0500 Subject: [PATCH 092/138] fix(native): bound build shutdown on a stalled caller and time base resolution Closing the output forwarder no longer waits on the sink lock: it marks the sink closed, waits at most one second for a write in progress, and returns, so a caller stream that stops draining cannot hold the operation or its build fence. The time budget now starts before base-image resolution: each resolution gets the operation deadline, runs on its own thread so Ctrl-C, SIGTERM and the deadline interrupt it, and the client gets only what remains. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 3 +- .../runtime-core/src/provider/image_build.rs | 83 ++++++++++++++++-- .../src/provider/image_build/output.rs | 84 ++++++++++++++++--- .../src/provider/image_build/tests.rs | 51 ++++++++++- .../src/provider/registry_image.rs | 26 ++++-- 5 files changed, 217 insertions(+), 30 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index a8f5b187d..9fdad5276 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -490,7 +490,8 @@ linked, mode-`0600` file owned by you), because build-argument values are in it: `native_build_runtime_restart`: stop it and start it again. - `runtime down` refuses while a build runs. Ctrl-C or SIGTERM cancels the build and stops the client's whole process group; so does the time budget (default 30 minutes, - at most 2 hours). + at most 2 hours). Both also apply while base images resolve, before the client + starts: the budget covers the whole operation. Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index d276e3ce7..ddf3599ed 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -203,7 +203,13 @@ pub(super) trait BuildPool { /// Inspect one image by content ID or tag; `None` when the engine reports it absent. fn inspect(&self, lease: &Self::Lease, image: &str) -> Result, CandidateError>; /// Resolve one base image to its pinned manifest. Network only; no lease is held. - fn resolve(&self, base: &BaseImage) -> Result; + /// Returns, with an error, by `deadline` or soon after `canceled` reports true. + fn resolve( + &self, + base: &BaseImage, + deadline: Instant, + canceled: &dyn Fn() -> bool, + ) -> Result; /// The verified builder executable. fn builder(&self) -> Result; /// Where the client's filtered output goes. @@ -647,19 +653,27 @@ fn owned_image( /// Run one build operation end to end. See the module documentation for the protocol. /// `canceled` reports a pending cancellation request; it is checked before each -/// unleased step and while the client runs. +/// unleased step, during base resolution and while the client runs. The time budget +/// starts here: base resolution and the client share it. pub(super) fn run( candidate: &Candidate, pool: &P, inputs: &Inputs, canceled: &dyn Fn() -> bool, ) -> Result { + let deadline = Instant::now() + inputs.timeout; let canceled_early = || { refusal( "native_build_canceled", "The image build was canceled before the build client started; no image was adopted.", ) }; + let expired_early = || { + refusal( + "native_build_timeout", + "The image build exceeded its time budget before the build client started; no image was adopted.", + ) + }; let root = builds_root(candidate); state::private_directory(&root)?; let directory = root.join(&inputs.namespace); @@ -736,7 +750,10 @@ pub(super) fn run( if canceled() { return Err(canceled_early()); } - let resolved = pool.resolve(base)?; + if Instant::now() >= deadline { + return Err(expired_early()); + } + let resolved = pool.resolve(base, deadline, canceled)?; if !image_id(&resolved.source_digest) || !resolved .pinned_reference @@ -753,6 +770,9 @@ pub(super) fn run( if canceled() { return Err(canceled_early()); } + if Instant::now() >= deadline { + return Err(expired_early()); + } // Step 3, under the lease: same pool, then record the attempt. let (lease, current) = pool.lease()?; @@ -790,7 +810,7 @@ pub(super) fn run( command( &builder, &scratch, &metadata, inputs, &identity, &pins, &expected, )?, - inputs.timeout, + deadline, canceled, output::Filter::new(inputs.args.values().map(String::as_str)), pool.output(), @@ -1051,7 +1071,7 @@ impl Drop for CancelSignals { /// before this returns, so nothing from the client follows the operation's result. fn supervise( mut command: std::process::Command, - timeout: Duration, + deadline: Instant, canceled: &dyn Fn() -> bool, filter: output::Filter, sink: Box, @@ -1066,7 +1086,6 @@ fn supervise( .stderr .take() .map(|stderr| output::Forwarder::spawn(stderr, filter, sink)); - let deadline = Instant::now() + timeout; loop { if let Some(status) = child.try_wait().map_err(state::io)? { return Ok(Ended::Exited(status.success())); @@ -1197,8 +1216,18 @@ impl<'a> BuildPool for LivePool<'a> { Err(error) => Err(error), } } - fn resolve(&self, base: &BaseImage) -> Result { - registry_image::resolve_build_base(&base.reference) + fn resolve( + &self, + base: &BaseImage, + deadline: Instant, + canceled: &dyn Fn() -> bool, + ) -> Result { + let reference = base.reference.clone(); + interruptible( + move || registry_image::resolve_build_base(&reference, deadline), + deadline, + canceled, + ) } fn builder(&self) -> Result { artifact::verify_builder(self.candidate)?; @@ -1209,6 +1238,44 @@ impl<'a> BuildPool for LivePool<'a> { } } +/// Run a blocking, effect-free network step on its own thread and wait for it until +/// `deadline` or a cancellation request. An abandoned step ends by its own deadline; +/// its result is discarded and it changes no local state. +fn interruptible( + step: impl FnOnce() -> Result + Send + 'static, + deadline: Instant, + canceled: &dyn Fn() -> bool, +) -> Result { + let (sender, result) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = sender.send(step()); + }); + loop { + match result.recv_timeout(POLL) { + Ok(outcome) => return outcome, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err(refusal( + "native_build_base_image", + "Base-image resolution ended without a result.", + )); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + if canceled() { + return Err(refusal( + "native_build_canceled", + "The image build was canceled before the build client started; no image was adopted.", + )); + } + if Instant::now() >= deadline { + return Err(refusal( + "native_build_timeout", + "The image build exceeded its time budget before the build client started; no image was adopted.", + )); + } + } +} + /// `runtime build-image --request `: build or reuse one native image. pub fn build_image(candidate: &Candidate, request: &Path) -> Result { let request: BuildRequest = state::read_bounded(request, MAX_REQUEST_BYTES)?; diff --git a/packages/runtime-core/src/provider/image_build/output.rs b/packages/runtime-core/src/provider/image_build/output.rs index 9a4fbf215..b3e6c9720 100644 --- a/packages/runtime-core/src/provider/image_build/output.rs +++ b/packages/runtime-core/src/provider/image_build/output.rs @@ -8,9 +8,13 @@ //! is not detected. use std::{ io::{Read, Write}, - sync::{Arc, Mutex, mpsc}, + sync::{ + Arc, Mutex, TryLockError, + atomic::{AtomicBool, Ordering}, + mpsc, + }, thread::JoinHandle, - time::Duration, + time::{Duration, Instant}, }; pub(super) const REDACTED: &str = "[redacted]"; @@ -23,6 +27,9 @@ const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024; /// How long forwarding may continue after the client exits (a process outside its group /// can hold the pipe open). After that, nothing more is written. const DRAIN_WAIT: Duration = Duration::from_secs(2); +/// How long closing waits for a write already in progress. A caller stream that stops +/// draining must not hold the operation (and its build fence) open. +const CLOSE_WAIT: Duration = Duration::from_secs(1); /// Line filter: redaction, terminal sanitizing and bounds. pub(super) struct Filter { @@ -158,12 +165,14 @@ impl Filter { } } -type Sink = Arc>>>; - /// Forwards one pipe through a [`Filter`] on its own thread. Dropping it waits a -/// bounded time for the pipe to close, then closes the sink so no later write lands. +/// bounded time for the pipe to close, then marks the sink closed. Each write checks +/// that mark while holding the sink lock, and dropping waits (bounded) for that lock, +/// so no write starts after the drop returns. A write already blocked on a caller +/// stream that stopped draining is abandoned after [`CLOSE_WAIT`] rather than awaited. pub(super) struct Forwarder { - sink: Sink, + sink: Arc>>, + closed: Arc, done: mpsc::Receiver<()>, thread: Option>, } @@ -174,15 +183,16 @@ impl Forwarder { mut filter: Filter, sink: Box, ) -> Self { - let sink: Sink = Arc::new(Mutex::new(Some(sink))); - let shared = Arc::clone(&sink); + let sink = Arc::new(Mutex::new(sink)); + let closed = Arc::new(AtomicBool::new(false)); + let (shared, stop) = (Arc::clone(&sink), Arc::clone(&closed)); let (sender, done) = mpsc::channel(); let thread = std::thread::spawn(move || { filter.pump(source, |bytes| { - let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner()); - let Some(sink) = guard.as_mut() else { + let mut sink = shared.lock().unwrap_or_else(|e| e.into_inner()); + if stop.load(Ordering::Acquire) { return false; - }; + } // A failing caller stream must not stall the client; keep draining. let _ = sink.write_all(bytes).and_then(|()| sink.flush()); true @@ -191,6 +201,7 @@ impl Forwarder { }); Self { sink, + closed, done, thread: Some(thread), } @@ -200,7 +211,14 @@ impl Forwarder { impl Drop for Forwarder { fn drop(&mut self) { let finished = self.done.recv_timeout(DRAIN_WAIT).is_ok(); - self.sink.lock().unwrap_or_else(|e| e.into_inner()).take(); + self.closed.store(true, Ordering::Release); + // Never block on the lock: a writer stuck on a full caller pipe holds it. + let deadline = Instant::now() + CLOSE_WAIT; + while matches!(self.sink.try_lock(), Err(TryLockError::WouldBlock)) + && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(5)); + } if let Some(thread) = self.thread.take().filter(|_| finished) { let _ = thread.join(); } @@ -276,6 +294,48 @@ mod tests { assert_eq!(out.matches(OUTPUT_CUT).count(), 1); } + #[test] + fn a_caller_stream_that_stops_draining_cannot_hold_shutdown() { + let (release, blocked) = mpsc::channel::<()>(); + let writes = Arc::new(Mutex::new(0_usize)); + struct Stuck(mpsc::Receiver<()>, Arc>); + impl Write for Stuck { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + *self.1.lock().unwrap() += 1; + // Like a full pipe whose reader stopped consuming. + let _ = self.0.recv(); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let (reader, writer) = std::os::unix::net::UnixStream::pair().unwrap(); + let forwarder = Forwarder::spawn( + reader, + Filter::new([]), + Box::new(Stuck(blocked, Arc::clone(&writes))), + ); + (&writer).write_all(b"one\n").unwrap(); + let started = Instant::now(); + while *writes.lock().unwrap() == 0 { + assert!(started.elapsed() < Duration::from_secs(5)); + std::thread::sleep(Duration::from_millis(5)); + } + (&writer).write_all(b"two\n").unwrap(); + let started = Instant::now(); + drop(forwarder); + let elapsed = started.elapsed(); + assert!( + elapsed < DRAIN_WAIT + CLOSE_WAIT + Duration::from_secs(2), + "shutdown waited {elapsed:?} on a blocked writer" + ); + // The blocked write completes later; no further line is written after close. + release.send(()).unwrap(); + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*writes.lock().unwrap(), 1); + } + #[test] fn a_held_pipe_does_not_outlive_the_forwarder() { let (reader, writer) = std::os::unix::net::UnixStream::pair().unwrap(); diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index 7dde84ed0..c980c371d 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -131,6 +131,8 @@ struct FakePool { images: RefCell>, leases: RefCell, resolve_error: bool, + resolve_delay: Duration, + resolve_deadlines: RefCell>, output: Arc>>, } impl FakePool { @@ -140,6 +142,8 @@ impl FakePool { images: RefCell::new(BTreeMap::new()), leases: RefCell::new(0), resolve_error: false, + resolve_delay: Duration::ZERO, + resolve_deadlines: RefCell::new(Vec::new()), output: Arc::default(), } } @@ -171,7 +175,14 @@ impl BuildPool for FakePool { fn inspect(&self, _: &(), image: &str) -> Result, CandidateError> { Ok(self.images.borrow().get(image).cloned()) } - fn resolve(&self, base: &BaseImage) -> Result { + fn resolve( + &self, + base: &BaseImage, + deadline: Instant, + _: &dyn Fn() -> bool, + ) -> Result { + self.resolve_deadlines.borrow_mut().push(deadline); + std::thread::sleep(self.resolve_delay); if self.resolve_error { return Err(CandidateError::new("registry_image", "offline")); } @@ -325,6 +336,44 @@ fn the_client_builds_the_scanned_dockerfile_and_ignore_rules_not_later_edits() { ); } +#[test] +fn the_time_budget_starts_before_base_resolution() { + let fixture = Fixture::new(); + let mut inputs = fixture.inputs(Policy::Missing); + inputs.timeout = Duration::from_millis(300); + let mut pool = FakePool::new("pool-1"); + pool.resolve_delay = Duration::from_millis(400); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + let started = Instant::now(); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + let finished = Instant::now(); + assert_eq!(error.code, "native_build_timeout"); + assert_eq!(fixture.calls(&inputs), 0, "the client ran after the budget"); + // Resolution got the operation's deadline, which had passed when it returned. + let deadlines = pool.resolve_deadlines.borrow(); + assert_eq!(deadlines.len(), 1); + assert!(deadlines[0] >= started + inputs.timeout && deadlines[0] < finished); +} + +#[test] +fn blocking_resolution_yields_to_the_deadline_and_to_cancellation() { + let slow = || { + std::thread::sleep(Duration::from_secs(3)); + Ok(()) + }; + let started = Instant::now(); + let error = interruptible(slow, started + Duration::from_millis(100), &never).unwrap_err(); + assert_eq!(error.code, "native_build_timeout"); + let error = interruptible(slow, started + Duration::from_secs(60), &|| true).unwrap_err(); + assert_eq!(error.code, "native_build_canceled"); + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!( + interruptible(|| Ok(7), Instant::now() + Duration::from_secs(5), &never).unwrap(), + 7 + ); +} + #[test] fn verified_image_is_reused_without_running_the_client() { let fixture = Fixture::new(); diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 735101f2b..36c4a7404 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -245,7 +245,10 @@ pub struct AcquiredImage { pub archive_sha256: String, pub archive_bytes: u64, } -fn authenticated_fetch(repo: &str) -> Result { +/// The complete-acquisition bound for one registry resolution or fetch. +const ACQUISITION_BUDGET: Duration = Duration::from_secs(300); + +fn authenticated_fetch(repo: &str, deadline: Instant) -> Result { let mut fetch = Fetch { client: Client::builder() .https_only(true) @@ -255,7 +258,7 @@ fn authenticated_fetch(repo: &str) -> Result { .build() .map_err(|_| error("Cannot initialize registry TLS client."))?, token: Zeroizing::new(String::new()), - deadline: Instant::now() + Duration::from_secs(300), + deadline: deadline.min(Instant::now() + ACQUISITION_BUDGET), }; let mut token_url = Url::parse("https://auth.docker.io/token").map_err(|_| error("Invalid token endpoint."))?; @@ -336,7 +339,8 @@ pub struct ResolvedImage { /// fetch must use pinned_reference: reusing the original tag would discard this review. pub fn resolve(reference_text: &str) -> Result { let (repo, tag) = tagged_reference(reference_text)?; - resolve_manifest(&repo, &tag, None).map(|(resolved, _)| resolved) + resolve_manifest(&repo, &tag, None, Instant::now() + ACQUISITION_BUDGET) + .map(|(resolved, _)| resolved) } /// Resolve one repository at a tag or digest to a Linux ARM64 image and its config bytes. @@ -345,8 +349,9 @@ fn resolve_manifest( repo: &str, reference: &str, expected_source: Option<&str>, + deadline: Instant, ) -> Result<(ResolvedImage, Vec), CandidateError> { - let fetch = authenticated_fetch(repo)?; + let fetch = authenticated_fetch(repo, deadline)?; let url = Url::parse(&format!( "https://registry-1.docker.io/v2/{repo}/manifests/{reference}" )) @@ -401,7 +406,11 @@ pub struct BuildBase { /// Either way the image must offer exactly one Linux ARM64 image, and its configuration /// must declare no `ONBUILD` triggers: a stored trigger could pull images the Dockerfile /// scan never saw. No layers, credentials or runtime state are touched. -pub fn resolve_build_base(named: &NamedReference) -> Result { +/// `deadline` bounds the whole resolution (never beyond the usual acquisition budget). +pub fn resolve_build_base( + named: &NamedReference, + deadline: Instant, +) -> Result { let repo = named.docker_hub_repository().ok_or_else(|| { error("Only public Docker Hub [namespace/]repository[:tag][@sha256] references are supported.") })?; @@ -410,7 +419,7 @@ pub fn resolve_build_base(named: &NamedReference) -> Result (tag.as_str(), None), (None, None) => ("latest", None), }; - let (resolved, config) = resolve_manifest(repo, reference, expected)?; + let (resolved, config) = resolve_manifest(repo, reference, expected, deadline)?; if declares_onbuild(&config)? { return Err(CandidateError::new( "native_build_base_image", @@ -458,7 +467,7 @@ pub fn acquire(reference_text: &str, output: &Path) -> Result named, ImageReference::ImageId(_) => unreachable!(), }; - let error = resolve_build_base(&named("ghcr.io/example/app:1")).unwrap_err(); + let error = + resolve_build_base(&named("ghcr.io/example/app:1"), Instant::now()).unwrap_err(); assert_eq!(error.code, "registry_image"); } From 1d42589481aaffa97dde165e9b33df8cdd27c858 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:24:30 -0500 Subject: [PATCH 093/138] fix(native): keep build arguments named __proto__ Admitted build arguments now collect into a null-prototype dictionary, so an argument named __proto__ is an own property that reaches the runtime request and the build identity instead of invoking the inherited prototype setter. Co-Authored-By: Claude Opus 5.5 --- src/backends/native-project-builds.ts | 3 ++- tests/native-project-builds.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/backends/native-project-builds.ts b/src/backends/native-project-builds.ts index 369b65e2a..63bf3965d 100644 --- a/src/backends/native-project-builds.ts +++ b/src/backends/native-project-builds.ts @@ -103,7 +103,8 @@ function buildArguments(value: unknown): Record { if (entries.length > MAX_ARGS) { throw refused("a native build accepts at most 64 build args."); } - const result: Record = {}; + // A null prototype keeps every admitted name an own property, `__proto__` included. + const result: Record = Object.create(null); for (const [name, raw] of entries) { if (!ARGUMENT.test(name) || Object.hasOwn(result, name)) { throw refused("build arg names must be unique identifiers."); diff --git a/tests/native-project-builds.test.ts b/tests/native-project-builds.test.ts index a77e5c060..e09f01e3a 100644 --- a/tests/native-project-builds.test.ts +++ b/tests/native-project-builds.test.ts @@ -75,6 +75,31 @@ test("admitted build forms follow Compose paths and literal arguments", () => { expect(admit({ image: "busybox" })).toBeUndefined(); }); +test("an argument named __proto__ survives admission and serialization", () => { + const requested = '{"__proto__":"requested-value"}'; + for (const args of [["__proto__=requested-value"], JSON.parse(requested)]) { + const request = admit({ build: { args } }); + expect(Object.keys(request?.args ?? {})).toEqual(["__proto__"]); + expect(JSON.stringify(request?.args)).toBe(requested); + } + // Declarations that differ only in that argument stay distinct builds. + const builds = admitNativeProjectBuilds({ + services: { + plain: { build: { context: "..", args: ["A=1"] } }, + proto: { + build: { context: "..", args: ["A=1", "__proto__=requested-value"] }, + }, + }, + projectRoot: "/p", + composeFile: "/p/.hack/compose.yaml", + enabled: true, + }); + expect(JSON.stringify(builds.get("proto")?.args)).toBe( + '{"A":"1","__proto__":"requested-value"}' + ); + expect(JSON.stringify(builds.get("plain")?.args)).toBe('{"A":"1"}'); +}); + test.each([ [{ image: "busybox", build: "." }, "both image and build"], [{ build: { context: ".", ssh: ["default"] } }, "only build context"], From 1973e9225e5104268b5cd9cceb13a06a8d63006d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:35:19 -0500 Subject: [PATCH 094/138] fix(native): refuse ADD sources that native builds would fetch remotely The engine fetches ADD URLs and git refs from the guest's own network namespace, so they would bypass the build network's isolation. The scanner now refuses any ADD source that BuildKit could treat as remote: after dropping quotes and escapes, a source with `://`, an scp-style `user@host:` ref, or a `$` that could expand to either. This covers shell and JSON forms, every source of a multi-source ADD, ONBUILD triggers and all flags. `--checksum` and `--keep-git-dir` apply only to remote sources and are refused outright. Local sources, local archives, heredocs and COPY are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 54 ++++++++++++++++++- .../src/provider/build_scan/tests.rs | 53 +++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index 6cea9b519..abe59d7bc 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -518,7 +518,14 @@ fn instruction_uses( } } "add" => { - parse_flags(&instruction.flags, &ADD_FLAGS, line)?; + let flags = parse_flags(&instruction.flags, &ADD_FLAGS, line)?; + if flags.contains_key("checksum") || flags.contains_key("keep-git-dir") { + return Err(refusal( + Some(line), + "ADD --checksum and --keep-git-dir apply only to remote sources, which native builds do not fetch.", + )); + } + refuse_remote_add_sources(&instruction.args, escape, line)?; } "run" => { let flags = parse_flags(&instruction.flags, &RUN_FLAGS, line)?; @@ -547,6 +554,51 @@ fn instruction_uses( Ok(uses) } +/// Refuse an `ADD` that could fetch a remote source. +/// +/// The engine fetches `ADD` URLs and git refs from the guest's own network +/// namespace, outside the build network's isolation, so a native build may add +/// only local build-context files. The frontend splits a shell-form `ADD` on +/// whitespace (or reads a JSON string array), takes every word but the last as a +/// source, and expands variables in each. A source is remote when it starts with +/// `scheme://` (http, https, git, ssh) or is an scp-style `user@host:path` git ref. +/// This refuses a superset: after dropping quotes and escapes, any source with +/// `://`, with `@` followed by `:`, or with a `$` that could expand to either. +fn refuse_remote_add_sources(args: &str, escape: char, line: usize) -> Result<(), CandidateError> { + let words: Vec = if json_form(args, line)? { + serde_json::from_str::>(args.trim_start()).map_err(|_| { + refusal( + Some(line), + "a JSON-form argument list must contain strings.", + ) + })? + } else { + args.split(is_frontend_whitespace) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect() + }; + // Every word but the destination; a lone word is checked as well. + let sources = match words.len() { + 0 | 1 => &words[..], + count => &words[..count - 1], + }; + for source in sources { + let bare: String = source + .chars() + .filter(|&c| !matches!(c, '\'' | '"' | '\\') && c != escape) + .collect(); + let scp_like = bare.find('@').is_some_and(|at| bare[at..].contains(':')); + if bare.contains("://") || scp_like || bare.contains('$') { + return Err(refusal( + Some(line), + "ADD may use only local build context sources, without URLs, git refs or variables.", + )); + } + } + Ok(()) +} + /// Whether the frontend parses these arguments as a JSON string array (exec form). fn json_form(args: &str, line: usize) -> Result { let args = args.trim_start(); diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 15ca05438..7f6c147b8 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -549,13 +549,64 @@ fn unknown_instructions_flags_and_preamble_refuse() { // Known flags are accepted. assert_eq!( images( - "FROM alpine:3.22\nADD --link --checksum=sha256:00 --exclude=*.md https://example.com/a /a\n\ + "FROM alpine:3.22\nADD --link --chown=1:1 --chmod=644 --unpack --exclude=*.md app.tar.gz /a\n\ RUN --network=none --security=sandbox true\n" ), ["alpine:3.22"] ); } +#[test] +fn add_refuses_remote_sources() { + // The engine fetches ADD URLs and git refs outside the build network's + // isolation, so only local build context sources are allowed. + for add in [ + "ADD http://example.com/a /a", + "ADD https://example.com/a.tar.gz /a", + "ADD git://example.com/repo.git /src", + "ADD ssh://git@example.com/repo.git /src", + "ADD git@github.com:org/repo.git /src", + "ADD https://example.com/repo.git#main /src", + "ADD HTTPS://example.com/a /a", + "ADD \"https://example.com/a\" /a", + "ADD https\\://example.com/a /a", + "ADD [\"https://example.com/a\", \"/a\"]", + "ADD [\"local.txt\", \"git@github.com:org/repo.git\", \"/a/\"]", + "ADD local.txt https://example.com/b /a/", + "ADD --link --chown=1:1 https://example.com/a /a", + "ADD $URL /a", + "ADD ${SRC}/file /a", + "ADD --checksum=sha256:00 local.txt /a", + "ADD --keep-git-dir local.txt /a", + "ADD --keep-git-dir=false local.txt /a", + ] { + let dockerfile = format!("FROM alpine:3.22\n{add}\n"); + assert_eq!(code(&dockerfile), "native_build_dockerfile", "{add}"); + } + // ONBUILD triggers too. + assert_eq!( + code("FROM alpine:3.22\nONBUILD ADD https://example.com/a /a\n"), + "native_build_dockerfile" + ); + // Local sources, archives, heredocs and a destination with a variable are fine. + for add in [ + "ADD app.tar.gz /srv/", + "ADD a.txt b.txt /dst/", + "ADD [\"a b.txt\", \"/dst/\"]", + "ADD github.com/org/repo /src", + "ADD local.txt $HOME/", + "ADD < Date: Thu, 5 Nov 2026 23:39:21 -0500 Subject: [PATCH 095/138] fix(native): pass build arguments in a private bake definition and widen redaction The build client now runs buildx bake with a private mode-0600 definition file that carries the argument values, the context, the pinned base contexts, labels and tag. It is removed after the run, so values never enter the client's command line or environment, which stays a fixed allowlist. Every string in the definition is escaped so bake never interpolates project text. Argument names must be plain identifiers; only names BuildKit or buildx interpret themselves (BUILDKIT_*, BUILDX_*, SOURCE_DATE_EPOCH) refuse. The output filter also redacts each value's Go-quoted, JSON-escaped and bake encoded forms. On a cut line it trims a trailing partial value from the sanitized bytes that are forwarded, not the raw ones. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 16 +- .../runtime-core/src/provider/image_build.rs | 142 +++++++++---- .../src/provider/image_build/output.rs | 167 +++++++++++++--- .../src/provider/image_build/tests.rs | 187 ++++++++++++++---- 4 files changed, 399 insertions(+), 113 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 9fdad5276..744a6994a 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -474,14 +474,16 @@ linked, mode-`0600` file owned by you), because build-argument values are in it: Base images with stored `ONBUILD` triggers refuse. The client builds from a private copy of the Dockerfile and ignore rules that were scanned, so editing either file during the build has no effect on it. -- Argument values reach the client only through its environment, never its command - line or receipts. Names that would change the client's own environment - (`PATH`, `HOME`, `DOCKER_*`, `BUILDX_*`, `BUILDKIT_*`, proxies, locale, Git and SSH - settings) refuse. +- Argument values reach the client only through a private definition file (mode + `0600`, removed after the build), never its command line, its environment or + receipts; the client's environment is a fixed list of its own settings. Names must + be plain identifiers. Names BuildKit or buildx interpret themselves (`BUILDKIT_*`, + `BUILDX_*`, `SOURCE_DATE_EPOCH`) and the platform arguments refuse. - Build output is shown on stderr after filtering: every argument value is replaced - with `[redacted]`, terminal control characters are removed, lines are cut at 16 KiB - and output stops after 4 MiB. A value that a Dockerfile transforms before printing - it (for example, by encoding it) is not detected. + with `[redacted]`, also when quoted or escaped the way the client prints values, + terminal control characters are removed, lines are cut at 16 KiB and output stops + after 4 MiB. A value that a Dockerfile transforms before printing it (for example, + by encoding it) is not detected. - `missing` reuses a verified image for the same build key without running the client; `rebuild` always builds. The key covers the Dockerfile and `.dockerignore` bytes, target, arguments and versions, not the context's other files (as with Compose). diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index ddf3599ed..ad006d5a7 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -27,10 +27,12 @@ //! is released only when its holder exits): it is recorded `indeterminate` and the //! image is built again. Build outputs are content addressed and adoption always //! rechecks labels and incarnation, so no operator action is needed. -//! - Argument values never appear in argv, receipts or messages: the client receives -//! `--build-arg NAME` and reads the value from its private environment. Its output -//! reaches the caller only through [`output::Filter`], which redacts every value and -//! strips terminal control sequences, within fixed bounds. +//! - Argument values never appear in argv, the client's environment, receipts or +//! messages: the client runs `buildx bake` with a private mode-0600 definition file +//! that carries them, removed after the run, and its environment is cleared to a +//! fixed allowlist. Its output reaches the caller only through [`output::Filter`], +//! which redacts every value and strips terminal control sequences, within fixed +//! bounds. //! - The client builds from a private copy of the Dockerfile and ignore bytes that were //! scanned and hashed, never from the project paths again. //! - Nothing here removes images, prunes caches or adopts an image whose labels differ. @@ -273,18 +275,15 @@ pub(super) fn namespace(root: &Path, branch: Option<&str>) -> String { format!("{:x}", digest.finalize()) } -/// Client environment the build must not let an argument name override, plus names -/// the client itself interprets (proxies would redirect the client's own traffic). +/// Build-argument names BuildKit or buildx interpret themselves rather than passing to +/// the Dockerfile: `BUILDKIT_*` (for example `BUILDKIT_SYNTAX` selects a frontend +/// image), `BUILDX_*` and `SOURCE_DATE_EPOCH`. Any case refuses. Other names, including +/// ones a process environment would interpret (`PATH`, `LD_*`, `GO*`, proxies), are +/// ordinary Dockerfile arguments here: values travel only in the private bake +/// definition and never enter the client's environment. fn reserved_argument(name: &str) -> bool { - const EXACT: [&str; 6] = ["PATH", "HOME", "LANG", "TMPDIR", "USER", "SHELL"]; - const PREFIXES: [&str; 3] = ["DOCKER_", "BUILDX_", "BUILDKIT_"]; let upper = name.to_ascii_uppercase(); - EXACT.contains(&upper.as_str()) - || PREFIXES.iter().any(|prefix| upper.starts_with(prefix)) - || upper.ends_with("_PROXY") - || upper.starts_with("LC_") - || upper.starts_with("GIT_") - || upper.starts_with("SSH_") + upper.starts_with("BUILDKIT_") || upper.starts_with("BUILDX_") || upper == "SOURCE_DATE_EPOCH" } fn valid_argument_name(name: &str) -> bool { @@ -351,7 +350,7 @@ fn validate_request(request: &BuildRequest) -> Result<(), CandidateError> { if reserved_argument(name) { return Err(refusal( "native_build_argument", - "Build argument names may not set the build client's own environment (PATH, HOME, DOCKER_*, BUILDX_*, BUILDKIT_*, proxies, locale, Git or SSH settings).", + "Build argument names may not be ones BuildKit or buildx interpret themselves (BUILDKIT_*, BUILDX_*, SOURCE_DATE_EPOCH).", )); } if value.bytes().any(|b| b == 0) { @@ -806,6 +805,8 @@ pub(super) fn run( Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(state::io(error)), } + // The definition carries argument values; it never outlives the client run. + let _definition = RemoveOnDrop(scratch.join("definition").join(BAKE_DEFINITION)); let ended = supervise( command( &builder, &scratch, &metadata, inputs, &identity, &pins, &expected, @@ -912,7 +913,7 @@ fn built_image_id(metadata: &Path) -> Result { "The build client wrote malformed image metadata.", ) })?; - let id = value["containerimage.config.digest"] + let id = value[BAKE_TARGET]["containerimage.config.digest"] .as_str() .filter(|id| image_id(id)) .ok_or_else(|| { @@ -955,8 +956,70 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<(), CandidateError> { file.write_all(bytes).map_err(state::io) } -/// The client command: argv carries only paths, names and non-secret labels; argument -/// values reach the client through its environment. +/// The one bake target a build defines; its metadata is keyed by this name. +const BAKE_TARGET: &str = "build"; +const BAKE_DEFINITION: &str = "definition.json"; + +/// Removes one private file when dropped, on every exit path. +struct RemoveOnDrop(PathBuf); +impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +/// Escape a string for the bake definition. Bake reads JSON definitions as HCL, where +/// `${...}` interpolates (variables, the environment, functions) and `%{...}` is a +/// template directive; `$${` and `%%{` are their literal forms. Every string in the +/// definition passes through here, so no project-supplied text is ever evaluated. +fn bake_literal(text: &str) -> String { + text.replace("${", "$${").replace("%{", "%%{") +} + +fn bake_strings(value: Value) -> Value { + match value { + Value::String(text) => Value::String(bake_literal(&text)), + Value::Array(items) => Value::Array(items.into_iter().map(bake_strings).collect()), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (bake_literal(&key), bake_strings(value))) + .collect(), + ), + other => other, + } +} + +/// The private bake definition: everything the build needs, argument values +/// included. It is written mode 0600 in the client's private scratch directory. +fn bake_definition( + inputs: &Inputs, + dockerfile: &Path, + pins: &BTreeMap, + labels: &BTreeMap<&'static str, String>, +) -> Value { + let contexts: BTreeMap<&str, String> = pins + .iter() + .map(|(name, reference)| (name.as_str(), format!("docker-image://{reference}"))) + .collect(); + let mut target = serde_json::json!({ + "context": inputs.context.to_string_lossy(), + "dockerfile": dockerfile.to_string_lossy(), + "args": inputs.args, + "contexts": contexts, + "labels": labels, + "tags": [inputs.tag()], + "platforms": ["linux/arm64"], + "output": ["type=docker"], + }); + if let Some(stage) = &inputs.target { + target["target"] = Value::String(stage.clone()); + } + bake_strings(serde_json::json!({"target": {BAKE_TARGET: target}})) +} + +/// The client command: `buildx bake` with the private definition. Argv carries only +/// paths and fixed flags, and the environment is cleared to a fixed allowlist: argument +/// values travel only in the definition, never in argv or the client's environment. fn command( builder: &Path, scratch: &Path, @@ -968,9 +1031,15 @@ fn command( ) -> Result { let config = scratch.join("docker-config"); let buildx = scratch.join("buildx"); + let bake = scratch.join("definition"); state::private_directory(&config)?; state::private_directory(&buildx)?; + state::private_directory(&bake)?; let dockerfile = stage_dockerfile(scratch, inputs)?; + let definition = bake.join(BAKE_DEFINITION); + let encoded = serde_json::to_vec(&bake_definition(inputs, &dockerfile, pins, labels)) + .map_err(|_| invalid("Cannot encode the build definition."))?; + write_private(&definition, &encoded)?; let mut command = super::process::clean_command(builder); command .current_dir(scratch) @@ -986,29 +1055,22 @@ fn command( .env("BUILDX_CONFIG", &buildx) .env("BUILDX_NO_DEFAULT_ATTESTATIONS", "1") .env("DOCKER_CLI_HINTS", "false") - .args(["build", "--progress=plain", "--platform", "linux/arm64"]) - .args(["--provenance=false", "--sbom=false", "--load"]) + .args([ + "bake", + "--progress=plain", + "--provenance=false", + "--sbom=false", + ]) .arg("--file") - .arg(&dockerfile) + .arg(&definition) .arg("--metadata-file") .arg(metadata) - .args(["--tag", &inputs.tag()]); - if let Some(target) = &inputs.target { - command.args(["--target", target]); - } - for (name, value) in &inputs.args { - command.args(["--build-arg", name]).env(name, value); - } - for (name, reference) in pins { - command - .arg("--build-context") - .arg(format!("{name}=docker-image://{reference}")); - } - for (name, value) in labels { - command.arg("--label").arg(format!("{name}={value}")); - } - command - .arg(&inputs.context) + // Bake requires explicit read access outside its working directory. + .arg("--allow") + .arg(format!("fs.read={}", inputs.context.display())) + .arg("--allow") + .arg(format!("fs.read={}", bake_parent(&dockerfile).display())) + .arg(BAKE_TARGET) .stdin(Stdio::null()) .stdout(Stdio::null()) // Output reaches the caller only through `output::Filter`. @@ -1017,6 +1079,10 @@ fn command( Ok(command) } +fn bake_parent(path: &Path) -> &Path { + path.parent().unwrap_or(path) +} + #[derive(Debug, Eq, PartialEq)] enum Ended { Exited(bool), diff --git a/packages/runtime-core/src/provider/image_build/output.rs b/packages/runtime-core/src/provider/image_build/output.rs index b3e6c9720..5e417e683 100644 --- a/packages/runtime-core/src/provider/image_build/output.rs +++ b/packages/runtime-core/src/provider/image_build/output.rs @@ -3,9 +3,12 @@ //! The client's stderr is a pipe read here, never inherited. Each line is redacted of //! every build-argument value (the request treats them all as secrets), stripped of //! terminal control characters and escape sequences, cut at a fixed length, and counted -//! against a fixed budget before it is written to the caller's stream. Redaction is -//! literal: a Dockerfile that transforms a value before printing it (encoding, slicing) -//! is not detected. +//! against a fixed budget before it is written to the caller's stream. Each value is +//! matched raw and in the forms the client prints it in: Go `%q` quoting, JSON string +//! escaping (with and without Go's HTML escaping), and the bake definition's own +//! encoding, which the client quotes in its parse errors. Redaction is otherwise literal: +//! a Dockerfile that transforms a value before printing it (encoding, slicing) is not +//! detected. use std::{ io::{Read, Write}, sync::{ @@ -49,6 +52,88 @@ fn sanitize(bytes: &[u8]) -> Vec { .into_bytes() } +/// The body of Go's `strconv.Quote` (without the surrounding quotes). Printability +/// follows Rust's control and whitespace classes, a close approximation of Go's +/// `unicode.IsPrint`. +fn go_quoted(value: &str) -> String { + let mut out = String::new(); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{7}' => out.push_str("\\a"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{b}' => out.push_str("\\v"), + c if (c as u32) < 0x80 && c.is_control() => { + out.push_str(&format!("\\x{:02x}", c as u32)); + } + c if c.is_control() || (c.is_whitespace() && c != ' ') => { + if (c as u32) < 0x10000 { + out.push_str(&format!("\\u{:04x}", c as u32)); + } else { + out.push_str(&format!("\\U{:08x}", c as u32)); + } + } + c => out.push(c), + } + } + out +} + +/// The body of a JSON string. `html` adds Go `encoding/json`'s default escaping of +/// `<`, `>`, `&`, U+2028 and U+2029. +fn json_escaped(value: &str, html: bool) -> String { + let mut out = String::new(); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + '<' | '>' | '&' | '\u{2028}' | '\u{2029}' if html => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out +} + +/// Every form of one value the filter redacts. +fn value_forms(value: &str) -> Vec { + let mut forms = vec![ + value.to_owned(), + go_quoted(value), + json_escaped(value, false), + json_escaped(value, true), + json_escaped(&super::bake_literal(value), false), + ]; + // A raw value spanning lines is also caught line by line. + forms.extend(value.split(['\n', '\r']).map(str::to_owned)); + forms +} + +/// The length of the longest suffix of `bytes` that is a proper prefix of a needle. +fn partial_tail(bytes: &[u8], needles: &[Vec]) -> usize { + needles + .iter() + .filter_map(|needle| { + (1..needle.len().min(bytes.len() + 1)) + .rev() + .find(|k| bytes.ends_with(&needle[..*k])) + }) + .max() + .unwrap_or(0) +} + fn redact(bytes: &[u8], needles: &[Vec]) -> Vec { let mut out = Vec::with_capacity(bytes.len()); let mut at = 0; @@ -67,15 +152,15 @@ fn redact(bytes: &[u8], needles: &[Vec]) -> Vec { } impl Filter { - /// Redact each value and each of its lines, raw and as sanitizing would leave them, - /// so a value spanning lines or containing control characters is still caught. + /// Redact every form of each value (see [`value_forms`]), raw and as sanitizing + /// would leave it, so a value containing quotes, escapes, control characters or line + /// breaks is still caught. pub(super) fn new<'a>(values: impl IntoIterator) -> Self { let mut needles = Vec::new(); for value in values { - let parts = std::iter::once(value).chain(value.split(['\n', '\r'])); - for part in parts { - needles.push(part.as_bytes().to_vec()); - needles.push(sanitize(part.as_bytes())); + for form in value_forms(value) { + needles.push(sanitize(form.as_bytes())); + needles.push(form.into_bytes()); } } needles.retain(|needle| !needle.is_empty()); @@ -88,30 +173,24 @@ impl Filter { } } - /// The bytes to forward for one line (without its terminator). `cut` marks a line - /// longer than the line bound whose remainder is dropped: a trailing partial match - /// of any value is removed too, so no prefix of a value survives the cut. + /// The bytes to forward for one line (without its terminator). Values are redacted + /// in the raw bytes (catching values that contain control characters), the result + /// is sanitized, and redacted again in the sanitized bytes that are forwarded. `cut` + /// marks a line longer than the line bound whose remainder is dropped: a trailing + /// partial match of any value is then trimmed from the sanitized bytes, the ones + /// actually forwarded, so no prefix of a value survives the cut. pub(super) fn line(&mut self, line: &[u8], cut: bool) -> Vec { if self.exhausted { return Vec::new(); } - let mut raw = line; + let mut clean = sanitize(&redact(line, &self.needles)); let mut partial = false; if cut { - let tail = self - .needles - .iter() - .filter_map(|needle| { - (1..needle.len().min(raw.len() + 1)) - .rev() - .find(|k| raw.ends_with(&needle[..*k])) - }) - .max() - .unwrap_or(0); - raw = &raw[..raw.len() - tail]; + let tail = partial_tail(&clean, &self.needles); + clean.truncate(clean.len() - tail); partial = tail > 0; } - let mut out = redact(&sanitize(&redact(raw, &self.needles)), &self.needles); + let mut out = redact(&clean, &self.needles); if partial { out.extend_from_slice(REDACTED.as_bytes()); } @@ -251,6 +330,44 @@ mod tests { assert!(!out.contains('\x1b')); } + #[test] + fn quoted_and_escaped_forms_of_values_are_redacted() { + let value = "p\"a\\s&\nw\u{1b}d"; + let forms = [ + // Go %q, as buildx prints values in errors. + "p\\\"a\\\\s&\\nw\\x1bd", + // JSON, and Go's HTML-escaped JSON. + "p\\\"a\\\\s&\\nw\\u001bd", + "p\\\"a\\\\s\\u003cs\\u003e\\u0026\\nw\\u001bd", + ]; + for form in forms { + let out = filtered(&[value], format!("err: \"{form}\" done\n").as_bytes()); + assert_eq!(out, format!("err: \"{REDACTED}\" done\n"), "{form}"); + } + // The bake definition encodes `${`, and the client quotes it in parse errors. + let out = filtered(&["a${b}c"], b"1 | {\"args\":{\"X\":\"a$${b}c\"}}\n"); + assert_eq!(out, format!("1 | {{\"args\":{{\"X\":\"{REDACTED}\"}}}}\n")); + assert_eq!( + go_quoted("\u{2028}\u{1f600}\u{7f}"), + "\\u2028\u{1f600}\\x7f" + ); + } + + #[test] + fn a_value_split_by_a_cut_after_sanitizing_leaves_no_prefix() { + // Raw bytes end in ESC, so only the sanitized (forwarded) bytes end in a prefix. + let mut input = vec![b'x'; MAX_LINE_BYTES - 4]; + input.extend_from_slice(b"abc\x1bdefgh tail\n"); + let out = filtered(&["abcdefgh"], &input); + let first = out.lines().next().unwrap(); + assert!( + first.ends_with(&format!("x{REDACTED}{LINE_CUT}")), + "{:?}", + &first[first.len() - 40..] + ); + assert!(!out.contains("abc")); + } + #[test] fn terminal_control_sequences_are_stripped_and_tabs_kept() { let out = filtered( diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index c980c371d..1c87732ab 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -1,8 +1,9 @@ //! `runtime build-image` against a fake pool and a fake build client. //! -//! The fake client is `/bin/sh` reading a script named `build` from the client's private -//! working directory, so no test writes and then executes a fresh binary. It records its -//! argv, the one build-argument variable it was given, and a call count. +//! The fake client is `/bin/sh` reading a script named `bake` (its first argument) from +//! the client's private working directory, so no test writes and then executes a fresh +//! binary. It records its argv, its whole environment, a copy of the bake definition, +//! the staged Dockerfile and ignore rules, and a call count. use super::*; use std::{ cell::RefCell, @@ -79,7 +80,7 @@ impl Fixture { count=$(cat calls 2>/dev/null || echo 0) echo $((count + 1)) > calls printf '%s\n' "$@" > argv -printf '%s' "${{GREETING-}}" > greeting +env > env meta= file= previous= @@ -88,21 +89,22 @@ for argument do if [ "$previous" = --file ]; then file=$argument; fi previous=$argument done -cat "$file" > seen-dockerfile -cat "$file.dockerignore" > seen-dockerignore +cat "$file" > seen-bake.json +cat dockerfile/Dockerfile > seen-dockerfile +cat dockerfile/Dockerfile.dockerignore > seen-dockerignore case {mode} in - ok) printf '{{"containerimage.config.digest":"{IMAGE}"}}' > "$meta" ;; + ok) printf '{{"build":{{"containerimage.config.digest":"{IMAGE}"}}}}' > "$meta" ;; leak) - printf '#5 0.1 value=%s\n\033[31mred\033[0m\033]0;title\007\n' "$GREETING" >&2 - printf '{{"containerimage.config.digest":"{IMAGE}"}}' > "$meta" ;; + printf '#5 0.1 value=%s\n\033[31mred\033[0m\033]0;title\007\n' '{SECRET}' >&2 + printf '{{"build":{{"containerimage.config.digest":"{IMAGE}"}}}}' > "$meta" ;; fail) exit 3 ;; nometa) ;; sleep) sleep 30 & echo $! > child.pending; mv child.pending child; wait ;; esac "# ); - fs::write(scratch.join("build"), script).unwrap(); - fs::set_permissions(scratch.join("build"), fs::Permissions::from_mode(0o600)).unwrap(); + fs::write(scratch.join("bake"), script).unwrap(); + fs::set_permissions(scratch.join("bake"), fs::Permissions::from_mode(0o600)).unwrap(); } fn calls(&self, inputs: &Inputs) -> u32 { fs::read_to_string(self.scratch(inputs).join("calls")) @@ -217,7 +219,7 @@ fn never() -> bool { } #[test] -fn cold_build_passes_values_only_through_the_environment_and_records_the_result() { +fn cold_build_passes_values_only_through_the_private_definition_and_records_the_result() { let fixture = Fixture::new(); let inputs = fixture.inputs(Policy::Missing); let pool = FakePool::new("pool-1"); @@ -237,27 +239,37 @@ fn cold_build_passes_values_only_through_the_environment_and_records_the_result( let argv = fs::read_to_string(scratch.join("argv")).unwrap(); assert!(!argv.contains(SECRET), "argument value reached argv"); let lines: Vec<&str> = argv.lines().collect(); - assert!(lines.windows(2).any(|w| w == ["--build-arg", "GREETING"])); - assert!(lines.windows(2).any(|w| w - == [ - "--build-context", - &format!("alpine:3.20=docker-image://docker.io/library/alpine@{PIN}") - ])); - assert!(lines.windows(2).any(|w| w == ["--platform", "linux/arm64"])); - assert!(lines.contains(&"--provenance=false")); - assert!( - lines - .windows(2) - .any(|w| w == ["--label", "io.hack-local.kind=build-image"]) - ); + // `sh` ran the script named by the first argument, so that argument was `bake`. + assert_eq!(lines.last(), Some(&"build")); + assert!(lines.contains(&"--provenance=false") && lines.contains(&"--sbom=false")); + assert!(lines.windows(2).any(|w| w[0] == "--allow" + && w[1] == format!("fs.read={}", fixture.project.join("app").display()))); assert!( !argv.contains("pool-1"), "the pool incarnation reached argv" ); + let env = fs::read_to_string(scratch.join("env")).unwrap(); + assert!( + !env.contains(SECRET), + "argument value reached the client environment" + ); + assert!(!env.lines().any(|line| line.starts_with("GREETING="))); + let bake: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-bake.json")).unwrap()).unwrap(); + let target = &bake["target"]["build"]; + assert_eq!(target["args"], serde_json::json!({"GREETING": SECRET})); assert_eq!( - fs::read_to_string(scratch.join("greeting")).unwrap(), - SECRET + target["contexts"], + serde_json::json!({ + "alpine:3.20": format!("docker-image://docker.io/library/alpine@{PIN}") + }) ); + assert_eq!(target["platforms"], serde_json::json!(["linux/arm64"])); + assert_eq!(target["output"], serde_json::json!(["type=docker"])); + assert_eq!(target["labels"]["io.hack-local.kind"], "build-image"); + assert_eq!(target["tags"], serde_json::json!([inputs.tag()])); + // The definition carries argument values; it is gone once the client has run. + assert!(!scratch.join("definition").join(BAKE_DEFINITION).exists()); let receipt = fixture.receipt(&inputs); assert_eq!(receipt.phase, Phase::ContentIdVerified); assert_eq!(receipt.image_id.as_deref(), Some(IMAGE)); @@ -270,6 +282,104 @@ fn cold_build_passes_values_only_through_the_environment_and_records_the_result( assert!(!text.contains(SECRET), "argument value reached the receipt"); } +#[test] +fn environment_names_are_plain_arguments_that_never_reach_the_client_environment() { + let fixture = Fixture::new(); + let names = [ + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "GODEBUG", + "PATH", + "HTTPS_PROXY", + "KUBECONFIG", + "DOCKER_HOST", + ]; + let mut request = fixture.request(Policy::Missing); + for name in names { + request + .args + .insert(name.to_owned(), format!("{SECRET}-{name}")); + } + let inputs = prepare(request).unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + let scratch = fixture.scratch(&inputs); + let env = fs::read_to_string(scratch.join("env")).unwrap(); + assert!( + !env.contains(SECRET), + "an argument value reached the client environment" + ); + // The client environment is exactly the fixed allowlist (plus what `sh` adds itself). + let allowed = [ + "PATH", + "LANG", + "HOME", + "TMPDIR", + "DOCKER_HOST", + "DOCKER_CONFIG", + "BUILDX_CONFIG", + "BUILDX_NO_DEFAULT_ATTESTATIONS", + "DOCKER_CLI_HINTS", + "PWD", + "OLDPWD", + "SHLVL", + "_", + ]; + for line in env.lines() { + let name = line.split('=').next().unwrap(); + assert!(allowed.contains(&name), "unexpected client variable {name}"); + } + assert!( + env.lines() + .any(|line| line == "PATH=/usr/bin:/bin:/usr/sbin:/sbin") + ); + assert!( + !env.lines() + .any(|line| line.starts_with("DYLD_") || line.starts_with("GODEBUG=")) + ); + let bake: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-bake.json")).unwrap()).unwrap(); + for name in names { + assert_eq!( + bake["target"]["build"]["args"][name], + format!("{SECRET}-{name}") + ); + } +} + +#[test] +fn bake_definitions_never_evaluate_project_text() { + assert_eq!(bake_literal("${HOME}"), "$${HOME}"); + assert_eq!( + bake_literal("%{ if true }x%{ endif }"), + "%%{ if true }x%%{ endif }" + ); + assert_eq!(bake_literal("$${x} a$ % \\${y}"), "$$${x} a$ % \\$${y}"); + let escaped = bake_strings(serde_json::json!({"a${b}": ["${c}", {"d": "%{e}"}], "n": 1})); + assert_eq!( + escaped, + serde_json::json!({"a$${b}": ["$${c}", {"d": "%%{e}"}], "n": 1}) + ); + let fixture = Fixture::new(); + let mut request = fixture.request(Policy::Missing); + request + .args + .insert("TEMPLATE".into(), "${HOME}%{ if true }".into()); + let inputs = prepare(request).unwrap(); + let definition = bake_definition( + &inputs, + Path::new("/s/Dockerfile"), + &BTreeMap::new(), + &BTreeMap::new(), + ); + assert_eq!( + definition["target"]["build"]["args"]["TEMPLATE"], + "$${HOME}%%{ if true }" + ); +} + #[test] fn client_output_reaches_the_caller_redacted_and_without_terminal_controls() { let fixture = Fixture::new(); @@ -304,13 +414,9 @@ fn the_client_builds_the_scanned_dockerfile_and_ignore_rules_not_later_edits() { fs::read_to_string(scratch.join("seen-dockerignore")).unwrap(), "*.txt\n" ); - let argv = fs::read_to_string(scratch.join("argv")).unwrap(); - let lines: Vec<&str> = argv.lines().collect(); - let file = lines - .windows(2) - .find(|w| w[0] == "--file") - .map(|w| PathBuf::from(w[1])) - .unwrap(); + let bake: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-bake.json")).unwrap()).unwrap(); + let file = PathBuf::from(bake["target"]["build"]["dockerfile"].as_str().unwrap()); assert!(file.starts_with(&scratch), "the client read a project path"); // A Dockerfile-specific ignore file wins; with none at all, the private rules are @@ -706,16 +812,11 @@ fn requests_refuse_unsafe_paths_arguments_and_bounds() { assert_eq!(code(request), "native_build_request"); } for name in [ - "PATH", - "home", - "DOCKER_HOST", "BUILDX_CONFIG", "BUILDKIT_SYNTAX", - "HTTPS_PROXY", - "no_proxy", - "LC_ALL", - "GIT_DIR", - "SSH_AUTH_SOCK", + "buildkit_inline_cache", + "SOURCE_DATE_EPOCH", + "TARGETPLATFORM", "1BAD", "bad-name", "", @@ -737,7 +838,7 @@ fn requests_refuse_unsafe_paths_arguments_and_bounds() { assert_eq!(code(request), "native_build_argument"); // Refusals never echo argument values. let mut request = base(); - request.args = BTreeMap::from([("DOCKER_HOST".to_owned(), SECRET.to_owned())]); + request.args = BTreeMap::from([("BUILDKIT_SYNTAX".to_owned(), SECRET.to_owned())]); assert!(!prepare(request).unwrap_err().message.contains(SECRET)); } From 7bcd47ea5b24f7942eb46ab4c0c05fbd51d72703 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:39:55 -0500 Subject: [PATCH 096/138] docs(storage): note that 1.4.2-helper storage refusal is expected on the candidate channel Co-Authored-By: Claude Opus 5.5 --- docs/reference/native-compose-storage-xattr-carrier.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/reference/native-compose-storage-xattr-carrier.md b/docs/reference/native-compose-storage-xattr-carrier.md index 8716e7a21..b2b3fe084 100644 --- a/docs/reference/native-compose-storage-xattr-carrier.md +++ b/docs/reference/native-compose-storage-xattr-carrier.md @@ -300,6 +300,9 @@ Bun 1.4.2 artifact still parse, so saved stop, inspection and explicit recovery available. That artifact is no longer cataloged: storage enrolled under it refuses verification as artifact drift. It is never relabeled, re-seeded or adopted by the new helper; succession for such storage needs its own qualified contract. +This refusal is expected on the candidate channel: each candidate version starts +with fresh native homes, so storage prepared under the 1.4.2 helper is not carried +forward. Prepare that storage again under the current helper. Maintain the packaged source with `bun scripts/generate-native-storage-witness-helper.ts --check` or `--write`; the generator refuses a changed artifact pin rather than approving new bytes. From de133091f4e819415e471f9483478d2fbd6db088 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:43:56 -0500 Subject: [PATCH 097/138] fix(native): read JSON form as BuildKit does and add a source policy BuildKit decodes exec-form arguments with Go's json.Decoder: it reads only the first value, ignores trailing text, and maps lone surrogate escapes to U+FFFD. The scanner required strict whole-text JSON, so for `ADD ["\uD800https://x/y", "/z"]` or `RUN ["echo"] < --- .../runtime-core/src/provider/build_policy.rs | 72 +++++++++++++++++++ .../runtime-core/src/provider/build_scan.rs | 54 +++++++++----- .../src/provider/build_scan/tests.rs | 44 ++++++++++++ packages/runtime-core/src/provider/mod.rs | 1 + 4 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 packages/runtime-core/src/provider/build_policy.rs diff --git a/packages/runtime-core/src/provider/build_policy.rs b/packages/runtime-core/src/provider/build_policy.rs new file mode 100644 index 000000000..e4dd2921d --- /dev/null +++ b/packages/runtime-core/src/provider/build_policy.rs @@ -0,0 +1,72 @@ +//! BuildKit source policy for native image builds. +//! +//! The engine resolves `ADD` URLs, git sources and image blobs from the guest's own +//! network namespace, outside the build network's isolation. The Dockerfile +//! scanner refuses such sources in the Dockerfile itself, but it cannot see +//! `ONBUILD` triggers stored in a pinned base image's configuration. This policy +//! closes that gap independent of parsing: the solver evaluates every source +//! operation of the build against it, including ones a base image's triggers add, +//! and fails the build on a denied source. +//! +//! The build step writes [`document`] to a private file and passes its path in +//! `EXPERIMENTAL_BUILDKIT_SOURCE_POLICY` to the pinned `buildx` client, which reads +//! it for `build` and `bake` and sends it with the solve request. The document uses +//! the JSON form of BuildKit's `sourcepolicy` `Policy` message, with string enum +//! names. Rules match source identifiers by wildcard; a later matching rule wins, +//! and a source no rule matches is allowed, so `docker-image` and `local` sources +//! (the pinned contexts and the build context) still resolve. + +use serde_json::{Value, json}; + +/// Source identifier schemes that fetch over the network outside image pulls. +pub const DENIED_SCHEMES: [&str; 4] = ["http", "https", "git", "docker-image+blob"]; + +/// The source policy every native build runs with. +pub fn document() -> Value { + let rules: Vec = DENIED_SCHEMES + .iter() + .map(|scheme| { + json!({ + "action": "DENY", + "selector": { + "identifier": format!("{scheme}://*"), + "match_type": "WILDCARD", + }, + }) + }) + .collect(); + json!({ "version": 1, "rules": rules }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_policy_denies_every_network_source_scheme_and_nothing_else() { + let policy = document(); + assert_eq!(policy["version"], 1); + let rules = policy["rules"].as_array().unwrap(); + let identifiers: Vec<&str> = rules + .iter() + .map(|rule| { + // Only the fields BuildKit's `Rule`/`Selector` JSON tags define. + assert_eq!(rule.as_object().unwrap().len(), 2); + assert_eq!(rule["action"], "DENY"); + assert_eq!(rule["selector"]["match_type"], "WILDCARD"); + assert_eq!(rule["selector"].as_object().unwrap().len(), 2); + rule["selector"]["identifier"].as_str().unwrap() + }) + .collect(); + assert_eq!( + identifiers, + ["http://*", "https://*", "git://*", "docker-image+blob://*"] + ); + // No rule touches image or local sources, so pinned contexts still resolve. + assert!( + identifiers + .iter() + .all(|id| !id.starts_with("docker-image://") && !id.starts_with("local://")) + ); + } +} diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index abe59d7bc..f51b6f8cc 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -25,8 +25,10 @@ //! unknown instructions or flags. Every image must be a public Docker Hub reference. //! //! Messages carry line numbers only; they never echo Dockerfile text or argument -//! values. Not covered here: `ONBUILD` triggers stored in a base image's own -//! configuration, which the build step must inspect on the pinned base images. +//! values. `ADD` sources the engine would fetch remotely (URLs, git refs, variables +//! that could become either) refuse. Not covered here: `ONBUILD` triggers stored in +//! a base image's own configuration. The build step closes that with the source +//! policy in [`super::build_policy`], which denies network sources at solve time. use super::image_reference::{ImageReference, NamedReference, normalize}; use crate::CandidateError; use std::collections::{BTreeMap, BTreeSet}; @@ -565,13 +567,8 @@ fn instruction_uses( /// This refuses a superset: after dropping quotes and escapes, any source with /// `://`, with `@` followed by `:`, or with a `$` that could expand to either. fn refuse_remote_add_sources(args: &str, escape: char, line: usize) -> Result<(), CandidateError> { - let words: Vec = if json_form(args, line)? { - serde_json::from_str::>(args.trim_start()).map_err(|_| { - refusal( - Some(line), - "a JSON-form argument list must contain strings.", - ) - })? + let words: Vec = if let Some(words) = json_words(args, line)? { + words } else { args.split(is_frontend_whitespace) .filter(|word| !word.is_empty()) @@ -599,19 +596,40 @@ fn refuse_remote_add_sources(args: &str, escape: char, line: usize) -> Result<() Ok(()) } -/// Whether the frontend parses these arguments as a JSON string array (exec form). -fn json_form(args: &str, line: usize) -> Result { +/// The words of a JSON-form (exec-form) argument list, or `None` for shell form. +/// +/// The frontend's `parseJSON` trims leading whitespace, requires `[`, and decodes +/// with Go's `json.Decoder`: it reads only the first value and ignores what +/// follows, and it decodes lone UTF-16 surrogate escapes leniently to U+FFFD. Its +/// result decides whether heredocs apply and which words are `ADD` sources, so a +/// disagreement could hide instructions or sources. The first value is decoded the +/// same way, trailing text included. Where the strict decoder fails on text that +/// has a `\u` escape, the frontend may still accept it, so that refuses. +fn json_words(args: &str, line: usize) -> Result>, CandidateError> { let args = args.trim_start(); if !args.starts_with('[') { - return Ok(false); + return Ok(None); } - match serde_json::from_str::>(args) { - Ok(values) if values.iter().all(serde_json::Value::is_string) => Ok(true), - Ok(_) => Err(refusal( + let first = serde_json::Deserializer::from_str(args) + .into_iter::() + .next(); + match first { + Some(Ok(serde_json::Value::Array(values))) => values + .into_iter() + .map(|value| match value { + serde_json::Value::String(word) => Ok(word), + _ => Err(refusal( + Some(line), + "a JSON-form argument list must contain strings.", + )), + }) + .collect::, _>>() + .map(Some), + _ if args.contains("\\u") => Err(refusal( Some(line), - "a JSON-form argument list must contain strings.", + "a JSON-form argument list must be strict JSON without lone surrogate escapes.", )), - Err(_) => Ok(false), + _ => Ok(None), } } @@ -736,7 +754,7 @@ fn heredoc_capable( }; Ok( matches!(instruction.command.as_str(), "run" | "copy" | "add") - && !json_form(&instruction.args, line)?, + && json_words(&instruction.args, line)?.is_none(), ) } diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index 7f6c147b8..b7ebfc71a 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -607,6 +607,50 @@ fn add_refuses_remote_sources() { ); } +#[test] +fn json_form_is_read_as_the_frontend_reads_it() { + // Go's decoder maps a lone surrogate to U+FFFD where strict JSON fails, so the + // frontend would see a JSON-form remote source: refuse. + for add in [ + "ADD [\"\\uD800https://x/y\", \"/z\"]", + "ADD [\"\\udfff\", \"https://x/y\", \"/z\"]", + "COPY [\"\\uD800\", \"/z\"]", + "RUN [\"\\uD800\"]", + ] { + let dockerfile = format!("FROM alpine:3.22\n{add}\n"); + assert_eq!(code(&dockerfile), "native_build_dockerfile", "{add}"); + } + // The decoder reads only the first value: text after it does not make this + // shell form, so the decoded words are the sources. + assert_eq!( + code("FROM alpine:3.22\nADD [\"https://x/y\", \"/z\"] trailing\n"), + "native_build_dockerfile" + ); + // Only the first value counts, so a malformed second one is ignored there too. + assert_eq!( + images("FROM alpine:3.22\nADD [\"a\", \"/z\"] [\"\\uD800\"]\n"), + ["alpine:3.22"] + ); + // ...and no heredoc starts there, so the next lines are instructions. + let dockerfile = + "FROM alpine:3.22\nRUN [\"echo\"] < Date: Thu, 5 Nov 2026 23:47:08 -0500 Subject: [PATCH 098/138] docs(native): state that build arguments are not secrets Build arguments follow Docker: an image records their values in its history, and resolved base references an argument selects are reported in receipts and results. The guide now says never to pass secrets as build arguments and points to BuildKit build secrets, which native builds do not support yet. Output redaction stays as defense in depth. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 22 +++++++++++-------- .../runtime-core/src/provider/image_build.rs | 8 ++++++- .../src/provider/image_build/output.rs | 2 +- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 744a6994a..897d9e553 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -460,7 +460,7 @@ refuses; it reports `builder_prepared` when the client is present and unchanged. `runtime build-image --request --json` builds one image in the running runtime's engine with that client. It is a building block: native `up` still refuses `build:` services and does not call it. The request is a private JSON file (a singly -linked, mode-`0600` file owned by you), because build-argument values are in it: +linked, mode-`0600` file owned by you): ```json {"version": 1, "project_root": "/absolute/project", "context": "app", @@ -474,16 +474,20 @@ linked, mode-`0600` file owned by you), because build-argument values are in it: Base images with stored `ONBUILD` triggers refuse. The client builds from a private copy of the Dockerfile and ignore rules that were scanned, so editing either file during the build has no effect on it. +- Build arguments are not secrets, exactly as with Docker: the built image records + their values in its history, and an argument that selects a base image appears in + the resolved base references that the receipt and the result report. Never pass a + secret as a build argument. BuildKit build secrets are the right mechanism for + secrets; native builds do not support them yet. - Argument values reach the client only through a private definition file (mode - `0600`, removed after the build), never its command line, its environment or - receipts; the client's environment is a fixed list of its own settings. Names must - be plain identifiers. Names BuildKit or buildx interpret themselves (`BUILDKIT_*`, + `0600`, removed after the build), never its command line or its environment; the + client's environment is a fixed list of its own settings. Names must be plain + identifiers. Names BuildKit or buildx interpret themselves (`BUILDKIT_*`, `BUILDX_*`, `SOURCE_DATE_EPOCH`) and the platform arguments refuse. -- Build output is shown on stderr after filtering: every argument value is replaced - with `[redacted]`, also when quoted or escaped the way the client prints values, - terminal control characters are removed, lines are cut at 16 KiB and output stops - after 4 MiB. A value that a Dockerfile transforms before printing it (for example, - by encoding it) is not detected. +- Build output is shown on stderr after filtering, as defense in depth: every + argument value is replaced with `[redacted]`, also when quoted or escaped the way + the client prints values, terminal control characters are removed, lines are cut at + 16 KiB and output stops after 4 MiB. - `missing` reuses a verified image for the same build key without running the client; `rebuild` always builds. The key covers the Dockerfile and `.dockerignore` bytes, target, arguments and versions, not the context's other files (as with Compose). diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index ad006d5a7..d1235b15d 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -35,6 +35,11 @@ //! bounds. //! - The client builds from a private copy of the Dockerfile and ignore bytes that were //! scanned and hashed, never from the project paths again. +//! - Build arguments are not secrets, as with Docker: an image records their values in +//! its history, and resolved base references (which an argument may select) are +//! reported in receipts and results. Hack still keeps values out of argv, the +//! client's environment and its own messages, and redacts them from forwarded output +//! as defense in depth. The caller refuses values equal to a managed secret. //! - Nothing here removes images, prunes caches or adopts an image whose labels differ. use super::{ artifact, @@ -136,7 +141,8 @@ pub struct BuildRequest { pub dockerfile: String, #[serde(default)] pub target: Option, - /// Literal build arguments. Values are secrets for every output purpose. + /// Literal build arguments. They are not secrets (an image records them in its + /// history), but Hack keeps them out of argv and its own messages. #[serde(default)] pub args: BTreeMap, pub policy: Policy, diff --git a/packages/runtime-core/src/provider/image_build/output.rs b/packages/runtime-core/src/provider/image_build/output.rs index 5e417e683..ce5d23e1c 100644 --- a/packages/runtime-core/src/provider/image_build/output.rs +++ b/packages/runtime-core/src/provider/image_build/output.rs @@ -1,7 +1,7 @@ //! The build client's output boundary. //! //! The client's stderr is a pipe read here, never inherited. Each line is redacted of -//! every build-argument value (the request treats them all as secrets), stripped of +//! every build-argument value (defense in depth; arguments are not secrets), stripped of //! terminal control characters and escape sequences, cut at a fixed length, and counted //! against a fixed budget before it is written to the caller's stream. Each value is //! matched raw and in the forms the client prints it in: Go `%q` quoting, JSON string From f21d5a04a1b75044b804452cbae5fe07b7a9f4a0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:48:34 -0500 Subject: [PATCH 099/138] fix(native): allow only pinned images in the build source policy Unmatched sources are allowed, so the policy let a base image's ONBUILD trigger pull any unpinned image. `build_policy::document` now takes the build's digest-pinned base references and denies every docker-image and oci-layout source except exactly those, normalized the way BuildKit's llb.Image names a `docker-image://` named context. The http, https, git and docker-image+blob denials stay last. References that are not digest-pinned public Docker Hub images refuse. The docs now say plainly that nothing applies the policy yet: ONBUILD triggers in base images are not enforced until the build step passes it to buildx. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_policy.rs | 244 ++++++++++++++---- .../runtime-core/src/provider/build_scan.rs | 5 +- 2 files changed, 201 insertions(+), 48 deletions(-) diff --git a/packages/runtime-core/src/provider/build_policy.rs b/packages/runtime-core/src/provider/build_policy.rs index e4dd2921d..e2a37aa7f 100644 --- a/packages/runtime-core/src/provider/build_policy.rs +++ b/packages/runtime-core/src/provider/build_policy.rs @@ -1,72 +1,224 @@ //! BuildKit source policy for native image builds. //! -//! The engine resolves `ADD` URLs, git sources and image blobs from the guest's own -//! network namespace, outside the build network's isolation. The Dockerfile -//! scanner refuses such sources in the Dockerfile itself, but it cannot see -//! `ONBUILD` triggers stored in a pinned base image's configuration. This policy -//! closes that gap independent of parsing: the solver evaluates every source -//! operation of the build against it, including ones a base image's triggers add, -//! and fails the build on a denied source. +//! The engine fetches `ADD` URLs, git sources and image blobs from the guest's own +//! network namespace, outside the build network's isolation, and pulls any image a +//! build names. The Dockerfile scanner refuses remote sources in the Dockerfile +//! itself, but it cannot see `ONBUILD` triggers stored in a pinned base image's +//! configuration, which may add remote sources or name other images. //! -//! The build step writes [`document`] to a private file and passes its path in -//! `EXPERIMENTAL_BUILDKIT_SOURCE_POLICY` to the pinned `buildx` client, which reads -//! it for `build` and `bake` and sends it with the solve request. The document uses -//! the JSON form of BuildKit's `sourcepolicy` `Policy` message, with string enum -//! names. Rules match source identifiers by wildcard; a later matching rule wins, -//! and a source no rule matches is allowed, so `docker-image` and `local` sources -//! (the pinned contexts and the build context) still resolve. +//! [`document`] is the policy meant to close that gap independent of parsing: the +//! solver checks every source operation of a build against it, including ones a +//! base image's triggers add, and fails the build on a denied source. **Nothing +//! applies it yet.** Until the build step writes it to a private file and passes +//! that path in `EXPERIMENTAL_BUILDKIT_SOURCE_POLICY` to the pinned `buildx` +//! client (which reads it for `build` and `bake`), `ONBUILD` triggers in base +//! images are not enforced. +//! +//! The document is the JSON form of BuildKit's `sourcepolicy` `Policy` message, with +//! string enum names. A rule matches when the source identifier equals its selector +//! identifier, or, for `WILDCARD`, matches it with `*` standing for any text. The +//! last matching rule decides, and a source no rule matches is allowed. Rules, in +//! order: +//! +//! 1. deny every `docker-image://` and `oci-layout://` source; +//! 2. allow exactly each pinned base image, as the identifier BuildKit gives a +//! `docker-image://` named context: `docker-image://` and the +//! normalized `docker.io//[:tag]@sha256:`; +//! 3. deny every `http`, `https`, `git` and `docker-image+blob` source. +//! +//! `local://` sources (the build context and Dockerfile) match no rule and resolve. +use super::image_reference::{ImageReference, normalize}; +use crate::CandidateError; use serde_json::{Value, json}; +use std::collections::BTreeSet; -/// Source identifier schemes that fetch over the network outside image pulls. -pub const DENIED_SCHEMES: [&str; 4] = ["http", "https", "git", "docker-image+blob"]; +/// Image source schemes denied unless the exact pinned identifier is allowed. +pub const IMAGE_SCHEMES: [&str; 2] = ["docker-image", "oci-layout"]; +/// Source schemes that fetch over the network, always denied. +pub const NETWORK_SCHEMES: [&str; 4] = ["http", "https", "git", "docker-image+blob"]; + +fn rule(action: &str, identifier: String, match_type: &str) -> Value { + json!({ + "action": action, + "selector": { "identifier": identifier, "match_type": match_type }, + }) +} -/// The source policy every native build runs with. -pub fn document() -> Value { - let rules: Vec = DENIED_SCHEMES +/// The source policy a native build runs with, allowing only `pinned` images. +/// +/// Each entry is a base image reference the build passes as a +/// `docker-image://` named context. It must be a public Docker Hub +/// reference pinned by digest; it is normalized the way BuildKit's `llb.Image` +/// normalizes it, so the allow rule equals the source identifier exactly. +pub fn document<'a>(pinned: impl IntoIterator) -> Result { + let mut allowed = BTreeSet::new(); + for reference in pinned { + match normalize(reference) { + Ok(ImageReference::Named(named)) + if named.digest.is_some() && named.docker_hub_repository().is_some() => + { + allowed.insert(format!("docker-image://{}", named.canonical())); + } + _ => { + return Err(CandidateError::new( + "native_build_base_image", + "A build source policy allows only digest-pinned public Docker Hub images.", + )); + } + } + } + let mut rules: Vec = IMAGE_SCHEMES .iter() - .map(|scheme| { - json!({ - "action": "DENY", - "selector": { - "identifier": format!("{scheme}://*"), - "match_type": "WILDCARD", - }, - }) - }) + .map(|scheme| rule("DENY", format!("{scheme}://*"), "WILDCARD")) .collect(); - json!({ "version": 1, "rules": rules }) + rules.extend( + allowed + .into_iter() + .map(|identifier| rule("ALLOW", identifier, "EXACT")), + ); + rules.extend( + NETWORK_SCHEMES + .iter() + .map(|scheme| rule("DENY", format!("{scheme}://*"), "WILDCARD")), + ); + Ok(json!({ "version": 1, "rules": rules })) } #[cfg(test)] mod tests { use super::*; + const A: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const B: &str = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + /// BuildKit's `Wildcard2Regexp`: `^...$` with each `*` matching any text. + fn wildcard(pattern: &str, value: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + let (first, last) = (parts[0], parts[parts.len() - 1]); + if parts.len() == 1 { + return pattern == value; + } + if !value.starts_with(first) || value.len() < first.len() + last.len() { + return false; + } + let mut rest = &value[first.len()..value.len() - last.len()]; + if !value.ends_with(last) { + return false; + } + for middle in &parts[1..parts.len() - 1] { + match rest.find(middle) { + Some(at) => rest = &rest[at + middle.len()..], + None => return false, + } + } + true + } + + /// BuildKit's `evaluatePolicy` for ALLOW/DENY rules: the last match decides. + fn allowed(policy: &Value, identifier: &str) -> bool { + let mut deny = false; + for rule in policy["rules"].as_array().unwrap() { + let selector = &rule["selector"]; + let pattern = selector["identifier"].as_str().unwrap(); + let matched = pattern == identifier + || (selector["match_type"] == "WILDCARD" && wildcard(pattern, identifier)); + if matched { + deny = rule["action"] == "DENY"; + } + } + !deny + } + #[test] - fn the_policy_denies_every_network_source_scheme_and_nothing_else() { - let policy = document(); + fn only_the_pinned_images_and_local_sources_are_allowed() { + let alpine = format!("docker.io/library/alpine@{A}"); + let tagged = format!("node:24-slim@{B}"); + let policy = document([alpine.as_str(), tagged.as_str()]).unwrap(); assert_eq!(policy["version"], 1); - let rules = policy["rules"].as_array().unwrap(); - let identifiers: Vec<&str> = rules + for identifier in [ + format!("docker-image://docker.io/library/alpine@{A}"), + format!("docker-image://docker.io/library/node:24-slim@{B}"), + "local://context".to_string(), + "local://dockerfile".to_string(), + ] { + assert!(allowed(&policy, &identifier), "{identifier}"); + } + for identifier in [ + // Unpinned, another digest, another repository with the same digest. + "docker-image://docker.io/library/alpine:3.22".to_string(), + "docker-image://docker.io/library/alpine:latest".to_string(), + format!("docker-image://docker.io/library/alpine@{B}"), + format!("docker-image://docker.io/library/busybox@{A}"), + format!("docker-image://docker.io/library/node@{B}"), + format!("docker-image://ghcr.io/example/app@{A}"), + format!("oci-layout://store/alpine@{A}"), + "https://example.com/a".to_string(), + "http://example.com/a".to_string(), + "git://github.com/org/repo.git".to_string(), + format!("docker-image+blob://docker.io/library/alpine@{A}"), + ] { + assert!(!allowed(&policy, &identifier), "{identifier}"); + } + } + + #[test] + fn the_rule_list_has_the_documented_order_and_shape() { + let short = format!("alpine@{A}"); + let policy = document([short.as_str(), short.as_str()]).unwrap(); + let rules: Vec<(String, String, String)> = policy["rules"] + .as_array() + .unwrap() .iter() .map(|rule| { // Only the fields BuildKit's `Rule`/`Selector` JSON tags define. assert_eq!(rule.as_object().unwrap().len(), 2); - assert_eq!(rule["action"], "DENY"); - assert_eq!(rule["selector"]["match_type"], "WILDCARD"); assert_eq!(rule["selector"].as_object().unwrap().len(), 2); - rule["selector"]["identifier"].as_str().unwrap() + ( + rule["action"].as_str().unwrap().to_string(), + rule["selector"]["identifier"].as_str().unwrap().to_string(), + rule["selector"]["match_type"].as_str().unwrap().to_string(), + ) }) .collect(); - assert_eq!( - identifiers, - ["http://*", "https://*", "git://*", "docker-image+blob://*"] - ); - // No rule touches image or local sources, so pinned contexts still resolve. - assert!( - identifiers - .iter() - .all(|id| !id.starts_with("docker-image://") && !id.starts_with("local://")) - ); + let expected: Vec<(String, String, String)> = [ + ("DENY", "docker-image://*".to_string(), "WILDCARD"), + ("DENY", "oci-layout://*".to_string(), "WILDCARD"), + // Normalized like BuildKit's `llb.Image`, and listed once. + ( + "ALLOW", + format!("docker-image://docker.io/library/alpine@{A}"), + "EXACT", + ), + ("DENY", "http://*".to_string(), "WILDCARD"), + ("DENY", "https://*".to_string(), "WILDCARD"), + ("DENY", "git://*".to_string(), "WILDCARD"), + ("DENY", "docker-image+blob://*".to_string(), "WILDCARD"), + ] + .into_iter() + .map(|(a, i, m)| (a.to_string(), i, m.to_string())) + .collect(); + assert_eq!(rules, expected); + // No pinned images: every image source is denied. + let empty = document([]).unwrap(); + assert!(!allowed( + &empty, + &format!("docker-image://docker.io/library/alpine@{A}") + )); + } + + #[test] + fn only_digest_pinned_docker_hub_references_are_accepted() { + for reference in [ + "alpine:3.22".to_string(), + "alpine".to_string(), + format!("ghcr.io/example/app@{A}"), + A.to_string(), + "not a reference".to_string(), + ] { + let error = document([reference.as_str()]).unwrap_err(); + assert_eq!(error.code, "native_build_base_image", "{reference}"); + assert!(!error.message.contains(&reference), "{reference}"); + } } } diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index f51b6f8cc..2ed941851 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -27,8 +27,9 @@ //! Messages carry line numbers only; they never echo Dockerfile text or argument //! values. `ADD` sources the engine would fetch remotely (URLs, git refs, variables //! that could become either) refuse. Not covered here: `ONBUILD` triggers stored in -//! a base image's own configuration. The build step closes that with the source -//! policy in [`super::build_policy`], which denies network sources at solve time. +//! a base image's own configuration. They are not enforced until the build step +//! applies the source policy in [`super::build_policy`], which denies network +//! sources and unpinned images at solve time. use super::image_reference::{ImageReference, NamedReference, normalize}; use crate::CandidateError; use std::collections::{BTreeMap, BTreeSet}; From b007be2475513650ad6720b0ed5cdbc3a5691a28 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:52:27 -0500 Subject: [PATCH 100/138] fix(native): build only active profiles and refuse managed secrets as build args Native up now follows Compose up: only build services in the selected profiles are built and pinned. An inactive build service keeps its build declaration, which review accepts for inactive services, so restore and restart need no image for it; selecting its profile in a later fresh up builds it then. Build args are not secrets, so a build arg whose value equals a hack env secret of the project and environment now refuses before any hook or VM effect, naming only the argument. The comparison is in memory and no value is shown. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 12 +- src/backends/native-project-builds.ts | 61 +++++++++- src/backends/native-project-input.ts | 38 +++++++ .../native-project-restart-preflight.ts | 8 ++ src/backends/native-project-start.ts | 13 ++- tests/native-project-builds.test.ts | 106 ++++++++++++++++++ tests/native-project-input.test.ts | 36 ++++++ tests/native-project-restart.test.ts | 17 ++- 8 files changed, 280 insertions(+), 11 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 836023efd..b566ddfde 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -242,6 +242,10 @@ With it: interpolation, a list item without `=` (host variable inheritance), `null`, other build keys (`secrets`, `ssh`, `cache_from`, `platforms`, `network` and the rest), remote contexts and `image` together with `build` refuse. Values are never shown. +- Build args are not secrets: as with Docker, the image records them in its history. + A build arg whose value equals a `hack env` secret value of this project and + environment refuses before any hook or VM effect, naming only the argument. Pass + secrets with BuildKit build secrets instead; native builds do not support them yet. - After the runtime is up, each distinct build runs once with `runtime build-image` (below), and its filtered output streams to your terminal. Every service with that build is then pinned to the built content ID, so review, @@ -252,9 +256,11 @@ With it: - Restoring an unchanged retained run uses the retained images and never builds; if one is missing, `up` refuses and asks you to start fresh. `hack restart` also never builds: it reuses the running or retained image, or refuses before stopping anything. -- Services in profiles you did not select are built too, because the native review - pins every declared service. Native builds are `linux/arm64` only; - `platform: linux/amd64` is still refused. +- As with Compose `up`, only build services in the selected profiles are built and + pinned. A build service in a profile you did not select is not built, and restore + and restart need no image for it; selecting its profile in a later fresh `up` builds + it then. Native builds are `linux/arm64` only; `platform: linux/amd64` is still + refused. `hack doctor` reports the same features for the current project in its `compose backend features` check. With `--json`, the check's `composeBackend.features` diff --git a/src/backends/native-project-builds.ts b/src/backends/native-project-builds.ts index 63bf3965d..0d1b737eb 100644 --- a/src/backends/native-project-builds.ts +++ b/src/backends/native-project-builds.ts @@ -421,6 +421,53 @@ function systemEnvironment(): Record { return env; } +/** + * Whether a service runs for the selected profiles, as in Compose `up`: a service with + * no `profiles` always runs; otherwise one of its profiles must be selected. A + * malformed `profiles` field counts as active, so it is built and reviewed. + */ +export function nativeServiceActive( + service: Readonly>, + profiles: readonly string[] +): boolean { + const declared = service.profiles; + if (declared === undefined) { + return true; + } + if (!Array.isArray(declared) || declared.length === 0) { + return true; + } + return declared.some( + (profile) => typeof profile === "string" && profiles.includes(profile) + ); +} + +/** + * Refuse, before any hook, VM or build effect, a build argument whose value equals a + * secret value Hack manages for this project and environment. Build arguments are + * not secrets (an image records them in its history), so a managed secret must not + * travel as one. The comparison is in memory; no value is ever shown. + */ +export function refuseManagedSecretBuildArguments(opts: { + readonly builds: ReadonlyMap; + readonly managedSecretValues: readonly string[]; +}): void { + if (opts.managedSecretValues.length === 0) { + return; + } + const secrets = new Set(opts.managedSecretValues.filter((v) => v !== "")); + for (const [service, request] of opts.builds) { + for (const [name, value] of Object.entries(request.args)) { + if (secrets.has(value)) { + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `Native foreground up cannot build ${service}: build arg ${name} equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started.`, + }); + } + } + } +} + /** A refusal when a retained run's built image is gone: restore never builds. */ function retainedMissing(service: string): ClosedCliError { return new ClosedCliError( @@ -429,14 +476,18 @@ function retainedMissing(service: string): ClosedCliError { } /** - * Build (or reuse) every admitted build service and pin it to its content ID. - * Identical declarations build once. Retained content IDs always win, and a - * restore with a missing retained image refuses instead of building. + * Build (or reuse) every admitted build service in the selected profiles and pin it to + * its content ID. As with Compose `up`, a build service outside the selected profiles + * is neither built nor pinned: it keeps its `build` declaration, which review accepts + * for inactive services, so restore and restart never need an image for it. Identical + * declarations build once. Retained content IDs always win, and a restore with a + * missing retained image refuses instead of building. */ export async function buildNativeProjectImages(opts: { readonly runtime: NativeRuntimeSelection; readonly projectRoot: string; readonly branch: string | null | undefined; + readonly profiles: readonly string[]; readonly builds: ReadonlyMap; readonly specs: Record>; readonly retainedImages: ReadonlyMap; @@ -456,6 +507,7 @@ type PendingBuild = { request: NativeBuildRequest; services: string[] }; /** Pin retained images, refuse a restore without one, and group identical builds. */ function pendingBuilds(opts: { + readonly profiles: readonly string[]; readonly builds: ReadonlyMap; readonly specs: Record>; readonly retainedImages: ReadonlyMap; @@ -469,6 +521,9 @@ function pendingBuilds(opts: { "Native build services changed during startup; values omitted." ); } + if (!nativeServiceActive(spec, opts.profiles)) { + continue; + } const retained = opts.retainedImages.get(service); if (retained !== undefined) { pin(spec, retained); diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index ab83234d9..f14daaeea 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -19,6 +19,11 @@ export type NativeProjectInput = { readonly managedEnvironment: Readonly< Record>> >; + /** + * Private in-memory only: the values Hack stores as secrets for this project and + * environment, so build arguments can refuse to carry one. Never log or write it. + */ + readonly managedSecretValues?: readonly string[]; /** Private lifecycle environment; not container environment. */ readonly lifecycleHostEnvironment: Readonly>; readonly effectiveEnvName: string | null; @@ -131,6 +136,38 @@ function normalizeManagedEnvironment( return managedEnvironment; } +/** Every value the resolved project env stores as a secret, for any scope or target. */ +function managedSecretValues( + resolved: Awaited> +): string[] { + if (!resolved) { + return []; + } + const values = new Set(); + const collect = ( + metadata: typeof resolved.effectiveMetadata, + valuesFor: (target: string) => Readonly> | undefined + ) => { + for (const [target, keys] of Object.entries(metadata)) { + const resolvedValues = valuesFor(target); + for (const [key, entry] of Object.entries(keys)) { + const value = resolvedValues?.[key]; + if (entry.secret && typeof value === "string" && value !== "") { + values.add(value); + } + } + } + }; + collect(resolved.effectiveMetadata, (target) => + target === "global" ? resolved.globalEnv : resolved.serviceEnv[target] + ); + collect( + resolved.hostEffectiveMetadata, + (target) => resolved.hostTargetEnv[target] + ); + return [...values]; +} + /** * Prepare public normalization and separate private delivery without generating files. * Unrelated Compose fields remain intact for native capability admission to review; @@ -176,6 +213,7 @@ export async function prepareNativeProjectInput(opts: { originalSha256: createHash("sha256").update(bytes).digest("hex"), normalizedComposeJson, managedEnvironment, + managedSecretValues: managedSecretValues(resolved), lifecycleHostEnvironment: resolved ? selectProjectEnvValuesForExecutionTarget({ resolved, diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 53309f28b..f095d6d4a 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -9,6 +9,7 @@ import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; import { admitNativeProjectBuilds, nativeBuildsEnabled, + nativeServiceActive, } from "./native-project-builds.ts"; import { discoverNativeHostDependency, @@ -282,6 +283,7 @@ async function stoppedRestartMode(opts: { async function pinRestartImages(opts: { readonly runtime: NativeRuntimeSelection; readonly projectRoot: string; + readonly profiles: readonly string[]; readonly specs: Record>; readonly retainedImages: ReadonlyMap; readonly invoke: typeof invokeNativeRuntime; @@ -296,6 +298,11 @@ async function pinRestartImages(opts: { continue; } if (spec.image === undefined && spec.build !== undefined) { + // Outside the selected profiles a build service is never built or pinned + // (as at startup), so it keeps its build declaration. + if (!nativeServiceActive(spec, opts.profiles)) { + continue; + } throw new ClosedCliError( `Native restart reuses built images and never builds; no running or retained image was found for ${name}. Run hack up to build it; nothing was stopped.` ); @@ -461,6 +468,7 @@ export async function preflightNativeRestart(opts: { await pinRestartImages({ runtime: opts.runtime, projectRoot: opts.scope.projectRoot, + profiles: selected.profiles ?? [], specs, retainedImages, invoke: deps.invoke, diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index d9e89b982..b9da95e7b 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -27,6 +27,7 @@ import { type NativeBuildRequest, nativeBuildsEnabled, nativeRebuildRequested, + refuseManagedSecretBuildArguments, } from "./native-project-builds.ts"; import { nativeSharedSourceFlags, @@ -345,12 +346,17 @@ function admitStartBuilds( readonly composeFile: string; } ): ReadonlyMap { - return admitNativeProjectBuilds({ + const builds = admitNativeProjectBuilds({ services: composeServices(input), projectRoot: opts.scope.projectRoot, composeFile: opts.composeFile, enabled: nativeBuildsEnabled(), }); + refuseManagedSecretBuildArguments({ + builds, + managedSecretValues: input.managedSecretValues ?? [], + }); + return builds; } /** A service names an image, or is an admitted build that names none yet. */ @@ -433,6 +439,10 @@ async function pinNativeProjectImages(opts: { spec.image = retainedImage; continue; } + // A build service left unbuilt here is outside the selected profiles. + if (spec.image === undefined && spec.build !== undefined) { + continue; + } const image = String(spec.image); if (IMAGE.test(image)) { continue; @@ -1240,6 +1250,7 @@ export async function startNativeProject(opts: { runtime: opts.runtime, projectRoot: opts.scope.projectRoot, branch: opts.scope.branch, + profiles, builds, specs, retainedImages, diff --git a/tests/native-project-builds.test.ts b/tests/native-project-builds.test.ts index e09f01e3a..c281fb319 100644 --- a/tests/native-project-builds.test.ts +++ b/tests/native-project-builds.test.ts @@ -18,6 +18,8 @@ import { NATIVE_BUILDS_DISABLED_REFUSAL, type NativeBuildRequest, nativeBuildsEnabled, + nativeServiceActive, + refuseManagedSecretBuildArguments, } from "../src/backends/native-project-builds.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; import { HackCliError } from "../src/lib/cli-result.ts"; @@ -190,6 +192,7 @@ test("identical builds run once, pin every service and keep the request private" runtime, projectRoot: project, branch: "feature", + profiles: [], builds: new Map([ ["one", request()], ["two", request()], @@ -238,6 +241,7 @@ test("retained images win, and restore never builds a missing one", async () => runtime, projectRoot: project, branch: null, + profiles: [], rebuild: false, signal: new AbortController().signal, invoke, @@ -274,6 +278,107 @@ test("retained images win, and restore never builds a missing one", async () => expect(calls).toEqual(["build"]); }); +test("only build services in the selected profiles are built and pinned, as with Compose up", async () => { + const project = await root(); + const built: string[] = []; + const invoke = async ({ requestFile }: { requestFile: string }) => { + built.push(JSON.parse(await readFile(requestFile, "utf8")).target ?? ""); + return { imageId: IMAGE, buildKey: KEY, reused: false }; + }; + const declared = () => ({ + web: { build: "app" } as Record, + tools: { build: "app", profiles: ["tools"] } as Record, + }); + const builds = new Map([ + ["web", request()], + ["tools", { ...request(), target: "tools" }], + ]); + const base = { + runtime, + projectRoot: project, + branch: null, + builds, + rebuild: false, + signal: new AbortController().signal, + invoke, + }; + const specs = declared(); + await buildNativeProjectImages({ + ...base, + profiles: [], + specs, + retainedImages: new Map(), + restore: false, + }); + expect(built).toEqual([""]); + expect(specs.web).toEqual({ image: IMAGE }); + // The inactive service keeps its declaration: no image, no build, no pin. + expect(specs.tools).toEqual({ build: "app", profiles: ["tools"] }); + + // Restoring that run needs no image for the inactive service. + built.length = 0; + const restored = declared(); + await buildNativeProjectImages({ + ...base, + profiles: [], + specs: restored, + retainedImages: new Map([["web", RETAINED]]), + restore: true, + }); + expect(built).toEqual([]); + expect(restored.web).toEqual({ image: RETAINED }); + expect(restored.tools).toEqual({ build: "app", profiles: ["tools"] }); + + // Selecting the profile later builds that service then. + const selected = declared(); + await buildNativeProjectImages({ + ...base, + profiles: ["tools"], + specs: selected, + retainedImages: new Map(), + restore: false, + }); + expect(built).toEqual(["", "tools"]); + expect(selected.tools).toEqual({ image: IMAGE, profiles: ["tools"] }); +}); + +test("service activity follows Compose profiles", () => { + expect(nativeServiceActive({}, [])).toBe(true); + expect(nativeServiceActive({ profiles: ["a"] }, [])).toBe(false); + expect(nativeServiceActive({ profiles: ["a", "b"] }, ["b"])).toBe(true); + expect(nativeServiceActive({ profiles: [] }, [])).toBe(true); + expect(nativeServiceActive({ profiles: "a" }, [])).toBe(true); +}); + +test("a build arg equal to a managed secret value refuses without showing it", () => { + const secret = "managed-secret-value-not-shown"; + const builds = new Map([ + ["web", request({ TOKEN: secret, MODE: "production" })], + ]); + let refusal: unknown; + try { + refuseManagedSecretBuildArguments({ + builds, + managedSecretValues: ["other", secret], + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(HackCliError); + const error = refusal as HackCliError; + expect(error.code).toBe("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(error.message).toContain( + "build arg TOKEN equals a hack env secret value" + ); + expect(error.message).not.toContain(secret); + // Values that are not managed secrets pass; so does any build with no secrets. + refuseManagedSecretBuildArguments({ + builds: new Map([["web", request({ MODE: "production" })]]), + managedSecretValues: [secret], + }); + refuseManagedSecretBuildArguments({ builds, managedSecretValues: [] }); +}); + test("an explicit rebuild asks the runtime to build again", async () => { const project = await root(); let policy: unknown; @@ -281,6 +386,7 @@ test("an explicit rebuild asks the runtime to build again", async () => { runtime, projectRoot: project, branch: null, + profiles: [], builds: new Map([["web", request()]]), specs: { web: { build: "app" } }, retainedImages: new Map(), diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index 8c04394d2..4df095a84 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -12,6 +12,8 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { prepareNativeProjectInput } from "../src/backends/native-project-input.ts"; +import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; +import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { restoreEnv } from "./helpers/env.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -82,6 +84,40 @@ test("modern values stay private while original contracts and exact input hash s expect(await readFile(opts.composeFile)).toEqual(before); expect(await readdir(opts.projectDir)).toEqual(names); }); +test("only values stored as secrets are reported as managed secret values", async () => { + const opts = await fixture({ + services: { web: { image: "bun:1" }, worker: { image: "bun:1" } }, + }); + await writeFile( + join(opts.projectRoot, PROJECT_ENV_KEY_FILENAME), + "synthetic-key" + ); + const set = async ( + scope: string, + key: string, + value: string, + secret: boolean + ) => + await setProjectEnvValue({ + projectRoot: opts.projectRoot, + projectDir: opts.projectDir, + envName: null, + scope, + key, + value, + secret, + }); + await set("global", "TOKEN", "synthetic-global-secret", true); + await set("worker", "WORKER_KEY", "synthetic-worker-secret", true); + await set("web", "MODE", "production", false); + const result = await prepareNativeProjectInput(opts); + expect([...(result.managedSecretValues ?? [])].sort()).toEqual([ + "synthetic-global-secret", + "synthetic-worker-secret", + ]); + expect(result.normalizedComposeJson).not.toContain("synthetic-"); +}); + test("fourteen services and 110 managed variables each preserve overlay selection", async () => { const services = Object.fromEntries( Array.from({ length: 14 }, (_, i) => [`service${i}`, { image: "bun:1" }]) diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index c0f2452db..c5aefe0de 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -947,12 +947,13 @@ test("stopped recovery reviews retained image IDs and resolves tags only after o } }); -test("restart pins a build service to its retained image and drops the build declaration", async () => { +test("restart pins an active build service to its retained image, drops its build declaration and leaves inactive ones unbuilt", async () => { const listener = await listenerIntentFixture(); const previous = process.env.HACK_NATIVE_BUILDS; process.env.HACK_NATIVE_BUILDS = "1"; try { let reviewed: Record | undefined; + let inactive: Record | undefined; const builtImage = `sha256:${"7".repeat(64)}`; await preflightNativeRestart({ ...listener.options, @@ -965,7 +966,11 @@ test("restart pins a build service to its retained image and drops the build dec environmentFiles: [], serviceNames: ["app"], normalizedComposeJson: JSON.stringify({ - services: { app: { build: { context: "..", args: { A: "1" } } } }, + services: { + app: { build: { context: "..", args: { A: "1" } } }, + // Outside the run's profiles: never built, pinned or required. + tools: { build: "..", profiles: ["tools"] }, + }, }), managedEnvironment: {}, lifecycleHostEnvironment: {}, @@ -1001,8 +1006,11 @@ test("restart pins a build service to its retained image and drops the build dec return await listener.options.dependencies!.invoke!(options); }, review: async (review) => { - reviewed = JSON.parse(review.input.normalizedComposeJson).services - .app; + const services = JSON.parse( + review.input.normalizedComposeJson + ).services; + reviewed = services.app; + inactive = services.tools; return await review.run({ planId: run.planId, namespace: run.namespace, @@ -1014,6 +1022,7 @@ test("restart pins a build service to its retained image and drops the build dec }); expect(reviewed?.image).toBe(builtImage); expect(reviewed).not.toHaveProperty("build"); + expect(inactive).toEqual({ build: "..", profiles: ["tools"] }); } finally { if (previous === undefined) { Reflect.deleteProperty(process.env, "HACK_NATIVE_BUILDS"); From 8c39b609e7ab14eb174a11ad5f7ff9dbeffb6eee Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:55:13 -0500 Subject: [PATCH 101/138] fix(native): run every build under the pinned-base source policy The bake client now receives EXPERIMENTAL_BUILDKIT_SOURCE_POLICY naming a private mode-0600 policy file written beside the definition and removed with it on every path. The policy comes from build_policy::document for exactly the pinned base references the definition names as contexts. It allows those images and denies every other image and network source, including sources a base image's stored ONBUILD triggers add. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 5 ++ .../runtime-core/src/provider/image_build.rs | 23 ++++++- .../src/provider/image_build/tests.rs | 67 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 897d9e553..0d778fd12 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -479,6 +479,11 @@ linked, mode-`0600` file owned by you): the resolved base references that the receipt and the result report. Never pass a secret as a build argument. BuildKit build secrets are the right mechanism for secrets; native builds do not support them yet. +- Every build runs under a BuildKit source policy: only the pinned base images + resolve as images, and network sources (`ADD` of an `http`, `https` or git URL, and + image blob sources) are denied. The engine would otherwise fetch those from outside + the isolated build network. The policy also covers sources that a base image's + stored `ONBUILD` triggers add; such bases refuse before the build anyway. - Argument values reach the client only through a private definition file (mode `0600`, removed after the build), never its command line or its environment; the client's environment is a fixed list of its own settings. Names must be plain diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index d1235b15d..770394ab9 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -35,6 +35,10 @@ //! bounds. //! - The client builds from a private copy of the Dockerfile and ignore bytes that were //! scanned and hashed, never from the project paths again. +//! - Every build runs under [`build_policy::document`] for its pins, a BuildKit source +//! policy the client sends with the solve: only the pinned base references resolve as +//! images, and network sources (`ADD` URLs, git, image blobs) are denied, including +//! ones a base image's stored `ONBUILD` triggers add. //! - Build arguments are not secrets, as with Docker: an image records their values in //! its history, and resolved base references (which an argument may select) are //! reported in receipts and results. Hack still keeps values out of argv, the @@ -42,7 +46,7 @@ //! as defense in depth. The caller refuses values equal to a managed secret. //! - Nothing here removes images, prunes caches or adopts an image whose labels differ. use super::{ - artifact, + artifact, build_policy, build_scan::{self, BaseImage}, registry_image::{self, BuildBase}, state, @@ -811,8 +815,10 @@ pub(super) fn run( Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(state::io(error)), } - // The definition carries argument values; it never outlives the client run. + // Neither the definition (it carries argument values) nor the source policy + // outlives the client run. let _definition = RemoveOnDrop(scratch.join("definition").join(BAKE_DEFINITION)); + let _policy = RemoveOnDrop(scratch.join("definition").join(SOURCE_POLICY)); let ended = supervise( command( &builder, &scratch, &metadata, inputs, &identity, &pins, &expected, @@ -965,6 +971,8 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<(), CandidateError> { /// The one bake target a build defines; its metadata is keyed by this name. const BAKE_TARGET: &str = "build"; const BAKE_DEFINITION: &str = "definition.json"; +/// The BuildKit source policy file beside the definition (see [`build_policy`]). +const SOURCE_POLICY: &str = "source-policy.json"; /// Removes one private file when dropped, on every exit path. struct RemoveOnDrop(PathBuf); @@ -1046,6 +1054,13 @@ fn command( let encoded = serde_json::to_vec(&bake_definition(inputs, &dockerfile, pins, labels)) .map_err(|_| invalid("Cannot encode the build definition."))?; write_private(&definition, &encoded)?; + // The policy allows exactly the pinned references the definition names as + // contexts, and denies every other image and network source. + let policy = bake.join(SOURCE_POLICY); + let policy_bytes = + serde_json::to_vec(&build_policy::document(pins.values().map(String::as_str))?) + .map_err(|_| invalid("Cannot encode the build source policy."))?; + write_private(&policy, &policy_bytes)?; let mut command = super::process::clean_command(builder); command .current_dir(scratch) @@ -1061,6 +1076,10 @@ fn command( .env("BUILDX_CONFIG", &buildx) .env("BUILDX_NO_DEFAULT_ATTESTATIONS", "1") .env("DOCKER_CLI_HINTS", "false") + // The client sends this policy with the solve, so BuildKit evaluates every + // source of the build against it, including ones a base image's ONBUILD + // triggers add. + .env("EXPERIMENTAL_BUILDKIT_SOURCE_POLICY", &policy) .args([ "bake", "--progress=plain", diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index 1c87732ab..805f9f764 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -81,6 +81,9 @@ count=$(cat calls 2>/dev/null || echo 0) echo $((count + 1)) > calls printf '%s\n' "$@" > argv env > env +[ -n "${{EXPERIMENTAL_BUILDKIT_SOURCE_POLICY-}}" ] || exit 9 +cat "$EXPERIMENTAL_BUILDKIT_SOURCE_POLICY" > seen-policy.json +printf '%s' "$EXPERIMENTAL_BUILDKIT_SOURCE_POLICY" > seen-policy-path meta= file= previous= @@ -270,6 +273,30 @@ fn cold_build_passes_values_only_through_the_private_definition_and_records_the_ assert_eq!(target["tags"], serde_json::json!([inputs.tag()])); // The definition carries argument values; it is gone once the client has run. assert!(!scratch.join("definition").join(BAKE_DEFINITION).exists()); + // The client ran under the source policy for exactly these pins, from a private + // file removed with the definition. + let policy_path = PathBuf::from(fs::read_to_string(scratch.join("seen-policy-path")).unwrap()); + assert_eq!(policy_path, scratch.join("definition").join(SOURCE_POLICY)); + assert!(!policy_path.exists(), "the source policy outlived the run"); + let policy: Value = + serde_json::from_slice(&fs::read(scratch.join("seen-policy.json")).unwrap()).unwrap(); + let pinned = format!("docker.io/library/alpine@{PIN}"); + assert_eq!(policy, build_policy::document([pinned.as_str()]).unwrap()); + assert_eq!( + policy_decision(&policy, &format!("docker-image://{pinned}")), + "ALLOW" + ); + for denied in [ + "docker-image://docker.io/library/alpine:3.20", + "docker-image://docker.io/library/busybox:latest", + "oci-layout://store@sha256:x", + "https://example.test/archive.tgz", + "git://example.test/repo.git", + "docker-image+blob://docker.io/library/alpine@sha256:x", + ] { + assert_eq!(policy_decision(&policy, denied), "DENY", "{denied}"); + } + assert_eq!(policy_decision(&policy, "local://context"), "ALLOW"); let receipt = fixture.receipt(&inputs); assert_eq!(receipt.phase, Phase::ContentIdVerified); assert_eq!(receipt.image_id.as_deref(), Some(IMAGE)); @@ -322,6 +349,7 @@ fn environment_names_are_plain_arguments_that_never_reach_the_client_environment "BUILDX_CONFIG", "BUILDX_NO_DEFAULT_ATTESTATIONS", "DOCKER_CLI_HINTS", + "EXPERIMENTAL_BUILDKIT_SOURCE_POLICY", "PWD", "OLDPWD", "SHLVL", @@ -349,6 +377,45 @@ fn environment_names_are_plain_arguments_that_never_reach_the_client_environment } } +/// BuildKit's evaluation of a source identifier: the last matching rule wins and an +/// unmatched source is allowed. `WILDCARD` patterns here use only a trailing `*`. +fn policy_decision(policy: &Value, identifier: &str) -> &'static str { + let mut decision = "ALLOW"; + for rule in policy["rules"].as_array().unwrap() { + let pattern = rule["selector"]["identifier"].as_str().unwrap(); + let matched = match rule["selector"]["match_type"].as_str().unwrap() { + "EXACT" => identifier == pattern, + "WILDCARD" => identifier.starts_with(pattern.strip_suffix('*').unwrap()), + other => panic!("unexpected match type {other}"), + }; + if matched { + decision = if rule["action"] == "DENY" { + "DENY" + } else { + "ALLOW" + }; + } + } + decision +} + +#[test] +fn private_build_files_are_removed_even_when_the_client_fails() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "fail"); + let error = run(&fixture.candidate, &pool, &inputs, &never).unwrap_err(); + assert_eq!(error.code, "native_build_failed"); + let scratch = fixture.scratch(&inputs); + assert!( + scratch.join("seen-policy.json").exists(), + "the client did not run" + ); + assert!(!scratch.join("definition").join(BAKE_DEFINITION).exists()); + assert!(!scratch.join("definition").join(SOURCE_POLICY).exists()); +} + #[test] fn bake_definitions_never_evaluate_project_text() { assert_eq!(bake_literal("${HOME}"), "$${HOME}"); From 3b42fdd93c71a4d50875b3326a1a3e7b48e9d72c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:00:03 -0500 Subject: [PATCH 102/138] fix(native): size the build receipt bound for every accepted base pin The receipt read bound is now 64 KiB, which covers a receipt with the most base pins the scanner accepts at their longest, so a receipt a build writes can always be read again. A new end-to-end test also shows that remote ADD sources (URLs, git refs, variables, ONBUILD forms) refuse at prepare, before any build effect. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/image_build.rs | 4 +- .../src/provider/image_build/tests.rs | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index 770394ab9..8a3b3fdc4 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -84,7 +84,9 @@ const CANCEL_GRACE: Duration = Duration::from_secs(10); const POLL: Duration = Duration::from_millis(50); const LEASE_WAIT: Duration = Duration::from_secs(5); const MAX_REQUEST_BYTES: u64 = 192 * 1024; -const MAX_RECEIPT_BYTES: u64 = 16 * 1024; +/// Covers a receipt with the most base pins the scanner accepts at their longest +/// (about 32 KiB pretty-printed), so a receipt the build writes is always readable. +const MAX_RECEIPT_BYTES: u64 = 64 * 1024; const MAX_METADATA_BYTES: u64 = 1024 * 1024; const MAX_IGNORE_BYTES: u64 = 1024 * 1024; const MAX_ARGS: usize = 64; diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index 805f9f764..14fa81c90 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -416,6 +416,63 @@ fn private_build_files_are_removed_even_when_the_client_fails() { assert!(!scratch.join("definition").join(SOURCE_POLICY).exists()); } +#[test] +fn receipts_with_the_most_and_longest_accepted_pins_stay_readable() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let mut receipt = Receipt { + version: 1, + checkout: fixture.candidate.checkout.clone(), + namespace: inputs.namespace.clone(), + build_key: inputs.key.clone(), + provider_incarnation: "i".repeat(128), + provider_boot_id: "b".repeat(128), + engine_id: "e".repeat(128), + phase: Phase::ContentIdVerified, + image_id: Some(IMAGE.into()), + base_pins: BTreeMap::new(), + }; + // The scanner's bounds: 32 images, Docker Hub paths up to 200 bytes, tags up to + // 128, each with a digest; the pinned reference keeps the name and adds the pin. + for index in 0..build_scan::MAX_IMAGE_REFERENCES { + let repository = format!("{index:03}{}/{}", "r".repeat(96), "p".repeat(100)); + let name = format!("{repository}:{}@{PIN}", "t".repeat(128)); + receipt + .base_pins + .insert(name.clone(), format!("docker.io/{name}")); + } + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let path = directory.join(format!("{}.json", inputs.key)); + state::write(&path, &receipt).unwrap(); + assert!(fs::metadata(&path).unwrap().len() > 16 * 1024); + let read = read_receipt(&path).unwrap().unwrap(); + assert_eq!(read.base_pins.len(), build_scan::MAX_IMAGE_REFERENCES); +} + +#[test] +fn remote_add_sources_refuse_before_any_build_effect() { + let fixture = Fixture::new(); + for dockerfile in [ + "FROM alpine:3.20\nADD http://100.96.0.1:8080/secret /secret\n", + "FROM alpine:3.20\nADD https://example.test/a.tgz /a\n", + "FROM alpine:3.20\nADD git@example.test:org/repo.git /src\n", + "FROM alpine:3.20\nARG SRC\nADD $SRC /src\n", + "FROM alpine:3.20 AS base\nONBUILD ADD https://example.test/a /a\n", + ] { + fs::write(fixture.project.join("app/Dockerfile"), dockerfile).unwrap(); + let error = prepare(fixture.request(Policy::Missing)).unwrap_err(); + assert_eq!(error.code, "native_build_dockerfile", "{dockerfile}"); + assert!(!error.message.contains("100.96.0.1") && !error.message.contains("example.test")); + } + fs::write( + fixture.project.join("app/Dockerfile"), + "FROM alpine:3.20\nADD main.txt /main.txt\n", + ) + .unwrap(); + assert!(prepare(fixture.request(Policy::Missing)).is_ok()); +} + #[test] fn bake_definitions_never_evaluate_project_text() { assert_eq!(bake_literal("${HOME}"), "$${HOME}"); From 5a40b44f452faf80a865d528d54ace30e3fdc30d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:03:49 -0500 Subject: [PATCH 103/138] fix(native): discard an interrupted receipt publication under the key lock state::write publishes a receipt through .pending and a rename. A process that died between the two left that file, and every later write for the key failed with AlreadyExists. Under the per-key lock, which proves no writer is live, the build now discards the leftover before writing; the committed receipt, if any, is unaffected. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/image_build.rs | 8 +++++++ .../src/provider/image_build/tests.rs | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index 8a3b3fdc4..7eec8d06e 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -709,6 +709,14 @@ pub(super) fn run( "Another operation is building this image; wait for it and retry.", ) })?; + // `state::write` publishes through `.pending` and a rename. A process that + // died between the two left that file; the key lock proves no writer is live, so + // the leftover is discarded (the committed receipt, if any, is unaffected). + match fs::remove_file(receipt_path.with_extension("pending")) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(state::io(error)), + } let expected = labels(inputs, &identity); let previous = read_receipt(&receipt_path)?; if let Some(previous) = &previous { diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index 14fa81c90..252663d17 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -473,6 +473,29 @@ fn remote_add_sources_refuse_before_any_build_effect() { assert!(prepare(fixture.request(Policy::Missing)).is_ok()); } +#[test] +fn an_interrupted_receipt_publication_does_not_block_the_key() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + let directory = builds_root(&fixture.candidate).join(&inputs.namespace); + state::private_directory(&directory).unwrap(); + let receipt = directory.join(format!("{}.json", inputs.key)); + // A process died after creating the temporary file and before renaming it, with + // no committed receipt yet. + fs::write(receipt.with_extension("pending"), b"{\"partial").unwrap(); + let pool = FakePool::new("pool-1"); + fixture.client(&inputs, "ok"); + pool.built(&inputs, "pool-1", serde_json::json!({})); + run(&fixture.candidate, &pool, &inputs, &never).unwrap(); + assert_eq!(fixture.receipt(&inputs).phase, Phase::ContentIdVerified); + assert!(!receipt.with_extension("pending").exists()); + // The same with a committed receipt present: a rebuild still proceeds. + fs::write(receipt.with_extension("pending"), b"{").unwrap(); + let rebuild = fixture.inputs(Policy::Rebuild); + run(&fixture.candidate, &pool, &rebuild, &never).unwrap(); + assert_eq!(fixture.calls(&inputs), 2); +} + #[test] fn bake_definitions_never_evaluate_project_text() { assert_eq!(bake_literal("${HOME}"), "$${HOME}"); From 460ddff233bd5ea7f4f94635cecb8bc746620450 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:12:33 -0500 Subject: [PATCH 104/138] fix(native): keep a surviving build client fenced after the CLI is killed The build client now inherits the build fence and the per-key lock. A flock belongs to the open file description, so if hack-native is SIGKILLed while the client runs, both stay held until the client and anything it left running exit. runtime down keeps refusing, and a later attempt of the same key refuses with native_build_busy instead of sharing the surviving client's attempt files. An orderly return still releases both. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/image_build.rs | 42 ++++++- .../src/provider/image_build/tests.rs | 104 ++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index 7eec8d06e..b088e78d8 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -546,6 +546,10 @@ fn builds_root(candidate: &Candidate) -> PathBuf { } /// A cooperative `flock` on a private lock file, shared or exclusive, never blocking. +/// +/// A `flock` belongs to the open file description, so a child that inherits the +/// descriptor holds the lock for as long as it keeps it open if this process is +/// killed before it drops the lock (see [`inherit_locks`]). struct Flock(File); impl Flock { fn open(path: &Path) -> Result { @@ -584,6 +588,10 @@ impl Flock { } impl Drop for Flock { fn drop(&mut self) { + // An orderly drop releases the lock for every holder of the description, so a + // transient copy in another thread's forked child cannot keep it. Only a + // process that dies without dropping (SIGKILL) leaves the lock with the + // descriptors its build client inherited. // SAFETY: the descriptor is live until this value drops. unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN); @@ -691,7 +699,7 @@ pub(super) fn run( let receipt_path = directory.join(format!("{}.json", inputs.key)); // Step 1, under the lease: fence, key lock and the reuse decision. let (lease, identity) = pool.lease()?; - let _fence = Flock::try_lock(Flock::open(&root.join("active.lock"))?, libc::LOCK_SH)? + let fence = Flock::try_lock(Flock::open(&root.join("active.lock"))?, libc::LOCK_SH)? .ok_or_else(|| { refusal( "provider_busy", @@ -699,7 +707,7 @@ pub(super) fn run( ) })?; state::private_directory(&directory)?; - let _key_lock = Flock::try_lock( + let key_lock = Flock::try_lock( Flock::open(&directory.join(format!("{}.lock", inputs.key)))?, libc::LOCK_EX, )? @@ -829,10 +837,12 @@ pub(super) fn run( // outlives the client run. let _definition = RemoveOnDrop(scratch.join("definition").join(BAKE_DEFINITION)); let _policy = RemoveOnDrop(scratch.join("definition").join(SOURCE_POLICY)); + let mut client = command( + &builder, &scratch, &metadata, inputs, &identity, &pins, &expected, + )?; + inherit_locks(&mut client, &[&fence, &key_lock]); let ended = supervise( - command( - &builder, &scratch, &metadata, inputs, &identity, &pins, &expected, - )?, + client, deadline, canceled, output::Filter::new(inputs.args.values().map(String::as_str)), @@ -1165,6 +1175,28 @@ impl Drop for CancelSignals { } } +/// Let the client inherit the build fence and the key lock. If this process is killed +/// while the client runs, both stay held until the client and everything it leaves +/// running exit: `runtime down` keeps refusing, and a later attempt of the same key +/// refuses with `native_build_busy` instead of sharing the surviving client's attempt +/// files. An orderly return (after the client was reaped or stopped) releases them. +fn inherit_locks(command: &mut std::process::Command, locks: &[&Flock]) { + let descriptors: Vec = locks.iter().map(|lock| lock.0.as_raw_fd()).collect(); + // SAFETY: runs in the forked child before exec; fcntl is async-signal-safe and the + // descriptors are live (their owners outlive the spawn). + unsafe { + command.pre_exec(move || { + for &fd in &descriptors { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags < 0 || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + }); + } +} + /// Spawn the client and wait for it, honoring cancellation and the time budget. On /// either, the whole process group gets SIGTERM, then SIGKILL after a grace period. /// The group is signaled only while its leader is unreaped, so its ID is still ours. diff --git a/packages/runtime-core/src/provider/image_build/tests.rs b/packages/runtime-core/src/provider/image_build/tests.rs index 252663d17..9e44f6e46 100644 --- a/packages/runtime-core/src/provider/image_build/tests.rs +++ b/packages/runtime-core/src/provider/image_build/tests.rs @@ -42,6 +42,14 @@ impl Fixture { project, } } + /// A fixture another process created at `root` (see `coordinator_child`). + fn at(root: PathBuf) -> Self { + Self { + candidate: Candidate::discover(&root.join("checkout")).unwrap(), + project: root.join("project"), + root, + } + } fn request(&self, policy: Policy) -> BuildRequest { BuildRequest { version: 1, @@ -103,6 +111,7 @@ case {mode} in fail) exit 3 ;; nometa) ;; sleep) sleep 30 & echo $! > child.pending; mv child.pending child; wait ;; + hold) echo $$ > client-pid.pending; mv client-pid.pending client-pid; sleep 30 ;; esac "# ); @@ -496,6 +505,101 @@ fn an_interrupted_receipt_publication_does_not_block_the_key() { assert_eq!(fixture.calls(&inputs), 2); } +/// The environment variable naming the fixture root the coordinator child builds in. +const COORDINATOR_ROOT: &str = "HACK_TEST_IMAGE_BUILD_COORDINATOR_ROOT"; + +#[test] +#[ignore = "coordinator process killed by a_killed_coordinator_leaves_its_client_fenced"] +fn coordinator_child() { + let Some(root) = std::env::var_os(COORDINATOR_ROOT) else { + return; + }; + // Never cleaned up here: the parent test owns the root, and this process is killed. + let fixture = std::mem::ManuallyDrop::new(Fixture::at(PathBuf::from(root))); + let inputs = fixture.inputs(Policy::Missing); + let pool = FakePool::new("pool-1"); + let _ = run(&fixture.candidate, &pool, &inputs, &never); +} + +#[test] +fn a_killed_coordinator_leaves_its_client_fenced() { + let fixture = Fixture::new(); + let inputs = fixture.inputs(Policy::Missing); + fixture.client(&inputs, "hold"); + let scratch = fixture.scratch(&inputs); + let mut coordinator = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "provider::image_build::tests::coordinator_child", + "--ignored", + "--nocapture", + ]) + .env(COORDINATOR_ROOT, &fixture.root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + while !scratch.join("client-pid").exists() { + assert!( + started.elapsed() < Duration::from_secs(60), + "the client never started" + ); + std::thread::sleep(Duration::from_millis(20)); + } + // SIGKILL: the coordinator drops nothing, as when hack-native itself is killed. + coordinator.kill().unwrap(); + coordinator.wait().unwrap(); + let client: libc::pid_t = fs::read_to_string(scratch.join("client-pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + let root = builds_root(&fixture.candidate); + let key = root + .join(&inputs.namespace) + .join(format!("{}.lock", inputs.key)); + let fence = root.join("active.lock"); + let held = |path: &Path| { + Flock::try_lock(Flock::open(path).unwrap(), libc::LOCK_SH) + .unwrap() + .is_none() + || Flock::try_lock(Flock::open(path).unwrap(), libc::LOCK_EX) + .unwrap() + .is_none() + }; + assert!( + held(&key), + "the key lock was released under a surviving client" + ); + assert!( + held(&fence), + "the build fence was released under a surviving client" + ); + assert!(require_no_active_build(&fixture.candidate).is_err()); + let pool = FakePool::new("pool-1"); + let busy = run( + &fixture.candidate, + &pool, + &fixture.inputs(Policy::Rebuild), + &never, + ) + .unwrap_err(); + assert_eq!(busy.code, "native_build_busy"); + // SAFETY: the surviving client leads its own process group, created for it. + unsafe { libc::killpg(client, libc::SIGKILL) }; + let started = Instant::now(); + while held(&key) || held(&fence) { + assert!( + started.elapsed() < Duration::from_secs(10), + "locks were never released" + ); + std::thread::sleep(Duration::from_millis(20)); + } + assert!(require_no_active_build(&fixture.candidate).is_ok()); +} + #[test] fn bake_definitions_never_evaluate_project_text() { assert_eq!(bake_literal("${HOME}"), "$${HOME}"); From e755e23208b7ae44571839030f0e08a756f5e76d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:41:19 -0500 Subject: [PATCH 105/138] fix(native): keep the gcloud selection on a recorded failed-start owner A fresh gcloud start whose cleanup was unconfirmed recorded its owner mapping without the ADC mode, so restart skipped ADC adaptation and an explicit gcloud up was refused as a selection change. The failed-start path now passes the same gcloud selection as the ready path. Co-Authored-By: Claude Opus 5.5 --- src/backends/native-project-start.ts | 1 + tests/native-project-start.test.ts | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 80c565970..d90eefb06 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -1750,6 +1750,7 @@ export async function startNativeProject(opts: { effectiveEnvName: input.effectiveEnvName, profiles, aws: selection.aws, + gcloud: selection.gcloud, runtimeMetadata: metadata !== undefined, }, finalization, diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 0699ab248..32704f1c4 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -22,7 +22,10 @@ import { } from "../src/backends/native-project-finalization.ts"; import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; import { nativeProjectPs } from "../src/backends/native-project-observe.ts"; -import { preflightNativeRestart } from "../src/backends/native-project-restart-preflight.ts"; +import { + nativeRestartSelection, + preflightNativeRestart, +} from "../src/backends/native-project-restart-preflight.ts"; import { selectNativeRetainedImages } from "../src/backends/native-project-retained-images.ts"; import { loadNativeProjectRun, @@ -581,6 +584,29 @@ test("a post-start failure whose cleanup is unconfirmed records the run for ps a ]); }); +test("a failed gcloud start's recorded owner keeps the ADC mode for restart", async () => { + const value = await fixture(false); + const { opts } = value; + opts.dependencies.adaptGcloud = async ({ input, mode }) => ({ + input, + receipt: { mode, types: ["authorized_user"], services: ["web"] }, + }); + const selected = failedStartupFixture(value); + const failure = await startNativeProject({ + ...opts, + gcloud: { mode: "file" }, + }).catch((error: unknown) => error); + expect((failure as Error).message).toContain( + `The failed run ${selected.run} is recorded as this project's run mapping` + ); + const recorded = await loadNativeProjectRun(opts.scope); + expect(recorded?.gcloud).toEqual({ mode: "file" }); + // Restart re-applies the recorded ADC adapter instead of dropping it. + expect(nativeRestartSelection({ run: recorded! }).gcloud).toEqual({ + mode: "file", + }); +}); + test("a post-start failure with an unauthenticated receipt records no owner", async () => { const value = await fixture(false); const { opts, events } = value; From e20e19b27007b403f174675d44a4bd97bb6aa732 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:02:55 -0500 Subject: [PATCH 106/138] fix(native): redact credential files from failed-service output The startup failure report redacted managed environment values only, so a failing service that printed its gcloud ADC refresh token or private key showed it in the error. Every private credential file now contributes its whole text, its lines and each JSON string value to the redaction set. A service whose credential file is not UTF-8 text, or looks like JSON but does not parse, has its output withheld with a pointer to hack logs. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 11 +++- src/backends/native-project-start.ts | 1 + src/backends/native-startup-logs.ts | 88 ++++++++++++++++++++++++++-- tests/native-project-start.test.ts | 81 +++++++++++++++++++++++++ tests/native-startup-logs.test.ts | 72 +++++++++++++++++++++++ 5 files changed, 246 insertions(+), 7 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index fceabbfef..507a5f2b2 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -1527,8 +1527,15 @@ startup failure report, and it never saves the tail or the report: shred unrelated output. - Other control characters except tab and newline are shown as U+FFFD. -Redaction covers only managed values. Application output can still hold other -sensitive data, such as values a service read from its own files. +Private credential files, such as gcloud Application Default Credentials, are +redacted the same way: the whole file text, each of its lines, and every JSON string +value of four or more characters, such as a refresh token or private key. When a +service received a credential file that is not UTF-8 text, or looks like JSON but +does not parse, its output is withheld from the report instead; read it with +`hack logs SERVICE`. + +Redaction covers only managed values and credential files. Application output can +still hold other sensitive data, such as values a service read from its own files. A Docker container can remain `created` with a nonzero error when its first start fails before a process runs. Retaining shutdown accepts this state only with diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 5a8b216d4..0dfd464e2 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -1635,6 +1635,7 @@ export async function startNativeProject(opts: { startupLogReport = formatNativeStartupLogReport({ logs, managedEnvironment: input.managedEnvironment, + managedFiles: input.managedFiles, }); }, onReady: async () => { diff --git a/src/backends/native-startup-logs.ts b/src/backends/native-startup-logs.ts index 7c8b6de7e..2c8f58ce4 100644 --- a/src/backends/native-startup-logs.ts +++ b/src/backends/native-startup-logs.ts @@ -1,4 +1,5 @@ import { isRecord } from "../lib/guards.ts"; +import type { NativeManagedFile } from "./native-project-input.ts"; /** * Bounded output of one failed service from a native foreground startup failure @@ -150,24 +151,101 @@ export function redactNativeStartupLog( return result.replace(CONTROL, "�"); } +/** String leaves of a parsed JSON document, depth-first, without keys. */ +function jsonStrings(value: unknown, out: string[]): void { + if (typeof value === "string") { + out.push(value); + } else if (Array.isArray(value)) { + for (const item of value) { + jsonStrings(item, out); + } + } else if (isRecord(value)) { + for (const item of Object.values(value)) { + jsonStrings(item, out); + } + } +} + +/** + * Redaction values of private credential file members. Every file contributes + * its whole text (whose lines are matched too) and, when it is JSON, each string + * value long enough to match as a substring, such as a refresh token, client + * secret or private key. A service whose file is not UTF-8 text, or looks like + * JSON but does not parse, cannot be redacted reliably: it is reported so its + * output is withheld. + */ +function credentialFileValues( + managedFiles: Readonly> +): { + readonly values: readonly string[]; + readonly withheld: ReadonlySet; +} { + const values: string[] = []; + const withheld = new Set(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + for (const [service, files] of Object.entries(managedFiles)) { + for (const file of files) { + let text: string; + try { + text = decoder.decode(file.content); + } catch { + withheld.add(service); + continue; + } + values.push(text); + const trimmed = text.trim(); + if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) { + continue; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + withheld.add(service); + continue; + } + const strings: string[] = []; + jsonStrings(parsed, strings); + values.push( + ...strings.filter( + (value) => value.length >= NATIVE_STARTUP_LOG_SUBSTRING_MIN + ) + ); + } + } + return { values, withheld }; +} + /** * Render failed services' last output for the startup failure report, with every - * managed value of every service redacted. Returns an empty string when there is - * nothing to show. The result is for display only; callers must not persist it. + * managed value and every private credential file value of every service + * redacted. A service holding a credential file that cannot be turned into + * redaction values has its output withheld. Returns an empty string when there + * is nothing to show. The result is for display only; callers must not persist it. */ export function formatNativeStartupLogReport(opts: { readonly logs: readonly NativeStartupLogTail[]; readonly managedEnvironment: Readonly< Record>> >; + readonly managedFiles?: Readonly< + Record + >; }): string { if (opts.logs.length === 0) { return ""; } - const values = Object.values(opts.managedEnvironment).flatMap((service) => - Object.values(service) - ); + const credentials = credentialFileValues(opts.managedFiles ?? {}); + const values = [ + ...Object.values(opts.managedEnvironment).flatMap((service) => + Object.values(service) + ), + ...credentials.values, + ]; const sections = opts.logs.map((tail) => { + if (credentials.withheld.has(tail.service)) { + return `--- ${tail.service} ---\n(output withheld: this service received credential files that could not be redacted; run hack logs ${tail.service} to read it)`; + } const streams = (["stdout", "stderr"] as const) .filter((stream) => tail[stream] !== "") .map( diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 9b4285fa0..a82860b64 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -1733,6 +1733,87 @@ test("failed services' output is printed with managed values redacted and never } }); +// Built at runtime so the synthetic PEM framing is not a key-shaped literal. +const PEM_BEGIN = ["-----BEGIN", "PRIVATE", "KEY-----"].join(" "); +const PEM_END = ["-----END", "PRIVATE", "KEY-----"].join(" "); +test("a failed gcloud start redacts echoed ADC credentials and saves none of them", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + // Synthetic dummy credential material only. + const refreshToken = "1//synthetic-adc-refresh-token"; + const keyLine = "U1lOVEhFVElDLURVTU1ZLVBSSVZBVEUtS0VZ"; + const adc = { + type: "authorized_user", + client_secret: "synthetic-adc-client-secret", + refresh_token: refreshToken, + private_key: `${PEM_BEGIN}\n${keyLine}\n${PEM_END}\n`, + }; + opts.dependencies.adaptGcloud = async ({ input, mode }) => ({ + input: { + ...input, + managedFiles: { + web: [ + { + name: "gcloud-adc.json", + target: "/config/adc.json", + content: Buffer.from(JSON.stringify(adc)), + }, + ], + }, + }, + receipt: { mode, types: ["authorized_user"], services: ["web"] }, + }); + let selectedRun = ""; + const saved: unknown[] = []; + opts.dependencies.save = async (request) => { + saved.push(request); + }; + opts.dependencies.serve = async (request) => { + selectedRun = request.run; + request.onStartupLogs?.([ + { + service: "web", + stdout: `token ${refreshToken}`, + stderr: `${keyLine}\nsecret=${adc.client_secret}`, + truncated: false, + }, + ]); + throw new ClosedCliError( + "Native graph startup was interrupted or failed (graph_startup_application_failed); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => + request.args[1] === "inspect" + ? stoppedGraph({ + run: selectedRun, + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + }) + : invoke(request); + const failure = await startNativeProject({ + ...opts, + gcloud: { mode: "file" }, + }).catch((error: unknown) => error); + const message = (failure as Error).message; + expect(message).toContain("token [REDACTED]"); + expect(message).toContain("secret=[REDACTED]"); + for (const secret of [refreshToken, keyLine, adc.client_secret]) { + expect(message).not.toContain(secret); + expect(JSON.stringify(saved)).not.toContain(secret); + } + expect(events.at(-1)).toBe("cleanup"); + for (const file of await filesUnder(opts.scope.projectRoot)) { + const text = await readFile(file, "utf8"); + for (const secret of [refreshToken, keyLine, adc.client_secret]) { + expect(text).not.toContain(secret); + } + } +}); + test("retained startup failure preserves its diagnostic and never removes the run mapping", async () => { const { opts, events } = await fixture(false); const serve = opts.dependencies.serve!; diff --git a/tests/native-startup-logs.test.ts b/tests/native-startup-logs.test.ts index 4cfad26f4..0a7ab3606 100644 --- a/tests/native-startup-logs.test.ts +++ b/tests/native-startup-logs.test.ts @@ -113,3 +113,75 @@ test("the report redacts values of every service and names omitted lines", () => formatNativeStartupLogReport({ logs: [], managedEnvironment: {} }) ).toBe(""); }); + +// Synthetic dummy credential material only; nothing here is a real key or token. +// Built at runtime so the synthetic PEM framing is not a key-shaped literal. +const PEM_BEGIN = ["-----BEGIN", "PRIVATE", "KEY-----"].join(" "); +const PEM_END = ["-----END", "PRIVATE", "KEY-----"].join(" "); +const syntheticAdc = { + type: "service_account", + client_id: "000000000000000000000", + client_secret: "synthetic-client-secret-value", + refresh_token: "1//synthetic-refresh-token-value", + private_key_id: "0123456789abcdef0123456789abcdef01234567", + private_key: `${PEM_BEGIN}\nU1lOVEhFVElDLURVTU1ZLUtFWS1MSU5FLU9ORQ==\nU1lOVEhFVElDLURVTU1ZLUtFWS1MSU5FLVRXTw==\n${PEM_END}\n`, +}; +const adcFile = (content: Buffer) => ({ + name: "gcloud-adc.json", + target: "/config/adc.json", + content, +}); + +test("credential file values are redacted from every service's output", () => { + const echoed = [ + `refresh=${syntheticAdc.refresh_token}`, + `secret "${syntheticAdc.client_secret}"`, + `key id ${syntheticAdc.private_key_id}`, + syntheticAdc.private_key.trimEnd(), + `quoted ${JSON.stringify(syntheticAdc.private_key)}`, + ].join("\n"); + const report = formatNativeStartupLogReport({ + logs: [ + tail({ stderr: echoed }), + tail({ service: "worker", stdout: echoed }), + ], + managedEnvironment: {}, + managedFiles: { + web: [adcFile(Buffer.from(JSON.stringify(syntheticAdc, null, 2)))], + }, + }); + for (const secret of [ + syntheticAdc.refresh_token, + syntheticAdc.client_secret, + syntheticAdc.private_key_id, + "U1lOVEhFVElDLURVTU1ZLUtFWS1MSU5FLU9ORQ==", + "U1lOVEhFVElDLURVTU1ZLUtFWS1MSU5FLVRXTw==", + "synthetic", + ]) { + expect(report).not.toContain(secret); + } + expect(report).toContain("refresh=[REDACTED]"); + expect(report).toContain("--- worker stdout ---"); +}); + +test("a credential file that cannot be redacted withholds its service's output", () => { + for (const content of [ + Buffer.from([0xff, 0xfe, 0x00, 0x73, 0x79, 0x6e]), + Buffer.from('{"refresh_token": "synthetic-unclosed'), + ]) { + const report = formatNativeStartupLogReport({ + logs: [ + tail({ stderr: "synthetic-unparsed-secret-output" }), + tail({ service: "worker", stdout: "worker ok" }), + ], + managedEnvironment: {}, + managedFiles: { web: [adcFile(content)] }, + }); + expect(report).not.toContain("synthetic-unparsed-secret-output"); + expect(report).toContain( + "--- web ---\n(output withheld: this service received credential files that could not be redacted; run hack logs web to read it)" + ); + // Only the service holding the file is withheld. + expect(report).toContain("--- worker stdout ---\nworker ok"); + } +}); From 17ad74b4d25b0ad8633a13e1d945b11fa68f83cf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:14:49 -0500 Subject: [PATCH 107/138] fix(native): initialize loopback ports in native config-plan resources The native-config-plan build constructs network and container resources directly; they now set the new loopback_ports field, so all-features builds compile. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/provider/graph/native/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/runtime-core/src/provider/graph/native/mod.rs b/packages/runtime-core/src/provider/graph/native/mod.rs index 741f3e072..a97d0e8a0 100644 --- a/packages/runtime-core/src/provider/graph/native/mod.rs +++ b/packages/runtime-core/src/provider/graph/native/mod.rs @@ -156,6 +156,7 @@ fn configuration_with_source( cache: None, cache_provenance: None, disposable_caches: Vec::new(), + loopback_ports: Vec::new(), kind: Kind::Network, key: key.clone(), name: format!("hkn-{}-network-{index}", review.scope().run), @@ -184,6 +185,7 @@ fn configuration_with_source( cache: None, cache_provenance: None, disposable_caches: Vec::new(), + loopback_ports: Vec::new(), kind: Kind::Container, key: name.clone(), name: format!("hkn-{}-container-{index}", review.scope().run), From f07ea583ab1e04062d846193dfb6ed984c7fe69d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:04:25 -0500 Subject: [PATCH 108/138] fix(native): admit native build fields and name services in build refusals With HACK_NATIVE_BUILDS=1 the Compose-backend feature gate now admits exactly the build fields native builds implement (context, dockerfile, target and args). Native build admission then reviews their values, so literal build args build natively and a build arg equal to a managed secret reaches the existing refusal, which names the service and the arg, never the value, and does not suggest the Compose backend. Other build fields, or image together with build, still select the Compose backend; without native builds the basic-form refusal is unchanged. Doctor applies the same classification. Every build refusal now names the service. Runtime refusals from the build source policy say so and what to change; other runtime build failures name the service and keep their message. The candidate guide states that the pinned network-tool APKs are Alpine v3.19 main/aarch64. Refs HACK-1925, HACK-1927 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 17 +- src/backends/native-project-builds.ts | 146 ++++++++++++++---- src/commands/doctor.ts | 5 + src/commands/project.ts | 4 + src/lib/doctor-compose-backend.ts | 13 +- src/lib/native-compose-backend-features.ts | 107 +++++++++---- ...ative-compose-backend-features-cli.test.ts | 86 ++++++++++- tests/native-compose-backend-features.test.ts | 58 +++++++ tests/native-project-builds.test.ts | 84 +++++++++- 9 files changed, 444 insertions(+), 76 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 68e82a2d4..c37ca4587 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -192,7 +192,7 @@ Compose backend. Native support for them is planned after v5. | External network | A top-level network sets `external: true` or `external: {name: ...}`. A reviewed `x-hack-isolated: true` replacement is not external: it creates an owned internal bridge. A network that the `HACK_NATIVE_ADAPTATION` selection (see [Frontend integration status](#frontend-integration-status)) lists in `isolatedNetworks` gets the same replacement, so it is not external either. | | External volume | A top-level volume sets `external: true` or `external: {name: ...}`. The native runtime only creates volumes it owns, and there is no native replacement for an external volume. | | Reserved ownership label | A service, network or volume label key starts with `com.docker.compose.`, `io.hack-local.`, `io.hack.native-config.`, `io.hack.native-file.` or `io.hack.storage-witness.`. Compose, the native runtime and native-config Compose use these labels to prove which resources they own. | -| Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. | +| Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. With `HACK_NATIVE_BUILDS=1` (see below), only a build field other than `context`, `dockerfile`, `target` and `args`, or `image` together with `build`, counts; native build admission reviews the values of those four fields itself. | | Non-native platform | A service sets a `platform` other than `linux/arm64` or `linux/arm64/v8`, for example `linux/amd64`. The native VM runs Linux arm64 only. A missing or `null` `platform` is unset and passes. | Native `hack up` and `hack restart` check for these features after selecting the @@ -244,8 +244,14 @@ With it: remote contexts and `image` together with `build` refuse. Values are never shown. - Build args are not secrets: as with Docker, the image records them in its history. A build arg whose value equals a `hack env` secret value of this project and - environment refuses before any hook or VM effect, naming only the argument. Pass + environment refuses before any hook or VM effect, naming the service and the + argument but never the value. The refusal does not suggest the Compose backend, + because a Compose build would record the value in the image history too. Pass secrets with BuildKit build secrets instead; native builds do not support them yet. +- Every build refusal names the service. A refusal from the runtime's build source + policy (for example a base image that is not a public Docker Hub reference) says + that it is the build source policy and what to change. Other build failures name + the service and keep the runtime's message. - After the runtime is up, each distinct build runs once with `runtime build-image` (below), and its filtered output streams to your terminal. Every service with that build is then pinned to the built content ID, so review, @@ -548,8 +554,11 @@ saved run state. The report lists removed images and why each other one was kept Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with -regular, singly linked mode-`0600` files. Acquire the matching public package bytes; -the command never downloads packages or accepts substitute versions: +regular, singly linked mode-`0600` files. The pins are the Alpine v3.19 `main` +repository for `aarch64`. Other Alpine releases, such as v3.20, publish files with +the same names but different bytes, which the command refuses. Acquire the matching +public package bytes; the command never downloads packages or accepts substitute +versions: | File | SHA-256 | | --- | --- | diff --git a/src/backends/native-project-builds.ts b/src/backends/native-project-builds.ts index 0d1b737eb..a7f60d13c 100644 --- a/src/backends/native-project-builds.ts +++ b/src/backends/native-project-builds.ts @@ -25,7 +25,18 @@ const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; const BUILD_KEY = /^[a-f0-9]{64}$/; const ARGUMENT = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; -const BUILD_KEYS = new Set(["context", "dockerfile", "target", "args"]); +/** + * The Compose `build` fields native builds implement. The Compose-backend feature + * gate admits exactly these fields when builds are enabled, so a build it lets + * through reaches this module's own admission and secret check. + */ +export const NATIVE_BUILD_FIELDS = [ + "context", + "dockerfile", + "target", + "args", +] as const; +const BUILD_KEYS: ReadonlySet = new Set(NATIVE_BUILD_FIELDS); const MAX_ARGS = 64; /** The runtime's own budget is 30 minutes; the client waits a little longer. */ const BUILD_TIMEOUT_SECONDS = 30 * 60; @@ -57,36 +68,48 @@ export interface NativeBuildRequest { readonly args: Readonly>; } -/** Fixed refusal for a `build:` service while native builds are not enabled. */ -export const NATIVE_BUILDS_DISABLED_REFUSAL = - "Native foreground up builds Compose build services only with HACK_NATIVE_BUILDS=1; set it to build them in the native runtime. No hook, VM or build was started."; +/** The refusal for the first `build:` service while native builds are not enabled. */ +export function nativeBuildsDisabledRefusal(service: string): string { + return `Native foreground up cannot build service ${service}: it builds Compose build services only with HACK_NATIVE_BUILDS=1; set it to build them in the native runtime. No hook, VM or build was started.`; +} -function refused(detail: string): HackCliError { +/** "service a", or "services a and b", for build refusals. */ +function serviceList(services: readonly string[]): string { + if (services.length <= 1) { + return `service ${services[0] ?? ""}`; + } + return `services ${services.slice(0, -1).join(", ")} and ${services.at(-1)}`; +} + +/** The admission refusal for one service. Authored values are never included. */ +function refused(service: string, detail: string): HackCliError { return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native foreground up cannot build this service: ${detail} Values were omitted; no hook, VM or build was started.`, + message: `Native foreground up cannot build service ${service}: ${detail} Values were omitted; no hook, VM or build was started.`, }); } -function literal(value: unknown, what: string): string { +function literal(service: string, value: unknown, what: string): string { if (typeof value !== "string" || value.length === 0 || value.includes("$")) { throw refused( + service, `${what} must be a literal non-empty string without Compose interpolation.` ); } return value; } -function argumentEntries(value: unknown): [string, unknown][] { +function argumentEntries(service: string, value: unknown): [string, unknown][] { if (isRecord(value)) { return Object.entries(value); } if (!Array.isArray(value)) { - throw refused("build args must be a mapping or a list."); + throw refused(service, "build args must be a mapping or a list."); } return value.map((item: unknown): [string, unknown] => { if (typeof item !== "string" || !item.includes("=")) { throw refused( + service, "list-form build args must be KEY=VALUE literals; inheriting a host variable is not supported." ); } @@ -95,25 +118,29 @@ function argumentEntries(value: unknown): [string, unknown][] { }); } -function buildArguments(value: unknown): Record { +function buildArguments( + service: string, + value: unknown +): Record { if (value === undefined) { return {}; } - const entries = argumentEntries(value); + const entries = argumentEntries(service, value); if (entries.length > MAX_ARGS) { - throw refused("a native build accepts at most 64 build args."); + throw refused(service, "a native build accepts at most 64 build args."); } // A null prototype keeps every admitted name an own property, `__proto__` included. const result: Record = Object.create(null); for (const [name, raw] of entries) { if (!ARGUMENT.test(name) || Object.hasOwn(result, name)) { - throw refused("build arg names must be unique identifiers."); + throw refused(service, "build arg names must be unique identifiers."); } const text = typeof raw === "number" && Number.isFinite(raw) ? String(raw) : raw; if (typeof text !== "string" || text.includes("$")) { // Env-derived values would be baked into image history. throw refused( + service, "build arg values must be literal strings or numbers; interpolation and null values are not supported." ); } @@ -123,41 +150,42 @@ function buildArguments(value: unknown): Record { } /** The declared build fields with Compose defaults, before path checks. */ -function buildFields(build: unknown): NativeBuildRequest { +function buildFields(service: string, build: unknown): NativeBuildRequest { if (typeof build === "string") { return { - context: literal(build, "the build context"), + context: literal(service, build, "the build context"), dockerfile: "Dockerfile", target: null, args: {}, }; } if (!isRecord(build)) { - throw refused("build must be a context path or a mapping."); + throw refused(service, "build must be a context path or a mapping."); } if (Object.keys(build).some((key) => !BUILD_KEYS.has(key))) { throw refused( + service, "only build context, dockerfile, target and literal args are supported." ); } const target = build.target === undefined ? null - : literal(build.target, "the build target"); + : literal(service, build.target, "the build target"); if (target !== null && !TARGET.test(target)) { - throw refused("the build target must be a stage name."); + throw refused(service, "the build target must be a stage name."); } return { context: build.context === undefined ? "." - : literal(build.context, "the build context"), + : literal(service, build.context, "the build context"), dockerfile: build.dockerfile === undefined ? "Dockerfile" - : literal(build.dockerfile, "the Dockerfile path"), + : literal(service, build.dockerfile, "the Dockerfile path"), target, - args: buildArguments(build.args), + args: buildArguments(service, build.args), }; } @@ -168,6 +196,8 @@ function buildFields(build: unknown): NativeBuildRequest { * runtime resolves it again without following symlinks. */ export function admitNativeProjectBuild(opts: { + /** The Compose service name; every refusal names it. */ + readonly name: string; readonly service: Readonly>; readonly projectRoot: string; readonly composeFile: string; @@ -176,21 +206,25 @@ export function admitNativeProjectBuild(opts: { if (build === undefined) { return; } + const name = opts.name; if (Object.hasOwn(opts.service, "image")) { - throw refused("a service may not declare both image and build."); + throw refused(name, "a service may not declare both image and build."); } - const { context, dockerfile, target, args } = buildFields(build); + const { context, dockerfile, target, args } = buildFields(name, build); if (context.includes("://") || context.startsWith("git@")) { - throw refused("remote build contexts are not supported."); + throw refused(name, "remote build contexts are not supported."); } if (isAbsolute(dockerfile)) { - throw refused("the Dockerfile must be relative to the build context."); + throw refused( + name, + "the Dockerfile must be relative to the build context." + ); } const absolute = resolve(dirname(opts.composeFile), context); const root = resolve(opts.projectRoot); const inside = relative(root, absolute); if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { - throw refused("the build context must be inside the project."); + throw refused(name, "the build context must be inside the project."); } return { context: inside === "" ? "." : inside, dockerfile, target, args }; } @@ -209,14 +243,14 @@ export function admitNativeProjectBuilds(opts: { }): ReadonlyMap { const builds = new Map(); for (const [name, service] of Object.entries(opts.services)) { - const request = admitNativeProjectBuild({ ...opts, service }); + const request = admitNativeProjectBuild({ ...opts, name, service }); if (request === undefined) { continue; } if (!opts.enabled) { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: NATIVE_BUILDS_DISABLED_REFUSAL, + message: nativeBuildsDisabledRefusal(name), }); } builds.set(name, request); @@ -461,7 +495,7 @@ export function refuseManagedSecretBuildArguments(opts: { if (secrets.has(value)) { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native foreground up cannot build ${service}: build arg ${name} equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started.`, + message: `Native foreground up cannot build service ${service}: build arg ${name} equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started.`, }); } } @@ -591,6 +625,8 @@ async function runPendingBuilds(opts: { cwd: opts.projectRoot, requestFile, signal: opts.signal, + }).catch((error: unknown) => { + throw nameBuildFailure(services, error); }); // The request carries argument values; remove it as soon as it is consumed. await rm(requestFile, { force: true }); @@ -606,6 +642,58 @@ async function runPendingBuilds(opts: { } } +/** + * Runtime refusal codes from the native build source policy: what the runtime + * accepts as build input and as the built image, each with what to change. The + * runtime checks them before or right after the build; they are not failures of + * the build itself. + */ +const BUILD_SOURCE_POLICY: Readonly> = { + native_build_source: + "Keep the build context and Dockerfile inside the project as regular files reached without symlinks.", + native_build_dockerfile: "Change the Dockerfile line it names.", + native_build_base_image: + "Use public Docker Hub images in the Dockerfile's FROM and COPY --from.", + native_build_argument: + "Change the build args in the service's Compose build.", + native_build_reserved_label: + "Remove reserved ownership labels from the Dockerfile and its base images.", + native_build_image_volumes: + "Remove VOLUME from the Dockerfile and its base images; declare volumes in Compose instead.", +}; + +/** + * Name the services of a failed `runtime build-image`. A refusal from the build + * source policy says so and what to change; any other runtime failure keeps its + * message. The native code is kept for callers that classify failures. + * Cancellation and closed client errors carry no service-specific cause and pass + * through unchanged. + */ +function nameBuildFailure( + services: readonly string[], + error: unknown +): unknown { + if (!(error instanceof NativeRuntimeRequestError)) { + return error; + } + const which = serviceList(services); + const code = error.nativeCode; + const change = + code !== undefined && Object.hasOwn(BUILD_SOURCE_POLICY, code) + ? BUILD_SOURCE_POLICY[code] + : undefined; + const message = + change === undefined + ? `Native foreground up could not build ${which}: ${error.message}` + : `Native foreground up cannot build ${which}: the native build source policy refused it. ${error.message} ${change} No image was adopted.`; + return new NativeRuntimeRequestError({ + message, + nativeCode: error.nativeCode, + nativeCauseCode: error.nativeCauseCode, + nativeStopFailures: error.nativeStopFailures, + }); +} + function pin(spec: Record, image: string): void { if (!IMAGE_ID.test(image)) { throw new ClosedCliError( diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..24553e467 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { checkLegacyProjectAgentArtifacts, checkLegacyUserAgentArtifacts, } from "../agents/legacy-artifacts.ts"; +import { nativeBuildsEnabled } from "../backends/native-project-builds.ts"; import { runNativeProjectCleanupCommand } from "../backends/native-project-cleanup-command.ts"; import { parseNativeRunMappingRecoveryOptions, @@ -2945,10 +2946,14 @@ async function checkComposeBackendFeatures(opts: { message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, }; } + // The same build classification native up applies under HACK_NATIVE_BUILDS. + const buildsEnabled = nativeBuildsEnabled(); return composeBackendCheckResult({ backend: opts.backend, + buildsEnabled, features: await readComposeBackendFeatures(ctx.composeFile, { adaptationFile: opts.adaptationFile, + buildsEnabled, }), }); } diff --git a/src/commands/project.ts b/src/commands/project.ts index ff71f6a36..18894ba4b 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -42,6 +42,7 @@ import { import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; import { parseNativePreparedBase } from "../backends/native-prepared-base.ts"; +import { nativeBuildsEnabled } from "../backends/native-project-builds.ts"; import { nativeDownEnvironment, nativeProjectDown, @@ -5728,10 +5729,13 @@ async function handleNativeUp({ // stays available for any graph a project already has. // The explicit adaptation applies first: a network it isolates is not // external here, exactly as native start normalizes it. + // With native builds enabled, build fields native builds implement pass + // here and native build admission reviews them, secret-valued args included. await refuseNativeComposeBackendFeatures({ composeFile: project.composeFile, adaptationFile: process.env.HACK_NATIVE_ADAPTATION, restart, + buildsEnabled: nativeBuildsEnabled(), }); const branch = await resolveEffectiveBranchForCommand({ project, diff --git a/src/lib/doctor-compose-backend.ts b/src/lib/doctor-compose-backend.ts index da78f14b8..86765332b 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -1,6 +1,7 @@ import { COMPOSE_BACKEND_SELECTION, type ComposeBackendFeature, + type ComposeBackendFeatureOptions, describeComposeBackendFeatures, } from "./native-compose-backend-features.ts"; @@ -21,10 +22,12 @@ export type ComposeBackendReport = { * refuses the project; on the Compose backend it is informational, because * the project already runs where those features are supported. */ -export function composeBackendCheckResult(opts: { - readonly backend: "native" | "compose"; - readonly features: readonly ComposeBackendFeature[]; -}): { +export function composeBackendCheckResult( + opts: { + readonly backend: "native" | "compose"; + readonly features: readonly ComposeBackendFeature[]; + } & ComposeBackendFeatureOptions +): { readonly name: string; readonly status: "ok" | "warn"; readonly message: string; @@ -40,7 +43,7 @@ export function composeBackendCheckResult(opts: { composeBackend, }; } - const uses = `This project uses ${describeComposeBackendFeatures(opts.features)}`; + const uses = `This project uses ${describeComposeBackendFeatures(opts.features, opts)}`; if (opts.backend === "native") { return { name: COMPOSE_BACKEND_CHECK, diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 2afef1ed8..2cf759832 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -1,5 +1,6 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; +import { NATIVE_BUILD_FIELDS } from "../backends/native-project-builds.ts"; import { readNativeSelection } from "../backends/native-project-selection.ts"; import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; @@ -73,6 +74,25 @@ const FEATURE_PHRASES: Readonly> = { foreign_platform: "a service platform other than linux/arm64", }; +/** "a, b, c and d" for the build fields native builds implement. */ +function nativeBuildFieldList(): string { + return `${NATIVE_BUILD_FIELDS.slice(0, -1).join(", ")} and ${NATIVE_BUILD_FIELDS.at(-1)}`; +} + +/** The `unsupported_build` phrase while `HACK_NATIVE_BUILDS=1` is set. */ +const NATIVE_BUILDS_PHRASE = `a Compose build field other than ${nativeBuildFieldList()}, or image together with build`; + +/** Options shared by the classifier, its readers and its messages. */ +export type ComposeBackendFeatureOptions = { + /** + * Whether native builds are enabled (`HACK_NATIVE_BUILDS=1`). Then a build + * is a Compose-backend feature only when it declares a field native builds + * do not implement, or `image` together with `build`; native build + * admission reviews the rest, including `args` and the managed-secret check. + */ + readonly buildsEnabled?: boolean; +}; + function labelKeys(labels: unknown): readonly string[] { if (isRecord(labels)) { return Object.keys(labels); @@ -109,19 +129,36 @@ function isExternalNetwork(network: unknown): boolean { } /** - * A build is inside the supported path only when the import preview's basic - * build mapper accepts it and the service names no image, exactly as - * `hack config import --dry-run` reports `invalid_or_unsupported_build` and - * `image_build_exclusive`. + * Without native builds, a build is inside the supported path only when the + * import preview's basic build mapper accepts it and the service names no + * image, exactly as `hack config import --dry-run` reports + * `invalid_or_unsupported_build` and `image_build_exclusive`. + * + * With native builds, the gate admits exactly the fields native builds + * implement ({@link NATIVE_BUILD_FIELDS}). Values in those fields, such as + * paths, interpolation and build args, are left to native build admission, + * which refuses with the service name and never suggests the Compose backend: + * a build arg that carries a secret would be recorded in the image history + * there too. */ -function isUnsupportedBuild(service: Record): boolean { +function isUnsupportedBuild( + service: Record, + buildsEnabled: boolean +): boolean { if (!Object.hasOwn(service, "build")) { return false; } - return ( - Object.hasOwn(service, "image") || - mapLegacyComposeBuild(service.build) === undefined - ); + if (Object.hasOwn(service, "image")) { + return true; + } + const build = service.build; + if (buildsEnabled) { + const fields: ReadonlySet = new Set(NATIVE_BUILD_FIELDS); + return ( + isRecord(build) && Object.keys(build).some((key) => !fields.has(key)) + ); + } + return mapLegacyComposeBuild(build) === undefined; } /** @@ -174,7 +211,7 @@ export function findComposeBackendFeatures( * {@link readAdaptedIsolatedNetworks}. */ readonly isolatedNetworks?: ReadonlySet; - } + } & ComposeBackendFeatureOptions ): ComposeBackendFeature[] { if (!isRecord(compose)) { return []; @@ -205,7 +242,8 @@ export function findComposeBackendFeatures( ) { found.add("reserved_label"); } - if (services.some(isUnsupportedBuild)) { + const buildsEnabled = opts?.buildsEnabled === true; + if (services.some((service) => isUnsupportedBuild(service, buildsEnabled))) { found.add("unsupported_build"); } if (services.some(isForeignPlatform)) { @@ -305,10 +343,11 @@ export async function readAdaptedIsolatedNetworks( */ export async function readComposeBackendFeatures( composeFile: string, - opts?: { readonly adaptationFile?: string } + opts?: { readonly adaptationFile?: string } & ComposeBackendFeatureOptions ): Promise { const compose = await readComposeDocument(composeFile); - const features = findComposeBackendFeatures(compose); + const buildsEnabled = opts?.buildsEnabled; + const features = findComposeBackendFeatures(compose, { buildsEnabled }); if ( opts?.adaptationFile === undefined || !features.includes("external_network") @@ -317,14 +356,20 @@ export async function readComposeBackendFeatures( } return findComposeBackendFeatures(compose, { isolatedNetworks: await readAdaptedIsolatedNetworks(opts.adaptationFile), + buildsEnabled, }); } /** Value-free phrase list, such as "a and b" or "a, b and c". */ export function describeComposeBackendFeatures( - features: readonly ComposeBackendFeature[] + features: readonly ComposeBackendFeature[], + opts?: ComposeBackendFeatureOptions ): string { - const phrases = features.map((feature) => FEATURE_PHRASES[feature]); + const phrases = features.map((feature) => + feature === "unsupported_build" && opts?.buildsEnabled === true + ? NATIVE_BUILDS_PHRASE + : FEATURE_PHRASES[feature] + ); if (phrases.length <= 1) { return phrases[0] ?? ""; } @@ -340,14 +385,16 @@ export const COMPOSE_BACKEND_SELECTION = * `E_NATIVE_PROJECT_UNSUPPORTED`, one line, and a `detail` that lists only the * closed feature names. */ -export function composeBackendRefusal(opts: { - readonly features: readonly ComposeBackendFeature[]; - readonly restart: boolean; -}): HackCliError { +export function composeBackendRefusal( + opts: { + readonly features: readonly ComposeBackendFeature[]; + readonly restart: boolean; + } & ComposeBackendFeatureOptions +): HackCliError { const command = opts.restart ? "hack restart" : "hack up"; return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features)}; this project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} and run the command again. Nothing was started or stopped.`, + message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features, opts)}; this project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} and run the command again. Nothing was started or stopped.`, detail: { backend: "compose", features: [...opts.features] }, }); } @@ -357,16 +404,16 @@ export function composeBackendRefusal(opts: { * needs the Compose backend. Call it after project selection and before any * runtime request, storage preparation, lifecycle hook or stop. */ -export async function refuseNativeComposeBackendFeatures(opts: { - readonly composeFile: string; - /** The `HACK_NATIVE_ADAPTATION` path native start applies, if any. */ - readonly adaptationFile?: string; - readonly restart: boolean; -}): Promise { - const features = await readComposeBackendFeatures(opts.composeFile, { - adaptationFile: opts.adaptationFile, - }); +export async function refuseNativeComposeBackendFeatures( + opts: { + readonly composeFile: string; + /** The `HACK_NATIVE_ADAPTATION` path native start applies, if any. */ + readonly adaptationFile?: string; + readonly restart: boolean; + } & ComposeBackendFeatureOptions +): Promise { + const features = await readComposeBackendFeatures(opts.composeFile, opts); if (features.length > 0) { - throw composeBackendRefusal({ features, restart: opts.restart }); + throw composeBackendRefusal({ ...opts, features }); } } diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index 0ef0e5a26..bf4e063c8 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -10,8 +10,10 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative, resolve } from "node:path"; -import { NATIVE_BUILDS_DISABLED_REFUSAL } from "../src/backends/native-project-builds.ts"; +import { nativeBuildsDisabledRefusal } from "../src/backends/native-project-builds.ts"; import { NATIVE_CONFIGURATION_REFUSAL } from "../src/backends/native-project-start.ts"; +import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; +import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -332,7 +334,7 @@ test( NATIVE_CONFIGURATION_REFUSAL, ], // A basic build stays refused unless native builds are explicitly enabled. - [{ build: ".." }, NATIVE_BUILDS_DISABLED_REFUSAL], + [{ build: ".." }, nativeBuildsDisabledRefusal("web")], ] as const) { const f = await fixture({ services: { web: service } }); const result = await cli(f, ["up"]); @@ -345,6 +347,86 @@ test( SPAWN_TIMEOUT_MS ); +/** Native builds enabled, with the usual shared-source grant. */ +const BUILDS_ENV = { HACK_NATIVE_SHARED_SOURCE: "1", HACK_NATIVE_BUILDS: "1" }; + +test( + "with native builds enabled, build args pass the check and reach native start", + async () => { + // HACK-1925: args are a native builds field, so the Compose-backend check + // passes and native start goes on to its own source preflight and runtime. + const f = await fixture({ + services: { + web: { + build: { context: "../app", args: { GREETING: "synthetic" } }, + }, + }, + }); + const result = await cli(f, ["up"], BUILDS_ENV); + expect(result.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); + expect(result.output).toMatch(RUNTIME_REQUEST_LINE); + const calls = await f.calls(); + expect(calls).toEqual( + ["runtime check-project-share", "runtime up"].slice(0, calls.length) + ); + expect(calls.length).toBeGreaterThan(0); + }, + SPAWN_TIMEOUT_MS +); + +test( + "with native builds enabled, a build arg equal to a managed secret refuses by name only", + async () => { + const secret = "synthetic-dummy-managed-value-1925"; + const f = await fixture({ + services: { + web: { build: { context: "../app", args: { GREETING: secret } } }, + }, + }); + await writeFile( + join(f.project, PROJECT_ENV_KEY_FILENAME), + "synthetic-project-key" + ); + await setProjectEnvValue({ + projectRoot: f.project, + projectDir: join(f.project, ".hack"), + envName: null, + scope: "global", + key: "FIXTURE_TOKEN", + value: secret, + secret: true, + }); + const result = await cli(f, ["up"], BUILDS_ENV); + expectOneClosedLine( + result, + "E_NATIVE_PROJECT_UNSUPPORTED: Native foreground up cannot build service web: build arg GREETING equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started." + ); + expect(result.output).not.toContain(secret); + // The Compose backend would record the value in the image history too. + expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); + expect(await f.calls()).toEqual(["runtime check-project-share"]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "with native builds enabled, a build field native builds lack still selects the Compose backend", + async () => { + await expectComposeBackendRefusal({ + compose: { + services: { + web: { build: { context: "../app", ssh: ["default"] } }, + }, + }, + env: BUILDS_ENV, + phrase: + "a Compose build field other than context, dockerfile, target and args, or image together with build", + }); + }, + SPAWN_TIMEOUT_MS +); + test( "control: a reviewed isolated network replacement and arm64 platform pass the check", async () => { diff --git a/tests/native-compose-backend-features.test.ts b/tests/native-compose-backend-features.test.ts index 34c766d22..b585f6910 100644 --- a/tests/native-compose-backend-features.test.ts +++ b/tests/native-compose-backend-features.test.ts @@ -166,6 +166,64 @@ test("builds follow the import preview's basic build path", () => { } }); +test("with native builds enabled, exactly the fields native builds implement pass", () => { + const enabled = { buildsEnabled: true } as const; + // HACK-1925: args and the other implemented fields reach native build admission, + // which reviews values (paths, interpolation, managed secrets) itself. + for (const build of [ + "..", + "/absolute/context", + { context: "../app", args: { GREETING: "synthetic" } }, + { context: "..", args: ["A=1"], dockerfile: "Dockerfile", target: "dev" }, + { context: "../../outside" }, + { args: { A: "${HOST_VALUE}" } }, + {}, + ]) { + expect(findComposeBackendFeatures(services({ build }), enabled)).toEqual( + [] + ); + } + for (const service of [ + { build: { context: "..", platforms: ["linux/amd64"] } }, + { build: { context: "..", ssh: ["default"] } }, + { build: { context: "..", secrets: ["token"] } }, + { build: { context: "..", args: { A: "1" }, cache_from: ["x"] } }, + { build: "..", image: IMAGE }, + ]) { + expect(findComposeBackendFeatures(services(service), enabled)).toEqual([ + "unsupported_build", + ]); + } + // Without native builds, today's basic-form classification is unchanged. + expect( + findComposeBackendFeatures( + services({ build: { context: "..", args: { A: "1" } } }) + ) + ).toEqual(["unsupported_build"]); +}); + +test("the build refusal phrase follows whether native builds are enabled", () => { + const features = ["unsupported_build"] as const; + expect(composeBackendRefusal({ features, restart: false }).message).toContain( + "does not support a Compose build outside the basic context, dockerfile and target form;" + ); + const enabled = composeBackendRefusal({ + features, + restart: false, + buildsEnabled: true, + }).message; + expect(enabled).toContain( + "does not support a Compose build field other than context, dockerfile, target and args, or image together with build;" + ); + expect( + composeBackendCheckResult({ + backend: "native", + features, + buildsEnabled: true, + }).message + ).toContain("a Compose build field other than context, dockerfile"); +}); + test("only the native linux/arm64 platform passes", () => { for (const platform of ["linux/arm64", "linux/arm64/v8"]) { expect( diff --git a/tests/native-project-builds.test.ts b/tests/native-project-builds.test.ts index c281fb319..9dc3c8f35 100644 --- a/tests/native-project-builds.test.ts +++ b/tests/native-project-builds.test.ts @@ -15,8 +15,8 @@ import { admitNativeProjectBuilds, buildNativeProjectImages, invokeNativeBuild, - NATIVE_BUILDS_DISABLED_REFUSAL, type NativeBuildRequest, + nativeBuildsDisabledRefusal, nativeBuildsEnabled, nativeServiceActive, refuseManagedSecretBuildArguments, @@ -45,7 +45,12 @@ function admit( service: Record, composeFile = "/p/.hack/compose.yaml" ) { - return admitNativeProjectBuild({ service, projectRoot: "/p", composeFile }); + return admitNativeProjectBuild({ + name: "app", + service, + projectRoot: "/p", + composeFile, + }); } test("admitted build forms follow Compose paths and literal arguments", () => { @@ -132,8 +137,14 @@ test.each([ })(); expect(error).toBeInstanceOf(HackCliError); expect(error).toMatchObject({ code: "E_NATIVE_PROJECT_UNSUPPORTED" }); - expect(String((error as Error).message)).toContain(text); - expect(String((error as Error).message)).not.toContain("HOST_VALUE"); + const message = String((error as Error).message); + expect(message).toContain(text); + // HACK-1927: every admission refusal names the service it is about. + expect(message).toStartWith( + "Native foreground up cannot build service app: " + ); + expect(message).not.toContain("HOST_VALUE"); + expect(message).not.toContain("HACK_RUNTIME_BACKEND"); }); test("builds refuse unless explicitly enabled, and projects without builds are unaffected", () => { @@ -145,7 +156,10 @@ test("builds refuse unless explicitly enabled, and projects without builds are u composeFile: "/p/compose.yaml", enabled: false, }) - ).toThrow(NATIVE_BUILDS_DISABLED_REFUSAL); + ).toThrow(nativeBuildsDisabledRefusal("web")); + expect(nativeBuildsDisabledRefusal("web")).toContain( + "cannot build service web: " + ); expect( admitNativeProjectBuilds({ services: { db: { image: "postgres:17" } }, @@ -367,10 +381,14 @@ test("a build arg equal to a managed secret value refuses without showing it", ( expect(refusal).toBeInstanceOf(HackCliError); const error = refusal as HackCliError; expect(error.code).toBe("E_NATIVE_PROJECT_UNSUPPORTED"); + // HACK-1925: the refusal names the service and the argument, never the value, + // and does not point at the Compose backend, which would bake it into history. expect(error.message).toContain( - "build arg TOKEN equals a hack env secret value" + "cannot build service web: build arg TOKEN equals a hack env secret value" ); expect(error.message).not.toContain(secret); + expect(error.message).not.toContain("HACK_RUNTIME_BACKEND"); + expect(error.message).not.toContain("Compose backend"); // Values that are not managed secrets pass; so does any build with no secrets. refuseManagedSecretBuildArguments({ builds: new Map([["web", request({ MODE: "production" })]]), @@ -460,3 +478,57 @@ test("cancellation sends SIGTERM so the runtime can stop its build client", asyn expect(String(error)).toContain("canceled during an image build"); expect(await readFile(`${fake.binary}.signal`, "utf8")).toBe("terminated"); }); + +test("runtime build refusals name the services and the build source policy", async () => { + const project = await root(); + const policy = + "Dockerfile line 1: build images must be public Docker Hub references."; + const fail = (nativeCode: string, message: string) => async () => { + throw new NativeRuntimeRequestError({ message, nativeCode }); + }; + const run = async (invoke: () => Promise) => + await buildNativeProjectImages({ + runtime, + projectRoot: project, + branch: null, + profiles: [], + // Identical declarations build once, so one refusal covers both services. + builds: new Map([ + ["web", request()], + ["worker", request()], + ]), + specs: { web: { build: "app" }, worker: { build: "app" } }, + retainedImages: new Map(), + restore: false, + rebuild: false, + signal: new AbortController().signal, + invoke, + }).catch((caught: unknown) => caught); + + const refused = await run(fail("native_build_base_image", policy)); + expect(refused).toBeInstanceOf(NativeRuntimeRequestError); + expect(refused).toMatchObject({ nativeCode: "native_build_base_image" }); + const message = (refused as Error).message; + expect(message).toStartWith( + "Native foreground up cannot build services web and worker: the native build source policy refused it. " + ); + expect(message).toContain(policy); + expect(message).toContain("Use public Docker Hub images"); + expect(message).not.toContain(VALUE); + + // A failure that is not a policy refusal names the services and keeps its message. + const failed = await run( + fail("native_build_failed", "The image build failed.") + ); + expect(failed).toMatchObject({ nativeCode: "native_build_failed" }); + expect((failed as Error).message).toBe( + "Native foreground up could not build services web and worker: The image build failed." + ); + expect((failed as Error).message).not.toContain("source policy"); + + // An inherited property name is not a policy code. + const odd = await run(fail("constructor", "Unexpected failure.")); + expect((odd as Error).message).toBe( + "Native foreground up could not build services web and worker: Unexpected failure." + ); +}); From f38751ceaeca59bbd8c654bfc3986194cc0ff900 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:26:03 -0500 Subject: [PATCH 109/138] fix(native): relay the build client so the VM stays responsive The pinned provider forwards the guest engine socket through its vsock muxer. On macOS, when a host client closes a forwarded connection while the guest is still sending, the muxer re-arms the dead stream with write interest only and then fails to delete it: one kevent change list stops at the unregistered read filter. The socket reports a hang-up on every wait, each one queues a reset to the guest and restarts the stream's reaper delay, so the stream is never removed. Guest vCPUs and the shared vsock queue stay saturated and later agent and engine requests time out. The BuildKit client closes many connections before reading the whole response and the rest at exit, so most builds reached that state. The build client now reaches the engine through a private relay that always drains guest bytes (discarding them once the client is gone), forwards a client's end of stream only as a half-close, and closes a provider connection only after the guest ended it, within a bounded wait. Refs HACK-1924 Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/README.md | 7 +- .../runtime-core/src/provider/image_build.rs | 25 +- .../src/provider/image_build/client_relay.rs | 536 ++++++++++++++++++ .../image_build/client_relay/tests.rs | 154 +++++ 4 files changed, 714 insertions(+), 8 deletions(-) create mode 100644 packages/runtime-core/src/provider/image_build/client_relay.rs create mode 100644 packages/runtime-core/src/provider/image_build/client_relay/tests.rs diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 3f650f695..ba644c1a6 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -490,7 +490,12 @@ that does not name an owned network or `none`. Cold builds need free guest stora cache to 8 GiB, and `runtime reclaim-build-images` removes only owned, superseded, unreferenced images (`provider/image_build/reclaim.rs`; model `tests/models/tla/native-build-retention`). Native `up` calls the build operation -for Compose `build:` services only when `HACK_NATIVE_BUILDS=1` is set. +for Compose `build:` services only when `HACK_NATIVE_BUILDS=1` is set. The client reaches +the engine through a private relay (`provider/image_build/client_relay.rs`): the pinned +provider's macOS socket forwarding can loop forever when a host client closes a forwarded +connection while the guest is still sending, which saturated the guest and stopped every later +agent and engine request. The relay always drains guest bytes, forwards a client's end of +stream only as a half-close, and closes a provider connection only after the guest ended it. Source binaries embed their build checkout. If a copied Cargo target directory retains a different checkout identity, use a fresh `--target-dir` for checks and diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index 371484822..0db8ea8e3 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -2,7 +2,9 @@ //! //! One operation builds one image for one build key inside the guest engine, through //! the pinned host BuildKit client (`docker-buildx`, docker driver) talking to the -//! engine's verified forwarded socket. The built image lands in the guest image store, +//! engine's verified forwarded socket through a private relay ([`client_relay`]) that +//! keeps the provider's socket forwarding out of a known busy loop. The built image +//! lands in the guest image store, //! where graph admission already looks, and the caller pins the service to the content //! ID it returns, so review, receipts and restore treat it like a pulled image. //! @@ -68,6 +70,7 @@ use std::{ time::{Duration, Instant}, }; +mod client_relay; mod output; pub mod reclaim; #[cfg(test)] @@ -875,8 +878,17 @@ pub(super) fn run( // outlives the client run. let _definition = RemoveOnDrop(scratch.join("definition").join(BAKE_DEFINITION)); let _policy = RemoveOnDrop(scratch.join("definition").join(SOURCE_POLICY)); + // The client reaches the engine only through the relay, which keeps the provider's + // forwarded socket out of the state described in `client_relay`. + let relay = client_relay::ClientRelay::start(&identity.socket)?; let mut client = command( - &builder, &scratch, &metadata, inputs, &identity, &pins, &expected, + &builder, + &scratch, + &metadata, + inputs, + relay.socket(), + &pins, + &expected, )?; inherit_locks(&mut client, &[&fence, &key_lock]); let ended = supervise( @@ -886,6 +898,8 @@ pub(super) fn run( output::Filter::new(inputs.args.values().map(String::as_str)), pool.output(), ); + // The client has exited or was stopped; wait for the guest to end its connections. + relay.finish(client_relay::FINISH_GRACE); let ended = match ended { Ok(ended) => ended, Err(error) => { @@ -1101,7 +1115,7 @@ fn command( scratch: &Path, metadata: &Path, inputs: &Inputs, - identity: &PoolIdentity, + engine: &Path, pins: &BTreeMap, labels: &BTreeMap<&'static str, String>, ) -> Result { @@ -1128,10 +1142,7 @@ fn command( .current_dir(scratch) .env("HOME", scratch) .env("TMPDIR", scratch) - .env( - "DOCKER_HOST", - format!("unix://{}", identity.socket.display()), - ) + .env("DOCKER_HOST", format!("unix://{}", engine.display())) // An empty private client configuration: no contexts, credential helpers or // proxies from the user's Docker setup. .env("DOCKER_CONFIG", &config) diff --git a/packages/runtime-core/src/provider/image_build/client_relay.rs b/packages/runtime-core/src/provider/image_build/client_relay.rs new file mode 100644 index 000000000..c536af042 --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/client_relay.rs @@ -0,0 +1,536 @@ +//! A private host relay between the build client and the provider's forwarded engine +//! socket. +//! +//! The pinned provider forwards the guest engine socket to the host through its vsock +//! muxer. On macOS that muxer can enter a permanent busy loop when the host peer of a +//! forwarded connection goes away while the guest is still sending on it: the send fails, +//! the stream is reset, a later credit update from the guest re-arms it with only write +//! interest, and deleting that interest fails (one `kevent` change list stops at the +//! unregistered read filter). The dead socket then reports a hang-up on every wait, each +//! one queues another reset to the guest and restarts the stream's reaper delay, so the +//! stream is never removed. The guest's vCPUs and the shared vsock queue stay saturated +//! and every later agent or engine request times out. +//! +//! The BuildKit client closes many connections before reading the whole response, and +//! closes the rest at exit, so builds reached that state in most runs. The relay keeps the +//! provider-side connection away from it with three rules: +//! +//! - Provider bytes are always read promptly, including after the client has gone (they +//! are then counted and discarded), so the provider never sees a failed send. +//! - The client's end of stream reaches the provider only as a half-close, after every +//! byte the client sent was delivered. +//! - The relay closes a provider-side connection only after the provider reported end of +//! stream, which means the guest side finished. [`ClientRelay::finish`] bounds that wait +//! and then closes what is left, counting it in [`RelayReport::forced`]. +//! +//! The listening socket lives in a fresh mode-0700 directory under the system temporary +//! root, so its path fits `sockaddr_un` and only this user can connect. It accepts only +//! while the client runs; nothing else is relayed and no bytes are inspected or logged. +use crate::CandidateError; +use std::{ + fs::{self, File}, + io::{ErrorKind, Read, Write}, + os::fd::AsRawFd, + os::unix::{ + fs::{DirBuilderExt, MetadataExt}, + net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +/// How long [`ClientRelay::finish`] waits for the guest to end its connections. +pub(super) const FINISH_GRACE: Duration = Duration::from_secs(10); +const TICK: Duration = Duration::from_millis(50); +const CHUNK: usize = 64 * 1024; +/// Client bytes not yet delivered; reading the client pauses above this. +const TO_PROVIDER_LIMIT: usize = 1024 * 1024; +/// Provider bytes not yet delivered to a live client; reading the provider pauses above +/// this only while the client is still connected but not reading. +const TO_CLIENT_LIMIT: usize = 16 * 1024 * 1024; +/// A larger receive buffer lets the provider's sends complete at once instead of +/// queueing in the provider. +const PROVIDER_RECEIVE_BUFFER: libc::c_int = 4 * 1024 * 1024; +const SOCKET_NAME: &str = "engine.sock"; + +/// What the relay did, for tests and diagnostics. It carries no payload bytes. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct RelayReport { + /// Client connections accepted. + pub connections: u32, + /// Provider bytes read after the client had gone. + pub discarded_bytes: u64, + /// Connections closed by the bound in `finish` before the provider ended them. + pub forced: u32, + /// Client connections dropped because the provider socket refused the connection. + pub refused: u32, +} + +struct Shared { + stop: AtomicBool, + force: AtomicBool, + report: Mutex, + connections: Mutex>>, +} + +impl Shared { + fn update(&self, change: impl FnOnce(&mut RelayReport)) { + // A poisoned lock means a connection thread panicked; the counts stay usable. + let mut report = self + .report + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + change(&mut report); + } +} + +/// A running relay. Call [`ClientRelay::finish`] once the client has exited. +pub(super) struct ClientRelay { + directory: PathBuf, + socket: PathBuf, + shared: Arc, + acceptor: Option>, +} + +impl ClientRelay { + /// Listen on a fresh private socket and relay each connection to `provider`. + /// Connecting to `provider` happens per client connection, so starting never touches + /// the engine. + pub(super) fn start(provider: &Path) -> Result { + let directory = private_directory()?; + let socket = directory.join(SOCKET_NAME); + let shared = Arc::new(Shared { + stop: AtomicBool::new(false), + force: AtomicBool::new(false), + report: Mutex::new(RelayReport::default()), + connections: Mutex::new(Vec::new()), + }); + // From here on, dropping `relay` removes the socket and its directory. + let mut relay = Self { + directory, + socket, + shared, + acceptor: None, + }; + let listener = UnixListener::bind(&relay.socket) + .and_then(|listener| listener.set_nonblocking(true).map(|()| listener)) + .map_err(|_| failure("Cannot listen for the build client."))?; + let provider = provider.to_path_buf(); + let accepting = Arc::clone(&relay.shared); + relay.acceptor = Some( + std::thread::Builder::new() + .name("build-client-relay".into()) + .spawn(move || accept(&listener, &provider, &accepting)) + .map_err(|_| failure("Cannot start the build client relay."))?, + ); + Ok(relay) + } + + /// The socket the client connects to. + pub(super) fn socket(&self) -> &Path { + &self.socket + } + + /// Stop accepting, then wait up to `grace` for the provider to end every relayed + /// connection, closing any that remain after it. + pub(super) fn finish(mut self, grace: Duration) -> RelayReport { + self.stop(grace) + } + + fn stop(&mut self, grace: Duration) -> RelayReport { + self.shared.stop.store(true, Ordering::Release); + if let Some(acceptor) = self.acceptor.take() { + let _ = acceptor.join(); + } + let _ = fs::remove_file(&self.socket); + let _ = fs::remove_dir(&self.directory); + let connections = std::mem::take( + &mut *self + .shared + .connections + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ); + let deadline = Instant::now() + grace; + while connections.iter().any(|handle| !handle.is_finished()) && Instant::now() < deadline { + std::thread::sleep(TICK); + } + self.shared.force.store(true, Ordering::Release); + for handle in connections { + let _ = handle.join(); + } + *self + .shared + .report + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +impl Drop for ClientRelay { + fn drop(&mut self) { + if self.acceptor.is_some() { + // Only an early return reaches this; the client has already been stopped. + self.stop(FINISH_GRACE); + } else { + let _ = fs::remove_file(&self.socket); + let _ = fs::remove_dir(&self.directory); + } + } +} + +fn failure(message: &str) -> CandidateError { + CandidateError::new("native_build_client", message) +} + +/// A new mode-0700 directory with a short path, owned by this user. +fn private_directory() -> Result { + let parent = if cfg!(target_os = "macos") { + Path::new("/private/tmp") + } else { + Path::new("/tmp") + }; + for _ in 0..8 { + let mut bytes = [0_u8; 6]; + File::open("/dev/urandom") + .and_then(|mut random| random.read_exact(&mut bytes)) + .map_err(|_| failure("Cannot choose a private build client socket."))?; + let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let directory = parent.join(format!("hkb-{name}")); + match fs::DirBuilder::new().mode(0o700).create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(_) => return Err(failure("Cannot create a private build client socket.")), + } + let metadata = fs::symlink_metadata(&directory) + .map_err(|_| failure("Cannot inspect the private build client socket."))?; + // SAFETY: geteuid has no preconditions. + if !metadata.is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(failure( + "The private build client socket directory is not private.", + )); + } + return Ok(directory); + } + Err(failure("Cannot create a private build client socket.")) +} + +fn accept(listener: &UnixListener, provider: &Path, shared: &Arc) { + while !shared.stop.load(Ordering::Acquire) { + match listener.accept() { + Ok((client, _)) => { + shared.update(|report| report.connections += 1); + let Ok(upstream) = connect(provider) else { + shared.update(|report| report.refused += 1); + continue; + }; + let relaying = Arc::clone(shared); + let spawned = std::thread::Builder::new() + .name("build-client-connection".into()) + .spawn(move || { + if let Ok(mut connection) = Connection::new(client, upstream) { + connection.run(&relaying); + } + }); + if let Ok(handle) = spawned { + shared + .connections + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(handle); + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => std::thread::sleep(TICK), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(_) => std::thread::sleep(TICK), + } + } +} + +fn connect(provider: &Path) -> std::io::Result { + let stream = UnixStream::connect(provider)?; + // SAFETY: the stream owns this live descriptor for the call; `size` points to a + // readable native int of the option's size. A refused size only loses headroom. + unsafe { + libc::setsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_RCVBUF, + (&PROVIDER_RECEIVE_BUFFER as *const libc::c_int).cast(), + std::mem::size_of::() as libc::socklen_t, + ); + } + Ok(stream) +} + +/// Pending bytes for one direction. +#[derive(Default)] +struct Pending { + bytes: Vec, + start: usize, +} + +impl Pending { + fn len(&self) -> usize { + self.bytes.len() - self.start + } + fn is_empty(&self) -> bool { + self.len() == 0 + } + fn push(&mut self, bytes: &[u8]) { + if self.start == self.bytes.len() { + self.bytes.clear(); + self.start = 0; + } + self.bytes.extend_from_slice(bytes); + } + fn clear(&mut self) { + self.bytes.clear(); + self.start = 0; + } + /// Write what `stream` accepts now. `Err` means the peer can take no more. + fn flush(&mut self, stream: &mut UnixStream) -> Result<(), ()> { + while !self.is_empty() { + match stream.write(&self.bytes[self.start..]) { + Ok(0) => return Err(()), + Ok(count) => self.start += count, + Err(error) if error.kind() == ErrorKind::WouldBlock => return Ok(()), + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(_) => return Err(()), + } + } + Ok(()) + } +} + +enum Received { + Data(usize), + End, + Again, +} + +fn read_some(stream: &mut UnixStream, buffer: &mut [u8]) -> Received { + loop { + match stream.read(buffer) { + Ok(0) => return Received::End, + Ok(count) => return Received::Data(count), + Err(error) if error.kind() == ErrorKind::WouldBlock => return Received::Again, + Err(error) if error.kind() == ErrorKind::Interrupted => {} + // A reset ends the stream like an orderly end of stream. + Err(_) => return Received::End, + } + } +} + +/// One relayed connection. `client_*` is the build client's side, `provider_*` the +/// provider's forwarded engine socket. +struct Connection { + client: UnixStream, + provider: UnixStream, + to_provider: Pending, + to_client: Pending, + /// The client sent end of stream or can no longer be read. + client_ended: bool, + /// The client can no longer receive; provider bytes are discarded. + client_gone: bool, + /// The provider's end of stream was forwarded to the client. + client_closed_write: bool, + /// The provider reported end of stream: the guest side finished sending. + provider_ended: bool, + /// The provider can no longer receive. + provider_gone: bool, + /// The client's end of stream was forwarded to the provider. + provider_closed_write: bool, + /// Provider bytes that could not reach the client because it had gone. + discarded: u64, +} + +impl Connection { + fn new(client: UnixStream, provider: UnixStream) -> std::io::Result { + client.set_nonblocking(true)?; + provider.set_nonblocking(true)?; + Ok(Self { + client, + provider, + to_provider: Pending::default(), + to_client: Pending::default(), + client_ended: false, + client_gone: false, + client_closed_write: false, + provider_ended: false, + provider_gone: false, + provider_closed_write: false, + discarded: 0, + }) + } + + fn done(&self) -> bool { + self.provider_ended + && (self.to_provider.is_empty() || self.provider_gone) + && (self.to_client.is_empty() || self.client_gone) + } + + fn run(&mut self, shared: &Shared) { + let forced = self.relay(shared); + let discarded = self.discarded; + shared.update(|report| { + report.discarded_bytes += discarded; + report.forced += u32::from(forced); + }); + } + + /// Relay until the connection is done; `true` when `finish` closed it first. + fn relay(&mut self, shared: &Shared) -> bool { + let mut buffer = vec![0_u8; CHUNK]; + while !self.done() { + if shared.force.load(Ordering::Acquire) { + return true; + } + let client_events = self.client_events(); + let provider_events = self.provider_events(); + let mut descriptors = [ + libc::pollfd { + // poll ignores a negative descriptor, so an idle side cannot wake it. + fd: if client_events == 0 { + -1 + } else { + self.client.as_raw_fd() + }, + events: client_events, + revents: 0, + }, + libc::pollfd { + fd: if provider_events == 0 { + -1 + } else { + self.provider.as_raw_fd() + }, + events: provider_events, + revents: 0, + }, + ]; + // SAFETY: `descriptors` is live, writable storage for two entries, and both + // non-negative descriptors are owned by this connection for the call. + let ready = unsafe { + libc::poll( + descriptors.as_mut_ptr(), + descriptors.len() as libc::nfds_t, + TICK.as_millis() as libc::c_int, + ) + }; + if ready <= 0 { + continue; + } + let woke = |entry: &libc::pollfd, wanted: libc::c_short| { + entry.revents & (wanted | libc::POLLHUP | libc::POLLERR) != 0 + }; + if woke(&descriptors[1], libc::POLLIN) && !self.provider_ended { + self.read_provider(&mut buffer); + } + if woke(&descriptors[0], libc::POLLIN) && !self.client_ended { + self.read_client(&mut buffer); + } + if woke(&descriptors[1], libc::POLLOUT) + && !self.provider_gone + && self.to_provider.flush(&mut self.provider).is_err() + { + self.provider_gone = true; + self.to_provider.clear(); + } + if woke(&descriptors[0], libc::POLLOUT) && !self.client_gone { + self.flush_client(); + } + self.forward_ends(); + } + false + } + + fn client_events(&self) -> libc::c_short { + let mut events = 0; + if !self.client_ended && self.to_provider.len() < TO_PROVIDER_LIMIT { + events |= libc::POLLIN; + } + if !self.client_gone && !self.to_client.is_empty() { + events |= libc::POLLOUT; + } + events + } + + fn provider_events(&self) -> libc::c_short { + let mut events = 0; + // Provider bytes are read whenever they can be kept or discarded: this is what + // keeps the provider's sends from failing or queueing. + if !self.provider_ended && (self.client_gone || self.to_client.len() < TO_CLIENT_LIMIT) { + events |= libc::POLLIN; + } + if !self.provider_gone && !self.to_provider.is_empty() { + events |= libc::POLLOUT; + } + events + } + + fn read_provider(&mut self, buffer: &mut [u8]) { + match read_some(&mut self.provider, buffer) { + Received::Data(count) if self.client_gone => self.discarded += count as u64, + Received::Data(count) => { + self.to_client.push(&buffer[..count]); + self.flush_client(); + } + Received::End => self.provider_ended = true, + Received::Again => {} + } + } + + fn read_client(&mut self, buffer: &mut [u8]) { + match read_some(&mut self.client, buffer) { + Received::Data(count) => { + self.to_provider.push(&buffer[..count]); + if !self.provider_gone && self.to_provider.flush(&mut self.provider).is_err() { + self.provider_gone = true; + self.to_provider.clear(); + } + } + Received::End => self.client_ended = true, + Received::Again => {} + } + } + + fn flush_client(&mut self) { + if self.to_client.flush(&mut self.client).is_err() { + self.client_gone = true; + self.client_ended = true; + self.discarded += self.to_client.len() as u64; + self.to_client.clear(); + } + } + + /// Forward each side's end of stream as a half-close once its bytes were delivered. + fn forward_ends(&mut self) { + if self.client_ended + && !self.provider_closed_write + && (self.to_provider.is_empty() || self.provider_gone) + { + self.provider_closed_write = true; + let _ = self.provider.shutdown(std::net::Shutdown::Write); + } + if self.provider_ended + && !self.client_closed_write + && !self.client_gone + && self.to_client.is_empty() + { + self.client_closed_write = true; + let _ = self.client.shutdown(std::net::Shutdown::Write); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs new file mode 100644 index 000000000..4fa1681d3 --- /dev/null +++ b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs @@ -0,0 +1,154 @@ +//! The relay against a fake provider socket. The fake provider plays the provider's side +//! of the hazard: it keeps sending after the client left and checks that the relay +//! neither fails those sends nor closes first. +use super::*; +use std::{net::Shutdown, os::unix::fs::FileTypeExt}; + +/// A fake provider socket in its own short private directory. +struct Provider { + directory: PathBuf, + listener: UnixListener, +} +impl Provider { + fn new() -> Self { + let directory = private_directory().unwrap(); + let listener = UnixListener::bind(directory.join("provider.sock")).unwrap(); + Self { + directory, + listener, + } + } + fn path(&self) -> PathBuf { + self.directory.join("provider.sock") + } + fn accept(&self) -> UnixStream { + let (stream, _) = self.listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + stream + } +} +impl Drop for Provider { + fn drop(&mut self) { + let _ = fs::remove_file(self.path()); + let _ = fs::remove_dir(&self.directory); + } +} + +fn read_to_end(stream: &mut UnixStream) -> Vec { + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).unwrap(); + bytes +} + +#[test] +fn bytes_pass_both_ways_and_each_end_arrives_as_a_half_close() { + let provider = Provider::new(); + let relay = ClientRelay::start(&provider.path()).unwrap(); + let mut client = UnixStream::connect(relay.socket()).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut upstream = provider.accept(); + client.write_all(b"GET /_ping HTTP/1.1\r\n\r\n").unwrap(); + client.shutdown(Shutdown::Write).unwrap(); + // The client's end of stream reaches the provider after its bytes, as a half-close. + assert_eq!(read_to_end(&mut upstream), b"GET /_ping HTTP/1.1\r\n\r\n"); + // The provider can still answer on the half-closed connection. + upstream.write_all(b"HTTP/1.1 200 OK\r\n\r\nOK").unwrap(); + upstream.shutdown(Shutdown::Write).unwrap(); + assert_eq!(read_to_end(&mut client), b"HTTP/1.1 200 OK\r\n\r\nOK"); + let report = relay.finish(FINISH_GRACE); + assert_eq!( + report, + RelayReport { + connections: 1, + ..RelayReport::default() + } + ); +} + +#[test] +fn provider_bytes_after_the_client_left_are_drained_and_the_provider_ends_the_connection() { + let provider = Provider::new(); + let relay = ClientRelay::start(&provider.path()).unwrap(); + let mut client = UnixStream::connect(relay.socket()).unwrap(); + let mut upstream = provider.accept(); + client.write_all(b"request").unwrap(); + // The client leaves before reading any response, as the build client does. + drop(client); + assert_eq!(read_to_end(&mut upstream), b"request"); + // Every later provider send succeeds: the relay reads and discards it. + upstream + .set_write_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let chunk = vec![7_u8; 256 * 1024]; + for _ in 0..16 { + upstream.write_all(&chunk).unwrap(); + } + // Later still, the relay has not closed the provider side: sends keep succeeding. + std::thread::sleep(Duration::from_millis(200)); + upstream.write_all(&chunk).unwrap(); + // Only after the provider ends its side does the relay close. + upstream.shutdown(Shutdown::Write).unwrap(); + assert!(read_to_end(&mut upstream).is_empty()); + let report = relay.finish(FINISH_GRACE); + assert_eq!(report.discarded_bytes, 17 * 256 * 1024); + assert_eq!(report.forced, 0); +} + +#[test] +fn finish_closes_connections_the_provider_never_ends_after_its_bound() { + let provider = Provider::new(); + let relay = ClientRelay::start(&provider.path()).unwrap(); + let client = UnixStream::connect(relay.socket()).unwrap(); + let mut upstream = provider.accept(); + drop(client); + assert!(read_to_end(&mut upstream).is_empty()); + let started = Instant::now(); + let report = relay.finish(Duration::from_millis(300)); + assert!(started.elapsed() < Duration::from_secs(5)); + assert_eq!(report.forced, 1); + // The forced close reaches the provider. + assert!(read_to_end(&mut upstream).is_empty()); +} + +#[test] +fn a_refused_provider_connection_ends_only_that_client_connection() { + let provider = Provider::new(); + let path = provider.path(); + drop(provider); + let relay = ClientRelay::start(&path).unwrap(); + let mut client = UnixStream::connect(relay.socket()).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut bytes = Vec::new(); + // The client sees the connection end, as with an absent engine socket. + let _ = client.read_to_end(&mut bytes); + assert!(bytes.is_empty()); + let report = relay.finish(FINISH_GRACE); + assert_eq!((report.connections, report.refused), (1, 1)); +} + +#[test] +fn the_listening_socket_is_short_private_and_removed_when_the_relay_finishes() { + let relay = ClientRelay::start(Path::new("/nonexistent/docker.sock")).unwrap(); + let socket = relay.socket().to_path_buf(); + // sockaddr_un holds 104 bytes on macOS, including the terminator. + assert!(socket.as_os_str().len() < 104); + let directory = socket.parent().unwrap().to_path_buf(); + let metadata = fs::symlink_metadata(&directory).unwrap(); + assert_eq!(metadata.mode() & 0o777, 0o700); + // SAFETY: geteuid has no preconditions. + assert_eq!(metadata.uid(), unsafe { libc::geteuid() }); + assert!( + fs::symlink_metadata(&socket) + .unwrap() + .file_type() + .is_socket() + ); + assert_eq!(relay.finish(FINISH_GRACE), RelayReport::default()); + assert!(!directory.exists()); +} From ffe2b3fd5fa1bb41975abed77bfa204e89a85e92 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:26:15 -0500 Subject: [PATCH 110/138] fix(native): stop a VM whose guest agent stopped responding `runtime down` failed with agent_protocol_uncertain and `runtime recover` refused while the provider process was alive, so a guest whose agent stopped answering could only be stopped by signalling the provider by hand. When a guest step of `down` fails that way, or `recover` finds the recorded provider alive, both now ping the agent three times over about 13 seconds. If no ping starts a reply, they stop this home's recorded provider after verifying its PID, start time, user and executable, with no guest quiescence, and record the pool `recovered-unclean`, which the next start accepts after its usual disk checks. A guest that answers keeps the clean stop, and `recover` still refuses it. Refs HACK-1924 Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/README.md | 4 + packages/runtime-core/src/provider/agent.rs | 53 ++++++ .../runtime-core/src/provider/lifecycle.rs | 172 ++++++++++++++++-- .../src/provider/lifecycle/interrupted.rs | 5 +- 4 files changed, 214 insertions(+), 20 deletions(-) diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index ba644c1a6..89a35ec40 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -505,6 +505,10 @@ tests; Cargo reporting a cached binary as fresh does not qualify that identity. native process/disk identity, guest protocol, and lifecycle decisions. Guest requests use a bounded connection to the existing socket; they cannot silently start or recover a VM. Failed operations retain their phase and receipts. Recovery preserves disks and labels an unclean exit explicitly. +When the guest agent stops responding (three pings over about 13 seconds start no reply), +`runtime down` and `runtime recover` stop this home's recorded provider after verifying its PID, +start time, user and executable, without guest quiescence, and record `recovered-unclean`; a +guest that answers keeps the normal clean stop, and `recover` still refuses it. SmolVM seeds a machine's disks during its first start (`machine create` makes none) from expanded `*-template.ext4` files that it creates from the verified compressed templates and then reuses. diff --git a/packages/runtime-core/src/provider/agent.rs b/packages/runtime-core/src/provider/agent.rs index 1aed6bdba..2a7e151db 100644 --- a/packages/runtime-core/src/provider/agent.rs +++ b/packages/runtime-core/src/provider/agent.rs @@ -112,6 +112,59 @@ fn exchange( serde_json::from_slice(&body).map_err(|_| failure("Malformed guest response.")) } +/// Whether the agent at `path` starts any reply to a ping within `timeout`. No +/// connection, a connection that ends before a reply, and no reply by the deadline are +/// silence; any reply byte, even of an unexpected response, is an answer. Only a stop of +/// a guest that stopped responding relies on this. +pub(super) fn answers_ping(path: &Path, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let Ok(bytes) = encode(&json!({"method":"ping"})) else { + return false; + }; + let Ok(mut stream) = UnixStream::connect(path) else { + return false; + }; + let mut frame = (bytes.len() as u32).to_be_bytes().to_vec(); + frame.extend_from_slice(&bytes); + if stream.set_nonblocking(true).is_err() { + return false; + } + let mut written = 0; + let mut reply = [0_u8; 1]; + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + if written < frame.len() { + match stream.write(&frame[written..]) { + Ok(count) => written += count, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return false, + } + } + match stream.read(&mut reply) { + Ok(0) => return false, + Ok(_) => return true, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return false, + } + let mut descriptor = libc::pollfd { + fd: stream.as_raw_fd(), + events: if written < frame.len() { + libc::POLLIN | libc::POLLOUT + } else { + libc::POLLIN + }, + revents: 0, + }; + let milliseconds = (remaining.as_millis() + 1).min(i32::MAX as u128) as i32; + // SAFETY: the descriptor is borrowed from the live stream for this synchronous wait. + unsafe { libc::poll(&mut descriptor, 1, milliseconds) }; + } +} + pub fn ping(path: &Path) -> Result<(), CandidateError> { let response = request(path, json!({"method":"ping"}), Duration::from_secs(3))?; if response["status"] != "pong" || response["version"] != 1 { diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 2b0f272d7..dc2e1db61 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -1384,7 +1384,7 @@ fn start_pool( // Disk rejection must not keep an allocation alive. Process authority is // independent of disk adoption; this is an unclean stop, never reconciliation. let process = owner.process.as_ref().expect("process just recorded"); - let cleanup = stop_failed_boot(process, &binary(candidate)); + let cleanup = stop_verified_provider(process, &binary(candidate)); state::write( &root(candidate).join("failed-boot.json"), &json!({ @@ -1429,7 +1429,8 @@ fn start_machine( .map(drop) } -fn stop_failed_boot( +/// SIGTERM a provider whose identity is verified at the signal, then wait for it to exit. +fn stop_verified_provider( process: &identity::ProcessIdentity, binary: &Path, ) -> Result<(), CandidateError> { @@ -1442,7 +1443,7 @@ fn stop_failed_boot( if Instant::now() >= deadline { return Err(CandidateError::new( "stop_uncertain", - "Verified failed-boot provider did not stop after SIGTERM.", + "Verified provider did not stop after SIGTERM.", )); } std::thread::sleep(Duration::from_millis(50)); @@ -1851,6 +1852,68 @@ pub fn down(candidate: &Candidate) -> Result { return status(candidate); } verify_live(candidate, &owner)?; + match guest_stop(candidate, &mut owner) { + Ok(()) => status(candidate), + Err(error) + if error.code == "agent_protocol_uncertain" && agent_silent(candidate, &owner)? => + { + stop_silent_guest(candidate, &mut owner)?; + status(candidate) + } + Err(error) => Err(error), + } +} + +/// How many pings, a few seconds apart, a guest agent must leave unanswered before +/// `down` or `recover` treats it as stopped responding. +const SILENT_PINGS: u32 = 3; +#[cfg(not(test))] +const SILENT_PING_TIMEOUT: Duration = Duration::from_secs(3); +#[cfg(not(test))] +const SILENT_PING_INTERVAL: Duration = Duration::from_secs(2); +#[cfg(test)] +const SILENT_PING_TIMEOUT: Duration = Duration::from_millis(200); +#[cfg(test)] +const SILENT_PING_INTERVAL: Duration = Duration::from_millis(50); + +/// `true` when the owned guest agent answered none of [`SILENT_PINGS`] pings. +fn agent_silent(candidate: &Candidate, owner: &Owner) -> Result { + Ok(agent_silent_at(&socket(candidate, owner, "agent.sock")?)) +} + +/// Any reply, even an unexpected one, means the guest is alive and keeps the normal stop +/// path. +fn agent_silent_at(path: &Path) -> bool { + for attempt in 0..SILENT_PINGS { + if attempt > 0 { + std::thread::sleep(SILENT_PING_INTERVAL); + } + if agent::answers_ping(path, SILENT_PING_TIMEOUT) { + return false; + } + } + true +} + +/// Stop the verified provider of a guest whose agent stopped responding. Nothing in the +/// guest is quiesced or flushed, so the pool is recorded `recovered-unclean`, as after a +/// provider exit; the next start checks its retained disks as usual. The signal reaches +/// only this home's recorded provider, rechecked by PID, start time, user and executable +/// immediately before it is sent. A provider that already exited is only recorded. +fn stop_silent_guest(candidate: &Candidate, owner: &mut Owner) -> Result<(), CandidateError> { + let process = owner.process.clone().ok_or_else(|| { + CandidateError::new("process_identity_unavailable", "No recorded provider.") + })?; + if identity::alive(process.pid)? { + verify_live(candidate, owner)?; + stop_verified_provider(&process, &binary(candidate))?; + } + finish_stopped(candidate, owner, "recovered-unclean") +} + +/// `down`'s guest-facing part: quiesce the engine, ask the guest to flush and shut down, +/// stop the provider and record the stop. +fn guest_stop(candidate: &Candidate, owner: &mut Owner) -> Result<(), CandidateError> { let before_engine = engine_was_not_started( &owner.phase, owner.guest_boot_id.as_deref(), @@ -1862,7 +1925,7 @@ pub fn down(candidate: &Candidate) -> Result { if failed_engine { guest( candidate, - &owner, + owner, r#"set -eu test "$(cat /storage/.hack-local-owner)" = "$1" test "$(cat /proc/sys/kernel/random/boot_id)" = "$2" @@ -1895,10 +1958,10 @@ sync"#, } else { "initial" }; - phase(candidate, &mut owner, "quiescing")?; + phase(candidate, owner, "quiescing")?; let quiescence = guest( candidate, - &owner, + owner, include_str!("guest-quiesce.sh"), &[ &owner.token, @@ -1917,9 +1980,9 @@ sync"#, }), )?; } - phase(candidate, &mut owner, "stopping")?; + phase(candidate, owner, "stopping")?; let response = agent::request( - &socket(candidate, &owner, "agent.sock")?, + &socket(candidate, owner, "agent.sock")?, json!({"method":"shutdown"}), Duration::from_secs(15), )?; @@ -1929,7 +1992,7 @@ sync"#, "Guest did not acknowledge flush/unmount.", )); } - verify_live(candidate, &owner)?; + verify_live(candidate, owner)?; let process = owner.process.as_ref().expect("live identity verified"); // Revalidate immediately before signalling. macOS has no Linux pidfd equivalent; // this is cooperative same-user ownership, not hostile-process containment. @@ -1945,13 +2008,13 @@ sync"#, )); } if before_engine { - finish_stopped(candidate, &mut owner, "stopped-before-engine")?; + finish_stopped(candidate, owner, "stopped-before-engine")?; } else if failed_engine { - finish_stopped(candidate, &mut owner, "stopped-after-engine-failure")?; + finish_stopped(candidate, owner, "stopped-after-engine-failure")?; } else { - finish_quiesced_stop(candidate, &mut owner)?; + finish_quiesced_stop(candidate, owner)?; } - status(candidate) + Ok(()) } /// `down`'s completion after the guest acknowledged flush and shutdown of a quiesced engine @@ -2151,10 +2214,18 @@ pub fn recover(candidate: &Candidate) -> Result { return status(candidate); }; if identity::alive(process.pid)? { - return Err(CandidateError::new( - "recovery_required", - "Recovery only accepts a confirmed dead recorded process. Use down for a verified live guest.", - )); + // A live provider is stopped here only when its identity verifies and its guest + // agent stopped responding; a responsive guest is stopped cleanly by `down`. + verify_live(candidate, &owner)?; + if !agent_silent(candidate, &owner)? { + return Err(CandidateError::new( + "recovery_required", + "Recovery only accepts a confirmed dead recorded process or a guest that stopped responding. Use down for a responsive guest.", + )); + } + // The operation lease taken above stays held through the stop and its receipt. + stop_silent_guest(candidate, &mut owner)?; + return status(candidate); } finish_stopped(candidate, &mut owner, "recovered-unclean")?; status(candidate) @@ -2721,6 +2792,71 @@ printf 'verified-cache\n' ); } + /// A short private directory for a fake agent socket (`sockaddr_un` is small). + struct ShortDirectory(std::path::PathBuf); + impl ShortDirectory { + fn new() -> Self { + let mut bytes = [0_u8; 6]; + File::open("/dev/urandom") + .unwrap() + .read_exact(&mut bytes) + .unwrap(); + let name: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let path = Path::new("/tmp").join(format!("hka-{name}")); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for ShortDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn only_a_guest_agent_that_never_starts_a_reply_counts_as_silent() { + use std::os::unix::net::UnixListener; + let directory = ShortDirectory::new(); + let path = directory.0.join("agent.sock"); + // No listener: refused connections are silence. + assert!(agent_silent_at(&path)); + let listener = UnixListener::bind(&path).unwrap(); + // Accepted but never answered, as when the guest's vsock path is saturated. + let held = std::thread::spawn(move || { + // Keep every connection open and unanswered until the probe is over. + let kept: Vec<_> = listener + .incoming() + .take(SILENT_PINGS as usize) + .map(Result::unwrap) + .collect(); + kept.len() + }); + let started = Instant::now(); + assert!(agent_silent_at(&path)); + // Each probe waits out its deadline (the last sees the fake end the connection). + assert!(started.elapsed() >= SILENT_PING_TIMEOUT * (SILENT_PINGS - 1)); + assert_eq!(held.join().unwrap(), SILENT_PINGS as usize); + fs::remove_file(&path).unwrap(); + // Any reply byte, even of an unexpected response, is an answer. + let listener = UnixListener::bind(&path).unwrap(); + let answering = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.write_all(b"?").unwrap(); + }); + assert!(!agent_silent_at(&path)); + answering.join().unwrap(); + fs::remove_file(&path).unwrap(); + // A connection the guest side ends before replying is silence. + let listener = UnixListener::bind(&path).unwrap(); + let closing = std::thread::spawn(move || { + for stream in listener.incoming().take(SILENT_PINGS as usize) { + drop(stream.unwrap()); + } + }); + assert!(agent_silent_at(&path)); + closing.join().unwrap(); + } + #[cfg(target_os = "macos")] #[test] fn failed_boot_cleanup_stops_only_the_verified_process() { @@ -2734,7 +2870,7 @@ printf 'verified-cache\n' .unwrap(); let observed = identity::observe(child.id() as i32).unwrap(); let reaper = std::thread::spawn(move || child.wait().unwrap()); - stop_failed_boot(&observed, &observed.executable).unwrap(); + stop_verified_provider(&observed, &observed.executable).unwrap(); reaper.join().unwrap(); assert!(!identity::alive(observed.pid).unwrap()); assert!(sentinel.try_wait().unwrap().is_none()); diff --git a/packages/runtime-core/src/provider/lifecycle/interrupted.rs b/packages/runtime-core/src/provider/lifecycle/interrupted.rs index c3bf4f4e4..84676a909 100644 --- a/packages/runtime-core/src/provider/lifecycle/interrupted.rs +++ b/packages/runtime-core/src/provider/lifecycle/interrupted.rs @@ -25,7 +25,8 @@ //! Nothing unowned is adopted, stopped or removed. use super::super::{config_audit, disk_template::Stage, identity, state}; use super::{ - Owner, binary, finish_absent, invoke, recorded_process, stop_failed_boot, verify_templates, + Owner, binary, finish_absent, invoke, recorded_process, stop_verified_provider, + verify_templates, }; use crate::{Candidate, CandidateError}; use std::fs; @@ -135,7 +136,7 @@ fn recover_start( &binary(candidate), unsafe { libc::geteuid() }, )?; - stop_failed_boot(&process, &binary(candidate))?; + stop_verified_provider(&process, &binary(candidate))?; } // The record may be stale (a previous boot's) while an orphaned start is still running. provider_idle(candidate)?; From 5643b8c9c34ebc7d47971478185183a7761341e6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:16:16 -0500 Subject: [PATCH 111/138] fix(native): remove build relay sockets a killed process left behind A build whose process is killed cannot remove its private relay socket directory. Relay directories now carry the creating PID, and each new relay removes those whose process no longer exists, but only a mode-0700 directory owned by this user that holds nothing except the relay socket. Refs HACK-1924 Co-Authored-By: Claude Opus 5.5 --- .../src/provider/image_build/client_relay.rs | 61 +++++++++++++++++-- .../image_build/client_relay/tests.rs | 36 +++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/runtime-core/src/provider/image_build/client_relay.rs b/packages/runtime-core/src/provider/image_build/client_relay.rs index c536af042..05cbdbfca 100644 --- a/packages/runtime-core/src/provider/image_build/client_relay.rs +++ b/packages/runtime-core/src/provider/image_build/client_relay.rs @@ -188,20 +188,28 @@ fn failure(message: &str) -> CandidateError { CandidateError::new("native_build_client", message) } -/// A new mode-0700 directory with a short path, owned by this user. -fn private_directory() -> Result { - let parent = if cfg!(target_os = "macos") { +const DIRECTORY_PREFIX: &str = "hkb-"; + +fn temporary_root() -> &'static Path { + if cfg!(target_os = "macos") { Path::new("/private/tmp") } else { Path::new("/tmp") - }; + } +} + +/// A new mode-0700 directory with a short path, owned by this user. Its name carries +/// this process's PID, so a later relay can remove what a killed process left behind. +fn private_directory() -> Result { + let parent = temporary_root(); + remove_abandoned(parent); for _ in 0..8 { let mut bytes = [0_u8; 6]; File::open("/dev/urandom") .and_then(|mut random| random.read_exact(&mut bytes)) .map_err(|_| failure("Cannot choose a private build client socket."))?; let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); - let directory = parent.join(format!("hkb-{name}")); + let directory = parent.join(format!("{DIRECTORY_PREFIX}{}-{name}", std::process::id())); match fs::DirBuilder::new().mode(0o700).create(&directory) { Ok(()) => {} Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, @@ -223,6 +231,49 @@ fn private_directory() -> Result { Err(failure("Cannot create a private build client socket.")) } +/// Remove relay directories whose creating process is gone (it was killed before it +/// could clean up). Only a mode-0700 directory owned by this user, named for a PID that +/// no longer exists and holding nothing but the relay socket, is removed; anything else +/// is left alone. A reused PID only keeps a stale directory longer. +fn remove_abandoned(parent: &Path) { + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + // SAFETY: geteuid has no preconditions. + let user = unsafe { libc::geteuid() }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(pid) = name + .to_str() + .and_then(|name| name.strip_prefix(DIRECTORY_PREFIX)) + .and_then(|rest| rest.split_once('-')) + .and_then(|(pid, _)| pid.parse::().ok()) + .filter(|pid| *pid > 0) + else { + continue; + }; + // SAFETY: signal 0 only checks for existence; no signal is delivered. + let gone = unsafe { libc::kill(pid, 0) } != 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); + let directory = entry.path(); + let private = fs::symlink_metadata(&directory).is_ok_and(|metadata| { + metadata.is_dir() && metadata.uid() == user && metadata.mode() & 0o777 == 0o700 + }); + if !gone || !private { + continue; + } + let socket = directory.join(SOCKET_NAME); + if fs::symlink_metadata(&socket).is_ok_and(|metadata| { + use std::os::unix::fs::FileTypeExt; + metadata.file_type().is_socket() && metadata.uid() == user + }) { + let _ = fs::remove_file(&socket); + } + // Fails, and keeps the directory, unless it is now empty. + let _ = fs::remove_dir(&directory); + } +} + fn accept(listener: &UnixListener, provider: &Path, shared: &Arc) { while !shared.stop.load(Ordering::Acquire) { match listener.accept() { diff --git a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs index 4fa1681d3..e04c1bb16 100644 --- a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs +++ b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs @@ -152,3 +152,39 @@ fn the_listening_socket_is_short_private_and_removed_when_the_relay_finishes() { assert_eq!(relay.finish(FINISH_GRACE), RelayReport::default()); assert!(!directory.exists()); } + +#[test] +fn a_relay_left_by_a_killed_process_is_removed_and_a_live_one_is_kept() { + // A process that has exited: its PID names the abandoned directory. + let mut exited = std::process::Command::new("/usr/bin/true").spawn().unwrap(); + let dead = exited.id(); + exited.wait().unwrap(); + let mut running = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .unwrap(); + let make = |pid: u32| { + let directory = temporary_root().join(format!("{DIRECTORY_PREFIX}{pid}-0123456789ab")); + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .unwrap(); + drop(UnixListener::bind(directory.join(SOCKET_NAME)).unwrap()); + directory + }; + let abandoned = make(dead); + let live = make(running.id()); + // A directory holding anything besides the socket is never removed. + let kept = make(dead + 1_000_000); + fs::write(kept.join("other"), "x").unwrap(); + let relay = ClientRelay::start(Path::new("/nonexistent/docker.sock")).unwrap(); + assert!(!abandoned.exists()); + assert!(live.join(SOCKET_NAME).exists()); + assert!(kept.join("other").exists()); + relay.finish(FINISH_GRACE); + running.kill().unwrap(); + running.wait().unwrap(); + for directory in [live, kept] { + let _ = fs::remove_dir_all(directory); + } +} From 66d25b8381e1f18cfe54d115a56615b06820effe Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:19:01 -0500 Subject: [PATCH 112/138] fix(native): bound build relay buffers by pending bytes Under sustained backpressure, partial writes advanced the start offset but the delivered prefix stayed in the buffer until the queue drained completely, so the limits bounded only the unread suffix while storage grew with the whole transfer (128 MiB for a 1 MiB limit in the new test). The delivered prefix is now dropped once it reaches half the buffer, keeping storage within about twice the pending bytes plus one read at linear cost. Refs HACK-1924 Co-Authored-By: Claude Opus 5.5 --- .../src/provider/image_build/client_relay.rs | 15 +++-- .../image_build/client_relay/tests.rs | 58 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/provider/image_build/client_relay.rs b/packages/runtime-core/src/provider/image_build/client_relay.rs index 05cbdbfca..564f38761 100644 --- a/packages/runtime-core/src/provider/image_build/client_relay.rs +++ b/packages/runtime-core/src/provider/image_build/client_relay.rs @@ -322,7 +322,9 @@ fn connect(provider: &Path) -> std::io::Result { Ok(stream) } -/// Pending bytes for one direction. +/// Pending bytes for one direction. Storage follows the bytes still pending, not the +/// traffic: the delivered prefix is dropped before it outgrows what remains, so the +/// buffer stays within about twice the pending bytes plus one read. #[derive(Default)] struct Pending { bytes: Vec, @@ -337,8 +339,10 @@ impl Pending { self.len() == 0 } fn push(&mut self, bytes: &[u8]) { - if self.start == self.bytes.len() { - self.bytes.clear(); + // Dropping the delivered prefix once it is at least half the buffer moves each + // byte at most once more, so the cost stays linear in the traffic. + if self.start > 0 && self.start >= self.bytes.len() / 2 { + self.bytes.drain(..self.start); self.start = 0; } self.bytes.extend_from_slice(bytes); @@ -348,7 +352,7 @@ impl Pending { self.start = 0; } /// Write what `stream` accepts now. `Err` means the peer can take no more. - fn flush(&mut self, stream: &mut UnixStream) -> Result<(), ()> { + fn flush(&mut self, stream: &mut impl Write) -> Result<(), ()> { while !self.is_empty() { match stream.write(&self.bytes[self.start..]) { Ok(0) => return Err(()), @@ -358,6 +362,9 @@ impl Pending { Err(_) => return Err(()), } } + if self.start == self.bytes.len() { + self.clear(); + } Ok(()) } } diff --git a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs index e04c1bb16..2cc86a6fa 100644 --- a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs +++ b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs @@ -188,3 +188,61 @@ fn a_relay_left_by_a_killed_process_is_removed_and_a_live_one_is_kept() { let _ = fs::remove_dir_all(directory); } } + +/// A peer that takes a little at a time and then reports a full buffer, checking that the +/// bytes arrive in order. +struct SlowPeer { + received: u64, + calls: u64, +} +impl Write for SlowPeer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.calls += 1; + if self.calls % 3 == 0 { + return Err(ErrorKind::WouldBlock.into()); + } + let count = bytes.len().min(1500); + for byte in &bytes[..count] { + assert_eq!(*byte, (self.received % 251) as u8, "bytes out of order"); + self.received += 1; + } + Ok(count) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[test] +fn pending_storage_follows_the_queued_bytes_not_the_whole_transfer() { + // Sustained backpressure: the queue is topped up to its limit while a slow peer + // takes partial writes, for 128 times the limit in total. + let total = 128 * TO_PROVIDER_LIMIT as u64; + let mut pending = Pending::default(); + let mut peer = SlowPeer { + received: 0, + calls: 0, + }; + let mut sent = 0_u64; + let mut largest = 0; + let mut chunk = vec![0_u8; CHUNK]; + while peer.received < total { + if pending.len() < TO_PROVIDER_LIMIT && sent < total { + let count = CHUNK.min((total - sent) as usize); + for (offset, byte) in chunk[..count].iter_mut().enumerate() { + *byte = ((sent + offset as u64) % 251) as u8; + } + pending.push(&chunk[..count]); + sent += count as u64; + } + pending.flush(&mut peer).unwrap(); + largest = largest.max(pending.bytes.capacity()); + } + assert!(pending.is_empty()); + assert_eq!(peer.received, total); + // Delivered bytes are dropped: storage stays within a small multiple of the limit. + assert!( + largest <= 4 * (TO_PROVIDER_LIMIT + CHUNK), + "buffer grew to {largest} bytes for a {TO_PROVIDER_LIMIT}-byte limit" + ); +} From a86b3cf718420ef3c7f8480b290cc8f59db8cd7e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:33:39 -0500 Subject: [PATCH 113/138] fix(native): refuse a silent-guest stop while a build is admitted A build releases the operation lease while its client runs and keeps only the shared build fence, so the lease alone did not prove that no build client was using the engine. `runtime recover` could stop the provider of a silent guest underneath a live build client. Both silent-guest paths now check the fence under the operation lease (`recover` before probing, and both again just before signalling) and refuse with provider_busy, telling the user to finish or cancel the build first; the next attempt then stops the silent guest. The fence is a flock released when the build's processes exit, including on SIGKILL, as a_killed_coordinator_leaves_its_client_fenced already proves. Also writes the abandoned-relay fixture's extra file before its socket, so a relay starting in a concurrent test cannot remove it first. Refs HACK-1924 Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/README.md | 4 +- .../runtime-core/src/provider/image_build.rs | 13 ++++ .../image_build/client_relay/tests.rs | 14 ++-- .../runtime-core/src/provider/lifecycle.rs | 65 +++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 89a35ec40..1af11cad9 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -508,7 +508,9 @@ retain their phase and receipts. Recovery preserves disks and labels an unclean When the guest agent stops responding (three pings over about 13 seconds start no reply), `runtime down` and `runtime recover` stop this home's recorded provider after verifying its PID, start time, user and executable, without guest quiescence, and record `recovered-unclean`; a -guest that answers keeps the normal clean stop, and `recover` still refuses it. +guest that answers keeps the normal clean stop, and `recover` still refuses it. Both refuse with +`provider_busy` while a native image build holds its fence; finishing or canceling that build +(Ctrl-C its `hack up`) releases the fence, also when a build process was killed. SmolVM seeds a machine's disks during its first start (`machine create` makes none) from expanded `*-template.ext4` files that it creates from the verified compressed templates and then reuses. diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index 0db8ea8e3..2cc9c1ece 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -987,6 +987,19 @@ pub(super) fn run( }) } +/// Hold the shared build fence as an admitted build does, for lifecycle tests. +#[cfg(test)] +pub(super) fn hold_build_fence_for_test(candidate: &Candidate) -> impl Drop { + let root = builds_root(candidate); + state::private_directory(&root).unwrap(); + Flock::try_lock( + Flock::open(&root.join("active.lock")).unwrap(), + libc::LOCK_SH, + ) + .unwrap() + .unwrap() +} + /// The image config digest the client reports for the built image. fn built_image_id(metadata: &Path) -> Result { let bytes = read_bounded_file(metadata, MAX_METADATA_BYTES)?.ok_or_else(|| { diff --git a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs index 2cc86a6fa..79edcaa26 100644 --- a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs +++ b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs @@ -163,20 +163,24 @@ fn a_relay_left_by_a_killed_process_is_removed_and_a_live_one_is_kept() { .arg("30") .spawn() .unwrap(); - let make = |pid: u32| { + // `other` is written before the socket, so a relay starting in a concurrent test never + // sees that directory holding only a socket. + let make = |pid: u32, other: bool| { let directory = temporary_root().join(format!("{DIRECTORY_PREFIX}{pid}-0123456789ab")); fs::DirBuilder::new() .mode(0o700) .create(&directory) .unwrap(); + if other { + fs::write(directory.join("other"), "x").unwrap(); + } drop(UnixListener::bind(directory.join(SOCKET_NAME)).unwrap()); directory }; - let abandoned = make(dead); - let live = make(running.id()); + let abandoned = make(dead, false); + let live = make(running.id(), false); // A directory holding anything besides the socket is never removed. - let kept = make(dead + 1_000_000); - fs::write(kept.join("other"), "x").unwrap(); + let kept = make(dead + 1_000_000, true); let relay = ClientRelay::start(Path::new("/nonexistent/docker.sock")).unwrap(); assert!(!abandoned.exists()); assert!(live.join(SOCKET_NAME).exists()); diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index dc2e1db61..d48908d6b 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -1901,6 +1901,9 @@ fn agent_silent_at(path: &Path) -> bool { /// only this home's recorded provider, rechecked by PID, start time, user and executable /// immediately before it is sent. A provider that already exited is only recorded. fn stop_silent_guest(candidate: &Candidate, owner: &mut Owner) -> Result<(), CandidateError> { + // Checked again at the effect: the caller holds the operation lease, but a running + // build holds only its shared fence while its client runs. + require_no_build_for_silent_stop(candidate)?; let process = owner.process.clone().ok_or_else(|| { CandidateError::new("process_identity_unavailable", "No recorded provider.") })?; @@ -1911,6 +1914,25 @@ fn stop_silent_guest(candidate: &Candidate, owner: &mut Owner) -> Result<(), Can finish_stopped(candidate, owner, "recovered-unclean") } +/// Refuse to stop a silent guest while a native image build is admitted. A build releases +/// the operation lease while its client runs and keeps only the shared build fence +/// (`run/image-builds/active.lock`), so the lease alone does not prove the engine is idle. +/// The fence is a `flock`: it is released when the build's processes exit, including on +/// SIGKILL, so a refusal lasts only while a build process is alive. Callers hold the +/// operation lease, under which no new build can take the fence. +fn require_no_build_for_silent_stop(candidate: &Candidate) -> Result<(), CandidateError> { + super::image_build::require_no_active_build(candidate).map_err(|error| { + if error.code == "provider_busy" { + CandidateError::new( + "provider_busy", + "A native image build is running against this guest. Finish or cancel it first (Ctrl-C the `hack up` that started it); the next attempt then stops the guest that stopped responding.", + ) + } else { + error + } + }) +} + /// `down`'s guest-facing part: quiesce the engine, ask the guest to flush and shut down, /// stop the provider and record the stop. fn guest_stop(candidate: &Candidate, owner: &mut Owner) -> Result<(), CandidateError> { @@ -2216,6 +2238,8 @@ pub fn recover(candidate: &Candidate) -> Result { if identity::alive(process.pid)? { // A live provider is stopped here only when its identity verifies and its guest // agent stopped responding; a responsive guest is stopped cleanly by `down`. + // Refuse first, without probing, while a build is admitted. + require_no_build_for_silent_stop(candidate)?; verify_live(candidate, &owner)?; if !agent_silent(candidate, &owner)? { return Err(CandidateError::new( @@ -2857,6 +2881,47 @@ printf 'verified-cache\n' closing.join().unwrap(); } + #[cfg(target_os = "macos")] + #[test] + fn a_silent_guest_is_not_stopped_under_an_admitted_build() { + let root = crate::test_root::private_test_root( + &std::env::temp_dir().canonicalize().unwrap(), + "hack-silent-stop-build", + ); + let checkout = root.join("checkout"); + state::private_directory(&checkout).unwrap(); + let candidate = Candidate::discover(&checkout).unwrap(); + // A live process stands in for the recorded provider; it must not be signalled. + let mut provider = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .unwrap(); + let mut owner: Owner = serde_json::from_value(json!({ + "version":1,"checkout":"/fixture","token":"fixture","machine":"fixture", + "short_home":"/fixture","created":true,"phase":"running","process":null, + "storage":null,"overlay":null,"guest_boot_id":null, + "daemon_pid":null,"daemon_start":null,"rootfs_digest":null + })) + .unwrap(); + owner.process = Some(identity::observe(provider.id() as i32).unwrap()); + // An admitted build holds the shared fence, as `image_build::run` does. + let fence = super::super::image_build::hold_build_fence_for_test(&candidate); + let refused = stop_silent_guest(&candidate, &mut owner).unwrap_err(); + assert_eq!(refused.code, "provider_busy"); + assert!(refused.message.contains("Ctrl-C")); + assert!( + provider.try_wait().unwrap().is_none(), + "provider was signalled" + ); + assert_eq!(owner.phase, "running"); + drop(fence); + // Without a build, the check passes and the stop proceeds to identity checks. + assert!(require_no_build_for_silent_stop(&candidate).is_ok()); + provider.kill().unwrap(); + provider.wait().unwrap(); + let _ = fs::remove_dir_all(&root); + } + #[cfg(target_os = "macos")] #[test] fn failed_boot_cleanup_stops_only_the_verified_process() { From 009989c73c99d819af06f2debf058c0837b2c4cd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:44:38 -0500 Subject: [PATCH 114/138] fix(native): restore a sourceless graph after its project files were touched A native plan ID binds the project source inventory, which records the size and modification time of every selected project file. Editing the Compose file and putting the original bytes back, or touching any other project file, therefore changed the plan ID of an otherwise identical review. A stopped graph that mounts no project source has no shared-source contract to prove such a change against, so `up` refused every later restore with graph_source_compatibility. Build services made this common: the refused attempt after a Compose edit was not the cause, the edit itself was. When the review carries the retained graph's exact original and normalized Compose input, and neither the graph nor the reviewed plan reads project source (no source binding, bind mount, dependency cache or cache provenance), source compatibility, fresh restore selection and source replay now treat it as the retained review. Restore keeps the retained ownership plan and still requires identical resource bindings, images and readiness. Graphs that read source keep their shared-source proofs, and a changed Compose input is still refused here. `doctor --native-run-mapping inspect` refused every mapping that did not need a device rebind with "unsafe, changed, or owned by another run". A current mapping is now reported read-only with its run, the native graph phase and whether the graph's ownership matches; repair is unchanged. Refs HACK-1926 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 11 ++ docs/reference/cli.md | 2 +- .../runtime-core/src/provider/graph/mod.rs | 4 +- .../src/provider/graph/normalized.rs | 144 ++++++++++++++++++ .../src/provider/graph/restore.rs | 9 +- .../runtime-core/src/provider/graph/source.rs | 6 +- .../native-project-mapping-command.ts | 10 +- src/backends/native-project-run.ts | 93 +++++++++++ src/commands/doctor.ts | 2 +- tests/native-project-run-filesystem.test.ts | 119 +++++++++++++++ 10 files changed, 393 insertions(+), 7 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 68e82a2d4..71ad6b9e7 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -256,6 +256,12 @@ With it: - Restoring an unchanged retained run uses the retained images and never builds; if one is missing, `up` refuses and asks you to start fresh. `hack restart` also never builds: it reuses the running or retained image, or refuses before stopping anything. +- "Unchanged" means the same Compose bytes. A run that mounts no project source and + uses no dependency cache still restores after its Compose file was edited and put + back, or after any project file was touched; build-context edits do not rebuild it. + Its review then differs only in the project source inventory (file sizes and + modification times), which such a run never reads, and restore keeps the retained + ownership plan, images and data. - As with Compose `up`, only build services in the selected profiles are built and pinned. A build service in a profile you did not select is not built, and restore and restart need no image for it; selecting its profile in a later fresh `up` builds @@ -444,6 +450,11 @@ the mapping, inspect the exact instance with the current bundle: Omitting `--branch` uses the same linked-worktree default as project commands; detached linked worktrees require an explicit instance. Inspection writes nothing. +A mapping that still matches the checkout reports `state: "current"` with its run, +the native graph phase (or `null` while the pool is stopped), whether the graph's +run, owner, namespace and plan match the mapping, and `repairRequired: false`. +Only a mapping whose scope no longer matches goes through the legacy device-rebind +inspection below, which returns the `selectionSha256` that repair requires. Repair requires the exact selection and explicit acceptance of unproven original filesystem volume continuity. It changes only the three scope directory device numbers, preserving canonical paths, inodes, branch, run, owner, plan, environment diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 050e7c675..c466a7bdc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1429,7 +1429,7 @@ hack doctor [options] | `--browser-result unknown|works|fails|permission-denied` | Your manual browser observation for --browser-url (default: unknown) | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--native-cleanup inspect` | Inspect one mapped native graph's pending cleanup without recovery or stop replay | -| `--native-run-mapping inspect|repair` | Inspect or explicitly repair a native run mapping after filesystem device renumbering | +| `--native-run-mapping inspect|repair` | Inspect a native run mapping, or explicitly repair it after filesystem device renumbering | | `--expect-selection <64-hex>` | Require the exact run-mapping recovery inspection selection | | `--accept-legacy-device-rebind` | Explicitly accept legacy migration without proof of original filesystem volume continuity | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 790ee52cb..ed52656a9 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -1529,7 +1529,9 @@ pub fn source_compatibility( if needs_source && revision.is_none() { return Err(refused()); } - if !unchanged_normalized_review(&receipt, plan_id, normalized) { + if !unchanged_normalized_review(&receipt, plan_id, normalized) + && !normalized::source_independent_review(&receipt, plan) + { let revision = revision.ok_or_else(refused)?; if hostname_change::prepare(&engine, plan, source_receipt, normalized)?.is_none() { source::prepare_shared_changed( diff --git a/packages/runtime-core/src/provider/graph/normalized.rs b/packages/runtime-core/src/provider/graph/normalized.rs index d3c80adbf..af623c0f5 100644 --- a/packages/runtime-core/src/provider/graph/normalized.rs +++ b/packages/runtime-core/src/provider/graph/normalized.rs @@ -42,6 +42,42 @@ impl NormalizedInputIdentity { } } +/// Whether a stopped or active graph reads nothing from the project tree: no +/// source binding, dependency cache or cache provenance was recorded for it. +pub(super) fn source_free_receipt(receipt: &Receipt) -> bool { + receipt.source.is_none() + && receipt.initializer_cache_release.is_empty() + && receipt + .resources + .values() + .all(|resource| resource.cache.is_none() && resource.cache_provenance.is_none()) +} + +/// Whether `plan` reviews exactly the retained graph's Compose input for a graph +/// that reads nothing from the project tree. +/// +/// A plan ID also binds the project source inventory, which records the size and +/// modification time of every selected project file. Rewriting the Compose file +/// with its original bytes, or touching any project file, therefore changes the +/// plan ID although the reviewed Compose input is identical. Only graphs that +/// mount project source or use a dependency cache consume that inventory; they +/// keep their shared-source proofs. For every other graph the identical original +/// and normalized Compose bytes are the whole reviewed input. Restore then keeps +/// the retained ownership plan and still requires identical resource bindings, +/// images and readiness, so no compute, data or ownership changes. +pub(super) fn source_independent_review(receipt: &Receipt, plan: &project::PlanData) -> bool { + receipt + .normalized_input + .as_ref() + .is_some_and(|input| input.valid(&receipt.namespace) && input.matches_plan(plan)) + && source_free_receipt(receipt) + && plan.services.values().all(|service| { + !service.active + || (service.dependency_cache.is_none() + && service.mounts.iter().all(|mount| mount.kind == "volume")) + }) +} + pub(super) fn replay_refused() -> CandidateError { error( "graph_normalized_replay", @@ -261,6 +297,114 @@ mod tests { ); } + /// Rewriting the Compose file with its original bytes, or touching any project + /// file, changes only the source inventory. A graph without project source or + /// caches restores from that review; anything that reads source, or any change + /// of the reviewed Compose input itself, keeps the strict proofs. + #[test] + fn source_metadata_change_keeps_a_sourceless_review_restorable() { + let fixture = super::super::tests::Fixture::new(); + let project = fixture.0.join("project"); + fs::create_dir_all(project.join("app")).unwrap(); + fs::write(project.join("app/hello.txt"), b"hello\n").unwrap(); + let candidate = Candidate::discover(&fixture.0).unwrap(); + let namespace = candidate.plan(&project).unwrap().namespace; + let review = |bytes: &[u8]| { + let original = format!("{:x}", Sha256::digest(bytes)); + let report = project::plan_normalized( + &candidate, + project::NormalizedComposeOptions { + branch: None, + project: &project, + compose_file: Path::new("compose.yaml"), + profiles: &[], + expected_namespace: &namespace, + expected_compose_sha256: &original, + compose_bytes: bytes, + }, + ) + .unwrap(); + let identity = NormalizedInputIdentity { + namespace: namespace.clone(), + original_compose_sha256: original.clone(), + normalized_compose_sha256: original, + }; + (report, identity) + }; + let rewrite = |bytes: &[u8]| { + // Some filesystems keep coarse timestamps; make the rewrite observable. + std::thread::sleep(std::time::Duration::from_millis(20)); + fs::write(project.join("compose.yaml"), bytes).unwrap(); + }; + let receipt_for = |plan_id: &str, identity: &NormalizedInputIdentity| -> Receipt { + serde_json::from_value(json!({"version":1,"run":"a".repeat(32),"owner":"b".repeat(32),"namespace":namespace,"plan_id":plan_id,"phase":"stopped-data-retained","readiness":{},"resources":{},"normalized_input":identity})).unwrap() + }; + let image = b"services: {app: {image: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', volumes: ['data:/data']}}\nvolumes: {data: {}}\n"; + rewrite(image); + let (before, identity) = review(image); + let receipt = receipt_for(&before.plan_id, &identity); + assert!(super::super::unchanged_normalized_review( + &receipt, + &before.plan_id, + &identity + )); + // Edit, refuse, put back: same bytes, new modification time. + rewrite(b"services: {app: {image: alpine}}\n"); + rewrite(image); + let (restored, restored_identity) = review(image); + assert_eq!(restored_identity, identity); + assert_ne!(restored.plan_id, before.plan_id); + assert!(!super::super::unchanged_normalized_review( + &receipt, + &restored.plan_id, + &identity + )); + assert!(source_independent_review(&receipt, &restored.plan)); + // Touching an unrelated project file is the same kind of change. + std::thread::sleep(std::time::Duration::from_millis(20)); + fs::write(project.join("app/hello.txt"), b"hello\n").unwrap(); + let (touched, _) = review(image); + assert_ne!(touched.plan_id, restored.plan_id); + assert!(source_independent_review(&receipt, &touched.plan)); + + // A changed Compose input is not this path (Compose edits are reconciled elsewhere). + let edited = b"services: {app: {image: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', environment: {REVISION: '2'}, volumes: ['data:/data']}}\nvolumes: {data: {}}\n"; + rewrite(edited); + let (changed, _) = review(edited); + assert!(!source_independent_review(&receipt, &changed.plan)); + rewrite(image); + + // A graph that recorded source, a cache or cache provenance keeps its proofs. + let mut sourced = receipt.clone(); + sourced.source = Some( + serde_json::from_value(json!({"revision":"e".repeat(64),"archive_sha256":"f".repeat(64),"selection_sha256":"0".repeat(64)})) + .unwrap(), + ); + assert!(!source_free_receipt(&sourced)); + let (current, _) = review(image); + assert!(!source_independent_review(&sourced, ¤t.plan)); + // A foreign namespace or missing provenance never qualifies. + let mut foreign = receipt.clone(); + foreign.namespace = "f".repeat(64); + assert!(!source_independent_review(&foreign, ¤t.plan)); + let mut legacy = receipt.clone(); + legacy.normalized_input = None; + assert!(!source_independent_review(&legacy, ¤t.plan)); + + // A bind mount reads project source: the inventory is part of what it runs. + let bind = b"services: {app: {image: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', volumes: ['./app:/app:ro']}}\n"; + rewrite(bind); + let (mounted, mounted_identity) = review(bind); + let mounted_receipt = receipt_for(&mounted.plan_id, &mounted_identity); + rewrite(bind); + let (remounted, _) = review(bind); + assert_ne!(remounted.plan_id, mounted.plan_id); + assert!(!source_independent_review( + &mounted_receipt, + &remounted.plan + )); + } + #[test] fn normalized_receipt_cannot_be_replayed_as_original() { let mut receipt: Receipt = serde_json::from_value(json!({"version":1,"run":"a".repeat(32),"owner":"b".repeat(32),"namespace":"c".repeat(64),"plan_id":"d".repeat(64),"phase":"ready","readiness":{},"resources":{}})).unwrap(); diff --git a/packages/runtime-core/src/provider/graph/restore.rs b/packages/runtime-core/src/provider/graph/restore.rs index d75e4feab..e898657fe 100644 --- a/packages/runtime-core/src/provider/graph/restore.rs +++ b/packages/runtime-core/src/provider/graph/restore.rs @@ -298,7 +298,14 @@ fn restore_inputs( Some(change), )?; } else { - verify_fresh(&receipt, &inputs.review.plan_id, fresh, &generation)?; + // A source-independent review of the exact retained Compose input + // replays under the retained plan; resource bindings are rechecked below. + let plan = if normalized::source_independent_review(&receipt, &inputs.review.plan) { + &receipt.plan_id + } else { + &inputs.review.plan_id + }; + verify_fresh(&receipt, plan, fresh, &generation)?; } } else { normalized::require_file_replay(&receipt)?; diff --git a/packages/runtime-core/src/provider/graph/source.rs b/packages/runtime-core/src/provider/graph/source.rs index 11aa9ed0c..ed7cc8191 100644 --- a/packages/runtime-core/src/provider/graph/source.rs +++ b/packages/runtime-core/src/provider/graph/source.rs @@ -390,7 +390,11 @@ pub(super) fn prepare_replay( .as_ref() .and_then(|source| source.live.as_ref()); if !live || retained.is_none() { - if live || retained.is_some() || inputs.review.plan_id != receipt.plan_id { + if live + || retained.is_some() + || (inputs.review.plan_id != receipt.plan_id + && !super::normalized::source_independent_review(receipt, &inputs.review.plan)) + { return Err(error( "graph_source_changed", "Replay source mode or immutable review changed.", diff --git a/src/backends/native-project-mapping-command.ts b/src/backends/native-project-mapping-command.ts index 995d5b476..2b3f7620c 100644 --- a/src/backends/native-project-mapping-command.ts +++ b/src/backends/native-project-mapping-command.ts @@ -8,7 +8,7 @@ import { sanitizeBranchSlug, } from "../lib/project.ts"; import { - inspectNativeProjectRunFilesystemRecovery, + inspectNativeProjectRunMapping, recoverNativeProjectRunFilesystem, } from "./native-project-run.ts"; import type { NativeRuntimeSelection } from "./native-runtime-client.ts"; @@ -108,7 +108,7 @@ export async function runNativeProjectMappingCommand(opts: { }; const data = opts.selection.action === "inspect" - ? await inspectNativeProjectRunFilesystemRecovery({ + ? await inspectNativeProjectRunMapping({ scope, runtime: opts.runtime, }) @@ -120,6 +120,12 @@ export async function runNativeProjectMappingCommand(opts: { }); if (opts.json) { emitCliResult({ result: okResult({ data }) }); + } else if ("state" in data) { + process.stdout.write( + `Native run mapping is current: branch=${selected.branch ?? "base"}, run=${data.run.run}\n` + + `graph=${data.graph ? `${data.graph.phase}, ownership ${data.graph.ownershipMatches ? "matches" : "differs"}` : "unavailable"}\n` + + "No repair is needed. Graph recovery, retained-data readback and application readiness require separate verification.\n" + ); } else { process.stdout.write( `Native run mapping ${opts.selection.action === "repair" ? "repaired" : "inspected"}: branch=${selected.branch ?? "base"}, run=${data.run.run}\n` + diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index 5ae8a1a50..f1b64f8d0 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -858,6 +858,99 @@ export async function inspectNativeProjectRunFilesystemRecovery(opts: { return (await recoverySelection(opts)).inspection; } +export type NativeProjectRunCurrentInspection = { + readonly schema: typeof CURRENT_SCHEMA; + readonly state: "current"; + readonly mappingSha256: string; + readonly run: NativeProjectRun; + /** Absent when the native graph cannot be read, for example while the pool is stopped. */ + readonly graph: { + readonly phase: string; + readonly ownershipMatches: boolean; + } | null; + readonly repairRequired: false; +}; + +const CURRENT_SCHEMA = "hack.native-project-run-mapping-inspection/v1"; +const GRAPH_PHASE = /^[a-z][a-z-]{0,63}$/; + +async function currentGraph(opts: { + readonly projectRoot: string; + readonly run: NativeProjectRun; + readonly runtime: NativeRuntimeSelection; + readonly invoke?: typeof invokeNativeRuntime; +}): Promise { + let observed: unknown; + try { + observed = await inspectNativeProjectGraph({ + runtime: opts.runtime, + projectRoot: opts.projectRoot, + run: opts.run.run, + invoke: opts.invoke ?? invokeNativeRuntime, + }); + } catch { + return null; + } + const receipt = isRecord(observed) ? observed.receipt : undefined; + if ( + !( + isRecord(receipt) && + typeof receipt.phase === "string" && + GRAPH_PHASE.test(receipt.phase) + ) + ) { + return null; + } + return { + phase: receipt.phase, + ownershipMatches: + isRecord(observed) && + observed.journal_incomplete === false && + receipt.run === opts.run.run && + receipt.owner === opts.run.owner && + receipt.namespace === opts.run.namespace && + receipt.plan_id === opts.run.planId, + }; +} + +/** + * Read-only Doctor inspection of the saved run mapping. A mapping that still + * matches this checkout is reported with its native graph phase and whether the + * graph's ownership matches it; nothing needs repair. Only a mapping whose scope + * no longer matches falls through to the explicit legacy device-rebind + * inspection, which refuses every other difference. + */ +export async function inspectNativeProjectRunMapping(opts: { + readonly scope: NativeProjectRunScope; + readonly runtime: NativeRuntimeSelection; + readonly invoke?: typeof invokeNativeRuntime; +}): Promise< + NativeProjectRunCurrentInspection | NativeProjectRunFilesystemInspection +> { + const p = await paths(opts.scope, false); + await recoveryDirectories(p); + const { bytes, value } = await readRecoveryFile(p.file); + let run: NativeProjectRun; + try { + run = record(value, p.identity); + } catch { + return await inspectNativeProjectRunFilesystemRecovery(opts); + } + return { + schema: CURRENT_SCHEMA, + state: "current", + mappingSha256: createHash("sha256").update(bytes).digest("hex"), + run, + graph: await currentGraph({ + projectRoot: p.identity.projectRoot, + run, + runtime: opts.runtime, + invoke: opts.invoke, + }), + repairRequired: false, + }; +} + /** Explicitly rebind only three mapping device numbers; graph history is untouched. */ export async function recoverNativeProjectRunFilesystem(opts: { readonly scope: NativeProjectRunScope; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..7708f3753 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -326,7 +326,7 @@ const doctorOptions = [ long: "--native-run-mapping", valueHint: "inspect|repair", description: - "Inspect or explicitly repair a native run mapping after filesystem device renumbering", + "Inspect a native run mapping, or explicitly repair it after filesystem device renumbering", } as const), defineOption({ name: "expectSelection", diff --git a/tests/native-project-run-filesystem.test.ts b/tests/native-project-run-filesystem.test.ts index 742a1c949..248c2cd78 100644 --- a/tests/native-project-run-filesystem.test.ts +++ b/tests/native-project-run-filesystem.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { inspectNativeProjectRunFilesystemRecovery as inspect, + inspectNativeProjectRunMapping as inspectMapping, loadNativeProjectRun as load, type NativeProjectRunScope, recoverNativeProjectRunFilesystem as recover, @@ -329,3 +330,121 @@ test("an exact published audit supports retry after prepublication refusal", asy expect(repaired.repaired).toBe(true); expect(await load(pool.scope)).toEqual(run); }); + +test("Doctor inspects a current mapping read-only and reports its graph", async () => { + const projectRoot = await realpath( + await mkdtemp(join(tmpdir(), "native-mapping-current-")) + ); + roots.push(projectRoot); + const projectDir = join(projectRoot, ".hack"); + const nativeHome = join(projectRoot, "candidate"); + await mkdir(projectDir); + await mkdir(nativeHome); + const scope: NativeProjectRunScope = { + projectRoot, + projectDir, + nativeHome, + branch: null, + }; + const current = { + run: "1".repeat(32), + owner: "2".repeat(32), + namespace: "3".repeat(64), + planId: "4".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + runtimeMetadata: true as const, + }; + await save({ ...scope, run: current }); + const key = createHash("sha256").update(JSON.stringify(null)).digest("hex"); + const file = join(projectDir, ".internal/native-runs", `${key}.json`); + const bytes = await readFile(file); + const calls: string[][] = []; + const graph = + (receipt: Record | Error) => + async (input: { readonly args: readonly string[] }) => { + calls.push([...input.args]); + if (receipt instanceof Error) { + throw receipt; + } + return { journal_incomplete: false, receipt }; + }; + const stopped = { + run: current.run, + owner: current.owner, + namespace: current.namespace, + plan_id: current.planId, + phase: "stopped-data-retained", + }; + + const inspected = await inspectMapping({ + scope, + runtime, + invoke: graph(stopped), + }); + expect(inspected).toEqual({ + schema: "hack.native-project-run-mapping-inspection/v1", + state: "current", + mappingSha256: createHash("sha256").update(bytes).digest("hex"), + run: current, + graph: { phase: "stopped-data-retained", ownershipMatches: true }, + repairRequired: false, + }); + // Only the read-only graph inspection runs; the mapping is unchanged. + expect(calls).toEqual([ + ["graph", "inspect", "--run-id", current.run, "--json"], + ]); + expect((await readFile(file)).equals(bytes)).toBe(true); + + // A graph owned by a different plan is reported, never repaired. + const foreign = await inspectMapping({ + scope, + runtime, + invoke: graph({ ...stopped, plan_id: "5".repeat(64) }), + }); + expect(foreign).toMatchObject({ + state: "current", + graph: { phase: "stopped-data-retained", ownershipMatches: false }, + }); + // A stopped pool leaves the graph unreadable; the mapping is still reported. + const unavailable = await inspectMapping({ + scope, + runtime, + invoke: graph(new Error("runtime stopped")), + }); + expect(unavailable).toMatchObject({ state: "current", graph: null }); + + // Repair still applies only to a device-renumbered mapping. + await expect( + recover({ + scope, + runtime, + expectSelection: "6".repeat(64), + acceptLegacyDeviceRebind: true, + invoke: graph(stopped), + }) + ).rejects.toThrow("Native project run mapping"); + // An unsafe file mode is refused, as before. + await chmod(file, 0o644); + await expect( + inspectMapping({ scope, runtime, invoke: graph(stopped) }) + ).rejects.toThrow("Native project run mapping"); + expect(await load(scope).catch(() => "refused")).toBe("refused"); + await chmod(file, 0o600); + expect(await load(scope)).toEqual(current); +}); + +test("Doctor inspection of a renumbered mapping keeps the legacy rebind selection", async () => { + const pool = await fixture(); + const invoke = authority(pool); + const inspected = await inspectMapping({ + scope: pool.scope, + runtime, + invoke, + }); + expect("state" in inspected).toBe(false); + expect(inspected).toEqual( + await inspect({ scope: pool.scope, runtime, invoke }) + ); +}); From f4fd82761ba02a415da204691d1d4378c555b493 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:45:53 -0500 Subject: [PATCH 115/138] fix(native): gate the build fence test helper to macOS Its only caller is a macOS-only lifecycle test, so Linux test builds flagged the helper as dead code and failed the candidate-core lint. Refs HACK-1924 Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/provider/image_build.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/provider/image_build.rs b/packages/runtime-core/src/provider/image_build.rs index 2cc9c1ece..38a781259 100644 --- a/packages/runtime-core/src/provider/image_build.rs +++ b/packages/runtime-core/src/provider/image_build.rs @@ -987,8 +987,9 @@ pub(super) fn run( }) } -/// Hold the shared build fence as an admitted build does, for lifecycle tests. -#[cfg(test)] +/// Hold the shared build fence as an admitted build does, for the macOS-only lifecycle +/// test of the silent-guest stop. +#[cfg(all(test, target_os = "macos"))] pub(super) fn hold_build_fence_for_test(candidate: &Candidate) -> impl Drop { let root = builds_root(candidate); state::private_directory(&root).unwrap(); From dfb51b3a6189140da608af975567aaba3ad289ab Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:47:36 -0500 Subject: [PATCH 116/138] test: type the data handoff advisory fixture literally The #601 merge typed the handoff fixture's code as string, which the NativeDataAdvisory union rejects in the CLI typecheck. Narrow it with as const; the assertion is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/native-data-advisories.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/native-data-advisories.test.ts b/tests/native-data-advisories.test.ts index e60bf6728..3bbe8ada0 100644 --- a/tests/native-data-advisories.test.ts +++ b/tests/native-data-advisories.test.ts @@ -49,7 +49,10 @@ function project( test("a root data handoff needs a warning only when its capabilities are dropped", () => { const db = { image: PINNED, volumes: ["data:/var/lib/data"] }; const handoff = [ - { code: "native_data_owner_handoff_unqualified", services: ["db"] }, + { + code: "native_data_owner_handoff_unqualified" as const, + services: ["db"], + }, ]; // Docker's default capabilities keep CHOWN, DAC_OVERRIDE, FOWNER, SETUID and SETGID. for (const lists of [ From 7f42e328d9b3f95060a71a5db0d22778cb0c5c2f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 6 Nov 2026 18:42:56 -0500 Subject: [PATCH 117/138] fix(native): admit native build fields and name services in build refusals (#628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes **HACK-1925: build args were refused before the builds path ran.** With `HACK_NATIVE_BUILDS=1`, the Compose-backend feature gate now admits exactly the build fields native builds implement: `context`, `dockerfile`, `target` and `args`. Both sides use one shared constant, `NATIVE_BUILD_FIELDS`. Native build admission then reviews the values of those fields. As a result: - Literal build args build natively. - A build arg whose value equals a managed `hack env` secret now reaches the existing refusal. That refusal names the service and the arg, never prints the value, and does not suggest the Compose backend (a Compose build would record the value in the image history too). - These still select the Compose backend: any other build field (`secrets`, `ssh`, `cache_from`, `platforms`, …) and `image` together with `build`. The refusal phrase names the native field set in this case. - Without native builds, today's basic-form classification and message are unchanged. - `hack doctor` applies the same classification, so doctor and `up` agree. **HACK-1927: build refusals now name the service.** - Admission refusals: `cannot build service : …`. - The builds-disabled refusal (now `nativeBuildsDisabledRefusal(service)`). - The secret-arg refusal. - Runtime failures from `runtime build-image`, wrapped in `runPendingBuilds`. A refusal with a build source policy code (`native_build_source`, `_dockerfile`, `_base_image`, `_argument`, `_reserved_label`, `_image_volumes`) says that it is the native build source policy and what to change. Other runtime failures name the service(s) and keep their message. The native codes are kept, and the code lookup uses only the map's own keys. - The candidate guide now says the pinned network-tool APKs come from Alpine v3.19 `main`/`aarch64`, and that v3.20 has files with the same names but different bytes. ## Validation - Focused tests (bounded runner, private HOME/TMPDIR, stubbed `docker`/`launchctl`/`security`): - `tests/native-project-builds.test.ts`, `tests/native-compose-backend-features.test.ts`, `tests/native-compose-backend-features-cli.test.ts`: 64 pass, 0 fail. - Related suites `native-project-restart`, `native-project-start`, `compose-startup-budget`, `doctor-json`, `doctor-command`: 157 pass, 0 fail. - New regression tests (synthetic values only): - Gate with builds enabled: implemented fields pass; unimplemented fields and image+build still refuse; disabled behavior is unchanged; the phrase follows the flag. - CLI with `HACK_NATIVE_BUILDS=1`: - A build arg passes the gate and reaches native start. - A build arg equal to a managed secret (set with `setProjectEnvValue`) refuses with one closed line naming the service and the arg. It does not show the value and does not mention `HACK_RUNTIME_BACKEND=compose`. The only runtime call is `check-project-share`. - An unimplemented field still gets the Compose-backend line. - Service-named admission refusals and the disabled refusal. - Runtime source-policy refusals, other runtime failures, and an inherited property name as a native code. - Checks: - `bun run --cwd packages/cli typecheck`: the only error is in `tests/fixtures/atomic-write-foreign-owner-worker.ts`. That file is unchanged, and the same error appears on the base head 8ec7460e99. - `bun run --cwd packages/cli check`: rc 0. - Biome on the changed files: clean. - `bun run docs:prepare && bun run docs:check`: pass. - `gitleaks protect --staged` and `gitleaks detect` over the range: no leaks. - Normal hooks. - Host check on an M5 VM. Isolated HOME, `HACK_DAEMON_AUTOSTART_DISABLE=1`, a fresh native home per fixture, and a bundle made of this branch's compiled CLI plus the base `hack-native`. The two `hack-native` files are byte-identical; this PR changes no Rust. - Fixture with a literal non-secret build arg: built in the VM, the service reached `ready-observed`, then stopped cleanly. - Fixture whose build arg equals a managed secret: refused with `cannot build service secretsvc: build arg GREETING equals a hack env secret value …`. The value is absent from the output, no Compose-backend suggestion is made, and the runtime stays `uninitialized`. - Fixture whose Dockerfile uses a non-Docker-Hub base: `cannot build service deniedsvc: the native build source policy refused it. Dockerfile line 1: … Use public Docker Hub images in the Dockerfile's FROM and COPY --from. No image was adopted.` - The full `bun test` suite was not run locally, as instructed for this unit. Hosted CI is the full-suite gate. ## Remaining gaps - The gate runs before managed env is loaded, so it cannot see secret values. Suppose a project has a Compose-only feature such as an external network, plus a build arg that carries a secret. It still gets the generic "use the Compose backend" line. The secret check only runs on the native builds path. - Value-level build problems such as a remote context or an absolute path no longer suggest the Compose backend when builds are enabled. Native build admission refuses them instead, naming the service. Refs HACK-1925 Refs HACK-1927 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Prove it works Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need. Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/guides/native-candidate.md | 17 +- src/backends/native-project-builds.ts | 146 ++++++++++++++---- src/commands/doctor.ts | 5 + src/commands/project.ts | 4 + src/lib/doctor-compose-backend.ts | 13 +- src/lib/native-compose-backend-features.ts | 107 +++++++++---- ...ative-compose-backend-features-cli.test.ts | 86 ++++++++++- tests/native-compose-backend-features.test.ts | 58 +++++++ tests/native-project-builds.test.ts | 84 +++++++++- 9 files changed, 444 insertions(+), 76 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 68e82a2d4..c37ca4587 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -192,7 +192,7 @@ Compose backend. Native support for them is planned after v5. | External network | A top-level network sets `external: true` or `external: {name: ...}`. A reviewed `x-hack-isolated: true` replacement is not external: it creates an owned internal bridge. A network that the `HACK_NATIVE_ADAPTATION` selection (see [Frontend integration status](#frontend-integration-status)) lists in `isolatedNetworks` gets the same replacement, so it is not external either. | | External volume | A top-level volume sets `external: true` or `external: {name: ...}`. The native runtime only creates volumes it owns, and there is no native replacement for an external volume. | | Reserved ownership label | A service, network or volume label key starts with `com.docker.compose.`, `io.hack-local.`, `io.hack.native-config.`, `io.hack.native-file.` or `io.hack.storage-witness.`. Compose, the native runtime and native-config Compose use these labels to prove which resources they own. | -| Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. | +| Build outside the basic form | A service `build` is not the basic form that [`hack config import --dry-run`](../reference/native-config-import.md#pure-basic-build-preview) accepts (a relative context and optional relative `dockerfile` and `target`), or the service also sets `image`. Build arguments, secrets, SSH, cache options, platforms and other build options are outside the basic form. With `HACK_NATIVE_BUILDS=1` (see below), only a build field other than `context`, `dockerfile`, `target` and `args`, or `image` together with `build`, counts; native build admission reviews the values of those four fields itself. | | Non-native platform | A service sets a `platform` other than `linux/arm64` or `linux/arm64/v8`, for example `linux/amd64`. The native VM runs Linux arm64 only. A missing or `null` `platform` is unset and passes. | Native `hack up` and `hack restart` check for these features after selecting the @@ -244,8 +244,14 @@ With it: remote contexts and `image` together with `build` refuse. Values are never shown. - Build args are not secrets: as with Docker, the image records them in its history. A build arg whose value equals a `hack env` secret value of this project and - environment refuses before any hook or VM effect, naming only the argument. Pass + environment refuses before any hook or VM effect, naming the service and the + argument but never the value. The refusal does not suggest the Compose backend, + because a Compose build would record the value in the image history too. Pass secrets with BuildKit build secrets instead; native builds do not support them yet. +- Every build refusal names the service. A refusal from the runtime's build source + policy (for example a base image that is not a public Docker Hub reference) says + that it is the build source policy and what to change. Other build failures name + the service and keep the runtime's message. - After the runtime is up, each distinct build runs once with `runtime build-image` (below), and its filtered output streams to your terminal. Every service with that build is then pinned to the built content ID, so review, @@ -548,8 +554,11 @@ saved run state. The report lists removed images and why each other one was kept Before startup, network-tool preparation additionally requires all four pinned Alpine aarch64 APKs in a current-user-owned private directory (mode `0700`), with -regular, singly linked mode-`0600` files. Acquire the matching public package bytes; -the command never downloads packages or accepts substitute versions: +regular, singly linked mode-`0600` files. The pins are the Alpine v3.19 `main` +repository for `aarch64`. Other Alpine releases, such as v3.20, publish files with +the same names but different bytes, which the command refuses. Acquire the matching +public package bytes; the command never downloads packages or accepts substitute +versions: | File | SHA-256 | | --- | --- | diff --git a/src/backends/native-project-builds.ts b/src/backends/native-project-builds.ts index 0d1b737eb..a7f60d13c 100644 --- a/src/backends/native-project-builds.ts +++ b/src/backends/native-project-builds.ts @@ -25,7 +25,18 @@ const IMAGE_ID = /^sha256:[a-f0-9]{64}$/; const BUILD_KEY = /^[a-f0-9]{64}$/; const ARGUMENT = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; const TARGET = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; -const BUILD_KEYS = new Set(["context", "dockerfile", "target", "args"]); +/** + * The Compose `build` fields native builds implement. The Compose-backend feature + * gate admits exactly these fields when builds are enabled, so a build it lets + * through reaches this module's own admission and secret check. + */ +export const NATIVE_BUILD_FIELDS = [ + "context", + "dockerfile", + "target", + "args", +] as const; +const BUILD_KEYS: ReadonlySet = new Set(NATIVE_BUILD_FIELDS); const MAX_ARGS = 64; /** The runtime's own budget is 30 minutes; the client waits a little longer. */ const BUILD_TIMEOUT_SECONDS = 30 * 60; @@ -57,36 +68,48 @@ export interface NativeBuildRequest { readonly args: Readonly>; } -/** Fixed refusal for a `build:` service while native builds are not enabled. */ -export const NATIVE_BUILDS_DISABLED_REFUSAL = - "Native foreground up builds Compose build services only with HACK_NATIVE_BUILDS=1; set it to build them in the native runtime. No hook, VM or build was started."; +/** The refusal for the first `build:` service while native builds are not enabled. */ +export function nativeBuildsDisabledRefusal(service: string): string { + return `Native foreground up cannot build service ${service}: it builds Compose build services only with HACK_NATIVE_BUILDS=1; set it to build them in the native runtime. No hook, VM or build was started.`; +} -function refused(detail: string): HackCliError { +/** "service a", or "services a and b", for build refusals. */ +function serviceList(services: readonly string[]): string { + if (services.length <= 1) { + return `service ${services[0] ?? ""}`; + } + return `services ${services.slice(0, -1).join(", ")} and ${services.at(-1)}`; +} + +/** The admission refusal for one service. Authored values are never included. */ +function refused(service: string, detail: string): HackCliError { return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native foreground up cannot build this service: ${detail} Values were omitted; no hook, VM or build was started.`, + message: `Native foreground up cannot build service ${service}: ${detail} Values were omitted; no hook, VM or build was started.`, }); } -function literal(value: unknown, what: string): string { +function literal(service: string, value: unknown, what: string): string { if (typeof value !== "string" || value.length === 0 || value.includes("$")) { throw refused( + service, `${what} must be a literal non-empty string without Compose interpolation.` ); } return value; } -function argumentEntries(value: unknown): [string, unknown][] { +function argumentEntries(service: string, value: unknown): [string, unknown][] { if (isRecord(value)) { return Object.entries(value); } if (!Array.isArray(value)) { - throw refused("build args must be a mapping or a list."); + throw refused(service, "build args must be a mapping or a list."); } return value.map((item: unknown): [string, unknown] => { if (typeof item !== "string" || !item.includes("=")) { throw refused( + service, "list-form build args must be KEY=VALUE literals; inheriting a host variable is not supported." ); } @@ -95,25 +118,29 @@ function argumentEntries(value: unknown): [string, unknown][] { }); } -function buildArguments(value: unknown): Record { +function buildArguments( + service: string, + value: unknown +): Record { if (value === undefined) { return {}; } - const entries = argumentEntries(value); + const entries = argumentEntries(service, value); if (entries.length > MAX_ARGS) { - throw refused("a native build accepts at most 64 build args."); + throw refused(service, "a native build accepts at most 64 build args."); } // A null prototype keeps every admitted name an own property, `__proto__` included. const result: Record = Object.create(null); for (const [name, raw] of entries) { if (!ARGUMENT.test(name) || Object.hasOwn(result, name)) { - throw refused("build arg names must be unique identifiers."); + throw refused(service, "build arg names must be unique identifiers."); } const text = typeof raw === "number" && Number.isFinite(raw) ? String(raw) : raw; if (typeof text !== "string" || text.includes("$")) { // Env-derived values would be baked into image history. throw refused( + service, "build arg values must be literal strings or numbers; interpolation and null values are not supported." ); } @@ -123,41 +150,42 @@ function buildArguments(value: unknown): Record { } /** The declared build fields with Compose defaults, before path checks. */ -function buildFields(build: unknown): NativeBuildRequest { +function buildFields(service: string, build: unknown): NativeBuildRequest { if (typeof build === "string") { return { - context: literal(build, "the build context"), + context: literal(service, build, "the build context"), dockerfile: "Dockerfile", target: null, args: {}, }; } if (!isRecord(build)) { - throw refused("build must be a context path or a mapping."); + throw refused(service, "build must be a context path or a mapping."); } if (Object.keys(build).some((key) => !BUILD_KEYS.has(key))) { throw refused( + service, "only build context, dockerfile, target and literal args are supported." ); } const target = build.target === undefined ? null - : literal(build.target, "the build target"); + : literal(service, build.target, "the build target"); if (target !== null && !TARGET.test(target)) { - throw refused("the build target must be a stage name."); + throw refused(service, "the build target must be a stage name."); } return { context: build.context === undefined ? "." - : literal(build.context, "the build context"), + : literal(service, build.context, "the build context"), dockerfile: build.dockerfile === undefined ? "Dockerfile" - : literal(build.dockerfile, "the Dockerfile path"), + : literal(service, build.dockerfile, "the Dockerfile path"), target, - args: buildArguments(build.args), + args: buildArguments(service, build.args), }; } @@ -168,6 +196,8 @@ function buildFields(build: unknown): NativeBuildRequest { * runtime resolves it again without following symlinks. */ export function admitNativeProjectBuild(opts: { + /** The Compose service name; every refusal names it. */ + readonly name: string; readonly service: Readonly>; readonly projectRoot: string; readonly composeFile: string; @@ -176,21 +206,25 @@ export function admitNativeProjectBuild(opts: { if (build === undefined) { return; } + const name = opts.name; if (Object.hasOwn(opts.service, "image")) { - throw refused("a service may not declare both image and build."); + throw refused(name, "a service may not declare both image and build."); } - const { context, dockerfile, target, args } = buildFields(build); + const { context, dockerfile, target, args } = buildFields(name, build); if (context.includes("://") || context.startsWith("git@")) { - throw refused("remote build contexts are not supported."); + throw refused(name, "remote build contexts are not supported."); } if (isAbsolute(dockerfile)) { - throw refused("the Dockerfile must be relative to the build context."); + throw refused( + name, + "the Dockerfile must be relative to the build context." + ); } const absolute = resolve(dirname(opts.composeFile), context); const root = resolve(opts.projectRoot); const inside = relative(root, absolute); if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { - throw refused("the build context must be inside the project."); + throw refused(name, "the build context must be inside the project."); } return { context: inside === "" ? "." : inside, dockerfile, target, args }; } @@ -209,14 +243,14 @@ export function admitNativeProjectBuilds(opts: { }): ReadonlyMap { const builds = new Map(); for (const [name, service] of Object.entries(opts.services)) { - const request = admitNativeProjectBuild({ ...opts, service }); + const request = admitNativeProjectBuild({ ...opts, name, service }); if (request === undefined) { continue; } if (!opts.enabled) { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: NATIVE_BUILDS_DISABLED_REFUSAL, + message: nativeBuildsDisabledRefusal(name), }); } builds.set(name, request); @@ -461,7 +495,7 @@ export function refuseManagedSecretBuildArguments(opts: { if (secrets.has(value)) { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native foreground up cannot build ${service}: build arg ${name} equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started.`, + message: `Native foreground up cannot build service ${service}: build arg ${name} equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started.`, }); } } @@ -591,6 +625,8 @@ async function runPendingBuilds(opts: { cwd: opts.projectRoot, requestFile, signal: opts.signal, + }).catch((error: unknown) => { + throw nameBuildFailure(services, error); }); // The request carries argument values; remove it as soon as it is consumed. await rm(requestFile, { force: true }); @@ -606,6 +642,58 @@ async function runPendingBuilds(opts: { } } +/** + * Runtime refusal codes from the native build source policy: what the runtime + * accepts as build input and as the built image, each with what to change. The + * runtime checks them before or right after the build; they are not failures of + * the build itself. + */ +const BUILD_SOURCE_POLICY: Readonly> = { + native_build_source: + "Keep the build context and Dockerfile inside the project as regular files reached without symlinks.", + native_build_dockerfile: "Change the Dockerfile line it names.", + native_build_base_image: + "Use public Docker Hub images in the Dockerfile's FROM and COPY --from.", + native_build_argument: + "Change the build args in the service's Compose build.", + native_build_reserved_label: + "Remove reserved ownership labels from the Dockerfile and its base images.", + native_build_image_volumes: + "Remove VOLUME from the Dockerfile and its base images; declare volumes in Compose instead.", +}; + +/** + * Name the services of a failed `runtime build-image`. A refusal from the build + * source policy says so and what to change; any other runtime failure keeps its + * message. The native code is kept for callers that classify failures. + * Cancellation and closed client errors carry no service-specific cause and pass + * through unchanged. + */ +function nameBuildFailure( + services: readonly string[], + error: unknown +): unknown { + if (!(error instanceof NativeRuntimeRequestError)) { + return error; + } + const which = serviceList(services); + const code = error.nativeCode; + const change = + code !== undefined && Object.hasOwn(BUILD_SOURCE_POLICY, code) + ? BUILD_SOURCE_POLICY[code] + : undefined; + const message = + change === undefined + ? `Native foreground up could not build ${which}: ${error.message}` + : `Native foreground up cannot build ${which}: the native build source policy refused it. ${error.message} ${change} No image was adopted.`; + return new NativeRuntimeRequestError({ + message, + nativeCode: error.nativeCode, + nativeCauseCode: error.nativeCauseCode, + nativeStopFailures: error.nativeStopFailures, + }); +} + function pin(spec: Record, image: string): void { if (!IMAGE_ID.test(image)) { throw new ClosedCliError( diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 02778adc6..24553e467 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -7,6 +7,7 @@ import { checkLegacyProjectAgentArtifacts, checkLegacyUserAgentArtifacts, } from "../agents/legacy-artifacts.ts"; +import { nativeBuildsEnabled } from "../backends/native-project-builds.ts"; import { runNativeProjectCleanupCommand } from "../backends/native-project-cleanup-command.ts"; import { parseNativeRunMappingRecoveryOptions, @@ -2945,10 +2946,14 @@ async function checkComposeBackendFeatures(opts: { message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, }; } + // The same build classification native up applies under HACK_NATIVE_BUILDS. + const buildsEnabled = nativeBuildsEnabled(); return composeBackendCheckResult({ backend: opts.backend, + buildsEnabled, features: await readComposeBackendFeatures(ctx.composeFile, { adaptationFile: opts.adaptationFile, + buildsEnabled, }), }); } diff --git a/src/commands/project.ts b/src/commands/project.ts index ff71f6a36..18894ba4b 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -42,6 +42,7 @@ import { import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; import { parseNativePreparedBase } from "../backends/native-prepared-base.ts"; +import { nativeBuildsEnabled } from "../backends/native-project-builds.ts"; import { nativeDownEnvironment, nativeProjectDown, @@ -5728,10 +5729,13 @@ async function handleNativeUp({ // stays available for any graph a project already has. // The explicit adaptation applies first: a network it isolates is not // external here, exactly as native start normalizes it. + // With native builds enabled, build fields native builds implement pass + // here and native build admission reviews them, secret-valued args included. await refuseNativeComposeBackendFeatures({ composeFile: project.composeFile, adaptationFile: process.env.HACK_NATIVE_ADAPTATION, restart, + buildsEnabled: nativeBuildsEnabled(), }); const branch = await resolveEffectiveBranchForCommand({ project, diff --git a/src/lib/doctor-compose-backend.ts b/src/lib/doctor-compose-backend.ts index da78f14b8..86765332b 100644 --- a/src/lib/doctor-compose-backend.ts +++ b/src/lib/doctor-compose-backend.ts @@ -1,6 +1,7 @@ import { COMPOSE_BACKEND_SELECTION, type ComposeBackendFeature, + type ComposeBackendFeatureOptions, describeComposeBackendFeatures, } from "./native-compose-backend-features.ts"; @@ -21,10 +22,12 @@ export type ComposeBackendReport = { * refuses the project; on the Compose backend it is informational, because * the project already runs where those features are supported. */ -export function composeBackendCheckResult(opts: { - readonly backend: "native" | "compose"; - readonly features: readonly ComposeBackendFeature[]; -}): { +export function composeBackendCheckResult( + opts: { + readonly backend: "native" | "compose"; + readonly features: readonly ComposeBackendFeature[]; + } & ComposeBackendFeatureOptions +): { readonly name: string; readonly status: "ok" | "warn"; readonly message: string; @@ -40,7 +43,7 @@ export function composeBackendCheckResult(opts: { composeBackend, }; } - const uses = `This project uses ${describeComposeBackendFeatures(opts.features)}`; + const uses = `This project uses ${describeComposeBackendFeatures(opts.features, opts)}`; if (opts.backend === "native") { return { name: COMPOSE_BACKEND_CHECK, diff --git a/src/lib/native-compose-backend-features.ts b/src/lib/native-compose-backend-features.ts index 2afef1ed8..2cf759832 100644 --- a/src/lib/native-compose-backend-features.ts +++ b/src/lib/native-compose-backend-features.ts @@ -1,5 +1,6 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; +import { NATIVE_BUILD_FIELDS } from "../backends/native-project-builds.ts"; import { readNativeSelection } from "../backends/native-project-selection.ts"; import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; @@ -73,6 +74,25 @@ const FEATURE_PHRASES: Readonly> = { foreign_platform: "a service platform other than linux/arm64", }; +/** "a, b, c and d" for the build fields native builds implement. */ +function nativeBuildFieldList(): string { + return `${NATIVE_BUILD_FIELDS.slice(0, -1).join(", ")} and ${NATIVE_BUILD_FIELDS.at(-1)}`; +} + +/** The `unsupported_build` phrase while `HACK_NATIVE_BUILDS=1` is set. */ +const NATIVE_BUILDS_PHRASE = `a Compose build field other than ${nativeBuildFieldList()}, or image together with build`; + +/** Options shared by the classifier, its readers and its messages. */ +export type ComposeBackendFeatureOptions = { + /** + * Whether native builds are enabled (`HACK_NATIVE_BUILDS=1`). Then a build + * is a Compose-backend feature only when it declares a field native builds + * do not implement, or `image` together with `build`; native build + * admission reviews the rest, including `args` and the managed-secret check. + */ + readonly buildsEnabled?: boolean; +}; + function labelKeys(labels: unknown): readonly string[] { if (isRecord(labels)) { return Object.keys(labels); @@ -109,19 +129,36 @@ function isExternalNetwork(network: unknown): boolean { } /** - * A build is inside the supported path only when the import preview's basic - * build mapper accepts it and the service names no image, exactly as - * `hack config import --dry-run` reports `invalid_or_unsupported_build` and - * `image_build_exclusive`. + * Without native builds, a build is inside the supported path only when the + * import preview's basic build mapper accepts it and the service names no + * image, exactly as `hack config import --dry-run` reports + * `invalid_or_unsupported_build` and `image_build_exclusive`. + * + * With native builds, the gate admits exactly the fields native builds + * implement ({@link NATIVE_BUILD_FIELDS}). Values in those fields, such as + * paths, interpolation and build args, are left to native build admission, + * which refuses with the service name and never suggests the Compose backend: + * a build arg that carries a secret would be recorded in the image history + * there too. */ -function isUnsupportedBuild(service: Record): boolean { +function isUnsupportedBuild( + service: Record, + buildsEnabled: boolean +): boolean { if (!Object.hasOwn(service, "build")) { return false; } - return ( - Object.hasOwn(service, "image") || - mapLegacyComposeBuild(service.build) === undefined - ); + if (Object.hasOwn(service, "image")) { + return true; + } + const build = service.build; + if (buildsEnabled) { + const fields: ReadonlySet = new Set(NATIVE_BUILD_FIELDS); + return ( + isRecord(build) && Object.keys(build).some((key) => !fields.has(key)) + ); + } + return mapLegacyComposeBuild(build) === undefined; } /** @@ -174,7 +211,7 @@ export function findComposeBackendFeatures( * {@link readAdaptedIsolatedNetworks}. */ readonly isolatedNetworks?: ReadonlySet; - } + } & ComposeBackendFeatureOptions ): ComposeBackendFeature[] { if (!isRecord(compose)) { return []; @@ -205,7 +242,8 @@ export function findComposeBackendFeatures( ) { found.add("reserved_label"); } - if (services.some(isUnsupportedBuild)) { + const buildsEnabled = opts?.buildsEnabled === true; + if (services.some((service) => isUnsupportedBuild(service, buildsEnabled))) { found.add("unsupported_build"); } if (services.some(isForeignPlatform)) { @@ -305,10 +343,11 @@ export async function readAdaptedIsolatedNetworks( */ export async function readComposeBackendFeatures( composeFile: string, - opts?: { readonly adaptationFile?: string } + opts?: { readonly adaptationFile?: string } & ComposeBackendFeatureOptions ): Promise { const compose = await readComposeDocument(composeFile); - const features = findComposeBackendFeatures(compose); + const buildsEnabled = opts?.buildsEnabled; + const features = findComposeBackendFeatures(compose, { buildsEnabled }); if ( opts?.adaptationFile === undefined || !features.includes("external_network") @@ -317,14 +356,20 @@ export async function readComposeBackendFeatures( } return findComposeBackendFeatures(compose, { isolatedNetworks: await readAdaptedIsolatedNetworks(opts.adaptationFile), + buildsEnabled, }); } /** Value-free phrase list, such as "a and b" or "a, b and c". */ export function describeComposeBackendFeatures( - features: readonly ComposeBackendFeature[] + features: readonly ComposeBackendFeature[], + opts?: ComposeBackendFeatureOptions ): string { - const phrases = features.map((feature) => FEATURE_PHRASES[feature]); + const phrases = features.map((feature) => + feature === "unsupported_build" && opts?.buildsEnabled === true + ? NATIVE_BUILDS_PHRASE + : FEATURE_PHRASES[feature] + ); if (phrases.length <= 1) { return phrases[0] ?? ""; } @@ -340,14 +385,16 @@ export const COMPOSE_BACKEND_SELECTION = * `E_NATIVE_PROJECT_UNSUPPORTED`, one line, and a `detail` that lists only the * closed feature names. */ -export function composeBackendRefusal(opts: { - readonly features: readonly ComposeBackendFeature[]; - readonly restart: boolean; -}): HackCliError { +export function composeBackendRefusal( + opts: { + readonly features: readonly ComposeBackendFeature[]; + readonly restart: boolean; + } & ComposeBackendFeatureOptions +): HackCliError { const command = opts.restart ? "hack restart" : "hack up"; return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features)}; this project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} and run the command again. Nothing was started or stopped.`, + message: `Native \`${command}\` does not support ${describeComposeBackendFeatures(opts.features, opts)}; this project runs on the Compose backend. ${COMPOSE_BACKEND_SELECTION} and run the command again. Nothing was started or stopped.`, detail: { backend: "compose", features: [...opts.features] }, }); } @@ -357,16 +404,16 @@ export function composeBackendRefusal(opts: { * needs the Compose backend. Call it after project selection and before any * runtime request, storage preparation, lifecycle hook or stop. */ -export async function refuseNativeComposeBackendFeatures(opts: { - readonly composeFile: string; - /** The `HACK_NATIVE_ADAPTATION` path native start applies, if any. */ - readonly adaptationFile?: string; - readonly restart: boolean; -}): Promise { - const features = await readComposeBackendFeatures(opts.composeFile, { - adaptationFile: opts.adaptationFile, - }); +export async function refuseNativeComposeBackendFeatures( + opts: { + readonly composeFile: string; + /** The `HACK_NATIVE_ADAPTATION` path native start applies, if any. */ + readonly adaptationFile?: string; + readonly restart: boolean; + } & ComposeBackendFeatureOptions +): Promise { + const features = await readComposeBackendFeatures(opts.composeFile, opts); if (features.length > 0) { - throw composeBackendRefusal({ features, restart: opts.restart }); + throw composeBackendRefusal({ ...opts, features }); } } diff --git a/tests/native-compose-backend-features-cli.test.ts b/tests/native-compose-backend-features-cli.test.ts index 0ef0e5a26..bf4e063c8 100644 --- a/tests/native-compose-backend-features-cli.test.ts +++ b/tests/native-compose-backend-features-cli.test.ts @@ -10,8 +10,10 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative, resolve } from "node:path"; -import { NATIVE_BUILDS_DISABLED_REFUSAL } from "../src/backends/native-project-builds.ts"; +import { nativeBuildsDisabledRefusal } from "../src/backends/native-project-builds.ts"; import { NATIVE_CONFIGURATION_REFUSAL } from "../src/backends/native-project-start.ts"; +import { PROJECT_ENV_KEY_FILENAME } from "../src/constants.ts"; +import { setProjectEnvValue } from "../src/lib/project-env-config.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -332,7 +334,7 @@ test( NATIVE_CONFIGURATION_REFUSAL, ], // A basic build stays refused unless native builds are explicitly enabled. - [{ build: ".." }, NATIVE_BUILDS_DISABLED_REFUSAL], + [{ build: ".." }, nativeBuildsDisabledRefusal("web")], ] as const) { const f = await fixture({ services: { web: service } }); const result = await cli(f, ["up"]); @@ -345,6 +347,86 @@ test( SPAWN_TIMEOUT_MS ); +/** Native builds enabled, with the usual shared-source grant. */ +const BUILDS_ENV = { HACK_NATIVE_SHARED_SOURCE: "1", HACK_NATIVE_BUILDS: "1" }; + +test( + "with native builds enabled, build args pass the check and reach native start", + async () => { + // HACK-1925: args are a native builds field, so the Compose-backend check + // passes and native start goes on to its own source preflight and runtime. + const f = await fixture({ + services: { + web: { + build: { context: "../app", args: { GREETING: "synthetic" } }, + }, + }, + }); + const result = await cli(f, ["up"], BUILDS_ENV); + expect(result.output).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); + expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); + expect(result.output).toMatch(RUNTIME_REQUEST_LINE); + const calls = await f.calls(); + expect(calls).toEqual( + ["runtime check-project-share", "runtime up"].slice(0, calls.length) + ); + expect(calls.length).toBeGreaterThan(0); + }, + SPAWN_TIMEOUT_MS +); + +test( + "with native builds enabled, a build arg equal to a managed secret refuses by name only", + async () => { + const secret = "synthetic-dummy-managed-value-1925"; + const f = await fixture({ + services: { + web: { build: { context: "../app", args: { GREETING: secret } } }, + }, + }); + await writeFile( + join(f.project, PROJECT_ENV_KEY_FILENAME), + "synthetic-project-key" + ); + await setProjectEnvValue({ + projectRoot: f.project, + projectDir: join(f.project, ".hack"), + envName: null, + scope: "global", + key: "FIXTURE_TOKEN", + value: secret, + secret: true, + }); + const result = await cli(f, ["up"], BUILDS_ENV); + expectOneClosedLine( + result, + "E_NATIVE_PROJECT_UNSUPPORTED: Native foreground up cannot build service web: build arg GREETING equals a hack env secret value. Build args are not secrets (images record them); pass secrets with BuildKit build secrets instead, which native builds do not support yet. Values were omitted; no hook, VM or build was started." + ); + expect(result.output).not.toContain(secret); + // The Compose backend would record the value in the image history too. + expect(result.output).not.toContain("HACK_RUNTIME_BACKEND=compose"); + expect(await f.calls()).toEqual(["runtime check-project-share"]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "with native builds enabled, a build field native builds lack still selects the Compose backend", + async () => { + await expectComposeBackendRefusal({ + compose: { + services: { + web: { build: { context: "../app", ssh: ["default"] } }, + }, + }, + env: BUILDS_ENV, + phrase: + "a Compose build field other than context, dockerfile, target and args, or image together with build", + }); + }, + SPAWN_TIMEOUT_MS +); + test( "control: a reviewed isolated network replacement and arm64 platform pass the check", async () => { diff --git a/tests/native-compose-backend-features.test.ts b/tests/native-compose-backend-features.test.ts index 34c766d22..b585f6910 100644 --- a/tests/native-compose-backend-features.test.ts +++ b/tests/native-compose-backend-features.test.ts @@ -166,6 +166,64 @@ test("builds follow the import preview's basic build path", () => { } }); +test("with native builds enabled, exactly the fields native builds implement pass", () => { + const enabled = { buildsEnabled: true } as const; + // HACK-1925: args and the other implemented fields reach native build admission, + // which reviews values (paths, interpolation, managed secrets) itself. + for (const build of [ + "..", + "/absolute/context", + { context: "../app", args: { GREETING: "synthetic" } }, + { context: "..", args: ["A=1"], dockerfile: "Dockerfile", target: "dev" }, + { context: "../../outside" }, + { args: { A: "${HOST_VALUE}" } }, + {}, + ]) { + expect(findComposeBackendFeatures(services({ build }), enabled)).toEqual( + [] + ); + } + for (const service of [ + { build: { context: "..", platforms: ["linux/amd64"] } }, + { build: { context: "..", ssh: ["default"] } }, + { build: { context: "..", secrets: ["token"] } }, + { build: { context: "..", args: { A: "1" }, cache_from: ["x"] } }, + { build: "..", image: IMAGE }, + ]) { + expect(findComposeBackendFeatures(services(service), enabled)).toEqual([ + "unsupported_build", + ]); + } + // Without native builds, today's basic-form classification is unchanged. + expect( + findComposeBackendFeatures( + services({ build: { context: "..", args: { A: "1" } } }) + ) + ).toEqual(["unsupported_build"]); +}); + +test("the build refusal phrase follows whether native builds are enabled", () => { + const features = ["unsupported_build"] as const; + expect(composeBackendRefusal({ features, restart: false }).message).toContain( + "does not support a Compose build outside the basic context, dockerfile and target form;" + ); + const enabled = composeBackendRefusal({ + features, + restart: false, + buildsEnabled: true, + }).message; + expect(enabled).toContain( + "does not support a Compose build field other than context, dockerfile, target and args, or image together with build;" + ); + expect( + composeBackendCheckResult({ + backend: "native", + features, + buildsEnabled: true, + }).message + ).toContain("a Compose build field other than context, dockerfile"); +}); + test("only the native linux/arm64 platform passes", () => { for (const platform of ["linux/arm64", "linux/arm64/v8"]) { expect( diff --git a/tests/native-project-builds.test.ts b/tests/native-project-builds.test.ts index c281fb319..9dc3c8f35 100644 --- a/tests/native-project-builds.test.ts +++ b/tests/native-project-builds.test.ts @@ -15,8 +15,8 @@ import { admitNativeProjectBuilds, buildNativeProjectImages, invokeNativeBuild, - NATIVE_BUILDS_DISABLED_REFUSAL, type NativeBuildRequest, + nativeBuildsDisabledRefusal, nativeBuildsEnabled, nativeServiceActive, refuseManagedSecretBuildArguments, @@ -45,7 +45,12 @@ function admit( service: Record, composeFile = "/p/.hack/compose.yaml" ) { - return admitNativeProjectBuild({ service, projectRoot: "/p", composeFile }); + return admitNativeProjectBuild({ + name: "app", + service, + projectRoot: "/p", + composeFile, + }); } test("admitted build forms follow Compose paths and literal arguments", () => { @@ -132,8 +137,14 @@ test.each([ })(); expect(error).toBeInstanceOf(HackCliError); expect(error).toMatchObject({ code: "E_NATIVE_PROJECT_UNSUPPORTED" }); - expect(String((error as Error).message)).toContain(text); - expect(String((error as Error).message)).not.toContain("HOST_VALUE"); + const message = String((error as Error).message); + expect(message).toContain(text); + // HACK-1927: every admission refusal names the service it is about. + expect(message).toStartWith( + "Native foreground up cannot build service app: " + ); + expect(message).not.toContain("HOST_VALUE"); + expect(message).not.toContain("HACK_RUNTIME_BACKEND"); }); test("builds refuse unless explicitly enabled, and projects without builds are unaffected", () => { @@ -145,7 +156,10 @@ test("builds refuse unless explicitly enabled, and projects without builds are u composeFile: "/p/compose.yaml", enabled: false, }) - ).toThrow(NATIVE_BUILDS_DISABLED_REFUSAL); + ).toThrow(nativeBuildsDisabledRefusal("web")); + expect(nativeBuildsDisabledRefusal("web")).toContain( + "cannot build service web: " + ); expect( admitNativeProjectBuilds({ services: { db: { image: "postgres:17" } }, @@ -367,10 +381,14 @@ test("a build arg equal to a managed secret value refuses without showing it", ( expect(refusal).toBeInstanceOf(HackCliError); const error = refusal as HackCliError; expect(error.code).toBe("E_NATIVE_PROJECT_UNSUPPORTED"); + // HACK-1925: the refusal names the service and the argument, never the value, + // and does not point at the Compose backend, which would bake it into history. expect(error.message).toContain( - "build arg TOKEN equals a hack env secret value" + "cannot build service web: build arg TOKEN equals a hack env secret value" ); expect(error.message).not.toContain(secret); + expect(error.message).not.toContain("HACK_RUNTIME_BACKEND"); + expect(error.message).not.toContain("Compose backend"); // Values that are not managed secrets pass; so does any build with no secrets. refuseManagedSecretBuildArguments({ builds: new Map([["web", request({ MODE: "production" })]]), @@ -460,3 +478,57 @@ test("cancellation sends SIGTERM so the runtime can stop its build client", asyn expect(String(error)).toContain("canceled during an image build"); expect(await readFile(`${fake.binary}.signal`, "utf8")).toBe("terminated"); }); + +test("runtime build refusals name the services and the build source policy", async () => { + const project = await root(); + const policy = + "Dockerfile line 1: build images must be public Docker Hub references."; + const fail = (nativeCode: string, message: string) => async () => { + throw new NativeRuntimeRequestError({ message, nativeCode }); + }; + const run = async (invoke: () => Promise) => + await buildNativeProjectImages({ + runtime, + projectRoot: project, + branch: null, + profiles: [], + // Identical declarations build once, so one refusal covers both services. + builds: new Map([ + ["web", request()], + ["worker", request()], + ]), + specs: { web: { build: "app" }, worker: { build: "app" } }, + retainedImages: new Map(), + restore: false, + rebuild: false, + signal: new AbortController().signal, + invoke, + }).catch((caught: unknown) => caught); + + const refused = await run(fail("native_build_base_image", policy)); + expect(refused).toBeInstanceOf(NativeRuntimeRequestError); + expect(refused).toMatchObject({ nativeCode: "native_build_base_image" }); + const message = (refused as Error).message; + expect(message).toStartWith( + "Native foreground up cannot build services web and worker: the native build source policy refused it. " + ); + expect(message).toContain(policy); + expect(message).toContain("Use public Docker Hub images"); + expect(message).not.toContain(VALUE); + + // A failure that is not a policy refusal names the services and keeps its message. + const failed = await run( + fail("native_build_failed", "The image build failed.") + ); + expect(failed).toMatchObject({ nativeCode: "native_build_failed" }); + expect((failed as Error).message).toBe( + "Native foreground up could not build services web and worker: The image build failed." + ); + expect((failed as Error).message).not.toContain("source policy"); + + // An inherited property name is not a policy code. + const odd = await run(fail("constructor", "Unexpected failure.")); + expect((odd as Error).message).toBe( + "Native foreground up could not build services web and worker: Unexpected failure." + ); +}); From 53b9522e5868c2c340d2a200aa51af00c395c457 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:11:50 -0500 Subject: [PATCH 118/138] test: give Compose config cases a budget above their child timeout Each native-compose-renderer-config case runs the real compiler and `docker compose config`, and the helper allows that child 15 seconds, but the cases used bun's 5 second default. Loaded CI runners hit the default twice. Give every case a 30 second budget. Co-Authored-By: Claude Opus 5.5 --- tests/native-compose-renderer-config.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/native-compose-renderer-config.test.ts b/tests/native-compose-renderer-config.test.ts index b20376a48..b20d3275e 100644 --- a/tests/native-compose-renderer-config.test.ts +++ b/tests/native-compose-renderer-config.test.ts @@ -7,8 +7,16 @@ import { mapLegacyNativeImport } from "../src/lib/native-config-import-plan.ts"; import { stringAdoptionWorkerSources } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; import { composeFixture } from "./helpers/native-compose.ts"; -const configTest = +/** + * Each case runs the real compiler and `docker compose config`, and + * `normalized` allows the child 15 seconds, so the case budget must exceed + * that instead of using bun's 5 second default. + */ +const COMPOSE_CONFIG_TEST_TIMEOUT_MS = 30_000; +const runConfigTest = process.env.HACK_TEST_COMPOSE_CONFIG === "1" ? test : test.skip; +const configTest = (name: string, fn: () => Promise) => + runConfigTest(name, fn, COMPOSE_CONFIG_TEST_TIMEOUT_MS); const compiler = resolveTestConfigCompilerBinary(); /** Uses configuration normalization only: no daemon connection, container start, pull or build. */ From 9f33a1d01c4c5a68dd5d4c253937f79ad6863091 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:20:03 -0500 Subject: [PATCH 119/138] docs(native): describe the orphaned build client as fenced in model coverage The native-build-retention coverage row and model README said the fence is released with its holder when the coordinator dies. Since #606 the build client inherits the fence and per-key lock, so both stay held until an orphaned client exits; the model still does not cover that case. Co-Authored-By: Claude Opus 5.5 --- docs/reference/v5-model-coverage.md | 2 +- tests/models/tla/README.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 973afee48..2db9b9fb8 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -44,7 +44,7 @@ must be attached to the eventual exact integration revision. | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | | Dependency rebind, incomplete initializer, terminal reuse | dependency-rebind, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | -| Native image builds: unleased build versus engine stop, crash mid-build, storage recreation, foreign or forged images, reclamation versus retained graph references | native-build-retention | `provider::image_build` (shared build fence, per-key lock, incarnation/boot/engine recheck and label check at adoption, dead-attempt recording) and `image_build::reclaim` (owned labels, current receipts, retained graph state scan, engine non-force removal); `image_build` regressions | One key and namespace; an orphaned client after a host crash is not modeled (the fence is released with its holder); real BuildKit, engine removal semantics and guest storage are host evidence. | +| Native image builds: unleased build versus engine stop, crash mid-build, storage recreation, foreign or forged images, reclamation versus retained graph references | native-build-retention | `provider::image_build` (shared build fence, per-key lock, incarnation/boot/engine recheck and label check at adoption, dead-attempt recording) and `image_build::reclaim` (owned labels, current receipts, retained graph state scan, engine non-force removal); `image_build` regressions | One key and namespace; a build client orphaned by a killed `hack-native` is not modeled (the client inherits the build fence and per-key lock, so both stay held until it exits: `image_build::tests::a_killed_coordinator_leaves_its_client_fenced`); real BuildKit, engine removal semantics and guest storage are host evidence. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | | Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | | Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. | diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index 2ebe1b8f4..f9b3f87ee 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -1070,6 +1070,7 @@ Three controls each remove one guard and must fail at the named action: `negative` (reclaim ignores retained references) fails `NoReclaimReferenced` at `Reclaim`; `foreign-adoption` (adoption without the label/incarnation check) fails `NoForeignAdoption` at `CompleteBuild`; `down-under-build` (down ignores the fence) -fails `DownNotUnderBuild` at `Down`. The model does not cover an orphaned build client -that outlives a crashed host process, real BuildKit or engine behavior, receipt -durability, or more than one key and namespace. +fails `DownNotUnderBuild` at `Down`. The model does not cover a build client that +outlives a killed `hack-native` process (the implementation keeps the fence and key +lock held by the inherited descriptors until that client exits), real BuildKit or +engine behavior, receipt durability, or more than one key and namespace. From a0e3e797b182edc67e1f66046943e0e84fede96a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:35:24 -0500 Subject: [PATCH 120/138] test(native): make the build client relay tests independent of concurrent relays The abandoned-relay test built its fixtures in the shared temporary directory, where any concurrently starting relay's sweep could remove a fixture between its creation and its socket bind. Fixtures now live under a per-test root that no other relay sweeps, and the relay under test starts there (ClientRelay::start_in, which start uses with the temporary directory). The test also checks that the abandoned fixture existed before the sweep. The refused-provider test treats EINVAL from setting the read timeout as the closed connection it expects: macOS refuses socket options once the relay has already closed the connection. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/image_build/client_relay.rs | 16 +++++-- .../image_build/client_relay/tests.rs | 47 +++++++++++++++---- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/runtime-core/src/provider/image_build/client_relay.rs b/packages/runtime-core/src/provider/image_build/client_relay.rs index 564f38761..446d42897 100644 --- a/packages/runtime-core/src/provider/image_build/client_relay.rs +++ b/packages/runtime-core/src/provider/image_build/client_relay.rs @@ -102,7 +102,13 @@ impl ClientRelay { /// Connecting to `provider` happens per client connection, so starting never touches /// the engine. pub(super) fn start(provider: &Path) -> Result { - let directory = private_directory()?; + Self::start_in(temporary_root(), provider) + } + + /// [`Self::start`] with its private directory (and the sweep of abandoned ones) under + /// `root` instead of the temporary directory; tests give each relay its own root. + fn start_in(root: &Path, provider: &Path) -> Result { + let directory = private_directory(root)?; let socket = directory.join(SOCKET_NAME); let shared = Arc::new(Shared { stop: AtomicBool::new(false), @@ -198,10 +204,10 @@ fn temporary_root() -> &'static Path { } } -/// A new mode-0700 directory with a short path, owned by this user. Its name carries -/// this process's PID, so a later relay can remove what a killed process left behind. -fn private_directory() -> Result { - let parent = temporary_root(); +/// A new mode-0700 directory with a short path under `parent`, owned by this user. Its +/// name carries this process's PID, so a later relay can remove what a killed process +/// left behind. +fn private_directory(parent: &Path) -> Result { remove_abandoned(parent); for _ in 0..8 { let mut bytes = [0_u8; 6]; diff --git a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs index 79edcaa26..9c6acafd0 100644 --- a/packages/runtime-core/src/provider/image_build/client_relay/tests.rs +++ b/packages/runtime-core/src/provider/image_build/client_relay/tests.rs @@ -11,7 +11,7 @@ struct Provider { } impl Provider { fn new() -> Self { - let directory = private_directory().unwrap(); + let directory = private_directory(temporary_root()).unwrap(); let listener = UnixListener::bind(directory.join("provider.sock")).unwrap(); Self { directory, @@ -121,9 +121,14 @@ fn a_refused_provider_connection_ends_only_that_client_connection() { drop(provider); let relay = ClientRelay::start(&path).unwrap(); let mut client = UnixStream::connect(relay.socket()).unwrap(); - client - .set_read_timeout(Some(Duration::from_secs(10))) - .unwrap(); + // The relay may already have closed this connection (its provider connect failed at + // once); macOS then refuses socket options with EINVAL. That is the outcome under + // test, and the read below returns at once. + match client.set_read_timeout(Some(Duration::from_secs(10))) { + Ok(()) => {} + Err(error) if error.raw_os_error() == Some(libc::EINVAL) => {} + Err(error) => panic!("unexpected socket option failure: {error}"), + } let mut bytes = Vec::new(); // The client sees the connection end, as with an absent engine socket. let _ = client.read_to_end(&mut bytes); @@ -163,10 +168,12 @@ fn a_relay_left_by_a_killed_process_is_removed_and_a_live_one_is_kept() { .arg("30") .spawn() .unwrap(); - // `other` is written before the socket, so a relay starting in a concurrent test never - // sees that directory holding only a socket. + // Fixtures live under this test's own root, which no other relay sweeps (concurrent + // tests sweep the temporary directory itself), so only the relay started below can + // remove or keep them. + let root = FixtureRoot::new(); let make = |pid: u32, other: bool| { - let directory = temporary_root().join(format!("{DIRECTORY_PREFIX}{pid}-0123456789ab")); + let directory = root.0.join(format!("{DIRECTORY_PREFIX}{pid}-0123456789ab")); fs::DirBuilder::new() .mode(0o700) .create(&directory) @@ -181,15 +188,35 @@ fn a_relay_left_by_a_killed_process_is_removed_and_a_live_one_is_kept() { let live = make(running.id(), false); // A directory holding anything besides the socket is never removed. let kept = make(dead + 1_000_000, true); - let relay = ClientRelay::start(Path::new("/nonexistent/docker.sock")).unwrap(); + assert!(abandoned.join(SOCKET_NAME).exists()); + let relay = ClientRelay::start_in(&root.0, Path::new("/nonexistent/docker.sock")).unwrap(); + assert!(relay.socket().starts_with(&root.0)); assert!(!abandoned.exists()); assert!(live.join(SOCKET_NAME).exists()); assert!(kept.join("other").exists()); relay.finish(FINISH_GRACE); running.kill().unwrap(); running.wait().unwrap(); - for directory in [live, kept] { - let _ = fs::remove_dir_all(directory); +} + +/// A fresh private directory for one test's relay fixtures, removed when dropped. Its name +/// does not carry the relay prefix, so no relay sweep ever matches it. +struct FixtureRoot(PathBuf); +impl FixtureRoot { + fn new() -> Self { + let mut bytes = [0_u8; 6]; + File::open("/dev/urandom") + .and_then(|mut random| random.read_exact(&mut bytes)) + .unwrap(); + let name: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let root = temporary_root().join(format!("hkt-{}-{name}", std::process::id())); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + Self(root) + } +} +impl Drop for FixtureRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); } } From 24b5c704c1d58aace37bb9ceef8e2de6ce3d3e2e Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:57:18 -0500 Subject: [PATCH 121/138] fix(native): start restart-policy services with ports or routes without the policy Route and loopback port bridges are bound to one container incarnation: the guest relay pins the original process's network namespace and exits with it, so an engine restart would leave the port or route dead. Until the owner can rebind them per incarnation (HACK-1935), a routed service or a service with published ports starts with the engine policy `no` instead of being refused. - ServicePlan::restart_policy_not_applied is the single rule. Review adds the warning `restart_policy_not_applied` at services..restart (appended after the service's own findings, so other plan identities are unchanged), and graph admission returns no policy for it, so the receipt carries none: no restart authority, no `restarting` state. - The published-ports refusal and the routed-service refusal are gone. Receipt load still rejects a policy next to a route or loopback port, which the planner never records. - Foreground `up` prints one value-free line per affected active service, naming the service and its reviewed policy. - Unpublished, unrouted services keep their engine restart policy. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 21 ++- docs/reference/v5-model-coverage.md | 2 +- .../src/project/compose/service.rs | 12 ++ packages/runtime-core/src/project/mod.rs | 9 ++ .../runtime-core/src/provider/graph/config.rs | 1 - .../src/provider/graph/restart_policy.rs | 96 ++++++++---- .../runtime-core/src/provider/graph/tests.rs | 147 ++++++++++++++---- src/backends/native-project-start.ts | 2 + src/backends/native-restart-policy-notices.ts | 67 ++++++++ .../tla/service-restart-policy/README.md | 2 +- tests/native-project-start.test.ts | 38 +++++ tests/native-restart-policy-notices.test.ts | 56 +++++++ 12 files changed, 382 insertions(+), 71 deletions(-) create mode 100644 src/backends/native-restart-policy-notices.ts create mode 100644 tests/native-restart-policy-notices.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 712a08c46..cc39587df 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -822,12 +822,21 @@ the engine waits to start the next incarnation, `hack ps` reports the service as `restarting`, and it satisfies no readiness goal. A policy-restarted container keeps its container ID but gets a new start time, so Hack treats it as a new incarnation: any proof bound to the previous one refuses rather than adopting it. -For that reason the graph refuses a restart policy, with `graph_restart_policy`, -on routed services, services with published `ports`, services with a native HTTP -probe, services with host dependencies, services that receive managed -environment values (delivery is single-use and time-bounded) and -dependency-cache initializers. Remove the `restart` key from those services, or -keep them on the Compose backend. + +The port and route bridges that serve published `ports` and local HTTPS routes +are bound to one incarnation and cannot yet follow a restart (HACK-1935). A +routed service, or a service with published `ports`, therefore starts without its +restart policy: the container runs with the engine policy `no`, and native +review reports `restart_policy_not_applied` for it. Foreground `up` prints one +line per affected service before startup, for example `Service web: restart: +unless-stopped is not applied. Restart policies are not applied yet to services +with published ports or routes (HACK-1935); the service starts without automatic +restarts.` Other services in the same project keep their policy. For the same +reason the graph refuses a restart policy, with `graph_restart_policy`, on other +services with a native HTTP probe, services with host dependencies, services +that receive managed environment values (delivery is single-use and +time-bounded) and dependency-cache initializers. Remove the `restart` key from +those services, or keep them on the Compose backend. Every owned stop first sets the engine policy back to `no` and confirms that with a fresh inspection, then requests the stop. A restart that was already pending is diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 3c432e76b..14d4d7c1e 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -45,7 +45,7 @@ must be attached to the eventual exact integration revision. | Graph cleanup container stop batch: bounded in-flight stops, shared worker queue, connect-stage retry, batch-wide unreachable state, deadline expiry, worker panic | **Coverage gap:** no maintained model covers scheduling inside the stop batch; native-route-stop covers a different stop protocol | `dispatch` and `attempt` in [the stop batch](../../packages/runtime-core/src/provider/engine/stop.rs): at most 4 stops in flight, every worker joined; only `connect`/`connect_timeout` failures retry (25 ms doubling to 500 ms, within the batch deadline and 30 s of a stop's first connect failure); once one stop exhausts its window, later connect failures return at once; a deadline reached during backoff keeps the connect stage; `response`, `timeout` and `transport` never retry. Regressions in the same file's `provider::engine::stop` tests (bounded in-flight socket server, late socket, missing socket, 14-stop retry counts, persistent connect failures, oversleeping backoff, non-retried stages, worker panic) and `fourteen_service_connect_failures_keep_resumable_progress_and_retry_only_unstopped` | Tests cover exact attempt counts and the in-flight bound for fixed interleavings; no model explores all worker schedules or proves at most one delivered request per stop. Assumes a failed connect sent no request bytes and that the stop budget covers fully serialized grace periods. Graph-level resume after a partial batch is covered by tests only. Host qualification of the descriptor-pressure root cause (HACK-1302) remains open. | | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | | Shared host helper instance: overlapping ups, session adoption, stale create decisions, replacement before finalize, epoch-record failure, interrupted cleanup, concurrent down, displaced launches, failed down session stop | shared-helper-instance | `src/lib/project-shared-helpers.ts` (shutdown generation, `assertCurrent`, `SharedHelperSessionOwnership`); `src/lib/project-lifecycle-controller.ts` session decision, the locked creation transaction (revalidate, publish, create, environment, record, stop on failure, all under one lock), launches fenced by the lock and a token check, abort and signal cleanup through the same epoch decision (`runIfOwned`), an unknown exact-presence answer failing the inspection, and the locked conditional finalize; `stopSessionBeforeReleasingHelpers` (down, restart, native retirement keep holders when the session stop fails); `runMuxCommand` bounds every lifecycle and backend mux call so it settles before the lock is released, and reports a timeout as unknown (never absence); `tests/mux-command-timeout.test.ts`; `tests/project-lifecycle-controller-ownership.test.ts`, `tests/project-shared-helpers.test.ts`, `tests/native-project-down.test.ts`, e2e `shared-host-helpers` | Helper holder/rollback and claim protocol are not modeled (claim fixtures cover them); timeout settlement is an assumption the code does not guarantee (a killed tmux client's queued command may still apply, so a late kill or window command can reach a newer session; consequences and the session-ID fence, not in this slice, are in the README); zellij is refused for shared-helper lifecycle sessions (token written after creation); the gap between a down's session stop and its release (an up that captured after the stopping mark can still succeed there) is a documented first-slice limitation; real mux/process/lock-file behavior. | -| Engine restart policies: stop versus pending restart, guest engine restart, stale incarnations, terminal proof | service-restart-policy | Receipt-admitted policy, disarm-before-stop and `restarting` stop preflight in `shutdown.rs`; admission refusals for incarnation-bound service classes in `restart_policy.rs`; Rust unit tests for admission, encoding, disarm and stop preflight | Real guest engine behavior (crash restart, stop during backoff, disarm before deletion) needs native host evidence; several services, backoff timing and retry counting are not modeled. | +| Engine restart policies: stop versus pending restart, guest engine restart, stale incarnations, terminal proof | service-restart-policy | Receipt-admitted policy, disarm-before-stop and `restarting` stop preflight in `shutdown.rs`; admission refusals for incarnation-bound service classes in `restart_policy.rs`, with routed and port-published services started without their policy (`restart_policy_not_applied`); Rust unit tests for admission, encoding, disarm and stop preflight | Real guest engine behavior (crash restart, stop during backoff, disarm before deletion) needs native host evidence; several services, backoff timing and retry counting are not modeled. Restart policies for routed and port-published services wait on bridge rebinding (HACK-1935). | | Foreground Ctrl-C down hooks: concurrent `down`, second interrupt, crash between or during hooks, stop during `up.after`, next generation | native-foreground-down-hooks | Value-free compare-and-swap journal in [down hooks](../../src/backends/native-project-down-hooks.ts), foreground orchestration in [foreground stop](../../src/backends/native-project-foreground-stop.ts) and `hack down` in [native down](../../src/backends/native-project-down.ts); [journal](../../tests/native-project-down-hooks.test.ts), [foreground](../../tests/native-project-start-stop-hooks.test.ts) and [down](../../tests/native-project-down-journal.test.ts) regressions | Durable writes are atomic in the model; real fsync/power loss, a stale journal lock after a crash inside the lock, and the live process-group behavior of the graph owner on a host are not covered. One generation and at most two `down` invocations are explored. | | Dependency rebind, automatic refresh, incomplete initializer, terminal reuse | dependency-rebind, dependency-auto-refresh, stopped-pool-startup, restore-history | Dependency claim/readiness, saved execution, restore history and source convergence tests | Actual installer failure, cache corruption, retained application state and resource plateau. | | Private environment allocation lifetime: staged values and private file members, crash between record and stage, retirement with the container | **Coverage gap:** no maintained model covers environment or credential delivery; file members share the existing allocation, intent record and retirement, so the state machine is unchanged | `packages/runtime-core/src/provider/environment.rs` (preparation bounds, staging layout, verification), `managed_environment.rs` (envelope v2 refusals, forwarding), `graph/launcher.rs` (read-only binds, overlap refusals) and `environment_recovery.rs` retirement tests | Live VM evidence that only the owning UID reads a member, writes fail read-only and the member is absent after cleanup; refresh or rotation of delivered files is not implemented. | diff --git a/packages/runtime-core/src/project/compose/service.rs b/packages/runtime-core/src/project/compose/service.rs index 926c837ca..f0c2621bf 100644 --- a/packages/runtime-core/src/project/compose/service.rs +++ b/packages/runtime-core/src/project/compose/service.rs @@ -1054,6 +1054,18 @@ pub(super) fn compile( user:m.get("user").map(|v| match v {Value::String(s)=>redacted(s),Value::Number(n)=>redacted(&n.to_string()),_=>Err(problem("invalid_user","User must be a string or numeric identifier."))}).transpose()?, platform,amd64_emulation,labels,routing,dependency_cache,one_shot,inert_route_labels,interactive,logging,stop_grace_period_nanos:duration(m.get("stop_grace_period"))?,stop_signal,capabilities, }); + // Appended after the service's own findings, so plans without this case + // keep their identity. Graph admission applies the same rule. + if result + .get(name.as_str()) + .is_some_and(|service| service.active && service.restart_policy_not_applied()) + { + diagnostics.push(Diagnostic::warning( + "restart_policy_not_applied", + &format!("{field}.restart"), + "Restart policies are not applied yet to services with published ports or routes (HACK-1935); the service starts without automatic restarts.", + )); + } } Ok(result) } diff --git a/packages/runtime-core/src/project/mod.rs b/packages/runtime-core/src/project/mod.rs index a22a633a9..f155f7a73 100644 --- a/packages/runtime-core/src/project/mod.rs +++ b/packages/runtime-core/src/project/mod.rs @@ -341,6 +341,15 @@ pub struct ServicePlan { #[serde(default, skip_serializing_if = "Option::is_none")] pub capabilities: Option, } +impl ServicePlan { + /// A declared restart policy that native starts without (engine policy `no`): + /// published port bridges and route relays are bound to one container + /// incarnation, so they cannot yet follow an engine restart (HACK-1935). + /// Review warns with `restart_policy_not_applied`; graph admission applies it. + pub fn restart_policy_not_applied(&self) -> bool { + self.restart != "no" && (self.routing.is_some() || !self.ports.is_empty()) + } +} /// Normalized Compose capability adjustments (`CAP_` names, or `ALL` in `drop`). /// Lowering applies Docker's precedence; see [`capabilities::effective`]. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index d625919b0..9e8323dff 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -265,7 +265,6 @@ pub(super) fn prepare_delivery( service, ready: graph.services[name].ready, managed_environment: inputs.service_requires_managed_environment(name), - published_ports: publications.contains_key(name), host_dependencies: !service.extra_hosts.is_empty() || inputs .services diff --git a/packages/runtime-core/src/provider/graph/restart_policy.rs b/packages/runtime-core/src/provider/graph/restart_policy.rs index 126cd3132..d11f0cee1 100644 --- a/packages/runtime-core/src/provider/graph/restart_policy.rs +++ b/packages/runtime-core/src/provider/graph/restart_policy.rs @@ -14,11 +14,13 @@ //! - Incarnation identity stays container ID plus `StartedAt`. A restart keeps //! the ID and changes `StartedAt`, so every existing incarnation-bound proof //! (endpoint generations, relay targets, completed-generation records) refuses -//! a restarted container instead of silently adopting it. Services whose -//! publication or delivery is bound to one incarnation (routes, loopback port -//! publications, HTTP probes, host dependencies, managed environment delivery, -//! dependency caches) are refused at admission until they can republish per -//! incarnation. +//! a restarted container instead of silently adopting it. Routed services and +//! services with published ports start without the policy (engine `no`, no +//! receipt policy) and review warns with `restart_policy_not_applied`, until +//! their bridges can follow a new incarnation (HACK-1935). Services whose +//! probe or delivery is bound to one incarnation (HTTP probes, host +//! dependencies, managed environment delivery, dependency caches) are refused +//! at admission. use super::{CandidateError, Kind, Resource, error, hex}; use crate::project::{ServicePlan, execution::Condition}; use serde::{Deserialize, Serialize}; @@ -140,14 +142,14 @@ pub(super) struct Admission<'a> { pub service: &'a ServicePlan, pub ready: Condition, pub managed_environment: bool, - /// The service owns admitted loopback port publications. - pub published_ports: bool, pub host_dependencies: bool, } -/// Admit a declared policy for one active service, or refuse it with a fixed, -/// value-free reason. Admission never weakens an incarnation-bound guard: those -/// service classes refuse instead of running without their guard. +/// Admit a declared policy for one active service, start it without one, or +/// refuse it with a fixed, value-free reason. Admission never weakens an +/// incarnation-bound guard: a routed or port-published service runs without +/// the policy (its bridge is then never asked to follow a restart), and the +/// other incarnation-bound classes refuse. pub(super) fn admit(admission: &Admission<'_>) -> Result, CandidateError> { let Some(policy) = RestartPolicy::parse(&admission.service.restart)? else { return Ok(None); @@ -164,15 +166,10 @@ pub(super) fn admit(admission: &Admission<'_>) -> Result, "Dependency cache initializers cannot use an automatic restart policy.", )); } - if service.routing.is_some() { - return Err(refused( - "Routed services cannot yet use an automatic restart policy: route publication is bound to one container incarnation.", - )); - } - if admission.published_ports { - return Err(refused( - "Services with published ports cannot yet use an automatic restart policy: the port bridge is bound to one container incarnation.", - )); + // Route and port bridges are bound to one incarnation (HACK-1935): the + // service starts with engine policy `no`, and review warned about it. + if service.restart_policy_not_applied() { + return Ok(None); } if service .healthcheck @@ -392,7 +389,6 @@ mod tests { service, ready, managed_environment, - published_ports: false, host_dependencies, }) } @@ -439,24 +435,11 @@ mod tests { let plain = service("unless-stopped"); assert!(admit_with(&plain, Condition::Started, true, false).is_err()); assert!(admit_with(&plain, Condition::Started, false, true).is_err()); - // A loopback port bridge is bound to one incarnation, like a route. - let published = |service: &ServicePlan| { - admit(&Admission { - service, - ready: Condition::Started, - managed_environment: false, - published_ports: true, - host_dependencies: false, - }) - }; - assert_eq!(published(&plain).unwrap_err().code, "graph_restart_policy"); - assert_eq!(published(&service("no")).unwrap(), None); let mut routed = plain.clone(); routed.routing = Some( serde_json::from_value(json!({"hostnames":["worker.example.test"],"port":3000})) .unwrap(), ); - assert!(admit_with(&routed, Condition::Healthy, false, false).is_err()); let mut probed = plain.clone(); probed.healthcheck = Some( serde_json::from_value( @@ -475,7 +458,7 @@ mod tests { assert!(admit_with(&cache, Condition::Completed, false, false).is_err()); // The same classes without a restart policy are unaffected. for (mut plan, ready) in [ - (routed, Condition::Healthy), + (routed.clone(), Condition::Healthy), (probed, Condition::Healthy), (cache, Condition::Completed), ] { @@ -490,6 +473,51 @@ mod tests { } } + #[test] + fn routed_and_port_published_services_start_without_their_policy() { + let mut routed = service("unless-stopped"); + routed.routing = Some( + serde_json::from_value(json!({"hostnames":["worker.example.test"],"port":3000})) + .unwrap(), + ); + let mut published = service("always"); + published.ports = vec![ + serde_json::from_value(json!({"target":3000,"published":8787,"protocol":"tcp", + "declared_host_ip":"127.0.0.1","proposed_host_ip":"127.0.0.1"})) + .unwrap(), + ]; + for plan in [&routed, &published] { + assert!(plan.restart_policy_not_applied()); + // Their bridge never has to follow a restart, so the guards that + // otherwise refuse (managed delivery, host dependencies) do not apply. + for (managed, hosts) in [(false, false), (true, false), (false, true)] { + assert_eq!( + admit_with(plan, Condition::Healthy, managed, hosts).unwrap(), + None + ); + } + } + // A conditional retry of a completion goal still refuses an unconditional + // policy first, whatever else the service declares. + assert_eq!( + admit_with(&published, Condition::Completed, false, false) + .unwrap_err() + .code, + "graph_restart_policy" + ); + // Without ports or a route the policy is admitted as before. + let mut plain = published.clone(); + plain.ports.clear(); + assert!(!plain.restart_policy_not_applied()); + assert_eq!( + admit_with(&plain, Condition::Healthy, false, false).unwrap(), + Some(RestartPolicy::Always) + ); + let mut declared_no = published; + declared_no.restart = "no".into(); + assert!(!declared_no.restart_policy_not_applied()); + } + #[test] fn disarm_requires_receipt_admission_for_any_engine_policy() { let admitted = resource(Some(RestartPolicy::UnlessStopped)); diff --git a/packages/runtime-core/src/provider/graph/tests.rs b/packages/runtime-core/src/provider/graph/tests.rs index 46ceb5d9b..d62900b92 100644 --- a/packages/runtime-core/src/provider/graph/tests.rs +++ b/packages/runtime-core/src/provider/graph/tests.rs @@ -587,6 +587,16 @@ fn receipt_restart_authority_excludes_incarnation_bound_publication() { false, Some(&Condition::Started) )); + // The planner never records a policy next to a route either. + let mut routed = container.clone(); + routed.routing = Some( + serde_json::from_value(json!({"hostnames":["worker.example.test"],"port":3000})).unwrap(), + ); + assert!(!restart_policy_valid( + &routed, + false, + Some(&Condition::Started) + )); // Publication without a policy, and the existing refusals, are unchanged. published.restart_policy = None; assert!(restart_policy_valid( @@ -3197,34 +3207,6 @@ fn loopback_ports_are_admitted_only_for_the_foreground_owner() { // Publication is an owned host effect, never an engine port binding. let host = &prepared.configs["web"]["HostConfig"]; assert!(host.get("PortBindings").is_none() && host.get("PublishAllPorts").is_none()); - // A published port bridge is bound to one container incarnation, so a - // published service cannot yet use an engine restart policy. Without - // loopback publication the ports refusal is unchanged; an unpublished - // service in the same graph keeps its policy. - let mut restarting = published.clone(); - restarting["services"]["web"]["restart"] = json!("unless-stopped"); - assert_eq!( - prepare(&restarting, true).err().map(|e| e.code), - Some("graph_restart_policy") - ); - assert_eq!( - prepare(&restarting, false).err().map(|e| e.code), - Some("graph_subset") - ); - let mut sibling = published.clone(); - sibling["services"]["init"]["restart"] = json!("on-failure:2"); - let prepared_sibling = prepare(&sibling, true).unwrap(); - assert_eq!( - prepared_sibling.resources["container:init"].restart_policy, - Some(restart_policy::RestartPolicy::OnFailure { - maximum_retry_count: 2 - }) - ); - assert!( - prepared_sibling.resources["container:web"] - .restart_policy - .is_none() - ); for (ports, check) in [ (json!(["127.0.0.1:8787:3000/udp"]), "web"), (json!(["[::1]:8787:3000"]), "web"), @@ -3269,6 +3251,115 @@ fn loopback_ports_are_admitted_only_for_the_foreground_owner() { assert!(!candidate.state_root.exists()); } +#[test] +fn restart_policy_is_not_applied_to_published_or_routed_services() { + let fixture = Fixture::new(); + let candidate_root = Fixture::new(); + let candidate = Candidate::discover(&candidate_root.0).unwrap(); + let image = format!("sha256:{}", "a".repeat(64)); + let review = |document: &Value| { + state::write(&fixture.0.join("compose.yaml"), document).unwrap(); + project::plan(&candidate, fixture.options()).unwrap() + }; + let prepare = |document: &Value, options: config::DeliveryOptions| { + let review = review(document); + let inputs = project::inputs::compile( + &candidate, + fixture.options(), + &review.plan_id, + &BTreeMap::new(), + ) + .unwrap(); + config::prepare_delivery( + inputs, + &goals(), + &"a".repeat(32), + &"b".repeat(32), + None, + options, + ) + .unwrap() + }; + let mut published = compose(&image, "marker", false); + published["services"]["web"]["ports"] = json!(["127.0.0.1:8787:3000"]); + let mut routed = compose(&image, "marker", false); + routed["services"]["web"]["labels"] = json!({"caddy":"web.example.test", + "caddy.reverse_proxy":"{{upstreams 3000}}","caddy.tls":"internal"}); + // Routes require the native HTTP probe; a routed restart policy would also + // have been refused for the probe, so this proves the route degrade runs first. + routed["services"]["web"]["healthcheck"] = json!({"x-hack-http":{"port":3000, + "path":"/health","interval_ms":100,"timeout_ms":500,"retries":3,"start_period_ms":0}}); + // Lowering a route's native HTTP probe needs the native-http-probe build + // (CI tests all features); review runs in every build. + for (mut document, options, lowered) in [ + ( + published, + config::DeliveryOptions { + loopback_ports: true, + ..Default::default() + }, + true, + ), + ( + routed, + config::DeliveryOptions { + routing_enrolled: true, + ..Default::default() + }, + cfg!(feature = "native-http-probe"), + ), + ] { + document["services"]["web"]["restart"] = json!("unless-stopped"); + // An unpublished, unrouted service in the same graph keeps its policy. + document["services"]["init"]["restart"] = json!("on-failure:2"); + let reviewed = review(&document); + assert!(reviewed.plan.enrollment_compatible); + let warnings = reviewed + .plan + .diagnostics + .iter() + .filter(|d| d.code == "restart_policy_not_applied") + .collect::>(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].severity, "warning"); + assert_eq!(warnings[0].field, "services.web.restart"); + assert!(warnings[0].message.contains("HACK-1935")); + if !lowered { + continue; + } + let prepared = prepare(&document, options); + // The bridged service starts with the engine's `no` and no receipt + // policy, so no restart authority or `restarting` state is admitted. + assert_eq!( + prepared.configs["web"]["HostConfig"]["RestartPolicy"], + json!({"Name":"no"}) + ); + let web = &prepared.resources["container:web"]; + assert!(web.restart_policy.is_none()); + assert!(!restart_policy::admits_restarting(web)); + assert_eq!( + prepared.resources["container:init"].restart_policy, + Some(restart_policy::RestartPolicy::OnFailure { + maximum_retry_count: 2 + }) + ); + assert_eq!( + prepared.configs["init"]["HostConfig"]["RestartPolicy"], + json!({"Name":"on-failure","MaximumRetryCount":2}) + ); + // Declaring `no` produces no warning. + document["services"]["web"]["restart"] = json!("no"); + assert!( + review(&document) + .plan + .diagnostics + .iter() + .all(|d| d.code != "restart_policy_not_applied") + ); + } + assert!(!candidate.state_root.exists()); +} + #[test] fn receipt_loopback_intent_is_bounded_unique_and_container_only() { let container = |key: &str, ports: Vec| Resource { diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index b73016252..212c513fb 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -119,6 +119,7 @@ import { } from "./native-project-run.ts"; import { selectNativeEnvFile } from "./native-project-selection.ts"; import { preflightNativeProjectSource } from "./native-project-source-preflight.ts"; +import { reportNativeRestartPolicyNotices } from "./native-restart-policy-notices.ts"; import { invokeNativeRuntime, NativeRuntimeRequestError, @@ -1741,6 +1742,7 @@ export async function startNativeProject(opts: { input: pinned, run: async (review) => { requireEnrollmentCompatible(review.report.plan); + reportNativeRestartPolicyNotices(review.report.plan, deps.warn); if (metadata) { verifyNativeRuntimeMetadataRoutes({ plan: review.report.plan, diff --git a/src/backends/native-restart-policy-notices.ts b/src/backends/native-restart-policy-notices.ts new file mode 100644 index 000000000..0defb4ee3 --- /dev/null +++ b/src/backends/native-restart-policy-notices.ts @@ -0,0 +1,67 @@ +import { isRecord } from "../lib/guards.ts"; + +/** Review warning for a restart policy that native starts without (HACK-1935). */ +export const RESTART_POLICY_NOT_APPLIED = "restart_policy_not_applied"; + +const SERVICE = /^[A-Za-z0-9_.-]{1,128}$/; +// The reviewed Compose grammar; anything else is never echoed. +const POLICY = /^(?:always|unless-stopped|on-failure(?::[1-9][0-9]{0,9})?)$/; + +/** + * One value-free notice per active service whose declared restart policy is + * not applied, read from the runtime review: the warning diagnostic names the + * service by its `services..restart` pointer, and the policy comes from + * the reviewed plan, so only a service name and a closed-grammar policy are + * printed. A malformed plan yields no notice; admission never depends on it. + */ +export function nativeRestartPolicyNotices(plan: unknown): string[] { + if ( + !( + isRecord(plan) && + Array.isArray(plan.diagnostics) && + isRecord(plan.services) + ) + ) { + return []; + } + const fields = new Set(); + for (const diagnostic of plan.diagnostics) { + if ( + isRecord(diagnostic) && + diagnostic.severity === "warning" && + diagnostic.code === RESTART_POLICY_NOT_APPLIED && + typeof diagnostic.field === "string" + ) { + fields.add(diagnostic.field); + } + } + const notices: string[] = []; + for (const [service, value] of Object.entries(plan.services).sort( + ([a], [b]) => a.localeCompare(b) + )) { + const policy = isRecord(value) ? value.restart : undefined; + if ( + SERVICE.test(service) && + typeof policy === "string" && + POLICY.test(policy) && + isRecord(value) && + value.active === true && + fields.has(`services.${service}.restart`) + ) { + notices.push( + `Service ${service}: restart: ${policy} is not applied. Restart policies are not applied yet to services with published ports or routes (HACK-1935); the service starts without automatic restarts.` + ); + } + } + return notices; +} + +/** Write each notice once through the caller's value-free warning channel. */ +export function reportNativeRestartPolicyNotices( + plan: unknown, + warn: (line: string) => void +): void { + for (const line of nativeRestartPolicyNotices(plan)) { + warn(line); + } +} diff --git a/tests/models/tla/service-restart-policy/README.md b/tests/models/tla/service-restart-policy/README.md index f2ef8cd53..7e8b4c997 100644 --- a/tests/models/tla/service-restart-policy/README.md +++ b/tests/models/tla/service-restart-policy/README.md @@ -22,7 +22,7 @@ model of the stop/restart interaction, not an implementation-refinement proof. | --- | --- | | `Exit` / `EngineRestart` | Engine policy behavior. A scheduled restart fires even after a policy update; only the stop's restart-manager cancel prevents it. The graph observes the pending state as `Observation::Restarting` (`provider/graph/mod.rs` `observation`), admitted only with a receipt policy (`probes::observe`). | | `DaemonRestart` | Guest engine restore: an armed `always` container restarts even after a manual stop. | -| `Bind` / `Use` / `RefuseStale` | Existing generation checks: endpoint generations hash `StartedAt` (`endpoints.rs`), bridge relays bind target PID and start ticks (`bridges.rs`), completed and relay services compare `StartedAt` (`startup/runtime.rs`). Admission refuses restart policies for routed, port-published, HTTP-probed, host-dependency, managed-environment and dependency-cache services, whose guards cannot yet rebind per incarnation; loopback port bridges bind the same endpoint generation as routes (`bridges.rs`). | +| `Bind` / `Use` / `RefuseStale` | Existing generation checks: endpoint generations hash `StartedAt` (`endpoints.rs`), bridge relays bind target PID and start ticks (`bridges.rs`), completed and relay services compare `StartedAt` (`startup/runtime.rs`). Routed and port-published services start without their policy (engine `no`, no receipt policy) with the review warning `restart_policy_not_applied`, because their route and loopback port bridges bind one endpoint generation and relay target (`bridges.rs`); rebinding them per incarnation is HACK-1935. Admission refuses restart policies for the other HTTP-probed, host-dependency, managed-environment and dependency-cache services, whose guards cannot yet rebind per incarnation. Receipt load rejects a policy next to a route or loopback port, which the planner never records. | | `Disarm` | `shutdown::disarm_owned` / `disarm_one`: `update RestartPolicy=no` on every receipt-admitted container, confirmed by a fresh ownership-verified inspection, before any stop request. A policy the receipt never admitted refuses. | | `RequestStop` / `EngineStop` | `shutdown::stop_owned` batch stop; `shutdown::prepare` accepts the engine `restarting` state only for a receipt-admitted policy. | | `ObserveTerminal` / `RefuseNonTerminal` | `shutdown::terminal` requires a fresh observation that is neither running nor restarting before evidence is written. | diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 2b7f69544..f1a63d422 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -738,6 +738,44 @@ test("a failed fresh start whose data removal is unconfirmed still reports the s expect(notices[0]).not.toContain("synthetic-do-not-print"); }); +test("a restart policy the runtime does not apply is reported once per service and the start continues", async () => { + const { opts } = await fixture(); + const review = opts.dependencies.review!; + opts.dependencies.review = async (request) => + await review({ + ...request, + run: (reviewed) => + request.run({ + ...reviewed, + report: { + plan: { + enrollment_compatible: true, + services: { + web: { active: true, restart: "unless-stopped" }, + worker: { active: true, restart: "always" }, + }, + diagnostics: [ + { + severity: "warning", + code: "restart_policy_not_applied", + field: "services.web.restart", + message: "fixed", + }, + ], + }, + }, + }), + }); + const notices: string[] = []; + opts.dependencies.warn = (line) => notices.push(line); + expect(await startNativeProject(opts)).toBe(0); + const restart = notices.filter((line) => line.includes("HACK-1935")); + // Only the warned service is named; `worker` keeps its policy silently. + expect(restart).toEqual([ + "Service web: restart: unless-stopped is not applied. Restart policies are not applied yet to services with published ports or routes (HACK-1935); the service starts without automatic restarts.", + ]); +}); + test("published, attempted or retained runs never have their data removed", async () => { // A successful start publishes its mapping before the graph stops. const published = await fixture(); diff --git a/tests/native-restart-policy-notices.test.ts b/tests/native-restart-policy-notices.test.ts new file mode 100644 index 000000000..a45ee3ea0 --- /dev/null +++ b/tests/native-restart-policy-notices.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { nativeRestartPolicyNotices } from "../src/backends/native-restart-policy-notices.ts"; + +const warning = (field: string) => ({ + severity: "warning", + code: "restart_policy_not_applied", + field, + message: "fixed", +}); + +test("each warned active service is named once with its reviewed policy", () => { + const notices = nativeRestartPolicyNotices({ + services: { + web: { active: true, restart: "unless-stopped" }, + api: { active: true, restart: "on-failure:3" }, + worker: { active: true, restart: "always" }, + }, + diagnostics: [ + warning("services.web.restart"), + warning("services.web.restart"), + warning("services.api.restart"), + { ...warning("services.worker.restart"), severity: "error" }, + { ...warning("services.worker.restart"), code: "other" }, + ], + }); + expect(notices).toEqual([ + "Service api: restart: on-failure:3 is not applied. Restart policies are not applied yet to services with published ports or routes (HACK-1935); the service starts without automatic restarts.", + "Service web: restart: unless-stopped is not applied. Restart policies are not applied yet to services with published ports or routes (HACK-1935); the service starts without automatic restarts.", + ]); +}); + +test("unreviewed names, policies outside the grammar and inactive services are never echoed", () => { + for (const [service, value] of [ + ["../web", { active: true, restart: "always" }], + ["web", { active: true, restart: "always; secret" }], + ["web", { active: true, restart: "on-failure:0" }], + ["web", { active: true, restart: "no" }], + ["web", { active: false, restart: "always" }], + ["web", { restart: "always" }], + ] as const) { + expect( + nativeRestartPolicyNotices({ + services: { [service]: value }, + diagnostics: [warning(`services.${service}.restart`)], + }) + ).toEqual([]); + } + for (const plan of [ + undefined, + {}, + { services: {}, diagnostics: "x" }, + { services: [], diagnostics: [] }, + ]) { + expect(nativeRestartPolicyNotices(plan)).toEqual([]); + } +}); From c9870ceb168db3797d633d2464337e13d0d2f7e4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:05:41 -0500 Subject: [PATCH 122/138] test: apply #634's Compose config test budget The previous merge commit (4cef93d8) records #634 as a parent but kept this branch's content, so #634's one-file change was not applied. This commit applies it: the test file now matches #634's head. Co-Authored-By: Claude Opus 5.5 --- tests/native-compose-renderer-config.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/native-compose-renderer-config.test.ts b/tests/native-compose-renderer-config.test.ts index b20376a48..b20d3275e 100644 --- a/tests/native-compose-renderer-config.test.ts +++ b/tests/native-compose-renderer-config.test.ts @@ -7,8 +7,16 @@ import { mapLegacyNativeImport } from "../src/lib/native-config-import-plan.ts"; import { stringAdoptionWorkerSources } from "./e2e/scenarios/native-compose-adoption-worktrees.ts"; import { composeFixture } from "./helpers/native-compose.ts"; -const configTest = +/** + * Each case runs the real compiler and `docker compose config`, and + * `normalized` allows the child 15 seconds, so the case budget must exceed + * that instead of using bun's 5 second default. + */ +const COMPOSE_CONFIG_TEST_TIMEOUT_MS = 30_000; +const runConfigTest = process.env.HACK_TEST_COMPOSE_CONFIG === "1" ? test : test.skip; +const configTest = (name: string, fn: () => Promise) => + runConfigTest(name, fn, COMPOSE_CONFIG_TEST_TIMEOUT_MS); const compiler = resolveTestConfigCompilerBinary(); /** Uses configuration normalization only: no daemon connection, container start, pull or build. */ From 4d25fa5a4d5808f8f1f9aa421dd21dd573ee99dc Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:00:04 -0500 Subject: [PATCH 123/138] fix(native): compile host aliases like execution and name unbound aliases Native up matched service extra_hosts names before Compose interpolation, and split list entries at the first ":" even inside "${...}". A service alias such as "${UNSET_PREFIX:+x-}api.example.test" next to the same project-wide internal.extra_hosts alias was therefore added twice, and the runtime refused the run with invalid_extra_hosts after the VM booted. - Predict the value native execution compiles each extra_hosts name and target to (same grammar as the runtime resolver: no ambient environment, :- selects its word, :+ selects nothing; managed names, required references and COMPOSE_PROJECT_NAME stay unknown), and split list entries outside braces like review. A project-wide alias that an interpolated or differently spelled declaration already compiles to is not added again. - A service whose declarations compile to one alias twice refuses before any runtime work with E_CONFIG_INVALID, naming the service and alias. A conflicting target for a project-wide alias also names both. - The unbound-alias refusal lists every unbound alias, the services that declare it and where it comes from (Compose, project config, this checkout's extra-hosts.json, or the main checkout's map a linked worktree inherits), the "aliases" a binding needs, and how to remove the alias. - Short managed values are no longer redacted inside version strings, decimals or hyphenated words (8.0.46 stays 8.0.46). - A fresh startup whose owner exited before reserving any runtime state reports the failure code and that nothing was created, not an unconfirmed cleanup. - Foreground up prints the run, reviewed HTTPS route URLs and published loopback ports once ready, and a confirmed-stop line after Ctrl-C. Refs HACK-1938, HACK-1939 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 33 +- src/backends/native-compose-interpolation.ts | 199 ++++++++++ src/backends/native-project-internal-hosts.ts | 357 +++++++++++++++--- .../native-project-restart-preflight.ts | 13 +- src/backends/native-project-start.ts | 193 +++++++++- src/backends/native-startup-logs.ts | 8 +- src/commands/project.ts | 10 +- tests/internal-extra-hosts-worktree.test.ts | 36 ++ tests/native-compose-interpolation.test.ts | 49 +++ tests/native-project-internal-hosts.test.ts | 184 ++++++++- tests/native-project-start.test.ts | 147 +++++++- tests/native-startup-logs.test.ts | 13 + 12 files changed, 1161 insertions(+), 81 deletions(-) create mode 100644 src/backends/native-compose-interpolation.ts create mode 100644 tests/native-compose-interpolation.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 6d3263668..25cc9811a 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -339,6 +339,12 @@ Native `up` and `restart` also stay in the foreground in every project: `--detach` refuses with one `E_USAGE` line. Run `hack up` in its own terminal and keep it open, use `hack ps` and `hack logs` from another terminal, and press Ctrl-C to stop it. For detached startup, use the Compose backend. +Once the graph is ready, image-project `up` prints the run id, each routed +service's reviewed hostnames as HTTPS URLs (with the `HACK_NATIVE_HTTPS_PORT` +port unless it is 443) and the published loopback ports. Without the native +HTTPS frontend it says that routes are not served on this host. After Ctrl-C +and confirmed cleanup it prints one line saying whether named volumes and the +stopped run mapping were kept. ### Network aliases and the Compose project name @@ -998,7 +1004,11 @@ run mapping before exiting, so `hack ps`, `hack down` and `hack doctor --native-cleanup inspect` select that run instead of reporting the project as not started; nothing is stopped or replayed to do so. When the receipt itself cannot be authenticated, nothing is recorded and the runtime -journal keeps the graph for explicit recovery. +journal keeps the graph for explicit recovery. When the graph owner exited +before reserving any runtime state (for example a configuration refusal during +execution compilation), inspection finds no journal for the run: startup then +reports the failure code and that nothing was created, instead of an +unconfirmed cleanup. Managed values are declared on every authored service, so the reviewed plan is the same for every profile selection, but they are delivered only to the services the @@ -1717,12 +1727,27 @@ must then have a selected binding for each alias it declares. A missing binding refuses with `E_NATIVE_PROJECT_UNSUPPORTED` ("Native host alias has no reviewed host dependency binding; configuration was not dropped."), before any runtime work; with no dependency selection at all it refuses before lifecycle hooks. +The refusal lists every unbound alias with the services that declare it and +where it comes from: the service's Compose `extra_hosts`, `internal.extra_hosts` +in project configuration, this checkout's `.hack/.internal/extra-hosts.json`, or +the main checkout's map that a linked worktree inherits. It then names the +`aliases` a binding needs and how to remove the alias instead +(`hack internal extra-hosts unset ` hides an inherited entry in this +worktree). Listener values are never printed. An entry whose target is not `host-gateway` (for example a fixed address), a name that is not an exact lowercase DNS name, or a service that already maps the name to a different target also refuses. Service-declared names are matched in the -form native review compiles them to (one trailing dot removed, lowercased), so a -service that declares `API.Example.` is covered by a binding for `api.example` and -does not receive a second spelling. Earlier native builds ignored these entries, +form native execution compiles them to: Compose interpolation resolved as the +native runtime resolves it (no ambient environment, so `${UNSET:-word}` selects +`word` and `${UNSET:+word}` selects nothing), then one trailing dot removed and +lowercased. So a service that declares `API.Example.` or +`${UNSET_PREFIX:+x-}api.example` is covered by a binding for `api.example` and +does not receive a second copy of a project-wide `api.example`. A service whose +declarations compile to the same alias twice refuses with `E_CONFIG_INVALID`, +naming the service and alias, before any runtime work; native execution would +refuse it only after the VM started. A name that refers to a required variable, +a Hack-managed value or `COMPOSE_PROJECT_NAME` is left to native review and +execution. Earlier native builds ignored these entries, which let a container resolve the public name instead of the local listener. Native reads these aliases before lifecycle hooks, because they are part of the reviewed configuration; unlike the Compose backend, a hook that changes them makes diff --git a/src/backends/native-compose-interpolation.ts b/src/backends/native-compose-interpolation.ts new file mode 100644 index 000000000..f09feda33 --- /dev/null +++ b/src/backends/native-compose-interpolation.ts @@ -0,0 +1,199 @@ +import { COMPOSE_PROJECT_NAME_VARIABLE } from "./native-compose-project-name.ts"; + +/** + * Predict the value native execution compiles one Compose string to, before + * any runtime work. + * + * Native execution resolves Compose interpolation itself (runtime-core + * `project::inputs::Resolver`) from an explicit map: no process environment or + * dotenv file is consulted, and the only bound name is `COMPOSE_PROJECT_NAME` + * (the reviewed Compose project name). Every other reference is therefore + * unset: `${NAME:-word}` selects `word`, `${NAME:+word}` selects nothing, and + * `$NAME` or `${NAME}` refuses at execution. + * + * This mirrors that grammar exactly: `$$` escapes, braced `:-` and `:+` + * operators whose words hold literal text, `$$` and nested braced expressions + * (at most four levels), and unbraced references. It returns null whenever + * native execution would refuse the text or the value is not knowable yet: a + * required reference, unsupported syntax, a Hack-managed name (delivered + * privately and never interpolated), or `COMPOSE_PROJECT_NAME`, which is bound + * only after review. Callers must treat null as "unknown" and leave the final + * decision to native review and execution, which stay the authority. + */ +export function resolveNativeComposeText(opts: { + readonly text: string; + /** Names Hack delivers privately to the service; a reference refuses. */ + readonly managed?: ReadonlySet; +}): string | null { + if (opts.text.length > MAX_TEXT) { + return null; + } + try { + return resolve(opts.text, opts.managed ?? EMPTY); + } catch (error) { + if (error === UNKNOWN) { + return null; + } + throw error; + } +} + +const MAX_TEXT = 64 * 1024; +/** Compose nesting such as `${A:-${B:-${C:-${D:-word}}}}` is bounded at four levels. */ +const MAX_DEPTH = 4; +const EMPTY: ReadonlySet = new Set(); +const VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const NAME_BYTE = /[A-Za-z0-9_]/; +/** Thrown internally for any text whose native value is not knowable here. */ +const UNKNOWN = Symbol("unknown"); + +function unknown(): never { + throw UNKNOWN; +} + +/** + * Validate a referenced name exactly as the runtime does before lookup. A live + * reference to the project name is unknown here; elsewhere it is irrelevant. + */ +function checked( + name: string, + managed: ReadonlySet, + live: boolean +): void { + if ( + !VARIABLE.test(name) || + managed.has(name) || + (live && name === COMPOSE_PROJECT_NAME_VARIABLE) + ) { + unknown(); + } +} + +/** Index of the `}` closing a body that follows `${`, as the runtime counts it. */ +function bracedEnd(text: string): number { + let depth = 1; + let index = 0; + while (index < text.length) { + if (text.startsWith("$$", index)) { + index += 2; + continue; + } + if (text.startsWith("${", index)) { + depth += 1; + index += 2; + continue; + } + if (text[index] === "}") { + depth -= 1; + if (depth === 0) { + return index; + } + } + index += 1; + } + return unknown(); +} + +function resolve(text: string, managed: ReadonlySet): string { + let result = ""; + let rest = text; + for (;;) { + const index = rest.indexOf("$"); + if (index < 0) { + return result + rest; + } + result += rest.slice(0, index); + rest = rest.slice(index + 1); + if (rest.startsWith("$")) { + result += "$"; + rest = rest.slice(1); + continue; + } + if (rest.startsWith("{")) { + const body = rest.slice(1); + const end = bracedEnd(body); + result += expression(body.slice(0, end), 1, true, managed); + rest = body.slice(end + 1); + continue; + } + // An unbraced reference is always required, and no supplied value exists. + let end = 0; + while (end < rest.length && NAME_BYTE.test(rest[end] ?? "")) { + end += 1; + } + checked(rest.slice(0, end), managed, true); + return unknown(); + } +} + +/** + * One braced body. `live` is false inside a word the operator does not select: + * it is still validated, but a missing input there is not required. + */ +function expression( + body: string, + depth: number, + live: boolean, + managed: ReadonlySet +): string { + const colon = body.indexOf(":"); + if (colon < 0) { + checked(body, managed, live); + // A live bare reference is required; nothing is supplied. + return live ? unknown() : ""; + } + const name = body.slice(0, colon); + const suffix = body.slice(colon + 1); + if (suffix === "") { + return unknown(); + } + checked(name, managed, live); + const operator = suffix[0]; + if (operator !== "+" && operator !== "-") { + return unknown(); + } + // Every checked name is unset, so `:-` selects its word and `:+` does not. + const selected = operator === "-"; + const value = word(suffix.slice(1), depth, live && selected, managed); + return selected ? value : ""; +} + +/** Literal text, `$$` escapes and nested braced expressions only. */ +function word( + text: string, + depth: number, + live: boolean, + managed: ReadonlySet +): string { + let result = ""; + let rest = text; + for (;;) { + const found = rest.indexOf("$"); + const index = found < 0 ? rest.length : found; + const literal = rest.slice(0, index); + if (literal.includes("{") || literal.includes("}")) { + return unknown(); + } + if (live) { + result += literal; + } + if (index === rest.length) { + return result; + } + rest = rest.slice(index + 1); + if (rest.startsWith("$")) { + if (live) { + result += "$"; + } + rest = rest.slice(1); + continue; + } + if (!rest.startsWith("{") || depth >= MAX_DEPTH) { + return unknown(); + } + const body = rest.slice(1); + const end = bracedEnd(body); + result += expression(body.slice(0, end), depth + 1, live, managed); + rest = body.slice(end + 1); + } +} diff --git a/src/backends/native-project-internal-hosts.ts b/src/backends/native-project-internal-hosts.ts index 2330a18cc..5e2504abb 100644 --- a/src/backends/native-project-internal-hosts.ts +++ b/src/backends/native-project-internal-hosts.ts @@ -1,11 +1,15 @@ import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; -import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; +import { + resolveExtraHostsPath, + resolveInternalExtraHosts, +} from "../lib/internal-extra-hosts.ts"; import { type ProjectConfig, type ProjectContext, readProjectConfig, } from "../lib/project.ts"; +import { resolveNativeComposeText } from "./native-compose-interpolation.ts"; import type { NativeHostDependency } from "./native-project-dependencies.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; @@ -24,21 +28,98 @@ import type { NativeProjectInput } from "./native-project-input.ts"; const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const NUMERIC_HOST = /^[0-9.]+$/; -const SEPARATOR = /[:=]/; const MAX_ALIASES = 32; -/** Fixed text when a declared host alias has no reviewed dependency binding. */ +/** Leading text when a declared host alias has no reviewed dependency binding. */ export const NATIVE_UNBOUND_HOST_ALIAS_REFUSAL = - "Native host alias has no reviewed host dependency binding; configuration was not dropped. Select a host dependency binding for every service that declares the alias."; + "Native host alias has no reviewed host dependency binding; configuration was not dropped."; /** Fixed text when a project-wide host alias cannot be expressed natively. */ export const NATIVE_INTERNAL_HOST_ALIAS_REFUSAL = "Native up supports internal extra_hosts only as exact host-gateway aliases with reviewed host dependency bindings; configuration was not dropped."; -function unbound(): Error { +/** Leading text when a service's declared host aliases cannot be compiled natively. */ +export const NATIVE_HOST_ALIAS_CONFLICT_REFUSAL = + "Native host alias selection conflicts with declared configuration"; + +/** + * Where a project-wide alias comes from, so a refusal can point at the file to + * change. `inherited` is the main checkout's checkout-local map, which a linked + * worktree inherits unless it hides the entry. + */ +export type NativeHostAliasSource = "project" | "checkout" | "inherited"; + +const PRINTABLE_SERVICE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +/** Compose service names are printed only in their validated form. */ +function serviceLabel(name: string): string { + return PRINTABLE_SERVICE.test(name) ? `service ${name}` : "a service"; +} + +function describeSource(source: NativeHostAliasSource | undefined): string { + switch (source) { + case "project": + return "internal.extra_hosts in the project configuration"; + case "checkout": + return "this checkout's .hack/.internal/extra-hosts.json"; + case "inherited": + return "the main checkout's .hack/.internal/extra-hosts.json, which this linked worktree inherits"; + default: + return "the service's Compose extra_hosts"; + } +} + +function removalHint( + alias: string, + source: NativeHostAliasSource | undefined +): string { + switch (source) { + case "project": + return `remove ${alias} from internal.extra_hosts in the project configuration`; + case "checkout": + return `run \`hack internal extra-hosts unset ${alias}\``; + case "inherited": + return `run \`hack internal extra-hosts unset ${alias}\` in this worktree to stop inheriting it`; + default: + return `remove ${alias} from the service's extra_hosts`; + } +} + +type UnboundAlias = { + readonly alias: string; + readonly services: readonly string[]; + readonly source: NativeHostAliasSource | undefined; +}; + +/** At most this many aliases are listed; the rest are counted. */ +const MAX_LISTED = 8; + +/** + * Name every unbound (service, alias) pair, where each alias comes from, and + * the binding that would admit it. Aliases are validated DNS names and service + * names are printed only when they match Compose's name rule, so the line + * carries configuration names only: never targets, ports or listener values. + */ +function unbound(missing: readonly UnboundAlias[]): Error { + const listed = missing + .slice(0, MAX_LISTED) + .map( + (entry) => + `${entry.alias} for ${entry.services.map(serviceLabel).join(", ")} (from ${describeSource(entry.source)})` + ); + const more = + missing.length > MAX_LISTED + ? `; and ${missing.length - MAX_LISTED} more aliases` + : ""; + const first = missing[0]; + const aliases = JSON.stringify( + missing.slice(0, MAX_LISTED).map((entry) => entry.alias) + ); + const hint = first + ? ` Add a binding with "aliases": ${aliases} to the HACK_NATIVE_DEPENDENCIES selection (a version 2 entry without "services" applies to every service that declares them), or ${removalHint(first.alias, first.source)}.` + : ""; return new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: NATIVE_UNBOUND_HOST_ALIAS_REFUSAL, + message: `${NATIVE_UNBOUND_HOST_ALIAS_REFUSAL} Unbound: ${listed.join("; ")}${more}.${hint}`, }); } function unsupported(): Error { @@ -50,8 +131,21 @@ function unsupported(): Error { function conflict(): Error { return new HackCliError({ code: "E_CONFIG_INVALID", - message: - "Native host alias selection conflicts with declared configuration; values omitted.", + message: `${NATIVE_HOST_ALIAS_CONFLICT_REFUSAL}; values omitted.`, + }); +} +/** A target other than `host-gateway` for a project-wide alias; the target is omitted. */ +function conflictingTarget(service: string, alias: string): Error { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: `${NATIVE_HOST_ALIAS_CONFLICT_REFUSAL}: ${serviceLabel(service)} maps project-wide host alias ${alias} to another target; native supports only host-gateway for it. Target value omitted.`, + }); +} +/** Two declarations compile to one runtime alias, which native execution refuses. */ +function duplicateAlias(service: string, alias: string): Error { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: `${NATIVE_HOST_ALIAS_CONFLICT_REFUSAL}: ${serviceLabel(service)} declares host alias ${alias} more than once after Compose interpolation and name normalization; keep one declaration. Nothing was started.`, }); } @@ -70,12 +164,11 @@ function exactAlias(value: string): boolean { const ASCII = /^[\x20-\x7e]*$/; /** - * The name native review compiles a service-declared extra host to, with the + * The name native review compiles a resolved extra host name to, with the * runtime's `normalize_hostname` semantics: strip one trailing dot, lowercase * ASCII, and require an exact DNS name (not an address). Returns null for a - * name this check cannot canonicalize (for example an `${...}` reference the - * runtime resolves later); native admission still enforces exact alias and - * binding equality for those. + * name this check cannot canonicalize; native admission still enforces exact + * alias and binding equality for those. */ function canonicalHostName(value: string): string | null { if (!ASCII.test(value)) { @@ -93,10 +186,87 @@ function canonicalHostName(value: string): string | null { return name; } +/** + * The runtime alias one declared `extra_hosts` entry compiles to: Compose + * interpolation as native execution resolves it (see + * {@link resolveNativeComposeText}), then name canonicalization. Null when + * the value is not knowable before execution or is not an exact DNS name. + */ +function compiledHostName( + value: string, + managed: ReadonlySet +): string | null { + const resolved = resolveNativeComposeText({ text: value, managed }); + return resolved === null ? null : canonicalHostName(resolved); +} + +/** A declared target as native execution resolves it; null when unknown. */ +function compiledTarget( + value: string, + managed: ReadonlySet +): string | null { + return resolveNativeComposeText({ text: value, managed }); +} + +/** + * The (compiled name, compiled target) of each declaration that resolves + * before execution, refusing a service that declares one alias twice: the + * runtime refuses that only after the VM boots. Unknown declarations are left + * to review and execution, keyed by their literal text. + */ +function compiledEntries(opts: { + readonly service: string; + readonly entries: readonly (readonly [string, string])[]; + readonly managed: ReadonlySet; +}): Map { + const declared = new Map(); + for (const [name, target] of opts.entries) { + const compiled = compiledHostName(name, opts.managed); + if (compiled !== null && declared.has(compiled)) { + throw duplicateAlias(opts.service, compiled); + } + declared.set(compiled ?? name, compiledTarget(target, opts.managed)); + } + return declared; +} + +function managedNames( + managedEnvironment: + | Readonly>>> + | undefined, + service: string +): ReadonlySet { + const values = managedEnvironment + ? Object.getOwnPropertyDescriptor(managedEnvironment, service)?.value + : undefined; + return new Set(isRecord(values) ? Object.keys(values) : []); +} + +/** + * The first `:` or `=` outside `{...}`, exactly as native review splits a list + * entry, so the `:` of an interpolation such as `${PREFIX:+x-}` stays in the + * name. -1 when there is none. + */ +function separatorIndex(entry: string): number { + let depth = 0; + for (let index = 0; index < entry.length; index += 1) { + const c = entry[index]; + if (c === "{") { + depth += 1; + } else if (c === "}") { + depth = Math.max(0, depth - 1); + } else if ((c === ":" || c === "=") && depth === 0) { + return index; + } + } + return -1; +} + /** * Split one Compose `extra_hosts` value into (name, target) pairs, using the - * first `:` or `=` like the native review does. Returns undefined for a shape - * native review would refuse; callers leave that refusal to review. + * first `:` or `=` outside braces like the native review does. Returns + * undefined for a shape native review would refuse; callers leave that + * refusal to review. */ function extraHostEntries( value: unknown @@ -122,11 +292,11 @@ function extraHostEntries( if (typeof entry !== "string") { return undefined; } - const match = SEPARATOR.exec(entry); - if (!match) { + const index = separatorIndex(entry); + if (index < 0) { return undefined; } - entries.push([entry.slice(0, match.index), entry.slice(match.index + 1)]); + entries.push([entry.slice(0, index), entry.slice(index + 1)]); } return entries; } @@ -145,27 +315,31 @@ function internalAliasNames(hosts: Readonly>): string[] { return names.sort(); } -function declareOnService( - service: Record, - aliases: readonly string[] -): void { +function declareOnService(opts: { + readonly name: string; + readonly service: Record; + readonly aliases: readonly string[]; + readonly managed: ReadonlySet; +}): void { + const { service, aliases } = opts; const entries = extraHostEntries(service.extra_hosts); if (!entries) { throw conflict(); } // Compare by the compiled name: `API.Example.` and `api.example` are one - // runtime alias, so adding the second spelling would duplicate it. - const declared = new Map(); - for (const [name, target] of entries) { - declared.set(canonicalHostName(name) ?? name, target); - } + // runtime alias, and so is `${UNSET:+x-}api.example` once execution + // resolves it; adding a second spelling would duplicate the alias. + const declared = compiledEntries({ + service: opts.name, + entries, + managed: opts.managed, + }); const added: string[] = []; for (const alias of aliases) { - const target = declared.get(alias); - if (target === undefined) { + if (!declared.has(alias)) { added.push(alias); - } else if (target !== "host-gateway") { - throw conflict(); + } else if (declared.get(alias) !== "host-gateway") { + throw conflictingTarget(opts.name, alias); } } if (added.length === 0) { @@ -204,28 +378,54 @@ function declareOnService( export function applyNativeInternalHostAliases(opts: { readonly input: NativeProjectInput; readonly hosts?: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly sources?: Readonly>; readonly hasDependencySelection: boolean; }): NativeProjectInput { if (opts.hosts === undefined || Object.keys(opts.hosts).length === 0) { return opts.input; } const aliases = internalAliasNames(opts.hosts); - if (!opts.hasDependencySelection) { - throw unbound(); - } const compose: unknown = JSON.parse(opts.input.normalizedComposeJson); if (!(isRecord(compose) && isRecord(compose.services))) { throw conflict(); } - for (const service of Object.values(compose.services)) { + if (!opts.hasDependencySelection) { + const services = Object.keys(compose.services).sort(); + throw unbound( + aliases.map((alias) => ({ + alias, + services, + source: sourceOf(opts.sources, alias), + })) + ); + } + for (const [name, service] of Object.entries(compose.services)) { if (!isRecord(service)) { throw conflict(); } - declareOnService(service, aliases); + declareOnService({ + name, + service, + aliases, + managed: managedNames(opts.input.managedEnvironment, name), + }); } return { ...opts.input, normalizedComposeJson: JSON.stringify(compose) }; } +function sourceOf( + sources: Readonly> | undefined, + alias: string +): NativeHostAliasSource | undefined { + const value: unknown = sources + ? Object.getOwnPropertyDescriptor(sources, alias)?.value + : undefined; + return value === "project" || value === "checkout" || value === "inherited" + ? value + : undefined; +} + function activeService( spec: Readonly>, profiles: readonly string[] @@ -245,17 +445,23 @@ function activeService( /** * The `host-gateway` aliases each profile-active service declares, keyed by - * service, in the canonical form native review compiles them to (see - * {@link canonicalHostName}), so a declared `API.Example.` matches a binding - * for `api.example`. Inactive services are never scheduled and receive no - * bindings, so they need none. Malformed or not-yet-resolved declarations are - * left to review and admission. + * service, in the form native execution compiles them to: Compose + * interpolation resolved as the runtime resolves it, then canonicalized (see + * {@link compiledHostName}), so a declared `API.Example.` or + * `${UNSET:-api.example}` matches a binding for `api.example`. A service that + * declares one alias twice refuses here, before any runtime work. Inactive + * services are never scheduled and receive no bindings, so they need none. + * Malformed or not-yet-knowable declarations are left to review and admission. */ export function nativeServiceHostAliases(opts: { readonly services: Readonly< Record>> >; readonly profiles: readonly string[]; + /** Names delivered privately per service; references to them are unknown. */ + readonly managedEnvironment?: Readonly< + Record>> + >; }): Record { const result: Record = {}; for (const [name, spec] of Object.entries(opts.services)) { @@ -263,9 +469,12 @@ export function nativeServiceHostAliases(opts: { continue; } const aliases = new Set(); - for (const [alias, target] of extraHostEntries(spec.extra_hosts) ?? []) { + const entries = extraHostEntries(spec.extra_hosts) ?? []; + const managed = managedNames(opts.managedEnvironment, name); + const declared = compiledEntries({ service: name, entries, managed }); + for (const [alias, target] of declared) { const canonical = canonicalHostName(alias); - if (target === "host-gateway" && canonical !== null) { + if (target === "host-gateway" && canonical === alias) { aliases.add(canonical); } } @@ -282,25 +491,38 @@ export function nativeServiceHostAliases(opts: { /** * Fail closed when an active service declares a host-gateway alias that no * selected binding of that service covers. Native admission later enforces - * exact equality as well; this refuses earlier, before runtime effects, with - * a value-free line. + * exact equality as well; this refuses earlier, before runtime effects. The + * refusal names every unbound (service, alias) pair and where each alias + * comes from, but never listener values. */ export function requireNativeHostAliasBindings(opts: { readonly dependencies: readonly NativeHostDependency[]; readonly hostAliases: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly sources?: Readonly>; }): void { const bound = new Set( opts.dependencies.flatMap((dependency) => dependency.aliases.map((alias) => `${dependency.service}\0${alias}`) ) ); + const missing = new Map(); for (const [service, aliases] of Object.entries(opts.hostAliases)) { for (const alias of aliases) { if (!bound.has(`${service}\0${alias}`)) { - throw unbound(); + missing.set(alias, [...(missing.get(alias) ?? []), service]); } } } + if (missing.size > 0) { + throw unbound( + [...missing.keys()].sort().map((alias) => ({ + alias, + services: (missing.get(alias) ?? []).sort(), + source: sourceOf(opts.sources, alias), + })) + ); + } } /** Fixed text when lifecycle hooks changed project-wide aliases after review input. */ @@ -335,18 +557,49 @@ export async function resolveNativeInternalExtraHosts(opts: { readonly project: ProjectContext; readonly cfg?: ProjectConfig; }): Promise> { + return (await resolveNativeInternalExtraHostSelection(opts)).hosts; +} + +/** + * {@link resolveNativeInternalExtraHosts} plus where each effective alias + * comes from: `internal.extra_hosts` in project configuration, this + * checkout's local map, or the main checkout's local map a linked worktree + * inherits. Sources are used only to explain refusals. + */ +export async function resolveNativeInternalExtraHostSelection(opts: { + readonly project: ProjectContext; + readonly cfg?: ProjectConfig; +}): Promise<{ + readonly hosts: Record; + readonly sources: Record; +}> { try { const cfg = opts.cfg ?? (await readProjectConfig(opts.project)); if (cfg.parseError) { throw new Error("invalid project configuration"); } - return ( - await resolveInternalExtraHosts({ - projectRoot: opts.project.projectRoot, - projectDir: opts.project.projectDir, - staticHosts: cfg.internal?.extraHosts, - }) - ).hosts; + const resolved = await resolveInternalExtraHosts({ + projectRoot: opts.project.projectRoot, + projectDir: opts.project.projectDir, + staticHosts: cfg.internal?.extraHosts, + }); + const local = resolveExtraHostsPath(opts.project.projectDir); + const sources: Record = {}; + for (const [alias, origin] of Object.entries(resolved.origins)) { + let source: NativeHostAliasSource = "inherited"; + if (origin === "internal.extra_hosts") { + source = "project"; + } else if (origin === local) { + source = "checkout"; + } + Object.defineProperty(sources, alias, { + value: source, + enumerable: true, + writable: true, + configurable: true, + }); + } + return { hosts: resolved.hosts, sources }; } catch { throw new HackCliError({ code: "E_CONFIG_INVALID", diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index d654ffb45..a06616231 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -19,6 +19,7 @@ import { prepareNativeProjectInput } from "./native-project-input.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { applyNativeInternalHostAliases, + type NativeHostAliasSource, nativeServiceHostAliases, requireNativeHostAliasBindings, } from "./native-project-internal-hosts.ts"; @@ -337,6 +338,10 @@ export async function preflightNativeRestart(opts: { readonly dependencyFile?: string; /** Same project-wide host aliases the replacement startup declares. */ readonly internalExtraHosts?: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly internalExtraHostSources?: Readonly< + Record + >; readonly allowedHosts?: readonly string[]; /** Same public metadata selection the replacement startup will review. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; @@ -383,6 +388,7 @@ export async function preflightNativeRestart(opts: { input = applyNativeInternalHostAliases({ input, hosts: opts.internalExtraHosts, + sources: opts.internalExtraHostSources, hasDependencySelection: opts.dependencyFile !== undefined, }); const reviewedBranch = await prepareNativeReviewBranch({ @@ -420,6 +426,7 @@ export async function preflightNativeRestart(opts: { const hostAliases = nativeServiceHostAliases({ services: specs, profiles: selected.profiles, + managedEnvironment: input.managedEnvironment, }); const dependencies = stoppedRetry ? await readNativeRestartDependencyIntent({ @@ -440,7 +447,11 @@ export async function preflightNativeRestart(opts: { invoke: deps.invoke, }), }); - requireNativeHostAliasBindings({ dependencies, hostAliases }); + requireNativeHostAliasBindings({ + dependencies, + hostAliases, + sources: opts.internalExtraHostSources, + }); prepareNativeDependencyServices({ dependencies, services: specs }); const activeImages = stoppedRetry ? undefined diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 2d3c6f579..6debd0d44 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -60,6 +60,7 @@ import { import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { applyNativeInternalHostAliases, + type NativeHostAliasSource, nativeServiceHostAliases, requireNativeHostAliasBindings, } from "./native-project-internal-hosts.ts"; @@ -106,6 +107,8 @@ import { import { preflightNativeProjectSource } from "./native-project-source-preflight.ts"; import { invokeNativeRuntime, + NATIVE_GRAPH_UNRESERVED, + NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; import { @@ -289,6 +292,8 @@ type Dependencies = { adaptGcloud: typeof adaptNativeGcloudEnvironment; /** Value-free operator notices (for example credential expiry); stderr by default. */ warn: (line: string) => void; + /** Foreground progress lines (readiness, confirmed stop); stderr by default. */ + notice: (line: string) => void; review: typeof withNativeProjectReview; selectReview: typeof selectNativeProjectReviewIdentity; retainedImages: typeof selectNativeRetainedImages; @@ -316,6 +321,9 @@ const DEFAULTS: Dependencies = { warn: (line) => { process.stderr.write(`${line}\n`); }, + notice: (line) => { + process.stderr.write(`${line}\n`); + }, review: withNativeProjectReview, selectReview: selectNativeProjectReviewIdentity, retainedImages: selectNativeRetainedImages, @@ -623,6 +631,77 @@ export async function verifyHttpsRoutes( } } +const PRINTABLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const PRINTABLE_HOST = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/; +/** + * What a ready foreground graph serves on this host, from the reviewed plan + * only: each routed service's reviewed hostnames as HTTPS URLs when the + * native HTTPS frontend runs (with its port unless 443), and the published + * loopback ports. Names that fail their closed patterns are skipped. + */ +export function nativeReadySummary(opts: { + readonly run: string; + readonly plan: unknown; + readonly routed: ReadonlySet; + readonly httpsPort: number | null; +}): string[] { + const lines = [ + `Native project ready (run ${RUN.test(opts.run) ? opts.run : "unknown"}). Press Ctrl-C to stop; use hack ps and hack logs from another terminal.`, + ]; + const services = + isRecord(opts.plan) && isRecord(opts.plan.services) + ? opts.plan.services + : {}; + const suffix = + opts.httpsPort === null || opts.httpsPort === 443 + ? "" + : `:${opts.httpsPort}`; + let unserved = false; + for (const name of [...opts.routed].sort()) { + const service = services[name]; + const hostnames = + isRecord(service) && + isRecord(service.routing) && + Array.isArray(service.routing.hostnames) + ? service.routing.hostnames.filter( + (host): host is string => + typeof host === "string" && PRINTABLE_HOST.test(host) + ) + : []; + if (!PRINTABLE_NAME.test(name) || hostnames.length === 0) { + continue; + } + if (opts.httpsPort === null) { + unserved = true; + continue; + } + lines.push( + ` ${name}: ${hostnames.map((host) => `https://${host}${suffix}`).join(" ")}` + ); + } + if (unserved) { + lines.push( + " Routes are not served on this host: the native HTTPS frontend is not selected (HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256 and HACK_NATIVE_HTTPS_PORT)." + ); + } + const ports = reviewedNativeLoopbackPorts(opts.plan); + if (ports.length > 0) { + lines.push( + ` Published ports: ${ports.map((port) => `127.0.0.1:${port}`).join(" ")}` + ); + } + return lines; +} +/** One confirmed-stop line for an interrupted ready graph, from its final receipt. */ +export function nativeStoppedNotice(final: unknown): string { + const removed = + isRecord(final) && + isRecord(final.receipt) && + final.receipt.phase === "removed"; + return removed + ? "Native project stopped; owned cleanup confirmed and no runtime data was retained." + : "Native project stopped; owned cleanup confirmed. Named volumes and the stopped run mapping are kept for the next hack up."; +} function foregroundExitCode(opts: { aborted: boolean; interruptedExit: number; @@ -787,6 +866,80 @@ function cleanupUnconfirmed( `Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying.${startup}${nativeCode ? ` Native exit diagnostic: ${nativeCode}.` : ""}${recorded}` ); } +const NATIVE_CODE = /^[A-Za-z0-9_]{1,80}$/; +/** + * A fresh startup whose graph owner exited before reserving any runtime state: + * 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 { + const message = startupFailure instanceof Error ? startupFailure.message : ""; + const diagnostic = SAFE_STARTUP_DIAGNOSTIC.exec(message); + const code = + nativeCode && NATIVE_CODE.test(nativeCode) ? nativeCode : diagnostic?.[2]; + return new ClosedCliError( + `Native graph startup failed${code ? ` (${code})` : ""} before any runtime state was reserved; nothing was created and no cleanup is needed.` + ); +} +/** + * The report when final inspection of a settled startup fails. The graph + * owner has exited (serve settles only after its exit), so for a fresh run + * that never attempted a mapping, an exactly absent journal proves nothing was + * reserved ({@link nothingReserved}). Every other case keeps the + * unconfirmed-cleanup report. + */ +function uninspectableStartupFailure(opts: { + readonly serveFailure: unknown; + readonly inspectionFailure: unknown; + readonly restored: boolean; + readonly mappingAttempted: boolean; + readonly nativeCode: string | undefined; +}): { readonly error: Error; readonly nothingReserved: boolean } { + if ( + opts.serveFailure === undefined || + opts.restored || + opts.mappingAttempted || + !(opts.inspectionFailure instanceof NativeRuntimeRequestError) || + opts.inspectionFailure.nativeCode !== NATIVE_GRAPH_UNRESERVED + ) { + return { + error: cleanupUnconfirmed( + opts.serveFailure ?? opts.inspectionFailure, + opts.nativeCode + ), + nothingReserved: false, + }; + } + return { + error: nothingReserved(opts.serveFailure, opts.nativeCode), + nothingReserved: true, + }; +} +/** The HTTPS port routes are served on: only while the frontend lease runs. */ +function servedHttpsPort( + lease: unknown, + selection: NativeHttpsSelection | undefined +): number | null { + return lease === undefined ? null : (selection?.httpsPort ?? null); +} +/** The confirmed-stop line, only for an interrupted graph that published a mapping. */ +function stoppedLines(opts: { + readonly aborted: boolean; + readonly published: boolean; + readonly final: unknown; +}): string[] { + return opts.aborted && opts.published + ? [nativeStoppedNotice(opts.final)] + : []; +} +function reportLines( + notice: (line: string) => void, + lines: readonly string[] +): void { + for (const line of lines) { + notice(line); + } +} function requireConfirmedCleanup( final: unknown, startupFailure?: unknown, @@ -1186,6 +1339,10 @@ export async function startNativeProject(opts: { * override; each active pair then requires a reviewed dependency binding. */ readonly internalExtraHosts?: Readonly>; + /** Where each project-wide alias comes from, for refusal text only. */ + readonly internalExtraHostSources?: Readonly< + Record + >; readonly allowedHosts?: readonly string[]; readonly preparedBase?: NativePreparedBase; readonly https?: NativeHttpsSelection; @@ -1296,6 +1453,7 @@ export async function startNativeProject(opts: { input = applyNativeInternalHostAliases({ input, hosts: opts.internalExtraHosts, + sources: opts.internalExtraHostSources, hasDependencySelection: opts.dependencyFile !== undefined, }); input = ( @@ -1372,6 +1530,7 @@ export async function startNativeProject(opts: { const hostAliases = nativeServiceHostAliases({ services: specs, profiles, + managedEnvironment: input.managedEnvironment, }); const hostDependencies = await readNativeHostDependencies({ path: opts.dependencyFile, @@ -1394,6 +1553,7 @@ export async function startNativeProject(opts: { requireNativeHostAliasBindings({ dependencies: hostDependencies, hostAliases, + sources: opts.internalExtraHostSources, }); prepareNativeDependencyServices({ dependencies: hostDependencies, @@ -1748,6 +1908,15 @@ export async function startNativeProject(opts: { } await hooks?.ready?.(); await opts.onReady?.(); + reportLines( + deps.notice, + nativeReadySummary({ + run, + plan: review.report.plan, + routed: routes.services, + httpsPort: servedHttpsPort(https, opts.https), + }) + ); // Last: only microtasks separate this from readiness. await stop?.owe(() => controller.signal.aborted); }, @@ -1762,10 +1931,15 @@ export async function startNativeProject(opts: { try { final = await invokeInspect(); } catch (inspectionFailure) { - throw cleanupUnconfirmed( - serveFailure ?? inspectionFailure, - nativeExitCode - ); + const settled = uninspectableStartupFailure({ + serveFailure, + inspectionFailure, + restored: restore !== undefined, + mappingAttempted, + nativeCode: nativeExitCode, + }); + graphCleanupConfirmed = settled.nothingReserved; + throw settled.error; } const confirmed = await confirmCleanupOrRecordOwner({ confirm: { @@ -1814,13 +1988,22 @@ export async function startNativeProject(opts: { mappingAttempted, invoke: deps.invoke, }); - return foregroundExitCode({ + const exit = foregroundExitCode({ aborted: controller.signal.aborted, interruptedExit: stopSignals.interruptedExit(), failure: serveFailure, infrastructureFailure: httpsFailure, code, }); + reportLines( + deps.notice, + stoppedLines({ + aborted: controller.signal.aborted, + published: mapping !== undefined, + final, + }) + ); + return exit; } catch (error) { throw withStartupLogReport(error, startupLogReport); } diff --git a/src/backends/native-startup-logs.ts b/src/backends/native-startup-logs.ts index 2c8f58ce4..347b58954 100644 --- a/src/backends/native-startup-logs.ts +++ b/src/backends/native-startup-logs.ts @@ -128,8 +128,10 @@ function needles(values: Iterable): { * JSON-escaped form and each line of a multi-line value that is long enough, * with and without surrounding whitespace. Longer values are replaced first. * - A shorter value (one to three characters) is replaced only where it stands as - * a whole token, not next to a letter, digit or underscore, so a value such as - * `1` or `on` cannot shred unrelated output. + * a whole token: not next to a letter, digit or underscore, and not joined to + * one by a `.` or `-`. So a value such as `1`, `0` or `on` cannot shred + * unrelated output such as a version string (`8.0.46`), a decimal or a + * hyphenated word, while a value ending a sentence (`mode on.`) still matches. * - Remaining control characters except tab and newline become U+FFFD. */ export function redactNativeStartupLog( @@ -143,7 +145,7 @@ export function redactNativeStartupLog( } for (const value of tokens) { const pattern = new RegExp( - `(?[0] = { runtime: native, scope: { @@ -5906,6 +5904,7 @@ async function handleNativeUp({ dependencyFile: process.env.HACK_NATIVE_DEPENDENCIES, adaptationFile: process.env.HACK_NATIVE_ADAPTATION, internalExtraHosts, + internalExtraHostSources, allowedHosts: parseNativeAllowedHosts(process.env.HACK_NATIVE_ALLOW_HOSTS), preparedBase: parseNativePreparedBase(process.env), https: parseNativeHttpsSelection(process.env), @@ -6108,6 +6107,7 @@ async function restartNativeImageProject({ adaptationFile: startup.adaptationFile, dependencyFile: startup.dependencyFile, internalExtraHosts: startup.internalExtraHosts, + internalExtraHostSources: startup.internalExtraHostSources, allowedHosts: startup.allowedHosts, runtimeMetadata: startup.runtimeMetadata, httpsPort: startup.https?.httpsPort ?? null, diff --git a/tests/internal-extra-hosts-worktree.test.ts b/tests/internal-extra-hosts-worktree.test.ts index 4ee15c06c..02f075a5b 100644 --- a/tests/internal-extra-hosts-worktree.test.ts +++ b/tests/internal-extra-hosts-worktree.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { resolveNativeInternalExtraHostSelection } from "../src/backends/native-project-internal-hosts.ts"; import { resolveInternalExtraHosts } from "../src/lib/internal-extra-hosts.ts"; import { resolvePrimaryLocalProjectDir } from "../src/lib/worktree-local-config.ts"; import { fixtureGitEnv } from "./helpers/fixture-git.ts"; @@ -255,3 +256,38 @@ test("explicit local static aliases override inherited aliases; local dynamic wi "search.test": "127.0.0.3", }); }); + +test("native alias sources distinguish project config, this checkout and the inherited main checkout", async () => { + const f = await fixture(); + await f.write(f.primary, { "inherited.example.test": "host-gateway" }); + await f.write(f.branch, { "local.example.test": "host-gateway" }); + const configFile = join(f.context.projectDir, "hack.config.json"); + await Bun.write( + configFile, + JSON.stringify({ + name: "sources-fixture", + internal: { extra_hosts: { "project.example.test": "host-gateway" } }, + }) + ); + const selection = await resolveNativeInternalExtraHostSelection({ + project: { + ...f.context, + projectDirName: ".hack", + composeFile: join(f.context.projectDir, "docker-compose.yml"), + envFile: join(f.context.projectDir, ".env"), + configFile, + }, + }); + expect(selection).toEqual({ + hosts: { + "inherited.example.test": "host-gateway", + "local.example.test": "host-gateway", + "project.example.test": "host-gateway", + }, + sources: { + "inherited.example.test": "inherited", + "local.example.test": "checkout", + "project.example.test": "project", + }, + }); +}); diff --git a/tests/native-compose-interpolation.test.ts b/tests/native-compose-interpolation.test.ts new file mode 100644 index 000000000..5bdc0c889 --- /dev/null +++ b/tests/native-compose-interpolation.test.ts @@ -0,0 +1,49 @@ +/* biome-ignore-all lint/suspicious/noTemplateCurlyInString: literal Compose interpolation fixtures. */ +import { expect, test } from "bun:test"; +import { resolveNativeComposeText } from "../src/backends/native-compose-interpolation.ts"; + +function resolved(text: string, managed: readonly string[] = []) { + return resolveNativeComposeText({ text, managed: new Set(managed) }); +} + +test("unset references select defaults and drop alternatives, as native execution does", () => { + expect(resolved("api.example.test")).toBe("api.example.test"); + expect(resolved("${UNSET_PREFIX:+x-}api.example.test")).toBe( + "api.example.test" + ); + expect(resolved("${API_HOST:-api.example.test}")).toBe("api.example.test"); + expect(resolved("${A:-${B:-${C:-${D:-deep}}}}.example.test")).toBe( + "deep.example.test" + ); + // An unselected word is validated but its references are not required. + expect(resolved("${A:+${B}}api.example.test")).toBe("api.example.test"); + expect(resolved("cost$$5")).toBe("cost$5"); + expect(resolved("${A:-a$$b}")).toBe("a$b"); +}); + +test("text native execution would refuse, or cannot know yet, is unknown", () => { + for (const text of [ + "${REQUIRED}", + "$REQUIRED.example.test", + "${A:-${REQUIRED}}", + "${A:?message}", + "${A-word}", + "${A:}", + "${1BAD:-x}", + "${A:-{literal}}", + "${A:-$B}", + "${unclosed", + "trailing$", + "${A:-${B:-${C:-${D:-${E:-x}}}}}", + // Bound to the reviewed project name only after review. + "${COMPOSE_PROJECT_NAME:-api}.example.test", + "${COMPOSE_PROJECT_NAME}", + ]) { + expect(resolved(text)).toBeNull(); + } + // A Hack-managed name is delivered privately and never interpolated. + expect(resolved("${SECRET:-api.example.test}", ["SECRET"])).toBeNull(); + expect(resolved("${A:+${SECRET}}", ["SECRET"])).toBeNull(); + // The project name in an unselected word does not matter. + expect(resolved("${A:+${COMPOSE_PROJECT_NAME}}x")).toBe("x"); +}); diff --git a/tests/native-project-internal-hosts.test.ts b/tests/native-project-internal-hosts.test.ts index d9e67ca41..68b3ffd0b 100644 --- a/tests/native-project-internal-hosts.test.ts +++ b/tests/native-project-internal-hosts.test.ts @@ -122,16 +122,59 @@ test("project-wide aliases are declared on every service like the Compose overri }); test("project-wide aliases without any dependency selection refuse instead of being ignored", () => { - expectCodedThrow( + const error = expectCodedThrow( () => applyNativeInternalHostAliases({ - input: input({ web: { image: "nginx" } }), + input: input({ web: { image: "nginx" }, worker: { image: "nginx" } }), hosts: { "api.example.test": "host-gateway" }, + sources: { "api.example.test": "inherited" }, hasDependencySelection: false, }), "E_NATIVE_PROJECT_UNSUPPORTED", NATIVE_UNBOUND_HOST_ALIAS_REFUSAL ); + expect(error.message).toContain( + "Unbound: api.example.test for service web, service worker (from the main checkout's .hack/.internal/extra-hosts.json, which this linked worktree inherits)." + ); + expect(error.message).toContain( + 'Add a binding with "aliases": ["api.example.test"] to the HACK_NATIVE_DEPENDENCIES selection' + ); + expect(error.message).toContain( + "run `hack internal extra-hosts unset api.example.test` in this worktree" + ); +}); + +test("an unbound refusal names each service, alias and where the alias comes from", () => { + const hostAliases = { + web: ["api.example.test", "auth.example.test", "own.example.test"], + worker: ["api.example.test", "local.example.test"], + }; + const error = expectCodedThrow( + () => + requireNativeHostAliasBindings({ + dependencies: [binding("web", ["auth.example.test"])], + hostAliases, + sources: { + "api.example.test": "project", + "auth.example.test": "project", + "local.example.test": "checkout", + }, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + NATIVE_UNBOUND_HOST_ALIAS_REFUSAL + ); + expect(error.message).toContain( + "Unbound: api.example.test for service web, service worker (from internal.extra_hosts in the project configuration); local.example.test for service worker (from this checkout's .hack/.internal/extra-hosts.json); own.example.test for service web (from the service's Compose extra_hosts)." + ); + expect(error.message).toContain( + '"aliases": ["api.example.test","local.example.test","own.example.test"]' + ); + expect(error.message).toContain( + "remove api.example.test from internal.extra_hosts in the project configuration" + ); + // Listener values are never echoed. + expect(error.message).not.toContain("8443"); + expect(error.message).not.toContain("123"); }); test("project-wide aliases native cannot honor refuse value-free", () => { @@ -170,6 +213,21 @@ test("a service that maps the alias to another target conflicts", () => { ["api.example.test:10.0.0.5"], ["api.example.test=10.0.0.5"], { "api.example.test": "10.0.0.5" }, + ["${UNSET:-api.example.test}:10.0.0.5"], + ]) { + const error = expectCodedThrow( + () => + applyNativeInternalHostAliases({ + input: input({ web: { image: "nginx", extra_hosts } }), + hosts: { "api.example.test": "host-gateway" }, + hasDependencySelection: true, + }), + "E_CONFIG_INVALID", + "Native host alias selection conflicts with declared configuration: service web maps project-wide host alias api.example.test to another target" + ); + expect(error.message).not.toContain("10.0.0.5"); + } + for (const extra_hosts of [ "api.example.test:host-gateway", Array.from({ length: 32 }, (_, i) => `h${i}.example.test:host-gateway`), ]) { @@ -224,10 +282,7 @@ test("service aliases are canonicalized like native review before matching bindi services: { web: { extra_hosts: ["API.Example.:host-gateway"] }, map: { extra_hosts: { "Search.EXAMPLE.test.": "host-gateway" } }, - both: { - extra_hosts: ["api.example:host-gateway", "API.EXAMPLE.=host-gateway"], - }, - // Resolved by the runtime later; admission checks these exactly. + // A required reference refuses at execution; admission checks it there. later: { extra_hosts: ["${ALIAS}:host-gateway", "1.2.3.4:host-gateway"] }, }, profiles: [], @@ -235,14 +290,12 @@ test("service aliases are canonicalized like native review before matching bindi expect(hostAliases).toEqual({ web: ["api.example"], map: ["search.example.test"], - both: ["api.example"], later: [], }); requireNativeHostAliasBindings({ dependencies: [ binding("web", ["api.example"]), binding("map", ["search.example.test"]), - binding("both", ["api.example"]), ], hostAliases, }); @@ -258,6 +311,119 @@ test("service aliases are canonicalized like native review before matching bindi ); }); +test("an interpolated service alias is compiled like native execution before matching", () => { + // Native execution supplies no ambient environment: `${UNSET:+x-}` selects + // nothing and `${UNSET:-word}` selects its word. + const hostAliases = nativeServiceHostAliases({ + services: { + plus: { + extra_hosts: ["${UNSET_PREFIX:+x-}api.example.test:host-gateway"], + }, + dash: { + extra_hosts: { "${API_HOST:-API.example.test.}": "host-gateway" }, + }, + target: { + extra_hosts: ["api.example.test:${GATEWAY:-host-gateway}"], + }, + // Managed values are delivered privately and never interpolated. + managed: { extra_hosts: ["${SECRET:-api.example.test}:host-gateway"] }, + // The project name is bound only after review. + named: { + extra_hosts: ["${COMPOSE_PROJECT_NAME:-api}.example.test:host-gateway"], + }, + }, + profiles: [], + managedEnvironment: { managed: { SECRET: "synthetic" } }, + }); + expect(hostAliases).toEqual({ + plus: ["api.example.test"], + dash: ["api.example.test"], + target: ["api.example.test"], + managed: [], + named: [], + }); +}); + +test("one alias declared twice after interpolation refuses before runtime work, naming it", () => { + for (const extra_hosts of [ + ["api.example:host-gateway", "API.EXAMPLE.=host-gateway"], + [ + "api.example.test:host-gateway", + "${UNSET_PREFIX:+x-}api.example.test:host-gateway", + ], + { + "api.example.test": "host-gateway", + "${A:-api.example.test}": "host-gateway", + }, + ]) { + const alias = + Array.isArray(extra_hosts) && extra_hosts[0]?.startsWith("api.example:") + ? "api.example" + : "api.example.test"; + const error = expectCodedThrow( + () => + nativeServiceHostAliases({ + services: { web: { extra_hosts } }, + profiles: [], + }), + "E_CONFIG_INVALID", + `Native host alias selection conflicts with declared configuration: service web declares host alias ${alias} more than once` + ); + expect(error.message).not.toContain("host-gateway"); + } +}); + +test("HACK-1938: an interpolated alias equal to a project-wide alias is not added twice", () => { + const hosts = { "api.example.test": "host-gateway" }; + for (const declared of [ + "${UNSET_PREFIX:+x-}api.example.test:host-gateway", + "${API_HOST:-api.example.test}:host-gateway", + // Literal control: the same name without interpolation. + "api.example.test:host-gateway", + ]) { + const result = applyNativeInternalHostAliases({ + input: input({ app: { image: "alpine", extra_hosts: [declared] } }), + hosts, + hasDependencySelection: true, + }); + const app = services(result).app; + expect(app.extra_hosts).toEqual([declared]); + // What native execution then compiles is exactly one alias. + expect( + nativeServiceHostAliases({ services: { app }, profiles: [] }) + ).toEqual({ app: ["api.example.test"] }); + } + // The map form is compared the same way. + const map = applyNativeInternalHostAliases({ + input: input({ + app: { + image: "alpine", + extra_hosts: { "${UNSET_PREFIX:+x-}api.example.test": "host-gateway" }, + }, + }), + hosts, + hasDependencySelection: true, + }); + expect(services(map).app.extra_hosts).toEqual({ + "${UNSET_PREFIX:+x-}api.example.test": "host-gateway", + }); + // A name that interpolates to something else still receives the alias. + const other = applyNativeInternalHostAliases({ + input: input({ + app: { + image: "alpine", + extra_hosts: ["${UNSET_PREFIX:-x-}api.example.test:host-gateway"], + }, + }), + hosts, + hasDependencySelection: true, + }); + expect(services(other).app.extra_hosts).toEqual([ + "${UNSET_PREFIX:-x-}api.example.test:host-gateway", + "api.example.test:host-gateway", + ]); +}); + test("a project-wide alias another spelling already declares is not duplicated", () => { const result = applyNativeInternalHostAliases({ input: input({ @@ -287,7 +453,7 @@ test("a project-wide alias another spelling already declares is not duplicated", hasDependencySelection: true, }), "E_CONFIG_INVALID", - "Native host alias selection conflicts with declared configuration; values omitted." + "Native host alias selection conflicts with declared configuration: service web maps project-wide host alias api.example.test to another target" ); }); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 2b12778a7..7ddc106dc 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -37,13 +37,18 @@ import { NATIVE_CONFIGURATION_REFUSAL, NATIVE_SHARED_SOURCE_REFUSAL, nativeReadinessArgs, + nativeReadySummary, + nativeStoppedNotice, parseNativeHttpsSelection, prepareNativeProjectServices, reviewedNativeHttpsRoutes, startNativeProject, verifyHttpsRoutes, } from "../src/backends/native-project-start.ts"; -import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { + NATIVE_GRAPH_UNRESERVED, + NativeRuntimeRequestError, +} from "../src/backends/native-runtime-client.ts"; import { ClosedCliError, HackCliError } from "../src/lib/cli-result.ts"; import { type ComposeEnvFingerprintRecord, @@ -4289,13 +4294,18 @@ test("a project-wide alias without a binding for every active service refuses be ...opts, dependencyFile: path, internalExtraHosts, + internalExtraHostSources: { "api.example.test": "inherited" }, }).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(HackCliError); expect(error).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); expect(String((error as Error).message)).toContain( "Native host alias has no reviewed host dependency binding" ); - expect(String((error as Error).message)).not.toContain("example"); + // HACK-1939: the refusal names the service, the alias and its source. + expect(String((error as Error).message)).toContain( + "Unbound: api.example.test for service worker (from the main checkout's .hack/.internal/extra-hosts.json, which this linked worktree inherits)." + ); + expect(String((error as Error).message)).not.toContain("8443"); expect(events).toContain("before"); expect(events).toContain("cleanup"); expect(events).not.toContain("runtime up"); @@ -4318,3 +4328,136 @@ test("project-wide aliases without a dependency selection refuse before hooks in // Without project-wide aliases the same project still starts unchanged. expect(await startNativeProject({ ...opts, internalExtraHosts: {} })).toBe(0); }); + +test("HACK-1939: a startup that failed before reserving any runtime state does not report unconfirmed cleanup", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + opts.dependencies.serve = async () => { + throw new Error( + "Native graph startup was interrupted or failed (invalid_extra_hosts); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "inspect") { + events.push("graph 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).toBeInstanceOf(ClosedCliError); + expect((error as Error).message).toBe( + "Native graph startup failed (invalid_extra_hosts) before any runtime state was reserved; nothing was created and no cleanup is needed." + ); + expect(events).toContain("graph inspect"); + expect(events).not.toContain("save"); + expect(events.at(-1)).toBe("cleanup"); +}); + +test("an unreserved run after a startup that published a mapping still reports unconfirmed cleanup", async () => { + const { opts } = await fixture(); + const invoke = opts.dependencies.invoke!; + const serve = opts.dependencies.serve!; + let served = false; + opts.dependencies.serve = async (request) => { + await serve({ + ...request, + onReady: async () => { + await request.onReady(); + served = true; + }, + }); + throw new Error( + "Native graph startup was interrupted or failed (runtime_not_running); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if (served && request.args[1] === "inspect") { + throw new NativeRuntimeRequestError({ + message: "Native graph run is not reserved.", + nativeCode: NATIVE_GRAPH_UNRESERVED, + }); + } + return await invoke(request); + }; + await expect(startNativeProject(opts)).rejects.toThrow( + "cleanup is unconfirmed" + ); +}); + +test("HACK-1939: foreground up prints a ready summary and a confirmed-stop line", async () => { + const { opts } = await fixture(); + const lines: string[] = []; + opts.dependencies.notice = (line) => { + lines.push(line); + }; + const controller = new AbortController(); + expect( + await startNativeProject({ + ...opts, + signal: controller.signal, + onReady: async () => { + controller.abort(); + }, + }) + ).toBe(130); + expect(lines[0]).toMatch( + /^Native project ready \(run [a-f0-9]{32}\)\. Press Ctrl-C to stop; use hack ps and hack logs from another terminal\.$/ + ); + expect(lines.at(-1)).toBe( + "Native project stopped; owned cleanup confirmed. Named volumes and the stopped run mapping are kept for the next hack up." + ); +}); + +test("the ready summary lists reviewed HTTPS routes and published loopback ports only", () => { + const plan = { + services: { + web: { + active: true, + routing: { hostnames: ["app.example.test", "www.example.test"] }, + ports: [ + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: 5173 }, + ], + }, + api: { active: true, routing: { hostnames: ["api.example.test"] } }, + odd: { active: true, routing: { hostnames: ["Not A Host"] } }, + db: { + active: true, + ports: [ + { protocol: "tcp", proposed_host_ip: "127.0.0.1", published: 5432 }, + { protocol: "tcp", proposed_host_ip: "0.0.0.0", published: 6000 }, + ], + }, + }, + }; + const run = "a".repeat(32); + const routed = new Set(["web", "api", "odd"]); + expect(nativeReadySummary({ run, plan, routed, httpsPort: 18_443 })).toEqual([ + `Native project ready (run ${run}). Press Ctrl-C to stop; use hack ps and hack logs from another terminal.`, + " api: https://api.example.test:18443", + " web: https://app.example.test:18443 https://www.example.test:18443", + " Published ports: 127.0.0.1:5173 127.0.0.1:5432", + ]); + expect(nativeReadySummary({ run, plan, routed, httpsPort: 443 })[1]).toBe( + " api: https://api.example.test" + ); + expect(nativeReadySummary({ run, plan, routed, httpsPort: null })).toEqual([ + `Native project ready (run ${run}). Press Ctrl-C to stop; use hack ps and hack logs from another terminal.`, + " Routes are not served on this host: the native HTTPS frontend is not selected (HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256 and HACK_NATIVE_HTTPS_PORT).", + " Published ports: 127.0.0.1:5173 127.0.0.1:5432", + ]); +}); + +test("the confirmed-stop line reflects whether data was retained", () => { + expect(nativeStoppedNotice({ receipt: { phase: "removed" } })).toBe( + "Native project stopped; owned cleanup confirmed and no runtime data was retained." + ); + expect( + nativeStoppedNotice({ receipt: { phase: "stopped-data-retained" } }) + ).toContain("Named volumes and the stopped run mapping are kept"); +}); diff --git a/tests/native-startup-logs.test.ts b/tests/native-startup-logs.test.ts index 0a7ab3606..b3e25ceea 100644 --- a/tests/native-startup-logs.test.ts +++ b/tests/native-startup-logs.test.ts @@ -76,6 +76,19 @@ test("values shorter than four characters are redacted only as whole tokens", () ); }); +test("HACK-1939: short values never shred version strings, decimals or hyphenated words", () => { + expect( + redactNativeStartupLog( + "mysqld 8.0.46 ready; ratio 0.5; build 1-2; flag v-1; DEBUG=0; exit -1; mode 0.", + ["0", "1"] + ) + ).toBe( + "mysqld 8.0.46 ready; ratio 0.5; build 1-2; flag v-1; DEBUG=[REDACTED]; exit -[REDACTED]; mode [REDACTED]." + ); + // Long values are still redacted wherever they occur. + expect(redactNativeStartupLog("v8.0.46-x", ["8.0.46"])).toBe("v[REDACTED]-x"); +}); + test("remaining control characters cannot reach the terminal", () => { expect(redactNativeStartupLog("bell\u0007 tab\tback\b", [])).toBe( "bell� tab\tback�" From afb4526eb64fefba64dca742f01bbdd8e1ef903c Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:08:03 -0500 Subject: [PATCH 124/138] fix(native): reconcile edited graphs that have a dependency cache An env-only Compose edit stranded a retained run whose graph has a `hack.dependencies.*` cache: plan reconciliation skipped every cache graph, so the edit fell through to the hostname-change proof and `up`/`restart` refused with a misleading `graph_hostname_change`. - Cache graphs are now eligible for reconciliation. A cache whose freshly resolved identity equals the recorded one is kept with its provenance; a changed identity selects that identity's shared cache exactly as a fresh admission does (adopt by labels or create, then the initializer runs) from a freshly published source snapshot, and only this run's reference to the old shared cache is released. Run-owned data is never reselected as a cache: declaring one refuses with `graph_plan_reconcile` and names the service and the `hack.dependencies.cache-volume` field. - A cache identity now binds the initializer's own reviewed definition and its cache volume declaration instead of the whole Compose digest, so an edit to another service keeps the cache, as Compose keeps a named volume. Receipts recorded under the earlier whole-document identity keep their cache while the review still resolves to it, and move to the new identity on the first edit. - Graphs that reconciliation still does not select (live source, a carried cache scope, a pending source-device rebind or initializer cache release) refuse a non-hostname edit with `graph_plan_change_unsupported` instead of `graph_hostname_change`; a hostname change mixed with other edits names the routed service. Errors carry a value-free `plan_change` diagnostic and the CLI prints closed guidance: revert, or `hack down --remove-data` then `up`. - Adds the native-cache-reconcile TLA+ model (54 states) with negative controls for whole-document identities, stale cache reattachment and run data reused as a cache. Refs HACK-1937 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 28 +- docs/reference/v5-model-coverage.md | 3 +- packages/runtime-core/README.md | 19 +- packages/runtime-core/src/error.rs | 34 ++ .../src/provider/graph/cache_provenance.rs | 9 + .../runtime-core/src/provider/graph/config.rs | 55 ++- .../src/provider/graph/dependency_cache.rs | 119 ++++- .../src/provider/graph/foreground.rs | 6 +- .../src/provider/graph/hostname_change.rs | 82 +++- .../src/provider/graph/plan_reconcile.rs | 429 ++++++++++++++++-- .../src/provider/graph/restore.rs | 17 +- scripts/lib/tla-runtime-models.ts | 22 + src/backends/native-plan-change.ts | 70 +++ src/backends/native-project-restore.ts | 11 +- src/backends/native-project-retained.ts | 34 +- src/backends/native-runtime-client.ts | 13 +- tests/models/tla/README.md | 37 +- .../native-cache-reconcile/CacheReconcile.tla | 103 +++++ .../native-cache-reconcile/data-as-cache.cfg | 4 + .../tla/native-cache-reconcile/negative.cfg | 4 + .../tla/native-cache-reconcile/positive.cfg | 4 + .../tla/native-cache-reconcile/stale.cfg | 4 + tests/native-plan-change.test.ts | 39 ++ tests/native-project-restore.test.ts | 52 +++ tests/native-project-retained-images.test.ts | 40 ++ tests/native-runtime-client.test.ts | 31 ++ 26 files changed, 1167 insertions(+), 102 deletions(-) create mode 100644 src/backends/native-plan-change.ts create mode 100644 tests/models/tla/native-cache-reconcile/CacheReconcile.tla create mode 100644 tests/models/tla/native-cache-reconcile/data-as-cache.cfg create mode 100644 tests/models/tla/native-cache-reconcile/negative.cfg create mode 100644 tests/models/tla/native-cache-reconcile/positive.cfg create mode 100644 tests/models/tla/native-cache-reconcile/stale.cfg create mode 100644 tests/native-plan-change.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 6d3263668..a0a7230d6 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -713,10 +713,30 @@ is reattached under its recorded name after the same label and identity checks; newly declared volume is created fresh; a retained volume the new file no longer declares stays owned and unmounted until `hack down --remove-data` (Compose also keeps project volumes). Retained data is only ever reattached to the run and -namespace that created it. Graphs with dependency caches, live source or a pending -source-device rebind keep the narrower rules below. `hack restart` of a stopped -graph refuses and points to `hack up`. -For a shared-source graph admitted with a retained compatibility contract, ordinary +namespace that created it. + +Graphs with `hack.dependencies.*` caches reconcile the same way. A cache's identity +covers only its own inputs: the initializer's service definition and resolved +command, entrypoint, environment and user, the cache declaration, its image, the +declared lockfile and runtime-file contents, and the cache volume layout. An edit +to another service, such as an environment value on the app, keeps the cache. An +edit to one of those inputs, including a lockfile, selects the cache for the new +identity as a fresh `up` would, so the initializer installs into it; the old cache +is shared and is only released by this run. Each such `up` publishes the current +source snapshot. A retained named volume is never reused as a dependency cache: +declaring one refuses and names the service and the `hack.dependencies.cache-volume` +field. Caches recorded by earlier builds, whose identity also covered the whole +Compose file, are kept while that file is unchanged and replaced once on the first +edit. Live source, a cache scope carried across a source-device rebind or a pending +initializer cache release keep the narrower rules below: a Compose edit other than +a routed hostname change refuses there with `graph_plan_change_unsupported`, and a +hostname change mixed with other edits refuses with `graph_hostname_change` and the +service name. Every refusal says to revert the edit, or to run +`hack down --remove-data` and then `hack up`. `hack restart` of a stopped graph +refuses and points to `hack up`. +For a shared-source graph that reconciliation does not select (live source, a +carried cache scope, a pending source-device rebind or initializer cache release) +and that was admitted with a retained compatibility contract, ordinary source-content edits may change the reviewed plan ID: restart checks the stable execution, exclusion, mount and dependency-cache inputs before cleanup and again under the provider lease. The original installer publication and graph ownership diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 2e4438c36..386fec859 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -40,7 +40,7 @@ must be attached to the eventual exact integration revision. | Foreground Compose startup failing after resources started; unconfirmed cleanup; unauthenticated final inspection | native-startup-owner | `recordFailedStartupOwner` in [native-project-start](../../src/backends/native-project-start.ts) publishes the authenticated failed run with a finalization lifetime; [start regressions](../../tests/native-project-start.test.ts) cover recording, an unauthenticated receipt and a retained mapping | A refused final inspection still leaves only the runtime journal; crash between finalization registration and mapping publication; physical guest acceptance. | | Canceled/expired startup; reader cancellation versus EOF; producer/group uncertainty | pre-ready-retirement, authority-lifetime, native-file-material | Native graph finalizer/onSettled, exact Removed, hard-signal freshness, host stop/close; local pre-Ready retained/removed regressions, including separate live/foreign/hard-canceled fresh-teardown-admission assertions (HACK-1801) | Physical guest acceptance; current root aggregate; explicit executable unknown host-close control; async proof-to-stop and partial binding-retirement crash windows; post-stop source/session replacement or hard cancellation before Retire (Retire does not recheck these inputs; Safe has no post-stop interference predicate). | | Crash between reserve, publish, sync and release; stale recovery | native-frontend-recovery, publication, publication-journal, native-storage-witness | Durable intent/enrolled/released records, fresh lease and expected binding; recovery and sync-failure tests | Fsync guarantees, process-kill windows, real restarted provider observations. No missing-path authority. | -| Data lifetime independent of compute; replaced/aliased metadata; retained-data handoff across Compose plan changes | native-persistent-enrollment, native-storage-witness, native-file-material, native-plan-reconcile | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests; `provider/graph/plan_reconcile.rs` projection tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. | +| Data lifetime independent of compute; replaced/aliased metadata; retained-data handoff across Compose plan changes, including dependency caches | native-persistent-enrollment, native-storage-witness, native-file-material, native-plan-reconcile, native-cache-reconcile | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests; `provider/graph/plan_reconcile.rs` projection and cache-selection tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. | | Settled retained Compose route-attempt retirement; pinned references; interrupted deletion | **Coverage gap:** filesystem journal compaction is not modeled; native-route-stop covers a different stop protocol | Instance mutation lock and exact surviving claim witnesses in [journal compaction](../../src/lib/native-compose-route-claims.ts); [claim regressions](../../tests/native-compose-route-claims.test.ts) and [retained lifecycle integration](../../tests/native-compose-adoption-routing-generation.test.ts) | 24 successful operations keep two attempt directories and constant Linux descriptors; four integrated lifecycle operations check directory counts. Injected unlink failures cover same-store and fresh-store cleanup retries. Assumes cooperative same-user writers, the caller's mutation lock and working filesystem synchronization. No process-kill/power-loss durability proof or bound on unresolved/over-budget histories. | | Graph cleanup container stop batch: bounded in-flight stops, shared worker queue, connect-stage retry, batch-wide unreachable state, deadline expiry, worker panic | **Coverage gap:** no maintained model covers scheduling inside the stop batch; native-route-stop covers a different stop protocol | `dispatch` and `attempt` in [the stop batch](../../packages/runtime-core/src/provider/engine/stop.rs): at most 4 stops in flight, every worker joined; only `connect`/`connect_timeout` failures retry (25 ms doubling to 500 ms, within the batch deadline and 30 s of a stop's first connect failure); once one stop exhausts its window, later connect failures return at once; a deadline reached during backoff keeps the connect stage; `response`, `timeout` and `transport` never retry. Regressions in the same file's `provider::engine::stop` tests (bounded in-flight socket server, late socket, missing socket, 14-stop retry counts, persistent connect failures, oversleeping backoff, non-retried stages, worker panic) and `fourteen_service_connect_failures_keep_resumable_progress_and_retry_only_unstopped` | Tests cover exact attempt counts and the in-flight bound for fixed interleavings; no model explores all worker schedules or proves at most one delivered request per stop. Assumes a failed connect sent no request bytes and that the stop budget covers fully serialized grace periods. Graph-level resume after a partial batch is covered by tests only. Host qualification of the descriptor-pressure root cause (HACK-1302) remains open. | | Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. | @@ -67,6 +67,7 @@ liveness/reachability controls have their own documented assumptions. | Model | Positive distinct states | Configurations | | --- | ---: | ---: | | [native-plan-reconcile](../../tests/models/tla/native-plan-reconcile/Reconcile.tla) | 747,656 | 4 | +| [native-cache-reconcile](../../tests/models/tla/native-cache-reconcile/CacheReconcile.tla) | 54 | 4 | | [pre-ready-retirement](../../tests/models/tla/pre-ready-retirement/Retirement.tla) | 10,965 | 8 | | [native-persistent-enrollment](../../tests/models/tla/native-persistent-enrollment/Enrollment.tla) | 38 | 6 | | [native-frontend-recovery](../../tests/models/tla/native-frontend-recovery/Recovery.tla) | 602 | 10 | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index e7abf4bcd..35c2d007b 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1451,10 +1451,21 @@ admission would, retained volumes are projected by logical key onto their record names, newly declared volumes receive unused `reserved` names in this run and are created exclusively, and retained volumes the plan no longer declares stay in the receipt. Run, owner, namespace and the ownership plan on every label are unchanged. -`graph source-compatibility` reports `"reconcile": true` for this path. Receipts -with dependency caches, live source or a pending source-device rebind keep the -hostname/shared-source proofs. A volume still `reserved` in a stopped receipt was -never created; selection requires it absent and restore creates it. +`graph source-compatibility` reports `"reconcile": true` for this path. Dependency +caches reconcile too: a cache whose freshly resolved identity (the initializer's own +definition and inputs, not the whole Compose digest) equals the recorded binding is +kept with its provenance; a changed identity is selected like a fresh admission +(adopted by labels or created) with a freshly published source revision, and the old +shared cache is only released. A retained run-owned volume newly declared as a cache +refuses with `graph_plan_reconcile` and a `plan_change` diagnostic naming the service +and field. A receipt recorded under the earlier whole-document identity keeps that +cache while the review still resolves to it (`config::retain_recorded_legacy_caches`). +Receipts with live source, a carried cache scope, pending initializer cache release +or a pending source-device rebind keep the hostname/shared-source proofs; a +non-hostname edit there refuses with `graph_plan_change_unsupported`. A volume still +`reserved` in a stopped receipt was never created; selection requires it absent and +restore creates it, except a reserved dependency cache, which may already exist as a +shared cache. Selection binds the receipt, current VM boot and verified current volume observations. Restoration rechecks these before effects, requires the old owner diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index 2e9f3132b..784244fdb 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -53,6 +53,27 @@ pub struct StartupLogsDiagnostic { pub services: Vec, } +/// Closed vocabulary for the Compose field of a retained-run edit that cannot be +/// applied in place. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanChangeField { + /// A retained run-owned named volume is now declared as a dependency cache. + DependencyCacheVolume, + /// Routed hostnames changed together with something only reconciliation applies. + Hostnames, +} + +/// Value-free location of a refused retained-run edit. The service is an +/// admitted Compose identifier, never a value, path or engine ID. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PlanChangeDiagnostic { + pub version: u8, + pub service: String, + pub field: PlanChangeField, +} + #[derive(Debug, Serialize)] pub struct CandidateError { pub code: &'static str, @@ -66,6 +87,8 @@ pub struct CandidateError { /// [`StartupLogTail`]. Never persist an error carrying it. #[serde(skip_serializing_if = "Option::is_none")] pub startup_logs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_change: Option>, } impl CandidateError { @@ -76,9 +99,20 @@ impl CandidateError { cause_code: None, stop_failures: None, startup_logs: None, + plan_change: None, } } + /// Name the service and field of a refused retained-run edit. + pub fn with_plan_change(mut self, service: &str, field: PlanChangeField) -> Self { + self.plan_change = Some(Box::new(PlanChangeDiagnostic { + version: 1, + service: service.to_owned(), + field, + })); + self + } + pub fn with_cause_code(mut self, code: String) -> Self { self.cause_code = Some(code); self diff --git a/packages/runtime-core/src/provider/graph/cache_provenance.rs b/packages/runtime-core/src/provider/graph/cache_provenance.rs index 5dc6cc873..9f243449b 100644 --- a/packages/runtime-core/src/provider/graph/cache_provenance.rs +++ b/packages/runtime-core/src/provider/graph/cache_provenance.rs @@ -63,6 +63,15 @@ impl CacheProvenance { && bounded(&c.finished_at) }) } + /// Keep only initializers the reconciled graph still declares. The volume + /// identity fence is unchanged; removed services' history is dropped so the + /// receipt never names a container it no longer records. + pub(super) fn retain_initializers(&mut self, mut present: impl FnMut(&str) -> bool) { + self.initializers.retain(|service| present(service)); + let initializers = &self.initializers; + self.completed + .retain(|service, _| initializers.contains(service)); + } pub(super) fn needs_completion(&self, service: &str) -> bool { self.origin == Origin::CreatedHere && self.initializers.contains(service) diff --git a/packages/runtime-core/src/provider/graph/config.rs b/packages/runtime-core/src/provider/graph/config.rs index 8cadf64d4..f9411278a 100644 --- a/packages/runtime-core/src/provider/graph/config.rs +++ b/packages/runtime-core/src/provider/graph/config.rs @@ -16,12 +16,53 @@ pub(super) fn container_base(image: &str, labels: Value) -> Value { pub(super) struct Prepared { pub graph: Graph, pub cache_initializers: BTreeMap, + /// Earlier whole-document identities of this review's cache volumes, keyed by + /// logical volume. Only [`retain_recorded_legacy_caches`] may select one. + pub legacy_caches: BTreeMap, pub configs: BTreeMap, pub probes: BTreeMap, pub resources: BTreeMap, pub namespace: String, pub plan_id: String, } +/// A receipt recorded before cache identities bound only the initializer's own +/// definition keeps its exact cache volume while the review still resolves to +/// that recorded identity under the earlier formula. Nothing else is projected: +/// a changed input yields a different legacy identity and the current one stays. +pub(super) fn retain_recorded_legacy_caches(prepared: &mut Prepared, receipt: &Receipt) { + let mut renamed = BTreeMap::new(); + for (volume, legacy) in &prepared.legacy_caches { + let key = format!("volume:{volume}"); + let Some(resource) = prepared.resources.get_mut(&key) else { + continue; + }; + if resource.cache.as_ref() == Some(legacy) + || receipt + .resources + .get(&key) + .and_then(|old| old.cache.as_ref()) + != Some(legacy) + { + continue; + } + renamed.insert(resource.name.clone(), legacy.name()); + resource.name = legacy.name(); + resource.cache = Some(legacy.clone()); + } + if renamed.is_empty() { + return; + } + for config in prepared.configs.values_mut() { + if let Some(mounts) = config["HostConfig"]["Mounts"].as_array_mut() { + for mount in mounts.iter_mut().filter(|m| m["Type"] == "volume") { + if let Some(name) = mount["Source"].as_str().and_then(|s| renamed.get(s)) { + mount["Source"] = json!(name); + } + } + } + } +} + /// After source replay admission, preserve the original graph ownership identity /// on restored requests. Never change resource names, images, routes or cache keys. pub(super) fn retain_replay_ownership( @@ -44,6 +85,7 @@ fn retain_ownership( receipt: &Receipt, change: Option<&hostname_change::Verified>, ) -> Result<(), CandidateError> { + retain_recorded_legacy_caches(prepared, receipt); let same = change.map_or_else( || same_resource_bindings(&prepared.resources, &receipt.resources), |change| change.matches_resources(&prepared.resources, receipt), @@ -229,7 +271,10 @@ pub(super) fn prepare_delivery( "At most 32 services fit this driver profile; aggregate resource limits also apply.", )); } - let caches = if plan + let dependency_cache::Resolved { + current: caches, + legacy: legacy_caches, + } = if plan .services .values() .any(|service| service.active && service.dependency_cache.is_some()) @@ -244,14 +289,17 @@ pub(super) fn prepare_delivery( || dependency_cache::scope(&plan.source), |scope| scope.scope(&plan.source), )?; - dependency_cache::resolve( + dependency_cache::resolve_with_legacy( plan, source.current_manifest.as_ref().unwrap_or(&source.manifest), &scope, &inputs.services, )? } else { - BTreeMap::new() + dependency_cache::Resolved { + current: BTreeMap::new(), + legacy: BTreeMap::new(), + } }; let publications = loopback_publications(plan, loopback_ports)?; let mut networks = BTreeSet::new(); @@ -651,6 +699,7 @@ pub(super) fn prepare_delivery( .collect(); Ok(Prepared { cache_initializers, + legacy_caches, graph, configs, probes, diff --git a/packages/runtime-core/src/provider/graph/dependency_cache.rs b/packages/runtime-core/src/provider/graph/dependency_cache.rs index ca6a44b63..ae7cdc624 100644 --- a/packages/runtime-core/src/provider/graph/dependency_cache.rs +++ b/packages/runtime-core/src/provider/graph/dependency_cache.rs @@ -82,12 +82,49 @@ fn selected(manifest: &ContentRevision, path: &str) -> Result, Can _ => Err(refused()), } } +/// Cache identities for one review: `current` names the volumes a fresh +/// admission selects; `legacy` recomputes the earlier whole-document identity for +/// the same inputs, so a retained receipt recorded under it can still be matched +/// exactly. Neither map grants volume creation, adoption or deletion authority. +pub struct Resolved { + pub current: BTreeMap, + pub legacy: BTreeMap, +} + +#[cfg(test)] pub fn resolve( plan: &PlanData, manifest: &ContentRevision, scope: &str, executable: &BTreeMap, ) -> Result, CandidateError> { + Ok(resolve_with_legacy(plan, manifest, scope, executable)?.current) +} + +fn insert_binding( + result: &mut BTreeMap, + volume: &str, + binding: CacheBinding, +) -> Result<(), CandidateError> { + if result + .get(volume) + .is_some_and(|existing| existing != &binding) + { + return Err(refused()); + } + result.insert(volume.to_owned(), binding); + Ok(()) +} + +/// The current identity binds the initializer's own reviewed service definition +/// and its cache volume declaration instead of the whole Compose document, so an +/// edit to an unrelated service keeps the cache, as Compose keeps a named volume. +pub fn resolve_with_legacy( + plan: &PlanData, + manifest: &ContentRevision, + scope: &str, + executable: &BTreeMap, +) -> Result { manifest.validate().map_err(|_| refused())?; if !hex(scope) || !hex(&plan.compose_sha256) @@ -95,7 +132,8 @@ pub fn resolve( { return Err(refused()); } - let mut result = BTreeMap::new(); + let mut current = BTreeMap::new(); + let mut legacy = BTreeMap::new(); for (name, service) in plan.services.iter().filter(|(_, s)| s.active) { let Some(declaration) = &service.dependency_cache else { continue; @@ -141,10 +179,29 @@ pub fn resolve( return Err(refused()); } } - // Compose digest binds actual argv text (the plan's argv representation is redacted). - // Script content belongs in explicitly declared runtime files; whole source revisions - // would prevent compatible branches with unrelated source changes from sharing. + // The plan's argv and literal values are redacted, so the resolved public + // command, entrypoint, environment, user and hosts are bound explicitly. + // Script content belongs in explicitly declared runtime files; whole source + // revisions would prevent compatible branches with unrelated source changes + // from sharing. The legacy identity also bound the whole Compose digest. let base_fingerprint = hash(&( + "hack-dependency-cache-v2", + scope, + &declaration.volume, + image, + service, + &plan.volumes[&declaration.volume], + &plan.registry, + declaration, + &inputs, + &public.command, + &public.entrypoint, + &public.environment, + &public.user, + &public.extra_hosts, + &service.working_dir, + ))?; + let legacy_fingerprint = hash(&( "hack-dependency-cache-v1", scope, &declaration.volume, @@ -173,25 +230,31 @@ pub fn resolve( .map(move |m| (name, &m.target, &m.subpath, m.read_only)) }) .collect::>(); - let fingerprint = if layout.iter().any(|(_, _, subpath, _)| subpath.is_some()) { - hash(&("hack-cache-volume-subpaths-v1", base_fingerprint, layout))? - } else { - base_fingerprint + let subpaths = layout.iter().any(|(_, _, subpath, _)| subpath.is_some()); + let with_layout = |fingerprint: String| -> Result { + if subpaths { + hash(&("hack-cache-volume-subpaths-v1", fingerprint, &layout)) + } else { + Ok(fingerprint) + } }; - let binding = CacheBinding { + let binding = |fingerprint| CacheBinding { scope: scope.into(), fingerprint, image: image.clone(), }; - if result - .get(&declaration.volume) - .is_some_and(|existing| existing != &binding) - { - return Err(refused()); - } - result.insert(declaration.volume.clone(), binding); + insert_binding( + &mut current, + &declaration.volume, + binding(with_layout(base_fingerprint)?), + )?; + insert_binding( + &mut legacy, + &declaration.volume, + binding(with_layout(legacy_fingerprint)?), + )?; } - Ok(result) + Ok(Resolved { current, legacy }) } fn owned_directory(path: &Path) -> Result { @@ -694,7 +757,9 @@ mod tests { .arguments[0] .environment_references .push("INSTALLER".into()); - assert_eq!(baseline, resolve(&dynamic, &manifest, &scope).unwrap()); + // The redacted plan form is part of the initializer's own definition. + let dynamic_baseline = resolve(&dynamic, &manifest, &scope).unwrap(); + assert_ne!(baseline, dynamic_baseline); for field in [ "command", "entrypoint", @@ -716,7 +781,7 @@ mod tests { } } assert_ne!( - baseline, + dynamic_baseline, super::resolve(&dynamic, &manifest, &scope, &values).unwrap() ); } @@ -725,9 +790,23 @@ mod tests { changed.services.get_mut("deps").unwrap().image = Some(format!("sha256:{}", "e".repeat(64))); assert_ne!(baseline, resolve(&changed, &manifest, &scope).unwrap()); + // Only the earlier whole-document identity follows unrelated Compose edits. changed = plan.clone(); changed.compose_sha256 = "f".repeat(64); - assert_ne!(baseline, resolve(&changed, &manifest, &scope).unwrap()); + assert_eq!(baseline, resolve(&changed, &manifest, &scope).unwrap()); + let legacy = |plan: &PlanData| { + super::resolve_with_legacy(plan, &manifest, &scope, &executable(plan)) + .unwrap() + .legacy + }; + assert_ne!(legacy(&plan), legacy(&changed)); + assert_ne!(legacy(&plan), baseline); + // Another service, with its own environment, is not a cache input. + let mut app = plan.services["deps"].clone(); + app.dependency_cache = None; + app.mounts.clear(); + changed.services.insert("app".into(), app); + assert_eq!(baseline, resolve(&changed, &manifest, &scope).unwrap()); changed = plan.clone(); let mut other = changed.services["deps"].clone(); other.dependency_cache.as_mut().unwrap().bootstrap = false; diff --git a/packages/runtime-core/src/provider/graph/foreground.rs b/packages/runtime-core/src/provider/graph/foreground.rs index 033bd4a85..79fb9e648 100644 --- a/packages/runtime-core/src/provider/graph/foreground.rs +++ b/packages/runtime-core/src/provider/graph/foreground.rs @@ -163,7 +163,11 @@ pub fn restore_selection(candidate: &Candidate, run: &str) -> Result CandidateError { error( @@ -9,6 +10,22 @@ fn refused() -> CandidateError { ) } +/// A hostname change of `service` that is refused, named so the caller can say +/// which routed service and field the edit touched. +fn refused_for(service: &str) -> CandidateError { + refused().with_plan_change(service, PlanChangeField::Hostnames) +} + +/// The review changed, but no routed hostname did. Only receipts that plan +/// reconciliation cannot select reach this proof, so the edit is not applicable +/// in place; reporting it as a hostname change would name the wrong field. +pub(super) fn not_hostname_change() -> CandidateError { + error( + "graph_plan_change_unsupported", + "This retained graph uses live source, a carried cache scope, a pending source-device rebind or a pending initializer cache release; only a routed hostname change can be applied to it in place. Values omitted.", + ) +} + pub(super) struct Verified { pub source: source::Inputs, previous: NormalizedInputIdentity, @@ -23,7 +40,7 @@ pub(super) struct Verified { fn selected_routes( plan: &project::PlanData, receipt: &Receipt, -) -> Result, CandidateError> { +) -> Result<(BTreeMap, String), CandidateError> { let mut names = BTreeSet::new(); let routes = plan .services @@ -46,6 +63,17 @@ fn selected_routes( .map(|route| (resource.key.clone(), route)) }) .collect::>(); + let changed = routes + .iter() + .find(|(service, route)| prior.get(*service) != Some(route)) + .map(|(service, _)| service.clone()) + .or_else(|| { + prior + .keys() + .find(|service| !routes.contains_key(*service)) + .cloned() + }) + .ok_or_else(not_hostname_change)?; if routes.is_empty() || routes.len() != prior.len() || !routes.iter().all(|(service, route)| { @@ -56,13 +84,10 @@ fn selected_routes( .iter() .all(|name| names.insert(name.clone())) }) - || routes - .iter() - .all(|(service, route)| prior.get(service).is_some_and(|old| *old == route)) { - return Err(refused()); + return Err(refused_for(&changed)); } - Ok(routes) + Ok((routes, changed)) } /// Identical normalized input remains on the ordinary strict replay path. @@ -81,18 +106,29 @@ pub(super) fn prepare( if previous == next { return Ok(None); } - let routes = selected_routes(plan, receipt)?; - let binding = receipt.source.as_ref().ok_or_else(refused)?; + let (routes, changed) = selected_routes(plan, receipt)?; + let binding = receipt + .source + .as_ref() + .ok_or_else(|| refused_for(&changed))?; let cached = plan .services .values() .any(|service| service.active && service.dependency_cache.is_some()); + // Anything besides the hostnames changed too: name the routed service. let source = source::prepare_shared_hostnames( engine, plan, receipt, cached.then_some(binding.revision.as_str()), - )?; + ) + .map_err(|failure| { + if failure.code == "live_source_contract" { + refused_for(&changed) + } else { + failure + } + })?; super::super::publication::check_hostname_change( engine.guest().candidate(), &receipt.owner, @@ -355,4 +391,32 @@ pub(super) mod tests { assert!(proof.apply(&mut stale).is_err()); } } + + #[test] + fn a_review_without_a_hostname_change_is_not_reported_as_one() { + let (fixture, mut receipt, _) = transition(); + let plan = project::plan( + &Candidate::discover(&fixture.0).unwrap(), + project::PlanOptions { + branch: None, + project: &fixture.0.join("project"), + compose_file: Path::new("compose.yaml"), + profiles: &[], + }, + ) + .unwrap() + .plan; + // The routed service lost its route: a real hostname change, named. + let failure = selected_routes(&plan, &receipt).unwrap_err(); + assert_eq!(failure.code, "graph_hostname_change"); + let change = failure.plan_change.unwrap(); + assert_eq!(change.service, "web"); + assert_eq!(change.field, PlanChangeField::Hostnames); + // No route before or after: some other edit, which only reconciliation + // applies. The accurate code says so instead of naming hostnames. + receipt.resources.get_mut("container:web").unwrap().routing = None; + let failure = selected_routes(&plan, &receipt).unwrap_err(); + assert_eq!(failure.code, "graph_plan_change_unsupported"); + assert!(failure.plan_change.is_none()); + } } diff --git a/packages/runtime-core/src/provider/graph/plan_reconcile.rs b/packages/runtime-core/src/provider/graph/plan_reconcile.rs index 526151433..f05b72997 100644 --- a/packages/runtime-core/src/provider/graph/plan_reconcile.rs +++ b/packages/runtime-core/src/provider/graph/plan_reconcile.rs @@ -13,18 +13,36 @@ //! (Compose also keeps project volumes of removed declarations); explicit //! data removal still reaches it; //! * containers and networks of removed services are dropped from the receipt only -//! because their absence was confirmed before this projection. +//! because their absence was confirmed before this projection; +//! * a dependency cache whose freshly resolved identity equals the recorded one is +//! kept with its provenance. Its identity binds only the initializer's own +//! definition, cache declaration, image and input files, so edits to other +//! services keep it. A changed identity selects that identity's shared cache +//! exactly as a fresh admission would (adopt by labels or create, then the +//! initializer runs); the old cache is shared, never run-owned data, so only +//! this run's reference to it is released. A retained run-owned volume is never +//! reselected as a cache. //! //! Ownership never moves: run, owner, namespace and the ownership plan recorded on //! every label are unchanged, so another instance's data cannot be selected. -//! Dependency caches, live source and source-device rebinding keep their narrower -//! dedicated proofs and are not eligible here. +//! Live source, a cache scope carried across a source-device rebind and pending +//! initializer cache-release effects keep their narrower dedicated proofs and are +//! not eligible here. use super::*; +use crate::error::PlanChangeField; fn refused(message: &str) -> CandidateError { error("graph_plan_reconcile", message) } +/// Run-owned data is never reselected as a shared, content-addressed cache. +fn cache_over_retained_data(service: &str) -> CandidateError { + refused( + "A retained named volume is now declared as a dependency cache; its data was not reselected as a shared cache.", + ) + .with_plan_change(service, PlanChangeField::DependencyCacheVolume) +} + /// Retained named data is any recorded volume that may have been created. A /// volume still `reserved` never received a create intent, so it holds no data. pub(super) fn retained_volume(resource: &Resource) -> bool { @@ -45,25 +63,18 @@ fn active_volume_keys(plan: &project::PlanData) -> BTreeSet { .collect() } -/// Whether the plan or receipt uses machinery that has its own narrower proof. -fn eligible(receipt: &Receipt, plan: &project::PlanData) -> bool { +/// Whether the receipt uses machinery that has its own narrower proof. +fn eligible(receipt: &Receipt) -> bool { receipt.normalized_input.is_some() && receipt.initializer_cache_release.is_empty() - && receipt - .resources - .values() - .all(|resource| resource.cache.is_none() && resource.cache_provenance.is_none()) && receipt .source .as_ref() .is_none_or(|binding| binding.cache_scope.is_none() && binding.live.is_none()) - && plan - .services - .values() - .all(|service| !service.active || service.dependency_cache.is_none()) } -/// The ordinary strict replay derives volume names from the sorted declared set. +/// The ordinary strict replay derives run-owned volume names from the sorted +/// declared set (a dependency cache takes an index but keeps its content name). /// Once a reconciliation reattached data out of that order, or retains data no /// longer declared, only this projection can restore the graph again. fn ordinary_layout(receipt: &Receipt, plan: &project::PlanData) -> bool { @@ -72,18 +83,21 @@ fn ordinary_layout(receipt: &Receipt, plan: &project::PlanData) -> bool { .resources .values() .filter(|resource| resource.kind == Kind::Volume) - .map(|resource| (resource.key.clone(), resource.name.clone())) + .map(|resource| (resource.key.clone(), resource)) .collect::>(); recorded.len() == declared.len() && declared.iter().enumerate().all(|(index, key)| { - recorded.get(key) == Some(&format!("{}{index}", volume_prefix(&receipt.run))) + recorded.get(key).is_some_and(|resource| { + resource.cache.is_some() + || resource.name == format!("{}{index}", volume_prefix(&receipt.run)) + }) }) } /// Selects reconciliation for a reviewed plan that the strict replay path cannot -/// restore. Unchanged reviews with the ordinary layout keep strict replay; -/// graphs with dependency caches, live source or a pending source-device rebind -/// keep their dedicated hostname/shared-source proofs. +/// restore. Unchanged reviews with the ordinary layout keep strict replay; live +/// source, a carried cache scope or a pending source-device rebind keep their +/// dedicated hostname/shared-source proofs. pub(super) fn applies( receipt: &Receipt, plan_id: &str, @@ -92,7 +106,7 @@ pub(super) fn applies( source_rebind: bool, ) -> bool { !source_rebind - && eligible(receipt, plan) + && eligible(receipt) && !(receipt.plan_id == plan_id && receipt.normalized_input.as_ref() == Some(next) && ordinary_layout(receipt, plan)) @@ -126,9 +140,9 @@ pub(super) fn prepare( .normalized_input .clone() .ok_or_else(|| refused("Only normalized graphs can be reconciled."))?; - if !eligible(receipt, plan) { + if !eligible(receipt) { return Err(refused( - "Dependency caches, live source and cache-scoped source use their dedicated restore proofs.", + "Live source, a carried cache scope and pending initializer cache release use their dedicated restore proofs.", )); } if !next.matches_plan(plan) @@ -156,17 +170,31 @@ pub(super) fn prepare( if resource.kind != Kind::Volume { continue; } - if !resource - .name - .strip_prefix(&prefix) - .is_some_and(|index| index.parse::().is_ok_and(|n| n < 64)) - || !matches!(resource.phase.as_str(), "created" | "reserved") - { + let owned_name = resource.cache.as_ref().map_or_else( + || { + resource + .name + .strip_prefix(&prefix) + .is_some_and(|index| index.parse::().is_ok_and(|n| n < 64)) + }, + |cache| cache.valid() && resource.name == cache.name(), + ); + if !owned_name || !matches!(resource.phase.as_str(), "created" | "reserved") { return Err(refused( "Retained data is not this run's settled owned volume; it was not reattached.", )); } } + for (service, declared) in plan.services.iter().filter(|(_, service)| service.active) { + if let Some(cache) = &declared.dependency_cache + && receipt + .resources + .get(&format!("volume:{}", cache.volume)) + .is_some_and(|old| old.cache.is_none() && retained_volume(old)) + { + return Err(cache_over_retained_data(service)); + } + } Ok(Verified { run: receipt.run.clone(), owner: receipt.owner.clone(), @@ -219,11 +247,14 @@ impl Verified { if !self.matches_receipt(receipt) || prepared.namespace != receipt.namespace { return Err(refused("Reconciliation ownership changed.")); } + config::retain_recorded_legacy_caches(prepared, receipt); let prefix = volume_prefix(&receipt.run); + // Run-owned retained data. Dependency caches are shared, content-addressed + // volumes selected separately below; they are never reattached as data. let retained = receipt .resources .iter() - .filter(|(_, resource)| retained_volume(resource)) + .filter(|(_, resource)| retained_volume(resource) && resource.cache.is_none()) .collect::>(); let mut used = retained .values() @@ -236,12 +267,42 @@ impl Verified { resources.insert(key.clone(), resource.clone()); continue; } - if resource.cache.is_some() { - return Err(refused( - "Dependency cache volumes use their dedicated restore proof.", - )); - } - let selected = if let Some(old) = retained.get(key) { + let selected = if let Some(cache) = &resource.cache { + if retained.contains_key(key) { + let service = prepared + .cache_initializers + .iter() + .find(|(_, volume)| *volume == &resource.key) + .map_or(resource.key.as_str(), |(service, _)| service.as_str()); + return Err(cache_over_retained_data(service)); + } + match receipt.resources.get(key) { + // Same identity: keep the cache and its provenance fence. + Some(old) + if old.cache.as_ref() == Some(cache) + && old.name == resource.name + && matches!(old.phase.as_str(), "created" | "reserved") => + { + let mut kept = old.clone(); + if let Some(provenance) = kept.cache_provenance.as_mut() { + provenance.retain_initializers(|service| { + prepared + .resources + .contains_key(&format!("container:{service}")) + }); + } + kept + } + // New or changed identity: fresh-admission selection of the + // shared cache; the recorded reference (if any) is released. + _ => Resource { + id: None, + cache_provenance: None, + phase: "reserved".into(), + ..resource.clone() + }, + } + } else if let Some(old) = retained.get(key) { if old.key != resource.key || old.disposable_caches != resource.disposable_caches { return Err(refused( "A retained volume's disposable-cache declaration changed; it was not reattached.", @@ -270,6 +331,7 @@ impl Verified { resources.insert(key.clone(), selected); } // Retained data a changed plan no longer declares stays owned and recorded. + // A dependency cache is not run-owned data: an undeclared one is released. for (key, resource) in &retained { resources .entry((*key).clone()) @@ -342,7 +404,7 @@ impl Verified { #[cfg(test)] mod tests { use super::*; - use std::path::Path; + use std::{os::unix::fs::DirBuilderExt, path::Path}; const RUN: &str = "abababababababababababababababab"; @@ -397,6 +459,20 @@ mod tests { project::PlanReport, NormalizedInputIdentity, config::Prepared, + ) { + self.prepared_with(document, false) + } + + /// Dependency-cache graphs resolve their identities from a reviewed + /// source snapshot, as a fresh admission's publication supplies it. + fn prepared_with( + &self, + document: Value, + cached: bool, + ) -> ( + project::PlanReport, + NormalizedInputIdentity, + config::Prepared, ) { let (report, identity) = self.review(document); let inputs = project::inputs::compile( @@ -416,9 +492,35 @@ mod tests { .services .iter() .filter(|(_, service)| service.active) - .map(|(name, _)| (name.clone(), Condition::Started)) + .map(|(name, service)| { + let goal = if service.dependency_cache.is_some() { + Condition::Completed + } else { + Condition::Started + }; + (name.clone(), goal) + }) .collect(); - let prepared = config::prepare(inputs, &readiness, RUN, &"b".repeat(32), None).unwrap(); + let source = cached.then(|| { + let snapshot = project::snapshot::capture_plan(&report.plan).unwrap(); + let manifest = snapshot.receipt().clone(); + source::Inputs { + current_manifest: None, + binding: SourceBinding { + cache_scope: None, + shared: None, + shared_contract: None, + live: None, + revision: manifest.revision.clone(), + archive_sha256: "6".repeat(64), + selection_sha256: manifest.selection_sha256.clone(), + }, + manifest, + paths: BTreeMap::new(), + } + }); + let prepared = + config::prepare(inputs, &readiness, RUN, &"b".repeat(32), source.as_ref()).unwrap(); (report, identity, prepared) } } @@ -802,4 +904,255 @@ mod tests { super::super::restore::observed_volumes_with(&next, |_| Ok(None)).unwrap_err(); assert_eq!(missing.code, "graph_data_missing"); } + + /// An initializer populating a shared dependency cache, beside an app that + /// mounts the cache and its own run-owned data. + fn cached(marker: &str, installer: &str) -> Value { + json!({ + "services": { + "deps": { + "image": image('a'), + "network_mode": "none", + "entrypoint": [installer], + "volumes": ["deps:/deps"], + "labels": { + "hack.dependencies.bootstrap": "true", + "hack.dependencies.cache-volume": "deps", + "hack.dependencies.lockfiles": "lock.txt", + }, + }, + "app": { + "image": image('a'), + "network_mode": "none", + "entrypoint": ["/bin/app"], + "environment": {"MARKER": marker}, + "volumes": ["deps:/deps", "data:/data"], + }, + }, + "volumes": {"deps": {}, "data": {}}, + }) + } + + fn cached_project() -> (Project, Receipt, config::Prepared) { + let project = Project::new(); + fs::write(project.root.join("lock.txt"), "v1").unwrap(); + let (report, identity, prepared) = + project.prepared_with(cached("one", "/bin/install"), true); + let mut receipt = stopped(&prepared, &identity); + // A completed first admission recorded the cache's provenance fence. + let provenance: cache_provenance::CacheProvenance = serde_json::from_value(json!({ + "version": 1, "origin": "created_here", "boot": "boot", + "identity": {"created_at": "2026-11-06T00:00:00Z", "directory": "1:2"}, + "initializers": ["deps"], "completed": {}, + })) + .unwrap(); + receipt + .resources + .get_mut("volume:deps") + .unwrap() + .cache_provenance = Some(provenance); + assert!(receipt.resources["volume:deps"].cache.is_some()); + assert!(!applies( + &receipt, + &report.plan_id, + &report.plan, + &identity, + false + )); + (project, receipt, prepared) + } + + fn reconcile_cached( + project: &Project, + receipt: &Receipt, + next: Value, + ) -> (config::Prepared, Projection) { + let (report, identity, mut prepared) = project.prepared_with(next, true); + assert!(applies( + receipt, + &report.plan_id, + &report.plan, + &identity, + false + )); + let proof = prepare(&report.plan, receipt, &identity).unwrap(); + let projection = proof.project(&mut prepared, receipt).unwrap(); + let mut applied = receipt.clone(); + let snapshot = Projection { + resources: projection.resources.clone(), + }; + let readiness = projection + .resources + .values() + .filter(|resource| resource.kind == Kind::Container) + .map(|resource| (resource.key.clone(), Condition::Started)) + .collect(); + proof + .apply(&mut applied, snapshot, &readiness, None) + .unwrap(); + // The reconciled receipt still loads: every cache name and provenance is valid. + let fixture = super::super::tests::Fixture::new(); + let root = fixture.0.join(RUN); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + state::write(&root.join("state.json"), &applied).unwrap(); + super::super::load_at(root, RUN, &applied.owner).unwrap(); + (prepared, projection) + } + + #[test] + fn env_edit_to_another_service_reconciles_and_keeps_the_dependency_cache() { + let (project, receipt, original) = cached_project(); + let cache = receipt.resources["volume:deps"].clone(); + let (prepared, projection) = + reconcile_cached(&project, &receipt, cached("two", "/bin/install")); + // The same shared cache, with its provenance, and the same run-owned data. + assert!(same(&projection.resources["volume:deps"], &cache)); + assert!(same( + &projection.resources["volume:data"], + &receipt.resources["volume:data"] + )); + assert!(added(&projection).is_empty()); + assert_eq!(mounts(&prepared, "deps"), mounts(&original, "deps")); + assert_eq!(mounts(&prepared, "app"), mounts(&original, "app")); + assert_eq!(projection.resources["container:app"].phase, "reserved"); + } + + #[test] + fn changed_cache_inputs_select_a_fresh_cache_and_keep_run_data() { + let (project, receipt, _) = cached_project(); + let old = receipt.resources["volume:deps"].clone(); + // The initializer's own definition is a cache input. + let (prepared, projection) = + reconcile_cached(&project, &receipt, cached("one", "/bin/install-v2")); + let fresh = &projection.resources["volume:deps"]; + assert_ne!(fresh.cache, old.cache); + assert_ne!(fresh.name, old.name); + assert_eq!(fresh.name, fresh.cache.as_ref().unwrap().name()); + assert_eq!(fresh.phase, "reserved"); + assert!(fresh.cache_provenance.is_none()); + assert!(mounts(&prepared, "app").contains(&fresh.name)); + assert!(!mounts(&prepared, "app").contains(&old.name)); + assert!(same( + &projection.resources["volume:data"], + &receipt.resources["volume:data"] + )); + // So is a lockfile's content. + fs::write(project.root.join("lock.txt"), "v2").unwrap(); + let (_, projection) = reconcile_cached(&project, &receipt, cached("two", "/bin/install")); + let rebuilt = &projection.resources["volume:deps"]; + assert_ne!(rebuilt.cache, old.cache); + assert_eq!(rebuilt.phase, "reserved"); + } + + #[test] + fn retained_run_data_is_never_reselected_as_a_dependency_cache() { + let project = Project::new(); + fs::write(project.root.join("lock.txt"), "v1").unwrap(); + let mut plain = cached("one", "/bin/install"); + plain["services"]["deps"] + .as_object_mut() + .unwrap() + .remove("labels"); + let (_, identity, prepared) = project.prepared(plain); + let receipt = stopped(&prepared, &identity); + assert!(receipt.resources["volume:deps"].cache.is_none()); + let (report, identity, mut next) = + project.prepared_with(cached("one", "/bin/install"), true); + let failure = prepare(&report.plan, &receipt, &identity).err().unwrap(); + assert_eq!(failure.code, "graph_plan_reconcile"); + let change = failure.plan_change.as_deref().unwrap(); + assert_eq!(change.service, "deps"); + assert_eq!(change.field, PlanChangeField::DependencyCacheVolume); + // Admission repeats the check on the projection itself. + let proof = Verified { + run: receipt.run.clone(), + owner: receipt.owner.clone(), + namespace: receipt.namespace.clone(), + ownership_plan: receipt.plan_id.clone(), + previous: receipt.normalized_input.clone().unwrap(), + next: identity, + }; + let failure = proof.project(&mut next, &receipt).err().unwrap(); + assert_eq!(failure.plan_change.unwrap().service, "deps"); + } + + #[test] + fn dropping_a_cache_declaration_releases_the_reference_and_creates_run_data() { + let (project, receipt, _) = cached_project(); + let mut plain = cached("one", "/bin/install"); + plain["services"]["deps"] + .as_object_mut() + .unwrap() + .remove("labels"); + let (report, identity, mut prepared) = project.prepared(plain); + let proof = prepare(&report.plan, &receipt, &identity).unwrap(); + let projection = proof.project(&mut prepared, &receipt).unwrap(); + let deps = &projection.resources["volume:deps"]; + assert!(deps.cache.is_none()); + assert_eq!(deps.phase, "reserved"); + assert!(deps.name.starts_with(&volume_prefix(RUN))); + assert_ne!(deps.name, receipt.resources["volume:data"].name); + } + + #[test] + fn legacy_cache_identity_is_kept_only_while_its_inputs_still_match() { + let (project, mut receipt, original) = cached_project(); + let legacy = original.legacy_caches["deps"].clone(); + assert_ne!( + Some(&legacy), + original.resources["volume:deps"].cache.as_ref() + ); + let deps = receipt.resources.get_mut("volume:deps").unwrap(); + deps.cache = Some(legacy.clone()); + deps.name = legacy.name(); + // Unchanged review: strict replay keeps the recorded legacy cache. + let (report, identity, mut prepared) = + project.prepared_with(cached("one", "/bin/install"), true); + // The review's source metadata is fresh; the receipt is that admission's. + receipt.plan_id = report.plan_id.clone(); + assert!(!applies( + &receipt, + &report.plan_id, + &report.plan, + &identity, + false + )); + config::retain_replay_ownership(&mut prepared, &receipt).unwrap(); + assert_eq!(prepared.resources["volume:deps"].name, legacy.name()); + assert!(mounts(&prepared, "app").contains(&legacy.name())); + // An edit changes the legacy whole-document identity: the current one is + // selected once, after which edits elsewhere keep it. + let (_, projection) = reconcile_cached(&project, &receipt, cached("two", "/bin/install")); + let fresh = &projection.resources["volume:deps"]; + assert_ne!(fresh.name, legacy.name()); + assert_eq!(fresh.cache, original.resources["volume:deps"].cache); + assert_eq!(fresh.phase, "reserved"); + } + + #[test] + fn reserved_shared_caches_may_exist_but_reserved_run_names_may_not() { + let (project, receipt, _) = cached_project(); + let (_, projection) = + reconcile_cached(&project, &receipt, cached("one", "/bin/install-v2")); + let mut next = receipt.clone(); + next.resources = projection.resources; + let present = |_: &Resource| { + Ok(Some( + json!({"inspected": {"Name": "x"}, "directory": "1:2"}), + )) + }; + // The fresh cache may already exist as another graph's shared cache. + let selected = super::super::restore::observed_volumes_with(&next, present).unwrap(); + assert!(!selected.contains_key("volume:deps")); + assert!(selected.contains_key("volume:data")); + let mut owned = next.clone(); + let data = owned.resources.get_mut("volume:data").unwrap(); + data.phase = "reserved".into(); + assert_eq!( + super::super::restore::observed_volumes_with(&owned, present) + .unwrap_err() + .code, + "graph_resource_exists" + ); + } } diff --git a/packages/runtime-core/src/provider/graph/restore.rs b/packages/runtime-core/src/provider/graph/restore.rs index 756427a92..435016a7b 100644 --- a/packages/runtime-core/src/provider/graph/restore.rs +++ b/packages/runtime-core/src/provider/graph/restore.rs @@ -176,7 +176,9 @@ pub(super) fn observed_volumes_with( } if !plan_reconcile::retained_volume(resource) { // Never created: it holds no data, and an existing name is not adopted. - if observe(resource)?.is_some() { + // A not-yet-selected dependency cache is shared and content-addressed: + // admission adopts it by labels or creates it, as a fresh graph does. + if resource.cache.is_none() && observe(resource)?.is_some() { return Err(error( "graph_resource_exists", "A never-created volume name already resolves; it will not be adopted.", @@ -366,19 +368,20 @@ fn restore_inputs( #[cfg(not(target_os = "macos"))] let source_receipt = &receipt; let ordinary_source = if reconcile.is_some() { - // Exactly what a fresh admission of this review would select. Caches are - // not eligible here, so no retained cache publication may be named. - if options.source_revision.is_some() || options.live_source { + // Exactly what a fresh admission of this review would select: dependency + // caches name a publication that must match the current project contents + // (verified by the same mode checks); other graphs name none. + if options.live_source { return Err(error( "graph_plan_reconcile", - "Reconciliation selects no retained cache publication or live source.", + "Reconciliation selects no live source.", )); } source::prepare_mode( candidate, &engine, &inputs.review.plan, - None, + options.source_revision, false, options.shared_source, )? @@ -494,7 +497,7 @@ fn restore_inputs( "Retained volume is missing; restore will not recreate it.", )); } - if !plan_reconcile::retained_volume(resource) && present { + if !plan_reconcile::retained_volume(resource) && resource.cache.is_none() && present { return Err(error( "graph_resource_exists", "A never-created volume name already resolves; it will not be adopted.", diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index dd00652b7..ebbc0f737 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -149,6 +149,28 @@ const contracts: readonly ModelContract[] = [ }, ], }, + { + name: "native-cache-reconcile", + module: "CacheReconcile", + states: 54, + invariant: "UnrelatedEditKeepsCache", + action: "UpCache", + fields: ["needlessRebuild = TRUE", "staleMounted = FALSE"], + additionalControls: [ + { + name: "stale", + negative: true, + invariant: "CacheMatchesInputs", + fields: ["staleMounted = TRUE", "needlessRebuild = FALSE"], + }, + { + name: "data-as-cache", + negative: true, + invariant: "NoDataAsCache", + fields: ["dataMounted = TRUE", "staleMounted = FALSE"], + }, + ], + }, { name: "pre-ready-retirement", module: "Retirement", diff --git a/src/backends/native-plan-change.ts b/src/backends/native-plan-change.ts new file mode 100644 index 000000000..fb331e8d7 --- /dev/null +++ b/src/backends/native-plan-change.ts @@ -0,0 +1,70 @@ +import { isRecord } from "../lib/guards.ts"; + +/** + * Where a Compose edit that a retained native run cannot apply in place was + * made. The service is an admitted Compose identifier and the field a closed + * vocabulary, so both are safe to print; native messages never are. + */ +export interface NativePlanChange { + readonly service: string; + readonly field: NativePlanChangeField; +} + +const FIELDS = { + dependency_cache_volume: "`hack.dependencies.cache-volume`", + hostnames: "routed hostnames", +} as const; +type NativePlanChangeField = keyof typeof FIELDS; + +const SERVICE = /^[A-Za-z0-9_.-]{1,128}$/; + +/** Accept only the exact versioned diagnostic; anything else is dropped. */ +export function readNativePlanChange( + value: unknown +): NativePlanChange | undefined { + if ( + !isRecord(value) || + Object.keys(value).sort().join(",") !== "field,service,version" || + value.version !== 1 || + typeof value.service !== "string" || + !SERVICE.test(value.service) || + typeof value.field !== "string" || + !Object.hasOwn(FIELDS, value.field) + ) { + return; + } + return { + service: value.service, + field: value.field as NativePlanChangeField, + }; +} + +const APPLY = + "Revert the edit to keep the retained run as admitted, or run `hack down --remove-data` and then `hack up` to apply it with fresh data."; + +/** + * Closed guidance for native refusals of a Compose edit to a retained run. + * Returns undefined for every other code, which keeps the generic text. + */ +export function nativePlanChangeGuidance( + code: string, + change: NativePlanChange | undefined +): string | undefined { + const where = change + ? ` (service \`${change.service}\`, ${FIELDS[change.field]})` + : ""; + switch (code) { + case "graph_plan_reconcile": + return `this Compose edit${where} cannot be applied to the retained run. ${ + change?.field === "dependency_cache_volume" + ? "A retained named volume holds run data and is never reused as a shared dependency cache. " + : "" + }${APPLY}`; + case "graph_plan_change_unsupported": + return `this Compose edit cannot be applied in place: the retained run uses live source, a carried cache scope, a pending source-device rebind or a pending initializer cache release, so only routed hostname changes apply to it. ${APPLY}`; + case "graph_hostname_change": + return `the hostname change${where} cannot be applied to the retained run together with the other changes. Apply the hostname change on its own, or ${APPLY.charAt(0).toLowerCase()}${APPLY.slice(1)}`; + default: + return; + } +} diff --git a/src/backends/native-project-restore.ts b/src/backends/native-project-restore.ts index 42d532b70..db77b873a 100644 --- a/src/backends/native-project-restore.ts +++ b/src/backends/native-project-restore.ts @@ -88,8 +88,15 @@ export async function verifyNativeSourceCompatibility(opts: { `Native shared-source compatibility changed; ${NOT_ADOPTED}` ); } - const cached = nativeCacheInitializers(opts.review.report.plan).size > 0; - if ((result.source_revision === null) !== !cached) { + // Reconciliation recreates compute exactly as a fresh admission: it never + // names a retained cache publication, so a cached review publishes afresh. + const cached = + result.reconcile !== true && + nativeCacheInitializers(opts.review.report.plan).size > 0; + if ( + (result.reconcile !== undefined && result.reconcile !== true) || + (result.source_revision === null) !== !cached + ) { throw new ClosedCliError( `Native shared-source compatibility changed; ${NOT_ADOPTED}` ); diff --git a/src/backends/native-project-retained.ts b/src/backends/native-project-retained.ts index 577d49d29..91a50d8ff 100644 --- a/src/backends/native-project-retained.ts +++ b/src/backends/native-project-retained.ts @@ -44,15 +44,7 @@ export function confirmedNativeRetainedGraph( return false; } expected.add(key); - const observed = observations[key]; - // A `reserved` volume never received a create intent: it holds no data and - // must be absent. Every other volume is settled retained data. - const settledData = - resource.kind === "volume" && resource.phase !== "reserved"; - if ( - !isRecord(observed) || - observed.state !== (settledData ? "present" : "absent") - ) { + if (!observedAsRecorded(resource, observations[key])) { return false; } } @@ -61,3 +53,27 @@ export function confirmedNativeRetainedGraph( Object.keys(observations).every((key) => expected.has(key)) ); } + +/** + * Compute must be absent and settled data present. A `reserved` volume never + * received a create intent: it holds no data and must be absent. A reserved + * dependency cache is shared and content-addressed: admission adopts an + * existing one by labels or creates it, so either observation is valid. + */ +function observedAsRecorded( + resource: Record, + observed: unknown +): boolean { + if (!isRecord(observed)) { + return false; + } + if (resource.kind !== "volume") { + return observed.state === "absent"; + } + if (resource.phase !== "reserved") { + return observed.state === "present"; + } + return isRecord(resource.cache) + ? observed.state === "present" || observed.state === "absent" + : observed.state === "absent"; +} diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 67d52f89d..fbf337bf1 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -2,6 +2,11 @@ import { isAbsolute } from "node:path"; import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { nativeAuthoredExecOptions } from "./native-authored-exec-options.ts"; +import { + type NativePlanChange, + nativePlanChangeGuidance, + readNativePlanChange, +} from "./native-plan-change.ts"; import { NATIVE_STARTUP_FAILURE_REPLY_BYTES, type NativeStartupLogTail, @@ -23,6 +28,7 @@ interface NativeFailure { readonly causeCode?: string; readonly stopFailures?: readonly NativeStopFailure[]; readonly startupLogs?: readonly NativeStartupLogTail[]; + readonly planChange?: NativePlanChange; } /** @@ -481,7 +487,10 @@ function nativeFailureMessage(failure: NativeFailure | undefined): string { const code = failure ? ` (${failure.code}${failure.causeCode ? `: ${failure.causeCode}` : ""})` : ""; - return `Native runtime request failed${code}; inspect owned state before retrying.${nativeStopFailureSummary(failure?.stopFailures)}`; + const guidance = failure + ? nativePlanChangeGuidance(failure.code, failure.planChange) + : undefined; + return `Native runtime request failed${code}; ${guidance ?? "inspect owned state before retrying."}${nativeStopFailureSummary(failure?.stopFailures)}`; } function completionResponse( @@ -628,9 +637,11 @@ function nativeFailureFromReply( code: value.code, cause: value.cause_code, }); + const planChange = readNativePlanChange(value.plan_change); return { code: value.code, ...(causeCode ? { causeCode } : {}), + ...(planChange ? { planChange } : {}), ...(["engine_protocol", "engine_rejected", "engine_not_found"].includes( value.code === "graph_owner_recovery" && typeof value.cause_code === "string" diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index 269822194..19a8dfce7 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -735,9 +735,40 @@ Assumptions: admission through startup holds the provider mutation lease, so no replacement happens inside it. A replacement with copied ownership labels before selection is indistinguishable from the recorded volume by name and labels, as in the implementation; the generation fence protects only the selection-to-admission -window. Dependency caches, live source and source-device rebinding keep their own -proofs and are outside this model. It does not prove engine mount behavior, crash -durability of the receipt, image resolution or application data contents. +window. Dependency caches are checked by the next model; live source and +source-device rebinding keep their own proofs and are outside this model. It does +not prove engine mount behavior, crash durability of the receipt, image resolution +or application data contents. + +## Dependency caches across plan changes + +`native-cache-reconcile/CacheReconcile.tla` checks which volume backs a dependency +cache key when a stopped graph is reconciled after a Compose edit. A cache is a +shared volume named by an identity of its inputs; run-owned data uses the run's own +names. Edits change the cache's own inputs (initializer definition, declaration, +image, lockfiles), anything else in the Compose file, or whether the key is declared +as a cache. Two values for each explore 54 distinct states. + +| Model action | Implementation boundary | +| --- | --- | +| `EditInputs` / `EditOther` / `EditDeclaration` | Compose or lockfile edit between ups; `dependency_cache::resolve_with_legacy` computes the current identity | +| `UpCache` | `plan_reconcile::prepare` refuses run data declared as a cache; `Verified::project` keeps a cache of the same identity or selects the new identity's shared cache, then `Session::create_resources` adopts it by labels or creates it | +| `UpData` | A key that is no longer a cache gets a fresh run-owned volume; the cache reference is released, never deleted | +| `Stop` | Ctrl-C or `down` keeps every recorded volume | + +The `negative` control binds the whole Compose document into the identity (the +earlier formula) and must fail `UnrelatedEditKeepsCache` in an `UpCache` state with +`needlessRebuild = TRUE`. The `stale` control reattaches the recorded cache whatever +its identity and must fail `CacheMatchesInputs` with `staleMounted = TRUE`. The +`data-as-cache` control mounts retained run data newly declared as a cache and must +fail `NoDataAsCache` with `dataMounted = TRUE`. + +Assumptions: identities are exact (no collisions), the initializer populates a newly +selected cache before dependents start, and admission holds the provider lease. The +compatibility path that keeps a receipt recorded under the earlier identity while +its inputs still match is covered by `plan_reconcile.rs` tests, not this model. It +does not prove initializer idempotence, cache content, capacity limits or cache-wide +collection. ## Graph admission diff --git a/tests/models/tla/native-cache-reconcile/CacheReconcile.tla b/tests/models/tla/native-cache-reconcile/CacheReconcile.tla new file mode 100644 index 000000000..0b03c7e04 --- /dev/null +++ b/tests/models/tla/native-cache-reconcile/CacheReconcile.tla @@ -0,0 +1,103 @@ +------------------------- MODULE CacheReconcile ------------------------- +(* Dependency-cache selection when a stopped native graph is reconciled after a + Compose edit. A cache is a shared volume named by an identity of its inputs; + run-owned data lives under the run's own names. Compute is always recreated, + so only which volume backs the cache key is modeled. `inputs` are the cache's + own inputs (initializer definition, declaration, image, lockfiles); `other` is + every other part of the Compose file. Broken constants are negative controls. *) +EXTENDS Naturals, TLC +CONSTANTS Values, + WholeDocument, \* identity also binds `other` (the earlier formula) + KeepStale, \* reattach the recorded cache whatever its identity + DataAsCache \* reselect retained run data newly declared as a cache +None == [kind |-> "none", id |-> <<0, 0>>, rin |-> 0] +Id(i, o) == IF WholeDocument THEN <> ELSE <> +VARIABLES phase, inputs, other, declared, recorded, caches, mounted, + staleMounted, dataMounted, needlessRebuild +vars == <> + +\* A first admission populated the cache for the initial inputs. +Init == /\ phase = "stopped" + /\ inputs = 1 /\ other = 1 /\ declared = "cache" + /\ recorded = [kind |-> "cache", id |-> Id(1, 1), rin |-> 1] + /\ caches = {Id(1, 1)} + /\ mounted = None + /\ staleMounted = FALSE /\ dataMounted = FALSE /\ needlessRebuild = FALSE + +\* Edits between ups: the cache's own inputs, anything else, or whether the key +\* is declared as a cache at all. +EditInputs(v) == /\ phase = "stopped" /\ v # inputs /\ inputs' = v + /\ UNCHANGED <> +EditOther(v) == /\ phase = "stopped" /\ v # other /\ other' = v + /\ UNCHANGED <> +EditDeclaration == /\ phase = "stopped" + /\ declared' = IF declared = "cache" THEN "data" ELSE "cache" + /\ UNCHANGED <> + +\* plan_reconcile::Verified::project then Session::create_resources: keep a +\* recorded cache of the same identity; otherwise select that identity's shared +\* cache (adopt or create; the initializer populates it) and release the old +\* reference. Retained run data declared as a cache is refused. +UpCache == + LET want == Id(inputs, other) + entry == [kind |-> "cache", id |-> want, rin |-> inputs] IN + /\ phase = "stopped" /\ declared = "cache" + /\ IF recorded.kind = "data" + THEN IF DataAsCache + THEN /\ phase' = "running" /\ mounted' = recorded + /\ dataMounted' = TRUE + /\ UNCHANGED <> + ELSE UNCHANGED <> + ELSE IF recorded.kind = "cache" /\ (recorded.id = want \/ KeepStale) + THEN /\ phase' = "running" /\ mounted' = recorded + /\ staleMounted' = (staleMounted \/ recorded.rin # inputs) + /\ UNCHANGED <> + ELSE /\ phase' = "running" /\ mounted' = entry + /\ recorded' = entry + /\ caches' = caches \cup {want} + /\ needlessRebuild' = (needlessRebuild \/ + (recorded.kind = "cache" /\ recorded.rin = inputs + /\ want \notin caches)) + /\ UNCHANGED <> + /\ UNCHANGED <> + +\* The key is ordinary run data: reattach recorded data, or create fresh run data +\* when it was a cache (whose shared volume is only released, never deleted). +UpData == + /\ phase = "stopped" /\ declared = "data" + /\ phase' = "running" + /\ recorded' = IF recorded.kind = "data" THEN recorded + ELSE [kind |-> "data", id |-> <<0, 0>>, rin |-> 0] + /\ mounted' = recorded' + /\ UNCHANGED <> + +Stop == /\ phase = "running" /\ phase' = "stopped" /\ mounted' = None + /\ UNCHANGED <> + +Next == \/ \E v \in Values : EditInputs(v) \/ EditOther(v) + \/ EditDeclaration \/ UpCache \/ UpData \/ Stop +Spec == Init /\ [][Next]_vars + +TypeOK == /\ phase \in {"stopped", "running"} + /\ inputs \in Values /\ other \in Values + /\ declared \in {"cache", "data"} + /\ recorded.kind \in {"none", "cache", "data"} + /\ mounted.kind \in {"none", "cache", "data"} + +\* A running graph's cache was populated for exactly its current inputs. +CacheMatchesInputs == /\ ~staleMounted + /\ (phase = "running" /\ mounted.kind = "cache") => + mounted.rin = inputs +\* Run-owned data is never mounted as a shared cache. +NoDataAsCache == ~dataMounted +\* An edit that leaves the cache's own inputs unchanged keeps the cache: it never +\* selects a not-yet-populated cache for the same inputs. +UnrelatedEditKeepsCache == ~needlessRebuild +============================================================================= diff --git a/tests/models/tla/native-cache-reconcile/data-as-cache.cfg b/tests/models/tla/native-cache-reconcile/data-as-cache.cfg new file mode 100644 index 000000000..2c5f19a6f --- /dev/null +++ b/tests/models/tla/native-cache-reconcile/data-as-cache.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS Values = {1, 2} + WholeDocument = FALSE KeepStale = FALSE DataAsCache = TRUE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache diff --git a/tests/models/tla/native-cache-reconcile/negative.cfg b/tests/models/tla/native-cache-reconcile/negative.cfg new file mode 100644 index 000000000..d8c5edf62 --- /dev/null +++ b/tests/models/tla/native-cache-reconcile/negative.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS Values = {1, 2} + WholeDocument = TRUE KeepStale = FALSE DataAsCache = FALSE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache diff --git a/tests/models/tla/native-cache-reconcile/positive.cfg b/tests/models/tla/native-cache-reconcile/positive.cfg new file mode 100644 index 000000000..cbcf0c537 --- /dev/null +++ b/tests/models/tla/native-cache-reconcile/positive.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS Values = {1, 2} + WholeDocument = FALSE KeepStale = FALSE DataAsCache = FALSE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache diff --git a/tests/models/tla/native-cache-reconcile/stale.cfg b/tests/models/tla/native-cache-reconcile/stale.cfg new file mode 100644 index 000000000..cc68f47cd --- /dev/null +++ b/tests/models/tla/native-cache-reconcile/stale.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS Values = {1, 2} + WholeDocument = FALSE KeepStale = TRUE DataAsCache = FALSE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache diff --git a/tests/native-plan-change.test.ts b/tests/native-plan-change.test.ts new file mode 100644 index 000000000..72deecda6 --- /dev/null +++ b/tests/native-plan-change.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { + nativePlanChangeGuidance, + readNativePlanChange, +} from "../src/backends/native-plan-change.ts"; + +test("only the exact versioned plan-change diagnostic is read", () => { + const valid = { version: 1, service: "deps", field: "hostnames" }; + expect(readNativePlanChange(valid)).toEqual({ + service: "deps", + field: "hostnames", + }); + for (const value of [ + undefined, + { ...valid, version: 2 }, + { ...valid, field: "environment" }, + { ...valid, field: "toString" }, + { ...valid, service: "two words" }, + { ...valid, extra: true }, + ]) { + expect(readNativePlanChange(value)).toBeUndefined(); + } +}); + +test("retained-run edit refusals get closed, accurate guidance", () => { + expect( + nativePlanChangeGuidance("graph_plan_change_unsupported", undefined) + ).toContain("only routed hostname changes apply to it"); + expect( + nativePlanChangeGuidance("graph_hostname_change", { + service: "web", + field: "hostnames", + }) + ).toContain("the hostname change (service `web`, routed hostnames)"); + expect(nativePlanChangeGuidance("graph_plan_reconcile", undefined)).toBe( + "this Compose edit cannot be applied to the retained run. Revert the edit to keep the retained run as admitted, or run `hack down --remove-data` and then `hack up` to apply it with fresh data." + ); + expect(nativePlanChangeGuidance("graph_budget", undefined)).toBeUndefined(); +}); diff --git a/tests/native-project-restore.test.ts b/tests/native-project-restore.test.ts index 4eb41a5dd..6eed50433 100644 --- a/tests/native-project-restore.test.ts +++ b/tests/native-project-restore.test.ts @@ -223,3 +223,55 @@ test("an edited Compose plan restores the same run after the native reconcile pr flags: ["--expect-generation", selected.generation], }); }); + +test("a reconciled dependency-cache graph publishes fresh source instead of the retained revision", async () => { + const review = { + ...options.review, + planId: "f".repeat(64), + report: { + plan: { + services: { + deps: { active: true, dependency_cache: { volume: "deps" } }, + app: { active: true }, + }, + }, + }, + }; + const compatibility = { + run: saved.run, + owner: saved.owner, + namespace: saved.namespace, + plan: saved.planId, + reviewed_plan: review.planId, + source_revision: null, + reconcile: true, + }; + const restore = (reply: Record) => + selectNativeProjectRestore({ + ...options, + review, + restore: saved, + invoke: async ({ args }) => + args[1] === "restore-selection" ? selected : reply, + }); + // No retained revision: the start path publishes the current snapshot. + expect(await restore(compatibility)).toEqual({ + run: saved.run, + restoring: true, + flags: ["--expect-generation", selected.generation], + }); + // A reconciliation never names a retained cache publication. + await expectClosedRefusal( + restore({ ...compatibility, source_revision: "1".repeat(64) }), + "compatibility changed" + ); + // Without reconciliation the retained revision stays mandatory. + await expectClosedRefusal( + restore({ ...compatibility, reconcile: undefined }), + "compatibility changed" + ); + await expectClosedRefusal( + restore({ ...compatibility, reconcile: "true" }), + "compatibility changed" + ); +}); diff --git a/tests/native-project-retained-images.test.ts b/tests/native-project-retained-images.test.ts index 816bc0359..27f8a2c31 100644 --- a/tests/native-project-retained-images.test.ts +++ b/tests/native-project-retained-images.test.ts @@ -459,3 +459,43 @@ test("restart of a stopped graph points at up instead of reporting image drift", "stopped with data retained. Run hack up" ); }); + +test("a reconciled graph's reserved shared cache may already exist; reserved run data may not", () => { + const baseline = observation(); + const withCache = (state: string, cache: boolean) => ({ + ...baseline, + receipt: { + ...baseline.receipt, + resources: { + ...baseline.receipt.resources, + "volume:deps": { + kind: "volume", + key: "deps", + phase: "reserved", + ...(cache + ? { + cache: { + scope: "1".repeat(64), + fingerprint: "2".repeat(64), + image, + }, + } + : {}), + }, + }, + }, + observations: { ...baseline.observations, "volume:deps": { state } }, + }); + expect(confirmedNativeRetainedGraph(withCache("present", true), saved)).toBe( + true + ); + expect(confirmedNativeRetainedGraph(withCache("absent", true), saved)).toBe( + true + ); + expect(confirmedNativeRetainedGraph(withCache("unknown", true), saved)).toBe( + false + ); + expect(confirmedNativeRetainedGraph(withCache("present", false), saved)).toBe( + false + ); +}); diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 75d8afd01..64d8e3e45 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -147,6 +147,8 @@ async function fixture() { binary, `#!${process.execPath} if (process.argv.includes("hang")) await Bun.sleep(60_000); +if (process.argv.includes("plan-change")) { console.error(JSON.stringify({code:"graph_plan_reconcile",message:"synthetic-secret-diagnostic",plan_change:{version:1,service:"deps",field:"dependency_cache_volume"}})); process.exit(23); } +if (process.argv.includes("plan-change-unsafe")) { console.error(JSON.stringify({code:"graph_hostname_change",message:"synthetic-secret-diagnostic",plan_change:{version:1,service:"synthetic secret diagnostic",field:"hostnames"}})); process.exit(23); } if (process.argv.includes("structured")) { console.error(JSON.stringify({code:"source_conflict",message:"synthetic-secret-diagnostic"})); process.exit(23); } if (process.argv.includes("foreground-stopped")) { console.error(JSON.stringify({code:"native_graph_foreground",cause_code:"runtime_not_running",message:"synthetic-secret-diagnostic"})); process.exit(23); } if (process.argv.includes("foreground-other")) { console.error(JSON.stringify({code:"native_graph_foreground",cause_code:"engine_protocol",message:"synthetic-secret-diagnostic"})); process.exit(23); } @@ -767,6 +769,35 @@ test("native failure preserves only its validated structured code", async () => } }); +test("a refused Compose edit names its service and field and how to apply it", async () => { + const runtime = await fixture(); + const reconcile: unknown = await invokeNativeRuntime({ + runtime, + cwd: runtime.home, + args: ["plan-change"], + }).catch((caught: unknown) => caught); + expect(reconcile).toBeInstanceOf(NativeRuntimeRequestError); + expect(reconcile).toMatchObject({ nativeCode: "graph_plan_reconcile" }); + const message = String(reconcile); + expect(message).toContain( + "Native runtime request failed (graph_plan_reconcile); this Compose edit (service `deps`, `hack.dependencies.cache-volume`) cannot be applied to the retained run." + ); + expect(message).toContain("`hack down --remove-data` and then `hack up`"); + expect(message).not.toContain("synthetic-secret-diagnostic"); + // An unbounded service name is dropped; the closed guidance remains. + const hostname = String( + await invokeNativeRuntime({ + runtime, + cwd: runtime.home, + args: ["plan-change-unsafe"], + }).catch((caught: unknown) => caught) + ); + expect(hostname).toContain( + "Native runtime request failed (graph_hostname_change); the hostname change cannot be applied" + ); + expect(hostname).not.toContain("synthetic"); +}); + test("failures omit subprocess diagnostics and timeouts are bounded", async () => { const runtime = await fixture(); await expect( From 516c723811fe9afb07bef15ab127c7d0bef787f8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:19:37 -0500 Subject: [PATCH 125/138] fix(native): run stock image processes in wrappers like Docker, naming refusals Project-wide host aliases put a host-dependency release barrier around every service, and private environment values put a launcher around their services. Both refused stock images whose entrypoint or health-check executable is a bare name (docker-entrypoint.sh, CMD mysqladmin), one service per run, without naming the service. Runtime: - The environment launcher and the relay release barrier exec the selected process like Docker: a name with "/" runs as given and a bare name is resolved through the container's own PATH (Docker's default when unset), never a host PATH and never a shell. - The selected process is materialized from the pinned image for host-dependency services too, not only for private environment delivery, so the barrier runs the image's ENTRYPOINT and CMD. A command-only image becomes the same argv, and a CMD-SHELL health check becomes the exec form Docker runs (the image SHELL, /bin/sh -c by default). - 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 a new value-free process_refusals diagnostic. CLI: - Native up refuses Compose-declared settings no wrapper can run (empty process, a health test without an executable, a named user, an unsupported stop signal, a reserved volume target) before the VM starts, naming every service and field. Restart preflight checks the same before stopping the current graph. - Runtime process_refusals are parsed in their closed form and named in the startup failure report. - Native doctor adds a "native process wrappers" check that predicts these cases for every service native may wrap. Refs HACK-1939 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 19 + packages/relay-guest/README.md | 11 +- packages/relay-guest/src/barrier.rs | 11 +- packages/relay-guest/src/main.rs | 2 +- packages/runtime-core/README.md | 12 +- .../guest/environment-launcher.zig | 15 +- packages/runtime-core/src/error.rs | 38 ++ .../runtime-core/src/provider/environment.rs | 2 +- .../src/provider/graph/image_process.rs | 222 +++++++-- .../src/provider/graph/launcher.rs | 132 +++-- .../runtime-core/src/provider/graph/mod.rs | 81 +++- .../src/provider/graph/native/runtime.rs | 6 +- .../src/provider/graph/process_refusals.rs | 145 ++++++ .../src/provider/graph/restore.rs | 11 +- .../src/provider/graph/startup.rs | 6 + .../src/provider/graph/startup/runtime.rs | 10 +- src/backends/native-project-process.ts | 5 + .../native-project-restart-preflight.ts | 9 + src/backends/native-project-start.ts | 36 +- src/backends/native-runtime-client.ts | 18 + src/commands/doctor.ts | 54 ++- src/lib/native-process-wrappers.ts | 458 ++++++++++++++++++ tests/native-process-wrappers.test.ts | 210 ++++++++ tests/native-project-process.test.ts | 67 +++ tests/native-project-start.test.ts | 54 +++ 25 files changed, 1531 insertions(+), 103 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/process_refusals.rs create mode 100644 src/lib/native-process-wrappers.ts create mode 100644 tests/native-process-wrappers.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 25cc9811a..03ba8d575 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -2538,6 +2538,25 @@ 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. + ### 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..9be223cd8 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,14 @@ 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, + 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..67bf37985 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,13 @@ export async function startNativeProject(opts: { dependencies: hostDependencies, services: specs, }); + // Refuse Compose settings no process wrapper can run before the VM starts. + requireWrappableNativeProcesses({ + services: specs, + managedEnvironment: input.managedEnvironment, + managedFiles: input.managedFiles, + dependencyServices: hostDependencies.map((binding) => binding.service), + }); await verifyNativeRetainedMapping({ run: retained, scope: opts.scope, @@ -1765,6 +1790,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 +1831,7 @@ export async function startNativeProject(opts: { ...stop?.serve, onExitDiagnostic: (diagnostic) => { nativeExitCode = diagnostic.nativeCode; + nativeProcessRefusals = diagnostic.processRefusals; }, onStartupLogs: (logs) => { startupLogReport = formatNativeStartupLogReport({ @@ -1937,6 +1966,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..6274d8a8f 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,13 @@ 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, + nativeProcessCheckResult, + nativeWrappedServiceCandidates, + predictNativeProcessRefusals, +} from "../lib/native-process-wrappers.ts"; import { isMac } from "../lib/os.ts"; import { defaultProjectSlugFromPath, @@ -251,6 +262,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 +1168,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 +1362,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 +1403,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 +3064,37 @@ 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: {} })); + const candidates = nativeWrappedServiceCandidates({ + compose: await readNativeComposeDocument(ctx.composeFile), + projectWideAliases: Object.keys(aliases.hosts).length > 0, + }); + return nativeProcessCheckResult(predictNativeProcessRefusals(candidates)); +} + /** * 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..d01758f6f --- /dev/null +++ b/src/lib/native-process-wrappers.ts @@ -0,0 +1,458 @@ +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; +} + +/** 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>> + >; + 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: opts.services, + 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[]; +}; + +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[] +): { + readonly name: string; + readonly status: "ok" | "warn"; + readonly message: string; + readonly nativeProcess: NativeProcessReport; +} { + const nativeProcess = { refusals: refusals.map((entry) => ({ ...entry })) }; + if (refusals.length === 0) { + return { + name: NATIVE_PROCESS_CHECK, + status: "ok", + message: + "No Compose setting blocks native process wrapping; native up checks image entrypoints, commands and users before it reserves anything and names every refused service at once.", + nativeProcess, + }; + } + return { + name: NATIVE_PROCESS_CHECK, + status: "warn", + message: `Native up would refuse to wrap these services' processes: ${describeNativeProcessRefusals(refusals)}.`, + nativeProcess, + }; +} diff --git a/tests/native-process-wrappers.test.ts b/tests/native-process-wrappers.test.ts new file mode 100644 index 000000000..9ef82a3cd --- /dev/null +++ b/tests/native-process-wrappers.test.ts @@ -0,0 +1,210 @@ +/* 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, + 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" }, + }, + 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"); +}); 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..061f3010e 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -4461,3 +4461,57 @@ 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)." + ); +}); From 139be6c345509152d0e0da7bc074b2732b243f51 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:27:34 -0500 Subject: [PATCH 126/138] fix(native): bind every cache-volume mount into the cache identity The v2 dependency-cache identity bound the mount layout only when a mount used a subpath. Adding or changing another service that mounts the shared cache volume read-write therefore kept the same identity, so a new writer could change contents that other runs and instances reuse. The current identity now always binds the full layout: every active service that mounts the cache volume, with its target, subpath and read-only flag. Adding, removing or changing a writer selects a fresh cache; an environment-only edit to a service keeps it. Mounts stay as declared, as in Compose and v4, rather than being forced read-only. The legacy whole-document identity is unchanged, so earlier receipts still match exactly. Regression tests cover a new read-write mount, its access mode, an env edit to that writer, a second initializer, and reconciliation selecting a fresh cache when a writer service is added. Refs HACK-1937 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 11 ++- packages/runtime-core/README.md | 3 +- .../src/provider/graph/dependency_cache.rs | 86 +++++++++++++------ .../src/provider/graph/plan_reconcile.rs | 23 +++++ tests/models/tla/README.md | 5 +- .../native-cache-reconcile/CacheReconcile.tla | 5 +- 6 files changed, 99 insertions(+), 34 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index a0a7230d6..56dd369fd 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -718,10 +718,13 @@ namespace that created it. Graphs with `hack.dependencies.*` caches reconcile the same way. A cache's identity covers only its own inputs: the initializer's service definition and resolved command, entrypoint, environment and user, the cache declaration, its image, the -declared lockfile and runtime-file contents, and the cache volume layout. An edit -to another service, such as an environment value on the app, keeps the cache. An -edit to one of those inputs, including a lockfile, selects the cache for the new -identity as a fresh `up` would, so the initializer installs into it; the old cache +declared lockfile and runtime-file contents, and the cache volume layout: every +service that mounts the cache volume, with its target, subpath and `:ro` mode. Any +of those services can write the shared contents, so adding, removing or changing +such a mount is a cache input; mounts stay as declared, as in Compose and v4, and +are not forced read-only. Any other edit to another service, such as an +environment value on the app, keeps the cache. An edit to one of those inputs, +including a lockfile, selects the cache for the new identity as a fresh `up` would, so the initializer installs into it; the old cache is shared and is only released by this run. Each such `up` publishes the current source snapshot. A retained named volume is never reused as a dependency cache: declaring one refuses and names the service and the `hack.dependencies.cache-volume` diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 35c2d007b..9a7221df2 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1453,7 +1453,8 @@ created exclusively, and retained volumes the plan no longer declares stay in th receipt. Run, owner, namespace and the ownership plan on every label are unchanged. `graph source-compatibility` reports `"reconcile": true` for this path. Dependency caches reconcile too: a cache whose freshly resolved identity (the initializer's own -definition and inputs, not the whole Compose digest) equals the recorded binding is +definition and inputs plus every service mount of the cache volume, not the whole +Compose digest) equals the recorded binding is kept with its provenance; a changed identity is selected like a fresh admission (adopted by labels or created) with a freshly published source revision, and the old shared cache is only released. A retained run-owned volume newly declared as a cache diff --git a/packages/runtime-core/src/provider/graph/dependency_cache.rs b/packages/runtime-core/src/provider/graph/dependency_cache.rs index ae7cdc624..1e7a409b6 100644 --- a/packages/runtime-core/src/provider/graph/dependency_cache.rs +++ b/packages/runtime-core/src/provider/graph/dependency_cache.rs @@ -179,12 +179,27 @@ pub fn resolve_with_legacy( return Err(refused()); } } + // Every active service that mounts the cache volume, with its target, + // subpath and access mode. Any of them can write the shared contents, so a + // writer added, removed or changed elsewhere is a cache input, even though + // the rest of that service's definition is not. + let layout = plan + .services + .iter() + .filter(|(_, s)| s.active) + .flat_map(|(name, s)| { + s.mounts + .iter() + .filter(|m| m.kind == "volume" && m.source == declaration.volume) + .map(move |m| (name, &m.target, &m.subpath, m.read_only)) + }) + .collect::>(); // The plan's argv and literal values are redacted, so the resolved public // command, entrypoint, environment, user and hosts are bound explicitly. // Script content belongs in explicitly declared runtime files; whole source // revisions would prevent compatible branches with unrelated source changes // from sharing. The legacy identity also bound the whole Compose digest. - let base_fingerprint = hash(&( + let current_fingerprint = hash(&( "hack-dependency-cache-v2", scope, &declaration.volume, @@ -200,6 +215,7 @@ pub fn resolve_with_legacy( &public.user, &public.extra_hosts, &service.working_dir, + &layout, ))?; let legacy_fingerprint = hash(&( "hack-dependency-cache-v1", @@ -217,26 +233,12 @@ pub fn resolve_with_legacy( &public.extra_hosts, &service.working_dir, ))?; - // Include the whole shared-volume layout, not only initializer mounts. - // Keep historical cache keys unchanged when no subpath is requested. - let layout = plan - .services - .iter() - .filter(|(_, s)| s.active) - .flat_map(|(name, s)| { - s.mounts - .iter() - .filter(|m| m.kind == "volume" && m.source == declaration.volume) - .map(move |m| (name, &m.target, &m.subpath, m.read_only)) - }) - .collect::>(); - let subpaths = layout.iter().any(|(_, _, subpath, _)| subpath.is_some()); - let with_layout = |fingerprint: String| -> Result { - if subpaths { - hash(&("hack-cache-volume-subpaths-v1", fingerprint, &layout)) - } else { - Ok(fingerprint) - } + // The legacy identity reproduces the earlier keys exactly: its Compose + // digest covered the layout, and only subpaths were bound again. + let legacy_fingerprint = if layout.iter().any(|(_, _, subpath, _)| subpath.is_some()) { + hash(&("hack-cache-volume-subpaths-v1", legacy_fingerprint, &layout))? + } else { + legacy_fingerprint }; let binding = |fingerprint| CacheBinding { scope: scope.into(), @@ -246,12 +248,12 @@ pub fn resolve_with_legacy( insert_binding( &mut current, &declaration.volume, - binding(with_layout(base_fingerprint)?), + binding(current_fingerprint), )?; insert_binding( &mut legacy, &declaration.volume, - binding(with_layout(legacy_fingerprint)?), + binding(legacy_fingerprint), )?; } Ok(Resolved { current, legacy }) @@ -804,9 +806,39 @@ mod tests { // Another service, with its own environment, is not a cache input. let mut app = plan.services["deps"].clone(); app.dependency_cache = None; + let cache_mount = app.mounts.clone(); app.mounts.clear(); - changed.services.insert("app".into(), app); + changed.services.insert("app".into(), app.clone()); assert_eq!(baseline, resolve(&changed, &manifest, &scope).unwrap()); + // But a service that can write the shared cache is: adding a read-write + // mount of the cache volume changes the identity, with or without subpaths. + app.mounts = cache_mount; + assert!( + app.mounts + .iter() + .all(|m| !m.read_only && m.subpath.is_none()) + ); + changed.services.insert("app".into(), app.clone()); + let writer = resolve(&changed, &manifest, &scope).unwrap(); + assert_ne!(baseline, writer); + // Its access mode and target are part of the layout too. + let mut reader = app.clone(); + reader.mounts[0].read_only = true; + changed.services.insert("app".into(), reader); + let read_only = resolve(&changed, &manifest, &scope).unwrap(); + assert_ne!(writer, read_only); + assert_ne!(baseline, read_only); + // An environment edit to that writer keeps the identity it now has. + let mut edited = app; + edited.environment.insert( + "MARKER".into(), + crate::project::RedactedText { + environment_references: Vec::new(), + literal_redacted: true, + }, + ); + changed.services.insert("app".into(), edited); + assert_eq!(writer, resolve(&changed, &manifest, &scope).unwrap()); changed = plan.clone(); let mut other = changed.services["deps"].clone(); other.dependency_cache.as_mut().unwrap().bootstrap = false; @@ -820,7 +852,11 @@ mod tests { .as_mut() .unwrap() .bootstrap = true; - assert_eq!(baseline, resolve(&changed, &manifest, &scope).unwrap()); + // Both initializers agree on one identity; the second writer of the shared + // volume is part of it. + let shared = resolve(&changed, &manifest, &scope).unwrap(); + assert_eq!(shared.len(), 1); + assert_ne!(baseline, shared); } #[test] fn missing_lockfiles_and_symlink_inputs_refuse() { diff --git a/packages/runtime-core/src/provider/graph/plan_reconcile.rs b/packages/runtime-core/src/provider/graph/plan_reconcile.rs index f05b72997..90bedcad1 100644 --- a/packages/runtime-core/src/provider/graph/plan_reconcile.rs +++ b/packages/runtime-core/src/provider/graph/plan_reconcile.rs @@ -1044,6 +1044,29 @@ mod tests { assert_eq!(rebuilt.phase, "reserved"); } + #[test] + fn adding_a_cache_writer_selects_a_fresh_cache() { + let (project, receipt, _) = cached_project(); + let old = receipt.resources["volume:deps"].clone(); + // A new service that mounts the shared cache read-write can change what + // every run sharing it reuses, so it is part of the cache identity. + let mut next = cached("one", "/bin/install"); + next["services"]["worker"] = json!({ + "image": image('a'), + "network_mode": "none", + "entrypoint": ["/bin/worker"], + "volumes": ["deps:/deps"], + }); + let (_, projection) = reconcile_cached(&project, &receipt, next); + let fresh = &projection.resources["volume:deps"]; + assert_ne!(fresh.cache, old.cache); + assert_eq!(fresh.phase, "reserved"); + assert!(same( + &projection.resources["volume:data"], + &receipt.resources["volume:data"] + )); + } + #[test] fn retained_run_data_is_never_reselected_as_a_dependency_cache() { let project = Project::new(); diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index 19a8dfce7..2d3b09877 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -746,8 +746,9 @@ or application data contents. cache key when a stopped graph is reconciled after a Compose edit. A cache is a shared volume named by an identity of its inputs; run-owned data uses the run's own names. Edits change the cache's own inputs (initializer definition, declaration, -image, lockfiles), anything else in the Compose file, or whether the key is declared -as a cache. Two values for each explore 54 distinct states. +image, lockfiles, and every service mount of the cache volume, since any of those +services can write it), anything else in the Compose file, or whether the key is +declared as a cache. Two values for each explore 54 distinct states. | Model action | Implementation boundary | | --- | --- | diff --git a/tests/models/tla/native-cache-reconcile/CacheReconcile.tla b/tests/models/tla/native-cache-reconcile/CacheReconcile.tla index 0b03c7e04..f59d4be26 100644 --- a/tests/models/tla/native-cache-reconcile/CacheReconcile.tla +++ b/tests/models/tla/native-cache-reconcile/CacheReconcile.tla @@ -3,8 +3,9 @@ Compose edit. A cache is a shared volume named by an identity of its inputs; run-owned data lives under the run's own names. Compute is always recreated, so only which volume backs the cache key is modeled. `inputs` are the cache's - own inputs (initializer definition, declaration, image, lockfiles); `other` is - every other part of the Compose file. Broken constants are negative controls. *) + own inputs (initializer definition, declaration, image, lockfiles and every + service mount of the cache volume, since each can write it); `other` is every + other part of the Compose file. Broken constants are negative controls. *) EXTENDS Naturals, TLC CONSTANTS Values, WholeDocument, \* identity also binds `other` (the earlier formula) From 08f2d8c3eb207a7393fe13326153cb7c85824b57 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:39:36 -0500 Subject: [PATCH 127/138] fix(native): check process wrappers for profile-active services only The wrapper preflight checked every authored service, so a service behind an unselected profile with an unsupported wrapper setting blocked the whole up and restart even though it never runs. Apply Compose profile activation before the check in the start and restart preflights. Doctor reports profile-gated services as information only and never warns on them. Refs HACK-1939 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 4 +- .../native-project-restart-preflight.ts | 1 + src/backends/native-project-start.ts | 1 + src/commands/doctor.ts | 7 +- src/lib/native-process-wrappers.ts | 87 +++++++++++++++++-- tests/native-process-wrappers.test.ts | 69 +++++++++++++++ tests/native-project-start.test.ts | 70 +++++++++++++++ 7 files changed, 227 insertions(+), 12 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 03ba8d575..f4c88b67f 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -2555,7 +2555,9 @@ 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. +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 diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 9be223cd8..bf63a9c46 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -458,6 +458,7 @@ export async function preflightNativeRestart(opts: { // before the current graph is stopped. requireWrappableNativeProcesses({ services: specs, + profiles: selected.profiles, managedEnvironment: input.managedEnvironment, managedFiles: input.managedFiles, dependencyServices: dependencies.map((binding) => binding.service), diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 67bf37985..e7b73a3ca 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -1580,6 +1580,7 @@ export async function startNativeProject(opts: { // 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), diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 6274d8a8f..14739e145 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -173,9 +173,7 @@ import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local import { NATIVE_PROCESS_CHECK, type NativeProcessReport, - nativeProcessCheckResult, - nativeWrappedServiceCandidates, - predictNativeProcessRefusals, + nativeProcessCheckFromCompose, } from "../lib/native-process-wrappers.ts"; import { isMac } from "../lib/os.ts"; import { @@ -3088,11 +3086,10 @@ async function checkNativeProcessWrappers(opts: { projectDir: ctx.projectDir, staticHosts: cfg.internal?.extraHosts, }).catch(() => ({ hosts: {} })); - const candidates = nativeWrappedServiceCandidates({ + return nativeProcessCheckFromCompose({ compose: await readNativeComposeDocument(ctx.composeFile), projectWideAliases: Object.keys(aliases.hosts).length > 0, }); - return nativeProcessCheckResult(predictNativeProcessRefusals(candidates)); } /** diff --git a/src/lib/native-process-wrappers.ts b/src/lib/native-process-wrappers.ts index d01758f6f..5d4c7087c 100644 --- a/src/lib/native-process-wrappers.ts +++ b/src/lib/native-process-wrappers.ts @@ -303,6 +303,46 @@ export function predictNativeProcessRefusals(opts: { 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."; @@ -318,6 +358,8 @@ 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>> >; @@ -337,7 +379,7 @@ export function requireWrappableNativeProcesses(opts: { .map(([service]) => service) ); const refusals = predictNativeProcessRefusals({ - services: opts.services, + services: activeServices(opts.services, opts.profiles), wrapped, dependencies, fileOwners, @@ -356,6 +398,8 @@ 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 { @@ -432,27 +476,58 @@ export function nativeWrappedServiceCandidates(opts: { * value-free apart from service names. */ export function nativeProcessCheckResult( - refusals: readonly NativeProcessRefusal[] + 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 })) }; + 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; native up checks image entrypoints, commands and users before it reserves anything and names every refused service at once.", + 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)}.`, + 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 index 9ef82a3cd..81c48a8ed 100644 --- a/tests/native-process-wrappers.test.ts +++ b/tests/native-process-wrappers.test.ts @@ -5,6 +5,7 @@ import { describeNativeProcessRefusals, NATIVE_PROCESS_CHECK, NATIVE_PROCESS_WRAPPER_REFUSAL, + nativeProcessCheckFromCompose, nativeProcessCheckResult, nativeWrappedServiceCandidates, predictNativeProcessRefusals, @@ -119,6 +120,7 @@ test("the up preflight refuses before runtime work, naming every service and fie web: { image: "nginx", healthcheck: { test: ["CMD"] } }, idle: { image: "nginx", user: "nginx" }, }, + profiles: [], managedEnvironment: { db: { PASSWORD: "synthetic" }, idle: {} }, dependencyServices: ["web"], }); @@ -208,3 +210,70 @@ test("doctor predicts for every service when project-wide aliases exist", () => ); 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-start.test.ts b/tests/native-project-start.test.ts index 061f3010e..43d64f11a 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -4515,3 +4515,73 @@ test("HACK-1939: runtime process refusals are named in the nothing-reserved repo "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([]); +}); From 592a9b1dcbb53aad32e13d89595ec5178fdef2ad Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:46:23 -0500 Subject: [PATCH 128/138] fix(native): scope dependency caches with writable consumers to one instance A service other than the initializer that mounts a dependency cache read-write can change the cache contents through its own image, command, environment or source, none of which the cache identity hashes. On a contributor branch it could therefore poison a cache that a linked worktree later reuses. Within one checkout the same author controls every input, so the trust boundary is sharing across worktrees and branch instances. - A cache with a writable non-initializer consumer binds the plan namespace (this checkout and branch instance) into its scope, so it is never shared across linked worktrees. Caches whose other consumers all mount read-only keep sharing; the initializer's definition, image and lockfiles stay in the identity. - Such graphs always re-prove the cache through reconciliation, and no earlier whole-document identity is matched for them: a run created by an earlier build moves to its instance-scoped cache once instead of reusing the shared one. Env-only edits inside one instance still keep the cache. - The native-cache-reconcile model adds writable consumers and foreign writes (2,208 states) with a `poison` negative control that keeps such caches shared and fails `NoForeignWrites`. Refs HACK-1937 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 9 +- docs/reference/v5-model-coverage.md | 2 +- packages/runtime-core/README.md | 6 +- .../src/provider/graph/dependency_cache.rs | 120 ++++++++++++++++-- .../src/provider/graph/plan_reconcile.rs | 80 +++++++++--- scripts/lib/tla-runtime-models.ts | 8 +- tests/models/tla/README.md | 33 +++-- .../native-cache-reconcile/CacheReconcile.tla | 113 ++++++++++------- .../native-cache-reconcile/data-as-cache.cfg | 4 +- .../tla/native-cache-reconcile/negative.cfg | 4 +- .../tla/native-cache-reconcile/poison.cfg | 4 + .../tla/native-cache-reconcile/positive.cfg | 4 +- .../tla/native-cache-reconcile/stale.cfg | 4 +- 13 files changed, 300 insertions(+), 91 deletions(-) create mode 100644 tests/models/tla/native-cache-reconcile/poison.cfg diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 56dd369fd..d073f2b18 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -722,7 +722,14 @@ declared lockfile and runtime-file contents, and the cache volume layout: every service that mounts the cache volume, with its target, subpath and `:ro` mode. Any of those services can write the shared contents, so adding, removing or changing such a mount is a cache input; mounts stay as declared, as in Compose and v4, and -are not forced read-only. Any other edit to another service, such as an +are not forced read-only. Caches are shared across linked worktrees and branch +instances only while every service other than the initializers mounts them +read-only. A service that mounts the cache read-write can change its contents +through its own image, command, environment or source, which the identity does not +cover, so that cache is scoped to this checkout and branch instance and never +shared. To share it again, mount it with `:ro` in the other services. A run created +by an earlier build whose cache has such a writer moves to its own instance cache +once instead of reusing the shared one. Any other edit to another service, such as an environment value on the app, keeps the cache. An edit to one of those inputs, including a lockfile, selects the cache for the new identity as a fresh `up` would, so the initializer installs into it; the old cache is shared and is only released by this run. Each such `up` publishes the current diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 386fec859..70cf0dab3 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -67,7 +67,7 @@ liveness/reachability controls have their own documented assumptions. | Model | Positive distinct states | Configurations | | --- | ---: | ---: | | [native-plan-reconcile](../../tests/models/tla/native-plan-reconcile/Reconcile.tla) | 747,656 | 4 | -| [native-cache-reconcile](../../tests/models/tla/native-cache-reconcile/CacheReconcile.tla) | 54 | 4 | +| [native-cache-reconcile](../../tests/models/tla/native-cache-reconcile/CacheReconcile.tla) | 2,208 | 5 | | [pre-ready-retirement](../../tests/models/tla/pre-ready-retirement/Retirement.tla) | 10,965 | 8 | | [native-persistent-enrollment](../../tests/models/tla/native-persistent-enrollment/Enrollment.tla) | 38 | 6 | | [native-frontend-recovery](../../tests/models/tla/native-frontend-recovery/Recovery.tla) | 602 | 10 | diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index 9a7221df2..03f988806 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -1457,7 +1457,11 @@ definition and inputs plus every service mount of the cache volume, not the whol Compose digest) equals the recorded binding is kept with its provenance; a changed identity is selected like a fresh admission (adopted by labels or created) with a freshly published source revision, and the old -shared cache is only released. A retained run-owned volume newly declared as a cache +shared cache is only released. A cache that a non-initializer service mounts +read-write (`dependency_cache::instance_scoped_volumes`) binds the plan namespace +into its scope, so it is never shared across linked worktrees or branch instances; +such graphs always re-prove the cache through reconciliation, and no earlier +whole-document identity is matched for them. A retained run-owned volume newly declared as a cache refuses with `graph_plan_reconcile` and a `plan_change` diagnostic naming the service and field. A receipt recorded under the earlier whole-document identity keeps that cache while the review still resolves to it (`config::retain_recorded_legacy_caches`). diff --git a/packages/runtime-core/src/provider/graph/dependency_cache.rs b/packages/runtime-core/src/provider/graph/dependency_cache.rs index 1e7a409b6..e3bc2b500 100644 --- a/packages/runtime-core/src/provider/graph/dependency_cache.rs +++ b/packages/runtime-core/src/provider/graph/dependency_cache.rs @@ -82,6 +82,28 @@ fn selected(manifest: &ContentRevision, path: &str) -> Result, Can _ => Err(refused()), } } +/// Logical cache volumes that a service other than their initializers mounts +/// read-write. Such a consumer can change the shared contents with inputs the +/// identity does not hash (its image, command, environment or source), so the +/// cache is scoped to this checkout and branch instance (the plan namespace) +/// rather than shared across linked worktrees. Read-only consumers keep sharing. +pub fn instance_scoped_volumes(plan: &PlanData) -> std::collections::BTreeSet { + let active = || plan.services.iter().filter(|(_, s)| s.active); + active() + .filter_map(|(_, s)| s.dependency_cache.as_ref().map(|d| d.volume.clone())) + .filter(|volume| { + active().any(|(_, s)| { + s.dependency_cache + .as_ref() + .is_none_or(|d| &d.volume != volume) + && s.mounts + .iter() + .any(|m| m.kind == "volume" && &m.source == volume && !m.read_only) + }) + }) + .collect() +} + /// Cache identities for one review: `current` names the volumes a fresh /// admission selects; `legacy` recomputes the earlier whole-document identity for /// the same inputs, so a retained receipt recorded under it can still be matched @@ -134,6 +156,12 @@ pub fn resolve_with_legacy( } let mut current = BTreeMap::new(); let mut legacy = BTreeMap::new(); + let scoped = instance_scoped_volumes(plan); + let instance_scope = hash(&( + "hack-dependency-cache-instance-scope-v1", + scope, + &plan.namespace, + ))?; for (name, service) in plan.services.iter().filter(|(_, s)| s.active) { let Some(declaration) = &service.dependency_cache else { continue; @@ -199,9 +227,15 @@ pub fn resolve_with_legacy( // Script content belongs in explicitly declared runtime files; whole source // revisions would prevent compatible branches with unrelated source changes // from sharing. The legacy identity also bound the whole Compose digest. + let writable = scoped.contains(&declaration.volume); + let current_scope = if writable { + instance_scope.as_str() + } else { + scope + }; let current_fingerprint = hash(&( "hack-dependency-cache-v2", - scope, + current_scope, &declaration.volume, image, service, @@ -240,7 +274,7 @@ pub fn resolve_with_legacy( } else { legacy_fingerprint }; - let binding = |fingerprint| CacheBinding { + let binding = |scope: &str, fingerprint| CacheBinding { scope: scope.into(), fingerprint, image: image.clone(), @@ -248,13 +282,18 @@ pub fn resolve_with_legacy( insert_binding( &mut current, &declaration.volume, - binding(current_fingerprint), - )?; - insert_binding( - &mut legacy, - &declaration.volume, - binding(legacy_fingerprint), + binding(current_scope, current_fingerprint), )?; + // An earlier shared cache with a writable consumer may hold another + // worktree's writes, so it is never matched again: such a receipt moves to + // its instance-scoped cache once. + if !writable { + insert_binding( + &mut legacy, + &declaration.volume, + binding(scope, legacy_fingerprint), + )?; + } } Ok(Resolved { current, legacy }) } @@ -858,6 +897,71 @@ mod tests { assert_eq!(shared.len(), 1); assert_ne!(baseline, shared); } + #[test] + fn a_writable_consumer_scopes_the_cache_to_one_instance() { + let (_source, _home, plan, manifest) = prepared(); + let scope = "a".repeat(64); + // Two linked worktrees (or branch instances) share the scope root but + // have their own plan namespaces. + let worktree = |plan: &PlanData, namespace: char| { + let mut plan = plan.clone(); + plan.namespace = namespace.to_string().repeat(64); + plan + }; + let with_consumer = |read_only: bool| { + let mut plan = plan.clone(); + let mut app = plan.services["deps"].clone(); + app.dependency_cache = None; + app.mounts[0].read_only = read_only; + plan.services.insert("app".into(), app); + plan + }; + // Only initializers write: shared across worktrees. + assert!(instance_scoped_volumes(&plan).is_empty()); + assert_eq!( + resolve(&worktree(&plan, 'b'), &manifest, &scope).unwrap(), + resolve(&worktree(&plan, 'c'), &manifest, &scope).unwrap() + ); + // A read-only consumer cannot change the contents: still shared. + let reader = with_consumer(true); + assert!(instance_scoped_volumes(&reader).is_empty()); + let shared = resolve(&worktree(&reader, 'b'), &manifest, &scope).unwrap(); + assert_eq!(shared["deps"].scope, scope); + assert_eq!( + shared, + resolve(&worktree(&reader, 'c'), &manifest, &scope).unwrap() + ); + // A writable consumer in worktree A never shares with worktree B, and has + // no earlier shared identity a receipt could keep. + let writer = with_consumer(false); + assert_eq!( + instance_scoped_volumes(&writer), + BTreeSet::from(["deps".to_owned()]) + ); + let a = super::resolve_with_legacy( + &worktree(&writer, 'b'), + &manifest, + &scope, + &executable(&writer), + ) + .unwrap(); + let b = resolve(&worktree(&writer, 'c'), &manifest, &scope).unwrap(); + assert_ne!(a.current["deps"].scope, scope); + assert_ne!(a.current, b); + assert_ne!(a.current["deps"].name(), b["deps"].name()); + assert!(a.legacy.is_empty()); + // Inside one instance an environment edit to the writer keeps the cache. + let mut edited = worktree(&writer, 'b'); + edited.services.get_mut("app").unwrap().environment.insert( + "MARKER".into(), + crate::project::RedactedText { + environment_references: Vec::new(), + literal_redacted: true, + }, + ); + assert_eq!(a.current, resolve(&edited, &manifest, &scope).unwrap()); + } + #[test] fn missing_lockfiles_and_symlink_inputs_refuse() { let (_source, _home, mut plan, mut manifest) = prepared(); diff --git a/packages/runtime-core/src/provider/graph/plan_reconcile.rs b/packages/runtime-core/src/provider/graph/plan_reconcile.rs index 90bedcad1..fd8b864fd 100644 --- a/packages/runtime-core/src/provider/graph/plan_reconcile.rs +++ b/packages/runtime-core/src/provider/graph/plan_reconcile.rs @@ -77,7 +77,17 @@ fn eligible(receipt: &Receipt) -> bool { /// declared set (a dependency cache takes an index but keeps its content name). /// Once a reconciliation reattached data out of that order, or retains data no /// longer declared, only this projection can restore the graph again. +/// A cache with a writable consumer is scoped to this instance; a receipt recorded +/// before that scoping names a shared cache that may hold another worktree's +/// writes. Such graphs always take this path, which keeps the cache only when its +/// freshly resolved instance-scoped identity equals the recorded binding exactly. fn ordinary_layout(receipt: &Receipt, plan: &project::PlanData) -> bool { + let scoped = dependency_cache::instance_scoped_volumes(plan); + if receipt.resources.values().any(|resource| { + resource.kind == Kind::Volume && resource.cache.is_some() && scoped.contains(&resource.key) + }) { + return false; + } let declared = active_volume_keys(plan); let recorded = receipt .resources @@ -905,9 +915,18 @@ mod tests { assert_eq!(missing.code, "graph_data_missing"); } - /// An initializer populating a shared dependency cache, beside an app that - /// mounts the cache and its own run-owned data. + /// An initializer populating a dependency cache, beside an app that mounts + /// the cache read-write (so the cache is instance-scoped) and its own data. fn cached(marker: &str, installer: &str) -> Value { + cached_with(marker, installer, "deps:/deps") + } + + /// The same graph whose app only reads the cache, so the cache is shared. + fn cached_shared(marker: &str, installer: &str) -> Value { + cached_with(marker, installer, "deps:/deps:ro") + } + + fn cached_with(marker: &str, installer: &str, consumer: &str) -> Value { json!({ "services": { "deps": { @@ -926,7 +945,7 @@ mod tests { "network_mode": "none", "entrypoint": ["/bin/app"], "environment": {"MARKER": marker}, - "volumes": ["deps:/deps", "data:/data"], + "volumes": [consumer, "data:/data"], }, }, "volumes": {"deps": {}, "data": {}}, @@ -934,10 +953,14 @@ mod tests { } fn cached_project() -> (Project, Receipt, config::Prepared) { + cached_project_with(cached("one", "/bin/install")) + } + + fn cached_project_with(document: Value) -> (Project, Receipt, config::Prepared) { let project = Project::new(); fs::write(project.root.join("lock.txt"), "v1").unwrap(); - let (report, identity, prepared) = - project.prepared_with(cached("one", "/bin/install"), true); + let shared = document["services"]["app"]["volumes"][0] == "deps:/deps:ro"; + let (report, identity, prepared) = project.prepared_with(document, true); let mut receipt = stopped(&prepared, &identity); // A completed first admission recorded the cache's provenance fence. let provenance: cache_provenance::CacheProvenance = serde_json::from_value(json!({ @@ -952,13 +975,12 @@ mod tests { .unwrap() .cache_provenance = Some(provenance); assert!(receipt.resources["volume:deps"].cache.is_some()); - assert!(!applies( - &receipt, - &report.plan_id, - &report.plan, - &identity, - false - )); + // A shared cache keeps strict replay; an instance-scoped one always + // re-proves its identity through reconciliation. + assert_eq!( + applies(&receipt, &report.plan_id, &report.plan, &identity, false), + !shared + ); (project, receipt, prepared) } @@ -1119,7 +1141,8 @@ mod tests { #[test] fn legacy_cache_identity_is_kept_only_while_its_inputs_still_match() { - let (project, mut receipt, original) = cached_project(); + let (project, mut receipt, original) = + cached_project_with(cached_shared("one", "/bin/install")); let legacy = original.legacy_caches["deps"].clone(); assert_ne!( Some(&legacy), @@ -1130,7 +1153,7 @@ mod tests { deps.name = legacy.name(); // Unchanged review: strict replay keeps the recorded legacy cache. let (report, identity, mut prepared) = - project.prepared_with(cached("one", "/bin/install"), true); + project.prepared_with(cached_shared("one", "/bin/install"), true); // The review's source metadata is fresh; the receipt is that admission's. receipt.plan_id = report.plan_id.clone(); assert!(!applies( @@ -1145,13 +1168,40 @@ mod tests { assert!(mounts(&prepared, "app").contains(&legacy.name())); // An edit changes the legacy whole-document identity: the current one is // selected once, after which edits elsewhere keep it. - let (_, projection) = reconcile_cached(&project, &receipt, cached("two", "/bin/install")); + let (_, projection) = + reconcile_cached(&project, &receipt, cached_shared("two", "/bin/install")); let fresh = &projection.resources["volume:deps"]; assert_ne!(fresh.name, legacy.name()); assert_eq!(fresh.cache, original.resources["volume:deps"].cache); assert_eq!(fresh.phase, "reserved"); } + #[test] + fn an_earlier_shared_cache_with_a_writable_consumer_moves_to_this_instance() { + let (project, mut receipt, original) = cached_project(); + // No earlier identity is ever matched for a writable consumer. + assert!(!original.legacy_caches.contains_key("deps")); + let scoped = original.resources["volume:deps"].cache.clone().unwrap(); + // A receipt from an earlier build names a cache shared across worktrees. + let shared = dependency_cache::CacheBinding { + scope: dependency_cache::scope(&project.root).unwrap(), + fingerprint: "c".repeat(64), + image: scoped.image.clone(), + }; + assert_ne!(shared.scope, scoped.scope); + let deps = receipt.resources.get_mut("volume:deps").unwrap(); + deps.cache = Some(shared.clone()); + deps.name = shared.name(); + // Even the unchanged review re-proves identity and selects this + // instance's cache once; the shared one is never mounted again. + let (prepared, projection) = + reconcile_cached(&project, &receipt, cached("one", "/bin/install")); + let fresh = &projection.resources["volume:deps"]; + assert_eq!(fresh.cache.as_ref(), Some(&scoped)); + assert_eq!(fresh.phase, "reserved"); + assert!(!mounts(&prepared, "app").contains(&shared.name())); + } + #[test] fn reserved_shared_caches_may_exist_but_reserved_run_names_may_not() { let (project, receipt, _) = cached_project(); diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index ebbc0f737..1bbc93bb0 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -152,7 +152,7 @@ const contracts: readonly ModelContract[] = [ { name: "native-cache-reconcile", module: "CacheReconcile", - states: 54, + states: 2208, invariant: "UnrelatedEditKeepsCache", action: "UpCache", fields: ["needlessRebuild = TRUE", "staleMounted = FALSE"], @@ -169,6 +169,12 @@ const contracts: readonly ModelContract[] = [ invariant: "NoDataAsCache", fields: ["dataMounted = TRUE", "staleMounted = FALSE"], }, + { + name: "poison", + negative: true, + invariant: "NoForeignWrites", + fields: ["foreignMounted = TRUE", "staleMounted = FALSE"], + }, ], }, { diff --git a/tests/models/tla/README.md b/tests/models/tla/README.md index 2d3b09877..c9017c6de 100644 --- a/tests/models/tla/README.md +++ b/tests/models/tla/README.md @@ -744,16 +744,21 @@ or application data contents. `native-cache-reconcile/CacheReconcile.tla` checks which volume backs a dependency cache key when a stopped graph is reconciled after a Compose edit. A cache is a -shared volume named by an identity of its inputs; run-owned data uses the run's own -names. Edits change the cache's own inputs (initializer definition, declaration, -image, lockfiles, and every service mount of the cache volume, since any of those -services can write it), anything else in the Compose file, or whether the key is -declared as a cache. Two values for each explore 54 distinct states. +volume named by an identity of its inputs; run-owned data uses the run's own names. +Edits change the cache's own inputs (initializer definition, declaration, image, +lockfiles, and every service mount of the cache volume), anything else in the +Compose file, whether a non-initializer service mounts the cache read-write, or +whether the key is declared as a cache. Such a writable consumer can change the +contents with inputs the identity does not hash, so its cache is scoped to this +checkout or branch instance; another instance with its own writable consumer may +write into the caches it selects (`ForeignWrite`). Two values for each explore +2,208 distinct states. | Model action | Implementation boundary | | --- | --- | -| `EditInputs` / `EditOther` / `EditDeclaration` | Compose or lockfile edit between ups; `dependency_cache::resolve_with_legacy` computes the current identity | -| `UpCache` | `plan_reconcile::prepare` refuses run data declared as a cache; `Verified::project` keeps a cache of the same identity or selects the new identity's shared cache, then `Session::create_resources` adopts it by labels or creates it | +| `EditInputs` / `EditOther` / `EditConsumer` / `EditDeclaration` | Compose or lockfile edit between ups; `dependency_cache::resolve_with_legacy` computes the current identity and `instance_scoped_volumes` the scope | +| `ForeignWrite` | A linked worktree or branch instance whose writable consumer changes the cache it mounts | +| `UpCache` | `plan_reconcile::prepare` refuses run data declared as a cache; `Verified::project` keeps a cache of the same identity or selects the new identity's cache, then `Session::create_resources` adopts it by labels or creates it | | `UpData` | A key that is no longer a cache gets a fresh run-owned volume; the cache reference is released, never deleted | | `Stop` | Ctrl-C or `down` keeps every recorded volume | @@ -762,14 +767,18 @@ earlier formula) and must fail `UnrelatedEditKeepsCache` in an `UpCache` state w `needlessRebuild = TRUE`. The `stale` control reattaches the recorded cache whatever its identity and must fail `CacheMatchesInputs` with `staleMounted = TRUE`. The `data-as-cache` control mounts retained run data newly declared as a cache and must -fail `NoDataAsCache` with `dataMounted = TRUE`. +fail `NoDataAsCache` with `dataMounted = TRUE`. The `poison` control keeps caches +with a writable consumer shared across instances and must fail `NoForeignWrites` +with `foreignMounted = TRUE`. Assumptions: identities are exact (no collisions), the initializer populates a newly -selected cache before dependents start, and admission holds the provider lease. The +selected cache before dependents start, read-only consumers and initializers only +produce what the identity describes, and admission holds the provider lease. Within +one instance the same author controls every input, so writes there are trusted. The compatibility path that keeps a receipt recorded under the earlier identity while -its inputs still match is covered by `plan_reconcile.rs` tests, not this model. It -does not prove initializer idempotence, cache content, capacity limits or cache-wide -collection. +its inputs still match (never for a writable consumer) is covered by +`plan_reconcile.rs` tests, not this model. It does not prove initializer +idempotence, cache content, capacity limits or cache-wide collection. ## Graph admission diff --git a/tests/models/tla/native-cache-reconcile/CacheReconcile.tla b/tests/models/tla/native-cache-reconcile/CacheReconcile.tla index f59d4be26..8dc981632 100644 --- a/tests/models/tla/native-cache-reconcile/CacheReconcile.tla +++ b/tests/models/tla/native-cache-reconcile/CacheReconcile.tla @@ -1,71 +1,94 @@ ------------------------- MODULE CacheReconcile ------------------------- (* Dependency-cache selection when a stopped native graph is reconciled after a - Compose edit. A cache is a shared volume named by an identity of its inputs; - run-owned data lives under the run's own names. Compute is always recreated, - so only which volume backs the cache key is modeled. `inputs` are the cache's - own inputs (initializer definition, declaration, image, lockfiles and every - service mount of the cache volume, since each can write it); `other` is every - other part of the Compose file. Broken constants are negative controls. *) + Compose edit. A cache is a volume named by an identity of its inputs; run-owned + data lives under the run's own names. Compute is always recreated, so only + which volume backs the cache key is modeled, for one trusted instance. `inputs` + are the cache's own inputs (initializer definition, declaration, image, + lockfiles and every service mount of the cache volume); `other` is every other + part of the Compose file. `writable` says a non-initializer service mounts the + cache read-write: it can change the contents with inputs the identity does not + hash, so the cache is scoped to this instance. A linked worktree or branch + instance with its own writable consumer may write into the caches it selects. + Broken constants are negative controls. *) EXTENDS Naturals, TLC CONSTANTS Values, WholeDocument, \* identity also binds `other` (the earlier formula) KeepStale, \* reattach the recorded cache whatever its identity - DataAsCache \* reselect retained run data newly declared as a cache -None == [kind |-> "none", id |-> <<0, 0>>, rin |-> 0] -Id(i, o) == IF WholeDocument THEN <> ELSE <> -VARIABLES phase, inputs, other, declared, recorded, caches, mounted, - staleMounted, dataMounted, needlessRebuild -vars == <> + DataAsCache, \* reselect retained run data newly declared as a cache + ShareWritable \* keep a cache with a writable consumer shared +Entry(k, id, i, w) == [kind |-> k, id |-> id, rin |-> i, rw |-> w] +None == Entry("none", <<0, 0, "none">>, 0, FALSE) +Scope(w) == IF w /\ ~ShareWritable THEN "here" ELSE "shared" +Id(i, o, w) == <> +VARIABLES phase, inputs, other, writable, declared, recorded, caches, tainted, + mounted, staleMounted, dataMounted, needlessRebuild, foreignMounted +vars == <> +flags == <> -\* A first admission populated the cache for the initial inputs. +\* A first admission populated the cache for the initial inputs; it was shared. Init == /\ phase = "stopped" - /\ inputs = 1 /\ other = 1 /\ declared = "cache" - /\ recorded = [kind |-> "cache", id |-> Id(1, 1), rin |-> 1] - /\ caches = {Id(1, 1)} + /\ inputs = 1 /\ other = 1 /\ writable = FALSE /\ declared = "cache" + /\ recorded = Entry("cache", Id(1, 1, FALSE), 1, FALSE) + /\ caches = {Id(1, 1, FALSE)} + /\ tainted = {} /\ mounted = None - /\ staleMounted = FALSE /\ dataMounted = FALSE /\ needlessRebuild = FALSE + /\ staleMounted = FALSE /\ dataMounted = FALSE + /\ needlessRebuild = FALSE /\ foreignMounted = FALSE -\* Edits between ups: the cache's own inputs, anything else, or whether the key -\* is declared as a cache at all. +\* Edits between ups: the cache's own inputs, anything else, a writable consumer +\* added or removed, or whether the key is declared as a cache at all. EditInputs(v) == /\ phase = "stopped" /\ v # inputs /\ inputs' = v - /\ UNCHANGED <> + /\ UNCHANGED <> EditOther(v) == /\ phase = "stopped" /\ v # other /\ other' = v - /\ UNCHANGED <> + /\ UNCHANGED <> +EditConsumer == /\ phase = "stopped" /\ writable' = ~writable + /\ UNCHANGED <> EditDeclaration == /\ phase = "stopped" /\ declared' = IF declared = "cache" THEN "data" ELSE "cache" - /\ UNCHANGED <> + /\ UNCHANGED <> + +\* Another instance whose writable consumer runs with any inputs writes into the +\* cache it selects. Its own instance-scoped identity is never "here". +ForeignWrite(v, o) == + LET id == <> IN + /\ tainted' = tainted \cup {id} + /\ caches' = caches \cup {id} + /\ UNCHANGED <> \* plan_reconcile::Verified::project then Session::create_resources: keep a -\* recorded cache of the same identity; otherwise select that identity's shared -\* cache (adopt or create; the initializer populates it) and release the old +\* recorded cache of the same identity; otherwise select that identity's cache +\* (adopt or create; the initializer populates it) and release the old \* reference. Retained run data declared as a cache is refused. UpCache == - LET want == Id(inputs, other) - entry == [kind |-> "cache", id |-> want, rin |-> inputs] IN + LET want == Id(inputs, other, writable) + entry == Entry("cache", want, inputs, writable) IN /\ phase = "stopped" /\ declared = "cache" /\ IF recorded.kind = "data" THEN IF DataAsCache THEN /\ phase' = "running" /\ mounted' = recorded /\ dataMounted' = TRUE - /\ UNCHANGED <> - ELSE UNCHANGED <> + /\ UNCHANGED <> + ELSE UNCHANGED <> ELSE IF recorded.kind = "cache" /\ (recorded.id = want \/ KeepStale) THEN /\ phase' = "running" /\ mounted' = recorded /\ staleMounted' = (staleMounted \/ recorded.rin # inputs) + /\ foreignMounted' = (foreignMounted \/ recorded.id \in tainted) /\ UNCHANGED <> ELSE /\ phase' = "running" /\ mounted' = entry /\ recorded' = entry /\ caches' = caches \cup {want} /\ needlessRebuild' = (needlessRebuild \/ (recorded.kind = "cache" /\ recorded.rin = inputs - /\ want \notin caches)) + /\ recorded.rw = writable /\ want \notin caches)) + /\ foreignMounted' = (foreignMounted \/ want \in tainted) /\ UNCHANGED <> - /\ UNCHANGED <> + /\ UNCHANGED <> \* The key is ordinary run data: reattach recorded data, or create fresh run data \* when it was a cache (whose shared volume is only released, never deleted). @@ -73,21 +96,21 @@ UpData == /\ phase = "stopped" /\ declared = "data" /\ phase' = "running" /\ recorded' = IF recorded.kind = "data" THEN recorded - ELSE [kind |-> "data", id |-> <<0, 0>>, rin |-> 0] + ELSE Entry("data", <<0, 0, "none">>, 0, FALSE) /\ mounted' = recorded' - /\ UNCHANGED <> + /\ UNCHANGED <> Stop == /\ phase = "running" /\ phase' = "stopped" /\ mounted' = None - /\ UNCHANGED <> + /\ UNCHANGED <> Next == \/ \E v \in Values : EditInputs(v) \/ EditOther(v) - \/ EditDeclaration \/ UpCache \/ UpData \/ Stop + \/ \E v, o \in Values : ForeignWrite(v, o) + \/ EditConsumer \/ EditDeclaration \/ UpCache \/ UpData \/ Stop Spec == Init /\ [][Next]_vars TypeOK == /\ phase \in {"stopped", "running"} - /\ inputs \in Values /\ other \in Values + /\ inputs \in Values /\ other \in Values /\ writable \in BOOLEAN /\ declared \in {"cache", "data"} /\ recorded.kind \in {"none", "cache", "data"} /\ mounted.kind \in {"none", "cache", "data"} @@ -98,7 +121,9 @@ CacheMatchesInputs == /\ ~staleMounted mounted.rin = inputs \* Run-owned data is never mounted as a shared cache. NoDataAsCache == ~dataMounted -\* An edit that leaves the cache's own inputs unchanged keeps the cache: it never -\* selects a not-yet-populated cache for the same inputs. +\* An edit that leaves the cache's own inputs and consumers unchanged keeps the +\* cache: it never selects a not-yet-populated cache for the same inputs. UnrelatedEditKeepsCache == ~needlessRebuild +\* A cache that another instance's writable consumer wrote is never mounted here. +NoForeignWrites == ~foreignMounted ============================================================================= diff --git a/tests/models/tla/native-cache-reconcile/data-as-cache.cfg b/tests/models/tla/native-cache-reconcile/data-as-cache.cfg index 2c5f19a6f..4dd13de17 100644 --- a/tests/models/tla/native-cache-reconcile/data-as-cache.cfg +++ b/tests/models/tla/native-cache-reconcile/data-as-cache.cfg @@ -1,4 +1,4 @@ SPECIFICATION Spec CONSTANTS Values = {1, 2} - WholeDocument = FALSE KeepStale = FALSE DataAsCache = TRUE -INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache + WholeDocument = FALSE KeepStale = FALSE DataAsCache = TRUE ShareWritable = FALSE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache NoForeignWrites diff --git a/tests/models/tla/native-cache-reconcile/negative.cfg b/tests/models/tla/native-cache-reconcile/negative.cfg index d8c5edf62..bdd5118ab 100644 --- a/tests/models/tla/native-cache-reconcile/negative.cfg +++ b/tests/models/tla/native-cache-reconcile/negative.cfg @@ -1,4 +1,4 @@ SPECIFICATION Spec CONSTANTS Values = {1, 2} - WholeDocument = TRUE KeepStale = FALSE DataAsCache = FALSE -INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache + WholeDocument = TRUE KeepStale = FALSE DataAsCache = FALSE ShareWritable = FALSE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache NoForeignWrites diff --git a/tests/models/tla/native-cache-reconcile/poison.cfg b/tests/models/tla/native-cache-reconcile/poison.cfg new file mode 100644 index 000000000..f2aea6d89 --- /dev/null +++ b/tests/models/tla/native-cache-reconcile/poison.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANTS Values = {1, 2} + WholeDocument = FALSE KeepStale = FALSE DataAsCache = FALSE ShareWritable = TRUE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache NoForeignWrites diff --git a/tests/models/tla/native-cache-reconcile/positive.cfg b/tests/models/tla/native-cache-reconcile/positive.cfg index cbcf0c537..1235af9d0 100644 --- a/tests/models/tla/native-cache-reconcile/positive.cfg +++ b/tests/models/tla/native-cache-reconcile/positive.cfg @@ -1,4 +1,4 @@ SPECIFICATION Spec CONSTANTS Values = {1, 2} - WholeDocument = FALSE KeepStale = FALSE DataAsCache = FALSE -INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache + WholeDocument = FALSE KeepStale = FALSE DataAsCache = FALSE ShareWritable = FALSE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache NoForeignWrites diff --git a/tests/models/tla/native-cache-reconcile/stale.cfg b/tests/models/tla/native-cache-reconcile/stale.cfg index cc68f47cd..086641289 100644 --- a/tests/models/tla/native-cache-reconcile/stale.cfg +++ b/tests/models/tla/native-cache-reconcile/stale.cfg @@ -1,4 +1,4 @@ SPECIFICATION Spec CONSTANTS Values = {1, 2} - WholeDocument = FALSE KeepStale = TRUE DataAsCache = FALSE -INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache + WholeDocument = FALSE KeepStale = TRUE DataAsCache = FALSE ShareWritable = FALSE +INVARIANTS TypeOK CacheMatchesInputs NoDataAsCache UnrelatedEditKeepsCache NoForeignWrites From dbcf2bcc3a9af54bfca332ee2df642beb7bb6a77 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:24:41 -0500 Subject: [PATCH 129/138] fix(native): keep CandidateError under clippy's large-error bound HEAD's fixed-message flag and service name (#615), #638's process_refusals and #637's plan_change together grew CandidateError from 104 to 120 bytes, so StopBatchFailure (error plus a boxed slice) reached 136 bytes and process_refusals::Refused 128. Clippy's result_large_err then failed check:local and the all-features clippy gate. - cause_code is an Option> instead of Option; with_cause_code keeps its String argument. - process_refusals and plan_change share one boxed RefusalDiagnostics, serialized flat with serde(flatten), so the reply JSON keeps both top-level keys. Callers use with_process_refusals, with_plan_change and the process_refusals()/plan_change() accessors. - A new error.rs test checks the flat serialization and that CandidateError stays at most 104 bytes. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/error.rs | 74 +++++++++++++++---- .../src/provider/graph/hostname_change.rs | 4 +- .../src/provider/graph/plan_reconcile.rs | 4 +- .../src/provider/graph/process_refusals.rs | 21 ++---- 4 files changed, 72 insertions(+), 31 deletions(-) diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index e193fc71f..7b6e92100 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -130,6 +130,20 @@ pub struct PlanChangeDiagnostic { pub field: PlanChangeField, } +/// Value-free refusal locations, boxed together so every +/// `Result<_, CandidateError>` stays small; serialized flat as +/// `process_refusals` and `plan_change`. +#[derive(Debug, Default, Serialize)] +pub struct RefusalDiagnostics { + /// 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, + /// Value-free location of a refused retained-run edit; see [`PlanChangeDiagnostic`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub plan_change: Option, +} + #[derive(Debug, Serialize)] pub struct CandidateError { pub code: &'static str, @@ -143,8 +157,9 @@ pub struct CandidateError { /// validated graph identifier, never an engine ID or a value. #[serde(skip_serializing_if = "Option::is_none")] pub service: Option>, + /// Boxed `str`, not `String`, to keep every `Result<_, CandidateError>` small. #[serde(skip_serializing_if = "Option::is_none")] - pub cause_code: Option, + pub cause_code: Option>, /// Boxed, like `startup_logs`, to keep every `Result<_, CandidateError>` small. #[serde(skip_serializing_if = "Option::is_none")] pub stop_failures: Option>, @@ -152,13 +167,9 @@ 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>, - /// Value-free location of a refused retained-run edit; see [`PlanChangeDiagnostic`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub plan_change: Option>, + /// Process-wrapper refusals and plan-change locations; see [`RefusalDiagnostics`]. + #[serde(flatten)] + pub refusals: Option>, } impl CandidateError { @@ -172,8 +183,7 @@ impl CandidateError { cause_code: None, stop_failures: None, startup_logs: None, - process_refusals: None, - plan_change: None, + refusals: None, } } @@ -191,16 +201,33 @@ impl CandidateError { /// Name the service and field of a refused retained-run edit. pub fn with_plan_change(mut self, service: &str, field: PlanChangeField) -> Self { - self.plan_change = Some(Box::new(PlanChangeDiagnostic { + self.refusals.get_or_insert_default().plan_change = Some(PlanChangeDiagnostic { version: 1, service: service.to_owned(), field, - })); + }); self } + /// Name every service and field a private process wrapper refused. + pub fn with_process_refusals(mut self, refusals: Vec) -> Self { + self.refusals.get_or_insert_default().process_refusals = Some(ProcessRefusalsDiagnostic { + version: 1, + refusals, + }); + self + } + + pub fn process_refusals(&self) -> Option<&ProcessRefusalsDiagnostic> { + self.refusals.as_ref()?.process_refusals.as_ref() + } + + pub fn plan_change(&self) -> Option<&PlanChangeDiagnostic> { + self.refusals.as_ref()?.plan_change.as_ref() + } + pub fn with_cause_code(mut self, code: String) -> Self { - self.cause_code = Some(code); + self.cause_code = Some(code.into_boxed_str()); self } } @@ -235,4 +262,25 @@ mod tests { assert!(error.service.is_none(), "{invalid:?}"); } } + + #[test] + fn refusal_diagnostics_serialize_flat_and_stay_small() { + let plain = + serde_json::to_value(CandidateError::new("graph_image", "Fixed text.")).unwrap(); + assert!(plain.get("process_refusals").is_none()); + assert!(plain.get("plan_change").is_none()); + let changed = serde_json::to_value( + CandidateError::new("graph_plan_reconcile", "Fixed text.") + .with_plan_change("deps", super::PlanChangeField::DependencyCacheVolume), + ) + .unwrap(); + assert_eq!( + changed["plan_change"], + serde_json::json!({"version":1,"service":"deps","field":"dependency_cache_volume"}) + ); + assert!(changed.get("process_refusals").is_none()); + // Errors embedded with a boxed slice (stop batches) stay under clippy's + // large-error bound only while the shared error is at most 104 bytes. + assert!(std::mem::size_of::() <= 104); + } } diff --git a/packages/runtime-core/src/provider/graph/hostname_change.rs b/packages/runtime-core/src/provider/graph/hostname_change.rs index 8c8c15d33..d57d4c0be 100644 --- a/packages/runtime-core/src/provider/graph/hostname_change.rs +++ b/packages/runtime-core/src/provider/graph/hostname_change.rs @@ -411,7 +411,7 @@ pub(super) mod tests { // The routed service lost its route: a real hostname change, named. let failure = selected_routes(&plan, &receipt).unwrap_err(); assert_eq!(failure.code, "graph_hostname_change"); - let change = failure.plan_change.unwrap(); + let change = failure.plan_change().unwrap(); assert_eq!(change.service, "web"); assert_eq!(change.field, PlanChangeField::Hostnames); // No route before or after: some other edit, which only reconciliation @@ -419,6 +419,6 @@ pub(super) mod tests { receipt.resources.get_mut("container:web").unwrap().routing = None; let failure = selected_routes(&plan, &receipt).unwrap_err(); assert_eq!(failure.code, "graph_plan_change_unsupported"); - assert!(failure.plan_change.is_none()); + assert!(failure.plan_change().is_none()); } } diff --git a/packages/runtime-core/src/provider/graph/plan_reconcile.rs b/packages/runtime-core/src/provider/graph/plan_reconcile.rs index 364095950..b99031b11 100644 --- a/packages/runtime-core/src/provider/graph/plan_reconcile.rs +++ b/packages/runtime-core/src/provider/graph/plan_reconcile.rs @@ -1105,7 +1105,7 @@ mod tests { project.prepared_with(cached("one", "/bin/install"), true); let failure = prepare(&report.plan, &receipt, &identity).err().unwrap(); assert_eq!(failure.code, "graph_plan_reconcile"); - let change = failure.plan_change.as_deref().unwrap(); + let change = failure.plan_change().unwrap(); assert_eq!(change.service, "deps"); assert_eq!(change.field, PlanChangeField::DependencyCacheVolume); // Admission repeats the check on the projection itself. @@ -1118,7 +1118,7 @@ mod tests { next: identity, }; let failure = proof.project(&mut next, &receipt).err().unwrap(); - assert_eq!(failure.plan_change.unwrap().service, "deps"); + assert_eq!(failure.plan_change().unwrap().service, "deps"); } #[test] diff --git a/packages/runtime-core/src/provider/graph/process_refusals.rs b/packages/runtime-core/src/provider/graph/process_refusals.rs index 04cf89cbc..79696bab2 100644 --- a/packages/runtime-core/src/provider/graph/process_refusals.rs +++ b/packages/runtime-core/src/provider/graph/process_refusals.rs @@ -6,7 +6,7 @@ //! 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}; +use crate::error::{CandidateError, ProcessField, ProcessRefusal}; /// One wrapper refusal before it is attributed to a service. #[derive(Debug)] @@ -66,11 +66,7 @@ impl Collector { "{} Refused process settings are named in process_refusals.", error.message ); - error.process_refusals = Some(Box::new(ProcessRefusalsDiagnostic { - version: 1, - refusals: self.refusals, - })); - Err(error) + Err(error.with_process_refusals(self.refusals)) } } } @@ -110,7 +106,7 @@ mod tests { ); let error = collector.finish().unwrap_err(); assert_eq!(error.code, "environment_user"); - let detail = error.process_refusals.expect("named refusals"); + let detail = error.process_refusals().expect("named refusals"); assert_eq!(detail.version, 1); assert_eq!( detail.refusals, @@ -125,13 +121,10 @@ mod tests { }, ] ); - let reply = serde_json::to_value(CandidateError { - process_refusals: Some(Box::new(ProcessRefusalsDiagnostic { - version: 1, - refusals: detail.refusals, - })), - ..CandidateError::new("environment_user", "Invalid.") - }) + let reply = serde_json::to_value( + CandidateError::new("environment_user", "Invalid.") + .with_process_refusals(detail.refusals.clone()), + ) .unwrap(); assert_eq!( reply["process_refusals"], From 5459c08943efe520af2d3eeff8e082521a665ab5 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:12:09 -0500 Subject: [PATCH 130/138] test(native): scope the doctor settings privacy check to setting paths The native settings doctor test asserted that no path under its temp root appears in doctor output. The native home sits inside that root, and next's hack.local route check legitimately reports its own receipt path there. The test now checks that no absolute project or machine-settings path (and none of the setting values) appears, which is what settings reporting promises. Co-Authored-By: Claude Opus 5.5 --- tests/native-project-settings-cli.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/native-project-settings-cli.test.ts b/tests/native-project-settings-cli.test.ts index 5f7468c16..3078c0f6c 100644 --- a/tests/native-project-settings-cli.test.ts +++ b/tests/native-project-settings-cli.test.ts @@ -391,7 +391,11 @@ test( "registry.example.com", "upstream.example.test", "api.example.test", - f.root, + // No absolute project or machine-settings path: settings are reported + // by origin and relative or ~-anchored paths. Doctor's own receipts + // under the native home are not setting values. + f.project, + privateCliHome(f.root), ]) { expect(result.output).not.toContain(value); } From 91867ae97748caf83ba52b552dd42f6cff02194f Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:52:25 -0500 Subject: [PATCH 131/138] fix(native): keep base-only named env selections restartable A project with hack.env.default.yaml but no hack.env..yaml can start with --env (for example to pick adaptation..json); the base values apply. Startup now records that case as baseOnlyEnvironment, next to the existing unmanagedEnvironment marker, so restart preflight, the next retained up, fresh exec and down hooks accept the unchanged files. The availability rule compares what startup found with what exists now: an overlay that existed must still exist, a base-only run must still lack the overlay, and an unmanaged run must still have no managed env file. A legacy mapping without a marker keeps the overlay requirement. A retained run keeps its recorded marker and is never relabeled. The mapping validator accepts the complete selector set plus any subset of gcloud, runtimeMetadata and one environment marker, instead of a listed set of combinations. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 9 ++- src/backends/native-project-run.ts | 117 +++++++++++++++++++-------- src/backends/native-project-start.ts | 43 ++++++---- tests/native-project-restart.test.ts | 75 +++++++++++++++++ tests/native-project-run.test.ts | 96 ++++++++++++++++++++++ tests/native-project-start.test.ts | 40 ++++++--- 6 files changed, 317 insertions(+), 63 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index cc39587df..7071ead81 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -2314,8 +2314,13 @@ their exact behavior. **Per-env files.** `adaptation..json` and `dependenciesByEnv.` replace the base file for runs whose effective env is ``: the `--env` you pass, the env a retained run recorded, or the project's default env. This holds even when the project -has no `hack.env.*` files. Otherwise the base file applies. A fresh start, a retained -start and a restart of one run therefore always select the same files. +has no `hack.env.*` files, or has `hack.env.default.yaml` but no `hack.env..yaml`. +Otherwise the base file applies. A fresh start, a retained start and a restart of one +run therefore always select the same files. The run records which managed env files +its env had at startup (none, the base only, or the overlay). A later `restart`, +retained `up`, fresh `exec` or `down` hook refuses when that changed: an overlay that +existed is gone, or an overlay (or, for a run started without any, a managed env file) +appeared. **Identity and recovery.** Settings resolution only reads files. It writes no state, so Ctrl-C needs no cleanup, and `down`, `ps`, `logs`, `exec` and `run` keep using the diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index ed0bcdc92..b37325091 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -56,18 +56,59 @@ export type NativeProjectRun = { * started with managed env files (or are legacy): a later missing file refuses. */ readonly unmanagedEnvironment?: true; + /** + * Present only when startup selected a named env while the project had managed env + * files but not that env's overlay, so the base values applied. The overlay must + * still be absent; one that appears later is a changed selection. + */ + readonly baseOnlyEnvironment?: true; }; +/** The startup environment markers a run mapping can carry; at most one is set. */ +export type NativeRunEnvironmentMarkers = Pick< + NativeProjectRun, + "unmanagedEnvironment" | "baseOnlyEnvironment" +>; +function overlayPresent( + envName: string, + environmentFiles: readonly string[] +): boolean { + return environmentFiles.some((path) => + [`hack.env.${envName}.yaml`, `hack.env.${envName}.local.yaml`].includes( + basename(path) + ) + ); +} +/** + * The marker a fresh startup records for its selection: none for a base selection or + * a named env whose overlay exists, `unmanagedEnvironment` without any managed env + * file, and `baseOnlyEnvironment` when managed env files exist but not the overlay. + */ +export function nativeRunEnvironmentMarkers(opts: { + readonly effectiveEnvName: string | null; + readonly environmentFiles: readonly string[]; +}): NativeRunEnvironmentMarkers { + const selected = opts.effectiveEnvName; + if (selected === null) { + return {}; + } + if (opts.environmentFiles.length === 0) { + return { unmanagedEnvironment: true }; + } + return overlayPresent(selected, opts.environmentFiles) + ? {} + : { baseOnlyEnvironment: true }; +} /** - * Whether the managed env files a run's recorded selection needs are still present. - * A base selection needs none. A named selection needs its overlay - * (`hack.env..yaml` or `hack.env..local.yaml`), except for a run recorded - * as started without any managed env file while there still is none. A legacy - * mapping without a recorded selection never qualifies. + * Whether the managed env files a run's recorded selection needs are unchanged. A base + * selection needs none. A named selection needs its overlay (`hack.env..yaml` or + * `hack.env..local.yaml`), except a run recorded as started without any managed + * env file (there must still be none) or without the overlay (it must still be + * absent). A legacy mapping without a recorded selection never qualifies. */ export function nativeRunEnvironmentAvailable(opts: { readonly run: Pick< NativeProjectRun, - "effectiveEnvName" | "unmanagedEnvironment" + "effectiveEnvName" | "unmanagedEnvironment" | "baseOnlyEnvironment" >; readonly environmentFiles: readonly string[]; }): boolean { @@ -78,17 +119,11 @@ export function nativeRunEnvironmentAvailable(opts: { if (selected === null) { return true; } - if ( - opts.run.unmanagedEnvironment === true && - opts.environmentFiles.length === 0 - ) { - return true; + if (opts.run.unmanagedEnvironment === true) { + return opts.environmentFiles.length === 0; } - return opts.environmentFiles.some((path) => - [`hack.env.${selected}.yaml`, `hack.env.${selected}.local.yaml`].includes( - basename(path) - ) - ); + const present = overlayPresent(selected, opts.environmentFiles); + return opts.run.baseOnlyEnvironment === true ? !present : present; } export type NativeProjectRunScope = { readonly projectRoot: string; @@ -157,28 +192,46 @@ function validProfiles(value: unknown): boolean { return false; } } +/** Mapping key sets written before the startup selectors were complete. */ +const LEGACY_KEY_SETS = [ + "namespace,owner,planId,run", + "effectiveEnvName,namespace,owner,planId,run", + "aws,effectiveEnvName,namespace,owner,planId,run", +]; +/** A complete mapping holds these keys plus any of {@link OPTIONAL_KEYS}. */ +const SELECTED_KEYS = + "aws,effectiveEnvName,namespace,owner,planId,profiles,run"; +const OPTIONAL_KEYS = new Set([ + "baseOnlyEnvironment", + "gcloud", + "runtimeMetadata", + "unmanagedEnvironment", +]); +function validKeys(value: Record): boolean { + const keys = Object.keys(value).sort(); + return ( + LEGACY_KEY_SETS.includes(keys.join()) || + keys.filter((key) => !OPTIONAL_KEYS.has(key)).join() === SELECTED_KEYS + ); +} +function validEnvironmentMarkers(value: Record): boolean { + const unmanaged = Object.hasOwn(value, "unmanagedEnvironment"); + const baseOnly = Object.hasOwn(value, "baseOnlyEnvironment"); + return ( + !(unmanaged && baseOnly) && + (!unmanaged || value.unmanagedEnvironment === true) && + (!baseOnly || value.baseOnlyEnvironment === true) && + (!(unmanaged || baseOnly) || typeof value.effectiveEnvName === "string") + ); +} function valid(value: unknown): value is NativeProjectRun { return ( isRecord(value) && - [ - "namespace,owner,planId,run", - "effectiveEnvName,namespace,owner,planId,run", - "aws,effectiveEnvName,namespace,owner,planId,run", - "aws,effectiveEnvName,namespace,owner,planId,profiles,run", - "aws,effectiveEnvName,namespace,owner,planId,profiles,run,runtimeMetadata", - "aws,effectiveEnvName,gcloud,namespace,owner,planId,profiles,run", - "aws,effectiveEnvName,gcloud,namespace,owner,planId,profiles,run,runtimeMetadata", - "aws,effectiveEnvName,namespace,owner,planId,profiles,run,unmanagedEnvironment", - "aws,effectiveEnvName,namespace,owner,planId,profiles,run,runtimeMetadata,unmanagedEnvironment", - "aws,effectiveEnvName,gcloud,namespace,owner,planId,profiles,run,unmanagedEnvironment", - "aws,effectiveEnvName,gcloud,namespace,owner,planId,profiles,run,runtimeMetadata,unmanagedEnvironment", - ].includes(Object.keys(value).sort().join()) && + validKeys(value) && (!Object.hasOwn(value, "gcloud") || validGcloud(value.gcloud)) && (!Object.hasOwn(value, "runtimeMetadata") || value.runtimeMetadata === true) && - (!Object.hasOwn(value, "unmanagedEnvironment") || - (value.unmanagedEnvironment === true && - typeof value.effectiveEnvName === "string")) && + validEnvironmentMarkers(value) && (!Object.hasOwn(value, "aws") || validAws(value.aws)) && (!Object.hasOwn(value, "profiles") || validProfiles(value.profiles)) && (!Object.hasOwn(value, "effectiveEnvName") || diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 212c513fb..4d644be9d 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -111,7 +111,9 @@ import { loadNativeRestartIntent, type NativeProjectRun, type NativeProjectRunScope, + type NativeRunEnvironmentMarkers, nativeRunEnvironmentAvailable, + nativeRunEnvironmentMarkers, normalizeNativeProfiles, prepareNativeProjectRunStorage, removeNativeProjectRun, @@ -1141,15 +1143,16 @@ async function selectNativeStartup(opts: { * Before any hook, refuse a retained run whose recorded env selection changed: its * env must resolve the same, and it keeps the managed env files it started with. Only * a run recorded as started without them may still find none, and a retained run never - * becomes unmanaged after the fact. A legacy restore without a recorded selection keeps - * its earlier behavior. Returns whether the new mapping records no managed env files. + * becomes unmanaged or base-only after the fact. A legacy restore without a recorded + * selection keeps its earlier behavior. Returns the environment markers the new mapping + * records: a fresh run's from its files, a retained run's unchanged. */ function requireRetainedEnvironment(opts: { /** The retained env a resumed (not restored) run must still resolve to. */ readonly retainedSelection: string | null | undefined; readonly restore: NativeProjectRun | undefined; readonly input: NativeProjectInput; -}): boolean { +}): NativeRunEnvironmentMarkers { const { restore, input } = opts; if ( (opts.retainedSelection !== undefined && @@ -1164,10 +1167,20 @@ function requireRetainedEnvironment(opts: { "Native retained environment selection changed; data was not replaced." ); } - return ( - input.environmentFiles.length === 0 && - (restore === undefined || restore.unmanagedEnvironment === true) - ); + if (restore === undefined) { + return nativeRunEnvironmentMarkers({ + effectiveEnvName: input.effectiveEnvName, + environmentFiles: input.environmentFiles, + }); + } + return { + ...(restore.unmanagedEnvironment === true + ? { unmanagedEnvironment: true as const } + : {}), + ...(restore.baseOnlyEnvironment === true + ? { baseOnlyEnvironment: true as const } + : {}), + }; } function selectedMapping(opts: { readonly ready: NativeProjectRun; @@ -1176,8 +1189,8 @@ function selectedMapping(opts: { readonly aws?: { readonly profile: string; readonly region?: string }; readonly gcloud?: NativeGcloudSelection; readonly runtimeMetadata: boolean; - /** Startup found no managed env file at all. */ - readonly unmanagedEnvironment: boolean; + /** Which managed env files startup found; see {@link nativeRunEnvironmentMarkers}. */ + readonly environmentMarkers: NativeRunEnvironmentMarkers; }): NativeProjectRun { return { ...opts.ready, @@ -1193,10 +1206,8 @@ function selectedMapping(opts: { // earlier candidates; a mapping that carries it refuses there. ...(opts.gcloud ? { gcloud: { mode: opts.gcloud.mode } } : {}), ...(opts.runtimeMetadata ? { runtimeMetadata: true as const } : {}), - // Only a named env needs the distinction; a base selection delivers no values either way. - ...(opts.unmanagedEnvironment && opts.effectiveEnvName !== null - ? { unmanagedEnvironment: true as const } - : {}), + // Only a named env needs the distinction; a base selection delivers base values. + ...(opts.effectiveEnvName === null ? {} : opts.environmentMarkers), }; } /** @@ -1482,7 +1493,7 @@ export async function startNativeProject(opts: { envByService: input.managedEnvironment, }) : undefined; - const unmanagedEnvironment = requireRetainedEnvironment({ + const environmentMarkers = requireRetainedEnvironment({ retainedSelection: retained && !opts.restore ? selection.envName : undefined, restore, @@ -1912,7 +1923,7 @@ export async function startNativeProject(opts: { aws: selection.aws, gcloud: selection.gcloud, runtimeMetadata: metadata !== undefined, - unmanagedEnvironment, + environmentMarkers, }); // Enrollment must exist before taking a shared lease: release // requires independent proof of this exact graph's cleanup. @@ -2045,7 +2056,7 @@ export async function startNativeProject(opts: { aws: selection.aws, gcloud: selection.gcloud, runtimeMetadata: metadata !== undefined, - unmanagedEnvironment, + environmentMarkers, }, finalization, deps, diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index 08dce2560..99109b49a 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -542,6 +542,81 @@ test("preflight keeps the retained env's per-env files without managed env files } }); +test("preflight keeps a base-only named env and refuses a changed overlay", async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-restart-base-only-")) + ); + try { + const projectDir = join(root, ".hack"); + await mkdir(projectDir); + const composeFile = join(projectDir, "docker-compose.yml"); + await writeFile( + composeFile, + JSON.stringify({ + services: { app: { image: `sha256:${"9".repeat(64)}` } }, + }) + ); + const envFile = async (name: string) => + await writeFile( + join(projectDir, `hack.env.${name}.yaml`), + JSON.stringify({ + version: 1, + environment: name, + secretsprovider: "project_key", + values: { global: {} }, + }) + ); + // The project has a managed base env file but no qa overlay. + await envFile("default"); + const selected: Record = {}; + const preflight = (markers: Record) => + preflightNativeRestart({ + runtime: { binary: "/unused", home: "/candidate" }, + scope: { ...scope, projectRoot: root, projectDir }, + composeFile, + run: { + ...run, + effectiveEnvName: "qa", + unmanagedEnvironment: undefined, + ...markers, + }, + adaptationFile: "/fixture/.hack/native/adaptation.json", + adaptationFileByEnv: { qa: "/fixture/.hack/native/adaptation.qa.json" }, + dependencies: { + activeImages: async () => activeImageFixture(), + adapt: async ({ path }) => { + selected.adaptation = path; + throw new ClosedCliError("stop after adaptation selection"); + }, + invoke: async ({ args }) => + args[1] === "probe" ? { admitted: true } : { network: "internet" }, + }, + }); + const changed = + "Native restart environment selection changed since startup; restart was refused. The current graph was not stopped."; + // Recorded as started with the base only: unchanged, so restart proceeds. + await expectClosedRefusal( + preflight({ baseOnlyEnvironment: true }), + "stop after adaptation selection" + ); + expect(selected).toEqual({ + adaptation: "/fixture/.hack/native/adaptation.qa.json", + }); + // A mapping without the marker started with the overlay, which is missing now. + await expectClosedRefusal(preflight({}), changed); + // An overlay that appears after a base-only start is a changed selection. + await envFile("qa"); + await expectClosedRefusal( + preflight({ baseOnlyEnvironment: true }), + changed + ); + // And one recorded with the overlay keeps working while it exists. + await expectClosedRefusal(preflight({}), "stop after adaptation selection"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("preflight refuses a stale host listener before restart cleanup", async () => { const { preflightNativeRestart } = await import( "../src/backends/native-project-restart-preflight.ts" diff --git a/tests/native-project-run.test.ts b/tests/native-project-run.test.ts index cbbf83a7e..ece486d7e 100644 --- a/tests/native-project-run.test.ts +++ b/tests/native-project-run.test.ts @@ -14,6 +14,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadNativeProjectRun as load, + nativeRunEnvironmentAvailable, + nativeRunEnvironmentMarkers, normalizeNativeProfiles, removeNativeProjectRun as remove, saveNativeProjectRun as save, @@ -304,6 +306,100 @@ test("the unmanaged environment marker roundtrips only beside a named env", asyn } }); +test("environment markers roundtrip once, beside a named env, with optional selectors", async () => { + const selected = { + ...run, + effectiveEnvName: "qa", + aws: null, + profiles: [], + }; + for (const extra of [ + { baseOnlyEnvironment: true }, + { baseOnlyEnvironment: true, gcloud: { mode: "file" } }, + { + baseOnlyEnvironment: true, + gcloud: { mode: "file" }, + runtimeMetadata: true, + }, + { unmanagedEnvironment: true, runtimeMetadata: true }, + ]) { + const opts = await fixture(); + const mapping = { ...selected, ...extra }; + await save({ ...opts, run: mapping as never }); + expect(await load(opts)).toEqual(mapping as never); + } + for (const invalid of [ + { ...selected, baseOnlyEnvironment: true, unmanagedEnvironment: true }, + { ...selected, baseOnlyEnvironment: false }, + { ...selected, effectiveEnvName: null, baseOnlyEnvironment: true }, + { ...run, effectiveEnvName: "qa", baseOnlyEnvironment: true }, + ]) { + const opts = await fixture(); + await expect(save({ ...opts, run: invalid as never })).rejects.toThrow(); + expect(await load(opts)).toBeNull(); + } +}); + +test("startup env markers and their later availability follow the managed env files", () => { + const base = "/project/.hack/hack.env.default.yaml"; + const overlay = "/project/.hack/hack.env.qa.yaml"; + const local = "/project/.hack/hack.env.qa.local.yaml"; + expect( + nativeRunEnvironmentMarkers({ + effectiveEnvName: null, + environmentFiles: [], + }) + ).toEqual({}); + expect( + nativeRunEnvironmentMarkers({ + effectiveEnvName: "qa", + environmentFiles: [], + }) + ).toEqual({ unmanagedEnvironment: true }); + expect( + nativeRunEnvironmentMarkers({ + effectiveEnvName: "qa", + environmentFiles: [base], + }) + ).toEqual({ baseOnlyEnvironment: true }); + expect( + nativeRunEnvironmentMarkers({ + effectiveEnvName: "qa", + environmentFiles: [base, local], + }) + ).toEqual({}); + const available = ( + markers: Record, + environmentFiles: string[] + ) => + nativeRunEnvironmentAvailable({ + run: { effectiveEnvName: "qa", ...markers }, + environmentFiles, + }); + // Recorded with the overlay: it must still exist. + expect(available({}, [base, overlay])).toBe(true); + expect(available({}, [base])).toBe(false); + expect(available({}, [])).toBe(false); + // Recorded base-only: the overlay must still be absent. + expect(available({ baseOnlyEnvironment: true }, [base])).toBe(true); + expect(available({ baseOnlyEnvironment: true }, [base, overlay])).toBe(false); + expect(available({ baseOnlyEnvironment: true }, [base, local])).toBe(false); + // Recorded without managed env files: there must still be none. + expect(available({ unmanagedEnvironment: true }, [])).toBe(true); + expect(available({ unmanagedEnvironment: true }, [overlay])).toBe(false); + expect(available({ unmanagedEnvironment: true }, [base])).toBe(false); + // A base selection needs nothing; a legacy mapping never qualifies. + expect( + nativeRunEnvironmentAvailable({ + run: { effectiveEnvName: null }, + environmentFiles: [], + }) + ).toBe(true); + expect( + nativeRunEnvironmentAvailable({ run: {}, environmentFiles: [overlay] }) + ).toBe(false); +}); + test("restart intent survives mapping retirement and refuses mismatched removal", async () => { const { loadNativeRestartIntent, diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index f1a63d422..a37c2f4c7 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -3899,14 +3899,15 @@ test("startup applies the per-env adaptation for the run's effective env, else t test("startup records the requested or default env and marks runs without managed env files", async () => { for (const [envName, defaultOverlay, managed] of [ - ["qa", undefined, false], - [undefined, "qa", false], - ["qa", undefined, true], + ["qa", undefined, "none"], + [undefined, "qa", "none"], + ["qa", undefined, "overlay"], + ["qa", undefined, "base"], ] as const) { const { opts } = await fixture(false); - if (managed) { - // Value-free managed env files: the run is recorded as managed. - for (const name of ["default", "qa"]) { + if (managed !== "none") { + // Value-free managed env files: the base, and the qa overlay unless base-only. + for (const name of managed === "base" ? ["default"] : ["default", "qa"]) { await writeFile( join(opts.scope.projectDir, `hack.env.${name}.yaml`), JSON.stringify({ @@ -3959,6 +3960,7 @@ test("startup records the requested or default env and marks runs without manage recorded.push({ effectiveEnvName: request.run.effectiveEnvName, unmanagedEnvironment: request.run.unmanagedEnvironment, + baseOnlyEnvironment: request.run.baseOnlyEnvironment, }); return await save(request); }; @@ -3981,7 +3983,8 @@ test("startup records the requested or default env and marks runs without manage for (const mapping of recorded) { expect(mapping).toEqual({ effectiveEnvName: "qa", - unmanagedEnvironment: managed ? undefined : true, + unmanagedEnvironment: managed === "none" ? true : undefined, + baseOnlyEnvironment: managed === "base" ? true : undefined, }); } } @@ -3989,10 +3992,18 @@ test("startup records the requested or default env and marks runs without manage test("a retained run that started with managed env files refuses before hooks once they are gone", async () => { const qaFile = "/fixture/.hack/hack.env.qa.yaml"; - for (const [unmanaged, environmentFiles, expected] of [ - [false, [], "refused"], - [true, [], "unmanaged"], - [false, [qaFile], "managed"], + const baseFile = "/fixture/.hack/hack.env.default.yaml"; + for (const [marker, environmentFiles, expected] of [ + [undefined, [], "refused"], + ["unmanagedEnvironment", [], "unmanaged"], + [undefined, [qaFile], "managed"], + // A base-only start survives while the overlay stays absent. + ["baseOnlyEnvironment", [baseFile], "baseOnly"], + // An overlay appearing after a base-only or unmanaged start is a change. + ["baseOnlyEnvironment", [baseFile, qaFile], "refused"], + ["unmanagedEnvironment", [qaFile], "refused"], + // A mapping without a marker that finds only the base lost its overlay. + [undefined, [baseFile], "refused"], ] as const) { const { opts, events } = await fixture(false); const restore: NativeProjectRun = { @@ -4003,7 +4014,7 @@ test("a retained run that started with managed env files refuses before hooks on effectiveEnvName: "qa", profiles: [], aws: null, - ...(unmanaged ? { unmanagedEnvironment: true as const } : {}), + ...(marker === undefined ? {} : { [marker]: true as const }), }; const prepare = opts.dependencies.prepare!; opts.dependencies.prepare = async (request) => ({ @@ -4034,10 +4045,13 @@ test("a retained run that started with managed env files refuses before hooks on expect(events).toContain("before"); expect(saved.length).toBeGreaterThan(0); for (const mapping of saved) { - // A managed run is never relabeled unmanaged; an unmanaged one keeps its marker. + // A managed run is never relabeled; a marked run keeps its marker. expect(mapping.unmanagedEnvironment).toBe( expected === "unmanaged" ? true : undefined ); + expect(mapping.baseOnlyEnvironment).toBe( + expected === "baseOnly" ? true : undefined + ); } } }); From 6a1192b90a259af889a20e76d7a41a51f266c5a2 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:59:08 -0500 Subject: [PATCH 132/138] fix(native): name the changed env file and how to apply it Restart preflight, retained up and fresh exec now say which managed env file appeared or was removed since the run started, by its project-relative name only, and how to apply it: run hack down, then hack up, for an appeared file; restore a removed overlay to keep the run, or start again without it. hack down stays the way to apply such a change. Its hooks use the selection the run recorded: no values for a run started without managed env files, and the base values for a run started base-only, even after an overlay or env file appeared. A run started with its overlay refuses its down hooks before cleanup when the overlay is gone, naming it and asking to restore it, as it did before without the name. Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 9 +- src/backends/native-exec-environment.ts | 24 +++- src/backends/native-project-down.ts | 45 +++++-- .../native-project-restart-preflight.ts | 14 ++ src/backends/native-project-run.ts | 120 ++++++++++++++++-- src/backends/native-project-start.ts | 18 +++ tests/native-exec-environment.test.ts | 28 ++++ tests/native-project-down.test.ts | 34 ++++- tests/native-project-restart.test.ts | 16 +-- tests/native-project-start.test.ts | 45 +++++-- 10 files changed, 300 insertions(+), 53 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 7071ead81..4f7adbce8 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -2318,9 +2318,12 @@ has no `hack.env.*` files, or has `hack.env.default.yaml` but no `hack.env. Otherwise the base file applies. A fresh start, a retained start and a restart of one run therefore always select the same files. The run records which managed env files its env had at startup (none, the base only, or the overlay). A later `restart`, -retained `up`, fresh `exec` or `down` hook refuses when that changed: an overlay that -existed is gone, or an overlay (or, for a run started without any, a managed env file) -appeared. +retained `up` or fresh `exec` refuses when that changed, and names the file: an +overlay that existed is gone, or an overlay (or, for a run started without any, a +managed env file) appeared. To apply an appeared file, run `hack down`, then `hack up`. +`down` hooks of such a run get the values it started with (none, or the base values), +so `down` is not blocked. A removed overlay can be restored to keep the run; `down` +hooks of a run that started with it refuse, naming the file, until it is restored. **Identity and recovery.** Settings resolution only reads files. It writes no state, so Ctrl-C needs no cleanup, and `down`, `ps`, `logs`, `exec` and `run` keep using the diff --git a/src/backends/native-exec-environment.ts b/src/backends/native-exec-environment.ts index c00b93a1f..7c54b101b 100644 --- a/src/backends/native-exec-environment.ts +++ b/src/backends/native-exec-environment.ts @@ -3,13 +3,22 @@ import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { adaptNativeGcloudEnvironment } from "./native-gcloud-environment.ts"; import { prepareNativeProjectInput } from "./native-project-input.ts"; import { + describeNativeRunEnvironmentChange, type NativeProjectRun, type NativeProjectRunScope, nativeRunEnvironmentAvailable, + nativeRunEnvironmentChange, + nativeRunEnvironmentRemedy, } from "./native-project-run.ts"; /** Marks a selection that no longer matches the run's recorded startup selection. */ -class SelectionChanged extends Error {} +class SelectionChanged extends Error { + readonly detail: string | undefined; + constructor(detail?: string) { + super("selection changed"); + this.detail = detail; + } +} /** * Both refusals keep `E_CONFIG_INVALID` for JSON callers. The messages differ: @@ -56,6 +65,17 @@ export async function prepareNativeExecEnvironment(opts: { ) { throw new SelectionChanged(); } + const change = nativeRunEnvironmentChange({ + run: opts.run, + environmentFiles: input.environmentFiles, + projectRoot: opts.scope.projectRoot, + projectDir: opts.scope.projectDir, + }); + if (change) { + throw new SelectionChanged( + `Native fresh exec selection changed since startup: ${describeNativeRunEnvironmentChange(change)}. ${nativeRunEnvironmentRemedy({ change, retry: "hack exec" })}; no command was requested. Values omitted.` + ); + } if ( !nativeRunEnvironmentAvailable({ run: opts.run, @@ -89,7 +109,7 @@ export async function prepareNativeExecEnvironment(opts: { code: "E_CONFIG_INVALID", message: error instanceof SelectionChanged - ? SELECTION_CHANGED_MESSAGE + ? (error.detail ?? SELECTION_CHANGED_MESSAGE) : ENVIRONMENT_UNAVAILABLE_MESSAGE, }); } diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index 95c3eca90..bd15a7440 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -16,11 +16,12 @@ import { recoverNativeInterruptedStartupCleanup } from "./native-project-interru import { withNativeRemovalLock } from "./native-project-removal-lock.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; import { + describeNativeRunEnvironmentChange, loadNativeProjectRun, loadNativeRestartIntent, type NativeProjectRun, type NativeProjectRunScope, - nativeRunEnvironmentAvailable, + nativeRunEnvironmentChange, removeNativeProjectRun, } from "./native-project-run.ts"; import { @@ -264,7 +265,13 @@ export async function nativeProjectDown(opts: { } as const; } -/** Resolve fresh values with the original selection; never infer legacy/default overlays. */ +/** + * Resolve fresh values with the original selection; never infer legacy/default overlays. + * A run recorded as started without managed env files delivered none, and one started + * base-only delivered the base layers, so their hooks get exactly that even when env + * files appeared since: `hack down` stays the way to apply them. A run started with its + * overlay refuses before cleanup when the overlay is gone, naming it. + */ export async function nativeDownEnvironment(opts: { readonly scope: NativeProjectRunScope; readonly run: NativeProjectRun; @@ -275,22 +282,33 @@ export async function nativeDownEnvironment(opts: { "Native down lifecycle hooks require persisted startup environment selection; legacy mapping must not guess an overlay." ); } + const recorded = opts.run.effectiveEnvName; + if (recorded !== null && opts.run.unmanagedEnvironment === true) { + return {}; + } + const baseOnly = recorded !== null && opts.run.baseOnlyEnvironment === true; + let named: ClosedCliError | undefined; try { const resolved = await resolveProjectEnvConfig({ projectRoot: opts.scope.projectRoot, projectDir: opts.scope.projectDir, - envName: opts.run.effectiveEnvName, + envName: baseOnly ? null : recorded, serviceNames: opts.serviceNames, }); - // Only a run recorded as started without managed env files may find none now; - // for any other named selection a missing overlay disappeared. - if ( - !nativeRunEnvironmentAvailable({ + const environmentFiles = resolved?.files ?? []; + if (!baseOnly) { + const change = nativeRunEnvironmentChange({ run: opts.run, - environmentFiles: resolved?.files ?? [], - }) - ) { - throw refused(); + environmentFiles, + projectRoot: opts.scope.projectRoot, + projectDir: opts.scope.projectDir, + }); + if (change) { + named = new ClosedCliError( + `Native down refused before cleanup: ${describeNativeRunEnvironmentChange(change)}, and the down hooks need the values this run started with. Restore it and run hack down again; nothing was stopped.` + ); + throw named; + } } if (!resolved) { return {}; @@ -300,7 +318,10 @@ export async function nativeDownEnvironment(opts: { scopeName: "global", target: "host", }); - } catch { + } catch (error) { + if (named !== undefined && error === named) { + throw named; + } throw new ClosedCliError( "Native down lifecycle environment is unavailable; values omitted. No cleanup was requested." ); diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index d09b91686..a8404c8c8 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -51,9 +51,12 @@ import { withNativeProjectReview, } from "./native-project-review.ts"; import { + describeNativeRunEnvironmentChange, type NativeProjectRun, type NativeProjectRunScope, nativeRunEnvironmentAvailable, + nativeRunEnvironmentChange, + nativeRunEnvironmentRemedy, normalizeNativeProfiles, } from "./native-project-run.ts"; import { @@ -395,6 +398,17 @@ export async function preflightNativeRestart(opts: { composeFile: opts.composeFile, envName: selected.envName, }); + const environmentChange = nativeRunEnvironmentChange({ + run: opts.run, + environmentFiles: input.environmentFiles, + projectRoot: opts.scope.projectRoot, + projectDir: opts.scope.projectDir, + }); + if (environmentChange) { + throw new ClosedCliError( + `Native restart refused: ${describeNativeRunEnvironmentChange(environmentChange)}. ${nativeRunEnvironmentRemedy({ change: environmentChange, retry: "hack restart" })}. The current graph was not stopped.` + ); + } if ( !nativeRunEnvironmentAvailable({ run: opts.run, diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index b37325091..fc1dade65 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -10,7 +10,7 @@ import { rmdir, unlink, } from "node:fs/promises"; -import { basename, join, relative } from "node:path"; +import { basename, isAbsolute, join, relative } from "node:path"; import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { @@ -68,15 +68,17 @@ export type NativeRunEnvironmentMarkers = Pick< NativeProjectRun, "unmanagedEnvironment" | "baseOnlyEnvironment" >; +function isOverlay(envName: string, path: string): boolean { + return [ + `hack.env.${envName}.yaml`, + `hack.env.${envName}.local.yaml`, + ].includes(basename(path)); +} function overlayPresent( envName: string, environmentFiles: readonly string[] ): boolean { - return environmentFiles.some((path) => - [`hack.env.${envName}.yaml`, `hack.env.${envName}.local.yaml`].includes( - basename(path) - ) - ); + return environmentFiles.some((path) => isOverlay(envName, path)); } /** * The marker a fresh startup records for its selection: none for a base selection or @@ -98,6 +100,37 @@ export function nativeRunEnvironmentMarkers(opts: { ? {} : { baseOnlyEnvironment: true }; } +/** A managed env file that appeared or was removed since a run started. */ +export type NativeRunEnvironmentChange = { + readonly change: "appeared" | "removed"; + /** Project-relative file name(s) only; never contents. */ + readonly file: string; +}; +type ChangedPath = + | { readonly change: "appeared"; readonly path: string } + | { readonly change: "removed"; readonly envName: string }; +/** The one comparison behind both exported checks; undefined means unchanged. */ +function changedEnvironmentFile(opts: { + readonly run: NativeRunEnvironmentMarkers & { readonly envName: string }; + readonly environmentFiles: readonly string[]; +}): ChangedPath | undefined { + const { envName } = opts.run; + const overlay = opts.environmentFiles.find((path) => + isOverlay(envName, path) + ); + if (opts.run.unmanagedEnvironment === true) { + const appeared = overlay ?? opts.environmentFiles[0]; + return appeared === undefined + ? undefined + : { change: "appeared", path: appeared }; + } + if (opts.run.baseOnlyEnvironment === true) { + return overlay === undefined + ? undefined + : { change: "appeared", path: overlay }; + } + return overlay === undefined ? { change: "removed", envName } : undefined; +} /** * Whether the managed env files a run's recorded selection needs are unchanged. A base * selection needs none. A named selection needs its overlay (`hack.env..yaml` or @@ -116,14 +149,77 @@ export function nativeRunEnvironmentAvailable(opts: { if (selected === undefined) { return false; } - if (selected === null) { - return true; + return ( + selected === null || + changedEnvironmentFile({ + run: { ...opts.run, envName: selected }, + environmentFiles: opts.environmentFiles, + }) === undefined + ); +} +/** + * The managed env file whose appearance or removal changed a recorded named selection, + * named relative to the project root (a file outside it by its base name), or + * undefined when nothing changed or the selection is a base or legacy one. + */ +export function nativeRunEnvironmentChange(opts: { + readonly run: Pick< + NativeProjectRun, + "effectiveEnvName" | "unmanagedEnvironment" | "baseOnlyEnvironment" + >; + readonly environmentFiles: readonly string[]; + readonly projectRoot: string; + readonly projectDir: string; +}): NativeRunEnvironmentChange | undefined { + const selected = opts.run.effectiveEnvName; + if (selected === undefined || selected === null) { + return undefined; } - if (opts.run.unmanagedEnvironment === true) { - return opts.environmentFiles.length === 0; + const changed = changedEnvironmentFile({ + run: { ...opts.run, envName: selected }, + environmentFiles: opts.environmentFiles, + }); + if (changed === undefined) { + return undefined; } - const present = overlayPresent(selected, opts.environmentFiles); - return opts.run.baseOnlyEnvironment === true ? !present : present; + const label = (path: string) => { + const name = relative(opts.projectRoot, path); + return name === "" || name.startsWith("..") || isAbsolute(name) + ? basename(path) + : name; + }; + if (changed.change === "appeared") { + return { change: "appeared", file: label(changed.path) }; + } + const overlay = label( + join(opts.projectDir, `hack.env.${changed.envName}.yaml`) + ); + const local = label( + join(opts.projectDir, `hack.env.${changed.envName}.local.yaml`) + ); + return { change: "removed", file: `${overlay} or ${local}` }; +} +/** + * How to apply a changed env file set: start again with the files as they are now. A + * removed overlay can also be restored to keep the run; a project with down hooks needs + * it restored for `hack down`, which runs them with the values the run started with. + */ +export function nativeRunEnvironmentRemedy(opts: { + readonly change: NativeRunEnvironmentChange; + /** The command the caller refused, e.g. `hack restart`. */ + readonly retry: string; +}): string { + return opts.change.change === "appeared" + ? "Run hack down, then hack up, to start with it" + : `Restore it and run ${opts.retry} again, or run hack down (with it restored if the project has down hooks), then hack up, to start without it`; +} +/** " appeared since this run started" or " was removed since ..." */ +export function describeNativeRunEnvironmentChange( + change: NativeRunEnvironmentChange +): string { + return change.change === "appeared" + ? `${change.file} appeared since this run started` + : `${change.file} was removed since this run started`; } export type NativeProjectRunScope = { readonly projectRoot: string; diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 4d644be9d..e4bafdc11 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -107,13 +107,16 @@ import { reviewedNativeRoutes, } from "./native-project-routing.ts"; import { + describeNativeRunEnvironmentChange, loadNativeProjectRun, loadNativeRestartIntent, type NativeProjectRun, type NativeProjectRunScope, type NativeRunEnvironmentMarkers, nativeRunEnvironmentAvailable, + nativeRunEnvironmentChange, nativeRunEnvironmentMarkers, + nativeRunEnvironmentRemedy, normalizeNativeProfiles, prepareNativeProjectRunStorage, removeNativeProjectRun, @@ -1152,8 +1155,22 @@ function requireRetainedEnvironment(opts: { readonly retainedSelection: string | null | undefined; readonly restore: NativeProjectRun | undefined; readonly input: NativeProjectInput; + readonly scope: NativeProjectRunScope; }): NativeRunEnvironmentMarkers { const { restore, input } = opts; + const change = + restore && + nativeRunEnvironmentChange({ + run: restore, + environmentFiles: input.environmentFiles, + projectRoot: opts.scope.projectRoot, + projectDir: opts.scope.projectDir, + }); + if (change) { + throw new ClosedCliError( + `Native up refused: ${describeNativeRunEnvironmentChange(change)}. ${nativeRunEnvironmentRemedy({ change, retry: "hack up" })}; retained data was not replaced.` + ); + } if ( (opts.retainedSelection !== undefined && input.effectiveEnvName !== opts.retainedSelection) || @@ -1498,6 +1515,7 @@ export async function startNativeProject(opts: { retained && !opts.restore ? selection.envName : undefined, restore, input, + scope: opts.scope, }); // The effective env (requested, retained or project default) picks the per-env // files, so a fresh start, a retained start and a restart select the same ones. diff --git a/tests/native-exec-environment.test.ts b/tests/native-exec-environment.test.ts index 24f7bdcb2..17859d765 100644 --- a/tests/native-exec-environment.test.ts +++ b/tests/native-exec-environment.test.ts @@ -121,6 +121,34 @@ test("fresh exec delivers no values only for a run recorded without managed env }) ).toEqual({}); }); +test("fresh exec names the env file that appeared or was removed", async () => { + const opts = await fixture(); + await rm(join(opts.scope.projectDir, "hack.env.qa.yaml")); + await expect(prepareNativeExecEnvironment(opts)).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: + "Native fresh exec selection changed since startup: .hack/hack.env.qa.yaml or .hack/hack.env.qa.local.yaml was removed since this run started. Restore it and run hack exec again, or run hack down (with it restored if the project has down hooks), then hack up, to start without it; no command was requested. Values omitted.", + }); + await writeFile( + join(opts.scope.projectDir, "hack.env.qa.yaml"), + JSON.stringify({ + version: 1, + environment: "qa", + secretsprovider: "project_key", + values: { global: { VALUE: "late" } }, + }) + ); + await expect( + prepareNativeExecEnvironment({ + ...opts, + run: { ...opts.run, baseOnlyEnvironment: true }, + }) + ).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: + "Native fresh exec selection changed since startup: .hack/hack.env.qa.yaml appeared since this run started. Run hack down, then hack up, to start with it; no command was requested. Values omitted.", + }); +}); test("fresh exec names a changed selection apart from an unresolvable environment", async () => { const opts = await fixture(); // The service this exec selects is no longer in the configuration. diff --git a/tests/native-project-down.test.ts b/tests/native-project-down.test.ts index fa6dcd4d2..ec3fb0ee1 100644 --- a/tests/native-project-down.test.ts +++ b/tests/native-project-down.test.ts @@ -20,6 +20,7 @@ import { } from "../src/backends/native-project-finalization.ts"; import { loadNativeProjectRun, + type NativeProjectRun, type NativeProjectRunScope, removeNativeProjectRun, saveNativeProjectRun, @@ -538,6 +539,8 @@ test("not-started native down does not run configured hooks", async () => { } }); +const DOWN_OVERLAY_REMOVED = + "Native down refused before cleanup: .hack/hack.env.qa.yaml or .hack/hack.env.qa.local.yaml was removed since this run started, and the down hooks need the values this run started with. Restore it and run hack down again; nothing was stopped."; test("down environment preserves explicit base and named overlay while legacy refuses", async () => { const opts = await fixture(false); const env = async (name: string, value: string) => @@ -585,7 +588,7 @@ test("down environment preserves explicit base and named overlay while legacy re run: { ...run, effectiveEnvName: "qa" }, serviceNames: ["web"], }), - "unavailable" + DOWN_OVERLAY_REMOVED ); await expectClosedRefusal( nativeDownEnvironment({ scope: opts.scope, run, serviceNames: ["web"] }), @@ -602,7 +605,7 @@ test("down environment is empty only for a run recorded without managed env file run: { ...run, effectiveEnvName: "qa" }, serviceNames: ["web"], }), - "unavailable" + DOWN_OVERLAY_REMOVED ); for (const selected of [ { effectiveEnvName: "qa", unmanagedEnvironment: true as const }, @@ -617,6 +620,33 @@ test("down environment is empty only for a run recorded without managed env file ).toEqual({}); } }); +test("down hooks keep the recorded selection when env files appeared since startup", async () => { + const opts = await fixture(false); + const env = async (name: string, value: string) => + await writeFile( + join(opts.scope.projectDir, `hack.env.${name}.yaml`), + JSON.stringify({ + version: 1, + environment: name, + secretsprovider: "project_key", + values: { global: { VALUE: value } }, + }) + ); + const down = (selected: Partial) => + nativeDownEnvironment({ + scope: opts.scope, + run: { ...run, effectiveEnvName: "qa", ...selected }, + serviceNames: ["web"], + }); + // Started without managed env files: no values, even after files appear. + await env("default", "base"); + await env("qa", "qa-value"); + expect(await down({ unmanagedEnvironment: true })).toEqual({}); + // Started base-only: the base values, never the overlay that appeared later. + expect(await down({ baseOnlyEnvironment: true })).toEqual({ VALUE: "base" }); + // Started with the overlay: its current values while it exists. + expect(await down({})).toEqual({ VALUE: "qa-value" }); +}); test("before failure prevents cleanup; after failure retains the stopped mapping", async () => { for (const phase of ["before", "after"] as const) { const opts = await fixture(); diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index 99109b49a..b814f3b26 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -461,6 +461,10 @@ test("preflight selects the per-env adaptation and dependency files by the retai }); }); +const RESTART_OVERLAY_REMOVED = + "Native restart refused: .hack/hack.env.qa.yaml or .hack/hack.env.qa.local.yaml was removed since this run started. Restore it and run hack restart again, or run hack down (with it restored if the project has down hooks), then hack up, to start without it. The current graph was not stopped."; +const RESTART_OVERLAY_APPEARED = + "Native restart refused: .hack/hack.env.qa.yaml appeared since this run started. Run hack down, then hack up, to start with it. The current graph was not stopped."; test("preflight keeps the retained env's per-env files without managed env files", async () => { const root = await realpath( await mkdtemp(join(tmpdir(), "native-restart-env-")) @@ -532,10 +536,7 @@ test("preflight keeps the retained env's per-env files without managed env files for (const key of Object.keys(selected)) { delete selected[key]; } - await expectClosedRefusal( - preflight("qa", true), - "Native restart environment selection changed since startup; restart was refused. The current graph was not stopped." - ); + await expectClosedRefusal(preflight("qa", true), RESTART_OVERLAY_REMOVED); expect(selected).toEqual({}); } finally { await rm(root, { recursive: true, force: true }); @@ -592,8 +593,7 @@ test("preflight keeps a base-only named env and refuses a changed overlay", asyn args[1] === "probe" ? { admitted: true } : { network: "internet" }, }, }); - const changed = - "Native restart environment selection changed since startup; restart was refused. The current graph was not stopped."; + // Recorded as started with the base only: unchanged, so restart proceeds. await expectClosedRefusal( preflight({ baseOnlyEnvironment: true }), @@ -603,12 +603,12 @@ test("preflight keeps a base-only named env and refuses a changed overlay", asyn adaptation: "/fixture/.hack/native/adaptation.qa.json", }); // A mapping without the marker started with the overlay, which is missing now. - await expectClosedRefusal(preflight({}), changed); + await expectClosedRefusal(preflight({}), RESTART_OVERLAY_REMOVED); // An overlay that appears after a base-only start is a changed selection. await envFile("qa"); await expectClosedRefusal( preflight({ baseOnlyEnvironment: true }), - changed + RESTART_OVERLAY_APPEARED ); // And one recorded with the overlay keeps working while it exists. await expectClosedRefusal(preflight({}), "stop after adaptation selection"); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index a37c2f4c7..7c703e9c8 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -3991,21 +3991,39 @@ test("startup records the requested or default env and marks runs without manage }); test("a retained run that started with managed env files refuses before hooks once they are gone", async () => { - const qaFile = "/fixture/.hack/hack.env.qa.yaml"; - const baseFile = "/fixture/.hack/hack.env.default.yaml"; - for (const [marker, environmentFiles, expected] of [ - [undefined, [], "refused"], + const removed = + "Native up refused: .hack/hack.env.qa.yaml or .hack/hack.env.qa.local.yaml was removed since this run started. Restore it and run hack up again, or run hack down (with it restored if the project has down hooks), then hack up, to start without it; retained data was not replaced."; + const appeared = (file: string) => + `Native up refused: .hack/${file} appeared since this run started. Run hack down, then hack up, to start with it; retained data was not replaced.`; + for (const [marker, files, expected] of [ + [undefined, [], removed], ["unmanagedEnvironment", [], "unmanaged"], - [undefined, [qaFile], "managed"], + [undefined, ["hack.env.qa.yaml"], "managed"], // A base-only start survives while the overlay stays absent. - ["baseOnlyEnvironment", [baseFile], "baseOnly"], - // An overlay appearing after a base-only or unmanaged start is a change. - ["baseOnlyEnvironment", [baseFile, qaFile], "refused"], - ["unmanagedEnvironment", [qaFile], "refused"], + ["baseOnlyEnvironment", ["hack.env.default.yaml"], "baseOnly"], + // An overlay or env file appearing after a base-only or unmanaged start is a change. + [ + "baseOnlyEnvironment", + ["hack.env.default.yaml", "hack.env.qa.yaml"], + appeared("hack.env.qa.yaml"), + ], + [ + "unmanagedEnvironment", + ["hack.env.qa.yaml"], + appeared("hack.env.qa.yaml"), + ], + [ + "unmanagedEnvironment", + ["hack.env.default.yaml"], + appeared("hack.env.default.yaml"), + ], // A mapping without a marker that finds only the base lost its overlay. - [undefined, [baseFile], "refused"], + [undefined, ["hack.env.default.yaml"], removed], ] as const) { const { opts, events } = await fixture(false); + const environmentFiles = files.map((name) => + join(opts.scope.projectDir, name) + ); const restore: NativeProjectRun = { run: "1".repeat(32), owner: "c".repeat(32), @@ -4032,10 +4050,9 @@ test("a retained run that started with managed env files refuses before hooks on saved.push(request.run); }; const started = startNativeProject({ ...opts, restore }); - if (expected === "refused") { - await expectClosedRefusal( - started, - "Native retained environment selection changed; data was not replaced." + if (expected.startsWith("Native up refused")) { + expect((await expectClosedRefusal(started, expected)).message).toBe( + expected ); expect(events).not.toContain("before"); expect(saved).toEqual([]); From 25849f9c6b1415a878e6110809411a69bdc479f0 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:07:20 -0500 Subject: [PATCH 133/138] fix(native): match image config keys case-insensitively like the engine The engine and BuildKit decode image configurations with Go's encoding/json, which matches object keys to fields case-insensitively and keeps the last of repeated keys. Exact-key lookups here could therefore miss what the engine acts on: a base config with "onbuild" or "ONBUILD" passed the stored-trigger refusal while BuildKit still ran its triggers. A new go_json reader keeps every member and returns the one member a Go field would receive, refusing when more than one key names it. - ONBUILD: case variants count at the top level and under config and the legacy container_config (those keys are also matched case-insensitively). A repeated or conflicting field, or one that is neither an array nor null, refuses. An empty array still passes. - Platform (os, architecture, variant) in config_platform, and architecture, os and rootfs.diff_ids in archive validation, are read the same way. Repetition refuses. - Manifests and indexes: every member this module reads (schemaVersion, mediaType, manifests, config, layers, descriptor digest, size, mediaType, platform, urls, and platform os, architecture, variant) must be spelled exactly once, so index platform selection cannot diverge from the engine's. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/provider/go_json.rs | 171 ++++++++++++++++++ .../runtime-core/src/provider/image_load.rs | 74 +++++++- packages/runtime-core/src/provider/mod.rs | 1 + .../src/provider/registry_image.rs | 118 +++++++++++- .../src/provider/registry_image_test.rs | 92 ++++++++++ 5 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 packages/runtime-core/src/provider/go_json.rs diff --git a/packages/runtime-core/src/provider/go_json.rs b/packages/runtime-core/src/provider/go_json.rs new file mode 100644 index 000000000..950aeb4c3 --- /dev/null +++ b/packages/runtime-core/src/provider/go_json.rs @@ -0,0 +1,171 @@ +//! JSON objects read the way the engine's Go `encoding/json` reads them. +//! +//! Image configurations come from registries and archives, and the engine and BuildKit +//! decode them into Go structs. Go matches an object key to a struct field by exact +//! name or, failing that, case-insensitively (`bytes.EqualFold`), and when several keys +//! match one field the last one wins. A plain lookup of the canonical key can therefore +//! see a different value than the engine acts on, for example `{"onbuild": [...]}` or +//! `{"architecture": "arm64", "Architecture": "amd64"}`. +//! +//! [`GoJson`] keeps every member, duplicates included, and [`GoJson::field`] returns the +//! one member a Go field would receive. More than one candidate member is ambiguous and +//! refuses, so a check here never passes a document the engine would read differently. +use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Value}; + +/// A JSON document with every object member kept, in order. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum GoJson { + Null, + Bool(bool), + Number(serde_json::Number), + String(String), + Array(Vec), + Object(Vec<(String, GoJson)>), +} + +/// More than one member of an object names the same Go struct field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Ambiguous; + +/// Whether `key` names the Go field `name`, as `bytes.EqualFold` decides for the ASCII +/// field names Hack inspects. Besides ASCII case, Unicode simple case folding maps only +/// U+017F (long s) to `s` and U+212A (Kelvin sign) to `k` among ASCII letters. +pub(super) fn names_field(key: &str, name: &str) -> bool { + let mut key = key.chars(); + for expected in name.chars() { + let Some(actual) = key.next() else { + return false; + }; + let folded = match actual { + '\u{17f}' => 's', + '\u{212a}' => 'k', + other if other.is_ascii() => other.to_ascii_lowercase(), + _ => return false, + }; + if folded != expected.to_ascii_lowercase() { + return false; + } + } + key.next().is_none() +} + +impl GoJson { + pub(super) fn parse(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes) + } + + /// The member a Go struct field named `name` receives: `None` when no key names it + /// (or this is not an object), [`Ambiguous`] when more than one does. + pub(super) fn field(&self, name: &str) -> Result, Ambiguous> { + let Self::Object(members) = self else { + return Ok(None); + }; + let mut found = None; + for (key, value) in members { + if names_field(key, name) { + if found.is_some() { + return Err(Ambiguous); + } + found = Some(value); + } + } + Ok(found) + } + + /// The same document as a [`Value`]. Duplicate keys collapse (last wins), so use + /// this only for a member [`GoJson::field`] already selected unambiguously. + pub(super) fn to_value(&self) -> Value { + match self { + Self::Null => Value::Null, + Self::Bool(value) => Value::Bool(*value), + Self::Number(value) => Value::Number(value.clone()), + Self::String(value) => Value::String(value.clone()), + Self::Array(items) => Value::Array(items.iter().map(Self::to_value).collect()), + Self::Object(members) => Value::Object( + members + .iter() + .map(|(key, value)| (key.clone(), value.to_value())) + .collect::>(), + ), + } + } +} + +impl<'de> Deserialize<'de> for GoJson { + fn deserialize>(deserializer: D) -> Result { + struct Document; + impl<'de> Visitor<'de> for Document { + type Value = GoJson; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a JSON value") + } + fn visit_unit(self) -> Result { + Ok(GoJson::Null) + } + fn visit_bool(self, value: bool) -> Result { + Ok(GoJson::Bool(value)) + } + fn visit_i64(self, value: i64) -> Result { + Ok(GoJson::Number(value.into())) + } + fn visit_u64(self, value: u64) -> Result { + Ok(GoJson::Number(value.into())) + } + fn visit_f64(self, value: f64) -> Result { + serde_json::Number::from_f64(value) + .map(GoJson::Number) + .ok_or_else(|| E::custom("non-finite number")) + } + fn visit_str(self, value: &str) -> Result { + Ok(GoJson::String(value.to_owned())) + } + fn visit_string(self, value: String) -> Result { + Ok(GoJson::String(value)) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = seq.next_element()? { + items.push(item); + } + Ok(GoJson::Array(items)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut members = Vec::new(); + while let Some((key, value)) = map.next_entry::()? { + members.push((key, value)); + } + Ok(GoJson::Object(members)) + } + } + deserializer.deserialize_any(Document) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn field_names_fold_like_go_and_duplicates_are_ambiguous() { + for key in ["OnBuild", "onbuild", "ONBUILD", "oNbUiLd"] { + assert!(names_field(key, "OnBuild"), "{key}"); + } + for key in ["OnBuilds", "OnBuil", "On_Build", "ÖnBuild", ""] { + assert!(!names_field(key, "OnBuild"), "{key}"); + } + // Unicode simple folding reaches these two ASCII letters. + assert!(names_field("o\u{17f}", "os")); + assert!(names_field("\u{212a}ind", "kind")); + let document = GoJson::parse(br#"{"a": 1, "os": "linux", "OS": "windows"}"#).unwrap(); + assert_eq!(document.field("os"), Err(Ambiguous)); + let exact = GoJson::parse(br#"{"os": "linux", "os": "linux"}"#).unwrap(); + assert_eq!(exact.field("os"), Err(Ambiguous)); + assert_eq!( + document.field("a").unwrap().map(GoJson::to_value), + Some(serde_json::json!(1)) + ); + assert_eq!(document.field("missing"), Ok(None)); + assert_eq!(GoJson::parse(b"[1]").unwrap().field("a"), Ok(None)); + } +} diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index 909dfa5e9..1e9c31158 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -6,7 +6,10 @@ //! the complete stream has hashed to the same value; a file changed after validation //! cannot complete an import. Memory use is bounded by small metadata members, not by //! archive size. -use super::{engine::Engine, image_platform::ImagePlatform, image_policy::ImagePolicy, state}; +use super::{ + engine::Engine, go_json::GoJson, image_platform::ImagePlatform, image_policy::ImagePolicy, + state, +}; use crate::{Candidate, CandidateError}; use flate2::read::MultiGzDecoder; use reqwest::Method; @@ -481,14 +484,29 @@ fn validated( "Image configuration does not match the expected content ID.", )); } - let config: Value = - serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; - if config["architecture"] != platform.architecture() || config["os"] != "linux" { + // Read as the engine reads it: keys match case-insensitively and a field named more + // than once refuses (see `go_json`), so these checks see what the load will use. + let config = GoJson::parse(config_bytes).map_err(|_| error("Invalid image configuration."))?; + let field = |document: &GoJson, name: &str| -> Result { + document + .field(name) + .map_err(|_| error("Image configuration names one field more than once.")) + .map(|value| value.map_or(Value::Null, GoJson::to_value)) + }; + if field(&config, "architecture")? != platform.architecture() + || field(&config, "os")? != "linux" + { return Err(error( "Image architecture is incompatible with this provider.", )); } - let diffs = config["rootfs"]["diff_ids"] + let rootfs = config + .field("rootfs") + .map_err(|_| error("Image configuration names one field more than once."))? + .cloned() + .unwrap_or(GoJson::Null); + let diffs = field(&rootfs, "diff_ids")?; + let diffs = diffs .as_array() .filter(|v| v.len() == manifest.layers.len()) .ok_or_else(|| error("Image layer identities are missing."))?; @@ -841,15 +859,21 @@ pub(super) mod tests { "rootfs":{"type":"layers","diff_ids":[format!("sha256:{diff}")]}}), ) .unwrap(); - let image = format!("sha256:{}", hash(&config)); + archive_with_config(&layer, &config, extra) + } + + /// A docker-archive tarball with one already compressed layer and these exact + /// configuration bytes. Returns the archive and its config content ID. + fn archive_with_config(layer: &[u8], config: &[u8], extra: bool) -> (Vec, String) { + let image = format!("sha256:{}", hash(config)); let manifest = serde_json::to_vec( &serde_json::json!([{"Config":"config.json","Layers":["layer"],"RepoTags":[]}]), ) .unwrap(); let mut archive = tar::Builder::new(Vec::new()); let mut members = vec![ - ("config.json", config.as_slice()), - ("layer", layer.as_slice()), + ("config.json", config), + ("layer", layer), ("manifest.json", manifest.as_slice()), ]; if extra { @@ -1189,6 +1213,40 @@ pub(super) mod tests { )); } + #[test] + fn archive_configuration_fields_are_read_like_the_engine_and_refuse_repetition() { + let payload = [b'x'; 2048]; + let layer = compress(Layer::Gzip, &payload); + let diff = format!("sha256:{}", hash(&payload)); + let check = |config: String| { + let (bytes, image) = archive_with_config(&layer, config.as_bytes(), false); + validate(&bytes, &image, 4096) + }; + // Case variants the engine accepts are read as it reads them. + assert!( + check(format!( + r#"{{"Architecture":"arm64","OS":"linux","RootFS":{{"type":"layers","DIFF_IDS":["{diff}"]}}}}"# + )) + .is_ok() + ); + for config in [ + format!( + r#"{{"architecture":"arm64","Architecture":"amd64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","os":"windows","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}},"RootFS":{{"diff_ids":[]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"diff_ids":["{diff}"],"Diff_IDs":[]}}}}"# + ), + ] { + assert!(check(config.clone()).is_err(), "{config}"); + } + } + #[test] fn archive_rejects_extra_members_wrong_architecture_digest_and_expansion() { let (bytes, image) = fixture("arm64", false, false); diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index 740cfbe7f..c6333e5b8 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -7,6 +7,7 @@ pub mod build_policy; pub mod build_scan; mod config_audit; mod dependency_socket; +mod go_json; pub use bridge::BridgeIntent; pub use dependency_socket::quiescent_recovery as quiescent_dependency_socket_recovery; pub use dependency_socket::recovery as dependency_socket_recovery; diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index dd9552796..5fbabca86 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -25,6 +25,7 @@ //! published. Archive and expansion sizes stay within the policy's budgets. //! - **Platform.** One unambiguous Linux ARM64 image by default; an explicit platform //! policy may select Linux AMD64 instead (see `image_platform`). +use super::go_json::GoJson; use super::image_load; use super::image_platform::{ImagePlatform, PlatformPolicy}; use super::image_policy::ImagePolicy; @@ -423,10 +424,67 @@ struct Manifest { #[serde(default)] layers: Vec, } +/// Refuse an object whose member for any of `fields` is not spelled exactly, or is +/// named more than once. The engine's Go decoder also accepts case variants and keeps +/// the last of repeated keys, while the exact-name parse below would miss or reject +/// them; refusing keeps both reading the same member. +fn canonical_members(value: &GoJson, fields: &[&str]) -> Result<(), CandidateError> { + let GoJson::Object(members) = value else { + return Ok(()); + }; + for field in fields { + let mut matching = members + .iter() + .filter(|(key, _)| super::go_json::names_field(key, field)); + let first = matching.next(); + if matching.next().is_some() || first.is_some_and(|(key, _)| key != field) { + return Err(error( + "Registry manifest member names are repeated or not in their exact case.", + )); + } + } + Ok(()) +} + +fn canonical_descriptor(value: &GoJson) -> Result<(), CandidateError> { + canonical_members(value, &["digest", "size", "mediaType", "platform", "urls"])?; + if let Ok(Some(platform)) = value.field("platform") { + canonical_members(platform, &["os", "architecture", "variant"])?; + } + Ok(()) +} + +/// The manifest or index members this module reads, checked for exact spelling. +fn canonical_manifest(bytes: &[u8]) -> Result<(), CandidateError> { + let document = GoJson::parse(bytes).map_err(|_| error("Invalid registry manifest."))?; + canonical_members( + &document, + &[ + "schemaVersion", + "mediaType", + "manifests", + "config", + "layers", + ], + )?; + for list in ["manifests", "layers"] { + if let Ok(Some(GoJson::Array(descriptors))) = document.field(list) { + for descriptor in descriptors { + canonical_descriptor(descriptor)?; + } + } + } + if let Ok(Some(config)) = document.field("config") { + canonical_descriptor(config)?; + } + Ok(()) +} + fn parse_manifest(bytes: &[u8], expected: &str) -> Result { if bytes.len() > MAX_METADATA || digest(bytes) != expected { return Err(error("Registry manifest digest or size mismatch.")); } + canonical_manifest(bytes)?; let m: Manifest = serde_json::from_slice(bytes).map_err(|_| error("Invalid registry manifest."))?; if m.schema_version != 2 { @@ -1266,8 +1324,7 @@ fn config_platform( policy: PlatformPolicy, indexed: Option, ) -> Result { - let config: serde_json::Value = - serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; + let config = platform_fields(config_bytes)?; let allowed: &[ImagePlatform] = match &indexed { Some(platform) => std::slice::from_ref(platform), None => policy.candidates(), @@ -1370,15 +1427,56 @@ pub fn resolve_build_base( }) } -/// Whether an image configuration carries stored `ONBUILD` triggers. +fn ambiguous(_: super::go_json::Ambiguous) -> CandidateError { + error( + "Image configuration names one field more than once (keys differing only in case count as the same field).", + ) +} + +/// The `os`, `architecture` and `variant` members of an image configuration as the +/// engine reads them (see [`super::go_json`]), as a document for +/// [`ImagePlatform::matches_config`]. A member named more than once refuses. +pub(super) fn platform_fields(config_bytes: &[u8]) -> Result { + let config = GoJson::parse(config_bytes).map_err(|_| error("Invalid image configuration."))?; + if !matches!(config, GoJson::Object(_)) { + return Err(error("Invalid image configuration.")); + } + let mut fields = serde_json::Map::new(); + for name in ["os", "architecture", "variant"] { + if let Some(value) = config.field(name).map_err(ambiguous)? { + fields.insert(name.to_owned(), value.to_value()); + } + } + Ok(serde_json::Value::Object(fields)) +} + +/// Whether an image configuration carries stored `ONBUILD` triggers, read as the +/// engine and BuildKit read it: keys match case-insensitively (`onbuild`, `ONBUILD`), at +/// the top level and under `config` and the legacy `container_config`. A field named +/// more than once, or holding anything but an array or `null`, refuses. fn declares_onbuild(config_bytes: &[u8]) -> Result { - let config: serde_json::Value = - serde_json::from_slice(config_bytes).map_err(|_| error("Invalid image configuration."))?; - Ok(match &config["config"]["OnBuild"] { - serde_json::Value::Null => false, - serde_json::Value::Array(triggers) => !triggers.is_empty(), - _ => return Err(error("Invalid image configuration.")), - }) + let document = + GoJson::parse(config_bytes).map_err(|_| error("Invalid image configuration."))?; + if !matches!(document, GoJson::Object(_)) { + return Err(error("Invalid image configuration.")); + } + let mut scopes = vec![&document]; + for name in ["config", "container_config"] { + match document.field(name).map_err(ambiguous)? { + None | Some(GoJson::Null) => {} + Some(object @ GoJson::Object(_)) => scopes.push(object), + Some(_) => return Err(error("Invalid image configuration.")), + } + } + let mut declared = false; + for scope in scopes { + match scope.field("OnBuild").map_err(ambiguous)? { + None | Some(GoJson::Null) => {} + Some(GoJson::Array(triggers)) => declared |= !triggers.is_empty(), + Some(_) => return Err(error("Invalid image configuration.")), + } + } + Ok(declared) } fn layer_extension(media_type: &str) -> Result<&'static str, CandidateError> { diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs index b02390dc6..f4d1145b7 100644 --- a/packages/runtime-core/src/provider/registry_image_test.rs +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -1608,6 +1608,98 @@ fn stored_onbuild_triggers_are_detected_and_malformed_configs_refuse() { assert!(declares_onbuild(b"not json").is_err()); } +#[test] +fn onbuild_triggers_are_found_under_any_key_case_the_engine_accepts() { + // The engine's Go decoder matches field names case-insensitively. + for config in [ + r#"{"config":{"onbuild":["ADD . /src"]}}"#, + r#"{"config":{"ONBUILD":["ADD . /src"]}}"#, + r#"{"config":{"oNbUiLd":["ADD . /src"]}}"#, + r#"{"CONFIG":{"OnBuild":["ADD . /src"]}}"#, + r#"{"Config":{"onBuild":["ADD . /src"]}}"#, + r#"{"container_config":{"OnBuild":["ADD . /src"]}}"#, + r#"{"Container_Config":{"onbuild":["ADD . /src"]}}"#, + r#"{"OnBuild":["ADD . /src"],"config":{}}"#, + ] { + assert!(declares_onbuild(config.as_bytes()).unwrap(), "{config}"); + } + for config in [ + r#"{"config":{"onbuild":[]}}"#, + r#"{"config":{"ONBUILD":null},"container_config":{"OnBuild":[]}}"#, + r#"{"config":{"Env":["OnBuild=x"],"onbuilds":["x"]}}"#, + ] { + assert!(!declares_onbuild(config.as_bytes()).unwrap(), "{config}"); + } +} + +#[test] +fn onbuild_fields_named_more_than_once_or_malformed_refuse() { + // The engine keeps the last of several matching keys; a check here could see + // another, so any repetition, exact or by case, fails closed. + for config in [ + r#"{"config":{"OnBuild":[],"onbuild":["ADD . /src"]}}"#, + r#"{"config":{"OnBuild":["ADD . /src"],"OnBuild":[]}}"#, + r#"{"config":{"OnBuild":[]},"Config":{"OnBuild":["ADD . /src"]}}"#, + r#"{"config":{},"config":{"OnBuild":["ADD . /src"]}}"#, + r#"{"container_config":{},"CONTAINER_CONFIG":{}}"#, + r#"{"config":{"onbuild":"ADD . /src"}}"#, + r#"{"config":"ADD . /src"}"#, + r#"["not an object"]"#, + ] { + let error = declares_onbuild(config.as_bytes()).unwrap_err(); + assert_eq!(error.code, "registry_image", "{config}"); + } +} + +#[test] +fn manifest_members_must_be_spelled_exactly_once() { + let parse = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); + let descriptor = format!( + r#"{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:{}","size":2,"platform":{{"os":"linux","architecture":"arm64"}}}}"#, + "a".repeat(64) + ); + let index = format!(r#"{{"schemaVersion":2,"manifests":[{descriptor}]}}"#); + assert!(parse(&index).is_ok()); + for variant in [ + index.replace("\"manifests\"", "\"Manifests\""), + index.replace("\"schemaVersion\"", "\"SchemaVersion\""), + index.replace("\"digest\"", "\"Digest\""), + index.replace("\"architecture\"", "\"ARCHITECTURE\""), + index.replace(r#""os":"linux""#, r#""os":"linux","OS":"windows""#), + format!(r#"{{"schemaVersion":2,"manifests":[{descriptor}],"MANIFESTS":[]}}"#), + format!( + r#"{{"schemaVersion":2,"manifests":[{descriptor}],"config":{descriptor},"Config":{descriptor}}}"# + ), + ] { + let error = parse(&variant).unwrap_err(); + assert!(error.message.contains("exact case"), "{variant}: {error:?}"); + } +} + +#[test] +fn image_platform_fields_are_read_like_the_engine_and_refuse_repetition() { + let native = |config: &str| config_platform(config.as_bytes(), PlatformPolicy::Native, None); + // A case variant the engine accepts is read, not missed. + assert_eq!( + native(r#"{"OS":"linux","Architecture":"arm64"}"#).unwrap(), + ImagePlatform::LinuxArm64 + ); + assert!(native(r#"{"OS":"linux","Architecture":"amd64"}"#).is_err()); + // A repeated field could mean another platform to the engine: refused. + for config in [ + r#"{"os":"linux","architecture":"arm64","Architecture":"amd64"}"#, + r#"{"os":"linux","architecture":"arm64","architecture":"amd64"}"#, + r#"{"os":"linux","OS":"windows","architecture":"arm64"}"#, + r#"{"os":"linux","architecture":"arm64","variant":"v8","VARIANT":"v7"}"#, + ] { + let error = native(config).unwrap_err(); + assert!( + error.message.contains("more than once"), + "{config}: {error:?}" + ); + } +} + #[test] fn build_bases_accept_only_docker_hub_references() { let named = |value: &str| match normalize(value).unwrap() { From f88495e0f6037dd2a054d62095aabe165a70edf1 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:14:01 -0500 Subject: [PATCH 134/138] fix(native): refuse non-ASCII keys in image metadata the engine reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's encoding/json folds some non-ASCII runes onto ASCII field names (for example U+0131 dotless i, U+017F long s and U+212A Kelvin sign), with rules that have changed between Go versions. So "OnBuıld", "archıtecture" or "diff_ıds" could name a field for the engine while a check here missed it. Every object Hack reads from image metadata now refuses, fail closed and value-free, when any of its member names contains a non-ASCII character: - the image config top level, and its config and container_config scopes; - platform fields; - the archive config and its rootfs; - manifest, index, descriptor and platform objects. ASCII case-fold matching and the duplicate and ambiguity refusals are kept. The special U+017F and U+212A folds are removed, since those keys now refuse. Co-Authored-By: Claude Opus 5.5 --- packages/runtime-core/src/provider/go_json.rs | 65 ++++++++------- .../runtime-core/src/provider/image_load.rs | 38 ++++++++- .../src/provider/registry_image.rs | 16 +++- .../src/provider/registry_image_test.rs | 80 +++++++++++++++++++ 4 files changed, 164 insertions(+), 35 deletions(-) diff --git a/packages/runtime-core/src/provider/go_json.rs b/packages/runtime-core/src/provider/go_json.rs index 950aeb4c3..4df91efac 100644 --- a/packages/runtime-core/src/provider/go_json.rs +++ b/packages/runtime-core/src/provider/go_json.rs @@ -9,7 +9,10 @@ //! //! [`GoJson`] keeps every member, duplicates included, and [`GoJson::field`] returns the //! one member a Go field would receive. More than one candidate member is ambiguous and -//! refuses, so a check here never passes a document the engine would read differently. +//! refuses. Go's case folding also matches some non-ASCII runes to ASCII letters (for +//! example U+0131, U+017F and U+212A, with rules that differ between Go versions), so an +//! object with any non-ASCII key refuses outright instead of being folded here. A check +//! here therefore never passes a document the engine could read differently. use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; use serde_json::{Map, Value}; @@ -24,30 +27,30 @@ pub(super) enum GoJson { Object(Vec<(String, GoJson)>), } -/// More than one member of an object names the same Go struct field. +/// Why an object member cannot be read the way the engine reads it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct Ambiguous; +pub(super) enum Unreadable { + /// More than one member names the same Go struct field. + Ambiguous, + /// A member name contains a non-ASCII character, which Go may fold onto a field. + NonAsciiKey, +} -/// Whether `key` names the Go field `name`, as `bytes.EqualFold` decides for the ASCII -/// field names Hack inspects. Besides ASCII case, Unicode simple case folding maps only -/// U+017F (long s) to `s` and U+212A (Kelvin sign) to `k` among ASCII letters. +/// Whether the ASCII `key` names the ASCII Go field `name`: equal under ASCII case +/// folding. Objects with a non-ASCII key never reach this comparison +/// ([`GoJson::field`] and [`ascii_keys`] refuse them). pub(super) fn names_field(key: &str, name: &str) -> bool { - let mut key = key.chars(); - for expected in name.chars() { - let Some(actual) = key.next() else { - return false; - }; - let folded = match actual { - '\u{17f}' => 's', - '\u{212a}' => 'k', - other if other.is_ascii() => other.to_ascii_lowercase(), - _ => return false, - }; - if folded != expected.to_ascii_lowercase() { - return false; + key.is_ascii() && key.eq_ignore_ascii_case(name) +} + +/// Refuse an object (any other value passes) with a non-ASCII member name. +pub(super) fn ascii_keys(value: &GoJson) -> Result<(), Unreadable> { + match value { + GoJson::Object(members) if members.iter().any(|(key, _)| !key.is_ascii()) => { + Err(Unreadable::NonAsciiKey) } + _ => Ok(()), } - key.next().is_none() } impl GoJson { @@ -56,16 +59,18 @@ impl GoJson { } /// The member a Go struct field named `name` receives: `None` when no key names it - /// (or this is not an object), [`Ambiguous`] when more than one does. - pub(super) fn field(&self, name: &str) -> Result, Ambiguous> { + /// (or this is not an object). Refuses when more than one key names it, or when any + /// key of this object is not ASCII. + pub(super) fn field(&self, name: &str) -> Result, Unreadable> { let Self::Object(members) = self else { return Ok(None); }; + ascii_keys(self)?; let mut found = None; for (key, value) in members { if names_field(key, name) { if found.is_some() { - return Err(Ambiguous); + return Err(Unreadable::Ambiguous); } found = Some(value); } @@ -154,13 +159,17 @@ mod tests { for key in ["OnBuilds", "OnBuil", "On_Build", "ÖnBuild", ""] { assert!(!names_field(key, "OnBuild"), "{key}"); } - // Unicode simple folding reaches these two ASCII letters. - assert!(names_field("o\u{17f}", "os")); - assert!(names_field("\u{212a}ind", "kind")); + // Go folds some non-ASCII runes onto ASCII letters; such keys are never matched + // here, and an object holding one refuses as a whole. + for key in ["o\u{17f}", "\u{212a}ind", "OnBu\u{131}ld"] { + assert!(!names_field(key, "OnBuild") && !names_field(key, "os")); + let document = GoJson::parse(format!(r#"{{"{key}": 1, "a": 2}}"#).as_bytes()).unwrap(); + assert_eq!(document.field("a"), Err(Unreadable::NonAsciiKey), "{key}"); + } let document = GoJson::parse(br#"{"a": 1, "os": "linux", "OS": "windows"}"#).unwrap(); - assert_eq!(document.field("os"), Err(Ambiguous)); + assert_eq!(document.field("os"), Err(Unreadable::Ambiguous)); let exact = GoJson::parse(br#"{"os": "linux", "os": "linux"}"#).unwrap(); - assert_eq!(exact.field("os"), Err(Ambiguous)); + assert_eq!(exact.field("os"), Err(Unreadable::Ambiguous)); assert_eq!( document.field("a").unwrap().map(GoJson::to_value), Some(serde_json::json!(1)) diff --git a/packages/runtime-core/src/provider/image_load.rs b/packages/runtime-core/src/provider/image_load.rs index 1e9c31158..1ab4f0d97 100644 --- a/packages/runtime-core/src/provider/image_load.rs +++ b/packages/runtime-core/src/provider/image_load.rs @@ -7,7 +7,10 @@ //! cannot complete an import. Memory use is bounded by small metadata members, not by //! archive size. use super::{ - engine::Engine, go_json::GoJson, image_platform::ImagePlatform, image_policy::ImagePolicy, + engine::Engine, + go_json::{GoJson, Unreadable}, + image_platform::ImagePlatform, + image_policy::ImagePolicy, state, }; use crate::{Candidate, CandidateError}; @@ -487,10 +490,14 @@ fn validated( // Read as the engine reads it: keys match case-insensitively and a field named more // than once refuses (see `go_json`), so these checks see what the load will use. let config = GoJson::parse(config_bytes).map_err(|_| error("Invalid image configuration."))?; + let unreadable = |reason: Unreadable| match reason { + Unreadable::Ambiguous => error("Image configuration names one field more than once."), + Unreadable::NonAsciiKey => error("Image configuration member names must be ASCII."), + }; let field = |document: &GoJson, name: &str| -> Result { document .field(name) - .map_err(|_| error("Image configuration names one field more than once.")) + .map_err(unreadable) .map(|value| value.map_or(Value::Null, GoJson::to_value)) }; if field(&config, "architecture")? != platform.architecture() @@ -502,7 +509,7 @@ fn validated( } let rootfs = config .field("rootfs") - .map_err(|_| error("Image configuration names one field more than once."))? + .map_err(unreadable)? .cloned() .unwrap_or(GoJson::Null); let diffs = field(&rootfs, "diff_ids")?; @@ -1245,6 +1252,31 @@ pub(super) mod tests { ] { assert!(check(config.clone()).is_err(), "{config}"); } + // Non-ASCII member names (which Go may fold onto a field) refuse in the config + // and in rootfs; a real-looking configuration still validates. + for config in [ + format!( + r#"{{"arch\u0131tecture":"amd64","architecture":"arm64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","o\u017f":"windows","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"type":"layers","diff_ids":["{diff}"],"diff_\u0131ds":[]}}}}"# + ), + format!( + r#"{{"architecture":"arm64","os":"linux","rootfs":{{"\u212aind":"x","diff_ids":["{diff}"]}}}}"# + ), + ] { + let error = check(config.clone()).unwrap_err(); + assert!(error.message.contains("ASCII"), "{config}: {error:?}"); + } + assert!( + check(format!( + r#"{{"created":"2024-01-01T00:00:00Z","architecture":"arm64","os":"linux","config":{{"Env":["PATH=/usr/bin"],"Cmd":["/bin/sh"]}},"rootfs":{{"type":"layers","diff_ids":["{diff}"]}},"history":[{{"created_by":"ADD file:x in /"}}]}}"# + )) + .is_ok() + ); } #[test] diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 5fbabca86..87bc223e4 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -432,6 +432,9 @@ fn canonical_members(value: &GoJson, fields: &[&str]) -> Result<(), CandidateErr let GoJson::Object(members) = value else { return Ok(()); }; + if super::go_json::ascii_keys(value).is_err() { + return Err(error("Registry manifest member names must be ASCII.")); + } for field in fields { let mut matching = members .iter() @@ -1427,10 +1430,15 @@ pub fn resolve_build_base( }) } -fn ambiguous(_: super::go_json::Ambiguous) -> CandidateError { - error( - "Image configuration names one field more than once (keys differing only in case count as the same field).", - ) +fn ambiguous(reason: super::go_json::Unreadable) -> CandidateError { + match reason { + super::go_json::Unreadable::Ambiguous => error( + "Image configuration names one field more than once (keys differing only in case count as the same field).", + ), + super::go_json::Unreadable::NonAsciiKey => { + error("Image configuration member names must be ASCII.") + } + } } /// The `os`, `architecture` and `variant` members of an image configuration as the diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs index f4d1145b7..80fefcdc5 100644 --- a/packages/runtime-core/src/provider/registry_image_test.rs +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -1651,6 +1651,86 @@ fn onbuild_fields_named_more_than_once_or_malformed_refuse() { } } +/// Non-ASCII spellings Go's folding can match to an ASCII field: dotless i (U+0131), +/// long s (U+017F) and the Kelvin sign (U+212A). +const NON_ASCII_KEYS: [(&str, &str); 4] = [ + ("OnBuild", "OnBu\u{131}ld"), + ("architecture", "arch\u{131}tecture"), + ("os", "o\u{17f}"), + ("kind", "\u{212a}ind"), +]; + +#[test] +fn image_configs_with_non_ascii_member_names_refuse() { + let onbuild = NON_ASCII_KEYS[0].1; + for config in [ + format!(r#"{{"config":{{"{onbuild}":["ADD . /src"]}}}}"#), + format!(r#"{{"container_config":{{"{onbuild}":["ADD . /src"]}}}}"#), + format!(r#"{{"{onbuild}":["ADD . /src"],"config":{{}}}}"#), + r#"{"conf\u0131g":{"OnBuild":["ADD . /src"]}}"#.to_owned(), + r#"{"config":{"Env":[],"\u017fhell":["/bin/sh"]}}"#.to_owned(), + ] { + let error = declares_onbuild(config.as_bytes()).unwrap_err(); + assert!(error.message.contains("ASCII"), "{config}: {error:?}"); + } + let native = |config: &str| config_platform(config.as_bytes(), PlatformPolicy::Native, None); + for (_, key) in NON_ASCII_KEYS { + let config = format!(r#"{{"os":"linux","architecture":"arm64","{key}":"amd64"}}"#); + assert!( + native(&config).unwrap_err().message.contains("ASCII"), + "{config}" + ); + } + // Real-looking OCI and Docker configurations still pass. + let oci = r#"{"created":"2024-01-01T00:00:00Z","architecture":"arm64","variant":"v8","os":"linux","config":{"Env":["PATH=/usr/local/bin:/usr/bin"],"Cmd":["/bin/sh"],"WorkingDir":"/","Labels":{"org.opencontainers.image.title":"base"},"OnBuild":null},"rootfs":{"type":"layers","diff_ids":["sha256:0000000000000000000000000000000000000000000000000000000000000000"]},"history":[{"created_by":"ADD file:x in /"}]}"#; + assert!(!declares_onbuild(oci.as_bytes()).unwrap()); + assert_eq!(native(oci).unwrap(), ImagePlatform::LinuxArm64); + let docker = r##"{"architecture":"arm64","os":"linux","config":{"Hostname":"","User":"","ExposedPorts":{"80/tcp":{}},"Env":["A=b"],"Cmd":["nginx"],"Image":"sha256:x","Volumes":null,"Entrypoint":null,"OnBuild":[],"Labels":null},"container_config":{"Cmd":["/bin/sh","-c","#(nop) CMD [\"nginx\"]"],"OnBuild":null},"docker_version":"20.10.7","rootfs":{"type":"layers","diff_ids":[]}}"##; + assert!(!declares_onbuild(docker.as_bytes()).unwrap()); + assert_eq!(native(docker).unwrap(), ImagePlatform::LinuxArm64); +} + +#[test] +fn manifests_with_non_ascii_member_names_refuse() { + let parse = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); + let digest_a = format!("sha256:{}", "a".repeat(64)); + // A real-looking OCI index, and a Docker v2 manifest, pass. + let index = format!( + r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"{digest_a}","size":1234,"platform":{{"architecture":"arm64","os":"linux","variant":"v8"}},"annotations":{{"org.opencontainers.image.ref.name":"1"}}}}]}}"# + ); + assert!(parse(&index).is_ok()); + let manifest = format!( + r#"{{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{{"mediaType":"application/vnd.docker.container.image.v1+json","size":1469,"digest":"{digest_a}"}},"layers":[{{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":3623807,"digest":"{digest_a}"}}]}}"# + ); + assert!(parse(&manifest).is_ok()); + for (from, to) in [ + // top-level index and manifest objects + ("\"manifests\"", "\"man\u{131}fests\""), + ("\"schemaVersion\"", "\"\u{17f}chemaVersion\""), + // descriptor + ("\"digest\"", "\"d\u{131}gest\""), + // platform + ("\"architecture\"", "\"arch\u{131}tecture\""), + ("\"os\"", "\"o\u{17f}\""), + ] { + let variant = index.replace(from, to); + assert_ne!(variant, index); + let error = parse(&variant).unwrap_err(); + assert!(error.message.contains("ASCII"), "{variant}: {error:?}"); + } + for (from, to) in [ + ("\"layers\"", "\"\u{212a}layers\""), + ("\"size\":1469", "\"\u{17f}ize\":1469"), + ] { + let variant = manifest.replace(from, to); + assert_ne!(variant, manifest); + assert!( + parse(&variant).unwrap_err().message.contains("ASCII"), + "{variant}" + ); + } +} + #[test] fn manifest_members_must_be_spelled_exactly_once() { let parse = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); From 0d13545e9a0fd2b765f9ed6f0f7dc4bb294ca9a4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:30:36 -0500 Subject: [PATCH 135/138] fix(native): bound a build base's declared size before the build resolve_build_base fetched only the config and handed BuildKit a pinned docker-image reference, so acquisition's archive budget never covered base layers. The selected manifest's config and layer sizes are now summed (with overflow checked) and must fit the per-image compressed archive budget (ImagePolicy archive_limit, the budget ensure-image applies). The manifest must also have 1 to MAX_LAYERS layers. Either check fails before any build, with a coded refusal naming the pinned base reference and the declared total. Descriptor sizes stay required unsigned integers, so a missing, negative or fractional size refuses. Bounded unpacked acquisition of build bases is tracked in HACK-1945. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/registry_image.rs | 45 +++++++++- .../src/provider/registry_image_test.rs | 84 +++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 87bc223e4..618bdd493 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -1412,6 +1412,10 @@ pub fn resolve_build_base( } let (manifest, _, indexed) = select(&mut fetch, &initial, &source_digest, PlatformPolicy::Native)?; + let pinned_reference = format!("{}/{}@{source_digest}", registry.domain, named.path); + // BuildKit pulls the base itself, outside acquisition's archive budget, so the sizes + // the manifest declares are bounded here before any build runs. + bounded_build_base(&manifest, &pinned_reference, policy.archive_limit)?; let config = manifest .config .ok_or_else(|| error("Image config descriptor is missing."))?; @@ -1424,12 +1428,51 @@ pub fn resolve_build_base( )); } Ok(BuildBase { - pinned_reference: format!("{}/{}@{source_digest}", registry.domain, named.path), + pinned_reference, source_digest, image_id: config.digest, }) } +/// Refuse a build base whose manifest declares more than `limit` compressed bytes of +/// config and layers (the per-image archive budget acquisition applies), or no layers +/// or more than the layer limit. Every descriptor's `size` is a required unsigned +/// integer (the manifest parse refuses a missing, negative or fractional one), so the +/// declared total is exact; it does not bound the unpacked size. +fn bounded_build_base( + manifest: &Manifest, + reference: &str, + limit: u64, +) -> Result<(), CandidateError> { + if manifest.layers.is_empty() || manifest.layers.len() > MAX_LAYERS { + return Err(CandidateError::new( + "native_build_base_image", + format!( + "Build base image {reference} declares {} layers; native builds accept 1 to {MAX_LAYERS}. No build was run.", + manifest.layers.len() + ), + )); + } + let config = manifest + .config + .as_ref() + .ok_or_else(|| error("Image config descriptor is missing."))?; + let declared = manifest + .layers + .iter() + .try_fold(config.size, |total, layer| total.checked_add(layer.size)); + match declared { + Some(total) if total <= limit => Ok(()), + _ => Err(CandidateError::new( + "native_build_base_image", + format!( + "Build base image {reference} declares {} bytes of config and layers, more than the {limit}-byte image budget. No build was run.", + declared.map_or_else(|| "at least 2^64".to_owned(), |total| total.to_string()) + ), + )), + } +} + fn ambiguous(reason: super::go_json::Unreadable) -> CandidateError { match reason { super::go_json::Unreadable::Ambiguous => error( diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs index 80fefcdc5..c87df6d33 100644 --- a/packages/runtime-core/src/provider/registry_image_test.rs +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -1731,6 +1731,90 @@ fn manifests_with_non_ascii_member_names_refuse() { } } +/// A single-image manifest whose config and layers declare these sizes. +fn sized_manifest(config: &str, layers: &[&str]) -> String { + let digest_a = format!("sha256:{}", "a".repeat(64)); + let layers: Vec = layers + .iter() + .map(|size| { + format!( + r#"{{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"{digest_a}","size":{size}}}"# + ) + }) + .collect(); + format!( + r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"{digest_a}","size":{config}}},"layers":[{}]}}"#, + layers.join(",") + ) +} + +#[test] +fn build_bases_are_bounded_by_the_image_budget_before_the_build() { + let reference = format!("docker.io/library/alpine@sha256:{}", "b".repeat(64)); + let manifest = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())); + let limit = ImagePolicy::default().archive_limit; + // A normal base passes, also exactly at the budget. + let normal = manifest(&sized_manifest("1469", &["3623807", "1024"])).unwrap(); + assert!(bounded_build_base(&normal, &reference, limit).is_ok()); + let exact = limit - 100; + let at_budget = manifest(&sized_manifest("100", &[&exact.to_string()])).unwrap(); + assert!(bounded_build_base(&at_budget, &reference, limit).is_ok()); + // Over the budget, including by overflow: refused naming the base and the total. + let over = manifest(&sized_manifest("101", &[&exact.to_string()])).unwrap(); + let error = bounded_build_base(&over, &reference, limit).unwrap_err(); + assert_eq!(error.code, "native_build_base_image"); + assert!(error.message.contains(&reference), "{error:?}"); + assert!( + error.message.contains(&(limit + 1).to_string()), + "{error:?}" + ); + assert!(error.message.contains(&limit.to_string()), "{error:?}"); + let huge = u64::MAX.to_string(); + let overflow = manifest(&sized_manifest("1", &[&huge])).unwrap(); + let error = bounded_build_base(&overflow, &reference, limit).unwrap_err(); + assert!(error.message.contains("at least 2^64"), "{error:?}"); + // Too many layers, or none. + let many: Vec<&str> = vec!["1"; MAX_LAYERS + 1]; + let error = bounded_build_base( + &manifest(&sized_manifest("1", &many)).unwrap(), + &reference, + limit, + ) + .unwrap_err(); + assert!( + error + .message + .contains(&format!("{} layers", MAX_LAYERS + 1)), + "{error:?}" + ); + assert!( + bounded_build_base( + &manifest(&sized_manifest("1", &[])).unwrap(), + &reference, + limit + ) + .is_err() + ); +} + +#[test] +fn descriptor_sizes_must_be_present_unsigned_integers() { + let manifest = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); + assert!(manifest(&sized_manifest("1469", &["1024"])).is_ok()); + for size in ["-1", "1.5", "\"1024\"", "null", "1e3"] { + assert!( + manifest(&sized_manifest("1469", &[size])).is_err(), + "layer {size}" + ); + assert!( + manifest(&sized_manifest(size, &["1024"])).is_err(), + "config {size}" + ); + } + let missing = sized_manifest("1469", &["1024"]).replace(r#","size":1024"#, ""); + assert!(manifest(&missing).is_err()); +} + #[test] fn manifest_members_must_be_spelled_exactly_once() { let parse = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop); From 6c0dd7d8e2199bf4ac3caf3c34203821863bd785 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:39:36 -0500 Subject: [PATCH 136/138] fix(native): match Dockerfile stage names case-insensitively Stage names are stored lowercase, and BuildKit lowercases a FROM base name before its stage lookup. The FROM lookup compared the expanded name unchanged, so `FROM alpine AS Base` followed by `FROM Base` was refused. It now lowercases the name the way stage_by_name already does for COPY --from, RUN --mount from= and the build target. A name that matches no earlier stage still falls through as an image reference, where uppercase is invalid, and a stage named Scratch is still refused. Co-Authored-By: Claude Opus 5.5 --- .../runtime-core/src/provider/build_scan.rs | 9 +++-- .../src/provider/build_scan/tests.rs | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/provider/build_scan.rs b/packages/runtime-core/src/provider/build_scan.rs index 2ed941851..2f9a08323 100644 --- a/packages/runtime-core/src/provider/build_scan.rs +++ b/packages/runtime-core/src/provider/build_scan.rs @@ -1200,17 +1200,22 @@ fn base_image(name: &str, line: usize) -> Result { fn resolve(parsed: &Parsed, request: &ScanRequest<'_>) -> Result { let env = global_env(parsed, request.args); // Each stage's base as the frontend registers it: an earlier stage of that name, - // `scratch`, or an image. `None` marks a name the scanner cannot expand. + // `scratch`, or an image. `None` marks a name the scanner cannot expand. Stage + // names are stored lowercase and the frontend lowercases a base name before its + // stage lookup (as `stage_by_name` does for `--from` and the target), so `FROM Base` + // names a stage declared `AS base`. A name that matches no stage stays an image + // reference, where uppercase is invalid. let mut bases: Vec> = Vec::with_capacity(parsed.stages.len()); for (index, stage) in parsed.stages.iter().enumerate() { let base = expand(&stage.base, &env, MAX_ARGUMENT_BYTES) .filter(|name| !name.is_empty()) .map(|name| { + let lowered = name.to_ascii_lowercase(); if name == "scratch" { Source::Scratch } else if let Some(earlier) = parsed.stages[..index] .iter() - .position(|s| s.name.as_deref() == Some(name.as_str())) + .position(|s| s.name.as_deref() == Some(lowered.as_str())) { Source::Stage(earlier) } else { diff --git a/packages/runtime-core/src/provider/build_scan/tests.rs b/packages/runtime-core/src/provider/build_scan/tests.rs index b7ebfc71a..b4922e6c5 100644 --- a/packages/runtime-core/src/provider/build_scan/tests.rs +++ b/packages/runtime-core/src/provider/build_scan/tests.rs @@ -390,6 +390,42 @@ fn doubling_argument_defaults_stay_bounded() { assert_eq!(images(&with("FROM alpine:3.22\n")), ["alpine:3.22"]); } +#[test] +fn stage_names_match_case_insensitively_everywhere_the_frontend_looks_them_up() { + let dockerfile = "\ +FROM alpine:3.22 AS Base +RUN true + +FROM BASE AS Tools +COPY --from=Base /etc/os-release /x + +FROM busybox:1.37 AS Final +COPY --from=TOOLS /x /y +RUN --mount=type=bind,from=bAsE,target=/b true +FROM fInAl +"; + let scan = scan_with(dockerfile, None, &[]).unwrap(); + // `FROM BASE`, `FROM fInAl`, `--from=Base`, `--from=TOOLS` and `from=bAsE` are stages. + assert_eq!(names(&scan), ["alpine:3.22", "busybox:1.37"]); + for target in ["final", "FINAL", "Tools"] { + let scan = scan_with(dockerfile, Some(target), &[]).unwrap(); + assert!(names(&scan).contains(&"alpine:3.22".to_owned()), "{target}"); + } + let tools = scan_with(dockerfile, Some("tools"), &[]).unwrap(); + assert_eq!(names(&tools), ["alpine:3.22"]); + // A name that matches no stage is an image reference, where uppercase is invalid. + assert_eq!(code("FROM Alpine:3.22\n"), "native_build_base_image"); + assert_eq!( + code("FROM alpine:3.22 AS base\nFROM Busybox:1.37\nCOPY --from=Base / /\n"), + "native_build_base_image" + ); + // A stage may only build on an earlier stage, as with the frontend. + assert_eq!( + code("FROM Later AS first\nFROM alpine:3.22 AS later\nFROM first\n"), + "native_build_base_image" + ); +} + #[test] fn a_stage_may_not_be_named_scratch() { // The frontend links `FROM scratch` to an earlier stage of that name, so the From 1866c70f35fc433321292650f366e39f5bf57a5d Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:47:33 -0500 Subject: [PATCH 137/138] fix(registry): judge a credential helper by its reply when it skips its input Docker runs credential helpers through Go's os/exec, which ignores a broken pipe when the helper exits without reading the server address it was given. Hack refused that case, so a helper that answered and exited quickly could fail a registry pull depending on timing, and the registry identity-token test failed intermittently on loaded CI runners. A broken pipe on that write is no longer an error; the helper's exit status and reply decide the result. Other write errors still refuse. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/registry_credentials.rs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/registry_credentials.rs b/packages/runtime-core/src/provider/registry_credentials.rs index 1734053b3..b4442fdbe 100644 --- a/packages/runtime-core/src/provider/registry_credentials.rs +++ b/packages/runtime-core/src/provider/registry_credentials.rs @@ -330,7 +330,7 @@ fn run_helper( "The Docker credential helper failed; its output was discarded.", )); } - if written.is_some_and(|result| result.is_err()) { + if written.as_ref().is_some_and(helper_write_failed) { return Err(error( "Cannot send the server address to the Docker credential helper.", )); @@ -415,6 +415,14 @@ pub(super) fn lookup( entry.map_or(Ok(None), file_credential) } +/// Like Go's `os/exec`, which Docker uses to run helpers, a helper that exits without +/// reading its input is not a failure: its exit status and reply decide the outcome. +fn helper_write_failed(result: &std::io::Result<()>) -> bool { + result + .as_ref() + .is_err_and(|error| error.kind() != std::io::ErrorKind::BrokenPipe) +} + #[cfg(test)] pub(super) mod tests { use super::*; @@ -639,4 +647,19 @@ pub(super) mod tests { .contains("PRIVATE_CANARY") ); } + + #[test] + fn a_helper_that_closes_its_input_early_is_judged_by_its_reply() { + use std::io::{Error, ErrorKind}; + assert!(!helper_write_failed(&Ok(()))); + assert!(!helper_write_failed(&Err(Error::from( + ErrorKind::BrokenPipe + )))); + assert!(helper_write_failed(&Err(Error::from( + ErrorKind::PermissionDenied + )))); + assert!(helper_write_failed(&Err(Error::from( + ErrorKind::Interrupted + )))); + } } From 32758d943b0cc3057df7376d5a5fc5d240eec4a5 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:57:34 -0500 Subject: [PATCH 138/138] fix(native): validate build-base layer descriptors like direct acquisition BuildKit fetches a build base's layers itself, so the preflight only summed their sizes. A malicious base could carry foreign-layer urls or non-distributable layer types that direct acquisition refuses, and BuildKit would then fetch them from the guest's own namespace, outside the build network's isolation and the source policy. Before any build, the base's config descriptor and every layer descriptor now go through the checks direct acquisition applies: - the config: an image-config media type, then Fetch::checked with the metadata bound; - every layer: a distributable gzip, zstd or uncompressed media type (layer_extension), then Fetch::checked with the archive ceiling, which covers a valid digest, a size within limits and no urls. An empty urls array is treated as absent, matching acquisition. Refusals are coded native_build_base_image, name the base reference and the reason, and never echo URLs. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/registry_image.rs | 24 +++++ .../src/provider/registry_image_test.rs | 91 ++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/registry_image.rs b/packages/runtime-core/src/provider/registry_image.rs index 618bdd493..bd46bb8c9 100644 --- a/packages/runtime-core/src/provider/registry_image.rs +++ b/packages/runtime-core/src/provider/registry_image.rs @@ -1457,6 +1457,30 @@ fn bounded_build_base( .config .as_ref() .ok_or_else(|| error("Image config descriptor is missing."))?; + // The same descriptor checks direct acquisition applies: BuildKit fetches these + // itself, and a foreign-layer `urls` entry or a non-distributable layer would be + // fetched from the guest's own namespace, outside the build network's isolation. + let refused = |what: &str, reason: CandidateError| { + CandidateError::new( + "native_build_base_image", + format!( + "Build base image {reference} has an unsupported {what}: {} No build was run.", + reason.message + ), + ) + }; + if !config.media_type.is_empty() && !CONFIG_TYPES.contains(&config.media_type.as_str()) { + return Err(refused( + "config descriptor", + error("The manifest does not describe a container image."), + )); + } + Fetch::checked(config, MAX_METADATA as u64).map_err(|e| refused("config descriptor", e))?; + for layer in &manifest.layers { + layer_extension(&layer.media_type).map_err(|e| refused("layer", e))?; + Fetch::checked(layer, super::image_policy::ARCHIVE_CEILING) + .map_err(|e| refused("layer", e))?; + } let declared = manifest .layers .iter() diff --git a/packages/runtime-core/src/provider/registry_image_test.rs b/packages/runtime-core/src/provider/registry_image_test.rs index c87df6d33..7756b4f86 100644 --- a/packages/runtime-core/src/provider/registry_image_test.rs +++ b/packages/runtime-core/src/provider/registry_image_test.rs @@ -1771,8 +1771,10 @@ fn build_bases_are_bounded_by_the_image_budget_before_the_build() { assert!(error.message.contains(&limit.to_string()), "{error:?}"); let huge = u64::MAX.to_string(); let overflow = manifest(&sized_manifest("1", &[&huge])).unwrap(); + // A layer beyond the acquisition ceiling refuses as a descriptor before any sum. let error = bounded_build_base(&overflow, &reference, limit).unwrap_err(); - assert!(error.message.contains("at least 2^64"), "{error:?}"); + assert_eq!(error.code, "native_build_base_image"); + assert!(error.message.contains("size limits"), "{error:?}"); // Too many layers, or none. let many: Vec<&str> = vec!["1"; MAX_LAYERS + 1]; let error = bounded_build_base( @@ -1797,6 +1799,93 @@ fn build_bases_are_bounded_by_the_image_budget_before_the_build() { ); } +#[test] +fn build_base_descriptors_are_validated_like_direct_acquisition() { + let reference = format!("docker.io/library/alpine@sha256:{}", "b".repeat(64)); + let digest_a = format!("sha256:{}", "a".repeat(64)); + let limit = ImagePolicy::default().archive_limit; + let check = |config_type: &str, layer: &str| { + let text = format!( + r#"{{"schemaVersion":2,"config":{{"mediaType":"{config_type}","digest":"{digest_a}","size":1469}},"layers":[{layer}]}}"# + ); + let manifest = parse_manifest(text.as_bytes(), &digest(text.as_bytes())).unwrap(); + bounded_build_base(&manifest, &reference, limit) + }; + let layer = |media_type: &str, extra: &str| { + format!(r#"{{"mediaType":"{media_type}","digest":"{digest_a}","size":1024{extra}}}"#) + }; + let docker_config = "application/vnd.docker.container.image.v1+json"; + let oci_config = "application/vnd.oci.image.config.v1+json"; + // Normal Docker v2 and OCI layers pass. + for (config_type, media_type) in [ + ( + docker_config, + "application/vnd.docker.image.rootfs.diff.tar.gzip", + ), + (oci_config, "application/vnd.oci.image.layer.v1.tar+gzip"), + (oci_config, "application/vnd.oci.image.layer.v1.tar+zstd"), + (oci_config, "application/vnd.oci.image.layer.v1.tar"), + ] { + assert!( + check(config_type, &layer(media_type, "")).is_ok(), + "{media_type}" + ); + } + // An empty `urls` is treated as absent, as direct acquisition does. + assert!( + check( + oci_config, + &layer( + "application/vnd.oci.image.layer.v1.tar+gzip", + r#","urls":[]"# + ) + ) + .is_ok() + ); + for (config_type, bad_layer) in [ + // Foreign-layer URLs. + ( + oci_config, + layer( + "application/vnd.oci.image.layer.v1.tar+gzip", + r#","urls":["http://100.96.0.1:8080/layer"]"#, + ), + ), + // Foreign and non-distributable layer types. + ( + docker_config, + layer( + "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip", + "", + ), + ), + ( + oci_config, + layer( + "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip", + "", + ), + ), + // Unknown layer type, and an invalid layer digest. + (oci_config, layer("application/vnd.example.layer", "")), + ( + oci_config, + r#"{"mediaType":"application/vnd.oci.image.layer.v1.tar","digest":"md5:x","size":1}"# + .to_owned(), + ), + // A config descriptor that is not an image configuration. + ( + "application/vnd.example.config+json", + layer("application/vnd.oci.image.layer.v1.tar", ""), + ), + ] { + let error = check(config_type, &bad_layer).unwrap_err(); + assert_eq!(error.code, "native_build_base_image", "{bad_layer}"); + assert!(error.message.contains(&reference), "{error:?}"); + assert!(!error.message.contains("100.96.0.1"), "{error:?}"); + } +} + #[test] fn descriptor_sizes_must_be_present_unsigned_integers() { let manifest = |text: &str| parse_manifest(text.as_bytes(), &digest(text.as_bytes())).map(drop);