Ran export -> uninstall -> import on trivy against the live install. It
worked end to end (1.3G app, marker file byte-identical afterwards,
container running, database status correct, tree owned by the container
user) but only after three real bugs, none of which syntax checks or
isolated tests would have caught.
Export wrote the tarball as the CONTAINER user, because tar has to read
app data holding sub-UIDs the manager cannot. That meant the container
user also had to be able to create the destination file, which fails for
any normal destination. Now tar writes to stdout and the caller's shell
creates the file: reading uses the privileges that need it, writing uses
the caller's. Import had the mirror-image bug — tar extracted as the
container user and so could not READ a manager-owned .lpapp; the caller
now opens it and tar reads stdin.
Export also failed at tar time with no hint that the destination was the
problem, so it checks the directory exists and is writable up front.
The third one was quiet and worse. The manifest is pretty-printed, so it
reads `"size_bytes": 1324973614` — with a space that a `"key":[0-9]*`
pattern does not match. Both size_bytes and storage.location came back
empty everywhere they were read, which turned "will it fit" and "does
that location still exist" into checks that always passed. That is the
failure mode preflight exists to prevent, hiding inside preflight itself.
Fixed in app_portable.sh and restore_preflight.sh.
Verified afterwards with crafted manifests: an app claiming 8 TB is now
refused on an 800 GB disk ("Needs 8192G, 806G free"), and one naming a
location this machine lacks warns and names the fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
14 KiB
Bash
354 lines
14 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
|
|
# Check up front rather than failing at tar time: the error there is a bare
|
|
# non-zero exit with no hint that the destination was the problem.
|
|
local out_dir="${out%/*}"; [[ -z "$out_dir" ]] && out_dir="."
|
|
if [[ ! -d "$out_dir" ]]; then
|
|
isError "'$out_dir' does not exist."
|
|
return 1
|
|
fi
|
|
if [[ ! -w "$out_dir" ]]; then
|
|
isError "Cannot write to '$out_dir' — pick somewhere $(id -un) can write."
|
|
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"
|
|
# Split the privileges deliberately: tar READS as the container user, because
|
|
# app data holds sub-UIDs the manager cannot read — but it writes to STDOUT,
|
|
# and the destination file is created by this shell, as whoever ran the
|
|
# command. Writing directly from tar meant the container user had to be able
|
|
# to create the file too, which fails for any normal destination.
|
|
#
|
|
# --numeric-owner so container uids survive the round trip rather than being
|
|
# remapped through this machine's /etc/passwd.
|
|
if ! runFileOp tar --numeric-owner -C "${dir%/*}" -czf - "$app" > "$out" 2>/dev/null; then
|
|
isError "Export failed."
|
|
rm -f "$out"
|
|
(( was_running )) && dockerComposeUp "$app" >/dev/null 2>&1
|
|
return 1
|
|
fi
|
|
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
|
|
}
|
|
|
|
# Inspect one or more exports WITHOUT importing anything, and report what would
|
|
# happen. This is what lets the WebUI ask for acceptance before it acts: the
|
|
# same checks appImport makes, run early and rendered as a list.
|
|
#
|
|
# A .lpapp is a plain tarball — not encrypted — so unlike a backup repository
|
|
# there is no password to collect, and nothing secret has to cross from the
|
|
# browser to the host. That is the whole reason this can live in the WebUI when
|
|
# the repository restore cannot.
|
|
#
|
|
# Emits one JSON object per line: {"file","app","size_bytes","verdict","detail"}
|
|
# verdict is ok | warn | refuse.
|
|
appImportCheck()
|
|
{
|
|
local target="$1"
|
|
if [[ -z "$target" ]]; then
|
|
isError "Usage: app import-check <file-or-directory>"
|
|
return 1
|
|
fi
|
|
|
|
local -a files=()
|
|
if [[ -d "$target" ]]; then
|
|
while IFS= read -r f; do [[ -n "$f" ]] && files+=("$f"); done \
|
|
< <(find "$target" -maxdepth 1 -type f -name "*.${lpAppExtension}" 2>/dev/null | sort)
|
|
elif [[ -f "$target" ]]; then
|
|
files=("$target")
|
|
else
|
|
printf '{"file":"%s","app":"","size_bytes":0,"verdict":"refuse","detail":"No such file or directory"}\n' "$(_lpJsonStr "$target")"
|
|
return 1
|
|
fi
|
|
|
|
if (( ${#files[@]} == 0 )); then
|
|
printf '{"file":"%s","app":"","size_bytes":0,"verdict":"refuse","detail":"No .%s files found there"}\n' \
|
|
"$(_lpJsonStr "$target")" "$lpAppExtension"
|
|
return 1
|
|
fi
|
|
|
|
local f app manifest size_bytes loc dir verdict detail
|
|
for f in "${files[@]}"; do
|
|
verdict="ok"; detail="ready to import"
|
|
app=$(appImportName "$f")
|
|
|
|
if [[ -z "$app" || ! "$app" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
_lpImportRow "$f" "" 0 refuse "Not a LibrePortal export"
|
|
continue
|
|
fi
|
|
|
|
# Squeeze whitespace first. The manifest is PRETTY-PRINTED, so the real
|
|
# text is `"size_bytes": 1324973614` — with a space that a
|
|
# `"key":[0-9]*` pattern does not match. Both the size and the location
|
|
# silently came back empty, which turned the "will it fit" and "does
|
|
# that location exist" checks into no-ops that always passed.
|
|
manifest=$(appImportManifest "$f" | tr -d ' \n\t')
|
|
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)
|
|
[[ "$size_bytes" =~ ^[0-9]+$ ]] || size_bytes=0
|
|
|
|
if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then
|
|
_lpImportRow "$f" "$app" "$size_bytes" refuse "This version no longer ships $app"
|
|
continue
|
|
fi
|
|
|
|
if ! dir=$(appDir "$app" 2>/dev/null); then
|
|
_lpImportRow "$f" "$app" "$size_bytes" refuse "Its storage location is not mounted"
|
|
continue
|
|
fi
|
|
if [[ -d "$dir" ]]; then
|
|
_lpImportRow "$f" "$app" "$size_bytes" refuse "$app is already installed — uninstall it first"
|
|
continue
|
|
fi
|
|
|
|
if (( size_bytes > 0 )); 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
|
|
_lpImportRow "$f" "$app" "$size_bytes" refuse \
|
|
"Needs $(( need_kb / 1048576 ))G, $(( avail_kb / 1048576 ))G free"
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]] \
|
|
&& ! storageLocationPath "$loc" >/dev/null 2>&1; then
|
|
verdict="warn"
|
|
detail="came from location \"$loc\", which this machine does not have — will use $(storageLocationName "${dir%/*}")"
|
|
fi
|
|
|
|
_lpImportRow "$f" "$app" "$size_bytes" "$verdict" "$detail"
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Same check, published where the WebUI can read it. The CLI prints one JSON
|
|
# object per line (easy to pipe); the WebUI wants one document, so this wraps
|
|
# them and writes it beside the other generated data.
|
|
appImportCheckPublish()
|
|
{
|
|
local target="$1"
|
|
local out_dir="$(webuiDir)/frontend/data/system"
|
|
local out_file="$out_dir/import_check.json"
|
|
createFolders "quiet" "$sudo_user_name" "$out_dir"
|
|
|
|
local tmp; tmp=$(mktemp) || return 1
|
|
{
|
|
printf '{\n "path": "%s",\n "checked": "%s",\n "results": [\n' \
|
|
"$(_lpJsonStr "$target")" "$(date -Iseconds)"
|
|
local first=1 line
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
(( first )) || printf ',\n'
|
|
first=0
|
|
printf ' %s' "$line"
|
|
done < <(appImportCheck "$target" 2>/dev/null)
|
|
printf '\n ]\n}\n'
|
|
} > "$tmp"
|
|
|
|
runFileWrite "$out_file" < "$tmp"
|
|
rm -f "$tmp"
|
|
return 0
|
|
}
|
|
|
|
_lpJsonStr()
|
|
{
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
|
|
s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/}"
|
|
printf '%s' "$s"
|
|
}
|
|
|
|
_lpImportRow()
|
|
{
|
|
printf '{"file":"%s","app":"%s","size_bytes":%s,"verdict":"%s","detail":"%s"}\n' \
|
|
"$(_lpJsonStr "$1")" "$(_lpJsonStr "$2")" "${3:-0}" "$4" "$(_lpJsonStr "$5")"
|
|
}
|
|
|
|
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
|
|
# Whitespace-squeezed: the manifest is pretty-printed (see appImportCheck).
|
|
manifest=$(appImportManifest "$file" | tr -d ' \n\t')
|
|
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%/*}"
|
|
# Mirror of the export split: the caller's shell opens the .lpapp (it may sit
|
|
# anywhere the user can read), and tar EXTRACTS as the container user so the
|
|
# unpacked tree lands with the right ownership. Passing the path to tar
|
|
# instead required the container user to be able to read the file, which
|
|
# fails for any file the user created themselves.
|
|
if ! runFileOp tar --numeric-owner -xzf - -C "${dir%/*}" < "$file" 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
|
|
}
|