dev: harness for the multi-disk install, and make lp-shot follow the roots
The three roots are independently relocatable, and the failures that matter are
the ones where only ONE of them moves: paths are baked into root-owned helpers,
the systemd unit and the CLI wrapper at install time, so anything that resolves
a root at runtime instead works on a default install and points at the wrong
disk on a relocated one. Testing "all default" or "all moved" misses that.
scripts/dev/lp-testdisk loopback ext4 disks — a real superblock, its own
st_dev and free space, thrown away between runs
scripts/dev/lp-install-matrix installs across the four root combinations and
checks each landed on the intended DEVICE, that
the helpers were baked (no __PLACEHOLDER__ left)
and that the WebUI answers
First thing the harness turned up: lp-shot hardcoded /libreportal-containers for
both the compose file it reads the published port from and the .auth.json it
signs a session with. On an install whose app data is on another disk it fell
back to a default port and a missing auth file — which looks exactly like a
WebUI that failed to boot. It now reads the baked LP_CONTAINERS_DIR back out of
the CLI wrapper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
226ebe1717
commit
fc92d556f1
124
scripts/dev/lp-install-matrix
Executable file
124
scripts/dev/lp-install-matrix
Executable file
@ -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
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
98
scripts/dev/lp-testdisk
Executable file
98
scripts/dev/lp-testdisk
Executable file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user