*: Switch to python

This commit is contained in:
2026-05-26 03:06:26 +02:00
parent 2e6704516a
commit b6e18c474e
62 changed files with 15663 additions and 3441 deletions
+5 -2
View File
@@ -1,2 +1,5 @@
/target __pycache__/
/build /cache
/build*
/sources
/sysroot
Generated
-1814
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "builder"
version = "0.1.0"
edition = "2024"
[dependencies]
allocative = "0.3.4"
anyhow = "1.0.102"
clap = { version = "4.6.1", features = ["derive"] }
either = "1.16.0"
petgraph = "0.8.3"
smallvec = "1.15.1"
starlark = "0.13.0"
starlark_derive = "0.13.0"
thiserror = "2.0.18"
+62
View File
@@ -0,0 +1,62 @@
FROM alpine:edge
RUN mkdir -p /sources /build /pkgs /sysroot /dest /tools /files
RUN apk add --no-cache \
apk-tools \
build-base \
bash \
patch \
tar \
xz \
zstd \
file \
findutils \
coreutils \
diffutils \
grep \
sed \
gawk \
musl-dev \
linux-headers \
gmp-dev \
mpfr-dev \
mpc1-dev \
isl-dev \
zlib-dev \
git \
pkgconf \
patchelf \
gperf \
python3 \
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
WORKDIR /build
-64
View File
@@ -1,64 +0,0 @@
arch = "x86_64"
libc = "glibc"
if libc == "glibc":
env = "gnu"
elif libc == "musl":
env = "musl"
else:
fail(f"Unknown libc: {libc}")
prefix = path("/usr")
host_cflags = ["-O2", "-pipe"]
host_cxxflags = host_cflags + []
host_ldflags = ["-Wl,-O1", "-Wl,--sort-common", "-Wl,--as-needed"]
target_cflags = host_cflags + []
target_cxxflags = host_cxxflags + []
target_ldflags = host_ldflags + ["-Wl,-z,now"]
if 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"]
config(
arch = arch,
recipes_dir = path("./recipes"),
host_recipes_dir = path("./host-recipes"),
container = podman(
image = "local/builder:latest",
dockerfile = path("./Dockerfile"),
),
target_arch = arch,
target_triple = f"{arch}-orchid-linux-{env}",
libc = libc,
prefix = prefix,
bindir = prefix / "bin",
sbindir = prefix / "bin",
libdir = prefix / "lib",
libexecdir = prefix / "libexec",
includedir = prefix / "include",
sysconfdir = path("/etc"),
localstatedir = path("/var"),
host_cflags = " ".join(host_cflags),
host_cxxflags = " ".join(host_cxxflags),
host_ldflags = " ".join(host_ldflags),
cflags = " ".join(target_cflags),
cxxflags = " ".join(target_cxxflags),
ldflags = " ".join(target_ldflags),
)
+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()
-39
View File
@@ -1,39 +0,0 @@
version = "2.46.0"
revision = 1
metadata = meta(
description = "GNU binutils cross-compiled for the target triple",
license = "GPL-3.0-or-later",
)
source = tarball(
url = f"https://ftp.gnu.org/gnu/binutils/binutils-{version}.tar.xz",
sha256 = "d75a94f4d73e7a4086f7513e67e439e8fcdcbb726ffe63f4661744e6256b2cf2",
strip_components = 1,
)
def configure(ctx):
ctx.run(
ctx.source_dir / "configure",
"--prefix=" + options.prefix,
"--target=" + options.target_triple,
"--with-sysroot=" + ctx.sysroot_dir,
"--with-pic",
"--enable-cet",
"--enable-default-execstack=no",
"--enable-deterministic-archives",
"--enable-ld=default",
"--enable-new-dtags",
"--enable-plugins",
"--enable-relro",
"--enable-separate-code",
"--enable-threads",
"--disable-nls",
"--disable-werror",
# gprofng's libcollector relies on glibc-specific internals.
"--disable-gprofng",
env = {
"CFLAGS": options.host_cflags,
"CXXFLAGS": options.host_cxxflags,
"LDFLAGS": options.host_ldflags,
})
_, build, install = autotools()
+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")
+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)})
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*"],
)
)
+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"],
)
-20
View File
@@ -1,20 +0,0 @@
version = "7.0.9"
revision = 1
metadata = meta(
description = "Linux kernel headers for userspace development",
license = "GPL-2.0-only",
)
source = tarball(
url = f"https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{version}.tar.xz",
sha256 = "ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4",
strip_components = 1,
)
def build(ctx):
ctx.run("cp", "-rp", ctx.source_dir / ".", ctx.build_dir)
ctx.run("make", "headers_install", "ARCH=" + options.target_arch)
ctx.run("find", ctx.build_dir / "usr" / "include", "-type", "f", "!", "-name", "*.h", "-delete")
def install(ctx):
ctx.run("mkdir", "-p", ctx.dest_dir / options.prefix)
ctx.run("cp", "-rp", ctx.build_dir / "usr" / "include", ctx.dest_dir / options.prefix)
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}",
)
+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()
+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"
)
+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
-137
View File
@@ -1,137 +0,0 @@
use crate::{
container::{ContainerManager, PodmanRuntime},
eval::{
Config, ContainerConfig, ContainerManagerWrapper, Context, Path, config_globals,
eval_files, types_globals,
},
log,
plan::{Plan, PlanKey},
recipe::RecipeSet,
};
use clap::{Parser, Subcommand};
use starlark::{
environment::{GlobalsBuilder, Module},
eval,
values::Value,
};
use std::{cell::Cell, path::PathBuf, sync::Arc};
#[derive(Debug, Parser)]
struct Cli {
#[arg(
long,
short = 'C',
default_value = ".",
help = "Directory containing the configuration and recipe files"
)]
root: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Parser)]
#[command(about = "Fetch sources for the given recipes")]
struct FetchCommand {
#[arg(
required = true,
help = "List of recipes to fetch, host recipes should be prefixed with `host:`"
)]
recipes: Vec<String>,
#[arg(long, short = 'n', help = "Print what will be done and exit")]
dry_run: bool,
}
#[derive(Debug, Parser)]
#[command(about = "Build the given recipes")]
struct BuildCommand {
#[arg(
required = true,
help = "List of recipes to build, host recipes should be prefixed with `host:`"
)]
recipes: Vec<String>,
#[arg(long, short, help = "Perform a full rebuild of the given recipes")]
rebuild: bool,
#[arg(long, short = 'n', help = "Print what will be done and exit")]
dry_run: bool,
}
#[derive(Debug, Subcommand)]
enum Command {
Fetch(FetchCommand),
Build(BuildCommand),
}
pub fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
let root_path = cli.root.canonicalize().unwrap_or(cli.root);
let config: Config = {
let cell = Cell::new(None);
let config_path = root_path.join("config.star");
eval_files(
&[&config_path],
&GlobalsBuilder::standard()
.with(types_globals)
.with(config_globals)
.build(),
None,
None,
Some(&cell),
)?;
cell.take()
.ok_or_else(|| anyhow::anyhow!("`config` was not called"))?
};
let container_runtime = match config.container() {
ContainerConfig::Podman(_) => Arc::new(PodmanRuntime::new()?),
};
let container_manager = ContainerManager::new(container_runtime);
let mut recipes = RecipeSet::new(&config);
recipes.load_recipes(
&root_path.join(config.recipes_dir()),
&root_path.join(config.host_recipes_dir()),
)?;
// let wrapper = ContainerManagerWrapper(&container_manager);
// for (name, recipe) in recipes.packages.iter() {
// println!("{name}: {:#?}", recipe);
// let mo = Module::new();
// let mut eval = eval::Evaluator::new(&mo);
// eval.extra = Some(&wrapper);
// eval.eval_function(
// recipe.build.unwrap().0.to_value(),
// &[mo.heap().alloc(Context {
// source_dir: Path::new("/source"),
// build_dir: Path::new("/build"),
// jobs: 4,
// })],
// &[],
// )
// .unwrap();
// }
let mut plan = Plan::new(&recipes);
match cli.command {
Command::Fetch(_) => {}
Command::Build(cmd) => {
for recipe in cmd.recipes.iter() {
plan.add_wanted(if let Some(recipe) = recipe.strip_prefix("host:") {
PlanKey::ToolInstall(recipe.to_string())
} else {
PlanKey::PkgPackage(recipe.clone())
});
}
}
}
log!("plan", "{:#?}", plan.steps());
Ok(())
}
+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)
-99
View File
@@ -1,99 +0,0 @@
use std::{
collections::HashMap,
path::Path,
sync::{Arc, Mutex},
};
mod podman;
pub use podman::PodmanRuntime;
#[derive(Clone)]
pub struct Container {
id: String,
runtime: Arc<dyn ContainerRuntime>,
}
impl Container {
fn new(id: String, runtime: Arc<dyn ContainerRuntime>) -> Self {
Self { id, runtime }
}
pub fn id(&self) -> &str {
&self.id
}
pub fn exec<'a, 'e, 'c>(
&self,
argv: impl Into<Vec<&'a str>>,
env: impl Into<Vec<(&'e str, &'e str)>>,
cwd: impl Into<&'c Path>,
) -> anyhow::Result<()> {
self.runtime
.exec(self.id(), argv.into(), env.into(), cwd.into())
}
}
pub trait ContainerRuntime {
/// Starts a new container, returns the ID.
fn start_container(
&self,
image_name: &str,
mounts: &[(&Path, &str, bool)],
) -> anyhow::Result<String>;
/// Stops a container.
fn stop_container(&self, container_id: &str);
/// Executes a command in a container.
fn exec(
&self,
container_id: &str,
argv: Vec<&str>,
env: Vec<(&str, &str)>,
cwd: &Path,
) -> anyhow::Result<()>;
}
pub struct ContainerManager {
inner: Mutex<ContainerManagerInner>,
}
struct ContainerManagerInner {
containers: HashMap<String, Container>,
runtime: Arc<dyn ContainerRuntime>,
}
impl ContainerManager {
pub fn new(runtime: Arc<dyn ContainerRuntime>) -> Self {
Self {
inner: Mutex::new(ContainerManagerInner {
containers: HashMap::new(),
runtime,
}),
}
}
pub fn container(&self, name: &str) -> anyhow::Result<Container> {
let mut inner = self.inner.lock().unwrap();
if inner.containers.get(name).is_none() {
let container_id = inner.runtime.start_container("alpine:edge", &[])?;
crate::log!("info", "Started new container ({container_id})");
let container = Container::new(container_id, inner.runtime.clone());
inner.containers.insert(name.into(), container);
}
Ok(inner.containers.get(name).cloned().unwrap())
}
}
impl Drop for ContainerManager {
fn drop(&mut self) {
let inner = self.inner.lock().unwrap();
for (_, container) in inner.containers.iter() {
inner.runtime.stop_container(container.id());
}
}
}
-101
View File
@@ -1,101 +0,0 @@
use crate::container::ContainerRuntime;
use anyhow::Context;
use std::{
path::Path,
process::{Command, Stdio},
};
pub struct PodmanRuntime;
impl PodmanRuntime {
pub fn new() -> anyhow::Result<Self> {
let output = Command::new("podman").arg("--version").output()?;
if output.status.success() {
Ok(Self)
} else {
anyhow::bail!(
"Could not execute `podman --version` - make sure you have podman installed."
);
}
}
}
impl ContainerRuntime for PodmanRuntime {
fn start_container(
&self,
image_name: &str,
mounts: &[(&Path, &str, bool)],
) -> anyhow::Result<String> {
let mut cmd = Command::new("podman");
cmd.arg("run");
cmd.arg("--detach");
cmd.arg("--read-only");
cmd.arg("--network=none");
cmd.arg("--userns=keep-id");
cmd.arg("--tmpfs").arg("/builder/dest");
cmd.arg("--tmpfs").arg("/builder/sysroot");
for &(host, container, read_only) in mounts {
cmd.arg("--volume").arg(format!(
"{}:{}{}",
host.display(),
container,
if read_only { ":ro" } else { "" }
));
}
cmd.arg(image_name);
cmd.arg("sleep").arg("infinity");
let output = cmd.output()?;
if output.status.success() {
Ok(String::from_utf8(output.stdout)
.context("container ID is not valid UTF-8")?
.trim()
.into())
} else {
todo!()
}
}
fn stop_container(&self, container_id: &str) {
Command::new("podman")
.arg("kill")
.arg(container_id)
.output()
.unwrap();
}
fn exec(
&self,
container_id: &str,
argv: Vec<&str>,
env: Vec<(&str, &str)>,
cwd: &Path,
) -> anyhow::Result<()> {
let mut cmd = Command::new("podman");
cmd.arg("exec");
cmd.arg("--workdir").arg(cwd);
for (key, value) in env {
cmd.arg("--env").arg(format!("{key}={value}"));
}
cmd.arg(container_id);
cmd.args(argv);
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
let output = cmd.output()?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!("Failed to execute command");
}
}
}
+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)
-154
View File
@@ -1,154 +0,0 @@
use crate::eval::Path;
use allocative::Allocative;
use starlark::{
collections::SmallMap,
environment::GlobalsBuilder,
eval::Evaluator,
starlark_module, starlark_simple_value,
values::{StarlarkValue, Value, ValueLike, none::NoneType},
};
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use std::{
cell::Cell,
collections::HashMap,
path::{Path as StdPath, PathBuf},
};
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct PodmanConfig {
image: String,
dockerfile: PathBuf,
}
impl std::fmt::Display for PodmanConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "podman_config")
}
}
starlark_simple_value!(PodmanConfig);
#[starlark_value(type = "podman_config")]
impl<'v> StarlarkValue<'v> for PodmanConfig {}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub enum ContainerConfig {
Podman(PodmanConfig),
}
impl std::fmt::Display for ContainerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "container_config")
}
}
starlark_simple_value!(ContainerConfig);
#[starlark_value(type = "container_config")]
impl<'v> StarlarkValue<'v> for ContainerConfig {}
#[derive(Debug, Clone, Allocative, ProvidesStaticType)]
pub enum ConfigValue {
String(String),
Integer(i32),
Bool(bool),
Path(Path),
}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Config {
arch: String,
container: ContainerConfig,
recipes_dir: Path,
host_recipes_dir: Path,
options: HashMap<String, ConfigValue>,
}
impl Config {
pub fn arch(&self) -> &str {
&self.arch
}
pub fn container(&self) -> &ContainerConfig {
&self.container
}
pub fn recipes_dir(&self) -> &StdPath {
&self.recipes_dir.path()
}
pub fn host_recipes_dir(&self) -> &StdPath {
&self.host_recipes_dir.path()
}
pub fn options(&self) -> &HashMap<String, ConfigValue> {
&self.options
}
}
impl std::fmt::Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "container_config")
}
}
starlark_simple_value!(Config);
#[starlark_value(type = "config")]
impl<'v> StarlarkValue<'v> for Config {}
#[starlark_module]
pub fn config_globals(b: &mut GlobalsBuilder) {
fn podman(
#[starlark(require = named)] image: &str,
#[starlark(require = named)] dockerfile: &Path,
) -> anyhow::Result<ContainerConfig> {
Ok(ContainerConfig::Podman(PodmanConfig {
image: image.to_string(),
dockerfile: dockerfile.path().to_owned(),
}))
}
fn config(
#[starlark(require = named)] arch: &str,
#[starlark(require = named)] container: &ContainerConfig,
#[starlark(require = named)] recipes_dir: &Path,
#[starlark(require = named)] host_recipes_dir: &Path,
#[starlark(kwargs)] kwargs: SmallMap<&str, Value>,
eval: &mut Evaluator,
) -> anyhow::Result<NoneType> {
let config = eval
.extra
.and_then(|extra| extra.downcast_ref::<Cell<Option<Config>>>())
.ok_or_else(|| anyhow::anyhow!("`config` called outside of config.star"))?;
config.set(Some(Config {
arch: arch.to_string(),
container: container.clone(),
recipes_dir: recipes_dir.clone(),
host_recipes_dir: host_recipes_dir.clone(),
options: kwargs
.iter()
.map(|(&k, v)| {
let value = if let Some(str) = v.unpack_str() {
ConfigValue::String(str.to_string())
} else if let Some(num) = v.unpack_i32() {
ConfigValue::Integer(num)
} else if let Some(bool) = v.unpack_bool() {
ConfigValue::Bool(bool)
} else if let Some(path) = v.downcast_ref::<Path>() {
ConfigValue::Path(path.clone())
} else {
anyhow::bail!("config option must be a `string`, `int`, `bool` or `path`");
};
Ok((k.to_string(), value))
})
.collect::<Result<_, _>>()?,
}));
Ok(NoneType)
}
}
-96
View File
@@ -1,96 +0,0 @@
use starlark::{
any::AnyLifetime,
environment::{FrozenModule, Globals, Module},
eval::Evaluator,
syntax::{AstModule, Dialect, DialectTypes},
values::{UnpackValue, Value, type_repr::StarlarkTypeRepr},
};
use std::path::Path as StdPath;
mod config;
mod recipe;
mod types;
#[allow(unused_imports)]
pub use config::*;
#[allow(unused_imports)]
pub use recipe::*;
#[allow(unused_imports)]
pub use types::*;
pub trait UnpackCloned: Sized + StarlarkTypeRepr {
fn unpack_cloned(value: Value<'_>) -> Option<Self>;
}
impl<T> UnpackCloned for T
where
for<'v> T: UnpackValue<'v>,
{
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
T::unpack_value(value).unwrap()
}
}
pub fn eval_files(
path: &[&StdPath],
globals: &Globals,
lib_module: Option<&FrozenModule>,
config: Option<&Config>,
extra: Option<&dyn AnyLifetime>,
) -> anyhow::Result<Module> {
use anyhow::Context;
let module = Module::new();
if let Some(lib_module) = lib_module {
module.import_public_symbols(lib_module);
}
if let Some(config) = config {
module.set("options", module.heap().alloc(config.clone()));
}
let mut paths = path.to_vec();
paths.sort();
let ast_modules = paths
.iter()
.map(|&path| {
let module = AstModule::parse_file(path, &default_dialect())
.map_err(|err| anyhow::anyhow!("{err}"))
.context(format!("parsing file {:?}", path.display()))?;
Ok((path, module))
})
.collect::<anyhow::Result<Vec<(&StdPath, AstModule)>>>()?;
for (path, ast) in ast_modules {
let mut eval = Evaluator::new(&module);
if let Some(extra) = extra {
eval.extra = Some(extra);
}
eval.eval_module(ast, globals)
.map_err(|err| anyhow::anyhow!("{err}"))
.context(format!("evaluating file {:?}", path.display()))?;
}
Ok(module)
}
fn default_dialect() -> Dialect {
Dialect {
enable_def: true,
enable_lambda: true,
enable_load: false,
enable_keyword_only_arguments: false,
enable_positional_only_arguments: false,
enable_types: DialectTypes::Disable,
enable_load_reexport: false,
enable_top_level_stmt: true,
enable_f_strings: true,
..Dialect::Standard
}
}
-202
View File
@@ -1,202 +0,0 @@
use std::cell::Cell;
use allocative::Allocative;
use starlark::{
environment::{GlobalsBuilder, Methods, MethodsBuilder, MethodsStatic},
eval::Evaluator,
starlark_module, starlark_simple_value,
typing::Ty,
values::{
Heap, StarlarkValue, UnpackValue, Value, ValueLike, none::NoneType, tuple::UnpackTuple,
type_repr::StarlarkTypeRepr,
},
};
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use crate::{
container::{Container, ContainerManager},
eval::{Path, UnpackCloned},
log,
recipe::Source,
};
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct TarballSource {
url: String,
sha256: String,
strip_components: u32,
}
impl std::fmt::Display for TarballSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "tarball")
}
}
starlark_simple_value!(TarballSource);
#[starlark_value(type = "tarball")]
impl<'v> StarlarkValue<'v> for TarballSource {}
impl UnpackCloned for TarballSource {
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
value.downcast_ref().cloned()
}
}
impl Source for TarballSource {}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Metadata {
maintainer: Option<String>,
description: Option<String>,
license: Option<String>,
website: Option<String>,
}
impl std::fmt::Display for Metadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "metadata")
}
}
starlark_simple_value!(Metadata);
#[starlark_value(type = "metadata")]
impl<'v> StarlarkValue<'v> for Metadata {}
impl UnpackCloned for Metadata {
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
value.downcast_ref().cloned()
}
}
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Context {
pub source_dir: Path,
pub build_dir: Path,
pub jobs: i32,
}
impl std::fmt::Display for Context {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "context")
}
}
starlark_simple_value!(Context);
#[derive(Debug)]
struct RunArg(pub String);
impl UnpackValue<'_> for RunArg {
type Error = anyhow::Error;
fn unpack_value_impl(value: Value) -> anyhow::Result<Option<Self>> {
Ok(if let Some(str) = value.unpack_str() {
Some(RunArg(str.to_owned()))
} else if let Some(int) = value.unpack_i32() {
Some(RunArg(int.to_string()))
} else if let Some(path) = value.downcast_ref::<Path>() {
Some(RunArg(path.path().to_str().unwrap_or("").to_string()))
} else {
None
})
}
}
impl StarlarkTypeRepr for RunArg {
type Canonical = Self;
fn starlark_type_repr() -> starlark::typing::Ty {
Ty::string()
}
}
#[derive(ProvidesStaticType)]
pub struct ContainerManagerWrapper<'a>(pub &'a ContainerManager);
#[starlark_module]
fn context_methods(b: &mut MethodsBuilder) {
fn run(
#[starlark(this)] this: &Context,
#[starlark(args)] args: UnpackTuple<RunArg>,
eval: &mut Evaluator,
) -> anyhow::Result<NoneType> {
let ContainerManagerWrapper(container_manager) = eval
.extra
.and_then(|extra| extra.downcast_ref())
.ok_or_else(|| anyhow::anyhow!("`config` called outside of config.star"))?;
let argv = args.items.iter().map(|x| x.0.as_str()).collect::<Vec<_>>();
log!("run", "Running command: {argv:?}");
container_manager
.container("changeme")? // TODO
.exec(argv, [], std::path::Path::new("/"))?;
Ok(NoneType)
}
}
#[starlark_value(type = "context")]
impl<'v> StarlarkValue<'v> for Context {
fn get_methods() -> Option<&'static Methods> {
static RES: MethodsStatic = MethodsStatic::new();
RES.methods(context_methods)
}
fn has_attr(&self, attr: &str, _heap: &Heap) -> bool {
match attr {
"source_dir" => true,
"build_dir" => true,
"jobs" => true,
_ => false,
}
}
fn get_attr(&self, attr: &str, heap: &'v Heap) -> Option<Value<'v>> {
match attr {
"source_dir" => Some(heap.alloc(self.source_dir.clone())),
"build_dir" => Some(heap.alloc(self.build_dir.clone())),
"jobs" => Some(heap.alloc(self.jobs)),
_ => None,
}
}
}
impl UnpackCloned for Context {
fn unpack_cloned(value: Value<'_>) -> Option<Self> {
value.downcast_ref().cloned()
}
}
#[starlark_module]
pub fn recipe_globals(b: &mut GlobalsBuilder) {
fn tarball(
#[starlark(require = named)] url: &str,
#[starlark(require = named)] sha256: &str,
#[starlark(require = named, default = 0)] strip_components: u32,
) -> anyhow::Result<TarballSource> {
Ok(TarballSource {
url: url.to_string(),
sha256: sha256.to_string(),
strip_components,
})
}
fn meta(
#[starlark(require = named)] maintainer: Option<&str>,
#[starlark(require = named)] description: Option<&str>,
#[starlark(require = named)] license: Option<&str>,
#[starlark(require = named)] website: Option<&str>,
) -> anyhow::Result<Metadata> {
Ok(Metadata {
maintainer: maintainer.map(|x| x.to_string()),
description: description.map(|x| x.to_string()),
license: license.map(|x| x.to_string()),
website: website.map(|x| x.to_string()),
})
}
}
-64
View File
@@ -1,64 +0,0 @@
use allocative::Allocative;
use starlark::{
environment::GlobalsBuilder,
starlark_module, starlark_simple_value,
values::{Heap, StarlarkValue, Value, ValueLike},
};
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
use std::path::{Path as StdPath, PathBuf};
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
pub struct Path {
inner: PathBuf,
}
impl Path {
pub fn new(value: impl Into<PathBuf>) -> Self {
Self {
inner: value.into(),
}
}
pub fn path(&self) -> &StdPath {
&self.inner
}
}
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "path({:?})", self.inner)
}
}
starlark_simple_value!(Path);
#[starlark_value(type = "path")]
impl<'v> StarlarkValue<'v> for Path {
fn div(&self, other: Value<'v>, heap: &'v Heap) -> starlark::Result<Value<'v>> {
let rhs = if let Some(str) = other.unpack_str() {
str.to_string()
} else if let Some(path) = other.downcast_ref::<Path>() {
path.inner.to_str().unwrap_or("").to_string()
} else {
return Err(starlark::Error::new_other(anyhow::anyhow!(
"expected a `string` or `path`, got `{}`",
other.get_type()
)));
};
Ok(heap.alloc(Path {
inner: self
.inner
.join(rhs.trim_start_matches(std::path::MAIN_SEPARATOR_STR)),
}))
}
}
#[starlark_module]
pub fn types_globals(b: &mut GlobalsBuilder) {
fn path(value: &str) -> anyhow::Result<Path> {
Ok(Path {
inner: PathBuf::from(value),
})
}
}
+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)
+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))
-20
View File
@@ -1,20 +0,0 @@
use std::{io::IsTerminal, sync::LazyLock};
static IS_STDERR_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stderr().is_terminal());
pub fn __emit(color: &str, action: &str, args: std::fmt::Arguments) {
const ARROW: &str = "==>";
if *IS_STDERR_TERMINAL {
eprintln!("\x1b[{color}m{ARROW}\x1b[0m \x1b[1m{action}\x1b[0m {args}");
} else {
eprintln!("{ARROW} {action} {args}");
}
}
#[macro_export]
macro_rules! log {
($action:literal, $($arg:tt)*) => {{
$crate::log::__emit("1;34", $action, format_args!($($arg)*));
}};
}
-136
View File
@@ -1,136 +0,0 @@
use crate::{
container::{ContainerManager, PodmanRuntime},
plan::{Plan, PlanKey},
recipe::{PackageRecipe, RecipeSet, SourceRecipe, ToolRecipe},
};
use std::{path::Path, sync::Arc};
mod cli;
mod container;
mod eval;
mod log;
mod plan;
mod recipe;
fn main() -> anyhow::Result<()> {
cli::run()
// let podman_runtime = Arc::new(PodmanRuntime::new()?);
// let mut container_manager = ContainerManager::new(podman_runtime);
// container_manager.container("example")?.exec(
// vec!["sh", "-c", "uname -a && id"],
// vec![],
// Path::new("/"),
// )?;
// let mut recipes = RecipeSet::default();
// recipes.load_recipes(Path::new("./recipes"), Path::new("./host-recipes"))?;
// recipes.add_source(
// "binutils-2.46",
// SourceRecipe {
// name: "binutils-2.46".into(),
// },
// );
// recipes.add_source(
// "gcc-16.1.0",
// SourceRecipe {
// name: "gcc-16.1.0".into(),
// },
// );
// recipes.add_source(
// "linux-7.0.9",
// SourceRecipe {
// name: "linux-7.0.9".into(),
// },
// );
// recipes.add_source(
// "glibc-2.41",
// SourceRecipe {
// name: "glibc-2.41".into(),
// },
// );
// recipes.add_source(
// "bash-5.3",
// SourceRecipe {
// name: "bash-5.3".into(),
// },
// );
// recipes.add_tool(
// "binutils",
// ToolRecipe {
// name: "binutils".into(),
// sources: vec!["binutils-2.46".into()],
// tools_wanted: vec![],
// pkgs_wanted: vec![],
// },
// );
// recipes.add_tool(
// "gcc-bootstrap",
// ToolRecipe {
// name: "gcc-bootstrap".into(),
// sources: vec!["gcc-16.1.0".into()],
// tools_wanted: vec!["binutils".into()],
// pkgs_wanted: vec![],
// },
// );
// recipes.add_tool(
// "gcc",
// ToolRecipe {
// name: "gcc".into(),
// sources: vec!["gcc-16.1.0".into()],
// tools_wanted: vec!["binutils".into()],
// pkgs_wanted: vec!["glibc".into()],
// },
// );
// recipes.add_package(
// "linux-headers",
// PackageRecipe {
// name: "linux-headers".into(),
// sources: vec!["linux-7.0.9".into()],
// tools_wanted: vec![],
// pkgs_wanted: vec![],
// },
// );
// recipes.add_package(
// "glibc",
// PackageRecipe {
// name: "glibc".into(),
// sources: vec!["glibc-2.41".into()],
// tools_wanted: vec!["gcc-bootstrap".into()],
// pkgs_wanted: vec!["linux-headers".into()],
// },
// );
// recipes.add_package(
// "bash",
// PackageRecipe {
// name: "bash".into(),
// sources: vec!["bash-5.3".into()],
// tools_wanted: vec!["gcc".into()],
// pkgs_wanted: vec!["glibc".into()],
// },
// );
// let mut plan = Plan::new(&recipes);
// plan.add_wanted(PlanKey::PkgPackage(
// recipes.package("bash").expect("back package"),
// ));
// println!("{:#?}", plan.steps()?);
// Ok(())
}
+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)
-222
View File
@@ -1,222 +0,0 @@
use crate::recipe::{PackageRecipe, RecipeSet, SourceRecipe, ToolRecipe};
use petgraph::{
Direction,
graph::{DiGraph, NodeIndex},
};
use smallvec::{SmallVec, smallvec};
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap, HashSet},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PlanError {
#[error("missing source recipe '{0}'")]
MissingSource(String),
#[error("missing tool recipe '{0}'")]
MissingTool(String),
#[error("missing package recipe '{0}'")]
MissingPackage(String),
#[error("plan cycle detected")]
CycleDetected,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PlanKey {
SourceFetch(String),
SourcePatch(String),
SourcePrepare(String),
ToolConfigure(String),
ToolBuild(String),
ToolInstall(String),
PkgConfigure(String),
PkgBuild(String),
PkgInstall(String),
PkgPackage(String),
}
impl PlanKey {
fn weight(&self) -> i8 {
match self {
PlanKey::SourceFetch(_) => 0,
PlanKey::SourcePatch(_) => 1,
PlanKey::SourcePrepare(_) => 2,
PlanKey::ToolConfigure(_) => 3,
PlanKey::ToolBuild(_) => 4,
PlanKey::ToolInstall(_) => 5,
PlanKey::PkgConfigure(_) => 6,
PlanKey::PkgBuild(_) => 7,
PlanKey::PkgInstall(_) => 8,
PlanKey::PkgPackage(_) => 9,
}
}
fn dependencies(&self, recipes: &RecipeSet) -> Result<SmallVec<[PlanKey; 8]>, PlanError> {
match self {
PlanKey::SourceFetch(_) => Ok(smallvec![]),
PlanKey::SourcePatch(recipe) => Ok(smallvec![PlanKey::SourceFetch(recipe.clone())]),
PlanKey::SourcePrepare(recipe) => Ok(smallvec![PlanKey::SourcePatch(recipe.clone())]),
PlanKey::ToolConfigure(recipe) => {
let recipe = recipes
.tool(recipe)
.ok_or(PlanError::MissingTool(recipe.clone()))?;
let source_deps = recipe.sources.iter().map(|name| {
recipes
.source(name)
.map(|_| PlanKey::SourcePrepare(name.to_string()))
.ok_or(PlanError::MissingSource(name.to_string()))
});
let tool_deps = recipe.tools_wanted.iter().map(|name| {
recipes
.tool(name)
.map(|_| PlanKey::ToolInstall(name.to_string()))
.ok_or(PlanError::MissingTool(name.to_string()))
});
let pkg_deps = recipe.pkgs_wanted.iter().map(|name| {
recipes
.package(name)
.map(|_| PlanKey::PkgPackage(name.to_string()))
.ok_or(PlanError::MissingPackage(name.to_string()))
});
source_deps.chain(tool_deps).chain(pkg_deps).collect()
}
PlanKey::ToolBuild(recipe) => Ok(smallvec![PlanKey::ToolConfigure(recipe.clone())]),
PlanKey::ToolInstall(recipe) => Ok(smallvec![PlanKey::ToolBuild(recipe.clone())]),
PlanKey::PkgConfigure(recipe) => {
let recipe = recipes
.package(recipe)
.ok_or(PlanError::MissingPackage(recipe.clone()))?;
let source_deps = recipe.sources.iter().map(|name| {
recipes
.source(name)
.map(|_| PlanKey::SourcePrepare(name.to_string()))
.ok_or(PlanError::MissingSource(name.to_string()))
});
let tool_deps = recipe.tools_wanted.iter().map(|name| {
recipes
.tool(name)
.map(|_| PlanKey::ToolInstall(name.to_string()))
.ok_or(PlanError::MissingTool(name.to_string()))
});
let pkg_deps = recipe.pkgs_wanted.iter().map(|name| {
recipes
.package(name)
.map(|_| PlanKey::PkgPackage(name.to_string()))
.ok_or(PlanError::MissingPackage(name.to_string()))
});
source_deps.chain(tool_deps).chain(pkg_deps).collect()
}
PlanKey::PkgBuild(recipe) => Ok(smallvec![PlanKey::PkgConfigure(recipe.clone())]),
PlanKey::PkgInstall(recipe) => Ok(smallvec![PlanKey::PkgBuild(recipe.clone())]),
PlanKey::PkgPackage(recipe) => Ok(smallvec![PlanKey::PkgInstall(recipe.clone())]),
}
}
fn is_active(&self) -> bool {
true
}
}
pub struct Plan<'a> {
recipes: &'a RecipeSet<'a>,
wanted: HashSet<PlanKey>,
}
impl<'a> Plan<'a> {
pub fn new(recipes: &'a RecipeSet) -> Self {
Self {
recipes,
wanted: HashSet::new(),
}
}
pub fn add_wanted(&mut self, key: PlanKey) {
self.wanted.insert(key);
}
pub fn steps(&self) -> Result<Vec<PlanKey>, PlanError> {
let mut stack: Vec<_> = self.wanted.iter().cloned().collect();
let mut graph: DiGraph<_, ()> = DiGraph::new();
let mut nodes = HashMap::new();
while let Some(node) = stack.pop() {
let node_idx = match nodes.get(&node) {
Some(&idx) => idx,
None => {
let idx = graph.add_node(node.clone());
nodes.insert(node.clone(), idx);
idx
}
};
for dep in node.dependencies(self.recipes)? {
let dep_idx = match nodes.get(&dep) {
Some(&idx) => idx,
None => {
let idx = graph.add_node(dep.clone());
nodes.insert(dep.clone(), idx);
stack.push(dep);
idx
}
};
graph.update_edge(dep_idx, node_idx, ());
}
}
// petgraph::algo::toposort(&graph, None)
// .and_then(|nodes| {
// Ok(nodes
// .iter()
// .map(|&k| graph[k])
// .filter(|node| node.is_active())
// .collect())
// })
// .map_err(|_| PlanError::CycleDetected)
let mut in_degree: HashMap<NodeIndex, usize> = graph
.node_indices()
.map(|i| (i, graph.neighbors_directed(i, Direction::Incoming).count()))
.collect();
let mut heap: BinaryHeap<(Reverse<i8>, NodeIndex)> = in_degree
.iter()
.filter(|&(_, d)| *d == 0)
.map(|(&i, _)| (Reverse(graph[i].weight()), i))
.collect();
let mut result = Vec::with_capacity(graph.node_count());
while let Some((_, idx)) = heap.pop() {
result.push(graph[idx].clone());
for neighbor in graph.neighbors_directed(idx, Direction::Outgoing) {
let d = in_degree.get_mut(&neighbor).unwrap();
*d -= 1;
if *d == 0 {
heap.push((Reverse(graph[neighbor].weight()), neighbor));
}
}
}
if result.len() != graph.node_count() {
Err(PlanError::CycleDetected)
} else {
result.retain(|node| node.is_active());
Ok(result)
}
}
}
+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)
-256
View File
@@ -1,256 +0,0 @@
use anyhow::Context;
use starlark::{
environment::{FrozenModule, GlobalsBuilder, Module},
eval,
values::{
UnpackValue,
typing::{FrozenStarlarkCallable, StarlarkCallable, StarlarkCallableParamSpec},
},
};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use crate::eval::{
Config, Metadata, TarballSource, UnpackCloned, eval_files, recipe_globals, types_globals,
};
pub struct SourceRecipe {
pub name: String,
pub source: Box<dyn Source>,
}
pub trait Source {}
pub struct ToolRecipe {
pub name: String,
pub sources: Vec<String>,
pub tools_wanted: Vec<String>,
pub pkgs_wanted: Vec<String>,
}
#[derive(Debug)]
pub struct PackageRecipe {
pub name: String,
pub meta: Option<Metadata>,
pub version: String,
pub revision: u32,
pub sources: Vec<String>,
pub tools_wanted: Vec<String>,
pub pkgs_wanted: Vec<String>,
pub module: FrozenModule,
pub configure: Option<FrozenStarlarkCallable>,
pub build: Option<FrozenStarlarkCallable>,
pub install: Option<FrozenStarlarkCallable>,
}
pub struct RecipeSet<'a> {
sources: HashMap<String, SourceRecipe>,
tools: HashMap<String, ToolRecipe>,
pub packages: HashMap<String, PackageRecipe>,
config: &'a Config,
}
impl<'a> RecipeSet<'a> {
fn add_source(&mut self, name: &str, recipe: SourceRecipe) -> anyhow::Result<()> {
if self.sources.insert(name.to_string(), recipe).is_some() {
anyhow::bail!("source '{name}' already exists");
}
Ok(())
}
fn add_tool(&mut self, name: &str, recipe: ToolRecipe) -> anyhow::Result<()> {
if self.tools.insert(name.to_string(), recipe).is_some() {
anyhow::bail!("tool '{name}' already exists");
}
Ok(())
}
fn add_package(&mut self, name: &str, recipe: PackageRecipe) -> anyhow::Result<()> {
if self.packages.insert(name.to_string(), recipe).is_some() {
anyhow::bail!("package '{name}' already exists");
}
Ok(())
}
fn load_tool_recipe(&mut self, name: &str, path: &Path) -> anyhow::Result<()> {
Ok(())
}
fn load_package_recipe(&mut self, name: &str, path: &Path) -> anyhow::Result<()> {
let module = eval_files(
&[path],
&GlobalsBuilder::standard()
.with(types_globals)
.with(recipe_globals)
.build(),
None,
Some(self.config),
None,
)?;
let module = module.freeze().map_err(|err| anyhow::anyhow!("{err:?}"))?;
let version: String = get_value(&module, "version")?;
let revision: u32 = get_value_option(&module, "revision")?.unwrap_or(1);
let metadata: Option<Metadata> = get_value_option(&module, "metadata")?;
let source: TarballSource = get_value(&module, "source")?;
let configure = get_frozen_callable(&module, "configure")?;
let build = get_frozen_callable(&module, "build")?;
let install = get_frozen_callable(&module, "install")?;
self.add_source(
name,
SourceRecipe {
name: name.to_string(),
source: Box::new(source),
},
)?;
self.add_package(
name,
PackageRecipe {
name: name.to_string(),
meta: metadata,
version,
revision,
sources: vec![name.to_string()],
tools_wanted: vec![],
pkgs_wanted: vec![],
module,
configure,
build,
install,
},
)
}
pub fn new(config: &'a Config) -> Self {
Self {
sources: HashMap::new(),
tools: HashMap::new(),
packages: HashMap::new(),
config,
}
}
pub fn load_recipes(
&mut self,
recipes_dir: &Path,
host_recipes_dir: &Path,
) -> anyhow::Result<()> {
for (dir, tool_recipe) in [(recipes_dir, false), (host_recipes_dir, true)] {
for entry in std::fs::read_dir(dir)? {
let entry = entry.context("reading directory entry")?;
if let Some((name, path)) = get_recipe_name_and_patch(&entry)? {
if tool_recipe {
self.load_tool_recipe(&name, &path)?;
} else {
self.load_package_recipe(&name, &path)?;
}
}
}
}
Ok(())
}
pub fn source(&self, name: &str) -> Option<&SourceRecipe> {
self.sources.get(name)
}
pub fn tool(&self, name: &str) -> Option<&ToolRecipe> {
self.tools.get(name)
}
pub fn package(&self, name: &str) -> Option<&PackageRecipe> {
self.packages.get(name)
}
}
fn get_recipe_name_and_patch(
entry: &std::fs::DirEntry,
) -> anyhow::Result<Option<(String, PathBuf)>> {
let file_type = entry.file_type()?;
let path = entry.path();
if file_type.is_dir() {
let recipe_path = path.join("recipe.star");
if recipe_path.exists() {
return Ok(Some((
entry.file_name().to_str().unwrap_or("").to_string(),
recipe_path,
)));
}
} else {
let name = path.file_stem().unwrap().to_str().unwrap_or("").to_string();
let extension = path
.extension()
.ok_or(anyhow::anyhow!("File did not have an extension"))?;
if extension == "star" {
return Ok(Some((name, path)));
}
}
Ok(None)
}
fn get_value_option<T: UnpackCloned>(
module: &FrozenModule,
name: &str,
) -> anyhow::Result<Option<T>> {
module
.get_option(name)?
.map(|value| {
T::unpack_cloned(value.value()).ok_or_else(|| {
anyhow::anyhow!(
"`{name}` should be of type `{}` but got `{}`",
T::starlark_type_repr(),
value.value().get_type()
)
})
})
.transpose()
}
fn get_value<T: UnpackCloned>(module: &FrozenModule, name: &str) -> anyhow::Result<T> {
let value = module
.get_option(name)?
.ok_or_else(|| anyhow::anyhow!("`{name}` is required"))?;
T::unpack_cloned(value.value()).ok_or_else(|| {
anyhow::anyhow!(
"`{name}` should be of type `{}` but got `{}`",
T::starlark_type_repr(),
value.value().get_type()
)
})
}
fn get_frozen_callable<P: StarlarkCallableParamSpec>(
module: &FrozenModule,
name: &str,
) -> anyhow::Result<Option<FrozenStarlarkCallable<P>>> {
let Some(value) = module.get_option(name)? else {
return Ok(None);
};
let callable = StarlarkCallable::unpack_value(value.value())
.map_err(|err| anyhow::anyhow!("{err}"))?
.ok_or_else(|| {
anyhow::anyhow!(
"`{name}` should be callable but got `{}`",
value.value().get_type()
)
})?;
callable
.unpack_frozen()
.ok_or_else(|| anyhow::anyhow!("`{name}` was callable but not frozen"))
.map(Some)
}
+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