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>
67 lines
2.9 KiB
Bash
Executable File
67 lines
2.9 KiB
Bash
Executable File
#!/bin/bash
|
|
# Which user does a config write drop to, for an app on a second disk?
|
|
#
|
|
# scripts/dev/lp-config-write-test
|
|
#
|
|
# `sed -i` writes its temporary file next to the target, so it needs write
|
|
# permission on the DIRECTORY. App directories are owned by the container user,
|
|
# so the manager cannot write in them and the write has to drop privilege.
|
|
#
|
|
# updateConfigOption chose that by comparing the path against $containers_dir —
|
|
# the PRIMARY root only. An app installed on any other registered storage
|
|
# location therefore took the manager branch and failed with
|
|
#
|
|
# sed: couldn't open temporary file /mnt/disk2/apps/<app>/sedXXXXXX: Permission denied
|
|
#
|
|
# which is the exact failure the comment above that code describes. Callers that
|
|
# tolerate a failed write hid it: `app move` persists the new location with
|
|
# `updateConfigOption ... || true`, so it reported a successful move while
|
|
# leaving the config naming the old disk.
|
|
|
|
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
BASE="$(mktemp -d "${TMPDIR:-/tmp}/lp-cfgwrite-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; }
|
|
|
|
containers_dir="$BASE/primary/"
|
|
mkdir -p "$BASE/primary/appA" "$BASE/disk2/appB" "$BASE/sys/configs"
|
|
for f in "$BASE/primary/appA/appA.config" "$BASE/disk2/appB/appB.config" "$BASE/sys/configs/general"; do
|
|
mkdir -p "$(dirname "$f")"; printf 'CFG_X=old # a comment\n' > "$f"
|
|
done
|
|
|
|
# The resolver knows every registered root; the primary-only comparison did not.
|
|
pathIsContainerData(){
|
|
[[ "$1" == "$BASE/primary/"* || "$1" == "$BASE/disk2/"* ]]
|
|
}
|
|
source "$REPO/scripts/docker/command/run_privileged.sh" 2>/dev/null || true
|
|
|
|
# Record which helper the write is routed through instead of actually escalating.
|
|
CHOSE=""
|
|
runFileOp(){ CHOSE="runFileOp"; shift 0; "$@"; }
|
|
runInstallOp(){ CHOSE="runInstallOp"; shift 0; "$@"; }
|
|
runAsManager(){ "$@"; }
|
|
isError(){ :; }; isSuccessful(){ :; }; isNotice(){ :; }; isQuestion(){ :; }
|
|
checkSuccess(){ :; }
|
|
|
|
source "$REPO/scripts/config/core/config_update_option.sh"
|
|
|
|
echo "--- an app on the PRIMARY root ---"
|
|
CHOSE=""; updateConfigOption CFG_X newA "$BASE/primary/appA/appA.config" >/dev/null 2>&1
|
|
chk "drops to the container user" "$CHOSE" "runFileOp"
|
|
chk "and the value landed" "$(grep -c 'newA' "$BASE/primary/appA/appA.config")" "1"
|
|
|
|
echo "--- an app on a SECOND registered location (the case that failed) ---"
|
|
CHOSE=""; updateConfigOption CFG_X newB "$BASE/disk2/appB/appB.config" >/dev/null 2>&1
|
|
chk "drops to the container user" "$CHOSE" "runFileOp"
|
|
chk "and the value landed" "$(grep -c 'newB' "$BASE/disk2/appB/appB.config")" "1"
|
|
|
|
echo "--- a manager-owned system config stays with the manager ---"
|
|
CHOSE=""; updateConfigOption CFG_X newS "$BASE/sys/configs/general" >/dev/null 2>&1
|
|
chk "stays with the manager" "$CHOSE" "runInstallOp"
|
|
|
|
echo ""
|
|
if (( fail )); then echo "FAILED"; exit 1; fi
|
|
echo "All config-write routing checks passed."
|