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.
185 lines
5.7 KiB
Python
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()
|