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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/image-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; };
Expand Down
79 changes: 57 additions & 22 deletions scripts/install-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,28 @@ FLAKE_REF="${FLAKE_REF:-github:SourceOS-Linux/source-os}"
TARGET_HOSTNAME="${HOSTNAME:-sourceos}"
MNT=/mnt

# ── Edition → flake module ────────────────────────────────────────────────────
# ── Args ──────────────────────────────────────────────────────────────────────
# --edition <desktop|server|edge> base edition (default desktop)
# --system <store-path> 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" ;;
Expand Down Expand Up @@ -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; }
Expand All @@ -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" <<EOF
if [[ -n "$PREBUILT_SYSTEM" ]]; then
# ── Install a PREBUILT system toplevel (offline; used by the install test) ──
[[ -e "$PREBUILT_SYSTEM" ]] || die "--system path does not exist: $PREBUILT_SYSTEM"
info "Installing prebuilt system: $PREBUILT_SYSTEM"
nixos-install --root "$MNT" --system "$PREBUILT_SYSTEM" --no-root-passwd --no-channel-copy
else
# ── Compose a per-machine flake: hardware-config + the SourceOS 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 edition module.
NIXDIR="$MNT/etc/nixos"
mkdir -p "$NIXDIR"
cat > "$NIXDIR/flake.nix" <<EOF
{
description = "SourceOS machine";
inputs.sourceos.url = "${FLAKE_REF}";
Expand All @@ -119,19 +146,27 @@ cat > "$NIXDIR/flake.nix" <<EOF
};
}
EOF
ok "Wrote $NIXDIR/flake.nix (module: ${MODULE})"
ok "Wrote $NIXDIR/flake.nix (module: ${MODULE})"

# ── Install ───────────────────────────────────────────────────────────────────
info "Running nixos-install (this builds the system; grab a coffee)..."
nixos-install --root "$MNT" --flake "$NIXDIR#${TARGET_HOSTNAME}" --no-channel-copy
# ── Install ─────────────────────────────────────────────────────────────────
info "Running nixos-install (this builds the system; grab a coffee)..."
nixos-install --root "$MNT" --flake "$NIXDIR#${TARGET_HOSTNAME}" --no-channel-copy
fi

# ── Password ──────────────────────────────────────────────────────────────────
echo; info "Set a password for the 'sourceos' user:"
nixos-enter --root "$MNT" -c 'passwd sourceos'
# ── Password (interactive installs only; unattended uses --no-root-passwd) ──────
if [[ "$ASSUME_YES" != "1" && -z "$PREBUILT_SYSTEM" ]]; then
echo; info "Set a password for the 'sourceos' user:"
nixos-enter --root "$MNT" -c 'passwd sourceos'
fi

echo
ok "SourceOS installed to ${TARGET}."
info "Remove the USB and reboot. After first boot you can apply the GNOME polish layer:"
info " bash <(curl -fsSL https://raw.githubusercontent.com/SourceOS-Linux/source-os/main/profiles/linux-dev/workstation-v0/gnome/apply.sh)"
echo
read -rp " Reboot now? [y/N] " R; [[ "${R:-N}" =~ ^[Yy]$ ]] && { umount -R "$MNT"; reboot; }
if [[ "$ASSUME_YES" == "1" ]]; then
umount -R "$MNT" 2>/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
136 changes: 136 additions & 0 deletions tests/install/server-install.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 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 = [ 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 [ ];
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")
'';
}
Loading