Two halves: the ladder could not climb the commonest versioning scheme, and nothing ever climbed it on its own. The ladder stepped by bumping a tag's LAST numeric component, so v1.158.0 went v1.158.1, v1.158.2, … and never arrived at v1.159.0. It then failed closed, refusing to build a path. Synapse publishes v1.159.0 and no v1.158.1 at all, so Matrix could not be laddered by the button either — three-part semver minor bumps were simply unreachable. updaterNextRung now considers a bump of every component, keeps the candidates that exist upstream and takes the smallest: the next release by definition, whether it lands in the patch position or crosses into a new major. Shape discipline is unchanged, so 31-fpm-alpine still never becomes 31-apache, and each rung is still probed, so none can be skipped. updaterTagBumpAt moves here from the scan, its natural home, which also breaks a source cycle. updaterUpgradeAuto then climbs at most ONE rung per app per calendar day, inside the install window, for apps set to auto. One rung because a ladder run unattended can be several migrations deep before anyone looks, and "restore the snapshot from a minute ago" stops comforting once four have stacked; one a day so there is time to notice. It crosses a major if that is genuinely the next release — refusing would strand an app on the last version of its line forever — but one step at a time, never as a leap. Two stamps: the target rung (a failure is not retried until something newer ships) and the day. Every rung goes through updaterUpgradeApp unchanged, so GATE 1 still refuses any app without a real verifier, and the per-rung contract is identical to the button: snapshot fail-closed, set version, pull, up, verify, restore that rung and stop on any failure. History now records the trigger instead of hardcoding "manual", including on the rollback paths. CFG_UPDATER_LADDER_AUTO gates the whole thing separately from CFG_UPDATER_AUTO, because "keep my apps patched" and "move my apps between versions unattended" are different appetites for risk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
10 KiB
Bash
221 lines
10 KiB
Bash
#!/bin/bash
|
|
|
|
# Stepped upgrade engine — climbs a version ladder one rung at a time.
|
|
# ---------------------------------------------------------------------------
|
|
# For apps that cannot skip a release (Nextcloud refuses outright; databases
|
|
# refuse via their data directory), moving 31 -> 34 is not one update but three,
|
|
# each with a migration that must COMPLETE before the next begins.
|
|
#
|
|
# The per-rung contract, and every part of it is load-bearing:
|
|
#
|
|
# snapshot (fail-closed) -> set version -> pull -> up -> VERIFY -> next rung
|
|
#
|
|
# On any failure, at any point: restore THIS rung's snapshot, put the version
|
|
# back, stop, and leave the app on the last version it verified at. The ladder
|
|
# never continues past a doubt.
|
|
#
|
|
# Why a snapshot per rung rather than one at the start: upstream migrations are
|
|
# usually one-way. Nextcloud 32's schema changes cannot be undone by putting the
|
|
# 31 image back. So the recovery guarantee is "restore the snapshot taken sixty
|
|
# seconds ago", not "undo the upgrade" — which only works if each rung has its
|
|
# own restore point.
|
|
#
|
|
# Deliberately NOT automatic. CFG_<APP>_UPDATE_TYPE=auto applies patches within
|
|
# a line; crossing versions on stateful data stays a decision a person makes,
|
|
# after reading release notes. The updater surfaces "34 available"; this runs
|
|
# only when asked.
|
|
|
|
_updaterUpgradeGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
|
|
|
|
# Rewrite the anchor image AND its version sentinel, so the live compose stays
|
|
# self-consistent. updaterSetAnchorRef preserves the trailing comment verbatim,
|
|
# which would leave the sentinel advertising the OLD version — and the next
|
|
# config-driven regeneration would then quietly revert the app. Both or neither.
|
|
updaterSetAnchorVersion() {
|
|
local app="$1" newtag="$2"
|
|
local compose="${containers_dir%/}/$app/docker-compose.yml"
|
|
[ -f "$compose" ] || return 1
|
|
local svc="${app//_/-}-service"
|
|
local up; up="$(printf '%s' "$app" | tr '[:lower:]' '[:upper:]')"
|
|
local tmp; tmp="$(mktemp)"
|
|
awk -v s="$svc" -v tag="$newtag" -v key="${up}_VERSION_TAG" '
|
|
!done && seen && /^[[:space:]]*image:/ {
|
|
match($0,/^[[:space:]]*/); ind=substr($0,1,RLENGTH)
|
|
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/,"",line)
|
|
sub(/[[:space:]]*#.*$/,"",line); gsub(/["'"'"']/,"",line)
|
|
repo=line; sub(/:[^:\/]*$/,"",repo) # strip the old tag
|
|
printf "%simage: %s:%s #LIBREPORTAL|%s|%s\n", ind, repo, tag, key, tag
|
|
done=1; next
|
|
}
|
|
$0 ~ ("^[[:space:]]*" s ":") { seen=1 }
|
|
{ print }
|
|
' "$compose" > "$tmp" || { rm -f "$tmp"; return 1; }
|
|
grep -q "image:.*:${newtag}" "$tmp" || { rm -f "$tmp"; return 1; }
|
|
runFileWrite "$compose" < "$tmp"; local rc=$?
|
|
rm -f "$tmp"
|
|
|
|
# Keep the config key in step when the app has one, so the WebUI's Version
|
|
# field shows what is actually deployed rather than what it used to be.
|
|
local cfgkey="CFG_${up}_VERSION"
|
|
if [ -n "${!cfgkey+x}" ] && declare -f updateConfigOption >/dev/null 2>&1; then
|
|
updateConfigOption "$cfgkey" "$newtag" >/dev/null 2>&1 || true
|
|
fi
|
|
return $rc
|
|
}
|
|
|
|
# Current anchor tag for an app, straight from its live compose.
|
|
updaterCurrentTag() {
|
|
local app="$1"
|
|
local compose="${containers_dir%/}/$app/docker-compose.yml"
|
|
[ -f "$compose" ] || return 1
|
|
updaterTagOf "$(updaterPrimaryImage "$app" "$compose")"
|
|
}
|
|
|
|
# updaterUpgradeApp <app> [target-tag] [--dry-run]
|
|
# Walks the ladder. Returns 0 only if every rung verified.
|
|
updaterUpgradeApp() {
|
|
local app="$1" target="${2:-}" mode="${3:-}"
|
|
[ "$target" = "--dry-run" ] && { mode="--dry-run"; target=""; }
|
|
|
|
local app_dir="${containers_dir%/}/$app"
|
|
[ -d "$app_dir" ] || { isError "App '$app' is not installed."; return 1; }
|
|
|
|
local cur; cur="$(updaterCurrentTag "$app")"
|
|
[ -n "$cur" ] || { isError "Could not read $app's current version tag."; return 1; }
|
|
|
|
local anchor repo
|
|
anchor="$(updaterPrimaryImage "$app" "$app_dir/docker-compose.yml")"
|
|
repo="$(updaterRepoTag "$anchor")"; repo="${repo%:*}"
|
|
|
|
# GATE 1 — a stepped upgrade without a real verifier is a guess. The generic
|
|
# health check cannot see a half-finished migration, so refusing here is the
|
|
# difference between this being a safety feature and a liability.
|
|
if ! updaterHasVerifier "$app"; then
|
|
isError "$app has no upgrade verifier, so a stepped upgrade cannot be confirmed safe."
|
|
isNotice "Add ${app}_upgrade_verify (see cli_updater_verify.sh) before laddering this app."
|
|
return 1
|
|
fi
|
|
|
|
# GATE 2 — the ladder must be computable end to end. updaterVersionLadder
|
|
# returns non-zero rather than guessing when it cannot reach the target.
|
|
local -a rungs=()
|
|
if ! mapfile -t rungs < <(updaterVersionLadder "$cur" "$repo" "$target") || (( ${#rungs[@]} == 0 )); then
|
|
if [ -n "$target" ]; then
|
|
isError "No safe path from $cur to $target could be determined — not attempting it."
|
|
isNotice "Upgrade these by hand, one release at a time, if you are sure."
|
|
return 1
|
|
fi
|
|
isSuccessful "$app is already on the newest release line ($cur)."
|
|
return 0
|
|
fi
|
|
|
|
isHeader "Upgrade plan for $app"
|
|
isNotice "$(updaterLadderSummary "$cur" "${rungs[@]}")"
|
|
isNotice "Each step: snapshot → pull → start → verify. A failure stops the ladder and restores that step."
|
|
|
|
if [ "$mode" = "--dry-run" ]; then
|
|
isSuccessful "Dry run — nothing was changed."
|
|
return 0
|
|
fi
|
|
|
|
local timeout="${CFG_UPDATER_UPGRADE_TIMEOUT:-900}"
|
|
local from="$cur" rung done_n=0
|
|
for rung in "${rungs[@]}"; do
|
|
isHeader "$app: $from → $rung (step $((done_n + 1)) of ${#rungs[@]})"
|
|
|
|
# 1. Snapshot THIS rung. Fail-closed: no snapshot, no step.
|
|
isNotice "Snapshotting $app before $rung…"
|
|
if ! backupAppStart "$app" >/dev/null 2>&1; then
|
|
isError "Pre-step snapshot failed — stopping with $app on $from."
|
|
updaterRecordHistory "$app" "upgrade" "$from" "$rung" "aborted-no-snapshot" "" "" "" "${UPDATER_UPGRADE_TRIGGER:-manual}"
|
|
return 1
|
|
fi
|
|
|
|
# 2. Move the version.
|
|
if ! updaterSetAnchorVersion "$app" "$rung"; then
|
|
isError "Could not set $app to $rung — stopping, nothing changed."
|
|
updaterRecordHistory "$app" "upgrade" "$from" "$rung" "aborted-set-version" "" "" "" "${UPDATER_UPGRADE_TRIGGER:-manual}"
|
|
return 1
|
|
fi
|
|
|
|
# 3. Pull + start.
|
|
if ! updaterComposePull "$app" || ! dockerComposeUp "$app" >/dev/null 2>&1; then
|
|
isError "$app failed to start on $rung — rolling this step back."
|
|
_updaterUpgradeRollbackStep "$app" "$from" "$rung"
|
|
return 1
|
|
fi
|
|
|
|
# 4. VERIFY. The rung is not done until the app says so itself.
|
|
if ! updaterVerifyUpgrade "$app" "$rung" "$timeout"; then
|
|
isError "$app did not verify on $rung — rolling this step back."
|
|
_updaterUpgradeRollbackStep "$app" "$from" "$rung"
|
|
return 1
|
|
fi
|
|
|
|
updaterRecordHistory "$app" "upgrade" "$from" "$rung" "ok" "" "" "" "${UPDATER_UPGRADE_TRIGGER:-manual}"
|
|
isSuccessful "$app is verified on $rung."
|
|
from="$rung"; done_n=$((done_n + 1))
|
|
done
|
|
|
|
isSuccessful "$app upgraded through ${done_n} version(s) — now on $from, verified."
|
|
_updaterUpgradePruneImages "$app" "$cur" "${rungs[@]}"
|
|
webuiUpdaterScan >/dev/null 2>&1 || true
|
|
return 0
|
|
}
|
|
|
|
# Drop the images the climb left behind. A 3-rung Nextcloud upgrade downloads
|
|
# ~1.5 GB per rung and keeps every one — 4.4 GB of superseded images after a
|
|
# single upgrade, which on a small VPS is the difference between working and
|
|
# full. `system reclaim` cannot help: it collects DANGLING images, and each rung
|
|
# is a distinct tag, so they are all still tagged and all still there. (Rolling
|
|
# apps do not have this problem — moving a floating tag orphans the old image,
|
|
# which reclaim then collects.)
|
|
#
|
|
# Only ever after a SUCCESSFUL climb, and the immediately-previous version is
|
|
# KEPT as the roll-back target so recovery does not depend on the network.
|
|
# CFG_UPDATER_UPGRADE_PRUNE=false to keep everything.
|
|
_updaterUpgradePruneImages() {
|
|
local app="$1" start="$2"; shift 2
|
|
local -a climbed=("$@")
|
|
[[ "${CFG_UPDATER_UPGRADE_PRUNE:-true}" == "true" ]] || { isNotice "Keeping superseded images (CFG_UPDATER_UPGRADE_PRUNE=false)."; return 0; }
|
|
(( ${#climbed[@]} >= 2 )) || return 0 # one step: previous IS the rollback target
|
|
|
|
local anchor repo
|
|
anchor="$(updaterPrimaryImage "$app" "${containers_dir%/}/$app/docker-compose.yml")"
|
|
repo="$(updaterRepoTag "$anchor")"; repo="${repo%:*}"
|
|
|
|
# Everything we moved off, minus the last one (kept for rollback).
|
|
local -a superseded=("$start" "${climbed[@]:0:$(( ${#climbed[@]} - 1 ))}")
|
|
unset 'superseded[-1]'
|
|
(( ${#superseded[@]} > 0 )) || return 0
|
|
|
|
local tag removed=0
|
|
for tag in "${superseded[@]}"; do
|
|
[[ -n "$tag" ]] || continue
|
|
if runFileOp docker image rm "${repo}:${tag}" >/dev/null 2>&1; then
|
|
removed=$((removed + 1))
|
|
fi
|
|
done
|
|
(( removed > 0 )) && isSuccessful "Removed $removed superseded image(s); kept ${climbed[-2]} for roll-back."
|
|
return 0
|
|
}
|
|
|
|
# Undo one failed rung: put the version back, restore the snapshot taken moments
|
|
# ago, start it, and record what happened. Best effort by nature — if the
|
|
# restore itself fails the user is told plainly rather than reassured.
|
|
_updaterUpgradeRollbackStep() {
|
|
local app="$1" from="$2" failed="$3"
|
|
isNotice "Restoring $app to $from…"
|
|
updaterSetAnchorVersion "$app" "$from" || isError "Could not put $app's version back to $from — check its compose file."
|
|
if restoreAppStart "$app" latest "" >/dev/null 2>&1; then
|
|
dockerComposeUp "$app" >/dev/null 2>&1 || true
|
|
updaterRecordHistory "$app" "upgrade" "$from" "$failed" "rolled-back" "" "" "" "${UPDATER_UPGRADE_TRIGGER:-manual}"
|
|
isSuccessful "$app restored to $from from its pre-step snapshot."
|
|
isNotice "The ladder stopped here. Read $failed's release notes before trying again."
|
|
return 0
|
|
fi
|
|
updaterRecordHistory "$app" "upgrade" "$from" "$failed" "rollback-failed" "" "" "" "${UPDATER_UPGRADE_TRIGGER:-manual}"
|
|
isError "Could not restore $app automatically. Its data snapshot is intact — restore it from the Backups page."
|
|
return 1
|
|
}
|