Files
dotfiles/python/installer/__main__.py

392 lines
12 KiB
Python

"""Install NixOS on a ZFS pool."""
from __future__ import annotations
import curses
import logging
import sys
from argparse import ArgumentParser
from os import environ, getenv
from pathlib import Path
from random import getrandbits
from subprocess import run
from time import sleep
from typing import TYPE_CHECKING
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__)
REQUIRED_COMMANDS = (
"blkdiscard",
"cryptsetup",
"find",
"lsblk",
"mkfs.vfat",
"mount",
"nixos-generate-config",
"nixos-install",
"parted",
"readlink",
"zfs",
"zpool",
)
def configure_terminal() -> None:
"""Force a terminal type every live system has a terminfo entry for.
Terminals such as kitty advertise TERM values (xterm-kitty) that live
systems have no terminfo entry for, so setupterm fails before the TUI
can start.
"""
environ["TERM"] = "xterm-256color"
if getenv("TERMINFO") or getenv("TERMINFO_DIRS"):
return
terminfo_fallback_directories = (
Path("/run/current-system/sw/share/terminfo"),
Path("/usr/share/terminfo"),
Path("/etc/terminfo"),
Path("/lib/terminfo"),
)
existing_directories = [str(directory) for directory in terminfo_fallback_directories if directory.is_dir()]
if existing_directories:
environ["TERMINFO_DIRS"] = ":".join(existing_directories)
def partition_disk(disk: str, swap_size: int, reserve: int = 0) -> None:
"""Partition a disk.
Args:
disk (str): The disk to partition.
swap_size (int): The size of the swap partition in GB.
minimum value is 1.
reserve (int, optional): The size of the reserve partition in GB. Defaults to 0.
minimum value is 0.
"""
logger.info(f"partitioning {disk=}")
swap_size = max(swap_size, 1)
reserve = max(reserve, 0)
run_output(("blkdiscard", "-f", disk))
if reserve > 0:
msg = f"Creating swap partition on {disk=} with size {swap_size=}GiB and reserve {reserve=}GiB"
logger.info(msg)
swap_start = swap_size + reserve
swap_partition = f"mkpart swap -{swap_start}GiB -{reserve}GiB "
else:
logger.info(f"Creating swap partition on {disk=} with size {swap_size=}GiB")
swap_start = swap_size
swap_partition = f"mkpart swap -{swap_start}GiB 100% "
logger.debug(f"{swap_partition=}")
create_partitions = (
"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",
)
run_output(create_partitions)
logger.info(f"{disk=} successfully partitioned")
def create_zfs_pool(pool_disks: Sequence[str], mnt_dir: str) -> None:
"""Create a ZFS pool.
Args:
pool_disks (Sequence[str]): A tuple of disks to use for the pool.
mnt_dir (str): The mount directory.
"""
if len(pool_disks) <= 0:
error = "disks must be a tuple of at least length 1"
raise ValueError(error)
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.append(pool_disks[0])
else:
zpool_create.append("mirror")
zpool_create.extend(pool_disks)
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)
def create_zfs_datasets() -> None:
"""Create ZFS datasets."""
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",
"root_pool/home",
"root_pool/var",
"root_pool/nix",
}
missing_datasets = expected_datasets.difference(datasets.splitlines())
if missing_datasets:
logger.critical(f"Failed to create pools {missing_datasets}")
sys.exit(1)
def get_cpu_manufacturer() -> str:
"""Get the CPU manufacturer."""
output = Path("/proc/cpuinfo").read_text()
id_vendor = {"AuthenticAMD": "amd", "GenuineIntel": "intel"}
for line in output.splitlines():
if "vendor_id" in line:
return id_vendor[line.split(": ")[1].strip()]
error = "Failed to get CPU manufacturer"
raise RuntimeError(error)
def get_boot_drive_id(disk: str) -> str:
"""Get the boot drive ID."""
output = run_output(("lsblk", "-o", "UUID", f"{disk}-part1"))
return output.splitlines()[1]
def create_nix_hardware_file(mnt_dir: str, disks: Sequence[str], encrypt: str | None) -> None:
"""Create a NixOS hardware file."""
cpu_manufacturer = get_cpu_manufacturer()
devices = ""
if encrypt:
disk = disks[0]
devices = (
f' luks.devices."luks-root-pool-{disk.split("/")[-1]}-part2"'
"= {\n"
f' device = "{disk}-part2";\n'
" bypassWorkqueues = true;\n"
" allowDiscards = true;\n"
" };\n"
)
host_id = format(getrandbits(32), "08x")
nix_hardware = (
"{ config, lib, modulesPath, ... }:\n"
"{\n"
' imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];\n\n'
" boot = {\n"
" initrd = {\n"
' availableKernelModules = [ \n "ahci"\n "ehci_pci"\n "nvme"\n "sd_mod"\n'
' "usb_storage"\n "usbhid"\n "xhci_pci"\n ];\n'
" kernelModules = [ ];\n"
f" {devices}"
" };\n"
f' kernelModules = [ "kvm-{cpu_manufacturer}" ];\n'
" extraModulePackages = [ ];\n"
" };\n\n"
" fileSystems = {\n"
' "/" = lib.mkDefault {\n device = "root_pool/root";\n fsType = "zfs";\n };\n\n'
' "/home" = {\n device = "root_pool/home";\n fsType = "zfs";\n };\n\n'
' "/var" = {\n device = "root_pool/var";\n fsType = "zfs";\n };\n\n'
' "/nix" = {\n device = "root_pool/nix";\n fsType = "zfs";\n };\n\n'
' "/boot" = {\n'
f' device = "/dev/disk/by-uuid/{get_boot_drive_id(disks[0])}";\n'
' fsType = "vfat";\n options = [\n "fmask=0077"\n'
' "dmask=0077"\n ];\n };\n };\n\n'
" swapDevices = [ ];\n\n"
" networking.useDHCP = lib.mkDefault true;\n\n"
' nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";\n'
f" hardware.cpu.{cpu_manufacturer}.updateMicrocode = "
"lib.mkDefault config.hardware.enableRedistributableFirmware;\n"
f' networking.hostId = "{host_id}";\n'
"}\n"
)
Path(f"{mnt_dir}/etc/nixos/hardware-configuration.nix").write_text(nix_hardware)
def install_nixos(mnt_dir: str, disks: Sequence[str], encrypt: str | None) -> None:
"""Install NixOS."""
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:
run_output(("mkfs.vfat", "-n", "EFI", f"{disk}-part1"))
# set up mirroring afterwards if more than one disk
run_output(
(
"mount",
"-t",
"vfat",
"-o",
"fmask=0077,dmask=0077,iocharset=iso8859-1,X-mount.mkdir",
f"{disks[0]}-part1",
f"{mnt_dir}/boot",
),
)
run_output(("nixos-generate-config", "--root", mnt_dir))
create_nix_hardware_file(mnt_dir, disks, encrypt)
run(("nixos-install", "--root", mnt_dir), check=True)
def installer(
disks: Sequence[str],
swap_size: int,
reserve: int,
encrypt_key: str | None,
) -> None:
"""Main."""
logger.info("Starting installation")
require_commands(REQUIRED_COMMANDS)
disks = tuple(sorted(disks))
for disk in disks:
partition_disk(disk, swap_size, reserve)
if encrypt_key:
sleep(1)
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,
)
# nixos-install rejects mount points under world-writable paths like /tmp
mnt_dir = "/mnt"
Path(mnt_dir).mkdir(parents=True, exist_ok=True)
if encrypt_key:
pool_disks = [f"/dev/mapper/luks-root-pool-{disk.split('/')[-1]}-part2" for disk in disks]
else:
pool_disks = [f"{disk}-part2" for disk in disks]
create_zfs_pool(pool_disks, mnt_dir)
create_zfs_datasets()
install_nixos(mnt_dir, disks, encrypt_key)
logger.info("Installation complete")
def main(argv: Sequence[str] | None = None) -> None:
"""Main."""
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
configure_terminal()
state = curses.wrapper(draw_menu)
encrypt_key = getenv("ENCRYPT_KEY")
if not encrypt_key:
encrypt_key = state.encryption_password
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}")
logger.info(f"reserve: {state.reserve_size}")
logger.info(f"encrypted: {bool(encrypt_key)}")
sleep(3)
installer(
disks=state.get_selected_devices(),
swap_size=state.swap_size,
reserve=state.reserve_size,
encrypt_key=encrypt_key,
)
if __name__ == "__main__":
main()