diff --git a/scripts/dev/lp-install-matrix b/scripts/dev/lp-install-matrix new file mode 100755 index 0000000..d11e1e0 --- /dev/null +++ b/scripts/dev/lp-install-matrix @@ -0,0 +1,124 @@ +#!/bin/bash +# Install LibrePortal across the combinations of relocatable roots and check +# each one landed where it was asked to. +# +# sudo scripts/dev/lp-install-matrix up # loopback disks first +# sudo scripts/dev/lp-install-matrix 1|2|3|4|all +# sudo scripts/dev/lp-install-matrix verify N # re-check without reinstalling +# +# The three roots are independently relocatable, and the interesting failures +# are the ones where only ONE of them moves: paths get baked into root-owned +# helpers, a systemd unit and the CLI wrapper at install time, so a root that is +# resolved at runtime instead of baked works on a default install and silently +# points at the wrong disk on a relocated one. Testing only "everything default" +# or only "everything moved" misses exactly that. +# +# 1 system / apps / baseline +# 2 system / apps disk2 app data on its own disk +# 3 system disk1 apps / control plane on its own disk +# 4 system disk1 apps disk2 all three roots moved (backups too) +set -u + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +D1="${LP_TESTDISK_MNT:-/mnt/lptest}1" +D2="${LP_TESTDISK_MNT:-/mnt/lptest}2" +LOG_DIR=/var/tmp/lp-matrix +mkdir -p "$LOG_DIR" + +[[ $EUID -eq 0 ]] || { echo "lp-install-matrix: run with sudo" >&2; exit 1; } + +case_dirs() { # $1 = case -> sets SYS/CON/BAK + case "$1" in + 1) SYS=/libreportal-system; CON=/libreportal-containers; BAK=/libreportal-backups ;; + 2) SYS=/libreportal-system; CON="$D2/libreportal-containers"; BAK=/libreportal-backups ;; + 3) SYS="$D1/libreportal-system"; CON=/libreportal-containers; BAK=/libreportal-backups ;; + 4) SYS="$D1/libreportal-system"; CON="$D2/libreportal-containers"; BAK="$D2/libreportal-backups" ;; + *) echo "unknown case '$1'" >&2; return 2 ;; + esac +} + +fail=0 +chk() { if [[ "$2" == "$3" ]]; then printf ' ok %-42s %s\n' "$1" "$2" + else printf ' FAIL %-42s got %s want %s\n' "$1" "$2" "$3"; fail=1; fi; } +chk_true() { if eval "$2" >/dev/null 2>&1; then printf ' ok %s\n' "$1" + else printf ' FAIL %s\n' "$1"; fail=1; fi; } + +# Which filesystem a path actually sits on. The point of the whole exercise: +# a root that resolved to the right STRING but the wrong DEVICE is the bug. +devof() { stat -c '%d' "$1" 2>/dev/null || echo "-"; } + +verify() { + local c="$1"; case_dirs "$c" || return 2 + fail=0 + echo "── case $c ─────────────────────────────────────────────" + printf ' system=%s\n apps=%s\n backups=%s\n\n' "$SYS" "$CON" "$BAK" + + chk_true "system root exists" "[[ -d '$SYS' ]]" + chk_true "containers root exists" "[[ -d '$CON' ]]" + chk_true "backups root exists" "[[ -d '$BAK' ]]" + + # On the intended DEVICE, not merely at the intended path. + chk "system on expected fs" "$(devof "$SYS")" "$(devof "$(dirname "$SYS")")" + chk "containers on expected fs" "$(devof "$CON")" "$(devof "$(dirname "$CON")")" + + chk "system owner" "$(stat -c '%U' "$SYS" 2>/dev/null)" "libreportal" + chk "containers owner" "$(stat -c '%U' "$CON" 2>/dev/null)" "dockerinstall" + + # Baked, not resolved: the whole trust model rests on these being fixed at + # install so the manager cannot redirect a root operation. + local w=/usr/local/lib/libreportal/libreportal + chk "wrapper LP_SYSTEM_DIR" "$(grep -oP '(?<=^LP_SYSTEM_DIR=")[^"]+' "$w" 2>/dev/null | head -1)" "$SYS" + chk "wrapper LP_CONTAINERS_DIR" "$(grep -oP '(?<=^LP_CONTAINERS_DIR=")[^"]+' "$w" 2>/dev/null | head -1)" "$CON" + + local o=/usr/local/lib/libreportal/libreportal-ownership + chk "ownership helper SYSTEM_DIR" "$(grep -oP '(?<=^SYSTEM_DIR=")[^"]+' "$o" 2>/dev/null | head -1)" "$SYS" + chk "ownership helper CONTAINERS_DIR" "$(grep -oP '(?<=^CONTAINERS_DIR=")[^"]+' "$o" 2>/dev/null | head -1)" "$CON" + + chk_true "no unbaked __PLACEHOLDER__ left" "! grep -q '__[A-Z_]*__' $o" + chk_true "systemd unit installed" "[[ -f /etc/systemd/system/libreportal.service ]]" + chk_true "CLI on PATH" "[[ -x /usr/local/bin/libreportal ]]" + chk_true "scoped sudoers present" "[[ -f /etc/sudoers.d/libreportal ]]" + + # The WebUI is the thing a person actually opens. + local port + port=$(su -c 'docker ps --filter name=libreportal-service --format "{{.Ports}}"' dockerinstall 2>/dev/null \ + | grep -oE '0\.0\.0\.0:[0-9]+' | head -1 | cut -d: -f2) + if [[ -n "$port" ]]; then + printf ' ok %-42s http://127.0.0.1:%s\n' "WebUI container up" "$port" + local code; code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "http://127.0.0.1:$port/" 2>/dev/null) + chk "WebUI responds" "$code" "200" + echo "$port" > "$LOG_DIR/case$c.port" + else + echo " FAIL WebUI container not running"; fail=1 + fi + + echo "" + if (( fail )); then echo " case $c: FAILED"; else echo " case $c: passed"; fi + return $fail +} + +install_case() { + local c="$1"; case_dirs "$c" || return 2 + echo "== case $c: uninstalling ==" + ( cd "$REPO" && bash init.sh --unattended --skip-rootless uninstall ) \ + > "$LOG_DIR/case$c-uninstall.log" 2>&1 + echo " uninstall rc=$? (log: $LOG_DIR/case$c-uninstall.log)" + + echo "== case $c: installing ==" + printf ' system=%s\n apps=%s\n backups=%s\n' "$SYS" "$CON" "$BAK" + ( cd "$REPO" && bash init.sh --random-password --local --unattended \ + --skip-os-update --skip-prereqs \ + --system-dir="$SYS" --containers-dir="$CON" --backups-dir="$BAK" init ) \ + > "$LOG_DIR/case$c-install.log" 2>&1 + echo " install rc=$? (log: $LOG_DIR/case$c-install.log)" + echo "" + verify "$c" +} + +case "${1:-}" in + up) bash "$REPO/scripts/dev/lp-testdisk" up 2 30G ;; + verify) verify "${2:?case number}" ;; + 1|2|3|4) install_case "$1" ;; + all) rc=0; for c in 1 2 3 4; do install_case "$c" || rc=1; echo; done; exit $rc ;; + *) sed -n '2,20p' "$0"; exit 2 ;; +esac diff --git a/scripts/dev/lp-shot b/scripts/dev/lp-shot index 21a55b3..4c3fada 100755 --- a/scripts/dev/lp-shot +++ b/scripts/dev/lp-shot @@ -64,7 +64,31 @@ import urllib.request from websockets.sync.client import connect DEFAULT_OUT = "/tmp/webui-shot.png" -COMPOSE = "/libreportal-containers/libreportal/docker-compose.yml" + + +def containers_root(): + """Where app data lives on THIS install, not where it lives by default. + + The containers root is relocatable (--containers-dir), and init.sh bakes the + resolved value into the root-owned CLI wrapper. Reading it back from there + is the only way to find the WebUI on an install whose data sits on another + disk; hardcoding /libreportal-containers meant lp-shot silently fell back to + a default port and a missing .auth.json, which looks exactly like a WebUI + that failed to boot. + """ + if os.environ.get("LP_CONTAINERS_DIR"): + return os.environ["LP_CONTAINERS_DIR"].rstrip("/") + try: + with open("/usr/local/lib/libreportal/libreportal") as fh: + m = re.search(r'^LP_CONTAINERS_DIR="([^"]+)"', fh.read(), re.M) + if m and "__" not in m.group(1): + return m.group(1).rstrip("/") + except OSError: + pass + return "/libreportal-containers" + + +COMPOSE = containers_root() + "/libreportal/docker-compose.yml" def die(msg, code=1): @@ -91,7 +115,7 @@ COOKIE = "libreportal_token" AUTH_FILES = [ os.environ.get("LP_SHOT_AUTH_FILE"), - "/libreportal-containers/libreportal/frontend/.auth.json", + containers_root() + "/libreportal/frontend/.auth.json", os.path.expanduser( "~/Documents/LibrePortal/LibrePortal/containers/libreportal/frontend/.auth.json"), ] diff --git a/scripts/dev/lp-testdisk b/scripts/dev/lp-testdisk new file mode 100755 index 0000000..b41fbcf --- /dev/null +++ b/scripts/dev/lp-testdisk @@ -0,0 +1,98 @@ +#!/bin/bash +# Loopback disks for exercising the multi-disk install and storage paths. +# +# scripts/dev/lp-testdisk up [count] [size] # default 2 disks, 30G sparse +# scripts/dev/lp-testdisk status +# scripts/dev/lp-testdisk down +# +# Why loopback rather than a spare partition: the code paths that matter care +# that a location sits on a DIFFERENT filesystem — appDir resolution, the +# storage registry's device numbers, `st_dev` comparisons in the installer's +# disk picker, and app-adopt's same-device mv vs cross-device copy. A loop +# device gives all of that with a real ext4 superblock, its own st_dev and its +# own free-space figures, and can be thrown away between runs. +# +# Sparse-allocated, so a 30G disk costs what is written to it, not 30G. +set -u + +BACKING_DIR="${LP_TESTDISK_DIR:-/var/lib/lp-testdisks}" +MNT_PREFIX="${LP_TESTDISK_MNT:-/mnt/lptest}" + +_need_root() { [[ $EUID -eq 0 ]] || { echo "lp-testdisk: run with sudo" >&2; exit 1; }; } + +up() { + _need_root + local count="${1:-2}" size="${2:-30G}" + mkdir -p "$BACKING_DIR" + local i img mnt + for (( i=1; i<=count; i++ )); do + img="$BACKING_DIR/disk$i.img" + mnt="${MNT_PREFIX}$i" + if ! [[ -f "$img" ]]; then + truncate -s "$size" "$img" + mkfs.ext4 -q -L "lptest$i" "$img" + echo " created $img ($size, sparse)" + fi + mkdir -p "$mnt" + if mountpoint -q "$mnt"; then + echo " already mounted: $mnt" + else + mount -o loop "$img" "$mnt" || { echo " FAILED to mount $img" >&2; return 1; } + # An install writes here as root and then hands ownership over, so + # the mount point itself only needs to be traversable. + chmod 0755 "$mnt" + echo " mounted $img -> $mnt" + fi + done + status +} + +status() { + local mnt + printf ' %-16s %-10s %-8s %-8s %s\n' MOUNT DEV SIZE AVAIL "st_dev" + for mnt in "${MNT_PREFIX}"*; do + [[ -d "$mnt" ]] || continue + if mountpoint -q "$mnt"; then + printf ' %-16s %-10s %-8s %-8s %s\n' "$mnt" \ + "$(findmnt -no SOURCE "$mnt" 2>/dev/null | xargs -r basename)" \ + "$(findmnt -no SIZE "$mnt" 2>/dev/null)" \ + "$(findmnt -no AVAIL "$mnt" 2>/dev/null)" \ + "$(stat -c '%d' "$mnt" 2>/dev/null)" + else + printf ' %-16s %s\n' "$mnt" "(not mounted)" + fi + done + printf ' %-16s %-10s %-8s %-8s %s\n' "/" \ + "$(findmnt -no SOURCE --target / | tail -1 | xargs -r basename)" \ + "$(findmnt -no SIZE --target / | tail -1)" \ + "$(findmnt -no AVAIL --target / | tail -1)" \ + "$(stat -c '%d' / 2>/dev/null)" +} + +down() { + _need_root + local mnt + for mnt in "${MNT_PREFIX}"*; do + [[ -d "$mnt" ]] || continue + if mountpoint -q "$mnt"; then + umount "$mnt" 2>/dev/null || umount -l "$mnt" 2>/dev/null \ + || { echo " busy, still mounted: $mnt" >&2; continue; } + echo " unmounted $mnt" + fi + rmdir "$mnt" 2>/dev/null + done +} + +destroy() { + _need_root + down + [[ -d "$BACKING_DIR" ]] && { rm -rf "$BACKING_DIR"; echo " removed $BACKING_DIR"; } +} + +case "${1:-status}" in + up) shift; up "$@" ;; + status) status ;; + down) down ;; + destroy) destroy ;; + *) echo "usage: lp-testdisk {up [count] [size]|status|down|destroy}" >&2; exit 2 ;; +esac