feat(install): ask where app data goes, like an OS installer

--containers-dir has existed for a while and nobody running the curl|bash
installer ever learned it existed, so a box with a 4 TB disk beside a
small system SSD quietly put everything on the SSD. The installer now
asks, once, in the shape an OS installer asks it.

Deliberately narrow, because a question you answer badly is worse than no
question:

  * app data only. The control plane is ~20 MB and never grows; the thing
    worth placing is the data. Offering all three roots would be three
    questions nobody can answer at that moment.
  * a SUBDIRECTORY on the chosen disk, never its mount point — that keeps
    the "root only ever takes an empty directory" rule intact and leaves
    whatever is already on the disk alone.
  * candidates exclude anything on the same filesystem as /, since placing
    data there gains nothing.

It stays out of the way: skipped when unattended, when --containers-dir
was passed, when there is no TTY, and when the scan finds nothing else —
a prompt with one possible answer is not a question. Self-contained
(findmnt only), since scripts/ is not necessarily loadable that early.

Verified all six paths under a pty: default, valid pick, out-of-range,
non-numeric, no TTY, unattended, explicit flag, and no candidates.

Docs updated: the installer prompt is shown, and the section that said
locations are fixed after install now explains what actually is fixed
(the three roots) versus what isn't (per-app placement, storage add, and
CFG_STORAGE_DEFAULT).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-26 03:33:44 +01:00
parent 49c463a996
commit 8d41b98497
2 changed files with 132 additions and 4 deletions

View File

@ -26,7 +26,25 @@ login), installs LibrePortal, and prints the WebUI address + a generated passwor
### Put data where you want it (separate disks, external drives) ### Put data where you want it (separate disks, external drives)
LibrePortal uses **three independent roots**, each can be its own path/disk: **The installer just asks.** If it finds a second drive, it offers it for app
data before installing anything:
```
Where should app data live?
1) This disk (default) 911.9G 808.4G free
2) /mnt/bigdisk 3.6T 3.6T free
LibrePortal itself stays on this disk either way — only app data moves.
Choose [1]:
```
It picks a subdirectory on the drive you choose, never the mount point itself,
and skips the question entirely when there is nothing else to choose, when you
passed `--containers-dir`, or when running unattended.
The three roots below are still there for scripted installs:
| Flag (default) | Holds | Owner | | Flag (default) | Holds | Owner |
|---|---|---| |---|---|---|
@ -57,8 +75,22 @@ Notes:
<backups-dir>/ one folder per backup location <backups-dir>/ one folder per backup location
``` ```
The locations are chosen at install and fixed afterward (changing them is a The three roots are chosen at install and fixed afterward — changing *them* is a
deliberate reinstall, not a setting — this is part of the security model). deliberate reinstall, not a setting, and that is part of the security model.
What is **not** fixed is where each app's data lives. You can register extra
drives at any time and place apps on them individually:
```bash
libreportal storage scan # what else could hold app data
libreportal storage add /mnt/bigdisk # register it (must be an empty directory)
libreportal app move nextcloud bigdisk
```
New apps follow `CFG_STORAGE_DEFAULT` (General → Basic), so one setting sends
everything to the big disk without touching each app. An app whose drive is not
mounted refuses to start rather than being rebuilt empty on the bare mount
point.
## Update ## Update

98
init.sh
View File

@ -188,6 +188,96 @@ libreportalDerivePaths() {
} }
libreportalDerivePaths libreportalDerivePaths
# Interactive disk picker for app data.
#
# The --containers-dir flag has existed for a while, but someone running the
# curl|bash installer never learns it exists — so a box with a 4 TB disk sitting
# next to a small system SSD quietly put everything on the SSD. This asks, once,
# in the shape an OS installer asks it.
#
# Deliberately narrow:
# * app data only. The control plane is ~20 MB and never grows; the thing
# worth placing is the data. Offering three roots would be three questions
# to answer badly.
# * skipped entirely when unattended, when --containers-dir was passed, when
# there is no TTY, or when there is nothing else to choose. A prompt with
# one answer is not a question.
# * a SUBDIRECTORY on the chosen disk, never its mount point — that keeps the
# "root only ever takes an empty directory" rule intact and leaves anything
# already on the disk alone.
#
# Self-contained (findmnt only), like the rest of init.sh: scripts/ is not
# necessarily loadable this early.
initPickContainersDir()
{
[[ "$init_unattended_mode" == true ]] && return 0
[[ -n "${LP_CONTAINERS_DIR_EXPLICIT:-}" ]] && return 0
[[ -t 0 && -t 1 ]] || return 0
command -v findmnt >/dev/null 2>&1 || return 0
local sys_dev; sys_dev=$(stat -c '%d' -- / 2>/dev/null)
local -a paths=() labels=()
local line kv val target source fstype size avail dev
while IFS= read -r line; do
[[ -z "$line" ]] && continue
target=""; source=""; fstype=""; size=""; avail=""
for kv in TARGET SOURCE FSTYPE SIZE AVAIL; do
val="${line#*${kv}=\"}"
[[ "$val" == "$line" ]] && continue
val="${val%%\"*}"
case "$kv" in
TARGET) target="$val" ;; SOURCE) source="$val" ;;
FSTYPE) fstype="$val" ;; SIZE) size="$val" ;; AVAIL) avail="$val" ;;
esac
done
[[ -z "$target" ]] && continue
case "$target" in
/|/boot|/boot/*|/efi|/proc*|/sys*|/dev*|/run*|/snap*|/var/snap/*|/tmp) continue ;;
esac
case "$fstype" in
squashfs|overlay|overlay2|aufs|tmpfs|devtmpfs|ramfs|iso9660|udf|vfat|exfat|ntfs|ntfs3|msdos|fuseblk|"") continue ;;
esac
dev=$(stat -c '%d' -- "$target" 2>/dev/null)
[[ -n "$dev" && "$dev" == "$sys_dev" ]] && continue # same disk as / — no gain
paths+=("$target")
labels+=("$(printf '%-24s %-8s %s free' "$target" "${size:-?}" "${avail:-?}")")
done < <(findmnt -Pno TARGET,SOURCE,FSTYPE,SIZE,AVAIL 2>/dev/null)
(( ${#paths[@]} )) || return 0
local root_size root_avail
root_size=$(findmnt -no SIZE --target / 2>/dev/null | tail -1)
root_avail=$(findmnt -no AVAIL --target / 2>/dev/null | tail -1)
echo ""
isHeader "Where should app data live?"
echo ""
printf ' %s %-24s %-8s %s free\n' "1)" "This disk (default)" "${root_size:-?}" "${root_avail:-?}"
local i
for i in "${!paths[@]}"; do
printf ' %s %s\n' "$((i + 2)))" "${labels[$i]}"
done
echo ""
echo " LibrePortal itself stays on this disk either way — only app data moves."
echo ""
local choice=""
isQuestion "Choose [1]:"
read -r choice
echo ""
[[ -z "$choice" || "$choice" == "1" ]] && return 0
if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 2 || choice > ${#paths[@]} + 1 )); then
isNotice "Not a listed option — using this disk."
return 0
fi
local chosen="${paths[$((choice - 2))]}"
LP_CONTAINERS_DIR="${chosen%/}/libreportal-containers"
isSuccessful "App data will live in $LP_CONTAINERS_DIR"
return 0
}
# Validate the chosen roots before anything is created/baked. Called from the # Validate the chosen roots before anything is created/baked. Called from the
# install flow only (NOT at source time — the CLI sources init.sh too). Aborts on # install flow only (NOT at source time — the CLI sources init.sh too). Aborts on
# an unsafe choice; the root helpers also re-check at runtime (defence in depth). # an unsafe choice; the root helpers also re-check at runtime (defence in depth).
@ -276,7 +366,7 @@ for ((i=1; i<=$#; i++)); do
# by libreportalValidatePaths before any folder is created. Can also be set # by libreportalValidatePaths before any folder is created. Can also be set
# via the LP_*_DIR environment. # via the LP_*_DIR environment.
--system-dir=*) LP_SYSTEM_DIR="${!i#*=}"; ((init_shift_count++)) ;; --system-dir=*) LP_SYSTEM_DIR="${!i#*=}"; ((init_shift_count++)) ;;
--containers-dir=*) LP_CONTAINERS_DIR="${!i#*=}"; ((init_shift_count++)) ;; --containers-dir=*) LP_CONTAINERS_DIR="${!i#*=}"; LP_CONTAINERS_DIR_EXPLICIT=1; ((init_shift_count++)) ;;
--backups-dir=*) LP_BACKUPS_DIR="${!i#*=}"; ((init_shift_count++)) ;; --backups-dir=*) LP_BACKUPS_DIR="${!i#*=}"; ((init_shift_count++)) ;;
--manager-user=*) LP_MANAGER_USER="${!i#*=}"; ((init_shift_count++)) ;; --manager-user=*) LP_MANAGER_USER="${!i#*=}"; ((init_shift_count++)) ;;
--allow-home) init_allow_home=true; ((init_shift_count++)) ;; --allow-home) init_allow_home=true; ((init_shift_count++)) ;;
@ -1910,6 +2000,12 @@ if [[ $EUID -ne 0 ]]; then
exit 1 exit 1
else else
if [[ "$param1" == "init" ]]; then if [[ "$param1" == "init" ]]; then
# Ask where app data goes before anything is validated or created.
# No-op when unattended, when --containers-dir was passed, or when
# there is only one possible answer.
initPickContainersDir
libreportalDerivePaths
# Validate the chosen install roots before creating/baking anything. # Validate the chosen install roots before creating/baking anything.
libreportalValidatePaths libreportalValidatePaths