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
+82
-23
@@ -14,6 +14,8 @@
|
|||||||
# Usage: omarchy-iso-test [release/omarchy.iso] [options]
|
# Usage: omarchy-iso-test [release/omarchy.iso] [options]
|
||||||
#
|
#
|
||||||
# --encrypt Drive the encrypted install flow (default: unencrypted)
|
# --encrypt Drive the encrypted install flow (default: unencrypted)
|
||||||
|
# --oem Drive the OEM install flow: no user during install, the
|
||||||
|
# first boot runs omarchy-oem-setup and creates it there
|
||||||
# --reuse-base Skip the install phase if a base image already exists
|
# --reuse-base Skip the install phase if a base image already exists
|
||||||
# --install-only Stop after producing the installed base image
|
# --install-only Stop after producing the installed base image
|
||||||
# --sync-omarchy DIR Push DIR's test/acceptance into the guest before running
|
# --sync-omarchy DIR Push DIR's test/acceptance into the guest before running
|
||||||
@@ -36,6 +38,7 @@ MEMORY=8192
|
|||||||
INSTALL_TIMEOUT=2400
|
INSTALL_TIMEOUT=2400
|
||||||
BOOT_TIMEOUT=600
|
BOOT_TIMEOUT=600
|
||||||
ENCRYPT=false
|
ENCRYPT=false
|
||||||
|
OEM=false
|
||||||
REUSE_BASE=false
|
REUSE_BASE=false
|
||||||
INSTALL_ONLY=false
|
INSTALL_ONLY=false
|
||||||
KEEP_RUNNING=false
|
KEEP_RUNNING=false
|
||||||
@@ -50,6 +53,7 @@ GUEST_HOSTNAME="omarchy-test"
|
|||||||
while (($#)); do
|
while (($#)); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--encrypt) ENCRYPT=true ;;
|
--encrypt) ENCRYPT=true ;;
|
||||||
|
--oem) OEM=true ;;
|
||||||
--reuse-base) REUSE_BASE=true ;;
|
--reuse-base) REUSE_BASE=true ;;
|
||||||
--install-only) INSTALL_ONLY=true ;;
|
--install-only) INSTALL_ONLY=true ;;
|
||||||
--keep-running) KEEP_RUNNING=true ;;
|
--keep-running) KEEP_RUNNING=true ;;
|
||||||
@@ -86,6 +90,7 @@ OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
|
|||||||
|
|
||||||
BASE_NAME="$(basename "$ISO" .iso)"
|
BASE_NAME="$(basename "$ISO" .iso)"
|
||||||
$ENCRYPT && BASE_NAME+="-encrypted"
|
$ENCRYPT && BASE_NAME+="-encrypted"
|
||||||
|
$OEM && BASE_NAME+="-oem"
|
||||||
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
|
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
|
||||||
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
|
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
|
||||||
BASE_DISK="$BASE_DIR/base.qcow2"
|
BASE_DISK="$BASE_DIR/base.qcow2"
|
||||||
@@ -580,59 +585,111 @@ drive_configurator() {
|
|||||||
capture_console "success-installer-01-keyboard-layout"
|
capture_console "success-installer-01-keyboard-layout"
|
||||||
press ret # English (US) is preselected
|
press ret # English (US) is preselected
|
||||||
|
|
||||||
|
wait_for_screen "install disk" 120
|
||||||
|
capture_console "success-installer-02-install-disk"
|
||||||
|
press ret # the single virtio disk
|
||||||
|
|
||||||
|
wait_for_screen "installation mode" 60
|
||||||
|
capture_console "success-installer-03-install-mode"
|
||||||
|
if $OEM; then
|
||||||
|
press down # "OEM install" sits below "Full disk install" on a blank disk
|
||||||
|
fi
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "recovery possible" 60
|
||||||
|
capture_console "success-installer-04-disk-warning-encrypted"
|
||||||
|
if ! $ENCRYPT; then
|
||||||
|
press ctrl-c # toggles the confirm to the unencrypted flow
|
||||||
|
wait_for_screen "without encryption" 30
|
||||||
|
capture_console "success-installer-05-disk-warning-unencrypted"
|
||||||
|
fi
|
||||||
|
press ret
|
||||||
|
|
||||||
|
if $OEM; then
|
||||||
|
# OEM installs skip the user step entirely; the install starts right away.
|
||||||
|
sleep 2
|
||||||
|
capture_console "success-installer-13-install-started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
wait_for_screen "Username" 120
|
wait_for_screen "Username" 120
|
||||||
type_text "$GUEST_USER"
|
type_text "$GUEST_USER"
|
||||||
capture_console "success-installer-02-username"
|
capture_console "success-installer-06-username"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_screen "Password" 60
|
wait_for_screen "Password" 60
|
||||||
type_text "$GUEST_PASSWORD"
|
type_text "$GUEST_PASSWORD"
|
||||||
capture_console "success-installer-03-password"
|
capture_console "success-installer-07-password"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_screen "Must match" 60
|
wait_for_screen "Must match" 60
|
||||||
type_text "$GUEST_PASSWORD"
|
type_text "$GUEST_PASSWORD"
|
||||||
capture_console "success-installer-04-password-confirmation"
|
capture_console "success-installer-08-password-confirmation"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_screen "Full name" 60
|
wait_for_screen "Full name" 60
|
||||||
type_text "Omarchy Test"
|
type_text "Omarchy Test"
|
||||||
capture_console "success-installer-05-full-name"
|
capture_console "success-installer-09-full-name"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_screen "Email address" 60
|
wait_for_screen "Email address" 60
|
||||||
type_text "test@omarchy.org"
|
type_text "test@omarchy.org"
|
||||||
capture_console "success-installer-06-email"
|
capture_console "success-installer-10-email"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_screen "Hostname" 60
|
wait_for_screen "Hostname" 60
|
||||||
type_text "$GUEST_HOSTNAME"
|
type_text "$GUEST_HOSTNAME"
|
||||||
capture_console "success-installer-07-hostname"
|
capture_console "success-installer-11-hostname"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment
|
wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment
|
||||||
capture_console "success-installer-08-timezone"
|
capture_console "success-installer-12-timezone"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_screen "look right" 60
|
wait_for_screen "look right" 60
|
||||||
capture_console "success-installer-09-review"
|
capture_console "success-installer-13-review"
|
||||||
press ret
|
|
||||||
|
|
||||||
wait_for_screen "install disk" 60
|
|
||||||
capture_console "success-installer-10-install-disk"
|
|
||||||
press ret # the single virtio disk
|
|
||||||
|
|
||||||
wait_for_screen "recovery possible" 60
|
|
||||||
capture_console "success-installer-11-disk-warning-encrypted"
|
|
||||||
if ! $ENCRYPT; then
|
|
||||||
press ctrl-c # toggles the confirm to the unencrypted flow
|
|
||||||
wait_for_screen "without encryption" 30
|
|
||||||
capture_console "success-installer-12-disk-warning-unencrypted"
|
|
||||||
fi
|
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
capture_console "success-installer-13-install-started"
|
capture_console "success-installer-14-install-started"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The OEM first boot runs the user form on tty1 (omarchy-oem-setup) before
|
||||||
|
# SDDM. Encrypted OEM installs auto-unlock from the staged keyfile, so there
|
||||||
|
# is no LUKS prompt to answer here either way.
|
||||||
|
drive_oem_setup() {
|
||||||
|
log "Driving the OEM first-boot setup"
|
||||||
|
|
||||||
|
wait_for_screen "Username" 600
|
||||||
|
capture_console "success-oem-01-username"
|
||||||
|
type_text "$GUEST_USER"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "Password" 60
|
||||||
|
type_text "$GUEST_PASSWORD"
|
||||||
|
capture_console "success-oem-02-password"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "Must match" 60
|
||||||
|
type_text "$GUEST_PASSWORD"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "Full name" 60
|
||||||
|
type_text "Omarchy Test"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "Email address" 60
|
||||||
|
type_text "test@omarchy.org"
|
||||||
|
capture_console "success-oem-03-email"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "look right" 60
|
||||||
|
capture_console "success-oem-04-review"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
# Finalization (offline Node unpack, LUKS re-key on encrypted installs)
|
||||||
|
# runs before SDDM appears.
|
||||||
|
capture_console "success-oem-05-finalizing"
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_install() {
|
wait_for_install() {
|
||||||
@@ -835,7 +892,9 @@ install_phase() {
|
|||||||
drive_configurator
|
drive_configurator
|
||||||
wait_for_install
|
wait_for_install
|
||||||
|
|
||||||
if $ENCRYPT; then
|
if $OEM; then
|
||||||
|
drive_oem_setup
|
||||||
|
elif $ENCRYPT; then
|
||||||
unlock_luks
|
unlock_luks
|
||||||
fi
|
fi
|
||||||
bootstrap_ssh
|
bootstrap_ssh
|
||||||
|
|||||||
@@ -117,4 +117,5 @@ rm -f /run/omarchy-install/state.json
|
|||||||
--email-file /root/user_email_address.txt \
|
--email-file /root/user_email_address.txt \
|
||||||
--encrypt-file /root/user_encrypt_installation.txt \
|
--encrypt-file /root/user_encrypt_installation.txt \
|
||||||
--authorized-keys-file /root/authorized_keys \
|
--authorized-keys-file /root/authorized_keys \
|
||||||
--tailscale-authkey-file /root/tailscale_authkey
|
--tailscale-authkey-file /root/tailscale_authkey \
|
||||||
|
--oem-file /root/oem
|
||||||
@@ -136,7 +136,7 @@ Ukrainian|ua'
|
|||||||
|
|
||||||
keyboard_form
|
keyboard_form
|
||||||
|
|
||||||
# STEP 2: USER
|
# STEP 2: USER (run after the install mode is chosen — OEM installs skip it)
|
||||||
|
|
||||||
user_form() {
|
user_form() {
|
||||||
step "Let's setup your user account..."
|
step "Let's setup your user account..."
|
||||||
@@ -197,11 +197,12 @@ user_form() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
user_form
|
user_step() {
|
||||||
|
user_form
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
# Add manual padding since gum table -p doesn't respect padding
|
# Add manual padding since gum table -p doesn't respect padding
|
||||||
echo -e "Field,Value
|
echo -e "Field,Value
|
||||||
Username,$username
|
Username,$username
|
||||||
Password,$(printf "%${#password}s" | tr ' ' '*')
|
Password,$(printf "%${#password}s" | tr ' ' '*')
|
||||||
Full name,${full_name:-[Skipped]}
|
Full name,${full_name:-[Skipped]}
|
||||||
@@ -209,16 +210,17 @@ Email address,${email_address:-[Skipped]}
|
|||||||
Hostname,$hostname
|
Hostname,$hostname
|
||||||
Timezone,$timezone
|
Timezone,$timezone
|
||||||
Keyboard,$keyboard" |
|
Keyboard,$keyboard" |
|
||||||
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
|
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if gum confirm --negative "No, change it" "Does this look right?"; then
|
if gum confirm --negative "No, change it" "Does this look right?"; then
|
||||||
break
|
break
|
||||||
else
|
else
|
||||||
keyboard_form
|
keyboard_form
|
||||||
user_form
|
user_form
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# STEP 3: DISK
|
# STEP 3: DISK
|
||||||
|
|
||||||
@@ -347,11 +349,22 @@ wait_for_device() {
|
|||||||
|
|
||||||
# Save the configurator answers the installer consumes: git identity and
|
# Save the configurator answers the installer consumes: git identity and
|
||||||
# encryption choice as plain files, account details as archinstall credentials.
|
# encryption choice as plain files, account details as archinstall credentials.
|
||||||
|
# OEM installs defer the user to first boot: no credentials, and on encrypted
|
||||||
|
# targets the orchestrator generates a throwaway LUKS passphrase itself.
|
||||||
write_user_files() {
|
write_user_files() {
|
||||||
echo "$full_name" >user_full_name.txt
|
echo "$full_name" >user_full_name.txt
|
||||||
echo "$email_address" >user_email_address.txt
|
echo "$email_address" >user_email_address.txt
|
||||||
printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt
|
printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt
|
||||||
|
|
||||||
|
if $oem_install; then
|
||||||
|
cat <<-_EOF_ >user_credentials.json
|
||||||
|
{
|
||||||
|
"users": []
|
||||||
|
}
|
||||||
|
_EOF_
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
local password_escaped password_hash_escaped username_escaped
|
local password_escaped password_hash_escaped username_escaped
|
||||||
password_escaped=$(echo -n "$password" | jq -Rsa)
|
password_escaped=$(echo -n "$password" | jq -Rsa)
|
||||||
password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa)
|
password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa)
|
||||||
@@ -413,10 +426,10 @@ not_enough_space() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
run_partition_install() {
|
# Decide half of the free-space install: checks, free-space analysis, and the
|
||||||
local dry=false
|
# encryption confirm. Sets the globals run_partition_execute needs. Returns 1
|
||||||
[[ "${1:-}" == "dry" ]] && dry=true
|
# to fall back to the install-mode picker.
|
||||||
|
run_partition_decide() {
|
||||||
step "Checking for BitLocker on $disk"
|
step "Checking for BitLocker on $disk"
|
||||||
local bl_part
|
local bl_part
|
||||||
bl_part=$(detect_bitlocker || true)
|
bl_part=$(detect_bitlocker || true)
|
||||||
@@ -448,7 +461,8 @@ run_partition_install() {
|
|||||||
|
|
||||||
# An unlabeled disk has no partition table for parted to scan, so we
|
# An unlabeled disk has no partition table for parted to scan, so we
|
||||||
# synthesize a whole-disk free region and mklabel gpt before mkpart.
|
# synthesize a whole-disk free region and mklabel gpt before mkpart.
|
||||||
local FREE_SPACE_INFO needs_mklabel=false pt_type disk_size_b
|
local FREE_SPACE_INFO pt_type disk_size_b
|
||||||
|
needs_mklabel=false
|
||||||
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
|
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
|
||||||
if [[ -z "$pt_type" ]]; then
|
if [[ -z "$pt_type" ]]; then
|
||||||
disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null)
|
disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null)
|
||||||
@@ -491,7 +505,6 @@ run_partition_install() {
|
|||||||
local EFI_SIZE_B=$((2 * gib))
|
local EFI_SIZE_B=$((2 * gib))
|
||||||
local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation
|
local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation
|
||||||
|
|
||||||
local EFI_START_B EFI_END_B ROOT_START_B ROOT_END_B
|
|
||||||
EFI_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN ))
|
EFI_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN ))
|
||||||
EFI_END_B=$((EFI_START_B + EFI_SIZE_B))
|
EFI_END_B=$((EFI_START_B + EFI_SIZE_B))
|
||||||
ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN ))
|
ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN ))
|
||||||
@@ -554,7 +567,6 @@ run_partition_install() {
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
local esp_mount_in_target
|
|
||||||
if [[ "$encrypt_installation" == "true" ]]; then
|
if [[ "$encrypt_installation" == "true" ]]; then
|
||||||
esp_mount_in_target=/boot
|
esp_mount_in_target=/boot
|
||||||
else
|
else
|
||||||
@@ -563,12 +575,11 @@ run_partition_install() {
|
|||||||
|
|
||||||
kernel_choice=$(detect_kernel)
|
kernel_choice=$(detect_kernel)
|
||||||
|
|
||||||
local last_part efi_part_num root_part_num
|
local last_part
|
||||||
last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1)
|
last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1)
|
||||||
efi_part_num=$(( ${last_part:-0} + 1 ))
|
efi_part_num=$(( ${last_part:-0} + 1 ))
|
||||||
root_part_num=$(( efi_part_num + 1 ))
|
root_part_num=$(( efi_part_num + 1 ))
|
||||||
|
|
||||||
local efi_dev root_partition_device root_mapper
|
|
||||||
efi_dev=$(partition_path "$disk" "$efi_part_num")
|
efi_dev=$(partition_path "$disk" "$efi_part_num")
|
||||||
root_partition_device=$(partition_path "$disk" "$root_part_num")
|
root_partition_device=$(partition_path "$disk" "$root_part_num")
|
||||||
if [[ "$encrypt_installation" == "true" ]]; then
|
if [[ "$encrypt_installation" == "true" ]]; then
|
||||||
@@ -576,6 +587,14 @@ run_partition_install() {
|
|||||||
else
|
else
|
||||||
root_mapper="$root_partition_device"
|
root_mapper="$root_partition_device"
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute half of the free-space install: partition, format, mount, and write
|
||||||
|
# the pre-mounted (protected) install configuration. Runs after the user step
|
||||||
|
# so LUKS can be formatted with the user's password.
|
||||||
|
run_partition_execute() {
|
||||||
|
local dry=false
|
||||||
|
[[ "${1:-}" == "dry" ]] && dry=true
|
||||||
|
|
||||||
if $dry; then
|
if $dry; then
|
||||||
$needs_mklabel && say "[dry] would initialize GPT on $disk"
|
$needs_mklabel && say "[dry] would initialize GPT on $disk"
|
||||||
@@ -634,10 +653,6 @@ run_partition_install() {
|
|||||||
mount "$efi_dev" /mnt"$esp_mount_in_target"
|
mount "$efi_dev" /mnt"$esp_mount_in_target"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
clear
|
|
||||||
|
|
||||||
write_user_files
|
|
||||||
|
|
||||||
local luks_uuid_json="null" luks_uuid
|
local luks_uuid_json="null" luks_uuid
|
||||||
if [[ "$encrypt_installation" == "true" ]]; then
|
if [[ "$encrypt_installation" == "true" ]]; then
|
||||||
if $dry; then
|
if $dry; then
|
||||||
@@ -716,10 +731,6 @@ run_partition_install() {
|
|||||||
"version": "3.0.9"
|
"version": "3.0.9"
|
||||||
}
|
}
|
||||||
_EOF_
|
_EOF_
|
||||||
|
|
||||||
if $dry; then
|
|
||||||
print_dry_run_files
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disk_form() {
|
disk_form() {
|
||||||
@@ -771,8 +782,7 @@ open_partition_tool() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# If the disk has no partition table or no unallocated free space, the only
|
# If the disk has no partition table or no unallocated free space, the only
|
||||||
# viable path is a full-disk install. Skip the install-mode picker and go
|
# viable paths write the whole disk, so the free-space mode is not offered.
|
||||||
# straight to confirmation.
|
|
||||||
requires_full_disk_install() {
|
requires_full_disk_install() {
|
||||||
local pt_type free_space_b
|
local pt_type free_space_b
|
||||||
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
|
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
|
||||||
@@ -799,9 +809,12 @@ install_mode_form() {
|
|||||||
|
|
||||||
# The free-space/protected path registers an EFI boot entry and is UEFI-only.
|
# The free-space/protected path registers an EFI boot entry and is UEFI-only.
|
||||||
# Full-disk installs still support BIOS through the orchestrator's BIOS branch.
|
# Full-disk installs still support BIOS through the orchestrator's BIOS branch.
|
||||||
if [[ -d /sys/firmware/efi ]]; then
|
if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then
|
||||||
choices+=("Free space install (alongside existing data)")
|
choices+=("Free space install (alongside existing data)")
|
||||||
fi
|
fi
|
||||||
|
# OEM: install the whole system now, defer user setup to the machine's
|
||||||
|
# first boot — for machines built to be sold or handed on.
|
||||||
|
choices+=("OEM install (new owner creates their user on first boot)")
|
||||||
choices+=("Choose a different disk")
|
choices+=("Choose a different disk")
|
||||||
|
|
||||||
step "Let's select how to install Omarchy..."
|
step "Let's select how to install Omarchy..."
|
||||||
@@ -846,30 +859,41 @@ confirm_disk_overwrite() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Decide what and how to install: sets install_target (full_disk|free_space),
|
||||||
|
# oem_install, and encrypt_installation. Nothing is written to disk yet — the
|
||||||
|
# user step (skipped for OEM) runs between this and the execution below.
|
||||||
select_installation() {
|
select_installation() {
|
||||||
local full_disk_only
|
local full_disk_only
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
if requires_full_disk_install; then
|
if requires_full_disk_install; then
|
||||||
full_disk_only=true
|
full_disk_only=true
|
||||||
install_mode="Full disk install"
|
|
||||||
else
|
else
|
||||||
full_disk_only=false
|
full_disk_only=false
|
||||||
install_mode_form
|
|
||||||
fi
|
fi
|
||||||
|
install_mode_form
|
||||||
|
|
||||||
case "$install_mode" in
|
case "$install_mode" in
|
||||||
"Full disk install")
|
"Full disk install")
|
||||||
|
oem_install=false
|
||||||
if confirm_disk_overwrite; then
|
if confirm_disk_overwrite; then
|
||||||
|
install_target="full_disk"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if $full_disk_only; then
|
|
||||||
disk_form
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
"Free space install (alongside existing data)")
|
"OEM install"*)
|
||||||
if run_partition_install "$@"; then
|
oem_install=true
|
||||||
exit 0
|
if confirm_disk_overwrite; then
|
||||||
|
install_target="full_disk"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
oem_install=false
|
||||||
|
;;
|
||||||
|
"Free space install"*)
|
||||||
|
oem_install=false
|
||||||
|
if run_partition_decide; then
|
||||||
|
install_target="free_space"
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"Choose a different disk")
|
"Choose a different disk")
|
||||||
@@ -879,13 +903,41 @@ select_installation() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# STEP 3: WHERE AND HOW TO INSTALL
|
||||||
|
|
||||||
|
oem_install=false
|
||||||
|
install_target="full_disk"
|
||||||
|
|
||||||
disk_form
|
disk_form
|
||||||
select_installation "$@"
|
select_installation
|
||||||
|
|
||||||
|
# STEP 4: USER (OEM installs defer this to the machine's first boot)
|
||||||
|
|
||||||
|
if $oem_install; then
|
||||||
|
username=""
|
||||||
|
password=""
|
||||||
|
password_hash=""
|
||||||
|
full_name=""
|
||||||
|
email_address=""
|
||||||
|
hostname="omarchy"
|
||||||
|
timezone="UTC"
|
||||||
|
else
|
||||||
|
user_step
|
||||||
|
fi
|
||||||
|
|
||||||
clear
|
clear
|
||||||
|
|
||||||
write_user_files
|
write_user_files
|
||||||
|
|
||||||
|
if [[ $install_target == "free_space" ]]; then
|
||||||
|
run_partition_execute "$@"
|
||||||
|
|
||||||
|
if [[ ${1:-} == "dry" ]]; then
|
||||||
|
print_dry_run_files
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Setup partition layout
|
# Setup partition layout
|
||||||
disk_size=$(lsblk -bdno SIZE "$disk")
|
disk_size=$(lsblk -bdno SIZE "$disk")
|
||||||
mib=$((1024 * 1024))
|
mib=$((1024 * 1024))
|
||||||
@@ -902,14 +954,21 @@ main_partition_size=$((disk_size_in_mib - main_partition_start - gpt_backup_rese
|
|||||||
kernel_choice=$(detect_kernel)
|
kernel_choice=$(detect_kernel)
|
||||||
|
|
||||||
if [[ $encrypt_installation == true ]]; then
|
if [[ $encrypt_installation == true ]]; then
|
||||||
|
# OEM installs carry no password: the orchestrator generates a throwaway
|
||||||
|
# LUKS passphrase and stages it for the first-boot re-key.
|
||||||
|
if $oem_install; then
|
||||||
|
encryption_password_line=""
|
||||||
|
else
|
||||||
|
encryption_password_line=",
|
||||||
|
\"encryption_password\": $(echo -n "$password" | jq -Rsa)"
|
||||||
|
fi
|
||||||
disk_encryption_config=$(cat <<-_EOF_
|
disk_encryption_config=$(cat <<-_EOF_
|
||||||
,
|
,
|
||||||
"disk_encryption": {
|
"disk_encryption": {
|
||||||
"encryption_type": "luks",
|
"encryption_type": "luks",
|
||||||
"lvm_volumes": [],
|
"lvm_volumes": [],
|
||||||
"iter_time": 2000,
|
"iter_time": 2000,
|
||||||
"partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ],
|
"partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ]$encryption_password_line
|
||||||
"encryption_password": $(echo -n "$password" | jq -Rsa)
|
|
||||||
}
|
}
|
||||||
_EOF_
|
_EOF_
|
||||||
)
|
)
|
||||||
@@ -927,6 +986,7 @@ cat <<-_EOF_ >user_configuration.json
|
|||||||
"custom_commands": [],
|
"custom_commands": [],
|
||||||
"omarchy_install": {
|
"omarchy_install": {
|
||||||
"mode": "full_disk",
|
"mode": "full_disk",
|
||||||
|
"oem": $oem_install,
|
||||||
"target_mount": "/mnt",
|
"target_mount": "/mnt",
|
||||||
"boot": {
|
"boot": {
|
||||||
"esp_mount": "/boot",
|
"esp_mount": "/boot",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
# Anything less than the pair means this isn't an autoinstall drive, and the
|
# Anything less than the pair means this isn't an autoinstall drive, and the
|
||||||
# caller falls back to the wizard.
|
# caller falls back to the wizard.
|
||||||
#
|
#
|
||||||
|
# An `oem` marker file stands in for user_credentials.json: OEM installs defer
|
||||||
|
# user creation to first boot, so imaging rigs ship no credentials at all.
|
||||||
|
#
|
||||||
# The optional argument prefixes every path the script touches; tests use it to
|
# The optional argument prefixes every path the script touches; tests use it to
|
||||||
# run against a sandbox instead of the live system.
|
# run against a sandbox instead of the live system.
|
||||||
|
|
||||||
@@ -36,9 +39,9 @@ mkdir -p "$mnt"
|
|||||||
mount -o ro "$device" "$mnt" || exit 1
|
mount -o ro "$device" "$mnt" || exit 1
|
||||||
|
|
||||||
loaded=1
|
loaded=1
|
||||||
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
|
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/oem ]]; then
|
||||||
files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json)
|
files=("$mnt"/user_configuration.json)
|
||||||
for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
|
for file in user_credentials.json oem user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
|
||||||
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
|
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
|
||||||
done
|
done
|
||||||
if cp "${files[@]}" "$dest"/; then
|
if cp "${files[@]}" "$dest"/; then
|
||||||
|
|||||||
@@ -321,11 +321,13 @@ install_progress() {
|
|||||||
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
|
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
|
||||||
"Configuring hibernation") lo=700 hi=720 tau=10 ;;
|
"Configuring hibernation") lo=700 hi=720 tau=10 ;;
|
||||||
"Configuring system") lo=720 hi=850 tau=120 ;;
|
"Configuring system") lo=720 hi=850 tau=120 ;;
|
||||||
"Finalizing Limine boot") lo=850 hi=950 tau=90 ;;
|
"Staging OEM state") lo=850 hi=855 tau=5 ;;
|
||||||
|
"Finalizing Limine boot") lo=855 hi=950 tau=90 ;;
|
||||||
"Finalizing user") lo=950 hi=978 tau=30 ;;
|
"Finalizing user") lo=950 hi=978 tau=30 ;;
|
||||||
"Configuring login") lo=978 hi=988 tau=5 ;;
|
"Configuring login") lo=978 hi=988 tau=5 ;;
|
||||||
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
|
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
|
||||||
"Validating boot setup") lo=994 hi=1000 tau=5 ;;
|
"Validating boot setup") lo=994 hi=998 tau=5 ;;
|
||||||
|
"Creating factory snapshot") lo=998 hi=1000 tau=5 ;;
|
||||||
*)
|
*)
|
||||||
# Unknown names used to inherit a 1->99 sweep. Derive an even band from
|
# Unknown names used to inherit a 1->99 sweep. Derive an even band from
|
||||||
# the published index instead; with no index, a bounded slice, so a
|
# the published index instead; with no index, a bounded slice, so a
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
|
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
|
||||||
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
|
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
|
||||||
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
|
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
|
||||||
|
--oem-file) export OMARCHY_INSTALL_OEM_FILE="$2"; shift 2 ;;
|
||||||
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
|
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -25,6 +26,7 @@ class InstallContext:
|
|||||||
user_credentials: dict
|
user_credentials: dict
|
||||||
arch_config_path: Path
|
arch_config_path: Path
|
||||||
omarchy_install: dict[str, Any]
|
omarchy_install: dict[str, Any]
|
||||||
|
oem: bool = False
|
||||||
|
|
||||||
target: Path = Path("/mnt")
|
target: Path = Path("/mnt")
|
||||||
omarchy_path: Path = Path("/usr/share/omarchy")
|
omarchy_path: Path = Path("/usr/share/omarchy")
|
||||||
@@ -48,10 +50,37 @@ class InstallContext:
|
|||||||
user_configuration = json.loads(config_path.read_text())
|
user_configuration = json.loads(config_path.read_text())
|
||||||
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
|
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 = dict(user_configuration)
|
||||||
arch_configuration.pop("omarchy_install", None)
|
arch_configuration.pop("omarchy_install", None)
|
||||||
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
|
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
|
||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
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 = state_dir / "archinstall-user_configuration.json"
|
||||||
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
|
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")),
|
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")),
|
tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
|
||||||
user_configuration=user_configuration,
|
user_configuration=user_configuration,
|
||||||
user_credentials=json.loads(creds_path.read_text()),
|
user_credentials=user_credentials,
|
||||||
arch_config_path=arch_config_path,
|
arch_config_path=arch_config_path,
|
||||||
omarchy_install=omarchy_install,
|
omarchy_install=omarchy_install,
|
||||||
|
oem=oem,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
)
|
)
|
||||||
disk_config = user_configuration.get("disk_config", {})
|
disk_config = user_configuration.get("disk_config", {})
|
||||||
@@ -77,7 +107,12 @@ class InstallContext:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def username(self) -> str:
|
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
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
@@ -91,6 +126,24 @@ class InstallContext:
|
|||||||
return self.mode == "protected"
|
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]:
|
def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
|
||||||
disk_config = user_configuration.get("disk_config", {})
|
disk_config = user_configuration.get("disk_config", {})
|
||||||
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
|
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,
|
arch_install_system,
|
||||||
configure_hibernation,
|
configure_hibernation,
|
||||||
run_system_finalizer,
|
run_system_finalizer,
|
||||||
|
stage_oem_state,
|
||||||
finalize_limine_boot,
|
finalize_limine_boot,
|
||||||
run_chroot_finalizer,
|
run_chroot_finalizer,
|
||||||
configure_dns_resolver,
|
configure_dns_resolver,
|
||||||
@@ -41,6 +42,7 @@ def build_phases(ctx: InstallContext):
|
|||||||
configure_ssh_access,
|
configure_ssh_access,
|
||||||
configure_tailscale,
|
configure_tailscale,
|
||||||
validate_boot,
|
validate_boot,
|
||||||
|
create_factory_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -49,6 +51,9 @@ def build_phases(ctx: InstallContext):
|
|||||||
("Installing Arch + Omarchy", arch_install_system),
|
("Installing Arch + Omarchy", arch_install_system),
|
||||||
("Configuring hibernation", configure_hibernation),
|
("Configuring hibernation", configure_hibernation),
|
||||||
("Configuring system", run_system_finalizer),
|
("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 Limine boot", finalize_limine_boot),
|
||||||
("Finalizing user", run_chroot_finalizer),
|
("Finalizing user", run_chroot_finalizer),
|
||||||
("Configuring login", configure_login),
|
("Configuring login", configure_login),
|
||||||
@@ -56,6 +61,7 @@ def build_phases(ctx: InstallContext):
|
|||||||
("Configuring Tailscale", configure_tailscale),
|
("Configuring Tailscale", configure_tailscale),
|
||||||
("Configuring DNS resolver", configure_dns_resolver),
|
("Configuring DNS resolver", configure_dns_resolver),
|
||||||
("Validating boot setup", validate_boot),
|
("Validating boot setup", validate_boot),
|
||||||
|
("Creating factory snapshot", create_factory_snapshot),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +72,8 @@ def main() -> int:
|
|||||||
error(f"Configuration error: {e}")
|
error(f"Configuration error: {e}")
|
||||||
return 2
|
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 (
|
from .phases_impl import (
|
||||||
boost_cpu_governor,
|
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:
|
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)
|
_mask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
|
||||||
try:
|
try:
|
||||||
_run_target_setup_command(
|
_run_target_setup_command(ctx, cmd)
|
||||||
ctx,
|
|
||||||
["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"],
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
_unmask_mkinitcpio_pacman_hooks(ctx, ctx.target, TARGET_DEFERRED_BOOT_HOOKS)
|
_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:
|
def finalize_limine_boot(ctx: InstallContext) -> None:
|
||||||
"""Finalize Limine after target system setup has written all dynamic
|
"""Finalize Limine after target system setup has written all dynamic
|
||||||
boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings).
|
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:
|
def run_chroot_finalizer(ctx: InstallContext) -> None:
|
||||||
|
if ctx.oem:
|
||||||
|
info("› OEM install: user finalization deferred to first boot")
|
||||||
|
return
|
||||||
|
|
||||||
_run_target_setup_command(
|
_run_target_setup_command(
|
||||||
ctx,
|
ctx,
|
||||||
["/usr/bin/omarchy-finalize-user", "--force", "--first-install"],
|
["/usr/bin/omarchy-finalize-user", "--force", "--first-install"],
|
||||||
@@ -1285,29 +1413,32 @@ def configure_login(ctx: InstallContext) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
autologin_conf = sddm_dir / "autologin.conf"
|
autologin_conf = sddm_dir / "autologin.conf"
|
||||||
if ctx.encrypt:
|
if ctx.encrypt and not ctx.oem:
|
||||||
autologin_conf.write_text(
|
autologin_conf.write_text(
|
||||||
"[Autologin]\n"
|
"[Autologin]\n"
|
||||||
f"User={ctx.username}\n"
|
f"User={ctx.username}\n"
|
||||||
"Session=omarchy.desktop\n"
|
"Session=omarchy.desktop\n"
|
||||||
)
|
)
|
||||||
else:
|
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)
|
autologin_conf.unlink(missing_ok=True)
|
||||||
|
|
||||||
state_dir = ctx.target / "var" / "lib" / "sddm"
|
if not ctx.oem:
|
||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
state_dir = ctx.target / "var" / "lib" / "sddm"
|
||||||
(state_dir / "state.conf").write_text(
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
f"[Last]\nSession=omarchy.desktop\nUser={ctx.username}\n"
|
(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 = ctx.target / "etc" / "systemd" / "system" / "getty@tty1.service.d" / "autologin.conf"
|
||||||
autologin.unlink(missing_ok=True)
|
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(
|
subprocess.run(
|
||||||
["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"],
|
["arch-chroot", str(ctx.target), "systemctl", "enable", "sddm.service"],
|
||||||
check=False, capture_output=True,
|
check=False, capture_output=True,
|
||||||
@@ -1326,24 +1457,36 @@ def configure_ssh_access(ctx: InstallContext) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
keys = _authorized_keys(ctx.authorized_keys_path)
|
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"
|
if ctx.oem:
|
||||||
ssh_dir.mkdir(parents=True, exist_ok=True)
|
# No user to authorize yet. Stage the keys in OEM state for
|
||||||
ssh_dir.chmod(0o700)
|
# 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"
|
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
|
||||||
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
authorized_keys.chmod(0o600)
|
ssh_dir.chmod(0o700)
|
||||||
|
|
||||||
# Ask the target for the uid rather than assuming the first user is 1000.
|
authorized_keys = ssh_dir / "authorized_keys"
|
||||||
# Uncaptured so a failure shows up in the install log, and checked because
|
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
|
||||||
# a root-owned authorized_keys is one sshd refuses to read.
|
authorized_keys.chmod(0o600)
|
||||||
subprocess.run(
|
|
||||||
["arch-chroot", str(ctx.target), "chown", "-R",
|
# Ask the target for the uid rather than assuming the first user is 1000.
|
||||||
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
|
# Uncaptured so a failure shows up in the install log, and checked because
|
||||||
check=True,
|
# 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")
|
info("› enabling sshd")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -1550,6 +1693,34 @@ def validate_boot(ctx: InstallContext) -> None:
|
|||||||
if ctx.is_protected:
|
if ctx.is_protected:
|
||||||
_validate_pre_mounted_filesystems(ctx)
|
_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:
|
def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
|
||||||
"""Never hand over a system whose UKI rebuild hook is still masked.
|
"""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']}")
|
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")
|
CPU_SYSFS = Path("/sys/devices/system/cpu")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,35 @@ run_load || fail "required pair plus authorized_keys loads"
|
|||||||
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
|
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
|
||||||
pass "present optional files are copied, absent ones skipped"
|
pass "present optional files are copied, absent ones skipped"
|
||||||
|
|
||||||
|
# An oem marker replaces user_credentials.json: OEM installs defer user
|
||||||
|
# creation to first boot, so imaging rigs ship no credentials at all.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
|
||||||
|
: >"$sandbox/media/oem"
|
||||||
|
run_load || fail "config plus oem marker loads"
|
||||||
|
[[ -f $sandbox/root/oem ]] || fail "oem marker is copied"
|
||||||
|
[[ ! -e $sandbox/root/user_credentials.json ]] || fail "oem drive copies no credentials"
|
||||||
|
pass "oem marker stands in for credentials"
|
||||||
|
|
||||||
|
# The oem marker and credentials can coexist (rig supplies its own LUKS
|
||||||
|
# passphrase in the credentials file); both are copied.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
: >"$sandbox/media/oem"
|
||||||
|
run_load || fail "oem marker plus credentials loads"
|
||||||
|
[[ -f $sandbox/root/oem && -f $sandbox/root/user_credentials.json ]] || fail "both oem marker and credentials are copied"
|
||||||
|
pass "oem marker plus credentials copies both"
|
||||||
|
|
||||||
|
# An oem marker without the configuration is still not an autoinstall drive.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
: >"$sandbox/media/oem"
|
||||||
|
! run_load || fail "oem marker alone is not an autoinstall drive"
|
||||||
|
[[ ! -e $sandbox/root/oem ]] || fail "oem marker alone copies nothing"
|
||||||
|
pass "oem marker alone falls back to the wizard"
|
||||||
|
|
||||||
# Half the required pair is not an autoinstall drive: unmount and fall back.
|
# Half the required pair is not an autoinstall drive: unmount and fall back.
|
||||||
new_sandbox
|
new_sandbox
|
||||||
attach_drive cidata
|
attach_drive cidata
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class ConfigureSshAccessTest(unittest.TestCase):
|
|||||||
if authorized_keys is not None:
|
if authorized_keys is not None:
|
||||||
authorized_keys_path = self.target / "authorized_keys"
|
authorized_keys_path = self.target / "authorized_keys"
|
||||||
authorized_keys_path.write_text(authorized_keys)
|
authorized_keys_path.write_text(authorized_keys)
|
||||||
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path)
|
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, oem=False)
|
||||||
|
|
||||||
def configure(self, **kwargs):
|
def configure(self, **kwargs):
|
||||||
phases_impl.configure_ssh_access(self.ctx(**kwargs))
|
phases_impl.configure_ssh_access(self.ctx(**kwargs))
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Unit tests for OEM install support in the orchestrator.
|
||||||
|
|
||||||
|
Covers the InstallContext OEM plumbing (marker file, credential-less input,
|
||||||
|
throwaway LUKS passphrase injection) and the stage_oem_state /
|
||||||
|
create_factory_snapshot / configure_login phases against a temp target with
|
||||||
|
subprocess mocked out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso"))
|
||||||
|
|
||||||
|
sys.modules.setdefault(
|
||||||
|
"orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter")
|
||||||
|
)
|
||||||
|
|
||||||
|
from orchestrator import phases_impl # noqa: E402
|
||||||
|
from orchestrator.context import InstallContext # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ContextOemTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.dir = Path(self.tmp.name)
|
||||||
|
self.state_dir = self.dir / "state"
|
||||||
|
|
||||||
|
self.env = {
|
||||||
|
"OMARCHY_INSTALL_CONFIG": str(self.dir / "user_configuration.json"),
|
||||||
|
"OMARCHY_INSTALL_CREDS": str(self.dir / "user_credentials.json"),
|
||||||
|
"OMARCHY_INSTALL_STATE_DIR": str(self.state_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
def write_config(self, config):
|
||||||
|
(self.dir / "user_configuration.json").write_text(json.dumps(config))
|
||||||
|
|
||||||
|
def write_creds(self, creds):
|
||||||
|
(self.dir / "user_credentials.json").write_text(json.dumps(creds))
|
||||||
|
|
||||||
|
def from_env(self, **extra_env):
|
||||||
|
env = {**self.env, **extra_env}
|
||||||
|
with mock.patch.dict(os.environ, env, clear=False):
|
||||||
|
for key in ("OMARCHY_INSTALL_OEM_FILE",):
|
||||||
|
if key not in env:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
return InstallContext.from_env()
|
||||||
|
|
||||||
|
def base_config(self, oem=None, disk_encryption=None):
|
||||||
|
config = {
|
||||||
|
"disk_config": {"config_type": "default_layout"},
|
||||||
|
"omarchy_install": {"mode": "full_disk", "target_mount": "/mnt"},
|
||||||
|
}
|
||||||
|
if oem is not None:
|
||||||
|
config["omarchy_install"]["oem"] = oem
|
||||||
|
if disk_encryption is not None:
|
||||||
|
config["disk_config"]["disk_encryption"] = disk_encryption
|
||||||
|
return config
|
||||||
|
|
||||||
|
def test_oem_flag_from_config(self):
|
||||||
|
self.write_config(self.base_config(oem=True))
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertTrue(ctx.oem)
|
||||||
|
self.assertEqual(ctx.user_credentials, {"users": []})
|
||||||
|
self.assertEqual(ctx.username, "")
|
||||||
|
|
||||||
|
def test_oem_marker_file_arms_oem_without_credentials(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
marker = self.dir / "oem"
|
||||||
|
marker.touch()
|
||||||
|
ctx = self.from_env(OMARCHY_INSTALL_OEM_FILE=str(marker))
|
||||||
|
self.assertTrue(ctx.oem)
|
||||||
|
self.assertTrue(ctx.omarchy_install["oem"])
|
||||||
|
|
||||||
|
def test_absent_marker_file_is_not_oem(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
self.write_creds({"users": [{"username": "jeff"}]})
|
||||||
|
ctx = self.from_env(OMARCHY_INSTALL_OEM_FILE=str(self.dir / "missing-oem"))
|
||||||
|
self.assertFalse(ctx.oem)
|
||||||
|
self.assertEqual(ctx.username, "jeff")
|
||||||
|
|
||||||
|
def test_missing_credentials_without_oem_raises(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "credentials file missing"):
|
||||||
|
self.from_env()
|
||||||
|
|
||||||
|
def test_oem_generates_throwaway_luks_passphrase(self):
|
||||||
|
self.write_config(self.base_config(oem=True, disk_encryption={
|
||||||
|
"encryption_type": "luks", "partitions": ["x"],
|
||||||
|
}))
|
||||||
|
ctx = self.from_env()
|
||||||
|
|
||||||
|
password = ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"]
|
||||||
|
self.assertTrue(password)
|
||||||
|
self.assertEqual(ctx.user_credentials["encryption_password"], password)
|
||||||
|
|
||||||
|
# archinstall reads both files from disk; the injected passphrase must
|
||||||
|
# be present in each.
|
||||||
|
arch_config = json.loads(ctx.arch_config_path.read_text())
|
||||||
|
self.assertEqual(
|
||||||
|
arch_config["disk_config"]["disk_encryption"]["encryption_password"], password
|
||||||
|
)
|
||||||
|
synthesized = json.loads(ctx.creds_path.read_text())
|
||||||
|
self.assertEqual(synthesized["encryption_password"], password)
|
||||||
|
|
||||||
|
def test_oem_reuses_rig_supplied_passphrase(self):
|
||||||
|
self.write_config(self.base_config(oem=True, disk_encryption={
|
||||||
|
"encryption_type": "luks", "partitions": ["x"],
|
||||||
|
}))
|
||||||
|
self.write_creds({"users": [], "encryption_password": "rig-secret"})
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertEqual(
|
||||||
|
ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"],
|
||||||
|
"rig-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_oem_unencrypted_touches_nothing(self):
|
||||||
|
self.write_config(self.base_config(oem=True))
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertNotIn("encryption_password", ctx.user_credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def make_ctx(target, **overrides):
|
||||||
|
defaults = dict(
|
||||||
|
target=target,
|
||||||
|
oem=True,
|
||||||
|
encrypt=False,
|
||||||
|
username="",
|
||||||
|
omarchy_install={"mode": "full_disk", "oem": True, "storage": {}},
|
||||||
|
user_configuration={"disk_config": {}},
|
||||||
|
user_credentials={"users": []},
|
||||||
|
state_dir=target / "state",
|
||||||
|
authorized_keys_path=None,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return types.SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class StageOemStateTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name) / "mnt"
|
||||||
|
self.target.mkdir()
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_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_oem_support(self):
|
||||||
|
service = self.target / "usr/share/omarchy/install/oem/omarchy-oem-setup.service"
|
||||||
|
service.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
service.write_text("[Unit]\n")
|
||||||
|
setup_bin = self.target / "usr/bin/omarchy-oem-setup"
|
||||||
|
setup_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
setup_bin.write_text("#!/bin/bash\n")
|
||||||
|
|
||||||
|
def oem_dir(self):
|
||||||
|
return self.target / "var/lib/omarchy/oem"
|
||||||
|
|
||||||
|
def test_non_oem_install_stages_only_the_node_tarball(self):
|
||||||
|
ctx = make_ctx(self.target, oem=False)
|
||||||
|
phases_impl.stage_oem_state(ctx)
|
||||||
|
|
||||||
|
self.assertTrue((self.oem_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists())
|
||||||
|
self.assertFalse((self.oem_dir() / "pending").exists())
|
||||||
|
self.assertFalse((self.target / "etc/systemd/system/omarchy-oem-setup.service").exists())
|
||||||
|
|
||||||
|
def test_oem_against_runtime_without_support_fails_clearly(self):
|
||||||
|
ctx = make_ctx(self.target)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "does not ship"):
|
||||||
|
phases_impl.stage_oem_state(ctx)
|
||||||
|
|
||||||
|
def test_oem_without_node_tarball_fails(self):
|
||||||
|
for tarball in self.packages.glob("*.tar.gz"):
|
||||||
|
tarball.unlink()
|
||||||
|
self.install_runtime_oem_support()
|
||||||
|
ctx = make_ctx(self.target)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
|
||||||
|
phases_impl.stage_oem_state(ctx)
|
||||||
|
|
||||||
|
def test_oem_arms_first_boot_setup(self):
|
||||||
|
self.install_runtime_oem_support()
|
||||||
|
ctx = make_ctx(self.target)
|
||||||
|
phases_impl.stage_oem_state(ctx)
|
||||||
|
|
||||||
|
self.assertTrue((self.oem_dir() / "pending").exists())
|
||||||
|
self.assertTrue((self.target / "etc/systemd/system/omarchy-oem-setup.service").exists())
|
||||||
|
link = self.target / "etc/systemd/system/multi-user.target.wants/omarchy-oem-setup.service"
|
||||||
|
self.assertTrue(link.is_symlink())
|
||||||
|
self.assertEqual(
|
||||||
|
os.readlink(link), "/etc/systemd/system/omarchy-oem-setup.service"
|
||||||
|
)
|
||||||
|
# Unencrypted: no LUKS staging.
|
||||||
|
self.assertFalse((self.oem_dir() / "luks-key").exists())
|
||||||
|
|
||||||
|
def test_oem_encrypted_stages_luks_auto_unlock(self):
|
||||||
|
self.install_runtime_oem_support()
|
||||||
|
ctx = make_ctx(
|
||||||
|
self.target,
|
||||||
|
user_configuration={"disk_config": {"disk_encryption": {
|
||||||
|
"encryption_type": "luks",
|
||||||
|
"encryption_password": "throwaway-secret",
|
||||||
|
}}},
|
||||||
|
)
|
||||||
|
phases_impl.stage_oem_state(ctx)
|
||||||
|
|
||||||
|
# Byte-for-byte the slot passphrase — no trailing newline.
|
||||||
|
self.assertEqual((self.oem_dir() / "luks-key").read_text(), "throwaway-secret")
|
||||||
|
keyfile = self.target / "etc/omarchy/oem.key"
|
||||||
|
self.assertEqual(keyfile.read_text(), "throwaway-secret")
|
||||||
|
self.assertEqual(keyfile.stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
cmdline = (self.target / "etc/limine-entry-tool.d/99-omarchy-oem-unlock.conf").read_text()
|
||||||
|
self.assertIn("cryptkey=rootfs:/etc/omarchy/oem.key", cmdline)
|
||||||
|
files = (self.target / "etc/mkinitcpio.conf.d/99-omarchy-oem-key.conf").read_text()
|
||||||
|
self.assertIn("FILES+=(/etc/omarchy/oem.key)", files)
|
||||||
|
|
||||||
|
def test_oem_pre_encrypted_without_passphrase_fails(self):
|
||||||
|
self.install_runtime_oem_support()
|
||||||
|
ctx = make_ctx(
|
||||||
|
self.target,
|
||||||
|
omarchy_install={"mode": "protected", "oem": True, "storage": {"luks_uuid": "abc"}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "passphrase"):
|
||||||
|
phases_impl.stage_oem_state(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureLoginOemTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(
|
||||||
|
phases_impl.subprocess, "run",
|
||||||
|
side_effect=lambda cmd, **kw: (self.calls.append(cmd), CompletedProcess(cmd, 0))[1],
|
||||||
|
)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
def test_oem_login_leaves_user_state_to_first_boot(self):
|
||||||
|
ctx = make_ctx(self.target, encrypt=True)
|
||||||
|
phases_impl.configure_login(ctx)
|
||||||
|
|
||||||
|
self.assertTrue((self.target / "etc/sddm.conf.d/99-omarchy-login.conf").exists())
|
||||||
|
self.assertFalse((self.target / "etc/sddm.conf.d/autologin.conf").exists())
|
||||||
|
self.assertFalse((self.target / "var/lib/sddm/state.conf").exists())
|
||||||
|
self.assertTrue(any("sddm.service" in cmd for cmd in self.calls))
|
||||||
|
|
||||||
|
def test_normal_encrypted_login_still_autologs_in(self):
|
||||||
|
ctx = make_ctx(self.target, oem=False, encrypt=True, username="jeff")
|
||||||
|
phases_impl.configure_login(ctx)
|
||||||
|
|
||||||
|
autologin = (self.target / "etc/sddm.conf.d/autologin.conf").read_text()
|
||||||
|
self.assertIn("User=jeff", autologin)
|
||||||
|
state = (self.target / "var/lib/sddm/state.conf").read_text()
|
||||||
|
self.assertIn("User=jeff", state)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureSshAccessOemTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name) / "mnt"
|
||||||
|
self.target.mkdir()
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if "ufw" in cmd:
|
||||||
|
rules = self.target / "etc/ufw/user.rules"
|
||||||
|
rules.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rules.write_text("-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n")
|
||||||
|
return CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
self.calls = []
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
def test_oem_stages_keys_for_first_boot(self):
|
||||||
|
keys = Path(self.tmp.name) / "authorized_keys"
|
||||||
|
keys.write_text("ssh-ed25519 AAAA rig@host\n")
|
||||||
|
ctx = make_ctx(self.target, authorized_keys_path=keys)
|
||||||
|
|
||||||
|
phases_impl.configure_ssh_access(ctx)
|
||||||
|
|
||||||
|
staged = self.target / "var/lib/omarchy/oem/authorized_keys"
|
||||||
|
self.assertEqual(staged.read_text(), "ssh-ed25519 AAAA rig@host\n")
|
||||||
|
self.assertEqual(staged.stat().st_mode & 0o777, 0o600)
|
||||||
|
# No user yet — nothing under /home, no chown.
|
||||||
|
self.assertFalse((self.target / "home").exists())
|
||||||
|
self.assertFalse(any("chown" in cmd for cmd in self.calls))
|
||||||
|
# The door is still opened for first boot.
|
||||||
|
self.assertTrue(any("sshd.service" in cmd for cmd in self.calls))
|
||||||
|
|
||||||
|
|
||||||
|
class CreateFactorySnapshotTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name) / "mnt"
|
||||||
|
self.target.mkdir()
|
||||||
|
self.state_dir = Path(self.tmp.name) / "state"
|
||||||
|
self.state_dir.mkdir()
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
self.findmnt = {
|
||||||
|
"FSTYPE": "btrfs",
|
||||||
|
"OPTIONS": "rw,noatime,compress=zstd:3,subvol=/@",
|
||||||
|
"SOURCE": "/dev/mapper/omarchy_root[/@]",
|
||||||
|
}
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if cmd[0] == "findmnt":
|
||||||
|
return CompletedProcess(cmd, 0, stdout=self.findmnt[cmd[2]] + "\n", stderr="")
|
||||||
|
if cmd[0] == "mount":
|
||||||
|
# Simulate the top level containing @.
|
||||||
|
(Path(cmd[-1]) / "@").mkdir(parents=True, exist_ok=True)
|
||||||
|
return CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
def ctx(self):
|
||||||
|
return make_ctx(self.target, oem=False, state_dir=self.state_dir)
|
||||||
|
|
||||||
|
def test_snapshots_root_as_factory(self):
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
|
||||||
|
top = str(self.state_dir / "factory-top")
|
||||||
|
self.assertIn(["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top], self.calls)
|
||||||
|
self.assertIn(
|
||||||
|
["btrfs", "subvolume", "snapshot", "-r", f"{top}/@", f"{top}/@factory"],
|
||||||
|
self.calls,
|
||||||
|
)
|
||||||
|
self.assertIn(["umount", top], self.calls)
|
||||||
|
|
||||||
|
def test_non_btrfs_target_skips(self):
|
||||||
|
self.findmnt["FSTYPE"] = "ext4"
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
|
||||||
|
|
||||||
|
def test_non_subvol_root_skips(self):
|
||||||
|
self.findmnt["OPTIONS"] = "rw,noatime"
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user