#!/usr/bin/env bash # # update-stacks.sh — Met à jour les stacks docker compose d'une racine donnée. # # Pour chaque sous-répertoire de la racine contenant un fichier compose : # 1. télécharge les images (pull) — sans toucher aux conteneurs en cours ; # 2. compare l'image utilisée par chaque conteneur à l'image fraîchement tirée ; # 3. si au moins une image a changé : down puis up -d de toute la stack. # Les sous-répertoires sans fichier compose, ou avec un fichier compose vide, # sont ignorés sans rien afficher. Un fichier compose invalide est signalé en erreur. # # Usage : update-stacks.sh [-v | -q] [-n] [-f] [-p] [-a] [RACINE] # -v verbose : détaille chaque conteneur et chaque commande docker # -q quiet : n'affiche qu'une ligne par stack (nom, répertoire, état) # -n dry-run : détecte les mises à jour mais ne relance rien # -f force : down + up -d de chaque stack, même sans mise à jour # -p prune : supprime les images orphelines (dangling) à la fin # -a all : traite aussi les stacks arrêtées (par défaut : ignorées) # RACINE : répertoire contenant les stacks (défaut : /home) # # Couleurs : vert = à jour, jaune = mise(s) à jour détectée(s) ou forcée(s), rouge = erreur. # Désactivées automatiquement hors terminal (cron, redirection) ou si NO_COLOR est défini. set -uo pipefail VERBOSITY=1 # 0 = quiet, 1 = normal, 2 = verbose DRY_RUN=0 FORCE=0 PRUNE=0 INCLUDE_STOPPED=0 while getopts "vqnfpah" opt; do case "$opt" in v) VERBOSITY=2 ;; q) VERBOSITY=0 ;; n) DRY_RUN=1 ;; f) FORCE=1 ;; p) PRUNE=1 ;; a) INCLUDE_STOPPED=1 ;; h|*) sed -n '2,23p' "$0"; exit 0 ;; esac done shift $((OPTIND - 1)) ROOT="${1:-/home}" ROOT="${ROOT%/}" COMPOSE_FILES=(compose.yaml compose.yml docker-compose.yaml docker-compose.yml) LOCK_FILE="/tmp/update-stacks.lock" if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then GREEN=$'\e[32m' YELLOW=$'\e[33m' RED=$'\e[31m' BOLD=$'\e[1m' DIM=$'\e[2m' RESET=$'\e[0m' else GREEN="" YELLOW="" RED="" BOLD="" DIM="" RESET="" fi ts() { date '+%F %T'; } log() { (( VERBOSITY >= 1 )) && printf '[%s] %s\n' "$(ts)" "$*"; } debug() { (( VERBOSITY >= 2 )) && printf '[%s] %s%s%s\n' "$(ts)" "$DIM" "$*" "$RESET"; } ok() { log " ${GREEN}✔ $*${RESET}"; } warn() { log " ${YELLOW}↑ $*${RESET}"; } error() { printf '[%s] %s✘ %s%s\n' "$(ts)" "$RED" "$*" "$RESET" >&2; } # En mode quiet : une seule ligne par stack, l'état s'affiche au bout quiet_status() { (( VERBOSITY == 0 )) && printf '%s%s%s\n' "$1" "$2" "$RESET"; } # Exécute une commande docker. En verbose, affiche la commande et sa sortie ; # sinon la sortie est gardée dans RUN_OUTPUT pour être affichée par fail(). RUN_OUTPUT="" run() { RUN_OUTPUT="" if (( VERBOSITY >= 2 )); then debug " \$ $*" "$@" else RUN_OUTPUT=$("$@" 2>&1) fi } # Signale l'échec d'une stack, avec la sortie de la commande fautive fail() { quiet_status "$RED" "✘ erreur ($1)" (( VERBOSITY >= 1 )) && error "$name : erreur ($1)" [[ -n "$RUN_OUTPUT" ]] && printf '%s\n' "$RUN_OUTPUT" | sed 's/^/ /' >&2 failed_stacks+=("$name") } # Empêche deux exécutions simultanées (utile en cron) exec 9>"$LOCK_FILE" if ! flock -n 9; then error "Une autre exécution est déjà en cours, abandon." exit 1 fi if ! docker compose version >/dev/null 2>&1; then error "'docker compose' (v2) introuvable." exit 1 fi [[ -d "$ROOT" ]] || { error "$ROOT n'est pas un répertoire."; exit 1; } # Renvoie 0 si au moins un conteneur de la stack tourne sur une image obsolète stack_has_updates() { local cid running_id image_ref latest_id service updated=1 for cid in $(docker compose ps -q 2>/dev/null); do running_id=$(docker inspect -f '{{.Image}}' "$cid") image_ref=$(docker inspect -f '{{.Config.Image}}' "$cid") service=$(docker inspect -f '{{index .Config.Labels "com.docker.compose.service"}}' "$cid") latest_id=$(docker image inspect -f '{{.Id}}' "$image_ref" 2>/dev/null) || { debug " · $service ($image_ref) : image locale introuvable, ignoré" continue } if [[ "$running_id" != "$latest_id" ]]; then warn "$service ($image_ref) : nouvelle image disponible" debug " actuelle : ${running_id:7:12} → nouvelle : ${latest_id:7:12}" updated=0 else debug " · $service ($image_ref) : à jour (${running_id:7:12})" fi done return $updated } updated_stacks=() failed_stacks=() log "${BOLD}Racine : $ROOT${RESET}$( (( DRY_RUN )) && echo ' [dry-run]')$( (( FORCE )) && echo ' [force]')" for dir in "$ROOT"/*/; do dir="${dir%/}" name=$(basename "$dir") found="" for f in "${COMPOSE_FILES[@]}"; do [[ -f "$dir/$f" ]] && { found="$f"; break; } done [[ -n "$found" ]] || continue pushd "$dir" >/dev/null || continue # Valide le fichier compose avant tout affichage : un fichier vide est ignoré # comme un répertoire sans stack, un fichier invalide est signalé. config_ok=1 RUN_OUTPUT=$(docker compose config -q 2>&1) || config_ok=0 if (( ! config_ok )) && [[ "$RUN_OUTPUT" == *"empty compose file"* ]]; then popd >/dev/null; continue fi log "${BOLD}== Stack : $name${RESET} ($dir/$found)" (( VERBOSITY == 0 )) && printf '%-25s %-45s ' "$name" "$dir" if (( ! config_ok )); then fail "fichier compose invalide" popd >/dev/null; continue fi if [[ -z "$(docker compose ps -q 2>/dev/null)" && $INCLUDE_STOPPED -eq 0 ]]; then log " ${DIM}stack arrêtée, ignorée (utiliser -a pour l'inclure)${RESET}" quiet_status "$DIM" "arrêtée" popd >/dev/null; continue fi # Le pull ne modifie pas les conteneurs en cours : il sert à la détection debug " Téléchargement des images…" if ! run docker compose pull --ignore-buildable; then fail "pull" popd >/dev/null; continue fi # Stack arrêtée incluse via -a : aucun conteneur à comparer, on la lance. # stack_has_updates est toujours appelé pour afficher le détail, même avec -f. has_updates=0 if [[ -z "$(docker compose ps -q 2>/dev/null)" ]] || stack_has_updates; then has_updates=1 fi if (( has_updates || FORCE )); then if (( has_updates )); then label="mise à jour" done_label="↑ mise à jour appliquée" else label="redémarrage forcé" done_label="↻ redémarrage forcé effectué" fi if [[ $DRY_RUN -eq 1 ]]; then warn "[dry-run] $label nécessaire, rien n'est relancé" quiet_status "$YELLOW" "↑ $label à faire" updated_stacks+=("$name") elif ! run docker compose down; then fail "down" elif ! run docker compose up -d; then fail "up -d" else log " ${YELLOW}✔ stack relancée (down + up -d) : $label${RESET}" quiet_status "$YELLOW" "$done_label" updated_stacks+=("$name") fi else ok "à jour" quiet_status "$GREEN" "✔ à jour" fi popd >/dev/null done if [[ $PRUNE -eq 1 && $DRY_RUN -eq 0 ]]; then log "Nettoyage des images orphelines…" run docker image prune -f fi if (( ${#failed_stacks[@]} )); then color=$RED elif (( ${#updated_stacks[@]} )); then color=$YELLOW else color=$GREEN fi log "${color}Terminé. Stacks relancées : ${#updated_stacks[@]} ${updated_stacks[*]:-} | Échecs : ${#failed_stacks[@]} ${failed_stacks[*]:-}${RESET}" [[ ${#failed_stacks[@]} -eq 0 ]]