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