Compare commits
334
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f188a4be
|
||
|
|
e22a13dfeb
|
||
|
|
53b80d406c | ||
|
|
4f4b3e3da9 | ||
|
|
bddcb7bfc8 | ||
|
|
dcfd3e9917 | ||
|
|
3e21939b9e | ||
|
|
40c5fb8e98 | ||
|
|
d4ad44c9f6 | ||
|
|
df10011064 | ||
|
|
d6a32f0dcf | ||
|
|
b5936a6cbe | ||
|
|
7f0ae48661 | ||
|
|
522b92a8d3 | ||
|
|
21a44ee4b4 | ||
|
|
ed0dfc5052 | ||
|
|
22ef6e12c5 | ||
|
|
f9caa25766 | ||
|
|
cabc8d1ea9 | ||
|
|
b01a396e62 | ||
|
|
bb1ad28887 | ||
|
|
20aeec8364 | ||
|
|
415831b1eb | ||
|
|
20fd1dd00b | ||
|
|
f9d184f2bc | ||
|
|
2b8d012688 | ||
|
|
97e06f025f | ||
|
|
04ff1f5ea1 | ||
|
|
82cec53728 | ||
|
|
469052b93e | ||
|
|
2a39b849c1 | ||
|
|
7cce6045fa | ||
|
|
18a968f467 | ||
|
|
f6377d2f87 | ||
|
|
61b309cf9a | ||
|
|
f99443dc4c | ||
|
|
65589d2138 | ||
|
|
0abcbb0b97 | ||
|
|
ff30b7f558 | ||
|
|
d054c00d24 | ||
|
|
4f7d4a9db3 | ||
|
|
f0216cf0c0 | ||
|
|
32f917fdff | ||
|
|
98b20a6829 | ||
|
|
c97836e4f2 | ||
|
|
41fa32d671 | ||
|
|
9d4d03ff1c | ||
|
|
d2677b3046 | ||
|
|
ebfbf43aa0 | ||
|
|
0bc00ff572 | ||
|
|
c6b1d2dadc | ||
|
|
d90119f5d8 | ||
|
|
c0a0e787f3 | ||
|
|
466a7a8221 | ||
|
|
4e7b18eb7d | ||
|
|
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 | ||
|
|
d6cd2d30a6 | ||
|
|
be618cc73b | ||
|
|
a6a442b9a0 | ||
|
|
e5f2b466ef | ||
|
|
a12bfea7a8 | ||
|
|
8b3dcc68b2 | ||
|
|
22eb81ebf5 | ||
|
|
8858ac2544 | ||
|
|
6b2f8a6ac7 | ||
|
|
6004e80485 | ||
|
|
0339b13d12 | ||
|
|
4d10233371 | ||
|
|
c9d445cf33 | ||
|
|
f11a70c6ba | ||
|
|
be68a76a44 | ||
|
|
69ec1b4b8e | ||
|
|
dd5c53cd3c | ||
|
|
e8a7e3cfce | ||
|
|
7e782bb7cf | ||
|
|
5f601f2c54 | ||
|
|
b323a4cf08 | ||
|
|
a4ec97d174 | ||
|
|
f5aa88b06c | ||
|
|
f66b6fc28e | ||
|
|
17532793fd | ||
|
|
0b4d74f6af | ||
|
|
a76f599eaa | ||
|
|
64152dda3f | ||
|
|
56eb451b92 | ||
|
|
d64ffb0e00 | ||
|
|
5b626d2a92 | ||
|
|
3efdb5c94b | ||
|
|
b3cf0c6f29 | ||
|
|
4f60a18c87 | ||
|
|
0631c0530a | ||
|
|
ed710a3b41 | ||
|
|
89245f44ec | ||
|
|
e47ce379ad | ||
|
|
a8c3a671ea | ||
|
|
c2b93afe01 | ||
|
|
436f37f926 | ||
|
|
529001d566 | ||
|
|
78824fe084 | ||
|
|
8653d07e06 | ||
|
|
5e80c917ea | ||
|
|
f47785a196 | ||
|
|
6ac95226af | ||
|
|
f97a664ea3 | ||
|
|
338e687f32 | ||
|
|
4e8ee7da9b | ||
|
|
ca79d0f4dc | ||
|
|
463f862b6c | ||
|
|
3544261e8c | ||
|
|
a274a2c56e | ||
|
|
fa54a8b2dc | ||
|
|
a27385483f | ||
|
|
01a5203009 | ||
|
|
a72a109e7a | ||
|
|
3c98b0e10c | ||
|
|
9d4b155e22 | ||
|
|
04aafcb781 | ||
|
|
c3f9743847 | ||
|
|
434db24b81 | ||
|
|
969d461eb9 | ||
|
|
4583a0e5e6 | ||
|
|
f79578f2db | ||
|
|
4e3e82922d | ||
|
|
59ffc3321e | ||
|
|
1463936de3 | ||
|
|
f6775344f6 | ||
|
|
49dcd808aa | ||
|
|
c2a58526f4 | ||
|
|
a5e605af4c | ||
|
|
8c1e143686 | ||
|
|
4ad8a22abe | ||
|
|
764a65f7ee | ||
|
|
c167bf9c1d | ||
|
|
f87b08bba7 | ||
|
|
588973b0cd | ||
|
|
c77a7b555b | ||
|
|
08b7460900 | ||
|
|
341bb84093 | ||
|
|
c303a9f746 | ||
|
|
abbd9eda34 | ||
|
|
c8a0072403 | ||
|
|
ff6c9582ca | ||
|
|
94c779d028 | ||
|
|
d44bbc5e5f | ||
|
|
d51a280636 | ||
|
|
eadb117560 | ||
|
|
2ed796535d | ||
|
|
7e830d6a28 | ||
|
|
8c77631f68 | ||
|
|
58221e5ed6 | ||
|
|
b1c09fa1a8 | ||
|
|
ef3b0e4b61 | ||
|
|
b280cbcc42 | ||
|
|
ce9e648ade | ||
|
|
239c8e7f64 | ||
|
|
afa8293b2b | ||
|
|
c50d0bc299 | ||
|
|
660edcb3af | ||
|
|
3a51e875b6 | ||
|
|
c7b8d4b584 | ||
|
|
38f56103e1 | ||
|
|
b16303f294 | ||
|
|
6232620806 | ||
|
|
8d1d3c906d | ||
|
|
fd12ace8bc | ||
|
|
1d656af51d | ||
|
|
af4679b153 | ||
|
|
c43bad26b5 | ||
|
|
7d7f6451a3 | ||
|
|
0890472c9b | ||
|
|
1162d111bd | ||
|
|
ca55321621 | ||
|
|
0734e982ca | ||
|
|
61f84a95cf | ||
|
|
63ba457034 | ||
|
|
337ba2e590 | ||
|
|
de0f013b5e | ||
|
|
948a53cc0a | ||
|
|
cab315bb8a | ||
|
|
2178c26a92 | ||
|
|
f3973abb4c | ||
|
|
3e0cd31c2b | ||
|
|
7e79355c71 | ||
|
|
d7e4233042 | ||
|
|
5ad90afbae | ||
|
|
c2396fb447 | ||
|
|
9ad189ef82 | ||
|
|
1e26d18031 | ||
|
|
6decb2f683 | ||
|
|
3420bc9aa8 | ||
|
|
a21ab8bcb1 | ||
|
|
c5baf2c791 | ||
|
|
ec218bade6 | ||
|
|
c179d78814 | ||
|
|
3a91cf780c | ||
|
|
39113a2d2b | ||
|
|
16cc1058be | ||
|
|
8734136908 | ||
|
|
a18d4651a3 | ||
|
|
a5a7110bea | ||
|
|
34c0a14766 | ||
|
|
be721e17f4 | ||
|
|
21dbbcb917 | ||
|
|
260edc13c5 | ||
|
|
e049f28bfa | ||
|
|
ed7c62febe | ||
|
|
b568b0b796 | ||
|
|
c1828b13ad | ||
|
|
22f94e6da6 | ||
|
|
8dad5a7436 | ||
|
|
0477275d08 | ||
|
|
13e0201e9b | ||
|
|
9c6d14a80b | ||
|
|
05a1c6f271 | ||
|
|
26e6a78d3a | ||
|
|
7a56a9c1e6 | ||
|
|
b36280f1a6 | ||
|
|
064b5e2393 | ||
|
|
6d6b516221 | ||
|
|
26f87f6789 | ||
|
|
1742e6d997 | ||
|
|
1daa755b70 | ||
|
|
89cc55d559 | ||
|
|
0c63044066 | ||
|
|
f8010a355a | ||
|
|
ca2b58fa4d | ||
|
|
53bc2550f9 | ||
|
|
874e6cd24d | ||
|
|
cf9f2745f3 | ||
|
|
01b932023a | ||
|
|
a4f5c1bf14 | ||
|
|
8982984f40 | ||
|
|
a74d20f8fb | ||
|
|
1224531ce1 | ||
|
|
8b0fd93011 | ||
|
|
b34f53629d | ||
|
|
e427304f29 | ||
|
|
b8df9d3f50 | ||
|
|
9f4e5feef0 | ||
|
|
023cd14f2a | ||
|
|
80042969ed | ||
|
|
6c00d03272 | ||
|
|
5925634c74 | ||
|
|
eb9112ae45 | ||
|
|
5297020040 | ||
|
|
01f393f10d | ||
|
|
7b42d8011e | ||
|
|
2d8ed3d1c3 | ||
|
|
7a8d9708e3 | ||
|
|
eadb1c05d4 | ||
|
|
94494c8837 | ||
|
|
ed4536cab4 | ||
|
|
dc8242c8c7 | ||
|
|
c43841c827 | ||
|
|
b341fa6eea | ||
|
|
f73062e156 | ||
|
|
0ace44e96e | ||
|
|
78af4402e4 | ||
|
|
6aff2b3e20 | ||
|
|
772d4e2813 | ||
|
|
0cbe625343 | ||
|
|
5756f35844 | ||
|
|
83a54e7fd5 | ||
|
|
716e118c81 | ||
|
|
9cf9cd1612 | ||
|
|
fba9fb3758 | ||
|
|
72b7a1f245 | ||
|
|
1f465cfeac | ||
|
|
6cd3702c27 | ||
|
|
bba89468f1 | ||
|
|
5ba771dc1b | ||
|
|
54991588e9 | ||
|
|
9d9a69e0c4 | ||
|
|
a202c81a0c | ||
|
|
e5152cd7b2 | ||
|
|
e3cb89d712 | ||
|
|
4188411b9b | ||
|
|
26805d9dd2 | ||
|
|
1a886cb5de | ||
|
|
a1ce189cbb |
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
# Every action is pinned to a commit, so nothing updates by itself. Once a
|
||||||
|
# week Dependabot proposes the newer releases in one pull request, moving each
|
||||||
|
# commit and the version in its comment together.
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns: ["*"]
|
||||||
@@ -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-26.04
|
||||||
|
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,134 @@
|
|||||||
|
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:
|
||||||
|
# Our own branches run on Namespace. A pull request from a fork stays on
|
||||||
|
# GitHub's runner: it is somebody else's code, and it has no business on
|
||||||
|
# our account's machines. No cache volume either way: what one pull
|
||||||
|
# request leaves in a cache the next run would trust.
|
||||||
|
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-26.04' || 'namespace-profile-linux-amd64-16x32' }}
|
||||||
|
timeout-minutes: 75
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
# Fail early and loudly if the runner ever has less disk than this needs.
|
||||||
|
- 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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
|
- 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 the runner's older one, 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 \
|
||||||
|
ghcr.io/archlinux/archlinux:latest bash -euo pipefail -c '
|
||||||
|
# pacman 7 fails every sync on a kernel without Landlock, which
|
||||||
|
# these runners have; builder/build-iso.sh does the same.
|
||||||
|
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||||
|
printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman
|
||||||
|
chmod 0755 /usr/local/bin/pacman
|
||||||
|
fi
|
||||||
|
# The tools come from our own Arch mirror first, the public ones
|
||||||
|
# after it. A mirror that drops one file must not fail the test:
|
||||||
|
# try again.
|
||||||
|
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
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 && break
|
||||||
|
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||||
|
echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15
|
||||||
|
done
|
||||||
|
# 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
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
|
||||||
@@ -1,41 +1,322 @@
|
|||||||
name: Nightly ISO Build
|
name: Nightly ISO Build
|
||||||
|
|
||||||
|
# Every night, for two channels: installs an ISO unattended and boots what it
|
||||||
|
# installed (the install test), turns that installed disk into a VM image, and
|
||||||
|
# publishes it with a latest.json saying what is in it.
|
||||||
|
#
|
||||||
|
# edge the ISO is built here, from the edge channel: its packages, its
|
||||||
|
# mirrors and the edge builds of Omarchy (omarchy-dev)
|
||||||
|
# stable the ISO is the last release: omacom/omarchy's latest release names
|
||||||
|
# the version, iso.omarchy.org has the ISO, its signature is checked.
|
||||||
|
# Done once for each release, skipped on the nights after.
|
||||||
|
#
|
||||||
|
# https://nightly.omarchy.org/<channel>/latest.json
|
||||||
|
# https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.iso
|
||||||
|
# https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.qcow2
|
||||||
|
# https://nightly.omarchy.org/edge/<version>/unpacked/ kernel, initramfs, vm-boot
|
||||||
|
# https://nightly.omarchy.org/stable/<version>/<run>/omarchy-<version>.qcow2
|
||||||
|
# https://nightly.omarchy.org/stable/<version>/<run>/unpacked/
|
||||||
|
#
|
||||||
|
# Nothing is published unless the ISO installed and the image passed its
|
||||||
|
# checks. A version's files are written once and never replaced: the CDN keeps
|
||||||
|
# them for a year. Only latest.json changes, and it is written last.
|
||||||
|
#
|
||||||
|
# No pull_request trigger: this workflow holds the key to the bucket, so it
|
||||||
|
# never runs for a fork. To try a change, run it by hand on a branch; that
|
||||||
|
# builds and checks everything and publishes nothing.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '1 0 * * *' # Daily at 00:01 UTC
|
- cron: "1 0 * * *" # Daily at 00:01 UTC
|
||||||
workflow_dispatch: # Allow manual triggering
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish:
|
||||||
|
description: "Publish to nightly.omarchy.org (only from quattro)"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: nightly-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
SITE: https://nightly.omarchy.org
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-iso:
|
# Which channels this run makes, under which version, and where each goes:
|
||||||
runs-on: ubuntu-latest
|
# test/vm-image/nightly-plan decides, from what is fetched here.
|
||||||
|
plan:
|
||||||
container:
|
name: Plan
|
||||||
image: archlinux/archlinux:latest
|
runs-on: ubuntu-26.04
|
||||||
options: --privileged
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
channels: ${{ steps.plan.outputs.channels }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
- id: plan
|
||||||
- name: Set up Docker Buildx
|
env:
|
||||||
uses: docker/setup-buildx-action@v3
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
- name: Build ISO in Arch Linux container
|
set -euo pipefail
|
||||||
run: |
|
release=$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name)
|
||||||
docker run --rm \
|
published=$(curl -fsL "$SITE/stable/latest.json?run=$GITHUB_RUN_ID" | jq -r .version || true)
|
||||||
--privileged \
|
channels=$(./test/vm-image/nightly-plan "$(date -u +%Y.%m.%d)" "${{ github.run_number }}" "${{ github.run_attempt }}" \
|
||||||
-v "${{ github.workspace }}:/workspace" \
|
"$GITHUB_EVENT_NAME" "$release" "$published")
|
||||||
-w /workspace \
|
echo "channels=$channels" | tee -a "$GITHUB_OUTPUT"
|
||||||
archlinux:latest \
|
|
||||||
bash -c "
|
build:
|
||||||
pacman -Syu --noconfirm && \
|
name: ${{ matrix.channel }} ${{ matrix.version }}
|
||||||
chmod +x build_iso.sh && \
|
needs: plan
|
||||||
./build_iso.sh
|
strategy:
|
||||||
"
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
- name: Upload ISO artifact
|
include: ${{ fromJSON(needs.plan.outputs.channels) }}
|
||||||
uses: actions/upload-artifact@v4
|
runs-on: namespace-profile-linux-amd64-16x32
|
||||||
with:
|
timeout-minutes: 90
|
||||||
name: omarchy-iso-${{ github.run_number }}
|
env:
|
||||||
path: out/*.iso
|
CHANNEL: ${{ matrix.channel }}
|
||||||
retention-days: 30
|
VERSION: ${{ matrix.version }}
|
||||||
|
RELEASE: ${{ matrix.release }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
- name: Runner
|
||||||
|
run: |
|
||||||
|
uname -srm
|
||||||
|
echo "$(nproc) CPUs, $(free -h | awk 'NR==2 {print $2}') memory"
|
||||||
|
grep -m1 'model name' /proc/cpuinfo || true
|
||||||
|
echo "LSMs: $(cat /sys/kernel/security/lsm 2>/dev/null || echo unreadable)"
|
||||||
|
ls -l /dev/kvm
|
||||||
|
|
||||||
|
- name: Check disk space
|
||||||
|
run: |
|
||||||
|
df -h /
|
||||||
|
avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
|
||||||
|
[ "$avail_gb" -ge 50 ] || { echo "::error::only ${avail_gb} GB free, the build, the install test and the image need ~50 GB"; exit 1; }
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
if: matrix.channel == 'edge'
|
||||||
|
|
||||||
|
# No package cache, on purpose: every package the ISO is made of is
|
||||||
|
# fetched from the mirrors again each night, so one that has gone
|
||||||
|
# missing there fails this build instead of being served from a cache.
|
||||||
|
- name: Build the edge ISO
|
||||||
|
if: matrix.channel == 'edge'
|
||||||
|
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make --edge
|
||||||
|
|
||||||
|
# Good only if the signature is the release key's, the one in this repo.
|
||||||
|
- name: Download the released ISO and check its signature
|
||||||
|
if: matrix.channel == 'stable'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p release
|
||||||
|
iso=omarchy-$VERSION.iso
|
||||||
|
curl -fSL --retry 3 -o "release/$iso" "https://iso.omarchy.org/$iso"
|
||||||
|
curl -fsSL --retry 3 -o "release/$iso.sig" "https://iso.omarchy.org/$iso.sig"
|
||||||
|
home=$(mktemp -d)
|
||||||
|
gpg --homedir "$home" --quiet --batch --import builder/omarchy.gpg
|
||||||
|
key=$(gpg --homedir "$home" --batch --with-colons --list-keys | awk -F: '$1 == "fpr" { print $10; exit }')
|
||||||
|
gpg --homedir "$home" --batch --status-fd 1 --verify "release/$iso.sig" "release/$iso" 2>/dev/null |
|
||||||
|
grep -q "^\[GNUPG:\] VALIDSIG $key " || { echo "::error::$iso is not signed by $key"; exit 1; }
|
||||||
|
echo "$iso is signed by $key"
|
||||||
|
rm -rf "$home" "release/$iso.sig"
|
||||||
|
|
||||||
|
# The same install test a pull request gets (install-test.yml), then the
|
||||||
|
# image: the installed disk that test leaves behind, sealed and checked.
|
||||||
|
- name: Install unattended, boot the installed system, seal it
|
||||||
|
run: |
|
||||||
|
sudo chmod 0666 /dev/kvm
|
||||||
|
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
|
||||||
|
-e OMARCHY_INTEGRATION_DISK_CACHE=none \
|
||||||
|
ghcr.io/archlinux/archlinux:latest bash -euo pipefail -c '
|
||||||
|
# pacman 7 fails every sync on a kernel without Landlock, which
|
||||||
|
# these runners have; builder/build-iso.sh does the same.
|
||||||
|
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||||
|
printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman
|
||||||
|
chmod 0755 /usr/local/bin/pacman
|
||||||
|
fi
|
||||||
|
# The tools come from our own Arch mirror first, the public ones
|
||||||
|
# after it. A mirror that drops one file must not fail the test:
|
||||||
|
# try again.
|
||||||
|
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
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 binutils >/dev/null && break
|
||||||
|
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||||
|
echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15
|
||||||
|
done
|
||||||
|
# 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
|
||||||
|
|
||||||
|
base=$(ls -d test-runs/*-integration)
|
||||||
|
./test/vm-image/vm-seal "$base" sealed
|
||||||
|
./test/vm-image/vm-verify sealed | tee vm-verify.txt
|
||||||
|
./test/vm-image/vm-boot sealed "pacman -Qq omarchy omarchy-dev 2>/dev/null | xargs pacman -Q; pacman -Qo /usr/lib/modules/\$(uname -r)/pkgbase; uname -r" >vm-packages.txt
|
||||||
|
cat vm-packages.txt
|
||||||
|
'
|
||||||
|
|
||||||
|
# The container ran as root; what it wrote belongs to root until here.
|
||||||
|
- name: Hand the results to the runner's user
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
mkdir -p test-runs sealed
|
||||||
|
sudo chown -R "$(id -u):$(id -g)" test-runs sealed release vm-*.txt 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Lay out the release and describe it
|
||||||
|
env:
|
||||||
|
ISO_COMMIT: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./test/vm-image/nightly-layout "$CHANNEL" "$VERSION" "$RELEASE" release/*.iso sealed vm-packages.txt out
|
||||||
|
dir=out/$RELEASE
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## Nightly $CHANNEL $VERSION"
|
||||||
|
echo
|
||||||
|
echo "| File | Size |"
|
||||||
|
echo "| --- | ---: |"
|
||||||
|
(cd "$dir" && find . -type f -printf '| `%P` | %s |\n' | sort) | awk -F'|' '{ printf "|%s| %.0f MiB |\n", $2, $3 / 1048576 }'
|
||||||
|
echo
|
||||||
|
echo '```'
|
||||||
|
cat vm-verify.txt
|
||||||
|
echo '```'
|
||||||
|
} >>"$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Keep the release for the publish job
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: nightly-${{ matrix.channel }}-${{ github.run_id }}
|
||||||
|
path: out/
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 2
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Keep the install test's logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: nightly-install-test-${{ matrix.channel }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: |
|
||||||
|
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
|
||||||
|
vm-verify.txt
|
||||||
|
vm-packages.txt
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# The only job that holds the bucket's key, and it builds nothing: it copies
|
||||||
|
# what the job above made, checks what the CDN then serves, and only then
|
||||||
|
# points latest.json at it.
|
||||||
|
# One channel failing does not hold back the other: each is published if
|
||||||
|
# its own build left a release behind.
|
||||||
|
publish:
|
||||||
|
name: Publish ${{ matrix.channel }}
|
||||||
|
needs: [plan, build]
|
||||||
|
if: ${{ !cancelled() && github.ref == 'refs/heads/quattro' && (github.event_name == 'schedule' || inputs.publish) }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include: ${{ fromJSON(needs.plan.outputs.channels) }}
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
timeout-minutes: 45
|
||||||
|
environment: nightly
|
||||||
|
# actions: read is for finding and fetching the build job's artifact.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
CHANNEL: ${{ matrix.channel }}
|
||||||
|
VERSION: ${{ matrix.version }}
|
||||||
|
RELEASE: ${{ matrix.release }}
|
||||||
|
RCLONE_CONFIG_R2_TYPE: s3
|
||||||
|
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
|
||||||
|
RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
BUCKET: omarchy-nightly
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
sparse-checkout: test/vm-image
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Not the download action: GitHub's storage limits each connection to a
|
||||||
|
# few megabytes a second, and the action took 10 minutes over 4.6 GB.
|
||||||
|
# nightly-fetch takes the same artifact over 16 connections.
|
||||||
|
- name: Fetch the release from the build job
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
# Not /tmp: on Ubuntu 26.04 it is held in memory, and the zip is
|
||||||
|
# gigabytes.
|
||||||
|
TMPDIR: ${{ runner.temp }}
|
||||||
|
run: |
|
||||||
|
df -h "$TMPDIR" .
|
||||||
|
command -v aria2c unzip >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq aria2 unzip >/dev/null; }
|
||||||
|
./test/vm-image/nightly-fetch "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "nightly-$CHANNEL-$GITHUB_RUN_ID" out
|
||||||
|
|
||||||
|
# A current rclone, by version and checksum. Ubuntu's is 1.60 from 2022:
|
||||||
|
# with it R2 answered every first upload "501 Not Implemented", though
|
||||||
|
# it had stored the file, and the publish only passed on rclone's retry.
|
||||||
|
# 1.60 sends "x-amz-acl: private" with each upload; current rclone does
|
||||||
|
# not send it to Cloudflare.
|
||||||
|
# Not RCLONE_VERSION: rclone reads every RCLONE_<FLAG> variable as that
|
||||||
|
# flag, and "--version=v1.75.2" stops it before it does anything.
|
||||||
|
- name: rclone
|
||||||
|
env:
|
||||||
|
PINNED_RCLONE: v1.75.2
|
||||||
|
PINNED_RCLONE_SHA256: efbfe852181f7191eb3c9043ed1ab49c9b2d0ba045c61c926a5da111c939ec5c
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -n "$RCLONE_CONFIG_R2_ACCESS_KEY_ID" ] || { echo "::error::the nightly environment has no bucket key"; exit 1; }
|
||||||
|
curl -fsSL --retry 3 -o rclone.deb "https://github.com/rclone/rclone/releases/download/$PINNED_RCLONE/rclone-$PINNED_RCLONE-linux-amd64.deb"
|
||||||
|
echo "$PINNED_RCLONE_SHA256 rclone.deb" | sha256sum -c -
|
||||||
|
sudo dpkg -i rclone.deb >/dev/null
|
||||||
|
rclone version | head -1
|
||||||
|
|
||||||
|
# --immutable: a name that exists with other bytes is an error, not an
|
||||||
|
# overwrite.
|
||||||
|
# nightly-rclone is rclone, with a warning on the run's page when an
|
||||||
|
# upload only passed on a retry: the files are compared afterwards either
|
||||||
|
# way, but it should not go unnoticed again.
|
||||||
|
- name: Upload this version
|
||||||
|
run: |
|
||||||
|
./test/vm-image/nightly-rclone copy "out/$RELEASE" "r2:$BUCKET/$RELEASE" \
|
||||||
|
--immutable --s3-no-check-bucket --transfers 8 --s3-upload-concurrency 8 --s3-chunk-size 64M --stats 30s --stats-one-line
|
||||||
|
|
||||||
|
# Fetched the way a user fetches them. It proves what is served, and it
|
||||||
|
# is the first request the CDN sees for each file.
|
||||||
|
- name: Check what the CDN serves
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
dir=out/$RELEASE
|
||||||
|
check() { # url, expected sha256
|
||||||
|
got=$(curl -fsSL --retry 3 "$1" | sha256sum | cut -d" " -f1)
|
||||||
|
[ "$got" = "$2" ] || { echo "::error::$1 serves $got, expected $2"; exit 1; }
|
||||||
|
echo "ok $1"
|
||||||
|
}
|
||||||
|
# Every file of the version, the checksum files too.
|
||||||
|
(cd "$dir" && find . -type f -printf '%P\n' | sort) >files.txt
|
||||||
|
[ -s files.txt ] || { echo "::error::nothing to check under $dir"; exit 1; }
|
||||||
|
while read -r file; do
|
||||||
|
check "$SITE/$RELEASE/$file" "$(sha256sum "$dir/$file" | cut -d" " -f1)"
|
||||||
|
done <files.txt
|
||||||
|
echo "$(wc -l <files.txt) files published and checked"
|
||||||
|
|
||||||
|
- name: Point latest.json at it
|
||||||
|
run: |
|
||||||
|
./test/vm-image/nightly-rclone copyto "out/$CHANNEL/latest.json" "r2:$BUCKET/$CHANNEL/latest.json" --s3-no-check-bucket
|
||||||
|
served=$(curl -fsSL "$SITE/$CHANNEL/latest.json?run=$GITHUB_RUN_ID" | jq -r .version)
|
||||||
|
[ "$served" = "$VERSION" ] || { echo "::error::latest.json serves version $served, expected $VERSION"; exit 1; }
|
||||||
|
echo "Published [$VERSION]($SITE/$CHANNEL/latest.json)" >>"$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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-26.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
# 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" ghcr.io/archlinux/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"
|
||||||
|
# From our own Arch mirror first, and a second and third try: a
|
||||||
|
# mirror that drops one file must not fail the tests.
|
||||||
|
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
pacman -Syu --noconfirm --needed python parted diffutils jq zip unzip >/dev/null && break
|
||||||
|
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
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
|
||||||
|
'
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
name: Upgrade test
|
||||||
|
|
||||||
|
# What an existing user gets: installs a release from its ISO, moves it to
|
||||||
|
# another channel the way a user would (omarchy-channel-set), reboots it, and
|
||||||
|
# checks what came back. test/upgrade-test does the work and says what it
|
||||||
|
# checks.
|
||||||
|
#
|
||||||
|
# Started by hand only: from the latest release to edge unless told otherwise.
|
||||||
|
#
|
||||||
|
# When there is a pull request for the branch it ran on, the result goes there
|
||||||
|
# as one comment, updated in place on later runs, with a picture of the desktop
|
||||||
|
# before and after. The pictures are committed to the upgrade-test-screenshots
|
||||||
|
# branch, which is what lets a comment show them.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
to:
|
||||||
|
description: "Channel to move to"
|
||||||
|
type: choice
|
||||||
|
options: [edge, rc]
|
||||||
|
default: edge
|
||||||
|
from:
|
||||||
|
description: "Release to start from, like 4.0.4 (default: the latest)"
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: upgrade-test-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
TO: ${{ inputs.to || 'edge' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upgrade:
|
||||||
|
name: Install, upgrade, reboot
|
||||||
|
runs-on: namespace-profile-linux-amd64-16x32
|
||||||
|
timeout-minutes: 60
|
||||||
|
outputs:
|
||||||
|
from: ${{ steps.release.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
- id: release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
FROM: ${{ inputs.from }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version=${FROM:-$(gh api repos/omacom/omarchy/releases/latest --jq .tag_name)}
|
||||||
|
version=${version#v}
|
||||||
|
[[ $version =~ ^[0-9]+(\.[0-9]+)+$ ]] || { echo "::error::'$version' is not a release version"; exit 1; }
|
||||||
|
echo "version=$version" | tee -a "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Good only if the signature is the release key's, the one in this repo.
|
||||||
|
- name: Download the released ISO and check its signature
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.release.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p release
|
||||||
|
iso=omarchy-$VERSION.iso
|
||||||
|
curl -fSL --retry 3 -o "release/$iso" "https://iso.omarchy.org/$iso"
|
||||||
|
curl -fsSL --retry 3 -o "release/$iso.sig" "https://iso.omarchy.org/$iso.sig"
|
||||||
|
home=$(mktemp -d)
|
||||||
|
gpg --homedir "$home" --quiet --batch --import builder/omarchy.gpg
|
||||||
|
key=$(gpg --homedir "$home" --batch --with-colons --list-keys | awk -F: '$1 == "fpr" { print $10; exit }')
|
||||||
|
gpg --homedir "$home" --batch --status-fd 1 --verify "release/$iso.sig" "release/$iso" 2>/dev/null |
|
||||||
|
grep -q "^\[GNUPG:\] VALIDSIG $key " || { echo "::error::$iso is not signed by $key"; exit 1; }
|
||||||
|
echo "$iso is signed by $key"
|
||||||
|
rm -rf "$home" "release/$iso.sig"
|
||||||
|
|
||||||
|
# The install test's own install, which leaves the installed disk behind;
|
||||||
|
# the upgrade then starts from that disk. A failed upgrade does not stop
|
||||||
|
# here: the evidence is kept first, and the last step fails the job.
|
||||||
|
- name: Install the release, then upgrade it
|
||||||
|
run: |
|
||||||
|
sudo chmod 0666 /dev/kvm
|
||||||
|
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
|
||||||
|
-e OMARCHY_INTEGRATION_DISK_CACHE=none -e TO \
|
||||||
|
ghcr.io/archlinux/archlinux:latest bash -euo pipefail -c '
|
||||||
|
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||||
|
printf "#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox \"\$@\"\n" >/usr/local/bin/pacman
|
||||||
|
chmod 0755 /usr/local/bin/pacman
|
||||||
|
fi
|
||||||
|
sed -i "1i Server = https://mirror.omarchy.org/\$repo/os/\$arch" /etc/pacman.d/mirrorlist
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
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 && break
|
||||||
|
[ "$attempt" -lt 3 ] || { echo "::error::the test container could not install its tools"; exit 1; }
|
||||||
|
echo "pacman failed, attempt $attempt of 3; trying again in 15 s"; sleep 15
|
||||||
|
done
|
||||||
|
printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add
|
||||||
|
chmod 0755 /usr/local/bin/omarchy-pkg-add
|
||||||
|
./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800
|
||||||
|
|
||||||
|
status=0
|
||||||
|
VM_STATE_DIR=/w ./test/upgrade-test "$(ls -d test-runs/*-integration)" "$TO" upgrade-evidence || status=$?
|
||||||
|
echo "$status" >upgrade-evidence/status
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Hand the results to the runner's user
|
||||||
|
if: always()
|
||||||
|
run: sudo chown -R "$(id -u):$(id -g)" upgrade-evidence test-runs 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Keep the evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: upgrade-evidence/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: The upgrade's result
|
||||||
|
run: |
|
||||||
|
cat upgrade-evidence/report.md >>"$GITHUB_STEP_SUMMARY"
|
||||||
|
[ "$(cat upgrade-evidence/status)" = 0 ] || { echo "::error::the upgrade test failed, see the summary"; exit 1; }
|
||||||
|
|
||||||
|
# The only job that can write: it commits the two pictures and writes the
|
||||||
|
# comment. It runs nothing from the test; it handles two PNG files and a
|
||||||
|
# Markdown report.
|
||||||
|
report:
|
||||||
|
name: Report
|
||||||
|
needs: upgrade
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
FROM: ${{ needs.upgrade.outputs.from }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
SHOTS_BRANCH: upgrade-test-screenshots
|
||||||
|
steps:
|
||||||
|
# No evidence at all still gets a comment saying so.
|
||||||
|
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
name: upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: evidence
|
||||||
|
|
||||||
|
# A comment can only show a picture that has a public address. A commit
|
||||||
|
# on a branch of this repository gives it one, and the address of a
|
||||||
|
# commit never changes.
|
||||||
|
- name: Commit the pictures
|
||||||
|
id: pictures
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -s evidence/before.png ] || [ -s evidence/after.png ] || { echo "no pictures to commit"; exit 0; }
|
||||||
|
url=https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git
|
||||||
|
dir=$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
|
||||||
|
git init -q shots && cd shots
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
# Runs on other branches push here too. A push that loses the race
|
||||||
|
# is made again on top of whatever won: the folder is this run's own,
|
||||||
|
# so there is never anything to merge.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if git fetch -q --depth 1 "$url" "$SHOTS_BRANCH" 2>/dev/null; then
|
||||||
|
git checkout -q -B "$SHOTS_BRANCH" FETCH_HEAD
|
||||||
|
else
|
||||||
|
git checkout -q --orphan "$SHOTS_BRANCH" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
mkdir -p "$dir"
|
||||||
|
cp ../evidence/*.png "$dir/"
|
||||||
|
git add "$dir"
|
||||||
|
git commit -q -m "Upgrade test $GITHUB_RUN_ID, attempt $GITHUB_RUN_ATTEMPT: $FROM to $TO"
|
||||||
|
if git push -q "$url" "HEAD:refs/heads/$SHOTS_BRANCH"; then break; fi
|
||||||
|
[ "$attempt" -lt 5 ] || { echo "::error::could not push the pictures after 5 tries"; exit 1; }
|
||||||
|
echo "The push lost a race, trying again"
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
echo "base=https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$(git rev-parse HEAD)/$dir" >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Write the comment
|
||||||
|
env:
|
||||||
|
PICTURES: ${{ steps.pictures.outputs.base }}
|
||||||
|
RESULT: ${{ needs.upgrade.result }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
marker="<!-- upgrade-test -->"
|
||||||
|
{
|
||||||
|
echo "$marker"
|
||||||
|
echo "## Upgrade test: $FROM → $TO"
|
||||||
|
echo
|
||||||
|
if [ -s evidence/report.md ]; then
|
||||||
|
cat evidence/report.md
|
||||||
|
else
|
||||||
|
echo "**The test did not get as far as a report** (job result: $RESULT)."
|
||||||
|
fi
|
||||||
|
if [ -n "$PICTURES" ] && [ -s evidence/before.png ] && [ -s evidence/after.png ]; then
|
||||||
|
echo
|
||||||
|
echo "| Before, $FROM | After, on $TO |"
|
||||||
|
echo "| --- | --- |"
|
||||||
|
echo "|  |  |"
|
||||||
|
fi
|
||||||
|
# The picture of a reboot that stuck stands by itself: it is the
|
||||||
|
# one worth having when there is no "after" to show.
|
||||||
|
if [ -n "$PICTURES" ] && [ -s evidence/stuck.png ]; then
|
||||||
|
echo
|
||||||
|
echo "The reboot did not come back by itself. This is what the screen showed:"
|
||||||
|
echo
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "[Run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID), attempt $GITHUB_RUN_ATTEMPT, at \`${GITHUB_SHA:0:7}\`."
|
||||||
|
} >comment.md
|
||||||
|
cat comment.md >>"$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
# The open pull request for the branch this was started on, if any.
|
||||||
|
pr=$(gh pr list -R "$GITHUB_REPOSITORY" --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
|
||||||
|
[ -n "$pr" ] || { echo "No pull request for $BRANCH: the result is on this run's page."; exit 0; }
|
||||||
|
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" --paginate \
|
||||||
|
--jq "[.[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\")))][0].id // empty")
|
||||||
|
if [ -n "$existing" ]; then
|
||||||
|
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -F body=@comment.md >/dev/null
|
||||||
|
echo "Updated the comment on #$pr"
|
||||||
|
else
|
||||||
|
gh api "repos/$GITHUB_REPOSITORY/issues/$pr/comments" -F body=@comment.md >/dev/null
|
||||||
|
echo "Commented on #$pr"
|
||||||
|
fi
|
||||||
@@ -1,2 +1,9 @@
|
|||||||
release/**
|
release/**
|
||||||
vm-saves/**
|
vm-saves/**
|
||||||
|
|
||||||
|
# Python bytecode (orchestrator)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Acceptance test harness artifacts
|
||||||
|
test-runs/**
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Anton Hvornum
|
Copyright (c) 2026 Anton Hvornum
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,39 +1,299 @@
|
|||||||
# Omarchy ISO
|
# Omarchy ISO
|
||||||
|
|
||||||
The Omarchy ISO streamlines [the installation of Omarchy](https://learn.omacom.io/2/the-omarchy-manual/50/getting-started). It includes the Omarchy Configurator as a front-end to archinstall and automatically launches the [Omarchy Installer](https://github.com/basecamp/omarchy) after base arch has been setup.
|
[](https://github.com/omacom/omarchy-iso/actions/workflows/nightly-build.yml?query=branch%3Aquattro+event%3Aschedule)
|
||||||
|
|
||||||
## Downloading the latest ISO
|
The Omarchy ISO is the only supported way to install Omarchy. It includes the Omarchy Configurator and installs Arch Linux and the Omarchy packages from the bundled mirror. It then runs target system setup in the chroot, creates the user, and runs `omarchy-provision-user` for that user.
|
||||||
|
|
||||||
See the ISO link on [omarchy.org](https://omarchy.org).
|
## Get Omarchy
|
||||||
|
|
||||||
|
1. [Download the ISO](https://omarchy.org).
|
||||||
|
2. Write it to a USB stick. Use [balenaEtcher](https://etcher.balena.io/) on Mac or Windows, or [caligula](https://github.com/ifd3f/caligula) on Linux.
|
||||||
|
3. Turn off Secure Boot and/or TPM in the BIOS.
|
||||||
|
4. Boot from the stick.
|
||||||
|
5. Answer the configuration questions and select a drive.
|
||||||
|
|
||||||
|
The install takes under a minute on the fastest machines and up to 5 minutes on an older computer.
|
||||||
|
|
||||||
|
### Verify the download
|
||||||
|
|
||||||
|
Verify the ISO before you write it to the stick. Otherwise, a corrupted download causes a pacman "invalid or corrupted package" error several minutes into the install.
|
||||||
|
|
||||||
|
Every release ISO has a `.sha256` file and a `.sig` file next to it. Run these commands to download and verify the ISO of the latest release of [omacom/omarchy](https://github.com/omacom/omarchy/releases):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
version=$(curl -fsSL https://api.github.com/repos/omacom/omarchy/releases/latest | jq -r .tag_name)
|
||||||
|
version=${version#v}
|
||||||
|
curl -fLO "https://iso.omarchy.org/omarchy-$version.iso"
|
||||||
|
curl -fsSLO "https://iso.omarchy.org/omarchy-$version.iso.sha256"
|
||||||
|
sha256sum -c "omarchy-$version.iso.sha256"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the `.sig` file to verify the ISO against the Omarchy signing key.
|
||||||
|
|
||||||
|
### More in the manual
|
||||||
|
|
||||||
|
- [Getting started](https://omarchy.org/manual/getting-started/): the install step by step, installing for another owner, and installing without encryption.
|
||||||
|
- [Dual-boot install](https://omarchy.org/manual/dual-boot-install/): install Omarchy in the free space of a drive, beside Windows or another OS.
|
||||||
|
- [Unattended installs](https://omarchy.org/manual/unattended-installs/): let the ISO install itself from a configuration on a second drive, with no keyboard and no wizard.
|
||||||
|
|
||||||
|
# For maintainers
|
||||||
|
|
||||||
|
The rest of this file covers building, testing and publishing the ISO.
|
||||||
|
|
||||||
|
## What is published, and where
|
||||||
|
|
||||||
|
| What | Where | When |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Release ISO | `https://iso.omarchy.org/omarchy-<version>.iso` | Each release |
|
||||||
|
| Edge ISO | `https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.iso` | Every night |
|
||||||
|
| VM image of edge | `https://nightly.omarchy.org/edge/<version>/omarchy-edge-<version>.qcow2` | Every night |
|
||||||
|
| VM image of the latest release | `https://nightly.omarchy.org/stable/<version>/<run>/omarchy-<version>.qcow2` | Once per release |
|
||||||
|
| `latest.json` | `https://nightly.omarchy.org/edge/latest.json` and `.../stable/latest.json` | With each nightly build |
|
||||||
|
|
||||||
|
Every ISO and every VM image has a `.sha256` file next to it. The release ISO also has a `.sig` file. Each VM image has an `unpacked/` folder next to it.
|
||||||
|
|
||||||
|
On nightly.omarchy.org, a published file is never replaced. Only `latest.json` changes. It is written last, after every file of the version has been fetched back through the CDN and compared.
|
||||||
|
|
||||||
|
## The nightly build
|
||||||
|
|
||||||
|
`.github/workflows/nightly-build.yml` runs every night for two channels:
|
||||||
|
|
||||||
|
- `edge`: builds the ISO from the edge channel: the edge packages and mirrors, and the edge builds of Omarchy (`omarchy-dev`).
|
||||||
|
- `stable`: uses the latest release. The latest release of omacom/omarchy determines the version. The ISO comes from iso.omarchy.org and is used only if its signature matches the release key. A scheduled run skips a release that is already published. A manual run builds it again.
|
||||||
|
|
||||||
|
Each ISO goes through the same install test that runs for pull requests: an unattended install, then a boot of the installed system. `test/vm-image/vm-seal` then turns the installed disk into a VM image, and `test/vm-image/vm-verify` boots that image and checks it. Nothing is published unless every check passes.
|
||||||
|
|
||||||
|
A separate job publishes. It is the only job with the bucket's key, and it runs only on `quattro`. To try a change, run the workflow manually on a branch. That run builds and checks everything and publishes nothing.
|
||||||
|
|
||||||
|
`test/vm-image/nightly-plan` decides which channels a run builds and where they are published. `test/vm-image/nightly-layout` lays out the files and writes `latest.json`. `test/unit/nightly-test.sh` tests both.
|
||||||
|
|
||||||
|
## latest.json
|
||||||
|
|
||||||
|
| Field | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `version` | Edge: the build's date and run number, `2026.10.09.431`. Stable: the release, `4.0.4`. |
|
||||||
|
| `channel` | `edge` or `stable`. |
|
||||||
|
| `built_at` | When this file was written, after the image passed its checks. UTC, `2026-10-09T18:02:35Z`. |
|
||||||
|
| `url` | The ISO that was installed. Edge: on nightly.omarchy.org. Stable: the release ISO on iso.omarchy.org. |
|
||||||
|
| `sha256`, `size` | The ISO's SHA-256 and its size in bytes. |
|
||||||
|
| `iso_commit` | The omarchy-iso commit the nightly build ran from. |
|
||||||
|
| `omarchy_version` | The version of the Omarchy package in the image. |
|
||||||
|
| `omarchy_commit` | Edge: the omarchy commit that package was built from. Stable: empty. |
|
||||||
|
| `kernel` | The image's kernel release, as reported by `uname -r`. |
|
||||||
|
| `vm.url`, `vm.sha256`, `vm.size` | The VM image: its URL, SHA-256 and size in bytes. |
|
||||||
|
| `vm.unpacked` | The URL of the image's `unpacked/` folder, ending in `/`. |
|
||||||
|
| `vm.unpacked_files` | Every file in that folder. |
|
||||||
|
| `vm.user`, `vm.password` | The account for the desktop and `sudo`. |
|
||||||
|
| `vm.ssh` | SSH authentication: keys only. Password authentication is off. |
|
||||||
|
|
||||||
|
The version is also in the edge ISO's file name.
|
||||||
|
|
||||||
|
## VM images
|
||||||
|
|
||||||
|
VM images are for testing. Users download the release ISO from iso.omarchy.org.
|
||||||
|
|
||||||
|
A VM image is Omarchy already installed, as one `.qcow2` file. There is an image of the edge channel, rebuilt every night, and an image of the latest release. Every boot is a new machine, with a new machine ID and new SSH host keys.
|
||||||
|
|
||||||
|
The user is `omarchy` and the password is `omarchy`. SSH takes keys only.
|
||||||
|
|
||||||
|
### Boot the image by itself
|
||||||
|
|
||||||
|
The image boots through UEFI with no other file. Run these commands on a Linux machine with QEMU, read and write access to `/dev/kvm`, `curl` and `jq`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
latest=https://nightly.omarchy.org/edge/latest.json # or .../stable/latest.json
|
||||||
|
image=$(curl -fsSL "$latest" | jq -r .vm.url)
|
||||||
|
curl -fLO "$image"
|
||||||
|
curl -fsSLO "$image.sha256"
|
||||||
|
sha256sum -c "${image##*/}.sha256"
|
||||||
|
|
||||||
|
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd vars.fd
|
||||||
|
qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 4G \
|
||||||
|
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
|
||||||
|
-drive if=pflash,format=raw,file=vars.fd \
|
||||||
|
-drive file="${image##*/}",if=virtio,snapshot=on \
|
||||||
|
-device virtio-vga
|
||||||
|
```
|
||||||
|
|
||||||
|
The firmware paths are Arch's. `snapshot=on` keeps the downloaded image unchanged. The image is a 4 to 5 GB download.
|
||||||
|
|
||||||
|
### Boot it faster, with SSH
|
||||||
|
|
||||||
|
The `unpacked/` folder next to each image holds what a direct boot needs:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `vmlinuz`, `initrd`, `cmdline` | The image's own kernel, initramfs and kernel arguments. |
|
||||||
|
| `vm-boot` | A script that boots the image directly into its kernel and gives it an SSH key. |
|
||||||
|
| `SHA256SUMS` | Checksums of the files above. |
|
||||||
|
|
||||||
|
A direct boot skips the firmware and the boot loader: SSH answers after about 6 seconds instead of about 20. Download the folder next to the image, then run `vm-boot`. It also needs `qemu-img` and `ssh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unpacked=$(curl -fsSL "$latest" | jq -r .vm.unpacked)
|
||||||
|
for file in $(curl -fsSL "$latest" | jq -r '.vm.unpacked_files[]'); do curl -fsSLO "$unpacked$file"; done
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
chmod +x vm-boot
|
||||||
|
|
||||||
|
./vm-boot "${image##*/}" # boots, prints how to log in, runs until Ctrl-C
|
||||||
|
./vm-boot "${image##*/}" uname -r # boots, runs the command over SSH, powers off
|
||||||
|
```
|
||||||
|
|
||||||
|
`vm-boot` makes a new SSH key for each boot. It runs the machine on an overlay under `/var/tmp`, so the downloaded image is never changed, and deletes the overlay on exit. Set `VM_STATE_DIR` to put the overlay somewhere else.
|
||||||
|
|
||||||
|
To get the URL of the edge ISO, run `jq -r .url` on the same `latest.json`.
|
||||||
|
|
||||||
|
## Autoinstall
|
||||||
|
|
||||||
|
The [manual's page](https://omarchy.org/manual/unattended-installs/) is the one for users; this is the same feature in full.
|
||||||
|
|
||||||
|
The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
|
||||||
|
|
||||||
|
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one.
|
||||||
|
|
||||||
|
### Configuration files
|
||||||
|
|
||||||
|
These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`.
|
||||||
|
|
||||||
|
| File | Required | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard |
|
||||||
|
| `user_credentials.json` | Yes | Username and password hash |
|
||||||
|
| `user_full_name.txt` | No | Git full name |
|
||||||
|
| `user_email_address.txt` | No | Git email |
|
||||||
|
| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false |
|
||||||
|
| `authorized_keys` | No | SSH public keys in sshd's own format, one per line |
|
||||||
|
| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot |
|
||||||
|
|
||||||
|
Both required files must be present or the installer falls back to the configurator. (A `defer-provisioning` marker file can stand in for `user_credentials.json`: user creation is then deferred to first boot.) Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
|
||||||
|
|
||||||
|
Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption.
|
||||||
|
|
||||||
|
`authorized_keys` is the same file sshd reads — copy your own or write one key per line:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh-ed25519 AAAA... you@host
|
||||||
|
```
|
||||||
|
|
||||||
|
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend and Docker's DNS. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no keys in it fails the install rather than producing a machine nobody can reach.
|
||||||
|
|
||||||
|
When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs.
|
||||||
|
|
||||||
|
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
|
||||||
|
mkdir cidata
|
||||||
|
cp user_configuration.json user_credentials.json authorized_keys cidata/
|
||||||
|
genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proxmox example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qm create 101 --name my-omarchy \
|
||||||
|
--bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \
|
||||||
|
--ostype l26 --scsihw virtio-scsi-single \
|
||||||
|
--efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \
|
||||||
|
--scsi0 local-lvm:40,discard=on,iothread=1 \
|
||||||
|
--net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \
|
||||||
|
--ide2 local:iso/omarchy.iso,media=cdrom \
|
||||||
|
--ide3 local:iso/cidata.iso,media=cdrom \
|
||||||
|
--boot order='scsi0;ide2'
|
||||||
|
|
||||||
|
qm start 101
|
||||||
|
```
|
||||||
|
|
||||||
|
Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes.
|
||||||
|
|
||||||
|
Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot.
|
||||||
|
|
||||||
## Creating the ISO
|
## Creating the ISO
|
||||||
|
|
||||||
Run `./bin/omarchy-iso-make` and the output goes into `./release`. You can build from your local $OMARCHY_PATH for testing by using `--local-source` or from a checkout of the dev branch (instead of master) by using `--dev`.
|
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.
|
||||||
|
|
||||||
### Environment Variables
|
For local development, build the ISO from sibling checkouts:
|
||||||
|
|
||||||
You can customize the repositories used during the build process by passing in variables:
|
|
||||||
|
|
||||||
- `OMARCHY_INSTALLER_REPO` - GitHub repository for the installer (default: `basecamp/omarchy`)
|
|
||||||
- `OMARCHY_INSTALLER_REF` - Git ref (branch/tag) for the installer (default: `master`)
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```bash
|
```bash
|
||||||
OMARCHY_INSTALLER_REPO="myuser/omarchy-fork" OMARCHY_INSTALLER_REF="some-feature" ./bin/omarchy-iso-make
|
./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Testing the ISO
|
## Testing the ISO
|
||||||
|
|
||||||
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
|
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
|
||||||
|
|
||||||
|
Run `./test/all` for the 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
|
||||||
|
synthetic disk in `/tmp` with an existing ESP and data partition plus ample
|
||||||
|
unallocated space, then offers to start an interactive installation on it. The
|
||||||
|
fixture exercises Windows partition preservation but does not contain Windows.
|
||||||
|
|
||||||
|
## Acceptance testing the ISO
|
||||||
|
|
||||||
|
Run `./bin/omarchy-iso-test [release/omarchy.iso]` to install the ISO into a headless VM by driving the real interactive install flow — the harness reads each screen via QMP screendumps + OCR and answers with virtual keystrokes, so the configurator wizard, install dashboard, reboot prompt, and SDDM login are all exercised exactly as a user would. It then boots the installed system, sends real VM keyboard shortcuts for the primary shell and window-management actions, and runs the in-guest acceptance suite (`test/acceptance` in the omarchy repo). The suite checks session and service health, the complete core-package manifest, user defaults, representative applications, menus, panels, live weather, launchers, visual selectors, notifications, clipboard, and other interactive shell behavior.
|
||||||
|
|
||||||
|
Visual checkpoints are saved as `success-<step>.png` or `failure-<step>.png` alongside the serial and install logs in `test-runs/<iso>/runs/<timestamp>/`. Independent test files and applications continue after a failure so one broken surface does not hide the rest of the report. The harness then stops the VM and opens the ordered screenshots in `imv` for quick visual review.
|
||||||
|
|
||||||
|
The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available. The install phase produces a reusable base image, so iterating against another checkout is fast:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/omarchy-iso-test release/omarchy.iso --install-only # once per ISO
|
||||||
|
./bin/omarchy-iso-test release/omarchy.iso --reuse-base \
|
||||||
|
--sync-omarchy ../omarchy # fast loop against local tests
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Upgrade testing
|
||||||
|
|
||||||
|
`test/upgrade-test` checks what an existing user gets. It takes an installed system, moves it to another channel with `omarchy-channel-set`, reboots it, and checks what came back: the update's exit status, skipped migrations, that the reboot comes back by itself into an installed kernel, failed units, the desktop, the user's wallpaper and the boot menu.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./test/integration release/omarchy.iso boot # installs once, leaves the installed disk
|
||||||
|
./test/upgrade-test test-runs/omarchy-integration edge upgrade-evidence # edge or rc
|
||||||
|
```
|
||||||
|
|
||||||
|
The evidence folder gets `report.md`, the update's output, and a picture of the desktop before and after.
|
||||||
|
|
||||||
|
The "Upgrade test" workflow does both steps from a release's ISO. Start it by hand, from the latest release to edge by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh workflow run upgrade-test.yml -f to=edge
|
||||||
|
```
|
||||||
|
|
||||||
|
It only runs when started. When the branch it ran on has a pull request, the result is posted there as one comment and updated in place on later runs.
|
||||||
|
|
||||||
## Signing the ISO
|
## Signing the ISO
|
||||||
|
|
||||||
Run `./bin/omarchy-iso-sign [gpg-user] [release/omarchy.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
|
## Uploading the ISO
|
||||||
|
|
||||||
Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires you've configured rclone (use `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
|
## Full release of the ISO
|
||||||
|
|
||||||
Run `./bin/omarchy-iso-release` to create, test, sign, and upload the ISO in one flow.
|
Run `./bin/omarchy-iso-release VERSION` to create, test, sign, and upload the ISO in one flow. Add `--rc` to release an RC build instead.
|
||||||
+1
-1
Submodule archiso updated: 69047bdc03...424e78130d.
+139
-17
@@ -2,46 +2,168 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
ISO="$1"
|
ISO=""
|
||||||
REUSE="$2"
|
REUSE=""
|
||||||
|
SSH_HOST="${OMARCHY_VM_SSH_HOST:-127.0.0.1}"
|
||||||
|
SSH_PORT="${OMARCHY_VM_SSH_PORT:-2222}"
|
||||||
|
NETWORK="${OMARCHY_VM_NETWORK:-1}"
|
||||||
|
DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
|
||||||
|
OVMF_VARS="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
|
||||||
|
EXTRA_QEMU_ARGS=()
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
Usage: omarchy-iso-boot [options] [release/omarchy.iso] [reuse]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--reuse Reuse the existing VM disk
|
||||||
|
--ssh-port PORT Forward host PORT to guest port 22 (default: $SSH_PORT)
|
||||||
|
--ssh-host HOST Host address for SSH forwarding (default: $SSH_HOST)
|
||||||
|
--no-network Boot without a virtual NIC
|
||||||
|
--disk PATH VM disk path (default: $DISK)
|
||||||
|
--ovmf-vars PATH OVMF vars path (default: $OVMF_VARS)
|
||||||
|
-h, --help Show this help
|
||||||
|
-- Pass the remaining arguments directly to QEMU
|
||||||
|
|
||||||
|
SSH access after starting sshd in the guest:
|
||||||
|
ssh -p $SSH_PORT root@localhost
|
||||||
|
scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
case "$1" in
|
||||||
|
--reuse|reuse)
|
||||||
|
REUSE="reuse"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--ssh-port)
|
||||||
|
SSH_PORT="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ssh-port=*)
|
||||||
|
SSH_PORT="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--ssh-host)
|
||||||
|
SSH_HOST="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ssh-host=*)
|
||||||
|
SSH_HOST="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-network|--no-net)
|
||||||
|
NETWORK=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--disk)
|
||||||
|
DISK="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--disk=*)
|
||||||
|
DISK="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--ovmf-vars)
|
||||||
|
OVMF_VARS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ovmf-vars=*)
|
||||||
|
OVMF_VARS="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
EXTRA_QEMU_ARGS+=("$@")
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z $ISO ]]; then
|
||||||
|
ISO="$1"
|
||||||
|
else
|
||||||
|
echo "Unexpected argument: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z $ISO && -t 0 ]]; then
|
||||||
ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to boot")
|
ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to boot")
|
||||||
if gum confirm --default=false "Reuse existing disk?"; then
|
if gum confirm --default=false "Reuse existing disk?"; then
|
||||||
REUSE="reuse"
|
REUSE="reuse"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$ISO" ]; then
|
if [[ -z $ISO ]]; then
|
||||||
echo "Usage: omarchy-iso-boot [release/omarchy.iso] [reuse]"
|
usage
|
||||||
echo " SSH available at: ssh -p 2222 root@localhost (after installing openssh)"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! pacman -Q qemu-full >/dev/null || ! pacman -Q edk2-ovmf >/dev/null; then
|
if [[ -z $DISK || -z $OVMF_VARS ]]; then
|
||||||
sudo pacman -S --noconfirm --needed --quiet qemu-full edk2-ovmf
|
echo "Disk and OVMF vars paths must not be empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
omarchy-pkg-add qemu-full edk2-ovmf
|
||||||
|
|
||||||
|
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
|
||||||
|
while IFS='=' read -r name value; do
|
||||||
|
case "$name" in
|
||||||
|
WAYLAND_DISPLAY|DISPLAY|XAUTHORITY|XDG_SESSION_TYPE)
|
||||||
|
export "$name=$value"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(systemctl --user show-environment 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
|
||||||
|
echo "Unable to find a graphical session for QEMU." >&2
|
||||||
|
echo "Run omarchy-iso-boot from the Omarchy desktop session." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DISK="/tmp/omarchy-iso-boot.qcow2"
|
|
||||||
if [[ ! -f $DISK || $REUSE != "reuse" ]]; then
|
if [[ ! -f $DISK || $REUSE != "reuse" ]]; then
|
||||||
qemu-img create -f qcow2 "$DISK" 20G
|
qemu-img create -f qcow2 "$DISK" 40G
|
||||||
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd /tmp/OVMF_VARS.4m.fd
|
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$OVMF_VARS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
NET_ARGS=()
|
||||||
|
if [[ $NETWORK != "0" && $NETWORK != "false" && $NETWORK != "no" ]]; then
|
||||||
|
if [[ -n $SSH_PORT && $SSH_PORT != "0" ]]; then
|
||||||
|
NET_ARGS=(-netdev "user,id=net0,hostfwd=tcp:${SSH_HOST}:${SSH_PORT}-:22" -device virtio-net-pci,netdev=net0)
|
||||||
|
echo "SSH forwarding enabled: ssh -p $SSH_PORT root@localhost"
|
||||||
|
echo "SCP example: scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/"
|
||||||
|
else
|
||||||
|
NET_ARGS=(-netdev user,id=net0 -device virtio-net-pci,netdev=net0)
|
||||||
|
echo "Networking enabled without SSH port forwarding."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Networking disabled."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Boot from ISO before we've installed, then boot from drive after
|
# Boot from ISO before we've installed, then boot from drive after
|
||||||
qemu-system-x86_64 \
|
qemu-system-x86_64 \
|
||||||
-cpu host -enable-kvm -machine q35,accel=kvm \
|
-cpu host -enable-kvm -machine q35,accel=kvm \
|
||||||
-smp "$(nproc)" \
|
-smp "$(($(nproc) < 8 ? $(nproc) : 8))" \
|
||||||
-m 8192 \
|
-m 8192 \
|
||||||
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
|
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
|
||||||
-drive if=pflash,format=raw,file=/tmp/OVMF_VARS.4m.fd \
|
-drive if=pflash,format=raw,file="$OVMF_VARS" \
|
||||||
-drive file="$DISK",format=qcow2,if=none,id=drive0 \
|
-drive file="$DISK",format=qcow2,if=none,id=drive0 \
|
||||||
-device virtio-blk-pci,drive=drive0,bootindex=1 \
|
-device virtio-blk-pci,drive=drive0,bootindex=1 \
|
||||||
-drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \
|
-drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \
|
||||||
-device ide-cd,drive=cdrom0,bootindex=2 \
|
-device ide-cd,drive=cdrom0,bootindex=2 \
|
||||||
-device virtio-vga-gl \
|
-device virtio-vga-gl \
|
||||||
-display gtk,gl=on \
|
-display sdl,gl=on \
|
||||||
-usb -device usb-tablet \
|
-usb -device usb-tablet \
|
||||||
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
|
-serial none \
|
||||||
-device virtio-net-pci,netdev=net0 \
|
"${NET_ARGS[@]}" \
|
||||||
-boot menu=on
|
-boot menu=on \
|
||||||
|
"${EXTRA_QEMU_ARGS[@]}"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
if [[ -z "${OMARCHY_PATH:-}" ]]; then
|
||||||
|
for candidate in "$repo_root/../omarchy-installer" "$repo_root/../omarchy" /usr/share/omarchy; do
|
||||||
|
if [[ -f "$candidate/logo.txt" ]]; then
|
||||||
|
export OMARCHY_PATH="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Do a dry-run of the configurator to show the flow without having to do a real installation
|
||||||
|
exec bash "$repo_root/configs/airootfs/root/configurator" dry "$@"
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Preview the shipped Omarchy installer dashboard without building an ISO.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# The dashboard drives the package phase from the target's pacman database, so
|
||||||
|
# fake one here; without it the preview falls back to its time curve and the bar
|
||||||
|
# barely moves, which reads as a bug.
|
||||||
|
PREVIEW_PACKAGES="${PREVIEW_PACKAGES:-925}"
|
||||||
|
|
||||||
|
write_simulation_state() {
|
||||||
|
local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4"
|
||||||
|
local target="$5" index="$6" total="$7"
|
||||||
|
local finished_at="${8:-null}" temporary="${state_file}.tmp"
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--argjson started_at "$started_at" \
|
||||||
|
--argjson phase_started_at "$phase_started_at" \
|
||||||
|
--arg current_phase "$current_phase" \
|
||||||
|
--arg target "$target" \
|
||||||
|
--argjson current_index "$index" \
|
||||||
|
--argjson total_phases "$total" \
|
||||||
|
--argjson finished_at "$finished_at" \
|
||||||
|
'{
|
||||||
|
started_at: $started_at,
|
||||||
|
phase_started_at: $phase_started_at,
|
||||||
|
current_phase: $current_phase,
|
||||||
|
target: $target,
|
||||||
|
current_index: $current_index,
|
||||||
|
total_phases: $total_phases
|
||||||
|
} + if $finished_at == null then {} else {finished_at: $finished_at} end' \
|
||||||
|
>"$temporary"
|
||||||
|
mv -f -- "$temporary" "$state_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fill the fake database over the phase's duration, so the bar is driven by the
|
||||||
|
# same count it uses on a real install.
|
||||||
|
simulate_packages() {
|
||||||
|
local db="$1" duration="$2" total="$3" steps=25 i n
|
||||||
|
for ((i = 1; i <= steps; i++)); do
|
||||||
|
n=$(( total * i / steps ))
|
||||||
|
while (( $(ls -1 "$db" 2>/dev/null | wc -l) < n )); do
|
||||||
|
mkdir -p "$db/preview-$(( $(ls -1 "$db" 2>/dev/null | wc -l) ))-1.0-1"
|
||||||
|
done
|
||||||
|
sleep "$(awk -v d="$duration" -v s="$steps" 'BEGIN { printf "%.3f", d / s }')"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
simulate_install() {
|
||||||
|
local state_file="$1"
|
||||||
|
local started_at="$EPOCHREALTIME"
|
||||||
|
local phase phase_started_at finished_at duration i index total
|
||||||
|
local target db
|
||||||
|
target="$(dirname -- "$state_file")/target"
|
||||||
|
db="$target/var/lib/pacman/local"
|
||||||
|
mkdir -p "$db"
|
||||||
|
local -a phases=(
|
||||||
|
"Starting installation" 0.5
|
||||||
|
"Preparing live environment" 0.8
|
||||||
|
"Preparing install target" 0.3
|
||||||
|
"Installing Arch + Omarchy" 5.0
|
||||||
|
"Configuring hibernation" 0.4
|
||||||
|
"Configuring system" 1.2
|
||||||
|
"Finalizing Limine boot" 1.0
|
||||||
|
"Finalizing user" 1.0
|
||||||
|
"Configuring login" 0.6
|
||||||
|
"Configuring DNS resolver" 0.3
|
||||||
|
"Validating boot setup" 0.8
|
||||||
|
)
|
||||||
|
total=$(( ${#phases[@]} / 2 ))
|
||||||
|
|
||||||
|
for ((i = 0; i < ${#phases[@]}; i += 2)); do
|
||||||
|
phase="${phases[i]}"
|
||||||
|
duration="${phases[i + 1]}"
|
||||||
|
index=$(( i / 2 ))
|
||||||
|
phase_started_at="$EPOCHREALTIME"
|
||||||
|
write_simulation_state "$state_file" "$started_at" "$phase_started_at" \
|
||||||
|
"$phase" "$target" "$index" "$total"
|
||||||
|
if [[ $phase == "Installing Arch + Omarchy" ]]; then
|
||||||
|
simulate_packages "$db" "$duration" "$PREVIEW_PACKAGES"
|
||||||
|
else
|
||||||
|
sleep "$duration"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
finished_at="$EPOCHREALTIME"
|
||||||
|
write_simulation_state "$state_file" "$started_at" "$finished_at" \
|
||||||
|
"Installation complete" "$target" "$(( total - 1 ))" "$total" "$finished_at"
|
||||||
|
sleep 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ${1:-} == "--simulate-install" ]]; then
|
||||||
|
(( $# == 2 )) || exit 2
|
||||||
|
simulate_install "$2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( $# > 0 )); then
|
||||||
|
echo "Usage: $0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
dashboard="$repo_root/configs/airootfs/usr/local/bin/omarchy-install-dashboard"
|
||||||
|
|
||||||
|
if [[ ! -x $dashboard ]]; then
|
||||||
|
echo "Installer dashboard is not executable: $dashboard" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${OMARCHY_PATH:-} ]]; then
|
||||||
|
for candidate in "$repo_root/../omarchy" /usr/share/omarchy; do
|
||||||
|
if [[ -f $candidate/logo.txt ]]; then
|
||||||
|
export OMARCHY_PATH="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${OMARCHY_PATH:-} || ! -f $OMARCHY_PATH/logo.txt ]]; then
|
||||||
|
echo "Unable to find Omarchy logo.txt; set OMARCHY_PATH to an Omarchy checkout." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
preview_dir="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$preview_dir"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
log_file="$preview_dir/install.log"
|
||||||
|
state_file="$preview_dir/state.json"
|
||||||
|
touch "$log_file"
|
||||||
|
|
||||||
|
# Match what the simulated database will reach, so the preview shows the
|
||||||
|
# package-driven bar rather than the missing-signal fallback.
|
||||||
|
printf '%s\n' "$PREVIEW_PACKAGES" >"$preview_dir/expected-packages"
|
||||||
|
|
||||||
|
OMARCHY_UI_AUTO_REBOOT=no \
|
||||||
|
OMARCHY_EXPECTED_PACKAGES_FILE="$preview_dir/expected-packages" \
|
||||||
|
PREVIEW_PACKAGES="$PREVIEW_PACKAGES" \
|
||||||
|
"$dashboard" "$log_file" "$state_file" -- \
|
||||||
|
"$0" --simulate-install "$state_file"
|
||||||
+115
-29
@@ -1,53 +1,131 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Abort if anything fails
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Parse command line arguments
|
|
||||||
NO_CACHE=""
|
NO_CACHE=""
|
||||||
|
KEEP_PKG_CACHE=""
|
||||||
|
LOCAL_OMARCHY_PATH=""
|
||||||
|
LOCAL_PKGS_PATH=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
--no-cache)
|
--no-cache)
|
||||||
NO_CACHE=1
|
NO_CACHE=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--keep-pkg-cache)
|
||||||
|
KEEP_PKG_CACHE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--no-boot-offer)
|
--no-boot-offer)
|
||||||
NO_BOOT_OFFER=1
|
NO_BOOT_OFFER=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--local-source)
|
--debug)
|
||||||
LOCAL_SOURCE=1
|
OMARCHY_INSTALL_DEBUG=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--edge)
|
||||||
|
OMARCHY_ISO_REF=edge
|
||||||
|
OMARCHY_MIRROR=edge
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--local-source)
|
||||||
|
LOCAL_OMARCHY_PATH="${2:-}"
|
||||||
|
LOCAL_PKGS_PATH="${3:-}"
|
||||||
|
if [[ -z $LOCAL_OMARCHY_PATH || -z $LOCAL_PKGS_PATH ]]; then
|
||||||
|
echo "Error: --local-source requires two args: <omarchy-checkout> <pkgs-checkout>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
OMARCHY_MIRROR=edge
|
||||||
|
OMARCHY_ISO_REF=local
|
||||||
|
shift 3
|
||||||
|
;;
|
||||||
--dev)
|
--dev)
|
||||||
OMARCHY_INSTALLER_REF=dev
|
OMARCHY_ISO_REF=dev
|
||||||
|
OMARCHY_MIRROR=edge
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--quattro)
|
||||||
|
OMARCHY_INSTALLER_REF=quattro
|
||||||
|
OMARCHY_MIRROR=edge
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--rc)
|
||||||
|
OMARCHY_ISO_REF=rc
|
||||||
|
OMARCHY_MIRROR=rc
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
echo "Unknown option: $1"
|
||||||
echo "Usage: $0 [--no-cache] [--no-boot-offer] [--local-source] [--dev]"
|
echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Ensure we have the submodule checked out
|
if [[ -n $LOCAL_OMARCHY_PATH ]]; then
|
||||||
|
[[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; }
|
||||||
|
[[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; }
|
||||||
|
LOCAL_OMARCHY_PATH=$(realpath "$LOCAL_OMARCHY_PATH")
|
||||||
|
LOCAL_PKGS_PATH=$(realpath "$LOCAL_PKGS_PATH")
|
||||||
|
fi
|
||||||
|
|
||||||
|
lint_file_permissions() {
|
||||||
|
local profiledef="configs/profiledef.sh"
|
||||||
|
local -A declared=()
|
||||||
|
local missing=()
|
||||||
|
|
||||||
|
while IFS= read -r path; do
|
||||||
|
[[ -n $path ]] && declared["$path"]=1
|
||||||
|
done < <(grep -oP '^\s*\["\K[^"]+(?="\])' "$profiledef" || true)
|
||||||
|
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[[ -f $f ]] || continue
|
||||||
|
|
||||||
|
local rel="${f#configs/airootfs}"
|
||||||
|
local mode
|
||||||
|
mode=$(git ls-files --stage -- "$f" | awk '{print $1}')
|
||||||
|
|
||||||
|
if [[ $mode != 100755 && $(head -c 2 "$f") != "#!" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${declared[$rel]+x} ]]; then
|
||||||
|
missing+=("$rel")
|
||||||
|
fi
|
||||||
|
done < <(find configs/airootfs/usr/local/bin configs/airootfs/root -type f 2>/dev/null)
|
||||||
|
|
||||||
|
if (( ${#missing[@]} > 0 )); then
|
||||||
|
echo "ERROR: the following executable files are missing from $profiledef file_permissions:" >&2
|
||||||
|
printf ' %s\n' "${missing[@]}" >&2
|
||||||
|
echo "Add entries like [\"<path>\"]=\"0:0:755\" to profiledef.sh and retry." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
lint_file_permissions
|
||||||
|
|
||||||
|
# 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
|
git submodule update --init --recursive --jobs=8
|
||||||
|
|
||||||
# Prepare build repo
|
|
||||||
BUILD_SCRIPT="builder/build-iso.sh"
|
BUILD_SCRIPT="builder/build-iso.sh"
|
||||||
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||||
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
|
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
|
||||||
mkdir -p "$BUILD_RELEASE_PATH"
|
mkdir -p "$BUILD_RELEASE_PATH"
|
||||||
|
|
||||||
OMARCHY_INSTALLER_REPO="${OMARCHY_INSTALLER_REPO:-basecamp/omarchy}"
|
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
|
||||||
OMARCHY_INSTALLER_REF="${OMARCHY_INSTALLER_REF:-master}"
|
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
|
||||||
|
|
||||||
DOCKER_ARGS=(
|
DOCKER_ARGS=(
|
||||||
--rm
|
--rm
|
||||||
--privileged
|
--privileged
|
||||||
-e "OMARCHY_INSTALLER_REPO=$OMARCHY_INSTALLER_REPO"
|
-e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF"
|
||||||
-e "OMARCHY_INSTALLER_REF=$OMARCHY_INSTALLER_REF"
|
-e "OMARCHY_MIRROR=$OMARCHY_MIRROR"
|
||||||
|
-e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}"
|
||||||
-e "HOST_UID=$(id -u)"
|
-e "HOST_UID=$(id -u)"
|
||||||
-e "HOST_GID=$(id -g)"
|
-e "HOST_GID=$(id -g)"
|
||||||
-v "$BUILD_RELEASE_PATH/:/out/"
|
-v "$BUILD_RELEASE_PATH/:/out/"
|
||||||
@@ -57,36 +135,44 @@ DOCKER_ARGS=(
|
|||||||
-v "$BUILD_ROOT/configs:/configs:ro"
|
-v "$BUILD_ROOT/configs:/configs:ro"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use local source of Omarchy from $OMARCHY_PATH instead of git checkout
|
if [[ -n $LOCAL_OMARCHY_PATH ]]; then
|
||||||
if [[ -n "${LOCAL_SOURCE-}" ]]; then
|
DOCKER_ARGS+=(
|
||||||
DOCKER_ARGS+=(-v "$OMARCHY_PATH:/omarchy:ro")
|
-v "$LOCAL_OMARCHY_PATH:/omarchy-source:ro"
|
||||||
|
-v "$LOCAL_PKGS_PATH:/omarchy-pkgs:ro"
|
||||||
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use local pacman cache if you already have one on host to speed up repeat runs
|
if [[ -d /var/cache/pacman/pkg ]]; then
|
||||||
if [ -d "/var/cache/pacman/pkg" ]; then
|
|
||||||
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
|
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Mount the build cache directory where packages are downloaded
|
# Mount the build cache directory where packages are downloaded. Channels use
|
||||||
|
# different package snapshots and must never share an offline mirror cache.
|
||||||
if [[ -z "$NO_CACHE" ]]; then
|
if [[ -z "$NO_CACHE" ]]; then
|
||||||
# using a dated dir on the host to refresh daily if building multiple times
|
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
|
||||||
BUILD_DATE=$(date +%Y-%m-%d)
|
|
||||||
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${BUILD_DATE}/airootfs/var/cache/omarchy"
|
|
||||||
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
|
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
|
||||||
# Mount to /var/cache/airootfs/var/cache/omarchy since that's where repo packages go in the ISO
|
|
||||||
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
|
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
|
||||||
|
else
|
||||||
|
# The locally cached :latest image is a cache too — docker run never
|
||||||
|
# re-checks the registry for it, so refresh it when building cold.
|
||||||
|
DOCKER_ARGS+=(--pull=always)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run the builder with assembled args
|
# The docker group is root-equivalent, so Omarchy operators stay out of it on
|
||||||
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
|
# 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[@]}" ghcr.io/archlinux/archlinux:latest /$BUILD_SCRIPT
|
||||||
|
|
||||||
# Move as install ref
|
|
||||||
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
|
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
|
||||||
iso_ref="${latest_iso%.*}-$OMARCHY_INSTALLER_REF.iso"
|
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso"
|
||||||
mv -f "$latest_iso" "$iso_ref"
|
mv -f "$latest_iso" "$iso_ref"
|
||||||
|
|
||||||
# Offer to boot the new build
|
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then
|
||||||
if [[ -z "$NO_BOOT_OFFER" ]]; then
|
|
||||||
echo
|
echo
|
||||||
gum confirm "Boot $iso_ref?" && omarchy-iso-boot "$iso_ref"
|
gum confirm "Boot $iso_ref?" && "$BUILD_ROOT/bin/omarchy-iso-boot" "$iso_ref"
|
||||||
fi
|
fi
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
omarchy-pkg-add rclone
|
||||||
|
mkdir -p ~/.config/rclone
|
||||||
|
|
||||||
access_key=$(op read "op://Shared/Cloudflare Buckets/iso.omarchy.org/access_key_id" --account Omarchy)
|
access_key=$(op read "op://Shared/Cloudflare Buckets/iso.omarchy.org/access_key_id" --account Omarchy)
|
||||||
secret_access_key=$(op read "op://Shared/Cloudflare Buckets/iso.omarchy.org/secret_access_key" --account Omarchy)
|
secret_access_key=$(op read "op://Shared/Cloudflare Buckets/iso.omarchy.org/secret_access_key" --account Omarchy)
|
||||||
|
|||||||
+75
-15
@@ -1,31 +1,91 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
# A failed step must stop the release: without this, a make that dies (say,
|
||||||
echo "Usage: omarchy-iso-release [version]"
|
# 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=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--no-make)
|
||||||
|
MAKE_ARGS+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--rc)
|
||||||
|
MAKE_ARGS+=("$1")
|
||||||
|
ISO_REF=rc
|
||||||
|
RELEASE_SUFFIX="-rc"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
echo "Usage: omarchy-iso-release [--no-make] [--rc] [version]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -n "${BUILD_VERSION-}" ]]; then
|
||||||
|
echo "Usage: omarchy-iso-release [--no-make] [--rc] [version]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BUILD_VERSION="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${BUILD_VERSION-}" ]]; then
|
||||||
|
echo "Usage: omarchy-iso-release [--no-make] [--rc] [version]"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
|
||||||
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
|
BUILD_RELEASE_PATH="$BUILD_ROOT/release"
|
||||||
BULD_VERSION="$1"
|
|
||||||
|
|
||||||
# Make ISOs
|
# Make ISOs
|
||||||
if [[ $1 != "--no-make" ]]; then
|
if [[ ! " ${MAKE_ARGS[*]} " =~ " --no-make " ]]; then
|
||||||
echo -e "\e[32mMaking master ISO\e[0m"
|
echo -e "\e[32mMaking $ISO_REF ISO\e[0m"
|
||||||
OMARCHY_INSTALLER_REF=master omarchy-iso-make --no-cache --no-boot-offer
|
omarchy-iso-make --no-cache --no-boot-offer "${MAKE_ARGS[@]}"
|
||||||
else
|
else
|
||||||
echo -e "\e[31mSkipping remaking ISOs\e[0m"
|
echo -e "\e[31mSkipping remaking ISOs\e[0m"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Sign and upload master ISO
|
# Sign and upload ISO
|
||||||
latest_master_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-master.iso | head -n1)
|
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$ISO_REF".iso | head -n1)
|
||||||
release_master_iso="$BUILD_RELEASE_PATH/omarchy-$BULD_VERSION.iso"
|
release_iso="$BUILD_RELEASE_PATH/omarchy-$BUILD_VERSION$RELEASE_SUFFIX.iso"
|
||||||
cp "$latest_master_iso" "$release_master_iso"
|
cp "$latest_iso" "$release_iso"
|
||||||
|
|
||||||
echo -e "\e[32mSigning master ISO\e[0m"
|
echo -e "\e[32mSigning $ISO_REF ISO\e[0m"
|
||||||
omarchy-iso-sign "$release_master_iso"
|
omarchy-iso-sign "$release_iso"
|
||||||
|
|
||||||
master_sha=$(sha256sum "$release_master_iso")
|
# The checksum sidecar names the ISO alone, never the build path: it is
|
||||||
echo -e "\e[32mSHA256 for master ISO: $master_sha\e[0m"
|
# 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_master_iso"
|
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"
|
||||||
+40
-6
@@ -1,14 +1,48 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
echo "usage: omarchy-iso-sign [release/omarchy.iso]"
|
echo "usage: omarchy-iso-sign [release/omarchy.iso]"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! pacman -Q gnupg >/dev/null; then
|
iso="$1"
|
||||||
sudo pacman -S --noconfirm --needed --quiet gnupg
|
key_ref="op://Shared/Omarchy GPG Private Key/omarchy-private.asc"
|
||||||
echo "You'll need to import key with: gpg --import path/to/signing-key.asc"
|
|
||||||
|
if [[ ! -f $iso ]]; then
|
||||||
|
echo "ISO not found: $iso" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
gnupg_user_key=$(gpg --list-secret-keys --keyid-format=long | awk '/^[[:space:]]/{print $1; exit}')
|
if ! command -v op >/dev/null; then
|
||||||
gpg --local-user "$gnupg_user_key" --output $1.sig --detach-sig $1
|
echo "1Password CLI is required to retrieve the signing key." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! pacman -Q gnupg >/dev/null 2>&1; then
|
||||||
|
sudo pacman -S --noconfirm --needed --quiet gnupg
|
||||||
|
fi
|
||||||
|
|
||||||
|
gnupg_home="$(mktemp -d)"
|
||||||
|
chmod 700 "$gnupg_home"
|
||||||
|
trap 'rm -rf "$gnupg_home"' EXIT
|
||||||
|
|
||||||
|
op read "$key_ref" --account Omarchy |
|
||||||
|
gpg --batch --homedir "$gnupg_home" --import >/dev/null
|
||||||
|
|
||||||
|
signing_key="$(
|
||||||
|
gpg --batch --homedir "$gnupg_home" --with-colons --list-secret-keys |
|
||||||
|
awk -F: '$1 == "fpr" { print $10; exit }'
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [[ -z $signing_key ]]; then
|
||||||
|
echo "No private signing key found in $key_ref" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
gpg --batch --yes \
|
||||||
|
--homedir "$gnupg_home" \
|
||||||
|
--local-user "$signing_key" \
|
||||||
|
--output "$iso.sig" \
|
||||||
|
--detach-sig "$iso"
|
||||||
Executable
+1236
File diff suppressed because it is too large.
Load diff
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."
|
||||||
Executable
+338
@@ -0,0 +1,338 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
DISK_SIZE_GIB=96
|
||||||
|
MIN_FREE_GIB=64
|
||||||
|
RECREATE=0
|
||||||
|
GAP=0
|
||||||
|
ISO=""
|
||||||
|
|
||||||
|
ACTIVE_NBD=""
|
||||||
|
MOUNT_DIR=""
|
||||||
|
MOUNTED=0
|
||||||
|
TEMPORARY_DISK=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<USAGE
|
||||||
|
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
|
||||||
|
- an existing ext4 data partition with a preservation marker
|
||||||
|
- more than 64GiB of contiguous unallocated space for Omarchy
|
||||||
|
|
||||||
|
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:
|
||||||
|
OMARCHY_ISO_TEST_WINDOWS_DISK
|
||||||
|
OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
case "$1" in
|
||||||
|
--recreate)
|
||||||
|
RECREATE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--gap)
|
||||||
|
GAP=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -n $ISO ]]; then
|
||||||
|
echo "Only one ISO may be specified." >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
ISO="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
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
|
||||||
|
MOUNTED=0
|
||||||
|
fi
|
||||||
|
if [[ -n $MOUNT_DIR ]]; then
|
||||||
|
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
|
||||||
|
MOUNT_DIR=""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect_disk() {
|
||||||
|
cleanup_mount
|
||||||
|
if [[ -n $ACTIVE_NBD ]]; then
|
||||||
|
sudo qemu-nbd --disconnect "$ACTIVE_NBD" >/dev/null 2>&1 || true
|
||||||
|
ACTIVE_NBD=""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
disconnect_disk
|
||||||
|
[[ -z $TEMPORARY_DISK ]] || rm -f "$TEMPORARY_DISK"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
find_free_nbd() {
|
||||||
|
local device
|
||||||
|
for device in /sys/class/block/nbd*; do
|
||||||
|
[[ -e $device ]] || continue
|
||||||
|
[[ ! -e $device/pid ]] || continue
|
||||||
|
printf '/dev/%s\n' "${device##*/}"
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
connect_disk() {
|
||||||
|
local disk="$1" mode="${2:-read-write}"
|
||||||
|
local -a options=(--format=qcow2)
|
||||||
|
|
||||||
|
ACTIVE_NBD="$(find_free_nbd)" || {
|
||||||
|
echo "No unused NBD device is available." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
[[ $mode == read-only ]] && options+=(--read-only)
|
||||||
|
|
||||||
|
if ! sudo qemu-nbd "${options[@]}" --connect="$ACTIVE_NBD" "$disk"; then
|
||||||
|
ACTIVE_NBD=""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! sudo udevadm settle; then
|
||||||
|
disconnect_disk
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mount_partition() {
|
||||||
|
local partition="$1"
|
||||||
|
MOUNT_DIR="$(mktemp -d)" || return 1
|
||||||
|
if ! sudo mount -o "${2:-defaults}" "$partition" "$MOUNT_DIR"; then
|
||||||
|
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
|
||||||
|
MOUNT_DIR=""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
MOUNTED=1
|
||||||
|
}
|
||||||
|
|
||||||
|
largest_free_region() {
|
||||||
|
sudo parted -ms "$1" unit B print free </dev/null 2>/dev/null |
|
||||||
|
awk -F: '
|
||||||
|
$NF ~ /free/ {
|
||||||
|
size=$4
|
||||||
|
gsub(/B/, "", size)
|
||||||
|
if (size+0 > max+0) max=size
|
||||||
|
}
|
||||||
|
END { print max+0 }
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_test_disk() {
|
||||||
|
local part_count pttype free_space_b min_free_b want_parts numbers
|
||||||
|
local esp data valid=1
|
||||||
|
|
||||||
|
[[ -f $TEST_DISK ]] || return 1
|
||||||
|
qemu-img check -q "$TEST_DISK" >/dev/null 2>&1 || return 1
|
||||||
|
connect_disk "$TEST_DISK" read-only || return 1
|
||||||
|
|
||||||
|
esp="${ACTIVE_NBD}p1"
|
||||||
|
data="${ACTIVE_NBD}p2"
|
||||||
|
part_count=$(lsblk -nro TYPE "$ACTIVE_NBD" | awk '$1 == "part" { count++ } END { print count+0 }')
|
||||||
|
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 == 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
|
||||||
|
[[ $(sudo blkid -s TYPE -o value "$data" 2>/dev/null || true) == ext4 ]] || valid=0
|
||||||
|
[[ $(sudo blkid -s LABEL -o value "$data" 2>/dev/null || true) == WINDOWS_DATA ]] || valid=0
|
||||||
|
|
||||||
|
if (( valid )); then
|
||||||
|
mount_partition "$esp" ro || valid=0
|
||||||
|
if (( valid )); then
|
||||||
|
sudo test -d "$MOUNT_DIR/EFI/Microsoft" || valid=0
|
||||||
|
fi
|
||||||
|
cleanup_mount
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( valid )); then
|
||||||
|
mount_partition "$data" ro || valid=0
|
||||||
|
if (( valid )); then
|
||||||
|
sudo test -f "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" || valid=0
|
||||||
|
fi
|
||||||
|
cleanup_mount
|
||||||
|
fi
|
||||||
|
|
||||||
|
disconnect_disk
|
||||||
|
(( valid ))
|
||||||
|
}
|
||||||
|
|
||||||
|
create_test_disk() {
|
||||||
|
local esp data
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$TEST_DISK")"
|
||||||
|
TEMPORARY_DISK="${TEST_DISK}.creating.$$"
|
||||||
|
rm -f "$TEMPORARY_DISK"
|
||||||
|
|
||||||
|
gum style --foreground 6 "Creating synthetic Windows test disk at $TEST_DISK"
|
||||||
|
qemu-img create -f qcow2 "$TEMPORARY_DISK" "${DISK_SIZE_GIB}G"
|
||||||
|
connect_disk "$TEMPORARY_DISK"
|
||||||
|
|
||||||
|
sudo parted --script "$ACTIVE_NBD" mklabel gpt
|
||||||
|
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
|
||||||
|
|
||||||
|
esp="${ACTIVE_NBD}p1"
|
||||||
|
data="${ACTIVE_NBD}p2"
|
||||||
|
sudo mkfs.fat -F32 -n WINDOWS_ESP "$esp" >/dev/null
|
||||||
|
sudo mkfs.ext4 -F -L WINDOWS_DATA "$data" >/dev/null 2>&1
|
||||||
|
|
||||||
|
mount_partition "$esp"
|
||||||
|
sudo mkdir -p "$MOUNT_DIR/EFI/Microsoft/Boot"
|
||||||
|
printf '%s\n' 'Synthetic Windows ESP for Omarchy ISO testing.' |
|
||||||
|
sudo tee "$MOUNT_DIR/EFI/Microsoft/README.txt" >/dev/null
|
||||||
|
cleanup_mount
|
||||||
|
|
||||||
|
mount_partition "$data"
|
||||||
|
printf '%s\n' 'This existing partition must survive the Omarchy installation.' |
|
||||||
|
sudo tee "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" >/dev/null
|
||||||
|
cleanup_mount
|
||||||
|
|
||||||
|
sync
|
||||||
|
disconnect_disk
|
||||||
|
mv -f "$TEMPORARY_DISK" "$TEST_DISK"
|
||||||
|
TEMPORARY_DISK=""
|
||||||
|
}
|
||||||
|
|
||||||
|
select_iso() {
|
||||||
|
local -a isos=()
|
||||||
|
|
||||||
|
if [[ -n $ISO ]]; then
|
||||||
|
[[ -f $ISO ]] || {
|
||||||
|
echo "ISO not found: $ISO" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
realpath "$ISO"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mapfile -t isos < <(
|
||||||
|
find "$REPO_ROOT/release" -maxdepth 1 -type f -name '*.iso' -printf '%T@ %p\n' 2>/dev/null |
|
||||||
|
sort -nr |
|
||||||
|
cut -d' ' -f2-
|
||||||
|
)
|
||||||
|
(( ${#isos[@]} > 0 )) || {
|
||||||
|
echo "No ISO found under $REPO_ROOT/release." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '%s\n' "${isos[@]}" | gum choose --header "Select ISO to install"
|
||||||
|
}
|
||||||
|
|
||||||
|
omarchy-pkg-add qemu-full edk2-ovmf parted dosfstools e2fsprogs
|
||||||
|
sudo modprobe nbd max_part=16
|
||||||
|
|
||||||
|
if [[ -f $TEST_DISK ]] && (( ! RECREATE )); then
|
||||||
|
gum style --foreground 6 "Verifying existing test disk at $TEST_DISK"
|
||||||
|
if validate_test_disk; then
|
||||||
|
gum style --foreground 2 "✓ Synthetic Windows test disk is ready"
|
||||||
|
elif gum confirm --default=false "The existing test disk is not a pristine fixture. Recreate it?"; then
|
||||||
|
create_test_disk
|
||||||
|
else
|
||||||
|
gum style "Left the existing disk untouched."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
create_test_disk
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The synthetic fixture has no real Windows bootloader, so reset OVMF state for
|
||||||
|
# every run. The ISO still sees EFI/Microsoft and must leave that ESP untouched.
|
||||||
|
mkdir -p "$(dirname "$TEST_OVMF_VARS")"
|
||||||
|
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: $LAYOUT_LABEL"
|
||||||
|
|
||||||
|
if gum confirm "Start installation on this disk?"; then
|
||||||
|
ISO="$(select_iso)"
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
exec "$SCRIPT_DIR/omarchy-iso-boot" \
|
||||||
|
--reuse \
|
||||||
|
--disk "$TEST_DISK" \
|
||||||
|
--ovmf-vars "$TEST_OVMF_VARS" \
|
||||||
|
"$ISO" \
|
||||||
|
-- -boot once=d,menu=on
|
||||||
|
fi
|
||||||
+19
-3
@@ -10,8 +10,24 @@ if [[ ! -f ~/.config/rclone/rclone.conf ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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" Omarchy:omarchy/ -P || status=1
|
||||||
rclone copy $1.sig Omarchy:omarchy/ -P
|
|
||||||
|
# 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
|
fi
|
||||||
|
|
||||||
|
exit $status
|
||||||
+68
-15
@@ -4,8 +4,8 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves"
|
VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves"
|
||||||
TMP_DISK="/tmp/omarchy-iso-boot.qcow2"
|
TMP_DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
|
||||||
TMP_OVMF="/tmp/OVMF_VARS.4m.fd"
|
TMP_OVMF="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
|
||||||
|
|
||||||
mkdir -p "$VM_DIR"
|
mkdir -p "$VM_DIR"
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ save_vm() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then
|
if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then
|
||||||
echo "Error: No active VM found in /tmp"
|
echo "Error: No active VM found at $TMP_DISK / $TMP_OVMF"
|
||||||
echo "Boot a VM first with: omarchy-iso-boot [iso]"
|
echo "Boot a VM first with: omarchy-iso-boot [iso]"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -53,7 +53,44 @@ list_vms() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boot_vm() {
|
boot_vm() {
|
||||||
local name="$1"
|
local name=""
|
||||||
|
local boot_args=()
|
||||||
|
local active_disk="$TMP_DISK"
|
||||||
|
local active_ovmf="$TMP_OVMF"
|
||||||
|
|
||||||
|
if [[ $# -gt 0 && ${1:-} != --* ]]; then
|
||||||
|
name="$1"
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
while (( $# > 0 )); do
|
||||||
|
case "$1" in
|
||||||
|
--disk)
|
||||||
|
active_disk="${2:-}"
|
||||||
|
boot_args+=("$1" "${2:-}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--disk=*)
|
||||||
|
active_disk="${1#*=}"
|
||||||
|
boot_args+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--ovmf-vars)
|
||||||
|
active_ovmf="${2:-}"
|
||||||
|
boot_args+=("$1" "${2:-}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ovmf-vars=*)
|
||||||
|
active_ovmf="${1#*=}"
|
||||||
|
boot_args+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
boot_args+=("$1")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
if [[ -z "$(ls -A "$VM_DIR" 2>/dev/null)" ]]; then
|
if [[ -z "$(ls -A "$VM_DIR" 2>/dev/null)" ]]; then
|
||||||
echo "No VM snapshots available"
|
echo "No VM snapshots available"
|
||||||
@@ -73,25 +110,34 @@ boot_vm() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$TMP_DISK" ]] && ! gum confirm "Override existing /tmp VM?"; then
|
if [[ -z $active_disk || -z $active_ovmf ]]; then
|
||||||
|
echo "Error: active disk and OVMF vars paths must not be empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f $active_disk ]] && ! gum confirm "Override existing active VM disk at $active_disk?"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Copying VM '$name' to /tmp..."
|
echo "Copying VM '$name' to $active_disk..."
|
||||||
cp "$vm_path/omarchy-iso-boot.qcow2" "$TMP_DISK"
|
mkdir -p "$(dirname "$active_disk")" "$(dirname "$active_ovmf")"
|
||||||
cp "$vm_path/OVMF_VARS.4m.fd" "$TMP_OVMF"
|
cp "$vm_path/omarchy-iso-boot.qcow2" "$active_disk"
|
||||||
|
cp "$vm_path/OVMF_VARS.4m.fd" "$active_ovmf"
|
||||||
|
|
||||||
gum style --foreground 212 "✓ VM ready. Booting..."
|
gum style --foreground 212 "✓ VM ready. Booting..."
|
||||||
|
|
||||||
exec omarchy-iso-boot /dev/null reuse
|
exec omarchy-iso-boot "${boot_args[@]}" /dev/null reuse
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
command="${1:-}"
|
||||||
|
[[ $# -gt 0 ]] && shift
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
save)
|
save)
|
||||||
save_vm "$2"
|
save_vm "${1:-}"
|
||||||
;;
|
;;
|
||||||
boot)
|
boot)
|
||||||
boot_vm "$2"
|
boot_vm "$@"
|
||||||
;;
|
;;
|
||||||
list)
|
list)
|
||||||
list_vms
|
list_vms
|
||||||
@@ -101,9 +147,16 @@ case "${1:-}" in
|
|||||||
"$(gum style --bold 'omarchy-vm') - VM snapshot manager" \
|
"$(gum style --bold 'omarchy-vm') - VM snapshot manager" \
|
||||||
"" \
|
"" \
|
||||||
"$(gum style --foreground 245 'Commands:')" \
|
"$(gum style --foreground 245 'Commands:')" \
|
||||||
" save [name] Save current VM from /tmp" \
|
" save [name] Save current active VM" \
|
||||||
" boot [name] Boot saved VM (interactive if no name)" \
|
" boot [name] [boot-options] Boot saved VM (interactive if no name)" \
|
||||||
" list List available VMs" \
|
" list List available VMs" \
|
||||||
|
"" \
|
||||||
|
"$(gum style --foreground 245 'Boot options:')" \
|
||||||
|
" --ssh-port PORT Forward host PORT to guest SSH (default: 2222)" \
|
||||||
|
" --ssh-host HOST Host address for SSH forwarding (default: 127.0.0.1)" \
|
||||||
|
" --disk PATH Active disk path, useful for parallel VMs" \
|
||||||
|
" --ovmf-vars PATH Active OVMF vars path, useful for parallel VMs" \
|
||||||
|
" --no-network Boot without a virtual NIC" \
|
||||||
"" \
|
"" \
|
||||||
"$(gum style --foreground 245 'Current VMs:')"
|
"$(gum style --foreground 245 'Current VMs:')"
|
||||||
list_vms 2>/dev/null || echo " None"
|
list_vms 2>/dev/null || echo " None"
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ openssh
|
|||||||
pipewire
|
pipewire
|
||||||
snapper
|
snapper
|
||||||
sof-firmware
|
sof-firmware
|
||||||
|
tailscale
|
||||||
+387
-64
@@ -2,99 +2,422 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Note that these are packages installed to the Arch container used to build the ISO.
|
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}"
|
||||||
|
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
edge|dev|local)
|
||||||
|
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
|
||||||
|
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy}"
|
||||||
|
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
|
||||||
|
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
|
||||||
|
|
||||||
|
# pacman 7 sandboxes its downloads with Landlock and fails every sync on a
|
||||||
|
# kernel without it (Namespace's AmpereOne arm64 machines): "restricting
|
||||||
|
# filesystem access failed because Landlock is not supported by the kernel".
|
||||||
|
# There, every pacman in this container (pacstrap's too) runs with
|
||||||
|
# --disable-sandbox, which pacman provides for exactly this. A wrapper rather
|
||||||
|
# than DisableSandbox in a config: pacman-offline.conf ships on the ISO.
|
||||||
|
if ! grep -qw landlock /sys/kernel/security/lsm 2>/dev/null; then
|
||||||
|
echo "No Landlock in this kernel: pacman runs with --disable-sandbox in this container"
|
||||||
|
printf '#!/bin/sh\nexec /usr/bin/pacman --disable-sandbox "$@"\n' >/usr/local/bin/pacman
|
||||||
|
chmod +x /usr/local/bin/pacman
|
||||||
|
hash -r
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Packages installed into the Arch container used to build the ISO.
|
||||||
pacman-key --init
|
pacman-key --init
|
||||||
pacman --noconfirm -Sy archlinux-keyring
|
pacman --noconfirm -Sy archlinux-keyring
|
||||||
pacman --noconfirm -Sy archiso git sudo base-devel jq grub
|
# Full upgrade, not just -Sy: docker never re-pulls :latest once it's cached,
|
||||||
|
# so this container can be months behind the mirror it installs from. A plain
|
||||||
|
# -Sy install is then a partial upgrade — new packages linked against a glibc
|
||||||
|
# the container doesn't have yet.
|
||||||
|
pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli
|
||||||
|
|
||||||
# Install omarchy-keyring for package verification during build
|
# Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo
|
||||||
# The [omarchy] repo is defined in /configs/pacman-online.conf with SigLevel = Optional TrustAll
|
# during the build without keyserver lookups).
|
||||||
pacman --config /configs/pacman-online.conf --noconfirm -Sy omarchy-keyring
|
pacman-key --add /builder/omarchy.gpg
|
||||||
|
pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571
|
||||||
|
|
||||||
|
# omarchy-keyring is needed inside the offline mirror too.
|
||||||
|
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring
|
||||||
pacman-key --populate omarchy
|
pacman-key --populate omarchy
|
||||||
|
|
||||||
# Setup build locations
|
# Put OPR first in the container too so makepkg resolves the same overrides
|
||||||
build_cache_dir="/var/cache"
|
# as the online installer configuration.
|
||||||
offline_mirror_dir="$build_cache_dir/airootfs/var/cache/omarchy/mirror/offline"
|
if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then
|
||||||
mkdir -p $build_cache_dir/
|
opr_section=$(awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf)
|
||||||
mkdir -p $offline_mirror_dir/
|
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
|
||||||
|
|
||||||
# We base our ISO on the official arch ISO (releng) config
|
# Build locations
|
||||||
cp -r /archiso/configs/releng/* $build_cache_dir/
|
build_cache_dir=/var/cache
|
||||||
|
offline_mirror_dir="$build_cache_dir/airootfs/var/cache/omarchy/mirror/offline"
|
||||||
|
mkdir -p "$build_cache_dir" "$offline_mirror_dir"
|
||||||
|
|
||||||
|
# Seed from the official Arch releng profile.
|
||||||
|
cp -r /archiso/configs/releng/* "$build_cache_dir/"
|
||||||
rm "$build_cache_dir/airootfs/etc/motd"
|
rm "$build_cache_dir/airootfs/etc/motd"
|
||||||
|
|
||||||
# Avoid using reflector for mirror identification as we are relying on the global CDN
|
# We rely on the global CDN; drop reflector.
|
||||||
rm "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service"
|
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service"
|
||||||
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d"
|
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d"
|
||||||
rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector"
|
rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector"
|
||||||
|
|
||||||
# Bring in our configs
|
# Bring in our archiso profile additions.
|
||||||
cp -r /configs/* $build_cache_dir/
|
cp -r /configs/* "$build_cache_dir/"
|
||||||
|
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
|
||||||
|
echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror"
|
||||||
|
echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref"
|
||||||
|
cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <<EOF
|
||||||
|
OMARCHY_RUNTIME_PACKAGE=$OMARCHY_RUNTIME_PACKAGE
|
||||||
|
OMARCHY_SETTINGS_PACKAGE=$OMARCHY_SETTINGS_PACKAGE
|
||||||
|
OMARCHY_NVIM_PACKAGE=$OMARCHY_NVIM_PACKAGE
|
||||||
|
EOF
|
||||||
|
|
||||||
# Setup Omarchy itself
|
if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
|
||||||
if [[ -d /omarchy ]]; then
|
touch "$build_cache_dir/airootfs/usr/share/omarchy-iso/install-debug"
|
||||||
cp -rp /omarchy "$build_cache_dir/airootfs/root/omarchy"
|
{
|
||||||
else
|
echo "debug=1"
|
||||||
git clone -b $OMARCHY_INSTALLER_REF https://github.com/$OMARCHY_INSTALLER_REPO.git "$build_cache_dir/airootfs/root/omarchy"
|
echo "built_at=$(date -Is)"
|
||||||
|
echo "ref=$OMARCHY_ISO_REF"
|
||||||
|
echo "mirror=$OMARCHY_MIRROR"
|
||||||
|
echo "runtime_package=$OMARCHY_RUNTIME_PACKAGE"
|
||||||
|
echo "settings_package=$OMARCHY_SETTINGS_PACKAGE"
|
||||||
|
echo "nvim_package=$OMARCHY_NVIM_PACKAGE"
|
||||||
|
if [[ -d /omarchy-source ]]; then
|
||||||
|
echo "omarchy_source=/omarchy-source"
|
||||||
|
git -c safe.directory=/omarchy-source -C /omarchy-source rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_commit=/' || true
|
||||||
|
git -c safe.directory=/omarchy-source -C /omarchy-source status --short 2>/dev/null | sed 's/^/omarchy_status=/' || true
|
||||||
|
fi
|
||||||
|
if [[ -d /omarchy-pkgs ]]; then
|
||||||
|
echo "omarchy_pkgs_source=/omarchy-pkgs"
|
||||||
|
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_pkgs_commit=/' || true
|
||||||
|
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs status --short 2>/dev/null | sed 's/^/omarchy_pkgs_status=/' || true
|
||||||
|
fi
|
||||||
|
} > "$build_cache_dir/airootfs/usr/share/omarchy-iso/build-info"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Make log uploader available in the ISO too
|
# When --local-source is in effect, build omarchy* from the mounted source
|
||||||
mkdir -p "$build_cache_dir/airootfs/usr/local/bin/"
|
# trees and drop them in the offline mirror. Otherwise pacman -Syw below
|
||||||
cp "$build_cache_dir/airootfs/root/omarchy/bin/omarchy-upload-log" "$build_cache_dir/airootfs/usr/local/bin/omarchy-upload-log"
|
# downloads the published versions from the omarchy network mirror.
|
||||||
|
if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then
|
||||||
|
bash /builder/build-omarchy-packages.sh "$offline_mirror_dir"
|
||||||
|
LOCAL_OMARCHY_BUILD=1
|
||||||
|
fi
|
||||||
|
|
||||||
# Copy the Omarchy Plymouth theme to the ISO
|
# Node.js binary for offline mise install.
|
||||||
mkdir -p "$build_cache_dir/airootfs/usr/share/plymouth/themes/omarchy"
|
|
||||||
cp -r "$build_cache_dir/airootfs/root/omarchy/default/plymouth/"* "$build_cache_dir/airootfs/usr/share/plymouth/themes/omarchy/"
|
|
||||||
|
|
||||||
# Download and verify Node.js binary for offline installation
|
|
||||||
NODE_DIST_URL="https://nodejs.org/dist/latest"
|
NODE_DIST_URL="https://nodejs.org/dist/latest"
|
||||||
|
|
||||||
# Get checksums and parse filename and SHA
|
|
||||||
NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
|
NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
|
||||||
NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}')
|
NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}')
|
||||||
NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}')
|
NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}')
|
||||||
|
|
||||||
# Download the tarball
|
|
||||||
curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME"
|
curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME"
|
||||||
|
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c -
|
||||||
# Verify SHA256 checksum
|
|
||||||
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c - || {
|
|
||||||
echo "ERROR: Node.js checksum verification failed!"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Copy to ISO
|
|
||||||
mkdir -p "$build_cache_dir/airootfs/opt/packages/"
|
mkdir -p "$build_cache_dir/airootfs/opt/packages/"
|
||||||
cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
|
cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
|
||||||
|
|
||||||
# Add our additional packages to packages.x86_64
|
# Packages installed into the live ISO environment itself (NOT the target system).
|
||||||
arch_packages=(linux-t2 git gum jq openssl plymouth tzupdate omarchy-keyring)
|
# The selected omarchy-settings package is needed here so its post_install hook
|
||||||
printf '%s\n' "${arch_packages[@]}" >>"$build_cache_dir/packages.x86_64"
|
# drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the
|
||||||
|
# live initramfs.
|
||||||
|
# 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"
|
||||||
|
|
||||||
# Build list of all the packages needed for the offline mirror
|
# The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so
|
||||||
all_packages=($(cat "$build_cache_dir/packages.x86_64"))
|
# stock linux is a second kernel nobody boots: ~147MB of ISO, plus its own archiso
|
||||||
all_packages+=($(grep -v '^#' "$build_cache_dir/airootfs/root/omarchy/install/omarchy-base.packages" | grep -v '^$'))
|
# initramfs, copied into both the ISO tree and the size-constrained FAT EFI image.
|
||||||
all_packages+=($(grep -v '^#' "$build_cache_dir/airootfs/root/omarchy/install/omarchy-other.packages" | grep -v '^$'))
|
#
|
||||||
all_packages+=($(grep -v '^#' /builder/archinstall.packages | grep -v '^$'))
|
# It cannot just be deleted — releng's broadcom-wl hard-depends on it, and it is
|
||||||
|
# the only releng package that does, so pacman would drag the kernel straight back
|
||||||
|
# in. broadcom-wl is a prebuilt module for stock linux and cannot load on the
|
||||||
|
# kernel we boot, so it has done nothing since we started booting T2 anyway. The
|
||||||
|
# install is entirely offline and the live environment needs no Wi-Fi driver.
|
||||||
|
#
|
||||||
|
# Anchored so linux-t2 and linux-firmware are untouched.
|
||||||
|
sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
|
||||||
|
|
||||||
# Download all the packages to the offline mirror inside the ISO
|
# Build the offline mirror: everything pacstrap might want during the target
|
||||||
|
# install. With --local-source, the omarchy* packages we just built are
|
||||||
|
# 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
|
||||||
|
# Pull the same package lists out of the freshly-downloaded Omarchy runtime
|
||||||
|
# package so we don't need a local checkout in the non-local-source path.
|
||||||
|
bootstrap_cache_dir=/tmp/omarchy-pkg-bootstrap
|
||||||
|
rm -rf "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap /tmp/omarchy-pkglists
|
||||||
|
mkdir -p "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap
|
||||||
|
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_RUNTIME_PACKAGE" --cachedir "$bootstrap_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
|
||||||
|
omarchy_pkg=$(find "$bootstrap_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_RUNTIME_PACKAGE-*.pkg.tar.zst" | sort | head -1)
|
||||||
|
if [[ -z $omarchy_pkg ]]; then
|
||||||
|
echo "ERROR: downloaded package for $OMARCHY_RUNTIME_PACKAGE not found in $bootstrap_cache_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p /tmp/omarchy-pkglists
|
||||||
|
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
|
||||||
|
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages)
|
||||||
|
# Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member
|
||||||
|
# it can't find, so asking for this alongside the package lists would abort the
|
||||||
|
# build here (set -e) with a bare "Not found in archive" instead of the
|
||||||
|
# actionable error below.
|
||||||
|
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true
|
||||||
|
# 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
|
||||||
|
|
||||||
|
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
|
||||||
|
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
|
||||||
|
cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
|
||||||
|
|
||||||
|
# The configurator's setup form comes from the runtime this ISO bundles, so the
|
||||||
|
# installer and the first-boot setup that finishes a deferred install can never
|
||||||
|
# disagree. A runtime predating the split ships no such file, which would leave
|
||||||
|
# the configurator with no prompts at all.
|
||||||
|
if [[ ! -f $setup_form ]]; then
|
||||||
|
if [[ -d /omarchy-source ]]; then
|
||||||
|
echo "ERROR: the --local-source checkout ships no install/provisioning/setup-form.sh" >&2
|
||||||
|
remedy="Update the checkout to a revision carrying the shared setup form."
|
||||||
|
else
|
||||||
|
echo "ERROR: $OMARCHY_RUNTIME_PACKAGE does not ship install/provisioning/setup-form.sh" >&2
|
||||||
|
remedy="Publish a runtime carrying the shared setup form, or build with --local-source against a checkout that has it."
|
||||||
|
fi
|
||||||
|
echo " The configurator sources its prompts from that file, so this ISO" >&2
|
||||||
|
echo " would boot into an installer with no questions to ask." >&2
|
||||||
|
echo " $remedy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
|
||||||
|
|
||||||
|
# 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 < <(
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
|
||||||
|
mapfile -t all_packages < <(
|
||||||
|
printf '%s\n' "${all_packages[@]}" |
|
||||||
|
grep -Fxv \
|
||||||
|
-e "$OMARCHY_RUNTIME_PACKAGE" \
|
||||||
|
-e "$OMARCHY_SETTINGS_PACKAGE" \
|
||||||
|
-e "$OMARCHY_NVIM_PACKAGE" || true
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p /tmp/offlinedb
|
mkdir -p /tmp/offlinedb
|
||||||
pacman --config /configs/pacman-online.conf --noconfirm -Syw "${all_packages[@]}" --cachedir $offline_mirror_dir/ --dbpath /tmp/offlinedb
|
download_offline_packages() {
|
||||||
repo-add --new "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
|
pacman --config "$online_config" --noconfirm -Syw \
|
||||||
|
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
|
||||||
|
}
|
||||||
|
|
||||||
|
# A repository may occasionally republish a package without changing its
|
||||||
|
# filename. Pacman detects that the persistent cached copy no longer matches
|
||||||
|
# the refreshed repository checksum and deletes it, but still fails the
|
||||||
|
# transaction. Retry once so the now-missing package is downloaded.
|
||||||
|
if ! download_offline_packages; then
|
||||||
|
echo "Offline package download failed; retrying after pacman cleaned invalid cached files..." >&2
|
||||||
|
download_offline_packages
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve the exact filenames chosen by the same synced package databases used
|
||||||
|
# for the download. Pruning by this transaction (rather than merely keeping the
|
||||||
|
# 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 "$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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mapfile -t required_package_files <<< "$resolved_package_files"
|
||||||
|
|
||||||
# Create a symlink to the offline mirror instead of duplicating it.
|
# The online transaction intentionally excludes packages built from the local
|
||||||
# mkarchiso needs packages at /var/cache/omarchy/mirror/offline in the container,
|
# checkouts. Add those exact artifacts back to the keep-set after verifying
|
||||||
# but they're actually in $build_cache_dir/airootfs/var/cache/omarchy/mirror/offline
|
# that the local build left exactly one file for each selected package name.
|
||||||
|
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
|
||||||
|
for local_package_name in \
|
||||||
|
"$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do
|
||||||
|
local_package_file=""
|
||||||
|
for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do
|
||||||
|
[[ -f $candidate && $candidate != *.sig ]] || continue
|
||||||
|
read -r candidate_name _ < <(pacman -Qp "$candidate" 2>/dev/null) || continue
|
||||||
|
[[ $candidate_name == "$local_package_name" ]] || continue
|
||||||
|
if [[ -n $local_package_file ]]; then
|
||||||
|
echo "ERROR: multiple local builds found for $local_package_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
local_package_file="${candidate##*/}"
|
||||||
|
done
|
||||||
|
if [[ -z $local_package_file ]]; then
|
||||||
|
echo "ERROR: local build not found for $local_package_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
required_package_files+=("$local_package_file")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${required_package_files[@]}" |
|
||||||
|
bash /builder/prune-offline-mirror.sh "$offline_mirror_dir"
|
||||||
|
|
||||||
|
# Rebuild the offline repo db from scratch so size/checksum/depends entries
|
||||||
|
# always reflect only the package files selected for this build.
|
||||||
|
# Every package archive in the mirror, indexed by one repo-add per CPU: one
|
||||||
|
# alone hashes and lists a thousand packages one after another.
|
||||||
|
bash /builder/index-offline-mirror.sh "$offline_mirror_dir"
|
||||||
|
|
||||||
|
# mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the
|
||||||
|
# container (the airootfs path); symlink rather than duplicate.
|
||||||
mkdir -p /var/cache/omarchy/mirror
|
mkdir -p /var/cache/omarchy/mirror
|
||||||
ln -s "$offline_mirror_dir" "/var/cache/omarchy/mirror/offline"
|
ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline
|
||||||
|
|
||||||
|
# Denominator for the install dashboard's progress bar. Resolving the mirror's
|
||||||
|
# own package lists against the mirror we just indexed, with an empty local db,
|
||||||
|
# is the question pacstrap asks at install time — same resolver, same repo, same
|
||||||
|
# lists — so no hand-kept constant can drift.
|
||||||
|
#
|
||||||
|
# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and
|
||||||
|
# intel-ucode because the mirror must contain either. phases.py records expected
|
||||||
|
# and actual in the timing JSON, so growing drift shows up in acceptance runs.
|
||||||
|
# The early-bootstrap set is already inside this closure, so restating it would
|
||||||
|
# only add a second list to drift.
|
||||||
|
resolve_expected_packages() {
|
||||||
|
local resolve_root=/tmp/omarchy-expected-packages
|
||||||
|
local resolved
|
||||||
|
local -a targets
|
||||||
|
|
||||||
|
rm -rf "$resolve_root"
|
||||||
|
mkdir -p "$resolve_root/var/lib/pacman"
|
||||||
|
|
||||||
|
mapfile -t targets < <(
|
||||||
|
{
|
||||||
|
grep -hv '^#\|^$' /builder/archinstall.packages
|
||||||
|
# Read the shipped copy, which is what _runtime_package_list reads at
|
||||||
|
# install time, not the build-time source it came from.
|
||||||
|
grep -hv '^#\|^$' \
|
||||||
|
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
|
||||||
|
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \
|
||||||
|
"$OMARCHY_NVIM_PACKAGE"
|
||||||
|
} | sort -u
|
||||||
|
)
|
||||||
|
|
||||||
|
pacman --config "$build_cache_dir/pacman-offline.conf" \
|
||||||
|
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
|
||||||
|
--noconfirm -Sy >/dev/null || return 1
|
||||||
|
|
||||||
|
# Capture before counting: no pipefail here, so a pacman failure inside a
|
||||||
|
# pipeline would become a plausible partial count, which never trips the
|
||||||
|
# dashboard's fallback.
|
||||||
|
resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \
|
||||||
|
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
|
||||||
|
--noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1
|
||||||
|
|
||||||
|
printf '%s\n' "$resolved" | sort -u | grep -c .
|
||||||
|
}
|
||||||
|
|
||||||
|
# Worth failing the build over: -S --print only aborts when a target is missing
|
||||||
|
# from the offline repo, which would fail pacstrap the same way. A count that
|
||||||
|
# merely looks wrong is not — the dashboard falls back without the file.
|
||||||
|
if ! expected_packages="$(resolve_expected_packages)"; then
|
||||||
|
echo "ERROR: could not resolve the target package count from the offline mirror." >&2
|
||||||
|
echo " pacman -S --print aborts the whole transaction if any single target" >&2
|
||||||
|
echo " is missing, so this almost certainly means pacstrap would fail the" >&2
|
||||||
|
echo " same way at install time." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if (( expected_packages < 600 || expected_packages > 2000 )); then
|
||||||
|
echo "WARNING: resolved target package count $expected_packages is outside the" >&2
|
||||||
|
echo " expected 600-2000 range; shipping no denominator so the install" >&2
|
||||||
|
echo " dashboard falls back to its time-based curve." >&2
|
||||||
|
else
|
||||||
|
printf '%s\n' "$expected_packages" \
|
||||||
|
>"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages"
|
||||||
|
echo "Target install resolves to $expected_packages packages."
|
||||||
|
fi
|
||||||
|
|
||||||
# Copy the pacman.conf to the ISO's /etc directory so the live environment uses our
|
# Live ISO uses the same offline pacman.conf.
|
||||||
# same config when booted
|
cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf"
|
||||||
cp $build_cache_dir/pacman.conf "$build_cache_dir/airootfs/etc/pacman.conf"
|
|
||||||
|
|
||||||
# Finally, we assemble the entire ISO
|
# Build the ISO.
|
||||||
mkarchiso -v -w "$build_cache_dir/work/" -o "/out/" "$build_cache_dir/"
|
mkarchiso -v -w "$build_cache_dir/work/" -o /out/ "$build_cache_dir/"
|
||||||
|
|
||||||
# Fix ownership of output files to match host user
|
# Match host UID/GID on output.
|
||||||
if [ -n "$HOST_UID" ] && [ -n "$HOST_GID" ]; then
|
if [[ -n $HOST_UID && -n $HOST_GID ]]; then
|
||||||
chown -R "$HOST_UID:$HOST_GID" /out/
|
chown -R "$HOST_UID:$HOST_GID" /out/
|
||||||
fi
|
fi
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build Omarchy packages from mounted source (/omarchy-source + /omarchy-pkgs)
|
||||||
|
# and place the resulting .pkg.tar.zst files in the offline mirror.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
offline_mirror_dir="$1"
|
||||||
|
if [[ -z $offline_mirror_dir ]]; then
|
||||||
|
echo "Usage: build-omarchy-packages.sh <offline-mirror-dir>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d /omarchy-source ]]; then
|
||||||
|
echo "ERROR: /omarchy-source not mounted (pass --local-source or set OMARCHY_SOURCE_PATH)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -d /omarchy-pkgs ]]; then
|
||||||
|
echo "ERROR: /omarchy-pkgs not mounted (set OMARCHY_PKGS_PATH or place ../omarchy-pkgs)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
work_dir=/tmp/omarchy-pkg-build
|
||||||
|
rm -rf "$work_dir"
|
||||||
|
mkdir -p "$work_dir"
|
||||||
|
|
||||||
|
if ! id builder &>/dev/null; then
|
||||||
|
useradd -m -s /bin/bash builder
|
||||||
|
fi
|
||||||
|
echo 'builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/99-omarchy-pkg-builder
|
||||||
|
chmod 440 /etc/sudoers.d/99-omarchy-pkg-builder
|
||||||
|
chown builder:builder "$work_dir"
|
||||||
|
|
||||||
|
pacman -Sy --noconfirm
|
||||||
|
|
||||||
|
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
|
||||||
|
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
|
||||||
|
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
|
||||||
|
|
||||||
|
packages=(
|
||||||
|
"$OMARCHY_SETTINGS_PACKAGE"
|
||||||
|
"$OMARCHY_RUNTIME_PACKAGE"
|
||||||
|
"$OMARCHY_NVIM_PACKAGE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Local-source packages must replace every cached build of the same package,
|
||||||
|
# even when the checkout's generated pkgver sorts below a published build.
|
||||||
|
# Otherwise the later generic cache pruning can silently keep edge instead.
|
||||||
|
for pkg in "${packages[@]}"; do
|
||||||
|
rm -f "$offline_mirror_dir/$pkg-"*.pkg.tar.*
|
||||||
|
done
|
||||||
|
|
||||||
|
for pkg in "${packages[@]}"; do
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "Building $pkg"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
pkg_work="$work_dir/$pkg"
|
||||||
|
if [[ ! -d "/omarchy-pkgs/pkgbuilds/$pkg" ]]; then
|
||||||
|
echo "ERROR: package source not found: /omarchy-pkgs/pkgbuilds/$pkg" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp -a "/omarchy-pkgs/pkgbuilds/$pkg" "$pkg_work"
|
||||||
|
chown -R builder:builder "$pkg_work"
|
||||||
|
|
||||||
|
su builder -c "
|
||||||
|
cd '$pkg_work' &&
|
||||||
|
PKGDEST='$work_dir' \
|
||||||
|
OMARCHY_SRC=/omarchy-source \
|
||||||
|
makepkg --noconfirm --skippgpcheck --skipchecksums --nodeps -f
|
||||||
|
"
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$offline_mirror_dir"
|
||||||
|
for package_file in "$work_dir"/*.pkg.tar.zst; do
|
||||||
|
destination="$offline_mirror_dir/$(basename "$package_file")"
|
||||||
|
|
||||||
|
# A cached signature belongs to the previously downloaded or locally built
|
||||||
|
# package. Keeping it beside a newly built package makes pacman reject the
|
||||||
|
# otherwise valid local-source build.
|
||||||
|
rm -f "$destination" "$destination.sig"
|
||||||
|
mv "$package_file" "$destination"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Built Omarchy packages, placed in $offline_mirror_dir:"
|
||||||
|
ls "$offline_mirror_dir"/omarchy*.pkg.tar.zst | sed 's|^| |'
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Index the offline mirror: <dir>/offline.db.tar.gz and offline.files.tar.gz
|
||||||
|
# from every package in <dir>, the same databases one repo-add call writes.
|
||||||
|
#
|
||||||
|
# index-offline-mirror.sh <dir> [jobs]
|
||||||
|
#
|
||||||
|
# repo-add handles one package at a time, and for each it hashes the archive
|
||||||
|
# and lists its files, which means decompressing all of it. A database is only
|
||||||
|
# a tar of one directory per package, so the packages are dealt out to one
|
||||||
|
# repo-add per CPU, each writing its own database, and the pieces are put
|
||||||
|
# together afterwards.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dir=$(realpath "$1")
|
||||||
|
jobs=${2:-$(nproc)}
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
mapfile -t packages < <(find "$dir" -maxdepth 1 -name '*.pkg.tar.*' ! -name '*.sig' | sort)
|
||||||
|
(( ${#packages[@]} > 0 )) || { echo "index-offline-mirror: no packages in $dir" >&2; exit 1; }
|
||||||
|
(( jobs > ${#packages[@]} )) && jobs=${#packages[@]}
|
||||||
|
(( jobs >= 1 )) || jobs=1
|
||||||
|
|
||||||
|
# Deal the sorted list out round-robin: big and small packages spread evenly.
|
||||||
|
for (( i = 0; i < jobs; i++ )); do mkdir "$work/$i"; done
|
||||||
|
for (( i = 0; i < ${#packages[@]}; i++ )); do
|
||||||
|
printf '%s\0' "${packages[i]}" >>"$work/$(( i % jobs ))/list"
|
||||||
|
done
|
||||||
|
|
||||||
|
pids=()
|
||||||
|
for (( i = 0; i < jobs; i++ )); do
|
||||||
|
xargs -0 -a "$work/$i/list" repo-add -q "$work/$i/offline.db.tar.gz" >"$work/$i/log" 2>&1 &
|
||||||
|
pids+=($!)
|
||||||
|
done
|
||||||
|
failed=0
|
||||||
|
for (( i = 0; i < jobs; i++ )); do
|
||||||
|
if ! wait "${pids[i]}"; then
|
||||||
|
failed=1
|
||||||
|
echo "index-offline-mirror: repo-add failed on part $i:" >&2
|
||||||
|
cat "$work/$i/log" >&2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
(( failed == 0 )) || exit 1
|
||||||
|
|
||||||
|
mkdir "$work/db" "$work/files"
|
||||||
|
for (( i = 0; i < jobs; i++ )); do
|
||||||
|
bsdtar -xf "$work/$i/offline.db.tar.gz" -C "$work/db"
|
||||||
|
bsdtar -xf "$work/$i/offline.files.tar.gz" -C "$work/files"
|
||||||
|
done
|
||||||
|
|
||||||
|
# One repo-add keeps a single entry per package name. Apart, two versions of
|
||||||
|
# one name would both survive, so that is an error here rather than a choice.
|
||||||
|
entries=$(find "$work/db" -mindepth 1 -maxdepth 1 -type d | wc -l)
|
||||||
|
names=$(cat "$work"/db/*/desc | awk '/^%NAME%$/ { getline; print }' | sort -u | wc -l)
|
||||||
|
if (( entries != ${#packages[@]} || names != entries )); then
|
||||||
|
echo "index-offline-mirror: ${#packages[@]} packages gave $entries entries for $names names; two versions of one package?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$dir"/offline.db* "$dir"/offline.files*
|
||||||
|
for kind in db files; do
|
||||||
|
find "$work/$kind" -mindepth 1 -maxdepth 1 -type d -printf '%P\n' | sort |
|
||||||
|
bsdtar -czf "$dir/offline.$kind.tar.gz" -C "$work/$kind" -T -
|
||||||
|
ln -s "offline.$kind.tar.gz" "$dir/offline.$kind"
|
||||||
|
done
|
||||||
|
echo "Indexed ${#packages[@]} packages with $jobs repo-add processes."
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mDMEaLAsIhYJKwYBBAHaRw8BAQdAYmKmrWMlfpdIPh3QzvEMzzxdNd/kqDl/Bj8H
|
||||||
|
mCavKji0Gk9tYXJjaHkgPHBrZ3NAb21hcmNoeS5vcmc+iJAEExYKADgWIQRA37Yw
|
||||||
|
/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIX
|
||||||
|
gAAKCRDwE07mgMrFcRF2AQCjjvjgju/kowkN69nenqqRnE+v2MrHmWh2wU0ugIt9
|
||||||
|
3gD/bXGugWc5J1vqoT+5aCnJAtfRRmHEfsrAiFgjduB+VA24OARosCwiEgorBgEE
|
||||||
|
AZdVAQUBAQdAJoU73WdgxE/pEYK5ofyRvQDs1IFRdfJHV9j4Jm+tuDUDAQgHiHgE
|
||||||
|
GBYKACAWIQRA37Yw/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbDAAKCRDwE07mgMrF
|
||||||
|
cYXsAQC5eBEGK0xKsGwtnDDVeNAapx32dpvLgJ94W4DtIpdLlwEA85cSsTiBMuGy
|
||||||
|
6kwYmHzqw7oFx3VYGXdMNl0SMKzvJA4=
|
||||||
|
=G7wU
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
@@ -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
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
offline_mirror_dir="${1:-}"
|
||||||
|
if [[ -z $offline_mirror_dir ]]; then
|
||||||
|
echo "Usage: prune-offline-mirror.sh <offline-mirror-dir>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -d $offline_mirror_dir ]]; then
|
||||||
|
echo "ERROR: offline mirror directory not found: $offline_mirror_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read the exact package filenames selected by pacman on stdin. Validate the
|
||||||
|
# complete selection before deleting anything: an empty/partial resolution must
|
||||||
|
# never turn a warm-cache build into a broken mirror.
|
||||||
|
declare -A required=()
|
||||||
|
while IFS= read -r filename; do
|
||||||
|
[[ -n $filename ]] || continue
|
||||||
|
if [[ $filename == */* || $filename != *.pkg.tar.* || $filename == *.sig ]]; then
|
||||||
|
echo "ERROR: invalid required package filename: $filename" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
required["$filename"]=1
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#required[@]} == 0 )); then
|
||||||
|
echo "ERROR: refusing to prune the offline mirror with an empty package selection" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
missing=()
|
||||||
|
for filename in "${!required[@]}"; do
|
||||||
|
[[ -f "$offline_mirror_dir/$filename" ]] || missing+=("$filename")
|
||||||
|
done
|
||||||
|
if (( ${#missing[@]} > 0 )); then
|
||||||
|
echo "ERROR: refusing to prune; selected packages are missing from the mirror:" >&2
|
||||||
|
printf ' %s\n' "${missing[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
stale=()
|
||||||
|
while IFS= read -r -d '' package_file; do
|
||||||
|
filename="${package_file##*/}"
|
||||||
|
[[ -n ${required[$filename]+x} ]] || stale+=("$package_file")
|
||||||
|
done < <(
|
||||||
|
find "$offline_mirror_dir" -maxdepth 1 -type f \
|
||||||
|
-name '*.pkg.tar.*' ! -name '*.sig' -print0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (( ${#stale[@]} > 0 )); then
|
||||||
|
echo "Pruning package archives not selected for the offline mirror:"
|
||||||
|
for package_file in "${stale[@]}"; do
|
||||||
|
echo " $(basename "$package_file")"
|
||||||
|
rm -f -- "$package_file" "$package_file.sig"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A failed or interrupted download can leave a signature without its archive.
|
||||||
|
# It cannot be indexed and only wastes space in the ISO.
|
||||||
|
while IFS= read -r -d '' signature_file; do
|
||||||
|
[[ -f ${signature_file%.sig} ]] || rm -f -- "$signature_file"
|
||||||
|
done < <(
|
||||||
|
find "$offline_mirror_dir" -maxdepth 1 -type f \
|
||||||
|
-name '*.pkg.tar.*.sig' -print0
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# mkinitcpio preset file for the 'linux-t2' package on archiso
|
||||||
|
#
|
||||||
|
# The filename is the pkgbase, and that is load-bearing. Kernel packages ship
|
||||||
|
# nothing in /boot — they only install usr/lib/modules/<kver>/vmlinuz — so
|
||||||
|
# mkinitcpio's alpm hook sources /etc/mkinitcpio.d/<pkgbase>.preset and copies
|
||||||
|
# THAT package's kernel to whatever ALL_kver names (is_kernelcopy in
|
||||||
|
# /usr/share/libalpm/scripts/mkinitcpio). Named linux.preset, this pointed the
|
||||||
|
# stock kernel at the T2 filenames, so the ISO booted stock Arch while every
|
||||||
|
# boot entry said linux-t2 and T2 Macs got no keyboard or trackpad.
|
||||||
|
#
|
||||||
|
# releng ships its own linux.preset for the stock kernel; leave it alone.
|
||||||
|
|
||||||
|
PRESETS=('archiso')
|
||||||
|
|
||||||
|
ALL_kver='/boot/vmlinuz-linux-t2'
|
||||||
|
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
|
||||||
|
|
||||||
|
archiso_image="/boot/initramfs-linux-t2.img"
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# mkinitcpio preset file for the 'linux' package on archiso
|
|
||||||
|
|
||||||
PRESETS=('archiso')
|
|
||||||
|
|
||||||
ALL_kver='/boot/vmlinuz-linux-t2'
|
|
||||||
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
|
|
||||||
|
|
||||||
archiso_image="/boot/initramfs-linux-t2.img"
|
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# linux-t2 ships for every machine, and on a pre-T2 Mac its applesmc oopses
|
||||||
|
# registering the keyboard-backlight LED, wedging udev. Nothing here needs the SMC.
|
||||||
|
blacklist applesmc
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Fix Panther Lake audio: blacklist SOF modules that conflict with HDA drivers
|
||||||
|
# (prevents boot issues on Dell XPS 2026 and similar Panther Lake systems)
|
||||||
|
blacklist snd_sof_pci_intel_ptl
|
||||||
|
blacklist snd_sof_pci_intel_lnl
|
||||||
|
blacklist snd_sof_pci_intel_mtl
|
||||||
|
blacklist snd_sof_intel_hda_generic
|
||||||
|
blacklist snd_sof_intel_hda_common
|
||||||
|
blacklist snd_sof_intel_hda
|
||||||
|
blacklist snd_sof_pci
|
||||||
|
blacklist snd_sof
|
||||||
|
blacklist soundwire_intel
|
||||||
|
blacklist snd_soc_cs35l56_sdw
|
||||||
|
blacklist snd_soc_cs35l56
|
||||||
|
blacklist snd_soc_skl_hda_dsp
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# remove from airootfs!
|
||||||
|
#
|
||||||
|
# Mirror omarchy-settings' /etc/skel shell defaults into /root so the live ISO
|
||||||
|
# console has the same bashrc, starship prompt, and aliases as an installed
|
||||||
|
# Omarchy system. Fires during airootfs pacstrap (when omarchy-settings is
|
||||||
|
# installed); the "remove from airootfs" magic string causes
|
||||||
|
# zzzz99-remove-custom-hooks-from-airootfs.hook to delete this file before
|
||||||
|
# the squashfs is built, so no runtime hook remains.
|
||||||
|
|
||||||
|
[Trigger]
|
||||||
|
Operation = Install
|
||||||
|
Operation = Upgrade
|
||||||
|
Type = Package
|
||||||
|
Target = omarchy-settings
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = Mirroring /etc/skel shell defaults into /root for the live ISO...
|
||||||
|
When = PostTransaction
|
||||||
|
Depends = coreutils
|
||||||
|
Depends = omarchy-settings
|
||||||
|
Exec = /bin/sh -c 'install -d -m 0700 /root/.config && for f in .bashrc .bash_profile; do [ -f "/etc/skel/$f" ] && install -m 0644 "/etc/skel/$f" "/root/$f"; done; [ -f /etc/skel/.config/starship.toml ] && install -m 0644 /etc/skel/.config/starship.toml /root/.config/starship.toml; true'
|
||||||
@@ -1,140 +1,186 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Live ISO entry point on tty1: set up the live VT, run the configurator
|
||||||
|
# wizard, then hand off to the Python install orchestrator. Mirrors the
|
||||||
|
# stream/env contract from the previously-working installer:
|
||||||
|
# - stdout teed to /var/log/omarchy-install.log (CSI-stripped) AND to tty
|
||||||
|
# - stderr direct to /dev/tty so gum (which draws its TUI on stderr)
|
||||||
|
# renders correctly
|
||||||
|
# - CLICOLOR_FORCE/FORCE_COLOR so gum emits ANSI even with stdout piped
|
||||||
|
# - COLUMNS/LINES so gum picks up real terminal size
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
use_omarchy_helpers() {
|
[[ $(tty) == /dev/tty1 || -n ${OMARCHY_CJK_CONSOLE:-} ]] || exit 0
|
||||||
export OMARCHY_PATH="/root/omarchy"
|
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
|
||||||
export OMARCHY_INSTALL="/root/omarchy/install"
|
touch "$XDG_RUNTIME_DIR/cjk-console-started"
|
||||||
export OMARCHY_INSTALL_LOG_FILE="/var/log/omarchy-install.log"
|
|
||||||
source /root/omarchy/install/helpers/all.sh
|
|
||||||
}
|
|
||||||
|
|
||||||
run_configurator() {
|
|
||||||
set_tokyo_night_colors
|
|
||||||
./configurator
|
|
||||||
export OMARCHY_USER="$(jq -r '.users[0].username' user_credentials.json)"
|
|
||||||
}
|
|
||||||
|
|
||||||
install_arch() {
|
|
||||||
clear_logo
|
|
||||||
gum style --foreground 3 --padding "1 0 0 $PADDING_LEFT" "Installing..."
|
|
||||||
echo
|
|
||||||
|
|
||||||
touch /var/log/omarchy-install.log
|
|
||||||
|
|
||||||
start_log_output
|
|
||||||
|
|
||||||
# Set CURRENT_SCRIPT for the trap to display better when nothing is returned for some reason
|
|
||||||
CURRENT_SCRIPT="install_base_system"
|
|
||||||
install_base_system > >(sed -u 's/\x1b\[[0-9;]*[a-zA-Z]//g' >>/var/log/omarchy-install.log) 2>&1
|
|
||||||
unset CURRENT_SCRIPT
|
|
||||||
stop_log_output
|
|
||||||
}
|
|
||||||
|
|
||||||
install_omarchy() {
|
|
||||||
chroot_bash -lc "sudo pacman -S --noconfirm --needed gum" >/dev/null
|
|
||||||
chroot_bash -lc "source /home/$OMARCHY_USER/.local/share/omarchy/install.sh || bash"
|
|
||||||
|
|
||||||
# Reboot if requested by installer
|
|
||||||
if [[ -f /mnt/var/tmp/omarchy-install-completed ]]; then
|
|
||||||
reboot
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set Tokyo Night color scheme for the terminal
|
|
||||||
set_tokyo_night_colors() {
|
|
||||||
if [[ $(tty) == "/dev/tty"* ]]; then
|
|
||||||
# Tokyo Night color palette
|
|
||||||
echo -en "\e]P01a1b26" # black (background)
|
|
||||||
echo -en "\e]P1f7768e" # red
|
|
||||||
echo -en "\e]P29ece6a" # green
|
|
||||||
echo -en "\e]P3e0af68" # yellow
|
|
||||||
echo -en "\e]P47aa2f7" # blue
|
|
||||||
echo -en "\e]P5bb9af7" # magenta
|
|
||||||
echo -en "\e]P67dcfff" # cyan
|
|
||||||
echo -en "\e]P7a9b1d6" # white
|
|
||||||
echo -en "\e]P8414868" # bright black
|
|
||||||
echo -en "\e]P9f7768e" # bright red
|
|
||||||
echo -en "\e]PA9ece6a" # bright green
|
|
||||||
echo -en "\e]PBe0af68" # bright yellow
|
|
||||||
echo -en "\e]PC7aa2f7" # bright blue
|
|
||||||
echo -en "\e]PDbb9af7" # bright magenta
|
|
||||||
echo -en "\e]PE7dcfff" # bright cyan
|
|
||||||
echo -en "\e]PFc0caf5" # bright white (foreground)
|
|
||||||
|
|
||||||
# Set default foreground and background
|
|
||||||
echo -en "\033[0m"
|
|
||||||
clear
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
install_base_system() {
|
|
||||||
# Initialize and populate the keyring
|
|
||||||
pacman-key --init
|
|
||||||
pacman-key --populate archlinux
|
|
||||||
pacman-key --populate omarchy
|
|
||||||
|
|
||||||
# Sync the offline database so pacman can find packages
|
|
||||||
pacman -Sy --noconfirm
|
|
||||||
|
|
||||||
# Ensure that no mounts exist from past install attempts
|
|
||||||
findmnt -R /mnt >/dev/null && umount -R /mnt
|
|
||||||
|
|
||||||
# Install using files generated by the ./configurator
|
|
||||||
# Skip NTP and WKD sync since we're offline (keyring is pre-populated in ISO)
|
|
||||||
archinstall \
|
|
||||||
--config user_configuration.json \
|
|
||||||
--creds user_credentials.json \
|
|
||||||
--silent \
|
|
||||||
--skip-ntp \
|
|
||||||
--skip-wkd
|
|
||||||
|
|
||||||
# After archinstall sets up the base system but before our installer runs,
|
|
||||||
# we need to ensure the offline pacman.conf is in place
|
|
||||||
cp /etc/pacman.conf /mnt/etc/pacman.conf
|
|
||||||
|
|
||||||
# Mount the offline mirror so it's accessible in the chroot
|
|
||||||
mkdir -p /mnt/var/cache/omarchy/mirror/offline
|
|
||||||
mount --bind /var/cache/omarchy/mirror/offline /mnt/var/cache/omarchy/mirror/offline
|
|
||||||
|
|
||||||
# Mount the packages dir so it's accessible in the chroot
|
|
||||||
mkdir -p /mnt/opt/packages
|
|
||||||
mount --bind /opt/packages /mnt/opt/packages
|
|
||||||
|
|
||||||
# No need to ask for sudo during the installation (omarchy itself responsible for removing after install)
|
|
||||||
mkdir -p /mnt/etc/sudoers.d
|
|
||||||
cat >/mnt/etc/sudoers.d/99-omarchy-installer <<EOF
|
|
||||||
root ALL=(ALL:ALL) NOPASSWD: ALL
|
|
||||||
%wheel ALL=(ALL:ALL) NOPASSWD: ALL
|
|
||||||
$OMARCHY_USER ALL=(ALL:ALL) NOPASSWD: ALL
|
|
||||||
EOF
|
|
||||||
chmod 440 /mnt/etc/sudoers.d/99-omarchy-installer
|
|
||||||
|
|
||||||
# Copy the local omarchy repo to the user's home directory
|
|
||||||
mkdir -p /mnt/home/$OMARCHY_USER/.local/share/
|
|
||||||
cp -r /root/omarchy /mnt/home/$OMARCHY_USER/.local/share/
|
|
||||||
|
|
||||||
chown -R 1000:1000 /mnt/home/$OMARCHY_USER/.local/
|
|
||||||
|
|
||||||
# Ensure all necessary scripts are executable
|
|
||||||
find /mnt/home/$OMARCHY_USER/.local/share/omarchy -type f -path "*/bin/*" -exec chmod +x {} \;
|
|
||||||
chmod +x /mnt/home/$OMARCHY_USER/.local/share/omarchy/boot.sh 2>/dev/null || true
|
|
||||||
chmod +x /mnt/home/$OMARCHY_USER/.local/share/omarchy/default/waybar/indicators/screen-recording.sh 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
chroot_bash() {
|
|
||||||
HOME=/home/$OMARCHY_USER \
|
|
||||||
arch-chroot -u $OMARCHY_USER /mnt/ \
|
|
||||||
env OMARCHY_CHROOT_INSTALL=1 \
|
|
||||||
OMARCHY_USER_NAME="$(<user_full_name.txt)" \
|
|
||||||
OMARCHY_USER_EMAIL="$(<user_email_address.txt)" \
|
|
||||||
USER="$OMARCHY_USER" \
|
|
||||||
HOME="/home/$OMARCHY_USER" \
|
|
||||||
/bin/bash "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ $(tty) == "/dev/tty1" ]]; then
|
|
||||||
use_omarchy_helpers
|
|
||||||
run_configurator
|
|
||||||
install_arch
|
|
||||||
install_omarchy
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
|
||||||
|
if [[ -f /root/omarchy_iso_ref ]]; then
|
||||||
|
export OMARCHY_ISO_REF="$(cat /root/omarchy_iso_ref)"
|
||||||
|
fi
|
||||||
|
if [[ -f /usr/share/omarchy-iso/package-targets ]]; then
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source /usr/share/omarchy-iso/package-targets
|
||||||
|
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
|
||||||
|
fi
|
||||||
|
export OMARCHY_PATH=/usr/share/omarchy
|
||||||
|
export OMARCHY_INSTALL=$OMARCHY_PATH/install
|
||||||
|
export OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log
|
||||||
|
if [[ -f /usr/share/omarchy-iso/install-debug ]]; then
|
||||||
|
export OMARCHY_INSTALL_DEBUG=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tokyo Night palette so the live VT matches the installed look.
|
||||||
|
set_tokyo_night_colors() {
|
||||||
|
echo -en "\e]P01a1b26"; echo -en "\e]P1f7768e"; echo -en "\e]P29ece6a"
|
||||||
|
echo -en "\e]P3e0af68"; echo -en "\e]P47aa2f7"; echo -en "\e]P5bb9af7"
|
||||||
|
echo -en "\e]P67dcfff"; echo -en "\e]P7a9b1d6"; echo -en "\e]P8414868"
|
||||||
|
echo -en "\e]P9f7768e"; echo -en "\e]PA9ece6a"; echo -en "\e]PBe0af68"
|
||||||
|
echo -en "\e]PC7aa2f7"; echo -en "\e]PDbb9af7"; echo -en "\e]PE7dcfff"
|
||||||
|
echo -en "\e]PFc0caf5"
|
||||||
|
echo -en "\033[0m"
|
||||||
|
clear
|
||||||
|
}
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
export COLUMNS=$(tput cols)
|
||||||
|
export LINES=$(tput lines)
|
||||||
|
exec > >(tee >(sed -u 's/\x1b\[[0-9;?]*[A-Za-z]//g' >>"$OMARCHY_INSTALL_LOG_FILE") 2>/dev/null) 2>/dev/tty
|
||||||
|
export CLICOLOR_FORCE=1
|
||||||
|
export FORCE_COLOR=1
|
||||||
|
|
||||||
|
if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
|
||||||
|
echo "=== Omarchy ISO debug build ==="
|
||||||
|
[[ -f /usr/share/omarchy-iso/build-info ]] && cat /usr/share/omarchy-iso/build-info
|
||||||
|
pacman -Q omarchy-settings omarchy-keyring 2>/dev/null || true
|
||||||
|
echo "================================"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warm the page cache for the bundled packages while the user works through the
|
||||||
|
# wizard. The install reads ~3GB out of the offline mirror, and pacman consumes
|
||||||
|
# it at only ~33MB/s, so on media slower than that the install is read-bound and
|
||||||
|
# every byte cached here is a byte it never waits for. On faster media this costs
|
||||||
|
# nothing but otherwise-idle bandwidth: the medium is untouched while the user
|
||||||
|
# types, and the target disk it writes to later is a different device.
|
||||||
|
#
|
||||||
|
# Clean page cache only, so the kernel reclaims it under pressure instead of
|
||||||
|
# OOMing, and a budget so small machines never evict what was just warmed.
|
||||||
|
# Set OMARCHY_NO_PREFETCH=1 to A/B the same ISO with this disabled.
|
||||||
|
warm_offline_mirror() {
|
||||||
|
local mirror=/var/cache/omarchy/mirror/offline
|
||||||
|
local budget_kb spent_kb=0 size_kb path
|
||||||
|
|
||||||
|
[[ ${OMARCHY_NO_PREFETCH:-} == 1 ]] && return 0
|
||||||
|
[[ -d $mirror ]] || return 0
|
||||||
|
|
||||||
|
budget_kb=$(($(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo) / 2))
|
||||||
|
((budget_kb > 262144)) || return 0
|
||||||
|
|
||||||
|
# Largest first: the install reads most of the mirror, so when the budget
|
||||||
|
# cannot cover all of it this still front-loads the bytes that dominate.
|
||||||
|
while read -r size_kb path; do
|
||||||
|
((spent_kb + size_kb > budget_kb)) && continue
|
||||||
|
cat -- "$path" >/dev/null 2>&1 || true
|
||||||
|
spent_kb=$((spent_kb + size_kb))
|
||||||
|
done < <(du -k "$mirror"/*.pkg.tar.zst 2>/dev/null | sort -rn)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
# everything downstream runs the ordinary path against ordinary inputs.
|
||||||
|
if /usr/local/bin/omarchy-cidata-load; then
|
||||||
|
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
|
||||||
|
export OMARCHY_UI_INTERACTIVE=no
|
||||||
|
else
|
||||||
|
# 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
|
||||||
|
# their own — the owner completes setup at first boot. Signalled by the config's
|
||||||
|
# defer_provisioning flag (interactive) or the defer-provisioning marker (cidata).
|
||||||
|
# Parse the flag with jq (the same JSON semantics the orchestrator uses) rather
|
||||||
|
# than a line regex, so a reformatted config can't read as a direct install.
|
||||||
|
if [[ -f /root/defer-provisioning ]] ||
|
||||||
|
[[ "$(jq -r '.omarchy_install.defer_provisioning // false' /root/user_configuration.json 2>/dev/null)" == "true" ]]; then
|
||||||
|
export OMARCHY_UI_DEFER_PROVISIONING=yes
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
export OMARCHY_DASHBOARD_TTY="$(tty)"
|
||||||
|
rm -f /run/omarchy-install/state.json
|
||||||
|
/usr/local/bin/omarchy-install-dashboard \
|
||||||
|
"$OMARCHY_INSTALL_LOG_FILE" \
|
||||||
|
/run/omarchy-install/state.json \
|
||||||
|
-- \
|
||||||
|
/usr/local/bin/omarchy-iso-install \
|
||||||
|
--config /root/user_configuration.json \
|
||||||
|
--creds /root/user_credentials.json \
|
||||||
|
--full-name-file /root/user_full_name.txt \
|
||||||
|
--email-file /root/user_email_address.txt \
|
||||||
|
--encrypt-file /root/user_encrypt_installation.txt \
|
||||||
|
--authorized-keys-file /root/authorized_keys \
|
||||||
|
--tailscale-authkey-file /root/tailscale_authkey \
|
||||||
|
--defer-provisioning-file /root/defer-provisioning
|
||||||
+1176
-207
File diff suppressed because it is too large.
Load diff
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox,
|
||||||
|
# libvirt and Packer all know how to attach one) carrying the configurator's own
|
||||||
|
# output files stands in for the wizard. Copy those files into /root and
|
||||||
|
# everything downstream runs the ordinary path against ordinary inputs.
|
||||||
|
#
|
||||||
|
# Exits 0 when an autoinstall drive was loaded and the configurator can be
|
||||||
|
# skipped. The required pair is the same one the orchestrator treats as
|
||||||
|
# mandatory; the rest are optional there too, so don't be stricter here.
|
||||||
|
# Anything less than the pair means this isn't an autoinstall drive, and the
|
||||||
|
# caller falls back to the wizard.
|
||||||
|
#
|
||||||
|
# A `defer-provisioning` marker file stands in for user_credentials.json: deferred-provisioning
|
||||||
|
# installs defer user creation to first boot, so imaging rigs ship no credentials.
|
||||||
|
#
|
||||||
|
# The optional argument prefixes every path the script touches; tests use it to
|
||||||
|
# run against a sandbox instead of the live system.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
root=${1:-}
|
||||||
|
by_label=$root/dev/disk/by-label
|
||||||
|
dest=$root/root
|
||||||
|
mnt=$root/run/cidata
|
||||||
|
|
||||||
|
# The by-label symlinks come from udev, and on a fast boot this can run before
|
||||||
|
# device enumeration has finished. Wait for the queue to drain instead of
|
||||||
|
# racing it and silently dropping a headless install into the wizard.
|
||||||
|
udevadm settle 2>/dev/null || true
|
||||||
|
|
||||||
|
device=""
|
||||||
|
for label in cidata CIDATA; do
|
||||||
|
[[ -e $by_label/$label ]] && { device=$by_label/$label; break; }
|
||||||
|
done
|
||||||
|
[[ -n $device ]] || exit 1
|
||||||
|
|
||||||
|
mkdir -p "$mnt"
|
||||||
|
mount -o ro "$device" "$mnt" || exit 1
|
||||||
|
|
||||||
|
# Clear any inputs a previous load in this live session left in /root before
|
||||||
|
# copying the current drive: a stale `defer-provisioning` marker or credentials file would
|
||||||
|
# otherwise force a fresh drive back into deferred provisioning or hand it an old
|
||||||
|
# LUKS passphrase. The interactive wizard writes these same names, but a
|
||||||
|
# cidata load always precedes it, so nothing of the user's is discarded here.
|
||||||
|
optional_inputs=(user_credentials.json defer-provisioning user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey)
|
||||||
|
for file in user_configuration.json "${optional_inputs[@]}"; do
|
||||||
|
rm -f "$dest/$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
loaded=1
|
||||||
|
if [[ -f $mnt/user_configuration.json ]] && [[ -f $mnt/user_credentials.json || -f $mnt/defer-provisioning ]]; then
|
||||||
|
files=("$mnt"/user_configuration.json)
|
||||||
|
for file in "${optional_inputs[@]}"; do
|
||||||
|
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
|
||||||
|
done
|
||||||
|
if cp "${files[@]}" "$dest"/; then
|
||||||
|
loaded=0
|
||||||
|
else
|
||||||
|
# A partial copy must not leave files behind: the wizard cleans up only
|
||||||
|
# what it writes itself, so a half-copied authorized_keys would leak into
|
||||||
|
# the interactive install that follows.
|
||||||
|
for file in "${files[@]}"; do
|
||||||
|
rm -f "$dest/${file##*/}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
umount "$mnt"
|
||||||
|
exit $loaded
|
||||||
+1206
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Clean up any holders / mounts / LVM / crypt devices on the install disk
|
||||||
|
# before archinstall starts wiping. Idempotent — called once per install attempt
|
||||||
|
# from the orchestrator's cleanup_disk phase.
|
||||||
|
#
|
||||||
|
# Ported verbatim from .automated_script.sh's cleanup_install_disk(). Kept in
|
||||||
|
# bash because lsblk / findmnt / lvm / cryptsetup are all easier to drive
|
||||||
|
# here than from Python.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
if [[ ${1:-} == --protected ]]; then
|
||||||
|
target="${2:-}"
|
||||||
|
[[ -n $target ]] || { echo "Usage: $0 --protected <target>" >&2; exit 1; }
|
||||||
|
findmnt -R "$target" >/dev/null 2>&1 && umount -R "$target" || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
disk="${1:-}"
|
||||||
|
[[ -n $disk && -b $disk ]] || { echo "Need a block device argument; got '$disk'" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "Cleaning up existing holders on install disk: $disk"
|
||||||
|
findmnt -R /mnt >/dev/null && umount -R /mnt || true
|
||||||
|
|
||||||
|
while read -r dev; do
|
||||||
|
[[ -b $dev ]] || continue
|
||||||
|
swapoff "$dev" 2>/dev/null || true
|
||||||
|
while read -r target; do
|
||||||
|
[[ -n $target ]] && umount "$target" 2>/dev/null || true
|
||||||
|
done < <(findmnt -rn -S "$dev" -o TARGET 2>/dev/null || true)
|
||||||
|
done < <(lsblk -rnpo PATH "$disk")
|
||||||
|
|
||||||
|
while read -r dev type; do
|
||||||
|
[[ $type == disk || $type == part || $type == crypt ]] || continue
|
||||||
|
while read -r vg; do
|
||||||
|
[[ -n $vg ]] && vgchange -an "$vg" 2>/dev/null || true
|
||||||
|
done < <(pvs --noheadings -o vg_name "$dev" 2>/dev/null | awk '{$1=$1; print}' | sort -u)
|
||||||
|
done < <(lsblk -rnpo PATH,TYPE "$disk")
|
||||||
|
|
||||||
|
while read -r dev type; do
|
||||||
|
[[ $type == crypt ]] && cryptsetup close "$dev" 2>/dev/null || true
|
||||||
|
done < <(lsblk -rnpo PATH,TYPE "$disk")
|
||||||
|
|
||||||
|
blockdev --flushbufs "$disk" 2>/dev/null || true
|
||||||
|
partprobe "$disk" 2>/dev/null || true
|
||||||
|
udevadm settle || true
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Live-ISO entry point for the Omarchy install orchestrator. Invoked from
|
||||||
|
# /root/.automated_script.sh after the configurator wizard has produced its
|
||||||
|
# user_*.json / user_*.txt files. The outer shim owns install logging.
|
||||||
|
|
||||||
|
set -eEo pipefail
|
||||||
|
|
||||||
|
# Parse our args; convert to env vars before exec'ing Python so archinstall's
|
||||||
|
# own argparse (which runs at module import time inside the orchestrator)
|
||||||
|
# doesn't choke on flags it doesn't recognize.
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;;
|
||||||
|
--creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;;
|
||||||
|
--full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;;
|
||||||
|
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
|
||||||
|
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
|
||||||
|
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
|
||||||
|
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
|
||||||
|
--defer-provisioning-file) export OMARCHY_INSTALL_DEFER_PROVISIONING_FILE="$2"; shift 2 ;;
|
||||||
|
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
cd /usr/share/omarchy-iso
|
||||||
|
exec python -m orchestrator.main
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -0,0 +1,254 @@
|
|||||||
|
"""Thin compatibility wall around the archinstall Python library.
|
||||||
|
|
||||||
|
ONLY this module imports from archinstall. Everything else uses these helpers.
|
||||||
|
If archinstall's API churns, the blast radius is contained here.
|
||||||
|
|
||||||
|
Tested against archinstall 4.4 (Python 3.14).
|
||||||
|
|
||||||
|
The canonical call sequence (mirrored from archinstall.scripts.guided.py) is:
|
||||||
|
|
||||||
|
FilesystemHandler(disk_config).perform_filesystem_operations()
|
||||||
|
with Installer(mountpoint, disk_config, kernels=, silent=) as inst:
|
||||||
|
# guided.py:84-85 SKIPS this for DiskLayoutType.Pre_mount — pre-mounted
|
||||||
|
# configs do their own mounting before the Installer context opens.
|
||||||
|
if disk_config.config_type != DiskLayoutType.Pre_mount:
|
||||||
|
inst.mount_ordered_layout()
|
||||||
|
sanity_check(inst)
|
||||||
|
inst.generate_key_files() # encrypted only
|
||||||
|
inst.set_mirrors(handler, mirror_config, on_target=False)
|
||||||
|
inst.minimal_installation(...) # base + linux pacstrap
|
||||||
|
inst.set_mirrors(handler, mirror_config, on_target=True)
|
||||||
|
inst.setup_swap(algo=...)
|
||||||
|
inst.create_users(users)
|
||||||
|
inst.add_additional_packages(packages)
|
||||||
|
inst.set_timezone(tz)
|
||||||
|
inst.activate_time_synchronization()
|
||||||
|
inst.set_user_password(root_user)
|
||||||
|
inst.enable_service(services)
|
||||||
|
inst.genfstab()
|
||||||
|
|
||||||
|
Our orchestrator installs Omarchy's Limine files directly instead of invoking
|
||||||
|
archinstall's bootloader helper, so EFI paths and efibootmgr labels are ours
|
||||||
|
from the start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
# Imports are top-level so a missing/incompatible archinstall surfaces at
|
||||||
|
# orchestrator startup, not deep inside a phase.
|
||||||
|
from archinstall.lib.args import ArchConfig, ArchConfigHandler
|
||||||
|
from archinstall.lib.authentication.authentication_handler import AuthenticationHandler
|
||||||
|
from archinstall.lib.disk.filesystem import FilesystemHandler
|
||||||
|
from archinstall.lib.disk.utils import get_parent_device_path, get_unique_path_for_device, udev_sync
|
||||||
|
from archinstall.lib.hardware import SysInfo
|
||||||
|
from archinstall.lib.installer import Installer
|
||||||
|
from archinstall.lib.mirror.mirror_handler import MirrorListHandler
|
||||||
|
from archinstall.lib.models import Bootloader
|
||||||
|
from archinstall.lib.models.device import DiskLayoutType, EncryptionType
|
||||||
|
from archinstall.lib.models.users import User
|
||||||
|
|
||||||
|
from .ui import info
|
||||||
|
|
||||||
|
|
||||||
|
def load_arch_config(config_path: Path, creds_path: Path) -> ArchConfigHandler:
|
||||||
|
"""Build an ArchConfigHandler from on-disk JSON.
|
||||||
|
|
||||||
|
archinstall's ArchConfigHandler reads --config / --creds from sys.argv
|
||||||
|
via argparse at construction time (lib/args.py:_parse_args). It does
|
||||||
|
NOT consult env vars for these paths. We can't pass them on our real
|
||||||
|
argv because the wrapper strips them before exec'ing Python (so other
|
||||||
|
archinstall arg-parsing code doesn't choke on our flags), so we hand
|
||||||
|
archinstall a synthetic argv just for this call.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
saved_argv = sys.argv
|
||||||
|
sys.argv = [
|
||||||
|
saved_argv[0] if saved_argv else "omarchy-iso-install",
|
||||||
|
"--config", str(config_path),
|
||||||
|
"--creds", str(creds_path),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
return ArchConfigHandler()
|
||||||
|
finally:
|
||||||
|
sys.argv = saved_argv
|
||||||
|
|
||||||
|
|
||||||
|
def make_mirror_handler(offline: bool = True) -> MirrorListHandler:
|
||||||
|
return MirrorListHandler(offline=offline, verbose=False)
|
||||||
|
|
||||||
|
|
||||||
|
def perform_filesystem_operations(arch_config: ArchConfig) -> None:
|
||||||
|
"""Partition, format, encrypt. archinstall's FilesystemHandler is its own
|
||||||
|
object (separate from Installer) so we run it before opening the
|
||||||
|
Installer context manager.
|
||||||
|
|
||||||
|
parted's partition-table commit races udev: the disk wipe and the first
|
||||||
|
BLKPG partition registration trigger probes that briefly hold the disk
|
||||||
|
open, and the kernel then refuses to register the remaining partitions
|
||||||
|
(_ped.IOException: "Partition(s) ... have been written, but we have been
|
||||||
|
unable to inform the kernel"). The table is written correctly when that
|
||||||
|
happens — only the kernel's view is stale — so settle udev and redo the
|
||||||
|
operations. The wipe/partition/format sequence is idempotent."""
|
||||||
|
if not arch_config.disk_config:
|
||||||
|
raise RuntimeError("disk_config missing from arch config")
|
||||||
|
|
||||||
|
handler = FilesystemHandler(arch_config.disk_config)
|
||||||
|
|
||||||
|
# archinstall's perform_filesystem_operations defaults to a ~4.5s
|
||||||
|
# "5...4...3...2...1" countdown (FilesystemHandler._final_warning) before it
|
||||||
|
# wipes the disk. That warning is meant for its interactive TUI; in our
|
||||||
|
# orchestrated install it renders nowhere and just sleeps. Suppress it when
|
||||||
|
# the running archinstall still takes the flag — newer releases dropped both
|
||||||
|
# the countdown and the parameter, so only pass it when it's accepted.
|
||||||
|
fs_kwargs = (
|
||||||
|
{"show_countdown": False}
|
||||||
|
if _method_accepts(handler.perform_filesystem_operations, "show_countdown")
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
attempts = 3
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
udev_sync()
|
||||||
|
try:
|
||||||
|
handler.perform_filesystem_operations(**fs_kwargs)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
if attempt == attempts or "unable to inform the kernel" not in str(exc):
|
||||||
|
raise
|
||||||
|
info(f"› partition commit lost a udev race (attempt {attempt}/{attempts}); retrying")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def open_installer(
|
||||||
|
arch_config: ArchConfig,
|
||||||
|
mountpoint: Path,
|
||||||
|
silent: bool = True,
|
||||||
|
) -> Iterator[Installer]:
|
||||||
|
"""Yield an open Installer; ensures __exit__ runs even on exception so
|
||||||
|
/mnt is left clean for a retry."""
|
||||||
|
if not arch_config.disk_config:
|
||||||
|
raise RuntimeError("disk_config missing from arch config")
|
||||||
|
with Installer(
|
||||||
|
mountpoint,
|
||||||
|
arch_config.disk_config,
|
||||||
|
kernels=arch_config.kernels,
|
||||||
|
silent=silent,
|
||||||
|
) as installer:
|
||||||
|
yield installer
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted(arch_config: ArchConfig) -> bool:
|
||||||
|
disk = arch_config.disk_config
|
||||||
|
if not disk or not disk.disk_encryption:
|
||||||
|
return False
|
||||||
|
return disk.disk_encryption.encryption_type != EncryptionType.NO_ENCRYPTION
|
||||||
|
|
||||||
|
|
||||||
|
def is_pre_mount(arch_config: ArchConfig) -> bool:
|
||||||
|
return bool(
|
||||||
|
arch_config.disk_config
|
||||||
|
and arch_config.disk_config.config_type == DiskLayoutType.Pre_mount
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bootloader_enabled(arch_config: ArchConfig) -> bool:
|
||||||
|
bl = arch_config.bootloader_config
|
||||||
|
return bool(bl and bl.bootloader != Bootloader.NO_BOOTLOADER)
|
||||||
|
|
||||||
|
|
||||||
|
def is_limine(arch_config: ArchConfig) -> bool:
|
||||||
|
bl = arch_config.bootloader_config
|
||||||
|
return bool(bl and bl.bootloader == Bootloader.Limine)
|
||||||
|
|
||||||
|
|
||||||
|
def has_uefi() -> bool:
|
||||||
|
return SysInfo.has_uefi()
|
||||||
|
|
||||||
|
|
||||||
|
def parent_device_path(dev_path: Path) -> Path:
|
||||||
|
return get_parent_device_path(dev_path)
|
||||||
|
|
||||||
|
|
||||||
|
def unique_device_path(dev_path: Path) -> Path | None:
|
||||||
|
return get_unique_path_for_device(dev_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _application_handler():
|
||||||
|
"""Return archinstall's application handler across archinstall versions."""
|
||||||
|
module = importlib.import_module("archinstall.lib.applications.application_handler")
|
||||||
|
handler = getattr(module, "application_handler", None)
|
||||||
|
if handler is not None and callable(getattr(handler, "install_applications", None)):
|
||||||
|
return handler
|
||||||
|
|
||||||
|
handler_class = getattr(module, "ApplicationHandler", None)
|
||||||
|
if handler_class is None:
|
||||||
|
raise RuntimeError("archinstall application handler is unavailable")
|
||||||
|
try:
|
||||||
|
handler = handler_class()
|
||||||
|
except TypeError as exc:
|
||||||
|
raise RuntimeError("archinstall ApplicationHandler cannot be constructed") from exc
|
||||||
|
if not callable(getattr(handler, "install_applications", None)):
|
||||||
|
raise RuntimeError("archinstall ApplicationHandler has no install_applications method")
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _method_accepts(method, name: str) -> bool:
|
||||||
|
"""Return whether a bound archinstall method accepts a named argument.
|
||||||
|
|
||||||
|
Do not use inspect.signature here: Python 3.14 may evaluate archinstall's
|
||||||
|
lazy annotations, and some archinstall releases annotate with names that are
|
||||||
|
only imported under TYPE_CHECKING.
|
||||||
|
"""
|
||||||
|
fn = getattr(method, "__func__", method)
|
||||||
|
code = getattr(fn, "__code__", None)
|
||||||
|
if code is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
positional = code.co_varnames[:code.co_argcount]
|
||||||
|
kwonly = code.co_varnames[code.co_argcount:code.co_argcount + code.co_kwonlyargcount]
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def install_applications(installer: Installer, arch_config: ArchConfig) -> None:
|
||||||
|
"""Install archinstall application selections such as PipeWire audio.
|
||||||
|
|
||||||
|
The configurator still writes archinstall's audio_config. We own phase
|
||||||
|
ordering now, but should not silently drop archinstall's hardware-aware
|
||||||
|
application installers (SOF/ALSA firmware detection, PipeWire packages,
|
||||||
|
Bluetooth selections, etc.).
|
||||||
|
"""
|
||||||
|
app_config = arch_config.app_config
|
||||||
|
if not app_config:
|
||||||
|
return
|
||||||
|
|
||||||
|
users = arch_config.auth_config.users if arch_config.auth_config else None
|
||||||
|
handler = _application_handler()
|
||||||
|
install_applications_method = handler.install_applications
|
||||||
|
if _method_accepts_users(install_applications_method):
|
||||||
|
install_applications_method(installer, app_config, users)
|
||||||
|
else:
|
||||||
|
install_applications_method(installer, app_config)
|
||||||
|
|
||||||
|
|
||||||
|
def root_user(arch_config: ArchConfig) -> User | None:
|
||||||
|
auth = arch_config.auth_config
|
||||||
|
if not auth or not auth.root_enc_password:
|
||||||
|
return None
|
||||||
|
return User("root", auth.root_enc_password, False)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Install context: parsed configurator output, invocation paths, and a
|
||||||
|
mutable `state` dict for objects that live across phases (e.g., the
|
||||||
|
archinstall config handler and mirror list handler)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
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:
|
||||||
|
config_path: Path
|
||||||
|
creds_path: Path
|
||||||
|
full_name: str
|
||||||
|
email: str
|
||||||
|
encrypt: bool
|
||||||
|
authorized_keys_path: Path | None
|
||||||
|
tailscale_authkey_path: Path | None
|
||||||
|
|
||||||
|
user_configuration: dict
|
||||||
|
user_credentials: dict
|
||||||
|
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")
|
||||||
|
state_dir: Path = Path("/run/omarchy-install")
|
||||||
|
log_path: Path = Path("/var/log/omarchy-install.log")
|
||||||
|
target_log_path: Path = Path("/mnt/var/log/omarchy-install.log")
|
||||||
|
|
||||||
|
# Mutable per-run state shared across phases (e.g., 'arch_config_handler',
|
||||||
|
# 'mirror_handler'). Phases populate as needed; later phases read.
|
||||||
|
state: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "InstallContext":
|
||||||
|
config_str = os.environ.get("OMARCHY_INSTALL_CONFIG")
|
||||||
|
creds_str = os.environ.get("OMARCHY_INSTALL_CREDS")
|
||||||
|
if not config_str or not creds_str:
|
||||||
|
raise RuntimeError("OMARCHY_INSTALL_CONFIG and OMARCHY_INSTALL_CREDS must be set")
|
||||||
|
|
||||||
|
config_path = Path(config_str)
|
||||||
|
creds_path = Path(creds_str)
|
||||||
|
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
|
||||||
|
# user_credentials.json requirement.
|
||||||
|
defer_provisioning_marker = _optional_path(os.environ.get("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE"))
|
||||||
|
defer_provisioning = bool(omarchy_install.get("defer_provisioning")) or defer_provisioning_marker is not None
|
||||||
|
omarchy_install["defer_provisioning"] = defer_provisioning
|
||||||
|
|
||||||
|
if creds_path.exists():
|
||||||
|
user_credentials = json.loads(creds_path.read_text())
|
||||||
|
elif defer_provisioning:
|
||||||
|
user_credentials = {"users": []}
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"credentials file missing: {creds_path}")
|
||||||
|
|
||||||
|
if defer_provisioning:
|
||||||
|
# Deferred provisioning promises no account until first boot. A supplied
|
||||||
|
# credentials file (a rig handing over its LUKS passphrase) must
|
||||||
|
# not smuggle in users or a root password.
|
||||||
|
encryption_password = user_credentials.get("encryption_password")
|
||||||
|
user_credentials = {"users": []}
|
||||||
|
if encryption_password:
|
||||||
|
user_credentials["encryption_password"] = encryption_password
|
||||||
|
|
||||||
|
arch_configuration = dict(user_configuration)
|
||||||
|
arch_configuration.pop("omarchy_install", None)
|
||||||
|
|
||||||
|
if defer_provisioning:
|
||||||
|
# The userless invariant covers the archinstall config too: a
|
||||||
|
# combined/legacy config could carry account authentication that
|
||||||
|
# archinstall would still act on (users, a root password). Strip
|
||||||
|
# every such field so no account is created before first boot.
|
||||||
|
_strip_account_fields(arch_configuration)
|
||||||
|
|
||||||
|
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
|
||||||
|
state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if defer_provisioning:
|
||||||
|
# Encrypted deferred-provisioning installs have no user password to protect LUKS
|
||||||
|
# with. Generate a throwaway passphrase (staged for first-boot
|
||||||
|
# re-key by the stage_provisioning_state phase) and hand it to archinstall
|
||||||
|
# through both places it may look: the disk_encryption block and
|
||||||
|
# the credentials file.
|
||||||
|
_inject_provisioning_encryption_password(arch_configuration, user_credentials)
|
||||||
|
creds_path = state_dir / "provisioning-user_credentials.json"
|
||||||
|
creds_path.write_text(json.dumps(user_credentials, indent=2) + "\n")
|
||||||
|
creds_path.chmod(0o600)
|
||||||
|
|
||||||
|
arch_config_path = state_dir / "archinstall-user_configuration.json"
|
||||||
|
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
|
||||||
|
|
||||||
|
ctx = cls(
|
||||||
|
config_path=config_path,
|
||||||
|
creds_path=creds_path,
|
||||||
|
full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")),
|
||||||
|
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
|
||||||
|
encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"),
|
||||||
|
authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")),
|
||||||
|
tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
|
||||||
|
user_configuration=user_configuration,
|
||||||
|
user_credentials=user_credentials,
|
||||||
|
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", {})
|
||||||
|
target_mount = omarchy_install.get("target_mount") or disk_config.get("mountpoint")
|
||||||
|
if target_mount:
|
||||||
|
ctx.target = Path(target_mount)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
@property
|
||||||
|
def username(self) -> str:
|
||||||
|
users = self.user_credentials.get("users") or []
|
||||||
|
if users:
|
||||||
|
return users[0]["username"]
|
||||||
|
if self.defer_provisioning:
|
||||||
|
return ""
|
||||||
|
raise RuntimeError("user_credentials.json contains no users")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mode(self) -> str:
|
||||||
|
if mode := self.omarchy_install.get("mode"):
|
||||||
|
return mode
|
||||||
|
cfg_type = self.user_configuration.get("disk_config", {}).get("config_type")
|
||||||
|
return "protected" if cfg_type == "pre_mounted_config" else "full_disk"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_protected(self) -> bool:
|
||||||
|
return self.mode == "protected"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_account_fields(arch_configuration: dict) -> None:
|
||||||
|
"""Remove every field archinstall reads to create users or set a root
|
||||||
|
password, so it cannot ship a hidden provisioning account.
|
||||||
|
Encryption material lives elsewhere (disk_config.disk_encryption) and is
|
||||||
|
untouched."""
|
||||||
|
for key in ("!users", "!root-password", "root_enc_password", "users"):
|
||||||
|
arch_configuration.pop(key, None)
|
||||||
|
|
||||||
|
auth = arch_configuration.get("auth_config")
|
||||||
|
if isinstance(auth, dict):
|
||||||
|
for key in ("users", "root_enc_password"):
|
||||||
|
auth.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_provisioning_encryption_password(arch_configuration: dict, user_credentials: dict) -> None:
|
||||||
|
"""Ensure an encrypted deferred-provisioning install has a LUKS passphrase archinstall can
|
||||||
|
use. Reuse one the input already carries (an autoinstall rig may supply
|
||||||
|
its own); otherwise generate a throwaway. Mutates the disk_encryption
|
||||||
|
block in place — arch_configuration shares it with user_configuration, so
|
||||||
|
the stage_provisioning_state phase can read the effective passphrase back."""
|
||||||
|
disk_encryption = (arch_configuration.get("disk_config") or {}).get("disk_encryption")
|
||||||
|
if disk_encryption is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
password = disk_encryption.get("encryption_password") or user_credentials.get("encryption_password")
|
||||||
|
if not password:
|
||||||
|
password = secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
disk_encryption["encryption_password"] = password
|
||||||
|
user_credentials["encryption_password"] = password
|
||||||
|
|
||||||
|
|
||||||
|
def _default_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"
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"target_mount": disk_config.get("mountpoint") or "/mnt",
|
||||||
|
"boot": {
|
||||||
|
"esp_mount": "/boot",
|
||||||
|
"esp_path": "/EFI/limine",
|
||||||
|
"efi_binary": "limine_x64.efi",
|
||||||
|
"enable_fallback": mode == "full_disk",
|
||||||
|
},
|
||||||
|
"storage": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_path(path: str | None) -> Path | None:
|
||||||
|
"""A path the caller always passes but that need not exist. The live ISO
|
||||||
|
passes --authorized-keys-file on every install; only autoinstall drives put
|
||||||
|
a file there."""
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
p = Path(path)
|
||||||
|
return p if p.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text(path: str | None) -> str:
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
return ""
|
||||||
|
return p.read_text().strip()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Offline target keyboard configuration.
|
||||||
|
|
||||||
|
archinstall's set_keyboard_language boots the installed system in a
|
||||||
|
systemd-nspawn container just to run localectl. systemd-firstboot --root
|
||||||
|
produces the part of that output Omarchy actually consumes — KEYMAP for the
|
||||||
|
console plus the XKB* settings in vconsole.conf that omarchy's
|
||||||
|
detect-keyboard-layout.sh copies into Hyprland's kb_layout — without booting
|
||||||
|
anything. The Xorg 00-keyboard.conf that localectl also writes is not
|
||||||
|
generated: nothing on an Omarchy system reads it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .command import capture
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
if input_method is None:
|
||||||
|
return True
|
||||||
|
language = "us"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# FONT= line archinstall's set_vconsole wrote earlier.
|
||||||
|
vconsole_path = target / "etc" / "vconsole.conf"
|
||||||
|
font = None
|
||||||
|
if vconsole_path.exists():
|
||||||
|
font = next(
|
||||||
|
(line for line in vconsole_path.read_text().splitlines() if line.startswith("FONT=")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = 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}")
|
||||||
|
|
||||||
|
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", "la"}:
|
||||||
|
raise ValueError(f"Unknown input keyboard layout: {xkb_layout}")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Omarchy install orchestrator.
|
||||||
|
|
||||||
|
Single tool that owns the full install phase ordering, with archinstall used as
|
||||||
|
a library subsystem (not as the top-level installer).
|
||||||
|
|
||||||
|
The live-ISO wrapper consumes CLI args and passes configuration paths via
|
||||||
|
OMARCHY_INSTALL_* environment variables before Python starts. This keeps
|
||||||
|
archinstall's import-time CLI parsing from seeing Omarchy-specific flags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from .context import InstallContext
|
||||||
|
from .phases import PhaseError, run
|
||||||
|
from .ui import error, info
|
||||||
|
|
||||||
|
|
||||||
|
def build_phases(ctx: InstallContext):
|
||||||
|
"""Phase order. Each entry is (display name, callable taking InstallContext).
|
||||||
|
|
||||||
|
The ordering is the whole point of this orchestrator: package-install
|
||||||
|
hooks (limine-mkinitcpio-hook, in particular) and useradd happen at
|
||||||
|
points where their prerequisites are guaranteed to be in place.
|
||||||
|
|
||||||
|
Full-disk and protected installs use the same phase sequence. The
|
||||||
|
configurator only changes the JSON input: full-disk asks archinstall to
|
||||||
|
create/mount the layout, while protected provides an already-mounted target
|
||||||
|
and the partition details Omarchy needs for boot/fstab generation.
|
||||||
|
"""
|
||||||
|
from .phases_impl import (
|
||||||
|
prepare_live,
|
||||||
|
prepare_install_target,
|
||||||
|
arch_install_system,
|
||||||
|
configure_hibernation,
|
||||||
|
run_system_finalizer,
|
||||||
|
stage_provisioning_state,
|
||||||
|
finalize_limine_boot,
|
||||||
|
run_chroot_finalizer,
|
||||||
|
configure_dns_resolver,
|
||||||
|
configure_login,
|
||||||
|
configure_ssh_access,
|
||||||
|
configure_tailscale,
|
||||||
|
validate_boot,
|
||||||
|
create_factory_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
("Preparing live environment", prepare_live),
|
||||||
|
("Preparing install target", prepare_install_target),
|
||||||
|
("Installing Arch + Omarchy", arch_install_system),
|
||||||
|
("Configuring hibernation", configure_hibernation),
|
||||||
|
("Configuring system", run_system_finalizer),
|
||||||
|
# Before finalize_limine_boot: the deferred-provisioning cryptkey drop-in and keyfile
|
||||||
|
# must be in place for the final UKI build.
|
||||||
|
("Staging provisioning", stage_provisioning_state),
|
||||||
|
("Finalizing Limine boot", finalize_limine_boot),
|
||||||
|
("Finalizing user", run_chroot_finalizer),
|
||||||
|
("Configuring login", configure_login),
|
||||||
|
("Configuring SSH access", configure_ssh_access),
|
||||||
|
("Configuring Tailscale", configure_tailscale),
|
||||||
|
("Configuring DNS resolver", configure_dns_resolver),
|
||||||
|
("Validating boot setup", validate_boot),
|
||||||
|
("Creating factory snapshot", create_factory_snapshot),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
ctx = InstallContext.from_env()
|
||||||
|
except RuntimeError as e:
|
||||||
|
error(f"Configuration error: {e}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
who = ctx.username or "deferred provisioning (user created at first boot)"
|
||||||
|
info(f"Installing Omarchy for {who} → {ctx.target}")
|
||||||
|
|
||||||
|
from .phases_impl import (
|
||||||
|
boost_cpu_governor,
|
||||||
|
cleanup_bind_mounts,
|
||||||
|
cleanup_protected_state,
|
||||||
|
cleanup_target_hook_masks,
|
||||||
|
restore_cpu_governors,
|
||||||
|
)
|
||||||
|
|
||||||
|
governors = boost_cpu_governor()
|
||||||
|
|
||||||
|
success = False
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
run(ctx, build_phases(ctx))
|
||||||
|
success = True
|
||||||
|
except PhaseError:
|
||||||
|
error("Installation halted.")
|
||||||
|
return 1
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
error("Installation interrupted.")
|
||||||
|
return 130
|
||||||
|
|
||||||
|
info("Installation complete.")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
restore_cpu_governors(governors)
|
||||||
|
cleanup_bind_mounts(ctx)
|
||||||
|
cleanup_target_hook_masks(ctx)
|
||||||
|
if not success:
|
||||||
|
cleanup_protected_state(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Phase state machine. Each phase is a (name, callable) pair; callables take
|
||||||
|
the InstallContext and either return cleanly or raise to abort the install."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .context import InstallContext
|
||||||
|
from .ui import error, info
|
||||||
|
|
||||||
|
|
||||||
|
PhaseFn = Callable[[InstallContext], None]
|
||||||
|
|
||||||
|
|
||||||
|
class PhaseError(Exception):
|
||||||
|
"""Raised when a phase fails. Wrapped with the phase name."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
|
||||||
|
ctx.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
state_path = ctx.state_dir / "state.json"
|
||||||
|
state = {
|
||||||
|
"started_at": time.time(),
|
||||||
|
# The dashboard counts packages under <target>/var/lib/pacman/local;
|
||||||
|
# publish the path rather than have the UI assume /mnt.
|
||||||
|
"target": str(ctx.target),
|
||||||
|
"total_phases": len(phases),
|
||||||
|
"current_index": 0,
|
||||||
|
"current_phase": "Starting installation",
|
||||||
|
"phases": [],
|
||||||
|
}
|
||||||
|
_write_state(state_path, state)
|
||||||
|
|
||||||
|
for index, (name, fn) in enumerate(phases):
|
||||||
|
state["current_index"] = index
|
||||||
|
state["current_phase"] = name
|
||||||
|
state["phase_started_at"] = time.time()
|
||||||
|
_write_state(state_path, state)
|
||||||
|
|
||||||
|
info(f"› {name}")
|
||||||
|
started = time.time()
|
||||||
|
try:
|
||||||
|
fn(ctx)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
elapsed = time.time() - started
|
||||||
|
state["phases"].append({
|
||||||
|
"name": name,
|
||||||
|
"status": "failed",
|
||||||
|
"elapsed": elapsed,
|
||||||
|
"error": str(exc),
|
||||||
|
})
|
||||||
|
_write_state(state_path, state)
|
||||||
|
|
||||||
|
error(f"Phase '{name}' failed after {elapsed:.1f}s: {exc}")
|
||||||
|
traceback.print_exc()
|
||||||
|
raise PhaseError(f"phase {name} failed: {exc}") from exc
|
||||||
|
|
||||||
|
elapsed = time.time() - started
|
||||||
|
state["phases"].append({"name": name, "status": "ok", "elapsed": elapsed})
|
||||||
|
_write_state(state_path, state)
|
||||||
|
|
||||||
|
state["current_index"] = max(len(phases) - 1, 0)
|
||||||
|
state["current_phase"] = "Installation complete"
|
||||||
|
state["finished_at"] = time.time()
|
||||||
|
# Expected vs actual for the bar's denominator, so drift is visible in
|
||||||
|
# acceptance runs rather than only by watching a bar creep.
|
||||||
|
state["installed_packages"] = _installed_package_count(ctx.target)
|
||||||
|
state["expected_packages"] = _expected_package_count()
|
||||||
|
_write_state(state_path, state)
|
||||||
|
|
||||||
|
timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json"
|
||||||
|
timing_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_state(timing_path, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _installed_package_count(target: Path) -> int:
|
||||||
|
"""Packages libalpm installed into the target — one directory each under
|
||||||
|
local/, which is what the dashboard counts live."""
|
||||||
|
local_db = target / "var" / "lib" / "pacman" / "local"
|
||||||
|
try:
|
||||||
|
with os.scandir(local_db) as entries:
|
||||||
|
return sum(1 for entry in entries if entry.is_dir())
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_package_count() -> int:
|
||||||
|
path = Path("/usr/share/omarchy-iso/expected-packages")
|
||||||
|
try:
|
||||||
|
return int(path.read_text().split()[0])
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(path: Path, state: dict) -> None:
|
||||||
|
# Dashboard polls this file while phases update it. Write atomically so the
|
||||||
|
# reader never observes a truncated/partial JSON document and resets UI.
|
||||||
|
tmp = path.with_name(f".{path.name}.tmp")
|
||||||
|
tmp.write_text(json.dumps(state, indent=2, default=str))
|
||||||
|
tmp.replace(path)
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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))
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Orchestrator progress lines.
|
||||||
|
|
||||||
|
These land in the install log, not on a screen: omarchy-install-dashboard owns
|
||||||
|
the visible UI, captures the orchestrator's stdout into the support log, and
|
||||||
|
strips CSI sequences on the way in. The colouring was therefore never seen by
|
||||||
|
anyone, while gum charged a Go process start per line for it. Plain writes keep
|
||||||
|
the same indented shape at no cost.
|
||||||
|
|
||||||
|
Flush every line: pacstrap and arch-chroot inherit this fd, so buffering here
|
||||||
|
would reorder our lines against theirs in the log.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(text: str) -> None:
|
||||||
|
sys.stdout.write(f"\n {text}\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def info(text: str) -> None:
|
||||||
|
_emit(text)
|
||||||
|
|
||||||
|
|
||||||
|
def error(text: str) -> None:
|
||||||
|
_emit(text)
|
||||||
@@ -2,4 +2,4 @@ title Omarchy (x86_64, UEFI)
|
|||||||
sort-key 01
|
sort-key 01
|
||||||
linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
||||||
initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
||||||
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash
|
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
|
||||||
@@ -54,13 +54,13 @@ timeout_style=hidden
|
|||||||
|
|
||||||
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
|
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
|
||||||
set gfxpayload=keep
|
set gfxpayload=keep
|
||||||
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash
|
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
|
||||||
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
||||||
}
|
}
|
||||||
|
|
||||||
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
|
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
|
||||||
set gfxpayload=keep
|
set gfxpayload=keep
|
||||||
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on
|
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0
|
||||||
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +104,3 @@ menuentry 'System restart' --class reboot --class restart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# GRUB init tune for accessibility
|
|
||||||
play 600 988 1 1319 4
|
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ timeout_style=hidden
|
|||||||
|
|
||||||
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
|
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
|
||||||
set gfxpayload=keep
|
set gfxpayload=keep
|
||||||
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash
|
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash xe.enable_panel_replay=0 initramfs_async=0
|
||||||
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
||||||
}
|
}
|
||||||
|
|
||||||
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
|
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
|
||||||
set gfxpayload=keep
|
set gfxpayload=keep
|
||||||
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on
|
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on xe.enable_panel_replay=0 initramfs_async=0
|
||||||
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#
|
||||||
|
# /etc/pacman.conf
|
||||||
|
#
|
||||||
|
# See the pacman.conf(5) manpage for option and repository directives
|
||||||
|
[options]
|
||||||
|
HoldPkg = pacman glibc
|
||||||
|
Architecture = auto
|
||||||
|
NoProgressBar
|
||||||
|
ParallelDownloads = 5
|
||||||
|
|
||||||
|
# By default, pacman accepts packages signed by keys that its local keyring
|
||||||
|
# trusts (see pacman-key and its man page), as well as unsigned packages.
|
||||||
|
SigLevel = Required DatabaseOptional
|
||||||
|
LocalFileSigLevel = Optional
|
||||||
|
|
||||||
|
# No signature checks for the offline mirror: it ships inside the ISO, whose
|
||||||
|
# integrity is already covered by the airootfs sha512 and the ISO signature.
|
||||||
|
# "Optional TrustAll" is NOT a bypass — Optional verifies whenever a .sig file
|
||||||
|
# exists (they all do), and pacstrap verifies against the LIVE keyring (pacman
|
||||||
|
# -r relocates the root but not GpgDir). That made installs depend on archiso's
|
||||||
|
# slow boot-time pacman-init.service having finished, which stalled or failed
|
||||||
|
# pacstrap on real hardware with "required key missing from keyring".
|
||||||
|
[offline]
|
||||||
|
SigLevel = Never
|
||||||
|
Server = file:///var/cache/omarchy/mirror/offline/
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#
|
||||||
|
# /etc/pacman.conf
|
||||||
|
#
|
||||||
|
# See the pacman.conf(5) manpage for option and repository directives
|
||||||
|
|
||||||
|
[options]
|
||||||
|
HoldPkg = pacman glibc
|
||||||
|
Architecture = auto
|
||||||
|
ParallelDownloads = 5
|
||||||
|
|
||||||
|
# By default, pacman accepts packages signed by keys that its local keyring
|
||||||
|
# trusts (see pacman-key and its man page), as well as unsigned packages.
|
||||||
|
SigLevel = Required DatabaseOptional
|
||||||
|
LocalFileSigLevel = Optional
|
||||||
|
|
||||||
|
[omarchy]
|
||||||
|
Server = https://pkgs.omarchy.org/edge/$arch
|
||||||
|
|
||||||
|
[core]
|
||||||
|
Server = https://mirror.omarchy.org/$repo/os/$arch
|
||||||
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
Server = https://mirror.omarchy.org/$repo/os/$arch
|
||||||
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
[multilib]
|
||||||
|
Server = https://mirror.omarchy.org/$repo/os/$arch
|
||||||
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
[arch-mact2]
|
||||||
|
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||||
|
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||||
|
SigLevel = Never
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#
|
||||||
|
# /etc/pacman.conf
|
||||||
|
#
|
||||||
|
# See the pacman.conf(5) manpage for option and repository directives
|
||||||
|
|
||||||
|
[options]
|
||||||
|
HoldPkg = pacman glibc
|
||||||
|
Architecture = auto
|
||||||
|
ParallelDownloads = 5
|
||||||
|
|
||||||
|
# By default, pacman accepts packages signed by keys that its local keyring
|
||||||
|
# trusts (see pacman-key and its man page), as well as unsigned packages.
|
||||||
|
SigLevel = Required DatabaseOptional
|
||||||
|
LocalFileSigLevel = Optional
|
||||||
|
|
||||||
|
[omarchy]
|
||||||
|
Server = https://pkgs.omarchy.org/rc/$arch
|
||||||
|
|
||||||
|
[core]
|
||||||
|
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
|
||||||
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
[extra]
|
||||||
|
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
|
||||||
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
[multilib]
|
||||||
|
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
|
||||||
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
|
[arch-mact2]
|
||||||
|
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||||
|
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||||
|
SigLevel = Never
|
||||||
@@ -13,6 +13,9 @@ ParallelDownloads = 5
|
|||||||
SigLevel = Required DatabaseOptional
|
SigLevel = Required DatabaseOptional
|
||||||
LocalFileSigLevel = Optional
|
LocalFileSigLevel = Optional
|
||||||
|
|
||||||
|
[omarchy]
|
||||||
|
Server = https://pkgs.omarchy.org/stable/$arch
|
||||||
|
|
||||||
[core]
|
[core]
|
||||||
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
@@ -25,10 +28,7 @@ Include = /etc/pacman.d/mirrorlist
|
|||||||
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
|
||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
[omarchy]
|
|
||||||
SigLevel = Optional TrustAll
|
|
||||||
Server = https://pkgs.omarchy.org/$arch
|
|
||||||
|
|
||||||
[arch-mact2]
|
[arch-mact2]
|
||||||
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
|
||||||
|
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
|
||||||
SigLevel = Never
|
SigLevel = Never
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
#
|
|
||||||
# /etc/pacman.conf
|
|
||||||
#
|
|
||||||
# See the pacman.conf(5) manpage for option and repository directives
|
|
||||||
[options]
|
|
||||||
HoldPkg = pacman glibc
|
|
||||||
Architecture = auto
|
|
||||||
NoProgressBar
|
|
||||||
ParallelDownloads = 5
|
|
||||||
|
|
||||||
# By default, pacman accepts packages signed by keys that its local keyring
|
|
||||||
# trusts (see pacman-key and its man page), as well as unsigned packages.
|
|
||||||
SigLevel = Required DatabaseOptional
|
|
||||||
LocalFileSigLevel = Optional
|
|
||||||
|
|
||||||
[offline]
|
|
||||||
SigLevel = Optional TrustAll
|
|
||||||
Server = file:///var/cache/omarchy/mirror/offline/
|
|
||||||
+24
-4
@@ -10,17 +10,37 @@ install_dir="arch"
|
|||||||
buildmodes=('iso')
|
buildmodes=('iso')
|
||||||
bootmodes=('bios.syslinux' 'uefi.grub')
|
bootmodes=('bios.syslinux' 'uefi.grub')
|
||||||
arch="x86_64"
|
arch="x86_64"
|
||||||
pacman_conf="pacman.conf"
|
pacman_conf="pacman-offline.conf"
|
||||||
airootfs_image_type="squashfs"
|
airootfs_image_type="squashfs"
|
||||||
airootfs_image_tool_options=('-comp' 'xz' '-Xbcj' 'x86' '-b' '1M' '-Xdict-size' '1M')
|
# Package archives in the offline mirror are already zstd-compressed. Storing
|
||||||
|
# them in an outer stream saves little space but makes pacman decompress the
|
||||||
|
# outer layer while hashing and extracting every package during installation.
|
||||||
|
#
|
||||||
|
# Everything else in the live root is zstd rather than xz. Squashfs decompresses
|
||||||
|
# on the page-fault path through a single stream (CONFIG_SQUASHFS_DECOMP_SINGLE),
|
||||||
|
# where xz manages ~100MB/s against zstd's ~900MB/s, and the live root is read
|
||||||
|
# cold on every boot: kernel, plymouth, systemd, python, archinstall, gum. The
|
||||||
|
# whole ISO grows well under a percent for it, and dropping the x86 BCJ filter
|
||||||
|
# also removes one of the blockers listed in plans/aarch64-support.md.
|
||||||
|
airootfs_image_tool_options=(
|
||||||
|
'-comp' 'zstd'
|
||||||
|
'-Xcompression-level' '19'
|
||||||
|
'-b' '1M'
|
||||||
|
'-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)'
|
||||||
|
)
|
||||||
bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19')
|
bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19')
|
||||||
file_permissions=(
|
file_permissions=(
|
||||||
["/etc/shadow"]="0:0:400"
|
["/etc/shadow"]="0:0:400"
|
||||||
["/root"]="0:0:750"
|
["/root"]="0:0:750"
|
||||||
["/root/.automated_script.sh"]="0:0:755"
|
["/root/.automated_script.sh"]="0:0:755"
|
||||||
["/root/.gnupg"]="0:0:700"
|
["/root/.gnupg"]="0:0:700"
|
||||||
["/usr/local/bin/choose-mirror"]="0:0:755"
|
|
||||||
["/root/configurator"]="0:0:755"
|
["/root/configurator"]="0:0:755"
|
||||||
["/var/cache/omarchy/mirror/offline/"]="0:0:775"
|
["/usr/local/bin/choose-mirror"]="0:0:755"
|
||||||
|
["/usr/local/bin/omarchy-cidata-load"]="0:0:755"
|
||||||
|
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
|
||||||
|
["/usr/local/bin/omarchy-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"
|
["/usr/local/bin/omarchy-upload-log"]="0:0:755"
|
||||||
|
["/var/cache/omarchy/mirror/offline/"]="0:0:775"
|
||||||
)
|
)
|
||||||
@@ -6,7 +6,7 @@ ENDTEXT
|
|||||||
MENU LABEL Omarchy install medium (x86_64, NBD)
|
MENU LABEL Omarchy install medium (x86_64, NBD)
|
||||||
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
||||||
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
||||||
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y
|
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y initramfs_async=0
|
||||||
SYSAPPEND 3
|
SYSAPPEND 3
|
||||||
|
|
||||||
LABEL arch64_nfs
|
LABEL arch64_nfs
|
||||||
@@ -17,7 +17,7 @@ ENDTEXT
|
|||||||
MENU LABEL Omarchy install medium (x86_64, NFS)
|
MENU LABEL Omarchy install medium (x86_64, NFS)
|
||||||
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
||||||
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
||||||
APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y
|
APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y initramfs_async=0
|
||||||
SYSAPPEND 3
|
SYSAPPEND 3
|
||||||
|
|
||||||
LABEL arch64_http
|
LABEL arch64_http
|
||||||
@@ -28,5 +28,5 @@ ENDTEXT
|
|||||||
MENU LABEL Omarchy install medium (x86_64, HTTP)
|
MENU LABEL Omarchy install medium (x86_64, HTTP)
|
||||||
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
||||||
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
||||||
APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y
|
APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y initramfs_async=0
|
||||||
SYSAPPEND 3
|
SYSAPPEND 3
|
||||||
@@ -6,7 +6,7 @@ ENDTEXT
|
|||||||
MENU LABEL Omarchy install medium (x86_64, BIOS)
|
MENU LABEL Omarchy install medium (x86_64, BIOS)
|
||||||
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
||||||
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
||||||
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash
|
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0
|
||||||
|
|
||||||
# Accessibility boot option
|
# Accessibility boot option
|
||||||
LABEL arch64speech
|
LABEL arch64speech
|
||||||
@@ -17,4 +17,4 @@ ENDTEXT
|
|||||||
MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech
|
MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech
|
||||||
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
|
||||||
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
|
||||||
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on
|
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,178 @@
|
|||||||
|
# Plan: aarch64 (Generic UEFI ARM64) build for omarchy-iso
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
omarchy-iso currently produces a single x86_64 Arch Linux live ISO. The build is x86-baked from top to bottom — profile arch, package list filename, microcode, kernel choice (`linux-t2`), bootloader configs (BIOS syslinux + UEFI GRUB), squashfs BCJ filter, Node.js download URL, QEMU smoke-test, release filename, and CI runner.
|
||||||
|
|
||||||
|
Target: a parallel **generic UEFI aarch64** ISO — boots on Ampere servers, AWS Graviton VMs, Snapdragon X laptops, ARM dev kits. Anything that exposes vanilla UEFI + ACPI. Apple Silicon (Asahi) and SBCs (U-Boot/rpi-firmware) are out of scope.
|
||||||
|
|
||||||
|
**Assumption:** cross-repo dependencies are someone else's problem to land first. This plan only covers omarchy-iso. The hard prerequisites are listed up front so it's clear what blocks the first green build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard prerequisites (NOT in this plan, but block any green build)
|
||||||
|
|
||||||
|
These exist outside omarchy-iso. Flagged for visibility — without them, nothing here boots:
|
||||||
|
|
||||||
|
1. **aarch64 base packages must exist somewhere we can pacman from.** Vanilla Arch (`geo.mirror.pkgbuild.com`) is x86_64-only — there is no `core/os/aarch64`. The realistic source is **Arch Linux ARM** (`mirror.archlinuxarm.org`) for `core`/`extra`/`alarm`/`aur`. Decision required: target Arch Linux ARM as the aarch64 base distribution.
|
||||||
|
2. **`pkgs.omarchy.org/{stable,edge}/aarch64/`** must serve a real repo. Probed today, both return 404. omarchy-pkgs already has multi-arch build support per its README, so this is a publish step, not a port.
|
||||||
|
3. **Omarchy ISO architecture guard** must allow supported architectures or scope any x86_64-only checks behind an explicit guard.
|
||||||
|
4. **archinstall + Limine** must work end-to-end on aarch64. archinstall supports it; Limine supports aarch64 UEFI. Worth a manual verification before committing to this bootloader path on ARM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Add an `--arch aarch64` flag to `bin/omarchy-iso-make`. Branch on it everywhere x86_64 is currently assumed. Don't fork the profile directory — overlay arch-specific files at build time so the two architectures share one source of truth.
|
||||||
|
|
||||||
|
Output artifact naming already uses no arch suffix until release-time renaming, so two parallel builds coexist cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concrete changes
|
||||||
|
|
||||||
|
### 1. Build entrypoint — `bin/omarchy-iso-make`
|
||||||
|
|
||||||
|
- Add `--arch x86_64|aarch64` flag (default `x86_64` for backward compat).
|
||||||
|
- Pass `OMARCHY_ARCH` env var into the container.
|
||||||
|
- Switch the docker image / platform per arch:
|
||||||
|
- x86_64: `archlinux/archlinux:latest` (unchanged)
|
||||||
|
- aarch64: `archlinuxarm/archlinuxarm:latest` (or `menci/archlinuxarm` — pick whichever publishes a recent multi-arch image), with `--platform linux/arm64`. Native arm64 host preferred; QEMU emulation works but is slow.
|
||||||
|
- Don't change pacman cache mount on the host: pacman keeps per-arch subdirs, so the cache is safe to share.
|
||||||
|
|
||||||
|
### 2. Profile — `configs/profiledef.sh`
|
||||||
|
|
||||||
|
Change `arch` and the squashfs BCJ filter at runtime. Either:
|
||||||
|
- Keep one `profiledef.sh` and have `builder/build-iso.sh` `sed` the arch line + BCJ filter (`x86` → `arm`) before `mkarchiso` runs, **or**
|
||||||
|
- Source `OMARCHY_ARCH` directly inside `profiledef.sh` (mkarchiso sources this file with bash, so env access is fine).
|
||||||
|
|
||||||
|
`bootmodes` also needs to drop `bios.syslinux` for aarch64 (BIOS boot is x86-only) — `bootmodes=('uefi.grub')` on ARM.
|
||||||
|
|
||||||
|
### 3. Build script — `builder/build-iso.sh`
|
||||||
|
|
||||||
|
- **Line 56–57**: Node.js URL grep. Branch on `OMARCHY_ARCH`:
|
||||||
|
- `x86_64` → `linux-x64.tar.gz`
|
||||||
|
- `aarch64` → `linux-arm64.tar.gz`
|
||||||
|
- **Line 73**: package additions. Drop `linux-t2` for aarch64 (T2-only x86 kernel) — use plain `linux`. Write to `packages.${OMARCHY_ARCH}` instead of hardcoded `packages.x86_64`.
|
||||||
|
- **Line 77**: read same `packages.${OMARCHY_ARCH}` for offline mirror enumeration.
|
||||||
|
- **archiso releng overlay** (`cp -r /archiso/configs/releng/*`): the upstream `releng` profile ships only `packages.x86_64`. For aarch64, copy `packages.x86_64` to `packages.aarch64` and prune obviously x86-only entries (`memtest86+`, `intel-ucode`, `amd-ucode`, `edk2-shell` x64 binary, `syslinux`). Land this as a small fixup step in `build-iso.sh` rather than a full fork of `releng`.
|
||||||
|
|
||||||
|
### 4. archinstall package list — `builder/archinstall.packages`
|
||||||
|
|
||||||
|
Drop microcode for aarch64 — `intel-ucode` and `amd-ucode` don't exist for ARM. Either:
|
||||||
|
- Two lists (`archinstall.packages.x86_64`, `archinstall.packages.aarch64`), or
|
||||||
|
- One list with sentinel comments and a filter step in `build-iso.sh:80` that drops microcode lines when `OMARCHY_ARCH=aarch64`.
|
||||||
|
|
||||||
|
The second is less duplication for one-line drift.
|
||||||
|
|
||||||
|
### 5. Bootloader configs
|
||||||
|
|
||||||
|
aarch64 has no BIOS — only UEFI. Drop the syslinux path entirely on ARM.
|
||||||
|
|
||||||
|
- **Skip on aarch64**: `configs/syslinux/*` (BIOS syslinux), `configs/grub/*` references to `shellx64.efi`/`shellia32.efi` (lines 81–85 of `grub.cfg`).
|
||||||
|
- **`configs/efiboot/loader/loader.conf`**: change `default 01-archiso-x86_64-linux.conf` to a per-arch entry. Either two loader.conf files (selected by build-iso.sh) or rename the entry generically (`01-archiso-linux.conf`) and keep one file.
|
||||||
|
- **`configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf`**: produce an aarch64 sibling (`01-archiso-aarch64-linux.conf`) at build time — same template, drop microcode initrd lines, swap title. The kernel/initrd paths use `%ARCH%` already in `grub.cfg` but are hardcoded `x86_64` in this file (line 3–4).
|
||||||
|
- **`configs/grub/grub.cfg` and `loopback.cfg`**: remove or guard the x86_64 conditionals. The `%ARCH%` placeholder gets substituted by mkarchiso from `profiledef.sh:arch`, so the kernel/initrd paths flip automatically. Manually-written `grub_cpu == 'x86_64'` blocks (lines 34–39, 68, 81–85) should drop on aarch64 builds — easiest done with a build-time sed for the aarch64 path, or by extracting them into a separate `grub-x86_64.cfg.fragment` only included on x86 builds.
|
||||||
|
|
||||||
|
The cleanest cut: keep one `grub.cfg` shared, strip the x86 shell/memtest fragments at build time when `OMARCHY_ARCH=aarch64`. Don't fork the file.
|
||||||
|
|
||||||
|
### 6. mkinitcpio preset — `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset`
|
||||||
|
|
||||||
|
Names `vmlinuz-linux-t2` and `initramfs-linux-t2.img`. The filename is the pkgbase and the alpm hook keys off it, so aarch64 does not rename this file — it simply does not ship it, and releng's own `linux.preset` covers the stock kernel. Drop `linux-t2` from `arch_packages` on aarch64 and the boot entries point at `vmlinuz-linux` / `initramfs-linux.img`.
|
||||||
|
|
||||||
|
### 7. Configurator — `configs/airootfs/root/configurator`
|
||||||
|
|
||||||
|
Lines 320–325:
|
||||||
|
```bash
|
||||||
|
if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then
|
||||||
|
kernel_choice="linux-t2"
|
||||||
|
else
|
||||||
|
kernel_choice="linux"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
T2 detection is harmless on aarch64 (`lspci` returns no match), so `kernel_choice` falls through to `linux`. **No change strictly required**, but clearer to wrap the lspci probe in `[[ $(uname -m) == "x86_64" ]]` so the intent reads correctly. Cheap.
|
||||||
|
|
||||||
|
The archinstall JSON's `mirror_config.custom_servers` (lines 422–424) points at `mirror.omarchy.org`, `mirror.rackspace.com/archlinux`, `geo.mirror.pkgbuild.com` — none of these serve aarch64. For aarch64 the list must be `mirror.archlinuxarm.org` and friends. Branch the JSON template on `OMARCHY_ARCH` (or `uname -m` at runtime, since this file runs on the live ISO).
|
||||||
|
|
||||||
|
### 8. pacman configs — `configs/pacman-online-{stable,rc,edge,offline}.conf`
|
||||||
|
|
||||||
|
The `$arch` placeholder is pacman-resolved (matches `Architecture = auto`), so `https://stable-mirror.omarchy.org/$repo/os/$arch` adapts automatically — **as long as the mirror serves aarch64**. The omarchy mirror is the bottleneck (prerequisite #2). The `arch-mact2` repo is x86-only by definition; gate it behind arch on aarch64 builds (drop the section).
|
||||||
|
|
||||||
|
### 9. Smoke-test scripts — `bin/omarchy-iso-boot`, `bin/omarchy-vm`
|
||||||
|
|
||||||
|
Detect arch from the ISO filename (or accept a flag) and switch:
|
||||||
|
- x86_64: `qemu-system-x86_64`, `/usr/share/edk2/x64/OVMF_CODE.4m.fd` + `OVMF_VARS.4m.fd` (unchanged).
|
||||||
|
- aarch64: `qemu-system-aarch64`, `-machine virt -cpu max`, `/usr/share/edk2/aarch64/QEMU_CODE.fd` + `QEMU_VARS.fd` (Arch package: `edk2-armvirt`).
|
||||||
|
|
||||||
|
Both scripts hardcode the OVMF path; small, mechanical fix.
|
||||||
|
|
||||||
|
### 10. Release script — `bin/omarchy-iso-release:52`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*${OMARCHY_ARCH}-"$ISO_REF".iso | head -n1)
|
||||||
|
```
|
||||||
|
Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${ISO_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized.
|
||||||
|
|
||||||
|
### 11. CI — `.github/workflows/nightly-build.yml`
|
||||||
|
|
||||||
|
Add a matrix:
|
||||||
|
```yaml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: [x86_64, aarch64]
|
||||||
|
include:
|
||||||
|
- arch: x86_64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: aarch64
|
||||||
|
runner: ubuntu-24.04-arm # GitHub-hosted ARM runner, GA
|
||||||
|
```
|
||||||
|
Pass `--arch ${{ matrix.arch }}` to the build. Upload artifacts named per-arch.
|
||||||
|
|
||||||
|
If GitHub-hosted ARM runners aren't available on this org's plan, fall back to QEMU emulation on `ubuntu-latest` (slow, ~3-4× build time) or self-hosted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to modify (summary)
|
||||||
|
|
||||||
|
Code:
|
||||||
|
- `bin/omarchy-iso-make` — add `--arch` flag, branch docker image/platform
|
||||||
|
- `bin/omarchy-iso-boot` — branch QEMU binary + OVMF path
|
||||||
|
- `bin/omarchy-vm` — same as iso-boot
|
||||||
|
- `bin/omarchy-iso-release` — parameterize ISO glob
|
||||||
|
- `builder/build-iso.sh` — main branching: Node.js URL, package list filename, releng overlay fixups, kernel package
|
||||||
|
- `builder/archinstall.packages` — filter microcode on aarch64 (sentinel comments + filter step)
|
||||||
|
- `configs/profiledef.sh` — env-driven `arch` + BCJ filter + bootmodes
|
||||||
|
- `configs/efiboot/loader/loader.conf` — per-arch default entry
|
||||||
|
- `configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf` — generate aarch64 sibling at build time
|
||||||
|
- `configs/grub/grub.cfg`, `loopback.cfg` — strip x86-only fragments on aarch64
|
||||||
|
- `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset` — omit on aarch64 (drop linux-t2 there; releng's linux.preset covers the stock kernel)
|
||||||
|
- `configs/airootfs/root/configurator` — branch archinstall JSON mirror list on `uname -m`; optionally guard lspci T2 probe
|
||||||
|
- `.github/workflows/nightly-build.yml` — matrix x86_64/aarch64
|
||||||
|
|
||||||
|
No new files unless we go the dual-list route on archinstall.packages or efiboot loader entries; preferred path is build-time templating to keep one source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
**Local (host is x86_64):**
|
||||||
|
1. `bin/omarchy-iso-make --arch x86_64` — must produce a byte-similar ISO to today's nightly (smoke test for regressions in the refactor).
|
||||||
|
2. `bin/omarchy-iso-make --arch aarch64` — succeeds (slow under QEMU binfmt; expect 30-60 min).
|
||||||
|
3. `bin/omarchy-iso-boot release/omarchy-*-aarch64-*.iso` — boots to the configurator under `qemu-system-aarch64 -machine virt`. Walk through the picker, confirm archinstall lays down a working system, reboot into the installed system.
|
||||||
|
4. Live-iso shell: `pacman -Sy && pacman -Si linux` returns an aarch64 package from `mirror.archlinuxarm.org`.
|
||||||
|
|
||||||
|
**On real hardware (post green QEMU run):**
|
||||||
|
- AWS Graviton VM (`c7g.medium`, dd ISO to a volume, attach as boot) — fastest cloud check, no hardware needed.
|
||||||
|
- One physical ARM64 UEFI box if available (Ampere dev kit, Snapdragon X laptop, etc.) — the long pole.
|
||||||
|
|
||||||
|
**CI:**
|
||||||
|
- Both matrix legs go green on a manual `workflow_dispatch` before merging.
|
||||||
|
- Nightly produces both ISOs as artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open risks (call out before implementation)
|
||||||
|
|
||||||
|
1. **mkarchiso on aarch64 is less-trodden ground.** It accepts `arch="aarch64"`, but the upstream Arch project doesn't dogfood it. Expect to file/patch around small bugs in archiso's helper scripts. Keep the archiso submodule pin tight so a regression doesn't surprise nightly.
|
||||||
|
2. **Limine + LUKS + Btrfs + Snapper on aarch64** — every one of these works individually on ARM, but the combination is what omarchy ships. Worth one manual end-to-end pass before declaring done.
|
||||||
|
3. **Apple T2 / linux-t2 / `arch-mact2` repo** are silently dropped on aarch64; users mistakenly trying to install the aarch64 ISO on a T2 Mac will get an obviously-wrong result. The configurator could refuse to install when arch mismatch is detected, but that's outside this plan.
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
# Consumer Secure Boot Plan: Microsoft Shim + MOK + UKI
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Support Omarchy installs on consumer laptops that ship with UEFI Secure Boot enabled and trust the Microsoft UEFI CA, without requiring users to disable Secure Boot.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Firmware Secure Boot
|
||||||
|
-> Microsoft-signed Omarchy shim
|
||||||
|
-> MokManager when key enrollment is pending
|
||||||
|
-> signed second-stage boot manager
|
||||||
|
-> signed Unified Kernel Image (UKI)
|
||||||
|
-> encrypted Omarchy root
|
||||||
|
```
|
||||||
|
|
||||||
|
For the installed OS, use machine-local Machine Owner Key (MOK) signing for UKIs generated during install and later kernel updates.
|
||||||
|
|
||||||
|
## Why MOK For Installed Systems
|
||||||
|
|
||||||
|
Omarchy's installed UKI is machine-specific because it includes or depends on install-time data such as:
|
||||||
|
|
||||||
|
- Kernel command line for encrypted root.
|
||||||
|
- Initramfs generated for the installed system.
|
||||||
|
- Kernel package version selected at install or update time.
|
||||||
|
|
||||||
|
We must not ship Omarchy's private release signing key on the ISO or installed system. Therefore:
|
||||||
|
|
||||||
|
1. The official ISO is signed by Omarchy release infrastructure.
|
||||||
|
2. During install, generate a machine-local Secure Boot keypair.
|
||||||
|
3. Enroll the public certificate through MokManager.
|
||||||
|
4. Sign installed UKIs and update-generated UKIs with the local private key.
|
||||||
|
5. Store the local private key only on the encrypted installed root, never on the ESP.
|
||||||
|
|
||||||
|
This preserves offline tamper resistance for `/boot` while avoiding any central private key on user machines.
|
||||||
|
|
||||||
|
## Scope Constraints
|
||||||
|
|
||||||
|
- UEFI only.
|
||||||
|
- Microsoft UEFI CA trust path only for consumer machines.
|
||||||
|
- No custom firmware key enrollment for v1 consumer installs.
|
||||||
|
- No TPM auto-unlock for Omarchy root in v1.
|
||||||
|
- No attempt to modify Windows BitLocker protectors or TPM ownership.
|
||||||
|
- Secure Boot mode uses signed UKIs only; no unsigned external kernel, initramfs, or command line files.
|
||||||
|
- Secure Boot mode may use a different boot manager than the current Limine path until Limine has a proven verified-boot story.
|
||||||
|
|
||||||
|
## Bootloader Decision
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
|
||||||
|
- Keep Limine as the normal non-Secure-Boot bootloader unless we later prove Limine enforces signed payloads correctly.
|
||||||
|
- Use systemd-boot only for Secure Boot installs if that is the fastest reliable path.
|
||||||
|
- Boot only UKIs with embedded command line and initramfs.
|
||||||
|
- Disable or avoid unsigned boot entry editing in Secure Boot mode.
|
||||||
|
|
||||||
|
If later validation proves Limine can enforce signed payloads behind shim, this plan can swap the second stage while keeping the shim, MOK, UKI, and signing architecture.
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
### Omarchy Shim Artifacts
|
||||||
|
|
||||||
|
These are release artifacts, not generated on normal developer machines:
|
||||||
|
|
||||||
|
- `shimx64.efi`, signed by Microsoft UEFI CA.
|
||||||
|
- `mmx64.efi` / MokManager artifact compatible with the shim.
|
||||||
|
- Optional `fbx64.efi` fallback artifact if needed by the selected shim packaging.
|
||||||
|
- Shim SBAT metadata owned by Omarchy.
|
||||||
|
- Omarchy public release certificate embedded in shim.
|
||||||
|
|
||||||
|
Do not depend on another distribution's shim as a permanent product strategy. Omarchy needs its own shim submission, SBAT identity, revocation path, and release process.
|
||||||
|
|
||||||
|
### Omarchy Signing Material
|
||||||
|
|
||||||
|
Release infrastructure owns:
|
||||||
|
|
||||||
|
- Omarchy Secure Boot release private key, stored offline or in an HSM-backed CI secret store.
|
||||||
|
- Omarchy Secure Boot release public certificate, embedded in shim and shipped for verification.
|
||||||
|
|
||||||
|
Installed systems own:
|
||||||
|
|
||||||
|
- Machine-local MOK private key, stored on encrypted root with `0600` permissions.
|
||||||
|
- Machine-local MOK public certificate, enrolled through MokManager.
|
||||||
|
|
||||||
|
Private keys must never be committed to this repo.
|
||||||
|
|
||||||
|
## Build-Time Flow
|
||||||
|
|
||||||
|
Add a Secure Boot-capable ISO build mode.
|
||||||
|
|
||||||
|
```text
|
||||||
|
bin/omarchy-iso-make --secure-boot
|
||||||
|
```
|
||||||
|
|
||||||
|
Build behavior:
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OMARCHY_SECURE_BOOT_SIGNING_MODE=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev mode creates local test keys and is only expected to boot in QEMU firmware enrolled with those test keys. It must not be presented as Microsoft Secure Boot compatible.
|
||||||
|
|
||||||
|
## Installed-System Flow
|
||||||
|
|
||||||
|
When the live installer detects Secure Boot enabled:
|
||||||
|
|
||||||
|
1. Show a Secure Boot explanation before disk mutation.
|
||||||
|
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.
|
||||||
|
6. Build an installed UKI with embedded kernel, initramfs, command line, and OS release data.
|
||||||
|
7. Sign the installed UKI with the machine-local MOK private key.
|
||||||
|
8. Sign the selected second-stage boot manager with the machine-local MOK private key, unless it is release-signed and expected to remain static.
|
||||||
|
9. Schedule public certificate enrollment with `mokutil --import`.
|
||||||
|
10. Tell the user they must complete MokManager enrollment on the next reboot.
|
||||||
|
11. Preserve current firmware boot order by default on dual-boot machines.
|
||||||
|
|
||||||
|
Expected first reboot sequence after install:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Firmware -> Omarchy shim -> MokManager -> user enrolls Omarchy machine key -> reboot -> Omarchy shim -> signed boot manager -> signed UKI -> LUKS unlock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kernel Update Flow
|
||||||
|
|
||||||
|
Secure Boot installs need deterministic UKI regeneration and signing after kernel or initramfs changes.
|
||||||
|
|
||||||
|
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. 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:
|
||||||
|
|
||||||
|
- If signing fails, do not replace the currently bootable UKI.
|
||||||
|
- If ESP space is low, keep the current UKI and fail loudly.
|
||||||
|
- If the local signing key is missing, block the update from producing an unsigned boot artifact.
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
### `bin/omarchy-iso-make`
|
||||||
|
|
||||||
|
Add flags:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--secure-boot build Secure Boot-capable ISO flavor
|
||||||
|
--secure-boot-dev build with local test keys for QEMU only
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass mode and signing configuration into the Docker build environment.
|
||||||
|
|
||||||
|
### `builder/build-iso.sh`
|
||||||
|
|
||||||
|
Add Secure Boot build support:
|
||||||
|
|
||||||
|
- Copy shim artifacts into the UEFI boot path.
|
||||||
|
- Build live UKI.
|
||||||
|
- Sign live UKI and second-stage EFI artifact.
|
||||||
|
- Fail official Secure Boot builds if release signing material is unavailable.
|
||||||
|
- Write `/root/omarchy_secure_boot_mode` into the live environment.
|
||||||
|
- Assert that Secure Boot boot entries do not point at unsigned payloads.
|
||||||
|
|
||||||
|
### `builder/secure-boot/`
|
||||||
|
|
||||||
|
Add non-secret Secure Boot build inputs:
|
||||||
|
|
||||||
|
- Public release certificate.
|
||||||
|
- SBAT template.
|
||||||
|
- Artifact manifest with expected shim/MokManager checksums.
|
||||||
|
- Developer test-key generation script for QEMU only.
|
||||||
|
|
||||||
|
Do not store private keys in this directory.
|
||||||
|
|
||||||
|
### `configs/airootfs/root/configurator`
|
||||||
|
|
||||||
|
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 (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 the install orchestrator.
|
||||||
|
|
||||||
|
Example state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SECURE_BOOT_ENABLED=true
|
||||||
|
SECURE_BOOT_INSTALL=true
|
||||||
|
SECURE_BOOT_BOOTLOADER=systemd-boot
|
||||||
|
SECURE_BOOT_UKI=true
|
||||||
|
SECURE_BOOT_MOK_COMMON_NAME="Omarchy Machine Owner Key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `configs/airootfs/usr/share/omarchy-iso/orchestrator/`
|
||||||
|
|
||||||
|
Add Secure Boot install support to the install phases:
|
||||||
|
|
||||||
|
- 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 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
|
||||||
|
|
||||||
|
Add installed files similar to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/omarchy/secure-boot.conf
|
||||||
|
/etc/kernel/cmdline
|
||||||
|
/etc/pacman.d/hooks/90-omarchy-uki.hook
|
||||||
|
/usr/local/sbin/omarchy-build-uki
|
||||||
|
/usr/local/sbin/omarchy-sign-uki
|
||||||
|
/boot/EFI/Omarchy/shimx64.efi
|
||||||
|
/boot/EFI/Omarchy/mmx64.efi
|
||||||
|
/boot/EFI/Omarchy/systemd-bootx64.efi
|
||||||
|
/boot/EFI/Linux/omarchy-linux.efi
|
||||||
|
/boot/EFI/Linux/omarchy-linux-fallback.efi
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact paths can change during implementation, but signed boot artifacts must stay under Omarchy-owned ESP directories.
|
||||||
|
|
||||||
|
## Package Requirements
|
||||||
|
|
||||||
|
Live ISO and installed Secure Boot path need tools for:
|
||||||
|
|
||||||
|
- Building UKIs.
|
||||||
|
- Signing PE/COFF EFI binaries.
|
||||||
|
- Importing MOK certificates.
|
||||||
|
- Inspecting Secure Boot state.
|
||||||
|
|
||||||
|
Candidate packages:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sbsigntools
|
||||||
|
efitools
|
||||||
|
mokutil
|
||||||
|
openssl
|
||||||
|
systemd
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate Arch package availability before implementation. Do not assume an official Arch `shim-signed` package exists.
|
||||||
|
|
||||||
|
## Dual-Boot Policy
|
||||||
|
|
||||||
|
On Windows dual-boot machines:
|
||||||
|
|
||||||
|
- Never modify Windows boot files.
|
||||||
|
- Never clear or change TPM state.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
Protected against:
|
||||||
|
|
||||||
|
- Offline replacement of unsigned kernels on the ESP.
|
||||||
|
- Offline modification of initramfs or kernel command line when UKI signature verification is enforced.
|
||||||
|
- Accidental boot of unsigned Omarchy boot payloads in Secure Boot mode.
|
||||||
|
|
||||||
|
Not protected against in v1:
|
||||||
|
|
||||||
|
- A compromised running root user signing malicious future UKIs with the local MOK key.
|
||||||
|
- Evil-maid attacks that trick the user into enrolling a malicious MOK.
|
||||||
|
- Attacks requiring TPM-measured boot or remote attestation.
|
||||||
|
|
||||||
|
Mitigations:
|
||||||
|
|
||||||
|
- Store local private key only on encrypted root.
|
||||||
|
- Make MokManager enrollment instructions explicit and brand-specific.
|
||||||
|
- Display expected certificate fingerprint before reboot.
|
||||||
|
- Keep release signing key outside shipped media.
|
||||||
|
- Keep SBAT metadata current for revocation response.
|
||||||
|
|
||||||
|
## QEMU Validation
|
||||||
|
|
||||||
|
Use QEMU with OVMF Secure Boot enabled and test keys enrolled.
|
||||||
|
|
||||||
|
Test matrix:
|
||||||
|
|
||||||
|
1. Secure Boot ISO boots with test shim/test db in OVMF.
|
||||||
|
2. Unsigned live UKI fails to boot.
|
||||||
|
3. Tampered live UKI fails to boot.
|
||||||
|
4. Secure Boot install completes to empty disk.
|
||||||
|
5. First installed boot enters MokManager enrollment flow.
|
||||||
|
6. After MOK enrollment, installed Omarchy boots and prompts for LUKS unlock.
|
||||||
|
7. Tampered installed UKI fails to boot.
|
||||||
|
8. Kernel update regenerates and signs a new UKI.
|
||||||
|
9. Failed signing leaves previous UKI bootable.
|
||||||
|
10. Dual-boot disk with Windows-style ESP preserves `EFI/Microsoft` and boot order.
|
||||||
|
|
||||||
|
QEMU cannot fully prove Microsoft production trust. Hardware validation is required with firmware that trusts Microsoft UEFI CA and has Secure Boot enabled.
|
||||||
|
|
||||||
|
## Hardware Validation
|
||||||
|
|
||||||
|
Minimum hardware tests:
|
||||||
|
|
||||||
|
1. Microsoft Secure Boot enabled laptop boots official Secure Boot ISO.
|
||||||
|
2. Windows dual-boot install preserves Windows boot.
|
||||||
|
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.
|
||||||
|
7. Kernel update boots with newly signed UKI.
|
||||||
|
8. Manually tampered UKI is rejected.
|
||||||
|
9. Firmware boot order is preserved unless user opted to promote Omarchy.
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
### 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 (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
|
||||||
|
|
||||||
|
- Add dev keys and QEMU OVMF Secure Boot harness.
|
||||||
|
- Build a dev-signed live UKI.
|
||||||
|
- Boot ISO in QEMU with test keys.
|
||||||
|
- Prove unsigned and tampered payload rejection.
|
||||||
|
|
||||||
|
### Phase 3: Installed UKI + MOK Flow
|
||||||
|
|
||||||
|
- Generate local MOK key during install.
|
||||||
|
- Build and sign installed UKI.
|
||||||
|
- Schedule MokManager enrollment.
|
||||||
|
- Boot installed system in QEMU after enrollment.
|
||||||
|
- Add kernel update hook and fallback UKI handling.
|
||||||
|
|
||||||
|
### Phase 4: Official Shim And Release Signing
|
||||||
|
|
||||||
|
- Build Omarchy shim with SBAT metadata and embedded release cert.
|
||||||
|
- Submit shim artifacts for Microsoft signing.
|
||||||
|
- Integrate signed shim artifacts into official build pipeline.
|
||||||
|
- Add release signing with HSM/offline key handling.
|
||||||
|
|
||||||
|
### Phase 5: Consumer Dual-Boot UX
|
||||||
|
|
||||||
|
- Add Secure Boot and BitLocker warnings to configurator.
|
||||||
|
- Preserve Windows boot order by default.
|
||||||
|
- Add MOK enrollment instructions and certificate fingerprint display.
|
||||||
|
- Validate on real Windows hardware.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Official Secure Boot ISO boots on Microsoft Secure Boot hardware without disabling Secure Boot.
|
||||||
|
- Secure Boot install does not ship or expose Omarchy release private keys.
|
||||||
|
- Installed Omarchy boots through shim after MOK enrollment.
|
||||||
|
- Installed Omarchy root remains LUKS-encrypted and passphrase-unlocked.
|
||||||
|
- Kernel updates produce signed UKIs automatically.
|
||||||
|
- Tampered UKIs fail to boot.
|
||||||
|
- Windows ESP contents are preserved.
|
||||||
|
- Windows boot order is preserved by default.
|
||||||
|
- Secure Boot mode never offers an unsigned kernel/initramfs boot entry.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- 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. **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.
|
||||||
@@ -0,0 +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/unit/*-test.sh; do
|
||||||
|
printf '==> %s\n' "${test#$ROOT/}"
|
||||||
|
bash "$test"
|
||||||
|
done
|
||||||
|
|
||||||
|
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,617 @@
|
|||||||
|
#!/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 edge/dev/local ISO installs the -dev packages, as builder/build-iso.sh
|
||||||
|
# picks them; a stable ISO the plain ones.
|
||||||
|
detect_packages() {
|
||||||
|
RUNTIME_PACKAGE=omarchy-dev
|
||||||
|
SETTINGS_PACKAGE=omarchy-settings-dev
|
||||||
|
if [[ $(basename "$ISO") != *edge* && $(basename "$ISO") != *dev* && $(basename "$ISO") != *local* && $(basename "$ISO") != *pr* ]]; then
|
||||||
|
RUNTIME_PACKAGE=omarchy
|
||||||
|
SETTINGS_PACKAGE=omarchy-settings
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The firmware names what it starts on the serial port: the ISO's medium while
|
||||||
|
# the installer runs, the installed disk's Limine once the install is over.
|
||||||
|
installed_system_started() {
|
||||||
|
grep -aq 'starting Boot.*"Limine"' "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 first_boot_at=""
|
||||||
|
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
|
||||||
|
# Once the installed system has started, the install is over and what is
|
||||||
|
# being waited for is the first boot: say so, or a hung first boot reads
|
||||||
|
# as a slow install.
|
||||||
|
if [[ -z $first_boot_at ]] && installed_system_started "$RUN_DIR/install-serial.log"; then
|
||||||
|
first_boot_at=$waited
|
||||||
|
log "Install finished after about ${waited}s. Waiting for the installed system's first boot."
|
||||||
|
fi
|
||||||
|
if ((waited % 30 == 0)); then
|
||||||
|
if [[ -n $first_boot_at ]]; then
|
||||||
|
echo " ... first boot ($((waited - first_boot_at))s, install took about ${first_boot_at}s)"
|
||||||
|
else
|
||||||
|
echo " ... installing (${waited}s)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
Whitespace-only changes.
@@ -0,0 +1,224 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Unit tests for omarchy-cidata-load. The script takes a path prefix, so every
|
||||||
|
# case runs against a throwaway sandbox with mount/umount/udevadm stubbed out:
|
||||||
|
# "mounting" copies the fake drive's contents into the mountpoint, and every
|
||||||
|
# stub logs its invocation so the cases can assert what was (not) called.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
printf 'ok - %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
local description="$1"
|
||||||
|
local detail="${2:-}"
|
||||||
|
|
||||||
|
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
|
||||||
|
printf 'not ok - %s\n' "$description" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'chmod -R u+w "$work"; rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
stub_dir="$work/stubs"
|
||||||
|
mkdir -p "$stub_dir"
|
||||||
|
|
||||||
|
cat >"$stub_dir/udevadm" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'udevadm %s\n' "$*" >>"$TEST_LOG"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$stub_dir/mount" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'mount %s\n' "$*" >>"$TEST_LOG"
|
||||||
|
[[ ${MOUNT_FAIL:-} == 1 ]] && exit 32
|
||||||
|
device=$3 mountpoint=$4
|
||||||
|
cp -a "$(readlink -f "$device")"/. "$mountpoint"/
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$stub_dir/umount" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'umount %s\n' "$*" >>"$TEST_LOG"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
chmod +x "$stub_dir"/*
|
||||||
|
|
||||||
|
new_sandbox() {
|
||||||
|
sandbox=$(mktemp -d "$work/sandbox.XXXXXX")
|
||||||
|
mkdir -p "$sandbox/dev/disk/by-label" "$sandbox/root" "$sandbox/media"
|
||||||
|
export TEST_LOG="$sandbox/calls.log"
|
||||||
|
: >"$TEST_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
attach_drive() {
|
||||||
|
ln -s "$sandbox/media" "$sandbox/dev/disk/by-label/$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_load() {
|
||||||
|
PATH="$stub_dir:$PATH" "$CIDATA_LOAD" "$sandbox"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_required_pair() {
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
|
||||||
|
echo '{"users": []}' >"$sandbox/media/user_credentials.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# No cidata drive attached: fall back to the wizard without mounting anything.
|
||||||
|
new_sandbox
|
||||||
|
! run_load || fail "no drive exits non-zero"
|
||||||
|
! grep -q '^mount ' "$TEST_LOG" || fail "no drive mounts nothing"
|
||||||
|
pass "no drive falls back to the wizard"
|
||||||
|
|
||||||
|
# The probe must wait for udev to finish enumerating before concluding there
|
||||||
|
# is no drive.
|
||||||
|
grep -q '^udevadm settle$' "$TEST_LOG" || fail "probe settles udev first"
|
||||||
|
pass "probe settles udev first"
|
||||||
|
|
||||||
|
# A drive with the full file set: everything lands in /root and the drive is
|
||||||
|
# unmounted afterwards.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
echo "Jeff" >"$sandbox/media/user_full_name.txt"
|
||||||
|
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
|
||||||
|
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
|
||||||
|
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
||||||
|
echo 'tskey-auth-kFAKEKEY' >"$sandbox/media/tailscale_authkey"
|
||||||
|
run_load || fail "full file set loads"
|
||||||
|
for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
|
||||||
|
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
|
||||||
|
done
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
|
||||||
|
pass "full file set loads, copies everything, and unmounts"
|
||||||
|
|
||||||
|
# The uppercase label variant some tools produce works too.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive CIDATA
|
||||||
|
write_required_pair
|
||||||
|
run_load || fail "uppercase CIDATA label loads"
|
||||||
|
pass "uppercase CIDATA label loads"
|
||||||
|
|
||||||
|
# The required pair alone is a valid autoinstall drive; the optional files
|
||||||
|
# stay optional.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
run_load || fail "required pair alone loads"
|
||||||
|
[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files"
|
||||||
|
pass "required pair alone loads without optional files"
|
||||||
|
|
||||||
|
# Optional files are copied individually when present.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
||||||
|
run_load || fail "required pair plus authorized_keys loads"
|
||||||
|
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
|
||||||
|
[[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied"
|
||||||
|
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
|
||||||
|
pass "present optional files are copied, absent ones skipped"
|
||||||
|
|
||||||
|
# A defer-provisioning marker replaces user_credentials.json: deferred-provisioning installs
|
||||||
|
# creation to first boot, so imaging rigs ship no credentials at all.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
|
||||||
|
: >"$sandbox/media/defer-provisioning"
|
||||||
|
run_load || fail "config plus defer-provisioning marker loads"
|
||||||
|
[[ -f $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker is copied"
|
||||||
|
[[ ! -e $sandbox/root/user_credentials.json ]] || fail "defer-provisioning drive copies no credentials"
|
||||||
|
pass "defer-provisioning marker stands in for credentials"
|
||||||
|
|
||||||
|
# The defer-provisioning marker and credentials can coexist (rig supplies its own LUKS
|
||||||
|
# passphrase in the credentials file); both are copied.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
: >"$sandbox/media/defer-provisioning"
|
||||||
|
run_load || fail "defer-provisioning marker plus credentials loads"
|
||||||
|
[[ -f $sandbox/root/defer-provisioning && -f $sandbox/root/user_credentials.json ]] || fail "both defer-provisioning marker and credentials are copied"
|
||||||
|
pass "defer-provisioning marker plus credentials copies both"
|
||||||
|
|
||||||
|
# An defer-provisioning marker without the configuration is still not an autoinstall drive.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
: >"$sandbox/media/defer-provisioning"
|
||||||
|
! run_load || fail "defer-provisioning marker alone is not an autoinstall drive"
|
||||||
|
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "defer-provisioning marker alone copies nothing"
|
||||||
|
pass "defer-provisioning marker alone falls back to the wizard"
|
||||||
|
|
||||||
|
# Stale deferred-provisioning inputs from a previous load in the same session are cleared before
|
||||||
|
# the current drive is copied: a normal drive must not inherit an old defer-provisioning
|
||||||
|
# marker or credentials.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
: >"$sandbox/root/defer-provisioning" # leftover from a prior deferred-provisioning load
|
||||||
|
echo 'old-keys' >"$sandbox/root/authorized_keys"
|
||||||
|
run_load || fail "normal drive after a stale defer-provisioning load loads"
|
||||||
|
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker is cleared"
|
||||||
|
[[ ! -e $sandbox/root/authorized_keys ]] || fail "stale optional inputs are cleared"
|
||||||
|
pass "stale deferred-provisioning inputs are cleared before loading a normal drive"
|
||||||
|
|
||||||
|
# A drive that isn't an autoinstall drive at all still clears stale inputs so
|
||||||
|
# the wizard that follows doesn't inherit them.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json" # half a pair
|
||||||
|
: >"$sandbox/root/defer-provisioning"
|
||||||
|
! run_load || fail "half-pair drive still falls back"
|
||||||
|
[[ ! -e $sandbox/root/defer-provisioning ]] || fail "stale defer-provisioning marker cleared even on fallback"
|
||||||
|
pass "stale inputs are cleared even when falling back to the wizard"
|
||||||
|
|
||||||
|
# Half the required pair is not an autoinstall drive: unmount and fall back.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
|
||||||
|
! run_load || fail "half the required pair exits non-zero"
|
||||||
|
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "half the required pair copies nothing"
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "half the required pair still unmounts"
|
||||||
|
pass "half the required pair falls back and unmounts"
|
||||||
|
|
||||||
|
# An empty drive labeled cidata is not an autoinstall drive either.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
! run_load || fail "empty drive exits non-zero"
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "empty drive still unmounts"
|
||||||
|
pass "empty drive falls back and unmounts"
|
||||||
|
|
||||||
|
# A drive that will not mount falls back rather than failing the boot.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
! MOUNT_FAIL=1 run_load || fail "mount failure exits non-zero"
|
||||||
|
! grep -q '^umount ' "$TEST_LOG" || fail "mount failure has nothing to unmount"
|
||||||
|
pass "mount failure falls back to the wizard"
|
||||||
|
|
||||||
|
# A copy failure must not report a loaded drive: the install would start with
|
||||||
|
# missing inputs. Fall back and let the wizard produce them instead.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
chmod 555 "$sandbox/root"
|
||||||
|
! run_load 2>/dev/null || fail "copy failure exits non-zero"
|
||||||
|
grep -q '^umount ' "$TEST_LOG" || fail "copy failure still unmounts"
|
||||||
|
pass "copy failure falls back and unmounts"
|
||||||
|
|
||||||
|
# A partial copy must clean up after itself: the wizard removes only what it
|
||||||
|
# writes, so anything the loader left behind would leak into the interactive
|
||||||
|
# install that follows.
|
||||||
|
new_sandbox
|
||||||
|
attach_drive cidata
|
||||||
|
write_required_pair
|
||||||
|
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
||||||
|
mkdir "$sandbox/root/user_credentials.json"
|
||||||
|
! run_load 2>/dev/null || fail "partial copy exits non-zero"
|
||||||
|
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied"
|
||||||
|
[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind"
|
||||||
|
pass "partial copy cleans up what it copied"
|
||||||
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,244 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# The nightly's decisions, tried without a runner: which channels a run makes
|
||||||
|
# and where they go (test/vm-image/nightly-plan), what it lays out and what
|
||||||
|
# latest.json then says (test/vm-image/nightly-layout), and two things the
|
||||||
|
# install test decides from what it is given: which Omarchy packages an ISO
|
||||||
|
# installs, and when an install is over.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
plan="$ROOT/test/vm-image/nightly-plan"
|
||||||
|
layout="$ROOT/test/vm-image/nightly-layout"
|
||||||
|
harness="$ROOT/test/integration.d/base-test.sh"
|
||||||
|
bash -n "$plan"
|
||||||
|
bash -n "$layout"
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
equal() { [[ $2 == "$3" ]] || fail "$1: got '$2', expected '$3'"; }
|
||||||
|
refused() { # description, command...: it has to fail and say why
|
||||||
|
local what=$1; shift
|
||||||
|
if "$@" >"$tmp/refused.out" 2>"$tmp/refused.err"; then fail "$what was accepted"; fi
|
||||||
|
[[ -s $tmp/refused.err ]] || fail "$what was refused without a word"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- nightly-plan
|
||||||
|
planned() { "$plan" "$@" 2>/dev/null; }
|
||||||
|
|
||||||
|
first=$(planned 2026.10.09 431 1 schedule v4.0.4 "")
|
||||||
|
equal "a first run makes both channels" "$(jq -r 'map(.channel) | join(",")' <<<"$first")" "edge,stable"
|
||||||
|
equal "the edge version" "$(jq -r '.[0].version' <<<"$first")" "2026.10.09.431"
|
||||||
|
equal "the edge path" "$(jq -r '.[0].release' <<<"$first")" "edge/2026.10.09.431"
|
||||||
|
equal "the stable version is the release's" "$(jq -r '.[1].version' <<<"$first")" "4.0.4"
|
||||||
|
equal "the stable path has the run number" "$(jq -r '.[1].release' <<<"$first")" "stable/4.0.4/431"
|
||||||
|
|
||||||
|
again=$(planned 2026.10.09 431 2 schedule v4.0.4 "")
|
||||||
|
equal "a run started again has its own edge version" "$(jq -r '.[0].version' <<<"$again")" "2026.10.09.431.2"
|
||||||
|
equal "a run started again has its own stable path" "$(jq -r '.[1].release' <<<"$again")" "stable/4.0.4/431.2"
|
||||||
|
[[ $(jq -r '.[].release' <<<"$first" | sort) != "$(jq -r '.[].release' <<<"$again" | sort)" ]] ||
|
||||||
|
fail "two attempts of one run share a path"
|
||||||
|
|
||||||
|
equal "a published release is left out of a scheduled run" \
|
||||||
|
"$(planned 2026.10.09 432 1 schedule v4.0.4 4.0.4 | jq -r 'map(.channel) | join(",")')" "edge"
|
||||||
|
equal "a new release is made on the next scheduled run" \
|
||||||
|
"$(planned 2026.10.09 432 1 schedule v4.0.5 4.0.4 | jq -r '.[1].version')" "4.0.5"
|
||||||
|
equal "a manual run makes a published release again" \
|
||||||
|
"$(planned 2026.10.09 432 1 workflow_dispatch v4.0.4 4.0.4 | jq -r 'map(.channel) | join(",")')" "edge,stable"
|
||||||
|
|
||||||
|
refused "a release tag that is not a version" "$plan" 2026.10.09 431 1 schedule nightly ""
|
||||||
|
refused "an empty release tag" "$plan" 2026.10.09 431 1 schedule "" ""
|
||||||
|
refused "a release tag with a path in it" "$plan" 2026.10.09 431 1 schedule "v4.0.4/../x" ""
|
||||||
|
refused "a run number that is not a number" "$plan" 2026.10.09 "" 1 schedule v4.0.4 ""
|
||||||
|
refused "a date in another form" "$plan" 2026-10-09 431 1 schedule v4.0.4 ""
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- nightly-layout
|
||||||
|
sealed() { # directory: what vm-seal leaves
|
||||||
|
rm -rf "$1"; mkdir -p "$1"
|
||||||
|
local file
|
||||||
|
for file in base.qcow2 vmlinuz initrd cmdline; do echo "$file of ${1##*/}" >"$1/$file"; done
|
||||||
|
echo "sums of the seal" >"$1/SHA256SUMS"
|
||||||
|
}
|
||||||
|
export SITE=https://nightly.example ISO_SITE=https://iso.example ISO_COMMIT=0123456789abcdef0123456789abcdef01234567
|
||||||
|
|
||||||
|
# Edge. pacman answers for omarchy and omarchy-dev with the one installed
|
||||||
|
# package, so its line is there twice.
|
||||||
|
mkdir -p "$tmp/release"
|
||||||
|
echo "an edge iso" >"$tmp/release/omarchy-2026.10.09-x86_64-edge.iso"
|
||||||
|
sealed "$tmp/sealed-edge"
|
||||||
|
printf '%s\n' "omarchy-dev 4.0.0.r6818.gc352b62-2" "omarchy-dev 4.0.0.r6818.gc352b62-2" \
|
||||||
|
"/usr/lib/modules/7.2.8-5-omarchy-bore/pkgbase is owned by linux-omarchy-bore 7.2.8-5" "7.2.8-5-omarchy-bore" >"$tmp/edge-packages"
|
||||||
|
"$layout" edge 2026.10.09.431 edge/2026.10.09.431 "$tmp/release/omarchy-2026.10.09-x86_64-edge.iso" \
|
||||||
|
"$tmp/sealed-edge" "$tmp/edge-packages" "$tmp/out" >/dev/null
|
||||||
|
json=$tmp/out/edge/latest.json
|
||||||
|
dir=$tmp/out/edge/2026.10.09.431
|
||||||
|
field() { jq -r "$1" "$json"; }
|
||||||
|
|
||||||
|
equal "edge: files published" "$(cd "$tmp/out" && find . -type f | sort | paste -sd' ')" \
|
||||||
|
"./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso.sha256 ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2 ./edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2.sha256 ./edge/2026.10.09.431/unpacked/SHA256SUMS ./edge/2026.10.09.431/unpacked/cmdline ./edge/2026.10.09.431/unpacked/initrd ./edge/2026.10.09.431/unpacked/vm-boot ./edge/2026.10.09.431/unpacked/vmlinuz ./edge/latest.json"
|
||||||
|
(cd "$dir" && sha256sum -c --quiet ./*.sha256) || fail "edge: a checksum file does not match the file it is for"
|
||||||
|
equal "edge: the ISO and the image each have a checksum file" "$(cd "$dir" && ls ./*.sha256 | wc -l)" "2"
|
||||||
|
equal "edge: the image is the sealed disk" "$(cat "$dir/omarchy-edge-2026.10.09.431.qcow2")" "base.qcow2 of sealed-edge"
|
||||||
|
(cd "$dir/unpacked" && sha256sum -c --quiet SHA256SUMS) || fail "edge: SHA256SUMS does not match the unpacked files"
|
||||||
|
equal "edge: SHA256SUMS covers every file beside it" \
|
||||||
|
"$(awk '{ print $2 }' "$dir/unpacked/SHA256SUMS" | sort | paste -sd' ')" "$(cd "$dir/unpacked" && ls | grep -v SHA256SUMS | sort | paste -sd' ')"
|
||||||
|
cmp -s "$dir/unpacked/vm-boot" "$ROOT/test/vm-image/vm-boot" || fail "edge: the published vm-boot is not the repository's"
|
||||||
|
equal "edge: version" "$(field .version)" "2026.10.09.431"
|
||||||
|
equal "edge: channel" "$(field .channel)" "edge"
|
||||||
|
equal "edge: url" "$(field .url)" "https://nightly.example/edge/2026.10.09.431/omarchy-edge-2026.10.09.431.iso"
|
||||||
|
equal "edge: sha256 is the ISO's" "$(field .sha256)" "$(sha256sum "$dir/omarchy-edge-2026.10.09.431.iso" | cut -d' ' -f1)"
|
||||||
|
equal "edge: size is the ISO's" "$(field .size)" "$(stat -c %s "$dir/omarchy-edge-2026.10.09.431.iso")"
|
||||||
|
equal "edge: iso_commit" "$(field .iso_commit)" "$ISO_COMMIT"
|
||||||
|
equal "edge: omarchy_version, once" "$(field .omarchy_version)" "4.0.0.r6818.gc352b62-2"
|
||||||
|
equal "edge: omarchy_commit" "$(field .omarchy_commit)" "c352b62"
|
||||||
|
equal "edge: kernel" "$(field .kernel)" "7.2.8-5-omarchy-bore"
|
||||||
|
equal "edge: image address" "$(field .vm.url)" "https://nightly.example/edge/2026.10.09.431/omarchy-edge-2026.10.09.431.qcow2"
|
||||||
|
equal "edge: image checksum is the image's" "$(field .vm.sha256)" "$(sha256sum "$dir/omarchy-edge-2026.10.09.431.qcow2" | cut -d' ' -f1)"
|
||||||
|
equal "edge: image size is the image's" "$(field .vm.size)" "$(stat -c %s "$dir/omarchy-edge-2026.10.09.431.qcow2")"
|
||||||
|
equal "edge: the ISO and the image differ only in their ending" "$(field .url | sed 's/\.iso$//')" "$(field .vm.url | sed 's/\.qcow2$//')"
|
||||||
|
equal "edge: unpacked folder" "$(field .vm.unpacked)" "https://nightly.example/edge/2026.10.09.431/unpacked/"
|
||||||
|
equal "edge: every unpacked file listed is published" "$(field '.vm.unpacked_files[]' | sort | paste -sd' ')" "$(cd "$dir/unpacked" && ls | sort | paste -sd' ')"
|
||||||
|
[[ $(field .built_at) =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$ ]] || fail "edge: built_at is '$(field .built_at)'"
|
||||||
|
# What a consumer does with the ISO's name: the first run of three or more
|
||||||
|
# dotted numbers is the version.
|
||||||
|
[[ $(basename "$(field .url)") =~ [0-9]+(\.[0-9]+){2,} ]] || fail "edge: the ISO's name carries no version"
|
||||||
|
equal "edge: the version read from the ISO's name" "${BASH_REMATCH[0]}" "2026.10.09.431"
|
||||||
|
|
||||||
|
# Stable: the image is published, the released ISO stays where it is.
|
||||||
|
rm -rf "$tmp/out"
|
||||||
|
echo "a released iso" >"$tmp/release/omarchy-4.0.4.iso"
|
||||||
|
sealed "$tmp/sealed-stable"
|
||||||
|
printf '%s\n' "omarchy 4.0.4-1" "/usr/lib/modules/7.2.5-3-omarchy/pkgbase is owned by linux-omarchy 7.2.5-3" "7.2.5-3-omarchy" >"$tmp/stable-packages"
|
||||||
|
"$layout" stable 4.0.4 stable/4.0.4/431 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-stable" "$tmp/stable-packages" "$tmp/out" >/dev/null
|
||||||
|
json=$tmp/out/stable/latest.json
|
||||||
|
equal "stable: no ISO is published" "$(find "$tmp/out" -name '*.iso*' | wc -l)" "0"
|
||||||
|
[[ -f $tmp/release/omarchy-4.0.4.iso ]] || fail "stable: the released ISO was moved away"
|
||||||
|
equal "stable: url is the release's" "$(field .url)" "https://iso.example/omarchy-4.0.4.iso"
|
||||||
|
equal "stable: sha256 is the ISO's" "$(field .sha256)" "$(sha256sum "$tmp/release/omarchy-4.0.4.iso" | cut -d' ' -f1)"
|
||||||
|
equal "stable: version" "$(field .version)" "4.0.4"
|
||||||
|
equal "stable: omarchy_version" "$(field .omarchy_version)" "4.0.4-1"
|
||||||
|
equal "stable: a release names no commit" "$(field .omarchy_commit)" ""
|
||||||
|
equal "stable: image address has the run number" "$(field .vm.url)" "https://nightly.example/stable/4.0.4/431/omarchy-4.0.4.qcow2"
|
||||||
|
equal "stable: unpacked folder" "$(field .vm.unpacked)" "https://nightly.example/stable/4.0.4/431/unpacked/"
|
||||||
|
(cd "$tmp/out/stable/4.0.4/431" && sha256sum -c --quiet omarchy-4.0.4.qcow2.sha256) || fail "stable: the image's checksum file does not match it"
|
||||||
|
(cd "$tmp/out/stable/4.0.4/431/unpacked" && sha256sum -c --quiet SHA256SUMS) || fail "stable: SHA256SUMS does not match the unpacked files"
|
||||||
|
|
||||||
|
# Nothing half-made is described.
|
||||||
|
sealed "$tmp/sealed-short"; rm "$tmp/sealed-short/initrd"
|
||||||
|
refused "a sealed image without its initrd" "$layout" stable 4.0.4 stable/4.0.4/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-short" "$tmp/stable-packages" "$tmp/out2"
|
||||||
|
sealed "$tmp/sealed-x"; : >"$tmp/no-packages"
|
||||||
|
refused "an image that named no Omarchy package" "$layout" stable 4.0.4 stable/4.0.4/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-x" "$tmp/no-packages" "$tmp/out3"
|
||||||
|
sealed "$tmp/sealed-y"
|
||||||
|
refused "a released ISO under another version's name" "$layout" stable 4.0.5 stable/4.0.5/9 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-y" "$tmp/stable-packages" "$tmp/out4"
|
||||||
|
sealed "$tmp/sealed-z"
|
||||||
|
refused "a channel that does not exist" "$layout" rc 4.0.4 rc/4.0.4 "$tmp/release/omarchy-4.0.4.iso" "$tmp/sealed-z" "$tmp/stable-packages" "$tmp/out5"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- nightly-fetch
|
||||||
|
# The network is played by three stand-ins: gh lists the artifacts, curl gives
|
||||||
|
# the signed address, aria2c "downloads" a zip made here.
|
||||||
|
fetch="$ROOT/test/vm-image/nightly-fetch"
|
||||||
|
bash -n "$fetch"
|
||||||
|
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||||
|
stubs=$tmp/stubs; mkdir -p "$stubs" "$tmp/payload/edge/1"
|
||||||
|
echo "an image" >"$tmp/payload/edge/1/omarchy-edge-1.qcow2"
|
||||||
|
echo '{"version": "1"}' >"$tmp/payload/edge/latest.json"
|
||||||
|
(cd "$tmp/payload" && zip -qr "$tmp/artifact.zip" .)
|
||||||
|
zip_size=$(stat -c %s "$tmp/artifact.zip")
|
||||||
|
cat >"$stubs/gh" <<STUB
|
||||||
|
#!/bin/bash
|
||||||
|
case "\$*" in
|
||||||
|
"auth token") echo token ;;
|
||||||
|
*"/runs/7/artifacts"*) printf '%s\\n' \${FETCH_IDS:-11 12} ;;
|
||||||
|
*"/artifacts/"*) echo \${FETCH_SIZE:-$zip_size} ;;
|
||||||
|
esac
|
||||||
|
STUB
|
||||||
|
cat >"$stubs/curl" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
echo "${@: -1}" >>"$FETCH_LOG"; printf 'https://storage.example/signed'
|
||||||
|
STUB
|
||||||
|
cat >"$stubs/aria2c" <<STUB
|
||||||
|
#!/bin/bash
|
||||||
|
echo aria2c >>"\$FETCH_LOG"
|
||||||
|
if [[ -n \${FETCH_FAIL_ONCE:-} && ! -e $tmp/failed ]]; then touch $tmp/failed; exit 1; fi
|
||||||
|
for arg in "\$@"; do case \$arg in --dir=*) dir=\${arg#--dir=} ;; --out=*) out=\${arg#--out=} ;; esac; done
|
||||||
|
cp "$tmp/artifact.zip" "\$dir/\$out"
|
||||||
|
STUB
|
||||||
|
chmod +x "$stubs"/*
|
||||||
|
fetched() { PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$@"; }
|
||||||
|
|
||||||
|
: >"$tmp/fetch.log"
|
||||||
|
fetched "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/fetched" >/dev/null
|
||||||
|
equal "fetch: the files of the artifact" "$(cd "$tmp/fetched" && find . -type f | sort | paste -sd' ')" "./edge/1/omarchy-edge-1.qcow2 ./edge/latest.json"
|
||||||
|
grep -q "/artifacts/12/zip" "$tmp/fetch.log" || fail "fetch: of two artifacts with one name, the newer was not the one fetched"
|
||||||
|
equal "fetch: nothing left behind" "$(ls "$tmp" | grep -c 'nightly-fetch\.')" "0"
|
||||||
|
|
||||||
|
: >"$tmp/fetch.log"; rm -f "$tmp/failed"
|
||||||
|
FETCH_FAIL_ONCE=1 fetched "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/fetched-again" >/dev/null 2>&1
|
||||||
|
equal "fetch: a failed download is tried again with a new address" "$(grep -c zip "$tmp/fetch.log")" "2"
|
||||||
|
|
||||||
|
refused "an artifact that does not exist" env FETCH_IDS=" " PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f3"
|
||||||
|
refused "a download of the wrong size" env FETCH_SIZE=1 PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f4"
|
||||||
|
[[ ! -e $tmp/f4/edge ]] || fail "fetch: a download of the wrong size was unpacked"
|
||||||
|
refused "an artifact larger than the free space" env FETCH_SIZE=9000000000000000 PATH="$stubs:$PATH" TMPDIR="$tmp" FETCH_LOG="$tmp/fetch.log" "$fetch" omacom/omarchy-iso 7 nightly-edge-7 "$tmp/f5"
|
||||||
|
grep -q "MiB free" "$tmp/refused.err" || fail "fetch: too little room is not what was reported"
|
||||||
|
else
|
||||||
|
echo "nightly-fetch tests skipped: no zip or unzip here"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- nightly-rclone
|
||||||
|
wrapped="$ROOT/test/vm-image/nightly-rclone"
|
||||||
|
bash -n "$wrapped"
|
||||||
|
mkdir -p "$tmp/rc"
|
||||||
|
rclone_says() { # exit status, output lines...: a stand-in rclone
|
||||||
|
local status=$1; shift
|
||||||
|
{ echo '#!/bin/bash'; printf 'echo %q >&2\n' "$@"; echo "exit $status"; } >"$tmp/rc/rclone"
|
||||||
|
chmod +x "$tmp/rc/rclone"
|
||||||
|
}
|
||||||
|
rclone_says 0 "Transferred: 3 / 3, 100%"
|
||||||
|
out=$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b)
|
||||||
|
[[ $out != *::warning::* ]] || fail "rclone: a clean upload was reported as a retry"
|
||||||
|
[[ $out == *"Transferred: 3 / 3"* ]] || fail "rclone: its output was not passed through"
|
||||||
|
|
||||||
|
rclone_says 0 "<3>ERROR : a.qcow2: Failed to copy: NotImplemented: Not Implemented" \
|
||||||
|
"<3>ERROR : a.qcow2: Failed to copy: NotImplemented: Not Implemented" \
|
||||||
|
"<3>ERROR : unpacked/vmlinuz: Failed to copy: NotImplemented: Not Implemented" \
|
||||||
|
"<3>ERROR : Attempt 2/3 failed with 2 errors" "<3>ERROR : Attempt 3/3 succeeded"
|
||||||
|
out=$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b)
|
||||||
|
[[ $out == *"::warning::rclone needed a retry for 2 file(s): a.qcow2 unpacked/vmlinuz"* ]] ||
|
||||||
|
fail "rclone: a file that failed twice is not counted once: $(grep warning <<<"$out")"
|
||||||
|
|
||||||
|
rclone_says 3 "<3>ERROR : a.qcow2: Failed to copy: AccessDenied"
|
||||||
|
if PATH="$tmp/rc:$PATH" "$wrapped" copy a b >"$tmp/rc/out" 2>&1; then fail "rclone: a failed upload passed"; fi
|
||||||
|
equal "rclone: its exit status is kept" "$(PATH="$tmp/rc:$PATH" "$wrapped" copy a b >/dev/null 2>&1; echo $?)" "3"
|
||||||
|
if grep -q '::warning::' "$tmp/rc/out"; then fail "rclone: a failed upload was reported as a retry"; fi
|
||||||
|
|
||||||
|
# rclone reads every RCLONE_<FLAG> variable as that flag: RCLONE_VERSION=v1.75.2
|
||||||
|
# was "--version=v1.75.2" and stopped the publish before it uploaded anything.
|
||||||
|
# Only its remote's settings, RCLONE_CONFIG_*, belong in a workflow's environment.
|
||||||
|
flags=$(sed -nE 's/^[[:space:]]+(RCLONE_[A-Z0-9_]+):.*/\1/p' "$ROOT"/.github/workflows/*.yml | grep -v '^RCLONE_CONFIG_' | sort -u | paste -sd' ' || true)
|
||||||
|
[[ -z $flags ]] || fail "rclone: a workflow sets $flags, which rclone would read as flags"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ the install test
|
||||||
|
# The harness is a script that starts a VM when it is read; take the two
|
||||||
|
# functions out of it.
|
||||||
|
eval "$(sed -n '/^detect_packages() {$/,/^}$/p; /^installed_system_started() {$/,/^}$/p' "$harness")"
|
||||||
|
declare -F detect_packages installed_system_started >/dev/null || fail "the harness no longer has detect_packages and installed_system_started"
|
||||||
|
|
||||||
|
packages_of() { ISO=$1; detect_packages; echo "$RUNTIME_PACKAGE $SETTINGS_PACKAGE"; }
|
||||||
|
equal "an edge ISO installs the -dev packages" "$(packages_of release/omarchy-2026.10.09-x86_64-edge.iso)" "omarchy-dev omarchy-settings-dev"
|
||||||
|
equal "a dev ISO installs the -dev packages" "$(packages_of release/omarchy-2026.10.09-x86_64-dev.iso)" "omarchy-dev omarchy-settings-dev"
|
||||||
|
equal "a released ISO installs the release packages" "$(packages_of release/omarchy-4.0.4.iso)" "omarchy omarchy-settings"
|
||||||
|
equal "a stable build installs the release packages" "$(packages_of /x/edge/omarchy-2026.10.09-x86_64-stable.iso)" "omarchy omarchy-settings"
|
||||||
|
|
||||||
|
printf '%s\r\n' 'BdsDxe: loading Boot0002 "UEFI QEMU DVD-ROM QM00011 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x5,0xFFFF,0x0)' \
|
||||||
|
'BdsDxe: starting Boot0002 "UEFI QEMU DVD-ROM QM00011 " from PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x5,0xFFFF,0x0)' >"$tmp/serial"
|
||||||
|
if installed_system_started "$tmp/serial"; then fail "the installer's own boot counts as the installed system's"; fi
|
||||||
|
printf '%s\r\n' 'BdsDxe: loading Boot0005 "Limine" from HD(1,GPT,774175D2,0x800,0x400000)/\EFI\limine\limine_x64.efi' >>"$tmp/serial"
|
||||||
|
if installed_system_started "$tmp/serial"; then fail "loading Limine counts as having started it"; fi
|
||||||
|
printf '%s\r\n' 'BdsDxe: starting Boot0005 "Limine" from HD(1,GPT,774175D2,0x800,0x400000)/\EFI\limine\limine_x64.efi' >>"$tmp/serial"
|
||||||
|
installed_system_started "$tmp/serial" || fail "the installed system's Limine starting is not noticed"
|
||||||
|
if installed_system_started "$tmp/no-such-log"; then fail "a missing serial log counts as a started system"; fi
|
||||||
|
|
||||||
|
echo "nightly tests passed"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# The offline mirror is indexed by several repo-add processes whose databases
|
||||||
|
# are then joined (builder/index-offline-mirror.sh). The result has to be the
|
||||||
|
# database one repo-add writes over the same packages, and two versions of one
|
||||||
|
# package have to be an error, since apart they would both be kept.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
index="$ROOT/builder/index-offline-mirror.sh"
|
||||||
|
bash -n "$index"
|
||||||
|
if ! command -v repo-add >/dev/null; then
|
||||||
|
echo "offline mirror index tests skipped: no repo-add here"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
fixture=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$fixture"' EXIT
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
package() { # directory name version: the least repo-add accepts as a package
|
||||||
|
local stage="$fixture/stage"
|
||||||
|
rm -rf "$stage"; mkdir -p "$stage/usr/share/$2"
|
||||||
|
echo "$2 $3" >"$stage/usr/share/$2/version"
|
||||||
|
printf 'pkgname = %s\npkgver = %s\npkgdesc = fixture\narch = any\nsize = 1\n' "$2" "$3" >"$stage/.PKGINFO"
|
||||||
|
bsdtar -czf "$1/$2-$3-any.pkg.tar.gz" -C "$stage" .PKGINFO usr
|
||||||
|
}
|
||||||
|
contents() { # database file -> directory holding its entries
|
||||||
|
mkdir -p "$2"; bsdtar -xf "$1" -C "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir "$fixture/one" "$fixture/many"
|
||||||
|
for name in alpha bravo charlie delta echo foxtrot golf; do
|
||||||
|
package "$fixture/one" "$name" 1.0-1
|
||||||
|
cp "$fixture/one/$name-1.0-1-any.pkg.tar.gz" "$fixture/many/"
|
||||||
|
done
|
||||||
|
: >"$fixture/many/alpha-1.0-1-any.pkg.tar.gz.sig" # a signature is not a package
|
||||||
|
|
||||||
|
repo-add -q "$fixture/one/offline.db.tar.gz" "$fixture"/one/*.pkg.tar.gz >/dev/null 2>&1
|
||||||
|
bash "$index" "$fixture/many" 3 >/dev/null || fail "indexing seven packages with three processes failed"
|
||||||
|
for kind in db files; do
|
||||||
|
[[ -L $fixture/many/offline.$kind ]] || fail "offline.$kind, the name pacman fetches, is missing"
|
||||||
|
contents "$fixture/one/offline.$kind.tar.gz" "$fixture/x-one-$kind"
|
||||||
|
contents "$fixture/many/offline.$kind" "$fixture/x-many-$kind"
|
||||||
|
diff -r "$fixture/x-one-$kind" "$fixture/x-many-$kind" >/dev/null \
|
||||||
|
|| fail "the joined $kind database differs from one repo-add's"
|
||||||
|
done
|
||||||
|
|
||||||
|
# More processes than packages, and a second run over an indexed mirror.
|
||||||
|
bash "$index" "$fixture/many" 64 >/dev/null || fail "more processes than packages failed"
|
||||||
|
contents "$fixture/many/offline.db" "$fixture/x-again"
|
||||||
|
diff -r "$fixture/x-one-db" "$fixture/x-again" >/dev/null || fail "indexing an already indexed mirror changed it"
|
||||||
|
|
||||||
|
package "$fixture/many" alpha 2.0-1
|
||||||
|
bash "$index" "$fixture/many" 3 >/dev/null 2>&1 && fail "two versions of one package were indexed"
|
||||||
|
|
||||||
|
mkdir "$fixture/empty"
|
||||||
|
bash "$index" "$fixture/empty" >/dev/null 2>&1 && fail "an empty mirror was indexed"
|
||||||
|
|
||||||
|
grep -Fq 'bash /builder/index-offline-mirror.sh "$offline_mirror_dir"' "$ROOT/builder/build-iso.sh" \
|
||||||
|
|| fail "the build does not use the parallel index"
|
||||||
|
|
||||||
|
echo "offline mirror index tests passed"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Unit tests for the orchestrator's configure_ssh_access phase.
|
||||||
|
|
||||||
|
The phase touches the target only through the filesystem and arch-chroot, so
|
||||||
|
the tests run it against a temp directory with subprocess.run recorded and the
|
||||||
|
ufw side effect (writing user.rules) simulated by the fake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().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
|
||||||
|
# never touches it, so stub the adapter out before the import.
|
||||||
|
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
|
||||||
|
|
||||||
|
from orchestrator import phases_impl # noqa: E402
|
||||||
|
|
||||||
|
UFW_RULE = "-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureSshAccessTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
self.ufw_writes_rule = True
|
||||||
|
|
||||||
|
def fake_run(self, cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if cmd[2] == "ufw":
|
||||||
|
if self.ufw_writes_rule:
|
||||||
|
rules = self.target / "etc" / "ufw" / "user.rules"
|
||||||
|
rules.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rules.write_text(UFW_RULE)
|
||||||
|
# ufw cannot reach netfilter inside a chroot and exits non-zero
|
||||||
|
# even when it recorded the rule; the phase must not trust this.
|
||||||
|
return CompletedProcess(cmd, 1)
|
||||||
|
return CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
def ctx(self, authorized_keys=None):
|
||||||
|
authorized_keys_path = None
|
||||||
|
if authorized_keys is not None:
|
||||||
|
authorized_keys_path = self.target / "authorized_keys"
|
||||||
|
authorized_keys_path.write_text(authorized_keys)
|
||||||
|
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path, defer_provisioning=False)
|
||||||
|
|
||||||
|
def configure(self, **kwargs):
|
||||||
|
phases_impl.configure_ssh_access(self.ctx(**kwargs))
|
||||||
|
|
||||||
|
def authorized_keys(self):
|
||||||
|
return self.target / "home" / "jeff" / ".ssh" / "authorized_keys"
|
||||||
|
|
||||||
|
def chrooted(self, program):
|
||||||
|
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
|
||||||
|
|
||||||
|
def test_no_authorized_keys_is_a_no_op(self):
|
||||||
|
self.configure()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists())
|
||||||
|
|
||||||
|
def test_installs_keys_one_per_line(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
|
||||||
|
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
|
||||||
|
|
||||||
|
def test_drops_blank_lines_and_comments(self):
|
||||||
|
self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n")
|
||||||
|
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
|
||||||
|
def test_keys_with_options_pass_through(self):
|
||||||
|
key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host'
|
||||||
|
self.configure(authorized_keys=f"{key}\n")
|
||||||
|
self.assertEqual(self.authorized_keys().read_text(), f"{key}\n")
|
||||||
|
|
||||||
|
def test_ssh_dir_is_private(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode)
|
||||||
|
self.assertEqual(mode, 0o700)
|
||||||
|
|
||||||
|
def test_authorized_keys_is_private(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_chowns_ssh_dir_to_the_user(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(self.chrooted("chown"), [
|
||||||
|
["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_enables_sshd(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(self.chrooted("systemctl"), [
|
||||||
|
["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_allows_ssh_through_ufw_despite_chroot_exit_status(self):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
self.assertEqual(self.chrooted("ufw"), [
|
||||||
|
["arch-chroot", str(self.target), "ufw", "allow", "ssh"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_fails_when_ufw_does_not_record_the_rule(self):
|
||||||
|
self.ufw_writes_rule = False
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"):
|
||||||
|
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
|
||||||
|
|
||||||
|
def test_empty_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
|
||||||
|
self.configure(authorized_keys="")
|
||||||
|
|
||||||
|
def test_blank_only_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
|
||||||
|
self.configure(authorized_keys="\n \n")
|
||||||
|
|
||||||
|
def test_comment_only_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
|
||||||
|
self.configure(authorized_keys="# no keys here\n")
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalPathTest(unittest.TestCase):
|
||||||
|
def test_unset_and_missing_paths_are_none(self):
|
||||||
|
from orchestrator.context import _optional_path
|
||||||
|
|
||||||
|
self.assertIsNone(_optional_path(None))
|
||||||
|
self.assertIsNone(_optional_path(""))
|
||||||
|
self.assertIsNone(_optional_path("/does/not/exist/authorized_keys"))
|
||||||
|
|
||||||
|
def test_existing_path_is_returned(self):
|
||||||
|
from orchestrator.context import _optional_path
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as f:
|
||||||
|
self.assertEqual(_optional_path(f.name), Path(f.name))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Unit tests for the orchestrator's configure_tailscale phase.
|
||||||
|
|
||||||
|
Same harness as the SSH access tests: the phase touches the target only
|
||||||
|
through the filesystem and arch-chroot, so it runs against a temp directory
|
||||||
|
with subprocess.run recorded and the ufw side effect (writing user.rules)
|
||||||
|
simulated by the fake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().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
|
||||||
|
# never touches it, so stub the adapter out before the import.
|
||||||
|
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
|
||||||
|
|
||||||
|
from orchestrator import phases_impl # noqa: E402
|
||||||
|
|
||||||
|
UFW_RULE = "-A ufw-user-input -i tailscale0 -j ACCEPT\n"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureTailscaleTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
self.ufw_writes_rule = True
|
||||||
|
|
||||||
|
def fake_run(self, cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if cmd[2] == "ufw":
|
||||||
|
if self.ufw_writes_rule:
|
||||||
|
rules = self.target / "etc" / "ufw" / "user.rules"
|
||||||
|
rules.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rules.write_text(UFW_RULE)
|
||||||
|
# ufw cannot reach netfilter inside a chroot and exits non-zero
|
||||||
|
# even when it recorded the rule; the phase must not trust this.
|
||||||
|
return CompletedProcess(cmd, 1)
|
||||||
|
return CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
def ctx(self, authkey=None, tailscale_installed=True):
|
||||||
|
authkey_path = None
|
||||||
|
if authkey is not None:
|
||||||
|
authkey_path = self.target / "tailscale_authkey"
|
||||||
|
authkey_path.write_text(authkey)
|
||||||
|
if tailscale_installed:
|
||||||
|
binary = self.target / "usr" / "bin" / "tailscale"
|
||||||
|
binary.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
binary.touch()
|
||||||
|
return types.SimpleNamespace(target=self.target, tailscale_authkey_path=authkey_path)
|
||||||
|
|
||||||
|
def configure(self, **kwargs):
|
||||||
|
phases_impl.configure_tailscale(self.ctx(**kwargs))
|
||||||
|
|
||||||
|
def staged_key(self):
|
||||||
|
return self.target / "etc" / "tailscale" / "authkey"
|
||||||
|
|
||||||
|
def unit(self):
|
||||||
|
return self.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
|
||||||
|
|
||||||
|
def chrooted(self, program):
|
||||||
|
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
|
||||||
|
|
||||||
|
def test_no_authkey_is_a_no_op(self):
|
||||||
|
self.configure()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertFalse((self.target / "etc" / "tailscale").exists())
|
||||||
|
|
||||||
|
def test_stages_the_key(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
|
||||||
|
|
||||||
|
def test_drops_blank_lines_and_comments(self):
|
||||||
|
self.configure(authkey="# reusable, tagged\n\n tskey-auth-kFAKEKEY \n")
|
||||||
|
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
|
||||||
|
|
||||||
|
def test_key_dir_and_file_are_root_private(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(stat.S_IMODE((self.target / "etc" / "tailscale").stat().st_mode), 0o700)
|
||||||
|
self.assertEqual(stat.S_IMODE(self.staged_key().stat().st_mode), 0o600)
|
||||||
|
|
||||||
|
def test_installs_the_join_unit(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
text = self.unit().read_text()
|
||||||
|
self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text)
|
||||||
|
# The key must not outlive a successful join, the unit must not run
|
||||||
|
# again after one, and cleanup must be sequenced after the join
|
||||||
|
# succeeds -- inside the script, not in ExecStartPost.
|
||||||
|
self.assertIn(
|
||||||
|
"ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:/etc/tailscale/authkey;"
|
||||||
|
" do sleep 15; done; rm -f /etc/tailscale/authkey;"
|
||||||
|
" systemctl disable omarchy-tailscale-join.service'",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_join_unit_does_not_hold_up_boot(self):
|
||||||
|
# A Type=oneshot wanted by multi-user.target holds the whole boot
|
||||||
|
# (SDDM included) hostage until the join finishes or times out --
|
||||||
|
# target units implicitly gain After= for their Wants=.
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
text = self.unit().read_text()
|
||||||
|
self.assertIn("Type=simple", text)
|
||||||
|
self.assertNotIn("Type=oneshot", text)
|
||||||
|
self.assertNotIn("TimeoutStartSec", text)
|
||||||
|
|
||||||
|
def test_unit_avoids_systemd_variable_expansion(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertNotIn("$", self.unit().read_text())
|
||||||
|
|
||||||
|
def test_enables_tailscaled_and_the_join(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(self.chrooted("systemctl"), [
|
||||||
|
["arch-chroot", str(self.target), "systemctl", "enable",
|
||||||
|
"tailscaled.service", "omarchy-tailscale-join.service"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_allows_tailnet_traffic_through_ufw_despite_chroot_exit_status(self):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
self.assertEqual(self.chrooted("ufw"), [
|
||||||
|
["arch-chroot", str(self.target), "ufw", "allow", "in", "on", "tailscale0"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_fails_when_ufw_does_not_record_the_rule(self):
|
||||||
|
self.ufw_writes_rule = False
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "allow rule for tailscale0"):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n")
|
||||||
|
|
||||||
|
def test_fails_when_tailscale_is_not_on_the_target(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "not installed on the target"):
|
||||||
|
self.configure(authkey="tskey-auth-kFAKEKEY\n", tailscale_installed=False)
|
||||||
|
|
||||||
|
def test_empty_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
|
||||||
|
self.configure(authkey="")
|
||||||
|
|
||||||
|
def test_comment_only_file_fails_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
|
||||||
|
self.configure(authkey="# no key here\n")
|
||||||
|
|
||||||
|
def test_multiple_keys_fail_the_phase(self):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "expected exactly one"):
|
||||||
|
self.configure(authkey="tskey-auth-kONE\ntskey-auth-kTWO\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def supported_keymaps():
|
||||||
|
for candidate in SETUP_FORM_CANDIDATES:
|
||||||
|
if not candidate.is_file():
|
||||||
|
continue
|
||||||
|
block = re.search(
|
||||||
|
r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL
|
||||||
|
)
|
||||||
|
assert block, f"no layout list in {candidate}"
|
||||||
|
return [line.split("|")[1] for line in block.group(1).splitlines()]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_KEYMAPS = supported_keymaps()
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardConfigurationTest(unittest.TestCase):
|
||||||
|
def target(self, directory: str) -> Path:
|
||||||
|
target = Path(directory)
|
||||||
|
(target / "etc").mkdir()
|
||||||
|
(target / "etc/vconsole.conf").write_text(
|
||||||
|
"KEYMAP=us\nFONT=default8x16\n"
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
|
||||||
|
def test_writes_keymap_and_xkb_settings_and_preserves_font(self):
|
||||||
|
# XKBLAYOUT is load-bearing: omarchy's detect-keyboard-layout.sh copies
|
||||||
|
# it into Hyprland's kb_layout on the installed system.
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
target = self.target(directory)
|
||||||
|
self.assertTrue(KEYBOARD.configure_keyboard(target, "us"))
|
||||||
|
lines = (target / "etc/vconsole.conf").read_text().splitlines()
|
||||||
|
self.assertIn("KEYMAP=us", lines)
|
||||||
|
self.assertIn("XKBLAYOUT=us", lines)
|
||||||
|
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
||||||
|
|
||||||
|
def test_all_configurator_keymaps_are_known_to_localectl(self):
|
||||||
|
if SUPPORTED_KEYMAPS is None:
|
||||||
|
self.skipTest("no Omarchy runtime checkout to read the layout list from")
|
||||||
|
for keymap in SUPPORTED_KEYMAPS:
|
||||||
|
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
|
||||||
|
target = self.target(directory)
|
||||||
|
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap))
|
||||||
|
lines = (target / "etc/vconsole.conf").read_text().splitlines()
|
||||||
|
self.assertIn(f"KEYMAP={keymap}", lines)
|
||||||
|
self.assertEqual(lines.count("FONT=default8x16"), 1)
|
||||||
|
|
||||||
|
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
|
||||||
|
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
|
||||||
|
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
|
||||||
|
target = self.target(directory)
|
||||||
|
self.assertEqual(KEYBOARD.configure_keyboard(target, keymap), expected)
|
||||||
|
self.assertEqual(
|
||||||
|
(target / "etc/vconsole.conf").read_text(),
|
||||||
|
"KEYMAP=us\nFONT=default8x16\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_input_choice_survives_while_console_keymap_stays_real(self):
|
||||||
|
for method, keymap, layout in (("mozc", "jp106", ""), ("mozc", "us", ""), ("hangul", "us", "kr"), ("none", "us", "la"), ("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"), ("none", "az")):
|
||||||
|
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()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/python
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
PRUNER = ROOT / "builder/prune-offline-mirror.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class OfflineMirrorPruningTest(unittest.TestCase):
|
||||||
|
def run_pruner(self, mirror: Path, required: list[str]):
|
||||||
|
return subprocess.run(
|
||||||
|
["bash", str(PRUNER), str(mirror)],
|
||||||
|
input="".join(f"{filename}\n" for filename in required),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_file(self, mirror: Path, filename: str):
|
||||||
|
(mirror / filename).write_bytes(b"package")
|
||||||
|
|
||||||
|
def test_keeps_only_exactly_selected_package_archives(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
mirror = Path(directory)
|
||||||
|
keep = "keep-2.0-1-x86_64.pkg.tar.zst"
|
||||||
|
other = "other-1.0-1-any.pkg.tar.zst"
|
||||||
|
stale_version = "keep-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
removed_package = "removed-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
orphan_signature = "interrupted-1.0-1-x86_64.pkg.tar.zst.sig"
|
||||||
|
|
||||||
|
for filename in (keep, other, stale_version, removed_package):
|
||||||
|
self.add_file(mirror, filename)
|
||||||
|
for filename in (keep, stale_version, removed_package):
|
||||||
|
self.add_file(mirror, f"{filename}.sig")
|
||||||
|
self.add_file(mirror, orphan_signature)
|
||||||
|
self.add_file(mirror, "offline.db.tar.gz")
|
||||||
|
|
||||||
|
result = self.run_pruner(mirror, [keep, other])
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(
|
||||||
|
{path.name for path in mirror.iterdir()},
|
||||||
|
{keep, f"{keep}.sig", other, "offline.db.tar.gz"},
|
||||||
|
)
|
||||||
|
self.assertIn(stale_version, result.stdout)
|
||||||
|
self.assertIn(removed_package, result.stdout)
|
||||||
|
|
||||||
|
def test_missing_selection_aborts_before_deleting_anything(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
mirror = Path(directory)
|
||||||
|
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
self.add_file(mirror, cached)
|
||||||
|
|
||||||
|
result = self.run_pruner(
|
||||||
|
mirror, ["missing-1.0-1-x86_64.pkg.tar.zst"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertTrue((mirror / cached).exists())
|
||||||
|
self.assertIn("selected packages are missing", result.stderr)
|
||||||
|
|
||||||
|
def test_empty_selection_aborts_before_deleting_anything(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
mirror = Path(directory)
|
||||||
|
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
|
||||||
|
self.add_file(mirror, cached)
|
||||||
|
|
||||||
|
result = self.run_pruner(mirror, [])
|
||||||
|
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertTrue((mirror / cached).exists())
|
||||||
|
self.assertIn("empty package selection", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
"""Unit tests for deferred-provisioning install support in the orchestrator.
|
||||||
|
|
||||||
|
Covers the InstallContext deferred-provisioning plumbing (marker file, credential-less input,
|
||||||
|
throwaway LUKS passphrase injection) and the stage_provisioning_state /
|
||||||
|
create_factory_snapshot / configure_login phases against a temp target with
|
||||||
|
subprocess mocked out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "configs/airootfs/usr/share/omarchy-iso"))
|
||||||
|
|
||||||
|
sys.modules.setdefault(
|
||||||
|
"orchestrator.archinstall_adapter", types.ModuleType("orchestrator.archinstall_adapter")
|
||||||
|
)
|
||||||
|
|
||||||
|
from orchestrator import phases_impl # noqa: E402
|
||||||
|
from orchestrator.context import InstallContext # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ContextDeferProvisioningTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.dir = Path(self.tmp.name)
|
||||||
|
self.state_dir = self.dir / "state"
|
||||||
|
|
||||||
|
self.env = {
|
||||||
|
"OMARCHY_INSTALL_CONFIG": str(self.dir / "user_configuration.json"),
|
||||||
|
"OMARCHY_INSTALL_CREDS": str(self.dir / "user_credentials.json"),
|
||||||
|
"OMARCHY_INSTALL_STATE_DIR": str(self.state_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
def write_config(self, config):
|
||||||
|
(self.dir / "user_configuration.json").write_text(json.dumps(config))
|
||||||
|
|
||||||
|
def write_creds(self, creds):
|
||||||
|
(self.dir / "user_credentials.json").write_text(json.dumps(creds))
|
||||||
|
|
||||||
|
def from_env(self, **extra_env):
|
||||||
|
env = {**self.env, **extra_env}
|
||||||
|
with mock.patch.dict(os.environ, env, clear=False):
|
||||||
|
for key in ("OMARCHY_INSTALL_DEFER_PROVISIONING_FILE",):
|
||||||
|
if key not in env:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
return InstallContext.from_env()
|
||||||
|
|
||||||
|
def base_config(self, defer_provisioning=None, disk_encryption=None):
|
||||||
|
config = {
|
||||||
|
"disk_config": {"config_type": "default_layout"},
|
||||||
|
"omarchy_install": {"mode": "full_disk", "target_mount": "/mnt"},
|
||||||
|
}
|
||||||
|
if defer_provisioning is not None:
|
||||||
|
config["omarchy_install"]["defer_provisioning"] = defer_provisioning
|
||||||
|
if disk_encryption is not None:
|
||||||
|
config["disk_config"]["disk_encryption"] = disk_encryption
|
||||||
|
return config
|
||||||
|
|
||||||
|
def test_defer_provisioning_flag_from_config(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True))
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertTrue(ctx.defer_provisioning)
|
||||||
|
self.assertEqual(ctx.user_credentials, {"users": []})
|
||||||
|
self.assertEqual(ctx.username, "")
|
||||||
|
|
||||||
|
def test_marker_file_arms_deferred_provisioning_without_credentials(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
marker = self.dir / "defer_provisioning"
|
||||||
|
marker.touch()
|
||||||
|
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(marker))
|
||||||
|
self.assertTrue(ctx.defer_provisioning)
|
||||||
|
self.assertTrue(ctx.omarchy_install["defer_provisioning"])
|
||||||
|
|
||||||
|
def test_absent_marker_file_is_not_deferred_provisioning(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
self.write_creds({"users": [{"username": "jeff"}]})
|
||||||
|
ctx = self.from_env(OMARCHY_INSTALL_DEFER_PROVISIONING_FILE=str(self.dir / "missing-defer_provisioning"))
|
||||||
|
self.assertFalse(ctx.defer_provisioning)
|
||||||
|
self.assertEqual(ctx.username, "jeff")
|
||||||
|
|
||||||
|
def test_missing_credentials_without_defer_provisioning_raises(self):
|
||||||
|
self.write_config(self.base_config())
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "credentials file missing"):
|
||||||
|
self.from_env()
|
||||||
|
|
||||||
|
def test_deferred_provisioning_generates_throwaway_luks_passphrase(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True, disk_encryption={
|
||||||
|
"encryption_type": "luks", "partitions": ["x"],
|
||||||
|
}))
|
||||||
|
ctx = self.from_env()
|
||||||
|
|
||||||
|
password = ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"]
|
||||||
|
self.assertTrue(password)
|
||||||
|
self.assertEqual(ctx.user_credentials["encryption_password"], password)
|
||||||
|
|
||||||
|
# archinstall reads both files from disk; the injected passphrase must
|
||||||
|
# be present in each.
|
||||||
|
arch_config = json.loads(ctx.arch_config_path.read_text())
|
||||||
|
self.assertEqual(
|
||||||
|
arch_config["disk_config"]["disk_encryption"]["encryption_password"], password
|
||||||
|
)
|
||||||
|
synthesized = json.loads(ctx.creds_path.read_text())
|
||||||
|
self.assertEqual(synthesized["encryption_password"], password)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_reuses_rig_supplied_passphrase(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True, disk_encryption={
|
||||||
|
"encryption_type": "luks", "partitions": ["x"],
|
||||||
|
}))
|
||||||
|
self.write_creds({"users": [], "encryption_password": "rig-secret"})
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertEqual(
|
||||||
|
ctx.user_configuration["disk_config"]["disk_encryption"]["encryption_password"],
|
||||||
|
"rig-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_discards_account_material(self):
|
||||||
|
# A rig credentials file must not smuggle in users or a root password.
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True))
|
||||||
|
self.write_creds({
|
||||||
|
"users": [{"username": "backdoor", "enc_password": "x", "sudo": True}],
|
||||||
|
"root_enc_password": "x",
|
||||||
|
"encryption_password": "rig-secret",
|
||||||
|
})
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertEqual(ctx.user_credentials["users"], [])
|
||||||
|
self.assertNotIn("root_enc_password", ctx.user_credentials)
|
||||||
|
self.assertEqual(ctx.user_credentials["encryption_password"], "rig-secret")
|
||||||
|
synthesized = json.loads(ctx.creds_path.read_text())
|
||||||
|
self.assertEqual(synthesized["users"], [])
|
||||||
|
self.assertNotIn("root_enc_password", synthesized)
|
||||||
|
|
||||||
|
def test_defer_provisioning_strips_account_fields(self):
|
||||||
|
config = self.base_config(defer_provisioning=True)
|
||||||
|
config["!users"] = [{"username": "rig"}]
|
||||||
|
config["!root-password"] = "hunter2"
|
||||||
|
config["root_enc_password"] = "x"
|
||||||
|
config["auth_config"] = {"users": [{"username": "rig"}], "root_enc_password": "x"}
|
||||||
|
self.write_config(config)
|
||||||
|
ctx = self.from_env()
|
||||||
|
|
||||||
|
arch_config = json.loads(ctx.arch_config_path.read_text())
|
||||||
|
for key in ("!users", "!root-password", "root_enc_password", "users"):
|
||||||
|
self.assertNotIn(key, arch_config)
|
||||||
|
self.assertEqual(arch_config["auth_config"], {})
|
||||||
|
|
||||||
|
def test_deferred_provisioning_unencrypted_touches_nothing(self):
|
||||||
|
self.write_config(self.base_config(defer_provisioning=True))
|
||||||
|
ctx = self.from_env()
|
||||||
|
self.assertNotIn("encryption_password", ctx.user_credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def make_ctx(target, **overrides):
|
||||||
|
defaults = dict(
|
||||||
|
target=target,
|
||||||
|
defer_provisioning=True,
|
||||||
|
encrypt=False,
|
||||||
|
username="",
|
||||||
|
omarchy_install={"mode": "full_disk", "defer_provisioning": True, "storage": {}},
|
||||||
|
user_configuration={"disk_config": {}},
|
||||||
|
user_credentials={"users": []},
|
||||||
|
state_dir=target / "state",
|
||||||
|
authorized_keys_path=None,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return types.SimpleNamespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class StageProvisioningStateTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name) / "mnt"
|
||||||
|
self.target.mkdir()
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
# A fake bundled Node tarball on the "live ISO".
|
||||||
|
self.packages = Path(self.tmp.name) / "opt-packages"
|
||||||
|
self.packages.mkdir()
|
||||||
|
(self.packages / "node-v24.0.0-linux-x64.tar.gz").write_bytes(b"node")
|
||||||
|
node_patch = mock.patch.object(phases_impl, "NODE_PACKAGES_DIR", self.packages)
|
||||||
|
node_patch.start()
|
||||||
|
self.addCleanup(node_patch.stop)
|
||||||
|
|
||||||
|
def install_runtime_provisioning_support(self):
|
||||||
|
service = self.target / "usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
|
||||||
|
service.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
service.write_text("[Unit]\n")
|
||||||
|
setup_bin = self.target / "usr/bin/omarchy-provision-owner"
|
||||||
|
setup_bin.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
setup_bin.write_text("#!/bin/bash\n")
|
||||||
|
|
||||||
|
def provisioning_dir(self):
|
||||||
|
return self.target / "var/lib/omarchy/provisioning"
|
||||||
|
|
||||||
|
def test_normal_install_stages_only_the_node_tarball(self):
|
||||||
|
ctx = make_ctx(self.target, defer_provisioning=False)
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
self.assertTrue((self.provisioning_dir() / "packages/node-v24.0.0-linux-x64.tar.gz").exists())
|
||||||
|
self.assertFalse((self.provisioning_dir() / "pending").exists())
|
||||||
|
self.assertFalse((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
|
||||||
|
|
||||||
|
def test_deferred_provisioning_against_runtime_without_support_fails_clearly(self):
|
||||||
|
ctx = make_ctx(self.target)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "does not ship"):
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_without_node_tarball_fails(self):
|
||||||
|
for tarball in self.packages.glob("*.tar.gz"):
|
||||||
|
tarball.unlink()
|
||||||
|
self.install_runtime_provisioning_support()
|
||||||
|
ctx = make_ctx(self.target)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
def test_missing_node_tarball_fails_normal_installs_too(self):
|
||||||
|
# The stash is what makes a later factory reset work offline.
|
||||||
|
for tarball in self.packages.glob("*.tar.gz"):
|
||||||
|
tarball.unlink()
|
||||||
|
ctx = make_ctx(self.target, defer_provisioning=False)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Node tarball"):
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_arms_first_boot_setup(self):
|
||||||
|
self.install_runtime_provisioning_support()
|
||||||
|
ctx = make_ctx(self.target)
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
self.assertTrue((self.provisioning_dir() / "pending").exists())
|
||||||
|
self.assertTrue((self.target / "etc/systemd/system/omarchy-provision-owner.service").exists())
|
||||||
|
link = self.target / "etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service"
|
||||||
|
self.assertTrue(link.is_symlink())
|
||||||
|
self.assertEqual(
|
||||||
|
os.readlink(link), "/etc/systemd/system/omarchy-provision-owner.service"
|
||||||
|
)
|
||||||
|
# Unencrypted: no LUKS staging.
|
||||||
|
self.assertFalse((self.provisioning_dir() / "luks-key").exists())
|
||||||
|
|
||||||
|
def test_deferred_provisioning_encrypted_stages_luks_auto_unlock(self):
|
||||||
|
self.install_runtime_provisioning_support()
|
||||||
|
ctx = make_ctx(
|
||||||
|
self.target,
|
||||||
|
user_configuration={"disk_config": {"disk_encryption": {
|
||||||
|
"encryption_type": "luks",
|
||||||
|
"encryption_password": "throwaway-secret",
|
||||||
|
}}},
|
||||||
|
)
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
# Byte-for-byte the slot passphrase — no trailing newline.
|
||||||
|
self.assertEqual((self.provisioning_dir() / "luks-key").read_text(), "throwaway-secret")
|
||||||
|
keyfile = self.target / "etc/omarchy/provisioning.key"
|
||||||
|
self.assertEqual(keyfile.read_text(), "throwaway-secret")
|
||||||
|
self.assertEqual(keyfile.stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
cmdline = (self.target / "etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf").read_text()
|
||||||
|
self.assertIn("cryptkey=rootfs:/etc/omarchy/provisioning.key", cmdline)
|
||||||
|
files = (self.target / "etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf").read_text()
|
||||||
|
self.assertIn("FILES+=(/etc/omarchy/provisioning.key)", files)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_pre_encrypted_without_passphrase_fails(self):
|
||||||
|
self.install_runtime_provisioning_support()
|
||||||
|
ctx = make_ctx(
|
||||||
|
self.target,
|
||||||
|
omarchy_install={"mode": "protected", "defer_provisioning": True, "storage": {"luks_uuid": "abc"}},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "passphrase"):
|
||||||
|
phases_impl.stage_provisioning_state(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureLoginDeferProvisioningTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(
|
||||||
|
phases_impl.subprocess, "run",
|
||||||
|
side_effect=lambda cmd, **kw: (self.calls.append(cmd), CompletedProcess(cmd, 0))[1],
|
||||||
|
)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_login_leaves_user_state_to_first_boot(self):
|
||||||
|
ctx = make_ctx(self.target, encrypt=True)
|
||||||
|
phases_impl.configure_login(ctx)
|
||||||
|
|
||||||
|
self.assertTrue((self.target / "etc/sddm.conf.d/99-omarchy-login.conf").exists())
|
||||||
|
self.assertFalse((self.target / "etc/sddm.conf.d/autologin.conf").exists())
|
||||||
|
self.assertFalse((self.target / "var/lib/sddm/state.conf").exists())
|
||||||
|
self.assertTrue(any("sddm.service" in cmd for cmd in self.calls))
|
||||||
|
|
||||||
|
def test_normal_encrypted_login_still_autologs_in(self):
|
||||||
|
ctx = make_ctx(self.target, defer_provisioning=False, encrypt=True, username="jeff")
|
||||||
|
phases_impl.configure_login(ctx)
|
||||||
|
|
||||||
|
autologin = (self.target / "etc/sddm.conf.d/autologin.conf").read_text()
|
||||||
|
self.assertIn("User=jeff", autologin)
|
||||||
|
state = (self.target / "var/lib/sddm/state.conf").read_text()
|
||||||
|
self.assertIn("User=jeff", state)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureSshAccessDeferProvisioningTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name) / "mnt"
|
||||||
|
self.target.mkdir()
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if "ufw" in cmd:
|
||||||
|
rules = self.target / "etc/ufw/user.rules"
|
||||||
|
rules.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
rules.write_text("-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n")
|
||||||
|
return CompletedProcess(cmd, 0)
|
||||||
|
|
||||||
|
self.calls = []
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
def test_deferred_provisioning_stages_keys_for_first_boot(self):
|
||||||
|
keys = Path(self.tmp.name) / "authorized_keys"
|
||||||
|
keys.write_text("ssh-ed25519 AAAA rig@host\n")
|
||||||
|
ctx = make_ctx(self.target, authorized_keys_path=keys)
|
||||||
|
|
||||||
|
phases_impl.configure_ssh_access(ctx)
|
||||||
|
|
||||||
|
staged = self.target / "var/lib/omarchy/provisioning/authorized_keys"
|
||||||
|
self.assertEqual(staged.read_text(), "ssh-ed25519 AAAA rig@host\n")
|
||||||
|
self.assertEqual(staged.stat().st_mode & 0o777, 0o600)
|
||||||
|
# No user yet — nothing under /home, no chown.
|
||||||
|
self.assertFalse((self.target / "home").exists())
|
||||||
|
self.assertFalse(any("chown" in cmd for cmd in self.calls))
|
||||||
|
# The door is still opened for first boot.
|
||||||
|
self.assertTrue(any("sshd.service" in cmd for cmd in self.calls))
|
||||||
|
|
||||||
|
|
||||||
|
class CreateFactorySnapshotTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.target = Path(self.tmp.name) / "mnt"
|
||||||
|
self.target.mkdir()
|
||||||
|
self.state_dir = Path(self.tmp.name) / "state"
|
||||||
|
self.state_dir.mkdir()
|
||||||
|
|
||||||
|
info_patch = mock.patch.object(phases_impl, "info")
|
||||||
|
info_patch.start()
|
||||||
|
self.addCleanup(info_patch.stop)
|
||||||
|
|
||||||
|
self.findmnt = {
|
||||||
|
"FSTYPE": "btrfs",
|
||||||
|
"OPTIONS": "rw,noatime,compress=zstd:3,subvol=/@",
|
||||||
|
"SOURCE": "/dev/mapper/omarchy_root[/@]",
|
||||||
|
}
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if cmd[0] == "findmnt":
|
||||||
|
return CompletedProcess(cmd, 0, stdout=self.findmnt[cmd[2]] + "\n", stderr="")
|
||||||
|
if cmd[0] == "mount":
|
||||||
|
# Simulate the top level containing @.
|
||||||
|
(Path(cmd[-1]) / "@").mkdir(parents=True, exist_ok=True)
|
||||||
|
return CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||||
|
|
||||||
|
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=fake_run)
|
||||||
|
run_patch.start()
|
||||||
|
self.addCleanup(run_patch.stop)
|
||||||
|
|
||||||
|
def ctx(self):
|
||||||
|
return make_ctx(self.target, defer_provisioning=False, state_dir=self.state_dir)
|
||||||
|
|
||||||
|
def test_snapshots_root_as_factory(self):
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
|
||||||
|
top = str(self.state_dir / "factory-top")
|
||||||
|
self.assertIn(["mount", "-o", "subvolid=5", "/dev/mapper/omarchy_root", top], self.calls)
|
||||||
|
self.assertIn(
|
||||||
|
["btrfs", "subvolume", "snapshot", f"{top}/@", f"{top}/@factory"],
|
||||||
|
self.calls,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
["btrfs", "property", "set", "-ts", f"{top}/@factory", "ro", "true"],
|
||||||
|
self.calls,
|
||||||
|
)
|
||||||
|
self.assertIn(["umount", top], self.calls)
|
||||||
|
|
||||||
|
def test_factory_snapshot_scrubs_provisioning_credentials(self):
|
||||||
|
# Simulate the snapshot carrying deployment credentials; the scrub
|
||||||
|
# must drop them before the snapshot goes read-only.
|
||||||
|
top = self.state_dir / "factory-top"
|
||||||
|
factory = top / "@factory"
|
||||||
|
secrets = [
|
||||||
|
factory / "var/lib/omarchy/provisioning/authorized_keys",
|
||||||
|
factory / "etc/tailscale/authkey",
|
||||||
|
factory / "etc/omarchy/provisioning.key",
|
||||||
|
]
|
||||||
|
keep = factory / "var/lib/omarchy/provisioning/groups"
|
||||||
|
for path in [*secrets, keep]:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("secret")
|
||||||
|
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
|
||||||
|
for path in secrets:
|
||||||
|
self.assertFalse(path.exists(), path)
|
||||||
|
self.assertTrue(keep.exists())
|
||||||
|
|
||||||
|
def test_non_btrfs_target_skips(self):
|
||||||
|
self.findmnt["FSTYPE"] = "ext4"
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
|
||||||
|
|
||||||
|
def test_non_subvol_root_skips(self):
|
||||||
|
self.findmnt["OPTIONS"] = "rw,noatime"
|
||||||
|
phases_impl.create_factory_snapshot(self.ctx())
|
||||||
|
self.assertFalse(any(cmd[0] == "mount" for cmd in self.calls))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
Executable
+270
@@ -0,0 +1,270 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Takes an installed Omarchy, as a user's machine holds it, moves it to another
|
||||||
|
# channel the way a user would, reboots it, and checks what came back.
|
||||||
|
#
|
||||||
|
# test/upgrade-test <installed_dir> <channel> <evidence_dir>
|
||||||
|
#
|
||||||
|
# installed_dir is what test/integration leaves once its install has passed,
|
||||||
|
# test-runs/<iso>-integration: base.qcow2, OVMF_VARS.4m.fd and id_ed25519. It is
|
||||||
|
# not written to; the machine runs on an overlay. channel is edge or rc.
|
||||||
|
#
|
||||||
|
# The machine boots through the firmware and the boot loader, so the reboot
|
||||||
|
# after the update starts whatever kernel the update installed. Commands run
|
||||||
|
# in a login shell, as a user's terminal does: Omarchy's commands need its
|
||||||
|
# environment.
|
||||||
|
#
|
||||||
|
# evidence_dir gets before.png and after.png (the desktop, unlocked), the
|
||||||
|
# update's output, the facts before and after, and report.md; stuck.png too if
|
||||||
|
# the reboot did not come back by itself. The exit status is 0 only if every
|
||||||
|
# check passed.
|
||||||
|
#
|
||||||
|
# Needs qemu-system-x86_64, qemu-img, ssh, python3 and a usable /dev/kvm.
|
||||||
|
# Env: SMP (4), MEM (8G), SSH_PORT (2422), GUEST_PASSWORD (omarchy),
|
||||||
|
# UPDATE_TIMEOUT (seconds, 1800), EXTRA_MIGRATIONS (migration files to put
|
||||||
|
# in place before the update, to try a fix that is not released yet).
|
||||||
|
set -uo pipefail
|
||||||
|
[[ $# -eq 3 ]] || { sed -n '3,24p' "$0" >&2; exit 2; }
|
||||||
|
in=$(realpath "$1") channel=$2 out=$(realpath -m "$3")
|
||||||
|
pw=${GUEST_PASSWORD:-omarchy} port=${SSH_PORT:-2422}
|
||||||
|
[[ $channel == edge || $channel == rc ]] || { echo "upgrade-test: channel is edge or rc" >&2; exit 2; }
|
||||||
|
for f in base.qcow2 OVMF_VARS.4m.fd id_ed25519; do
|
||||||
|
[[ -s $in/$f ]] || { echo "upgrade-test: $in/$f is missing" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "upgrade-test: /dev/kvm is not usable" >&2; exit 1; }
|
||||||
|
for fw in /usr/share/edk2/x64/OVMF_CODE.4m.fd /usr/share/OVMF/OVMF_CODE_4M.fd; do
|
||||||
|
[[ -f $fw ]] && { fw_code=$fw; break; }
|
||||||
|
done
|
||||||
|
: "${fw_code:?no UEFI firmware found}"
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
state=$(mktemp -d "${VM_STATE_DIR:-/var/tmp}/upgrade-test.XXXXXX")
|
||||||
|
cleanup() {
|
||||||
|
local status=$? pid
|
||||||
|
if [[ -f $state/qemu.pid ]]; then
|
||||||
|
pid=$(cat "$state/qemu.pid")
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||||
|
fi
|
||||||
|
rm -rf "$state"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT TERM
|
||||||
|
|
||||||
|
log() { printf '==> %s\n' "$*"; }
|
||||||
|
install -m600 "$in/id_ed25519" "$state/key"
|
||||||
|
ssh_opts=(-i "$state/key" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=no
|
||||||
|
-o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=3)
|
||||||
|
guest() { ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "bash -lc $(printf %q "$1")" </dev/null; }
|
||||||
|
wait_ssh() { # seconds
|
||||||
|
local start=$SECONDS
|
||||||
|
until ssh "${ssh_opts[@]}" omarchy@127.0.0.1 true </dev/null 2>/dev/null; do
|
||||||
|
kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null || { echo "upgrade-test: qemu exited" >&2; return 1; }
|
||||||
|
(( SECONDS - start < $1 )) || return 1
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
qmp() { # shot <file> | key <qcode>...
|
||||||
|
python3 - "$state/qmp.sock" "$@" <<'PY'
|
||||||
|
import json, socket, sys
|
||||||
|
s = socket.socket(socket.AF_UNIX); s.settimeout(20); s.connect(sys.argv[1]); f = s.makefile("rw"); f.readline()
|
||||||
|
def run(command):
|
||||||
|
f.write(json.dumps(command) + "\n"); f.flush()
|
||||||
|
while "return" not in (reply := json.loads(f.readline())):
|
||||||
|
if "error" in reply: sys.exit(reply["error"]["desc"])
|
||||||
|
run({"execute": "qmp_capabilities"})
|
||||||
|
if sys.argv[2] == "shot":
|
||||||
|
run({"execute": "screendump", "arguments": {"filename": sys.argv[3], "format": "png"}})
|
||||||
|
else:
|
||||||
|
for key in sys.argv[3:]:
|
||||||
|
run({"execute": "send-key", "arguments": {"keys": [{"type": "qcode", "data": key}]}})
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
# The session starts locked. Type the password, as its owner would, and wait
|
||||||
|
# for the desktop to draw.
|
||||||
|
desktop_shot() { # file
|
||||||
|
local key keys=()
|
||||||
|
for (( key = 0; key < ${#pw}; key++ )); do keys+=("${pw:key:1}"); done
|
||||||
|
qmp key "${keys[@]}" ret || true
|
||||||
|
sleep 8
|
||||||
|
qmp shot "$1" || echo "upgrade-test: no screenshot" >&2
|
||||||
|
}
|
||||||
|
# One fact per line, name=value, the same names before and after.
|
||||||
|
facts() { # file
|
||||||
|
guest '
|
||||||
|
link=~/.local/state/omarchy/current/background; target=$(readlink -f "$link" 2>/dev/null)
|
||||||
|
echo "omarchy=$(pacman -Qq omarchy omarchy-dev 2>/dev/null | sort -u | xargs -r pacman -Q 2>/dev/null | paste -sd, -)"
|
||||||
|
echo "channel=$(omarchy-channel-current 2>/dev/null)"
|
||||||
|
echo "kernel=$(uname -r)"
|
||||||
|
echo "kernel_is_installed=$([[ -d /usr/lib/modules/$(uname -r)/kernel ]] && echo yes || echo no)"
|
||||||
|
echo "systemd=$(systemctl --version | sed -n "1s/^systemd \([0-9.]*\).*/\1/p")"
|
||||||
|
echo "hyprland=$(pacman -Q hyprland 2>/dev/null | cut -d" " -f2)"
|
||||||
|
echo "system_state=$(systemctl is-system-running 2>/dev/null)"
|
||||||
|
# A manager that cannot be asked is not a manager with nothing failed.
|
||||||
|
units=$(systemctl --failed --no-legend --plain 2>/dev/null) && units=$(awk "{print \$1}" <<<"$units" | paste -sd, -) || units="could not ask systemd"
|
||||||
|
user_units=$(systemctl --user --failed --no-legend --plain 2>/dev/null) && user_units=$(awk "{print \$1}" <<<"$user_units" | paste -sd, -) || user_units="could not ask the user manager"
|
||||||
|
echo "failed_units=$units"
|
||||||
|
echo "failed_user_units=$user_units"
|
||||||
|
echo "hyprland_running=$(pgrep -c -x Hyprland)"
|
||||||
|
echo "migrations_run=$(find ~/.local/state/omarchy/migrations -maxdepth 1 -name "*.sh" | wc -l)"
|
||||||
|
echo "migrations_skipped=$(find ~/.local/state/omarchy/migrations/skipped -name "*.sh" 2>/dev/null | wc -l)"
|
||||||
|
echo "wallpaper=${target##*/}"
|
||||||
|
echo "wallpaper_exists=$([[ -n $target && -e $target ]] && echo yes || echo no)"
|
||||||
|
echo "boot_entry_groups=$(sudo grep -c "^/+" /boot/limine.conf 2>/dev/null)"
|
||||||
|
echo "boot_id=$(cut -c1-8 /proc/sys/kernel/random/boot_id)"
|
||||||
|
' 2>/dev/null | grep -E '^[a-z_]+=' >"$1"
|
||||||
|
}
|
||||||
|
fact() { sed -n "s/^$2=//p" "$1" | head -n 1; }
|
||||||
|
|
||||||
|
qemu-img create -qf qcow2 -b "$in/base.qcow2" -F qcow2 "$state/run.qcow2"
|
||||||
|
cp "$in/OVMF_VARS.4m.fd" "$state/vars.fd"
|
||||||
|
: >"$out/serial.log"
|
||||||
|
qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp "${SMP:-4}" -m "${MEM:-8G}" \
|
||||||
|
-drive "if=pflash,format=raw,readonly=on,file=$fw_code" -drive "if=pflash,format=raw,file=$state/vars.fd" \
|
||||||
|
-drive "file=$state/run.qcow2,format=qcow2,if=none,id=d0" -device virtio-blk-pci,drive=d0 \
|
||||||
|
-device virtio-vga -device virtio-rng-pci -display none -usb -device usb-kbd -serial "file:$out/serial.log" \
|
||||||
|
-netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \
|
||||||
|
-qmp "unix:$state/qmp.sock,server,nowait" -pidfile "$state/qemu.pid" -daemonize
|
||||||
|
|
||||||
|
log "Booting the installed system"
|
||||||
|
wait_ssh 300 || { echo "upgrade-test: the installed system did not come up" >&2; exit 1; }
|
||||||
|
# The overlay is thrown away: let sudo through without asking, so a long
|
||||||
|
# update does not stop at a password prompt nobody is there to answer.
|
||||||
|
# sudo takes the last rule that matches, and reads this folder in name order:
|
||||||
|
# the name has to sort after the install's own rule for the wheel group.
|
||||||
|
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "echo $(printf %q "$pw") | sudo -S -p '' sh -c 'printf \"%s\\n\" \"omarchy ALL=(ALL:ALL) NOPASSWD: ALL\" \"Defaults:omarchy !authenticate\" >/etc/sudoers.d/zzz-upgrade-test'" </dev/null
|
||||||
|
# "sudo -v" is the hard one: it asks unless every rule for the user lets it
|
||||||
|
# through, which is why the rule alone is not enough.
|
||||||
|
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 'sudo -k; sudo -n true && sudo -n -v' </dev/null ||
|
||||||
|
{ echo "upgrade-test: sudo still asks for a password in the guest" >&2; exit 1; }
|
||||||
|
guest 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
|
||||||
|
facts "$out/before.txt"
|
||||||
|
desktop_shot "$out/before.png"
|
||||||
|
log "Before: $(fact "$out/before.txt" omarchy), kernel $(fact "$out/before.txt" kernel), channel $(fact "$out/before.txt" channel)"
|
||||||
|
|
||||||
|
for migration in ${EXTRA_MIGRATIONS:-}; do
|
||||||
|
log "Putting $(basename "$migration") in place before the update"
|
||||||
|
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "sudo tee /usr/share/omarchy/migrations/$(basename "$migration") >/dev/null" <"$migration"
|
||||||
|
done
|
||||||
|
|
||||||
|
# A channel that offers what the machine already has, or something older, is
|
||||||
|
# no upgrade. Say so now: the release's own updater, run with nothing to do,
|
||||||
|
# ends at a prompt nobody is there to answer.
|
||||||
|
case $channel in edge) package=omarchy-dev ;; *) package=omarchy ;; esac
|
||||||
|
# A server that cannot be reached, or does not answer in time, is not a channel
|
||||||
|
# with nothing on offer: that is a failure of its own, said as one.
|
||||||
|
if ! offered=$(guest "set -o pipefail; curl -fsSL --connect-timeout 20 --max-time 120 --retry 2 https://pkgs.omarchy.org/$channel/\$(uname -m)/omarchy.db | bsdtar -xOf - '$package-[0-9]*/desc' | sed -n '/^%VERSION%\$/{n;p;}' | head -n 1" 2>"$out/probe.log") || [[ -z $offered ]]; then
|
||||||
|
{
|
||||||
|
echo "**The $channel channel could not be asked what it offers.** The test did not run."
|
||||||
|
echo
|
||||||
|
echo '```'
|
||||||
|
tail -n 5 "$out/probe.log"
|
||||||
|
echo '```'
|
||||||
|
} >"$out/report.md"
|
||||||
|
cat "$out/report.md"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
installed=$(guest "pacman -Q $package 2>/dev/null | cut -d' ' -f2" 2>/dev/null)
|
||||||
|
log "The $channel channel offers $package $offered; installed: ${installed:-none}"
|
||||||
|
# pacman's own ordering: 0 for the same version, below 0 for an older offer.
|
||||||
|
newer=1
|
||||||
|
[[ -z $installed ]] || newer=$(guest "vercmp $(printf %q "$offered") $(printf %q "$installed")" 2>/dev/null)
|
||||||
|
if (( ${newer:-0} <= 0 )); then
|
||||||
|
{
|
||||||
|
echo "**Nothing to upgrade to.** The $channel channel offers \`$package $offered\`, and the machine has \`$(fact "$out/before.txt" omarchy)\`."
|
||||||
|
echo
|
||||||
|
echo "The test did not run: point it at a channel that holds a newer Omarchy."
|
||||||
|
} >"$out/report.md"
|
||||||
|
cat "$out/report.md"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "omarchy-channel-set $channel"
|
||||||
|
update_start=$SECONDS
|
||||||
|
guest "timeout ${UPDATE_TIMEOUT:-1800} omarchy-channel-set $channel" >"$out/update.log" 2>&1
|
||||||
|
update_status=$?
|
||||||
|
update_seconds=$(( SECONDS - update_start ))
|
||||||
|
sed -i 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\r/\n/g' "$out/update.log"
|
||||||
|
migrations_in_update=$(grep -c '^Running migration' "$out/update.log" || true)
|
||||||
|
log "The update exited $update_status after $update_seconds s, $migrations_in_update migrations"
|
||||||
|
|
||||||
|
log "Rebooting"
|
||||||
|
guest 'sudo systemctl reboot' >/dev/null 2>&1 || true
|
||||||
|
# Wait for the old boot to go away first: its sshd answers for a moment yet.
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
ssh "${ssh_opts[@]}" -o ConnectTimeout=2 omarchy@127.0.0.1 true </dev/null 2>/dev/null || break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
rebooted=no
|
||||||
|
if wait_ssh 150; then
|
||||||
|
rebooted=yes
|
||||||
|
else
|
||||||
|
# A boot that sits there until a key is pressed is a failure, and a known
|
||||||
|
# kind: keep what the screen showed, then press Enter to find out whether
|
||||||
|
# that was it, and to have a system to collect the rest from.
|
||||||
|
qmp shot "$out/stuck.png" || true
|
||||||
|
qmp key ret || true
|
||||||
|
if wait_ssh 150; then rebooted="only after a key was pressed"; fi
|
||||||
|
fi
|
||||||
|
if [[ $rebooted != no ]]; then
|
||||||
|
guest 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
|
||||||
|
facts "$out/after.txt"
|
||||||
|
desktop_shot "$out/after.png"
|
||||||
|
else
|
||||||
|
: >"$out/after.txt"
|
||||||
|
qmp shot "$out/after.png" || true
|
||||||
|
fi
|
||||||
|
log "After: $(fact "$out/after.txt" omarchy), kernel $(fact "$out/after.txt" kernel), channel $(fact "$out/after.txt" channel)"
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
rows=()
|
||||||
|
check() { # description, command...
|
||||||
|
local what=$1; shift
|
||||||
|
if "$@"; then rows+=("| ✅ | $what |"); else rows+=("| ❌ | $what |"); failures=$((failures + 1)); fi
|
||||||
|
}
|
||||||
|
b() { fact "$out/before.txt" "$1"; }
|
||||||
|
a() { fact "$out/after.txt" "$1"; }
|
||||||
|
check "The update finished without an error (exit $update_status)" test "$update_status" = 0
|
||||||
|
check "No migration was skipped ($(a migrations_skipped) skipped)" test "$(a migrations_skipped)" = 0
|
||||||
|
check "The system came back from the reboot by itself (came back: $rebooted)" test "$rebooted" = yes
|
||||||
|
check "It is a new boot (boot ID $(b boot_id) → $(a boot_id))" test -n "$(a boot_id)" -a "$(a boot_id)" != "$(b boot_id)"
|
||||||
|
check "It is on the $channel channel (got: $(a channel))" test "$(a channel)" = "$channel"
|
||||||
|
# A channel that holds what the machine already had upgrades nothing, and
|
||||||
|
# every other check would pass on a machine that was only rebooted.
|
||||||
|
check "Omarchy itself changed ($(b omarchy) → $(a omarchy))" test -n "$(a omarchy)" -a "$(a omarchy)" != "$(b omarchy)"
|
||||||
|
check "The running kernel is an installed one ($(a kernel))" test "$(a kernel_is_installed)" = yes
|
||||||
|
check "systemd reports the system running (got: $(a system_state))" test "$(a system_state)" = running
|
||||||
|
failed="$(a failed_units) $(a failed_user_units)"
|
||||||
|
failed=$(xargs <<<"$failed")
|
||||||
|
check "No failed units (${failed:-none})" test -z "$failed"
|
||||||
|
check "The desktop is up (Hyprland processes: $(a hyprland_running))" test "$(a hyprland_running)" = 1
|
||||||
|
# The same picture, whatever format it is kept in now: an update may convert
|
||||||
|
# the bundled images, and may not swap the one the user chose for another.
|
||||||
|
before_wallpaper=$(b wallpaper) after_wallpaper=$(a wallpaper)
|
||||||
|
check "The wallpaper is the one the user had, and exists ($before_wallpaper → $after_wallpaper)" \
|
||||||
|
test "$(a wallpaper_exists)" = yes -a -n "$after_wallpaper" -a "${after_wallpaper%.*}" = "${before_wallpaper%.*}"
|
||||||
|
check "The boot menu has one Omarchy group ($(a boot_entry_groups))" test "$(a boot_entry_groups)" = 1
|
||||||
|
|
||||||
|
row() { printf '| %s | %s | %s |\n' "$1" "$(b "$2")" "$(a "$2")"; }
|
||||||
|
{
|
||||||
|
if (( failures )); then echo "**$failures of ${#rows[@]} checks failed.**"; else echo "**All ${#rows[@]} checks passed.**"; fi
|
||||||
|
echo
|
||||||
|
echo "| | Before | After the update and a reboot |"
|
||||||
|
echo "| --- | --- | --- |"
|
||||||
|
row "Omarchy" omarchy
|
||||||
|
row "Channel" channel
|
||||||
|
row "Kernel running" kernel
|
||||||
|
row "systemd" systemd
|
||||||
|
row "Hyprland" hyprland
|
||||||
|
row "Migrations run" migrations_run
|
||||||
|
row "Wallpaper" wallpaper
|
||||||
|
echo
|
||||||
|
echo "The update took $update_seconds s and ran $migrations_in_update migrations."
|
||||||
|
echo
|
||||||
|
echo "| | Check |"
|
||||||
|
echo "| --- | --- |"
|
||||||
|
printf '%s\n' "${rows[@]}"
|
||||||
|
} >"$out/report.md"
|
||||||
|
cat "$out/report.md"
|
||||||
|
(( failures == 0 ))
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fetches a run's artifact for the publish job, and fast.
|
||||||
|
#
|
||||||
|
# nightly-fetch <owner/repo> <run_id> <artifact_name> <out_dir>
|
||||||
|
#
|
||||||
|
# The download action takes a multi-gigabyte artifact over a few connections,
|
||||||
|
# and GitHub's storage limits each one to a few megabytes a second: an 11 GB
|
||||||
|
# release would outlast the job. An artifact is one zip at a signed address,
|
||||||
|
# so this asks the API for that address and takes it over 16 connections.
|
||||||
|
#
|
||||||
|
# The zip goes to TMPDIR and is deleted once unpacked, so there has to be room
|
||||||
|
# for the artifact twice: point TMPDIR at a disk, not at a /tmp held in memory.
|
||||||
|
#
|
||||||
|
# Needs gh (with GH_TOKEN allowed to read the run's artifacts), curl, aria2c
|
||||||
|
# and unzip. A run started again keeps the artifacts of its earlier attempts
|
||||||
|
# under the same name: the newest is the one fetched.
|
||||||
|
set -euo pipefail
|
||||||
|
[[ $# -eq 4 ]] || { sed -n '2,16p' "$0" >&2; exit 2; }
|
||||||
|
repo=$1 run=$2 name=$3 out=$4
|
||||||
|
fail() { echo "nightly-fetch: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
id=$(gh api "repos/$repo/actions/runs/$run/artifacts" --paginate \
|
||||||
|
--jq ".artifacts[] | select(.name == \"$name\" and .expired == false) | .id" | sort -n | tail -n 1)
|
||||||
|
[[ -n $id ]] || fail "run $run has no artifact named $name"
|
||||||
|
size=$(gh api "repos/$repo/actions/artifacts/$id" --jq .size_in_bytes)
|
||||||
|
echo "nightly-fetch: artifact $id, $((size / 1048576)) MiB"
|
||||||
|
|
||||||
|
zip=$(mktemp -u "${TMPDIR:-/tmp}/nightly-fetch.XXXXXX.zip")
|
||||||
|
mkdir -p "$out"
|
||||||
|
for place in "$(dirname "$zip")" "$out"; do
|
||||||
|
free=$(( $(df --output=avail -B1 "$place" | tail -n 1) ))
|
||||||
|
(( free > size )) || fail "$place has $((free / 1048576)) MiB free, the artifact is $((size / 1048576)) MiB"
|
||||||
|
done
|
||||||
|
trap 'rm -f "$zip" "$zip.aria2"' EXIT
|
||||||
|
started=$(date +%s)
|
||||||
|
# The address is signed for a few minutes. A try that fails asks for a new one
|
||||||
|
# and carries on from what is already there.
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
url=$(curl -fsS -o /dev/null -w '%{redirect_url}' \
|
||||||
|
-H "Authorization: Bearer $(gh auth token)" -H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/$repo/actions/artifacts/$id/zip")
|
||||||
|
[[ $url == https://* ]] || fail "the API gave no download address for artifact $id"
|
||||||
|
if aria2c --quiet --continue --max-connection-per-server=16 --split=16 --min-split-size=16M \
|
||||||
|
--file-allocation=none --dir="$(dirname "$zip")" --out="$(basename "$zip")" "$url"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
(( attempt < 3 )) || fail "the download failed three times"
|
||||||
|
echo "nightly-fetch: attempt $attempt failed, trying again" >&2
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
[[ $(stat -c %s "$zip") == "$size" ]] || fail "downloaded $(stat -c %s "$zip") bytes, the artifact is $size"
|
||||||
|
seconds=$(( $(date +%s) - started ))
|
||||||
|
echo "nightly-fetch: downloaded in $seconds s, $(( size / 1048576 / (seconds > 0 ? seconds : 1) )) MiB/s"
|
||||||
|
|
||||||
|
unzip -q "$zip" -d "$out"
|
||||||
|
echo "nightly-fetch: $(find "$out" -type f | wc -l) files in $out"
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Lays out what one channel of a nightly run publishes and writes the
|
||||||
|
# latest.json that describes it.
|
||||||
|
#
|
||||||
|
# nightly-layout <channel> <version> <release> <iso> <sealed_dir> <vm_packages> <out_dir>
|
||||||
|
#
|
||||||
|
# release the path under the site, from nightly-plan
|
||||||
|
# iso the ISO that was installed
|
||||||
|
# sealed_dir what vm-seal made
|
||||||
|
# vm_packages what the sealed image answered to: pacman -Q of its Omarchy
|
||||||
|
# package, pacman -Qo of its kernel's pkgbase, uname -r
|
||||||
|
#
|
||||||
|
# Env: SITE (https://nightly.omarchy.org), ISO_SITE (https://iso.omarchy.org),
|
||||||
|
# ISO_COMMIT (the omarchy-iso commit, default: HEAD of this checkout).
|
||||||
|
#
|
||||||
|
# out_dir gets, under <release>/:
|
||||||
|
#
|
||||||
|
# omarchy-<...>.qcow2 and .qcow2.sha256 the VM image: one file, which boots
|
||||||
|
# by itself through UEFI
|
||||||
|
# unpacked/ its kernel, initramfs and kernel
|
||||||
|
# arguments for booting it directly,
|
||||||
|
# vm-boot, and their SHA256SUMS
|
||||||
|
# omarchy-edge-<version>.iso and .sha256 edge only
|
||||||
|
#
|
||||||
|
# and <channel>/latest.json. The released ISO already has its place:
|
||||||
|
# latest.json points there and the ISO stays where it is.
|
||||||
|
#
|
||||||
|
# The ISO and the image are moved, not copied: they are gigabytes.
|
||||||
|
set -euo pipefail
|
||||||
|
[[ $# -eq 7 ]] || { sed -n '2,12p' "$0" >&2; exit 2; }
|
||||||
|
channel=$1 version=$2 release=$3 iso_file=$4 sealed=$5 packages=$6 out=$7
|
||||||
|
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
site=${SITE:-https://nightly.omarchy.org}
|
||||||
|
iso_site=${ISO_SITE:-https://iso.omarchy.org}
|
||||||
|
iso_commit=${ISO_COMMIT:-$(git -C "$here" rev-parse HEAD)}
|
||||||
|
fail() { echo "nightly-layout: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ $channel == edge || $channel == stable ]] || fail "'$channel' is not a channel"
|
||||||
|
[[ -f $iso_file ]] || fail "no ISO at $iso_file"
|
||||||
|
unpacked=(vmlinuz initrd cmdline)
|
||||||
|
for file in base.qcow2 "${unpacked[@]}"; do
|
||||||
|
[[ -s $sealed/$file ]] || fail "the sealed image has no $file"
|
||||||
|
done
|
||||||
|
|
||||||
|
omarchy=$(awk '$1 == "omarchy" || $1 == "omarchy-dev" { print $2; exit }' "$packages")
|
||||||
|
kernel=$(tail -n 1 "$packages")
|
||||||
|
[[ -n $omarchy ]] || fail "$packages names no omarchy package"
|
||||||
|
[[ $kernel =~ ^[0-9]+\.[0-9] ]] || fail "$packages does not end with a kernel release"
|
||||||
|
# An edge package's version carries the commit it was built from.
|
||||||
|
omarchy_commit=$(sed -nE 's/.*\.g([0-9a-f]+)-.*/\1/p' <<<"$omarchy")
|
||||||
|
|
||||||
|
dir=$out/$release
|
||||||
|
mkdir -p "$dir/unpacked" "$out/$channel"
|
||||||
|
if [[ $channel == edge ]]; then
|
||||||
|
name=omarchy-$channel-$version
|
||||||
|
iso=$name.iso
|
||||||
|
iso_url=$site/$release/$iso
|
||||||
|
mv "$iso_file" "$dir/$iso"
|
||||||
|
(cd "$dir" && sha256sum "$iso" >"$iso.sha256")
|
||||||
|
iso_sha256=$(cut -d' ' -f1 "$dir/$iso.sha256") iso_size=$(stat -c %s "$dir/$iso")
|
||||||
|
else
|
||||||
|
name=omarchy-$version
|
||||||
|
iso=$name.iso
|
||||||
|
[[ ${iso_file##*/} == "$iso" ]] || fail "the released ISO is $iso, not ${iso_file##*/}"
|
||||||
|
iso_url=$iso_site/$iso
|
||||||
|
iso_sha256=$(sha256sum "$iso_file" | cut -d' ' -f1) iso_size=$(stat -c %s "$iso_file")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The image has the ISO's name, so the two are told apart by their endings.
|
||||||
|
image=$name.qcow2
|
||||||
|
mv "$sealed/base.qcow2" "$dir/$image"
|
||||||
|
(cd "$dir" && sha256sum "$image" >"$image.sha256")
|
||||||
|
|
||||||
|
for file in "${unpacked[@]}"; do mv "$sealed/$file" "$dir/unpacked/$file"; done
|
||||||
|
cp "$here/vm-boot" "$dir/unpacked/vm-boot"
|
||||||
|
unpacked+=(vm-boot)
|
||||||
|
(cd "$dir/unpacked" && sha256sum "${unpacked[@]}" >SHA256SUMS)
|
||||||
|
|
||||||
|
latest=$out/$channel/latest.json
|
||||||
|
jq -n \
|
||||||
|
--arg version "$version" --arg channel "$channel" --arg release "$site/$release" --arg image "$image" \
|
||||||
|
--arg built_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
--arg url "$iso_url" --arg sha256 "$iso_sha256" --argjson size "$iso_size" \
|
||||||
|
--arg iso_commit "$iso_commit" --arg omarchy_version "$omarchy" --arg omarchy_commit "$omarchy_commit" \
|
||||||
|
--arg kernel "$kernel" \
|
||||||
|
--arg vm_sha256 "$(cut -d' ' -f1 "$dir/$image.sha256")" --argjson vm_size "$(stat -c %s "$dir/$image")" \
|
||||||
|
--args '{
|
||||||
|
version: $version, channel: $channel, built_at: $built_at,
|
||||||
|
url: $url, sha256: $sha256, size: $size,
|
||||||
|
iso_commit: $iso_commit, omarchy_version: $omarchy_version, omarchy_commit: $omarchy_commit,
|
||||||
|
kernel: $kernel,
|
||||||
|
vm: {
|
||||||
|
url: "\($release)/\($image)", sha256: $vm_sha256, size: $vm_size,
|
||||||
|
unpacked: "\($release)/unpacked/", unpacked_files: ($ARGS.positional + ["SHA256SUMS"]),
|
||||||
|
user: "omarchy", password: "omarchy",
|
||||||
|
ssh: "keys only; vm-boot makes one for each boot"
|
||||||
|
}
|
||||||
|
}' "${unpacked[@]}" >"$latest"
|
||||||
|
|
||||||
|
# What is about to be published has to say what it should: one value in every
|
||||||
|
# field, and checksums that are checksums.
|
||||||
|
jq -e --arg channel "$channel" '
|
||||||
|
def one_line: type == "string" and test("^[^\n]+$");
|
||||||
|
def sha: type == "string" and test("^[0-9a-f]{64}$");
|
||||||
|
(.version | one_line) and .channel == $channel
|
||||||
|
and (.url | test("^https://[^\n ]+\\.iso$")) and (.sha256 | sha) and .size > 0
|
||||||
|
and (.iso_commit | test("^[0-9a-f]{40}$"))
|
||||||
|
and (.omarchy_version | one_line) and (.omarchy_commit | test("^[0-9a-f]*$")) and (.kernel | one_line)
|
||||||
|
and (.vm.url | test("^https://[^\n ]+\\.qcow2$")) and (.vm.sha256 | sha) and .vm.size > 0
|
||||||
|
and (.vm.unpacked | test("^https://[^\n ]+/unpacked/$")) and (.vm.unpacked_files | length) == 5
|
||||||
|
' "$latest" >/dev/null || { cat "$latest" >&2; fail "latest.json does not say what it should"; }
|
||||||
|
cat "$latest"
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Says which channels a nightly run makes, under which version, and where
|
||||||
|
# each one is published. It decides; the workflow fetches what it needs to
|
||||||
|
# know and passes it in, so every case can be tried without a runner.
|
||||||
|
#
|
||||||
|
# nightly-plan <date> <run_number> <run_attempt> <event> <release_tag> [published_stable]
|
||||||
|
#
|
||||||
|
# date today, as YYYY.MM.DD
|
||||||
|
# event what started the run: schedule, workflow_dispatch
|
||||||
|
# release_tag omacom/omarchy's latest release, such as v4.0.4
|
||||||
|
# published_stable the version stable/latest.json names now, if any
|
||||||
|
#
|
||||||
|
# Prints a JSON list of {channel, version, release}; release is the path
|
||||||
|
# under the site.
|
||||||
|
#
|
||||||
|
# An edge version is the date and the run number. A stable version is the
|
||||||
|
# release's own, and on a scheduled run a release already published is left
|
||||||
|
# out: it is done once. Two builds are never the same bytes and a published
|
||||||
|
# file is never replaced, so no path is used twice: a stable image has the run
|
||||||
|
# number in its path, and a run started again adds its attempt to the number.
|
||||||
|
set -euo pipefail
|
||||||
|
[[ $# -eq 5 || $# -eq 6 ]] || { sed -n '2,12p' "$0" >&2; exit 2; }
|
||||||
|
date=$1 run=$2 attempt=$3 event=$4 tag=$5 published=${6:-}
|
||||||
|
fail() { echo "nightly-plan: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ $date =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || fail "'$date' is not a date like 2026.10.09"
|
||||||
|
[[ $run =~ ^[1-9][0-9]*$ ]] || fail "'$run' is not a run number"
|
||||||
|
[[ $attempt =~ ^[1-9][0-9]*$ ]] || fail "'$attempt' is not a run attempt"
|
||||||
|
stable=${tag#v}
|
||||||
|
[[ $stable =~ ^[0-9]+(\.[0-9]+)+$ ]] || fail "the latest release is '$tag', not a version"
|
||||||
|
|
||||||
|
build=$run
|
||||||
|
[[ $attempt == 1 ]] || build+=.$attempt
|
||||||
|
|
||||||
|
channels=$(jq -cn --arg v "$date.$build" '[{channel: "edge", version: $v, release: "edge/\($v)"}]')
|
||||||
|
if [[ $event == schedule && $published == "$stable" ]]; then
|
||||||
|
echo "nightly-plan: stable $stable is already published" >&2
|
||||||
|
else
|
||||||
|
channels=$(jq -c --arg v "$stable" --arg build "$build" \
|
||||||
|
'. + [{channel: "stable", version: $v, release: "stable/\($v)/\($build)"}]' <<<"$channels")
|
||||||
|
fi
|
||||||
|
echo "$channels"
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs rclone for the publish job and says when it only got there on a retry.
|
||||||
|
#
|
||||||
|
# nightly-rclone <rclone arguments...>
|
||||||
|
#
|
||||||
|
# rclone tries a failed transfer again by itself, and a run that passes on the
|
||||||
|
# second attempt looks like any other. This passes rclone's output and exit
|
||||||
|
# status through, and adds a warning for the run's page naming the files that
|
||||||
|
# failed on an earlier attempt.
|
||||||
|
set -uo pipefail
|
||||||
|
log=$(mktemp)
|
||||||
|
trap 'rm -f "$log"' EXIT
|
||||||
|
rclone "$@" 2>&1 | tee "$log"
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
files=$(sed -nE 's/^.*ERROR : (.*): Failed to copy: .*$/\1/p' "$log" | sort -u)
|
||||||
|
if [[ $status -eq 0 && -n $files ]]; then
|
||||||
|
echo "::warning::rclone needed a retry for $(wc -l <<<"$files") file(s): $(paste -sd' ' <<<"$files")"
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Boots a sealed Omarchy image (vm-seal) as a throwaway machine.
|
||||||
|
#
|
||||||
|
# vm-boot <image> boot, print how to log in, run until Ctrl-C
|
||||||
|
# vm-boot <image> <command...> boot, run the command over SSH, power off;
|
||||||
|
# exits with the command's status
|
||||||
|
#
|
||||||
|
# <image> is a downloaded disk, omarchy-<...>.qcow2, with vmlinuz, initrd and
|
||||||
|
# cmdline in the same directory as this script, as in a published "unpacked"
|
||||||
|
# folder. Or it is a directory as vm-seal leaves it, holding base.qcow2 and
|
||||||
|
# those three files.
|
||||||
|
#
|
||||||
|
# Nothing is written to the image: the machine runs on a copy-on-write overlay
|
||||||
|
# that is deleted on exit. Each boot gets a new SSH key and a new machine ID;
|
||||||
|
# the image itself holds no key and its SSH takes no password.
|
||||||
|
#
|
||||||
|
# Needs qemu-system-x86_64, qemu-img, ssh and a usable /dev/kvm.
|
||||||
|
# Env: SMP (4), MEM (4G), SSH_PORT (random), VM_UUID (random), APPEND (more
|
||||||
|
# kernel arguments), QEMU_EXTRA (more qemu arguments, split on spaces),
|
||||||
|
# VM_STATE_DIR (/var/tmp; holds the overlay, so it needs room to grow).
|
||||||
|
set -euo pipefail
|
||||||
|
[[ $# -ge 1 ]] || { sed -n '2,21p' "$0" >&2; exit 2; }
|
||||||
|
image=$(realpath "$1"); shift
|
||||||
|
if [[ -d $image ]]; then
|
||||||
|
base=$image disk=$image/base.qcow2
|
||||||
|
else
|
||||||
|
base=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) disk=$image
|
||||||
|
fi
|
||||||
|
[[ -s $disk ]] || { echo "vm-boot: there is no disk image at $disk" >&2; exit 1; }
|
||||||
|
for f in vmlinuz initrd cmdline; do
|
||||||
|
[[ -s $base/$f ]] || { echo "vm-boot: $base/$f is missing" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo "vm-boot: /dev/kvm is not usable" >&2; exit 1; }
|
||||||
|
|
||||||
|
state=$(mktemp -d "${VM_STATE_DIR:-/var/tmp}/vm.XXXXXX")
|
||||||
|
cleanup() {
|
||||||
|
local status=$? pid
|
||||||
|
if [[ -f $state/qemu.pid ]]; then
|
||||||
|
pid=$(cat "$state/qemu.pid")
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||||
|
fi
|
||||||
|
rm -rf "$state"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT TERM
|
||||||
|
port=${SSH_PORT:-$(( 20000 + RANDOM % 10000 ))}
|
||||||
|
uuid=${VM_UUID:-$(cat /proc/sys/kernel/random/uuid)}
|
||||||
|
|
||||||
|
# A key made for this boot only, handed in through the firmware tables as a
|
||||||
|
# systemd credential that writes authorized_keys.
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -C vm-boot -f "$state/id_ed25519"
|
||||||
|
keys=$(printf 'd /home/omarchy/.ssh 0700 omarchy omarchy -\nf+ /home/omarchy/.ssh/authorized_keys 0600 omarchy omarchy - %s\n' \
|
||||||
|
"$(cat "$state/id_ed25519.pub")" | base64 -w0)
|
||||||
|
ssh_opts=(-i "$state/id_ed25519" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes
|
||||||
|
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR)
|
||||||
|
|
||||||
|
qemu-img create -qf qcow2 -b "$disk" -F qcow2 "$state/run.qcow2"
|
||||||
|
t0=$(date +%s%N)
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp "${SMP:-4}" -m "${MEM:-4G}" \
|
||||||
|
-kernel "$base/vmlinuz" -initrd "$base/initrd" -append "$(cat "$base/cmdline")${APPEND:+ $APPEND}" \
|
||||||
|
-uuid "$uuid" \
|
||||||
|
-drive "file=$state/run.qcow2,format=qcow2,if=none,id=d0" -device virtio-blk-pci,drive=d0 \
|
||||||
|
-smbios "type=11,value=io.systemd.credential.binary:tmpfiles.extra=$keys" \
|
||||||
|
-device virtio-vga -device virtio-rng-pci -display none \
|
||||||
|
-netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \
|
||||||
|
-qmp "unix:$state/qmp.sock,server,nowait" -pidfile "$state/qemu.pid" -daemonize ${QEMU_EXTRA:-}
|
||||||
|
|
||||||
|
# </dev/null: the probe must not eat a script meant for the command below.
|
||||||
|
until ssh "${ssh_opts[@]}" -o ConnectTimeout=1 omarchy@127.0.0.1 true </dev/null 2>/dev/null; do
|
||||||
|
kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null || { echo "vm-boot: qemu exited before ssh came up" >&2; exit 1; }
|
||||||
|
(( ($(date +%s%N) - t0) / 1000000 < 300000 )) || { echo "vm-boot: no ssh after 300s" >&2; exit 1; }
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
echo "vm-boot: ssh up after $(( ($(date +%s%N) - t0) / 1000000 )) ms, machine ID ${uuid//-/}" >&2
|
||||||
|
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
# Stdin passes through, so `vm-boot dir bash -s <script` works.
|
||||||
|
ssh "${ssh_opts[@]}" omarchy@127.0.0.1 "$@"
|
||||||
|
else
|
||||||
|
echo "ssh ${ssh_opts[*]} omarchy@127.0.0.1"
|
||||||
|
echo "Desktop and sudo password: omarchy. QMP socket: $state/qmp.sock. Ctrl-C powers it off."
|
||||||
|
while kill -0 "$(cat "$state/qemu.pid")" 2>/dev/null; do sleep 1; done
|
||||||
|
fi
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Turns an installed Omarchy disk (test/integration's base image) into an image anyone can
|
||||||
|
# download and boot: every boot is its own machine, nothing of the build is
|
||||||
|
# left in it, and it boots straight into its kernel, so a boot takes seconds.
|
||||||
|
#
|
||||||
|
# vm-seal <installed_dir> <sealed_dir>
|
||||||
|
#
|
||||||
|
# installed_dir holds base.qcow2, OVMF_VARS.4m.fd and id_ed25519, which is
|
||||||
|
# what test/integration leaves in test-runs/<iso>-integration once its install
|
||||||
|
# has passed. sealed_dir gets a standalone, zstd-compressed base.qcow2, which
|
||||||
|
# boots by itself through UEFI, and the UKI's vmlinuz, initrd and cmdline for
|
||||||
|
# booting it directly. It holds no key: each boot is handed one (see vm-boot).
|
||||||
|
#
|
||||||
|
# The image keeps the installer's user and password (GUEST_PASSWORD, default
|
||||||
|
# omarchy) for the desktop and sudo. SSH takes keys only.
|
||||||
|
set -euo pipefail
|
||||||
|
[[ $# -eq 2 ]] || { echo "Usage: vm-seal <installed_dir> <sealed_dir>" >&2; exit 2; }
|
||||||
|
in=$(realpath "$1") out=$(realpath -m "$2")
|
||||||
|
pw=${GUEST_PASSWORD:-omarchy}
|
||||||
|
for fw in /usr/share/edk2/x64/OVMF_CODE.4m.fd /usr/share/OVMF/OVMF_CODE_4M.fd; do
|
||||||
|
[[ -f $fw ]] && { fw_code=$fw; break; }
|
||||||
|
done
|
||||||
|
: "${fw_code:?no OVMF firmware found}"
|
||||||
|
|
||||||
|
state=$(mktemp -d /var/tmp/seal.XXXXXX)
|
||||||
|
cleanup() {
|
||||||
|
local status=$? pid
|
||||||
|
if [[ -f $state/qemu.pid ]]; then
|
||||||
|
pid=$(cat "$state/qemu.pid")
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
|
||||||
|
fi
|
||||||
|
rm -rf "$state"
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
port=$(( 30000 + RANDOM % 5000 ))
|
||||||
|
|
||||||
|
ssh_vm() {
|
||||||
|
ssh -i "$in/id_ed25519" -p "$port" -o BatchMode=yes -o IdentitiesOnly=yes \
|
||||||
|
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||||
|
-o ConnectTimeout=2 -o LogLevel=ERROR omarchy@127.0.0.1 "$@"
|
||||||
|
}
|
||||||
|
# Runs a script as root in the guest and fails if any line of it fails. The
|
||||||
|
# password goes to sudo on stdin, never into a command line.
|
||||||
|
root() {
|
||||||
|
printf '%s\n' "$pw" | ssh_vm "sudo -S -p '' bash -ec $(printf %q "trap 'echo \"vm-seal: failed in the guest at: \$BASH_COMMAND\" >&2' ERR
|
||||||
|
$1")"
|
||||||
|
}
|
||||||
|
|
||||||
|
qemu-img create -qf qcow2 -b "$in/base.qcow2" -F qcow2 "$state/edit.qcow2"
|
||||||
|
cp "$in/OVMF_VARS.4m.fd" "$state/vars.fd"
|
||||||
|
qemu-system-x86_64 -cpu host -enable-kvm -machine q35,accel=kvm -smp 4 -m 4G \
|
||||||
|
-drive if=pflash,format=raw,readonly=on,file="$fw_code" \
|
||||||
|
-drive if=pflash,format=raw,file="$state/vars.fd" \
|
||||||
|
-drive "file=$state/edit.qcow2,format=qcow2,if=virtio,discard=unmap" -display none \
|
||||||
|
-netdev "user,id=n0,hostfwd=tcp:127.0.0.1:$port-:22" -device virtio-net-pci,netdev=n0 \
|
||||||
|
-pidfile "$state/qemu.pid" -daemonize
|
||||||
|
for i in $(seq 1 150); do
|
||||||
|
ssh_vm true 2>/dev/null && break
|
||||||
|
(( i < 150 )) || { echo "vm-seal: no ssh on the installed disk" >&2; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
# Let first-boot work finish before taking it apart.
|
||||||
|
ssh_vm 'timeout 180 systemctl is-system-running --wait >/dev/null 2>&1 || true'
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
# The kernel that is running: edge installs linux-omarchy-bore where stable
|
||||||
|
# installs linux-omarchy, and the boot entry, not the UKI, may hold the
|
||||||
|
# command line.
|
||||||
|
kernel=$(ssh_vm 'cat "/usr/lib/modules/$(uname -r)/pkgbase"')
|
||||||
|
root "cat /boot/EFI/Linux/omarchy_$kernel.efi" >"$state/uki.efi"
|
||||||
|
ssh_vm 'cat /proc/cmdline' >"$state/cmdline"
|
||||||
|
|
||||||
|
root '
|
||||||
|
# The ISO writes autologin only for encrypted installs; a test has nobody at
|
||||||
|
# SDDM, and a desktop check that skips for want of a session reads as a pass.
|
||||||
|
printf "[Autologin]\nUser=omarchy\nSession=omarchy.desktop\n" >/etc/sddm.conf.d/autologin.conf
|
||||||
|
|
||||||
|
# The image is public and its password is too, so SSH takes keys only. sshd
|
||||||
|
# keeps the first value it reads, hence a name that sorts first.
|
||||||
|
printf "PasswordAuthentication no\nKbdInteractiveAuthentication no\nAuthenticationMethods publickey\n" \
|
||||||
|
>/etc/ssh/sshd_config.d/00-omarchy-vm.conf
|
||||||
|
ssh-keygen -A >/dev/null # sshd -T needs host keys to parse; they go below
|
||||||
|
sshd -T | grep -qix "passwordauthentication no"
|
||||||
|
sshd -T | grep -qix "authenticationmethods publickey"
|
||||||
|
|
||||||
|
# Empty, not "uninitialized": that string marks a first boot, and a first boot
|
||||||
|
# applies unit presets, which on Arch disable everything not listed. Empty lets
|
||||||
|
# systemd take the VM SMBIOS UUID (qemu -uuid) as this boot machine ID.
|
||||||
|
# Host keys come back from sshdgenkeys.service before sshd starts.
|
||||||
|
: >/etc/machine-id
|
||||||
|
rm -f /var/lib/dbus/machine-id /etc/ssh/ssh_host_*
|
||||||
|
rm -f /home/omarchy/.ssh/authorized_keys /home/omarchy/.bash_history /root/.bash_history
|
||||||
|
|
||||||
|
# The factory snapshot the installer took carries the same machine ID.
|
||||||
|
mkdir -p /run/seal-top
|
||||||
|
mount -o subvolid=5 "$(findmnt -no SOURCE / | sed "s/\[.*//")" /run/seal-top
|
||||||
|
if [ -f /run/seal-top/@factory/etc/machine-id ]; then
|
||||||
|
ro=$(btrfs property get -ts /run/seal-top/@factory ro)
|
||||||
|
[ "$ro" = ro=false ] || btrfs property set -ts /run/seal-top/@factory ro false
|
||||||
|
: >/run/seal-top/@factory/etc/machine-id
|
||||||
|
[ "$ro" = ro=false ] || btrfs property set -ts /run/seal-top/@factory ro true
|
||||||
|
fi
|
||||||
|
umount /run/seal-top
|
||||||
|
|
||||||
|
# Stopping the unit saves a seed; with it stopped, shutdown saves none.
|
||||||
|
systemctl stop systemd-random-seed.service
|
||||||
|
rm -f /var/lib/systemd/random-seed /var/lib/systemd/credential.secret
|
||||||
|
|
||||||
|
# Leases and the key NetworkManager derives stable addresses from.
|
||||||
|
systemctl stop NetworkManager.service
|
||||||
|
rm -f /var/lib/NetworkManager/*.lease /var/lib/NetworkManager/secret_key \
|
||||||
|
/var/lib/NetworkManager/seen-bssids /var/lib/NetworkManager/timestamps \
|
||||||
|
/var/lib/NetworkManager/NetworkManager.state
|
||||||
|
|
||||||
|
# Vacuuming leaves the active journal. Send journald to /run, then the
|
||||||
|
# build logs on disk can go, and shutdown writes nothing back.
|
||||||
|
journalctl --relinquish-var
|
||||||
|
rm -rf /var/log/journal/*
|
||||||
|
|
||||||
|
test ! -s /etc/machine-id
|
||||||
|
test -z "$(ls /etc/ssh/ssh_host_* /var/lib/NetworkManager/*.lease /var/log/journal/* 2>/dev/null)"
|
||||||
|
test ! -e /home/omarchy/.ssh/authorized_keys
|
||||||
|
test ! -e /var/lib/systemd/random-seed
|
||||||
|
|
||||||
|
sync
|
||||||
|
# Hand freed blocks back to the disk image, so the copy skips them.
|
||||||
|
fstrim -av >/dev/null
|
||||||
|
(sleep 1; systemctl poweroff) >/dev/null 2>&1 &'
|
||||||
|
|
||||||
|
# The key that let us in is gone, so the poweroff above was the last word.
|
||||||
|
pid=$(cat "$state/qemu.pid")
|
||||||
|
for _ in $(seq 1 120); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then echo "vm-seal: the VM did not power off" >&2; exit 1; fi
|
||||||
|
rm -f "$state/qemu.pid"
|
||||||
|
|
||||||
|
# Direct boot skips the firmware and Limine, which spend most of a boot reading
|
||||||
|
# and hashing the UKI. resume= points at the image's own swap, so drop it.
|
||||||
|
objcopy -O binary --only-section=.linux "$state/uki.efi" "$out/vmlinuz"
|
||||||
|
objcopy -O binary --only-section=.initrd "$state/uki.efi" "$out/initrd"
|
||||||
|
tr -d '\0' <"$state/cmdline" | tr ' ' '\n' | { grep -vE '^(resume|resume_offset)=|^$' || true; } | paste -sd' ' >"$out/cmdline"
|
||||||
|
[[ -s $out/vmlinuz && -s $out/initrd && -s $out/cmdline ]] || { echo "vm-seal: the UKI gave no kernel, initrd or cmdline" >&2; exit 1; }
|
||||||
|
|
||||||
|
# zstd clusters: QEMU reads them as they are, so the download boots as it is.
|
||||||
|
qemu-img convert -c -O qcow2 -o compression_type=zstd -m 16 -W "$state/edit.qcow2" "$out/base.qcow2"
|
||||||
|
( cd "$out" && sha256sum base.qcow2 vmlinuz initrd cmdline >SHA256SUMS )
|
||||||
|
echo "sealed: $out ($(du -sh "$out/base.qcow2" | cut -f1)); cmdline: $(cat "$out/cmdline")"
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Boots a sealed image once, as a throwaway machine, and checks that it is
|
||||||
|
# what vm-seal promises: a desktop that comes up, nothing of the build left
|
||||||
|
# in it, and SSH that takes keys only. Exits non-zero on any failed check.
|
||||||
|
#
|
||||||
|
# vm-verify <sealed_dir>
|
||||||
|
set -euo pipefail
|
||||||
|
[[ $# -eq 1 ]] || { echo "Usage: vm-verify <sealed_dir>" >&2; exit 2; }
|
||||||
|
here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
base=$(realpath "$1")
|
||||||
|
( cd "$base" && sha256sum --quiet -c SHA256SUMS )
|
||||||
|
uuid=$(cat /proc/sys/kernel/random/uuid)
|
||||||
|
export VM_UUID=$uuid SSH_PORT=$(( 20000 + RANDOM % 10000 ))
|
||||||
|
|
||||||
|
VM_PW=${GUEST_PASSWORD:-omarchy} "$here/vm-boot" "$base" "WANT_ID=${uuid//-/} PW=${GUEST_PASSWORD:-omarchy} bash -s" <<'GUEST'
|
||||||
|
fail=0
|
||||||
|
check() { # description, then a command that must succeed
|
||||||
|
local what=$1; shift
|
||||||
|
if "$@" >/dev/null 2>&1; then echo "ok $what"; else echo "FAIL $what"; fail=1; fi
|
||||||
|
}
|
||||||
|
root() { printf '%s\n' "$PW" | sudo -S -p '' "$@"; }
|
||||||
|
booted=$(( $(date +%s) - $(cut -d. -f1 /proc/uptime) - 5 ))
|
||||||
|
newer() { [ "$(root stat -c %Y "$1")" -ge "$booted" ]; } # written by this boot
|
||||||
|
|
||||||
|
check "the system reaches running or degraded" \
|
||||||
|
sh -c 's=$(timeout 120 systemctl is-system-running --wait); [ "$s" = running ] || [ "$s" = degraded ]'
|
||||||
|
check "no unit failed" sh -c '[ -z "$(systemctl --failed --no-legend)" ]'
|
||||||
|
check "a desktop session is logged in" sh -c 'for i in $(seq 1 60); do pgrep -x Hyprland && exit 0; sleep 0.5; done; exit 1'
|
||||||
|
check "the machine ID is the one this boot was given" [ "$(cat /etc/machine-id)" = "$WANT_ID" ]
|
||||||
|
check "the journal holds this machine only" [ "$(root ls /var/log/journal | tr '\n' ' ')" = "$WANT_ID " ]
|
||||||
|
check "SSH refuses passwords" sh -c "printf '%s\n' '$PW' | sudo -S -p '' sshd -T | grep -qix 'passwordauthentication no'"
|
||||||
|
check "SSH requires a key" sh -c "printf '%s\n' '$PW' | sudo -S -p '' sshd -T | grep -qix 'authenticationmethods publickey'"
|
||||||
|
# Asking with no method makes sshd list the ones it would take.
|
||||||
|
check "sshd offers a key as the only way in" \
|
||||||
|
sh -c 'ssh -o PreferredAuthentications=none -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null omarchy@127.0.0.1 true 2>&1 | grep -qF "Permission denied (publickey)."'
|
||||||
|
check "authorized_keys holds one key, this boot's" [ "$(grep -c . ~/.ssh/authorized_keys)" = 1 ]
|
||||||
|
check "SSH host keys were made by this boot" newer /etc/ssh/ssh_host_ed25519_key
|
||||||
|
check "NetworkManager's key was made by this boot" newer /var/lib/NetworkManager/secret_key
|
||||||
|
check "one DHCP lease, this boot's" [ "$(root sh -c 'ls /var/lib/NetworkManager/*.lease | wc -l')" = 1 ]
|
||||||
|
check "no shell history" sh -c '! [ -e ~/.bash_history ]'
|
||||||
|
check "the package cache is empty" [ -z "$(ls /var/cache/pacman/pkg)" ]
|
||||||
|
root mkdir -p /run/top && root mount -o subvolid=5,ro "$(findmnt -no SOURCE / | sed 's/\[.*//')" /run/top
|
||||||
|
check "the factory snapshot has no machine ID" sh -c "! printf '%s\n' '$PW' | sudo -S -p '' test -s /run/top/@factory/etc/machine-id"
|
||||||
|
check "the factory snapshot holds no SSH login key" sh -c "! printf '%s\n' '$PW' | sudo -S -p '' sh -c 'ls /run/top/@factory/root/.ssh/authorized_keys /run/top/@factory/home/*/.ssh/authorized_keys' 2>/dev/null | grep -q ."
|
||||||
|
check "the factory snapshot has no SSH host keys" sh -c "[ -z \"\$(printf '%s\n' '$PW' | sudo -S -p '' sh -c 'ls /run/top/@factory/etc/ssh/ssh_host_* 2>/dev/null')\" ]"
|
||||||
|
check "no snapshots besides the current system" [ -z "$(root ls /.snapshots)" ]
|
||||||
|
echo "omarchy $(pacman -Q omarchy 2>/dev/null | cut -d' ' -f2), kernel $(uname -r), $(systemd-analyze | head -1)"
|
||||||
|
exit $fail
|
||||||
|
GUEST
|
||||||
Reference in new issue
Block a user