diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 9b5c50ff..92d1d63e 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -4,6 +4,7 @@ from __future__ import annotations +import copy import json import os import secrets @@ -74,8 +75,13 @@ def from_env(cls) -> "InstallContext": if encryption_password: user_credentials["encryption_password"] = encryption_password - arch_configuration = dict(user_configuration) + # Keep the persistent identity from cidata for cleanup/support, but + # feed archinstall canonical /dev names. Archinstall 4.4 indexes its + # BlockDevice objects by canonical kernel path and silently drops a + # layout whose device is a valid /dev/disk/by-id symlink. + arch_configuration = copy.deepcopy(user_configuration) arch_configuration.pop("omarchy_install", None) + _canonicalize_install_devices(arch_configuration) if defer_provisioning: # The userless invariant covers the archinstall config too: a @@ -93,7 +99,13 @@ def from_env(cls) -> "InstallContext": # re-key by the stage_provisioning_state phase) and hand it to archinstall # through both places it may look: the disk_encryption block and # the credentials file. - _inject_provisioning_encryption_password(arch_configuration, user_credentials) + generated_password = _inject_provisioning_encryption_password( + arch_configuration, user_credentials + ) + if generated_password: + user_configuration["disk_config"]["disk_encryption"][ + "encryption_password" + ] = generated_password creds_path = state_dir / "provisioning-user_credentials.json" creds_path.write_text(json.dumps(user_credentials, indent=2) + "\n") creds_path.chmod(0o600) @@ -157,15 +169,41 @@ def _strip_account_fields(arch_configuration: dict) -> None: auth.pop(key, None) -def _inject_provisioning_encryption_password(arch_configuration: dict, user_credentials: dict) -> None: +def _canonicalize_install_devices(arch_configuration: dict) -> None: + """Resolve persistent disk aliases before handing JSON to archinstall. + + Cidata deliberately names a stable /dev/disk/by-id path. Keep that value + in ``user_configuration`` for diagnostics, but archinstall needs the + canonical runtime name (for example /dev/nvme0n1 or /dev/vda). + """ + modifications = ( + (arch_configuration.get("disk_config") or {}).get("device_modifications") or [] + ) + for modification in modifications: + device = modification.get("device") + if not device: + continue + path = Path(device) + try: + canonical = path.resolve(strict=True) + except OSError as exc: + raise RuntimeError(f"install device does not exist: {device}") from exc + if not canonical.is_block_device(): + raise RuntimeError(f"install device is not a block device: {device} -> {canonical}") + modification["device"] = str(canonical) + + +def _inject_provisioning_encryption_password( + arch_configuration: dict, user_credentials: dict +) -> str | None: """Ensure an encrypted deferred-provisioning install has a LUKS passphrase archinstall can use. Reuse one the input already carries (an autoinstall rig may supply - its own); otherwise generate a throwaway. Mutates the disk_encryption - block in place — arch_configuration shares it with user_configuration, so - the stage_provisioning_state phase can read the effective passphrase back.""" + its own); otherwise generate a throwaway. Return the effective passphrase + so the caller can explicitly mirror it into the separate persistent install + context used by stage_provisioning_state.""" disk_encryption = (arch_configuration.get("disk_config") or {}).get("disk_encryption") if disk_encryption is None: - return + return None password = disk_encryption.get("encryption_password") or user_credentials.get("encryption_password") if not password: @@ -173,6 +211,7 @@ def _inject_provisioning_encryption_password(arch_configuration: dict, user_cred disk_encryption["encryption_password"] = password user_credentials["encryption_password"] = password + return password def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: diff --git a/test/unit/test_provisioning_state.py b/test/unit/test_provisioning_state.py index 7386f001..dd0185f5 100644 --- a/test/unit/test_provisioning_state.py +++ b/test/unit/test_provisioning_state.py @@ -156,6 +156,42 @@ def test_deferred_provisioning_unencrypted_touches_nothing(self): ctx = self.from_env() self.assertNotIn("encryption_password", ctx.user_credentials) + def test_persistent_device_alias_is_canonicalized_only_for_archinstall(self): + target = self.dir / "vda" + target.touch() + alias = self.dir / "by-id" / "virtio-omarchy-lab" + alias.parent.mkdir() + alias.symlink_to(target) + config = self.base_config() + config["disk_config"]["device_modifications"] = [{"device": str(alias), "wipe": True}] + self.write_config(config) + self.write_creds({"users": [{"username": "jeff"}]}) + + with mock.patch.object(Path, "is_block_device", return_value=True): + ctx = self.from_env() + + # Cleanup/support retains the stable identity from cidata. + self.assertEqual( + ctx.user_configuration["disk_config"]["device_modifications"][0]["device"], + str(alias), + ) + # Archinstall receives the canonical key used by its BlockDevice map. + arch_config = json.loads(ctx.arch_config_path.read_text()) + self.assertEqual( + arch_config["disk_config"]["device_modifications"][0]["device"], + str(target.resolve()), + ) + + def test_missing_install_device_fails_before_archinstall(self): + config = self.base_config() + missing = self.dir / "by-id" / "missing" + config["disk_config"]["device_modifications"] = [{"device": str(missing), "wipe": True}] + self.write_config(config) + self.write_creds({"users": [{"username": "jeff"}]}) + + with self.assertRaisesRegex(RuntimeError, "install device does not exist"): + self.from_env() + def make_ctx(target, **overrides): defaults = dict(