From 0c9a3fde9452a8f4a16a45779b3428715acc7480 Mon Sep 17 00:00:00 2001 From: iretq Date: Mon, 18 May 2026 20:39:21 +0200 Subject: [PATCH] wip 2 --- .../{gcc.star.old => gcc-bootstrap.star} | 33 +- src/builder.rs | 179 +++++- src/cli.rs | 22 +- src/config.rs | 17 + src/graph.rs | 552 ++++++++++++++++++ src/log.rs | 26 + src/main.rs | 2 + src/recipe/mod.rs | 101 ++++ src/recipe/source.rs | 20 + 9 files changed, 930 insertions(+), 22 deletions(-) rename host-recipes/{gcc.star.old => gcc-bootstrap.star} (52%) create mode 100644 src/graph.rs create mode 100644 src/log.rs diff --git a/host-recipes/gcc.star.old b/host-recipes/gcc-bootstrap.star similarity index 52% rename from host-recipes/gcc.star.old rename to host-recipes/gcc-bootstrap.star index 57b00f7..dd23f84 100644 --- a/host-recipes/gcc.star.old +++ b/host-recipes/gcc-bootstrap.star @@ -1,23 +1,22 @@ -name = "gcc" version = "16.1.0" revision = 1 -description = "GNU GCC cross-compiler (bootstrap stage, C/C++ only)" -license = "GPL-3.0-or-later" - -source = { - "url": f"https://ftp.gnu.org/gnu/gcc/gcc-{version}/gcc-{version}.tar.xz", - "sha256": "50efb4d94c3397aff3b0d61a5abd748b4dd31d9d3f2ab7be05b171d36a510f79", - "strip_components": 1, -} - +metadata = meta( + description = "GNU GCC cross-compiler (bootstrap stage, C/C++ only)", + license = "GPL-3.0-or-later", +) +source = tarball_source( + url = f"https://ftp.gnu.org/gnu/gcc/gcc-{version}/gcc-{version}.tar.xz", + sha256 = "50efb4d94c3397aff3b0d61a5abd748b4dd31d9d3f2ab7be05b171d36a510f79", + strip_components = 1, +) host_deps = ["binutils"] def configure(ctx): ctx.run([ ctx.source_dir + "/configure", + "--target=" + options.target_triple, "--prefix=" + ctx.prefix, - "--target=" + OPTIONS.target_triple, - "--with-sysroot=" + ctx.prefix + "/" + OPTIONS.target_triple, + "--with-sysroot=" + ctx.sysroot, "--without-headers", "--with-newlib", "--enable-languages=c,c++", @@ -33,9 +32,9 @@ def configure(ctx): "--disable-libvtv", "--disable-multilib", ], env = { - "CFLAGS": OPTIONS.host_cflags, - "CXXFLAGS": OPTIONS.host_cxxflags, - "LDFLAGS": OPTIONS.host_ldflags, + "CFLAGS": options.host_cflags, + "CXXFLAGS": options.host_cxxflags, + "LDFLAGS": options.host_ldflags, }) def build(ctx): @@ -44,5 +43,5 @@ def build(ctx): ctx.run(["make", jobs, "all-target-libgcc"]) def install(ctx, pkg): - ctx.run(["make", "DESTDIR=" + pkg.destdir, "install-gcc"]) - ctx.run(["make", "DESTDIR=" + pkg.destdir, "install-target-libgcc"]) + ctx.run(["make", "install-gcc"], env = {"DESTDIR": pkg.dest_dir}) + ctx.run(["make", "install-target-libgcc"], env = {"DESTDIR": pkg.dest_dir}) diff --git a/src/builder.rs b/src/builder.rs index 0784f2f..1e8955a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1 +1,178 @@ -pub struct Builder; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{Context, bail}; +use sha2::{Digest, Sha256}; + +use crate::{ + config::Config, + graph::{TaskPlan, TaskPlanner}, + log, + recipe::RecipeSet, +}; + +#[derive(Debug)] +pub struct Builder { + root: PathBuf, + config: Config, + container_ready: bool, +} + +impl Builder { + pub fn new(root: PathBuf, config: Config) -> Self { + Self { + root, + config, + container_ready: false, + } + } + + pub fn build( + &mut self, + recipes: &RecipeSet, + requested: &[String], + rebuild: bool, + dry_run: bool, + ) -> anyhow::Result<()> { + 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"); + } + Ok(()) + } + + pub fn fetch( + &mut self, + recipes: &RecipeSet, + requested: &[String], + dry_run: bool, + ) -> anyhow::Result<()> { + 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"); + } + Ok(()) + } + + pub fn ensure_container_ready(&mut self) -> anyhow::Result<()> { + if !self.container_ready { + self.ensure_container_image(&self.abs_config_path(&self.config.container_dockerfile))?; + self.container_ready = true; + } + + Ok(()) + } + + fn ensure_container_image(&self, dockerfile: &Path) -> anyhow::Result<()> { + if !dockerfile.exists() { + bail!( + "configured container Dockerfile does not exist: {}", + dockerfile.display() + ); + } + + let hash = self.container_build_hash(dockerfile)?; + let stamp = self.root.join("build/container-image.hash"); + if fs::read_to_string(&stamp).ok().as_deref() == Some(hash.as_str()) + && self.container_image_exists()? + { + log::skip( + "image", + &format!("using cached {}", self.config.container_image), + ); + return Ok(()); + } + + log::step( + "image", + &format!( + "building {} from {}", + self.config.container_image, + dockerfile.display() + ), + ); + let runtime = self.config.container_runtime.as_str(); + let status = Command::new(runtime) + .arg("build") + .arg("-f") + .arg(dockerfile) + .arg("-t") + .arg(&self.config.container_image) + .arg(&self.root) + .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) -> anyhow::Result { + let runtime = self.config.container_runtime.as_str(); + let status = Command::new(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) -> anyhow::Result { + let mut hasher = Sha256::new(); + hasher.update(fs::read(dockerfile)?); + hasher.update(self.config.container_image.as_bytes()); + Ok(hex::encode(hasher.finalize())) + } + + fn print_plan(&self, plan: &TaskPlan) { + if plan.is_empty() { + log::skip("plan", "nothing to do"); + return; + } + + log::step( + "plan", + &format!( + "{} active task(s), {} edge(s)", + plan.order().len(), + plan.dependency_count() + ), + ); + for task in plan.order() { + println!("{task}"); + } + } + + fn abs_config_path(&self, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + self.root.join(path) + } + } +} diff --git a/src/cli.rs b/src/cli.rs index f855070..198990f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -46,6 +46,8 @@ struct BuildCommand { enum Command { Fetch(FetchCommand), Build(BuildCommand), + #[command(about = "Create or refresh the configured build container image")] + Image, } pub fn run() -> anyhow::Result<()> { @@ -53,11 +55,23 @@ pub fn run() -> anyhow::Result<()> { let root_path = cli.root.canonicalize().unwrap_or(cli.root); let config = Config::load(&root_path.join("config.star"))?; - let lib = eval::eval_lib(&root_path.join("lib"), Some(&config.options))?; - let recipes = RecipeSet::load(&root_path, &config.options, lib.as_ref())?; match cli.command { - Command::Fetch { .. } => todo!(), - Command::Build { .. } => todo!(), + Command::Fetch(command) => { + let lib = eval::eval_lib(&root_path.join("lib"), Some(&config.options))?; + let recipes = RecipeSet::load(&root_path, &config.options, lib.as_ref())?; + let mut builder = Builder::new(root_path, config); + builder.fetch(&recipes, &command.recipes, command.dry_run) + } + Command::Build(command) => { + let lib = eval::eval_lib(&root_path.join("lib"), Some(&config.options))?; + let recipes = RecipeSet::load(&root_path, &config.options, lib.as_ref())?; + let mut builder = Builder::new(root_path, config); + builder.build(&recipes, &command.recipes, command.rebuild, command.dry_run) + } + Command::Image => { + let mut builder = Builder::new(root_path, config); + builder.ensure_container_ready() + } } } diff --git a/src/config.rs b/src/config.rs index b330a1c..4c2cb7b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,14 +12,31 @@ use crate::{ #[derive(Debug)] pub enum ContainerRuntime { + Docker, Podman, } +impl ContainerRuntime { + pub fn as_str(&self) -> &'static str { + match self { + Self::Docker => "docker", + Self::Podman => "podman", + } + } +} + +impl std::fmt::Display for ContainerRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + impl TryFrom<&str> for ContainerRuntime { type Error = anyhow::Error; fn try_from(value: &str) -> anyhow::Result { match value { + "docker" => Ok(Self::Docker), "podman" => Ok(Self::Podman), _ => anyhow::bail!("invalid runtime: {value}"), } diff --git a/src/graph.rs b/src/graph.rs new file mode 100644 index 0000000..efbb6f7 --- /dev/null +++ b/src/graph.rs @@ -0,0 +1,552 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, bail}; +use sha2::{Digest, Sha256}; + +use crate::recipe::{OutputPackage, Recipe, RecipeKind, RecipeSet}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum TaskId { + FetchSources(String), + PrepareSources(String), + ConfigureRecipe(String), + BuildRecipe(String), + InstallPackageFiles(String), + ProduceApk(String), + InstallHostRecipe(String), +} + +impl fmt::Display for TaskId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::FetchSources(recipe) => write!(f, "fetch sources {recipe}"), + Self::PrepareSources(recipe) => write!(f, "prepare sources {recipe}"), + Self::ConfigureRecipe(recipe) => write!(f, "configure {recipe}"), + Self::BuildRecipe(recipe) => write!(f, "build {recipe}"), + Self::InstallPackageFiles(output) => write!(f, "install package files {output}"), + Self::ProduceApk(output) => write!(f, "produce apk {output}"), + Self::InstallHostRecipe(recipe) => write!(f, "install host recipe {recipe}"), + } + } +} + +#[derive(Debug)] +pub struct TaskPlan { + dependencies: BTreeMap>, + order: Vec, +} + +impl TaskPlan { + pub fn order(&self) -> &[TaskId] { + &self.order + } + + pub fn is_empty(&self) -> bool { + self.order.is_empty() + } + + pub fn dependency_count(&self) -> usize { + self.dependencies.values().map(Vec::len).sum() + } + + #[cfg(test)] + pub fn dependencies(&self, task: &TaskId) -> Option<&[TaskId]> { + self.dependencies.get(task).map(Vec::as_slice) + } +} + +pub struct TaskPlanner<'a> { + root: &'a Path, + arch: &'a str, + recipes: &'a RecipeSet, + force: bool, + dependencies: BTreeMap>, + inactive: BTreeSet, + visiting: BTreeSet, + visited: BTreeSet, +} + +impl<'a> TaskPlanner<'a> { + pub fn new(root: &'a Path, arch: &'a str, recipes: &'a RecipeSet) -> Self { + Self { + root, + arch, + recipes, + force: false, + dependencies: BTreeMap::new(), + inactive: BTreeSet::new(), + visiting: BTreeSet::new(), + visited: BTreeSet::new(), + } + } + + pub fn build_plan(mut self, requests: &[String], force: bool) -> anyhow::Result { + self.force = force; + for request in requests { + let recipe = self.recipes.recipe(request)?; + match recipe.kind() { + RecipeKind::Package => { + for output in recipe.outputs() { + self.visit(TaskId::ProduceApk(output.key()))?; + } + } + RecipeKind::HostPackage => { + self.visit(TaskId::InstallHostRecipe(recipe.key()))?; + } + } + } + self.into_plan() + } + + pub fn fetch_plan(mut self, requests: &[String]) -> anyhow::Result { + for request in requests { + let recipe = self.recipes.recipe(request)?; + self.visit(TaskId::FetchSources(recipe.key()))?; + } + self.into_plan() + } + + fn visit(&mut self, task: TaskId) -> anyhow::Result<()> { + if self.visited.contains(&task) || self.inactive.contains(&task) { + return Ok(()); + } + if !self.is_active(&task)? { + self.inactive.insert(task); + return Ok(()); + } + if !self.visiting.insert(task.clone()) { + bail!("task dependency cycle involving `{task}`"); + } + + let dependencies = self.dependencies(&task)?; + let mut active_dependencies = Vec::new(); + for dependency in dependencies { + self.visit(dependency.clone())?; + if self.dependencies.contains_key(&dependency) { + active_dependencies.push(dependency); + } + } + + self.visiting.remove(&task); + self.visited.insert(task.clone()); + self.dependencies.insert(task, active_dependencies); + Ok(()) + } + + fn into_plan(self) -> anyhow::Result { + let mut order = Vec::new(); + let mut visiting = BTreeSet::new(); + let mut visited = BTreeSet::new(); + for task in self.dependencies.keys() { + topo_visit( + task, + &self.dependencies, + &mut visiting, + &mut visited, + &mut order, + )?; + } + Ok(TaskPlan { + dependencies: self.dependencies, + order, + }) + } + + fn dependencies(&self, task: &TaskId) -> anyhow::Result> { + match task { + TaskId::FetchSources(_) => Ok(Vec::new()), + TaskId::PrepareSources(recipe) => Ok(vec![TaskId::FetchSources(recipe.clone())]), + TaskId::ConfigureRecipe(recipe) => { + let recipe = self.recipes.recipe(recipe)?; + let mut deps = vec![TaskId::PrepareSources(recipe.key())]; + deps.extend( + recipe + .host_deps() + .iter() + .map(|dep| TaskId::InstallHostRecipe(RecipeKind::HostPackage.key(dep))), + ); + deps.extend( + recipe + .build_deps() + .iter() + .chain(recipe.deps().iter()) + .map(|dep| TaskId::ProduceApk(dep.clone())), + ); + Ok(deps) + } + TaskId::BuildRecipe(recipe) => Ok(vec![TaskId::ConfigureRecipe(recipe.clone())]), + TaskId::InstallPackageFiles(output) => { + let output = self.recipes.output(output)?; + Ok(vec![TaskId::BuildRecipe(output.recipe().to_owned())]) + } + TaskId::ProduceApk(output) => { + let output = self.recipes.output(output)?; + let recipe = self.recipes.recipe(output.recipe())?; + let mut deps = vec![TaskId::InstallPackageFiles(output.key())]; + deps.extend( + recipe + .deps() + .iter() + .chain(recipe.run_deps().iter()) + .map(|dep| TaskId::ProduceApk(dep.clone())), + ); + Ok(deps) + } + TaskId::InstallHostRecipe(recipe) => { + self.recipes.recipe(recipe)?; + Ok(vec![TaskId::BuildRecipe(recipe.clone())]) + } + } + } + + fn is_active(&self, task: &TaskId) -> anyhow::Result { + match task { + TaskId::FetchSources(recipe) => self.fetch_sources_active(self.recipes.recipe(recipe)?), + TaskId::PrepareSources(recipe) => { + self.prepare_sources_active(self.recipes.recipe(recipe)?) + } + TaskId::ConfigureRecipe(recipe) => { + self.recipe_task_active(self.recipes.recipe(recipe)?, "configure") + } + TaskId::BuildRecipe(recipe) => { + self.recipe_task_active(self.recipes.recipe(recipe)?, "build") + } + TaskId::InstallPackageFiles(output) => { + let output = self.recipes.output(output)?; + let recipe = self.recipes.recipe(output.recipe())?; + self.output_task_active(recipe, output, "install") + } + TaskId::ProduceApk(output) => { + let output = self.recipes.output(output)?; + let recipe = self.recipes.recipe(output.recipe())?; + self.produce_apk_active(recipe, output) + } + TaskId::InstallHostRecipe(recipe) => { + let recipe = self.recipes.recipe(recipe)?; + self.install_host_recipe_active(recipe) + } + } + } + + fn fetch_sources_active(&self, recipe: &Recipe) -> anyhow::Result { + Ok(recipe.sources().entries().iter().any(|(_, source)| { + source.is_unknown_cache_key() || !self.source_cache_path(source.cache_key()).exists() + })) + } + + fn prepare_sources_active(&self, recipe: &Recipe) -> anyhow::Result { + if self.force { + return Ok(true); + } + let want_version = format!("{}-r{}", recipe.version(), recipe.revision()); + if fs::read_to_string(self.source_stamp(recipe, "version")) + .ok() + .as_deref() + != Some(want_version.as_str()) + { + return Ok(true); + } + if self.recipe_has_patches(recipe)? && !self.source_stamp(recipe, "patched").exists() { + return Ok(true); + } + Ok(false) + } + + fn recipe_task_active(&self, recipe: &Recipe, kind: &str) -> anyhow::Result { + if self.force { + return Ok(true); + } + Ok(fs::read_to_string(self.recipe_task_stamp(recipe, kind)) + .ok() + .as_deref() + != Some(self.recipe_fingerprint(recipe)?.as_str())) + } + + fn output_task_active( + &self, + recipe: &Recipe, + output: &OutputPackage, + kind: &str, + ) -> anyhow::Result { + if self.force { + return Ok(true); + } + Ok(fs::read_to_string(self.output_task_stamp(output, kind)) + .ok() + .as_deref() + != Some(self.output_fingerprint(recipe, output)?.as_str())) + } + + fn produce_apk_active(&self, recipe: &Recipe, output: &OutputPackage) -> anyhow::Result { + if self.force { + return Ok(true); + } + if !self.apk_path(recipe, output).exists() { + return Ok(true); + } + Ok(fs::read_to_string(self.output_task_stamp(output, "apk")) + .ok() + .as_deref() + != Some(self.output_fingerprint(recipe, output)?.as_str())) + } + + fn install_host_recipe_active(&self, recipe: &Recipe) -> anyhow::Result { + if self.force { + return Ok(true); + } + if !self.host_install_dir(recipe).exists() { + return Ok(true); + } + Ok( + fs::read_to_string(self.recipe_task_stamp(recipe, "host-install")) + .ok() + .as_deref() + != Some(self.recipe_fingerprint(recipe)?.as_str()), + ) + } + + fn recipe_fingerprint(&self, recipe: &Recipe) -> anyhow::Result { + let mut hasher = Sha256::new(); + hasher.update(self.arch.as_bytes()); + hasher.update(recipe.key().as_bytes()); + hasher.update(recipe.version().as_bytes()); + hasher.update(recipe.revision().to_le_bytes()); + hasher.update( + fs::read(recipe.path()) + .with_context(|| format!("reading recipe {}", recipe.path().display()))?, + ); + for (name, source) in recipe.sources().entries() { + hasher.update(name.unwrap_or("").as_bytes()); + hasher.update(source.url().as_bytes()); + hasher.update(source.cache_key().as_bytes()); + } + for patch in self.recipe_patches(recipe)? { + hasher.update(patch.display().to_string().as_bytes()); + hasher + .update(fs::read(&patch).with_context(|| format!("reading {}", patch.display()))?); + } + Ok(hex::encode(hasher.finalize())) + } + + fn output_fingerprint( + &self, + recipe: &Recipe, + output: &OutputPackage, + ) -> anyhow::Result { + let mut hasher = Sha256::new(); + hasher.update(self.recipe_fingerprint(recipe)?.as_bytes()); + hasher.update(output.key().as_bytes()); + Ok(hex::encode(hasher.finalize())) + } + + fn recipe_has_patches(&self, recipe: &Recipe) -> anyhow::Result { + Ok(!self.recipe_patches(recipe)?.is_empty()) + } + + fn recipe_patches(&self, recipe: &Recipe) -> anyhow::Result> { + let patches_dir = recipe.dir().join("patches"); + if !patches_dir.exists() { + return Ok(Vec::new()); + } + let mut patches = Vec::new(); + for entry in fs::read_dir(&patches_dir) + .with_context(|| format!("reading patches directory {}", patches_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_file() { + patches.push(path); + } + } + patches.sort(); + Ok(patches) + } + + fn source_cache_path(&self, key: &str) -> PathBuf { + self.root.join("build/cache/sources").join(key) + } + + fn source_stamp(&self, recipe: &Recipe, kind: &str) -> PathBuf { + self.root + .join("build/sources") + .join(format!("{}.{kind}", recipe.slug())) + } + + fn recipe_task_stamp(&self, recipe: &Recipe, kind: &str) -> PathBuf { + self.root + .join("build/tasks") + .join(format!("{}.{kind}", recipe.slug())) + } + + fn output_task_stamp(&self, output: &OutputPackage, kind: &str) -> PathBuf { + self.root + .join("build/tasks") + .join(format!("{}.{kind}", output.key().replace(':', "-"))) + } + + fn apk_path(&self, recipe: &Recipe, output: &OutputPackage) -> PathBuf { + self.root.join("build/pkgs").join(self.arch).join(format!( + "{}-{}-r{}.apk", + output.name(), + recipe.version(), + recipe.revision() + )) + } + + fn host_install_dir(&self, recipe: &Recipe) -> PathBuf { + self.root + .join("build/host-pkgs") + .join(recipe.slug()) + .join("usr/local") + } +} + +fn topo_visit( + task: &TaskId, + dependencies: &BTreeMap>, + visiting: &mut BTreeSet, + visited: &mut BTreeSet, + order: &mut Vec, +) -> anyhow::Result<()> { + if visited.contains(task) { + return Ok(()); + } + if !visiting.insert(task.clone()) { + bail!("task dependency cycle involving `{task}`"); + } + for dependency in dependencies.get(task).into_iter().flatten() { + topo_visit(dependency, dependencies, visiting, visited, order)?; + } + visiting.remove(task); + visited.insert(task.clone()); + order.push(task.clone()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use crate::{config::Config, eval, recipe::RecipeSet}; + + use super::{TaskId, TaskPlanner}; + + fn write_config(root: &TempDir) { + fs::write( + root.path().join("config.star"), + r#" +container_runtime = "podman" +container_image = "local/test:latest" +container_dockerfile = "Dockerfile" +arch = "x86_64" +options = dict(target_arch = arch) +"#, + ) + .unwrap(); + fs::write(root.path().join("Dockerfile"), "FROM scratch\n").unwrap(); + } + + fn write_recipe(root: &TempDir, dir: &str, name: &str, extra: &str) { + let recipe_dir = root.path().join(dir); + fs::create_dir_all(&recipe_dir).unwrap(); + fs::write( + recipe_dir.join(format!("{name}.star")), + format!( + r#" +version = "1.0" +revision = 1 +source = tarball_source(url = "file:///tmp/{name}.tar", sha256 = "hash-{name}") +{extra} +def build(ctx): + ctx.run(["true"]) +def install(ctx, pkg): + ctx.run(["true"]) +"# + ), + ) + .unwrap(); + } + + fn load(root: &TempDir) -> (Config, RecipeSet) { + let config = Config::load(&root.path().join("config.star")).unwrap(); + let lib = eval::eval_lib(&root.path().join("lib"), Some(&config.options)).unwrap(); + let recipes = RecipeSet::load(root.path(), &config.options, lib.as_ref()).unwrap(); + (config, recipes) + } + + #[test] + fn inactive_seed_does_not_pull_dependencies() { + let root = TempDir::new().unwrap(); + write_config(&root); + write_recipe(&root, "recipes", "dep", ""); + write_recipe(&root, "recipes", "app", r#"deps = ["dep"]"#); + let (config, recipes) = load(&root); + + let planner = TaskPlanner::new(root.path(), &config.arch, &recipes); + let output = recipes.output("app").unwrap(); + let recipe = recipes.recipe(output.recipe()).unwrap(); + fs::create_dir_all(root.path().join("build/pkgs/x86_64")).unwrap(); + fs::write(root.path().join("build/pkgs/x86_64/app-1.0-r1.apk"), "").unwrap(); + fs::create_dir_all(root.path().join("build/tasks")).unwrap(); + fs::write( + root.path().join("build/tasks/app.apk"), + planner.output_fingerprint(recipe, output).unwrap(), + ) + .unwrap(); + + let plan = TaskPlanner::new(root.path(), &config.arch, &recipes) + .build_plan(&["app".to_owned()], false) + .unwrap(); + assert!(plan.is_empty()); + } + + #[test] + fn active_target_recipe_keeps_edges_and_topo_order() { + let root = TempDir::new().unwrap(); + write_config(&root); + write_recipe(&root, "recipes", "app", ""); + let (config, recipes) = load(&root); + + let plan = TaskPlanner::new(root.path(), &config.arch, &recipes) + .build_plan(&["app".to_owned()], false) + .unwrap(); + assert_eq!( + plan.order(), + &[ + TaskId::FetchSources("app".to_owned()), + TaskId::PrepareSources("app".to_owned()), + TaskId::ConfigureRecipe("app".to_owned()), + TaskId::BuildRecipe("app".to_owned()), + TaskId::InstallPackageFiles("app".to_owned()), + TaskId::ProduceApk("app".to_owned()), + ] + ); + assert_eq!( + plan.dependencies(&TaskId::ProduceApk("app".to_owned())), + Some([TaskId::InstallPackageFiles("app".to_owned())].as_slice()) + ); + } + + #[test] + fn host_dependencies_are_installed_as_host_recipes() { + let root = TempDir::new().unwrap(); + write_config(&root); + write_recipe(&root, "host-recipes", "binutils", ""); + write_recipe(&root, "recipes", "app", r#"host_deps = ["binutils"]"#); + let (config, recipes) = load(&root); + + let plan = TaskPlanner::new(root.path(), &config.arch, &recipes) + .build_plan(&["app".to_owned()], false) + .unwrap(); + assert!( + plan.order() + .contains(&TaskId::InstallHostRecipe("host:binutils".to_owned())) + ); + } +} diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..a58c854 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,26 @@ +use std::{io::IsTerminal, sync::LazyLock}; + +static IS_STDERR_TERMINAL: LazyLock = LazyLock::new(|| std::io::stderr().is_terminal()); + +const ARROW: &str = "==>"; + +fn emit(color: &str, action: &str, details: &str) { + if *IS_STDERR_TERMINAL { + eprintln!("\x1b[{color}m{ARROW} \x1b[1m{action} \x1b[0m{details}"); + } else { + eprintln!("{ARROW} {action} {details}"); + } +} + +pub fn step(action: &str, details: &str) { + emit("1;34", action, details); +} + +pub fn skip(action: &str, details: &str) { + emit("1;33", action, details); +} + +#[allow(dead_code)] +pub fn info(action: &str, details: &str) { + emit("1;32", action, details); +} diff --git a/src/main.rs b/src/main.rs index 6d9edcd..6fa9cdb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ mod builder; mod cli; mod config; mod eval; +mod graph; +mod log; mod options; mod recipe; diff --git a/src/recipe/mod.rs b/src/recipe/mod.rs index 2165ced..6b8f57a 100644 --- a/src/recipe/mod.rs +++ b/src/recipe/mod.rs @@ -36,6 +36,10 @@ impl RecipeKind { Self::HostPackage => format!("host:{name}"), } } + + pub fn slug(self, name: &str) -> String { + self.key(name).replace(':', "-") + } } #[derive(Debug)] @@ -44,7 +48,25 @@ pub enum Sources { Multiple(HashMap), } +impl Sources { + pub fn entries(&self) -> Vec<(Option<&str>, &source::Source)> { + match self { + Self::Single(source) => vec![(None, source)], + Self::Multiple(sources) => { + let mut entries = sources + .iter() + .map(|(name, source)| (Some(name.as_str()), source)) + .collect::>(); + entries.sort_by_key(|(name, _)| *name); + entries + } + } + } +} + pub struct Recipe { + /// Recipe name without namespace prefix. + name: String, /// Path to the recipe's .star file. path: PathBuf, /// What kind of a recipe is that? @@ -173,6 +195,7 @@ impl Recipe { let phases = RecipePhases::load(&module)?; Ok(Recipe { + name: name.to_owned(), path: path.to_path_buf(), kind, version, @@ -190,6 +213,58 @@ impl Recipe { pub fn phases(&self) -> &RecipePhases { &self.phases } + + pub fn key(&self) -> String { + self.kind.key(&self.name) + } + + pub fn slug(&self) -> String { + self.kind.slug(&self.name) + } + + pub fn dir(&self) -> &Path { + self.path.parent().unwrap_or_else(|| Path::new(".")) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn kind(&self) -> RecipeKind { + self.kind + } + + pub fn version(&self) -> &str { + &self.version + } + + pub fn revision(&self) -> i32 { + self.revision + } + + pub fn sources(&self) -> &Sources { + &self.sources + } + + pub fn outputs(&self) -> &[OutputPackage] { + &self.outputs + } + + pub fn host_deps(&self) -> &[String] { + &self.host_deps + } + + pub fn build_deps(&self) -> &[String] { + &self.build_deps + } + + pub fn deps(&self) -> &[String] { + &self.deps + } + + pub fn run_deps(&self) -> &[String] { + &self.run_deps + } } fn optional_string_list(module: &Module, key: &str) -> anyhow::Result> { @@ -281,6 +356,20 @@ pub struct OutputPackage { metadata: Metadata, } +impl OutputPackage { + pub fn key(&self) -> String { + self.name.clone() + } + + pub fn recipe(&self) -> &str { + &self.recipe + } + + pub fn name(&self) -> &str { + &self.name + } +} + #[derive(Debug)] pub struct RecipeSet { recipes: HashMap, @@ -329,6 +418,18 @@ impl RecipeSet { Ok(Self { recipes, outputs }) } + + pub fn recipe(&self, key: &str) -> anyhow::Result<&Recipe> { + self.recipes + .get(key) + .ok_or_else(|| anyhow::anyhow!("unknown recipe `{key}`")) + } + + pub fn output(&self, key: &str) -> anyhow::Result<&OutputPackage> { + self.outputs + .get(key) + .ok_or_else(|| anyhow::anyhow!("unknown output package `{key}`")) + } } /// Find all recipe `.star` files under `dir`, returning a map of recipe name diff --git a/src/recipe/source.rs b/src/recipe/source.rs index 97f4af8..61d870c 100644 --- a/src/recipe/source.rs +++ b/src/recipe/source.rs @@ -89,3 +89,23 @@ starlark::starlark_simple_value!(Source); #[starlark_value(type = "source")] impl<'v> StarlarkValue<'v> for Source {} + +impl Source { + pub fn url(&self) -> &str { + match self { + Self::Tarball(source) => source.url(), + Self::Git(source) => source.url(), + } + } + + pub fn cache_key(&self) -> &str { + match self { + Self::Tarball(source) => source.sha256(), + Self::Git(source) => source.commit(), + } + } + + pub fn is_unknown_cache_key(&self) -> bool { + matches!(self.cache_key(), "?" | "???") + } +}