docker restart uses the image ID baked in at container creation — it never picks up a new digest. rebuild_container reads the stored Unraid XML template, stops the old container, recreates it with the new image, then handles the old image cleanup. This is what the Unraid UI 'Apply Update' button does. docker_update.sh: add rebuild section after pull loop for DAILY containers. docker_update_remaining.sh: replace retry_docker docker restart with rebuild_container. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
386 lines
16 KiB
Bash
386 lines
16 KiB
Bash
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ================================= Docker Update ==============================================
|
|
# ==============================================================================================
|
|
#
|
|
# PURPOSE
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Pulls the latest images for configured containers. Two modes: normal (daily)
|
|
# and remainder (weekly).
|
|
#
|
|
# Normal mode is called by daily_sync_maintenance.sh before docker_daily_restart.sh.
|
|
# Containers stay running during the pull — no extra downtime beyond what the
|
|
# nightly restart already causes.
|
|
#
|
|
# Remainder mode is called by weekly_sync_maintenance.sh as the final update step.
|
|
# It catches everything that normal mode and the weekly sync window did not already
|
|
# update — derived automatically from docker ps, nothing to configure.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL MODEL
|
|
# ==============================================================================================
|
|
#
|
|
# Normal mode (daily):
|
|
# Targets DAILY_RESTART_CONTAINERS — same list used by docker_daily_restart.sh.
|
|
# Pull → compare old vs new image ID → mark updated or already current.
|
|
# docker_daily_restart.sh runs after — containers restart onto the fresh image.
|
|
#
|
|
# Remainder mode (weekly):
|
|
# Targets all currently running containers NOT in:
|
|
# DAILY_RESTART_CONTAINERS — already updated daily
|
|
# emby + critical-data profiles — updated inline by the weekly sync window
|
|
# FALLBACK_*_TIER* — owned by the remote server's update cycle
|
|
# Pull → compare → prune dangling images.
|
|
#
|
|
# ==============================================================================================
|
|
# DESIGN PRINCIPLES
|
|
# ==============================================================================================
|
|
#
|
|
# Single List
|
|
# Normal mode reuses DAILY_RESTART_CONTAINERS rather than maintaining a
|
|
# separate update list. Adding or removing a container from the restart list
|
|
# automatically updates the image pull list — one change, both places.
|
|
#
|
|
# Version Ownership
|
|
# Fallback containers are excluded from remainder mode. This server only runs
|
|
# them during a failover. The remote server owns their version — if remainder
|
|
# updates them independently and a handback occurs, the remote's older image
|
|
# may not handle data written by the newer version.
|
|
#
|
|
# State Respect
|
|
# Stopped containers are never targeted. Pulling while stopped adds no value
|
|
# and a stopped container was likely halted intentionally.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL SAFEGUARDS
|
|
# ==============================================================================================
|
|
#
|
|
# Lock Acquisition
|
|
# Prevents concurrent execution via acquire_lock(). Safe to call from
|
|
# maintenance scripts without risk of overlap.
|
|
#
|
|
# Host Detection
|
|
# detect_hosts() identifies which server is running the script and aliases
|
|
# HOST*_DAILY_RESTART_CONTAINERS to the correct host's values.
|
|
#
|
|
# Root Enforcement
|
|
# Docker operations require root privileges.
|
|
#
|
|
# DAILY_CONTAINER_UPDATES Toggle
|
|
# Normal mode exits cleanly when disabled. docker_daily_restart.sh still runs
|
|
# regardless — update and restart are independent operations.
|
|
#
|
|
# Fallback Exclusion
|
|
# Remainder mode excludes containers owned by the remote server's update cycle
|
|
# to prevent version divergence across the failover boundary.
|
|
#
|
|
# Running-Only Filter
|
|
# Stopped containers excluded from remainder mode — intentionally down.
|
|
#
|
|
# Image ID Comparison
|
|
# Containers not restarted unless their image actually changed. Pulls that
|
|
# result in "already up to date" produce no restart.
|
|
#
|
|
# ==============================================================================================
|
|
# CONFIGURATION
|
|
# ==============================================================================================
|
|
#
|
|
# master.conf
|
|
#
|
|
# DAILY_CONTAINER_UPDATES
|
|
# Enable or disable normal mode. docker_daily_restart.sh runs regardless.
|
|
# (default: true)
|
|
#
|
|
# PROFILE_CRITICAL_CONTAINER_NAMES[emby|critical-data]
|
|
# Container names for emby and critical-data profiles — excluded from
|
|
# remainder mode (already updated by the weekly sync window)
|
|
#
|
|
# host*.conf
|
|
#
|
|
# HOST*_DAILY_RESTART_CONTAINERS
|
|
# Containers updated in normal mode. Aliased by detect_hosts() →
|
|
# DAILY_RESTART_CONTAINERS
|
|
#
|
|
# ==============================================================================================
|
|
# RUNTIME MODES
|
|
# ==============================================================================================
|
|
#
|
|
# docker_update.sh
|
|
# Normal mode — pull latest images for DAILY_RESTART_CONTAINERS
|
|
#
|
|
# docker_update.sh --remainder
|
|
# Remainder mode — pull all running containers not in managed lists,
|
|
# restart those that received updates, prune dangling images
|
|
#
|
|
# docker_update.sh --dry-run
|
|
# Preview which containers would be pulled without making changes
|
|
#
|
|
# docker_update.sh --status
|
|
# Show configuration and container list for current mode
|
|
#
|
|
# docker_update.sh --log
|
|
# Verbose per-container pull and comparison output
|
|
#
|
|
# ==============================================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
source "$SCRIPT_DIR/../load_config.sh"
|
|
|
|
# Pre-parse --remainder before parse_args (unknown args pass through to PARSED_ARGS)
|
|
REMAINDER_MODE=false
|
|
_filtered_args=()
|
|
for _arg in "$@"; do
|
|
if [[ "$_arg" == "--remainder" ]]; then
|
|
REMAINDER_MODE=true
|
|
else
|
|
_filtered_args+=("$_arg")
|
|
fi
|
|
done
|
|
unset _arg
|
|
|
|
parse_args "${_filtered_args[@]}"
|
|
unset _filtered_args
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Setup ━━━
|
|
# ==============================================================================================
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
error "Must be run as root"
|
|
exit 1
|
|
fi
|
|
|
|
acquire_lock
|
|
|
|
if ! command -v docker &>/dev/null; then
|
|
error "Docker command not found"
|
|
exit 1
|
|
fi
|
|
|
|
detect_hosts
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Container Discovery ━━━
|
|
# ==============================================================================================
|
|
if [[ "$REMAINDER_MODE" == true ]]; then
|
|
declare -A _exclude=()
|
|
|
|
# Daily containers — updated by docker_update.sh normal mode
|
|
for _c in "${DAILY_RESTART_CONTAINERS[@]}"; do
|
|
[[ -n "$_c" ]] && _exclude["$_c"]=1
|
|
done
|
|
|
|
# Weekly sync-window containers (emby + critical-data) — updated inline by weekly_sync_maintenance.sh
|
|
_weekly_str="${PROFILE_CRITICAL_CONTAINER_NAMES[emby]:-} ${PROFILE_CRITICAL_CONTAINER_NAMES[critical-data]:-}"
|
|
read -r -a _weekly_arr <<< "$_weekly_str"
|
|
for _c in "${_weekly_arr[@]}"; do
|
|
[[ -n "$_c" ]] && _exclude["$_c"]=1
|
|
done
|
|
unset _weekly_str _weekly_arr
|
|
|
|
# Fallback coverage containers — owned by the remote server's update cycle.
|
|
# This server runs them during failover but should never update them independently.
|
|
# Updating them here risks version divergence: if remote's writeback after handback
|
|
# encounters data written by a newer version, it may not handle it correctly.
|
|
for _tier in 1 2 3 4; do
|
|
_tier_var="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER${_tier}"
|
|
eval "_tier_arr=(\"\${${_tier_var}[@]:-}\")" 2>/dev/null
|
|
for _c in "${_tier_arr[@]}"; do
|
|
[[ -n "$_c" ]] && _exclude["$_c"]=1
|
|
done
|
|
done
|
|
unset _tier _tier_var _tier_arr _c
|
|
|
|
mapfile -t _all_running < <(docker ps --format '{{.Names}}' | sort)
|
|
TARGET_CONTAINERS=()
|
|
for _c in "${_all_running[@]}"; do
|
|
[[ -z "${_exclude[$_c]+x}" ]] && TARGET_CONTAINERS+=("$_c")
|
|
done
|
|
unset _all_running _exclude _c
|
|
else
|
|
if [[ "${DAILY_CONTAINER_UPDATES:-true}" != "true" ]]; then
|
|
echo "DAILY_CONTAINER_UPDATES=false — skipping container updates"
|
|
exit 0
|
|
fi
|
|
|
|
if [[ ${#DAILY_RESTART_CONTAINERS[@]} -eq 0 ]]; then
|
|
warn "DAILY_RESTART_CONTAINERS is empty for $MY_ID — nothing to update"
|
|
warn "Check HOST${MY_ID#HOST}_DAILY_RESTART_CONTAINERS in host*.conf"
|
|
exit 0
|
|
fi
|
|
|
|
TARGET_CONTAINERS=("${DAILY_RESTART_CONTAINERS[@]}")
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Status ━━━
|
|
# ==============================================================================================
|
|
if [[ "$SHOW_STATUS" == true ]]; then
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_GEAR Mode: $([[ "$REMAINDER_MODE" == true ]] && echo "remainder" || echo "normal (daily)")"
|
|
if [[ "$REMAINDER_MODE" == true ]]; then
|
|
echo "$ICON_CONTAINERS Containers: ${#TARGET_CONTAINERS[@]} running (excluding daily, weekly sync, and fallback)"
|
|
for _c in "${TARGET_CONTAINERS[@]}"; do echo " $_c"; done
|
|
else
|
|
echo "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
|
|
echo "$ICON_GEAR Enabled: ${DAILY_CONTAINER_UPDATES:-true}"
|
|
fi
|
|
echo "$ICON_GEAR Dry Run: $DRY_RUN"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no images will be pulled"
|
|
|
|
if [[ ${#TARGET_CONTAINERS[@]} -eq 0 ]]; then
|
|
echo "No containers to update"
|
|
exit 0
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Pull Updates ━━━
|
|
# ==============================================================================================
|
|
echo ""
|
|
if [[ "$REMAINDER_MODE" == true ]]; then
|
|
echo "━━━ $ICON_CONTAINERS Docker Update (remainder) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
echo "$ICON_CONTAINERS Updating ${#TARGET_CONTAINERS[@]} container(s) (not in daily or weekly sync)"
|
|
else
|
|
echo "━━━ $ICON_CONTAINERS Docker Update — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
log "$ICON_CONTAINERS Containers: ${TARGET_CONTAINERS[*]}"
|
|
fi
|
|
echo ""
|
|
|
|
START=$(date +%s)
|
|
UPDATED=()
|
|
UP_TO_DATE=()
|
|
FAILED=()
|
|
SKIPPED=()
|
|
|
|
for container in "${TARGET_CONTAINERS[@]}"; do
|
|
[[ -z "$container" ]] && continue
|
|
log "━━━ $ICON_CONTAINERS $container ━━━"
|
|
|
|
if ! docker inspect "$container" &>/dev/null; then
|
|
warn "$container — not found, skipping"
|
|
SKIPPED+=("$container")
|
|
continue
|
|
fi
|
|
|
|
IMAGE=$(docker inspect --format='{{.Config.Image}}' "$container" 2>/dev/null)
|
|
if [[ -z "$IMAGE" ]]; then
|
|
warn "$container — could not determine image, skipping"
|
|
SKIPPED+=("$container")
|
|
continue
|
|
fi
|
|
|
|
log "$container — image: $IMAGE"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would pull: $IMAGE"
|
|
UPDATED+=("$container")
|
|
continue
|
|
fi
|
|
|
|
# Capture image ID before pull to detect whether an update landed
|
|
OLD_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
|
|
|
|
log "$ICON_SYNC Pulling $IMAGE..."
|
|
if [[ "$ENABLE_LOGGING" == "true" ]]; then
|
|
docker pull "$IMAGE" 2>&1 | grep -E "^(Status:|Digest:|Error|error)" | sed 's/^/ /'
|
|
_pull_rc=${PIPESTATUS[0]}
|
|
else
|
|
docker pull "$IMAGE" >/dev/null 2>&1
|
|
_pull_rc=$?
|
|
fi
|
|
NEW_ID=$(docker image inspect "$IMAGE" --format='{{.Id}}' 2>/dev/null || echo "")
|
|
|
|
if [[ $_pull_rc -eq 0 ]]; then
|
|
if [[ -n "$OLD_ID" ]] && [[ "$OLD_ID" != "$NEW_ID" ]]; then
|
|
log "$ICON_DONE $container — updated ✅"
|
|
UPDATED+=("$container")
|
|
else
|
|
log "$container — already up to date"
|
|
UP_TO_DATE+=("$container")
|
|
fi
|
|
else
|
|
warn "$container — pull failed ($IMAGE)"
|
|
FAILED+=("$container")
|
|
fi
|
|
|
|
done
|
|
|
|
# ── Recreate containers that received a new image ────────────────────────────
|
|
# docker restart uses the image ID baked in at creation time — it never picks
|
|
# up the new digest. rebuild_container reads the stored XML template, stops the
|
|
# old container, recreates it (new image, same config), then prunes the old image.
|
|
REBUILT=()
|
|
REBUILD_FAILED=()
|
|
if [[ ${#UPDATED[@]} -gt 0 ]]; then
|
|
for container in "${UPDATED[@]}"; do
|
|
[[ -z "$container" ]] && continue
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would rebuild $container from template"
|
|
REBUILT+=("$container")
|
|
continue
|
|
fi
|
|
log "$ICON_SYNC Rebuilding $container from template on new image..."
|
|
if /usr/local/emhttp/plugins/dynamix.docker.manager/scripts/rebuild_container "$container" >/dev/null 2>&1; then
|
|
log "$ICON_DONE $container rebuilt ✅"
|
|
REBUILT+=("$container")
|
|
else
|
|
error "Failed to rebuild $container — will be picked up by docker_daily_restart.sh"
|
|
notify "$container failed to rebuild after image update on $(hostname)" "Docker Update" "warning"
|
|
REBUILD_FAILED+=("$container")
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── Prune dangling images ─────────────────────────────────────────────────────
|
|
# Old images become dangling after a pull lands a new digest. Prune here so
|
|
# they don't accumulate across daily runs.
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would prune dangling images"
|
|
PRUNED_SUMMARY="(dry run)"
|
|
else
|
|
PRUNED_OUTPUT=$(docker image prune -f 2>&1)
|
|
[[ "$ENABLE_LOGGING" == "true" ]] && echo "$PRUNED_OUTPUT" | sed 's/^/ /'
|
|
PRUNED_SUMMARY=$(echo "$PRUNED_OUTPUT" | grep -E "^Total reclaimed" || echo "nothing reclaimed")
|
|
fi
|
|
|
|
END=$(date +%s)
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Summary ━━━
|
|
# ==============================================================================================
|
|
if [[ "$REMAINDER_MODE" == true ]]; then
|
|
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE (REMAINDER) SUMMARY ━━━━━"
|
|
else
|
|
echo "━━━━━ $ICON_SUMMARY DOCKER UPDATE SUMMARY ━━━━━"
|
|
fi
|
|
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo "$ICON_TIME Duration: $(format_duration $(( END - START )))"
|
|
if [[ ${#UPDATED[@]} -gt 0 ]]; then
|
|
echo "$ICON_DONE Updated: ${#UPDATED[@]}"
|
|
log " ${UPDATED[*]}"
|
|
fi
|
|
[[ ${#REBUILT[@]} -gt 0 ]] && echo "$ICON_SYNC Rebuilt: ${#REBUILT[@]}"
|
|
[[ ${#REBUILD_FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Rebuild fail:${REBUILD_FAILED[*]}"
|
|
[[ ${#UP_TO_DATE[@]} -gt 0 ]] && log "$ICON_RUNNING Up to date: ${#UP_TO_DATE[@]}"
|
|
[[ ${#SKIPPED[@]} -gt 0 ]] && log "$ICON_WARN Skipped: ${#SKIPPED[@]}"
|
|
[[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}"
|
|
echo "$ICON_SYNC Pruned: ${PRUNED_SUMMARY:-none}"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — no images pulled"
|
|
elif [[ ${#FAILED[@]} -eq 0 ]]; then
|
|
echo "$ICON_DONE Status: done ✅ — ${#UPDATED[@]} updated, ${#UP_TO_DATE[@]} current"
|
|
else
|
|
warn "Status: ${#FAILED[@]} pull(s) failed — restart will proceed with existing images"
|
|
fi
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
|
|
# Pull failures are non-fatal — restart proceeds regardless
|
|
exit 0
|