#!/bin/bash # Upgrade verification — "did that rung actually land?" # --------------------------------------------------------------------------- # A stepped upgrade is only as safe as its verification. Stepping 31 -> 32 -> 33 # is arithmetic; knowing that 32 FINISHED before touching 33 is the whole ball # game, because the dangerous state is invisible from the outside: Nextcloud # runs its migration on boot and can sit in maintenance mode, or fail halfway, # while Docker cheerfully reports the container healthy. Advance a rung then and # you have skipped a migration on live data. # # So "the container is up" is explicitly NOT accepted as proof for an app that # declares a verifier. Contract: # # _upgrade_verify -> 0 = verified # # It must poll until the deadline and return 0 ONLY when it can positively # confirm the app is serving at the expected version with no migration # outstanding. Any other outcome — unhealthy, indeterminate, timed out — must # return non-zero. Uncertainty is a failure here, not a maybe: the engine # aborts and restores rather than guessing, which is the only honest reading # when the alternative risks someone's data. # # Apps with no verifier fall back to updaterVerifyGeneric, which is deliberately # conservative and is NOT sufficient for a stepped upgrade — the engine refuses # to ladder an app that has not declared a real one. # Container name for an app's primary service, matching the compose convention. _updaterPrimaryContainer() { printf '%s-service' "${1//_/-}"; } # Generic health: running, not restarting, healthcheck (if any) reporting # healthy, and STILL true after a settle period — a crash-loop looks perfect # in the instant between restarts. Good enough for a single in-place update, # never good enough to justify climbing another rung. updaterVerifyGeneric() { local app="$1" deadline="${3:-$(( $(date +%s) + 120 ))}" local c; c="$(_updaterPrimaryContainer "$app")" local stable_needed=3 stable=0 while [ "$(date +%s)" -lt "$deadline" ]; do local state health restarts state="$(dockerCommandRun "docker inspect --format '{{.State.Status}}' $c" 2>/dev/null | tr -d '\r')" health="$(dockerCommandRun "docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $c" 2>/dev/null | tr -d '\r')" restarts="$(dockerCommandRun "docker inspect --format '{{.RestartCount}}' $c" 2>/dev/null | tr -d '\r')" if [ "$state" = "running" ] && { [ "$health" = "healthy" ] || [ "$health" = "none" ]; }; then stable=$((stable + 1)) [ -z "${_uv_restarts:-}" ] && _uv_restarts="$restarts" # A restart during the settle window means it is looping, not up. [ "$restarts" != "$_uv_restarts" ] && stable=0 && _uv_restarts="$restarts" (( stable >= stable_needed )) && { unset _uv_restarts; return 0; } else stable=0 fi sleep 5 done unset _uv_restarts return 1 } # Does this app ship a real verifier? The stepped engine requires one. updaterHasVerifier() { local app="$1" declare -F "${app}_upgrade_verify" >/dev/null 2>&1 } # updaterVerifyUpgrade [timeout-secs] # Dispatches to the app's verifier, falling back to the generic check. Returns # 0 only on positive confirmation. updaterVerifyUpgrade() { local app="$1" expected="$2" timeout="${3:-600}" local deadline=$(( $(date +%s) + timeout )) if updaterHasVerifier "$app"; then isNotice "Verifying $app is serving $expected (up to ${timeout}s)…" if "${app}_upgrade_verify" "$app" "$expected" "$deadline"; then isSuccessful "$app verified at $expected." return 0 fi isError "$app did NOT verify at $expected — treating as failed." return 1 fi isNotice "$app has no upgrade verifier; using the generic health check." updaterVerifyGeneric "$app" "$expected" "$deadline" }