Add OEM install mode and factory snapshots
OEM mode installs the entire system but defers user creation to first boot, where the runtime's omarchy-oem-setup asks for the owner and lands them in the already-installed system — and the same machinery arms omarchy-reset-computer. The configurator's install-mode step gains an OEM choice, which required moving the user step after mode selection (OEM skips it entirely). Autoinstall rigs select OEM with an `oem` marker file on the cidata drive, which also replaces the user_credentials.json requirement. In the orchestrator, OEM swaps the user phases for a stage_oem_state phase: it stashes the bundled Node tarball (every install, for offline factory resets), arms omarchy-oem-setup.service in the target, and on encrypted installs stages the throwaway LUKS passphrase — generated by InstallContext when the input carries none — with a keyfile embedded in the UKI so boot auto-unlocks until first-boot setup re-keys to the owner's password. The install fails with a clear error if the bundled runtime lacks OEM support. Every install now also ends with a read-only @factory snapshot of @ at the btrfs top level — outside snapper's reach — which is what makes omarchy-reset-computer a true factory reset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6004e80485
commit
679edce2e3
12 files changed
+927
-108
No files matched your search
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -25,6 +26,7 @@ class InstallContext:
|
||||
user_credentials: dict
|
||||
arch_config_path: Path
|
||||
omarchy_install: dict[str, Any]
|
||||
oem: bool = False
|
||||
|
||||
target: Path = Path("/mnt")
|
||||
omarchy_path: Path = Path("/usr/share/omarchy")
|
||||
@@ -48,10 +50,37 @@ class InstallContext:
|
||||
user_configuration = json.loads(config_path.read_text())
|
||||
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
|
||||
|
||||
# OEM mode: the whole system installs but user creation is deferred to
|
||||
# first boot. Selected by the configurator (omarchy_install.oem) or by
|
||||
# an `oem` marker file on an autoinstall drive, which also replaces the
|
||||
# user_credentials.json requirement.
|
||||
oem_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_OEM_FILE"))
|
||||
oem = bool(omarchy_install.get("oem")) or oem_marker is not None
|
||||
omarchy_install["oem"] = oem
|
||||
|
||||
if creds_path.exists():
|
||||
user_credentials = json.loads(creds_path.read_text())
|
||||
elif oem:
|
||||
user_credentials = {"users": []}
|
||||
else:
|
||||
raise RuntimeError(f"credentials file missing: {creds_path}")
|
||||
|
||||
arch_configuration = dict(user_configuration)
|
||||
arch_configuration.pop("omarchy_install", None)
|
||||
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if oem:
|
||||
# Encrypted OEM installs have no user password to protect LUKS
|
||||
# with. Generate a throwaway passphrase (staged for first-boot
|
||||
# re-key by the stage_oem_state phase) and hand it to archinstall
|
||||
# through both places it may look: the disk_encryption block and
|
||||
# the credentials file.
|
||||
_inject_oem_encryption_password(arch_configuration, user_credentials)
|
||||
creds_path = state_dir / "oem-user_credentials.json"
|
||||
creds_path.write_text(json.dumps(user_credentials, indent=2) + "\n")
|
||||
creds_path.chmod(0o600)
|
||||
|
||||
arch_config_path = state_dir / "archinstall-user_configuration.json"
|
||||
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
|
||||
|
||||
@@ -64,9 +93,10 @@ class InstallContext:
|
||||
authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")),
|
||||
tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
|
||||
user_configuration=user_configuration,
|
||||
user_credentials=json.loads(creds_path.read_text()),
|
||||
user_credentials=user_credentials,
|
||||
arch_config_path=arch_config_path,
|
||||
omarchy_install=omarchy_install,
|
||||
oem=oem,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
disk_config = user_configuration.get("disk_config", {})
|
||||
@@ -77,7 +107,12 @@ class InstallContext:
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return self.user_credentials["users"][0]["username"]
|
||||
users = self.user_credentials.get("users") or []
|
||||
if users:
|
||||
return users[0]["username"]
|
||||
if self.oem:
|
||||
return ""
|
||||
raise RuntimeError("user_credentials.json contains no users")
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
@@ -91,6 +126,24 @@ class InstallContext:
|
||||
return self.mode == "protected"
|
||||
|
||||
|
||||
def _inject_oem_encryption_password(arch_configuration: dict, user_credentials: dict) -> None:
|
||||
"""Ensure an encrypted OEM 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_oem_state phase can read the effective passphrase back."""
|
||||
disk_encryption = (arch_configuration.get("disk_config") or {}).get("disk_encryption")
|
||||
if disk_encryption is None:
|
||||
return
|
||||
|
||||
password = disk_encryption.get("encryption_password") or user_credentials.get("encryption_password")
|
||||
if not password:
|
||||
password = secrets.token_urlsafe(24)
|
||||
|
||||
disk_encryption["encryption_password"] = password
|
||||
user_credentials["encryption_password"] = password
|
||||
|
||||
|
||||
def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
|
||||
disk_config = user_configuration.get("disk_config", {})
|
||||
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
|
||||
|
||||
@@ -34,6 +34,7 @@ def build_phases(ctx: InstallContext):
|
||||
arch_install_system,
|
||||
configure_hibernation,
|
||||
run_system_finalizer,
|
||||
stage_oem_state,
|
||||
finalize_limine_boot,
|
||||
run_chroot_finalizer,
|
||||
configure_dns_resolver,
|
||||
@@ -41,6 +42,7 @@ def build_phases(ctx: InstallContext):
|
||||
configure_ssh_access,
|
||||
configure_tailscale,
|
||||
validate_boot,
|
||||
create_factory_snapshot,
|
||||
)
|
||||
|
||||
return [
|
||||
@@ -49,6 +51,9 @@ def build_phases(ctx: InstallContext):
|
||||
("Installing Arch + Omarchy", arch_install_system),
|
||||
("Configuring hibernation", configure_hibernation),
|
||||
("Configuring system", run_system_finalizer),
|
||||
# Before finalize_limine_boot: the OEM cryptkey drop-in and keyfile
|
||||
# must be in place for the final UKI build.
|
||||
("Staging OEM state", stage_oem_state),
|
||||
("Finalizing Limine boot", finalize_limine_boot),
|
||||
("Finalizing user", run_chroot_finalizer),
|
||||
("Configuring login", configure_login),
|
||||
@@ -56,6 +61,7 @@ def build_phases(ctx: InstallContext):
|
||||
("Configuring Tailscale", configure_tailscale),
|
||||
("Configuring DNS resolver", configure_dns_resolver),
|
||||
("Validating boot setup", validate_boot),
|
||||
("Creating factory snapshot", create_factory_snapshot),
|
||||
]
|
||||
|
||||
|
||||
@@ -66,7 +72,8 @@ def main() -> int:
|
||||
error(f"Configuration error: {e}")
|
||||
return 2
|
||||
|
||||
info(f"Installing Omarchy for {ctx.username} → {ctx.target}")
|
||||
who = ctx.username or "OEM (user created at first boot)"
|
||||
info(f"Installing Omarchy for {who} → {ctx.target}")
|
||||
|
||||
from .phases_impl import (
|
||||
boost_cpu_governor,
|
||||
|
||||
@@ -1136,16 +1136,140 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
|
||||
|
||||
|
||||
def run_system_finalizer(ctx: InstallContext) -> None:
|
||||
if ctx.oem:
|
||||
cmd = ["/usr/bin/omarchy-setup-system", "--oem", "--first-install"]
|
||||
else:
|
||||
cmd = ["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"]
|
||||
|
||||
_mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
|
||||
try:
|
||||
_run_target_setup_command(
|
||||
ctx,
|
||||
["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"],
|
||||
)
|
||||
_run_target_setup_command(ctx, cmd)
|
||||
finally:
|
||||
_unmask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# stage_oem_state: produce the on-disk "OEM state" the runtime's first-boot
|
||||
# setup (omarchy-oem-setup) and factory reset (omarchy-reset-computer) consume.
|
||||
#
|
||||
# Every install stashes the bundled Node tarball in /var/lib/omarchy/oem/
|
||||
# so a later factory reset can finalize the new owner's user offline. OEM-mode
|
||||
# installs additionally arm the first-boot setup service and, on encrypted
|
||||
# targets, stage the throwaway LUKS passphrase: the keyfile embedded in the
|
||||
# initramfs auto-unlocks boot during the OEM window, and first-boot setup
|
||||
# re-keys the volume to the owner's password and removes it.
|
||||
#
|
||||
# Runs before finalize_limine_boot so the cryptkey cmdline drop-in and the
|
||||
# keyfile land in the final UKI build.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
OEM_STATE_DIR = "var/lib/omarchy/oem"
|
||||
OEM_KEYFILE = "etc/omarchy/oem.key"
|
||||
NODE_PACKAGES_DIR = Path("/opt/packages")
|
||||
|
||||
|
||||
def stage_oem_state(ctx: InstallContext) -> None:
|
||||
oem_dir = ctx.target / OEM_STATE_DIR
|
||||
oem_dir.mkdir(parents=True, exist_ok=True)
|
||||
oem_dir.chmod(0o700)
|
||||
|
||||
_stage_node_tarball(ctx, oem_dir)
|
||||
|
||||
if not ctx.oem:
|
||||
return
|
||||
|
||||
service_src = ctx.target / "usr/share/omarchy/install/oem/omarchy-oem-setup.service"
|
||||
setup_bin = ctx.target / "usr/bin/omarchy-oem-setup"
|
||||
if not service_src.exists() or not setup_bin.exists():
|
||||
raise RuntimeError(
|
||||
"OEM install requested, but the installed Omarchy runtime does not ship "
|
||||
"first-boot setup (omarchy-oem-setup + install/oem/omarchy-oem-setup.service). "
|
||||
"Update the runtime package this ISO bundles before installing in OEM mode."
|
||||
)
|
||||
|
||||
info("› arming first-boot OEM setup")
|
||||
(oem_dir / "pending").touch()
|
||||
|
||||
unit_dst = ctx.target / "etc/systemd/system/omarchy-oem-setup.service"
|
||||
unit_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(service_src, unit_dst)
|
||||
wants_dir = ctx.target / "etc/systemd/system/multi-user.target.wants"
|
||||
wants_dir.mkdir(parents=True, exist_ok=True)
|
||||
link = wants_dir / "omarchy-oem-setup.service"
|
||||
link.unlink(missing_ok=True)
|
||||
link.symlink_to("/etc/systemd/system/omarchy-oem-setup.service")
|
||||
|
||||
if _oem_install_encrypted(ctx):
|
||||
_stage_oem_luks_unlock(ctx, oem_dir)
|
||||
|
||||
|
||||
def _stage_node_tarball(ctx: InstallContext, oem_dir) -> None:
|
||||
tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.tar.gz"))
|
||||
if not tarballs:
|
||||
if ctx.oem:
|
||||
raise RuntimeError(
|
||||
"no bundled Node tarball in /opt/packages — OEM first-boot setup "
|
||||
"could not finalize the user offline"
|
||||
)
|
||||
info("› no bundled Node tarball to stash for factory reset; skipping")
|
||||
return
|
||||
|
||||
packages_dir = oem_dir / "packages"
|
||||
packages_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_tarball = packages_dir / tarballs[0].name
|
||||
if not target_tarball.exists():
|
||||
info("› stashing Node tarball for offline first-boot setup")
|
||||
shutil.copy2(tarballs[0], target_tarball)
|
||||
|
||||
|
||||
def _oem_install_encrypted(ctx: InstallContext) -> bool:
|
||||
if _storage_intent(ctx).get("luks_uuid"):
|
||||
return True
|
||||
disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption")
|
||||
if disk_encryption and disk_encryption.get("encryption_type", "luks") != "no_encryption":
|
||||
return True
|
||||
return ctx.encrypt
|
||||
|
||||
|
||||
def _oem_encryption_password(ctx: InstallContext) -> str | None:
|
||||
disk_encryption = (ctx.user_configuration.get("disk_config") or {}).get("disk_encryption") or {}
|
||||
return disk_encryption.get("encryption_password") or ctx.user_credentials.get("encryption_password")
|
||||
|
||||
|
||||
def _stage_oem_luks_unlock(ctx: InstallContext, oem_dir) -> None:
|
||||
password = _oem_encryption_password(ctx)
|
||||
if not password:
|
||||
# Full-disk OEM installs get a generated passphrase injected by
|
||||
# InstallContext; only a pre-mounted (rig-partitioned) LUKS target can
|
||||
# land here, and it must hand over the passphrase it formatted with.
|
||||
raise RuntimeError(
|
||||
"OEM install on a pre-encrypted target requires the LUKS passphrase "
|
||||
"in user_credentials.json (encryption_password) so first boot can re-key"
|
||||
)
|
||||
|
||||
info("› staging LUKS auto-unlock for the OEM window")
|
||||
|
||||
# Byte-for-byte the slot passphrase: no trailing newline anywhere.
|
||||
luks_key = oem_dir / "luks-key"
|
||||
luks_key.write_text(password)
|
||||
luks_key.chmod(0o600)
|
||||
|
||||
keyfile = ctx.target / OEM_KEYFILE
|
||||
keyfile.parent.mkdir(parents=True, exist_ok=True)
|
||||
keyfile.write_text(password)
|
||||
keyfile.chmod(0o600)
|
||||
|
||||
cmdline_dropin = ctx.target / "etc/limine-entry-tool.d/99-omarchy-oem-unlock.conf"
|
||||
cmdline_dropin.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmdline_dropin.write_text(
|
||||
'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/oem.key"\n'
|
||||
)
|
||||
|
||||
files_dropin = ctx.target / "etc/mkinitcpio.conf.d/99-omarchy-oem-key.conf"
|
||||
files_dropin.parent.mkdir(parents=True, exist_ok=True)
|
||||
files_dropin.write_text("FILES+=(/etc/omarchy/oem.key)\n")
|
||||
|
||||
|
||||
def finalize_limine_boot(ctx: InstallContext) -> None:
|
||||
"""Finalize Limine after target system setup has written all dynamic
|
||||
boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings).
|
||||
@@ -1239,6 +1363,10 @@ def _limine_kernel_cmdline(config_text: str) -> str:
|
||||
|
||||
|
||||
def run_chroot_finalizer(ctx: InstallContext) -> None:
|
||||
if ctx.oem:
|
||||
info("› OEM install: user finalization deferred to first boot")
|
||||
return
|
||||
|
||||
_run_target_setup_command(
|
||||
ctx,
|
||||
["/usr/bin/omarchy-finalize-user", "--force", "--first-install"],
|
||||
@@ -1285,29 +1413,32 @@ def configure_login(ctx: InstallContext) -> None:
|
||||
)
|
||||
|
||||
autologin_conf = sddm_dir / "autologin.conf"
|
||||
if ctx.encrypt:
|
||||
if ctx.encrypt and not ctx.oem:
|
||||
autologin_conf.write_text(
|
||||
"[Autologin]\n"
|
||||
f"User={ctx.username}\n"
|
||||
"Session=omarchy.desktop\n"
|
||||
)
|
||||
else:
|
||||
# OEM installs have no user yet; omarchy-oem-setup writes autologin
|
||||
# and SDDM state at first boot once the owner exists.
|
||||
autologin_conf.unlink(missing_ok=True)
|
||||
|
||||
state_dir = ctx.target / "var" / "lib" / "sddm"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(state_dir / "state.conf").write_text(
|
||||
f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n"
|
||||
)
|
||||
if not ctx.oem:
|
||||
state_dir = ctx.target / "var" / "lib" / "sddm"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(state_dir / "state.conf").write_text(
|
||||
f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n"
|
||||
)
|
||||
subprocess.run(
|
||||
["arch-chroot", str(ctx.target), "chown", "sddm:sddm",
|
||||
"/var/lib/sddm", "/var/lib/sddm/state.conf"],
|
||||
check=False, capture_output=True,
|
||||
)
|
||||
|
||||
autologin = ctx.target / "etc" / "systemd" / "system" / "getty@tty1.service.d" / "autologin.conf"
|
||||
autologin.unlink(missing_ok=True)
|
||||
|
||||
subprocess.run(
|
||||
["arch-chroot", str(ctx.target), "chown", "sddm:sddm",
|
||||
"/var/lib/sddm", "/var/lib/sddm/state.conf"],
|
||||
check=False, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"],
|
||||
check=False, capture_output=True,
|
||||
@@ -1326,24 +1457,36 @@ def configure_ssh_access(ctx: InstallContext) -> None:
|
||||
return
|
||||
|
||||
keys = _authorized_keys(ctx.authorized_keys_path)
|
||||
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
|
||||
|
||||
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
|
||||
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||
ssh_dir.chmod(0o700)
|
||||
if ctx.oem:
|
||||
# No user to authorize yet. Stage the keys in OEM state for
|
||||
# omarchy-oem-setup to install once first boot creates the owner, and
|
||||
# still open the door (sshd + ufw) below.
|
||||
info(f"› staging {len(keys)} SSH key(s) for the first-boot user")
|
||||
oem_dir = ctx.target / OEM_STATE_DIR
|
||||
oem_dir.mkdir(parents=True, exist_ok=True)
|
||||
staged = oem_dir / "authorized_keys"
|
||||
staged.write_text("".join(f"{key}\n" for key in keys))
|
||||
staged.chmod(0o600)
|
||||
else:
|
||||
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
|
||||
|
||||
authorized_keys = ssh_dir / "authorized_keys"
|
||||
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
|
||||
authorized_keys.chmod(0o600)
|
||||
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
|
||||
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||
ssh_dir.chmod(0o700)
|
||||
|
||||
# Ask the target for the uid rather than assuming the first user is 1000.
|
||||
# Uncaptured so a failure shows up in the install log, and checked because
|
||||
# a root-owned authorized_keys is one sshd refuses to read.
|
||||
subprocess.run(
|
||||
["arch-chroot", str(ctx.target), "chown", "-R",
|
||||
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
|
||||
check=True,
|
||||
)
|
||||
authorized_keys = ssh_dir / "authorized_keys"
|
||||
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
|
||||
authorized_keys.chmod(0o600)
|
||||
|
||||
# Ask the target for the uid rather than assuming the first user is 1000.
|
||||
# Uncaptured so a failure shows up in the install log, and checked because
|
||||
# a root-owned authorized_keys is one sshd refuses to read.
|
||||
subprocess.run(
|
||||
["arch-chroot", str(ctx.target), "chown", "-R",
|
||||
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
info("› enabling sshd")
|
||||
subprocess.run(
|
||||
@@ -1550,6 +1693,34 @@ def validate_boot(ctx: InstallContext) -> None:
|
||||
if ctx.is_protected:
|
||||
_validate_pre_mounted_filesystems(ctx)
|
||||
|
||||
if ctx.oem:
|
||||
_validate_oem_state(ctx)
|
||||
|
||||
|
||||
def _validate_oem_state(ctx: InstallContext) -> None:
|
||||
"""An OEM install that boots without a working first-boot setup is a
|
||||
user-less brick; insist the armed state is complete before reboot."""
|
||||
oem_dir = ctx.target / OEM_STATE_DIR
|
||||
if not (oem_dir / "pending").exists():
|
||||
raise RuntimeError(f"OEM install but {oem_dir / 'pending'} is missing")
|
||||
|
||||
link = ctx.target / "etc/systemd/system/multi-user.target.wants/omarchy-oem-setup.service"
|
||||
if not link.is_symlink():
|
||||
raise RuntimeError("OEM install but omarchy-oem-setup.service is not enabled")
|
||||
|
||||
if not list((oem_dir / "packages").glob("node-v*.tar.gz")):
|
||||
raise RuntimeError(f"OEM install but no Node tarball staged in {oem_dir / 'packages'}")
|
||||
|
||||
if _oem_install_encrypted(ctx):
|
||||
for required in (oem_dir / "luks-key", ctx.target / OEM_KEYFILE):
|
||||
if not required.exists():
|
||||
raise RuntimeError(f"encrypted OEM install but {required} is missing")
|
||||
limine_conf_text = (
|
||||
ctx.target / _boot_intent(ctx)["esp_mount"].lstrip("/") / "limine.conf"
|
||||
).read_text()
|
||||
if "cryptkey=rootfs:" not in limine_conf_text:
|
||||
raise RuntimeError("encrypted OEM install but limine.conf has no cryptkey= for auto-unlock")
|
||||
|
||||
|
||||
def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
|
||||
"""Never hand over a system whose UKI rebuild hook is still masked.
|
||||
@@ -1607,6 +1778,62 @@ def _validate_pre_mounted_filesystems(ctx: InstallContext) -> None:
|
||||
raise RuntimeError(f"{crypttab} missing LUKS UUID {storage['luks_uuid']}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# create_factory_snapshot: read-only snapshot of @ kept at the btrfs top level
|
||||
# as @factory — outside snapper's .snapshots, so cleanup timers and the Limine
|
||||
# snapshot menu never touch it. Zero bytes at creation; grows only with drift.
|
||||
# Taken at the end of every install, it is what makes omarchy-reset-computer a
|
||||
# true factory reset.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_factory_snapshot(ctx: InstallContext) -> None:
|
||||
fstype = _findmnt_value(ctx.target, "FSTYPE")
|
||||
if fstype != "btrfs":
|
||||
info(f"› target root is {fstype or 'unknown'}, not btrfs; skipping factory snapshot")
|
||||
return
|
||||
|
||||
options = (_findmnt_value(ctx.target, "OPTIONS") or "").split(",")
|
||||
if not any(opt in ("subvol=/@", "subvol=@") for opt in options):
|
||||
info("› target root is not the @ subvolume; skipping factory snapshot")
|
||||
return
|
||||
|
||||
device = (_findmnt_value(ctx.target, "SOURCE") or "").split("[")[0]
|
||||
if not device:
|
||||
raise RuntimeError(f"could not determine the btrfs device backing {ctx.target}")
|
||||
|
||||
top = ctx.state_dir / "factory-top"
|
||||
top.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["mount", "-o", "subvolid=5", device, str(top)], check=True)
|
||||
try:
|
||||
root_subvol = top / "@"
|
||||
if not root_subvol.is_dir():
|
||||
raise RuntimeError(f"no @ subvolume at the top level of {device}")
|
||||
|
||||
factory = top / "@factory"
|
||||
if factory.exists():
|
||||
subprocess.run(
|
||||
["btrfs", "subvolume", "delete", str(factory)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
info("› snapshotting @ as @factory (read-only)")
|
||||
subprocess.run(
|
||||
["btrfs", "subvolume", "snapshot", "-r", str(root_subvol), str(factory)],
|
||||
check=True,
|
||||
)
|
||||
finally:
|
||||
subprocess.run(["umount", str(top)], check=False, capture_output=True)
|
||||
|
||||
|
||||
def _findmnt_value(path: Path, column: str) -> str | None:
|
||||
res = subprocess.run(
|
||||
["findmnt", "-no", column, str(path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
value = res.stdout.strip()
|
||||
return value if res.returncode == 0 and value else None
|
||||
|
||||
|
||||
CPU_SYSFS = Path("/sys/devices/system/cpu")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user