LibrePortal/scripts/storage/storage_disks.sh
librelad 0de7f40f5d feat(storage): registry helper, fitness checks, CLI and the Disks view
Phase 1 of docs/roadmap/storage-locations.md — locations can now exist.
Nothing places an app on one yet; that is phase 2.

libreportal-storage is the only writer of the root-owned registry, and
its admission rules are what make adding a location safe: absolute and
canonical (a symlinked path is refused), outside the protected system
set, non-nesting with any known root in either direction, and EMPTY — or
already carrying our marker, which is the adopt case for a drive that
already holds app data. Root only ever chowns an empty directory, so
acceptance cannot hand away anything that existed. The parent must also
not be manager-writable, which is what closes the validate-then-chown
race; /mnt and /srv qualify, a path inside the manager's home does not.

The fitness checks answer a different question — "will app data actually
work here" — and grade rather than refuse. Only checks 1-5 (filesystem
type, mount options, ownership, sub-UID range, write/read-back) can block.
Reboot persistence and removability warn, because both describe supported
setups and start-up is already gated by the marker test.

The ownership probe had to move into the root helper. For a candidate the
directory is not ours yet — a fresh /mnt/disk is root-owned 0755 — so an
unprivileged probe could only ever report "cannot create a directory
here", which says nothing about the filesystem. Verified against a real
loopback ext4: it now reports ownership, sub-UID and read-back cleanly.

The Disks view unions the registry with attached hardware, registry
first. Verified on the live box that pulling a drive leaves its row in
place as not-attached, naming the app stranded on it, rather than the row
silently disappearing at exactly the moment someone needs it.

Also registers storage_scripts with both loaders, adds the CLI category
(auto-dispatched by cli_initialize.sh), and bumps footprint_version to 6
for the new root helper and the widened sudoers allowlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:30:46 +01:00

169 lines
7.6 KiB
Bash

#!/bin/bash
# The Disks view — one row per filesystem, showing what LibrePortal does with it.
#
# This is the answer to "storage location" and "backup location" being two
# competing top-level nouns: the device becomes the organising concept and the
# registries become ROLES on it. See §7.1 of docs/roadmap/storage-locations.md.
#
# The union is the part that matters more than the enrichment. A registered
# location whose drive is unplugged does not appear in findmnt or lsblk at all —
# and that is precisely when someone looks at this page. So rows come from the
# REGISTRY first and attached hardware second: a missing device still renders,
# marked not-attached, naming the apps stranded on it. A row vanishing when the
# disk is pulled is the one failure this view cannot have.
# Emit one TSV record per row:
# <key> <mount> <source> <fstype> <size> <free> <removable> <state> <roles> <apps>
#
# key fs UUID when known, else the mount point (device names reorder across
# reboots and would scramble the table)
# state ok | not-attached
# roles comma-separated: system, primary, storage:<id>, backups:<idx>
# apps comma-separated app slugs on this filesystem ("-" when none/unknown)
storageDisksData()
{
local -A row_mount=() row_source=() row_fstype=() row_size=() row_free=()
local -A row_rm=() row_state=() row_roles=() row_apps=() row_order=()
local order=0
_addRole() {
local k="$1" r="$2"
if [[ -z "${row_roles[$k]:-}" ]]; then row_roles["$k"]="$r"
elif [[ ",${row_roles[$k]}," != *",$r,"* ]]; then row_roles["$k"]="${row_roles[$k]},$r"; fi
}
# ---- 1. attached filesystems -------------------------------------------
local target source fstype size avail uuid key line kv val
if command -v findmnt >/dev/null 2>&1; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
target=""; source=""; fstype=""; size=""; avail=""; uuid=""
for kv in TARGET SOURCE FSTYPE SIZE AVAIL UUID; 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" ;; UUID) uuid="$val" ;;
esac
done
[[ -z "$target" ]] && continue
_storageSkipTarget "$target" && continue
[[ "$fstype" =~ ^($_STORAGE_SKIP_FSTYPES)$ ]] && continue
key="${uuid:-$target}"
[[ -n "${row_mount[$key]:-}" ]] && continue
row_order["$key"]=$((order++))
row_mount["$key"]="$target"; row_source["$key"]="$source"
row_fstype["$key"]="$fstype"; row_size["$key"]="$size"
row_free["$key"]="$avail"; row_state["$key"]="ok"
row_rm["$key"]=0
if [[ "$source" == /dev/* ]] && command -v lsblk >/dev/null 2>&1; then
local r h
r=$(lsblk -no RM "$source" 2>/dev/null | head -1 | tr -d ' ')
h=$(lsblk -no HOTPLUG "$source" 2>/dev/null | head -1 | tr -d ' ')
[[ "$r" == "1" || "$h" == "1" ]] && row_rm["$key"]=1
fi
done < <(findmnt -Pno TARGET,SOURCE,FSTYPE,SIZE,AVAIL,UUID 2>/dev/null)
fi
# Map a path to the row that holds it (by mount point, longest match wins).
_rowFor() {
local p="${1%/}" best="" best_len=0 k m
for k in "${!row_mount[@]}"; do
m="${row_mount[$k]%/}"; [[ -z "$m" ]] && m="/"
if [[ "$p" == "$m" || "$p" == "$m/"* || "$m" == "/" ]]; then
(( ${#m} >= best_len )) && { best="$k"; best_len=${#m}; }
fi
done
printf '%s' "$best"
}
# ---- 2. roles ------------------------------------------------------------
local k
k=$(_rowFor "/"); [[ -n "$k" ]] && _addRole "$k" "system"
k=$(_rowFor "$(primaryRoot)"); [[ -n "$k" ]] && _addRole "$k" "primary"
# Backup locations
if declare -f resticEnabledLocations >/dev/null 2>&1 \
&& declare -f backupLocationResolvedPath >/dev/null 2>&1; then
local idx bpath probe
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
bpath=$(backupLocationResolvedPath "$idx" 2>/dev/null); bpath="${bpath%/}"
[[ -z "$bpath" ]] && continue
probe=$(_storageProbeDir "$bpath")
k=$(_rowFor "$probe"); [[ -n "$k" ]] && _addRole "$k" "backups:$idx"
done < <(resticEnabledLocations 2>/dev/null)
fi
# ---- 3. registered storage locations, INCLUDING absent ones -------------
local _id _path _rest
if [[ -r "$lp_storage_registry" ]]; then
while IFS=$'\t' read -r _id _path _rest || [[ -n "$_id" ]]; do
[[ -z "$_path" || "$_id" == \#* ]] && continue
_path="${_path%/}"
if storageRootAvailable "$_path"; then
k=$(_rowFor "$_path")
[[ -n "$k" ]] && { _addRole "$k" "storage:$_id"; row_apps["$k"]="$(storageAppsOnRoot "$_path" | paste -sd, -)"; }
else
# Not attached: synthesise the row from the registry so it still
# appears, and name what is stranded on it.
key="absent:$_id"
row_order["$key"]=$((order++))
row_mount["$key"]="$_path"; row_source["$key"]="-"
row_fstype["$key"]="-"; row_size["$key"]="-"
row_free["$key"]="-"; row_rm["$key"]=0
row_state["$key"]="not-attached"
_addRole "$key" "storage:$_id"
local stranded=""
while IFS=$'\t' read -r _s _r; do
[[ "${_r%/}" == "$_path" ]] && stranded+="${stranded:+,}$_s"
done < <(cat "$(storageIndexFile)" 2>/dev/null)
row_apps["$key"]="${stranded:--}"
fi
done < "$lp_storage_registry"
fi
# Apps on the primary root
k=$(_rowFor "$(primaryRoot)")
[[ -n "$k" && -z "${row_apps[$k]:-}" ]] && row_apps["$k"]="$(storageAppsOnRoot "$(primaryRoot)" | paste -sd, -)"
# ---- 4. emit, in discovery order ----------------------------------------
local sorted
sorted=$(for k in "${!row_order[@]}"; do printf '%s\t%s\n' "${row_order[$k]}" "$k"; done | sort -n | cut -f2)
while IFS= read -r k; do
[[ -z "$k" ]] && continue
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$k" "${row_mount[$k]}" "${row_source[$k]}" "${row_fstype[$k]}" \
"${row_size[$k]}" "${row_free[$k]}" "${row_rm[$k]}" \
"${row_state[$k]}" "${row_roles[$k]:--}" "${row_apps[$k]:--}"
done <<< "$sorted"
unset -f _addRole _rowFor
}
# Human-readable Disks view.
storageDisks()
{
local key mount source fstype size free rm_flag state roles apps
isHeader "Disks"
printf '%-24s %-9s %-8s %-8s %-22s %-14s %s\n' \
"MOUNT" "FS" "SIZE" "FREE" "ROLES" "STATE" "APPS"
while IFS=$'\t' read -r key mount source fstype size free rm_flag state roles apps; do
[[ -z "$key" ]] && continue
local shown_state="$state"
[[ "$rm_flag" == "1" ]] && shown_state="$state, removable"
printf '%-24s %-9s %-8s %-8s %-22s %-14s %s\n' \
"$mount" "$fstype" "$size" "$free" "$roles" "$shown_state" "$apps"
done < <(storageDisksData)
echo ""
isNotice "A location shown as 'not-attached' is registered but its drive is not mounted."
isNotice "Apps listed against it will refuse to start until it is back — deliberately,"
isNotice "so they are never rebuilt empty on the bare mount point."
}