Skip to content

RFC 0005 ยท Package Set and Images

Draft RFC 0005 Scope Distribution

Field Value
Status Draft, open for comment
Supersedes None
Components PyBonsai (functions shared between files), Lattice (package sets, builtin:closure, store registration), the package set, images, the public binary cache

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.

1. Summary

The package set is a directory of PyBonsai files in the PyrixOS repository. Lattice evaluates its files in name order, and each file sees the names the files before it define, so the set is split into readable layers without an import statement. A system description sees the names of the whole set. Every package is built with the stage tools of RFC 0002 through one function, build, which runs the usual configure, build and install steps in the sandbox. PyrixOS 1.0 ships the packages needed to boot, log in, reach the network, edit files and run Lattice itself. Images are ordinary derivations: a disk image holds one system and its store, and the installer image is a disk image whose system installs another one. Continuous integration builds both and boots them in a virtual machine, and a public binary cache serves every path the package set builds.

2. Motivation

RFC 0004 lets a description name bash or linux_6_12, but says only that these names come from "the PyrixOS package set". PyBonsai has no imports, by design (RFC 0001 section 7), so a description cannot load a file of packages, and one file holding the whole distribution would be hard to review. The bootstrap package set already shows the shape of a package description; it needs a common way to build a package, a way to share that across files, and a way to turn a system into something a machine can boot.

3. Goals and non-goals

Goals Non-goals
Split the package set into files without adding imports to PyBonsai A general module or overlay system
Build every package from source with the stage tools, through one function Packages for desktops, graphics or sound in 1.0
Ship a base system that boots, logs in, networks and runs Lattice Architectures other than x86_64, which RFC 0006 adds
Build bootable disk and installer images as derivations, in the sandbox ISO images, which RFC 0006 adds; the installer is a raw disk image for USB sticks and virtual machines
Test the images by booting them in continuous integration Testing on physical machines in continuous integration; one physical machine is tested by hand for each release
Serve the whole package set from a signed public cache A cache that accepts uploads from users

4. Terminology

Package set
The directory pkgs/ of the PyrixOS repository, and the names its files define.
Layer
One file of the package set. A layer sees the names of the layers before it.
Stage tools
As in RFC 0002: the stage 3 compiler, linker, C library, shell and core utilities.
Closure
A store path and every store path it refers to at run time, directly or indirectly (RFC 0001 section 11).

5. The package set

5.1 Layers

The package set is a directory. Each file whose name ends in .pyb is a layer, and layers are evaluated in the order of their names, compared as bytes. The names start with two digits so that the order is plain:

Layer Contents
00-bootstrap.pyb The seed and stages 1 to 3 of RFC 0002, unchanged
10-stdenv.pyb build, meson_build, cmake_build and the other functions every later layer uses (section 5.3)
20-base.pyb Libraries and command line programs
30-toolchains.pyb Python and the build tools later packages need, such as Meson and Ninja, and Rust with the RUST option
40-system.pyb Linux, systemd, time zone data and initrd
50-python.pyb The Python packages of Lattice, all pure Python
90-images.pyb disk_image and default_file_systems (section 8)
95-systems.pyb pyrixos_system, the default system, pyrixos-install and the installer's system (section 8.3)

The images themselves, pkgs/systems/default.pyb for the test image and pkgs/systems/installer.pyb for the installer, are descriptions evaluated with the package set and are not layers.

A layer is evaluated with the Lattice standard library and every top-level name of the layers before it, including functions. A name a layer defines MUST NOT repeat a name of an earlier layer or of the standard library; evaluation fails and names both places. A name that starts with _ is private to its layer and is not passed on. Each layer is evaluated once per evaluation of the set, so a package defined in 20-base.pyb is one derivation, whichever later layer uses it.

PyBonsai 1.2 makes this possible: a host name may hold a function defined by an earlier evaluation, and an error inside that function names the file and line where the function is defined. The function keeps its own closure, so a layer can call a function of an earlier layer but cannot reach names the earlier layer did not define.

5.2 Reaching the package set

The setting system.package_set names the package set directory, by default /etc/pyrixos/pkgs. When it exists, Lattice evaluates the set before a system description and gives the description every public name of the set, as if they were standard library names. lattice eval, lattice build and the other commands that take a file accept --package-set DIR to do the same for any file. As with config.pyb (RFC 0004 section 5.2), a client of the daemon reads the layers and sends their text, and the daemon never reads a file named by its caller. Since Lattice 0.13 the layers see the same config as the file evaluated with them, which is how the Trellis option ARCH selects the target architecture (RFC 0006 section 4).

The package set is versioned with the PyrixOS repository. A machine updates it by checking out a newer revision into /etc/pyrixos/pkgs; the installer writes the revision it was built from. Because every package is a derivation, a system pins the whole package set: two machines with the same revision and the same description get the same system store path.

5.3 The standard environment

10-stdenv.pyb defines the functions that build packages, and later layers add functions for the languages they bring. All of them call derivation with the stage 3 shell as the builder, so they add nothing to the standard library.

Function Builds
build(name, version, src, inputs, configure_flags, make_flags, phases, outputs, patches, env) A package with the steps unpack, patch, configure, build, check, install and fixup. Each step is a shell fragment with a default for the GNU build system; phases replaces any of them. The default check step is empty, because many test suites need a network or a real /dev; a package turns its tests on through phases. patches lists patch files applied with patch -p1, and env adds variables to the build.
meson_build(...), cmake_build(...) The same, with the configure and build steps of Meson and Ninja or of CMake
python_package(name, version, src, inputs) A Python package from its source distribution, built into a wheel with pip without network access and installed into $out; defined in 30-toolchains.pyb, after Python
python_modules(name, version, src, modules, inputs) A pure Python package whose build backend is not in the package set, such as Hatchling: its module directories are copied and its metadata written as pip would; defined in 50-python.pyb
cargo_vendor(name, crates) The crates of a Cargo.lock, each fetched by checksum, unpacked into one directory with the checksum files Cargo checks; defined in 50-python.pyb
python_rust(name, version, src, crates, inputs, ...) A Python package with a PyO3 extension module: Cargo builds the library offline from cargo_vendor, and the result is installed with the package's Python sources

The fixup step removes debugging information from programs and libraries, or moves it into a debug output when the package declares one, and keeps the symbol tables. It deletes Libtool .la files, rewrites the #! lines of scripts to name programs by store path, and fails the build if an output refers to a path of the seed or of stages 1 and 2, the same check stage 3 makes (RFC 0002 section 6). Programs are linked against the stage 3 C library, whose loader they name by store path. Their run path holds $out/lib and the lib directory of every input that has shared libraries, so they never read /lib or /usr/lib.

Programs are installed into bin, also those a package would put in sbin: the GNU, Meson and CMake configure steps set the sbin directory to bin when the build system offers the option. The profile of a system then has a single directory of programs, and /bin and /usr/bin of an image point at it (section 8.2).

Some builds need a UTF-8 locale, among them the Configure script of Perl. The fragment UTF8_LOCALE creates the C.UTF-8 locale with localedef inside the build directory and points LOCPATH at it, so no locale is taken from the host.

GNU sources are fetched from the mirror mirrors.kernel.org/gnu, which serves the same files as ftp.gnu.org; the digest, not the host, identifies the file.

Every source is fetched with fetch_url and its SHA-256 digest. A Rust crate is fetched from crates.io with the checksum its Cargo.lock records, which is the SHA-256 digest of the .crate file. The script scripts/cargo-crates.py turns a Cargo.lock into the PyBonsai list of crates, and refuses one with a source other than crates.io, so that the list is reviewed as source and not read at evaluation time.

6. Packages of PyrixOS 1.0

Group Packages
Toolchain GCC, Binutils, glibc and Make from stage 3; pkg-config, Autoconf, Automake, Libtool, Bison, Flex, gperf, Meson, Ninja, CMake
Core Bash, coreutils, util-linux, findutils, diffutils, grep, sed, gawk, tar, gzip, xz, bzip2, zstd, less, file, which, procps-ng, psmisc, kmod, shadow without PAM, e2fsprogs, dosfstools, mtools; the programs stage 3 already builds, such as coreutils, sed and tar, are used as they are and form the list CORE_TOOLS
Libraries zlib, OpenSSL, libffi, libxcrypt, libcap, libseccomp, ncurses, readline, expat, SQLite, libxml2, acl, attr
System Linux 6.12 LTS, linux-firmware for common network adapters, systemd with systemd-boot, networkd, resolved and logind, the D-Bus reference daemon, the glibc locales C.UTF-8 and en_US.UTF-8, initrd (section 7.2)
Network iproute2, iputils, OpenSSH, curl, ca-certificates, nftables
Languages Python 3.12 with pip, Perl for the builds that need it; Rust from mrustc with the RUST option (section 6.1)
Lattice PyBonsai, Cloche, Trellis and Lattice with their Python dependencies, Espalier and pyrixos-ed25519
Editors nano, vim

20-base.pyb holds the packages that build with the stage 3 tools and Perl. Packages built with Meson, such as iputils and systemd, are defined in 40-system.pyb, after 30-toolchains.pyb provides Meson and Ninja.

Every Python package of the system is pure Python. Lattice, Cloche and Trellis define their data models with Espalier (pyrixos-espalier), which implements the part of the Pydantic 2 interface they use, and Lattice signs with pyrixos-ed25519, an implementation of Ed25519 after RFC 8032. Both are PyrixOS packages published on PyPI. Pydantic and cryptography, which the components used before, have parts written in Rust, so building them would bring the whole Rust chain of section 6.1 into the system. A package whose build backend is not in the set, such as Hatchling, can still be installed by python_modules, which copies its modules with the metadata a wheel would have.

6.1 Rust

No package of a system needs Rust, so the chain is built only when the Trellis option RUST is set; it is there for package sets layered on this one that need a Rust program or a Rust extension, which python_rust builds. RFC 0002 builds languages other than C and C++ with the stage tools, so PyrixOS builds Rust without a Rust binary: mrustc, a Rust compiler written in C++, is built with the stage 3 GCC. mrustc 0.12 supports Rust 1.90, the version the package set uses, so the chain has two steps: mrustc compiles rustc 1.90.0 and Cargo, and that rustc builds the standard library, rustc and Cargo again, against an LLVM built from source. The first step is split into two derivations, rustc_mrustc and rust_mrustc, so that a failure while building Cargo does not discard the rustc that took longest to compile. Only the last derivation, rust, is used by packages. The chain takes several hours to build, and the public cache holds every step for x86_64.

The compiler wrapper of stage 3 gives a program a run path only to the libraries of GCC and glibc, and the Rust compilers link through it. Every other library a Rust program uses is therefore linked statically: libgit2 is built static, and OpenSSL and zlib are linked static through OPENSSL_STATIC and LIBZ_SYS_STATIC. A PyO3 extension module built by python_rust passes the run paths of its inputs to the linker with -C link-arg, so it may link shared libraries such as OpenSSL.

7. Kernel and boot

7.1 Linux

The kernel is Linux 6.12, the long-term release whose source stage 1 already fetches for its headers. Its configuration starts from x86_64_defconfig and adds fragments, strings in 40-system.pyb, one per concern: base (systemd's requirements, cgroup version 2, namespaces, seccomp, EFI stub), storage (NVMe, virtio, ext4, vfat, xfs), network (common Ethernet and wireless adapters, nftables) and virt (KVM and virtio for the microVM backend). make olddefconfig merges them, and the build fails if an option a fragment sets is missing from the result, so a fragment cannot be silently dropped. Modules are installed into the kernel's output, which kmod and the slots of RFC 0001 section 13 find by store path.

Kbuild links its host programs, such as objtool and sorttable, with HOSTLDFLAGS, which receives the run paths of the inputs as old-style DT_RPATH entries. glibc loads libgcc_s.so.1 itself when a thread exits, and only DT_RPATH of the program applies to that load.

7.2 Initial RAM disk

initrd(kernel, modules) builds a newc archive with a static BusyBox and a short init script, written by the kernel's gen_init_cpio with fixed owners and times. The script loads modules and their dependencies from the kernel's output, waits for the device of the file system mounted at /, which the kernel command line names with root= (RFC 0004 section 9.1), and resolves LABEL= and UUID= with BusyBox findfs, so no udev is needed. It mounts the device at /sysroot and runs switch_root to the init= program of the system. The drivers for virtio, NVMe, SATA, ext4 and vfat are built into the kernel, so the default system loads no modules. Mounting file systems marked needed_for_boot from the initial RAM disk is deferred; systemd mounts them after switch_root. BusyBox is built from the source that the seed rebuild already uses (RFC 0002 section 8). A systemd-based initial RAM disk is deferred.

7.3 The running system

Some programs of a PyrixOS system search fixed directories, and the package set gives them what they look for:

Need Answer
systemd reads sysusers.d, tmpfiles.d, udev rules, presets and os-release only from /etc, /run, /usr/local/lib and /usr/lib The image links /usr/lib to /run/current-system/sw/lib, so the files of the system's profile are found; the etc file os_release writes /etc/os-release
glibc reads compiled locales from its own lib/locale, which is empty, and has no built-in C.UTF-8 The package glibc_locales compiles C.UTF-8 and en_US.UTF-8 with localedef, and LOCPATH names their directory in the profile, in systemd's default environment
Readline 8.2 crashes when the locale named by LANG is missing, a bug fixed by its first upstream patch Bash is built with the Readline it bundles, which carries the fix; the patches for the shared Readline wait for the next rebuild of Python and the toolchains that depend on it
The D-Bus daemon reads its service and policy files from its own prefix The etc file dbus_config writes /etc/dbus-1/system-local.conf, which adds the share/dbus-1 directories of the profile, so the policies of systemd's services are found

Every system PyrixOS ships is made by pyrixos_system of 95-systems.pyb and holds CORE_TOOLS, the base packages, systemd, D-Bus, the time zones, the locales and Lattice. The test image of pkgs/systems/default.pyb adds a serial console on which root logs in without a password. It boots in QEMU to multi-user.target with every unit started, and lattice systems lists generation 1 as current and booted.

8. Images

8.1 Closures

An image must hold the closure of its system, which only Lattice knows, since references are found after a build (RFC 0001 section 11). The standard library function closure(drv) declares a derivation with the builder builtin:closure. Lattice builds drv first, then writes two files outside the sandbox, as it does for builtin:unpack:

File Content
paths Every store path of the closure of the outputs of drv, one per line, sorted
registration.json For each of those paths, its references, content hash and size, in the format lattice export-registration writes

The output depends only on drv and on the contents of its closure, so it is reproducible whenever the closure is.

8.2 Disk images

disk_image(system, size_mib, label, esp_label) builds a raw disk image with a GUID partition table, an EFI system partition of 512 MiB and an ext4 root partition, without loop devices or root privileges, so it builds in the sandbox:

  1. The root file system is laid out in a directory: the closure of system under lattice/store, registration.json under lattice/var, the system profile with system as generation 1, the empty directories the system mounts over, /bin and /usr/bin as links to /run/current-system/sw/bin, so that #!/bin/sh and #!/usr/bin/env work, and /usr/lib as a link to /run/current-system/sw/lib (section 7.3). Writing the package set to /etc/pyrixos/pkgs is deferred to the installer (section 8.3).
  2. mkfs.ext4 -d turns the directory into a file system image with fixed timestamps, a fixed UUID derived from the system's hash and a fixed hash seed.
  3. The EFI system partition is made with mkfs.vfat --invariant and filled with mmd and mcopy in sorted order: systemd-boot as EFI/BOOT/BOOTX64.EFI, and the kernel, initial RAM disk and loader entry that RFC 0004 section 9.1 would write.
  4. sfdisk writes the partition table, with identifiers also derived from the system's hash, into the image file, and the two file systems are copied into place.

The system's description must mount the root file system by LABEL= the image's label and the EFI system partition by LABEL=ESP at /boot; default_file_systems(label) returns those two mounts. scripts/boot-test.sh IMAGE boots an image in QEMU with OVMF, on a temporary overlay, and checks the serial console.

At the first boot, lattice activate-boot finds registration.json and an empty store database, registers every path and the root of generation 1, and removes the file. From then on the machine is an ordinary PyrixOS machine, and lattice switch adds generation 2.

The image of the same system and package set is the same file on every machine. lattice build --check tests this for each release.

8.3 Installer image

The installer image, pkgs/systems/installer.pyb, is a disk image of the system installer_system, which logs root in on the first console and the serial console. Its root file system is labelled pyrixos-inst and its EFI system partition INSTALLER, and it mounts only its root, so the labels pyrixos and ESP of the disk it installs to stay free while both are attached. Its store holds the closure of default_system as well as its own, because the command pyrixos-install names that system, so it installs without a network. pyrixos-install [--yes] [--password-file FILE] DISK:

  1. Refuses a disk that is not a whole disk, that holds the running system, that has a mounted file system or that is smaller than 4 GiB, and asks for the root password twice unless --password-file gives it.
  2. Asks for confirmation unless --yes is given, then partitions DISK with the layout of section 8.2 and makes the file systems, labelled ESP and pyrixos.
  3. Lays out the root file system as disk_image does: the closure of default_system is copied from the installer's store, and lattice export-registration writes its registration.json.
  4. Writes /etc/shadow with root's password hash, made by openssl passwd -6. Activation keeps an account it did not create (RFC 0004 section 8.2), so the first boot keeps the password.
  5. Writes the boot loader entry of generation 1 and systemd-boot as EFI/BOOT/BOOTX64.EFI, the path UEFI firmware boots without a boot variable.

The first boot of the installed disk then registers the store, as for a disk image. scripts/install-test.sh boots the installer image in QEMU with an empty second disk, installs to it, and boots the installed disk alone, where root logs in with the password the installer set; both boots must reach the state running with no failed unit.

Choosing the host name, users and packages during installation, copying the package set to /etc/pyrixos, and installing a system the installer does not hold, by building it or fetching it from the public cache, are deferred to a later release. An installed machine changes its system with lattice switch and a description of its own.

9. Continuous integration and the public cache

The package set is built by a builder machine run by the project, which pushes every path to the public cache (RFC 0001 section 14) with the key cache.pyrixos-1. The cache is a directory published over HTTPS; its URL and public key are written into the default settings of the images. The builder records SLSA provenance for every path, and the images set require_provenance = true.

Continuous integration of the PyrixOS repository evaluates the whole package set on every change, and for every change to pkgs/ builds the disk and installer images with the public cache as a substituter, so only changed paths are built. It then boots each image in QEMU with OVMF, without a network for the disk image, and passes when the system reaches multi-user.target, a login works on the serial console and, for the installer image, pyrixos-install --yes installs to a second virtual disk that boots in turn. A change that builds something the cache does not hold and that takes longer than the time limit of the CI runner fails, and is built by the builder machine first.

10. Security considerations

A layer is an ordinary PyBonsai file and is evaluated with the same rules as any other (RFC 0001 section 7). Sharing functions between layers gives a later layer no new capability: a function runs in the scope it was defined in, and the standard library is the same for every layer.

The package set is trusted code for the machine that uses it, in the same way a description is, because it decides what the system runs. It is reviewed in the PyrixOS repository, and /etc/pyrixos/pkgs is writable only by root.

registration.json lets the first boot register paths without checking their signatures, because the image itself was the unit of trust. Images are signed with the release key, and their digests are published with each release. lattice activate-boot MUST check that every path in registration.json has the content hash it lists before it registers the path.

With the RUST option, the Rust compiler is built from mrustc rather than downloaded, so the trust placed in the binary seed of RFC 0002 is the only binary trust in the distribution.

The test image and the installer image log root in without a password, on the serial console and, for the installer, on the first console as well; they are for virtual machines and for installation, not for a machine that stays in use. The system pyrixos-install writes has no automatic login, and its root password is stored only as a SHA-512 crypt hash in /etc/shadow, with mode 0600. The password given with --password-file is read from that file, so it does not appear in the process list or in the shell history.

11. Alternatives considered

Imports in PyBonsai
An import statement for .pyb files would make the order of evaluation depend on what files ask for, and would let a file reach any other file. Layers keep a fixed, visible order and need only one change to PyBonsai.
One file for the whole package set
Simple to evaluate, but a single file of several thousand lines is hard to review, and every change touches the same file.
Downloading a Rust binary
Quicker by hours, but it adds a second binary that the distribution trusts. The mrustc chain is built once and served from the cache.
An ISO installer
An ISO needs xorriso and a second boot path for El Torito. A raw disk image boots from a USB stick on every UEFI machine and in every virtual machine, and needs no extra tools. ISO images may follow in 1.x.
A systemd initial RAM disk
More capable, but larger, and it ties the first stage of boot to the whole of systemd. A BusyBox init of a few dozen lines is enough to mount / in 1.0.

12. Resolved and deferred questions

Package versions
Resolved: the package set holds one version of each package. A second version, such as a second Linux series, is a separate name such as linux_6_6.
Secure Boot
Deferred, as in RFC 0004.
Graphics and desktops
Deferred to 1.x.
aarch64
Deferred; it needs its own seed (RFC 0002).