feat(updater): P3 — honest pinned apply/revert

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
  <slug>-service image), not `grep -m1 image:` — fixes ollama et al.
- Records EXACT build refs in history from->to: repo:tag@sha256:<digest>
  (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) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-07-17 23:54:46 +01:00
parent 67e807c617
commit e4297fc77e

View File

@ -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 (<slug>-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 <slug>-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.