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:
@@ -0,0 +1,184 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user