LibrePortal/scripts/app/install/app_install.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

235 lines
11 KiB
Bash

#!/bin/bash
# Generic per-app install/uninstall/start/stop/restart/edit driver.
#
# The 31 containers/<app>/<app>.sh files used to each define their own
# install<App>() with the SAME 10-step sequence. ~4,000 lines of duplicated
# boilerplate. This is the one place that sequence lives now; per-app
# customisation lands in declarative hooks in containers/<app>/tools/
# <app>_tools.sh (or wherever the app's tools.sh lives — auto-sourced).
#
# Dispatch is driven by the `$<slug>` global variable (set by dockerInstallApp
# in scripts/docker/app/functions/function_install_app.sh — `declare $app=i`).
# Same convention the per-app .sh files used; nothing changes for the caller.
# Actions are letters: c (config edit), u (uninstall), s (stop), r (restart),
# i (install), t (treated like c — legacy alias).
#
# Hook surface — all are `declare -f`-gated, silent no-op when absent:
#
# <slug>_install_pre before any install work. THE ONE HOOK WHOSE
# RETURN VALUE COUNTS — return non-zero and
# the install stops here (see CFG_<APP>_REQUIRES)
# <slug>_install_post_setup after dockerConfigSetupToContainer
# (install folder + .config exist; compose
# file not yet written)
# <slug>_install_post_compose after dockerComposeSetupFile (the compose
# TEMPLATE has been copied into place;
# container not yet up). NOTE: the tags are
# NOT substituted yet — IPs, ports and
# #LIBREPORTAL values are filled later, by
# dockerConfigSetupFileWithData during
# dockerComposeUpdateAndStartApp. A hook that
# needs a settled value must read it from
# CFG_<APP>_* / the port arrays in scope, not
# by grepping the deployed compose.
# <slug>_install_post_start after dockerComposeUpdateAndStartApp
# (container is up; the place for
# wait-for-ready + post-up API calls)
# <slug>_install_message_data echoes extra args for menuShowFinalMessages
# (typically credentials / URLs)
# <slug>_install_post very last thing, after the final message
#
# <slug>_uninstall_pre / _post around dockerUninstallApp
# <slug>_stop_post after dockerComposeDown
# <slug>_restart_post after dockerComposeRestart
#
# Hooks receive $app_name as $1 (and stay un-namespaced — they're already
# slug-prefixed). Return code is ignored unless they isError; the install
# continues regardless. Use that escape hatch for non-fatal app-specific
# refinements (rotate a key, patch a yaml after start, etc.).
# Returns the hook's own exit status when it ran, and 0 when no such hook
# exists. The explicit `return 0` matters: without it an absent hook returns the
# status of the failed `declare -F` test, i.e. non-zero, and any caller that
# gates on the result would treat "app has no hook" as "hook failed".
_appCallHook()
{
local hook_name="$1"; shift
if declare -F "$hook_name" >/dev/null 2>&1; then
"$hook_name" "$@"
return $?
fi
return 0
}
# Standard "post-start integration" steps. Same for every app. Lives in a
# helper so the generic install body stays readable; safe for apps that
# don't tag for monitoring (the helpers no-op gracefully).
_appPostStartIntegrations()
{
local app_name="$1"
appUpdateSpecifics "$app_name"
setupHeadscale "$app_name"
databaseInstallApp "$app_name"
webuiContainerSetup "$app_name" install
# Scrape-target + dashboard re-gather. The compose-level toggle ran
# already (post-compose, so the running container reflects it).
# monitoringRefreshAll is self-correcting and no-ops when Prometheus
# / Grafana aren't installed.
if declare -F monitoringRefreshAll >/dev/null 2>&1; then
monitoringRefreshAll 2>/dev/null || true
fi
}
installApp()
{
local app_slug="$1"
local config_variables="$2"
# APP_NAME comes from the app's CFG_<APP>_APP_NAME (the user's chosen
# subdomain / install name). Fall back to the slug if unset.
local app_name_var="CFG_${app_slug^^}_APP_NAME"
local app_name="${!app_name_var:-$app_slug}"
# Dispatch flags live in the $<slug> global, e.g. linkding=i. Default to
# install if nothing set — installApp called directly without flag = install.
local actions="${!app_slug:-i}"
# Setup phase shared by every action (folder + variables).
if [[ "$actions" == *[cCtTuUsSrRiI]* ]]; then
dockerConfigSetupToContainer silent "$app_slug"
initializeAppVariables "$app_name"
fi
if [[ "$actions" == *[cCtT]* ]]; then
editAppConfig "$app_name"
fi
if [[ "$actions" == *[uU]* ]]; then
_appCallHook "${app_slug}_uninstall_pre" "$app_name"
dockerUninstallApp "$app_name"
_appCallHook "${app_slug}_uninstall_post" "$app_name"
fi
if [[ "$actions" == *[sS]* ]]; then
dockerComposeDown "$app_name"
_appCallHook "${app_slug}_stop_post" "$app_name"
fi
if [[ "$actions" == *[rR]* ]]; then
dockerComposeRestart "$app_name"
_appCallHook "${app_slug}_restart_post" "$app_name"
fi
if [[ "$actions" == *[iI]* ]]; then
isHeader "Install $app_name"
# The ONE hook whose return value is honoured. An app declaring
# CFG_<APP>_REQUIRES uses its _install_pre to refuse when a prerequisite
# is missing; before this gate existed the refusal printed its reasons
# and the install carried straight on, leaving a half-configured app
# whose later steps failed for confusing secondary reasons.
if ! _appCallHook "${app_slug}_install_pre" "$app_name"; then
isError "Install of $app_name stopped — its pre-install checks did not pass."
return 1
fi
((menu_number++))
echo ""
echo "---- $menu_number. Setting up install folder and config for $app_name."
echo ""
dockerConfigSetupToContainer "loud" "$app_name" "install" "$config_variables"
isSuccessful "Install folders and Config files set up for $app_name."
_appCallHook "${app_slug}_install_post_setup" "$app_name"
((menu_number++))
echo ""
echo "---- $menu_number. Setting up the $app_name docker-compose.yml."
echo ""
dockerComposeSetupFile "$app_name"
# Compose-level monitoring toggle MUST run before docker-compose up
# — the compose file is the source of truth for the running
# container, so editing it post-start wouldn't take effect until
# the next restart. Idempotent + no-op for apps without a marker
# block; apps that toggle additional files (authelia config.yml,
# traefik traefik.yml, unbound unbound.conf …) call it again from
# their _install_post_compose hook.
if declare -F monitoringToggleAppConfig >/dev/null 2>&1; then
monitoringToggleAppConfig "$app_name" "docker-compose.yml" 2>/dev/null || true
fi
_appCallHook "${app_slug}_install_post_compose" "$app_name"
# Optional .env handling — apps that ship a .env in their template
# dir get it copied + tag-substituted. No-op for apps without one.
if [[ -f "${install_containers_dir}${app_slug}/.env" ]]; then
local result
result=$(copyResource "$app_name" ".env" "")
checkSuccess "Copying .env for $app_name"
configSetupFileWithData "$app_name" ".env"
fi
((menu_number++))
echo ""
echo "---- $menu_number. Updating file permissions before starting."
echo ""
fixPermissionsBeforeStart "$app_name"
isSuccessful "File permissions updated for $app_name."
((menu_number++))
echo ""
echo "---- $menu_number. Running docker-compose to install + start $app_name."
echo ""
dockerComposeUpdateAndStartApp "$app_name" install
_appCallHook "${app_slug}_install_post_start" "$app_name"
# Reality gate: after `up`, the app MUST have at least one container
# (its compose project == the app dir name). An image-pull failure
# creates none, yet the deep compose call chain swallows that error —
# without this the app is recorded as installed+active with nothing
# running (the silent-fail seen when a low path-MTU black-holed pulls).
# ps -a (not just running) so a created-but-slow-to-start container
# still counts; only a total absence is treated as failure.
if declare -F dockerCommandRun >/dev/null 2>&1 \
&& ! dockerCommandRun "docker ps -a --filter label=com.docker.compose.project=$app_name --format '{{.Names}}' 2>/dev/null" 2>/dev/null | grep -q '[^[:space:]]'; then
# Deliberately does not name a cause. "(image pull failed?)" was a
# guess carried over from the case this backstop was written for, and
# it actively misdirects for every other one — an unsubstituted tag
# that made compose reject the file reads as a registry problem, and
# the real error is further up the log.
isError "$app_name: no container started — not installed."
isNotice "The compose output above says why. Common causes: an image that could not be pulled, or a compose the daemon rejected (e.g. an unsubstituted LibrePortal tag)."
eval "$app_slug=n"
return 1
fi
((menu_number++))
echo ""
echo "---- $menu_number. Running post-install integrations."
echo ""
_appPostStartIntegrations "$app_name"
((menu_number++))
echo ""
echo "---- $menu_number. You can find $app_name files at $(appDir "$app_name")"
echo ""
# Final-message data — apps that want extra args (creds, URLs, etc.)
# printed in the menu output echo them from their hook. Word-split is
# intentional: each space-separated token becomes a positional arg.
local msg_data=""
if declare -F "${app_slug}_install_message_data" >/dev/null 2>&1; then
msg_data=$("${app_slug}_install_message_data" "$app_name")
fi
# shellcheck disable=SC2086 # intentional split — hook returns "u p" etc.
menuShowFinalMessages "$app_name" $msg_data
_appCallHook "${app_slug}_install_post" "$app_name"
menu_number=0
fi
# Reset the dispatch flag so a stale value doesn't trip a later call.
eval "$app_slug=n"
}