Phase 213 — first musl systemd userspace payload
At a glance
| Field | Value |
|---|---|
| Phase family | Phase 2 — bootable image |
| Run command | make phase 213 |
| Underlying make target/script | vm/phase2/build-image-skeleton.sh --systemd-payload |
| Runs on | host, then rootful image mount/copy work |
| Main proof/artifact | Stages a first musl-targeted systemd userspace payload so /usr/lib/systemd/systemd exists in the ONIX image. |
Phase 213 answers the exact failure from Phase 212.
Phase 212 proved the boot chain reached the real root filesystem. Then the initramfs tried to hand control to:
/usr/lib/systemd/systemd
and failed because that path did not exist yet.
So Phase 213 is not about the bootloader. It is not about the kernel. It is not about partitions. Those layers already reached their next checkpoint.
Phase 213 is about the next missing layer:
systemd userspace
What gets staged
The immediate required path is:
/usr/lib/systemd/systemd
But systemd is not one standalone static binary. It has helper binaries, unit files, libraries, and runtime expectations.
So the first payload also needs at least:
/usr/lib/systemd/systemd-udevd
/usr/lib/systemd/system/multi-user.target
/usr/bin/systemctl
/usr/bin/journalctl
That is why this phase stages a runtime closure, not just one file.
One layout detail matters here: the pinned Nix pkgsMusl.systemd output keeps
the stock unit files under:
/nix/store/...-systemd-259.3/example/systemd/system
ONIX exposes that directory at the conventional runtime path:
/usr/lib/systemd/system
so systemd.unit=multi-user.target can resolve the target through the normal
systemd unit search path.
For this particular Nix-built systemd, there is another important path. The binary has a compiled unit search directory under its own output:
/nix/store/...-systemd-259.3/lib/systemd/system
The packaged default units live under example/systemd/system, so Phase 213
also links the compiled path inside the image copy:
/nix/store/...-systemd-259.3/lib/systemd/system
-> /nix/store/...-systemd-259.3/example/systemd/system
Without this, systemd can start but then fails with:
Unit multi-user.target not found.
Unit rescue.target not found.
Why this phase uses pinned pkgsMusl.systemd
The final ONIX shape should be:
onix-systemd.stone
That package should be built and owned by ONIX.
But we are not there yet. We still need to learn whether a musl systemd payload can actually cross the kernel/initramfs handoff in our image.
So Phase 213 uses a slim override of the pinned Nix package:
pkgsMusl.systemd
as a bootstrap/probe payload.
That means:
- it must be musl-targeted
- it must come from the pinned
flake.lock - it is copied into the image intentionally and visibly
- it is documented as a temporary probe layer
- it does not replace the future
onix-systemdstone
The override disables features that are not needed for the first PID 1 handoff:
documentation
BPF framework
TPM support
systemd-boot/UKI building
remote/import/homed/container extras
polkit/PAM/login-manager extras
networkd/resolved/timesyncd extras
compression/coredump/sysupdate/repart extras
This avoids dragging in a very large build graph for features we are not testing yet. The first question is simply:
can a musl systemd binary become PID 1 in our image?
Later phases can add features back intentionally.
One feature is intentionally not optional for this probe:
libmount
systemd uses libmount while setting up early API filesystems such as /run and
cgroup mounts. Without libmount support, systemd can execute as PID 1 but fails
almost immediately with messages like:
Failed to mangle mount options ... Not supported
Failed to mount API filesystems.
So Phase 213 forces the Meson option:
-Dlibmount=enabled
and verifies the resulting shared systemd library contains libmount.so.1
support instead of the fallback message:
libmount support not compiled in
There is one more musl-specific detail.
systemd lazy-loads libmount with:
dlopen("libmount.so.1")
For direct dependencies, Nix-style absolute RUNPATHs usually work well. Lazy
dlopen is more subtle, because the dynamic loader has to discover a library by
soname at runtime.
With a Nix-store musl loader, the loader path file is not simply:
/etc/ld-musl-x86_64.path
The loader path is derived from the interpreter prefix. For our payload the interpreter looks like:
/nix/store/...-musl-1.2.5/lib/ld-musl-x86_64.so.1
So the useful path file inside the image is:
/nix/store/...-musl-1.2.5/etc/ld-musl-x86_64.path
Phase 213 writes that file inside the image copy of the musl store path. It contains library directories from the copied runtime closure, including the util-linux directory that provides:
libmount.so.1
This does not mutate the host Nix store. It only changes the copy of the store paths inside the ONIX disk image.
This keeps the learning step small:
first prove systemd userspace can start
then package it properly as ONIX-owned stone content
Why copying a Nix closure is different from copying one binary
A dynamically linked Linux binary records the path to its program interpreter and shared libraries.
For a Nix-built musl binary, those paths usually look like:
/nix/store/...-musl/lib/ld-musl-x86_64.so.1
/nix/store/...-libcap.../lib/libcap.so...
/nix/store/...-systemd-259.3/lib/systemd/systemd
If we copied only:
/usr/lib/systemd/systemd
the kernel would find the file but the dynamic loader or libraries could still be missing.
So Phase 213 asks Nix for the runtime closure:
nix path-info -r <pkgsMusl.systemd output>
and copies those /nix/store/... paths into the image.
Why the script chooses the runtime output explicitly
Nix packages can have more than one output.
For example, a package may produce:
/nix/store/...-systemd-259.3
/nix/store/...-systemd-259.3-man
The -man output is documentation. It is not the runtime payload. It does not
contain:
lib/systemd/systemd
So Phase 213 does not blindly trust the first path printed by nix build.
Instead it checks each printed output path and selects the one that actually has
the executable:
<output>/lib/systemd/systemd
That matters because the image needs the runtime output, not manuals or development extras.
Why the closure is copied twice
The early boot moment is subtle.
The kernel/initramfs mounts the ONIX root filesystem first. At that moment,
before systemd has processed /etc/fstab, the path:
/nix/store
must already work from the root partition, because PID 1 may need libraries from there immediately.
Later, ONIX wants Nix to live persistently under:
/persist/nix
and bind-mount that onto:
/nix
So Phase 213 copies the systemd runtime closure into both places:
root partition: /nix/store/...
persist partition: /persist/nix/store/...
That gives PID 1 access before fstab, and keeps the same paths available after
the future /persist/nix -> /nix bind mount.
This is not the final storage model for every package. It is the first boot probe strategy for a Nix-built musl systemd payload.
What the image builder writes
make phase 213 updates the existing image:
artifacts/onix-image/onix.raw
It mounts:
ONIX-ESP
ONIX-BOOT
onix-root
ONIX-PERSIST
Then it stages:
/nix/store/<systemd runtime closure>
/persist/nix/store/<systemd runtime closure>
/usr/lib/systemd/systemd -> /nix/store/...-systemd-259.3/lib/systemd/systemd
/usr/lib/systemd/systemd-udevd -> /nix/store/...-systemd-259.3/lib/systemd/systemd-udevd
/usr/lib/systemd/system -> /nix/store/...-systemd-259.3/example/systemd/system
/nix/store/...-systemd-259.3/lib/systemd/system -> /nix/store/...-systemd-259.3/example/systemd/system
/usr/bin/systemctl -> /nix/store/...-systemd-259.3/bin/systemctl
/usr/bin/journalctl -> /nix/store/...-systemd-259.3/bin/journalctl
/nix/store/...-musl-1.2.5/etc/ld-musl-x86_64.path
/etc/machine-id
/usr/share/onix/bootstrap/systemd-payload.txt
/boot/ONIX/README.phase213
It also writes a new boot entry:
/boot/loader/entries/onix-phase-213.conf
and makes it the default in:
/efi/loader/loader.conf
Why /etc/machine-id is empty
/etc/machine-id is the machine identity file.
It must not be baked with the same non-empty ID into every image forever.
Phase 213 creates the file as an empty placeholder:
/etc/machine-id
That lets later boot policy decide whether systemd should initialize it on first boot or whether image assembly should seed it for a specific machine.
The important part in this phase is:
the path exists, but no shared permanent identity is baked in
What Phase 213 proves
make phase 213 proves:
- the pinned
pkgsMusl.systemdpayload can be built or fetched - the payload contains
lib/systemd/systemd - the payload contains
lib/systemd/systemd-udevd - the payload is built with libmount support for early API filesystem setup
- the payload contains
example/systemd/system/multi-user.target - the Nix runtime closure can be copied into the image
- the image copy of the musl loader has an
ld-musl-x86_64.pathfile that can findlibmount.so.1 /usr/lib/systemd/systemdexists from the ONIX root view/usr/lib/systemd/system/multi-user.targetexists from the ONIX root view- the compiled Nix store unit path also resolves
multi-user.target - the persist partition also has the same closure for the future
/nixbind - the bootloader default now points at
onix-phase-213.conf
What Phase 213 does not prove
Phase 213 does not prove:
systemd starts successfully as PID 1
udev works
journald works
multi-user.target reaches login
networking works
the machine is usable
Those are exactly what the next boot probe should tell us.
What comes after 213?
Run the boot probe again:
make phase 212
or watch it directly:
ATTACHED=1 make phase 212
This time the expected question changes from:
can the kernel find /usr/lib/systemd/systemd?
to:
what is the next missing userspace dependency after systemd starts?