Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ An ordinary machine is trusted as you: it can read and write your home, includin

## Is it for you?

nsl needs an x86-64 Linux host with KVM, systemd-vmspawn, QEMU with UEFI firmware, virtiofsd and membership in the `kvm` group; Waypipe adds desktop windows. `nsl doctor` checks each requirement. [Install →](https://frostyard.github.io/nsl/getting-started/install/)
nsl needs an x86-64 Linux host with KVM, systemd-vmspawn from systemd 259 or newer (as in Fedora 44 and Ubuntu 26.04 LTS), QEMU with UEFI firmware, virtiofsd and membership in the `kvm` group; Waypipe adds desktop windows. `nsl doctor` checks each requirement. [Install →](https://frostyard.github.io/nsl/getting-started/install/)

It is **pre-release**. v0.4.0 was the first release of the current design; v0.3.0 and earlier are a retired prototype. The tested host is Snow Linux 13 with systemd 261.2, QEMU 10.0.13, virtiofsd 1.13.2 and GNOME Wayland. Windows are Wayland only, host file edits produce no inotify events in machines, and idle machines stop even when a service inside them is busy. [Limits and troubleshooting →](https://frostyard.github.io/nsl/reference/limits/)

Expand Down
12 changes: 11 additions & 1 deletion docs/adr/0005-vmspawn-and-nspawn-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@

The [vmspawn comparison](../plans/vmspawn-comparison.md) established a complete workflow on Snow with systemd-vmspawn and QEMU: rootless KVM and vsock, commands, localhost forwarding and Waypipe. It passed 20 restart trials, started faster than the Lima prototype, and returned freed anonymous memory to the host.

vmspawn's flags change between systemd releases. qcow2 disks (`--image-format`, a format in `--extra-drive`) and `--user` arrived in 260; `--firmware=describe` and a disk type in `--extra-drive` arrived in 261. Fedora 44 and Ubuntu 26.04 LTS ship systemd 259 and Debian 13 ships 257; no stable distribution ships 260 ([#54](https://github.com/frostyard/nsl/issues/54)).

## Decision

Run every nsl VM with systemd-vmspawn and QEMU under a systemd user unit. Lima remains only an image-build tool.

- **Launch** through the rootless named-device-descriptor path: open `/dev/kvm` and `/dev/vhost-vsock` under the `kvm` group, then run vmspawn in an unprivileged user namespace that maps the user to themselves.
- **Host floor:** systemd 259. Launch passes only flags that vmspawn 259 accepts. It omits `--user`: the namespace keeps the user's UID, so every release from 259 on already runs vmspawn in the user's service manager. `doctor` and launch fail on an older vmspawn and name both versions.
- **Disks** are raw files, because vmspawn 259 attaches no other format. The root is a copy of the cached VM image, cloned where the filesystem shares extents, otherwise copied without its holes and zero blocks, then extended to 16 GiB. The data disk is a sparse file that grows by extension. vmspawn names the raw format to QEMU, so nothing a guest writes to a disk can make QEMU open another file.
- **Group membership:** doctor and launch check the host account database before invoking `sg` or `newgrp`, so a non-member gets instructions to join `kvm` instead of a password prompt. Use host `getent` and `id` for NSS lookups: release binaries disable cgo, so Go's `os/user` would read only local files. Query the named account's groups so membership added since login takes effect immediately.
- **Identity:** supply the VM's ID, the host UID and primary GID, and a unique public SSH key through a systemd credential. The VM binds that identity on first boot and rejects a different one later. Private keys stay on the host.
- **Uniqueness:** give each VM its own unit names and vsock CID, and control a unit only after its description names the VM's ID.
Expand All @@ -22,7 +26,10 @@ Run every nsl VM with systemd-vmspawn and QEMU under a systemd user unit. Lima r
## Consequences

- nsl owns lifecycle, readiness and forwarding behavior, which need failure and multi-VM tests.
- Host systemd-vmspawn, vhost-vsock access through `kvm`, and user namespaces are prerequisites. The tested baseline is systemd 261 on x86-64.
- Host systemd-vmspawn from systemd 259 or newer, vhost-vsock access through `kvm`, and user namespaces are prerequisites. Tested hosts run systemd 261 and 262 on x86-64. Each new floor needs an acceptance run on a host that ships it.
- `doctor` cannot ask vmspawn 259 which firmware it would select. It lists vmspawn's firmware descriptors and applies the selection rules of systemd 259 to 262 itself.
- Without shared extents, as on ext4, each fresh root writes the image's non-zero blocks, about 1 GB of the 2.9 GB image: at creation, after `update` and on `recover`, for the shared VM and each isolated one. On btrfs and XFS the copy shares the image's blocks.
- `qemu-img` is no longer a prerequisite. A raw disk has no metadata to verify, so the data disk check is its ownership, type and size.
- Generic images configure the host's numeric UID and GID at boot without rebuilding.
- Host file changes do not produce inotify events in the guest; watchers need polling or guest-native files.

Expand All @@ -31,6 +38,9 @@ Run every nsl VM with systemd-vmspawn and QEMU under a systemd user unit. Lima r
- **Keep Lima as the runtime:** mature integration, but the measured vmspawn path justified owning the missing pieces.
- **libvirt or Incus:** capable VM managers that add a daemon and their own image and network models.
- **Another implementation language:** no demonstrated need; Go manages the required tools and state.
- **Require systemd 261:** keeps qcow2 overlays and `--firmware=describe`, but excludes Fedora 44 and Ubuntu 26.04 LTS. Requiring 260 gains no distribution.
- **qcow2 on newer systemd, raw on 259:** two disk formats and two launch paths to test, for a saving only on hosts without shared extents.
- **Serve qcow2 to vmspawn 259 as a raw file through `qemu-storage-daemon`'s FUSE export:** adds a daemon and FUSE to every disk access.

## References

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0008-offline-storage-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Lifecycle commands that waited on a lock reject a replacement ID.
`resize [NAME] --disk GiB`:

- grows the stopped VM's data disk; shrinking is forbidden;
- records the pending target before invoking `qemu-img`, then syncs and verifies the disk before committing the new size;
- records the pending target before extending the disk file, then syncs and verifies the disk before committing the new size;
- is completed by a repeated `resize` or by `recover`, and VM start refuses pending growth.

The VM grows its filesystem at the next boot ([ADR-0010](0010-explicit-guest-root-growth.md)). The CLI reports virtual capacity separately from filesystem growth.
Expand Down
16 changes: 8 additions & 8 deletions docs/design/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ nsl runs one VM per state directory, the shared VM, and one more for each isolat
flowchart LR
CLI[nsl CLI] --> Unit[VM user unit]
Unit --> VM[vmspawn + QEMU/KVM]
VMImage[Signed VM image] --> Root[Replaceable root overlay]
VMImage[Signed VM image] --> Root[Replaceable root]
Root --> VM
Data[Data disk: machines + state] --> VM
Cache[Verified image cache] -->|read-only virtiofs| VM
Expand All @@ -28,38 +28,38 @@ flowchart LR

| Path | Content |
| --- | --- |
| `vm/` | The shared VM: its record, root overlay, data disk, SSH keypair, pinned host key, SSH configuration and boot credential. |
| `vm/` | The shared VM: its record, root, data disk, SSH keypair, pinned host key, SSH configuration and boot credential. |
| `isolated/NAME/` | The same for isolated machine NAME's VM. |
| `machines/NAME.json` | A machine record: ID, trust tier, account, image build and digest. |
| `default` | The default machine's name, when there is one. |
| `removing/NAME` | A machine whose removal is in progress; the name stays reserved. |
| `images/`, `delivery/` | The verified image cache and authenticated catalogue history. |

A VM record holds the VM's ID, owner, UID and GID, the image build its root overlay came from and any pending image, the memory and CPUs in effect, and the data disk's committed and pending capacity. Runtime sockets live under `/run/user/UID/nsl/ID`, so long state paths do not lengthen socket names.
A VM record holds the VM's ID, owner, UID and GID, the image build its root came from and any pending image, the memory and CPUs in effect, and the data disk's committed and pending capacity. Runtime sockets live under `/run/user/UID/nsl/ID`, so long state paths do not lengthen socket names.

Ownership, file type and permissions are checked before any change. The VM ID yields its unit names and a stable vsock CID, and creation avoids CIDs used by other VMs in the same state directory. Launch refuses a CID that already answers. Unit descriptions must name the ID before nsl controls a unit.

A manager lock serializes image import, name allocation and the default. Per-VM and per-machine locks serialize lifecycle changes, and commands release them after readiness so sessions run concurrently. A call that waited for a lock reloads the record and rejects a changed ID. Atomic writes separate prepared state from interrupted preparation. nsl never adopts or replaces an existing name, image, disk or archive.

## The VM

The root is a qcow2 overlay on a cached, verified VM image. It holds no user state. `nsl update` records a pending image, and the next start discards the overlay and creates a new one from that image. The data disk is a separate qcow2 whose btrfs filesystem holds the machines and a state subvolume. That subvolume keeps the VM's binding, SSH host keys and machine records, so a new root keeps the pinned host key and every machine.
The root is a raw copy of a cached, verified VM image, extended to 16 GiB. The copy shares the image's extents where the filesystem can (btrfs, XFS); elsewhere it writes only the image's data, leaving its holes and zero blocks as holes. It holds no user state. `nsl update` records a pending image, and the next start discards the root and copies a new one from that image. The data disk is a separate sparse raw file whose btrfs filesystem holds the machines and a state subvolume. Both disks are raw because systemd 259's vmspawn attaches no other format ([ADR-0005](../adr/0005-vmspawn-and-nspawn-images.md)). That subvolume keeps the VM's binding, SSH host keys and machine records, so a new root keeps the pinned host key and every machine.

vmspawn passes the `nsl.vm` credential: the VM ID, role, host UID and GID, public key, autostart setting, shares and aliases. On first boot the VM formats a blank data disk and records the binding. On later boots it rejects a credential that differs, and it grows the data filesystem to the disk before readiness.

Resources come from the configuration when the VM starts, and the record keeps what is in effect. When the file differs, `config` and `list` report a pending restart.

## Launch and readiness

The user needs KVM and vhost-vsock access through the `kvm` group. Doctor and launch share a membership check: `getent group kvm` resolves the group's GID through NSS, and `id -G -- USER` returns the account's current group IDs, including its primary group. This works in static release builds and recognizes membership added since login ([ADR-0005](../adr/0005-vmspawn-and-nspawn-images.md)). Missing membership produces a command for an administrator to run; lookup failures report their cause. Neither invokes the group switch when the check fails. A fixed internal command opens the devices under that group, restores the account's primary group, and enters an unprivileged user namespace that maps the user's UID and GID to themselves. It verifies the devices and passes them to vmspawn as named file descriptors. Capabilities stay scoped to that namespace. nsl changes no host permissions, groups, packages or sudoers.
The user needs KVM and vhost-vsock access through the `kvm` group. Doctor and launch share a membership check: `getent group kvm` resolves the group's GID through NSS, and `id -G -- USER` returns the account's current group IDs, including its primary group. This works in static release builds and recognizes membership added since login ([ADR-0005](../adr/0005-vmspawn-and-nspawn-images.md)). Missing membership produces a command for an administrator to run; lookup failures report their cause. Neither invokes the group switch when the check fails. A fixed internal command opens the devices under that group, restores the account's primary group, and enters an unprivileged user namespace that maps the user's UID and GID to themselves. It verifies the devices and passes them to vmspawn as named file descriptors. Capabilities stay scoped to that namespace. Because the namespace keeps the user's UID, vmspawn runs in the user's service manager without `--user`, which systemd 259 lacks. nsl changes no host permissions, groups, packages or sudoers.

The unit `nsl-UID-vm-ID.service` runs vmspawn with the root overlay, the data disk as an extra drive, the virtiofs shares, the read-only image cache and the credential. A newly launched VM gets up to 90 seconds to become ready. Readiness is the agent's `identity` answer over authenticated vsock SSH. The host checks it for the VM's ID, UID, GID and role, and for the image descriptor's protocols and architecture, even when the unit was already running. Failures keep the disks and point to `logs` and `recover`. Forwarding starts only after readiness.
The unit `nsl-UID-vm-ID.service` runs vmspawn with the root, the data disk as an extra drive, the virtiofs shares, the read-only image cache and the credential. A newly launched VM gets up to 90 seconds to become ready. Readiness is the agent's `identity` answer over authenticated vsock SSH. The host checks it for the VM's ID, UID, GID and role, and for the image descriptor's protocols and architecture, even when the unit was already running. Failures keep the disks and point to `logs` and `recover`. Forwarding starts only after readiness.

The shared VM starts on first use. At boot it starts every machine unless `autostart` is false, or unless it was started only to create, import, export or remove a machine. It stops itself when no machine is running, and `shutdown` stops it at once. A stop asks the VM to power off, waits up to 30 seconds, then stops the owned unit.

The VM's idle monitor makes both idle decisions, so the host runs no background process. It stops a running machine after `idle_timeout` without `nsl-run-*` sessions, Waypipe clients or a `start` or `run` request. It powers the VM off 60 seconds after the last machine stopped and the last agent request ended. Every agent request holds a shared lock that the monitor takes exclusively, and keeps, before powering off. A command that arrives then sees the VM stop, and its readiness loop starts it again.

`recover` stops the owned runtime, completes pending growth, checks the data disk without repairing it, and starts the VM again from a fresh root overlay, since the root holds nothing that must survive. It keeps keys, pinned host keys and machines. It cannot rebuild deleted keys or repair a corrupt filesystem, and it is not a backup.
`recover` stops the owned runtime, completes pending growth, checks the data disk's ownership, type and capacity without repairing it, and starts the VM again from a fresh root, since the root holds nothing that must survive. It keeps keys, pinned host keys and machines. It cannot rebuild deleted keys or repair a corrupt filesystem, and it is not a backup.

## Machines

Expand Down Expand Up @@ -87,7 +87,7 @@ Machines that are not isolated see the user's home, `/run/media/USER` and `/mnt`

`remove --yes` requires a stopped machine and the manager and machine locks. It renames the record into `removing/NAME`, and the agent deletes the subvolume and VM-side record. The host record goes last, so a repeated command resumes.

`resize --disk` grows a stopped VM's data disk. It records the target before invoking `qemu-img`, then syncs and verifies the disk and commits the new capacity. `recover` or a repeated resize completes interrupted growth. The VM grows the filesystem at its next boot, and shrinking is unsupported. See [ADR-0008](../adr/0008-offline-storage-management.md).
`resize --disk` grows a stopped VM's data disk. It records the target before extending the disk file, then syncs and verifies the disk and commits the new capacity. `recover` or a repeated resize completes interrupted growth. The VM grows the filesystem at its next boot, and shrinking is unsupported. See [ADR-0008](../adr/0008-offline-storage-management.md).

## Export and import

Expand Down
31 changes: 31 additions & 0 deletions docs/plans/shared-vm-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,37 @@ and `nsl_linux_arm64.tar.gz` with `checksums.txt`, and a cask whose URLs keep
the tag and pin each archive's SHA256. The guide's URLs resolve from the first
release after this change.

**systemd 259 floor, 2026-10-08 ([#54](https://github.com/frostyard/nsl/issues/54)):**
launch now passes only flags vmspawn 259 accepts, and both VM disks are raw
([ADR-0005](../adr/0005-vmspawn-and-nspawn-images.md)). Doctor and launch
refuse an older vmspawn; doctor then checks the firmware through
`--firmware=list`. `probe-vm.py` passed all nine checks on VM image r10
(`sha256:9c3d68d7a1b963a6a482adee846b71282da1f721458f11de1db9ca295f42ea7a`,
`build/image/evidence/r10-raw-disks-probe.json`) with raw disks and without
`--user`: a 9.5 s first boot, and refusal and binding failing readiness in 9.1
and 10.6 s. Host: kernel 7.2.8+deb14-amd64, systemd 262 (262-1), QEMU 10.0.13,
virtiofsd 1.13.2. `probe-machines.py --isolated`, with Debian r5 and Fedora 44
r4 machines and an isolated Debian r5 machine
(`build/image/evidence/r10-raw-disks-machines.json`), passed every check but
one item: Fedora's tally could not reinstall `tzdata`, because the cached r4
image's version has left Fedora's repositories; its time zone link was correct.
Doctor chose `/usr/share/OVMF/OVMF_CODE_4M.fd`, as vmspawn 262's
`--firmware=describe --secure-boot=no` does. `make ci` and `make site` passed.

Root cost on the same host's NVMe btrfs, with the image in page cache: cloning
the 2.9 GB image took 130 µs. The copy used without shared extents, which
skips holes and zero blocks, took 0.34–0.39 s and allocated 965 MB; writing
every block took 0.7 s and 2.7 GB. No ext4 filesystem was available; the
Ubuntu 26.04 run below measures it.

Still required before a release documents 259 as supported: on Fedora 44
(systemd 259.9), `probe-vm.py` and `probe-machines.py --isolated` must pass,
covering virtiofs shares, the read-only image cache, `update`, `recover`,
`resize` and an isolated machine. vmspawn 259 starts virtiofsd through
`systemd-socket-activate` instead of in a namespace, which no run has covered
yet. An Ubuntu 26.04 run on ext4 should record `recover`'s time for the shared
VM and for an isolated machine.

## Requirements carried from the experiment

Each item was found by a failing check and must not regress.
Expand Down
Loading
Loading