From 913cacaff0704c192501732d180ee768d6c8d925 Mon Sep 17 00:00:00 2001 From: librelad Date: Wed, 12 Aug 2026 23:52:53 +0100 Subject: [PATCH] feat(updater): version ladder for apps that cannot skip a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../commands/updater/cli_updater_ladder.sh | 119 ++++++++++++++++++ scripts/source/files/arrays/files_cli.sh | 1 + .../source/files/arrays/function_manifest.sh | 33 +++++ 3 files changed, 153 insertions(+) create mode 100644 scripts/cli/commands/updater/cli_updater_ladder.sh diff --git a/scripts/cli/commands/updater/cli_updater_ladder.sh b/scripts/cli/commands/updater/cli_updater_ladder.sh new file mode 100644 index 0000000..1c71780 --- /dev/null +++ b/scripts/cli/commands/updater/cli_updater_ladder.sh @@ -0,0 +1,119 @@ +#!/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 )" +} diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 616ffae..92b3cca 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -54,6 +54,7 @@ cli_scripts=( "cli/commands/updater/cli_updater_auto.sh" "cli/commands/updater/cli_updater_commands.sh" "cli/commands/updater/cli_updater_header.sh" + "cli/commands/updater/cli_updater_ladder.sh" "cli/commands/validation/cli_validation_commands.sh" "cli/commands/validation/cli_validation_header.sh" "cli/commands/verify/cli_verify_commands.sh" diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 28e799b..20ed085 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -280,6 +280,7 @@ declare -gA LP_FN_MAP=( [checkUFWRequirement]="checks/requirements/check_ufw.sh" [checkUpdates]="update/check_update.sh" [checkWebUISystemdRequirement]="checks/requirements/check_webui_systemd.sh" + [cleanupOrphanQueueEntries]="task/crontab_task_processor.sh" [cleanupZeroByteFiles]="task/crontab_task_processor.sh" [cliAppRestore]="cli/commands/app/cli_app_restore.sh" [cliAppToolList]="cli/commands/app/cli_app_tool_list.sh" @@ -936,15 +937,25 @@ declare -gA LP_FN_MAP=( [updaterDisplayVersion]="webui/data/generators/updater/webui_updater_scan.sh" [updaterInspectLocal]="webui/data/generators/updater/webui_updater_scan.sh" [updaterInWindow]="cli/commands/updater/cli_updater_auto.sh" + [updaterLadderSummary]="cli/commands/updater/cli_updater_ladder.sh" [updaterLastUpdateFrom]="cli/commands/updater/cli_updater_commands.sh" + [updaterNewerVersionTag]="webui/data/generators/updater/webui_updater_scan.sh" [updaterPrimaryImage]="webui/data/generators/updater/webui_updater_scan.sh" [updaterRecordHistory]="cli/commands/updater/cli_updater_commands.sh" [updaterRefDigest]="cli/commands/updater/cli_updater_commands.sh" [updaterRegistryDigest]="webui/data/generators/updater/webui_updater_scan.sh" + [updaterRegistryTags]="webui/data/generators/updater/webui_updater_scan.sh" [updaterRepoTag]="webui/data/generators/updater/webui_updater_scan.sh" [updaterRollbackApp]="cli/commands/updater/cli_updater_commands.sh" [updaterSetAnchorRef]="cli/commands/updater/cli_updater_commands.sh" + [updaterTagExists]="cli/commands/updater/cli_updater_ladder.sh" + [updaterTagGreater]="webui/data/generators/updater/webui_updater_scan.sh" + [updaterTagIncrement]="cli/commands/updater/cli_updater_ladder.sh" + [updaterTagNums]="webui/data/generators/updater/webui_updater_scan.sh" [updaterTagOf]="webui/data/generators/updater/webui_updater_scan.sh" + [updaterTagShape]="webui/data/generators/updater/webui_updater_scan.sh" + [updaterTagSortKey]="cli/commands/updater/cli_updater_ladder.sh" + [updaterVersionLadder]="cli/commands/updater/cli_updater_ladder.sh" [updateTaskFields]="task/crontab_task_processor.sh" [_upReportComposeFailure]="docker/app/compose/up_app.sh" [userExists]="function/checks/user_exists.sh" @@ -1302,6 +1313,7 @@ declare -gA LP_FN_ROOT=( [checkUFWRequirement]="scripts" [checkUpdates]="scripts" [checkWebUISystemdRequirement]="scripts" + [cleanupOrphanQueueEntries]="scripts" [cleanupZeroByteFiles]="scripts" [cliAppRestore]="scripts" [cliAppToolList]="scripts" @@ -1958,15 +1970,25 @@ declare -gA LP_FN_ROOT=( [updaterDisplayVersion]="scripts" [updaterInspectLocal]="scripts" [updaterInWindow]="scripts" + [updaterLadderSummary]="scripts" [updaterLastUpdateFrom]="scripts" + [updaterNewerVersionTag]="scripts" [updaterPrimaryImage]="scripts" [updaterRecordHistory]="scripts" [updaterRefDigest]="scripts" [updaterRegistryDigest]="scripts" + [updaterRegistryTags]="scripts" [updaterRepoTag]="scripts" [updaterRollbackApp]="scripts" [updaterSetAnchorRef]="scripts" + [updaterTagExists]="scripts" + [updaterTagGreater]="scripts" + [updaterTagIncrement]="scripts" + [updaterTagNums]="scripts" [updaterTagOf]="scripts" + [updaterTagShape]="scripts" + [updaterTagSortKey]="scripts" + [updaterVersionLadder]="scripts" [updateTaskFields]="scripts" [_upReportComposeFailure]="scripts" [userExists]="scripts" @@ -2357,6 +2379,7 @@ checkUFWDRequirement() { unset -f checkUFWDRequirement; __lpAutoload "${install_ checkUFWRequirement() { unset -f checkUFWRequirement; __lpAutoload "${install_scripts_dir}checks/requirements/check_ufw.sh"; checkUFWRequirement "$@"; } checkUpdates() { unset -f checkUpdates; __lpAutoload "${install_scripts_dir}update/check_update.sh"; checkUpdates "$@"; } checkWebUISystemdRequirement() { unset -f checkWebUISystemdRequirement; __lpAutoload "${install_scripts_dir}checks/requirements/check_webui_systemd.sh"; checkWebUISystemdRequirement "$@"; } +cleanupOrphanQueueEntries() { unset -f cleanupOrphanQueueEntries; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; cleanupOrphanQueueEntries "$@"; } cleanupZeroByteFiles() { unset -f cleanupZeroByteFiles; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; cleanupZeroByteFiles "$@"; } cliAppRestore() { unset -f cliAppRestore; __lpAutoload "${install_scripts_dir}cli/commands/app/cli_app_restore.sh"; cliAppRestore "$@"; } cliAppToolList() { unset -f cliAppToolList; __lpAutoload "${install_scripts_dir}cli/commands/app/cli_app_tool_list.sh"; cliAppToolList "$@"; } @@ -3013,15 +3036,25 @@ updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scri updaterDisplayVersion() { unset -f updaterDisplayVersion; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterDisplayVersion "$@"; } updaterInspectLocal() { unset -f updaterInspectLocal; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterInspectLocal "$@"; } updaterInWindow() { unset -f updaterInWindow; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterInWindow "$@"; } +updaterLadderSummary() { unset -f updaterLadderSummary; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterLadderSummary "$@"; } updaterLastUpdateFrom() { unset -f updaterLastUpdateFrom; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterLastUpdateFrom "$@"; } +updaterNewerVersionTag() { unset -f updaterNewerVersionTag; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterNewerVersionTag "$@"; } updaterPrimaryImage() { unset -f updaterPrimaryImage; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterPrimaryImage "$@"; } updaterRecordHistory() { unset -f updaterRecordHistory; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRecordHistory "$@"; } updaterRefDigest() { unset -f updaterRefDigest; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRefDigest "$@"; } updaterRegistryDigest() { unset -f updaterRegistryDigest; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterRegistryDigest "$@"; } +updaterRegistryTags() { unset -f updaterRegistryTags; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterRegistryTags "$@"; } updaterRepoTag() { unset -f updaterRepoTag; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterRepoTag "$@"; } updaterRollbackApp() { unset -f updaterRollbackApp; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRollbackApp "$@"; } updaterSetAnchorRef() { unset -f updaterSetAnchorRef; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterSetAnchorRef "$@"; } +updaterTagExists() { unset -f updaterTagExists; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterTagExists "$@"; } +updaterTagGreater() { unset -f updaterTagGreater; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagGreater "$@"; } +updaterTagIncrement() { unset -f updaterTagIncrement; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterTagIncrement "$@"; } +updaterTagNums() { unset -f updaterTagNums; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagNums "$@"; } updaterTagOf() { unset -f updaterTagOf; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagOf "$@"; } +updaterTagShape() { unset -f updaterTagShape; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterTagShape "$@"; } +updaterTagSortKey() { unset -f updaterTagSortKey; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterTagSortKey "$@"; } +updaterVersionLadder() { unset -f updaterVersionLadder; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_ladder.sh"; updaterVersionLadder "$@"; } updateTaskFields() { unset -f updateTaskFields; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; updateTaskFields "$@"; } _upReportComposeFailure() { unset -f _upReportComposeFailure; __lpAutoload "${install_scripts_dir}docker/app/compose/up_app.sh"; _upReportComposeFailure "$@"; } userExists() { unset -f userExists; __lpAutoload "${install_scripts_dir}function/checks/user_exists.sh"; userExists "$@"; }