adsb-1090/update-adsb.sh

519 lines
20 KiB
Bash
Executable File

#!/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 <nom>] [--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<n>_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 <nom>] [--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-<service> : 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