LibrePortal/scripts/app/app_portable.sh
librelad ef60cce98a feat(app): portable .lpapp export and import — phase 4
`libreportal app export <app>` writes one app to a single file;
`libreportal app import <file>` installs it here. This is the thing the
original request described as "upload or navigate to the backup file" — a
restic repository is not a file, but the want behind the phrasing is real.

The format is deliberately boring: gzipped tar of the app directory with
its .libreportal-manifest.json at the root. That manifest already records
size, images, volumes, databases and storage location, so import reuses
the phase-3 checks for free — refusing an app this version no longer
ships, or one that will not fit, before unpacking anything.

Export stops the app first. A tar of a running Postgres is a corrupt
Postgres, and a file that looks fine until you restore it is worse than a
refusal. tar runs as the owning user with --numeric-owner so container
sub-UIDs survive the round trip instead of being remapped through this
machine's /etc/passwd.

Import re-runs the normal install pipeline after unpacking, because the
compose still carries the SOURCE machine's ports, IPs and domains — that
pipeline is what re-allocates them here, and migrateUrlRewrite fixes the
host-bound CFG_* fields.

Documented throughout as a courier format, not a backup: no history, no
retention, no encryption. Importing under a different name is refused
outright rather than half-working — the CFG_<APP>_* namespace and compose
identities would all need rewriting, and `instance create` already
answers "a second copy".

Fixes a bug this surfaced: _appDirIntended did an indirect expansion on
CFG_<SLUG>_STORAGE without checking <SLUG> can be a variable name, so a
hyphenated or mistyped app name emitted "invalid variable name" and then
reported the misleading "storage location is not mounted" for an app that
simply did not exist.

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

197 lines
7.3 KiB
Bash

#!/bin/bash
# Portable per-app export/import — the "one file you can hand around".
#
# libreportal app export <app> [file] -> <app>-<date>.lpapp
# libreportal app import <file> [newname]
#
# WHAT THIS IS NOT
#
# Not a backup. A restic/borg/kopia repository gives you deduplication, history,
# retention and encryption; this gives you one tarball of one app at one moment.
# It exists because "email me that app" and "keep a copy before I break this" are
# real jobs that a repository answers badly — and because a single file is what
# people picture when they say "the backup file".
#
# The format is deliberately boring: gzipped tar of the app directory, with its
# .libreportal-manifest.json at the root. That manifest already records the
# compose hash, images, volumes, size, databases and storage location, so import
# gets the same reconciliation the restore preflight does, for free.
#
# The app is stopped for the duration of an export. A tar of a running Postgres
# is a corrupt Postgres, and a file that looks fine until you restore it is
# worse than a refusal.
lpAppExtension="lpapp"
appExport()
{
local app="$1"
local out="$2"
if [[ -z "$app" ]]; then
isError "Usage: app export <app_name> [file]"
return 1
fi
local dir
if ! dir=$(appDir "$app"); then
# appDir only refuses for an unmounted location; anything else is the
# app simply not being here, and saying "not mounted" for a typo sends
# people looking at their disks.
if [[ -d "$(primaryRoot)/$app" ]]; then
isError "$app is on a storage location that is not mounted."
else
isError "$app is not installed."
fi
return 1
fi
if [[ ! -d "$dir" ]]; then
isError "$app is not installed."
return 1
fi
[[ -z "$out" ]] && out="$(pwd)/${app}-$(date +%Y%m%d-%H%M%S).${lpAppExtension}"
case "$out" in */) out="${out}${app}-$(date +%Y%m%d-%H%M%S).${lpAppExtension}" ;; esac
if [[ -e "$out" ]]; then
isError "'$out' already exists — refusing to overwrite it."
return 1
fi
isHeader "Export $app"
# Refresh the manifest first: it is what makes the file self-describing, and
# a stale one would describe a different app than the tar contains.
declare -f manifestWrite >/dev/null 2>&1 && manifestWrite "$app" >/dev/null 2>&1
local was_running=0
if declare -f dockerComposeDown >/dev/null 2>&1; then
if runFileOp docker compose -f "$dir/docker-compose.yml" ps -q 2>/dev/null | grep -q .; then
was_running=1
fi
isNotice "Stopping $app — a copy taken while it runs can be corrupt."
dockerComposeDown "$app" >/dev/null 2>&1 || true
fi
isNotice "Writing $out"
# Run as the owner: app data holds container sub-UIDs the manager cannot
# read. --numeric-owner so those uids survive the round trip rather than
# being remapped through this machine's /etc/passwd.
if ! runFileOp tar --numeric-owner -C "${dir%/*}" -czf "$out" "$app" 2>/dev/null; then
isError "Export failed."
(( was_running )) && dockerComposeUp "$app" >/dev/null 2>&1
return 1
fi
runFileOp chmod 0640 "$out" 2>/dev/null
if (( was_running )); then
isNotice "Starting $app again."
dockerComposeUp "$app" >/dev/null 2>&1 || true
fi
local size; size=$(du -h "$out" 2>/dev/null | awk '{print $1}')
isSuccessful "Exported $app to $out (${size:-?})"
isNotice "This is a copy, not a backup — no history, no retention, and not encrypted."
echo "$out"
}
# Read the manifest out of an export without unpacking the whole thing.
appImportManifest()
{
local file="$1"
tar -xzOf "$file" --wildcards '*/.libreportal-manifest.json' 2>/dev/null | head -c 65536
}
# The app name an export contains, taken from the tar's top-level directory
# rather than the filename — the filename is whatever someone renamed it to.
appImportName()
{
local file="$1"
tar -tzf "$file" 2>/dev/null | head -1 | cut -d/ -f1
}
appImport()
{
local file="$1"
local as_name="$2"
if [[ -z "$file" || ! -f "$file" ]]; then
isError "Usage: app import <file.${lpAppExtension}> [new_name]"
return 1
fi
isHeader "Import $file"
local app
app=$(appImportName "$file")
if [[ -z "$app" || ! "$app" =~ ^[A-Za-z0-9._-]+$ ]]; then
isError "'$file' does not look like a LibrePortal export."
return 1
fi
[[ -n "$as_name" ]] && {
isError "Importing under a different name is not supported yet — the app's config namespace (CFG_${app^^}_*) and compose identities would all need rewriting. Use 'libreportal instance create' for a second copy."
return 1
}
# --- the same checks the restore preflight makes -------------------------
local manifest size_bytes loc
manifest=$(appImportManifest "$file")
size_bytes=$(printf '%s' "$manifest" | grep -o '"size_bytes":[0-9]*' | head -1 | cut -d: -f2)
loc=$(printf '%s' "$manifest" | grep -o '"location":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then
isError "This version of LibrePortal does not ship '$app' — it would restore into something that cannot start."
return 1
fi
local dir
if ! dir=$(appDir "$app"); then
isError "The storage location for '$app' is not mounted."
return 1
fi
if [[ -d "$dir" ]]; then
isError "'$app' is already installed at $dir — uninstall it first, or export it before overwriting."
return 1
fi
if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]] \
&& ! storageLocationPath "$loc" >/dev/null 2>&1; then
isNotice "Came from storage location '$loc', which this machine does not have — using $(storageLocationName "${dir%/*}")."
fi
if [[ -n "$size_bytes" && "$size_bytes" =~ ^[0-9]+$ ]]; then
local need_kb=$(( size_bytes / 1024 )) avail_kb
avail_kb=$(df -Pk "${dir%/*}" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ -n "$avail_kb" ]] && (( avail_kb < need_kb )); then
isError "Needs $(( need_kb / 1048576 ))G, only $(( avail_kb / 1048576 ))G free at ${dir%/*}."
return 1
fi
fi
# --- unpack ---------------------------------------------------------------
isNotice "Unpacking into $dir"
runFileOp mkdir -p "${dir%/*}"
if ! runFileOp tar --numeric-owner -xzf "$file" -C "${dir%/*}" 2>/dev/null; then
isError "Unpack failed — removing the partial directory."
runFileOp rm -rf "$dir"
return 1
fi
# Ownership, then the normal install pipeline: the compose still carries the
# SOURCE machine's ports, IPs and domains, and re-running the pipeline is
# what re-allocates them for this one.
declare -f runOwnership >/dev/null 2>&1 && runOwnership app-perms >/dev/null 2>&1
isNotice "Wiring $app into this machine (ports, IPs, domains)."
dockerConfigSetupToContainer "silent" "$app"
initializeAppVariables "$app"
declare -f migrateUrlRewrite >/dev/null 2>&1 && migrateUrlRewrite "$app" >/dev/null 2>&1
dockerComposeUpdateAndStartApp "$app" install
dockerComposeUp "$app"
declare -f databaseInstallApp >/dev/null 2>&1 && databaseInstallApp "$app"
isSuccessful "$app imported and started"
return 0
}