4 Commits

Author SHA1 Message Date
marv7000 b6e18c474e *: Switch to python 2026-05-26 03:06:26 +02:00
marv7000 2e6704516a recipe: Implement run 2026-05-23 00:10:08 +02:00
marv7000 1a7c817fb9 recipe: basic recipe parsing 2026-05-22 20:54:44 +02:00
iretq a525868969 meta: initial rewrite (final) 2026-05-22 18:48:11 +02:00
72 changed files with 15652 additions and 6210 deletions
-4
View File
@@ -1,4 +0,0 @@
target
build
.git
*.bak
+5 -4
View File
@@ -1,4 +1,5 @@
/target
/build
*.bak
*.lock
__pycache__/
/cache
/build*
/sources
/sysroot
Generated
-2900
View File
File diff suppressed because it is too large Load Diff
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "distro"
version = "0.1.0"
edition = "2024"
license = "MIT"
[dependencies]
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"
+51 -47
View File
@@ -1,58 +1,62 @@
FROM docker.io/library/alpine:edge
FROM alpine:edge
RUN apk upgrade --no-cache && \
apk add --no-cache \
alpine-sdk \
RUN mkdir -p /sources /build /pkgs /sysroot /dest /tools /files
RUN apk add --no-cache \
apk-tools \
autoconf \
automake \
build-base \
bash \
bc \
bison \
bzip2 \
ca-certificates \
cmake \
coreutils \
curl \
patch \
tar \
xz \
zstd \
file \
findutils \
flex \
gettext-dev \
git \
gzip \
elfutils-dev \
coreutils \
diffutils \
grep \
sed \
gawk \
musl-dev \
linux-headers \
gmp-dev \
mpfr-dev \
mpc1-dev \
libtool \
linux-headers \
meson \
ninja \
openssl \
openssl-dev \
patch \
isl-dev \
zlib-dev \
git \
pkgconf \
patchelf \
gperf \
python3 \
tar \
texinfo \
xz \
zstd
python3-dev \
py3-mako \
py3-yaml \
py3-packaging \
py3-docutils \
py3-passlib \
perl \
m4 \
libtool \
gettext-dev \
bison \
flex \
which \
ca-certificates \
rsync \
mtools \
nasm \
cmake \
ninja \
meson \
glslang \
elfutils-dev \
libffi-dev \
expat-dev \
libxml2-dev \
pcre2-dev \
openssl-dev \
openssl \
ncurses
RUN rm -rf /tmp/mkpkg-root /tmp/distro-preflight.apk /tmp/APKINDEX.adb /tmp/distro-preflight.rsa && \
openssl genrsa -out /tmp/distro-preflight.rsa 2048 >/dev/null 2>&1 && \
openssl rsa -in /tmp/distro-preflight.rsa -pubout -out /etc/apk/keys/distro-preflight.rsa.pub >/dev/null 2>&1 && \
mkdir -p /tmp/mkpkg-root/usr/share/distro && \
printf ok > /tmp/mkpkg-root/usr/share/distro/preflight && \
apk --sign-key /tmp/distro-preflight.rsa mkpkg \
--files /tmp/mkpkg-root \
--output /tmp/distro-preflight.apk \
--info name:distro-preflight \
--info version:0-r0 \
--info arch:noarch \
--info description:preflight \
--info license:MIT >/dev/null && \
apk --sign-key /tmp/distro-preflight.rsa mkndx -o /tmp/APKINDEX.adb /tmp/distro-preflight.apk >/dev/null && \
test -s /tmp/distro-preflight.apk && \
test -s /tmp/APKINDEX.adb
WORKDIR /work
WORKDIR /build
-18
View File
@@ -1,18 +0,0 @@
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.
-2
View File
@@ -1,2 +0,0 @@
# distro
-36
View File
@@ -1,36 +0,0 @@
container_runtime = "podman"
container_image = "localhost/distro-builder:latest"
container_dockerfile = "Dockerfile"
signing_key = "build/keys/distro.rsa"
signing_pubkey = "build/keys/distro.rsa.pub"
target_arch = "x86_64"
libc = "musl"
host_cflags = "-O2 -pipe"
host_cxxflags = ""
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"
if target_arch == "x86_64":
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"
options = {
"libc": libc,
"target_triple": target_arch + "-linux-" + libc,
"host_cflags": host_cflags,
"host_cxxflags": host_cxxflags,
"host_ldflags": host_ldflags,
"cflags": target_cflags,
"cxxflags": target_cxxflags,
"ldflags": target_ldflags,
"wayland": True,
"x11": True,
}
+21
View File
@@ -0,0 +1,21 @@
version = "2.72"
revision = 1
description = "GNU autoconf"
license = "GPL-3.0-or-later"
url = "https://www.gnu.org/software/autoconf/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/autoconf/autoconf-{version}.tar.xz",
sha256="ba885c1319578d6c94d46e9b0dceb4014caafe2490e437a0dbca3f270a223f5a",
)
def configure(self):
self.run(self.source_dir / "configure", f"--prefix={self.prefix}")
def build(self):
autotools_build(self)
def install(self):
autotools_install(self)
+22
View File
@@ -0,0 +1,22 @@
version = "1.17"
revision = 1
description = "GNU automake"
license = "GPL-2.0-or-later"
url = "https://www.gnu.org/software/automake/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/automake/automake-{version}.tar.xz",
sha256="8920c1fc411e13b90bf704ef9db6f29d540e76d232cb3b2c9f4dc4cc599bd990",
)
host_deps = ["autoconf"]
def configure(self):
self.run(self.source_dir / "configure", f"--prefix={self.prefix}")
def build(self):
autotools_build(self)
def install(self):
autotools_install(self)
+39
View File
@@ -0,0 +1,39 @@
version = "2.46.0"
revision = 1
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",
)
def configure(self):
self.run(
self.source_dir / "configure",
f"--prefix={self.prefix}",
f"--target={self.triple}",
f"--with-sysroot={self.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",
"--disable-nls",
"--disable-werror",
# gprofng's libcollector relies on glibc-specific internals.
"--disable-gprofng",
env={
"CFLAGS": self.profile["host_cflags"],
"CXXFLAGS": self.profile["host_cxxflags"],
"LDFLAGS": self.profile["host_ldflags"],
},
)
_, build, install = autotools()
-42
View File
@@ -1,42 +0,0 @@
name = "binutils"
version = "2.46.0"
revision = 1
description = "GNU binutils cross-compiled for the target triple"
license = "GPL-3.0-or-later"
source = {
"url": "https://ftp.gnu.org/gnu/binutils/binutils-" + version + ".tar.xz",
"sha256": "d75a94f4d73e7a4086f7513e67e439e8fcdcbb726ffe63f4661744e6256b2cf2",
"strip_components": 1,
}
host_deps = []
def configure(ctx):
ctx.run([
ctx.source_dir + "/configure",
"--prefix=" + ctx.prefix,
"--target=" + OPTIONS.target_triple,
"--with-sysroot=" + ctx.prefix + "/" + OPTIONS.target_triple,
"--disable-nls",
"--disable-werror",
"--enable-deterministic-archives",
"--enable-ld=default",
"--enable-plugins",
"--enable-threads",
"--with-system-zlib",
# gprofng's libcollector does not build against musl/recent gcc.
"--disable-gprofng",
], env = {
"CFLAGS": OPTIONS.host_cflags,
"CXXFLAGS": OPTIONS.host_cxxflags,
"LDFLAGS": OPTIONS.host_ldflags,
})
def build(ctx):
ctx.run(["make", "-j" + str(ctx.jobs)])
def install(ctx, pkg):
ctx.run(["make", "DESTDIR=" + pkg.destdir, "install"])
# Drop static archives we don't need on the cross side.
ctx.run(["sh", "-c", "rm -f " + pkg.destdir + ctx.prefix + "/lib/*.a"])
+47
View File
@@ -0,0 +1,47 @@
version = "16.1.0"
revision = 1
description = "GNU GCC cross-compiler (bootstrap stage, C/C++ only)"
license = "GPL-3.0-or-later"
source = tarball(
url=f"https://ftp.gnu.org/gnu/gcc/gcc-{version}/gcc-{version}.tar.xz",
sha256="50efb4d94c3397aff3b0d61a5abd748b4dd31d9d3f2ab7be05b171d36a510f79",
)
host_deps = ["binutils"]
def configure(self):
self.run(
self.source_dir / "configure",
f"--target={self.triple}",
f"--prefix={self.prefix}",
f"--with-sysroot={self.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": self.profile["host_cflags"],
"CXXFLAGS": self.profile["host_cxxflags"],
"LDFLAGS": self.profile["host_ldflags"],
},
)
def build(self):
self.run("make", f"-j{self.jobs}", "all-gcc")
self.run("make", f"-j{self.jobs}", "all-target-libgcc")
def install(self):
self.run("make", "install-gcc", env={"DESTDIR": str(self.dest_dir)})
self.run("make", "install-target-libgcc", env={"DESTDIR": str(self.dest_dir)})
+54
View File
@@ -0,0 +1,54 @@
version = "16.1.0"
revision = 1
description = "GNU GCC cross-compiler targeting the system triple"
license = "GPL-3.0-or-later"
url = "https://gcc.gnu.org/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/gcc/gcc-{version}/gcc-{version}.tar.xz",
sha256="50efb4d94c3397aff3b0d61a5abd748b4dd31d9d3f2ab7be05b171d36a510f79",
)
host_deps = ["binutils", "gcc-bootstrap"]
deps = [profile["libc"], "linux-headers"]
def configure(self):
self.run(
self.source_dir / "configure",
f"--target={self.triple}",
f"--prefix={self.prefix}",
f"--with-sysroot={self.sysroot}",
f"--with-build-sysroot={self.sysroot}",
f"--with-gxx-include-dir={self.sysroot}{self.profile['includedir']}/c++/{version}",
"--enable-languages=c,c++,lto",
"--disable-bootstrap",
"--enable-default-pie",
"--enable-default-ssp",
"--enable-lto",
"--enable-threads=posix",
"--enable-tls",
"--enable-libstdcxx-time",
"--enable-checking=release",
"--enable-cet=auto",
"--enable-linker-build-id",
"--disable-nls",
"--disable-multilib",
"--disable-fixed-point",
"--disable-werror",
"--disable-libsanitizer",
"--disable-symvers",
env={
"CFLAGS": self.profile["host_cflags"],
"CXXFLAGS": self.profile["host_cxxflags"],
"LDFLAGS": self.profile["host_ldflags"],
},
)
def build(self):
self.run("make", f"-j{self.jobs}")
def install(self):
self.run("make", "install-strip", env={"DESTDIR": str(self.dest_dir)})
# Drop libtool archives.
self.run("find", self.dest_dir, "-name", "*.la", "-delete")
-48
View File
@@ -1,48 +0,0 @@
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,
}
host_deps = ["binutils"]
def configure(ctx):
ctx.run([
ctx.source_dir + "/configure",
"--prefix=" + ctx.prefix,
"--target=" + OPTIONS.target_triple,
"--with-sysroot=" + ctx.prefix + "/" + OPTIONS.target_triple,
"--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", "DESTDIR=" + pkg.destdir, "install-gcc"])
ctx.run(["make", "DESTDIR=" + pkg.destdir, "install-target-libgcc"])
+56
View File
@@ -0,0 +1,56 @@
version = "20.1.0"
revision = 1
description = "LLVM compiler infrastructure with clang and lld"
license = "Apache-2.0 WITH LLVM-exception"
url = "https://llvm.org/"
source = tarball(
url=f"https://github.com/llvm/llvm-project/releases/download/llvmorg-{version}/llvm-project-{version}.src.tar.xz",
sha256="4579051e3c255fb4bb795d54324f5a7f3ef79bd9181e44293d7ee9a7f62aad9a",
)
host_deps = ["binutils"]
def configure(self):
self.run(
"cmake",
"-S",
self.source_dir / "llvm",
"-B",
self.build_dir,
"-G",
"Ninja",
"-DCMAKE_BUILD_TYPE=Release",
f"-DCMAKE_INSTALL_PREFIX={self.prefix}",
"-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra;lld",
"-DLLVM_ENABLE_RUNTIMES=compiler-rt",
"-DLLVM_TARGETS_TO_BUILD=X86;AArch64;RISCV",
f"-DLLVM_DEFAULT_TARGET_TRIPLE={self.triple}",
f"-DLLVM_HOST_TRIPLE={self.triple}",
"-DLLVM_ENABLE_LIBXML2=OFF",
"-DLLVM_ENABLE_LIBEDIT=OFF",
"-DLLVM_ENABLE_TERMINFO=OFF",
"-DLLVM_ENABLE_ASSERTIONS=OFF",
"-DLLVM_ENABLE_PIC=ON",
"-DLLVM_BUILD_LLVM_DYLIB=ON",
"-DLLVM_LINK_LLVM_DYLIB=ON",
"-DLLVM_INSTALL_UTILS=ON",
"-DLLVM_INCLUDE_TESTS=OFF",
"-DLLVM_INCLUDE_EXAMPLES=OFF",
"-DLLVM_INCLUDE_BENCHMARKS=OFF",
"-DCLANG_DEFAULT_LINKER=lld",
"-DCLANG_DEFAULT_RTLIB=compiler-rt",
"-DCLANG_DEFAULT_CXX_STDLIB=libstdc++",
env={
"CFLAGS": self.profile["host_cflags"],
"CXXFLAGS": self.profile["host_cxxflags"],
"LDFLAGS": self.profile["host_ldflags"],
},
)
def build(self):
self.run("cmake", "--build", self.build_dir, f"-j{self.jobs}")
def install(self):
self.run("cmake", "--install", self.build_dir, env={"DESTDIR": str(self.dest_dir)})
-99
View File
@@ -1,99 +0,0 @@
# Commonly used helpers, auto-loaded into every recipe.
def _toolchain_env(ctx):
sysroot_flag = " --sysroot=" + ctx.sysroot
return {
"CFLAGS": OPTIONS.cflags + sysroot_flag,
"CXXFLAGS": OPTIONS.cxxflags + sysroot_flag,
"LDFLAGS": OPTIONS.ldflags + sysroot_flag,
}
# Autotools
def autotools_configure(ctx, extra_args = [], extra_env = {}):
args = [
ctx.source_dir + "/configure",
"--prefix=" + ctx.prefix,
"--sysconfdir=/etc",
"--localstatedir=/var",
"--bindir=" + ctx.prefix + "/bin",
"--sbindir=" + ctx.prefix + "/bin",
"--libdir=" + ctx.prefix + "/lib",
"--with-sysroot=" + ctx.sysroot,
"--disable-static",
"--enable-shared",
]
args.append("--host=" + OPTIONS.target_triple)
args.extend(extra_args)
envs = _toolchain_env(ctx)
envs.update(extra_env)
ctx.run(args, env = envs)
def autotools_build(ctx, extra_args = []):
args = ["make", "-j" + str(ctx.jobs)]
args.extend(extra_args)
ctx.run(args)
def autotools_check(ctx, extra_args = []):
args = ["make", "check", "-j" + str(ctx.jobs)]
args.extend(extra_args)
ctx.run(args)
def autotools_install(ctx, pkg, extra_args = []):
args = ["make", "install", "DESTDIR=" + pkg.destdir]
args.extend(extra_args)
ctx.run(args)
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
# Meson
def meson_configure(ctx, extra_args = []):
args = [
"meson",
"setup",
ctx.build_dir,
ctx.source_dir,
"--prefix=" + ctx.prefix,
]
args.extend(extra_args)
ctx.run(args, env = _toolchain_env(ctx))
def meson_build(ctx):
ctx.run(["meson", "compile", "-C", ctx.build_dir, "-j" + str(ctx.jobs)])
def meson_install(ctx, pkg):
ctx.run(["meson", "install", "-C", ctx.build_dir, "--destdir", pkg.destdir])
def meson(configure_args = [], build_args = [], install_args = []):
def _configure(ctx):
meson_configure(ctx, extra_args = configure_args)
def _build(ctx):
meson_build(ctx, extra_args = build_args)
def _install(ctx, pkg):
meson_install(ctx, pkg, extra_args = install_args)
return _configure, _build, _install
# Make
def make(ctx, target = None, extra_args = []):
args = ["make", "-C", ctx.source_dir, "O=" + ctx.build_dir,
"-j" + str(ctx.jobs)]
args.extend(extra_args)
if target:
args.append(target)
ctx.run(args)
def make_install(ctx, pkg, extra_args = []):
args = ["make", "-C", ctx.build_dir, "DESTDIR=" + pkg.destdir, "install"]
args.extend(extra_args)
ctx.run(args)
Symlink
+1
View File
@@ -0,0 +1 @@
src/__main__.py
+34
View File
@@ -0,0 +1,34 @@
def profile():
arch = "x86_64"
libc = "glibc"
triple = f"{arch}-orchid-linux-gnu"
host_cflags = "-O2 -pipe"
host_cxxflags = host_cflags
host_ldflags = "-Wl,-O1 -Wl,--sort-common -Wl,--as-needed"
target_flags = " -march=x86-64-v3 -mtune=generic -fstack-clash-protection -fstack-protector-strong -fcf-protection"
cflags = host_cflags + target_flags
cxxflags = cflags
ldflags = host_ldflags + " -Wl,-z,now -Wl,-z,pack-relative-relocs"
return {
"arch": arch,
"libc": libc,
"triple": triple,
"container_image": "localhost/orchid-builder:latest",
"host_cflags": host_cflags,
"host_cxxflags": host_cxxflags,
"host_ldflags": host_ldflags,
"cflags": cflags,
"cxxflags": cxxflags,
"ldflags": ldflags,
"prefix": "/usr",
"bindir": "/usr/bin",
"sbindir": "/usr/bin",
"libdir": "/usr/lib",
"libexecdir": "/usr/libexec",
"includedir": "/usr/include",
"sysconfdir": "/etc",
"localstatedir": "/var",
}
+34
View File
@@ -0,0 +1,34 @@
def profile():
arch = "x86_64"
libc = "musl"
triple = f"{arch}-orchid-linux-{libc}"
host_cflags = "-O2 -pipe"
host_cxxflags = host_cflags
host_ldflags = "-Wl,-O1 -Wl,--sort-common -Wl,--as-needed"
target_flags = " -march=x86-64-v3 -mtune=generic -fstack-clash-protection -fstack-protector-strong -fcf-protection"
cflags = host_cflags + target_flags
cxxflags = cflags
ldflags = host_ldflags + " -Wl,-z,now -Wl,-z,pack-relative-relocs"
return {
"arch": arch,
"libc": libc,
"triple": triple,
"container_image": "localhost/orchid-builder:latest",
"host_cflags": host_cflags,
"host_cxxflags": host_cxxflags,
"host_ldflags": host_ldflags,
"cflags": cflags,
"cxxflags": cxxflags,
"ldflags": ldflags,
"prefix": "/usr",
"bindir": "/usr/bin",
"sbindir": "/usr/bin",
"libdir": "/usr/lib",
"libexecdir": "/usr/libexec",
"includedir": "/usr/include",
"sysconfdir": "/etc",
"localstatedir": "/var",
}
+6
View File
@@ -0,0 +1,6 @@
{
"ignore": [
"recipes",
"host-recipes"
]
}
+28
View File
@@ -0,0 +1,28 @@
version = "5.2.32"
revision = 1
description = "GNU Bourne-Again SHell"
license = "GPL-3.0-or-later"
url = "https://www.gnu.org/software/bash/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/bash/bash-{version}.tar.gz",
sha256="d3ef80d2b67d8cbbe4d3265c63a72c46f9b278ead6e0e06d61801b58f23f50b5",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"], "ncurses", "readline"]
build_if = False # TODO: Doesn't build yet
configure, build, install = autotools(
configure_args=[
"--without-bash-malloc",
"--disable-nls",
"--with-installed-readline",
"--with-curses",
"--enable-readline",
"--with-installed-readline=/sysroot/usr", # TODO: get /sysroot from context
],
configure_env={
"CFLAGS": profile["cflags"] + " -std=gnu17",
"CFLAGS_FOR_BUILD": profile["cflags"] + " -std=gnu17",
},
)
+20
View File
@@ -0,0 +1,20 @@
version = "9.6"
revision = 1
description = "GNU core utilities (file, shell, and text manipulation)"
license = "GPL-3.0-or-later"
url = "https://www.gnu.org/software/coreutils/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/coreutils/coreutils-{version}.tar.xz",
sha256="7a0124327b398fd9eb1a6abde583389821422c744ffa10734b24f557610d3283",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"]]
configure, build, install = autotools(
configure_args=[
"--enable-no-install-program=kill,uptime",
"--without-selinux",
"--without-openssl",
],
configure_env={"FORCE_UNSAFE_CONFIGURE": "1"},
)
+38
View File
@@ -0,0 +1,38 @@
version = "2.41"
revision = 1
description = "GNU C library"
license = "LGPL-2.1-or-later"
url = "https://www.gnu.org/software/libc/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/glibc/glibc-{version}.tar.xz",
sha256="a5a26b22f545d6b7d7b3dd828e11e428f24f4fac43c934fb071b6a7d0828e901",
)
host_deps = ["autoconf", "automake", "binutils", "gcc-bootstrap"]
deps = ["linux-headers"]
build_if = profile["libc"] == "glibc"
def configure(self):
autotools_configure(
self,
[
f"--build={self.triple}",
f"--with-headers={self.sysroot}{self.profile['prefix']}/include",
"--enable-kernel=5.4",
"--enable-bind-now",
"--enable-stack-protector=strong",
"--enable-cet",
"--disable-werror",
"--disable-profile",
"--disable-nscd",
"--without-selinux",
"--without-gd",
"libc_cv_slibdir=/lib",
"libc_cv_rtlddir=/lib",
"libc_cv_forced_unwind=yes",
],
)
_, build, install = autotools()
+59
View File
@@ -0,0 +1,59 @@
version = "16.1.0"
revision = 1
description = "GNU C++ standard library"
license = "GPL-3.0-or-later"
url = "https://gcc.gnu.org/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/gcc/gcc-{version}/gcc-{version}.tar.xz",
sha256="50efb4d94c3397aff3b0d61a5abd748b4dd31d9d3f2ab7be05b171d36a510f79",
)
host_deps = ["binutils", "gcc"]
deps = [profile["libc"]]
def configure(self):
self.run(
self.source_dir / "configure",
f"--target={self.triple}",
f"--prefix={self.profile['prefix']}",
f"--libdir={self.profile['libdir']}",
f"--with-sysroot={self.sysroot}",
f"--with-build-sysroot={self.sysroot}",
f"--with-gxx-include-dir={self.profile['includedir']}/c++/{version}",
f"--with-toolexeclibdir={self.profile['libdir']}",
"--enable-languages=c,c++",
"--disable-bootstrap",
"--enable-default-pie",
"--enable-default-ssp",
"--enable-lto",
"--enable-threads=posix",
"--enable-tls",
"--enable-libstdcxx-time",
"--enable-checking=release",
"--enable-cet=auto",
"--enable-linker-build-id",
"--disable-nls",
"--disable-multilib",
"--disable-fixed-point",
"--disable-werror",
"--disable-libsanitizer",
"--disable-symvers",
env={
"CFLAGS": self.profile["host_cflags"],
"CXXFLAGS": self.profile["host_cxxflags"],
"LDFLAGS": self.profile["host_ldflags"],
},
)
def build(self):
self.run("make", f"-j{self.jobs}", "all-target-libstdc++-v3")
def install(self):
self.run(
"make",
"install-target-libstdc++-v3",
env={"DESTDIR": str(self.dest_dir)},
)
self.run("find", self.dest_dir, "-name", "*.la", "-delete")
+50
View File
@@ -0,0 +1,50 @@
version = "12.2.0"
revision = 1
description = "Modern, secure, portable, multiprotocol bootloader and boot manager"
license = "BSD-2-Clause"
url = "https://limine-bootloader.org"
source = tarball(
url=f"https://github.com/Limine-Bootloader/Limine/releases/download/v{version}/limine-{version}.tar.gz",
sha256="db8a119878cfeead63c0a78236c577c40539c5759496950ea0ed32a6cf567865",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"]]
build_if = profile["arch"] in ("x86_64", "aarch64", "riscv64", "loongarch64")
_arch_args = {
"x86_64": [
"--enable-uefi-x86-64",
"--enable-uefi-ia32",
"--enable-bios",
"--enable-bios-cd",
],
"aarch64": ["--enable-uefi-aarch64"],
"riscv64": ["--enable-uefi-riscv64"],
"loongarch64": ["--enable-uefi-loongarch64"],
}
configure, build, install = autotools(
configure_args=["--enable-uefi-cd", *_arch_args.get(profile["arch"], [])],
configure_env={"TOOLCHAIN_FOR_TARGET": profile["triple"] + "-"},
)
subpackages = [
subpackage(
"limine-uefi",
description="UEFI files",
files=[
"usr/share/limine/BOOT*.EFI",
"usr/share/limine/limine-uefi-*.bin",
],
),
]
if profile["arch"] == "x86_64":
subpackages.append(
subpackage(
"limine-bios",
description="BIOS files",
files=["usr/share/limine/limine-bios*"],
)
)
-25
View File
@@ -1,25 +0,0 @@
name = "limine"
version = "12.2.0"
revision = 1
description = "Modern, secure, portable, multiprotocol bootloader and boot manager"
license = "BSD-2-Clause"
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]
def configure(ctx):
toolchain = OPTIONS.target_triple + "-"
autotools_configure(ctx, extra_env = {
"TOOLCHAIN_FOR_TARGET": toolchain,
"LD_FOR_TARGET": toolchain + "ld",
"OBJCOPY_FOR_TARGET": toolchain + "objcopy",
"OBJDUMP_FOR_TARGET": toolchain + "objdump",
})
_, build, install = autotools()
+38
View File
@@ -0,0 +1,38 @@
version = "7.0.9"
revision = 2
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",
)
linux_archs = {"aarch64": "arm64"}
def build(self):
# Stage the source into the (writable) build dir before invoking the kernel
# build system, which is not happy with a read-only source tree.
self.run("cp", "-rp", f"{self.source_dir}/.", self.build_dir)
arch = linux_archs.get(self.profile["arch"], self.profile["arch"])
self.run("make", "headers_install", f"ARCH={arch}")
self.run(
"find",
self.build_dir / "usr/include",
"-type",
"f",
"!",
"-name",
"*.h",
"-delete",
)
def install(self):
self.run("mkdir", "-p", self.dest_dir / self.profile["prefix"].lstrip("/"))
self.run(
"cp",
"-rp",
self.build_dir / "usr/include",
str(self.dest_dir) + self.profile["prefix"],
)
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
version = "7.0.9"
revision = 2
description = "Linux kernel"
license = "GPL-2.0-only"
source = tarball(
url=f"https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{version}.tar.xz",
sha256="ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4",
)
host_deps = ["binutils", "gcc"]
deps = [profile["libc"]]
linux_archs = {"aarch64": "arm64"}
linux_subarchs = {"x86_64": "x86"}
def _make(self, *extra):
arch = linux_archs.get(self.profile["arch"], self.profile["arch"])
subarch = linux_subarchs.get(self.profile["arch"])
subarch_arg = (f"SUBARCH={subarch}",) if subarch else ()
return (
"make",
f"ARCH={arch}",
*subarch_arg,
f"CROSS_COMPILE={self.triple}-",
f"-j{self.jobs}",
*extra,
)
def configure(self):
self.run("cp", "-rp", f"{self.source_dir}/.", self.build_dir)
self.run(
"cp", self.files / f"config.{self.profile['arch']}", self.build_dir / ".config"
)
self.run(*_make(self, "olddefconfig"))
def build(self):
self.run(*_make(self))
def install(self):
self.run(
"install",
"-Dm644",
self.build_dir / "arch/x86/boot/bzImage",
self.dest_dir / f"boot/vmlinuz-{self.version}",
)
-37
View File
@@ -1,37 +0,0 @@
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,
)
+13
View File
@@ -0,0 +1,13 @@
version = "4.4.1"
revision = 1
description = "GNU make build automation tool"
license = "GPL-3.0-or-later"
url = "https://www.gnu.org/software/make/"
source = tarball(
url=f"https://ftp.gnu.org/gnu/make/make-{version}.tar.gz",
sha256="dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"]]
configure, build, install = autotools(configure_args=["--without-guile"])
+28
View File
@@ -0,0 +1,28 @@
version = "1.2.6"
revision = 1
description = "Small, standards-conformant implementation of libc"
license = "MIT"
source = tarball(
url=f"https://musl.libc.org/releases/musl-{version}.tar.gz",
sha256="d585fd3b613c66151fc3249e8ed44f77020cb5e6c1e635a616d3f9f82460512a",
)
host_deps = ["binutils", "gcc-bootstrap"]
build_if = profile["libc"] == "musl"
def configure(self):
self.run(
self.source_dir / "configure",
f"--target={self.triple}",
f"--prefix={self.profile['prefix']}",
"--syslibdir=/lib",
env={
"CC": f"{self.triple}-gcc",
"CFLAGS": self.profile["cflags"],
"LDFLAGS": self.profile["ldflags"],
},
)
_, build, install = autotools()
-31
View File
@@ -1,31 +0,0 @@
name = "musl"
version = "1.2.6"
revision = 1
description = "Musl C library"
license = "MIT"
source = {
"url": f"https://musl.libc.org/releases/musl-{version}.tar.gz",
"sha256": "d585fd3b613c66151fc3249e8ed44f77020cb5e6c1e635a616d3f9f82460512a",
"strip_components": 1,
}
host_deps = ["binutils", "gcc"]
def configure(ctx):
ctx.run(
[
ctx.source_dir + "/configure",
"--prefix=/usr",
"--syslibdir=/lib",
"--target=" + OPTIONS.target_triple,
],
env = {
"CC": OPTIONS.target_triple + "-gcc",
"CFLAGS": OPTIONS.cflags,
"LDFLAGS": OPTIONS.ldflags,
},
)
build = autotools_build
install = autotools_install
+39
View File
@@ -0,0 +1,39 @@
version = "6.5"
revision = 1
description = "Terminal control library with wide-character support"
license = "MIT"
url = "https://invisible-island.net/ncurses/"
source = tarball(
url=f"https://invisible-mirror.net/archives/ncurses/ncurses-{version}.tar.gz",
sha256="136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"], "libstdc++"]
configure, build, _ = autotools(
configure_args=[
"--with-shared",
"--without-debug",
"--without-ada",
"--enable-pc-files",
"--enable-widec",
"--with-termlib",
"--with-cxx-binding",
"--with-cxx-shared",
"--with-pkg-config-libdir=/usr/lib/pkgconfig",
"--with-tic-path=/usr/bin/tic",
"--with-infocmp-path=/usr/bin/infocmp",
"--mandir=/usr/share/man",
],
configure_env={
# Conflicts with GCC 16 headers.
"cf_cv_type_of_bool": "bool",
"cf_cv_cc_bool_type": "1",
"cf_cv_builtin_bool": "1",
"ac_cv_header_stdbool_h": "yes",
},
)
def install(self):
autotools_install(self, [f"DESTDIR={self.dest_dir}"])
+53
View File
@@ -0,0 +1,53 @@
version = "3.4.1"
revision = 1
description = "Cryptography and TLS library (OpenSSL)"
license = "Apache-2.0"
url = "https://www.openssl.org/"
source = tarball(
url=f"https://github.com/openssl/openssl/releases/download/openssl-{version}/openssl-{version}.tar.gz",
sha256="?",
)
host_deps = ["binutils", "gcc"]
deps = ["zlib"]
ossl_targets = {
"x86_64": "linux-x86_64",
"aarch64": "linux-aarch64",
"riscv64": "linux64-riscv64",
}
def configure(self):
target = ossl_targets.get(self.profile["arch"])
if target is None:
raise ValueError(f"openssl: unsupported arch {self.profile['arch']}")
self.run(
self.source_dir / "Configure",
target,
f"--prefix={self.profile['prefix']}",
"--openssldir=/etc/ssl",
"--libdir=lib",
"shared",
"zlib",
"no-tests",
"no-static-engine",
"enable-ktls",
env={
"CC": f"{self.triple}-gcc",
"AR": f"{self.triple}-ar",
"RANLIB": f"{self.triple}-ranlib",
"CFLAGS": self.profile["cflags"],
"LDFLAGS": self.profile["ldflags"],
},
)
def build(self):
self.run("make", f"-j{self.jobs}")
def install(self):
self.run(
"make", "install_sw", "install_ssldirs", env={"DESTDIR": str(self.dest_dir)}
)
+18
View File
@@ -0,0 +1,18 @@
version = "3.3.3"
revision = 1
description = "Lightweight pkg-config implementation"
license = "ISC"
url = "http://pkgconf.org/"
source = tarball(
url=f"https://distfiles.ariadne.space/pkgconf/pkgconf-{version}.tar.xz",
sha256="?",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"]]
configure, build, install = autotools(
configure_args=[
f"--with-system-libdir={profile['libdir']}",
f"--with-system-includedir={profile['includedir']}",
]
)
+22
View File
@@ -0,0 +1,22 @@
version = "8.2"
revision = 1
description = "Library for command-line editing"
license = "GPL-3.0-or-later"
url = "https://tiswww.case.edu/php/chet/readline/rltop.html"
source = tarball(
url=f"https://ftp.gnu.org/gnu/readline/readline-{version}.tar.gz",
sha256="3feb7171f16a84ee82ca18a36d7b9be109a52c04f492a053331d7d1095007c35",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"], "ncurses"]
configure, build, _ = autotools(
configure_args=["--with-curses"],
# Force linking against system ncurses, not readline's internal termcap stub.
configure_env={"bash_cv_termcap_lib": "ncursesw"},
)
def install(self):
# readline overwrites DESTDIR on its own; pass explicitly.
autotools_install(self, [f"DESTDIR={self.dest_dir}"])
+13
View File
@@ -0,0 +1,13 @@
version = "5.6.3"
revision = 1
description = "LZMA/XZ compression tools and library"
license = "0BSD AND GPL-2.0-or-later AND LGPL-2.1-or-later"
url = "https://tukaani.org/xz/"
source = tarball(
url=f"https://github.com/tukaani-project/xz/releases/download/v{version}/xz-{version}.tar.xz",
sha256="db0590629b6f0fa36e74aea5f9731dc6f8df068ce7b7bafa45301832a5eebc3a",
)
host_deps = ["autoconf", "automake", "binutils", "gcc"]
deps = [profile["libc"]]
configure, build, install = autotools(configure_args=["--disable-doc"])
+36
View File
@@ -0,0 +1,36 @@
version = "1.3.2"
revision = 1
description = "Lossless data-compression library"
license = "Zlib"
url = "https://zlib.net/"
source = tarball(
url=f"https://github.com/madler/zlib/releases/download/v{version}/zlib-{version}.tar.gz",
sha256="?",
)
host_deps = ["binutils", "gcc"]
deps = [profile["libc"]]
def configure(self):
# zlib ships its own ./configure; doesn't grok autoconf flags.
self.run(
self.source_dir / "configure",
f"--prefix={self.profile['prefix']}",
f"--libdir={self.profile['libdir']}",
f"--sharedlibdir={self.profile['libdir']}",
env={
"CC": f"{self.triple}-gcc",
"AR": f"{self.triple}-ar",
"RANLIB": f"{self.triple}-ranlib",
"CFLAGS": self.profile["cflags"],
"LDFLAGS": self.profile["ldflags"],
},
)
def build(self):
self.run("make", f"-j{self.jobs}")
def install(self):
self.run("make", "install", env={"DESTDIR": str(self.dest_dir)})
+3
View File
@@ -0,0 +1,3 @@
"""Orchid build system"""
__version__ = "0.1.0"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/python3
import os
import sys
_HERE = os.path.dirname(os.path.realpath(__file__))
_ROOT = os.path.dirname(_HERE)
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from src.cli import main
raise SystemExit(main())
+135
View File
@@ -0,0 +1,135 @@
from src.container import Container
from src.recipe import Recipe
from src.source import Subpackage
def _info_args(
name: str,
version: str,
revision: int,
arch: str,
origin: str,
description: str,
license: str,
url: str,
maintainer: str,
depends: list[str],
) -> list[str]:
args = [
"--info",
f"name:{name}",
"--info",
f"version:{version}-r{revision}",
"--info",
f"arch:{arch}",
"--info",
f"origin:{origin}",
]
if description:
args += ["--info", f"description:{description}"]
if license:
args += ["--info", f"license:{license}"]
if url:
args += ["--info", f"url:{url}"]
if maintainer:
args += ["--info", f"packager:{maintainer}"]
for d in depends:
args += ["--info", f"depends:{d}"]
return args
def mkpkg_base(container: Container, recipe: Recipe, arch: str) -> None:
name = recipe.name
deps = list(recipe.deps) + list(recipe.run_deps)
args = [
"apk",
"mkpkg",
"--files",
f"/dest/{name}",
"--output",
f"/pkgs/{name}-{recipe.version}-r{recipe.revision}.apk",
*_info_args(
name,
recipe.version,
recipe.revision,
arch,
recipe.name,
recipe.description,
recipe.license,
recipe.url,
recipe.maintainer,
deps,
),
]
container.exec(args)
def mkpkg_subpackage(
container: Container, recipe: Recipe, sub: Subpackage, arch: str
) -> None:
args = [
"apk",
"mkpkg",
"--files",
f"/dest/{sub.name}",
"--output",
f"/pkgs/{sub.name}-{recipe.version}-r{recipe.revision}.apk",
*_info_args(
sub.name,
recipe.version,
recipe.revision,
arch,
recipe.name,
sub.description,
sub.license,
sub.url,
sub.maintainer,
[recipe.name],
),
]
container.exec(args)
def index(container: Container) -> None:
container.exec_shell(
"apk mkndx --allow-untrusted -o /pkgs/Packages.adb /pkgs/*.apk"
)
def sysroot_install(container: Container, pkgs: list[str], *, initdb: bool) -> None:
if not pkgs:
return
args = [
"apk",
"add",
"--root",
"/sysroot",
"--allow-untrusted",
"--no-network",
"--repository",
"/pkgs/Packages.adb",
]
if initdb:
args.append("--initdb")
args += pkgs
container.exec(args)
def sysroot_initialized(container: Container) -> bool:
rc = container.exec(["test", "-f", "/sysroot/lib/apk/db/installed"], check=False)
return rc == 0
def split_subpackage_script(base: str, dest: str, pattern: str) -> str:
# Move matching files from /dest/<base> to /dest/<dest>, preserving dirs.
# TODO: Make this cleaner.
return (
f"set -e; "
f"cd /dest/{base}; "
f"find . -depth -path './{pattern}' -print 2>/dev/null | while IFS= read -r f; do "
f' rel="${{f#./}}"; '
f' target=/dest/{dest}/"$rel"; '
f' mkdir -p "$(dirname "$target")"; '
f' mv "$f" "$target"; '
f"done"
)
-46
View File
@@ -1,46 +0,0 @@
use crate::config::Config;
use crate::recipe::OutputPackage;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ApkPackagePlan {
pub args: Vec<String>,
}
pub fn mkpkg_plan(
config: &Config,
package: &OutputPackage,
files_root: &Path,
output_dir: &Path,
) -> ApkPackagePlan {
let file_name = format!(
"{}-{}-r{}.apk",
package.name, package.version, package.revision
);
let output = output_dir.join(file_name);
let version = format!("{}-r{}", package.version, package.revision);
let mut args = vec![
"mkpkg".to_owned(),
"--files".to_owned(),
files_root.display().to_string(),
"--output".to_owned(),
output.display().to_string(),
"--info".to_owned(),
format!("name:{}", package.name),
"--info".to_owned(),
format!("version:{version}"),
"--info".to_owned(),
format!("arch:{}", config.target_arch),
"--info".to_owned(),
format!("description:{}", package.description),
"--info".to_owned(),
format!("license:{}", package.license),
"--info".to_owned(),
format!("origin:{}", package.recipe),
];
for dep in &package.deps {
args.push("--info".to_owned());
args.push(format!("depends:{dep}"));
}
ApkPackagePlan { args }
}
-949
View File
@@ -1,949 +0,0 @@
use crate::apk;
use crate::config::Config;
use crate::graph::PackageGraph;
use crate::log;
use crate::patches;
use crate::phase::{PhaseCommand, PhaseEnv, SourceDir, collect_phase_commands};
use crate::recipe::{OutputPackage, PackageKind, Recipe, RecipeSet};
use crate::source;
use anyhow::{Context, Result, anyhow, bail};
use sha2::{Digest, Sha256};
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const C_SOURCE: &str = "/source";
const C_BUILD: &str = "/builddir";
const C_DEST: &str = "/dest";
const C_SYSROOT: &str = "/sysroot";
const HOST_PREFIX: &str = "/usr/local";
const TARGET_PREFIX: &str = "/usr";
#[derive(Debug)]
pub struct Builder {
repo: PathBuf,
config: Config,
skip_checks: bool,
preflight_done: Cell<bool>,
built_recipes: RefCell<HashSet<String>>,
}
impl Builder {
pub fn new(repo: PathBuf, config: Config, skip_checks: bool) -> Self {
Self {
repo,
config,
skip_checks,
preflight_done: Cell::new(false),
built_recipes: RefCell::default(),
}
}
pub fn build(
&self,
recipes: &RecipeSet,
graph: &PackageGraph,
package: Option<&str>,
rebuild: bool,
) -> Result<()> {
self.preflight_container()?;
let (label, order) = match package {
Some(p) => (p.to_owned(), graph.build_order(p)?),
None => ("<all>".to_owned(), graph.build_order_all()?),
};
log::step(
"plan",
&format!("{label}: {} package(s) [{}]", order.len(), order.join(", ")),
);
for output_name in order {
let output = graph.output(&output_name)?;
let recipe = recipes.recipe_for_package(&output_name)?;
self.ensure_source(recipe)?;
// Only force a rebuild of the explicitly requested package;
// dependencies stay cached when their manifest is up to date.
// With no package (build all), every package honours --rebuild.
let force = rebuild && package.map_or(true, |p| p == output_name);
match output.kind {
PackageKind::Host => self.build_host(recipe, output, force)?,
PackageKind::Target => self.build_target(recipe, output, force)?,
}
}
self.repo_index()?;
Ok(())
}
pub fn fetch(&self, recipes: &RecipeSet, package: &str) -> Result<()> {
let recipe = recipes.recipe_by_user_ref(package)?;
log::step("fetch", &recipe.key());
source::fetch_sources(recipe, &self.source_cache_dir())?;
Ok(())
}
pub fn shell(&self, recipes: &RecipeSet, graph: &PackageGraph, package: &str) -> Result<()> {
graph.output(package)?;
let recipe = recipes.recipe_for_package(package)?;
self.preflight_container()?;
let source = self.unpacked_source_dir(recipe);
let build = self.build_dir(recipe);
fs::create_dir_all(&source)?;
fs::create_dir_all(&build)?;
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-it")
.arg("-v")
.arg(format!("{}:{C_SOURCE}:ro", source.display()))
.arg("-v")
.arg(format!("{}:{C_BUILD}", build.display()))
.arg("-w")
.arg(C_BUILD)
.arg(&self.config.container_image)
.arg("/bin/sh")
.status()?;
if !status.success() {
bail!("container shell exited with {status}");
}
Ok(())
}
pub fn repo_index(&self) -> Result<()> {
self.preflight_container()?;
let repo = self.pkgs_dir();
fs::create_dir_all(&repo)?;
// Nothing to index yet — leave it alone so callers don't trip on a
// failing `*.apk` glob.
let has_apks = fs::read_dir(&repo)?.any(|e| {
e.ok()
.and_then(|e| e.path().extension().map(|x| x == "apk"))
.unwrap_or(false)
});
if !has_apks {
return Ok(());
}
log::step(
"index",
&format!("signing repository at {}", repo.display()),
);
let key = self.abs_config_path(&self.config.signing_key);
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
if !key.exists() || !pubkey.exists() {
bail!("signing key is not configured or missing; run `distro init-key` first");
}
let index_name = "APKINDEX.tar.gz";
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:/repo", repo.display()))
.arg("-v")
.arg(format!("{}:/keys/distro.rsa:ro", key.display()))
.arg("-v")
.arg(format!(
"{}:/etc/apk/keys/distro.rsa.pub:ro",
pubkey.display()
))
.arg(&self.config.container_image)
.arg("/bin/sh")
.arg("-lc")
.arg(format!(
"cd /repo && apk --sign-key /keys/distro.rsa mkndx -o {index_name} *.apk"
))
.status()
.context("failed to run repository index command")?;
if !status.success() {
bail!("repository index command failed with {status}");
}
Ok(())
}
pub fn init_key(&self) -> Result<()> {
let key = self.abs_config_path(&self.config.signing_key);
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
if key.exists() && pubkey.exists() {
log::skip("init-key", "signing key already present");
return Ok(());
}
log::step("init-key", &format!("generating {}", key.display()));
let key_dir = key
.parent()
.ok_or_else(|| anyhow!("invalid signing key path"))?;
let key_name = key
.file_name()
.ok_or_else(|| anyhow!("invalid signing key path"))?
.to_string_lossy();
fs::create_dir_all(key_dir)?;
self.preflight_container()?;
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:/keys", key_dir.display()))
.arg(&self.config.container_image)
.arg("/bin/sh")
.arg("-lc")
.arg(format!(
"openssl genrsa -out /keys/{key_name} 4096 && \
openssl rsa -in /keys/{key_name} -pubout -out /keys/{key_name}.pub"
))
.status()
.context("failed to run key generation command")?;
if !status.success() {
bail!("key generation failed with {status}");
}
Ok(())
}
pub fn rootfs(&self, root: &Path, packages: &[String]) -> Result<()> {
self.preflight_container()?;
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
if !pubkey.exists() {
bail!("rootfs requires a configured public signing key; run `distro init-key` first");
}
fs::create_dir_all(root)?;
log::step(
"rootfs",
&format!(
"{} -> {} [{}]",
packages.join(", "),
root.display(),
root.display()
),
);
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:/rootfs", root.display()))
.arg("-v")
.arg(format!("{}:/repo:ro", self.pkgs_root().display()))
.arg("-v")
.arg(format!(
"{}:/etc/apk/keys/distro.rsa.pub:ro",
pubkey.display()
))
.arg(&self.config.container_image)
.arg("apk")
.arg("--root")
.arg("/rootfs")
.arg("--keys-dir")
.arg("/etc/apk/keys")
.arg("--initdb")
.arg("--repository")
.arg("/repo")
.arg("add")
.args(packages)
.status()
.context("failed to run rootfs apk command")?;
if !status.success() {
bail!("rootfs creation failed with {status}");
}
Ok(())
}
// --- per-recipe build steps -------------------------------------------
fn build_host(&self, recipe: &Recipe, output: &OutputPackage, rebuild: bool) -> Result<()> {
let manifest = self.manifest_path(&output.key());
let hash = self.manifest_hash(recipe)?;
if !rebuild && fs::read_to_string(&manifest).ok().as_deref() == Some(hash.as_str()) {
log::skip("up-to-date", &output.key());
return Ok(());
}
log::step(
"build",
&format!("{} {}-r{}", output.key(), recipe.version, recipe.revision),
);
let build_dir = self.host_build_dir(recipe);
let dest_dir = self.host_pkg_dir(recipe);
let source_dir = self.unpacked_source_dir(recipe);
// A rebuild starts from a clean build dir; sources stay shared.
if rebuild {
Self::recreate(&build_dir)?;
} else {
fs::create_dir_all(&build_dir)?;
}
Self::recreate(&dest_dir)?;
fs::create_dir_all(&build_dir)?;
let env = PhaseEnv {
source_dir: source_dir_env(recipe),
build_dir: C_BUILD,
dest_dir: C_DEST,
prefix: HOST_PREFIX,
sysroot: C_SYSROOT,
};
self.run_recipe_phases(
recipe,
output,
&env,
&source_dir,
&build_dir,
&dest_dir,
None,
)?;
fs::create_dir_all(manifest.parent().unwrap())?;
fs::write(manifest, hash)?;
Ok(())
}
fn build_target(&self, recipe: &Recipe, output: &OutputPackage, rebuild: bool) -> Result<()> {
let manifest = self.manifest_path(&output.key());
let hash = self.manifest_hash(recipe)?;
if !rebuild && fs::read_to_string(&manifest).ok().as_deref() == Some(hash.as_str()) {
log::skip("up-to-date", &output.key());
return Ok(());
}
log::step(
"build",
&format!("{} {}-r{}", output.key(), recipe.version, recipe.revision),
);
let build_dir = self.build_dir(recipe);
let source_dir = self.unpacked_source_dir(recipe);
// A rebuild starts from a clean build dir; sources stay shared.
if rebuild {
Self::recreate(&build_dir)?;
} else {
fs::create_dir_all(&build_dir)?;
}
let dest_tmp =
tempfile::tempdir_in(&build_dir).context("failed to create ephemeral destdir")?;
let dest_dir = dest_tmp.path();
let sysroot = self.materialize_sysroot(recipe)?;
let env = PhaseEnv {
source_dir: source_dir_env(recipe),
build_dir: C_BUILD,
dest_dir: C_DEST,
prefix: TARGET_PREFIX,
sysroot: C_SYSROOT,
};
self.run_recipe_phases(
recipe,
output,
&env,
&source_dir,
&build_dir,
dest_dir,
sysroot.as_ref().map(|s| s.path()),
)?;
self.apk_mkpkg(output, dest_dir)?;
fs::create_dir_all(manifest.parent().unwrap())?;
fs::write(manifest, hash)?;
Ok(())
}
fn run_recipe_phases(
&self,
recipe: &Recipe,
output: &OutputPackage,
env: &PhaseEnv<'_>,
source_dir: &Path,
build_dir: &Path,
dest_dir: &Path,
target_sysroot: Option<&Path>,
) -> Result<()> {
let host_sandbox = self.materialize_host_sandbox(recipe)?;
let recipe_already_built = self.built_recipes.borrow().contains(&recipe.key());
// configure/build/check run at most once per recipe per invocation.
let mut shared_phases: Vec<(&str, bool)> = Vec::new();
if !recipe_already_built {
if let Some(p) = recipe.configure_fn.as_deref() {
shared_phases.push((p, false));
}
if let Some(p) = recipe.build_fn.as_deref() {
shared_phases.push((p, false));
}
if !self.skip_checks {
if let Some(p) = recipe.check_fn.as_deref() {
shared_phases.push((p, false));
}
}
}
// install runs per output (each output has its own destdir/install_fn).
let install_phase = (output.install_fn.as_str(), true);
let mut commands: Vec<PhaseCommand> = Vec::new();
for (phase, takes_pkg) in shared_phases.iter().chain(std::iter::once(&install_phase)) {
let pkg = if *takes_pkg {
Some((output.name.as_str(), C_DEST))
} else {
None
};
let owner = if *takes_pkg {
output.key()
} else {
recipe.key()
};
log::info("phase", &format!("{phase} {owner}"));
commands.extend(collect_phase_commands(
&recipe.path,
&self.repo,
&self.config,
phase,
env,
pkg,
)?);
}
if commands.is_empty() {
self.built_recipes.borrow_mut().insert(recipe.key());
return Ok(());
}
self.run_in_container(
source_dir,
build_dir,
dest_dir,
host_sandbox.as_ref().map(|s| s.path()),
target_sysroot,
&commands,
)?;
self.built_recipes.borrow_mut().insert(recipe.key());
Ok(())
}
fn run_in_container(
&self,
source_dir: &Path,
build_dir: &Path,
dest_dir: &Path,
host_sandbox: Option<&Path>,
target_sysroot: Option<&Path>,
commands: &[PhaseCommand],
) -> Result<()> {
fs::create_dir_all(dest_dir)?;
let mut process = Command::new(&self.config.container_runtime);
process
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:{C_SOURCE}:ro", source_dir.display()))
.arg("-v")
.arg(format!("{}:{C_BUILD}", build_dir.display()))
.arg("-v")
.arg(format!("{}:{C_DEST}", dest_dir.display()))
.arg("-w")
.arg(C_BUILD);
if let Some(sandbox) = host_sandbox {
// Host packages are configured with --prefix=/usr/local, so we
// bind the assembled tree exactly there (matching Jinx). This
// means rpaths, --print-search-dirs, pkg-config lookups, etc.
// all keep working with no extra environment fiddling.
process
.arg("-v")
.arg(format!("{}:{HOST_PREFIX}:ro", sandbox.display()));
}
if let Some(sysroot) = target_sysroot {
process
.arg("-v")
.arg(format!("{}:{C_SYSROOT}:ro", sysroot.display()))
.arg("-e")
.arg(format!("PKG_CONFIG_SYSROOT_DIR={C_SYSROOT}"))
.arg("-e")
.arg(format!("SYSROOT={C_SYSROOT}"));
}
process
.arg(&self.config.container_image)
.arg("/bin/sh")
.arg("-c")
.arg(build_phase_script(commands));
let status = process
.status()
.context("failed to start container for phase commands")?;
if !status.success() {
bail!("phase commands failed with {status}");
}
Ok(())
}
fn apk_mkpkg(&self, output: &OutputPackage, dest_dir: &Path) -> Result<()> {
let repo = self.pkgs_dir();
fs::create_dir_all(&repo)?;
let signing_key = self.abs_config_path(&self.config.signing_key);
if !signing_key.exists() {
bail!("package signing key is missing; run `distro init-key` first");
}
log::step("package", &format!("{} -> {}", output.name, repo.display()));
let plan = apk::mkpkg_plan(&self.config, output, Path::new(C_DEST), Path::new("/out"));
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:{C_DEST}:ro", dest_dir.display()))
.arg("-v")
.arg(format!("{}:/out", repo.display()))
.arg("-v")
.arg(format!("{}:/keys/distro.rsa:ro", signing_key.display()))
.arg(&self.config.container_image)
.arg("apk")
.arg("--sign-key")
.arg("/keys/distro.rsa")
.args(plan.args)
.status()
.context("failed to run apk mkpkg command")?;
if !status.success() {
bail!("apk mkpkg failed with {status}");
}
Ok(())
}
// --- source preparation (Jinx-style persistent sources/<recipe>/) ------
fn ensure_source(&self, recipe: &Recipe) -> Result<()> {
let cached = source::fetch_sources(recipe, &self.source_cache_dir())?;
let unpacked = self.unpacked_source_dir(recipe);
let version_stamp = self.source_stamp(recipe, "version");
let want_version = format!("{}-r{}", recipe.version, recipe.revision);
let need_extract =
fs::read_to_string(&version_stamp).ok().as_deref() != Some(&want_version);
if need_extract {
log::info("unpack", &recipe.key());
if unpacked.exists() {
fs::remove_dir_all(&unpacked)?;
}
fs::create_dir_all(&unpacked)?;
for (src, tarball) in recipe.sources.iter().zip(cached.iter()) {
let dest = if src.name.is_empty() {
unpacked.clone()
} else {
unpacked.join(&src.name)
};
self.extract_tarball(tarball, &dest, src.strip_components)?;
}
fs::write(&version_stamp, &want_version)?;
let patched = self.source_stamp(recipe, "patched");
let _ = fs::remove_file(&patched);
}
let patched_stamp = self.source_stamp(recipe, "patched");
if !patched_stamp.exists() {
self.apply_patches(recipe, &unpacked)?;
fs::write(&patched_stamp, "")?;
}
Ok(())
}
fn extract_tarball(&self, tarball: &Path, dest: &Path, strip_components: u32) -> Result<()> {
fs::create_dir_all(dest)?;
log::info(
"extract",
&format!("{} -> {}", tarball.display(), dest.display()),
);
let strip = if strip_components > 0 {
format!("--strip-components={strip_components}")
} else {
String::new()
};
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:/in.tar:ro", tarball.display()))
.arg("-v")
.arg(format!("{}:/out", dest.display()))
.arg(&self.config.container_image)
.arg("/bin/sh")
.arg("-c")
.arg(format!("tar -xf /in.tar -C /out {strip}"))
.status()
.context("failed to unpack source archive")?;
if !status.success() {
bail!("source unpack failed with {status}");
}
Ok(())
}
fn apply_patches(&self, recipe: &Recipe, source_dir: &Path) -> Result<()> {
let patches = patches::discover(&recipe.dir)?;
if patches.is_empty() {
return Ok(());
}
if recipe.sources.iter().any(|s| !s.name.is_empty()) {
bail!(
"recipe `{}` has patches/ but uses multi-source layout; \
apply patches yourself via ctx.run in the configure phase",
recipe.id
);
}
for patch in patches {
log::info(
"patch",
&format!(
"{} <- {}",
recipe.id,
patch
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
),
);
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:/source", source_dir.display()))
.arg("-v")
.arg(format!("{}:/patch.diff:ro", patch.display()))
.arg(&self.config.container_image)
.arg("patch")
.arg("-d")
.arg("/source")
.arg("-p1")
.arg("-i")
.arg("/patch.diff")
.status()
.context("failed to apply patch")?;
if !status.success() {
bail!("patch {} failed with {status}", patch.display());
}
}
Ok(())
}
// --- host sandbox / target sysroot materialization --------------------
/// Assemble all `host_deps` into a single ephemeral tree that we can mount
/// at `/usr/local` inside the container. Following Jinx, we hard-link
/// rather than byte-copy so this is essentially free. The returned
/// `TempDir` cleans up when dropped.
fn materialize_host_sandbox(&self, recipe: &Recipe) -> Result<Option<tempfile::TempDir>> {
if recipe.host_deps.is_empty() {
return Ok(None);
}
fs::create_dir_all(self.repo.join("build"))?;
let sandbox = tempfile::Builder::new()
.prefix(&format!("host-sandbox-{}-", recipe.id))
.tempdir_in(self.repo.join("build"))
.context("failed to create host sandbox tempdir")?;
log::info(
"host-sandbox",
&format!("{} <- [{}]", recipe.id, recipe.host_deps.join(", ")),
);
for dep in &recipe.host_deps {
let source = self.host_pkg_dir_by_id(dep).join("usr/local");
if !source.exists() {
bail!(
"host dependency `{dep}` has not been built at {}",
source.display()
);
}
hardlink_tree(&source, sandbox.path())?;
}
Ok(Some(sandbox))
}
fn materialize_sysroot(&self, recipe: &Recipe) -> Result<Option<tempfile::TempDir>> {
let mut deps: Vec<String> = recipe
.build_deps
.iter()
.chain(recipe.deps.iter())
.cloned()
.collect();
deps.sort();
deps.dedup();
if deps.is_empty() {
return Ok(None);
}
// The local repo index must be present and current so apk can resolve
// and verify the just-built target packages.
self.repo_index()?;
let pubkey = self.abs_config_path(&self.config.signing_pubkey);
if !pubkey.exists() {
bail!("target dependency sysroot requires a configured public signing key");
}
fs::create_dir_all(self.repo.join("build"))?;
let sysroot = tempfile::Builder::new()
.prefix(&format!("sysroot-{}-", recipe.id))
.tempdir_in(self.repo.join("build"))
.context("failed to create sysroot tempdir")?;
log::info(
"sysroot",
&format!("{} <- [{}]", recipe.id, deps.join(", ")),
);
let status = Command::new(&self.config.container_runtime)
.arg("run")
.arg("--rm")
.arg("-v")
.arg(format!("{}:/sysroot", sysroot.path().display()))
.arg("-v")
.arg(format!("{}:/repo:ro", self.pkgs_root().display()))
.arg("-v")
.arg(format!(
"{}:/etc/apk/keys/distro.rsa.pub:ro",
pubkey.display()
))
.arg(&self.config.container_image)
.arg("apk")
.arg("--root")
.arg("/sysroot")
.arg("--keys-dir")
.arg("/etc/apk/keys")
.arg("--initdb")
.arg("--repository")
.arg("/repo")
.arg("add")
.args(&deps)
.status()
.context("failed to install target dependency sysroot")?;
if !status.success() {
bail!("target dependency sysroot install failed with {status}");
}
Ok(Some(sysroot))
}
// --- preflight & container image --------------------------------------
fn preflight_container(&self) -> Result<()> {
if self.preflight_done.get() {
return Ok(());
}
let status = Command::new(&self.config.container_runtime)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.with_context(|| {
format!(
"{} is required but was not found",
self.config.container_runtime
)
})?;
if !status.success() {
bail!(
"{} preflight failed with {status}",
self.config.container_runtime
);
}
let dockerfile = self.abs_config_path(&self.config.container_dockerfile);
self.ensure_container_image(&dockerfile)?;
self.preflight_done.set(true);
Ok(())
}
fn ensure_container_image(&self, dockerfile: &Path) -> Result<()> {
if !dockerfile.exists() {
bail!(
"configured container Dockerfile does not exist: {}",
dockerfile.display()
);
}
let hash = self.container_build_hash(dockerfile)?;
let stamp = self.repo.join("build/container-image.hash");
if fs::read_to_string(&stamp).ok().as_deref() == Some(hash.as_str())
&& self.container_image_exists()?
{
return Ok(());
}
log::step(
"image",
&format!(
"building {} from {}",
self.config.container_image,
dockerfile.display()
),
);
let status = Command::new(&self.config.container_runtime)
.arg("build")
.arg("-f")
.arg(dockerfile)
.arg("-t")
.arg(&self.config.container_image)
.arg(&self.repo)
.status()
.with_context(|| {
format!(
"failed to build container image `{}` from {}",
self.config.container_image,
dockerfile.display()
)
})?;
if !status.success() {
bail!(
"container image build failed for `{}` with {status}",
self.config.container_image
);
}
fs::create_dir_all(stamp.parent().unwrap())?;
fs::write(stamp, hash)?;
Ok(())
}
fn container_image_exists(&self) -> Result<bool> {
let status = Command::new(&self.config.container_runtime)
.arg("image")
.arg("exists")
.arg(&self.config.container_image)
.status()
.with_context(|| {
format!(
"failed to inspect container image `{}`",
self.config.container_image
)
})?;
Ok(status.success())
}
fn container_build_hash(&self, dockerfile: &Path) -> Result<String> {
let mut hasher = Sha256::new();
hasher.update(fs::read(dockerfile)?);
hasher.update(self.config.container_image.as_bytes());
Ok(hex::encode(hasher.finalize()))
}
fn manifest_hash(&self, recipe: &Recipe) -> Result<String> {
let mut hasher = Sha256::new();
hasher.update(self.config.target_arch.as_bytes());
hasher.update(recipe.version.as_bytes());
hasher.update(recipe.revision.to_le_bytes());
for source in &recipe.sources {
hasher.update(source.url.as_bytes());
hasher.update(source.sha256.as_bytes());
}
for patch in patches::discover(&recipe.dir)? {
hasher.update(patch.display().to_string().as_bytes());
hasher.update(fs::read(patch)?);
}
Ok(hex::encode(hasher.finalize()))
}
// --- path helpers -----------------------------------------------------
fn source_cache_dir(&self) -> PathBuf {
self.repo.join("build/cache/sources")
}
fn unpacked_source_dir(&self, recipe: &Recipe) -> PathBuf {
self.repo.join("build/sources").join(recipe.slug())
}
fn source_stamp(&self, recipe: &Recipe, kind: &str) -> PathBuf {
self.repo
.join("build/sources")
.join(format!("{}.{kind}", recipe.slug()))
}
fn build_dir(&self, recipe: &Recipe) -> PathBuf {
self.repo.join("build/builds").join(&recipe.id)
}
fn host_build_dir(&self, recipe: &Recipe) -> PathBuf {
self.repo.join("build/host-builds").join(&recipe.id)
}
fn host_pkg_dir(&self, recipe: &Recipe) -> PathBuf {
self.repo.join("build/host-pkgs").join(&recipe.id)
}
fn host_pkg_dir_by_id(&self, host_recipe_id: &str) -> PathBuf {
self.repo.join("build/host-pkgs").join(host_recipe_id)
}
/// Root of the target package repo. apk treats this as the repo root and
/// expects `<root>/<arch>/APKINDEX.tar.gz` underneath.
fn pkgs_root(&self) -> PathBuf {
self.repo.join("build/pkgs")
}
/// Arch-specific package directory: where .apk files and the index live.
fn pkgs_dir(&self) -> PathBuf {
self.pkgs_root().join(&self.config.target_arch)
}
fn manifest_path(&self, output_key: &str) -> PathBuf {
// Output keys may contain `:` (e.g. `host:gcc`); the manifest file
// name uses the filesystem-safe slug form instead.
let safe = output_key.replace(':', "-");
self.repo
.join("build/manifests")
.join(format!("{safe}.hash"))
}
fn abs_config_path(&self, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
self.repo.join(path)
}
}
fn recreate(path: &Path) -> Result<()> {
if path.exists() {
fs::remove_dir_all(path)?;
}
fs::create_dir_all(path)?;
Ok(())
}
}
fn build_phase_script(commands: &[PhaseCommand]) -> String {
let mut parts: Vec<String> = Vec::with_capacity(commands.len() + 1);
parts.push("set -e".to_owned());
for cmd in commands {
let mut tokens: Vec<String> = Vec::with_capacity(cmd.env.len() + cmd.argv.len());
for (k, v) in &cmd.env {
let value = shell_escape::unix::escape(v.as_str().into()).into_owned();
tokens.push(format!("{k}={value}"));
}
for arg in &cmd.argv {
tokens.push(shell_escape::unix::escape(arg.as_str().into()).into_owned());
}
parts.push(tokens.join(" "));
}
parts.join("\n")
}
/// Expose `/source` as a string for single-source recipes, or a struct of
/// `/source/<name>` paths for multi-source recipes.
fn source_dir_env(recipe: &Recipe) -> SourceDir {
if recipe.sources.iter().all(|s| s.name.is_empty()) {
SourceDir::Single(C_SOURCE.to_owned())
} else {
let map = recipe
.sources
.iter()
.map(|s| (s.name.clone(), format!("{C_SOURCE}/{}", s.name)))
.collect();
SourceDir::Many(map)
}
}
/// Mirror `src` into `dst` using hard links for regular files (and preserving
/// symlinks). This matches Jinx's `cp -Pplr`: no bytes are copied, just inode
/// references, so assembling a host-deps sandbox is essentially free.
fn hardlink_tree(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in walkdir::WalkDir::new(src) {
let entry = entry?;
let relative = entry.path().strip_prefix(src)?;
if relative.as_os_str().is_empty() {
continue;
}
let target = dst.join(relative);
let file_type = entry.file_type();
if file_type.is_dir() {
fs::create_dir_all(&target)?;
} else if file_type.is_symlink() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let link_target = fs::read_link(entry.path())?;
// Replace any pre-existing entry (multiple host deps may ship the
// same symlink path).
let _ = fs::remove_file(&target);
std::os::unix::fs::symlink(&link_target, &target)?;
} else if file_type.is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
if target.exists() {
continue;
}
fs::hard_link(entry.path(), &target).with_context(|| {
format!(
"failed to hard-link {} -> {}",
entry.path().display(),
target.display()
)
})?;
}
}
Ok(())
}
+283
View File
@@ -0,0 +1,283 @@
import os
import re
import shutil
from pathlib import Path
from src import apk, fetch, log
from src.container import Container, Mount
from src.context import RecipeContext
from src.layout import Layout
from src.plan import (
PHASE_STAMPS,
Plan,
stamp_dir,
stamp_token,
stamp_valid,
transitive_host_deps,
)
from src.recipe import Recipe, RecipeSet
def _container_name(*parts: str) -> str:
name = "-".join(parts)
name = re.sub(
r"[^a-zA-Z0-9_.-]",
lambda m: f"_{ord(m.group(0)):02x}",
name,
)
if not name or not name[0].isalnum():
name = f"orchid-{name}"
return name
def _source_tree(layout: Layout, r: Recipe, key: str | None) -> Path:
base = layout.source_tree(r.name, r.version)
if len(r.sources) == 1:
return base
if key is None:
raise ValueError(f"{r.name}: multi-source entries must be named")
return base / key
def _prepare_all_sources(layout: Layout, r: Recipe) -> None:
for key, src in r.sources.items():
tree = _source_tree(layout, r, key)
tree.parent.mkdir(parents=True, exist_ok=True)
fetch.prepare_source(layout, r.dir, src, tree)
def _build_path_env(host_deps_order: list[str], layout: Layout) -> str:
parts: list[str] = []
for h in reversed(host_deps_order):
parts += [
f"/tools/{h}/sbin",
f"/tools/{h}/bin",
]
parts += [
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin",
]
return ":".join(parts)
def _container_for(
rs: RecipeSet, layout: Layout, profile: dict, r: Recipe
) -> tuple[Container, list[str]]:
host_order = transitive_host_deps(rs, r)
name = _container_name("orchid", layout.build.name, r.kind, r.name)
mounts: list[Mount] = []
tmpfs: list[str] = ["/sysroot"]
# sources
if len(r.sources) == 1:
key = next(iter(r.sources.keys()))
mounts.append(Mount(_source_tree(layout, r, key), "/sources", readonly=True))
else:
for k in r.sources.keys():
mounts.append(
Mount(_source_tree(layout, r, k), f"/sources/{k}", readonly=True)
)
# build dir
wd = layout.build_workdir(r.name if r.kind == "target" else f"host-{r.name}")
wd.mkdir(parents=True, exist_ok=True)
mounts.append(Mount(wd, "/build", readonly=False))
# files dir
if r.files_dir is not None:
mounts.append(Mount(r.files_dir, "/files", readonly=True))
# tools
for h in host_order:
mounts.append(Mount(layout.host_pkg_dir(h), f"/tools/{h}", readonly=True))
# pkgs (produced packages persist on disk); sysroot lives in tmpfs
mounts.append(Mount(layout.pkgs_dir, "/pkgs", readonly=False))
env = {
"PATH": _build_path_env(host_order, layout),
"ORCHID_ARCH": profile["arch"],
"ORCHID_TRIPLE": profile["triple"],
"ORCHID_JOBS": str(os.cpu_count() or 1),
}
c = Container(
name=name,
image=profile["container_image"],
mounts=mounts,
tmpfs=tmpfs,
network=False,
extra_env=env,
)
return c, host_order
def _recipe_ref(r: Recipe) -> str:
return f"{r.key} ({r.version}-r{r.revision})"
def _mark_stamp(layout: Layout, r: Recipe, phase: str) -> None:
name = PHASE_STAMPS.get(phase)
if name is None:
return
d = stamp_dir(layout, r)
d.mkdir(parents=True, exist_ok=True)
(d / name).write_text(stamp_token(r))
def _clear_stamps(layout: Layout, r: Recipe) -> None:
d = stamp_dir(layout, r)
if d.is_dir():
shutil.rmtree(d)
def _wipe_workdir(layout: Layout, r: Recipe) -> None:
wd = layout.build_workdir(r.name if r.kind == "target" else f"host-{r.name}")
if wd.is_dir():
shutil.rmtree(wd)
def _run_phases(ctx: RecipeContext, r: Recipe, layout: Layout) -> None:
for phase in ("prepare", "configure", "build"):
fn = r.phases.get(phase)
if fn is None:
continue
if stamp_valid(layout, r, phase):
log.info_field(phase, f"{_recipe_ref(r)} (cached)")
continue
log.info_field(phase, _recipe_ref(r))
fn(ctx)
_mark_stamp(layout, r, phase)
def _do_install(ctx: RecipeContext, r: Recipe) -> None:
fn = r.phases.get("install")
if fn is None:
return
log.info_field("install", _recipe_ref(r))
ctx._dest_output = r.name
try:
fn(ctx)
finally:
ctx._dest_output = None
def _split_subpackages(c: Container, r: Recipe) -> None:
for sub in r.subpackages:
c.exec(["mkdir", "-p", f"/dest/{sub.name}"])
for pat in sub.files:
c.exec_shell(apk.split_subpackage_script(r.name, sub.name, pat))
def _package_target(c: Container, r: Recipe, arch: str) -> None:
apk.mkpkg_base(c, r, arch)
for sub in r.subpackages:
apk.mkpkg_subpackage(c, r, sub, arch)
apk.index(c)
def _finalize_host(c: Container, layout: Layout, r: Recipe) -> None:
import subprocess
out = layout.host_pkg_dir(r.name)
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
p1 = subprocess.Popen(
[
"podman",
"exec",
c.name,
"sh",
"-c",
f"cd /dest/{r.name}/tools/{r.name} && tar -cf - .",
],
stdout=subprocess.PIPE,
)
p2 = subprocess.Popen(["tar", "-xf", "-", "-C", str(out)], stdin=p1.stdout)
if p1.stdout is not None:
p1.stdout.close()
rc = p2.wait()
p1.wait()
if rc != 0 or p1.returncode != 0:
raise RuntimeError(f"host {r.name}: copy-out failed")
layout.host_pkg_marker(r.name, r.version, r.revision).write_text("ok\n")
def _sysroot_sync(c: Container, r: Recipe) -> None:
direct_deps = list(r.deps)
if not direct_deps:
return
initdb = not apk.sysroot_initialized(c)
apk.sysroot_install(c, direct_deps, initdb=initdb)
def build_one(
rs: RecipeSet, layout: Layout, profile: dict, r: Recipe, *, forced: bool = False
) -> None:
log.info_field("recipe", _recipe_ref(r))
if forced:
_clear_stamps(layout, r)
_wipe_workdir(layout, r)
_prepare_all_sources(layout, r)
c, _host_order = _container_for(rs, layout, profile, r)
c.start()
try:
# Ensure base output dest dir exists.
c.exec(["mkdir", "-p", f"/dest/{r.name}"])
_sysroot_sync(c, r)
ctx = RecipeContext(
recipe=r, profile=profile, container=c, jobs=os.cpu_count() or 1
)
_run_phases(ctx, r, layout)
_do_install(ctx, r)
if r.kind == "target":
log.info_field("package", _recipe_ref(r))
_split_subpackages(c, r)
_package_target(c, r, profile["arch"])
else:
log.info_field("finalize", _recipe_ref(r))
_finalize_host(c, layout, r)
finally:
c.stop()
log.ok_field("done", _recipe_ref(r))
def execute(plan: Plan, rs: RecipeSet, layout: Layout, profile: dict) -> None:
if not plan.order:
log.info_field("plan", "nothing to do")
return
for k in plan.order:
r = plan.recipes[k]
build_one(rs, layout, profile, r, forced=k in plan.forced)
def install_to(
layout: Layout,
profile: dict,
dest: Path,
pkgs: list[str],
*,
initdb: bool = True,
) -> None:
if not pkgs:
log.info_field("install", "nothing to install")
return
c = Container(
name=_container_name("orchid", layout.build.name, "install"),
image=profile["container_image"],
mounts=[
Mount(layout.pkgs_dir, "/pkgs", readonly=True),
Mount(dest, "/sysroot", readonly=False),
],
network=False,
)
c.start()
try:
log.info_field("install", f"{dest}: {', '.join(pkgs)}")
apk.sysroot_install(c, pkgs, initdb=initdb)
finally:
c.stop()
+240
View File
@@ -0,0 +1,240 @@
import argparse
import os
import sys
from pathlib import Path
from src import (
builder,
container,
log,
plan,
profile as profile_mod,
recipe as recipe_mod,
)
from src.layout import Layout, find_repo_root
def _resolve_build_dir(p: str | None) -> Path:
if p:
return Path(p).resolve()
env = os.environ.get("ORCHID_BUILD")
if env:
return Path(env).resolve()
cwd = Path.cwd()
if (cwd / "profile").is_symlink() or (cwd / "profile").exists():
return cwd
raise SystemExit("error: -C <build-dir> required (or run inside a build dir)")
def _layout(build_dir: Path) -> Layout:
repo = find_repo_root(build_dir)
return Layout(repo=repo, build=build_dir)
def _load(layout: Layout):
prof = profile_mod.load_profile(layout)
rs = recipe_mod.load_recipes(layout, prof)
return prof, rs
def cmd_init(args) -> int:
target = Path(args.build_dir).resolve()
repo = find_repo_root(Path.cwd())
profile_mod.init_build_dir(target, repo, args.profile)
log.ok(f"initialized {target} (profile: {args.profile})")
return 0
def cmd_image(args) -> int:
layout = _layout(_resolve_build_dir(args.build_dir))
layout.ensure()
prof = profile_mod.load_profile(layout)
container.ensure_image(
layout.dockerfile, prof["container_image"], layout.image_hash_file
)
return 0
def _recipe_version(r) -> str:
return f"{r.version}-r{r.revision}"
def _print_plan(p: plan.Plan) -> None:
out = sys.stdout
if not p.order:
print(
f"{log.tag('info', stream=out)} "
f"{log.field('plan', 'nothing to do', stream=out)}"
)
return
count = len(p.order)
suffix = "" if count == 1 else "s"
print(
f"{log.tag('info', stream=out)} "
f"{log.field('plan', f'{count} recipe{suffix}', stream=out)}"
)
rows: list[tuple[str, str, str, str]] = []
for i, k in enumerate(p.order, start=1):
r = p.recipes[k]
rows.append((str(i), k, _recipe_version(r), ", ".join(p.stages.get(k, ()))))
widths = [
max(len(row[0]) for row in rows),
max(len("recipe"), *(len(row[1]) for row in rows)),
max(len("version"), *(len(row[2]) for row in rows)),
]
header = (
f" {'#':>{widths[0]}} "
f"{'recipe':<{widths[1]}} "
f"{'version':<{widths[2]}} "
"stages"
)
print(log.bold(header, stream=out))
for num, name, version, stages in rows:
print(
f" {num:>{widths[0]}} "
f"{name:<{widths[1]}} "
f"{version:<{widths[2]}} "
f"{stages}"
)
def cmd_plan(args) -> int:
layout = _layout(_resolve_build_dir(args.build_dir))
layout.ensure()
prof, rs = _load(layout)
p = plan.build_plan(rs, layout, args.recipes or None, rebuild=args.rebuild)
_print_plan(p)
return 0
def cmd_build(args) -> int:
layout = _layout(_resolve_build_dir(args.build_dir))
layout.ensure()
prof, rs = _load(layout)
container.ensure_image(
layout.dockerfile, prof["container_image"], layout.image_hash_file
)
p = plan.build_plan(rs, layout, args.recipes or None, rebuild=args.rebuild)
if args.dry_run:
return cmd_plan(args)
builder.execute(p, rs, layout, prof)
return 0
def cmd_install(args) -> int:
layout = _layout(_resolve_build_dir(args.build_dir))
layout.ensure()
prof, rs = _load(layout)
container.ensure_image(
layout.dockerfile, prof["container_image"], layout.image_hash_file
)
dest = Path(args.dest).resolve()
dest.mkdir(parents=True, exist_ok=True)
if args.recipes:
pkgs: list[str] = []
for k in args.recipes:
r = rs.get(k)
if r.kind != "target":
raise SystemExit(f"error: cannot install host recipe {k!r}")
pkgs.extend(r.outputs)
else:
pkgs = [r.name for r in rs.target.values() if r.enabled]
builder.install_to(layout, prof, dest, pkgs, initdb=args.initdb)
log.ok(f"installed {len(pkgs)} package(s) to {dest}")
return 0
def cmd_fetch(args) -> int:
layout = _layout(_resolve_build_dir(args.build_dir))
layout.ensure()
prof, rs = _load(layout)
from . import fetch as fetch_mod
targets = args.recipes or [r.key for r in rs.all() if r.enabled]
for k in targets:
r = rs.get(k)
for key, src in r.sources.items():
fetch_mod.fetch(layout, src)
return 0
def _common(p: argparse.ArgumentParser, with_recipes: bool = True) -> None:
p.add_argument(
"-C", "--build-dir", help="build directory (defaults to $ORCHID_BUILD or cwd)"
)
if with_recipes:
p.add_argument(
"recipes", nargs="*", help="recipe keys (target name or host:<name>)"
)
def make_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="orchid", description="Orchid distribution builder"
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_init = sub.add_parser("init", help="create a build directory")
p_init.add_argument("build_dir")
p_init.add_argument("--profile", required=True)
p_init.set_defaults(func=cmd_init)
p_img = sub.add_parser("image", help="build/refresh the container image")
_common(p_img, with_recipes=False)
p_img.set_defaults(func=cmd_image)
p_plan = sub.add_parser("plan", help="print build plan")
_common(p_plan)
p_plan.add_argument("--rebuild", action="store_true")
p_plan.set_defaults(func=cmd_plan)
p_build = sub.add_parser("build", help="build recipes")
_common(p_build)
p_build.add_argument("--rebuild", action="store_true")
p_build.add_argument("-n", "--dry-run", action="store_true")
p_build.set_defaults(func=cmd_build)
p_inst = sub.add_parser(
"install", help="install built packages into a sysroot directory"
)
p_inst.add_argument(
"-C", "--build-dir", help="build directory (defaults to $ORCHID_BUILD or cwd)"
)
p_inst.add_argument("dest", help="destination sysroot directory")
p_inst.add_argument(
"recipes",
nargs="*",
help="target recipes to install (defaults to all enabled targets)",
)
p_inst.add_argument(
"--no-initdb",
dest="initdb",
action="store_false",
help="do not initialize the apk database (use for incremental installs)",
)
p_inst.set_defaults(func=cmd_install, initdb=True)
p_fetch = sub.add_parser("fetch", help="fetch sources only")
_common(p_fetch)
p_fetch.set_defaults(func=cmd_fetch)
return parser
def main(argv: list[str] | None = None) -> int:
parser = make_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except (SystemExit, KeyboardInterrupt):
raise
except Exception as e:
log.error(f"{type(e).__name__}: {e}")
if os.environ.get("ORCHID_DEBUG"):
raise
return 1
-119
View File
@@ -1,119 +0,0 @@
use crate::build::Builder;
use crate::config::Config;
use crate::graph::PackageGraph;
use crate::recipe::RecipeSet;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "distro", version)]
pub struct Cli {
#[arg(long, default_value = ".")]
pub repo: PathBuf,
#[arg(long)]
pub skip_checks: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Build {
package: Option<String>,
},
Rebuild {
package: Option<String>,
},
Fetch {
package: String,
},
Graph {
package: Option<String>,
},
List,
Info {
package: String,
},
Clean,
Shell {
package: String,
},
RepoIndex,
InitKey,
Rootfs {
root: PathBuf,
packages: Vec<String>,
},
Update {
#[arg(long)]
bump: bool,
packages: Vec<String>,
},
}
pub fn run(cli: Cli) -> Result<()> {
let repo = cli.repo.canonicalize().unwrap_or(cli.repo);
match &cli.command {
Command::Clean => {
let build = repo.join("build");
if build.exists() {
fs::remove_dir_all(&build)
.with_context(|| format!("failed to remove {}", build.display()))?;
}
Ok(())
}
Command::InitKey => {
let config = Config::load(&repo.join("config.star"))?;
Builder::new(repo, config, cli.skip_checks).init_key()
}
_ => {
let config = Config::load(&repo.join("config.star"))?;
let recipes = RecipeSet::load(&repo, &config)?;
if let Command::Update { bump, packages } = &cli.command {
return crate::update::run(&recipes, packages, *bump);
}
let graph = PackageGraph::new(&recipes)?;
let builder = Builder::new(repo, config, cli.skip_checks);
match cli.command {
Command::Build { package } => {
builder.build(&recipes, &graph, package.as_deref(), false)
}
Command::Rebuild { package } => {
builder.build(&recipes, &graph, package.as_deref(), true)
}
Command::Fetch { package } => builder.fetch(&recipes, &package),
Command::Graph { package } => {
for line in graph.render(package.as_deref())? {
println!("{line}");
}
Ok(())
}
Command::List => {
for output in graph.outputs() {
println!("{output}");
}
Ok(())
}
Command::Info { package } => {
let output = graph.output(&package)?;
println!("{}", serde_json::to_string_pretty(output)?);
Ok(())
}
Command::Shell { package } => builder.shell(&recipes, &graph, &package),
Command::RepoIndex => builder.repo_index(),
Command::Rootfs { root, packages } => {
if packages.is_empty() {
bail!("rootfs requires at least one package");
}
builder.rootfs(&root, &packages)
}
Command::Update { .. } => unreachable!(),
Command::Clean | Command::InitKey => unreachable!(),
}
}
}
}
-37
View File
@@ -1,37 +0,0 @@
use crate::starlark::{eval_file, get_json_map, get_string, get_string_default};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
pub target_arch: String,
pub options: BTreeMap<String, JsonValue>,
pub container_runtime: String,
pub container_image: String,
pub container_dockerfile: PathBuf,
pub signing_key: PathBuf,
pub signing_pubkey: PathBuf,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let module = eval_file(path, None, None)
.with_context(|| format!("failed to evaluate {}", path.display()))?;
Ok(Self {
target_arch: get_string(&module, "target_arch")?,
options: get_json_map(&module, "options")?,
container_runtime: get_string_default(&module, "container_runtime", "podman")?,
container_image: get_string(&module, "container_image")?,
container_dockerfile: PathBuf::from(get_string_default(
&module,
"container_dockerfile",
"Dockerfile",
)?),
signing_key: PathBuf::from(get_string(&module, "signing_key")?),
signing_pubkey: PathBuf::from(get_string(&module, "signing_pubkey")?),
})
}
}
+200
View File
@@ -0,0 +1,200 @@
import atexit
import hashlib
import os
import signal
import subprocess
import threading
from dataclasses import dataclass, field
from pathlib import Path
from src import log
@dataclass
class Mount:
src: Path
dst: str
readonly: bool = False
def as_arg(self) -> str:
flag = ":ro" if self.readonly else ""
return f"{self.src}:{self.dst}{flag}"
_active: set["Container"] = set()
_lock = threading.Lock()
_handlers_installed = False
def _install_handlers() -> None:
global _handlers_installed
if _handlers_installed:
return
_handlers_installed = True
def cleanup(*_):
_shutdown_all()
atexit.register(_shutdown_all)
for s in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(s, lambda *_: (_shutdown_all(), os._exit(130)))
except (ValueError, OSError):
pass
def _shutdown_all() -> None:
with _lock:
cs = list(_active)
_active.clear()
for c in cs:
try:
subprocess.run(
["podman", "rm", "-f", c.name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
except Exception:
pass
@dataclass
class Container:
name: str
image: str
mounts: list[Mount] = field(default_factory=list)
tmpfs: list[str] = field(default_factory=list)
network: bool = False
extra_env: dict[str, str] = field(default_factory=dict)
started: bool = False
def __hash__(self) -> int:
return id(self)
def __eq__(self, other) -> bool:
return self is other
def start(self) -> None:
if self.started:
return
_install_handlers()
# Ensure no stale container.
subprocess.run(
["podman", "rm", "-f", self.name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
argv = [
"podman",
"run",
"-d",
"--rm",
"--name",
self.name,
]
for t in ("/tmp", "/dest", "/var/cache", *self.tmpfs):
argv += ["--tmpfs", t]
argv += ["--network=none"] if not self.network else []
for m in self.mounts:
m.src.mkdir(parents=True, exist_ok=True) if not m.src.exists() else None
argv += ["-v", m.as_arg()]
argv += [self.image, "sleep", "infinity"]
log.debug(f"container start: {' '.join(argv)}")
r = subprocess.run(argv, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"podman run failed: {r.stderr.strip()}")
with _lock:
_active.add(self)
self.started = True
def stop(self) -> None:
if not self.started:
return
subprocess.run(
["podman", "rm", "-f", self.name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
with _lock:
_active.discard(self)
self.started = False
def exec(
self,
argv: list[str],
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
check: bool = True,
user: str | None = None,
) -> int:
if not self.started:
raise RuntimeError("container not started")
cmd = ["podman", "exec"]
if cwd:
cmd += ["-w", cwd]
if user:
cmd += ["-u", user]
merged: dict[str, str] = {}
merged.update(self.extra_env)
if env:
merged.update(env)
for k, v in merged.items():
cmd += ["-e", f"{k}={v}"]
cmd += [self.name, *argv]
log.debug(f"exec: {' '.join(argv)}")
rc = subprocess.call(cmd)
if check and rc != 0:
raise RuntimeError(f"command failed (exit {rc}): {' '.join(argv)}")
return rc
def exec_shell(
self,
script: str,
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
check: bool = True,
) -> int:
return self.exec(["sh", "-ec", script], env=env, cwd=cwd, check=check)
def cp_out(self, src_in: str, dst_host: Path) -> None:
dst_host.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
["podman", "cp", f"{self.name}:{src_in}", str(dst_host)],
capture_output=True,
text=True,
)
if r.returncode != 0:
raise RuntimeError(f"podman cp failed: {r.stderr.strip()}")
def hash_dockerfile(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest()
def image_exists(tag: str) -> bool:
r = subprocess.run(["podman", "image", "exists", tag])
return r.returncode == 0
def build_image(dockerfile: Path, tag: str) -> None:
log.info(f"building container image {tag}")
r = subprocess.run(
["podman", "build", "-t", tag, "-f", str(dockerfile), str(dockerfile.parent)]
)
if r.returncode != 0:
raise RuntimeError("podman build failed")
def ensure_image(dockerfile: Path, tag: str, hash_file: Path) -> None:
cur = hash_dockerfile(dockerfile) + "\n" + tag
if hash_file.exists() and hash_file.read_text() == cur and image_exists(tag):
log.debug(f"image {tag} up-to-date")
return
build_image(dockerfile, tag)
hash_file.parent.mkdir(parents=True, exist_ok=True)
hash_file.write_text(cur)
+104
View File
@@ -0,0 +1,104 @@
import os
from dataclasses import dataclass, field
from pathlib import PurePosixPath
from typing import Any
from src.container import Container
from src.recipe import Recipe
@dataclass
class RecipeContext:
"""The `self` value passed to recipe phase functions."""
recipe: Recipe
profile: dict
container: Container
jobs: int
_dest_output: str | None = None
env: dict[str, str] = field(default_factory=dict)
@property
def name(self) -> str:
return self.recipe.name
@property
def version(self) -> str:
return self.recipe.version
@property
def revision(self) -> int:
return self.recipe.revision
@property
def source_dir(self) -> PurePosixPath:
srcs = self.recipe.sources
if len(srcs) == 1:
return PurePosixPath("/sources")
raise RuntimeError(f"{self.name}: multiple sources; use self.sources")
@property
def sources(self) -> dict[str, PurePosixPath]:
srcs = self.recipe.sources
if len(srcs) == 1:
raise RuntimeError(f"{self.name}: only one source; use self.source_dir")
out: dict[str, PurePosixPath] = {}
for k in srcs.keys():
if k is None:
raise RuntimeError(f"{self.name}: multiple sources must be named")
out[k] = PurePosixPath("/sources") / k
return out
@property
def build_dir(self) -> PurePosixPath:
return PurePosixPath("/build")
@property
def sysroot(self) -> PurePosixPath:
return PurePosixPath("/sysroot")
@property
def dest_dir(self) -> PurePosixPath:
if self._dest_output is None:
raise RuntimeError("dest_dir only available during install/package phases")
return PurePosixPath("/dest") / self._dest_output
@property
def files(self) -> PurePosixPath:
if self.recipe.files_dir is None:
raise RuntimeError(f"{self.name}: no files/ dir")
return PurePosixPath("/files")
@property
def prefix(self) -> str:
return f"/tools/{self.name}" if self.recipe.kind == "host" else "/usr"
@property
def triple(self) -> str:
return self.profile["triple"]
@property
def arch(self) -> str:
return self.profile["arch"]
def run(
self,
*argv,
env: dict[str, str] | None = None,
cwd: str | os.PathLike | None = None,
) -> None:
flat: list[str] = []
for a in argv:
if isinstance(a, (PurePosixPath, os.PathLike)):
flat.append(str(a))
elif isinstance(a, bool):
raise TypeError("bool not allowed in run(); use str")
elif isinstance(a, (str, int)):
flat.append(str(a))
else:
raise TypeError(f"unsupported arg type in run(): {type(a).__name__}")
merged = dict(self.env)
if env:
merged.update(env)
cwd_s = str(cwd) if cwd is not None else "/build"
self.container.exec(flat, env=merged, cwd=cwd_s)
+179
View File
@@ -0,0 +1,179 @@
import contextlib
import fcntl
import hashlib
import os
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
from pathlib import Path
from src import log
from src.layout import Layout
from src.source import Git, Tarball
@contextlib.contextmanager
def cache_lock(layout: Layout):
layout.cache_dir.mkdir(parents=True, exist_ok=True)
f = open(layout.cache_lock, "w")
try:
fcntl.flock(f, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(f, fcntl.LOCK_UN)
f.close()
def fetch_tarball(layout: Layout, src: Tarball) -> Path:
dest = layout.tarball_cache / src.sha256
if dest.is_file():
return dest
if src.sha256 == "?":
log.warn(f"fetching {src.url} (sha256 unknown)")
else:
log.info(f"fetching {src.url}")
layout.tarball_cache.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_path = tempfile.mkstemp(dir=layout.tarball_cache, prefix=".tmp-")
tmp = Path(tmp_path)
h = hashlib.sha256()
try:
with os.fdopen(tmp_fd, "wb") as out, urllib.request.urlopen(src.url) as resp:
while True:
chunk = resp.read(1 << 20)
if not chunk:
break
out.write(chunk)
h.update(chunk)
got = h.hexdigest()
if src.sha256 == "?":
log.warn(f"computed sha256 = {got} (paste into recipe)")
final = layout.tarball_cache / got
os.replace(tmp, final)
return final
if got != src.sha256:
raise RuntimeError(
f"sha256 mismatch for {src.url}: expected {src.sha256}, got {got}"
)
os.replace(tmp, dest)
return dest
except BaseException:
with contextlib.suppress(FileNotFoundError):
tmp.unlink()
raise
def fetch_git(layout: Layout, src: Git) -> Path:
dest = layout.git_cache / src.commit
if dest.is_dir():
return dest
log.info(f"git clone {src.url} (commit {src.commit[:12]})")
layout.git_cache.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=layout.git_cache, prefix=".tmp-") as td:
bare = Path(td) / "bare"
subprocess.run(
["git", "clone", "--bare", "--quiet", src.url, str(bare)], check=True
)
r = subprocess.run(["git", "-C", str(bare), "cat-file", "-e", src.commit])
if r.returncode != 0:
raise RuntimeError(f"commit {src.commit} not found in {src.url}")
os.replace(bare, dest)
return dest
def fetch(layout: Layout, src) -> Path:
with cache_lock(layout):
if isinstance(src, Tarball):
return fetch_tarball(layout, src)
if isinstance(src, Git):
return fetch_git(layout, src)
raise TypeError(f"unknown source type {type(src).__name__}")
def _extracted_marker(tree: Path) -> Path:
return tree.with_name(tree.name + ".extracted")
def _patched_marker(tree: Path) -> Path:
return tree.with_name(tree.name + ".patched")
def _wipe(tree: Path) -> None:
if tree.exists():
shutil.rmtree(tree)
for m in (_extracted_marker(tree), _patched_marker(tree)):
with contextlib.suppress(FileNotFoundError):
m.unlink()
def extract_tarball(cache_path: Path, tree: Path, strip: int) -> None:
tree.mkdir(parents=True, exist_ok=True)
# Use tar binary to support all formats including zstd
cmd = ["tar", "-xf", str(cache_path), "-C", str(tree)]
if strip:
cmd += [f"--strip-components={strip}"]
subprocess.run(cmd, check=True)
def extract_git(cache_path: Path, tree: Path, commit: str) -> None:
subprocess.run(["git", "clone", "--quiet", str(cache_path), str(tree)], check=True)
subprocess.run(
[
"git",
"-C",
str(tree),
"-c",
"advice.detachedHead=false",
"checkout",
"--quiet",
commit,
],
check=True,
)
def extract(cache_path: Path, src, tree: Path) -> None:
_wipe(tree)
if isinstance(src, Tarball):
extract_tarball(cache_path, tree, src.strip_components)
elif isinstance(src, Git):
extract_git(cache_path, tree, src.commit)
else:
raise TypeError(f"unknown source type {type(src).__name__}")
_extracted_marker(tree).write_text("ok\n")
def apply_patches(tree: Path, recipe_dir: Path, patches: tuple[str, ...]) -> None:
if not patches:
_patched_marker(tree).write_text("\n")
return
pdir = recipe_dir / "patches"
for name in patches:
p = pdir / name
if not p.is_file():
raise FileNotFoundError(f"patch not found: {p}")
log.info(f" patch {name}")
with open(p, "rb") as fh:
r = subprocess.run(
["patch", "-p1", "--no-backup-if-mismatch", "--quiet"],
cwd=tree,
stdin=fh,
)
if r.returncode != 0:
raise RuntimeError(f"patch {name} failed in {tree}")
_patched_marker(tree).write_text("\n".join(patches) + "\n")
def prepare_source(layout: Layout, recipe_dir: Path, src, tree: Path) -> None:
"""Fetch + extract + patch into `tree`. Idempotent via marker files."""
cache_path = fetch(layout, src)
expected_patches = "\n".join(src.patches) + "\n" if src.patches else "\n"
if (
_patched_marker(tree).is_file()
and _patched_marker(tree).read_text() == expected_patches
):
return
log.info(f"extract {tree.name}")
extract(cache_path, src, tree)
apply_patches(tree, recipe_dir, src.patches)
-135
View File
@@ -1,135 +0,0 @@
use crate::recipe::{OutputPackage, PackageKind, RecipeSet, unresolved_deps};
use anyhow::{Result, anyhow, bail};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone)]
pub struct PackageGraph {
outputs: BTreeMap<String, OutputPackage>,
target_edges: BTreeMap<String, Vec<String>>,
host_edges: BTreeMap<String, Vec<String>>,
}
impl PackageGraph {
pub fn new(recipes: &RecipeSet) -> Result<Self> {
let missing = unresolved_deps(recipes);
if !missing.is_empty() {
bail!("unresolved local dependencies:\n{}", missing.join("\n"));
}
let mut target_edges = BTreeMap::new();
let mut host_edges = BTreeMap::new();
for recipe in recipes.recipes.values() {
// host_deps always resolve into the host namespace.
let host_dep_keys: Vec<String> = recipe
.host_deps
.iter()
.map(|d| PackageKind::Host.key(d))
.collect();
for output in &recipe.outputs {
let key = output.key();
let mut edges = output.all_target_deps();
if output.name == recipe.name {
edges.extend(recipe.build_deps.iter().cloned());
edges.extend(recipe.deps.iter().cloned());
}
match output.kind {
PackageKind::Host => {
host_edges.insert(key, host_dep_keys.clone());
}
PackageKind::Target => {
let mut deps = host_dep_keys.clone();
deps.extend(edges);
target_edges.insert(key, deps);
}
}
}
}
Ok(Self {
outputs: recipes.outputs.clone(),
target_edges,
host_edges,
})
}
pub fn output(&self, package: &str) -> Result<&OutputPackage> {
self.outputs
.get(package)
.ok_or_else(|| anyhow!("unknown package `{package}`"))
}
pub fn outputs(&self) -> impl Iterator<Item = &str> {
self.outputs.keys().map(String::as_str)
}
pub fn build_order(&self, package: &str) -> Result<Vec<String>> {
self.output(package)?;
let mut visiting = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut order = Vec::new();
self.visit(package, &mut visiting, &mut visited, &mut order)?;
Ok(order)
}
/// Topologically-ordered list of every output in the graph (host + target).
pub fn build_order_all(&self) -> Result<Vec<String>> {
let mut visiting = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut order = Vec::new();
for package in self.outputs.keys() {
self.visit(package, &mut visiting, &mut visited, &mut order)?;
}
Ok(order)
}
fn visit(
&self,
package: &str,
visiting: &mut BTreeSet<String>,
visited: &mut BTreeSet<String>,
order: &mut Vec<String>,
) -> Result<()> {
if visited.contains(package) {
return Ok(());
}
if !visiting.insert(package.to_owned()) {
bail!("dependency cycle involving `{package}`");
}
let deps = self
.target_edges
.get(package)
.or_else(|| self.host_edges.get(package))
.cloned()
.unwrap_or_default();
for dep in deps {
self.visit(&dep, visiting, visited, order)?;
}
visiting.remove(package);
visited.insert(package.to_owned());
order.push(package.to_owned());
Ok(())
}
pub fn render(&self, package: Option<&str>) -> Result<Vec<String>> {
match package {
Some(package) => {
let order = self.build_order(package)?;
Ok(order
.into_iter()
.map(|pkg| format!("{pkg}: {:?}", self.edges(&pkg)))
.collect())
}
None => Ok(self
.outputs
.keys()
.map(|pkg| format!("{pkg}: {:?}", self.edges(pkg)))
.collect()),
}
}
fn edges(&self, package: &str) -> Vec<String> {
self.target_edges
.get(package)
.or_else(|| self.host_edges.get(package))
.cloned()
.unwrap_or_default()
}
}
+107
View File
@@ -0,0 +1,107 @@
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Layout:
"""All filesystem paths used by the builder."""
repo: Path
build: Path
@property
def recipes_dir(self) -> Path:
return self.repo / "recipes"
@property
def host_recipes_dir(self) -> Path:
return self.repo / "host-recipes"
@property
def profiles_dir(self) -> Path:
return self.repo / "profiles"
@property
def dockerfile(self) -> Path:
return self.repo / "Dockerfile"
@property
def cache_dir(self) -> Path:
return self.repo / "cache"
@property
def tarball_cache(self) -> Path:
return self.cache_dir / "tarballs"
@property
def git_cache(self) -> Path:
return self.cache_dir / "git"
@property
def cache_lock(self) -> Path:
return self.cache_dir / ".lock"
@property
def sources_dir(self) -> Path:
return self.repo / "sources"
def source_tree(self, name: str, version: str) -> Path:
return self.sources_dir / f"{name}-{version}"
@property
def profile_link(self) -> Path:
return self.build / "profile"
@property
def build_workdirs(self) -> Path:
return self.build / "builds"
def build_workdir(self, name: str) -> Path:
return self.build_workdirs / name
@property
def host_pkgs_dir(self) -> Path:
return self.build / "host-pkgs"
def host_pkg_dir(self, name: str) -> Path:
return self.host_pkgs_dir / name
def host_pkg_marker(self, name: str, version: str, revision: int) -> Path:
return self.host_pkg_dir(name) / f".built-{version}-r{revision}"
@property
def pkgs_dir(self) -> Path:
return self.build / "pkgs"
def apk_path(self, output: str, version: str, revision: int) -> Path:
return self.pkgs_dir / f"{output}-{version}-r{revision}.apk"
@property
def apkindex(self) -> Path:
return self.pkgs_dir / "Packages.adb"
@property
def image_hash_file(self) -> Path:
return self.build / ".image-hash"
def ensure(self) -> None:
for p in (
self.cache_dir,
self.tarball_cache,
self.git_cache,
self.sources_dir,
self.build_workdirs,
self.host_pkgs_dir,
self.pkgs_dir,
):
p.mkdir(parents=True, exist_ok=True)
def find_repo_root(start: Path) -> Path:
cur = start.resolve()
for p in (cur, *cur.parents):
if (p / "Dockerfile").is_file() and (p / "profiles").is_dir():
return p
raise FileNotFoundError(
f"Could not find Orchid repo root (looking for Dockerfile + profiles/) from {start}"
)
+45
View File
@@ -0,0 +1,45 @@
def autotools_configure(self, extra_args=(), extra_env=None):
p = self.profile
env = {
"CFLAGS": p.get("cflags", ""),
"CXXFLAGS": p.get("cxxflags", ""),
"LDFLAGS": p.get("ldflags", ""),
}
if extra_env:
env.update(extra_env)
args = [
self.source_dir / "configure",
f"--host={p['triple']}",
f"--with-sysroot={self.sysroot}",
f"--prefix={p.get('prefix', '/usr')}",
f"--sysconfdir={p.get('sysconfdir', '/etc')}",
f"--localstatedir={p.get('localstatedir', '/var')}",
f"--bindir={p.get('bindir', '/usr/bin')}",
f"--sbindir={p.get('sbindir', '/usr/bin')}",
f"--libdir={p.get('libdir', '/usr/lib')}",
"--disable-static",
"--enable-shared",
*extra_args,
]
self.run(*args, env=env)
def autotools_build(self, extra_args=()):
self.run("make", f"-j{self.jobs}", *extra_args)
def autotools_install(self, extra_args=()):
self.run("make", "install", *extra_args, env={"DESTDIR": str(self.dest_dir)})
def autotools(*, configure_args=(), configure_env=None, build_args=(), install_args=()):
def _configure(self):
autotools_configure(self, configure_args, configure_env)
def _build(self):
autotools_build(self, build_args)
def _install(self):
autotools_install(self, (*install_args, f"DESTDIR={self.dest_dir}"))
return _configure, _build, _install
+73
View File
@@ -0,0 +1,73 @@
def cmake_configure(self, extra_args=(), extra_env=None, *, host=False):
p = self.profile
if host:
env = {
"CFLAGS": p.get("host_cflags", ""),
"CXXFLAGS": p.get("host_cxxflags", ""),
"LDFLAGS": p.get("host_ldflags", ""),
}
toolchain = []
else:
env = {
"CFLAGS": p.get("cflags", ""),
"CXXFLAGS": p.get("cxxflags", ""),
"LDFLAGS": p.get("ldflags", ""),
}
toolchain = [
"-DCMAKE_SYSTEM_NAME=Linux",
f"-DCMAKE_SYSTEM_PROCESSOR={p['arch']}",
f"-DCMAKE_SYSROOT={self.sysroot}",
f"-DCMAKE_C_COMPILER={p['triple']}-gcc",
f"-DCMAKE_CXX_COMPILER={p['triple']}-g++",
"-DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER",
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY",
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY",
"-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY",
]
if extra_env:
env.update(extra_env)
self.run(
"cmake",
"-S",
self.source_dir,
"-B",
self.build_dir,
"-G",
"Ninja",
"-DCMAKE_BUILD_TYPE=Release",
f"-DCMAKE_INSTALL_PREFIX={p.get('prefix', '/usr')}",
f"-DCMAKE_INSTALL_SYSCONFDIR={p.get('sysconfdir', '/etc')}",
f"-DCMAKE_INSTALL_LOCALSTATEDIR={p.get('localstatedir', '/var')}",
*toolchain,
*extra_args,
env=env,
)
def cmake_build(self, extra_args=()):
self.run("cmake", "--build", self.build_dir, "-j", self.jobs, *extra_args)
def cmake_install(self, extra_args=()):
self.run(
"cmake",
"--install",
self.build_dir,
*extra_args,
env={"DESTDIR": str(self.dest_dir)},
)
def cmake(
*, configure_args=(), configure_env=None, build_args=(), install_args=(), host=False
):
def _configure(self):
cmake_configure(self, configure_args, configure_env, host=host)
def _build(self):
cmake_build(self, build_args)
def _install(self):
cmake_install(self, install_args)
return _configure, _build, _install
+9
View File
@@ -0,0 +1,9 @@
def make_build(self, extra_args=(), *, env=None):
self.run("make", f"-j{self.jobs}", *extra_args, env=env)
def make_install(self, extra_args=(), *, env=None):
merged = {"DESTDIR": str(self.dest_dir)}
if env:
merged.update(env)
self.run("make", "install", *extra_args, env=merged)
+85
View File
@@ -0,0 +1,85 @@
def meson_cross_file(self):
cross = self.build_dir / "meson-cross.ini"
self.write_text(
cross,
f"""\
[binaries]
c = '{self.triple}-gcc'
cpp = '{self.triple}-g++'
ar = '{self.triple}-gcc-ar'
nm = '{self.triple}-nm'
objcopy = '{self.triple}-objcopy'
ranlib = '{self.triple}-ranlib'
strip = '{self.triple}-strip'
pkg-config = '{self.triple}-pkg-config'
[host_machine]
system = 'linux'
cpu_family = '{self.arch}'
cpu = '{self.arch}'
endian = 'little'
""",
)
return cross
def meson_configure(
self, extra_args=(), extra_env=None, *, source_dir=None, flags=True, host=False
):
p = self.options
env = {}
if flags:
env.update(
{
"CFLAGS": p.get("cflags", ""),
"CXXFLAGS": p.get("cxxflags", ""),
"LDFLAGS": p.get("ldflags", ""),
}
)
if extra_env:
env.update(extra_env)
cross_args = [] if host else ["--cross-file", meson_cross_file(self)]
self.run(
"meson",
"setup",
source_dir or self.source_dir,
*cross_args,
f"--prefix={p.get('prefix', '/usr')}",
f"--sysconfdir={p.get('sysconfdir', '/etc')}",
f"--localstatedir={p.get('localstatedir', '/var')}",
"--libdir=lib",
"--sbindir=bin",
"--bindir=bin",
"--datadir=share",
"--buildtype=release",
"-Ddefault_library=shared",
*extra_args,
env=env,
)
def meson_build(self, extra_args=()):
self.run("meson", "compile", f"-j{self.jobs}", *extra_args)
def meson_install(self, extra_args=()):
self.run(
"meson",
"install",
"--no-rebuild",
*extra_args,
env={"DESTDIR": str(self.dest_dir)},
)
def meson(*, configure_args=(), configure_env=None, build_args=(), install_args=()):
def _configure(self):
meson_configure(self, configure_args, configure_env)
def _build(self):
meson_build(self, build_args)
def _install(self):
meson_install(self, install_args)
return _configure, _build, _install
+70
View File
@@ -0,0 +1,70 @@
import os
import sys
from typing import TextIO
_COLORS = {
"debug": "\033[2m",
"info": "\033[34m",
"warn": "\033[33m",
"error": "\033[31m",
"ok": "\033[32m",
}
_RESET = "\033[0m"
_BOLD = "\033[1m"
_TAGS = {"debug": "..", "info": "->", "warn": "!!", "error": "xx", "ok": "**"}
def _enabled(stream: TextIO | None = None) -> bool:
stream = stream or sys.stderr
return stream.isatty() and os.environ.get("NO_COLOR") is None
def tag(level: str, *, stream: TextIO | None = None) -> str:
tag_text = _TAGS[level]
if _enabled(stream):
return f"{_COLORS[level]}{tag_text}{_RESET}"
return tag_text
def bold(text: str, *, stream: TextIO | None = None) -> str:
if _enabled(stream):
return f"{_BOLD}{text}{_RESET}"
return text
def _emit(level: str, msg: str) -> None:
sys.stderr.write(f"{tag(level)} {msg}\n")
sys.stderr.flush()
def field(label: str, value: str, *, stream: TextIO | None = None) -> str:
return f"{bold(label, stream=stream)}: {value}"
def debug(msg: str) -> None:
if os.environ.get("ORCHID_DEBUG"):
_emit("debug", msg)
def info(msg: str) -> None:
_emit("info", msg)
def info_field(label: str, value: str) -> None:
_emit("info", field(label, value))
def warn(msg: str) -> None:
_emit("warn", msg)
def error(msg: str) -> None:
_emit("error", msg)
def ok(msg: str) -> None:
_emit("ok", msg)
def ok_field(label: str, value: str) -> None:
_emit("ok", field(label, value))
-35
View File
@@ -1,35 +0,0 @@
//! Tiny stderr logger. We use a consistent `==> <action>: <details>` prefix
//! so progress messages are easy to scan during long builds.
use std::io::{IsTerminal, Write};
const ARROW: &str = "==>";
fn paint(color: &str, text: &str) -> String {
if std::io::stderr().is_terminal() {
format!("\x1b[{color}m{text}\x1b[0m")
} else {
text.to_owned()
}
}
fn emit(color: &str, action: &str, details: &str) {
let arrow = paint(color, ARROW);
let action = paint("1", action);
let _ = writeln!(std::io::stderr(), "{arrow} {action} {details}");
}
/// Major step, e.g. starting a build or packaging an output.
pub fn step(action: &str, details: &str) {
emit("1;34", action, details); // bold blue
}
/// Cache hit / skipped work.
pub fn skip(action: &str, details: &str) {
emit("1;33", action, details); // bold yellow
}
/// Sub-step inside a larger action.
pub fn info(action: &str, details: &str) {
emit("1;32", action, details); // bold green
}
-21
View File
@@ -1,21 +0,0 @@
mod apk;
mod build;
mod cli;
mod config;
mod graph;
mod log;
mod patches;
mod phase;
mod recipe;
mod rewrite;
mod source;
mod starlark;
mod update;
use anyhow::Result;
use clap::Parser;
fn main() -> Result<()> {
let cli = cli::Cli::parse();
cli::run(cli)
}
-19
View File
@@ -1,19 +0,0 @@
use anyhow::Result;
use std::path::{Path, PathBuf};
pub fn discover(recipe_dir: &Path) -> Result<Vec<PathBuf>> {
let patch_dir = recipe_dir.join("patches");
if !patch_dir.exists() {
return Ok(Vec::new());
}
let mut patches = Vec::new();
for entry in std::fs::read_dir(&patch_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("patch") {
patches.push(path);
}
}
patches.sort();
Ok(patches)
}
-213
View File
@@ -1,213 +0,0 @@
use crate::config::Config;
use crate::starlark::{eval_content_with_extra, prepend_common_lib_load};
use allocative::Allocative;
use anyhow::{Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use starlark::environment::{GlobalsBuilder, LibraryExtension};
use starlark::eval::Evaluator;
use starlark::starlark_module;
use starlark::values::none::NoneType;
use starlark::values::{ProvidesStaticType, Value};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Allocative)]
pub struct PhaseCommand {
pub argv: Vec<String>,
/// Extra environment variables exported just for this command, in the
/// order the recipe supplied them. Empty means "inherit only".
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<(String, String)>,
}
/// How `ctx.source_dir` is exposed to Starlark.
///
/// * `Single` is a single container path (e.g. `/source`), used when the
/// recipe declared a `source = {...}` form.
/// * `Many` is a map of source name to container path, exposed as a
/// `struct(...)` (e.g. `ctx.source_dir.linux`), used when the recipe
/// declared `sources = {"linux": {...}, ...}`.
#[derive(Debug, Clone)]
pub enum SourceDir {
Single(String),
Many(BTreeMap<String, String>),
}
/// Per-phase paths exposed to Starlark as `ctx.source_dir`, `ctx.build_dir`,
/// `ctx.dest_dir`, `ctx.prefix`, `ctx.sysroot` (Jinx-inspired).
#[derive(Debug, Clone)]
pub struct PhaseEnv<'a> {
pub source_dir: SourceDir,
pub build_dir: &'a str,
pub dest_dir: &'a str,
pub prefix: &'a str,
pub sysroot: &'a str,
}
#[derive(Debug, Default, ProvidesStaticType, Allocative)]
struct CommandStore {
commands: RefCell<Vec<PhaseCommand>>,
}
impl CommandStore {
fn push(&self, command: PhaseCommand) {
self.commands.borrow_mut().push(command);
}
}
#[starlark_module]
fn phase_globals(builder: &mut GlobalsBuilder) {
fn ctx_run<'v>(
argv: Value<'v>,
#[starlark(require = named, default = NoneType)] env: Value<'v>,
eval: &mut Evaluator,
) -> anyhow::Result<NoneType> {
let json = argv.to_json()?;
let values: Vec<String> = serde_json::from_str(&json)
.map_err(|err| anyhow!("ctx.run expects a list of strings: {err}"))?;
if values.is_empty() {
bail!("ctx.run argv cannot be empty");
}
let env_vars = parse_env(env)?;
store(eval)?.push(PhaseCommand {
argv: values,
env: env_vars,
});
Ok(NoneType)
}
fn ctx_install(
src: &str,
dst: &str,
#[starlark(require = named, default = "644")] mode: &str,
eval: &mut Evaluator,
) -> anyhow::Result<NoneType> {
store(eval)?.push(PhaseCommand {
argv: vec![
"install".to_owned(),
format!("-Dm{mode}"),
src.to_owned(),
dst.to_owned(),
],
env: Vec::new(),
});
Ok(NoneType)
}
}
fn parse_env(value: Value<'_>) -> anyhow::Result<Vec<(String, String)>> {
if value.is_none() {
return Ok(Vec::new());
}
let json = value.to_json()?;
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json)
.map_err(|err| anyhow!("ctx.run env must be a dict of string -> string: {err}"))?;
let mut out = Vec::with_capacity(map.len());
for (key, val) in map {
let serde_json::Value::String(val) = val else {
bail!("ctx.run env value for `{key}` must be a string");
};
if key.is_empty() || key.contains('=') {
bail!("ctx.run env key `{key}` is not a valid variable name");
}
out.push((key, val));
}
Ok(out)
}
fn store<'a, 'b, 'c, 'd>(eval: &'a Evaluator<'b, 'c, 'd>) -> anyhow::Result<&'a CommandStore> {
eval.extra
.ok_or_else(|| anyhow!("ctx command used without command store"))?
.downcast_ref::<CommandStore>()
.ok_or_else(|| anyhow!("command store has the wrong type"))
}
pub fn collect_phase_commands(
recipe_path: &Path,
repo_root: &Path,
config: &Config,
phase: &str,
env: &PhaseEnv<'_>,
package: Option<(&str, &str)>,
) -> Result<Vec<PhaseCommand>> {
validate_identifier(phase)?;
let raw = std::fs::read_to_string(recipe_path)?;
// Auto-load helpers from `lib/common.star` so recipes never need an
// explicit `load()` for the canonical helpers.
let mut content = prepend_common_lib_load(Some(repo_root), Some(config), &raw)?;
let jobs = std::thread::available_parallelism()
.map(|j| j.get())
.unwrap_or(1);
let source_dir_expr = source_dir_literal(&env.source_dir)?;
let ctx_literal = format!(
"struct(run = ctx_run, install = ctx_install, jobs = {jobs}, \
source_dir = {sd}, build_dir = {bd}, dest_dir = {dd}, prefix = {pf}, sysroot = {sr})",
sd = source_dir_expr,
bd = serde_json::to_string(env.build_dir)?,
dd = serde_json::to_string(env.dest_dir)?,
pf = serde_json::to_string(env.prefix)?,
sr = serde_json::to_string(env.sysroot)?,
);
let call = match package {
Some((name, destdir)) => format!(
"\n__ctx = {ctx_literal}\n__pkg = struct(name = {n}, destdir = {d})\n{phase}(__ctx, __pkg)\n",
n = serde_json::to_string(name)?,
d = serde_json::to_string(destdir)?,
),
None => format!("\n__ctx = {ctx_literal}\n{phase}(__ctx)\n"),
};
content.push_str(&call);
let globals = GlobalsBuilder::extended_by(&[LibraryExtension::StructType])
.with(phase_globals)
.build();
let cmd_store = CommandStore::default();
eval_content_with_extra(
recipe_path,
content,
Some(config),
Some(repo_root),
globals,
Some(&cmd_store),
)?;
Ok(cmd_store.commands.into_inner())
}
fn source_dir_literal(source_dir: &SourceDir) -> Result<String> {
match source_dir {
SourceDir::Single(path) => Ok(serde_json::to_string(path)?),
SourceDir::Many(map) => {
let mut fields = Vec::with_capacity(map.len());
for (name, path) in map {
if !is_valid_field_name(name) {
bail!("source name `{name}` is not a valid Starlark identifier");
}
fields.push(format!("{name} = {}", serde_json::to_string(path)?));
}
Ok(format!("struct({})", fields.join(", ")))
}
}
}
fn is_valid_field_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
fn validate_identifier(name: &str) -> Result<()> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
bail!("phase function name cannot be empty");
};
if !(first == '_' || first.is_ascii_alphabetic()) {
bail!("invalid phase function name `{name}`");
}
if chars.any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) {
bail!("invalid phase function name `{name}`");
}
Ok(())
}
+171
View File
@@ -0,0 +1,171 @@
import graphlib
from dataclasses import dataclass
from pathlib import Path
from src.layout import Layout
from src.recipe import Recipe, RecipeSet
BUILD_PHASES = ("prepare", "configure", "build")
PHASE_STAMPS = {
"prepare": "prepared",
"configure": "configured",
"build": "built",
}
def _key(r: Recipe) -> str:
return r.key # "host:<name>" or "<name>"
def transitive_host_deps(rs: RecipeSet, r: Recipe) -> list[str]:
"""Returns transitive host_deps for `r`, in topological order (deepest first)."""
seen: dict[str, Recipe] = {}
order: list[str] = []
def visit(name: str) -> None:
if name in seen:
return
h = rs.host.get(name)
if h is None:
raise KeyError(f"unknown host dep: {name}")
seen[name] = h
for hh in h.host_deps:
visit(hh)
order.append(name)
for d in r.host_deps:
visit(d)
return order
def is_built(layout: Layout, r: Recipe) -> bool:
if r.kind == "host":
return layout.host_pkg_marker(r.name, r.version, r.revision).is_file()
for out in r.outputs:
if not layout.apk_path(out, r.version, r.revision).is_file():
return False
return True
def stamp_dir(layout: Layout, r: Recipe) -> Path:
wd = layout.build_workdir(r.name if r.kind == "target" else f"host-{r.name}")
return wd / ".orchid-stamps"
def stamp_token(r: Recipe) -> str:
return f"{r.version}-r{r.revision}\n"
def stamp_valid(layout: Layout, r: Recipe, phase: str) -> bool:
name = PHASE_STAMPS.get(phase)
if name is None:
return False
p = stamp_dir(layout, r) / name
try:
return p.read_text() == stamp_token(r)
except FileNotFoundError:
return False
def planned_stages(
layout: Layout, r: Recipe, *, forced: bool = False
) -> tuple[str, ...]:
stages: list[str] = []
for phase in BUILD_PHASES:
if phase in r.phases and (forced or not stamp_valid(layout, r, phase)):
stages.append(phase)
if "install" in r.phases:
stages.append("install")
stages.append("package" if r.kind == "target" else "finalize")
return tuple(stages)
@dataclass
class Plan:
order: list[str] # ordered list of recipe keys
recipes: dict[str, Recipe] # all referenced recipes
forced: set[str] # keys to rebuild even if built
stages: dict[str, tuple[str, ...]] # stages each planned recipe will run
def __iter__(self):
return iter(self.order)
def _collect_targets(rs: RecipeSet, requested: list[str] | None) -> list[Recipe]:
if not requested:
return [r for r in rs.target.values() if r.enabled] + [
r for r in rs.host.values() if r.enabled
]
out: list[Recipe] = []
for spec in requested:
r = rs.get(spec)
if not r.enabled:
raise ValueError(f"{spec}: disabled by build_if = False")
out.append(r)
return out
def build_plan(
rs: RecipeSet, layout: Layout, requested: list[str] | None, *, rebuild: bool = False
) -> Plan:
seen: dict[str, Recipe] = {}
ts: graphlib.TopologicalSorter[str] = graphlib.TopologicalSorter()
forced: set[str] = set()
def add(r: Recipe) -> None:
k = _key(r)
if k in seen:
return
seen[k] = r
deps: list[str] = []
for h in r.host_deps:
hr = rs.host.get(h)
if hr is None:
raise KeyError(f"{r.name}: unknown host dep {h!r}")
if not hr.enabled:
raise ValueError(f"{r.name}: host dep {h!r} disabled by build_if")
add(hr)
deps.append(_key(hr))
if r.kind == "target":
for d in (*r.deps, *r.run_deps):
tr = rs.target.get(d)
if tr is None:
raise KeyError(f"{r.name}: unknown dep {d!r}")
if not tr.enabled:
raise ValueError(f"{r.name}: dep {d!r} disabled by build_if")
add(tr)
deps.append(_key(tr))
else:
# host recipes may declare target `deps` that need to land in /sysroot
for d in r.deps:
tr = rs.target.get(d)
if tr is None:
raise KeyError(f"host:{r.name}: unknown target dep {d!r}")
if not tr.enabled:
raise ValueError(f"host:{r.name}: dep {d!r} disabled by build_if")
add(tr)
deps.append(_key(tr))
ts.add(k, *deps)
requested_recipes = _collect_targets(rs, requested)
for r in requested_recipes:
add(r)
if rebuild:
forced.add(_key(r))
order = list(ts.static_order())
stages: dict[str, tuple[str, ...]] = {}
final_order: list[str] = []
for k in order:
r = seen[k]
if k in forced:
stages[k] = planned_stages(layout, r, forced=True)
final_order.append(k)
continue
if is_built(layout, r):
continue
stages[k] = planned_stages(layout, r)
final_order.append(k)
return Plan(order=final_order, recipes=seen, forced=forced, stages=stages)
+49
View File
@@ -0,0 +1,49 @@
import importlib.util
from pathlib import Path
from typing import Any
from src.layout import Layout
REQUIRED_KEYS = ("arch", "triple", "container_image")
def _load_module(path: Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load profile module {path}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def load_profile(layout: Layout) -> dict[str, Any]:
link = layout.profile_link
if not link.exists():
raise FileNotFoundError(f"{link} missing a profile")
target = link.resolve()
if not target.is_file():
raise FileNotFoundError(f"profile {target.name}: missing config.py")
mod = _load_module(target, f"orchid_profile_{target.name}")
if not hasattr(mod, "profile"):
raise AttributeError(f"profile {target.name}: config.py must define profile()")
data = mod.profile()
if not isinstance(data, dict):
raise TypeError(f"profile {target.name}: profile() must return a dict")
missing = [k for k in REQUIRED_KEYS if k not in data]
if missing:
raise ValueError(f"profile {target.name}: missing keys {missing}")
data["__name__"] = target.name
return data
def init_build_dir(build: Path, repo: Path, profile_name: str) -> None:
profile_src = repo / "profiles" / (profile_name + ".py")
if not (profile_src).is_file():
raise FileNotFoundError(f"profile {profile_name!r} not found at {profile_src}")
build.mkdir(parents=True, exist_ok=False)
link = build / "profile"
# Use relative symlink so the build dir can be moved with the repo.
import os
rel = Path(os.path.relpath(profile_src, build))
link.symlink_to(rel)
+258
View File
@@ -0,0 +1,258 @@
import importlib
import importlib.util
import pkgutil
from dataclasses import dataclass
from functools import cache
from pathlib import Path, PurePosixPath
from typing import Any, Callable
from src import source as src_mod
from src.layout import Layout
from src.source import Subpackage, Tarball, subpackage, tarball, git
PHASE_NAMES = ("prepare", "configure", "build", "install")
LIB_DIR = Path(__file__).with_name("lib")
@dataclass
class Recipe:
name: str
kind: str # "target" | "host"
dir: Path # recipe directory or parent (for pure form)
pure: bool
version: str
revision: int
description: str
license: str
url: str
maintainer: str
sources: dict[str | None, Tarball | src_mod.Git] # key=None for single source
host_deps: tuple[str, ...]
deps: tuple[str, ...]
run_deps: tuple[str, ...]
subpackages: tuple[Subpackage, ...]
phases: dict[str, Callable[[Any], None]]
enabled: bool
@property
def key(self) -> str:
return f"host:{self.name}" if self.kind == "host" else self.name
@property
def patches_dir(self) -> Path | None:
if self.pure:
return None
p = self.dir / "patches"
return p if p.is_dir() else None
@property
def files_dir(self) -> Path | None:
if self.pure:
return None
p = self.dir / "files"
return p if p.is_dir() else None
@property
def outputs(self) -> list[str]:
if self.kind == "host":
return [self.name]
return [self.name, *(s.name for s in self.subpackages)]
# Import everything in lib/ for recipes
@cache
def _lib_symbols() -> dict:
ns = {}
if not LIB_DIR.is_dir():
return ns
for info in sorted(pkgutil.iter_modules([str(LIB_DIR)]), key=lambda i: i.name):
if info.ispkg:
continue
mod = importlib.import_module(f"src.lib.{info.name}")
names = getattr(mod, "__all__", None)
if names is None:
names = [n for n in mod.__dict__ if not n.startswith("_")]
for n in names:
ns[n] = getattr(mod, n)
return ns
# Symbols which are injected into the recipe
def _builtins(profile: dict) -> dict:
return {
**_lib_symbols(),
"tarball": tarball,
"git": git,
"subpackage": subpackage,
"path": PurePosixPath,
"profile": profile,
}
def _load_module(path: Path, ns: dict):
spec = importlib.util.spec_from_file_location(
f"orchid_recipe_{path.stem}_{id(path)}", path
)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {path}")
mod = importlib.util.module_from_spec(spec)
mod.__dict__.update(ns)
spec.loader.exec_module(mod)
return mod
def _plain_field(mod, recipe_name: str, field_name: str) -> str:
value = getattr(mod, field_name, "")
if value is None:
return ""
if not isinstance(value, str):
raise TypeError(f"{recipe_name}: '{field_name}' must be a string")
return value
def _load_one(
name: str, kind: str, recipe_file: Path, recipe_dir: Path, pure: bool, profile: dict
) -> Recipe | None:
mod = _load_module(recipe_file, _builtins(profile))
version = getattr(mod, "version", None)
if not isinstance(version, str) or not version:
raise ValueError(f"{name}: 'version' (str) required")
revision = int(getattr(mod, "revision", 1))
if hasattr(mod, "metadata"):
raise TypeError(
f"{name}: use plain description/license/url/maintainer fields, not metadata = meta(...)"
)
description = _plain_field(mod, name, "description")
license = _plain_field(mod, name, "license")
url = _plain_field(mod, name, "url")
maintainer = _plain_field(mod, name, "maintainer")
single = getattr(mod, "source", None)
multi = getattr(mod, "sources", None)
if single is not None and multi is not None:
raise ValueError(f"{name}: define either 'source' or 'sources', not both")
sources = {}
if single is not None:
if not isinstance(single, (Tarball, src_mod.Git)):
raise TypeError(f"{name}: 'source' must be tarball()/git()")
sources[None] = single
elif multi is not None:
if not isinstance(multi, dict) or not multi:
raise TypeError(f"{name}: 'sources' must be a non-empty dict")
multi_items = list(multi.items())
for k, v in multi_items:
if not isinstance(k, str) or not k:
raise TypeError(f"{name}: 'sources' keys must be non-empty strings")
if not isinstance(v, (Tarball, src_mod.Git)):
raise TypeError(f"{name}: source {k!r} must be tarball()/git()")
if len(multi_items) == 1:
sources[None] = multi_items[0][1]
else:
for k, v in multi_items:
sources[k] = v
else:
raise ValueError(f"{name}: 'source' or 'sources' required")
if pure:
for s in sources.values():
if s.patches:
raise ValueError(
f"{name}: pure-form recipe cannot declare patches; convert to {name}/recipe.py + patches/"
)
host_deps = tuple(getattr(mod, "host_deps", ()) or ())
deps = tuple(getattr(mod, "deps", ()) or ())
run_deps = tuple(getattr(mod, "run_deps", ()) or ())
subs = tuple(getattr(mod, "subpackages", ()) or ())
for s in subs:
if not isinstance(s, Subpackage):
raise TypeError(
f"{name}: subpackages entries must be subpackage(...) values"
)
if kind == "host" and subs:
raise ValueError(f"{name}: host recipes do not support subpackages")
phases: dict[str, Callable] = {}
for pn in PHASE_NAMES:
fn = getattr(mod, pn, None)
if fn is not None:
if not callable(fn):
raise TypeError(f"{name}: '{pn}' must be a function")
phases[pn] = fn
if kind == "target" and "build" not in phases and "install" not in phases:
# Purely declarative pkgs are unusual but allowed
pass
enabled = bool(getattr(mod, "build_if", True))
return Recipe(
name=name,
kind=kind,
dir=recipe_dir,
pure=pure,
version=version,
revision=revision,
description=description,
license=license,
url=url,
maintainer=maintainer,
sources=sources,
host_deps=host_deps,
deps=deps,
run_deps=run_deps,
subpackages=subs,
phases=phases,
enabled=enabled,
)
def _discover(root: Path, kind: str, profile: dict) -> dict[str, Recipe]:
out: dict[str, Recipe] = {}
if not root.is_dir():
return out
for entry in sorted(root.iterdir()):
if entry.name.startswith(".") or entry.name.startswith("_"):
continue
if entry.is_file() and entry.suffix == ".py":
name = entry.stem
r = _load_one(name, kind, entry, entry.parent, pure=True, profile=profile)
elif entry.is_dir():
rf = entry / "recipe.py"
if not rf.is_file():
continue
r = _load_one(entry.name, kind, rf, entry, pure=False, profile=profile)
else:
continue
if r is None:
continue
if r.name in out:
raise ValueError(f"duplicate {kind} recipe: {r.name}")
out[r.name] = r
return out
@dataclass
class RecipeSet:
target: dict[str, Recipe]
host: dict[str, Recipe]
def get(self, key: str) -> Recipe:
if key.startswith("host:"):
n = key[5:]
if n not in self.host:
raise KeyError(f"host recipe not found: {n}")
return self.host[n]
if key not in self.target:
raise KeyError(f"recipe not found: {key}")
return self.target[key]
def all(self) -> list[Recipe]:
return [*self.target.values(), *self.host.values()]
def load_recipes(layout: Layout, profile: dict) -> RecipeSet:
target = _discover(layout.recipes_dir, "target", profile)
host = _discover(layout.host_recipes_dir, "host", profile)
return RecipeSet(target=target, host=host)
-439
View File
@@ -1,439 +0,0 @@
use crate::config::Config;
use crate::starlark::{
eval_content, get_i32_default, get_json, get_string, get_string_default, get_string_vec,
has_name, prepend_common_lib_load,
};
use anyhow::{Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum PackageKind {
Host,
Target,
}
impl PackageKind {
/// Canonical `host:`/bare key form used across the graph, CLI and
/// manifest layer. Host and target trees are completely separate
/// namespaces — they may share names.
pub fn key(&self, name: &str) -> String {
match self {
PackageKind::Host => format!("host:{name}"),
PackageKind::Target => name.to_owned(),
}
}
/// Filesystem-safe variant of [`PackageKind::key`] (no `:`), used to
/// derive build/source/manifest directory names.
pub fn slug(&self, name: &str) -> String {
match self {
PackageKind::Host => format!("host-{name}"),
PackageKind::Target => name.to_owned(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Source {
/// Empty for the single-`source` form, otherwise the dict key from `sources`.
pub name: String,
pub url: String,
pub sha256: String,
/// Number of leading path components to strip when extracting (tar's
/// `--strip-components`). `0` means strip nothing.
pub strip_components: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OutputPackage {
pub name: String,
/// Canonical key of the owning recipe (see [`PackageKind::key`]).
pub recipe: String,
pub kind: PackageKind,
pub version: String,
pub revision: i32,
pub description: String,
pub license: String,
/// Target packages installed into the build sysroot. Not propagated as
/// apk `depends:` metadata.
pub build_deps: Vec<String>,
/// Target packages declared as runtime dependencies (apk `depends:`).
/// Also installed into the sysroot so the recipe can link against them.
pub deps: Vec<String>,
pub install_fn: String,
}
impl OutputPackage {
pub fn key(&self) -> String {
self.kind.key(&self.name)
}
/// Union of build- and run-dependencies (used to materialize the sysroot
/// and to compute the build graph).
pub fn all_target_deps(&self) -> Vec<String> {
let mut out = self.build_deps.clone();
out.extend(self.deps.iter().cloned());
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recipe {
pub id: String,
pub path: PathBuf,
pub dir: PathBuf,
pub name: String,
pub kind: PackageKind,
pub version: String,
pub revision: i32,
pub description: String,
pub license: String,
pub sources: Vec<Source>,
pub host_deps: Vec<String>,
pub build_deps: Vec<String>,
pub deps: Vec<String>,
pub outputs: Vec<OutputPackage>,
pub configure_fn: Option<String>,
pub build_fn: Option<String>,
pub check_fn: Option<String>,
}
impl Recipe {
/// Canonical key (`host:<id>` or `<id>`), used as the recipe-level
/// identifier in graphs, manifests and CLI references.
pub fn key(&self) -> String {
self.kind.key(&self.id)
}
/// Filesystem-safe variant of [`Recipe::key`].
pub fn slug(&self) -> String {
self.kind.slug(&self.id)
}
}
#[derive(Debug, Clone)]
pub struct RecipeSet {
pub recipes: BTreeMap<String, Recipe>,
pub outputs: BTreeMap<String, OutputPackage>,
}
impl RecipeSet {
/// Discover recipes under `repo_root`:
/// * `recipes/<name>/recipe.star` → target packages
/// * `host-recipes/<name>/recipe.star` → host packages
pub fn load(repo_root: &Path, config: &Config) -> Result<Self> {
let mut recipes = BTreeMap::new();
for (subdir, kind) in [
("recipes", PackageKind::Target),
("host-recipes", PackageKind::Host),
] {
let root = repo_root.join(subdir);
if !root.exists() {
continue;
}
for entry in WalkDir::new(&root).follow_links(false) {
let entry = entry?;
if entry.file_type().is_file() && entry.file_name() == "recipe.star" {
let recipe = Recipe::load(entry.path(), config, repo_root, kind.clone())?;
let key = recipe.key();
if recipes.insert(key.clone(), recipe).is_some() {
bail!("duplicate recipe `{key}` below {}", root.display());
}
}
}
}
let mut outputs = BTreeMap::new();
for recipe in recipes.values() {
for output in &recipe.outputs {
let key = output.key();
if outputs.insert(key.clone(), output.clone()).is_some() {
bail!("duplicate package output `{key}`");
}
}
}
Ok(Self { recipes, outputs })
}
/// Look up a recipe by the package key produced by an output.
pub fn recipe_for_package(&self, package: &str) -> Result<&Recipe> {
let output = self
.outputs
.get(package)
.ok_or_else(|| anyhow!("unknown package `{package}`"))?;
self.recipes.get(&output.recipe).ok_or_else(|| {
anyhow!(
"package `{package}` references missing recipe `{}`",
output.recipe
)
})
}
/// Resolve a user-supplied reference (recipe key, output key, or bare
/// id — provided it isn't ambiguous between the host and target trees).
pub fn recipe_by_user_ref(&self, name: &str) -> Result<&Recipe> {
if let Some(recipe) = self.recipes.get(name) {
return Ok(recipe);
}
if self.outputs.contains_key(name) {
return self.recipe_for_package(name);
}
// Bare id: search both trees, error on ambiguity.
let host_key = PackageKind::Host.key(name);
let target_key = PackageKind::Target.key(name);
match (self.recipes.get(&host_key), self.recipes.get(&target_key)) {
(Some(_), Some(_)) => bail!(
"`{name}` is ambiguous: matches both `{host_key}` and `{target_key}`; \
use the explicit form"
),
(Some(r), None) | (None, Some(r)) => Ok(r),
(None, None) => bail!("unknown recipe `{name}`"),
}
}
}
impl Recipe {
pub fn load(path: &Path, config: &Config, repo_root: &Path, kind: PackageKind) -> Result<Self> {
// Auto-load helpers from `lib/common.star` so recipes never need an
// explicit `load()` for the canonical helpers.
let raw = std::fs::read_to_string(path)?;
let content = prepend_common_lib_load(Some(repo_root), Some(config), &raw)?;
let module = eval_content(
path,
content,
Some(config),
Some(repo_root),
starlark::environment::Globals::standard(),
)?;
let dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let id = dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("recipe path has no package directory: {}", path.display()))?
.to_owned();
let name = get_string(&module, "name")?;
let version = get_string(&module, "version")?;
let revision = get_i32_default(&module, "revision", 0)?;
let description = get_string_default(&module, "description", "???")?;
let license = get_string_default(&module, "license", "???")?;
let build_deps = get_string_vec(&module, "build_deps")?;
let deps = get_string_vec(&module, "deps")?;
let host_deps = get_string_vec(&module, "host_deps")?;
let sources = parse_sources(get_json(&module, "sources")?, get_json(&module, "source")?)?;
let subpackages = parse_subpackages(get_json(&module, "subpackages")?)?;
let mut outputs = Vec::new();
let recipe_key = kind.key(&id);
outputs.push(OutputPackage {
name: name.clone(),
recipe: recipe_key.clone(),
kind: kind.clone(),
version: version.clone(),
revision,
description: description.clone(),
license: license.clone(),
build_deps: build_deps.clone(),
deps: deps.clone(),
install_fn: "install".to_owned(),
});
for subpkg in subpackages {
let sub_name = subpkg
.get("name")
.and_then(JsonValue::as_str)
.ok_or_else(|| anyhow!("subpackage in `{name}` is missing string `name`"))?
.to_owned();
outputs.push(OutputPackage {
name: sub_name,
recipe: recipe_key.clone(),
kind: kind.clone(),
version: version.clone(),
revision,
description: subpkg
.get("description")
.and_then(JsonValue::as_str)
.unwrap_or(&description)
.to_owned(),
license: subpkg
.get("license")
.and_then(JsonValue::as_str)
.unwrap_or(&license)
.to_owned(),
build_deps: json_string_list(subpkg.get("build_deps"), "subpackage build_deps")?
.unwrap_or_default(),
deps: json_string_list(subpkg.get("deps"), "subpackage deps")?.unwrap_or_default(),
install_fn: subpkg
.get("install")
.and_then(JsonValue::as_str)
.unwrap_or("install")
.to_owned(),
});
}
validate_required(&outputs)?;
Ok(Self {
id,
path: path.to_path_buf(),
dir,
name,
kind,
version,
revision,
description,
license,
sources,
host_deps,
build_deps,
deps,
outputs,
configure_fn: has_name(&module, "configure").then_some("configure".to_owned()),
build_fn: has_name(&module, "build").then_some("build".to_owned()),
check_fn: has_name(&module, "check").then_some("check".to_owned()),
})
}
}
fn validate_required(outputs: &[OutputPackage]) -> Result<()> {
for output in outputs {
if output.name.trim().is_empty() {
bail!("package output name cannot be empty");
}
for (field, value) in [
("version", &output.version),
("description", &output.description),
("license", &output.license),
] {
if value.trim().is_empty() {
bail!(
"package `{}` has empty required field `{field}`",
output.name
);
}
}
}
Ok(())
}
fn parse_sources(
sources: Option<JsonValue>,
legacy_source: Option<JsonValue>,
) -> Result<Vec<Source>> {
match (sources, legacy_source) {
(Some(_), Some(_)) => bail!("recipe defines both `sources` and `source`; use only one"),
(None, None) => Ok(Vec::new()),
(None, Some(single)) => {
let obj = single
.as_object()
.ok_or_else(|| anyhow!("`source` must be a dict"))?;
Ok(vec![parse_source_entry(String::new(), obj)?])
}
(Some(multi), None) => {
let obj = multi
.as_object()
.ok_or_else(|| anyhow!("`sources` must be a dict of {{name: source}}"))?;
obj.iter()
.map(|(name, value)| {
if name.is_empty() {
bail!("source name in `sources` cannot be empty");
}
let entry = value
.as_object()
.ok_or_else(|| anyhow!("source `{name}` must be a dict"))?;
parse_source_entry(name.clone(), entry)
})
.collect()
}
}
}
fn parse_source_entry(name: String, obj: &serde_json::Map<String, JsonValue>) -> Result<Source> {
let url = obj
.get("url")
.and_then(JsonValue::as_str)
.ok_or_else(|| anyhow!("source entry missing string `url`"))?
.to_owned();
let sha256 = obj
.get("sha256")
.and_then(JsonValue::as_str)
.unwrap_or("???")
.to_owned();
let strip_components = match obj.get("strip_components") {
None => 0,
Some(JsonValue::Number(n)) => n
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.ok_or_else(|| anyhow!("source `strip_components` must be a non-negative integer"))?,
Some(_) => bail!("source `strip_components` must be an integer"),
};
Ok(Source {
name,
url,
sha256,
strip_components,
})
}
fn parse_subpackages(value: Option<JsonValue>) -> Result<Vec<serde_json::Map<String, JsonValue>>> {
match value {
Some(JsonValue::Array(values)) => values
.into_iter()
.map(|value| {
value
.as_object()
.cloned()
.ok_or_else(|| anyhow!("subpackages entries must be objects"))
})
.collect(),
Some(_) => bail!("subpackages must be a list of objects"),
None => Ok(Vec::new()),
}
}
fn json_string_list(value: Option<&JsonValue>, label: &str) -> Result<Option<Vec<String>>> {
match value {
Some(JsonValue::Array(values)) => values
.iter()
.map(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow!("{label} must contain only strings"))
})
.collect::<Result<Vec<_>>>()
.map(Some),
Some(_) => bail!("{label} must be a string list"),
None => Ok(None),
}
}
pub fn unresolved_deps(recipes: &RecipeSet) -> Vec<String> {
let names: BTreeSet<_> = recipes.outputs.keys().cloned().collect();
let mut missing = Vec::new();
for recipe in recipes.recipes.values() {
// host_deps always refer to host outputs (canonical `host:<name>`);
// build_deps / deps refer to target outputs (bare names).
for dep in &recipe.host_deps {
let key = PackageKind::Host.key(dep);
if !names.contains(&key) {
missing.push(format!("{} -> {key}", recipe.key()));
}
}
for dep in recipe.build_deps.iter().chain(recipe.deps.iter()) {
if !names.contains(dep) {
missing.push(format!("{} -> {dep}", recipe.key()));
}
}
for output in &recipe.outputs {
for dep in output.build_deps.iter().chain(output.deps.iter()) {
if !names.contains(dep) {
missing.push(format!("{} -> {dep}", output.key()));
}
}
}
}
missing
}
-39
View File
@@ -1,39 +0,0 @@
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rewrite {
pub field: String,
pub old: String,
pub new: String,
}
pub fn backup_path(path: &Path) -> PathBuf {
let mut backup = path.as_os_str().to_os_string();
backup.push(".bak");
PathBuf::from(backup)
}
pub fn rewrite_placeholders(path: &Path, rewrites: &[Rewrite]) -> Result<bool> {
if rewrites.is_empty() {
return Ok(false);
}
let original =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let mut updated = original.clone();
for rewrite in rewrites {
let quoted_old = format!("{} = \"{}\"", rewrite.field, rewrite.old);
let quoted_new = format!("{} = \"{}\"", rewrite.field, rewrite.new);
updated = updated.replacen(&quoted_old, &quoted_new, 1);
let dict_old = format!("\"{}\": \"{}\"", rewrite.field, rewrite.old);
let dict_new = format!("\"{}\": \"{}\"", rewrite.field, rewrite.new);
updated = updated.replacen(&dict_old, &dict_new, 1);
}
if updated == original {
return Ok(false);
}
fs::write(backup_path(path), original)?;
fs::write(path, updated)?;
Ok(true)
}
+91
View File
@@ -0,0 +1,91 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class Tarball:
url: str
sha256: str
strip_components: int = 1
patches: tuple[str, ...] = ()
@property
def cache_key(self) -> str:
return self.sha256
@dataclass(frozen=True)
class Git:
url: str
commit: str
patches: tuple[str, ...] = ()
@property
def cache_key(self) -> str:
return self.commit
def tarball(
*,
url: str,
sha256: str,
strip_components: int = 1,
patches: list[str] | tuple[str, ...] = (),
) -> Tarball:
return Tarball(
url=url,
sha256=sha256,
strip_components=strip_components,
patches=tuple(patches),
)
def git(*, url: str, commit: str, patches: list[str] | tuple[str, ...] = ()) -> Git:
if not commit or commit == "?":
raise ValueError("git source requires an explicit commit SHA")
return Git(url=url, commit=commit, patches=tuple(patches))
@dataclass(frozen=True)
class Subpackage:
name: str
files: tuple[str, ...]
description: str = ""
license: str = ""
url: str = ""
maintainer: str = ""
def subpackage(
name: str,
*,
description: str = "",
license: str = "",
url: str = "",
maintainer: str = "",
files: list[str] | tuple[str, ...] = (),
) -> Subpackage:
for field_name, value in (
("description", description),
("license", license),
("url", url),
("maintainer", maintainer),
):
if not isinstance(value, str):
raise TypeError(f"subpackage {name}: '{field_name}' must be a string")
for pat in files:
if pat.startswith("/") or ".." in pat.split("/"):
raise ValueError(
f"subpackage {name}: file pattern {pat!r} must be relative and free of '..'"
)
return Subpackage(
name=name,
description=description,
license=license,
url=url,
maintainer=maintainer,
files=tuple(files),
)
def patches_of(src) -> tuple[str, ...]:
return src.patches
-73
View File
@@ -1,73 +0,0 @@
use crate::log;
use crate::recipe::Recipe;
use crate::rewrite::{Rewrite, rewrite_placeholders};
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
pub fn fetch_sources(recipe: &Recipe, cache_dir: &Path) -> Result<Vec<PathBuf>> {
fs::create_dir_all(cache_dir)?;
let mut rewrites = Vec::new();
let mut paths = Vec::new();
for source in &recipe.sources {
let label = if source.name.is_empty() {
recipe.key()
} else {
format!("{}:{}", recipe.key(), source.name)
};
let cached = source.sha256 != "???" && cache_dir.join(&source.sha256).exists();
if cached {
log::skip("cached", &format!("{label} ({})", source.url));
paths.push(cache_dir.join(&source.sha256));
continue;
}
log::step("fetch", &format!("{label} <- {}", source.url));
let bytes = download(&source.url)?;
let actual = sha256_hex(&bytes);
if source.sha256 == "???" {
log::info("sha256", &format!("{label} = {actual}"));
rewrites.push(Rewrite {
field: "sha256".into(),
old: "???".into(),
new: actual.clone(),
});
} else if source.sha256 != actual {
bail!(
"checksum mismatch for {}: expected {}, got {}",
source.url,
source.sha256,
actual
);
}
let path = cache_dir.join(&actual);
if !path.exists() {
fs::write(&path, &bytes)
.with_context(|| format!("failed to write {}", path.display()))?;
}
paths.push(path);
}
rewrite_placeholders(&recipe.path, &rewrites)?;
Ok(paths)
}
fn download(url: &str) -> Result<Vec<u8>> {
if let Some(path) = url.strip_prefix("file://") {
return Ok(fs::read(path)?);
}
// Some mirrors reject requests without a User-Agent with HTTP 403, so set an explicit one.
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("distro/", env!("CARGO_PKG_VERSION")))
.build()?;
let mut response = client.get(url).send()?.error_for_status()?;
let mut bytes = Vec::new();
response.read_to_end(&mut bytes)?;
Ok(bytes)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
-513
View File
@@ -1,513 +0,0 @@
use crate::config::Config;
use allocative::{Allocative, Visitor, ident_key};
use anyhow::{Result, anyhow, bail};
use serde_json::Value as JsonValue;
use starlark::environment::{FrozenModule, Globals, Module};
use starlark::eval::{Evaluator, FileLoader};
use starlark::starlark_simple_value;
use starlark::syntax::{AstModule, Dialect};
use starlark::values::dict::AllocDict;
use starlark::values::{AnyLifetime, Heap, NoSerialize, ProvidesStaticType, StarlarkValue, Value};
use starlark_derive::starlark_value;
use std::collections::{BTreeMap, HashMap};
use std::fmt::{self, Display};
use std::mem;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, ProvidesStaticType, NoSerialize)]
pub struct OptionsValue {
values: BTreeMap<String, JsonValue>,
}
impl OptionsValue {
fn new(values: BTreeMap<String, JsonValue>) -> Result<Self> {
for key in values.keys() {
validate_starlark_identifier(key)?;
}
Ok(Self { values })
}
}
impl Display for OptionsValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "options")
}
}
starlark_simple_value!(OptionsValue);
impl Allocative for OptionsValue {
fn visit<'a, 'b: 'a>(&self, visitor: &'a mut Visitor<'b>) {
let mut visitor = visitor.enter_self(self);
visitor.visit_simple(
ident_key!(values),
mem::size_of::<(String, JsonValue)>() * self.values.len(),
);
visitor.exit();
}
}
#[starlark_value(type = "options")]
impl<'v> StarlarkValue<'v> for OptionsValue {
fn get_attr(&self, attr: &str, heap: &'v Heap) -> Option<Value<'v>> {
self.values.get(attr).map(|value| heap.alloc(value))
}
}
#[derive(Debug, Clone, ProvidesStaticType, NoSerialize, Allocative)]
pub struct SettingsValue {
target_arch: String,
container_runtime: String,
container_image: String,
container_dockerfile: String,
options: BTreeMap<String, String>,
}
impl From<&Config> for SettingsValue {
fn from(config: &Config) -> Self {
Self {
target_arch: config.target_arch.clone(),
container_runtime: config.container_runtime.clone(),
container_image: config.container_image.clone(),
container_dockerfile: config.container_dockerfile.display().to_string(),
options: config
.options
.iter()
.map(|(key, value)| (key.clone(), option_value_to_string(value)))
.collect(),
}
}
}
impl Display for SettingsValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "settings")
}
}
starlark_simple_value!(SettingsValue);
#[starlark_value(type = "settings")]
impl<'v> StarlarkValue<'v> for SettingsValue {
fn get_attr(&self, attr: &str, heap: &'v Heap) -> Option<Value<'v>> {
match attr {
"target_arch" => Some(heap.alloc(self.target_arch.as_str())),
"container_runtime" => Some(heap.alloc(self.container_runtime.as_str())),
"container_image" => Some(heap.alloc(self.container_image.as_str())),
"container_dockerfile" => Some(heap.alloc(self.container_dockerfile.as_str())),
"options" => Some(heap.alloc(AllocDict(self.options.clone()))),
_ => None,
}
}
}
pub fn eval_file(
path: &Path,
settings: Option<&Config>,
repo_root: Option<&Path>,
) -> Result<Module> {
let content = std::fs::read_to_string(path)?;
eval_content(path, content, settings, repo_root, Globals::standard())
}
/// Path of the implicit helper library auto-loaded into every recipe.
pub const COMMON_LIB_MODULE: &str = "//lib:common.star";
const COMMON_LIB_RELATIVE: &str = "lib/common.star";
const OPTIONS_NAME: &str = "OPTIONS";
/// Names exported by `lib/common.star`, if the file exists. Empty otherwise.
pub fn common_lib_names(repo_root: &Path, settings: Option<&Config>) -> Result<Vec<String>> {
let path = repo_root.join(COMMON_LIB_RELATIVE);
if !path.exists() {
return Ok(Vec::new());
}
let module = eval_file(&path, settings, Some(repo_root))?;
Ok(module
.names()
.map(|n| n.as_str().to_owned())
.filter(|n| !n.starts_with('_') && n != "settings" && n != OPTIONS_NAME)
.collect())
}
/// Prepend an implicit `load("//lib:common.star", ...)` so every recipe sees
/// the shared helpers without an explicit import. Does nothing if there's no
/// `lib/common.star` or no repo root.
pub fn prepend_common_lib_load(
repo_root: Option<&Path>,
settings: Option<&Config>,
content: &str,
) -> Result<String> {
let Some(root) = repo_root else {
return Ok(content.to_owned());
};
let names = common_lib_names(root, settings)?;
if names.is_empty() {
return Ok(content.to_owned());
}
let names_lit = names
.iter()
.map(|n| format!("{:?}", n))
.collect::<Vec<_>>()
.join(", ");
Ok(format!(
"load(\"{COMMON_LIB_MODULE}\", {names_lit})\n{content}"
))
}
pub fn eval_content(
path: &Path,
content: String,
settings: Option<&Config>,
repo_root: Option<&Path>,
globals: Globals,
) -> Result<Module> {
eval_content_with_extra(path, content, settings, repo_root, globals, None)
}
pub fn eval_content_with_extra<'a>(
path: &Path,
content: String,
settings: Option<&Config>,
repo_root: Option<&Path>,
globals: Globals,
extra: Option<&'a dyn AnyLifetime<'a>>,
) -> Result<Module> {
let filename = path.display().to_string();
validate_options_source(path, &content)?;
let ast = AstModule::parse(
&filename,
content,
&Dialect {
enable_f_strings: true,
enable_top_level_stmt: true,
..Dialect::Standard
},
)
.map_err(|err| anyhow!("{err}"))?;
let loader = match repo_root {
Some(root) => Some(RepoFileLoader::new(root, settings, globals.clone(), &ast)?),
None if !ast.loads().is_empty() => {
bail!(
"load() requires a repo root while evaluating {}",
path.display()
)
}
None => None,
};
let module = Module::new();
let protected_options = if let Some(config) = settings {
Some(set_config_values(&module, config)?)
} else {
None
};
{
let mut eval = Evaluator::new(&module);
if let Some(loader) = &loader {
eval.set_loader(loader);
}
eval.extra = extra;
eval.eval_module(ast, &globals)
.map_err(|err| anyhow!("{err}"))?;
}
validate_options_binding(path, &module, protected_options)?;
Ok(module)
}
fn validate_options_source(path: &Path, content: &str) -> Result<()> {
let line_offset = implicit_common_load_line_offset(content);
for (index, line) in content.lines().enumerate() {
let code = line.split('#').next().unwrap_or_default().trim_start();
if defines_or_reassigns_options(code) {
let line = (index + 1).saturating_sub(line_offset).max(1);
bail!(
"{}:{} must not define or reassign `{OPTIONS_NAME}`",
path.display(),
line
);
}
}
Ok(())
}
fn implicit_common_load_line_offset(content: &str) -> usize {
content
.lines()
.next()
.is_some_and(|line| line.starts_with(&format!("load(\"{COMMON_LIB_MODULE}\",")))
as usize
}
fn defines_or_reassigns_options(code: &str) -> bool {
let Some(rest) = code.strip_prefix(OPTIONS_NAME) else {
return defines_options_function(code) || binds_options_loop_variable(code);
};
let rest = rest.trim_start();
rest.starts_with('=')
|| rest.starts_with("+=")
|| rest.starts_with("-=")
|| rest.starts_with("*=")
|| rest.starts_with("/=")
|| rest.starts_with("%=")
|| rest.starts_with("&=")
|| rest.starts_with("|=")
|| rest.starts_with("^=")
}
fn defines_options_function(code: &str) -> bool {
code.strip_prefix("def ")
.and_then(|rest| rest.trim_start().strip_prefix(OPTIONS_NAME))
.is_some_and(|rest| rest.trim_start().starts_with('('))
}
fn binds_options_loop_variable(code: &str) -> bool {
code.strip_prefix("for ")
.and_then(|rest| rest.trim_start().strip_prefix(OPTIONS_NAME))
.is_some_and(|rest| rest.trim_start().starts_with("in "))
}
fn validate_options_binding(
path: &Path,
module: &Module,
expected: Option<Value<'_>>,
) -> Result<()> {
let Some(expected) = expected else {
return Ok(());
};
let actual = module.get(OPTIONS_NAME).ok_or_else(|| {
anyhow!(
"{} removed the protected `{OPTIONS_NAME}` binding",
path.display()
)
})?;
if !actual.ptr_eq(expected) {
bail!(
"{} must not define or reassign `{OPTIONS_NAME}`",
path.display()
);
}
Ok(())
}
#[derive(Clone)]
struct RepoFileLoader {
modules: HashMap<String, FrozenModule>,
}
impl RepoFileLoader {
fn new(
repo_root: &Path,
settings: Option<&Config>,
globals: Globals,
ast: &AstModule,
) -> Result<Self> {
let mut modules = HashMap::new();
for load in ast.loads() {
load_module(
repo_root,
settings,
globals.clone(),
load.module_id,
&mut modules,
)?;
}
Ok(Self { modules })
}
}
impl FileLoader for RepoFileLoader {
fn load(&self, path: &str) -> starlark::Result<FrozenModule> {
self.modules
.get(path)
.cloned()
.ok_or_else(|| starlark::Error::new_other(anyhow!("unknown Starlark module `{path}`")))
}
}
fn load_module(
repo_root: &Path,
settings: Option<&Config>,
globals: Globals,
module_id: &str,
modules: &mut HashMap<String, FrozenModule>,
) -> Result<()> {
if modules.contains_key(module_id) {
return Ok(());
}
let path = resolve_load_path(repo_root, module_id)?;
let content = std::fs::read_to_string(&path)?;
let filename = path.display().to_string();
validate_options_source(&path, &content)?;
let ast =
AstModule::parse(&filename, content, &Dialect::Standard).map_err(|err| anyhow!("{err}"))?;
for load in ast.loads() {
load_module(
repo_root,
settings,
globals.clone(),
load.module_id,
modules,
)?;
}
let nested_loader = RepoFileLoader {
modules: modules.clone(),
};
let module = Module::new();
let protected_options = if let Some(config) = settings {
Some(set_config_values(&module, config)?)
} else {
None
};
{
let mut eval = Evaluator::new(&module);
eval.set_loader(&nested_loader);
eval.eval_module(ast, &globals)
.map_err(|err| anyhow!("{err}"))?;
}
validate_options_binding(&path, &module, protected_options)?;
let frozen = module.freeze().map_err(|err| anyhow!("{err:?}"))?;
modules.insert(module_id.to_owned(), frozen);
Ok(())
}
fn set_config_values<'v>(module: &'v Module, config: &Config) -> Result<Value<'v>> {
module.set("settings", module.heap().alloc(SettingsValue::from(config)));
let options = module
.heap()
.alloc(OptionsValue::new(config.options.clone())?);
module.set(OPTIONS_NAME, options);
Ok(options)
}
fn validate_starlark_identifier(name: &str) -> Result<()> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
bail!("config option name cannot be empty");
};
if !(first == '_' || first.is_ascii_alphabetic()) {
bail!("config option `{name}` is not a valid Starlark identifier");
}
if chars.any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric())) {
bail!("config option `{name}` is not a valid Starlark identifier");
}
if matches!(
name,
"and"
| "as"
| "assert"
| "break"
| "class"
| "continue"
| "def"
| "del"
| "elif"
| "else"
| "except"
| "finally"
| "for"
| "from"
| "global"
| "if"
| "import"
| "in"
| "is"
| "lambda"
| "load"
| "not"
| "or"
| "pass"
| "return"
| "try"
| "while"
| "with"
| "yield"
) {
bail!("config option `{name}` is a reserved Starlark keyword");
}
Ok(())
}
fn resolve_load_path(repo_root: &Path, module_id: &str) -> Result<PathBuf> {
let relative = if let Some(stripped) = module_id.strip_prefix("//") {
stripped.replace(':', "/")
} else {
module_id.to_owned()
};
let path = repo_root.join(relative);
let canonical_root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
let canonical_path = path.canonicalize().unwrap_or(path);
if !canonical_path.starts_with(&canonical_root) {
bail!("Starlark load escapes repo root: {module_id}");
}
Ok(canonical_path)
}
fn option_value_to_string(value: &JsonValue) -> String {
match value {
JsonValue::String(value) => value.clone(),
JsonValue::Bool(value) => value.to_string(),
JsonValue::Number(value) => value.to_string(),
JsonValue::Null => "null".to_owned(),
JsonValue::Array(_) | JsonValue::Object(_) => value.to_string(),
}
}
pub fn get_string(module: &Module, name: &str) -> Result<String> {
module
.get(name)
.and_then(|v| v.unpack_str().map(ToOwned::to_owned))
.ok_or_else(|| anyhow!("missing or non-string Starlark variable `{name}`"))
}
pub fn get_string_default(module: &Module, name: &str, default: &str) -> Result<String> {
Ok(match module.get(name) {
Some(value) => value
.unpack_str()
.ok_or_else(|| anyhow!("non-string Starlark variable `{name}`"))?
.to_owned(),
None => default.to_owned(),
})
}
pub fn get_i32_default(module: &Module, name: &str, default: i32) -> Result<i32> {
Ok(match module.get(name) {
Some(value) => value
.unpack_i32()
.ok_or_else(|| anyhow!("non-integer Starlark variable `{name}`"))?,
None => default,
})
}
pub fn has_name(module: &Module, name: &str) -> bool {
module.get(name).is_some()
}
pub fn get_json(module: &Module, name: &str) -> Result<Option<JsonValue>> {
match module.get(name) {
Some(value) => Ok(Some(serde_json::from_str(&value.to_json()?)?)),
None => Ok(None),
}
}
pub fn get_string_vec(module: &Module, name: &str) -> Result<Vec<String>> {
match get_json(module, name)? {
Some(JsonValue::Array(values)) => values
.into_iter()
.map(|value| match value {
JsonValue::String(s) => Ok(s),
_ => bail!("`{name}` must be a list of strings"),
})
.collect(),
Some(_) => bail!("`{name}` must be a list of strings"),
None => Ok(Vec::new()),
}
}
pub fn get_json_map(module: &Module, name: &str) -> Result<BTreeMap<String, JsonValue>> {
match get_json(module, name)? {
Some(JsonValue::Object(values)) => Ok(values.into_iter().collect()),
Some(_) => bail!("`{name}` must be a dict"),
None => Ok(BTreeMap::new()),
}
}
-256
View File
@@ -1,256 +0,0 @@
//! `distro update` — check repology for newer upstream versions and bump
//! recipes in-place.
//!
//! For each requested recipe we query
//! `https://repology.org/api/v1/project/<name>` and pick the highest version
//! among entries whose `status` is `"newest"` or `"unique"` (i.e. not
//! outdated, not rolling/devel, not ignored). If that version is strictly
//! greater than the recipe's current `version`, we rewrite:
//!
//! * `version = "..."` → the new upstream version
//! * `revision = N` → `revision = 1`
//! * every `"sha256": "..."` → `"sha256": "???"` so the next fetch fills it
//!
//! Repology asks API clients to set a descriptive User-Agent.
use crate::log;
use crate::recipe::{Recipe, RecipeSet};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::cmp::Ordering;
use std::fs;
use std::path::Path;
const REPOLOGY_API: &str = "https://repology.org/api/v1/project";
#[derive(Debug, Deserialize)]
struct RepologyEntry {
#[serde(default)]
version: String,
#[serde(default)]
status: String,
}
pub fn run(recipes: &RecipeSet, names: &[String], bump: bool) -> Result<()> {
let targets: Vec<&Recipe> = if names.is_empty() {
recipes.recipes.values().collect()
} else {
let mut out = Vec::with_capacity(names.len());
for name in names {
out.push(recipes.recipe_by_user_ref(name)?);
}
out
};
let client = reqwest::blocking::Client::builder()
.user_agent(concat!(
"distro/",
env!("CARGO_PKG_VERSION"),
" (+repology version checker)"
))
.build()?;
let mut outdated = 0usize;
let mut bumped = 0usize;
let mut up_to_date = 0usize;
let mut errored = 0usize;
for recipe in targets {
match check_one(&client, recipe) {
Ok(Some(new_version)) => {
if bump {
bump_recipe(&recipe.path, &new_version)?;
log::step(
"bump",
&format!("{}: {} -> {}", recipe.key(), recipe.version, new_version),
);
bumped += 1;
} else {
log::step(
"outdated",
&format!("{}: {} -> {}", recipe.key(), recipe.version, new_version),
);
outdated += 1;
}
}
Ok(None) => {
log::skip(
"up-to-date",
&format!("{} {}", recipe.key(), recipe.version),
);
up_to_date += 1;
}
Err(e) => {
log::info("error", &format!("{}: {e}", recipe.key()));
errored += 1;
}
}
}
if bump {
log::step(
"summary",
&format!("{bumped} bumped, {up_to_date} up-to-date, {errored} errored"),
);
} else {
log::step(
"summary",
&format!(
"{outdated} outdated, {up_to_date} up-to-date, {errored} errored (re-run with --bump to rewrite recipes)"
),
);
}
Ok(())
}
fn check_one(client: &reqwest::blocking::Client, recipe: &Recipe) -> Result<Option<String>> {
let url = format!("{REPOLOGY_API}/{}", recipe.name);
let resp = client
.get(&url)
.send()
.with_context(|| format!("GET {url}"))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
bail!("repology has no project named `{}`", recipe.name);
}
let entries: Vec<RepologyEntry> = serde_json::from_slice(&resp.error_for_status()?.bytes()?)
.context("failed to parse repology response")?;
let latest = entries
.iter()
.filter(|e| matches!(e.status.as_str(), "newest" | "unique"))
.map(|e| e.version.as_str())
.max_by(|a, b| natural_cmp(a, b));
match latest {
Some(v) if natural_cmp(v, &recipe.version) == Ordering::Greater => Ok(Some(v.to_owned())),
_ => Ok(None),
}
}
fn bump_recipe(path: &Path, new_version: &str) -> Result<()> {
let original =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let mut out = String::with_capacity(original.len());
let mut version_replaced = false;
let mut revision_replaced = false;
for line in original.split_inclusive('\n') {
let trimmed = line.trim_start();
if !version_replaced && trimmed.starts_with("version") {
if let Some(replaced) = replace_string_assignment(line, "version", new_version) {
out.push_str(&replaced);
version_replaced = true;
continue;
}
}
if !revision_replaced && trimmed.starts_with("revision") {
if let Some(replaced) = replace_int_assignment(line, "revision", 1) {
out.push_str(&replaced);
revision_replaced = true;
continue;
}
}
out.push_str(line);
}
if !version_replaced {
bail!("could not find `version = \"...\"` in {}", path.display());
}
// Reset every sha256 placeholder so the next fetch re-derives it.
out = out.replace("\"sha256\": \"", "\x00sha256_marker\x00\"");
out = regex_lite_replace_sha(&out);
out = out.replace("\x00sha256_marker\x00\"", "\"sha256\": \"");
fs::write(path, out).with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
/// Replace every quoted sha256 value with `"???"`, leaving keys/whitespace
/// alone. Avoids a full regex dep by walking the string by hand.
fn regex_lite_replace_sha(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let needle = b"\x00sha256_marker\x00\"";
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(needle) {
out.push_str("\x00sha256_marker\x00\"???\"");
i += needle.len();
// skip the original value up to the closing quote
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
if i < bytes.len() {
i += 1; // consume the closing quote we replaced
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn replace_string_assignment(line: &str, key: &str, new_value: &str) -> Option<String> {
// Match `<key><space>=<space>"value"` while preserving surrounding text
// (indentation, trailing newline).
let stripped = line.strip_prefix(key)?;
let rest = stripped.trim_start_matches(|c: char| c == ' ' || c == '\t');
let after_eq = rest.strip_prefix('=')?.trim_start();
let after_quote = after_eq.strip_prefix('"')?;
let end = after_quote.find('"')?;
let trailing = &after_quote[end + 1..];
Some(format!("{key} = \"{new_value}\"{trailing}"))
}
fn replace_int_assignment(line: &str, key: &str, new_value: i32) -> Option<String> {
let stripped = line.strip_prefix(key)?;
let rest = stripped.trim_start_matches(|c: char| c == ' ' || c == '\t');
let after_eq = rest.strip_prefix('=')?.trim_start();
let end = after_eq.find(|c: char| !c.is_ascii_digit())?;
let trailing = &after_eq[end..];
Some(format!("{key} = {new_value}{trailing}"))
}
/// dpkg-ish natural comparison: split into runs of digits and non-digits and
/// compare numerically where both sides are digits, lexicographically
/// otherwise. Good enough for upstream tarball versions.
fn natural_cmp(a: &str, b: &str) -> Ordering {
let mut ai = a.chars().peekable();
let mut bi = b.chars().peekable();
loop {
match (ai.peek().copied(), bi.peek().copied()) {
(None, None) => return Ordering::Equal,
(None, _) => return Ordering::Less,
(_, None) => return Ordering::Greater,
(Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => {
let mut na = String::new();
while let Some(&c) = ai.peek() {
if c.is_ascii_digit() {
na.push(c);
ai.next();
} else {
break;
}
}
let mut nb = String::new();
while let Some(&c) = bi.peek() {
if c.is_ascii_digit() {
nb.push(c);
bi.next();
} else {
break;
}
}
let xa: u64 = na.parse().unwrap_or(0);
let xb: u64 = nb.parse().unwrap_or(0);
match xa.cmp(&xb) {
Ordering::Equal => continue,
other => return other,
}
}
(Some(x), Some(y)) => match x.cmp(&y) {
Ordering::Equal => {
ai.next();
bi.next();
}
other => return other,
},
}
}
}