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:
+138
-5
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user