From e6528bdaa0a589c221ca3126a12618253e5fa3ae Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 01:32:49 +0000 Subject: [PATCH 1/8] feat(build): produce complete AMD64 and ARM64 Railpack images --- Cargo.lock | 1 + README.md | 15 +- crates/ployz-build/Cargo.toml | 1 + crates/ployz-build/src/builder.rs | 69 +++++--- crates/ployz-build/src/cancellation.rs | 42 +++++ crates/ployz-build/src/index.rs | 197 +++++++++++++++++++++ crates/ployz-build/src/lib.rs | 233 ++++++++++++++++--------- crates/ployz-build/src/railpack.rs | 72 ++++++-- crates/ployz/src/compose/build.rs | 83 +++++---- crates/ployz/src/handlers/build.rs | 2 +- crates/ployz/tests/build.rs | 26 +-- crates/ployz/tests/build/capture.rs | 2 +- crates/ployz/tests/build/railpack.rs | 189 ++++++++++++++++++++ crates/ployz/tests/build_layer3.rs | 122 ++++++++++++- crates/ployz/tests/compose_cli.rs | 4 +- 15 files changed, 876 insertions(+), 182 deletions(-) create mode 100644 crates/ployz-build/src/cancellation.rs create mode 100644 crates/ployz-build/src/index.rs diff --git a/Cargo.lock b/Cargo.lock index 83977f8ed7..6caa9101d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2163,6 +2163,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "signal-hook", "thiserror 2.0.20", ] diff --git a/README.md b/README.md index 6f52b48a0d..ef5d0d3554 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,19 @@ Set `build.x-recipe` to `dockerfile`, `railpack`, or `auto` (the default) to con selection. A failed recipe never falls back to another. Railpack uses matching pinned 0.39.0 preparation/frontend tooling with BuildKit -0.26.2, provisioned through Docker, and builds one native Linux AMD64 or ARM64 -image. Service variables default build variables; Compose `build.args` and then +0.26.2, provisioned through Docker. Builds default to the native Linux AMD64 or +ARM64 platform. Set `build.platforms: [linux/amd64, linux/arm64]` to build both; +the execution host must provide native or emulated support for each platform. +Separate solves are assembled locally with pinned regctl 0.11.6 into one immutable +image in Docker’s containerd store, with every platform’s content verified. +Multi-platform builds currently require local output; Dockerfiles remain limited +to one platform. Deploy platform inference is separate work. + +The [prototype findings](https://github.com/getployz/ployz2/blob/c3ca5519a4607256ffb28052d77a1c7d89f1bbe1/prototypes/railpack-transfer/FINDINGS.md) +preserve the evidence for this assembly approach. They used shipped beta binaries +and emulated ARM64; they do not qualify this implementation or native ARM64. + +Service variables default build variables; Compose `build.args` and then `--build-arg` override them without changing runtime values. Values travel as private secret mounts. Docker ignore patterns and Railpack's configured exclusions apply before source transfer. Recipes can still print or embed values. diff --git a/crates/ployz-build/Cargo.toml b/crates/ployz-build/Cargo.toml index 991a7e9093..9be70cffad 100644 --- a/crates/ployz-build/Cargo.toml +++ b/crates/ployz-build/Cargo.toml @@ -10,6 +10,7 @@ rustix = { version = "1.1.4", features = ["fs", "process"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.11.0" +signal-hook = "0.3.18" thiserror = "2.0.20" [lints] diff --git a/crates/ployz-build/src/builder.rs b/crates/ployz-build/src/builder.rs index f6ea39d35b..f703cbda86 100644 --- a/crates/ployz-build/src/builder.rs +++ b/crates/ployz-build/src/builder.rs @@ -17,6 +17,7 @@ pub(crate) struct Builder<'a> { docker: &'a Docker<'a>, name: String, _lock: Lock, + removed: bool, } impl<'a> Builder<'a> { @@ -30,14 +31,19 @@ impl<'a> Builder<'a> { /// Fails when Buildx is unavailable or the container cannot be created. pub(crate) fn acquire(docker: &'a Docker<'a>, lock: Lock) -> Result { let name = builder_name(); - // Best effort: any container left behind is stale by construction. - let _ = remove(&docker.releasing(), &name); - create(docker, &name)?; - Ok(Self { + // Confirm stale work has stopped before reusing its cache. + remove(&docker.releasing(), &name) + .map_err(|e| BuildError::UncertainTermination(e.to_string()))?; + let mut builder = Self { docker, name, _lock: lock, - }) + removed: false, + }; + if let Err(error) = create(docker, &builder.name) { + return builder.finish(Err(error)); + } + Ok(builder) } /// Run one build, bounded by the attempt's deadline. @@ -47,18 +53,31 @@ impl<'a> Builder<'a> { /// confirmed stopped, or an uncertain termination when it is not. pub(crate) fn run(&self, arguments: &[String]) -> Result<(), BuildError> { let borrowed = arguments.iter().map(String::as_str).collect::>(); - match self.docker.run("the build", &borrowed, Streams::Inherited) { - Ok(_) => Ok(()), - Err(BuildError::TimedOut(seconds)) => Err(self.terminate(seconds)), - Err(error) => Err(error), - } + self.docker + .run("the build", &borrowed, Streams::Inherited) + .map(|_| ()) } - /// The attempt was terminated; report whether its builder stopped too. - fn terminate(&self, seconds: u64) -> BuildError { - match remove(&self.docker.releasing(), &self.name) { - Ok(()) => BuildError::TimedOut(seconds), - Err(error) => BuildError::UncertainTermination(error.to_string()), + /// Confirm cleanup before releasing admission, preserving existing failures. + /// + /// # Errors + /// Reports uncertain cleanup, the original failure, or a late cancellation. + pub(crate) fn finish(&mut self, result: Result) -> Result { + let cleanup = remove(&self.docker.releasing(), &self.name); + self.removed = true; + match cleanup { + Ok(()) => result.and_then(|output| { + if self + .docker + .cancelled + .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::SeqCst)) + { + Err(BuildError::Cancelled) + } else { + Ok(output) + } + }), + Err(error) => Err(BuildError::UncertainTermination(error.to_string())), } } } @@ -69,7 +88,9 @@ impl Drop for Builder<'_> { /// A removal that fails is reported rather than discarded: the image this /// attempt built stays usable, and the next attempt replaces the container. fn drop(&mut self) { - if let Err(error) = remove(&self.docker.releasing(), &self.name) { + if !self.removed + && let Err(error) = remove(&self.docker.releasing(), &self.name) + { eprintln!( "WARNING: build container '{}' was left behind: {error}. The next build replaces it.", self.name @@ -127,11 +148,14 @@ impl Lock { /// /// # Errors /// Fails when the lock file cannot be opened or locked. - pub(crate) fn acquire() -> Result { - Self::acquire_in(&directory()) + pub(crate) fn acquire(cancelled: &std::sync::atomic::AtomicBool) -> Result { + Self::acquire_in(&directory(), cancelled) } - fn acquire_in(directory: &std::path::Path) -> Result { + fn acquire_in( + directory: &std::path::Path, + cancelled: &std::sync::atomic::AtomicBool, + ) -> Result { use rustix::fs::{FlockOperation, flock}; let path = directory.join(format!("{}.lock", builder_name())); @@ -153,6 +177,9 @@ impl Lock { eprintln!("Waiting for another local Ployz build to finish."); let deadline = std::time::Instant::now() + QUEUE_TIMEOUT; while std::time::Instant::now() < deadline { + if cancelled.load(std::sync::atomic::Ordering::SeqCst) { + return Err(BuildError::Cancelled); + } std::thread::sleep(std::time::Duration::from_millis(200)); if flock(&file, FlockOperation::NonBlockingLockExclusive).is_ok() { return Ok(Self { _file: file }); @@ -204,9 +231,11 @@ mod tests { environment: &environment, working_dir: &directory, deadline: crate::Deadline::starting_now(crate::EXECUTION_TIMEOUT), + cancelled: None, }; - let lock = Lock::acquire_in(&directory).unwrap(); + let lock = + Lock::acquire_in(&directory, &std::sync::atomic::AtomicBool::new(false)).unwrap(); let builder = Builder::acquire(&docker, lock).unwrap(); let name = builder_name(); let acquired = fs::read_to_string(directory.join("calls")).unwrap(); diff --git a/crates/ployz-build/src/cancellation.rs b/crates/ployz-build/src/cancellation.rs new file mode 100644 index 0000000000..777531e919 --- /dev/null +++ b/crates/ployz-build/src/cancellation.rs @@ -0,0 +1,42 @@ +//! Interrupt all phases of an admitted attempt, while allowing bounded cleanup. + +use crate::BuildError; +use std::sync::{Arc, atomic::AtomicBool}; + +/// Own the signal registrations for one synchronous Build Attempt. +pub(crate) struct Cancellation { + /// Set when the caller interrupts; cleanup commands ignore this flag. + pub(crate) flag: Arc, + signals: Vec, +} + +impl Cancellation { + /// Observe SIGINT and SIGTERM until this attempt leaves scope. + /// + /// # Errors + /// Fails when the operating system cannot register a signal handler. + pub(crate) fn new() -> Result { + let mut cancellation = Self { + flag: Arc::new(AtomicBool::new(false)), + signals: Vec::new(), + }; + for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { + cancellation.signals.push( + signal_hook::flag::register(signal, Arc::clone(&cancellation.flag)).map_err( + |error| { + BuildError::Prerequisite(format!("install build cancellation: {error}")) + }, + )?, + ); + } + Ok(cancellation) + } +} + +impl Drop for Cancellation { + fn drop(&mut self) { + for signal in &self.signals { + signal_hook::low_level::unregister(*signal); + } + } +} diff --git a/crates/ployz-build/src/index.rs b/crates/ployz-build/src/index.rs new file mode 100644 index 0000000000..10cb1449fe --- /dev/null +++ b/crates/ployz-build/src/index.rs @@ -0,0 +1,197 @@ +//! Railpack 0.39.0 needs separate solves and upstream OCI index assembly. +//! Primary evidence (shipped beta binaries, ARM64 under emulation): +//! https://github.com/getployz/ployz2/blob/c3ca5519a4607256ffb28052d77a1c7d89f1bbe1/prototypes/railpack-transfer/FINDINGS.md + +use crate::{ + BuildError, BuiltImage, Docker, Planned, Request, Streams, TargetMetadata, bake_arguments, + builder::Builder, builder_name, +}; +use std::{collections::BTreeMap, path::Path}; + +// regctl 0.11.6, matching the prototype. Containerized so remote Docker also works. +const REGCTL: &str = + "regclient/regctl@sha256:5fe7c6a6206e1d9d71af719674c9db03e74a4ae221a32089b691f5b55533d6ea"; + +/// Solve each requested platform, assemble its exact content, and verify the local image. +/// +/// # Errors +/// Reports solve, assembly, import or content failures; cleanup failure remains uncertain. +pub(crate) fn build( + docker: &Docker<'_>, + builder: &Builder<'_>, + request: &Request<'_>, + target: &Planned<'_>, + overrides: Option<&Path>, +) -> Result { + let name = format!("{}-assemble", builder_name()); + let _ = docker.releasing().run( + "remove stale image assembly", + &["rm", "--force", &name], + Streams::Captured, + ); + let result = (|| { + docker.run( + "create image assembly", + &[ + "create", + "--name", + &name, + "--network", + "none", + "--entrypoint", + "/bin/sleep", + REGCTL, + "infinity", + ], + Streams::Captured, + )?; + docker.run("start image assembly", &["start", &name], Streams::Captured)?; + let regctl = |action, args: &[&str]| { + let mut command = vec!["exec", &name, "regctl"]; + command.extend_from_slice(args); + docker.run(action, &command, Streams::Captured) + }; + let mut refs = Vec::new(); + let mut tags = Vec::new(); + for (number, platform) in target.target.platforms.iter().enumerate() { + let archive = request.working_dir.join("private/railpack/variant.tar"); + let metadata = request.working_dir.join("private/railpack/variant.json"); + let mut args = + bake_arguments(request, std::slice::from_ref(target), &metadata, overrides); + args.retain(|arg| arg != "--load"); + for setting in [ + format!("{}.platform={platform}", target.bake), + format!("{}.output=type=oci,dest={}", target.bake, archive.display()), + format!("{}.attest=type=provenance,disabled=true", target.bake), + ] { + args.extend(["--set".into(), setting]); + } + builder.run(&args)?; + let results: BTreeMap = + serde_json::from_slice(&std::fs::read(&metadata).map_err(result_error)?) + .map_err(result_error)?; + let result = results.get(&target.bake).ok_or_else(|| { + BuildError::Result("Railpack solve produced no image result".into()) + })?; + let current_tags = result.tags(); + if number == 0 { + tags = current_tags; + } else if tags != current_tags { + return Err(BuildError::Result( + "Railpack solves reported different image tags".into(), + )); + } + docker.run( + "copy Railpack variant for assembly", + &[ + "cp", + &archive.to_string_lossy(), + &format!("{name}:/tmp/variant.tar"), + ], + Streams::Captured, + )?; + let reference = format!("ocidir:///tmp/layout@{}", result.digest); + regctl( + "import Railpack variant", + &["image", "import", &reference, "/tmp/variant.tar"], + )?; + refs.push(reference); + } + let tag = tags + .first() + .ok_or_else(|| BuildError::Result("Railpack solve tagged no image".into()))?; + let mut args = vec!["index", "create", "ocidir:///tmp/layout:complete"]; + for reference in &refs { + args.extend(["--ref", reference]); + } + regctl("assemble Railpack image index", &args)?; + let digest = regctl( + "read assembled image identity", + &["image", "digest", "ocidir:///tmp/layout:complete"], + )?; + let digest = digest.trim(); + if !digest + .strip_prefix("sha256:") + .is_some_and(|hex| hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())) + { + return Err(BuildError::Result( + "assembly returned an invalid image digest".into(), + )); + } + regctl( + "export assembled Railpack image", + &[ + "image", + "export", + "ocidir:///tmp/layout:complete", + "/tmp/complete.tar", + "--name", + tag, + ], + )?; + let archive = request.working_dir.join("private/railpack/complete.tar"); + docker.run( + "copy assembled Railpack image", + &[ + "cp", + &format!("{name}:/tmp/complete.tar"), + &archive.to_string_lossy(), + ], + Streams::Captured, + )?; + docker.run( + "load assembled Railpack image", + &["image", "load", "--input", &archive.to_string_lossy()], + Streams::Captured, + )?; + let reference = digest.to_owned(); + let inspected = docker.run( + "inspect assembled Railpack image", + &["image", "inspect", &reference, "--format", "{{json .}}"], + Streams::Captured, + )?; + let image: crate::ImageInspection = + serde_json::from_str(&inspected).map_err(result_error)?; + if !image.descriptor.is_some_and(|d| { + d.digest == digest && d.media_type == "application/vnd.oci.image.index.v1+json" + }) { + return Err(BuildError::Result( + "the local store does not hold the assembled Railpack index".into(), + )); + } + for platform in &target.target.platforms { + // Export forces Docker to read the manifest, config and every layer. + // Inspect alone can list an index variant whose content is absent. + docker.run( + "verify Railpack platform content", + &["image", "save", "--platform", platform, &reference], + Streams::Discarded, + )?; + } + for tag in &tags { + docker.run( + "tag completed Railpack image", + &["image", "tag", &reference, tag], + Streams::Captured, + )?; + } + Ok(BuiltImage { + reference, + tags, + platforms: target.target.platforms.clone(), + location: docker.location(), + }) + })(); + match docker.releasing().run( + "remove image assembly", + &["rm", "--force", &name], + Streams::Captured, + ) { + Ok(_) => result, + Err(error) => Err(BuildError::UncertainTermination(error.to_string())), + } +} + +fn result_error(error: impl std::fmt::Display) -> BuildError { + BuildError::Result(format!("read Railpack image result: {error}")) +} diff --git a/crates/ployz-build/src/lib.rs b/crates/ployz-build/src/lib.rs index 675e746c7c..59ee223254 100644 --- a/crates/ployz-build/src/lib.rs +++ b/crates/ployz-build/src/lib.rs @@ -11,6 +11,8 @@ //! bounded attempt, and one image bound to the content that attempt produced. mod builder; +mod cancellation; +mod index; mod railpack; pub use railpack::Railpack; @@ -19,6 +21,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, process::{Child, Command, ExitStatus, Stdio}, + sync::atomic::{AtomicBool, Ordering}, time::{Duration, Instant}, }; @@ -78,9 +81,9 @@ pub struct Request<'a> { pub struct Target { /// Caller's name for this image, as the captured Compose file names it. pub name: String, - /// Platform the capture asks for, which the completed image must carry. - /// `None` accepts whichever platform the builder produces. - pub platform: Option, + /// Platforms the capture asks for, which the completed image must carry. + /// Empty accepts the native platform. + pub platforms: Vec, } /// What an attempt does with its result. These outcomes are exclusive: an @@ -100,13 +103,15 @@ pub enum Output { /// the content this attempt observed there. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BuiltImage { - /// Digest-pinned reference. Binds later use to this attempt's content, so - /// a later Build moving a shared tag cannot substitute its image. + /// Docker content digest on the execution host. Unlike repository@digest, + /// this still resolves after the repository's last tag moves to another image. pub reference: String, /// Tags this attempt applied, as the execution host recorded them. pub tags: Vec, - /// The platform actually present, not the one requested. - pub platform: String, + /// Platforms verified in the execution host’s image store. + pub platforms: Vec, + /// Docker endpoint holding this content. + pub location: String, } /// Why a Build Attempt did not produce the image it was asked for. @@ -124,11 +129,14 @@ pub enum BuildError { action: &'static str, diagnostic: String, }, + /// The caller interrupted this attempt. + #[error("the build was cancelled")] + Cancelled, /// The attempt exceeded its bounded execution time and was terminated. #[error("the build exceeded its {0}s execution timeout and was terminated")] TimedOut(u64), /// The attempt stopped, but its termination could not be observed. - #[error("the build was terminated but its builder did not stop: {0}")] + #[error("build resource cleanup could not be confirmed: {0}")] UncertainTermination(String), /// The build finished, but its result is not the image it claims. #[error("{0}")] @@ -152,30 +160,74 @@ pub enum BuildError { /// claims. pub fn execute(request: &Request<'_>) -> Result, BuildError> { let planned = plan(request.targets)?; + for target in request.targets { + if target.platforms.len() > 1 && !request.railpack.iter().any(|r| r.name == target.name) { + return Err(BuildError::Request( + "a Dockerfile Build produces one platform".into(), + )); + } + } + if request.output != Output::Load && request.targets.iter().any(|t| t.platforms.len() > 1) { + return Err(BuildError::Request( + "multi-platform Railpack builds require local image output".into(), + )); + } if planned.is_empty() { return Ok(Vec::new()); } // Waiting for another local build is queueing, not attempt time, so the // attempt's clock starts once this process owns the builder. - let lock = Lock::acquire()?; + let cancellation = cancellation::Cancellation::new()?; + let lock = Lock::acquire(&cancellation.flag)?; let docker = Docker { program: request.docker.unwrap_or_else(|| Path::new("docker")), environment: request.environment, working_dir: request.working_dir, deadline: Deadline::starting_now(EXECUTION_TIMEOUT), + cancelled: Some(&cancellation.flag), }; let metadata = request.working_dir.join("build-metadata.json"); - let builder = Builder::acquire(&docker, lock)?; - let preparation = railpack::prepare(&docker, request)?; - let overrides = preparation - .as_ref() - .map(railpack::Preparation::override_file); - let arguments = bake_arguments(request, &planned, &metadata, overrides.as_deref()); - builder.run(&arguments)?; - match request.output { - Output::Load => built_images(&docker, &metadata, &planned), - Output::Registry | Output::Validate => Ok(Vec::new()), - } + let mut builder = Builder::acquire(&docker, lock)?; + let mut preparation = None; + let result = (|| { + preparation = railpack::prepare(&docker, request)?; + let overrides = preparation + .as_ref() + .map(railpack::Preparation::override_file); + let (multi, ordinary): (Vec<_>, Vec<_>) = planned + .into_iter() + .partition(|p| p.target.platforms.len() > 1); + let mut images = Vec::new(); + if !ordinary.is_empty() { + let arguments = bake_arguments(request, &ordinary, &metadata, overrides.as_deref()); + builder.run(&arguments)?; + if request.output == Output::Load { + images = built_images(&docker, &metadata, &ordinary)?; + } + } + for target in &multi { + images.push(index::build( + &docker, + &builder, + request, + target, + overrides.as_deref(), + )?); + } + // Keep the public result aligned with the caller's captured targets. + let mut by_name = ordinary + .iter() + .chain(&multi) + .map(|p| p.target.name.as_str()) + .zip(images) + .collect::>(); + Ok(request + .targets + .iter() + .filter_map(|t| by_name.remove(t.name.as_str())) + .collect()) + })(); + builder.finish(result) } /// One target with the Buildx name it will carry, derived once. @@ -277,31 +329,23 @@ fn built_images( )) }) })?; - let tags = result - .name - .split(',') - .map(str::trim) - .filter(|tag| !tag.is_empty()) - .map(ToOwned::to_owned) - .collect::>(); - let repository = tags - .first() - .map(|tag| repository(tag)) - .ok_or_else(|| { - BuildError::Result(format!("the build tagged no image for '{name}'")) - })? - .to_owned(); - let reference = format!("{repository}@{}", result.digest); + let tags = result.tags(); + if tags.is_empty() { + return Err(BuildError::Result(format!( + "the build tagged no image for '{name}'" + ))); + } + let reference = result.digest; let platform = verify( docker, &reference, - &result.digest, - planned.target.platform.as_deref(), + planned.target.platforms.first().map(String::as_str), )?; Ok(BuiltImage { reference, tags, - platform, + platforms: vec![platform], + location: docker.location(), }) }) .collect() @@ -312,7 +356,6 @@ fn built_images( fn verify( docker: &Docker<'_>, reference: &str, - digest: &str, requested: Option<&str>, ) -> Result { let inspected = docker @@ -336,7 +379,7 @@ fn verify( "the local image store reports no content descriptor for {reference}; Ployz Builds require Docker's containerd image store" ))); }; - if descriptor.digest != digest { + if descriptor.digest != reference { return Err(BuildError::Result(format!( "the local image store holds {} for {reference} rather than the completed content", descriptor.digest @@ -373,15 +416,6 @@ fn covers(observed: &str, requested: &str) -> bool { .is_some_and(|rest| rest.starts_with('/')) } -/// The repository part of a tag, keeping a registry port intact. -fn repository(tag: &str) -> &str { - let start = tag.rfind('/').map_or(0, |slash| slash + 1); - match tag.get(start..).and_then(|name| name.find([':', '@'])) { - Some(offset) => tag.get(..start + offset).unwrap_or(tag), - None => tag, - } -} - #[derive(Deserialize)] struct TargetMetadata { #[serde(rename = "containerimage.digest")] @@ -390,6 +424,17 @@ struct TargetMetadata { name: String, } +impl TargetMetadata { + fn tags(&self) -> Vec { + self.name + .split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(ToOwned::to_owned) + .collect() + } +} + #[derive(Deserialize)] struct ImageInspection { #[serde(rename = "Os")] @@ -436,6 +481,8 @@ pub(crate) enum Streams { Inherited, /// Evidence this crate reads. Captured, + /// Read failure diagnostics while discarding binary image content. + Discarded, } /// The Docker CLI this attempt drives, with the values captured for it. @@ -447,9 +494,18 @@ pub(crate) struct Docker<'a> { environment: &'a BTreeMap, working_dir: &'a Path, deadline: Deadline, + cancelled: Option<&'a AtomicBool>, } impl<'a> Docker<'a> { + fn location(&self) -> String { + self.environment + .get("DOCKER_HOST") + .filter(|host| !host.is_empty()) + .cloned() + .unwrap_or_else(|| "unix:///var/run/docker.sock".into()) + } + /// The same Docker with a fresh budget for releasing resources, so /// cleanup still runs, bounded, after the attempt's deadline passes. pub(crate) fn releasing(&self) -> Docker<'a> { @@ -458,6 +514,7 @@ impl<'a> Docker<'a> { environment: self.environment, working_dir: self.working_dir, deadline: Deadline::starting_now(CLEANUP_TIMEOUT), + cancelled: None, } } @@ -471,6 +528,15 @@ impl<'a> Docker<'a> { arguments: &[&str], streams: Streams, ) -> Result { + if self + .cancelled + .is_some_and(|flag| flag.load(Ordering::SeqCst)) + { + return Err(BuildError::Cancelled); + } + if self.deadline.remaining().is_zero() { + return Err(BuildError::TimedOut(self.deadline.budget.as_secs())); + } let mut command = Command::new(self.program); command .env_clear() @@ -483,31 +549,40 @@ impl<'a> Docker<'a> { // block until its deadline. let output = self.working_dir.join("docker-output"); let diagnosis = self.working_dir.join("docker-diagnosis"); - if streams == Streams::Captured { + if streams != Streams::Inherited { command - .stdout(self.create(action, &output)?) + .stdout(match streams { + Streams::Captured => Stdio::from(self.create(action, &output)?), + Streams::Discarded => Stdio::null(), + Streams::Inherited => unreachable!(), + }) .stderr(self.create(action, &diagnosis)?); } let mut child = command.spawn().map_err(|error| BuildError::Docker { action, diagnostic: error.to_string(), })?; - let Some(status) = - wait_bounded(&mut child, self.deadline.remaining()).map_err(|error| { - BuildError::Docker { - action, - diagnostic: error.to_string(), - } - })? - else { - return Err(BuildError::TimedOut(self.deadline.budget.as_secs())); - }; + let status = wait_bounded(&mut child, self.deadline.remaining(), self.cancelled).map_err( + |error| BuildError::Docker { + action, + diagnostic: error.to_string(), + }, + )?; + if self + .cancelled + .is_some_and(|flag| flag.load(Ordering::SeqCst)) + { + return Err(BuildError::Cancelled); + } + let status = status.ok_or(BuildError::TimedOut(self.deadline.budget.as_secs()))?; if !status.success() { // Only a captured command has a diagnosis this side can read back. // Inherited output already reached the operator, and the file // still holds whatever the previous captured command wrote. let diagnostic = match streams { - Streams::Captured => std::fs::read_to_string(&diagnosis).unwrap_or_default(), + Streams::Captured | Streams::Discarded => { + std::fs::read_to_string(&diagnosis).unwrap_or_default() + } Streams::Inherited => String::new(), }; let diagnostic = diagnostic.trim(); @@ -527,7 +602,7 @@ impl<'a> Docker<'a> { diagnostic: error.to_string(), }) } - Streams::Inherited => Ok(String::new()), + Streams::Inherited | Streams::Discarded => Ok(String::new()), } } @@ -540,13 +615,17 @@ impl<'a> Docker<'a> { } /// Wait for a child, terminating it when the budget runs out. -fn wait_bounded(child: &mut Child, budget: Duration) -> std::io::Result> { +fn wait_bounded( + child: &mut Child, + budget: Duration, + cancelled: Option<&AtomicBool>, +) -> std::io::Result> { let deadline = Instant::now() + budget; loop { if let Some(status) = child.try_wait()? { return Ok(Some(status)); } - if Instant::now() >= deadline { + if Instant::now() >= deadline || cancelled.is_some_and(|flag| flag.load(Ordering::SeqCst)) { child.kill()?; child.wait()?; return Ok(None); @@ -596,6 +675,7 @@ mod tests { environment: &environment, working_dir: &directory, deadline: Deadline::starting_now(EXECUTION_TIMEOUT), + cancelled: None, }; // A captured command carries its own diagnosis. @@ -622,7 +702,7 @@ mod tests { fn target(name: &str, platform: Option<&str>) -> Target { Target { name: name.to_owned(), - platform: platform.map(ToOwned::to_owned), + platforms: platform.map(ToOwned::to_owned).into_iter().collect(), } } @@ -654,25 +734,6 @@ mod tests { assert!(!covers("linux/arm", "linux/arm64")); } - #[test] - fn a_repository_survives_tags_digests_and_registry_ports() { - assert_eq!( - repository("docker.io/library/api:v1"), - "docker.io/library/api" - ); - assert_eq!(repository("127.0.0.1:5000/api:v1"), "127.0.0.1:5000/api"); - assert_eq!( - repository("registry.test:5000/team/api"), - "registry.test:5000/team/api" - ); - assert_eq!( - repository( - "api@sha256:0000000000000000000000000000000000000000000000000000000000000000" - ), - "api" - ); - } - #[test] fn requested_output_selects_exclusive_bake_behavior() { let environment = BTreeMap::new(); @@ -723,7 +784,7 @@ mod tests { .unwrap(); let waited = Instant::now(); assert!( - wait_bounded(&mut child, Duration::from_millis(200)) + wait_bounded(&mut child, Duration::from_millis(200), None) .unwrap() .is_none() ); diff --git a/crates/ployz-build/src/railpack.rs b/crates/ployz-build/src/railpack.rs index 4688122de3..d6f76cb170 100644 --- a/crates/ployz-build/src/railpack.rs +++ b/crates/ployz-build/src/railpack.rs @@ -71,6 +71,60 @@ pub(crate) fn prepare( "Railpack requires a native Linux AMD64 or ARM64 builder".into(), )); } + docker.run( + "inspect build worker capabilities", + &["buildx", "inspect", &builder_name(), "--bootstrap"], + Streams::Captured, + )?; + let workers = docker.run( + "read build worker capabilities", + &["buildx", "ls", "--format", "{{json .}}"], + Streams::Captured, + )?; + let worker = workers + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|worker| { + worker.get("Name").and_then(serde_json::Value::as_str) == Some(&builder_name()) + }) + .ok_or_else(|| { + BuildError::Prerequisite("the build worker reported no capabilities".into()) + })?; + let supports = |platform: &str| { + worker + .get("Nodes") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|node| node.get("Platforms").and_then(serde_json::Value::as_array)) + .flatten() + .filter_map(serde_json::Value::as_str) + .any(|p| crate::covers(p, platform)) + }; + for recipe in request.railpack { + let target = request + .targets + .iter() + .find(|t| t.name == recipe.name) + .ok_or_else(|| BuildError::Request("Railpack recipe has no build target".into()))?; + for platform in target + .platforms + .iter() + .map(String::as_str) + .chain(target.platforms.is_empty().then_some(native)) + { + if !matches!(platform, "linux/amd64" | "linux/arm64") { + return Err(BuildError::Request( + "Railpack supports only linux/amd64 and linux/arm64".into(), + )); + } + if !supports(platform) { + return Err(BuildError::Prerequisite(format!( + "the build worker cannot execute {platform}; configure native or emulated support before building" + ))); + } + } + } let directory = request.working_dir.join("private/railpack"); fs::DirBuilder::new() .mode(0o700) @@ -81,11 +135,6 @@ pub(crate) fn prepare( if request.railpack.iter().any(|recipe| recipe.refresh_cache) { // ponytail: this frontend ignores no-cache/pull. Prune only the locked, // Ployz-owned builder; replace this cold rebuild when upstream supports them. - docker.run( - "start the builder for cache refresh", - &["buildx", "inspect", &builder_name(), "--bootstrap"], - Streams::Captured, - )?; docker.run( "refresh Railpack build cache", &[ @@ -100,17 +149,6 @@ pub(crate) fn prepare( )?; } for (index, recipe) in request.railpack.iter().enumerate() { - if request - .targets - .iter() - .find(|target| target.name == recipe.name) - .and_then(|target| target.platform.as_deref()) - .is_some_and(|platform| !crate::covers(native, platform)) - { - return Err(BuildError::Request(format!( - "Railpack requires the execution host's native platform {native}" - ))); - } let directory = preparation.directory.join(index.to_string()); fs::DirBuilder::new() .mode(0o700) @@ -162,7 +200,7 @@ pub(crate) fn prepare( recipe.name.replace('.', "_"), json!({ "dockerfile": plan, - "platforms": [native], + "platforms": [request.targets.iter().find(|t| t.name == recipe.name).and_then(|t| t.platforms.first()).map_or(native, String::as_str)], "args": {"BUILDKIT_SYNTAX": IMAGE, "secrets-hash": hash}, "secret": secrets, }), diff --git a/crates/ployz/src/compose/build.rs b/crates/ployz/src/compose/build.rs index 545ffbd639..7189268fad 100644 --- a/crates/ployz/src/compose/build.rs +++ b/crates/ployz/src/compose/build.rs @@ -155,23 +155,36 @@ pub fn capture_build( .get("context") .and_then(Value::as_str) .is_some_and(is_ssh_context); - if let Some(recipe) = capture_recipe(&name, build, options, project, &mut inputs)? { + let recipe = capture_recipe(&name, build, options, project, &mut inputs)?; + let railpack = recipe.is_some(); + if let Some(recipe) = recipe { railpack_recipes.push(recipe); } - let platform = requested_platform(&name, build)?.or_else(|| { - project + let mut platforms = requested_platforms(&name, build, railpack)?; + if platforms.is_empty() + && let Some(platform) = project .environment .get("DOCKER_DEFAULT_PLATFORM") - .filter(|platform| !platform.is_empty()) - .cloned() - }); - if let Some(platform) = &platform { + .filter(|p| !p.is_empty()) + { + platforms.push(platform.clone()); + } + if railpack + && platforms + .iter() + .any(|p| !matches!(p.as_str(), "linux/amd64" | "linux/arm64")) + { + return Err(invalid_build( + "Railpack supports only linux/amd64 and linux/arm64", + )); + } + if !platforms.is_empty() { build.insert( Value::String("platforms".into()), - Value::Sequence(vec![Value::String(platform.clone())]), + Value::Sequence(platforms.iter().cloned().map(Value::String).collect()), ); } - targets.push(ployz_build::Target { name, platform }); + targets.push(ployz_build::Target { name, platforms }); retain_service_image_tag(&service.name, image, build)?; if let Some(ssh) = build .get_mut(Value::String("ssh".into())) @@ -658,36 +671,38 @@ fn refuse_unpassable_settings( Ok(()) } -/// The single platform this Service asks for, if it asks for one. -/// -/// A Dockerfile Build produces one image for one platform, so several -/// requested platforms are refused here, where the Service is named. -fn requested_platform( +/// Dockerfiles remain single-platform; Railpack assembles explicit variants. +fn requested_platforms( service: &str, build: &serde_norway::Mapping, -) -> Result, ComposeError> { - let Some(platforms) = build - .get(Value::String("platforms".into())) - .and_then(Value::as_sequence) - else { - return Ok(None); + railpack: bool, +) -> Result, ComposeError> { + let Some(value) = build.get("platforms") else { + return Ok(Vec::new()); }; - match platforms.as_slice() { - [] => Ok(None), - [platform] => platform - .as_str() - .map(ToOwned::to_owned) - .map(Some) - .ok_or_else(|| { - invalid_build(&format!( - "service '{service}' has an invalid build platform" - )) - }), - several => Err(invalid_build(&format!( + let platforms = value + .as_sequence() + .ok_or_else(|| invalid_build("build.platforms must be a list"))?; + if !railpack && platforms.len() > 1 { + return Err(invalid_build(&format!( "service '{service}' requests {} build platforms; a Dockerfile Build produces one platform", - several.len() - ))), + platforms.len() + ))); } + platforms + .iter() + .map(|platform| { + platform + .as_str() + .filter(|p| !p.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + invalid_build(&format!( + "service '{service}' has an invalid build platform" + )) + }) + }) + .collect() } /// Upstream translation uses explicit build tags alone; keep the Service image diff --git a/crates/ployz/src/handlers/build.rs b/crates/ployz/src/handlers/build.rs index 492e7cd403..48035f1dcf 100644 --- a/crates/ployz/src/handlers/build.rs +++ b/crates/ployz/src/handlers/build.rs @@ -49,7 +49,7 @@ pub(super) fn run(matches: &ArgMatches) -> Result<(), Error> { println!( "Built {} ({}) as {} in local Docker", service.built.tags.join(", "), - service.built.platform, + service.built.platforms.join(", "), service.built.reference, ); } diff --git a/crates/ployz/tests/build.rs b/crates/ployz/tests/build.rs index bc64cbc554..3c4abf7ec8 100644 --- a/crates/ployz/tests/build.rs +++ b/crates/ployz/tests/build.rs @@ -233,12 +233,9 @@ fn captured_build_preserves_sources_configuration_and_builder_flags() { ); let service = one_built(outcome); assert_eq!(service.image, "example.test/api:version2"); - assert_eq!( - service.built.reference, - format!("example.test/api@{FIRST_CONTENT}") - ); + assert_eq!(service.built.reference, FIRST_CONTENT); assert_eq!(service.built.tags, ["example.test/api:version2"]); - assert_eq!(service.built.platform, "linux/amd64"); + assert_eq!(service.built.platforms, ["linux/amd64"]); let override_yaml = fs::read_to_string(captured).unwrap(); assert!(override_yaml.contains("api")); assert!(override_yaml.contains("example.test/api:version2")); @@ -374,20 +371,15 @@ fn built_images_bind_to_exact_content_after_tag_reuse() { let (first, second) = (one_built(first), one_built(second)); assert_eq!(first.built.tags, second.built.tags); - assert_eq!( - first.built.reference, - format!("example.test/api@{FIRST_CONTENT}") - ); - assert_eq!( - second.built.reference, - format!("example.test/api@{SECOND_CONTENT}") - ); + assert_eq!(first.built.reference, FIRST_CONTENT); + assert_eq!(second.built.reference, SECOND_CONTENT); // Each attempt verified its own content instead of the shared tag. let calls = fs::read_to_string(root.join("calls")).unwrap(); for content in [FIRST_CONTENT, SECOND_CONTENT] { assert!( - calls.lines().any(|call| call - == format!("image inspect example.test/api@{content} --format {{{{json .}}}}")), + calls + .lines() + .any(|call| call == format!("image inspect {content} --format {{{{json .}}}}")), "{calls}" ); } @@ -430,7 +422,7 @@ fn an_image_the_store_does_not_hold_is_refused_as_a_result() { #[test] fn several_requested_build_platforms_are_refused_with_the_service_named() { let mut project = parse_normalized( - "name: demo\nservices: {api: {build: {context: ., platforms: [linux/amd64, linux/arm64]}}}\n", + "name: demo\nservices: {api: {build: {context: ., dockerfile_inline: 'FROM scratch', platforms: [linux/amd64, linux/arm64]}}}\n", ".", ) .unwrap(); @@ -554,7 +546,7 @@ case "$1 $2" in 'buildx rm') rm -f "$root/builder"; exit 0 ;; 'buildx ls') if [ -f "$root/builder" ]; then - printf '%s\n' '{{"Name":"{builder}","Nodes":[{{"Platforms":["linux/amd64"],"DriverOpts":{{"image":"{image}"}}}}]}}' + printf '%s\n' '{{"Name":"{builder}","Nodes":[{{"Platforms":["linux/amd64","linux/arm64"],"DriverOpts":{{"image":"{image}"}}}}]}}' fi exit 0 ;; 'image inspect') diff --git a/crates/ployz/tests/build/capture.rs b/crates/ployz/tests/build/capture.rs index d022408693..6831908369 100644 --- a/crates/ployz/tests/build/capture.rs +++ b/crates/ployz/tests/build/capture.rs @@ -454,7 +454,7 @@ fn default_platform_is_captured_and_verified_unless_compose_overrides_it() { ); "linux/arm64" } else { - assert_eq!(one_built(result.unwrap()).built.platform, "linux/amd64"); + assert_eq!(one_built(result.unwrap()).built.platforms, ["linux/amd64"]); "linux/amd64" }; let config: serde_norway::Value = diff --git a/crates/ployz/tests/build/railpack.rs b/crates/ployz/tests/build/railpack.rs index 79b632359b..82c7a4b8fb 100644 --- a/crates/ployz/tests/build/railpack.rs +++ b/crates/ployz/tests/build/railpack.rs @@ -205,3 +205,192 @@ fn railpack_refuses_frontend_options_it_cannot_honor() { } fs::remove_dir_all(root).unwrap(); } + +#[test] +fn railpack_accepts_explicit_linux_architectures_and_rejects_other_platforms() { + let root = + std::env::temp_dir().join(format!("ployz-railpack-platforms-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + for (platforms, accepted) in [ + ("linux/amd64, linux/arm64", true), + ("linux/arm64", true), + ("linux/amd64, windows/amd64", false), + ("linux/arm/v7", false), + ] { + let mut project = parse_normalized(&format!("services:\n api:\n build: {{context: ., x-recipe: railpack, platforms: [{platforms}]}}\n"), &root).unwrap(); + let options = BuildOptions::default(); + let plan = plan_build(&project, &options).unwrap(); + let result = capture_build(&plan, &options, &mut project); + assert_eq!(result.is_ok(), accepted, "{platforms}: {:?}", result.err()); + } + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn multi_platform_build_requires_complete_content_and_cleans_up_failed_attempts() { + let root = std::env::temp_dir().join(format!("ployz-railpack-outcomes-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let docker = write_multi_docker(&root); + let mut project = parse_normalized("services:\n api:\n image: example.test/api:check\n build: {context: ., x-recipe: railpack, platforms: [linux/amd64, linux/arm64]}\n", &root).unwrap(); + let options = BuildOptions::default(); + let plan = plan_build(&project, &options).unwrap(); + let captured = capture_build(&plan, &options, &mut project).unwrap(); + for (failure, diagnostic) in [ + ("assembly-fails", "assemble Railpack"), + ("import-fails", "load assembled"), + ("missing-content", "verify Railpack platform content"), + ("solve-fails", "the build"), + ("worker-missing", "reported no capabilities"), + ] { + fs::write(root.join(failure), "").unwrap(); + let error = captured.execute(Some(&docker)).unwrap_err().to_string(); + assert!(error.contains(diagnostic), "{error}"); + assert!(!root.join("assembly").exists()); + assert!(!root.join("builder").exists()); + let capture_root = fs::read_to_string(root.join("capture-root")).unwrap(); + assert!( + !Path::new(capture_root.trim()) + .join("private/railpack") + .exists() + ); + fs::remove_file(root.join(failure)).unwrap(); + } + let first = one_built(captured.execute(Some(&docker)).unwrap()); + assert_eq!(first.built.platforms, ["linux/amd64", "linux/arm64"]); + assert_eq!(first.built.reference, FIRST_CONTENT); + assert_eq!(first.built.tags, ["example.test/api:check"]); + fs::write(root.join("digest"), SECOND_CONTENT).unwrap(); + let second = one_built(captured.execute(Some(&docker)).unwrap()); + assert_ne!(first.built.reference, second.built.reference); + assert_eq!(first.built.tags, second.built.tags); + fs::remove_dir_all(root).unwrap(); +} + +fn write_multi_docker(root: &Path) -> std::path::PathBuf { + let base = root.join("base-docker"); + write_docker(&base, root); + fs::write(root.join("image"), "example.test/api:check").unwrap(); + fs::write(root.join("digest"), FIRST_CONTENT).unwrap(); + fs::write( + root.join("media"), + "application/vnd.oci.image.index.v1+json", + ) + .unwrap(); + let docker = root.join("docker"); + fs::write( + &docker, + format!( + r#"#!/bin/sh +root='{}' +case "$1" in --ready) exit 0 ;; esac +case "$*" in + 'buildx bake '*) + if [ -f "$root/cancel-solve" ]; then pwd > "$root/capture-root"; touch "$root/active"; exec sleep 60; fi + if [ -f "$root/solve-fails" ]; then pwd > "$root/capture-root"; exit 1; fi ;; + 'buildx ls '*) + if [ -f "$root/worker-missing" ]; then printf '{{}}'; exit 0; fi ;; + 'create --name '*-assemble*) touch "$root/assembly" ;; + 'rm --force '*-assemble) + if [ -f "$root/cleanup-fails" ] && [ -f "$root/active" ]; then printf 'assembly still running' >&2; exit 1; fi + rm -f "$root/assembly" ;; + 'start '*-assemble) exit 0 ;; + 'exec '*-assemble' regctl index create '*) + if [ -f "$root/cancel-assembly" ]; then touch "$root/active"; exec sleep 60; fi + test ! -f "$root/assembly-fails"; exit $? ;; + 'exec '*-assemble' regctl image digest '*) cat "$root/digest"; exit 0 ;; + 'exec '*-assemble' regctl '*) exit 0 ;; + 'cp '*:/tmp/variant.tar) exit 0 ;; + 'image load '*) test ! -f "$root/import-fails"; exit $? ;; + 'image save '*) test ! -f "$root/missing-content"; exit $? ;; + 'image tag '*) exit 0 ;; +esac +exec "$root/base-docker" "$@" +"#, + root.display() + ), + ) + .unwrap(); + fs::set_permissions(&docker, fs::Permissions::from_mode(0o700)).unwrap(); + docker +} + +#[test] +fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() { + const CHILD_ROOT: &str = "PLOYZ_TEST_CANCEL_BUILD_ROOT"; + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let root = std::path::PathBuf::from(root); + let mut project = parse_normalized("services:\n api:\n image: example.test/api:check\n build: {context: ., x-recipe: railpack, platforms: [linux/amd64, linux/arm64]}\n", &root).unwrap(); + let options = BuildOptions::default(); + let plan = plan_build(&project, &options).unwrap(); + let captured = capture_build(&plan, &options, &mut project).unwrap(); + let error = captured + .execute(Some(&root.join("docker"))) + .unwrap_err() + .to_string(); + if root.join("cleanup-fails").exists() { + assert!( + error.contains("cleanup could not be confirmed") + && error.contains("assembly still running"), + "{error}" + ); + assert!(root.join("assembly").exists()); + } else { + assert!(error.contains("cancelled"), "{error}"); + assert!(!root.join("assembly").exists()); + } + assert!(!root.join("builder").exists()); + let capture_root = fs::read_to_string(root.join("capture-root")).unwrap(); + assert!( + !Path::new(capture_root.trim()) + .join("private/railpack") + .exists() + ); + return; + } + let root = std::env::temp_dir().join(format!("ployz-railpack-cancel-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + write_multi_docker(&root); + for (phase, cleanup_fails) in [("solve", false), ("assembly", false), ("assembly", true)] { + if cleanup_fails { + fs::write(root.join("cleanup-fails"), "").unwrap(); + } + let marker = root.join(format!("cancel-{phase}")); + fs::write(&marker, "").unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "railpack::railpack_cancellation_stops_work_and_releases_private_inputs_and_admission", "--nocapture"]) + .env(CHILD_ROOT, &root) + .env("HOME", &root).spawn().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !root.join("active").exists() { + if child.try_wait().unwrap().is_some() || std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("build did not reach its {phase} workload"); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!( + Command::new("kill") + .args(["-INT", &child.id().to_string()]) + .status() + .unwrap() + .success() + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success()); + break; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("cancelled build did not finish cleanup"); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + fs::remove_file(marker).unwrap(); + fs::remove_file(root.join("active")).unwrap(); + } + fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/ployz/tests/build_layer3.rs b/crates/ployz/tests/build_layer3.rs index a2458045e3..08adbbbe11 100644 --- a/crates/ployz/tests/build_layer3.rs +++ b/crates/ployz/tests/build_layer3.rs @@ -166,8 +166,8 @@ fn local_dockerfile_build_loads_a_runnable_image_and_reuses_its_retained_cache() let first = one_built(execute_build(&plan, &options, &load, &mut project).unwrap()); assert_eq!(first.image, image); assert!(first.built.tags.iter().any(|tag| tag.ends_with(&image))); - assert_eq!(first.built.platform, host_platform()); - assert!(first.built.reference.contains("@sha256:")); + assert_eq!(first.built.platforms, [host_platform()]); + assert!(first.built.reference.starts_with("sha256:")); // The image is in the local store under exactly the content just built. command(["image", "inspect", &first.built.reference]); let stamp = output(["run", "--rm", &first.built.reference, "cat", "/built-at"]); @@ -456,7 +456,13 @@ async fn railpack_build_and_deploy_preserve_variables_cache_and_failure_boundari ); } previous = Some(content); - assert!(built.built.platform.starts_with("linux/")); + assert!( + built + .built + .platforms + .iter() + .all(|p| p.starts_with("linux/")) + ); assert!(built.built.tags.contains(&image)); assert!( output([ @@ -579,3 +585,113 @@ async fn railpack_build_and_deploy_preserve_variables_cache_and_failure_boundari fs::remove_dir_all(ssh).unwrap(); fs::remove_dir_all(root).unwrap(); } + +/// Informing evidence only: on AMD64 hosts ARM64 execution uses binfmt/QEMU. +/// Native ARM64 Machine and packaged release proof remain qualification work. +#[test] +#[ignore = "informing: requires Docker containerd storage and AMD64/ARM64 worker support"] +fn railpack_builds_complete_amd64_arm64_content_and_runs_without_registry_fallback() { + let root = std::env::temp_dir().join(format!("ployz-l3-805-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let image = format!("railpack.invalid/ployz-{}:multi", std::process::id()); + let extra = format!("railpack.invalid/ployz-{}:extra", std::process::id()); + let mut cleanup = LocalBuild { + images: vec![image.clone(), extra.clone()], + }; + fs::write(root.join("compose.yaml"), format!("services:\n app:\n image: {image}\n build:\n context: .\n platforms: [linux/amd64, linux/arm64]\n tags: [{extra}]\n")).unwrap(); + fs::write(root.join("package.json"), r#"{"name":"multi-check","version":"1.0.0","engines":{"node":"22.16.0"},"scripts":{"start":"node index.js"}}"#).unwrap(); + fs::write(root.join("index.js"), "console.log(process.arch);").unwrap(); + let load = LoadOptions { + command: "build".into(), + working_dir: Some(root.clone()), + ..Default::default() + }; + let mut project = load_project(&load).unwrap(); + let options = BuildOptions::default(); + let plan = plan_build(&project, &options).unwrap(); + let built = one_built(execute_build(&plan, &options, &load, &mut project).unwrap()); + cleanup.images.push(built.built.reference.clone()); + assert_eq!(built.built.platforms, ["linux/amd64", "linux/arm64"]); + assert!(built.built.tags.contains(&image)); + assert!(built.built.tags.contains(&extra)); + // Moving every requested tag must not change or lose the Build's identity. + for tag in &built.built.tags { + command(["image", "tag", ployz_build::BUILDKIT_IMAGE, tag]); + } + eprintln!( + "Execution host: {}; non-native variants below are EMULATED", + host_platform() + ); + for (platform, expected) in [("linux/amd64", "x64"), ("linux/arm64", "arm64")] { + assert_eq!( + output([ + "run", + "--rm", + "--pull", + "never", + "--platform", + platform, + &built.built.reference + ]) + .lines() + .last(), + Some(expected) + ); + } + // Saving from the execution store reads every blob; check the resulting + // OCI inventory and hashes independently of the executor and regctl. + let archive = root.join("observed.tar"); + command([ + "image", + "save", + "--output", + archive.to_str().unwrap(), + &built.built.reference, + ]); + let inventory = Command::new("python3") + .args([ + "-c", + r#" +import hashlib, json, sys, tarfile +with tarfile.open(sys.argv[1]) as archive: + def read(digest): + data = archive.extractfile('blobs/' + digest.replace(':', '/')).read() + assert 'sha256:' + hashlib.sha256(data).hexdigest() == digest + return data + index = json.loads(read(sys.argv[2])) + platforms = set() + for descriptor in index['manifests']: + manifest = json.loads(read(descriptor['digest'])) + config = json.loads(read(manifest['config']['digest'])) + platforms.add(config['os'] + '/' + config['architecture']) + for layer in manifest['layers']: + read(layer['digest']) + assert platforms == {'linux/amd64', 'linux/arm64'}, platforms + print('Verified index, manifests, configurations and all layer hashes:', sorted(platforms)) +"#, + archive.to_str().unwrap(), + &built.built.reference, + ]) + .status() + .unwrap(); + assert!(inventory.success()); + assert!( + output([ + "ps", + "-aq", + "--filter", + &format!("name={}-assemble", ployz_build::builder_name()) + ]) + .trim() + .is_empty() + ); + assert!( + !Command::new("docker") + .args(["buildx", "inspect", &ployz_build::builder_name()]) + .status() + .unwrap() + .success() + ); + command(["volume", "inspect", &cache_volume()]); + fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/ployz/tests/compose_cli.rs b/crates/ployz/tests/compose_cli.rs index 679e7b0d1d..e8bc741472 100644 --- a/crates/ployz/tests/compose_cli.rs +++ b/crates/ployz/tests/compose_cli.rs @@ -499,12 +499,14 @@ fn build_cli_selects_recipes_without_fallback() { r#"#!/bin/sh printf '%s\n' "$1 $2" >> '{}' case "$1 $2" in + 'buildx ls') printf '{{"Name":"{builder}","Nodes":[{{"Platforms":["linux/amd64"]}}]}}\n' ;; 'version --format') printf 'linux/amd64\n' ;; 'buildx bake') echo dockerfile-failed >&2; exit 23 ;; 'start --attach') echo preparation-failed >&2; exit 24 ;; esac "#, - calls.display() + calls.display(), + builder = ployz_build::builder_name() ), ); let path = std::env::join_paths( From 7f6bd1617536b5dc6362c08873f7ef44a4dfd1b2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 02:12:14 +0000 Subject: [PATCH 2/8] Make build cancellation caller-owned and scope helper container cleanup --- Cargo.lock | 3 +- crates/ployz-build/Cargo.toml | 2 +- crates/ployz-build/src/builder.rs | 29 +- crates/ployz-build/src/cancellation.rs | 42 --- crates/ployz-build/src/execution.rs | 39 +-- crates/ployz-build/src/index.rs | 307 ++++++++++------------ crates/ployz-build/src/lib.rs | 56 +++- crates/ployz-build/src/railpack.rs | 145 +++------- crates/ployz-build/src/tests.rs | 62 +++++ crates/ployz/Cargo.toml | 1 + crates/ployz/src/cancellation.rs | 156 +++++++++++ crates/ployz/src/compose/build.rs | 36 ++- crates/ployz/src/deploy/apply.rs | 46 ++-- crates/ployz/src/deploy/pipeline.rs | 16 +- crates/ployz/src/handlers/build.rs | 16 +- crates/ployz/src/handlers/deploy.rs | 12 +- crates/ployz/src/handlers/deploy_tests.rs | 11 +- crates/ployz/src/handlers/image.rs | 11 +- crates/ployz/src/handlers/mod.rs | 16 +- crates/ployz/src/image.rs | 53 ++-- crates/ployz/src/image/proxy.rs | 24 +- crates/ployz/src/lib.rs | 1 + crates/ployz/tests/build.rs | 16 +- crates/ployz/tests/build/capture.rs | 30 ++- crates/ployz/tests/build/railpack.rs | 82 ++++-- crates/ployz/tests/build_layer3.rs | 59 ++++- crates/ployz/tests/compose_cli.rs | 9 +- crates/ployzd/src/build/tests.rs | 5 +- 28 files changed, 803 insertions(+), 482 deletions(-) delete mode 100644 crates/ployz-build/src/cancellation.rs create mode 100644 crates/ployz/src/cancellation.rs diff --git a/Cargo.lock b/Cargo.lock index e84668e7e3..10661d8886 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2142,6 +2142,7 @@ dependencies = [ "serde_norway", "sha2", "shell-words", + "signal-hook", "sigpipe", "thiserror 2.0.20", "tokio", @@ -2166,8 +2167,8 @@ dependencies = [ "serde_json", "serde_norway", "sha2", - "signal-hook", "thiserror 2.0.20", + "tokio-util", "url", "uuid", ] diff --git a/crates/ployz-build/Cargo.toml b/crates/ployz-build/Cargo.toml index 0a20777737..9b2f8044ac 100644 --- a/crates/ployz-build/Cargo.toml +++ b/crates/ployz-build/Cargo.toml @@ -15,7 +15,7 @@ rustix = { version = "1.1.4", features = ["fs", "process"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.11.0" -signal-hook = "0.3.18" +tokio-util = "0.7.19" thiserror = "2.0.20" [lints] diff --git a/crates/ployz-build/src/builder.rs b/crates/ployz-build/src/builder.rs index befe8a279c..6f349bab28 100644 --- a/crates/ployz-build/src/builder.rs +++ b/crates/ployz-build/src/builder.rs @@ -47,6 +47,9 @@ impl<'a> Builder<'a> { } /// Read the running worker, not the Machine's advertised architecture. + /// + /// # Errors + /// Refuses unsupported image stores, hosts, or requested worker platforms. pub(crate) fn native_platform(&self, targets: &[crate::Target]) -> Result { let info = self.docker.run( "inspect the image store", @@ -173,7 +176,17 @@ impl<'a> Builder<'a> { }); } match cleanup { - Ok(()) => result, + Ok(()) => result.and_then(|output| { + if self + .docker + .cancellation + .is_some_and(crate::Cancellation::is_cancelled) + { + Err(BuildError::Cancelled) + } else { + Ok(output) + } + }), Err(error) => Err(BuildError::UncertainTermination(match result { Ok(_) => format!("output handling completed; cleanup failed: {error}"), Err(cause) => format!("{cause}; cleanup failed: {error}"), @@ -244,13 +257,19 @@ impl Lock { /// /// # Errors /// Fails when the lock file cannot be opened or locked. - pub(crate) fn acquire() -> Result { - Self::acquire_in(&directory()) + pub(crate) fn acquire(cancellation: &crate::Cancellation) -> Result { + Self::acquire_in(&directory(), cancellation) } - fn acquire_in(directory: &std::path::Path) -> Result { + fn acquire_in( + directory: &std::path::Path, + cancellation: &crate::Cancellation, + ) -> Result { let deadline = std::time::Instant::now() + QUEUE_TIMEOUT; loop { + if cancellation.is_cancelled() { + return Err(BuildError::Cancelled); + } match Self::try_acquire_in(directory) { Err(BuildError::Busy) if std::time::Instant::now() < deadline => { std::thread::sleep(std::time::Duration::from_millis(200)); @@ -423,7 +442,7 @@ mod tests { progress: None, }; - let lock = Lock::acquire_in(&directory).unwrap(); + let lock = Lock::acquire_in(&directory, &crate::Cancellation::default()).unwrap(); let builder = Builder::acquire(&docker, lock).unwrap(); let name = builder_name(); let acquired = fs::read_to_string(directory.join("calls")).unwrap(); diff --git a/crates/ployz-build/src/cancellation.rs b/crates/ployz-build/src/cancellation.rs deleted file mode 100644 index 777531e919..0000000000 --- a/crates/ployz-build/src/cancellation.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Interrupt all phases of an admitted attempt, while allowing bounded cleanup. - -use crate::BuildError; -use std::sync::{Arc, atomic::AtomicBool}; - -/// Own the signal registrations for one synchronous Build Attempt. -pub(crate) struct Cancellation { - /// Set when the caller interrupts; cleanup commands ignore this flag. - pub(crate) flag: Arc, - signals: Vec, -} - -impl Cancellation { - /// Observe SIGINT and SIGTERM until this attempt leaves scope. - /// - /// # Errors - /// Fails when the operating system cannot register a signal handler. - pub(crate) fn new() -> Result { - let mut cancellation = Self { - flag: Arc::new(AtomicBool::new(false)), - signals: Vec::new(), - }; - for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { - cancellation.signals.push( - signal_hook::flag::register(signal, Arc::clone(&cancellation.flag)).map_err( - |error| { - BuildError::Prerequisite(format!("install build cancellation: {error}")) - }, - )?, - ); - } - Ok(cancellation) - } -} - -impl Drop for Cancellation { - fn drop(&mut self) { - for signal in &self.signals { - signal_hook::low_level::unregister(*signal); - } - } -} diff --git a/crates/ployz-build/src/execution.rs b/crates/ployz-build/src/execution.rs index 5ccc6ec943..15af22de2c 100644 --- a/crates/ployz-build/src/execution.rs +++ b/crates/ployz-build/src/execution.rs @@ -2,14 +2,7 @@ use crate::{BuildError, Deadline, EXECUTION_TIMEOUT, builder::Lock}; use serde::{Deserialize, Serialize}; -use std::{ - path::PathBuf, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; +use std::{path::PathBuf, time::Duration}; /// Earliest observed phase of one Build attempt. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -66,20 +59,9 @@ impl WorkEvidence { } } -/// A request to stop, never proof that execution stopped. -#[derive(Clone, Default)] -pub struct Cancellation(Arc); -impl Cancellation { - /// Request bounded termination. - pub fn cancel(&self) { - self.0.store(true, Ordering::Release); - } - /// Whether termination has been requested. - #[must_use] - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::Acquire) - } -} +/// A caller-owned request to stop, never proof that execution stopped. +/// Shared by synchronous Build execution and subsequent async command phases. +pub use tokio_util::sync::CancellationToken as Cancellation; /// Execution-host settings. These are never deserialized from a Build request. #[derive(Clone)] @@ -119,15 +101,12 @@ impl Admission { cancellation: Cancellation::default(), }) } - pub(crate) fn wait() -> Result { - Ok(Self::new(Lock::acquire()?)) - } - fn new(lock: Lock) -> Self { - Self { - lock, + pub(crate) fn wait(cancellation: &Cancellation) -> Result { + Ok(Self { + lock: Lock::acquire(cancellation)?, deadline: Deadline::starting_now(EXECUTION_TIMEOUT), - cancellation: Cancellation::default(), - } + cancellation: cancellation.clone(), + }) } /// Handle for requesting this attempt to stop. #[must_use] diff --git a/crates/ployz-build/src/index.rs b/crates/ployz-build/src/index.rs index 775b22db36..d0a80b0903 100644 --- a/crates/ployz-build/src/index.rs +++ b/crates/ployz-build/src/index.rs @@ -24,185 +24,168 @@ pub(crate) fn build( overrides: Option<&Path>, ) -> Result { let name = format!("{}-assemble", builder_name()); - let _ = docker.releasing().run( - "remove stale image assembly", - &["rm", "--force", &name], - Streams::Captured, - ); - let result = (|| { - docker.run( - "create image assembly", - &[ - "create", - "--name", - &name, - "--network", - "none", - "--entrypoint", - "/bin/sleep", - REGCTL, - "infinity", - ], - Streams::Captured, - )?; - docker.run("start image assembly", &["start", &name], Streams::Captured)?; - let regctl = |action, args: &[&str]| { - let mut command = vec!["exec", &name, "regctl"]; - command.extend_from_slice(args); - docker.run(action, &command, Streams::Captured) - }; - let mut refs = Vec::new(); - let mut tags = Vec::new(); - for (number, platform) in target.target.platforms.iter().enumerate() { - let archive = request.working_dir.join("private/railpack/variant.tar"); - let metadata = request.working_dir.join("private/railpack/variant.json"); - let mut args = - bake_arguments(request, std::slice::from_ref(target), &metadata, overrides); - args.retain(|arg| arg != "--load"); - for setting in [ - format!("{}.platform={platform}", target.bake), - format!("{}.output=type=oci,dest={}", target.bake, archive.display()), - format!("{}.attest=type=provenance,disabled=true", target.bake), - ] { - args.extend(["--set".into(), setting]); - } - if let Some(progress) = docker.progress { - progress(crate::Progress::Stage(crate::Stage::Building)); - } - builder.run(&args, || { + docker.with_container( + &name, + &[ + "--network", + "none", + "--entrypoint", + "/bin/sleep", + REGCTL, + "infinity", + ], + |name| { + docker.run("start image assembly", &["start", name], Streams::Captured)?; + let regctl = |action, args: &[&str]| { + let mut command = vec!["exec", name, "regctl"]; + command.extend_from_slice(args); + docker.run(action, &command, Streams::Captured) + }; + let mut refs = Vec::new(); + let mut tags = Vec::new(); + for (number, platform) in target.target.platforms.iter().enumerate() { + let archive = request.working_dir.join("private/railpack/variant.tar"); + let metadata = request.working_dir.join("private/railpack/variant.json"); + let mut args = + bake_arguments(request, std::slice::from_ref(target), &metadata, overrides); + args.retain(|arg| arg != "--load"); + for setting in [ + format!("{}.platform={platform}", target.bake), + format!("{}.output=type=oci,dest={}", target.bake, archive.display()), + format!("{}.attest=type=provenance,disabled=true", target.bake), + ] { + args.extend(["--set".into(), setting]); + } if let Some(progress) = docker.progress { - progress(crate::Progress::Target { - name: target.target.name.clone(), - outcome: crate::TargetEvidence::Unknown, - }); + progress(crate::Progress::Stage(crate::Stage::Building)); + } + builder.run(&args, || { + if let Some(progress) = docker.progress { + progress(crate::Progress::Target { + name: target.target.name.clone(), + outcome: crate::TargetEvidence::Unknown, + }); + } + })?; + if let Some(progress) = docker.progress { + progress(crate::Progress::Stage(crate::Stage::Output)); + } + let results: BTreeMap = + serde_json::from_slice(&std::fs::read(&metadata).map_err(result_error)?) + .map_err(result_error)?; + let result = results.get(&target.bake).ok_or_else(|| { + BuildError::Result("Railpack solve produced no image result".into()) + })?; + let current_tags = result.tags(); + if number == 0 { + tags = current_tags; + } else if tags != current_tags { + return Err(BuildError::Result( + "Railpack solves reported different image tags".into(), + )); } - })?; - if let Some(progress) = docker.progress { - progress(crate::Progress::Stage(crate::Stage::Output)); + docker.run( + "copy Railpack variant for assembly", + &[ + "cp", + &archive.to_string_lossy(), + &format!("{name}:/tmp/variant.tar"), + ], + Streams::Captured, + )?; + let reference = format!("ocidir:///tmp/layout@{}", result.digest); + regctl( + "import Railpack variant", + &["image", "import", &reference, "/tmp/variant.tar"], + )?; + refs.push(reference); } - let results: BTreeMap = - serde_json::from_slice(&std::fs::read(&metadata).map_err(result_error)?) - .map_err(result_error)?; - let result = results.get(&target.bake).ok_or_else(|| { - BuildError::Result("Railpack solve produced no image result".into()) - })?; - let current_tags = result.tags(); - if number == 0 { - tags = current_tags; - } else if tags != current_tags { + let tag = tags + .first() + .ok_or_else(|| BuildError::Result("Railpack solve tagged no image".into()))?; + let mut args = vec!["index", "create", "ocidir:///tmp/layout:complete"]; + for reference in &refs { + args.extend(["--ref", reference]); + } + regctl("assemble Railpack image index", &args)?; + let digest = regctl( + "read assembled image identity", + &["image", "digest", "ocidir:///tmp/layout:complete"], + )?; + let digest = digest.trim(); + if !digest + .strip_prefix("sha256:") + .is_some_and(|hex| hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())) + { return Err(BuildError::Result( - "Railpack solves reported different image tags".into(), + "assembly returned an invalid image digest".into(), )); } + regctl( + "export assembled Railpack image", + &[ + "image", + "export", + "ocidir:///tmp/layout:complete", + "/tmp/complete.tar", + "--name", + tag, + ], + )?; + let archive = request.working_dir.join("private/railpack/complete.tar"); docker.run( - "copy Railpack variant for assembly", + "copy assembled Railpack image", &[ "cp", + &format!("{name}:/tmp/complete.tar"), &archive.to_string_lossy(), - &format!("{name}:/tmp/variant.tar"), ], Streams::Captured, )?; - let reference = format!("ocidir:///tmp/layout@{}", result.digest); - regctl( - "import Railpack variant", - &["image", "import", &reference, "/tmp/variant.tar"], - )?; - refs.push(reference); - } - let tag = tags - .first() - .ok_or_else(|| BuildError::Result("Railpack solve tagged no image".into()))?; - let mut args = vec!["index", "create", "ocidir:///tmp/layout:complete"]; - for reference in &refs { - args.extend(["--ref", reference]); - } - regctl("assemble Railpack image index", &args)?; - let digest = regctl( - "read assembled image identity", - &["image", "digest", "ocidir:///tmp/layout:complete"], - )?; - let digest = digest.trim(); - if !digest - .strip_prefix("sha256:") - .is_some_and(|hex| hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())) - { - return Err(BuildError::Result( - "assembly returned an invalid image digest".into(), - )); - } - regctl( - "export assembled Railpack image", - &[ - "image", - "export", - "ocidir:///tmp/layout:complete", - "/tmp/complete.tar", - "--name", - tag, - ], - )?; - let archive = request.working_dir.join("private/railpack/complete.tar"); - docker.run( - "copy assembled Railpack image", - &[ - "cp", - &format!("{name}:/tmp/complete.tar"), - &archive.to_string_lossy(), - ], - Streams::Captured, - )?; - docker.run( - "load assembled Railpack image", - &["image", "load", "--input", &archive.to_string_lossy()], - Streams::Captured, - )?; - let reference = digest.to_owned(); - let inspected = docker.run( - "inspect assembled Railpack image", - &["image", "inspect", &reference, "--format", "{{json .}}"], - Streams::Captured, - )?; - let image: crate::ImageInspection = - serde_json::from_str(&inspected).map_err(result_error)?; - if !image.descriptor.is_some_and(|d| { - d.digest == digest && d.media_type == "application/vnd.oci.image.index.v1+json" - }) { - return Err(BuildError::Result( - "the local store does not hold the assembled Railpack index".into(), - )); - } - for platform in &target.target.platforms { - // Export forces Docker to read the manifest, config and every layer. - // Inspect alone can list an index variant whose content is absent. docker.run( - "verify Railpack platform content", - &["image", "save", "--platform", platform, &reference], - Streams::Discarded, + "load assembled Railpack image", + &["image", "load", "--input", &archive.to_string_lossy()], + Streams::Captured, )?; - } - for tag in &tags { - docker.run( - "tag completed Railpack image", - &["image", "tag", &reference, tag], + let reference = digest.to_owned(); + let inspected = docker.run( + "inspect assembled Railpack image", + &["image", "inspect", &reference, "--format", "{{json .}}"], Streams::Captured, )?; - } - Ok(BuiltImage { - reference, - tags, - platforms: target.target.platforms.clone(), - location: docker.location(), - }) - })(); - match docker.releasing().run( - "remove image assembly", - &["rm", "--force", &name], - Streams::Captured, - ) { - Ok(_) => result, - Err(error) => Err(BuildError::UncertainTermination(error.to_string())), - } + let image: crate::ImageInspection = + serde_json::from_str(&inspected).map_err(result_error)?; + if !image.descriptor.is_some_and(|d| { + d.digest == digest && d.media_type == "application/vnd.oci.image.index.v1+json" + }) { + return Err(BuildError::Result( + "the local store does not hold the assembled Railpack index".into(), + )); + } + for platform in &target.target.platforms { + // Export forces Docker to read the manifest, config and every layer. + // Inspect alone can list an index variant whose content is absent. + docker.run( + "verify Railpack platform content", + &["image", "save", "--platform", platform, &reference], + Streams::Discarded, + )?; + } + for tag in &tags { + docker.run( + "tag completed Railpack image", + &["image", "tag", &reference, tag], + Streams::Captured, + )?; + } + Ok(BuiltImage { + reference, + tags, + platforms: target.target.platforms.clone(), + location: docker.location(), + }) + }, + ) } fn result_error(error: impl std::fmt::Display) -> BuildError { diff --git a/crates/ployz-build/src/lib.rs b/crates/ployz-build/src/lib.rs index d24330ddae..6e234e0da0 100644 --- a/crates/ployz-build/src/lib.rs +++ b/crates/ployz-build/src/lib.rs @@ -207,7 +207,8 @@ impl BuildError { /// [`EXECUTION_TIMEOUT`], with a separate bounded budget for cleanup. /// /// Returns the completed images in the order of `targets`. Validation and -/// registry publication retain no local image, so they return none. +/// registry publication retain no local image, so they return none. Cancellation +/// is supplied by the caller; this function never installs process signal handlers. /// /// # Errors /// Returns a prerequisite error when Docker cannot serve the Build, a request @@ -215,8 +216,11 @@ impl BuildError { /// diagnosis, a timeout, an uncertain outcome when termination could not be /// observed, or a result error when the completed image is not the content it /// claims. -pub fn execute(request: &Request<'_>) -> Result, BuildError> { - execute_admitted(request, Admission::wait()?, &|event| { +pub fn execute( + request: &Request<'_>, + cancellation: &Cancellation, +) -> Result, BuildError> { + execute_admitted(request, Admission::wait(cancellation)?, &|event| { if let Progress::Output(bytes) = event { use std::io::Write as _; let _ = std::io::stderr().write_all(&bytes); @@ -272,8 +276,8 @@ pub fn execute_admitted( let native = builder .native_platform(request.targets) .map_err(|error| error.at(Stage::Preparation))?; - preparation = - railpack::prepare(&docker, request).map_err(|error| error.at(Stage::Preparation))?; + preparation = railpack::prepare(&docker, request, &native) + .map_err(|error| error.at(Stage::Preparation))?; let overrides = preparation .as_ref() .map(railpack::Preparation::override_file); @@ -684,6 +688,48 @@ impl<'a> Docker<'a> { } } + /// Run work with a private helper container, confirming its absence afterwards. + /// + /// # Errors + /// Preserves the work's failure unless container removal cannot be confirmed. + pub(crate) fn with_container( + &self, + name: &str, + arguments: &[&str], + work: impl FnOnce(&str) -> Result, + ) -> Result { + self.remove_container(name)?; + // A failed or interrupted create may still have reached Docker. Always + // confirm absence, including when no successful create was observed. + let mut create = vec!["create", "--name", name]; + create.extend_from_slice(arguments); + let result = self + .run("create build helper", &create, Streams::Captured) + .map_err(|error| error.at(Stage::Preparation)) + .and_then(|_| work(name)); + match (result, self.remove_container(name)) { + (result, Ok(())) => result, + (Ok(_), Err(cleanup)) => Err(cleanup), + (Err(error), Err(cleanup)) => Err(error.with_later_failure(cleanup)), + } + } + + fn remove_container(&self, name: &str) -> Result<(), BuildError> { + match self.releasing().run( + "remove build helper", + &["rm", "--force", name], + Streams::Captured, + ) { + Ok(_) => Ok(()), + Err(BuildError::Docker { diagnostic, .. }) + if diagnostic.contains("No such container") => + { + Ok(()) + } + Err(error) => Err(BuildError::UncertainTermination(error.to_string())), + } + } + /// Run one Docker command to completion within the deadline. /// /// # Errors diff --git a/crates/ployz-build/src/railpack.rs b/crates/ployz-build/src/railpack.rs index d6f76cb170..e7c2edd823 100644 --- a/crates/ployz-build/src/railpack.rs +++ b/crates/ployz-build/src/railpack.rs @@ -51,6 +51,7 @@ impl Drop for Preparation { pub(crate) fn prepare( docker: &Docker<'_>, request: &Request<'_>, + native: &str, ) -> Result, BuildError> { if request.railpack.is_empty() { return Ok(None); @@ -60,47 +61,6 @@ pub(crate) fn prepare( "Railpack does not support --check".into(), )); } - let native = docker.run( - "read the native build platform", - &["version", "--format", "{{.Server.Os}}/{{.Server.Arch}}"], - Streams::Captured, - )?; - let native = native.trim(); - if !matches!(native, "linux/amd64" | "linux/arm64") { - return Err(BuildError::Request( - "Railpack requires a native Linux AMD64 or ARM64 builder".into(), - )); - } - docker.run( - "inspect build worker capabilities", - &["buildx", "inspect", &builder_name(), "--bootstrap"], - Streams::Captured, - )?; - let workers = docker.run( - "read build worker capabilities", - &["buildx", "ls", "--format", "{{json .}}"], - Streams::Captured, - )?; - let worker = workers - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .find(|worker| { - worker.get("Name").and_then(serde_json::Value::as_str) == Some(&builder_name()) - }) - .ok_or_else(|| { - BuildError::Prerequisite("the build worker reported no capabilities".into()) - })?; - let supports = |platform: &str| { - worker - .get("Nodes") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|node| node.get("Platforms").and_then(serde_json::Value::as_array)) - .flatten() - .filter_map(serde_json::Value::as_str) - .any(|p| crate::covers(p, platform)) - }; for recipe in request.railpack { let target = request .targets @@ -118,11 +78,6 @@ pub(crate) fn prepare( "Railpack supports only linux/amd64 and linux/arm64".into(), )); } - if !supports(platform) { - return Err(BuildError::Prerequisite(format!( - "the build worker cannot execute {platform}; configure native or emulated support before building" - ))); - } } } let directory = request.working_dir.join("private/railpack"); @@ -222,66 +177,44 @@ fn prepare_container( plan: &Path, ) -> Result<(), BuildError> { let name = format!("{}-prepare", builder_name()); - // The builder lock also owns this name. Remove an abandoned preparation - // before copying new source; docker cp works with remote Docker hosts too. - let _ = docker.releasing().run( - "remove stale Railpack preparation", - &["rm", "--force", &name], - Streams::Captured, - ); - let result = (|| { - docker.run( - "create Railpack preparation", - &[ - "create", - "--name", - &name, - "--network", - "host", - "--entrypoint", - "/bin/sh", - IMAGE, - "/prepare.sh", - ], - Streams::Captured, - )?; - docker.run( - "copy captured Railpack source", - &["cp", &context.to_string_lossy(), &format!("{name}:/app")], - Streams::Captured, - )?; - docker.run( - "copy private Railpack variables", - &[ - "cp", - &script.to_string_lossy(), - &format!("{name}:/prepare.sh"), - ], - Streams::Captured, - )?; - docker.run( - "Railpack preparation", - &["start", "--attach", &name], - Streams::Inherited, - )?; - docker.run( - "read the Railpack plan", - &["cp", &format!("{name}:/plan.json"), &plan.to_string_lossy()], - Streams::Captured, - )?; - Ok(()) - })(); - match docker.releasing().run( - "remove Railpack preparation", - &["rm", "--force", &name], - Streams::Captured, - ) { - Ok(_) => result, - Err(BuildError::Docker { diagnostic, .. }) if diagnostic.contains("No such container") => { - result - } - Err(error) => Err(BuildError::UncertainTermination(error.to_string())), - } + docker.with_container( + &name, + &[ + "--network", + "host", + "--entrypoint", + "/bin/sh", + IMAGE, + "/prepare.sh", + ], + |name| { + docker.run( + "copy captured Railpack source", + &["cp", &context.to_string_lossy(), &format!("{name}:/app")], + Streams::Captured, + )?; + docker.run( + "copy private Railpack variables", + &[ + "cp", + &script.to_string_lossy(), + &format!("{name}:/prepare.sh"), + ], + Streams::Captured, + )?; + docker.run( + "Railpack preparation", + &["start", "--attach", name], + Streams::Inherited, + )?; + docker.run( + "read the Railpack plan", + &["cp", &format!("{name}:/plan.json"), &plan.to_string_lossy()], + Streams::Captured, + )?; + Ok(()) + }, + ) } fn quote(value: &str) -> String { diff --git a/crates/ployz-build/src/tests.rs b/crates/ployz-build/src/tests.rs index 7208dc24bb..699053f806 100644 --- a/crates/ployz-build/src/tests.rs +++ b/crates/ployz-build/src/tests.rs @@ -258,3 +258,65 @@ fn a_command_that_outlasts_its_budget_is_terminated() { )); assert!(waited.elapsed() < Duration::from_secs(5)); } + +#[test] +fn executing_a_build_does_not_change_process_signal_handlers() { + const CHILD: &str = "PLOYZ_BUILD_SIGNAL_TEST"; + if let Ok(signal) = std::env::var(CHILD) { + let directory = + std::env::temp_dir().join(format!("ployz-build-signal-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let program = directory.join("docker"); + executable( + &program, + &format!( + r#"#!/bin/sh +case "$1 $2" in + 'info --format') echo '{{"DriverStatus":[["driver-type","io.containerd.snapshotter.v1"]],"Architecture":"amd64","OSType":"linux"}}' ;; + 'buildx ls') echo '{{"Name":"{}","Nodes":[{{"Status":"running","Platforms":["linux/amd64"]}}]}}' ;; +esac +"#, + builder_name() + ), + ); + let environment = BTreeMap::new(); + let targets = [target("api", None)]; + let request = Request { + compose_file: Path::new("compose.yaml"), + working_dir: &directory, + environment: &environment, + docker: Some(&program), + targets: &targets, + railpack: &[], + build_args: &[], + output: Output::Validate, + no_cache: false, + pull: false, + }; + execute(&request, &Cancellation::new()).unwrap(); + std::fs::remove_dir_all(directory).unwrap(); + Command::new("kill") + .args([&signal, &std::process::id().to_string()]) + .status() + .unwrap(); + std::thread::sleep(Duration::from_millis(200)); + panic!("the build swallowed {signal} after returning"); + } + use std::os::unix::process::ExitStatusExt as _; + for (signal, number) in [("-INT", 2), ("-TERM", 15)] { + let directory = + std::env::temp_dir().join(format!("ployz-signal-home-{}-{number}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let status = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tests::executing_a_build_does_not_change_process_signal_handlers", + ]) + .env(CHILD, signal) + .env("HOME", &directory) + .status() + .unwrap(); + std::fs::remove_dir_all(directory).unwrap(); + assert_eq!(status.signal(), Some(number), "{signal}: {status}"); + } +} diff --git a/crates/ployz/Cargo.toml b/crates/ployz/Cargo.toml index 4725faee48..e2d7c582fc 100644 --- a/crates/ployz/Cargo.toml +++ b/crates/ployz/Cargo.toml @@ -33,6 +33,7 @@ thiserror = "2.0.20" tokio = { version = "1.53.1", features = ["io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-stream = "0.1.19" tokio-util = "0.7.19" +signal-hook = "0.3.18" tonic = { version = "0.13.1", features = ["gzip", "transport"] } tower = { version = "0.5.3", features = ["util"] } ts-rs.workspace = true diff --git a/crates/ployz/src/cancellation.rs b/crates/ployz/src/cancellation.rs new file mode 100644 index 0000000000..929ba76bd6 --- /dev/null +++ b/crates/ployz/src/cancellation.rs @@ -0,0 +1,156 @@ +//! Process-lifetime signal ownership for commands spanning synchronous and async work. + +use tokio_util::sync::CancellationToken; + +use crate::failure::Failure as Error; + +/// Observe interrupts until this CLI process exits; every phase shares one token. +/// +/// # Errors +/// Fails before command work if signals or the listener thread cannot be installed. +pub(crate) fn listen() -> Result { + static SIGNALS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + SIGNALS + .get_or_init(install) + .as_ref() + .cloned() + .map_err(|error| Error::usage(format!("listen for command cancellation: {error}"))) +} + +fn install() -> std::io::Result { + let mut signals = signal_hook::iterator::Signals::new([ + signal_hook::consts::SIGINT, + signal_hook::consts::SIGTERM, + ])?; + let cancellation = CancellationToken::new(); + let cancelled = cancellation.clone(); + std::thread::Builder::new() + .name("command-signals".into()) + .spawn(move || { + for _ in signals.forever() { + cancelled.cancel(); + } + })?; + Ok(cancellation) +} + +/// Cancel read-only work without abandoning mutation or cleanup futures. +/// +/// # Errors +/// Reports cancellation or the read's own failure. +pub(crate) async fn read( + cancellation: &CancellationToken, + work: impl std::future::Future>, +) -> Result { + tokio::select! { + biased; + () = cancellation.cancelled() => Err(Error::usage("command cancelled")), + result = work => result, + } +} + +/// Read a prompt response without holding command cancellation behind stdin. +/// +/// # Errors +/// Reports cancellation, input errors, or failure to start the input thread. +pub(crate) async fn read_line( + cancellation: &CancellationToken, + mut input: impl std::io::BufRead + Send + 'static, +) -> Result { + read(cancellation, async { + let (sender, receiver) = tokio::sync::oneshot::channel(); + // ponytail: a cancelled terminal read can linger until CLI exit; use + // cancellable OS I/O if interactive commands become reusable. + std::thread::Builder::new() + .name("confirmation-input".into()) + .spawn(move || { + let mut line = String::new(); + let result = input.read_line(&mut line).map(|_| line); + let _ = sender.send(result); + })?; + Ok(receiver + .await + .map_err(|error| std::io::Error::other(error.to_string()))??) + }) + .await +} + +/// Subscribe to Ctrl-C for async-only commands already running on Tokio. +pub(crate) fn on_ctrl_c() -> CancellationToken { + let cancellation = CancellationToken::new(); + let signal = cancellation.clone(); + tokio::spawn(async move { + tokio::select! { + () = signal.cancelled() => {} + result = tokio::signal::ctrl_c() => if result.is_ok() { + signal.cancel(); + } + } + }); + cancellation +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[tokio::test] + async fn cancellation_stops_a_prompt_with_no_input() { + let (input, _writer) = std::os::unix::net::UnixStream::pair().unwrap(); + let cancellation = CancellationToken::new(); + let response = read_line(&cancellation, std::io::BufReader::new(input)); + tokio::pin!(response); + tokio::select! { + result = &mut response => panic!("input unexpectedly completed: {result:?}"), + () = tokio::time::sleep(std::time::Duration::from_millis(20)) => {} + } + cancellation.cancel(); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), response) + .await + .unwrap(); + assert!(result.unwrap_err().to_string().contains("cancelled")); + } + + #[test] + fn interrupts_survive_the_handoff_to_an_async_phase() { + const CHILD: &str = "PLOYZ_COMMAND_SIGNAL_TEST"; + if let Ok(signal) = std::env::var(CHILD) { + let cancellation = listen().unwrap(); + // The synchronous phase has returned; no Tokio runtime exists yet. + Command::new("kill") + .args([&signal, &std::process::id().to_string()]) + .status() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !cancellation.is_cancelled() { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let next_phase = async { panic!("cancelled command admitted the next phase") }; + assert!(read::<()>(&cancellation, next_phase).await.is_err()); + // Cancellation is sticky across subscribers and cleanup can still finish. + assert!(listen().unwrap().is_cancelled()); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }); + return; + } + for signal in ["-INT", "-TERM"] { + let status = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "cancellation::tests::interrupts_survive_the_handoff_to_an_async_phase", + ]) + .env(CHILD, signal) + .status() + .unwrap(); + assert!(status.success(), "{signal}: {status}"); + } + } +} diff --git a/crates/ployz/src/compose/build.rs b/crates/ployz/src/compose/build.rs index 961cdaecee..a658e44cdb 100644 --- a/crates/ployz/src/compose/build.rs +++ b/crates/ployz/src/compose/build.rs @@ -569,7 +569,11 @@ impl CapturedBuild { /// # Errors /// Fails if the runner cannot execute the build or the result cannot be /// bound to the content it produced. - pub fn execute(&self, docker: Option<&Path>) -> Result, ComposeError> { + pub fn execute( + &self, + docker: Option<&Path>, + cancellation: &tokio_util::sync::CancellationToken, + ) -> Result, ComposeError> { let mut environment = self.environment.clone(); environment.insert( "HOME".into(), @@ -587,18 +591,21 @@ impl CapturedBuild { .to_string_lossy() .into_owned(), ); - let images = ployz_build::execute(&ployz_build::Request { - compose_file: Path::new("compose.yaml"), - working_dir: self.inputs.root(), - environment: &environment, - docker, - targets: &self.targets, - railpack: &self.railpack, - build_args: &self.options.build_args, - output: self.options.output, - no_cache: self.options.no_cache, - pull: self.options.pull, - }) + let images = ployz_build::execute( + &ployz_build::Request { + compose_file: Path::new("compose.yaml"), + working_dir: self.inputs.root(), + environment: &environment, + docker, + targets: &self.targets, + railpack: &self.railpack, + build_args: &self.options.build_args, + output: self.options.output, + no_cache: self.options.no_cache, + pull: self.options.pull, + }, + cancellation, + ) // BuildKit diagnoses its own failure; name the Builds it was running. .map_err(|source| ComposeError::Build { services: self @@ -632,8 +639,9 @@ pub fn execute_build( options: &BuildOptions, load: &LoadOptions, project: &mut ComposeProject, + cancellation: &tokio_util::sync::CancellationToken, ) -> Result, ComposeError> { - capture_build(plan, options, project)?.execute(load.docker.as_deref()) + capture_build(plan, options, project)?.execute(load.docker.as_deref(), cancellation) } /// Refuse a setting Ployz cannot pass on, naming it rather than dropping it. diff --git a/crates/ployz/src/deploy/apply.rs b/crates/ployz/src/deploy/apply.rs index 8ddce24740..483e17f6c0 100644 --- a/crates/ployz/src/deploy/apply.rs +++ b/crates/ployz/src/deploy/apply.rs @@ -82,12 +82,15 @@ async fn apply_spec( ); return Ok(()); } + let cancellation = crate::cancellation::on_ctrl_c(); + let _stop_listener = cancellation.clone().drop_guard(); finish( stream_confirm( client, &preview, format!("Running service {}", requested.name), Ink::detect(io::stdout()), + &cancellation, ) .await, &format!("Deployed to {context}"), @@ -155,10 +158,12 @@ pub(crate) async fn deploy_project( client: &mut Client, candidate: &CapturedCompose, builds: &[BuiltService], + cancellation: &CancellationToken, gate: ConfirmGate<'_>, ) -> Result<(), Failure> { - let machines = client.machines().await?; - let outcome = push_project_images(client, builds, &machines).await?; + let machines = + crate::cancellation::read(cancellation, async { Ok(client.machines().await?) }).await?; + let outcome = push_project_images(client, builds, &machines, cancellation).await?; print_pushed_images(&outcome); if !outcome.failures.is_empty() { return Err(Failure::usage(format!( @@ -166,10 +171,11 @@ pub(crate) async fn deploy_project( outcome.failures.join("; ") ))); } - let preview = plan_project(client, candidate, machines).await?; + let preview = + crate::cancellation::read(cancellation, plan_project(client, candidate, machines)).await?; println!("Captured candidate {}", candidate.id()); print_warnings(&preview); - confirm_and_execute(client, &preview, gate).await + confirm_and_execute(client, &preview, gate, cancellation).await } pub(crate) async fn deploy_scale( @@ -191,6 +197,8 @@ pub(crate) async fn deploy_scale( name: project_name, source: gate.project.source, }; + let cancellation = crate::cancellation::on_ctrl_c(); + let _stop_listener = cancellation.clone().drop_guard(); confirm_and_execute( client, &preview, @@ -199,6 +207,7 @@ pub(crate) async fn deploy_scale( context: gate.context, project: &project, }, + &cancellation, ) .await } @@ -207,6 +216,7 @@ async fn confirm_and_execute( client: &Client, preview: &DeployPlan, gate: ConfirmGate<'_>, + cancellation: &CancellationToken, ) -> Result<(), Failure> { let source = Some(gate.project.source.to_string()); print!( @@ -216,7 +226,13 @@ async fn confirm_and_execute( if preview.noop() { return Ok(()); } - if !gate.auto_confirm && !confirm(&render::confirm_prompt(gate.context))? { + if !gate.auto_confirm + && !crate::cancellation::read( + cancellation, + confirm(&render::confirm_prompt(gate.context), cancellation), + ) + .await? + { println!("No changes were made."); return Ok(()); } @@ -226,6 +242,7 @@ async fn confirm_and_execute( preview, format!("Deploying to {}", gate.context), Ink::detect(io::stdout()), + cancellation, ) .await, &format!("Deployed to {}", gate.context), @@ -250,12 +267,15 @@ pub(crate) async fn remove_project( if preview.noop() { return Ok(()); } + let cancellation = crate::cancellation::on_ctrl_c(); + let _stop_listener = cancellation.clone().drop_guard(); finish( stream_confirm( client, &preview, format!("Removing Project {name} from {context}"), Ink::detect(io::stdout()), + &cancellation, ) .await, &format!("Removed Project {name} from {context}"), @@ -269,15 +289,10 @@ async fn stream_confirm( preview: &DeployPlan, title: String, ink: Ink, + cancel: &CancellationToken, ) -> (DeployOutcome, ProgressPrinter) { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let cancel = CancellationToken::new(); - let abort = cancel.clone(); - let ctrl_c = tokio::spawn(async move { - let _ = tokio::signal::ctrl_c().await; - abort.cancel(); - }); - let execute = client.confirm(preview, &cancel, Some(tx)); + let execute = client.confirm(preview, cancel, Some(tx)); tokio::pin!(execute); let mut printer = ProgressPrinter::new(title, ink); let outcome = loop { @@ -295,7 +310,6 @@ async fn stream_confirm( } } }; - ctrl_c.abort(); (outcome, printer) } @@ -398,7 +412,7 @@ fn print_warnings(preview: &DeployPreview) { } } -fn confirm(prompt: &str) -> Result { +async fn confirm(prompt: &str, cancellation: &CancellationToken) -> Result { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { return Err(Failure::usage( "confirmation requires a terminal; pass --yes to continue", @@ -406,8 +420,8 @@ fn confirm(prompt: &str) -> Result { } print!("{prompt}"); io::stdout().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; + let input = + crate::cancellation::read_line(cancellation, io::BufReader::new(io::stdin())).await?; Ok(matches!(input.trim(), "y" | "Y" | "yes" | "YES")) } diff --git a/crates/ployz/src/deploy/pipeline.rs b/crates/ployz/src/deploy/pipeline.rs index 2ad3613c5b..fbc6a86629 100644 --- a/crates/ployz/src/deploy/pipeline.rs +++ b/crates/ployz/src/deploy/pipeline.rs @@ -281,11 +281,12 @@ pub(super) async fn push_project_images( client: &mut Client, builds: &[BuiltService], machines: &[MachineObservation], + cancellation: &CancellationToken, ) -> Result { let mut pushed = Vec::new(); let mut failures = Vec::new(); for service in builds { - match push_image(client, service, machines).await { + match push_image(client, service, machines, cancellation).await { Ok((images, service_failures)) => { pushed.extend(images); failures.extend(service_failures); @@ -528,6 +529,7 @@ async fn push_image( client: &mut Client, service: &BuiltService, machines: &[MachineObservation], + cancellation: &CancellationToken, ) -> Result<(Vec, Vec), PushError> { let targets = service .machines @@ -535,9 +537,15 @@ async fn push_image( .map(ToString::to_string) .collect::>(); // Deliver the content this command built, not whatever the tag now holds. - let result = - crate::image::push_using_machines(client, service.content(), None, &targets, machines) - .await?; + let result = crate::image::push_using_machines( + client, + service.content(), + None, + &targets, + machines, + cancellation, + ) + .await?; let pushed = result .successes .iter() diff --git a/crates/ployz/src/handlers/build.rs b/crates/ployz/src/handlers/build.rs index 8a60b6c7f6..a2091e566e 100644 --- a/crates/ployz/src/handlers/build.rs +++ b/crates/ployz/src/handlers/build.rs @@ -91,7 +91,8 @@ pub(super) fn run(matches: &ArgMatches) -> Result<(), Error> { report_remote(outcome) }); } - let built = execute_build(&plan, &options, &load, &mut project)?; + let cancellation = crate::cancellation::listen()?; + let built = execute_build(&plan, &options, &load, &mut project, &cancellation)?; match options.output { Output::Validate => { println!("Validated the selected builds. No image was produced."); @@ -123,11 +124,20 @@ pub(super) fn run(matches: &ArgMatches) -> Result<(), Error> { ); let runtime = runtime()?; let failures = runtime.block_on(async { - let mut client = connect_client(matches, context).await?; + let mut client = + crate::cancellation::read(&cancellation, connect_client(matches, context)).await?; let mut failures = Vec::new(); for service in &built { let targets = push_targets(&explicit, &service.machines); - match crate::image::push(&mut client, service.content(), None, &targets).await { + match crate::image::push( + &mut client, + service.content(), + None, + &targets, + &cancellation, + ) + .await + { Ok(result) => failures.extend(report_push(&service.image, result)), Err(error) => failures.push(push_failure(&service.image, error)?), } diff --git a/crates/ployz/src/handlers/deploy.rs b/crates/ployz/src/handlers/deploy.rs index 2e3f3644da..5aeec25530 100644 --- a/crates/ployz/src/handlers/deploy.rs +++ b/crates/ployz/src/handlers/deploy.rs @@ -56,13 +56,18 @@ pub(super) fn deploy(root: &ArgMatches) -> Result<(), Error> { let skip_health_monitor = matches.get_flag("skip-health"); let mut options = plan_options(force_recreate, skip_health_monitor); options.selected = selected_attempts(&project, &string_values(matches, "service"))?; - let (candidate, builds) = prepare_deploy(matches, &load, project, &resolved, options)?; + let cancellation = crate::cancellation::listen()?; + let (candidate, builds) = + prepare_deploy(matches, &load, project, &resolved, options, &cancellation)?; runtime()?.block_on(async { - let mut client = connect_client(root, context.as_deref()).await?; + let mut client = + crate::cancellation::read(&cancellation, connect_client(root, context.as_deref())) + .await?; deploy_project( &mut client, &candidate, &builds, + &cancellation, crate::deploy::ConfirmGate { auto_confirm: yes, context: context.as_deref().unwrap_or("default"), @@ -257,6 +262,7 @@ fn prepare_deploy( mut project: ComposeProject, resolved: &ResolvedProject, options: ployz_core::PlanOptions, + cancellation: &tokio_util::sync::CancellationToken, ) -> Result<(CapturedCompose, Vec), Error> { let selected = string_values(matches, "service"); for warning in &project.warnings { @@ -294,7 +300,7 @@ fn prepare_deploy( // Every required Build finishes before any application change begins. let built = match captured_build { Some(build) => build - .execute(load.docker.as_deref()) + .execute(load.docker.as_deref(), cancellation) .map_err(crate::deploy::DeployError::from)?, None => Vec::new(), }; diff --git a/crates/ployz/src/handlers/deploy_tests.rs b/crates/ployz/src/handlers/deploy_tests.rs index fb37d759e8..e8e5c4c649 100644 --- a/crates/ployz/src/handlers/deploy_tests.rs +++ b/crates/ployz/src/handlers/deploy_tests.rs @@ -342,7 +342,15 @@ fn deploy_preparation_captures_resolved_input_and_selection_before_planning() { ..Default::default() }; std::fs::write(&source, yaml.replace("captured", "later-edit")).unwrap(); - let (candidate, builds) = prepare_deploy(matches, &load, project, &resolved, options).unwrap(); + let (candidate, builds) = prepare_deploy( + matches, + &load, + project, + &resolved, + options, + &tokio_util::sync::CancellationToken::new(), + ) + .unwrap(); assert!(builds.is_empty()); assert_eq!( std::fs::read_to_string(directory.join("calls")).unwrap(), @@ -415,6 +423,7 @@ fn a_failed_build_leaves_the_deployment_unattempted() { project, &resolved, ployz_core::PlanOptions::default(), + &tokio_util::sync::CancellationToken::new(), ) { Ok(_) => panic!("a failed build was admitted for deployment"), Err(error) => error.to_string(), diff --git a/crates/ployz/src/handlers/image.rs b/crates/ployz/src/handlers/image.rs index 996b348e81..787d3c7e7c 100644 --- a/crates/ployz/src/handlers/image.rs +++ b/crates/ployz/src/handlers/image.rs @@ -121,10 +121,14 @@ pub(super) fn push(matches: &ArgMatches) -> Result<(), Error> { let image = leaf .get_one::("image") .ok_or_else(|| Error::usage("image is required"))?; + let cancellation = crate::cancellation::listen()?; let result = runtime()?.block_on(async { - let mut client = connect_client( - matches, - leaf.get_one::("context").map(String::as_str), + let mut client = crate::cancellation::read( + &cancellation, + connect_client( + matches, + leaf.get_one::("context").map(String::as_str), + ), ) .await?; Ok::<_, Error>( @@ -133,6 +137,7 @@ pub(super) fn push(matches: &ArgMatches) -> Result<(), Error> { crate::image::ImageContent::tagged(image), leaf.get_one::("platform").map(String::as_str), &string_values(leaf, "machine"), + &cancellation, ) .await?, ) diff --git a/crates/ployz/src/handlers/mod.rs b/crates/ployz/src/handlers/mod.rs index 3a9b418380..612e0475cd 100644 --- a/crates/ployz/src/handlers/mod.rs +++ b/crates/ployz/src/handlers/mod.rs @@ -1,8 +1,8 @@ use std::{future::Future, path::Path, pin::Pin}; +use crate::cancellation::on_ctrl_c as cancellation_on_ctrl_c; use clap::{ArgMatches, Command}; use clap_complete::{Shell, generate}; -use tokio_util::sync::CancellationToken; use crate::failure::Failure; @@ -106,20 +106,6 @@ fn required(matches: &ArgMatches, name: &str) -> Result { .ok_or_else(|| Error::usage(format!("{name} is required"))) } -fn cancellation_on_ctrl_c() -> CancellationToken { - let cancellation = CancellationToken::new(); - let signal = cancellation.clone(); - tokio::spawn(async move { - tokio::select! { - () = signal.cancelled() => {} - result = tokio::signal::ctrl_c() => if result.is_ok() { - signal.cancel(); - } - } - }); - cancellation -} - fn runtime() -> Result { Ok(tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/crates/ployz/src/image.rs b/crates/ployz/src/image.rs index c234f9ff4d..3322733bec 100644 --- a/crates/ployz/src/image.rs +++ b/crates/ployz/src/image.rs @@ -1,7 +1,6 @@ use std::{ ffi::OsStr, future::Future, - pin::Pin, process::{Output, Stdio}, }; @@ -53,8 +52,6 @@ pub enum PushError { UnsupportedPlatform(String), #[error("image push cancelled")] Cancelled, - #[error("listen for image-push cancellation: {0}")] - Cancellation(#[source] std::io::Error), #[error("image '{0}' not found locally")] ImageNotFound(String), #[error("Machine target selection failed: {0}")] @@ -100,26 +97,25 @@ pub enum PushError { impl PushError { pub(crate) fn is_cancellation(&self) -> bool { - matches!(self, Self::Cancelled | Self::Cancellation(_)) + matches!(self, Self::Cancelled) || matches!(self, Self::CleanupAfter { primary, .. } if primary.is_cancellation()) } } -struct Cancellation { - signal: Pin>>>, +struct Cancellation<'a> { + token: &'a tokio_util::sync::CancellationToken, } -impl Cancellation { - fn new() -> Self { - Self { - signal: Box::pin(tokio::signal::ctrl_c()), - } +impl<'a> Cancellation<'a> { + fn new(token: &'a tokio_util::sync::CancellationToken) -> Self { + Self { token } } async fn race(&mut self, future: impl Future) -> Result { tokio::select! { + biased; + () = self.token.cancelled() => Err(PushError::Cancelled), output = future => Ok(output), - result = self.signal.as_mut() => Err(cancellation_error(result)), } } } @@ -156,11 +152,20 @@ pub async fn push( content: ImageContent<'_>, platform: Option<&str>, selectors: &[String], + cancellation: &tokio_util::sync::CancellationToken, ) -> Result, PushError> { - let machines = client - .call::(ListMachinesRequest {}, None) - .await?; - push_using_machines(client, content, platform, selectors, &machines.machines).await + let machines = Cancellation::new(cancellation) + .race(client.call::(ListMachinesRequest {}, None)) + .await??; + push_using_machines( + client, + content, + platform, + selectors, + &machines.machines, + cancellation, + ) + .await } pub(crate) async fn push_using_machines( @@ -169,8 +174,9 @@ pub(crate) async fn push_using_machines( platform: Option<&str>, selectors: &[String], machines: &[ployz_core::MachineObservation], + cancellation: &tokio_util::sync::CancellationToken, ) -> Result, PushError> { - let mut cancellation = Cancellation::new(); + let mut cancellation = Cancellation::new(cancellation); // TODO: without an explicit platform, Docker chooses what to push; target platforms are not inferred. let platform = platform.map(validated_platform).transpose()?; let image = content.published; @@ -295,7 +301,7 @@ async fn push_to_machine( platform: Option<&str>, machine: &Machine, mode: ProxyMode, - cancellation: &mut Cancellation, + cancellation: &mut Cancellation<'_>, ) -> Result { // EnsureImageIngest is idempotent; a dropped RPC must not fail the Machine. let opened = cancellation @@ -322,7 +328,7 @@ async fn pull_on_machine( image: &str, machine: &Machine, source: ImageIngestDestination, - cancellation: &mut Cancellation, + cancellation: &mut Cancellation<'_>, ) -> Result<(), PushError> { cancellation .race(client.call::( @@ -451,7 +457,7 @@ impl PushSession { mode: ProxyMode, content: ImageContent<'_>, platform: Option<&str>, - cancellation: &mut Cancellation, + cancellation: &mut Cancellation<'_>, ) -> Result<(), PushError> { let mut session = Self { proxy: ImageProxy::open(mode, cancellation).await?, @@ -575,13 +581,6 @@ impl PushSession { } } -fn cancellation_error(result: std::io::Result<()>) -> PushError { - match result { - Ok(()) => PushError::Cancelled, - Err(error) => PushError::Cancellation(error), - } -} - fn validate_push_reference(image: &str) -> Result<(), PushError> { let reference = image .parse::() diff --git a/crates/ployz/src/image/proxy.rs b/crates/ployz/src/image/proxy.rs index d703ece64f..30df6b6586 100644 --- a/crates/ployz/src/image/proxy.rs +++ b/crates/ployz/src/image/proxy.rs @@ -74,7 +74,7 @@ pub(super) struct ImageProxy { impl ImageProxy { pub(super) async fn open( mode: ProxyMode, - cancellation: &mut Cancellation, + cancellation: &mut Cancellation<'_>, ) -> Result { let mut proxy = Self { listener: Listener::bind(mode).await?, @@ -482,6 +482,25 @@ mod tests { } } + #[tokio::test] + async fn an_interrupt_from_the_build_prevents_image_push_work() { + let mut client = client(FailingDial { + attempts: Arc::new(AtomicUsize::new(0)), + error: || ConnectError::from(std::io::Error::other("must not dial")), + }); + let cancellation = tokio_util::sync::CancellationToken::new(); + cancellation.cancel(); + let result = super::super::push( + &mut client, + super::super::ImageContent::tagged("api:v1"), + None, + &[], + &cancellation, + ) + .await; + assert!(matches!(result, Err(PushError::Cancelled)), "{result:?}"); + } + #[tokio::test] async fn image_proxy_holds_docker_connection_across_a_dropped_machine_dial() { let echo = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -493,7 +512,8 @@ mod tests { stream.write_all(&buffer).await.unwrap(); }); - let mut cancellation = super::super::Cancellation::new(); + let token = tokio_util::sync::CancellationToken::new(); + let mut cancellation = super::super::Cancellation::new(&token); let mut proxy = ImageProxy::open(ProxyMode::Native, &mut cancellation) .await .unwrap(); diff --git a/crates/ployz/src/lib.rs b/crates/ployz/src/lib.rs index 38a9166450..6189d30977 100644 --- a/crates/ployz/src/lib.rs +++ b/crates/ployz/src/lib.rs @@ -1,3 +1,4 @@ +mod cancellation; pub mod changes; pub mod cli; mod cloud_enroll; diff --git a/crates/ployz/tests/build.rs b/crates/ployz/tests/build.rs index cf626986d1..c5e849bdb6 100644 --- a/crates/ployz/tests/build.rs +++ b/crates/ployz/tests/build.rs @@ -196,7 +196,9 @@ fn captured_build_preserves_sources_configuration_and_builder_flags() { fs::remove_file(root.join("Dockerfile")).unwrap(); fs::remove_file(root.join("Dockerfile.dockerignore")).unwrap(); project.builds.clear(); - let outcome = build.execute(Some(&docker)).unwrap(); + let outcome = build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let calls = fs::read_to_string(calls).unwrap(); let bake = calls .lines() @@ -357,13 +359,13 @@ fn built_images_bind_to_exact_content_after_tag_reuse() { let first = capture_build(&plan, &options, &mut project) .unwrap() - .execute(Some(&docker)) + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) .unwrap(); // A later Build moves the same requested tag onto different content. fs::write(root.join("digest"), SECOND_CONTENT).unwrap(); let second = capture_build(&plan, &options, &mut project) .unwrap() - .execute(Some(&docker)) + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) .unwrap(); let (first, second) = (one_built(first), one_built(second)); @@ -404,7 +406,7 @@ fn an_image_the_store_does_not_hold_is_refused_as_a_result() { let plan = plan_build(&project, &options).unwrap(); let build = capture_build(&plan, &options, &mut project).unwrap(); - let error = match build.execute(Some(&docker)) { + let error = match build.execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) { Ok(outcome) => panic!("substituted content was reported as built: {outcome:?}"), Err(error) => error.to_string(), }; @@ -458,7 +460,7 @@ fn content_holding_several_platforms_is_refused_however_it_was_requested() { let plan = plan_build(&project, &options).unwrap(); let build = capture_build(&plan, &options, &mut project).unwrap(); - let error = match build.execute(Some(&docker)) { + let error = match build.execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) { Ok(built) => panic!("a multi-platform image was reported as built: {built:?}"), Err(error) => error.to_string(), }; @@ -629,7 +631,9 @@ secrets: }; let plan = plan_build(&project, &options).unwrap(); let build = capture_build(&plan, &options, &mut project).unwrap(); - build.execute(Some(&docker)).unwrap(); + build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let config: serde_norway::Value = serde_norway::from_str(&fs::read_to_string(root.join("override.yaml")).unwrap()).unwrap(); assert_eq!( diff --git a/crates/ployz/tests/build/capture.rs b/crates/ployz/tests/build/capture.rs index 6831908369..8490221126 100644 --- a/crates/ployz/tests/build/capture.rs +++ b/crates/ployz/tests/build/capture.rs @@ -44,7 +44,9 @@ fn capture_rejects_links_outside_the_captured_source() { let plan = plan_build(&project, &options).unwrap(); let build = capture_build(&plan, &options, &mut project).unwrap(); fs::remove_dir_all(root.join("src")).unwrap(); - build.execute(Some(&docker)).unwrap(); + build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let config: serde_norway::Value = serde_norway::from_str(&fs::read_to_string(root.join("override.yaml")).unwrap()).unwrap(); let context = config @@ -126,7 +128,9 @@ fn resolved_file_credentials_stay_private_across_captures() { let docker = root.join("docker"); write_docker(&docker, &root); let build = capture_build(&plan, &options, &mut project).unwrap(); - build.execute(Some(&docker)).unwrap(); + build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let config: serde_norway::Value = serde_norway::from_str(&fs::read_to_string(root.join("override.yaml")).unwrap()).unwrap(); let staged = root.join("relocated"); @@ -147,7 +151,9 @@ fn resolved_file_credentials_stay_private_across_captures() { ); fs::write(root.join("src/token"), "later-provider-value").unwrap(); let second = capture_build(&plan, &options, &mut project).unwrap(); - second.execute(Some(&docker)).unwrap(); + second + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); assert_eq!( project.services["api"].container.environment["TOKEN"], "private-file-token" @@ -188,7 +194,9 @@ fn builds_use_captured_explicit_registry_credentials_and_proxies() { fs::write(root.join("auth/config.json"), "changed after capture").unwrap(); let docker = root.join("docker"); write_docker(&docker, &root); - build.execute(Some(&docker)).unwrap(); + build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let captured: serde_json::Value = serde_json::from_str(&fs::read_to_string(root.join("docker-config.json")).unwrap()) .unwrap(); @@ -251,7 +259,9 @@ secrets: fs::remove_dir_all(root.join("src")).unwrap(); let docker = root.join("docker"); write_docker(&docker, &root); - build.execute(Some(&docker)).unwrap(); + build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let config: serde_norway::Value = serde_norway::from_str(&fs::read_to_string(root.join("override.yaml")).unwrap()).unwrap(); let context = |service: &str| { @@ -345,7 +355,9 @@ fn authored_configuration_follows_dockerignore() { let build = capture_build(&plan, &options, &mut project).unwrap(); let docker = root.join("docker"); write_docker(&docker, &root); - build.execute(Some(&docker)).unwrap(); + build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let config: serde_norway::Value = serde_norway::from_str(&fs::read_to_string(root.join("override.yaml")).unwrap()).unwrap(); let context = root.join("relocated").join( @@ -444,7 +456,7 @@ fn default_platform_is_captured_and_verified_unless_compose_overrides_it() { let build = capture_build(&plan, &options, &mut project).unwrap(); fs::write(root.join(".env"), "DOCKER_DEFAULT_PLATFORM=linux/amd64\n").unwrap(); // The recording executor reports AMD64: an ARM64 request must reject it. - let result = build.execute(Some(&docker)); + let result = build.execute(Some(&docker), &tokio_util::sync::CancellationToken::new()); let expected = if declared.is_empty() { assert!( result @@ -531,7 +543,9 @@ fn ssh_docker_hosts_and_git_contexts_keep_the_captured_agent_socket() { let plan = plan_build(&project, &options).unwrap(); let build = capture_build(&plan, &options, &mut project).unwrap(); fs::write(root.join(".env"), "SSH_AUTH_SOCK=/tmp/changed-agent.sock\n").unwrap(); - build.execute(Some(&docker)).unwrap(); + build + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(); let environment = fs::read_to_string(root.join("docker-environment")).unwrap(); assert_eq!(environment.lines().nth(2), Some(expected), "{host}"); } diff --git a/crates/ployz/tests/build/railpack.rs b/crates/ployz/tests/build/railpack.rs index 82c7a4b8fb..90edae80df 100644 --- a/crates/ployz/tests/build/railpack.rs +++ b/crates/ployz/tests/build/railpack.rs @@ -112,11 +112,25 @@ secrets: let mut hashes = Vec::new(); for (value, capture) in captures { fs::write(root.join("preparation-fails"), "").unwrap(); - let failed = capture.execute(Some(&docker)).unwrap_err().to_string(); + let failed = capture + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap_err() + .to_string(); assert!(failed.contains("Railpack preparation"), "{failed}"); fs::remove_file(root.join("preparation-fails")).unwrap(); - let result = one_built(capture.execute(Some(&docker)).unwrap()); - assert_eq!(one_built(capture.execute(Some(&docker)).unwrap()), result); + let result = one_built( + capture + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(), + ); + assert_eq!( + one_built( + capture + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap() + ), + result + ); assert!(!format!("{result:?}").contains("private-token")); let received = root.join("received"); assert_eq!( @@ -236,15 +250,24 @@ fn multi_platform_build_requires_complete_content_and_cleans_up_failed_attempts( let plan = plan_build(&project, &options).unwrap(); let captured = capture_build(&plan, &options, &mut project).unwrap(); for (failure, diagnostic) in [ + ("create-fails", "assembly image unavailable"), + ( + "create-fails-after-creation", + "assembly create response lost", + ), ("assembly-fails", "assemble Railpack"), ("import-fails", "load assembled"), ("missing-content", "verify Railpack platform content"), ("solve-fails", "the build"), - ("worker-missing", "reported no capabilities"), + ("worker-missing", "reported no capability"), ] { fs::write(root.join(failure), "").unwrap(); - let error = captured.execute(Some(&docker)).unwrap_err().to_string(); + let error = captured + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap_err() + .to_string(); assert!(error.contains(diagnostic), "{error}"); + assert!(!error.contains("cleanup could not be confirmed"), "{error}"); assert!(!root.join("assembly").exists()); assert!(!root.join("builder").exists()); let capture_root = fs::read_to_string(root.join("capture-root")).unwrap(); @@ -255,12 +278,20 @@ fn multi_platform_build_requires_complete_content_and_cleans_up_failed_attempts( ); fs::remove_file(root.join(failure)).unwrap(); } - let first = one_built(captured.execute(Some(&docker)).unwrap()); + let first = one_built( + captured + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(), + ); assert_eq!(first.built.platforms, ["linux/amd64", "linux/arm64"]); assert_eq!(first.built.reference, FIRST_CONTENT); assert_eq!(first.built.tags, ["example.test/api:check"]); fs::write(root.join("digest"), SECOND_CONTENT).unwrap(); - let second = one_built(captured.execute(Some(&docker)).unwrap()); + let second = one_built( + captured + .execute(Some(&docker), &tokio_util::sync::CancellationToken::new()) + .unwrap(), + ); assert_ne!(first.built.reference, second.built.reference); assert_eq!(first.built.tags, second.built.tags); fs::remove_dir_all(root).unwrap(); @@ -289,9 +320,16 @@ case "$*" in if [ -f "$root/solve-fails" ]; then pwd > "$root/capture-root"; exit 1; fi ;; 'buildx ls '*) if [ -f "$root/worker-missing" ]; then printf '{{}}'; exit 0; fi ;; - 'create --name '*-assemble*) touch "$root/assembly" ;; + 'create --name '*-assemble*) + pwd > "$root/capture-root" + if [ -f "$root/cancel-create" ]; then touch "$root/active"; exec sleep 60; fi + if [ -f "$root/create-fails" ]; then printf 'assembly image unavailable' >&2; exit 1; fi + touch "$root/assembly" + if [ -f "$root/cancel-create-after-creation" ]; then touch "$root/active"; exec sleep 60; fi + if [ -f "$root/create-fails-after-creation" ]; then printf 'assembly create response lost' >&2; exit 1; fi ;; 'rm --force '*-assemble) if [ -f "$root/cleanup-fails" ] && [ -f "$root/active" ]; then printf 'assembly still running' >&2; exit 1; fi + if [ ! -f "$root/assembly" ]; then printf "Error response from daemon: No such container: %s" "$3" >&2; exit 1; fi rm -f "$root/assembly" ;; 'start '*-assemble) exit 0 ;; 'exec '*-assemble' regctl index create '*) @@ -323,8 +361,17 @@ fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() let options = BuildOptions::default(); let plan = plan_build(&project, &options).unwrap(); let captured = capture_build(&plan, &options, &mut project).unwrap(); + let cancellation = tokio_util::sync::CancellationToken::new(); + let cancelled = cancellation.clone(); + let marker = root.join("interrupt"); + std::thread::spawn(move || { + while !marker.exists() { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + cancelled.cancel(); + }); let error = captured - .execute(Some(&root.join("docker"))) + .execute(Some(&root.join("docker")), &cancellation) .unwrap_err() .to_string(); if root.join("cleanup-fails").exists() { @@ -350,7 +397,13 @@ fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() let root = std::env::temp_dir().join(format!("ployz-railpack-cancel-{}", std::process::id())); fs::create_dir_all(&root).unwrap(); write_multi_docker(&root); - for (phase, cleanup_fails) in [("solve", false), ("assembly", false), ("assembly", true)] { + for (phase, cleanup_fails) in [ + ("create", false), + ("create-after-creation", false), + ("solve", false), + ("assembly", false), + ("assembly", true), + ] { if cleanup_fails { fs::write(root.join("cleanup-fails"), "").unwrap(); } @@ -369,13 +422,7 @@ fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() } std::thread::sleep(std::time::Duration::from_millis(50)); } - assert!( - Command::new("kill") - .args(["-INT", &child.id().to_string()]) - .status() - .unwrap() - .success() - ); + fs::write(root.join("interrupt"), "").unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); loop { if let Some(status) = child.try_wait().unwrap() { @@ -389,6 +436,7 @@ fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() } std::thread::sleep(std::time::Duration::from_millis(50)); } + fs::remove_file(root.join("interrupt")).unwrap(); fs::remove_file(marker).unwrap(); fs::remove_file(root.join("active")).unwrap(); } diff --git a/crates/ployz/tests/build_layer3.rs b/crates/ployz/tests/build_layer3.rs index 8e662c0361..7f07e63d43 100644 --- a/crates/ployz/tests/build_layer3.rs +++ b/crates/ployz/tests/build_layer3.rs @@ -91,7 +91,14 @@ fn compose_build_basic_pushes_only_buildable_resolved_images() { ); cleanup.images = plan.iter().map(|service| service.image.clone()).collect(); - execute_build(&plan, &options, &load, &mut project).unwrap(); + execute_build( + &plan, + &options, + &load, + &mut project, + &tokio_util::sync::CancellationToken::new(), + ) + .unwrap(); for image in &cleanup.images { // Publication retains no local copy, so the registry is the only source. let _ = Command::new("docker").args(["image", "rm", image]).status(); @@ -163,7 +170,16 @@ fn local_dockerfile_build_loads_a_runnable_image_and_reuses_its_retained_cache() let options = BuildOptions::default(); let plan = plan_build(&project, &options).unwrap(); - let first = one_built(execute_build(&plan, &options, &load, &mut project).unwrap()); + let first = one_built( + execute_build( + &plan, + &options, + &load, + &mut project, + &tokio_util::sync::CancellationToken::new(), + ) + .unwrap(), + ); assert_eq!(first.image, image); assert!(first.built.tags.iter().any(|tag| tag.ends_with(&image))); assert_eq!(first.built.platforms, [host_platform()]); @@ -184,7 +200,16 @@ fn local_dockerfile_build_loads_a_runnable_image_and_reuses_its_retained_cache() command(["volume", "inspect", &cache_volume()]); // A second Build recreates the builder and reuses that cache. - let rebuilt = one_built(execute_build(&plan, &options, &load, &mut project).unwrap()); + let rebuilt = one_built( + execute_build( + &plan, + &options, + &load, + &mut project, + &tokio_util::sync::CancellationToken::new(), + ) + .unwrap(), + ); assert_eq!( output(["run", "--rm", &rebuilt.built.reference, "cat", "/built-at"]), stamp, @@ -313,7 +338,11 @@ RUN --mount=type=secret,id=token test ! -e /source/token && test ! -e /source/ig "runtime" ); fs::remove_dir_all(&root).unwrap(); - let built = one_built(captured.execute(None).unwrap()); + let built = one_built( + captured + .execute(None, &tokio_util::sync::CancellationToken::new()) + .unwrap(), + ); assert_eq!( output(["run", "--rm", &built.built.reference, "cat", "/values"]), "cli\nservice\nprivate-$VALUE\n$LATER\nprivate-$VALUE\n" @@ -423,7 +452,16 @@ async fn railpack_build_and_deploy_preserve_variables_cache_and_failure_boundari ..Default::default() }; let plan = plan_build(&project, &options).unwrap(); - let built = one_built(execute_build(&plan, &options, &load, &mut project).unwrap()); + let built = one_built( + execute_build( + &plan, + &options, + &load, + &mut project, + &tokio_util::sync::CancellationToken::new(), + ) + .unwrap(), + ); let result = output([ "run", "--rm", @@ -611,7 +649,16 @@ fn railpack_builds_complete_amd64_arm64_content_and_runs_without_registry_fallba let mut project = load_project(&load).unwrap(); let options = BuildOptions::default(); let plan = plan_build(&project, &options).unwrap(); - let built = one_built(execute_build(&plan, &options, &load, &mut project).unwrap()); + let built = one_built( + execute_build( + &plan, + &options, + &load, + &mut project, + &tokio_util::sync::CancellationToken::new(), + ) + .unwrap(), + ); cleanup.images.push(built.built.reference.clone()); assert_eq!(built.built.platforms, ["linux/amd64", "linux/arm64"]); assert!(built.built.tags.contains(&image)); diff --git a/crates/ployz/tests/compose_cli.rs b/crates/ployz/tests/compose_cli.rs index 025c60d513..d14e01e429 100644 --- a/crates/ployz/tests/compose_cli.rs +++ b/crates/ployz/tests/compose_cli.rs @@ -283,7 +283,14 @@ esac ..Default::default() }; let plan = plan_build(&project, &options).unwrap(); - execute_build(&plan, &options, &load, &mut project).unwrap(); + execute_build( + &plan, + &options, + &load, + &mut project, + &tokio_util::sync::CancellationToken::new(), + ) + .unwrap(); let calls = fs::read_to_string(calls).unwrap(); assert!(calls.lines().any(|argument| argument == "--check")); assert!(calls.lines().any(|argument| argument == "api")); diff --git a/crates/ployzd/src/build/tests.rs b/crates/ployzd/src/build/tests.rs index 2bb7d1565b..b6d7e4052b 100644 --- a/crates/ployzd/src/build/tests.rs +++ b/crates/ployzd/src/build/tests.rs @@ -210,10 +210,7 @@ async fn captured_build_crosses_owned_rpc_and_returns_only_remote_image_evidence let [image] = images.as_slice() else { panic!("expected one image: {images:?}") }; - assert_eq!( - image.reference, - format!("sha256:{}", "1".repeat(64)) - ); + assert_eq!(image.reference, format!("sha256:{}", "1".repeat(64))); assert_eq!(image.platforms, ["linux/amd64"]); assert_eq!( fs::read_to_string(fixture.root.join("received-payload")).unwrap(), From aae7c2d4226272fbde27cbb953413d6ccbfa2913 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 02:21:33 +0000 Subject: [PATCH 3/8] Preserve assembled image evidence and output failure stages --- crates/ployz-build/src/index.rs | 46 +++++++++----- crates/ployz-build/src/lib.rs | 4 -- crates/ployz-build/src/received_recipe.rs | 8 +-- crates/ployz/src/image.rs | 8 +-- crates/ployz/tests/build/railpack.rs | 76 ++++++++++++++++++++++- 5 files changed, 110 insertions(+), 32 deletions(-) diff --git a/crates/ployz-build/src/index.rs b/crates/ployz-build/src/index.rs index d0a80b0903..f08ae5a2f3 100644 --- a/crates/ployz-build/src/index.rs +++ b/crates/ployz-build/src/index.rs @@ -3,8 +3,8 @@ //! https://github.com/getployz/ployz2/blob/c3ca5519a4607256ffb28052d77a1c7d89f1bbe1/prototypes/railpack-transfer/FINDINGS.md use crate::{ - BuildError, BuiltImage, Docker, Planned, Request, Streams, TargetMetadata, bake_arguments, - builder::Builder, builder_name, + BuildError, BuiltImage, Docker, Planned, Progress, Request, Stage, Streams, TargetEvidence, + TargetMetadata, bake_arguments, builder::Builder, builder_name, }; use std::{collections::BTreeMap, path::Path}; @@ -35,11 +35,18 @@ pub(crate) fn build( "infinity", ], |name| { - docker.run("start image assembly", &["start", name], Streams::Captured)?; + docker + .run("start image assembly", &["start", name], Streams::Captured) + .map_err(|error| error.at(Stage::Preparation))?; + let output = |action, arguments: &[&str], streams| { + docker + .run(action, arguments, streams) + .map_err(|error| error.at(Stage::Output)) + }; let regctl = |action, args: &[&str]| { let mut command = vec!["exec", name, "regctl"]; command.extend_from_slice(args); - docker.run(action, &command, Streams::Captured) + output(action, &command, Streams::Captured) }; let mut refs = Vec::new(); let mut tags = Vec::new(); @@ -57,18 +64,18 @@ pub(crate) fn build( args.extend(["--set".into(), setting]); } if let Some(progress) = docker.progress { - progress(crate::Progress::Stage(crate::Stage::Building)); + progress(Progress::Stage(Stage::Building)); } builder.run(&args, || { if let Some(progress) = docker.progress { - progress(crate::Progress::Target { + progress(Progress::Target { name: target.target.name.clone(), - outcome: crate::TargetEvidence::Unknown, + outcome: TargetEvidence::Unknown, }); } })?; if let Some(progress) = docker.progress { - progress(crate::Progress::Stage(crate::Stage::Output)); + progress(Progress::Stage(Stage::Output)); } let results: BTreeMap = serde_json::from_slice(&std::fs::read(&metadata).map_err(result_error)?) @@ -84,7 +91,7 @@ pub(crate) fn build( "Railpack solves reported different image tags".into(), )); } - docker.run( + output( "copy Railpack variant for assembly", &[ "cp", @@ -133,7 +140,7 @@ pub(crate) fn build( ], )?; let archive = request.working_dir.join("private/railpack/complete.tar"); - docker.run( + output( "copy assembled Railpack image", &[ "cp", @@ -142,13 +149,13 @@ pub(crate) fn build( ], Streams::Captured, )?; - docker.run( + output( "load assembled Railpack image", &["image", "load", "--input", &archive.to_string_lossy()], Streams::Captured, )?; let reference = digest.to_owned(); - let inspected = docker.run( + let inspected = output( "inspect assembled Railpack image", &["image", "inspect", &reference, "--format", "{{json .}}"], Streams::Captured, @@ -165,25 +172,32 @@ pub(crate) fn build( for platform in &target.target.platforms { // Export forces Docker to read the manifest, config and every layer. // Inspect alone can list an index variant whose content is absent. - docker.run( + output( "verify Railpack platform content", &["image", "save", "--platform", platform, &reference], Streams::Discarded, )?; } for tag in &tags { - docker.run( + output( "tag completed Railpack image", &["image", "tag", &reference, tag], Streams::Captured, )?; } - Ok(BuiltImage { + let image = BuiltImage { reference, tags, platforms: target.target.platforms.clone(), location: docker.location(), - }) + }; + if let Some(progress) = docker.progress { + progress(Progress::Target { + name: target.target.name.clone(), + outcome: TargetEvidence::Image(image.clone()), + }); + } + Ok(image) }, ) } diff --git a/crates/ployz-build/src/lib.rs b/crates/ployz-build/src/lib.rs index 5244cf4d76..8595cccc6d 100644 --- a/crates/ployz-build/src/lib.rs +++ b/crates/ployz-build/src/lib.rs @@ -351,10 +351,6 @@ pub fn execute_admitted( } for target in &multi { let image = index::build(&docker, &builder, request, target, overrides.as_deref())?; - progress(Progress::Target { - name: target.target.name.clone(), - outcome: TargetEvidence::Image(image.clone()), - }); images.push(image); } let mut by_name = ordinary diff --git a/crates/ployz-build/src/received_recipe.rs b/crates/ployz-build/src/received_recipe.rs index f7406d6f1d..470668707d 100644 --- a/crates/ployz-build/src/received_recipe.rs +++ b/crates/ployz-build/src/received_recipe.rs @@ -144,13 +144,7 @@ pub fn validate_capture(root: &Path, definition: &Definition) -> Result<(), Inpu .iter() .find(|target| target.name == name) .expect("validated target"); - if platforms.iter().map(text).collect::, _>>()? - != target - .platforms - .iter() - .map(String::as_str) - .collect::>() - { + if platforms.iter().map(text).collect::, _>>()? != target.platforms { return Err("Build recipe platforms differ from the admitted request".into()); } } diff --git a/crates/ployz/src/image.rs b/crates/ployz/src/image.rs index 3322733bec..db0bee22b7 100644 --- a/crates/ployz/src/image.rs +++ b/crates/ployz/src/image.rs @@ -102,12 +102,12 @@ impl PushError { } } -struct Cancellation<'a> { - token: &'a tokio_util::sync::CancellationToken, +struct Cancellation<'token> { + token: &'token tokio_util::sync::CancellationToken, } -impl<'a> Cancellation<'a> { - fn new(token: &'a tokio_util::sync::CancellationToken) -> Self { +impl<'token> Cancellation<'token> { + fn new(token: &'token tokio_util::sync::CancellationToken) -> Self { Self { token } } diff --git a/crates/ployz/tests/build/railpack.rs b/crates/ployz/tests/build/railpack.rs index 90edae80df..dc0eb196a8 100644 --- a/crates/ployz/tests/build/railpack.rs +++ b/crates/ployz/tests/build/railpack.rs @@ -340,7 +340,7 @@ case "$*" in 'cp '*:/tmp/variant.tar) exit 0 ;; 'image load '*) test ! -f "$root/import-fails"; exit $? ;; 'image save '*) test ! -f "$root/missing-content"; exit $? ;; - 'image tag '*) exit 0 ;; + 'image tag '*) if [ -f "$root/cleanup-fails" ]; then touch "$root/active"; fi; exit 0 ;; esac exec "$root/base-docker" "$@" "#, @@ -442,3 +442,77 @@ fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() } fs::remove_dir_all(root).unwrap(); } + +#[test] +fn multi_platform_failures_preserve_output_stage_and_completed_image_evidence() { + use ployz_build::{ + Admission, HostPolicy, Progress, Request, Stage, Target, TargetEvidence, WorkEvidence, + }; + use std::collections::BTreeMap; + let root = std::env::temp_dir().join(format!("ployz-railpack-evidence-{}", std::process::id())); + let capture = root.join("capture"); + fs::create_dir_all(capture.join("private")).unwrap(); + fs::create_dir(capture.join("source")).unwrap(); + fs::create_dir(root.join("state")).unwrap(); + fs::write(capture.join("compose.yaml"), "services: {}\n").unwrap(); + let docker = write_multi_docker(&root); + let targets = [Target { + name: "api".into(), + platforms: vec!["linux/amd64".into(), "linux/arm64".into()], + }]; + let recipes = [ployz_build::Railpack { + name: "api".into(), + context: "source".into(), + variables: BTreeMap::new(), + refresh_cache: false, + }]; + let environment = BTreeMap::from([("PATH".into(), std::env::var("PATH").unwrap())]); + let policy = HostPolicy { + state_directory: root.join("state"), + docker, + active_timeout: std::time::Duration::from_secs(15), + }; + let request = Request { + compose_file: &capture.join("compose.yaml"), + working_dir: &capture, + environment: &environment, + docker: Some(&policy.docker), + targets: &targets, + railpack: &recipes, + build_args: &[], + output: ployz_build::Output::Load, + no_cache: false, + pull: false, + }; + for (failure, stage, completed) in [ + ("assembly-fails", Stage::Output, false), + ("import-fails", Stage::Output, false), + ("cleanup-fails", Stage::Cleanup, true), + ] { + fs::write(root.join(failure), "").unwrap(); + let evidence = std::sync::Mutex::new(WorkEvidence::new(&targets)); + let error = ployz_build::execute_admitted( + &request, + Admission::try_acquire_with(&policy).unwrap(), + &|event: Progress| { + evidence.lock().unwrap().observe(&event); + }, + ) + .unwrap_err(); + assert_eq!(error.stage(), stage, "{error}"); + assert_eq!(error.is_unknown(), completed, "{error}"); + let evidence = evidence.into_inner().unwrap(); + let evidence = evidence.0.get("api").unwrap(); + if completed { + let TargetEvidence::Image(image) = evidence else { + panic!("completed image evidence was lost: {evidence:?}"); + }; + assert_eq!(image.reference, FIRST_CONTENT); + assert_eq!(image.platforms, ["linux/amd64", "linux/arm64"]); + } else { + assert_eq!(evidence, &TargetEvidence::Unknown); + } + fs::remove_file(root.join(failure)).unwrap(); + } + fs::remove_dir_all(root).unwrap(); +} From bfa2aefeaee512ce91dc85edf7a117051510a359 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 03:20:40 +0000 Subject: [PATCH 4/8] Reject unsupported provenance for multi-platform Railpack builds --- README.md | 5 ++-- crates/ployz/src/compose/build.rs | 11 +++++++++ crates/ployz/tests/build/railpack.rs | 36 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ef5d0d3554..51a56e9fa4 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,9 @@ ARM64 platform. Set `build.platforms: [linux/amd64, linux/arm64]` to build both; the execution host must provide native or emulated support for each platform. Separate solves are assembled locally with pinned regctl 0.11.6 into one immutable image in Docker’s containerd store, with every platform’s content verified. -Multi-platform builds currently require local output; Dockerfiles remain limited -to one platform. Deploy platform inference is separate work. +Multi-platform builds currently require local output and reject requested +`build.provenance`; Dockerfiles remain limited to one platform. Deploy platform +inference is separate work. The [prototype findings](https://github.com/getployz/ployz2/blob/c3ca5519a4607256ffb28052d77a1c7d89f1bbe1/prototypes/railpack-transfer/FINDINGS.md) preserve the evidence for this assembly approach. They used shipped beta binaries diff --git a/crates/ployz/src/compose/build.rs b/crates/ployz/src/compose/build.rs index a658e44cdb..634b603dd6 100644 --- a/crates/ployz/src/compose/build.rs +++ b/crates/ployz/src/compose/build.rs @@ -161,6 +161,17 @@ pub fn capture_build( railpack_recipes.push(recipe); } let mut platforms = requested_platforms(&name, build, railpack)?; + if railpack + && platforms.len() > 1 + && build.get("provenance").is_some_and(|value| { + !matches!(value, Value::Null | Value::Bool(false)) + && value.as_str() != Some("false") + }) + { + return Err(invalid_build( + "multi-platform Railpack does not support build.provenance; use false or a single platform", + )); + } if platforms.is_empty() && let Some(platform) = project .environment diff --git a/crates/ployz/tests/build/railpack.rs b/crates/ployz/tests/build/railpack.rs index dc0eb196a8..bc01b7963e 100644 --- a/crates/ployz/tests/build/railpack.rs +++ b/crates/ployz/tests/build/railpack.rs @@ -240,6 +240,42 @@ fn railpack_accepts_explicit_linux_architectures_and_rejects_other_platforms() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn multi_platform_railpack_refuses_requested_provenance() { + let root = + std::env::temp_dir().join(format!("ployz-railpack-provenance-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + for (platforms, provenance, accepted) in [ + ("linux/amd64, linux/arm64", "true", false), + ("linux/amd64, linux/arm64", "mode=max", false), + ("linux/amd64, linux/arm64", "false", true), + ("linux/amd64", "true", true), + ("linux/arm64", "mode=max", true), + ] { + let mut project = parse_normalized(&format!("services:\n api:\n build: {{context: ., x-recipe: railpack, platforms: [{platforms}], provenance: {provenance}}}\n"), &root).unwrap(); + let options = BuildOptions::default(); + let plan = plan_build(&project, &options).unwrap(); + let result = capture_build(&plan, &options, &mut project); + if accepted { + assert!( + result.is_ok(), + "{platforms}, {provenance}: {:?}", + result.err() + ); + } else { + let error = result + .err() + .expect("requested provenance must not be discarded") + .to_string(); + assert!( + error.contains("multi-platform Railpack") && error.contains("build.provenance"), + "{error}" + ); + } + } + fs::remove_dir_all(root).unwrap(); +} + #[test] fn multi_platform_build_requires_complete_content_and_cleans_up_failed_attempts() { let root = std::env::temp_dir().join(format!("ployz-railpack-outcomes-{}", std::process::id())); From 1f9f82a169c4c8e0c3db9d50957829405d187ad4 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 03:35:50 +0000 Subject: [PATCH 5/8] Isolate cancellation fixtures under host build ownership --- crates/ployz/tests/build/railpack.rs | 52 +++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/crates/ployz/tests/build/railpack.rs b/crates/ployz/tests/build/railpack.rs index 3fc018b67f..d2a99300f9 100644 --- a/crates/ployz/tests/build/railpack.rs +++ b/crates/ployz/tests/build/railpack.rs @@ -393,12 +393,43 @@ fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() const CHILD_ROOT: &str = "PLOYZ_TEST_CANCEL_BUILD_ROOT"; if let Some(root) = std::env::var_os(CHILD_ROOT) { let root = std::path::PathBuf::from(root); - let mut project = parse_normalized("services:\n api:\n image: example.test/api:check\n build: {context: ., x-recipe: railpack, platforms: [linux/amd64, linux/arm64]}\n", &root).unwrap(); - let options = BuildOptions::default(); - let plan = plan_build(&project, &options).unwrap(); - let captured = capture_build(&plan, &options, &mut project).unwrap(); - let cancellation = tokio_util::sync::CancellationToken::new(); - let cancelled = cancellation.clone(); + use ployz_build::{Admission, HostPolicy, Railpack, Request, Target}; + use std::collections::BTreeMap; + use std::os::unix::fs::DirBuilderExt as _; + fs::create_dir_all(root.join("private")).unwrap(); + fs::create_dir_all(root.join("source")).unwrap(); + let state = root.join(format!("state-{}", std::process::id())); + fs::DirBuilder::new().mode(0o700).create(&state).unwrap(); + let policy = HostPolicy { + state_directory: state, + configuration_file: root.join("build.yaml"), + docker: root.join("docker"), + ..HostPolicy::default() + }; + let targets = [Target { + name: "api".into(), + platforms: vec!["linux/amd64".into(), "linux/arm64".into()], + }]; + let recipes = [Railpack { + name: "api".into(), + context: "source".into(), + variables: BTreeMap::new(), + refresh_cache: false, + }]; + let request = Request { + compose_file: &root.join("compose.yaml"), + working_dir: &root, + environment: &BTreeMap::from([("PATH".into(), std::env::var("PATH").unwrap())]), + docker: Some(&policy.docker), + targets: &targets, + railpack: &recipes, + build_args: &[], + output: ployz_build::Output::Load, + no_cache: false, + pull: false, + }; + let admission = Admission::try_acquire_with(&policy).unwrap(); + let cancelled = admission.cancellation(); let marker = root.join("interrupt"); std::thread::spawn(move || { while !marker.exists() { @@ -406,8 +437,7 @@ fn railpack_cancellation_stops_work_and_releases_private_inputs_and_admission() } cancelled.cancel(); }); - let error = captured - .execute(Some(&root.join("docker")), &cancellation) + let error = ployz_build::execute_admitted(&request, admission, &|_| {}) .unwrap_err() .to_string(); if root.join("cleanup-fails").exists() { @@ -489,7 +519,11 @@ fn multi_platform_failures_preserve_output_stage_and_completed_image_evidence() let capture = root.join("capture"); fs::create_dir_all(capture.join("private")).unwrap(); fs::create_dir(capture.join("source")).unwrap(); - fs::create_dir(root.join("state")).unwrap(); + use std::os::unix::fs::DirBuilderExt as _; + fs::DirBuilder::new() + .mode(0o700) + .create(root.join("state")) + .unwrap(); fs::write(capture.join("compose.yaml"), "services: {}\n").unwrap(); let docker = write_multi_docker(&root); let targets = [Target { From bff2aad598b2e73967a9b1bcec7db365a1611cc0 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 03:39:30 +0000 Subject: [PATCH 6/8] Report local Docker context in signal regression fixture --- crates/ployz-build/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ployz-build/src/tests.rs b/crates/ployz-build/src/tests.rs index 6a095ce8c3..89fb880f54 100644 --- a/crates/ployz-build/src/tests.rs +++ b/crates/ployz-build/src/tests.rs @@ -356,6 +356,7 @@ fn executing_a_build_does_not_change_process_signal_handlers() { &format!( r#"#!/bin/sh case "$1 $2" in + 'context show') echo default ;; 'info --format') echo '{{"DriverStatus":[["driver-type","io.containerd.snapshotter.v1"]],"Architecture":"amd64","OSType":"linux"}}' ;; 'buildx ls') echo '{{"Name":"{}","Nodes":[{{"Status":"running","Platforms":["linux/amd64"]}}]}}' ;; esac From f6f50260fef876db1282f5df9059f70a417dadad Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 03:43:54 +0000 Subject: [PATCH 7/8] Reject unsupported SBOM requests for multi-platform Railpack --- README.md | 2 +- crates/ployz/src/compose/build.rs | 21 +++++++-------- crates/ployz/tests/build/railpack.rs | 38 ++++++++++++++++++---------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e4f13d931f..ae03d57ad0 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ the execution host must provide native or emulated support for each platform. Separate solves are assembled locally with pinned regctl 0.11.6 into one immutable image in Docker’s containerd store, with every platform’s content verified. Multi-platform builds currently require local output and reject requested -`build.provenance`; Dockerfiles remain limited to one platform. Deploy platform +`build.provenance` or `build.sbom`; Dockerfiles remain limited to one platform. Deploy platform inference is separate work. The [prototype findings](https://github.com/getployz/ployz2/blob/c3ca5519a4607256ffb28052d77a1c7d89f1bbe1/prototypes/railpack-transfer/FINDINGS.md) diff --git a/crates/ployz/src/compose/build.rs b/crates/ployz/src/compose/build.rs index 1da7ede7bf..cc87792514 100644 --- a/crates/ployz/src/compose/build.rs +++ b/crates/ployz/src/compose/build.rs @@ -161,16 +161,17 @@ pub fn capture_build( railpack_recipes.push(recipe); } let mut platforms = requested_platforms(&name, build, railpack)?; - if railpack - && platforms.len() > 1 - && build.get("provenance").is_some_and(|value| { - !matches!(value, Value::Null | Value::Bool(false)) - && value.as_str() != Some("false") - }) - { - return Err(invalid_build( - "multi-platform Railpack does not support build.provenance; use false or a single platform", - )); + if railpack && platforms.len() > 1 { + for field in ["provenance", "sbom"] { + if build.get(field).is_some_and(|value| { + !matches!(value, Value::Null | Value::Bool(false)) + && value.as_str() != Some("false") + }) { + return Err(invalid_build(&format!( + "multi-platform Railpack does not support build.{field}; use false or a single platform", + ))); + } + } } if platforms.is_empty() && let Some(platform) = project diff --git a/crates/ployz/tests/build/railpack.rs b/crates/ployz/tests/build/railpack.rs index d2a99300f9..83352efea6 100644 --- a/crates/ployz/tests/build/railpack.rs +++ b/crates/ployz/tests/build/railpack.rs @@ -241,34 +241,46 @@ fn railpack_accepts_explicit_linux_architectures_and_rejects_other_platforms() { } #[test] -fn multi_platform_railpack_refuses_requested_provenance() { - let root = - std::env::temp_dir().join(format!("ployz-railpack-provenance-{}", std::process::id())); +fn multi_platform_railpack_refuses_requested_attestations() { + let root = std::env::temp_dir().join(format!( + "ployz-railpack-attestations-{}", + std::process::id() + )); fs::create_dir_all(&root).unwrap(); - for (platforms, provenance, accepted) in [ - ("linux/amd64, linux/arm64", "true", false), - ("linux/amd64, linux/arm64", "mode=max", false), - ("linux/amd64, linux/arm64", "false", true), - ("linux/amd64", "true", true), - ("linux/arm64", "mode=max", true), + for (field, platforms, value, accepted) in [ + ("provenance", "linux/amd64, linux/arm64", "true", false), + ("provenance", "linux/amd64, linux/arm64", "mode=max", false), + ("provenance", "linux/amd64, linux/arm64", "false", true), + ("provenance", "linux/amd64", "true", true), + ("provenance", "linux/arm64", "mode=max", true), + ("sbom", "linux/amd64, linux/arm64", "true", false), + ( + "sbom", + "linux/amd64, linux/arm64", + "generator=docker/scout-sbom-indexer", + false, + ), + ("sbom", "linux/amd64, linux/arm64", "false", true), + ("sbom", "linux/amd64", "true", true), ] { - let mut project = parse_normalized(&format!("services:\n api:\n build: {{context: ., x-recipe: railpack, platforms: [{platforms}], provenance: {provenance}}}\n"), &root).unwrap(); + let mut project = parse_normalized(&format!("services:\n api:\n build: {{context: ., x-recipe: railpack, platforms: [{platforms}], {field}: {value}}}\n"), &root).unwrap(); let options = BuildOptions::default(); let plan = plan_build(&project, &options).unwrap(); let result = capture_build(&plan, &options, &mut project); if accepted { assert!( result.is_ok(), - "{platforms}, {provenance}: {:?}", + "{field}, {platforms}, {value}: {:?}", result.err() ); } else { let error = result .err() - .expect("requested provenance must not be discarded") + .expect("requested attestation must not be discarded") .to_string(); assert!( - error.contains("multi-platform Railpack") && error.contains("build.provenance"), + error.contains("multi-platform Railpack") + && error.contains(&format!("build.{field}")), "{error}" ); } From b9a8ee09e91fb5789e07b956b053a69ba7898a5b Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 03:57:45 +0000 Subject: [PATCH 8/8] Normalize disabled multi-platform attestations before remote validation --- crates/ployz/src/compose/build.rs | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/ployz/src/compose/build.rs b/crates/ployz/src/compose/build.rs index cc87792514..a3b2ebc0e0 100644 --- a/crates/ployz/src/compose/build.rs +++ b/crates/ployz/src/compose/build.rs @@ -171,6 +171,7 @@ pub fn capture_build( "multi-platform Railpack does not support build.{field}; use false or a single platform", ))); } + build.remove(field); } } if platforms.is_empty() @@ -928,6 +929,39 @@ impl BuildSpec { mod tests { use super::*; + #[test] + fn disabled_multi_platform_attestations_pass_remote_validation() { + let root = std::env::temp_dir().join(format!( + "ployz-disabled-attestations-{}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + for settings in [ + "provenance: false", + "sbom: false", + "provenance: false, sbom: false", + ] { + let mut project = crate::compose::parse_normalized( + &format!("services:\n api:\n build: {{context: ., x-recipe: railpack, platforms: [linux/amd64, linux/arm64], {settings}}}\n"), + &root, + ).unwrap(); + let options = BuildOptions::default(); + let plan = plan_build(&project, &options).unwrap(); + let captured = capture_build(&plan, &options, &mut project).unwrap(); + let definition = ployz_build::remote::Definition { + targets: captured.targets, + output: options.output, + no_cache: options.no_cache, + pull: options.pull, + }; + let recipes = + ployz_build::remote::validate_capture(captured.inputs.root(), &definition) + .unwrap_or_else(|error| panic!("{settings}: {error}")); + assert_eq!(recipes.len(), 1); + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn git_context_suffix_rules_follow_buildkit_transports() { let commit = "0123456789abcdef0123456789abcdef01234567";