Skip to main content

max / mountaineer

17.4 KB · 513 lines History Blame Raw
1 #!/bin/sh
2 # Mountaineer installer — MVP (shell + dialog).
3 #
4 # Runs in the live ISO environment. Implements the 17-step flow from
5 # INSTALL.md. ratatui-shaped UX is a v0.2 goal; for MVP every interactive
6 # prompt uses `dialog`. Non-interactive installs are supported via
7 # `--config <file>` (TODO — config schema not yet defined).
8 #
9 # Status: not yet tested end-to-end. Each step is documented with what it
10 # expects to find and how it fails. Run with `--dry-run` to walk every
11 # prompt without touching the disk.
12
13 set -eu
14
15 # --- globals -------------------------------------------------------------
16 DRY_RUN=0
17 APKOVL_PATH="${APKOVL_PATH:-/media/cdrom/mountaineer.apkovl.tar.gz}"
18 CONFIG=""
19 TARGET=/mnt
20 ROOT_DATASET=rpool/ROOT/mountaineer
21
22 while [ $# -gt 0 ]; do
23 case "$1" in
24 --dry-run) DRY_RUN=1 ;;
25 --apkovl) shift; APKOVL_PATH="$1" ;;
26 --config) shift; CONFIG="$1" ;;
27 *) echo "unknown flag: $1" >&2; exit 2 ;;
28 esac
29 shift
30 done
31
32 # Auto-detect a config on standard paths if none was passed.
33 if [ -z "$CONFIG" ]; then
34 for candidate in /media/cdrom/install.yaml /mnt/config/install.yaml /media/MTN-CFG/install.yaml; do
35 if [ -f "$candidate" ]; then
36 CONFIG="$candidate"
37 break
38 fi
39 done
40 fi
41
42 # --- config reader (minimal YAML — flat key: value pairs only) ----------
43 # Mountaineer's install config is intentionally flat. If it grows into
44 # something a real YAML parser is required for, switch to yq.
45 cfg_get() {
46 # cfg_get <key> [<default>]
47 local key="$1"
48 local default="${2:-}"
49 if [ -z "$CONFIG" ] || [ ! -f "$CONFIG" ]; then
50 echo "$default"
51 return
52 fi
53 local val
54 val=$(awk -F': *' -v k="$key" '
55 $0 ~ "^"k": *" {
56 sub(/^[^:]+: */, "")
57 sub(/ *#.*/, "")
58 gsub(/^"|"$/, "")
59 print
60 exit
61 }
62 ' "$CONFIG")
63 if [ -z "$val" ]; then
64 echo "$default"
65 else
66 echo "$val"
67 fi
68 }
69
70 INTERACTIVE=1
71 if [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then
72 echo "==> using install config: $CONFIG"
73 INTERACTIVE=0
74 fi
75
76 run() {
77 if [ "$DRY_RUN" = 1 ]; then
78 echo "[dry-run] $*"
79 else
80 # shellcheck disable=SC2068
81 $@
82 fi
83 }
84
85 die() { echo "error: $*" >&2; exit 1; }
86
87 # --- step 1: refuse legacy BIOS ------------------------------------------
88 require_uefi() {
89 if [ ! -d /sys/firmware/efi ]; then
90 die "this system booted in legacy BIOS mode; Mountaineer is UEFI-only (see INSTALL.md). install aborted."
91 fi
92 }
93
94 # --- step 2: pick a disk -------------------------------------------------
95 pick_disk() {
96 if [ "$INTERACTIVE" = 0 ]; then
97 DISK=$(cfg_get disk)
98 [ -n "$DISK" ] || die "config has no 'disk' key"
99 [ -b "$DISK" ] || die "config disk '$DISK' is not a block device"
100 echo "==> using disk $DISK from config"
101 return
102 fi
103
104 # Build a dialog menu of candidate disks. Exclude readonly, removable,
105 # and the boot medium itself.
106 candidates=$(lsblk -dn -o NAME,TYPE,SIZE,MODEL | awk '$2 == "disk" {print $1, $3, substr($0, index($0, $4))}')
107 if [ -z "$candidates" ]; then
108 die "no candidate disks found via lsblk"
109 fi
110
111 menu_args=""
112 while IFS= read -r line; do
113 name=$(echo "$line" | awk '{print $1}')
114 size=$(echo "$line" | awk '{print $2}')
115 model=$(echo "$line" | cut -d' ' -f3-)
116 menu_args="$menu_args /dev/$name ${size}_${model}"
117 done <<EOF
118 $candidates
119 EOF
120
121 # shellcheck disable=SC2086
122 dialog --no-tags --menu "Select install disk (WILL BE WIPED):" 20 70 10 $menu_args 2>/tmp/disk
123 DISK=$(cat /tmp/disk)
124 rm -f /tmp/disk
125 [ -n "$DISK" ] || die "no disk selected"
126
127 dialog --yesno "WIPE $DISK and install Mountaineer? This destroys all data on the disk." 8 60 || \
128 die "wipe declined"
129 }
130
131 # --- step 3: optional encryption -----------------------------------------
132 ask_encryption() {
133 if [ "$INTERACTIVE" = 0 ]; then
134 case "$(cfg_get encrypt false)" in
135 true|yes|1) ENCRYPT=1; PASSPHRASE=$(cfg_get passphrase)
136 [ -n "$PASSPHRASE" ] || die "encrypt=true requires a passphrase in config" ;;
137 *) ENCRYPT=0; PASSPHRASE="" ;;
138 esac
139 echo "==> encryption: $([ $ENCRYPT = 1 ] && echo on || echo off)"
140 return
141 fi
142
143 if dialog --yesno "Encrypt the root pool with a passphrase? (Recommended for laptops; often inappropriate for racked servers.)" 9 60; then
144 ENCRYPT=1
145 # Ask twice and confirm match.
146 while :; do
147 dialog --insecure --passwordbox "Root pool passphrase:" 8 60 2>/tmp/p1
148 dialog --insecure --passwordbox "Confirm passphrase:" 8 60 2>/tmp/p2
149 if [ "$(cat /tmp/p1)" = "$(cat /tmp/p2)" ] && [ -s /tmp/p1 ]; then
150 PASSPHRASE=$(cat /tmp/p1)
151 break
152 fi
153 dialog --msgbox "Passphrases did not match or were empty. Try again." 7 50
154 done
155 rm -f /tmp/p1 /tmp/p2
156 else
157 ENCRYPT=0
158 PASSPHRASE=""
159 fi
160 }
161
162 # --- step 4: partition ---------------------------------------------------
163 partition() {
164 run sgdisk --zap-all "$DISK"
165 run sgdisk -n1:1M:+512M -t1:EF00 -c1:MTN-ESP "$DISK"
166 run sgdisk -n2:0:0 -t2:BF00 -c2:MTN-ROOT "$DISK"
167 run partprobe "$DISK" || true
168 sleep 1
169 # NVMe disks expose partitions as ${DISK}p1, ${DISK}p2;
170 # SATA/SCSI as ${DISK}1, ${DISK}2.
171 case "$DISK" in
172 *nvme*|*mmcblk*) ESP="${DISK}p1"; ZPART="${DISK}p2" ;;
173 *) ESP="${DISK}1"; ZPART="${DISK}2" ;;
174 esac
175 }
176
177 # --- step 5: create the pool ---------------------------------------------
178 create_pool() {
179 pool_opts="-f \
180 -o ashift=12 \
181 -o autotrim=on \
182 -O compression=lz4 \
183 -O atime=off \
184 -O xattr=sa \
185 -O acltype=posixacl \
186 -O dnodesize=auto \
187 -O normalization=formD \
188 -O mountpoint=none \
189 -O canmount=off"
190
191 if [ "$ENCRYPT" = 1 ]; then
192 # Pipe passphrase to zpool create via /dev/stdin.
193 if [ "$DRY_RUN" = 1 ]; then
194 echo "[dry-run] zpool create $pool_opts -O encryption=on -O keyformat=passphrase -O keylocation=prompt rpool $ZPART"
195 else
196 # shellcheck disable=SC2086
197 printf '%s' "$PASSPHRASE" | zpool create $pool_opts \
198 -O encryption=on -O keyformat=passphrase -O keylocation=prompt \
199 rpool "$ZPART"
200 fi
201 else
202 # shellcheck disable=SC2086
203 run zpool create $pool_opts rpool "$ZPART"
204 fi
205 }
206
207 # --- step 6: dataset hierarchy -------------------------------------------
208 create_datasets() {
209 run zfs create -o canmount=off -o mountpoint=none rpool/ROOT
210 run zfs create -o mountpoint=/ -o canmount=noauto "$ROOT_DATASET"
211 run zfs mount "$ROOT_DATASET"
212 run zfs set reservation=1G "$ROOT_DATASET"
213
214 run zfs create -o mountpoint=/home rpool/home
215 run zfs create -o canmount=off -o mountpoint=/var rpool/var
216 run zfs create rpool/var/log
217 run zfs create rpool/var/lib
218 run zfs create -o recordsize=128k rpool/var/lib/containers
219 run zfs create -o sync=disabled rpool/var/cache
220
221 # Mount the rest under $TARGET via altroot semantics.
222 run zpool export rpool
223 if [ "$ENCRYPT" = 1 ]; then
224 if [ "$DRY_RUN" = 0 ]; then
225 printf '%s' "$PASSPHRASE" | zpool import -N -R "$TARGET" rpool
226 zfs load-key rpool
227 fi
228 else
229 run zpool import -N -R "$TARGET" rpool
230 fi
231 run zfs mount -a
232 }
233
234 # --- step 7: ESP ---------------------------------------------------------
235 format_esp() {
236 run mkfs.vfat -F32 -n MTN-ESP "$ESP"
237 run mkdir -p "$TARGET/boot/efi"
238 # busybox mount can't auto-detect FAT32 on a freshly formatted partition
239 # — needs explicit fstype.
240 run mount -t vfat "$ESP" "$TARGET/boot/efi"
241 }
242
243 # --- step 8: ARC sizing --------------------------------------------------
244 compute_arc_max() {
245 # MemTotal is in kilobytes.
246 total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
247 total_gib=$(( total_kb / 1024 / 1024 ))
248
249 if [ "$total_gib" -le 2 ]; then arc=$((256 * 1024 * 1024))
250 elif [ "$total_gib" -le 4 ]; then arc=$((512 * 1024 * 1024))
251 elif [ "$total_gib" -le 8 ]; then arc=$((1 * 1024 * 1024 * 1024))
252 elif [ "$total_gib" -le 16 ]; then arc=$((2 * 1024 * 1024 * 1024))
253 elif [ "$total_gib" -le 32 ]; then arc=$((4 * 1024 * 1024 * 1024))
254 else
255 # 12.5% of RAM, capped at 16 GiB.
256 arc=$(( total_kb * 1024 / 8 ))
257 cap=$(( 16 * 1024 * 1024 * 1024 ))
258 [ "$arc" -gt "$cap" ] && arc="$cap"
259 fi
260
261 ARC_MAX="$arc"
262 echo "==> RAM: ${total_gib} GiB; zfs_arc_max: ${ARC_MAX} bytes"
263 if [ "$INTERACTIVE" = 1 ]; then
264 dialog --msgbox "Detected ${total_gib} GiB RAM. Setting zfs_arc_max=${ARC_MAX} bytes.\n\nOverride post-install by editing /etc/modprobe.d/zfs.conf." 10 60
265 fi
266 }
267
268 # --- step 9: install the base --------------------------------------------
269 install_base() {
270 # Read the world file out of the apkovl directly so the installed
271 # package set matches the apkovl-supplied declaration.
272 tmpovl=$(mktemp -d)
273 if [ "$DRY_RUN" = 0 ]; then
274 tar -xzf "$APKOVL_PATH" -C "$tmpovl"
275 fi
276 WORLD="$tmpovl/etc/apk/world"
277 REPOS="$tmpovl/etc/apk/repositories"
278
279 run mkdir -p "$TARGET/etc/apk"
280 if [ "$DRY_RUN" = 0 ]; then
281 cp "$REPOS" "$TARGET/etc/apk/repositories"
282 cp "$WORLD" "$TARGET/etc/apk/world"
283 # Seed Alpine signing keys so apk trusts the index. Without these
284 # apk reports "UNTRUSTED signature" and treats every package as
285 # missing.
286 mkdir -p "$TARGET/etc/apk/keys"
287 cp /etc/apk/keys/*.pub "$TARGET/etc/apk/keys/" 2>/dev/null || true
288 fi
289
290 # Install every package listed in the world file. Strip inline `#`
291 # comments and blank lines, then collapse to a single space-separated
292 # list. The world file allows trailing comments on package lines
293 # ("ifupdown-ng-wifi # harmless on Server"), which apk does not.
294 pkgs=$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' "$WORLD" | xargs)
295 # shellcheck disable=SC2086
296 run apk --root "$TARGET" --initdb \
297 --repositories-file "$TARGET/etc/apk/repositories" \
298 add $pkgs
299
300 rm -rf "$tmpovl"
301 }
302
303 # --- step 10: unpack apkovl over the target ------------------------------
304 unpack_apkovl() {
305 run tar -xzf "$APKOVL_PATH" -C "$TARGET"
306 }
307
308 # --- step 11: write the ARC config ---------------------------------------
309 write_arc_conf() {
310 if [ "$DRY_RUN" = 1 ]; then
311 echo "[dry-run] write zfs_arc_max=$ARC_MAX to $TARGET/etc/modprobe.d/zfs.conf"
312 else
313 cat > "$TARGET/etc/modprobe.d/zfs.conf" <<EOF
314 # Mountaineer: zfs_arc_max set at install time from RAM size.
315 options zfs zfs_arc_max=$ARC_MAX
316 EOF
317 fi
318 }
319
320 # --- step 12: install zfsbootmenu ----------------------------------------
321 # Pinned to the upstream-signed unified EFI binary (kernel+initramfs+ZFS
322 # bundled). Update both VERSION and SHA256 together; sha256.txt at
323 # https://github.com/zbm-dev/zfsbootmenu/releases/download/<v>/sha256.txt
324 # is the source of truth.
325 ZBM_VERSION=v3.1.0
326 ZBM_KERNEL=linux6.18
327 ZBM_EFI_SHA256=0a2a192de90be4935c21a810f9e5281f0cbf625a76b2d6e9125c39c13becfe83
328
329 install_zbm() {
330 esp_boot="$TARGET/boot/efi/EFI/BOOT"
331 run mkdir -p "$esp_boot"
332
333 asset="zfsbootmenu-release-x86_64-${ZBM_VERSION}-${ZBM_KERNEL}.EFI"
334 url="https://github.com/zbm-dev/zfsbootmenu/releases/download/${ZBM_VERSION}/${asset}"
335
336 if [ "$DRY_RUN" = 1 ]; then
337 echo "[dry-run] curl -fL $url"
338 echo "[dry-run] verify sha256 == $ZBM_EFI_SHA256"
339 echo "[dry-run] install to $esp_boot/BOOTX64.EFI"
340 echo "[dry-run] efibootmgr --create --disk $DISK --part 1 --label Mountaineer --loader '\\EFI\\BOOT\\BOOTX64.EFI'"
341 return
342 fi
343
344 tmp=$(mktemp -d)
345 echo "==> downloading zfsbootmenu $ZBM_VERSION ($ZBM_KERNEL)"
346 if ! curl -fLsS -o "$tmp/zbm.EFI" "$url"; then
347 rm -rf "$tmp"
348 die "failed to download $url — check network and ZBM_VERSION"
349 fi
350
351 actual=$(sha256sum "$tmp/zbm.EFI" | awk '{print $1}')
352 if [ "$actual" != "$ZBM_EFI_SHA256" ]; then
353 rm -rf "$tmp"
354 die "ZBM checksum mismatch: expected $ZBM_EFI_SHA256, got $actual"
355 fi
356
357 cp "$tmp/zbm.EFI" "$esp_boot/BOOTX64.EFI"
358 rm -rf "$tmp"
359
360 # Tell ZBM what kernel cmdline to use when chainloading Mountaineer.
361 # `console=tty0 console=ttyS0,115200`: kernel emits to both consoles
362 # so headless/serial-only hosts (VMs, oob mgmt) see boot messages
363 # and physical hosts get a graphical console. Last-listed is init's
364 # controlling tty. `ro` is conventional for ZFS root; `quiet` keeps
365 # the boot quiet but visible. Set as a ZFS property on the root
366 # dataset; ZBM reads `org.zfsbootmenu:commandline` per-dataset.
367 # NB: bypass `run` because it word-splits, which mangles the
368 # space-separated cmdline value.
369 if [ "$DRY_RUN" = 1 ]; then
370 echo "[dry-run] zfs set org.zfsbootmenu:commandline='ro quiet console=tty0 console=ttyS0,115200' $ROOT_DATASET"
371 else
372 zfs set org.zfsbootmenu:commandline='ro quiet console=tty0 console=ttyS0,115200' "$ROOT_DATASET"
373 fi
374
375 # Register a UEFI boot entry. Some firmware (notably QEMU/EDK2 without
376 # an efivarfs writable mount) refuses; the BOOTX64.EFI default path
377 # works as fallback either way, so this is best-effort.
378 efibootmgr --create --disk "$DISK" --part 1 \
379 --label "Mountaineer" --loader '\EFI\BOOT\BOOTX64.EFI' || \
380 echo "warning: efibootmgr could not add boot entry — relying on \\EFI\\BOOT\\BOOTX64.EFI fallback path" >&2
381 }
382
383 # Bind-mount the host pseudo-filesystems into the target so chroot'd
384 # helpers (ssh-keygen, s6-rc-compile, post-install hooks) can open
385 # /dev/null, read /proc, etc. Idempotent.
386 bind_target_dev() {
387 for d in dev proc sys; do
388 run mkdir -p "$TARGET/$d"
389 mountpoint -q "$TARGET/$d" 2>/dev/null && continue
390 run mount --bind "/$d" "$TARGET/$d"
391 done
392 }
393
394 unbind_target_dev() {
395 for d in sys proc dev; do
396 mountpoint -q "$TARGET/$d" 2>/dev/null && run umount "$TARGET/$d"
397 done
398 return 0
399 }
400
401 # --- step 13: per-host identity ------------------------------------------
402 generate_identity() {
403 bind_target_dev
404 run chroot "$TARGET" ssh-keygen -A
405 if [ "$DRY_RUN" = 0 ]; then
406 dd if=/dev/urandom bs=16 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' > "$TARGET/etc/machine-id"
407 echo >> "$TARGET/etc/machine-id"
408 fi
409 }
410
411 # --- step 14: first-boot prompts -----------------------------------------
412 first_boot_prompts() {
413 if [ "$INTERACTIVE" = 0 ]; then
414 HOSTNAME=$(cfg_get hostname mountaineer)
415 PUBKEY=$(cfg_get ssh_pubkey)
416 ROOTPW=$(cfg_get root_password)
417 echo "==> hostname: $HOSTNAME"
418 else
419 dialog --inputbox "Hostname:" 8 50 "mountaineer" 2>/tmp/hn
420 HOSTNAME=$(cat /tmp/hn); rm -f /tmp/hn
421 [ -n "$HOSTNAME" ] || HOSTNAME=mountaineer
422
423 dialog --inputbox "Operator SSH public key (paste, or leave blank to add later):" 10 70 "" 2>/tmp/pk
424 PUBKEY=$(cat /tmp/pk); rm -f /tmp/pk
425
426 dialog --insecure --passwordbox "Console root password (sshd does not allow password login; this is for console only):" 9 60 2>/tmp/rp
427 ROOTPW=$(cat /tmp/rp); rm -f /tmp/rp
428 fi
429
430 if [ "$DRY_RUN" = 0 ]; then
431 echo "$HOSTNAME" > "$TARGET/etc/hostname"
432 fi
433
434 if [ -n "$PUBKEY" ] && [ "$DRY_RUN" = 0 ]; then
435 mkdir -p "$TARGET/root/.ssh"
436 chmod 700 "$TARGET/root/.ssh"
437 # Even though root login is denied for password auth, an
438 # authorized_keys entry on root lets the operator bootstrap an
439 # unprivileged account via console. Per first-boot route, the
440 # operator should then add a real user and remove this entry.
441 echo "$PUBKEY" > "$TARGET/root/.ssh/authorized_keys"
442 chmod 600 "$TARGET/root/.ssh/authorized_keys"
443 fi
444
445 if [ -n "$ROOTPW" ] && [ "$DRY_RUN" = 0 ]; then
446 echo "root:$ROOTPW" | chroot "$TARGET" chpasswd
447 fi
448
449 # Network: for MVP default to DHCP on every interface. Static and wifi
450 # are post-install via /etc/network/interfaces.d/ — first-boot route
451 # documents how.
452 if [ "$DRY_RUN" = 0 ]; then
453 cat > "$TARGET/etc/network/interfaces" <<'EOF'
454 # Mountaineer: DHCP on every non-loopback interface by default.
455 # Per-interface overrides go in /etc/network/interfaces.d/.
456
457 auto lo
458 iface lo inet loopback
459
460 auto eth0
461 iface eth0 inet dhcp
462 EOF
463 fi
464 }
465
466 # --- step 15: compile s6-rc tree -----------------------------------------
467 compile_s6rc() {
468 run chroot "$TARGET" s6-rc-compile /etc/s6-rc/compiled /etc/s6-rc/source
469 }
470
471 # --- step 16: summary + confirm ------------------------------------------
472 summary() {
473 cat <<EOF
474 ==> Install summary
475 Disk: $DISK
476 Encrypted: $([ "$ENCRYPT" = 1 ] && echo yes || echo no)
477 Hostname: $HOSTNAME
478 SSH key: $([ -n "$PUBKEY" ] && echo set || echo NOT SET)
479 ARC max: $ARC_MAX bytes
480 EOF
481 if [ "$INTERACTIVE" = 1 ]; then
482 dialog --yesno "Proceed with finalize + reboot?" 7 50 || die "install cancelled at confirm step"
483 fi
484 }
485
486 # --- step 17: finalize ---------------------------------------------------
487 finalize() {
488 unbind_target_dev
489 run umount "$TARGET/boot/efi"
490 run zfs umount -a
491 run zpool export rpool
492 run reboot
493 }
494
495 # --- main flow -----------------------------------------------------------
496 require_uefi
497 pick_disk
498 ask_encryption
499 partition
500 create_pool
501 create_datasets
502 format_esp
503 compute_arc_max
504 install_base
505 unpack_apkovl
506 write_arc_conf
507 install_zbm
508 generate_identity
509 first_boot_prompts
510 compile_s6rc
511 summary
512 finalize
513