feat(installer): add one-file installer build and custom install ISO

Replace old_installer.py with a curses TUI installer packaged as a
one-file PyInstaller binary (python/installer/build.py, wrapped by
python/installer/package.nix). The default .#installer is patched to run
on foreign Linux live media; the new .#installer-nixos variant keeps its
Nix store interpreter so it runs on NixOS.

Add systems/iso, a minimal NixOS install CD with kernel 6.18 and ZFS 2.4
matching the deployed systems and the installer on PATH; build it with
nix build .#iso. Shared logging and subprocess helpers move out of
common.py into python/logging_config.py and python/process.py.
This commit is contained in:
2026-07-02 14:17:49 -04:00
committed by Richie
parent fbf288649a
commit b252b0bf6e
11 changed files with 559 additions and 851 deletions
+130 -75
View File
@@ -5,41 +5,37 @@ from __future__ import annotations
import curses
import logging
import sys
from argparse import ArgumentParser
from os import getenv
from pathlib import Path
from random import getrandbits
from subprocess import PIPE, Popen, run
from subprocess import run
from time import sleep
from typing import TYPE_CHECKING
from python.common import configure_logger
from python.installer.tui import draw_menu
from python.logging_config import configure_logger
from python.process import require_commands, run_output
if TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__)
def bash_wrapper(command: str) -> str:
"""Execute a bash command and capture the output.
Args:
command (str): The bash command to be executed.
Returns:
Tuple[str, int]: A tuple containing the output of the command (stdout) as a string,
the error output (stderr) as a string (optional), and the return code as an integer.
"""
logger.debug(f"running {command=}")
# This is a acceptable risk
process = Popen(command.split(), stdout=PIPE, stderr=PIPE)
output, _ = process.communicate()
if process.returncode != 0:
error = f"Failed to run command {command=} return code {process.returncode=}"
raise RuntimeError(error)
return output.decode()
REQUIRED_COMMANDS = (
"blkdiscard",
"cryptsetup",
"find",
"lsblk",
"mkfs.vfat",
"mount",
"nixos-generate-config",
"nixos-install",
"parted",
"readlink",
"zfs",
"zpool",
)
def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
@@ -56,7 +52,7 @@ def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
swap_size = max(swap_size, 1)
reserve = max(reserve, 0)
bash_wrapper(f"blkdiscard -f {disk}")
run_output(("blkdiscard", "-f", disk))
if reserve > 0:
msg = f"Creating swap partition on {disk=} with size {swap_size=}GiB and reserve {reserve=}GiB"
@@ -72,14 +68,28 @@ def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
logger.debug(f"{swap_partition=}")
create_partitions = (
f"parted --script --align=optimal {disk} -- "
"mklabel gpt "
"mkpart EFI 1MiB 4GiB "
f"mkpart root_pool 4GiB -{swap_start}GiB "
f"{swap_partition}"
"set 1 esp on"
"parted",
"--script",
"--align=optimal",
disk,
"--",
"mklabel",
"gpt",
"mkpart",
"EFI",
"1MiB",
"4GiB",
"mkpart",
"root_pool",
"4GiB",
f"-{swap_start}GiB",
*swap_partition.split(),
"set",
"1",
"esp",
"on",
)
bash_wrapper(create_partitions)
run_output(create_partitions)
logger.info(f"{disk=} successfully partitioned")
@@ -95,30 +105,43 @@ def create_zfs_pool(pool_disks: Sequence[str], mnt_dir: str) -> None:
error = "disks must be a tuple of at least length 1"
raise ValueError(error)
zpool_create = (
"zpool create "
"-o ashift=12 "
"-o autotrim=on "
f"-R {mnt_dir} "
"-O acltype=posixacl "
"-O canmount=off "
"-O dnodesize=auto "
"-O normalization=formD "
"-O relatime=on "
"-O xattr=sa "
"-O mountpoint=legacy "
"-O compression=zstd "
"-O atime=off "
"root_pool "
)
zpool_create = [
"zpool",
"create",
"-o",
"ashift=12",
"-o",
"autotrim=on",
"-R",
mnt_dir,
"-O",
"acltype=posixacl",
"-O",
"canmount=off",
"-O",
"dnodesize=auto",
"-O",
"normalization=formD",
"-O",
"relatime=on",
"-O",
"xattr=sa",
"-O",
"mountpoint=legacy",
"-O",
"compression=zstd",
"-O",
"atime=off",
"root_pool",
]
if len(pool_disks) == 1:
zpool_create += pool_disks[0]
zpool_create.append(pool_disks[0])
else:
zpool_create += "mirror "
zpool_create += " ".join(pool_disks)
zpool_create.append("mirror")
zpool_create.extend(pool_disks)
bash_wrapper(zpool_create)
zpools = bash_wrapper("zpool list -o name")
run_output(zpool_create)
zpools = run_output(("zpool", "list", "-o", "name"))
if "root_pool" not in zpools.splitlines():
logger.critical("Failed to create root_pool")
sys.exit(1)
@@ -126,11 +149,11 @@ def create_zfs_pool(pool_disks: Sequence[str], mnt_dir: str) -> None:
def create_zfs_datasets() -> None:
"""Create ZFS datasets."""
bash_wrapper("zfs create -o canmount=noauto -o reservation=10G root_pool/root")
bash_wrapper("zfs create root_pool/home")
bash_wrapper("zfs create root_pool/var -o reservation=1G")
bash_wrapper("zfs create -o compression=zstd-9 -o reservation=10G root_pool/nix")
datasets = bash_wrapper("zfs list -o name")
run_output(("zfs", "create", "-o", "canmount=noauto", "-o", "reservation=10G", "root_pool/root"))
run_output(("zfs", "create", "root_pool/home"))
run_output(("zfs", "create", "-o", "reservation=1G", "root_pool/var"))
run_output(("zfs", "create", "-o", "compression=zstd-9", "-o", "reservation=10G", "root_pool/nix"))
datasets = run_output(("zfs", "list", "-o", "name"))
expected_datasets = {
"root_pool/root",
@@ -146,7 +169,7 @@ def create_zfs_datasets() -> None:
def get_cpu_manufacturer() -> str:
"""Get the CPU manufacturer."""
output = bash_wrapper("cat /proc/cpuinfo")
output = Path("/proc/cpuinfo").read_text()
id_vendor = {"AuthenticAMD": "amd", "GenuineIntel": "intel"}
@@ -160,7 +183,7 @@ def get_cpu_manufacturer() -> str:
def get_boot_drive_id(disk: str) -> str:
"""Get the boot drive ID."""
output = bash_wrapper(f"lsblk -o UUID {disk}-part1")
output = run_output(("lsblk", "-o", "UUID", f"{disk}-part1"))
return output.splitlines()[1]
@@ -220,21 +243,28 @@ def create_nix_hardware_file(mnt_dir: str, disks: Sequence[str], encrypt: str |
def install_nixos(mnt_dir: str, disks: Sequence[str], encrypt: str | None) -> None:
"""Install NixOS."""
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/root {mnt_dir}")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/home {mnt_dir}/home")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/var {mnt_dir}/var")
bash_wrapper(f"mount -o X-mount.mkdir -t zfs root_pool/nix {mnt_dir}/nix")
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/root", mnt_dir))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/home", f"{mnt_dir}/home"))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/var", f"{mnt_dir}/var"))
run_output(("mount", "-o", "X-mount.mkdir", "-t", "zfs", "root_pool/nix", f"{mnt_dir}/nix"))
for disk in disks:
bash_wrapper(f"mkfs.vfat -n EFI {disk}-part1")
run_output(("mkfs.vfat", "-n", "EFI", f"{disk}-part1"))
# set up mirroring afterwards if more than one disk
boot_partition = (
f"mount -t vfat -o fmask=0077,dmask=0077,iocharset=iso8859-1,X-mount.mkdir {disks[0]}-part1 {mnt_dir}/boot"
run_output(
(
"mount",
"-t",
"vfat",
"-o",
"fmask=0077,dmask=0077,iocharset=iso8859-1,X-mount.mkdir",
f"{disks[0]}-part1",
f"{mnt_dir}/boot",
),
)
bash_wrapper(boot_partition)
bash_wrapper(f"nixos-generate-config --root {mnt_dir}")
run_output(("nixos-generate-config", "--root", mnt_dir))
create_nix_hardware_file(mnt_dir, disks, encrypt)
@@ -249,18 +279,25 @@ def installer(
) -> None:
"""Main."""
logger.info("Starting installation")
require_commands(REQUIRED_COMMANDS)
disks = tuple(sorted(disks))
for disk in disks:
partition_disk(disk, swap_size, reserve)
test = Popen(("printf", f"'{encrypt_key}'"), stdout=PIPE)
if encrypt_key:
sleep(1)
for command in (
f"cryptsetup luksFormat --type luks2 {disk}-part2 -",
f"cryptsetup luksOpen {disk}-part2 luks-root-pool-{disk.split('/')[-1]}-part2 -",
):
run(command, check=True, stdin=test.stdout)
key_input = encrypt_key.encode()
run(
("cryptsetup", "luksFormat", "--type", "luks2", f"{disk}-part2", "-"),
input=key_input,
check=True,
)
run(
("cryptsetup", "luksOpen", f"{disk}-part2", f"luks-root-pool-{disk.split('/')[-1]}-part2", "-"),
input=key_input,
check=True,
)
# Fixed mount point for the new system; the installer runs as root on a fresh disk
mnt_dir = "/tmp/nix_install" # noqa: S108
@@ -281,14 +318,32 @@ def installer(
logger.info("Installation complete")
def main() -> None:
def main(argv: Sequence[str] | None = None) -> None:
"""Main."""
configure_logger("DEBUG")
parser = ArgumentParser(description="Install this NixOS configuration onto a ZFS root pool.")
parser.add_argument(
"--check",
action="store_true",
help="validate that the live environment has the external installer commands and exit",
)
parser.add_argument("--log-level", default=getenv("LOG_LEVEL", "DEBUG"), help="Python log level")
args = parser.parse_args(argv)
configure_logger(args.log_level)
if args.check:
require_commands(REQUIRED_COMMANDS)
logger.info("installer runtime dependencies are available")
return
state = curses.wrapper(draw_menu)
encrypt_key = getenv("ENCRYPT_KEY")
if not state.selected_device_ids:
logger.error("No disks selected; exiting without installing")
sys.exit(1)
logger.info("installing_nixos")
logger.info(f"disks: {state.selected_device_ids}")
logger.info(f"swap_size: {state.swap_size}")