#!/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