950 lines
33 KiB
Rust
950 lines
33 KiB
Rust
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)?;
|
|
// Nothing to index yet — leave it alone so callers don't trip on a
|
|
// failing `*.apk` glob.
|
|
let has_apks = fs::read_dir(&repo)?.any(|e| {
|
|
e.ok()
|
|
.and_then(|e| e.path().extension().map(|x| x == "apk"))
|
|
.unwrap_or(false)
|
|
});
|
|
if !has_apks {
|
|
return Ok(());
|
|
}
|
|
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.tar.gz";
|
|
let status = Command::new(&self.config.container_runtime)
|
|
.arg("run")
|
|
.arg("--rm")
|
|
.arg("-v")
|
|
.arg(format!("{}:/repo", repo.display()))
|
|
.arg("-v")
|
|
.arg(format!("{}:/keys/distro.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/distro.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_root().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("--keys-dir")
|
|
.arg("/etc/apk/keys")
|
|
.arg("--initdb")
|
|
.arg("--repository")
|
|
.arg("/repo")
|
|
.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_ref().map(|s| s.path()),
|
|
)?;
|
|
|
|
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/distro.rsa:ro", signing_key.display()))
|
|
.arg(&self.config.container_image)
|
|
.arg("apk")
|
|
.arg("--sign-key")
|
|
.arg("/keys/distro.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<tempfile::TempDir>> {
|
|
let mut deps: Vec<String> = recipe
|
|
.build_deps
|
|
.iter()
|
|
.chain(recipe.deps.iter())
|
|
.cloned()
|
|
.collect();
|
|
deps.sort();
|
|
deps.dedup();
|
|
if deps.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
// The local repo index must be present and current so apk can resolve
|
|
// and verify the just-built target packages.
|
|
self.repo_index()?;
|
|
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
|
|
if !pubkey.exists() {
|
|
bail!("target dependency sysroot requires a configured public signing key");
|
|
}
|
|
fs::create_dir_all(self.repo.join("build"))?;
|
|
let sysroot = tempfile::Builder::new()
|
|
.prefix(&format!("sysroot-{}-", recipe.id))
|
|
.tempdir_in(self.repo.join("build"))
|
|
.context("failed to create sysroot tempdir")?;
|
|
log::info(
|
|
"sysroot",
|
|
&format!("{} <- [{}]", recipe.id, deps.join(", ")),
|
|
);
|
|
let status = Command::new(&self.config.container_runtime)
|
|
.arg("run")
|
|
.arg("--rm")
|
|
.arg("-v")
|
|
.arg(format!("{}:/sysroot", sysroot.path().display()))
|
|
.arg("-v")
|
|
.arg(format!("{}:/repo:ro", self.pkgs_root().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("--keys-dir")
|
|
.arg("/etc/apk/keys")
|
|
.arg("--initdb")
|
|
.arg("--repository")
|
|
.arg("/repo")
|
|
.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)
|
|
}
|
|
/// Root of the target package repo. apk treats this as the repo root and
|
|
/// expects `<root>/<arch>/APKINDEX.tar.gz` underneath.
|
|
fn pkgs_root(&self) -> PathBuf {
|
|
self.repo.join("build/pkgs")
|
|
}
|
|
/// Arch-specific package directory: where .apk files and the index live.
|
|
fn pkgs_dir(&self) -> PathBuf {
|
|
self.pkgs_root().join(&self.config.target_arch)
|
|
}
|
|
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(())
|
|
}
|