diff --git a/docker-stack-backup/README.md b/docker-stacks-backup/README.md similarity index 100% rename from docker-stack-backup/README.md rename to docker-stacks-backup/README.md diff --git a/docker-stack-backup/docker-stack-backup.sh b/docker-stacks-backup/docker-stacks-backup.sh similarity index 100% rename from docker-stack-backup/docker-stack-backup.sh rename to docker-stacks-backup/docker-stacks-backup.sh diff --git a/docker-stacks-update/README.md b/docker-stacks-update/README.md new file mode 100644 index 0000000..b1db13d --- /dev/null +++ b/docker-stacks-update/README.md @@ -0,0 +1,41 @@ +# docker-stacks-update + +Finds all running Docker Compose stacks, pulls the latest images, and +restarts only the stacks that received updates. + +## Requirements + +- **docker** (with Compose v2 plugin — `docker compose`, not `docker-compose`) +- **jq** — used to parse the JSON output of `docker compose ls` + +## Installation + +Place the script anywhere in your `$PATH`, for example: + +```bash +cp docker-stacks-update ~/.local/bin/ +chmod +x ~/.local/bin/docker-stacks-update +``` + +## Usage + +```bash +docker-stacks-update +``` + +Run from any directory — the script finds every running stack +system-wide using `docker compose ls`. + +## What it does + +1. **Discover** — lists all running stacks with `docker compose ls + --format json` +2. **Pull** — runs `docker compose pull` for each stack +3. **Detect** — checks whether any service image was actually + downloaded (not just "already up to date") +4. **Restart** — recreates containers with `docker compose up -d` only + for stacks that received new images +5. **Orphan cleanup** — any stack whose compose file(s) no longer exist + on disk is torn down automatically +6. **Report** — prints a summary showing updated (with service names), + unchanged, orphaned (removed), and failed stacks diff --git a/docker-stacks-update/docker-stacks-update.sh b/docker-stacks-update/docker-stacks-update.sh new file mode 100755 index 0000000..b70a5f0 --- /dev/null +++ b/docker-stacks-update/docker-stacks-update.sh @@ -0,0 +1,218 @@ +#!/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