#!/bin/bash # ============================================================================= # docker-auto-update.sh — Vérifie et applique les mises à jour d'une app Docker # ============================================================================= # À placer dans /usr/local/bin/ ou ~/scripts/ # Cron recommandé : 0 4 * * * /usr/local/bin/docker-auto-update.sh # # Paramètres configurables : voir docker-scripts.conf # Utilisation avec autre app : DOCKER_APP_NAME=monapp bash docker-auto-update.sh # ============================================================================= set -euo pipefail # ─── Charger la configuration commune ───────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_FILE="${CONFIG_FILE:-$SCRIPT_DIR/docker-scripts.conf}" if [[ -f "$CONFIG_FILE" ]]; then # shellcheck source=/dev/null source "$CONFIG_FILE" fi # ─── Nom de l'application (défaut si pas dans la config) ───────────────────── DOCKER_APP_NAME="${DOCKER_APP_NAME:-gitea}" # ─── Configuration locale (après chargement conf, permet surcharge env) ────── COMPOSE_DIR="${COMPOSE_DIR:-$HOME/dev/git/outils/$DOCKER_APP_NAME}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" APP_SERVICE="${APP_SERVICE:-${DOCKER_APP_NAME}_server}" APP_CONTAINER="${APP_CONTAINER:-${DOCKER_APP_NAME}_server}" DB_CONTAINER="${DB_CONTAINER:-${DOCKER_APP_NAME}_db}" IMAGE_NAME="${IMAGE_NAME:-gitea/gitea}" IMAGE_TAG="${IMAGE_TAG:-latest}" # Base de données DB_NAME="${DB_NAME:-$DOCKER_APP_NAME}" DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-gitea}" # Logs LOG_FILE="${LOG_FILE:-/var/log/${DOCKER_APP_NAME}-update.log}" LOG_DIR="$(dirname "$LOG_FILE")" # Notifications ENABLE_NOTIFICATIONS="${ENABLE_NOTIFICATIONS:-false}" NOTIFY_METHOD="${NOTIFY_METHOD:-log}" TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}" NTPY_TOPIC="${NTPY_TOPIC:-}" # Santé HEALTH_CHECK_URL="${HEALTH_CHECK_URL:-http://localhost:3000}" HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-30}" VERSION_ENDPOINT="${VERSION_ENDPOINT:-}" # Lock file LOCK_DIR="${LOCK_DIR:-/tmp}" LOCK_FILE="${LOCK_DIR}/${DOCKER_APP_NAME}-auto-update.lock" # ─── Prérequis : s'assurer que le dossier de log existe ────────────────────── mkdir -p "$LOG_DIR" 2>/dev/null || true # ─── Logging ───────────────────────────────────────────────────────────────── log() { local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1" echo "$msg" echo "$msg" >> "$LOG_FILE" 2>/dev/null || true } # ─── Notification ──────────────────────────────────────────────────────────── notify() { local msg="$1" case "$NOTIFY_METHOD" in telegram) if [[ -n "$TELEGRAM_BOT_TOKEN" ]] && [[ -n "$TELEGRAM_CHAT_ID" ]]; then curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=$TELEGRAM_CHAT_ID" -d "text=$msg" > /dev/null 2>&1 || true fi ;; ntfy) if [[ -n "$NTPY_TOPIC" ]]; then curl -s -H "Title: ${DOCKER_APP_NAME^} Update" -d "$msg" "https://ntfy.sh/$NTPY_TOPIC" > /dev/null 2>&1 || true fi ;; esac } # ─── Lock file ─────────────────────────────────────────────────────────────── acquire_lock() { if [[ -f "$LOCK_FILE" ]]; then local pid pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "") # Vérifier si le processus existe encore if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then log "⚠️ Une autre instance (PID $pid) est déjà en cours d'exécution — arrêt" exit 0 fi # Stale lock, on le nettoie log "⚠️ Lock stale détecté (PID $pid n'existe plus), on continue" rm -f "$LOCK_FILE" fi echo $$ > "$LOCK_FILE" } release_lock() { rm -f "$LOCK_FILE" } # ─── Nettoyage en cas d'erreur ─────────────────────────────────────────────── cleanup() { release_lock } trap cleanup EXIT # ─── Récupération du digest (compatible POSIX grep) ────────────────────────── get_image_digest() { local image="$1" local digest # Essayer d'abord avec -E (ERE, disponible presque partout) digest=$(docker image inspect "$image" --format '{{.RepoDigests}}' 2>/dev/null | grep -oE 'sha256:[a-f0-9]{64}' | head -1 || echo "") if [[ -z "$digest" ]]; then # Fallback : utiliser awk digest=$(docker image inspect "$image" --format '{{.RepoDigests}}' 2>/dev/null | grep -o 'sha256:[a-f0-9]\{64\}' | head -1 || echo "") fi echo "$digest" } # ═════════════════════════════════════════════════════════════════════════════ # MAIN # ═════════════════════════════════════════════════════════════════════════════ acquire_lock log "════════════════════════════════════════════════════════" log "🔄 Début vérification mise à jour ${DOCKER_APP_NAME^}" # ─── Pré-vérifications ────────────────────────────────────────────────────── cd "$COMPOSE_DIR" || { log "❌ Répertoire $COMPOSE_DIR introuvable"; exit 1; } if ! command -v docker &>/dev/null; then log "❌ Docker n'est pas installé" exit 1 fi # Vérifier si docker compose (plugin) ou docker-compose (standalone) est dispo if docker compose version &>/dev/null; then DOCKER_COMPOSE="docker compose" elif command -v docker-compose &>/dev/null; then DOCKER_COMPOSE="docker-compose" else log "❌ Ni 'docker compose' ni 'docker-compose' n'est disponible" exit 1 fi # ─── Récupérer l'image digest actuelle ────────────────────────────────────── FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" OLD_DIGEST=$(get_image_digest "$FULL_IMAGE") log "📦 Image actuelle : $FULL_IMAGE" log "🔍 Digest actuel : ${OLD_DIGEST:-non disponible}" # ─── Pull de la nouvelle image ────────────────────────────────────────────── log "📥 Pull de la nouvelle image..." PULL_OUTPUT=$($DOCKER_COMPOSE -f "$COMPOSE_FILE" pull "$APP_SERVICE" 2>&1) || { PULL_EXIT=$? log "❌ Échec du pull (code $PULL_EXIT) : $PULL_OUTPUT" exit 1 } NEW_DIGEST=$(get_image_digest "$FULL_IMAGE") log "🔍 Nouveau digest : ${NEW_DIGEST:-non disponible}" # ─── Comparer les digests ─────────────────────────────────────────────────── if [[ -n "$OLD_DIGEST" ]] && [[ -n "$NEW_DIGEST" ]] && [[ "$OLD_DIGEST" == "$NEW_DIGEST" ]]; then log "✅ Aucune mise à jour disponible (digest identique)" log "═══ Fin ───────────────────────────────────────────" exit 0 fi # ─── MISE À JOUR NÉCESSAIRE ───────────────────────────────────────────────── log "🔔 Nouvelle version détectée ! Mise à jour en cours..." # Sauvegarder l'image actuelle pour rollback éventuel log "💾 Sauvegarde de l'image actuelle pour rollback..." OLD_IMAGE_ID=$(docker image inspect "$FULL_IMAGE" --format '{{.ID}}' 2>/dev/null || echo "") ROLLBACK_TAG="${IMAGE_NAME}:rollback-$(date +%Y%m%d-%H%M%S)" if [[ -n "$OLD_IMAGE_ID" ]]; then docker tag "$OLD_IMAGE_ID" "$ROLLBACK_TAG" 2>/dev/null || { log "⚠️ Impossible de tagger l'image pour rollback (on continue)" ROLLBACK_TAG="" } else ROLLBACK_TAG="" fi # Backup rapide DB avant mise à jour BACKUP_PRE_DIR="${BACKUP_PRE_DIR:-/tmp/${DOCKER_APP_NAME}-pre-update-$(date +%Y%m%d-%H%M%S)}" mkdir -p "$BACKUP_PRE_DIR" log "💾 Backup DB pré-update vers $BACKUP_PRE_DIR" if docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then docker exec "$DB_CONTAINER" mysqldump \ -u root -p"$DB_ROOT_PASSWORD" \ --single-transaction \ "$DB_NAME" > "$BACKUP_PRE_DIR/${DOCKER_APP_NAME}_db.sql" 2>/dev/null || { log "⚠️ Backup pré-update DB échoué, on continue quand même" } else log "⚠️ Conteneur $DB_CONTAINER non trouvé — pas de backup pré-update" fi # Recréer le conteneur avec la nouvelle image log "🚀 Redémarrage du conteneur avec la nouvelle image..." UP_OUTPUT=$($DOCKER_COMPOSE -f "$COMPOSE_FILE" up -d "$APP_SERVICE" 2>&1) || { UP_EXIT=$? log "❌ Échec du redémarrage (code $UP_EXIT) : $UP_OUTPUT" # Rollback réel : restaurer l'ancienne image if [[ -n "$ROLLBACK_TAG" ]]; then log "🔄 ROLLBACK : restauration de l'image $ROLLBACK_TAG..." docker tag "$ROLLBACK_TAG" "$FULL_IMAGE" 2>/dev/null || true $DOCKER_COMPOSE -f "$COMPOSE_FILE" up -d "$APP_SERVICE" 2>&1 || true docker rmi "$ROLLBACK_TAG" 2>/dev/null || true else log "⚠️ Rollback impossible (pas d'image sauvegardée)" $DOCKER_COMPOSE -f "$COMPOSE_FILE" up -d "$APP_SERVICE" 2>&1 || true fi notify "❌ ${DOCKER_APP_NAME^^} UPDATE FAILED sur $(hostname) — rollback effectué" exit 1 } # Nettoyer le tag de rollback si tout va bien if [[ -n "$ROLLBACK_TAG" ]]; then docker rmi "$ROLLBACK_TAG" 2>/dev/null || true fi # ─── Vérification santé ───────────────────────────────────────────────────── log "🏥 Vérification santé (timeout=${HEALTH_CHECK_TIMEOUT}s)..." HEALTH_OK=false for i in $(seq 1 "$HEALTH_CHECK_TIMEOUT"); do STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_CHECK_URL" 2>/dev/null || echo "000") if [[ "$STATUS" == "200" || "$STATUS" == "302" || "$STATUS" == "303" ]]; then log "✅ ${DOCKER_APP_NAME^} répond (HTTP $STATUS) après ${i}s" HEALTH_OK=true break fi if [[ $i -eq "$HEALTH_CHECK_TIMEOUT" ]]; then log "❌ ${DOCKER_APP_NAME^} ne répond pas après ${HEALTH_CHECK_TIMEOUT}s" notify "⚠️ ${DOCKER_APP_NAME^^} UPDATE : service lent à démarrer sur $(hostname) — vérifier" exit 1 fi sleep 1 done # ─── Rapport final ────────────────────────────────────────────────────────── APP_VERSION="inconnue" if [[ -n "$VERSION_ENDPOINT" ]]; then APP_VERSION=$(curl -s "${HEALTH_CHECK_URL}${VERSION_ENDPOINT}" 2>/dev/null | grep -oE '"version":"[^"]+"' | cut -d'"' -f4 || echo "inconnue") else # Essayer de détecter la version via l'image Docker APP_VERSION=$(docker image inspect "$FULL_IMAGE" --format '{{.Id}}' 2>/dev/null | cut -c8-19 || echo "inconnue") fi log "✅ Mise à jour réussie !" log "📋 Nouvelle version : $APP_VERSION" log "📋 Ancien digest : ${OLD_DIGEST:-N/A}" log "📋 Nouveau digest : ${NEW_DIGEST:-N/A}" log "═══ Fin ───────────────────────────────────────────" notify "✅ ${DOCKER_APP_NAME^} mis à jour vers $APP_VERSION sur $(hostname)" # ─── Nettoyage ciblé : seulement les vieilles images de l'app ─────────────── log "🧹 Nettoyage des anciennes images ${DOCKER_APP_NAME^}..." # Trouver toutes les images de l'app sauf la dernière utilisée CURRENT_IMAGE_ID=$(docker image inspect "$FULL_IMAGE" --format '{{.ID}}' 2>/dev/null || echo "") if [[ -n "$CURRENT_IMAGE_ID" ]]; then # Lister les images de l'app non taggées ou anciennes docker images "$IMAGE_NAME" --format '{{.ID}} {{.Tag}}' 2>/dev/null | while read -r img_id img_tag; do if [[ "$img_id" != "$CURRENT_IMAGE_ID" ]] && [[ "$img_tag" != "$IMAGE_TAG" ]]; then log " 🗑️ Suppression ancienne image : $img_id ($img_tag)" docker rmi "$img_id" 2>/dev/null || true fi done fi # Nettoyer aussi les images dangling (sans tag) docker image prune -f 2>/dev/null || true exit 0