Files
scripts/docker-stacks-update/docker-stacks-update.sh
T
2026-07-16 01:39:12 -04:00

219 lines
7.8 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 (disabled when not on a terminal) ──────────────
if [[ -t 1 ]]; then
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
else
GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; NC=''
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 ─────────────────────────────────────────────────────────
info() { echo -e "${CYAN}::${NC} $*"; }
ok() { echo -e " ${GREEN}${NC} $*"; }
warn() { echo -e " ${YELLOW}~${NC} $*"; }
fail() { echo -e " ${RED}${NC} $*"; }
# ── Pre-flight ──────────────────────────────────────────────────────
if ! command -v docker &>/dev/null; then
echo -e "${RED}Error: 'docker' not found in PATH.${NC}" >&2
exit 1
fi
echo -e "${BOLD}Docker Compose Stack Updater${NC}"
echo -e "${BOLD}────────────────────────────${NC}"
echo
# ── Discover stacks ─────────────────────────────────────────────────
info "Scanning for running Docker Compose stacks ..."
echo
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}Error: 'jq' is required. Install it first.${NC}" >&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 " Found ${total} running stack(s):"
printf " • %s\n" "${names[@]}"
echo
# ── Process each stack ──────────────────────────────────────────────
for i in "${!names[@]}"; do
stack="${names[$i]}"
cfg_str="${configs[$i]}"
echo -e "${BOLD}── ${stack}${NC}"
# 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
info "Tearing down orphaned stack ..."
if docker compose -p "$stack" down 2>/dev/null; then
ok "${stack} — orphaned stack removed"
else
# Fall back to direct container / network cleanup by project label.
info "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
info "Pulling latest images ..."
pull_out=$(docker compose "${compose_args[@]}" pull 2>&1) || {
fail "Image pull failed for ${stack}"
echo "$pull_out" | head -5
failed_names+=("$stack")
echo; continue
}
# Always run up -d after a successful pull (idempotent when nothing
# changed).
info "Recreating containers …"
docker compose "${compose_args[@]}" up -d 2>&1 || {
fail "Container recreation failed for ${stack}"
failed_names+=("$stack"); echo; continue
}
# 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.
#
# Patterns matched (TTY and non-TTY alike):
# " ✔ svc Pulled" / "svc Pulled"
# "Downloaded newer image for svc"
updated_svc=()
while IFS= read -r line; do
# "Downloaded newer image for svc" — unambiguous.
if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\"]+) ]]; then
updated_svc+=("${BASH_REMATCH[1]}"); continue
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"
# 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: ${deduped[*]}"
updated_names+=("$stack")
updated_detail+=("${stack}: ${deduped[*]}")
else
info "${stack} — already up to date"
unchanged_names+=("$stack")
fi
echo
done
# ── Summary ─────────────────────────────────────────────────────────
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
echo -e "${BOLD} Summary${NC}"
echo -e "${BOLD}──────────────────────────────────────────────────────${NC}"
echo
total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} ))
echo " Total stacks found: ${total_found}"
echo
if [[ ${#updated_names[@]} -gt 0 ]]; then
echo -e " ${GREEN}${NC} ${BOLD}Updated (${#updated_names[@]})${NC}"
for d in "${updated_detail[@]}"; do
echo " ${d}"
done
echo
fi
if [[ ${#unchanged_names[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${NC} ${BOLD}Unchanged (${#unchanged_names[@]})${NC}"
printf " %s\n" "${unchanged_names[@]}"
echo
fi
if [[ ${#orphaned_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${NC}"
printf " %s\n" "${orphaned_names[@]}"
echo
fi
if [[ ${#failed_names[@]} -gt 0 ]]; then
echo -e " ${RED}${NC} ${BOLD}Failed (${#failed_names[@]})${NC}"
printf " %s\n" "${failed_names[@]}"
echo
fi