From e4297fc77eee751e2270a94bb13bd3ab5140a184 Mon Sep 17 00:00:00 2001 From: librelad Date: Fri, 17 Jul 2026 23:54:46 +0100 Subject: [PATCH] =?UTF-8?q?feat(updater):=20P3=20=E2=80=94=20honest=20pinn?= =?UTF-8?q?ed=20apply/revert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Update / Roll-back buttons tell the truth, closing the "new code on old data" hole a floating tag creates. updaterApplyApp: - Anchor-correct: capture before/after from updaterPrimaryImage (the -service image), not `grep -m1 image:` — fixes ollama et al. - Records EXACT build refs in history from->to: repo:tag@sha256: (via updaterRefDigest), so history is meaningful even when the tag doesn't move (a rebuilt `latest`). - Un-pins any digest a prior rollback pinned before pulling, so Update tracks the channel again instead of freezing on the rolled-back build. updaterRollbackApp: - Before recreating, re-pins the anchor image to the pre-update build's digest (from history's last update/ok `from`) via updaterSetAnchorRef, so `up` runs the OLD code — not the current channel head. This is the fix for restoring a data snapshot but recreating on a newer image. New helpers (cli_updater_commands.sh): updaterRefDigest (local RepoDigest), updaterSetAnchorRef (rewrite the anchor image line by service name, preserving indent + the version sentinel; correct for companion-first apps like ollama), updaterLastUpdateFrom (roll-back target from history). Verified the helpers on nextcloud + ollama: pin adds @sha256 to the right anchor only, sidecars untouched, sentinel preserved, unpin restores, YAML valid. Caught and fixed a `local a=$1 b=...$a...` same-statement expansion bug that would have silently no-op'd the rollback pin. End-to-end apply/revert not exercised live here (no installed app has a pending update on this box). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: librelad --- .../commands/updater/cli_updater_commands.sh | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index ce61d55..0feb9e3 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -97,6 +97,46 @@ cliHandleUpdaterCommands() esac } +# Digest (sha256:…) of a locally-present image ref; "" if absent. Rootless-aware. +updaterRefDigest() +{ + local ref="$1" out + out="$(dockerCommandRun "docker inspect --format '{{index .RepoDigests 0}}' $ref" 2>/dev/null | tr -d '\r' | head -1)" + out="${out##*@}"; case "$out" in sha256:*) printf '%s' "$out";; esac +} + +# Rewrite the app's ANCHOR (-service) image line to $newref, preserving the +# original indent and any trailing ` #LIBREPORTAL|…` version sentinel. Targets the +# anchor by service name (not the first image line — some apps list a companion +# first), so it's correct for ollama et al. +updaterSetAnchorRef() +{ + local app="$1" newref="$2" + local compose="${containers_dir%/}/$app/docker-compose.yml" + [ -f "$compose" ] || return 1 + local tmp; tmp="$(mktemp)" + awk -v s="${app//_/-}-service" -v ref="$newref" ' + !done && seen && /^[[:space:]]*image:/ { + match($0,/^[[:space:]]*/); ind=substr($0,1,RLENGTH) + cmt=""; if (match($0,/#.*/)) cmt=" " substr($0,RSTART) + print ind "image: " ref cmt; done=1; next + } + $0 ~ ("^[[:space:]]*" s ":") { seen=1 } + { print } + ' "$compose" > "$tmp" || { rm -f "$tmp"; return 1; } + runFileWrite "$compose" < "$tmp"; local rc=$? + rm -f "$tmp"; return $rc +} + +# The image ref (repo:tag@sha256:…) the app ran BEFORE its last successful update +# — i.e. the roll-back target. Read from history.json's most recent update/ok. +updaterLastUpdateFrom() +{ + local app="$1" hist="$containers_dir/libreportal/frontend/data/updater/generated/history.json" + [ -f "$hist" ] && command -v jq >/dev/null 2>&1 || return 0 + jq -r --arg a "$app" 'first(.entries[]? | select(.app==$a and .action=="update" and .result=="ok") | .from) // ""' "$hist" 2>/dev/null +} + # Update one app with disaster-recovery: snapshot -> pull -> recreate -> verify, # auto-rolling-back on failure. Uses existing primitives (the backup CLI for the # snapshot, docker compose for the image swap) so it shares their locking/logging. @@ -121,13 +161,21 @@ updaterApplyApp() return 1 fi - # 2. Capture the current image so we can record from->to / roll back. - local before; before="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')" + # 2. Capture the current ANCHOR image + its running digest, so from->to is an + # exact build reference (repo:tag@sha256:…) even for a floating tag. Anchor is + # the -service image (updaterPrimaryImage), NOT the first line. If a + # prior rollback pinned a digest, unpin it first so we track the channel again. + local anchor; anchor="$(updaterPrimaryImage "$app" "$app_dir/docker-compose.yml")" + case "$anchor" in *@sha256:*) updaterSetAnchorRef "$app" "${anchor%%@*}"; anchor="${anchor%%@*}";; esac + local before_dig; before_dig="$(updaterRefDigest "$anchor")" + local before="$anchor${before_dig:+@$before_dig}" # 3. Pull + recreate (uses the real, install-type-aware compose helpers). isNotice "Pulling new image(s) for $app…" if updaterComposePull "$app" && dockerComposeUp "$app" >/dev/null 2>&1; then - local after; after="$(grep -m1 -E '^\s*image:' "$app_dir/docker-compose.yml" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g')" + local after_ref; after_ref="$(updaterPrimaryImage "$app" "$app_dir/docker-compose.yml")"; after_ref="${after_ref%%@*}" + local after_dig; after_dig="$(updaterRefDigest "$after_ref")" + local after="$after_ref${after_dig:+@$after_dig}" updaterRecordHistory "$app" "update" "$before" "$after" "ok" isSuccessful "$app updated. Rollback point retained." webuiUpdaterScan >/dev/null 2>&1 || true @@ -162,6 +210,15 @@ updaterRollbackApp() { local app="$1" mode="$2" [[ "$mode" != "auto" ]] && isHeader "Rolling $app back to its pre-update snapshot" + # Re-pin the ANCHOR image to the exact build the app ran before the last + # update, so `up` below runs the OLD code — not the current channel head. + # Without this, restoring the data snapshot but recreating on the new `latest` + # image is "new code on old data", the hole a floating tag makes invisible. + # (Preserves the version sentinel; apply un-pins it again on the next update.) + local prev_ref; prev_ref="$(updaterLastUpdateFrom "$app")" + if [[ -n "$prev_ref" && "$prev_ref" == *@sha256:* ]]; then + updaterSetAnchorRef "$app" "$prev_ref" && isNotice "Pinned $app back to its pre-update build." + fi # Delegate to the restore engine (latest snapshot for this app). Call the # function directly — the old `backup app "$app" restore latest` CLI form was # malformed (parsed as action="$app") so it silently did nothing yet exited 0.