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>
177 lines
7.1 KiB
Bash
177 lines
7.1 KiB
Bash
#!/bin/bash
|
|
|
|
# Preflight for a first-run restore: check every app in a backup against THIS
|
|
# machine, before anything is written.
|
|
#
|
|
# The whole thing hinges on one fact — each app's snapshot carries its own
|
|
# .libreportal-manifest.json, and engineDumpFile pulls a single file out of a
|
|
# snapshot without restoring it. So we can read what an app needs (its size,
|
|
# where it used to live, which images it runs) and compare that to the machine
|
|
# in front of us, while it is still free to say no.
|
|
#
|
|
# What it catches, and why each matters more than a failed restore would:
|
|
#
|
|
# template missing restoring an app this version no longer ships produces a
|
|
# directory that can never start, and looks like a
|
|
# successful restore until the user tries to open it
|
|
# won't fit filling the disk mid-restore takes the OTHER apps with
|
|
# it, so this must be decided per app, up front
|
|
# location gone the manifest names a storage location this machine does
|
|
# not have; falling back is right, but silently is not
|
|
#
|
|
# Emits one record per app on stdout:
|
|
#
|
|
# <verdict>\t<app>\t<size_human>\t<detail>
|
|
#
|
|
# verdict is ok | move | skip. Returns non-zero only if it could not read the
|
|
# repository at all — a skipped app is a finding, not a failure.
|
|
|
|
# Human-readable bytes, for a report a person reads rather than parses.
|
|
_restorePfSize()
|
|
{
|
|
local b="${1:-0}"
|
|
[[ "$b" =~ ^[0-9]+$ ]] || { printf '?'; return; }
|
|
if (( b >= 1099511627776 )); then printf '%sT' "$(( b / 1099511627776 ))"
|
|
elif (( b >= 1073741824 )); then printf '%sG' "$(( b / 1073741824 ))"
|
|
elif (( b >= 1048576 )); then printf '%sM' "$(( b / 1048576 ))"
|
|
else printf '%sK' "$(( b / 1024 ))"; fi
|
|
}
|
|
|
|
# Pull one app's manifest out of its newest snapshot. Empty on failure — an
|
|
# older backup may predate manifests, and that is not a reason to refuse.
|
|
restorePreflightManifest()
|
|
{
|
|
local idx="$1" app="$2" host="$3"
|
|
local snap; snap=$(engineSnapshotLatestId "$idx" "$app" "$host" 2>/dev/null)
|
|
[[ -n "$snap" ]] || return 1
|
|
|
|
# The manifest sits at the app dir's root, but that path is the SOURCE
|
|
# machine's — which is exactly what we cannot assume. Ask the snapshot where
|
|
# its own files are rather than guessing.
|
|
local base
|
|
base=$(storageSnapshotSourcePath "$idx" "$snap" "$app" 2>/dev/null)
|
|
[[ -n "$base" ]] || return 1
|
|
|
|
engineDumpFile "$idx" "$snap" "$base/.libreportal-manifest.json" 2>/dev/null
|
|
}
|
|
|
|
# Check one app. Echoes a single verdict record.
|
|
restorePreflightApp()
|
|
{
|
|
local idx="$1" app="$2" host="$3"
|
|
local manifest size_bytes size_h loc want_dir avail_kb need_kb
|
|
|
|
# Squeezed: the manifest is pretty-printed, so `"size_bytes": 123` carries a
|
|
# space that a `"key":[0-9]*` pattern misses — which silently emptied both
|
|
# the size and the location, turning the fit and location checks into
|
|
# no-ops that always passed.
|
|
manifest=$(restorePreflightManifest "$idx" "$app" "$host" 2>/dev/null | 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_h=$(_restorePfSize "${size_bytes:-0}")
|
|
[[ -z "$size_bytes" ]] && size_h="?"
|
|
|
|
# 1. Does this version still ship the app?
|
|
if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then
|
|
printf 'skip\t%s\t%s\t%s\n' "$app" "$size_h" "this version no longer ships it"
|
|
return 0
|
|
fi
|
|
|
|
# 2. Where would it go, and does that location still exist?
|
|
local detail="" verdict="ok"
|
|
if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]]; then
|
|
if ! storageLocationPath "$loc" >/dev/null 2>&1; then
|
|
verdict="move"
|
|
detail="its old location \\"$loc\\" is not on this machine"
|
|
fi
|
|
fi
|
|
|
|
want_dir=$(appDir "$app" 2>/dev/null) || {
|
|
printf 'skip\t%s\t%s\t%s\n' "$app" "$size_h" "its storage location is not mounted"
|
|
return 0
|
|
}
|
|
|
|
# 3. Will it fit? Checked per app: filling the disk part-way through a
|
|
# restore damages the apps that already landed.
|
|
if [[ -n "$size_bytes" && "$size_bytes" =~ ^[0-9]+$ ]]; then
|
|
need_kb=$(( size_bytes / 1024 ))
|
|
avail_kb=$(df -Pk "${want_dir%/*}" 2>/dev/null | awk 'NR==2 {print $4}')
|
|
if [[ -n "$avail_kb" ]] && (( avail_kb < need_kb )); then
|
|
printf 'skip\t%s\t%s\t%s\n' "$app" "$size_h" \
|
|
"needs $size_h, $(( avail_kb / 1048576 ))G free where it would go"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
if [[ "$verdict" == "move" ]]; then
|
|
printf 'move\t%s\t%s\t-> %s (%s)\n' "$app" "$size_h" "$(storageLocationName "${want_dir%/*}")" "$detail"
|
|
else
|
|
printf 'ok\t%s\t%s\t%s\n' "$app" "$size_h" "restores as-is"
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Check a whole list. Prints the report and leaves the apps worth restoring in
|
|
# RESTORE_PREFLIGHT_OK.
|
|
restorePreflightReport()
|
|
{
|
|
local idx="$1" host="$2"; shift 2
|
|
|
|
# Callers may pass an explicit list, but the CLI wrapper pads its argv to
|
|
# nine slots and fills the unset ones with the literal string "empty"
|
|
# (${5:-empty} … ${9:-empty} in /usr/local/lib/libreportal/libreportal).
|
|
# So a trailing slot arrives as a five-character app name, not a blank —
|
|
# which is why filtering on -n alone let five phantom apps through and
|
|
# reported them as "no longer shipped".
|
|
#
|
|
# That also caps any explicit list at five apps, so when nothing real is
|
|
# left we discover the host's apps from the repository instead: a whole-host
|
|
# restore then has no list to truncate.
|
|
local -a apps=()
|
|
local _a
|
|
for _a in "$@"; do
|
|
[[ -z "$_a" || "$_a" == "empty" ]] && continue
|
|
apps+=("$_a")
|
|
done
|
|
if (( ${#apps[@]} == 0 )); then
|
|
while IFS= read -r _a; do [[ -n "$_a" ]] && apps+=("$_a"); done \
|
|
< <(migrateDiscoverApps "$host" "$idx" 2>/dev/null)
|
|
fi
|
|
|
|
RESTORE_PREFLIGHT_OK=()
|
|
RESTORE_PREFLIGHT_SKIPPED=0
|
|
|
|
if (( ${#apps[@]} == 0 )); then
|
|
isNotice "No apps found for '$host' in this repository."
|
|
return 0
|
|
fi
|
|
|
|
isNotice "Checking ${#apps[@]} apps against this machine…"
|
|
echo ""
|
|
|
|
local rec verdict app size detail
|
|
for app in "${apps[@]}"; do
|
|
rec=$(restorePreflightApp "$idx" "$app" "$host")
|
|
IFS=$'\t' read -r verdict app size detail <<< "$rec"
|
|
case "$verdict" in
|
|
skip)
|
|
printf ' \033[0;31m✗\033[0m %-16s %-6s %s\n' "$app" "$size" "skipped — $detail"
|
|
RESTORE_PREFLIGHT_SKIPPED=$(( RESTORE_PREFLIGHT_SKIPPED + 1 )) ;;
|
|
move)
|
|
printf ' \033[0;33m~\033[0m %-16s %-6s %s\n' "$app" "$size" "$detail"
|
|
RESTORE_PREFLIGHT_OK+=("$app") ;;
|
|
*)
|
|
printf ' \033[0;32m✓\033[0m %-16s %-6s %s\n' "$app" "$size" "$detail"
|
|
RESTORE_PREFLIGHT_OK+=("$app") ;;
|
|
esac
|
|
done
|
|
|
|
echo ""
|
|
if (( RESTORE_PREFLIGHT_SKIPPED > 0 )); then
|
|
isNotice "${#RESTORE_PREFLIGHT_OK[@]} will restore, $RESTORE_PREFLIGHT_SKIPPED skipped."
|
|
else
|
|
isNotice "All ${#RESTORE_PREFLIGHT_OK[@]} will restore."
|
|
fi
|
|
return 0
|
|
}
|