#!/usr/bin/env bash # ============================================================================= # update-adsb.sh — Mise à jour sécurisée du stack ADS-B # # Stratégie : # - Images publiques : docker pull + recréation si nouvelle version # - Images locales : rebuild --no-cache (nouvelles versions des paquets) # - Données/config : jamais touchées (volumes nommés + fichiers sources) # - Rollback : automatique si un conteneur ne démarre pas # # Usage : # sudo ./update-adsb.sh [--check-only] [--service ] [--force] # ============================================================================= set -euo pipefail STACK_DIR="${STACK_DIR:-/data/adsb}" LOG_FILE="/var/log/adsb-update.log" BACKUP_DIR="/data/adsb/backups" # ── Fichiers d'environnement (v4.2) ────────────────────────────────────────── # Depuis le passage aux profiles Compose, docker compose doit recevoir DEUX # --env-file : .env (secret PG_PASSWORD) et .env.receivers (RX_SERIAL + # COMPOSE_PROFILES, généré par gen_env.sh). Sans eux, Compose émet # « The "RX1_SERIAL" variable is not set » et recréerait readsb avec un numéro # de série vide — le conteneur démarrerait alors sur un dongle arbitraire. ENV_FILE="${STACK_DIR}/.env" ENV_RECEIVERS="${STACK_DIR}/.env.receivers" GEN_ENV="${STACK_DIR}/scripts/gen_env.sh" COMPOSE_ARGS=(-f "${STACK_DIR}/docker-compose.yml") [[ -f "$ENV_FILE" ]] && COMPOSE_ARGS+=(--env-file "$ENV_FILE") [[ -f "$ENV_RECEIVERS" ]] && COMPOSE_ARGS+=(--env-file "$ENV_RECEIVERS") COMPOSE="docker compose ${COMPOSE_ARGS[*]}" # ── Couleurs ───────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' info() { echo -e "${CYAN}[INFO]${NC} $*" | tee -a "$LOG_FILE"; } success() { echo -e "${GREEN}[OK]${NC} $*" | tee -a "$LOG_FILE"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "$LOG_FILE"; } error() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "$LOG_FILE" >&2; } header() { echo -e "\n${BOLD}${BLUE}=== $* ===${NC}\n" | tee -a "$LOG_FILE"; } die() { error "$*"; exit 1; } # ── Arguments ──────────────────────────────────────────────────────────────── CHECK_ONLY=false FORCE=false TARGET_SERVICE="" while [[ $# -gt 0 ]]; do case $1 in --check-only) CHECK_ONLY=true ; shift ;; --force) FORCE=true ; shift ;; --service) TARGET_SERVICE="$2" ; shift 2 ;; -h|--help) echo "Usage: $0 [--check-only] [--service ] [--force]" echo " --check-only Vérifie les mises à jour sans les appliquer" echo " --service nom Met à jour uniquement ce service" echo " --force Force le rebuild même sans nouvelle version" exit 0 ;; *) die "Argument inconnu : $1" ;; esac done # ── Prérequis ──────────────────────────────────────────────────────────────── [[ $EUID -eq 0 ]] || die "Exécuter en root" command -v docker &>/dev/null || die "Docker non trouvé" [[ -f "${STACK_DIR}/docker-compose.yml" ]] || die "docker-compose.yml introuvable dans ${STACK_DIR}" # ── Prérequis v4.2 : environnement multi-récepteurs ────────────────────────── [[ -f "$ENV_FILE" ]] || warn "$ENV_FILE absent — PG_PASSWORD ne sera pas injecté" if [[ -x "$GEN_ENV" ]]; then # Régénère .env.receivers depuis les dongles réellement branchés : évite de # recréer un conteneur sur un slot dont le dongle a disparu depuis le # dernier démarrage du stack. if "$GEN_ENV" >/dev/null 2>&1; then RX_COUNT=$(grep -c '^RX[0-9]*_SERIAL=..*' "$ENV_RECEIVERS" 2>/dev/null || echo 0) PROFILES=$(sed -n 's/^COMPOSE_PROFILES=//p' "$ENV_RECEIVERS" 2>/dev/null) success "Environnement régénéré : ${RX_COUNT} récepteur(s) — profiles=${PROFILES:-aucun}" [[ -z "$PROFILES" ]] && warn "Aucun dongle RTL-SDR détecté — les services readsb/tar1090/adsb2pg ne seront pas recréés" else warn "gen_env.sh a échoué — utilisation de $ENV_RECEIVERS tel quel" fi else warn "$GEN_ENV absent — stack antérieur à la v4.2 ? Les profiles ne seront pas recalculés" fi mkdir -p "$BACKUP_DIR" echo "" >> "$LOG_FILE" echo "═══════════════════════════════════════════════════" >> "$LOG_FILE" echo " Mise à jour ADS-B — $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" echo "═══════════════════════════════════════════════════" >> "$LOG_FILE" # ── Définition des services ─────────────────────────────────────────────────── # Format : "nom:type:image_ou_contexte" # type = public (image Docker Hub/ghcr) | local (build local) declare -A SERVICE_TYPE=( [readsb]="public" [tar1090]="public" [adsb2pg]="local" [webapp]="local" # v4.2 : slots 2 et 3, présents seulement si un dongle les alimente [readsb2]="public" [tar10902]="public" [adsb2pg2]="local" [readsb3]="public" [tar10903]="public" [adsb2pg3]="local" ) declare -A SERVICE_IMAGE=( [readsb]="ghcr.io/sdr-enthusiasts/docker-readsb-protobuf:latest" [tar1090]="ghcr.io/sdr-enthusiasts/docker-tar1090:latest" [adsb2pg]="" [webapp]="" [readsb2]="ghcr.io/sdr-enthusiasts/docker-readsb-protobuf:latest" [tar10902]="ghcr.io/sdr-enthusiasts/docker-tar1090:latest" [adsb2pg2]="" [readsb3]="ghcr.io/sdr-enthusiasts/docker-readsb-protobuf:latest" [tar10903]="ghcr.io/sdr-enthusiasts/docker-tar1090:latest" [adsb2pg3]="" ) # Nom du conteneur associé à un service (les slots 2/3 ne suivent pas la # convention adsb- : le service tar10902 donne adsb-tar10902). service_container() { case "$1" in tar10902) echo "adsb-tar10902" ;; tar10903) echo "adsb-tar10903" ;; *) echo "adsb-$1" ;; esac } # Services réellement déployables : on ne traite un slot que si son conteneur # TOURNE. docker inspect réussit aussi sur un conteneur arrêté — un slot dont # le dongle a été retiré laisse un conteneur stoppé derrière lui, qu'il ne faut # ni mettre à jour ni redémarrer (il serait relancé sans profile actif). service_is_active() { [[ "$(docker inspect --format '{{.State.Running}}' \ "$(service_container "$1")" 2>/dev/null)" == "true" ]] } # Services à traiter if [[ -n "$TARGET_SERVICE" ]]; then SERVICES=("$TARGET_SERVICE") else # webapp d'abord (toujours présent), puis les slots réellement déployés SERVICES=("webapp") for _svc in readsb tar1090 adsb2pg readsb2 tar10902 adsb2pg2 readsb3 tar10903 adsb2pg3; do service_is_active "$_svc" && SERVICES+=("$_svc") done info "Services déployés à traiter : ${SERVICES[*]}" fi # ── Sauvegarde de la config avant mise à jour ───────────────────────────────── backup_config() { header "Sauvegarde de la configuration" local backup="${BACKUP_DIR}/config_$(date +%Y%m%d_%H%M%S)" mkdir -p "$backup" # docker-compose.yml cp "${STACK_DIR}/docker-compose.yml" "$backup/" # Config adsb2pg [[ -f "${STACK_DIR}/adsb2pg/adsb2pg.py" ]] && \ cp "${STACK_DIR}/adsb2pg/adsb2pg.py" "$backup/" # Config webapp [[ -d "${STACK_DIR}/webapp/src" ]] && \ cp -r "${STACK_DIR}/webapp/src" "$backup/webapp-src/" # adsb.conf [[ -f "${STACK_DIR}/adsb.conf" ]] && \ cp "${STACK_DIR}/adsb.conf" "$backup/" success "Config sauvegardée dans $backup" echo "$backup" } # ── Sauvegarder l'ID de l'image actuelle (pour rollback) ───────────────────── get_image_id() { local service=$1 docker inspect --format='{{.Image}}' "$(service_container "$service")" 2>/dev/null || echo "" } # ── Vérifier si une nouvelle image est disponible ──────────────────────────── check_public_update() { local service=$1 local image="${SERVICE_IMAGE[$service]}" local current_id current_id=$(get_image_id "$service") info "Vérification de $service ($image)..." # Puller la nouvelle image if ! docker pull "$image" 2>&1 | tee -a "$LOG_FILE"; then warn "Impossible de puller $image" return 1 fi local new_id new_id=$(docker inspect --format='{{.Id}}' "$image" 2>/dev/null || echo "") if [[ "$current_id" == "$new_id" ]] && [[ "$FORCE" != "true" ]]; then success "$service : déjà à jour (${new_id:7:12})" return 1 # Pas de mise à jour nécessaire fi if [[ "$current_id" != "$new_id" ]]; then info "$service : nouvelle version disponible" info " Avant : ${current_id:7:12}" info " Après : ${new_id:7:12}" else info "$service : forçage du redémarrage" fi return 0 # Mise à jour disponible/forcée } # ── Vérifier les mises à jour des paquets dans une image locale ─────────────── check_local_update() { local service=$1 info "Vérification des mises à jour de sécurité pour $service..." # Simuler un build avec --no-cache pour voir s'il y a des paquets à mettre à jour # On vérifie la date du dernier build local built_at built_at=$(docker inspect --format='{{.Created}}' "$(service_container "$service")" 2>/dev/null || echo "") if [[ -z "$built_at" ]]; then warn "$service : conteneur non trouvé, build nécessaire" return 0 fi local built_epoch built_epoch=$(date -d "$built_at" +%s 2>/dev/null || echo 0) local now_epoch now_epoch=$(date +%s) local age_days=$(( (now_epoch - built_epoch) / 86400 )) if [[ $age_days -ge 7 ]] || [[ "$FORCE" == "true" ]]; then info "$service : image vieille de ${age_days} jours → rebuild recommandé" return 0 else success "$service : image récente (${age_days} jours) — pas de rebuild nécessaire" return 1 fi } # ── Mettre à jour un service public ────────────────────────────────────────── update_public_service() { local service=$1 local old_image_id old_image_id=$(get_image_id "$service") info "Mise à jour de $service..." # Recréer le conteneur avec la nouvelle image if ! $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE"; then error "Échec du redémarrage de $service" return 1 fi # Vérifier que le conteneur est sain après 15s sleep 15 if verify_service "$service"; then success "$service mis à jour avec succès" # Supprimer l'ancienne image si elle n'est plus utilisée docker image prune -f 2>/dev/null || true return 0 else error "$service ne répond pas après mise à jour — rollback" rollback_service "$service" "$old_image_id" return 1 fi } # ── Rebuilder un service local ──────────────────────────────────────────────── update_local_service() { local service=$1 info "Rebuild de $service (--no-cache pour forcer MAJ des paquets)..." # Arrêter proprement $COMPOSE stop "$service" 2>&1 | tee -a "$LOG_FILE" || true # Rebuild sans cache if ! $COMPOSE build --no-cache "$service" 2>&1 | tee -a "$LOG_FILE"; then error "Échec du build de $service" # Relancer avec l'ancienne image si elle existe encore $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE" || true return 1 fi # Relancer if ! $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE"; then error "Échec du démarrage de $service après rebuild" return 1 fi # Vérifier sleep 20 if verify_service "$service"; then success "$service rebuild et redémarré avec succès" # Nettoyer les anciennes images docker image prune -f 2>/dev/null || true return 0 else error "$service ne répond pas après rebuild" return 1 fi } # ── Vérification qu'un service est opérationnel ─────────────────────────────── verify_service() { local service=$1 local container; container=$(service_container "$service") local max_attempts=6 local attempt=0 while [[ $attempt -lt $max_attempts ]]; do local status status=$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null || echo "") case "$status" in running) # Vérification supplémentaire selon le service case "$service" in readsb) # Vérifie que readsb décode des messages if docker logs "$container" 2>&1 | \ grep -q "RTL\|tuner\|Found"; then return 0 fi ;; tar1090) # Vérifie que tar1090 a une connexion Beast if docker logs "$container" 2>&1 | \ grep -q "Connection established\|successfully started"; then return 0 fi ;; adsb2pg) # Vérifie la connexion Postgres if docker logs "$container" 2>&1 | \ grep -q "Connecté\|initialisé"; then return 0 fi ;; webapp) # Vérifie qu'Apache répond if curl -s --max-time 3 \ "http://127.0.0.1:8080/" \ -o /dev/null -w "%{http_code}" 2>/dev/null \ | grep -q "200\|302"; then return 0 fi ;; esac ;; exited|dead) error "$container est arrêté (status: $status)" docker logs "$container" 2>&1 | tail -10 | tee -a "$LOG_FILE" return 1 ;; esac ((attempt++)) [[ $attempt -lt $max_attempts ]] && sleep 5 done warn "$service : timeout de vérification (peut encore démarrer)" return 0 # On ne rollback pas sur timeout } # ── Rollback vers une ancienne image ───────────────────────────────────────── rollback_service() { local service=$1 local old_image_id=$2 warn "Rollback de $service vers $old_image_id..." if [[ -z "$old_image_id" ]]; then error "Pas d'image précédente pour rollback" return 1 fi # Forcer l'utilisation de l'ancienne image docker tag "$old_image_id" "$(service_container "$service"):rollback" 2>/dev/null || true docker inspect "$(service_container "$service"):rollback" &>/dev/null && \ $COMPOSE up -d --no-deps "$service" 2>&1 | tee -a "$LOG_FILE" if verify_service "$service"; then success "Rollback de $service réussi" else error "Rollback échoué — intervention manuelle nécessaire" fi } # ── Nettoyer les images orphelines ──────────────────────────────────────────── cleanup_images() { header "Nettoyage des images obsolètes" local freed freed=$(docker image prune -f 2>/dev/null | grep "reclaimed" || echo "") [[ -n "$freed" ]] && info "Espace libéré : $freed" || info "Rien à nettoyer" } # ── Afficher le statut final ────────────────────────────────────────────────── show_status() { header "Statut du stack après mise à jour" $COMPOSE ps 2>&1 | tee -a "$LOG_FILE" echo "" info "Vérification du flux ADS-B :" local count count=$(curl -s --max-time 3 "http://127.0.0.1:8090/data/aircraft.json" \ 2>/dev/null | python3 -c " import json,sys try: d=json.load(sys.stdin) ac=[a for a in d.get('aircraft',[]) if a.get('lat')] print(f' Messages={d[\"messages\"]} Avions={len(ac)}') except: print(' tar1090 non disponible') " 2>/dev/null || echo " tar1090 non disponible") info "$count" } # ── Mode check-only ─────────────────────────────────────────────────────────── if [[ "$CHECK_ONLY" == "true" ]]; then header "Vérification des mises à jour disponibles (mode lecture seule)" for service in "${SERVICES[@]}"; do type="${SERVICE_TYPE[$service]}" case "$type" in public) check_public_update "$service" && \ echo " → Mise à jour disponible pour $service" || true ;; local) check_local_update "$service" && \ echo " → Rebuild recommandé pour $service" || true ;; esac done echo "" info "Lancez sans --check-only pour appliquer les mises à jour" exit 0 fi # ── Mise à jour principale ──────────────────────────────────────────────────── header "Mise à jour du stack ADS-B — $(date '+%Y-%m-%d %H:%M:%S')" # 1. Sauvegarder la config BACKUP_PATH=$(backup_config) # 2. Traiter chaque service UPDATED=() FAILED=() SKIPPED=() for service in "${SERVICES[@]}"; do echo "" type="${SERVICE_TYPE[$service]:-}" [[ -z "$type" ]] && { warn "Service inconnu : $service — ignoré"; continue; } case "$type" in public) if check_public_update "$service"; then if [[ "$CHECK_ONLY" != "true" ]]; then if update_public_service "$service"; then UPDATED+=("$service") else FAILED+=("$service") fi fi else SKIPPED+=("$service") fi ;; local) if check_local_update "$service"; then if [[ "$CHECK_ONLY" != "true" ]]; then if update_local_service "$service"; then UPDATED+=("$service") else FAILED+=("$service") fi fi else SKIPPED+=("$service") fi ;; esac done # 3. Nettoyage cleanup_images # 4. Statut final show_status # 5. Résumé header "Résumé" [[ ${#UPDATED[@]} -gt 0 ]] && success "Mis à jour : ${UPDATED[*]}" [[ ${#SKIPPED[@]} -gt 0 ]] && info "Pas de MAJ : ${SKIPPED[*]}" [[ ${#FAILED[@]} -gt 0 ]] && error "Échecs : ${FAILED[*]}" info "Log complet : $LOG_FILE" info "Backup config : $BACKUP_PATH" # 6. Rotation des backups (garder les 10 derniers) ls -dt "${BACKUP_DIR}"/config_* 2>/dev/null | tail -n +11 | xargs rm -rf 2>/dev/null || true # 7. Rotation des logs (garder 1 Mo max) if [[ -f "$LOG_FILE" ]] && [[ $(stat -c%s "$LOG_FILE") -gt 1048576 ]]; then mv "$LOG_FILE" "${LOG_FILE}.old" info "Log archivé (${LOG_FILE}.old)" fi [[ ${#FAILED[@]} -gt 0 ]] && exit 1 || exit 0