Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Phase 213 — first musl systemd userspace payload

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 213
Underlying make target/scriptvm/phase2/build-image-skeleton.sh --systemd-payload
Runs onhost, then rootful image mount/copy work
Main proof/artifactStages 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-systemd stone

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.systemd payload 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.path file that can find libmount.so.1
  • /usr/lib/systemd/systemd exists from the ONIX root view
  • /usr/lib/systemd/system/multi-user.target exists 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 /nix bind
  • 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?