LibrePortal/scripts/cli/commands/updater/cli_updater_ladder.sh
librelad 913cacaff0 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>
2026-08-12 23:52:53 +01:00

120 lines
5.7 KiB
Bash

#!/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 )"
}