Compare commits
36
Commits
No files matched your search
@@ -2,40 +2,34 @@ name: Nightly ISO Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '1 0 * * *' # Daily at 00:01 UTC
|
- cron: "1 0 * * *" # Daily at 00:01 UTC
|
||||||
workflow_dispatch: # Allow manual triggering
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-iso:
|
build-iso:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
container:
|
|
||||||
image: archlinux/archlinux:latest
|
|
||||||
options: --privileged
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build ISO in Arch Linux container
|
- name: Free Disk Space
|
||||||
run: |
|
uses: jlumbroso/free-disk-space@v1.3.1
|
||||||
docker run --rm \
|
|
||||||
--privileged \
|
|
||||||
-v "${{ github.workspace }}:/workspace" \
|
|
||||||
-w /workspace \
|
|
||||||
archlinux:latest \
|
|
||||||
bash -c "
|
|
||||||
pacman -Syu --noconfirm && \
|
|
||||||
chmod +x build_iso.sh && \
|
|
||||||
./build_iso.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
- name: Upload ISO artifact
|
- name: Build ISO
|
||||||
uses: actions/upload-artifact@v4
|
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
|
||||||
with:
|
|
||||||
name: omarchy-iso-${{ github.run_number }}
|
- name: Delete previously uploaded ISO artifact
|
||||||
path: out/*.iso
|
uses: philips-labs/action-delete-artifacts@v1.0.0
|
||||||
retention-days: 30
|
with:
|
||||||
|
workflow: nightly-build.yml
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Upload newly built ISO artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: omarchy-iso-${{ github.run_number }}
|
||||||
|
path: ./release/*.iso
|
||||||
|
retention-days: 1
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Anton Hvornum
|
Copyright (c) 2026 Anton Hvornum
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -20,10 +20,74 @@ Despite the local folder name, the first argument is the Omarchy source checkout
|
|||||||
|
|
||||||
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
|
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
|
||||||
|
|
||||||
|
## Autoinstall
|
||||||
|
|
||||||
|
The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
|
||||||
|
|
||||||
|
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one.
|
||||||
|
|
||||||
|
### Configuration files
|
||||||
|
|
||||||
|
These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`.
|
||||||
|
|
||||||
|
| File | Required | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard |
|
||||||
|
| `user_credentials.json` | Yes | Username and password hash |
|
||||||
|
| `user_full_name.txt` | No | Git full name |
|
||||||
|
| `user_email_address.txt` | No | Git email |
|
||||||
|
| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false |
|
||||||
|
| `authorized_keys` | No | SSH public keys in sshd's own format, one per line |
|
||||||
|
| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot |
|
||||||
|
|
||||||
|
Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
|
||||||
|
|
||||||
|
Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption.
|
||||||
|
|
||||||
|
`authorized_keys` is the same file sshd reads — copy your own or write one key per line:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh-ed25519 AAAA... you@host
|
||||||
|
```
|
||||||
|
|
||||||
|
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach.
|
||||||
|
|
||||||
|
When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs.
|
||||||
|
|
||||||
|
### Building the drive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir cidata
|
||||||
|
cp user_configuration.json user_credentials.json authorized_keys cidata/
|
||||||
|
genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxmox example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qm create 101 --name my-omarchy \
|
||||||
|
--bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \
|
||||||
|
--ostype l26 --scsihw virtio-scsi-single \
|
||||||
|
--efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \
|
||||||
|
--scsi0 local-lvm:40,discard=on,iothread=1 \
|
||||||
|
--net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \
|
||||||
|
--ide2 local:iso/omarchy.iso,media=cdrom \
|
||||||
|
--ide3 local:iso/cidata.iso,media=cdrom \
|
||||||
|
--boot order='scsi0;ide2'
|
||||||
|
|
||||||
|
qm start 101
|
||||||
|
```
|
||||||
|
|
||||||
|
Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes.
|
||||||
|
|
||||||
|
Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot.
|
||||||
|
|
||||||
## Testing the ISO
|
## Testing the ISO
|
||||||
|
|
||||||
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
|
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
|
||||||
|
|
||||||
|
Run `./test/all` for the unit tests, which cover cidata autoinstall loading and the orchestrator's SSH access phase without needing a built ISO.
|
||||||
|
|
||||||
To exercise installation alongside existing Windows-style partitions, run
|
To exercise installation alongside existing Windows-style partitions, run
|
||||||
`./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a
|
`./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a
|
||||||
synthetic disk in `/tmp` with an existing ESP and data partition plus ample
|
synthetic disk in `/tmp` with an existing ESP and data partition plus ample
|
||||||
|
|||||||
@@ -2,49 +2,89 @@
|
|||||||
# Preview the shipped Omarchy installer dashboard without building an ISO.
|
# Preview the shipped Omarchy installer dashboard without building an ISO.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# The dashboard drives the package phase from the target's pacman database, so
|
||||||
|
# fake one here; without it the preview falls back to its time curve and the bar
|
||||||
|
# barely moves, which reads as a bug.
|
||||||
|
PREVIEW_PACKAGES="${PREVIEW_PACKAGES:-925}"
|
||||||
|
|
||||||
write_simulation_state() {
|
write_simulation_state() {
|
||||||
local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4"
|
local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4"
|
||||||
local finished_at="${5:-null}" temporary="${state_file}.tmp"
|
local target="$5" index="$6" total="$7"
|
||||||
|
local finished_at="${8:-null}" temporary="${state_file}.tmp"
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--argjson started_at "$started_at" \
|
--argjson started_at "$started_at" \
|
||||||
--argjson phase_started_at "$phase_started_at" \
|
--argjson phase_started_at "$phase_started_at" \
|
||||||
--arg current_phase "$current_phase" \
|
--arg current_phase "$current_phase" \
|
||||||
|
--arg target "$target" \
|
||||||
|
--argjson current_index "$index" \
|
||||||
|
--argjson total_phases "$total" \
|
||||||
--argjson finished_at "$finished_at" \
|
--argjson finished_at "$finished_at" \
|
||||||
'{
|
'{
|
||||||
started_at: $started_at,
|
started_at: $started_at,
|
||||||
phase_started_at: $phase_started_at,
|
phase_started_at: $phase_started_at,
|
||||||
current_phase: $current_phase
|
current_phase: $current_phase,
|
||||||
|
target: $target,
|
||||||
|
current_index: $current_index,
|
||||||
|
total_phases: $total_phases
|
||||||
} + if $finished_at == null then {} else {finished_at: $finished_at} end' \
|
} + if $finished_at == null then {} else {finished_at: $finished_at} end' \
|
||||||
>"$temporary"
|
>"$temporary"
|
||||||
mv -f -- "$temporary" "$state_file"
|
mv -f -- "$temporary" "$state_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fill the fake database over the phase's duration, so the bar is driven by the
|
||||||
|
# same count it uses on a real install.
|
||||||
|
simulate_packages() {
|
||||||
|
local db="$1" duration="$2" total="$3" steps=25 i n
|
||||||
|
for ((i = 1; i <= steps; i++)); do
|
||||||
|
n=$(( total * i / steps ))
|
||||||
|
while (( $(ls -1 "$db" 2>/dev/null | wc -l) < n )); do
|
||||||
|
mkdir -p "$db/preview-$(( $(ls -1 "$db" 2>/dev/null | wc -l) ))-1.0-1"
|
||||||
|
done
|
||||||
|
sleep "$(awk -v d="$duration" -v s="$steps" 'BEGIN { printf "%.3f", d / s }')"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
simulate_install() {
|
simulate_install() {
|
||||||
local state_file="$1"
|
local state_file="$1"
|
||||||
local started_at="$EPOCHREALTIME"
|
local started_at="$EPOCHREALTIME"
|
||||||
local phase phase_started_at finished_at duration i
|
local phase phase_started_at finished_at duration i index total
|
||||||
|
local target db
|
||||||
|
target="$(dirname -- "$state_file")/target"
|
||||||
|
db="$target/var/lib/pacman/local"
|
||||||
|
mkdir -p "$db"
|
||||||
local -a phases=(
|
local -a phases=(
|
||||||
"Starting installation" 0.5
|
"Starting installation" 0.5
|
||||||
"Preparing live environment" 0.8
|
"Preparing live environment" 0.8
|
||||||
|
"Preparing install target" 0.3
|
||||||
"Installing Arch + Omarchy" 5.0
|
"Installing Arch + Omarchy" 5.0
|
||||||
|
"Configuring hibernation" 0.4
|
||||||
"Configuring system" 1.2
|
"Configuring system" 1.2
|
||||||
"Finalizing Limine boot" 1.0
|
"Finalizing Limine boot" 1.0
|
||||||
"Finalizing user" 1.0
|
"Finalizing user" 1.0
|
||||||
"Configuring login" 0.6
|
"Configuring login" 0.6
|
||||||
|
"Configuring DNS resolver" 0.3
|
||||||
"Validating boot setup" 0.8
|
"Validating boot setup" 0.8
|
||||||
)
|
)
|
||||||
|
total=$(( ${#phases[@]} / 2 ))
|
||||||
|
|
||||||
for ((i = 0; i < ${#phases[@]}; i += 2)); do
|
for ((i = 0; i < ${#phases[@]}; i += 2)); do
|
||||||
phase="${phases[i]}"
|
phase="${phases[i]}"
|
||||||
duration="${phases[i + 1]}"
|
duration="${phases[i + 1]}"
|
||||||
|
index=$(( i / 2 ))
|
||||||
phase_started_at="$EPOCHREALTIME"
|
phase_started_at="$EPOCHREALTIME"
|
||||||
write_simulation_state "$state_file" "$started_at" "$phase_started_at" "$phase"
|
write_simulation_state "$state_file" "$started_at" "$phase_started_at" \
|
||||||
sleep "$duration"
|
"$phase" "$target" "$index" "$total"
|
||||||
|
if [[ $phase == "Installing Arch + Omarchy" ]]; then
|
||||||
|
simulate_packages "$db" "$duration" "$PREVIEW_PACKAGES"
|
||||||
|
else
|
||||||
|
sleep "$duration"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
finished_at="$EPOCHREALTIME"
|
finished_at="$EPOCHREALTIME"
|
||||||
write_simulation_state "$state_file" "$started_at" "$finished_at" "Installation complete" "$finished_at"
|
write_simulation_state "$state_file" "$started_at" "$finished_at" \
|
||||||
|
"Installation complete" "$target" "$(( total - 1 ))" "$total" "$finished_at"
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +131,12 @@ log_file="$preview_dir/install.log"
|
|||||||
state_file="$preview_dir/state.json"
|
state_file="$preview_dir/state.json"
|
||||||
touch "$log_file"
|
touch "$log_file"
|
||||||
|
|
||||||
|
# Match what the simulated database will reach, so the preview shows the
|
||||||
|
# package-driven bar rather than the missing-signal fallback.
|
||||||
|
printf '%s\n' "$PREVIEW_PACKAGES" >"$preview_dir/expected-packages"
|
||||||
|
|
||||||
OMARCHY_UI_AUTO_REBOOT=no \
|
OMARCHY_UI_AUTO_REBOOT=no \
|
||||||
|
OMARCHY_EXPECTED_PACKAGES_FILE="$preview_dir/expected-packages" \
|
||||||
|
PREVIEW_PACKAGES="$PREVIEW_PACKAGES" \
|
||||||
"$dashboard" "$log_file" "$state_file" -- \
|
"$dashboard" "$log_file" "$state_file" -- \
|
||||||
"$0" --simulate-install "$state_file"
|
"$0" --simulate-install "$state_file"
|
||||||
@@ -143,6 +143,10 @@ if [[ -z "$NO_CACHE" ]]; then
|
|||||||
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
|
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
|
||||||
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
|
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
|
||||||
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
|
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
|
||||||
|
else
|
||||||
|
# The locally cached :latest image is a cache too — docker run never
|
||||||
|
# re-checks the registry for it, so refresh it when building cold.
|
||||||
|
DOCKER_ARGS+=(--pull=always)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
|
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
|
||||||
@@ -153,5 +157,5 @@ mv -f "$latest_iso" "$iso_ref"
|
|||||||
|
|
||||||
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
|
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
|
||||||
echo
|
echo
|
||||||
gum confirm "Boot $iso_ref?" && omarchy-iso-boot "$iso_ref"
|
gum confirm "Boot $iso_ref?" && "$BUILD_ROOT/bin/omarchy-iso-boot" "$iso_ref"
|
||||||
fi
|
fi
|
||||||
+207
-7
@@ -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)
|
||||||
|
# --provision Drive the deferred-provisioning install flow: no user during install, the
|
||||||
|
# first boot runs omarchy-provision-owner 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
|
||||||
|
PROVISION=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 ;;
|
||||||
|
--provision) PROVISION=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"
|
||||||
|
$PROVISION && BASE_NAME+="-provision"
|
||||||
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"
|
||||||
@@ -96,7 +101,9 @@ HTTP_PID=""
|
|||||||
|
|
||||||
mkdir -p "$BASE_DIR" "$RUN_DIR"
|
mkdir -p "$BASE_DIR" "$RUN_DIR"
|
||||||
|
|
||||||
QMP_SOCK="$RUN_DIR/qmp.sock"
|
# Unix socket paths are capped at ~108 bytes, which a run directory nested in
|
||||||
|
# test-runs/<iso-name>/runs/<timestamp> can exceed. Keep the socket in /tmp.
|
||||||
|
QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-iso-test-qmp.XXXXXX.sock")
|
||||||
PIDFILE="$RUN_DIR/qemu.pid"
|
PIDFILE="$RUN_DIR/qemu.pid"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
@@ -286,6 +293,28 @@ type_text() {
|
|||||||
:) press shift-semicolon ;;
|
:) press shift-semicolon ;;
|
||||||
@) press shift-2 ;;
|
@) press shift-2 ;;
|
||||||
\|) press shift-backslash ;;
|
\|) press shift-backslash ;;
|
||||||
|
";") press semicolon ;;
|
||||||
|
"'") press apostrophe ;;
|
||||||
|
'"') press shift-apostrophe ;;
|
||||||
|
"=") press equal ;;
|
||||||
|
"+") press shift-equal ;;
|
||||||
|
"%") press shift-5 ;;
|
||||||
|
"&") press shift-7 ;;
|
||||||
|
"*") press shift-8 ;;
|
||||||
|
"(") press shift-9 ;;
|
||||||
|
")") press shift-0 ;;
|
||||||
|
"<") press shift-comma ;;
|
||||||
|
">") press shift-dot ;;
|
||||||
|
"!") press shift-1 ;;
|
||||||
|
"#") press shift-3 ;;
|
||||||
|
'$') press shift-4 ;;
|
||||||
|
"?") press shift-slash ;;
|
||||||
|
"~") press shift-grave_accent ;;
|
||||||
|
"\\") press backslash ;;
|
||||||
|
"[") press bracket_left ;;
|
||||||
|
"]") press bracket_right ;;
|
||||||
|
"{") press shift-bracket_left ;;
|
||||||
|
"}") press shift-bracket_right ;;
|
||||||
*) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
|
*) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
|
||||||
esac
|
esac
|
||||||
sleep 0.05
|
sleep 0.05
|
||||||
@@ -297,6 +326,7 @@ type_text() {
|
|||||||
ssh_guest() {
|
ssh_guest() {
|
||||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
|
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
|
||||||
-o BatchMode=yes \
|
-o BatchMode=yes \
|
||||||
|
-o IdentitiesOnly=yes \
|
||||||
-o StrictHostKeyChecking=no \
|
-o StrictHostKeyChecking=no \
|
||||||
-o UserKnownHostsFile=/dev/null \
|
-o UserKnownHostsFile=/dev/null \
|
||||||
-o ConnectTimeout=5 \
|
-o ConnectTimeout=5 \
|
||||||
@@ -576,10 +606,43 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
|
|||||||
drive_configurator() {
|
drive_configurator() {
|
||||||
log "Driving the installer wizard"
|
log "Driving the installer wizard"
|
||||||
|
|
||||||
|
# A greeter (animated logo + tagline) opens the installer; Return begins.
|
||||||
|
wait_for_screen "Opinionated" 300
|
||||||
|
capture_console "success-installer-00-greeter"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
# Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning
|
||||||
|
# mode; a normal install just selects the layout.
|
||||||
wait_for_screen "keyboard layout" 300
|
wait_for_screen "keyboard layout" 300
|
||||||
capture_console "success-installer-01-keyboard-layout"
|
capture_console "success-installer-01-keyboard-layout"
|
||||||
|
|
||||||
|
if $PROVISION; then
|
||||||
|
press ctrl-c # arm "prepare for another owner"
|
||||||
|
wait_for_screen "another owner" 30
|
||||||
|
capture_console "success-installer-02-prepare-confirm"
|
||||||
|
press ret # "Yes, prepare for another owner" is the affirmative default
|
||||||
|
|
||||||
|
# Jumps straight to disk selection, then the overwrite confirm.
|
||||||
|
wait_for_screen "install disk" 60
|
||||||
|
capture_console "success-installer-03-prepare-install-disk"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "recovery possible" 60
|
||||||
|
capture_console "success-installer-04-prepare-disk-warning-encrypted"
|
||||||
|
if ! $ENCRYPT; then
|
||||||
|
press ctrl-c
|
||||||
|
wait_for_screen "without encryption" 30
|
||||||
|
capture_console "success-installer-05-prepare-disk-warning-unencrypted"
|
||||||
|
fi
|
||||||
|
press ret
|
||||||
|
sleep 2
|
||||||
|
capture_console "success-installer-13-install-started"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
press ret # English (US) is preselected
|
press ret # English (US) is preselected
|
||||||
|
|
||||||
|
# Normal flow: user step comes before disk selection.
|
||||||
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-02-username"
|
||||||
@@ -622,17 +685,93 @@ drive_configurator() {
|
|||||||
capture_console "success-installer-10-install-disk"
|
capture_console "success-installer-10-install-disk"
|
||||||
press ret # the single virtio disk
|
press ret # the single virtio disk
|
||||||
|
|
||||||
|
wait_for_screen "installation mode\|recovery possible" 60
|
||||||
|
if ocr_screen | grep -qi "installation mode"; then
|
||||||
|
capture_console "success-installer-11-install-mode"
|
||||||
|
press ret # "Full disk install" is preselected
|
||||||
|
fi
|
||||||
|
|
||||||
wait_for_screen "recovery possible" 60
|
wait_for_screen "recovery possible" 60
|
||||||
capture_console "success-installer-11-disk-warning-encrypted"
|
capture_console "success-installer-12-disk-warning-encrypted"
|
||||||
if ! $ENCRYPT; then
|
if ! $ENCRYPT; then
|
||||||
press ctrl-c # toggles the confirm to the unencrypted flow
|
press ctrl-c # toggles the confirm to the unencrypted flow
|
||||||
wait_for_screen "without encryption" 30
|
wait_for_screen "without encryption" 30
|
||||||
capture_console "success-installer-12-disk-warning-unencrypted"
|
capture_console "success-installer-13-disk-warning-unencrypted"
|
||||||
fi
|
fi
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
capture_console "success-installer-13-install-started"
|
capture_console "success-installer-14-install-started"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The deferred-provisioning first boot runs the user form on tty1 (omarchy-provision-owner) before
|
||||||
|
# SDDM. Encrypted deferred-provisioning installs auto-unlock from the staged keyfile, so there
|
||||||
|
# is no LUKS prompt to answer here either way.
|
||||||
|
drive_provision_owner() {
|
||||||
|
log "Driving the deferred-provisioning first-boot setup"
|
||||||
|
|
||||||
|
# Deferred provisioning defers the keyboard step to first boot; it comes before the user form.
|
||||||
|
# Pick English (UK) (one row below the preselected English (US), which leads
|
||||||
|
# the list) so the verifier can prove the deferred step applied a non-default
|
||||||
|
# layout — UK leaves every letter identical to US, so the typed password (sent
|
||||||
|
# as raw keycodes) still matches the literal one the harness pipes to sudo.
|
||||||
|
# A greeter (animated logo + tagline) opens first boot; Return begins.
|
||||||
|
wait_for_screen "Opinionated" 600
|
||||||
|
capture_console "success-provision-00-greeter"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "keyboard layout" 120
|
||||||
|
press down
|
||||||
|
capture_console "success-provision-01-keyboard-layout-uk"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "Username" 120
|
||||||
|
capture_console "success-provision-02-username"
|
||||||
|
type_text "$GUEST_USER"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "Password" 60
|
||||||
|
type_text "$GUEST_PASSWORD"
|
||||||
|
capture_console "success-provision-03-password"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "Conf *irm" 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
|
||||||
|
# The keyboard step above applied English (UK), where '@' is Shift+' (not
|
||||||
|
# Shift+2 as on US); send that keycode so the typed email is correct under
|
||||||
|
# the new layout — itself further proof the deferred keymap took effect.
|
||||||
|
type_text "test"
|
||||||
|
press shift-apostrophe
|
||||||
|
type_text "omarchy.org"
|
||||||
|
capture_console "success-provision-04-email"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
# Hostname is deferred to first boot too.
|
||||||
|
wait_for_screen "Hostname" 60
|
||||||
|
type_text "$GUEST_HOSTNAME"
|
||||||
|
capture_console "success-provision-05-hostname"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
# Timezone is deferred to first boot too; accept the geo-guessed default.
|
||||||
|
# Generous timeout: the tzupdate geo-guess over first-boot networking can lag.
|
||||||
|
wait_for_screen "Timezone" 180
|
||||||
|
capture_console "success-provision-06-timezone"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
wait_for_screen "look right" 60
|
||||||
|
capture_console "success-provision-07-review"
|
||||||
|
press ret
|
||||||
|
|
||||||
|
# Finalization (offline Node unpack, LUKS re-key on encrypted installs)
|
||||||
|
# runs before SDDM appears.
|
||||||
|
capture_console "success-provision-08-finalizing"
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_install() {
|
wait_for_install() {
|
||||||
@@ -649,6 +788,15 @@ wait_for_install() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Deferred-provisioning installs skip the celebration and reboot on their own
|
||||||
|
# (no Reboot Now prompt); the first-boot greeter tagline appearing means the
|
||||||
|
# install finished and rebooted into setup.
|
||||||
|
if $PROVISION && grep -qi "Opinionated" <<<"$text"; then
|
||||||
|
log "Install finished and auto-rebooted into first-boot setup."
|
||||||
|
capture_console "success-installer-15-autoreboot"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
if grep -qi "installation stopped" <<<"$text"; then
|
if grep -qi "installation stopped" <<<"$text"; then
|
||||||
capture_console "failure-installer-installation-stopped"
|
capture_console "failure-installer-installation-stopped"
|
||||||
echo "Install failed — screenshot saved to $RUN_DIR" >&2
|
echo "Install failed — screenshot saved to $RUN_DIR" >&2
|
||||||
@@ -751,11 +899,11 @@ EOF
|
|||||||
press ret
|
press ret
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap | bash"
|
type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs"
|
||||||
capture_console "success-first-boot-05-bootstrap-command"
|
capture_console "success-first-boot-05-bootstrap-command"
|
||||||
press ret
|
press ret
|
||||||
|
|
||||||
wait_for_ssh 180 "failure-first-boot-ssh-timeout"
|
wait_for_ssh 360 "failure-first-boot-ssh-timeout"
|
||||||
capture_console "success-first-boot-06-bootstrap-complete"
|
capture_console "success-first-boot-06-bootstrap-complete"
|
||||||
|
|
||||||
kill "$HTTP_PID" 2>/dev/null || true
|
kill "$HTTP_PID" 2>/dev/null || true
|
||||||
@@ -835,11 +983,63 @@ install_phase() {
|
|||||||
drive_configurator
|
drive_configurator
|
||||||
wait_for_install
|
wait_for_install
|
||||||
|
|
||||||
if $ENCRYPT; then
|
if $PROVISION; then
|
||||||
|
drive_provision_owner
|
||||||
|
elif $ENCRYPT; then
|
||||||
unlock_luks
|
unlock_luks
|
||||||
fi
|
fi
|
||||||
bootstrap_ssh
|
bootstrap_ssh
|
||||||
|
|
||||||
|
# SSH can come up (console bootstrap logs in on a spare TTY) while
|
||||||
|
# omarchy-provision-owner is still finalizing the user and re-keying LUKS. Saving
|
||||||
|
# the base mid-finalization produces a flaky image, so wait for the pending
|
||||||
|
# flag to clear before powering off.
|
||||||
|
if $PROVISION; then
|
||||||
|
log "Waiting for provisioning first-boot setup to finish"
|
||||||
|
local waited=0 rc
|
||||||
|
while true; do
|
||||||
|
# Capture rc via `|| rc=$?` so a non-zero result doesn't trip `set -e`
|
||||||
|
# (this command is intentionally allowed to "fail" — that's the signal).
|
||||||
|
rc=0
|
||||||
|
ssh_guest "test -f /var/lib/omarchy/provisioning/pending" 2>/dev/null || rc=$?
|
||||||
|
# rc 0 = flag still present; rc 255 = SSH not up yet (finalization may
|
||||||
|
# still be running) — keep waiting. Only a clean "file absent" (rc 1)
|
||||||
|
# means provisioning finished, so we don't snapshot mid-finalization on a
|
||||||
|
# transient SSH blip.
|
||||||
|
(( rc == 0 || rc == 255 )) || break
|
||||||
|
if ((waited >= 900)); then
|
||||||
|
capture_console "failure-provision-finalization-timeout"
|
||||||
|
echo "Timed out waiting for provisioning first-boot setup to complete" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
((waited += 10))
|
||||||
|
done
|
||||||
|
|
||||||
|
# The owner's hostname and timezone are set at first boot now, not baked
|
||||||
|
# into the install — confirm they actually landed on the running system.
|
||||||
|
# Hostname is deterministic (we typed it), so a mismatch is a hard failure;
|
||||||
|
# timezone is geo-guessed, so just report what landed.
|
||||||
|
local got_host="" got_tz=""
|
||||||
|
got_host=$(ssh_guest "cat /etc/hostname" 2>/dev/null | tr -d '\r\n') || got_host=""
|
||||||
|
got_tz=$(ssh_guest "timedatectl show -p Timezone --value" 2>/dev/null | tr -d '\r\n') || got_tz=""
|
||||||
|
if [[ $got_host != "$GUEST_HOSTNAME" ]]; then
|
||||||
|
capture_console "failure-provision-hostname-not-applied"
|
||||||
|
echo "First-boot hostname is '${got_host:-<empty>}', expected '$GUEST_HOSTNAME'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "hostname applied at first boot: $got_host"
|
||||||
|
if [[ -n $got_tz && $got_tz != "UTC" ]]; then
|
||||||
|
log "timezone applied at first boot: $got_tz"
|
||||||
|
else
|
||||||
|
echo "WARN: timezone is '${got_tz:-<empty>}' (expected the owner's choice, not UTC)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deferred first-boot setup hands straight off to SDDM once the oneshot
|
||||||
|
# service exits — no celebration screen, no Start Omarchy button to confirm.
|
||||||
|
capture_console "success-provision-09-handoff"
|
||||||
|
fi
|
||||||
|
|
||||||
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \
|
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \
|
||||||
>"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true
|
>"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true
|
||||||
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \
|
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ openssh
|
|||||||
pipewire
|
pipewire
|
||||||
snapper
|
snapper
|
||||||
sof-firmware
|
sof-firmware
|
||||||
|
tailscale
|
||||||
+135
-40
@@ -25,7 +25,11 @@ export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
|
|||||||
# Packages installed into the Arch container used to build the ISO.
|
# Packages installed into the Arch container used to build the ISO.
|
||||||
pacman-key --init
|
pacman-key --init
|
||||||
pacman --noconfirm -Sy archlinux-keyring
|
pacman --noconfirm -Sy archlinux-keyring
|
||||||
pacman --noconfirm -Sy archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli
|
# Full upgrade, not just -Sy: docker never re-pulls :latest once it's cached,
|
||||||
|
# so this container can be months behind the mirror it installs from. A plain
|
||||||
|
# -Sy install is then a partial upgrade — new packages linked against a glibc
|
||||||
|
# the container doesn't have yet.
|
||||||
|
pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli
|
||||||
|
|
||||||
# Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo
|
# Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo
|
||||||
# during the build without keyserver lookups).
|
# during the build without keyserver lookups).
|
||||||
@@ -113,7 +117,7 @@ cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
|
|||||||
# The selected omarchy-settings package is needed here so its post_install hook
|
# 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
|
# drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the
|
||||||
# live initramfs.
|
# live initramfs.
|
||||||
arch_packages=(linux-t2 git gum jq openssl plymouth python-terminaltexteffects tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted)
|
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted)
|
||||||
printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64"
|
printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64"
|
||||||
|
|
||||||
# The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so
|
# The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so
|
||||||
@@ -135,6 +139,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
|
|||||||
# pulls the published omarchy* from the network mirror like any other package.
|
# pulls the published omarchy* from the network mirror like any other package.
|
||||||
if [[ -d /omarchy-source ]]; then
|
if [[ -d /omarchy-source ]]; then
|
||||||
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
|
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
|
||||||
|
setup_form=/omarchy-source/install/provisioning/setup-form.sh
|
||||||
else
|
else
|
||||||
# Pull the same package lists out of the freshly-downloaded Omarchy runtime
|
# 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.
|
# package so we don't need a local checkout in the non-local-source path.
|
||||||
@@ -150,12 +155,37 @@ else
|
|||||||
mkdir -p /tmp/omarchy-pkglists
|
mkdir -p /tmp/omarchy-pkglists
|
||||||
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
|
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
|
||||||
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages)
|
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages)
|
||||||
|
# Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member
|
||||||
|
# it can't find, so asking for this alongside the package lists would abort the
|
||||||
|
# build here (set -e) with a bare "Not found in archive" instead of the
|
||||||
|
# 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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
|
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
|
||||||
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
|
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
|
||||||
cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
|
cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
|
||||||
|
|
||||||
|
# The configurator's setup form comes from the runtime this ISO bundles, so the
|
||||||
|
# installer and the first-boot setup that finishes a deferred install can never
|
||||||
|
# disagree. A runtime predating the split ships no such file, which would leave
|
||||||
|
# the configurator with no prompts at all.
|
||||||
|
if [[ ! -f $setup_form ]]; then
|
||||||
|
if [[ -d /omarchy-source ]]; then
|
||||||
|
echo "ERROR: the --local-source checkout ships no install/provisioning/setup-form.sh" >&2
|
||||||
|
remedy="Update the checkout to a revision carrying the shared setup form."
|
||||||
|
else
|
||||||
|
echo "ERROR: $OMARCHY_RUNTIME_PACKAGE does not ship install/provisioning/setup-form.sh" >&2
|
||||||
|
remedy="Publish a runtime carrying the shared setup form, or build with --local-source against a checkout that has it."
|
||||||
|
fi
|
||||||
|
echo " The configurator sources its prompts from that file, so this ISO" >&2
|
||||||
|
echo " would boot into an installer with no questions to ask." >&2
|
||||||
|
echo " $remedy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
|
||||||
|
|
||||||
# Collect every package we want available in the offline mirror.
|
# Collect every package we want available in the offline mirror.
|
||||||
declare -a all_packages
|
declare -a all_packages
|
||||||
mapfile -t all_packages < <(
|
mapfile -t all_packages < <(
|
||||||
@@ -197,48 +227,49 @@ if ! download_offline_packages; then
|
|||||||
download_offline_packages
|
download_offline_packages
|
||||||
fi
|
fi
|
||||||
|
|
||||||
prune_stale_package_versions() {
|
# Resolve the exact filenames chosen by the same synced package databases used
|
||||||
local dir="$1"
|
# for the download. Pruning by this transaction (rather than merely keeping the
|
||||||
local pkgfile name version cmp
|
# newest version of every cached package name) removes packages that have left
|
||||||
local -a stale=()
|
# the lists or dependency closure, such as an old Electron major version.
|
||||||
declare -A newest_file=()
|
if ! resolved_package_files="$(
|
||||||
declare -A newest_version=()
|
pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \
|
||||||
|
--dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}"
|
||||||
|
)"; then
|
||||||
|
echo "ERROR: could not resolve the package files required by the offline mirror" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mapfile -t required_package_files <<< "$resolved_package_files"
|
||||||
|
|
||||||
while IFS= read -r pkgfile; do
|
# The online transaction intentionally excludes packages built from the local
|
||||||
read -r name version < <(pacman -Qp "$pkgfile" 2>/dev/null) || continue
|
# checkouts. Add those exact artifacts back to the keep-set after verifying
|
||||||
[[ -n $name && -n $version ]] || continue
|
# that the local build left exactly one file for each selected package name.
|
||||||
|
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
|
||||||
if [[ -z ${newest_version[$name]+x} ]]; then
|
for local_package_name in \
|
||||||
newest_version[$name]="$version"
|
"$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do
|
||||||
newest_file[$name]="$pkgfile"
|
local_package_file=""
|
||||||
continue
|
for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do
|
||||||
fi
|
[[ -f $candidate && $candidate != *.sig ]] || continue
|
||||||
|
read -r candidate_name _ < <(pacman -Qp "$candidate" 2>/dev/null) || continue
|
||||||
cmp=$(vercmp "$version" "${newest_version[$name]}")
|
[[ $candidate_name == "$local_package_name" ]] || continue
|
||||||
if (( cmp > 0 )); then
|
if [[ -n $local_package_file ]]; then
|
||||||
stale+=("${newest_file[$name]}")
|
echo "ERROR: multiple local builds found for $local_package_name" >&2
|
||||||
newest_version[$name]="$version"
|
exit 1
|
||||||
newest_file[$name]="$pkgfile"
|
fi
|
||||||
else
|
local_package_file="${candidate##*/}"
|
||||||
stale+=("$pkgfile")
|
|
||||||
fi
|
|
||||||
done < <(find "$dir" -maxdepth 1 -type f -name '*.pkg.tar.*' ! -name '*.sig' -print | sort)
|
|
||||||
|
|
||||||
if (( ${#stale[@]} > 0 )); then
|
|
||||||
echo "Pruning stale package versions from offline mirror:"
|
|
||||||
for pkgfile in "${stale[@]}"; do
|
|
||||||
echo " $(basename "$pkgfile")"
|
|
||||||
rm -f "$pkgfile" "$pkgfile.sig"
|
|
||||||
done
|
done
|
||||||
fi
|
if [[ -z $local_package_file ]]; then
|
||||||
}
|
echo "ERROR: local build not found for $local_package_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
required_package_files+=("$local_package_file")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${required_package_files[@]}" |
|
||||||
|
bash /builder/prune-offline-mirror.sh "$offline_mirror_dir"
|
||||||
|
|
||||||
# Rebuild the offline repo db from scratch so size/checksum/depends entries
|
# Rebuild the offline repo db from scratch so size/checksum/depends entries
|
||||||
# always reflect the current package files. The persistent cache can contain
|
# always reflect only the package files selected for this build.
|
||||||
# multiple versions of the same package; repo-add keeps the last one it sees,
|
|
||||||
# which is lexical glob order, not version order (e.g. pkgrel -9 can override
|
|
||||||
# -15). Prune to one newest version per package before indexing.
|
|
||||||
prune_stale_package_versions "$offline_mirror_dir"
|
|
||||||
rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files*
|
rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files*
|
||||||
repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
|
repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
|
||||||
|
|
||||||
@@ -247,6 +278,70 @@ repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar
|
|||||||
mkdir -p /var/cache/omarchy/mirror
|
mkdir -p /var/cache/omarchy/mirror
|
||||||
ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline
|
ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline
|
||||||
|
|
||||||
|
# Denominator for the install dashboard's progress bar. Resolving the mirror's
|
||||||
|
# own package lists against the mirror we just indexed, with an empty local db,
|
||||||
|
# is the question pacstrap asks at install time — same resolver, same repo, same
|
||||||
|
# lists — so no hand-kept constant can drift.
|
||||||
|
#
|
||||||
|
# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and
|
||||||
|
# intel-ucode because the mirror must contain either. phases.py records expected
|
||||||
|
# and actual in the timing JSON, so growing drift shows up in acceptance runs.
|
||||||
|
# The early-bootstrap set is already inside this closure, so restating it would
|
||||||
|
# only add a second list to drift.
|
||||||
|
resolve_expected_packages() {
|
||||||
|
local resolve_root=/tmp/omarchy-expected-packages
|
||||||
|
local resolved
|
||||||
|
local -a targets
|
||||||
|
|
||||||
|
rm -rf "$resolve_root"
|
||||||
|
mkdir -p "$resolve_root/var/lib/pacman"
|
||||||
|
|
||||||
|
mapfile -t targets < <(
|
||||||
|
{
|
||||||
|
grep -hv '^#\|^$' /builder/archinstall.packages
|
||||||
|
# Read the shipped copy, which is what _runtime_package_list reads at
|
||||||
|
# install time, not the build-time source it came from.
|
||||||
|
grep -hv '^#\|^$' \
|
||||||
|
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
|
||||||
|
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \
|
||||||
|
"$OMARCHY_NVIM_PACKAGE"
|
||||||
|
} | sort -u
|
||||||
|
)
|
||||||
|
|
||||||
|
pacman --config "$build_cache_dir/pacman-offline.conf" \
|
||||||
|
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
|
||||||
|
--noconfirm -Sy >/dev/null || return 1
|
||||||
|
|
||||||
|
# Capture before counting: no pipefail here, so a pacman failure inside a
|
||||||
|
# pipeline would become a plausible partial count, which never trips the
|
||||||
|
# dashboard's fallback.
|
||||||
|
resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \
|
||||||
|
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
|
||||||
|
--noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1
|
||||||
|
|
||||||
|
printf '%s\n' "$resolved" | sort -u | grep -c .
|
||||||
|
}
|
||||||
|
|
||||||
|
# Worth failing the build over: -S --print only aborts when a target is missing
|
||||||
|
# from the offline repo, which would fail pacstrap the same way. A count that
|
||||||
|
# merely looks wrong is not — the dashboard falls back without the file.
|
||||||
|
if ! expected_packages="$(resolve_expected_packages)"; then
|
||||||
|
echo "ERROR: could not resolve the target package count from the offline mirror." >&2
|
||||||
|
echo " pacman -S --print aborts the whole transaction if any single target" >&2
|
||||||
|
echo " is missing, so this almost certainly means pacstrap would fail the" >&2
|
||||||
|
echo " same way at install time." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if (( expected_packages < 600 || expected_packages > 2000 )); then
|
||||||
|
echo "WARNING: resolved target package count $expected_packages is outside the" >&2
|
||||||
|
echo " expected 600-2000 range; shipping no denominator so the install" >&2
|
||||||
|
echo " dashboard falls back to its time-based curve." >&2
|
||||||
|
else
|
||||||
|
printf '%s\n' "$expected_packages" \
|
||||||
|
>"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages"
|
||||||
|
echo "Target install resolves to $expected_packages packages."
|
||||||
|
fi
|
||||||
|
|
||||||
# Live ISO uses the same offline pacman.conf.
|
# Live ISO uses the same offline pacman.conf.
|
||||||
cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf"
|
cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf"
|
||||||
|
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
offline_mirror_dir="${1:-}"
|
||||||
|
if [[ -z $offline_mirror_dir ]]; then
|
||||||
|
echo "Usage: prune-offline-mirror.sh <offline-mirror-dir>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -d $offline_mirror_dir ]]; then
|
||||||
|
echo "ERROR: offline mirror directory not found: $offline_mirror_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read the exact package filenames selected by pacman on stdin. Validate the
|
||||||
|
# complete selection before deleting anything: an empty/partial resolution must
|
||||||
|
# never turn a warm-cache build into a broken mirror.
|
||||||
|
declare -A required=()
|
||||||
|
while IFS= read -r filename; do
|
||||||
|
[[ -n $filename ]] || continue
|
||||||
|
if [[ $filename == */* || $filename != *.pkg.tar.* || $filename == *.sig ]]; then
|
||||||
|
echo "ERROR: invalid required package filename: $filename" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
required["$filename"]=1
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#required[@]} == 0 )); then
|
||||||
|
echo "ERROR: refusing to prune the offline mirror with an empty package selection" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
missing=()
|
||||||
|
for filename in "${!required[@]}"; do
|
||||||
|
[[ -f "$offline_mirror_dir/$filename" ]] || missing+=("$filename")
|
||||||
|
done
|
||||||
|
if (( ${#missing[@]} > 0 )); then
|
||||||
|
echo "ERROR: refusing to prune; selected packages are missing from the mirror:" >&2
|
||||||
|
printf ' %s\n' "${missing[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
stale=()
|
||||||
|
while IFS= read -r -d '' package_file; do
|
||||||
|
filename="${package_file##*/}"
|
||||||
|
[[ -n ${required[$filename]+x} ]] || stale+=("$package_file")
|
||||||
|
done < <(
|
||||||
|
find "$offline_mirror_dir" -maxdepth 1 -type f \
|
||||||
|
-name '*.pkg.tar.*' ! -name '*.sig' -print0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (( ${#stale[@]} > 0 )); then
|
||||||
|
echo "Pruning package archives not selected for the offline mirror:"
|
||||||
|
for package_file in "${stale[@]}"; do
|
||||||
|
echo " $(basename "$package_file")"
|
||||||
|
rm -f -- "$package_file" "$package_file.sig"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A failed or interrupted download can leave a signature without its archive.
|
||||||
|
# It cannot be indexed and only wastes space in the ISO.
|
||||||
|
while IFS= read -r -d '' signature_file; do
|
||||||
|
[[ -f ${signature_file%.sig} ]] || rm -f -- "$signature_file"
|
||||||
|
done < <(
|
||||||
|
find "$offline_mirror_dir" -maxdepth 1 -type f \
|
||||||
|
-name '*.pkg.tar.*.sig' -print0
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# linux-t2 ships for every machine, and on a pre-T2 Mac its applesmc oopses
|
||||||
|
# registering the keyboard-backlight LED, wedging udev. Nothing here needs the SMC.
|
||||||
|
blacklist applesmc
|
||||||
@@ -91,7 +91,25 @@ warm_pid=$!
|
|||||||
trap 'kill "$warm_pid" 2>/dev/null' EXIT
|
trap 'kill "$warm_pid" 2>/dev/null' EXIT
|
||||||
|
|
||||||
cd /root
|
cd /root
|
||||||
./configurator
|
# Autoinstall: a cidata drive carrying the configurator's own output files
|
||||||
|
# stands in for the wizard. omarchy-cidata-load copies them into /root and
|
||||||
|
# everything downstream runs the ordinary path against ordinary inputs.
|
||||||
|
if /usr/local/bin/omarchy-cidata-load; then
|
||||||
|
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
|
||||||
|
export OMARCHY_UI_INTERACTIVE=no
|
||||||
|
else
|
||||||
|
./configurator
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on
|
||||||
|
# their own — the owner completes setup at first boot. Signalled by the config's
|
||||||
|
# defer_provisioning flag (interactive) or the defer-provisioning marker (cidata).
|
||||||
|
# Parse the flag with jq (the same JSON semantics the orchestrator uses) rather
|
||||||
|
# than a line regex, so a reformatted config can't read as a direct install.
|
||||||
|
if [[ -f /root/defer-provisioning ]] ||
|
||||||
|
[[ "$(jq -r '.omarchy_install.defer_provisioning // false' /root/user_configuration.json 2>/dev/null)" == "true" ]]; then
|
||||||
|
export OMARCHY_UI_DEFER_PROVISIONING=yes
|
||||||
|
fi
|
||||||
|
|
||||||
# The foreground dashboard is now the sole visible install UI owner. It starts
|
# The foreground dashboard is now the sole visible install UI owner. It starts
|
||||||
# the actual installer as a non-interactive child, logs child output, waits for
|
# the actual installer as a non-interactive child, logs child output, waits for
|
||||||
@@ -107,4 +125,7 @@ rm -f /run/omarchy-install/state.json
|
|||||||
--creds /root/user_credentials.json \
|
--creds /root/user_credentials.json \
|
||||||
--full-name-file /root/user_full_name.txt \
|
--full-name-file /root/user_full_name.txt \
|
||||||
--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 \
|
||||||
|
--tailscale-authkey-file /root/tailscale_authkey \
|
||||||
|
--defer-provisioning-file /root/defer-provisioning
|
||||||
+316
-148
@@ -9,6 +9,21 @@ OMARCHY_SETTINGS_PACKAGE="${OMARCHY_SETTINGS_PACKAGE:-omarchy-settings}"
|
|||||||
|
|
||||||
LOGO_PATH="$OMARCHY_PATH/logo.txt"
|
LOGO_PATH="$OMARCHY_PATH/logo.txt"
|
||||||
|
|
||||||
|
# The setup form — keyboard, account, hostname, and timezone questions, plus the
|
||||||
|
# rules their answers are checked against — shared verbatim with the first-boot
|
||||||
|
# owner setup that finishes a deferred install. build-iso.sh vendors it out of
|
||||||
|
# the runtime package this ISO bundles, so the two can never offer different
|
||||||
|
# layouts or accept different usernames. On the ISO that vendored copy is what
|
||||||
|
# loads; a dry run outside the ISO falls back to the omarchy checkout
|
||||||
|
# bin/omarchy-iso-configurator already points OMARCHY_PATH at.
|
||||||
|
SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}"
|
||||||
|
[[ -r $SETUP_FORM ]] || SETUP_FORM="$OMARCHY_PATH/install/provisioning/setup-form.sh"
|
||||||
|
if [[ ! -r $SETUP_FORM ]]; then
|
||||||
|
echo "ERROR: shared setup form not found at $SETUP_FORM" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
source "$SETUP_FORM"
|
||||||
|
|
||||||
LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH")
|
LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH")
|
||||||
|
|
||||||
export GUM_CONFIRM_PROMPT_FOREGROUND="6"
|
export GUM_CONFIRM_PROMPT_FOREGROUND="6"
|
||||||
@@ -38,12 +53,117 @@ measure_terminal() {
|
|||||||
}
|
}
|
||||||
measure_terminal
|
measure_terminal
|
||||||
|
|
||||||
|
# The live VT resizes late during boot: tty1 starts as the 80-column boot
|
||||||
|
# console and only grows once the kernel framebuffer/KMS takes over (noticeably
|
||||||
|
# late in VMs). The logo is wider than 80 columns, so a first draw against that
|
||||||
|
# transient narrow console wraps the logo ("smushed") and can't center it
|
||||||
|
# (padding clamps to 0, so it hugs the left). A terminal emulator (or sudo's
|
||||||
|
# pty) has the same problem the other way: it reports a stale 24x80 for a few
|
||||||
|
# hundred ms after start before its real winsize is set. Either way, hold the
|
||||||
|
# first draw until the width settles at (or above) the logo width. A real VT
|
||||||
|
# that never widens (e.g. nomodeset) still proceeds after the cap.
|
||||||
|
wait_for_stable_terminal() {
|
||||||
|
local width last="" stable=0 waited=0
|
||||||
|
while (( waited < 5000 )); do
|
||||||
|
width=$(stty size 2>/dev/null </dev/tty | awk '{print $2}')
|
||||||
|
[[ $width =~ ^[0-9]+$ ]] || width=0
|
||||||
|
if [[ $width == "$last" ]] && (( width >= LOGO_WIDTH )); then
|
||||||
|
(( ++stable >= 3 )) && break
|
||||||
|
else
|
||||||
|
stable=0
|
||||||
|
fi
|
||||||
|
last=$width
|
||||||
|
sleep 0.2
|
||||||
|
(( waited += 200 ))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
clear_logo() {
|
clear_logo() {
|
||||||
measure_terminal
|
measure_terminal
|
||||||
printf "\033[H\033[2J"
|
printf "\033[H\033[2J"
|
||||||
gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
|
gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Vertically-centered welcome shown once before the keyboard step — the same
|
||||||
|
# frame the deferred-provisioning first boot opens on, so install and first boot
|
||||||
|
# bookend the setup identically. Logo, the Omarchy tagline, and a single button,
|
||||||
|
# centered like the boot logo. No animation.
|
||||||
|
greeter() {
|
||||||
|
measure_terminal
|
||||||
|
|
||||||
|
local rows logo_h content_h top cols logo_row tagline_row hint_row
|
||||||
|
local tagline hint tpad hpad anim
|
||||||
|
cols=$TERM_WIDTH
|
||||||
|
rows=$(stty size 2>/dev/null </dev/tty | awk '{print $1}')
|
||||||
|
[[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24}
|
||||||
|
logo_h=$(wc -l <"$LOGO_PATH")
|
||||||
|
|
||||||
|
# logo + blank + tagline + blank + hint
|
||||||
|
content_h=$((logo_h + 4))
|
||||||
|
top=$(((rows - content_h) / 2))
|
||||||
|
(( top < 0 )) && top=0
|
||||||
|
logo_row=$((top + 1))
|
||||||
|
tagline_row=$((top + logo_h + 2))
|
||||||
|
hint_row=$((top + logo_h + 4))
|
||||||
|
|
||||||
|
printf '\033[?25l\033[H\033[2J'
|
||||||
|
|
||||||
|
printf '\033[%d;1H' "$logo_row"
|
||||||
|
gum style --foreground 2 --padding "0 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
|
||||||
|
|
||||||
|
tagline="Beautiful, Modern & Opinionated Linux by DHH"
|
||||||
|
tpad=$(((cols - ${#tagline}) / 2)); (( tpad < 0 )) && tpad=0
|
||||||
|
printf '\033[%d;%dH%s' "$tagline_row" "$((tpad + 1))" "$tagline"
|
||||||
|
|
||||||
|
hint="Press Return to Start Install"
|
||||||
|
hpad=$(((cols - ${#hint}) / 2)); (( hpad < 0 )) && hpad=0
|
||||||
|
printf '\033[%d;%dH\033[2m%s\033[0m' "$hint_row" "$((hpad + 1))" "$hint"
|
||||||
|
|
||||||
|
# ColorShift the logo: a green base (indexed color 2) with a cyan accent (6)
|
||||||
|
# drifting through, settling on green. Indexed ANSI colors, not hex — the
|
||||||
|
# framebuffer console can't render truecolor faithfully (it crushes the
|
||||||
|
# palette to a muddy lavender), but the 16 indexed colors map to the Tokyo
|
||||||
|
# Night palette and render true. --xterm-colors is what keeps them indexed:
|
||||||
|
# ttfx resolves even indexed stops to truecolor otherwise, which would put the
|
||||||
|
# settle color out of step with the `gum style --foreground 2` logo underneath.
|
||||||
|
# One long-running invocation (many cycles) so it never restarts — a restart is
|
||||||
|
# what flashed. The effect reads from /dev/null so it never swallows the
|
||||||
|
# Return; --reuse-canvas paints upward from the saved cursor, anchored one row
|
||||||
|
# below the logo.
|
||||||
|
printf '\033[%d;1H\0337' "$((logo_row + logo_h))"
|
||||||
|
# Run ttfx directly (not inside a `while` subshell) so $anim is ttfx's own
|
||||||
|
# PID: killing a wrapping subshell would orphan ttfx, which then keeps
|
||||||
|
# painting the logo over the keyboard step. --cycles is high enough that it
|
||||||
|
# never ends on its own before Return.
|
||||||
|
# Two columns narrower than the console: ttfx centers its text two columns
|
||||||
|
# right of plain centering, so a full-width canvas would sit the animated
|
||||||
|
# logo a column off the gum-drawn one underneath it.
|
||||||
|
ttfx -i "$LOGO_PATH" \
|
||||||
|
--canvas-width "$((cols > 2 ? cols - 2 : cols))" \
|
||||||
|
--anchor-text c \
|
||||||
|
--frame-rate 60 \
|
||||||
|
--reuse-canvas \
|
||||||
|
--xterm-colors \
|
||||||
|
colorshift \
|
||||||
|
--gradient-stops 2 10 6 10 \
|
||||||
|
--gradient-frames 3 \
|
||||||
|
--cycles 1000 \
|
||||||
|
--final-gradient-stops 2 \
|
||||||
|
</dev/null >/dev/tty 2>/dev/null &
|
||||||
|
anim=$!
|
||||||
|
|
||||||
|
IFS= read -r _ </dev/tty || true
|
||||||
|
# Tolerate ttfx's kill signal in `wait` so a future `set -e` here can't abort.
|
||||||
|
kill "$anim" 2>/dev/null
|
||||||
|
wait "$anim" 2>/dev/null || true
|
||||||
|
# ttfx leaves the tty in raw/no-echo mode when killed; restore it or the gum
|
||||||
|
# prompts in the keyboard step that follows silently die.
|
||||||
|
stty sane </dev/tty 2>/dev/null || true
|
||||||
|
# Reset the screen so the leftover animation frame doesn't linger under the
|
||||||
|
# keyboard step that follows.
|
||||||
|
printf '\033[0m\033[H\033[2J\033[?25h'
|
||||||
|
}
|
||||||
|
|
||||||
abort() {
|
abort() {
|
||||||
gum style "${1:-Aborted installation}"
|
gum style "${1:-Aborted installation}"
|
||||||
echo
|
echo
|
||||||
@@ -73,59 +193,33 @@ notice() {
|
|||||||
# STEP 1: KEYBOARD LAYOUT
|
# STEP 1: KEYBOARD LAYOUT
|
||||||
|
|
||||||
keyboard_form() {
|
keyboard_form() {
|
||||||
step "Let's setup your machine..."
|
# Ctrl+C is the hidden entry into deferred provisioning, offered on every
|
||||||
keyboards=$'Azerbaijani|azerty
|
# visit: the user step unwinds back here and nothing is on disk yet. Callers
|
||||||
Belarusian|by
|
# re-check defer_provisioning after this returns.
|
||||||
Belgian|be-latin1
|
local status
|
||||||
Bosnian|ba
|
|
||||||
Bulgarian|bg-cp1251
|
while true; do
|
||||||
Croatian|croat
|
clear_logo
|
||||||
Czech|cz
|
echo
|
||||||
Danish|dk-latin1
|
say "Let's setup your machine..."
|
||||||
Dutch|nl
|
say --foreground 8 "Press Ctrl+C to prepare this machine for another owner."
|
||||||
English (UK)|uk
|
echo
|
||||||
English (US)|us
|
|
||||||
English (US, Dvorak)|dvorak
|
omarchy_prompt_keyboard && status=0 || status=$?
|
||||||
English (US, Colemak)|colemak
|
((status == 0)) && break
|
||||||
Estonian|et
|
|
||||||
Finnish|fi
|
# Esc means "back", and nothing precedes the first screen, so re-ask.
|
||||||
French|fr
|
((status == OMARCHY_FORM_BACK)) && continue
|
||||||
French (Canada)|cf
|
|
||||||
French (Switzerland)|fr_CH
|
if ((status == OMARCHY_FORM_SIGNAL)); then
|
||||||
Georgian|ge
|
if confirm_prepare_for_another_owner; then
|
||||||
German|de
|
defer_provisioning=true
|
||||||
German (Switzerland)|de_CH-latin1
|
return 0
|
||||||
Greek|gr
|
fi
|
||||||
Hebrew|il
|
continue # declined: back to the keyboard picker
|
||||||
Hungarian|hu
|
fi
|
||||||
Icelandic|is-latin1
|
abort
|
||||||
Irish|ie
|
done
|
||||||
Italian|it
|
|
||||||
Japanese|jp106
|
|
||||||
Kazakh|kazakh
|
|
||||||
Khmer (Cambodia)|khmer
|
|
||||||
Kyrgyz|kyrgyz
|
|
||||||
Lao|la-latin1
|
|
||||||
Latvian|lv
|
|
||||||
Lithuanian|lt
|
|
||||||
Macedonian|mk-utf
|
|
||||||
Norwegian|no-latin1
|
|
||||||
Polish|pl
|
|
||||||
Portuguese|pt-latin1
|
|
||||||
Portuguese (Brazil)|br-abnt2
|
|
||||||
Romanian|ro
|
|
||||||
Russian|ru
|
|
||||||
Serbian|sr-latin
|
|
||||||
Slovak|sk-qwertz
|
|
||||||
Slovenian|slovene
|
|
||||||
Spanish|es
|
|
||||||
Spanish (Latin American)|la-latin1
|
|
||||||
Swedish|sv-latin1
|
|
||||||
Tajik|tj_alt-UTF8
|
|
||||||
Turkish|trq
|
|
||||||
Ukrainian|ua'
|
|
||||||
choice=$(printf '%s\n' "$keyboards" | cut -d'|' -f1 | gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") || abort
|
|
||||||
keyboard=$(printf '%s\n' "$keyboards" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}')
|
|
||||||
|
|
||||||
# Only attempt to load keyboard layout if we're on a real console
|
# Only attempt to load keyboard layout if we're on a real console
|
||||||
# loadkeys only works on Linux virtual consoles (tty), not in terminal emulators
|
# loadkeys only works on Linux virtual consoles (tty), not in terminal emulators
|
||||||
@@ -134,70 +228,59 @@ Ukrainian|ua'
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
keyboard_form
|
# Ctrl+C on the first screen offers to prepare the machine for another owner.
|
||||||
|
# Confirm it before committing, so an accidental Ctrl+C doesn't silently switch
|
||||||
|
# the whole install.
|
||||||
|
confirm_prepare_for_another_owner() {
|
||||||
|
clear_logo
|
||||||
|
echo
|
||||||
|
say "This prepares the machine for another owner."
|
||||||
|
say --foreground 8 "The system installs now, but setup is delayed until first boot."
|
||||||
|
echo
|
||||||
|
gum confirm --affirmative "Yes, prepare for another owner" --negative "No, keep setting up" \
|
||||||
|
"Prepare this machine for another owner?"
|
||||||
|
}
|
||||||
|
|
||||||
# STEP 2: USER
|
# The user step. Deferred-provisioning installs skip it entirely — the machine's first owner
|
||||||
|
# picks their keyboard and creates their user in the first-boot setup, so the
|
||||||
|
# operator sets nothing user-specific.
|
||||||
|
|
||||||
user_form() {
|
user_form() {
|
||||||
step "Let's setup your user account..."
|
step "Let's setup your user account..."
|
||||||
|
|
||||||
while true; do
|
# Each prompt reports 0 (set), OMARCHY_FORM_BACK (Esc), or OMARCHY_FORM_SIGNAL
|
||||||
username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort
|
# (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they
|
||||||
|
# mean here; the form itself never aborts.
|
||||||
|
omarchy_prompt_username || return $?
|
||||||
|
omarchy_prompt_password || return $?
|
||||||
|
|
||||||
if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
|
# Hash the password with SHA-512 crypt ($6$), which is what openssl passwd offers
|
||||||
break
|
|
||||||
else
|
|
||||||
notice "Username must be alphanumeric with no spaces" 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
password=$(gum input --placeholder "Used for user + root, and disk encryption when enabled" --prompt.foreground="#845DF9" --password --prompt "Password> ") || abort
|
|
||||||
password_confirmation=$(gum input --placeholder "Must match the password you just typed" --prompt.foreground="#845DF9" --password --prompt "Confirm> ") || abort
|
|
||||||
|
|
||||||
if [[ -n "$password" && "$password" == "$password_confirmation" ]]; then
|
|
||||||
break
|
|
||||||
elif [[ -z "$password" ]]; then
|
|
||||||
notice "Your password can't be blank!" 1
|
|
||||||
else
|
|
||||||
notice "Passwords didn't match!" 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Hash the password using yescrypt
|
|
||||||
password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin)
|
password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin)
|
||||||
|
|
||||||
full_name=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Full name> ")
|
omarchy_prompt_identity || return $?
|
||||||
email_address=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Email address> ")
|
omarchy_prompt_hostname || return $?
|
||||||
|
omarchy_prompt_timezone || return $?
|
||||||
while true; do
|
|
||||||
hostname=$(gum input --placeholder "Letters, digits, and dashes (or return for 'omarchy')" --prompt.foreground="#845DF9" --prompt "Hostname> ")
|
|
||||||
|
|
||||||
if [[ "$hostname" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]]; then
|
|
||||||
break
|
|
||||||
elif [[ -z $hostname ]]; then
|
|
||||||
hostname="omarchy"
|
|
||||||
break
|
|
||||||
else
|
|
||||||
notice "Hostname must be 1-63 letters, digits, or dashes, and cannot start or end with a dash" 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Pick timezone
|
|
||||||
geo_guessed_timezone=$(tzupdate -p 2>/dev/null)
|
|
||||||
|
|
||||||
if [[ -n $geo_guessed_timezone ]]; then
|
|
||||||
timezone=$(timedatectl list-timezones | gum choose --height 10 --selected "$geo_guessed_timezone" --header "Timezone") || abort
|
|
||||||
else
|
|
||||||
timezone=$(timedatectl list-timezones | gum filter --height 10 --header "Timezone") || abort
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user_form
|
# Returns 0 on confirmed account details, or as soon as the keyboard screen
|
||||||
|
# arms deferred provisioning.
|
||||||
|
user_step() {
|
||||||
|
local status
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
# Add manual padding since gum table -p doesn't respect padding
|
user_form && status=0 || status=$?
|
||||||
echo -e "Field,Value
|
if ((status != 0)); then
|
||||||
|
# Esc unwinds to the start of the form. Ctrl+C has no side channel in the
|
||||||
|
# user step (only the keyboard and disk screens give it a meaning), so it
|
||||||
|
# keeps its long-standing behaviour of ending the install.
|
||||||
|
((status == OMARCHY_FORM_BACK)) || abort
|
||||||
|
keyboard_form
|
||||||
|
$defer_provisioning && return 0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add manual padding since gum table -p doesn't respect padding
|
||||||
|
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]}
|
||||||
@@ -205,16 +288,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
|
$defer_provisioning && return 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# STEP 3: DISK
|
# STEP 3: DISK
|
||||||
|
|
||||||
@@ -343,11 +427,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.
|
||||||
|
# Deferred-provisioning 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 $defer_provisioning; 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)
|
||||||
@@ -409,17 +504,18 @@ 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)
|
||||||
if [[ -n "$bl_part" ]]; then
|
if [[ -n "$bl_part" ]]; then
|
||||||
say --foreground 1 "BitLocker signature detected on $bl_part."
|
say --foreground 1 "BitLocker signature detected on $bl_part."
|
||||||
say "Suspend or disable BitLocker in Windows before installing alongside it."
|
say "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."
|
||||||
abort "Aborted: BitLocker is active on this disk."
|
say "Suspending BitLocker is not enough — the drive stays encrypted."
|
||||||
|
abort "Aborted: BitLocker is enabled on this disk."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Omarchy always creates its own dedicated ESP in free space and never
|
# Omarchy always creates its own dedicated ESP in free space and never
|
||||||
@@ -444,7 +540,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)
|
||||||
@@ -487,7 +584,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 ))
|
||||||
@@ -550,7 +646,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
|
||||||
@@ -559,12 +654,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
|
||||||
@@ -572,6 +666,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"
|
||||||
@@ -630,10 +732,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
|
||||||
@@ -712,10 +810,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() {
|
||||||
@@ -767,8 +861,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)
|
||||||
@@ -795,7 +888,7 @@ 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
|
||||||
choices+=("Choose a different disk")
|
choices+=("Choose a different disk")
|
||||||
@@ -842,30 +935,43 @@ confirm_disk_overwrite() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Decide what and how to install: sets install_target (full_disk|free_space),
|
||||||
|
# install_target and encrypt_installation. Nothing is written to disk yet —
|
||||||
|
# the user step runs between this and the execution below. (deferred provisioning never
|
||||||
|
# reaches here; it's armed by Ctrl+C on the keyboard screen and goes straight to
|
||||||
|
# disk selection.)
|
||||||
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
|
||||||
|
fi
|
||||||
|
|
||||||
|
# With no free-space option to offer, a full-disk install is the only
|
||||||
|
# mode, so skip the picker and go straight to the overwrite confirm.
|
||||||
|
if $full_disk_only; then
|
||||||
|
install_mode="Full disk install"
|
||||||
|
else
|
||||||
install_mode_form
|
install_mode_form
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$install_mode" in
|
case "$install_mode" in
|
||||||
"Full disk install")
|
"Full disk install")
|
||||||
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
|
# When the picker was skipped, declining the overwrite means the user
|
||||||
disk_form
|
# wants a different disk.
|
||||||
fi
|
$full_disk_only && disk_form
|
||||||
;;
|
;;
|
||||||
"Free space install (alongside existing data)")
|
"Free space install"*)
|
||||||
if run_partition_install "$@"; then
|
if run_partition_decide; then
|
||||||
exit 0
|
install_target="free_space"
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"Choose a different disk")
|
"Choose a different disk")
|
||||||
@@ -875,13 +981,67 @@ select_installation() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
disk_form
|
# STEP 1: KEYBOARD (Ctrl+C here arms deferred provisioning and jumps to disk selection)
|
||||||
select_installation "$@"
|
|
||||||
|
defer_provisioning=false
|
||||||
|
install_target="full_disk"
|
||||||
|
|
||||||
|
# Let the live VT reach its settled width before the first screen so the logo
|
||||||
|
# isn't measured against the transient 80-column boot console.
|
||||||
|
wait_for_stable_terminal
|
||||||
|
|
||||||
|
greeter
|
||||||
|
|
||||||
|
keyboard_form
|
||||||
|
|
||||||
|
# Normal install: keyboard (loaded above, so the LUKS passphrase is typed under
|
||||||
|
# the right layout) → user → disk → install mode. The user step can unwind to
|
||||||
|
# the keyboard screen and arm deferral there, hence the second check.
|
||||||
|
if ! $defer_provisioning; then
|
||||||
|
user_step
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $defer_provisioning; then
|
||||||
|
# Deferred provisioning: no user setup, and the keyboard is deferred to the machine's first
|
||||||
|
# boot. A defined default keymap so it boots before then; the owner picks
|
||||||
|
# their real layout in the OOBE, which re-keys the console and, on encrypted
|
||||||
|
# installs, rebuilds the initramfs so the LUKS prompt matches.
|
||||||
|
keyboard="us"
|
||||||
|
username=""
|
||||||
|
password=""
|
||||||
|
password_hash=""
|
||||||
|
full_name=""
|
||||||
|
email_address=""
|
||||||
|
# Neutral defaults for the install; the owner overwrites both in the
|
||||||
|
# first-boot OOBE (omarchy-provision-owner).
|
||||||
|
hostname="omarchy"
|
||||||
|
timezone="UTC"
|
||||||
|
|
||||||
|
# Jump straight to disk selection + overwrite confirmation (encrypted by
|
||||||
|
# default, Ctrl+C toggles unencrypted). deferred provisioning is always a full-disk install.
|
||||||
|
disk_form
|
||||||
|
until confirm_disk_overwrite; do
|
||||||
|
disk_form
|
||||||
|
done
|
||||||
|
install_target="full_disk"
|
||||||
|
else
|
||||||
|
disk_form
|
||||||
|
select_installation
|
||||||
|
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))
|
||||||
@@ -898,14 +1058,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
|
||||||
|
# Deferred-provisioning installs carry no password: the orchestrator generates a throwaway
|
||||||
|
# LUKS passphrase and stages it for the first-boot re-key.
|
||||||
|
if $defer_provisioning; 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_
|
||||||
)
|
)
|
||||||
@@ -923,6 +1090,7 @@ cat <<-_EOF_ >user_configuration.json
|
|||||||
"custom_commands": [],
|
"custom_commands": [],
|
||||||
"omarchy_install": {
|
"omarchy_install": {
|
||||||
"mode": "full_disk",
|
"mode": "full_disk",
|
||||||
|
"defer_provisioning": $defer_provisioning,
|
||||||
"target_mount": "/mnt",
|
"target_mount": "/mnt",
|
||||||
"boot": {
|
"boot": {
|
||||||
"esp_mount": "/boot",
|
"esp_mount": "/boot",
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox,
|
||||||
|
# libvirt and Packer all know how to attach one) carrying the configurator's own
|
||||||
|
# output files stands in for the wizard. Copy those files into /root and
|
||||||
|
# everything downstream runs the ordinary path against ordinary inputs.
|
||||||
|
#
|
||||||
|
# Exits 0 when an autoinstall drive was loaded and the configurator can be
|
||||||
|
# skipped. The required pair is the same one the orchestrator treats as
|
||||||
|
# mandatory; the rest are optional there too, so don't be stricter here.
|
||||||
|
# Anything less than the pair means this isn't an autoinstall drive, and the
|
||||||
|
# caller falls back to the wizard.
|
||||||
|
#
|
||||||
|
# A `defer-provisioning` marker file stands in for user_credentials.json: deferred-provisioning
|
||||||
|
# installs defer user creation to first boot, so imaging rigs ship no credentials.
|
||||||
|
#
|
||||||
|
# The optional argument prefixes every path the script touches; tests use it to
|
||||||
|
# run against a sandbox instead of the live system.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
root=${1:-}
|
||||||
|
by_label=$root/dev/disk/by-label
|
||||||
|
dest=$root/root
|
||||||
|
mnt=$root/run/cidata
|
||||||
|
|
||||||
|
# The by-label symlinks come from udev, and on a fast boot this can run before
|
||||||
|
# device enumeration has finished. Wait for the queue to drain instead of
|
||||||
|
# racing it and silently dropping a headless install into the wizard.
|
||||||
|
udevadm settle 2>/dev/null || true
|
||||||
|
|
||||||
|
device=""
|
||||||
|
for label in cidata CIDATA; do
|
||||||
|
[[ -e $by_label/$label ]] && { device=$by_label/$label; break; }
|
||||||
|
done
|
||||||
|
[[ -n $device ]] || exit 1
|
||||||
|
|
||||||
|
mkdir -p "$mnt"
|
||||||
|
mount -o ro "$device" "$mnt" || exit 1
|
||||||
|
|
||||||
|
# Clear any inputs a previous load in this live session left in /root before
|
||||||
|
# copying the current drive: a stale `defer-provisioning` marker or credentials file would
|
||||||
|
# otherwise force a fresh drive back into deferred provisioning or hand it an old
|
||||||
|
# LUKS passphrase. The interactive wizard writes these same names, but a
|
||||||
|
# cidata load always precedes it, so nothing of the user's is discarded here.
|
||||||
|
optional_inputs=(user_credentials.json defer-provisioning user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey)
|
||||||
|
for file in user_configuration.json "${optional_inputs[@]}"; do
|
||||||
|
rm -f "$dest/$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
loaded=1
|
||||||
|
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then
|
||||||
|
files=("$mnt"/user_configuration.json)
|
||||||
|
for file in "${optional_inputs[@]}"; do
|
||||||
|
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
|
||||||
|
done
|
||||||
|
if cp "${files[@]}" "$dest"/; then
|
||||||
|
loaded=0
|
||||||
|
else
|
||||||
|
# A partial copy must not leave files behind: the wizard cleans up only
|
||||||
|
# what it writes itself, so a half-copied authorized_keys would leak into
|
||||||
|
# the interactive install that follows.
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
rm -f "$dest/${file##*/}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
umount "$mnt"
|
||||||
|
exit $loaded
|
||||||
@@ -23,6 +23,12 @@ INSTALL_CMD=("$@")
|
|||||||
|
|
||||||
TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}"
|
TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}"
|
||||||
LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt"
|
LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt"
|
||||||
|
|
||||||
|
# Autoinstall has no one at the keyboard, and every prompt this dashboard owns
|
||||||
|
# reads from the TTY, so each would hang forever. Suppress them here rather than
|
||||||
|
# in the scripts they live in: this process is the only owner of the visible UI.
|
||||||
|
interactive() { [[ ${OMARCHY_UI_INTERACTIVE:-} != "no" ]]; }
|
||||||
|
|
||||||
CSI=$'\033['
|
CSI=$'\033['
|
||||||
RESET="${CSI}0m"
|
RESET="${CSI}0m"
|
||||||
DIM="${CSI}2m"
|
DIM="${CSI}2m"
|
||||||
@@ -117,7 +123,16 @@ repeat() {
|
|||||||
|
|
||||||
logo_width() {
|
logo_width() {
|
||||||
if [[ -f $LOGO_PATH ]]; then
|
if [[ -f $LOGO_PATH ]]; then
|
||||||
awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH"
|
# The logo is drawn in box-drawing glyphs, three bytes each in UTF-8, so
|
||||||
|
# awk's `length` returns 197 rather than 81 unless the locale happens to be
|
||||||
|
# UTF-8 — and a width wider than the console makes left_padding clamp to
|
||||||
|
# zero, which puts the logo flush left. Count bytes deliberately (LC_ALL=C)
|
||||||
|
# and drop the continuation bytes instead, so the answer is columns in every
|
||||||
|
# locale rather than only in the ones that have a charset.
|
||||||
|
LC_ALL=C awk '
|
||||||
|
{ gsub(/[\200-\277]/, ""); if (length > max) max = length }
|
||||||
|
END { print max+0 }
|
||||||
|
' "$LOGO_PATH"
|
||||||
else
|
else
|
||||||
printf '81\n'
|
printf '81\n'
|
||||||
fi
|
fi
|
||||||
@@ -136,8 +151,74 @@ LOGO_HEIGHT="$(logo_height)"
|
|||||||
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
|
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
|
||||||
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
|
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
|
||||||
CONTENT_WIDTH="$LOGO_WIDTH"
|
CONTENT_WIDTH="$LOGO_WIDTH"
|
||||||
|
# Rows the progress screen occupies: logo, blank, title, blank, bar, blank, tip.
|
||||||
|
PROGRESS_HEIGHT=$((LOGO_HEIGHT + 6))
|
||||||
|
TOP_ROW=1
|
||||||
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
|
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
|
||||||
LAST_PCT=1
|
LAST_SIZE=""
|
||||||
|
LAST_PM=10
|
||||||
|
# The finish screen mirrors the greeter's frame — logo, blank, one line, gap,
|
||||||
|
# one button — so the last thing the installer shows matches the first. It runs
|
||||||
|
# one row taller: `gum confirm` always paints prompt/blank/button and clears all
|
||||||
|
# three, so the button cannot sit closer than two rows under a title we drew.
|
||||||
|
FINISH_HEIGHT=$((LOGO_HEIGHT + 5))
|
||||||
|
FINISH_TOP_ROW=2
|
||||||
|
|
||||||
|
# ── Install progress ─────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Position = max(floor, work), both non-decreasing, in per-mille of the bar.
|
||||||
|
#
|
||||||
|
# floor lo + (span-1) * t / (t+tau), on time since this process saw the
|
||||||
|
# phase. Asymptotic, so it approaches the band end without reaching it.
|
||||||
|
# tau is a shape constant, not a duration prediction.
|
||||||
|
# work packages installed / PKG_TOTAL, for the package phase only (~85% of
|
||||||
|
# the install). libalpm makes one directory per package under
|
||||||
|
# <target>/var/lib/pacman/local; the denominator comes from
|
||||||
|
# build-iso.sh, so there is no second package list to drift.
|
||||||
|
#
|
||||||
|
# Per-mille because a whole-percent asymptote over a 2-point band has two
|
||||||
|
# reachable values and stops moving. No give-up predicate: two discontinuous
|
||||||
|
# curves behind a monotonic clamp freeze. With no signal the floor alone still
|
||||||
|
# finishes the band.
|
||||||
|
EXPECTED_PACKAGES_FILE="${OMARCHY_EXPECTED_PACKAGES_FILE:-/usr/share/omarchy-iso/expected-packages}"
|
||||||
|
PKG_TOTAL=0
|
||||||
|
if [[ -r $EXPECTED_PACKAGES_FILE ]]; then
|
||||||
|
read -r PKG_TOTAL _ <"$EXPECTED_PACKAGES_FILE" 2>/dev/null || PKG_TOTAL=0
|
||||||
|
fi
|
||||||
|
[[ $PKG_TOTAL =~ ^[0-9]+$ ]] || PKG_TOTAL=0
|
||||||
|
# A wrong denominator is worse than none: it never triggers the fallback.
|
||||||
|
(( PKG_TOTAL < 300 || PKG_TOTAL > 5000 )) && PKG_TOTAL=0
|
||||||
|
|
||||||
|
PKG_BONUS_MAX_PM=100 # cap on how far the bar may run ahead of the count
|
||||||
|
PKG_TAIL_PM=40 # band held back so a stalled bar has somewhere to go
|
||||||
|
PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the
|
||||||
|
# first package, so slow it until it cannot outrun data
|
||||||
|
|
||||||
|
POS=10 # position, per-mille, monotonic
|
||||||
|
PROGRESS_PM=10
|
||||||
|
NOW=0 # monotonic seconds, refreshed once per frame
|
||||||
|
DASH_T0=0
|
||||||
|
PHASE_NAME="" # last phase this process observed
|
||||||
|
PHASE_T0=0 # NOW when it was first observed
|
||||||
|
PHASE_LO=10 # POS on entry, for an unknown phase with no index
|
||||||
|
PKG_DB_DIR=""
|
||||||
|
PKG_COUNT=0
|
||||||
|
PKG_BASE=-1 # package count when the phase started
|
||||||
|
PKG_SEEN=0 # running max of packages since PKG_BASE
|
||||||
|
PKG_LAST_CHANGE=0
|
||||||
|
PKG_BONUS=0
|
||||||
|
|
||||||
|
# Monotonic clock: tzupdate and systemd-timesyncd can step the wall clock
|
||||||
|
# mid-install, which would jump every time-driven band to its ceiling.
|
||||||
|
set_now() {
|
||||||
|
local up rest
|
||||||
|
if read -r up rest </proc/uptime 2>/dev/null && [[ $up == [0-9]* ]]; then
|
||||||
|
NOW=${up%%.*}
|
||||||
|
else
|
||||||
|
NOW=$EPOCHSECONDS
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
left_padding() {
|
left_padding() {
|
||||||
local width="${1:-$CONTENT_WIDTH}" cols pad
|
local width="${1:-$CONTENT_WIDTH}" cols pad
|
||||||
@@ -183,97 +264,201 @@ render_logo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress_bar() {
|
progress_bar() {
|
||||||
local pct="$1" width="${2:-34}" filled empty
|
local pm="$1" width="${2:-34}" filled empty
|
||||||
(( pct < 0 )) && pct=0
|
(( pm < 0 )) && pm=0
|
||||||
(( pct > 100 )) && pct=100
|
(( pm > 1000 )) && pm=1000
|
||||||
filled=$(( pct * width / 100 ))
|
filled=$(( pm * width / 1000 ))
|
||||||
empty=$(( width - filled ))
|
empty=$(( width - filled ))
|
||||||
printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET"
|
printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Packages libalpm has finished installing. local/ holds one directory per
|
||||||
|
# package and nothing else in it is a directory, so */ is exact. One glob, no
|
||||||
|
# forks: this runs twice a second while the disk is saturated.
|
||||||
|
count_packages() {
|
||||||
|
local -a entries
|
||||||
|
PKG_COUNT=0
|
||||||
|
[[ -n $PKG_DB_DIR ]] || return 0
|
||||||
|
entries=("$PKG_DB_DIR"/*/)
|
||||||
|
# Unmatched globs expand to the pattern; nullglob stays off script-wide.
|
||||||
|
if (( ${#entries[@]} == 1 )) && [[ ! -d ${entries[0]} ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
PKG_COUNT=${#entries[@]}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sets PROGRESS_PM. Never call in a command substitution: POS, the phase timer
|
||||||
|
# and the package counters must survive between frames. Print nothing — output
|
||||||
|
# here would land on the TTY between the cursor escape and the bar.
|
||||||
install_progress() {
|
install_progress() {
|
||||||
local phase finished phase_started elapsed span active pct
|
local phase finished index total target
|
||||||
local start=1 end=99 expected=90
|
local lo hi tau span t pos work gap n pkg_signal=0
|
||||||
local -a state
|
local -a state
|
||||||
|
|
||||||
[[ -f $STATE_FILE ]] || { printf '1'; return; }
|
PROGRESS_PM=$POS
|
||||||
|
[[ -f $STATE_FILE ]] || return 0
|
||||||
|
|
||||||
mapfile -t state < <(jq -r '
|
mapfile -t state < <(jq -r '
|
||||||
(.current_phase // "Starting installation"),
|
(.current_phase // "Starting installation"),
|
||||||
((.finished_at // 0) | tonumber? // 0 | floor),
|
((.finished_at // 0) | tonumber? // 0 | floor),
|
||||||
((.phase_started_at // .started_at // 0) | tonumber? // 0 | floor)
|
((.current_index // -1) | tonumber? // -1 | floor),
|
||||||
|
((.total_phases // 0) | tonumber? // 0 | floor),
|
||||||
|
(.target // "/mnt")
|
||||||
' "$STATE_FILE" 2>/dev/null)
|
' "$STATE_FILE" 2>/dev/null)
|
||||||
(( ${#state[@]} == 3 )) || { printf '1'; return; }
|
(( ${#state[@]} == 5 )) || return 0
|
||||||
phase="${state[0]}" finished="${state[1]}" phase_started="${state[2]}"
|
phase="${state[0]}" finished="${state[1]}"
|
||||||
|
index="${state[2]}" total="${state[3]}" target="${state[4]}"
|
||||||
|
[[ -n $target ]] || target="/mnt"
|
||||||
|
|
||||||
if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then
|
if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then
|
||||||
printf '100'
|
POS=1000
|
||||||
return
|
PROGRESS_PM=1000
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Phase weights: start%, end%, expected seconds in phase.
|
# Transitions are observed here rather than read from the state file, which
|
||||||
|
# keeps wall clock out of the dashboard entirely.
|
||||||
|
if [[ $phase != "$PHASE_NAME" ]]; then
|
||||||
|
PHASE_NAME="$phase"
|
||||||
|
PHASE_T0=$NOW
|
||||||
|
PHASE_LO=$POS
|
||||||
|
PKG_BASE=-1
|
||||||
|
PKG_SEEN=0
|
||||||
|
PKG_BONUS=0
|
||||||
|
PKG_LAST_CHANGE=$NOW
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bands in per-mille, plus tau. The tail bands are wide relative to what they
|
||||||
|
# cost on NVMe: run_system_finalizer can DKMS-build a driver and
|
||||||
|
# finalize_limine_boot rebuilds every UKI, so they vary most on real hardware.
|
||||||
|
# Over-allotting costs a step at a boundary; under-allotting costs a stall.
|
||||||
case "$phase" in
|
case "$phase" in
|
||||||
"Starting installation") start=1 end=2 expected=2 ;;
|
"Starting installation") lo=10 hi=25 tau=5 ;;
|
||||||
"Preparing live environment") start=2 end=6 expected=5 ;;
|
"Preparing live environment") lo=25 hi=50 tau=25 ;;
|
||||||
"Verifying protected mounts") start=6 end=8 expected=3 ;;
|
"Preparing install target") lo=50 hi=65 tau=20 ;;
|
||||||
"Installing Arch + Omarchy") start=8 end=86 expected=95 ;;
|
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
|
||||||
"Installing base system") start=8 end=84 expected=85 ;;
|
"Configuring hibernation") lo=700 hi=720 tau=10 ;;
|
||||||
"Configuring protected boot") start=84 end=86 expected=8 ;;
|
"Configuring system") lo=720 hi=850 tau=120 ;;
|
||||||
"Configuring hibernation") start=86 end=88 expected=5 ;;
|
"Staging provisioning") lo=850 hi=855 tau=5 ;;
|
||||||
"Configuring system") start=88 end=93 expected=10 ;;
|
"Finalizing Limine boot") lo=855 hi=950 tau=90 ;;
|
||||||
"Finalizing Limine boot") start=93 end=96 expected=8 ;;
|
"Finalizing user") lo=950 hi=978 tau=30 ;;
|
||||||
"Finalizing user") start=96 end=98 expected=10 ;;
|
"Configuring login") lo=978 hi=988 tau=5 ;;
|
||||||
"Finalizing in chroot") start=88 end=98 expected=10 ;;
|
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
|
||||||
"Configuring login") start=98 end=99 expected=3 ;;
|
"Validating boot setup") lo=994 hi=998 tau=5 ;;
|
||||||
"Configuring DNS resolver") start=99 end=100 expected=2 ;;
|
"Creating factory snapshot") lo=998 hi=1000 tau=5 ;;
|
||||||
"Validating protected boot setup") start=99 end=100 expected=5 ;;
|
*)
|
||||||
"Validating boot setup") start=99 end=100 expected=5 ;;
|
# 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
|
||||||
|
# renamed phase degrades to one cell rather than a runaway.
|
||||||
|
if (( total >= 2 && index >= 0 && index < total )); then
|
||||||
|
lo=$(( 10 + 990 * index / total ))
|
||||||
|
hi=$(( 10 + 990 * (index + 1) / total ))
|
||||||
|
else
|
||||||
|
lo=$PHASE_LO
|
||||||
|
hi=$(( PHASE_LO + 30 ))
|
||||||
|
fi
|
||||||
|
tau=60
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
(( phase_started > 0 )) || phase_started=$EPOCHSECONDS
|
(( pkg_signal && PKG_TOTAL > 0 )) || pkg_signal=0
|
||||||
elapsed=$(( EPOCHSECONDS - phase_started ))
|
(( pkg_signal )) && tau=$PKG_FLOOR_TAU
|
||||||
(( elapsed < 0 )) && elapsed=0
|
|
||||||
span=$(( end - start ))
|
t=$(( NOW - PHASE_T0 ))
|
||||||
(( span < 0 )) && span=0
|
(( t < 0 )) && t=0
|
||||||
(( expected < 1 )) && expected=1
|
span=$(( hi - lo ))
|
||||||
active=$(( elapsed * span / expected ))
|
(( span < 2 )) && span=2
|
||||||
(( active > span - 1 )) && active=$(( span - 1 ))
|
(( tau < 1 )) && tau=1
|
||||||
(( active < 0 )) && active=0
|
|
||||||
pct=$(( start + active ))
|
pos=$(( lo + (span - 1) * t / (t + tau) ))
|
||||||
(( pct > 100 )) && pct=100
|
|
||||||
printf '%s' "$pct"
|
if (( pkg_signal )); then
|
||||||
|
PKG_DB_DIR="$target/var/lib/pacman/local"
|
||||||
|
count_packages
|
||||||
|
# Baseline on first sight: a target that already holds packages would
|
||||||
|
# otherwise pin the bar at the band ceiling for the whole phase.
|
||||||
|
(( PKG_BASE < 0 )) && PKG_BASE=$PKG_COUNT
|
||||||
|
n=$(( PKG_COUNT - PKG_BASE ))
|
||||||
|
(( n < 0 )) && n=0
|
||||||
|
# Running max: a reinstalled package briefly drops its version-stamped dir.
|
||||||
|
if (( n > PKG_SEEN )); then
|
||||||
|
PKG_SEEN=$n
|
||||||
|
PKG_LAST_CHANGE=$NOW
|
||||||
|
fi
|
||||||
|
gap=$(( NOW - PKG_LAST_CHANGE ))
|
||||||
|
(( gap < 0 )) && gap=0
|
||||||
|
(( gap > PKG_BONUS_MAX_PM )) && gap=$PKG_BONUS_MAX_PM
|
||||||
|
(( gap > PKG_BONUS )) && PKG_BONUS=$gap
|
||||||
|
work=$(( lo + (span - 1 - PKG_TAIL_PM) * PKG_SEEN / PKG_TOTAL + PKG_BONUS ))
|
||||||
|
(( work > pos )) && pos=$work
|
||||||
|
fi
|
||||||
|
|
||||||
|
(( pos > hi - 1 )) && pos=$(( hi - 1 ))
|
||||||
|
(( pos > POS )) && POS=$pos
|
||||||
|
(( POS > 1000 )) && POS=1000
|
||||||
|
PROGRESS_PM=$POS
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# One tip per ~8 seconds (frames tick every 0.5s), in order, so each stays
|
# One tip per 8 seconds, in order. On elapsed time, not a frame counter: a frame
|
||||||
# readable and none are skipped when progress jumps.
|
# is 0.5s of sleep plus a render, which stretches under pacstrap I/O — on the
|
||||||
|
# machine where the tips, not the bar, are carrying liveness.
|
||||||
current_tip() {
|
current_tip() {
|
||||||
local frame="$1"
|
printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}"
|
||||||
printf '%s' "${tips[$(( frame / 16 % ${#tips[@]} ))]}"
|
}
|
||||||
|
|
||||||
|
# Vertically center a block of the given height, echoing the row it starts on.
|
||||||
|
# Both screens here center the way the configurator's greeter centers its
|
||||||
|
# welcome, so the install opens, runs, and closes on the same composition
|
||||||
|
# rather than stacking each frame from row one.
|
||||||
|
centered_top_row() {
|
||||||
|
local height="$1" rows top
|
||||||
|
rows="$(term_size)"
|
||||||
|
rows="${rows%% *}"
|
||||||
|
[[ $rows =~ ^[0-9]+$ ]] || rows=24
|
||||||
|
top=$(( (rows - height) / 2 ))
|
||||||
|
(( top < 0 )) && top=0
|
||||||
|
printf '%d' "$(( top + 1 ))"
|
||||||
|
}
|
||||||
|
|
||||||
|
measure_progress_layout() {
|
||||||
|
LAST_SIZE="$(term_size)"
|
||||||
|
TOP_ROW="$(centered_top_row "$PROGRESS_HEIGHT")"
|
||||||
|
DYNAMIC_ROW=$(( TOP_ROW + LOGO_HEIGHT + 1 ))
|
||||||
}
|
}
|
||||||
|
|
||||||
render_static() {
|
render_static() {
|
||||||
printf '%s' "$CLEAR"
|
measure_progress_layout
|
||||||
blank_line
|
printf '%s%s%d;1H' "$CLEAR" "$CSI" "$TOP_ROW"
|
||||||
render_logo
|
render_logo
|
||||||
blank_line
|
blank_line
|
||||||
}
|
}
|
||||||
|
|
||||||
render_dynamic() {
|
render_dynamic() {
|
||||||
local frame="$1" pct
|
local pm
|
||||||
pct="$(install_progress)"
|
# The console can still resize under us (the framebuffer settles late in VMs).
|
||||||
[[ $pct =~ ^[0-9]+$ ]] || pct=$LAST_PCT
|
# Horizontal padding is recomputed every frame anyway, but the logo is drawn
|
||||||
(( pct > 100 )) && pct=100
|
# once, so redraw the static half at the new center rather than leave the
|
||||||
(( pct < LAST_PCT )) && pct=$LAST_PCT
|
# centered layout stranded against a stale geometry.
|
||||||
LAST_PCT=$pct
|
[[ "$(term_size)" == "$LAST_SIZE" ]] || render_static
|
||||||
|
set_now
|
||||||
|
install_progress
|
||||||
|
pm=$PROGRESS_PM
|
||||||
|
[[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM
|
||||||
|
(( pm > 1000 )) && pm=1000
|
||||||
|
# POS is monotone by construction; this is a guard, not the mechanism.
|
||||||
|
(( pm < LAST_PM )) && pm=$LAST_PM
|
||||||
|
LAST_PM=$pm
|
||||||
|
|
||||||
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
|
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
|
||||||
center "Installing Omarchy" "$CONTENT_WIDTH"
|
center "Installing Omarchy" "$CONTENT_WIDTH"
|
||||||
blank_line
|
blank_line
|
||||||
line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) ""
|
line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) ""
|
||||||
progress_bar "$pct" 34
|
progress_bar "$pm" 34
|
||||||
printf '\n'
|
printf '\n'
|
||||||
blank_line
|
blank_line
|
||||||
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip "$frame")${RESET}" "$CONTENT_WIDTH"
|
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH"
|
||||||
printf '%s' "$CLEAR_TO_END"
|
printf '%s' "$CLEAR_TO_END"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,38 +496,59 @@ render_finish() {
|
|||||||
local duration effect_canvas_width
|
local duration effect_canvas_width
|
||||||
duration="$(install_duration || true)"
|
duration="$(install_duration || true)"
|
||||||
duration="${duration:-Complete}"
|
duration="${duration:-Complete}"
|
||||||
|
# ttfx centers its text two columns right of plain centering, so hand it a
|
||||||
|
# canvas two columns narrower and the animated logo lands exactly on the
|
||||||
|
# static one left_padding already drew. Otherwise the logo jumps a column
|
||||||
|
# sideways when the effect starts and again when it ends.
|
||||||
effect_canvas_width="$(term_cols)"
|
effect_canvas_width="$(term_cols)"
|
||||||
(( effect_canvas_width > 1 )) && effect_canvas_width=$((effect_canvas_width - 1))
|
(( effect_canvas_width > 2 )) && effect_canvas_width=$((effect_canvas_width - 2))
|
||||||
|
|
||||||
|
# Set before anything that can fail, so reboot_prompt still lands on the
|
||||||
|
# button row even if the logo or the effect below never renders.
|
||||||
|
FINISH_TOP_ROW="$(centered_top_row "$FINISH_HEIGHT")"
|
||||||
|
|
||||||
{
|
{
|
||||||
printf '%s%s' "$SHOW_CURSOR" "$CLEAR"
|
printf '%s%s%s%d;1H' "$SHOW_CURSOR" "$CLEAR" "$CSI" "$FINISH_TOP_ROW"
|
||||||
blank_line
|
|
||||||
render_logo
|
render_logo
|
||||||
blank_line
|
blank_line
|
||||||
center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH"
|
center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH"
|
||||||
blank_line
|
blank_line
|
||||||
} >"$TTY_PATH"
|
} >"$TTY_PATH"
|
||||||
|
|
||||||
if command -v tte >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1 && [[ -f $LOGO_PATH ]]; then
|
if [[ -f $LOGO_PATH ]]; then
|
||||||
# TTE reuse mode restores a saved cursor before moving up over its input.
|
# Reuse mode restores a saved cursor before moving up over its input. Save
|
||||||
# Save this anchor so the effect redraws the same logo rows shown above.
|
# this anchor so the effect redraws the same logo rows shown above.
|
||||||
printf '%s%d;1H\0337' "$CSI" "$((LOGO_HEIGHT + 2))" >"$TTY_PATH"
|
printf '%s%d;1H\0337' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT))" >"$TTY_PATH"
|
||||||
timeout 8s tte -i "$LOGO_PATH" \
|
# Laseretch runs ~4s at 260fps, so the 8s cap is a stuck-process guard rather
|
||||||
|
# than a trim. --xterm-colors keeps the whole effect in the 256-index palette
|
||||||
|
# the way the greeter does, since the console reduces indexed codes well and
|
||||||
|
# truecolor ones badly, and it settles on indexed 2 — the green gum already
|
||||||
|
# drew the logo in above, so the send-off ends on the frame it started from
|
||||||
|
# instead of recoloring it. The laser and sparks keep their own hot palette.
|
||||||
|
timeout 8s ttfx -i "$LOGO_PATH" \
|
||||||
--canvas-width "$effect_canvas_width" \
|
--canvas-width "$effect_canvas_width" \
|
||||||
--anchor-text c \
|
--anchor-text c \
|
||||||
--frame-rate 260 \
|
--frame-rate 260 \
|
||||||
--reuse-canvas \
|
--reuse-canvas \
|
||||||
|
--xterm-colors \
|
||||||
laseretch \
|
laseretch \
|
||||||
>"$TTY_PATH" 2>/dev/null || true
|
--final-gradient-stops 2 \
|
||||||
printf '%s%d;1H' "$CSI" "$((LOGO_HEIGHT + 8))" >"$TTY_PATH"
|
>"$TTY_PATH" 2>/dev/null || dashboard_log "logo effect skipped (ttfx exited $?)"
|
||||||
|
printf '%s%d;1H' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
reboot_prompt() {
|
reboot_prompt() {
|
||||||
local prompt_pad
|
local prompt_pad
|
||||||
|
|
||||||
# Position the cursor one blank line below the title so the button sits right there.
|
# Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still
|
||||||
printf '%s%d;1H%s' "$CSI" "$((LOGO_HEIGHT + 5))" "$CLEAR_LINE" >"$TTY_PATH"
|
# applies, so setting both stops on the finish screen without a keyboard.
|
||||||
|
interactive || return 0
|
||||||
|
|
||||||
|
# gum paints its prompt row at the cursor and the button two rows under it, so
|
||||||
|
# anchor on the row right below the title to put the button as close to it as
|
||||||
|
# gum allows. FINISH_HEIGHT budgets for the same three rows.
|
||||||
|
printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH"
|
||||||
|
|
||||||
prompt_pad=$(( ( $(term_cols) - 18 ) / 2 ))
|
prompt_pad=$(( ( $(term_cols) - 18 ) / 2 ))
|
||||||
(( prompt_pad < 0 )) && prompt_pad=0
|
(( prompt_pad < 0 )) && prompt_pad=0
|
||||||
@@ -465,6 +671,9 @@ failure_menu() {
|
|||||||
local choice uploader
|
local choice uploader
|
||||||
|
|
||||||
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
|
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
|
||||||
|
# The failure screen and log tail have already rendered; exit with the
|
||||||
|
# installer's status instead of blocking on a menu nobody can answer.
|
||||||
|
interactive || return 0
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
uploader=""
|
uploader=""
|
||||||
@@ -541,12 +750,14 @@ launch_child() {
|
|||||||
[[ -e $TTY_PATH ]] || exit 2
|
[[ -e $TTY_PATH ]] || exit 2
|
||||||
printf '%s' "$HIDE_CURSOR" >"$TTY_PATH"
|
printf '%s' "$HIDE_CURSOR" >"$TTY_PATH"
|
||||||
render_static >"$TTY_PATH"
|
render_static >"$TTY_PATH"
|
||||||
|
set_now
|
||||||
|
DASH_T0=$NOW
|
||||||
|
PHASE_T0=$NOW
|
||||||
|
PKG_LAST_CHANGE=$NOW
|
||||||
launch_child
|
launch_child
|
||||||
|
|
||||||
frame=0
|
|
||||||
while jobs -pr | grep -qx "$child_pid"; do
|
while jobs -pr | grep -qx "$child_pid"; do
|
||||||
render_dynamic "$frame" >"$TTY_PATH" 2>/dev/null || true
|
render_dynamic >"$TTY_PATH" 2>/dev/null || true
|
||||||
frame=$((frame + 1))
|
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -559,6 +770,13 @@ child_pgid=""
|
|||||||
|
|
||||||
dashboard_log "installer child exited with status $status"
|
dashboard_log "installer child exited with status $status"
|
||||||
|
|
||||||
|
if (( status == 0 )); then
|
||||||
|
# finished_at lands microseconds before the child exits, so the 0.5s poll
|
||||||
|
# rarely renders a full bar before the finish screen replaces it.
|
||||||
|
render_dynamic >"$TTY_PATH" 2>/dev/null || true
|
||||||
|
sleep 0.4
|
||||||
|
fi
|
||||||
|
|
||||||
if (( status != 0 )); then
|
if (( status != 0 )); then
|
||||||
failure_status="$status"
|
failure_status="$status"
|
||||||
failure_summary=""
|
failure_summary=""
|
||||||
@@ -579,6 +797,17 @@ if (( status != 0 )); then
|
|||||||
exit "$failure_status"
|
exit "$failure_status"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Deferred-provisioning installs finish silently and reboot on their own — the
|
||||||
|
# owner completes setup (and sees the send-off) at first boot, so the "Installed
|
||||||
|
# Omarchy in ..." celebration and the Reboot Now prompt belong to a direct
|
||||||
|
# install only.
|
||||||
|
if [[ ${OMARCHY_UI_DEFER_PROVISIONING:-} == "yes" ]]; then
|
||||||
|
printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
|
||||||
|
[[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]] && exit 0
|
||||||
|
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
render_finish
|
render_finish
|
||||||
finish_render_status=$?
|
finish_render_status=$?
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ set -eEo pipefail
|
|||||||
# doesn't choke on flags it doesn't recognize.
|
# doesn't choke on flags it doesn't recognize.
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;;
|
--config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;;
|
||||||
--creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;;
|
--creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;;
|
||||||
--full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;;
|
--full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;;
|
||||||
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
|
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
|
||||||
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
|
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
|
||||||
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 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 ;;
|
||||||
|
--defer-provisioning-file) export OMARCHY_INSTALL_DEFER_PROVISIONING_FILE="$2"; shift 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
|
||||||
@@ -18,11 +19,14 @@ class InstallContext:
|
|||||||
full_name: str
|
full_name: str
|
||||||
email: str
|
email: str
|
||||||
encrypt: bool
|
encrypt: bool
|
||||||
|
authorized_keys_path: Path | None
|
||||||
|
tailscale_authkey_path: Path | None
|
||||||
|
|
||||||
user_configuration: dict
|
user_configuration: dict
|
||||||
user_credentials: dict
|
user_credentials: dict
|
||||||
arch_config_path: Path
|
arch_config_path: Path
|
||||||
omarchy_install: dict[str, Any]
|
omarchy_install: dict[str, Any]
|
||||||
|
defer_provisioning: bool = False
|
||||||
|
|
||||||
target: Path = Path("/mnt")
|
target: Path = Path("/mnt")
|
||||||
omarchy_path: Path = Path("/usr/share/omarchy")
|
omarchy_path: Path = Path("/usr/share/omarchy")
|
||||||
@@ -46,10 +50,54 @@ 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)
|
||||||
|
|
||||||
|
# Deferred provisioning: the whole system installs but user creation is deferred to
|
||||||
|
# first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by
|
||||||
|
# an `defer-provisioning` marker file on an autoinstall drive, which also replaces the
|
||||||
|
# user_credentials.json requirement.
|
||||||
|
defer_provisioning_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE"))
|
||||||
|
defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None
|
||||||
|
omarchy_install["defer_provisioning"] = defer_provisioning
|
||||||
|
|
||||||
|
if creds_path.exists():
|
||||||
|
user_credentials = json.loads(creds_path.read_text())
|
||||||
|
elif defer_provisioning:
|
||||||
|
user_credentials = {"users": []}
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"credentials file missing: {creds_path}")
|
||||||
|
|
||||||
|
if defer_provisioning:
|
||||||
|
# Deferred provisioning promises no account until first boot. A supplied
|
||||||
|
# credentials file (a rig handing over its LUKS passphrase) must
|
||||||
|
# not smuggle in users or a root password.
|
||||||
|
encryption_password = user_credentials.get("encryption_password")
|
||||||
|
user_credentials = {"users": []}
|
||||||
|
if encryption_password:
|
||||||
|
user_credentials["encryption_password"] = encryption_password
|
||||||
|
|
||||||
arch_configuration = dict(user_configuration)
|
arch_configuration = dict(user_configuration)
|
||||||
arch_configuration.pop("omarchy_install", None)
|
arch_configuration.pop("omarchy_install", None)
|
||||||
|
|
||||||
|
if defer_provisioning:
|
||||||
|
# The userless invariant covers the archinstall config too: a
|
||||||
|
# combined/legacy config could carry account authentication that
|
||||||
|
# archinstall would still act on (users, a root password). Strip
|
||||||
|
# every such field so no account is created before first boot.
|
||||||
|
_strip_account_fields(arch_configuration)
|
||||||
|
|
||||||
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 defer_provisioning:
|
||||||
|
# Encrypted deferred-provisioning installs have no user password to protect LUKS
|
||||||
|
# with. Generate a throwaway passphrase (staged for first-boot
|
||||||
|
# re-key by the stage_provisioning_state phase) and hand it to archinstall
|
||||||
|
# through both places it may look: the disk_encryption block and
|
||||||
|
# the credentials file.
|
||||||
|
_inject_provisioning_encryption_password(arch_configuration, user_credentials)
|
||||||
|
creds_path = state_dir / "provisioning-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")
|
||||||
|
|
||||||
@@ -59,10 +107,13 @@ class InstallContext:
|
|||||||
full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")),
|
full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")),
|
||||||
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
|
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
|
||||||
encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"),
|
encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"),
|
||||||
|
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_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,
|
||||||
|
defer_provisioning=defer_provisioning,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
)
|
)
|
||||||
disk_config = user_configuration.get("disk_config", {})
|
disk_config = user_configuration.get("disk_config", {})
|
||||||
@@ -73,7 +124,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.defer_provisioning:
|
||||||
|
return ""
|
||||||
|
raise RuntimeError("user_credentials.json contains no users")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
@@ -87,6 +143,38 @@ class InstallContext:
|
|||||||
return self.mode == "protected"
|
return self.mode == "protected"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_account_fields(arch_configuration: dict) -> None:
|
||||||
|
"""Remove every field archinstall reads to create users or set a root
|
||||||
|
password, so it cannot ship a hidden provisioning account.
|
||||||
|
Encryption material lives elsewhere (disk_config.disk_encryption) and is
|
||||||
|
untouched."""
|
||||||
|
for key in ("!users", "!root-password", "root_enc_password", "users"):
|
||||||
|
arch_configuration.pop(key, None)
|
||||||
|
|
||||||
|
auth = arch_configuration.get("auth_config")
|
||||||
|
if isinstance(auth, dict):
|
||||||
|
for key in ("users", "root_enc_password"):
|
||||||
|
auth.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_provisioning_encryption_password(arch_configuration: dict, user_credentials: dict) -> None:
|
||||||
|
"""Ensure an encrypted deferred-provisioning install has a LUKS passphrase archinstall can
|
||||||
|
use. Reuse one the input already carries (an autoinstall rig may supply
|
||||||
|
its own); otherwise generate a throwaway. Mutates the disk_encryption
|
||||||
|
block in place — arch_configuration shares it with user_configuration, so
|
||||||
|
the stage_provisioning_state phase can read the effective passphrase back."""
|
||||||
|
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"
|
||||||
@@ -103,6 +191,16 @@ def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_path(path: str | None) -> Path | None:
|
||||||
|
"""A path the caller always passes but that need not exist. The live ISO
|
||||||
|
passes --authorized-keys-file on every install; only autoinstall drives put
|
||||||
|
a file there."""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
p = Path(path)
|
||||||
|
return p if p.exists() else None
|
||||||
|
|
||||||
|
|
||||||
def _read_text(path: str | None) -> str:
|
def _read_text(path: str | None) -> str:
|
||||||
if not path:
|
if not path:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Offline target keyboard configuration.
|
||||||
|
|
||||||
|
archinstall's set_keyboard_language boots the installed system in a
|
||||||
|
systemd-nspawn container just to run localectl. systemd-firstboot --root
|
||||||
|
produces the part of that output Omarchy actually consumes — KEYMAP for the
|
||||||
|
console plus the XKB* settings in vconsole.conf that omarchy's
|
||||||
|
detect-keyboard-layout.sh copies into Hyprland's kb_layout — without booting
|
||||||
|
anything. The Xorg 00-keyboard.conf that localectl also writes is not
|
||||||
|
generated: nothing on an Omarchy system reads it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def configure_keyboard(target: Path, language: str) -> bool:
|
||||||
|
"""Write the console keymap into a mounted target without booting it.
|
||||||
|
|
||||||
|
Returns False for layouts localectl doesn't know, matching archinstall:
|
||||||
|
warn and keep the default. Every layout the configurator offers is known;
|
||||||
|
the guard is for the kb_layout an autoinstall drive can name freely.
|
||||||
|
"""
|
||||||
|
if not language.strip():
|
||||||
|
return True
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["localectl", "--no-pager", "list-keymaps"],
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = result.stderr.strip() or "localectl returned an error"
|
||||||
|
raise RuntimeError(f"Unable to list keyboard layouts: {detail}")
|
||||||
|
if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
|
||||||
|
# FONT= line archinstall's set_vconsole wrote earlier.
|
||||||
|
vconsole_path = target / "etc" / "vconsole.conf"
|
||||||
|
font = None
|
||||||
|
if vconsole_path.exists():
|
||||||
|
font = next(
|
||||||
|
(line for line in vconsole_path.read_text().splitlines() if line.startswith("FONT=")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemd-firstboot", f"--root={target}", f"--keymap={language}", "--force"],
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = result.stderr.strip() or "systemd-firstboot returned an error"
|
||||||
|
raise RuntimeError(f"Unable to configure keyboard layout {language}: {detail}")
|
||||||
|
|
||||||
|
if font:
|
||||||
|
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -34,11 +34,15 @@ def build_phases(ctx: InstallContext):
|
|||||||
arch_install_system,
|
arch_install_system,
|
||||||
configure_hibernation,
|
configure_hibernation,
|
||||||
run_system_finalizer,
|
run_system_finalizer,
|
||||||
|
stage_provisioning_state,
|
||||||
finalize_limine_boot,
|
finalize_limine_boot,
|
||||||
run_chroot_finalizer,
|
run_chroot_finalizer,
|
||||||
configure_dns_resolver,
|
configure_dns_resolver,
|
||||||
configure_login,
|
configure_login,
|
||||||
|
configure_ssh_access,
|
||||||
|
configure_tailscale,
|
||||||
validate_boot,
|
validate_boot,
|
||||||
|
create_factory_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -47,11 +51,17 @@ 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 deferred-provisioning cryptkey drop-in and keyfile
|
||||||
|
# must be in place for the final UKI build.
|
||||||
|
("Staging provisioning", stage_provisioning_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),
|
||||||
|
("Configuring SSH access", configure_ssh_access),
|
||||||
|
("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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -62,14 +72,19 @@ 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 "deferred provisioning (user created at first boot)"
|
||||||
|
info(f"Installing Omarchy for {who} → {ctx.target}")
|
||||||
|
|
||||||
from .phases_impl import (
|
from .phases_impl import (
|
||||||
|
boost_cpu_governor,
|
||||||
cleanup_bind_mounts,
|
cleanup_bind_mounts,
|
||||||
cleanup_protected_state,
|
cleanup_protected_state,
|
||||||
cleanup_target_hook_masks,
|
cleanup_target_hook_masks,
|
||||||
|
restore_cpu_governors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
governors = boost_cpu_governor()
|
||||||
|
|
||||||
success = False
|
success = False
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -85,6 +100,7 @@ def main() -> int:
|
|||||||
info("Installation complete.")
|
info("Installation complete.")
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
|
restore_cpu_governors(governors)
|
||||||
cleanup_bind_mounts(ctx)
|
cleanup_bind_mounts(ctx)
|
||||||
cleanup_target_hook_masks(ctx)
|
cleanup_target_hook_masks(ctx)
|
||||||
if not success:
|
if not success:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ the InstallContext and either return cleanly or raise to abort the install."""
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -25,6 +26,9 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
|
|||||||
state_path = ctx.state_dir / "state.json"
|
state_path = ctx.state_dir / "state.json"
|
||||||
state = {
|
state = {
|
||||||
"started_at": time.time(),
|
"started_at": time.time(),
|
||||||
|
# The dashboard counts packages under <target>/var/lib/pacman/local;
|
||||||
|
# publish the path rather than have the UI assume /mnt.
|
||||||
|
"target": str(ctx.target),
|
||||||
"total_phases": len(phases),
|
"total_phases": len(phases),
|
||||||
"current_index": 0,
|
"current_index": 0,
|
||||||
"current_phase": "Starting installation",
|
"current_phase": "Starting installation",
|
||||||
@@ -63,6 +67,10 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
|
|||||||
state["current_index"] = max(len(phases) - 1, 0)
|
state["current_index"] = max(len(phases) - 1, 0)
|
||||||
state["current_phase"] = "Installation complete"
|
state["current_phase"] = "Installation complete"
|
||||||
state["finished_at"] = time.time()
|
state["finished_at"] = time.time()
|
||||||
|
# Expected vs actual for the bar's denominator, so drift is visible in
|
||||||
|
# acceptance runs rather than only by watching a bar creep.
|
||||||
|
state["installed_packages"] = _installed_package_count(ctx.target)
|
||||||
|
state["expected_packages"] = _expected_package_count()
|
||||||
_write_state(state_path, state)
|
_write_state(state_path, state)
|
||||||
|
|
||||||
timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json"
|
timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json"
|
||||||
@@ -70,6 +78,25 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
|
|||||||
_write_state(timing_path, state)
|
_write_state(timing_path, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _installed_package_count(target: Path) -> int:
|
||||||
|
"""Packages libalpm installed into the target — one directory each under
|
||||||
|
local/, which is what the dashboard counts live."""
|
||||||
|
local_db = target / "var" / "lib" / "pacman" / "local"
|
||||||
|
try:
|
||||||
|
with os.scandir(local_db) as entries:
|
||||||
|
return sum(1 for entry in entries if entry.is_dir())
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_package_count() -> int:
|
||||||
|
path = Path("/usr/share/omarchy-iso/expected-packages")
|
||||||
|
try:
|
||||||
|
return int(path.read_text().split()[0])
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _write_state(path: Path, state: dict) -> None:
|
def _write_state(path: Path, state: dict) -> None:
|
||||||
# Dashboard polls this file while phases update it. Write atomically so the
|
# Dashboard polls this file while phases update it. Write atomically so the
|
||||||
# reader never observes a truncated/partial JSON document and resets UI.
|
# reader never observes a truncated/partial JSON document and resets UI.
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ Phase ordering (full-disk and protected/pre-mounted):
|
|||||||
base install, early Omarchy packages, Limine setup,
|
base install, early Omarchy packages, Limine setup,
|
||||||
useradd, runtime Omarchy packages, fstab
|
useradd, runtime Omarchy packages, fstab
|
||||||
configure_hibernation → root-owned swap/resume drop-ins
|
configure_hibernation → root-owned swap/resume drop-ins
|
||||||
run_system_finalizer → arch-chroot root omarchy-setup-system, including Snapper
|
run_system_finalizer → arch-chroot root omarchy-apply-system, including Snapper
|
||||||
finalize_limine_boot → final Limine config/UKI build after hardware drop-ins
|
finalize_limine_boot → final Limine config/UKI build after hardware drop-ins
|
||||||
run_chroot_finalizer → arch-chroot -u user omarchy-finalize-user
|
run_chroot_finalizer → arch-chroot -u user omarchy-provision-user
|
||||||
configure_login → sddm state + encrypted-install autologin
|
configure_login → sddm state + encrypted-install autologin
|
||||||
|
configure_ssh_access → authorized_keys for autoinstall; no-op otherwise
|
||||||
|
configure_tailscale → tailnet join staged for first boot; no-op otherwise
|
||||||
validate_boot → assert UKI / limine.conf / kernel cmdline are sane
|
validate_boot → assert UKI / limine.conf / kernel cmdline are sane
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -27,11 +29,13 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import textwrap
|
import textwrap
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import archinstall_adapter as arch
|
from . import archinstall_adapter as arch
|
||||||
from .context import InstallContext
|
from .context import InstallContext
|
||||||
from .ui import info
|
from .keyboard import configure_keyboard
|
||||||
|
from .ui import error, info
|
||||||
|
|
||||||
|
|
||||||
# Package targets are written by builder/build-iso.sh. Stable ISOs use the
|
# Package targets are written by builder/build-iso.sh. Stable ISOs use the
|
||||||
@@ -246,6 +250,10 @@ def arch_install_system(ctx: InstallContext) -> None:
|
|||||||
_mask_mkinitcpio_pacman_hooks(ctx)
|
_mask_mkinitcpio_pacman_hooks(ctx)
|
||||||
try:
|
try:
|
||||||
info("› installing base system (mkinitcpio deferred to final Limine UKI build)")
|
info("› installing base system (mkinitcpio deferred to final Limine UKI build)")
|
||||||
|
# An empty kb_layout makes archinstall's set_keyboard_language skip
|
||||||
|
# booting the target in a container just to run localectl; the
|
||||||
|
# keymap is configured offline right after instead.
|
||||||
|
kb_layout = config.locale_config.kb_layout if config.locale_config else ""
|
||||||
installer.minimal_installation(
|
installer.minimal_installation(
|
||||||
optional_repositories=(
|
optional_repositories=(
|
||||||
config.mirror_config.optional_repositories
|
config.mirror_config.optional_repositories
|
||||||
@@ -253,10 +261,16 @@ def arch_install_system(ctx: InstallContext) -> None:
|
|||||||
),
|
),
|
||||||
mkinitcpio=False,
|
mkinitcpio=False,
|
||||||
hostname=config.hostname,
|
hostname=config.hostname,
|
||||||
locale_config=config.locale_config,
|
locale_config=(
|
||||||
|
replace(config.locale_config, kb_layout="")
|
||||||
|
if config.locale_config else None
|
||||||
|
),
|
||||||
pacman_config=config.pacman_config,
|
pacman_config=config.pacman_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not configure_keyboard(installer.target, kb_layout):
|
||||||
|
error(f"Invalid keyboard language specified: {kb_layout}")
|
||||||
|
|
||||||
if config.mirror_config:
|
if config.mirror_config:
|
||||||
installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True)
|
installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True)
|
||||||
|
|
||||||
@@ -277,6 +291,14 @@ def arch_install_system(ctx: InstallContext) -> None:
|
|||||||
|
|
||||||
info("› installing Omarchy runtime + omarchy-base.packages")
|
info("› installing Omarchy runtime + omarchy-base.packages")
|
||||||
installer.add_additional_packages(_runtime_package_list(ctx))
|
installer.add_additional_packages(_runtime_package_list(ctx))
|
||||||
|
|
||||||
|
# Tailscale is bundled in the offline mirror but only installed
|
||||||
|
# when an autoinstall drive staged an auth key; must happen here,
|
||||||
|
# while the mirror is still bind-mounted, not in the phase that
|
||||||
|
# configures the join.
|
||||||
|
if ctx.tailscale_authkey_path is not None:
|
||||||
|
info("› installing tailscale (auth key staged for first boot)")
|
||||||
|
installer.add_additional_packages(["tailscale"])
|
||||||
finally:
|
finally:
|
||||||
_unmask_mkinitcpio_pacman_hooks(ctx)
|
_unmask_mkinitcpio_pacman_hooks(ctx)
|
||||||
_unmount_offline_package_cache(ctx)
|
_unmount_offline_package_cache(ctx)
|
||||||
@@ -437,7 +459,13 @@ def _register_limine_efi_entry(
|
|||||||
raise RuntimeError("efibootmgr --create reported success but no Limine entry found")
|
raise RuntimeError("efibootmgr --create reported success but no Limine entry found")
|
||||||
limine_num = new_limine[0]
|
limine_num = new_limine[0]
|
||||||
|
|
||||||
keep = [num for num in pre_state["order"] if num not in stale_limine and num != limine_num]
|
keep = [
|
||||||
|
num
|
||||||
|
for num in pre_state["order"]
|
||||||
|
if num not in stale_limine
|
||||||
|
and num != limine_num
|
||||||
|
and num in pre_state["entries"]
|
||||||
|
]
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["efibootmgr", "--bootorder", ",".join([limine_num, *keep])],
|
["efibootmgr", "--bootorder", ",".join([limine_num, *keep])],
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
@@ -570,7 +598,7 @@ DEFERRED_BOOT_HOOKS = (
|
|||||||
# limine-entry-tool's 90-mkinitcpio-install.hook triggers on usr/lib/firmware/*,
|
# limine-entry-tool's 90-mkinitcpio-install.hook triggers on usr/lib/firmware/*,
|
||||||
# usr/src/*/dkms.conf and usr/lib/modules/*/pkgbase, and anything but a
|
# usr/src/*/dkms.conf and usr/lib/modules/*/pkgbase, and anything but a
|
||||||
# usr/lib/modules path makes it rebuild the initramfs and UKI for EVERY
|
# usr/lib/modules path makes it rebuild the initramfs and UKI for EVERY
|
||||||
# installed kernel. omarchy-setup-system's hardware scripts routinely install
|
# installed kernel. omarchy-apply-system's hardware scripts routinely install
|
||||||
# such packages (sof-firmware on Intel audio, nvidia-open-dkms, linux-ptl on
|
# such packages (sof-firmware on Intel audio, nvidia-open-dkms, linux-ptl on
|
||||||
# Panther Lake, linux-t2 on Macs), so the phase can pay for several full UKI
|
# Panther Lake, linux-t2 on Macs), so the phase can pay for several full UKI
|
||||||
# builds. finalize_limine_boot runs limine-update right after, which pipes
|
# builds. finalize_limine_boot runs limine-update right after, which pipes
|
||||||
@@ -977,8 +1005,8 @@ def _debug_run(ctx: InstallContext, cmd: list[str]) -> None:
|
|||||||
# 1. point the target at the offline pacman.conf
|
# 1. point the target at the offline pacman.conf
|
||||||
# 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman
|
# 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman
|
||||||
# and bundled language runtimes
|
# and bundled language runtimes
|
||||||
# 3. arch-chroot as root → omarchy-setup-system --first-install
|
# 3. arch-chroot as root → omarchy-apply-system --first-install
|
||||||
# 4. arch-chroot as user → omarchy-finalize-user --first-install
|
# 4. arch-chroot as user → omarchy-provision-user --first-install
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _prepare_target_setup(ctx: InstallContext) -> None:
|
def _prepare_target_setup(ctx: InstallContext) -> None:
|
||||||
@@ -1108,16 +1136,142 @@ 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.defer_provisioning:
|
||||||
|
cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"]
|
||||||
|
else:
|
||||||
|
cmd = ["/usr/bin/omarchy-apply-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_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.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
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
|
||||||
|
|
||||||
|
service_src = ctx.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
|
||||||
|
setup_bin = ctx.target / "usr/bin/omarchy-provision-owner"
|
||||||
|
if not service_src.exists() or not setup_bin.exists():
|
||||||
|
raise RuntimeError(
|
||||||
|
"deferred-provisioning install requested, but the installed Omarchy runtime does not ship "
|
||||||
|
"first-boot setup (omarchy-provision-owner + install/provisioning/omarchy-provision-owner.service). "
|
||||||
|
"Update the runtime package this ISO bundles before installing in deferred provisioning."
|
||||||
|
)
|
||||||
|
|
||||||
|
info("› arming first-boot setup")
|
||||||
|
(provisioning_dir / "pending").touch()
|
||||||
|
|
||||||
|
unit_dst = ctx.target / "etc/systemd/system/omarchy-provision-owner.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-provision-owner.service"
|
||||||
|
link.unlink(missing_ok=True)
|
||||||
|
link.symlink_to("/etc/systemd/system/omarchy-provision-owner.service")
|
||||||
|
|
||||||
|
if _provision_install_encrypted(ctx):
|
||||||
|
_stage_provisioning_luks_unlock(ctx, provisioning_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_node_tarball(ctx: InstallContext, provisioning_dir) -> None:
|
||||||
|
tarballs = sorted(NODE_PACKAGES_DIR.glob("node-v*-linux-x64.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
|
||||||
|
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 _provision_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_provisioning_luks_unlock(ctx: InstallContext, provisioning_dir) -> None:
|
||||||
|
password = _provision_encryption_password(ctx)
|
||||||
|
if not password:
|
||||||
|
# Full-disk deferred-provisioning 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(
|
||||||
|
"deferred-provisioning 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 provisioning window")
|
||||||
|
|
||||||
|
# Byte-for-byte the slot passphrase: no trailing newline anywhere.
|
||||||
|
luks_key = provisioning_dir / "luks-key"
|
||||||
|
luks_key.write_text(password)
|
||||||
|
luks_key.chmod(0o600)
|
||||||
|
|
||||||
|
keyfile = ctx.target / PROVISION_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-provisioning-unlock.conf"
|
||||||
|
cmdline_dropin.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmdline_dropin.write_text(
|
||||||
|
'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
files_dropin = ctx.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf"
|
||||||
|
files_dropin.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
files_dropin.write_text("FILES+=(/etc/omarchy/provisioning.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).
|
||||||
@@ -1211,9 +1365,13 @@ def _limine_kernel_cmdline(config_text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def run_chroot_finalizer(ctx: InstallContext) -> None:
|
def run_chroot_finalizer(ctx: InstallContext) -> None:
|
||||||
|
if ctx.defer_provisioning:
|
||||||
|
info("› deferred-provisioning 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-provision-user", "--force", "--first-install"],
|
||||||
user=ctx.username,
|
user=ctx.username,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1257,35 +1415,233 @@ 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.defer_provisioning:
|
||||||
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:
|
||||||
|
# deferred-provisioning installs have no user yet; omarchy-provision-owner 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.defer_provisioning:
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# configure_ssh_access: make the installed machine reachable over SSH with the
|
||||||
|
# keys an autoinstall drive supplied. A stock Omarchy install ships openssh but
|
||||||
|
# leaves sshd disabled, and its firewall.sh opens only LocalSend and docker DNS,
|
||||||
|
# so all three pieces -- keys, service, firewall -- have to be done here.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def configure_ssh_access(ctx: InstallContext) -> None:
|
||||||
|
if ctx.authorized_keys_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
keys = _authorized_keys(ctx.authorized_keys_path)
|
||||||
|
|
||||||
|
if ctx.defer_provisioning:
|
||||||
|
# No user to authorize yet. Stage the keys in provisioning state for
|
||||||
|
# omarchy-provision-owner 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")
|
||||||
|
provisioning_dir = ctx.target / PROVISION_STATE_DIR
|
||||||
|
provisioning_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
staged = provisioning_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}")
|
||||||
|
|
||||||
|
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
|
||||||
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ssh_dir.chmod(0o700)
|
||||||
|
|
||||||
|
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(
|
||||||
|
["arch-chroot", str(ctx.target), "systemctl", "enable", "sshd.service"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open port 22 in the target's ufw, which runs default-deny incoming, so an
|
||||||
|
# enabled sshd is still unreachable -- connections time out rather than
|
||||||
|
# being refused.
|
||||||
|
#
|
||||||
|
# ufw cannot reach netfilter from inside the chroot and exits non-zero
|
||||||
|
# saying so, but it writes the rule to user.rules first, and that file is
|
||||||
|
# what ufw.service loads on first boot. So the exit status is the wrong
|
||||||
|
# thing to check here; the rule landing in the file is the thing that
|
||||||
|
# matters.
|
||||||
|
info("› allowing SSH through ufw")
|
||||||
|
subprocess.run(["arch-chroot", str(ctx.target), "ufw", "allow", "ssh"], check=False)
|
||||||
|
|
||||||
|
rules = ctx.target / "etc" / "ufw" / "user.rules"
|
||||||
|
text = rules.read_text() if rules.exists() else ""
|
||||||
|
if "--dport 22 -j ACCEPT" not in text:
|
||||||
|
raise RuntimeError(f"ufw did not record an allow rule for port 22 in {rules}")
|
||||||
|
|
||||||
|
|
||||||
|
def _authorized_keys(path: Path) -> list[str]:
|
||||||
|
"""Read the autoinstall authorized_keys: sshd's own format, one public key
|
||||||
|
per line, with blank lines and # comments dropped.
|
||||||
|
|
||||||
|
Raise rather than skip on anything unusable. An install that "succeeds"
|
||||||
|
into a machine nobody can log into is worse than one that stops with the
|
||||||
|
reason on screen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"{path} is not readable: {exc}") from exc
|
||||||
|
|
||||||
|
keys = [line.strip() for line in lines]
|
||||||
|
keys = [key for key in keys if key and not key.startswith("#")]
|
||||||
|
if not keys:
|
||||||
|
raise RuntimeError(f"{path} contains no SSH keys")
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# configure_tailscale: stage the tailnet join an autoinstall drive asked for.
|
||||||
|
# `tailscale up` needs a running tailscaled and there is no systemd in the
|
||||||
|
# chroot, so the install only stages: the key, the enabled services, and a
|
||||||
|
# oneshot first-boot unit that performs the join once the network is really
|
||||||
|
# there. The package itself was installed from the offline mirror during
|
||||||
|
# arch_install_system -- nothing is fetched at boot.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TAILSCALE_AUTHKEY_TARGET = "/etc/tailscale/authkey"
|
||||||
|
|
||||||
|
# systemd expands $VAR in ExecStart, so the retry loop avoids `$` entirely.
|
||||||
|
# network-online.target can be reached before there is real connectivity, so
|
||||||
|
# retry inside the boot -- but NOT as a oneshot: target units implicitly gain
|
||||||
|
# After= for their Wants=, so a oneshot in multi-user.target holds the whole
|
||||||
|
# boot (SDDM included) hostage until it finishes. Type=simple counts as
|
||||||
|
# started the moment it forks, letting boot proceed while the join retries in
|
||||||
|
# the background for as long as the boot lasts. Cleanup lives inside the
|
||||||
|
# script because it must only run after a successful join: the key is removed
|
||||||
|
# and the unit disabled on success, while on a boot with no connectivity both
|
||||||
|
# survive -- so a machine installed offline joins on the first boot that can.
|
||||||
|
TAILSCALE_JOIN_UNIT = f"""\
|
||||||
|
[Unit]
|
||||||
|
Description=Join the tailnet with the auth key staged by autoinstall
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target tailscaled.service
|
||||||
|
Requires=tailscaled.service
|
||||||
|
ConditionPathExists={TAILSCALE_AUTHKEY_TARGET}
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:{TAILSCALE_AUTHKEY_TARGET}; do sleep 15; done; rm -f {TAILSCALE_AUTHKEY_TARGET}; systemctl disable omarchy-tailscale-join.service'
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def configure_tailscale(ctx: InstallContext) -> None:
|
||||||
|
if ctx.tailscale_authkey_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
key = _tailscale_authkey(ctx.tailscale_authkey_path)
|
||||||
|
|
||||||
|
# An ISO built before tailscale was bundled installs nothing, and a staged
|
||||||
|
# key with no binary would fail silently forever on first boot.
|
||||||
|
if not (ctx.target / "usr" / "bin" / "tailscale").exists():
|
||||||
|
raise RuntimeError("tailscale is not installed on the target; this ISO does not bundle it")
|
||||||
|
|
||||||
|
info("› staging Tailscale auth key")
|
||||||
|
ts_dir = ctx.target / "etc" / "tailscale"
|
||||||
|
ts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts_dir.chmod(0o700)
|
||||||
|
authkey = ts_dir / "authkey"
|
||||||
|
authkey.write_text(f"{key}\n")
|
||||||
|
authkey.chmod(0o600)
|
||||||
|
|
||||||
|
info("› enabling tailscaled and the first-boot join")
|
||||||
|
unit = ctx.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
|
||||||
|
unit.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
unit.write_text(TAILSCALE_JOIN_UNIT)
|
||||||
|
subprocess.run(
|
||||||
|
["arch-chroot", str(ctx.target), "systemctl", "enable",
|
||||||
|
"tailscaled.service", "omarchy-tailscale-join.service"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same dance as configure_ssh_access: ufw cannot reach netfilter from the
|
||||||
|
# chroot and exits non-zero, but it records the rule in user.rules first,
|
||||||
|
# and that file is what ufw.service loads on first boot. Without the rule
|
||||||
|
# the node joins the tailnet and is then unreachable over it.
|
||||||
|
info("› allowing tailnet traffic through ufw")
|
||||||
|
subprocess.run(
|
||||||
|
["arch-chroot", str(ctx.target), "ufw", "allow", "in", "on", "tailscale0"],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = ctx.target / "etc" / "ufw" / "user.rules"
|
||||||
|
text = rules.read_text() if rules.exists() else ""
|
||||||
|
if "-i tailscale0 -j ACCEPT" not in text:
|
||||||
|
raise RuntimeError(f"ufw did not record an allow rule for tailscale0 in {rules}")
|
||||||
|
|
||||||
|
|
||||||
|
def _tailscale_authkey(path: Path) -> str:
|
||||||
|
"""Read the autoinstall tailscale_authkey: exactly one key, with blank
|
||||||
|
lines and # comments dropped. No format validation beyond that -- key
|
||||||
|
formats are Tailscale's to change.
|
||||||
|
|
||||||
|
Raise rather than skip on anything unusable, same reasoning as
|
||||||
|
_authorized_keys: a machine that "succeeds" into never joining the
|
||||||
|
tailnet is worse than one that stops with the reason on screen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"{path} is not readable: {exc}") from exc
|
||||||
|
|
||||||
|
keys = [line.strip() for line in lines]
|
||||||
|
keys = [key for key in keys if key and not key.startswith("#")]
|
||||||
|
if not keys:
|
||||||
|
raise RuntimeError(f"{path} contains no auth key")
|
||||||
|
if len(keys) > 1:
|
||||||
|
raise RuntimeError(f"{path} contains {len(keys)} keys; expected exactly one")
|
||||||
|
|
||||||
|
return keys[0]
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# validate_boot: hard checks before reboot. If the install ran but produced a
|
# validate_boot: hard checks before reboot. If the install ran but produced a
|
||||||
# boot config or UKI that can't actually boot, halt here rather than surprise
|
# boot config or UKI that can't actually boot, halt here rather than surprise
|
||||||
@@ -1339,6 +1695,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.defer_provisioning:
|
||||||
|
_validate_provisioning_state(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provisioning_state(ctx: InstallContext) -> None:
|
||||||
|
"""An deferred-provisioning install that boots without a working first-boot setup is a
|
||||||
|
user-less brick; insist the armed state is complete before reboot."""
|
||||||
|
provisioning_dir = ctx.target / PROVISION_STATE_DIR
|
||||||
|
if not (provisioning_dir / "pending").exists():
|
||||||
|
raise RuntimeError(f"deferred-provisioning install but {provisioning_dir / 'pending'} is missing")
|
||||||
|
|
||||||
|
link = ctx.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service"
|
||||||
|
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():
|
||||||
|
raise RuntimeError(f"encrypted deferred-provisioning 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 deferred-provisioning 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.
|
||||||
@@ -1396,6 +1780,124 @@ 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-system-factory-reset 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", str(root_subvol), str(factory)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
_scrub_factory_snapshot(factory)
|
||||||
|
subprocess.run(
|
||||||
|
["btrfs", "property", "set", "-ts", str(factory), "ro", "true"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
subprocess.run(["umount", str(top)], check=False, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Provisioning credentials staged for THIS deployment's first boot must not
|
||||||
|
# survive into the factory image: a reset years later would otherwise hand the
|
||||||
|
# next owner the original deployment's SSH keys or rejoin its tailnet, and a
|
||||||
|
# stale LUKS key (dead after the first re-key) has no business lingering.
|
||||||
|
# The mkinitcpio/cmdline drop-ins go with the keyfile — a reset rebuild would
|
||||||
|
# otherwise fail on FILES pointing at a scrubbed path.
|
||||||
|
FACTORY_SCRUB_PATHS = (
|
||||||
|
"var/lib/omarchy/provisioning/authorized_keys",
|
||||||
|
"var/lib/omarchy/provisioning/luks-key",
|
||||||
|
"etc/omarchy/provisioning.key",
|
||||||
|
"etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf",
|
||||||
|
"etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf",
|
||||||
|
"etc/tailscale/authkey",
|
||||||
|
"etc/systemd/system/omarchy-tailscale-join.service",
|
||||||
|
"etc/systemd/system/multi-user.target.wants/omarchy-tailscale-join.service",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scrub_factory_snapshot(factory: Path) -> None:
|
||||||
|
for rel in FACTORY_SCRUB_PATHS:
|
||||||
|
(factory / rel).unlink(missing_ok=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")
|
||||||
|
|
||||||
|
|
||||||
|
def boost_cpu_governor() -> dict[Path, str]:
|
||||||
|
"""Run the live CPUs flat out for the install.
|
||||||
|
|
||||||
|
Package extraction and the UKI build are both CPU-bound, and archiso boots
|
||||||
|
on whatever governor the kernel defaults to. Writing an unsupported
|
||||||
|
governor just fails, so nothing needs probing first, and hosts without
|
||||||
|
cpufreq (most VMs) have no paths at all. Returns the prior governors.
|
||||||
|
"""
|
||||||
|
saved: dict[Path, str] = {}
|
||||||
|
for path in sorted(CPU_SYSFS.glob("cpu*/cpufreq/scaling_governor")):
|
||||||
|
try:
|
||||||
|
saved[path] = path.read_text().strip()
|
||||||
|
path.write_text("performance\n")
|
||||||
|
except OSError:
|
||||||
|
saved.pop(path, None)
|
||||||
|
|
||||||
|
if saved:
|
||||||
|
info(f"› CPU governor set to performance ({len(saved)} CPUs)")
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def restore_cpu_governors(saved: dict[Path, str]) -> None:
|
||||||
|
"""Only matters when an install fails and the user keeps using the live
|
||||||
|
environment — a successful one reboots out of it."""
|
||||||
|
for path, governor in saved.items():
|
||||||
|
try:
|
||||||
|
path.write_text(f"{governor}\n")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# cleanup_bind_mounts: invoked from main()'s finally so bind mounts get
|
# cleanup_bind_mounts: invoked from main()'s finally so bind mounts get
|
||||||
# unwound on success, failure, or interrupt. Idempotent.
|
# unwound on success, failure, or interrupt. Idempotent.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ file_permissions=(
|
|||||||
["/root/.gnupg"]="0:0:700"
|
["/root/.gnupg"]="0:0:700"
|
||||||
["/root/configurator"]="0:0:755"
|
["/root/configurator"]="0:0:755"
|
||||||
["/usr/local/bin/choose-mirror"]="0:0:755"
|
["/usr/local/bin/choose-mirror"]="0:0:755"
|
||||||
|
["/usr/local/bin/omarchy-cidata-load"]="0:0:755"
|
||||||
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
|
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
|
||||||
["/usr/local/bin/omarchy-install-dashboard"]="0:0:755"
|
["/usr/local/bin/omarchy-install-dashboard"]="0:0:755"
|
||||||
["/usr/local/bin/omarchy-iso-install"]="0:0:755"
|
["/usr/local/bin/omarchy-iso-install"]="0:0:755"
|
||||||
|
|||||||
@@ -3763,7 +3763,6 @@
|
|||||||
"kservice",
|
"kservice",
|
||||||
"ktextwidgets",
|
"ktextwidgets",
|
||||||
"kvantum",
|
"kvantum",
|
||||||
"kvantum-qt5",
|
|
||||||
"kwallet",
|
"kwallet",
|
||||||
"kwidgetsaddons",
|
"kwidgetsaddons",
|
||||||
"kwindowsystem",
|
"kwindowsystem",
|
||||||
@@ -4212,12 +4211,6 @@
|
|||||||
"qpdf",
|
"qpdf",
|
||||||
"qqc2-desktop-style",
|
"qqc2-desktop-style",
|
||||||
"qrcodegencpp-cmake",
|
"qrcodegencpp-cmake",
|
||||||
"qt5-base",
|
|
||||||
"qt5-declarative",
|
|
||||||
"qt5-svg",
|
|
||||||
"qt5-translations",
|
|
||||||
"qt5-wayland",
|
|
||||||
"qt5-x11extras",
|
|
||||||
"qt6-5compat",
|
"qt6-5compat",
|
||||||
"qt6-base",
|
"qt6-base",
|
||||||
"qt6-declarative",
|
"qt6-declarative",
|
||||||
@@ -4485,7 +4478,7 @@
|
|||||||
"jq",
|
"jq",
|
||||||
"kdenlive",
|
"kdenlive",
|
||||||
"kernel-modules-hook",
|
"kernel-modules-hook",
|
||||||
"kvantum-qt5",
|
"kvantum",
|
||||||
"lazydocker",
|
"lazydocker",
|
||||||
"lazygit",
|
"lazygit",
|
||||||
"less",
|
"less",
|
||||||
@@ -4531,7 +4524,6 @@
|
|||||||
"python-poetry-core",
|
"python-poetry-core",
|
||||||
"python-terminaltexteffects",
|
"python-terminaltexteffects",
|
||||||
"qemu-user-static-binfmt",
|
"qemu-user-static-binfmt",
|
||||||
"qt5-wayland",
|
|
||||||
"quickshell",
|
"quickshell",
|
||||||
"ripgrep",
|
"ripgrep",
|
||||||
"ruby",
|
"ruby",
|
||||||
@@ -4639,7 +4631,7 @@
|
|||||||
"jq",
|
"jq",
|
||||||
"kdenlive",
|
"kdenlive",
|
||||||
"kernel-modules-hook",
|
"kernel-modules-hook",
|
||||||
"kvantum-qt5",
|
"kvantum",
|
||||||
"lazydocker",
|
"lazydocker",
|
||||||
"lazygit",
|
"lazygit",
|
||||||
"less",
|
"less",
|
||||||
@@ -4685,7 +4677,6 @@
|
|||||||
"python-poetry-core",
|
"python-poetry-core",
|
||||||
"python-terminaltexteffects",
|
"python-terminaltexteffects",
|
||||||
"qemu-user-static-binfmt",
|
"qemu-user-static-binfmt",
|
||||||
"qt5-wayland",
|
|
||||||
"quickshell",
|
"quickshell",
|
||||||
"ripgrep",
|
"ripgrep",
|
||||||
"ruby",
|
"ruby",
|
||||||
@@ -5026,7 +5017,6 @@
|
|||||||
"kservice",
|
"kservice",
|
||||||
"ktextwidgets",
|
"ktextwidgets",
|
||||||
"kvantum",
|
"kvantum",
|
||||||
"kvantum-qt5",
|
|
||||||
"kwallet",
|
"kwallet",
|
||||||
"kwidgetsaddons",
|
"kwidgetsaddons",
|
||||||
"kwindowsystem",
|
"kwindowsystem",
|
||||||
@@ -5475,12 +5465,6 @@
|
|||||||
"qpdf",
|
"qpdf",
|
||||||
"qqc2-desktop-style",
|
"qqc2-desktop-style",
|
||||||
"qrcodegencpp-cmake",
|
"qrcodegencpp-cmake",
|
||||||
"qt5-base",
|
|
||||||
"qt5-declarative",
|
|
||||||
"qt5-svg",
|
|
||||||
"qt5-translations",
|
|
||||||
"qt5-wayland",
|
|
||||||
"qt5-x11extras",
|
|
||||||
"qt6-5compat",
|
"qt6-5compat",
|
||||||
"qt6-base",
|
"qt6-base",
|
||||||
"qt6-declarative",
|
"qt6-declarative",
|
||||||
@@ -5976,7 +5960,6 @@
|
|||||||
"kservice": "6.26.0-1",
|
"kservice": "6.26.0-1",
|
||||||
"ktextwidgets": "6.26.0-1",
|
"ktextwidgets": "6.26.0-1",
|
||||||
"kvantum": "1.1.7-1",
|
"kvantum": "1.1.7-1",
|
||||||
"kvantum-qt5": "1.1.7-1",
|
|
||||||
"kwallet": "6.26.0-1",
|
"kwallet": "6.26.0-1",
|
||||||
"kwidgetsaddons": "6.26.0-1",
|
"kwidgetsaddons": "6.26.0-1",
|
||||||
"kwindowsystem": "6.26.0-1",
|
"kwindowsystem": "6.26.0-1",
|
||||||
@@ -6426,12 +6409,6 @@
|
|||||||
"qpdf": "12.3.2-2",
|
"qpdf": "12.3.2-2",
|
||||||
"qqc2-desktop-style": "6.26.0-1",
|
"qqc2-desktop-style": "6.26.0-1",
|
||||||
"qrcodegencpp-cmake": "1.8.0-4",
|
"qrcodegencpp-cmake": "1.8.0-4",
|
||||||
"qt5-base": "5.15.19+kde+r96-1",
|
|
||||||
"qt5-declarative": "5.15.19+kde+r23-1",
|
|
||||||
"qt5-svg": "5.15.19+kde+r5-1",
|
|
||||||
"qt5-translations": "5.15.19-1",
|
|
||||||
"qt5-wayland": "5.15.19+kde+r55-1",
|
|
||||||
"qt5-x11extras": "5.15.19-1",
|
|
||||||
"qt6-5compat": "6.11.1-1",
|
"qt6-5compat": "6.11.1-1",
|
||||||
"qt6-base": "6.11.1-1",
|
"qt6-base": "6.11.1-1",
|
||||||
"qt6-declarative": "6.11.1-2",
|
"qt6-declarative": "6.11.1-2",
|
||||||
|
|||||||
+2
-25
@@ -3016,7 +3016,6 @@
|
|||||||
"kservice",
|
"kservice",
|
||||||
"ktextwidgets",
|
"ktextwidgets",
|
||||||
"kvantum",
|
"kvantum",
|
||||||
"kvantum-qt5",
|
|
||||||
"kwallet",
|
"kwallet",
|
||||||
"kwidgetsaddons",
|
"kwidgetsaddons",
|
||||||
"kwindowsystem",
|
"kwindowsystem",
|
||||||
@@ -3465,12 +3464,6 @@
|
|||||||
"qpdf",
|
"qpdf",
|
||||||
"qqc2-desktop-style",
|
"qqc2-desktop-style",
|
||||||
"qrcodegencpp-cmake",
|
"qrcodegencpp-cmake",
|
||||||
"qt5-base",
|
|
||||||
"qt5-declarative",
|
|
||||||
"qt5-svg",
|
|
||||||
"qt5-translations",
|
|
||||||
"qt5-wayland",
|
|
||||||
"qt5-x11extras",
|
|
||||||
"qt6-5compat",
|
"qt6-5compat",
|
||||||
"qt6-base",
|
"qt6-base",
|
||||||
"qt6-declarative",
|
"qt6-declarative",
|
||||||
@@ -3738,7 +3731,7 @@
|
|||||||
"jq",
|
"jq",
|
||||||
"kdenlive",
|
"kdenlive",
|
||||||
"kernel-modules-hook",
|
"kernel-modules-hook",
|
||||||
"kvantum-qt5",
|
"kvantum",
|
||||||
"lazydocker",
|
"lazydocker",
|
||||||
"lazygit",
|
"lazygit",
|
||||||
"less",
|
"less",
|
||||||
@@ -3784,7 +3777,6 @@
|
|||||||
"python-poetry-core",
|
"python-poetry-core",
|
||||||
"python-terminaltexteffects",
|
"python-terminaltexteffects",
|
||||||
"qemu-user-static-binfmt",
|
"qemu-user-static-binfmt",
|
||||||
"qt5-wayland",
|
|
||||||
"quickshell",
|
"quickshell",
|
||||||
"ripgrep",
|
"ripgrep",
|
||||||
"ruby",
|
"ruby",
|
||||||
@@ -3892,7 +3884,7 @@
|
|||||||
"jq",
|
"jq",
|
||||||
"kdenlive",
|
"kdenlive",
|
||||||
"kernel-modules-hook",
|
"kernel-modules-hook",
|
||||||
"kvantum-qt5",
|
"kvantum",
|
||||||
"lazydocker",
|
"lazydocker",
|
||||||
"lazygit",
|
"lazygit",
|
||||||
"less",
|
"less",
|
||||||
@@ -3938,7 +3930,6 @@
|
|||||||
"python-poetry-core",
|
"python-poetry-core",
|
||||||
"python-terminaltexteffects",
|
"python-terminaltexteffects",
|
||||||
"qemu-user-static-binfmt",
|
"qemu-user-static-binfmt",
|
||||||
"qt5-wayland",
|
|
||||||
"quickshell",
|
"quickshell",
|
||||||
"ripgrep",
|
"ripgrep",
|
||||||
"ruby",
|
"ruby",
|
||||||
@@ -4279,7 +4270,6 @@
|
|||||||
"kservice",
|
"kservice",
|
||||||
"ktextwidgets",
|
"ktextwidgets",
|
||||||
"kvantum",
|
"kvantum",
|
||||||
"kvantum-qt5",
|
|
||||||
"kwallet",
|
"kwallet",
|
||||||
"kwidgetsaddons",
|
"kwidgetsaddons",
|
||||||
"kwindowsystem",
|
"kwindowsystem",
|
||||||
@@ -4728,12 +4718,6 @@
|
|||||||
"qpdf",
|
"qpdf",
|
||||||
"qqc2-desktop-style",
|
"qqc2-desktop-style",
|
||||||
"qrcodegencpp-cmake",
|
"qrcodegencpp-cmake",
|
||||||
"qt5-base",
|
|
||||||
"qt5-declarative",
|
|
||||||
"qt5-svg",
|
|
||||||
"qt5-translations",
|
|
||||||
"qt5-wayland",
|
|
||||||
"qt5-x11extras",
|
|
||||||
"qt6-5compat",
|
"qt6-5compat",
|
||||||
"qt6-base",
|
"qt6-base",
|
||||||
"qt6-declarative",
|
"qt6-declarative",
|
||||||
@@ -5229,7 +5213,6 @@
|
|||||||
"kservice": "6.26.0-1",
|
"kservice": "6.26.0-1",
|
||||||
"ktextwidgets": "6.26.0-1",
|
"ktextwidgets": "6.26.0-1",
|
||||||
"kvantum": "1.1.7-1",
|
"kvantum": "1.1.7-1",
|
||||||
"kvantum-qt5": "1.1.7-1",
|
|
||||||
"kwallet": "6.26.0-1",
|
"kwallet": "6.26.0-1",
|
||||||
"kwidgetsaddons": "6.26.0-1",
|
"kwidgetsaddons": "6.26.0-1",
|
||||||
"kwindowsystem": "6.26.0-1",
|
"kwindowsystem": "6.26.0-1",
|
||||||
@@ -5679,12 +5662,6 @@
|
|||||||
"qpdf": "12.3.2-2",
|
"qpdf": "12.3.2-2",
|
||||||
"qqc2-desktop-style": "6.26.0-1",
|
"qqc2-desktop-style": "6.26.0-1",
|
||||||
"qrcodegencpp-cmake": "1.8.0-4",
|
"qrcodegencpp-cmake": "1.8.0-4",
|
||||||
"qt5-base": "5.15.19+kde+r96-1",
|
|
||||||
"qt5-declarative": "5.15.19+kde+r23-1",
|
|
||||||
"qt5-svg": "5.15.19+kde+r5-1",
|
|
||||||
"qt5-translations": "5.15.19-1",
|
|
||||||
"qt5-wayland": "5.15.19+kde+r55-1",
|
|
||||||
"qt5-x11extras": "5.15.19-1",
|
|
||||||
"qt6-5compat": "6.11.1-1",
|
"qt6-5compat": "6.11.1-1",
|
||||||
"qt6-base": "6.11.1-1",
|
"qt6-base": "6.11.1-1",
|
||||||
"qt6-declarative": "6.11.1-2",
|
"qt6-declarative": "6.11.1-2",
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Autoinstall Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let the standard Omarchy ISO install itself with no keyboard present, driven by config files handed to the VM on a second drive. This makes Omarchy usable as a Packer/Proxmox base image for disposable dev environments: create VM, boot, walk away, SSH in.
|
||||||
|
|
||||||
|
## Product Requirements
|
||||||
|
|
||||||
|
- No ISO rebuild. The shipped ISO autoinstalls when config is present and runs the configurator when it is not.
|
||||||
|
- No new menu entry, no new boot parameter, no separate ISO flavor.
|
||||||
|
- Config format is exactly what the configurator already writes — no second schema to maintain.
|
||||||
|
- The installed machine is reachable over SSH with a key supplied at install time.
|
||||||
|
- Interactive installs behave identically to today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chosen Architecture
|
||||||
|
|
||||||
|
### Trigger: config presence, not a mode flag
|
||||||
|
|
||||||
|
The installer looks for a drive labeled `cidata` at startup. If it is there and carries the configurator's output files, those files are copied into `/root` and `./configurator` is skipped — the rest of the install runs the ordinary path against ordinary inputs. If it is not there, nothing changes.
|
||||||
|
|
||||||
|
This is Ryan Hughes' suggestion from PR #72 and it is what keeps the diff small: the install pipeline can be rewritten freely as long as it keeps reading its five files out of `/root`, and autoinstall follows along for free.
|
||||||
|
|
||||||
|
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer all already know how to attach one.
|
||||||
|
|
||||||
|
### What the ISO already does for us
|
||||||
|
|
||||||
|
One thing that would otherwise need building is already true on a stock install: `NetworkManager.service` is enabled and active with DHCP by default, so autoinstall needs no `systemd-networkd` unit — one would contend with NetworkManager.
|
||||||
|
|
||||||
|
Remote access needs three things, all verified against a real install rather than inferred:
|
||||||
|
|
||||||
|
- `authorized_keys` for the user.
|
||||||
|
- `systemctl enable sshd.service`. Omarchy ships `openssh` but leaves the service disabled.
|
||||||
|
- `ufw allow ssh`. `install/config/firewall.sh` opens LocalSend (53317) and docker DNS and nothing else, and ufw runs default-deny incoming.
|
||||||
|
|
||||||
|
Still no loosening of `PasswordAuthentication` — key auth only.
|
||||||
|
|
||||||
|
Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. It claims `sshd.service: enabled` and carries an `allow tcp 22` ufw rule; a real install of this ISO has neither. It is a snapshot of one machine's state, not a contract about stock behavior, and trusting it cost two test cycles.
|
||||||
|
|
||||||
|
### Non-interactivity belongs to the dashboard
|
||||||
|
|
||||||
|
`omarchy-install-dashboard` owns every prompt that survives to the end of an install: the reboot confirm and, on failure, the action menu. Both are `gum` calls reading from the TTY, and both hang forever on a headless VM. Autoinstall therefore sets one env var that the dashboard reads, rather than patching prompts out of scripts after the fact — which is what PR #72 did to `finished.sh`, and what everyone correctly disliked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No config generator, validator, or schema doc. The configurator is the source of truth for the format; users copy its output.
|
||||||
|
- No secret management. Whoever builds the `cidata` drive owns the password hash and the public key on it.
|
||||||
|
- No PXE, no network-fetched config, no cloud-init compatibility beyond reusing the drive label.
|
||||||
|
- No changes to disk selection, encryption, or any install phase behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo Implementation Plan
|
||||||
|
|
||||||
|
## 1) Detect the cidata drive and skip the configurator
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `configs/airootfs/usr/local/bin/omarchy-cidata-load`
|
||||||
|
- `configs/airootfs/root/.automated_script.sh`
|
||||||
|
- `configs/profiledef.sh`
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Add `omarchy-cidata-load` alongside the other install commands in `/usr/local/bin` (with the matching `file_permissions` entry in `profiledef.sh`). It exits 0 when an autoinstall drive was loaded:
|
||||||
|
- `udevadm settle` first: the by-label symlinks come from udev, and probing before enumeration finishes would silently drop a headless install into the wizard.
|
||||||
|
- Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists.
|
||||||
|
- Mount read-only on a temp mountpoint; exit non-zero if the mount fails.
|
||||||
|
- Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero.
|
||||||
|
- Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `authorized_keys` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error.
|
||||||
|
- Unmount before exiting. Leave nothing mounted for the install to trip over.
|
||||||
|
- The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing.
|
||||||
|
- Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`.
|
||||||
|
- Pass `--authorized-keys-file /root/authorized_keys` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies.
|
||||||
|
- `omarchy-cidata-load` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
- ISO with no `cidata` drive attached: configurator runs, byte-identical UX to today.
|
||||||
|
- ISO with a `cidata` drive carrying the five files: configurator never appears; install starts within seconds of boot.
|
||||||
|
- ISO with a drive labeled `cidata` that is empty or carries only one of the two required files: falls back to the configurator rather than failing the boot.
|
||||||
|
- Nothing remains mounted from `/dev/disk/by-label/cidata` once the install begins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Non-interactive dashboard
|
||||||
|
|
||||||
|
### File
|
||||||
|
- `configs/airootfs/usr/local/bin/omarchy-install-dashboard`
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Read `OMARCHY_UI_INTERACTIVE` once near the other env reads at the top.
|
||||||
|
- In `reboot_prompt()` (`:474`): when non-interactive, return 0 immediately instead of calling `gum confirm`. The existing `OMARCHY_UI_AUTO_REBOOT=no` check at `:737` stays in front of the actual `reboot`, so a debug run can set both and stop on the finish screen without a keyboard.
|
||||||
|
- In `failure_menu()` (`:597`): treat non-interactive as equivalent to the existing `OMARCHY_UI_FAILURE_ACTION=exit` early return (`:600`), so a failed autoinstall exits with the installer's status instead of blocking on `gum choose` forever. The failure screen and log tail still render, and the serial console still captures them.
|
||||||
|
|
||||||
|
### Why here and not in the caller
|
||||||
|
The dashboard is the only process that owns the visible UI. Suppressing its prompts anywhere else means editing scripts it invokes — the `finished.sh` `sed` from PR #72 — which breaks the moment those scripts move.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
- Autoinstall run reaches the finish screen and reboots with no input.
|
||||||
|
- Autoinstall run whose install fails renders the failure screen, exits non-zero, and does not hang.
|
||||||
|
- Interactive run still prompts for reboot and still offers the failure menu.
|
||||||
|
- `OMARCHY_UI_INTERACTIVE=no` plus `OMARCHY_UI_AUTO_REBOOT=no` stops at the finish screen without prompting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) SSH keys as an orchestrator phase
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `configs/airootfs/usr/local/bin/omarchy-iso-install`
|
||||||
|
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py`
|
||||||
|
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py`
|
||||||
|
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py`
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- `omarchy-iso-install`: add `--authorized-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`).
|
||||||
|
- `context.py`: add an `authorized_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist.
|
||||||
|
- `phases_impl.py`: add `configure_ssh_access(ctx)`:
|
||||||
|
- Return immediately when `ctx.authorized_keys_path` is `None`.
|
||||||
|
- Read the file as sshd's own authorized_keys format, dropping blank lines and # comments; write the keys one per line to `<target>/home/<user>/.ssh/authorized_keys`.
|
||||||
|
- `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot <target> chown -R <user>:<user>`. Do not hardcode uid 1000 as PR #72 did — ask the target for it.
|
||||||
|
- `arch-chroot <target> systemctl enable sshd.service`. Works cleanly in the chroot.
|
||||||
|
- `arch-chroot <target> ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing.
|
||||||
|
- An `authorized_keys` with no usable keys must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen.
|
||||||
|
- `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`.
|
||||||
|
|
||||||
|
### Model to follow
|
||||||
|
`configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
- Install with `authorized_keys`: `ssh <user>@<vm>` works with the corresponding private key on first boot, no password.
|
||||||
|
- Install without `authorized_keys`: phase runs, does nothing, install unaffected; `~/.ssh` is not created.
|
||||||
|
- `authorized_keys` is `0600` and owned by the install user, not root.
|
||||||
|
- Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it.
|
||||||
|
- Password SSH auth is left at the distro default — autoinstall does not enable it.
|
||||||
|
- Interactive installs are unaffected: the flag is passed but the file never exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Documentation
|
||||||
|
|
||||||
|
### File
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward.
|
||||||
|
- Show `openssl passwd -6` for the credential hash and the `authorized_keys` format.
|
||||||
|
- State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote.
|
||||||
|
- Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Validation
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
`./test/all` runs everything under `test/`: `cidata-load-test.sh` exercises `omarchy-cidata-load` against a sandbox with `mount`/`umount`/`udevadm` stubbed, and `test_configure_ssh_access.py` runs the orchestrator phase against a temp target with `subprocess.run` recorded and ufw's write-the-rule-then-fail chroot behavior simulated. The Python tests run through `unittest` discovery alongside the repo's existing `test_*.py` suites.
|
||||||
|
|
||||||
|
### Manual matrix
|
||||||
|
1. Build the ISO from this branch.
|
||||||
|
2. Interactive control run: boot with no `cidata` drive, install normally, confirm nothing changed.
|
||||||
|
3. Autoinstall run on Proxmox: attach ISO + `cidata` drive, boot, no input; confirm the configurator never renders, the install completes, the machine reboots on its own, and SSH with the key works against the booted system.
|
||||||
|
4. Encrypted autoinstall: `user_encrypt_installation.txt` set true; confirm the LUKS prompt appears on boot (this is the one autoinstall case that still needs a human at first boot — document it).
|
||||||
|
5. Full Packer cycle: build, destroy, rebuild from the same `cidata` drive; confirm reproducibility.
|
||||||
|
|
||||||
|
### Negative tests
|
||||||
|
- Drive labeled `cidata` with no files → configurator runs.
|
||||||
|
- Drive with `user_configuration.json` only → configurator runs.
|
||||||
|
- An `authorized_keys` with no usable keys → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine.
|
||||||
|
- Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang.
|
||||||
|
|
||||||
|
### Regression surface
|
||||||
|
The interactive path shares every line of this change except the `omarchy-cidata-load` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship to PR #72
|
||||||
|
|
||||||
|
PR #72 targeted the pre-`quattro` tree and cannot be rebased: `use_omarchy_helpers`, `run_configurator`, `install_arch`, `install_omarchy`, and `chroot_bash` no longer exist. This plan reimplements the same feature against the current tree and drops three things that PR deliberately flagged as questionable:
|
||||||
|
|
||||||
|
- The `finished.sh` `sed` — replaced by section 2. The dashboard renders the reboot prompt now.
|
||||||
|
- The `systemd-networkd` DHCP unit — unnecessary; NetworkManager is already enabled and would contend with it.
|
||||||
|
- The `PasswordAuthentication yes` rewrite — unnecessary and a security downgrade.
|
||||||
|
|
||||||
|
`load_cidata` is the one piece that carries over close to intact. PR #72's `ufw allow ssh` also carries over: it was dropped from the first draft of this plan on the reasoning that an enabled sshd is a reachable sshd, and a test install proved otherwise. That PR was right to include it.
|
||||||
+127
-188
@@ -1,224 +1,163 @@
|
|||||||
# OEM Install + First-Boot Setup Plan
|
# OEM Install + Factory Reset Plan
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Create a second ISO flavor for OEM preloading that is installed to internal storage at factory time, then boots into customer setup on first power-on (no external media required), and ends in a fully encrypted Omarchy install.
|
An install mode that installs the entire system but defers user account setup to
|
||||||
|
first boot. On first power-on the machine asks for the user, creates it, and
|
||||||
|
lands in the already-installed system — the classic OEM out-of-box experience.
|
||||||
|
The same machinery doubles as a "reset computer" option for wiping a machine
|
||||||
|
before selling it.
|
||||||
|
|
||||||
## Product Requirements
|
## Core idea: "OEM state" as a first-class system state
|
||||||
|
|
||||||
- Factory can preload one image to internal disk.
|
Define one canonical state — *fully installed, no user, setup pending* — and
|
||||||
- Customer first boot launches setup flow automatically (already works: `.automated_script.sh` runs configurator on tty1).
|
make both paths produce it:
|
||||||
- Customer chooses credentials/settings.
|
|
||||||
- Final installed OS uses full-disk encryption (LUKS) with customer password (already works: configurator collects password and passes it to archinstall as `encryption_password`).
|
|
||||||
- Existing consumer ISO behavior remains unchanged.
|
|
||||||
|
|
||||||
---
|
- **ISO OEM mode** ends the install in OEM state instead of creating the user.
|
||||||
|
- **Reset** returns a running machine to OEM state.
|
||||||
|
|
||||||
## Chosen Architecture
|
OEM state is: a fully installed system plus `/var/lib/omarchy/oem/` containing:
|
||||||
|
|
||||||
### Bootstrapping model
|
| File | Purpose |
|
||||||
- OEM devices ship with an OEM bootstrap ISO image written to internal disk.
|
|------|---------|
|
||||||
- On first boot, `copytoram=y` kernel arg loads the live environment into RAM, freeing the internal disk.
|
| `pending` | Flag that arms the first-boot setup service |
|
||||||
- Bootstrap environment launches existing configurator + installer.
|
| `packages/node-v*.tar.gz` | Node tarball copied off the ISO's `/opt/packages` (~30MB) so `omarchy-finalize-user` works offline at first boot |
|
||||||
- Installer performs a fresh install to internal disk with LUKS encryption enabled.
|
| `groups` | Deferred `usermod -aG` groups written by system setup (see below) |
|
||||||
- Bootstrap environment is overwritten during install.
|
| `luks-key` | Throwaway LUKS passphrase for encrypted installs, staged for auto-unlock until first-boot re-key |
|
||||||
|
|
||||||
### Recovery model: zero-recovery
|
## Why the seam is nearly clean already
|
||||||
- No permanent recovery partition.
|
|
||||||
- Simplest and strongest security story.
|
|
||||||
- Recovery requires external reinstall media.
|
|
||||||
|
|
||||||
---
|
Everything in the orchestrator through `finalize_limine_boot` is
|
||||||
|
user-independent except five touch points:
|
||||||
|
|
||||||
## Non-Goals
|
1. `installer.create_users` — useradd + password (root gets the same password).
|
||||||
|
2. Three `usermod -aG` calls inside `omarchy-setup-system`'s scripts
|
||||||
|
(`config/docker.sh`, `hardware/input-group.sh`, `hardware/apple/fix-t2.sh` —
|
||||||
|
docker/input/video). This is the *only* reason `omarchy-setup-system`
|
||||||
|
requires the user to exist (its getent check).
|
||||||
|
3. `run_chroot_finalizer` → `omarchy-finalize-user` as the user. Already
|
||||||
|
offline-safe: `/etc/skel` does the heavy seeding at useradd time,
|
||||||
|
`omarchy-mise-install` writes lazy wrappers, and the only external artifact
|
||||||
|
is the bundled Node tarball from `/opt/packages` (`install/user/mise.sh`).
|
||||||
|
4. `configure_login` — SDDM `state.conf` and the encrypted-install autologin.
|
||||||
|
5. Encryption — the user's password *is* the LUKS passphrase.
|
||||||
|
|
||||||
- No on-device recovery workflow.
|
Nothing in hardware setup, Limine, hibernation, snapper, or package
|
||||||
- No changes to normal installer UX outside explicit OEM mode checks and warnings.
|
installation cares who the user is.
|
||||||
- No factory credential provisioning.
|
|
||||||
|
|
||||||
---
|
## First boot: `omarchy-oem-setup.service`
|
||||||
|
|
||||||
## Repo Implementation Plan
|
Ships in the omarchy runtime package (so reset can arm it without the ISO):
|
||||||
|
|
||||||
## 1) Add OEM build mode
|
- `ConditionPathExists=/var/lib/omarchy/oem/pending`
|
||||||
|
- `Before=display-manager.service`, conflicts `getty@tty1`, TUI on tty1.
|
||||||
|
- Runs the user form extracted from the configurator (Step 2: username,
|
||||||
|
password, full name, email). The configurator is bash+gum, so the form moves
|
||||||
|
into a runtime-shipped `omarchy-oem-setup` script that reuses the exact ISO
|
||||||
|
look (logo, Tokyo Night VT palette).
|
||||||
|
|
||||||
### Files
|
Then it does what the ISO would have done:
|
||||||
- `bin/omarchy-iso-make`
|
|
||||||
- `builder/build-iso.sh`
|
|
||||||
|
|
||||||
### Changes
|
1. `useradd` with wheel + the groups from `/var/lib/omarchy/oem/groups`,
|
||||||
- Add CLI flag `--oem` to `bin/omarchy-iso-make`.
|
set user + root password.
|
||||||
- Pass `OMARCHY_MODE=oem` into Docker build env (default `normal`).
|
2. `omarchy-finalize-user` as the user, with a setup context that points at the
|
||||||
- In `builder/build-iso.sh`, write mode marker `/root/omarchy_mode` containing `oem` or `normal` (same pattern as `omarchy_mirror`).
|
stashed Node tarball (a sibling of the existing `iso-chroot` context).
|
||||||
- Keep OEM on current boot stack (`uefi.grub` + `bios.syslinux`); do not add or depend on systemd-boot paths.
|
3. The `configure_login` writes: SDDM `state.conf`, autologin for encrypted
|
||||||
- When `OMARCHY_MODE=oem`, inject `copytoram=y` into all shipped OEM boot entry points before `mkarchiso` runs:
|
installs.
|
||||||
- `grub/grub.cfg`: append to `linux` cmdline entries.
|
4. If `luks-key` exists: `cryptsetup luksChangeKey` to the user's password,
|
||||||
- `grub/loopback.cfg`: append to `linux` cmdline entries.
|
delete the stash, remove the auto-unlock keyfile, rebuild initramfs.
|
||||||
- `syslinux/archiso_sys-linux.cfg`: append to `APPEND` lines.
|
5. Git identity from the form (the `OMARCHY_USER_NAME`/`OMARCHY_USER_EMAIL`
|
||||||
- When `OMARCHY_MODE=oem`, remove accessibility/speakup boot entries:
|
path in `install/user/git.sh`).
|
||||||
- `grub/grub.cfg`: remove `archlinux-accessibility` menuentry.
|
6. Remove `/var/lib/omarchy/oem/`, hand off to SDDM.
|
||||||
- `grub/loopback.cfg`: remove `archlinux-accessibility` menuentry.
|
|
||||||
- `syslinux/archiso_sys-linux.cfg`: remove `arch64speech` label block.
|
|
||||||
- When `OMARCHY_MODE=oem`, override `iso_application` in `profiledef.sh` to `"Omarchy OEM Installer"`.
|
|
||||||
- Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_ISO_REF` suffix).
|
|
||||||
|
|
||||||
### Hardening
|
The user lands in their session exactly like a normal install.
|
||||||
- Add post-injection build assertion that fails build if any expected boot config does not contain `copytoram=y`.
|
|
||||||
- Add post-injection build assertion that fails build if any OEM boot config still contains accessibility/speakup entries.
|
|
||||||
- Make injection idempotent (do not duplicate `copytoram=y` if already present).
|
|
||||||
|
|
||||||
### Acceptance criteria
|
## Encryption: keep it, Pop!_OS-style
|
||||||
- `./bin/omarchy-iso-make --oem` builds successfully.
|
|
||||||
- OEM ISO boot entries contain `copytoram=y` in all supported boot paths.
|
|
||||||
- OEM ISO does not expose accessibility/speakup boot entries.
|
|
||||||
- Normal build output and behavior remain unchanged.
|
|
||||||
|
|
||||||
### Suggested verification commands
|
Do not force OEM installs unencrypted. Install encrypted with a generated
|
||||||
- `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg copytoram=y`
|
throwaway passphrase, stage a keyfile so boot auto-unlocks during the OEM
|
||||||
- `bsdtar -xOf out/<iso>.iso boot/grub/loopback.cfg | rg copytoram=y`
|
window, and have first-boot setup re-key to the user's chosen password
|
||||||
- `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg copytoram=y`
|
(step 4 above). Limine and mkinitcpio don't care what the passphrase is. This
|
||||||
- `bsdtar -xOf out/<iso>.iso boot/grub/grub.cfg | rg 'accessibility|speakup'` (should return no matches)
|
preserves encrypted-by-default without the machine builder ever knowing the end
|
||||||
- `bsdtar -xOf out/<iso>.iso boot/syslinux/archiso_sys-linux.cfg | rg 'speech|accessibility'` (should return no matches)
|
user's password.
|
||||||
|
|
||||||
---
|
## ISO-side changes (this repo)
|
||||||
|
|
||||||
## 2) Add OEM safety guardrails
|
- **Configurator**: an OEM choice in the install-mode step that skips Step 2
|
||||||
|
(user), plus a cidata `oem` marker file that replaces the
|
||||||
|
`user_credentials.json` requirement — that's what actual OEM imaging rigs
|
||||||
|
will use via autoinstall.
|
||||||
|
- **Orchestrator**: `oem: true` in the `omarchy_install` JSON. Swaps the three
|
||||||
|
user phases (`create_users` inside `arch_install_system`,
|
||||||
|
`run_chroot_finalizer`, the user parts of `configure_login`) for a
|
||||||
|
"stage OEM state" phase that writes `/var/lib/omarchy/oem/` and enables
|
||||||
|
`omarchy-oem-setup.service` in the target. `InstallContext.username` needs an
|
||||||
|
OEM-safe fallback (creds are absent).
|
||||||
|
- **Factory snapshot**: at the end of *every* install (not just OEM), take a
|
||||||
|
read-only snapshot of `@` kept at the 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. This is what makes
|
||||||
|
reset a true factory reset.
|
||||||
|
|
||||||
### Why
|
## Runtime-side changes (omarchy repo)
|
||||||
OEM image boots from internal disk and later wipes that same disk. Live environment must be fully in RAM before install begins.
|
|
||||||
|
|
||||||
### File
|
- `omarchy-oem-setup` + `omarchy-oem-setup.service` (the OOBE above).
|
||||||
- `configs/airootfs/root/.automated_script.sh`
|
- `omarchy-system-factory-reset` (below).
|
||||||
|
- Relax `omarchy-setup-system`: when the install user doesn't exist yet, the
|
||||||
|
three `usermod -aG` scripts append to `/var/lib/omarchy/oem/groups` instead
|
||||||
|
of failing the getent check.
|
||||||
|
|
||||||
### Changes
|
## Reset: `omarchy-system-factory-reset`
|
||||||
- Before `run_configurator`, if `/root/omarchy_mode` is `oem`:
|
|
||||||
- Verify `/proc/cmdline` contains `copytoram=y`; abort with clear actionable error if missing.
|
|
||||||
- Verify factory hardware minimum using `MemTotal`:
|
|
||||||
- Require `MemTotal >= 8 GiB`.
|
|
||||||
- Abort with clear error if below minimum.
|
|
||||||
- Verify runtime memory safety using `MemAvailable`:
|
|
||||||
- Minimum threshold: `1.5 x ISO size + 1 GiB`.
|
|
||||||
- Abort with clear error if below threshold.
|
|
||||||
- Verify runtime state indicates live root is in RAM (for example, expected archiso runtime mounts present and boot media not actively mounted as install target path).
|
|
||||||
|
|
||||||
### Acceptance criteria
|
Behind a very explicit confirmation (typed, not y/n):
|
||||||
- OEM mode aborts safely if `copytoram=y` missing from cmdline.
|
|
||||||
- OEM mode aborts safely if `MemTotal < 8 GiB`.
|
|
||||||
- OEM mode aborts safely if `MemAvailable` below threshold.
|
|
||||||
- OEM mode aborts safely if runtime state does not confirm RAM-backed live environment.
|
|
||||||
|
|
||||||
### Error message requirements
|
1. Point the default subvolume at a writable snapshot of `@factory`; stage a
|
||||||
- State exactly what failed.
|
next-boot wipe unit.
|
||||||
- State why install is blocked.
|
2. On reboot: recreate `@home` (subvolume delete/create — instant, no rm -rf),
|
||||||
- Provide next step (reboot using proper OEM media or use external recovery media).
|
clear `@log`, delete snapper snapshots, reset machine-id, SSH host keys,
|
||||||
|
NetworkManager connections, Tailscale state, SDDM state; run `fstrim`.
|
||||||
|
3. Re-arm OEM state: `pending` flag, `groups`, Node tarball (kept from install
|
||||||
|
or re-fetched into `@factory` at install time), and on encrypted machines a
|
||||||
|
throwaway LUKS re-key — the buyer never needs the seller's passphrase.
|
||||||
|
|
||||||
---
|
Next boot is the same OOBE as a fresh OEM install. User-installed packages and
|
||||||
|
/etc drift are gone, which is what "wipe and sell" actually promises.
|
||||||
|
|
||||||
## 3) OEM disk warning
|
Machines installed before `@factory` existed get a degraded reset — keep the
|
||||||
|
current system, wipe users/state only — with that caveat surfaced in the
|
||||||
|
confirmation.
|
||||||
|
|
||||||
### Why
|
### Honest limitations (document these)
|
||||||
With `copytoram=y`, archiso copies squashfs to RAM and releases boot device. Existing configurator call `findmnt -no SOURCE /run/archiso/bootmnt` may return empty, so exclusion filter is skipped and all physical disks are shown, including former boot disk. No disk selection logic change is required.
|
|
||||||
|
|
||||||
OEM mode still needs an unmistakable warning before destructive install proceeds.
|
- On unencrypted disks this is deletion, not forensic erasure. `fstrim` helps
|
||||||
|
on SSDs; offer `cryptsetup reencrypt` or `blkdiscard` + reinstall for the
|
||||||
|
paranoid.
|
||||||
|
- `luksChangeKey` changes the passphrase but not the volume key; freed extents
|
||||||
|
remain readable to someone with raw-device access and the new passphrase.
|
||||||
|
Same remedy as above.
|
||||||
|
- A reset machine boots with day-one packages until it updates — same as a
|
||||||
|
fresh install.
|
||||||
|
|
||||||
### File
|
## Sequencing
|
||||||
- `configs/airootfs/root/configurator`
|
|
||||||
|
|
||||||
### Changes
|
The OOBE lives in the omarchy runtime package, but the ISO's OEM phase depends
|
||||||
- Read mode from `/root/omarchy_mode`.
|
on it existing. So:
|
||||||
- If mode is `oem`, add OEM-specific warning text before existing destructive confirmation.
|
|
||||||
- Upgrade confirmation in OEM mode to explicit typed confirmation (for example: `YES, WIPE DISK`) while preserving existing normal-mode flow.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
1. Ship the runtime side first (`omarchy-oem-setup`, service, reset command,
|
||||||
- Normal ISO behavior is unchanged.
|
`omarchy-setup-system` relaxation).
|
||||||
- OEM ISO shows OEM-specific warning before disk wipe confirmation.
|
2. ISO OEM mode then asserts the service file exists in the target before
|
||||||
- OEM typed confirmation is required before destructive action.
|
offering the mode.
|
||||||
- In VM with `copytoram=y`, former boot disk appears in disk list without exclusion-logic changes.
|
3. Factory snapshot can land with either half; reset requires both.
|
||||||
|
|
||||||
---
|
## Validation
|
||||||
|
|
||||||
## 4) Documentation
|
- VM: OEM install (encrypted + unencrypted) → reboot → OOBE → session; verify
|
||||||
|
LUKS passphrase is the user's, throwaway key gone, groups applied,
|
||||||
### File
|
`omarchy-finalize-user` state marker present.
|
||||||
- `README.md`
|
- VM: normal install → use system → `omarchy-system-factory-reset` → OOBE as new
|
||||||
|
user; verify no trace of prior user (home, NM connections, tailscale,
|
||||||
### Changes
|
machine-id, host keys, snapper snapshots).
|
||||||
- Document `--oem` build flag and resulting artifact naming.
|
- Autoinstall: cidata drive with `oem` marker and no credentials → unattended
|
||||||
- Document factory workflow: writing OEM ISO to internal disk and expected first-boot UX.
|
OEM install.
|
||||||
- Document failure and recovery behavior:
|
- Negative: OEM ISO built against a runtime package lacking the service fails
|
||||||
- Missing `copytoram=y`, `MemTotal < 8 GiB`, or low `MemAvailable` causes safe abort.
|
the install with a clear error, not a user-less boot loop.
|
||||||
- Interrupted/failed install requires external reinstall media.
|
|
||||||
- Add short operator checklist for manufacturing line validation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Validation
|
|
||||||
|
|
||||||
### VM test matrix
|
|
||||||
1. Build OEM ISO.
|
|
||||||
2. Write OEM ISO to virtual disk (not external media emulation).
|
|
||||||
3. Boot from virtual disk and verify:
|
|
||||||
- `/proc/cmdline` contains `copytoram=y`.
|
|
||||||
- Guardrail checks pass in healthy case.
|
|
||||||
- OEM boot menu does not include accessibility/speech entries.
|
|
||||||
4. Complete setup flow on first boot.
|
|
||||||
5. Install to same disk.
|
|
||||||
6. Reboot into installed OS.
|
|
||||||
7. Verify encryption:
|
|
||||||
- `lsblk` shows crypt mapping.
|
|
||||||
- `cryptsetup status` reports active LUKS device.
|
|
||||||
- Boot requires unlock passphrase.
|
|
||||||
8. Validate OEM warning + typed confirmation appears and gates destructive action.
|
|
||||||
|
|
||||||
### Boot path coverage
|
|
||||||
- UEFI + GRUB path.
|
|
||||||
- BIOS/syslinux path (if still shipped).
|
|
||||||
|
|
||||||
### Hardware test matrix
|
|
||||||
- UEFI test on at least two hardware families (Intel + AMD).
|
|
||||||
- Confirm no external media required.
|
|
||||||
- Confirm first boot starts setup automatically.
|
|
||||||
|
|
||||||
### Negative tests
|
|
||||||
- OEM boot without `copytoram=y` -> safe abort with clear error.
|
|
||||||
- `MemTotal < 8 GiB` -> safe abort with clear error.
|
|
||||||
- Low `MemAvailable` -> safe abort with clear error.
|
|
||||||
- Runtime state not RAM-backed -> safe abort with clear error.
|
|
||||||
- Power interruption before installer starts -> deterministic reboot behavior.
|
|
||||||
- Power interruption during install -> recovery requires external media (documented).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Security hardening checklist
|
|
||||||
|
|
||||||
- No factory credentials exist; customer sets all passwords at first boot.
|
|
||||||
- Clear shell history and transient setup artifacts at end of install.
|
|
||||||
- Verify no secrets in logs shipped to customer.
|
|
||||||
- Regenerate host identity on first installed boot as needed:
|
|
||||||
- `machine-id`
|
|
||||||
- SSH host keys (if installed/enabled)
|
|
||||||
- Keep package signature verification and keyring handling intact.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollout Plan
|
|
||||||
|
|
||||||
1. Implement OEM build flag, mode marker, boot config injection, and build assertions.
|
|
||||||
2. Implement copytoram, memory, and runtime-state guardrails in `.automated_script.sh`.
|
|
||||||
3. Add OEM warning text and typed destructive confirmation in configurator.
|
|
||||||
4. Add docs and operator checklist.
|
|
||||||
5. Run VM matrix, including negative tests.
|
|
||||||
6. Run hardware matrix.
|
|
||||||
7. Cut first OEM preproduction release for manufacturing pilot.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- `--oem` build exists and is documented.
|
|
||||||
- Device preloaded with OEM image boots directly to setup.
|
|
||||||
- Setup installs to internal disk without external media.
|
|
||||||
- Final installed system is LUKS-encrypted with customer-owned passphrase.
|
|
||||||
- OEM safety guardrails fail closed with actionable errors.
|
|
||||||
- Existing non-OEM ISO behavior remains unchanged.
|
|
||||||
- VM and hardware QA matrix pass.
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Tailscale Autoinstall Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let an autoinstall drive carry a Tailscale auth key so the installed machine
|
||||||
|
joins the tailnet on its own. Combined with `authorized_keys`, this completes
|
||||||
|
the disposable-VM story: create VM, boot, walk away, `ssh user@machine` over
|
||||||
|
the tailnet from anywhere — no LAN discovery, no port forwarding.
|
||||||
|
|
||||||
|
## Product Requirements
|
||||||
|
|
||||||
|
- Same trigger philosophy as the rest of autoinstall: presence of a
|
||||||
|
`tailscale_authkey` file on the `cidata` drive, no new mode flag. No file,
|
||||||
|
no change — byte-identical behavior to today.
|
||||||
|
- The join happens when the network is available, not when the installer runs.
|
||||||
|
- No package installs from the network on first boot.
|
||||||
|
- The auth key does not persist on the installed system after a successful join.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chosen Architecture
|
||||||
|
|
||||||
|
### The package is bundled, not fetched
|
||||||
|
|
||||||
|
The `tailscale` package rides in the offline mirror (~10MB on the ISO) and is
|
||||||
|
installed during the ordinary package phase — but only when the auth key file
|
||||||
|
is present, so a stock install stays stock.
|
||||||
|
|
||||||
|
The alternative — installing from the network on first boot — was rejected
|
||||||
|
outright. A bare `pacman -Sy tailscale` on a fresh machine is a partial
|
||||||
|
upgrade waiting to corrupt something, and the "correct" version, `pacman -Syu
|
||||||
|
tailscale`, means an unattended full system upgrade racing the user's first
|
||||||
|
login (potentially swapping the kernel out on a machine that has booted once).
|
||||||
|
It would also fight Omarchy's own update flow and make the join depend on
|
||||||
|
mirror availability. The offline mirror is a coherent snapshot; installing
|
||||||
|
from it keeps the target consistent and the install deterministic and
|
||||||
|
offline-capable.
|
||||||
|
|
||||||
|
### The join is a first-boot unit, not an install step
|
||||||
|
|
||||||
|
`tailscale up` needs a running tailscaled, and there is no systemd in the
|
||||||
|
chroot. So the installer only stages: the key at `/etc/tailscale/authkey`
|
||||||
|
(root, `0600`), `tailscaled.service` enabled, and an
|
||||||
|
`omarchy-tailscale-join.service` that runs `tailscale up --auth-key
|
||||||
|
file:/etc/tailscale/authkey` on first boot.
|
||||||
|
|
||||||
|
"When the network is available" lives in the unit, not the installer:
|
||||||
|
`After=network-online.target` for ordering, plus an in-unit retry loop
|
||||||
|
(`until tailscale up …; do sleep 15; done`), because network-online can be
|
||||||
|
reached before there is real connectivity.
|
||||||
|
|
||||||
|
The unit is `Type=simple`, not oneshot, and that is load-bearing: target
|
||||||
|
units implicitly gain `After=` for their `Wants=`, so a oneshot wanted by
|
||||||
|
`multi-user.target` holds the whole boot — SDDM included — hostage until the
|
||||||
|
join finishes or times out, and once timed out it never retries within that
|
||||||
|
boot. (Caught by review.) Type=simple counts as started the moment it forks:
|
||||||
|
boot proceeds normally while the join retries in the background for as long
|
||||||
|
as the boot lasts.
|
||||||
|
|
||||||
|
Cleanup is sequenced inside the script, after `tailscale up` succeeds — the
|
||||||
|
key is removed and the unit disables itself, so the key never outlives the
|
||||||
|
join and the unit never runs again. On a boot that never gets connectivity
|
||||||
|
both survive (`ConditionPathExists` gates re-runs on the key), so a machine
|
||||||
|
installed offline joins on the first boot that can.
|
||||||
|
|
||||||
|
The machine appears on the tailnet under the hostname from
|
||||||
|
`user_configuration.json` — nothing extra to configure.
|
||||||
|
|
||||||
|
### Firewall
|
||||||
|
|
||||||
|
`ufw allow in on tailscale0`, the same write-the-rule-then-ignore-the-chroot-
|
||||||
|
exit-code dance as `configure_ssh_access` (ufw cannot reach netfilter in a
|
||||||
|
chroot but records the rule in `user.rules`, which is what first boot loads).
|
||||||
|
Without it the node joins and is then unreachable over the tailnet — worse
|
||||||
|
than not joining.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
Same stance the autoinstall plan takes for the LUKS passphrase: whoever
|
||||||
|
builds the `cidata` drive owns the key on it. Docs recommend a reusable,
|
||||||
|
pre-authorized (tagged) key so one drive image serves N machines, and note
|
||||||
|
that ephemeral keys fit disposable VMs. No validation of the key beyond
|
||||||
|
"exactly one non-comment line" — Tailscale key formats are theirs to change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `builder/archinstall.packages`: add `tailscale` so it lands in the offline
|
||||||
|
mirror on every build.
|
||||||
|
- `omarchy-cidata-load`: add `tailscale_authkey` to the optional copy list.
|
||||||
|
- `.automated_script.sh`: pass `--tailscale-authkey-file /root/tailscale_authkey`
|
||||||
|
(always; the orchestrator no-ops when the file is absent, like
|
||||||
|
`--authorized-keys-file`).
|
||||||
|
- `omarchy-iso-install`: parse the flag into
|
||||||
|
`OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE`.
|
||||||
|
- `context.py`: `tailscale_authkey_path: Path | None` via `_optional_path`.
|
||||||
|
- `phases_impl.py`:
|
||||||
|
- `arch_install_system`: when the key is present, add `tailscale` to the
|
||||||
|
additional packages while the offline mirror is still bind-mounted.
|
||||||
|
- `configure_tailscale(ctx)`: no-op when the path is `None`; otherwise
|
||||||
|
stage key + unit, enable services, open ufw. Fails loudly on an empty or
|
||||||
|
ambiguous key file and when the tailscale binary is missing from the
|
||||||
|
target (an ISO built before the package was bundled).
|
||||||
|
- `main.py`: register `("Configuring Tailscale", configure_tailscale)` after
|
||||||
|
`configure_ssh_access`.
|
||||||
|
- `README.md`: file-table row + a Tailscale paragraph in the Autoinstall
|
||||||
|
section.
|
||||||
|
- Tests: `tailscale_authkey` cases in `cidata-load-test.sh`;
|
||||||
|
`test_configure_tailscale.py` mirroring the SSH phase tests.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Drive with `tailscale_authkey`: machine appears on the tailnet under its
|
||||||
|
configured hostname after first boot with network; `/etc/tailscale/authkey`
|
||||||
|
is gone; `omarchy-tailscale-join.service` is disabled.
|
||||||
|
- First boot without network: no failure surfaced to the user; the join
|
||||||
|
happens on the first boot that has connectivity.
|
||||||
|
- Drive without the file: no tailscale package on the target, no unit, no
|
||||||
|
ufw rule — indistinguishable from today.
|
||||||
|
- Empty or multi-line key file: install stops in the Tailscale phase with a
|
||||||
|
visible error.
|
||||||
|
- Interactive installs: unaffected; the flag is passed but the file never
|
||||||
|
exists.
|
||||||
Whitespace-only changes.
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
|
||||||
|
for test in "$ROOT"/test/*-test.sh; do
|
||||||
|
printf '==> %s\n' "${test#$ROOT/}"
|
||||||
|
bash "$test"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '==> test/test_*.py\n'
|
||||||
|
python3 -m unittest discover -t "$ROOT" -s "$ROOT/test"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Unit tests for omarchy-cidata-load. The script takes a path prefix, so every
|
||||||
|
# case runs against a throwaway sandbox with mount/umount/udevadm stubbed out:
|
||||||
|
# "mounting" copies the fake drive's contents into the mountpoint, and every
|
||||||
|
# stub logs its invocation so the cases can assert what was (not) called.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
printf 'ok - %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
local description="$1"
|
||||||
|
local detail="${2:-}"
|
||||||
|
|
||||||
|
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
|
||||||
|
printf 'not ok - %s\n' "$description" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'chmod -R u+w "$work"; rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
stub_dir="$work/stubs"
|
||||||
|
mkdir -p "$stub_dir"
|
||||||
|
|
||||||
|
cat >"$stub_dir/udevadm" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'udevadm %s\n' "$*" >>"$TEST_LOG"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$stub_dir/mount" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'mount %s\n' "$*" >>"$TEST_LOG"
|
||||||
|
[[ ${MOUNT_FAIL:-} == 1 ]] && exit 32
|
||||||
|
device=$3 mountpoint=$4
|
||||||
|
cp -a "$(readlink -f "$device")"/. "$mountpoint"/
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$stub_dir/umount" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'umount %s\n' "$*" >>"$TEST_LOG"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
chmod +x "$stub_dir"/*
|
||||||
|
|
||||||
|
new_sandbox() {
|
||||||
|
sandbox=$(mktemp -d "$work/sandbox.XXXXXX")
|
||||||
|
mkdir -p "$sandbox/dev/disk/by-label" "$sandbox/root" "$sandbox/media"
|
||||||
|
export TEST_LOG="$sandbox/calls.log"
|
||||||
|
: >"$TEST_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
attach_drive() {
|
||||||
|
ln -s "$sandbox/media" "$sandbox/dev/disk/by-label/$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_load() {
|
||||||
|
PATH="$stub_dir:$PATH" "$CIDATA_LOAD" "$sandbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_required_pair() {
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
|
||||||
|
echo '{"users": []}' >"$sandbox/media/user_credentials.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# No cidata drive attached: fall back to the wizard without mounting anything.
|
||||||
|
new_sandbox
|
||||||
|
! run_load || fail "no drive exits non-zero"
|
||||||
|
! grep -q '^mount ' "$TEST_LOG" || fail "no drive mounts nothing"
|
||||||
|
pass "no drive falls back to the wizard"
|
||||||
|
|
||||||
|
# The probe must wait for udev to finish enumerating before concluding there
|
||||||
|
# is no drive.
|
||||||
|
grep -q '^udevadm settle$' "$TEST_LOG" || fail "probe settles udev first"
|
||||||
|
pass "probe settles udev first"
|
||||||
|
|
||||||
|
# A drive with the full file set: everything lands in /root and the drive is
|
||||||
|
# unmounted afterwards.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
echo "Jeff" >"$sandbox/media/user_full_name.txt"
|
||||||
|
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
|
||||||
|
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
|
||||||
|
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
||||||
|
echo 'tskey-auth-kFAKEKEY' >"$sandbox/media/tailscale_authkey"
|
||||||
|
run_load || fail "full file set loads"
|
||||||
|
for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
|
||||||
|
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
|
||||||
|
done
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
|
||||||
|
pass "full file set loads, copies everything, and unmounts"
|
||||||
|
|
||||||
|
# The uppercase label variant some tools produce works too.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive CIDATA
|
||||||
|
write_required_pair
|
||||||
|
run_load || fail "uppercase CIDATA label loads"
|
||||||
|
pass "uppercase CIDATA label loads"
|
||||||
|
|
||||||
|
# The required pair alone is a valid autoinstall drive; the optional files
|
||||||
|
# stay optional.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
run_load || fail "required pair alone loads"
|
||||||
|
[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files"
|
||||||
|
pass "required pair alone loads without optional files"
|
||||||
|
|
||||||
|
# Optional files are copied individually when present.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
||||||
|
run_load || fail "required pair plus authorized_keys loads"
|
||||||
|
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
|
||||||
|
[[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied"
|
||||||
|
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
|
||||||
|
pass "present optional files are copied, absent ones skipped"
|
||||||
|
|
||||||
|
# A defer-provisioning marker replaces user_credentials.json: deferred-provisioning installs
|
||||||
|
# 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/defer-provisioning"
|
||||||
|
run_load || fail "config plus defer-provisioning marker loads"
|
||||||
|
[[ -f $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker is copied"
|
||||||
|
[[ ! -e $sandbox/root/user_credentials.json ]] || fail "defer-provisioning drive copies no credentials"
|
||||||
|
pass "defer-provisioning marker stands in for credentials"
|
||||||
|
|
||||||
|
# The defer-provisioning 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/defer-provisioning"
|
||||||
|
run_load || fail "defer-provisioning marker plus credentials loads"
|
||||||
|
[[ -f $sandbox/root/defer-provisioning && -f $sandbox/root/user_credentials.json ]] || fail "both defer-provisioning marker and credentials are copied"
|
||||||
|
pass "defer-provisioning marker plus credentials copies both"
|
||||||
|
|
||||||
|
# An defer-provisioning marker without the configuration is still not an autoinstall drive.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
: >"$sandbox/media/defer-provisioning"
|
||||||
|
! run_load || fail "defer-provisioning marker alone is not an autoinstall drive"
|
||||||
|
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker alone copies nothing"
|
||||||
|
pass "defer-provisioning marker alone falls back to the wizard"
|
||||||
|
|
||||||
|
# Stale deferred-provisioning inputs from a previous load in the same session are cleared before
|
||||||
|
# the current drive is copied: a normal drive must not inherit an old defer-provisioning
|
||||||
|
# marker or credentials.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
: >"$sandbox/root/defer-provisioning" # leftover from a prior deferred-provisioning load
|
||||||
|
echo 'old-keys' >"$sandbox/root/authorized_keys"
|
||||||
|
run_load || fail "normal drive after a stale defer-provisioning load loads"
|
||||||
|
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker is cleared"
|
||||||
|
[[ ! -e $sandbox/root/authorized_keys ]] || fail "stale optional inputs are cleared"
|
||||||
|
pass "stale deferred-provisioning inputs are cleared before loading a normal drive"
|
||||||
|
|
||||||
|
# A drive that isn't an autoinstall drive at all still clears stale inputs so
|
||||||
|
# the wizard that follows doesn't inherit them.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" # half a pair
|
||||||
|
: >"$sandbox/root/defer-provisioning"
|
||||||
|
! run_load || fail "half-pair drive still falls back"
|
||||||
|
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker cleared even on fallback"
|
||||||
|
pass "stale inputs are cleared even when falling back to the wizard"
|
||||||
|
|
||||||
|
# Half the required pair is not an autoinstall drive: unmount and fall back.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
|
||||||
|
! run_load || fail "half the required pair exits non-zero"
|
||||||
|
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "half the required pair copies nothing"
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "half the required pair still unmounts"
|
||||||
|
pass "half the required pair falls back and unmounts"
|
||||||
|
|
||||||
|
# An empty drive labeled cidata is not an autoinstall drive either.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
! run_load || fail "empty drive exits non-zero"
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "empty drive still unmounts"
|
||||||
|
pass "empty drive falls back and unmounts"
|
||||||
|
|
||||||
|
# A drive that will not mount falls back rather than failing the boot.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
! MOUNT_FAIL=1 run_load || fail "mount failure exits non-zero"
|
||||||
|
! grep -q '^umount ' "$TEST_LOG" || fail "mount failure has nothing to unmount"
|
||||||
|
pass "mount failure falls back to the wizard"
|
||||||
|
|
||||||
|
# A copy failure must not report a loaded drive: the install would start with
|
||||||
|
# missing inputs. Fall back and let the wizard produce them instead.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
chmod 555 "$sandbox/root"
|
||||||
|
! run_load 2>/dev/null || fail "copy failure exits non-zero"
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "copy failure still unmounts"
|
||||||
|
pass "copy failure falls back and unmounts"
|
||||||
|
|
||||||
|
# A partial copy must clean up after itself: the wizard removes only what it
|
||||||
|
# writes, so anything the loader left behind would leak into the interactive
|
||||||
|
# install that follows.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
||||||
|
mkdir "$sandbox/root/user_credentials.json"
|
||||||
|
! run_load 2>/dev/null || fail "partial copy exits non-zero"
|
||||||
|
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied"
|
||||||
|
[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind"
|
||||||
|
pass "partial copy cleans up what it copied"
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Unit tests for the orchestrator's configure_ssh_access phase.
|
||||||
|
|
||||||
|
The phase touches the target only through the filesystem and arch-chroot, so
|
||||||
|
the tests run it against a temp directory with subprocess.run recorded and the
|
||||||
|
ufw side effect (writing user.rules) simulated by the fake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import stat
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# phases_impl imports the archinstall adapter at module scope, which pulls in
|
||||||
|
# the archinstall library that only exists on the live ISO. configure_ssh_access
|
||||||
|
# never touches it, so stub the adapter out before the import.
|
||||||
|
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
|
||||||
|
|
||||||
|
from orchestrator import phases_impl # noqa: E402
|
||||||
|
|
||||||
|
UFW_RULE = "-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureSshAccessTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
self.ufw_writes_rule = True
|
||||||
|
|
||||||
|
def fake_run(self, cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if cmd[2] == "ufw":
|
||||||
|
if self.ufw_writes_rule:
|
||||||
|
rules = self.target / "etc" / "ufw" / "user.rules"
|
||||||
|
rules.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rules.write_text(UFW_RULE)
|
||||||
|
# ufw cannot reach netfilter inside a chroot and exits non-zero
|
||||||
|
# even when it recorded the rule; the phase must not trust this.
|
||||||
|
return CompletedProcess(cmd, 1)
|
||||||
|
return CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
def ctx(self, authorized_keys=None):
|
||||||
|
authorized_keys_path = None
|
||||||
|
if authorized_keys is not None:
|
||||||
|
authorized_keys_path = self.target / "authorized_keys"
|
||||||
|
authorized_keys_path.write_text(authorized_keys)
|
||||||
|
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False)
|
||||||
|
|
||||||
|
def configure(self, **kwargs):
|
||||||
|
phases_impl.configure_ssh_access(self.ctx(**kwargs))
|
||||||
|
|
||||||
|
def authorized_keys(self):
|
||||||
|
return self.target / "home" / "jeff" / ".ssh" / "authorized_keys"
|
||||||
|
|
||||||
|
def chrooted(self, program):
|
||||||
|
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
|
||||||
|
|
||||||
|
def test_no_authorized_keys_is_a_no_op(self):
|
||||||
|
self.configure()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists())
|
||||||
|
|
||||||
|
def test_installs_keys_one_per_line(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
|
||||||
|
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
|
||||||
|
|
||||||
|
def test_drops_blank_lines_and_comments(self):
|
||||||
|
self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n")
|
||||||
|
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
|
||||||
|
def test_keys_with_options_pass_through(self):
|
||||||
|
key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host'
|
||||||
|
self.configure(authorized_keys=f"{key}\n")
|
||||||
|
self.assertEqual(self.authorized_keys().read_text(), f"{key}\n")
|
||||||
|
|
||||||
|
def test_ssh_dir_is_private(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode)
|
||||||
|
self.assertEqual(mode, 0o700)
|
||||||
|
|
||||||
|
def test_authorized_keys_is_private(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_chowns_ssh_dir_to_the_user(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(self.chrooted("chown"), [
|
||||||
|
["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_enables_sshd(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(self.chrooted("systemctl"), [
|
||||||
|
["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_allows_ssh_through_ufw_despite_chroot_exit_status(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(self.chrooted("ufw"), [
|
||||||
|
["arch-chroot", str(self.target), "ufw", "allow", "ssh"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_fails_when_ufw_does_not_record_the_rule(self):
|
||||||
|
self.ufw_writes_rule = False
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
|
||||||
|
def test_empty_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
|
||||||
|
self.configure(authorized_keys="")
|
||||||
|
|
||||||
|
def test_blank_only_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
|
||||||
|
self.configure(authorized_keys="\n \n")
|
||||||
|
|
||||||
|
def test_comment_only_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
|
||||||
|
self.configure(authorized_keys="# no keys here\n")
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalPathTest(unittest.TestCase):
|
||||||
|
def test_unset_and_missing_paths_are_none(self):
|
||||||
|
from orchestrator.context import _optional_path
|
||||||
|
|
||||||
|
self.assertIsNone(_optional_path(None))
|
||||||
|
self.assertIsNone(_optional_path(""))
|
||||||
|
self.assertIsNone(_optional_path("/does/not/exist/authorized_keys"))
|
||||||
|
|
||||||
|
def test_existing_path_is_returned(self):
|
||||||
|
from orchestrator.context import _optional_path
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as f:
|
||||||
|
self.assertEqual(_optional_path(f.name), Path(f.name))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Unit tests for the orchestrator's configure_tailscale phase.
|
||||||
|
|
||||||
|
Same harness as the SSH access tests: the phase touches the target only
|
||||||
|
through the filesystem and arch-chroot, so it runs against a temp directory
|
||||||
|
with subprocess.run recorded and the ufw side effect (writing user.rules)
|
||||||
|
simulated by the fake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import stat
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# phases_impl imports the archinstall adapter at module scope, which pulls in
|
||||||
|
# the archinstall library that only exists on the live ISO. configure_tailscale
|
||||||
|
# never touches it, so stub the adapter out before the import.
|
||||||
|
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
|
||||||
|
|
||||||
|
from orchestrator import phases_impl # noqa: E402
|
||||||
|
|
||||||
|
UFW_RULE = "-A ufw-user-input -i tailscale0 -j ACCEPT\n"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureTailscaleTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
self.ufw_writes_rule = True
|
||||||
|
|
||||||
|
def fake_run(self, cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if cmd[2] == "ufw":
|
||||||
|
if self.ufw_writes_rule:
|
||||||
|
rules = self.target / "etc" / "ufw" / "user.rules"
|
||||||
|
rules.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rules.write_text(UFW_RULE)
|
||||||
|
# ufw cannot reach netfilter inside a chroot and exits non-zero
|
||||||
|
# even when it recorded the rule; the phase must not trust this.
|
||||||
|
return CompletedProcess(cmd, 1)
|
||||||
|
return CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
def ctx(self, authkey=None, tailscale_installed=True):
|
||||||
|
authkey_path = None
|
||||||
|
if authkey is not None:
|
||||||
|
authkey_path = self.target / "tailscale_authkey"
|
||||||
|
authkey_path.write_text(authkey)
|
||||||
|
if tailscale_installed:
|
||||||
|
binary = self.target / "usr" / "bin" / "tailscale"
|
||||||
|
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
binary.touch()
|
||||||
|
return types.SimpleNamespace(target=self.target, tailscale_authkey_path=authkey_path)
|
||||||
|
|
||||||
|
def configure(self, **kwargs):
|
||||||
|
phases_impl.configure_tailscale(self.ctx(**kwargs))
|
||||||
|
|
||||||
|
def staged_key(self):
|
||||||
|
return self.target / "etc" / "tailscale" / "authkey"
|
||||||
|
|
||||||
|
def unit(self):
|
||||||
|
return self.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
|
||||||
|
|
||||||
|
def chrooted(self, program):
|
||||||
|
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
|
||||||
|
|
||||||
|
def test_no_authkey_is_a_no_op(self):
|
||||||
|
self.configure()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertFalse((self.target / "etc" / "tailscale").exists())
|
||||||
|
|
||||||
|
def test_stages_the_key(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
|
||||||
|
|
||||||
|
def test_drops_blank_lines_and_comments(self):
|
||||||
|
self.configure(authkey="# reusable, tagged\n\n tskey-auth-kFAKEKEY \n")
|
||||||
|
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
|
||||||
|
|
||||||
|
def test_key_dir_and_file_are_root_private(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(stat.S_IMODE((self.target / "etc" / "tailscale").stat().st_mode), 0o700)
|
||||||
|
self.assertEqual(stat.S_IMODE(self.staged_key().stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_installs_the_join_unit(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
text = self.unit().read_text()
|
||||||
|
self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text)
|
||||||
|
# The key must not outlive a successful join, the unit must not run
|
||||||
|
# again after one, and cleanup must be sequenced after the join
|
||||||
|
# succeeds -- inside the script, not in ExecStartPost.
|
||||||
|
self.assertIn(
|
||||||
|
"ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:/etc/tailscale/authkey;"
|
||||||
|
" do sleep 15; done; rm -f /etc/tailscale/authkey;"
|
||||||
|
" systemctl disable omarchy-tailscale-join.service'",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_join_unit_does_not_hold_up_boot(self):
|
||||||
|
# A Type=oneshot wanted by multi-user.target holds the whole boot
|
||||||
|
# (SDDM included) hostage until the join finishes or times out --
|
||||||
|
# target units implicitly gain After= for their Wants=.
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
text = self.unit().read_text()
|
||||||
|
self.assertIn("Type=simple", text)
|
||||||
|
self.assertNotIn("Type=oneshot", text)
|
||||||
|
self.assertNotIn("TimeoutStartSec", text)
|
||||||
|
|
||||||
|
def test_unit_avoids_systemd_variable_expansion(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertNotIn("$", self.unit().read_text())
|
||||||
|
|
||||||
|
def test_enables_tailscaled_and_the_join(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(self.chrooted("systemctl"), [
|
||||||
|
["arch-chroot", str(self.target), "systemctl", "enable",
|
||||||
|
"tailscaled.service", "omarchy-tailscale-join.service"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_allows_tailnet_traffic_through_ufw_despite_chroot_exit_status(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(self.chrooted("ufw"), [
|
||||||
|
["arch-chroot", str(self.target), "ufw", "allow", "in", "on", "tailscale0"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_fails_when_ufw_does_not_record_the_rule(self):
|
||||||
|
self.ufw_writes_rule = False
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "allow rule for tailscale0"):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
|
||||||
|
def test_fails_when_tailscale_is_not_on_the_target(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "not installed on the target"):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n", tailscale_installed=False)
|
||||||
|
|
||||||
|
def test_empty_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
|
||||||
|
self.configure(authkey="")
|
||||||
|
|
||||||
|
def test_comment_only_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
|
||||||
|
self.configure(authkey="# no key here\n")
|
||||||
|
|
||||||
|
def test_multiple_keys_fail_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "expected exactly one"):
|
||||||
|
self.configure(authkey="tskey-auth-kONE\ntskey-auth-kTWO\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MODULE_PATH = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("installer_keyboard", MODULE_PATH)
|
||||||
|
KEYBOARD = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader
|
||||||
|
SPEC.loader.exec_module(KEYBOARD)
|
||||||
|
|
||||||
|
# The layout list lives in the Omarchy runtime now, shared verbatim with the
|
||||||
|
# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from
|
||||||
|
# wherever this checkout can see a runtime, and skip the coverage test rather
|
||||||
|
# than fail when none is around (a bare CI checkout of just this repo).
|
||||||
|
SETUP_FORM_CANDIDATES = (
|
||||||
|
Path("/omarchy-source/install/provisioning/setup-form.sh"),
|
||||||
|
ROOT.parent / "omarchy/install/provisioning/setup-form.sh",
|
||||||
|
Path("/usr/share/omarchy/install/provisioning/setup-form.sh"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def supported_keymaps():
|
||||||
|
for candidate in SETUP_FORM_CANDIDATES:
|
||||||
|
if not candidate.is_file():
|
||||||
|
continue
|
||||||
|
block = re.search(
|
||||||
|
r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL
|
||||||
|
)
|
||||||
|
assert block, f"no layout list in {candidate}"
|
||||||
|
return [line.split("|", 1)[1] for line in block.group(1).splitlines()]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_KEYMAPS = supported_keymaps()
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardConfigurationTest(unittest.TestCase):
|
||||||
|
def target(self, directory: str) -> Path:
|
||||||
|
target = Path(directory)
|
||||||
|
(target / "etc").mkdir()
|
||||||
|
(target / "etc/vconsole.conf").write_text(
|
||||||
|
"KEYMAP=us\nFONT=default8x16\n"
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
|
||||||
|
def test_writes_keymap_and_xkb_settings_and_preserves_font(self):
|
||||||
|
# XKBLAYOUT is load-bearing: omarchy's detect-keyboard-layout.sh copies
|
||||||
|
# it into Hyprland's kb_layout on the installed system.
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
target = self.target(directory)
|
||||||
|
self.assertTrue(KEYBOARD.configure_keyboard(target, "us"))
|
||||||
|
lines = (target / "etc/vconsole.conf").read_text().splitlines()
|
||||||
|
self.assertIn("KEYMAP=us", lines)
|
||||||
|
self.assertIn("XKBLAYOUT=us", lines)
|
||||||
|
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
||||||
|
|
||||||
|
def test_all_configurator_keymaps_are_known_to_localectl(self):
|
||||||
|
if SUPPORTED_KEYMAPS is None:
|
||||||
|
self.skipTest("no Omarchy runtime checkout to read the layout list from")
|
||||||
|
for keymap in SUPPORTED_KEYMAPS:
|
||||||
|
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
|
||||||
|
target = self.target(directory)
|
||||||
|
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap))
|
||||||
|
lines = (target / "etc/vconsole.conf").read_text().splitlines()
|
||||||
|
self.assertIn(f"KEYMAP={keymap}", lines)
|
||||||
|
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
||||||
|
|
||||||
|
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
|
||||||
|
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
|
||||||
|
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
|
||||||
|
target = self.target(directory)
|
||||||
|
self.assertEqual(KEYBOARD.configure_keyboard(target, keymap), expected)
|
||||||
|
self.assertEqual(
|
||||||
|
(target / "etc/vconsole.conf").read_text(),
|
||||||
|
"KEYMAP=us\nFONT=default8x16\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PRUNER = ROOT / "builder/prune-offline-mirror.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class OfflineMirrorPruningTest(unittest.TestCase):
|
||||||
|
def run_pruner(self, mirror: Path, required: list[str]):
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", str(PRUNER), str(mirror)],
|
||||||
|
input="".join(f"{filename}\n" for filename in required),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_file(self, mirror: Path, filename: str):
|
||||||
|
(mirror / filename).write_bytes(b"package")
|
||||||
|
|
||||||
|
def test_keeps_only_exactly_selected_package_archives(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
mirror = Path(directory)
|
||||||
|
keep = "keep-2.0-1-x86_64.pkg.tar.zst"
|
||||||
|
other = "other-1.0-1-any.pkg.tar.zst"
|
||||||
|
stale_version = "keep-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
removed_package = "removed-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
orphan_signature = "interrupted-1.0-1-x86_64.pkg.tar.zst.sig"
|
||||||
|
|
||||||
|
for filename in (keep, other, stale_version, removed_package):
|
||||||
|
self.add_file(mirror, filename)
|
||||||
|
for filename in (keep, stale_version, removed_package):
|
||||||
|
self.add_file(mirror, f"{filename}.sig")
|
||||||
|
self.add_file(mirror, orphan_signature)
|
||||||
|
self.add_file(mirror, "offline.db.tar.gz")
|
||||||
|
|
||||||
|
result = self.run_pruner(mirror, [keep, other])
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
{path.name for path in mirror.iterdir()},
|
||||||
|
{keep, f"{keep}.sig", other, "offline.db.tar.gz"},
|
||||||
|
)
|
||||||
|
self.assertIn(stale_version, result.stdout)
|
||||||
|
self.assertIn(removed_package, result.stdout)
|
||||||
|
|
||||||
|
def test_missing_selection_aborts_before_deleting_anything(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
mirror = Path(directory)
|
||||||
|
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
self.add_file(mirror, cached)
|
||||||
|
|
||||||
|
result = self.run_pruner(
|
||||||
|
mirror, ["missing-1.0-1-x86_64.pkg.tar.zst"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertTrue((mirror / cached).exists())
|
||||||
|
self.assertIn("selected packages are missing", result.stderr)
|
||||||
|
|
||||||
|
def test_empty_selection_aborts_before_deleting_anything(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
mirror = Path(directory)
|
||||||
|
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
self.add_file(mirror, cached)
|
||||||
|
|
||||||
|
result = self.run_pruner(mirror, [])
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertTrue((mirror / cached).exists())
|
||||||
|
self.assertIn("empty package selection", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"""Unit tests for deferred-provisioning install support in the orchestrator.
|
||||||
|
|
||||||
|
Covers the InstallContext deferred-provisioning plumbing (marker file, credential-less input,
|
||||||
|
throwaway LUKS passphrase injection) and the stage_provisioning_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 ContextDeferProvisioningTest(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_DEFER_PROVISIONING_FILE",):
|
||||||
|
if key not in env:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
return InstallContext.from_env()
|
||||||
|
|
||||||
|
def base_config(self, defer_provisioning=None, disk_encryption=None):
|
||||||
|
config = {
|
||||||
|
"disk_config": {"config_type": "default_layout"},
|
||||||
|
"omarchy_install": {"mode": "full_disk", "target_mount": "/mnt"},
|
||||||
|
}
|
||||||
|
if defer_provisioning is not None:
|
||||||
|
config["omarchy_install"]["defer_provisioning"] = defer_provisioning
|
||||||
|
if disk_encryption is not None:
|
||||||
|
config["disk_config"]["disk_encryption"] = disk_encryption
|
||||||
|
return config
|
||||||
|
|
||||||
|
def test_defer_provisioning_flag_from_config(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True))
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertTrue(ctx.defer_provisioning)
|
||||||
|
self.assertEqual(ctx.user_credentials, {"users": []})
|
||||||
|
self.assertEqual(ctx.username, "")
|
||||||
|
|
||||||
|
def test_marker_file_arms_deferred_provisioning_without_credentials(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
marker = self.dir / "defer_provisioning"
|
||||||
|
marker.touch()
|
||||||
|
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(marker))
|
||||||
|
self.assertTrue(ctx.defer_provisioning)
|
||||||
|
self.assertTrue(ctx.omarchy_install["defer_provisioning"])
|
||||||
|
|
||||||
|
def test_absent_marker_file_is_not_deferred_provisioning(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
self.write_creds({"users": [{"username": "jeff"}]})
|
||||||
|
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(self.dir / "missing-defer_provisioning"))
|
||||||
|
self.assertFalse(ctx.defer_provisioning)
|
||||||
|
self.assertEqual(ctx.username, "jeff")
|
||||||
|
|
||||||
|
def test_missing_credentials_without_defer_provisioning_raises(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "credentials file missing"):
|
||||||
|
self.from_env()
|
||||||
|
|
||||||
|
def test_deferred_provisioning_generates_throwaway_luks_passphrase(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=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_deferred_provisioning_reuses_rig_supplied_passphrase(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=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_deferred_provisioning_discards_account_material(self):
|
||||||
|
# A rig credentials file must not smuggle in users or a root password.
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True))
|
||||||
|
self.write_creds({
|
||||||
|
"users": [{"username": "backdoor", "enc_password": "x", "sudo": True}],
|
||||||
|
"root_enc_password": "x",
|
||||||
|
"encryption_password": "rig-secret",
|
||||||
|
})
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertEqual(ctx.user_credentials["users"], [])
|
||||||
|
self.assertNotIn("root_enc_password", ctx.user_credentials)
|
||||||
|
self.assertEqual(ctx.user_credentials["encryption_password"], "rig-secret")
|
||||||
|
synthesized = json.loads(ctx.creds_path.read_text())
|
||||||
|
self.assertEqual(synthesized["users"], [])
|
||||||
|
self.assertNotIn("root_enc_password", synthesized)
|
||||||
|
|
||||||
|
def test_defer_provisioning_strips_account_fields(self):
|
||||||
|
config = self.base_config(defer_provisioning=True)
|
||||||
|
config["!users"] = [{"username": "rig"}]
|
||||||
|
config["!root-password"] = "hunter2"
|
||||||
|
config["root_enc_password"] = "x"
|
||||||
|
config["auth_config"] = {"users": [{"username": "rig"}], "root_enc_password": "x"}
|
||||||
|
self.write_config(config)
|
||||||
|
ctx = self.from_env()
|
||||||
|
|
||||||
|
arch_config = json.loads(ctx.arch_config_path.read_text())
|
||||||
|
for key in ("!users", "!root-password", "root_enc_password", "users"):
|
||||||
|
self.assertNotIn(key, arch_config)
|
||||||
|
self.assertEqual(arch_config["auth_config"], {})
|
||||||
|
|
||||||
|
def test_deferred_provisioning_unencrypted_touches_nothing(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True))
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertNotIn("encryption_password", ctx.user_credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def make_ctx(target, **overrides):
|
||||||
|
defaults = dict(
|
||||||
|
target=target,
|
||||||
|
defer_provisioning=True,
|
||||||
|
encrypt=False,
|
||||||
|
username="",
|
||||||
|
omarchy_install={"mode": "full_disk", "defer_provisioning": 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 StageProvisioningStateTest(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_provisioning_support(self):
|
||||||
|
service = self.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
|
||||||
|
service.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
service.write_text("[Unit]\n")
|
||||||
|
setup_bin = self.target / "usr/bin/omarchy-provision-owner"
|
||||||
|
setup_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
setup_bin.write_text("#!/bin/bash\n")
|
||||||
|
|
||||||
|
def provisioning_dir(self):
|
||||||
|
return self.target / "var/lib/omarchy/provisioning"
|
||||||
|
|
||||||
|
def test_normal_install_stages_only_the_node_tarball(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.assertFalse((self.provisioning_dir() / "pending").exists())
|
||||||
|
self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
|
||||||
|
|
||||||
|
def test_deferred_provisioning_against_runtime_without_support_fails_clearly(self):
|
||||||
|
ctx = make_ctx(self.target)
|
||||||
|
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)
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
self.assertTrue((self.provisioning_dir() / "pending").exists())
|
||||||
|
self.assertTrue((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
|
||||||
|
link = self.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service"
|
||||||
|
self.assertTrue(link.is_symlink())
|
||||||
|
self.assertEqual(
|
||||||
|
os.readlink(link), "/etc/systemd/system/omarchy-provision-owner.service"
|
||||||
|
)
|
||||||
|
# Unencrypted: no LUKS staging.
|
||||||
|
self.assertFalse((self.provisioning_dir() / "luks-key").exists())
|
||||||
|
|
||||||
|
def test_deferred_provisioning_encrypted_stages_luks_auto_unlock(self):
|
||||||
|
self.install_runtime_provisioning_support()
|
||||||
|
ctx = make_ctx(
|
||||||
|
self.target,
|
||||||
|
user_configuration={"disk_config": {"disk_encryption": {
|
||||||
|
"encryption_type": "luks",
|
||||||
|
"encryption_password": "throwaway-secret",
|
||||||
|
}}},
|
||||||
|
)
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
# Byte-for-byte the slot passphrase — no trailing newline.
|
||||||
|
self.assertEqual((self.provisioning_dir() / "luks-key").read_text(), "throwaway-secret")
|
||||||
|
keyfile = self.target / "etc/omarchy/provisioning.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-provisioning-unlock.conf").read_text()
|
||||||
|
self.assertIn("cryptkey=rootfs:/etc/omarchy/provisioning.key", cmdline)
|
||||||
|
files = (self.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf").read_text()
|
||||||
|
self.assertIn("FILES+=(/etc/omarchy/provisioning.key)", files)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_pre_encrypted_without_passphrase_fails(self):
|
||||||
|
self.install_runtime_provisioning_support()
|
||||||
|
ctx = make_ctx(
|
||||||
|
self.target,
|
||||||
|
omarchy_install={"mode": "protected", "defer_provisioning": True, "storage": {"luks_uuid": "abc"}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "passphrase"):
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureLoginDeferProvisioningTest(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_deferred_provisioning_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, defer_provisioning=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 ConfigureSshAccessDeferProvisioningTest(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_deferred_provisioning_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/provisioning/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, defer_provisioning=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", f"{top}/@", f"{top}/@factory"],
|
||||||
|
self.calls,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
["btrfs", "property", "set", "-ts", f"{top}/@factory", "ro", "true"],
|
||||||
|
self.calls,
|
||||||
|
)
|
||||||
|
self.assertIn(["umount", top], self.calls)
|
||||||
|
|
||||||
|
def test_factory_snapshot_scrubs_provisioning_credentials(self):
|
||||||
|
# Simulate the snapshot carrying deployment credentials; the scrub
|
||||||
|
# must drop them before the snapshot goes read-only.
|
||||||
|
top = self.state_dir / "factory-top"
|
||||||
|
factory = top / "@factory"
|
||||||
|
secrets = [
|
||||||
|
factory / "var/lib/omarchy/provisioning/authorized_keys",
|
||||||
|
factory / "etc/tailscale/authkey",
|
||||||
|
factory / "etc/omarchy/provisioning.key",
|
||||||
|
]
|
||||||
|
keep = factory / "var/lib/omarchy/provisioning/groups"
|
||||||
|
for path in [*secrets, keep]:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("secret")
|
||||||
|
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
|
||||||
|
for path in secrets:
|
||||||
|
self.assertFalse(path.exists(), path)
|
||||||
|
self.assertTrue(keep.exists())
|
||||||
|
|
||||||
|
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