The resolver already supported per-app placement — CFG_<APP>_STORAGE names a
location and appDir sends data, compose and config there — and 37 of 39 app
templates ship the field. What was missing was choosing AT INSTALL TIME. The
only routes were editing a config by hand before installing, or installing onto
the default disk and then `app move`ing it, which copies the data twice.
libreportal app install <app> --storage=<location>
and the App Center's existing storage dropdown, which travels inside
config_variables. Both resolve to one answer in storageChoiceFor, so there is a
single code path.
Ordering is the whole difficulty, and getting it wrong is quiet. installApp
copies the app template into appDir(), sources it, and later applies the form
overrides. The choice has to be live before the copy (or the directory is
created on the wrong disk), written into the config before the source (or the
template's "default" wins and every later appDir in that process returns the
primary root), and folded into config_variables (or the override pass writes
"default" back). Miss any one and the directory and its config disagree — which
resolves correctly only until something sources the config.
Refuses an unknown or unmounted location, an existing directory, and an app
whose template marks the field **READONLY** (fixed to the primary root because
other apps reach it by literal path — storageMoveApp already refuses to move
those, and installing one elsewhere is the same violation from the other end).
Three shipped bugs found making this work:
* updateConfigOption chose its write helper by comparing the path against
$containers_dir — the PRIMARY root only — so an app on any other registered
location took the manager branch and `sed -i` failed with exactly the
permission error the comment above that code describes. `app move` writes
the new location with `|| true`, so it reported a successful move while
leaving the config naming the old disk.
* storageLocationName resolved a location's name only from an in-scope
CFG_STORAGE_LOC_<id>_NAME, falling back to the bare id. That name is the
value CFG_<APP>_STORAGE is set to, so the generated dropdown offered
"location-1" as both label and value — a choice that does not resolve. Read
it from the location's config when the variable is not in scope.
* storageSyncAllAppComments was written for "the regen path" and never wired
into one. Every CFG_<APP>_STORAGE option list was frozen at install time, so
adding a drive did not make it selectable anywhere. Called from the storage
generator now, which runs exactly when those lists go stale — and extended
to app TEMPLATES, since an app not installed yet is precisely the one whose
install form needs to show which drives exist.
Verified on a live install with three locations: linkding and authelia on disk1,
ipinfo on disk2, fourteen on the default root, each config naming its own drive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
12 KiB
Bash
256 lines
12 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}"
|
|
|
|
# Which drive this app goes on. It arrives either as `app install
|
|
# --storage=<name>` or as the storage dropdown in the App Center's install
|
|
# form (which travels inside config_variables); both resolve to one answer,
|
|
# and the ordering that makes it stick is spelled out in
|
|
# scripts/storage/storage_place.sh.
|
|
local _storage_want
|
|
_storage_want=$(storageChoiceFor "$app_slug" "$config_variables")
|
|
if [[ -n "$_storage_want" ]]; then
|
|
if ! storagePlaceAppPre "$app_slug" "$_storage_want"; then
|
|
isError "Not installing $app_slug."
|
|
return 1
|
|
fi
|
|
# So the override pass further down writes the choice rather than
|
|
# resetting it to the template's "default".
|
|
config_variables=$(storageChoiceMerge "$app_slug" "$_storage_want" "$config_variables")
|
|
fi
|
|
|
|
# Setup phase shared by every action (folder + variables).
|
|
if [[ "$actions" == *[cCtTuUsSrRiI]* ]]; then
|
|
dockerConfigSetupToContainer silent "$app_slug"
|
|
# Between the copy and the source: the fresh template says "default",
|
|
# and sourcing that would send every later appDir in this process to the
|
|
# primary root while the directory sits elsewhere.
|
|
storagePlaceAppPost "$app_slug" "$_storage_want"
|
|
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"
|
|
}
|