#!/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 , 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 exist in ? 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 , 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 : 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 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 [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 )" }