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
This commit is contained in:
2026-07-17 06:25:39 -04:00
parent 84e7dfb2b7
commit 0afc798ee6
+118 -68
View File
@@ -9,12 +9,14 @@
set -euo pipefail set -euo pipefail
# ── Terminal colours (disabled when not on a terminal) ────────────── # ── Terminal colours ──────────────────────────────────────────────
if [[ -t 1 ]]; then if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m' BOLD=$(tput bold 2>/dev/null || true)
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' RED=$(tput setaf 1 2>/dev/null || true)
else GREEN=$(tput setaf 2 2>/dev/null || true)
GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC='' YELLOW=$(tput setaf 3 2>/dev/null || true)
CYAN=$(tput setaf 6 2>/dev/null || true)
RESET=$(tput sgr0 2>/dev/null || true)
fi fi
# ── State ─────────────────────────────────────────────────────────── # ── State ───────────────────────────────────────────────────────────
@@ -25,24 +27,46 @@ orphaned_names=() # stacks whose compose files are gone removed
failed_names=() # stack names that errored failed_names=() # stack names that errored
# ── Helpers ───────────────────────────────────────────────────────── # ── Helpers ─────────────────────────────────────────────────────────
info() { echo -e "${CYAN}::${NC} $*"; } log() { echo -e " ${CYAN}${RESET} $*"; }
ok() { echo -e " ${GREEN}${NC} $*"; } ok() { echo -e " ${GREEN}${RESET} $*"; }
warn() { echo -e " ${YELLOW}~${NC} $*"; } warn() { echo -e " ${YELLOW}${RESET} $*"; }
fail() { echo -e " ${RED}${NC} $*"; } 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 ────────────────────────────────────────────────────── # ── Pre-flight ──────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then if ! command -v docker &>/dev/null; then
echo -e "${RED}Error: 'docker' not found in PATH.${NC}" >&2 echo -e "${RED}${RESET} Error: 'docker' not found in PATH." >&2
exit 1 exit 1
fi fi
echo -e "${BOLD}Docker Compose Stack Updater${NC}" header "Docker Compose Stack Updater"
echo -e "${BOLD}────────────────────────────${NC}"
echo
# ── Discover stacks ───────────────────────────────────────────────── # ── Discover stacks ─────────────────────────────────────────────────
info "Scanning for running Docker Compose stacks ..." log "Scanning for running Docker Compose stacks ..."
echo
stack_json=$(docker compose ls --format json 2>/dev/null || true) stack_json=$(docker compose ls --format json 2>/dev/null || true)
@@ -53,7 +77,7 @@ fi
# Parse JSON with jq (required). # Parse JSON with jq (required).
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
echo -e "${RED}Error: 'jq' is required. Install it first.${NC}" >&2 echo -e "${RED}${RESET} Error: 'jq' is required. Install it first." >&2
exit 1 exit 1
fi fi
@@ -61,16 +85,44 @@ readarray -t names < <(echo "$stack_json" | jq -r '.[].Name')
readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles') readarray -t configs < <(echo "$stack_json" | jq -r '.[].ConfigFiles')
total=${#names[@]} total=${#names[@]}
echo " Found ${total} running stack(s):" echo " ${CYAN}${RESET} Found ${BOLD}${total}${RESET} running stack(s):"
printf " • %s\n" "${names[@]}" for name in "${names[@]}"; do
echo 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 ────────────────────────────────────────────── # ── Process each stack ──────────────────────────────────────────────
for i in "${!names[@]}"; do for i in "${!names[@]}"; do
stack="${names[$i]}" stack="${names[$i]}"
cfg_str="${configs[$i]}" cfg_str="${configs[$i]}"
echo -e "${BOLD}── ${stack}${NC}" echo -e " ${BOLD}${CYAN}──── ${stack}${RESET}"
# Support multi-file projects (ConfigFiles is comma-separated). # Support multi-file projects (ConfigFiles is comma-separated).
IFS=',' read -ra cfg_files <<< "$cfg_str" IFS=',' read -ra cfg_files <<< "$cfg_str"
@@ -97,12 +149,13 @@ for i in "${!names[@]}"; do
for m in "${missing_files[@]}"; do for m in "${missing_files[@]}"; do
echo " ${m}" echo " ${m}"
done done
info "Tearing down orphaned stack ..." _spin_start "Tearing down orphaned stack..."
if docker compose -p "$stack" down 2>/dev/null; then if docker compose -p "$stack" down 2>/dev/null; then
ok "${stack} — orphaned stack removed" _spin_stop; ok "${stack} — orphaned stack removed"
else else
_spin_stop
# Fall back to direct container / network cleanup by project label. # Fall back to direct container / network cleanup by project label.
info "Fallback: removing containers by project label" log "Fallback: removing containers by project label..."
containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true containers=$(docker container ls -q --filter label=com.docker.compose.project="$stack") || true
if [[ -n "$containers" ]]; then if [[ -n "$containers" ]]; then
docker container rm -f $containers 2>/dev/null || true docker container rm -f $containers 2>/dev/null || true
@@ -114,49 +167,43 @@ for i in "${!names[@]}"; do
ok "${stack} — orphaned containers removed" ok "${stack} — orphaned containers removed"
fi fi
orphaned_names+=("$stack") orphaned_names+=("$stack")
echo; continue echo ""; continue
fi fi
info "Pulling latest images ..." # Pull with spinner (capture output for parsing later).
_spin_start "Pulling latest images..."
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || { pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
_spin_stop
fail "Image pull failed for ${stack}" fail "Image pull failed for ${stack}"
echo "$pull_out" | head -5 echo "$pull_out" | head -5 | while IFS= read -r line; do echo " ${line}"; done
failed_names+=("$stack") failed_names+=("$stack")
echo; continue echo ""; continue
} }
_spin_stop
# Always run up -d after a successful pull (idempotent when nothing # Recreate containers with spinner.
# changed). _spin_start "Recreating containers..."
info "Recreating containers …"
docker compose "${compose_args[@]}" up -d 2>&1 || { docker compose "${compose_args[@]}" up -d 2>&1 || {
_spin_stop
fail "Container recreation failed for ${stack}" fail "Container recreation failed for ${stack}"
failed_names+=("$stack"); echo; continue failed_names+=("$stack"); echo ""; continue
} }
_spin_stop
# Determine which services (if any) received new images by parsing the # Determine which services (if any) received new images by parsing the
# pull output. Only the pull output is authoritative — up -d restarts # pull output. Only the pull output is authoritative — up -d restarts
# are idempotent and tell us nothing about whether images changed. # are idempotent and tell us nothing about whether images changed.
# #
# Patterns matched (TTY and non-TTY alike): # The only reliable indicator of an actual image update is the
# " ✔ svc Pulled" / "svc Pulled" # "Downloaded newer image for <service>" string. The "Pulled" status
# "Downloaded newer image for svc" # appears even when the image is already up to date (Docker shows
# "Pulled" for the manifest check), so we ignore it.
updated_svc=() updated_svc=()
while IFS= read -r line; do while IFS= read -r line; do
# "Downloaded newer image for svc" — unambiguous. if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\ \"]+) ]]; then
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\"]+) ]]; then updated_svc+=("${BASH_REMATCH[1]}")
updated_svc+=("${BASH_REMATCH[1]}"); continue
fi fi
# "Pulled" not "Already up to date"
[[ "$line" =~ Already\ up\ to\ date ]] && continue
[[ ! "$line" =~ Pulled ]] && continue
# Extract the word before "Pulled". Strip leading " ✔ " if
# present, then read the first field.
cleaned="${line#*✔ }"
read -r svc _ <<< "$cleaned"
[[ -n "$svc" && "$svc" != "Pulled" ]] && updated_svc+=("$svc")
done <<< "$pull_out" done <<< "$pull_out"
# Deduplicate while preserving order. # Deduplicate while preserving order.
@@ -170,49 +217,52 @@ for i in "${!names[@]}"; do
done done
if [[ ${#deduped[@]} -gt 0 ]]; then if [[ ${#deduped[@]} -gt 0 ]]; then
ok "${stack} — updated: ${deduped[*]}" ok "${stack} — updated: ${BOLD}${deduped[*]}${RESET}"
updated_names+=("$stack") updated_names+=("$stack")
updated_detail+=("${stack}: ${deduped[*]}") updated_detail+=("${stack}: ${deduped[*]}")
else else
info "${stack} — already up to date" log "${stack} — already up to date"
unchanged_names+=("$stack") unchanged_names+=("$stack")
fi fi
echo echo ""
done done
# ── Summary ───────────────────────────────────────────────────────── # ── Summary ─────────────────────────────────────────────────────────
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}" header "Summary"
echo -e "${BOLD} Summary${NC}"
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
echo
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} )) total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
echo " Total stacks found: ${total_found}" echo " ${CYAN}${RESET} Stacks processed: ${BOLD}${total_found}${RESET}"
echo echo ""
if [[ ${#updated_names[@]} -gt 0 ]]; then if [[ ${#updated_names[@]} -gt 0 ]]; then
echo -e " ${GREEN}${NC} ${BOLD}Updated (${#updated_names[@]})${NC}" echo -e " ${GREEN}${RESET} ${BOLD}Updated (${#updated_names[@]})${RESET}"
for d in "${updated_detail[@]}"; do for d in "${updated_detail[@]}"; do
echo " ${d}" echo " ${d}"
done done
echo echo ""
fi fi
if [[ ${#unchanged_names[@]} -gt 0 ]]; then if [[ ${#unchanged_names[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${NC} ${BOLD}Unchanged (${#unchanged_names[@]})${NC}" echo -e " ${CYAN}${RESET} ${BOLD}Already up to date (${#unchanged_names[@]})${RESET}"
printf " %s\n" "${unchanged_names[@]}" for s in "${unchanged_names[@]}"; do
echo echo " ${CYAN}·${RESET} ${s}"
done
echo ""
fi fi
if [[ ${#orphaned_names[@]} -gt 0 ]]; then if [[ ${#orphaned_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${NC}" echo -e " ${YELLOW}${RESET} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${RESET}"
printf " %s\n" "${orphaned_names[@]}" for s in "${orphaned_names[@]}"; do
echo echo " ${YELLOW}·${RESET} ${s}"
done
echo ""
fi fi
if [[ ${#failed_names[@]} -gt 0 ]]; then if [[ ${#failed_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Failed (${#failed_names[@]})${NC}" echo -e " ${RED}${RESET} ${BOLD}Failed (${#failed_names[@]})${RESET}"
printf " %s\n" "${failed_names[@]}" for s in "${failed_names[@]}"; do
echo echo " ${RED}·${RESET} ${s}"
done
echo ""
fi fi