From 1ac85636837f84ae932316606925b617e1dfff4b Mon Sep 17 00:00:00 2001 From: Ofek Bickel Date: Mon, 6 Jul 2026 15:37:49 +0300 Subject: [PATCH] Add interactive installer, tagged releases, GRUB fixes, and docs refresh Interactive installer (plain-prompt TUI, no new dependencies): - src/installer/install-lib.sh: shared install logic extracted from the QEMU test autoinstaller (pure helpers, prompt helpers, effectful steps), sourceable and bats-tested; both frontends share one implementation. - src/installer/silverblue-install: prompts for disk (type-ERASE gate), hostname/timezone/locale/keymap, bootloader, microcode, linux-firmware, network stack (none/systemd-networkd/NetworkManager), root password, and an optional wheel+sudo admin user. Installed as /usr/bin/-install; live-ISO motd points at it. UEFI-only, no LUKS/swap by design. - The unattended autoinstaller keeps byte-identical test behavior and becomes a thin frontend over the shared library. Testing: - tests/unit/test_installer{,_prompts}.bats: 20 new unit tests. - tests/qemu/run.sh --interactive + harness phases drive the interactive installer over serial via SILVERBLUE-INSTALL-PROMPT markers, then boot, log in with the configured password, and verify the target (including that no test-only artifacts leak onto interactive installs). - Harness probes now lead with a bare Enter so GRUB menu countdowns are never cancelled into the entry editor. GRUB fixes (pre-existing bugs exposed by the new coverage): - grub package now installed via pacstrap; the chroot pacman -S ran after the hermetic path disabled remote repos and always failed. - recordfail no longer holds the menu forever (timeout=-1 -> 10s): staged updates on headless GRUB machines used to hang at the menu. - GRUB rollback test exercises the health-check OnFailure path; unattended recovery from an unloadable kernel is documented as systemd-boot-only. - Frontends run with set -E so SILVERBLUE-INSTALL-FAIL markers and cleanup fire for failures inside library functions. CI/releases: - v* tags publish ISO + SHA256SUMS to GitHub Releases (gh CLI, no new deps). - Manual QEMU job gains scenario (unattended/interactive/both) and bootloader (systemd-boot/grub) dispatch inputs. - ISO is named after DISTRO_ID instead of archlinux-*. Docs/website: new docs/installing.md (synced to /guides/installing/), README/SECURITY/CONTRIBUTING/DERIVING/update-flow updates, Download hero action, FAQ/getting-started/comparison refreshed to reflect releases and the installer. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 46 +- CONTRIBUTING.md | 17 +- DERIVING.md | 19 +- Makefile | 8 +- README.md | 37 +- SECURITY.md | 10 +- docs/installing.md | 96 ++++ docs/update-flow.md | 10 +- .../usr/local/bin/silverblue-autoinstall.sh | 185 ++----- iso/build.sh | 15 + src/bootloader/grub-helpers.sh | 14 +- src/installer/install-lib.sh | 467 ++++++++++++++++++ src/installer/silverblue-install | 261 ++++++++++ tests/qemu/harness.py | 195 +++++++- tests/qemu/run.sh | 9 +- tests/unit/helper.bash | 5 + tests/unit/test_installer.bats | 119 +++++ tests/unit/test_installer_prompts.bats | 94 ++++ tests/unit/test_render_entries.bats | 9 + website/.gitignore | 1 + website/astro.config.mjs | 5 +- website/scripts/sync-docs.mjs | 8 + website/src/content/docs/comparison.md | 8 +- website/src/content/docs/faq.md | 22 +- website/src/content/docs/getting-started.mdx | 30 +- website/src/content/docs/index.mdx | 14 +- 26 files changed, 1492 insertions(+), 212 deletions(-) create mode 100644 docs/installing.md create mode 100644 src/installer/install-lib.sh create mode 100644 src/installer/silverblue-install create mode 100644 tests/unit/test_installer.bats create mode 100644 tests/unit/test_installer_prompts.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e369867..829ac2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,8 @@ # CI for Arch Silverblue. Mirrors the Makefile targets: # * test -> `make test` (shellcheck + bats + systemd-analyze verify) — fast, required # * build-iso -> `make build-iso` (privileged Docker + mkarchiso) — main/tags only -# * qemu -> `make test-qemu` (full boot in QEMU) — manual only +# * qemu -> `make test-qemu` / `make test-qemu-interactive` — manual only +# * release -> publish the built ISO + SHA256SUMS to a GitHub Release — v* tags only # # shellcheck/bats come from `nix shell` (as documented for local dev), so CI installs Nix and # runs the same `make` targets a contributor runs — no separate tool list to drift. @@ -13,6 +14,17 @@ on: tags: ["v*"] pull_request: workflow_dispatch: + inputs: + qemu_scenario: + description: "QEMU scenario(s) to run" + type: choice + options: [unattended, interactive, both] + default: unattended + qemu_bootloader: + description: "Bootloader to drive through the QEMU scenario(s)" + type: choice + options: [systemd-boot, grub] + default: systemd-boot # One in-flight run per ref; cancel superseded ones (e.g. force-pushes to a PR). concurrency: @@ -68,5 +80,33 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends qemu-system-x86 ovmf - - name: make test-qemu - run: make test-qemu # builds the ISO first via its Makefile prerequisite + - name: Build ISO + run: make build-iso + - name: QEMU unattended (install/happy/rollback) + if: inputs.qemu_scenario == 'unattended' || inputs.qemu_scenario == 'both' || inputs.qemu_scenario == '' + run: bash tests/qemu/run.sh --bootloader "${{ inputs.qemu_bootloader || 'systemd-boot' }}" + - name: QEMU interactive installer + if: inputs.qemu_scenario == 'interactive' || inputs.qemu_scenario == 'both' + run: bash tests/qemu/run.sh --interactive --bootloader "${{ inputs.qemu_bootloader || 'systemd-boot' }}" + + release: + name: publish release (tags) + runs-on: ubuntu-latest + needs: [test, build-iso] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: iso + path: iso/output + - name: Create release and upload ISO + SHA256SUMS + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \ + gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes \ + --title "Arch Silverblue $GITHUB_REF_NAME" + gh release upload "$GITHUB_REF_NAME" iso/output/*.iso iso/output/SHA256SUMS --clobber diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 832a20c..112bedf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,7 @@ full pipeline (`make ci`) additionally needs **Docker** and **QEMU**. | `make test` | `lint` + `test-unit` + `verify-units` — the fast inner loop | nix, systemd-analyze | | `make build-iso` | Build the bootable ISO via Docker (also writes `iso/output/SHA256SUMS`) | docker (`--privileged`) + network | | `make test-qemu` | Boot the ISO in QEMU; run the happy-path + rollback tests | qemu (KVM or TCG) | +| `make test-qemu-interactive` | Drive the interactive installer in QEMU; boot + verify the result | qemu (KVM or TCG) | | `make ci` | `test` + `build-iso` + `test-qemu` (full pipeline) | all of the above | Run **`make test` before every change** — it is fast and needs no docker/qemu. @@ -48,6 +49,7 @@ nix shell nixpkgs#bats --command bats tests/unit/test_rollback.bats # one file bash tests/qemu/run.sh # integration (systemd-boot, default) bash tests/qemu/run.sh --bootloader grub # integration against GRUB bash tests/qemu/run.sh --net # update cycle over real pacman -Syu +bash tests/qemu/run.sh --interactive # interactive installer end-to-end ``` ## Conventions @@ -66,8 +68,12 @@ These are load-bearing — please follow them: reads config from `SB_*` env vars, so tests can mock them. Prefer adding **pure, unit-testable functions** over inlining effectful logic. - **`SILVERBLUE-*` markers are a contract.** The uppercase progress markers (e.g. - `SILVERBLUE-MARKGOOD-OK`, `SILVERBLUE-ROLLBACK-ARMED`) are grepped literally by the QEMU - harness, and the build's `render()` deliberately leaves them untouched. Don't rename them. + `SILVERBLUE-MARKGOOD-OK`, `SILVERBLUE-ROLLBACK-ARMED`, `SILVERBLUE-INSTALL-PROMPT`) are + grepped literally by the QEMU harness, and the build's `render()` deliberately leaves them + untouched. Don't rename them. The interactive installer's **prompt order** is part of the + same contract: `phase_interactive_install()` in `tests/qemu/harness.py` answers the prompts + in the order `gather_answers()` (in `src/installer/silverblue-install`) asks them — change + one and you must change the other. - **Keep `src/` generic.** The source tree keeps the upstream `silverblue` names; all rebranding happens at build/install time from `config/distro.conf` (see DERIVING.md). Don't hardcode brand-specific strings into `src/`. @@ -75,9 +81,10 @@ These are load-bearing — please follow them: ## CI Every push and pull request runs `make test`. Pushes to `main` and tags also build the ISO and -upload it with its `SHA256SUMS`. The QEMU integration job is manual (`workflow_dispatch`) because -GitHub runners have no KVM and the TCG fallback is slow. See -[`.github/workflows/ci.yml`](.github/workflows/ci.yml). +upload it with its `SHA256SUMS`; pushing a `v*` tag additionally publishes them to a **GitHub +Release**. The QEMU integration job is manual (`workflow_dispatch`, with a scenario picker for +the unattended and/or interactive suites) because GitHub runners have no KVM and the TCG +fallback is slow. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml). ## Pull requests diff --git a/DERIVING.md b/DERIVING.md index 1d75f66..e13d13b 100644 --- a/DERIVING.md +++ b/DERIVING.md @@ -58,7 +58,7 @@ stock Arch Silverblue. Variables, grouped: | Variable | Purpose | Required for a rename? | | --- | --- | --- | -| `BIN_PREFIX` | the CLI becomes `${BIN_PREFIX}-update` (`/usr/bin/silverblue-update` by default) | yes | +| `BIN_PREFIX` | the CLIs become `${BIN_PREFIX}-update` and `${BIN_PREFIX}-install` (`/usr/bin/silverblue-update` / `-install` by default) | yes | | `UNIT_PREFIX` | the units become `${UNIT_PREFIX}-mark-good.service` / `-rollback.service` / `-rollback.target` | yes | | `LIB_DIR` | helper/library dir (`/usr/lib/silverblue`) | yes | | `ESP_SUBDIR` | per-snapshot kernel dir on the ESP (`/efi/silverblue/`, systemd-boot) | cosmetic | @@ -76,6 +76,9 @@ stock Arch Silverblue. Variables, grouped: | `LOCALE` | `locale.gen` entry + `/etc/locale.conf` `LANG` | | `KEYMAP` | vconsole keymap (`""` = leave default) | +These are what the unattended test install writes verbatim, and what the **interactive +installer** offers as prompt defaults (`BOOTLOADER` below likewise seeds its bootloader menu). + ### Bootloader / filesystem | Variable | Purpose | @@ -99,7 +102,8 @@ stock Arch Silverblue. Variables, grouped: | You set… | …and it lands in | | --- | --- | -| `BIN_PREFIX` | `/usr/bin/-update`; the baked engine defaults; `file_permissions` in the ISO profile | +| `DISTRO_ID` | the ISO file name (`-YYYY.MM.DD-x86_64.iso`) and the Docker image tag | +| `BIN_PREFIX` | `/usr/bin/-update` and `/usr/bin/-install`; the baked engine defaults; `file_permissions` in the ISO profile | | `UNIT_PREFIX` | the three unit files + the two `*-mark-good.sh`/`*-rollback.sh` scripts; the enable symlink; the watchdog drop-in | | `LIB_DIR` | where the engine finds its helpers/scripts (`SB_LIB_DIR` baked in) | | `DISTRO_NAME` | systemd-boot/GRUB entry titles; unit `Description=`; os-release `NAME`/`PRETTY_NAME` | @@ -107,12 +111,14 @@ stock Arch Silverblue. Variables, grouped: | `HOSTNAME` / `TIMEZONE` / `LOCALE` / `KEYMAP` | the installed `/etc/hostname`, `/etc/localtime`, `/etc/locale.conf`, `/etc/vconsole.conf` | | `FS_LABEL` / `ESP_LABEL` | the Btrfs/ESP labels and the GPT partition name | | `PKGS_ISO` | `packages.x86_64` in the archiso profile | -| `PKGS_BASE` | the `pacstrap` command in the autoinstaller | +| `PKGS_BASE` | the `pacstrap` package list of both installers (the interactive one adds the user's microcode/firmware/network/sudo choices on top) | | `EXTRA_REPOS` | the target's `/etc/pacman.conf` | What deliberately stays generic: the whole `src/` tree on disk, the upstream unit tests, `tools/verify-units.sh`, the uppercase `SILVERBLUE-*` progress markers (the QEMU harness greps them), and the test-only `silverblue-autoinstall.sh` / synthetic `[silverblue-local]` repo. +The installer library and interactive frontend are installed **verbatim** (they read your +`distro.conf` at runtime); only the frontend's file *name* is derived from `BIN_PREFIX`. ## Branding @@ -133,9 +139,10 @@ them), and the test-only `silverblue-autoinstall.sh` / synthetic `[silverblue-lo $'[mydistro]\nSigLevel = Optional TrustAll\nServer = https://repo.mydistro.org/$arch' ) ``` - **Caveat:** `EXTRA_REPOS` is only applied on the **networked** install path (`net=1`). The - offline/hermetic test path leaves the target with just the bundled `file://` repo so the - self-contained update test still works. + **Caveat:** `EXTRA_REPOS` is applied by the **interactive installer** (always networked) and + by the unattended test install's networked path (`net=1`). The offline/hermetic test path + leaves the target with just the bundled `file://` repo so the self-contained update test + still works. ## Build & verify diff --git a/Makefile b/Makefile index f69134c..64034a2 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,8 @@ SHELL_FILES := \ src/bootloader/grub-helpers.sh \ src/init/silverblue-mark-good.sh \ src/init/silverblue-rollback.sh \ + src/installer/install-lib.sh \ + src/installer/silverblue-install \ iso/build.sh \ iso/airootfs/usr/local/bin/silverblue-autoinstall.sh \ tests/qemu/run.sh \ @@ -31,7 +33,7 @@ IMAGE := $(shell bash -c '. config/distro.conf 2>/dev/null && printf "%s-iso" "$ ISO_STAMP := iso/output/.built ISO_INPUTS := iso/Dockerfile iso/build.sh $(shell find iso/airootfs src config -type f 2>/dev/null) -.PHONY: all lint test-unit verify-units build-iso rebuild-iso test-qemu test ci clean help +.PHONY: all lint test-unit verify-units build-iso rebuild-iso test-qemu test-qemu-interactive test ci clean help help: @echo "Targets:" @@ -40,6 +42,7 @@ help: @echo " verify-units systemd-analyze verify the Silverblue systemd units" @echo " build-iso build the bootable ISO via Docker (needs --privileged + network)" @echo " test-qemu boot the ISO in QEMU and run the happy-path + rollback tests" + @echo " test-qemu-interactive drive the interactive installer in QEMU and boot the result" @echo " test lint + test-unit + verify-units (fast inner loop)" @echo " ci test + build-iso + test-qemu (full pipeline)" @echo " clean remove build/test artifacts" @@ -75,6 +78,9 @@ rebuild-iso: test-qemu: $(ISO_STAMP) bash tests/qemu/run.sh +test-qemu-interactive: $(ISO_STAMP) + bash tests/qemu/run.sh --interactive + ci: test build-iso test-qemu clean: diff --git a/README.md b/README.md index de1b342..5ed11fd 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,20 @@ src/ update-engine/silverblue-update # the atomic update CLI (Bash, shellcheck-clean) bootloader/sdboot-helpers.sh # systemd-boot entry/copy/bless/prune helpers bootloader/grub-helpers.sh # GRUB menuentry/grubenv helpers + installer/install-lib.sh # shared install steps (partition/pacstrap/bootloader) + installer/silverblue-install # minimal interactive installer (runs on the live ISO) init/silverblue-mark-good.{service,sh} # post-boot health check + good-marking init/silverblue-rollback.{target,service,sh} # OnFailure rollback init/silverblue-watchdog.conf # RuntimeWatchdogSec drop-in (hang recovery) iso/ Dockerfile # reproducible archiso build image build.sh # assembles the releng profile + overlay, runs mkarchiso - airootfs/ # ISO overlay: autoinstaller, serial autologin, fw_cfg + airootfs/ # ISO overlay: test autoinstaller, serial autologin, fw_cfg tests/ unit/ # bats tests + command mocks (no root/Btrfs needed) - qemu/run.sh, qemu/harness.py # boot the ISO in QEMU; happy-path + rollback tests + qemu/run.sh, qemu/harness.py # boot the ISO in QEMU; install/update/rollback tests docs/update-flow.md # documented flow + ASCII diagram +docs/installing.md # end-user install guide (real hardware) Makefile # lint / test-unit / build-iso / test-qemu ``` @@ -82,6 +85,16 @@ make test-qemu # boots the ISO, installs, runs the happy-path + rollback ass rollback path (bad update → reverts to previous root) pass. It uses KVM when `/dev/kvm` is writable and falls back to TCG (`-cpu qemu64`) otherwise. +## Installing on real hardware + +Download the ISO from +[GitHub Releases](https://github.com/sinisterMage/Arch-silverblue/releases/latest) (published +automatically on version tags), boot it in UEFI mode, and run **`silverblue-install`** — a +minimal plain-prompt installer that asks for disk, hostname, timezone/locale/keymap, +bootloader, CPU microcode, `linux-firmware`, a network stack, a root password, and an optional +admin user, then requires typing `ERASE` before touching anything. See +[`docs/installing.md`](docs/installing.md) for the full guide. + ## Usage on an installed system `silverblue-update` must run as root on a Btrfs root with an ESP at `/efi`. @@ -114,6 +127,13 @@ default `3`), and the injectable command paths `BTRFS`, `BOOTCTL`, `GRUB_EDITENV real `pacman -Syu` over QEMU user-net. The integration test is validated against systemd-boot. > The install phase pacstraps a base system, which requires network (QEMU user-net) — this is > intrinsic to installing Arch. The *update* assertion is what runs offline. +- **Interactive-installer test** (`make test-qemu-interactive`) drives `silverblue-install` + itself over the serial console: the harness answers every prompt via the + `SILVERBLUE-INSTALL-PROMPT` markers, confirms with `ERASE`, then boots the installed system, + logs in with the password it set, and asserts the subvolume, hostname, mark-good, network + stack, admin user, and that no test-only artifacts (autologin, local test repo) leaked onto + the target. The interactive installer and the test autoinstaller share one implementation + (`src/installer/install-lib.sh`), so the unattended scenarios also cover the shared steps. ## Verification checklist @@ -130,7 +150,11 @@ silverblue-update --rollback ## Design notes & limitations - **systemd-boot is the primary, CI-validated path.** GRUB is fully implemented, covered by unit - tests, and the QEMU harness can now drive it end-to-end (`tests/qemu/run.sh --bootloader grub`). + tests, and the QEMU harness can drive it end-to-end (`tests/qemu/run.sh --bootloader grub`). + One GRUB limitation: a kernel that fails to *load* is not recovered unattended (stock GRUB has + no in-session fallback; the held menu offers the previous root one keypress away) — the GRUB + rollback test therefore exercises the health-check `OnFailure` path, while the systemd-boot + test exercises boot counting with a corrupt kernel. - **GRUB cannot write Btrfs**, so `grubenv` is kept on the FAT ESP (`/efi/grub/grubenv`). This avoids patching GRUB and keeps everything within stock Arch packages. - **Boot counting cannot reboot a hang by itself** — `RuntimeWatchdogSec` (a hardware watchdog) @@ -143,9 +167,10 @@ silverblue-update --rollback ### Out of scope -GUI/TUI, ZFS implementation (documented as future work in `docs/update-flow.md`), OTA/delta -updates, custom pacman wrappers or signing, PXE, Secure Boot, and any immutable-root -enforcement. +Any GUI (a minimal plain-prompt TUI installer *is* included — see +[`docs/installing.md`](docs/installing.md)), LUKS/disk encryption, swap setup, ZFS +implementation (documented as future work in `docs/update-flow.md`), OTA/delta updates, custom +pacman wrappers or signing, PXE, Secure Boot, and any immutable-root enforcement. ## Contributing diff --git a/SECURITY.md b/SECURITY.md index dc8a101..f4c6ac2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,9 +21,13 @@ report we want. `iso/airootfs/usr/local/bin/silverblue-autoinstall.sh` targets `/dev/vda`, wipes it unconditionally, and provisions a **passwordless root** with serial-console autologin. It is gated behind a QEMU `fw_cfg` blob (`ConditionPathExists=…/opt/silverblue/scenario/raw`), so it - never runs on a normal/interactive boot or on real hardware. Producing a hardened, interactive - installer (disk selection, user accounts, network, microcode) is out of scope for the current - image. + never runs on a normal/interactive boot or on real hardware. +- **The interactive installer (`silverblue-install`) is separate from the test appliance.** + It only runs when the user invokes it, requires typing `ERASE` before any destructive step, + requires a root password (no passwordless accounts on interactive targets), and never + installs the test-only artifacts (no autologin drop-in, no `[silverblue-local]` repo). It is + still a *minimal* installer: no LUKS, no Secure Boot — see + [docs/installing.md](docs/installing.md) for its scope. - **The offline test repo trusts unsigned packages.** The synthetic `[silverblue-local]` repo baked into the ISO for the hermetic update test uses `SigLevel = Optional TrustAll`. Derivatives that ship their own repositories should **sign them** and avoid `TrustAll` (see diff --git a/docs/installing.md b/docs/installing.md new file mode 100644 index 0000000..7537dba --- /dev/null +++ b/docs/installing.md @@ -0,0 +1,96 @@ +# Installing on real hardware + +Arch Silverblue ships a **minimal interactive installer**: plain prompts and numbered menus on +the console — no GUI, no dialog boxes, nothing preselected beyond sane defaults. It asks +everything upfront, shows a summary, and only touches the disk after you type `ERASE`. + +## 1. Get the ISO + +Download the latest ISO and checksums from +[GitHub Releases](https://github.com/sinisterMage/Arch-silverblue/releases/latest), then verify: + +```bash +sha256sum -c SHA256SUMS +``` + +(You can also build it yourself: `make build-iso` → `iso/output/*.iso`. CI builds on every push +to `main` are available as workflow artifacts.) + +## 2. Write it to a USB stick + +```bash +# ALL DATA ON THE STICK IS LOST. Replace /dev/sdX with your USB device. +cp path/to/silverblue-*.iso /dev/sdX && sync +``` + +`dd if=... of=/dev/sdX bs=4M oflag=sync` works too, as do tools like Ventoy or GNOME Disks. + +## 3. Boot it — UEFI only + +Boot the stick in **UEFI mode**. Secure Boot is not supported — disable it in firmware setup. +If the installer reports "booted in BIOS mode", switch your firmware from Legacy/CSM to UEFI. + +## 4. Run the installer + +Log in lands you in a root shell. Start the installer: + +```bash +silverblue-install +``` + +It checks UEFI mode and network connectivity first (installation pacstraps from the Arch +mirrors — for Wi-Fi, bring the link up with `iwctl` before/when prompted, Ethernet with DHCP +just works), then asks, in order: + +| Prompt | Notes | Default | +|---|---|---| +| Target disk | Numbered menu; the live USB itself is excluded | first disk | +| Hostname | | `silverblue` | +| Timezone | e.g. `Europe/Amsterdam` | `UTC` | +| Locale | validated against glibc's list | `en_US.UTF-8` | +| Console keymap | empty keeps the kernel default | empty | +| Bootloader | `systemd-boot` (primary, CI-validated) or `grub` | `systemd-boot` | +| CPU microcode | detected from `/proc/cpuinfo` (`intel-ucode`/`amd-ucode`) | yes | +| linux-firmware | needed on most real hardware; skip only in VMs | yes | +| Network stack | `none` / `systemd-networkd` (DHCP, ships with systemd) / `NetworkManager` | `systemd-networkd` | +| Root password | required — the installed system has no passwordless accounts | — | +| Admin user | optional; created in `wheel` with a sudoers drop-in (installs `sudo`) | none | + +After the summary, type `ERASE` to proceed. The installer partitions the disk (512 MB ESP + +Btrfs), creates the initial `root-` and `@home` subvolumes, pacstraps the base +system plus your choices, installs the update engine and health-check units, sets up the +bootloader, and offers a reboot. The resulting on-disk layout is exactly the one +[`update-flow.md`](update-flow.md) describes. + +If a step fails, the installer reports the failing line, unmounts the target, and leaves you +in the live shell — nothing is half-mounted and the log is on your screen. + +## 5. First boot + +Log in as root (or your admin user) and try the update flow: + +```bash +silverblue-update --dry-run # show the plan +silverblue-update # stage an updated root for the next boot +``` + +The first boot is automatically health-checked and marked good by +`silverblue-mark-good.service`; a failed boot of a *staged update* rolls back to the previous +root on its own. + +## Troubleshooting + +- **"booted in BIOS mode"** — enable UEFI (disable CSM/Legacy) in firmware setup; the ISO and + the installed system are UEFI-only. +- **"no outbound network"** — Ethernet: plug in and retry (DHCP is automatic on the live ISO). + Wi-Fi: `iwctl station wlan0 connect `, then retry at the prompt. +- **Disk not listed** — the installer only offers whole disks (not partitions) and hides the + live USB, optical, loop, and zram devices. Check `lsblk -d`. +- **Disk size** — keep at least ~12 GB; the ESP holds up to 3 snapshots × ~80–120 MB of + kernels, the Btrfs pool holds up to 3 root snapshots. + +## Scope — deliberately not included + +No LUKS encryption, no swap setup, no partitioning schemes beyond ESP + single Btrfs pool, no +GUI, no package-set choices beyond the prompts above. Arch Silverblue stays unopinionated: +everything else is a normal `pacman` command away after the first boot. diff --git a/docs/update-flow.md b/docs/update-flow.md index 83f395f..3cc8e36 100644 --- a/docs/update-flow.md +++ b/docs/update-flow.md @@ -10,6 +10,9 @@ bootloader falls back to the previous root. ## On-disk layout +This is the layout both installers create — the interactive `silverblue-install` (see +[installing.md](installing.md)) and the unattended QEMU test appliance. + ``` GPT disk (e.g. /dev/vda) ├─ p1 ESP (FAT32, mounted at /efi) kernels for systemd-boot live here per-snapshot @@ -116,7 +119,12 @@ complementary mechanisms: | New kernel won't load | systemd-boot boot counting demotes the entry; previous boots | After any of these reboots, the new entry's tries are exhausted / its one-shot consumed, so the -previous (good, counter-less) root is selected. +previous (good, counter-less) root is selected. On GRUB the one-shot boot arms the `recordfail` +tripwire (cleared by mark-good) and keeps a **finite** menu timeout, so an unattended machine +always keeps booting rather than holding the menu; the first two failure modes roll back +automatically on GRUB too. The third does not: a kernel that fails to *load* leaves stock GRUB +waiting at its menu with the previous root one keypress away — unattended recovery from an +unloadable kernel is a systemd-boot (boot counting) feature. ## Manual control diff --git a/iso/airootfs/usr/local/bin/silverblue-autoinstall.sh b/iso/airootfs/usr/local/bin/silverblue-autoinstall.sh index e202de6..72a78bc 100755 --- a/iso/airootfs/usr/local/bin/silverblue-autoinstall.sh +++ b/iso/airootfs/usr/local/bin/silverblue-autoinstall.sh @@ -9,10 +9,12 @@ # the QEMU harness can drive the booted system, then powers off. Progress is reported with # SILVERBLUE-* markers on the serial console. # +# The generic install steps live in install-lib.sh (shared with the interactive installer); +# only the test-appliance behavior stays here: the fw_cfg gate, the hardcoded /dev/vda, the +# passwordless root, the target autologin drop-in, the local test repo, and the poweroff. +# # It is intentionally conservative: it only proceeds when the scenario blob says "install". -set -euo pipefail -marker() { printf '%s\n' "$*"; } fwc() { cat "/sys/firmware/qemu_fw_cfg/by_name/$1/raw" 2>/dev/null || true; } # Load the derived-distro configuration that build.sh shipped into the ISO. A fixed, @@ -20,63 +22,12 @@ fwc() { cat "/sys/firmware/qemu_fw_cfg/by_name/$1/raw" 2>/dev/null || true; } DISTRO_CONF=${DISTRO_CONF:-/usr/local/share/distro/distro.conf} OSRELEASE_IN=${OSRELEASE_IN:-/usr/local/share/distro/os-release.in} # shellcheck source=../../../../../config/distro.conf -source "$DISTRO_CONF" - -# Escape a string for the replacement side of a sed 's|...|...|' command. -esc() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } - -install_sdboot() { - local mnt=$1 snap=$2 uuid=$3 - local efi="$mnt/efi" - bootctl --esp-path="$efi" install - mkdir -p "$efi/EFI/BOOT" "$efi/$ESP_SUBDIR/$snap" "$efi/loader/entries" - # Removable fallback so the VM boots even without persisted EFI NVRAM. - cp "$efi/EFI/systemd/systemd-bootx64.efi" "$efi/EFI/BOOT/BOOTX64.EFI" - cp "$mnt/boot/vmlinuz-linux" "$efi/$ESP_SUBDIR/$snap/" - cp "$mnt/boot/initramfs-linux.img" "$efi/$ESP_SUBDIR/$snap/" - # No explicit default= : systemd-boot then selects the newest-version entry, which is how - # silverblue-update makes a freshly registered root boot next without touching the default. - cat > "$efi/loader/loader.conf" < "$efi/loader/entries/$snap.conf" <&2; exit 1; } -install_grub() { - local mnt=$1 snap=$2 uuid=$3 - local efi="$mnt/efi" - arch-chroot "$mnt" pacman -S --noconfirm grub - arch-chroot "$mnt" grub-install --target=x86_64-efi --efi-directory=/efi \ - --boot-directory=/efi --bootloader-id=GRUB --removable - mkdir -p "$efi/grub" - grub-editenv "$efi/grub/grubenv" create - grub-editenv "$efi/grub/grubenv" set "saved_entry=$snap" - cat > "$efi/grub/grub.cfg" <&2; exit 1; } configure_pacman_repo() { local mnt=$1 net=$2 @@ -88,10 +39,7 @@ Server = file:///opt/silverblue/localrepo EOF if [[ "$net" == 1 ]]; then # Networked install: add the derivative's extra repos (if any) to the target. - local r - for r in "${EXTRA_REPOS[@]}"; do - printf '\n%s\n' "$r" >> "$mnt/etc/pacman.conf" - done + append_extra_repos "$mnt" else # Hermetic update test: disable remote repos so `pacman -Syu` only needs the offline # file:// repo. Comment each remote section header and its Include/Server lines. @@ -107,6 +55,7 @@ EOF main() { local scenario net bootloader disk esp rootpart ts snap pool_uuid + local -a pkgs # Config comes from the environment (the QEMU harness drives this over the autologin # shell), falling back to QEMU fw_cfg blobs for the service-driven path. scenario=${SB_SCENARIO:-$(fwc opt/silverblue/scenario)} @@ -125,74 +74,30 @@ main() { snap="root-$ts" marker "SILVERBLUE-INSTALL-START disk=$disk snap=$snap bootloader=$bootloader net=$net" - # --- Partition: p1 ESP (FAT32), p2 Btrfs --------------------------------------------- - wipefs -a "$disk" - sgdisk -Z "$disk" - sgdisk -n1:0:+512M -t1:ef00 -c1:EFI "$disk" - sgdisk -n2:0:0 -t2:8300 -c2:"$FS_LABEL" "$disk" - partprobe "$disk" 2>/dev/null || true - udevadm settle || true - sleep 1 - esp="${disk}1" - rootpart="${disk}2" - - mkfs.fat -F32 -n "$ESP_LABEL" "$esp" - mkfs.btrfs -f -L "$FS_LABEL" "$rootpart" - - # --- Subvolumes ---------------------------------------------------------------------- - mount "$rootpart" /mnt - btrfs subvolume create "/mnt/$snap" - btrfs subvolume create /mnt/@home - umount /mnt - - mount -o "subvol=$snap,compress=zstd" "$rootpart" /mnt - mkdir -p /mnt/efi /mnt/home - mount -o subvol=@home "$rootpart" /mnt/home - mount "$esp" /mnt/efi - - # --- Wait for outbound network (pacstrap needs the mirrors) -------------------------- - marker "SILVERBLUE-INSTALL-NETWAIT" - local _ - for _ in $(seq 1 30); do - if curl -fsS --max-time 5 https://geo.mirror.pkgbuild.com/ >/dev/null 2>&1 \ - || ping -c1 -W2 8.8.8.8 >/dev/null 2>&1; then - break - fi - sleep 2 - done - - # --- Base system (no linux-firmware: virtio needs none, and it saves ~700MB) --------- - pacstrap -K /mnt "${PKGS_BASE[@]}" - - # fstab: omit the '/' line — the root subvolume comes from the kernel cmdline rootflags. - genfstab -U /mnt | awk '$2 != "/"' > /mnt/etc/fstab - - printf '%s\n' "$HOSTNAME" > /mnt/etc/hostname - ln -sf "/usr/share/zoneinfo/$TIMEZONE" /mnt/etc/localtime - - # Locale. - printf '%s UTF-8\n' "$LOCALE" > /mnt/etc/locale.gen - arch-chroot /mnt locale-gen - printf 'LANG=%s\n' "$LOCALE" > /mnt/etc/locale.conf - if [[ -n "$KEYMAP" ]]; then printf 'KEYMAP=%s\n' "$KEYMAP" > /mnt/etc/vconsole.conf; fi - - # /etc/os-release — a regular file overrides the stock symlink to /usr/lib/os-release. - sed -e "s|@DISTRO_NAME@|$(esc "$DISTRO_NAME")|g" \ - -e "s|@DISTRO_ID@|$(esc "$DISTRO_ID")|g" \ - -e "s|@DISTRO_VERSION@|$(esc "$DISTRO_VERSION")|g" \ - -e "s|@DISTRO_VERSION_ID@|$(esc "$DISTRO_VERSION_ID")|g" \ - -e "s|@DISTRO_ANSI_COLOR@|$(esc "$DISTRO_ANSI_COLOR")|g" \ - -e "s|@DISTRO_HOME_URL@|$(esc "$DISTRO_HOME_URL")|g" \ - -e "s|@DISTRO_DOC_URL@|$(esc "$DISTRO_DOC_URL")|g" \ - -e "s|@DISTRO_SUPPORT_URL@|$(esc "$DISTRO_SUPPORT_URL")|g" \ - -e "s|@DISTRO_BUG_URL@|$(esc "$DISTRO_BUG_URL")|g" \ - "$OSRELEASE_IN" > /mnt/etc/os-release + partition_disk "$disk" + esp=$(partition_path "$disk" 1) + rootpart=$(partition_path "$disk" 2) + format_partitions "$esp" "$rootpart" + create_subvolumes "$rootpart" "$snap" + mount_target "$rootpart" "$esp" "$snap" + # Wait for outbound network (pacstrap needs the mirrors). + marker "SILVERBLUE-INSTALL-NETWAIT" + wait_network + + # grub must come in via pacstrap (live pacman.conf + network): by bootloader-install + # time the hermetic path has already disabled the target's remote repos. + pkgs=("${PKGS_BASE[@]}") + if [[ "$bootloader" == grub ]]; then pkgs+=(grub); fi + run_pacstrap /mnt "${pkgs[@]}" + write_fstab /mnt + configure_target_system /mnt "$HOSTNAME" "$TIMEZONE" "$LOCALE" "$KEYMAP" + write_os_release /mnt + + # Test appliance: no root password — the harness drives an autologin shell. arch-chroot /mnt passwd -d root - # initramfs must carry btrfs (autodetect can't see it from the live medium's root). - sed -i 's/^MODULES=.*/MODULES=(btrfs)/' /mnt/etc/mkinitcpio.conf - arch-chroot /mnt mkinitcpio -P + configure_initramfs /mnt # Serial-console autologin on the target so the harness can drive it. install -Dm0644 /dev/stdin \ @@ -202,20 +107,7 @@ ExecStart= ExecStart=-/sbin/agetty -o '-p -- \\u' --autologin root --keep-baud 115200,38400,9600 - $TERM EOF - # --- Distro tools into the target (already renamed/rendered in the ISO by build.sh) --- - install -Dm0755 "/usr/bin/${BIN_PREFIX}-update" "/mnt/usr/bin/${BIN_PREFIX}-update" - mkdir -p "/mnt${LIB_DIR}" - cp -a "${LIB_DIR}/." "/mnt${LIB_DIR}/" - # Ensure the entry-point scripts are executable on the target regardless of source modes. - chmod 0755 "/mnt${LIB_DIR}/${UNIT_PREFIX}-mark-good.sh" \ - "/mnt${LIB_DIR}/${UNIT_PREFIX}-rollback.sh" - local u - for u in "${UNIT_PREFIX}-mark-good.service" "${UNIT_PREFIX}-rollback.service" "${UNIT_PREFIX}-rollback.target"; do - install -Dm0644 "/usr/lib/systemd/system/$u" "/mnt/usr/lib/systemd/system/$u" - done - install -Dm0644 "/etc/systemd/system.conf.d/${UNIT_PREFIX}-watchdog.conf" \ - "/mnt/etc/systemd/system.conf.d/${UNIT_PREFIX}-watchdog.conf" - arch-chroot /mnt systemctl enable "${UNIT_PREFIX}-mark-good.service" + install_target_tools /mnt # --- Local repo + marker v1; configure pacman for the update test -------------------- cp -a /opt/silverblue /mnt/opt/silverblue @@ -226,9 +118,9 @@ EOF # --- Bootloader ---------------------------------------------------------------------- pool_uuid=$(blkid -s UUID -o value "$rootpart") if [[ "$bootloader" == grub ]]; then - install_grub /mnt "$snap" "$pool_uuid" + install_grub /mnt "$snap" "$pool_uuid" "console=ttyS0,115200 console=tty0" else - install_sdboot /mnt "$snap" "$pool_uuid" + install_sdboot /mnt "$snap" "$pool_uuid" "console=ttyS0,115200 console=tty0" fi sync @@ -237,4 +129,9 @@ EOF poweroff -f } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + # -E so the ERR trap (FAIL marker + poweroff) also fires for failures inside the + # sourced library's functions, not only for commands directly in main(). + set -Eeuo pipefail + main "$@" +fi diff --git a/iso/build.sh b/iso/build.sh index 3c20b4c..47e39a9 100755 --- a/iso/build.sh +++ b/iso/build.sh @@ -76,6 +76,10 @@ main() { log "Overlaying iso/airootfs" cp -rT "$REPO/iso/airootfs" "$PROFILE/airootfs" + # Name the ISO after the distro id (releng defaults to archlinux-*.iso), so releases + # and derivatives ship a recognizable file name. + sed -i "s|^iso_name=.*|iso_name=\"${DISTRO_ID}\"|" "$PROFILE/profiledef.sh" + # --- Inject the distro tools at their installed paths (renamed/rendered per config) ---- log "Injecting tools (bin=${BIN_PREFIX}-update lib=${LIB_DIR})" local A="$PROFILE/airootfs" @@ -107,6 +111,12 @@ main() { install -Dm0644 "$REPO/src/bootloader/sdboot-helpers.sh" "$A${LIB_DIR}/sdboot-helpers.sh" install -Dm0644 "$REPO/src/bootloader/grub-helpers.sh" "$A${LIB_DIR}/grub-helpers.sh" + # Installer: shared library + interactive frontend. Both source config/distro.conf at + # runtime, so they are installed verbatim — only the frontend's *name* is derived. The + # library lives at a fixed, id-independent path both frontends default to. + install -Dm0644 "$REPO/src/installer/install-lib.sh" "$A/usr/local/lib/installer/install-lib.sh" + install -Dm0755 "$REPO/src/installer/silverblue-install" "$A/usr/bin/${BIN_PREFIX}-install" + # Init scripts + units: renamed by prefix and rendered (paths/branding substituted). render < "$REPO/src/init/silverblue-mark-good.sh" > "$A${LIB_DIR}/${UNIT_PREFIX}-mark-good.sh" render < "$REPO/src/init/silverblue-rollback.sh" > "$A${LIB_DIR}/${UNIT_PREFIX}-rollback.sh" @@ -129,6 +139,7 @@ main() { local fp; fp=$(mktemp) { printf ' ["/usr/bin/%s-update"]="0:0:755"\n' "$BIN_PREFIX" + printf ' ["/usr/bin/%s-install"]="0:0:755"\n' "$BIN_PREFIX" printf ' ["%s/%s-mark-good.sh"]="0:0:755"\n' "$LIB_DIR" "$UNIT_PREFIX" printf ' ["%s/%s-rollback.sh"]="0:0:755"\n' "$LIB_DIR" "$UNIT_PREFIX" printf ' ["/usr/local/bin/silverblue-autoinstall.sh"]="0:0:755"\n' @@ -136,6 +147,10 @@ main() { sed -i "/^file_permissions=(/r $fp" "$PROFILE/profiledef.sh" rm -f "$fp" + # Live-ISO greeting pointing at the interactive installer (overrides releng's motd). + printf 'Welcome to %s live.\n\nTo install to disk, run: %s-install\n\n' \ + "$DISTRO_NAME" "$BIN_PREFIX" > "$A/etc/motd" + # --- Enable services in the live ISO -------------------------------------------------- log "Enabling services" mkdir -p "$A/etc/systemd/system/multi-user.target.wants" diff --git a/src/bootloader/grub-helpers.sh b/src/bootloader/grub-helpers.sh index de18ce6..599482e 100644 --- a/src/bootloader/grub-helpers.sh +++ b/src/bootloader/grub-helpers.sh @@ -12,7 +12,15 @@ # # Next-boot-without-changing-default uses the one-shot `next_entry`; auto-rollback uses # the `recordfail` tripwire: grub.cfg arms it on the one-shot boot and mark-good clears it, -# so a boot that never marks good leaves `saved_entry` (the old root) as the default. +# so a boot that never marks good leaves `saved_entry` (the old root) as the default on +# the next (e.g. watchdog-reset or rollback-service) boot. recordfail keeps a FINITE menu +# timeout: an unattended machine must keep booting the default, never hold the menu. +# +# Known GRUB limitation (unlike systemd-boot's boot counting): a kernel that fails to +# LOAD leaves GRUB waiting at "Press any key"/the menu — stock GRUB has no unattended +# in-session fallback (its `fallback` variable only accepts numeric entry indexes and +# does not chain from an interactive selection). The previous root is one keypress away +# on the held menu; health-check failures and hangs still roll back automatically. # Return 0 if GRUB appears to be the active bootloader under $1 (the ESP mount). grub_is_active() { @@ -40,7 +48,7 @@ EOF } # Render the grub.cfg header: load grubenv from the FAT ESP, consume the one-shot -# `next_entry`, arm `recordfail`, otherwise fall back to the permanent `saved_entry`. +# `next_entry` (arming `recordfail`), otherwise boot the permanent `saved_entry`. grub_render_header() { local timeout=${1:-5} cat < %s\n' "$*" >&2; } +err() { printf 'error: %s\n' "$*" >&2; } +die() { err "$*"; exit 1; } + +# Progress markers on stdout. Uppercase SILVERBLUE-* markers are a contract with the QEMU +# harness (grepped literally, never renamed by iso/build.sh's render()). +marker() { printf '%s\n' "$*"; } + +# --- Pure helpers (unit-tested; no IO) ------------------------------------------------------ + +# Escape a string for the replacement side of a sed 's|...|...|' command. +esc() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } + +# RFC-1123 single-label host name. +validate_hostname() { + [[ "$1" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$ ]] +} + +# useradd-compatible user name. +validate_username() { + [[ "$1" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] +} + +# Print the microcode package for the CPU described by the /proc/cpuinfo text in $1 +# (nothing when the vendor is not recognized). +detect_microcode() { + local cpuinfo=$1 + if [[ "$cpuinfo" == *GenuineIntel* ]]; then + printf 'intel-ucode\n' + elif [[ "$cpuinfo" == *AuthenticAMD* ]]; then + printf 'amd-ucode\n' + fi +} + +# Filter `lsblk -dn -P -o PATH,TYPE,SIZE,MODEL` output ($1) down to installable disks, +# dropping the live-medium disk ($2, may be empty) and loop/rom/zram/ram/floppy devices. +# Prints one 'PATH|SIZE|MODEL' line per candidate. +filter_disks() { + local lsblk_text=$1 live_disk=${2:-} + local line path type size model + while IFS= read -r line; do + [[ -n "$line" ]] || continue + path=""; type=""; size=""; model="" + [[ "$line" =~ PATH=\"([^\"]*)\" ]] && path=${BASH_REMATCH[1]} + [[ "$line" =~ TYPE=\"([^\"]*)\" ]] && type=${BASH_REMATCH[1]} + [[ "$line" =~ SIZE=\"([^\"]*)\" ]] && size=${BASH_REMATCH[1]} + [[ "$line" =~ MODEL=\"([^\"]*)\" ]] && model=${BASH_REMATCH[1]} + [[ "$type" == disk && -n "$path" ]] || continue + [[ "$path" == "$live_disk" ]] && continue + [[ "$path" =~ ^/dev/(loop|ram|zram|sr|fd)[0-9]*$ ]] && continue + printf '%s|%s|%s\n' "$path" "$size" "$model" + done <<< "$lsblk_text" + return 0 +} + +# Partition device node: disks whose name ends in a digit get a 'p' separator +# (/dev/nvme0n1 -> /dev/nvme0n1p1, /dev/vda -> /dev/vda1). +partition_path() { + local disk=$1 n=$2 + if [[ "$disk" =~ [0-9]$ ]]; then + printf '%sp%s\n' "$disk" "$n" + else + printf '%s%s\n' "$disk" "$n" + fi +} + +# Kernel console= options for the tty the installer runs on ($1, as printed by tty(1)). +# A serial-console install carries its console to the installed system's boot entry; +# anything else adds nothing. +console_opts_for_tty() { + local tty=$1 + if [[ "$tty" =~ ^/dev/(ttyS[0-9]+)$ ]]; then + printf 'console=%s,115200 console=tty0\n' "${BASH_REMATCH[1]}" + fi +} + +# Render the pre-install confirmation summary from the SB_INST_* globals set by the +# interactive frontend's gather_answers(). +render_summary() { + printf 'Installation summary\n' + printf ' Disk : %s\n' "${SB_INST_DISK:-}" + printf ' Hostname : %s\n' "${SB_INST_HOSTNAME:-}" + printf ' Timezone : %s\n' "${SB_INST_TIMEZONE:-}" + printf ' Locale : %s\n' "${SB_INST_LOCALE:-}" + printf ' Keymap : %s\n' "${SB_INST_KEYMAP:-(kernel default)}" + printf ' Bootloader : %s\n' "${SB_INST_BOOTLOADER:-}" + printf ' Microcode : %s\n' "${SB_INST_MICROCODE:-none}" + printf ' linux-firmware : %s\n' "${SB_INST_FIRMWARE:-no}" + printf ' Network : %s\n' "${SB_INST_NETWORK:-none}" + printf ' Admin user : %s\n' "${SB_INST_USERNAME:-(none)}" + printf ' Packages : %s\n' "${SB_INST_PKGS[*]:-}" +} + +# --- Prompt helpers (unit-tested via piped stdin) ------------------------------------------- +# Prompt text goes to stderr and the result to stdout, so callers can capture answers with +# $(...) while the user still sees the prompts. When SB_INSTALL_MARKERS=1, each prompt is +# preceded by a 'SILVERBLUE-INSTALL-PROMPT key=' line on stderr; the QEMU harness keys +# its scripted answers off these markers instead of the human-readable prompt text. + +prompt_marker() { + [[ "${SB_INSTALL_MARKERS:-0}" == 1 ]] || return 0 + printf 'SILVERBLUE-INSTALL-PROMPT key=%s\n' "$1" >&2 +} + +# Read one line into the caller's $reply; tolerate a final unterminated line, fail on EOF. +read_reply() { + IFS= read -r reply && return 0 + [[ -n "$reply" ]] || { err "unexpected end of input"; return 1; } +} + +read_reply_secret() { + IFS= read -rs reply && return 0 + [[ -n "$reply" ]] || { err "unexpected end of input"; return 1; } +} + +# ask KEY PROMPT DEFAULT [VALIDATOR] — free-text prompt. Empty input takes the default; +# re-prompts until VALIDATOR (a function name) accepts the value. +ask() { + local key=$1 prompt=$2 default=$3 validator=${4:-} reply + while true; do + prompt_marker "$key" + if [[ -n "$default" ]]; then + printf '%s [%s]: ' "$prompt" "$default" >&2 + else + printf '%s: ' "$prompt" >&2 + fi + read_reply || return 1 + reply=${reply:-$default} + if [[ -z "$validator" ]] || "$validator" "$reply"; then + printf '%s\n' "$reply" + return 0 + fi + printf 'invalid value: %s\n' "$reply" >&2 + done +} + +# ask_yesno KEY PROMPT DEFAULT(y|n) — returns 0 for yes, 1 for no. +ask_yesno() { + local key=$1 prompt=$2 default=$3 reply hint="y/N" + [[ "$default" == y ]] && hint="Y/n" + while true; do + prompt_marker "$key" + printf '%s [%s]: ' "$prompt" "$hint" >&2 + read_reply || return 1 + reply=${reply:-$default} + case "${reply,,}" in + y|yes) return 0 ;; + n|no) return 1 ;; + esac + printf 'please answer y or n\n' >&2 + done +} + +# choose KEY PROMPT DEFAULT ITEM... — numbered menu; prints the chosen item to stdout. +# Empty input selects DEFAULT (an item value, not an index). +choose() { + local key=$1 prompt=$2 default=$3 + shift 3 + local items=("$@") i reply + while true; do + prompt_marker "$key" + printf '%s\n' "$prompt" >&2 + for i in "${!items[@]}"; do + printf ' %d) %s\n' "$((i + 1))" "${items[$i]}" >&2 + done + printf 'Select [1-%d] (default: %s): ' "${#items[@]}" "$default" >&2 + read_reply || return 1 + if [[ -z "$reply" ]]; then + printf '%s\n' "$default" + return 0 + fi + if [[ "$reply" =~ ^[0-9]+$ ]] && (( reply >= 1 && reply <= ${#items[@]} )); then + printf '%s\n' "${items[$((reply - 1))]}" + return 0 + fi + printf 'invalid selection: %s\n' "$reply" >&2 + done +} + +# ask_secret KEY PROMPT — read a password twice without echo (markers KEY and KEY-confirm); +# loops until the entries are non-empty and match. +ask_secret() { + local key=$1 prompt=$2 first reply + while true; do + prompt_marker "$key" + printf '%s: ' "$prompt" >&2 + read_reply_secret || return 1 + printf '\n' >&2 + first=$reply + prompt_marker "$key-confirm" + printf '%s (again): ' "$prompt" >&2 + read_reply_secret || return 1 + printf '\n' >&2 + if [[ -z "$first" ]]; then + printf 'password must not be empty\n' >&2 + continue + fi + if [[ "$first" != "$reply" ]]; then + printf 'passwords do not match\n' >&2 + continue + fi + printf '%s\n' "$first" + return 0 + done +} + +# --- Effectful install steps (exercised by the QEMU integration test) ----------------------- +# The canonical sequence both frontends run; see docs/update-flow.md for the resulting +# on-disk layout. All of these expect config/distro.conf to have been sourced. + +# One outbound connectivity probe (mirrors reachable / DNS up). +check_network() { + curl -fsS --max-time 5 https://geo.mirror.pkgbuild.com/ >/dev/null 2>&1 \ + || ping -c1 -W2 8.8.8.8 >/dev/null 2>&1 +} + +# Wait up to ~60s for outbound network (slow DHCP); proceeds either way — pacstrap gives +# the definitive error if the mirrors really are unreachable. +wait_network() { + local _ + for _ in $(seq 1 30); do + check_network && return 0 + sleep 2 + done + return 0 +} + +# Partition $1: p1 ESP (FAT32, 512M), p2 Btrfs (rest). +partition_disk() { + local disk=$1 + wipefs -a "$disk" + "$SGDISK" -Z "$disk" + "$SGDISK" -n1:0:+512M -t1:ef00 -c1:EFI "$disk" + "$SGDISK" -n2:0:0 -t2:8300 -c2:"$FS_LABEL" "$disk" + partprobe "$disk" 2>/dev/null || true + udevadm settle || true + sleep 1 +} + +format_partitions() { + local esp=$1 rootpart=$2 + mkfs.fat -F32 -n "$ESP_LABEL" "$esp" + mkfs.btrfs -f -L "$FS_LABEL" "$rootpart" +} + +# Create the initial root- subvolume ($2) and @home on $1. +create_subvolumes() { + local rootpart=$1 snap=$2 + mount "$rootpart" /mnt + btrfs subvolume create "/mnt/$snap" + btrfs subvolume create /mnt/@home + umount /mnt +} + +# Mount the target tree at /mnt (root subvol + @home + ESP). +mount_target() { + local rootpart=$1 esp=$2 snap=$3 + mount -o "subvol=$snap,compress=zstd" "$rootpart" /mnt + mkdir -p /mnt/efi /mnt/home + mount -o subvol=@home "$rootpart" /mnt/home + mount "$esp" /mnt/efi +} + +run_pacstrap() { + local mnt=$1 + shift + "$PACSTRAP" -K "$mnt" "$@" +} + +# fstab: omit the '/' line — the root subvolume comes from the kernel cmdline rootflags. +write_fstab() { + local mnt=$1 + genfstab -U "$mnt" | awk '$2 != "/"' > "$mnt/etc/fstab" +} + +configure_target_system() { + local mnt=$1 hostname=$2 timezone=$3 locale=$4 keymap=$5 + printf '%s\n' "$hostname" > "$mnt/etc/hostname" + ln -sf "/usr/share/zoneinfo/$timezone" "$mnt/etc/localtime" + printf '%s UTF-8\n' "$locale" > "$mnt/etc/locale.gen" + "$ARCH_CHROOT" "$mnt" locale-gen + printf 'LANG=%s\n' "$locale" > "$mnt/etc/locale.conf" + if [[ -n "$keymap" ]]; then printf 'KEYMAP=%s\n' "$keymap" > "$mnt/etc/vconsole.conf"; fi +} + +# /etc/os-release — a regular file overrides the stock symlink to /usr/lib/os-release. +write_os_release() { + local mnt=$1 + sed -e "s|@DISTRO_NAME@|$(esc "$DISTRO_NAME")|g" \ + -e "s|@DISTRO_ID@|$(esc "$DISTRO_ID")|g" \ + -e "s|@DISTRO_VERSION@|$(esc "$DISTRO_VERSION")|g" \ + -e "s|@DISTRO_VERSION_ID@|$(esc "$DISTRO_VERSION_ID")|g" \ + -e "s|@DISTRO_ANSI_COLOR@|$(esc "$DISTRO_ANSI_COLOR")|g" \ + -e "s|@DISTRO_HOME_URL@|$(esc "$DISTRO_HOME_URL")|g" \ + -e "s|@DISTRO_DOC_URL@|$(esc "$DISTRO_DOC_URL")|g" \ + -e "s|@DISTRO_SUPPORT_URL@|$(esc "$DISTRO_SUPPORT_URL")|g" \ + -e "s|@DISTRO_BUG_URL@|$(esc "$DISTRO_BUG_URL")|g" \ + "$OSRELEASE_IN" > "$mnt/etc/os-release" +} + +# initramfs must carry btrfs (autodetect can't see it from the live medium's root). +configure_initramfs() { + local mnt=$1 + sed -i 's/^MODULES=.*/MODULES=(btrfs)/' "$mnt/etc/mkinitcpio.conf" + "$ARCH_CHROOT" "$mnt" mkinitcpio -P +} + +# Copy the distro tools (already renamed/rendered in the ISO by build.sh) into the target +# and enable the post-boot health check. +install_target_tools() { + local mnt=$1 u + install -Dm0755 "/usr/bin/${BIN_PREFIX}-update" "$mnt/usr/bin/${BIN_PREFIX}-update" + mkdir -p "${mnt}${LIB_DIR}" + cp -a "${LIB_DIR}/." "${mnt}${LIB_DIR}/" + # Ensure the entry-point scripts are executable on the target regardless of source modes. + chmod 0755 "${mnt}${LIB_DIR}/${UNIT_PREFIX}-mark-good.sh" \ + "${mnt}${LIB_DIR}/${UNIT_PREFIX}-rollback.sh" + for u in "${UNIT_PREFIX}-mark-good.service" "${UNIT_PREFIX}-rollback.service" "${UNIT_PREFIX}-rollback.target"; do + install -Dm0644 "/usr/lib/systemd/system/$u" "$mnt/usr/lib/systemd/system/$u" + done + install -Dm0644 "/etc/systemd/system.conf.d/${UNIT_PREFIX}-watchdog.conf" \ + "$mnt/etc/systemd/system.conf.d/${UNIT_PREFIX}-watchdog.conf" + "$ARCH_CHROOT" "$mnt" systemctl enable "${UNIT_PREFIX}-mark-good.service" +} + +# Append the derivative's extra pacman repos (if any) to the target's pacman.conf. +append_extra_repos() { + local mnt=$1 r + for r in "${EXTRA_REPOS[@]}"; do + printf '\n%s\n' "$r" >> "$mnt/etc/pacman.conf" + done +} + +set_root_password() { + local mnt=$1 password=$2 + printf 'root:%s\n' "$password" | "$ARCH_CHROOT" "$mnt" chpasswd +} + +# Create a wheel-group admin user with sudo access (the frontend adds the sudo package to +# the pacstrap list when a user is requested). +create_admin_user() { + local mnt=$1 user=$2 password=$3 + "$ARCH_CHROOT" "$mnt" useradd -m -G wheel -s /bin/bash "$user" + printf '%s:%s\n' "$user" "$password" | "$ARCH_CHROOT" "$mnt" chpasswd + printf '%%wheel ALL=(ALL:ALL) ALL\n' | install -Dm0440 /dev/stdin "$mnt/etc/sudoers.d/10-wheel" +} + +# enable_network_stack MNT none|networkd|networkmanager — keep the base unopinionated: +# networkd only enables what systemd already ships; NetworkManager is opt-in (the frontend +# adds the package to the pacstrap list). +enable_network_stack() { + local mnt=$1 mode=$2 + case "$mode" in + networkd) + install -Dm0644 /dev/stdin "$mnt/etc/systemd/network/20-wired.network" <<'EOF' +[Match] +Type=ether + +[Network] +DHCP=yes +EOF + "$ARCH_CHROOT" "$mnt" systemctl enable systemd-networkd.service systemd-resolved.service + ln -sf ../run/systemd/resolve/stub-resolv.conf "$mnt/etc/resolv.conf" + ;; + networkmanager) + "$ARCH_CHROOT" "$mnt" systemctl enable NetworkManager.service + ;; + none|"") + ;; + *) + err "unknown network mode: $mode" + return 1 + ;; + esac +} + +# Install systemd-boot with an initial boot entry for snapshot $2. +# $1 mnt $2 snap $3 pool_uuid $4 extra kernel cmdline options (may be empty) +install_sdboot() { + local mnt=$1 snap=$2 uuid=$3 extra_opts=$4 + local efi="$mnt/efi" img + bootctl --esp-path="$efi" install + mkdir -p "$efi/EFI/BOOT" "$efi/$ESP_SUBDIR/$snap" "$efi/loader/entries" + # Removable fallback so the machine boots even without persisted EFI NVRAM. + cp "$efi/EFI/systemd/systemd-bootx64.efi" "$efi/EFI/BOOT/BOOTX64.EFI" + cp "$mnt/boot/vmlinuz-linux" "$efi/$ESP_SUBDIR/$snap/" + # Microcode images load before the initramfs; copy and list them first (the same + # ordering the update engine's sdboot helpers use for every later entry). + for img in "$mnt"/boot/*-ucode.img; do + [[ -e "$img" ]] || continue + cp "$img" "$efi/$ESP_SUBDIR/$snap/" + done + cp "$mnt/boot/initramfs-linux.img" "$efi/$ESP_SUBDIR/$snap/" + # No explicit default= : systemd-boot then selects the newest-version entry, which is how + # the update engine makes a freshly registered root boot next without touching the default. + cat > "$efi/loader/loader.conf" < "$efi/loader/entries/$snap.conf" +} + +# Install GRUB (removable layout) with the grubenv-on-ESP scheme the update engine manages. +# The grub package must already be on the target: frontends add it to the pacstrap list +# (installing it here via chroot pacman would break the hermetic test path, whose target +# has the remote repos disabled). +# $1 mnt $2 snap $3 pool_uuid $4 extra kernel cmdline options (may be empty) +install_grub() { + local mnt=$1 snap=$2 uuid=$3 extra_opts=$4 + local efi="$mnt/efi" + "$ARCH_CHROOT" "$mnt" grub-install --target=x86_64-efi --efi-directory=/efi \ + --boot-directory=/efi --bootloader-id=GRUB --removable + mkdir -p "$efi/grub" + grub-editenv "$efi/grub/grubenv" create + grub-editenv "$efi/grub/grubenv" set "saved_entry=$snap" + cat > "$efi/grub/grub.cfg" <' marker, and tests/qemu/harness.py answers the +# prompts in the order gather_answers() asks them. If you add, remove, or reorder prompts, +# update phase_interactive_install() in harness.py to match. + +# Branding + install defaults for the distro this ISO was built as (fixed, id-independent +# path; see iso/build.sh). +DISTRO_CONF=${DISTRO_CONF:-/usr/local/share/distro/distro.conf} +OSRELEASE_IN=${OSRELEASE_IN:-/usr/local/share/distro/os-release.in} +# shellcheck source=../../config/distro.conf +source "$DISTRO_CONF" || { echo "error: cannot load $DISTRO_CONF" >&2; exit 1; } + +SB_INSTALL_LIB=${SB_INSTALL_LIB:-/usr/local/lib/installer/install-lib.sh} +# shellcheck source=install-lib.sh +source "$SB_INSTALL_LIB" || { echo "error: cannot load $SB_INSTALL_LIB" >&2; exit 1; } + +usage() { + printf 'Usage: %s\n\nInteractive installer for %s. Run from the live ISO as root;\nit takes no arguments and prompts for everything it needs.\n' \ + "${0##*/}" "$DISTRO_NAME" >&2 +} + +# --- Environment-dependent validators (the pure ones live in install-lib.sh) --------------- + +validate_timezone() { [[ -n "$1" && -f "/usr/share/zoneinfo/$1" ]]; } + +validate_locale() { + [[ -n "$1" ]] || return 1 + # If the live ISO ships glibc's SUPPORTED list, check against it; otherwise accept. + [[ -f /usr/share/i18n/SUPPORTED ]] || return 0 + cut -d' ' -f1 /usr/share/i18n/SUPPORTED | grep -qxF "$1" +} + +# --- Steps ---------------------------------------------------------------------------------- + +preflight() { + [[ $EUID -eq 0 ]] || die "must run as root" + [[ -d /sys/firmware/efi/efivars ]] \ + || die "booted in BIOS mode; $DISTRO_NAME supports UEFI only" + local tool + for tool in pacstrap sgdisk mkfs.fat mkfs.btrfs btrfs genfstab arch-chroot blkid wipefs; do + command -v "$tool" >/dev/null 2>&1 || die "missing tool: $tool" + done + if "$FINDMNT" -no TARGET /mnt >/dev/null 2>&1; then + die "/mnt is already mounted; unmount it first (umount -R /mnt)" + fi + while ! check_network; do + err "no outbound network (pacstrap needs the Arch mirrors)" + log "bring networking up in another console (e.g. iwctl for Wi-Fi), then retry" + ask_yesno retry-network "Retry the network check?" y || die "aborted: no network" + done +} + +# Print the disk backing the live medium (so it can be excluded), or nothing. +live_medium_disk() { + local src pk + src=$("$FINDMNT" -no SOURCE /run/archiso/bootmnt 2>/dev/null) || return 0 + [[ -n "$src" && -b "$src" ]] || return 0 + pk=$("$LSBLK" -no PKNAME "$src" 2>/dev/null | head -n1) || true + if [[ -n "$pk" ]]; then + printf '/dev/%s\n' "$pk" + else + printf '%s\n' "$src" + fi +} + +# Numbered disk menu; prints the chosen /dev path. +select_disk() { + local live listing line path size model choice + local lines=() menu=() + live=$(live_medium_disk) + listing=$("$LSBLK" -dn -P -o PATH,TYPE,SIZE,MODEL) + mapfile -t lines < <(filter_disks "$listing" "$live") + (( ${#lines[@]} > 0 )) || die "no installable disks found" + for line in "${lines[@]}"; do + IFS='|' read -r path size model <<< "$line" + menu+=("$path $size $model") + done + choice=$(choose disk "Target disk (will be ERASED):" "${menu[0]}" "${menu[@]}") + printf '%s\n' "${choice%% *}" +} + +# Ask everything upfront. PROMPT ORDER IS A CONTRACT with tests/qemu/harness.py (see the +# header comment). Results land in the SB_INST_* globals render_summary() reads. +gather_answers() { + SB_INST_DISK=$(select_disk) + + SB_INST_HOSTNAME=$(ask hostname "Hostname" "$HOSTNAME" validate_hostname) + SB_INST_TIMEZONE=$(ask timezone "Timezone (e.g. Europe/Amsterdam)" "$TIMEZONE" validate_timezone) + SB_INST_LOCALE=$(ask locale "Locale" "$LOCALE" validate_locale) + SB_INST_KEYMAP=$(ask keymap "Console keymap (empty = kernel default)" "$KEYMAP") + SB_INST_BOOTLOADER=$(choose bootloader "Bootloader:" "$BOOTLOADER" systemd-boot grub) + + local ucode + ucode=$(detect_microcode "$(cat /proc/cpuinfo)") + SB_INST_MICROCODE=none + if [[ -n "$ucode" ]]; then + if ask_yesno microcode "Install CPU microcode updates ($ucode)?" y; then + SB_INST_MICROCODE=$ucode + fi + else + if ask_yesno microcode "CPU vendor not detected. Install both intel-ucode and amd-ucode?" n; then + SB_INST_MICROCODE="intel-ucode amd-ucode" + fi + fi + + SB_INST_FIRMWARE=no + if ask_yesno firmware "Install linux-firmware (needed on most real hardware)?" y; then + SB_INST_FIRMWARE=yes + fi + + local netchoice + netchoice=$(choose network "Network configuration for the installed system:" \ + "systemd-networkd (DHCP, ships with systemd)" \ + "none (configure it yourself)" \ + "systemd-networkd (DHCP, ships with systemd)" \ + "NetworkManager (installs the networkmanager package)") + case "$netchoice" in + none*) SB_INST_NETWORK=none ;; + systemd-networkd*) SB_INST_NETWORK=networkd ;; + NetworkManager*) SB_INST_NETWORK=networkmanager ;; + esac + + SB_INST_ROOT_PASSWORD=$(ask_secret root-password "Root password") + + while true; do + SB_INST_USERNAME=$(ask username "Admin user name (empty = none)" "") + [[ -z "$SB_INST_USERNAME" ]] && break + validate_username "$SB_INST_USERNAME" && break + printf 'invalid user name: %s\n' "$SB_INST_USERNAME" >&2 + done + SB_INST_USER_PASSWORD="" + if [[ -n "$SB_INST_USERNAME" ]]; then + SB_INST_USER_PASSWORD=$(ask_secret user-password "Password for $SB_INST_USERNAME") + fi + + SB_INST_PKGS=("${PKGS_BASE[@]}") + if [[ "$SB_INST_MICROCODE" != none ]]; then + local -a ucode_pkgs + read -ra ucode_pkgs <<< "$SB_INST_MICROCODE" + SB_INST_PKGS+=("${ucode_pkgs[@]}") + fi + if [[ "$SB_INST_FIRMWARE" == yes ]]; then + SB_INST_PKGS+=(linux-firmware) + fi + if [[ "$SB_INST_NETWORK" == networkmanager ]]; then + SB_INST_PKGS+=(networkmanager) + fi + if [[ "$SB_INST_BOOTLOADER" == grub ]]; then + SB_INST_PKGS+=(grub) + fi + if [[ -n "$SB_INST_USERNAME" ]]; then + SB_INST_PKGS+=(sudo) + fi +} + +confirm_or_abort() { + printf '\n' >&2 + render_summary >&2 + printf '\n !! ALL DATA ON %s WILL BE ERASED !!\n\n' "$SB_INST_DISK" >&2 + prompt_marker confirm + printf 'Type ERASE to continue, anything else to abort: ' >&2 + local reply + IFS= read -r reply || reply="" + [[ "$reply" == ERASE ]] || die "aborted (no changes were made)" +} + +do_install() { + local esp rootpart ts snap pool_uuid extra_opts + ts=$(date +%Y%m%d-%H%M%S) + snap="root-$ts" + # A serial-console install carries its console= options into the boot entry (headless + # machines stay reachable; this is also what makes the QEMU test observable). + extra_opts=$(console_opts_for_tty "$(tty 2>/dev/null || true)") + + # On failure: report, unmount, and leave the user their shell (no poweroff). + # shellcheck disable=SC2154 # LINENO is provided by bash + trap 'marker "SILVERBLUE-INSTALL-FAIL line=$LINENO"; umount -R /mnt 2>/dev/null || true' ERR + + marker "SILVERBLUE-INSTALL-START disk=$SB_INST_DISK snap=$snap bootloader=$SB_INST_BOOTLOADER" + + marker "SILVERBLUE-INSTALL-STEP name=partition" + partition_disk "$SB_INST_DISK" + esp=$(partition_path "$SB_INST_DISK" 1) + rootpart=$(partition_path "$SB_INST_DISK" 2) + + marker "SILVERBLUE-INSTALL-STEP name=filesystems" + format_partitions "$esp" "$rootpart" + create_subvolumes "$rootpart" "$snap" + mount_target "$rootpart" "$esp" "$snap" + + marker "SILVERBLUE-INSTALL-STEP name=pacstrap" + run_pacstrap /mnt "${SB_INST_PKGS[@]}" + + marker "SILVERBLUE-INSTALL-STEP name=configure" + write_fstab /mnt + configure_target_system /mnt "$SB_INST_HOSTNAME" "$SB_INST_TIMEZONE" "$SB_INST_LOCALE" "$SB_INST_KEYMAP" + write_os_release /mnt + configure_initramfs /mnt + install_target_tools /mnt + append_extra_repos /mnt + enable_network_stack /mnt "$SB_INST_NETWORK" + set_root_password /mnt "$SB_INST_ROOT_PASSWORD" + if [[ -n "$SB_INST_USERNAME" ]]; then + create_admin_user /mnt "$SB_INST_USERNAME" "$SB_INST_USER_PASSWORD" + fi + + marker "SILVERBLUE-INSTALL-STEP name=bootloader" + pool_uuid=$("$BLKID" -s UUID -o value "$rootpart") + if [[ "$SB_INST_BOOTLOADER" == grub ]]; then + install_grub /mnt "$snap" "$pool_uuid" "$extra_opts" + else + install_sdboot /mnt "$snap" "$pool_uuid" "$extra_opts" + fi + + sync + umount -R /mnt + trap - ERR + marker "SILVERBLUE-INSTALL-OK snap=$snap uuid=$pool_uuid bootloader=$SB_INST_BOOTLOADER" +} + +offer_reboot() { + log "installation complete" + if ask_yesno reboot "Reboot into the installed system now?" n; then + systemctl reboot + else + log "reboot when ready with: systemctl reboot" + fi +} + +main() { + if (( $# > 0 )); then + usage + exit 2 + fi + printf '%s installer\n' "$DISTRO_NAME" >&2 + preflight + gather_answers + confirm_or_abort + do_install + offer_reboot +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + # -E so do_install's ERR trap (FAIL marker + unmount) also fires for failures inside + # the sourced library's functions, not only for commands directly in do_install(). + set -Eeuo pipefail + main "$@" +fi diff --git a/tests/qemu/harness.py b/tests/qemu/harness.py index 8b5a85e..b0dd2c1 100755 --- a/tests/qemu/harness.py +++ b/tests/qemu/harness.py @@ -13,6 +13,19 @@ new root's kernel (on the ESP for systemd-boot, inside the subvolume for GRUB), reboot; assert the system fell back to the previous (good) root. +With SB_INTERACTIVE=1 (run.sh --interactive) it instead drives the interactive installer +over the serial console — answering its prompts via the SILVERBLUE-INSTALL-PROMPT markers +(SB_INSTALL_MARKERS=1; the prompt ORDER is a contract with gather_answers() in +src/installer/silverblue-install) — then boots the installed system, logs in with the +password it set, and verifies the result: + + 1. interactive-install — boot the ISO, run silverblue-install, answer every prompt, + confirm with ERASE, wait for SILVERBLUE-INSTALL-OK, decline the reboot. + 2. interactive-boot — boot the disk, log in as root at the serial getty (no autologin + on interactive targets), assert the right subvol/hostname/mark-good, the + enabled network stack, the admin user + sudoers drop-in, and that no + test-only artifacts (autologin drop-in, /opt/silverblue) were installed. + Each scenario prints PASS/FAIL; the process exits 0 only if all pass (CI-friendly). Only the Python standard library is used. """ @@ -31,8 +44,13 @@ CPU = os.environ.get("SB_CPU", "qemu64") NET = os.environ.get("SB_NET", "0") BOOTLOADER = os.environ.get("SB_BOOTLOADER", "systemd-boot") +INTERACTIVE = os.environ.get("SB_INTERACTIVE", "0") == "1" WORK = os.environ.get("SB_WORK", ".") +# Credentials the interactive scenario feeds the installer (test-only values). +ROOT_PW = "sbtest-root-pw" +USER_PW = "sbtest-user-pw" + # TCG is much slower than KVM, so scale timeouts accordingly. SLOW = ACCEL != "kvm" T_INSTALL = 3600 if SLOW else 1200 @@ -186,12 +204,18 @@ def wait_login(con, timeout=T_BOOT): """Wait until an autologin root shell is accepting commands. Works regardless of prompt theming/escape codes (grml-zsh on the live ISO, bash on the - target) because it matches a sentinel we print, not the prompt. The repeated Enter also - advances a systemd-boot menu left up by a failed (corrupt-kernel) boot. + target) because it matches a sentinel we print, not the prompt. + + Each probe leads with a bare Enter: at a shell that is a harmless empty line, but at a + waiting boot menu it immediately boots the highlighted default entry. This matters for + GRUB, where any *printable* keystroke cancels the menu countdown and 'e' (the first + letter of our echo probe) would drop into the entry editor; it also advances a + systemd-boot menu left up by a failed (corrupt-kernel) boot. """ deadline = time.time() + timeout while True: marker = _next_marker() + con.send("") con.send(_emit(marker)) try: con.expect([marker], timeout=8) @@ -211,6 +235,42 @@ def sh(con, command, timeout=T_CMD): return before +def answer(con, key, value, timeout=T_CMD): + """Wait for the installer's prompt marker for `key`, then send `value`. + + Matches the trailing newline so a key that is a prefix of another + (root-password vs root-password-confirm) cannot match the wrong marker. + """ + con.expect([r"SILVERBLUE-INSTALL-PROMPT key=%s[\r\n]" % re.escape(key)], timeout) + con.send(value) + + +def login_serial(con, password, host, timeout=T_BOOT): + """Log in as root at a serial getty (interactive targets have no autologin). + + Do NOT call wait_login() first: its sentinel probes would be typed into the + login: prompt. After the password we reuse the sentinel loop to confirm the + shell — early probes may be swallowed while PAM runs, so keep retrying. + """ + con.expect([r"%s login:" % re.escape(host)], timeout) + con.send("root") + con.expect([r"Password:"], T_CMD) + con.send(password) + deadline = time.time() + T_CMD + while True: + marker = _next_marker() + con.send("") + con.send(_emit(marker)) + try: + con.expect([marker], timeout=8) + return + except ConsoleError: + if con.proc.poll() is not None: + raise + if time.time() > deadline: + raise ConsoleError("%s: timed out waiting for a shell after login" % con.name) + + def get_subvol(con): out = sh(con, "cat /proc/cmdline") m = re.search(r"rootflags=subvol=(root-\S+)", out) @@ -307,21 +367,41 @@ def phase_rollback(): bad_snap = run_update(con, tries=1) if bad_snap == good: raise ConsoleError("update did not create a distinct snapshot") - print("[rollback] bad update staged as %s; corrupting its kernel" % bad_snap) if BOOTLOADER == "grub": - # GRUB loads the kernel from inside the snapshot's Btrfs subvolume (it reads Btrfs - # natively), not from a per-snapshot copy on the ESP. Reach it via the Btrfs - # top-level (subvolid=5) and zero it there. + # An unloadable kernel is systemd-boot's scenario (boot counting recovers it); + # stock GRUB cannot recover that unattended — after a failed automatic boot it + # waits at "Press any key" / the menu (see grub-helpers.sh). Instead exercise + # the rollback mechanism GRUB does automate end-to-end: force the staged + # root's health check to fail, so mark-good's OnFailure handler arms the + # previous root and reboots into it. + delay = 180 if SLOW else 45 + print("[rollback] bad update staged as %s; forcing its health check to fail" % bad_snap) sh(con, "d=$(findmnt -no SOURCE / | sed 's/\\[.*//'); " "mkdir -p /mnt/sbtop && mount -o subvolid=5 \"$d\" /mnt/sbtop && " - "truncate -s 0 /mnt/sbtop/%s/boot/vmlinuz-linux && sync && umount /mnt/sbtop" - % bad_snap) + "mkdir -p /mnt/sbtop/%s/etc/systemd/system/silverblue-mark-good.service.d && " + "printf '[Service]\\nEnvironment=\"SB_HEALTHCHECK_CMD=sleep %d; exit 1\"\\n' " + "> /mnt/sbtop/%s/etc/systemd/system/silverblue-mark-good.service.d/99-fail-health.conf && " + "sync && umount /mnt/sbtop" + % (bad_snap, delay, bad_snap)) else: # systemd-boot only reads FAT, so each snapshot's kernel is copied onto the ESP. + print("[rollback] bad update staged as %s; corrupting its kernel" % bad_snap) sh(con, "truncate -s 0 /efi/silverblue/%s/vmlinuz-linux; sync" % bad_snap) con.send("systemctl reboot") + if BOOTLOADER == "grub": + # The staged root boots normally and fails its health check `delay`s later. + # Confirm the staged boot actually happened, then wait passively for the + # rollback reboot's autologin banner — keystrokes at the GRUB menu would boot + # an entry interactively and derail the automatic flow. + con.expect([r"login: root \(automatic login\)"], timeout=T_BOOT) + wait_login(con) + staged = get_subvol(con) + if staged != bad_snap: + raise ConsoleError("expected staged boot of %s, got %s" % (bad_snap, staged)) + print("[rollback] staged root %s booted; awaiting health failure + auto-rollback" % staged) + con.expect([r"login: root \(automatic login\)"], timeout=T_BOOT * 2) # systemd-boot tries the corrupt entry (tries=1 -> 0), fails, and falls back. wait_login # sends Enter each iteration, advancing any paused boot menu to the fallback entry. wait_login(con, timeout=T_BOOT * 2) @@ -339,8 +419,107 @@ def phase_rollback(): con.close() +def phase_interactive_install(): + con = Console("interactive-install", ["-cdrom", ISO]) + try: + wait_login(con, timeout=T_BOOT) + # ANSWER ORDER IS A CONTRACT with gather_answers() in src/installer/silverblue-install. + con.send("SB_INSTALL_MARKERS=1 silverblue-install") + answer(con, "disk", "1") # the only candidate: the virtio test disk + answer(con, "hostname", "sbtest") + answer(con, "timezone", "") # accept the distro.conf defaults + answer(con, "locale", "") + answer(con, "keymap", "") + answer(con, "bootloader", "2" if BOOTLOADER == "grub" else "1") + answer(con, "microcode", "n") # keep the test target lean + answer(con, "firmware", "n") + answer(con, "network", "2") # systemd-networkd + answer(con, "root-password", ROOT_PW) + answer(con, "root-password-confirm", ROOT_PW) + answer(con, "username", "tester") + answer(con, "user-password", USER_PW) + answer(con, "user-password-confirm", USER_PW) + answer(con, "confirm", "ERASE") + idx, _ = con.expect( + [r"SILVERBLUE-INSTALL-OK snap=(root-\S+)", r"SILVERBLUE-INSTALL-FAIL"], + timeout=T_INSTALL, + ) + if idx != 0: + raise ConsoleError("interactive install failed") + with con.lock: + text = con.buf + snap = re.search(r"SILVERBLUE-INSTALL-OK snap=(root-\S+)", text).group(1) + answer(con, "reboot", "n") + sh(con, "sync") + con.send("poweroff -f") + con.wait_exit(timeout=120) + return snap + finally: + con.close() + + +def phase_interactive_boot(snap): + con = Console("interactive-boot", []) + try: + login_serial(con, ROOT_PW, host="sbtest") + booted = get_subvol(con) + if booted != snap: + raise ConsoleError("expected to boot %s, booted %s" % (snap, booted)) + out = sh(con, "cat /etc/hostname") + if "sbtest" not in out: + raise ConsoleError("unexpected hostname:\n%s" % out) + assert_markgood(con) + print("\n[interactive-boot] %s booted and marked good" % booted) + + out = sh(con, "systemctl is-enabled systemd-networkd.service") + if "enabled" not in out: + raise ConsoleError("systemd-networkd is not enabled:\n%s" % out) + out = sh(con, "id -nG tester") + if "wheel" not in out: + raise ConsoleError("user tester missing or not in wheel:\n%s" % out) + out = sh(con, "test -f /etc/sudoers.d/10-wheel && echo SUDOERS-PRESENT") + if "SUDOERS-PRESENT" not in out: + raise ConsoleError("sudoers drop-in missing:\n%s" % out) + + # Test-only artifacts of the unattended appliance must NOT exist here. + out = sh(con, "test ! -e /etc/systemd/system/serial-getty@ttyS0.service.d/autologin.conf" + " && echo NO-AUTOLOGIN") + if "NO-AUTOLOGIN" not in out: + raise ConsoleError("autologin drop-in leaked onto an interactive target") + out = sh(con, "test ! -d /opt/silverblue && echo NO-TESTREPO") + if "NO-TESTREPO" not in out: + raise ConsoleError("local test repo leaked onto an interactive target") + print("[interactive-boot] user/network/no-test-artifacts checks passed") + + # Diagnostic only: GRUB installs rely on mkinitcpio's `microcode` hook embedding + # ucode into the initramfs (systemd-boot lists *-ucode.img explicitly). + out = sh(con, "grep ^HOOKS /etc/mkinitcpio.conf") + print("[interactive-boot] target mkinitcpio %s" % out.strip().splitlines()[-1] + if out.strip() else "[interactive-boot] no HOOKS line found") + + con.send("poweroff") + con.wait_exit(timeout=120) + return True + finally: + con.close() + + def main(): results = [] + if INTERACTIVE: + try: + snap = phase_interactive_install() + results.append(("interactive-install", True, "installed %s" % snap)) + except ConsoleError as e: + results.append(("interactive-install", False, str(e))) + return report(results) + try: + phase_interactive_boot(snap) + results.append(("interactive-boot", True, "ok")) + except ConsoleError as e: + results.append(("interactive-boot", False, str(e))) + return report(results) + try: snap = phase_install() results.append(("install", True, "installed %s" % snap)) diff --git a/tests/qemu/run.sh b/tests/qemu/run.sh index e067516..875bd1d 100755 --- a/tests/qemu/run.sh +++ b/tests/qemu/run.sh @@ -17,16 +17,19 @@ WORK="$ROOT_DIR/tests/qemu/work" NET=0 BOOTLOADER=systemd-boot +INTERACTIVE=0 DISK_SIZE=${SB_DISK_SIZE:-12G} usage() { cat <<'EOF' -Usage: tests/qemu/run.sh [--net] [--bootloader systemd-boot|grub] +Usage: tests/qemu/run.sh [--net] [--bootloader systemd-boot|grub] [--interactive] --net Update cycle does a real `pacman -Syu` over QEMU user-net (default: a hermetic offline upgrade against the ISO's synthetic local repo). --bootloader Bootloader to install on the target and drive through the full install/update/rollback cycle: systemd-boot | grub (default: systemd-boot). + --interactive Drive the interactive installer over the serial console instead of the + unattended autoinstaller, then boot and verify the installed system. -h, --help Show this help. Environment overrides: SB_OVMF_CODE, SB_OVMF_VARS (firmware), SB_DISK_SIZE. @@ -37,6 +40,7 @@ while (( $# )); do case "$1" in --net) NET=1 ;; --bootloader) BOOTLOADER=${2:?}; shift ;; + --interactive) INTERACTIVE=1 ;; -h|--help) usage; exit 0 ;; *) echo "unknown argument: $1 (try --help)" >&2; exit 2 ;; esac @@ -104,7 +108,8 @@ chmod u+w "$WORK/OVMF_VARS.fd" export SB_ISO="$ISO" SB_DISK="$DISK" export SB_FW_CODE="$FW_CODE" SB_FW_VARS="$WORK/OVMF_VARS.fd" export SB_ACCEL="$ACCEL" SB_CPU="$CPU" SB_NET="$NET" SB_BOOTLOADER="$BOOTLOADER" +export SB_INTERACTIVE="$INTERACTIVE" export SB_WORK="$WORK" -echo "==> launching harness (net=$NET bootloader=$BOOTLOADER)" +echo "==> launching harness (net=$NET bootloader=$BOOTLOADER interactive=$INTERACTIVE)" exec python3 "$ROOT_DIR/tests/qemu/harness.py" diff --git a/tests/unit/helper.bash b/tests/unit/helper.bash index 0ffeadc..71adf9b 100644 --- a/tests/unit/helper.bash +++ b/tests/unit/helper.bash @@ -11,3 +11,8 @@ load_engine() { # shellcheck source=/dev/null source "$SB_REPO/src/update-engine/silverblue-update" } + +load_installer_lib() { + # shellcheck source=/dev/null + source "$SB_REPO/src/installer/install-lib.sh" +} diff --git a/tests/unit/test_installer.bats b/tests/unit/test_installer.bats new file mode 100644 index 0000000..d24bc11 --- /dev/null +++ b/tests/unit/test_installer.bats @@ -0,0 +1,119 @@ +#!/usr/bin/env bats +# Pure helpers of the shared installer library (install-lib.sh). + +load helper + +setup() { load_installer_lib; } + +@test "esc escapes sed replacement metacharacters" { + run esc 'a|b&c\d' + [ "$output" = 'a\|b\&c\\d' ] +} + +@test "validate_hostname accepts sane names" { + validate_hostname "silverblue" + validate_hostname "my-box2" + validate_hostname "A1" +} + +@test "validate_hostname rejects bad names" { + run validate_hostname "-bad" + [ "$status" -ne 0 ] + run validate_hostname "bad-" + [ "$status" -ne 0 ] + run validate_hostname "with space" + [ "$status" -ne 0 ] + run validate_hostname "" + [ "$status" -ne 0 ] +} + +@test "validate_username accepts sane names" { + validate_username "tester" + validate_username "_svc" + validate_username "user-1" +} + +@test "validate_username rejects bad names" { + run validate_username "Tester" + [ "$status" -ne 0 ] + run validate_username "1user" + [ "$status" -ne 0 ] + run validate_username "" + [ "$status" -ne 0 ] +} + +@test "detect_microcode identifies Intel" { + run detect_microcode "$(printf 'processor : 0\nvendor_id : GenuineIntel\n')" + [ "$output" = "intel-ucode" ] +} + +@test "detect_microcode identifies AMD" { + run detect_microcode "$(printf 'processor : 0\nvendor_id : AuthenticAMD\n')" + [ "$output" = "amd-ucode" ] +} + +@test "detect_microcode is silent on unknown vendors" { + run detect_microcode "$(printf 'processor : 0\nvendor_id : CyrixInstead\n')" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "filter_disks keeps real disks and drops live medium, rom, loop, zram" { + local listing='PATH="/dev/vda" TYPE="disk" SIZE="12G" MODEL="" +PATH="/dev/nvme0n1" TYPE="disk" SIZE="931.5G" MODEL="Samsung SSD 980" +PATH="/dev/sda" TYPE="disk" SIZE="14.9G" MODEL="USB Flash" +PATH="/dev/sr0" TYPE="rom" SIZE="800M" MODEL="QEMU DVD-ROM" +PATH="/dev/loop0" TYPE="loop" SIZE="700M" MODEL="" +PATH="/dev/zram0" TYPE="disk" SIZE="4G" MODEL=""' + run filter_disks "$listing" "/dev/sda" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 2 ] + [ "${lines[0]}" = "/dev/vda|12G|" ] + [ "${lines[1]}" = "/dev/nvme0n1|931.5G|Samsung SSD 980" ] +} + +@test "filter_disks with no live disk keeps everything installable" { + local listing='PATH="/dev/sda" TYPE="disk" SIZE="14.9G" MODEL="USB Flash"' + run filter_disks "$listing" "" + [ "${lines[0]}" = "/dev/sda|14.9G|USB Flash" ] +} + +@test "partition_path appends the number for letter-named disks" { + run partition_path /dev/vda 1 + [ "$output" = "/dev/vda1" ] + run partition_path /dev/sda 2 + [ "$output" = "/dev/sda2" ] +} + +@test "partition_path inserts p for digit-named disks" { + run partition_path /dev/nvme0n1 1 + [ "$output" = "/dev/nvme0n1p1" ] + run partition_path /dev/mmcblk0 2 + [ "$output" = "/dev/mmcblk0p2" ] +} + +@test "console_opts_for_tty carries a serial console" { + run console_opts_for_tty /dev/ttyS0 + [ "$output" = "console=ttyS0,115200 console=tty0" ] +} + +@test "console_opts_for_tty is empty for virtual terminals" { + run console_opts_for_tty /dev/tty1 + [ "$status" -eq 0 ] + [ -z "$output" ] + run console_opts_for_tty "not a tty" + [ -z "$output" ] +} + +@test "render_summary reflects the chosen answers" { + SB_INST_DISK=/dev/vda + SB_INST_HOSTNAME=sbtest + SB_INST_BOOTLOADER=grub + SB_INST_NETWORK=networkd + run render_summary + [ "$status" -eq 0 ] + [[ "$output" == *"/dev/vda"* ]] + [[ "$output" == *"sbtest"* ]] + [[ "$output" == *"grub"* ]] + [[ "$output" == *"networkd"* ]] +} diff --git a/tests/unit/test_installer_prompts.bats b/tests/unit/test_installer_prompts.bats new file mode 100644 index 0000000..11e687e --- /dev/null +++ b/tests/unit/test_installer_prompts.bats @@ -0,0 +1,94 @@ +#!/usr/bin/env bats +# Prompt helpers of the shared installer library: stdin-driven, result on stdout, +# prompt text on stderr, SILVERBLUE-INSTALL-PROMPT markers behind SB_INSTALL_MARKERS=1. + +load helper + +setup() { load_installer_lib; } + +@test "ask returns the typed value" { + result=$(ask hostname "Hostname" "fallback" 2>/dev/null <<< "mybox") + [ "$result" = "mybox" ] +} + +@test "ask returns the default on empty input" { + result=$(ask hostname "Hostname" "fallback" 2>/dev/null <<< "") + [ "$result" = "fallback" ] +} + +@test "ask re-prompts until the validator accepts" { + result=$(ask hostname "Hostname" "" validate_hostname 2>/dev/null <<< $'-bad-\ngood-name') + [ "$result" = "good-name" ] +} + +@test "ask fails on end of input" { + run ask hostname "Hostname" "" validate_hostname <<< "-bad-" + [ "$status" -ne 0 ] +} + +@test "ask_yesno takes the default on empty input" { + run ask_yesno ok "OK?" y <<< "" + [ "$status" -eq 0 ] + run ask_yesno ok "OK?" n <<< "" + [ "$status" -eq 1 ] +} + +@test "ask_yesno accepts explicit answers" { + run ask_yesno ok "OK?" y <<< "n" + [ "$status" -eq 1 ] + run ask_yesno ok "OK?" n <<< "yes" + [ "$status" -eq 0 ] +} + +@test "ask_yesno re-prompts on garbage" { + run ask_yesno ok "OK?" n <<< $'maybe\ny' + [ "$status" -eq 0 ] +} + +@test "choose maps a number to its item" { + result=$(choose bootloader "Bootloader:" "systemd-boot" systemd-boot grub 2>/dev/null <<< "2") + [ "$result" = "grub" ] +} + +@test "choose returns the default on empty input" { + result=$(choose bootloader "Bootloader:" "systemd-boot" systemd-boot grub 2>/dev/null <<< "") + [ "$result" = "systemd-boot" ] +} + +@test "choose re-prompts on an out-of-range selection" { + result=$(choose pick "Pick:" "a" a b 2>/dev/null <<< $'9\n1') + [ "$result" = "a" ] +} + +@test "ask_secret accepts a matching pair" { + result=$(ask_secret root-password "Root password" 2>/dev/null <<< $'secret\nsecret') + [ "$result" = "secret" ] +} + +@test "ask_secret loops on mismatch, then accepts" { + result=$(ask_secret root-password "Root password" 2>/dev/null <<< $'one\ntwo\nsecret\nsecret') + [ "$result" = "secret" ] +} + +@test "ask_secret rejects empty passwords" { + result=$(ask_secret root-password "Root password" 2>/dev/null <<< $'\n\nsecret\nsecret') + [ "$result" = "secret" ] +} + +@test "prompt markers are emitted when SB_INSTALL_MARKERS=1" { + SB_INSTALL_MARKERS=1 + run ask hostname "Hostname" "x" <<< "" + [[ "$output" == *"SILVERBLUE-INSTALL-PROMPT key=hostname"* ]] +} + +@test "ask_secret emits both key and key-confirm markers" { + SB_INSTALL_MARKERS=1 + run ask_secret root-password "Root password" <<< $'secret\nsecret' + [[ "$output" == *"SILVERBLUE-INSTALL-PROMPT key=root-password"* ]] + [[ "$output" == *"SILVERBLUE-INSTALL-PROMPT key=root-password-confirm"* ]] +} + +@test "prompt markers are silent by default" { + run ask hostname "Hostname" "x" <<< "" + [[ "$output" != *"SILVERBLUE-INSTALL-PROMPT"* ]] +} diff --git a/tests/unit/test_render_entries.bats b/tests/unit/test_render_entries.bats index ba06e3f..9e816bd 100644 --- a/tests/unit/test_render_entries.bats +++ b/tests/unit/test_render_entries.bats @@ -52,6 +52,15 @@ teardown() { rm -rf "$TMP"; } [[ "$output" == *"search --no-floppy --fs-uuid --set=root UUID1"* ]] } +@test "grub_render_header arms recordfail with a finite timeout" { + run grub_render_header 5 + # recordfail must never hold the menu forever — an unattended machine has to keep + # booting the default (the staged root, or saved_entry after a failed try). + [[ "$output" != *"timeout=-1"* ]] + [[ "$output" == *"set recordfail=1"* ]] + [[ "$output" == *'set default="${next_entry}"'* ]] +} + @test "grub_set_next writes next_entry via the grub-editenv shim" { export SB_MOCK_LOG="$TMP/mock.log" GRUB_EDITENV="$SB_REPO/tests/unit/mocks/grub-editenv" diff --git a/website/.gitignore b/website/.gitignore index 2cfefeb..1f54f72 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -5,6 +5,7 @@ dist/ # generated by scripts/sync-docs.mjs from repo-root docs — do not commit or edit: src/content/docs/architecture/update-flow.md +src/content/docs/guides/installing.md src/content/docs/guides/deriving.md src/content/docs/project/contributing.md src/content/docs/project/security.md diff --git a/website/astro.config.mjs b/website/astro.config.mjs index bac9c7b..e98ad8e 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -41,7 +41,10 @@ export default defineConfig({ }, { label: 'Guides', - items: [{ label: 'Derive Your Own Distro', slug: 'guides/deriving' }], + items: [ + { label: 'Install on Real Hardware', slug: 'guides/installing' }, + { label: 'Derive Your Own Distro', slug: 'guides/deriving' }, + ], }, { label: 'Project', diff --git a/website/scripts/sync-docs.mjs b/website/scripts/sync-docs.mjs index 775831b..f2f4c5a 100644 --- a/website/scripts/sync-docs.mjs +++ b/website/scripts/sync-docs.mjs @@ -34,6 +34,14 @@ const MANIFEST = [ description: 'On-disk layout, the seven-step atomic update flow, and the three mechanisms that make auto-rollback actually trigger.', }, + { + source: 'docs/installing.md', + dest: 'guides/installing.md', + route: '/guides/installing/', + title: 'Install on Real Hardware', + description: + 'Download the ISO, boot it in UEFI mode, and run the minimal plain-prompt installer.', + }, { source: 'DERIVING.md', dest: 'guides/deriving.md', diff --git a/website/src/content/docs/comparison.md b/website/src/content/docs/comparison.md index d6898c9..b7faf6e 100644 --- a/website/src/content/docs/comparison.md +++ b/website/src/content/docs/comparison.md @@ -10,7 +10,7 @@ Silverblue gets it from Btrfs copy-on-write snapshots alone. | Project | Update mechanism | Running root writable? | Atomic updates | Automatic health-check rollback | Maturity | | --- | --- | --- | --- | --- | --- | -| **Arch Silverblue** | `pacman -Syu` inside a Btrfs snapshot clone | **Yes** — plain Arch | Yes | **Yes** — health check + watchdog + boot counting | Experimental, pre-release | +| **Arch Silverblue** | `pacman -Syu` inside a Btrfs snapshot clone | **Yes** — plain Arch | Yes | **Yes** — health check + watchdog + boot counting | Experimental | | Fedora Silverblue / Atomic Desktops | rpm-ostree image deployments | No (read-only `/usr`; package layering) | Yes | No by default — previous deployment selectable at boot | Mature | | openSUSE MicroOS / Aeon | `transactional-update` into a new Btrfs snapshot | No (read-only root) | Yes | Optional, via `health-checker` | Mature | | NixOS | Declarative rebuild producing a new generation | Mostly (`/nix/store` is read-only; system is config-defined) | Yes | No by default — previous generation selectable at boot | Mature | @@ -56,5 +56,7 @@ evolve. Corrections are welcome via ::: Also worth honest emphasis: every other project in this table is **far more -mature**. Arch Silverblue is experimental and pre-release — see the -[FAQ](/faq/) and [Getting Started](/getting-started/) for what works today. +mature**. Arch Silverblue is experimental — releases exist and it installs on +real hardware, but expect rough edges. See the [FAQ](/faq/), +[Getting Started](/getting-started/), and +[Install on Real Hardware](/guides/installing/) for what works today. diff --git a/website/src/content/docs/faq.md b/website/src/content/docs/faq.md index fc9155f..c53e0d5 100644 --- a/website/src/content/docs/faq.md +++ b/website/src/content/docs/faq.md @@ -47,16 +47,20 @@ neighbors. At most three are kept, pruned automatically. The ESP needs to be ## Can I install it on real hardware today? -Not comfortably. There is no end-user installer — the bundled autoinstaller is -a QEMU-gated test appliance. Advanced users can replicate the -[on-disk layout](/architecture/update-flow/#on-disk-layout) manually, but the -project currently targets development and testing in VMs. See -[Getting Started](/getting-started/). +Yes. The ISO ships `silverblue-install`, a **minimal plain-prompt installer**: +disk selection (type `ERASE` to confirm), hostname/timezone/locale/keymap, +bootloader choice, CPU microcode and `linux-firmware`, an optional network +stack (`systemd-networkd` or NetworkManager), a root password, and an optional +sudo-capable admin user. UEFI only, no disk encryption, no GUI — see +[Install on Real Hardware](/guides/installing/). The project is still young; +expect rough edges. ## Where do I download an ISO? -There are no releases yet. ISOs are built locally with `make build-iso` or -produced as CI artifacts on pushes to `main`. +From [GitHub Releases](https://github.com/sinisterMage/Arch-silverblue/releases/latest) +(published on version tags, with `SHA256SUMS` for verification — run +`sha256sum -c SHA256SUMS`). ISOs are also built locally with `make build-iso` +or produced as CI artifacts on pushes to `main`. ## Which bootloaders are supported? @@ -75,8 +79,8 @@ not implemented. ## What about Secure Boot? -Out of scope for now, along with OTA/delta updates, custom package signing, -PXE, and any immutable-root enforcement. +Out of scope for now, along with LUKS/disk encryption, swap setup, OTA/delta +updates, custom package signing, PXE, and any immutable-root enforcement. ## How is this different from snapper or Timeshift with grub-btrfs? diff --git a/website/src/content/docs/getting-started.mdx b/website/src/content/docs/getting-started.mdx index 44ae50c..91df0ff 100644 --- a/website/src/content/docs/getting-started.mdx +++ b/website/src/content/docs/getting-started.mdx @@ -3,10 +3,11 @@ title: Getting Started description: Build the Arch Silverblue ISO, test it in QEMU, and use the atomic update CLI on an installed system. --- -Today, Arch Silverblue is something you **build and test**, not something you -download and install on your laptop. There are no releases yet; the ISO is -built locally in Docker and exercised in QEMU. This page walks that path, then -covers the `silverblue-update` CLI you get on an installed system. +There are two ways in: **install it on real hardware** from a released ISO +using the minimal interactive installer — see +[Install on Real Hardware](/guides/installing/) — or **build and test it +yourself**. This page walks the build-and-test path, then covers the +`silverblue-update` CLI you get on an installed system. ## Prerequisites @@ -60,15 +61,18 @@ onto a virtual disk via the test autoinstaller, and then asserts both paths: The update cycle is hermetic by default (a synthetic bumped package in an offline repo baked into the ISO); pass `--net` to `tests/qemu/run.sh` for a real `pacman -Syu` instead, or `--bootloader grub` to drive GRUB. - -:::caution[Real hardware] -There is **no end-user installer**. The bundled autoinstaller is a test -appliance: it is gated behind a QEMU `fw_cfg` flag (so it never fires on a -normal boot) and it **wipes `/dev/vda`** unconditionally. Advanced users can -replicate the on-disk layout by hand — see the -[on-disk layout](/architecture/update-flow/#on-disk-layout) — but be aware the -default package set omits `linux-firmware` (VM-oriented); add it to -`PKGS_BASE` in `config/distro.conf` for real hardware. +`make test-qemu-interactive` runs a third scenario that drives the +**interactive installer** end-to-end over the serial console and verifies the +installed system. + +:::note[Real hardware] +To install on a physical machine, follow +[Install on Real Hardware](/guides/installing/) — download a released ISO, +boot it in UEFI mode, and run `silverblue-install`. The default `PKGS_BASE` +package set omits `linux-firmware` (VM-oriented), so accept the installer's +default-on `linux-firmware` prompt on real machines. The *unattended* +autoinstaller in the ISO remains a QEMU-only test appliance: it is gated +behind a `fw_cfg` flag and never fires on a normal boot. ::: ## Using an installed system diff --git a/website/src/content/docs/index.mdx b/website/src/content/docs/index.mdx index ef66e19..b8f39c4 100644 --- a/website/src/content/docs/index.mdx +++ b/website/src/content/docs/index.mdx @@ -11,6 +11,10 @@ hero: - text: Get Started link: /getting-started/ icon: right-arrow + - text: Download ISO + link: https://github.com/sinisterMage/Arch-silverblue/releases/latest + icon: download + variant: minimal - text: How it works link: /architecture/update-flow/ variant: minimal @@ -96,9 +100,11 @@ Read the full flow, the on-disk layout, and the rollback-trigger table in [Update & Rollback Flow](/architecture/update-flow/). :::caution[Project status] -Arch Silverblue is a **young, experimental project**. There are no releases or -stable ISO downloads yet — ISOs are built locally (`make build-iso`) or as CI -artifacts. The bundled installer is a test appliance for QEMU only and never -runs on real hardware. If the approach interests you, star the project on +Arch Silverblue is a **young, experimental project**. ISOs (with `SHA256SUMS`) +are published on [GitHub Releases](https://github.com/sinisterMage/Arch-silverblue/releases/latest) +and include a **minimal plain-prompt installer** for real hardware — see +[Install on Real Hardware](/guides/installing/). Expect rough edges; treat it +as something to try, not yet something to rely on. If the approach interests +you, star the project on [GitHub](https://github.com/sinisterMage/Arch-silverblue) and follow along. :::