5 Commits

Author SHA1 Message Date
iretq 0c9a3fde94 wip 2 2026-05-18 20:39:21 +02:00
iretq 0d610fd2de wip 2026-05-18 19:32:33 +02:00
marv7000 98f3fee099 More stuff 2026-05-18 00:49:24 +02:00
marv7000 695f30d678 first 2026-05-17 23:23:13 +02:00
marv7000 a23e2c83d1 Initial commit 2026-04-11 20:18:09 +02:00
34 changed files with 3237 additions and 1566 deletions
+4
View File
@@ -0,0 +1,4 @@
target
build
.git
*.bak
+2
View File
@@ -1,2 +1,4 @@
/target
/build
*.bak
*.lock
Generated
+1133 -47
View File
File diff suppressed because it is too large Load Diff
+18 -10
View File
@@ -1,15 +1,23 @@
[package]
name = "builder"
name = "distro"
version = "0.1.0"
edition = "2024"
license = "MIT"
[dependencies]
allocative = "0.3.4"
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
either = "1.16.0"
petgraph = "0.8.3"
smallvec = "1.15.1"
starlark = "0.13.0"
starlark_derive = "0.13.0"
thiserror = "2.0.18"
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
hex = "0.4"
reqwest = { version = "0.12", default-features = false, features = [
"blocking",
"rustls-tls",
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
shell-escape = "0.1"
starlark = "0.13"
starlark_derive = "0.13"
allocative = "0.3"
tempfile = "3.10"
walkdir = "2.5"
+40
View File
@@ -0,0 +1,40 @@
FROM alpine:3.22.4
RUN apk upgrade --no-cache && apk add --no-cache \
alpine-sdk \
apk-tools \
autoconf \
automake \
bash \
bc \
bison \
bzip2 \
ca-certificates \
cmake \
coreutils \
curl \
file \
findutils \
flex \
gettext-dev \
git \
gzip \
elfutils-dev \
gmp-dev \
mpfr-dev \
mpc1-dev \
libtool \
linux-headers \
meson \
ninja \
openssl \
openssl-dev \
patch \
pkgconf \
python3 \
tar \
texinfo \
xz \
zstd
WORKDIR /work
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 marv7000
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+25 -53
View File
@@ -1,64 +1,36 @@
container_runtime = "podman"
container_image = "local/distro-builder:latest"
container_dockerfile = "Dockerfile"
arch = "x86_64"
libc = "glibc"
libc = "musl"
if libc == "glibc":
env = "gnu"
elif libc == "musl":
env = "musl"
else:
fail(f"Unknown libc: {libc}")
host_cflags = "-O2 -pipe"
host_cxxflags = ""
host_ldflags = "-Wl,-O1 -Wl,--sort-common -Wl,--as-needed"
prefix = path("/usr")
host_cflags = ["-O2", "-pipe"]
host_cxxflags = host_cflags + []
host_ldflags = ["-Wl,-O1", "-Wl,--sort-common", "-Wl,--as-needed"]
target_cflags = host_cflags + []
target_cxxflags = host_cxxflags + []
target_ldflags = host_ldflags + ["-Wl,-z,now"]
target_cflags = host_cflags
target_cxxflags = host_cxxflags
target_ldflags = host_ldflags + " -Wl,-z,now"
if arch == "x86_64":
flags = [
"-march=x86-64-v3",
"-mtune=generic",
"-fstack-clash-protection",
"-fstack-protector-strong",
"-fcf-protection",
]
flags = " -march=x86-64-v3 -mtune=generic -fstack-clash-protection -fstack-protector-strong -fcf-protection"
target_cflags += flags
target_cxxflags += flags
target_ldflags += ["-Wl,-z,pack-relative-relocs"]
target_cflags += flags
target_cxxflags += flags
target_ldflags += " -Wl,-z,pack-relative-relocs"
config(
arch = arch,
recipes_dir = path("./recipes"),
host_recipes_dir = path("./host-recipes"),
container = podman(
image = "local/builder:latest",
dockerfile = path("./Dockerfile"),
),
options = dict(
target_arch = arch,
target_triple = f"{arch}-linux-{libc}",
target_arch = arch,
target_triple = f"{arch}-orchid-linux-{env}",
host_cflags = host_cflags,
host_cxxflags = host_cxxflags,
host_ldflags = host_ldflags,
libc = libc,
cflags = target_cflags,
cxxflags = target_cxxflags,
ldflags = target_ldflags,
prefix = prefix,
bindir = prefix / "bin",
sbindir = prefix / "bin",
libdir = prefix / "lib",
libexecdir = prefix / "libexec",
includedir = prefix / "include",
sysconfdir = path("/etc"),
localstatedir = path("/var"),
host_cflags = " ".join(host_cflags),
host_cxxflags = " ".join(host_cxxflags),
host_ldflags = " ".join(host_ldflags),
cflags = " ".join(target_cflags),
cxxflags = " ".join(target_cxxflags),
ldflags = " ".join(target_ldflags),
libc = libc,
)
+30 -30
View File
@@ -1,39 +1,39 @@
version = "2.46.0"
revision = 1
metadata = meta(
description = "GNU binutils cross-compiled for the target triple",
license = "GPL-3.0-or-later",
description = "GNU binutils cross-compiled for the target triple",
license = "GPL-3.0-or-later",
)
source = tarball(
url = f"https://ftp.gnu.org/gnu/binutils/binutils-{version}.tar.xz",
sha256 = "d75a94f4d73e7a4086f7513e67e439e8fcdcbb726ffe63f4661744e6256b2cf2",
strip_components = 1,
source = tarball_source(
url = "https://ftp.gnu.org/gnu/binutils/binutils-" + version + ".tar.xz",
sha256 = "?",
strip_components = 1,
)
def configure(ctx):
ctx.run(
ctx.source_dir / "configure",
"--prefix=" + options.prefix,
"--target=" + options.target_triple,
"--with-sysroot=" + ctx.sysroot_dir,
"--with-pic",
"--enable-cet",
"--enable-default-execstack=no",
"--enable-deterministic-archives",
"--enable-ld=default",
"--enable-new-dtags",
"--enable-plugins",
"--enable-relro",
"--enable-separate-code",
"--enable-threads",
"--disable-nls",
"--disable-werror",
# gprofng's libcollector relies on glibc-specific internals.
"--disable-gprofng",
env = {
"CFLAGS": options.host_cflags,
"CXXFLAGS": options.host_cxxflags,
"LDFLAGS": options.host_ldflags,
})
ctx.run([
ctx.source_dir / "configure",
"--prefix=" + ctx.prefix,
"--target=" + options.target_triple,
"--with-sysroot=" + ctx.sysroot,
"--with-pic",
"--enable-cet",
"--enable-default-execstack=no",
"--enable-deterministic-archives",
"--enable-ld=default",
"--enable-new-dtags",
"--enable-plugins",
"--enable-relro",
"--enable-separate-code",
"--enable-threads",
# gprofng's libcollector does not build against musl.
"--disable-gprofng",
"--disable-nls",
"--disable-werror",
], env = {
"CFLAGS": options.host_cflags,
"CXXFLAGS": options.host_cxxflags,
"LDFLAGS": options.host_ldflags,
})
_, build, install = autotools()
+47
View File
@@ -0,0 +1,47 @@
version = "16.1.0"
revision = 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,
"--with-sysroot=" + ctx.sysroot,
"--without-headers",
"--with-newlib",
"--enable-languages=c,c++",
"--enable-default-pie",
"--enable-default-ssp",
"--disable-nls",
"--disable-shared",
"--disable-threads",
"--disable-libssp",
"--disable-libgomp",
"--disable-libquadmath",
"--disable-libatomic",
"--disable-libvtv",
"--disable-multilib",
], env = {
"CFLAGS": options.host_cflags,
"CXXFLAGS": options.host_cxxflags,
"LDFLAGS": options.host_ldflags,
})
def build(ctx):
jobs = "-j" + str(ctx.jobs)
ctx.run(["make", jobs, "all-gcc"])
ctx.run(["make", jobs, "all-target-libgcc"])
def install(ctx, pkg):
ctx.run(["make", "install-gcc"], env = {"DESTDIR": pkg.dest_dir})
ctx.run(["make", "install-target-libgcc"], env = {"DESTDIR": pkg.dest_dir})
+37
View File
@@ -0,0 +1,37 @@
# Commonly used helpers.
def autotools_configure(ctx, extra_args = [], extra_env = {}):
env = {
"CFLAGS": options.cflags,
"CXXFLAGS": options.cxxflags,
"LDFLAGS": options.ldflags,
}
env.update(extra_env)
ctx.run([
ctx.source_dir / "configure",
"--host=" + options.target_triple,
"--with-sysroot=" + ctx.sysroot,
"--prefix=" + ctx.prefix,
"--sysconfdir=/etc",
"--localstatedir=/var",
"--bindir=" + ctx.prefix + "/bin",
"--sbindir=" + ctx.prefix + "/bin",
"--libdir=" + ctx.prefix + "/lib",
"--disable-static",
"--enable-shared",
] + extra_args, env = env)
def autotools_build(ctx, extra_args = []):
ctx.run(["make", "-j" + str(ctx.jobs)] + extra_args)
def autotools_install(ctx, pkg, extra_args = []):
ctx.run(["make", "install"] + extra_args, env = {"DESTDIR": pkg.destdir})
def autotools(configure_args = [], configure_env = [], build_args = [], install_args = []):
def _configure(ctx):
autotools_configure(ctx, extra_args = configure_args, extra_env = configure_env)
def _build(ctx):
autotools_build(ctx, extra_args = build_args)
def _install(ctx, pkg):
autotools_install(ctx, pkg, extra_args = install_args)
return _configure, _build, _install
+25
View File
@@ -0,0 +1,25 @@
version = "12.2.0"
revision = 1
metadata = meta(
description = "Modern, secure, portable, multiprotocol bootloader and boot manager",
license = "BSD-2-Clause",
)
source = tarball_source(
url = f"https://github.com/Limine-Bootloader/Limine/releases/download/v{version}/limine-{version}.tar.gz",
sha256 = "db8a119878cfeead63c0a78236c577c40539c5759496950ea0ed32a6cf567865",
strip_components = 1,
)
host_deps = ["binutils", "gcc"]
deps = [options.libc]
subpackages = [
subpackage(
name = "limine-bios",
),
]
configure, build, install = autotools(configure_env = {
"TOOLCHAIN_FOR_TARGET": options.target_triple + "-",
"LD_FOR_TARGET": options.target_triple + "-" + "ld",
"OBJCOPY_FOR_TARGET": options.target_triple + "-" + "objcopy",
"OBJDUMP_FOR_TARGET": options.target_triple + "-" + "objdump",
})
+12 -12
View File
@@ -1,20 +1,20 @@
version = "7.0.9"
revision = 1
metadata = meta(
description = "Linux kernel headers for userspace development",
license = "GPL-2.0-only",
description = "Linux kernel headers for userspace development",
license = "GPL-2.0-only",
)
source = tarball(
url = f"https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{version}.tar.xz",
sha256 = "ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4",
strip_components = 1,
source = tarball_source(
url = f"https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{version}.tar.xz",
sha256 = "ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4",
strip_components = 1,
)
def build(ctx):
ctx.run("cp", "-rp", ctx.source_dir / ".", ctx.build_dir)
ctx.run("make", "headers_install", "ARCH=" + options.target_arch)
ctx.run("find", ctx.build_dir / "usr" / "include", "-type", "f", "!", "-name", "*.h", "-delete")
ctx.run(["cp", "-rp", ctx.source_dir / ".", ctx.build_dir])
ctx.run(["make", "headers_install", "ARCH=" + options.target_arch])
ctx.run(["find", ctx.build_dir / "usr/include", "-type", "f", "!", "-name", "*.h", "-delete"])
def install(ctx):
ctx.run("mkdir", "-p", ctx.dest_dir / options.prefix)
ctx.run("cp", "-rp", ctx.build_dir / "usr" / "include", ctx.dest_dir / options.prefix)
def install(ctx, pkg):
ctx.run(["mkdir", "-p", pkg.dest_dir / ctx.prefix])
ctx.run(["cp", "-rp", ctx.build_dir / "usr/include", pkg.dest_dir / ctx.prefix])
+37
View File
@@ -0,0 +1,37 @@
name = "linux"
version = "7.0.9"
revision = 1
description = "Linux kernel"
license = "GPL-2.0-only"
source = {
"url": f"https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{version}.tar.xz",
"sha256": "ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4",
"strip_components": 1,
}
host_deps = ["binutils", "gcc"]
def _make_args(ctx, *args):
result = [
"make",
"-C", ctx.source_dir,
"O=" + ctx.build_dir,
"ARCH=x86_64",
"CROSS_COMPILE=" + OPTIONS.target_triple + "-",
"-j" + str(ctx.jobs),
]
result.extend(args)
return result
def configure(ctx):
ctx.run(_make_args(ctx, "defconfig"))
def build(ctx):
ctx.run(_make_args(ctx, "bzImage"))
def install(ctx, pkg):
ctx.install(
ctx.build_dir + "/arch/x86/boot/bzImage",
pkg.destdir + "/boot/vmlinuz-" + version,
)
+26
View File
@@ -0,0 +1,26 @@
version = "1.2.6"
revision = 1
metadata = meta(
description = "Small, standards-conformant implementation of libc",
license = "MIT",
)
source = tarball_source(
url = f"https://musl.libc.org/releases/musl-{version}.tar.gz",
sha256 = "?",
strip_components = 1,
)
host_deps = ["binutils", "gcc-bootstrap"]
def configure(ctx):
ctx.run([
ctx.source_dir / "configure",
"--target=" + options.target_triple,
"--prefix=" + ctx.prefix,
"--syslibdir=/lib",
], env = {
"CC": options.target_triple + "-gcc",
"CFLAGS": options.cflags,
"LDFLAGS": options.ldflags,
})
_, build, install = autotools()
+178
View File
@@ -0,0 +1,178 @@
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<bool> {
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<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 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)
}
}
}
+23 -83
View File
@@ -1,27 +1,13 @@
use crate::{
container::{ContainerManager, PodmanRuntime},
eval::{
Config, ContainerConfig, ContainerManagerWrapper, Context, Path, config_globals,
eval_files, types_globals,
},
log,
plan::{Plan, PlanKey},
recipe::RecipeSet,
};
use clap::{Parser, Subcommand};
use starlark::{
environment::{GlobalsBuilder, Module},
eval,
values::Value,
};
use std::{cell::Cell, path::PathBuf, sync::Arc};
use std::path::PathBuf;
use crate::{builder::Builder, config::Config, eval, recipe::RecipeSet};
#[derive(Debug, Parser)]
struct Cli {
#[arg(
long,
short = 'C',
short,
default_value = ".",
help = "Directory containing the configuration and recipe files"
)]
@@ -60,78 +46,32 @@ struct BuildCommand {
enum Command {
Fetch(FetchCommand),
Build(BuildCommand),
#[command(about = "Create or refresh the configured build container image")]
Image,
}
pub fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
let root_path = cli.root.canonicalize().unwrap_or(cli.root);
let config: Config = {
let cell = Cell::new(None);
let config_path = root_path.join("config.star");
eval_files(
&[&config_path],
&GlobalsBuilder::standard()
.with(types_globals)
.with(config_globals)
.build(),
None,
None,
Some(&cell),
)?;
cell.take()
.ok_or_else(|| anyhow::anyhow!("`config` was not called"))?
};
let container_runtime = match config.container() {
ContainerConfig::Podman(_) => Arc::new(PodmanRuntime::new()?),
};
let container_manager = ContainerManager::new(container_runtime);
let mut recipes = RecipeSet::new(&config);
recipes.load_recipes(
&root_path.join(config.recipes_dir()),
&root_path.join(config.host_recipes_dir()),
)?;
// let wrapper = ContainerManagerWrapper(&container_manager);
// for (name, recipe) in recipes.packages.iter() {
// println!("{name}: {:#?}", recipe);
// let mo = Module::new();
// let mut eval = eval::Evaluator::new(&mo);
// eval.extra = Some(&wrapper);
// eval.eval_function(
// recipe.build.unwrap().0.to_value(),
// &[mo.heap().alloc(Context {
// source_dir: Path::new("/source"),
// build_dir: Path::new("/build"),
// jobs: 4,
// })],
// &[],
// )
// .unwrap();
// }
let mut plan = Plan::new(&recipes);
let config = Config::load(&root_path.join("config.star"))?;
match cli.command {
Command::Fetch(_) => {}
Command::Build(cmd) => {
for recipe in cmd.recipes.iter() {
plan.add_wanted(if let Some(recipe) = recipe.strip_prefix("host:") {
PlanKey::ToolInstall(recipe.to_string())
} else {
PlanKey::PkgPackage(recipe.clone())
});
}
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()
}
}
log!("plan", "{:#?}", plan.steps());
Ok(())
}
+117
View File
@@ -0,0 +1,117 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use starlark::values::dict::FrozenDictRef;
use crate::{
eval::{ExtractError, eval_file, extract_string},
options::Options,
};
#[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<Self> {
match value {
"docker" => Ok(Self::Docker),
"podman" => Ok(Self::Podman),
_ => anyhow::bail!("invalid runtime: {value}"),
}
}
}
#[derive(Debug)]
pub struct Config {
pub container_runtime: ContainerRuntime,
pub container_image: String,
pub container_dockerfile: PathBuf,
pub arch: String,
pub options: Options,
}
impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let module = eval_file(path, None, None)?;
let container_runtime = match extract_string(&module, "container_runtime") {
Ok(v) => ContainerRuntime::try_from(v.as_str())?,
Err(ExtractError::NotFound) => ContainerRuntime::Podman,
Err(ExtractError::TypeMismatch) => anyhow::bail!("`container_runtime` is not a string"),
};
let container_image = match extract_string(&module, "container_image") {
Ok(container_image) => container_image,
Err(ExtractError::NotFound) => {
anyhow::bail!("`container_image` config variable not set")
}
Err(ExtractError::TypeMismatch) => anyhow::bail!("`container_image` is not a string"),
};
let container_dockerfile = match extract_string(&module, "container_dockerfile") {
Ok(container_dockerfile) => PathBuf::from(container_dockerfile),
Err(ExtractError::NotFound) => PathBuf::from("Dockerfile"),
Err(ExtractError::TypeMismatch) => {
anyhow::bail!("`container_dockerfile` is not a string")
}
};
let arch = match extract_string(&module, "arch") {
Ok(arch) => arch,
Err(ExtractError::NotFound) => anyhow::bail!("`arch` config variable not set"),
Err(ExtractError::TypeMismatch) => anyhow::bail!("`arch` is not a string"),
};
let frozen_module = module.freeze()?;
let options_value = frozen_module
.get_option("options")?
.ok_or_else(|| anyhow::anyhow!("`options` config variable not set"))?;
let entries = {
// SAFETY: the FrozenValue is only used to construct a FrozenDictRef whose
// lifetime is bounded by `options_value`, which keeps the frozen heap alive.
let dict =
FrozenDictRef::from_frozen_value(unsafe { options_value.unchecked_frozen_value() })
.ok_or_else(|| anyhow::anyhow!("`options` is not a dict"))?;
dict.iter()
.map(|(k, v)| {
let key = k
.to_value()
.unpack_str()
.ok_or_else(|| anyhow::anyhow!("non-string key in `options`"))?
.to_owned();
Ok((key, options_value.map(|_| v)))
})
.collect::<anyhow::Result<HashMap<_, _>>>()?
};
let options = Options::new(entries);
Ok(Self {
container_runtime,
container_image,
container_dockerfile,
arch,
options,
})
}
}
-99
View File
@@ -1,99 +0,0 @@
use std::{
collections::HashMap,
path::Path,
sync::{Arc, Mutex},
};
mod podman;
pub use podman::PodmanRuntime;
#[derive(Clone)]
pub struct Container {
id: String,
runtime: Arc<dyn ContainerRuntime>,
}
impl Container {
fn new(id: String, runtime: Arc<dyn ContainerRuntime>) -> Self {
Self { id, runtime }
}
pub fn id(&self) -> &str {
&self.id
}
pub fn exec<'a, 'e, 'c>(
&self,
argv: impl Into<Vec<&'a str>>,
env: impl Into<Vec<(&'e str, &'e str)>>,
cwd: impl Into<&'c Path>,
) -> anyhow::Result<()> {
self.runtime
.exec(self.id(), argv.into(), env.into(), cwd.into())
}
}
pub trait ContainerRuntime {
/// Starts a new container, returns the ID.
fn start_container(
&self,
image_name: &str,
mounts: &[(&Path, &str, bool)],
) -> anyhow::Result<String>;
/// Stops a container.
fn stop_container(&self, container_id: &str);
/// Executes a command in a container.
fn exec(
&self,
container_id: &str,
argv: Vec<&str>,
env: Vec<(&str, &str)>,
cwd: &Path,
) -> anyhow::Result<()>;
}
pub struct ContainerManager {
inner: Mutex<ContainerManagerInner>,
}
struct ContainerManagerInner {
containers: HashMap<String, Container>,
runtime: Arc<dyn ContainerRuntime>,
}
impl ContainerManager {
pub fn new(runtime: Arc<dyn ContainerRuntime>) -> Self {
Self {
inner: Mutex::new(ContainerManagerInner {
containers: HashMap::new(),
runtime,
}),
}
}
pub fn container(&self, name: &str) -> anyhow::Result<Container> {
let mut inner = self.inner.lock().unwrap();
if inner.containers.get(name).is_none() {
let container_id = inner.runtime.start_container("alpine:edge", &[])?;
crate::log!("info", "Started new container ({container_id})");
let container = Container::new(container_id, inner.runtime.clone());
inner.containers.insert(name.into(), container);
}
Ok(inner.containers.get(name).cloned().unwrap())
}
}
impl Drop for ContainerManager {
fn drop(&mut self) {
let inner = self.inner.lock().unwrap();
for (_, container) in inner.containers.iter() {
inner.runtime.stop_container(container.id());
}
}
}
-101
View File
@@ -1,101 +0,0 @@
use crate::container::ContainerRuntime;
use anyhow::Context;
use std::{
path::Path,
process::{Command, Stdio},
};
pub struct PodmanRuntime;
impl PodmanRuntime {
pub fn new() -> anyhow::Result<Self> {
let output = Command::new("podman").arg("--version").output()?;
if output.status.success() {
Ok(Self)
} else {
anyhow::bail!(
"Could not execute `podman --version` - make sure you have podman installed."
);
}
}
}
impl ContainerRuntime for PodmanRuntime {
fn start_container(
&self,
image_name: &str,
mounts: &[(&Path, &str, bool)],
) -> anyhow::Result<String> {
let mut cmd = Command::new("podman");
cmd.arg("run");
cmd.arg("--detach");
cmd.arg("--read-only");
cmd.arg("--network=none");
cmd.arg("--userns=keep-id");
cmd.arg("--tmpfs").arg("/builder/dest");
cmd.arg("--tmpfs").arg("/builder/sysroot");
for &(host, container, read_only) in mounts {
cmd.arg("--volume").arg(format!(
"{}:{}{}",
host.display(),
container,
if read_only { ":ro" } else { "" }
));
}
cmd.arg(image_name);
cmd.arg("sleep").arg("infinity");
let output = cmd.output()?;
if output.status.success() {
Ok(String::from_utf8(output.stdout)
.context("container ID is not valid UTF-8")?
.trim()
.into())
} else {
todo!()
}
}
fn stop_container(&self, container_id: &str) {
Command::new("podman")
.arg("kill")
.arg(container_id)
.output()
.unwrap();
}
fn exec(
&self,
container_id: &str,
argv: Vec<&str>,
env: Vec<(&str, &str)>,
cwd: &Path,
) -> anyhow::Result<()> {
let mut cmd = Command::new("podman");
cmd.arg("exec");
cmd.arg("--workdir").arg(cwd);
for (key, value) in env {
cmd.arg("--env").arg(format!("{key}={value}"));
}
cmd.arg(container_id);
cmd.args(argv);
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
let output = cmd.output()?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!("Failed to execute command");
}
}
}
+177
View File
@@ -0,0 +1,177 @@
use anyhow::Context;
use starlark::{
environment::{FrozenModule, Globals, GlobalsBuilder, Module},
eval::Evaluator,
syntax::{AstModule, Dialect},
values::list::ListRef,
};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::{
options::Options,
recipe::{GitSource, Metadata, Source, Subpackage, TarballSource},
};
#[derive(Debug)]
pub enum ExtractError {
NotFound,
TypeMismatch,
}
impl std::fmt::Display for ExtractError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExtractError::NotFound => write!(f, "missing"),
ExtractError::TypeMismatch => write!(f, "wrong type"),
}
}
}
impl std::error::Error for ExtractError {}
#[starlark::starlark_module]
fn builder_globals(builder: &mut GlobalsBuilder) {
fn meta(
maintainer: Option<String>,
description: Option<String>,
license: Option<String>,
website: Option<String>,
) -> anyhow::Result<Metadata> {
Ok(Metadata::new(maintainer, description, license, website))
}
fn tarball_source(
url: String,
sha256: String,
strip_components: Option<u32>,
) -> anyhow::Result<Source> {
Ok(Source::Tarball(TarballSource::new(
url,
sha256,
strip_components.unwrap_or(0),
)))
}
fn git_source(url: String, commit: String) -> anyhow::Result<Source> {
Ok(Source::Git(GitSource::new(url, commit)))
}
fn subpackage(name: String, metadata: Option<&Metadata>) -> anyhow::Result<Subpackage> {
let metadata = metadata
.cloned()
.unwrap_or_else(|| Metadata::new(None, None, None, None));
Ok(Subpackage::new(name, metadata))
}
}
pub fn eval_file(
path: &Path,
options: Option<&Options>,
lib: Option<&FrozenModule>,
) -> anyhow::Result<Module> {
let module = Module::new();
if let Some(lib) = lib {
module.import_public_symbols(lib);
}
if let Some(options) = options {
inject_options(&module, options);
}
let ast = AstModule::parse_file(path, &dialect()).map_err(|err| anyhow::anyhow!("{err}"))?;
let globals = globals();
let mut eval = Evaluator::new(&module);
eval.eval_module(ast, &globals)
.map_err(|err| anyhow::anyhow!("{err}"))?;
drop(eval);
Ok(module)
}
/// Parse and evaluate every `.star` file under `dir` into a single frozen
/// module whose public bindings can be imported into recipe modules. Returns
/// `Ok(None)` if `dir` doesn't exist or contains no `.star` files.
pub fn eval_lib(dir: &Path, options: Option<&Options>) -> anyhow::Result<Option<FrozenModule>> {
if !dir.exists() {
return Ok(None);
}
let mut files: Vec<PathBuf> = Vec::new();
for entry in WalkDir::new(dir) {
let entry = entry.with_context(|| format!("walking lib directory {}", dir.display()))?;
let path = entry.path();
if entry.file_type().is_file() && path.extension().is_some_and(|ext| ext == "star") {
files.push(path.to_path_buf());
}
}
if files.is_empty() {
return Ok(None);
}
// Sorted for deterministic ordering when later definitions shadow earlier ones.
files.sort();
let module = Module::new();
if let Some(options) = options {
inject_options(&module, options);
}
let dialect = dialect();
let globals = globals();
for file in &files {
let ast = AstModule::parse_file(file, &dialect)
.map_err(|err| anyhow::anyhow!("parsing {}: {err}", file.display()))?;
let mut eval = Evaluator::new(&module);
eval.eval_module(ast, &globals)
.map_err(|err| anyhow::anyhow!("evaluating {}: {err}", file.display()))?;
}
Ok(Some(module.freeze()?))
}
fn dialect() -> Dialect {
Dialect {
enable_top_level_stmt: true,
enable_f_strings: true,
..Dialect::Standard
}
}
fn globals() -> Globals {
GlobalsBuilder::standard().with(builder_globals).build()
}
fn inject_options(module: &Module, options: &Options) {
let value = module.heap().alloc(options.clone());
module.set("options", value);
}
pub fn extract_string(module: &Module, key: &str) -> Result<String, ExtractError> {
module
.get(key)
.ok_or_else(|| ExtractError::NotFound)
.and_then(|v| {
v.unpack_str()
.map(|v| v.to_string())
.ok_or_else(|| ExtractError::TypeMismatch)
})
}
pub fn extract_i32(module: &Module, key: &str) -> Result<i32, ExtractError> {
module
.get(key)
.ok_or(ExtractError::NotFound)
.and_then(|v| v.unpack_i32().ok_or(ExtractError::TypeMismatch))
}
pub fn extract_string_list(module: &Module, key: &str) -> Result<Vec<String>, ExtractError> {
let value = module.get(key).ok_or(ExtractError::NotFound)?;
let list = ListRef::from_value(value).ok_or(ExtractError::TypeMismatch)?;
list.iter()
.map(|v| {
v.unpack_str()
.map(|s| s.to_string())
.ok_or(ExtractError::TypeMismatch)
})
.collect()
}
-154
View File
@@ -1,154 +0,0 @@
use crate::eval::Path;
use allocative::Allocative;
use starlark::{
collections::SmallMap,
environment::GlobalsBuilder,
eval::Evaluator,
starlark_module, starlark_simple_value,
values::{StarlarkValue, Value, ValueLike, none::NoneType},
};
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use std::{
cell::Cell,
collections::HashMap,
path::{Path as StdPath, PathBuf},
};
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct PodmanConfig {
image: String,
dockerfile: PathBuf,
}
impl std::fmt::Display for PodmanConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "podman_config")
}
}
starlark_simple_value!(PodmanConfig);
#[starlark_value(type = "podman_config")]
impl<'v> StarlarkValue<'v> for PodmanConfig {}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub enum ContainerConfig {
Podman(PodmanConfig),
}
impl std::fmt::Display for ContainerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "container_config")
}
}
starlark_simple_value!(ContainerConfig);
#[starlark_value(type = "container_config")]
impl<'v> StarlarkValue<'v> for ContainerConfig {}
#[derive(Debug, Clone, Allocative, ProvidesStaticType)]
pub enum ConfigValue {
String(String),
Integer(i32),
Bool(bool),
Path(Path),
}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Config {
arch: String,
container: ContainerConfig,
recipes_dir: Path,
host_recipes_dir: Path,
options: HashMap<String, ConfigValue>,
}
impl Config {
pub fn arch(&self) -> &str {
&self.arch
}
pub fn container(&self) -> &ContainerConfig {
&self.container
}
pub fn recipes_dir(&self) -> &StdPath {
&self.recipes_dir.path()
}
pub fn host_recipes_dir(&self) -> &StdPath {
&self.host_recipes_dir.path()
}
pub fn options(&self) -> &HashMap<String, ConfigValue> {
&self.options
}
}
impl std::fmt::Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "container_config")
}
}
starlark_simple_value!(Config);
#[starlark_value(type = "config")]
impl<'v> StarlarkValue<'v> for Config {}
#[starlark_module]
pub fn config_globals(b: &mut GlobalsBuilder) {
fn podman(
#[starlark(require = named)] image: &str,
#[starlark(require = named)] dockerfile: &Path,
) -> anyhow::Result<ContainerConfig> {
Ok(ContainerConfig::Podman(PodmanConfig {
image: image.to_string(),
dockerfile: dockerfile.path().to_owned(),
}))
}
fn config(
#[starlark(require = named)] arch: &str,
#[starlark(require = named)] container: &ContainerConfig,
#[starlark(require = named)] recipes_dir: &Path,
#[starlark(require = named)] host_recipes_dir: &Path,
#[starlark(kwargs)] kwargs: SmallMap<&str, Value>,
eval: &mut Evaluator,
) -> anyhow::Result<NoneType> {
let config = eval
.extra
.and_then(|extra| extra.downcast_ref::<Cell<Option<Config>>>())
.ok_or_else(|| anyhow::anyhow!("`config` called outside of config.star"))?;
config.set(Some(Config {
arch: arch.to_string(),
container: container.clone(),
recipes_dir: recipes_dir.clone(),
host_recipes_dir: host_recipes_dir.clone(),
options: kwargs
.iter()
.map(|(&k, v)| {
let value = if let Some(str) = v.unpack_str() {
ConfigValue::String(str.to_string())
} else if let Some(num) = v.unpack_i32() {
ConfigValue::Integer(num)
} else if let Some(bool) = v.unpack_bool() {
ConfigValue::Bool(bool)
} else if let Some(path) = v.downcast_ref::<Path>() {
ConfigValue::Path(path.clone())
} else {
anyhow::bail!("config option must be a `string`, `int`, `bool` or `path`");
};
Ok((k.to_string(), value))
})
.collect::<Result<_, _>>()?,
}));
Ok(NoneType)
}
}
-96
View File
@@ -1,96 +0,0 @@
use starlark::{
any::AnyLifetime,
environment::{FrozenModule, Globals, Module},
eval::Evaluator,
syntax::{AstModule, Dialect, DialectTypes},
values::{UnpackValue, Value, type_repr::StarlarkTypeRepr},
};
use std::path::Path as StdPath;
mod config;
mod recipe;
mod types;
#[allow(unused_imports)]
pub use config::*;
#[allow(unused_imports)]
pub use recipe::*;
#[allow(unused_imports)]
pub use types::*;
pub trait UnpackCloned: Sized + StarlarkTypeRepr {
fn unpack_cloned(value: Value<'_>) -> Option<Self>;
}
impl<T> UnpackCloned for T
where
for<'v> T: UnpackValue<'v>,
{
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
T::unpack_value(value).unwrap()
}
}
pub fn eval_files(
path: &[&StdPath],
globals: &Globals,
lib_module: Option<&FrozenModule>,
config: Option<&Config>,
extra: Option<&dyn AnyLifetime>,
) -> anyhow::Result<Module> {
use anyhow::Context;
let module = Module::new();
if let Some(lib_module) = lib_module {
module.import_public_symbols(lib_module);
}
if let Some(config) = config {
module.set("options", module.heap().alloc(config.clone()));
}
let mut paths = path.to_vec();
paths.sort();
let ast_modules = paths
.iter()
.map(|&path| {
let module = AstModule::parse_file(path, &default_dialect())
.map_err(|err| anyhow::anyhow!("{err}"))
.context(format!("parsing file {:?}", path.display()))?;
Ok((path, module))
})
.collect::<anyhow::Result<Vec<(&StdPath, AstModule)>>>()?;
for (path, ast) in ast_modules {
let mut eval = Evaluator::new(&module);
if let Some(extra) = extra {
eval.extra = Some(extra);
}
eval.eval_module(ast, globals)
.map_err(|err| anyhow::anyhow!("{err}"))
.context(format!("evaluating file {:?}", path.display()))?;
}
Ok(module)
}
fn default_dialect() -> Dialect {
Dialect {
enable_def: true,
enable_lambda: true,
enable_load: false,
enable_keyword_only_arguments: false,
enable_positional_only_arguments: false,
enable_types: DialectTypes::Disable,
enable_load_reexport: false,
enable_top_level_stmt: true,
enable_f_strings: true,
..Dialect::Standard
}
}
-202
View File
@@ -1,202 +0,0 @@
use std::cell::Cell;
use allocative::Allocative;
use starlark::{
environment::{GlobalsBuilder, Methods, MethodsBuilder, MethodsStatic},
eval::Evaluator,
starlark_module, starlark_simple_value,
typing::Ty,
values::{
Heap, StarlarkValue, UnpackValue, Value, ValueLike, none::NoneType, tuple::UnpackTuple,
type_repr::StarlarkTypeRepr,
},
};
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use crate::{
container::{Container, ContainerManager},
eval::{Path, UnpackCloned},
log,
recipe::Source,
};
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct TarballSource {
url: String,
sha256: String,
strip_components: u32,
}
impl std::fmt::Display for TarballSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "tarball")
}
}
starlark_simple_value!(TarballSource);
#[starlark_value(type = "tarball")]
impl<'v> StarlarkValue<'v> for TarballSource {}
impl UnpackCloned for TarballSource {
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
value.downcast_ref().cloned()
}
}
impl Source for TarballSource {}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Metadata {
maintainer: Option<String>,
description: Option<String>,
license: Option<String>,
website: Option<String>,
}
impl std::fmt::Display for Metadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "metadata")
}
}
starlark_simple_value!(Metadata);
#[starlark_value(type = "metadata")]
impl<'v> StarlarkValue<'v> for Metadata {}
impl UnpackCloned for Metadata {
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
value.downcast_ref().cloned()
}
}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Context {
pub source_dir: Path,
pub build_dir: Path,
pub jobs: i32,
}
impl std::fmt::Display for Context {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "context")
}
}
starlark_simple_value!(Context);
#[derive(Debug)]
struct RunArg(pub String);
impl UnpackValue<'_> for RunArg {
type Error = anyhow::Error;
fn unpack_value_impl(value: Value) -> anyhow::Result<Option<Self>> {
Ok(if let Some(str) = value.unpack_str() {
Some(RunArg(str.to_owned()))
} else if let Some(int) = value.unpack_i32() {
Some(RunArg(int.to_string()))
} else if let Some(path) = value.downcast_ref::<Path>() {
Some(RunArg(path.path().to_str().unwrap_or("").to_string()))
} else {
None
})
}
}
impl StarlarkTypeRepr for RunArg {
type Canonical = Self;
fn starlark_type_repr() -> starlark::typing::Ty {
Ty::string()
}
}
#[derive(ProvidesStaticType)]
pub struct ContainerManagerWrapper<'a>(pub &'a ContainerManager);
#[starlark_module]
fn context_methods(b: &mut MethodsBuilder) {
fn run(
#[starlark(this)] this: &Context,
#[starlark(args)] args: UnpackTuple<RunArg>,
eval: &mut Evaluator,
) -> anyhow::Result<NoneType> {
let ContainerManagerWrapper(container_manager) = eval
.extra
.and_then(|extra| extra.downcast_ref())
.ok_or_else(|| anyhow::anyhow!("`config` called outside of config.star"))?;
let argv = args.items.iter().map(|x| x.0.as_str()).collect::<Vec<_>>();
log!("run", "Running command: {argv:?}");
container_manager
.container("changeme")? // TODO
.exec(argv, [], std::path::Path::new("/"))?;
Ok(NoneType)
}
}
#[starlark_value(type = "context")]
impl<'v> StarlarkValue<'v> for Context {
fn get_methods() -> Option<&'static Methods> {
static RES: MethodsStatic = MethodsStatic::new();
RES.methods(context_methods)
}
fn has_attr(&self, attr: &str, _heap: &Heap) -> bool {
match attr {
"source_dir" => true,
"build_dir" => true,
"jobs" => true,
_ => false,
}
}
fn get_attr(&self, attr: &str, heap: &'v Heap) -> Option<Value<'v>> {
match attr {
"source_dir" => Some(heap.alloc(self.source_dir.clone())),
"build_dir" => Some(heap.alloc(self.build_dir.clone())),
"jobs" => Some(heap.alloc(self.jobs)),
_ => None,
}
}
}
impl UnpackCloned for Context {
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
value.downcast_ref().cloned()
}
}
#[starlark_module]
pub fn recipe_globals(b: &mut GlobalsBuilder) {
fn tarball(
#[starlark(require = named)] url: &str,
#[starlark(require = named)] sha256: &str,
#[starlark(require = named, default = 0)] strip_components: u32,
) -> anyhow::Result<TarballSource> {
Ok(TarballSource {
url: url.to_string(),
sha256: sha256.to_string(),
strip_components,
})
}
fn meta(
#[starlark(require = named)] maintainer: Option<&str>,
#[starlark(require = named)] description: Option<&str>,
#[starlark(require = named)] license: Option<&str>,
#[starlark(require = named)] website: Option<&str>,
) -> anyhow::Result<Metadata> {
Ok(Metadata {
maintainer: maintainer.map(|x| x.to_string()),
description: description.map(|x| x.to_string()),
license: license.map(|x| x.to_string()),
website: website.map(|x| x.to_string()),
})
}
}
-64
View File
@@ -1,64 +0,0 @@
use allocative::Allocative;
use starlark::{
environment::GlobalsBuilder,
starlark_module, starlark_simple_value,
values::{Heap, StarlarkValue, Value, ValueLike},
};
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use std::path::{Path as StdPath, PathBuf};
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Path {
inner: PathBuf,
}
impl Path {
pub fn new(value: impl Into<PathBuf>) -> Self {
Self {
inner: value.into(),
}
}
pub fn path(&self) -> &StdPath {
&self.inner
}
}
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "path({:?})", self.inner)
}
}
starlark_simple_value!(Path);
#[starlark_value(type = "path")]
impl<'v> StarlarkValue<'v> for Path {
fn div(&self, other: Value<'v>, heap: &'v Heap) -> starlark::Result<Value<'v>> {
let rhs = if let Some(str) = other.unpack_str() {
str.to_string()
} else if let Some(path) = other.downcast_ref::<Path>() {
path.inner.to_str().unwrap_or("").to_string()
} else {
return Err(starlark::Error::new_other(anyhow::anyhow!(
"expected a `string` or `path`, got `{}`",
other.get_type()
)));
};
Ok(heap.alloc(Path {
inner: self
.inner
.join(rhs.trim_start_matches(std::path::MAIN_SEPARATOR_STR)),
}))
}
}
#[starlark_module]
pub fn types_globals(b: &mut GlobalsBuilder) {
fn path(value: &str) -> anyhow::Result<Path> {
Ok(Path {
inner: PathBuf::from(value),
})
}
}
+552
View File
@@ -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<TaskId, Vec<TaskId>>,
order: Vec<TaskId>,
}
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<TaskId, Vec<TaskId>>,
inactive: BTreeSet<TaskId>,
visiting: BTreeSet<TaskId>,
visited: BTreeSet<TaskId>,
}
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<TaskPlan> {
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<TaskPlan> {
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<TaskPlan> {
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<Vec<TaskId>> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<bool> {
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<String> {
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<String> {
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<bool> {
Ok(!self.recipe_patches(recipe)?.is_empty())
}
fn recipe_patches(&self, recipe: &Recipe) -> anyhow::Result<Vec<PathBuf>> {
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<TaskId, Vec<TaskId>>,
visiting: &mut BTreeSet<TaskId>,
visited: &mut BTreeSet<TaskId>,
order: &mut Vec<TaskId>,
) -> 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()))
);
}
}
+15 -9
View File
@@ -2,19 +2,25 @@ use std::{io::IsTerminal, sync::LazyLock};
static IS_STDERR_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stderr().is_terminal());
pub fn __emit(color: &str, action: &str, args: std::fmt::Arguments) {
const ARROW: &str = "==>";
const ARROW: &str = "==>";
fn emit(color: &str, action: &str, details: &str) {
if *IS_STDERR_TERMINAL {
eprintln!("\x1b[{color}m{ARROW}\x1b[0m \x1b[1m{action}\x1b[0m {args}");
eprintln!("\x1b[{color}m{ARROW} \x1b[1m{action} \x1b[0m{details}");
} else {
eprintln!("{ARROW} {action} {args}");
eprintln!("{ARROW} {action} {details}");
}
}
#[macro_export]
macro_rules! log {
($action:literal, $($arg:tt)*) => {{
$crate::log::__emit("1;34", $action, format_args!($($arg)*));
}};
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);
}
+4 -128
View File
@@ -1,136 +1,12 @@
use crate::{
container::{ContainerManager, PodmanRuntime},
plan::{Plan, PlanKey},
recipe::{PackageRecipe, RecipeSet, SourceRecipe, ToolRecipe},
};
use std::{path::Path, sync::Arc};
mod builder;
mod cli;
mod container;
mod config;
mod eval;
mod graph;
mod log;
mod plan;
mod options;
mod recipe;
fn main() -> anyhow::Result<()> {
cli::run()
// let podman_runtime = Arc::new(PodmanRuntime::new()?);
// let mut container_manager = ContainerManager::new(podman_runtime);
// container_manager.container("example")?.exec(
// vec!["sh", "-c", "uname -a && id"],
// vec![],
// Path::new("/"),
// )?;
// let mut recipes = RecipeSet::default();
// recipes.load_recipes(Path::new("./recipes"), Path::new("./host-recipes"))?;
// recipes.add_source(
// "binutils-2.46",
// SourceRecipe {
// name: "binutils-2.46".into(),
// },
// );
// recipes.add_source(
// "gcc-16.1.0",
// SourceRecipe {
// name: "gcc-16.1.0".into(),
// },
// );
// recipes.add_source(
// "linux-7.0.9",
// SourceRecipe {
// name: "linux-7.0.9".into(),
// },
// );
// recipes.add_source(
// "glibc-2.41",
// SourceRecipe {
// name: "glibc-2.41".into(),
// },
// );
// recipes.add_source(
// "bash-5.3",
// SourceRecipe {
// name: "bash-5.3".into(),
// },
// );
// recipes.add_tool(
// "binutils",
// ToolRecipe {
// name: "binutils".into(),
// sources: vec!["binutils-2.46".into()],
// tools_wanted: vec![],
// pkgs_wanted: vec![],
// },
// );
// recipes.add_tool(
// "gcc-bootstrap",
// ToolRecipe {
// name: "gcc-bootstrap".into(),
// sources: vec!["gcc-16.1.0".into()],
// tools_wanted: vec!["binutils".into()],
// pkgs_wanted: vec![],
// },
// );
// recipes.add_tool(
// "gcc",
// ToolRecipe {
// name: "gcc".into(),
// sources: vec!["gcc-16.1.0".into()],
// tools_wanted: vec!["binutils".into()],
// pkgs_wanted: vec!["glibc".into()],
// },
// );
// recipes.add_package(
// "linux-headers",
// PackageRecipe {
// name: "linux-headers".into(),
// sources: vec!["linux-7.0.9".into()],
// tools_wanted: vec![],
// pkgs_wanted: vec![],
// },
// );
// recipes.add_package(
// "glibc",
// PackageRecipe {
// name: "glibc".into(),
// sources: vec!["glibc-2.41".into()],
// tools_wanted: vec!["gcc-bootstrap".into()],
// pkgs_wanted: vec!["linux-headers".into()],
// },
// );
// recipes.add_package(
// "bash",
// PackageRecipe {
// name: "bash".into(),
// sources: vec!["bash-5.3".into()],
// tools_wanted: vec!["gcc".into()],
// pkgs_wanted: vec!["glibc".into()],
// },
// );
// let mut plan = Plan::new(&recipes);
// plan.add_wanted(PlanKey::PkgPackage(
// recipes.package("bash").expect("back package"),
// ));
// println!("{:#?}", plan.steps()?);
// Ok(())
}
+35
View File
@@ -0,0 +1,35 @@
use allocative::Allocative;
use starlark::values::{Heap, OwnedFrozenValue, StarlarkValue, Value};
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use std::collections::HashMap;
#[derive(Debug, Clone, Allocative, ProvidesStaticType, NoSerialize)]
pub struct Options {
entries: HashMap<String, OwnedFrozenValue>,
}
impl Options {
pub fn new(entries: HashMap<String, OwnedFrozenValue>) -> Self {
Self { entries }
}
}
impl std::fmt::Display for Options {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "options")
}
}
starlark::starlark_simple_value!(Options);
#[starlark_value(type = "options")]
impl<'v> StarlarkValue<'v> for Options {
fn get_attr(&self, attribute: &str, _heap: &'v Heap) -> Option<Value<'v>> {
let owned = self.entries.get(attribute)?;
// SAFETY: `self` is kept alive by the module heap into which it was
// allocated, and `owned` holds an Arc to its source frozen heap. The
// returned Value therefore remains valid for as long as the receiving
// module is alive.
Some(unsafe { owned.unchecked_frozen_value() }.to_value())
}
}
-222
View File
@@ -1,222 +0,0 @@
use crate::recipe::{PackageRecipe, RecipeSet, SourceRecipe, ToolRecipe};
use petgraph::{
Direction,
graph::{DiGraph, NodeIndex},
};
use smallvec::{SmallVec, smallvec};
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap, HashSet},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PlanError {
#[error("missing source recipe '{0}'")]
MissingSource(String),
#[error("missing tool recipe '{0}'")]
MissingTool(String),
#[error("missing package recipe '{0}'")]
MissingPackage(String),
#[error("plan cycle detected")]
CycleDetected,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PlanKey {
SourceFetch(String),
SourcePatch(String),
SourcePrepare(String),
ToolConfigure(String),
ToolBuild(String),
ToolInstall(String),
PkgConfigure(String),
PkgBuild(String),
PkgInstall(String),
PkgPackage(String),
}
impl PlanKey {
fn weight(&self) -> i8 {
match self {
PlanKey::SourceFetch(_) => 0,
PlanKey::SourcePatch(_) => 1,
PlanKey::SourcePrepare(_) => 2,
PlanKey::ToolConfigure(_) => 3,
PlanKey::ToolBuild(_) => 4,
PlanKey::ToolInstall(_) => 5,
PlanKey::PkgConfigure(_) => 6,
PlanKey::PkgBuild(_) => 7,
PlanKey::PkgInstall(_) => 8,
PlanKey::PkgPackage(_) => 9,
}
}
fn dependencies(&self, recipes: &RecipeSet) -> Result<SmallVec<[PlanKey; 8]>, PlanError> {
match self {
PlanKey::SourceFetch(_) => Ok(smallvec![]),
PlanKey::SourcePatch(recipe) => Ok(smallvec![PlanKey::SourceFetch(recipe.clone())]),
PlanKey::SourcePrepare(recipe) => Ok(smallvec![PlanKey::SourcePatch(recipe.clone())]),
PlanKey::ToolConfigure(recipe) => {
let recipe = recipes
.tool(recipe)
.ok_or(PlanError::MissingTool(recipe.clone()))?;
let source_deps = recipe.sources.iter().map(|name| {
recipes
.source(name)
.map(|_| PlanKey::SourcePrepare(name.to_string()))
.ok_or(PlanError::MissingSource(name.to_string()))
});
let tool_deps = recipe.tools_wanted.iter().map(|name| {
recipes
.tool(name)
.map(|_| PlanKey::ToolInstall(name.to_string()))
.ok_or(PlanError::MissingTool(name.to_string()))
});
let pkg_deps = recipe.pkgs_wanted.iter().map(|name| {
recipes
.package(name)
.map(|_| PlanKey::PkgPackage(name.to_string()))
.ok_or(PlanError::MissingPackage(name.to_string()))
});
source_deps.chain(tool_deps).chain(pkg_deps).collect()
}
PlanKey::ToolBuild(recipe) => Ok(smallvec![PlanKey::ToolConfigure(recipe.clone())]),
PlanKey::ToolInstall(recipe) => Ok(smallvec![PlanKey::ToolBuild(recipe.clone())]),
PlanKey::PkgConfigure(recipe) => {
let recipe = recipes
.package(recipe)
.ok_or(PlanError::MissingPackage(recipe.clone()))?;
let source_deps = recipe.sources.iter().map(|name| {
recipes
.source(name)
.map(|_| PlanKey::SourcePrepare(name.to_string()))
.ok_or(PlanError::MissingSource(name.to_string()))
});
let tool_deps = recipe.tools_wanted.iter().map(|name| {
recipes
.tool(name)
.map(|_| PlanKey::ToolInstall(name.to_string()))
.ok_or(PlanError::MissingTool(name.to_string()))
});
let pkg_deps = recipe.pkgs_wanted.iter().map(|name| {
recipes
.package(name)
.map(|_| PlanKey::PkgPackage(name.to_string()))
.ok_or(PlanError::MissingPackage(name.to_string()))
});
source_deps.chain(tool_deps).chain(pkg_deps).collect()
}
PlanKey::PkgBuild(recipe) => Ok(smallvec![PlanKey::PkgConfigure(recipe.clone())]),
PlanKey::PkgInstall(recipe) => Ok(smallvec![PlanKey::PkgBuild(recipe.clone())]),
PlanKey::PkgPackage(recipe) => Ok(smallvec![PlanKey::PkgInstall(recipe.clone())]),
}
}
fn is_active(&self) -> bool {
true
}
}
pub struct Plan<'a> {
recipes: &'a RecipeSet<'a>,
wanted: HashSet<PlanKey>,
}
impl<'a> Plan<'a> {
pub fn new(recipes: &'a RecipeSet) -> Self {
Self {
recipes,
wanted: HashSet::new(),
}
}
pub fn add_wanted(&mut self, key: PlanKey) {
self.wanted.insert(key);
}
pub fn steps(&self) -> Result<Vec<PlanKey>, PlanError> {
let mut stack: Vec<_> = self.wanted.iter().cloned().collect();
let mut graph: DiGraph<_, ()> = DiGraph::new();
let mut nodes = HashMap::new();
while let Some(node) = stack.pop() {
let node_idx = match nodes.get(&node) {
Some(&idx) => idx,
None => {
let idx = graph.add_node(node.clone());
nodes.insert(node.clone(), idx);
idx
}
};
for dep in node.dependencies(self.recipes)? {
let dep_idx = match nodes.get(&dep) {
Some(&idx) => idx,
None => {
let idx = graph.add_node(dep.clone());
nodes.insert(dep.clone(), idx);
stack.push(dep);
idx
}
};
graph.update_edge(dep_idx, node_idx, ());
}
}
// petgraph::algo::toposort(&graph, None)
// .and_then(|nodes| {
// Ok(nodes
// .iter()
// .map(|&k| graph[k])
// .filter(|node| node.is_active())
// .collect())
// })
// .map_err(|_| PlanError::CycleDetected)
let mut in_degree: HashMap<NodeIndex, usize> = graph
.node_indices()
.map(|i| (i, graph.neighbors_directed(i, Direction::Incoming).count()))
.collect();
let mut heap: BinaryHeap<(Reverse<i8>, NodeIndex)> = in_degree
.iter()
.filter(|&(_, d)| *d == 0)
.map(|(&i, _)| (Reverse(graph[i].weight()), i))
.collect();
let mut result = Vec::with_capacity(graph.node_count());
while let Some((_, idx)) = heap.pop() {
result.push(graph[idx].clone());
for neighbor in graph.neighbors_directed(idx, Direction::Outgoing) {
let d = in_degree.get_mut(&neighbor).unwrap();
*d -= 1;
if *d == 0 {
heap.push((Reverse(graph[neighbor].weight()), neighbor));
}
}
}
if result.len() != graph.node_count() {
Err(PlanError::CycleDetected)
} else {
result.retain(|node| node.is_active());
Ok(result)
}
}
}
-256
View File
@@ -1,256 +0,0 @@
use anyhow::Context;
use starlark::{
environment::{FrozenModule, GlobalsBuilder, Module},
eval,
values::{
UnpackValue,
typing::{FrozenStarlarkCallable, StarlarkCallable, StarlarkCallableParamSpec},
},
};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use crate::eval::{
Config, Metadata, TarballSource, UnpackCloned, eval_files, recipe_globals, types_globals,
};
pub struct SourceRecipe {
pub name: String,
pub source: Box<dyn Source>,
}
pub trait Source {}
pub struct ToolRecipe {
pub name: String,
pub sources: Vec<String>,
pub tools_wanted: Vec<String>,
pub pkgs_wanted: Vec<String>,
}
#[derive(Debug)]
pub struct PackageRecipe {
pub name: String,
pub meta: Option<Metadata>,
pub version: String,
pub revision: u32,
pub sources: Vec<String>,
pub tools_wanted: Vec<String>,
pub pkgs_wanted: Vec<String>,
pub module: FrozenModule,
pub configure: Option<FrozenStarlarkCallable>,
pub build: Option<FrozenStarlarkCallable>,
pub install: Option<FrozenStarlarkCallable>,
}
pub struct RecipeSet<'a> {
sources: HashMap<String, SourceRecipe>,
tools: HashMap<String, ToolRecipe>,
pub packages: HashMap<String, PackageRecipe>,
config: &'a Config,
}
impl<'a> RecipeSet<'a> {
fn add_source(&mut self, name: &str, recipe: SourceRecipe) -> anyhow::Result<()> {
if self.sources.insert(name.to_string(), recipe).is_some() {
anyhow::bail!("source '{name}' already exists");
}
Ok(())
}
fn add_tool(&mut self, name: &str, recipe: ToolRecipe) -> anyhow::Result<()> {
if self.tools.insert(name.to_string(), recipe).is_some() {
anyhow::bail!("tool '{name}' already exists");
}
Ok(())
}
fn add_package(&mut self, name: &str, recipe: PackageRecipe) -> anyhow::Result<()> {
if self.packages.insert(name.to_string(), recipe).is_some() {
anyhow::bail!("package '{name}' already exists");
}
Ok(())
}
fn load_tool_recipe(&mut self, name: &str, path: &Path) -> anyhow::Result<()> {
Ok(())
}
fn load_package_recipe(&mut self, name: &str, path: &Path) -> anyhow::Result<()> {
let module = eval_files(
&[path],
&GlobalsBuilder::standard()
.with(types_globals)
.with(recipe_globals)
.build(),
None,
Some(self.config),
None,
)?;
let module = module.freeze().map_err(|err| anyhow::anyhow!("{err:?}"))?;
let version: String = get_value(&module, "version")?;
let revision: u32 = get_value_option(&module, "revision")?.unwrap_or(1);
let metadata: Option<Metadata> = get_value_option(&module, "metadata")?;
let source: TarballSource = get_value(&module, "source")?;
let configure = get_frozen_callable(&module, "configure")?;
let build = get_frozen_callable(&module, "build")?;
let install = get_frozen_callable(&module, "install")?;
self.add_source(
name,
SourceRecipe {
name: name.to_string(),
source: Box::new(source),
},
)?;
self.add_package(
name,
PackageRecipe {
name: name.to_string(),
meta: metadata,
version,
revision,
sources: vec![name.to_string()],
tools_wanted: vec![],
pkgs_wanted: vec![],
module,
configure,
build,
install,
},
)
}
pub fn new(config: &'a Config) -> Self {
Self {
sources: HashMap::new(),
tools: HashMap::new(),
packages: HashMap::new(),
config,
}
}
pub fn load_recipes(
&mut self,
recipes_dir: &Path,
host_recipes_dir: &Path,
) -> anyhow::Result<()> {
for (dir, tool_recipe) in [(recipes_dir, false), (host_recipes_dir, true)] {
for entry in std::fs::read_dir(dir)? {
let entry = entry.context("reading directory entry")?;
if let Some((name, path)) = get_recipe_name_and_patch(&entry)? {
if tool_recipe {
self.load_tool_recipe(&name, &path)?;
} else {
self.load_package_recipe(&name, &path)?;
}
}
}
}
Ok(())
}
pub fn source(&self, name: &str) -> Option<&SourceRecipe> {
self.sources.get(name)
}
pub fn tool(&self, name: &str) -> Option<&ToolRecipe> {
self.tools.get(name)
}
pub fn package(&self, name: &str) -> Option<&PackageRecipe> {
self.packages.get(name)
}
}
fn get_recipe_name_and_patch(
entry: &std::fs::DirEntry,
) -> anyhow::Result<Option<(String, PathBuf)>> {
let file_type = entry.file_type()?;
let path = entry.path();
if file_type.is_dir() {
let recipe_path = path.join("recipe.star");
if recipe_path.exists() {
return Ok(Some((
entry.file_name().to_str().unwrap_or("").to_string(),
recipe_path,
)));
}
} else {
let name = path.file_stem().unwrap().to_str().unwrap_or("").to_string();
let extension = path
.extension()
.ok_or(anyhow::anyhow!("File did not have an extension"))?;
if extension == "star" {
return Ok(Some((name, path)));
}
}
Ok(None)
}
fn get_value_option<T: UnpackCloned>(
module: &FrozenModule,
name: &str,
) -> anyhow::Result<Option<T>> {
module
.get_option(name)?
.map(|value| {
T::unpack_cloned(value.value()).ok_or_else(|| {
anyhow::anyhow!(
"`{name}` should be of type `{}` but got `{}`",
T::starlark_type_repr(),
value.value().get_type()
)
})
})
.transpose()
}
fn get_value<T: UnpackCloned>(module: &FrozenModule, name: &str) -> anyhow::Result<T> {
let value = module
.get_option(name)?
.ok_or_else(|| anyhow::anyhow!("`{name}` is required"))?;
T::unpack_cloned(value.value()).ok_or_else(|| {
anyhow::anyhow!(
"`{name}` should be of type `{}` but got `{}`",
T::starlark_type_repr(),
value.value().get_type()
)
})
}
fn get_frozen_callable<P: StarlarkCallableParamSpec>(
module: &FrozenModule,
name: &str,
) -> anyhow::Result<Option<FrozenStarlarkCallable<P>>> {
let Some(value) = module.get_option(name)? else {
return Ok(None);
};
let callable = StarlarkCallable::unpack_value(value.value())
.map_err(|err| anyhow::anyhow!("{err}"))?
.ok_or_else(|| {
anyhow::anyhow!(
"`{name}` should be callable but got `{}`",
value.value().get_type()
)
})?;
callable
.unpack_frozen()
.ok_or_else(|| anyhow::anyhow!("`{name}` was callable but not frozen"))
.map(Some)
}
+54
View File
@@ -0,0 +1,54 @@
use allocative::Allocative;
use starlark::values::StarlarkValue;
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
#[derive(Debug, Clone, Allocative, ProvidesStaticType, NoSerialize)]
pub struct Metadata {
maintainer: Option<String>,
description: Option<String>,
license: Option<String>,
website: Option<String>,
}
impl std::fmt::Display for Metadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "metadata")
}
}
starlark::starlark_simple_value!(Metadata);
#[starlark_value(type = "metadata")]
impl<'v> StarlarkValue<'v> for Metadata {}
impl Metadata {
pub fn new(
maintainer: Option<String>,
description: Option<String>,
license: Option<String>,
website: Option<String>,
) -> Self {
Self {
maintainer,
description,
license,
website,
}
}
pub fn maintainer(&self) -> Option<&str> {
self.maintainer.as_deref()
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn license(&self) -> Option<&str> {
self.license.as_deref()
}
pub fn website(&self) -> Option<&str> {
self.website.as_deref()
}
}
+481
View File
@@ -0,0 +1,481 @@
mod metadata;
mod source;
mod subpackage;
use anyhow::{Context, bail};
use starlark::{
environment::{FrozenModule, Module},
values::{OwnedFrozenValue, UnpackValue, ValueLike, list::ListRef, typing::StarlarkCallable},
};
use std::{
collections::HashMap,
fmt,
path::{Path, PathBuf},
};
use walkdir::WalkDir;
use crate::{
eval::{self, ExtractError},
options::Options,
};
pub use metadata::Metadata;
pub use source::{GitSource, Source, TarballSource};
pub use subpackage::Subpackage;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RecipeKind {
Package,
HostPackage,
}
impl RecipeKind {
pub fn key(self, name: &str) -> String {
match self {
Self::Package => name.to_string(),
Self::HostPackage => format!("host:{name}"),
}
}
pub fn slug(self, name: &str) -> String {
self.key(name).replace(':', "-")
}
}
#[derive(Debug)]
pub enum Sources {
Single(source::Source),
Multiple(HashMap<String, source::Source>),
}
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::<Vec<_>>();
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?
kind: RecipeKind,
/// Version shared by every package output of this recipe.
version: String,
/// Revision shared by every package output of this recipe.
revision: i32,
/// List of sources required to build this recipe.
sources: Sources,
/// All packages produced by this recipe.
/// This is empty for host recipes.
outputs: Vec<OutputPackage>,
/// Host packages requires for this recipe.
host_deps: Vec<String>,
/// Packages installed to the system root during build of the recipe, but
/// not listed as part of the `apk` dependencies.
build_deps: Vec<String>,
/// Packages installed to the system root during build of the recipe AND
/// listed as part of the `apk` dependencies.
deps: Vec<String>,
/// Packages NOT installed to the system root during build, only listed
/// as part of the `apk` dependencies.
run_deps: Vec<String>,
/// Starlark phase functions defined by the recipe.
phases: RecipePhases,
}
impl fmt::Debug for Recipe {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Recipe")
.field("path", &self.path)
.field("kind", &self.kind)
.field("version", &self.version)
.field("revision", &self.revision)
.field("sources", &self.sources)
.field("outputs", &self.outputs)
.field("host_deps", &self.host_deps)
.field("build_deps", &self.build_deps)
.field("deps", &self.deps)
.field("run_deps", &self.run_deps)
.field("phases", &self.phases)
.finish()
}
}
impl Recipe {
pub fn load(
path: &Path,
name: &str,
kind: RecipeKind,
options: &Options,
lib: Option<&FrozenModule>,
) -> anyhow::Result<Self> {
let module = eval::eval_file(path, Some(options), lib)
.with_context(|| format!("evaluating recipe {}", path.display()))?;
let version = eval::extract_string(&module, "version")
.map_err(|e| anyhow::anyhow!("field `version`: {e}"))?;
let revision = match eval::extract_i32(&module, "revision") {
Ok(v) => v,
Err(ExtractError::NotFound) => 1,
Err(e) => bail!("field `revision`: {e}"),
};
let metadata = match module.get("metadata") {
None => Metadata::new(None, None, None, None),
Some(value) => value
.downcast_ref::<Metadata>()
.ok_or_else(|| anyhow::anyhow!("field `metadata`: expected a metadata value"))?
.clone(),
};
let source_value = module
.get("source")
.ok_or_else(|| anyhow::anyhow!("field `source`: missing"))?;
let source = source_value
.downcast_ref::<Source>()
.ok_or_else(|| anyhow::anyhow!("field `source`: expected a source value"))?
.clone();
let sources = Sources::Single(source);
let host_deps = optional_string_list(&module, "host_deps")?;
let build_deps = optional_string_list(&module, "build_deps")?;
let deps = optional_string_list(&module, "deps")?;
let run_deps = optional_string_list(&module, "run_deps")?;
let recipe_key = kind.key(name);
let outputs = match kind {
RecipeKind::Package => {
let mut outputs = vec![OutputPackage {
recipe: recipe_key.clone(),
name: name.to_owned(),
metadata: metadata.clone(),
}];
if let Some(value) = module.get("subpackages") {
let list = ListRef::from_value(value)
.ok_or_else(|| anyhow::anyhow!("field `subpackages`: expected a list"))?;
for item in list.iter() {
let sub = item.downcast_ref::<Subpackage>().ok_or_else(|| {
anyhow::anyhow!(
"field `subpackages`: each entry must be a subpackage value"
)
})?;
outputs.push(OutputPackage {
recipe: recipe_key.clone(),
name: sub.name().to_owned(),
metadata: sub.metadata().clone(),
});
}
}
outputs
}
RecipeKind::HostPackage => {
if module.get("subpackages").is_some() {
bail!("host recipes cannot declare `subpackages`");
}
Vec::new()
}
};
let module = module
.freeze()
.map_err(|err| anyhow::anyhow!("freezing recipe module {}: {err:?}", path.display()))?;
let phases = RecipePhases::load(&module)?;
Ok(Recipe {
name: name.to_owned(),
path: path.to_path_buf(),
kind,
version,
revision,
sources,
outputs,
host_deps,
build_deps,
deps,
run_deps,
phases,
})
}
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<Vec<String>> {
match eval::extract_string_list(module, key) {
Ok(v) => Ok(v),
Err(ExtractError::NotFound) => Ok(Vec::new()),
Err(e) => Err(anyhow::anyhow!("field `{key}`: {e}")),
}
}
pub struct RecipePhases {
configure: Option<OwnedFrozenValue>,
build: OwnedFrozenValue,
install: OwnedFrozenValue,
}
impl fmt::Debug for RecipePhases {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RecipePhases")
.field("configure", &self.configure.is_some())
.field("build", &true)
.field("install", &true)
.finish()
}
}
impl RecipePhases {
fn load(module: &FrozenModule) -> anyhow::Result<Self> {
Ok(Self {
configure: optional_phase_function(module, "configure")?,
build: required_phase_function(module, "build")?,
install: required_phase_function(module, "install")?,
})
}
pub fn configure(&self) -> Option<&OwnedFrozenValue> {
self.configure.as_ref()
}
pub fn build(&self) -> &OwnedFrozenValue {
&self.build
}
pub fn install(&self) -> &OwnedFrozenValue {
&self.install
}
}
fn optional_phase_function(
module: &FrozenModule,
name: &str,
) -> anyhow::Result<Option<OwnedFrozenValue>> {
let Some(value) = module
.get_option(name)
.with_context(|| format!("field `{name}`"))?
else {
return Ok(None);
};
validate_phase_function(name, &value)?;
Ok(Some(value))
}
fn required_phase_function(module: &FrozenModule, name: &str) -> anyhow::Result<OwnedFrozenValue> {
let value = module
.get_option(name)
.with_context(|| format!("field `{name}`"))?
.ok_or_else(|| anyhow::anyhow!("field `{name}`: missing"))?;
validate_phase_function(name, &value)?;
Ok(value)
}
fn validate_phase_function(name: &str, value: &OwnedFrozenValue) -> anyhow::Result<()> {
let callable: Option<StarlarkCallable<'_>> = StarlarkCallable::unpack_value_opt(value.value());
if callable.is_none() {
bail!("field `{name}`: expected a callable value");
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct OutputPackage {
/// Canonical key of the owning recipe.
recipe: String,
/// Name of the output package.
name: String,
/// Metadata attached to the output package.
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<String, Recipe>,
outputs: HashMap<String, OutputPackage>,
}
impl RecipeSet {
pub fn load(
root_path: &Path,
options: &Options,
lib: Option<&FrozenModule>,
) -> anyhow::Result<Self> {
let mut recipes = HashMap::new();
let mut outputs = HashMap::new();
for (path, kind) in [
("recipes", RecipeKind::Package),
("host-recipes", RecipeKind::HostPackage),
] {
let recipes_dir = root_path.join(path);
if !recipes_dir.exists() {
continue;
}
for (name, path) in discover_recipes(&recipes_dir)? {
let recipe = Recipe::load(&path, &name, kind, options, lib)
.with_context(|| format!("loading recipe `{name}`"))?;
let key = kind.key(&name);
if recipes.insert(key.clone(), recipe).is_some() {
bail!("duplicate recipe `{key}`");
}
}
}
for recipe in recipes.values() {
for output in &recipe.outputs {
let key = recipe.kind.key(&output.name);
if outputs.insert(key.clone(), output.clone()).is_some() {
bail!("duplicate package output `{key}`");
}
}
}
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
/// to its `.star` file. Intermediate directories act as categories and are
/// not themselves recipes. Within any subtree, a recipe takes either form:
/// - `.../<name>.star` — name is the file stem
/// - `.../<name>/recipe.star` — name is the parent directory
fn discover_recipes(dir: &Path) -> anyhow::Result<HashMap<String, PathBuf>> {
let mut recipes: HashMap<String, PathBuf> = HashMap::new();
let walker = WalkDir::new(dir).follow_links(false);
for entry in walker {
let entry =
entry.with_context(|| format!("walking recipes directory {}", dir.display()))?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let name = if file_name == "recipe.star" {
let Some(parent_name) = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
else {
continue;
};
parent_name.to_owned()
} else if let Some(stem) = file_name.strip_suffix(".star") {
stem.to_owned()
} else {
continue;
};
if let Some(existing) = recipes.insert(name.clone(), path.to_path_buf()) {
bail!(
"recipe `{name}` is defined twice: {} and {}",
existing.display(),
recipes[&name].display(),
);
}
}
Ok(recipes)
}
+111
View File
@@ -0,0 +1,111 @@
use allocative::Allocative;
use starlark::values::StarlarkValue;
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
#[derive(Debug, Clone, Allocative, ProvidesStaticType, NoSerialize)]
pub struct TarballSource {
url: String,
sha256: String,
strip_components: u32,
}
impl std::fmt::Display for TarballSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "tarball_source")
}
}
starlark::starlark_simple_value!(TarballSource);
#[starlark_value(type = "tarball_source")]
impl<'v> StarlarkValue<'v> for TarballSource {}
impl TarballSource {
pub fn new(url: String, sha256: String, strip_components: u32) -> Self {
Self {
url,
sha256,
strip_components,
}
}
pub fn url(&self) -> &str {
&self.url
}
pub fn sha256(&self) -> &str {
&self.sha256
}
pub fn strip_components(&self) -> u32 {
self.strip_components
}
}
#[derive(Debug, Clone, Allocative, ProvidesStaticType, NoSerialize)]
pub struct GitSource {
url: String,
commit: String,
}
impl std::fmt::Display for GitSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "git_source")
}
}
starlark::starlark_simple_value!(GitSource);
#[starlark_value(type = "git_source")]
impl<'v> StarlarkValue<'v> for GitSource {}
impl GitSource {
pub fn new(url: String, commit: String) -> Self {
Self { url, commit }
}
pub fn url(&self) -> &str {
&self.url
}
pub fn commit(&self) -> &str {
&self.commit
}
}
#[derive(Debug, Clone, Allocative, ProvidesStaticType, NoSerialize)]
pub enum Source {
Tarball(TarballSource),
Git(GitSource),
}
impl std::fmt::Display for Source {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "source")
}
}
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(), "?" | "???")
}
}
+36
View File
@@ -0,0 +1,36 @@
use allocative::Allocative;
use starlark::values::StarlarkValue;
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use crate::recipe::Metadata;
#[derive(Debug, Clone, Allocative, ProvidesStaticType, NoSerialize)]
pub struct Subpackage {
name: String,
metadata: Metadata,
}
impl Subpackage {
pub fn new(name: String, metadata: Metadata) -> Self {
Self { name, metadata }
}
pub fn name(&self) -> &str {
&self.name
}
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
}
impl std::fmt::Display for Subpackage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "subpackage")
}
}
starlark::starlark_simple_value!(Subpackage);
#[starlark_value(type = "subpackage")]
impl<'v> StarlarkValue<'v> for Subpackage {}