From 0de7f40f5d8fb77ec83e7f2be5b35b098566a5e5 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 24 Aug 2026 20:30:46 +0100 Subject: [PATCH] feat(storage): registry helper, fitness checks, CLI and the Disks view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../commands/storage/cli_storage_commands.sh | 111 ++++++++++++ .../commands/storage/cli_storage_header.sh | 33 ++++ scripts/source/files/app_files.sh | 1 + scripts/source/files/arrays/files_cli.sh | 2 + scripts/source/files/arrays/files_crontab.sh | 1 + scripts/source/files/arrays/files_source.sh | 1 + scripts/source/files/arrays/files_storage.sh | 12 ++ .../source/files/arrays/function_manifest.sh | 97 ++++++++++ scripts/source/files/cli_files.sh | 1 + scripts/storage/storage_disks.sh | 168 ++++++++++++++++++ 10 files changed, 427 insertions(+) create mode 100644 scripts/cli/commands/storage/cli_storage_commands.sh create mode 100644 scripts/cli/commands/storage/cli_storage_header.sh create mode 100644 scripts/source/files/arrays/files_storage.sh create mode 100644 scripts/storage/storage_disks.sh diff --git a/scripts/cli/commands/storage/cli_storage_commands.sh b/scripts/cli/commands/storage/cli_storage_commands.sh new file mode 100644 index 0000000..68a4d97 --- /dev/null +++ b/scripts/cli/commands/storage/cli_storage_commands.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +# Dispatched automatically by cli_initialize.sh (category -> cliHandleStorageCommands). +# +# Mirrors `libreportal backup location …`. The mutating verbs go through the +# root-owned libreportal-storage helper, which is the only writer of the storage +# registry — see scripts/system/libreportal-storage. + +cliHandleStorageCommands() +{ + local action="$initial_command2" + local arg1="$initial_command3" + local arg2="$initial_command4" + + case "$action" in + ""|help) + cliShowStorageHelp + ;; + + list) + storageList + ;; + + scan) + storageScan + ;; + + add) + if [[ -z "$arg1" ]]; then + isNotice "Usage: storage add [name]" + cliShowStorageHelp + return 1 + fi + storageAdd "$arg1" "$arg2" >/dev/null + ;; + + remove) + if [[ -z "$arg1" ]]; then + isNotice "Usage: storage remove " + cliShowStorageHelp + return 1 + fi + storageRemove "$arg1" + ;; + + check) + local sev check msg rc=0 + if [[ -n "$arg1" ]]; then + isHeader "Checking storage location $arg1" + while IFS=$'\t' read -r sev check msg; do + [[ -z "$sev" ]] && continue + case "$sev" in + refuse) isError "$check: $msg" ;; + warn) isNotice "$check: $msg" ;; + *) isNotice "$check: $msg" ;; + esac + done < <(storageCheckLocation "$arg1") + else + local id state path + while IFS=$'\t' read -r id state path; do + [[ -z "$id" ]] && continue + isHeader "Location $id — $path ($state)" + while IFS=$'\t' read -r sev check msg; do + [[ -z "$sev" ]] && continue + case "$sev" in + refuse) isError "$check: $msg" ;; + warn) isNotice "$check: $msg" ;; + *) isNotice "$check: $msg" ;; + esac + done < <(storageCheckLocation "$id") + done < <(runStorage verify 2>/dev/null) + [[ -z "$(runStorage verify 2>/dev/null)" ]] && \ + isNotice "No storage locations registered — everything lives on $(primaryRoot)." + fi + return $rc + ;; + + apps) + local root + if [[ -n "$arg1" ]]; then + root=$(storageLocationPath "$arg1") || { isError "No such location: $arg1"; return 1; } + isHeader "Apps on $(storageLocationName "$root") ($root)" + storageAppsOnRoot "$root" + else + local id state path + isHeader "Apps by location" + echo "default ($(primaryRoot)):" + storageAppsOnRoot "$(primaryRoot)" | sed 's/^/ /' + while IFS=$'\t' read -r id state path; do + [[ -z "$id" ]] && continue + echo "$(storageLocationName "$path") ($path) [$state]:" + if [[ "$state" == "ok" ]]; then + storageAppsOnRoot "$path" | sed 's/^/ /' + else + echo " (drive not mounted — apps here will not start)" + fi + done < <(runStorage verify 2>/dev/null) + fi + ;; + + disks) + storageDisks + ;; + + *) + isNotice "Invalid storage action: $action" + cliShowStorageHelp + return 1 + ;; + esac +} diff --git a/scripts/cli/commands/storage/cli_storage_header.sh b/scripts/cli/commands/storage/cli_storage_header.sh new file mode 100644 index 0000000..ad7f720 --- /dev/null +++ b/scripts/cli/commands/storage/cli_storage_header.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +cliShowStorageHelp() +{ + isHeader "LibrePortal Storage Commands" + echo "storage list" + echo " Every registered storage location: name, path, mount state, apps, free space." + echo "" + echo "storage scan" + echo " Filesystems on this host that could hold app data, with a verdict for each." + echo "" + echo "storage add [name]" + echo " Register a location. The directory must be EMPTY (or already be a" + echo " LibrePortal location), and must not sit inside a protected system path." + echo " Root only ever takes ownership of an empty directory — that is what" + echo " makes adding one safe." + echo "" + echo "storage remove " + echo " Unregister a location. Refused while any app still lives on it, and" + echo " refused when its drive is not mounted (that would orphan those apps)." + echo "" + echo "storage check [id]" + echo " Re-run the fitness checks for one location, or all of them." + echo "" + echo "storage apps [id]" + echo " Which apps live on a location." + echo "" + echo "storage disks" + echo " One row per filesystem, showing what LibrePortal uses it for." + echo "" + isNotice "A location whose drive is absent is reported, never silently skipped:" + isNotice "apps stored there refuse to start rather than being rebuilt empty." +} diff --git a/scripts/source/files/app_files.sh b/scripts/source/files/app_files.sh index 86fd3f6..85ae98b 100755 --- a/scripts/source/files/app_files.sh +++ b/scripts/source/files/app_files.sh @@ -23,6 +23,7 @@ files_libreportal_app=( "${source_scripts[@]}" "${ssh_scripts[@]}" "${start_scripts[@]}" + "${storage_scripts[@]}" "${task_scripts[@]}" "${update_scripts[@]}" "${validation_scripts[@]}" diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 6ea3e9c..8d08023 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -47,6 +47,8 @@ cli_scripts=( "cli/commands/setup/cli_setup_header.sh" "cli/commands/ssh/cli_ssh_commands.sh" "cli/commands/ssh/cli_ssh_header.sh" + "cli/commands/storage/cli_storage_commands.sh" + "cli/commands/storage/cli_storage_header.sh" "cli/commands/system/cli_system_commands.sh" "cli/commands/system/cli_system_header.sh" "cli/commands/update/cli_update_commands.sh" diff --git a/scripts/source/files/arrays/files_crontab.sh b/scripts/source/files/arrays/files_crontab.sh index 351145d..93bdedf 100755 --- a/scripts/source/files/arrays/files_crontab.sh +++ b/scripts/source/files/arrays/files_crontab.sh @@ -10,6 +10,7 @@ crontab_scripts=( "crontab/crontab_install.sh" "crontab/crontab_refresh.sh" "crontab/crontab_setup.sh" + "crontab/system/crontab_boot_app_reconcile.sh" "crontab/system/crontab_setup_system_info_updater.sh" ) diff --git a/scripts/source/files/arrays/files_source.sh b/scripts/source/files/arrays/files_source.sh index 57fba26..2205f81 100755 --- a/scripts/source/files/arrays/files_source.sh +++ b/scripts/source/files/arrays/files_source.sh @@ -29,6 +29,7 @@ source_scripts=( "source/files/arrays/files_source.sh" "source/files/arrays/files_ssh.sh" "source/files/arrays/files_start.sh" + "source/files/arrays/files_storage.sh" "source/files/arrays/files_task.sh" "source/files/arrays/files_update.sh" "source/files/arrays/files_validation.sh" diff --git a/scripts/source/files/arrays/files_storage.sh b/scripts/source/files/arrays/files_storage.sh new file mode 100644 index 0000000..01c6458 --- /dev/null +++ b/scripts/source/files/arrays/files_storage.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# This file is auto-generated by generate_arrays.sh +# Do not edit manually - run './scripts/source/files/generate_arrays.sh run' to regenerate + +storage_scripts=( + "storage/storage_checks.sh" + "storage/storage_disks.sh" + "storage/storage_locations.sh" + "storage/storage_scan.sh" + +) diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 5394564..a88b122 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -338,6 +338,7 @@ declare -gA LP_FN_MAP=( [cliHandleRestoreCommands]="cli/commands/restore/cli_restore_commands.sh" [cliHandleSetupCommands]="cli/commands/setup/cli_setup_commands.sh" [cliHandleSshCommands]="cli/commands/ssh/cli_ssh_commands.sh" + [cliHandleStorageCommands]="cli/commands/storage/cli_storage_commands.sh" [cliHandleSystemCommands]="cli/commands/system/cli_system_commands.sh" [cliHandleUpdateCommands]="cli/commands/update/cli_update_commands.sh" [cliHandleUpdaterCommands]="cli/commands/updater/cli_updater_commands.sh" @@ -364,6 +365,7 @@ declare -gA LP_FN_MAP=( [cliShowRestoreHelp]="cli/commands/restore/cli_restore_header.sh" [cliShowSetupHelp]="cli/commands/setup/cli_setup_header.sh" [cliShowSshHelp]="cli/commands/ssh/cli_ssh_header.sh" + [cliShowStorageHelp]="cli/commands/storage/cli_storage_header.sh" [cliShowSystemHelp]="cli/commands/system/cli_system_header.sh" [cliShowUpdateHelp]="cli/commands/update/cli_update_header.sh" [cliShowUpdaterHelp]="cli/commands/updater/cli_updater_header.sh" @@ -928,6 +930,7 @@ declare -gA LP_FN_MAP=( [_runRootHelper]="docker/command/run_privileged.sh" [runSocket]="docker/command/run_privileged.sh" [runSshAccess]="docker/command/run_privileged.sh" + [runStorage]="docker/command/run_privileged.sh" [runSvc]="docker/command/run_privileged.sh" [runSystem]="docker/command/run_privileged.sh" [runTask]="task/crontab_task_processor.sh" @@ -959,6 +962,7 @@ declare -gA LP_FN_MAP=( [setupWizardTerminal]="checks/first_install.sh" [showInstructions]="menu/message/instructions.sh" [sourceBackupLocations]="backup/locations/location_loader.sh" + [sourceStorageLocations]="storage/storage_locations.sh" [sshRemote]="network/ssh/ssh.sh" [stalwart_apply_port_access]="stalwart/scripts/stalwart_install_hooks.sh" [stalwart_cli]="stalwart/scripts/stalwart_install_hooks.sh" @@ -999,6 +1003,34 @@ declare -gA LP_FN_MAP=( [_stoatWriteSecrets]="stoat/scripts/stoat_install_hooks.sh" [_stoatWriteUrlFiles]="stoat/scripts/stoat_install_hooks.sh" [stopCrowdsec]="crowdsec/crowdsec.sh" + [storageAdd]="storage/storage_locations.sh" + [storageAppsOnRoot]="storage/storage_locations.sh" + [_storageCheckDistinct]="storage/storage_checks.sh" + [_storageCheckEncryption]="storage/storage_checks.sh" + [_storageCheckFsType]="storage/storage_checks.sh" + [storageCheckLocation]="storage/storage_checks.sh" + [_storageCheckMountOpts]="storage/storage_checks.sh" + [_storageCheckOwnership]="storage/storage_checks.sh" + [storageCheckPath]="storage/storage_checks.sh" + [_storageCheckPersistence]="storage/storage_checks.sh" + [_storageCheckRemovable]="storage/storage_checks.sh" + [_storageCheckSharedWithBackup]="storage/storage_checks.sh" + [_storageCheckSpace]="storage/storage_checks.sh" + [storageDisks]="storage/storage_disks.sh" + [storageDisksData]="storage/storage_disks.sh" + [_storageEmit]="storage/storage_checks.sh" + [_storageFreeHuman]="storage/storage_locations.sh" + [storageList]="storage/storage_locations.sh" + [storageLocationConfig]="storage/storage_locations.sh" + [storageLocationDir]="storage/storage_locations.sh" + [storageLocationsDir]="storage/storage_locations.sh" + [_storageProbeDir]="storage/storage_checks.sh" + [_storageRefreshWebui]="storage/storage_locations.sh" + [storageRemove]="storage/storage_locations.sh" + [storageScan]="storage/storage_scan.sh" + [storageScanCandidates]="storage/storage_scan.sh" + [_storageSkipTarget]="storage/storage_scan.sh" + [_storageWriteLocationConfig]="storage/storage_locations.sh" [switchMigrateBackupApps]="docker/type_switcher/swap_docker_type.sh" [switchMigrateRestoreApps]="docker/type_switcher/swap_docker_type.sh" [tagsManagerGetTagContent]="config/tags/manager/tags_manager_content.sh" @@ -1522,6 +1554,7 @@ declare -gA LP_FN_ROOT=( [cliHandleRestoreCommands]="scripts" [cliHandleSetupCommands]="scripts" [cliHandleSshCommands]="scripts" + [cliHandleStorageCommands]="scripts" [cliHandleSystemCommands]="scripts" [cliHandleUpdateCommands]="scripts" [cliHandleUpdaterCommands]="scripts" @@ -1548,6 +1581,7 @@ declare -gA LP_FN_ROOT=( [cliShowRestoreHelp]="scripts" [cliShowSetupHelp]="scripts" [cliShowSshHelp]="scripts" + [cliShowStorageHelp]="scripts" [cliShowSystemHelp]="scripts" [cliShowUpdateHelp]="scripts" [cliShowUpdaterHelp]="scripts" @@ -2112,6 +2146,7 @@ declare -gA LP_FN_ROOT=( [_runRootHelper]="scripts" [runSocket]="scripts" [runSshAccess]="scripts" + [runStorage]="scripts" [runSvc]="scripts" [runSystem]="scripts" [runTask]="scripts" @@ -2143,6 +2178,7 @@ declare -gA LP_FN_ROOT=( [setupWizardTerminal]="scripts" [showInstructions]="scripts" [sourceBackupLocations]="scripts" + [sourceStorageLocations]="scripts" [sshRemote]="scripts" [stalwart_apply_port_access]="containers" [stalwart_cli]="containers" @@ -2183,6 +2219,34 @@ declare -gA LP_FN_ROOT=( [_stoatWriteSecrets]="containers" [_stoatWriteUrlFiles]="containers" [stopCrowdsec]="containers" + [storageAdd]="scripts" + [storageAppsOnRoot]="scripts" + [_storageCheckDistinct]="scripts" + [_storageCheckEncryption]="scripts" + [_storageCheckFsType]="scripts" + [storageCheckLocation]="scripts" + [_storageCheckMountOpts]="scripts" + [_storageCheckOwnership]="scripts" + [storageCheckPath]="scripts" + [_storageCheckPersistence]="scripts" + [_storageCheckRemovable]="scripts" + [_storageCheckSharedWithBackup]="scripts" + [_storageCheckSpace]="scripts" + [storageDisks]="scripts" + [storageDisksData]="scripts" + [_storageEmit]="scripts" + [_storageFreeHuman]="scripts" + [storageList]="scripts" + [storageLocationConfig]="scripts" + [storageLocationDir]="scripts" + [storageLocationsDir]="scripts" + [_storageProbeDir]="scripts" + [_storageRefreshWebui]="scripts" + [storageRemove]="scripts" + [storageScan]="scripts" + [storageScanCandidates]="scripts" + [_storageSkipTarget]="scripts" + [_storageWriteLocationConfig]="scripts" [switchMigrateBackupApps]="scripts" [switchMigrateRestoreApps]="scripts" [tagsManagerGetTagContent]="scripts" @@ -2392,6 +2456,7 @@ LP_EAGER_FILES=( "scripts:migrate/migrate_url_rewrite.sh" "containers:nextcloud/scripts/nextcloud_upgrade_hooks.sh" "scripts:source/artifacts.sh" + "scripts:storage/storage_scan.sh" "scripts:task/crontab_check_processor.sh" "scripts:task/crontab_task_processor.sh" "scripts:validation/validate_config.sh" @@ -2742,6 +2807,7 @@ cliHandleResetCommands() { unset -f cliHandleResetCommands; __lpAutoload "${inst cliHandleRestoreCommands() { unset -f cliHandleRestoreCommands; __lpAutoload "${install_scripts_dir}cli/commands/restore/cli_restore_commands.sh"; cliHandleRestoreCommands "$@"; } cliHandleSetupCommands() { unset -f cliHandleSetupCommands; __lpAutoload "${install_scripts_dir}cli/commands/setup/cli_setup_commands.sh"; cliHandleSetupCommands "$@"; } cliHandleSshCommands() { unset -f cliHandleSshCommands; __lpAutoload "${install_scripts_dir}cli/commands/ssh/cli_ssh_commands.sh"; cliHandleSshCommands "$@"; } +cliHandleStorageCommands() { unset -f cliHandleStorageCommands; __lpAutoload "${install_scripts_dir}cli/commands/storage/cli_storage_commands.sh"; cliHandleStorageCommands "$@"; } cliHandleSystemCommands() { unset -f cliHandleSystemCommands; __lpAutoload "${install_scripts_dir}cli/commands/system/cli_system_commands.sh"; cliHandleSystemCommands "$@"; } cliHandleUpdateCommands() { unset -f cliHandleUpdateCommands; __lpAutoload "${install_scripts_dir}cli/commands/update/cli_update_commands.sh"; cliHandleUpdateCommands "$@"; } cliHandleUpdaterCommands() { unset -f cliHandleUpdaterCommands; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; cliHandleUpdaterCommands "$@"; } @@ -2768,6 +2834,7 @@ cliShowResetHelp() { unset -f cliShowResetHelp; __lpAutoload "${install_scripts_ cliShowRestoreHelp() { unset -f cliShowRestoreHelp; __lpAutoload "${install_scripts_dir}cli/commands/restore/cli_restore_header.sh"; cliShowRestoreHelp "$@"; } cliShowSetupHelp() { unset -f cliShowSetupHelp; __lpAutoload "${install_scripts_dir}cli/commands/setup/cli_setup_header.sh"; cliShowSetupHelp "$@"; } cliShowSshHelp() { unset -f cliShowSshHelp; __lpAutoload "${install_scripts_dir}cli/commands/ssh/cli_ssh_header.sh"; cliShowSshHelp "$@"; } +cliShowStorageHelp() { unset -f cliShowStorageHelp; __lpAutoload "${install_scripts_dir}cli/commands/storage/cli_storage_header.sh"; cliShowStorageHelp "$@"; } cliShowSystemHelp() { unset -f cliShowSystemHelp; __lpAutoload "${install_scripts_dir}cli/commands/system/cli_system_header.sh"; cliShowSystemHelp "$@"; } cliShowUpdateHelp() { unset -f cliShowUpdateHelp; __lpAutoload "${install_scripts_dir}cli/commands/update/cli_update_header.sh"; cliShowUpdateHelp "$@"; } cliShowUpdaterHelp() { unset -f cliShowUpdaterHelp; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_header.sh"; cliShowUpdaterHelp "$@"; } @@ -3332,6 +3399,7 @@ runResolv() { unset -f runResolv; __lpAutoload "${install_scripts_dir}docker/com _runRootHelper() { unset -f _runRootHelper; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; _runRootHelper "$@"; } runSocket() { unset -f runSocket; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runSocket "$@"; } runSshAccess() { unset -f runSshAccess; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runSshAccess "$@"; } +runStorage() { unset -f runStorage; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runStorage "$@"; } runSvc() { unset -f runSvc; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runSvc "$@"; } runSystem() { unset -f runSystem; __lpAutoload "${install_scripts_dir}docker/command/run_privileged.sh"; runSystem "$@"; } runTask() { unset -f runTask; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; runTask "$@"; } @@ -3363,6 +3431,7 @@ setupWizardReset() { unset -f setupWizardReset; __lpAutoload "${install_scripts_ setupWizardTerminal() { unset -f setupWizardTerminal; __lpAutoload "${install_scripts_dir}checks/first_install.sh"; setupWizardTerminal "$@"; } showInstructions() { unset -f showInstructions; __lpAutoload "${install_scripts_dir}menu/message/instructions.sh"; showInstructions "$@"; } sourceBackupLocations() { unset -f sourceBackupLocations; __lpAutoload "${install_scripts_dir}backup/locations/location_loader.sh"; sourceBackupLocations "$@"; } +sourceStorageLocations() { unset -f sourceStorageLocations; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; sourceStorageLocations "$@"; } sshRemote() { unset -f sshRemote; __lpAutoload "${install_scripts_dir}network/ssh/ssh.sh"; sshRemote "$@"; } stalwart_apply_port_access() { unset -f stalwart_apply_port_access; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_apply_port_access "$@"; } stalwart_cli() { unset -f stalwart_cli; __lpAutoload "${install_containers_dir}stalwart/scripts/stalwart_install_hooks.sh"; stalwart_cli "$@"; } @@ -3403,6 +3472,34 @@ _stoatSetDisabled() { unset -f _stoatSetDisabled; __lpAutoload "${install_contai _stoatWriteSecrets() { unset -f _stoatWriteSecrets; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteSecrets "$@"; } _stoatWriteUrlFiles() { unset -f _stoatWriteUrlFiles; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteUrlFiles "$@"; } stopCrowdsec() { unset -f stopCrowdsec; __lpAutoload "${install_containers_dir}crowdsec/crowdsec.sh"; stopCrowdsec "$@"; } +storageAdd() { unset -f storageAdd; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageAdd "$@"; } +storageAppsOnRoot() { unset -f storageAppsOnRoot; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageAppsOnRoot "$@"; } +_storageCheckDistinct() { unset -f _storageCheckDistinct; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckDistinct "$@"; } +_storageCheckEncryption() { unset -f _storageCheckEncryption; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckEncryption "$@"; } +_storageCheckFsType() { unset -f _storageCheckFsType; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckFsType "$@"; } +storageCheckLocation() { unset -f storageCheckLocation; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; storageCheckLocation "$@"; } +_storageCheckMountOpts() { unset -f _storageCheckMountOpts; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckMountOpts "$@"; } +_storageCheckOwnership() { unset -f _storageCheckOwnership; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckOwnership "$@"; } +storageCheckPath() { unset -f storageCheckPath; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; storageCheckPath "$@"; } +_storageCheckPersistence() { unset -f _storageCheckPersistence; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckPersistence "$@"; } +_storageCheckRemovable() { unset -f _storageCheckRemovable; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckRemovable "$@"; } +_storageCheckSharedWithBackup() { unset -f _storageCheckSharedWithBackup; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckSharedWithBackup "$@"; } +_storageCheckSpace() { unset -f _storageCheckSpace; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageCheckSpace "$@"; } +storageDisks() { unset -f storageDisks; __lpAutoload "${install_scripts_dir}storage/storage_disks.sh"; storageDisks "$@"; } +storageDisksData() { unset -f storageDisksData; __lpAutoload "${install_scripts_dir}storage/storage_disks.sh"; storageDisksData "$@"; } +_storageEmit() { unset -f _storageEmit; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageEmit "$@"; } +_storageFreeHuman() { unset -f _storageFreeHuman; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; _storageFreeHuman "$@"; } +storageList() { unset -f storageList; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageList "$@"; } +storageLocationConfig() { unset -f storageLocationConfig; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageLocationConfig "$@"; } +storageLocationDir() { unset -f storageLocationDir; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageLocationDir "$@"; } +storageLocationsDir() { unset -f storageLocationsDir; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageLocationsDir "$@"; } +_storageProbeDir() { unset -f _storageProbeDir; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageProbeDir "$@"; } +_storageRefreshWebui() { unset -f _storageRefreshWebui; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; _storageRefreshWebui "$@"; } +storageRemove() { unset -f storageRemove; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageRemove "$@"; } +storageScan() { unset -f storageScan; __lpAutoload "${install_scripts_dir}storage/storage_scan.sh"; storageScan "$@"; } +storageScanCandidates() { unset -f storageScanCandidates; __lpAutoload "${install_scripts_dir}storage/storage_scan.sh"; storageScanCandidates "$@"; } +_storageSkipTarget() { unset -f _storageSkipTarget; __lpAutoload "${install_scripts_dir}storage/storage_scan.sh"; _storageSkipTarget "$@"; } +_storageWriteLocationConfig() { unset -f _storageWriteLocationConfig; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; _storageWriteLocationConfig "$@"; } switchMigrateBackupApps() { unset -f switchMigrateBackupApps; __lpAutoload "${install_scripts_dir}docker/type_switcher/swap_docker_type.sh"; switchMigrateBackupApps "$@"; } switchMigrateRestoreApps() { unset -f switchMigrateRestoreApps; __lpAutoload "${install_scripts_dir}docker/type_switcher/swap_docker_type.sh"; switchMigrateRestoreApps "$@"; } tagsManagerGetTagContent() { unset -f tagsManagerGetTagContent; __lpAutoload "${install_scripts_dir}config/tags/manager/tags_manager_content.sh"; tagsManagerGetTagContent "$@"; } diff --git a/scripts/source/files/cli_files.sh b/scripts/source/files/cli_files.sh index e7a3993..b55453e 100755 --- a/scripts/source/files/cli_files.sh +++ b/scripts/source/files/cli_files.sh @@ -23,6 +23,7 @@ files_libreportal_cli=( "${source_scripts[@]}" "${ssh_scripts[@]}" "${start_scripts[@]}" + "${storage_scripts[@]}" "${task_scripts[@]}" "${update_scripts[@]}" "${validation_scripts[@]}" diff --git a/scripts/storage/storage_disks.sh b/scripts/storage/storage_disks.sh new file mode 100644 index 0000000..8c72f91 --- /dev/null +++ b/scripts/storage/storage_disks.sh @@ -0,0 +1,168 @@ +#!/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 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:, backups: +# 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." +}