519 lines
20 KiB
Bash
Executable File
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
|