Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdff11f0d0 | ||
|
|
730ae083c4 | ||
|
|
d6cd2d30a6 | ||
|
|
be618cc73b | ||
|
|
a6a442b9a0 | ||
|
|
e5f2b466ef | ||
|
|
a12bfea7a8 | ||
|
|
8b3dcc68b2 | ||
|
|
22eb81ebf5 | ||
|
|
8858ac2544 | ||
|
|
6b2f8a6ac7 | ||
|
|
6004e80485 | ||
|
|
0339b13d12 |
No files matched your search
@@ -38,6 +38,7 @@ These are the configurator's own output files, so the way to get a starting set
|
|||||||
| `user_email_address.txt` | No | Git email |
|
| `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 |
|
| `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 |
|
| `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"`.
|
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"`.
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ 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 `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
|
### Building the drive
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+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
|
||||||
+27
-1
@@ -117,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
|
||||||
@@ -139,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.
|
||||||
@@ -154,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 < <(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -101,6 +101,16 @@ else
|
|||||||
./configurator
|
./configurator
|
||||||
fi
|
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
|
||||||
# completion, then renders the final installed-time/reboot prompt itself.
|
# completion, then renders the final installed-time/reboot prompt itself.
|
||||||
@@ -116,4 +126,6 @@ rm -f /run/omarchy-install/state.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
|
--authorized-keys-file /root/authorized_keys \
|
||||||
|
--tailscale-authkey-file /root/tailscale_authkey \
|
||||||
|
--defer-provisioning-file /root/defer-provisioning
|
||||||
+316
-152
@@ -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,74 +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
|
||||||
if [[ "$username" =~ ^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$ ]] ; then
|
|
||||||
notice "Username is reserved for system" 1
|
|
||||||
else
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
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]}
|
||||||
@@ -209,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
|
||||||
|
|
||||||
@@ -347,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)
|
||||||
@@ -413,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
|
||||||
@@ -448,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)
|
||||||
@@ -491,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 ))
|
||||||
@@ -554,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
|
||||||
@@ -563,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
|
||||||
@@ -576,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"
|
||||||
@@ -634,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
|
||||||
@@ -716,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() {
|
||||||
@@ -771,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)
|
||||||
@@ -799,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")
|
||||||
@@ -846,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")
|
||||||
@@ -879,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))
|
||||||
@@ -902,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_
|
||||||
)
|
)
|
||||||
@@ -927,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",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
# Anything less than the pair means this isn't an autoinstall drive, and the
|
# Anything less than the pair means this isn't an autoinstall drive, and the
|
||||||
# caller falls back to the wizard.
|
# caller falls back to the wizard.
|
||||||
#
|
#
|
||||||
|
# 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
|
# The optional argument prefixes every path the script touches; tests use it to
|
||||||
# run against a sandbox instead of the live system.
|
# run against a sandbox instead of the live system.
|
||||||
|
|
||||||
@@ -35,10 +38,20 @@ done
|
|||||||
mkdir -p "$mnt"
|
mkdir -p "$mnt"
|
||||||
mount -o ro "$device" "$mnt" || exit 1
|
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
|
loaded=1
|
||||||
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
|
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then
|
||||||
files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json)
|
files=("$mnt"/user_configuration.json)
|
||||||
for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do
|
for file in "${optional_inputs[@]}"; do
|
||||||
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
|
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
|
||||||
done
|
done
|
||||||
if cp "${files[@]}" "$dest"/; then
|
if cp "${files[@]}" "$dest"/; then
|
||||||
|
|||||||
@@ -123,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
|
||||||
@@ -142,8 +151,18 @@ 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_SIZE=""
|
||||||
LAST_PM=10
|
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 ─────────────────────────────────────────────────────────
|
# ── Install progress ─────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
@@ -321,11 +340,13 @@ install_progress() {
|
|||||||
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
|
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
|
||||||
"Configuring hibernation") lo=700 hi=720 tau=10 ;;
|
"Configuring hibernation") lo=700 hi=720 tau=10 ;;
|
||||||
"Configuring system") lo=720 hi=850 tau=120 ;;
|
"Configuring system") lo=720 hi=850 tau=120 ;;
|
||||||
"Finalizing Limine boot") lo=850 hi=950 tau=90 ;;
|
"Staging provisioning") lo=850 hi=855 tau=5 ;;
|
||||||
|
"Finalizing Limine boot") lo=855 hi=950 tau=90 ;;
|
||||||
"Finalizing user") lo=950 hi=978 tau=30 ;;
|
"Finalizing user") lo=950 hi=978 tau=30 ;;
|
||||||
"Configuring login") lo=978 hi=988 tau=5 ;;
|
"Configuring login") lo=978 hi=988 tau=5 ;;
|
||||||
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
|
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
|
||||||
"Validating boot setup") lo=994 hi=1000 tau=5 ;;
|
"Validating boot setup") lo=994 hi=998 tau=5 ;;
|
||||||
|
"Creating factory snapshot") lo=998 hi=1000 tau=5 ;;
|
||||||
*)
|
*)
|
||||||
# Unknown names used to inherit a 1->99 sweep. Derive an even band from
|
# Unknown names used to inherit a 1->99 sweep. Derive an even band from
|
||||||
# the published index instead; with no index, a bounded slice, so a
|
# the published index instead; with no index, a bounded slice, so a
|
||||||
@@ -387,15 +408,40 @@ current_tip() {
|
|||||||
printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}"
|
printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#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 pm
|
local pm
|
||||||
|
# The console can still resize under us (the framebuffer settles late in VMs).
|
||||||
|
# Horizontal padding is recomputed every frame anyway, but the logo is drawn
|
||||||
|
# once, so redraw the static half at the new center rather than leave the
|
||||||
|
# centered layout stranded against a stale geometry.
|
||||||
|
[[ "$(term_size)" == "$LAST_SIZE" ]] || render_static
|
||||||
set_now
|
set_now
|
||||||
install_progress
|
install_progress
|
||||||
pm=$PROGRESS_PM
|
pm=$PROGRESS_PM
|
||||||
@@ -450,30 +496,45 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,8 +545,10 @@ reboot_prompt() {
|
|||||||
# applies, so setting both stops on the finish screen without a keyboard.
|
# applies, so setting both stops on the finish screen without a keyboard.
|
||||||
interactive || return 0
|
interactive || return 0
|
||||||
|
|
||||||
# Position the cursor one blank line below the title so the button sits right there.
|
# gum paints its prompt row at the cursor and the button two rows under it, so
|
||||||
printf '%s%d;1H%s' "$CSI" "$((LOGO_HEIGHT + 5))" "$CLEAR_LINE" >"$TTY_PATH"
|
# 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
|
||||||
@@ -734,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=$?
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--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 ;;
|
||||||
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
|
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
|
||||||
|
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
|
||||||
|
--defer-provisioning-file) export OMARCHY_INSTALL_DEFER_PROVISIONING_FILE="$2"; shift 2 ;;
|
||||||
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
|
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -19,11 +20,13 @@ class InstallContext:
|
|||||||
email: str
|
email: str
|
||||||
encrypt: bool
|
encrypt: bool
|
||||||
authorized_keys_path: Path | None
|
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")
|
||||||
@@ -47,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")
|
||||||
|
|
||||||
@@ -61,10 +108,12 @@ class InstallContext:
|
|||||||
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")),
|
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", {})
|
||||||
@@ -75,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:
|
||||||
@@ -89,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"
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ from pathlib import Path
|
|||||||
def configure_keyboard(target: Path, language: str) -> bool:
|
def configure_keyboard(target: Path, language: str) -> bool:
|
||||||
"""Write the console keymap into a mounted target without booting it.
|
"""Write the console keymap into a mounted target without booting it.
|
||||||
|
|
||||||
Returns False for layouts localectl doesn't know (the configurator offers
|
Returns False for layouts localectl doesn't know, matching archinstall:
|
||||||
two, ba and khmer), matching archinstall: warn and keep the default.
|
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():
|
if not language.strip():
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -34,12 +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_ssh_access,
|
||||||
|
configure_tailscale,
|
||||||
validate_boot,
|
validate_boot,
|
||||||
|
create_factory_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -48,12 +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 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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +72,8 @@ def main() -> int:
|
|||||||
error(f"Configuration error: {e}")
|
error(f"Configuration error: {e}")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
info(f"Installing Omarchy for {ctx.username} → {ctx.target}")
|
who = ctx.username or "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,
|
boost_cpu_governor,
|
||||||
|
|||||||
@@ -11,11 +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_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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -290,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)
|
||||||
@@ -589,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
|
||||||
@@ -996,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:
|
||||||
@@ -1127,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).
|
||||||
@@ -1230,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1276,29 +1415,32 @@ def configure_login(ctx: InstallContext) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
autologin_conf = sddm_dir / "autologin.conf"
|
autologin_conf = sddm_dir / "autologin.conf"
|
||||||
if ctx.encrypt:
|
if ctx.encrypt and not ctx.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,
|
||||||
@@ -1317,24 +1459,36 @@ def configure_ssh_access(ctx: InstallContext) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
keys = _authorized_keys(ctx.authorized_keys_path)
|
keys = _authorized_keys(ctx.authorized_keys_path)
|
||||||
info(f"› installing {len(keys)} SSH key(s) for {ctx.username}")
|
|
||||||
|
|
||||||
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
|
if ctx.defer_provisioning:
|
||||||
ssh_dir.mkdir(parents=True, exist_ok=True)
|
# No user to authorize yet. Stage the keys in provisioning state for
|
||||||
ssh_dir.chmod(0o700)
|
# 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}")
|
||||||
|
|
||||||
authorized_keys = ssh_dir / "authorized_keys"
|
ssh_dir = ctx.target / "home" / ctx.username / ".ssh"
|
||||||
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
authorized_keys.chmod(0o600)
|
ssh_dir.chmod(0o700)
|
||||||
|
|
||||||
# Ask the target for the uid rather than assuming the first user is 1000.
|
authorized_keys = ssh_dir / "authorized_keys"
|
||||||
# Uncaptured so a failure shows up in the install log, and checked because
|
authorized_keys.write_text("".join(f"{key}\n" for key in keys))
|
||||||
# a root-owned authorized_keys is one sshd refuses to read.
|
authorized_keys.chmod(0o600)
|
||||||
subprocess.run(
|
|
||||||
["arch-chroot", str(ctx.target), "chown", "-R",
|
# Ask the target for the uid rather than assuming the first user is 1000.
|
||||||
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
|
# Uncaptured so a failure shows up in the install log, and checked because
|
||||||
check=True,
|
# a root-owned authorized_keys is one sshd refuses to read.
|
||||||
)
|
subprocess.run(
|
||||||
|
["arch-chroot", str(ctx.target), "chown", "-R",
|
||||||
|
f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
info("› enabling sshd")
|
info("› enabling sshd")
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -1381,6 +1535,113 @@ def _authorized_keys(path: Path) -> list[str]:
|
|||||||
return 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
|
||||||
@@ -1434,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.
|
||||||
@@ -1491,6 +1780,90 @@ 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")
|
CPU_SYSFS = Path("/sys/devices/system/cpu")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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.
|
||||||
@@ -89,8 +89,9 @@ echo "Jeff" >"$sandbox/media/user_full_name.txt"
|
|||||||
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
|
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
|
||||||
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
|
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
|
||||||
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
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"
|
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; do
|
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"
|
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
|
||||||
done
|
done
|
||||||
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
|
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
|
||||||
@@ -120,8 +121,61 @@ echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
|||||||
run_load || fail "required pair plus authorized_keys loads"
|
run_load || fail "required pair plus authorized_keys loads"
|
||||||
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
|
[[ -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/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"
|
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.
|
# Half the required pair is not an autoinstall drive: unmount and fall back.
|
||||||
new_sandbox
|
new_sandbox
|
||||||
attach_drive cidata
|
attach_drive cidata
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class ConfigureSshAccessTest(unittest.TestCase):
|
|||||||
if authorized_keys is not None:
|
if authorized_keys is not None:
|
||||||
authorized_keys_path = self.target / "authorized_keys"
|
authorized_keys_path = self.target / "authorized_keys"
|
||||||
authorized_keys_path.write_text(authorized_keys)
|
authorized_keys_path.write_text(authorized_keys)
|
||||||
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path)
|
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False)
|
||||||
|
|
||||||
def configure(self, **kwargs):
|
def configure(self, **kwargs):
|
||||||
phases_impl.configure_ssh_access(self.ctx(**kwargs))
|
phases_impl.configure_ssh_access(self.ctx(**kwargs))
|
||||||
|
|||||||
@@ -0,0 +1,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()
|
||||||
+30
-14
@@ -14,14 +14,30 @@ KEYBOARD = importlib.util.module_from_spec(SPEC)
|
|||||||
assert SPEC.loader
|
assert SPEC.loader
|
||||||
SPEC.loader.exec_module(KEYBOARD)
|
SPEC.loader.exec_module(KEYBOARD)
|
||||||
|
|
||||||
CONFIGURATOR = (ROOT / "configs/airootfs/root/configurator").read_text()
|
# The layout list lives in the Omarchy runtime now, shared verbatim with the
|
||||||
KEYBOARD_BLOCK = re.search(r"keyboards=\$'(.*?)'\n choice=", CONFIGURATOR, re.DOTALL)
|
# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from
|
||||||
assert KEYBOARD_BLOCK
|
# wherever this checkout can see a runtime, and skip the coverage test rather
|
||||||
SUPPORTED_KEYMAPS = [
|
# than fail when none is around (a bare CI checkout of just this repo).
|
||||||
line.split("|", 1)[1]
|
SETUP_FORM_CANDIDATES = (
|
||||||
for line in KEYBOARD_BLOCK.group(1).splitlines()
|
Path("/omarchy-source/install/provisioning/setup-form.sh"),
|
||||||
]
|
ROOT.parent / "omarchy/install/provisioning/setup-form.sh",
|
||||||
UNSUPPORTED_KEYMAPS = {"ba", "khmer"}
|
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):
|
class KeyboardConfigurationTest(unittest.TestCase):
|
||||||
@@ -45,15 +61,15 @@ class KeyboardConfigurationTest(unittest.TestCase):
|
|||||||
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
||||||
|
|
||||||
def test_all_configurator_keymaps_are_known_to_localectl(self):
|
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:
|
for keymap in SUPPORTED_KEYMAPS:
|
||||||
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
|
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
|
||||||
target = self.target(directory)
|
target = self.target(directory)
|
||||||
configured = KEYBOARD.configure_keyboard(target, keymap)
|
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap))
|
||||||
self.assertEqual(configured, keymap not in UNSUPPORTED_KEYMAPS)
|
lines = (target / "etc/vconsole.conf").read_text().splitlines()
|
||||||
if configured:
|
self.assertIn(f"KEYMAP={keymap}", lines)
|
||||||
lines = (target / "etc/vconsole.conf").read_text().splitlines()
|
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
||||||
self.assertIn(f"KEYMAP={keymap}", lines)
|
|
||||||
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
|
||||||
|
|
||||||
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
|
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
|
||||||
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
|
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
|
||||||
|
|||||||
@@ -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