From 5f4f9c5daeca29a24dc26aab5d5508ca32ab735e Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:25:40 -0400 Subject: [PATCH 1/2] test(install): real install-to-disk + boot-from-disk validation (server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Tier-2 boot validation: prove the actual clean-disk installer works end to end, not just that an edition config boots (edition-*-boot already cover that). tests/install/server-install.nix — two-phase nixosTest (the nixpkgs installer pattern): an installation-environment VM runs the REAL scripts/install-image.sh against a blank /dev/vda, then the same disk is rebooted as a useBootLoader 'target' and asserted to reach multi-user.target on its own systemd-boot ESP (server edition; smallest closure). Installs a PREBUILT system toplevel offline (closure seeded read-only via extraDependencies) so the test does no network build — lighter + more deterministic than rebuilding in-VM. Asserts: GPT layout + EFI/nixos labels, systemd-bootx64.efi on the ESP, hostname/user/sshd, headless. scripts/install-image.sh — add the modes the test (and unattended installs) need: --assume-yes / SOURCEOS_ASSUME_YES skip the typed confirmation + final reboot --system / SOURCEOS_SYSTEM install a prebuilt system (no flake build) Refactored arg parsing into a flag loop; interactive behavior unchanged when neither is set. shellcheck-clean. flake.nix — checks.x86_64-linux.edition-server-install (skip stub elsewhere). image-tests.yml — opt-in run_install job (heavy: full install + 2 boots), so it never gates ordinary PRs. Promote to required once a CI dispatch confirms green. Validated locally: arg parser unit-tested, installer shellcheck-clean, the full nixosTest evaluates to a build derivation (KVM run happens in CI via dispatch). --- .github/workflows/image-tests.yml | 33 ++++++++ flake.nix | 8 ++ scripts/install-image.sh | 79 +++++++++++++------ tests/install/server-install.nix | 121 ++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 tests/install/server-install.nix diff --git a/.github/workflows/image-tests.yml b/.github/workflows/image-tests.yml index c61a3ac..b8536dc 100644 --- a/.github/workflows/image-tests.yml +++ b/.github/workflows/image-tests.yml @@ -17,6 +17,11 @@ on: required: false default: false type: boolean + run_install: + description: 'Also run the install-to-disk test (real installer partitions + installs + boots from disk)' + required: false + default: false + type: boolean pull_request: paths: - 'profiles/**' @@ -62,6 +67,34 @@ jobs: nix build ".#checks.x86_64-linux.${{ matrix.check }}" \ --print-build-logs --show-trace -L + # ── Install-to-disk test (opt-in) ──────────────────────────────────────────── + # Heavy: the real installer partitions a blank disk, installs the server + # edition, then the disk is rebooted and asserted to come up on its own + # bootloader. Opt-in via run_install so it never gates ordinary PRs. + install-test: + name: install-to-disk (x86_64) + if: github.event_name == 'workflow_dispatch' && github.event.inputs.run_install == 'true' + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules >/dev/null + sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm || true + ls -l /dev/kvm || echo "no /dev/kvm (test will use slower TCG)" + - uses: ./.github/actions/setup-nix + with: + cache-url: ${{ vars.NIX_CACHE_URL }} + cache-pubkey: ${{ vars.NIX_CACHE_PUBKEY }} + - name: Free disk space + run: sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android || true; df -h + - name: Run install-to-disk test + run: | + nix build ".#checks.x86_64-linux.edition-server-install" \ + --print-build-logs --show-trace -L + # ── aarch64 boot tests — need KVM on ARM (your GCP arm VM / self-hosted runner) ── # Opt-in: GitHub's free ARM runners have no /dev/kvm, so these run on a # KVM-capable self-hosted aarch64 runner when you enable run_arm_boot. diff --git a/flake.nix b/flake.nix index ad70f32..90c401c 100644 --- a/flake.nix +++ b/flake.nix @@ -286,6 +286,14 @@ then import ./tests/editions/edge-boot.nix { inherit pkgs self; } else pkgs.runCommand "edition-edge-boot-skip" {} "mkdir -p $out"; + # Install-to-disk: the real installer partitions a blank disk, installs + # SourceOS, and the system boots from disk on its own bootloader. + # x86_64 only (asserts systemd-bootx64.efi). Heavy → opt-in in CI. + edition-server-install = + if system == "x86_64-linux" + then import ./tests/install/server-install.nix { inherit pkgs self; } + else pkgs.runCommand "edition-server-install-skip" {} "mkdir -p $out"; + mesh-module-contract = import ./tests/mesh-module-contract.nix { inherit pkgs; }; mesh-runtime-contract = import ./tests/mesh-runtime-contract.nix { inherit pkgs; }; mesh-package-contract = import ./tests/mesh-package-contract.nix { inherit pkgs; }; diff --git a/scripts/install-image.sh b/scripts/install-image.sh index db4cfe9..aa3e7f6 100644 --- a/scripts/install-image.sh +++ b/scripts/install-image.sh @@ -24,11 +24,28 @@ FLAKE_REF="${FLAKE_REF:-github:SourceOS-Linux/source-os}" TARGET_HOSTNAME="${HOSTNAME:-sourceos}" MNT=/mnt -# ── Edition → flake module ──────────────────────────────────────────────────── +# ── Args ────────────────────────────────────────────────────────────────────── +# --edition base edition (default desktop) +# --system install a PREBUILT system toplevel instead of +# composing+building a flake (faster; offline; +# used by the install-to-disk test). Env: SOURCEOS_SYSTEM. +# --assume-yes skip the interactive typed confirmation (for +# unattended/automated installs). Env: SOURCEOS_ASSUME_YES=1. EDITION="desktop" -case "${1:-}" in - --edition) EDITION="${2:?--edition needs a value: desktop|server|edge}"; shift 2 ;; -esac +PREBUILT_SYSTEM="${SOURCEOS_SYSTEM:-}" +ASSUME_YES="${SOURCEOS_ASSUME_YES:-0}" +ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --edition) EDITION="${2:?--edition needs a value: desktop|server|edge}"; shift 2 ;; + --system) PREBUILT_SYSTEM="${2:?--system needs a store path}"; shift 2 ;; + --assume-yes|-y) ASSUME_YES=1; shift ;; + --) shift; while [[ $# -gt 0 ]]; do ARGS+=("$1"); shift; done ;; + -*) echo "Unknown flag '$1'" >&2; exit 1 ;; + *) ARGS+=("$1"); shift ;; + esac +done +set -- "${ARGS[@]:-}" case "$EDITION" in desktop) MODULE="desktop-gnome" ;; server) MODULE="server" ;; @@ -71,8 +88,12 @@ echo " 1. New GPT label" echo " 2. ESP 512 MiB FAT32 → /boot" echo " 3. Root rest ext4 → / (${EDITION} edition)" echo -read -rp " Type the disk name ('${TARGET}') to confirm: " CONFIRM -[[ "$CONFIRM" == "$TARGET" ]] || die "Confirmation did not match. Aborted — nothing changed." +if [[ "$ASSUME_YES" == "1" ]]; then + warn "--assume-yes: skipping typed confirmation for ${TARGET}." +else + read -rp " Type the disk name ('${TARGET}') to confirm: " CONFIRM + [[ "$CONFIRM" == "$TARGET" ]] || die "Confirmation did not match. Aborted — nothing changed." +fi # Partition suffix: nvme0n1 -> nvme0n1p1 ; sda -> sda1 part() { case "$TARGET" in *[0-9]) echo "${TARGET}p$1" ;; *) echo "${TARGET}$1" ;; esac; } @@ -96,14 +117,20 @@ mount "$ROOT" "$MNT" mkdir -p "$MNT/boot" mount "$ESP" "$MNT/boot" -# ── Compose a per-machine flake: hardware-config + the SourceOS GNOME module ── -info "Generating hardware configuration..." -nixos-generate-config --root "$MNT" --no-filesystems >/dev/null 2>&1 || nixos-generate-config --root "$MNT" -# Keep the generated hardware-configuration.nix; replace configuration with a -# flake that pulls SourceOS and applies the desktop-gnome module. -NIXDIR="$MNT/etc/nixos" -mkdir -p "$NIXDIR" -cat > "$NIXDIR/flake.nix" </dev/null 2>&1 || nixos-generate-config --root "$MNT" + # Keep the generated hardware-configuration.nix; replace configuration with a + # flake that pulls SourceOS and applies the edition module. + NIXDIR="$MNT/etc/nixos" + mkdir -p "$NIXDIR" + cat > "$NIXDIR/flake.nix" < "$NIXDIR/flake.nix" </dev/null || true + ok "--assume-yes: install complete, left unmounted (no reboot)." +else + read -rp " Reboot now? [y/N] " R; [[ "${R:-N}" =~ ^[Yy]$ ]] && { umount -R "$MNT"; reboot; } +fi diff --git a/tests/install/server-install.nix b/tests/install/server-install.nix new file mode 100644 index 0000000..b26e489 --- /dev/null +++ b/tests/install/server-install.nix @@ -0,0 +1,121 @@ +# Layer-1.5 install-to-disk test: prove the REAL clean-disk installer +# (scripts/install-image.sh) partitions a blank disk, installs SourceOS, and the +# installed system BOOTS FROM DISK on its own bootloader — not just that the +# edition config boots (that's edition-server-boot). +# +# Two phases on one disk (the nixpkgs installer-test pattern): +# 1. `installer` VM (NixOS installation environment) runs install-image.sh +# against a blank /dev/vda, installing a PREBUILT server toplevel offline. +# 2. The same disk is rebooted as `target` (useBootLoader) — we assert it comes +# up to multi-user.target off its own systemd-boot ESP. +# +# Uses the server edition (smallest closure). Heavy (full install + 2 boots), so +# it's wired opt-in in CI (image-tests run_install), not a required PR gate. +# +# Run: nix build .#checks.x86_64-linux.edition-server-install -L +{ pkgs, self }: +let + lib = pkgs.lib; + + # The exact system installed onto the target disk. Filesystem layout matches + # what install-image.sh creates: ext4 root labeled "nixos", vfat ESP labeled + # "EFI" mounted at /boot, systemd-boot (canTouchEfiVariables=false). + targetSystem = (pkgs.nixos ({ modulesPath, ... }: { + imports = [ + self.nixosModules.server + (modulesPath + "/testing/test-instrumentation.nix") + ]; + boot.loader.systemd-boot.enable = true; + boot.loader.grub.enable = lib.mkForce false; + boot.loader.efi.canTouchEfiVariables = false; + fileSystems."/" = { device = "/dev/disk/by-label/nixos"; fsType = "ext4"; }; + fileSystems."/boot" = { device = "/dev/disk/by-label/EFI"; fsType = "vfat"; }; + networking.hostName = "sourceos"; + # root is left passwordless by test-instrumentation.nix (login-free console). + })).config.system.build.toplevel; +in +pkgs.testers.runNixOSTest { + name = "edition-server-install"; + + # installation-device.nix sets nixpkgs.overlays, which conflicts with the + # read-only pkgs runNixOSTest installs by default. + node.pkgsReadOnly = false; + + nodes = { + # Phase 1: the installation environment. Its OWN root runs from /dev/vdb so + # /dev/vda stays blank for the install (and becomes the target's boot disk). + installer = { config, pkgs, lib, modulesPath, ... }: { + imports = [ (modulesPath + "/profiles/installation-device.nix") ]; + + virtualisation.emptyDiskImages = [ 1024 ]; # /dev/vdb = installer root + virtualisation.rootDevice = "/dev/vdb"; + virtualisation.diskSize = 8192; # /dev/vda = blank install target + virtualisation.memorySize = 3072; + virtualisation.cores = 2; + + # No network in the sandbox: the target closure must already be present. + # It is seeded read-only via the shared host store (extraDependencies). + nix.settings.substituters = lib.mkForce [ ]; + system.extraDependencies = [ targetSystem ]; + + # The real installer script + the tools it shells out to. + environment.etc."sourceos/install-image.sh".source = ../../scripts/install-image.sh; + environment.systemPackages = with pkgs; [ + gptfdisk dosfstools e2fsprogs util-linux parted nixos-install-tools + ]; + }; + + # Phase 2: same disk, booting on its own bootloader this time. + target = { ... }: { + virtualisation.useBootLoader = true; + virtualisation.useEFIBoot = true; + virtualisation.useDefaultFilesystems = false; + virtualisation.efi.keepVariables = false; + # Placeholder root; the real one is whatever the installer wrote to /dev/vda. + virtualisation.fileSystems."/" = { + device = "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + }; + }; + + testScript = '' + installer.start() + installer.wait_for_unit("multi-user.target") + + with subtest("Clean-disk installer partitions /dev/vda and installs SourceOS"): + installer.succeed( + "SOURCEOS_ASSUME_YES=1 bash /etc/sourceos/install-image.sh " + "--edition server --system ${targetSystem} /dev/vda >&2" + ) + + with subtest("Installer created the expected GPT layout + filesystems"): + installer.succeed("test -b /dev/vda1") # ESP + installer.succeed("test -b /dev/vda2") # root + installer.succeed("blkid /dev/vda1 | grep -q 'LABEL=\"EFI\"'") + installer.succeed("blkid /dev/vda2 | grep -q 'LABEL=\"nixos\"'") + + with subtest("Shut the installer down cleanly"): + installer.succeed("sync") + installer.shutdown() + + # Same machine, different boot: now boot from the freshly installed disk. + target.state_dir = installer.state_dir + + with subtest("The installed system boots from disk on its own bootloader"): + target.start() + target.wait_for_unit("multi-user.target") + + with subtest("systemd-boot was installed to the ESP"): + target.wait_for_unit("local-fs.target") + target.succeed("test -e /boot/EFI/systemd/systemd-bootx64.efi") + target.succeed("test -e /boot/loader/loader.conf") + + with subtest("It is the SourceOS server edition we installed"): + target.succeed("test \"$(hostname)\" = sourceos") + target.succeed("id sourceos") + target.succeed("systemctl is-active sshd.service") + # server edition is headless + target.fail("systemctl is-active display-manager.service") + ''; +} From 350c3832ece54a94656e8f94cbc4dbbccfc86925 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:35:03 -0400 Subject: [PATCH 2/2] fix(test): auto-format the installer's /dev/vdb root so it boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First KVM run failed: the installer VM couldn't mount /sysroot because its root disk /dev/vdb (an emptyDiskImage) was never formatted. Mirror the nixpkgs installer-test approach — autoFormat under systemd initrd, mke2fs in postDeviceCommands under the classic initrd. Bumped vdb to 2 GiB. --- tests/install/server-install.nix | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/install/server-install.nix b/tests/install/server-install.nix index b26e489..69a78ef 100644 --- a/tests/install/server-install.nix +++ b/tests/install/server-install.nix @@ -47,12 +47,27 @@ pkgs.testers.runNixOSTest { installer = { config, pkgs, lib, modulesPath, ... }: { imports = [ (modulesPath + "/profiles/installation-device.nix") ]; - virtualisation.emptyDiskImages = [ 1024 ]; # /dev/vdb = installer root + virtualisation.emptyDiskImages = [ 2048 ]; # /dev/vdb = installer root virtualisation.rootDevice = "/dev/vdb"; virtualisation.diskSize = 8192; # /dev/vda = blank install target virtualisation.memorySize = 3072; virtualisation.cores = 2; + # /dev/vdb starts blank — auto-format it as the installer's root so the + # installer environment boots. systemd initrd uses autoFormat; the classic + # initrd path mke2fs's it in postDeviceCommands. + virtualisation.fileSystems."/".autoFormat = config.boot.initrd.systemd.enable; + boot.initrd.extraUtilsCommands = lib.mkIf (!config.boot.initrd.systemd.enable) '' + copy_bin_and_libs ${pkgs.e2fsprogs}/bin/mke2fs + ''; + boot.initrd.postDeviceCommands = lib.mkIf (!config.boot.initrd.systemd.enable) '' + FSTYPE=$(blkid -o value -s TYPE /dev/vdb || true) + PARTTYPE=$(blkid -o value -s PTTYPE /dev/vdb || true) + if test -z "$FSTYPE" -a -z "$PARTTYPE"; then + mke2fs -t ext4 /dev/vdb + fi + ''; + # No network in the sandbox: the target closure must already be present. # It is seeded read-only via the shared host store (extraDependencies). nix.settings.substituters = lib.mkForce [ ];