Skip to main content

max / alloy

Escalate through run0 where it exists, sudo where it does not The build scripts were the one place that never followed the run0-first stance (wiki alloy-privilege), because they run on the dev host and the x86_64 build host is fw13: Pop!_OS, systemd 255, no run0. A straight s/sudo/run0/ breaks the build there, so the mechanism resolves once in build/privilege.sh instead. Two functions, because run0 puts its child on a pty and sudo does not. priv keeps the pty where a person is watching podman or dd; privc passes --pipe wherever output is captured, without which ROOTFS from `podman image mount` arrives with a carriage return on the end and gets bind mounted as a path that does not exist. Working directory is the other thing run0 does not inherit: every privileged call site passes absolute paths built from $REPO_ROOT, so it does not apply. Checked rather than assumed. write-device.sh comes along, having the same two capture sites, and the USB-write hint now names whichever command the script itself used. Also fixes --help in both scripts, which printed seven lines of script body: the header range was a hardcoded line number and the header had grown past it. It stops at the end of the comment block now.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 23:59 UTC
Signed with PGP, not checked
Commit: a2c08f008854a18c141ef162ee18935b6cb64f86
Parent: dc132fa
5 files changed, +103 insertions, -37 deletions
@@ -34,6 +34,12 @@
34 34 set -euo pipefail
35 35
36 36 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
37 +
38 + # priv / privc. run0 where it exists, sudo where it does not; see the header
39 + # of build/privilege.sh for which of the two a call site wants.
40 + # shellcheck source=build/privilege.sh
41 + . "$REPO_ROOT/build/privilege.sh"
42 +
37 43 IMAGE="localhost/alloy:local"
38 44 BIB_IMAGE="quay.io/centos-bootc/bootc-image-builder:latest"
39 45 DEF="$REPO_ROOT/build/alloy-0.0.yaml"
@@ -48,8 +54,12 @@
48 54
49 55 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
50 56
57 + # The header block is the help text, so it stops where the comments stop.
58 + # A hardcoded last line was wrong by seven lines and printed `set -euo
59 + # pipefail` and REPO_ROOT= at people; any edit to the header would have
60 + # rotted it again.
51 61 usage() {
52 - sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
62 + awk 'NR==1 {next} !/^#/ {exit} {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}"
53 63 exit "${1:-0}"
54 64 }
55 65
@@ -98,17 +108,17 @@
98 108 :
99 109 elif [ "$SKIP_BUILD" -eq 0 ]; then
100 110 echo "==> Building $IMAGE (rootful)"
101 - sudo podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
111 + priv podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
102 112 else
103 113 echo "==> Skipping image build; reusing $IMAGE"
104 - sudo podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
114 + privc podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
105 115 fi
106 116
107 117 if [ "$SKIP_BIB" -eq 0 ]; then
108 118 # 2. Make sure the image builder is present.
109 - sudo podman image exists "$BIB_IMAGE" || {
119 + privc podman image exists "$BIB_IMAGE" || {
110 120 echo "==> Pulling $BIB_IMAGE"
111 - sudo podman pull "$BIB_IMAGE"
121 + priv podman pull "$BIB_IMAGE"
112 122 }
113 123
114 124 # 3. Build the artifact. The alloy-0.0 def is mounted read-only into bib's
@@ -120,14 +130,14 @@
120 130 # working. That happened on 2026-07-19: the previous ISO was gone before
121 131 # anyone thought to keep it. Rotate one generation aside instead of
122 132 # deleting, so a failed build leaves something to fall back to.
123 - if [ -d "$OUTPUT" ] && [ -n "$(sudo ls -A "$OUTPUT" 2>/dev/null)" ]; then
133 + if [ -d "$OUTPUT" ] && [ -n "$(privc ls -A "$OUTPUT" 2>/dev/null)" ]; then
124 134 echo "==> Rotating previous output to ${OUTPUT}.prev"
125 - sudo rm -rf "${OUTPUT:?}.prev"
126 - sudo mv "$OUTPUT" "${OUTPUT}.prev"
135 + privc rm -rf "${OUTPUT:?}.prev"
136 + privc mv "$OUTPUT" "${OUTPUT}.prev"
127 137 fi
128 138 mkdir -p "$OUTPUT"
129 139
130 - sudo podman run --rm --privileged \
140 + priv podman run --rm --privileged \
131 141 --security-opt label=type:unconfined_t \
132 142 -v /var/lib/containers/storage:/var/lib/containers/storage \
133 143 -v "$OUTPUT":/output \
@@ -142,10 +152,10 @@
142 152 case "$TYPE" in
143 153 raw) ARTIFACT="$OUTPUT/image/disk.raw" ;;
144 154 qcow2) ARTIFACT="$OUTPUT/qcow2/disk.qcow2" ;;
145 - *) ARTIFACT="$(sudo find "$OUTPUT" -type f ! -name '*.json' | head -1)" ;;
155 + *) ARTIFACT="$(privc find "$OUTPUT" -type f ! -name '*.json' | head -1)" ;;
146 156 esac
147 - [ -n "$ARTIFACT" ] && sudo test -f "$ARTIFACT" || die "expected artifact not found for type $TYPE"
148 - echo "==> Built: $ARTIFACT ($(sudo du -h "$ARTIFACT" | cut -f1))"
157 + [ -n "$ARTIFACT" ] && privc test -f "$ARTIFACT" || die "expected artifact not found for type $TYPE"
158 + echo "==> Built: $ARTIFACT ($(privc du -h "$ARTIFACT" | cut -f1))"
149 159
150 160 # 5. Optionally write to a device. The guards, the confirmation and the
151 161 # verify live in build/write-device.sh, which build/build-iso.sh calls
@@ -154,6 +164,6 @@
154 164 "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "$TYPE"
155 165 else
156 166 echo "==> To write it to a USB stick:"
157 - echo " sudo dd if=$ARTIFACT of=/dev/sdX bs=4M oflag=direct conv=fsync status=progress"
167 + echo " $PRIV_NAME dd if=$ARTIFACT of=/dev/sdX bs=4M oflag=direct conv=fsync status=progress"
158 168 echo " (or re-run with --write /dev/sdX to rebuild and write in one step)"
159 169 fi
M build/build-iso.sh +23 -15
@@ -36,6 +36,12 @@
36 36 set -euo pipefail
37 37
38 38 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
39 +
40 + # priv / privc. run0 where it exists, sudo where it does not; see the header
41 + # of build/privilege.sh for which of the two a call site wants.
42 + # shellcheck source=build/privilege.sh
43 + . "$REPO_ROOT/build/privilege.sh"
44 +
39 45 IMAGE="localhost/alloy:local"
40 46 BUILDER="localhost/alloy-iso-builder:local"
41 47 OUTPUT="$REPO_ROOT/output"
@@ -96,7 +102,9 @@
96 102 # console emits one spelling for both artifacts.
97 103 --write) WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;;
98 104 --write-only) WRITE_ONLY=1; shift ;;
99 - -h|--help) sed -n '2,34p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
105 + # The header block is the help text, so it stops where the comments stop
106 + # rather than at a line number that rots the next time the header grows.
107 + -h|--help) awk 'NR==1 {next} !/^#/ {exit} {sub(/^# ?/, ""); print}' "${BASH_SOURCE[0]}"; exit 0 ;;
100 108 *) die "unknown argument: $1 (see --help)" ;;
101 109 esac
102 110 done
@@ -108,7 +116,7 @@
108 116 # before that check: writing a stick should not depend on being able to build.
109 117 if [ "$WRITE_ONLY" -eq 1 ]; then
110 118 [ -n "$WRITE_DEV" ] || die "--write-only only makes sense with --write"
111 - sudo test -f "$ARTIFACT" || die "no ISO at $ARTIFACT; build one first"
119 + privc test -f "$ARTIFACT" || die "no ISO at $ARTIFACT; build one first"
112 120 say "writing the ISO already in $OUTPUT"
113 121 exec "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO"
114 122 fi
@@ -118,27 +126,27 @@
118 126 # 1. The Alloy image.
119 127 if [ "$SKIP_BUILD" -eq 0 ]; then
120 128 say "building $IMAGE"
121 - sudo podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
129 + priv podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
122 130 else
123 - sudo podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
131 + privc podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
124 132 say "reusing $IMAGE"
125 133 fi
126 134
127 135 # 2. The builder, derived from it so the initramfs matches the shipped kernel.
128 136 say "building $BUILDER"
129 - sudo podman build -t "$BUILDER" \
137 + priv podman build -t "$BUILDER" \
130 138 --build-arg "BASE=$IMAGE" \
131 139 -f "$REPO_ROOT/build/Containerfile.iso" \
132 140 "$REPO_ROOT"
133 141
134 142 # 3. Somewhere to work. Rotated rather than deleted, same as build-image.sh:
135 143 # a failed run should not take the last good ISO with it.
136 - if [ -d "$OUTPUT" ] && [ -n "$(sudo ls -A "$OUTPUT" 2>/dev/null)" ]; then
144 + if [ -d "$OUTPUT" ] && [ -n "$(privc ls -A "$OUTPUT" 2>/dev/null)" ]; then
137 145 say "rotating previous output to ${OUTPUT}.prev"
138 - sudo rm -rf "${OUTPUT:?}.prev"
139 - sudo mv "$OUTPUT" "${OUTPUT}.prev"
146 + privc rm -rf "${OUTPUT:?}.prev"
147 + privc mv "$OUTPUT" "${OUTPUT}.prev"
140 148 fi
141 - sudo mkdir -p "$OUTPUT" "$WORKDIR/source"
149 + privc mkdir -p "$OUTPUT" "$WORKDIR/source"
142 150
143 151 # 4. The image to install, as an OCI layout. skopeo rather than `podman
144 152 # save` because bootc reads skopeo transports, and this is the exact
@@ -159,7 +167,7 @@
159 167 # has no skopeo, and requiring one would make the build depend on which
160 168 # distro happens to be running it. The host's container store is bind
161 169 # mounted in so skopeo can read the image out of it.
162 - sudo podman run --rm --privileged \
170 + priv podman run --rm --privileged \
163 171 --security-opt label=type:unconfined_t \
164 172 -v /var/lib/containers/storage:/var/lib/containers/storage \
165 173 -v "$WORKDIR/source":/source \
@@ -176,13 +184,13 @@
176 184 # extracting it, which saves writing several GB to disk only to read it
177 185 # straight back. Rootful, so it lands in the same store the image is in.
178 186 say "mounting $IMAGE"
179 - ROOTFS="$(sudo podman image mount "$IMAGE")"
187 + ROOTFS="$(privc podman image mount "$IMAGE")"
180 188 [ -n "$ROOTFS" ] || die "could not mount $IMAGE"
181 - trap 'sudo podman image umount "$IMAGE" >/dev/null 2>&1 || true' EXIT
189 + trap 'privc podman image umount "$IMAGE" >/dev/null 2>&1 || true' EXIT
182 190 say "rootfs at $ROOTFS"
183 191
184 192 say "assembling the ISO"
185 - sudo podman run --rm --privileged \
193 + priv podman run --rm --privileged \
186 194 -e "ALLOY_ISO_FAST=$FAST" \
187 195 -e "ALLOY_UPDATE_TARGET=$UPDATE_TARGET" \
188 196 -e "ALLOY_ISO_ARCH=$ARCH" \
@@ -192,8 +200,8 @@
192 200 -v "$WORKDIR/source":/source:ro \
193 201 "$BUILDER"
194 202
195 - sudo test -f "$ARTIFACT" || die "no ISO produced"
196 - say "built: $ARTIFACT ($(sudo du -h "$ARTIFACT" | cut -f1))"
203 + privc test -f "$ARTIFACT" || die "no ISO produced"
204 + say "built: $ARTIFACT ($(privc du -h "$ARTIFACT" | cut -f1))"
197 205 echo
198 206
199 207 if [ -n "$WRITE_DEV" ]; then
@@ -42,9 +42,10 @@
42 42 # registry or a git remote nobody could reach. Prints `error:`, which the
43 43 # sweep turns into an error cell rather than a finding.
44 44 #
45 - # Rootless on purpose, unlike build-image.sh, which needs `sudo podman` for the
46 - # bootc conversion. Nothing here leaves the builder stage, so the root store is
47 - # not involved and a check should not ask for privilege it does not need. The
45 + # Rootless on purpose, unlike build-image.sh, which needs rootful podman for
46 + # the bootc conversion and escalates through build/privilege.sh to get it.
47 + # Nothing here leaves the builder stage, so the root store is not involved and
48 + # a check should not ask for privilege it does not need. The
48 49 # consequence worth knowing: this populates the *rootless* layer cache, so it
49 50 # does not warm a subsequent `build/build-image.sh`.
50 51
@@ -22,11 +22,16 @@
22 22
23 23 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
24 24
25 + # priv / privc. run0 where it exists, sudo where it does not; see the header
26 + # of build/privilege.sh for which of the two a call site wants.
27 + # shellcheck source=build/privilege.sh
28 + . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/privilege.sh"
29 +
25 30 ARTIFACT="${1:?usage: write-device.sh <artifact> <device> [label]}"
26 31 DEVICE="${2:?usage: write-device.sh <artifact> <device> [label]}"
27 32 LABEL="${3:-image}"
28 33
29 - sudo test -f "$ARTIFACT" || die "$ARTIFACT is not a file"
34 + privc test -f "$ARTIFACT" || die "$ARTIFACT is not a file"
30 35 [ -b "$DEVICE" ] || die "$DEVICE is not a block device"
31 36
32 37 # A whole disk, not a partition. An ISO written to /dev/sda1 produces
@@ -74,9 +79,9 @@
74 79 # still prints the transfer summary once, at the end, which is the whole of what
75 80 # a log wants from it. Checked on stderr because that is where dd writes both.
76 81 if [ -t 2 ]; then
77 - sudo dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync status=progress
82 + priv dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync status=progress
78 83 else
79 - sudo dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync
84 + privc dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync
80 85 fi
81 86 sync
82 87
@@ -85,13 +90,13 @@
85 90 # `dd | head -c N | sha256sum` pipeline reported phantom corruption on a
86 91 # write that cmp proved perfect (wiki alloy-build-notes).
87 92 echo "==> Verifying the write"
88 - size="$(sudo stat -c %s "$ARTIFACT")"
93 + size="$(privc stat -c %s "$ARTIFACT")"
89 94 # A check that cannot fail is not a check: prove cmp can still disagree
90 95 # before trusting it to agree.
91 - if sudo cmp -s -n 4096 "$DEVICE" /dev/zero; then
96 + if privc cmp -s -n 4096 "$DEVICE" /dev/zero; then
92 97 die "negative control passed, which means cmp is not comparing anything"
93 98 fi
94 - sudo cmp -n "$size" "$DEVICE" "$ARTIFACT" \
99 + privc cmp -n "$size" "$DEVICE" "$ARTIFACT" \
95 100 || die "$DEVICE does not match $ARTIFACT; the write is bad"
96 101 echo "==> Verified $size bytes."
97 102 echo "==> Done. $DEVICE is now a bootable Alloy $LABEL."
@@ -1,0 +1,42 @@
1 + # privilege.sh — how the build scripts become root. Sourced, never executed.
2 + #
3 + # Alloy names run0 as the way a person becomes root (wiki alloy-privilege,
4 + # "run0-first, not sudo"). These scripts are the one place that could not
5 + # follow, because they run on the *dev host* rather than on the shipped OS,
6 + # and the x86_64 build host is fw13: Pop!_OS, systemd 255, no run0 at all.
7 + # So the mechanism resolves here, once, instead of being hardcoded to either
8 + # one. When every build host is on systemd 256 or newer this file loses its
9 + # else-branch and becomes two lines.
10 + #
11 + # Two functions, because run0 runs its child on a pty by default and sudo
12 + # does not:
13 + #
14 + # priv output is for the person watching. The pty is right: podman keeps
15 + # its progress rendering and dd redraws its status line in place.
16 + # privc output is captured, piped, or consumed as an exit status. --pipe
17 + # suppresses the pty, without which every captured line arrives
18 + # carrying a carriage return and `$(privc du -h X | cut -f1)`
19 + # returns "1.2G" with a \r stuck to the end of it.
20 + #
21 + # The rule for a new call site: if a human reads it as it scrolls past, priv.
22 + # Anything else, privc.
23 + #
24 + # Working directory is the other thing run0 does not inherit the way sudo
25 + # does. Every privileged call site under build/ passes absolute paths, built
26 + # from $REPO_ROOT, so it does not matter — verified 2026-08-10 across
27 + # build-iso.sh, build-image.sh and write-device.sh. A new call site taking a
28 + # relative path is the thing that would break it, so check rather than assume.
29 + #
30 + # PRIV_NAME is the resolved command, for scripts that print a root command
31 + # for the operator to type. A message naming sudo on a machine where the
32 + # script itself used run0 teaches the wrong one.
33 +
34 + if command -v run0 >/dev/null 2>&1; then
35 + PRIV_NAME="run0"
36 + priv() { run0 "$@"; }
37 + privc() { run0 --pipe "$@"; }
38 + else
39 + PRIV_NAME="sudo"
40 + priv() { sudo "$@"; }
41 + privc() { sudo "$@"; }
42 + fi