diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 8e18b59..0d5bbde 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -290,6 +290,14 @@ user_step() { continue fi + # The timezone selects the region (mirrors and repositories), using the + # same lookup as the orchestrator. Global installs get no extra row. + region=$(cd /usr/share/omarchy-iso 2>/dev/null && python -m orchestrator.region "$timezone") || region="global" + region_row="" + if [[ $region != "global" ]]; then + region_row="\nRegion,$region" + fi + # Add manual padding since gum table -p doesn't respect padding echo -e "Field,Value Username,$username @@ -297,7 +305,7 @@ Password,$(printf "%${#password}s" | tr ' ' '*') Full name,${full_name:-[Skipped]} Email address,${email_address:-[Skipped]} Hostname,$hostname -Timezone,$timezone +Timezone,$timezone$region_row Keyboard,$keyboard" | gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 436bfa8..994697e 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any from .keyboard import validate_input_selection +from .region import GLOBAL, resolve_region @dataclass @@ -29,6 +30,7 @@ class InstallContext: arch_config_path: Path omarchy_install: dict[str, Any] defer_provisioning: bool = False + region: str = GLOBAL target: Path = Path("/mnt") omarchy_path: Path = Path("/usr/share/omarchy") @@ -61,6 +63,11 @@ class InstallContext: validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", "")) + # Resolved before any disk is touched, so an autoinstall naming a + # region this ISO lacks fails without wiping anything. + region = resolve_region(omarchy_install, user_configuration.get("timezone")) + omarchy_install["region"] = region + # Deferred provisioning: the whole system installs but user creation is deferred to # first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by # an `defer-provisioning` marker file on an autoinstall drive, which also replaces the @@ -125,6 +132,7 @@ class InstallContext: arch_config_path=arch_config_path, omarchy_install=omarchy_install, defer_provisioning=defer_provisioning, + region=region, state_dir=state_dir, ) disk_config = user_configuration.get("disk_config", {}) 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 e30e8c7..83b8487 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -36,6 +36,7 @@ from . import archinstall_adapter as arch from .command import capture, capture_identifier, require_text from .context import InstallContext from .keyboard import configure_keyboard +from .region import GLOBAL, region_keyrings, region_packages from .ui import error, info @@ -298,6 +299,11 @@ def arch_install_system(ctx: InstallContext) -> None: info("› installing Omarchy runtime + omarchy-base.packages") installer.add_additional_packages(_runtime_package_list(ctx)) + if region_pkgs := region_packages(ctx.region): + info(f"› installing {ctx.region} region packages: {', '.join(region_pkgs)}") + installer.add_additional_packages(region_pkgs) + _write_region_marker(ctx) + # Tailscale is bundled in the offline mirror but only installed # when an autoinstall drive staged an auth key; must happen here, # while the mirror is still bind-mounted, not in the phase that @@ -728,6 +734,17 @@ def _unmask_mkinitcpio_pacman_hooks( info(f"warning: failed to restore pacman hook mask for {name}: {exc}") +def _write_region_marker(ctx: InstallContext) -> None: + """Tell the runtime's pacman finalizer which profile to apply. Global + installs write nothing, so they stay identical to a region-less install.""" + if ctx.region == GLOBAL: + return + marker = ctx.target / "etc/omarchy/region" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(ctx.region + "\n") + marker.chmod(0o644) + + def _runtime_package_list(ctx: InstallContext) -> list[str]: """Selected Omarchy runtime package + every package in the ISO-bundled base package list that isn't already installed early.""" @@ -1135,6 +1152,14 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str def run_system_finalizer(ctx: InstallContext) -> None: + # The region's keyring packages are installed by now, but the build + # container's GPG state is not inherited. Initialize the target's trust + # database with only this region's keys before the finalizer enables its + # online repositories; global installs never trust them. + if keyrings := region_keyrings(ctx.region): + _run_target_setup_command(ctx, ["pacman-key", "--init"]) + _run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", *keyrings]) + if ctx.defer_provisioning: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] else: diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py new file mode 100644 index 0000000..ea168d2 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py @@ -0,0 +1,66 @@ +"""Region selection: which of the runtime's region profiles a target gets. + +The builder copies every profile from the bundled runtime's default/regions/ +into REGIONS_DIR, and their packages into the offline mirror. Nobody picks a +region by hand: the timezone the user already chose selects it, and an +autoinstall config may name one explicitly in omarchy_install.region. Run as +`python -m orchestrator.region ` to print the region the installer +would use, which is how the configurator fills its summary. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REGIONS_DIR = Path("/usr/share/omarchy-iso/regions") +GLOBAL = "global" + + +def _entries(path: Path) -> list[str]: + if not path.is_file(): + return [] + lines = (line.strip() for line in path.read_text().splitlines()) + return [line for line in lines if line and not line.startswith("#")] + + +def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) -> str: + regions_dir = regions_dir or REGIONS_DIR + if timezone and regions_dir.is_dir(): + for profile in sorted(regions_dir.iterdir()): + if timezone in _entries(profile / "timezones"): + return profile.name + return GLOBAL + + +def resolve_region(omarchy_install: dict, timezone: str | None, regions_dir: Path | None = None) -> str: + regions_dir = regions_dir or REGIONS_DIR + region = omarchy_install.get("region") + if not region: + return region_for_timezone(timezone, regions_dir) + # Profiles are named by ISO 3166-1 alpha-2 code; checking the shape first + # keeps a path like ".." from passing the directory test. + if region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir()): + raise RuntimeError(f"Unsupported region: {region}") + return region + + +def region_packages(region: str, regions_dir: Path | None = None) -> list[str]: + if region == GLOBAL: + return [] + return _entries((regions_dir or REGIONS_DIR) / region / "packages") + + +def region_keyrings(region: str, regions_dir: Path | None = None) -> list[str]: + """Pacman keyrings the profile ships, named the Arch way: -keyring + installs /usr/share/pacman/keyrings/.gpg.""" + return [ + package.removesuffix("-keyring") + for package in region_packages(region, regions_dir) + if package.endswith("-keyring") + ] + + +if __name__ == "__main__": + print(region_for_timezone(sys.argv[1] if len(sys.argv) > 1 else None)) diff --git a/test/unit/test_kernel_selection.py b/test/unit/test_kernel_selection.py index a366a2e..8c952bf 100644 --- a/test/unit/test_kernel_selection.py +++ b/test/unit/test_kernel_selection.py @@ -108,6 +108,7 @@ class KernelSelectionTest(unittest.TestCase): ctx = types.SimpleNamespace( state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None}, target=Path("/unused"), tailscale_authkey_path=None, omarchy_install={}, + region="global", ) for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks", "_configure_limine_boot", "_write_pre_mounted_fstab"): diff --git a/test/unit/test_region.py b/test/unit/test_region.py new file mode 100644 index 0000000..6a9bbcd --- /dev/null +++ b/test/unit/test_region.py @@ -0,0 +1,166 @@ +"""The target's region comes from its timezone, using the runtime's profiles.""" + +import json +import os +import subprocess +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso")) +sys.modules.setdefault( + "orchestrator.archinstall_adapter", + types.ModuleType("orchestrator.archinstall_adapter"), +) +from orchestrator import context, phases_impl, region # noqa: E402 + + +def write_profiles(root: Path) -> Path: + regions = root / "regions" + cn = regions / "cn" + (cn / "pacman").mkdir(parents=True) + (cn / "timezones").write_text("# China\nAsia/Shanghai\n\n Asia/Urumqi \n") + (cn / "packages").write_text("# keyring first\narchlinuxcn-keyring\nextra-tool\n") + (cn / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://example.invalid/$arch\n") + (cn / "pacman/mirrorlist.append").write_text("Server = https://example.invalid/$repo/os/$arch\n") + return regions + + +class RegionSelectionTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.regions = write_profiles(self.root) + patcher = mock.patch.object(region, "REGIONS_DIR", self.regions) + patcher.start() + self.addCleanup(patcher.stop) + + def test_timezone_selects_region(self): + for timezone, expected in [ + ("Asia/Shanghai", "cn"), + ("Asia/Urumqi", "cn"), + ("Asia/Hong_Kong", "global"), + ("America/New_York", "global"), + ("UTC", "global"), + ("# China", "global"), + ("", "global"), + (None, "global"), + ]: + with self.subTest(timezone=timezone): + self.assertEqual(region.region_for_timezone(timezone), expected) + + def test_iso_without_profiles_is_global(self): + self.assertEqual(region.region_for_timezone("Asia/Shanghai", self.root / "missing"), "global") + self.assertEqual(region.resolve_region({}, "Asia/Shanghai", self.root / "missing"), "global") + + def test_autoinstall_region_overrides_timezone(self): + self.assertEqual(region.resolve_region({"region": "cn"}, "UTC"), "cn") + self.assertEqual(region.resolve_region({"region": "global"}, "Asia/Shanghai"), "global") + self.assertEqual(region.resolve_region({}, "Asia/Shanghai"), "cn") + for bad in ("us", "CN", "..", "cn/../cn"): + with self.subTest(region=bad), self.assertRaises(RuntimeError): + region.resolve_region({"region": bad}, "UTC") + + def test_profile_packages_and_keyrings(self): + self.assertEqual(region.region_packages("cn"), ["archlinuxcn-keyring", "extra-tool"]) + self.assertEqual(region.region_keyrings("cn"), ["archlinuxcn"]) + self.assertEqual(region.region_packages("global"), []) + self.assertEqual(region.region_keyrings("global"), []) + + def from_env(self, config: dict) -> context.InstallContext: + config_path = self.root / "config.json" + creds_path = self.root / "creds.json" + config_path.write_text(json.dumps(config)) + creds_path.write_text(json.dumps({"users": [{"username": "test"}]})) + with mock.patch.dict(os.environ, { + "OMARCHY_INSTALL_CONFIG": str(config_path), + "OMARCHY_INSTALL_CREDS": str(creds_path), + "OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"), + }, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"): + return context.InstallContext.from_env() + + def test_context_resolves_region_before_install(self): + self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn") + self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global") + # Deferred installs carry UTC until first boot, so they are global + # unless the autoinstall config names a region. + self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global") + self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn") + with self.assertRaises(RuntimeError): + self.from_env({"omarchy_install": {"region": "zz"}}) + + def test_marker_is_written_only_for_regional_targets(self): + target = self.root / "target" + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global")) + self.assertFalse((target / "etc/omarchy/region").exists()) + + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn")) + marker = target / "etc/omarchy/region" + self.assertEqual(marker.read_text(), "cn\n") + self.assertEqual(marker.stat().st_mode & 0o777, 0o644) + + def test_finalizer_trusts_only_the_selected_region(self): + for selected, expected in [ + ("global", []), + ("cn", [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]), + ]: + with self.subTest(region=selected), \ + mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ + mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ + mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + ctx = types.SimpleNamespace(region=selected, defer_provisioning=True, target=Path("/unused")) + phases_impl.run_system_finalizer(ctx) + commands = [call.args[1] for call in run.call_args_list] + self.assertEqual(commands[:-1], expected) + self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system") + + +class PrepareRegionsTest(unittest.TestCase): + def run_prepare(self, regions: Path, root: Path) -> tuple[Path, Path, str]: + payload = root / "payload" + payload.mkdir() + online = root / "online.conf" + online.write_text("[core]\nInclude = /etc/pacman.d/mirrorlist\n") + stubs = root / "bin" + stubs.mkdir() + log = root / "calls.log" + for name in ("pacman", "pacman-key"): + stub = stubs / name + stub.write_text(f'#!/bin/bash\necho "{name} $*" >> "{log}"\n') + stub.chmod(0o755) + subprocess.run( + ["bash", str(ROOT / "builder/prepare-regions.sh"), str(regions), str(payload), str(online)], + env={**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}"}, + check=True, capture_output=True, text=True, + ) + return payload, online, log.read_text() if log.exists() else "" + + def test_ships_every_profile_and_bootstraps_its_keyring(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + regions = write_profiles(root) + payload, online, calls = self.run_prepare(regions, root) + self.assertEqual((payload / "regions/cn/timezones").read_text(), (regions / "cn/timezones").read_text()) + self.assertIn("[archlinuxcn]", online.read_text()) + self.assertTrue(online.read_text().startswith("[core]\n")) + self.assertEqual(calls.splitlines(), [ + f"pacman --config {online} --noconfirm -Sy --needed archlinuxcn-keyring", + "pacman-key --populate archlinuxcn", + ]) + + def test_runtime_without_profiles_builds_a_global_iso(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload, online, calls = self.run_prepare(root / "missing", root) + self.assertFalse((payload / "regions").exists()) + self.assertNotIn("archlinuxcn", online.read_text()) + self.assertEqual(calls, "") + + +if __name__ == "__main__": + unittest.main()