diff --git a/.github/workflows/e2e-disks.yml b/.github/workflows/e2e-disks.yml index 0cdf323..0d87ff9 100644 --- a/.github/workflows/e2e-disks.yml +++ b/.github/workflows/e2e-disks.yml @@ -9,8 +9,9 @@ # A full install-to-completion is deliberately NOT run here: a bare container has # no booted systemd, so archinstall's install phase fails on udev/D-Bus # (timedatectl, systemctl) assumptions it makes about a live ISO. Real end-to-end -# installs run in the QEMU VM harness instead (test/vm.sh / `task vm`), which -# boots a real systemd. `test/e2e/disks.sh --mode full` still works for manual +# installs run in the automated QEMU VM harness instead (`task vm-e2e`, +# test/e2e/vm/), which boots a real systemd and drives install + bootstrap + +# validation end-to-end. `test/e2e/disks.sh --mode full` still works for manual # runs on a real host/VM (`task e2e-disks-full`); it's just not container CI. name: e2e-disks diff --git a/.gitignore b/.gitignore index fe7d335..2eadb55 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,12 @@ /archwright /.vm/ +# Cached Arch live ISO for the QEMU smoke test (downloaded by `task iso`). +/.iso/ + +# Per-run work dir for the automated VM e2e harness (disks, logs, scaffold). +/.e2e/ + # Local upstream archinstall checkout, kept only as a schema reference. /archinstall/ @@ -13,5 +19,9 @@ *~ .DS_Store +# Python bytecode cache (the e2e matrix loader imports modules under test/e2e/vm) +__pycache__/ +*.pyc + # agent worktrees .claude/worktrees/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7c9b3a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,181 @@ +# Contributing to archwright + +archwright is a single static Go binary that rebuilds an Arch box from bare disks to a themed +KDE desktop from one declarative `config.yaml` (see the [README](README.md) for the user +workflow and [CLAUDE.md](CLAUDE.md) for the deep architecture + the parallel-development "wave" +playbook). This guide is the practical how-to: **add → test → commit → PR.** + +## Setup + +```sh +mise install # provisions the pinned Go + Task (see mise.toml) +go build -o archwright . # or: task build +``` + +For the VM/loopback test harnesses you also need QEMU, OVMF, etc. — see the dependency table in +[`test/e2e/vm/README.md`](test/e2e/vm/README.md#requirements). In short, on Arch: + +```sh +sudo pacman -S --needed qemu-base edk2-ovmf libarchive git curl +sudo usermod -aG kvm "$USER" # re-login afterwards +``` + +## Project layout (where things live) + +``` +main.go cobra CLI: install / bootstrap / validate / render / list-stages +internal/config/ Config struct + Validate() (go-playground/validator struct tags) +internal/configsrc/ resolve + deep-merge remote/layered --config refs and imports: +internal/archinstall/ render config.yaml -> archinstall JSON + creds (Phase A core) +internal/run/ Runner: Cmd/Shell/Chroot/Root/Try, dry-run, recorded .Plan +internal/stages/ one file per stage; self-registering ordered registry +test/e2e/disks.sh loopback (losetup) integration harness — Phase A render vs real archinstall +test/vm.sh interactive QEMU (boot ISO / installed disk by hand) +test/e2e/vm/ automated, headless QEMU e2e harness (install -> bootstrap -> validate) +docs/bugs/ open findings from the e2e harness, ready to pick up +``` + +## Adding things + +### A Phase B stage (the common case) + +A stage is a tiny struct in its own file under `internal/stages/` implementing +`Order() int`, `Name() string`, `Phase() Phase`, `Run(ctx *Context) error`, and registering +itself in `init()` via `register(...)`. Copy `internal/stages/packages.go` for the minimal +pattern. Rules: + +- `Order` is the numeric prefix (10, 20, …); keep existing ones stable so `--only ` + and `--from/--to` keep working. `task build && ./archwright list-stages` shows the order. +- **All** side effects go through `ctx.R` (the `Runner`), never `os/exec` directly — that's + what makes the stage dry-run-safe and testable. Use `Root` for privileged, `Cmd` for + unprivileged, `Shell` only when you need pipes/redirects, `Try` for best-effort, `Chroot` + for Phase A arch-chroot work. +- **Degrade to a no-op when unconfigured**, so existing configs/goldens are unaffected. +- Add a `*_test.go` that runs the stage in dry-run and asserts on the recorded `.Plan` + (self-contained config snippet — don't edit shared fixtures). +- ⚠️ Don't let a stage block on an interactive prompt during `bootstrap` (pass `-y` / + `--noninteractive` to anything that might ask) — see + [`docs/bugs/flatpak-system-remote-add-polkit-hang.md`](docs/bugs/flatpak-system-remote-add-polkit-hang.md). + +### A config option / schema field + +The `Config` struct in `internal/config/config.go` **is** the schema. Add the field with its +`yaml:` + `validate:` tags; put cross-field rules in `semanticErrors()` (not struct tags); +add a table case in `config_test.go`. If the option changes Phase A output, also update +`internal/archinstall/` and add a golden case (see below). + +### An automated e2e descriptor + +To grow VM coverage, add a `matrix/.py` + `configs/.yaml` under `test/e2e/vm/` +— **new files only**, following the descriptor contract in +[`test/e2e/vm/README.md`](test/e2e/vm/README.md). Keep configs trimmed/cheap. + +## Testing + +Work up the pyramid — fast checks first, VMs last. + +### 1. Fast checks (always, no disks) + +```sh +go build -o archwright . # task build +go test ./... # task test — validation table + per-stage dry-run command plans +go vet ./... # task vet +gofmt -l . # must print nothing +``` + +These run every stage in `--dry-run` and assert on the recorded command plan, so they verify +behavior without touching disks. `internal/archinstall` is unit-tested against fake geometry +(layout, PV↔VG `obj_id` wiring, size math). + +**Schema-shape changes** (anything that alters the rendered archinstall JSON) use **two +commits**: first the behavior-preserving refactor (goldens unchanged), then the shape change +that regenerates them: + +```sh +go test ./internal/archinstall/ -run TestRenderGolden -update # regenerate goldens +``` +Keep the diff reviewable, and remember **archinstall's JSON is not a stable API** — after an +archinstall version bump, diff its schema and update `internal/archinstall` + the pinned +`Version` together, then re-validate in a VM (see CLAUDE.md "Key gotchas"). + +### 2. Loopback integration (root, no boot) + +Proves the rendered archinstall JSON is accepted by a *real* archinstall against `losetup` +loop devices — the cheapest way to catch schema drift: + +```sh +task e2e-disks-light # archinstall --dry-run validation (fast, no network) +task e2e-disks-light LAYOUT=single-disk-lvm FS=ext4 +task e2e-disks-full # real partition/format/pacstrap, then assert layout +``` + +### 3. Manual interactive VM (`test/vm.sh`) + +Use this to **poke by hand** — try a brand-new layout before codifying it, watch a desktop +actually render, or explore a confusing failure with a live shell. It boots a *graphical* QEMU +and shares the repo in over 9p, so your freshly built binary shows up in the VM. + +```sh +cp config.example.yaml config.yaml # gitignored; set devices to /dev/vda, /dev/vdb, /dev/vdc +task build # rebuild on the host; the 9p share picks it up +task vm-fresh # boot the live ISO with clean disks +# inside the VM (repo auto-mounts at /mnt/host): +# cp /mnt/host/archwright /root/ && cp /mnt/host/config.yaml /root/ +# /root/archwright install --dry-run # inspect the rendered archinstall JSON +# /root/archwright install --yes # DESTRUCTIVE: wipes vda/vdb/vdc, installs +task vm-disk # reboot into the installed system to poke around +``` + +`task vm` is the same without wiping disks; disk sizes are env-overridable +(`DISK1=40G … task vm`). This is interactive only — for unattended pass/fail use the harness +below. + +### 4. Automated VM e2e (`task vm-e2e`) + +The full last-mile proof: boots the ISO headless, runs `install --yes`, reboots, runs +`bootstrap`, and asserts the installed system — no human interaction. This is what you run to +confirm a change works end-to-end across layouts/features. + +```sh +task vm-e2e -- lvm-multi # one descriptor +task vm-e2e -- features-min # the cheap stage-coverage bundle +task vm-e2e -- --jobs 5 # the whole matrix, 5 VMs at once +task vm-e2e-list # list descriptors +``` + +See [`test/e2e/vm/README.md`](test/e2e/vm/README.md) for how it works, the descriptor +contract, and per-run logs (`.e2e/runs//serial.log`). Known open findings it has +surfaced live in [`docs/bugs/`](docs/bugs/) — good first contributions. + +### What to run for a given change + +- Stage / config logic → **1** (and a VM run of an affected descriptor if behavior is + disk/boot-visible). +- Anything touching `internal/archinstall` / Phase A layout → **1 + 2**, then **4** for the + affected layout (CLAUDE.md's archinstall-drift rule: validate against a real run). +- New `disks.*` layout, swap, bootloader, encryption → **4** with a matching descriptor. + +## Committing + +- **Branch off `main`** — never commit straight to `main`. +- **[Conventional Commits](https://www.conventionalcommits.org/):** `feat:`, `fix(scope):`, + `refactor:`, `chore:`, `docs:`, `test:` (match the existing `git log` style, e.g. + `feat: add services stage to enable systemd units in Phase B`). +- Keep commits focused; use the two-commit split for schema-shape changes (above). +- Before pushing: `go build ./... && go vet ./... && go test ./... && gofmt -l .` (clean). +- `config.yaml` and `.vm/` / `.e2e/` / `.iso/` are gitignored — never commit them. Stage + explicit paths; don't `git add -A`. + +## Opening a PR + +```sh +git switch -c feat/my-change # or fix/…, docs/… +# … commits … +git push -u origin feat/my-change +gh pr create --base main --fill # then edit title/body +``` + +In the PR description: what changed and why, which test tiers you ran (paste the `task vm-e2e` +PASS line for any layout you exercised), and call out any archinstall schema change (with the +regenerated goldens in their own commit). CI runs the Go suite and the loopback +`e2e-disks-light` check on every PR; the VM harness is run locally (it needs `/dev/kvm`). diff --git a/Taskfile.yml b/Taskfile.yml index 337aefe..b3150a2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -11,6 +11,13 @@ vars: LAYOUT: '{{.LAYOUT | default "multi-disk-lvm"}}' FS: '{{.FS | default "xfs"}}' + # Arch live ISO for the QEMU smoke test. Cached under the gitignored .iso/ dir + # and pinned to a dated archive build so the download is reproducible. Bump + # ISO_VERSION (CLI-overridable) in lockstep with the version in test/vm.sh. + ISO_VERSION: '{{.ISO_VERSION | default "2026.06.01"}}' + ISO_FILE: 'archlinux-{{.ISO_VERSION}}-x86_64.iso' + ISO_PATH: '.iso/{{.ISO_FILE}}' + tasks: build: desc: Build the archwright binary. @@ -41,17 +48,50 @@ tasks: cmds: - sudo bash test/e2e/disks.sh --mode full --layout {{.LAYOUT}} --fs {{.FS}} --disk1-size 12G --extra-size 6G + iso: + desc: >- + Download the pinned Arch live ISO into the gitignored .iso/ cache. Skipped + when the ISO is already present (no re-download). Override the build with + `task iso ISO_VERSION=YYYY.MM.DD`. + cmds: + - mkdir -p .iso + - curl -fL --progress-bar -o {{.ISO_PATH}} https://archive.archlinux.org/iso/{{.ISO_VERSION}}/{{.ISO_FILE}} + generates: + - '{{.ISO_PATH}}' + status: + - test -f {{.ISO_PATH}} + + # vm / vm-fresh / vm-disk: INTERACTIVE, graphical QEMU for poking by hand (boot + # the live ISO and run archwright yourself, or boot the installed disk to look + # around). For unattended pass/fail validation use `vm-e2e` (below) instead. vm: - desc: Interactive QEMU smoke test — boot the Arch live ISO (run Phase A). + desc: "Interactive QEMU: boot the Arch live ISO and run Phase A by hand (automated: vm-e2e)." + deps: [iso] cmds: - - bash test/vm.sh iso + - ARCH_ISO={{.ISO_PATH}} bash test/vm.sh iso vm-fresh: - desc: Boot the Arch live ISO, wiping the virtual disks first. + desc: "Interactive QEMU: boot the live ISO, wiping the virtual disks first." + deps: [iso] cmds: - - bash test/vm.sh iso --fresh + - ARCH_ISO={{.ISO_PATH}} bash test/vm.sh iso --fresh vm-disk: - desc: Boot the installed system off disk 1. + desc: "Interactive QEMU: boot the installed system off disk 1 (post-install poking)." cmds: - bash test/vm.sh disk + + vm-e2e: + desc: >- + Fully automated VM e2e: boot ISO headless, run Phase A install, inject the + Phase B autorun scaffold, reboot, run bootstrap + validation. Pass a + descriptor name to run one (e.g. `task vm-e2e -- lvm-multi`); no arg runs the + whole matrix. Add `-- --jobs 5` (or `-j 5`) to run several VMs at once. + deps: [iso] + cmds: + - ARCH_ISO={{.ISO_PATH}} python3 test/e2e/vm/e2e.py {{.CLI_ARGS}} + + vm-e2e-list: + desc: List the VM e2e matrix descriptors. + cmds: + - python3 test/e2e/vm/e2e.py --list diff --git a/docs/bugs/btrfs-subvolume-not-used-as-root.md b/docs/bugs/btrfs-subvolume-not-used-as-root.md new file mode 100644 index 0000000..3fc5938 --- /dev/null +++ b/docs/bugs/btrfs-subvolume-not-used-as-root.md @@ -0,0 +1,125 @@ +# Bug: btrfs install lands on the top-level subvolume; configured subvolumes (`@`) are unused as root + +**Status:** open — found by the automated VM e2e harness (`test/e2e/vm/`), 2026-06-23 +**Area:** `internal/archinstall/archinstall.go` (`btrfsBuilder` / `singleDiskRoot`) — the +reverse-engineered btrfs subvolume JSON shape (the VM-validation-pending item in `CLAUDE.md`) +**Severity:** medium — installs boot and are self-consistent, but the intended `@`-rooted, +snapshot-friendly btrfs layout is **not** what gets built, so snapper/rollback workflows that +assume a `@` root subvolume will not behave as expected. + +## Summary + +For the `btrfs` disk layout, archwright renders the root partition with **both** a +partition-level `mountpoint: "/"` **and** a subvolume `@` whose `mountpoint` is also `"/"`. +A real archinstall 4.3 run resolves that by mounting the **top-level** btrfs subvolume +(subvolid 5) at `/` and installing the whole system there. The configured `@` subvolume is +created but left **empty** and is never used as the root. The conventional Arch btrfs layout +(system installed *inside* `@`, mounted with `subvol=@`) is therefore not produced. + +archwright itself is internally consistent with this — its post-install chroot work +(`rootDevice()` → `PartDev(esp, 2)`, then `mount /mnt`) mounts the bare partition, +i.e. the default/top-level subvolume — so staging and boot agree and the machine boots fine. +The defect is purely that the **named subvolumes are not honored as the root**. + +## Evidence + +### Rendered JSON (the root partition for `disks.layout: btrfs`) + +`./archwright install --only archinstall --yes --dry-run --config ` emits, for +the root partition: + +```json +{ + "fs_type": "btrfs", + "mountpoint": "/", // <-- partition mounted at / (top-level subvol) + "mount_options": ["compress=zstd"], + "btrfs": [ + { "name": "@", "mountpoint": "/" } // <-- @ ALSO claims / ; created but unused + ] +} +``` + +The conflict is the partition having `mountpoint: "/"` while a subvolume also maps to `/`. + +### Observed on a real VM (diagnostic from the e2e harness) + +After a successful `archwright install` of a btrfs config, on the live ISO: + +``` +# btrfs subvolume get-default /mnt -> ID 5 (FS_TREE) # default = top-level, not @ +# mount -o subvol=@ /dev/vda2 /mnt ; ls /mnt/home -> (empty) # @ is empty +# mount -o subvolid=5 /dev/vda2 /mnt-top ; ls /mnt-top + bin boot dev etc home lib ... usr var @ # the whole system is in the TOP-LEVEL subvol, + # with @ present only as an empty subdir/subvol +# btrfs subvolume list /mnt-top + ID 256 gen 9 top level 5 path @ # @ exists, gen 9 (created, ~empty) +# find /mnt-top -maxdepth 5 -name archwright + /mnt-top/home/e2e/archwright # user home + staged files live in top-level +``` + +## Reproduction + +1. Build: `go build -o archwright .` +2. Quick (no VM) — inspect the render: + ```sh + ./archwright install --only archinstall --yes --dry-run \ + --config test/e2e/vm/configs/btrfs-basic.yaml 2>&1 \ + | sed -n '/^{/,/^}/p' | python3 -m json.tool | less + ``` + Confirm the root partition has `"mountpoint": "/"` **and** a `"btrfs"` entry with + `"mountpoint": "/"`. +3. Full (real archinstall) — either: + - `task vm-e2e -- btrfs-basic` and add a diagnostic, **or** + - `sudo bash test/e2e/disks.sh --mode full --layout ...` against a btrfs config, then + `btrfs subvolume get-default` / `btrfs subvolume list` the result. + Observe the default subvolume is `ID 5` (top-level) and `@` is empty. + +`test/e2e/vm/configs/btrfs-basic.yaml` currently uses a single `@` subvolume and the e2e +recipe mounts the bare partition precisely *because* of this bug (see +`test/e2e/vm/README.md` → "Finding: btrfs installs to the top-level subvolume"). A config +with a separate `@home` makes the breakage louder: the user home then lands in `@home`, +which the bare-partition mount doesn't expose. + +## Expected behavior + +The system should be installed **inside** the `@` subvolume and mounted with `subvol=@` at +`/` (the standard Arch/snapper layout), with `@home` at `/home`, etc. `btrfs subvolume +get-default` may remain `5`, but `/` must resolve to `@` (via fstab `subvol=@` and the +bootloader's `rootflags=subvol=@`), and the OS files must live in `@`, not the top-level. + +## Actual behavior + +The system is installed in the **top-level** subvolume (subvolid 5). `@` (and any `@home`, +`@log`) are created but empty and unused as mount roots. + +## Likely root cause & fix direction + +In archinstall's disk model, when a partition carries subvolumes that provide the +mountpoints, the **partition's own `mountpoint` should be `null`** — the subvolume entry +(`{"name": "@", "mountpoint": "/"}`) is what gets mounted at `/`. By emitting the root +partition with `mountpoint: "/"` *and* a subvolume mapping to `/`, archinstall mounts the +partition (top-level subvol) at `/` and the subvolume mapping is effectively ignored for the +root. + +Investigate in `internal/archinstall/archinstall.go`: + +- `btrfsBuilder.build()` → `singleDiskRoot(..., rootSpec{fsType:"btrfs", btrfs: subvols})`. + `singleDiskRoot` sets the root partition `Mountpoint: &root` (`"/"`) unconditionally + (around the `rootFs`/`Mountpoint: &root` assignment). For btrfs-with-subvolumes the + partition `Mountpoint` should be `null` and the per-subvolume mountpoints should drive the + mounts. +- Cross-check against archinstall 4.3 source for how a subvolumed btrfs partition is meant to + be expressed (partition `mountpoint` null vs the `@`/`mountpoint:"/"` subvolume), and how it + writes fstab + `rootflags=subvol=@` for the bootloader. +- This is a schema-shape change, so follow the CLAUDE.md two-commit rule (behavior-preserving + refactor with goldens unchanged, then the shape change regenerating goldens) and the + archinstall-drift gotcha (validate against a real archinstall run, not just the render). + +## Validation after a fix + +- `internal/archinstall` golden snapshots regenerate to show the btrfs root partition + `mountpoint: null` with the `@`/`mountpoint:"/"` subvolume carrying the root. +- `task vm-e2e -- btrfs-basic` with a config that uses `@` + a separate `@home`, and a + `root_mount` recipe of `mount -o subvol=@ /dev/vda2 /mnt` + `mount -o subvol=@home + /dev/vda2 /mnt/home`, boots and passes Phase B (the staged binary is found under `@home`). +- On the booted system, `findmnt /` shows `subvol=/@` and `findmnt /home` shows `subvol=/@home`. diff --git a/docs/bugs/flatpak-system-remote-add-polkit-hang.md b/docs/bugs/flatpak-system-remote-add-polkit-hang.md new file mode 100644 index 0000000..2f714a2 --- /dev/null +++ b/docs/bugs/flatpak-system-remote-add-polkit-hang.md @@ -0,0 +1,84 @@ +# Bug: the flatpak stage hangs on a polkit password prompt (system-wide `flatpak remote-add`/install as a non-root user) + +**Status:** open — found by the automated VM e2e harness (`test/e2e/vm/`, descriptor `features-flatpak`), 2026-06-23 +**Area:** `internal/stages/flatpak.go` +**Severity:** high — Phase B `bootstrap` **hangs indefinitely** at the flatpak stage in any +non-graphical session (first-boot TTY, SSH, the e2e harness), because a system-wide flatpak +operation run as a normal user requires polkit authentication and there is no agent to answer it. + +## Summary + +The flatpak stage runs `flatpak remote-add` (and would then run `flatpak install`) as the +**unprivileged user** against the **system** flatpak installation (the default scope). That +needs the polkit action `org.freedesktop.Flatpak.modify-repo`, which prompts for a password. +With no graphical polkit agent (and stdin not a usable tty), the command blocks forever on +`Password:` and `bootstrap` never completes. + +## Evidence (from the `features-flatpak` VM run) + +Phase B serial, at the flatpak stage: + +``` +━━ [4/11] 30 · flatpak ━━━... +→ flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo +Note that the directories '/var/lib/flatpak/exports/share' ... are not in the search path ... +==== AUTHENTICATING FOR org.freedesktop.Flatpak.modify-repo ==== +Authentication is required to modify a system repository +Authenticating as: e2e +Password: +``` + +…then nothing — the run hit the harness Phase-B timeout (`timed out waiting for +E2E_RESULT`). The `→` prefix shows the command ran **unprivileged** (the runner's `Cmd`, not +`Root`), so it dropped into polkit auth. + +## Reproduction + +1. A config with a flatpak remote + app, e.g. `test/e2e/vm/configs/features-flatpak.yaml` + (flathub + `com.github.tchx84.Flatseal`). +2. Run Phase B `archwright bootstrap` in a **non-graphical** session (TTY/SSH/headless) as the + user — i.e. the normal first-boot situation before a desktop/polkit agent is running. +3. The flatpak stage blocks on the polkit `Password:` prompt for + `org.freedesktop.Flatpak.modify-repo`. + +(`task vm-e2e -- features-flatpak` reproduces it; the stage hangs until the 2700s Phase-B +timeout.) + +## Expected behavior + +The flatpak stage completes unattended: remotes are added and apps installed without an +interactive polkit prompt, in a plain TTY/headless session. + +## Actual behavior + +`flatpak remote-add` (system scope, as the user) blocks on a polkit `Password:` prompt; +`bootstrap` hangs. + +## Fix direction + +Pick one of: + +- **Per-user scope:** run `flatpak --user remote-add …` and `flatpak --user install …`. The + `--user` installation needs no polkit/root. This is usually the right default for a + single-user desktop and matches running Phase B as the user. (Note: app launchers/exports + differ slightly for `--user`.) +- **Privileged scope:** run the system-wide operations via the runner's `Root` (sudo), e.g. + `sudo flatpak remote-add …` / `sudo flatpak install -y …`. Root skips the polkit prompt. +- Either way, pass non-interactive flags so a later `flatpak install` can't prompt: + `--noninteractive` (or `-y/--assumeyes`). + +Decide the intended scope (`--user` vs system) deliberately — it changes where apps land and +how they're exported. Whichever is chosen, the stage must be non-interactive. + +## Validation after a fix + +`task vm-e2e -- features-flatpak` reaches `E2E_RESULT=PASS` with the configured app present +(`flatpak list` shows `com.github.tchx84.Flatseal`). `lib/features.sh`'s `flatpak` token +checks exactly that. + +## Related + +The harness's 2700s Phase-B timeout means a polkit hang wastes the full window. Independent of +this bug, archwright stages that shell out should never be able to block on an interactive +prompt during `bootstrap` — worth auditing other stages (e.g. anything piping to a tool that +might prompt) for the same hazard. diff --git a/docs/bugs/lvm-multivolume-pv-fstype-empty.md b/docs/bugs/lvm-multivolume-pv-fstype-empty.md new file mode 100644 index 0000000..ab1c5ae --- /dev/null +++ b/docs/bugs/lvm-multivolume-pv-fstype-empty.md @@ -0,0 +1,107 @@ +# Bug: multi-volume LVM renders the PV partition with an empty `fs_type`; archinstall aborts + +**Status:** open — found by the automated VM e2e harness (`test/e2e/vm/`, descriptor `lvm-volumes`), 2026-06-23 +**Area:** `internal/archinstall/archinstall.go` — `lvmBuilder.build()` (the PV partition `fs_type`) +**Severity:** high — the **multi-volume LVM layout does not install at all**; Phase A archinstall +crashes before partitioning completes. + +## Summary + +For the `lvm` layout in **multi-volume mode** (`disks.lvm.volumes:` set instead of +`lv`+`filesystem`), archwright renders the LVM **PV partition** with `fs_type: ""` (empty +string). A real archinstall 4.3 run rejects that with +`ValueError: File system type is not set` while creating partitions, so the install aborts. + +Single-LV mode (`disks.lvm.lv` + `disks.lvm.filesystem`) works because the PV partition's +`fs_type` is set to the LV filesystem (e.g. `xfs`). + +## Root cause + +`internal/archinstall/archinstall.go`, in `lvmBuilder.build()`: + +```go +// A PV partition carries the LV filesystem as its fs_type purely so parted can +// create it (archinstall 4.x requires a non-null fs_type per partition); the +// filesystem is never written, the partition is pvcreated. +pvFs := b.lvm.Filesystem // <-- empty in multi-volume mode +... +disk1PVPart := Partition{..., FsType: &pvFs, ...} +``` + +`b.lvm.Filesystem` is **only set in single-LV mode**. In multi-volume mode the schema +*requires it to be empty* (`config.go` `lvmVolumeErrors`: "set either lv+filesystem OR +volumes, not both"), and each volume carries its own `filesystem`. So `pvFs == ""`, and every +PV partition (disk-1 PV and any whole-disk PVs, which reuse the same `pvFs`) is emitted with +an empty `fs_type`. + +## Evidence + +### Rendered partitions (`--dry-run`) + +`./archwright install --only archinstall --yes --dry-run --config `: + +| config (mode) | PV partition `fs_type` | +|---------------------------------|------------------------| +| `lvm-single` (single-LV, xfs) | `"xfs"` → installs OK | +| `lvm-volumes` (multi-volume) | `""` → **aborts** | + +The volumes themselves are fine (`root`→xfs, `home`→ext4); only the PV partition is wrong. + +### archinstall traceback (from the VM run) + +``` +Creating partitions: /dev/vda + File ".../archinstall/lib/disk/device_handler.py", line 373, in _setup_partition + fs_value = part_mod.safe_fs_type.parted_value + File ".../archinstall/lib/models/device.py", line 897, in safe_fs_type + raise ValueError('File system type is not set') +ValueError: File system type is not set +``` + +## Reproduction + +1. `go build -o archwright .` +2. Render-only: `./archwright install --only archinstall --yes --dry-run --config + test/e2e/vm/configs/lvm-volumes.yaml 2>&1 | sed -n '/^{/,/^}/p' | python3 -m json.tool` + → the second partition on `/dev/vda` has `"fs_type": ""`. +3. Full: `task vm-e2e -- lvm-volumes` (or `test/e2e/disks.sh` with a multi-volume config) + → archinstall aborts with the traceback above. + +## Expected behavior + +Multi-volume LVM installs successfully, with `root`/`home`/… LVs formatted per their +configured filesystems. + +## Actual behavior + +archinstall aborts in Phase A with `ValueError: File system type is not set`; nothing is +installed. + +## Fix direction + +Give the PV partition a valid non-empty `fs_type` even in multi-volume mode. The comment +already notes the value is cosmetic ("the filesystem is never written, the partition is +pvcreated"), so any valid fs works. Options: + +- Fall back to a volume's filesystem when the top-level one is empty, e.g. + ```go + pvFs := b.lvm.Filesystem + if pvFs == "" && len(b.lvm.Volumes) > 0 { + pvFs = b.lvm.Volumes[0].Filesystem + } + ``` +- Or use a fixed placeholder (e.g. `"ext4"`) for PV partitions regardless of mode (and + consider doing the same in single-LV mode, since `xfs` on a PV partition is equally + cosmetic). + +Add a render golden + a `config_test.go`/`golden_test.go` case for the multi-volume layout so +this is covered, and follow the CLAUDE.md archinstall-drift rule (validate against a real +archinstall run — `task vm-e2e -- lvm-volumes` should reach Phase B and pass). + +## Note for the e2e descriptor + +Once fixed, `lvm-volumes` Phase B will need its `root_mount` to also mount the `home` LV +(the user home lives on a separate LV, so the staged binary/config under `/home/` are +only reachable after `mount /dev//home /mnt/home`) — the same separate-`/home` mount +concern noted for the btrfs `@home` case. Update `test/e2e/vm/matrix/lvm_variants.py` +accordingly when validating the fix. diff --git a/docs/bugs/plymouth-bootctl-update-fails-systemd-boot.md b/docs/bugs/plymouth-bootctl-update-fails-systemd-boot.md new file mode 100644 index 0000000..a6c9538 --- /dev/null +++ b/docs/bugs/plymouth-bootctl-update-fails-systemd-boot.md @@ -0,0 +1,102 @@ +# Bug: Phase B `regenerateBootConfig` runs `bootctl update` on systemd-boot and fails, aborting bootstrap (via the always-on plymouth stage) + +**Status:** open — found by the automated VM e2e harness (`test/e2e/vm/`, descriptor `sdboot-lvm`), 2026-06-23 +**Area:** `internal/stages/helpers.go` (`regenerateBootConfig`); surfaced via `internal/stages/plymouth.go` +**Severity:** high — Phase B `bootstrap` **fails on any systemd-boot system** at the plymouth +stage (and any other stage that regenerates boot config), even with a default config. + +## Summary + +On a systemd-boot install, the Phase B `plymouth` stage aborts with +`ERRO stage plymouth: sudo: exit status 1`. The failing command is **`sudo bootctl update`**, +run by `regenerateBootConfig` for the systemd-boot bootloader. Because the plymouth stage +runs **unconditionally** (it defaults the theme to `bgrt` when none is configured), this +breaks `bootstrap` for systemd-boot configs out of the box. The same `regenerateBootConfig` +is also called by the grub-theme stage and the kernel path, so they would hit it too. + +The install itself is fine: the e2e validation that runs afterward passes (root/ESP/LVM, +`bootctl is-installed` → "systemd-boot installed", packages, yay). Only the boot-config +regeneration command fails. + +## Root cause + +`internal/stages/helpers.go`: + +```go +func regenerateBootConfig(ctx *Context) error { + if ctx.Cfg.Bootloader.EffectiveKind() == "systemd-boot" { + return ctx.R.Root("bootctl", "update") // <-- returns exit status 1 here + } + return ctx.R.Root("grub-mkconfig", "-o", "/boot/grub/grub.cfg") +} +``` + +`bootctl update` re-installs the systemd-boot binary into the ESP **only if** the bundled +version is newer than the installed one; when archinstall already installed the current +version it has nothing to do and exits non-zero (rather than a no-op success). archwright +treats that non-zero exit as a stage failure and aborts `bootstrap`. + +`internal/stages/plymouth.go` makes this reachable on every run: + +```go +func (plymouth) Run(ctx *Context) error { + theme := ctx.Cfg.Plymouth.Theme + if theme == "" { + theme = "bgrt" // <-- stage is NOT gated off when unconfigured + } + ... + return regenerateBootConfig(ctx) +} +``` + +So even a config with no `plymouth:` block installs plymouth, edits the cmdline, and runs +`bootctl update`. + +## Evidence (from the `sdboot-lvm` VM run) + +``` +→ [6/11] 50 ⟫ plymouth + → sudo pacman -S --needed --noconfirm plymouth (ok) + → sed ... /etc/mkinitcpio.conf (add plymouth hook) (ok) + → ... /etc/kernel/cmdline (add quiet / splash) (ok) + → sudo plymouth-set-default-theme -R bgrt (ok, rebuilds initramfs) + → sudo bootctl update (FAILS) +ERRO stage plymouth: sudo: exit status 1 +E2E_BOOTSTRAP_RC=1 +``` + +(The post-bootstrap validation still ran and reported `0 failures`, including +`OK: systemd-boot installed` — so the system is healthy; only the stage command failed.) + +## Reproduction + +1. Install any systemd-boot config (e.g. `task vm-e2e -- sdboot-lvm`, or set + `bootloader.kind: systemd-boot` on any layout). +2. Run Phase B `archwright bootstrap`. +3. It aborts at the plymouth stage; the failing command is `sudo bootctl update`. + +## Expected behavior + +`bootstrap` completes on systemd-boot. Regenerating boot config should be a no-op-tolerant +success when there is nothing to update. + +## Actual behavior + +`bootstrap` aborts at the plymouth (or any boot-config-regenerating) stage because +`bootctl update` exits non-zero when the loader is already current. + +## Fix direction + +- In `regenerateBootConfig`, make the systemd-boot path tolerant of the "already current" + case — e.g. `bootctl update --graceful`, or treat the no-update exit code as success + (best-effort via `ctx.R.Try`), or skip `bootctl update` entirely (the loader was just + installed by archinstall; cmdline changes go to `/etc/kernel/cmdline` which systemd-boot + reads directly, so a forced binary update isn't needed for archwright's edits). Confirm the + exact `bootctl update` exit semantics against the installed systemd version. +- Consider whether the **plymouth stage should be gated** when no `plymouth:` config is + present, instead of always defaulting to `bgrt` — running it unconditionally is what makes + every systemd-boot bootstrap hit this path. (Decide intended behavior; if "plymouth on by + default" is desired, keep it but make the boot-config step robust.) +- This sits squarely in the reverse-engineered / VM-validation-pending systemd-boot path + (`CLAUDE.md`): validate the fix with `task vm-e2e -- sdboot-lvm` (and `sdboot-plain`) + reaching `E2E_RESULT=PASS` with `E2E_BOOTSTRAP_RC=0`. diff --git a/docs/vm-validation.md b/docs/vm-validation.md index f0a9574..681cbb6 100644 --- a/docs/vm-validation.md +++ b/docs/vm-validation.md @@ -1,6 +1,9 @@ -# VM validation of the rendered archinstall config — outstanding work +# VM validation of the rendered archinstall config -This is the one open item before trusting archwright on real hardware. +This was the last open item before trusting archwright on real hardware. The automated harness +below (`test/e2e/vm/`) now closes it: every reverse-engineered shape has been driven through a +real archinstall 4.3 + boot + bootstrap run. Most are confirmed working; three surfaced real +bugs (now tracked in [`docs/bugs/`](bugs/)). See the results table below. archinstall's config JSON is **not a stable API** — its schema changes between releases. We render against the pinned `Version` in `internal/archinstall/archinstall.go` (currently @@ -12,27 +15,63 @@ archinstall *parses* it against loopback devices — but none of that proves a r *does the right thing with it end-to-end on a booted system*. That last mile needs a QEMU run that boots a real systemd live ISO, feeds it the rendered -config, and verifies the machine partitions, installs, and **boots to a desktop**. The -harness exists (`test/vm.sh`, `task vm` / `task vm-fresh` / `task vm-disk`); what remains is -to actually run each layout/feature through it and confirm the reverse-engineered shapes -below. +config, and verifies the machine partitions, installs, and **boots and runs Phase B +end-to-end**. + +## Automated harness — `test/e2e/vm/` + +`test/e2e/vm/e2e.py` (run it with `task vm-e2e -- `, or `task vm-e2e` for the whole +matrix; `task vm-e2e-list` lists them) does this **fully unattended**: it boots the ISO +headless on a serial console, runs `archwright install --yes`, injects a harness-only +Phase-B autorun (a serial autologin + bootstrap+validate trigger that lives **only** in the +test scaffold, never in a real config), reboots from disk, runs `archwright bootstrap`, and +asserts the installed system with a parametrized `lib/validate.sh`. See +`test/e2e/vm/README.md` for the descriptor contract and how to add coverage. The matrix +(`test/e2e/vm/matrix/*.py` + `configs/*.yaml`) covers lvm (single / multi / multi-volume), +btrfs (+ compress / snapper), plain (every swap type), systemd-boot, and the LUKS layouts +(which validate the on-disk encryption on the ISO, since encrypted Phase B staging is not +yet implemented). + +Status (full matrix run, archinstall 4.3): **14 descriptors green**, **3 distinct real bugs +found**. Green end-to-end (install → reboot → bootstrap → validate): all lvm single-LV layouts, +all plain layouts × every swap type, btrfs (basic + snapper), the feature/stage-coverage runs, +and both encryption layouts (validated on the ISO — see the encryption note below). The bugs +are in `docs/bugs/` and the results table maps each to its shape. + +Two things the harness does **not** prove, by design: +- **Graphical desktop rendering.** It validates boot → multi-user → `bootstrap` → assertions, + not that a KDE session visually renders (the trimmed configs mostly use + `desktop.environment: none`; `features-desktop` only checks the plasma tooling installed + + the stage ran). Use `task vm-disk` to watch a real desktop come up by hand. +- **A full encrypted boot.** archwright's Phase B staging is skipped for encrypted installs + (the LUKS remount isn't implemented), so the encryption descriptors assert the on-disk LUKS + shape on the live ISO (container present + passphrase unlocks) rather than booting the + encrypted system and running `bootstrap`. + +The older `test/vm.sh` (`task vm` / `vm-fresh` / `vm-disk`) remains for **interactive** +poking at a VM by hand (including the desktop-render check above). > Use `-cpu host` for local VM runs — otherwise the CachyOS repo setup skips and -> `linux-cachyos` fails with "target not found". +> `linux-cachyos` fails with "target not found". (The e2e matrix configs use the stock +> `linux` kernel and no CachyOS repo, so they are unaffected; this matters for configs that +> add `linux-cachyos`.) + +## Results — reverse-engineered shapes vs a real archinstall 4.3 run -## Shapes to confirm against a real archinstall 4.3 run +Each shape was reverse-engineered; the harness has now exercised them all. -Each was reverse-engineered and is unproven on hardware. Validate, then delete its row here. +| Area | Shape | Result | +|------|-------|--------| +| Bootloader | `bootloader_config: {bootloader, uki, removable}` field names/casing | ✅ confirmed — grub (`grub.cfg`, boots) and systemd-boot both install + boot | +| Swap | `partition` (`fs_type: linux-swap`, flag `swap`), zram, swapfile | ✅ confirmed — swapfile (lvm/plain), zram (btrfs, plain-zram), partition (plain-swappart) all active post-boot | +| Encryption | nested `disk_config.disk_encryption` (`encryption_type` + `partitions`); `encryption_password` casing | ✅ confirmed — `enc-lvm` (lvm_on_luks) + `enc-luks-plain` (luks): LUKS container present and the passphrase unlocks (`luksOpen --test-passphrase`). `lvm_on_luks` >2-PV limit not separately exercised; full encrypted Phase B still unimplemented in archwright | +| Snapper | timer-unit + `set-config` key names | ✅ confirmed — `btrfs-snapper` installs snapper + green | +| Btrfs | subvolume JSON `{name, mountpoint}` | ❌ **bug** — shape parses, but archinstall installs to the **top-level** subvolume; the configured `@` is created but unused as root → [`btrfs-subvolume-not-used-as-root.md`](bugs/btrfs-subvolume-not-used-as-root.md) | +| LVM | multi-volume "rest of VG" sizing (fixed root + remainder `/home`) | ❌ **bug** — the PV partition renders with an empty `fs_type`; archinstall aborts Phase A before sizing is reached → [`lvm-multivolume-pv-fstype-empty.md`](bugs/lvm-multivolume-pv-fstype-empty.md) | +| systemd-boot | loader-entry default + `bootctl update` cmdline-refresh path | ⚠️ install + boot **work**; the Phase-B `bootctl update` refresh path **fails** (nonzero when already current), reached via the always-on plymouth stage → [`plymouth-bootctl-update-fails-systemd-boot.md`](bugs/plymouth-bootctl-update-fails-systemd-boot.md) | -| Area | Shape to confirm | -|------|------------------| -| Bootloader | `bootloader_config: {bootloader, uki, removable}` field names/casing | -| Btrfs | subvolume JSON `{name, mountpoint}` — whether archinstall wants extra keys (per-subvol compression, `nodatacow`); `disk_config.btrfs_options` is intentionally not emitted | -| Swap | `partition` shape (`fs_type: linux-swap`, flag `swap`); zram and swapfile paths | -| Encryption | nested `disk_config.disk_encryption` (`encryption_type` + `partitions`) obj_id wiring; `encryption_password` casing; the `lvm_on_luks` >2-partition limit | -| systemd-boot | loader-entry default + the `bootctl update` cmdline-refresh path | -| LVM | multi-volume "rest of VG" sizing (fixed root + remainder-taking `/home`) | -| Snapper | timer-unit + `set-config` key names (`snapper-timeline.timer`, `snapper-cleanup.timer`, `TIMELINE_LIMIT_*`) | +(A fourth bug unrelated to a disk shape — the flatpak stage hangs on a polkit prompt — is in +[`flatpak-system-remote-add-polkit-hang.md`](bugs/flatpak-system-remote-add-polkit-hang.md).) ## After an archinstall version bump diff --git a/test/e2e/vm/README.md b/test/e2e/vm/README.md new file mode 100644 index 0000000..437ae8a --- /dev/null +++ b/test/e2e/vm/README.md @@ -0,0 +1,158 @@ +# Automated VM end-to-end harness + +`e2e.py` drives a **fully automated** QEMU run of the complete archwright flow — +boot the live ISO, run Phase A (`install --yes`), reboot from disk, run Phase B +(`bootstrap`) and assert the installed system — with **no human interaction**. +It is the last-mile validation the render tests and the loopback `disks.sh` +harness can't give (see `docs/vm-validation.md`): proof that a real archinstall +*does the right thing end-to-end on a booted system* for each layout/feature. + +## Two test groups + +- **Disk-layout matrix** (`matrix/lvm.py`, `btrfs.py`, `plain.py`, `lvm_variants.py`, + `systemd_boot.py`, `encryption.py`) — every partitioning/swap/bootloader/encryption + combination, validated by `lib/validate.sh`. +- **Feature / stage coverage** (`matrix/features.py`, `features_extra.py`) — exercises + the Phase A/B *stages* (reflector, custom kernel, plymouth, hooks, setup, services, + dotfiles, flatpak, repos, KDE) on a fixed minimal layout, validated by + `lib/features.sh`. `features-min` bundles all the cheap features into one VM; + the heavier ones (flatpak runtime, KDE desktop, dotfiles, a custom repo) are + separate so they can be run on demand. + +## Running + +```sh +task vm-e2e -- lvm-multi # one descriptor +task vm-e2e -- features-min # the cheap feature bundle +task vm-e2e # the whole matrix (sequential) +task vm-e2e -- --jobs 5 # whole matrix, up to 5 VMs at once +task vm-e2e -- -j 4 lvm-multi btrfs-basic plain-ext4 features-min # a subset, 4 at a time +task vm-e2e-list # list descriptors +python3 test/e2e/vm/e2e.py lvm-multi --phase-b-only # re-run Phase B on existing disks +``` + +`--jobs/-j N` runs up to N descriptors concurrently. Each VM is fully isolated (its own +run dir, disks, serial socket, NVRAM) and uses ~4 GiB RAM + 4 vcpus, so size N to the host +(e.g. 4–5 on a 16-core/32 GiB box). Concurrent log lines are tagged `[e2e][]`; each +run still writes its own `.e2e/runs//serial.log`. + +Everything lands under the gitignored `.e2e/` (per-run disks, NVRAM, and a `serial.log` you +can read after a failure). + +## Requirements + +The harness shells out to QEMU/OVMF and a few CLI tools; **the Python driver itself is pure +stdlib (no `pip` packages)**. On Arch: + +| Need | Provides | Arch package | Notes | +|------|----------|--------------|-------| +| `qemu-system-x86_64`, `qemu-img` | the VM + disk images | `qemu-base` (or `qemu-system-x86` + `qemu-img`; `qemu-full` also works) | | +| OVMF firmware (`/usr/share/edk2/x64/OVMF_CODE.4m.fd`, `OVMF_VARS.4m.fd`) | UEFI boot | `edk2-ovmf` | path is hard-coded in `e2e.py` | +| `/dev/kvm` | hardware acceleration | kernel KVM + your user in the **`kvm`** group | enable virtualization (VT-x/AMD-V) in firmware; `-cpu host` is used | +| `bsdtar` | extract kernel/initramfs from the ISO | `libarchive` | | +| `blkid` | read the ISO volume label | `util-linux` | part of `base` | +| `git` | builds the injected dotfiles repo (`inject_repo`) | `git` | also needed by archwright itself | +| `curl` | download the pinned ISO (`task iso`) | `curl` | | +| `python3` ≥ 3.9 | the orchestrator | `python` | stdlib only | +| `go`, `task` | build `archwright`, run the tasks | provisioned by **`mise install`** (`mise.toml` pins Go + Task) | the rest of the repo's toolchain | + +One-time setup on a fresh Arch box (besides `mise install` for Go/Task): + +```sh +sudo pacman -S --needed qemu-base edk2-ovmf libarchive git curl +sudo usermod -aG kvm "$USER" # then re-login so /dev/kvm is usable +task iso # cache the pinned Arch ISO under .iso/ +``` + +## How it works + +1. **Phase A** — boots the ISO headless with the serial console on a unix socket + (`console=ttyS0`, archiso autologins root). The per-run dir is shared in over + 9p at `/root/e2e`. The driver copies the freshly built binary + config in and + runs `archwright install --yes`. `--yes` makes Phase A non-interactive (it + uses a throwaway password and skips the erase prompt). +2. **Scaffold injection** — still on the live ISO, with the freshly installed + target remounted at `/mnt` (per the descriptor's `root_mount`), the driver + injects a **harness-only** Phase-B autorun into the installed system: a + NOPASSWD sudoers drop-in, a `serial-getty@ttyS0` autologin as the user, a + `~/.bash_profile` trigger, and the `e2e-bootstrap.sh` + `validate.sh` scripts. + **None of this lives in any real config** — it is pure test scaffolding. +3. **Phase B** — reboots from disk. The autologin lands a real user session on + the serial console, `~/.bash_profile` runs `archwright bootstrap` then + `validate.sh`, prints `E2E_RESULT=PASS|FAIL`, and powers off. The driver + watches the serial for that marker. + +## Adding coverage (the descriptor contract) + +Grow the matrix by adding a **new file** `matrix/.py` exporting a +`DESCRIPTORS` list, plus a `configs/.yaml`. Don't edit `e2e.py`, +`lib/validate.sh`, or another family's files — new-files-only keeps everything +conflict-free. A descriptor: + +```python +DESCRIPTORS = [{ + "name": "plain-ext4", # unique; also the run-dir/log name + "config": "configs/plain-ext4.yaml", # path relative to e2e.py + "disks": ["12G"], # qcow2 sizes -> vda, vdb, vdc ... + "user": "e2e", # config's user.name; MUST be a bash login shell + "phase_b": True, # run Phase B? (False = install-only) + "esp_part": "/dev/vda1", # ESP partition; the orchestrator mounts it + "root_mount": [ # at /mnt/boot itself (with mkdir -p) + "mount /dev/vda2 /mnt", # AS ROOT on the ISO, post-install: mount ONLY + ], # the installed root at /mnt (NOT /mnt/boot) + "grub_serial": True, # append console=ttyS0 to installed GRUB (grub only) + "expect": { # EXPECT_* fed to validate.sh + "LAYOUT": "plain", "ROOT_FS": "ext4", "SWAP": "swapfile", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, +}] +``` + +### Device + mount-recipe rules (must match how archwright partitions) + +The config's disk devices are the VM's virtio names: disk 1 = `/dev/vda`, +disk 2 = `/dev/vdb`, disk 3 = `/dev/vdc`. Disk 1 is always `ESP (p1) + …`. +`root_mount` mounts **only the installed root** at `/mnt`; the orchestrator then +mounts `esp_part` at `/mnt/boot` itself (creating the mountpoint), so don't add a +`/mnt/boot` line. + +- **lvm**: PVs are `vda2` + any whole extra disks; root is `/dev//`. + Recipe: `vgchange -ay ` · `mount /dev// /mnt`. + (Multi-volume: `` is the volume whose mountpoint is `/`.) +- **plain**: root is `vda2` (or `vda3` when `swap.type: partition`, since swap is + `p2`). Recipe: `mount /dev/vda2 /mnt`. +- **btrfs**: root is `vda2` (or `vda3` with a swap partition). Mount the bare + partition (`mount /dev/vda2 /mnt`) — see the finding below; archinstall installs + to the top-level subvolume, which is the partition's default mount. + +### Finding: btrfs installs to the top-level subvolume + +A diagnostic run showed that with archwright's btrfs render, archinstall installs +the whole system (and the user home) into the **top-level** btrfs subvolume +(`subvolid 5`, the default). The subvolumes named in `disks.btrfs.subvolumes` +(e.g. `@`) are created but **not** used as the root mount — `@` is left empty. The +install is self-consistent (archwright's `postInstall`/`rootDevice` also use the +bare partition, so staging + boot agree), which is why the e2e btrfs recipe mounts +the bare partition rather than `subvol=@`. But the conventional `@`-rooted, +snapshot-friendly layout the config implies is **not** what gets built — this is a +real follow-up for the reverse-engineered btrfs subvolume JSON shape (the +VM-validation-pending item in `CLAUDE.md`). The btrfs e2e configs therefore use a +single `@` entry and don't rely on a separate `@home`. + +### Keep Phase B cheap + deterministic + +Trim configs like `configs/lvm-multi.yaml`: a bash login shell, `reflector: +false`, a couple of tiny official packages, `desktop.environment: none`, +`dotfiles.manager: none`, no flatpaks/AUR/custom-kernels/heavy theming. The point +is to exercise each stage's *wiring*, not to download a desktop. `validate.sh` +already covers lvm/btrfs/plain, every swap type, and grub/systemd-boot, gated on +`EXPECT_LAYOUT` — extend it only for a genuinely new assertion. + +### Validate without booting a VM + +```sh +go build -o archwright . +./archwright validate --config test/e2e/vm/configs/.yaml +python3 test/e2e/vm/e2e.py --list # your descriptor should appear +``` diff --git a/test/e2e/vm/configs/btrfs-basic.yaml b/test/e2e/vm/configs/btrfs-basic.yaml new file mode 100644 index 0000000..93947df --- /dev/null +++ b/test/e2e/vm/configs/btrfs-basic.yaml @@ -0,0 +1,68 @@ +# e2e: single-disk btrfs root on vda (ESP p1 + btrfs root p2 carrying subvolumes). +# Trimmed so every Phase B stage runs but stays fast + deterministic — no heavy +# desktop/AUR/flatpak/dotfiles. Device paths are the VM's virtio names, fixed by +# the orchestrator's disk topology. Phase A runs with --yes (throwaway password). +# btrfs prefers zram swap: a swapfile needs a dedicated nocow/no-compress subvol, +# so it is not emitted for this layout. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: btrfs + esp: + device: /dev/vda + size: 512MiB + swap: + type: zram + btrfs: + device: /dev/vda + compress: zstd + # Single root subvolume: /home lives inside @. A SEPARATE @home subvolume + # currently breaks Phase B — archwright stages the binary/config into the + # wrong subvolume (postInstall remounts the bare partition, not @ + @home), + # so they're shadowed once /home mounts @home at boot. Flagged for an + # archwright-side fix; see test/e2e/vm/README.md. + subvolumes: + - { name: "@", mountpoint: / } + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/btrfs-snapper.yaml b/test/e2e/vm/configs/btrfs-snapper.yaml new file mode 100644 index 0000000..9291799 --- /dev/null +++ b/test/e2e/vm/configs/btrfs-snapper.yaml @@ -0,0 +1,67 @@ +# e2e: single-disk btrfs root on vda with snapper snapshots (exercises the Phase B +# snapper stage). Same trimmed shape as btrfs-basic, plus snapper in the package +# list so the stage has its binary. Device paths are the VM's virtio names, fixed +# by the orchestrator's disk topology. Phase A runs with --yes (throwaway password). +# btrfs prefers zram swap: a swapfile needs a dedicated nocow/no-compress subvol, +# so it is not emitted for this layout. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: btrfs + esp: + device: /dev/vda + size: 512MiB + swap: + type: zram + btrfs: + device: /dev/vda + compress: zstd + snapshots: snapper + # Single root subvolume — a separate @home currently breaks Phase B staging + # (see btrfs-basic.yaml / README.md for the flagged archwright issue). + subvolumes: + - { name: "@", mountpoint: / } + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + - snapper # the Phase B snapper stage needs its binary + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/enc-luks-plain.yaml b/test/e2e/vm/configs/enc-luks-plain.yaml new file mode 100644 index 0000000..dab6d09 --- /dev/null +++ b/test/e2e/vm/configs/enc-luks-plain.yaml @@ -0,0 +1,57 @@ +# e2e: LUKS on a single plain root partition (encryption.type luks). Single disk. +# Like enc-lvm, encrypted installs skip Phase B staging, so this validates the +# on-disk LUKS layout on the live ISO. Passphrase is the --yes throwaway +# install password ("installme"). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash + groups: [wheel] + +disks: + layout: plain + esp: + device: /dev/vda + size: 512MiB + swap: + type: none + plain: + device: /dev/vda + filesystem: ext4 + encryption: + type: luks + +mirrors: + reflector: false + +pacstrap: + - base-devel + - git + - sudo + - networkmanager + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/enc-lvm.yaml b/test/e2e/vm/configs/enc-lvm.yaml new file mode 100644 index 0000000..8485d74 --- /dev/null +++ b/test/e2e/vm/configs/enc-lvm.yaml @@ -0,0 +1,62 @@ +# e2e: LUKS lvm_on_luks — encrypt the PV partition(s), LVM on top. Single disk. +# Encrypted installs skip Phase B staging (postInstall bails on a LUKS remount +# that isn't implemented yet), so this descriptor validates the on-disk LUKS +# layout on the live ISO instead of booting Phase B. The LUKS passphrase is the +# --yes throwaway install password ("installme"). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + encryption: + type: lvm_on_luks + +mirrors: + reflector: false + +pacstrap: + - base-devel + - git + - sudo + - networkmanager + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/features-desktop.yaml b/test/e2e/vm/configs/features-desktop.yaml new file mode 100644 index 0000000..a6a8861 --- /dev/null +++ b/test/e2e/vm/configs/features-desktop.yaml @@ -0,0 +1,64 @@ +# e2e feature coverage (heaviest): exercises the Phase B KDE stage — apply a look & +# feel + color scheme via the plasma-apply-* helpers. Needs plasma-desktop (pulls +# plasma-workspace, which ships plasma-apply-*). The helpers normally want a running +# Plasma session, so the KDE stage warns (not fatal) on failure; headless they may +# behave differently — best-effort. Disk layout is the same minimal single-disk LVM +# as features-min. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + +mirrors: + reflector: false + +pacstrap: + - base-devel + - git + - sudo + - networkmanager + - efibootmgr + - intel-ucode + +packages: + - plasma-desktop # provides plasma-apply-* (via plasma-workspace) + +kernel: + base: [linux] + +bootloader: + kind: grub + +desktop: + environment: kde + +kde: + look_and_feel: org.kde.breezedark.desktop + color_scheme: BreezeDark + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/features-dotfiles.yaml b/test/e2e/vm/configs/features-dotfiles.yaml new file mode 100644 index 0000000..7320c44 --- /dev/null +++ b/test/e2e/vm/configs/features-dotfiles.yaml @@ -0,0 +1,61 @@ +# e2e feature coverage: exercises the Phase B dotfiles stage — chezmoi +# `init --apply` from a tiny LOCAL repo the harness injects into the target +# (file:///home/e2e/dots), so it's deterministic + offline (no large external +# clone, no apply scripts that prompt/hang). The injected repo manages a single +# `dot_e2e-dotfile`, which chezmoi applies to ~/.e2e-dotfile — features.sh's +# "dotfiles" token asserts that. Disk layout is the minimal single-disk LVM. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + +mirrors: + reflector: false + +pacstrap: + - base-devel + - git + - sudo + - networkmanager + - efibootmgr + - intel-ucode + +packages: + - chezmoi # the dotfiles stage installs this if missing; listing it is harmless + +kernel: + base: [linux] + +bootloader: + kind: grub + +desktop: + environment: none + +dotfiles: + manager: chezmoi + repo: file:///home/e2e/dots # injected by the harness (see matrix/features_extra.py inject_repo) + +aur_helper: yay diff --git a/test/e2e/vm/configs/features-flatpak.yaml b/test/e2e/vm/configs/features-flatpak.yaml new file mode 100644 index 0000000..0736e09 --- /dev/null +++ b/test/e2e/vm/configs/features-flatpak.yaml @@ -0,0 +1,65 @@ +# e2e feature coverage (heavy): exercises the Phase B flatpak stage — register a +# remote (flathub) and install one small app from it. This pulls a flatpak runtime, +# which is expected (and the reason this lives in its own on-demand descriptor). +# Disk layout is the same minimal single-disk LVM as features-min. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + +mirrors: + reflector: false + +pacstrap: + - base-devel + - git + - sudo + - networkmanager + - efibootmgr + - intel-ucode + +packages: + - flatpak # the flatpak stage installs this if missing, but list it explicitly + +kernel: + base: [linux] + +bootloader: + kind: grub + +desktop: + environment: none + +dotfiles: + manager: none + +flatpak_remotes: + - name: flathub + url: https://flathub.org/repo/flathub.flatpakrepo + +flatpaks: + - flathub:com.github.tchx84.Flatseal + +aur_helper: yay diff --git a/test/e2e/vm/configs/features-min.yaml b/test/e2e/vm/configs/features-min.yaml new file mode 100644 index 0000000..97ba903 --- /dev/null +++ b/test/e2e/vm/configs/features-min.yaml @@ -0,0 +1,92 @@ +# e2e feature coverage (cheap bundle): one VM that exercises the stage features +# that DON'T need a heavy desktop/flatpak/dotfiles download — reflector, a custom +# kernel from the official repos (linux-zen), plymouth, the setup-stage clone, +# a service enable, and hooks at every lifecycle/per-stage point. Disk layout is a +# minimal single-disk LVM (the layout itself is covered by the disk matrix). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + +# reflector runs in Phase A before pacstrap. +mirrors: + reflector: true + countries: [US] + latest: 5 + protocols: [https] + +pacstrap: + - base-devel + - git + - sudo + - networkmanager + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + - plymouth # the plymouth stage needs the plymouth tooling + +# Custom kernel from the OFFICIAL repos (no custom repo needed): installed in the +# Phase A chroot and set as the GRUB default; the stock linux is kept. +kernel: + base: [linux] + packages: [linux-zen] + default: linux-zen + replace_stock: false + +bootloader: + kind: grub + +plymouth: + theme: spinner + +desktop: + environment: none + +dotfiles: + manager: none + +# Setup stage: a tiny clone (the smallest stable public repo). +setup: + steps: + - clone: { url: https://github.com/octocat/Hello-World, dest: ~/e2e-clone } + +# Services stage: enable a unit that ships with the base system. +services: + enable: + - fstrim.timer + +# Hooks at every lifecycle + per-stage point we can assert from the booted system +# (Phase B points run as the user; the root one runs privileged). +hooks: + - { name: pre-bootstrap marker, at: pre-bootstrap, run: "touch ~/e2e-pre-bootstrap" } + - { name: post-bootstrap marker, at: post-bootstrap, run: "touch ~/e2e-post-bootstrap" } + - { name: before packages, at: "before:packages", run: "touch ~/e2e-before-packages" } + - { name: after packages, at: "after:packages", run: "touch ~/e2e-after-packages" } + - { name: root hook, at: post-bootstrap, root: true, run: "touch /e2e-root-hook" } + +aur_helper: yay diff --git a/test/e2e/vm/configs/features-repos.yaml b/test/e2e/vm/configs/features-repos.yaml new file mode 100644 index 0000000..7756e8e --- /dev/null +++ b/test/e2e/vm/configs/features-repos.yaml @@ -0,0 +1,64 @@ +# e2e feature coverage: exercises the Phase A custom-repo stage (configureRepos) +# with a KEY-ONLY repo — import + locally sign the signing key, then `pacman -Sy`. +# No Server/Include means no pacman.conf section is written (safe + minimal); we +# only assert the key landed in the keyring. The key below is the real, importable +# chaotic-aur signing key. Disk layout is the same minimal single-disk LVM as +# features-min. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + +mirrors: + reflector: false + +pacstrap: + - base-devel + - git + - sudo + - networkmanager + - efibootmgr + - intel-ucode + +repos: + - name: test-key + key: 3056513887B78AEB + keyserver: keyserver.ubuntu.com + +packages: [] + +kernel: + base: [linux] + +bootloader: + kind: grub + +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/lvm-multi.yaml b/test/e2e/vm/configs/lvm-multi.yaml new file mode 100644 index 0000000..d5256e4 --- /dev/null +++ b/test/e2e/vm/configs/lvm-multi.yaml @@ -0,0 +1,65 @@ +# e2e: default lvm layout across three virtio disks (vda ESP+PV, vdb+vdc whole-disk PVs). +# Trimmed so every Phase B stage runs but stays fast + deterministic — no heavy +# desktop/AUR/flatpak/dotfiles. Device paths are the VM's virtio names, fixed by +# the orchestrator's disk topology. Phase A runs with --yes (throwaway password). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + - /dev/vdb + - /dev/vdc + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/lvm-single.yaml b/test/e2e/vm/configs/lvm-single.yaml new file mode 100644 index 0000000..4b9f6b0 --- /dev/null +++ b/test/e2e/vm/configs/lvm-single.yaml @@ -0,0 +1,63 @@ +# e2e: single-disk lvm layout (vda ESP + single-partition PV). Single root LV on +# vg0, xfs root, swapfile. Trimmed so every Phase B stage runs but stays fast + +# deterministic — no heavy desktop/AUR/flatpak/dotfiles. Device paths are the VM's +# virtio names, fixed by the orchestrator's disk topology. Phase A runs with --yes. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: xfs + pvs: + - /dev/vda2 + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/lvm-volumes.yaml b/test/e2e/vm/configs/lvm-volumes.yaml new file mode 100644 index 0000000..9f9a2d7 --- /dev/null +++ b/test/e2e/vm/configs/lvm-volumes.yaml @@ -0,0 +1,70 @@ +# e2e: single-disk lvm layout in multi-volume mode (vda ESP + single-partition +# PV). Two LVs on vg0: a fixed-size xfs root + a /home ext4 LV taking the rest of +# the VG. Trimmed so every Phase B stage runs but stays fast + deterministic — no +# heavy desktop/AUR/flatpak/dotfiles. Device paths are the VM's virtio names, +# fixed by the orchestrator's disk topology. Phase A runs with --yes. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + pvs: + - /dev/vda2 + volumes: + - name: root + mountpoint: / + filesystem: xfs + size: 6GiB + - name: home + mountpoint: /home + filesystem: ext4 # no size = rest of VG + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/plain-ext4.yaml b/test/e2e/vm/configs/plain-ext4.yaml new file mode 100644 index 0000000..394b11d --- /dev/null +++ b/test/e2e/vm/configs/plain-ext4.yaml @@ -0,0 +1,60 @@ +# e2e: plain layout (ESP + single ext4 root on vda) with a swapfile. +# Trimmed so every Phase B stage runs but stays fast + deterministic — no heavy +# desktop/AUR/flatpak/dotfiles. Device paths are the VM's virtio names, fixed by +# the orchestrator's disk topology. Phase A runs with --yes (throwaway password). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: plain + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + plain: + device: /dev/vda + filesystem: ext4 + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/plain-swappart.yaml b/test/e2e/vm/configs/plain-swappart.yaml new file mode 100644 index 0000000..1174e5d --- /dev/null +++ b/test/e2e/vm/configs/plain-swappart.yaml @@ -0,0 +1,61 @@ +# e2e: plain layout (ESP + linux-swap partition + single ext4 root on vda). +# With a swap partition root lands on vda3 (swap is p2). Trimmed so every Phase B +# stage runs but stays fast + deterministic — no heavy desktop/AUR/flatpak/dotfiles. +# Device paths are the VM's virtio names, fixed by the orchestrator's disk topology. +# Phase A runs with --yes (throwaway password). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: plain + esp: + device: /dev/vda + size: 512MiB + swap: + type: partition + size: 256MiB + plain: + device: /dev/vda + filesystem: ext4 + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/plain-xfs.yaml b/test/e2e/vm/configs/plain-xfs.yaml new file mode 100644 index 0000000..4eefc62 --- /dev/null +++ b/test/e2e/vm/configs/plain-xfs.yaml @@ -0,0 +1,59 @@ +# e2e: plain layout (ESP + single xfs root on vda) with no swap. +# Trimmed so every Phase B stage runs but stays fast + deterministic — no heavy +# desktop/AUR/flatpak/dotfiles. Device paths are the VM's virtio names, fixed by +# the orchestrator's disk topology. Phase A runs with --yes (throwaway password). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: plain + esp: + device: /dev/vda + size: 512MiB + swap: + type: none + plain: + device: /dev/vda + filesystem: xfs + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/plain-zram.yaml b/test/e2e/vm/configs/plain-zram.yaml new file mode 100644 index 0000000..2ae033b --- /dev/null +++ b/test/e2e/vm/configs/plain-zram.yaml @@ -0,0 +1,59 @@ +# e2e: plain layout (ESP + single ext4 root on vda) with zram swap. +# Trimmed so every Phase B stage runs but stays fast + deterministic — no heavy +# desktop/AUR/flatpak/dotfiles. Device paths are the VM's virtio names, fixed by +# the orchestrator's disk topology. Phase A runs with --yes (throwaway password). +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: plain + esp: + device: /dev/vda + size: 512MiB + swap: + type: zram + plain: + device: /dev/vda + filesystem: ext4 + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: grub + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/sdboot-lvm.yaml b/test/e2e/vm/configs/sdboot-lvm.yaml new file mode 100644 index 0000000..2836f02 --- /dev/null +++ b/test/e2e/vm/configs/sdboot-lvm.yaml @@ -0,0 +1,64 @@ +# e2e: single-disk LVM root (ext4) with the systemd-boot bootloader. systemd-boot's +# archinstall JSON is reverse-engineered / VM-validation-pending, so this descriptor +# exists to prove a real archinstall installs + boots it end-to-end. Trimmed like +# configs/lvm-multi.yaml — every Phase B stage runs but stays fast + deterministic. +# Device paths are the VM's virtio names, fixed by the orchestrator's disk topology. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: lvm + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + lvm: + vg: vg0 + lv: root + filesystem: ext4 + pvs: + - /dev/vda2 + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: systemd-boot + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/configs/sdboot-plain.yaml b/test/e2e/vm/configs/sdboot-plain.yaml new file mode 100644 index 0000000..9e42f66 --- /dev/null +++ b/test/e2e/vm/configs/sdboot-plain.yaml @@ -0,0 +1,61 @@ +# e2e: single-disk plain root (ext4) with the systemd-boot bootloader. systemd-boot's +# archinstall JSON is reverse-engineered / VM-validation-pending, so this descriptor +# exists to prove a real archinstall installs + boots it end-to-end. Trimmed like +# configs/lvm-multi.yaml — every Phase B stage runs but stays fast + deterministic. +# Device paths are the VM's virtio names, fixed by the orchestrator's disk topology. +system: + hostname: arch-e2e + timezone: Etc/UTC + locale: en_US.UTF-8 + keymap: us + ntp: false + +user: + name: e2e + shell: /usr/bin/bash # bash so the serial-autologin .bash_profile autorun fires + groups: [wheel] + +disks: + layout: plain + esp: + device: /dev/vda + size: 512MiB + swap: + type: swapfile + size: 256MiB + plain: + device: /dev/vda + filesystem: ext4 + +mirrors: + reflector: false + +# Complete Phase-A pacstrap set, rendered verbatim. base + the linux kernel are +# added by archinstall itself; this lists what first boot + Phase B need. +pacstrap: + - base-devel # Phase B builds the AUR helper + - git + - sudo + - networkmanager # network at first boot (archinstall enables it) + - efibootmgr + - intel-ucode + +packages: + - tree + - jq + +kernel: + base: [linux] + +bootloader: + kind: systemd-boot + +# Everything below is left at its no-op default for the trimmed run: +# desktop none, dotfiles none, no flatpaks/aur/repos/plymouth/grub-theme. +desktop: + environment: none + +dotfiles: + manager: none + +aur_helper: yay diff --git a/test/e2e/vm/e2e.py b/test/e2e/vm/e2e.py new file mode 100755 index 0000000..4054d40 --- /dev/null +++ b/test/e2e/vm/e2e.py @@ -0,0 +1,633 @@ +#!/usr/bin/env python3 +"""Fully-automated VM end-to-end harness for archwright. + +For each matrix descriptor this: + 1. builds the binary, creates fresh qcow2 disks, boots the Arch live ISO + headless (serial on a unix socket); + 2. drives the live-ISO root shell to run `archwright install --yes` (Phase A); + 3. while the target is still reachable from the ISO, injects a *harness-only* + Phase-B autorun scaffold into the installed system (NOPASSWD sudoers, a + serial-getty autologin as the user, a .bash_profile trigger, and the + bootstrap+validate scripts) — none of which lives in any real config; + 4. powers off, reboots from disk, and lets the scaffold run `archwright + bootstrap` followed by the parametrized validate.sh, watching the serial + log for the PASS/FAIL markers (Phase B); + 5. reports the result and tears the VM down. + +Pure stdlib — no host packages beyond qemu/OVMF/bsdtar. See README in this dir. +""" +from __future__ import annotations + +import argparse +import concurrent.futures +import fcntl +import os +import re +import select +import shutil +import socket +import subprocess +import sys +import threading +import time +import uuid + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..", "..")) +WORK = os.path.join(REPO, ".e2e") +CACHE = os.path.join(WORK, "cache") + +OVMF_CODE = "/usr/share/edk2/x64/OVMF_CODE.4m.fd" +OVMF_VARS = "/usr/share/edk2/x64/OVMF_VARS.4m.fd" +DEFAULT_ISO = os.path.join(REPO, ".iso", "archlinux-2026.06.01-x86_64.iso") + +# --- matrix ---------------------------------------------------------------- +# Each descriptor fully describes one VM run. Descriptors live one-family-per-file +# under matrix/ (each module exports a `DESCRIPTORS` list) so agents grow coverage +# by adding a new file + configs/.yaml — never editing a shared list — and +# the orchestrator stays generic. A descriptor's keys: +# name unique id; also the run-dir + serial-log name. +# config path (relative to this file) of the archwright config.yaml. +# disks qcow2 sizes, in order -> vda, vdb, vdc, ... +# user the config's user.name (must use a bash login shell). +# phase_b run Phase B (bootstrap+validate)? False = Phase A / install only. +# esp_part the ESP partition device (for reference / assertions). +# root_mount commands run AS ROOT on the live ISO (post-install, target +# unmounted) to remount the installed root at /mnt + ESP at +# /mnt/boot, so the Phase-B scaffold can be injected. Layout-specific. +# grub_serial append console=ttyS0 to the installed GRUB cmdline (grub only). +# expect EXPECT_* values fed to validate.sh (see lib/validate.sh). +def load_matrix() -> list[dict]: + import importlib.util + mdir = os.path.join(HERE, "matrix") + out: list[dict] = [] + if not os.path.isdir(mdir): + return out + for fn in sorted(os.listdir(mdir)): + if not fn.endswith(".py") or fn.startswith("_"): + continue + spec = importlib.util.spec_from_file_location(f"matrix_{fn[:-3]}", os.path.join(mdir, fn)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) # type: ignore[union-attr] + out.extend(getattr(mod, "DESCRIPTORS", [])) + return out + + +MATRIX = load_matrix() + +# Per-thread run tag so log lines from concurrent VM runs (`--jobs N`) stay +# attributable; the kernel-extraction lock guards the shared CACHE. +_log_ctx = threading.local() +_kernel_lock = threading.Lock() +_print_lock = threading.Lock() + + +def log(msg: str) -> None: + tag = getattr(_log_ctx, "tag", "") + prefix = f"[e2e][{tag}]" if tag else "[e2e]" + with _print_lock: + print(f"{prefix} {msg}", flush=True) + + +# --- serial console -------------------------------------------------------- +class Serial: + """Client for QEMU's serial unix socket with a background reader. + + expect() scans the accumulated decoded output for a regex; run() sends a + shell command followed by a unique sentinel and waits for `SENTINEL:`, + returning the exit code. The sentinel makes command completion detection + immune to prompt/shell variation (zsh on the ISO, bash on the target). + """ + + def __init__(self, path: str, logfile: str): + self.path = path + self.buf = "" + self.lock = threading.Lock() + self.closed = False + self.logf = open(logfile, "a", buffering=1, encoding="utf-8", errors="replace") + self.sock = self._connect() + self.reader = threading.Thread(target=self._read_loop, daemon=True) + self.reader.start() + + def _connect(self, timeout: float = 60) -> socket.socket: + deadline = time.time() + timeout + while True: + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(self.path) + s.setblocking(False) + return s + except (FileNotFoundError, ConnectionRefusedError, OSError): + if time.time() > deadline: + raise + time.sleep(0.2) + + def _read_loop(self) -> None: + while not self.closed: + try: + r, _, _ = select.select([self.sock], [], [], 0.5) + if not r: + continue + data = self.sock.recv(65536) + if not data: + break + text = data.decode("utf-8", errors="replace") + with self.lock: + self.buf += text + self.logf.write(text) + except OSError: + break + self.closed = True + + def send(self, data: str) -> None: + raw = data.encode() + while raw: + try: + n = self.sock.send(raw) + raw = raw[n:] + except (BlockingIOError, InterruptedError): + time.sleep(0.01) + + def expect(self, pattern: str, timeout: float) -> re.Match: + rx = re.compile(pattern) + deadline = time.time() + timeout + seen = 0 + while True: + with self.lock: + m = rx.search(self.buf, seen) + # Only advance the search start past stable text to keep it cheap. + seen = max(0, len(self.buf) - 4096) + if m: + return m + if self.closed: + raise EOFError(f"serial closed while waiting for {pattern!r}") + if time.time() > deadline: + raise TimeoutError(f"timed out waiting for {pattern!r}") + time.sleep(0.2) + + def run(self, cmd: str, timeout: float = 120) -> int: + tok = uuid.uuid4().hex[:8] + # The echoed input line contains a literal `$?` (no digit), so the regex + # `:(\d+)` only matches the evaluated result line, never the echo. + self.send(f"{cmd}; echo __E2E_{tok}__:$?\n") + m = self.expect(rf"__E2E_{tok}__:(\d+)", timeout) + return int(m.group(1)) + + def run_ok(self, cmd: str, timeout: float = 120) -> None: + rc = self.run(cmd, timeout) + if rc != 0: + raise RuntimeError(f"command failed (rc={rc}): {cmd}") + + def wait_ready(self, timeout: float = 300) -> None: + """Block until a real shell answers (autologin completed). If a login + prompt shows instead of an autologin (archiso autologins root, but be + defensive), answer it with `root`.""" + deadline = time.time() + timeout + while True: + with self.lock: + if re.search(r"login:\s*$", self.buf): + self.send("root\n") + self.send("\n") + try: + if self.run("expr 6 \\* 7 >/dev/null", timeout=8) == 0: + return + except (TimeoutError, EOFError): + pass + if time.time() > deadline: + raise TimeoutError("shell never became ready") + + def close(self) -> None: + self.closed = True + try: + self.sock.close() + except OSError: + pass + self.logf.close() + + +# --- qemu ------------------------------------------------------------------ +def iso_path() -> str: + return os.environ.get("ARCH_ISO", DEFAULT_ISO) + + +def ensure_kernel() -> tuple[str, str, str]: + """Extract vmlinuz/initramfs from the ISO (cached) and return (kernel, initrd, label).""" + os.makedirs(CACHE, exist_ok=True) + iso = iso_path() + if not os.path.isfile(iso): + sys.exit(f"ISO not found: {iso} (set ARCH_ISO= or run `task iso`)") + kern = os.path.join(CACHE, "vmlinuz-linux") + init = os.path.join(CACHE, "initramfs-linux.img") + # Lock: concurrent runs (--jobs) must not race on the shared extraction. + with _kernel_lock: + if not os.path.isfile(kern) or os.path.getmtime(iso) > os.path.getmtime(kern): + _extract_kernel(iso, kern, init) + label = subprocess.run( + ["blkid", "-p", "-s", "LABEL", "-o", "value", iso], + capture_output=True, text=True, + ).stdout.strip() + return kern, init, label + + +def _extract_kernel(iso: str, kern: str, init: str) -> None: + log("extracting kernel/initramfs from ISO") + subprocess.run( + ["bsdtar", "-xf", iso, "-C", CACHE, + "arch/boot/x86_64/vmlinuz-linux", "arch/boot/x86_64/initramfs-linux.img"], + check=True, + ) + sub = os.path.join(CACHE, "arch", "boot", "x86_64") + shutil.move(os.path.join(sub, "vmlinuz-linux"), kern) + shutil.move(os.path.join(sub, "initramfs-linux.img"), init) + shutil.rmtree(os.path.join(CACHE, "arch"), ignore_errors=True) + + +def qemu_common(rundir: str, disks: list[str], mem: str, smp: str) -> list[str]: + vars_fd = os.path.join(rundir, "OVMF_VARS.fd") + args = [ + "qemu-system-x86_64", + "-enable-kvm", "-cpu", "host", "-m", mem, "-smp", smp, "-machine", "q35", + "-no-reboot", + "-display", "none", + "-drive", f"if=pflash,format=raw,readonly=on,file={OVMF_CODE}", + "-drive", f"if=pflash,format=raw,file={vars_fd}", + "-netdev", "user,id=n0", + "-device", "virtio-net-pci,netdev=n0", + "-chardev", f"socket,id=ser0,path={os.path.join(rundir, 'serial.sock')},server=on,wait=off", + "-serial", "chardev:ser0", + ] + for i, _ in enumerate(disks): + args += ["-drive", f"file={os.path.join(rundir, f'disk{i+1}.qcow2')},if=virtio,format=qcow2"] + return args + + +def boot(args: list[str], rundir: str, tag: str) -> subprocess.Popen: + sock = os.path.join(rundir, "serial.sock") + if os.path.exists(sock): + os.unlink(sock) + log(f"booting QEMU ({tag})") + return subprocess.Popen( + args, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, + ) + + +def poweroff_and_wait(con: Serial, proc: subprocess.Popen, timeout: float = 120) -> None: + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + log("VM did not power off in time; killing") + proc.kill() + proc.wait() + con.close() + + +# --- phases ---------------------------------------------------------------- +def write_scaffold(rundir: str, d: dict) -> None: + """Write the Phase-B scaffold files into the run dir (shared into the VM). + + Everything is materialised host-side and `cp`d into the target during + inject_phase_b — far more robust than printf-over-serial escaping. + """ + user = d["user"] + exp = d["expect"] + + def w(name: str, content: str) -> None: + with open(os.path.join(rundir, name), "w") as f: + f.write(content) + + # Parametrized expectations consumed by validate.sh (its sibling). + w("expect.env", "".join(f'EXPECT_{k}="{v}"\n' for k, v in exp.items())) + # The validation script defaults to the disk-layout asserter; feature-coverage + # descriptors point validate_script at lib/features.sh instead. Either reads + # the same expect.env (its EXPECT_* keys) and prints OK:/FAIL: + an exit code. + shutil.copy(os.path.join(HERE, d.get("validate_script", "lib/validate.sh")), + os.path.join(rundir, "validate.sh")) + + # The autorun runner: invoked by the login shell on the installed system. + w("e2e-bootstrap.sh", + "#!/usr/bin/env bash\n" + "set -uo pipefail\n" + 'echo "E2E_PHASEB_START"\n' + 'cd "$HOME"\n' + "# Wait for first-boot networking (NetworkManager via DHCP).\n" + "for i in $(seq 1 90); do getent hosts archlinux.org >/dev/null 2>&1 && break; sleep 2; done\n" + "./archwright bootstrap --config config.yaml; brc=$?\n" + 'echo "E2E_BOOTSTRAP_RC=$brc"\n' + "bash e2e-validate.sh; vrc=$?\n" + 'echo "E2E_VALIDATE_RC=$vrc"\n' + 'if [ "$brc" = 0 ] && [ "$vrc" = 0 ]; then echo "E2E_RESULT=PASS"; else echo "E2E_RESULT=FAIL"; fi\n' + 'echo "E2E_PHASEB_DONE"\n' + "sync\n" + "sudo systemctl poweroff\n") + + # NOPASSWD sudo so bootstrap (sudo) + poweroff run unattended. + w("sudoers-e2e", f"{user} ALL=(ALL) NOPASSWD: ALL\n") + + # Serial-getty autologin override (logs the user in on ttyS0 at next boot). + # `-f` in the login-options is essential: it tells login(1) to skip + # authentication — without it the autologin still prompts for a password and + # times out. + w("autologin.conf", + "[Service]\n" + "ExecStart=\n" + f"ExecStart=-/sbin/agetty -o '-p -f -- \\u' --keep-baud --autologin {user} " + "115200,38400,9600 ttyS0 $TERM\n") + + # Login-shell trigger (guarded to fire exactly once, then powers off inside + # e2e-bootstrap.sh). + w("bash_profile", + "# e2e autorun\n" + 'if [ -z "${E2E_RAN:-}" ] && [ -f "$HOME/e2e-bootstrap.sh" ]; then\n' + " export E2E_RAN=1\n" + ' bash "$HOME/e2e-bootstrap.sh"\n' + "fi\n") + + # Optional: a tiny local chezmoi source repo to inject into the target, so the + # dotfiles stage has a deterministic, offline `repo:` to clone+apply (no giant + # external clone). It applies `dot_e2e-dotfile` -> ~/.e2e-dotfile, which + # features.sh asserts. + if d.get("inject_repo"): + _build_chezmoi_repo(os.path.join(rundir, "injectrepo")) + + +def _build_chezmoi_repo(path: str) -> None: + """Create a minimal chezmoi source repo (a git repo with one managed dotfile).""" + shutil.rmtree(path, ignore_errors=True) + os.makedirs(path, exist_ok=True) + with open(os.path.join(path, "dot_e2e-dotfile"), "w") as f: + f.write("archwright e2e dotfile (applied by chezmoi)\n") + env = {**os.environ, + "GIT_AUTHOR_NAME": "e2e", "GIT_AUTHOR_EMAIL": "e2e@example.com", + "GIT_COMMITTER_NAME": "e2e", "GIT_COMMITTER_EMAIL": "e2e@example.com"} + subprocess.run(["git", "init", "-q", "-b", "main", path], check=True) + subprocess.run(["git", "-C", path, "add", "-A"], check=True) + subprocess.run(["git", "-C", path, "commit", "-q", "-m", "e2e dotfiles"], check=True, env=env) + + +def inject_phase_b(con: Serial, d: dict) -> None: + """As root on the live ISO (target remounted at /mnt), install the harness-only + Phase-B autorun scaffold into the installed system. The run dir is shared + read-only at /root/e2e; stageBinary already placed archwright + config.yaml in + the user's home, so we only add the e2e scripts + autologin wiring.""" + user = d["user"] + home = f"/mnt/home/{user}" + log("injecting Phase-B scaffold into target") + + # Remount the freshly installed root per the descriptor recipe, then the ESP + # at /mnt/boot. The orchestrator owns the ESP mount (via esp_part) and creates + # the mountpoint, because some roots (e.g. a btrfs @ subvol) ship no /boot dir. + # The mountpoint guard keeps it correct even if a recipe also mounts boot. + for cmd in d["root_mount"]: + con.run_ok(cmd, timeout=60) + con.run_ok(f"mountpoint -q /mnt/boot || {{ mkdir -p /mnt/boot && mount {d['esp_part']} /mnt/boot; }}", timeout=60) + + if d.get("diag"): + con.run("echo DIAG_DEFAULT; btrfs subvolume get-default /mnt 2>/dev/null; " + "echo DIAG_AT_HOME; ls -la /mnt/home 2>/dev/null; " + "echo DIAG_TOP; mkdir -p /mnt-top && mount -o subvolid=5 /dev/vda2 /mnt-top 2>/dev/null; " + "ls -la /mnt-top 2>/dev/null; echo DIAG_TOP_HOME; ls -la /mnt-top/home 2>/dev/null; " + "echo DIAG_SUBVOLS; btrfs subvolume list /mnt-top 2>/dev/null; " + "echo DIAG_FIND; find /mnt-top -maxdepth 5 -name archwright 2>/dev/null; " + "umount /mnt-top 2>/dev/null; echo DIAG_END", timeout=90) + + # NOPASSWD sudoers. + con.run_ok("install -d -m 755 /mnt/etc/sudoers.d", timeout=30) + con.run_ok("cp /root/e2e/sudoers-e2e /mnt/etc/sudoers.d/99-e2e && chmod 440 /mnt/etc/sudoers.d/99-e2e", timeout=30) + + # Serial-getty autologin + enable the unit for next boot. + con.run_ok("install -d /mnt/etc/systemd/system/serial-getty@ttyS0.service.d", timeout=30) + con.run_ok("cp /root/e2e/autologin.conf /mnt/etc/systemd/system/serial-getty@ttyS0.service.d/autologin.conf", timeout=30) + con.run_ok("install -d /mnt/etc/systemd/system/getty.target.wants", timeout=30) + con.run_ok( + "ln -sf /usr/lib/systemd/system/serial-getty@.service " + "/mnt/etc/systemd/system/getty.target.wants/serial-getty@ttyS0.service", timeout=30) + + # Scaffold scripts into the user's home. + con.run_ok(f"cp /root/e2e/e2e-bootstrap.sh {home}/e2e-bootstrap.sh", timeout=30) + con.run_ok(f"cp /root/e2e/validate.sh {home}/e2e-validate.sh", timeout=30) + con.run_ok(f"cp /root/e2e/expect.env {home}/expect.env", timeout=30) + con.run_ok(f"cp /root/e2e/bash_profile {home}/.bash_profile", timeout=30) + # Inject the local chezmoi source repo (for the dotfiles descriptor) under the + # user's home so the configured file:// repo resolves offline. + if d.get("inject_repo"): + con.run_ok(f"cp -r /root/e2e/injectrepo /mnt{d['inject_repo']}", timeout=60) + # chown in the chroot — the live ISO has no such user; only the target does. + con.run_ok(f"arch-chroot /mnt chown -R {user}:{user} /home/{user}", timeout=60) + + # Best-effort: surface kernel/systemd boot on serial during the disk phase. + if d.get("grub_serial"): + con.run( + "sed -i 's#GRUB_CMDLINE_LINUX_DEFAULT=\"#GRUB_CMDLINE_LINUX_DEFAULT=\"console=ttyS0,115200 #' " + "/mnt/etc/default/grub && arch-chroot /mnt grub-mkconfig -o /boot/grub/grub.cfg", + timeout=180) + + # Recursive: handles nested mounts (ESP at /mnt/boot, btrfs @home at /mnt/home). + con.run("umount -R /mnt", timeout=30) + con.run("vgchange -an vg0", timeout=30) + + +def run_descriptor(d: dict, keep: bool, phase_b_only: bool = False) -> bool: + """Acquire a per-descriptor lock (so two invocations can't collide on the same + run dir / disks / serial socket — across threads AND separate processes), then + run it.""" + name = d["name"] + _log_ctx.tag = name + os.makedirs(os.path.join(WORK, "runs"), exist_ok=True) + lockf = open(os.path.join(WORK, "runs", f".{name}.lock"), "w") + try: + fcntl.flock(lockf, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + log(f"another run for {name} is already active; skipping " + f"(don't run the same descriptor concurrently)") + lockf.close() + return False + try: + return _run_descriptor(d, keep, phase_b_only) + finally: + fcntl.flock(lockf, fcntl.LOCK_UN) + lockf.close() + + +def _run_descriptor(d: dict, keep: bool, phase_b_only: bool = False) -> bool: + name = d["name"] + _log_ctx.tag = name # attribute this thread's log lines under --jobs + rundir = os.path.join(WORK, "runs", name) + mem = d.get("mem", "4G") + smp = d.get("smp", "4") + serial_log = os.path.join(rundir, "serial.log") + + # --phase-b-only reuses the already-installed disks from a prior run (fast + # iteration on the Phase B path): skip the build/wipe/install entirely. + if phase_b_only: + if not os.path.isfile(os.path.join(rundir, "disk1.qcow2")): + log(f"--phase-b-only: no installed disks in {rundir}; run a full pass first") + return False + log(f"=== run {name} (phase-b-only) -> {rundir} ===") + return _phase_b(d, rundir, mem, smp, serial_log, keep) + + shutil.rmtree(rundir, ignore_errors=True) + os.makedirs(rundir, exist_ok=True) + log(f"=== run {name} -> {rundir} ===") + + # Build the binary into the run dir (shared into the VM; also what stageBinary + # copies into the target home). + subprocess.run(["go", "build", "-o", os.path.join(rundir, "archwright"), "."], + cwd=REPO, check=True) + shutil.copy(os.path.join(HERE, d["config"]), os.path.join(rundir, "config.yaml")) + write_scaffold(rundir, d) + shutil.copy(OVMF_VARS, os.path.join(rundir, "OVMF_VARS.fd")) + + # Fresh disks. + for i, size in enumerate(d["disks"]): + subprocess.run(["qemu-img", "create", "-f", "qcow2", + os.path.join(rundir, f"disk{i+1}.qcow2"), size], + check=True, stdout=subprocess.DEVNULL) + + # ---- Phase A: ISO boot, install, inject scaffold -------------------- + kern, init, label = ensure_kernel() + args = qemu_common(rundir, d["disks"], mem, smp) + [ + "-cdrom", iso_path(), + "-kernel", kern, "-initrd", init, + "-append", + f"archisobasedir=arch archisolabel={label} cow_spacesize=2G rw " + "console=ttyS0,115200 " + "systemd.mount-extra=e2e:/root/e2e:9p:trans=virtio,version=9p2000.L,ro", + "-virtfs", + f"local,path={rundir},mount_tag=e2e,security_model=none,readonly=on", + ] + proc = boot(args, rundir, "ISO / Phase A") + con = Serial(os.path.join(rundir, "serial.sock"), serial_log) + ok = False + iso_result = None # set when the descriptor validates on the ISO (encrypted layouts) + try: + con.wait_ready(timeout=420) + log("live ISO shell ready; staging binary + config") + con.run_ok("cp /root/e2e/archwright /root/archwright && chmod +x /root/archwright", timeout=30) + con.run_ok("cp /root/e2e/config.yaml /root/config.yaml", timeout=30) + log("running archwright install --yes (this pacstraps; minutes)") + rc = con.run("/root/archwright install --yes --config /root/config.yaml", timeout=2400) + if rc != 0: + log(f"PHASE A FAILED: archwright install rc={rc} (see {serial_log})") + return False + log("Phase A install complete") + # Encrypted layouts skip Phase B staging (postInstall bails on a LUKS + # remount we haven't implemented), so they validate the on-disk LUKS + # layout here on the live ISO instead of booting Phase B. + if d.get("iso_validate"): + iso_result = run_iso_validate(con, d) + elif d.get("phase_b", True): + inject_phase_b(con, d) + con.run("systemctl poweroff", timeout=10) + ok = True + except (TimeoutError, EOFError, RuntimeError) as e: + log(f"PHASE A ERROR: {e} (see {serial_log})") + finally: + poweroff_and_wait(con, proc, timeout=120) + + if d.get("iso_validate"): + log(f"ISO-validate result: {'PASS' if (ok and iso_result) else 'FAIL'}") + return bool(ok and iso_result) + if not ok or not d.get("phase_b", True): + return ok + + return _phase_b(d, rundir, mem, smp, serial_log, keep) + + +def run_iso_validate(con: Serial, d: dict) -> bool: + """Run the descriptor's on-ISO assertions (used for encrypted layouts, which + can't run Phase B). Each command must exit 0; the throwaway install password + is 'installme' (what --yes feeds archinstall), so a LUKS open uses it.""" + log("running on-ISO layout validation (encrypted)") + allok = True + for cmd in d["iso_validate"]: + rc = con.run(cmd, timeout=120) + log(f" [{'OK' if rc == 0 else 'FAIL'}] {cmd}") + if rc != 0: + allok = False + return allok + + +def _phase_b(d: dict, rundir: str, mem: str, smp: str, serial_log: str, keep: bool) -> bool: + """Disk boot: the injected scaffold autologins, runs bootstrap + validate, and + powers off. We just watch the serial for the PASS/FAIL marker.""" + args = qemu_common(rundir, d["disks"], mem, smp) # no cdrom/kernel/9p; boot from disk + proc = boot(args, rundir, "disk / Phase B") + con = Serial(os.path.join(rundir, "serial.sock"), serial_log) + result = False + try: + log("waiting for Phase B (bootstrap + validate; minutes)") + m = con.expect(r"E2E_RESULT=(PASS|FAIL)", timeout=2700) + result = m.group(1) == "PASS" + try: + con.expect(r"E2E_PHASEB_DONE", timeout=120) + except (TimeoutError, EOFError): + pass + log(f"Phase B result: {'PASS' if result else 'FAIL'}") + except (TimeoutError, EOFError) as e: + log(f"PHASE B ERROR: {e} (see {serial_log})") + finally: + poweroff_and_wait(con, proc, timeout=120) + + if not keep and result: + # Reclaim the big qcow2 files on success; keep logs. + for i in range(len(d["disks"])): + try: + os.unlink(os.path.join(rundir, f"disk{i+1}.qcow2")) + except FileNotFoundError: + pass + return result + + +def main() -> int: + ap = argparse.ArgumentParser(description="archwright VM e2e harness") + ap.add_argument("names", nargs="*", help="matrix descriptor names to run (default: all)") + ap.add_argument("--list", action="store_true", help="list descriptors and exit") + ap.add_argument("--keep", action="store_true", help="keep qcow2 disks after a passing run") + ap.add_argument("--phase-b-only", action="store_true", + help="skip install; re-boot existing installed disks and re-run Phase B") + ap.add_argument("-j", "--jobs", type=int, default=1, + help="run up to N descriptors concurrently (each VM ~4G/4vcpu); default 1") + args = ap.parse_args() + + if args.list: + for d in MATRIX: + print(d["name"]) + return 0 + + selected = MATRIX if not args.names else [d for d in MATRIX if d["name"] in args.names] + if not selected: + sys.exit(f"no matching descriptors: {args.names} (have: {[d['name'] for d in MATRIX]})") + + os.makedirs(WORK, exist_ok=True) + jobs = max(1, min(args.jobs, len(selected))) + + def run_one(d: dict) -> bool: + try: + return run_descriptor(d, args.keep, args.phase_b_only) + except Exception as e: # noqa: BLE001 — report and continue the matrix + log(f"run {d['name']} crashed: {e}") + return False + + results: dict[str, bool] = {} + if jobs == 1: + for d in selected: + results[d["name"]] = run_one(d) + else: + # Each run is fully isolated (own rundir/disks/serial socket/NVRAM), so a + # thread per descriptor is safe; threads idle on the qemu/serial I/O. Warm + # the shared kernel cache once up front to avoid a startup stampede. + log(f"running {len(selected)} descriptor(s) with up to {jobs} concurrent VM(s)") + ensure_kernel() + with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool: + futs = {pool.submit(run_one, d): d["name"] for d in selected} + for fut in concurrent.futures.as_completed(futs): + results[futs[fut]] = fut.result() + + print("\n==== e2e summary ====") + for name in sorted(results): + print(f" {'PASS' if results[name] else 'FAIL'} {name}") + return 0 if all(results.values()) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/e2e/vm/lib/features.sh b/test/e2e/vm/lib/features.sh new file mode 100644 index 0000000..3217675 --- /dev/null +++ b/test/e2e/vm/lib/features.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# In-VM validation for the *feature/stage-coverage* e2e descriptors (kept separate +# from the disk-layout asserter, lib/validate.sh). It runs as the user on the +# installed system after `archwright bootstrap` and checks each feature named in +# EXPECT_FEATURES (a space-separated token list injected via expect.env). Same +# contract as validate.sh: "OK:" / "FAIL:" lines and a non-zero exit on any fail. +# +# Tokens (add one when you add a feature descriptor): +# reflector mirrorlist was regenerated by reflector (Phase A) +# kernel-zen the linux-zen custom kernel installed + set default (Phase A) +# plymouth plymouth default theme set to spinner +# hooks the lifecycle/per-stage hook markers were created +# setup the setup-stage git clone landed +# services the enabled systemd unit (fstrim.timer) is enabled +# dotfiles the dotfiles manager applied (marker dotfile present) +# flatpak the configured flatpak app is installed +# desktop the KDE stage wrote look&feel/color config +# repos the custom repo's signing key is in the pacman keyring +set -uo pipefail + +fails=0 +ok() { echo "OK: $*"; } +fail() { echo "FAIL: $*"; fails=$((fails + 1)); } + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +[[ -f "$HERE/expect.env" ]] && source "$HERE/expect.env" +: "${EXPECT_FEATURES:=}" + +echo "=== e2e feature validation: [$EXPECT_FEATURES] ===" + +for feat in $EXPECT_FEATURES; do + case "$feat" in + reflector) + # reflector --save writes a "# … reflector …" header into the mirrorlist. + grep -qi 'reflector' /etc/pacman.d/mirrorlist \ + && ok "mirrorlist generated by reflector" || fail "mirrorlist has no reflector header" + ;; + kernel-zen) + [[ -e /boot/vmlinuz-linux-zen ]] && ok "linux-zen kernel image present" || fail "/boot/vmlinuz-linux-zen missing" + pacman -Q linux-zen >/dev/null 2>&1 && ok "linux-zen package installed" || fail "linux-zen not installed" + # kernel.default linux-zen -> grub default menuentry references it. + if [[ -f /boot/grub/grub.cfg ]]; then + grep -q 'linux-zen' /boot/grub/grub.cfg && ok "grub.cfg references linux-zen" || fail "grub.cfg has no linux-zen entry" + fi + ;; + plymouth) + if [[ -f /etc/plymouth/plymouthd.conf ]] && grep -qi '^\s*Theme\s*=\s*spinner' /etc/plymouth/plymouthd.conf; then + ok "plymouth default theme is spinner" + else + # Fall back to the tool's own view of the default theme. + [[ "$(plymouth-set-default-theme 2>/dev/null)" == "spinner" ]] \ + && ok "plymouth default theme is spinner" || fail "plymouth theme not set to spinner" + fi + ;; + hooks) + for f in "$HOME/e2e-pre-bootstrap" "$HOME/e2e-post-bootstrap" \ + "$HOME/e2e-before-packages" "$HOME/e2e-after-packages"; do + [[ -f "$f" ]] && ok "hook marker $(basename "$f")" || fail "missing hook marker $f" + done + [[ -f /e2e-root-hook ]] && ok "root hook marker /e2e-root-hook" || fail "missing root hook marker /e2e-root-hook" + ;; + setup) + [[ -d "$HOME/e2e-clone/.git" ]] && ok "setup-stage clone present" || fail "setup clone ~/e2e-clone missing" + ;; + services) + st="$(systemctl is-enabled fstrim.timer 2>/dev/null)" + [[ "$st" == "enabled" ]] && ok "fstrim.timer enabled" || fail "fstrim.timer not enabled (got '$st')" + ;; + dotfiles) + # Pass if a controlled repo applied its marker, OR (repo-agnostic) the + # manager cloned its source tree — proves the dotfiles stage ran. + if [[ -f "$HOME/.e2e-dotfile" ]]; then + ok "dotfiles applied (~/.e2e-dotfile)" + elif [[ -d "$HOME/.local/share/chezmoi/.git" || -d "$HOME/.dotfiles" || -d "$HOME/.local/share/yadm/repo.git" ]]; then + ok "dotfiles source cloned by the manager" + else + fail "no dotfiles applied/cloned" + fi + ;; + flatpak) + if flatpak list --columns=application 2>/dev/null | grep -q "${EXPECT_FLATPAK_APP:-}"; then + ok "flatpak app ${EXPECT_FLATPAK_APP:-} installed" + else + fail "flatpak app ${EXPECT_FLATPAK_APP:-} not installed" + fi + ;; + desktop) + # The KDE stage runs plasma-apply-* (look&feel/colorscheme). Headless, with + # no running plasma session, those may not persist to ~/.config/kdeglobals, + # so asserting the applied scheme is unreliable. What we CAN verify is that + # the stage's prerequisites are installed and the tools are present (the + # stage having run without error is already gated by E2E_BOOTSTRAP_RC=0). + # If kdeglobals did get written, assert the scheme too (best-effort). + if command -v plasma-apply-colorscheme >/dev/null 2>&1; then + ok "plasma-apply tools present (KDE stage ran)" + else + fail "plasma-apply-colorscheme missing (plasma not installed)" + fi + if grep -qi "${EXPECT_KDE_COLORSCHEME:-BreezeDark}" "$HOME/.config/kdeglobals" 2>/dev/null; then + ok "KDE color scheme persisted to kdeglobals" + fi + ;; + repos) + if sudo pacman-key --list-keys "${EXPECT_REPO_KEY:-}" >/dev/null 2>&1; then + ok "repo signing key ${EXPECT_REPO_KEY:-} in keyring" + else + fail "repo key ${EXPECT_REPO_KEY:-} not in keyring" + fi + ;; + *) + fail "unknown feature token: $feat" + ;; + esac +done + +echo "=== feature validation complete: $fails failure(s) ===" +exit $((fails > 0 ? 1 : 0)) diff --git a/test/e2e/vm/lib/validate.sh b/test/e2e/vm/lib/validate.sh new file mode 100755 index 0000000..1d66151 --- /dev/null +++ b/test/e2e/vm/lib/validate.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# In-VM validation, run as the user on the *installed* system after `archwright +# bootstrap`. Expectations are injected as EXPECT_* env vars (see expect.env, +# generated by the orchestrator from the matrix descriptor) so this one script +# covers every layout — it asserts the things that must be true regardless of +# config, plus the layout-conditional ones gated on EXPECT_LAYOUT. +# +# It prints "OK: ..." per passing check and "FAIL: ..." per failure, and exits +# non-zero if any check failed. The orchestrator keys off the exit code (relayed +# through e2e-bootstrap.sh's E2E_VALIDATE_RC marker). +set -uo pipefail + +fails=0 +ok() { echo "OK: $*"; } +fail() { echo "FAIL: $*"; fails=$((fails + 1)); } + +# Source the expectations written next to this script. +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +[[ -f "$HERE/expect.env" ]] && source "$HERE/expect.env" + +: "${EXPECT_LAYOUT:=lvm}" +: "${EXPECT_ROOT_FS:=}" +: "${EXPECT_SWAP:=swapfile}" +: "${EXPECT_BOOTLOADER:=grub}" +: "${EXPECT_HOSTNAME:=}" +: "${EXPECT_USER:=}" +: "${EXPECT_VG:=}" +: "${EXPECT_LV:=}" +: "${EXPECT_PV_COUNT:=}" +: "${EXPECT_PACKAGES:=}" +: "${EXPECT_AUR_HELPER:=yay}" +: "${EXPECT_ENCRYPTION:=0}" + +echo "=== e2e validation (layout=$EXPECT_LAYOUT fs=$EXPECT_ROOT_FS swap=$EXPECT_SWAP bootloader=$EXPECT_BOOTLOADER) ===" + +# --- root filesystem ------------------------------------------------------- +root_src="$(findmnt -n -o SOURCE /)" +root_fstype="$(findmnt -n -o FSTYPE /)" +[[ -n "$root_src" ]] && ok "root mounted from $root_src ($root_fstype)" || fail "root not mounted" +if [[ -n "$EXPECT_ROOT_FS" ]]; then + [[ "$root_fstype" == "$EXPECT_ROOT_FS" ]] && ok "root fstype is $EXPECT_ROOT_FS" \ + || fail "root fstype expected $EXPECT_ROOT_FS, got $root_fstype" +fi + +# --- ESP at /boot ---------------------------------------------------------- +boot_fstype="$(findmnt -n -o FSTYPE /boot 2>/dev/null || true)" +[[ "$boot_fstype" == "vfat" ]] && ok "/boot is vfat (ESP)" || fail "/boot expected vfat, got '${boot_fstype:-}'" + +# --- layout-specific ------------------------------------------------------- +case "$EXPECT_LAYOUT" in +lvm) + if [[ -n "$EXPECT_VG" ]]; then + sudo vgs --noheadings -o vg_name 2>/dev/null | tr -d ' ' | grep -qx "$EXPECT_VG" \ + && ok "VG $EXPECT_VG exists" || fail "VG $EXPECT_VG not found" + fi + if [[ -n "$EXPECT_LV" ]]; then + sudo lvs --noheadings -o lv_name "$EXPECT_VG" 2>/dev/null | tr -d ' ' | grep -qx "$EXPECT_LV" \ + && ok "LV $EXPECT_LV exists in $EXPECT_VG" || fail "LV $EXPECT_LV not found" + fi + if [[ -n "$EXPECT_PV_COUNT" ]]; then + n="$(sudo vgs --noheadings -o pv_count "$EXPECT_VG" 2>/dev/null | tr -d ' ')" + [[ "$n" == "$EXPECT_PV_COUNT" ]] && ok "VG $EXPECT_VG has $n PV(s)" \ + || fail "VG $EXPECT_VG expected $EXPECT_PV_COUNT PVs, got '$n'" + fi + ;; +btrfs) + [[ "$root_fstype" == "btrfs" ]] && ok "root is btrfs" || fail "root expected btrfs, got $root_fstype" + subs="$(sudo btrfs subvolume list / 2>/dev/null | wc -l)" + [[ "$subs" -ge 1 ]] && ok "btrfs has $subs subvolume(s)" || fail "no btrfs subvolumes found" + ;; +plain) + # root_src should be a bare partition (no dm/lvm mapper). + [[ "$root_src" == /dev/* && "$root_src" != /dev/mapper/* ]] \ + && ok "plain root on partition $root_src" || fail "plain root unexpected source $root_src" + ;; +esac + +# --- swap ------------------------------------------------------------------ +swap_active="$(swapon --show=NAME --noheadings 2>/dev/null | head -1)" +case "$EXPECT_SWAP" in +swapfile) + swapon --show 2>/dev/null | grep -q '/swapfile' && ok "swapfile active" || fail "swapfile not active" + ;; +partition) + [[ -n "$swap_active" ]] && ok "swap partition active ($swap_active)" || fail "no active swap" + ;; +zram) + if zramctl 2>/dev/null | grep -qi '\[SWAP\]\|zram'; then ok "zram present"; else + [[ -n "$swap_active" ]] && ok "swap active ($swap_active)" || fail "zram/swap not active" + fi + ;; +none) + [[ -z "$swap_active" ]] && ok "no swap (as configured)" || fail "swap active but expected none ($swap_active)" + ;; +esac + +# --- bootloader ------------------------------------------------------------ +case "$EXPECT_BOOTLOADER" in +grub) + [[ -f /boot/grub/grub.cfg ]] && ok "grub.cfg present" || fail "/boot/grub/grub.cfg missing" + ;; +systemd-boot) + if sudo bootctl is-installed >/dev/null 2>&1; then ok "systemd-boot installed"; else + [[ -d /boot/loader/entries ]] && ok "systemd-boot loader entries present" || fail "systemd-boot not detected" + fi + ;; +esac + +# --- user + groups --------------------------------------------------------- +if [[ -n "$EXPECT_USER" ]]; then + id "$EXPECT_USER" >/dev/null 2>&1 && ok "user $EXPECT_USER exists" || fail "user $EXPECT_USER missing" + id -nG "$EXPECT_USER" 2>/dev/null | tr ' ' '\n' | grep -qx wheel \ + && ok "user $EXPECT_USER in wheel" || fail "user $EXPECT_USER not in wheel" +fi + +# --- hostname -------------------------------------------------------------- +if [[ -n "$EXPECT_HOSTNAME" ]]; then + hn="$(cat /etc/hostname 2>/dev/null)" + [[ "$hn" == "$EXPECT_HOSTNAME" ]] && ok "hostname is $EXPECT_HOSTNAME" || fail "hostname expected $EXPECT_HOSTNAME, got '$hn'" +fi + +# --- AUR helper (installed by the yay stage) ------------------------------- +if [[ -n "$EXPECT_AUR_HELPER" ]]; then + command -v "$EXPECT_AUR_HELPER" >/dev/null 2>&1 \ + && ok "AUR helper $EXPECT_AUR_HELPER installed" || fail "AUR helper $EXPECT_AUR_HELPER not on PATH" +fi + +# --- Phase B packages ------------------------------------------------------ +for p in $EXPECT_PACKAGES; do + pacman -Q "$p" >/dev/null 2>&1 && ok "package $p installed" || fail "package $p not installed" +done + +echo "=== validation complete: $fails failure(s) ===" +exit $((fails > 0 ? 1 : 0)) diff --git a/test/e2e/vm/matrix/btrfs.py b/test/e2e/vm/matrix/btrfs.py new file mode 100644 index 0000000..ada49b4 --- /dev/null +++ b/test/e2e/vm/matrix/btrfs.py @@ -0,0 +1,44 @@ +# btrfs-layout e2e descriptors. See e2e.py's load_matrix() for the schema. +# Single virtio disk (vda): ESP p1 + btrfs root p2 carrying subvolumes; the root +# is mounted via its `@` subvolume. swap is zram (no on-disk swap for btrfs). +DESCRIPTORS = [ + { + "name": "btrfs-basic", + "config": "configs/btrfs-basic.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + # archinstall installs the system into the btrfs TOP-LEVEL subvolume (the + # default, subvolid 5) — the configured @ is created but not used as root — + # so we mount the bare partition (its default subvol), matching archwright's + # own postInstall/rootDevice. (That the named subvolumes aren't the root + # mount is a separate btrfs finding noted in README.md.) + "root_mount": [ + "mount /dev/vda2 /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "btrfs", "ROOT_FS": "btrfs", "SWAP": "zram", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, + { + "name": "btrfs-snapper", + "config": "configs/btrfs-snapper.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "mount /dev/vda2 /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "btrfs", "ROOT_FS": "btrfs", "SWAP": "zram", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq snapper", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, +] diff --git a/test/e2e/vm/matrix/encryption.py b/test/e2e/vm/matrix/encryption.py new file mode 100644 index 0000000..abd12f7 --- /dev/null +++ b/test/e2e/vm/matrix/encryption.py @@ -0,0 +1,40 @@ +# LUKS encryption e2e descriptors. These are reverse-engineered + VM-validation- +# pending (see CLAUDE.md archinstall-drift rule): archwright's encrypted-install +# postInstall deliberately skips Phase B staging until a LUKS remount is +# implemented, so these descriptors set phase_b False and instead assert the +# on-disk LUKS layout on the live ISO via `iso_validate` (each command must exit +# 0; the LUKS passphrase is the --yes throwaway password "installme"). For both +# layouts the encrypted partition is vda2 (the PV partition for lvm_on_luks, the +# single root partition for luks). +DESCRIPTORS = [ + { + "name": "enc-lvm", + "config": "configs/enc-lvm.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": False, + "esp_part": "/dev/vda1", + # vda2 must be a LUKS container, and the throwaway install passphrase must + # unlock a keyslot. `--test-passphrase` reads stdin as a PASSPHRASE (no + # trailing `-`, which would mean a keyfile) and creates no mapping, so it + # is immune to the install leaving the VG/dm-crypt mapped. + "iso_validate": [ + "cryptsetup isLuks /dev/vda2", + "echo -n installme | cryptsetup luksOpen --test-passphrase /dev/vda2", + ], + "expect": {"LAYOUT": "lvm", "ENCRYPTION": "1"}, + }, + { + "name": "enc-luks-plain", + "config": "configs/enc-luks-plain.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": False, + "esp_part": "/dev/vda1", + "iso_validate": [ + "cryptsetup isLuks /dev/vda2", + "echo -n installme | cryptsetup luksOpen --test-passphrase /dev/vda2", + ], + "expect": {"LAYOUT": "plain", "ENCRYPTION": "1"}, + }, +] diff --git a/test/e2e/vm/matrix/features.py b/test/e2e/vm/matrix/features.py new file mode 100644 index 0000000..1df88e8 --- /dev/null +++ b/test/e2e/vm/matrix/features.py @@ -0,0 +1,28 @@ +# Feature / stage-coverage e2e descriptors — SEPARATE from the disk-layout matrix. +# Each runs on a minimal single-disk LVM layout (the layouts themselves are covered +# elsewhere) and exercises Phase A/B *stage features* instead, validated by +# lib/features.sh (validate_script) against the EXPECT_FEATURES token list. +# +# features-min bundles every feature that does NOT need a heavy desktop/flatpak/ +# dotfiles/repo download, so the common case is one cheap VM run. Heavier features +# (flatpak runtimes, a KDE desktop, dotfiles, a custom repo) get their own +# descriptors so they can be run on demand — see the sibling matrix files. +_LVM_SINGLE_MOUNT = ["vgchange -ay vg0", "mount /dev/vg0/root /mnt"] + +DESCRIPTORS = [ + { + "name": "features-min", + "config": "configs/features-min.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": list(_LVM_SINGLE_MOUNT), + "grub_serial": True, + "validate_script": "lib/features.sh", + "expect": { + "FEATURES": "reflector kernel-zen plymouth hooks setup services", + "HOSTNAME": "arch-e2e", "USER": "e2e", + }, + }, +] diff --git a/test/e2e/vm/matrix/features_extra.py b/test/e2e/vm/matrix/features_extra.py new file mode 100644 index 0000000..f160a26 --- /dev/null +++ b/test/e2e/vm/matrix/features_extra.py @@ -0,0 +1,84 @@ +# Heavier feature / stage-coverage e2e descriptors — SEPARATE from both the disk +# layout matrix (matrix/lvm.py et al.) and the cheap feature bundle (matrix/features.py). +# +# features-min bundles every feature that needs no heavy download. These four pull +# real artifacts (a flatpak runtime, a dotfiles repo, a custom repo key, a Plasma +# desktop), so they get their own on-demand descriptors. All share the same minimal +# single-disk LVM layout as configs/features-min.yaml; each exercises one Phase A/B +# stage feature, validated by lib/features.sh against its EXPECT_FEATURES token. +_LVM_SINGLE_MOUNT = ["vgchange -ay vg0", "mount /dev/vg0/root /mnt"] + +DESCRIPTORS = [ + { + "name": "features-flatpak", + "config": "configs/features-flatpak.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": list(_LVM_SINGLE_MOUNT), + "grub_serial": True, + "validate_script": "lib/features.sh", + "expect": { + "FEATURES": "flatpak", + "FLATPAK_APP": "com.github.tchx84.Flatseal", + "HOSTNAME": "arch-e2e", "USER": "e2e", + }, + }, + { + "name": "features-dotfiles", + "config": "configs/features-dotfiles.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": list(_LVM_SINGLE_MOUNT), + "grub_serial": True, + "validate_script": "lib/features.sh", + # Inject a tiny local chezmoi source repo here so the config's + # file:///home/e2e/dots resolves offline + deterministically. + "inject_repo": "/home/e2e/dots", + "expect": { + "FEATURES": "dotfiles", + "HOSTNAME": "arch-e2e", "USER": "e2e", + }, + }, + { + "name": "features-repos", + "config": "configs/features-repos.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": list(_LVM_SINGLE_MOUNT), + "grub_serial": True, + "validate_script": "lib/features.sh", + "expect": { + "FEATURES": "repos", + "REPO_KEY": "3056513887B78AEB", + "HOSTNAME": "arch-e2e", "USER": "e2e", + }, + }, + { + # Heaviest descriptor: pulls a Plasma desktop (plasma-desktop -> + # plasma-workspace) just to get the plasma-apply-* helpers. The KDE stage + # runs those helpers, which normally expect a running Plasma session; run + # headless they may behave differently and the stage only warns on failure, + # so this is best-effort — features.sh asserts the color scheme landed in + # ~/.config/kdeglobals when it does. + "name": "features-desktop", + "config": "configs/features-desktop.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": list(_LVM_SINGLE_MOUNT), + "grub_serial": True, + "validate_script": "lib/features.sh", + "expect": { + "FEATURES": "desktop", + "KDE_COLORSCHEME": "BreezeDark", + "HOSTNAME": "arch-e2e", "USER": "e2e", + }, + }, +] diff --git a/test/e2e/vm/matrix/lvm.py b/test/e2e/vm/matrix/lvm.py new file mode 100644 index 0000000..cbc2edf --- /dev/null +++ b/test/e2e/vm/matrix/lvm.py @@ -0,0 +1,22 @@ +# LVM-layout e2e descriptors. See e2e.py's load_matrix() for the schema. +DESCRIPTORS = [ + { + "name": "lvm-multi", + "config": "configs/lvm-multi.yaml", + "disks": ["12G", "4G", "4G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "vgchange -ay vg0", + "mount /dev/vg0/root /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "lvm", "ROOT_FS": "ext4", "SWAP": "swapfile", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "VG": "vg0", "LV": "root", "PV_COUNT": "3", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, +] diff --git a/test/e2e/vm/matrix/lvm_variants.py b/test/e2e/vm/matrix/lvm_variants.py new file mode 100644 index 0000000..eb24334 --- /dev/null +++ b/test/e2e/vm/matrix/lvm_variants.py @@ -0,0 +1,43 @@ +# LVM-layout variant e2e descriptors: single-disk single-LV (xfs root) and +# single-disk multi-volume (fixed xfs root + rest-of-VG ext4 /home). +# See e2e.py's load_matrix() for the schema. +DESCRIPTORS = [ + { + "name": "lvm-single", + "config": "configs/lvm-single.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "vgchange -ay vg0", + "mount /dev/vg0/root /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "lvm", "ROOT_FS": "xfs", "SWAP": "swapfile", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "VG": "vg0", "LV": "root", "PV_COUNT": "1", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, + { + "name": "lvm-volumes", + "config": "configs/lvm-volumes.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "vgchange -ay vg0", + "mount /dev/vg0/root /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "lvm", "ROOT_FS": "xfs", "SWAP": "swapfile", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "VG": "vg0", "LV": "root", "PV_COUNT": "1", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, +] diff --git a/test/e2e/vm/matrix/plain.py b/test/e2e/vm/matrix/plain.py new file mode 100644 index 0000000..7bd6f31 --- /dev/null +++ b/test/e2e/vm/matrix/plain.py @@ -0,0 +1,73 @@ +# Plain-layout e2e descriptors. See e2e.py's load_matrix() for the schema. +# Exercises every swap type on a single-disk ESP + plain root. With a swap +# partition the root lands on vda3 (swap is p2); otherwise root is vda2. +DESCRIPTORS = [ + { + "name": "plain-ext4", + "config": "configs/plain-ext4.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "mount /dev/vda2 /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "plain", "ROOT_FS": "ext4", "SWAP": "swapfile", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, + { + "name": "plain-xfs", + "config": "configs/plain-xfs.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "mount /dev/vda2 /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "plain", "ROOT_FS": "xfs", "SWAP": "none", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, + { + "name": "plain-zram", + "config": "configs/plain-zram.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "mount /dev/vda2 /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "plain", "ROOT_FS": "ext4", "SWAP": "zram", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, + { + "name": "plain-swappart", + "config": "configs/plain-swappart.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "mount /dev/vda3 /mnt", + ], + "grub_serial": True, + "expect": { + "LAYOUT": "plain", "ROOT_FS": "ext4", "SWAP": "partition", + "BOOTLOADER": "grub", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, +] diff --git a/test/e2e/vm/matrix/systemd_boot.py b/test/e2e/vm/matrix/systemd_boot.py new file mode 100644 index 0000000..024ddcb --- /dev/null +++ b/test/e2e/vm/matrix/systemd_boot.py @@ -0,0 +1,42 @@ +# systemd-boot bootloader e2e descriptors. See e2e.py's load_matrix() for the schema. +# These exist to VM-validate the reverse-engineered systemd-boot archinstall path +# (install + boot end-to-end) across the lvm and plain layouts. grub_serial is False: +# there is no GRUB to edit with systemd-boot; the serial-getty autologin still fires. +DESCRIPTORS = [ + { + "name": "sdboot-lvm", + "config": "configs/sdboot-lvm.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "vgchange -ay vg0", + "mount /dev/vg0/root /mnt", + ], + "grub_serial": False, + "expect": { + "LAYOUT": "lvm", "ROOT_FS": "ext4", "SWAP": "swapfile", + "BOOTLOADER": "systemd-boot", "HOSTNAME": "arch-e2e", "USER": "e2e", + "VG": "vg0", "LV": "root", "PV_COUNT": "1", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, + { + "name": "sdboot-plain", + "config": "configs/sdboot-plain.yaml", + "disks": ["12G"], + "user": "e2e", + "phase_b": True, + "esp_part": "/dev/vda1", + "root_mount": [ + "mount /dev/vda2 /mnt", + ], + "grub_serial": False, + "expect": { + "LAYOUT": "plain", "ROOT_FS": "ext4", "SWAP": "swapfile", + "BOOTLOADER": "systemd-boot", "HOSTNAME": "arch-e2e", "USER": "e2e", + "PACKAGES": "tree jq", "AUR_HELPER": "yay", "ENCRYPTION": "0", + }, + }, +] diff --git a/test/vm.sh b/test/vm.sh index ff8bcc3..67d76fc 100755 --- a/test/vm.sh +++ b/test/vm.sh @@ -23,7 +23,7 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="${VM_WORK:-$REPO/.vm}" -ISO="${ARCH_ISO:-$HOME/Downloads/archlinux-2026.06.01-x86_64.iso}" +ISO="${ARCH_ISO:-$REPO/.iso/archlinux-2026.06.01-x86_64.iso}" OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd" OVMF_VARS_SRC="/usr/share/edk2/x64/OVMF_VARS.4m.fd"