This commit is contained in:
2026-05-19 03:24:10 +02:00
parent 0c9a3fde94
commit 45d47e8d84
12 changed files with 1481 additions and 299 deletions
+714 -9
View File
@@ -1,17 +1,27 @@
use std::{
cell::RefCell,
collections::{BTreeSet, VecDeque},
fs,
io::Write,
path::{Path, PathBuf},
process::Command,
process::{Command, Stdio},
rc::Rc,
thread,
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, bail};
use sha2::{Digest, Sha256};
use starlark::values::OwnedFrozenValue;
use crate::{
config::Config,
graph::{TaskPlan, TaskPlanner},
container::{Container, Mount},
graph::{TaskId, TaskPlan, TaskPlanner, task_recipe_slug},
layout::Layout,
log,
recipe::RecipeSet,
phase::{self, PackageContext, PhaseArg, PhaseContext, PhaseRuntime, PhaseRuntimeGuard, SourceDir},
recipe::{GitSource, OutputPackage, Recipe, RecipeKind, RecipeSet, Source},
};
#[derive(Debug)]
@@ -40,10 +50,10 @@ impl Builder {
let plan = TaskPlanner::new(&self.root, &self.config.arch, recipes)
.build_plan(requested, rebuild)?;
self.print_plan(&plan);
if !dry_run {
bail!("task execution is not implemented yet");
if dry_run {
return Ok(());
}
Ok(())
self.execute_plan(recipes, &plan)
}
pub fn fetch(
@@ -55,10 +65,10 @@ impl Builder {
let plan =
TaskPlanner::new(&self.root, &self.config.arch, recipes).fetch_plan(requested)?;
self.print_plan(&plan);
if !dry_run {
bail!("task execution is not implemented yet");
if dry_run {
return Ok(());
}
Ok(())
self.execute_plan(recipes, &plan)
}
pub fn ensure_container_ready(&mut self) -> anyhow::Result<()> {
@@ -70,6 +80,610 @@ impl Builder {
Ok(())
}
fn execute_plan(&mut self, recipes: &RecipeSet, plan: &TaskPlan) -> anyhow::Result<()> {
if plan.is_empty() {
return Ok(());
}
let mut active: Option<ActiveContainer> = None;
for task in plan.order() {
let recipe_slug = task_recipe_slug(task, recipes)?;
if let Some(current) = active.as_ref() {
if current.recipe_key != recipe_slug {
active = None;
}
}
if task_needs_container(task) && active.is_none() {
self.ensure_container_ready()?;
active = Some(self.start_recipe_container(recipes, &recipe_slug)?);
}
let layout = Layout::new(&self.root, &self.config.arch);
self.run_task(&layout, recipes, task, active.as_ref())?;
}
Ok(())
}
fn run_task(
&self,
layout: &Layout<'_>,
recipes: &RecipeSet,
task: &TaskId,
active: Option<&ActiveContainer>,
) -> anyhow::Result<()> {
match task {
TaskId::FetchSources(key) => {
let recipe = recipes.recipe(key)?;
self.task_fetch_sources(layout, recipe)
}
TaskId::PrepareSources(key) => {
let recipe = recipes.recipe(key)?;
self.task_prepare_sources(layout, recipe)
}
TaskId::ConfigureRecipe(key) => {
let recipe = recipes.recipe(key)?;
let active = active.expect("configure task requires an active container");
self.task_configure(layout, recipe, active)
}
TaskId::BuildRecipe(key) => {
let recipe = recipes.recipe(key)?;
let active = active.expect("build task requires an active container");
self.task_build(layout, recipe, active)
}
TaskId::InstallPackageFiles(output_key) => {
let output = recipes.output(output_key)?;
let recipe = recipes.recipe(output.recipe())?;
let active = active.expect("install task requires an active container");
self.task_install_package(layout, recipe, output, active)
}
TaskId::ProduceApk(output_key) => {
let output = recipes.output(output_key)?;
let recipe = recipes.recipe(output.recipe())?;
let active = active.expect("apk task requires an active container");
self.task_produce_apk(layout, recipe, output, active)
}
TaskId::InstallHostRecipe(key) => {
let recipe = recipes.recipe(key)?;
let active = active.expect("host install task requires an active container");
self.task_install_host(layout, recipe, active)
}
}
}
fn task_fetch_sources(&self, layout: &Layout<'_>, recipe: &Recipe) -> anyhow::Result<()> {
fs::create_dir_all(layout.source_cache_dir())?;
for (name, source) in recipe.sources().entries() {
let label = name.unwrap_or("source");
if !source.is_unknown_cache_key()
&& layout.source_cache_path(source.cache_key()).exists()
{
log::skip("fetch", &format!("{} ({})", recipe.key(), label));
continue;
}
log::step(
"fetch",
&format!("{} ({}) from {}", recipe.key(), label, source.url()),
);
match source {
Source::Tarball(_) => self.fetch_tarball(layout, recipe, source)?,
Source::Git(git) => self.fetch_git(layout, recipe, git)?,
}
}
Ok(())
}
fn fetch_tarball(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
source: &Source,
) -> anyhow::Result<()> {
let url = source.url();
let expected = source.cache_key().to_owned();
let unknown = source.is_unknown_cache_key();
let client = reqwest::blocking::Client::builder()
.user_agent("distro-builder")
.build()
.context("building HTTP client")?;
let mut response = client
.get(url)
.send()
.with_context(|| format!("downloading {url}"))?
.error_for_status()
.with_context(|| format!("downloading {url}"))?;
let cache_dir = layout.source_cache_dir();
fs::create_dir_all(&cache_dir)?;
let mut tmp = tempfile::NamedTempFile::new_in(&cache_dir)
.with_context(|| format!("creating temp file in {}", cache_dir.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = std::io::Read::read(&mut response, &mut buf)
.with_context(|| format!("reading response body for {url}"))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
tmp.write_all(&buf[..n])?;
}
tmp.flush()?;
let hash = hex::encode(hasher.finalize());
if unknown {
log::info(
"fetch",
&format!("{}: computed sha256 = {hash}", recipe.key()),
);
} else if hash != expected {
bail!("sha256 mismatch for {url}: expected {expected}, got {hash}");
}
let final_path = layout.source_cache_path(&hash);
if !final_path.exists() {
tmp.persist(&final_path).with_context(|| {
format!("renaming downloaded archive to {}", final_path.display())
})?;
}
if unknown {
bail!(
"{}: source sha256 is unknown; update the recipe with sha256 = \"{hash}\"",
recipe.key()
);
}
Ok(())
}
fn fetch_git(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
source: &GitSource,
) -> anyhow::Result<()> {
let url = source.url();
let commit = source.commit();
let unknown = matches!(commit, "?" | "???");
let cache_dir = layout.source_cache_dir();
fs::create_dir_all(&cache_dir)?;
let tmp = tempfile::tempdir_in(&cache_dir)?;
let work = tmp.path().join("repo");
let status = Command::new("git")
.arg("clone")
.arg("--bare")
.arg(url)
.arg(&work)
.status()
.with_context(|| format!("spawning git clone {url}"))?;
if !status.success() {
bail!("git clone failed for {url} with {status}");
}
let rev = if unknown {
let out = Command::new("git")
.arg("-C")
.arg(&work)
.arg("rev-parse")
.arg("HEAD")
.output()
.context("spawning git rev-parse HEAD")?;
if !out.status.success() {
bail!("git rev-parse HEAD failed with {}", out.status);
}
let rev = String::from_utf8(out.stdout)?.trim().to_owned();
log::info(
"fetch",
&format!("{}: resolved git HEAD = {rev}", recipe.key()),
);
rev
} else {
let status = Command::new("git")
.arg("-C")
.arg(&work)
.arg("cat-file")
.arg("-e")
.arg(commit)
.status()
.context("spawning git cat-file")?;
if !status.success() {
bail!("commit {commit} not found in {url}");
}
commit.to_owned()
};
let final_path = layout.source_cache_path(&rev);
if !final_path.exists() {
fs::rename(&work, &final_path).with_context(|| {
format!(
"moving git clone to {} from {}",
final_path.display(),
work.display()
)
})?;
}
if unknown {
bail!(
"{}: source commit is unknown; update the recipe with commit = \"{rev}\"",
recipe.key()
);
}
Ok(())
}
fn task_prepare_sources(&self, layout: &Layout<'_>, recipe: &Recipe) -> anyhow::Result<()> {
log::step("prepare", &recipe.key());
let src = layout.source_workdir(recipe);
if src.exists() {
fs::remove_dir_all(&src).with_context(|| format!("clearing {}", src.display()))?;
}
fs::create_dir_all(&src)?;
let build = layout.build_workdir(recipe);
if build.exists() {
fs::remove_dir_all(&build).with_context(|| format!("clearing {}", build.display()))?;
}
fs::create_dir_all(&build)?;
for (name, source) in recipe.sources().entries() {
if source.is_unknown_cache_key() {
bail!(
"source for {} has an unknown cache key; run `distro fetch` first",
recipe.key()
);
}
let cache_path = layout.source_cache_path(source.cache_key());
if !cache_path.exists() {
bail!(
"missing cached source for {} at {}",
recipe.key(),
cache_path.display()
);
}
let dst = match name {
None => src.clone(),
Some(named) => {
let dst = src.join(named);
fs::create_dir_all(&dst)?;
dst
}
};
match source {
Source::Tarball(tar) => {
let mut cmd = Command::new("tar");
cmd.arg("-xf").arg(&cache_path);
if tar.strip_components() > 0 {
cmd.arg(format!("--strip-components={}", tar.strip_components()));
}
cmd.arg("-C").arg(&dst);
let status = cmd
.status()
.with_context(|| format!("spawning tar -xf {}", cache_path.display()))?;
if !status.success() {
bail!(
"tar extraction of {} failed with {status}",
cache_path.display()
);
}
}
Source::Git(git) => {
let status = Command::new("git")
.arg("clone")
.arg(&cache_path)
.arg(&dst)
.status()
.context("spawning git clone from cache")?;
if !status.success() {
bail!("git clone from cache failed with {status}");
}
let status = Command::new("git")
.arg("-C")
.arg(&dst)
.arg("checkout")
.arg(git.commit())
.status()
.context("spawning git checkout")?;
if !status.success() {
bail!("git checkout {} failed with {status}", git.commit());
}
}
}
}
let version_stamp = layout.source_stamp(recipe, "version");
fs::create_dir_all(version_stamp.parent().unwrap())?;
fs::write(
&version_stamp,
format!("{}-r{}", recipe.version(), recipe.revision()),
)?;
if layout.recipe_has_patches(recipe)? {
log::skip(
"patches",
&format!("{}: patch application not implemented", recipe.key()),
);
fs::write(layout.source_stamp(recipe, "patched"), b"skipped")?;
}
Ok(())
}
fn task_configure(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
active: &ActiveContainer,
) -> anyhow::Result<()> {
log::step("configure", &recipe.key());
if let Some(func) = recipe.phases().configure() {
let ctx = PhaseContext::new(source_dir_for(recipe), prefix_for(recipe.kind()), default_jobs());
self.invoke_with_runtime(active, &[PhaseArg::Ctx(ctx)], func)?;
}
self.write_recipe_stamp(layout, recipe, "configure")
}
fn task_build(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
active: &ActiveContainer,
) -> anyhow::Result<()> {
log::step("build", &recipe.key());
let ctx = PhaseContext::new(source_dir_for(recipe), prefix_for(recipe.kind()), default_jobs());
self.invoke_with_runtime(active, &[PhaseArg::Ctx(ctx)], recipe.phases().build())?;
self.write_recipe_stamp(layout, recipe, "build")
}
fn task_install_package(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
output: &OutputPackage,
active: &ActiveContainer,
) -> anyhow::Result<()> {
log::step("install", &output.key());
let dest = format!("/dest/{}", output.name());
active.container.borrow().exec(
&["mkdir".to_owned(), "-p".to_owned(), dest.clone()],
&base_env(&active.base_path),
"/",
)?;
let ctx = PhaseContext::new(source_dir_for(recipe), prefix_for(recipe.kind()), default_jobs());
let pkg = PackageContext::new(dest);
self.invoke_with_runtime(
active,
&[PhaseArg::Ctx(ctx), PhaseArg::Pkg(pkg)],
recipe.phases().install(),
)?;
self.write_output_stamp(layout, recipe, output, "install")
}
fn task_install_host(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
active: &ActiveContainer,
) -> anyhow::Result<()> {
log::step("host-install", &recipe.key());
let dest = format!("/dest/{}", recipe.slug());
active.container.borrow().exec(
&["mkdir".to_owned(), "-p".to_owned(), dest.clone()],
&base_env(&active.base_path),
"/",
)?;
let ctx = PhaseContext::new(source_dir_for(recipe), prefix_for(recipe.kind()), default_jobs());
let pkg = PackageContext::new(dest.clone());
self.invoke_with_runtime(
active,
&[PhaseArg::Ctx(ctx), PhaseArg::Pkg(pkg)],
recipe.phases().install(),
)?;
let host_root = layout.host_install_root(recipe);
if host_root.exists() {
fs::remove_dir_all(&host_root)
.with_context(|| format!("clearing {}", host_root.display()))?;
}
fs::create_dir_all(&host_root)?;
active
.container
.borrow()
.cp_out(&format!("{dest}/."), &host_root)?;
self.write_recipe_stamp(layout, recipe, "host-install")
}
fn task_produce_apk(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
output: &OutputPackage,
active: &ActiveContainer,
) -> anyhow::Result<()> {
log::step("apk", &output.key());
let files_dir = format!("/dest/{}", output.name());
let file_name = format!(
"{}-{}-r{}.apk",
output.name(),
recipe.version(),
recipe.revision()
);
let out_in_container = format!("/pkgs/{file_name}");
let version = format!("{}-r{}", recipe.version(), recipe.revision());
let mut argv: Vec<String> = vec![
"apk".to_owned(),
"mkpkg".to_owned(),
"--files".to_owned(),
files_dir,
"--output".to_owned(),
out_in_container,
"--info".to_owned(),
format!("name:{}", output.name()),
"--info".to_owned(),
format!("version:{version}"),
"--info".to_owned(),
format!("arch:{}", self.config.arch),
"--info".to_owned(),
format!("origin:{}", recipe.key()),
];
if let Some(desc) = output.metadata().description() {
argv.push("--info".to_owned());
argv.push(format!("description:{desc}"));
}
if let Some(license) = output.metadata().license() {
argv.push("--info".to_owned());
argv.push(format!("license:{license}"));
}
if let Some(url) = output.metadata().website() {
argv.push("--info".to_owned());
argv.push(format!("url:{url}"));
}
if let Some(packager) = output.metadata().maintainer() {
argv.push("--info".to_owned());
argv.push(format!("packager:{packager}"));
}
for dep in recipe.deps().iter().chain(recipe.run_deps().iter()) {
argv.push("--info".to_owned());
argv.push(format!("depends:{dep}"));
}
active
.container
.borrow()
.exec(&argv, &base_env(&active.base_path), "/")?;
self.write_output_stamp(layout, recipe, output, "apk")
}
fn invoke_with_runtime(
&self,
active: &ActiveContainer,
args: &[PhaseArg],
func: &OwnedFrozenValue,
) -> anyhow::Result<()> {
let runtime = PhaseRuntime {
container: active.container.clone(),
base_path: active.base_path.clone(),
base_env: bare_env(),
};
let _guard = PhaseRuntimeGuard::enter(runtime);
phase::invoke_phase(func, args)
}
fn start_recipe_container(
&self,
recipes: &RecipeSet,
recipe_key: &str,
) -> anyhow::Result<ActiveContainer> {
let recipe = recipes.recipe(recipe_key)?;
let layout = Layout::new(&self.root, &self.config.arch);
let source_dir = layout.source_workdir(recipe);
let build_dir = layout.build_workdir(recipe);
fs::create_dir_all(&source_dir)?;
fs::create_dir_all(&build_dir)?;
let host_deps = transitive_host_deps(recipes, recipe)?;
let pkgs_dir = self.root.join("build/pkgs").join(&self.config.arch);
fs::create_dir_all(&pkgs_dir)?;
let mut mounts = vec![
Mount {
host: source_dir,
container: "/sources".to_owned(),
read_only: false,
},
Mount {
host: build_dir,
container: "/build".to_owned(),
read_only: false,
},
Mount {
host: pkgs_dir,
container: "/pkgs".to_owned(),
read_only: false,
},
];
let mut tools_bins: Vec<String> = Vec::new();
for dep_key in &host_deps {
let dep_recipe = recipes.recipe(dep_key)?;
let install = layout.host_install_dir(dep_recipe);
if !install.exists() {
bail!(
"missing host install for {dep_key} at {}; build it first",
install.display()
);
}
let bare = dep_key.strip_prefix("host:").unwrap_or(dep_key);
mounts.push(Mount {
host: install,
container: format!("/tools/{bare}/usr/local"),
read_only: true,
});
tools_bins.push(format!("/tools/{bare}/usr/local/bin"));
}
let name = format!(
"distro-builder-{}-{:x}",
std::process::id(),
random_suffix()
);
let container = Container::start(
&self.config.container_runtime,
&self.config.container_image,
&name,
&mounts,
)?;
let mut path_segments = tools_bins;
path_segments.push("/usr/local/sbin".to_owned());
path_segments.push("/usr/local/bin".to_owned());
path_segments.push("/usr/sbin".to_owned());
path_segments.push("/usr/bin".to_owned());
path_segments.push("/sbin".to_owned());
path_segments.push("/bin".to_owned());
let base_path = path_segments.join(":");
let active = ActiveContainer {
recipe_key: recipe_key.to_owned(),
container: Rc::new(RefCell::new(container)),
base_path,
};
Ok(active)
}
fn write_recipe_stamp(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
kind: &str,
) -> anyhow::Result<()> {
let path = layout.recipe_task_stamp(recipe, kind);
fs::create_dir_all(path.parent().unwrap())?;
fs::write(path, layout.recipe_fingerprint(recipe)?)?;
Ok(())
}
fn write_output_stamp(
&self,
layout: &Layout<'_>,
recipe: &Recipe,
output: &OutputPackage,
kind: &str,
) -> anyhow::Result<()> {
let path = layout.output_task_stamp(output, kind);
fs::create_dir_all(path.parent().unwrap())?;
fs::write(path, layout.output_fingerprint(recipe, output)?)?;
Ok(())
}
fn ensure_container_image(&self, dockerfile: &Path) -> anyhow::Result<()> {
if !dockerfile.exists() {
bail!(
@@ -132,6 +746,8 @@ impl Builder {
.arg("image")
.arg("exists")
.arg(&self.config.container_image)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.with_context(|| {
format!(
@@ -176,3 +792,92 @@ impl Builder {
}
}
}
struct ActiveContainer {
recipe_key: String,
container: Rc<RefCell<Container>>,
base_path: String,
}
fn task_needs_container(task: &TaskId) -> bool {
matches!(
task,
TaskId::ConfigureRecipe(_)
| TaskId::BuildRecipe(_)
| TaskId::InstallPackageFiles(_)
| TaskId::ProduceApk(_)
| TaskId::InstallHostRecipe(_)
)
}
fn prefix_for(kind: RecipeKind) -> &'static str {
match kind {
RecipeKind::Package => "/usr",
RecipeKind::HostPackage => "/usr/local",
}
}
fn source_dir_for(recipe: &Recipe) -> SourceDir {
let entries = recipe.sources().entries();
let named: Vec<(&str, &crate::recipe::Source)> = entries
.iter()
.filter_map(|(name, src)| name.map(|n| (n, *src)))
.collect();
if named.is_empty() {
SourceDir::single("/sources")
} else {
SourceDir::named(named.into_iter().map(|(n, _)| (n.to_owned(), format!("/sources/{n}"))))
}
}
fn default_jobs() -> i32 {
thread::available_parallelism()
.map(|n| n.get() as i32)
.unwrap_or(1)
}
fn bare_env() -> Vec<(String, String)> {
vec![
("PATH".to_owned(), String::new()),
("HOME".to_owned(), "/tmp".to_owned()),
("TERM".to_owned(), "dumb".to_owned()),
("LC_ALL".to_owned(), "C".to_owned()),
]
}
fn base_env(path: &str) -> Vec<(String, String)> {
let mut env = bare_env();
if let Some(slot) = env.iter_mut().find(|(k, _)| k == "PATH") {
slot.1 = path.to_owned();
}
env
}
fn transitive_host_deps(recipes: &RecipeSet, recipe: &Recipe) -> anyhow::Result<Vec<String>> {
let mut order: Vec<String> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut queue: VecDeque<String> = recipe
.host_deps()
.iter()
.map(|d| RecipeKind::HostPackage.key(d))
.collect();
while let Some(key) = queue.pop_front() {
if !seen.insert(key.clone()) {
continue;
}
let dep = recipes.recipe(&key)?;
for sub in dep.host_deps() {
queue.push_back(RecipeKind::HostPackage.key(sub));
}
order.push(key);
}
Ok(order)
}
fn random_suffix() -> u64 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
nanos.wrapping_mul(0x9E3779B97F4A7C15)
}