LibrePortal/scripts/cli/commands/updater/cli_updater_ladder.sh
librelad 9c8f0782e1 feat(updater): build dates for off-Hub images, from the config blob
The unmaintained warning runs on one field — when upstream last rebuilt
the image — and off-Hub apps had no value for it. Hub answers in a single
call; the OCI API does not expose it at all, so an app on ghcr.io, quay.io
or lscr.io simply could not be assessed for staleness, which is the one
signal a user cannot work out for themselves.

It is in the image, just further down: manifest -> (if a multi-arch
index) a platform manifest -> config blob, whose "created" is the build
time. Three requests instead of Hub's one, once per registry window, and
only for the apps Hub cannot answer for — which is why Hub keeps its
cheap path rather than being routed through this.

Index and single-arch manifests are distinguished explicitly rather than
by position: in an index the first digest is a CHILD manifest, in an
image manifest it is the config itself, so reading "the first digest"
would silently fetch the wrong blob for one of the two shapes.

Live: stoat 2026-08-08, bookstack 2026-08-17, speedtest 2026-08-16,
invidious 2026-08-05 — all previously null. Hub unchanged, navidrome
still answered by the single-call path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 03:11:29 +01:00

309 lines
15 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 }'
}
# ---------------------------------------------------------------------------
# Generic OCI registry access, for everything that is not Docker Hub.
#
# Hub keeps its own path below: hub.docker.com/v2/repositories is a SEPARATE
# rate-limit pool, whereas registry-1.docker.io manifest reads count against the
# anonymous PULL limit (100/hour). A ladder probes a tag per rung, so routing
# Hub through the OCI API would spend the budget the updater needs for pulls.
#
# Everything else speaks the standard Distribution API and needs only the token
# dance: ask, read the WWW-Authenticate challenge, fetch a token from the realm
# it names, retry. That is all "we cannot probe ghcr.io" ever amounted to —
# ghcr.io, lscr.io (which points its realm at ghcr.io) and quay.io all answer
# anonymously for public images. Until this existed those apps were reported as
# up to date having never been looked at, which is the wrong kind of quiet.
# ---------------------------------------------------------------------------
# Split an image ref into registry + repository. Sets _oci_reg / _oci_repo.
# A first segment containing a dot or a colon, or "localhost", is a host;
# anything else is Hub, where a bare name means the library/ namespace.
_updaterOciSplit() {
local ref="${1%%:*}"
ref="${ref#docker.io/}"; ref="${ref#index.docker.io/}"
case "$ref" in
localhost/*|*.*/*|*:*/*) _oci_reg="${ref%%/*}"; _oci_repo="${ref#*/}" ;;
*/*) _oci_reg="registry-1.docker.io"; _oci_repo="$ref" ;;
*) _oci_reg="registry-1.docker.io"; _oci_repo="library/$ref" ;;
esac
}
# Bearer token for <registry> <repo>, or nothing when the registry does not
# challenge (quay.io serves public repos unauthenticated).
_updaterOciToken() {
local reg="$1" repo="$2" ch realm service scope
ch="$(curl -sS -o /dev/null -D - --connect-timeout 5 --max-time 12 \
"https://${reg}/v2/${repo}/tags/list?n=1" 2>/dev/null \
| tr -d '\r' | grep -i '^www-authenticate:' | head -1)"
[ -n "$ch" ] || return 0
realm="$(printf '%s' "$ch" | grep -o 'realm="[^"]*"' | head -1 | cut -d'"' -f2)"
service="$(printf '%s' "$ch" | grep -o 'service="[^"]*"' | head -1 | cut -d'"' -f2)"
scope="$(printf '%s' "$ch" | grep -o 'scope="[^"]*"' | head -1 | cut -d'"' -f2)"
[ -n "$realm" ] || return 0
[ -n "$scope" ] || scope="repository:${repo}:pull"
curl -fsS --connect-timeout 5 --max-time 12 \
"${realm}?service=${service}&scope=${scope}" 2>/dev/null \
| grep -oE '"(access_)?token"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'
}
# Does <tag> exist in <image-ref>? A manifest HEAD, so nothing is downloaded.
updaterOciTagExists() {
local ref="$1" tag="$2"
[ -n "$tag" ] || return 1
command -v curl >/dev/null 2>&1 || return 1
_updaterOciSplit "$ref"
local tok; tok="$(_updaterOciToken "$_oci_reg" "$_oci_repo")"
local code
code="$(curl -sS -o /dev/null -w '%{http_code}' -I --connect-timeout 5 --max-time 12 \
${tok:+-H "Authorization: Bearer $tok"} \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
"https://${_oci_reg}/v2/${_oci_repo}/manifests/${tag}" 2>/dev/null)"
[ "$code" = "200" ]
}
# Tags for <image-ref>, one per line. NOTE the ordering differs from Hub's:
# /v2/.../tags/list is lexical, not newest-first, and large repos cap the page —
# so a listing can legitimately miss the newest release. That is survivable
# because newer-version discovery falls back to probing, which has no window.
updaterOciTagList() {
command -v curl >/dev/null 2>&1 || return 0
_updaterOciSplit "$1"
local tok; tok="$(_updaterOciToken "$_oci_reg" "$_oci_repo")"
curl -fsSL --connect-timeout 5 --max-time 20 \
${tok:+-H "Authorization: Bearer $tok"} \
"https://${_oci_reg}/v2/${_oci_repo}/tags/list?n=1000" 2>/dev/null \
| sed -E 's/.*"tags"[[:space:]]*:[[:space:]]*\[//; s/\].*//' \
| tr ',' '\n' | grep -oE '"[^"]*"' | tr -d '"' | grep -v '^$'
}
# When was the image behind <image-ref>:<tag> built? ISO8601, empty if unknown.
#
# Hub answers this in one call (updaterTagLastUpdated); the OCI API does not
# expose it at all, so it has to be walked out of the image itself:
# manifest -> (if a multi-arch index) a platform manifest -> config blob
# and the config blob's "created" is the build time. Three requests, once per
# registry window, and only for the apps Hub cannot answer for.
#
# Worth the walk because this single field is what the unmaintained warning
# runs on. Without it an off-Hub app cannot be assessed at all, and "upstream
# stopped rebuilding this a year ago" is exactly the thing a user cannot work
# out for themselves.
updaterOciCreated() {
command -v curl >/dev/null 2>&1 || return 0
local ref="${1%%@*}" tag="$2"
[ -n "$tag" ] || tag="${ref##*:}"
_updaterOciSplit "$ref"
local tok; tok="$(_updaterOciToken "$_oci_reg" "$_oci_repo")"
local -a acc=(
-H 'Accept: application/vnd.oci.image.index.v1+json'
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json'
-H 'Accept: application/vnd.oci.image.manifest.v1+json'
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json'
)
local base="https://${_oci_reg}/v2/${_oci_repo}"
local man
man="$(curl -fsS --connect-timeout 5 --max-time 15 ${tok:+-H "Authorization: Bearer $tok"} \
"${acc[@]}" "${base}/manifests/${tag}" 2>/dev/null | tr -d ' \n')"
[ -n "$man" ] || return 0
# A multi-arch index lists child manifests; descend into the first. A single
# manifest has no "manifests" key and already carries the config reference —
# the distinction matters because in an index the first digest is a CHILD,
# while in an image manifest it is the config itself.
if printf '%s' "$man" | grep -q '"manifests":\['; then
local child
child="$(printf '%s' "$man" | grep -oE '"digest":"sha256:[0-9a-f]{64}"' | head -1 | grep -oE 'sha256:[0-9a-f]{64}')"
[ -n "$child" ] || return 0
man="$(curl -fsS --connect-timeout 5 --max-time 15 ${tok:+-H "Authorization: Bearer $tok"} \
"${acc[@]}" "${base}/manifests/${child}" 2>/dev/null | tr -d ' \n')"
[ -n "$man" ] || return 0
fi
local cfg
cfg="$(printf '%s' "$man" | sed -E 's/.*"config":\{//; s/\}.*//' | grep -oE 'sha256:[0-9a-f]{64}' | head -1)"
[ -n "$cfg" ] || return 0
curl -fsSL --connect-timeout 5 --max-time 20 ${tok:+-H "Authorization: Bearer $tok"} \
"${base}/blobs/${cfg}" 2>/dev/null \
| grep -oE '"created":"[^"]*"' | head -1 | cut -d'"' -f4
}
# 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"
repo="${repo#docker.io/}"; repo="${repo#index.docker.io/}" # docker.io/ IS Hub
# Non-Hub: the standard OCI API rather than a shrug. This is what makes
# ghcr.io / lscr.io / quay.io apps laddered and version-checked at all.
case "$repo" in *.*/*|localhost/*) updaterOciTagExists "$1" "$tag"; return $? ;; esac
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 Nth (0-based) numeric component of a tag by one and zero every
# component after it, so a bump means what a version bump means:
# "v1.158.2", 1 -> "v1.159.0" "31-fpm-alpine", 0 -> "32-fpm-alpine"
# Shape is preserved by construction (only digits change), which is what lets
# callers compare shapes to reject nonsense candidates.
updaterTagBumpAt() {
printf '%s' "$1" | awk -v idx="$2" '{
out=""; n=0; s=$0
while (match(s, /[0-9]+/)) {
pre = substr(s, 1, RSTART-1)
num = substr(s, RSTART, RLENGTH) + 0
s = substr(s, RSTART+RLENGTH)
if (n == idx) num = num + 1; else if (n > idx) num = 0
out = out pre num
n++
}
print out s
}'
}
# The immediately-next PUBLISHED version above $1 in repo $2, or "" if there is
# none. THE step primitive: everything else here is built on it being right.
#
# It exists because bumping only the last component cannot cross a component
# boundary. v1.158.0 -> v1.158.1 -> v1.158.2 … never arrives at v1.159.0, so a
# ladder built that way gave up on the single most common versioning scheme
# there is, and Synapse — which publishes v1.159.0 and no v1.158.1 at all —
# could not be climbed one rung.
#
# So consider a bump of EVERY component (major, minor, patch), keep only the
# candidates that actually exist upstream, and take the SMALLEST of those. That
# is the next release by definition, whether it lands in the patch position or
# crosses into a new major. Candidates that change the tag's shape are dropped,
# so 31-fpm-alpine never becomes 31-apache. Costs one lookup per component.
updaterNextRung() {
local cur="$1" repo="$2"
local shape; shape="$(updaterTagShape "$cur")"
local ncomp; ncomp="$(printf '%s' "$cur" | grep -oE '[0-9]+' | wc -l | tr -d ' ')"
[ "${ncomp:-0}" -gt 0 ] 2>/dev/null || return 0
local best="" i cand
for ((i=0; i<ncomp; i++)); do
cand="$(updaterTagBumpAt "$cur" "$i")"
[ "$(updaterTagShape "$cand")" = "$shape" ] || continue
updaterTagExists "$repo" "$cand" || continue
if [ -z "$best" ] || updaterTagGreater "$best" "$cand"; then best="$cand"; fi
done
printf '%s' "$best"
}
# 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 next
for ((i=0; i<64; i++)); do # bounded: no runaway
# Step to the next version that EXISTS, rather than incrementing blindly
# and testing. Same guarantee as before — every rung is probed, so a
# release missing from any listing can still never be skipped — but it
# can now cross a component boundary, which blind incrementing could
# not: v1.158.0 -> v1.159.0 was unreachable and the whole ladder failed
# closed on it.
next="$(updaterNextRung "$probe" "$repo")"
[ -n "$next" ] || break # nothing further published
updaterTagGreater "$next" "$target" && break # overshot
rungs+=("$next")
probe="$next"
[ "$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 )"
}