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>
143 lines
6.0 KiB
Bash
143 lines
6.0 KiB
Bash
#!/bin/bash
|
|
|
|
# Pull an app from a direct-ssh-direct peer onto this host. End-to-end:
|
|
#
|
|
# 1. Resolve peer + verify reachable (peerPing).
|
|
# 2. Take the existing-app safety backup (migratePreBackupDestination) so
|
|
# a bad pull is rollback-able via the normal restore flow.
|
|
# 3. Stop + wipe the destination's app folder (same as restoreAppStart).
|
|
# 4. Stream `peer-shell stream-app <slug>` over SSH and untar into
|
|
# containers_dir/.
|
|
# 5. Reuse the install-time tag pipeline to repoint host-bound values:
|
|
# - dockerComposeUpdateAndStartApp <app> install re-emits compose
|
|
# - migrateApplyUrlRewrite rewrites URLs
|
|
# 6. Start the container, run app-specific post-restore hooks.
|
|
#
|
|
# That gives us the same idempotent install path the restic-mediated
|
|
# migrateApplyApp uses, just with tar-over-SSH as the data source instead
|
|
# of a restic snapshot.
|
|
|
|
# peerPullApp <peer-name> <app-slug> [--no-pre-backup] [--keep-urls]
|
|
peerPullApp()
|
|
{
|
|
local peer_name="$1"; shift
|
|
local app="$1"; shift
|
|
|
|
# Reuse migrate_apply's opt parser to keep flag semantics identical.
|
|
_migrateParseOpts "$@"
|
|
|
|
if [[ -z "$peer_name" || -z "$app" ]]; then
|
|
isError "peerPullApp: peer_name and app required"
|
|
return 1
|
|
fi
|
|
|
|
local row; row=$(peerGet "$peer_name")
|
|
if [[ -z "$row" || "$row" == "null" ]]; then
|
|
isError "No peer named '$peer_name'"
|
|
return 1
|
|
fi
|
|
local kind
|
|
kind=$(printf '%s' "$row" | grep -o '"kind":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
if [[ "$kind" != "direct-ssh-direct" ]]; then
|
|
isError "Peer '$peer_name' is kind=$kind — peerPullApp needs direct-ssh-direct"
|
|
return 1
|
|
fi
|
|
|
|
local started_at; started_at=$(date +%s)
|
|
isHeader "Pull $app from peer=$peer_name → this host"
|
|
migrateEmit phase=start status=running app="$app" peer="$peer_name" transport=direct-ssh
|
|
|
|
# ---- 1. Reachability --------------------------------------------------
|
|
migrateEmit phase=ping status=running peer="$peer_name"
|
|
local ping_status; ping_status=$(peerPing "$peer_name")
|
|
if [[ "$ping_status" != "ok" ]]; then
|
|
isError "Peer '$peer_name' unreachable: $ping_status"
|
|
migrateEmit phase=ping status=failed detail="$ping_status"
|
|
return 1
|
|
fi
|
|
migrateEmit phase=ping status=complete
|
|
|
|
# ---- 2. Pre-backup of destination (default ON) ------------------------
|
|
if (( MIGRATE_OPT_PRE_BACKUP )); then
|
|
migratePreBackupDestination "$app"
|
|
else
|
|
migrateEmit phase=pre-backup status=skipped reason=user-opt-out app="$app"
|
|
fi
|
|
|
|
# Per-app pre-migrate hook (optional) — declared in the app's tools.sh.
|
|
migrateRunHook "$app" pre "$peer_name" "direct-ssh"
|
|
|
|
# ---- 3. Stop + wipe ----------------------------------------------------
|
|
migrateEmit phase=stop status=running app="$app"
|
|
if declare -f dockerComposeDown >/dev/null 2>&1 && [[ -d "$(appDir "$app")" ]]; then
|
|
dockerComposeDown "$app" >/dev/null 2>&1 || true
|
|
fi
|
|
if [[ -d "$(appDir "$app")" ]]; then
|
|
runFileOp rm -rf "$(appDir "$app")"
|
|
fi
|
|
migrateEmit phase=stop status=complete app="$app"
|
|
|
|
# ---- 4. Stream tar over SSH and untar ---------------------------------
|
|
migrateEmit phase=transfer status=running app="$app"
|
|
# The pipe gives us streaming throughput without staging the whole tarball
|
|
# to disk. set -o pipefail catches ssh failures so we don't run the rest
|
|
# of the flow on a partial extract.
|
|
# Untar into the root that will HOLD this app — with storage locations that
|
|
# is not necessarily the primary one. appDir resolves the intended location
|
|
# (CFG_<APP>_STORAGE) for an app that isn't on disk yet, and refuses if that
|
|
# location's drive is absent, so we never extract onto a bare mountpoint.
|
|
local _dest_dir _dest_root
|
|
if ! _dest_dir=$(appDir "$app"); then
|
|
isError "Cannot pull $app — its storage location is not available"
|
|
migrateEmit phase=transfer status=failed detail="storage-unavailable"
|
|
return 1
|
|
fi
|
|
_dest_root="${_dest_dir%/*}"
|
|
runFileOp mkdir -p "$_dest_root"
|
|
(
|
|
set -o pipefail
|
|
peerExec "$peer_name" "stream-app $app" | runFileOp tar -C "$_dest_root" -xf -
|
|
)
|
|
local transfer_rc=$?
|
|
if (( transfer_rc != 0 )); then
|
|
isError "Transfer of $app from $peer_name failed (rc=$transfer_rc)"
|
|
migrateEmit phase=transfer status=failed rc="$transfer_rc"
|
|
return 1
|
|
fi
|
|
if [[ ! -d "$(appDir "$app")" ]]; then
|
|
isError "Transfer reported success but $(appDir "$app") missing"
|
|
migrateEmit phase=transfer status=failed reason=missing-output
|
|
return 1
|
|
fi
|
|
runFileOp chown -R "${docker_install_user:-$(whoami)}":"${docker_install_user:-$(whoami)}" "$(appDir "$app")" 2>/dev/null || true
|
|
migrateEmit phase=transfer status=complete app="$app"
|
|
|
|
# ---- 5. URL rewrite (default ON) + re-deploy compose -------------------
|
|
if ! (( MIGRATE_OPT_KEEP_URLS )); then
|
|
migrateApplyUrlRewrite "$app"
|
|
else
|
|
migrateEmit phase=url-rewrite status=skipped reason=user-opt-out app="$app"
|
|
fi
|
|
if declare -f dockerComposeUpdateAndStartApp >/dev/null 2>&1; then
|
|
dockerComposeUpdateAndStartApp "$app" install >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
# ---- 6. Start + post-restore hooks ------------------------------------
|
|
migrateEmit phase=start-app status=running app="$app"
|
|
if declare -f dockerComposeUp >/dev/null 2>&1; then
|
|
dockerComposeUp "$app" >/dev/null 2>&1 || true
|
|
fi
|
|
if declare -f restoreAppRunHook >/dev/null 2>&1; then
|
|
restoreAppRunHook "$app" post || true
|
|
fi
|
|
migrateEmit phase=start-app status=complete app="$app"
|
|
|
|
# Per-app post-migrate hook (optional) — last thing before declaring done.
|
|
migrateRunHook "$app" post "$peer_name" "direct-ssh"
|
|
|
|
local finished_at; finished_at=$(date +%s)
|
|
local duration=$((finished_at - started_at))
|
|
isSuccessful "Pulled $app from $peer_name in ${duration}s"
|
|
migrateEmit phase=done status=complete app="$app" duration_seconds="$duration"
|
|
}
|