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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion configs/airootfs/root/.automated_script.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,42 @@ warm_pid=$!
trap 'kill "$warm_pid" 2>/dev/null' EXIT

cd /root
# A run that aborted — a refused cfdisk Resize, a failed partition snapshot,
# Ctrl+C — leaves its inputs behind, and the retry it recommends comes straight
# back here. Clear them first so the gate below can only ever pass on a
# configuration this run produced, never on one describing a partition layout
# that no longer exists.
#
# This is the same set omarchy-cidata-load clears before it copies, and it has
# to stay the same set. Its cleanup only runs once a cidata drive is found, so
# an autoinstall attempt that aborts and is retried with the drive pulled never
# reaches it: authorized_keys and tailscale_authkey would survive into the
# interactive install, which passes both to the orchestrator whenever the files
# exist, and the new machine would authorize the rig's SSH key and enroll with
# its Tailscale key.
rm -f user_configuration.json user_credentials.json defer-provisioning \
user_full_name.txt user_email_address.txt user_encrypt_installation.txt \
authorized_keys tailscale_authkey

# Autoinstall: a cidata drive carrying the configurator's own output files
# stands in for the wizard. omarchy-cidata-load copies them into /root and
# everything downstream runs the ordinary path against ordinary inputs.
if /usr/local/bin/omarchy-cidata-load; then
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
export OMARCHY_UI_INTERACTIVE=no
else
./configurator
# The else body already inherits set -e, but the dashboard launch below is
# unconditional and the guards it stands between are data-safety guards.
# State the contract in the code so a configurator abort (a Resize of an
# existing OS, a table that would not snapshot) cannot start an install.
./configurator || exit 1
fi

# Both branches must have produced a configuration. Nothing downstream re-reads
# the partition table, so an install started without one would run against
# whatever the disk happens to look like now.
[[ -f user_configuration.json ]] || exit 1

# 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).
Expand Down
50 changes: 50 additions & 0 deletions configs/airootfs/root/configurator
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ not_enough_space() {
step "Not enough free space on $disk"
say --foreground 1 "$disk has $(to_gb $available) of usable free space; Omarchy needs at least 32GB."
say "Open the partition tool to free at least 32GB on $disk, then try again."
say --foreground 8 "Do not Resize an existing OS partition in cfdisk — that only changes the table and corrupts the filesystem."
echo
if ! gum confirm --affirmative "Back" --negative "Open partition tool" "Return to installation mode?"; then
open_partition_tool
Expand Down Expand Up @@ -895,18 +896,67 @@ disk_form() {
# STEP 4: INSTALL MODE

open_partition_tool() {
local dump="" before status

step "Partition tool for $disk"
gum style "Create unallocated free space for Omarchy, then write changes and quit."
gum style --foreground 8 "Do not create an Omarchy partition here — leave the target area as Free space."
gum style --foreground 8 "Do not Resize an existing OS partition. cfdisk does not shrink filesystems; that corrupts the other OS."
gum style --foreground 8 "Shrink from Windows Disk Management (or the running OS) first, then leave the gap as Free space."
gum style --foreground 8 "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."
echo
gum confirm --affirmative "Open cfdisk" --negative "Back" "Open partition tool for $disk?" || return 0

dump=$(mktemp)
# The baseline scan and the snapshot are prerequisites, not conveniences.
# Without both there is nothing to compare cfdisk's result against and
# nothing to put back, so a Resize of an existing OS would go unnoticed and
# unrecoverable. Refuse to open the tool rather than open it unguarded.
if ! before=$(partition_starts_and_sizes "$disk") || ! save_partition_table "$disk" "$dump"; then
rm -f "$dump"
step "Cannot open the partition tool"
say --foreground 1 "The current partition table on $disk could not be read and saved, so a Resize could not be undone."
say "Make free space from Windows Disk Management (or the other OS) instead, then return here."
say --foreground 8 "Leave the space you free as unallocated Free space."
echo
gum confirm --affirmative "OK" --negative "Quit" "Return to installation mode?" || abort
return 1
fi

clear
cfdisk "$disk" || true
partprobe "$disk" 2>/dev/null || true
sync
sleep 1

restore_shrunk_partitions "$disk" "$dump" "$before"
status=$?
rm -f "$dump"
if (( status == 0 )); then
partprobe "$disk" 2>/dev/null || true
sync
step "Partition table restored"
say --foreground 1 "cfdisk Resize only changes the partition table. It does not shrink the filesystem."
say "Continuing would have corrupted the existing OS, so the previous partition table was restored."
say --foreground 8 "Make free space from Windows Disk Management (or the other OS), then return here."
say --foreground 8 "Deleting an unused partition in cfdisk is fine. Resizing one is not."
echo
if gum confirm --affirmative "OK" --negative "Quit" "Return to installation mode?"; then
return 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed restore does not stop install

When sfdisk fails to restore a shrunk partition table, this branch calls abort, but .automated_script.sh ignores the configurator's nonzero exit and unconditionally launches the installer dashboard. The install therefore continues after the safety guard reports that the existing filesystem remains behind a shortened partition boundary, producing a later failure or allowing retained configuration from an earlier attempt to operate against the damaged table.

Knowledge Base Used:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 96927a6, with one correction: .automated_script.sh sets set -euo pipefail at the top, and the else body inherits it (only the if condition is exempt), so a failing configurator did already terminate the script. The install was not actually reachable that way.

The guard is worth stating explicitly regardless, so the call is now ./configurator || exit 1, and a [[ -f user_configuration.json ]] gate covers both branches before the dashboard handoff. The launcher also clears the previous run's inputs first, since the abort message tells people to re-run it and a leftover configuration describes a partition layout the guard just rejected.

test/unit/installer-gate-test.sh runs the real handoff region against a sandboxed /root and asserts no install starts without a clean configurator exit and a configuration this run produced.

abort
elif (( status == 2 )); then
step "Partition resize detected"
say --foreground 1 "cfdisk Resize only changes the partition table. It does not shrink the filesystem."
say "The previous table could not be restored automatically. Do not continue this install."
abort "Aborted: shrinking an existing partition would corrupt the other OS."
elif (( status == 3 )); then
step "Partition table could not be verified"
say --foreground 1 "The partition table on $disk could not be read back after cfdisk."
say "A Resize of an existing OS cannot be ruled out, and installing into that space would corrupt it."
say "Do not continue this install. Reboot and check the disk from the other OS."
abort "Aborted: the partition table could not be verified after cfdisk."
fi
}

# If the disk has no partition table or no unallocated free space, the only
Expand Down
74 changes: 74 additions & 0 deletions configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,80 @@ create_partition() {
created_partition_number="$num"
}

# Start sector and size in bytes for every partition, one "start size" line.
# Start is the identity cfdisk Resize preserves (it changes the end, not the
# start), so a later comparison can tell a shrink from a delete or a new
# partition in free space. parted rather than lsblk so this works against
# image files in tests, the same way partition_numbers does.
partition_starts_and_sizes() {
local out
# Judge parted by its exit status, not by what it printed. Reading the table
# can fail, and a failure that arrives as empty output is indistinguishable
# from a disk with no partitions — which reads as "nothing shrank" and is
# exactly how a guard like this fails open. Capture first so the status is
# parted's own and not awk's.
out=$(parted -ms "$1" unit B print 2>/dev/null) || return 1
printf '%s\n' "$out" | tail -n +3 | awk -F: '
$1 ~ /^[0-9]+$/ {
start=$2; size=$4;
gsub(/B/, "", start);
gsub(/B/, "", size);
print start, size
}'
}

# Lines of "start old_size new_size" for partitions that shrank by more than
# 1MiB. Matched by start sector so a delete (start gone) or a newly created
# partition (start unseen) is not a shrink. The 1MiB slack is the same
# alignment tolerance create_partition uses.
shrunk_partition_lines() {
local before="$1" after="$2"
awk -v tol=$((1024 * 1024)) '
NR == FNR { old[$1] = $2; next }
($1 in old) && ((old[$1] - $2) > tol) { print $1, old[$1], $2 }
' <(printf '%s\n' "$before") <(printf '%s\n' "$after")
}

# Snapshot the GPT so a later cfdisk session can be undone. Fails when sfdisk
# fails or writes nothing usable (no table, sfdisk unavailable, unreadable
# disk): a partial dump would restore a table nobody verified, so the caller
# must treat a false here as "do not let cfdisk near this disk".
save_partition_table() {
local disk="$1" dest="$2" status=0
sfdisk -d "$disk" >"$dest" 2>/dev/null || status=$?
(( status == 0 )) && [[ -s $dest ]]
}

# Rewrite the GPT from an sfdisk dump. Used to undo a cfdisk Resize: that
# command only changes the partition table, so putting the original table
# back is a full recovery as long as nothing has been written into the gap.
restore_partition_table() {
local disk="$1" src="$2" output status=0
output=$(sfdisk --force "$disk" <"$src" 2>&1) || status=$?
[[ -n $output ]] && printf '%s\n' "$output"
partprobe "$disk" 2>/dev/null || true
return "$status"
}

# If any existing partition shrank, restore dump and return 0. Return 1 when
# the table is fine (delete/create/grow/no-op). Return 2 when a shrink was
# found but the dump could not be written back. Return 3 when the edit cannot
# be judged at all: no usable snapshot to compare against or restore from, or
# a table that no longer reads back.
#
# 3 must never collapse into 1. "Cannot tell" is precisely the state in which
# a shrink is invisible, and answering "the table is fine" there hands the
# installer a disk whose existing filesystem may already be past its new end.
restore_shrunk_partitions() {
local disk="$1" dump="$2" before="$3" after shrunk
[[ -n $dump && -s $dump ]] || return 3
after=$(partition_starts_and_sizes "$disk") || return 3
shrunk=$(shrunk_partition_lines "$before" "$after")
[[ -n $shrunk ]] || return 1
restore_partition_table "$disk" "$dump" || return 2
return 0
}

# Undo the partitions this run created, highest number first. Scoped strictly
# to created_parts: without this, a failed install leaves the user's freed
# space occupied by orphans, and the retry reports "not enough free space"
Expand Down
182 changes: 182 additions & 0 deletions test/unit/installer-gate-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/bin/bash
#
# The partition guard in the configurator is only worth anything if the script
# that launches it honours a refusal. .automated_script.sh hands off to the
# install dashboard unconditionally once the wizard returns, so these cases
# pin the two gates that stand between: the configurator's exit status, and
# the configuration file it is supposed to have produced.
#
# The launcher is a tty1-gated monolith that redirects its own output and
# execs the real installer, so the handoff region is lifted out and run in a
# sandbox with /root, /usr/local/bin and /run rewritten to throwaway paths.
# The lines under test are the file's own, unmodified.

set -uo pipefail

ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
LAUNCHER="$ROOT/configs/airootfs/root/.automated_script.sh"

work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT

failures=0

check() {
local label="$1" expected="$2" actual="$3"
if [[ $expected == "$actual" ]]; then
printf ' ok %s\n' "$label"
else
printf ' FAIL %s: expected %s, got %s\n' "$label" "$expected" "$actual"
failures=$((failures + 1))
fi
}

# Rebuild the sandbox and the handoff script for each case. CIDATA_EXIT and
# CONFIGURATOR_EXIT drive which branch runs; CONFIGURATOR_WRITES_CONFIG says
# whether the wizard got far enough to leave a configuration behind.
new_sandbox() {
sandbox=$(mktemp -d "$work/sandbox.XXXXXX")
mkdir -p "$sandbox/root" "$sandbox/bin" "$sandbox/run"

cat >"$sandbox/bin/omarchy-cidata-load" <<'STUB'
#!/bin/bash
exit "${CIDATA_EXIT:-1}"
STUB

cat >"$sandbox/root/configurator" <<'STUB'
#!/bin/bash
[[ ${CONFIGURATOR_WRITES_CONFIG:-0} == 1 ]] && echo '{"disk_config": {}}' >user_configuration.json
exit "${CONFIGURATOR_EXIT:-0}"
STUB

# The marker is the whole assertion: if it exists, an install was started.
cat >"$sandbox/bin/omarchy-install-dashboard" <<'STUB'
#!/bin/bash
touch "$SANDBOX/install-started"
STUB

cat >"$sandbox/bin/jq" <<'STUB'
#!/bin/bash
echo false
STUB

# tty is read for the dashboard's TTY handle; the test has no terminal.
cat >"$sandbox/bin/tty" <<'STUB'
#!/bin/bash
echo /dev/console
STUB

chmod +x "$sandbox/bin"/* "$sandbox/root/configurator"

sed -n '/^cd \/root$/,$p' "$LAUNCHER" |
sed -e "s#^cd /root\$#cd $sandbox/root#" \
-e "s#/usr/local/bin/#$sandbox/bin/#g" \
-e "s#/root/#$sandbox/root/#g" \
-e "s#/run/omarchy-install/#$sandbox/run/#g" \
>"$sandbox/handoff.sh"

# The lift has to keep the pieces under test. A silent sed miss would turn
# every case into a vacuous pass.
grep -q 'configurator || exit 1' "$sandbox/handoff.sh" &&
grep -q 'user_configuration.json \]\] || exit 1' "$sandbox/handoff.sh" &&
grep -q "$sandbox/bin/omarchy-install-dashboard" "$sandbox/handoff.sh"
}

run_handoff() {
SANDBOX="$sandbox" \
OMARCHY_INSTALL_LOG_FILE="$sandbox/install.log" \
PATH="$sandbox/bin:$PATH" \
bash "$sandbox/handoff.sh" >/dev/null 2>&1
}

installed() {
[[ -e $sandbox/install-started ]] && echo yes || echo no
}

echo "==> the handoff region is lifted intact"
new_sandbox
check "both gates and the dashboard handoff survive the rewrite" "0" "$?"

echo "==> a configurator that refuses does not start an install"
new_sandbox
CONFIGURATOR_EXIT=1 CONFIGURATOR_WRITES_CONFIG=0 run_handoff
check "the launcher exits nonzero" "1" "$?"
check "no install was started" "no" "$(installed)"

# The abort path tells the user to re-run the launcher. A configuration left
# by the run that just refused describes a partition layout the guard rejected.
echo "==> a stale configuration cannot stand in for a refused one"
new_sandbox
echo '{"disk_config": {}}' >"$sandbox/root/user_configuration.json"
CONFIGURATOR_EXIT=1 CONFIGURATOR_WRITES_CONFIG=0 run_handoff
check "the launcher still exits nonzero" "1" "$?"
check "no install was started" "no" "$(installed)"
[[ -e $sandbox/root/user_configuration.json ]]
check "the stale configuration was cleared" "1" "$?"

# An autoinstall attempt that aborts and is retried with the cidata drive
# pulled never reaches the loader's own cleanup — it exits as soon as no drive
# is found. The orchestrator treats these files as "present means use it", so
# anything left here is the previous attempt's remote access on the new machine.
echo "==> a pulled cidata drive leaves no remote-access credentials behind"
new_sandbox
echo 'ssh-ed25519 AAAA rig@imaging' >"$sandbox/root/authorized_keys"
echo 'tskey-auth-rig' >"$sandbox/root/tailscale_authkey"
echo '{"disk_config": {}}' >"$sandbox/root/user_configuration.json"
: >"$sandbox/root/defer-provisioning"
CONFIGURATOR_EXIT=0 CONFIGURATOR_WRITES_CONFIG=1 run_handoff
check "the interactive retry starts its own install" "yes" "$(installed)"
[[ -e $sandbox/root/authorized_keys ]]
check "the rig's SSH key was cleared" "1" "$?"
[[ -e $sandbox/root/tailscale_authkey ]]
check "the rig's Tailscale key was cleared" "1" "$?"
[[ -e $sandbox/root/defer-provisioning ]]
check "the stale defer-provisioning marker was cleared" "1" "$?"

# The launcher clears these because omarchy-cidata-load cannot: its cleanup is
# behind the drive-found check. Drift between the two lists reopens the leak,
# so compare them rather than trusting two hand-maintained copies.
echo "==> the launcher clears every input the cidata loader knows about"
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
loader_inputs=$(
sed -n 's/^optional_inputs=(\(.*\))$/\1/p' "$CIDATA_LOAD" |
tr ' ' '\n' | sed '/^$/d' | sort -u
)
loader_inputs=$(printf '%s\nuser_configuration.json\n' "$loader_inputs" | sort -u)
launcher_inputs=$(
awk '/^rm -f / { grab = 1 }
grab { sub(/^rm -f /, ""); cont = /\\$/; sub(/\\$/, ""); print; if (!cont) exit }' "$LAUNCHER" |
tr ' ' '\n' | sed '/^$/d' | sort -u
)
check "the loader's input list was parsed" "0" "$([[ -n $loader_inputs ]] && echo 0 || echo 1)"
check "the two cleanup lists match" "$loader_inputs" "$launcher_inputs"

echo "==> a configurator that produced nothing does not start an install"
new_sandbox
CONFIGURATOR_EXIT=0 CONFIGURATOR_WRITES_CONFIG=0 run_handoff
check "the launcher exits nonzero" "1" "$?"
check "no install was started" "no" "$(installed)"

echo "==> a completed wizard still starts the install"
new_sandbox
CONFIGURATOR_EXIT=0 CONFIGURATOR_WRITES_CONFIG=1 run_handoff
check "the launcher exits clean" "0" "$?"
check "the install was started" "yes" "$(installed)"

echo "==> autoinstall from a cidata drive still starts the install"
new_sandbox
echo '{"disk_config": {}}' >"$sandbox/root/cidata-config"
cat >"$sandbox/bin/omarchy-cidata-load" <<STUB
#!/bin/bash
cp "$sandbox/root/cidata-config" "$sandbox/root/user_configuration.json"
STUB
chmod +x "$sandbox/bin/omarchy-cidata-load"
run_handoff
check "the launcher exits clean" "0" "$?"
check "the install was started" "yes" "$(installed)"

if (( failures > 0 )); then
printf '\n%d check(s) failed\n' "$failures"
exit 1
fi
printf '\nall checks passed\n'
Loading