diff --git a/scripts/cli/commands/updater/cli_updater_upgrade.sh b/scripts/cli/commands/updater/cli_updater_upgrade.sh index 90b792e..b617c55 100644 --- a/scripts/cli/commands/updater/cli_updater_upgrade.sh +++ b/scripts/cli/commands/updater/cli_updater_upgrade.sh @@ -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 _VERSION_TAG sentinel, not by +# guessing "-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 }