deploy: automate a full local reinstall, self-elevating and interactive-safe

./scripts/deploy install <config> localhost now branches on is_live_installer()
(checks uname -n): outside a live installer it builds installer-iso, stages
its kernel/initrd on the ESP and the iso file on a disk the caller picks
(never auto-picked — the wrong disk here is destroyed mid-install), writes a
systemd-boot one-shot findiso= entry with homelab.install=<config> on the
kernel cmdline, and does a real systemctl reboot (not kexec — terra's
kexec-local hang is specifically in kexec's device-shutdown pass, a real ACPI
reboot never runs that code at all).

installer-iso gains homelab-auto-install.service: once homelab-checkout.service
clones the repo, it reads homelab.install= back off /proc/cmdline and re-runs
the identical deploy command itself, now genuinely inside the installer, so
it takes the disko+nixos-install branch instead of preparing again. The whole
reinstall is one command and unattended after the first reboot.

Also: every root-requiring path (kexec-local, the new prepare-and-reboot
branch, the disko+nixos-install branch) self-elevates via a require_root()
helper that re-execs the original invocation under sudo -E, instead of dying
and asking the caller to prefix sudo themselves. Uses an absolute script path
captured before the script's own cd, so the re-exec is correct regardless of
how it was invoked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 01:53:45 +02:00
co-authored by Claude Sonnet 5
parent fd8328d7b3
commit 0ea90200b4
4 changed files with 222 additions and 42 deletions
+138 -5
View File
@@ -20,8 +20,23 @@
# ./deploy install <config> <host> first install. Wipes the OS disk. Ships the
# host's sops key. <host>=localhost/127.0.0.1
# skips nixos-anywhere/ssh and runs disko +
# nixos-install directly against /mnt (use
# after `kexec-local`, or on a live ISO).
# nixos-install directly against /mnt — but
# ONLY once actually inside a live installer
# (hostname nixos-installer, from kexec, or
# homelab-installer, from installer-iso).
# Run from the REAL running OS instead (e.g.
# a box where kexec-local doesn't work),
# it builds installer-iso, stages its
# kernel/initrd on the ESP + the iso file on
# a non-OS-disk partition, sets a systemd-boot
# one-shot entry with homelab.install=<config>
# on its kernel cmdline, and reboots — a real
# ACPI reboot, not a kexec jump. The booted
# installer's homelab-auto-install.service
# reads that cmdline param and re-runs this
# exact command itself once its repo checkout
# (homelab-checkout.service) succeeds, finishing
# the install unattended. See CLAUDE.md.
# ./deploy switch <config> <host> rebuild + activate on a running host.
# ./deploy boot <config> <host> stage for next boot, don't activate now.
# ./deploy test <config> <host> activate without adding a boot entry.
@@ -46,8 +61,16 @@
set -euo pipefail
shopt -s nullglob
# Captured before anything shifts/parses $@, so require_root() below can
# re-exec the ORIGINAL invocation under sudo — inside a function, "$@"/"$1"
# refer to the function's own args (empty here), not the script's, so this
# has to be a global array instead of relying on positional-parameter scoping.
SCRIPT_ARGS=("$@")
# Locate the repo root (flake dir) regardless of where this script lives on disk.
SCRIPT_DIR="$(cd "$(dirname "$(realpath "$0")")" && pwd)"
SCRIPT_PATH="$(realpath "$0")" # absolute — "$0" itself may be relative,
# and require_root() re-execs after cd "$REPO"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || dirname "$SCRIPT_DIR")"
cd "$REPO"
export PATH="/nix/var/nix/profiles/default/bin:$PATH"
@@ -56,6 +79,15 @@ die() { echo "error: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
# Self-elevate instead of dying: re-exec this exact invocation under sudo.
# -E preserves the environment (HOMELAB_* overrides, Proton Pass vault vars)
# across the re-exec. A no-op once already root.
require_root() {
[ "$(id -u)" = 0 ] && return 0
echo ">> $1 needs root — re-executing under sudo" >&2
exec sudo -E -- "$SCRIPT_PATH" "${SCRIPT_ARGS[@]}"
}
# Exactly one path matching a glob, or die. `ls glob | head -1` silently yields
# an empty string when nothing matches (head exits 0, so set -e never fires) and
# the failure only surfaces later as a confusing tar/dd error.
@@ -92,6 +124,99 @@ kexec_artifacts() {
fi
}
# True inside one of the throwaway live-installer environments this repo
# produces (kexec's nixos-installer, or installer-iso's homelab-installer) —
# i.e. `install <config> localhost` should wipe/install right here. False on
# any real running OS, where the same command instead means "prepare and
# reboot into an installer for THIS box" (see local_install_prepare_and_reboot).
is_live_installer() {
case "$(uname -n)" in
nixos-installer | homelab-installer) return 0 ;;
*) return 1 ;;
esac
}
# `install <config> localhost` run on a REAL running OS (not already inside a
# live installer): builds installer-iso, stages its kernel/initrd + iso file
# locally, points a systemd-boot one-shot entry at them with
# homelab.install=<config> on the kernel cmdline, and reboots — a real ACPI
# reboot through firmware POST, deliberately NOT a kexec jump (see terra's
# kexec-local gotcha in CLAUDE.md). The booted installer's
# homelab-auto-install.service reads that cmdline param and re-runs this exact
# `install <config> localhost` command itself (now genuinely inside the
# installer) once homelab-checkout.service has fetched the repo, finishing the
# job unattended.
local_install_prepare_and_reboot() {
local config="$1"
require_root "preparing a local reinstall"
[ -d /sys/firmware/efi ] || die "not booted UEFI — the one-shot boot entry needs systemd-boot"
need bootctl
need nix
need lsblk
need findmnt
# No default/auto-picked location — the wrong disk here is destroyed
# mid-install (see the OS-disk check below), so this always asks rather
# than guessing. HOMELAB_INSTALLER_STAGE_DIR skips the prompt for scripted
# use, but is otherwise just as explicit a choice as typing it in.
local stagedir="${HOMELAB_INSTALLER_STAGE_DIR:-}"
if [ -z "$stagedir" ]; then
echo ">> currently mounted filesystems:"
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT
read -rp ">> path to stage the installer iso on (must NOT be on the OS disk being wiped): " stagedir
fi
[ -n "$stagedir" ] || die "no staging path given"
[ -d "$stagedir" ] \
|| die "staging dir $stagedir doesn't exist — needs to be an existing partition that is NOT the OS disk being wiped"
# Refuse if the staging partition turns out to live on the same disk
# disko is about to wipe — the iso file (and the running installer
# loopback-mounted from it) would be destroyed mid-install.
local osdisk osdisk_real stage_src stage_pkname stage_disk_real
osdisk="$(nix eval --raw ".#nixosConfigurations.$config.config.disko.devices.disk" \
--apply 'd: (builtins.head (builtins.attrValues d)).device' 2>/dev/null)" \
|| die "couldn't read the OS disk device from hosts/$config/disk-config.nix"
osdisk_real="$(readlink -f "$osdisk")"
stage_src="$(findmnt -no SOURCE --target "$stagedir")" \
|| die "$stagedir doesn't resolve to a mounted filesystem"
stage_pkname="$(lsblk -no PKNAME "$stage_src" 2>/dev/null || true)"
if [ -n "$stage_pkname" ]; then
stage_disk_real="$(readlink -f "/dev/$stage_pkname")"
[ "$stage_disk_real" = "$osdisk_real" ] \
&& die "$stagedir is on the OS disk ($osdisk) that install would wipe — re-run and pick a different disk"
fi
echo ">> building installer-iso (kernel + initrd + iso image)"
local kernel initrd isodir iso mnt_point iso_relpath
kernel="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.kernel)/bzImage"
initrd="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.initialRamdisk)/initrd"
isodir="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.isoImage)"
iso="$(one_match 'installer iso' "$isodir"/iso/*.iso)"
echo ">> staging kernel/initrd on the ESP, iso image on $stagedir"
install -Dm644 "$kernel" /boot/homelab-installer/bzImage
install -Dm644 "$initrd" /boot/homelab-installer/initrd
install -Dm644 "$iso" "$stagedir/homelab-installer.iso"
# findiso= is a path relative to whatever partition the initrd finds it on
# (it mounts every blkid-visible partition looking for it) — not necessarily
# relative to `/`, if $stagedir is a subdirectory of a bigger filesystem
# rather than a mountpoint itself.
mnt_point="$(findmnt -no TARGET --target "$stagedir")"
iso_relpath="${stagedir#"$mnt_point"}/homelab-installer.iso"
cat >/boot/loader/entries/homelab-installer.conf <<EOF
title Homelab Installer ($config, findiso)
linux /homelab-installer/bzImage
initrd /homelab-installer/initrd
options nohibernate root=fstab loglevel=4 lsm=landlock,yama,bpf findiso=$iso_relpath homelab.install=$config
EOF
echo ">> one-shot boot into the installer, then rebooting — it will finish this install itself"
bootctl set-oneshot homelab-installer.conf
systemctl reboot
}
# Flakes only see git-tracked files: an untracked hosts/<config>/ is silently
# invisible to `nix build`/`nixos-install`, which then fails obscurely or builds
# a stale config. Check before doing anything destructive.
@@ -240,7 +365,7 @@ case "$cmd" in
# This is a one-way trip on the machine you are typing at, so every check
# that can fail is done BEFORE the point of no return, and nothing that the
# jump depends on is cleaned up behind it (see the trap discussion below).
[ "$(id -u)" = 0 ] || die "kexec-local must run as root (sudo ./deploy kexec-local)"
require_root "kexec-local"
assume_yes=""
[ "${2:-}" = "--yes" ] && assume_yes=1
@@ -352,10 +477,18 @@ case "$cmd" in
require_tracked "$config"
if [ "$host" = "localhost" ] || [ "$host" = "127.0.0.1" ]; then
if ! is_live_installer; then
# Not already inside a live installer: build one, stage it, one-shot
# boot into it, and let it finish this exact command itself. See
# local_install_prepare_and_reboot above and CLAUDE.md.
local_install_prepare_and_reboot "$config"
exit 0
fi
# Local install: no ssh, no nixos-anywhere. Run after `kexec-local` (or
# from a live ISO) so /mnt is free to wipe — this IS the box, no second
# machine in the loop, so skip straight to disko + nixos-install.
[ "$(id -u)" = 0 ] || die "local install must run as root"
require_root "local install"
[ -f "./hosts/$config/disk-config.nix" ] || die "no ./hosts/$config/disk-config.nix"
echo ">> disko .#$config onto this box's OS disk (WILL be wiped)"