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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,20 @@ 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 and reject requested
`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)
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.
Expand Down
1 change: 1 addition & 0 deletions crates/ployz-build/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +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"
tokio-util = "0.7.19"
thiserror = "2.0.20"

[lints]
Expand Down
40 changes: 32 additions & 8 deletions crates/ployz-build/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ impl<'a> Builder<'a> {
}

/// Read the running worker, not the Machine's advertised architecture.
///
/// # Errors
/// Refuses unsupported image stores, hosts, resource limits, or requested worker platforms.
pub(crate) fn native_platform(
&self,
targets: &[crate::Target],
Expand Down Expand Up @@ -118,8 +121,13 @@ impl<'a> Builder<'a> {
"the selected BuildKit worker reported no capability".into(),
)
})?;
for target in targets {
let requested = target.platform.as_deref().unwrap_or(&native);
for requested in targets.iter().flat_map(|target| {
target
.platforms
.iter()
.map(String::as_str)
.chain(target.platforms.is_empty().then_some(native.as_str()))
}) {
if !nodes.iter().any(|node| {
node.get("Status").and_then(serde_json::Value::as_str) == Some("running")
&& node
Expand Down Expand Up @@ -175,7 +183,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}"),
Expand Down Expand Up @@ -261,13 +279,19 @@ impl Lock {
///
/// # Errors
/// Fails when the lock file cannot be opened or locked.
pub(crate) fn acquire() -> Result<Self, BuildError> {
Self::acquire_in(&directory())
pub(crate) fn acquire(cancellation: &crate::Cancellation) -> Result<Self, BuildError> {
Self::acquire_in(&directory(), cancellation)
}

fn acquire_in(directory: &std::path::Path) -> Result<Self, BuildError> {
fn acquire_in(
directory: &std::path::Path,
cancellation: &crate::Cancellation,
) -> Result<Self, BuildError> {
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));
Expand Down Expand Up @@ -444,7 +468,7 @@ esac
let result = builder.native_platform(
&[crate::Target {
name: "api".into(),
platform: Some(platform.into()),
platforms: vec![platform.into()],
}],
&resources,
);
Expand Down Expand Up @@ -564,7 +588,7 @@ esac
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, &crate::policy::Resources::default()).unwrap();
let name = builder_name();
Expand Down
32 changes: 7 additions & 25 deletions crates/ployz-build/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -72,20 +65,9 @@ impl WorkEvidence {
}
}

/// A request to stop, never proof that execution stopped.
#[derive(Clone, Default)]
pub struct Cancellation(Arc<AtomicBool>);
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)]
Expand Down Expand Up @@ -210,14 +192,14 @@ impl Admission {
cancellation: Cancellation::default(),
})
}
pub(crate) fn wait() -> Result<Self, BuildError> {
let lock = Lock::acquire()?;
pub(crate) fn wait(cancellation: &Cancellation) -> Result<Self, BuildError> {
let lock = Lock::acquire(cancellation)?;
let resources = crate::policy::Resources::load(&HostPolicy::default().configuration_file)?;
Ok(Self {
resources,
lock,
deadline: Deadline::starting_now(EXECUTION_TIMEOUT),
cancellation: Cancellation::default(),
cancellation: cancellation.clone(),
})
}
/// Handle for requesting this attempt to stop.
Expand Down
207 changes: 207 additions & 0 deletions crates/ployz-build/src/index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//! 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, Progress, Request, Stage, Streams, TargetEvidence,
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<BuiltImage, BuildError> {
let name = format!("{}-assemble", builder_name());
docker.with_container(
&name,
&[
"--network",
"none",
"--entrypoint",
"/bin/sleep",
REGCTL,
"infinity",
],
|name| {
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);
output(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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve requested provenance in multi-platform builds

When a Railpack service requests multiple platforms with build.provenance: true, the capture layer explicitly accepts that setting (crates/ployz/src/compose/build.rs:337-355), but this unconditional command-line override disables provenance for every solve. The build then succeeds while silently omitting requested attestations, unlike the single-platform path; either preserve provenance through index assembly or reject the option for multi-platform Railpack builds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve SBOM in multi-platform Railpack solves

For a Railpack service with platforms: [linux/amd64, linux/arm64] and sbom: true, capture still accepts the SBOM option, but this --set <target>.attest=type=provenance,disabled=true replaces Bake's attestation list and silently removes the requested SBOM from each solve. The final tree now rejects requested provenance for this path but has no equivalent SBOM guard, so either reject non-false build.sbom for multi-platform Railpack builds or preserve it with an additive attestation override.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

] {
args.extend(["--set".into(), setting]);
}
if let Some(progress) = docker.progress {
progress(Progress::Stage(Stage::Building));
}
builder.run(&args, || {
if let Some(progress) = docker.progress {
progress(Progress::Target {
name: target.target.name.clone(),
outcome: TargetEvidence::Unknown,
});
}
})?;
if let Some(progress) = docker.progress {
progress(Progress::Stage(Stage::Output));
}
let results: BTreeMap<String, TargetMetadata> =
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(),
));
}
output(
"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");
output(
"copy assembled Railpack image",
&[
"cp",
&format!("{name}:/tmp/complete.tar"),
&archive.to_string_lossy(),
],
Streams::Captured,
)?;
output(
"load assembled Railpack image",
&["image", "load", "--input", &archive.to_string_lossy()],
Streams::Captured,
)?;
let reference = digest.to_owned();
let inspected = output(
"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.
output(
"verify Railpack platform content",
&["image", "save", "--platform", platform, &reference],
Streams::Discarded,
)?;
}
for tag in &tags {
output(
"tag completed Railpack image",
&["image", "tag", &reference, tag],
Streams::Captured,
)?;
}
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)
},
)
}

fn result_error(error: impl std::fmt::Display) -> BuildError {
BuildError::Result(format!("read Railpack image result: {error}"))
}
Loading
Loading