deploy: make the local reinstall path actually work, and fail closed

The `install <config> localhost` auto-path added in 0ea9020 could not have
completed. Fixed three blockers plus the guard that was silently not guarding.

Inside installer-iso the run died before doing anything:
  - systemd sets no $HOME for a service without User= (SetLoginEnvironment=
    defaults to false), and this script runs under `set -u`, so it aborted on
    the bare $HOME with "unbound variable". Added $KEYDIR + Environment=HOME.
  - the host key it needs to seed /etc/ssh isn't on the ISO at all — that is
    built from a public repo and carries no credentials on purpose. It now
    travels on the boot partition, located via homelab.keypart=<PARTUUID> on
    the kernel cmdline, and dies with the disko wipe minutes later. Without
    it sops can't decrypt on boot #1 and mutableUsers locks darman for good.
  - installation-cd-minimal leaves experimental-features unset, so both
    `nix run` and `nixos-install --flake` failed. (The nixos-images kexec
    installer sets them itself, which is why the same branch worked after
    kexec-local but not from the ISO.)

The staging-dir guard passed everything on btrfs: findmnt prints the
subvolume as /dev/sdb2[/@], lsblk can't open that, and an empty parent was
treated as "different disk" — so it allowed staging the iso on the very disk
disko then wiped. terra's current CachyOS root is exactly that layout. Now
uses --nofsroot, resolves EVERY whole-disk ancestor (LVM/RAID span several:
/mnt/ssd_01 -> sdd+sde), and treats "can't tell" as a hard error. btrfs
staging is refused outright — stage-1 mounts a btrfs volume's top level, so
an iso inside a subvolume is unreachable.

findiso= lost its leading slash whenever the staging mountpoint was /,
giving /findisovar/tmp/x.iso and an emergency shell after the reboot.

Also:
  - confirm before rebooting, like flash/kexec-local already do; --yes skips
    it and is what the ISO passes itself
  - $BOOT from `bootctl --print-boot-path`, not a hardcoded /boot
  - free-space checks on both target partitions before the ~1GB copy
  - `nix run .#disko` / `.#nixos-anywhere` from locked inputs instead of
    github:... master-of-the-day, resolved while a disk is being wiped
  - one_match warns instead of silently taking [0]; require_tracked covers
    every hosts/<config>/*.nix; flash traps its mount
  - drop nixos-images' `inputs.nixpkgs.follows` — that input doesn't exist,
    it only printed a warning on every nix command

Verified: the prepare path exercised under stubs against this box's real
disks (btrfs-on-OS-disk, tmpfs, LVM, subdirectory), shellcheck clean, all
six configs evaluate, checks.kexec-local still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 02:25:14 +02:00
co-authored by Claude Opus 4.8
parent 0ea90200b4
commit e538788907
5 changed files with 418 additions and 97 deletions
+72 -5
View File
@@ -16,9 +16,24 @@
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# NB: no `inputs.nixpkgs.follows` here — nixos-images has no `nixpkgs`
# input (it takes nixos-stable / nixos-unstable), so declaring one only
# printed "override for a non-existent input" on every nix command.
nixos-images = {
url = "github:nix-community/nixos-images";
inputs.nixos-stable.follows = "nixpkgs";
};
# Pinned as an input rather than `nix run github:...` from scripts/deploy.
# Both it and disko run at the exact moment a disk is being wiped, so the
# revision has to come from flake.lock — reviewed, reproducible, and
# resolvable from the local store — instead of whatever upstream master
# happens to be that day (which also fails outright with no network).
nixos-anywhere = {
url = "github:nix-community/nixos-anywhere";
inputs.nixpkgs.follows = "nixpkgs";
inputs.nixos-stable.follows = "nixpkgs"; # 26.05 already IS stable
inputs.disko.follows = "disko";
inputs.nixos-images.follows = "nixos-images";
};
home-manager = {
url = "github:nix-community/home-manager/release-26.05";
@@ -48,13 +63,21 @@
};
};
outputs = { self, nixpkgs, disko, sops-nix, nixos-images, home-manager, mediamanager-nix, authentik-nix, ... }@inputs:
outputs = { self, nixpkgs, disko, nixos-anywhere, sops-nix, nixos-images, home-manager, mediamanager-nix, authentik-nix, ... }@inputs:
let
system = "x86_64-linux";
in
{
packages.${system}.tome = nixpkgs.legacyPackages.${system}.callPackage ./pkgs/tome.nix {
src = inputs.tome;
packages.${system} = {
tome = nixpkgs.legacyPackages.${system}.callPackage ./pkgs/tome.nix {
src = inputs.tome;
};
# Re-exported so `./scripts/deploy` can run them as `nix run .#disko` /
# `nix run .#nixos-anywhere`, at the revision flake.lock pins. See the
# nixos-anywhere input above for why that matters.
disko = disko.packages.${system}.disko;
nixos-anywhere = nixos-anywhere.packages.${system}.nixos-anywhere;
};
nixosConfigurations = {
@@ -185,6 +208,16 @@
networking.hostName = "homelab-installer";
environment.systemPackages = [ pkgs.git ];
# installation-cd-minimal leaves experimental-features unset, so
# the ISO's nix.conf has no `nix-command`/`flakes` at all (unlike
# the nixos-images kexec installer, which sets
# extra-experimental-features itself — which is why the same
# `install <config> localhost` branch works after kexec-local but
# not here). Without this, both `nix run .#disko` and
# `nixos-install --flake` die with "experimental Nix feature
# 'nix-command' is disabled".
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Fresh clone of a PUBLIC repo — no credentials baked into the
# ISO. require_tracked() in scripts/deploy still works fine here
# (this IS a real git checkout, unlike the old baked-`self`
@@ -219,17 +252,51 @@
after = [ "homelab-checkout.service" ];
requires = [ "homelab-checkout.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.gnugrep ];
path = [ pkgs.gnugrep pkgs.util-linux pkgs.coreutils pkgs.git ];
serviceConfig.Type = "oneshot";
# systemd does NOT set $HOME for a system service without User=
# (systemd.exec(5): SetLoginEnvironment= "defaults to true if
# User=, DynamicUser= or PAMName= are set, false otherwise"), and
# scripts/deploy runs under `set -u`. Without this the whole
# unattended run died on the bare $HOME expansion with an
# "unbound variable" that reads like a bug in the script.
serviceConfig.Environment = "HOME=/root";
script = ''
cfg=$(grep -o 'homelab\.install=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
if [ -z "$cfg" ]; then
echo "no homelab.install= on the kernel cmdline nothing to auto-install"
exit 0
fi
# The host key scripts/deploy seeds /etc/ssh with (so sops can
# decrypt on boot #1) cannot live in this ISO: it is built from
# a PUBLIC repo and the private keys are deliberately off-repo.
# local_install_prepare_and_reboot() therefore drops it on the
# boot partition and passes that partition's PARTUUID here.
# That copy dies with the disko wipe a few minutes later.
keypart=$(grep -o 'homelab\.keypart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
if [ -n "$keypart" ]; then
mkdir -p /run/homelab-key
if mount -o ro "/dev/disk/by-partuuid/$keypart" /run/homelab-key; then
src=/run/homelab-key/homelab-installer
if [ -f "$src/ssh_host_ed25519_key" ]; then
echo "picking up $cfg's host key from PARTUUID=$keypart"
install -Dm600 "$src/ssh_host_ed25519_key" \
"/root/.config/homelab/$cfg/ssh_host_ed25519_key"
install -Dm644 "$src/ssh_host_ed25519_key.pub" \
"/root/.config/homelab/$cfg/ssh_host_ed25519_key.pub"
else
echo "warning: no host key at $src the install will refuse" >&2
fi
umount /run/homelab-key
else
echo "warning: could not mount PARTUUID=$keypart for the host key" >&2
fi
fi
echo "auto-installing $cfg (homelab.install= on the kernel cmdline)"
cd /root/homelab
exec ./scripts/deploy install "$cfg" localhost
exec ./scripts/deploy install "$cfg" localhost --yes
'';
};
})