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