diff --git a/docker-stacks-update/docker-stacks-update.sh b/docker-stacks-update/docker-stacks-update.sh index b70a5f0..a2c7fa5 100755 --- a/docker-stacks-update/docker-stacks-update.sh +++ b/docker-stacks-update/docker-stacks-update.sh @@ -9,12 +9,14 @@ 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='' +# ── 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 ─────────────────────────────────────────────────────────── @@ -25,24 +27,46 @@ 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} $*"; } +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 <&2; usage ;; + esac +done # ── Pre-flight ────────────────────────────────────────────────────── 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 fi -echo -e "${BOLD}Docker Compose Stack Updater${NC}" -echo -e "${BOLD}────────────────────────────${NC}" -echo +header "Docker Compose Stack Updater" # ── Discover stacks ───────────────────────────────────────────────── -info "Scanning for running Docker Compose stacks ..." -echo +log "Scanning for running Docker Compose stacks ..." stack_json=$(docker compose ls --format json 2>/dev/null || true) @@ -53,7 +77,7 @@ 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 + echo -e "${RED}✗${RESET} Error: 'jq' is required. Install it first." >&2 exit 1 fi @@ -61,16 +85,44 @@ 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 +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}── ${stack}${NC}" + echo -e " ${BOLD}${CYAN}──── ${stack}${RESET}" # Support multi-file projects (ConfigFiles is comma-separated). IFS=',' read -ra cfg_files <<< "$cfg_str" @@ -95,14 +147,15 @@ for i in "${!names[@]}"; do if [[ ${#missing_files[@]} -gt 0 ]]; then warn "Orphaned stack — compose file(s) missing:" for m in "${missing_files[@]}"; do - echo " ${m}" + echo " ${m}" done - info "Tearing down orphaned stack ..." + _spin_start "Tearing down orphaned stack..." if docker compose -p "$stack" down 2>/dev/null; then - ok "${stack} — orphaned stack removed" + _spin_stop; ok "${stack} — orphaned stack removed" else + _spin_stop # 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 if [[ -n "$containers" ]]; then docker container rm -f $containers 2>/dev/null || true @@ -114,49 +167,43 @@ for i in "${!names[@]}"; do ok "${stack} — orphaned containers removed" fi orphaned_names+=("$stack") - echo; continue + echo ""; continue 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) || { + _spin_stop 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") - echo; continue + echo ""; continue } + _spin_stop - # Always run up -d after a successful pull (idempotent when nothing - # changed). - info "Recreating containers …" + # 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 + 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. # - # Patterns matched (TTY and non-TTY alike): - # " ✔ svc Pulled" / "svc Pulled" - # "Downloaded newer image for svc" + # The only reliable indicator of an actual image update is the + # "Downloaded newer image for " 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 - # "Downloaded newer image for svc" — unambiguous. - if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\"]+) ]]; then - updated_svc+=("${BASH_REMATCH[1]}"); continue + if [[ "$line" =~ Downloaded\ newer\ image\ for\ ([^:/\ \"]+) ]]; then + updated_svc+=("${BASH_REMATCH[1]}") 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. @@ -170,49 +217,52 @@ for i in "${!names[@]}"; do done if [[ ${#deduped[@]} -gt 0 ]]; then - ok "${stack} — updated: ${deduped[*]}" + ok "${stack} — updated: ${BOLD}${deduped[*]}${RESET}" updated_names+=("$stack") updated_detail+=("${stack}: ${deduped[*]}") else - info "${stack} — already up to date" + log "${stack} — already up to date" unchanged_names+=("$stack") fi - echo + echo "" done # ── Summary ───────────────────────────────────────────────────────── -echo -e "${BOLD}──────────────────────────────────────────────────────${NC}" -echo -e "${BOLD} Summary${NC}" -echo -e "${BOLD}──────────────────────────────────────────────────────${NC}" -echo +header "Summary" total_found=$(( ${#updated_names[@]} + ${#unchanged_names[@]} + ${#orphaned_names[@]} + ${#failed_names[@]} )) -echo " Total stacks found: ${total_found}" -echo +echo " ${CYAN}●${RESET} Stacks processed: ${BOLD}${total_found}${RESET}" +echo "" 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 echo " ${d}" done - echo + echo "" fi if [[ ${#unchanged_names[@]} -gt 0 ]]; then - echo -e " ${YELLOW}−${NC} ${BOLD}Unchanged (${#unchanged_names[@]})${NC}" - printf " %s\n" "${unchanged_names[@]}" - echo + 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 " ${RED}✗${NC} ${BOLD}Removed (orphaned — ${#orphaned_names[@]})${NC}" - printf " %s\n" "${orphaned_names[@]}" - echo + 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}✗${NC} ${BOLD}Failed (${#failed_names[@]})${NC}" - printf " %s\n" "${failed_names[@]}" - echo + echo -e " ${RED}✗${RESET} ${BOLD}Failed (${#failed_names[@]})${RESET}" + for s in "${failed_names[@]}"; do + echo " ${RED}·${RESET} ${s}" + done + echo "" fi