#!/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 fallback. 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 fallback 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 fallback 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)" log "$ICON_CONTAINERS Remainder targets: ${TARGET_CONTAINERS[*]}" 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 ✅ (${OLD_ID:7:12} → ${NEW_ID:7:12})" UPDATED+=("$container") else log "$container — already up to date (${NEW_ID:7:12})" 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