LibrePortal/scripts/storage/storage_place.sh
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

167 lines
6.1 KiB
Bash

#!/bin/bash
# Choosing which drive an app is installed onto.
#
# The resolver already supported this — CFG_<APP>_STORAGE names a location and
# appDir sends data, compose and config there — and every app template ships the
# field with a generated dropdown of the registered locations. What was missing
# was making the choice effective AT INSTALL TIME. Until now the only routes
# were editing the config by hand before installing, or installing onto the
# default disk and then `app move`ing it, which copies the data twice.
#
# The choice arrives two ways, and they are the same choice:
#
# libreportal app install <app> --storage=<name> -> LP_INSTALL_STORAGE
# the storage dropdown in the App Center install form -> config_variables
#
# Both are resolved to one answer here so there is a single code path.
#
# ORDERING is the whole difficulty. installApp does:
#
# 1. dockerConfigSetupToContainer silent <app> creates the app dir at
# appDir() and copies the
# template config into it
# 2. initializeAppVariables sources that config
# ...
# 3. dockerConfigSetupToContainer loud <app> install <config_variables>
# applies the form overrides
#
# The choice has to be live before (1) or the directory is created on the wrong
# disk; written into the config between (1) and (2) or sourcing the fresh
# template resets it to "default" and every later appDir in the same process
# returns the primary root; and present in config_variables for (3) or that
# pass writes "default" back over it. Miss any one and you get an app whose
# directory and config disagree — which resolves correctly only for as long as
# nothing sources the config.
# The requested location, or empty for "no opinion" (the normal path).
# The CLI flag wins over the form field when both are present.
storageChoiceFor()
{
local app="$1" config_vars="${2:-}"
[[ -n "$app" ]] || return 0
if [[ -n "${LP_INSTALL_STORAGE:-}" ]]; then
printf '%s' "$LP_INSTALL_STORAGE"
return 0
fi
local key="CFG_${app^^}_STORAGE" pair
local -a pairs=()
IFS='|' read -ra pairs <<< "$config_vars"
for pair in "${pairs[@]}"; do
if [[ "$pair" == "$key="* ]]; then
printf '%s' "${pair#$key=}"
return 0
fi
done
return 0
}
# Make appDir resolve to the chosen location, before anything is created.
storagePlaceAppPre()
{
local app="$1" want="$2"
[[ -n "$app" && -n "$want" ]] || return 0
# "default" is the templates' own value and means "follow CFG_STORAGE_DEFAULT",
# so it is not a placement and must not be treated as one.
[[ "$want" == "default" ]] && return 0
# Some apps are deliberately fixed to the primary root because other apps
# reach them by literal path; their template marks the field **READONLY**.
# storageMoveApp already refuses to move those, and installing one somewhere
# else is the same violation arrived at from the other end — it just breaks
# the referring app immediately instead of later.
local tmpl="${install_containers_dir%/}/$app/$app.config"
if [[ -f "$tmpl" ]] && grep -qE "^CFG_${app^^}_STORAGE=.*\*\*READONLY\*\*" "$tmpl" 2>/dev/null; then
isError "$app is fixed to the default location — other apps reference it by path."
isNotice "Install it without a location, or move the apps that depend on it instead."
return 1
fi
local root
if [[ "$want" == "primary" ]]; then
root=$(primaryRoot)
elif ! root=$(storageLocationPath "$want"); then
isError "No such storage location: $want"
isNotice "Known locations: $(storageLocationNames 2>/dev/null | paste -sd, -)"
return 1
fi
if ! storageRootAvailable "$root"; then
isError "Storage location '$want' ($root) is not mounted — refusing to install onto it."
isNotice "Mount it first, or leave the location unset to use the default."
return 1
fi
if [[ -e "${root%/}/$app" ]]; then
isError "'${root%/}/$app' already exists — refusing to install over it."
return 1
fi
# Two records answering at different times: the exported variable is what
# _appDirIntended reads in THIS process before any config exists for the
# app; the index is what answers for a later process whose config lives on
# a drive that is not mounted.
export "CFG_${app^^}_STORAGE=$want"
storageIndexSet "$app" "$root"
storageCacheReset
isNotice "Installing $app onto '$want' ($root)."
return 0
}
# Keep the choice, now that there is a deployed config to keep it in.
storagePlaceAppPost()
{
local app="$1" want="$2"
[[ -n "$app" && -n "$want" && "$want" != "default" ]] || return 0
local dir cfg
dir=$(appDir "$app") || return 0
cfg="$dir/$app.config"
[[ -f "$cfg" ]] || return 0
updateConfigOption "CFG_${app^^}_STORAGE" "$want" "$cfg" >/dev/null 2>&1 || true
export "CFG_${app^^}_STORAGE=$want"
declare -f storageSyncAppComment >/dev/null 2>&1 && storageSyncAppComment "$app"
return 0
}
# config_variables with the choice set, so the later override pass writes it
# rather than resetting it to the template's "default".
storageChoiceMerge()
{
local app="$1" want="$2" config_vars="${3:-}"
local key="CFG_${app^^}_STORAGE"
[[ -n "$want" ]] || { printf '%s' "$config_vars"; return 0; }
local -a keep=() pairs=()
local pair
IFS='|' read -ra pairs <<< "$config_vars"
for pair in "${pairs[@]}"; do
[[ -z "$pair" || "$pair" == "$key="* ]] && continue
keep+=("$pair")
done
keep+=("$key=$want")
local out="" p
for p in "${keep[@]}"; do
[[ -n "$out" ]] && out+="|"
out+="$p"
done
printf '%s' "$out"
}
# Every location an app can be installed onto, one per line.
storageLocationNames()
{
printf '%s\n' primary
local root
while IFS= read -r root; do
[[ -z "$root" ]] && continue
[[ "${root%/}" == "$(primaryRoot)" ]] && continue
storageLocationName "$root"
done < <(storageRoots 2>/dev/null)
}