hosts/terra/home.nix ships dotfiles/quickshell via xdg.configFile, which
copies the tree into the store: ~/.config/quickshell is a read-only symlink
into /nix/store and every QML tweak costs a nixos-rebuild. quickshell does
hot-reload on file save -- but only for the files it watches, which are those
frozen store copies. `nix develop` now swaps the running shell to the working
tree (`qs -p`) and swaps it back on exit, so QML edits need no rebuild at all.
The swap starts the dev instance FIRST and kills the packaged one only once
dev is confirmed up. A QML error in the working tree then leaves you on your
normal bar instead of no bar, which matters because a broken save is exactly
when you would be running this. Liveness is "did `qs list -j` return json" --
it exits 0 whether or not it found anything, so the exit code says nothing.
Every kill is scoped to one config (`qs kill` = default, `qs kill -p` = that
path). A blanket kill would also take out unrelated instances; pkgs/rishot.nix
is one.
Three guards on the auto-swap, all learned by testing it:
- interactive only. `nix develop --command X` EXECs X, replacing the shell
that set the `trap ... EXIT`, so the restore never runs and you are left
on the dev instance. Non-interactive use gets `nix develop -c qs-dev`.
- WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the desktop's
bar and leave nothing in its place.
- a sentinel, so a nested `nix develop` does not swap and restore twice.
Deliberately not wired to direnv (no .envrc): programs.direnv is enabled for
this user, so a `use flake` would swap the running desktop shell on every `cd`
into the checkout.
Verified end to end on terra: swap, hot-reload of a working-tree edit, and
restore, plus both the interactive and non-interactive paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgcWkm3t6BDQktYHQvb8Hx
665 lines
32 KiB
Nix
665 lines
32 KiB
Nix
{
|
|
description = "Homelab NixOS configuration";
|
|
|
|
inputs = {
|
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
|
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
|
disko = {
|
|
url = "github:nix-community/disko";
|
|
inputs.nixpkgs.follows = "nixpkgs";
|
|
};
|
|
sops-nix = {
|
|
url = "github:Mic92/sops-nix";
|
|
inputs.nixpkgs.follows = "nixpkgs";
|
|
};
|
|
nixos-images = {
|
|
url = "github:nix-community/nixos-images";
|
|
inputs.nixos-stable.follows = "nixpkgs";
|
|
};
|
|
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";
|
|
inputs.nixpkgs.follows = "nixpkgs";
|
|
};
|
|
mediamanager-nix = {
|
|
url = "github:strangeglyph/mediamanager-nix";
|
|
inputs.nixpkgs.follows = "nixpkgs";
|
|
};
|
|
authentik-nix.url = "github:nix-community/authentik-nix";
|
|
nix-flatpak.url = "github:gmodena/nix-flatpak";
|
|
# Own Hyprland plugin (border + title bar), public repo, fetched over
|
|
# https (no credentials needed, unlike tome below). `nixpkgs.follows` is
|
|
# what makes its packaged build ABI-correct — Hyprland plugins are
|
|
# ABI-locked to the exact Hyprland build they load into, so it has to be
|
|
# built against THIS flake's own nixpkgs, not whatever hypr-chrome's own
|
|
# flake.lock happens to pin standalone.
|
|
hypr-chrome = {
|
|
url = "git+https://git.mgaction.town/darman/hypr-chrome.git";
|
|
inputs.nixpkgs.follows = "nixpkgs";
|
|
};
|
|
# Tome (formerly AudibleLibrary) — darman's own .NET/Photino desktop app.
|
|
# Private repo on our own gitea; fetched over ssh with darman's ambient key,
|
|
# same as any other git flake input. `flake = false`: it's a plain source
|
|
# tree, not itself a flake. See pkgs/tome.nix.
|
|
#
|
|
# NOTE: the credential-less installer-iso can't fetch this (git+ssh needs
|
|
# darman's key), so `./scripts/deploy install terra localhost` will fail
|
|
# at nixos-install (post-disko) while this input is present. Known
|
|
# tradeoff — re-removed this once before (4f79ec7) for the same reason.
|
|
tome = {
|
|
url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git";
|
|
flake = false;
|
|
};
|
|
};
|
|
|
|
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} = {
|
|
# 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 = {
|
|
# Real host — install on the ZimaBlade.
|
|
# disko owns the OS-disk partitioning + filesystems (see disk-config.nix).
|
|
jupiter = nixpkgs.lib.nixosSystem {
|
|
inherit system;
|
|
specialArgs = { inherit inputs; };
|
|
modules = [
|
|
disko.nixosModules.disko
|
|
sops-nix.nixosModules.sops
|
|
home-manager.nixosModules.home-manager
|
|
./hosts/jupiter/configuration.nix
|
|
];
|
|
};
|
|
|
|
# netcup VPS — public reverse proxy + tailnet node.
|
|
neptun = nixpkgs.lib.nixosSystem {
|
|
inherit system;
|
|
specialArgs = { inherit inputs; };
|
|
modules = [
|
|
disko.nixosModules.disko
|
|
sops-nix.nixosModules.sops
|
|
home-manager.nixosModules.home-manager
|
|
./hosts/neptun/configuration.nix
|
|
];
|
|
};
|
|
|
|
# terra — Ryzen 9 5900X desktop (MSI MS-7A32). Replaces CachyOS on the
|
|
# OS SSD; Hyprland desktop + tailnet node. See hosts/terra/*.
|
|
terra = nixpkgs.lib.nixosSystem {
|
|
inherit system;
|
|
specialArgs = { inherit inputs; };
|
|
modules = [
|
|
disko.nixosModules.disko
|
|
sops-nix.nixosModules.sops
|
|
home-manager.nixosModules.home-manager
|
|
inputs.nix-flatpak.nixosModules.nix-flatpak
|
|
./hosts/terra/configuration.nix
|
|
];
|
|
};
|
|
|
|
# mars — on-site x86_64 box, single-purpose: Hermes Agent only.
|
|
# See hosts/mars/*.
|
|
mars = nixpkgs.lib.nixosSystem {
|
|
inherit system;
|
|
specialArgs = { inherit inputs; };
|
|
modules = [
|
|
disko.nixosModules.disko
|
|
sops-nix.nixosModules.sops
|
|
home-manager.nixosModules.home-manager
|
|
./hosts/mars/configuration.nix
|
|
];
|
|
};
|
|
|
|
# mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP. Boots from an SD image:
|
|
# nix build .#nixosConfigurations.mercury.config.system.build.sdImage
|
|
# (aarch64 build — needs binfmt/qemu on this x86 host, or a remote/aarch64
|
|
# builder; substitutes most paths from cache.nixos.org.)
|
|
mercury = nixpkgs.lib.nixosSystem {
|
|
system = "aarch64-linux";
|
|
specialArgs = { inherit inputs; };
|
|
modules = [
|
|
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
|
|
sops-nix.nixosModules.sops
|
|
home-manager.nixosModules.home-manager
|
|
./hosts/mercury/configuration.nix
|
|
];
|
|
};
|
|
|
|
# x86_64 QEMU VM to runtime-test mercury's DNS/DHCP stack (pihole +
|
|
# unbound) before flashing the aarch64 SD. Build + run:
|
|
# nix build .#nixosConfigurations.mercury-vm.config.system.build.vm
|
|
# ./result/bin/run-mercury-vm-vm
|
|
mercury-vm = nixpkgs.lib.nixosSystem {
|
|
inherit system; # x86_64-linux, fast to build/boot with KVM
|
|
modules = [
|
|
(nixpkgs + "/nixos/modules/virtualisation/qemu-vm.nix")
|
|
home-manager.nixosModules.home-manager
|
|
./common.nix
|
|
./services/network/unbound.nix
|
|
./services/network/pihole.nix
|
|
({ lib, ... }: {
|
|
networking.hostName = "mercury-vm";
|
|
networking.nameservers = [ "1.1.1.1" "9.9.9.9" ]; # host resolver (not pihole)
|
|
users.users.darman.initialPassword = "test";
|
|
users.users.root.initialPassword = "test";
|
|
services.openssh.settings.PasswordAuthentication = lib.mkForce true;
|
|
virtualisation.graphics = false;
|
|
virtualisation.memorySize = 2048;
|
|
virtualisation.forwardPorts = [
|
|
{ from = "host"; host.port = 2223; guest.port = 22; }
|
|
{ from = "host"; host.port = 8081; guest.port = 80; }
|
|
];
|
|
system.stateVersion = "26.05";
|
|
})
|
|
];
|
|
};
|
|
|
|
# VirtualBox test image. Build the OVA with:
|
|
# nix build .#nixosConfigurations.jupiter-vbox.config.system.build.virtualBoxOVA
|
|
# NOTE: no disko here — the virtualbox-image module supplies the disk.
|
|
jupiter-vbox = nixpkgs.lib.nixosSystem {
|
|
inherit system;
|
|
specialArgs = { inherit inputs; };
|
|
modules = [
|
|
home-manager.nixosModules.home-manager
|
|
./hosts/jupiter/vm.nix
|
|
];
|
|
};
|
|
|
|
# Custom kexec installer with our SSH key baked in, for headless install
|
|
# onto a box with a read-only root (ZimaOS) where nixos-anywhere can't
|
|
# ssh-copy-id. Build the tarball:
|
|
# nix build .#nixosConfigurations.kexec.config.system.build.kexecInstallerTarball
|
|
# then scp it to the target's writable /tmp and run kexec/run (see README).
|
|
kexec = nixpkgs.lib.nixosSystem {
|
|
inherit system;
|
|
modules = [
|
|
nixos-images.nixosModules.kexec-installer
|
|
({ ... }: {
|
|
users.users.root.openssh.authorizedKeys.keys = [
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD5K6AQ0wYYHbNGzC4PyunUQsXbaD0iu1eaadLtv+Xp darman@terra"
|
|
];
|
|
})
|
|
];
|
|
};
|
|
|
|
# Bootable USB recovery installer with our SSH key + sshd + DHCP. Clones
|
|
# the (now public) homelab repo fresh at every boot to /root/homelab —
|
|
# always current master, so the same USB stick stays useful across
|
|
# install/rescue occasions without ever needing a rebuild. No
|
|
# rsync/copy-the-repo-over step: boot it, ssh in,
|
|
# `cd /root/homelab && ./scripts/deploy install ...`.
|
|
# Reusable for any host's manual-USB install path (jupiter, terra, ...).
|
|
# Build the ISO:
|
|
# nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage
|
|
# dd it to a USB stick, boot the target from it, SSH in, ./deploy install.
|
|
installer-iso = nixpkgs.lib.nixosSystem {
|
|
inherit system;
|
|
modules = [
|
|
(nixpkgs + "/nixos/modules/installer/cd-dvd/installation-cd-minimal.nix")
|
|
({ pkgs, lib, ... }: {
|
|
services.openssh.enable = true;
|
|
services.openssh.settings.PermitRootLogin = "prohibit-password";
|
|
users.users.root.openssh.authorizedKeys.keys = [
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD5K6AQ0wYYHbNGzC4PyunUQsXbaD0iu1eaadLtv+Xp darman@terra"
|
|
];
|
|
networking.hostName = "homelab-installer";
|
|
console.keyMap = "de"; # matches common.nix's real hosts
|
|
environment.systemPackages = [ pkgs.git ];
|
|
|
|
# findiso= is a SCRIPT-stage-1 feature (stage-1-init.sh) only. The
|
|
# systemd initrd — the default since 26.05 — has no findiso path
|
|
# at all: it mounts /iso straight from
|
|
# /dev/disk/by-label/<volumeID> (iso-image.nix), which only exists
|
|
# when the ISO is the physical boot medium. Booted as a kernel +
|
|
# initrd off the ESP with the iso as a plain file elsewhere, that
|
|
# label never appears and stage 1 times out into an emergency
|
|
# shell (mounts /sysroot fine, then fails /sysroot/nix/.ro-store).
|
|
# Script stage 1 instead loop-mounts the file findiso= points at
|
|
# and symlinks it to /dev/root — which is the whole mechanism this
|
|
# install path relies on. So force it off here.
|
|
boot.initrd.systemd.enable = false;
|
|
|
|
# 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`
|
|
# approach), but retry manually with `systemctl restart
|
|
# homelab-checkout` if DHCP was still coming up at boot.
|
|
systemd.services.homelab-checkout = {
|
|
description = "Clone the homelab repo to /root/homelab";
|
|
after = [ "network-online.target" ];
|
|
wants = [ "network-online.target" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
path = [ pkgs.git ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
};
|
|
script = ''
|
|
rm -rf /root/homelab
|
|
git clone --depth 1 https://git.mgaction.town/darman/homelab.git /root/homelab
|
|
'';
|
|
};
|
|
|
|
# Finishes a local_install_prepare_and_reboot() run (scripts/deploy)
|
|
# unattended: that function stages this ISO, points a systemd-boot
|
|
# one-shot entry at it with `homelab.install=<config>` on the kernel
|
|
# cmdline, and reboots. Once booted here, this re-runs the exact same
|
|
# `./scripts/deploy install <config> localhost` command — now genuinely
|
|
# inside the installer (hostname homelab-installer), so is_live_installer
|
|
# takes the disko+nixos-install branch instead of preparing again.
|
|
# A manual boot of this ISO with no such cmdline param is a no-op.
|
|
systemd.services.homelab-auto-install = {
|
|
description = "Auto-run the homelab install if homelab.install= was passed on the kernel cmdline";
|
|
after = [ "homelab-checkout.service" ];
|
|
requires = [ "homelab-checkout.service" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
serviceConfig.Type = "oneshot";
|
|
# Full system PATH, not the restricted default a `path = [...]`
|
|
# produces: this unit execs `./scripts/deploy`, whose
|
|
# `#!/usr/bin/env bash` needs bash, and which then reaches for
|
|
# nix / nixos-install / git / sudo / efibootmgr. The default
|
|
# service PATH gave "env: 'bash': No such file or directory"
|
|
# (status 127) before the script even started.
|
|
# /run/current-system/sw/bin carries all of it on the installer;
|
|
# /run/wrappers/bin for sudo. mkForce because NixOS otherwise
|
|
# derives environment.PATH from `path` and that line would win.
|
|
#
|
|
# HOME too: systemd sets no $HOME for a service without User=
|
|
# (systemd.exec(5): SetLoginEnvironment= defaults false), and
|
|
# scripts/deploy runs under `set -u`, so a bare $HOME aborted the
|
|
# whole run with an "unbound variable" that read like a bug.
|
|
environment = {
|
|
HOME = "/root";
|
|
PATH = lib.mkForce "/run/current-system/sw/bin:/run/wrappers/bin";
|
|
};
|
|
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
|
|
|
|
# Persist this whole run to a file that OUTLIVES the install.
|
|
# The systemd journal is on the installer's tmpfs and dies with
|
|
# the reboot, and by the time anything interesting fails disko
|
|
# has already wiped the OS disk — so a failed attempt used to
|
|
# leave nothing to debug. local_install_prepare_and_reboot()
|
|
# (scripts/deploy) passes the STAGING partition's PARTUUID as
|
|
# homelab.logpart=; that partition holds the iso and is on a
|
|
# different disk from the one disko wipes, so it survives. The
|
|
# actual install runs inside do_install() below so one tee at
|
|
# the end captures all of it. Every step here is best-effort:
|
|
# logging must never be the thing that breaks an install.
|
|
logfile=""
|
|
logpart=$(grep -o 'homelab\.logpart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
|
|
if [ -n "$logpart" ]; then
|
|
dev="/dev/disk/by-partuuid/$logpart"
|
|
logdir=""
|
|
mkdir -p /run/homelab-log
|
|
if mount -o rw "$dev" /run/homelab-log 2>/dev/null; then
|
|
logdir=/run/homelab-log
|
|
elif where=$(findmnt -fno TARGET "$dev" 2>/dev/null) && [ -n "$where" ]; then
|
|
# stage-1's findiso already holds this partition mounted
|
|
# (that is how it reached the iso) — write into the existing
|
|
# mount rather than trying to stack a second one on it.
|
|
mount -o remount,rw "$where" 2>/dev/null || true
|
|
logdir="$where"
|
|
fi
|
|
if [ -n "$logdir" ]; then
|
|
# Next to the iso: findiso= is its path on this partition.
|
|
iso=$(grep -o 'findiso=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
|
|
dest="$logdir/$(dirname "$iso" 2>/dev/null || echo /)"
|
|
if mkdir -p "$dest" 2>/dev/null; then
|
|
logfile="$dest/homelab-install-$cfg.log"
|
|
else
|
|
logfile="$logdir/homelab-install-$cfg.log"
|
|
fi
|
|
echo "logging this install to $logfile (on the staging disk — survives the wipe)"
|
|
else
|
|
echo "warning: could not mount PARTUUID=$logpart to log to — continuing without a persistent log" >&2
|
|
fi
|
|
fi
|
|
|
|
do_install() {
|
|
# 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
|
|
|
|
# On a box whose old bootloader had no one-shot (Limine on
|
|
# terra), scripts/deploy got us here via a temporary UEFI
|
|
# entry + BootNext (arm_efi_bootnext). BootNext is already
|
|
# spent, but the entry itself would linger in NVRAM pointing
|
|
# at a partition disko is about to reformat. Drop it now, so
|
|
# even an install that fails later leaves NVRAM clean.
|
|
for n in $(efibootmgr 2>/dev/null \
|
|
| sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\?[[:space:]]Homelab Installer[[:space:]].*/\1/p'); do
|
|
echo "removing temporary UEFI entry Boot$n"
|
|
efibootmgr -q -B -b "$n" || true
|
|
done
|
|
|
|
echo "auto-installing $cfg (homelab.install= on the kernel cmdline)"
|
|
cd /root/homelab
|
|
./scripts/deploy install "$cfg" localhost --yes
|
|
}
|
|
|
|
# tee, not exec: we need the exit status back to sync the log
|
|
# to the platter before the box possibly drops to a shell.
|
|
if [ -n "$logfile" ]; then
|
|
{ echo "=== homelab auto-install: $cfg ($(date -u 2>/dev/null || true)) ==="; do_install; } 2>&1 | tee -a "$logfile"
|
|
status=''${PIPESTATUS[0]}
|
|
else
|
|
do_install
|
|
status=$?
|
|
fi
|
|
sync 2>/dev/null || true
|
|
exit "$status"
|
|
'';
|
|
};
|
|
})
|
|
];
|
|
};
|
|
};
|
|
|
|
# VM test for `./scripts/deploy kexec-local`. Run:
|
|
# nix build .#checks.x86_64-linux.kexec-local -L
|
|
#
|
|
# Worth having because kexec-local is the one command that cannot be
|
|
# rehearsed on real hardware: it jumps the machine you are typing at, and
|
|
# a failure looks exactly like a slow boot. It regression-tests the
|
|
# subtle one — kexec-run.sh backgrounds `sleep 6 && kexec -e` and returns,
|
|
# so anything that cleans up the staging dir on exit deletes the binary
|
|
# that performs the jump and the box silently never leaves the old kernel.
|
|
#
|
|
# After the jump the test driver's backdoor is gone with the old kernel,
|
|
# so the installer is driven over a forwarded ssh port instead (the same
|
|
# approach nixos-images uses in its own kexec test).
|
|
checks.${system}.kexec-local =
|
|
let
|
|
pkgs = nixpkgs.legacyPackages.${system};
|
|
tarball = self.nixosConfigurations.kexec.config.system.build.kexecInstallerTarball;
|
|
sshKey = nixos-images + "/nix/kexec-installer/ssh-keys/id_ed25519";
|
|
in
|
|
pkgs.testers.runNixOSTest {
|
|
name = "deploy-kexec-local";
|
|
|
|
nodes.machine = { modulesPath, ... }: {
|
|
imports = [ (modulesPath + "/profiles/minimal.nix") ];
|
|
virtualisation.vlans = [ ];
|
|
# kexec-local refuses to run if RAM < 3x the installer image, and
|
|
# the staging dir needs ~3x the tarball on /var/tmp.
|
|
virtualisation.memorySize = 4 * 1024;
|
|
virtualisation.diskSize = 12 * 1024;
|
|
virtualisation.forwardPorts = [{ host.port = 2222; guest.port = 22; }];
|
|
|
|
services.openssh.enable = true;
|
|
users.users.root.openssh.authorizedKeys.keyFiles = [ "${sshKey}.pub" ];
|
|
|
|
# Everything the script shells out to, minus nix — the test uses the
|
|
# HOMELAB_KEXEC_* hook so no build happens inside the VM.
|
|
environment.systemPackages = with pkgs; [
|
|
bash gnutar coreutils findutils util-linux cpio gzip
|
|
];
|
|
system.extraDependencies = [ tarball pkgs.cpio pkgs.gzip ];
|
|
|
|
environment.etc."deploy".source = ./scripts/deploy;
|
|
};
|
|
|
|
testScript = /* python */ ''
|
|
import os, shutil, subprocess, tempfile, time
|
|
|
|
start_all()
|
|
machine.wait_for_unit("sshd.service")
|
|
|
|
# ssh refuses a private key that is group/world readable, and nix
|
|
# store paths are 0444 — copy it out and tighten the mode.
|
|
keydir = tempfile.mkdtemp()
|
|
key = os.path.join(keydir, "id_ed25519")
|
|
shutil.copyfile("${sshKey}", key)
|
|
os.chmod(key, 0o600)
|
|
|
|
def ssh(cmd, check=True, stdout=None):
|
|
return subprocess.run(
|
|
[ "${pkgs.openssh}/bin/ssh",
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "ConnectTimeout=1",
|
|
"-i", key,
|
|
"-p", "2222", "root@127.0.0.1", "--" ] + cmd,
|
|
text=True, check=check, stdout=stdout)
|
|
|
|
machine.succeed("install -Dm755 /etc/deploy /root/deploy")
|
|
|
|
# systemd-run starts units with a bare PATH that lacks
|
|
# /run/current-system/sw/bin, so `#!/usr/bin/env bash` cannot even
|
|
# resolve bash, let alone tar/findmnt/nohup. Set it explicitly.
|
|
env = (
|
|
" --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin"
|
|
" --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
|
|
" --setenv=HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
|
|
" --setenv=HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
|
|
)
|
|
# Same values for the foreground (non-systemd-run) invocation below.
|
|
envsh = (
|
|
"HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
|
|
" HOMELAB_KEXEC_CPIO=${pkgs.cpio}/bin/cpio"
|
|
" HOMELAB_KEXEC_GZIP=${pkgs.gzip}/bin/gzip"
|
|
)
|
|
|
|
# Marker on a tmpfs: it must NOT survive the jump, proving we really
|
|
# booted a new kernel rather than just restarting a service.
|
|
machine.succeed("touch /run/pre-kexec-marker")
|
|
host_key_before = machine.succeed("cat /etc/ssh/ssh_host_ed25519_key.pub").strip()
|
|
|
|
while ssh(["true"], check=False).returncode != 0:
|
|
time.sleep(1)
|
|
|
|
# Refuses without --yes when stdin is not a tty (read gets EOF).
|
|
# Must reach the confirmation prompt, so it needs the same env —
|
|
# otherwise it just dies early on the nix build and proves nothing.
|
|
out = machine.fail(f"{envsh} /root/deploy kexec-local </dev/null 2>&1")
|
|
assert "using prebuilt kexec installer" in out, \
|
|
f"never reached the prompt, so the refusal proves nothing:\n{out}"
|
|
|
|
# systemd-run so the call returns immediately: the script stays
|
|
# alive ~60s on purpose, outliving kexec-run.sh's `sleep 6`.
|
|
machine.succeed(f"systemd-run --collect --unit=kexec-local{env} /root/deploy kexec-local --yes")
|
|
|
|
print("waiting for the jump...")
|
|
deadline = time.time() + 300
|
|
while ssh(["true"], check=False).returncode == 0:
|
|
# Surface a dead unit immediately instead of stalling until the
|
|
# deadline and blaming "never left the old kernel".
|
|
st = ssh(["systemctl", "is-active", "kexec-local"],
|
|
check=False, stdout=subprocess.PIPE).stdout or ""
|
|
if st.strip() in ("failed", "inactive"):
|
|
# NB: not `log` — the driver already binds that name to its
|
|
# AbstractLogger and the type check rejects the shadowing.
|
|
unit_log = ssh(["journalctl", "-u", "kexec-local", "--no-pager"],
|
|
check=False, stdout=subprocess.PIPE).stdout or ""
|
|
raise AssertionError(
|
|
f"kexec-local.service ended ({st.strip()}) without jumping:\n{unit_log}")
|
|
assert time.time() < deadline, "machine never left the old kernel"
|
|
time.sleep(1)
|
|
|
|
print("waiting for the installer...")
|
|
deadline = time.time() + 300
|
|
while ssh(["true"], check=False).returncode != 0:
|
|
assert time.time() < deadline, "installer never came up"
|
|
time.sleep(1)
|
|
|
|
# It really is the RAM installer, not the old system.
|
|
host = ssh(["hostname"], stdout=subprocess.PIPE).stdout.strip()
|
|
assert host == "nixos-installer", f"hostname is {host}, not nixos-installer"
|
|
|
|
assert ssh(["ls", "/run/pre-kexec-marker"], check=False).returncode != 0, \
|
|
"old /run survived — this was not a fresh kernel"
|
|
|
|
# The host key is carried across (kexec-run.sh copies /etc/ssh into
|
|
# the appended initrd), which is why `kexec` does no ssh-keygen -R.
|
|
host_key_after = ssh(
|
|
["cat", "/etc/ssh/ssh_host_ed25519_key.pub"], stdout=subprocess.PIPE
|
|
).stdout.strip()
|
|
assert host_key_before == host_key_after, \
|
|
f"host key changed: {host_key_before} != {host_key_after}"
|
|
|
|
machine.crash()
|
|
'';
|
|
};
|
|
|
|
# `nix develop` — hot-reload loop for dotfiles/quickshell.
|
|
#
|
|
# hosts/terra/home.nix ships the shell via `xdg.configFile."quickshell"`,
|
|
# which COPIES the tree into the store, so ~/.config/quickshell is a
|
|
# read-only symlink into /nix/store and every QML tweak costs a
|
|
# nixos-rebuild. quickshell DOES hot-reload on file save — but only for
|
|
# the files it is watching, which are those frozen store copies. Pointing
|
|
# it at the working tree with `qs -p` restores edit-save-see, no rebuild.
|
|
#
|
|
# quickshell keys instance identity on the CONFIG PATH, so a working-tree
|
|
# instance and the store-backed one are two different instances that would
|
|
# both map layer-shell bars onto every output. Hence a swap, not a second
|
|
# instance — and the swap starts dev FIRST, killing the packaged shell
|
|
# only once dev is confirmed up, so a QML error in the working tree leaves
|
|
# you on your normal bar instead of no bar at all.
|
|
#
|
|
# Every kill is scoped to one config (`qs kill` = default only, `qs kill
|
|
# -p` = that path only). A blanket kill would also take out unrelated
|
|
# quickshell instances — pkgs/rishot.nix is one.
|
|
#
|
|
# Deliberately NOT wired to direnv (no .envrc in this repo): programs.direnv
|
|
# is enabled for this user, so a `use flake` would swap the running desktop
|
|
# shell on every `cd` into the checkout, including over ssh.
|
|
devShells.${system}.default =
|
|
let
|
|
pkgs = nixpkgs.legacyPackages.${system};
|
|
# Same nixpkgs terra's home.nix takes pkgs.quickshell from, so the dev
|
|
# instance is the identical build to the packaged one.
|
|
qs = "${nixpkgs.legacyPackages.${system}.quickshell}/bin/qs";
|
|
git = "${nixpkgs.legacyPackages.${system}.git}/bin/git";
|
|
|
|
# Resolved at RUN time, not build time: the entire point is to run the
|
|
# working tree, and `self` here is only a store snapshot of it.
|
|
preamble = ''
|
|
root="$(${git} rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
cfg="$root/dotfiles/quickshell"
|
|
if [ ! -f "$cfg/shell.qml" ]; then
|
|
echo "no shell.qml under $cfg — run this from the homelab checkout" >&2
|
|
exit 1
|
|
fi
|
|
# `qs list` exits 0 whether or not it found anything, and only emits
|
|
# json when it did — so "json came back" is the liveness test.
|
|
running() { ${qs} list -p "$1" -j 2>/dev/null | grep -q '"id"'; }
|
|
prod_running() { ${qs} list -j 2>/dev/null | grep -q '"id"'; }
|
|
'';
|
|
|
|
qs-dev = pkgs.writeShellScriptBin "qs-dev" ''
|
|
set -uo pipefail
|
|
${preamble}
|
|
|
|
if [ -z "''${WAYLAND_DISPLAY:-}" ]; then
|
|
echo "qs-dev: no WAYLAND_DISPLAY — refusing to swap the desktop shell" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if running "$cfg"; then
|
|
echo "qs-dev: already running from $cfg"
|
|
exit 0
|
|
fi
|
|
|
|
${qs} -d -p "$cfg"
|
|
|
|
# Confirm it came up before touching the packaged shell.
|
|
for _ in $(seq 1 50); do
|
|
running "$cfg" && break
|
|
sleep 0.1
|
|
done
|
|
|
|
if ! running "$cfg"; then
|
|
echo "qs-dev: dev shell failed to start — packaged shell left alone" >&2
|
|
echo "qs-dev: run 'qs -p $cfg' in the foreground to see the QML error" >&2
|
|
exit 1
|
|
fi
|
|
|
|
${qs} kill || true
|
|
echo "qs-dev: live on $cfg — edits there now hot-reload"
|
|
'';
|
|
|
|
qs-prod = pkgs.writeShellScriptBin "qs-prod" ''
|
|
set -uo pipefail
|
|
${preamble}
|
|
|
|
running "$cfg" && ${qs} kill -p "$cfg" || true
|
|
prod_running || ${qs} -d
|
|
echo "qs-prod: back on ~/.config/quickshell"
|
|
'';
|
|
in
|
|
pkgs.mkShell {
|
|
packages = [ pkgs.quickshell qs-dev qs-prod ];
|
|
|
|
# Swap on entry, swap back on exit. Three guards:
|
|
# - interactive only ($- has i). `nix develop --command X` EXECs X,
|
|
# replacing the shell that set the trap, so the restore would
|
|
# never run and you'd be left on the dev instance. Non-interactive
|
|
# use gets the explicit `nix develop -c qs-dev` instead.
|
|
# - WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the
|
|
# desktop's bar and leave nothing in its place.
|
|
# - a sentinel, so a nested `nix develop` does not swap (and then
|
|
# restore) a second time.
|
|
shellHook = ''
|
|
if [[ $- == *i* ]] && [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${HOMELAB_QS_DEV:-}" ]; then
|
|
export HOMELAB_QS_DEV=1
|
|
qs-dev && trap qs-prod EXIT
|
|
fi
|
|
echo "homelab devshell — qs-dev (working tree) / qs-prod (packaged); exit restores"
|
|
'';
|
|
};
|
|
};
|
|
}
|