Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e22a13dfeb
|
||
|
|
31cca48c14 | ||
|
|
abc1a7716d | ||
|
|
27b7e76c5a | ||
|
|
9dcfdb035a | ||
|
|
e121561851 | ||
|
|
1c912658aa | ||
|
|
8b17699997 | ||
|
|
595d38cb04 | ||
|
|
073f7b2fc0 | ||
|
|
d02dee7e73 | ||
|
|
36b072ce76 | ||
|
|
fe133bd419 | ||
|
|
89a34f7885 | ||
|
|
37f71a0efb | ||
|
|
84149ef33b | ||
|
|
57df34ddfc | ||
|
|
6aef3008c1 | ||
|
|
1ad8872146 | ||
|
|
e7774eeeaf | ||
|
|
82d5693395 | ||
|
|
1ca5e5a7a3 | ||
|
|
86c07785cb | ||
|
|
9373d0e139 | ||
|
|
555c364221 | ||
|
|
7cfb7111a0 | ||
|
|
1daf1202d4 | ||
|
|
47426693de | ||
|
|
b507a20b7e | ||
|
|
447f6d2e58 | ||
|
|
a23f8d464d | ||
|
|
6f5ab0bcd1 | ||
|
|
2f5af06895 | ||
|
|
2673c613d9 | ||
|
|
d6e4060d56 | ||
|
|
100a41f7fd | ||
|
|
f9be60b24b | ||
|
|
222a742daf | ||
|
|
9900e63bd4 | ||
|
|
37fd3c4305 | ||
|
|
e89c0796f7 | ||
|
|
0b2739a55d | ||
|
|
9a7325e3a6 | ||
|
|
bba81a6ec5 | ||
|
|
6b5ce68f25 | ||
|
|
02f8d1cf50 | ||
|
|
3559b2bc9d | ||
|
|
d2e764d317 | ||
|
|
268bac16d3 | ||
|
|
6d02625f7f | ||
|
|
df80b320cb | ||
|
|
0c1333837f | ||
|
|
10bd6632d3 | ||
|
|
174dd82b15 | ||
|
|
7d3b01eae9 | ||
|
|
7c50d72f40 |
No files matched your search
@@ -0,0 +1,118 @@
|
||||
name: Install test comment
|
||||
|
||||
# Posts the install test's numbers on the pull request as one comment, kept
|
||||
# up to date on every run, next to quattro's latest numbers. GitHub runs
|
||||
# workflow_run workflows from the default branch, with write access even for
|
||||
# pull requests from forks, so this job never checks out or runs pull request
|
||||
# code: it only reads the finished run's artifact, as data.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Install test"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
|
||||
steps:
|
||||
# A rerun keeps earlier attempts' artifacts under the same run, so take
|
||||
# exactly the attempt that finished (and the baseline's latest attempt).
|
||||
- name: Fetch this run's numbers and quattro's latest
|
||||
env:
|
||||
ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
run: |
|
||||
gh run download "$RUN_ID" -R "$REPO" -n "install-test-$RUN_ID-$ATTEMPT" -D pr || true
|
||||
base=$(gh run list -R "$REPO" --workflow install-test.yml --branch quattro --event push \
|
||||
--status success -L 1 --json databaseId -q '.[0].databaseId')
|
||||
if [ -n "$base" ]; then
|
||||
base_attempt=$(gh api "repos/$REPO/actions/runs/$base" -q .run_attempt)
|
||||
gh run download "$base" -R "$REPO" -n "install-test-$base-$base_attempt" -D base || true
|
||||
fi
|
||||
|
||||
- name: Comment on the pull request
|
||||
run: |
|
||||
set -euo pipefail
|
||||
marker='<!-- omarchy-install-test -->'
|
||||
|
||||
# The number comes from the pull request's own run, so trust it only
|
||||
# when that pull request's head is the commit this run tested.
|
||||
pr=$({ find pr -name pr-number -exec cat {} + 2>/dev/null || true; } | head -1 | tr -dc 0-9)
|
||||
[ -n "$pr" ] || { echo "No pull request number in the artifact"; exit 0; }
|
||||
[ "$(gh api "repos/$REPO/pulls/$pr" -q .head.sha)" = "$HEAD_SHA" ] ||
|
||||
{ echo "PR #$pr has moved past $HEAD_SHA; not commenting"; exit 0; }
|
||||
|
||||
# Every lookup prints nothing rather than failing, so a missing baseline (none
|
||||
# yet, or expired) shows as "–" instead of ending the script under set -e.
|
||||
timing() { { find "$1" -name omarchy-install-timing.json 2>/dev/null || true; } | head -1; }
|
||||
install_s() { [ -n "$1" ] || return 0; jq -r '.finished_at - .started_at' "$1" 2>/dev/null || true; }
|
||||
# "Startup finished in … = 1min 2.3s" or "= 12.104s" -> seconds
|
||||
boot_s() {
|
||||
local f
|
||||
f=$({ find "$1" -path "$2" 2>/dev/null || true; } | head -1)
|
||||
[ -n "$f" ] || return 0
|
||||
head -1 "$f" | sed -n 's/.*= *//p' |
|
||||
awk '{t=0; for (i=1;i<=NF;i++) { if ($i ~ /min$/) t+=60*$i; else if ($i ~ /ms$/) t+=$i/1000; else t+=$i } printf "%.3f", t}'
|
||||
}
|
||||
cell() { [ -n "$1" ] && awk -v x="$1" 'BEGIN{printf "%.1f s", x}' || echo "–"; }
|
||||
change() {
|
||||
[ -n "$1" ] && [ -n "$2" ] || { echo "–"; return; }
|
||||
awk -v a="$1" -v b="$2" 'BEGIN{d=a-b; printf "%s%.1f s", (d>=0?"+":"−"), (d<0?-d:d)}'
|
||||
}
|
||||
|
||||
pt=$(timing pr); bt=$(timing base)
|
||||
rows=(
|
||||
"Install|$(install_s "$pt")|$(install_s "$bt")"
|
||||
"First boot|$(boot_s pr '*/first-boot-systemd-analyze.txt')|$(boot_s base '*/first-boot-systemd-analyze.txt')"
|
||||
"Everyday boot|$(boot_s pr '*-boot/systemd-analyze.txt')|$(boot_s base '*-boot/systemd-analyze.txt')"
|
||||
)
|
||||
|
||||
status="passed"; [ "$CONCLUSION" = success ] || status="**${CONCLUSION}**"
|
||||
{
|
||||
echo "$marker"
|
||||
echo "### Install test $status"
|
||||
echo
|
||||
echo "| | This PR | quattro | Change |"
|
||||
echo "| --- | ---: | ---: | ---: |"
|
||||
for row in "${rows[@]}"; do
|
||||
IFS='|' read -r name this base_v <<<"$row"
|
||||
echo "| $name | $(cell "$this") | $(cell "$base_v") | $(change "$this" "$base_v") |"
|
||||
done
|
||||
if [ -n "$pt" ]; then
|
||||
echo
|
||||
echo "<details><summary>Install phases</summary>"
|
||||
echo
|
||||
echo "| Phase | This PR | quattro |"
|
||||
echo "| --- | ---: | ---: |"
|
||||
# Phase names come from the pull request's installer: keep them to plain text.
|
||||
jq -r --slurpfile b <( [ -n "$bt" ] && cat "$bt" || echo '{"phases":[]}' ) '
|
||||
($b[0].phases | map({key: .name, value: .elapsed}) | from_entries) as $base
|
||||
| .phases[]
|
||||
| "| \(.name | gsub("[^A-Za-z0-9 +&/().-]"; "") | .[0:60]) | \(.elapsed * 10 | round / 10) s | \(($base[.name] // null) | if . == null then "–" else "\(. * 10 | round / 10) s" end) |"
|
||||
' "$pt"
|
||||
echo
|
||||
echo "</details>"
|
||||
fi
|
||||
echo
|
||||
echo "[Run]($RUN_URL) · ${HEAD_SHA:0:8}"
|
||||
} > comment.md
|
||||
|
||||
existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \
|
||||
-q ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -1)
|
||||
if [ -n "$existing" ]; then
|
||||
gh api -X PATCH "repos/$REPO/issues/comments/$existing" -F body=@comment.md >/dev/null
|
||||
else
|
||||
gh api "repos/$REPO/issues/$pr/comments" -F body=@comment.md >/dev/null
|
||||
fi
|
||||
cat comment.md
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Install test
|
||||
|
||||
# Builds the ISO a pull request produces, installs it unattended in a KVM
|
||||
# guest, boots the installed system again, and reports how long the install
|
||||
# and both boots took. Every later change to the installer is measured
|
||||
# against these numbers in its own PR.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Each merge records quattro's numbers, which PR comments compare against.
|
||||
push:
|
||||
branches: [quattro]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: install-test-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
# The hosted runner has far more disk than the build needs (145 GB,
|
||||
# ~86 GB free); fail early and loudly if that ever changes.
|
||||
- name: Check disk space
|
||||
run: |
|
||||
df -h /
|
||||
avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
|
||||
[ "$avail_gb" -ge 40 ] || { echo "::error::only ${avail_gb} GB free, the ISO build and install test need ~40 GB"; exit 1; }
|
||||
|
||||
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- name: Build the ISO
|
||||
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
|
||||
|
||||
# The harness runs in an Arch container: Arch's current QEMU and OVMF
|
||||
# (QEMU 11) instead of Ubuntu 24.04's QEMU 8.2, at the paths the
|
||||
# harness expects, the same as on an Omarchy workstation.
|
||||
- name: Install unattended, then boot the installed system
|
||||
run: |
|
||||
sudo chmod 0666 /dev/kvm
|
||||
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
|
||||
-e OMARCHY_INTEGRATION_DISK_CACHE=none \
|
||||
archlinux:latest bash -euo pipefail -c '
|
||||
pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \
|
||||
imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null
|
||||
# test/integration installs its tools through Omarchy'"'"'s helper; they are installed above.
|
||||
printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add
|
||||
chmod 0755 /usr/local/bin/omarchy-pkg-add
|
||||
qemu-system-x86_64 --version | head -1
|
||||
./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800
|
||||
'
|
||||
|
||||
# The harness ran as root in its container: test-runs and what QEMU
|
||||
# wrote there (the serial logs) belong to root until this step.
|
||||
- name: Hand the results to the runner's user
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p test-runs
|
||||
sudo chown -R "$(id -u):$(id -g)" test-runs
|
||||
|
||||
- name: Report times
|
||||
if: always()
|
||||
run: |
|
||||
base=$(ls -d test-runs/*-integration 2>/dev/null | head -1)
|
||||
[ -n "$base" ] || { echo "No test run to report" >> "$GITHUB_STEP_SUMMARY"; exit 0; }
|
||||
{
|
||||
echo "## Install test"
|
||||
echo
|
||||
if [ -f "$base/omarchy-install-timing.json" ]; then
|
||||
jq -r '"**Install:** \((.finished_at - .started_at) * 10 | round / 10) s across \(.phases | length) phases"' "$base/omarchy-install-timing.json"
|
||||
echo
|
||||
echo "| Phase | Seconds |"
|
||||
echo "| --- | ---: |"
|
||||
jq -r '.phases[] | "| \(.name) | \(.elapsed * 10 | round / 10) |"' "$base/omarchy-install-timing.json"
|
||||
echo
|
||||
fi
|
||||
if [ -f "$base/first-boot-systemd-analyze.txt" ]; then
|
||||
echo "**First boot:** $(head -1 "$base/first-boot-systemd-analyze.txt")"
|
||||
echo
|
||||
fi
|
||||
boot_dir=$(ls -d "$base"/runs/*-boot 2>/dev/null | tail -1)
|
||||
if [ -n "$boot_dir" ] && [ -f "$boot_dir/systemd-analyze.txt" ]; then
|
||||
echo "**Everyday boot:** $(head -1 "$boot_dir/systemd-analyze.txt")"
|
||||
echo
|
||||
echo "System state: $(jq -r .system_state "$boot_dir/boot-timing.json" 2>/dev/null)"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The comment workflow runs from quattro and reads only this artifact;
|
||||
# it needs the PR number, which a workflow_run event omits for forks.
|
||||
- name: Record the pull request
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
run: echo "${{ github.event.pull_request.number }}" > test-runs/pr-number
|
||||
|
||||
- name: Upload test artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: install-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
test-runs/pr-number
|
||||
test-runs/*/omarchy-install-timing.json
|
||||
test-runs/*/omarchy-install.log
|
||||
test-runs/*/first-boot-systemd-analyze.txt
|
||||
test-runs/*/runs/**/*.png
|
||||
test-runs/*/runs/**/*.txt
|
||||
test-runs/*/runs/**/*.json
|
||||
test-runs/*/runs/**/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Unit tests
|
||||
|
||||
# The fast, VM-free tests under test/unit/: cidata autoinstall loading and the
|
||||
# orchestrator's phases. Seconds per run, no ISO build; the install test
|
||||
# covers the rest.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [quattro]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
# The tests run where the code they cover runs: on Arch, the same as
|
||||
# the live ISO and an Omarchy workstation. The container boots systemd
|
||||
# because the keyboard tests ask the real localectl for its keymaps, and
|
||||
# the tests run as an ordinary user because some of them check what
|
||||
# happens when a file cannot be written.
|
||||
#
|
||||
# A privileged container shares the host's consoles and devices, so the
|
||||
# units that would claim them are masked before systemd starts: without
|
||||
# that, its getty, udev and vconsole setup take over the host's.
|
||||
- name: Run ./test/all in an Arch container
|
||||
run: |
|
||||
docker run -d --name unit --privileged --cgroupns=host \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw --tmpfs /run --tmpfs /tmp:exec \
|
||||
-v "$PWD:/src:ro" archlinux:latest bash -c '
|
||||
systemctl mask getty.target console-getty.service \
|
||||
systemd-udev-trigger.service systemd-udevd.service \
|
||||
systemd-vconsole-setup.service
|
||||
exec /usr/lib/systemd/systemd
|
||||
'
|
||||
docker exec unit bash -euo pipefail -c '
|
||||
for i in $(seq 1 60); do
|
||||
state=$(systemctl is-system-running 2>/dev/null || true)
|
||||
case $state in running | degraded) break ;; esac
|
||||
sleep 1
|
||||
done
|
||||
echo "systemd: $state"
|
||||
pacman -Syu --noconfirm --needed python parted >/dev/null
|
||||
useradd -m tester
|
||||
cp -r /src /home/tester/omarchy-iso
|
||||
chown -R tester /home/tester/omarchy-iso
|
||||
cd /home/tester/omarchy-iso
|
||||
setpriv --reuid=tester --regid=tester --init-groups ./test/all
|
||||
'
|
||||
@@ -6,6 +6,14 @@ The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarc
|
||||
|
||||
See the ISO link on [omarchy.org](https://omarchy.org).
|
||||
|
||||
Every published ISO has a `.sha256` beside it at the same URL. Download both into the same directory and check the ISO before writing it to a USB stick:
|
||||
|
||||
```bash
|
||||
sha256sum -c omarchy-3.0.iso.sha256
|
||||
```
|
||||
|
||||
Corruption anywhere in the ISO is worth catching before the write, and corruption in the bundled package mirror is worth catching most: the mirror lives inside the ISO and the installer reads it straight off the medium, so those bytes surface minutes into the install as a pacman "invalid or corrupted package" error rather than as anything that names the download. Corruption elsewhere is louder and earlier — it stops the medium booting or mounting. There is a `.sig` beside the ISO too for anyone who wants to verify it against the Omarchy signing key.
|
||||
|
||||
## Creating the ISO
|
||||
|
||||
Run `./bin/omarchy-iso-make`; output goes into `./release`. By default the ISO uses the Omarchy packages and tracks the `quattro` branch, from the stable mirror. Pass `--edge` to use `omarchy-dev` and `omarchy-settings-dev` from the edge mirror.
|
||||
@@ -18,6 +26,8 @@ For local development, build the ISO from sibling checkouts:
|
||||
|
||||
Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo.
|
||||
|
||||
The configurator asks for the language on its first screen: English or Simplified Chinese. Chinese reruns the installer in cage + foot, a console that draws Chinese, with Noto Sans Mono CJK unpacked from the offline mirror's `noto-fonts-cjk`. It opens on the keyboard screen, offers only keyboards with a US keymap (cage keeps a US layout) and preselects Chinese (Simplified, Pinyin); every later screen and the install progress read in Chinese. Esc there goes back to a bilingual language screen, where English returns to the VT, which applies the chosen keymap. Without a working display or the font, Chinese continues in English on the VT. Either way a Chinese install sets the target's `LANG` to `zh_CN.UTF-8` through archinstall's `locale_config`. The language never decides the region: that follows the timezone. The Chinese text lives in `OMARCHY_ZH` tables beside the English it replaces.
|
||||
|
||||
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
|
||||
|
||||
## Autoinstall
|
||||
@@ -54,6 +64,8 @@ When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh
|
||||
|
||||
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.
|
||||
|
||||
The region follows `timezone`, exactly as in an interactive install: a timezone listed in one of the runtime's region profiles (`default/regions/*/timezones`, such as `Asia/Shanghai` for `cn`) selects that profile's mirrors and repositories, and anything else is global. Set `omarchy_install.region` in `user_configuration.json` to pick one explicitly, for example `"cn"` on a deferred-provisioning install, which carries `UTC` until first boot. Every ISO bundles every profile's packages, so a regional install still needs no network.
|
||||
|
||||
### Building the drive
|
||||
|
||||
```bash
|
||||
@@ -86,7 +98,7 @@ Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt s
|
||||
|
||||
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
|
||||
|
||||
Run `./test/all` for the unit tests, which cover cidata autoinstall loading and the orchestrator's SSH access phase without needing a built ISO.
|
||||
Run `./test/all` for the fast, VM-free tests under `test/unit/`, which cover cidata autoinstall loading and the orchestrator's phases without needing a built ISO.
|
||||
|
||||
To exercise installation alongside existing Windows-style partitions, run
|
||||
`./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a
|
||||
@@ -110,13 +122,27 @@ The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available
|
||||
|
||||
Pass `--encrypt` to drive the encrypted install flow (including typing the LUKS passphrase at boot) instead of the unencrypted one. Pass `--no-preview` to collect the same visual artifacts without opening them in `imv` when the run finishes.
|
||||
|
||||
## Integration testing the ISO
|
||||
|
||||
Scenarios under `test/integration.d/` boot a real ISO install in QEMU and assert on what the running system actually does. The runner installs the ISO once — unattended, from a generated cidata drive — and saves the result as a reusable base image; every scenario then boots a throwaway overlay of that base with its own copy of the firmware vars, so neither disk nor NVRAM state leaks between runs. Shared machinery (VM lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest SSH, the cidata build) lives in `test/integration.d/base-test.sh`, so a new scenario is one file.
|
||||
|
||||
```bash
|
||||
./test/integration release/omarchy.iso # install once, run all scenarios
|
||||
./test/integration release/omarchy.iso --reuse-base # fast loop against the saved base
|
||||
./test/integration release/omarchy.iso factory-reset # a single named scenario
|
||||
```
|
||||
|
||||
The first scenario is `factory-reset`: it proves `omarchy-system-factory-reset` hands a machine on without destroying a shared ESP. The installed ESP gets a Windows entry with payload plus a second Linux cloned under a foreign machine-id with its own boot directory and UKIs; a real factory reset is then driven through a guest pty, and the harness asserts the foreign entries survive both the staged reset and first-boot provisioning, that the old Omarchy identity is fully retired, and that the machine reaches first-boot setup unattended.
|
||||
|
||||
Artifacts — screenshots, the fixtured/staged/final `limine.conf`, the reset typescript, and the factory-reset log — land under `test-runs/<iso>-integration/runs/<timestamp>-<scenario>/`, and `--no-preview` skips the `imv` review just like the acceptance harness.
|
||||
|
||||
## Signing the ISO
|
||||
|
||||
Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI.
|
||||
|
||||
## Uploading the ISO
|
||||
|
||||
Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`).
|
||||
Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). The `.sig` and `.sha256` sidecars go up with the ISO when they exist beside it.
|
||||
|
||||
## Full release of the ISO
|
||||
|
||||
|
||||
+20
-3
@@ -3,6 +3,7 @@
|
||||
set -e
|
||||
|
||||
NO_CACHE=""
|
||||
KEEP_PKG_CACHE=""
|
||||
LOCAL_OMARCHY_PATH=""
|
||||
LOCAL_PKGS_PATH=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -11,6 +12,10 @@ while [[ $# -gt 0 ]]; do
|
||||
NO_CACHE=1
|
||||
shift
|
||||
;;
|
||||
--keep-pkg-cache)
|
||||
KEEP_PKG_CACHE=1
|
||||
shift
|
||||
;;
|
||||
--no-boot-offer)
|
||||
NO_BOOT_OFFER=1
|
||||
shift
|
||||
@@ -52,7 +57,7 @@ while [[ $# -gt 0 ]]; do
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--no-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
|
||||
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -100,7 +105,11 @@ lint_file_permissions() {
|
||||
|
||||
lint_file_permissions
|
||||
|
||||
sudo rm -rf /var/cache/pacman/pkg/*
|
||||
# Purging the host package cache needs interactive sudo; --keep-pkg-cache
|
||||
# skips it so unattended builds (test rigs, agents) can run without a prompt.
|
||||
if [[ -z $KEEP_PKG_CACHE ]]; then
|
||||
sudo rm -rf /var/cache/pacman/pkg/*
|
||||
fi
|
||||
git submodule update --init --recursive --jobs=8
|
||||
|
||||
BUILD_SCRIPT="builder/build-iso.sh"
|
||||
@@ -149,7 +158,15 @@ else
|
||||
DOCKER_ARGS+=(--pull=always)
|
||||
fi
|
||||
|
||||
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
|
||||
# The docker group is root-equivalent, so Omarchy operators stay out of it on
|
||||
# purpose. When the socket refuses the calling user, run the client through
|
||||
# sudo instead of requiring group membership.
|
||||
DOCKER=(docker)
|
||||
if ! docker version &>/dev/null; then
|
||||
DOCKER=(sudo docker)
|
||||
fi
|
||||
|
||||
"${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
|
||||
|
||||
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
|
||||
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
# A failed step must stop the release: without this, a make that dies (say,
|
||||
# docker permission denied) falls through to signing and uploading whatever
|
||||
# stale ISO release/ already holds, under the new release's name.
|
||||
set -e
|
||||
|
||||
MAKE_ARGS=()
|
||||
ISO_REF=quattro
|
||||
RELEASE_SUFFIX=""
|
||||
@@ -37,6 +42,14 @@ if [[ -z "${BUILD_VERSION-}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A version that already numbers its candidate (4.0.2rc2) is the complete
|
||||
# artifact name; the bare -rc suffix exists only for an unnumbered candidate.
|
||||
# Without this, every candidate of a train uploads over the same
|
||||
# omarchy-X.Y.Z-rc.iso and testers cannot tell which rc they are holding.
|
||||
if [[ $BUILD_VERSION == *rc* ]]; then
|
||||
RELEASE_SUFFIX=""
|
||||
fi
|
||||
|
||||
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
|
||||
|
||||
@@ -56,7 +69,23 @@ cp "$latest_iso" "$release_iso"
|
||||
echo -e "\e[32mSigning $ISO_REF ISO\e[0m"
|
||||
omarchy-iso-sign "$release_iso"
|
||||
|
||||
# The checksum sidecar names the ISO alone, never the build path: it is
|
||||
# downloaded next to the ISO and has to verify with `sha256sum -c` from
|
||||
# whatever directory the person who downloaded it happens to be in. A release
|
||||
# that cannot write it stops here, because the sidecar left over from the last
|
||||
# release reads as a checksum for this one.
|
||||
iso_sha=$(sha256sum "$release_iso")
|
||||
iso_sha=${iso_sha%% *}
|
||||
if [[ -z $iso_sha ]] || ! printf '%s %s\n' "$iso_sha" "${release_iso##*/}" >"$release_iso.sha256"; then
|
||||
echo -e "\e[31mCould not write the checksum for $release_iso\e[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -e "\e[32mSHA256 for $ISO_REF ISO: $iso_sha\e[0m"
|
||||
|
||||
omarchy-iso-upload "$release_iso"
|
||||
|
||||
# The whole point of a release is handing someone this pair.
|
||||
echo -e "\e[32mReleased $ISO_REF ISO $BUILD_VERSION\e[0m"
|
||||
echo " URL: https://iso.omarchy.org/${release_iso##*/}"
|
||||
echo " SHA256: $iso_sha"
|
||||
echo " Local: $release_iso"
|
||||
+192
-70
@@ -13,14 +13,19 @@
|
||||
#
|
||||
# Usage: omarchy-iso-test [release/omarchy.iso] [options]
|
||||
#
|
||||
# --keyboard LABEL English (US/UK), Japanese, Japanese (US keyboard), Korean,
|
||||
# or Chinese (Simplified, Pinyin)/(Traditional, Zhuyin).
|
||||
# OMARCHY_PATH must be the source tree used to build the ISO.
|
||||
# --encrypt Drive the encrypted install flow (default: unencrypted)
|
||||
# --language LANG Pick en (default) or zh; zh keeps the preselected Pinyin keyboard
|
||||
# --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
|
||||
# --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 suite into the guest before running
|
||||
# (defaults to OMARCHY_PATH when the suite is present)
|
||||
# --sync-all DIR Push all of DIR (bin/config/shell/test) into the guest
|
||||
# and also run the CLI and shell suites there
|
||||
# --port PORT Host port forwarded to guest SSH (default 2222)
|
||||
# --memory MB VM memory (default 8192)
|
||||
# --timeout SECS Install timeout (default 2400)
|
||||
@@ -38,6 +43,7 @@ MEMORY=8192
|
||||
INSTALL_TIMEOUT=2400
|
||||
BOOT_TIMEOUT=600
|
||||
ENCRYPT=false
|
||||
INSTALL_LANGUAGE=en
|
||||
PROVISION=false
|
||||
REUSE_BASE=false
|
||||
INSTALL_ONLY=false
|
||||
@@ -46,13 +52,16 @@ NO_PREVIEW=false
|
||||
SYNC_DIR=""
|
||||
SYNC_ALL=false
|
||||
|
||||
KEYBOARD_LABEL="English (US)"
|
||||
GUEST_USER="omarchy"
|
||||
GUEST_PASSWORD="omarchy"
|
||||
GUEST_HOSTNAME="omarchy-test"
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--keyboard) KEYBOARD_LABEL="$2"; shift ;;
|
||||
--encrypt) ENCRYPT=true ;;
|
||||
--language) INSTALL_LANGUAGE="$2"; shift ;;
|
||||
--provision) PROVISION=true ;;
|
||||
--reuse-base) REUSE_BASE=true ;;
|
||||
--install-only) INSTALL_ONLY=true ;;
|
||||
@@ -83,13 +92,42 @@ if [[ ! -f ${ISO:-} ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng
|
||||
if [[ $INSTALL_LANGUAGE != "en" && $INSTALL_LANGUAGE != "zh" ]]; then
|
||||
echo "--language must be en or zh, not $INSTALL_LANGUAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $INSTALL_LANGUAGE == "zh" && $KEYBOARD_LABEL != "English (US)" ]]; then
|
||||
echo "--language zh takes the preselected Pinyin keyboard; drop --keyboard" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$KEYBOARD_LABEL" in
|
||||
"English (US)" | "English (UK)" | "Japanese" | "Japanese (US keyboard)" | "Korean" | "Chinese (Simplified, Pinyin)" | "Chinese (Traditional, Zhuyin)") ;;
|
||||
*) echo "The VM harness cannot type credentials with keyboard: $KEYBOARD_LABEL" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng tesseract-data-chi_sim
|
||||
|
||||
# After the language screen a Chinese install reads in Chinese.
|
||||
OCR_LANGS=eng
|
||||
[[ $INSTALL_LANGUAGE == "zh" ]] && OCR_LANGS=eng+chi_sim
|
||||
# lang_text <english> <chinese> picks the text a screen shows in this run.
|
||||
lang_text() {
|
||||
if [[ $INSTALL_LANGUAGE == "zh" ]]; then
|
||||
printf '%s' "$2"
|
||||
else
|
||||
printf '%s' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
|
||||
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
|
||||
|
||||
BASE_NAME="$(basename "$ISO" .iso)"
|
||||
if [[ $KEYBOARD_LABEL != "English (US)" ]]; then
|
||||
BASE_NAME+="-$(printf '%s' "$KEYBOARD_LABEL" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g')"
|
||||
fi
|
||||
$ENCRYPT && BASE_NAME+="-encrypted"
|
||||
[[ $INSTALL_LANGUAGE == "zh" ]] && BASE_NAME+="-zh"
|
||||
$PROVISION && BASE_NAME+="-provision"
|
||||
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
|
||||
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
|
||||
@@ -201,6 +239,11 @@ start_vm() {
|
||||
local disk="$1" serial="$2"
|
||||
shift 2
|
||||
|
||||
# A viewer that resizes the guest display breaks the console driving: OCR
|
||||
# cannot read the shrunken font, and a resize during early boot can wedge
|
||||
# virtio-gpu before the system comes up.
|
||||
log "Watch (do not resize): vncviewer -RemoteResize=0 127.0.0.1:5905"
|
||||
|
||||
qemu-system-x86_64 \
|
||||
-cpu host -enable-kvm -machine q35,accel=kvm \
|
||||
-smp "$(nproc)" \
|
||||
@@ -211,6 +254,7 @@ start_vm() {
|
||||
-device virtio-blk-pci,drive=drive0,bootindex=1 \
|
||||
-device virtio-vga \
|
||||
-display none \
|
||||
-vnc 127.0.0.1:5 \
|
||||
-usb -device usb-tablet \
|
||||
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \
|
||||
-device virtio-net-pci,netdev=net0 \
|
||||
@@ -233,7 +277,13 @@ ocr_screen() {
|
||||
screendump "$shot"
|
||||
[[ -s $shot ]] || return 0
|
||||
magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0
|
||||
tesseract "$prepped" - --psm 6 2>/dev/null || true
|
||||
# The logo throws tesseract off CJK, so Chinese runs also read below it, without spaces.
|
||||
if [[ $INSTALL_LANGUAGE == "zh" ]]; then
|
||||
magick "$shot" -gravity north -chop 0x35% -colorspace gray -negate -resize 150% "$prepped.text.png" 2>/dev/null || return 0
|
||||
{ tesseract "$prepped" - --psm 6 -l "$OCR_LANGS"; tesseract "$prepped.text.png" - --psm 6 -l "$OCR_LANGS"; } 2>/dev/null | sed 'p; s/ //g' || true
|
||||
else
|
||||
tesseract "$prepped" - --psm 6 -l "$OCR_LANGS" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait until the console shows the given text. Saves the offending screen as
|
||||
@@ -400,7 +450,7 @@ guest_layer_absent() {
|
||||
}
|
||||
|
||||
cleanup_shortcut_state() {
|
||||
ssh_session 'for plugin in omarchy.launcher omarchy.menu omarchy.emojis omarchy.clipboard omarchy.audio; do
|
||||
ssh_session 'for plugin in omarchy.menu omarchy.emojis omarchy.clipboard omarchy.audio; do
|
||||
omarchy-shell shell hide "$plugin" >/dev/null 2>&1 || true
|
||||
done
|
||||
while read -r address; do
|
||||
@@ -577,8 +627,8 @@ shortcut_smoke_phase() {
|
||||
|
||||
local status=0
|
||||
local name chord namespace plugin
|
||||
local shortcuts='launcher|meta_l-spc|omarchy-launcher|omarchy.launcher
|
||||
menu|meta_l-alt-spc|omarchy-menu|omarchy.menu
|
||||
local shortcuts='menu|meta_l-spc|omarchy-menu|omarchy.menu
|
||||
apps-menu|meta_l-alt-spc|omarchy-menu|omarchy.menu
|
||||
emojis|meta_l-ctrl-e|omarchy-emojis|omarchy.emojis
|
||||
clipboard|meta_l-ctrl-v|omarchy-clipboard|omarchy.clipboard
|
||||
audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
|
||||
@@ -603,35 +653,73 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
|
||||
|
||||
# ----------------------------------------------------- driving the installer
|
||||
|
||||
select_keyboard() {
|
||||
local label="$1" index direction="down"
|
||||
# gum choose uses arrows, not a searchable text field. Read the same ordered
|
||||
# choices the ISO vendors from the runtime rather than hard-code row numbers.
|
||||
index=$(OMARCHY_TEST_KEYBOARD="$label" python - <<'PYTHON'
|
||||
import os, re
|
||||
from pathlib import Path
|
||||
source = Path(os.environ.get("OMARCHY_PATH", "")) / "install/provisioning/setup-form.sh"
|
||||
if not source.is_file():
|
||||
raise SystemExit("Set OMARCHY_PATH to the source tree used to build this ISO")
|
||||
text = source.read_text()
|
||||
rows = re.search(r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", text, re.S).group(1).splitlines()
|
||||
labels = [row.split("|")[0] for row in rows]
|
||||
label = os.environ["OMARCHY_TEST_KEYBOARD"]
|
||||
if label not in labels:
|
||||
raise SystemExit(f"Keyboard choice is absent from the ISO source: {label}")
|
||||
print(labels.index(label) - labels.index("English (US)"))
|
||||
PYTHON
|
||||
) || return 1
|
||||
if (( index < 0 )); then
|
||||
direction="up"
|
||||
index=$((-index))
|
||||
fi
|
||||
for (( i = 0; i < index; i++ )); do
|
||||
press "$direction"
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
drive_configurator() {
|
||||
log "Driving the installer wizard"
|
||||
|
||||
# A greeter (animated logo + tagline) opens the installer; Return begins.
|
||||
wait_for_screen "Opinionated" 300
|
||||
wait_for_screen "Linux by DHH" 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
|
||||
# Language comes first: English on top, Chinese below.
|
||||
wait_for_screen "Select language" 300
|
||||
capture_console "success-installer-01-language"
|
||||
if [[ $INSTALL_LANGUAGE == "zh" ]]; then press end; else press home; fi
|
||||
press ret
|
||||
|
||||
# The keyboard follows, for Chinese in cage + foot; Ctrl+C there defers provisioning.
|
||||
wait_for_screen "$(lang_text "keyboard layout" "键盘布局")" 300
|
||||
capture_console "success-installer-01-keyboard-layout"
|
||||
|
||||
if $PROVISION; then
|
||||
press ctrl-c # arm "prepare for another owner"
|
||||
wait_for_screen "another owner" 30
|
||||
wait_for_screen "$(lang_text "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
|
||||
wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60
|
||||
capture_console "success-installer-03-prepare-install-disk"
|
||||
press ret
|
||||
|
||||
wait_for_screen "recovery possible" 60
|
||||
wait_for_screen "$(lang_text "recovery possible" "无法恢复")" 60
|
||||
capture_console "success-installer-04-prepare-disk-warning-encrypted"
|
||||
if ! $ENCRYPT; then
|
||||
press ctrl-c
|
||||
wait_for_screen "without encryption" 30
|
||||
# The toggled confirm ("Yes, install without encryption") is a highlighted
|
||||
# button OCR cannot read, so don't wait for it. A failed toggle still
|
||||
# surfaces: the encrypted install stalls at the LUKS prompt on first boot
|
||||
# and SSH never comes up.
|
||||
sleep 2
|
||||
capture_console "success-installer-05-prepare-disk-warning-unencrypted"
|
||||
fi
|
||||
press ret
|
||||
@@ -640,62 +728,78 @@ drive_configurator() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
press ret # English (US) is preselected
|
||||
if [[ $KEYBOARD_LABEL != "English (US)" ]]; then
|
||||
select_keyboard "$KEYBOARD_LABEL"
|
||||
capture_console "success-installer-01-selected-keyboard"
|
||||
fi
|
||||
press ret
|
||||
|
||||
# Normal flow: user step comes before disk selection.
|
||||
wait_for_screen "Username" 120
|
||||
wait_for_screen "$(lang_text "Username" "用户名")" 120
|
||||
type_text "$GUEST_USER"
|
||||
capture_console "success-installer-02-username"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Password" 60
|
||||
wait_for_screen "$(lang_text "Password" "密码")" 60
|
||||
type_text "$GUEST_PASSWORD"
|
||||
capture_console "success-installer-03-password"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Must match" 60
|
||||
wait_for_screen "$(lang_text "Must match" "确认")" 60
|
||||
type_text "$GUEST_PASSWORD"
|
||||
capture_console "success-installer-04-password-confirmation"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Full name" 60
|
||||
wait_for_screen "$(lang_text "Full name" "姓名")" 60
|
||||
type_text "Omarchy Test"
|
||||
capture_console "success-installer-05-full-name"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Email address" 60
|
||||
type_text "test@omarchy.org"
|
||||
wait_for_screen "$(lang_text "Email address" "邮箱")" 60
|
||||
type_text "test"
|
||||
if [[ $KEYBOARD_LABEL == "Japanese" ]]; then
|
||||
press bracket_left
|
||||
elif [[ $KEYBOARD_LABEL == "English (UK)" ]]; then
|
||||
press shift-apostrophe
|
||||
else
|
||||
press shift-2
|
||||
fi
|
||||
type_text "omarchy.org"
|
||||
capture_console "success-installer-06-email"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Hostname" 60
|
||||
wait_for_screen "$(lang_text "Hostname" "主机名")" 60
|
||||
type_text "$GUEST_HOSTNAME"
|
||||
capture_console "success-installer-07-hostname"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment
|
||||
wait_for_screen "$(lang_text "Timezone" "时区")" 180 # tzupdate geo-guess can take a moment
|
||||
capture_console "success-installer-08-timezone"
|
||||
press ret
|
||||
|
||||
wait_for_screen "look right" 60
|
||||
wait_for_screen "$(lang_text "look right" "正确")" 60
|
||||
capture_console "success-installer-09-review"
|
||||
press ret
|
||||
|
||||
wait_for_screen "install disk" 60
|
||||
wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60
|
||||
capture_console "success-installer-10-install-disk"
|
||||
press ret # the single virtio disk
|
||||
|
||||
wait_for_screen "installation mode\|recovery possible" 60
|
||||
if ocr_screen | grep -qi "installation mode"; then
|
||||
wait_for_screen "$(lang_text "installation mode\|recovery possible" "安装方式\|无法恢复")" 60
|
||||
if ocr_screen | grep -qi "$(lang_text "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 "$(lang_text "recovery possible" "无法恢复")" 60
|
||||
capture_console "success-installer-12-disk-warning-encrypted"
|
||||
if ! $ENCRYPT; then
|
||||
press ctrl-c # toggles the confirm to the unencrypted flow
|
||||
wait_for_screen "without encryption" 30
|
||||
# The toggled confirm ("Yes, install without encryption") is a highlighted
|
||||
# button OCR cannot read, so don't wait for it. A failed toggle still
|
||||
# surfaces: the encrypted install stalls at the LUKS prompt on first boot
|
||||
# and SSH never comes up.
|
||||
sleep 2
|
||||
capture_console "success-installer-13-disk-warning-unencrypted"
|
||||
fi
|
||||
press ret
|
||||
@@ -716,13 +820,17 @@ drive_provision_owner() {
|
||||
# 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
|
||||
wait_for_screen "Linux by DHH" 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"
|
||||
if [[ $KEYBOARD_LABEL == "English (US)" ]]; then
|
||||
press down # preserve the harness's default UK provisioning coverage
|
||||
else
|
||||
select_keyboard "$KEYBOARD_LABEL"
|
||||
fi
|
||||
capture_console "success-provision-01-keyboard-layout"
|
||||
press ret
|
||||
|
||||
wait_for_screen "Username" 120
|
||||
@@ -748,7 +856,13 @@ drive_provision_owner() {
|
||||
# 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
|
||||
if [[ $KEYBOARD_LABEL == "English (US)" || $KEYBOARD_LABEL == "English (UK)" ]]; then
|
||||
press shift-apostrophe
|
||||
elif [[ $KEYBOARD_LABEL == "Japanese" ]]; then
|
||||
press bracket_left
|
||||
else
|
||||
press shift-2
|
||||
fi
|
||||
type_text "omarchy.org"
|
||||
capture_console "success-provision-04-email"
|
||||
press ret
|
||||
@@ -781,7 +895,8 @@ wait_for_install() {
|
||||
while true; do
|
||||
text=$(ocr_screen)
|
||||
|
||||
if grep -qi "Reboot Now" <<<"$text"; then
|
||||
# OCR cannot read the Chinese reboot button, so Chinese runs match the title above it.
|
||||
if grep -qi "$(lang_text "Reboot Now" "安装完成")" <<<"$text"; then
|
||||
log "Install finished. Confirming the reboot prompt."
|
||||
capture_console "success-installer-15-reboot"
|
||||
press ret
|
||||
@@ -791,13 +906,13 @@ wait_for_install() {
|
||||
# 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
|
||||
if $PROVISION && grep -qi "Linux by DHH" <<<"$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 "$(lang_text "installation stopped" "安装已停止")" <<<"$text"; then
|
||||
capture_console "failure-installer-installation-stopped"
|
||||
echo "Install failed — screenshot saved to $RUN_DIR" >&2
|
||||
return 1
|
||||
@@ -875,40 +990,39 @@ EOF
|
||||
(cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) &
|
||||
HTTP_PID=$!
|
||||
|
||||
local waited=0
|
||||
while true; do
|
||||
# Type the login blind: a VNC viewer that resizes the framebuffer makes the
|
||||
# console font unreadable to OCR, and the real gate is SSH coming up anyway.
|
||||
# Getty re-prompts after a failed login, so a whole-sequence retry recovers
|
||||
# from typing before the prompt was ready.
|
||||
sleep 15 # give first boot time to reach getty
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
press ctrl-alt-f3
|
||||
sleep 8
|
||||
press ret # settle a half-typed prompt from a previous attempt
|
||||
sleep 2
|
||||
type_text "$GUEST_USER"
|
||||
capture_console "success-first-boot-03-console-username"
|
||||
press ret
|
||||
sleep 3
|
||||
type_text "$GUEST_PASSWORD"
|
||||
press ret
|
||||
sleep 4
|
||||
ocr_screen | grep -qi "login:" && break
|
||||
((waited += 8))
|
||||
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"
|
||||
press ret
|
||||
|
||||
if ((waited >= 300)); then
|
||||
capture_console "failure-first-boot-console-timeout"
|
||||
echo "Timed out waiting for a console login prompt" >&2
|
||||
return 1
|
||||
if wait_for_ssh 120 "failure-first-boot-ssh-timeout-$attempt"; then
|
||||
capture_console "success-first-boot-06-bootstrap-complete"
|
||||
kill "$HTTP_PID" 2>/dev/null || true
|
||||
HTTP_PID=""
|
||||
press ctrl-alt-f1
|
||||
return 0
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
|
||||
type_text "$GUEST_USER"
|
||||
capture_console "success-first-boot-03-console-username"
|
||||
press ret
|
||||
wait_for_screen "Password" 60
|
||||
type_text "$GUEST_PASSWORD"
|
||||
capture_console "success-first-boot-04-console-password"
|
||||
press ret
|
||||
sleep 3
|
||||
|
||||
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"
|
||||
press ret
|
||||
|
||||
wait_for_ssh 360 "failure-first-boot-ssh-timeout"
|
||||
capture_console "success-first-boot-06-bootstrap-complete"
|
||||
|
||||
kill "$HTTP_PID" 2>/dev/null || true
|
||||
HTTP_PID=""
|
||||
press ctrl-alt-f1
|
||||
echo "Console bootstrap did not produce SSH access after 3 attempts" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get a freshly booted system to a running Hyprland session, typing at the
|
||||
@@ -1058,17 +1172,14 @@ sync_omarchy() {
|
||||
log "Syncing $SYNC_DIR into the guest"
|
||||
tar -C "$SYNC_DIR" --exclude .git -cf - . | ssh_guest "mkdir -p $target && tar -C $target -xf -"
|
||||
else
|
||||
log "Syncing $SYNC_DIR/test/acceptance into the guest"
|
||||
tar -C "$SYNC_DIR/test" -cf - acceptance acceptance.d | ssh_guest "mkdir -p $target/test && tar -C $target/test -xf -"
|
||||
log "Syncing $SYNC_DIR/test into the guest"
|
||||
tar -C "$SYNC_DIR" -cf - test | ssh_guest "mkdir -p $target && tar -C $target -xf -"
|
||||
fi
|
||||
}
|
||||
|
||||
acceptance_phase() {
|
||||
log "Booting acceptance VM from base image overlay"
|
||||
|
||||
local guest_omarchy_path="/usr/share/omarchy"
|
||||
$SYNC_ALL && guest_omarchy_path=".local/share/omarchy"
|
||||
|
||||
qemu-img create -f qcow2 -b "$BASE_DISK" -F qcow2 "$RUN_DIR/run.qcow2" >/dev/null
|
||||
start_vm "$RUN_DIR/run.qcow2" "$RUN_DIR/serial.log"
|
||||
|
||||
@@ -1078,8 +1189,19 @@ acceptance_phase() {
|
||||
local status=0
|
||||
shortcut_smoke_phase || status=1
|
||||
|
||||
# The synced tree is a full checkout under --sync-all; run the host-style
|
||||
# CLI and shell suites in the guest too before the graphical acceptance
|
||||
# run. They derive their root from their own location in the synced tree.
|
||||
if $SYNC_ALL; then
|
||||
log "Running CLI and shell suites in the guest"
|
||||
ssh_guest "bash .local/share/omarchy/test/all" || status=$?
|
||||
fi
|
||||
|
||||
# The product under test is always the installed tree — VM runs verify the
|
||||
# finished product, never a dev-linked checkout.
|
||||
log "Running acceptance suite"
|
||||
ssh_guest "OMARCHY_PATH=$guest_omarchy_path OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance bash .local/share/omarchy/test/acceptance" || status=$?
|
||||
ssh_guest "OMARCHY_PATH=/usr/share/omarchy OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance \
|
||||
OMARCHY_ACCEPTANCE_SUDO_PASSWORD=$GUEST_PASSWORD bash .local/share/omarchy/test/acceptance" || status=$?
|
||||
|
||||
log "Collecting artifacts into $RUN_DIR"
|
||||
ssh_guest "tar -C /tmp -cf - omarchy-acceptance 2>/dev/null" | tar -C "$RUN_DIR" -xf - || true
|
||||
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stop the VM a `omarchy-iso-test --keep-running` run left behind: graceful
|
||||
# guest poweroff over SSH first, escalating to TERM then KILL. The base image
|
||||
# is untouched either way; only the throwaway run overlay is affected.
|
||||
#
|
||||
# Usage: omarchy-iso-test-stop [--kill] [--port PORT]
|
||||
#
|
||||
# --kill Skip the graceful poweroff and kill the VM immediately
|
||||
# --port PORT Host port forwarded to guest SSH (default 2222)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
GUEST_USER="omarchy"
|
||||
GUEST_PASSWORD="omarchy"
|
||||
SSH_PORT=2222
|
||||
GRACEFUL=true
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--kill) GRACEFUL=false ;;
|
||||
--port) SSH_PORT="$2"; shift ;;
|
||||
-h | --help)
|
||||
sed -n '3,10p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# The newest run dir whose qemu is still alive is the VM to stop.
|
||||
PID=""
|
||||
PIDFILE=""
|
||||
while IFS= read -r candidate; do
|
||||
pid=$(<"$candidate")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
PID="$pid"
|
||||
PIDFILE="$candidate"
|
||||
break
|
||||
fi
|
||||
done < <(ls -t "$REPO_DIR"/test-runs/*/runs/*/qemu.pid 2>/dev/null)
|
||||
|
||||
if [[ -z $PID ]]; then
|
||||
echo "No running omarchy-iso-test VM found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_DIR="$(dirname "$(dirname "$(dirname "$PIDFILE")")")"
|
||||
SSH_KEY="$BASE_DIR/id_ed25519"
|
||||
|
||||
# A harness that is still driving this VM dies with it; make sure that is
|
||||
# what the caller wants before pulling anything down.
|
||||
if [[ -t 0 ]]; then
|
||||
if pgrep -f "bin/omarchy-iso-test .*\.iso" >/dev/null; then
|
||||
echo "Warning: an omarchy-iso-test run appears to be in progress."
|
||||
fi
|
||||
if ! gum confirm "Stop test VM (pid $PID, ${PIDFILE#$REPO_DIR/})?"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Stopping test VM (pid $PID, ${PIDFILE#$REPO_DIR/})"
|
||||
|
||||
if $GRACEFUL && [[ -f $SSH_KEY ]]; then
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
|
||||
-o BatchMode=yes \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=5 \
|
||||
-o LogLevel=ERROR \
|
||||
"$GUEST_USER@127.0.0.1" \
|
||||
"echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1 || true
|
||||
|
||||
waited=0
|
||||
while kill -0 "$PID" 2>/dev/null && ((waited < 15)); do
|
||||
sleep 1
|
||||
((waited += 1))
|
||||
done
|
||||
fi
|
||||
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
kill "$PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
kill -9 "$PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "VM (pid $PID) refused to die." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "VM stopped; port $SSH_PORT is free."
|
||||
@@ -5,11 +5,10 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows.qcow2}"
|
||||
TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows.fd}"
|
||||
DISK_SIZE_GIB=96
|
||||
MIN_FREE_GIB=64
|
||||
RECREATE=0
|
||||
GAP=0
|
||||
ISO=""
|
||||
|
||||
ACTIVE_NBD=""
|
||||
@@ -19,7 +18,7 @@ TEMPORARY_DISK=""
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: omarchy-iso-test-windows-disk [--recreate] [release/omarchy.iso]
|
||||
Usage: omarchy-iso-test-windows-disk [--recreate] [--gap] [release/omarchy.iso]
|
||||
|
||||
Creates or validates a synthetic Windows-style test disk with:
|
||||
- a 512MiB EFI partition containing EFI/Microsoft
|
||||
@@ -29,8 +28,16 @@ Creates or validates a synthetic Windows-style test disk with:
|
||||
This exercises Windows partition detection and preservation; it is not a
|
||||
bootable Windows installation.
|
||||
|
||||
--gap builds the same fixture with a hole in the partition numbering: a third
|
||||
partition is created and then deleted, leaving slot 3 free while slot 4 stays
|
||||
in use. parted fills the lowest free slot, so the installer must create its
|
||||
partitions as 3 and 5 and read those numbers back. The contiguous fixture
|
||||
cannot catch that class of bug, because there "lowest free" and "highest + 1"
|
||||
are the same number.
|
||||
|
||||
Options:
|
||||
--recreate Replace the existing test fixture
|
||||
--gap Build the numbering-hole variant (separate disk image)
|
||||
-h, --help Show this help
|
||||
|
||||
Paths can be overridden with:
|
||||
@@ -45,6 +52,10 @@ while (( $# > 0 )); do
|
||||
RECREATE=1
|
||||
shift
|
||||
;;
|
||||
--gap)
|
||||
GAP=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -66,6 +77,18 @@ while (( $# > 0 )); do
|
||||
esac
|
||||
done
|
||||
|
||||
# Separate images per layout so the two fixtures can coexist and neither has
|
||||
# to be rebuilt to run the other.
|
||||
if (( GAP )); then
|
||||
TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows-gap.qcow2}"
|
||||
TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows-gap.fd}"
|
||||
LAYOUT_LABEL="512MiB Windows ESP + data + deleted slot 3 + data + 64GiB+ free"
|
||||
else
|
||||
TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows.qcow2}"
|
||||
TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows.fd}"
|
||||
LAYOUT_LABEL="512MiB Windows ESP + existing data + 64GiB+ free"
|
||||
fi
|
||||
|
||||
cleanup_mount() {
|
||||
if (( MOUNTED )); then
|
||||
sudo umount "$MOUNT_DIR" >/dev/null 2>&1 || true
|
||||
@@ -149,7 +172,7 @@ largest_free_region() {
|
||||
}
|
||||
|
||||
validate_test_disk() {
|
||||
local part_count pttype free_space_b min_free_b
|
||||
local part_count pttype free_space_b min_free_b want_parts numbers
|
||||
local esp data valid=1
|
||||
|
||||
[[ -f $TEST_DISK ]] || return 1
|
||||
@@ -162,9 +185,17 @@ validate_test_disk() {
|
||||
pttype=$(lsblk -dnro PTTYPE "$ACTIVE_NBD" 2>/dev/null || true)
|
||||
free_space_b=$(largest_free_region "$ACTIVE_NBD")
|
||||
min_free_b=$((MIN_FREE_GIB * 1024 * 1024 * 1024))
|
||||
want_parts=2
|
||||
(( GAP )) && want_parts=3
|
||||
|
||||
[[ $pttype == gpt ]] || valid=0
|
||||
(( part_count == 2 )) || valid=0
|
||||
(( part_count == want_parts )) || valid=0
|
||||
|
||||
# The whole point of the gap fixture: slot 3 free, slot 4 in use.
|
||||
if (( GAP )); then
|
||||
numbers=$(sudo parted -ms "$ACTIVE_NBD" unit B print 2>/dev/null | tail -n +3 | cut -d: -f1 | sort | tr '\n' ' ')
|
||||
[[ $numbers == "1 2 4 " ]] || valid=0
|
||||
fi
|
||||
(( free_space_b >= min_free_b )) || valid=0
|
||||
[[ $(sudo blkid -s TYPE -o value "$esp" 2>/dev/null || true) == vfat ]] || valid=0
|
||||
[[ $(sudo blkid -s LABEL -o value "$esp" 2>/dev/null || true) == WINDOWS_ESP ]] || valid=0
|
||||
@@ -206,6 +237,18 @@ create_test_disk() {
|
||||
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_ESP fat32 1MiB 513MiB
|
||||
sudo parted --script "$ACTIVE_NBD" set 1 esp on
|
||||
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_DATA ext4 513MiB 20GiB
|
||||
|
||||
if (( GAP )); then
|
||||
# Slot 3 is created and deleted, slot 4 stays. parted then fills 3 before
|
||||
# 5, so an installer that assumes "highest + 1" (which would be 5 and 6)
|
||||
# writes to device nodes that do not exist. The deleted region is
|
||||
# deliberately smaller than the trailing free space so the installer still
|
||||
# picks the tail as its largest free region.
|
||||
sudo parted --script "$ACTIVE_NBD" mkpart SCRATCH ext4 20GiB 28GiB
|
||||
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_MORE ext4 28GiB 30GiB
|
||||
sudo parted --script "$ACTIVE_NBD" rm 3
|
||||
fi
|
||||
|
||||
sudo partprobe "$ACTIVE_NBD"
|
||||
sudo udevadm settle
|
||||
|
||||
@@ -281,7 +324,7 @@ cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$TEST_OVMF_VARS"
|
||||
gum style --border rounded --padding "0 1" --border-foreground 6 \
|
||||
"Synthetic Windows disk ready" \
|
||||
"Disk: $TEST_DISK" \
|
||||
"Layout: 512MiB Windows ESP + existing data + 64GiB+ free"
|
||||
"Layout: $LAYOUT_LABEL"
|
||||
|
||||
if gum confirm "Start installation on this disk?"; then
|
||||
ISO="$(select_iso)"
|
||||
|
||||
+19
-3
@@ -10,8 +10,24 @@ if [[ ! -f ~/.config/rclone/rclone.conf ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rclone copy $1 Omarchy:omarchy/ -P
|
||||
# Every copy reports, and one failure fails the upload. Without the tally the
|
||||
# last copy decides the exit status, so a signature that never made it is
|
||||
# hidden by a checksum that did.
|
||||
status=0
|
||||
|
||||
if [[ -f $1.sig ]]; then
|
||||
rclone copy $1.sig Omarchy:omarchy/ -P
|
||||
rclone copy "$1" Omarchy:omarchy/ -P || status=1
|
||||
|
||||
# The signature and the checksum are only worth anything to someone who can
|
||||
# reach them beside the ISO, so they go up in the same run rather than a step
|
||||
# a release can finish without.
|
||||
for sidecar in "$1.sig" "$1.sha256"; do
|
||||
if [[ -f $sidecar ]]; then
|
||||
rclone copy "$sidecar" Omarchy:omarchy/ -P || status=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -f $1.sha256 ]]; then
|
||||
echo "Warning: no $1.sha256 to publish, so nobody can verify this download" >&2
|
||||
fi
|
||||
|
||||
exit $status
|
||||
+67
-12
@@ -5,12 +5,13 @@ set -e
|
||||
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
|
||||
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
|
||||
|
||||
# Quattro, edge, dev, and local-source ISOs install the dev packages explicitly.
|
||||
# Those package recipes track the quattro branch. This avoids relying on
|
||||
# pacman's provides=omarchy resolution and shows the real package names being
|
||||
# tested in the offline mirror and target install.
|
||||
# Edge, dev, and local-source ISOs install the dev packages explicitly. Those
|
||||
# package recipes track the quattro branch. This avoids relying on pacman's
|
||||
# provides=omarchy resolution and shows the real package names being tested in
|
||||
# the offline mirror and target install. Every other ref, the default quattro
|
||||
# build included, installs the published omarchy packages.
|
||||
case "$OMARCHY_ISO_REF" in
|
||||
quattro|edge|dev|local)
|
||||
edge|dev|local)
|
||||
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
|
||||
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
|
||||
;;
|
||||
@@ -40,11 +41,14 @@ pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571
|
||||
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring
|
||||
pacman-key --populate omarchy
|
||||
|
||||
# Append the [omarchy] repo to the container's /etc/pacman.conf so subsequent
|
||||
# tools (notably makepkg in build-omarchy-packages.sh) can resolve omarchy-
|
||||
# only build deps like limine-snapper-sync and limine-mkinitcpio-hook.
|
||||
# Put OPR first in the container too so makepkg resolves the same overrides
|
||||
# as the online installer configuration.
|
||||
if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then
|
||||
awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf >> /etc/pacman.conf
|
||||
opr_section=$(awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf)
|
||||
awk -v opr="$opr_section" '/^\[core\]/ { print opr; print "" } { print }' \
|
||||
/etc/pacman.conf >/tmp/pacman-opr-first.conf
|
||||
install -m 644 /tmp/pacman-opr-first.conf /etc/pacman.conf
|
||||
rm -f /tmp/pacman-opr-first.conf
|
||||
fi
|
||||
|
||||
# Build locations
|
||||
@@ -117,7 +121,8 @@ cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
|
||||
# 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
|
||||
# live initramfs.
|
||||
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted)
|
||||
# cage and foot give Chinese installs a console that draws Chinese.
|
||||
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted cage foot)
|
||||
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
|
||||
@@ -138,6 +143,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
|
||||
# already in the mirror and we filter them out below. Without it, pacman -Syw
|
||||
# pulls the published omarchy* from the network mirror like any other package.
|
||||
if [[ -d /omarchy-source ]]; then
|
||||
regions_source=/omarchy-source/default/regions
|
||||
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
|
||||
@@ -160,6 +166,17 @@ else
|
||||
# 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
|
||||
# Region profiles live under default/, which the settings package ships,
|
||||
# not the runtime. Its own cache dir keeps the runtime lookup above from
|
||||
# ever matching it.
|
||||
settings_cache_dir="$bootstrap_cache_dir/settings"
|
||||
mkdir -p "$settings_cache_dir"
|
||||
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_SETTINGS_PACKAGE" --cachedir "$settings_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
|
||||
regions_source=$(find "$settings_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_SETTINGS_PACKAGE-*.pkg.tar.zst" | sort | head -1)
|
||||
if [[ -z $regions_source ]]; then
|
||||
echo "ERROR: downloaded package for $OMARCHY_SETTINGS_PACKAGE not found in $settings_cache_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh
|
||||
fi
|
||||
|
||||
@@ -186,6 +203,24 @@ if [[ ! -f $setup_form ]]; then
|
||||
fi
|
||||
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
|
||||
|
||||
# Every region's packages ride in the offline mirror, and its community
|
||||
# repositories join a writable copy of the online config to download them.
|
||||
online_config=/tmp/omarchy-pacman-online.conf
|
||||
cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config"
|
||||
bash /builder/prepare-regions.sh "$regions_source" \
|
||||
"$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config"
|
||||
|
||||
# Profiles ship in the settings package, but the finalizer that applies them
|
||||
# ships in the runtime. A runtime without it would ignore the region marker,
|
||||
# leaving regional targets with the keyring but none of the repositories.
|
||||
if [[ -n ${omarchy_pkg:-} && -d $build_cache_dir/airootfs/usr/share/omarchy-iso/regions ]]; then
|
||||
runtime_listing=$(bsdtar -tf "$omarchy_pkg")
|
||||
if ! grep -qx 'usr/bin/omarchy-apply-pacman' <<<"$runtime_listing"; then
|
||||
echo "ERROR: $OMARCHY_SETTINGS_PACKAGE ships region profiles, but $OMARCHY_RUNTIME_PACKAGE lacks omarchy-apply-pacman to apply them." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Collect every package we want available in the offline mirror.
|
||||
declare -a all_packages
|
||||
mapfile -t all_packages < <(
|
||||
@@ -193,12 +228,32 @@ mapfile -t all_packages < <(
|
||||
cat "$build_cache_dir/packages.x86_64"
|
||||
grep -hv '^#\|^$' "${base_pkg_lists[@]}"
|
||||
grep -hv '^#\|^$' /builder/archinstall.packages
|
||||
# Downloaded but not installed: the installer adds a region's packages
|
||||
# only to targets in that region.
|
||||
grep -hsv '^#\|^$' "$build_cache_dir"/airootfs/usr/share/omarchy-iso/regions/*/packages || true
|
||||
# Always include the selected Omarchy packages so the target install can
|
||||
# find the runtime and companion packages in the offline mirror.
|
||||
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"
|
||||
} | sort -u
|
||||
)
|
||||
|
||||
# Arch dropped the prebuilt broadcom-wl on 2026-09-02 and rebuilt broadcom-wl-dkms
|
||||
# with replaces=(broadcom-wl). A replaces entry only helps upgrades of an already
|
||||
# installed package; as an explicit pacman target the old name now fails with
|
||||
# "target not found". Published Omarchy runtime packages that predate the rename
|
||||
# still list it in omarchy-other.packages, so map it here until every channel
|
||||
# ships a runtime that names broadcom-wl-dkms itself.
|
||||
# arch-mact2 dropped apple-bcm-firmware on 2026-09-16 in favour of
|
||||
# apple-bcm-firmware-fetcher, which does the same job (pull the T2 Wi-Fi and
|
||||
# Bluetooth firmware off the macOS volume) but only conflicts with the old name
|
||||
# rather than replacing it, so pacman cannot follow the rename on its own.
|
||||
mapfile -t all_packages < <(
|
||||
printf '%s\n' "${all_packages[@]}" |
|
||||
sed -e 's/^broadcom-wl$/broadcom-wl-dkms/' \
|
||||
-e 's/^apple-bcm-firmware$/apple-bcm-firmware-fetcher/' |
|
||||
sort -u
|
||||
)
|
||||
|
||||
# With --local-source we already built these omarchy* packages directly into
|
||||
# the mirror; strip them from the pacman -Syw list so it doesn't try to fetch
|
||||
# the published versions on top.
|
||||
@@ -214,7 +269,7 @@ fi
|
||||
|
||||
mkdir -p /tmp/offlinedb
|
||||
download_offline_packages() {
|
||||
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \
|
||||
pacman --config "$online_config" --noconfirm -Syw \
|
||||
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
|
||||
}
|
||||
|
||||
@@ -232,7 +287,7 @@ fi
|
||||
# newest version of every cached package name) removes packages that have left
|
||||
# the lists or dependency closure, such as an old Electron major version.
|
||||
if ! resolved_package_files="$(
|
||||
pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \
|
||||
pacman --config "$online_config" --noconfirm \
|
||||
--dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}"
|
||||
)"; then
|
||||
echo "ERROR: could not resolve the package files required by the offline mirror" >&2
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ship every region profile from the bundled runtime, so one ISO serves every
|
||||
# region. The installer picks a profile from the target's timezone; this only
|
||||
# stages the profiles and makes their packages downloadable. Repository
|
||||
# defaults stay in the runtime, not in a second set of ISO-specific templates.
|
||||
set -euo pipefail
|
||||
|
||||
regions_source="$1"
|
||||
iso_payload="$2"
|
||||
online_config="$3"
|
||||
|
||||
# The source is a checkout's default/regions, or the published settings
|
||||
# package that ships default/. One predating region profiles ships none; the
|
||||
# ISO then installs global targets only, exactly as before. Anything else that
|
||||
# fails here, such as an unreadable package, aborts the build.
|
||||
regions="$regions_source"
|
||||
if [[ -f $regions_source ]]; then
|
||||
member=usr/share/omarchy/default/regions
|
||||
listing=$(bsdtar -tf "$regions_source")
|
||||
if grep -q "^$member/" <<<"$listing"; then
|
||||
extracted=$(mktemp -d)
|
||||
trap 'rm -rf "$extracted"' EXIT
|
||||
bsdtar -xf "$regions_source" -C "$extracted" "$member"
|
||||
regions="$extracted/$member"
|
||||
fi
|
||||
fi
|
||||
if [[ ! -d $regions ]]; then
|
||||
echo "Runtime ships no region profiles; every install will be global."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp -a "$regions" "$iso_payload/regions"
|
||||
|
||||
for profile in "$regions"/*/; do
|
||||
# Only the community repositories are needed to download regional packages.
|
||||
# Keep the channel's build mirrors intact; target mirrors are set later by
|
||||
# the runtime's own pacman finalizer.
|
||||
printf '\n' >> "$online_config"
|
||||
cat "$profile/pacman/pacman.conf.append" >> "$online_config"
|
||||
|
||||
# Each keyring package must be signed by a key Arch already trusts. Do not
|
||||
# bootstrap trust from an unchecked keyserver response or disable signatures.
|
||||
# https://github.com/archlinuxcn/repo#usage
|
||||
mapfile -t keyring_packages < <(grep -hv '^#\|^$' "$profile/packages" | grep -- '-keyring$' || true)
|
||||
if (( ${#keyring_packages[@]} > 0 )); then
|
||||
pacman --config "$online_config" --noconfirm -Sy --needed "${keyring_packages[@]}"
|
||||
pacman-key --populate "${keyring_packages[@]%-keyring}"
|
||||
fi
|
||||
done
|
||||
@@ -10,7 +10,10 @@
|
||||
# - COLUMNS/LINES so gum picks up real terminal size
|
||||
set -euo pipefail
|
||||
|
||||
[[ $(tty) == /dev/tty1 ]] || exit 0
|
||||
[[ $(tty) == /dev/tty1 || -n ${OMARCHY_CJK_CONSOLE:-} ]] || exit 0
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
touch "$XDG_RUNTIME_DIR/cjk-console-started"
|
||||
fi
|
||||
|
||||
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
|
||||
if [[ -f /root/omarchy_iso_ref ]]; then
|
||||
@@ -39,7 +42,8 @@ set_tokyo_night_colors() {
|
||||
echo -en "\033[0m"
|
||||
clear
|
||||
}
|
||||
set_tokyo_night_colors
|
||||
# foot sets the same palette from foot.ini; these escapes are VT-only.
|
||||
[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] || set_tokyo_night_colors
|
||||
|
||||
mkdir -p /var/log
|
||||
touch "$OMARCHY_INSTALL_LOG_FILE"
|
||||
@@ -90,6 +94,16 @@ warm_offline_mirror &
|
||||
warm_pid=$!
|
||||
trap 'kill "$warm_pid" 2>/dev/null' EXIT
|
||||
|
||||
# The CJK console's font comes from the offline mirror's noto-fonts-cjk, not the live image.
|
||||
unpack_cjk_font() {
|
||||
local packages=(/var/cache/omarchy/mirror/offline/noto-fonts-cjk-*.pkg.tar.zst)
|
||||
if ((${#packages[@]} != 1)) || [[ ! -f ${packages[0]} ]]; then
|
||||
echo "Expected one noto-fonts-cjk package in the offline mirror, found: ${packages[*]}" >&2
|
||||
return 1
|
||||
fi
|
||||
bsdtar -xf "${packages[0]}" -C / usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc
|
||||
}
|
||||
|
||||
cd /root
|
||||
# Autoinstall: a cidata drive carrying the configurator's own output files
|
||||
# stands in for the wizard. omarchy-cidata-load copies them into /root and
|
||||
@@ -98,7 +112,43 @@ 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
|
||||
# Chinese moves to cage + foot, which draw Chinese; English moves back to the VT, which applies keymaps.
|
||||
cjk_relaunch=10
|
||||
vt_return=11
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
export OMARCHY_VT_RETURN=$vt_return
|
||||
elif command -v cage >/dev/null; then
|
||||
export OMARCHY_CJK_RELAUNCH=$cjk_relaunch
|
||||
fi
|
||||
./configurator && status=0 || status=$?
|
||||
while ((status == cjk_relaunch)); do
|
||||
cjk_runtime=/run/omarchy-cjk-console
|
||||
mkdir -p -m 700 "$cjk_runtime"
|
||||
rm -f "$cjk_runtime/cjk-console-started" "$cjk_runtime/return-to-vt"
|
||||
if ! unpack_cjk_font; then
|
||||
echo "The Chinese font could not be unpacked from the offline mirror; continuing in English."
|
||||
unset OMARCHY_CJK_RELAUNCH
|
||||
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
|
||||
continue
|
||||
fi
|
||||
cage_status=0
|
||||
env -u OMARCHY_CJK_RELAUNCH XDG_RUNTIME_DIR=$cjk_runtime OMARCHY_CJK_CONSOLE=1 OMARCHY_INSTALL_LANGUAGE=zh \
|
||||
LANG=C.UTF-8 WLR_RENDERER=pixman cage -- foot -c /usr/share/omarchy-iso/foot.ini "$0" || cage_status=$?
|
||||
if [[ -f $cjk_runtime/return-to-vt ]]; then
|
||||
OMARCHY_INSTALL_LANGUAGE=en ./configurator && status=0 || status=$?
|
||||
elif [[ -f $cjk_runtime/cjk-console-started ]]; then
|
||||
exit "$cage_status"
|
||||
else
|
||||
echo "The Chinese console could not start (cage exit $cage_status); continuing in English."
|
||||
unset OMARCHY_CJK_RELAUNCH
|
||||
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
|
||||
fi
|
||||
done
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && ((status == vt_return)); then
|
||||
touch "$XDG_RUNTIME_DIR/return-to-vt"
|
||||
exit 0
|
||||
fi
|
||||
((status == 0)) || exit "$status"
|
||||
fi
|
||||
|
||||
# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on
|
||||
@@ -111,6 +161,11 @@ if [[ -f /root/defer-provisioning ]] ||
|
||||
export OMARCHY_UI_DEFER_PROVISIONING=yes
|
||||
fi
|
||||
|
||||
# Only Chinese reaches the install from the CJK console; English returns to the VT.
|
||||
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
export OMARCHY_INSTALL_LANG=zh
|
||||
fi
|
||||
|
||||
# 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
|
||||
# completion, then renders the final installed-time/reboot prompt itself.
|
||||
|
||||
+296
-120
@@ -24,6 +24,83 @@ if [[ ! -r $SETUP_FORM ]]; then
|
||||
fi
|
||||
source "$SETUP_FORM"
|
||||
|
||||
# Chinese text for OMARCHY_INSTALL_LANG=zh; disk-step failures stay English for support logs.
|
||||
OMARCHY_ZH+=(
|
||||
["Aborted installation"]="安装已中止"
|
||||
["You can retry later by running: ./.automated_script.sh"]="稍后可运行 ./.automated_script.sh 重试"
|
||||
["Let's setup your machine..."]="开始设置这台机器……"
|
||||
["Press Ctrl+C to prepare this machine for another owner."]="按 Ctrl+C 为另一位使用者准备这台机器。"
|
||||
["This prepares the machine for another owner."]="这会为另一位使用者准备这台机器。"
|
||||
["The system installs now, but setup is delayed until first boot."]="系统现在安装,设置留到首次开机。"
|
||||
["Yes, prepare for another owner"]="是,为他人准备"
|
||||
["No, keep setting up"]="否,继续设置"
|
||||
["Prepare this machine for another owner?"]="为另一位使用者准备这台机器?"
|
||||
["Let's setup your user account..."]="开始设置用户账户……"
|
||||
["Field"]="项目"
|
||||
["Value"]="值"
|
||||
["Username"]="用户名"
|
||||
["Password"]="密码"
|
||||
["Full name"]="姓名"
|
||||
["Email address"]="邮箱"
|
||||
["Hostname"]="主机名"
|
||||
["Keyboard"]="键盘"
|
||||
["Region"]="区域"
|
||||
["[Skipped]"]="[已跳过]"
|
||||
["Yes"]="是"
|
||||
["No, change it"]="否,去修改"
|
||||
["Does this look right?"]="以上信息正确吗?"
|
||||
["Not enough free space on %s"]="%s 的空闲空间不足"
|
||||
["%s has %s of usable free space; Omarchy needs at least 32GB."]="%s 有 %s 可用空闲空间;Omarchy 至少需要 32GB。"
|
||||
["Open the partition tool to free at least 32GB on %s, then try again."]="打开分区工具,在 %s 上腾出至少 32GB,然后重试。"
|
||||
["Back"]="返回"
|
||||
["Open partition tool"]="打开分区工具"
|
||||
["Return to installation mode?"]="返回安装方式选择?"
|
||||
["Checking for BitLocker on %s"]="检查 %s 上的 BitLocker"
|
||||
["BitLocker signature detected on %s."]="在 %s 上发现 BitLocker 签名。"
|
||||
["Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."]="在 Windows 中关闭 BitLocker,等磁盘解密完成后重试。"
|
||||
["Suspending BitLocker is not enough — the drive stays encrypted."]="只暂停 BitLocker 不够,磁盘仍是加密的。"
|
||||
["Aborted: BitLocker is enabled on this disk."]="已中止:这块磁盘开启了 BitLocker。"
|
||||
["Checking existing EFI partitions on %s"]="检查 %s 上已有的 EFI 分区"
|
||||
["Found a Windows ESP at %s — leaving it untouched."]="在 %s 发现 Windows ESP,保持不动。"
|
||||
["Omarchy will create its own dedicated EFI partition in free space."]="Omarchy 会在空闲空间中创建自己的 EFI 分区。"
|
||||
["A new EFI partition will be created in free space."]="会在空闲空间中新建 EFI 分区。"
|
||||
["Analyzing free space on %s"]="分析 %s 的空闲空间"
|
||||
["Install Omarchy in the %s of free space."]="在 %s 空闲空间中安装 Omarchy。"
|
||||
["Press Ctrl+C for unencrypted install."]="按 Ctrl+C 改为不加密安装。"
|
||||
["Yes, install"]="是,开始安装"
|
||||
["Yes, install without encryption"]="是,不加密安装"
|
||||
["Confirm installing on %s"]="确认安装到 %s"
|
||||
["Creating partitions on %s"]="在 %s 上创建分区"
|
||||
["Setting up LUKS2 on %s"]="在 %s 上设置 LUKS2 加密"
|
||||
["Creating Btrfs filesystem and subvolumes"]="创建 Btrfs 文件系统和子卷"
|
||||
["Let's select where to install Omarchy..."]="选择 Omarchy 的安装位置……"
|
||||
["Select install disk"]="选择安装磁盘"
|
||||
["Partition tool for %s"]="%s 的分区工具"
|
||||
["Create unallocated free space for Omarchy, then write changes and quit."]="为 Omarchy 腾出未分配空间,然后写入更改并退出。"
|
||||
["Do not create an Omarchy partition here — leave the target area as Free space."]="不要在这里创建 Omarchy 分区,目标区域保持为 Free space。"
|
||||
["Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."]="提示:空闲空间安装至少需要 32GB 未分配空间;没有 ESP 时再加 2GB。"
|
||||
["Open cfdisk"]="打开 cfdisk"
|
||||
["Open partition tool for %s?"]="打开 %s 的分区工具?"
|
||||
["Full disk install"]="整盘安装"
|
||||
["Free space install (alongside existing data)"]="空闲空间安装(保留现有数据)"
|
||||
["Choose a different disk"]="换一块磁盘"
|
||||
["Let's select how to install Omarchy..."]="选择 Omarchy 的安装方式……"
|
||||
["Select installation mode on %s"]="选择 %s 上的安装方式"
|
||||
["Everything will be overwritten. There is no recovery possible."]="磁盘上的一切都会被覆盖,无法恢复。"
|
||||
["Confirm overwriting %s"]="确认覆盖 %s"
|
||||
)
|
||||
|
||||
# Partition creation helpers, shared with test/partition-numbering-test.sh so
|
||||
# the numbering logic can be exercised without a real disk.
|
||||
DISK_PARTITIONING="${DISK_PARTITIONING:-/usr/share/omarchy-iso/disk-partitioning.sh}"
|
||||
[[ -r $DISK_PARTITIONING ]] ||
|
||||
DISK_PARTITIONING="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../usr/share/omarchy-iso/disk-partitioning.sh"
|
||||
if [[ ! -r $DISK_PARTITIONING ]]; then
|
||||
echo "ERROR: disk partitioning helpers not found at $DISK_PARTITIONING" >&2
|
||||
exit 1
|
||||
fi
|
||||
source "$DISK_PARTITIONING"
|
||||
|
||||
LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH")
|
||||
|
||||
export GUM_CONFIRM_PROMPT_FOREGROUND="6"
|
||||
@@ -111,7 +188,7 @@ greeter() {
|
||||
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"
|
||||
tagline="Beautiful, Fun & Agentic Linux by DHH"
|
||||
tpad=$(((cols - ${#tagline}) / 2)); (( tpad < 0 )) && tpad=0
|
||||
printf '\033[%d;%dH%s' "$tagline_row" "$((tpad + 1))" "$tagline"
|
||||
|
||||
@@ -165,9 +242,9 @@ greeter() {
|
||||
}
|
||||
|
||||
abort() {
|
||||
gum style "${1:-Aborted installation}"
|
||||
gum style "${1:-$(omarchy_text "Aborted installation")}"
|
||||
echo
|
||||
gum style "You can retry later by running: ./.automated_script.sh"
|
||||
gum style "$(omarchy_text "You can retry later by running: ./.automated_script.sh")"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -192,6 +269,30 @@ notice() {
|
||||
|
||||
# STEP 1: KEYBOARD LAYOUT
|
||||
|
||||
# The target's LANG follows the language picked on the first screen.
|
||||
target_lang() {
|
||||
if [[ $language == "zh" ]]; then
|
||||
echo zh_CN.UTF-8
|
||||
else
|
||||
echo en_US.UTF-8
|
||||
fi
|
||||
}
|
||||
|
||||
# .automated_script.sh moves Chinese into the CJK console and English back to the VT, which applies keymaps.
|
||||
apply_language() {
|
||||
if [[ $language == "zh" && -n ${OMARCHY_CJK_RELAUNCH:-} ]]; then
|
||||
exit "$OMARCHY_CJK_RELAUNCH"
|
||||
fi
|
||||
if [[ $language == "en" && -n ${OMARCHY_VT_RETURN:-} ]]; then
|
||||
exit "$OMARCHY_VT_RETURN"
|
||||
fi
|
||||
if [[ $language == "zh" && -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||
export OMARCHY_INSTALL_LANG=zh
|
||||
else
|
||||
unset OMARCHY_INSTALL_LANG
|
||||
fi
|
||||
}
|
||||
|
||||
keyboard_form() {
|
||||
# Ctrl+C is the hidden entry into deferred provisioning, offered on every
|
||||
# visit: the user step unwinds back here and nothing is on disk yet. Callers
|
||||
@@ -201,11 +302,24 @@ keyboard_form() {
|
||||
while true; do
|
||||
clear_logo
|
||||
echo
|
||||
say "Let's setup your machine..."
|
||||
say --foreground 8 "Press Ctrl+C to prepare this machine for another owner."
|
||||
say "$(omarchy_text "Let's setup your machine...")"
|
||||
say --foreground 8 "$(omarchy_text "Press Ctrl+C to prepare this machine for another owner.")"
|
||||
echo
|
||||
|
||||
omarchy_prompt_keyboard && status=0 || status=$?
|
||||
status=0
|
||||
if ! $language_chosen; then
|
||||
omarchy_prompt_language && status=0 || status=$?
|
||||
((status == 0)) && apply_language
|
||||
fi
|
||||
# Esc, Ctrl+C or a later "No, change it" asks the language again.
|
||||
language_chosen=false
|
||||
if ((status == 0)); then
|
||||
local layouts=$OMARCHY_KEYBOARD_LAYOUTS selected="English (US)"
|
||||
[[ $language == "zh" ]] && selected="Chinese (Simplified, Pinyin)"
|
||||
# cage keeps its US layout, so the CJK console offers only US-keymap keyboards.
|
||||
[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && layouts=$(awk -F'|' '$2 == "us"' <<<"$layouts")
|
||||
OMARCHY_KEYBOARD_LAYOUTS=$layouts omarchy_prompt_keyboard "$selected" && status=0 || status=$?
|
||||
fi
|
||||
((status == 0)) && break
|
||||
|
||||
# Esc means "back", and nothing precedes the first screen, so re-ask.
|
||||
@@ -234,11 +348,11 @@ keyboard_form() {
|
||||
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."
|
||||
say "$(omarchy_text "This prepares the machine for another owner.")"
|
||||
say --foreground 8 "$(omarchy_text "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?"
|
||||
gum confirm --affirmative "$(omarchy_text "Yes, prepare for another owner")" --negative "$(omarchy_text "No, keep setting up")" \
|
||||
"$(omarchy_text "Prepare this machine for another owner?")"
|
||||
}
|
||||
|
||||
# The user step. Deferred-provisioning installs skip it entirely — the machine's first owner
|
||||
@@ -246,7 +360,7 @@ confirm_prepare_for_another_owner() {
|
||||
# operator sets nothing user-specific.
|
||||
|
||||
user_form() {
|
||||
step "Let's setup your user account..."
|
||||
step "$(omarchy_text "Let's setup your user account...")"
|
||||
|
||||
# Each prompt reports 0 (set), OMARCHY_FORM_BACK (Esc), or OMARCHY_FORM_SIGNAL
|
||||
# (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they
|
||||
@@ -279,19 +393,29 @@ user_step() {
|
||||
continue
|
||||
fi
|
||||
|
||||
# The timezone selects the region (mirrors and repositories), using the
|
||||
# same lookup as the orchestrator. Global installs get no extra row.
|
||||
region=$(cd /usr/share/omarchy-iso 2>/dev/null && python -m orchestrator.region "$timezone") || region="global"
|
||||
region_row=""
|
||||
if [[ $region != "global" ]]; then
|
||||
region_row="\n$(omarchy_text Region),$region"
|
||||
fi
|
||||
|
||||
# Add manual padding since gum table -p doesn't respect padding
|
||||
echo -e "Field,Value
|
||||
Username,$username
|
||||
Password,$(printf "%${#password}s" | tr ' ' '*')
|
||||
Full name,${full_name:-[Skipped]}
|
||||
Email address,${email_address:-[Skipped]}
|
||||
Hostname,$hostname
|
||||
Timezone,$timezone
|
||||
Keyboard,$keyboard" |
|
||||
local skipped
|
||||
skipped=$(omarchy_text "[Skipped]")
|
||||
echo -e "$(omarchy_text Field),$(omarchy_text Value)
|
||||
$(omarchy_text Username),$username
|
||||
$(omarchy_text Password),$(printf "%${#password}s" | tr ' ' '*')
|
||||
$(omarchy_text "Full name"),${full_name:-$skipped}
|
||||
$(omarchy_text "Email address"),${email_address:-$skipped}
|
||||
$(omarchy_text Hostname),$hostname
|
||||
$(omarchy_text Timezone),$timezone$region_row
|
||||
$(omarchy_text Keyboard),\"$keyboard_label\"" |
|
||||
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
|
||||
|
||||
echo
|
||||
if gum confirm --negative "No, change it" "Does this look right?"; then
|
||||
if gum confirm --affirmative "$(omarchy_text "Yes")" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Does this look right?")"; then
|
||||
break
|
||||
else
|
||||
keyboard_form
|
||||
@@ -361,15 +485,7 @@ get_root_disk() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Compute partition device path, handling NVMe/mmcblk's pN naming.
|
||||
partition_path() {
|
||||
local _disk="$1" _num="$2"
|
||||
if [[ "$_disk" == *nvme* || "$_disk" == *mmcblk* ]]; then
|
||||
echo "${_disk}p${_num}"
|
||||
else
|
||||
echo "${_disk}${_num}"
|
||||
fi
|
||||
}
|
||||
# partition_path lives in disk-partitioning.sh alongside the creation helpers.
|
||||
|
||||
# BitLocker writes the "-FVE-FS-" signature at offset 3 of the partition.
|
||||
detect_bitlocker() {
|
||||
@@ -412,17 +528,24 @@ detect_kernel() {
|
||||
if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then
|
||||
echo "linux-t2"
|
||||
else
|
||||
echo "linux"
|
||||
echo "linux-omarchy"
|
||||
fi
|
||||
}
|
||||
|
||||
# Give udev a moment to create a device node after partitioning.
|
||||
wait_for_device() {
|
||||
local i
|
||||
for i in 1 2 3 4 5; do
|
||||
[[ -b "$1" ]] && return 0
|
||||
sleep 1
|
||||
done
|
||||
# wait_for_device lives in disk-partitioning.sh: it now reports failure, and
|
||||
# every caller here aborts on it.
|
||||
|
||||
# disk_step and create_partition fail through this rather than abort() so a
|
||||
# half-built layout is undone before the user is handed back their disk.
|
||||
disk_abort_hook() {
|
||||
# Release the target before reclaiming its partitions: a mounted filesystem
|
||||
# or an open LUKS mapping would keep parted from removing them.
|
||||
umount -R /mnt 2>/dev/null || true
|
||||
umount /mnt/btrfs-root 2>/dev/null || true
|
||||
rmdir /mnt/btrfs-root 2>/dev/null || true
|
||||
cryptsetup close omarchy_root 2>/dev/null || true
|
||||
rollback_created_parts "$disk"
|
||||
abort "$1"
|
||||
}
|
||||
|
||||
# Save the configurator answers the installer consumes: git identity and
|
||||
@@ -494,11 +617,11 @@ print_dry_run_files() {
|
||||
# action so users have a way to actually fix the situation.
|
||||
not_enough_space() {
|
||||
local available="${1:-0}"
|
||||
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."
|
||||
step "$(omarchy_text "Not enough free space on %s" "$disk")"
|
||||
say --foreground 1 "$(omarchy_text "%s has %s of usable free space; Omarchy needs at least 32GB." "$disk" "$(to_gb $available)")"
|
||||
say "$(omarchy_text "Open the partition tool to free at least 32GB on %s, then try again." "$disk")"
|
||||
echo
|
||||
if ! gum confirm --affirmative "Back" --negative "Open partition tool" "Return to installation mode?"; then
|
||||
if ! gum confirm --affirmative "$(omarchy_text "Back")" --negative "$(omarchy_text "Open partition tool")" "$(omarchy_text "Return to installation mode?")"; then
|
||||
open_partition_tool
|
||||
fi
|
||||
return 1
|
||||
@@ -508,14 +631,14 @@ not_enough_space() {
|
||||
# encryption confirm. Sets the globals run_partition_execute needs. Returns 1
|
||||
# to fall back to the install-mode picker.
|
||||
run_partition_decide() {
|
||||
step "Checking for BitLocker on $disk"
|
||||
step "$(omarchy_text "Checking for BitLocker on %s" "$disk")"
|
||||
local bl_part
|
||||
bl_part=$(detect_bitlocker || true)
|
||||
if [[ -n "$bl_part" ]]; then
|
||||
say --foreground 1 "BitLocker signature detected on $bl_part."
|
||||
say "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."
|
||||
say "Suspending BitLocker is not enough — the drive stays encrypted."
|
||||
abort "Aborted: BitLocker is enabled on this disk."
|
||||
say --foreground 1 "$(omarchy_text "BitLocker signature detected on %s." "$bl_part")"
|
||||
say "$(omarchy_text "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again.")"
|
||||
say "$(omarchy_text "Suspending BitLocker is not enough — the drive stays encrypted.")"
|
||||
abort "$(omarchy_text "Aborted: BitLocker is enabled on this disk.")"
|
||||
fi
|
||||
|
||||
# Omarchy always creates its own dedicated ESP in free space and never
|
||||
@@ -524,17 +647,17 @@ run_partition_decide() {
|
||||
# Windows ESP is far too small for our Unified Kernel Images, and a shared
|
||||
# ESP forces the install unencrypted. A separate Linux ESP keeps Windows
|
||||
# and Omarchy fully isolated and keeps LUKS on the table.
|
||||
step "Checking existing EFI partitions on $disk"
|
||||
step "$(omarchy_text "Checking existing EFI partitions on %s" "$disk")"
|
||||
local detected_win_esp
|
||||
detected_win_esp=$(detect_windows_esp || true)
|
||||
if [[ -n "$detected_win_esp" ]]; then
|
||||
say "Found a Windows ESP at $detected_win_esp — leaving it untouched."
|
||||
say "Omarchy will create its own dedicated EFI partition in free space."
|
||||
say "$(omarchy_text "Found a Windows ESP at %s — leaving it untouched." "$detected_win_esp")"
|
||||
say "$(omarchy_text "Omarchy will create its own dedicated EFI partition in free space.")"
|
||||
else
|
||||
say "A new EFI partition will be created in free space."
|
||||
say "$(omarchy_text "A new EFI partition will be created in free space.")"
|
||||
fi
|
||||
|
||||
step "Analyzing free space on $disk"
|
||||
step "$(omarchy_text "Analyzing free space on %s" "$disk")"
|
||||
partprobe "$disk" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
@@ -609,21 +732,21 @@ run_partition_decide() {
|
||||
while true; do
|
||||
clear_logo
|
||||
echo
|
||||
say "Install Omarchy in the $(to_gb $INSTALL_MAX_B) of free space."
|
||||
say "$(omarchy_text "Install Omarchy in the %s of free space." "$(to_gb $INSTALL_MAX_B)")"
|
||||
|
||||
case $mode in
|
||||
encrypted)
|
||||
say --foreground 8 "Press Ctrl+C for unencrypted install."
|
||||
affirmative="Yes, install"
|
||||
say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")"
|
||||
affirmative="$(omarchy_text "Yes, install")"
|
||||
;;
|
||||
unencrypted)
|
||||
affirmative="Yes, install without encryption"
|
||||
affirmative="$(omarchy_text "Yes, install without encryption")"
|
||||
;;
|
||||
esac
|
||||
echo
|
||||
|
||||
gum confirm --affirmative "$affirmative" --negative "No, change it" \
|
||||
"Confirm installing on $disk"
|
||||
gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" \
|
||||
"$(omarchy_text "Confirm installing on %s" "$disk")"
|
||||
confirm_status=$?
|
||||
|
||||
case $confirm_status in
|
||||
@@ -654,17 +777,17 @@ run_partition_decide() {
|
||||
|
||||
kernel_choice=$(detect_kernel)
|
||||
|
||||
local last_part
|
||||
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 ))
|
||||
root_part_num=$(( efi_part_num + 1 ))
|
||||
|
||||
efi_dev=$(partition_path "$disk" "$efi_part_num")
|
||||
root_partition_device=$(partition_path "$disk" "$root_part_num")
|
||||
# Partition numbers are not knowable until parted assigns them (it fills the
|
||||
# lowest free GPT slot, and freeing space by deleting a partition leaves one
|
||||
# below the highest). run_partition_execute reads them back after creation;
|
||||
# a dry run has nothing to read.
|
||||
efi_part_num=""
|
||||
root_part_num=""
|
||||
efi_dev="<assigned at creation>"
|
||||
root_partition_device="<assigned at creation>"
|
||||
root_mapper="<assigned at creation>"
|
||||
if [[ "$encrypt_installation" == "true" ]]; then
|
||||
root_mapper="/dev/mapper/omarchy_root"
|
||||
else
|
||||
root_mapper="$root_partition_device"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -672,64 +795,103 @@ run_partition_decide() {
|
||||
# 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
|
||||
local dry=false subvol
|
||||
[[ "${1:-}" == "dry" ]] && dry=true
|
||||
|
||||
if $dry; then
|
||||
$needs_mklabel && say "[dry] would initialize GPT on $disk"
|
||||
say "[dry] would create root partition at $root_partition_device"
|
||||
say "[dry] would create EFI partition at $efi_dev"
|
||||
say "[dry] would create an EFI partition and a root partition in the free region"
|
||||
say "[dry] partition numbers are assigned by parted at creation, then read back"
|
||||
say "[dry] encrypted=$encrypt_installation kernel=$kernel_choice"
|
||||
else
|
||||
step "Creating partitions on $disk"
|
||||
step "$(omarchy_text "Creating partitions on %s" "$disk")"
|
||||
if $needs_mklabel; then
|
||||
parted --script "$disk" mklabel gpt
|
||||
disk_step "initializing GPT on $disk" parted --script "$disk" mklabel gpt
|
||||
partprobe "$disk" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
parted --script "$disk" mkpart primary fat32 "${EFI_START_B}B" "${EFI_END_B}B"
|
||||
parted --script "$disk" set "$efi_part_num" esp on
|
||||
parted --script "$disk" name "$efi_part_num" "OMARCHY_EFI"
|
||||
parted --script "$disk" mkpart primary btrfs "${ROOT_START_B}B" "${ROOT_END_B}B"
|
||||
parted --script "$disk" name "$root_part_num" "OMARCHY_ROOT"
|
||||
|
||||
# create_partition reports the number parted actually used; nothing here
|
||||
# may assume it. It sets created_partition_number rather than printing so
|
||||
# the rollback bookkeeping survives (a $(...) would subshell it away).
|
||||
create_partition "$disk" "$EFI_START_B" "$EFI_END_B" fat32 OMARCHY_EFI ||
|
||||
disk_abort_hook "Could not create the EFI partition on $disk"
|
||||
efi_part_num="$created_partition_number"
|
||||
|
||||
create_partition "$disk" "$ROOT_START_B" "$ROOT_END_B" btrfs OMARCHY_ROOT ||
|
||||
disk_abort_hook "Could not create the root partition on $disk"
|
||||
root_part_num="$created_partition_number"
|
||||
|
||||
disk_step "flagging partition $efi_part_num as ESP" \
|
||||
parted --script "$disk" set "$efi_part_num" esp on
|
||||
|
||||
efi_dev=$(partition_path "$disk" "$efi_part_num")
|
||||
root_partition_device=$(partition_path "$disk" "$root_part_num")
|
||||
|
||||
partprobe "$disk"
|
||||
sync
|
||||
sleep 2
|
||||
|
||||
wait_for_device "$efi_dev"
|
||||
wait_for_device "$root_partition_device"
|
||||
wait_for_device "$efi_dev" || disk_abort_hook "EFI partition $efi_dev never appeared"
|
||||
wait_for_device "$root_partition_device" ||
|
||||
disk_abort_hook "Root partition $root_partition_device never appeared"
|
||||
|
||||
# Both partitions are ours and brand new, but the space they occupy may
|
||||
# carry signatures from whatever was deleted to free it.
|
||||
disk_step "clearing stale signatures on $efi_dev" wipefs -af "$efi_dev"
|
||||
disk_step "clearing stale signatures on $root_partition_device" wipefs -af "$root_partition_device"
|
||||
|
||||
if [[ "$encrypt_installation" == "true" ]]; then
|
||||
step "Setting up LUKS2 on $root_partition_device"
|
||||
printf "%s" "$password" | cryptsetup luksFormat --type luks2 --batch-mode "$root_partition_device" -
|
||||
printf "%s" "$password" | cryptsetup open "$root_partition_device" omarchy_root -
|
||||
step "$(omarchy_text "Setting up LUKS2 on %s" "$root_partition_device")"
|
||||
# Kept as pipes rather than routed through disk_step: the passphrase must
|
||||
# not become an argv the process table can show. Folding stderr into
|
||||
# stdout still lands any error in the install log.
|
||||
if ! printf "%s" "$password" |
|
||||
cryptsetup luksFormat --type luks2 --batch-mode "$root_partition_device" - 2>&1; then
|
||||
disk_abort_hook "Formatting LUKS2 on $root_partition_device failed"
|
||||
fi
|
||||
if ! printf "%s" "$password" |
|
||||
cryptsetup open "$root_partition_device" omarchy_root - 2>&1; then
|
||||
disk_abort_hook "Opening the encrypted root on $root_partition_device failed"
|
||||
fi
|
||||
root_mapper="/dev/mapper/omarchy_root"
|
||||
else
|
||||
wipefs -af "$root_partition_device"
|
||||
root_mapper="$root_partition_device"
|
||||
fi
|
||||
|
||||
step "Creating Btrfs filesystem and subvolumes"
|
||||
mkfs.btrfs -f -L OMARCHY "$root_mapper"
|
||||
wait_for_device "$root_mapper"
|
||||
step "$(omarchy_text "Creating Btrfs filesystem and subvolumes")"
|
||||
disk_step "creating the Btrfs filesystem on $root_mapper" \
|
||||
mkfs.btrfs -f -L OMARCHY "$root_mapper"
|
||||
wait_for_device "$root_mapper" || disk_abort_hook "Root device $root_mapper never appeared"
|
||||
|
||||
mkdir -p /mnt/btrfs-root
|
||||
mount "$root_mapper" /mnt/btrfs-root
|
||||
btrfs subvolume create /mnt/btrfs-root/@
|
||||
btrfs subvolume create /mnt/btrfs-root/@home
|
||||
btrfs subvolume create /mnt/btrfs-root/@log
|
||||
btrfs subvolume create /mnt/btrfs-root/@pkg
|
||||
disk_step "mounting $root_mapper" mount "$root_mapper" /mnt/btrfs-root
|
||||
for subvol in @ @home @log @pkg; do
|
||||
disk_step "creating subvolume $subvol" btrfs subvolume create "/mnt/btrfs-root/$subvol"
|
||||
done
|
||||
umount /mnt/btrfs-root
|
||||
rmdir /mnt/btrfs-root
|
||||
|
||||
mount -o noatime,compress=zstd,subvol=@ "$root_mapper" /mnt
|
||||
disk_step "mounting the target root" \
|
||||
mount -o noatime,compress=zstd,subvol=@ "$root_mapper" /mnt
|
||||
mkdir -p /mnt/home /mnt/var/log /mnt/var/cache/pacman/pkg /mnt"$esp_mount_in_target"
|
||||
mount -o noatime,compress=zstd,subvol=@home "$root_mapper" /mnt/home
|
||||
mount -o noatime,compress=zstd,subvol=@log "$root_mapper" /mnt/var/log
|
||||
mount -o noatime,compress=zstd,subvol=@pkg "$root_mapper" /mnt/var/cache/pacman/pkg
|
||||
disk_step "mounting /home" \
|
||||
mount -o noatime,compress=zstd,subvol=@home "$root_mapper" /mnt/home
|
||||
disk_step "mounting /var/log" \
|
||||
mount -o noatime,compress=zstd,subvol=@log "$root_mapper" /mnt/var/log
|
||||
disk_step "mounting the package cache" \
|
||||
mount -o noatime,compress=zstd,subvol=@pkg "$root_mapper" /mnt/var/cache/pacman/pkg
|
||||
|
||||
mkfs.fat -F32 -n OMARCHY_EFI "$efi_dev"
|
||||
mount "$efi_dev" /mnt"$esp_mount_in_target"
|
||||
disk_step "creating the ESP filesystem on $efi_dev" mkfs.fat -F32 -n OMARCHY_EFI "$efi_dev"
|
||||
disk_step "mounting the ESP" mount "$efi_dev" /mnt"$esp_mount_in_target"
|
||||
|
||||
# The orchestrator's first act is to verify this handoff. Check it here so
|
||||
# a failure names the step that broke rather than surfacing later as
|
||||
# "protected mode: /mnt is not a mountpoint".
|
||||
mountpoint -q /mnt ||
|
||||
disk_abort_hook "Target /mnt is not mounted; not starting the installer"
|
||||
mountpoint -q "/mnt$esp_mount_in_target" ||
|
||||
disk_abort_hook "ESP is not mounted at /mnt$esp_mount_in_target"
|
||||
fi
|
||||
|
||||
local luks_uuid_json="null" luks_uuid
|
||||
@@ -751,6 +913,8 @@ run_partition_execute() {
|
||||
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
|
||||
"custom_commands": [],
|
||||
"omarchy_install": {
|
||||
"input_method": "${keyboard_input_method:-none}",
|
||||
"input_xkb_layout": "${keyboard_xkb_layout:-}",
|
||||
"mode": "protected",
|
||||
"target_mount": "/mnt",
|
||||
"boot": {
|
||||
@@ -783,7 +947,7 @@ run_partition_execute() {
|
||||
"locale_config": {
|
||||
"kb_layout": "$keyboard",
|
||||
"sys_enc": "UTF-8",
|
||||
"sys_lang": "en_US.UTF-8"
|
||||
"sys_lang": "$(target_lang)"
|
||||
},
|
||||
"mirror_config": {
|
||||
"custom_repositories": [],
|
||||
@@ -813,7 +977,7 @@ _EOF_
|
||||
}
|
||||
|
||||
disk_form() {
|
||||
step "Let's select where to install Omarchy..."
|
||||
step "$(omarchy_text "Let's select where to install Omarchy...")"
|
||||
|
||||
# Don't offer the install media as an option (Arch ISO mounts it here)
|
||||
local boot_source
|
||||
@@ -839,19 +1003,19 @@ disk_form() {
|
||||
fi
|
||||
done <<<"$available_disks"
|
||||
|
||||
selected_display=$(echo "$disk_options" | gum choose --header "Select install disk") || abort
|
||||
selected_display=$(echo "$disk_options" | gum choose --header "$(omarchy_text "Select install disk")") || abort
|
||||
disk=$(echo "$selected_display" | awk '{print $1}')
|
||||
}
|
||||
|
||||
# STEP 4: INSTALL MODE
|
||||
|
||||
open_partition_tool() {
|
||||
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 "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."
|
||||
step "$(omarchy_text "Partition tool for %s" "$disk")"
|
||||
gum style "$(omarchy_text "Create unallocated free space for Omarchy, then write changes and quit.")"
|
||||
gum style --foreground 8 "$(omarchy_text "Do not create an Omarchy partition here — leave the target area as Free space.")"
|
||||
gum style --foreground 8 "$(omarchy_text "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
|
||||
gum confirm --affirmative "$(omarchy_text "Open cfdisk")" --negative "$(omarchy_text "Back")" "$(omarchy_text "Open partition tool for %s?" "$disk")" || return 0
|
||||
|
||||
clear
|
||||
cfdisk "$disk" || true
|
||||
@@ -884,17 +1048,17 @@ requires_full_disk_install() {
|
||||
}
|
||||
|
||||
install_mode_form() {
|
||||
local choices=("Full disk install")
|
||||
local choices=("$(omarchy_text "Full disk install")")
|
||||
|
||||
# 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.
|
||||
if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then
|
||||
choices+=("Free space install (alongside existing data)")
|
||||
choices+=("$(omarchy_text "Free space install (alongside existing data)")")
|
||||
fi
|
||||
choices+=("Choose a different disk")
|
||||
choices+=("$(omarchy_text "Choose a different disk")")
|
||||
|
||||
step "Let's select how to install Omarchy..."
|
||||
install_mode=$(gum choose --header "Select installation mode on $disk" "${choices[@]}") || abort
|
||||
step "$(omarchy_text "Let's select how to install Omarchy...")"
|
||||
install_mode=$(gum choose --header "$(omarchy_text "Select installation mode on %s" "$disk")" "${choices[@]}") || abort
|
||||
}
|
||||
|
||||
confirm_disk_overwrite() {
|
||||
@@ -904,17 +1068,17 @@ confirm_disk_overwrite() {
|
||||
while true; do
|
||||
clear_logo
|
||||
echo
|
||||
say "Everything will be overwritten. There is no recovery possible."
|
||||
say "$(omarchy_text "Everything will be overwritten. There is no recovery possible.")"
|
||||
|
||||
if [[ $mode == "encrypted" ]]; then
|
||||
say --foreground 8 "Press Ctrl+C for unencrypted install."
|
||||
affirmative="Yes, install"
|
||||
say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")"
|
||||
affirmative="$(omarchy_text "Yes, install")"
|
||||
else
|
||||
affirmative="Yes, install without encryption"
|
||||
affirmative="$(omarchy_text "Yes, install without encryption")"
|
||||
fi
|
||||
|
||||
echo
|
||||
gum confirm --affirmative "$affirmative" --negative "No, change it" "Confirm overwriting ${disk}"
|
||||
gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Confirm overwriting %s" "$disk")"
|
||||
confirm_status=$?
|
||||
|
||||
case $confirm_status in
|
||||
@@ -953,13 +1117,13 @@ select_installation() {
|
||||
# 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"
|
||||
install_mode=$(omarchy_text "Full disk install")
|
||||
else
|
||||
install_mode_form
|
||||
fi
|
||||
|
||||
case "$install_mode" in
|
||||
"Full disk install")
|
||||
"$(omarchy_text "Full disk install")")
|
||||
if confirm_disk_overwrite; then
|
||||
install_target="full_disk"
|
||||
return 0
|
||||
@@ -968,13 +1132,13 @@ select_installation() {
|
||||
# wants a different disk.
|
||||
$full_disk_only && disk_form
|
||||
;;
|
||||
"Free space install"*)
|
||||
"$(omarchy_text "Free space install (alongside existing data)")")
|
||||
if run_partition_decide; then
|
||||
install_target="free_space"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
"Choose a different disk")
|
||||
"$(omarchy_text "Choose a different disk")")
|
||||
disk_form
|
||||
;;
|
||||
esac
|
||||
@@ -985,12 +1149,20 @@ select_installation() {
|
||||
|
||||
defer_provisioning=false
|
||||
install_target="full_disk"
|
||||
# A rerun arrives with the language chosen; otherwise English.
|
||||
language="${OMARCHY_INSTALL_LANGUAGE:-en}"
|
||||
language_chosen=false
|
||||
|
||||
# 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
|
||||
if [[ -n ${OMARCHY_INSTALL_LANGUAGE:-} ]]; then
|
||||
apply_language
|
||||
language_chosen=true
|
||||
else
|
||||
greeter
|
||||
fi
|
||||
|
||||
keyboard_form
|
||||
|
||||
@@ -1007,6 +1179,8 @@ if $defer_provisioning; then
|
||||
# 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"
|
||||
keyboard_input_method="none"
|
||||
keyboard_xkb_layout=""
|
||||
username=""
|
||||
password=""
|
||||
password_hash=""
|
||||
@@ -1089,6 +1263,8 @@ cat <<-_EOF_ >user_configuration.json
|
||||
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
|
||||
"custom_commands": [],
|
||||
"omarchy_install": {
|
||||
"input_method": "${keyboard_input_method:-none}",
|
||||
"input_xkb_layout": "${keyboard_xkb_layout:-}",
|
||||
"mode": "full_disk",
|
||||
"defer_provisioning": $defer_provisioning,
|
||||
"target_mount": "/mnt",
|
||||
@@ -1172,7 +1348,7 @@ cat <<-_EOF_ >user_configuration.json
|
||||
"locale_config": {
|
||||
"kb_layout": "$keyboard",
|
||||
"sys_enc": "UTF-8",
|
||||
"sys_lang": "en_US.UTF-8"
|
||||
"sys_lang": "$(target_lang)"
|
||||
},
|
||||
"mirror_config": {
|
||||
"custom_repositories": [],
|
||||
|
||||
@@ -38,9 +38,7 @@ CLEAR="${CSI}2J${CSI}H"
|
||||
CLEAR_LINE="${CSI}2K"
|
||||
CLEAR_TO_END="${CSI}J"
|
||||
GREEN="${CSI}32m"
|
||||
WHITE="${CSI}37m"
|
||||
RED="${CSI}31m"
|
||||
DARK="${CSI}90m"
|
||||
|
||||
# The dashboard owns the final reboot prompt now, so keep the gum confirm
|
||||
# styling that used to come from install/helpers/presentation.sh.
|
||||
@@ -53,6 +51,22 @@ export GUM_CONFIRM_UNSELECTED_BACKGROUND="${GUM_CONFIRM_UNSELECTED_BACKGROUND:-0
|
||||
child_pid=""
|
||||
child_pgid=""
|
||||
|
||||
whimsy=(
|
||||
"delivering delightful defaults"
|
||||
"saving setup struggles"
|
||||
"picking polished packages"
|
||||
"curating cohesive colors"
|
||||
"preparing pleasant pixels"
|
||||
"hyping happy hyprland"
|
||||
"integrating intuitive interfaces"
|
||||
"bootstrapping beautiful boxes"
|
||||
"crafting cozy commands"
|
||||
"making machines marvelous"
|
||||
"polishing pretty pixels"
|
||||
"tuning tasteful terminals"
|
||||
"weaving wonderful workflows"
|
||||
)
|
||||
|
||||
tips=(
|
||||
"Super + Space opens the Omarchy menu for apps, settings, and more"
|
||||
"Super + K shows all the key bindings"
|
||||
@@ -74,6 +88,81 @@ tips=(
|
||||
"Keep the system fresh with Update in the Omarchy menu"
|
||||
)
|
||||
|
||||
# omarchy_text and its Chinese table come from the setup form the configurator uses.
|
||||
SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}"
|
||||
[[ -r $SETUP_FORM ]] || SETUP_FORM="${OMARCHY_PATH:-/usr/share/omarchy}/install/provisioning/setup-form.sh"
|
||||
source "$SETUP_FORM"
|
||||
OMARCHY_ZH+=(
|
||||
["Tip: %s"]="提示:%s"
|
||||
["Installed Omarchy"]="Omarchy 安装完成"
|
||||
["Installed Omarchy in %s"]="Omarchy 安装完成,用时 %s"
|
||||
["Reboot Now"]="立即重启"
|
||||
["Last log lines:"]="日志末尾:"
|
||||
["Last target log lines:"]="目标系统日志末尾:"
|
||||
["Press Enter to continue…"]="按回车继续……"
|
||||
["No log uploader is available in this environment."]="这个环境没有日志上传工具。"
|
||||
["Install log: %s"]="安装日志:%s"
|
||||
["Target log: %s"]="目标系统日志:%s"
|
||||
["Omarchy installation stopped"]="Omarchy 安装已停止"
|
||||
["Installer exited with status %s"]="安装程序退出,状态码 %s"
|
||||
["Get help at https://omarchy.org/discord"]="求助:https://omarchy.org/discord"
|
||||
["What would you like to do?"]="接下来做什么?"
|
||||
["Upload log for support"]="上传日志求助"
|
||||
["View full log"]="查看完整日志"
|
||||
["Drop to shell"]="进入 shell"
|
||||
["Reboot"]="重启"
|
||||
["Power off"]="关机"
|
||||
["The install medium was misread"]="安装介质读取出错"
|
||||
["This package is intact on this ISO, so the download and the write were fine:"]="这个软件包在 ISO 中完好,下载和写入都没有问题:"
|
||||
["Try another USB port or another stick, and test this machine's memory"]="换一个 USB 口或换一个 U 盘,并检测这台机器的内存"
|
||||
["The install medium is damaged"]="安装介质已损坏"
|
||||
["This package no longer matches the checksum it was built with:"]="这个软件包和构建时的校验和不再一致:"
|
||||
["Re-download the ISO, verify it with sha256sum, and write the USB again"]="重新下载 ISO,用 sha256sum 校验,再重新写入 U 盘"
|
||||
["If a verified ISO fails here too, the USB stick itself is the problem"]="如果校验过的 ISO 在这里也失败,问题出在 U 盘本身"
|
||||
["The install medium is damaged or misread"]="安装介质已损坏或读取出错"
|
||||
["pacman rejected this package, read straight off this ISO:"]="pacman 拒绝了这个直接从 ISO 读出的软件包:"
|
||||
["Verify your download with sha256sum, then write the USB again"]="用 sha256sum 校验下载的文件,再重新写入 U 盘"
|
||||
["If the download verifies, the stick or this machine's memory is at fault"]="如果下载校验无误,问题出在 U 盘或这台机器的内存"
|
||||
)
|
||||
|
||||
if [[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]]; then
|
||||
whimsy=(
|
||||
"备好贴心的默认设置"
|
||||
"省掉装机的折腾"
|
||||
"挑选打磨过的软件包"
|
||||
"调配协调的配色"
|
||||
"准备悦目的像素"
|
||||
"唤醒 Hyprland"
|
||||
"接好顺手的界面"
|
||||
"搭起漂亮的系统"
|
||||
"写好趁手的命令"
|
||||
"让机器焕然一新"
|
||||
"擦亮每一个像素"
|
||||
"调好终端的品味"
|
||||
"织出流畅的工作流"
|
||||
)
|
||||
tips=(
|
||||
"Super + Space 打开 Omarchy 菜单:应用、设置等"
|
||||
"Super + K 查看全部快捷键"
|
||||
"Super 是键盘上的 Windows 或 Command 键"
|
||||
"用 Xournal++ 给 PDF 签名"
|
||||
"用 LocalSend 和手机、电脑互传文件"
|
||||
"菜单 安装 > 网页应用 把任意网站变成应用"
|
||||
"Super + Return 打开终端,Super + Shift + Return 打开浏览器"
|
||||
"图片用 Pinta,视频用 Kdenlive,文档用 LibreOffice"
|
||||
"Super + Ctrl + Print 用 OCR 抓取屏幕文字"
|
||||
"Print 截屏,Alt + Print 录屏"
|
||||
"Super + Ctrl + R 设置提醒"
|
||||
"菜单 风格 > 主题 切换主题"
|
||||
"双击菜单栏让它透明"
|
||||
"菜单 安装 > Windows 运行完整的 Windows 虚拟机"
|
||||
"Super + Ctrl + V 打开剪贴板管理器"
|
||||
"Super + 1 到 0 切换工作区,加 Shift 带上窗口"
|
||||
"Super + Print 拾取屏幕上任意位置的颜色"
|
||||
"用菜单里的 更新 让系统保持最新"
|
||||
)
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
printf '%s%s' "$RESET" "$SHOW_CURSOR" >"$TTY_PATH" 2>/dev/null || true
|
||||
}
|
||||
@@ -115,10 +204,58 @@ trap 'interrupt 143' TERM
|
||||
term_size() { stty size 2>/dev/null <"$TTY_PATH" || printf '24 80\n'; }
|
||||
term_cols() { local size; size="$(term_size)"; printf '%s\n' "${size#* }"; }
|
||||
|
||||
repeat() {
|
||||
local char="$1" count="$2" out="" i
|
||||
for ((i=0; i<count; i++)); do out+="$char"; done
|
||||
printf '%s' "$out"
|
||||
# Read the actual character dimensions instead of assuming an 8 x 16 font.
|
||||
# Linux VTs expose the font through KDFONTOP; desktop terminals may expose
|
||||
# pixels in their window size or answer the standard cell-size query.
|
||||
term_cell_size() {
|
||||
python3 - "$TTY_PATH" <<'PY'
|
||||
import fcntl
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import struct
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
|
||||
with open(sys.argv[1], "r+b", buffering=0) as terminal:
|
||||
fd = terminal.fileno()
|
||||
rows, cols, width, height = struct.unpack(
|
||||
"HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))
|
||||
if rows and cols and width and height:
|
||||
print(round(width / cols), round(height / rows))
|
||||
sys.exit()
|
||||
try:
|
||||
font = bytearray(struct.pack("@5IP", 1, 0, 0, 0, 0, 0))
|
||||
fcntl.ioctl(fd, 0x4B72, font) # KDFONTOP / KD_FONT_OP_GET, no glyph data
|
||||
_, _, width, height, _ = struct.unpack_from("@5I", font)
|
||||
if width and height:
|
||||
print(width, height)
|
||||
sys.exit()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd, termios.TCSANOW)
|
||||
os.write(fd, b"\x1b[16t")
|
||||
response = b""
|
||||
deadline = time.monotonic() + 0.15
|
||||
while select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]:
|
||||
response += os.read(fd, 64)
|
||||
match = re.search(rb"\x1b\[6;(\d+);(\d+)t", response)
|
||||
if match:
|
||||
height, width = map(int, match.groups())
|
||||
if width and height:
|
||||
print(width, height)
|
||||
sys.exit()
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSANOW, settings)
|
||||
print(8, 16) # Conventional console font when dimensions aren't available.
|
||||
PY
|
||||
}
|
||||
|
||||
logo_width() {
|
||||
@@ -151,22 +288,16 @@ LOGO_HEIGHT="$(logo_height)"
|
||||
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
|
||||
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
|
||||
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))
|
||||
LAST_SIZE=""
|
||||
LAST_PM=10
|
||||
# The finish screen mirrors the greeter's frame — logo, blank, one line, gap,
|
||||
# one button — so the last thing the installer shows matches the first. It runs
|
||||
# one row taller: `gum confirm` always paints prompt/blank/button and clears all
|
||||
# three, so the button cannot sit closer than two rows under a title we drew.
|
||||
# Wordmark, blank, title, and the three rows gum confirm uses.
|
||||
FINISH_HEIGHT=$((LOGO_HEIGHT + 5))
|
||||
FINISH_TOP_ROW=2
|
||||
|
||||
# ── Install progress ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Position = max(floor, work), both non-decreasing, in per-mille of the bar.
|
||||
# Position = max(floor, work), both non-decreasing, in per-mille of the drawing.
|
||||
#
|
||||
# floor lo + (span-1) * t / (t+tau), on time since this process saw the
|
||||
# phase. Asymptotic, so it approaches the band end without reaching it.
|
||||
@@ -189,8 +320,8 @@ fi
|
||||
# A wrong denominator is worse than none: it never triggers the fallback.
|
||||
(( PKG_TOTAL < 300 || PKG_TOTAL > 5000 )) && PKG_TOTAL=0
|
||||
|
||||
PKG_BONUS_MAX_PM=100 # cap on how far the bar may run ahead of the count
|
||||
PKG_TAIL_PM=40 # band held back so a stalled bar has somewhere to go
|
||||
PKG_BONUS_MAX_PM=100 # cap on how far progress may run ahead of the count
|
||||
PKG_TAIL_PM=40 # band held back so stalled progress has somewhere to go
|
||||
PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the
|
||||
# first package, so slow it until it cannot outrun data
|
||||
|
||||
@@ -230,8 +361,8 @@ left_padding() {
|
||||
|
||||
visible_len() {
|
||||
local text="$1"
|
||||
text="$(printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g')"
|
||||
printf '%s' "${#text}"
|
||||
# wc -L counts display columns, so double-width CJK centers too.
|
||||
printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' | wc -L
|
||||
}
|
||||
|
||||
blank_line() { printf '\r%s\n' "$CLEAR_LINE"; }
|
||||
@@ -263,13 +394,161 @@ render_logo() {
|
||||
fi
|
||||
}
|
||||
|
||||
progress_bar() {
|
||||
local pm="$1" width="${2:-34}" filled empty
|
||||
(( pm < 0 )) && pm=0
|
||||
(( pm > 1000 )) && pm=1000
|
||||
filled=$(( pm * width / 1000 ))
|
||||
empty=$(( width - filled ))
|
||||
printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET"
|
||||
# The icon uses half-block glyphs, scaled to the terminal's actual cell aspect
|
||||
# ratio so its square silhouette stays square. No graphics protocol is needed.
|
||||
# Coordinates follow the 15 x 15 grid of Omarchy's icon.svg. Each segment
|
||||
# paints the full stroke width, starting at the bottom middle of the outer ring.
|
||||
ICON_WIDTH=30
|
||||
ICON_HEIGHT=15
|
||||
CELL_WIDTH=8
|
||||
CELL_HEIGHT=16
|
||||
ICON_LEFT=1
|
||||
ICON_STEPS=0
|
||||
ICON_DRAWN=0
|
||||
DISPLAY_PM=0
|
||||
ICON_FRAME=""
|
||||
declare -a ICON_ORDER=() ICON_TRACE=() ICON_FILL=()
|
||||
|
||||
icon_segment() {
|
||||
local scale="$1" x1="$2" y1="$3" x2="$4" y2="$5"
|
||||
local x y from to
|
||||
if (( y1 == y2 )); then
|
||||
from=$(( (x1 * ICON_WIDTH + 14) / 15 ))
|
||||
to=$(( ((x2 + 1) * ICON_WIDTH + 14) / 15 ))
|
||||
if (( x2 < x1 )); then
|
||||
from=$(( ((x1 + 1) * ICON_WIDTH + 14) / 15 - 1 ))
|
||||
to=$(( (x2 * ICON_WIDTH + 14) / 15 - 1 ))
|
||||
fi
|
||||
for ((x=from; x!=to; x+=(x2 >= x1 ? 1 : -1))); do
|
||||
for ((y=y1 * scale; y<(y1 + 1) * scale; y++)); do
|
||||
icon_pixel "$((y * ICON_WIDTH + x))"
|
||||
done
|
||||
done
|
||||
else
|
||||
from=$(( y1 * scale ))
|
||||
to=$(( (y2 + 1) * scale ))
|
||||
if (( y2 < y1 )); then
|
||||
from=$(( (y1 + 1) * scale - 1 ))
|
||||
to=$(( y2 * scale - 1 ))
|
||||
fi
|
||||
for ((y=from; y!=to; y+=(y2 >= y1 ? 1 : -1))); do
|
||||
for ((x=(x1 * ICON_WIDTH + 14) / 15; x<((x1 + 1) * ICON_WIDTH + 14) / 15; x++)); do
|
||||
icon_pixel "$((y * ICON_WIDTH + x))"
|
||||
done
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
icon_pixel() {
|
||||
local pixel="$1"
|
||||
if [[ ! -v ICON_TRACE[pixel] ]]; then
|
||||
ICON_ORDER+=("$pixel")
|
||||
ICON_TRACE[pixel]=${#ICON_ORDER[@]}
|
||||
fi
|
||||
}
|
||||
|
||||
build_icon() {
|
||||
local scale="$1" x y row
|
||||
local -a mask=(
|
||||
111111111111111
|
||||
100000010000001
|
||||
101111110001101
|
||||
101000000000101
|
||||
101000000000101
|
||||
101000000000101
|
||||
101000000000101
|
||||
111000000000101
|
||||
101000000000101
|
||||
101000000000101
|
||||
101000000000101
|
||||
101000000000101
|
||||
101111111111101
|
||||
100000010000001
|
||||
111111110111111
|
||||
)
|
||||
ICON_WIDTH=$(( (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) ))
|
||||
ICON_HEIGHT=$(((15 * scale + 1) / 2))
|
||||
ICON_ORDER=() ICON_TRACE=() ICON_FILL=()
|
||||
ICON_DRAWN=0
|
||||
# Start at the bottom middle, follow the outer left edge to the top cross,
|
||||
# then descend into the inner shape. Fill its two bridges as we reach them.
|
||||
icon_segment "$scale" 7 14 0 14
|
||||
icon_segment "$scale" 0 14 0 0
|
||||
icon_segment "$scale" 0 0 7 0
|
||||
icon_segment "$scale" 7 0 7 2
|
||||
icon_segment "$scale" 7 2 2 2
|
||||
icon_segment "$scale" 2 2 2 7
|
||||
icon_segment "$scale" 2 7 0 7
|
||||
icon_segment "$scale" 2 7 2 12
|
||||
icon_segment "$scale" 2 12 7 12
|
||||
icon_segment "$scale" 7 12 7 14
|
||||
icon_segment "$scale" 7 12 12 12
|
||||
icon_segment "$scale" 12 12 12 2
|
||||
icon_segment "$scale" 12 2 11 2
|
||||
# Finish from the top cross: right along the outside, down, then left.
|
||||
icon_segment "$scale" 7 0 14 0
|
||||
icon_segment "$scale" 14 0 14 14
|
||||
icon_segment "$scale" 14 14 9 14
|
||||
ICON_STEPS=${#ICON_ORDER[@]}
|
||||
for ((y=0; y<15 * scale; y++)); do
|
||||
row=${mask[y / scale]}
|
||||
for ((x=0; x<ICON_WIDTH; x++)); do
|
||||
ICON_FILL[y * ICON_WIDTH + x]=${row:x * 15 / ICON_WIDTH:1}
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Append one cell with independently colored upper/lower pixels. This also
|
||||
# handles a green tracing tip sharing a cell with the still-dark outline.
|
||||
icon_cell() {
|
||||
local pixel="$1" drawn="$2" solid="$3" upper=0 lower=0 step
|
||||
if (( solid )); then
|
||||
(( ICON_FILL[pixel] )) && upper=32
|
||||
(( ${ICON_FILL[pixel + ICON_WIDTH]:-0} )) && lower=32
|
||||
else
|
||||
step=${ICON_TRACE[pixel]:-0}
|
||||
(( step > 0 )) && upper=90
|
||||
(( step > 0 && step <= drawn )) && upper=32
|
||||
step=${ICON_TRACE[pixel + ICON_WIDTH]:-0}
|
||||
(( step > 0 )) && lower=90
|
||||
(( step > 0 && step <= drawn )) && lower=32
|
||||
fi
|
||||
if (( upper == 0 && lower == 0 )); then
|
||||
ICON_FRAME+="${RESET} "
|
||||
elif (( upper == lower )); then
|
||||
ICON_FRAME+="${CSI}${upper};49m█"
|
||||
elif (( lower == 0 )); then
|
||||
ICON_FRAME+="${CSI}${upper};49m▀"
|
||||
elif (( upper == 0 )); then
|
||||
ICON_FRAME+="${CSI}${lower};49m▄"
|
||||
else
|
||||
ICON_FRAME+="${CSI}${upper};$((lower + 10))m▀"
|
||||
fi
|
||||
}
|
||||
|
||||
render_icon() {
|
||||
local pm="$1" full="${2:-0}" drawn i pixel row col
|
||||
drawn=$((pm * ICON_STEPS / 1000))
|
||||
ICON_FRAME=""
|
||||
if (( full || pm == 1000 )); then
|
||||
for ((row=0; row<ICON_HEIGHT; row++)); do
|
||||
ICON_FRAME+="${CSI}$((TOP_ROW + row));${ICON_LEFT}H"
|
||||
for ((col=0; col<ICON_WIDTH; col++)); do
|
||||
icon_cell "$((row * 2 * ICON_WIDTH + col))" "$drawn" "$((pm == 1000))"
|
||||
done
|
||||
done
|
||||
else
|
||||
# Between layout changes, repaint only newly traced pixels. This keeps the
|
||||
# animation cheap even while pacstrap saturates the disk.
|
||||
for ((i=ICON_DRAWN; i<drawn; i++)); do
|
||||
pixel=${ICON_ORDER[i]}
|
||||
row=$((pixel / ICON_WIDTH / 2)) col=$((pixel % ICON_WIDTH))
|
||||
ICON_FRAME+="${CSI}$((TOP_ROW + row));$((ICON_LEFT + col))H"
|
||||
icon_cell "$((row * 2 * ICON_WIDTH + col))" "$drawn" 0
|
||||
done
|
||||
fi
|
||||
printf '%s%s' "$ICON_FRAME" "$RESET"
|
||||
ICON_DRAWN=$drawn
|
||||
}
|
||||
|
||||
# Packages libalpm has finished installing. local/ holds one directory per
|
||||
@@ -290,7 +569,7 @@ count_packages() {
|
||||
|
||||
# Sets PROGRESS_PM. Never call in a command substitution: POS, the phase timer
|
||||
# and the package counters must survive between frames. Print nothing — output
|
||||
# here would land on the TTY between the cursor escape and the bar.
|
||||
# here would land on the TTY between the cursor escape and the drawing.
|
||||
install_progress() {
|
||||
local phase finished index total target
|
||||
local lo hi tau span t pos work gap n pkg_signal=0
|
||||
@@ -377,7 +656,7 @@ install_progress() {
|
||||
PKG_DB_DIR="$target/var/lib/pacman/local"
|
||||
count_packages
|
||||
# Baseline on first sight: a target that already holds packages would
|
||||
# otherwise pin the bar at the band ceiling for the whole phase.
|
||||
# otherwise pin progress at the band ceiling for the whole phase.
|
||||
(( PKG_BASE < 0 )) && PKG_BASE=$PKG_COUNT
|
||||
n=$(( PKG_COUNT - PKG_BASE ))
|
||||
(( n < 0 )) && n=0
|
||||
@@ -401,17 +680,20 @@ install_progress() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# One tip per 8 seconds, in order. On elapsed time, not a frame counter: a frame
|
||||
# is 0.5s of sleep plus a render, which stretches under pacstrap I/O — on the
|
||||
# machine where the tips, not the bar, are carrying liveness.
|
||||
# Keep the original loading messages on their own clock, so progress jumps
|
||||
# cannot skip phrases and animation frame rate does not change their cadence.
|
||||
current_whimsy() {
|
||||
printf '%s' "${whimsy[$(( (NOW - DASH_T0) / 4 % ${#whimsy[@]} ))]}"
|
||||
}
|
||||
|
||||
# One tip per 8 seconds, in order. Use elapsed time, since rendering can stretch
|
||||
# under pacstrap I/O, when the tips may be the most visible sign of activity.
|
||||
current_tip() {
|
||||
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.
|
||||
# Center both the progress icon and the final wordmark on the console.
|
||||
centered_top_row() {
|
||||
local height="$1" rows top
|
||||
rows="$(term_size)"
|
||||
@@ -423,42 +705,60 @@ centered_top_row() {
|
||||
}
|
||||
|
||||
measure_progress_layout() {
|
||||
local rows cols scale=1
|
||||
LAST_SIZE="$(term_size)"
|
||||
TOP_ROW="$(centered_top_row "$PROGRESS_HEIGHT")"
|
||||
DYNAMIC_ROW=$(( TOP_ROW + LOGO_HEIGHT + 1 ))
|
||||
read -r rows cols <<<"$LAST_SIZE"
|
||||
read -r CELL_WIDTH CELL_HEIGHT < <(term_cell_size 2>/dev/null || printf '8 16\n')
|
||||
(( rows >= 34 && cols >= 49 )) && scale=2
|
||||
(( rows >= 50 && cols >= 64 )) && scale=3
|
||||
while (( scale > 1 && (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) > cols - 4 )); do
|
||||
scale=$((scale - 1))
|
||||
done
|
||||
build_icon "$scale"
|
||||
TOP_ROW="$(centered_top_row "$((ICON_HEIGHT + 6))")"
|
||||
ICON_LEFT=$(( (cols - ICON_WIDTH) / 2 + 1 ))
|
||||
(( ICON_LEFT < 1 )) && ICON_LEFT=1
|
||||
PROGRESS_TEXT_WIDTH=$((cols - 2))
|
||||
(( PROGRESS_TEXT_WIDTH > CONTENT_WIDTH )) && PROGRESS_TEXT_WIDTH=$CONTENT_WIDTH
|
||||
return 0
|
||||
}
|
||||
|
||||
render_static() {
|
||||
measure_progress_layout
|
||||
printf '%s%s%d;1H' "$CLEAR" "$CSI" "$TOP_ROW"
|
||||
render_logo
|
||||
blank_line
|
||||
printf '%s' "$CLEAR"
|
||||
render_icon "$DISPLAY_PM" 1
|
||||
}
|
||||
|
||||
render_dynamic() {
|
||||
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
|
||||
update_progress() {
|
||||
set_now
|
||||
install_progress
|
||||
pm=$PROGRESS_PM
|
||||
local pm=$PROGRESS_PM
|
||||
[[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM
|
||||
(( pm > 1000 )) && pm=1000
|
||||
# Only the child's successful exit can complete the drawing.
|
||||
(( pm > 999 )) && pm=999
|
||||
# POS is monotone by construction; this is a guard, not the mechanism.
|
||||
(( pm < LAST_PM )) && pm=$LAST_PM
|
||||
LAST_PM=$pm
|
||||
}
|
||||
|
||||
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
|
||||
center "Installing Omarchy" "$CONTENT_WIDTH"
|
||||
render_dynamic() {
|
||||
local step message tip
|
||||
# The framebuffer can settle late in VMs. Rebuild at the new size, preserving
|
||||
# progress, instead of leaving the drawing at the old screen coordinates.
|
||||
[[ "$(term_size)" == "$LAST_SIZE" ]] || render_static
|
||||
# Ease phase jumps over several frames, with the percentage following the
|
||||
# same position as the trace. The measured progress still never goes back.
|
||||
step=$(( (LAST_PM - DISPLAY_PM + 3) / 4 ))
|
||||
DISPLAY_PM=$((DISPLAY_PM + step))
|
||||
render_icon "$DISPLAY_PM"
|
||||
|
||||
message="$(truncate_for_width "$(current_whimsy)" "$PROGRESS_TEXT_WIDTH")"
|
||||
tip="$(truncate_for_width "$(omarchy_text "Tip: %s" "$(current_tip)")" "$PROGRESS_TEXT_WIDTH")"
|
||||
printf '%s%d;1H' "$CSI" "$((TOP_ROW + ICON_HEIGHT + 1))"
|
||||
center "${GREEN}$((DISPLAY_PM / 10))%${RESET}" "$PROGRESS_TEXT_WIDTH"
|
||||
center "${GREEN}${message}${RESET}" "$PROGRESS_TEXT_WIDTH"
|
||||
blank_line
|
||||
line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) ""
|
||||
progress_bar "$pm" 34
|
||||
printf '\n'
|
||||
blank_line
|
||||
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH"
|
||||
center "${DIM}${tip}${RESET}" "$PROGRESS_TEXT_WIDTH"
|
||||
printf '%s' "$CLEAR_TO_END"
|
||||
}
|
||||
|
||||
@@ -493,13 +793,15 @@ install_duration() {
|
||||
}
|
||||
|
||||
render_finish() {
|
||||
local duration effect_canvas_width
|
||||
local duration effect_canvas_width title
|
||||
duration="$(install_duration || true)"
|
||||
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.
|
||||
if [[ -n $duration ]]; then
|
||||
title=$(omarchy_text "Installed Omarchy in %s" "$duration")
|
||||
else
|
||||
title=$(omarchy_text "Installed Omarchy")
|
||||
fi
|
||||
# ttfx centers its text two columns right of plain centering. A canvas two
|
||||
# columns narrower aligns the animation with the title and fallback logo.
|
||||
effect_canvas_width="$(term_cols)"
|
||||
(( effect_canvas_width > 2 )) && effect_canvas_width=$((effect_canvas_width - 2))
|
||||
|
||||
@@ -508,23 +810,21 @@ render_finish() {
|
||||
FINISH_TOP_ROW="$(centered_top_row "$FINISH_HEIGHT")"
|
||||
|
||||
{
|
||||
printf '%s%s%s%d;1H' "$SHOW_CURSOR" "$CLEAR" "$CSI" "$FINISH_TOP_ROW"
|
||||
render_logo
|
||||
blank_line
|
||||
center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH"
|
||||
# Leave the wordmark area blank: laseretch reveals it for the first time.
|
||||
printf '%s%s%s%d;1H' "$HIDE_CURSOR" "$CLEAR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 1))"
|
||||
center "$title" "$CONTENT_WIDTH"
|
||||
blank_line
|
||||
} >"$TTY_PATH"
|
||||
|
||||
if [[ -f $LOGO_PATH ]]; then
|
||||
# Reuse mode restores a saved cursor before moving up over its input. Save
|
||||
# this anchor so the effect redraws the same logo rows shown above.
|
||||
# this anchor so the effect occupies the reserved wordmark rows.
|
||||
printf '%s%d;1H\0337' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT))" >"$TTY_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.
|
||||
# truecolor ones badly. It settles on indexed 2, the Tokyo Night green used
|
||||
# throughout the installer. The laser and sparks keep their own hot palette.
|
||||
timeout 8s ttfx -i "$LOGO_PATH" \
|
||||
--canvas-width "$effect_canvas_width" \
|
||||
--anchor-text c \
|
||||
@@ -533,13 +833,18 @@ render_finish() {
|
||||
--xterm-colors \
|
||||
laseretch \
|
||||
--final-gradient-stops 2 \
|
||||
>"$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"
|
||||
>"$TTY_PATH" 2>/dev/null || {
|
||||
dashboard_log "logo effect skipped (ttfx exited $?)"
|
||||
{ printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH"
|
||||
}
|
||||
else
|
||||
{ printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH"
|
||||
fi
|
||||
printf '%s%s%d;1H' "$SHOW_CURSOR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH"
|
||||
}
|
||||
|
||||
reboot_prompt() {
|
||||
local prompt_pad
|
||||
local prompt_pad label
|
||||
|
||||
# Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still
|
||||
# applies, so setting both stops on the finish screen without a keyboard.
|
||||
@@ -550,31 +855,41 @@ reboot_prompt() {
|
||||
# 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 ))
|
||||
# gum pads the button label by four columns on each side.
|
||||
label=$(omarchy_text "Reboot Now")
|
||||
prompt_pad=$(( ( $(term_cols) - $(visible_len "$label") - 8 ) / 2 ))
|
||||
(( prompt_pad < 0 )) && prompt_pad=0
|
||||
gum confirm \
|
||||
--padding "0 0 0 $prompt_pad" \
|
||||
--show-help=false \
|
||||
--default \
|
||||
--affirmative "Reboot Now" \
|
||||
--affirmative "$label" \
|
||||
--negative "" \
|
||||
"" <"$TTY_PATH" >"$TTY_PATH" 2>&1
|
||||
}
|
||||
|
||||
# Columns, not characters: each CJK character takes two.
|
||||
truncate_for_width() {
|
||||
local text="$1" width="$2"
|
||||
if (( ${#text} > width )); then
|
||||
printf '%s…' "${text:0:$((width - 1))}"
|
||||
else
|
||||
if (( $(printf '%s' "$text" | wc -L) <= width )); then
|
||||
printf '%s' "$text"
|
||||
return
|
||||
fi
|
||||
text=${text:0:$((width - 1))}
|
||||
while (( $(printf '%s' "$text" | wc -L) > width - 1 )); do
|
||||
text=${text%?}
|
||||
done
|
||||
printf '%s…' "$text"
|
||||
}
|
||||
|
||||
render_failure_log_tail() {
|
||||
local rows lines width line tail_log label
|
||||
# The caller passes the rows it spent above this point that the constant below
|
||||
# does not know about, so the tail gives way rather than pushing the top of
|
||||
# the screen off a short console.
|
||||
local reserved="${1:-0}" rows lines width line tail_log label
|
||||
rows=$(term_size)
|
||||
rows=${rows%% *}
|
||||
lines=$((rows - LOGO_HEIGHT - 18))
|
||||
lines=$((rows - LOGO_HEIGHT - 18 - reserved))
|
||||
(( lines < 6 )) && lines=6
|
||||
(( lines > 14 )) && lines=14
|
||||
width=$((CONTENT_WIDTH - 4))
|
||||
@@ -590,8 +905,8 @@ render_failure_log_tail() {
|
||||
fi
|
||||
[[ -f $tail_log ]] || return 0
|
||||
|
||||
label="Last log lines:"
|
||||
[[ $tail_log == /mnt/* ]] && label="Last target log lines:"
|
||||
label=$(omarchy_text "Last log lines:")
|
||||
[[ $tail_log == /mnt/* ]] && label=$(omarchy_text "Last target log lines:")
|
||||
line_at "$CONTENT_WIDTH" 2 "${DIM}${label}${RESET}"
|
||||
printf '\n'
|
||||
tail -n "$lines" "$tail_log" 2>/dev/null | while IFS= read -r line; do
|
||||
@@ -614,7 +929,7 @@ find_log_uploader() {
|
||||
}
|
||||
|
||||
prompt_enter() {
|
||||
printf '\nPress Enter to continue…' >"$TTY_PATH"
|
||||
printf '\n%s' "$(omarchy_text "Press Enter to continue…")" >"$TTY_PATH"
|
||||
IFS= read -r _ <"$TTY_PATH" || true
|
||||
}
|
||||
|
||||
@@ -625,9 +940,9 @@ upload_failure_log() {
|
||||
"$uploader" install >"$TTY_PATH" 2>&1 || true
|
||||
else
|
||||
{
|
||||
echo "No log uploader is available in this environment."
|
||||
echo "Install log: $LOG_FILE"
|
||||
[[ -f /mnt/var/log/omarchy-install.log ]] && echo "Target log: /mnt/var/log/omarchy-install.log"
|
||||
printf '%s\n' "$(omarchy_text "No log uploader is available in this environment.")"
|
||||
printf '%s\n' "$(omarchy_text "Install log: %s" "$LOG_FILE")"
|
||||
[[ -f /mnt/var/log/omarchy-install.log ]] && printf '%s\n' "$(omarchy_text "Target log: %s" /mnt/var/log/omarchy-install.log)"
|
||||
} >"$TTY_PATH"
|
||||
fi
|
||||
prompt_enter
|
||||
@@ -643,14 +958,62 @@ view_failure_log() {
|
||||
fi
|
||||
}
|
||||
|
||||
# A failure the medium explains is worth saying before the log tail, because
|
||||
# the tail is where "invalid or corrupted package" already scrolled past
|
||||
# without ever naming the USB stick it came off.
|
||||
diagnose_media() {
|
||||
local logs=("$LOG_FILE")
|
||||
|
||||
[[ -f /mnt/var/log/omarchy-install.log ]] && logs+=(/mnt/var/log/omarchy-install.log)
|
||||
[[ -n ${OMARCHY_FAILURE_TAIL_LOG:-} ]] && logs+=("$OMARCHY_FAILURE_TAIL_LOG")
|
||||
|
||||
omarchy-install-diagnose-media "${logs[@]}" 2>/dev/null
|
||||
}
|
||||
|
||||
# Rows the diagnosis occupies, its leading blank included.
|
||||
media_diagnosis_rows() {
|
||||
local diagnosis="$1"
|
||||
|
||||
if [[ -z $diagnosis ]]; then
|
||||
printf '0\n'
|
||||
else
|
||||
printf '%s\n' "$(( $(grep -c '' <<<"$diagnosis") + 1 ))"
|
||||
fi
|
||||
}
|
||||
|
||||
render_media_diagnosis() {
|
||||
local diagnosis="$1" line first=1
|
||||
|
||||
[[ -n $diagnosis ]] || return 0
|
||||
blank_line
|
||||
while IFS= read -r line; do
|
||||
# A blank line inside the diagnosis is deliberate: it separates what
|
||||
# happened from what to do about it, so it is drawn rather than skipped.
|
||||
if [[ -z $line ]]; then
|
||||
blank_line
|
||||
continue
|
||||
fi
|
||||
# The support log keeps English; the screen shows the translation, and package names pass through.
|
||||
[[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]] && line=${OMARCHY_ZH[$line]:-$line}
|
||||
if (( first )); then
|
||||
center "${RED}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH"
|
||||
first=0
|
||||
else
|
||||
center "$(truncate_for_width "$line" "$CONTENT_WIDTH")" "$CONTENT_WIDTH"
|
||||
fi
|
||||
done <<<"$diagnosis"
|
||||
}
|
||||
|
||||
render_failure() {
|
||||
local status="$1" state_summary="${2:-}" line
|
||||
local status="$1" state_summary="${2:-}" media_diagnosis="${3:-}" line
|
||||
printf '%s%s' "$SHOW_CURSOR" "$CLEAR"
|
||||
blank_line
|
||||
render_logo
|
||||
blank_line
|
||||
center "${RED}Omarchy installation stopped${RESET}" "$CONTENT_WIDTH"
|
||||
center "Installer exited with status $status" "$CONTENT_WIDTH"
|
||||
center "${RED}$(omarchy_text "Omarchy installation stopped")${RESET}" "$CONTENT_WIDTH"
|
||||
center "$(omarchy_text "Installer exited with status %s" "$status")" "$CONTENT_WIDTH"
|
||||
|
||||
render_media_diagnosis "$media_diagnosis"
|
||||
|
||||
if [[ -n $state_summary ]]; then
|
||||
blank_line
|
||||
@@ -661,14 +1024,14 @@ render_failure() {
|
||||
fi
|
||||
|
||||
blank_line
|
||||
render_failure_log_tail
|
||||
render_failure_log_tail "$(media_diagnosis_rows "$media_diagnosis")"
|
||||
blank_line
|
||||
center "${DIM}Get help at https://omarchy.org/discord${RESET}" "$CONTENT_WIDTH"
|
||||
center "${DIM}$(omarchy_text "Get help at https://omarchy.org/discord")${RESET}" "$CONTENT_WIDTH"
|
||||
blank_line
|
||||
}
|
||||
|
||||
failure_menu() {
|
||||
local choice uploader
|
||||
local choice uploader actions shown i
|
||||
|
||||
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
|
||||
# The failure screen and log tail have already rendered; exit with the
|
||||
@@ -678,35 +1041,29 @@ failure_menu() {
|
||||
while true; do
|
||||
uploader=""
|
||||
uploader=$(find_log_uploader || true)
|
||||
if [[ -n $uploader ]]; then
|
||||
choice=$(gum choose \
|
||||
--height 6 \
|
||||
--header "What would you like to do?" \
|
||||
"Upload log for support" \
|
||||
"View full log" \
|
||||
"Drop to shell" \
|
||||
"Reboot" \
|
||||
"Power off" \
|
||||
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
|
||||
else
|
||||
choice=$(gum choose \
|
||||
--height 5 \
|
||||
--header "What would you like to do?" \
|
||||
"View full log" \
|
||||
"Drop to shell" \
|
||||
"Reboot" \
|
||||
"Power off" \
|
||||
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
|
||||
fi
|
||||
actions=("View full log" "Drop to shell" "Reboot" "Power off")
|
||||
[[ -n $uploader ]] && actions=("Upload log for support" "${actions[@]}")
|
||||
shown=()
|
||||
for i in "${!actions[@]}"; do
|
||||
shown+=("$(omarchy_text "${actions[i]}")")
|
||||
done
|
||||
choice=$(gum choose \
|
||||
--height "$((${#actions[@]} + 1))" \
|
||||
--header "$(omarchy_text "What would you like to do?")" \
|
||||
"${shown[@]}" \
|
||||
<"$TTY_PATH" 2>"$TTY_PATH") || choice=""
|
||||
for i in "${!shown[@]}"; do
|
||||
[[ ${shown[i]} == "$choice" ]] && choice=${actions[i]}
|
||||
done
|
||||
|
||||
case "$choice" in
|
||||
"Upload log for support")
|
||||
upload_failure_log
|
||||
render_failure "${failure_status:-1}" "${failure_summary:-}" >"$TTY_PATH" 2>/dev/null || true
|
||||
render_failure "${failure_status:-1}" "${failure_summary:-}" "${failure_media_diagnosis:-}" >"$TTY_PATH" 2>/dev/null || true
|
||||
;;
|
||||
"View full log")
|
||||
view_failure_log
|
||||
render_failure "${failure_status:-1}" "${failure_summary:-}" >"$TTY_PATH" 2>/dev/null || true
|
||||
render_failure "${failure_status:-1}" "${failure_summary:-}" "${failure_media_diagnosis:-}" >"$TTY_PATH" 2>/dev/null || true
|
||||
;;
|
||||
"Reboot")
|
||||
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
|
||||
@@ -756,9 +1113,13 @@ PHASE_T0=$NOW
|
||||
PKG_LAST_CHANGE=$NOW
|
||||
launch_child
|
||||
|
||||
frame=0
|
||||
while jobs -pr | grep -qx "$child_pid"; do
|
||||
# Keep package counting/state reads at 2 Hz; animate the trace at 10 Hz.
|
||||
(( frame % 5 == 0 )) && update_progress
|
||||
render_dynamic >"$TTY_PATH" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
frame=$((frame + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
set +e
|
||||
@@ -771,8 +1132,14 @@ child_pgid=""
|
||||
dashboard_log "installer child exited with status $status"
|
||||
|
||||
if (( status == 0 )); then
|
||||
# finished_at lands microseconds before the child exits, so the 0.5s poll
|
||||
# rarely renders a full bar before the finish screen replaces it.
|
||||
# Finish the trace even when a short-lived child exits between state polls.
|
||||
LAST_PM=1000
|
||||
PHASE_NAME="Installation complete"
|
||||
while (( DISPLAY_PM < 999 )); do
|
||||
render_dynamic >"$TTY_PATH" 2>/dev/null || true
|
||||
sleep 0.05
|
||||
done
|
||||
DISPLAY_PM=1000
|
||||
render_dynamic >"$TTY_PATH" 2>/dev/null || true
|
||||
sleep 0.4
|
||||
fi
|
||||
@@ -792,7 +1159,15 @@ if (( status != 0 )); then
|
||||
done <<<"$failure_summary"
|
||||
fi
|
||||
fi
|
||||
render_failure "$failure_status" "$failure_summary" >"$TTY_PATH" 2>/dev/null || true
|
||||
failure_media_diagnosis="$(diagnose_media || true)"
|
||||
render_failure "$failure_status" "$failure_summary" "$failure_media_diagnosis" >"$TTY_PATH" 2>/dev/null || true
|
||||
# Recorded after the screen is drawn, not before: the log tail on that screen
|
||||
# reads this same file, and the diagnosis printed twice reads as two findings.
|
||||
if [[ -n $failure_media_diagnosis ]]; then
|
||||
while IFS= read -r line; do
|
||||
[[ -n $line ]] && dashboard_log "$line"
|
||||
done <<<"$failure_media_diagnosis"
|
||||
fi
|
||||
failure_menu
|
||||
exit "$failure_status"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Say whether an install failure was the install medium, and say it in words
|
||||
# that name the fix.
|
||||
#
|
||||
# The ISO's offline mirror is bind-mounted onto the target's package cache for
|
||||
# the duration of pacstrap (see _mount_offline_package_cache), so a package
|
||||
# pacman calls corrupt under that path is a byte that came off the medium --
|
||||
# nothing else ever writes there. That much is certain, and it is the part
|
||||
# worth saying out loud, because the failure screen otherwise ends at "get help
|
||||
# at discord" with the word USB nowhere on it.
|
||||
#
|
||||
# What is not certain is which way the medium failed. Bytes written wrong and
|
||||
# bytes read wrong produce the same checksum mismatch, and pacstrap runs with
|
||||
# --noconfirm, which answers yes to pacman's "delete it?" and unlinks the
|
||||
# package through the bind mount before anything here can weigh it. So the
|
||||
# usual verdict names the medium and gives both remedies in the order worth
|
||||
# trying. When the package does survive, its checksum in the mirror's database
|
||||
# tells the two apart, and then this says which.
|
||||
#
|
||||
# Prints the diagnosis on stdout, one line per line, headline first. Exits 1
|
||||
# with no output when there is nothing here to diagnose, so the caller can ask
|
||||
# unconditionally.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
TARGET_PACKAGE_CACHE="${OMARCHY_TARGET_PACKAGE_CACHE:-/mnt/var/cache/pacman/pkg}"
|
||||
OFFLINE_MIRROR="${OMARCHY_OFFLINE_MIRROR:-/var/cache/omarchy/mirror/offline}"
|
||||
|
||||
if (( $# == 0 )); then
|
||||
echo "Usage: omarchy-install-diagnose-media <log-file>..." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Only packages under the target cache count. A corrupt package anywhere else
|
||||
# was not read off this ISO, and guessing wrong here sends someone to buy a USB
|
||||
# stick they did not need.
|
||||
corrupt_package() {
|
||||
local log match package=""
|
||||
|
||||
for log in "$@"; do
|
||||
[[ -f $log ]] || continue
|
||||
match=$(sed -n "s|.*${TARGET_PACKAGE_CACHE}/\([^ ]*\) is corrupted.*|\1|p" "$log" | tail -n 1)
|
||||
[[ -n $match ]] && package="$match"
|
||||
done
|
||||
|
||||
printf '%s' "$package"
|
||||
}
|
||||
|
||||
expected_checksum() {
|
||||
local package="$1" reader
|
||||
|
||||
[[ -f $OFFLINE_MIRROR/offline.db ]] || return 1
|
||||
|
||||
if command -v bsdtar >/dev/null 2>&1; then
|
||||
reader=(bsdtar -xOf "$OFFLINE_MIRROR/offline.db")
|
||||
else
|
||||
reader=(tar -xOzf "$OFFLINE_MIRROR/offline.db")
|
||||
fi
|
||||
|
||||
# repo-add writes %FILENAME% before %SHA256SUM% in every desc record, so the
|
||||
# name in hand when a checksum arrives is the name it belongs to. The wanted
|
||||
# name comes through the environment rather than -v, which would read a
|
||||
# backslash in a package version as an escape sequence.
|
||||
"${reader[@]}" 2>/dev/null | want="$package" awk '
|
||||
BEGIN { want = ENVIRON["want"] }
|
||||
$0 == "%FILENAME%" { getline name; next }
|
||||
$0 == "%SHA256SUM%" { getline sha; if (name == want) { print sha; exit } }
|
||||
'
|
||||
}
|
||||
|
||||
actual_checksum() {
|
||||
local package="$1" sum
|
||||
|
||||
[[ -f $OFFLINE_MIRROR/$package ]] || return 1
|
||||
sum=$(sha256sum "$OFFLINE_MIRROR/$package" 2>/dev/null) || return 1
|
||||
printf '%s' "${sum%% *}"
|
||||
}
|
||||
|
||||
package=$(corrupt_package "$@")
|
||||
if [[ -z $package ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The name reaches a terminal, and a package version may legally hold a
|
||||
# backslash that the renderer would read as an escape. Strip anything a package
|
||||
# filename has no business containing before printing it.
|
||||
display_package="${package//[^A-Za-z0-9._+:@%-]/}"
|
||||
|
||||
expected=$(expected_checksum "$package")
|
||||
actual=$(actual_checksum "$package")
|
||||
|
||||
# The package name goes on a line of its own throughout. Names run past fifty
|
||||
# characters and the screen is only as wide as the logo, so a name sharing a
|
||||
# line with the sentence that explains it truncates the explanation away. The
|
||||
# blank line after it separates what happened from what to do about it.
|
||||
if [[ -n $expected && -n $actual ]]; then
|
||||
if [[ $expected == "$actual" ]]; then
|
||||
# Intact now, so the bytes were misread rather than written wrong, and
|
||||
# re-downloading fixes neither of the things that could have done it.
|
||||
echo "The install medium was misread"
|
||||
echo "This package is intact on this ISO, so the download and the write were fine:"
|
||||
echo "$display_package"
|
||||
echo
|
||||
echo "Try another USB port or another stick, and test this machine's memory"
|
||||
else
|
||||
echo "The install medium is damaged"
|
||||
echo "This package no longer matches the checksum it was built with:"
|
||||
echo "$display_package"
|
||||
echo
|
||||
echo "Re-download the ISO, verify it with sha256sum, and write the USB again"
|
||||
echo "If a verified ISO fails here too, the USB stick itself is the problem"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The usual case: pacman deleted the package it rejected, so the medium is the
|
||||
# answer but the manner of its failure is not recoverable from here.
|
||||
echo "The install medium is damaged or misread"
|
||||
echo "pacman rejected this package, read straight off this ISO:"
|
||||
echo "$display_package"
|
||||
echo
|
||||
echo "Verify your download with sha256sum, then write the USB again"
|
||||
echo "If the download verifies, the stick or this machine's memory is at fault"
|
||||
@@ -0,0 +1,131 @@
|
||||
# Disk partitioning helpers, shared by the ISO configurator and its tests.
|
||||
#
|
||||
# The rule here is: never predict a partition number. parted fills the lowest
|
||||
# free GPT slot, not "highest existing + 1", so any disk whose numbering has a
|
||||
# hole — exactly what deleting a partition to free space leaves behind, which
|
||||
# is what our own partition tool tells the user to do — hands back a number we
|
||||
# did not choose. Everything below reads back what was actually created.
|
||||
#
|
||||
# Sourced, not executed. The configurator defines abort() and
|
||||
# disk_abort_hook(); tests get the plain fallbacks.
|
||||
|
||||
# Partitions this run created, in creation order. rollback_created_parts()
|
||||
# undoes exactly these and nothing else.
|
||||
created_parts=()
|
||||
|
||||
# Set by create_partition() instead of being printed: a command substitution
|
||||
# would run it in a subshell and lose the created_parts bookkeeping.
|
||||
created_partition_number=""
|
||||
|
||||
# Compute partition device path, handling NVMe/mmcblk's pN naming.
|
||||
partition_path() {
|
||||
local _disk="$1" _num="$2"
|
||||
if [[ "$_disk" == *nvme* || "$_disk" == *mmcblk* ]]; then
|
||||
echo "${_disk}p${_num}"
|
||||
else
|
||||
echo "${_disk}${_num}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Partition numbers as the on-disk GPT reports them. parted rather than lsblk
|
||||
# on purpose: this is the table parted itself is about to modify, so discovery
|
||||
# never depends on the kernel having re-read the partition table yet — and the
|
||||
# same code works against an image file in tests, where lsblk sees nothing.
|
||||
partition_numbers() {
|
||||
parted -ms "$1" unit B print 2>/dev/null | tail -n +3 | cut -d: -f1
|
||||
}
|
||||
|
||||
partition_size_bytes() {
|
||||
parted -ms "$1" unit B print 2>/dev/null |
|
||||
awk -F: -v n="$2" '$1 == n { gsub(/B/, "", $4); print $4; exit }'
|
||||
}
|
||||
|
||||
# Fail loudly. The former version looped over sleep and returned its status,
|
||||
# so a device that never appeared was indistinguishable from one that did.
|
||||
wait_for_device() {
|
||||
local i
|
||||
for i in $(seq 1 10); do
|
||||
[[ -b "$1" ]] && return 0
|
||||
udevadm settle 2>/dev/null || true
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_disk_abort() {
|
||||
if declare -F disk_abort_hook >/dev/null; then
|
||||
disk_abort_hook "$1"
|
||||
elif declare -F abort >/dev/null; then
|
||||
abort "$1"
|
||||
fi
|
||||
echo "Error: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run a disk-writing command, fold its stderr into stdout, and abort on a
|
||||
# non-zero exit. The fold matters: .automated_script.sh tees stdout into
|
||||
# /var/log/omarchy-install.log but sends stderr straight to the tty (gum draws
|
||||
# its TUI there), so an unwrapped failure leaves no trace in the log the user
|
||||
# uploads — and the configurator's next screen clears it off the display too.
|
||||
disk_step() {
|
||||
local desc="$1"
|
||||
shift
|
||||
local output status=0
|
||||
output=$("$@" 2>&1) || status=$?
|
||||
[[ -n $output ]] && printf '%s\n' "$output"
|
||||
(( status == 0 )) && return 0
|
||||
_disk_abort "$desc failed (exit $status)"
|
||||
}
|
||||
|
||||
# Create one partition and report the number parted actually assigned.
|
||||
# Returns non-zero without touching created_parts if anything looks wrong;
|
||||
# the caller decides how loudly to fail.
|
||||
create_partition() {
|
||||
local disk="$1" start="$2" end="$3" fstype="$4" name="$5"
|
||||
local before after num actual want tolerance
|
||||
|
||||
created_partition_number=""
|
||||
|
||||
# Lexicographic sort on both sides: comm needs its inputs ordered the same
|
||||
# way it compares them, and `sort -n` (1, 2, 10) is not that order.
|
||||
before=$(partition_numbers "$disk" | sort)
|
||||
|
||||
parted --script "$disk" mkpart primary "$fstype" "${start}B" "${end}B" || return 1
|
||||
partprobe "$disk" 2>/dev/null || true
|
||||
udevadm settle 2>/dev/null || true
|
||||
|
||||
after=$(partition_numbers "$disk" | sort)
|
||||
num=$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after") | head -1)
|
||||
[[ -n $num ]] || return 1
|
||||
|
||||
# The number must be genuinely new. This is the safety property that keeps a
|
||||
# numbering mistake from ever formatting a partition somebody else is using;
|
||||
# it is cheaper and more reliable than sniffing the target for signatures,
|
||||
# which would false-positive on remnants left in freed space.
|
||||
grep -qx "$num" <<<"$before" && return 1
|
||||
|
||||
actual=$(partition_size_bytes "$disk" "$num")
|
||||
[[ -n $actual ]] || return 1
|
||||
want=$((end - start))
|
||||
tolerance=$((1024 * 1024))
|
||||
(( actual >= want - tolerance && actual <= want + tolerance )) || return 1
|
||||
|
||||
parted --script "$disk" name "$num" "$name" || true
|
||||
|
||||
created_parts+=("$num")
|
||||
created_partition_number="$num"
|
||||
}
|
||||
|
||||
# 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"
|
||||
# with no way to connect that to what just happened.
|
||||
rollback_created_parts() {
|
||||
local disk="$1" n
|
||||
(( ${#created_parts[@]} > 0 )) || return 0
|
||||
for n in $(printf '%s\n' "${created_parts[@]}" | sort -rn); do
|
||||
parted --script "$disk" rm "$n" >/dev/null 2>&1 || true
|
||||
done
|
||||
partprobe "$disk" 2>/dev/null || true
|
||||
created_parts=()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# The Chinese installer's console: the VT's Tokyo Night palette, with Noto Sans Mono CJK from the offline mirror.
|
||||
[main]
|
||||
font=Noto Sans Mono CJK SC:size=13
|
||||
# Noto's tall default rows leave the summary too few lines for the logo.
|
||||
line-height=22px
|
||||
|
||||
[colors-dark]
|
||||
background=1a1b26
|
||||
foreground=a9b1d6
|
||||
regular0=1a1b26
|
||||
regular1=f7768e
|
||||
regular2=9ece6a
|
||||
regular3=e0af68
|
||||
regular4=7aa2f7
|
||||
regular5=bb9af7
|
||||
regular6=7dcfff
|
||||
regular7=a9b1d6
|
||||
bright0=414868
|
||||
bright1=f7768e
|
||||
bright2=9ece6a
|
||||
bright3=e0af68
|
||||
bright4=7aa2f7
|
||||
bright5=bb9af7
|
||||
bright6=7dcfff
|
||||
bright7=c0caf5
|
||||
|
||||
# cage draws no title bar, and a reserved one leaves a black strip.
|
||||
[csd]
|
||||
preferred=none
|
||||
@@ -13,7 +13,7 @@ The canonical call sequence (mirrored from archinstall.scripts.guided.py) is:
|
||||
# configs do their own mounting before the Installer context opens.
|
||||
if disk_config.config_type != DiskLayoutType.Pre_mount:
|
||||
inst.mount_ordered_layout()
|
||||
inst.sanity_check(offline=, skip_ntp=, skip_wkd=)
|
||||
sanity_check(inst)
|
||||
inst.generate_key_files() # encrypted only
|
||||
inst.set_mirrors(handler, mirror_config, on_target=False)
|
||||
inst.minimal_installation(...) # base + linux pacstrap
|
||||
@@ -214,6 +214,14 @@ def _method_accepts(method, name: str) -> bool:
|
||||
return name in (*positional, *kwonly)
|
||||
|
||||
|
||||
def sanity_check(installer) -> None:
|
||||
"""Archinstall 4.5 removed offline; retain it for older releases."""
|
||||
options = {"skip_ntp": True, "skip_wkd": True}
|
||||
if _method_accepts(installer.sanity_check, "offline"):
|
||||
options["offline"] = True
|
||||
installer.sanity_check(**options)
|
||||
|
||||
|
||||
def _method_accepts_users(method) -> bool:
|
||||
return _method_accepts(method, "users")
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
r"""Reading text out of the commands the installer shells out to.
|
||||
|
||||
Firmware and filesystem metadata reach the orchestrator as command output, and
|
||||
neither is obliged to be UTF-8: an HP ProBook's NVRAM held a legacy BBS entry
|
||||
whose device path began `BBS(HD,\x80\x7f\xff\x04`, efibootmgr printed those
|
||||
bytes verbatim, and subprocess's strict text decoding turned a boot entry
|
||||
nothing here reads into a failed install.
|
||||
|
||||
So capture() replaces what it cannot decode, and the boot-entry parsing that
|
||||
reads its output stays tolerant on purpose: an entry's text is its label and its
|
||||
device path together, and the junk lives in the path, so refusing one would abort
|
||||
the install this exists to let finish. Nothing mangled reaches efibootmgr from
|
||||
there either way — only four-digit ASCII boot numbers become arguments, and a
|
||||
boot order is filtered through the entries that parsed — so a label that loses a
|
||||
byte costs a stale entry left behind rather than a broken system.
|
||||
|
||||
An identifier is the other case entirely: require_text() refuses a UUID or a
|
||||
device name that came through mangled, because that one is written into fstab and
|
||||
the kernel cmdline, or handed to mount.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
REPLACEMENT = "\ufffd"
|
||||
|
||||
|
||||
def capture(cmd: list[str], *, check: bool = False) -> subprocess.CompletedProcess:
|
||||
"""Run cmd and return it with stdout/stderr decoded, bad bytes replaced."""
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
def require_text(value: str, what: str) -> str:
|
||||
"""Refuse a value the installed system depends on if bytes were replaced in it.
|
||||
|
||||
Validation reads these back from the same command that wrote them, so a
|
||||
mangled UUID agrees with itself and ships a machine that cannot find its
|
||||
root at boot. Stopping here says why while someone is still watching.
|
||||
"""
|
||||
if REPLACEMENT in value:
|
||||
raise RuntimeError(f"{what} is not valid text: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def capture_identifier(cmd: list[str], what: str) -> str:
|
||||
"""Capture a single value the installed system will depend on."""
|
||||
return require_text(capture(cmd, check=True).stdout.strip(), what)
|
||||
@@ -11,6 +11,9 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .keyboard import validate_input_selection
|
||||
from .region import GLOBAL, resolve_region
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallContext:
|
||||
@@ -27,6 +30,7 @@ class InstallContext:
|
||||
arch_config_path: Path
|
||||
omarchy_install: dict[str, Any]
|
||||
defer_provisioning: bool = False
|
||||
region: str = GLOBAL
|
||||
|
||||
target: Path = Path("/mnt")
|
||||
omarchy_path: Path = Path("/usr/share/omarchy")
|
||||
@@ -50,6 +54,20 @@ class InstallContext:
|
||||
user_configuration = json.loads(config_path.read_text())
|
||||
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
|
||||
|
||||
# Autoinstall configs may omit kernels, which makes archinstall default
|
||||
# to stock linux. Apply the same hardware default as the configurator,
|
||||
# while honoring an explicit selection (including storage-only configs).
|
||||
if not user_configuration.get("kernels"):
|
||||
kernel = (omarchy_install.get("storage") or {}).get("kernel") or _default_kernel()
|
||||
user_configuration["kernels"] = [kernel]
|
||||
|
||||
validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", ""))
|
||||
|
||||
# Resolved before any disk is touched, so an autoinstall naming a
|
||||
# region this ISO lacks fails without wiping anything.
|
||||
region = resolve_region(omarchy_install, user_configuration.get("timezone"))
|
||||
omarchy_install["region"] = region
|
||||
|
||||
# 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
|
||||
@@ -114,6 +132,7 @@ class InstallContext:
|
||||
arch_config_path=arch_config_path,
|
||||
omarchy_install=omarchy_install,
|
||||
defer_provisioning=defer_provisioning,
|
||||
region=region,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
disk_config = user_configuration.get("disk_config", {})
|
||||
@@ -175,6 +194,18 @@ def _inject_provisioning_encryption_password(arch_configuration: dict, user_cred
|
||||
user_credentials["encryption_password"] = password
|
||||
|
||||
|
||||
def _default_kernel(pci_devices: Path = Path("/sys/bus/pci/devices")) -> str:
|
||||
for device in pci_devices.glob("*"):
|
||||
try:
|
||||
vendor = (device / "vendor").read_text().strip().lower()
|
||||
device_id = (device / "device").read_text().strip().lower()
|
||||
except OSError:
|
||||
continue
|
||||
if vendor == "0x106b" and device_id in {"0x1801", "0x1802"}:
|
||||
return "linux-t2"
|
||||
return "linux-omarchy"
|
||||
|
||||
|
||||
def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
|
||||
disk_config = user_configuration.get("disk_config", {})
|
||||
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
|
||||
|
||||
@@ -11,30 +11,36 @@ generated: nothing on an Omarchy system reads it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .command import capture
|
||||
|
||||
def configure_keyboard(target: Path, language: str) -> bool:
|
||||
|
||||
def configure_keyboard(target: Path, language: str, input_method: str | None = None, xkb_layout: str = "") -> bool:
|
||||
"""Write the console keymap into a mounted target without booting it.
|
||||
|
||||
Returns False for layouts localectl doesn't know, matching archinstall:
|
||||
warn and keep the default. Every layout the configurator offers is known;
|
||||
the guard is for the kb_layout an autoinstall drive can name freely.
|
||||
"""
|
||||
validate_input_selection(input_method, xkb_layout)
|
||||
if not language.strip():
|
||||
return True
|
||||
if input_method is None:
|
||||
return True
|
||||
language = "us"
|
||||
|
||||
result = subprocess.run(
|
||||
["localectl", "--no-pager", "list-keymaps"],
|
||||
check=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Input engines are separate from real console keymaps. The shared picker
|
||||
# uses US for Chinese/Korean and carries its preference in omarchy_install.
|
||||
if input_method is None:
|
||||
input_method = "mozc" if language == "jp106" else "none"
|
||||
|
||||
result = capture(["localectl", "--no-pager", "list-keymaps"])
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "localectl returned an error"
|
||||
raise RuntimeError(f"Unable to list keyboard layouts: {detail}")
|
||||
if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}:
|
||||
if input_method != "none":
|
||||
raise ValueError(f"Unknown keyboard language: {language}")
|
||||
return False
|
||||
|
||||
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
|
||||
@@ -47,12 +53,7 @@ def configure_keyboard(target: Path, language: str) -> bool:
|
||||
None,
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["systemd-firstboot", f"--root={target}", f"--keymap={language}", "--force"],
|
||||
check=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
result = capture(["systemd-firstboot", f"--root={target}", f"--keymap={language}", "--force"])
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "systemd-firstboot returned an error"
|
||||
raise RuntimeError(f"Unable to configure keyboard layout {language}: {detail}")
|
||||
@@ -60,4 +61,15 @@ def configure_keyboard(target: Path, language: str) -> bool:
|
||||
if font:
|
||||
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
|
||||
|
||||
preference = target / "etc/omarchy/input-method"
|
||||
preference.parent.mkdir(parents=True, exist_ok=True)
|
||||
preference.write_text(f"INPUT_METHOD={input_method}\nXKB_LAYOUT={xkb_layout}\n")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_input_selection(input_method: str | None, xkb_layout: str) -> None:
|
||||
if input_method is not None and input_method not in {"none", "mozc", "hangul", "pinyin", "chewing"}:
|
||||
raise ValueError(f"Unknown input method: {input_method}")
|
||||
if xkb_layout not in {"", "kr"}:
|
||||
raise ValueError(f"Unknown input keyboard layout: {xkb_layout}")
|
||||
@@ -33,8 +33,10 @@ from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from . import archinstall_adapter as arch
|
||||
from .command import capture, capture_identifier, require_text
|
||||
from .context import InstallContext
|
||||
from .keyboard import configure_keyboard
|
||||
from .region import GLOBAL, region_keyrings, region_packages
|
||||
from .ui import error, info
|
||||
|
||||
|
||||
@@ -234,11 +236,7 @@ def arch_install_system(ctx: InstallContext) -> None:
|
||||
if not pre_mounted:
|
||||
installer.mount_ordered_layout()
|
||||
|
||||
installer.sanity_check(
|
||||
offline=True,
|
||||
skip_ntp=True,
|
||||
skip_wkd=True,
|
||||
)
|
||||
arch.sanity_check(installer)
|
||||
|
||||
if not pre_mounted and arch.is_encrypted(config):
|
||||
installer.generate_key_files()
|
||||
@@ -268,7 +266,16 @@ def arch_install_system(ctx: InstallContext) -> None:
|
||||
pacman_config=config.pacman_config,
|
||||
)
|
||||
|
||||
if not configure_keyboard(installer.target, kb_layout):
|
||||
# Headers are optional kernel dependencies. Install them before any
|
||||
# DKMS package so generic installs have them too, and module builds
|
||||
# use the target kernel instead of the live ISO's kernel.
|
||||
installer.add_additional_packages([f"{kernel}-headers" for kernel in config.kernels])
|
||||
|
||||
if not configure_keyboard(
|
||||
installer.target, kb_layout,
|
||||
ctx.omarchy_install.get("input_method"),
|
||||
ctx.omarchy_install.get("input_xkb_layout", ""),
|
||||
):
|
||||
error(f"Invalid keyboard language specified: {kb_layout}")
|
||||
|
||||
if config.mirror_config:
|
||||
@@ -292,6 +299,11 @@ def arch_install_system(ctx: InstallContext) -> None:
|
||||
info("› installing Omarchy runtime + omarchy-base.packages")
|
||||
installer.add_additional_packages(_runtime_package_list(ctx))
|
||||
|
||||
if region_pkgs := region_packages(ctx.region):
|
||||
info(f"› installing {ctx.region} region packages: {', '.join(region_pkgs)}")
|
||||
installer.add_additional_packages(region_pkgs)
|
||||
_write_region_marker(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
|
||||
@@ -722,6 +734,17 @@ def _unmask_mkinitcpio_pacman_hooks(
|
||||
info(f"warning: failed to restore pacman hook mask for {name}: {exc}")
|
||||
|
||||
|
||||
def _write_region_marker(ctx: InstallContext) -> None:
|
||||
"""Tell the runtime's pacman finalizer which profile to apply. Global
|
||||
installs write nothing, so they stay identical to a region-less install."""
|
||||
if ctx.region == GLOBAL:
|
||||
return
|
||||
marker = ctx.target / "etc/omarchy/region"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text(ctx.region + "\n")
|
||||
marker.chmod(0o644)
|
||||
|
||||
|
||||
def _runtime_package_list(ctx: InstallContext) -> list[str]:
|
||||
"""Selected Omarchy runtime package + every package in the ISO-bundled
|
||||
base package list that isn't already installed early."""
|
||||
@@ -765,33 +788,35 @@ def _storage_intent(ctx: InstallContext) -> dict:
|
||||
|
||||
def verify_protected_mounts(ctx: InstallContext) -> None:
|
||||
target = ctx.target
|
||||
if not _is_mountpoint(target):
|
||||
raise RuntimeError(f"protected mode: {target} is not a mountpoint")
|
||||
|
||||
boot = _boot_intent(ctx)
|
||||
storage = _storage_intent(ctx)
|
||||
|
||||
# Devices before the mountpoint: when the configurator hands over paths for
|
||||
# partitions that do not exist, "root_device /dev/nvme0n1p12 does not
|
||||
# exist" is diagnosable from the log alone, where "/mnt is not a
|
||||
# mountpoint" sends everyone looking at the mount instead of the paths.
|
||||
for key in ("esp_device", "root_device"):
|
||||
if not storage.get(key):
|
||||
device = storage.get(key)
|
||||
if not device:
|
||||
raise RuntimeError(f"protected mode: omarchy_install.storage.{key} missing")
|
||||
if not Path(device).exists():
|
||||
raise RuntimeError(f"protected mode: {key} {device} does not exist")
|
||||
|
||||
if not _is_mountpoint(target):
|
||||
raise RuntimeError(f"protected mode: {target} is not a mountpoint")
|
||||
|
||||
esp_mp = target / boot["esp_mount"].lstrip("/")
|
||||
if not _is_mountpoint(esp_mp):
|
||||
esp_dev = storage["esp_device"]
|
||||
if not Path(esp_dev).exists():
|
||||
raise RuntimeError(f"protected mode: ESP device {esp_dev} does not exist")
|
||||
info(f"› remounting protected ESP {esp_dev} at {esp_mp}")
|
||||
esp_mp.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["mount", esp_dev, str(esp_mp)], check=True)
|
||||
|
||||
info(f"› protected target verified: kernel={storage.get('kernel', 'linux')} esp={boot['esp_mount']}")
|
||||
info(f"› protected target verified: kernel={storage.get('kernel', 'linux-omarchy')} esp={boot['esp_mount']}")
|
||||
|
||||
|
||||
def _is_mountpoint(path: Path) -> bool:
|
||||
res = subprocess.run(
|
||||
["findmnt", "-rn", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
res = capture(["findmnt", "-rn", str(path)])
|
||||
return res.returncode == 0 and bool(res.stdout.strip())
|
||||
|
||||
|
||||
@@ -805,11 +830,9 @@ def _btrfs_root_device(ctx: InstallContext) -> str:
|
||||
|
||||
|
||||
def _blkid_uuid(device: str) -> str:
|
||||
res = subprocess.run(
|
||||
["blkid", "-s", "UUID", "-o", "value", device],
|
||||
capture_output=True, text=True, check=True,
|
||||
uuid = capture_identifier(
|
||||
["blkid", "-s", "UUID", "-o", "value", device], f"the UUID of {device}"
|
||||
)
|
||||
uuid = res.stdout.strip()
|
||||
if not uuid:
|
||||
raise RuntimeError(f"blkid returned no UUID for {device}")
|
||||
return uuid
|
||||
@@ -822,11 +845,9 @@ def _esp_device(ctx: InstallContext) -> str:
|
||||
|
||||
boot = _boot_intent(ctx)
|
||||
esp_mp = ctx.target / boot["esp_mount"].lstrip("/")
|
||||
res = subprocess.run(
|
||||
["findmnt", "-n", "-o", "SOURCE", str(esp_mp)],
|
||||
capture_output=True, text=True, check=True,
|
||||
dev = capture_identifier(
|
||||
["findmnt", "-n", "-o", "SOURCE", str(esp_mp)], f"the ESP device at {esp_mp}"
|
||||
)
|
||||
dev = res.stdout.strip()
|
||||
if not dev:
|
||||
raise RuntimeError(f"could not resolve ESP device at {esp_mp}")
|
||||
return dev
|
||||
@@ -898,10 +919,7 @@ _BOOT_ORDER_RE = re.compile(r"^BootOrder:\s*(.*)$")
|
||||
|
||||
|
||||
def _read_efibootmgr() -> dict:
|
||||
res = subprocess.run(
|
||||
["efibootmgr"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
res = capture(["efibootmgr"], check=True)
|
||||
entries: dict[str, str] = {}
|
||||
order: list[str] = []
|
||||
for line in res.stdout.splitlines():
|
||||
@@ -920,16 +938,14 @@ def _find_label_entries(entries: dict[str, str], needle: str) -> list[str]:
|
||||
|
||||
|
||||
def _split_partition_device(part_dev: str) -> tuple[str, int]:
|
||||
parent = subprocess.run(
|
||||
["lsblk", "-ndo", "PKNAME", part_dev],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
parent = capture_identifier(
|
||||
["lsblk", "-ndo", "PKNAME", part_dev], f"the parent disk of {part_dev}"
|
||||
)
|
||||
if not parent:
|
||||
raise RuntimeError(f"could not find parent disk for {part_dev}")
|
||||
part_num = subprocess.run(
|
||||
["lsblk", "-ndo", "PARTN", part_dev],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout.strip()
|
||||
part_num = capture_identifier(
|
||||
["lsblk", "-ndo", "PARTN", part_dev], f"the partition number of {part_dev}"
|
||||
)
|
||||
if not part_num:
|
||||
raise RuntimeError(f"could not find partition number for {part_dev}")
|
||||
return f"/dev/{parent}", int(part_num)
|
||||
@@ -988,7 +1004,7 @@ def _debug_run(ctx: InstallContext, cmd: list[str]) -> None:
|
||||
if not _install_debug_enabled():
|
||||
return
|
||||
_debug_log(ctx, "+ " + " ".join(cmd))
|
||||
proc = subprocess.run(cmd, check=False, text=True, capture_output=True)
|
||||
proc = capture(cmd)
|
||||
if proc.stdout:
|
||||
with ctx.log_path.open("a", encoding="utf-8") as log:
|
||||
for line in proc.stdout.splitlines():
|
||||
@@ -1136,6 +1152,14 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
|
||||
|
||||
|
||||
def run_system_finalizer(ctx: InstallContext) -> None:
|
||||
# The region's keyring packages are installed by now, but the build
|
||||
# container's GPG state is not inherited. Initialize the target's trust
|
||||
# database with only this region's keys before the finalizer enables its
|
||||
# online repositories; global installs never trust them.
|
||||
if keyrings := region_keyrings(ctx.region):
|
||||
_run_target_setup_command(ctx, ["pacman-key", "--init"])
|
||||
_run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", *keyrings])
|
||||
|
||||
if ctx.defer_provisioning:
|
||||
cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"]
|
||||
else:
|
||||
@@ -1650,6 +1674,7 @@ def _tailscale_authkey(path: Path) -> str:
|
||||
|
||||
def validate_boot(ctx: InstallContext) -> None:
|
||||
_assert_boot_hooks_restored(ctx)
|
||||
_validate_kernel_headers(ctx)
|
||||
|
||||
boot = _boot_intent(ctx)
|
||||
storage = _storage_intent(ctx)
|
||||
@@ -1672,7 +1697,7 @@ def validate_boot(ctx: InstallContext) -> None:
|
||||
default_limine = ctx.target / "etc" / "default" / "limine"
|
||||
config_text = _limine_combined_config_text(ctx, default_limine.read_text())
|
||||
uki_prefix = _limine_setting(config_text, "CUSTOM_UKI_NAME", "omarchy") or "omarchy"
|
||||
kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux"])[0]
|
||||
kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux-omarchy"])[0]
|
||||
|
||||
if arch.has_uefi():
|
||||
limine_binary = esp_mount / boot.get("esp_path", "/EFI/limine").lstrip("/") / boot.get("efi_binary", "limine_x64.efi")
|
||||
@@ -1748,6 +1773,19 @@ def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
|
||||
raise RuntimeError(f"{path} is missing — future kernel updates would ship no UKI")
|
||||
|
||||
|
||||
def _validate_kernel_headers(ctx: InstallContext) -> None:
|
||||
kernels = sorted((ctx.target / "usr/lib/modules").glob("*/pkgbase"))
|
||||
if not kernels:
|
||||
raise RuntimeError("no installed kernel found in target")
|
||||
for pkgbase in kernels:
|
||||
release = pkgbase.parent.name
|
||||
header_release = pkgbase.parent / "build/include/config/kernel.release"
|
||||
if not header_release.is_file():
|
||||
raise RuntimeError(f"{pkgbase.read_text().strip()} ({release}) has no kernel headers")
|
||||
if header_release.read_text().strip() != release:
|
||||
raise RuntimeError(f"{pkgbase.read_text().strip()} headers do not match kernel {release}")
|
||||
|
||||
|
||||
# Every kernel package leaves its pkgbase next to its modules, which is also
|
||||
# the name limine-mkinitcpio-hook builds the UKI under.
|
||||
def _installed_kernels(ctx: InstallContext) -> list[str]:
|
||||
@@ -1799,7 +1837,10 @@ def create_factory_snapshot(ctx: InstallContext) -> None:
|
||||
info("› target root is not the @ subvolume; skipping factory snapshot")
|
||||
return
|
||||
|
||||
device = (_findmnt_value(ctx.target, "SOURCE") or "").split("[")[0]
|
||||
device = require_text(
|
||||
(_findmnt_value(ctx.target, "SOURCE") or "").split("[")[0],
|
||||
f"the btrfs device backing {ctx.target}",
|
||||
)
|
||||
if not device:
|
||||
raise RuntimeError(f"could not determine the btrfs device backing {ctx.target}")
|
||||
|
||||
@@ -1856,10 +1897,7 @@ def _scrub_factory_snapshot(factory: Path) -> None:
|
||||
|
||||
|
||||
def _findmnt_value(path: Path, column: str) -> str | None:
|
||||
res = subprocess.run(
|
||||
["findmnt", "-no", column, str(path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
res = capture(["findmnt", "-no", column, str(path)])
|
||||
value = res.stdout.strip()
|
||||
return value if res.returncode == 0 and value else None
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Region selection: which of the runtime's region profiles a target gets.
|
||||
|
||||
The builder copies every profile from the bundled runtime's default/regions/
|
||||
into REGIONS_DIR, and their packages into the offline mirror. Nobody picks a
|
||||
region by hand: the timezone the user already chose selects it, and an
|
||||
autoinstall config may name one explicitly in omarchy_install.region. Run as
|
||||
`python -m orchestrator.region <timezone>` to print the region the installer
|
||||
would use, which is how the configurator fills its summary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REGIONS_DIR = Path("/usr/share/omarchy-iso/regions")
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
def _entries(path: Path) -> list[str]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
lines = (line.strip() for line in path.read_text().splitlines())
|
||||
return [line for line in lines if line and not line.startswith("#")]
|
||||
|
||||
|
||||
def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) -> str:
|
||||
regions_dir = regions_dir or REGIONS_DIR
|
||||
if timezone and regions_dir.is_dir():
|
||||
for profile in sorted(regions_dir.iterdir()):
|
||||
if timezone in _entries(profile / "timezones"):
|
||||
return profile.name
|
||||
return GLOBAL
|
||||
|
||||
|
||||
def resolve_region(omarchy_install: dict, timezone: str | None, regions_dir: Path | None = None) -> str:
|
||||
regions_dir = regions_dir or REGIONS_DIR
|
||||
region = omarchy_install.get("region")
|
||||
if region is None or region == "":
|
||||
return region_for_timezone(timezone, regions_dir)
|
||||
# Profiles are named by ISO 3166-1 alpha-2 code; checking the shape first
|
||||
# keeps a path like ".." from passing the directory test.
|
||||
if not isinstance(region, str) or (
|
||||
region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir())
|
||||
):
|
||||
raise RuntimeError(f"Unsupported region: {region!r}")
|
||||
return region
|
||||
|
||||
|
||||
def region_packages(region: str, regions_dir: Path | None = None) -> list[str]:
|
||||
if region == GLOBAL:
|
||||
return []
|
||||
return _entries((regions_dir or REGIONS_DIR) / region / "packages")
|
||||
|
||||
|
||||
def region_keyrings(region: str, regions_dir: Path | None = None) -> list[str]:
|
||||
"""Pacman keyrings the profile ships, named the Arch way: <name>-keyring
|
||||
installs /usr/share/pacman/keyrings/<name>.gpg."""
|
||||
return [
|
||||
package.removesuffix("-keyring")
|
||||
for package in region_packages(region, regions_dir)
|
||||
if package.endswith("-keyring")
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(region_for_timezone(sys.argv[1] if len(sys.argv) > 1 else None))
|
||||
@@ -13,6 +13,10 @@ ParallelDownloads = 5
|
||||
SigLevel = Required DatabaseOptional
|
||||
LocalFileSigLevel = Optional
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/edge/$arch
|
||||
|
||||
[core]
|
||||
Server = https://mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
@@ -25,10 +29,6 @@ Include = /etc/pacman.d/mirrorlist
|
||||
Server = https://mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/edge/$arch
|
||||
|
||||
[arch-mact2]
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||
|
||||
@@ -13,6 +13,10 @@ ParallelDownloads = 5
|
||||
SigLevel = Required DatabaseOptional
|
||||
LocalFileSigLevel = Optional
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/rc/$arch
|
||||
|
||||
[core]
|
||||
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
@@ -25,10 +29,6 @@ Include = /etc/pacman.d/mirrorlist
|
||||
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/edge/$arch
|
||||
|
||||
[arch-mact2]
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||
|
||||
@@ -13,6 +13,10 @@ ParallelDownloads = 5
|
||||
SigLevel = Required DatabaseOptional
|
||||
LocalFileSigLevel = Optional
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/stable/$arch
|
||||
|
||||
[core]
|
||||
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
@@ -25,10 +29,6 @@ Include = /etc/pacman.d/mirrorlist
|
||||
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
||||
Include = /etc/pacman.d/mirrorlist
|
||||
|
||||
[omarchy]
|
||||
SigLevel = Optional TrustAll
|
||||
Server = https://pkgs.omarchy.org/stable/$arch
|
||||
|
||||
[arch-mact2]
|
||||
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||
|
||||
@@ -39,6 +39,7 @@ file_permissions=(
|
||||
["/usr/local/bin/omarchy-cidata-load"]="0:0:755"
|
||||
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
|
||||
["/usr/local/bin/omarchy-install-dashboard"]="0:0:755"
|
||||
["/usr/local/bin/omarchy-install-diagnose-media"]="0:0:755"
|
||||
["/usr/local/bin/omarchy-iso-install"]="0:0:755"
|
||||
["/usr/local/bin/omarchy-upload-log"]="0:0:755"
|
||||
["/var/cache/omarchy/mirror/offline/"]="0:0:775"
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# Autoinstall Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Let the standard Omarchy ISO install itself with no keyboard present, driven by config files handed to the VM on a second drive. This makes Omarchy usable as a Packer/Proxmox base image for disposable dev environments: create VM, boot, walk away, SSH in.
|
||||
|
||||
## Product Requirements
|
||||
|
||||
- No ISO rebuild. The shipped ISO autoinstalls when config is present and runs the configurator when it is not.
|
||||
- No new menu entry, no new boot parameter, no separate ISO flavor.
|
||||
- Config format is exactly what the configurator already writes — no second schema to maintain.
|
||||
- The installed machine is reachable over SSH with a key supplied at install time.
|
||||
- Interactive installs behave identically to today.
|
||||
|
||||
---
|
||||
|
||||
## Chosen Architecture
|
||||
|
||||
### Trigger: config presence, not a mode flag
|
||||
|
||||
The installer looks for a drive labeled `cidata` at startup. If it is there and carries the configurator's output files, those files are copied into `/root` and `./configurator` is skipped — the rest of the install runs the ordinary path against ordinary inputs. If it is not there, nothing changes.
|
||||
|
||||
This is Ryan Hughes' suggestion from PR #72 and it is what keeps the diff small: the install pipeline can be rewritten freely as long as it keeps reading its five files out of `/root`, and autoinstall follows along for free.
|
||||
|
||||
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer all already know how to attach one.
|
||||
|
||||
### What the ISO already does for us
|
||||
|
||||
One thing that would otherwise need building is already true on a stock install: `NetworkManager.service` is enabled and active with DHCP by default, so autoinstall needs no `systemd-networkd` unit — one would contend with NetworkManager.
|
||||
|
||||
Remote access needs three things, all verified against a real install rather than inferred:
|
||||
|
||||
- `authorized_keys` for the user.
|
||||
- `systemctl enable sshd.service`. Omarchy ships `openssh` but leaves the service disabled.
|
||||
- `ufw allow ssh`. `install/config/firewall.sh` opens LocalSend (53317) and docker DNS and nothing else, and ufw runs default-deny incoming.
|
||||
|
||||
Still no loosening of `PasswordAuthentication` — key auth only.
|
||||
|
||||
Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. It claims `sshd.service: enabled` and carries an `allow tcp 22` ufw rule; a real install of this ISO has neither. It is a snapshot of one machine's state, not a contract about stock behavior, and trusting it cost two test cycles.
|
||||
|
||||
### Non-interactivity belongs to the dashboard
|
||||
|
||||
`omarchy-install-dashboard` owns every prompt that survives to the end of an install: the reboot confirm and, on failure, the action menu. Both are `gum` calls reading from the TTY, and both hang forever on a headless VM. Autoinstall therefore sets one env var that the dashboard reads, rather than patching prompts out of scripts after the fact — which is what PR #72 did to `finished.sh`, and what everyone correctly disliked.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No config generator, validator, or schema doc. The configurator is the source of truth for the format; users copy its output.
|
||||
- No secret management. Whoever builds the `cidata` drive owns the password hash and the public key on it.
|
||||
- No PXE, no network-fetched config, no cloud-init compatibility beyond reusing the drive label.
|
||||
- No changes to disk selection, encryption, or any install phase behavior.
|
||||
|
||||
---
|
||||
|
||||
## Repo Implementation Plan
|
||||
|
||||
## 1) Detect the cidata drive and skip the configurator
|
||||
|
||||
### Files
|
||||
- `configs/airootfs/usr/local/bin/omarchy-cidata-load`
|
||||
- `configs/airootfs/root/.automated_script.sh`
|
||||
- `configs/profiledef.sh`
|
||||
|
||||
### Changes
|
||||
- Add `omarchy-cidata-load` alongside the other install commands in `/usr/local/bin` (with the matching `file_permissions` entry in `profiledef.sh`). It exits 0 when an autoinstall drive was loaded:
|
||||
- `udevadm settle` first: the by-label symlinks come from udev, and probing before enumeration finishes would silently drop a headless install into the wizard.
|
||||
- Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists.
|
||||
- Mount read-only on a temp mountpoint; exit non-zero if the mount fails.
|
||||
- Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero.
|
||||
- Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `authorized_keys` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error.
|
||||
- Unmount before exiting. Leave nothing mounted for the install to trip over.
|
||||
- The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing.
|
||||
- Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`.
|
||||
- Pass `--authorized-keys-file /root/authorized_keys` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line.
|
||||
|
||||
### Notes
|
||||
- The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies.
|
||||
- `omarchy-cidata-load` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start.
|
||||
|
||||
### Acceptance criteria
|
||||
- ISO with no `cidata` drive attached: configurator runs, byte-identical UX to today.
|
||||
- ISO with a `cidata` drive carrying the five files: configurator never appears; install starts within seconds of boot.
|
||||
- ISO with a drive labeled `cidata` that is empty or carries only one of the two required files: falls back to the configurator rather than failing the boot.
|
||||
- Nothing remains mounted from `/dev/disk/by-label/cidata` once the install begins.
|
||||
|
||||
---
|
||||
|
||||
## 2) Non-interactive dashboard
|
||||
|
||||
### File
|
||||
- `configs/airootfs/usr/local/bin/omarchy-install-dashboard`
|
||||
|
||||
### Changes
|
||||
- Read `OMARCHY_UI_INTERACTIVE` once near the other env reads at the top.
|
||||
- In `reboot_prompt()` (`:474`): when non-interactive, return 0 immediately instead of calling `gum confirm`. The existing `OMARCHY_UI_AUTO_REBOOT=no` check at `:737` stays in front of the actual `reboot`, so a debug run can set both and stop on the finish screen without a keyboard.
|
||||
- In `failure_menu()` (`:597`): treat non-interactive as equivalent to the existing `OMARCHY_UI_FAILURE_ACTION=exit` early return (`:600`), so a failed autoinstall exits with the installer's status instead of blocking on `gum choose` forever. The failure screen and log tail still render, and the serial console still captures them.
|
||||
|
||||
### Why here and not in the caller
|
||||
The dashboard is the only process that owns the visible UI. Suppressing its prompts anywhere else means editing scripts it invokes — the `finished.sh` `sed` from PR #72 — which breaks the moment those scripts move.
|
||||
|
||||
### Acceptance criteria
|
||||
- Autoinstall run reaches the finish screen and reboots with no input.
|
||||
- Autoinstall run whose install fails renders the failure screen, exits non-zero, and does not hang.
|
||||
- Interactive run still prompts for reboot and still offers the failure menu.
|
||||
- `OMARCHY_UI_INTERACTIVE=no` plus `OMARCHY_UI_AUTO_REBOOT=no` stops at the finish screen without prompting.
|
||||
|
||||
---
|
||||
|
||||
## 3) SSH keys as an orchestrator phase
|
||||
|
||||
### Files
|
||||
- `configs/airootfs/usr/local/bin/omarchy-iso-install`
|
||||
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py`
|
||||
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py`
|
||||
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py`
|
||||
|
||||
### Changes
|
||||
- `omarchy-iso-install`: add `--authorized-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`).
|
||||
- `context.py`: add an `authorized_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist.
|
||||
- `phases_impl.py`: add `configure_ssh_access(ctx)`:
|
||||
- Return immediately when `ctx.authorized_keys_path` is `None`.
|
||||
- Read the file as sshd's own authorized_keys format, dropping blank lines and # comments; write the keys one per line to `<target>/home/<user>/.ssh/authorized_keys`.
|
||||
- `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot <target> chown -R <user>:<user>`. Do not hardcode uid 1000 as PR #72 did — ask the target for it.
|
||||
- `arch-chroot <target> systemctl enable sshd.service`. Works cleanly in the chroot.
|
||||
- `arch-chroot <target> ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing.
|
||||
- An `authorized_keys` with no usable keys must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen.
|
||||
- `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`.
|
||||
|
||||
### Model to follow
|
||||
`configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure.
|
||||
|
||||
### Acceptance criteria
|
||||
- Install with `authorized_keys`: `ssh <user>@<vm>` works with the corresponding private key on first boot, no password.
|
||||
- Install without `authorized_keys`: phase runs, does nothing, install unaffected; `~/.ssh` is not created.
|
||||
- `authorized_keys` is `0600` and owned by the install user, not root.
|
||||
- Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it.
|
||||
- Password SSH auth is left at the distro default — autoinstall does not enable it.
|
||||
- Interactive installs are unaffected: the flag is passed but the file never exists.
|
||||
|
||||
---
|
||||
|
||||
## 4) Documentation
|
||||
|
||||
### File
|
||||
- `README.md`
|
||||
|
||||
### Changes
|
||||
- New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward.
|
||||
- Show `openssl passwd -6` for the credential hash and the `authorized_keys` format.
|
||||
- State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote.
|
||||
- Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists.
|
||||
|
||||
---
|
||||
|
||||
## 5) Validation
|
||||
|
||||
### Unit tests
|
||||
`./test/all` runs everything under `test/`: `cidata-load-test.sh` exercises `omarchy-cidata-load` against a sandbox with `mount`/`umount`/`udevadm` stubbed, and `test_configure_ssh_access.py` runs the orchestrator phase against a temp target with `subprocess.run` recorded and ufw's write-the-rule-then-fail chroot behavior simulated. The Python tests run through `unittest` discovery alongside the repo's existing `test_*.py` suites.
|
||||
|
||||
### Manual matrix
|
||||
1. Build the ISO from this branch.
|
||||
2. Interactive control run: boot with no `cidata` drive, install normally, confirm nothing changed.
|
||||
3. Autoinstall run on Proxmox: attach ISO + `cidata` drive, boot, no input; confirm the configurator never renders, the install completes, the machine reboots on its own, and SSH with the key works against the booted system.
|
||||
4. Encrypted autoinstall: `user_encrypt_installation.txt` set true; confirm the LUKS prompt appears on boot (this is the one autoinstall case that still needs a human at first boot — document it).
|
||||
5. Full Packer cycle: build, destroy, rebuild from the same `cidata` drive; confirm reproducibility.
|
||||
|
||||
### Negative tests
|
||||
- Drive labeled `cidata` with no files → configurator runs.
|
||||
- Drive with `user_configuration.json` only → configurator runs.
|
||||
- An `authorized_keys` with no usable keys → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine.
|
||||
- Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang.
|
||||
|
||||
### Regression surface
|
||||
The interactive path shares every line of this change except the `omarchy-cidata-load` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to PR #72
|
||||
|
||||
PR #72 targeted the pre-`quattro` tree and cannot be rebased: `use_omarchy_helpers`, `run_configurator`, `install_arch`, `install_omarchy`, and `chroot_bash` no longer exist. This plan reimplements the same feature against the current tree and drops three things that PR deliberately flagged as questionable:
|
||||
|
||||
- The `finished.sh` `sed` — replaced by section 2. The dashboard renders the reboot prompt now.
|
||||
- The `systemd-networkd` DHCP unit — unnecessary; NetworkManager is already enabled and would contend with it.
|
||||
- The `PasswordAuthentication yes` rewrite — unnecessary and a security downgrade.
|
||||
|
||||
`load_cidata` is the one piece that carries over close to intact. PR #72's `ufw allow ssh` also carries over: it was dropped from the first draft of this plan on the reasoning that an enabled sshd is a reachable sshd, and a test install proved otherwise. That PR was right to include it.
|
||||
@@ -6,6 +6,17 @@ Support Omarchy installs on consumer laptops that ship with UEFI Secure Boot ena
|
||||
|
||||
This plan targets normal consumer hardware, especially Windows dual-boot machines. OEM-controlled hardware with Omarchy-owned firmware keys is a separate path.
|
||||
|
||||
## Current State (August 2026)
|
||||
|
||||
Where the repo stands today, and what that changes about this plan:
|
||||
|
||||
- The ISO builds with `bootmodes=('bios.syslinux' 'uefi.grub')` (`configs/profiledef.sh`): an unsigned GRUB as `BOOTX64.EFI` loading an unsigned kernel and initramfs. Firmware that trusts only the Microsoft UEFI CA rejects the ISO at the first stage, so Secure Boot must be disabled today.
|
||||
- The vendored mkarchiso already supports a `uefi.systemd-boot` bootmode (mutually exclusive with `uefi.grub`), so the live-ISO half of `shim -> systemd-boot -> UKI` needs no archiso surgery — a bootmode switch plus signing.
|
||||
- The installed system already boots a UKI: the orchestrator's `finalize_limine_boot` phase has mkinitcpio build the UKI onto the ESP, and `validate_boot` asserts it exists. "Build an installed UKI" is no longer new work; the new work is signing that UKI and choosing a second stage that enforces signatures.
|
||||
- Kernel updates already regenerate the UKI through the target's mkinitcpio pacman hooks. The update flow below reduces to adding a signing step and fallback retention to machinery that exists.
|
||||
|
||||
The critical path is external: the Microsoft-signed shim. That requires an EV code-signing certificate for the Omarchy org, a shim built from a reviewed upstream release with Omarchy SBAT identity and NX compatibility, and a `shim-review` submission — historically weeks to months of calendar time, with standing obligations afterward (SBAT revocation response, resubmission when the embedded cert or shim version changes). Start it first; every phase below except release signing can proceed in parallel on dev test keys in QEMU.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
Use a Microsoft-signed first-stage shim, then boot only signed Omarchy-controlled artifacts.
|
||||
@@ -51,7 +62,7 @@ This preserves offline tamper resistance for `/boot` while avoiding any central
|
||||
|
||||
## Bootloader Decision
|
||||
|
||||
Signing Limine as an EFI binary is not sufficient if Limine can then boot unsigned kernels, initrds, or configs. For v1 Secure Boot support, use a boot path with a small, enforceable verification surface:
|
||||
Installed systems already boot a UKI through Limine, so the boot *artifact* needs no change. But signing Limine as an EFI binary is not sufficient if Limine will then load unsigned payloads or take direction from an unsigned `limine.conf`. For v1 Secure Boot support, use a boot path with a small, enforceable verification surface:
|
||||
|
||||
```text
|
||||
shim -> systemd-boot -> signed UKI
|
||||
@@ -104,12 +115,13 @@ bin/omarchy-iso-make --secure-boot
|
||||
|
||||
Build behavior:
|
||||
|
||||
1. Add Microsoft-signed Omarchy shim artifacts to the ISO UEFI boot path.
|
||||
2. Build the live environment boot payload as a UKI.
|
||||
3. Sign the live UKI with the Omarchy release key in official builds.
|
||||
4. Sign any second-stage EFI binary with the Omarchy release key.
|
||||
5. Assert that every Secure Boot UEFI entry uses the signed shim path.
|
||||
6. Assert that no unsigned kernel/initramfs path is offered in Secure Boot UEFI mode.
|
||||
1. Switch the UEFI bootmode from `uefi.grub` to `uefi.systemd-boot` in `configs/profiledef.sh` (mkarchiso forbids running both). BIOS syslinux stays as-is; Secure Boot is UEFI-only.
|
||||
2. Add Microsoft-signed Omarchy shim artifacts to the ISO UEFI boot path.
|
||||
3. Build the live environment boot payload as a UKI.
|
||||
4. Sign the live UKI with the Omarchy release key in official builds.
|
||||
5. Sign any second-stage EFI binary with the Omarchy release key.
|
||||
6. Assert that every Secure Boot UEFI entry uses the signed shim path.
|
||||
7. Assert that no unsigned kernel/initramfs path is offered in Secure Boot UEFI mode.
|
||||
|
||||
Local developer builds should support a dev-signing mode for QEMU validation:
|
||||
|
||||
@@ -124,7 +136,7 @@ Dev mode creates local test keys and is only expected to boot in QEMU firmware e
|
||||
When the live installer detects Secure Boot enabled:
|
||||
|
||||
1. Show a Secure Boot explanation before disk mutation.
|
||||
2. If Windows is detected, warn the user to suspend BitLocker and have the recovery key available.
|
||||
2. If Windows is detected, follow the shipped dual-boot BitLocker policy (decrypt, not suspend — see the configurator's existing checks) and warn the user to have the recovery key available.
|
||||
3. Install Omarchy's shim to its own ESP directory, for example `EFI/Omarchy`.
|
||||
4. Do not modify `EFI/Microsoft`.
|
||||
5. Generate a machine-local MOK keypair under the installed encrypted root.
|
||||
@@ -145,13 +157,11 @@ Firmware -> Omarchy shim -> MokManager -> user enrolls Omarchy machine key -> re
|
||||
|
||||
Secure Boot installs need deterministic UKI regeneration and signing after kernel or initramfs changes.
|
||||
|
||||
Add a target-system hook that:
|
||||
The regeneration half already exists: the target's mkinitcpio pacman hooks rebuild the UKI on kernel or initramfs changes. Add the signing half — a hook ordered after the UKI build that:
|
||||
|
||||
1. Regenerates the initramfs.
|
||||
2. Rebuilds the UKI.
|
||||
3. Signs the UKI with the machine-local MOK private key.
|
||||
4. Writes it atomically to the ESP.
|
||||
5. Keeps at least one previously booted signed UKI as fallback.
|
||||
1. Signs the freshly built UKI with the machine-local MOK private key.
|
||||
2. Writes it atomically to the ESP.
|
||||
3. Keeps at least one previously booted signed UKI as fallback.
|
||||
|
||||
Failure policy:
|
||||
|
||||
@@ -199,10 +209,9 @@ Do not store private keys in this directory.
|
||||
Add Secure Boot detection and UX:
|
||||
|
||||
- Detect UEFI Secure Boot state through efivars and/or `mokutil --sb-state`.
|
||||
- Detect Windows through ESP vendor paths and partition metadata.
|
||||
- Warn about BitLocker recovery risk when Windows is present.
|
||||
- Detect Windows through ESP vendor paths and partition metadata (the dual-boot path's `detect_windows_esp` and BitLocker checks already exist — extend, don't duplicate).
|
||||
- Explain one-time MokManager enrollment.
|
||||
- Write `secure_boot.sh` with simple shell state for `.automated_script.sh`.
|
||||
- Write `secure_boot.sh` with simple shell state for the install orchestrator.
|
||||
|
||||
Example state:
|
||||
|
||||
@@ -214,17 +223,18 @@ SECURE_BOOT_UKI=true
|
||||
SECURE_BOOT_MOK_COMMON_NAME="Omarchy Machine Owner Key"
|
||||
```
|
||||
|
||||
### `configs/airootfs/root/.automated_script.sh`
|
||||
### `configs/airootfs/usr/share/omarchy-iso/orchestrator/`
|
||||
|
||||
Add Secure Boot install support:
|
||||
Add Secure Boot install support to the install phases:
|
||||
|
||||
- Source `secure_boot.sh` after configurator runs.
|
||||
- Install the Secure Boot boot path when `SECURE_BOOT_INSTALL=true`.
|
||||
- Generate machine-local MOK keypair in the installed encrypted root.
|
||||
- Build and sign UKI.
|
||||
- Read the `secure_boot.sh` state the configurator wrote.
|
||||
- When `SECURE_BOOT_INSTALL=true`, install the shim boot path and the enforcing second stage instead of (or in front of) the plain Limine setup in `_configure_limine_boot`.
|
||||
- Generate the machine-local MOK keypair on the installed encrypted root.
|
||||
- Sign the UKI that `finalize_limine_boot` already builds.
|
||||
- Schedule MOK enrollment with `mokutil --import`.
|
||||
- Install kernel-update hooks for UKI rebuild/signing.
|
||||
- Install the UKI signing hook alongside the existing mkinitcpio hooks.
|
||||
- Preserve dual-boot firmware boot order unless explicitly promoted.
|
||||
- Extend `validate_boot` to assert the UKI on the ESP carries a valid signature.
|
||||
|
||||
### Target System Files
|
||||
|
||||
@@ -275,7 +285,7 @@ On Windows dual-boot machines:
|
||||
- Never change BitLocker protectors.
|
||||
- Preserve existing firmware default boot order by default.
|
||||
- Warn that adding a boot entry or changing ESP contents can trigger BitLocker recovery.
|
||||
- Recommend suspending BitLocker from Windows before install and resuming it after both OSes boot.
|
||||
- Follow the shipped dual-boot policy: tell users to turn BitLocker off in Windows and let the drive finish decrypting before installing (the configurator already enforces this; suspending alone proved insufficient — see #105).
|
||||
|
||||
If the user chooses to make Omarchy first in boot order, capture the old `BootOrder` and provide rollback guidance.
|
||||
|
||||
@@ -326,7 +336,7 @@ Minimum hardware tests:
|
||||
|
||||
1. Microsoft Secure Boot enabled laptop boots official Secure Boot ISO.
|
||||
2. Windows dual-boot install preserves Windows boot.
|
||||
3. BitLocker-suspended install avoids recovery prompt after resuming BitLocker.
|
||||
3. BitLocker-decrypted install boots both OSes cleanly afterward.
|
||||
4. BitLocker-active install warning is visible before any disk mutation.
|
||||
5. Installed Omarchy boots only after MOK enrollment.
|
||||
6. Secure Boot remains enabled after install.
|
||||
@@ -338,11 +348,13 @@ Minimum hardware tests:
|
||||
|
||||
### Phase 1: Research And Decisions
|
||||
|
||||
- Kick off the EV certificate purchase and `shim-review` submission prep immediately — this is the calendar-bound item that gates Phase 4, and nothing else depends on it being done first.
|
||||
- Confirm shim submission requirements and timeline.
|
||||
- Confirm selected second-stage boot manager.
|
||||
- Confirm UKI generation mechanism on Arch.
|
||||
- Confirm selected second-stage boot manager (systemd-boot unless Limine's signature enforcement is proven).
|
||||
- ~~Confirm UKI generation mechanism on Arch~~ — resolved: mkinitcpio already builds the installed UKI today.
|
||||
- Confirm package availability for signing and MOK workflows.
|
||||
- Decide local MOK key storage and update-hook policy.
|
||||
- Decide the DKMS/out-of-tree module signing story (see Open Questions — this is a v1 blocker, not a nice-to-have).
|
||||
|
||||
### Phase 2: Dev Secure Boot In QEMU
|
||||
|
||||
@@ -387,9 +399,9 @@ Minimum hardware tests:
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether v1 should use systemd-boot, GRUB, or a verified Limine configuration as the second stage.
|
||||
- Whether Limine can enforce signature verification on the UKIs and config it loads; if not proven, Secure Boot installs use systemd-boot as the second stage while non-Secure-Boot installs keep Limine.
|
||||
- Whether the local MOK private key should be encrypted with a user-supplied passphrase or stored root-only on encrypted root.
|
||||
- How to support signed out-of-tree kernel modules, especially proprietary GPU drivers or DKMS packages.
|
||||
- How snapshot booting should work when each bootable kernel must be a signed UKI.
|
||||
- How to support signed out-of-tree kernel modules, especially proprietary GPU drivers or DKMS packages. **This must be answered for v1:** once Secure Boot is enforcing, unsigned modules will not load, and NVIDIA laptops are exactly the consumer hardware this plan targets. The module signing hook must sign with the machine-local MOK alongside the UKI.
|
||||
- How snapshot booting (factory reset restores from Btrfs snapshots) should work when each bootable kernel must be a signed UKI.
|
||||
- Whether official builds should always be Secure Boot-capable or ship a separate Secure Boot ISO flavor.
|
||||
- What user-facing recovery path should exist if MOK enrollment is skipped or fails.
|
||||
@@ -1,158 +0,0 @@
|
||||
# Dual-Boot Installation Support for Omarchy ISO
|
||||
|
||||
## Context
|
||||
|
||||
Currently, the Omarchy installer wipes the entire selected disk (`"wipe": true`) and creates two partitions: a 2GB FAT32 ESP and a LUKS-encrypted Btrfs root. This means users must dedicate an entire disk to Omarchy. Adding dual-boot support lets users install alongside an existing OS (typically Windows) while keeping the Omarchy partition encrypted.
|
||||
|
||||
## Core Approach: `pre_mounted_config`
|
||||
|
||||
**Do our own partitioning, then hand off to archinstall for package installation only.**
|
||||
|
||||
archinstall's `manual_partitioning` mode has a long history of bugs with existing partitions and LUKS (archinstall issues #998, #2072, #2444). Instead, we:
|
||||
|
||||
1. Detect existing partitions and free space ourselves (with `sgdisk`/`parted`)
|
||||
2. Create the LUKS partition and Btrfs subvolumes ourselves (with `cryptsetup`/`mkfs.btrfs`)
|
||||
3. Mount everything at `/mnt/archinstall`
|
||||
4. Tell archinstall to use `"config_type": "pre_mounted_config"` — it installs packages into our pre-mounted tree and does zero disk operations
|
||||
5. Handle encryption config (crypttab, mkinitcpio hooks, bootloader cmdline) in post-install
|
||||
|
||||
The existing full-disk path stays completely unchanged. Dual-boot is a parallel code path.
|
||||
|
||||
## Implementation Guardrails
|
||||
|
||||
- **Run target changes in chroot** — all target-system commands (`mkinitcpio -P`, `limine-scan`, bootloader config updates) must run via `arch-chroot "$INSTALL_ROOT" ...`
|
||||
- **Edit target files under `$INSTALL_ROOT` only** — write `mkinitcpio` hook config, `limine.conf`, `crypttab`, and related files in `$INSTALL_ROOT/etc` and `$INSTALL_ROOT/boot` (never in the live environment root)
|
||||
- **Settle kernel partition state** — after creating the new partition, run `partprobe "$DISK"` and `udevadm settle` before `cryptsetup` to avoid device-node races
|
||||
- **Deterministic fstab** — always generate `/etc/fstab` for dual-boot with overwrite semantics (e.g., `genfstab -U "$INSTALL_ROOT" > "$INSTALL_ROOT/etc/fstab"`) to avoid duplicate entries on retries
|
||||
- **Hook compatibility** — when updating mkinitcpio hooks, add `encrypt` for busybox initramfs or `sd-encrypt` for systemd initramfs; never assume one universally
|
||||
- **Safe rollback on failure** — on any dual-boot setup failure: unmount mounts, close LUKS mapping, and surface recovery guidance; optionally delete the just-created partition only if it was created in this run
|
||||
|
||||
## Scope Constraints
|
||||
|
||||
- **UEFI/GPT only** — dual-boot on MBR/BIOS is too fragile; those users get full-disk only
|
||||
- **No auto-shrinking** — users must free up space from their existing OS first (e.g., Windows Disk Management). Too risky to shrink partitions automatically
|
||||
- **Minimum 20GB free space** required for Omarchy
|
||||
|
||||
## User Flow
|
||||
|
||||
```
|
||||
Select disk → Detect layout →
|
||||
|
||||
If disk has existing OS + ESP + ≥20GB free space:
|
||||
"How would you like to install?"
|
||||
→ "Install alongside Windows (use 85GB free space)"
|
||||
→ "Erase entire disk"
|
||||
|
||||
If disk is empty or no free space:
|
||||
Existing "Confirm overwriting" flow (unchanged)
|
||||
```
|
||||
|
||||
## File Changes
|
||||
|
||||
### 1. `configs/airootfs/root/configurator`
|
||||
|
||||
**Add after disk selection (line 233), before config generation:**
|
||||
|
||||
- `detect_disk_layout()` — examines selected disk for:
|
||||
- GPT partition table (required for dual-boot)
|
||||
- Existing ESP partition (via `lsblk` PARTTYPE matching EFI GUID)
|
||||
- Free unallocated space (via `sgdisk -F`/`-E` or `parted print free`)
|
||||
- OS detection for installer UX warnings/prompts (not boot correctness): mount ESP read-only, check common vendor paths like `EFI/Microsoft`, `EFI/systemd`, `EFI/ubuntu`, `EFI/fedora`, `EFI/GRUB`
|
||||
|
||||
- `install_mode_form()` — shows gum choose with dual-boot vs erase options when conditions are met. Sets `DUAL_BOOT=true/false`.
|
||||
|
||||
**Generate different JSON when `DUAL_BOOT=true`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"disk_config": {
|
||||
"config_type": "pre_mounted_config",
|
||||
"mountpoint": "/mnt/archinstall"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No `device_modifications`, no `disk_encryption`, no `btrfs_options` — we handle all of that ourselves. Everything else (hostname, kernels, packages, etc.) stays the same.
|
||||
|
||||
**Write `install_mode.sh`** alongside the other config files:
|
||||
|
||||
```bash
|
||||
DUAL_BOOT=true
|
||||
DISK=/dev/nvme0n1
|
||||
ESP_PARTITION=/dev/nvme0n1p1
|
||||
```
|
||||
|
||||
### 2. `configs/airootfs/root/.automated_script.sh`
|
||||
|
||||
**Source `install_mode.sh`** in `run_configurator()` if it exists.
|
||||
|
||||
**Add `setup_dual_boot_partitions()`** — runs before archinstall when `DUAL_BOOT=true`:
|
||||
|
||||
1. Select the largest contiguous free region and run `sgdisk` to create new partition there (type 8309 = Linux LUKS)
|
||||
2. `partprobe "$DISK"` + `udevadm settle` so new partition node is present
|
||||
3. `cryptsetup luksFormat` + `luksOpen` with user's password
|
||||
4. `mkfs.btrfs` on `/dev/mapper/omarchy_root`
|
||||
5. Create Btrfs subvolumes: `@`, `@home`, `@log`, `@pkg`
|
||||
6. Mount subvolumes at `/mnt/archinstall` with `compress=zstd`
|
||||
7. Mount existing ESP (untouched) at `/mnt/archinstall/boot`
|
||||
8. Generate target `/etc/fstab` from mounted filesystems before post-install steps
|
||||
|
||||
**Add `configure_dual_boot_post_install()`** — runs after archinstall:
|
||||
|
||||
1. Write `/etc/crypttab` entry using LUKS UUID/PARTUUID (stable identifier), e.g. `omarchy_root UUID=<luks-uuid> none luks,discard`
|
||||
2. Update mkinitcpio hooks before `filesystems`: `encrypt` (busybox) or `sd-encrypt` (systemd)
|
||||
3. Regenerate initramfs with `arch-chroot "$INSTALL_ROOT" mkinitcpio -P`
|
||||
4. Configure Limine kernel cmdline to match initramfs mode:
|
||||
- busybox `encrypt`: `cryptdevice=UUID=<uuid>:omarchy_root root=/dev/mapper/omarchy_root`
|
||||
- systemd `sd-encrypt`: `rd.luks.name=<uuid>=omarchy_root root=/dev/mapper/omarchy_root` (or equivalent `rd.luks.uuid=` form)
|
||||
5. Run `arch-chroot "$INSTALL_ROOT" limine-scan` to detect other OS boot entries (Windows, etc.)
|
||||
6. Preserve current firmware default boot order unless user explicitly chooses to make Limine first (capture and restore `BootOrder` if `efibootmgr` side effects reorder entries)
|
||||
|
||||
**Use `$INSTALL_ROOT` variable** — defaults to `/mnt` for full-disk, `/mnt/archinstall` for dual-boot. Post-install steps (offline mirror bind-mount, sudoers, omarchy copy) reference this instead of hardcoded `/mnt`.
|
||||
|
||||
### 3. `builder/build-iso.sh` + `builder/archinstall.packages`
|
||||
|
||||
Add `parted` to packages available in the live ISO (needed for free space detection).
|
||||
|
||||
## Bootloader Coexistence
|
||||
|
||||
Limine installs to `ESP/EFI/arch-limine/` and registers via `efibootmgr`. This sits alongside:
|
||||
- Windows Boot Manager at `ESP/EFI/Microsoft/Boot/bootmgfw.efi`
|
||||
- Any other bootloader in its own ESP subdirectory
|
||||
|
||||
`limine-scan` auto-detects other UEFI boot entries and adds them to `limine.conf`. Users can choose OS at boot.
|
||||
|
||||
**Boot order policy**: creating a new EFI entry must not silently change the current default boot target. Keep existing firmware boot order by default and only promote Limine when user opts in.
|
||||
|
||||
Implementation note: when registering Limine with `efibootmgr`, do not pass reorder flags by default; if firmware still changes order, explicitly restore prior `BootOrder` unless the user opted to promote Limine.
|
||||
|
||||
**ESP size note**: Windows typically creates a 100-260MB ESP. This is enough for Limine + kernel + initramfs, but Snapper boot snapshots (which copy kernels to ESP) will be limited or disabled.
|
||||
|
||||
**ESP free-space threshold**:
|
||||
- If ESP has `<100MB` free at install time: show a strong warning (do not block install)
|
||||
- If ESP has `<64MB` free: hide dual-boot option and require full-disk install (or user frees space first)
|
||||
|
||||
**BitLocker warning**: If Windows is detected, show a warning about having the BitLocker recovery key ready, since modifying ESP/UEFI entries can trigger recovery.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Disk is empty | Full-disk install only (current behavior) |
|
||||
| Existing OS but no free space | Full-disk install only, with note to free space first |
|
||||
| Free space < 20GB | Dual-boot option not shown |
|
||||
| MBR/BIOS disk | Dual-boot option not shown |
|
||||
| ESP < 100MB free after mount | Warn user; continue allowed |
|
||||
| ESP < 64MB free after mount | Dual-boot option not shown |
|
||||
| Multiple free regions | Use largest contiguous region |
|
||||
| Partitioning/setup failure mid-run | Cleanup mounts/LUKS and abort safely |
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Regression**: Full-disk install in VM with empty disk — must work identically to today
|
||||
2. **Dual-boot with Windows**: VM with Windows installed + 40GB free space — detect Windows, install alongside, both OSes boot
|
||||
3. **Dual-boot with Linux**: VM with another Linux + free space — similar test
|
||||
4. **No free space**: VM with fully partitioned disk — dual-boot option should not appear
|
||||
5. **Encryption**: After dual-boot install, verify LUKS unlock prompt at boot and encrypted root
|
||||
6. **Initramfs mode**: Validate both busybox and systemd hook paths boot correctly (`encrypt` vs `sd-encrypt`)
|
||||
7. **Boot order**: Verify existing default UEFI entry remains unchanged unless user opted to promote Limine
|
||||
@@ -1,163 +0,0 @@
|
||||
# OEM Install + Factory Reset Plan
|
||||
|
||||
## Goal
|
||||
|
||||
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.
|
||||
|
||||
## Core idea: "OEM state" as a first-class system state
|
||||
|
||||
Define one canonical state — *fully installed, no user, setup pending* — and
|
||||
make both paths produce it:
|
||||
|
||||
- **ISO OEM mode** ends the install in OEM state instead of creating the user.
|
||||
- **Reset** returns a running machine to OEM state.
|
||||
|
||||
OEM state is: a fully installed system plus `/var/lib/omarchy/oem/` containing:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `pending` | Flag that arms the first-boot setup service |
|
||||
| `packages/node-v*.tar.gz` | Node tarball copied off the ISO's `/opt/packages` (~30MB) so `omarchy-finalize-user` works offline at first boot |
|
||||
| `groups` | Deferred `usermod -aG` groups written by system setup (see below) |
|
||||
| `luks-key` | Throwaway LUKS passphrase for encrypted installs, staged for auto-unlock until first-boot re-key |
|
||||
|
||||
## Why the seam is nearly clean already
|
||||
|
||||
Everything in the orchestrator through `finalize_limine_boot` is
|
||||
user-independent except five touch points:
|
||||
|
||||
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.
|
||||
|
||||
Nothing in hardware setup, Limine, hibernation, snapper, or package
|
||||
installation cares who the user is.
|
||||
|
||||
## First boot: `omarchy-oem-setup.service`
|
||||
|
||||
Ships in the omarchy runtime package (so reset can arm it without the ISO):
|
||||
|
||||
- `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).
|
||||
|
||||
Then it does what the ISO would have done:
|
||||
|
||||
1. `useradd` with wheel + the groups from `/var/lib/omarchy/oem/groups`,
|
||||
set user + root password.
|
||||
2. `omarchy-finalize-user` as the user, with a setup context that points at the
|
||||
stashed Node tarball (a sibling of the existing `iso-chroot` context).
|
||||
3. The `configure_login` writes: SDDM `state.conf`, autologin for encrypted
|
||||
installs.
|
||||
4. If `luks-key` exists: `cryptsetup luksChangeKey` to the user's password,
|
||||
delete the stash, remove the auto-unlock keyfile, rebuild initramfs.
|
||||
5. Git identity from the form (the `OMARCHY_USER_NAME`/`OMARCHY_USER_EMAIL`
|
||||
path in `install/user/git.sh`).
|
||||
6. Remove `/var/lib/omarchy/oem/`, hand off to SDDM.
|
||||
|
||||
The user lands in their session exactly like a normal install.
|
||||
|
||||
## Encryption: keep it, Pop!_OS-style
|
||||
|
||||
Do not force OEM installs unencrypted. Install encrypted with a generated
|
||||
throwaway passphrase, stage a keyfile so boot auto-unlocks during the OEM
|
||||
window, and have first-boot setup re-key to the user's chosen password
|
||||
(step 4 above). Limine and mkinitcpio don't care what the passphrase is. This
|
||||
preserves encrypted-by-default without the machine builder ever knowing the end
|
||||
user's password.
|
||||
|
||||
## ISO-side changes (this repo)
|
||||
|
||||
- **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.
|
||||
|
||||
## Runtime-side changes (omarchy repo)
|
||||
|
||||
- `omarchy-oem-setup` + `omarchy-oem-setup.service` (the OOBE above).
|
||||
- `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.
|
||||
|
||||
## Reset: `omarchy-system-factory-reset`
|
||||
|
||||
Behind a very explicit confirmation (typed, not y/n):
|
||||
|
||||
1. Point the default subvolume at a writable snapshot of `@factory`; stage a
|
||||
next-boot wipe unit.
|
||||
2. On reboot: recreate `@home` (subvolume delete/create — instant, no rm -rf),
|
||||
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.
|
||||
|
||||
Machines installed before `@factory` existed get a degraded reset — keep the
|
||||
current system, wipe users/state only — with that caveat surfaced in the
|
||||
confirmation.
|
||||
|
||||
### Honest limitations (document these)
|
||||
|
||||
- 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.
|
||||
|
||||
## Sequencing
|
||||
|
||||
The OOBE lives in the omarchy runtime package, but the ISO's OEM phase depends
|
||||
on it existing. So:
|
||||
|
||||
1. Ship the runtime side first (`omarchy-oem-setup`, service, reset command,
|
||||
`omarchy-setup-system` relaxation).
|
||||
2. ISO OEM mode then asserts the service file exists in the target before
|
||||
offering the mode.
|
||||
3. Factory snapshot can land with either half; reset requires both.
|
||||
|
||||
## Validation
|
||||
|
||||
- VM: OEM install (encrypted + unencrypted) → reboot → OOBE → session; verify
|
||||
LUKS passphrase is the user's, throwaway key gone, groups applied,
|
||||
`omarchy-finalize-user` state marker present.
|
||||
- VM: normal install → use system → `omarchy-system-factory-reset` → OOBE as new
|
||||
user; verify no trace of prior user (home, NM connections, tailscale,
|
||||
machine-id, host keys, snapper snapshots).
|
||||
- Autoinstall: cidata drive with `oem` marker and no credentials → unattended
|
||||
OEM install.
|
||||
- Negative: OEM ISO built against a runtime package lacking the service fails
|
||||
the install with a clear error, not a user-less boot loop.
|
||||
@@ -1,643 +0,0 @@
|
||||
# Protected Partition Install Plan: Dual-Boot + Restore
|
||||
|
||||
## Goal
|
||||
|
||||
Implement one shared non-destructive install path that supports both:
|
||||
|
||||
- Dual-boot installs alongside an existing OS.
|
||||
- OEM/restore installs from a permanent internal restore partition set.
|
||||
|
||||
Beyond install: every standalone Omarchy machine — whether OEM-provisioned or installed from the consumer ISO onto an empty disk — exposes a "factory reset" command that durably stages the next boot into the on-disk restore partition set. The installed-OS command leaves the machine as though it had just been prepared to boot from restore; the restore environment owns the destructive wipe/reinstall flow. Dual-boot installs do not get factory reset (no room for a restore partition set).
|
||||
|
||||
The current full-disk install path remains unchanged until the protected-partition path is proven. The protected path does its own partitioning, encryption, filesystems, mounts, and boot configuration, then hands a prepared mount tree to archinstall with `pre_mounted_config`.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
Use a single installer engine with multiple partition policies.
|
||||
|
||||
```text
|
||||
full_disk existing archinstall default_layout + wipe:true path
|
||||
dual_boot protect existing OS partitions, reuse existing ESP, add Omarchy LUKS root
|
||||
restore_install protect restore partition set, create/recreate installed ESP + Omarchy LUKS root
|
||||
```
|
||||
|
||||
The key rule: protected modes must never pass the whole disk to archinstall with `wipe: true`.
|
||||
|
||||
## Why This Combines Both Features
|
||||
|
||||
Dual-boot and restore installs are the same technical problem:
|
||||
|
||||
1. Detect disk layout.
|
||||
2. Identify protected partitions.
|
||||
3. Create or format only explicitly writable Omarchy partitions.
|
||||
4. Set up encrypted root manually.
|
||||
5. Mount a complete install tree manually.
|
||||
6. Run archinstall only as a package/base-system installer.
|
||||
7. Configure encryption, fstab, initramfs, and bootloader after archinstall.
|
||||
|
||||
Only the partition policy differs.
|
||||
|
||||
## Scope Constraints
|
||||
|
||||
- UEFI/GPT only for `dual_boot` and `restore_install` v1.
|
||||
- Existing BIOS/syslinux ISO behavior stays available for normal full-disk installs.
|
||||
- No automatic shrinking of existing partitions.
|
||||
- Dual-boot requires pre-existing free space.
|
||||
- Restore install requires a detected Omarchy restore partition set.
|
||||
- Installed Omarchy root is encrypted with LUKS.
|
||||
- Restore partitions are not encrypted unless a later product requirement demands it.
|
||||
- Restore payload partitions are normally left unmounted in the installed OS and mounted read-only by the restore environment. A separate restore state partition is the only restore-owned partition mounted read-write for reset triggers and recovery logs.
|
||||
- Swap matches the current full-disk install: zram for memory pressure, plus a Btrfs swapfile in a dedicated `NODATACOW` `/swap` subvolume sized to RAM for hibernation. The subvolume, swapfile, `resume` mkinitcpio hook, and `resume=<dev> resume_offset=<offset>` Limine cmdline drop-in are created post-install by Omarchy's existing `omarchy-hibernation-setup`. Protected modes must not break this: the `encrypt`/`sd-encrypt` hook must precede `resume` in mkinitcpio so the swapfile is reachable after LUKS unlock, and Limine must honor `/etc/limine-entry-tool.d/*.conf` drop-ins so the post-install hook can append resume parameters. No swap partition.
|
||||
|
||||
## Partition Policies
|
||||
|
||||
### Full Disk
|
||||
|
||||
Current behavior:
|
||||
|
||||
```text
|
||||
p1 installed ESP FAT32 2G
|
||||
p2 installed root LUKS rest of disk
|
||||
```
|
||||
|
||||
Implementation stays on archinstall `default_layout` with `wipe: true` for now.
|
||||
|
||||
Phase 7 replaces this with a layout that mirrors `restore_install`:
|
||||
|
||||
```text
|
||||
p1 restore ESP FAT32 protected after install
|
||||
p2 restore payload ext4 protected after install, read-only payload
|
||||
p3 restore state ext4 protected after install, writable trigger/log state
|
||||
p4 installed ESP FAT32 writable
|
||||
p5 installed root LUKS writable
|
||||
```
|
||||
|
||||
Rationale: factory reset requires an on-disk restore partition set. Making this the default for standalone installs gives every consumer machine the same self-recovery capability OEM-provisioned machines get. The legacy two-partition layout becomes opt-in (e.g. `--no-restore`) for users who explicitly want the disk space back.
|
||||
|
||||
### Dual Boot
|
||||
|
||||
Example target disk before install:
|
||||
|
||||
```text
|
||||
p1 existing ESP FAT32 protected, reused as /efi
|
||||
p2 Windows MSR MSR protected
|
||||
p3 Windows NTFS protected
|
||||
free space writable region
|
||||
```
|
||||
|
||||
After install:
|
||||
|
||||
```text
|
||||
p1 existing ESP FAT32 protected, mounted as /efi during install
|
||||
p2 Windows MSR MSR protected
|
||||
p3 Windows NTFS protected
|
||||
p4 Omarchy root LUKS new encrypted Btrfs root
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
- Protect all existing partitions.
|
||||
- Reuse the existing ESP only for Omarchy boot files under `EFI/Omarchy`. Do not write top-level `vmlinuz-*`, `initramfs-*`, or vendor-looking paths on a shared ESP.
|
||||
- Mount the shared ESP at `/efi`, keep `/boot` on the encrypted root, and install an Omarchy-owned kernel/initramfs sync hook that copies boot artifacts into `/efi/EFI/Omarchy` after kernel or initramfs updates.
|
||||
- Refuse to install if the existing ESP is below 200 MB total or has less than 150 MB free. Warn below 300 MB total or below 200 MB free. These thresholds size for one Omarchy kernel + initramfs + Limine alongside a typical vendor loader; revisit if the Omarchy initramfs grows materially. Free space must be measured from the mounted ESP, not inferred from partition size.
|
||||
- Create one new LUKS partition in the largest single contiguous free region of at least 40 GB. Refuse otherwise. Do not stitch across multiple non-contiguous regions.
|
||||
- Preserve current firmware boot order unless the user explicitly opts to make Omarchy first.
|
||||
- Abort with a clear error if any Windows partition is detected as BitLocker/FVE, including suspended BitLocker. Do not proceed past detection. Adding a new partition to a disk with a BitLocker-protected volume changes the firmware/boot environment enough that Windows can demand the recovery key on next boot — and many users do not have it. The error message must instruct the user to turn off BitLocker from inside Windows and wait for decryption to complete, then reboot into the Omarchy installer and retry. Do not offer an "I have my recovery key, continue anyway" override in v1; users who really want it can fully disable BitLocker themselves.
|
||||
- Warn if non-BitLocker Windows is detected, since boot order changes can still surprise users.
|
||||
|
||||
### Restore Install
|
||||
|
||||
Recommended factory disk image layout:
|
||||
|
||||
```text
|
||||
p1 restore ESP FAT32 protected, boots restore environment
|
||||
p2 restore payload ext4 protected, contains live installer/offline payload
|
||||
p3 restore state ext4 protected from formatting, writable reset trigger/log state
|
||||
p4 installed ESP FAT32 writable, created/recreated by installer
|
||||
p5 installed root LUKS writable, created/recreated by installer
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
- Protect restore ESP, restore payload, and restore state partitions from delete/format operations. Only the restore state partition is mounted read-write, and only for trigger files and logs.
|
||||
- On first boot, create installed ESP and installed root in remaining disk space.
|
||||
- On reinstall, format the installed Omarchy partitions in place by default. Only delete and recreate them when their geometry no longer matches policy (e.g. the disk was resized). Format-in-place preserves PARTUUIDs, keeps NVRAM entries valid, and avoids touching the partition table.
|
||||
- Set installed Omarchy first in boot order after successful install.
|
||||
- Keep restore boot available as firmware fallback and as a boot menu entry.
|
||||
- Require `copytoram=y` before mutating the internal disk from restore mode.
|
||||
|
||||
Use separate restore and installed ESPs for v1. A shared ESP saves space but makes restore easier to break during normal bootloader maintenance or reinstall.
|
||||
|
||||
## Restore Image Provisioning
|
||||
|
||||
Create a factory artifact separate from the normal consumer ISO:
|
||||
|
||||
```text
|
||||
omarchy-restore.img.zst
|
||||
```
|
||||
|
||||
Factory provisioning writes this image to the internal disk once:
|
||||
|
||||
```bash
|
||||
zstd -dc omarchy-restore.img.zst | dd of=/dev/nvme0n1 bs=16M status=progress conv=fsync
|
||||
```
|
||||
|
||||
The restore image should contain:
|
||||
|
||||
- A restore ESP with `EFI/BOOT/BOOTX64.EFI` fallback boot path.
|
||||
- A restore payload partition labeled `OMARCHY_RESTORE`.
|
||||
- A small restore state partition labeled `OMARCHY_STATE` for reset triggers and recovery logs.
|
||||
- The archiso live environment payload, offline package mirror, configurator, and installer scripts.
|
||||
- A boot entry that passes `omarchy.mode=restore copytoram=y`.
|
||||
- Boot configuration that locates the restore payload by stable label/UUID, not by transient disk name.
|
||||
- A signed manifest covering the archiso payload and offline mirror. The verification public key must be shipped outside the mutable restore payload, such as in the restore boot environment and installed reset tool package.
|
||||
|
||||
The factory flasher must verify the integrity of `omarchy-restore.img.zst` before writing. The on-disk restore environment must verify its signed payload manifest at boot before mutating the internal disk. A plain checksum stored on the same writable disk is not sufficient. Without a signed manifest, a tampered or corrupted restore image silently destroys the user's data the next time they invoke "factory reset."
|
||||
|
||||
On first restore boot:
|
||||
|
||||
1. Bind the candidate restore disk to the actual boot source (`/run/archiso/bootmnt`, kernel command line, or equivalent). Do not select an arbitrary disk just because it has matching labels; cloned restore images can have duplicate labels and UUIDs.
|
||||
2. Move the backup GPT header to the real end of disk with `sgdisk -e` if the factory image was written to a larger disk. This relocates the header only — existing partitions are not grown. The newly-available space at the end of the disk is what installed ESP and installed root consume.
|
||||
3. On first boot only, randomize the GPT disk GUID, partition GUIDs/PARTUUIDs, and filesystem UUIDs for restore-owned partitions before generating any NVRAM entries, fstab entries, or installer state. Labels may remain stable; UUIDs must not stay cloned across machines.
|
||||
4. Detect and protect restore partitions by the newly-randomized PARTUUIDs.
|
||||
5. Create installed ESP and installed root in remaining space.
|
||||
6. Continue through the shared protected install path.
|
||||
|
||||
## Factory Reset
|
||||
|
||||
Goal: an installed Omarchy machine that has a restore partition set (any standalone install — `restore_install` today, `full_disk` after Phase 7) exposes a user-facing factory reset command that prepares the device to boot from internal restore without external media. After the next restore boot, the restore environment returns the device to a fresh-install state.
|
||||
|
||||
### User Flow
|
||||
|
||||
1. User runs `omarchy-factory-reset` from a terminal with sudo. (A GUI entry under Settings is deferred to a follow-up.)
|
||||
2. The command prints which partitions will be destroyed (installed ESP, installed root) and which will be preserved (restore ESP, restore payload, restore state), by stable identifier, and asks the user to type a confirmation phrase. The typed-phrase gate is intentional friction; no `--yes` shortcut in v1.
|
||||
3. On confirmation, the command writes a trigger file to the restore state partition, verifies or recreates the restore boot entry, and sets `BootNext` via `efibootmgr` to that restore entry.
|
||||
4. The command exits only after the trigger file is fsynced, the restore state partition is unmounted, and `BootNext` is confirmed. At that point the installed-OS side is complete: the machine has been left as though it had just been prepared to boot from the restore partition.
|
||||
5. The user reboots when ready. A later GUI may offer a "reboot now" action, but rebooting is a convenience after the staged state is durable, not part of the safety-critical mutation.
|
||||
6. The restore environment detects the trigger on boot, verifies the signed restore payload manifest, writes a reset-in-progress marker to restore state, and temporarily sets persistent `BootOrder` to restore first before mutating installed partitions.
|
||||
7. The restore environment runs the existing `restore_install` reinstall path — format installed ESP, LUKS-format installed root, run the standard install flow — non-interactively, then deletes the trigger and reset-in-progress marker.
|
||||
8. On install completion, the restore environment restores persistent `BootOrder` to installed Omarchy first, restore second, sets `BootNext` to the installed Omarchy entry, and reboots.
|
||||
9. The user lands in a fresh installed Omarchy. Normal persistent `BootOrder` (Omarchy first, restore second) has been restored.
|
||||
|
||||
User data on installed ESP and installed root is destroyed. Restore ESP, restore payload, and restore state are protected from destructive operations throughout. The restore payload is the source of truth for the rebuild; restore state contains only trigger and recovery state.
|
||||
|
||||
### Trigger Mechanism
|
||||
|
||||
Use a flag file on the restore state partition rather than NVRAM variables or the restore payload. NVRAM is fragile across firmware updates, and the restore payload should stay immutable/read-only so signed manifest verification remains meaningful.
|
||||
|
||||
- Path: `<restore-state-mount>/.omarchy-factory-reset`
|
||||
- Contents: timestamp, hostname at trigger time, optional reason. Plain text, one `key=value` per line.
|
||||
- The installed-OS command mounts the restore state partition by `OMARCHY_STATE` label, writes the flag, fsync, and unmounts. It may mount the restore payload read-only only long enough to verify the signed manifest before triggering.
|
||||
- The restore environment checks for the flag at boot. If present, it runs the reset flow non-interactively and deletes the flag on success. If absent, restore boot behaves normally (manual install/repair menu).
|
||||
- The flag must be deleted before reboot. Otherwise the machine resets itself in a loop.
|
||||
|
||||
### Boot Order
|
||||
|
||||
`efibootmgr --bootnext <restore-entry>` is one-shot — it takes effect on the next boot only, then NVRAM reverts to the existing `BootOrder`. This is what we want before the destructive phase: if the reset is interrupted before restore boot, the next normal boot still tries the installed Omarchy first.
|
||||
|
||||
After the restore environment accepts the trigger and before it wipes installed partitions, it must temporarily set persistent `BootOrder` to restore first, installed Omarchy second. This prevents a power loss during reinstall from leaving firmware stuck on a half-written installed ESP. On successful reinstall, the restore environment deletes the reset-in-progress marker, restores persistent `BootOrder` to installed Omarchy first and restore second, then sets `BootNext` to the installed Omarchy entry to land the user in their fresh OS.
|
||||
|
||||
If the installed OS cannot write and confirm `BootNext`, the staging command must remove any trigger it wrote and abort; a manual reinstall can still proceed through the firmware boot menu, but `omarchy-factory-reset` has not successfully prepared the machine. If the restore environment cannot set temporary restore-first `BootOrder` for an unattended reset, abort before destructive operations; manual reinstall can still proceed with an explicit warning.
|
||||
|
||||
### Safety
|
||||
|
||||
The factory reset command is a staging tool, not the destructive installer. It must:
|
||||
|
||||
- Require root and refuse to run otherwise.
|
||||
- Refuse to run if no complete restore partition set is detected (restore ESP, restore payload, restore state), e.g. on a dual-boot machine or a `--no-restore` full-disk install.
|
||||
- Refuse to run if the signed restore payload manifest check fails — a corrupt restore is not recoverable from inside the running OS, and triggering a reset would brick the machine.
|
||||
- Verify the restore boot entry exists in NVRAM before triggering; if not, repopulate it from `EFI/BOOT/BOOTX64.EFI` fallback or abort with a message.
|
||||
- Confirm `BootNext` points to the restore boot entry before exiting, so the system is actually prepared to boot restore.
|
||||
- Print a final pre-confirmation list of partitions that will be destroyed, by stable identifier, so the user sees exactly what they are committing to.
|
||||
|
||||
The restore environment, when running due to the trigger flag, must still apply every guard from the Safety Model. The trigger is permission to run the reinstall flow, not permission to skip safety checks.
|
||||
|
||||
### File Changes for Factory Reset
|
||||
|
||||
- Ship `omarchy-factory-reset` on the installed system. Source location depends on Omarchy's package boundary — either as part of the Omarchy package on the offline mirror, or copied from the live ISO into the installed system during install. Pick one consistent approach during implementation.
|
||||
- Restore state must be mountable read-write from the installed OS by an admin via `OMARCHY_STATE`. Restore payload should only be mounted read-only for manifest verification via `OMARCHY_RESTORE`.
|
||||
|
||||
## Safety Model
|
||||
|
||||
Protected installs must use stable identifiers, not only `/dev/nvme0n1pX` names.
|
||||
|
||||
Track:
|
||||
|
||||
```text
|
||||
protected partition PARTUUIDs
|
||||
restore state PARTUUID
|
||||
writable partition PARTUUIDs
|
||||
selected disk ID
|
||||
install mode
|
||||
install root
|
||||
```
|
||||
|
||||
Every destructive operation must call a guard first:
|
||||
|
||||
```text
|
||||
assert_writable_partition <partition>
|
||||
assert_not_protected <partition>
|
||||
```
|
||||
|
||||
Guarded operations include:
|
||||
|
||||
- `sgdisk --delete`
|
||||
- `sgdisk --new`
|
||||
- `wipefs`
|
||||
- `mkfs.fat`
|
||||
- `mkfs.btrfs`
|
||||
- `cryptsetup luksFormat`
|
||||
- partition table rewrites
|
||||
|
||||
In restore mode, also assert:
|
||||
|
||||
- `/proc/cmdline` contains `copytoram=y`.
|
||||
- The restore payload is not mounted read-write from the target disk at mutation time.
|
||||
- Restore ESP, restore payload, and restore state partitions are detected and marked protected from delete/format operations.
|
||||
- The installed target partitions do not overlap protected partition ranges.
|
||||
|
||||
### Power Loss Recovery
|
||||
|
||||
A torn install must be recoverable on next boot rather than leaving the disk in a state that needs manual intervention.
|
||||
|
||||
- After partition table changes but before LUKS format: the new partition exists but holds no recognizable filesystem or LUKS header. On the next install boot, treat any writable partition without a valid header as discardable and re-run the format step.
|
||||
- After LUKS format but before archinstall completes: the LUKS header exists but no working OS is installed. On the next boot from restore or ISO, offer to wipe and retry rather than attempting to resume.
|
||||
- During archinstall: same as above. Resuming partway through a torn pacstrap is more brittle than restarting it.
|
||||
|
||||
In dual-boot mode, "wipe and retry" applies only to the writable Omarchy partition. Protected partitions must be re-verified by PARTUUID before any retry runs — power loss does not unlock protected ranges.
|
||||
|
||||
## Installer State Contract
|
||||
|
||||
The configurator writes `install_mode.sh` for `.automated_script.sh` to source.
|
||||
|
||||
Example dual-boot state:
|
||||
|
||||
```bash
|
||||
INSTALL_MODE=dual_boot
|
||||
INSTALL_ROOT=/mnt/archinstall
|
||||
DISK=/dev/nvme0n1
|
||||
BOOT_PARTITION=/dev/nvme0n1p1
|
||||
ROOT_PARTITION=/dev/nvme0n1p4
|
||||
PROTECTED_PARTUUIDS="1111-2222 3333-4444 5555-6666"
|
||||
WRITABLE_PARTUUIDS="7777-8888"
|
||||
PROMOTE_OMARCHY_BOOT=false
|
||||
```
|
||||
|
||||
Example restore state:
|
||||
|
||||
```bash
|
||||
INSTALL_MODE=restore_install
|
||||
INSTALL_ROOT=/mnt/archinstall
|
||||
DISK=/dev/nvme0n1
|
||||
RESTORE_ESP_PARTITION=/dev/nvme0n1p1
|
||||
RESTORE_PAYLOAD_PARTITION=/dev/nvme0n1p2
|
||||
RESTORE_STATE_PARTITION=/dev/nvme0n1p3
|
||||
INSTALLED_ESP_PARTITION=/dev/nvme0n1p4
|
||||
ROOT_PARTITION=/dev/nvme0n1p5
|
||||
PROTECTED_PARTUUIDS="1111-2222 3333-4444 5555-6666"
|
||||
WRITABLE_PARTUUIDS="7777-8888 9999-aaaa"
|
||||
PROMOTE_OMARCHY_BOOT=true
|
||||
```
|
||||
|
||||
Keep this file simple shell, generated only by our configurator, and quote values safely.
|
||||
|
||||
Restore mode uses `INSTALLED_ESP_PARTITION` (distinct from `RESTORE_ESP_PARTITION`) because two ESPs are present. It also tracks `RESTORE_STATE_PARTITION` separately because that partition is writable state but protected from delete/format operations. Dual-boot mode keeps `BOOT_PARTITION` since only one ESP exists and is unambiguous.
|
||||
|
||||
## Archinstall Config
|
||||
|
||||
Protected modes generate this disk config and explicitly disable archinstall bootloader writes:
|
||||
|
||||
```json
|
||||
{
|
||||
"bootloader": null,
|
||||
"disk_config": {
|
||||
"config_type": "pre_mounted_config",
|
||||
"mountpoint": "/mnt/archinstall"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Do not include `device_modifications`, `disk_encryption`, or `btrfs_options` in protected modes. The installer handles those before and after archinstall. If archinstall does not accept `"bootloader": null`, Phase 2a must identify the supported equivalent; protected mode must not proceed while archinstall can write boot files or NVRAM entries independently.
|
||||
|
||||
Full-disk mode keeps the existing generated `default_layout` config.
|
||||
|
||||
**Verify before Phase 2b.** Under `pre_mounted_config`, archinstall still tends to run `genfstab`, write a bootloader, and may write `/etc/crypttab`. The plan currently assumes archinstall acts only as a base-system installer in protected mode; this needs to be confirmed on a throwaway VM. Once confirmed, decide for each step (fstab, crypttab, bootloader, initramfs, kernel cmdline) whether the manual steps below run *instead of* archinstall's behavior, *after* archinstall has had its turn, or with archinstall's step disabled in config. The bootloader outcome is a hard gate: protected mode must have a confirmed way to prevent archinstall from writing Limine files or NVRAM entries. Do not start writing the manual pre/post steps until this is resolved — the unknown sits underneath every downstream step.
|
||||
|
||||
## File Changes
|
||||
|
||||
### `configs/airootfs/root/configurator`
|
||||
|
||||
Add shared detection and policy selection:
|
||||
|
||||
- `detect_disk_layout` finds GPT state, partitions, partition ranges, filesystem labels, ESPs, ESP free space, and free regions.
|
||||
- `detect_existing_os` mounts ESP read-only and checks known vendor paths like `EFI/Microsoft`, `EFI/ubuntu`, `EFI/fedora`, `EFI/GRUB`, and `EFI/systemd`.
|
||||
- `detect_bitlocker` checks every Windows/basic-data candidate partition on the selected disk for BitLocker. Use `blkid -o value -s TYPE`; BitLocker volumes report `BitLocker` rather than `ntfs`. As a backup, read 8 bytes at offset 3 of the partition — BitLocker volumes contain `-FVE-FS-` instead of `NTFS `. If any partition reports BitLocker, the dual-boot path aborts before any disk mutation with the message described in the dual-boot policy.
|
||||
- `detect_restore_layout` finds `OMARCHY_RESTORE` and `OMARCHY_STATE` partitions and validates the restore ESP/payload/state set.
|
||||
- `find_free_regions` finds unallocated GPT space using `sgdisk` or `parted`. Selection rule: pick the largest single contiguous region of at least 40 GB. Do not concatenate non-contiguous regions.
|
||||
- `install_mode_form` chooses between full disk, dual boot, and restore install based on context.
|
||||
|
||||
User flow:
|
||||
|
||||
```text
|
||||
Normal ISO boot:
|
||||
select disk
|
||||
detect layout
|
||||
if existing OS/install is detected:
|
||||
show detected installs and disk layout summary
|
||||
always offer "Exclusive install (erase disk)" using the existing full-disk `wipe: true` path
|
||||
if existing ESP + enough free space + guardrails pass:
|
||||
also offer "Install alongside"
|
||||
else:
|
||||
show why "Install alongside" is unavailable (for example: no ESP, no suitable free space, BitLocker/FVE, ESP too small)
|
||||
else:
|
||||
keep current erase-disk flow
|
||||
|
||||
Restore boot:
|
||||
detect restore disk automatically
|
||||
show first-install or reinstall warning
|
||||
protect restore ESP/payload/state partitions
|
||||
create/recreate installed partitions only
|
||||
```
|
||||
|
||||
Generated files:
|
||||
|
||||
- `user_credentials.json`
|
||||
- `user_configuration.json`
|
||||
- `user_full_name.txt`
|
||||
- `user_email_address.txt`
|
||||
- `install_mode.sh`
|
||||
|
||||
### `configs/airootfs/root/.automated_script.sh`
|
||||
|
||||
Add shared protected install functions:
|
||||
|
||||
- Source `install_mode.sh` after configurator runs.
|
||||
- Set `INSTALL_ROOT=/mnt/archinstall` for all modes, including full-disk. A single mount root avoids subtle bugs in code touched by both paths and removes one branch from every post-install step.
|
||||
- Before changing the mount root, grep the live ISO and post-install scripts for hardcoded `/mnt` references and produce an explicit list of call sites. Update each one consciously rather than via a global rename.
|
||||
- Add `setup_protected_partitions` before archinstall.
|
||||
- Add `configure_protected_post_install` after archinstall.
|
||||
- Add cleanup trap for mounts, LUKS mappings, and temporary directories.
|
||||
|
||||
Protected pre-install steps:
|
||||
|
||||
1. Verify mode-specific guardrails.
|
||||
2. Create writable partitions if needed.
|
||||
3. Run `partprobe "$DISK"` and `udevadm settle`.
|
||||
4. Format installed ESP when policy requires it.
|
||||
5. `cryptsetup luksFormat` the root partition.
|
||||
6. `cryptsetup open` root as `omarchy_root`.
|
||||
7. `mkfs.btrfs /dev/mapper/omarchy_root`.
|
||||
8. Create Btrfs subvolumes `@`, `@home`, `@log`, and `@pkg`.
|
||||
9. Mount subvolumes at `$INSTALL_ROOT` with `compress=zstd`.
|
||||
10. Mount ESP at `$INSTALL_ROOT/efi`; keep `$INSTALL_ROOT/boot` as a normal directory on encrypted root.
|
||||
11. Generate deterministic fstab with overwrite semantics.
|
||||
|
||||
Protected post-install steps:
|
||||
|
||||
1. Configure root unlock for the initramfs hook actually in use. Root unlock is driven by initramfs + kernel cmdline, not by `/etc/crypttab`:
|
||||
- `encrypt` hook: kernel cmdline `cryptdevice=UUID=<luks-uuid>:omarchy_root root=/dev/mapper/omarchy_root`. No crypttab entry needed for root.
|
||||
- `sd-encrypt` hook: write `/etc/crypttab.initramfs` (not `/etc/crypttab`) with the LUKS UUID, and pass `rd.luks.name=<luks-uuid>=omarchy_root root=/dev/mapper/omarchy_root` on the kernel cmdline.
|
||||
2. Update mkinitcpio hooks for the initramfs mode actually present.
|
||||
3. Regenerate initramfs in chroot with `mkinitcpio -P`.
|
||||
4. Install an Omarchy-owned kernel/initramfs sync hook that copies boot artifacts from `/boot` to `/efi/EFI/Omarchy` after kernel or initramfs updates.
|
||||
5. Run the initial boot artifact sync before writing Limine config.
|
||||
6. Configure Limine kernel cmdline for encrypted root per the hook chosen above, pointing at namespaced artifacts under `EFI/Omarchy`.
|
||||
7. Install or update Limine on the selected installed ESP.
|
||||
8. Run `limine-scan` in chroot when appropriate.
|
||||
9. Apply mode-specific boot order policy.
|
||||
10. Leave swapfile creation, `resume` hook, and `resume=` cmdline injection to Omarchy's existing `omarchy-hibernation-setup` post-install hook. Verify `/etc/limine-entry-tool.d/` drop-ins are honored by the Limine config layout written here so that hook works unchanged.
|
||||
|
||||
### `builder/archinstall.packages`
|
||||
|
||||
Ensure the live environment has the tools needed for protected installs:
|
||||
|
||||
- `parted`
|
||||
- `gptfdisk`
|
||||
- `btrfs-progs`
|
||||
- `cryptsetup`
|
||||
- `dosfstools`
|
||||
- `efibootmgr`
|
||||
- `mtools`
|
||||
|
||||
`efibootmgr` already exists in this file, but keep it explicit in the protected-install dependency checklist.
|
||||
|
||||
### `builder/build-iso.sh`
|
||||
|
||||
Add build-time mode support:
|
||||
|
||||
- Persist `/root/omarchy_mode` as `normal`, `dual_boot_capable`, or `restore`.
|
||||
- For restore builds, inject `copytoram=y omarchy.mode=restore` into restore boot entries.
|
||||
- For restore builds, assert boot entries contain `copytoram=y`.
|
||||
- For normal ISO builds, keep existing behavior except adding dual-boot capability.
|
||||
|
||||
### `bin/omarchy-iso-make`
|
||||
|
||||
Add optional build flags:
|
||||
|
||||
```text
|
||||
--restore-image build omarchy-restore.img.zst factory artifact
|
||||
```
|
||||
|
||||
Keep the existing ISO output path unchanged unless a restore image is explicitly requested.
|
||||
|
||||
## Bootloader Policy
|
||||
|
||||
### Default Boot Target
|
||||
|
||||
For any standalone install (full-disk or restore_install, with or without a restore partition set), installed Omarchy is the default boot target. The persistent `BootOrder` after every successful install or factory reset is:
|
||||
|
||||
1. Installed Omarchy
|
||||
2. Restore (when a restore partition set exists)
|
||||
3. Firmware fallback
|
||||
|
||||
The user should never have to pick Omarchy from a boot menu under normal operation. Restore is reachable but never default. Boot-menu entries inside Limine for restore/reinstall are convenience; they do not replace this NVRAM ordering.
|
||||
|
||||
`BootNext` is used only for one-shot transitions (factory reset trigger, post-reset return to installed Omarchy) and must not be used to make Omarchy "stickily" default — that's the job of `BootOrder`. The only temporary exception is an in-progress factory reset: after the restore environment accepts the trigger and before it mutates installed partitions, restore becomes the persistent first boot target until reinstall succeeds.
|
||||
|
||||
For dual-boot, the previous OS's firmware order is preserved unless the user explicitly opts to make Omarchy first. Dual-boot is the only mode where Omarchy may not be the firmware default.
|
||||
|
||||
### Dual Boot
|
||||
|
||||
- Install Omarchy boot files under `EFI/Omarchy` on the shared ESP.
|
||||
- Do not overwrite existing vendor directories.
|
||||
- Preserve firmware default boot order unless user opts in.
|
||||
- Run `limine-scan` so Windows/Linux entries are discoverable from Omarchy's menu. Verify on a real Windows + Linux dual-boot before depending on this — Limine's auto-detection is less battle-tested than GRUB's. If it does not pick them up cleanly, fall back to the firmware boot menu and document the limitation rather than hand-writing entries.
|
||||
- BitLocker is handled at detection time by aborting the install (see Dual Boot policy). The bootloader path therefore only ever sees Windows volumes with BitLocker fully off/decrypted.
|
||||
|
||||
### Restore Install
|
||||
|
||||
- Installed Omarchy gets its own ESP and boot entry.
|
||||
- Restore ESP keeps `EFI/BOOT/BOOTX64.EFI` fallback path.
|
||||
- After successful install, write `BootOrder` as installed Omarchy first, restore second. Do not reorder on every boot — only after install or factory reset.
|
||||
- Add a restore/reinstall entry to the installed Limine menu if it can be done cleanly.
|
||||
- If NVRAM writes fail, rely on fallback boot paths and show a warning. The user can still recover via firmware boot menu.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Refactor Without Behavior Change
|
||||
|
||||
- Introduce `INSTALL_MODE=full_disk`.
|
||||
- Introduce `$INSTALL_ROOT` and update post-install paths.
|
||||
- Keep existing full-disk JSON and archinstall behavior.
|
||||
- Add tests or dry-run checks for config generation.
|
||||
|
||||
### Phase 2a: Archinstall pre_mounted_config Spike
|
||||
|
||||
Single-purpose spike on a throwaway VM. No production code changes.
|
||||
|
||||
- Drive archinstall with a hand-crafted mount tree and `pre_mounted_config`.
|
||||
- Record exactly what archinstall does: fstab generation, crypttab writes, bootloader install, initramfs regeneration, kernel cmdline writes.
|
||||
- Prove the protected-mode config disables archinstall bootloader and NVRAM writes, or document the supported replacement setting. This is a release blocker for Phase 2b.
|
||||
- Decide whether the manual fstab/Limine/initramfs steps in this plan run before, after, or instead of archinstall's own steps.
|
||||
- Update this plan with the answers before Phase 2b begins. The downstream design depends on this; do not skip.
|
||||
|
||||
### Phase 2b: Shared Protected Install Engine
|
||||
|
||||
- Add `pre_mounted_config` generation.
|
||||
- Add manual LUKS, Btrfs, subvolume, mount, fstab, and cleanup logic.
|
||||
- Add encrypted boot post-install configuration.
|
||||
- Add namespaced ESP boot artifact sync under `EFI/Omarchy`.
|
||||
- Keep it behind internal mode switches until validated.
|
||||
|
||||
### Phase 3: Dual-Boot UI and Policy
|
||||
|
||||
- Add disk layout detection.
|
||||
- When another install is detected, show an install-mode choice instead of assuming dual boot: "Install alongside" when eligible, and "Exclusive install (erase disk)" using the existing full-disk `wipe: true` path.
|
||||
- If another install is detected but dual boot is not eligible, explain why and still offer the exclusive install path.
|
||||
- Add Windows/BitLocker warnings.
|
||||
- Validate alongside Windows and Linux.
|
||||
|
||||
### Phase 4: Restore Install Policy
|
||||
|
||||
- Add restore layout detection.
|
||||
- Add restore partition set protection.
|
||||
- Add first-install and reinstall flows.
|
||||
- Validate that restore ESP, payload, and state survive installation and later reinstall.
|
||||
|
||||
### Phase 5: Restore Factory Image
|
||||
|
||||
- Build `omarchy-restore.img.zst`.
|
||||
- Add factory flashing instructions or a dedicated factory flasher ISO/PXE workflow.
|
||||
- Validate first boot from internal disk with no external media.
|
||||
- Sign `omarchy-restore.img.zst` and the restore payload manifest. Verify in the factory flasher and at restore boot before any disk mutation.
|
||||
- Add first-boot UUID randomization for cloned restore images before generating installed boot entries or state.
|
||||
- Decide on Secure Boot before this phase ships. Shipping with SB disabled is a non-starter for some buyers, and BitLocker (when present on a dual-boot machine restored later) reseals against firmware state. Either implement a signed shim/Limine path, or commit to "SB off" with a documented rationale and a way for the user to flip it on later if they have keys.
|
||||
|
||||
### Phase 6: Factory Reset
|
||||
|
||||
- Implement `omarchy-factory-reset` as the installed-OS staging command: write and fsync the trigger flag, verify/recreate the restore boot entry, set and confirm `BootNext`, then leave the machine ready to boot restore.
|
||||
- Wire the restore environment to detect the trigger flag and run the reinstall flow non-interactively.
|
||||
- Set `BootNext` for one-shot transitions and temporary restore-first persistent `BootOrder` during the destructive reset window.
|
||||
- Validate on `restore_install` machines first (the flow already has a restore partition set).
|
||||
|
||||
### Phase 7: Restore Partition for Standalone Installs
|
||||
|
||||
- Extend the standalone full-disk install path to create restore ESP + restore payload + restore state + installed ESP + installed root, mirroring the `restore_install` layout.
|
||||
- Populate restore payload at install time from the live ISO offline mirror.
|
||||
- Add `--no-restore` opt-out for users who explicitly want the legacy two-partition layout.
|
||||
- After this phase, every consumer install gets factory reset by default.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Full-disk install behavior remains unchanged through Phase 6. Phase 7 changes the default layout to include a restore partition set; legacy layout remains available via `--no-restore`.
|
||||
- Dual-boot install preserves existing OS partitions.
|
||||
- Dual-boot install creates encrypted Omarchy root in free space.
|
||||
- When another install is detected, the installer offers the current exclusive full-disk wipe path and only offers dual boot when guardrails pass.
|
||||
- Dual-boot install writes Omarchy boot artifacts only under `EFI/Omarchy` on a shared ESP and leaves existing vendor paths untouched.
|
||||
- Dual-boot install aborts cleanly when BitLocker/FVE is detected, including suspended BitLocker, before any disk mutation, with an actionable message.
|
||||
- Restore install preserves restore ESP, restore payload, and restore state partitions.
|
||||
- Restore install creates or recreates only installed Omarchy partitions.
|
||||
- Protected modes never generate archinstall config with `wipe: true`.
|
||||
- Protected modes prevent archinstall from independently writing boot files or NVRAM entries.
|
||||
- Protected modes abort before destructive operations if a target partition is protected.
|
||||
- Restore payload integrity is verified with a signed manifest before any restore-triggered disk mutation.
|
||||
- Installed Omarchy boots and requires LUKS unlock.
|
||||
- Installed Omarchy is the default firmware boot target for any standalone install (full-disk or restore_install).
|
||||
- Restore boot remains available after first install but is never the default.
|
||||
- Reinstall from restore works without external media.
|
||||
- `omarchy-factory-reset` from the installed OS leaves the machine staged exactly like a freshly prepared restore-boot device: trigger flag durable on restore state, restore boot entry present, and `BootNext` pointing at restore. No installed partitions are destroyed before the next restore boot.
|
||||
- A subsequent restore boot from that staged state wipes installed partitions, reinstalls a fresh Omarchy, and lands the user back in the installed OS without external media. Restore ESP, payload, and state partitions survive intact.
|
||||
- Factory reset refuses to run when no complete restore partition set is present (e.g. dual-boot, `--no-restore` install).
|
||||
|
||||
## Validation Matrix
|
||||
|
||||
### Regression
|
||||
|
||||
1. Full-disk UEFI install on empty disk.
|
||||
2. Current normal ISO boot menu and default install behavior.
|
||||
3. Existing offline package installation flow.
|
||||
|
||||
### Dual Boot
|
||||
|
||||
1. Windows + free space + existing ESP (BitLocker off).
|
||||
2. Windows + BitLocker enabled — installer must abort before any disk mutation with a clear "turn off BitLocker and wait for decryption" message.
|
||||
3. Windows + BitLocker suspended — installer must still abort, because v1 treats any BitLocker/FVE signature as unsafe.
|
||||
4. Linux distro + free space + existing ESP.
|
||||
5. GPT disk with no free space.
|
||||
6. ESP below total-size warning threshold.
|
||||
7. ESP below total-size minimum threshold.
|
||||
8. ESP large enough by total size but below free-space minimum.
|
||||
9. Multiple free regions; choose largest suitable region.
|
||||
10. Firmware boot order preservation.
|
||||
|
||||
### Restore
|
||||
|
||||
1. Factory image written to larger virtual disk.
|
||||
2. First boot from internal restore ESP.
|
||||
3. First install randomizes cloned disk/partition/filesystem UUIDs before creating installed boot entries.
|
||||
4. First install creates installed ESP/root and preserves restore ESP, payload, and state.
|
||||
5. Installed OS boots with encrypted root.
|
||||
6. Installed Omarchy is the firmware default; restore is reachable but not default.
|
||||
7. Restore boot still works after installed OS exists.
|
||||
8. Reinstall formats only the installed Omarchy partitions in place; PARTUUIDs unchanged.
|
||||
9. Restore mode without `copytoram=y` aborts before disk mutation.
|
||||
10. Missing restore payload or restore state aborts before disk mutation.
|
||||
11. NVRAM boot entry write failure still leaves fallback boot path usable.
|
||||
|
||||
### Factory Reset
|
||||
|
||||
1. `omarchy-factory-reset` from installed OS leaves the system staged for restore boot: trigger flag durable on restore state, restore boot entry present, and `BootNext` confirmed.
|
||||
2. No installed partitions are destroyed before the staged system actually boots into restore.
|
||||
3. Booting into restore from the staged state reinstalls and returns to a fresh installed Omarchy as the default-booted OS.
|
||||
4. Trigger flag is deleted before the post-reset reboot. Machine does not loop.
|
||||
5. Reset interrupted between trigger and restore boot: next normal boot still goes to installed Omarchy (because `BootNext` is one-shot).
|
||||
6. Reset interrupted during reinstall (power loss): persistent temporary restore-first `BootOrder` boots restore again and re-runs the flow from the trigger flag/reset-in-progress state.
|
||||
7. Reset refused when no complete restore partition set is detected.
|
||||
8. Reset refused when signed restore payload verification fails.
|
||||
9. Reset refused when running without root.
|
||||
10. Reset refused when restore boot entry is missing from NVRAM and cannot be repopulated from fallback.
|
||||
|
||||
### Negative Safety Tests
|
||||
|
||||
1. Attempt to format protected restore ESP.
|
||||
2. Attempt to format protected restore payload.
|
||||
3. Attempt to format protected restore state.
|
||||
4. Attempt to format existing Windows partition in dual-boot mode.
|
||||
5. Partition overlap with protected range.
|
||||
6. Power loss before partition creation.
|
||||
7. Power loss after partition creation but before archinstall.
|
||||
8. Power loss during archinstall.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Restore payload size: full offline mirror versus smaller network-capable restore image. Affects whether the consumer ISO's restore-partition default in Phase 7 is feasible at typical disk sizes.
|
||||
- Restore update strategy after devices ship.
|
||||
- Whether restore should also expose diagnostics / log upload alongside factory reset.
|
||||
- Whether factory reset should preserve `/home` user data optionally, or always wipe. v1 always wipes (simpler, matches user expectation of "factory reset"); a "keep my files" variant could land later.
|
||||
|
||||
Resolved:
|
||||
|
||||
- User-facing reset staging command: yes, `omarchy-factory-reset` (Phase 6).
|
||||
- Secure Boot: promoted to Phase 5 decision gate.
|
||||
- Btrfs subvolume layout: protected modes use the current full-disk layout, `@`, `@home`, `@log`, and `@pkg`. The `/swap` subvolume for hibernation is created post-install by `omarchy-hibernation-setup` and is not predeclared at install time.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- One shared protected-partition implementation supports both dual-boot and restore install policies.
|
||||
- Full-disk installs remain stable through Phase 6 and gain a default restore partition set in Phase 7.
|
||||
- Dual-boot users can install alongside existing OSes without data loss.
|
||||
- OEM devices can boot internal restore on first power-on, install Omarchy, and retain restore for future reinstall.
|
||||
- All destructive operations are guarded by protected partition checks.
|
||||
- Installed Omarchy is the firmware default boot target on every standalone install.
|
||||
- `omarchy-factory-reset` stages any standalone install to boot internal restore without external media; the restore-triggered reinstall returns the user to a fresh installed Omarchy.
|
||||
- VM and hardware validation matrices pass.
|
||||
@@ -1,124 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,13 +1,16 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Fast, VM-free tests only. The integration scenarios that boot the ISO in
|
||||
# QEMU live in test/integration.d/ and run via ./test/integration.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
for test in "$ROOT"/test/*-test.sh; do
|
||||
for test in "$ROOT"/test/unit/*-test.sh; do
|
||||
printf '==> %s\n' "${test#$ROOT/}"
|
||||
bash "$test"
|
||||
done
|
||||
|
||||
printf '==> test/test_*.py\n'
|
||||
python3 -m unittest discover -t "$ROOT" -s "$ROOT/test"
|
||||
printf '==> test/unit/test_*.py\n'
|
||||
python3 -m unittest discover -t "$ROOT" -s "$ROOT/test/unit"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Integration runner: boots an ISO's installed system in QEMU and runs the
|
||||
# scenarios in test/integration.d/ against it. The unattended cidata install
|
||||
# happens once per ISO and is saved as a reusable base image; every scenario
|
||||
# then boots a throwaway overlay of that base.
|
||||
#
|
||||
# Usage: test/integration [release/omarchy.iso] [scenario ...] [options]
|
||||
#
|
||||
# scenario Basename of a test/integration.d/<scenario>-test.sh file;
|
||||
# all scenarios run when none are named
|
||||
# --reuse-base Skip the install if a saved base image exists
|
||||
# --port PORT Host port forwarded to guest SSH (default 2322)
|
||||
# --memory MB VM memory (default 8192)
|
||||
# --timeout SECS Install timeout (default 2400)
|
||||
# --no-preview Do not open the collected screenshots in imv
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
ISO=""
|
||||
REUSE_BASE=false
|
||||
SCENARIOS=()
|
||||
|
||||
export OMARCHY_INTEGRATION_SSH_PORT=2322
|
||||
export OMARCHY_INTEGRATION_MEMORY=8192
|
||||
export OMARCHY_INTEGRATION_INSTALL_TIMEOUT=2400
|
||||
export OMARCHY_INTEGRATION_NO_PREVIEW=false
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--reuse-base) REUSE_BASE=true ;;
|
||||
--no-preview) OMARCHY_INTEGRATION_NO_PREVIEW=true ;;
|
||||
--port) OMARCHY_INTEGRATION_SSH_PORT="$2"; shift ;;
|
||||
--memory) OMARCHY_INTEGRATION_MEMORY="$2"; shift ;;
|
||||
--timeout) OMARCHY_INTEGRATION_INSTALL_TIMEOUT="$2"; shift ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*)
|
||||
if [[ $1 == *.iso ]]; then
|
||||
ISO="$1"
|
||||
else
|
||||
SCENARIOS+=("$ROOT/test/integration.d/$1-test.sh")
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ -z $ISO && -t 0 ]]; then
|
||||
ISO=$(ls -t "$ROOT"/release/*.iso 2>/dev/null | gum choose --header "Select ISO to test")
|
||||
fi
|
||||
|
||||
if [[ ! -f ${ISO:-} ]]; then
|
||||
echo "No ISO to test. Pass one explicitly or put it in release/." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( ${#SCENARIOS[@]} == 0 )); then
|
||||
SCENARIOS=("$ROOT"/test/integration.d/*-test.sh)
|
||||
else
|
||||
for scenario in "${SCENARIOS[@]}"; do
|
||||
if [[ $(basename "$scenario") == "base-test.sh" || ! -f $scenario ]]; then
|
||||
echo "No such scenario: $scenario" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng mtools
|
||||
|
||||
export OMARCHY_INTEGRATION_ISO
|
||||
OMARCHY_INTEGRATION_ISO=$(realpath "$ISO")
|
||||
|
||||
# The install phase runs in this process; scenarios run in their own so each
|
||||
# gets its own VM lifecycle, cleanup trap, and run directory.
|
||||
SCENARIO=install
|
||||
source "$ROOT/test/integration.d/base-test.sh"
|
||||
|
||||
if $REUSE_BASE && base_image_ready; then
|
||||
log "Reusing existing base image: $BASE_DISK"
|
||||
else
|
||||
install_phase
|
||||
fi
|
||||
|
||||
status=0
|
||||
for scenario in "${SCENARIOS[@]}"; do
|
||||
[[ $(basename "$scenario") == "base-test.sh" ]] && continue
|
||||
printf '\033[1;35m==> %s\033[0m\n' "${scenario#$ROOT/}"
|
||||
# env -u: a SCENARIO exported by the caller (or leaked from this process)
|
||||
# must not override the name each scenario derives from its own $0.
|
||||
env -u SCENARIO bash "$scenario" || status=1
|
||||
done
|
||||
|
||||
exit $status
|
||||
@@ -0,0 +1,597 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Shared harness for the integration scenarios in test/integration.d/: QEMU
|
||||
# lifecycle, QMP screendump + OCR console driving, virtual keystrokes, guest
|
||||
# SSH, cidata autoinstall, and the reusable base-image contract. Source this
|
||||
# from a scenario; ./test/integration exports the configuration and ensures
|
||||
# the base image exists before any scenario runs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
|
||||
ISO="$OMARCHY_INTEGRATION_ISO"
|
||||
SSH_PORT="${OMARCHY_INTEGRATION_SSH_PORT:-2322}"
|
||||
MEMORY="${OMARCHY_INTEGRATION_MEMORY:-8192}"
|
||||
INSTALL_TIMEOUT="${OMARCHY_INTEGRATION_INSTALL_TIMEOUT:-2400}"
|
||||
# cache=none keeps the host page cache out of the guest's disk writes, so
|
||||
# install times measure the installer rather than the host (CI sets it).
|
||||
DISK_CACHE="${OMARCHY_INTEGRATION_DISK_CACHE:-}"
|
||||
NO_PREVIEW="${OMARCHY_INTEGRATION_NO_PREVIEW:-false}"
|
||||
BOOT_TIMEOUT=600
|
||||
|
||||
GUEST_USER="omarchy"
|
||||
GUEST_PASSWORD="omarchy"
|
||||
GUEST_HOSTNAME="omarchy-test"
|
||||
|
||||
SCENARIO="${SCENARIO:-$(basename "${0%-test.sh}")}"
|
||||
|
||||
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
|
||||
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
|
||||
|
||||
BASE_DIR="$ROOT/test-runs/$(basename "$ISO" .iso)-integration"
|
||||
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)-$SCENARIO"
|
||||
BASE_DISK="$BASE_DIR/base.qcow2"
|
||||
BASE_OVMF="$BASE_DIR/OVMF_VARS.4m.fd"
|
||||
ACTIVE_OVMF="$BASE_OVMF"
|
||||
SSH_KEY="$BASE_DIR/id_ed25519"
|
||||
CIDATA_IMG="$BASE_DIR/cidata.img"
|
||||
HTTP_PORT=$((SSH_PORT + 1))
|
||||
HTTP_PID=""
|
||||
|
||||
mkdir -p "$BASE_DIR" "$RUN_DIR"
|
||||
|
||||
QMP_SOCK=$(mktemp -u "${TMPDIR:-/tmp}/omarchy-integration-qmp.XXXXXX.sock")
|
||||
PIDFILE="$RUN_DIR/qemu.pid"
|
||||
|
||||
FAILURES=0
|
||||
|
||||
log() {
|
||||
printf '\033[1;35m==> %s\033[0m\n' "$1"
|
||||
}
|
||||
|
||||
check() {
|
||||
local description="$1"
|
||||
shift
|
||||
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
printf 'ok - %s\n' "$description"
|
||||
else
|
||||
printf 'not ok - %s\n' "$description"
|
||||
((FAILURES += 1))
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
if ((FAILURES == 0)); then
|
||||
log "$SCENARIO passed. Artifacts: $RUN_DIR"
|
||||
else
|
||||
log "$SCENARIO FAILED: $FAILURES assertion(s). Artifacts: $RUN_DIR"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
base_image_ready() {
|
||||
[[ -f $BASE_DISK && -f $BASE_OVMF && -f $SSH_KEY ]]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- vm control
|
||||
|
||||
vm_running() {
|
||||
[[ -f $PIDFILE ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
|
||||
}
|
||||
|
||||
qmp() {
|
||||
printf '{"execute":"qmp_capabilities"}\n{"execute":%s}\n' "$1" |
|
||||
timeout 5 socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true
|
||||
}
|
||||
|
||||
screendump() {
|
||||
qmp "\"screendump\", \"arguments\": {\"filename\": \"$1\"}" >/dev/null
|
||||
}
|
||||
|
||||
capture_console() {
|
||||
local name="$1" shot="$RUN_DIR/.capture.ppm"
|
||||
|
||||
sleep 1
|
||||
screendump "$shot"
|
||||
[[ -s $shot ]] || return 0
|
||||
magick "$shot" "$RUN_DIR/$name.png" 2>/dev/null || true
|
||||
rm -f "$shot"
|
||||
}
|
||||
|
||||
stop_vm() {
|
||||
vm_running || return 0
|
||||
|
||||
if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then
|
||||
qmp '"system_powerdown"' >/dev/null
|
||||
fi
|
||||
|
||||
local waited=0
|
||||
while vm_running && ((waited < 15)); do
|
||||
sleep 1
|
||||
((waited += 1))
|
||||
done
|
||||
|
||||
if vm_running; then
|
||||
qmp '"quit"' >/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
if vm_running; then
|
||||
kill "$(cat "$PIDFILE")" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
open_screenshots() {
|
||||
local entry
|
||||
local -a screenshots=()
|
||||
|
||||
while IFS= read -r -d '' entry; do
|
||||
screenshots+=("${entry#* }")
|
||||
done < <(
|
||||
find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -printf '%T@ %p\0' |
|
||||
sort -zn
|
||||
)
|
||||
|
||||
(( ${#screenshots[@]} > 0 )) || return 0
|
||||
|
||||
log "Visual review: ${#screenshots[@]} screenshots in $RUN_DIR"
|
||||
[[ $NO_PREVIEW == "true" ]] && return 0
|
||||
|
||||
if command -v imv >/dev/null && [[ -n ${WAYLAND_DISPLAY:-}${DISPLAY:-} ]]; then
|
||||
setsid -f imv "${screenshots[@]}" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
|
||||
[[ -n $HTTP_PID ]] && kill "$HTTP_PID" 2>/dev/null || true
|
||||
vm_running && log "Stopping test VM"
|
||||
stop_vm
|
||||
rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png"
|
||||
open_screenshots
|
||||
rmdir "$RUN_DIR" 2>/dev/null || true
|
||||
|
||||
return $status
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_vm() {
|
||||
local disk="$1" serial="$2"
|
||||
shift 2
|
||||
|
||||
qemu-system-x86_64 \
|
||||
-cpu host -enable-kvm -machine q35,accel=kvm \
|
||||
-smp "$(nproc)" \
|
||||
-m "$MEMORY" \
|
||||
-drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \
|
||||
-drive if=pflash,format=raw,file="$ACTIVE_OVMF" \
|
||||
-drive file="$disk",format=qcow2,if=none,id=drive0${DISK_CACHE:+,cache=$DISK_CACHE} \
|
||||
-device virtio-blk-pci,drive=drive0,bootindex=1 \
|
||||
-device virtio-vga \
|
||||
-display none \
|
||||
-usb -device usb-tablet \
|
||||
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \
|
||||
-device virtio-net-pci,netdev=net0 \
|
||||
-qmp "unix:$QMP_SOCK,server,nowait" \
|
||||
-serial "file:$serial" \
|
||||
-pidfile "$PIDFILE" \
|
||||
-daemonize \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# Boot a throwaway overlay of the installed base. The disk gets a per-run
|
||||
# overlay; the firmware vars need the same isolation or NVRAM state would
|
||||
# leak between runs.
|
||||
start_vm_from_base() {
|
||||
qemu-img create -f qcow2 -b "$BASE_DISK" -F qcow2 "$RUN_DIR/run.qcow2" >/dev/null
|
||||
cp "$BASE_OVMF" "$RUN_DIR/OVMF_VARS.4m.fd"
|
||||
ACTIVE_OVMF="$RUN_DIR/OVMF_VARS.4m.fd"
|
||||
start_vm "$RUN_DIR/run.qcow2" "$RUN_DIR/serial.log"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ console driver
|
||||
|
||||
ocr_screen() {
|
||||
local shot="$RUN_DIR/.screen.ppm" prepped="$RUN_DIR/.screen.png"
|
||||
|
||||
screendump "$shot"
|
||||
[[ -s $shot ]] || return 0
|
||||
magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0
|
||||
tesseract "$prepped" - --psm 6 2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_for_screen() {
|
||||
local text="$1" timeout="$2" waited=0 slug
|
||||
|
||||
until ocr_screen | grep -qi "$text"; do
|
||||
if ! vm_running; then
|
||||
echo "VM exited while waiting for screen: $text" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ((waited >= timeout)); then
|
||||
slug=${text,,}
|
||||
slug=${slug// /-}
|
||||
slug=${slug//[^a-z0-9-]/}
|
||||
capture_console "failure-waiting-for-$slug"
|
||||
echo "Timed out after ${timeout}s waiting for screen: $text" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
((waited += 3))
|
||||
done
|
||||
|
||||
sleep 1
|
||||
}
|
||||
|
||||
press() {
|
||||
local part json=""
|
||||
local -a parts
|
||||
|
||||
IFS='-' read -ra parts <<<"$1"
|
||||
for part in "${parts[@]}"; do
|
||||
json+="{\"type\":\"qcode\",\"data\":\"$part\"},"
|
||||
done
|
||||
|
||||
qmp "\"send-key\", \"arguments\": {\"keys\": [${json%,}]}" >/dev/null
|
||||
}
|
||||
|
||||
type_text() {
|
||||
local text="$1" ch i
|
||||
|
||||
for ((i = 0; i < ${#text}; i++)); do
|
||||
ch=${text:i:1}
|
||||
case "$ch" in
|
||||
[a-z0-9]) press "$ch" ;;
|
||||
[A-Z]) press "shift-${ch,,}" ;;
|
||||
" ") press spc ;;
|
||||
.) press dot ;;
|
||||
,) press comma ;;
|
||||
-) press minus ;;
|
||||
_) press shift-minus ;;
|
||||
/) press slash ;;
|
||||
:) press shift-semicolon ;;
|
||||
"&") press shift-7 ;;
|
||||
"=") press equal ;;
|
||||
"?") press shift-slash ;;
|
||||
"!") press shift-1 ;;
|
||||
"~") press shift-grave_accent ;;
|
||||
@) press shift-2 ;;
|
||||
*) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
|
||||
esac
|
||||
sleep 0.05
|
||||
done
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------- guest
|
||||
|
||||
ssh_guest() {
|
||||
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
|
||||
-o BatchMode=yes \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=5 \
|
||||
-o LogLevel=ERROR \
|
||||
"$GUEST_USER@127.0.0.1" "$@"
|
||||
}
|
||||
|
||||
ssh_sudo() {
|
||||
ssh_guest "echo $GUEST_PASSWORD | sudo -S -p '' bash -c $(printf %q "$1")"
|
||||
}
|
||||
|
||||
# The deadline is wall-clock: under QEMU user networking every attempt can
|
||||
# spend its whole ConnectTimeout waiting for a banner, so counting only the
|
||||
# sleeps let a "300 s" wait run for ten minutes or more, silently.
|
||||
wait_for_ssh() {
|
||||
local timeout="$1" failure_name="${2:-failure-ssh-timeout}"
|
||||
local started=$SECONDS next_note=30 progress_name
|
||||
|
||||
while ! ssh_guest true 2>/dev/null; do
|
||||
if ! vm_running; then
|
||||
echo "VM exited while waiting for SSH" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ((SECONDS - started >= timeout)); then
|
||||
capture_console "$failure_name"
|
||||
echo "Timed out after ${timeout}s waiting for SSH" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ((SECONDS - started >= next_note)); then
|
||||
printf -v progress_name 'waiting-ssh-%04ds' "$((SECONDS - started))"
|
||||
capture_console "$progress_name"
|
||||
echo " ... waiting for SSH ($((SECONDS - started))s)"
|
||||
((next_note += 30))
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
# Authorize SSH the way a person would when the guest has no key yet (or a
|
||||
# reset just scrubbed it): console login on a spare TTY, then a bootstrap
|
||||
# script fetched from a throwaway host HTTP server.
|
||||
bootstrap_ssh() {
|
||||
log "Authorizing SSH access via console login"
|
||||
|
||||
mkdir -p "$BASE_DIR/www"
|
||||
cat >"$BASE_DIR/www/bootstrap" <<EOF
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "$(cat "$SSH_KEY.pub")" >>~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
echo "$GUEST_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp
|
||||
echo "$GUEST_PASSWORD" | sudo -S systemctl enable --now sshd.service
|
||||
EOF
|
||||
|
||||
(cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) &
|
||||
HTTP_PID=$!
|
||||
|
||||
local waited=0
|
||||
while true; do
|
||||
press ctrl-alt-f3
|
||||
sleep 4
|
||||
ocr_screen | grep -qi "login:" && break
|
||||
((waited += 8))
|
||||
|
||||
if ((waited >= 300)); then
|
||||
capture_console "failure-console-timeout"
|
||||
echo "Timed out waiting for a console login prompt" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
|
||||
type_text "$GUEST_USER"
|
||||
press ret
|
||||
wait_for_screen "Password" 60
|
||||
type_text "$GUEST_PASSWORD"
|
||||
press ret
|
||||
sleep 3
|
||||
|
||||
type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap -o /tmp/bs && bash /tmp/bs"
|
||||
press ret
|
||||
|
||||
wait_for_ssh 360 "failure-bootstrap-ssh-timeout"
|
||||
capture_console "success-bootstrap-ssh"
|
||||
|
||||
kill "$HTTP_PID" 2>/dev/null || true
|
||||
HTTP_PID=""
|
||||
press ctrl-alt-f1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------- cidata autoinstall
|
||||
|
||||
# The configurator's own output files, synthesized for the 40G virtio disk the
|
||||
# VM boots from. Sizing mirrors the configurator: 1MiB gap, 2GiB ESP, the rest
|
||||
# btrfs minus the GPT backup reserve.
|
||||
build_cidata() {
|
||||
local dir="$BASE_DIR/cidata" hash
|
||||
local disk_bytes=$((40 * 1024 * 1024 * 1024))
|
||||
local mib=$((1024 * 1024)) gib=$((1024 * 1024 * 1024))
|
||||
local boot_start=$mib boot_size=$((2 * gib))
|
||||
local main_start=$((boot_size + boot_start))
|
||||
local main_size=$((disk_bytes - main_start - mib))
|
||||
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir"
|
||||
|
||||
hash=$(openssl passwd -6 "$GUEST_PASSWORD")
|
||||
|
||||
cat >"$dir/user_credentials.json" <<EOF
|
||||
{
|
||||
"root_enc_password": $(jq -Rn --arg v "$hash" '$v'),
|
||||
"users": [
|
||||
{
|
||||
"enc_password": $(jq -Rn --arg v "$hash" '$v'),
|
||||
"groups": [],
|
||||
"sudo": true,
|
||||
"username": "$GUEST_USER"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
cat >"$dir/user_configuration.json" <<EOF
|
||||
{
|
||||
"app_config": null,
|
||||
"archinstall-language": "English",
|
||||
"auth_config": {},
|
||||
"audio_config": { "audio": "pipewire" },
|
||||
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
|
||||
"custom_commands": [],
|
||||
"omarchy_install": {
|
||||
"mode": "full_disk",
|
||||
"defer_provisioning": false,
|
||||
"target_mount": "/mnt",
|
||||
"boot": {
|
||||
"esp_mount": "/boot",
|
||||
"esp_path": "/EFI/limine",
|
||||
"efi_binary": "limine_x64.efi",
|
||||
"enable_fallback": true
|
||||
},
|
||||
"storage": { "kernel": "linux-omarchy" }
|
||||
},
|
||||
"disk_config": {
|
||||
"config_type": "default_layout",
|
||||
"device_modifications": [
|
||||
{
|
||||
"device": "/dev/vda",
|
||||
"partitions": [
|
||||
{
|
||||
"btrfs": [],
|
||||
"dev_path": null,
|
||||
"flags": [ "boot", "esp" ],
|
||||
"fs_type": "fat32",
|
||||
"mount_options": [],
|
||||
"mountpoint": "/boot",
|
||||
"obj_id": "ea21d3f2-82bb-49cc-ab5d-6f81ae94e18d",
|
||||
"size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $boot_size },
|
||||
"start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $boot_start },
|
||||
"status": "create",
|
||||
"type": "primary"
|
||||
},
|
||||
{
|
||||
"btrfs": [
|
||||
{ "mountpoint": "/", "name": "@" },
|
||||
{ "mountpoint": "/home", "name": "@home" },
|
||||
{ "mountpoint": "/var/log", "name": "@log" },
|
||||
{ "mountpoint": "/var/cache/pacman/pkg", "name": "@pkg" }
|
||||
],
|
||||
"dev_path": null,
|
||||
"flags": [],
|
||||
"fs_type": "btrfs",
|
||||
"mount_options": [ "compress=zstd" ],
|
||||
"mountpoint": null,
|
||||
"obj_id": "8c2c2b92-1070-455d-b76a-56263bab24aa",
|
||||
"size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $main_size },
|
||||
"start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": $main_start },
|
||||
"status": "create",
|
||||
"type": "primary"
|
||||
}
|
||||
],
|
||||
"wipe": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"hostname": "$GUEST_HOSTNAME",
|
||||
"kernels": [ "linux-omarchy" ],
|
||||
"network_config": { "type": "iso" },
|
||||
"ntp": true,
|
||||
"parallel_downloads": 8,
|
||||
"script": null,
|
||||
"services": [],
|
||||
"swap": true,
|
||||
"timezone": "UTC",
|
||||
"locale_config": { "kb_layout": "us", "sys_enc": "UTF-8", "sys_lang": "en_US.UTF-8" },
|
||||
"mirror_config": {
|
||||
"custom_repositories": [],
|
||||
"custom_servers": [
|
||||
{"url": "https://mirror.omarchy.org/\$repo/os/\$arch"},
|
||||
{"url": "https://mirror.rackspace.com/archlinux/\$repo/os/\$arch"},
|
||||
{"url": "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"}
|
||||
],
|
||||
"mirror_regions": {},
|
||||
"optional_repositories": []
|
||||
},
|
||||
"packages": [
|
||||
"base-devel",
|
||||
"git",
|
||||
"omarchy-keyring",
|
||||
"$SETTINGS_PACKAGE",
|
||||
"$RUNTIME_PACKAGE"
|
||||
],
|
||||
"profile_config": { "gfx_driver": null, "greeter": null, "profile": {} },
|
||||
"version": "3.0.9"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Omarchy Test" >"$dir/user_full_name.txt"
|
||||
echo "test@omarchy.org" >"$dir/user_email_address.txt"
|
||||
echo "false" >"$dir/user_encrypt_installation.txt"
|
||||
cp "$SSH_KEY.pub" "$dir/authorized_keys"
|
||||
|
||||
rm -f "$CIDATA_IMG"
|
||||
truncate -s 4M "$CIDATA_IMG"
|
||||
mkfs.vfat -n CIDATA "$CIDATA_IMG" >/dev/null
|
||||
mcopy -i "$CIDATA_IMG" "$dir"/* ::/
|
||||
}
|
||||
|
||||
# The dev/local ISO installs the -dev packages; a stable ISO the plain ones.
|
||||
detect_packages() {
|
||||
RUNTIME_PACKAGE=omarchy-dev
|
||||
SETTINGS_PACKAGE=omarchy-settings-dev
|
||||
if [[ $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then
|
||||
RUNTIME_PACKAGE=omarchy
|
||||
SETTINGS_PACKAGE=omarchy-settings
|
||||
fi
|
||||
}
|
||||
|
||||
install_phase() {
|
||||
log "Installing $(basename "$ISO") unattended via cidata (headless)"
|
||||
|
||||
[[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-integration" -f "$SSH_KEY"
|
||||
detect_packages
|
||||
build_cidata
|
||||
|
||||
# Build under a staging name: the finished base is promoted only after a
|
||||
# clean shutdown, so a failed install can never pass for a reusable base.
|
||||
rm -f "$BASE_DISK" "$BASE_DISK.building"
|
||||
qemu-img create -f qcow2 "$BASE_DISK.building" 40G >/dev/null
|
||||
cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF"
|
||||
ACTIVE_OVMF="$BASE_OVMF"
|
||||
|
||||
start_vm "$BASE_DISK.building" "$RUN_DIR/install-serial.log" \
|
||||
-drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \
|
||||
-device ide-cd,drive=cdrom0,bootindex=2 \
|
||||
-drive "file=$CIDATA_IMG,format=raw,if=none,id=cidata" \
|
||||
-device usb-storage,drive=cidata
|
||||
|
||||
log "Waiting for the unattended install to finish (timeout ${INSTALL_TIMEOUT}s)"
|
||||
local waited=0 text progress_name
|
||||
while true; do
|
||||
# An unattended install reboots on its own; SSH answering means the
|
||||
# installed system is up (cidata's authorized_keys enables sshd).
|
||||
if ssh_guest true 2>/dev/null; then
|
||||
log "Install finished and rebooted into the installed system."
|
||||
capture_console "success-install-first-boot"
|
||||
break
|
||||
fi
|
||||
|
||||
text=$(ocr_screen)
|
||||
|
||||
if grep -qi "Reboot Now" <<<"$text"; then
|
||||
log "Install finished. Confirming the reboot prompt."
|
||||
capture_console "success-install-reboot"
|
||||
press ret
|
||||
fi
|
||||
|
||||
if grep -qi "installation stopped" <<<"$text"; then
|
||||
capture_console "failure-install-stopped"
|
||||
echo "Install failed — screenshot saved to $RUN_DIR" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! vm_running; then
|
||||
echo "VM exited during install" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ((waited >= INSTALL_TIMEOUT)); then
|
||||
capture_console "failure-install-timeout"
|
||||
echo "Timed out after ${INSTALL_TIMEOUT}s waiting for install" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# A line every 30 s so a slow runner never looks hung; a screenshot of
|
||||
# the installer's dashboard every 2 min, which is enough to see progress.
|
||||
if ((waited % 120 == 0)); then
|
||||
printf -v progress_name 'success-install-progress-%04ds' "$waited"
|
||||
capture_console "$progress_name"
|
||||
fi
|
||||
((waited % 30 == 0)) && echo " ... installing (${waited}s)"
|
||||
|
||||
sleep 10
|
||||
((waited += 10))
|
||||
done
|
||||
|
||||
# Keep the installer's own clock, its log and the first boot's numbers next
|
||||
# to the base image while the system is reachable, so CI can report them
|
||||
# without attaching the disk.
|
||||
ssh_sudo "cat /var/log/omarchy-install-timing.json" >"$BASE_DIR/omarchy-install-timing.json" 2>/dev/null || true
|
||||
ssh_sudo "cat /var/log/omarchy-install.log" >"$BASE_DIR/omarchy-install.log" 2>/dev/null || true
|
||||
# SSH can answer before startup finishes, and systemd-analyze refuses until
|
||||
# it has; wait (bounded) so the first-boot numbers are final.
|
||||
ssh_guest "timeout 120 systemctl is-system-running --wait >/dev/null; systemd-analyze" \
|
||||
>"$BASE_DIR/first-boot-systemd-analyze.txt" 2>/dev/null || true
|
||||
|
||||
log "Installed system is up. Saving base image."
|
||||
stop_vm
|
||||
mv "$BASE_DISK.building" "$BASE_DISK"
|
||||
}
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Everyday boot: boots the installed system a second time and records how
|
||||
# long it takes to answer SSH and what systemd-analyze reports, then asserts
|
||||
# that systemd finished starting with no failed units. CI reports these next
|
||||
# to the install time, so a change that slows booting shows up in its PR.
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; }
|
||||
|
||||
log "Booting the installed system"
|
||||
boot_started=$(date +%s%N)
|
||||
start_vm_from_base
|
||||
if ! wait_for_ssh 300 failure-boot-ssh-timeout; then
|
||||
# The console screenshot is saved; the serial log shows what the firmware,
|
||||
# loader and kernel printed, which a screenshot of a hung boot often lacks.
|
||||
echo "--- last lines of the serial console ---" >&2
|
||||
tail -n 40 "$RUN_DIR/serial.log" >&2 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
boot_ms=$((($(date +%s%N) - boot_started) / 1000000))
|
||||
# Coarse: wait_for_ssh polls every 2 s. systemd-analyze below has the exact
|
||||
# firmware, loader, kernel and userspace split.
|
||||
log "SSH answered within ${boot_ms} ms of power-on"
|
||||
|
||||
# --wait returns once startup has finished, so systemd-analyze has final
|
||||
# numbers and a unit that fails late in boot is still counted. It exits
|
||||
# non-zero for "degraded"; keep the answer so the diagnostics below are still
|
||||
# collected and the assertion reports it.
|
||||
state=$(ssh_guest "timeout 120 systemctl is-system-running --wait" 2>/dev/null | tr -d '\r' || true)
|
||||
ssh_guest "systemd-analyze" >"$RUN_DIR/systemd-analyze.txt" 2>/dev/null || true
|
||||
ssh_guest "systemd-analyze blame --no-pager | head -n 20" >"$RUN_DIR/systemd-analyze-blame.txt" 2>/dev/null || true
|
||||
# A query that fails must not read as "no failed units".
|
||||
ssh_guest "systemctl --failed --no-legend --plain" >"$RUN_DIR/failed-units.txt" 2>/dev/null ||
|
||||
echo "could not query failed units over SSH" >"$RUN_DIR/failed-units.txt"
|
||||
printf '{"boot_to_ssh_ms": %d, "system_state": "%s"}\n' "$boot_ms" "$state" >"$RUN_DIR/boot-timing.json"
|
||||
|
||||
check "systemd reports the system running (got: ${state:-nothing})" test "$state" = running
|
||||
check "no failed units" test ! -s "$RUN_DIR/failed-units.txt"
|
||||
capture_console "success-boot"
|
||||
|
||||
stop_vm
|
||||
finish
|
||||
Executable
+287
@@ -0,0 +1,287 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Factory reset on a shared ESP: proves omarchy-system-factory-reset hands a
|
||||
# machine on without destroying a dual-boot setup (basecamp/omarchy#6847).
|
||||
# Dresses the installed ESP up as a dual-boot machine (Windows + a foreign
|
||||
# Linux, exactly what the reset must not destroy), runs a real factory reset,
|
||||
# and walks the first-boot setup that follows.
|
||||
#
|
||||
# Staging asserts the Windows and foreign Linux entries and their ESP payload
|
||||
# survive, the old Omarchy identity (entry + directory) is gone, and a fresh
|
||||
# Omarchy entry exists. First boot asserts provisioning completes, the machine
|
||||
# identity changed, and the foreign entries are still intact afterwards.
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
FOREIGN_ID="fedcfedcfedcfedcfedcfedcfedcfedc"
|
||||
|
||||
base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; }
|
||||
|
||||
# --------------------------------------------------------------- esp fixture
|
||||
|
||||
# Dress the ESP up as a shared dual-boot ESP: a Windows entry, and a second
|
||||
# Linux installation cloned from the real Omarchy entry so its shape is
|
||||
# exactly what limine-entry-tool writes, under a foreign machine-id with its
|
||||
# own boot directory and byte-identical UKIs (so the cloned entry hashes
|
||||
# stay valid).
|
||||
fixture_shared_esp() {
|
||||
OLD_ID=$(ssh_guest "cat /etc/machine-id" | tr -d '\r\n')
|
||||
[[ $OLD_ID =~ ^[0-9a-f]{32}$ ]] || { echo "unexpected machine-id: $OLD_ID" >&2; return 1; }
|
||||
|
||||
ssh_sudo "cp /boot/limine.conf /boot/limine.conf.pretest" >/dev/null
|
||||
ssh_sudo "
|
||||
set -euo pipefail
|
||||
awk '/^\//{block=1} block' /boot/limine.conf |
|
||||
sed -e 's/$OLD_ID/$FOREIGN_ID/g' \
|
||||
-e 's|/EFI/Linux/omarchy_|/EFI/Linux/foreign_|g' \
|
||||
-e 's|^/\(+\{0,1\}\)\([^/+]\)|/\1Foreign \2|' >/tmp/foreign-entries
|
||||
grep -q 'machine-id=$FOREIGN_ID' /tmp/foreign-entries
|
||||
for uki in /boot/EFI/Linux/omarchy_*.efi; do
|
||||
cp \"\$uki\" \"\${uki/omarchy_/foreign_}\"
|
||||
done
|
||||
cat >>/boot/limine.conf <<'CONF'
|
||||
|
||||
/Windows
|
||||
protocol: efi
|
||||
path: boot():/EFI/Microsoft/Boot/bootmgfw.efi
|
||||
CONF
|
||||
cat /tmp/foreign-entries >>/boot/limine.conf
|
||||
mkdir -p /boot/$FOREIGN_ID /boot/EFI/Microsoft/Boot
|
||||
echo foreign-payload >/boot/$FOREIGN_ID/marker
|
||||
echo windows-payload >/boot/EFI/Microsoft/Boot/bootmgfw.efi
|
||||
" >/dev/null
|
||||
|
||||
ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.fixtured"
|
||||
log "ESP fixtured as a shared dual-boot ESP (old machine-id $OLD_ID)"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- reset driver
|
||||
|
||||
# Drive the interactive reset through a pty on the guest: `script` supplies
|
||||
# the terminal gum needs, a fifo feeds it answers on our schedule, and the
|
||||
# typescript lands in /tmp/reset.out for the polling below.
|
||||
start_reset() {
|
||||
# script hands the pty over at 0x0 when its own stdin is not a terminal,
|
||||
# which crashes gum's text input; give it a real size first. setsid keeps
|
||||
# both processes alive after this ssh session hangs up.
|
||||
ssh_guest "
|
||||
rm -f /tmp/reset.in /tmp/reset.out
|
||||
mkfifo /tmp/reset.in
|
||||
setsid bash -c 'exec 3<>/tmp/reset.in && sleep 3600' >/dev/null 2>&1 &
|
||||
setsid script -qefc 'stty rows 35 cols 130; sudo -S -p \"\" omarchy-system-factory-reset' /tmp/reset.out \
|
||||
</tmp/reset.in >/dev/null 2>&1 &
|
||||
"
|
||||
sleep 2
|
||||
ssh_guest "printf '%s\n' '$GUEST_PASSWORD' >/tmp/reset.in"
|
||||
}
|
||||
|
||||
reset_output() {
|
||||
ssh_guest "sed 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\x1b\][^\x07]*\x07//g' /tmp/reset.out 2>/dev/null" || true
|
||||
}
|
||||
|
||||
wait_for_reset_output() {
|
||||
local text="$1" timeout="$2" waited=0
|
||||
|
||||
until reset_output | grep -qi "$text"; do
|
||||
if reset_output | grep -qE "^Error:|COMMAND_EXIT_CODE"; then
|
||||
reset_output >"$RUN_DIR/reset.out"
|
||||
echo "The factory reset failed before reaching: $text" >&2
|
||||
reset_output | grep -E "^Error:" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
if ((waited >= timeout)); then
|
||||
reset_output >"$RUN_DIR/reset.out"
|
||||
echo "Timed out after ${timeout}s waiting for reset output: $text" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 3
|
||||
((waited += 3))
|
||||
done
|
||||
}
|
||||
|
||||
reset_phase() {
|
||||
log "Booting reset VM from base image overlay"
|
||||
|
||||
start_vm_from_base
|
||||
wait_for_ssh "$BOOT_TIMEOUT"
|
||||
|
||||
fixture_shared_esp
|
||||
|
||||
log "Running omarchy-system-factory-reset"
|
||||
start_reset
|
||||
wait_for_reset_output "Type 'reset' to continue" 60
|
||||
capture_console "success-reset-confirm"
|
||||
ssh_guest "printf 'reset\r' >/tmp/reset.in"
|
||||
|
||||
# Staging clones @factory, rebuilds the UKI in a chroot, and reworks the
|
||||
# ESP; only then does the reboot prompt appear.
|
||||
wait_for_reset_output "Reboot to complete the reset" 900
|
||||
reset_output >"$RUN_DIR/reset.out"
|
||||
log "Reset staged. Asserting on the shared ESP before rebooting."
|
||||
|
||||
check "windows entry survives staging" \
|
||||
ssh_sudo "grep -q '^/Windows' /boot/limine.conf"
|
||||
check "windows boot payload survives staging" \
|
||||
ssh_sudo "grep -q windows-payload /boot/EFI/Microsoft/Boot/bootmgfw.efi"
|
||||
check "foreign linux entry survives staging" \
|
||||
ssh_sudo "grep -q 'machine-id=$FOREIGN_ID' /boot/limine.conf"
|
||||
check "foreign boot directory survives staging" \
|
||||
ssh_sudo "grep -q foreign-payload /boot/$FOREIGN_ID/marker"
|
||||
check "foreign UKI survives staging" \
|
||||
ssh_sudo "test -f /boot/EFI/Linux/foreign_linux.efi"
|
||||
check "old omarchy entry is removed by staging" \
|
||||
ssh_sudo "! grep -q 'machine-id=$OLD_ID' /boot/limine.conf"
|
||||
check "old omarchy boot directory is removed by staging" \
|
||||
ssh_sudo "! test -e /boot/$OLD_ID"
|
||||
check "a fresh omarchy entry exists after staging" \
|
||||
ssh_sudo "grep -o 'machine-id=[0-9a-f]\{32\}' /boot/limine.conf | grep -qv 'machine-id=$FOREIGN_ID'"
|
||||
|
||||
ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.staged" || true
|
||||
ssh_sudo "cat /var/log/omarchy-system-factory-reset.log" >"$RUN_DIR/factory-reset.log" || true
|
||||
capture_console "success-reset-staged"
|
||||
|
||||
if ((FAILURES > 0)); then
|
||||
log "Staging assertions failed; keeping the VM off the reboot path."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Decline the prompt's reboot; reboot deliberately so the ssh session
|
||||
# closing cannot race the answer.
|
||||
ssh_guest "printf 'n' >/tmp/reset.in"
|
||||
sleep 2
|
||||
log "Rebooting into the factory first boot"
|
||||
ssh_sudo "systemctl reboot" || true
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- first boot
|
||||
|
||||
# Answer whichever setup screens the factory first boot presents. The form's
|
||||
# exact steps depend on what the reset deferred, so dispatch on the screen
|
||||
# actually showing instead of scripting a fixed sequence.
|
||||
drive_first_boot() {
|
||||
log "Driving the factory first-boot setup"
|
||||
|
||||
# The reset must leave a bootable default: the machine has to reach the
|
||||
# first-boot greeter on its own. A numeric default_entry pointing at a
|
||||
# moved entry parks Limine at the menu instead — catch that explicitly,
|
||||
# then boot the Omarchy entry by hand so the rest of the flow still runs.
|
||||
if wait_for_screen "Linux by DHH" 120 2>/dev/null; then
|
||||
printf 'ok - %s\n' "the machine boots into first-boot setup unattended"
|
||||
else
|
||||
if ocr_screen | grep -qi "Omarchy Bootloader"; then
|
||||
printf 'not ok - %s\n' "the machine boots into first-boot setup unattended (parked at the Limine menu)"
|
||||
((FAILURES += 1))
|
||||
capture_console "failure-firstboot-parked-at-menu"
|
||||
log "Selecting the Omarchy entry manually to continue the run"
|
||||
press down; sleep 1; press down; sleep 1; press down; sleep 1
|
||||
press ret
|
||||
wait_for_screen "Linux by DHH" 600
|
||||
else
|
||||
echo "Neither the greeter nor the Limine menu appeared" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
capture_console "success-firstboot-00-greeter"
|
||||
press ret
|
||||
|
||||
local waited=0 text step
|
||||
local -A answered=()
|
||||
while true; do
|
||||
text=$(ocr_screen)
|
||||
step=""
|
||||
|
||||
if grep -qi "look right" <<<"$text"; then
|
||||
capture_console "success-firstboot-03-review"
|
||||
press ret
|
||||
break
|
||||
elif grep -qi "keyboard layout" <<<"$text"; then
|
||||
step="keyboard"; [[ -v answered[$step] ]] || { capture_console "success-firstboot-01-keyboard"; press ret; }
|
||||
elif grep -qi "Username" <<<"$text"; then
|
||||
step="username"; [[ -v answered[$step] ]] || { type_text "$GUEST_USER"; press ret; }
|
||||
elif grep -qi "Conf *irm" <<<"$text"; then
|
||||
step="confirm"; [[ -v answered[$step] ]] || { type_text "$GUEST_PASSWORD"; press ret; }
|
||||
elif grep -qi "Password" <<<"$text"; then
|
||||
step="password"; [[ -v answered[$step] ]] || { type_text "$GUEST_PASSWORD"; press ret; }
|
||||
elif grep -qi "Full name" <<<"$text"; then
|
||||
step="fullname"; [[ -v answered[$step] ]] || { type_text "Omarchy Test"; press ret; }
|
||||
elif grep -qi "Email address" <<<"$text"; then
|
||||
step="email"; [[ -v answered[$step] ]] || { type_text "test@omarchy.org"; capture_console "success-firstboot-02-form"; press ret; }
|
||||
elif grep -qi "Hostname" <<<"$text"; then
|
||||
step="hostname"; [[ -v answered[$step] ]] || { type_text "$GUEST_HOSTNAME"; press ret; }
|
||||
elif grep -qi "Timezone" <<<"$text"; then
|
||||
step="timezone"; [[ -v answered[$step] ]] || press ret
|
||||
fi
|
||||
[[ -n $step ]] && answered[$step]=1
|
||||
|
||||
if ! vm_running; then
|
||||
echo "VM exited during first-boot setup" >&2
|
||||
return 1
|
||||
fi
|
||||
if ((waited >= 600)); then
|
||||
capture_console "failure-firstboot-form-timeout"
|
||||
echo "Timed out driving the first-boot form" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 3
|
||||
((waited += 3))
|
||||
done
|
||||
|
||||
capture_console "success-firstboot-04-finalizing"
|
||||
}
|
||||
|
||||
first_boot_phase() {
|
||||
drive_first_boot
|
||||
|
||||
# Finalization can still be running when the console appears; the pending
|
||||
# flag clearing is the definitive end of provisioning. The reset scrubbed
|
||||
# users, host keys, and sshd state, so SSH has to be re-authorized first.
|
||||
bootstrap_ssh
|
||||
|
||||
log "Waiting for provisioning to finish"
|
||||
local waited=0 rc
|
||||
while true; do
|
||||
rc=0
|
||||
ssh_guest "test -f /var/lib/omarchy/provisioning/pending" 2>/dev/null || rc=$?
|
||||
(( rc == 0 || rc == 255 )) || break
|
||||
if ((waited >= 900)); then
|
||||
capture_console "failure-provisioning-timeout"
|
||||
echo "Timed out waiting for first-boot provisioning to complete" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 10
|
||||
((waited += 10))
|
||||
done
|
||||
|
||||
local new_id
|
||||
new_id=$(ssh_guest "cat /etc/machine-id" | tr -d '\r\n')
|
||||
|
||||
check "provisioning completed on first boot" \
|
||||
ssh_guest "! test -f /var/lib/omarchy/provisioning/pending"
|
||||
# A well-formed identity, and not one we already know: an empty or echoed
|
||||
# ID would make the boot-entry grep below match the wrong entry.
|
||||
check "the reset minted a fresh machine identity" \
|
||||
bash -c "[[ '$new_id' =~ ^[0-9a-f]{32}\$ && '$new_id' != '$OLD_ID' && '$new_id' != '$FOREIGN_ID' ]]"
|
||||
check "windows entry survives the first boot" \
|
||||
ssh_sudo "grep -q '^/Windows' /boot/limine.conf"
|
||||
check "windows boot payload survives the first boot" \
|
||||
ssh_sudo "grep -q windows-payload /boot/EFI/Microsoft/Boot/bootmgfw.efi"
|
||||
check "foreign linux entry survives the first boot" \
|
||||
ssh_sudo "grep -q 'machine-id=$FOREIGN_ID' /boot/limine.conf"
|
||||
check "foreign boot directory survives the first boot" \
|
||||
ssh_sudo "grep -q foreign-payload /boot/$FOREIGN_ID/marker"
|
||||
check "foreign UKI survives the first boot" \
|
||||
ssh_sudo "test -f /boot/EFI/Linux/foreign_linux.efi"
|
||||
check "old omarchy identity never returns" \
|
||||
ssh_sudo "! grep -q 'machine-id=$OLD_ID' /boot/limine.conf"
|
||||
check "the new identity owns a boot entry" \
|
||||
ssh_sudo "grep -q 'machine-id=$new_id' /boot/limine.conf"
|
||||
|
||||
ssh_sudo "cat /boot/limine.conf" >"$RUN_DIR/limine.conf.final" || true
|
||||
capture_console "success-final"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------- main
|
||||
|
||||
reset_phase
|
||||
first_boot_phase
|
||||
finish
|
||||
File renamed without changes.
@@ -7,7 +7,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
|
||||
|
||||
pass() {
|
||||
Executable
+458
@@ -0,0 +1,458 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Unit tests for omarchy-install-diagnose-media and the failure screen that
|
||||
# renders it. Every case builds a throwaway offline mirror -- a package file
|
||||
# and a real repo-add-shaped offline.db naming its sha256 -- so the three
|
||||
# verdicts are told apart by the same evidence the live ISO has.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
DIAGNOSE="$ROOT/configs/airootfs/usr/local/bin/omarchy-install-diagnose-media"
|
||||
DASHBOARD="$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard"
|
||||
PACKAGE="npth-1.8-1-x86_64.pkg.tar.zst"
|
||||
TARGET_CACHE="/mnt/var/cache/pacman/pkg"
|
||||
|
||||
pass() {
|
||||
printf 'ok - %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local description="$1"
|
||||
local detail="${2:-}"
|
||||
|
||||
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
|
||||
printf 'not ok - %s\n' "$description" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
mirror="$work/mirror"
|
||||
mkdir -p "$mirror"
|
||||
|
||||
# repo-add writes one desc record per package, %FILENAME% before %SHA256SUM%.
|
||||
# Building a real gzipped tar of that shape keeps the parser honest.
|
||||
write_mirror_db() {
|
||||
local checksum="$1" filename="${2:-$PACKAGE}" staging="$work/db"
|
||||
|
||||
rm -rf "$staging"
|
||||
mkdir -p "$staging/npth-1.8-1"
|
||||
{
|
||||
printf '%%FILENAME%%\n%s\n\n' "$filename"
|
||||
printf '%%NAME%%\nnpth\n\n'
|
||||
printf '%%VERSION%%\n1.8-1\n\n'
|
||||
printf '%%SHA256SUM%%\n%s\n\n' "$checksum"
|
||||
} >"$staging/npth-1.8-1/desc"
|
||||
|
||||
# A second package proves the lookup matches on name rather than on order.
|
||||
mkdir -p "$staging/zlib-1.3-1"
|
||||
{
|
||||
printf '%%FILENAME%%\nzlib-1.3-1-x86_64.pkg.tar.zst\n\n'
|
||||
printf '%%NAME%%\nzlib\n\n'
|
||||
printf '%%SHA256SUM%%\n%s\n\n' "$(printf 'f%.0s' {1..64})"
|
||||
} >"$staging/zlib-1.3-1/desc"
|
||||
|
||||
tar -czf "$mirror/offline.db" -C "$staging" npth-1.8-1 zlib-1.3-1
|
||||
}
|
||||
|
||||
write_log() {
|
||||
local log="$1" path="${2:-$TARGET_CACHE/$PACKAGE}"
|
||||
|
||||
{
|
||||
echo "[dashboard] checking package integrity..."
|
||||
echo "[dashboard] :: File $path is corrupted (invalid or corrupted package)."
|
||||
echo "[dashboard] error: failed to commit transaction (invalid or corrupted package)"
|
||||
echo "[dashboard] ==> ERROR: Failed to install packages to new root"
|
||||
} >"$log"
|
||||
}
|
||||
|
||||
diagnose() {
|
||||
OMARCHY_OFFLINE_MIRROR="$mirror" OMARCHY_TARGET_PACKAGE_CACHE="$TARGET_CACHE" \
|
||||
"$DIAGNOSE" "$@"
|
||||
}
|
||||
|
||||
log="$work/install.log"
|
||||
write_log "$log"
|
||||
|
||||
# The medium was written wrong: the package on the ISO no longer hashes to what
|
||||
# the build recorded for it.
|
||||
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
|
||||
write_mirror_db "$(printf '0%.0s' {1..64})"
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$log")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
(( status == 0 )) || fail "a checksum mismatch is diagnosed" "exit status $status"
|
||||
[[ $(head -n 1 <<<"$output") == "The install medium is damaged" ]] ||
|
||||
fail "a checksum mismatch is diagnosed" "$output"
|
||||
grep -qF "$PACKAGE" <<<"$output" ||
|
||||
fail "the diagnosis names the package" "$output"
|
||||
grep -qF "no longer matches the checksum" <<<"$output" ||
|
||||
fail "the diagnosis says the checksum disagrees" "$output"
|
||||
grep -qF "sha256sum" <<<"$output" ||
|
||||
fail "the diagnosis says how to verify a fresh download" "$output"
|
||||
pass "a checksum mismatch says the medium is damaged and names the package"
|
||||
|
||||
# The medium reads clean now, so the bytes were misread rather than written
|
||||
# wrong. Re-downloading fixes nothing, and the advice has to say so.
|
||||
write_mirror_db "$(sha256sum "$mirror/$PACKAGE" | cut -d " " -f 1)"
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$log")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
(( status == 0 )) || fail "an intact package is still diagnosed" "exit status $status"
|
||||
[[ $(head -n 1 <<<"$output") == "The install medium was misread" ]] ||
|
||||
fail "an intact package is diagnosed as a misread" "$output"
|
||||
grep -qF "another USB port" <<<"$output" ||
|
||||
fail "a misread points at the hardware" "$output"
|
||||
if grep -qF "Re-download" <<<"$output"; then
|
||||
fail "a misread does not send anyone to re-download" "$output"
|
||||
fi
|
||||
pass "an intact package on the medium is diagnosed as a misread, not a bad download"
|
||||
|
||||
# The usual case. pacstrap runs with --noconfirm, which answers yes to pacman's
|
||||
# "delete it?" and unlinks the package through the bind mount, so by the time
|
||||
# anything here looks there is nothing left to weigh. The verdict must name the
|
||||
# medium without claiming a comparison it never made, and must offer both
|
||||
# remedies: a bad write and a bad read leave identical evidence.
|
||||
rm "$mirror/$PACKAGE"
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$log")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
(( status == 0 )) || fail "a deleted package is still diagnosed" "exit status $status"
|
||||
[[ $(head -n 1 <<<"$output") == "The install medium is damaged or misread" ]] ||
|
||||
fail "a deleted package is diagnosed without over-claiming" "$output"
|
||||
grep -qF "read straight off this ISO" <<<"$output" ||
|
||||
fail "a deleted package still names the medium" "$output"
|
||||
grep -qF "sha256sum" <<<"$output" ||
|
||||
fail "a deleted package offers the download remedy" "$output"
|
||||
grep -qF "memory is at fault" <<<"$output" ||
|
||||
fail "a deleted package offers the hardware remedy too" "$output"
|
||||
if grep -qF "no longer matches the checksum" <<<"$output"; then
|
||||
fail "a deleted package claims no comparison" "$output"
|
||||
fi
|
||||
pass "a deleted package names the medium without claiming which way it failed"
|
||||
|
||||
# A record with no %SHA256SUM% must not borrow the next record's, which would
|
||||
# compare a real file against another package's checksum.
|
||||
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
|
||||
staging="$work/db"
|
||||
rm -rf "$staging"
|
||||
mkdir -p "$staging/npth-1.8-1" "$staging/zlib-1.3-1"
|
||||
printf '%%FILENAME%%\n%s\n\n%%NAME%%\nnpth\n\n' "$PACKAGE" >"$staging/npth-1.8-1/desc"
|
||||
{
|
||||
printf '%%FILENAME%%\nzlib-1.3-1-x86_64.pkg.tar.zst\n\n'
|
||||
printf '%%SHA256SUM%%\n%s\n\n' "$(sha256sum "$mirror/$PACKAGE" | cut -d " " -f 1)"
|
||||
} >"$staging/zlib-1.3-1/desc"
|
||||
tar -czf "$mirror/offline.db" -C "$staging" npth-1.8-1 zlib-1.3-1
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$log")
|
||||
set -e
|
||||
|
||||
[[ $(head -n 1 <<<"$output") == "The install medium is damaged or misread" ]] ||
|
||||
fail "a record without a checksum borrows nobody else's" "$output"
|
||||
pass "a record without a checksum borrows nobody else's"
|
||||
|
||||
# A package version may legally hold a backslash. It must not reach a terminal
|
||||
# as an escape sequence, and awk must look it up literally rather than as one.
|
||||
escaped='demo-1\033[2J-1-x86_64.pkg.tar.zst'
|
||||
write_log "$work/escaped.log" "$TARGET_CACHE/$escaped"
|
||||
printf 'a package, but damaged\n' >"$mirror/$escaped"
|
||||
write_mirror_db "$(printf '0%.0s' {1..64})" "$escaped"
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$work/escaped.log")
|
||||
set -e
|
||||
|
||||
# The dashboard renders these lines through printf %b, which turns a surviving
|
||||
# backslash into whatever escape it spells. None may reach it.
|
||||
if grep -qF '\' <<<"$output"; then
|
||||
fail "a backslash in a package name never reaches the renderer" "$(cat -v <<<"$output")"
|
||||
fi
|
||||
grep -qF "demo-1033" <<<"$output" ||
|
||||
fail "the sanitized name still identifies the package" "$output"
|
||||
# The database record is found despite the backslash, so the verdict is the
|
||||
# mismatch rather than the one that means no comparison could be made.
|
||||
[[ $(head -n 1 <<<"$output") == "The install medium is damaged" ]] ||
|
||||
fail "a backslash in a package name is looked up literally" "$output"
|
||||
pass "a backslash in a package name is looked up literally and never rendered as an escape"
|
||||
rm "$mirror/$escaped"
|
||||
|
||||
# The live ISO has bsdtar through pacman's libarchive, but the tar fallback is
|
||||
# the one that runs if that ever stops being true.
|
||||
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
|
||||
write_mirror_db "$(sha256sum "$mirror/$PACKAGE" | cut -d " " -f 1)"
|
||||
no_bsdtar="$work/no-bsdtar"
|
||||
mkdir -p "$no_bsdtar"
|
||||
for tool in sed awk tar gzip sha256sum cat grep head tail cut printf; do
|
||||
command -v "$tool" >/dev/null && ln -sf "$(command -v "$tool")" "$no_bsdtar/$tool"
|
||||
done
|
||||
|
||||
set +e
|
||||
output=$(PATH="$no_bsdtar" diagnose "$log")
|
||||
set -e
|
||||
|
||||
[[ $(head -n 1 <<<"$output") == "The install medium was misread" ]] ||
|
||||
fail "the tar fallback reads the database when bsdtar is absent" "$output"
|
||||
pass "the tar fallback reads the database when bsdtar is absent"
|
||||
|
||||
printf 'a package, but damaged\n' >"$mirror/$PACKAGE"
|
||||
write_mirror_db "$(printf '0%.0s' {1..64})"
|
||||
|
||||
# A corrupt package that was never read off this ISO is somebody else's
|
||||
# problem. Guessing here sends people to buy a USB stick they did not need.
|
||||
write_log "$work/elsewhere.log" "/var/tmp/build/$PACKAGE"
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$work/elsewhere.log")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
(( status == 1 )) || fail "corruption outside the target cache is not diagnosed" "exit $status: $output"
|
||||
[[ -z $output ]] || fail "corruption outside the target cache prints nothing" "$output"
|
||||
pass "corruption outside the target cache is not blamed on the medium"
|
||||
|
||||
# An ordinary failure has no media diagnosis, and the caller asks
|
||||
# unconditionally, so silence has to be the answer.
|
||||
printf 'error: something else went wrong entirely\n' >"$work/other.log"
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$work/other.log")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
(( status == 1 )) || fail "an unrelated failure is not diagnosed" "exit $status: $output"
|
||||
[[ -z $output ]] || fail "an unrelated failure prints nothing" "$output"
|
||||
pass "an unrelated failure produces no diagnosis"
|
||||
|
||||
set +e
|
||||
output=$(diagnose "$work/does-not-exist.log")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
(( status == 1 )) || fail "a missing log is not diagnosed" "exit $status: $output"
|
||||
pass "a missing log produces no diagnosis"
|
||||
|
||||
# End to end: the dashboard runs a failing installer and the diagnosis has to
|
||||
# reach both the screen and the support log, above the tail it explains.
|
||||
#
|
||||
# The screen goes through a real pty rather than a file. The dashboard's exit
|
||||
# handler reopens its TTY with a truncating redirect, which does nothing to a
|
||||
# character device and empties a regular file, so a file would capture nothing.
|
||||
stubs="$work/stubs"
|
||||
mkdir -p "$stubs"
|
||||
{
|
||||
echo '#!/bin/bash'
|
||||
echo "OMARCHY_OFFLINE_MIRROR='$mirror' OMARCHY_TARGET_PACKAGE_CACHE='$TARGET_CACHE' exec '$DIAGNOSE' \"\$@\""
|
||||
} >"$stubs/omarchy-install-diagnose-media"
|
||||
chmod +x "$stubs/omarchy-install-diagnose-media"
|
||||
|
||||
state_file="$work/state.json"
|
||||
screen="$work/screen"
|
||||
|
||||
# A ten-line logo, the height of the real one. Without it the dashboard falls
|
||||
# back to a single centred word and the screen can never be tall enough to
|
||||
# overflow, which is exactly the case worth testing.
|
||||
omarchy_share="$work/share"
|
||||
mkdir -p "$omarchy_share"
|
||||
# 81 columns wide, like the real logo: the dashboard takes its content width
|
||||
# from the logo, and a narrow one would truncate every line under test.
|
||||
{
|
||||
printf 'LOGO TOP ROW%*s\n' 69 ''
|
||||
for n in 2 3 4 5 6 7 8 9; do printf 'logo row %s%*s\n' "$n" 71 ''; done
|
||||
printf 'logo bottom row%*s\n' 66 ''
|
||||
} >"$omarchy_share/logo.txt"
|
||||
|
||||
# The ISO vendors the runtime's setup form; the dashboard only needs its text lookup.
|
||||
cat >"$work/setup-form.sh" <<'FORM'
|
||||
declare -gA OMARCHY_ZH=()
|
||||
omarchy_text() { local text=$1; shift; printf -- "$text" "$@"; }
|
||||
FORM
|
||||
|
||||
run_dashboard() {
|
||||
local install_log="$1"
|
||||
|
||||
# A real failure carries the failed phase as well as the current one, and
|
||||
# that second summary line is another row the screen has to find space for.
|
||||
cat >"$state_file" <<'STATE'
|
||||
{"current_phase": "Installing Arch + Omarchy",
|
||||
"phases": [{"name": "Installing Arch + Omarchy", "status": "failed",
|
||||
"error": "Pacstrap failed. See /var/log/archinstall.log"}]}
|
||||
STATE
|
||||
: >"$screen"
|
||||
script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' SETUP_FORM='$work/setup-form.sh' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \
|
||||
"$screen" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
visible_screen() {
|
||||
# Carriage returns as well as escapes: the pty ends every line with one, and
|
||||
# a row that looks blank still carries it.
|
||||
sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g; s/\r//g' "$screen"
|
||||
}
|
||||
|
||||
dashboard_log="$work/dashboard.log"
|
||||
write_log "$dashboard_log"
|
||||
|
||||
# A real install log is longer than the tail can show, so the tail actually
|
||||
# spends its whole budget. A short one hides an over-tall screen.
|
||||
{
|
||||
for n in $(seq 1 20); do printf '[dashboard] installing package %s\n' "$n"; done
|
||||
cat "$dashboard_log"
|
||||
} >"$dashboard_log.padded"
|
||||
mv "$dashboard_log.padded" "$dashboard_log"
|
||||
|
||||
# State the case rather than inheriting whichever verdict the previous one left
|
||||
# behind: this is the shape a real install fails in, with pacman having deleted
|
||||
# the package it rejected.
|
||||
rm -f "$mirror/$PACKAGE"
|
||||
write_mirror_db "$(printf '0%.0s' {1..64})"
|
||||
|
||||
set +e
|
||||
run_dashboard "$dashboard_log"
|
||||
dashboard_status=$?
|
||||
set -e
|
||||
|
||||
(( dashboard_status == 1 )) ||
|
||||
fail "the dashboard still exits with the installer's status" "exit $dashboard_status"
|
||||
visible_screen | grep -qF "The install medium is damaged" ||
|
||||
fail "the failure screen shows the diagnosis" "$(visible_screen | tail -n 25)"
|
||||
visible_screen | grep -qF "$PACKAGE" ||
|
||||
fail "the failure screen names the package" "$(visible_screen | tail -n 25)"
|
||||
grep -qF "[dashboard] The install medium is damaged" "$dashboard_log" ||
|
||||
fail "the diagnosis is written to the support log" "$(tail -n 20 "$dashboard_log")"
|
||||
|
||||
# Once on the screen. The log tail rendered underneath reads the same file the
|
||||
# diagnosis is recorded in, and the same finding printed twice reads as two.
|
||||
headline_count=$(visible_screen | grep -cF "The install medium is damaged or misread" || true)
|
||||
(( headline_count == 1 )) ||
|
||||
fail "the diagnosis appears once on the screen" "found $headline_count times"
|
||||
|
||||
# The renderer used to drop blank lines, so the break has to be checked where
|
||||
# it matters: on the screen, not only in the text the helper produced.
|
||||
package_row=$(visible_screen | grep -nF "$PACKAGE" | grep -v "dashboard" | head -n 1 | cut -d: -f1)
|
||||
[[ -n $package_row ]] ||
|
||||
fail "the screen names the package on its own row" "$(visible_screen | tail -n 25)"
|
||||
next_row=$(visible_screen | sed -n "$((package_row + 1))p")
|
||||
[[ -z ${next_row// /} ]] ||
|
||||
fail "the screen breaks after the package" "next row was: $next_row"
|
||||
pass "the dashboard renders the diagnosis once, with the break, and logs it"
|
||||
|
||||
# The diagnosis costs rows the log tail's budget knew nothing about, so a
|
||||
# screen carrying one scrolled its own logo off the top of the console. The
|
||||
# tail gives those rows back now, which is visible as a shorter tail: count the
|
||||
# filler lines it reaches, with a diagnosis and without one.
|
||||
#
|
||||
# Asserting the logo is still on screen would be the direct test, and it cannot
|
||||
# be written here: this reads the output stream, which keeps every row ever
|
||||
# emitted whether or not it stayed visible. Only a terminal emulator could tell
|
||||
# the difference, so the mechanism is what gets checked.
|
||||
tail_filler_rows() {
|
||||
visible_screen | grep -c "installing package" || true
|
||||
}
|
||||
|
||||
with_diagnosis=$(tail_filler_rows)
|
||||
|
||||
# The same log line for line, differing only in the one path that decides
|
||||
# whether there is a diagnosis at all. Two differently shaped logs would show
|
||||
# different amounts of filler for reasons that have nothing to do with the
|
||||
# budget, and the comparison would prove nothing.
|
||||
elsewhere_log="$work/elsewhere-padded.log"
|
||||
write_log "$elsewhere_log" "/var/tmp/build/$PACKAGE"
|
||||
{
|
||||
for n in $(seq 1 20); do printf '[dashboard] installing package %s\n' "$n"; done
|
||||
cat "$elsewhere_log"
|
||||
} >"$elsewhere_log.padded"
|
||||
mv "$elsewhere_log.padded" "$elsewhere_log"
|
||||
|
||||
set +e
|
||||
run_dashboard "$elsewhere_log"
|
||||
set -e
|
||||
without_diagnosis=$(tail_filler_rows)
|
||||
|
||||
(( with_diagnosis < without_diagnosis )) ||
|
||||
fail "the tail gives up rows to the diagnosis" \
|
||||
"with: $with_diagnosis, without: $without_diagnosis"
|
||||
pass "the log tail gives up rows to the diagnosis so the screen still fits"
|
||||
|
||||
# The screen is only as wide as the logo and the dashboard truncates to it with
|
||||
# an ellipsis. Every sentence has to survive that, whatever the package is
|
||||
# called, or the advice arrives with its second half cut off.
|
||||
long_package="linux-firmware-nvidia-tegra-20250917.0e800e46-1-any.pkg.tar.zst"
|
||||
long_log="$work/long.log"
|
||||
write_log "$long_log" "$TARGET_CACHE/$long_package"
|
||||
|
||||
check_widths() {
|
||||
local label="$1" line
|
||||
while IFS= read -r line; do
|
||||
(( ${#line} <= 81 )) ||
|
||||
fail "no diagnosis line outruns the screen ($label)" "${#line} chars: $line"
|
||||
done < <(diagnose "$long_log")
|
||||
}
|
||||
|
||||
# The package line and the remedy that follows it are two different thoughts,
|
||||
# so a blank line separates them in every verdict.
|
||||
check_break() {
|
||||
local label="$1" package="$2" out
|
||||
out=$(diagnose "$long_log")
|
||||
local -a lines
|
||||
mapfile -t lines <<<"$out"
|
||||
|
||||
local i
|
||||
for i in "${!lines[@]}"; do
|
||||
[[ ${lines[i]} == "$package" ]] || continue
|
||||
[[ -z ${lines[i + 1]-x} ]] ||
|
||||
fail "a break follows the package ($label)" "$out"
|
||||
[[ -n ${lines[i + 2]-} ]] ||
|
||||
fail "the remedy follows the break ($label)" "$out"
|
||||
return 0
|
||||
done
|
||||
fail "the package is on a line of its own ($label)" "$out"
|
||||
}
|
||||
|
||||
# All three verdicts, since each writes its own sentences.
|
||||
rm -f "$mirror/$long_package"
|
||||
check_widths "deleted"
|
||||
check_break "deleted" "$long_package"
|
||||
printf 'these are not the bytes that were built\n' >"$mirror/$long_package"
|
||||
write_mirror_db "$(printf '0%.0s' {1..64})" "$long_package"
|
||||
check_widths "damaged"
|
||||
check_break "damaged" "$long_package"
|
||||
write_mirror_db "$(sha256sum "$mirror/$long_package" | cut -d " " -f 1)" "$long_package"
|
||||
check_widths "misread"
|
||||
check_break "misread" "$long_package"
|
||||
rm -f "$mirror/$long_package"
|
||||
pass "no diagnosis line outruns the screen, even with a long package name"
|
||||
pass "a break separates the package from the remedy in every verdict"
|
||||
|
||||
# A failure with no media explanation must leave the screen as it was, rather
|
||||
# than gaining a banner or an empty gap where the diagnosis would go.
|
||||
printf 'error: something else went wrong entirely\n' >"$work/plain.log"
|
||||
|
||||
set +e
|
||||
run_dashboard "$work/plain.log"
|
||||
set -e
|
||||
|
||||
if visible_screen | grep -qF "install medium"; then
|
||||
fail "an unrelated failure gets no media banner" "$(visible_screen | tail -n 25)"
|
||||
fi
|
||||
visible_screen | grep -qF "Omarchy installation stopped" ||
|
||||
fail "an unrelated failure still renders the normal failure screen" "$(visible_screen | tail -n 25)"
|
||||
pass "an unrelated failure renders the failure screen unchanged"
|
||||
|
||||
# The failure menu redraws the screen after uploading or viewing the log, and a
|
||||
# redraw that drops the diagnosis loses it exactly when someone went looking.
|
||||
# Those paths need gum and a person at the keyboard, so they are checked here.
|
||||
missing_redraw=$(grep -n 'render_failure "' "$DASHBOARD" | grep -v 'failure_media_diagnosis' || true)
|
||||
[[ -z $missing_redraw ]] ||
|
||||
fail "every failure-screen redraw carries the diagnosis" "$missing_redraw"
|
||||
pass "every failure-screen redraw carries the diagnosis"
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Every installer text needs an OMARCHY_ZH entry, or a Chinese install shows it in English.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
SETUP_FORM="${OMARCHY_SOURCE:-$ROOT/../omarchy}/install/provisioning/setup-form.sh"
|
||||
|
||||
fail() {
|
||||
printf 'not ok - %s\n' "$1"
|
||||
[[ -z ${2:-} ]] || printf '%s\n' "$2" | sed 's/^/# /'
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ ! -r $SETUP_FORM ]]; then
|
||||
printf 'ok - # skip the setup form is not checked out next to this repo (set OMARCHY_SOURCE)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
source "$SETUP_FORM"
|
||||
for file in configs/airootfs/root/configurator configs/airootfs/usr/local/bin/omarchy-install-dashboard; do
|
||||
eval "$(sed -n '/^OMARCHY_ZH+=(/,/^)/p' "$ROOT/$file")"
|
||||
done
|
||||
|
||||
mapfile -t texts < <(
|
||||
grep -ho 'omarchy_text "[^"$]*"' "$ROOT/configs/airootfs/root/configurator" "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard" |
|
||||
sed 's/^omarchy_text "//; s/"$//' | sort -u
|
||||
)
|
||||
((${#texts[@]} > 50)) || fail "the installer routes its text through omarchy_text" "found ${#texts[@]}"
|
||||
for text in "${texts[@]}"; do
|
||||
[[ -n ${OMARCHY_ZH[$text]:-} ]] || fail "every installer text has a Chinese entry" "missing: $text"
|
||||
done
|
||||
printf 'ok - every installer and dashboard text has a Chinese entry\n'
|
||||
|
||||
for action in "View full log" "Drop to shell" "Reboot" "Power off" "Upload log for support"; do
|
||||
[[ -n ${OMARCHY_ZH[$action]:-} ]] || fail "labels passed through variables have Chinese entries" "missing: $action"
|
||||
done
|
||||
printf 'ok - labels passed through variables have Chinese entries\n'
|
||||
|
||||
mapfile -t diagnosis < <(grep -x ' *echo "[^"$]*"' "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-diagnose-media" | sed 's/^ *echo "//; s/"$//')
|
||||
((${#diagnosis[@]} >= 10)) || fail "the media diagnosis sentences are found" "found ${#diagnosis[@]}"
|
||||
for line in "${diagnosis[@]}"; do
|
||||
[[ -n ${OMARCHY_ZH[$line]:-} ]] || fail "every media diagnosis sentence has a Chinese entry" "missing: $line"
|
||||
done
|
||||
printf 'ok - every media diagnosis sentence has a Chinese entry\n'
|
||||
|
||||
[[ $(OMARCHY_INSTALL_LANG=zh omarchy_text "Confirm overwriting %s" /dev/vda) == "确认覆盖 /dev/vda" ]] ||
|
||||
fail "Chinese text keeps its printf arguments"
|
||||
[[ $(omarchy_text "Confirm overwriting %s" /dev/vda) == "Confirm overwriting /dev/vda" ]] ||
|
||||
fail "English stays the default"
|
||||
printf 'ok - omarchy_text formats Chinese and English alike\n'
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Unit tests for the checksum sidecar omarchy-iso-release writes and
|
||||
# omarchy-iso-upload ships. Both scripts derive everything from their own
|
||||
# location and their argument, so each case runs against a throwaway sandbox
|
||||
# with the real script copied in and rclone/sign/upload stubbed out.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
|
||||
pass() {
|
||||
printf 'ok - %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local description="$1"
|
||||
local detail="${2:-}"
|
||||
|
||||
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
|
||||
printf 'not ok - %s\n' "$description" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# omarchy-iso-release resolves its release directory from the script's own
|
||||
# path, so relocating the script is what points it at the sandbox.
|
||||
sandbox="$work/repo"
|
||||
mkdir -p "$sandbox/bin" "$sandbox/release" "$work/stubs"
|
||||
cp "$ROOT/bin/omarchy-iso-release" "$sandbox/bin/"
|
||||
printf 'not really an iso\n' >"$sandbox/release/omarchy-2099.01.01-x86_64-quattro.iso"
|
||||
|
||||
cat >"$work/stubs/omarchy-iso-sign" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'signature\n' >"$1.sig"
|
||||
STUB
|
||||
|
||||
cat >"$work/stubs/omarchy-iso-upload" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'upload %s\n' "$*" >>"$TEST_LOG"
|
||||
STUB
|
||||
|
||||
# Log one argument per field rather than "$*": the point of several cases below
|
||||
# is that a path survives as a single argument, which "$*" cannot show.
|
||||
cat >"$work/stubs/rclone" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'rclone'
|
||||
printf '|%s' "$@"
|
||||
printf '\n'
|
||||
[[ -n ${RCLONE_FAIL_ON:-} && $* == *"$RCLONE_FAIL_ON"* ]] && exit 1
|
||||
exit 0
|
||||
STUB
|
||||
|
||||
chmod +x "$work/stubs"/*
|
||||
|
||||
export TEST_LOG="$work/log"
|
||||
: >"$TEST_LOG"
|
||||
|
||||
run_upload() {
|
||||
PATH="$work/stubs:$PATH" "$ROOT/bin/omarchy-iso-upload" "$@" >"$work/rclone-log" 2>"$work/upload-err"
|
||||
}
|
||||
|
||||
PATH="$work/stubs:$PATH" "$sandbox/bin/omarchy-iso-release" --no-make 9.9.9 >/dev/null
|
||||
|
||||
release_iso="$sandbox/release/omarchy-9.9.9.iso"
|
||||
checksum_file="$release_iso.sha256"
|
||||
|
||||
[[ -f $checksum_file ]] ||
|
||||
fail "release writes a checksum beside the ISO" "missing: $checksum_file"
|
||||
pass "release writes a checksum beside the ISO"
|
||||
|
||||
# The name in the file has to be the ISO alone. A build path in there verifies
|
||||
# only on the release machine, which is the one machine that never needs it.
|
||||
expected="$(sha256sum "$release_iso" | cut -d " " -f 1) omarchy-9.9.9.iso"
|
||||
actual="$(cat "$checksum_file")"
|
||||
[[ $actual == "$expected" ]] ||
|
||||
fail "the checksum names the ISO alone" "expected: $expected"$'\n'"actual: $actual"
|
||||
pass "the checksum names the ISO alone"
|
||||
|
||||
(cd "$sandbox/release" && sha256sum -c --status omarchy-9.9.9.iso.sha256) ||
|
||||
fail "sha256sum -c verifies the ISO from its own directory"
|
||||
pass "sha256sum -c verifies the ISO from its own directory"
|
||||
|
||||
# The whole point of the sidecar is catching bytes that changed after release,
|
||||
# so prove it fails on bytes that changed after release.
|
||||
printf 'corrupted\n' >>"$release_iso"
|
||||
if (cd "$sandbox/release" && sha256sum -c --status omarchy-9.9.9.iso.sha256 2>/dev/null); then
|
||||
fail "sha256sum -c rejects a corrupted ISO"
|
||||
fi
|
||||
pass "sha256sum -c rejects a corrupted ISO"
|
||||
|
||||
# An unreadable ISO must stop the release rather than publish an empty digest
|
||||
# beside a stale sidecar from the previous one.
|
||||
stale="$sandbox/release/omarchy-8.8.8.iso.sha256"
|
||||
printf 'deadbeef omarchy-8.8.8.iso\n' >"$stale"
|
||||
rm "$sandbox/release/omarchy-2099.01.01-x86_64-quattro.iso"
|
||||
printf 'not really an iso\n' >"$sandbox/release/omarchy-2099.01.02-x86_64-quattro.iso"
|
||||
cp "$sandbox/release/omarchy-2099.01.02-x86_64-quattro.iso" "$sandbox/release/omarchy-8.8.8.iso"
|
||||
chmod 000 "$sandbox/release/omarchy-8.8.8.iso"
|
||||
|
||||
set +e
|
||||
PATH="$work/stubs:$PATH" "$sandbox/bin/omarchy-iso-release" --no-make 8.8.8 >/dev/null 2>&1
|
||||
release_status=$?
|
||||
set -e
|
||||
chmod 644 "$sandbox/release/omarchy-8.8.8.iso"
|
||||
|
||||
(( release_status != 0 )) ||
|
||||
fail "release stops when the ISO cannot be checksummed" "exit status was 0"
|
||||
[[ "$(cat "$stale")" == "deadbeef omarchy-8.8.8.iso" ]] ||
|
||||
fail "a failed checksum leaves no half-written sidecar" "$(cat "$stale")"
|
||||
pass "release stops when the ISO cannot be checksummed"
|
||||
|
||||
# Uploading: both sidecars go up with the ISO, and a missing one is skipped
|
||||
# rather than handed to rclone as a path that does not exist.
|
||||
export HOME="$work/home"
|
||||
mkdir -p "$HOME/.config/rclone"
|
||||
: >"$HOME/.config/rclone/rclone.conf"
|
||||
|
||||
# A space in the path is the case that tells a quoted argument from an unquoted
|
||||
# one, so every upload case runs from a directory that has one.
|
||||
mkdir -p "$work/release builds"
|
||||
upload_iso="$work/release builds/omarchy-9.9.9.iso"
|
||||
printf 'not really an iso\n' >"$upload_iso"
|
||||
printf 'signature\n' >"$upload_iso.sig"
|
||||
printf 'checksum\n' >"$upload_iso.sha256"
|
||||
|
||||
run_upload "$upload_iso"
|
||||
|
||||
for suffix in "" ".sig" ".sha256"; do
|
||||
grep -qxF "rclone|copy|$upload_iso$suffix|Omarchy:omarchy/|-P" "$work/rclone-log" ||
|
||||
fail "upload ships the ISO, its signature and its checksum, path spaces intact" \
|
||||
"$(cat "$work/rclone-log")"
|
||||
done
|
||||
pass "upload ships the ISO, its signature and its checksum, path spaces intact"
|
||||
|
||||
rm "$upload_iso.sig"
|
||||
run_upload "$upload_iso"
|
||||
|
||||
if grep -qF ".sig" "$work/rclone-log"; then
|
||||
fail "upload skips a sidecar that is not there" "$(cat "$work/rclone-log")"
|
||||
fi
|
||||
grep -qF "$upload_iso.sha256" "$work/rclone-log" ||
|
||||
fail "upload still ships the checksum without a signature" "$(cat "$work/rclone-log")"
|
||||
pass "upload skips a sidecar that is not there"
|
||||
|
||||
# A failed copy has to fail the upload even when a later copy succeeds, or a
|
||||
# release reports success having published an ISO nobody can download.
|
||||
printf 'signature\n' >"$upload_iso.sig"
|
||||
set +e
|
||||
RCLONE_FAIL_ON=".sig" run_upload "$upload_iso"
|
||||
upload_status=$?
|
||||
set -e
|
||||
(( upload_status != 0 )) ||
|
||||
fail "a failed sidecar copy fails the upload" "$(cat "$work/rclone-log")"
|
||||
grep -qF "$upload_iso.sha256" "$work/rclone-log" ||
|
||||
fail "a failed sidecar copy still attempts the rest" "$(cat "$work/rclone-log")"
|
||||
pass "a failed sidecar copy fails the upload without skipping the rest"
|
||||
|
||||
set +e
|
||||
RCLONE_FAIL_ON="omarchy-9.9.9.iso" run_upload "$upload_iso"
|
||||
upload_status=$?
|
||||
set -e
|
||||
(( upload_status != 0 )) ||
|
||||
fail "a failed ISO copy fails the upload" "$(cat "$work/rclone-log")"
|
||||
pass "a failed ISO copy fails the upload"
|
||||
|
||||
# Publishing an ISO nobody can verify is the thing this change exists to stop,
|
||||
# so the uploader says so out loud rather than exiting quietly.
|
||||
rm "$upload_iso.sha256"
|
||||
run_upload "$upload_iso"
|
||||
grep -qF "nobody can verify" "$work/upload-err" ||
|
||||
fail "upload warns when there is no checksum to publish" "$(cat "$work/upload-err")"
|
||||
pass "upload warns when there is no checksum to publish"
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/install/provisioning"
|
||||
cat > "$work/install/provisioning/setup-form.sh" <<'FORM'
|
||||
OMARCHY_KEYBOARD_LAYOUTS=$'Chinese (Simplified, Pinyin)|us|pinyin
|
||||
English (US)|us
|
||||
Japanese|jp106|mozc'
|
||||
FORM
|
||||
export OMARCHY_PATH="$work"
|
||||
eval "$(sed -n '/^select_keyboard() {$/,/^}$/p' "$ROOT/bin/omarchy-iso-test")"
|
||||
press() { pressed+=("$1"); }
|
||||
sleep() { :; }
|
||||
|
||||
pressed=()
|
||||
select_keyboard "Japanese"
|
||||
[[ ${pressed[*]} == "down" ]]
|
||||
pressed=()
|
||||
select_keyboard "Chinese (Simplified, Pinyin)"
|
||||
[[ ${pressed[*]} == "up" ]]
|
||||
pressed=()
|
||||
select_keyboard "English (US)"
|
||||
(( ${#pressed[@]} == 0 ))
|
||||
if (set -e; select_keyboard "Missing") 2>"$work/error"; then
|
||||
echo "Unknown keyboard unexpectedly succeeded" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Keyboard choice is absent' "$work/error"
|
||||
echo "ok - VM keyboard choices are relative to the default and reject absent rows"
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# create_partition() must report the number parted actually assigned, never a
|
||||
# predicted one. The regression this pins: the configurator used to compute
|
||||
# "highest existing partition + 1", while parted fills the lowest free GPT
|
||||
# slot. Any disk whose numbering has a hole — what deleting a partition to
|
||||
# free space leaves behind — then had every later command (cryptsetup, mkfs,
|
||||
# mount) pointed at device nodes that did not exist, surfacing much later as
|
||||
# "protected mode: /mnt is not a mountpoint".
|
||||
#
|
||||
# parted operates on image files directly, so this needs no root, no loop
|
||||
# devices, and no real disk.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
LIB="$ROOT/configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh"
|
||||
|
||||
if ! command -v parted >/dev/null 2>&1; then
|
||||
echo "SKIP: parted is not installed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# shellcheck source=../configs/airootfs/usr/share/omarchy-iso/disk-partitioning.sh
|
||||
source "$LIB"
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
IMG="$WORK/disk.img"
|
||||
MIB=$((1024 * 1024))
|
||||
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
|
||||
}
|
||||
|
||||
# parted reports a partition's size inclusive of both endpoints, so it lands
|
||||
# one sector above (end - start). Same slack create_partition allows.
|
||||
check_size() {
|
||||
local label="$1" want="$2" actual="${3:-0}" delta
|
||||
delta=$((actual - want))
|
||||
(( delta < 0 )) && delta=$((-delta))
|
||||
if (( delta <= MIB )); then
|
||||
printf ' ok %s\n' "$label"
|
||||
else
|
||||
printf ' FAIL %s: expected ~%s, got %s\n' "$label" "$want" "$actual"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# parted's end position is inclusive, so partitions laid back to back overlap
|
||||
# by a sector and are refused. Leave a MiB between them.
|
||||
mkpart_mib() {
|
||||
parted --script "$IMG" mkpart "$1" "$2" "$(($3 * MIB))B" "$(($4 * MIB))B"
|
||||
}
|
||||
|
||||
# A disk with holes: four partitions, then delete the middle two. Slots 2 and
|
||||
# 3 are free while the highest number in use is 4 — the shape of the reported
|
||||
# failure, where the user deleted a partition to make room.
|
||||
build_holey_disk() {
|
||||
rm -f "$IMG"
|
||||
truncate -s 4G "$IMG"
|
||||
parted --script "$IMG" mklabel gpt
|
||||
mkpart_mib one ext4 1 200
|
||||
mkpart_mib two ext4 201 400
|
||||
mkpart_mib three ext4 401 600
|
||||
mkpart_mib four ext4 601 800
|
||||
parted --script "$IMG" rm 2
|
||||
parted --script "$IMG" rm 3
|
||||
created_parts=()
|
||||
}
|
||||
|
||||
echo "==> numbering on a disk with holes"
|
||||
build_holey_disk
|
||||
check "existing numbers" "1 4" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')"
|
||||
|
||||
create_partition "$IMG" "$((1000 * MIB))" "$((1200 * MIB))" fat32 OMARCHY_EFI
|
||||
check "create_partition succeeded (esp)" "0" "$?"
|
||||
esp_num="$created_partition_number"
|
||||
check "esp took the lowest free slot" "2" "$esp_num"
|
||||
|
||||
# Starts a MiB past the ESP, the way run_partition_decide aligns ROOT_START_B
|
||||
# up from EFI_END_B + 1.
|
||||
create_partition "$IMG" "$((1201 * MIB))" "$((2000 * MIB))" btrfs OMARCHY_ROOT
|
||||
check "create_partition succeeded (root)" "0" "$?"
|
||||
root_num="$created_partition_number"
|
||||
check "root took the next free slot" "3" "$root_num"
|
||||
|
||||
# The old prediction. If either of these ever matches again, the regression is
|
||||
# back: the highest number in use was 4, so the guess would have been 5 and 6.
|
||||
check "esp is not the predicted number" "not-5" "$([[ $esp_num == 5 ]] && echo 5 || echo not-5)"
|
||||
check "root is not the predicted number" "not-6" "$([[ $root_num == 6 ]] && echo 6 || echo not-6)"
|
||||
|
||||
check "both creations tracked for rollback" "2 3" "${created_parts[*]}"
|
||||
check_size "esp size" "$((200 * MIB))" "$(partition_size_bytes "$IMG" "$esp_num")"
|
||||
check_size "root size" "$((799 * MIB))" "$(partition_size_bytes "$IMG" "$root_num")"
|
||||
|
||||
echo "==> rollback reclaims only what this run created"
|
||||
rollback_created_parts "$IMG"
|
||||
check "created partitions removed" "1 4" "$(partition_numbers "$IMG" | sort | tr '\n' ' ' | sed 's/ $//')"
|
||||
check "rollback list cleared" "0" "${#created_parts[@]}"
|
||||
|
||||
echo "==> numbering on a disk without holes"
|
||||
rm -f "$IMG"
|
||||
truncate -s 4G "$IMG"
|
||||
parted --script "$IMG" mklabel gpt
|
||||
mkpart_mib one ext4 1 200
|
||||
mkpart_mib two ext4 201 400
|
||||
created_parts=()
|
||||
|
||||
create_partition "$IMG" "$((1000 * MIB))" "$((1200 * MIB))" fat32 OMARCHY_EFI
|
||||
check "appends when there is no hole" "3" "$created_partition_number"
|
||||
|
||||
echo "==> a refused creation stays out of the rollback list"
|
||||
created_parts=()
|
||||
create_partition "$IMG" "$((100 * MIB))" "$((300 * MIB))" ext4 OVERLAP
|
||||
check "overlapping creation failed" "1" "$?"
|
||||
check "nothing tracked" "0" "${#created_parts[@]}"
|
||||
|
||||
if (( failures > 0 )); then
|
||||
printf '\n%d check(s) failed\n' "$failures"
|
||||
exit 1
|
||||
fi
|
||||
printf '\nall checks passed\n'
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Exercise the compatibility boundary without importing host archinstall."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import types
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
source = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py"
|
||||
tree = ast.parse(source.read_text())
|
||||
namespace = {}
|
||||
functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in {"_method_accepts", "sanity_check"}]
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), str(source), "exec"), namespace)
|
||||
|
||||
|
||||
class SanityCheckTest(unittest.TestCase):
|
||||
def test_legacy_archinstall_stays_offline(self):
|
||||
calls = []
|
||||
def check(offline=False, skip_ntp=False, skip_wkd=False):
|
||||
calls.append((offline, skip_ntp, skip_wkd))
|
||||
namespace["sanity_check"](types.SimpleNamespace(sanity_check=check))
|
||||
self.assertEqual(calls, [(True, True, True)])
|
||||
|
||||
def test_archinstall_45_does_not_receive_removed_argument(self):
|
||||
calls = []
|
||||
class Installer:
|
||||
def sanity_check(self, skip_ntp=False, skip_wkd=False):
|
||||
calls.append((skip_ntp, skip_wkd))
|
||||
namespace["sanity_check"](Installer())
|
||||
self.assertEqual(calls, [(True, True)])
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
"""Undecodable bytes in command output, and where tolerating them stops.
|
||||
|
||||
The orchestrator reads firmware and filesystem metadata out of commands whose
|
||||
output is not required to be UTF-8: noise in a boot entry must not stop an
|
||||
install, while a mangled identifier must stop one. These tests run stand-in
|
||||
commands emitting the bytes two HP firmwares actually put in a legacy BBS entry,
|
||||
so they exercise the decode itself rather than a mocked result.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "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.
|
||||
sys.modules.setdefault(
|
||||
"orchestrator.archinstall_adapter",
|
||||
types.ModuleType("orchestrator.archinstall_adapter"),
|
||||
)
|
||||
|
||||
from orchestrator import phases_impl # noqa: E402
|
||||
from orchestrator.command import capture, capture_identifier # noqa: E402
|
||||
|
||||
# Boot0000 is the entry that aborted the install: raw bytes inside a BBS device
|
||||
# path, in a legacy entry nothing in the installer reads. 0x80 and 0xe0 are the
|
||||
# bytes two different HP firmwares emitted.
|
||||
EFIBOOTMGR_OUTPUT = (
|
||||
b"BootCurrent: 0003\n"
|
||||
b"Timeout: 0 seconds\n"
|
||||
b"BootOrder: 2001,0003,0000\n"
|
||||
b"Boot0000* Notebook Hard Drive\tBBS(HD,\x80\x7f\xff\x04\xe0\x7f,)\n"
|
||||
b"Boot0003* Omarchy\tHD(1,GPT,0d9c9d4f)/File(\\EFI\\limine\\BOOTX64.EFI)\n"
|
||||
b"Boot2001* USB Drive (UEFI)\tRC\n"
|
||||
)
|
||||
|
||||
|
||||
class CommandCaptureTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bin_dir = None
|
||||
|
||||
def fake_bin(self) -> Path:
|
||||
"""A directory at the front of PATH, made once per test."""
|
||||
if self.bin_dir is None:
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.bin_dir = Path(tmp.name)
|
||||
path = os.environ["PATH"]
|
||||
self.addCleanup(os.environ.__setitem__, "PATH", path)
|
||||
os.environ["PATH"] = f"{self.bin_dir}:{path}"
|
||||
return self.bin_dir
|
||||
|
||||
def fake_script(self, name: str, body: str) -> None:
|
||||
script = self.fake_bin() / name
|
||||
script.write_text(f"#!/bin/sh\n{body}\n")
|
||||
script.chmod(0o755)
|
||||
|
||||
def fake_command(self, name: str, output: bytes) -> None:
|
||||
"""A command printing exactly these bytes, undecodable ones included."""
|
||||
payload = self.fake_bin() / f"{name}.out"
|
||||
payload.write_bytes(output)
|
||||
self.fake_script(name, f'cat "{payload}"')
|
||||
|
||||
def fake_findmnt(self, source: bytes) -> None:
|
||||
"""A findmnt answering per column, so the phase gets as far as SOURCE."""
|
||||
payload = self.fake_bin() / "findmnt.source"
|
||||
payload.write_bytes(source)
|
||||
self.fake_script(
|
||||
"findmnt",
|
||||
'case "$2" in\n'
|
||||
" FSTYPE) echo btrfs ;;\n"
|
||||
" OPTIONS) echo rw,subvol=/@ ;;\n"
|
||||
f' SOURCE) cat "{payload}" ;;\n'
|
||||
"esac",
|
||||
)
|
||||
|
||||
def test_undecodable_bytes_are_replaced_rather_than_raised(self):
|
||||
self.fake_command("omarchy-not-utf8", b"before\x80after\n")
|
||||
self.assertEqual(capture(["omarchy-not-utf8"]).stdout, "before�after\n")
|
||||
|
||||
def test_efibootmgr_entries_parse_around_a_legacy_bbs_entry(self):
|
||||
self.fake_command("efibootmgr", EFIBOOTMGR_OUTPUT)
|
||||
|
||||
state = phases_impl._read_efibootmgr()
|
||||
|
||||
self.assertEqual(state["order"], ["2001", "0003", "0000"])
|
||||
self.assertEqual(phases_impl._find_label_entries(state["entries"], "Omarchy"), ["0003"])
|
||||
self.assertTrue(state["entries"]["0000"].startswith("Notebook Hard Drive"))
|
||||
|
||||
def test_a_mangled_identifier_stops_the_install_rather_than_reaching_fstab(self):
|
||||
# _validate_pre_mounted_filesystems reads the UUID back from this same
|
||||
# command, so a replaced byte would agree with itself all the way to a
|
||||
# machine that cannot find its root.
|
||||
self.fake_command("blkid", b"1a2b3c4d-\x80-dead-beef\n")
|
||||
|
||||
with self.assertRaises(RuntimeError) as raised:
|
||||
phases_impl._blkid_uuid("/dev/sda2")
|
||||
|
||||
self.assertIn("the UUID of /dev/sda2 is not valid text", str(raised.exception))
|
||||
|
||||
def test_a_mangled_btrfs_device_is_never_handed_to_mount(self):
|
||||
# create_factory_snapshot mounts this device and deletes subvolumes
|
||||
# under it; a replaced byte must stop the phase before that, not turn
|
||||
# into an opaque mount failure.
|
||||
self.fake_findmnt(source=b"/dev/sda\xe02[/@]")
|
||||
ctx = types.SimpleNamespace(target=Path("/mnt"), state_dir=Path("/run/omarchy"))
|
||||
|
||||
with mock.patch.object(phases_impl, "info"), self.assertRaises(RuntimeError) as raised:
|
||||
phases_impl.create_factory_snapshot(ctx)
|
||||
|
||||
self.assertIn("is not valid text", str(raised.exception))
|
||||
|
||||
def test_a_clean_identifier_is_returned_unchanged(self):
|
||||
self.fake_command("blkid", b"1a2b3c4d-0e5f-dead-beef\n")
|
||||
self.assertEqual(
|
||||
capture_identifier(["blkid"], "the UUID"), "1a2b3c4d-0e5f-dead-beef"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,7 +14,7 @@ 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.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso"))
|
||||
|
||||
# phases_impl imports the archinstall adapter at module scope, which pulls in
|
||||
# the archinstall library that only exists on the live ISO. configure_ssh_access
|
||||
@@ -15,7 +15,7 @@ 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.path.insert(0, str(Path(__file__).resolve().parents[2] / "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
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Fresh installs use the Omarchy kernel, except for T2 Macs."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
|
||||
sys.modules.setdefault(
|
||||
"orchestrator.archinstall_adapter",
|
||||
types.ModuleType("orchestrator.archinstall_adapter"),
|
||||
)
|
||||
from orchestrator import context, phases_impl # noqa: E402
|
||||
|
||||
|
||||
class KernelSelectionTest(unittest.TestCase):
|
||||
def test_hardware_detection(self):
|
||||
# Run the configurator's actual probe without starting its disk wizard.
|
||||
configurator = (ROOT / "configs/airootfs/root/configurator").read_text()
|
||||
probe = re.search(r"^detect_kernel\(\) \{\n.*?^\}", configurator, re.M | re.S)
|
||||
self.assertIsNotNone(probe)
|
||||
cases = [
|
||||
("00:02.0 VGA compatible controller [0300]: Intel [8086:b080]", "linux-omarchy"),
|
||||
("00:01.0 Ethernet controller [0200]: Apple [106b:2001]", "linux-omarchy"),
|
||||
("04:00.0 Mass storage controller [0180]: Apple [106b:1801]", "linux-t2"),
|
||||
("04:00.0 Mass storage controller [0180]: Apple [106b:1802]", "linux-t2"),
|
||||
("", "linux-omarchy"),
|
||||
]
|
||||
for devices, expected in cases:
|
||||
with self.subTest(devices=devices):
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'lspci() { printf "%s\\n" "$TEST_PCI"; }\n'
|
||||
+ probe.group() + "\ndetect_kernel"],
|
||||
env={**os.environ, "TEST_PCI": devices},
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(result.stdout.strip(), expected)
|
||||
|
||||
def test_unattended_kernel_default_uses_pci_hardware(self):
|
||||
for vendor, device, expected in [
|
||||
("0x8086", "0xb080", "linux-omarchy"),
|
||||
("0x106b", "0x2001", "linux-omarchy"),
|
||||
("0x106b", "0x1801", "linux-t2"),
|
||||
("0x106b", "0x1802", "linux-t2"),
|
||||
]:
|
||||
with self.subTest(device=device), tempfile.TemporaryDirectory() as tmp:
|
||||
pci = Path(tmp)
|
||||
slot = pci / "0000:00:00.0"
|
||||
slot.mkdir()
|
||||
(slot / "vendor").write_text(vendor + "\n")
|
||||
(slot / "device").write_text(device + "\n")
|
||||
self.assertEqual(context._default_kernel(pci), expected)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(context._default_kernel(Path(tmp)), "linux-omarchy")
|
||||
|
||||
def test_context_passes_effective_kernels_to_archinstall(self):
|
||||
for config, default, expected in [
|
||||
({}, "linux-omarchy", ["linux-omarchy"]),
|
||||
({"kernels": []}, "linux-omarchy", ["linux-omarchy"]),
|
||||
({"kernels": None}, "linux-t2", ["linux-t2"]),
|
||||
({}, "linux-t2", ["linux-t2"]),
|
||||
({"kernels": ["linux-lts"]}, "linux-omarchy", ["linux-lts"]),
|
||||
({"kernels": ["linux-omarchy", "linux-lts"]}, "linux-t2", ["linux-omarchy", "linux-lts"]),
|
||||
({"omarchy_install": {"storage": {"kernel": "linux-t2"}}}, "linux-omarchy", ["linux-t2"]),
|
||||
]:
|
||||
with self.subTest(config=config, default=default), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
config_path = root / "config.json"
|
||||
creds_path = root / "creds.json"
|
||||
config_path.write_text(json.dumps(config))
|
||||
creds_path.write_text(json.dumps({"users": [{"username": "test"}]}))
|
||||
with mock.patch.dict(os.environ, {
|
||||
"OMARCHY_INSTALL_CONFIG": str(config_path),
|
||||
"OMARCHY_INSTALL_CREDS": str(creds_path),
|
||||
"OMARCHY_INSTALL_STATE_DIR": str(root / "state"),
|
||||
}, clear=True), mock.patch.object(context, "_default_kernel", return_value=default):
|
||||
ctx = context.InstallContext.from_env()
|
||||
self.assertEqual(ctx.user_configuration["kernels"], expected)
|
||||
self.assertEqual(json.loads(ctx.arch_config_path.read_text())["kernels"], expected)
|
||||
|
||||
def test_headers_install_before_dkms_packages(self):
|
||||
for kernels in (["linux-omarchy"], ["linux-t2"], ["linux-omarchy", "linux-lts"]):
|
||||
for fail_headers in (False, True):
|
||||
with self.subTest(kernels=kernels, fail_headers=fail_headers), ExitStack() as stack:
|
||||
events = []
|
||||
installer = mock.MagicMock()
|
||||
installer.minimal_installation.side_effect = lambda **kwargs: events.append("base")
|
||||
|
||||
def install_packages(packages):
|
||||
events.append(packages)
|
||||
if fail_headers:
|
||||
raise RuntimeError("header install failed")
|
||||
|
||||
installer.add_additional_packages.side_effect = install_packages
|
||||
config = types.SimpleNamespace(
|
||||
kernels=kernels, locale_config=None, mirror_config=None,
|
||||
swap=None, auth_config=None, app_config=None, timezone=None,
|
||||
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
|
||||
)
|
||||
ctx = types.SimpleNamespace(
|
||||
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
|
||||
target=Path("/unused"), tailscale_authkey_path=None, omarchy_install={},
|
||||
region="global",
|
||||
)
|
||||
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
|
||||
"_configure_limine_boot", "_write_pre_mounted_fstab"):
|
||||
stack.enter_context(mock.patch.object(phases_impl, name))
|
||||
unmask = stack.enter_context(mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"))
|
||||
unmount = stack.enter_context(mock.patch.object(phases_impl, "_unmount_offline_package_cache"))
|
||||
stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True))
|
||||
stack.enter_context(mock.patch.object(phases_impl, "_install_early_packages", side_effect=lambda inst: events.append("early")))
|
||||
stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"]))
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, "is_pre_mount", return_value=True, create=True))
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, "root_user", return_value=None, create=True))
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
|
||||
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
|
||||
opened.return_value.__enter__.return_value = installer
|
||||
if fail_headers:
|
||||
with self.assertRaisesRegex(RuntimeError, "header install failed"):
|
||||
phases_impl.arch_install_system(ctx)
|
||||
else:
|
||||
phases_impl.arch_install_system(ctx)
|
||||
expected = ["base", [f"{kernel}-headers" for kernel in kernels]]
|
||||
if not fail_headers:
|
||||
expected += ["early", ["omarchy"]]
|
||||
self.assertEqual(events, expected)
|
||||
unmask.assert_called_once_with(ctx)
|
||||
unmount.assert_called_once_with(ctx)
|
||||
|
||||
def test_validation_rejects_missing_or_mismatched_headers(self):
|
||||
for kernel in ("linux-omarchy", "linux-t2"):
|
||||
with self.subTest(kernel=kernel), tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp)
|
||||
ctx = types.SimpleNamespace(target=target)
|
||||
with self.assertRaisesRegex(RuntimeError, "no installed kernel"):
|
||||
phases_impl._validate_kernel_headers(ctx)
|
||||
modules = target / "usr/lib/modules/7.2-test"
|
||||
modules.mkdir(parents=True)
|
||||
(modules / "pkgbase").write_text(kernel + "\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "no kernel headers"):
|
||||
phases_impl._validate_kernel_headers(ctx)
|
||||
header_release = modules / "build/include/config/kernel.release"
|
||||
header_release.parent.mkdir(parents=True)
|
||||
header_release.write_text("7.2-stock\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "headers do not match"):
|
||||
phases_impl._validate_kernel_headers(ctx)
|
||||
header_release.write_text("7.2-test\n")
|
||||
phases_impl._validate_kernel_headers(ctx)
|
||||
|
||||
def test_boot_validation_uses_default_or_explicit_kernel(self):
|
||||
for storage, configuration, expected in [
|
||||
({}, {}, "linux-omarchy"),
|
||||
({}, {"kernels": ["linux-t2"]}, "linux-t2"),
|
||||
({"kernel": "linux-t2"}, {"kernels": ["linux-omarchy"]}, "linux-t2"),
|
||||
({}, {"kernels": ["linux-lts"]}, "linux-lts"),
|
||||
]:
|
||||
with self.subTest(kernel=expected, storage=storage), tempfile.TemporaryDirectory() as tmp:
|
||||
target = Path(tmp)
|
||||
files = {
|
||||
f"usr/lib/modules/7.2-test/pkgbase": expected + "\n",
|
||||
"usr/lib/modules/7.2-test/build/include/config/kernel.release": "7.2-test\n",
|
||||
"boot/limine.conf": "/Omarchy\n",
|
||||
"etc/kernel/cmdline": "root=UUID=test\n",
|
||||
"etc/default/limine": "CUSTOM_UKI_NAME=omarchy\n",
|
||||
"boot/EFI/limine/limine_x64.efi": "bootloader",
|
||||
f"boot/EFI/Linux/omarchy_{expected}.efi": "UKI",
|
||||
}
|
||||
for name, content in files.items():
|
||||
path = target / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
ctx = types.SimpleNamespace(
|
||||
target=target, omarchy_install={"storage": storage},
|
||||
user_configuration=configuration, encrypt=False,
|
||||
is_protected=False, defer_provisioning=False,
|
||||
)
|
||||
with mock.patch.object(phases_impl, "_assert_boot_hooks_restored"), \
|
||||
mock.patch.object(phases_impl.arch, "has_uefi", return_value=True, create=True), \
|
||||
mock.patch.object(phases_impl, "_read_efibootmgr", return_value={
|
||||
"entries": {"0001": "Limine\tHD(1,GPT,test)"},
|
||||
}):
|
||||
phases_impl.validate_boot(ctx)
|
||||
(target / f"boot/EFI/Linux/omarchy_{expected}.efi").unlink()
|
||||
with self.assertRaisesRegex(RuntimeError, "missing or empty"):
|
||||
phases_impl.validate_boot(ctx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,24 +1,24 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py"
|
||||
SPEC = importlib.util.spec_from_file_location("installer_keyboard", MODULE_PATH)
|
||||
KEYBOARD = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader
|
||||
SPEC.loader.exec_module(KEYBOARD)
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
|
||||
|
||||
from orchestrator import keyboard as KEYBOARD # noqa: E402
|
||||
|
||||
# The layout list lives in the Omarchy runtime now, shared verbatim with the
|
||||
# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from
|
||||
# wherever this checkout can see a runtime, and skip the coverage test rather
|
||||
# than fail when none is around (a bare CI checkout of just this repo).
|
||||
SETUP_FORM_CANDIDATES = (
|
||||
Path(os.environ.get("OMARCHY_PATH", "/nonexistent")) / "install/provisioning/setup-form.sh",
|
||||
Path("/omarchy-source/install/provisioning/setup-form.sh"),
|
||||
ROOT.parent / "omarchy/install/provisioning/setup-form.sh",
|
||||
Path("/usr/share/omarchy/install/provisioning/setup-form.sh"),
|
||||
@@ -33,7 +33,7 @@ def supported_keymaps():
|
||||
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 [line.split("|")[1] for line in block.group(1).splitlines()]
|
||||
return None
|
||||
|
||||
|
||||
@@ -81,6 +81,36 @@ class KeyboardConfigurationTest(unittest.TestCase):
|
||||
"KEYMAP=us\nFONT=default8x16\n",
|
||||
)
|
||||
|
||||
def test_input_choice_survives_while_console_keymap_stays_real(self):
|
||||
for method, keymap, layout in (("mozc", "jp106", ""), ("mozc", "us", ""), ("hangul", "us", "kr"), ("pinyin", "us", ""), ("chewing", "us", "")):
|
||||
with self.subTest(method=method), tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap, method, layout))
|
||||
self.assertIn(f"KEYMAP={keymap}", (target / "etc/vconsole.conf").read_text())
|
||||
if keymap == "jp106":
|
||||
self.assertIn("XKBLAYOUT=jp", (target / "etc/vconsole.conf").read_text())
|
||||
self.assertEqual((target / "etc/omarchy/input-method").read_text(), f"INPUT_METHOD={method}\nXKB_LAYOUT={layout}\n")
|
||||
|
||||
def test_input_choice_with_no_console_map_defaults_to_us(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
self.assertTrue(KEYBOARD.configure_keyboard(target, "", "pinyin"))
|
||||
self.assertIn("INPUT_METHOD=pinyin", (target / "etc/omarchy/input-method").read_text())
|
||||
|
||||
def test_unknown_keyboard_does_not_silently_drop_an_input_choice(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
with self.assertRaises(ValueError):
|
||||
KEYBOARD.configure_keyboard(target, "unknown", "pinyin")
|
||||
|
||||
def test_invalid_input_preferences_are_rejected_without_writes(self):
|
||||
for method, layout in (("unknown", ""), ("hangul", "kr\nKEYMAP=ru")):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
target = self.target(directory)
|
||||
with self.assertRaises(ValueError):
|
||||
KEYBOARD.configure_keyboard(target, "us", method, layout)
|
||||
self.assertFalse((target / "etc/omarchy/input-method").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PRUNER = ROOT / "builder/prune-offline-mirror.sh"
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ 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.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso"))
|
||||
|
||||
sys.modules.setdefault(
|
||||
"orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter")
|
||||
@@ -0,0 +1,236 @@
|
||||
"""The target's region comes from its timezone, using the runtime's profiles."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
|
||||
sys.modules.setdefault(
|
||||
"orchestrator.archinstall_adapter",
|
||||
types.ModuleType("orchestrator.archinstall_adapter"),
|
||||
)
|
||||
from orchestrator import context, phases_impl, region # noqa: E402
|
||||
|
||||
|
||||
def write_profiles(root: Path) -> Path:
|
||||
regions = root / "regions"
|
||||
cn = regions / "cn"
|
||||
(cn / "pacman").mkdir(parents=True)
|
||||
(cn / "timezones").write_text("# China\nAsia/Shanghai\n\n Asia/Urumqi \n")
|
||||
(cn / "packages").write_text("# keyring first\narchlinuxcn-keyring\nextra-tool\n")
|
||||
(cn / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://example.invalid/$arch\n")
|
||||
for channel in ("stable", "rc", "edge"):
|
||||
(cn / f"pacman/mirrorlist-{channel}.prepend").write_text(f"Server = https://{channel}.example.invalid/$repo/os/$arch\n")
|
||||
return regions
|
||||
|
||||
|
||||
class RegionSelectionTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
self.regions = write_profiles(self.root)
|
||||
patcher = mock.patch.object(region, "REGIONS_DIR", self.regions)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_timezone_selects_region(self):
|
||||
for timezone, expected in [
|
||||
("Asia/Shanghai", "cn"),
|
||||
("Asia/Urumqi", "cn"),
|
||||
("Asia/Hong_Kong", "global"),
|
||||
("America/New_York", "global"),
|
||||
("UTC", "global"),
|
||||
("# China", "global"),
|
||||
("", "global"),
|
||||
(None, "global"),
|
||||
]:
|
||||
with self.subTest(timezone=timezone):
|
||||
self.assertEqual(region.region_for_timezone(timezone), expected)
|
||||
|
||||
def test_iso_without_profiles_is_global(self):
|
||||
self.assertEqual(region.region_for_timezone("Asia/Shanghai", self.root / "missing"), "global")
|
||||
self.assertEqual(region.resolve_region({}, "Asia/Shanghai", self.root / "missing"), "global")
|
||||
|
||||
def test_autoinstall_region_overrides_timezone(self):
|
||||
self.assertEqual(region.resolve_region({"region": "cn"}, "UTC"), "cn")
|
||||
self.assertEqual(region.resolve_region({"region": "global"}, "Asia/Shanghai"), "global")
|
||||
self.assertEqual(region.resolve_region({}, "Asia/Shanghai"), "cn")
|
||||
for bad in ("us", "CN", "..", "cn/../cn", True, 1, ["cn"]):
|
||||
with self.subTest(region=bad), self.assertRaises(RuntimeError):
|
||||
region.resolve_region({"region": bad}, "UTC")
|
||||
|
||||
def test_profile_packages_and_keyrings(self):
|
||||
self.assertEqual(region.region_packages("cn"), ["archlinuxcn-keyring", "extra-tool"])
|
||||
self.assertEqual(region.region_keyrings("cn"), ["archlinuxcn"])
|
||||
self.assertEqual(region.region_packages("global"), [])
|
||||
self.assertEqual(region.region_keyrings("global"), [])
|
||||
|
||||
def from_env(self, config: dict) -> context.InstallContext:
|
||||
config_path = self.root / "config.json"
|
||||
creds_path = self.root / "creds.json"
|
||||
config_path.write_text(json.dumps(config))
|
||||
creds_path.write_text(json.dumps({"users": [{"username": "test"}]}))
|
||||
with mock.patch.dict(os.environ, {
|
||||
"OMARCHY_INSTALL_CONFIG": str(config_path),
|
||||
"OMARCHY_INSTALL_CREDS": str(creds_path),
|
||||
"OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"),
|
||||
}, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"):
|
||||
return context.InstallContext.from_env()
|
||||
|
||||
def test_context_resolves_region_before_install(self):
|
||||
self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn")
|
||||
self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global")
|
||||
# Deferred installs carry UTC until first boot, so they are global
|
||||
# unless the autoinstall config names a region.
|
||||
self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global")
|
||||
self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.from_env({"omarchy_install": {"region": "zz"}})
|
||||
|
||||
def test_marker_is_written_only_for_regional_targets(self):
|
||||
target = self.root / "target"
|
||||
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global"))
|
||||
self.assertFalse((target / "etc/omarchy/region").exists())
|
||||
|
||||
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn"))
|
||||
marker = target / "etc/omarchy/region"
|
||||
self.assertEqual(marker.read_text(), "cn\n")
|
||||
self.assertEqual(marker.stat().st_mode & 0o777, 0o644)
|
||||
|
||||
def test_region_packages_install_while_the_offline_mirror_is_mounted(self):
|
||||
for selected, expected_packages in [("global", []), ("cn", [["archlinuxcn-keyring", "extra-tool"]])]:
|
||||
with self.subTest(region=selected), ExitStack() as stack:
|
||||
events = []
|
||||
installer = mock.MagicMock()
|
||||
installer.add_additional_packages.side_effect = lambda packages: events.append(packages)
|
||||
config = types.SimpleNamespace(
|
||||
kernels=["linux-omarchy"], locale_config=None, mirror_config=None,
|
||||
swap=None, auth_config=None, app_config=None, timezone=None,
|
||||
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
|
||||
)
|
||||
ctx = types.SimpleNamespace(
|
||||
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
|
||||
target=self.root / "target", tailscale_authkey_path=None, omarchy_install={},
|
||||
region=selected,
|
||||
)
|
||||
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
|
||||
"_unmask_mkinitcpio_pacman_hooks", "_configure_limine_boot",
|
||||
"_write_pre_mounted_fstab", "_install_early_packages"):
|
||||
stack.enter_context(mock.patch.object(phases_impl, name))
|
||||
stack.enter_context(mock.patch.object(
|
||||
phases_impl, "_unmount_offline_package_cache", side_effect=lambda _: events.append("unmount")))
|
||||
stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True))
|
||||
stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"]))
|
||||
for name, value in (("is_pre_mount", True), ("root_user", None)):
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, name, return_value=value, create=True))
|
||||
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
|
||||
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
|
||||
opened.return_value.__enter__.return_value = installer
|
||||
|
||||
phases_impl.arch_install_system(ctx)
|
||||
|
||||
self.assertEqual(events, [["linux-omarchy-headers"], ["omarchy"], *expected_packages, "unmount"])
|
||||
self.assertEqual((ctx.target / "etc/omarchy/region").exists(), selected != "global")
|
||||
|
||||
def test_finalizer_trusts_only_the_selected_region(self):
|
||||
for selected, expected in [
|
||||
("global", []),
|
||||
("cn", [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]),
|
||||
]:
|
||||
with self.subTest(region=selected), \
|
||||
mock.patch.object(phases_impl, "_run_target_setup_command") as run, \
|
||||
mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \
|
||||
mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"):
|
||||
ctx = types.SimpleNamespace(region=selected, defer_provisioning=True, target=Path("/unused"))
|
||||
phases_impl.run_system_finalizer(ctx)
|
||||
commands = [call.args[1] for call in run.call_args_list]
|
||||
self.assertEqual(commands[:-1], expected)
|
||||
self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system")
|
||||
|
||||
|
||||
class PrepareRegionsTest(unittest.TestCase):
|
||||
def run_prepare(self, regions: Path, root: Path) -> tuple[Path, Path, str]:
|
||||
payload = root / "payload"
|
||||
payload.mkdir()
|
||||
online = root / "online.conf"
|
||||
online.write_text("[core]\nInclude = /etc/pacman.d/mirrorlist\n")
|
||||
stubs = root / "bin"
|
||||
stubs.mkdir()
|
||||
log = root / "calls.log"
|
||||
for name in ("pacman", "pacman-key"):
|
||||
stub = stubs / name
|
||||
stub.write_text(f'#!/bin/bash\necho "{name} $*" >> "{log}"\n')
|
||||
stub.chmod(0o755)
|
||||
subprocess.run(
|
||||
["bash", str(ROOT / "builder/prepare-regions.sh"), str(regions), str(payload), str(online)],
|
||||
env={**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}"},
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
return payload, online, log.read_text() if log.exists() else ""
|
||||
|
||||
def test_ships_every_profile_and_bootstraps_its_keyring(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
regions = write_profiles(root)
|
||||
payload, online, calls = self.run_prepare(regions, root)
|
||||
self.assertEqual((payload / "regions/cn/timezones").read_text(), (regions / "cn/timezones").read_text())
|
||||
self.assertIn("[archlinuxcn]", online.read_text())
|
||||
self.assertTrue(online.read_text().startswith("[core]\n"))
|
||||
self.assertEqual(calls.splitlines(), [
|
||||
f"pacman --config {online} --noconfirm -Sy --needed archlinuxcn-keyring",
|
||||
"pacman-key --populate archlinuxcn",
|
||||
])
|
||||
|
||||
def settings_package(self, root: Path, with_regions: bool) -> Path:
|
||||
tree = root / "pkgroot"
|
||||
(tree / "usr/share/omarchy/default/pacman").mkdir(parents=True)
|
||||
if with_regions:
|
||||
write_profiles(root / "src")
|
||||
(root / "src/regions").rename(tree / "usr/share/omarchy/default/regions")
|
||||
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
|
||||
subprocess.run(["bsdtar", "--zstd", "-cf", str(package), "-C", str(tree), "usr"], check=True)
|
||||
return package
|
||||
|
||||
def test_published_build_reads_profiles_from_the_settings_package(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=True), root)
|
||||
self.assertEqual((payload / "regions/cn/packages").read_text(), "# keyring first\narchlinuxcn-keyring\nextra-tool\n")
|
||||
self.assertIn("[archlinuxcn]", online.read_text())
|
||||
self.assertIn("pacman-key --populate archlinuxcn", calls)
|
||||
|
||||
def test_settings_package_without_profiles_builds_a_global_iso(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=False), root)
|
||||
self.assertFalse((payload / "regions").exists())
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
def test_unreadable_settings_package_fails_the_build(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
|
||||
package.write_text("not an archive")
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
self.run_prepare(package, root)
|
||||
|
||||
def test_runtime_without_profiles_builds_a_global_iso(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
payload, online, calls = self.run_prepare(root / "missing", root)
|
||||
self.assertFalse((payload / "regions").exists())
|
||||
self.assertNotIn("archlinuxcn", online.read_text())
|
||||
self.assertEqual(calls, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user