Files
dotfiles/python/installer/build.py
Richie b252b0bf6e 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.
2026-07-02 14:17:49 -04:00

185 lines
5.7 KiB
Python

"""Build the one-file installer binary."""
from __future__ import annotations
import logging
import os
import shutil
import stat
import subprocess
from argparse import ArgumentParser, Namespace
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
logger = logging.getLogger(__name__)
BINARY_NAME = "nixos-installer"
DEFAULT_INTERPRETER = "/lib64/ld-linux-x86-64.so.2"
INSTALLER_SOURCE_FILES = (
Path("python/__init__.py"),
Path("python/logging_config.py"),
Path("python/process.py"),
Path("python/installer/__init__.py"),
Path("python/installer/__main__.py"),
Path("python/installer/tui.py"),
)
class InstallerBuildError(RuntimeError):
"""Raised when the installer binary cannot be built."""
class MissingSourceFileError(InstallerBuildError):
"""Raised when a required source file is missing."""
def __init__(self, path: Path) -> None:
"""Store the missing path."""
super().__init__(f"Required installer source file is missing: {path}")
self.path = path
class MissingToolError(InstallerBuildError):
"""Raised when a required build tool is missing."""
def __init__(self, tool: str) -> None:
"""Store the missing tool name."""
super().__init__(f"Required build tool is missing from PATH: {tool}")
self.tool = tool
def repo_root() -> Path:
"""Return the repository root for direct script usage."""
return Path(__file__).resolve().parents[2]
def require_tool(tool: str) -> str:
"""Return the path to a tool or raise."""
tool_path = shutil.which(tool)
if tool_path is None:
raise MissingToolError(tool)
return tool_path
def copy_installer_source(source_root: Path, destination: Path) -> None:
"""Copy only the installer files into a minimal staging tree."""
if destination.exists():
shutil.rmtree(destination)
destination.mkdir(parents=True)
for relative_path in INSTALLER_SOURCE_FILES:
source = source_root / relative_path
if not source.is_file():
raise MissingSourceFileError(source)
target = destination / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def run_command(command: Sequence[str], *, env: dict[str, str] | None = None) -> None:
"""Run a build command."""
logger.info("running command=%s", command)
subprocess.run(command, check=True, env=env)
def pyinstaller_environment(staged_source: Path, build_root: Path) -> dict[str, str]:
"""Return environment variables for PyInstaller."""
env = os.environ.copy()
home = build_root / "home"
home.mkdir(parents=True, exist_ok=True)
env["HOME"] = str(home)
if existing_pythonpath := env.get("PYTHONPATH"):
env["PYTHONPATH"] = f"{staged_source}{os.pathsep}{existing_pythonpath}"
else:
env["PYTHONPATH"] = str(staged_source)
return env
def build_installer(
*,
source_root: Path,
build_root: Path,
output: Path,
interpreter: str,
patch_elf: bool,
) -> Path:
"""Build the one-file installer binary."""
pyinstaller = require_tool("pyinstaller")
if patch_elf:
patchelf = require_tool("patchelf")
source_root = source_root.resolve()
build_root = build_root.resolve()
output = output.resolve()
staged_source = build_root / "source"
dist_dir = build_root / "dist"
work_dir = build_root / "work"
spec_dir = build_root / "spec"
copy_installer_source(source_root, staged_source)
output.parent.mkdir(parents=True, exist_ok=True)
run_command(
(
pyinstaller,
"--clean",
"--onefile",
"--name",
BINARY_NAME,
"--paths",
str(staged_source),
"--distpath",
str(dist_dir),
"--workpath",
str(work_dir),
"--specpath",
str(spec_dir),
str(staged_source / "python/installer/__main__.py"),
),
env=pyinstaller_environment(staged_source, build_root),
)
built_binary = dist_dir / BINARY_NAME
shutil.copy2(built_binary, output)
output.chmod(output.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
if patch_elf:
run_command((patchelf, "--set-interpreter", interpreter, "--remove-rpath", str(output)))
return output
def parse_args(argv: Sequence[str] | None = None) -> Namespace:
"""Parse command-line arguments."""
parser = ArgumentParser(description="Build the one-file NixOS installer binary.")
parser.add_argument("--source-root", type=Path, default=repo_root(), help="repo or staged source root")
parser.add_argument("--build-root", type=Path, default=Path("build/nixos-installer"), help="temporary build root")
parser.add_argument("--output", type=Path, default=Path("dist/nixos-installer"), help="output binary path")
parser.add_argument("--interpreter", default=DEFAULT_INTERPRETER, help="ELF interpreter path for the USB binary")
parser.add_argument("--skip-patchelf", action="store_true", help="do not patch the final ELF binary")
parser.add_argument("--log-level", default="INFO", help="Python log level")
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> None:
"""Build the installer binary from the command line."""
args = parse_args(argv)
logging.basicConfig(level=args.log_level, format="%(levelname)s %(message)s")
build_installer(
source_root=args.source_root,
build_root=args.build_root,
output=args.output,
interpreter=args.interpreter,
patch_elf=not args.skip_patchelf,
)
if __name__ == "__main__":
main()