use std::path::Path;
use async_trait::async_trait;
use crate::runner::RunnerError;
pub const MAX_CONTEXT_BYTES: u64 = 512 * 1024 * 1024;
#[async_trait]
pub trait BuildContextPublisher: Send + Sync {
async fn publish(&self, key: &str, tarball: Vec<u8>) -> Result<String, RunnerError>;
async fn discard(&self, key: &str);
}
pub struct NoBuildContextPublisher;
#[async_trait]
impl BuildContextPublisher for NoBuildContextPublisher {
async fn publish(&self, _key: &str, _tarball: Vec<u8>) -> Result<String, RunnerError> {
Err(RunnerError::InvalidConfig(
"this build-image step must run on a different host than qed, so its build \
context has to be fetched by the worker rather than bind-mounted — but no \
build-context publisher is wired into this runner. The `yah` CLI wires an \
R2-backed one; a bare `yah-qed` embedding must supply its own via \
`PipelineRunner::with_build_context_publisher`."
.into(),
))
}
async fn discard(&self, _key: &str) {}
}
pub fn pack_context(
context_dir: &Path,
extra: &[(String, Vec<u8>)],
) -> Result<Vec<u8>, RunnerError> {
let mut budget = MAX_CONTEXT_BYTES;
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut tar = tar::Builder::new(gz);
tar.follow_symlinks(true);
append_dir(&mut tar, context_dir, Path::new(""), &mut budget)?;
for (name, bytes) in extra {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_cksum();
tar.append_data(&mut header, name, bytes.as_slice())
.map_err(|e| pack_err(format!("adding {name} to the context tar: {e}")))?;
}
let gz = tar
.into_inner()
.map_err(|e| pack_err(format!("finishing the context tar: {e}")))?;
gz.finish()
.map_err(|e| pack_err(format!("compressing the context tar: {e}")))
}
fn append_dir<W: std::io::Write>(
tar: &mut tar::Builder<W>,
dir: &Path,
prefix: &Path,
budget: &mut u64,
) -> Result<(), RunnerError> {
let entries = std::fs::read_dir(dir)
.map_err(|e| pack_err(format!("reading build context {}: {e}", dir.display())))?;
let mut names: Vec<std::ffi::OsString> = Vec::new();
for entry in entries {
let entry =
entry.map_err(|e| pack_err(format!("walking {}: {e}", dir.display())))?;
names.push(entry.file_name());
}
names.sort();
for name in names {
let path = dir.join(&name);
let rel = prefix.join(&name);
let meta = std::fs::metadata(&path)
.map_err(|e| pack_err(format!("stat {}: {e}", path.display())))?;
if meta.is_dir() {
append_dir(tar, &path, &rel, budget)?;
continue;
}
let len = meta.len();
*budget = budget.checked_sub(len).ok_or_else(|| {
RunnerError::StepFailed {
step: "build-image".into(),
msg: format!(
"build context {} exceeds the {} MiB cross-host limit (tripped at {}). \
A remote build ships its context over the network, so the whole \
directory has to travel — set `context = \"<dir>\"` on the step to the \
directory the Dockerfile actually COPYs from instead of the camp root.",
path.display(),
MAX_CONTEXT_BYTES / (1024 * 1024),
rel.display(),
),
}
})?;
let mut file = std::fs::File::open(&path)
.map_err(|e| pack_err(format!("opening {}: {e}", path.display())))?;
tar.append_file(&rel, &mut file)
.map_err(|e| pack_err(format!("adding {} to the context tar: {e}", rel.display())))?;
}
Ok(())
}
fn pack_err(msg: String) -> RunnerError {
RunnerError::StepFailed {
step: "build-image".into(),
msg,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn entries_of(tarball: &[u8]) -> Vec<(String, Vec<u8>)> {
let gz = flate2::read::GzDecoder::new(tarball);
let mut archive = tar::Archive::new(gz);
let mut out = Vec::new();
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
let name = entry.path().unwrap().to_string_lossy().into_owned();
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
out.push((name, bytes));
}
out
}
#[test]
fn packs_relative_to_the_context_root() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build-v8.sh"), b"#!/bin/sh\n").unwrap();
std::fs::create_dir(dir.path().join("patches")).unwrap();
std::fs::write(dir.path().join("patches/a.patch"), b"diff\n").unwrap();
let names: Vec<String> = entries_of(&pack_context(dir.path(), &[]).unwrap())
.into_iter()
.map(|(n, _)| n)
.collect();
assert_eq!(names, vec!["build-v8.sh", "patches/a.patch"]);
}
#[test]
fn packs_without_extended_attributes() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("verify-consumer.sh"), b"#!/bin/sh\n").unwrap();
#[cfg(target_os = "macos")]
{
let path = dir.path().join("verify-consumer.sh");
let _ = std::process::Command::new("xattr")
.args(["-w", "com.apple.provenance", "x"])
.arg(&path)
.status();
}
let tarball = pack_context(dir.path(), &[]).unwrap();
let gz = flate2::read::GzDecoder::new(tarball.as_slice());
let mut archive = tar::Archive::new(gz);
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
let name = entry.path().unwrap().to_string_lossy().into_owned();
assert!(
!name.starts_with("._"),
"AppleDouble sidecar leaked into the context tar: {name}"
);
let pax: Vec<String> = entry
.pax_extensions()
.unwrap()
.into_iter()
.flatten()
.map(|e| e.unwrap().key().unwrap().to_string())
.collect();
assert!(
!pax.iter().any(|k| k.contains("xattr")),
"{name} carries xattr pax headers {pax:?} — BuildKit will fail \
the remote-context unpack with lsetxattr: operation not supported"
);
}
}
#[test]
fn extra_entries_override_same_named_context_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("x.Dockerfile"), b"FROM stale\n").unwrap();
let tarball = pack_context(
dir.path(),
&[("x.Dockerfile".to_string(), b"FROM compiled\n".to_vec())],
)
.unwrap();
let entries = entries_of(&tarball);
let last = entries.last().unwrap();
assert_eq!(last.0, "x.Dockerfile");
assert_eq!(last.1, b"FROM compiled\n");
}
#[test]
fn packs_in_sorted_order() {
let dir = tempfile::tempdir().unwrap();
for name in ["zeta", "alpha", "mid"] {
std::fs::write(dir.path().join(name), name.as_bytes()).unwrap();
}
let names: Vec<String> = entries_of(&pack_context(dir.path(), &[]).unwrap())
.into_iter()
.map(|(n, _)| n)
.collect();
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
}
#[test]
fn oversized_context_is_refused_with_an_actionable_message() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("big.bin"), vec![0u8; 4096]).unwrap();
let mut budget = 1024u64;
let err = append_dir(
&mut tar::Builder::new(Vec::new()),
dir.path(),
Path::new(""),
&mut budget,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("big.bin"), "must name the file: {msg}");
assert!(msg.contains("context ="), "must name the fix: {msg}");
}
#[tokio::test]
async fn no_publisher_refuses_with_the_wiring_instruction() {
let err = NoBuildContextPublisher
.publish("k", vec![])
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("with_build_context_publisher"), "{msg}");
}
}