fix(updater): move lock-step services together, and find the anchor at all

updaterSetAnchorVersion located the anchor by looking for a service
literally named "<app>-service". That is a convention, not a rule:
matrix names its anchor service matrix-synapse and stoat names its api.
For those apps nothing matched, so the rewrite changed no lines and the
upgrade aborted at step 2 with "could not set version" — after having
already taken a snapshot. Dry runs never showed it because they return
before that step. It now finds the anchor by its bare <APP>_VERSION_TAG
sentinel, the same way updaterPrimaryImage does.

It also moved only the anchor. Some apps are one product shipped as many
images: stoat is nine stoatchat services released together, all on
v0.15.1, expecting matching versions of each other. Stepping the anchor
alone would have put api on v0.16 while events stayed on v0.15.1 — the
exact mismatch that once justified keeping the app off automatic
updates.

The lock-step set is DERIVED from the compose rather than configured,
because the compose already states it: a service moves with the anchor
when it carries a version sentinel, sits on the SAME tag, and shares the
anchor's registry namespace. Both tests are load-bearing and each
rejects a real case — livekit-server is same-namespace but on its own
cadence, for-web is pinned to a commit hash, element-web is a different
namespace entirely, and mongo has no namespace at all. Verified against
copies of five composes: stoat moves all eight sibling services and
nothing else; matrix, rocketchat and nextcloud move exactly one image.

Every sentinel that moved gets its CFG_*_VERSION key set, not just the
anchor's, or the next config-driven regeneration would quietly pull the
locked-step services back to the old version.

One trap worth naming: quotes were stripped with sed 's/["\047]//g',
but \047 is an octal escape awk honours and sed does not — in a sed
bracket expression it is the literal characters \ 0 4 7, so it deleted
every 0, 4 and 7 it saw and v0.15.1 arrived as v.15.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-19 21:00:06 +01:00
parent 64ff5f508b
commit 8a997e14dd

View File

@ -27,38 +27,90 @@
_updaterUpgradeGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
# Rewrite the anchor image AND its version sentinel, so the live compose stays
# self-consistent. updaterSetAnchorRef preserves the trailing comment verbatim,
# which would leave the sentinel advertising the OLD version — and the next
# config-driven regeneration would then quietly revert the app. Both or neither.
# Rewrite the anchor image AND every image locked in step with it, plus their
# version sentinels, so the live compose stays self-consistent.
#
# THE LOCK-STEP PROBLEM. Some apps are one product shipped as many images:
# Stoat is nine stoatchat services released together, all on v0.15.1, and they
# expect matching versions of each other. Moving only the anchor would put api
# on v0.16 while events stayed on v0.15.1 — precisely the API/events mismatch
# that once justified keeping the app off automatic updates. So the set has to
# move together or not at all.
#
# The set is DERIVED, not configured, because the compose already states it:
# a service is locked in step with the anchor when it carries a version
# sentinel, currently sits on the SAME tag, and lives under the same registry
# namespace. Both conditions are needed and each rejects a real case here:
# ghcr.io/stoatchat/events:v0.15.1 same ns, same tag -> moves
# ghcr.io/stoatchat/livekit-server:v1.9.13 same ns, other tag -> stays
# vectorim/element-web:v1.12.25 other ns -> stays
# mongo:8.0 no namespace -> stays
# A sidecar that coincidentally shares a version number is excluded by the
# namespace test; a sibling on its own release cadence by the tag test.
#
# The anchor itself is found by its BARE <APP>_VERSION_TAG sentinel, not by
# guessing "<app>-service". That guess was wrong for every app that names its
# services anything else — matrix (matrix-synapse) and stoat (api) among them —
# and since nothing matched, the rewrite silently changed no lines and the
# upgrade aborted at "could not set version". Dry runs never showed it: they
# return before this point.
updaterSetAnchorVersion() {
local app="$1" newtag="$2"
local compose="${containers_dir%/}/$app/docker-compose.yml"
[ -f "$compose" ] || return 1
local svc="${app//_/-}-service"
local up; up="$(printf '%s' "$app" | tr '[:lower:]' '[:upper:]')"
# Anchor line -> its current tag and registry namespace.
local aline; aline="$(grep -E "#LIBREPORTAL\|${up}_VERSION_TAG\|" "$compose" 2>/dev/null | head -1)"
[ -n "$aline" ] || return 1
# tr, not sed, to strip quotes: in a sed bracket expression \047 is not an
# octal escape but the literal characters \ 0 4 7, so it silently deleted
# every 0, 4 and 7 in the ref -- v0.15.1 became v.15.1. awk does honour the
# escape, which is why the same idiom is fine below.
local aref; aref="$(printf '%s' "$aline" | sed -E 's/^[[:space:]]*image:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d "\"' ")"
local atag="${aref##*:}"; local arepo="${aref%:*}"
case "$aref" in */*:*|*:*) : ;; *) return 1 ;; esac
local ans=""; case "$arepo" in */*) ans="${arepo%/*}" ;; esac
[ -n "$atag" ] || return 1
local tmp; tmp="$(mktemp)"
awk -v s="$svc" -v tag="$newtag" -v key="${up}_VERSION_TAG" '
!done && seen && /^[[:space:]]*image:/ {
awk -v newtag="$newtag" -v atag="$atag" -v ans="$ans" '
/^[[:space:]]*image:/ && index($0, "#LIBREPORTAL|") && index($0, "_VERSION_TAG|") {
match($0,/^[[:space:]]*/); ind=substr($0,1,RLENGTH)
line=$0; sub(/^[[:space:]]*image:[[:space:]]*/,"",line)
sub(/[[:space:]]*#.*$/,"",line); gsub(/["'"'"']/,"",line)
repo=line; sub(/:[^:\/]*$/,"",repo) # strip the old tag
printf "%simage: %s:%s #LIBREPORTAL|%s|%s\n", ind, repo, tag, key, tag
done=1; next
# key = the sentinel this line owns; each keeps its own.
k=$0; sub(/^.*#LIBREPORTAL\|/,"",k); sub(/\|.*$/,"",k)
ref=$0; sub(/^[[:space:]]*image:[[:space:]]*/,"",ref)
sub(/[[:space:]]*#.*$/,"",ref); gsub(/["\047]/,"",ref)
if (ref ~ /:/) {
tag=ref; sub(/^.*:/,"",tag)
repo=ref; sub(/:[^:\/]*$/,"",repo)
ns=""; if (repo ~ /\//) { ns=repo; sub(/\/[^\/]*$/,"",ns) }
if (tag == atag && ns == ans) {
printf "%simage: %s:%s #LIBREPORTAL|%s|%s\n", ind, repo, newtag, k, newtag
changed++
next
}
}
}
$0 ~ ("^[[:space:]]*" s ":") { seen=1 }
{ print }
END { if (!changed) exit 3 }
' "$compose" > "$tmp" || { rm -f "$tmp"; return 1; }
grep -q "image:.*:${newtag}" "$tmp" || { rm -f "$tmp"; return 1; }
runFileWrite "$compose" < "$tmp"; local rc=$?
rm -f "$tmp"
# Keep the config key in step when the app has one, so the WebUI's Version
# field shows what is actually deployed rather than what it used to be.
local cfgkey="CFG_${up}_VERSION"
if [ -n "${!cfgkey+x}" ] && declare -f updateConfigOption >/dev/null 2>&1; then
updateConfigOption "$cfgkey" "$newtag" >/dev/null 2>&1 || true
# Keep the config keys in step so the WebUI's Version field shows what is
# actually deployed. Every sentinel now on the new tag gets its key set, not
# just the anchor's — otherwise the next config-driven regeneration would
# quietly pull the locked-step services back to the old version.
if declare -f updateConfigOption >/dev/null 2>&1; then
local k cfgkey
while IFS= read -r k; do
[ -n "$k" ] || continue
cfgkey="CFG_${k%_VERSION_TAG}_VERSION"
[ -n "${!cfgkey+x}" ] && updateConfigOption "$cfgkey" "$newtag" >/dev/null 2>&1
done < <(grep -oE "#LIBREPORTAL\|[A-Z0-9_]+_VERSION_TAG\|${newtag}" "$compose" 2>/dev/null \
| sed -E 's/#LIBREPORTAL\|//; s/\|.*$//' | sort -u)
fi
return $rc
}