- Ajout .env.example : variables Docker Compose - docker-compose.yml : mots de passe variabilises - Scripts : chargement de config a 2 niveaux (global + per-app) - docker-scripts.conf : renomme docker-stack-template - URLs mises a jour dans setup, install_cron, README
292 lines
13 KiB
Bash
292 lines
13 KiB
Bash
#!/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 ────────────────────────────────────────────────
|
|
# Ordre : 1) config globale (template) → 2) config spécifique à l'app (surcharge)
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
|
GLOBAL_CONFIG="${SCRIPT_DIR}/docker-scripts.conf"
|
|
if [[ -f "$GLOBAL_CONFIG" ]]; then
|
|
# shellcheck source=/dev/null
|
|
source "$GLOBAL_CONFIG"
|
|
fi
|
|
|
|
# ─── Nom de l'application (défaut si pas dans la config) ─────────────────────
|
|
DOCKER_APP_NAME="${DOCKER_APP_NAME:-gitea}"
|
|
|
|
# ─── Configuration locale (après chargement global, permet surcharge env) ────
|
|
COMPOSE_DIR="${COMPOSE_DIR:-/srv/docker/$DOCKER_APP_NAME}"
|
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
|
|
|
# Charger la config spécifique à l'application (surcharge la config globale)
|
|
APP_CONFIG="${COMPOSE_DIR}/docker-scripts.conf"
|
|
if [[ -f "$APP_CONFIG" ]] && [[ "$APP_CONFIG" != "$GLOBAL_CONFIG" ]]; then
|
|
# shellcheck source=/dev/null
|
|
source "$APP_CONFIG"
|
|
fi
|
|
|
|
# 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
|