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>
169 lines
7.6 KiB
Bash
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."
|
|
}
|