diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index c85ee50..910eb13 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -35,20 +35,37 @@ class InstallContext: def from_args(cls, args) -> "InstallContext": config_path = Path(args.config) creds_path = Path(args.creds) - return cls( + user_configuration = json.loads(config_path.read_text()) + ctx = cls( config_path=config_path, creds_path=creds_path, full_name=_read_text(args.full_name_file), email=_read_text(args.email_file), encrypt=_read_text(args.encrypt_file).lower() in ("true", "yes", "1"), - user_configuration=json.loads(config_path.read_text()), + user_configuration=user_configuration, user_credentials=json.loads(creds_path.read_text()), ) + disk_config = user_configuration.get("disk_config", {}) + if ( + disk_config.get("config_type") == "pre_mounted_config" + and disk_config.get("mountpoint") + ): + ctx.target = Path(disk_config["mountpoint"]) + return ctx @property def username(self) -> str: return self.user_credentials["users"][0]["username"] + @property + def mode(self) -> str: + cfg_type = self.user_configuration.get("disk_config", {}).get("config_type") + return "protected" if cfg_type == "pre_mounted_config" else "full_disk" + + @property + def is_protected(self) -> bool: + return self.mode == "protected" + def _read_text(path: str | None) -> str: if not path: diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index fa33ac8..85fe56b 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -24,28 +24,49 @@ from .phases import PhaseError, run from .ui import error, info -def build_phases(): +def build_phases(ctx: InstallContext): """Phase order. Each entry is (display name, callable taking InstallContext). The ordering is the whole point of this orchestrator: package-install hooks (limine-mkinitcpio-hook, in particular) and useradd happen at points where their prerequisites are guaranteed to be in place. + + Two modes: + - full_disk: archinstall owns disk layout + bootloader. + - protected: Omarchy owns disk layout + bootloader; archinstall is + used for pacstrap + users + packages only. """ from .phases_impl import ( prepare_live, - arch_install, + arch_install_full, + arch_install_base, + verify_protected_mounts, + configure_protected_boot, run_chroot_finalizer, configure_login, - validate_boot, + validate_boot_full, + validate_boot_protected, finish, ) + if ctx.is_protected: + return [ + ("Preparing live environment", prepare_live), + ("Verifying protected mounts", verify_protected_mounts), + ("Installing base system", arch_install_base), + ("Configuring protected boot", configure_protected_boot), + ("Finalizing in chroot", run_chroot_finalizer), + ("Configuring login", configure_login), + ("Validating protected boot setup", validate_boot_protected), + ("Finishing", finish), + ] + return [ ("Preparing live environment", prepare_live), - ("Installing Arch + Omarchy", arch_install), + ("Installing Arch + Omarchy", arch_install_full), ("Finalizing in chroot", run_chroot_finalizer), ("Configuring login", configure_login), - ("Validating boot setup", validate_boot), + ("Validating boot setup", validate_boot_full), ("Finishing", finish), ] @@ -70,7 +91,7 @@ def main(argv=None) -> int: try: try: - run(ctx, build_phases()) + run(ctx, build_phases(ctx)) except PhaseError: error("Installation halted.") return 1 diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 5b5bc61..06a0b2d 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -1,21 +1,37 @@ """Concrete phase implementations. -Phase ordering: +Phase ordering (full-disk): - prepare_live → pacman-key init, disk cleanup, load configurator - handlers (archinstall patch happens in the - wrapper before Python imports archinstall) - arch_install → archinstall-driven install (partition, base, - bootloader, write limine config, early omarchy - pkgs, useradd, runtime omarchy pkgs) - run_chroot_finalizer → bind mounts + sudoers shim + arch-chroot finalize.sh - configure_login → sddm autologin for unencrypted installs - validate_boot → assert UKI / limine.conf / kernel cmdline are sane - finish → reboot prompt + prepare_live → pacman-key init, disk cleanup, load configurator + handlers (archinstall patch happens in the + wrapper before Python imports archinstall) + arch_install_full → archinstall-driven install (partition, base, + bootloader, write limine config, early omarchy + pkgs, useradd, runtime omarchy pkgs) + run_chroot_finalizer → bind mounts + sudoers shim + arch-chroot finalize.sh + configure_login → sddm autologin for unencrypted installs + validate_boot_full → assert UKI / limine.conf / kernel cmdline are sane + finish → reboot prompt + +Phase ordering (protected / pre-mounted): + + prepare_live → same, minus disk cleanup + verify_protected_mounts → confirm target + ESP are mounted; load + /root/protected_install.json into ctx.state + arch_install_base → archinstall used as pacstrap + users + + packages driver only; no bootloader, + no fstab, no mkinitcpio + configure_protected_boot → Omarchy-owned fstab/crypttab/mkinitcpio/ + bootloader — implemented in Step 8 + run_chroot_finalizer → same + configure_login → same + validate_boot_protected → implemented in Step 8 + finish → same """ from __future__ import annotations +import json import os import shutil import subprocess @@ -54,10 +70,13 @@ def prepare_live(ctx: InstallContext) -> None: subprocess.run(["pacman-key", "--populate", "omarchy"], check=True) subprocess.run(["pacman", "-Sy", "--noconfirm"], check=True) - disk = _install_disk(ctx) - if disk: - info(f"› cleaning up holders on install disk: {disk}") - subprocess.run(["omarchy-iso-cleanup-disk", disk], check=True) + if ctx.is_protected: + info("› protected mode: skipping whole-disk cleanup") + else: + disk = _install_disk(ctx) + if disk: + info(f"› cleaning up holders on install disk: {disk}") + subprocess.run(["omarchy-iso-cleanup-disk", disk], check=True) info("› loading configurator output") ctx.state["arch_config_handler"] = arch.load_arch_config( @@ -77,13 +96,14 @@ def _install_disk(ctx: InstallContext) -> str | None: # ───────────────────────────────────────────────────────────────────────────── -# arch_install: everything inside a single Installer context manager. Reorders -# guided.py's perform_installation() 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. +# arch_install_full: everything inside a single Installer context manager. +# Reorders guided.py's perform_installation() 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: +def arch_install_full(ctx: InstallContext) -> None: + """Full-disk install: archinstall owns disk layout + bootloader.""" handler = ctx.state["arch_config_handler"] mirror_handler = ctx.state["mirror_handler"] config = handler.config @@ -212,6 +232,123 @@ def _runtime_package_list(ctx: InstallContext) -> list[str]: return pkgs +# ───────────────────────────────────────────────────────────────────────────── +# verify_protected_mounts: confirm the configurator pre-mounted everything +# we need under ctx.target and load /root/protected_install.json so +# configure_protected_boot has the partition intent to act on. +# ───────────────────────────────────────────────────────────────────────────── + +PROTECTED_INTENT_PATH = Path("/root/protected_install.json") + + +def verify_protected_mounts(ctx: InstallContext) -> None: + target = ctx.target + if not _is_mountpoint(target): + raise RuntimeError(f"protected mode: {target} is not a mountpoint") + + boot_mp = target / "boot" + efi_mp = target / "efi" + if not (_is_mountpoint(boot_mp) or _is_mountpoint(efi_mp)): + raise RuntimeError( + f"protected mode: no ESP mounted under {target} (checked {boot_mp}, {efi_mp})" + ) + + if not PROTECTED_INTENT_PATH.exists(): + raise RuntimeError( + f"protected mode: expected partition intent at {PROTECTED_INTENT_PATH} " + "(configurator should have written it)" + ) + + intent = json.loads(PROTECTED_INTENT_PATH.read_text()) + for key in ("esp_mount", "esp_path", "luks_uuid", "root_device", "kernel"): + if key not in intent: + raise RuntimeError( + f"protected mode: {PROTECTED_INTENT_PATH} missing key '{key}'" + ) + ctx.state["protected"] = intent + info(f"› protected intent loaded: kernel={intent['kernel']} esp={intent['esp_mount']}") + + +def _is_mountpoint(path: Path) -> bool: + res = subprocess.run( + ["findmnt", "-rn", str(path)], + capture_output=True, + text=True, + ) + return res.returncode == 0 and bool(res.stdout.strip()) + + +# ───────────────────────────────────────────────────────────────────────────── +# arch_install_base: archinstall used as a pacstrap + base-config driver only. +# No disk layout, no bootloader, no fstab, no mkinitcpio (step 8 owns those). +# ───────────────────────────────────────────────────────────────────────────── + +def arch_install_base(ctx: InstallContext) -> None: + """Protected install base: archinstall handles pacstrap + users + packages + only. Bootloader, fstab, mkinitcpio, crypttab are owned by + configure_protected_boot.""" + handler = ctx.state["arch_config_handler"] + mirror_handler = ctx.state["mirror_handler"] + config = handler.config + + info("› opening installer context (pre-mounted target)") + with arch.open_installer(config, ctx.target, silent=True) as installer: + installer.sanity_check( + offline=True, + skip_ntp=True, + skip_wkd=True, + ) + + if config.mirror_config: + installer.set_mirrors(mirror_handler, config.mirror_config, on_target=False) + + info("› installing base system (mkinitcpio deferred to configure_protected_boot)") + installer.minimal_installation( + optional_repositories=( + config.mirror_config.optional_repositories + if config.mirror_config else [] + ), + mkinitcpio=False, + 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(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") + installer.add_additional_packages(_runtime_package_list(ctx)) + + 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) + + +# ───────────────────────────────────────────────────────────────────────────── +# configure_protected_boot: Omarchy-owned fstab/crypttab/mkinitcpio/bootloader. +# Implemented in Step 8. +# ───────────────────────────────────────────────────────────────────────────── + +def configure_protected_boot(ctx: InstallContext) -> None: + raise NotImplementedError( + "Implemented in Step 8 — see REFACTOR-PLAN-C3-EXECUTION.md" + ) + + # ───────────────────────────────────────────────────────────────────────────── # run_chroot_finalizer: # 1. point the target at the offline pacman.conf so chroot pacman uses the @@ -331,11 +468,12 @@ def configure_login(ctx: InstallContext) -> None: # ───────────────────────────────────────────────────────────────────────────── -# validate_boot: hard checks before reboot. If the install ran but produced a -# UKI that can't actually boot, halt here rather than surprise the user. +# validate_boot_full: hard checks before reboot. If the install ran but +# produced a UKI that can't actually boot, halt here rather than surprise +# the user. # ───────────────────────────────────────────────────────────────────────────── -def validate_boot(ctx: InstallContext) -> None: +def validate_boot_full(ctx: InstallContext) -> None: limine_conf = ctx.target / "boot" / "limine.conf" if not limine_conf.exists(): raise RuntimeError(f"{limine_conf} missing") @@ -358,6 +496,17 @@ def validate_boot(ctx: InstallContext) -> None: raise RuntimeError(f"No UKI found in {uki_dir}") +# ───────────────────────────────────────────────────────────────────────────── +# validate_boot_protected: hard checks for the dualboot/protected path. +# Implemented in Step 8. +# ───────────────────────────────────────────────────────────────────────────── + +def validate_boot_protected(ctx: InstallContext) -> None: + raise NotImplementedError( + "Implemented in Step 8 — see REFACTOR-PLAN-C3-EXECUTION.md" + ) + + # ───────────────────────────────────────────────────────────────────────────── # cleanup_bind_mounts: invoked from main()'s finally so bind mounts get # unwound on success, failure, or interrupt. Idempotent.