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>
120 lines
5.7 KiB
Bash
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 )"
|
|
}
|