All bash scripts are now platform-neutral. Unraid-specific paths, commands, and service checks moved to Plugin/unraid/adapter.sh. Core scripts call platform_*() functions exclusively — no direct OS paths in runtime logic. New adapter functions: platform_storage_path, platform_webui_install_path, platform_scripts_dir_probe_cmd, platform_setup_db_path, platform_storage_healthy, platform_is_service_enabled, platform_get_temp_thresholds, platform_disk_states_path, platform_rebuild_container, platform_push_conf, platform_push_setup_state, platform_get_templates_dir, platform_send_os_notification. Partnership services stack (Emby/Jellyfin/Seerr/SeerrFin) added as third onboarding stack alongside auth and arr stacks.
400 lines
17 KiB
Bash
Executable File
400 lines
17 KiB
Bash
Executable File
#!/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=()
|
|
OLD_IMAGE_IDS=() # old image IDs to explicitly remove after rebuilds
|
|
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 the image ID the container is currently running on, and the
|
|
# image ID :latest points to before the pull. After pulling, we rebuild if
|
|
# either a new digest landed OR the container is behind what :latest is now.
|
|
CONTAINER_IMAGE_ID=$(docker inspect "$container" --format='{{.Image}}' 2>/dev/null || echo "")
|
|
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
|
|
_pull_new=$( [[ -n "$OLD_ID" && "$OLD_ID" != "$NEW_ID" ]] && echo true || echo false)
|
|
_container_behind=$([[ -n "$CONTAINER_IMAGE_ID" && -n "$NEW_ID" && "$CONTAINER_IMAGE_ID" != "$NEW_ID" ]] && echo true || echo false)
|
|
|
|
if [[ "$_pull_new" == true || "$_container_behind" == true ]]; then
|
|
[[ "$_pull_new" == true ]] && log "$ICON_DONE $container — new image (${OLD_ID:7:12} → ${NEW_ID:7:12})"
|
|
[[ "$_container_behind" == true && "$_pull_new" == false ]] && log "$ICON_DONE $container — image already pulled, container behind (${CONTAINER_IMAGE_ID:7:12} → ${NEW_ID:7:12})"
|
|
UPDATED+=("$container")
|
|
OLD_IMAGE_IDS+=("$CONTAINER_IMAGE_ID")
|
|
else
|
|
log "$container — 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 platform_rebuild_container "$container"; 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
|
|
|
|
# ── Remove old images ────────────────────────────────────────────────────────
|
|
# Explicitly rmi by the IDs captured before each pull. Tagged images are never
|
|
# caught by dangling-only prune, so this is the only reliable cleanup path.
|
|
# Fall through to dangling prune to catch any leftovers from other update paths.
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would remove ${#OLD_IMAGE_IDS[@]} old image(s) and prune dangling"
|
|
PRUNED_SUMMARY="(dry run)"
|
|
else
|
|
for _old_id in "${OLD_IMAGE_IDS[@]}"; do
|
|
docker rmi "$_old_id" >/dev/null 2>&1 || true
|
|
done
|
|
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
|