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
4 changes: 4 additions & 0 deletions crates/ployz-build/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,14 @@ impl Admission {
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;

#[test]
fn restart_removes_abandoned_uploads_but_keeps_unknown_ownership_unavailable() {
let root =
std::env::temp_dir().join(format!("ployz-build-restart-{}", uuid::Uuid::new_v4()));
std::fs::create_dir(&root).unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
let policy = HostPolicy {
state_directory: root.clone(),
docker: root.join("docker"),
Expand Down Expand Up @@ -302,6 +304,7 @@ mod tests {
fn expired_admission_and_docker_report_configured_budget() {
let root = std::env::temp_dir().join(format!("ployz-budget-{}", uuid::Uuid::new_v4()));
std::fs::create_dir(&root).unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
let policy = HostPolicy {
state_directory: root.clone(),
active_timeout: Duration::from_secs(5),
Expand Down Expand Up @@ -332,6 +335,7 @@ mod tests {
let root =
std::env::temp_dir().join(format!("ployz-upload-cancel-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(root.join("build-upload")).unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::write(root.join("build-upload/existing"), "capture").unwrap();
let policy = HostPolicy {
state_directory: root.clone(),
Expand Down
83 changes: 83 additions & 0 deletions crates/ployz-build/src/image_contexts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! Bind completed Service outputs to BuildKit named contexts. The existing
//! Machine image server supplies bytes directly; no external registry fallback.

use crate::{BuildError, Request};
use serde_norway::Value;
use std::{fs, net::IpAddr};

pub(crate) fn prepare(request: &Request<'_>) -> Result<(), BuildError> {
if request.image_contexts.is_empty() {
return Ok(());
}
let invalid = |message: &str| BuildError::Request(message.into());
let mut document: Value = serde_norway::from_slice(
&fs::read(request.working_dir.join(request.compose_file)).map_err(input_error)?,
)
.map_err(|_| invalid("invalid captured Build recipe"))?;
let services = document
.get_mut("services")
.and_then(Value::as_mapping_mut)
.ok_or_else(|| invalid("Build recipe has no services"))?;
for target in request.targets {
let build = services
.get_mut(Value::String(target.name.clone()))
.and_then(|service| service.get_mut("build"))
.and_then(Value::as_mapping_mut)
.ok_or_else(|| invalid("Build target has no recipe"))?;
let contexts = build.get("additional_contexts");
let entries = match contexts {
Some(Value::Mapping(values)) => values
.iter()
.map(|(name, value)| name.as_str().zip(value.as_str()))
.collect::<Option<Vec<_>>>(),
Some(Value::Sequence(values)) => values
.iter()
.map(|value| value.as_str().and_then(|value| value.split_once('=')))
.collect::<Option<Vec<_>>>(),
None | Some(Value::Null) => Some(Vec::new()),
Some(_) => None,
}
.ok_or_else(|| invalid("invalid additional contexts"))?;
let mut bound = serde_norway::Mapping::new();
for (name, value) in entries {
if let Some(dependency) = value.strip_prefix("service:") {
let image = request.image_contexts.get(dependency).ok_or_else(|| {
invalid("Service image context has no completed Build output")
})?;
bound.insert(
name.into(),
format!(
"docker-image://{}/{}",
std::net::SocketAddr::new(
image
.source
.management_address
.0
.to_ipv4_mapped()
.map_or(IpAddr::V6(image.source.management_address.0), IpAddr::V4),
image.source.port,
),
image.reference
)
.into(),
);
} else {
bound.insert(name.into(), value.into());
}
}
if !bound.is_empty() {
build.insert("additional_contexts".into(), Value::Mapping(bound));
}
}
fs::write(
request.working_dir.join(request.compose_file),
serde_norway::to_string(&document)
.map_err(|error| BuildError::Request(error.to_string()))?,
)
.map_err(input_error)?;
Ok(())
}

fn input_error(error: std::io::Error) -> BuildError {
BuildError::Request(format!("prepare completed image contexts: {error}"))
}
39 changes: 39 additions & 0 deletions crates/ployz-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@

mod builder;
mod execution;
mod image_contexts;
mod index;
mod policy;
pub use policy::clear_cache;
mod received_recipe;
mod retention;
pub use retention::{ImageRetention, RetainedImages};
pub mod remote;
mod upload;

Expand Down Expand Up @@ -61,6 +64,8 @@ pub fn builder_name() -> String {
/// Paths point into the caller's private capture; nothing here is read from
/// the original sources again.
pub struct Request<'a> {
/// Completed Service image contexts served from their actual Build hosts.
pub image_contexts: &'a BTreeMap<String, ImageContext>,
/// Captured Compose file describing every target of this Build.
pub compose_file: &'a Path,
/// Private directory the build runs in, so no stray file can join it.
Expand Down Expand Up @@ -121,6 +126,39 @@ pub struct BuiltImage {
pub location: String,
}

impl BuiltImage {
/// Name this manifest in the repository used by its Build.
/// # Errors
/// Rejects missing or malformed repository evidence.
pub fn repository_reference(&self) -> Result<String, BuildError> {
let tag = self
.tags
.first()
Comment on lines +134 to +136

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 Bind completed digests to the Service repository

When a Compose service declares explicit build.tags, capture preserves those tags before appending service.image, so the first metadata tag can name an auxiliary repository. Using that first tag here causes bind_builds and image delivery to use auxiliary-repo@sha256:... rather than the repository declared by the Service; for local deploys, an auxiliary repository containing a registry port can additionally trigger RegistryPortReference even when service.image itself is supported. Construct the digest reference from BuiltService.image instead of arbitrary tag ordering.

Useful? React with 👍 / 👎.

.ok_or_else(|| BuildError::Result("Build image has no tags".into()))?;
let reference = tag
.parse::<oci_client::Reference>()
.map_err(|error| BuildError::Result(error.to_string()))?;
Ok(format!(
"{}/{}@{}",
reference.registry(),
reference.repository(),
self.reference
))
}
}

/// An immutable named image context, delivered by the existing Machine image
/// server. It carries no Machine selection or Deploy policy into the host.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ImageContext {
/// Repository reference pinned to the completed manifest’s SHA-256 digest.
pub reference: String,
/// Platforms actually available in that completed image.
pub platforms: Vec<String>,
/// Open serving endpoint of the Machine containing that exact content.
pub source: ployz_core::ImageIngestDestination,
}

/// Why a Build Attempt did not produce the image it was asked for.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum BuildError {
Expand Down Expand Up @@ -277,6 +315,7 @@ pub fn execute_admitted(
docker
.require_local()
.and_then(|()| docker.run("check Buildx", &["buildx", "version"], Streams::Captured))
.and_then(|_| image_contexts::prepare(request))
.map_err(|error| {
let error = error.at(Stage::Preparation);
if !error.is_unknown()
Expand Down
54 changes: 51 additions & 3 deletions crates/ployz-build/src/received_recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,39 @@ pub fn validate_capture(
{
return Err("invalid Build target names".into());
}
if definition.image_contexts.len() > 128
|| definition.image_contexts.keys().any(|name| {
names.contains(name.as_str())
|| name.is_empty()
|| !name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c))
})
{
return Err("invalid completed Build context names".into());
}
for context in definition.image_contexts.values() {
validate_remote_context(&format!("docker-image://{}", context.reference))?;
if context.source.port == 0
|| (context.platforms.is_empty()
|| context
.platforms
.iter()
.any(|platform| !crate::remote::linux_platform(platform)))
{
return Err("invalid completed Build image context".into());
}
}
if definition.retained_tags.len() > names.len()
|| (!definition.retained_tags.is_empty() && definition.output != crate::Output::Load)
{
return Err("invalid temporary Build tags".into());
}
let context_names = names
.iter()
.copied()
.chain(definition.image_contexts.keys().map(String::as_str))
.collect();
let bytes = fs::read(root.join("compose.yaml")).map_err(|_| "Build recipe is missing")?;
let document: Value =
serde_norway::from_slice(&bytes).map_err(|_| "invalid captured Build recipe")?;
Expand All @@ -49,6 +82,17 @@ pub fn validate_capture(
if services.len() != names.len() {
return Err("Build recipe targets differ from the admitted request".into());
}
for tag in &definition.retained_tags {
if !services.values().any(|service| {
service
.get("build")
.and_then(|build| build.get("tags"))
.and_then(Value::as_sequence)
.is_some_and(|tags| tags.iter().any(|value| value.as_str() == Some(tag)))
}) {
return Err("temporary Build tag is absent from the captured recipe".into());
}
}
for (name, service) in services {
let name = text(name)?;
if !names.contains(name) {
Expand Down Expand Up @@ -101,7 +145,7 @@ pub fn validate_capture(
.get("context")
.ok_or("Build recipe has no captured context")?,
)?;
context_path(root, context, &names, default_ssh)?;
context_path(root, context, &context_names, default_ssh)?;
if let Some(recipe) = build.get("dockerfile") {
let recipe = text(recipe)?;
if remote(context) {
Expand All @@ -119,15 +163,15 @@ pub fn validate_capture(
match contexts {
Value::Mapping(contexts) => {
for value in contexts.values() {
context_path(root, text(value)?, &names, default_ssh)?;
context_path(root, text(value)?, &context_names, default_ssh)?;
}
}
Value::Sequence(contexts) => {
for value in contexts {
let (_, value) = text(value)?
.split_once('=')
.ok_or("invalid named Build context")?;
context_path(root, value, &names, default_ssh)?;
context_path(root, value, &context_names, default_ssh)?;
}
}
Value::Null => {}
Expand Down Expand Up @@ -448,6 +492,8 @@ mod tests {
)
.unwrap();
let definition = Definition {
retained_tags: Vec::new(),
image_contexts: Default::default(),
targets: vec![crate::Target {
name: "api".into(),
platforms: Vec::new(),
Expand Down Expand Up @@ -493,6 +539,8 @@ mod tests {
fs::write(root.join("private/key"), "captured-key").unwrap();
fs::write(root.join("source/key"), "source-content").unwrap();
let definition = Definition {
retained_tags: Vec::new(),
image_contexts: Default::default(),
targets: vec![crate::Target {
name: "api".into(),
platforms: Vec::new(),
Expand Down
37 changes: 37 additions & 0 deletions crates/ployz-build/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned};
pub use crate::received_recipe::{validate_capture, validate_remote_context};
pub use crate::upload::{AdmittedUpload, Upload, upload};

/// Whether a completed image reports a well-formed Linux platform. Worker
/// capability checks, rather than a fixed architecture list, determine support.
#[must_use]
pub fn linux_platform(platform: &str) -> bool {
let valid = |part: &str| {
!part.is_empty()
&& part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
};
let mut parts = platform.split('/');
parts.next() == Some("linux")
&& parts.next().is_some_and(valid)
&& parts.next().is_none_or(valid)
&& parts.next().is_none()
}
/// Invalid or unavailable captured input at the transport trust boundary.
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
Expand All @@ -32,6 +48,12 @@ pub const CHUNK_SIZE: usize = 32 * 1024;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Definition {
/// Command-generated temporary tags, retained until the response stream closes.
#[serde(default)]
pub retained_tags: Vec<String>,
/// Completed Service contexts, bound to immutable content and serving endpoints.
#[serde(default)]
pub image_contexts: std::collections::BTreeMap<String, crate::ImageContext>,
/// Targets in this admitted attempt.
pub targets: Vec<Target>,
/// Requested disposition of completed output.
Expand Down Expand Up @@ -138,6 +160,21 @@ impl Outcome {

#[cfg(test)]
mod tests {
#[test]
fn linux_platform_validation_keeps_worker_architectures_and_rejects_malformed_values() {
for value in ["linux/amd64", "linux/386", "linux/arm/v7", "linux/ppc64le"] {
assert!(super::linux_platform(value), "{value}");
}
for value in [
"windows/amd64",
"linux//v7",
"linux/arm/",
"linux/arm/v7/extra",
] {
assert!(!super::linux_platform(value), "{value}");
}
}

#[test]
fn requests_cannot_supply_execution_host_policy() {
for setting in [
Expand Down
Loading
Loading