Audit of per-app hooks/tools found 19 of 33 apps whose helpers would have
operated on the BASE app after cloning. Two general causes, both fixed by
rewriting classes rather than patching apps:
- Container references escaped the rewrite whenever a flag sat between the
docker verb and the target (`docker exec -u git gitea-service …`), since the
old rule only matched a name immediately after the verb — and the hyphenated
form missed the `<type>_` rule too. Hook trees now get the same discovered
identity rename the compose does, reading names from the TYPE's compose since
the clone has already been rewritten by then. Safe to apply broadly: the
compose pass runs first and aborts for any app whose identities aren't
<type>-prefixed, so a bare word like stoat's `api` never reaches it.
- Hooks that build the deployed path as "${containers_dir}<type>/..." instead
of "$containers_dir$app_name/..." read and WROTE the base app's files —
adguard's auth adapter edits AdGuardHome.yaml, so an instance would have
rewritten the original's config. The trailing slash is optional in the match:
dashy tests [[ -d "${containers_dir}dashy" ]] and gluetun cds into it, both
ending at the quote. Only the first path component is touched, so
${containers_dir}prometheus/prometheus/... keeps its inner segment.
Re-audit: all 33 apps with hook trees are clean. Stoat still leaks, but it is
refused at the compose stage and never reaches this code.
Volumes audited too, and need no changes: no app uses named volumes, so the
./relative bind mounts every app uses resolve inside each instance's own
deployed dir. The absolute sources that exist are host or in-container paths
correctly shared read-only (/etc/localtime, /sys, /etc/ssl/certs). Jitsi's
${CONFIG} is set per-app by its own hook to $containers_dir$app_name/... and so
follows the slug.
Bookstack's rewritten tool tree is byte-identical to the live instance's across
all 8 files, so the running instances are unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
8.5 KiB
Bash
176 lines
8.5 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"
|
|
repo="${repo#docker.io/}"; repo="${repo#index.docker.io/}" # docker.io/ IS Hub
|
|
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 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 )"
|
|
}
|