diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index 8f64619..88bd9f9 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -95,6 +95,27 @@ cliHandleUpdaterCommands() fi ;; + "upgrade") + # Stepped, cross-version upgrade for apps that cannot skip a + # release. Separate verb from `apply` on purpose: apply moves you + # within a line, upgrade moves you between lines, and only the + # latter needs a ladder, a verifier and a snapshot per step. + if [[ -z "$app" ]]; then isError "Usage: libreportal updater upgrade [version] [--dry-run]"; return 1; fi + local upgrade_target="$initial_command4" upgrade_mode="$initial_command5" + [[ "$upgrade_target" == "--dry-run" ]] && { upgrade_mode="--dry-run"; upgrade_target=""; } + for _f in cli_updater_ladder cli_updater_verify cli_updater_upgrade; do + declare -F updaterUpgradeApp >/dev/null 2>&1 || \ + source "$install_scripts_dir/cli/commands/updater/${_f}.sh" 2>/dev/null + done + # A dry run touches nothing, so it runs inline — making the plan + # instant to read instead of arriving via the task log. + if [[ "$upgrade_mode" == "--dry-run" || "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then + updaterUpgradeApp "$app" "$upgrade_target" "$upgrade_mode" + else + cliTaskRun "libreportal updater upgrade $app $upgrade_target" "updater_upgrade" "$app" "" + fi + ;; + "apply-all") local list="$app" # optional comma-list in $initial_command3 if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then diff --git a/scripts/cli/commands/updater/cli_updater_header.sh b/scripts/cli/commands/updater/cli_updater_header.sh index d4f5f21..f3454ed 100644 --- a/scripts/cli/commands/updater/cli_updater_header.sh +++ b/scripts/cli/commands/updater/cli_updater_header.sh @@ -13,6 +13,10 @@ cliShowUpdaterHelp() echo " older than CFG_UPDATER_SCAN_INTERVAL minutes)" echo " libreportal updater apply - Update one app (snapshots it first; auto-rollback on failure)" echo " libreportal updater apply-all [a,b] - Update a comma-list of apps (each snapshotted first)" + echo " libreportal updater upgrade - Move an app BETWEEN release lines, one version at a" + echo " time (31→32→33→34). Snapshots and verifies every step," + echo " and stops at the first doubt. Add --dry-run to just see" + echo " the plan, or a version to stop short of the newest." echo " libreportal updater rollback - Restore an app's most recent pre-update snapshot" echo "" echo "Every update takes a recovery snapshot via the Backup engine before" diff --git a/scripts/cli/commands/updater/cli_updater_upgrade.sh b/scripts/cli/commands/updater/cli_updater_upgrade.sh new file mode 100644 index 0000000..a89e871 --- /dev/null +++ b/scripts/cli/commands/updater/cli_updater_upgrade.sh @@ -0,0 +1,182 @@ +#!/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__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 [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" "" "" "" "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" "" "" "" "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" "" "" "" "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." + webuiUpdaterScan >/dev/null 2>&1 || true + 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" "" "" "" "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" "" "" "" "manual" + isError "Could not restore $app automatically. Its data snapshot is intact — restore it from the Backups page." + return 1 +} diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 5c6b234..6ea3e9c 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -55,6 +55,7 @@ cli_scripts=( "cli/commands/updater/cli_updater_commands.sh" "cli/commands/updater/cli_updater_header.sh" "cli/commands/updater/cli_updater_ladder.sh" + "cli/commands/updater/cli_updater_upgrade.sh" "cli/commands/updater/cli_updater_verify.sh" "cli/commands/validation/cli_validation_commands.sh" "cli/commands/validation/cli_validation_header.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index eec278e..1af4d5d 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -937,6 +937,7 @@ declare -gA LP_FN_MAP=( [updaterClassifyTag]="webui/data/generators/updater/webui_updater_scan.sh" [_updaterCleanImageRef]="webui/data/generators/updater/webui_updater_scan.sh" [updaterComposePull]="cli/commands/updater/cli_updater_commands.sh" + [updaterCurrentTag]="cli/commands/updater/cli_updater_upgrade.sh" [updaterDisplayVersion]="webui/data/generators/updater/webui_updater_scan.sh" [updaterHasVerifier]="cli/commands/updater/cli_updater_verify.sh" [updaterInspectLocal]="webui/data/generators/updater/webui_updater_scan.sh" @@ -953,6 +954,7 @@ declare -gA LP_FN_MAP=( [updaterRepoTag]="webui/data/generators/updater/webui_updater_scan.sh" [updaterRollbackApp]="cli/commands/updater/cli_updater_commands.sh" [updaterSetAnchorRef]="cli/commands/updater/cli_updater_commands.sh" + [updaterSetAnchorVersion]="cli/commands/updater/cli_updater_upgrade.sh" [updaterTagExists]="cli/commands/updater/cli_updater_ladder.sh" [updaterTagGreater]="webui/data/generators/updater/webui_updater_scan.sh" [updaterTagIncrement]="cli/commands/updater/cli_updater_ladder.sh" @@ -960,6 +962,9 @@ declare -gA LP_FN_MAP=( [updaterTagOf]="webui/data/generators/updater/webui_updater_scan.sh" [updaterTagShape]="webui/data/generators/updater/webui_updater_scan.sh" [updaterTagSortKey]="cli/commands/updater/cli_updater_ladder.sh" + [updaterUpgradeApp]="cli/commands/updater/cli_updater_upgrade.sh" + [_updaterUpgradeGenDir]="cli/commands/updater/cli_updater_upgrade.sh" + [_updaterUpgradeRollbackStep]="cli/commands/updater/cli_updater_upgrade.sh" [updaterVerifyGeneric]="cli/commands/updater/cli_updater_verify.sh" [updaterVerifyUpgrade]="cli/commands/updater/cli_updater_verify.sh" [updaterVersionLadder]="cli/commands/updater/cli_updater_ladder.sh" @@ -1977,6 +1982,7 @@ declare -gA LP_FN_ROOT=( [updaterClassifyTag]="scripts" [_updaterCleanImageRef]="scripts" [updaterComposePull]="scripts" + [updaterCurrentTag]="scripts" [updaterDisplayVersion]="scripts" [updaterHasVerifier]="scripts" [updaterInspectLocal]="scripts" @@ -1993,6 +1999,7 @@ declare -gA LP_FN_ROOT=( [updaterRepoTag]="scripts" [updaterRollbackApp]="scripts" [updaterSetAnchorRef]="scripts" + [updaterSetAnchorVersion]="scripts" [updaterTagExists]="scripts" [updaterTagGreater]="scripts" [updaterTagIncrement]="scripts" @@ -2000,6 +2007,9 @@ declare -gA LP_FN_ROOT=( [updaterTagOf]="scripts" [updaterTagShape]="scripts" [updaterTagSortKey]="scripts" + [updaterUpgradeApp]="scripts" + [_updaterUpgradeGenDir]="scripts" + [_updaterUpgradeRollbackStep]="scripts" [updaterVerifyGeneric]="scripts" [updaterVerifyUpgrade]="scripts" [updaterVersionLadder]="scripts" @@ -3051,6 +3061,7 @@ updaterAutoTaskPending() { unset -f updaterAutoTaskPending; __lpAutoload "${inst updaterClassifyTag() { unset -f updaterClassifyTag; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterClassifyTag "$@"; } _updaterCleanImageRef() { unset -f _updaterCleanImageRef; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; _updaterCleanImageRef "$@"; } updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterComposePull "$@"; } +updaterCurrentTag() { unset -f updaterCurrentTag; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_upgrade.sh"; updaterCurrentTag "$@"; } updaterDisplayVersion() { unset -f updaterDisplayVersion; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterDisplayVersion "$@"; } updaterHasVerifier() { unset -f updaterHasVerifier; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_verify.sh"; updaterHasVerifier "$@"; } updaterInspectLocal() { unset -f updaterInspectLocal; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterInspectLocal "$@"; } @@ -3067,6 +3078,7 @@ updaterRegistryTags() { unset -f updaterRegistryTags; __lpAutoload "${install_sc updaterRepoTag() { unset -f updaterRepoTag; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterRepoTag "$@"; } updaterRollbackApp() { unset -f updaterRollbackApp; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRollbackApp "$@"; } updaterSetAnchorRef() { unset -f updaterSetAnchorRef; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterSetAnchorRef "$@"; } +updaterSetAnchorVersion() { unset -f updaterSetAnchorVersion; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_upgrade.sh"; updaterSetAnchorVersion "$@"; } updaterTagExists() { unset -f updaterTagExists; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterTagExists "$@"; } updaterTagGreater() { unset -f updaterTagGreater; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagGreater "$@"; } updaterTagIncrement() { unset -f updaterTagIncrement; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterTagIncrement "$@"; } @@ -3074,6 +3086,9 @@ updaterTagNums() { unset -f updaterTagNums; __lpAutoload "${install_scripts_dir} updaterTagOf() { unset -f updaterTagOf; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagOf "$@"; } updaterTagShape() { unset -f updaterTagShape; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagShape "$@"; } updaterTagSortKey() { unset -f updaterTagSortKey; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterTagSortKey "$@"; } +updaterUpgradeApp() { unset -f updaterUpgradeApp; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_upgrade.sh"; updaterUpgradeApp "$@"; } +_updaterUpgradeGenDir() { unset -f _updaterUpgradeGenDir; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_upgrade.sh"; _updaterUpgradeGenDir "$@"; } +_updaterUpgradeRollbackStep() { unset -f _updaterUpgradeRollbackStep; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_upgrade.sh"; _updaterUpgradeRollbackStep "$@"; } updaterVerifyGeneric() { unset -f updaterVerifyGeneric; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_verify.sh"; updaterVerifyGeneric "$@"; } updaterVerifyUpgrade() { unset -f updaterVerifyUpgrade; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_verify.sh"; updaterVerifyUpgrade "$@"; } updaterVersionLadder() { unset -f updaterVersionLadder; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterVersionLadder "$@"; }