feat(installer): ISO that installs a host with one command

hosts/installer builds an image (nix build .#installer-iso) carrying this
flake as /etc/moonix, disko and a wrapper. The guest runs
`moonix-install <host>`: disko formats per the host file, nixos-install
installs, and the repository is cloned into the account own ~/flakes/moonix
with /etc/nixos/flake.nix pointing at it.

The image is built from the local tree, so a state does not have to be pushed
to be installable.

disko is patched to re-ask for the LUKS passphrase on a mismatch instead of
killing the script: its `exit 1` sits inside a function called from the until
loop that was meant to repeat the prompt.
This commit is contained in:
2026-08-20 14:16:39 +02:00
parent 14d16ee8ec
commit 2fd4dcd073
3 changed files with 118 additions and 1 deletions
+25
View File
@@ -1,5 +1,30 @@
# Decisions
## 2026-08-20 Installing from an own ISO, one command
- **Who**: Maintainer, ClaudeCode
- **Why**: The documented install was a five-line `nix run` invocation typed at
the guest console, reading the configuration from Gitea — so only pushed
states were installable, and a typo in a long command cost the whole run.
- **Tradeoffs**: `nixos-anywhere` and an SSH-driven install were ruled out: the
machine is installed in front of it, not over the network. A script or flake
app in the repository still has to be fetched before it can run, which is the
problem it was meant to solve. Baking the target closure into the image would
make the install work offline, at four to six gigabytes per rebuild — not
taken.
- **How**: `hosts/installer` builds an ISO (`nix build .#installer-iso`) that
carries this flake as `/etc/moonix`, disko and one wrapper. The guest runs
`moonix-install <host>`: partition and format per the host file, install, then
clone the repository into the account's `~/flakes/moonix`, hand it to the
account and point `/etc/nixos/flake.nix` at it. The image is built from the
local tree, so unpushed changes are installable. Network is still needed —
the packages come from the binary cache.
- **Also**: disko aborts the whole format script when the two passphrase
entries differ, because `exit 1` sits inside a function called from the
`until` loop meant to repeat the prompt
(`share/disko/lib/types/luks.nix:151`). The installer uses a disko patched to
`return 1` there, so a typo asks again instead of restarting the install.
## 2026-08-20 The checkout on a machine lives in `~/flakes/moonix`
- **Who**: Maintainer, ClaudeCode
+27 -1
View File
@@ -52,6 +52,18 @@
};
};
# disko kills the whole format script when the two passphrase entries
# differ: `exit 1` sits inside a function called from the `until` loop
# that was meant to repeat the prompt
# (share/disko/lib/types/luks.nix:151). A typo therefore means starting
# the install over. One word changes that.
diskoRetryingPassphrase = inputs.disko.packages.${system}.disko.overrideAttrs (old: {
postFixup = (old.postFixup or "") + ''
substituteInPlace $out/share/disko/lib/types/luks.nix \
--replace-fail 'exit 1' 'return 1'
'';
});
# Colloid overrides, used by the desktop session and by the greeter.
# Defined once so the two cannot drift apart.
themePackages = {
@@ -68,7 +80,10 @@
};
in
{
packages.${system} = moonarchPackages;
packages.${system} = moonarchPackages // {
# Installer image: `nix build .#installer-iso`.
installer-iso = self.nixosConfigurations.installer.config.system.build.isoImage;
};
# Makes the packages and the moonarch source tree available to every
# module without threading arguments through by hand.
@@ -90,6 +105,17 @@
};
nixosConfigurations = {
# Installer image. Carries this flake and disko, so installing needs
# neither a clone nor the Gitea reference.
installer = nixpkgs.lib.nixosSystem {
inherit system;
modules = [ ./hosts/installer ];
specialArgs = {
moonixSrc = self;
disko = diskoRetryingPassphrase;
};
};
# QEMU/KVM test VM.
testvm = nixpkgs.lib.nixosSystem {
inherit system;
+66
View File
@@ -0,0 +1,66 @@
# ABOUTME: Installer ISO carrying the moonix flake, disko and one install command.
# ABOUTME: Built with `nix build .#installer-iso`; installs a host from the image.
{ pkgs, modulesPath, moonixSrc, disko, ... }:
let
# Wraps the flags that never change, so the guest console sees one short
# command instead of a line that has to be typed without a typo.
moonix-install = pkgs.writeShellApplication {
name = "moonix-install";
runtimeInputs = [ disko pkgs.nixos-install-tools pkgs.git pkgs.nix pkgs.gawk ];
text = ''
host=''${1:-}
if [[ -z "$host" ]]; then
echo "usage: moonix-install <host> # testvm, thinkpad, desktop" >&2
exit 1
fi
# Two steps instead of disko-install, which demands a --disk flag per
# disk and overrides the declared device anyway
# (disko/install-cli.nix:14). This way the devices stay declared in the
# host file and nothing has to be typed twice.
#
# Partition, format and mount at /mnt. Erases the disks the host file
# names.
disko --mode destroy,format,mount --flake /etc/moonix#"$host"
# Install onto what disko just mounted. The bootloader writes its NVRAM
# entry because the host files set boot.loader.efi.canTouchEfiVariables.
nixos-install --flake /etc/moonix#"$host" --no-root-password
# The image carries the flake as a store copy, which cannot be committed
# from. Put a real checkout in the account's home instead, owned by it,
# and point /etc/nixos at it that is what nixos-rebuild reads later.
user=$(nix eval --raw "/etc/moonix#nixosConfigurations.$host.config.moonarch.user")
owner=$(awk -F: -v u="$user" '$1 == u { print $3 ":" $4 }' /mnt/etc/passwd)
git clone https://gitea.moonarch.de/nevaforget/moonix.git \
"/mnt/home/$user/flakes/moonix"
git -C "/mnt/home/$user/flakes/moonix" remote set-url origin \
git@gitea.moonarch.de:nevaforget/moonix.git
chown -R "$owner" "/mnt/home/$user/flakes"
mkdir -p /mnt/etc/nixos
ln -sfn "/home/$user/flakes/moonix/flake.nix" /mnt/etc/nixos/flake.nix
echo "Installed $host. Checkout: /home/$user/flakes/moonix" >&2
'';
};
in
{
imports = [ (modulesPath + "/installer/cd-dvd/installation-cd-minimal.nix") ];
# The flake travels inside the image, so the install reads the revision this
# ISO was built from — including changes that are not pushed yet.
environment.etc.moonix.source = moonixSrc;
environment.systemPackages = [ moonix-install ];
# Same layout the installed systems use. The LUKS passphrase is typed twice
# per container here, so the layout has to match what it will be at boot.
console.keyMap = "de";
# disko-install evaluates the target configuration, which needs flakes.
nix.settings.experimental-features = [ "nix-command" "flakes" ];
}