first
This commit is contained in:
+921
@@ -0,0 +1,921 @@
|
||||
use crate::apk;
|
||||
use crate::config::Config;
|
||||
use crate::graph::PackageGraph;
|
||||
use crate::log;
|
||||
use crate::patches;
|
||||
use crate::phase::{PhaseCommand, PhaseEnv, SourceDir, collect_phase_commands};
|
||||
use crate::recipe::{OutputPackage, PackageKind, Recipe, RecipeSet};
|
||||
use crate::source;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
const C_SOURCE: &str = "/source";
|
||||
const C_BUILD: &str = "/builddir";
|
||||
const C_DEST: &str = "/dest";
|
||||
const C_SYSROOT: &str = "/sysroot";
|
||||
const HOST_PREFIX: &str = "/usr/local";
|
||||
const TARGET_PREFIX: &str = "/usr";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Builder {
|
||||
repo: PathBuf,
|
||||
config: Config,
|
||||
skip_checks: bool,
|
||||
preflight_done: Cell<bool>,
|
||||
built_recipes: RefCell<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
pub fn new(repo: PathBuf, config: Config, skip_checks: bool) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
config,
|
||||
skip_checks,
|
||||
preflight_done: Cell::new(false),
|
||||
built_recipes: RefCell::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(
|
||||
&self,
|
||||
recipes: &RecipeSet,
|
||||
graph: &PackageGraph,
|
||||
package: Option<&str>,
|
||||
rebuild: bool,
|
||||
) -> Result<()> {
|
||||
self.preflight_container()?;
|
||||
let (label, order) = match package {
|
||||
Some(p) => (p.to_owned(), graph.build_order(p)?),
|
||||
None => ("<all>".to_owned(), graph.build_order_all()?),
|
||||
};
|
||||
log::step(
|
||||
"plan",
|
||||
&format!("{label}: {} package(s) [{}]", order.len(), order.join(", ")),
|
||||
);
|
||||
for output_name in order {
|
||||
let output = graph.output(&output_name)?;
|
||||
let recipe = recipes.recipe_for_package(&output_name)?;
|
||||
self.ensure_source(recipe)?;
|
||||
// Only force a rebuild of the explicitly requested package;
|
||||
// dependencies stay cached when their manifest is up to date.
|
||||
// With no package (build all), every package honours --rebuild.
|
||||
let force = rebuild && package.map_or(true, |p| p == output_name);
|
||||
match output.kind {
|
||||
PackageKind::Host => self.build_host(recipe, output, force)?,
|
||||
PackageKind::Target => self.build_target(recipe, output, force)?,
|
||||
}
|
||||
}
|
||||
self.repo_index()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fetch(&self, recipes: &RecipeSet, package: &str) -> Result<()> {
|
||||
let recipe = recipes.recipe_by_user_ref(package)?;
|
||||
log::step("fetch", &recipe.key());
|
||||
source::fetch_sources(recipe, &self.source_cache_dir())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shell(&self, recipes: &RecipeSet, graph: &PackageGraph, package: &str) -> Result<()> {
|
||||
graph.output(package)?;
|
||||
let recipe = recipes.recipe_for_package(package)?;
|
||||
self.preflight_container()?;
|
||||
let source = self.unpacked_source_dir(recipe);
|
||||
let build = self.build_dir(recipe);
|
||||
fs::create_dir_all(&source)?;
|
||||
fs::create_dir_all(&build)?;
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-it")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{C_SOURCE}:ro", source.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{C_BUILD}", build.display()))
|
||||
.arg("-w")
|
||||
.arg(C_BUILD)
|
||||
.arg(&self.config.container_image)
|
||||
.arg("/bin/sh")
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
bail!("container shell exited with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn repo_index(&self) -> Result<()> {
|
||||
self.preflight_container()?;
|
||||
let repo = self.pkgs_dir();
|
||||
fs::create_dir_all(&repo)?;
|
||||
log::step(
|
||||
"index",
|
||||
&format!("signing repository at {}", repo.display()),
|
||||
);
|
||||
let key = self.abs_config_path(&self.config.signing_key);
|
||||
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
|
||||
if !key.exists() || !pubkey.exists() {
|
||||
bail!("signing key is not configured or missing; run `distro init-key` first");
|
||||
}
|
||||
let index_name = "APKINDEX.adb";
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/repo", repo.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/keys/private.rsa:ro", key.display()))
|
||||
.arg("-v")
|
||||
.arg(format!(
|
||||
"{}:/etc/apk/keys/distro.rsa.pub:ro",
|
||||
pubkey.display()
|
||||
))
|
||||
.arg(&self.config.container_image)
|
||||
.arg("/bin/sh")
|
||||
.arg("-lc")
|
||||
.arg(format!(
|
||||
"cd /repo && apk --sign-key /keys/private.rsa mkndx -o {index_name} *.apk"
|
||||
))
|
||||
.status()
|
||||
.context("failed to run repository index command")?;
|
||||
if !status.success() {
|
||||
bail!("repository index command failed with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_key(&self) -> Result<()> {
|
||||
let key = self.abs_config_path(&self.config.signing_key);
|
||||
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
|
||||
if key.exists() && pubkey.exists() {
|
||||
log::skip("init-key", "signing key already present");
|
||||
return Ok(());
|
||||
}
|
||||
log::step("init-key", &format!("generating {}", key.display()));
|
||||
let key_dir = key
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("invalid signing key path"))?;
|
||||
let key_name = key
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("invalid signing key path"))?
|
||||
.to_string_lossy();
|
||||
fs::create_dir_all(key_dir)?;
|
||||
self.preflight_container()?;
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/keys", key_dir.display()))
|
||||
.arg(&self.config.container_image)
|
||||
.arg("/bin/sh")
|
||||
.arg("-lc")
|
||||
.arg(format!(
|
||||
"openssl genrsa -out /keys/{key_name} 4096 && \
|
||||
openssl rsa -in /keys/{key_name} -pubout -out /keys/{key_name}.pub"
|
||||
))
|
||||
.status()
|
||||
.context("failed to run key generation command")?;
|
||||
if !status.success() {
|
||||
bail!("key generation failed with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rootfs(&self, root: &Path, packages: &[String]) -> Result<()> {
|
||||
self.preflight_container()?;
|
||||
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
|
||||
if !pubkey.exists() {
|
||||
bail!("rootfs requires a configured public signing key; run `distro init-key` first");
|
||||
}
|
||||
fs::create_dir_all(root)?;
|
||||
log::step(
|
||||
"rootfs",
|
||||
&format!(
|
||||
"{} -> {} [{}]",
|
||||
packages.join(", "),
|
||||
root.display(),
|
||||
root.display()
|
||||
),
|
||||
);
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/rootfs", root.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/repo:ro", self.pkgs_dir().display()))
|
||||
.arg("-v")
|
||||
.arg(format!(
|
||||
"{}:/etc/apk/keys/distro.rsa.pub:ro",
|
||||
pubkey.display()
|
||||
))
|
||||
.arg(&self.config.container_image)
|
||||
.arg("apk")
|
||||
.arg("--root")
|
||||
.arg("/rootfs")
|
||||
.arg("--repository")
|
||||
.arg("/repo/APKINDEX.adb")
|
||||
.arg("add")
|
||||
.args(packages)
|
||||
.status()
|
||||
.context("failed to run rootfs apk command")?;
|
||||
if !status.success() {
|
||||
bail!("rootfs creation failed with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- per-recipe build steps -------------------------------------------
|
||||
|
||||
fn build_host(&self, recipe: &Recipe, output: &OutputPackage, rebuild: bool) -> Result<()> {
|
||||
let manifest = self.manifest_path(&output.key());
|
||||
let hash = self.manifest_hash(recipe)?;
|
||||
if !rebuild && fs::read_to_string(&manifest).ok().as_deref() == Some(hash.as_str()) {
|
||||
log::skip("up-to-date", &output.key());
|
||||
return Ok(());
|
||||
}
|
||||
log::step(
|
||||
"build",
|
||||
&format!("{} {}-r{}", output.key(), recipe.version, recipe.revision),
|
||||
);
|
||||
let build_dir = self.host_build_dir(recipe);
|
||||
let dest_dir = self.host_pkg_dir(recipe);
|
||||
let source_dir = self.unpacked_source_dir(recipe);
|
||||
// A rebuild starts from a clean build dir; sources stay shared.
|
||||
if rebuild {
|
||||
Self::recreate(&build_dir)?;
|
||||
} else {
|
||||
fs::create_dir_all(&build_dir)?;
|
||||
}
|
||||
Self::recreate(&dest_dir)?;
|
||||
fs::create_dir_all(&build_dir)?;
|
||||
|
||||
let env = PhaseEnv {
|
||||
source_dir: source_dir_env(recipe),
|
||||
build_dir: C_BUILD,
|
||||
dest_dir: C_DEST,
|
||||
prefix: HOST_PREFIX,
|
||||
sysroot: C_SYSROOT,
|
||||
};
|
||||
self.run_recipe_phases(
|
||||
recipe,
|
||||
output,
|
||||
&env,
|
||||
&source_dir,
|
||||
&build_dir,
|
||||
&dest_dir,
|
||||
None,
|
||||
)?;
|
||||
|
||||
fs::create_dir_all(manifest.parent().unwrap())?;
|
||||
fs::write(manifest, hash)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_target(&self, recipe: &Recipe, output: &OutputPackage, rebuild: bool) -> Result<()> {
|
||||
let manifest = self.manifest_path(&output.key());
|
||||
let hash = self.manifest_hash(recipe)?;
|
||||
if !rebuild && fs::read_to_string(&manifest).ok().as_deref() == Some(hash.as_str()) {
|
||||
log::skip("up-to-date", &output.key());
|
||||
return Ok(());
|
||||
}
|
||||
log::step(
|
||||
"build",
|
||||
&format!("{} {}-r{}", output.key(), recipe.version, recipe.revision),
|
||||
);
|
||||
let build_dir = self.build_dir(recipe);
|
||||
let source_dir = self.unpacked_source_dir(recipe);
|
||||
// A rebuild starts from a clean build dir; sources stay shared.
|
||||
if rebuild {
|
||||
Self::recreate(&build_dir)?;
|
||||
} else {
|
||||
fs::create_dir_all(&build_dir)?;
|
||||
}
|
||||
|
||||
let dest_tmp =
|
||||
tempfile::tempdir_in(&build_dir).context("failed to create ephemeral destdir")?;
|
||||
let dest_dir = dest_tmp.path();
|
||||
|
||||
let sysroot = self.materialize_sysroot(recipe)?;
|
||||
let env = PhaseEnv {
|
||||
source_dir: source_dir_env(recipe),
|
||||
build_dir: C_BUILD,
|
||||
dest_dir: C_DEST,
|
||||
prefix: TARGET_PREFIX,
|
||||
sysroot: C_SYSROOT,
|
||||
};
|
||||
self.run_recipe_phases(
|
||||
recipe,
|
||||
output,
|
||||
&env,
|
||||
&source_dir,
|
||||
&build_dir,
|
||||
dest_dir,
|
||||
sysroot.as_deref(),
|
||||
)?;
|
||||
|
||||
self.apk_mkpkg(output, dest_dir)?;
|
||||
|
||||
fs::create_dir_all(manifest.parent().unwrap())?;
|
||||
fs::write(manifest, hash)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_recipe_phases(
|
||||
&self,
|
||||
recipe: &Recipe,
|
||||
output: &OutputPackage,
|
||||
env: &PhaseEnv<'_>,
|
||||
source_dir: &Path,
|
||||
build_dir: &Path,
|
||||
dest_dir: &Path,
|
||||
target_sysroot: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
let host_sandbox = self.materialize_host_sandbox(recipe)?;
|
||||
let recipe_already_built = self.built_recipes.borrow().contains(&recipe.key());
|
||||
|
||||
// configure/build/check run at most once per recipe per invocation.
|
||||
let mut shared_phases: Vec<(&str, bool)> = Vec::new();
|
||||
if !recipe_already_built {
|
||||
if let Some(p) = recipe.configure_fn.as_deref() {
|
||||
shared_phases.push((p, false));
|
||||
}
|
||||
if let Some(p) = recipe.build_fn.as_deref() {
|
||||
shared_phases.push((p, false));
|
||||
}
|
||||
if !self.skip_checks {
|
||||
if let Some(p) = recipe.check_fn.as_deref() {
|
||||
shared_phases.push((p, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
// install runs per output (each output has its own destdir/install_fn).
|
||||
let install_phase = (output.install_fn.as_str(), true);
|
||||
|
||||
let mut commands: Vec<PhaseCommand> = Vec::new();
|
||||
for (phase, takes_pkg) in shared_phases.iter().chain(std::iter::once(&install_phase)) {
|
||||
let pkg = if *takes_pkg {
|
||||
Some((output.name.as_str(), C_DEST))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let owner = if *takes_pkg {
|
||||
output.key()
|
||||
} else {
|
||||
recipe.key()
|
||||
};
|
||||
log::info("phase", &format!("{phase} {owner}"));
|
||||
commands.extend(collect_phase_commands(
|
||||
&recipe.path,
|
||||
&self.repo,
|
||||
&self.config,
|
||||
phase,
|
||||
env,
|
||||
pkg,
|
||||
)?);
|
||||
}
|
||||
if commands.is_empty() {
|
||||
self.built_recipes.borrow_mut().insert(recipe.key());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.run_in_container(
|
||||
source_dir,
|
||||
build_dir,
|
||||
dest_dir,
|
||||
host_sandbox.as_ref().map(|s| s.path()),
|
||||
target_sysroot,
|
||||
&commands,
|
||||
)?;
|
||||
self.built_recipes.borrow_mut().insert(recipe.key());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_in_container(
|
||||
&self,
|
||||
source_dir: &Path,
|
||||
build_dir: &Path,
|
||||
dest_dir: &Path,
|
||||
host_sandbox: Option<&Path>,
|
||||
target_sysroot: Option<&Path>,
|
||||
commands: &[PhaseCommand],
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(dest_dir)?;
|
||||
let mut process = Command::new(&self.config.container_runtime);
|
||||
process
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{C_SOURCE}:ro", source_dir.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{C_BUILD}", build_dir.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{C_DEST}", dest_dir.display()))
|
||||
.arg("-w")
|
||||
.arg(C_BUILD);
|
||||
if let Some(sandbox) = host_sandbox {
|
||||
// Host packages are configured with --prefix=/usr/local, so we
|
||||
// bind the assembled tree exactly there (matching Jinx). This
|
||||
// means rpaths, --print-search-dirs, pkg-config lookups, etc.
|
||||
// all keep working with no extra environment fiddling.
|
||||
process
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{HOST_PREFIX}:ro", sandbox.display()));
|
||||
}
|
||||
if let Some(sysroot) = target_sysroot {
|
||||
process
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{C_SYSROOT}:ro", sysroot.display()))
|
||||
.arg("-e")
|
||||
.arg(format!("PKG_CONFIG_SYSROOT_DIR={C_SYSROOT}"))
|
||||
.arg("-e")
|
||||
.arg(format!("SYSROOT={C_SYSROOT}"));
|
||||
}
|
||||
process
|
||||
.arg(&self.config.container_image)
|
||||
.arg("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(build_phase_script(commands));
|
||||
let status = process
|
||||
.status()
|
||||
.context("failed to start container for phase commands")?;
|
||||
if !status.success() {
|
||||
bail!("phase commands failed with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apk_mkpkg(&self, output: &OutputPackage, dest_dir: &Path) -> Result<()> {
|
||||
let repo = self.pkgs_dir();
|
||||
fs::create_dir_all(&repo)?;
|
||||
let signing_key = self.abs_config_path(&self.config.signing_key);
|
||||
if !signing_key.exists() {
|
||||
bail!("package signing key is missing; run `distro init-key` first");
|
||||
}
|
||||
log::step("package", &format!("{} -> {}", output.name, repo.display()));
|
||||
let plan = apk::mkpkg_plan(&self.config, output, Path::new(C_DEST), Path::new("/out"));
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:{C_DEST}:ro", dest_dir.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/out", repo.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/keys/private.rsa:ro", signing_key.display()))
|
||||
.arg(&self.config.container_image)
|
||||
.arg("apk")
|
||||
.arg("--sign-key")
|
||||
.arg("/keys/private.rsa")
|
||||
.args(plan.args)
|
||||
.status()
|
||||
.context("failed to run apk mkpkg command")?;
|
||||
if !status.success() {
|
||||
bail!("apk mkpkg failed with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- source preparation (Jinx-style persistent sources/<recipe>/) ------
|
||||
|
||||
fn ensure_source(&self, recipe: &Recipe) -> Result<()> {
|
||||
let cached = source::fetch_sources(recipe, &self.source_cache_dir())?;
|
||||
|
||||
let unpacked = self.unpacked_source_dir(recipe);
|
||||
let version_stamp = self.source_stamp(recipe, "version");
|
||||
let want_version = format!("{}-r{}", recipe.version, recipe.revision);
|
||||
let need_extract =
|
||||
fs::read_to_string(&version_stamp).ok().as_deref() != Some(&want_version);
|
||||
|
||||
if need_extract {
|
||||
log::info("unpack", &recipe.key());
|
||||
if unpacked.exists() {
|
||||
fs::remove_dir_all(&unpacked)?;
|
||||
}
|
||||
fs::create_dir_all(&unpacked)?;
|
||||
for (src, tarball) in recipe.sources.iter().zip(cached.iter()) {
|
||||
let dest = if src.name.is_empty() {
|
||||
unpacked.clone()
|
||||
} else {
|
||||
unpacked.join(&src.name)
|
||||
};
|
||||
self.extract_tarball(tarball, &dest, src.strip_components)?;
|
||||
}
|
||||
fs::write(&version_stamp, &want_version)?;
|
||||
let patched = self.source_stamp(recipe, "patched");
|
||||
let _ = fs::remove_file(&patched);
|
||||
}
|
||||
|
||||
let patched_stamp = self.source_stamp(recipe, "patched");
|
||||
if !patched_stamp.exists() {
|
||||
self.apply_patches(recipe, &unpacked)?;
|
||||
fs::write(&patched_stamp, "")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tarball(&self, tarball: &Path, dest: &Path, strip_components: u32) -> Result<()> {
|
||||
fs::create_dir_all(dest)?;
|
||||
log::info(
|
||||
"extract",
|
||||
&format!("{} -> {}", tarball.display(), dest.display()),
|
||||
);
|
||||
let strip = if strip_components > 0 {
|
||||
format!("--strip-components={strip_components}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/in.tar:ro", tarball.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/out", dest.display()))
|
||||
.arg(&self.config.container_image)
|
||||
.arg("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(format!("tar -xf /in.tar -C /out {strip}"))
|
||||
.status()
|
||||
.context("failed to unpack source archive")?;
|
||||
if !status.success() {
|
||||
bail!("source unpack failed with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_patches(&self, recipe: &Recipe, source_dir: &Path) -> Result<()> {
|
||||
let patches = patches::discover(&recipe.dir)?;
|
||||
if patches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if recipe.sources.iter().any(|s| !s.name.is_empty()) {
|
||||
bail!(
|
||||
"recipe `{}` has patches/ but uses multi-source layout; \
|
||||
apply patches yourself via ctx.run in the configure phase",
|
||||
recipe.id
|
||||
);
|
||||
}
|
||||
for patch in patches {
|
||||
log::info(
|
||||
"patch",
|
||||
&format!(
|
||||
"{} <- {}",
|
||||
recipe.id,
|
||||
patch
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
);
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/source", source_dir.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/patch.diff:ro", patch.display()))
|
||||
.arg(&self.config.container_image)
|
||||
.arg("patch")
|
||||
.arg("-d")
|
||||
.arg("/source")
|
||||
.arg("-p1")
|
||||
.arg("-i")
|
||||
.arg("/patch.diff")
|
||||
.status()
|
||||
.context("failed to apply patch")?;
|
||||
if !status.success() {
|
||||
bail!("patch {} failed with {status}", patch.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- host sandbox / target sysroot materialization --------------------
|
||||
|
||||
/// Assemble all `host_deps` into a single ephemeral tree that we can mount
|
||||
/// at `/usr/local` inside the container. Following Jinx, we hard-link
|
||||
/// rather than byte-copy so this is essentially free. The returned
|
||||
/// `TempDir` cleans up when dropped.
|
||||
fn materialize_host_sandbox(&self, recipe: &Recipe) -> Result<Option<tempfile::TempDir>> {
|
||||
if recipe.host_deps.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
fs::create_dir_all(self.repo.join("build"))?;
|
||||
let sandbox = tempfile::Builder::new()
|
||||
.prefix(&format!("host-sandbox-{}-", recipe.id))
|
||||
.tempdir_in(self.repo.join("build"))
|
||||
.context("failed to create host sandbox tempdir")?;
|
||||
log::info(
|
||||
"host-sandbox",
|
||||
&format!("{} <- [{}]", recipe.id, recipe.host_deps.join(", ")),
|
||||
);
|
||||
for dep in &recipe.host_deps {
|
||||
let source = self.host_pkg_dir_by_id(dep).join("usr/local");
|
||||
if !source.exists() {
|
||||
bail!(
|
||||
"host dependency `{dep}` has not been built at {}",
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
hardlink_tree(&source, sandbox.path())?;
|
||||
}
|
||||
Ok(Some(sandbox))
|
||||
}
|
||||
|
||||
fn materialize_sysroot(&self, recipe: &Recipe) -> Result<Option<PathBuf>> {
|
||||
let mut deps: Vec<String> = recipe
|
||||
.build_deps
|
||||
.iter()
|
||||
.chain(recipe.run_deps.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
deps.sort();
|
||||
deps.dedup();
|
||||
if deps.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
|
||||
if !pubkey.exists() {
|
||||
bail!("target dependency sysroot requires a configured public signing key");
|
||||
}
|
||||
let sysroot = self.repo.join("build/sysroots").join(&recipe.id);
|
||||
log::info(
|
||||
"sysroot",
|
||||
&format!("{} <- [{}]", recipe.id, deps.join(", ")),
|
||||
);
|
||||
Self::recreate(&sysroot)?;
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/sysroot", sysroot.display()))
|
||||
.arg("-v")
|
||||
.arg(format!("{}:/repo:ro", self.pkgs_dir().display()))
|
||||
.arg("-v")
|
||||
.arg(format!(
|
||||
"{}:/etc/apk/keys/distro.rsa.pub:ro",
|
||||
pubkey.display()
|
||||
))
|
||||
.arg(&self.config.container_image)
|
||||
.arg("apk")
|
||||
.arg("--root")
|
||||
.arg("/sysroot")
|
||||
.arg("--repository")
|
||||
.arg("/repo/APKINDEX.adb")
|
||||
.arg("add")
|
||||
.args(&deps)
|
||||
.status()
|
||||
.context("failed to install target dependency sysroot")?;
|
||||
if !status.success() {
|
||||
bail!("target dependency sysroot install failed with {status}");
|
||||
}
|
||||
Ok(Some(sysroot))
|
||||
}
|
||||
|
||||
// --- preflight & container image --------------------------------------
|
||||
|
||||
fn preflight_container(&self) -> Result<()> {
|
||||
if self.preflight_done.get() {
|
||||
return Ok(());
|
||||
}
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"{} is required but was not found",
|
||||
self.config.container_runtime
|
||||
)
|
||||
})?;
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"{} preflight failed with {status}",
|
||||
self.config.container_runtime
|
||||
);
|
||||
}
|
||||
let dockerfile = self.abs_config_path(&self.config.container_dockerfile);
|
||||
self.ensure_container_image(&dockerfile)?;
|
||||
self.preflight_done.set(true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_container_image(&self, dockerfile: &Path) -> Result<()> {
|
||||
if !dockerfile.exists() {
|
||||
bail!(
|
||||
"configured container Dockerfile does not exist: {}",
|
||||
dockerfile.display()
|
||||
);
|
||||
}
|
||||
let hash = self.container_build_hash(dockerfile)?;
|
||||
let stamp = self.repo.join("build/container-image.hash");
|
||||
if fs::read_to_string(&stamp).ok().as_deref() == Some(hash.as_str())
|
||||
&& self.container_image_exists()?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
log::step(
|
||||
"image",
|
||||
&format!(
|
||||
"building {} from {}",
|
||||
self.config.container_image,
|
||||
dockerfile.display()
|
||||
),
|
||||
);
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("build")
|
||||
.arg("-f")
|
||||
.arg(dockerfile)
|
||||
.arg("-t")
|
||||
.arg(&self.config.container_image)
|
||||
.arg(&self.repo)
|
||||
.status()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to build container image `{}` from {}",
|
||||
self.config.container_image,
|
||||
dockerfile.display()
|
||||
)
|
||||
})?;
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"container image build failed for `{}` with {status}",
|
||||
self.config.container_image
|
||||
);
|
||||
}
|
||||
fs::create_dir_all(stamp.parent().unwrap())?;
|
||||
fs::write(stamp, hash)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn container_image_exists(&self) -> Result<bool> {
|
||||
let status = Command::new(&self.config.container_runtime)
|
||||
.arg("image")
|
||||
.arg("exists")
|
||||
.arg(&self.config.container_image)
|
||||
.status()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to inspect container image `{}`",
|
||||
self.config.container_image
|
||||
)
|
||||
})?;
|
||||
Ok(status.success())
|
||||
}
|
||||
|
||||
fn container_build_hash(&self, dockerfile: &Path) -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(fs::read(dockerfile)?);
|
||||
hasher.update(self.config.container_image.as_bytes());
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
fn manifest_hash(&self, recipe: &Recipe) -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.config.target_arch.as_bytes());
|
||||
hasher.update(recipe.version.as_bytes());
|
||||
hasher.update(recipe.revision.to_le_bytes());
|
||||
for source in &recipe.sources {
|
||||
hasher.update(source.url.as_bytes());
|
||||
hasher.update(source.sha256.as_bytes());
|
||||
}
|
||||
for patch in patches::discover(&recipe.dir)? {
|
||||
hasher.update(patch.display().to_string().as_bytes());
|
||||
hasher.update(fs::read(patch)?);
|
||||
}
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
// --- path helpers -----------------------------------------------------
|
||||
|
||||
fn source_cache_dir(&self) -> PathBuf {
|
||||
self.repo.join("build/cache/sources")
|
||||
}
|
||||
fn unpacked_source_dir(&self, recipe: &Recipe) -> PathBuf {
|
||||
self.repo.join("build/sources").join(recipe.slug())
|
||||
}
|
||||
fn source_stamp(&self, recipe: &Recipe, kind: &str) -> PathBuf {
|
||||
self.repo
|
||||
.join("build/sources")
|
||||
.join(format!("{}.{kind}", recipe.slug()))
|
||||
}
|
||||
fn build_dir(&self, recipe: &Recipe) -> PathBuf {
|
||||
self.repo.join("build/builds").join(&recipe.id)
|
||||
}
|
||||
fn host_build_dir(&self, recipe: &Recipe) -> PathBuf {
|
||||
self.repo.join("build/host-builds").join(&recipe.id)
|
||||
}
|
||||
fn host_pkg_dir(&self, recipe: &Recipe) -> PathBuf {
|
||||
self.repo.join("build/host-pkgs").join(&recipe.id)
|
||||
}
|
||||
fn host_pkg_dir_by_id(&self, host_recipe_id: &str) -> PathBuf {
|
||||
self.repo.join("build/host-pkgs").join(host_recipe_id)
|
||||
}
|
||||
fn pkgs_dir(&self) -> PathBuf {
|
||||
self.repo.join("build/pkgs")
|
||||
}
|
||||
fn manifest_path(&self, output_key: &str) -> PathBuf {
|
||||
// Output keys may contain `:` (e.g. `host:gcc`); the manifest file
|
||||
// name uses the filesystem-safe slug form instead.
|
||||
let safe = output_key.replace(':', "-");
|
||||
self.repo
|
||||
.join("build/manifests")
|
||||
.join(format!("{safe}.hash"))
|
||||
}
|
||||
fn abs_config_path(&self, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
self.repo.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn recreate(path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
fs::remove_dir_all(path)?;
|
||||
}
|
||||
fs::create_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_phase_script(commands: &[PhaseCommand]) -> String {
|
||||
let mut parts: Vec<String> = Vec::with_capacity(commands.len() + 1);
|
||||
parts.push("set -e".to_owned());
|
||||
for cmd in commands {
|
||||
let mut tokens: Vec<String> = Vec::with_capacity(cmd.env.len() + cmd.argv.len());
|
||||
for (k, v) in &cmd.env {
|
||||
let value = shell_escape::unix::escape(v.as_str().into()).into_owned();
|
||||
tokens.push(format!("{k}={value}"));
|
||||
}
|
||||
for arg in &cmd.argv {
|
||||
tokens.push(shell_escape::unix::escape(arg.as_str().into()).into_owned());
|
||||
}
|
||||
parts.push(tokens.join(" "));
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Expose `/source` as a string for single-source recipes, or a struct of
|
||||
/// `/source/<name>` paths for multi-source recipes.
|
||||
fn source_dir_env(recipe: &Recipe) -> SourceDir {
|
||||
if recipe.sources.iter().all(|s| s.name.is_empty()) {
|
||||
SourceDir::Single(C_SOURCE.to_owned())
|
||||
} else {
|
||||
let map = recipe
|
||||
.sources
|
||||
.iter()
|
||||
.map(|s| (s.name.clone(), format!("{C_SOURCE}/{}", s.name)))
|
||||
.collect();
|
||||
SourceDir::Many(map)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror `src` into `dst` using hard links for regular files (and preserving
|
||||
/// symlinks). This matches Jinx's `cp -Pplr`: no bytes are copied, just inode
|
||||
/// references, so assembling a host-deps sandbox is essentially free.
|
||||
fn hardlink_tree(src: &Path, dst: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dst)?;
|
||||
for entry in walkdir::WalkDir::new(src) {
|
||||
let entry = entry?;
|
||||
let relative = entry.path().strip_prefix(src)?;
|
||||
if relative.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let target = dst.join(relative);
|
||||
let file_type = entry.file_type();
|
||||
if file_type.is_dir() {
|
||||
fs::create_dir_all(&target)?;
|
||||
} else if file_type.is_symlink() {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let link_target = fs::read_link(entry.path())?;
|
||||
// Replace any pre-existing entry (multiple host deps may ship the
|
||||
// same symlink path).
|
||||
let _ = fs::remove_file(&target);
|
||||
std::os::unix::fs::symlink(&link_target, &target)?;
|
||||
} else if file_type.is_file() {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if target.exists() {
|
||||
continue;
|
||||
}
|
||||
fs::hard_link(entry.path(), &target).with_context(|| {
|
||||
format!(
|
||||
"failed to hard-link {} -> {}",
|
||||
entry.path().display(),
|
||||
target.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user