From c6ccd625500295b55b0e2133a8274b6f68b9643d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Thu, 21 May 2026 16:36:08 -0400 Subject: [PATCH] Wire archinstall adapter + concrete phase implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunks 2-6 of the C3 refactor. With the adapter in place, the phase list collapses from 12 stubs to 5 real phases: prepare_live parse user_*.json into archinstall handlers arch_install partition + base + bootloader + limine config + early omarchy + useradd + runtime omarchy (all inside a single Installer context manager) run_chroot_finalizer arch-chroot -u $user → finalize.sh validate_boot halt if /boot/limine.conf, /etc/kernel/cmdline, or UKI are missing/malformed finish reboot prompt Key reordering vs upstream guided.py: write_limine_config runs BETWEEN add_bootloader and the first add_additional_packages call. By the time omarchy-limine pulls limine-mkinitcpio-hook (in the runtime package install), /etc/default/limine and /etc/kernel/cmdline are in place, so the UKI is built correctly on the first try — no stale-UKI purge, no follow-up limine-update. add_additional_packages is split into two calls so /etc/skel is populated (via omarchy-settings in EARLY_PACKAGES) BEFORE useradd: EARLY_PACKAGES = base-devel git omarchy-keyring omarchy-settings omarchy-installer → installer.create_users(users) → runtime: omarchy + omarchy-base.packages archinstall_adapter.py is the ONLY module that imports from archinstall. If archinstall's API churns, the blast radius is contained here. Tested against archinstall 4.3 / Python 3.14. Limine template lookup tries the new install/assets/limine/ path first and falls back to the legacy default/limine/ location during the template-ownership migration (lands in a follow-up chunk). --- install/orchestrator/archinstall_adapter.py | 111 ++++++-- install/orchestrator/context.py | 13 +- install/orchestrator/main.py | 26 +- install/orchestrator/phases_impl.py | 264 +++++++++++++++++--- 4 files changed, 342 insertions(+), 72 deletions(-) diff --git a/install/orchestrator/archinstall_adapter.py b/install/orchestrator/archinstall_adapter.py index 3149bef7..c4747c31 100644 --- a/install/orchestrator/archinstall_adapter.py +++ b/install/orchestrator/archinstall_adapter.py @@ -1,37 +1,116 @@ """Thin compatibility wall around the archinstall Python library. -ONLY this module imports from archinstall. Everything else uses these functions. +ONLY this module imports from archinstall. Everything else uses these helpers. If archinstall's API churns, the blast radius is contained here. Tested against archinstall 4.3 (Python 3.14). + +The canonical call sequence (mirrored from archinstall.scripts.guided.py) is: + + FilesystemHandler(disk_config).perform_filesystem_operations() + with Installer(mountpoint, disk_config, kernels=, silent=) as inst: + inst.mount_ordered_layout() + inst.sanity_check(offline=, skip_ntp=, skip_wkd=) + inst.generate_key_files() # encrypted only + inst.set_mirrors(handler, mirror_config, on_target=False) + inst.minimal_installation(...) # base + linux pacstrap + inst.set_mirrors(handler, mirror_config, on_target=True) + inst.setup_swap(algo=...) + inst.add_bootloader(bootloader, uki, removable) + inst.create_users(users) + inst.add_additional_packages(packages) + inst.set_timezone(tz) + inst.activate_time_synchronization() + inst.set_user_password(root_user) + inst.enable_service(services) + inst.genfstab() + +Our orchestrator interleaves `write_limine_config` between `add_bootloader` +and the first `add_additional_packages` so the limine UKI hook fires once, +correctly, on its first install. """ from __future__ import annotations -# Phase 2 will populate this module. For now we declare the contract so the -# rest of the orchestrator can be wired up against the eventual surface area. +import os +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +# Imports are top-level so a missing/incompatible archinstall surfaces at +# orchestrator startup, not deep inside a phase. +from archinstall.lib.args import ArchConfig, ArchConfigHandler +from archinstall.lib.authentication.authentication_handler import AuthenticationHandler +from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.disk.filesystem import FilesystemHandler +from archinstall.lib.installer import Installer +from archinstall.lib.mirror.mirror_handler import MirrorListHandler +from archinstall.lib.models import Bootloader +from archinstall.lib.models.device import DiskLayoutType, EncryptionType +from archinstall.lib.models.users import User -def prepare_live() -> None: - """pacman-key init/populate, mount checks, etc. Currently a no-op stub.""" - raise NotImplementedError("populated in Chunk 2") +def load_arch_config(config_path: Path, creds_path: Path) -> ArchConfigHandler: + """Build an ArchConfigHandler from on-disk JSON. archinstall reads the + paths via env vars, so we set them before instantiating the handler.""" + os.environ["ARCHINSTALL_CONFIG"] = str(config_path) + os.environ["ARCHINSTALL_CREDS"] = str(creds_path) + return ArchConfigHandler() -def cleanup_disk() -> None: - raise NotImplementedError("populated in Chunk 2") +def make_mirror_handler(offline: bool = True) -> MirrorListHandler: + return MirrorListHandler(offline=offline, verbose=False) -def create_partitions_and_mounts(install_ctx) -> None: - raise NotImplementedError("populated in Chunk 2") +def perform_filesystem_operations(arch_config: ArchConfig) -> None: + """Partition, format, encrypt. archinstall's FilesystemHandler is its own + object (separate from Installer) so we run it before opening the + Installer context manager.""" + if not arch_config.disk_config: + raise RuntimeError("disk_config missing from arch config") + FilesystemHandler(arch_config.disk_config).perform_filesystem_operations() -def install_base_system(install_ctx) -> None: - raise NotImplementedError("populated in Chunk 2") +@contextmanager +def open_installer( + arch_config: ArchConfig, + mountpoint: Path, + silent: bool = True, +) -> Iterator[Installer]: + """Yield an open Installer; ensures __exit__ runs even on exception so + /mnt is left clean for a retry.""" + if not arch_config.disk_config: + raise RuntimeError("disk_config missing from arch config") + with Installer( + str(mountpoint), + arch_config.disk_config, + kernels=arch_config.kernels, + silent=silent, + ) as installer: + yield installer -def install_limine_bootloader(install_ctx) -> None: - raise NotImplementedError("populated in Chunk 2") +def is_encrypted(arch_config: ArchConfig) -> bool: + disk = arch_config.disk_config + if not disk or not disk.disk_encryption: + return False + return disk.disk_encryption.encryption_type != EncryptionType.NO_ENCRYPTION -def create_users(install_ctx) -> None: - raise NotImplementedError("populated in Chunk 2") +def is_pre_mount(arch_config: ArchConfig) -> bool: + return bool( + arch_config.disk_config + and arch_config.disk_config.config_type == DiskLayoutType.Pre_mount + ) + + +def is_limine(arch_config: ArchConfig) -> bool: + bl = arch_config.bootloader_config + return bool(bl and bl.bootloader == Bootloader.Limine) + + +def root_user(arch_config: ArchConfig) -> User | None: + auth = arch_config.auth_config + if not auth or not auth.root_enc_password: + return None + return User("root", auth.root_enc_password, False) diff --git a/install/orchestrator/context.py b/install/orchestrator/context.py index 65d76313..c85ee504 100644 --- a/install/orchestrator/context.py +++ b/install/orchestrator/context.py @@ -1,13 +1,16 @@ -"""Install context: parsed configurator output + invocation paths.""" +"""Install context: parsed configurator output, invocation paths, and a +mutable `state` dict for objects that live across phases (e.g., the +archinstall config handler and mirror list handler).""" from __future__ import annotations import json -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from typing import Any -@dataclass(frozen=True) +@dataclass class InstallContext: config_path: Path creds_path: Path @@ -24,6 +27,10 @@ class InstallContext: log_path: Path = Path("/var/log/omarchy-install.log") target_log_path: Path = Path("/mnt/var/log/omarchy-install.log") + # Mutable per-run state shared across phases (e.g., 'arch_config_handler', + # 'mirror_handler'). Phases populate as needed; later phases read. + state: dict[str, Any] = field(default_factory=dict) + @classmethod def from_args(cls, args) -> "InstallContext": config_path = Path(args.config) diff --git a/install/orchestrator/main.py b/install/orchestrator/main.py index 3c261e9e..38f93225 100644 --- a/install/orchestrator/main.py +++ b/install/orchestrator/main.py @@ -33,32 +33,18 @@ def build_phases(): """ from .phases_impl import ( prepare_live, - cleanup_disk, - partition_and_mount, - install_base, - install_bootloader, - write_limine_config, - install_early_omarchy_packages, - create_user, - install_omarchy_runtime, + arch_install, run_chroot_finalizer, validate_boot, finish, ) return [ - ("Preparing live environment", prepare_live), - ("Cleaning install disk", cleanup_disk), - ("Partitioning + mounting", partition_and_mount), - ("Installing base system", install_base), - ("Installing bootloader", install_bootloader), - ("Writing Limine config", write_limine_config), - ("Installing Omarchy keyring + settings", install_early_omarchy_packages), - ("Creating user", create_user), - ("Installing Omarchy runtime", install_omarchy_runtime), - ("Finalizing in chroot", run_chroot_finalizer), - ("Validating boot setup", validate_boot), - ("Finishing", finish), + ("Preparing live environment", prepare_live), + ("Installing Arch + Omarchy", arch_install), + ("Finalizing in chroot", run_chroot_finalizer), + ("Validating boot setup", validate_boot), + ("Finishing", finish), ] diff --git a/install/orchestrator/phases_impl.py b/install/orchestrator/phases_impl.py index a0c8cd56..dde1cb57 100644 --- a/install/orchestrator/phases_impl.py +++ b/install/orchestrator/phases_impl.py @@ -1,63 +1,261 @@ """Concrete phase implementations. -Each function takes the InstallContext and either returns or raises. Phases are -small wrappers — heavy lifting lives in archinstall_adapter (for Arch substrate -work) and helpers.* (for Omarchy-specific work). +Phase ordering: -Most are stubbed until Chunks 2–6 land. Keeping them here so the phase wiring -in main.py is testable end-to-end as a smoke check ('every phase imports -cleanly') from Chunk 1 onwards. + prepare_live → live ISO env + load arch config + arch_install → archinstall-driven install (partition, base, + bootloader, write limine config, early omarchy + pkgs, useradd, runtime omarchy pkgs) + run_chroot_finalizer → arch-chroot finalize.sh as the install user + validate_boot → assert UKI / limine.conf / kernel cmdline are sane + finish → reboot prompt + +Heavy lifting in arch_install lives in archinstall_adapter (for the Installer +context manager) and in this file (for our limine-config write + omarchy +package selection). Other phases are kept small. """ from __future__ import annotations +import os +import subprocess +from pathlib import Path + from . import archinstall_adapter as arch from .context import InstallContext +from .ui import info +# Packages installed BEFORE useradd. omarchy-settings populates /etc/skel so +# the user's home gets seeded correctly; omarchy-installer ships the install/ +# tree + finalize.sh that the in-chroot finalizer needs. +EARLY_PACKAGES = [ + "base-devel", + "git", + "omarchy-keyring", + "omarchy-settings", + "omarchy-installer", +] + + +# ───────────────────────────────────────────────────────────────────────────── +# prepare_live: parse user_configuration.json/user_credentials.json, build the +# archinstall handlers. Cached on ctx.state for downstream phases. +# ───────────────────────────────────────────────────────────────────────────── + def prepare_live(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 2") + ctx.state["arch_config_handler"] = arch.load_arch_config( + ctx.config_path, ctx.creds_path + ) + ctx.state["mirror_handler"] = arch.make_mirror_handler(offline=True) -def cleanup_disk(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 2") +# ───────────────────────────────────────────────────────────────────────────── +# arch_install: everything inside a single Installer context manager. Mirrors +# guided.py's perform_installation() but reorders so our limine config write +# lands between add_bootloader and the first add_additional_packages call, and +# user creation happens AFTER early omarchy packages populate /etc/skel. +# ───────────────────────────────────────────────────────────────────────────── + +def arch_install(ctx: InstallContext) -> None: + handler = ctx.state["arch_config_handler"] + mirror_handler = ctx.state["mirror_handler"] + config = handler.config + + info("› partitioning + formatting + encrypting") + arch.perform_filesystem_operations(config) + + info("› opening installer context") + with arch.open_installer(config, ctx.target, silent=True) as installer: + if not arch.is_pre_mount(config): + installer.mount_ordered_layout() + + installer.sanity_check( + offline=True, + skip_ntp=True, + skip_wkd=True, + ) + + if not arch.is_pre_mount(config) and arch.is_encrypted(config): + installer.generate_key_files() + + if config.mirror_config: + installer.set_mirrors(mirror_handler, config.mirror_config, on_target=False) + + info("› installing base system") + installer.minimal_installation( + optional_repositories=( + config.mirror_config.optional_repositories + if config.mirror_config else [] + ), + mkinitcpio=True, + hostname=config.hostname, + locale_config=config.locale_config, + pacman_config=config.pacman_config, + ) + + if config.mirror_config: + installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True) + + if config.swap and config.swap.enabled: + installer.setup_swap(algo=config.swap.algorithm) + + info("› installing bootloader (Limine)") + if config.bootloader_config: + installer.add_bootloader( + config.bootloader_config.bootloader, + config.bootloader_config.uki, + config.bootloader_config.removable, + ) + + info("› writing Limine config (so limine-mkinitcpio-hook fires correctly)") + _write_limine_defaults(ctx) + + info(f"› installing early Omarchy packages: {', '.join(EARLY_PACKAGES)}") + installer.add_additional_packages(EARLY_PACKAGES) + + info("› creating user (with /etc/skel populated)") + if config.auth_config and config.auth_config.users: + installer.create_users(config.auth_config.users) + + info("› installing Omarchy runtime + omarchy-base.packages") + runtime_pkgs = _runtime_package_list(ctx) + installer.add_additional_packages(runtime_pkgs) + + # Standard arch finishers. + if config.timezone: + installer.set_timezone(config.timezone) + if config.ntp: + installer.activate_time_synchronization() + if root := arch.root_user(config): + installer.set_user_password(root) + + installer.genfstab() -def partition_and_mount(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 2") +# ───────────────────────────────────────────────────────────────────────────── +# Limine config write — extracted from install/login/limine-snapper.sh logic. +# Reads cmdline from /mnt/boot/limine.conf (which add_bootloader wrote), +# substitutes @@CMDLINE@@ into the template, writes /etc/default/limine + +# /etc/kernel/cmdline. +# ───────────────────────────────────────────────────────────────────────────── + +def _write_limine_defaults(ctx: InstallContext) -> None: + if not arch.is_limine(ctx.state["arch_config_handler"].config): + return + + limine_conf = ctx.target / "boot" / "limine.conf" + if not limine_conf.exists(): + raise RuntimeError(f"{limine_conf} not found after add_bootloader") + + cmdline = _extract_cmdline(limine_conf) + if not cmdline.strip(): + raise RuntimeError("Could not extract kernel cmdline from limine.conf") + if "root=" not in cmdline: + raise RuntimeError(f"Extracted cmdline has no root=: {cmdline!r}") + + # The template lives in omarchy-installer (this package), so it's + # available from /mnt/usr/share/omarchy/... as soon as the early + # omarchy-installer pacstrap completes — but we want it BEFORE that. + # Read from our own runtime tree on the live ISO instead. + template = ctx.omarchy_path / "install" / "assets" / "limine" / "default.conf" + if not template.exists(): + # Fallback to the legacy path while we migrate templates between packages. + template = ctx.omarchy_path / "default" / "limine" / "default.conf" + if not template.exists(): + raise RuntimeError(f"Limine template not found at {template}") + + default_limine = ctx.target / "etc" / "default" / "limine" + default_limine.parent.mkdir(parents=True, exist_ok=True) + default_limine.write_text(template.read_text().replace("@@CMDLINE@@", cmdline)) + + kernel_cmdline = ctx.target / "etc" / "kernel" / "cmdline" + kernel_cmdline.parent.mkdir(parents=True, exist_ok=True) + kernel_cmdline.write_text(cmdline + "\n") -def install_base(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 2") +def _extract_cmdline(limine_conf: Path) -> str: + for line in limine_conf.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("cmdline:"): + return stripped[len("cmdline:"):].strip() + return "" -def install_bootloader(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 2") +def _runtime_package_list(ctx: InstallContext) -> list[str]: + """omarchy + every package in install/omarchy-base.packages that isn't + already in EARLY_PACKAGES.""" + base_pkgs_file = ctx.omarchy_path / "install" / "omarchy-base.packages" + pkgs = ["omarchy"] + early = set(EARLY_PACKAGES) + for raw in base_pkgs_file.read_text().splitlines(): + s = raw.strip() + if not s or s.startswith("#"): + continue + if s not in early and s not in pkgs: + pkgs.append(s) + return pkgs -def write_limine_config(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 3") - - -def install_early_omarchy_packages(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 4") - - -def create_user(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 2") - - -def install_omarchy_runtime(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 4") - +# ───────────────────────────────────────────────────────────────────────────── +# run_chroot_finalizer: arch-chroot -u $user into /mnt and run finalize.sh. +# Inherits stdout/stderr so the in-target output streams to our log capture. +# ───────────────────────────────────────────────────────────────────────────── def run_chroot_finalizer(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 5") + env_extras = [ + f"OMARCHY_INSTALL_MODE=offline", + f"OMARCHY_USER_NAME={ctx.full_name}", + f"OMARCHY_USER_EMAIL={ctx.email}", + f"USER={ctx.username}", + f"HOME=/home/{ctx.username}", + ] + cmd = [ + "arch-chroot", + "-u", ctx.username, + str(ctx.target), + "env", "--unset=XDG_RUNTIME_DIR", + *env_extras, + "/bin/bash", "-lc", + f"bash {ctx.omarchy_path}/finalize.sh", + ] + subprocess.run(cmd, check=True) +# ───────────────────────────────────────────────────────────────────────────── +# validate_boot: hard checks before reboot. If the install ran but produced +# a UKI that can't actually boot, we want to halt here, not surprise the user. +# ───────────────────────────────────────────────────────────────────────────── + def validate_boot(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 6") + limine_conf = ctx.target / "boot" / "limine.conf" + if not limine_conf.exists(): + raise RuntimeError(f"{limine_conf} missing") + content = limine_conf.read_text() + if "^/+Omarchy" not in content and "Omarchy" not in content: + raise RuntimeError("/boot/limine.conf has no Omarchy entry") + + if ctx.encrypt and "cryptdevice=" not in content: + raise RuntimeError("Encrypted install but /boot/limine.conf has no cryptdevice=") + + kernel_cmdline = ctx.target / "etc" / "kernel" / "cmdline" + if not kernel_cmdline.exists(): + raise RuntimeError(f"{kernel_cmdline} missing — UKI would have no cmdline") + + uki_dir = ctx.target / "boot" / "EFI" / "Linux" + if uki_dir.exists(): + ukis = list(uki_dir.glob("*_linux*.efi")) + if not ukis: + raise RuntimeError(f"No UKI found in {uki_dir}") + + +# ───────────────────────────────────────────────────────────────────────────── +# finish: show completion + offer reboot. No mutation. +# ───────────────────────────────────────────────────────────────────────────── def finish(ctx: InstallContext) -> None: - raise NotImplementedError("Chunk 6") + from .ui import confirm + info("Installation finished. Reboot when ready.") + if confirm("Reboot now?", default=True): + os.system("reboot")