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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user