Files
scripts/docker-stacks-update/docker-stacks-update.sh
T
jeremy 0afc798ee6 docker-stacks-update: add --help, fix over-reporting updates
- Add -h/--help usage with options
- Add argument parsing with unknown-option error
- Fix update detection: 'Pulled' appears for all services even when
  up-to-date; only 'Downloaded newer image for' is reliable
2026-07-17 06:25:39 -04:00

269 lines
9.1 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# docker-stacks-update - Pull latest images and restart all running
# Docker Compose stacks that have updates.
#
# Usage: docker-stacks-update
#
# Works from any directory; put it anywhere in $PATH.
set -euo pipefail
# ── Terminal colours ──────────────────────────────────────────────
if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
BOLD=$(tput bold 2>/dev/null || true)
RED=$(tput setaf 1 2>/dev/null || true)
GREEN=$(tput setaf 2 2>/dev/null || true)
YELLOW=$(tput setaf 3 2>/dev/null || true)
CYAN=$(tput setaf 6 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
fi
# ── State ───────────────────────────────────────────────────────────
updated_names=() # stack names that were updated
updated_detail=() # parallel: "stack: service1, service2"
unchanged_names=() # stack names already up to date
orphaned_names=() # stacks whose compose files are gone removed
failed_names=() # stack names that errored
# ── Helpers ─────────────────────────────────────────────────────────
log() { echo -e " ${CYAN}${RESET} $*"; }
ok() { echo -e " ${GREEN}${RESET} $*"; }
warn() { echo -e " ${YELLOW}${RESET} $*"; }
fail() { echo -e " ${RED}${RESET} $*"; }
header() { echo ""; echo -e " ${BOLD}${CYAN}━━━ $* ━━━${RESET}"; echo ""; }
# ── Usage ─────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Pull latest images and restart all running Docker Compose stacks
that have updates.
Options:
-h, --help Show this help message
No options = update all running stacks.
EOF
exit 0
}
# ── Parse arguments ──────────────────────────────────────────────────
for arg in "$@"; do
case "$arg" in
-h|--help) usage ;;
*) echo -e " ${RED}${RESET} Unknown option: ${arg}" >&2; usage ;;
esac
done
# ── Pre-flight ──────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then
echo -e "${RED}${RESET} Error: 'docker' not found in PATH." >&2
exit 1
fi
header "Docker Compose Stack Updater"
# ── Discover stacks ─────────────────────────────────────────────────
log "Scanning for running Docker Compose stacks ..."
stack_json=$(docker compose ls --format json 2>/dev/null || true)
if [[ -z "$stack_json" ]]; then
warn "No running Docker Compose stacks found."
exit 0
fi
# Parse JSON with jq (required).
if ! command -v jq &>/dev/null; then
echo -e "${RED}${RESET} Error: 'jq' is required. Install it first." >&2
exit 1
fi
readarray -t names < <(echo "$stack_json" | jq -r '.[].Name')
readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles')
total=${#names[@]}
echo " ${CYAN}${RESET} Found ${BOLD}${total}${RESET} running stack(s):"
for name in "${names[@]}"; do
echo " ${CYAN}·${RESET} ${name}"
done
echo ""
# ── Spinner for long operations ─────────────────────────────────────
_spin_kill=""
_spin_start() {
local msg="$1"
local -a ch=('-' '\' '|' '/')
local i=0
(
while true; do
echo -ne "\r ${CYAN}${ch[i]}${RESET} ${msg} "
i=$(( (i+1) % 4 ))
sleep 0.15
done
) &
_spin_kill=$!
disown 2>/dev/null || true
}
_spin_stop() {
[[ -z "$_spin_kill" ]] && return
kill "$_spin_kill" 2>/dev/null || true
wait "$_spin_kill" 2>/dev/null || true
_spin_kill=""
echo -ne "\r\033[K"
}
# ── Process each stack ──────────────────────────────────────────────
for i in "${!names[@]}"; do
stack="${names[$i]}"
cfg_str="${configs[$i]}"
echo -e " ${BOLD}${CYAN}──── ${stack}${RESET}"
# Support multi-file projects (ConfigFiles is comma-separated).
IFS=',' read -ra cfg_files <<< "$cfg_str"
compose_args=()
compose_dir=""
for f in "${cfg_files[@]}"; do
f_trimmed=$(echo "$f" | xargs)
compose_args+=(-f "$f_trimmed")
# Use the directory of the first config file as working directory.
if [[ -z "$compose_dir" ]]; then
compose_dir=$(dirname "$f_trimmed")
fi
done
# Check whether the compose files still exist (orphan detection).
missing_files=()
for f in "${compose_args[@]}"; do
[[ "$f" == "-f" ]] && continue
[[ ! -e "$f" ]] && missing_files+=("$f")
done
if [[ ${#missing_files[@]} -gt 0 ]]; then
warn "Orphaned stack — compose file(s) missing:"
for m in "${missing_files[@]}"; do
echo " ${m}"
done
_spin_start "Tearing down orphaned stack..."
if docker compose -p "$stack" down 2>/dev/null; then
_spin_stop; ok "${stack} — orphaned stack removed"
else
_spin_stop
# Fall back to direct container / network cleanup by project label.
log "Fallback: removing containers by project label..."
containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true
if [[ -n "$containers" ]]; then
docker container rm -f $containers 2>/dev/null || true
fi
networks=$(docker network ls -q --filter label=com.docker.compose.project="$stack") || true
if [[ -n "$networks" ]]; then
docker network rm $networks 2>/dev/null || true
fi
ok "${stack} — orphaned containers removed"
fi
orphaned_names+=("$stack")
echo ""; continue
fi
# Pull with spinner (capture output for parsing later).
_spin_start "Pulling latest images..."
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
_spin_stop
fail "Image pull failed for ${stack}"
echo "$pull_out" | head -5 | while IFS= read -r line; do echo " ${line}"; done
failed_names+=("$stack")
echo ""; continue
}
_spin_stop
# Recreate containers with spinner.
_spin_start "Recreating containers..."
docker compose "${compose_args[@]}" up -d 2>&1 || {
_spin_stop
fail "Container recreation failed for ${stack}"
failed_names+=("$stack"); echo ""; continue
}
_spin_stop
# Determine which services (if any) received new images by parsing the
# pull output. Only the pull output is authoritative — up -d restarts
# are idempotent and tell us nothing about whether images changed.
#
# The only reliable indicator of an actual image update is the
# "Downloaded newer image for <service>" string. The "Pulled" status
# appears even when the image is already up to date (Docker shows
# "Pulled" for the manifest check), so we ignore it.
updated_svc=()
while IFS= read -r line; do
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\ \"]+) ]]; then
updated_svc+=("${BASH_REMATCH[1]}")
fi
done <<< "$pull_out"
# Deduplicate while preserving order.
seen=""
deduped=()
for svc in "${updated_svc[@]}"; do
if [[ ! " $seen " =~ [[:space:]]${svc}[[:space:]] ]]; then
deduped+=("$svc")
seen+=" $svc "
fi
done
if [[ ${#deduped[@]} -gt 0 ]]; then
ok "${stack} — updated: ${BOLD}${deduped[*]}${RESET}"
updated_names+=("$stack")
updated_detail+=("${stack}: ${deduped[*]}")
else
log "${stack} — already up to date"
unchanged_names+=("$stack")
fi
echo ""
done
# ── Summary ─────────────────────────────────────────────────────────
header "Summary"
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
echo " ${CYAN}${RESET} Stacks processed: ${BOLD}${total_found}${RESET}"
echo ""
if [[ ${#updated_names[@]} -gt 0 ]]; then
echo -e " ${GREEN}${RESET} ${BOLD}Updated (${#updated_names[@]})${RESET}"
for d in "${updated_detail[@]}"; do
echo " ${d}"
done
echo ""
fi
if [[ ${#unchanged_names[@]} -gt 0 ]]; then
echo -e " ${CYAN}${RESET} ${BOLD}Already up to date (${#unchanged_names[@]})${RESET}"
for s in "${unchanged_names[@]}"; do
echo " ${CYAN}·${RESET} ${s}"
done
echo ""
fi
if [[ ${#orphaned_names[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${RESET} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${RESET}"
for s in "${orphaned_names[@]}"; do
echo " ${YELLOW}·${RESET} ${s}"
done
echo ""
fi
if [[ ${#failed_names[@]} -gt 0 ]]; then
echo -e " ${RED}${RESET} ${BOLD}Failed (${#failed_names[@]})${RESET}"
for s in "${failed_names[@]}"; do
echo " ${RED}·${RESET} ${s}"
done
echo ""
fi