LibrePortal/scripts/dev/lp-storage-place-test
librelad 56cd6e7fa4 storage: choose which drive an app installs onto
The resolver already supported per-app placement — CFG_<APP>_STORAGE names a
location and appDir sends data, compose and config there — and 37 of 39 app
templates ship the field. What was missing was choosing AT INSTALL TIME. The
only routes were editing a config by hand before installing, or installing onto
the default disk and then `app move`ing it, which copies the data twice.

    libreportal app install <app> --storage=<location>

and the App Center's existing storage dropdown, which travels inside
config_variables. Both resolve to one answer in storageChoiceFor, so there is a
single code path.

Ordering is the whole difficulty, and getting it wrong is quiet. installApp
copies the app template into appDir(), sources it, and later applies the form
overrides. The choice has to be live before the copy (or the directory is
created on the wrong disk), written into the config before the source (or the
template's "default" wins and every later appDir in that process returns the
primary root), and folded into config_variables (or the override pass writes
"default" back). Miss any one and the directory and its config disagree — which
resolves correctly only until something sources the config.

Refuses an unknown or unmounted location, an existing directory, and an app
whose template marks the field **READONLY** (fixed to the primary root because
other apps reach it by literal path — storageMoveApp already refuses to move
those, and installing one elsewhere is the same violation from the other end).

Three shipped bugs found making this work:

  * updateConfigOption chose its write helper by comparing the path against
    $containers_dir — the PRIMARY root only — so an app on any other registered
    location took the manager branch and `sed -i` failed with exactly the
    permission error the comment above that code describes. `app move` writes
    the new location with `|| true`, so it reported a successful move while
    leaving the config naming the old disk.
  * storageLocationName resolved a location's name only from an in-scope
    CFG_STORAGE_LOC_<id>_NAME, falling back to the bare id. That name is the
    value CFG_<APP>_STORAGE is set to, so the generated dropdown offered
    "location-1" as both label and value — a choice that does not resolve. Read
    it from the location's config when the variable is not in scope.
  * storageSyncAllAppComments was written for "the regen path" and never wired
    into one. Every CFG_<APP>_STORAGE option list was frozen at install time, so
    adding a drive did not make it selectable anywhere. Called from the storage
    generator now, which runs exactly when those lists go stale — and extended
    to app TEMPLATES, since an app not installed yet is precisely the one whose
    install form needs to show which drives exist.

Verified on a live install with three locations: linkding and authelia on disk1,
ipinfo on disk2, fourteen on the default root, each config naming its own drive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 07:22:11 +01:00

102 lines
5.0 KiB
Bash
Executable File

#!/bin/bash
# Per-app install location: does the choice arrive, validate, and stick?
#
# scripts/dev/lp-storage-place-test
#
# Two routes carry the same choice — `app install --storage=<name>` and the
# storage dropdown in the App Center's install form, which travels inside
# config_variables — so the first thing to pin is that they resolve to one
# answer, with the explicit flag winning.
#
# The rest is ordering. installApp copies the app template, sources it, and
# later applies the form overrides. The template ships CFG_<APP>_STORAGE=default,
# so a choice that is not folded into config_variables gets written back to
# "default" by that last pass — leaving an app whose directory is on one disk
# and whose config claims another. That resolves correctly only for as long as
# nothing sources the config, which is why it is easy to miss.
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
BASE="$(mktemp -d "${TMPDIR:-/tmp}/lp-place-test-XXXXXX")"
trap 'rm -rf "$BASE"' EXIT
fail=0
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
mkdir -p "$BASE/primary" "$BASE/disk1"
isNotice(){ :; }; isError(){ LAST_ERR="$*"; }; isSuccessful(){ :; }
primaryRoot(){ printf '%s' "$BASE/primary"; }
storageLocationPath(){ [[ "$1" == "disk1" ]] && { printf '%s' "$BASE/disk1"; return 0; }; return 1; }
storageLocationName(){ basename "$1"; }
storageRoots(){ printf '%s\n%s\n' "$BASE/primary" "$BASE/disk1"; }
storageRootAvailable(){ [[ "$1" == "$BASE/unmounted" ]] && return 1; return 0; }
storageIndexSet(){ INDEXED="$1 -> $2"; }
storageCacheReset(){ :; }
updateConfigOption(){ CFG_WRITTEN="$1=$2 -> $3"; }
source "$REPO/scripts/storage/storage_place.sh"
echo "--- the two routes resolve to one answer ---"
chk "flag only" "$(LP_INSTALL_STORAGE=disk1 storageChoiceFor myapp '')" "disk1"
chk "form only" "$(storageChoiceFor myapp 'CFG_MYAPP_STORAGE=disk1|CFG_MYAPP_PORT=80')" "disk1"
chk "flag wins" "$(LP_INSTALL_STORAGE=disk1 storageChoiceFor myapp 'CFG_MYAPP_STORAGE=primary')" "disk1"
chk "neither" "$(storageChoiceFor myapp 'CFG_MYAPP_PORT=80')" ""
echo "--- the choice is folded into config_variables ---"
# Without this the later override pass writes the template's "default" back.
out=$(storageChoiceMerge myapp disk1 'CFG_MYAPP_PORT=80|CFG_MYAPP_STORAGE=default')
chk "storage set" "$(tr '|' '\n' <<< "$out" | grep -c '^CFG_MYAPP_STORAGE=disk1$')" "1"
chk "no stale value" "$(tr '|' '\n' <<< "$out" | grep -c '^CFG_MYAPP_STORAGE=default$')" "0"
chk "others kept" "$(tr '|' '\n' <<< "$out" | grep -c '^CFG_MYAPP_PORT=80$')" "1"
chk "from empty" "$(storageChoiceMerge myapp disk1 '')" "CFG_MYAPP_STORAGE=disk1"
echo "--- placement validates before anything is created ---"
LAST_ERR=""; INDEXED=""
storagePlaceAppPre myapp nosuch && { echo " FAIL unknown location accepted"; fail=1; } || echo " ok unknown location refused"
chk "and says so" "${LAST_ERR:0:26}" "No such storage location: "
chk "nothing indexed" "$INDEXED" ""
storageRootAvailable(){ return 1; }
LAST_ERR=""
storagePlaceAppPre myapp disk1 && { echo " FAIL unmounted location accepted"; fail=1; } || echo " ok unmounted location refused"
storageRootAvailable(){ return 0; }
mkdir -p "$BASE/disk1/taken"
LAST_ERR=""
storagePlaceAppPre taken disk1 && { echo " FAIL installed over an existing directory"; fail=1; } || echo " ok refuses to install over an existing directory"
echo "--- a good placement records both answers ---"
INDEXED=""
storagePlaceAppPre myapp disk1 || { echo " FAIL valid placement refused"; fail=1; }
chk "index" "$INDEXED" "myapp -> $BASE/disk1"
chk "exported" "$CFG_MYAPP_STORAGE" "disk1"
echo "--- an app pinned to the primary root cannot be placed elsewhere ---"
# Some apps are referenced by other apps at a literal path, so their template
# marks the field **READONLY**. storageMoveApp refuses to move those; installing
# one onto another disk is the same violation from the other end.
install_containers_dir="$BASE/templates/"
mkdir -p "$BASE/templates/pinned"
printf 'CFG_PINNED_STORAGE=default # Storage Location - Fixed **READONLY**\n' \
> "$BASE/templates/pinned/pinned.config"
LAST_ERR=""; INDEXED=""
storagePlaceAppPre pinned disk1 && { echo " FAIL a pinned app was placed elsewhere"; fail=1; } \
|| echo " ok refused"
chk "nothing indexed" "$INDEXED" ""
mkdir -p "$BASE/templates/free"
printf 'CFG_FREE_STORAGE=default # Storage Location - Which disk [default:Primary]\n' \
> "$BASE/templates/free/free.config"
INDEXED=""
storagePlaceAppPre free disk1 || { echo " FAIL an unpinned app was refused"; fail=1; }
chk "unpinned still placed" "$INDEXED" "free -> $BASE/disk1"
echo "--- \"default\" is not a placement ---"
# Templates ship it; treating it as a choice would pin every app to the primary
# root and break "follow CFG_STORAGE_DEFAULT".
INDEXED=""
storagePlaceAppPre other default || { echo " FAIL default should be a no-op"; fail=1; }
chk "nothing indexed" "$INDEXED" ""
echo ""
if (( fail )); then echo "FAILED"; exit 1; fi
echo "All install-placement checks passed."