Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/omarchy-iso-test
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@ drive_provision_owner() {
capture_console "success-provision-07-review"
press ret

# Finalization (offline Node unpack, LUKS re-key on encrypted installs)
# Finalization (user setup, LUKS re-key on encrypted installs)
# runs before SDDM appears.
capture_console "success-provision-08-finalizing"
}
Expand Down
36 changes: 22 additions & 14 deletions builder/build-iso.sh
Original file line number Diff line number Diff line change
Expand Up @@ -312,20 +312,6 @@ if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then
fi
fi

# Node.js binary for offline mise install, matched to the target architecture.
NODE_DIST_URL="https://nodejs.org/dist/latest"
NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
node_platform=linux-x64
[[ $OMARCHY_ARCH == aarch64 ]] && node_platform=linux-arm64
if ! IFS=$'\t' read -r NODE_FILENAME NODE_SHA < <(select_node_release "$node_platform" <<<"$NODE_SHASUMS"); then
echo "ERROR: could not find Node.js $node_platform release metadata" >&2
exit 1
fi
curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME"
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c -
mkdir -p "$build_cache_dir/airootfs/opt/packages/"
cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"

# Packages installed into the live ISO environment itself (NOT the target system).
# The selected omarchy-settings package is needed here so its post_install hook
# drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the
Expand Down Expand Up @@ -362,6 +348,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$live_packages_file"
if [[ -d /omarchy-source ]]; then
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
setup_form=/omarchy-source/install/provisioning/setup-form.sh
mise_work=/omarchy-source/install/user/mise-work.sh
else
# Pull the same package lists out of the freshly-downloaded Omarchy runtime
# package so we don't need a local checkout in the non-local-source path.
Expand All @@ -383,6 +370,8 @@ else
# actionable error below.
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true
setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/user/mise-work.sh 2>/dev/null || true
mise_work=/tmp/omarchy-pkglists/usr/share/omarchy/install/user/mise-work.sh
# The aarch64 list, each platform's own and the x86_64-only one. Runtimes
# from before the platform lists ship none, so each is optional.
for platform_list in 'omarchy-aarch64*.packages' omarchy-x86_64-only.packages; do
Expand Down Expand Up @@ -506,6 +495,25 @@ if [[ ! -f $setup_form ]]; then
fi
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"

# Omarchy installs Node lazily through mise since omacom/omarchy#15876. A
# runtime from before it unpacks a bundled Node tarball during setup and stops
# the install without one, so it still gets the build for its architecture.
# Remove this once every channel's runtime installs Node lazily.
if grep -qF /opt/packages "$mise_work" 2>/dev/null; then
NODE_DIST_URL="https://nodejs.org/dist/latest"
NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
node_platform=linux-x64
[[ $OMARCHY_ARCH == aarch64 ]] && node_platform=linux-arm64
if ! IFS=$'\t' read -r NODE_FILENAME NODE_SHA < <(select_node_release "$node_platform" <<<"$NODE_SHASUMS"); then
echo "ERROR: could not find Node.js $node_platform release metadata" >&2
exit 1
fi
curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME"
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c -
mkdir -p "$build_cache_dir/airootfs/opt/packages/"
cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
fi

# Collect every package we want available in the offline mirror.
declare -a all_packages
mapfile -t all_packages < <(
Expand Down
52 changes: 10 additions & 42 deletions configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -1099,8 +1099,8 @@ def _debug_run(ctx: InstallContext, cmd: list[str]) -> None:
# ─────────────────────────────────────────────────────────────────────────────
# Target setup phases:
# 1. point the target at the offline pacman.conf
# 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman
# and bundled language runtimes
# 2. bind-mount the offline mirror into /mnt for target pacman, and the
# bundled Node for a runtime from before Omarchy installed Node lazily
# 3. arch-chroot as root → omarchy-apply-system --first-install
# 4. arch-chroot as user → omarchy-provision-user --first-install
# ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -1113,8 +1113,10 @@ def _prepare_target_setup(ctx: InstallContext) -> None:

bind_mounts = [
("/var/cache/omarchy/mirror/offline", "/var/cache/omarchy/mirror/offline"),
("/opt/packages", "/opt/packages"),
]
# build-iso.sh bundles Node only for a runtime whose setup still unpacks it.
if Path("/opt/packages").is_dir():
bind_mounts.append(("/opt/packages", "/opt/packages"))
ctx.state.setdefault("bind_mounts", [])
mounted = set(ctx.state["bind_mounts"])
for src, dst in bind_mounts:
Expand Down Expand Up @@ -1250,31 +1252,25 @@ def run_system_finalizer(ctx: InstallContext) -> None:
# stage_provisioning_state: produce the on-disk "provisioning state" the runtime's first-boot
# setup (omarchy-provision-owner) and factory reset (omarchy-system-factory-reset) consume.
#
# Every install stashes the bundled Node tarball in /var/lib/omarchy/provisioning/
# so a later factory reset can finalize the new owner's user offline. deferred-provisioning
# 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 provisioning window, and first-boot setup
# re-keys the volume to the owner's password and removes it.
# Deferred-provisioning installs 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 provisioning 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.
# ─────────────────────────────────────────────────────────────────────────────

PROVISION_STATE_DIR = "var/lib/omarchy/provisioning"
PROVISION_KEYFILE = "etc/omarchy/provisioning.key"
NODE_PACKAGES_DIR = Path("/opt/packages")


def stage_provisioning_state(ctx: InstallContext) -> None:
# World-readable: first-boot finalization reads the Node tarball as the
# new user. The only secret inside (luks-key) is itself 0600 root.
# The only secret inside (luks-key) is itself 0600 root.
provisioning_dir = ctx.target / PROVISION_STATE_DIR
provisioning_dir.mkdir(parents=True, exist_ok=True)
provisioning_dir.chmod(0o755)

_stage_node_tarball(ctx, provisioning_dir)

if not ctx.defer_provisioning:
return

Expand Down Expand Up @@ -1303,31 +1299,6 @@ def stage_provisioning_state(ctx: InstallContext) -> None:
_stage_provisioning_luks_unlock(ctx, provisioning_dir)


# Node.js names its builds after the CPU: build-iso.sh bundles the one the
# live ISO (and so the target) runs on.
def _node_platform() -> str:
return "linux-arm64" if platform.machine() == "aarch64" else "linux-x64"


def _stage_node_tarball(ctx: InstallContext, provisioning_dir) -> None:
tarballs = sorted(NODE_PACKAGES_DIR.glob(f"node-v*-{_node_platform()}.tar.gz"))
if not tarballs:
# Hard error on every install, not just deferred-provisioning installs: the stash is what lets a
# later factory reset finalize the next owner offline, and an ISO
# build always bundles the tarball — its absence means a broken build.
raise RuntimeError(
f"no bundled Node tarball in {NODE_PACKAGES_DIR} — first-boot setup "
"and factory reset could not finalize a user offline"
)

packages_dir = provisioning_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 _provision_install_encrypted(ctx: InstallContext) -> bool:
if _storage_intent(ctx).get("luks_uuid"):
return True
Expand Down Expand Up @@ -2034,9 +2005,6 @@ def _validate_provisioning_state(ctx: InstallContext) -> None:
if not link.is_symlink():
raise RuntimeError("deferred-provisioning install but omarchy-provision-owner.service is not enabled")

if not list((provisioning_dir / "packages").glob("node-v*.tar.gz")):
raise RuntimeError(f"deferred-provisioning install but no Node tarball staged in {provisioning_dir / 'packages'}")

if _provision_install_encrypted(ctx):
for required in (provisioning_dir / "luks-key", ctx.target / PROVISION_KEYFILE):
if not required.exists():
Expand Down
5 changes: 3 additions & 2 deletions test/arm64-install
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,9 @@ check() { # description, then a command run in the guest that must succeed
check "it booted through Limine's unified kernel image" \
"echo $GUEST_PASSWORD | sudo -S -p '' sh -c 'ls /boot/EFI/Linux/omarchy_*.efi'"
check "the Omarchy runtime is installed" "pacman -Q $RUNTIME_PACKAGE"
check "Node came from the ISO, for this architecture" \
'node=$(ls ~/.local/share/mise/installs/node/*/bin/node | head -n 1); file -bL "$node" | grep -q aarch64'
# A runtime from before lazy Node unpacked the one the ISO bundled for it.
check "Node waits for its first use, or came from the ISO for this architecture" \
'node=$(ls ~/.local/share/mise/installs/node/*/bin/node 2>/dev/null | head -n 1); if [ -n "$node" ]; then file -bL "$node" | grep -q aarch64; else [ -x ~/.local/share/mise/shims/node ]; fi'
check "packages come from Omarchy's mirror and nowhere else" \
"grep -q '^Server.*omarchy\.org' /etc/pacman.d/mirrorlist && ! grep '^Server' /etc/pacman.d/mirrorlist | grep -vq 'omarchy\.org'"
if [[ -n ${PLATFORM:-} ]]; then
Expand Down
23 changes: 0 additions & 23 deletions test/unit/test_n1x_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,29 +122,6 @@ def test_mirror_lines_are_valid_json_in_the_heredoc(self):
self.assertEqual(servers[0]["url"], first_url)


class NodeTarballTest(unittest.TestCase):
def test_stages_the_tarball_for_the_running_cpu(self):
for machine, bundled in [("aarch64", "node-v26.10.0-linux-arm64.tar.gz"), ("x86_64", "node-v26.10.0-linux-x64.tar.gz")]:
with self.subTest(machine=machine), tempfile.TemporaryDirectory() as tmp:
packages, provisioning = Path(tmp) / "packages", Path(tmp) / "provisioning"
packages.mkdir()
(packages / "node-v26.10.0-linux-arm64.tar.gz" if machine == "x86_64" else packages / "node-v26.10.0-linux-x64.tar.gz").write_text("other cpu")
(packages / bundled).write_text("node")
with mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", packages), \
mock.patch.object(phases_impl.platform, "machine", return_value=machine):
phases_impl._stage_node_tarball(None, provisioning)
self.assertEqual([p.name for p in (provisioning / "packages").iterdir()], [bundled])

def test_missing_tarball_for_the_cpu_fails(self):
with tempfile.TemporaryDirectory() as tmp:
packages = Path(tmp)
(packages / "node-v26.10.0-linux-x64.tar.gz").write_text("x86")
with mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", packages), \
mock.patch.object(phases_impl.platform, "machine", return_value="aarch64"), \
self.assertRaisesRegex(RuntimeError, "no bundled Node tarball"):
phases_impl._stage_node_tarball(None, packages / "provisioning")


class N1xLimineContractTest(unittest.TestCase):
@staticmethod
def validate(conf, arm_platform):
Expand Down
33 changes: 2 additions & 31 deletions test/unit/test_provisioning_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,6 @@ def setUp(self):
info_patch.start()
self.addCleanup(info_patch.stop)

# The fixtures are x86_64 ones; the Node build chosen follows the host.
machine_patch = mock.patch.object(phases_impl.platform, "machine", return_value="x86_64")
machine_patch.start()
self.addCleanup(machine_patch.stop)

# A fake bundled Node tarball on the "live ISO".
self.packages = Path(self.tmp.name) / "opt-packages"
self.packages.mkdir()
(self.packages / "node-v24.0.0-linux-x64.tar.gz").write_bytes(b"node")
node_patch = mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", self.packages)
node_patch.start()
self.addCleanup(node_patch.stop)

def install_runtime_provisioning_support(self):
service = self.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
service.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -208,11 +195,11 @@ def install_runtime_provisioning_support(self):
def provisioning_dir(self):
return self.target / "var/lib/omarchy/provisioning"

def test_normal_install_stages_only_the_node_tarball(self):
def test_normal_install_arms_nothing(self):
ctx = make_ctx(self.target, defer_provisioning=False)
phases_impl.stage_provisioning_state(ctx)

self.assertTrue((self.provisioning_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists())
self.assertEqual(list(self.provisioning_dir().iterdir()), [])
self.assertFalse((self.provisioning_dir() / "pending").exists())
self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())

Expand All @@ -221,22 +208,6 @@ def test_deferred_provisioning_against_runtime_without_support_fails_clearly(sel
with self.assertRaisesRegex(RuntimeError, "does not ship"):
phases_impl.stage_provisioning_state(ctx)

def test_deferred_provisioning_without_node_tarball_fails(self):
for tarball in self.packages.glob("*.tar.gz"):
tarball.unlink()
self.install_runtime_provisioning_support()
ctx = make_ctx(self.target)
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
phases_impl.stage_provisioning_state(ctx)

def test_missing_node_tarball_fails_normal_installs_too(self):
# The stash is what makes a later factory reset work offline.
for tarball in self.packages.glob("*.tar.gz"):
tarball.unlink()
ctx = make_ctx(self.target, defer_provisioning=False)
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
phases_impl.stage_provisioning_state(ctx)

def test_deferred_provisioning_arms_first_boot_setup(self):
self.install_runtime_provisioning_support()
ctx = make_ctx(self.target)
Expand Down