✨ feat(install): pick the region from the timezone
- ✨ the timezone the user already chose selects the region, so no new prompt is needed - 🔒 populate only the selected region's keys so global installs never trust them - ✅ cover the mapping, autoinstall override, marker, keyring setup, and builder staging Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
This commit is contained in:
1 parent
1c912658aa
commit
e121561851
6 files changed
+275
-1
No files matched your search
@@ -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}/"
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <timezone>` 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: <name>-keyring
|
||||
installs /usr/share/pacman/keyrings/<name>.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))
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user