diff --git a/README.md b/README.md index 120e01c..4e46c45 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,22 @@ git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama ~/.local/share/Panama/install ``` -`install` runs everything in `setup/scripts/` in order: +`install` asks its questions first and then runs the stages in `setup/scripts/` +in order, without stopping again: | Script | Does | |---|---| -| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` | -| `link-dotfiles` | Symlinks `config/dot/` → `~/.config/` | +| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path | +| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the package lists in `setup/packages/` | +| `link-dotfiles` | Symlinks `config/dot/` → `~/.config/`, and seeds the wallpaper and cursor theme | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | +| `link-vicinae-scripts` | Publishes the Vicinae script commands | +| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for | +| `install-hardware` | NVIDIA, Secure Boot enrolment, Fedora's extras, firmware — each only if it was asked for. Last, because enrolment and firmware are consumed at the next boot | + +The run ends with a health summary from `panama-doctor`, which reports what is +actually running rather than what was attempted. It never fails the install: on a +fresh machine it legitimately reports things as not yet configured. Existing configs are moved to `config/old/` rather than overwritten. diff --git a/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md b/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md index eb0a409..550de76 100644 --- a/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md +++ b/docs/superpowers/specs/2026-08-20-panama-fresh-install-design.md @@ -156,24 +156,39 @@ way. The new order: | Stage | Status | Responsibility | |---|---|---| | `interview` | new | Every prompt, before any work | -| `install-packages` | edited | Repos, package lists, extras, debloat | -| `install-cursors` | new | Vendored cursor theme into `~/.local/share/icons` | -| `install-hardware` | new | NVIDIA, akmods, secure boot, firmware — all conditional | -| `link-dotfiles` | edited | Adds the wallpaper; drops `forge` | +| `install-packages` | edited | Repos, package lists, extras | +| `link-dotfiles` | edited | Adds the wallpaper and the cursor theme; drops `forge` | | `change-settings` | edited | Unchanged in substance | | `link-vicinae-scripts` | unchanged | | | `setup-identity` | new | git config, `gh auth login`, SSH key | +| `install-hardware` | new | NVIDIA, akmods, secure boot, debloat, firmware — all conditional | + +Two things in this table changed while building it, and the reasons are worth +keeping. + +There is no `install-cursors`. The cursor theme is vendored into the repository +and exposed by the same narrow per-directory symlink `link-dotfiles` already uses +for icons and Vicinae themes, so it needs no stage of its own — and because +`link-dotfiles` precedes `change-settings`, the ordering constraint that stage +was invented to satisfy holds anyway. + +`install-hardware` runs *last*, not fourth. The constraint was always "late", and +fourth of eight is not late: MOK enrolment arms a prompt consumed at the next +boot and a firmware update may ask for a reboot, so a machine that reboots out of +this stage should already be completely configured — dotfiles linked, settings +applied, identity set. Placing it after `setup-identity` costs nothing, since +nothing later depends on it. Ordering constraints that are not obvious and must not be broken: -- `install-cursors` precedes `change-settings`, because `change-settings` sets a +- `link-dotfiles` precedes `change-settings`, because `change-settings` sets a gsettings value naming a cursor theme. Setting it before the theme exists is not an error, but it does mean the first session renders with a fallback. Fonts are installed by `install-packages`, which already runs first. - `setup-identity` follows `install-packages`, because `gh` is installed there. -- `install-hardware` runs late; MOK enrollment sets a password consumed at the next - boot, and firmware updates may prompt for a reboot. Neither should sit in front of - twenty minutes of package work. +- `install-hardware` is last, for the reason above. It also has to follow its own + driver install: akmods generates the certificate that MOK enrolment enrols, so + there is nothing to enrol until `akmod-nvidia` is on the machine. Every stage stays independently re-runnable and independently failable, which is the existing contract and the reason Panama exists. @@ -297,12 +312,30 @@ hostname and it was one of the things that made sunhat unmaintainable. Everything below blocks sunhat's deletion. -- **NVIDIA and secure boot** — `akmod-nvidia`, `xorg-x11-drv-nvidia-cuda`, MOK - enrollment via `mokutil --import`, and the `rebuild_nvidia_kmod` helper. sunhat's - version opened an editor to hand-edit grub; the replacement does not, because a - step that requires a human mid-run is the failure this repository exists to avoid. -- **Fedora debloat** — removing `gnome-contacts`, `gnome-tour`, `gnome-maps`, - `totem` and the LibreOffice components, behind an interview question. +- **NVIDIA and secure boot** — `akmod-nvidia`, `xorg-x11-drv-nvidia-cuda` and MOK + enrolment via `mokutil --import`. sunhat's version opened an editor to hand-edit + grub; the replacement does not, because a step that requires a human mid-run is + the failure this repository exists to avoid. It turned out not to need one: + sunhat's editor step existed to delete duplicated kernel arguments, and `grubby + --update-kernel=ALL --args=` replaces an argument that already exists rather than + appending a second copy, so the duplicates cannot accumulate. + + Enrolment is likewise non-interactive, and did not need a compromise either. + `mokutil --generate-hash` and `--hash-file` are exactly the pair for this: the + interview asks for the password, hashes it immediately, and records only the + hash — so the plaintext never reaches the answers file, the environment, or a + command line, and the stage still runs without prompting. + + `rebuild_nvidia_kmod` was not ported. akmods already rebuilds on kernel updates + through its own systemd unit, and a helper that duplicates that is one more + thing to keep true. +- **Fedora debloat** — behind an interview question. sunhat's list was + `gnome-contacts`, `gnome-tour`, `gnome-maps`, `totem` and three LibreOffice + components; on Fedora 44 `totem` has been replaced by `showtime` and LibreOffice + is not preinstalled at all, so the list is `gnome-contacts`, `gnome-tour`, + `gnome-maps` and `showtime`. A package that is not installed is never passed to + dnf, which is what lets the list outlive a Fedora release rather than starting to + fail on one. - **Firmware** — `fwupdmgr refresh` and `update`, behind an interview question. Panama's Updates page covers this after installation; the installer covers the first run. @@ -370,6 +403,13 @@ three places instead: - **The assets contract**, which encodes each gap found here as a test that fails on the broken tree. +- **The hardware contract**, for the one stage that cannot be verified by running + it. `install-hardware` installs a proprietary driver and queues a Secure Boot + enrolment, and the machine Panama is developed on is an AMD desktop; so every + privileged command is stood in on `PATH` and the contract asserts which answer + led to which call. That covers the decisions, which is the part that can be + wrong. It does not cover whether `akmod-nvidia` builds, and does not claim to — + the first real NVIDIA machine is still the first real test. - **`panama doctor` at the end of every install**, which reports what is actually running rather than what was attempted. - **`panama test`**, which makes the existing 119 contracts runnable in one command diff --git a/install b/install index 16f5ac3..dc01075 100755 --- a/install +++ b/install @@ -53,8 +53,11 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true # Explicit order, not glob order: change-settings runs `vicinae theme set`, # which needs both vicinae itself (installed by install-packages) and the # theme files it selects among (symlinked into place by link-dotfiles); -# setup-identity needs the gh and git-all that install-packages provides. New -# scripts must be added here explicitly, or they will not run at all. +# setup-identity needs the gh and git-all that install-packages provides; and +# install-hardware is last because MOK enrolment arms a prompt consumed at the +# next boot and a firmware update may ask for a reboot -- a machine that reboots +# out of the final stage has already been completely configured. New scripts +# must be added here explicitly, or they will not run at all. # The interview is not in that list, because it is the one stage whose output the # installer reads back -- and because declining it must stop everything rather @@ -73,7 +76,8 @@ fi # shellcheck source=/dev/null source "$PANAMA_ANSWERS" export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ - PANAMA_GH_LOGIN PANAMA_SSH_KEY + PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA PANAMA_MOK_HASH \ + PANAMA_DEBLOAT PANAMA_FIRMWARE # Applied here rather than in a stage, and applied early: it needs sudo, and # sudo is warm right now. At the end of a long unattended run the timestamp has @@ -84,7 +88,7 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then echo "Hostname set to: $(hostname)" fi -STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts setup-identity) +STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts setup-identity install-hardware) failed=() for stage in "${STAGES[@]}"; do script="$PANAMA_PATH/setup/scripts/$stage" diff --git a/setup/packages/desktop-packages b/setup/packages/desktop-packages index 978748a..ce9fd86 100644 --- a/setup/packages/desktop-packages +++ b/setup/packages/desktop-packages @@ -19,6 +19,10 @@ gnome-calendar # panels it deliberately does not own -- Online Accounts, Region, Colour, # Accessibility, Wellbeing, Wacom. See SystemSettings.qml's panel allow-list. gnome-control-center +# install-hardware sets the nouveau blacklist and nvidia-drm.modeset with +# grubby, which replaces an argument it already set rather than appending a +# second copy -- the reason that stage needs no editor. +grubby gstreamer1-plugin-fmp4 gstreamer1-plugin-gif gstreamer1-plugin-gtk4 diff --git a/setup/scripts/install-hardware b/setup/scripts/install-hardware new file mode 100755 index 0000000..b713511 --- /dev/null +++ b/setup/scripts/install-hardware @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +# The parts of an install that depend on what the machine actually is: the +# NVIDIA driver, the machine owner key that lets it load under Secure Boot, +# Fedora's preinstalled extras, and firmware. +# +# Runs last. MOK enrolment arms a prompt consumed at the next boot and firmware +# updates can ask for a reboot, so neither belongs in front of the package work +# or the dotfiles -- a machine that reboots out of this stage has already been +# fully configured. +# +# Nothing here decides anything. Every branch is an answer the interview +# collected before the run began, and an absent answer means no, which is what +# makes this safe to re-run by hand while repairing one piece of a machine. +# +# sunhat's version of this opened an editor in the middle of the run so grub +# could be hand-corrected. That is the exact failure this repository exists to +# avoid, and it is unnecessary: grubby replaces an argument that already exists +# rather than appending a second copy, so the duplicates that had to be cleaned +# up by hand cannot accumulate in the first place. + +set -uo pipefail + +log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } +warn() { echo -e "\033[1;33m[WARN]\033[0m $*" >&2; } + +# Fedora ships these and Panama uses none of them. Named here rather than in the +# interview so there is one list: the interview asks for it with --debloat-list +# to name what it is about to remove, and this stage removes it. A package that +# is not installed is skipped rather than passed to dnf, so the list can outlive +# a Fedora release -- totem left in Fedora 43 and the list should not start +# failing because of it. +DEBLOAT=(gnome-contacts gnome-tour gnome-maps showtime) + +if [[ "${1:-}" == "--debloat-list" ]]; then + printf '%s\n' "${DEBLOAT[@]}" + exit 0 +fi + +# ── NVIDIA ─────────────────────────────────────────────────────────────────── + +if [[ "${PANAMA_NVIDIA:-no}" == yes ]]; then + log "Installing the NVIDIA driver" + if sudo dnf install -y akmod-nvidia xorg-x11-drv-nvidia-cuda; then + # nouveau has to be out of the way before the kernel would otherwise + # bind it, which is why these are kernel arguments and not a modprobe + # drop-in. modeset=1 is what makes the Wayland session work at all. + if command -v grubby >/dev/null 2>&1; then + sudo grubby --update-kernel=ALL \ + --args="rd.driver.blacklist=nouveau modprobe.blacklist=nouveau nvidia-drm.modeset=1" + log "Kernel arguments set for every installed kernel" + else + warn "grubby is not installed; nouveau was not blacklisted" + fi + + # Suspend and resume are where a proprietary driver most visibly fails. + # These units save and restore VRAM across it. + sudo systemctl enable nvidia-hibernate.service nvidia-suspend.service \ + nvidia-resume.service nvidia-powerd.service + log "NVIDIA power management services enabled" + else + warn "The NVIDIA driver did not install; skipping its kernel arguments and services" + fi +fi + +# ── Secure Boot ────────────────────────────────────────────────────────────── +# +# akmods signs the modules it builds with a key it generates on installation. +# Under Secure Boot that key means nothing until it is enrolled, and enrolment +# is deliberately a thing only somebody at the physical machine can complete: +# the request is queued here, and the next boot shows a blue screen asking for +# the password before it will trust the key. +# +# The password itself never reaches this stage. The interview hashed it and +# recorded the hash, so nothing readable is passed on a command line or left in +# this process's environment -- which is the whole reason mokutil has +# --generate-hash and --hash-file. + +mok_hash="${PANAMA_MOK_HASH:-}" +if [[ -n "$mok_hash" ]]; then + # Overridable so the contract can exercise this against a certificate it is + # allowed to create. Nothing else sets it. + cert="${PANAMA_MOK_CERT:-/etc/pki/akmods/certs/public_key.der}" + + if [[ ! -r "$cert" ]]; then + warn "No akmods certificate at $cert, so there is no key to enrol" + elif mokutil --test-key "$cert" 2>/dev/null | grep -q 'already enrolled'; then + log "The akmods key is already enrolled" + else + hash_file="$(mktemp -t panama-mok.XXXXXX)" + chmod 600 "$hash_file" + printf '%s\n' "$mok_hash" >"$hash_file" + if sudo mokutil --import "$cert" --hash-file "$hash_file"; then + log "Key enrolment requested" + log "At the next boot, choose 'Enrol MOK' and enter the password you gave the installer" + else + warn "Key enrolment failed; the NVIDIA module will not load until it is enrolled" + fi + rm -f "$hash_file" + fi +fi + +# ── Fedora's preinstalled extras ───────────────────────────────────────────── + +if [[ "${PANAMA_DEBLOAT:-no}" == yes ]]; then + present=() + for package in "${DEBLOAT[@]}"; do + rpm -q "$package" >/dev/null 2>&1 && present+=("$package") + done + + if (( ${#present[@]} > 0 )); then + log "Removing ${present[*]}" + sudo dnf remove -y "${present[@]}" >/dev/null \ + || warn "Some packages could not be removed" + else + log "None of Fedora's extras are installed" + fi +fi + +# ── Firmware ───────────────────────────────────────────────────────────────── +# +# Panama's Updates page covers this from then on; the installer covers the +# first run. --no-reboot-check because deciding to reboot is not this stage's +# call to make in the middle of an unattended install. + +if [[ "${PANAMA_FIRMWARE:-no}" == yes ]]; then + if command -v fwupdmgr >/dev/null 2>&1; then + log "Refreshing firmware metadata" + sudo fwupdmgr refresh --force >/dev/null 2>&1 + log "Applying firmware updates" + sudo fwupdmgr update -y --no-reboot-check \ + || log "No firmware updates were applied" + else + warn "fwupdmgr is not installed; skipping firmware" + fi +fi diff --git a/setup/scripts/interview b/setup/scripts/interview index 8ab950d..3458fdc 100755 --- a/setup/scripts/interview +++ b/setup/scripts/interview @@ -15,9 +15,14 @@ # somebody else could clone. Re-answering a handful of questions costs less than # maintaining an answers file that drifts out of date. # -# This asks only what a stage in this repository actually consumes. Extras, -# hardware and debloat questions arrive with the stages that act on them; a prompt -# whose answer nothing reads is a control that lies. +# This asks only what a stage in this repository actually consumes. The extras +# checklist arrives with the stage that acts on it; a prompt whose answer nothing +# reads is a control that lies. +# +# The hardware questions name what was found rather than asking a person to +# recite their own machine, and they are not asked at all on a machine they +# would do nothing to. Detection alone would be worse: it would remove the +# ability to decline a proprietary driver on a machine that has the card. set -uo pipefail @@ -84,6 +89,77 @@ elif yes_no "Generate an SSH key?"; then fi record PANAMA_SSH_KEY "$ssh_key" +# ── Hardware ───────────────────────────────────────────────────────────────── +# +# Each question names what was detected, so declining is a decision about this +# machine rather than an answer to a hypothetical. A machine with no NVIDIA card +# is never asked about drivers, and one with nothing to remove is never asked +# about removing it. + +heading "Hardware" + +nvidia=no +mok_hash="" +nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)" + +if [[ -n "$nvidia_card" ]]; then + if yes_no "Found $nvidia_card — install the NVIDIA driver?"; then + nvidia=yes + + # Only asked where it does something. On a machine with Secure Boot off, + # akmods' signature is never checked and enrolling a key is ceremony. + if mokutil --sb-state 2>/dev/null | grep -qi 'secureboot enabled'; then + printf 'Secure Boot is on, so the driver must be signed with a key you enrol.\n' + printf 'The next boot will ask for this password on a blue screen.\n' + if yes_no "Enrol a machine owner key?"; then + # Hashed here and only the hash recorded. The password never + # reaches the answers file, the environment, or a command line + # -- mokutil takes a hash file precisely so it does not have to. + while :; do + first="$(gum input --password --header "MOK password")" + if [[ -z "$first" ]]; then + printf 'No password given; skipping enrolment.\n' + break + fi + second="$(gum input --password --header "MOK password again")" + if [[ "$first" == "$second" ]]; then + mok_hash="$(mokutil --generate-hash="$first")" + break + fi + printf 'Those did not match.\n' + done + unset first second + fi + fi + fi +else + printf 'No NVIDIA card found.\n' +fi +record PANAMA_NVIDIA "$nvidia" +record PANAMA_MOK_HASH "$mok_hash" + +# The stage that removes them owns the list, so there is one copy of it. +debloat=no +mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list) +installed=() +for package in "${removable[@]}"; do + rpm -q "$package" >/dev/null 2>&1 && installed+=("$package") +done +if (( ${#installed[@]} > 0 )); then + if yes_no "Remove Fedora's preinstalled extras (${installed[*]})?"; then + debloat=yes + fi +fi +record PANAMA_DEBLOAT "$debloat" + +firmware=no +if command -v fwupdmgr >/dev/null 2>&1; then + if yes_no "Update firmware with fwupdmgr?"; then + firmware=yes + fi +fi +record PANAMA_FIRMWARE "$firmware" + # ── Confirm ────────────────────────────────────────────────────────────────── # # The last chance to catch a typo before twenty minutes of package work that @@ -98,7 +174,11 @@ gum style --border rounded --padding "0 1" "$( printf 'Git email %s\n' "$(shown "$git_email")" printf 'Git editor %s\n' "$(shown "$git_editor")" printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")" - printf 'SSH key %s' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")" + printf 'SSH key %s\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")" + printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")" + printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enrol a key" || echo "no change")" + printf 'Extras %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")" + printf 'Firmware %s' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")" )" if ! gum confirm --default=true "Install with these answers?"; then diff --git a/tests/setup/hardware-contract b/tests/setup/hardware-contract new file mode 100755 index 0000000..1bde136 --- /dev/null +++ b/tests/setup/hardware-contract @@ -0,0 +1,199 @@ +#!/usr/bin/env bash + +# What install-hardware does, and — more importantly — what it does not. +# +# This stage cannot be verified the way the rest of Panama is. It installs a +# proprietary driver, rewrites kernel arguments and queues a Secure Boot +# enrolment, and the machine it was written on is an AMD desktop with no NVIDIA +# card in it. Running it to see what happens is not available. +# +# So every privileged command it can reach is stood in on PATH, and the contract +# asserts what was called with what. That verifies the decisions — which answer +# leads to which command — which is the part that can be wrong. It does not +# verify that akmod-nvidia builds, and nothing here should be read as claiming it +# does. +# +# The properties worth pinning: +# +# 1. Absent answers do nothing at all. Every stage in this repository is +# independently re-runnable while repairing one piece of a machine, and a +# hardware stage that acted on its own defaults would be the one that +# installed a driver nobody asked for. +# 2. The MOK password never appears in a command line or an environment. It is +# hashed in the interview and reaches mokutil through a file, which is what +# --generate-hash and --hash-file exist for. +# 3. Nothing opens an editor. sunhat stopped in the middle of a run so grub +# could be hand-corrected, and that single step is why walking away from an +# install did not work. +# 4. Removal is offered only for packages that are actually installed, and the +# list has exactly one home. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +stage="$repo_dir/setup/scripts/install-hardware" +interview="$repo_dir/setup/scripts/interview" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$stage" ]] || { printf 'hardware contract: %s is not executable\n' "$stage" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# ── The stand-ins ──────────────────────────────────────────────────────────── +# +# Each records its own name and arguments and succeeds. `sudo` records the +# command it was asked to run and then runs it through the same stubs, so a +# privileged call is visible whether or not it went through sudo. + +stub_dir="$work/bin" +mkdir -p "$stub_dir" +calls="$work/calls" + +for command in dnf grubby systemctl mokutil fwupdmgr rpm; do + cat >"$stub_dir/$command" <>"\$PANAMA_CALLS" +exit \${STUB_${command^^}_STATUS:-0} +STUB + chmod +x "$stub_dir/$command" +done + +cat >"$stub_dir/sudo" <<'STUB' +#!/usr/bin/env bash +printf 'sudo %s\n' "$*" >>"$PANAMA_CALLS" +exec "$@" +STUB +chmod +x "$stub_dir/sudo" + +# `rpm -q` decides what debloat has to work with, so it answers for exactly one +# package. Overrides the recording stub for that one query. +cat >"$stub_dir/rpm" <<'STUB' +#!/usr/bin/env bash +printf 'rpm %s\n' "$*" >>"$PANAMA_CALLS" +if [[ "${1:-}" == "-q" ]]; then + [[ "${2:-}" == "${STUB_INSTALLED:-}" ]] && exit 0 + exit 1 +fi +exit 0 +STUB +chmod +x "$stub_dir/rpm" + +# Runs the stage with the given answers and returns everything it invoked. +run_stage() { + : >"$calls" + env -i HOME="$HOME" PATH="$stub_dir:/usr/bin:/bin" PANAMA_CALLS="$calls" \ + STUB_INSTALLED="${STUB_INSTALLED:-}" STUB_DNF_STATUS="${STUB_DNF_STATUS:-0}" \ + "$@" bash "$stage" >/dev/null 2>&1 + cat "$calls" +} + +called() { grep -q -- "$2" <<<"$1"; } + +# ── 1. Nothing asked for, nothing done ─────────────────────────────────────── + +quiet="$(run_stage)" +if [[ -n "$quiet" ]]; then + note "with no answers the stage still ran: $(head -1 <<<"$quiet")" +fi + +# ── The NVIDIA path ────────────────────────────────────────────────────────── + +nvidia="$(run_stage PANAMA_NVIDIA=yes)" + +called "$nvidia" 'dnf install -y akmod-nvidia' \ + || note 'answering yes to NVIDIA does not install akmod-nvidia' +called "$nvidia" 'xorg-x11-drv-nvidia-cuda' \ + || note 'the CUDA driver is not installed alongside the kernel module' +called "$nvidia" 'grubby --update-kernel=ALL' \ + || note 'the kernel arguments are never set' +called "$nvidia" 'modprobe.blacklist=nouveau' \ + || note 'nouveau is not blacklisted, so it can bind the card before nvidia does' +called "$nvidia" 'nvidia-drm.modeset=1' \ + || note 'nvidia-drm.modeset is not set, which a Wayland session needs' +called "$nvidia" 'systemctl enable nvidia-hibernate.service' \ + || note 'the suspend and resume services are never enabled' + +# A driver that failed to install must not be followed by arguments and services +# for a driver that is not there. +failed="$(STUB_DNF_STATUS=1 run_stage PANAMA_NVIDIA=yes)" +if called "$failed" 'grubby --update-kernel'; then + note 'kernel arguments are set even when the driver failed to install' +fi + +# ── 2. The MOK password stays out of sight ─────────────────────────────────── + +cert="$work/public_key.der" +printf 'not a real certificate\n' >"$cert" +hash='$6$notarealsalt$notarealhashvalue' + +mok="$(run_stage PANAMA_MOK_HASH="$hash" PANAMA_MOK_CERT="$cert")" + +called "$mok" 'mokutil --import' \ + || note 'a recorded MOK hash does not queue an enrolment' +called "$mok" -- '--hash-file' \ + || note 'the enrolment does not pass a hash file, so mokutil would prompt for a password' +if grep -qF -- "$hash" <<<"$mok"; then + note 'the MOK hash is passed on a command line where any process can read it' +fi + +# No certificate means akmods never generated a key. Requesting enrolment of a +# key that does not exist is worse than skipping: it queues a prompt at the next +# boot for nothing. +without_cert="$(run_stage PANAMA_MOK_HASH="$hash" PANAMA_MOK_CERT="$work/absent.der")" +if called "$without_cert" 'mokutil --import'; then + note 'enrolment is requested even with no akmods certificate to enrol' +fi + +# ── 4. Removal is honest about what it removes ─────────────────────────────── + +removable="$("$stage" --debloat-list)" +[[ -n "$removable" ]] || note '--debloat-list prints nothing, so the interview cannot name what it removes' + +# Exactly one of them is installed, so exactly one may be passed to dnf. +present="$(head -1 <<<"$removable")" +absent="$(tail -1 <<<"$removable")" +debloat="$(STUB_INSTALLED="$present" run_stage PANAMA_DEBLOAT=yes)" + +called "$debloat" "dnf remove -y $present" \ + || note "an installed package ($present) is not removed" +removal="$(grep 'dnf remove' <<<"$debloat")" +if grep -q -- "$absent" <<<"$removal"; then + note "a package that is not installed ($absent) is still passed to dnf remove" +fi + +# ── Firmware ───────────────────────────────────────────────────────────────── + +firmware="$(run_stage PANAMA_FIRMWARE=yes)" +called "$firmware" 'fwupdmgr refresh' \ + || note 'firmware metadata is never refreshed' +called "$firmware" 'fwupdmgr update' \ + || note 'firmware updates are never applied' +called "$firmware" -- '--no-reboot-check' \ + || note 'the firmware update may reboot the machine in the middle of an install' + +# ── 3. Nothing stops for a human ───────────────────────────────────────────── + +if grep -qE 'sudoedit|EDITOR=|\bnvim\b|kitty .*-e|read -r? *-?p' "$stage"; then + note 'the stage opens an editor or waits for input, which is the failure the interview exists to prevent' +fi + +# The interview must ask for every answer this stage reads. The interview +# contract checks that in general; what it cannot check is that the two agree on +# the list itself, which is the coupling that breaks when a question is renamed. +for key in PANAMA_NVIDIA PANAMA_MOK_HASH PANAMA_DEBLOAT PANAMA_FIRMWARE; do + grep -q "record $key " "$interview" \ + || note "install-hardware reads $key, but the interview never records it" +done + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + printf 'hardware contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'hardware contract: PASS\n' diff --git a/tests/setup/interview-contract b/tests/setup/interview-contract index 9f828bd..5f20d11 100755 --- a/tests/setup/interview-contract +++ b/tests/setup/interview-contract @@ -31,8 +31,11 @@ asked="$(grep -oE '^record [A-Z_]+' "$interview" | awk '{print $2}' | sort -u)" consumed="$(grep -rhoE '\$\{PANAMA_[A-Z_]+' "$repo_dir"/setup/scripts/* "$install_script" 2>/dev/null \ | sed 's/^\${//' | sort -u)" -# Not answers: paths the installer sets up for itself. -INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR)$' +# Not answers: paths the installer sets up for itself, plus PANAMA_MOK_CERT, +# which is the akmods certificate path with an override on it. Nothing sets that +# override outside the hardware contract, which needs a certificate it is allowed +# to create -- enrolling the real one to find out what happens is not a test. +INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT)$' while read -r key; do [[ -n "$key" ]] || continue