feat(updater): version ladder for apps that cannot skip a release
Foundation for stepped upgrades. Answers one question only — WHICH
versions, in WHICH order — with no side effects, so it can be tested
exhaustively. Applying the rungs is a separate job.
Nextcloud refuses to skip a major ("Updates between multiple major
versions and downgrades are unsupported") and will not start; databases
behave the same way about their data directory. For those apps 31 -> 34
is three upgrades, each with a migration that must finish before the
next begins.
Built by PROBING each candidate rung, not by enumerating tags — because
enumeration is provably unsafe here. Docker Hub pages at 100 ordered by
recency, and the first real-registry run proved the danger: it produced
v4.2 -> v4.4 -> v4.5 -> v4.6 for mastodon, silently skipping v4.3, which
exists (HTTP 200) but had fallen off the newest-100 listing. Skipping a
rung is the precise failure this file exists to prevent, so the ladder is
now built by incrementing and probing: v4.2 -> v4.3 -> v4.4 -> v4.5 ->
v4.6, 4 steps.
Guarantees: same shape only (never 31-fpm-alpine onto 31-apache),
strictly ascending, never a downgrade, rolling tags refused outright, and
a version upstream never published is stepped over only because the probe
said so. If a continuous path to the target cannot be constructed it
returns 1 and prints nothing — refusing to guess, because a wrong ladder
means a skipped migration.
20 unit tests, including the exact listing-truncation case above and the
numeric ordering that would otherwise drive an app backwards (0.9 vs
0.10). Real registry: nextcloud 3 steps, mastodon 4, stalwart current.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
8a0c3641d0
commit
913cacaff0
119
scripts/cli/commands/updater/cli_updater_ladder.sh
Normal file
119
scripts/cli/commands/updater/cli_updater_ladder.sh
Normal file
@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Version ladder — the rung list for a stepped upgrade.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Some apps refuse to skip a version. Nextcloud says so outright ("Updates
|
||||
# between multiple major versions and downgrades are unsupported") and simply
|
||||
# will not start; databases behave the same way about their data directory.
|
||||
# For those, going 31 -> 34 is not one update, it is three, each with its own
|
||||
# migration that must finish before the next begins.
|
||||
#
|
||||
# This file answers only one question: WHICH VERSIONS, IN WHICH ORDER. It does
|
||||
# no I/O beyond listing tags and never touches an app — so it is exhaustively
|
||||
# testable, which matters because every later safety guarantee is built on it
|
||||
# being right. Applying the rungs (snapshot, pull, verify, abort) is the
|
||||
# engine's job, not this one's.
|
||||
#
|
||||
# Rules it enforces:
|
||||
# * same SHAPE only 31-fpm-alpine never ladders onto 31-apache
|
||||
# * strictly ascending never a downgrade, never a repeat
|
||||
# * no gaps every published rung between here and there
|
||||
# * stops at the target or at the newest rung if no target is given
|
||||
|
||||
# Sort key for a tag: each numeric run zero-padded to 6 digits, so a plain
|
||||
# lexical sort orders correctly. Without this, "9" sorts after "10" and the
|
||||
# ladder would be built in the wrong order — the one bug in here that could
|
||||
# actually drive an app backwards through a migration.
|
||||
updaterTagSortKey() {
|
||||
printf '%s' "$1" | grep -oE '[0-9]+' | awk '{ printf "%06d.", $0 }'
|
||||
}
|
||||
|
||||
# Does this exact tag exist? One cheap lookup, and the ONLY reliable way to ask.
|
||||
# Listing cannot answer it: Docker Hub pages at 100 and orders by recency, so an
|
||||
# older intermediate rung falls off the end — mastodon's v4.3 exists but is
|
||||
# absent from the newest-100, and a ladder built from that listing skipped it.
|
||||
# Skipping a rung is the exact failure this whole file exists to prevent, so the
|
||||
# ladder is built by PROBING each candidate, never by enumerating.
|
||||
updaterTagExists() {
|
||||
local repo="${1%%:*}" tag="$2"
|
||||
case "$repo" in *.*/*|localhost/*) return 1 ;; esac # non-Hub: unknown
|
||||
case "$repo" in */*) : ;; *) repo="library/$repo" ;; esac
|
||||
command -v curl >/dev/null 2>&1 || return 1
|
||||
local code
|
||||
code="$(curl -fsS -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 12 \
|
||||
"https://hub.docker.com/v2/repositories/${repo}/tags/${tag}" 2>/dev/null)"
|
||||
[ "$code" = "200" ]
|
||||
}
|
||||
|
||||
# Bump the LAST numeric component of a tag by one: v4.2 -> v4.3, 31-fpm-alpine
|
||||
# -> 32-fpm-alpine, v0.16 -> v0.17.
|
||||
updaterTagIncrement() {
|
||||
local tag="$1"
|
||||
# Greedy leading group takes everything up to the LAST digit run, so the
|
||||
# split is prefix / number / suffix: "31-fpm-alpine" -> ""/"31"/"-fpm-alpine",
|
||||
# "v4.2" -> "v4."/"2"/"". 10# keeps "08" decimal rather than octal.
|
||||
if [[ "$tag" =~ ^(.*[^0-9])?([0-9]+)([^0-9]*)$ ]]; then
|
||||
printf '%s%d%s' "${BASH_REMATCH[1]}" "$((10#${BASH_REMATCH[2]} + 1))" "${BASH_REMATCH[3]}"
|
||||
else
|
||||
printf '%s' "$tag"
|
||||
fi
|
||||
}
|
||||
|
||||
# updaterVersionLadder <current-tag> <repo> [target-tag]
|
||||
# Prints the rungs to climb, one per line, ascending, EXCLUDING the current
|
||||
# version and INCLUDING the target.
|
||||
#
|
||||
# Built by probing consecutive increments, so a rung missing from any listing
|
||||
# can never be missed. A version upstream genuinely skipped (no v4.3 at all) is
|
||||
# stepped over, but ONLY because the probe said it does not exist.
|
||||
#
|
||||
# FAILS LOUDLY (returns 1, prints nothing) if it cannot construct a continuous
|
||||
# path to the target — e.g. the target is across a boundary simple incrementing
|
||||
# cannot reach. Refusing to guess is the point: a wrong ladder means a skipped
|
||||
# migration, and "I cannot compute this safely, do it by hand" is the only
|
||||
# honest answer in that case.
|
||||
updaterVersionLadder() {
|
||||
local cur="$1" repo="$2" target="${3:-}"
|
||||
[ -n "$cur" ] && [ -n "$repo" ] || return 0
|
||||
local shape; shape="$(updaterTagShape "$cur")"
|
||||
|
||||
# A rolling tag has no ladder — it moves on its own. Guarded here as well as
|
||||
# at the call site: this function must never be why an app moves.
|
||||
[ "$(updaterClassifyTag "$cur")" = "rolling" ] && return 0
|
||||
|
||||
# Discover the target (newest same-shape tag) when not told one. Listing is
|
||||
# fine for THIS — being one rung short is harmless, whereas a gap is not.
|
||||
[ -n "$target" ] || target="$(updaterNewerVersionTag "$cur" "$repo")"
|
||||
[ -n "$target" ] || return 0 # already current
|
||||
[ "$(updaterTagShape "$target")" = "$shape" ] || return 0
|
||||
updaterTagGreater "$target" "$cur" || return 0 # never downgrade
|
||||
|
||||
local -a rungs=()
|
||||
local probe="$cur" i
|
||||
for ((i=0; i<64; i++)); do # bounded: no runaway
|
||||
probe="$(updaterTagIncrement "$probe")"
|
||||
updaterTagGreater "$probe" "$target" && break # overshot
|
||||
if updaterTagExists "$repo" "$probe"; then
|
||||
rungs+=("$probe")
|
||||
fi
|
||||
[ "$probe" = "$target" ] && break
|
||||
done
|
||||
|
||||
# The last rung MUST be the target. Anything else means the path is
|
||||
# incomplete and applying it would land the app somewhere unintended.
|
||||
if (( ${#rungs[@]} == 0 )) || [ "${rungs[-1]}" != "$target" ]; then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "${rungs[@]}"
|
||||
}
|
||||
|
||||
# Human summary of a ladder, for the confirmation the user sees before any of
|
||||
# it runs: "31-fpm-alpine → 32-fpm-alpine → 33-fpm-alpine → 34-fpm-alpine (3 steps)".
|
||||
updaterLadderSummary() {
|
||||
local cur="$1"; shift
|
||||
local -a rungs=("$@")
|
||||
(( ${#rungs[@]} == 0 )) && { printf 'already current (%s)' "$cur"; return 0; }
|
||||
local out="$cur" r
|
||||
for r in "${rungs[@]}"; do out+=" → $r"; done
|
||||
printf '%s (%d step%s)' "$out" "${#rungs[@]}" "$( (( ${#rungs[@]} == 1 )) || echo s )"
|
||||
}
|
||||
@ -54,6 +54,7 @@ cli_scripts=(
|
||||
"cli/commands/updater/cli_updater_auto.sh"
|
||||
"cli/commands/updater/cli_updater_commands.sh"
|
||||
"cli/commands/updater/cli_updater_header.sh"
|
||||
"cli/commands/updater/cli_updater_ladder.sh"
|
||||
"cli/commands/validation/cli_validation_commands.sh"
|
||||
"cli/commands/validation/cli_validation_header.sh"
|
||||
"cli/commands/verify/cli_verify_commands.sh"
|
||||
|
||||
@ -280,6 +280,7 @@ declare -gA LP_FN_MAP=(
|
||||
[checkUFWRequirement]="checks/requirements/check_ufw.sh"
|
||||
[checkUpdates]="update/check_update.sh"
|
||||
[checkWebUISystemdRequirement]="checks/requirements/check_webui_systemd.sh"
|
||||
[cleanupOrphanQueueEntries]="task/crontab_task_processor.sh"
|
||||
[cleanupZeroByteFiles]="task/crontab_task_processor.sh"
|
||||
[cliAppRestore]="cli/commands/app/cli_app_restore.sh"
|
||||
[cliAppToolList]="cli/commands/app/cli_app_tool_list.sh"
|
||||
@ -936,15 +937,25 @@ declare -gA LP_FN_MAP=(
|
||||
[updaterDisplayVersion]="webui/data/generators/updater/webui_updater_scan.sh"
|
||||
[updaterInspectLocal]="webui/data/generators/updater/webui_updater_scan.sh"
|
||||
[updaterInWindow]="cli/commands/updater/cli_updater_auto.sh"
|
||||
[updaterLadderSummary]="cli/commands/updater/cli_updater_ladder.sh"
|
||||
[updaterLastUpdateFrom]="cli/commands/updater/cli_updater_commands.sh"
|
||||
[updaterNewerVersionTag]="webui/data/generators/updater/webui_updater_scan.sh"
|
||||
[updaterPrimaryImage]="webui/data/generators/updater/webui_updater_scan.sh"
|
||||
[updaterRecordHistory]="cli/commands/updater/cli_updater_commands.sh"
|
||||
[updaterRefDigest]="cli/commands/updater/cli_updater_commands.sh"
|
||||
[updaterRegistryDigest]="webui/data/generators/updater/webui_updater_scan.sh"
|
||||
[updaterRegistryTags]="webui/data/generators/updater/webui_updater_scan.sh"
|
||||
[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"
|
||||
[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"
|
||||
[updaterTagNums]="webui/data/generators/updater/webui_updater_scan.sh"
|
||||
[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"
|
||||
[updaterVersionLadder]="cli/commands/updater/cli_updater_ladder.sh"
|
||||
[updateTaskFields]="task/crontab_task_processor.sh"
|
||||
[_upReportComposeFailure]="docker/app/compose/up_app.sh"
|
||||
[userExists]="function/checks/user_exists.sh"
|
||||
@ -1302,6 +1313,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[checkUFWRequirement]="scripts"
|
||||
[checkUpdates]="scripts"
|
||||
[checkWebUISystemdRequirement]="scripts"
|
||||
[cleanupOrphanQueueEntries]="scripts"
|
||||
[cleanupZeroByteFiles]="scripts"
|
||||
[cliAppRestore]="scripts"
|
||||
[cliAppToolList]="scripts"
|
||||
@ -1958,15 +1970,25 @@ declare -gA LP_FN_ROOT=(
|
||||
[updaterDisplayVersion]="scripts"
|
||||
[updaterInspectLocal]="scripts"
|
||||
[updaterInWindow]="scripts"
|
||||
[updaterLadderSummary]="scripts"
|
||||
[updaterLastUpdateFrom]="scripts"
|
||||
[updaterNewerVersionTag]="scripts"
|
||||
[updaterPrimaryImage]="scripts"
|
||||
[updaterRecordHistory]="scripts"
|
||||
[updaterRefDigest]="scripts"
|
||||
[updaterRegistryDigest]="scripts"
|
||||
[updaterRegistryTags]="scripts"
|
||||
[updaterRepoTag]="scripts"
|
||||
[updaterRollbackApp]="scripts"
|
||||
[updaterSetAnchorRef]="scripts"
|
||||
[updaterTagExists]="scripts"
|
||||
[updaterTagGreater]="scripts"
|
||||
[updaterTagIncrement]="scripts"
|
||||
[updaterTagNums]="scripts"
|
||||
[updaterTagOf]="scripts"
|
||||
[updaterTagShape]="scripts"
|
||||
[updaterTagSortKey]="scripts"
|
||||
[updaterVersionLadder]="scripts"
|
||||
[updateTaskFields]="scripts"
|
||||
[_upReportComposeFailure]="scripts"
|
||||
[userExists]="scripts"
|
||||
@ -2357,6 +2379,7 @@ checkUFWDRequirement() { unset -f checkUFWDRequirement; __lpAutoload "${install_
|
||||
checkUFWRequirement() { unset -f checkUFWRequirement; __lpAutoload "${install_scripts_dir}checks/requirements/check_ufw.sh"; checkUFWRequirement "$@"; }
|
||||
checkUpdates() { unset -f checkUpdates; __lpAutoload "${install_scripts_dir}update/check_update.sh"; checkUpdates "$@"; }
|
||||
checkWebUISystemdRequirement() { unset -f checkWebUISystemdRequirement; __lpAutoload "${install_scripts_dir}checks/requirements/check_webui_systemd.sh"; checkWebUISystemdRequirement "$@"; }
|
||||
cleanupOrphanQueueEntries() { unset -f cleanupOrphanQueueEntries; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; cleanupOrphanQueueEntries "$@"; }
|
||||
cleanupZeroByteFiles() { unset -f cleanupZeroByteFiles; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; cleanupZeroByteFiles "$@"; }
|
||||
cliAppRestore() { unset -f cliAppRestore; __lpAutoload "${install_scripts_dir}cli/commands/app/cli_app_restore.sh"; cliAppRestore "$@"; }
|
||||
cliAppToolList() { unset -f cliAppToolList; __lpAutoload "${install_scripts_dir}cli/commands/app/cli_app_tool_list.sh"; cliAppToolList "$@"; }
|
||||
@ -3013,15 +3036,25 @@ updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scri
|
||||
updaterDisplayVersion() { unset -f updaterDisplayVersion; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterDisplayVersion "$@"; }
|
||||
updaterInspectLocal() { unset -f updaterInspectLocal; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterInspectLocal "$@"; }
|
||||
updaterInWindow() { unset -f updaterInWindow; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterInWindow "$@"; }
|
||||
updaterLadderSummary() { unset -f updaterLadderSummary; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterLadderSummary "$@"; }
|
||||
updaterLastUpdateFrom() { unset -f updaterLastUpdateFrom; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterLastUpdateFrom "$@"; }
|
||||
updaterNewerVersionTag() { unset -f updaterNewerVersionTag; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterNewerVersionTag "$@"; }
|
||||
updaterPrimaryImage() { unset -f updaterPrimaryImage; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterPrimaryImage "$@"; }
|
||||
updaterRecordHistory() { unset -f updaterRecordHistory; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRecordHistory "$@"; }
|
||||
updaterRefDigest() { unset -f updaterRefDigest; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRefDigest "$@"; }
|
||||
updaterRegistryDigest() { unset -f updaterRegistryDigest; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterRegistryDigest "$@"; }
|
||||
updaterRegistryTags() { unset -f updaterRegistryTags; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterRegistryTags "$@"; }
|
||||
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 "$@"; }
|
||||
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 "$@"; }
|
||||
updaterTagNums() { unset -f updaterTagNums; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagNums "$@"; }
|
||||
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 "$@"; }
|
||||
updaterVersionLadder() { unset -f updaterVersionLadder; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterVersionLadder "$@"; }
|
||||
updateTaskFields() { unset -f updateTaskFields; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; updateTaskFields "$@"; }
|
||||
_upReportComposeFailure() { unset -f _upReportComposeFailure; __lpAutoload "${install_scripts_dir}docker/app/compose/up_app.sh"; _upReportComposeFailure "$@"; }
|
||||
userExists() { unset -f userExists; __lpAutoload "${install_scripts_dir}function/checks/user_exists.sh"; userExists "$@"; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user