#!/usr/bin/env bash
# =============================================================================
# SYLEX — installation d une Sylex Station
# Agora Software
#
#   sudo ./install-sylex.sh
#
# Ne demande QUE la cle de licence. Tout le reste est automatique : verification
# de la machine, acces au depot Agora, telechargement, demarrage, validation.
#
# Options (aucune n est necessaire en installation normale) :
#   --key <cle>        cle de licence sans invite (ou variable SYLEX_KEY)
#   --version <tag>    version d image (defaut: stable — le pointeur pose par Agora sur la
#                      derniere version VALIDEE ; jamais latest, qui peut etre un build d essai)
#   --port <port>      port de l API sur le reseau local (defaut: 8000). Accepte une LISTE
#                      (`--port 11434,11435`) pour reprendre plusieurs points d entree a
#                      l identique en remplacant une passerelle existante
#   --bind <adresse>   adresse d ecoute du port en clair. Defaut: auto — loopback si cette
#                      machine porte une adresse PUBLIQUE, toutes les interfaces sinon (le cas
#                      d une Station derriere un NAT). `--bind 0.0.0.0` force l ouverture
#   --api-url <url>    canal de configuration Agora (console d administration).
#                      Optionnel : par defaut la production. A ne preciser que pour
#                      une pre-production ou un poste de developpement.
#   --tls-port <port>  port HTTPS publie (defaut: 443). 0 = ne pas publier
#   --acme-port <port> port du defi Let s Encrypt (defaut: 80). 0 = ne pas publier
#   --gpus <spec>      cartes affectees a SYLEX (defaut: all). Ex. --gpus 0,1 sur un serveur
#                      partage. C est AUSSI l assiette de la licence en mode gpu-* (cf. --identity)
#   --identity <mode>  liaison licence<->machine : auto (defaut) | dmi | gpu-serial | gpu-uuid
#   --admin-token <t>  jeton des routes /api/* d Eole (en-tete X-Admin-Token). Par defaut VIDE :
#                      le superviseur en tire un aleatoire a chaque demarrage, donc les routes
#                      sont fermees et personne d exterieur ne peut les piloter. A poser quand un
#                      appelant doit le faire — le canal de synchro des ModelFiles du backend
#                      Agora (console : Inference > Cluster > SHARED AUTHENTICATION). Un jeton
#                      tire au boot ne peut PAS servir a ca : il change a chaque bascule d image
#   --compat <profil>  profil de compatibilite par defaut des ModelFiles sans champ `compat` :
#                      openai (defaut de l image, harnais tiers) | agora (pile SDK Agora)
#   --check            ne rien installer : verifier une Station existante
#   --uninstall        retirer SYLEX (conteneur, service de mise a jour, /opt/sylex, images).
#                      GARDE /data : modeles, cles et compteurs. Ajouter --purge pour l effacer
#   --no-wait          rendre la main sans attendre que la passerelle reponde
#
# ARCHITECTURES : arm64 (Sylex Station / DGX Spark GB10) et amd64 (serveurs CUDA, ex. gpu3).
# Le suffixe de tag est resolu automatiquement (`-amd64` sur x86_64) : une fiche du catalogue
# dit `0.1.10` et chaque machine tire l image de SON architecture.
#
# Relançable sans risque : une installation existante est mise a jour, les
# modeles deja telecharges ne le sont pas deux fois.
#
# Mises a jour SUIVANTES : automatiques. L installateur pose un timer systemd
# (sylex-update.timer) qui lit la demande ecrite par la Station quand la console lui
# deploie un modele exigeant une autre version, tire l image, bascule dans la fenetre
# de maintenance et revient en arriere si le moteur ne repond pas. Le client n a plus
# jamais a relancer ce script. Spec : backend/docs/sylex-image-update.md.
# =============================================================================
set -euo pipefail

IMAGE_REPO="registry.agora.tools/sylex"
REGISTRY_HOST="registry.agora.tools"
INSTALL_DIR="/opt/sylex"
DATA_DIR="/data"
CONTAINER="sylex"
VERSION="stable"
API_PORT="8000"
# Adresse d ecoute du port en clair — resolue plus bas (cf. bind_auto) quand elle vaut `auto`.
API_BIND="auto"
HEALTH_PORT="8081"
KEY="${SYLEX_KEY:-}"
MODE="install"
WAIT="yes"
GPUS="all"
IDENTITY="auto"
IDENTITY_EXPLICITE=0
GPUS_EXPLICITE=0
# Ports du TLS. Publies MEME SI le TLS est eteint (c est le defaut) : un port publie sans rien
# derriere ne fait que refuser la connexion, et les publier d avance rend l activation depuis la
# console possible SANS visite sur la machine — ce qui est toute la promesse du produit. Un
# conflit sur l hote est detecte plus bas et n echoue pas l installation.
TLS_PORT_HOST="443"
ACME_PORT_HOST="80"
PURGE="no"
# Jeton des routes /api/* d Eole. VIDE = comportement par defaut du produit : le superviseur en
# genere un aleatoire au demarrage, ce qui ferme /api/config (PUT/DELETE !) et /api/usage par
# construction. Ne le poser que si un appelant EXTERIEUR doit piloter la passerelle.
ADMIN_TOKEN=""
# Profil de compatibilite par defaut (vide = celui de l image, openai).
COMPAT_DEFAULT=""
# Suffixe de tag par architecture. Les images sont buildees separement (base vLLM differente :
# NGC arm64 pour le GB10, vllm/vllm-openai amd64 pour les serveurs SM120) ; le suffixe est ce qui
# permet a UNE fiche catalogue (`image: 0.1.10`) de servir les deux parcs. Il est ecrit dans
# update.conf pour que le service de mise a jour tire le meme.
case "$(uname -m)" in
  x86_64|amd64)   IMAGE_SUFFIX="-amd64" ;;
  aarch64|arm64)  IMAGE_SUFFIX="" ;;
  *)              IMAGE_SUFFIX="" ;;
esac
DISK_MIN_GB=200
# Canal de conf Agora (contrat /sylex/v1). INDISPENSABLE depuis que l image ne porte plus de
# modele par defaut : sans lui, la Station n a aucun moyen d obtenir un modele et resterait une
# passerelle qui repond 502. D ou un DEFAUT sur la production, plutot qu une option obligatoire
# que l installateur oublierait — l option ne sert plus qu aux environnements de test.
API_URL_DEFAULT="https://agora.agora.tools/sylex/v1"
API_URL="${SYLEX_API_URL:-$API_URL_DEFAULT}"
[[ -n "${SYLEX_API_URL:-}" ]] && API_URL_EXPLICITE=1
API_URL_EXPLICITE="${API_URL_EXPLICITE:-0}"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --key)     KEY="$2"; shift 2 ;;
    --version) VERSION="$2"; shift 2 ;;
    --port)    API_PORT="$2"; shift 2 ;;
    --bind)    API_BIND="$2"; shift 2 ;;
    --api-url) API_URL="$2"; API_URL_EXPLICITE=1; shift 2 ;;
    --tls-port)  TLS_PORT_HOST="$2"; shift 2 ;;
    --acme-port) ACME_PORT_HOST="$2"; shift 2 ;;
    --gpus)    GPUS="$2"; GPUS_EXPLICITE=1; shift 2 ;;
    --identity) IDENTITY="$2"; IDENTITY_EXPLICITE=1; shift 2 ;;
    --admin-token) ADMIN_TOKEN="$2"; shift 2 ;;
    --compat)  COMPAT_DEFAULT="$2"; shift 2 ;;
    --check)   MODE="check"; shift ;;
    --uninstall) MODE="uninstall"; shift ;;
    --purge)   PURGE="yes"; shift ;;
    --no-wait) WAIT="no"; shift ;;
    -h|--help) sed -n '2,43p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) echo "Option inconnue : $1 (--help)" >&2; exit 2 ;;
  esac
done

# ------------------------------------------------- profil de compatibilite
# Valide TOT, avant tout controle machine : c est une faute de SAISIE, et refuser d abord de
# n etre pas root ne l apprendrait a personne. Une faute de frappe (`agore`) ne serait pas
# rejetee par Eole, qui retomberait sur son defaut — la Station servirait alors un autre profil
# que celui demande, sans un mot. Vide reste legitime : garder le defaut de l image.
# (Une valeur REPRISE d une installation existante n est pas revalidee : elle l a ete a l ecriture.)
case "$COMPAT_DEFAULT" in
  ""|agora|openai) ;;
  *) echo "--compat : valeur inattendue '$COMPAT_DEFAULT' (agora | openai)" >&2; exit 2 ;;
esac

# ------------------------------------------------- ports de l API (liste acceptee)
# `--port` accepte une LISTE parce que remplacer une passerelle existante demande parfois de
# reprendre plusieurs points d entree a l identique — l Eole de pre-prod de gpu3 publiait ses deux
# ports historiques (11434 et 11435) vers le meme service. Un seul Eole ecoute derriere : les
# sondes, le fichier de l hote et le recapitulatif utilisent donc le PREMIER de la liste.
IFS=', ' read -r -a API_PORTS <<< "$API_PORT"
[[ ${#API_PORTS[@]} -gt 0 ]] || { echo "--port : aucune valeur" >&2; exit 2; }
for _p in "${API_PORTS[@]}"; do
  [[ "$_p" =~ ^[0-9]+$ ]] && (( _p >= 1 && _p <= 65535 )) \
    || { echo "--port : '$_p' n est pas un port valide" >&2; exit 2; }
done
API_PORT="${API_PORTS[0]}"

# ---------------------------------------------------------------- affichage
if [[ -t 1 ]]; then B=$'\e[1m'; G=$'\e[32m'; R=$'\e[31m'; Y=$'\e[33m'; N=$'\e[0m'
else B=""; G=""; R=""; Y=""; N=""; fi
step()  { echo; echo "${B}▸ $*${N}"; }
ok()    { echo "  ${G}✓${N} $*"; }
warn()  { echo "  ${Y}!${N} $*"; }
die()   { echo; echo "${R}✗ $*${N}" >&2; echo; exit 1; }

echo "${B}SYLEX — installation${N}"
echo "Agora Software · $(date '+%d/%m/%Y %H:%M')"

# ---------------------------------------------------------------- 1. machine
step "Vérification de la machine"

[[ $EUID -eq 0 ]] || die "À lancer avec sudo : sudo $0"

command -v docker >/dev/null || die "Docker est absent. Installez Docker puis relancez."
docker info >/dev/null 2>&1 || die "Le service Docker ne répond pas (systemctl start docker)."
ok "Docker $(docker --version | sed 's/Docker version //;s/,.*//')"

# ⚠️ Contrôle du plugin compose ICI, avec les autres prerequis machine. Sans lui, l installation
# deroulait tous ses controles, telechargeait 27 Go, ecrivait le compose... et echouait a la
# derniere ligne sur `docker compose up`. Un echec tardif apres un long telechargement, la ou une
# ligne au debut le dit tout de suite.
docker compose version >/dev/null 2>&1 \
  || die "Le plugin 'docker compose' est absent (Docker Compose v2 requis). Sur Debian/Ubuntu : apt install docker-compose-plugin."
ok "Docker Compose $(docker compose version --short 2>/dev/null || echo v2)"

# ---------------------------------------------------------------- mode uninstall
# Placé AVANT les contrôles GPU : on doit pouvoir désinstaller une Station dont le pilote est
# cassé — c'est même un cas fréquent de désinstallation.
if [[ "$MODE" == "uninstall" ]]; then
  step "Désinstallation de SYLEX"
  echo "  Seront retirés : le service ${CONTAINER}, le service de mise à jour, ${INSTALL_DIR}"
  echo "                   et les images ${IMAGE_REPO}:*"
  if [[ "$PURGE" == "yes" ]]; then
    echo "  ${R}${B}--purge : ${DATA_DIR} SERA EFFACÉ${N} — modèles téléchargés, clés d'API,"
    echo "  ${R}  compteurs de consommation et configuration. IRRÉVERSIBLE.${N}"
  else
    echo "  ${G}Conservé${N} : ${DATA_DIR} (modèles, clés, compteurs) — réinstaller le réutilise."
  fi
  # ⚠️ ARRÊTER LE TIMER EN PREMIER. Il se réveille toutes les 5 min et sur inotify : s'il tire
  # pendant qu'on démonte, il RECRÉE le conteneur qu'on vient de retirer.
  if command -v systemctl >/dev/null; then
    systemctl disable --now sylex-update.path sylex-update.timer >/dev/null 2>&1 || true
    systemctl stop sylex-update.service >/dev/null 2>&1 || true
    ok "Service de mise à jour arrêté"
  fi
  if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then
    ( cd "$INSTALL_DIR" && docker compose down --remove-orphans >/dev/null 2>&1 ) || true
  fi
  docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
  ok "Service ${CONTAINER} arrêté et retiré"
  if command -v systemctl >/dev/null; then
    rm -f /etc/systemd/system/sylex-update.service /etc/systemd/system/sylex-update.timer \
          /etc/systemd/system/sylex-update.path
    # Balayage des liens d'activation : `disable` les a normalement retirés, mais une unité dont
    # le fichier aurait déjà disparu (édition manuelle, mise à jour interrompue) laisse un lien
    # pendant que `disable` ne sait plus résoudre.
    find /etc/systemd/system -name 'sylex-update.*' -delete 2>/dev/null || true
    systemctl daemon-reload >/dev/null 2>&1 || true
    # Sans ça, un service qui a ÉCHOUÉ avant la désinstallation reste listé en `not-found` dans
    # `systemctl list-units --failed` : un fantôme, sur une machine censée n'avoir plus de SYLEX.
    systemctl reset-failed sylex-update.service sylex-update.timer sylex-update.path >/dev/null 2>&1 || true
    ok "Unités systemd retirées"
  fi
  rm -rf "$INSTALL_DIR"
  ok "${INSTALL_DIR} supprimé"
  IMGS="$(docker images --format '{{.Repository}}:{{.Tag}}' | grep "^${IMAGE_REPO}:" || true)"
  if [[ -n "$IMGS" ]]; then
    echo "$IMGS" | xargs -r docker rmi -f >/dev/null 2>&1 || true
    ok "Images retirées ($(echo "$IMGS" | wc -l | tr -d ' '))"
  fi
  # La clé de licence servait aussi d'identifiant au dépôt : la laisser serait laisser un secret.
  docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
  ok "Accès au dépôt Agora retiré"
  if [[ "$PURGE" == "yes" ]]; then
    TAILLE="$(du -sh "$DATA_DIR" 2>/dev/null | cut -f1 || echo '?')"
    echo
    echo "  ${R}${B}Confirmer l'effacement de ${DATA_DIR} (${TAILLE})${N}"
    if { exec 3< /dev/tty; } 2>/dev/null; then
      read -rp "  Taper SUPPRIMER pour confirmer : " REPONSE <&3; exec 3<&-
    else
      REPONSE=""
    fi
    if [[ "$REPONSE" == "SUPPRIMER" ]]; then
      rm -rf "${DATA_DIR:?}"/* "${DATA_DIR:?}"/.[!.]* 2>/dev/null || true
      ok "${DATA_DIR} vidé"
    else
      warn "Effacement annulé — ${DATA_DIR} conservé"
    fi
  fi
  echo
  echo "${B}${G}SYLEX est désinstallé.${N}"
  [[ "$PURGE" != "yes" ]] && echo "  ${DATA_DIR} est conservé : une réinstallation repartira des modèles déjà présents."
  echo
  exit 0
fi

command -v nvidia-smi >/dev/null || die "Pilote NVIDIA absent (nvidia-smi introuvable)."
GPU="$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1 || true)"
[[ -n "$GPU" ]] || die "Aucun GPU détecté par nvidia-smi."
ok "GPU : $GPU"

# NB : pas de `docker run <image> true` de contrôle ici — l'ENTRYPOINT de l'image est
# le superviseur (l'argument serait ignoré) : si l'image est déjà locale, la commande
# démarrerait le service en avant-plan et le script pendrait indéfiniment.
if ! docker info 2>/dev/null | grep -q "Runtimes:.*nvidia"; then
  die "Le runtime NVIDIA de Docker est absent (NVIDIA Container Toolkit requis)."
fi
ok "Runtime NVIDIA disponible"

AVAIL_GB=$(df -BG --output=avail "$(dirname "$DATA_DIR")" 2>/dev/null | tail -1 | tr -dc '0-9')
if [[ -n "${AVAIL_GB:-}" && "$AVAIL_GB" -lt "$DISK_MIN_GB" ]]; then
  warn "Espace libre : ${AVAIL_GB} Go (recommandé : ${DISK_MIN_GB} Go)."
  warn "Un modèle occupe 25 à 85 Go. L'installation peut échouer faute de place."
else
  ok "Espace disque : ${AVAIL_GB:-?} Go libres"
fi

# Rappel thermique : premier facteur de panne sur cette plateforme.
warn "Emplacement : boîtier dégagé, 10 cm libres autour, jamais en placard fermé."
warn "En usage intensif, une ventilation d'appoint évite les arrêts par surchauffe."

# ---------------------------------------------------------------- mode check
if [[ "$MODE" == "check" ]]; then
  step "État de la Station"
  docker ps --filter "name=^${CONTAINER}$" --format '  {{.Status}} · {{.Image}}' || true
  if curl -fsS --max-time 5 "http://localhost:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
    ok "Service en ligne"
    M="$(curl -s -o /tmp/sylex-chk.$$ -w '%{http_code}' --max-time 5 "http://localhost:${API_PORT}/v1/models" || echo 000)"
    if [[ "$M" == "401" ]]; then
      echo "  Modèles : (API protégée par clé — présenter une clé d'API Sylex)"
    else
      echo "  Modèles : $(tr ',' '\n' < /tmp/sylex-chk.$$ | grep -o '"id":"[^"]*"' | cut -d'"' -f4 | tr '\n' ' ')"
    fi
    rm -f /tmp/sylex-chk.$$
  else
    warn "Le service ne répond pas encore (chargement en cours ?)"
    echo "  Suivi : docker logs -f ${CONTAINER}"
  fi
  exit 0
fi

# Relit UNE variable du bloc `environment:` d un compose deja en place. Paire exacte de ce
# qu ecrit env_optionnel() : l aller-retour est eprouve par tests/test-install-ports.sh.
lire_env_compose() {  # lire_env_compose <NOM> <fichier>
  sed -n "s/^[[:space:]]*$1: *\"\([^\"]*\)\".*/\1/p" "$2" 2>/dev/null | head -1
}

# ------------------------------------------------- 1ter. réglages déjà en place
# Une RELANCE est une mise à jour, pas une réinstallation : redemander la clé serait
# une friction inutile, et surtout, réécrire l URL par le défaut PRODUCTION
# déplacerait la Station vers un autre backend sans que personne ne le voie. On
# reprend donc ce qui est en place, sauf demande explicite en ligne de commande.
COMPOSE_EXISTANT="$INSTALL_DIR/docker-compose.yml"
if [[ -f "$COMPOSE_EXISTANT" ]]; then
  if [[ -z "$KEY" ]]; then
    KEY="$(sed -n 's/.*SYLEX_ACTIVATION_KEY: *"\([^"]*\)".*/\1/p' "$COMPOSE_EXISTANT" | head -1)"
    [[ -n "$KEY" ]] && echo "  ${G}✓${N} Clé de licence reprise de l installation existante"
  fi
  if [[ "$API_URL_EXPLICITE" == "0" ]]; then
    ANCIENNE_URL="$(sed -n 's/.*SYLEX_API_URL: *"\([^"]*\)".*/\1/p' "$COMPOSE_EXISTANT" | head -1)"
    if [[ -n "$ANCIENNE_URL" && "$ANCIENNE_URL" != "$API_URL" ]]; then
      API_URL="$ANCIENNE_URL"
      echo "  ${G}✓${N} Console Agora reprise de l installation existante : ${API_URL}"
    fi
  fi
  # Meme raison pour les deux variables optionnelles : l installateur REGENERE le compose, donc
  # sans reprise une relance sans option effacerait le jeton — et la synchro du backend
  # retomberait en 403 des jours plus tard, sans que rien ne relie la cause a l effet.
  if [[ -z "$ADMIN_TOKEN" ]]; then
    ADMIN_TOKEN="$(lire_env_compose ADMIN_TOKEN "$COMPOSE_EXISTANT")"
    [[ -n "$ADMIN_TOKEN" ]] && echo "  ${G}✓${N} Jeton /api/* repris de l installation existante"
  fi
  if [[ -z "$COMPAT_DEFAULT" ]]; then
    COMPAT_DEFAULT="$(lire_env_compose COMPAT_DEFAULT "$COMPOSE_EXISTANT")"
    [[ -n "$COMPAT_DEFAULT" ]] && echo "  ${G}✓${N} Profil de compatibilite repris : ${COMPAT_DEFAULT}"
  fi
  # L IDENTITE de licence et l assiette de cartes sont FIGEES a la premiere installation : la
  # liaison licence<->machine est permanente cote serveur, et une identite qui change vaut un
  # `409 serial_mismatch` opaque. Re-resoudre `auto` a chaque relance rejouerait ce pari ; et
  # comme l identite couvre les cartes VISIBLES, changer --gpus la change aussi. On reprend donc
  # les deux, sauf demande explicite en ligne de commande (`--identity auto` force la re-resolution).
  if [[ "$IDENTITY_EXPLICITE" == "0" ]]; then
    ANCIENNE_ID="$(lire_env_compose SYLEX_IDENTITY "$COMPOSE_EXISTANT")"
    if [[ -n "$ANCIENNE_ID" ]]; then
      IDENTITY="$ANCIENNE_ID"
      echo "  ${G}✓${N} Identite de licence reprise : ${IDENTITY}"
    fi
  fi
  if [[ "$GPUS_EXPLICITE" == "0" ]]; then
    ANCIEN_GPUS="$(lire_env_compose NVIDIA_VISIBLE_DEVICES "$COMPOSE_EXISTANT")"
    if [[ -n "$ANCIEN_GPUS" && "$ANCIEN_GPUS" != "$GPUS" ]]; then
      GPUS="$ANCIEN_GPUS"
      echo "  ${G}✓${N} Cartes affectees reprises : ${GPUS}"
    fi
  fi
fi

# ---------------------------------------------------------------- 2. licence
step "Clé de licence"
if [[ -z "$KEY" ]]; then
  # ⚠️ LIRE SUR /dev/tty, PAS sur l'entrée standard. En installation par
  #   curl -fsSL .../install-sylex.sh | sudo bash
  # l'entrée standard EST le script : un `read` classique y avalerait les lignes
  # suivantes du script au lieu d'attendre le clavier. /dev/tty désigne le terminal
  # réel, indépendamment de ce qui est branché sur stdin.
  # ⚠️ Tester l'OUVERTURE, pas les droits : `[[ -r /dev/tty ]]` réussit sur une machine
  # sans terminal de contrôle alors que la lecture, elle, échoue (vérifié).
  # Le `2>/dev/null` doit englober le `exec` (accolades) : sur un `exec` la redirection
  # devient permanente et le message d'erreur s'affiche quand même. Vérifié.
  if { exec 3< /dev/tty; } 2>/dev/null; then
    echo "  Collez la clé fournie par Agora, puis Entrée :"
    read -rsp "  Clé : " KEY <&3; echo
    exec 3<&-
  else
    die "Aucun terminal pour saisir la clé — la passer en option : --key 'identifiant:secret'"
  fi
fi
[[ -n "$KEY" ]] || die "Aucune clé saisie."
[[ "$KEY" == *:* ]] || die "Clé invalide (format attendu : identifiant:secret)."
KEY_USER="${KEY%%:*}"
KEY_SECRET="${KEY#*:}"
[[ -n "$KEY_USER" && -n "$KEY_SECRET" ]] || die "Clé invalide."
ok "Clé lue (${KEY_USER})"

step "Vérification de la clé auprès d'Agora"
echo "$KEY_SECRET" | docker login "$REGISTRY_HOST" -u "$KEY_USER" --password-stdin >/dev/null 2>&1 \
  || die "Clé refusée par Agora, ou pas d'accès Internet vers ${REGISTRY_HOST}."
ok "Clé valide"

if ! curl -fsS -u "$KEY_USER:$KEY_SECRET" --max-time 20 \
     "https://${REGISTRY_HOST}/models/common/qwen36-27b/nvfp4/manifest.json" >/dev/null 2>&1; then
  die "Accès au catalogue de modèles refusé. Contactez Agora (clé sans droit de téléchargement)."
fi
ok "Accès au catalogue de modèles"

SERIAL="$(cat /sys/class/dmi/id/product_serial 2>/dev/null || echo inconnu)"
ok "Station : ${SERIAL}"

# ---------------------------------------------------------------- 3. image
step "Téléchargement du logiciel"
IMAGE_REF="${IMAGE_REPO}:${VERSION}${IMAGE_SUFFIX}"
[[ -n "$IMAGE_SUFFIX" ]] && ok "Architecture $(uname -m) → images ${IMAGE_SUFFIX#-}"
if docker pull "$IMAGE_REF"; then
  ok "Image $IMAGE_REF"
elif docker image inspect "$IMAGE_REF" >/dev/null 2>&1; then
  # Image DÉJÀ LÀ mais registry injoignable (ou version non publiée : recette d'un build
  # local). Échouer ici empêcherait de relancer l'installateur hors ligne alors que tout ce
  # qu'il lui faut est sur la machine — on continue, en le disant.
  warn "Registry injoignable ou version non publiée — image LOCALE $IMAGE_REF utilisée"
else
  if [[ "$VERSION" == "stable" ]]; then
    die "Le pointeur '${IMAGE_REPO}:stable${IMAGE_SUFFIX}' n'est pas publié. C'est à Agora de le poser sur la dernière version validée (docker tag + push) — ou préciser une version : --version X.Y.Z"
  fi
  die "Téléchargement de l'image ${IMAGE_REF} impossible$( [[ -n "$IMAGE_SUFFIX" ]] && echo " (cette architecture exige une image ${IMAGE_SUFFIX#-} ; est-elle publiée ?)")."
fi
# La version RÉSOLUE, écrite dans le compose : un pointeur (`stable`) bougerait sous les pieds de
# l'hôte — `sylex-update.sh` compare la demande à la ligne image: et doit y lire un X.Y.Z. On la
# lit dans l'image elle-même (AGORA_IMAGE_VERSION=X.Y.Z-sylex), pas dans le tag demandé.
RESOLVED="$(docker inspect "$IMAGE_REF" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \
  | sed -n 's/^AGORA_IMAGE_VERSION=\(.*\)-sylex$/\1/p' | head -1)"
if [[ "$RESOLVED" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
  [[ "$RESOLVED" != "$VERSION" ]] && ok "Version : $RESOLVED"
  docker tag "$IMAGE_REF" "${IMAGE_REPO}:${RESOLVED}${IMAGE_SUFFIX}" 2>/dev/null || true
  VERSION="$RESOLVED"; IMAGE_REF="${IMAGE_REPO}:${VERSION}${IMAGE_SUFFIX}"
else
  warn "Version d'image non lisible (image ancienne) — le compose portera '$VERSION' tel quel"
fi

# ---------------------------------------------------------------- 4. volume
step "Préparation de l'espace de travail"
mkdir -p "$DATA_DIR" "$DATA_DIR/.cache/flashinfer-121a" "$DATA_DIR/.cache/vllm-jit" "$INSTALL_DIR"
ok "$DATA_DIR"

# ---------------------------------------------------------------- 4bis. identité de licence
# La liaison licence↔machine est PERMANENTE côté backend : une identité qui change vaut un
# `409 serial_mismatch` et une intervention Agora. On la résout donc ICI, une fois, et on FIGE le
# mode dans le compose — l'agent ne devine jamais.
step "Identité de la Station"
if [[ "$IDENTITY" == "auto" ]]; then
  # L'appliance (Sylex Station, arm64) s'identifie par son CHÂSSIS : un numéro gravé, lisible sur
  # l'étiquette, que le support peut rapprocher d'un bon de livraison.
  # Un SERVEUR (amd64) s'identifie par ses CARTES : c'est ce qui est licencié, ça survit à une
  # réinstallation de l'OS, et ça ne se duplique pas comme un serial DMI de VM.
  DMI_SERIAL="$(tr -d '[:space:]' < /sys/class/dmi/id/product_serial 2>/dev/null || true)"
  case "${DMI_SERIAL^^}" in
    ''|*'TOBEFILLED'*|*'DEFAULTSTRING'*|*'SYSTEMSERIALNUMBER'*|*'NOTSPECIFIED'*|'0'|'123456789'|'0123456789') DMI_SERIAL="" ;;
  esac
  if [[ -z "$IMAGE_SUFFIX" && -n "$DMI_SERIAL" ]]; then
    IDENTITY="dmi"
  elif nvidia-smi --query-gpu=serial --format=csv,noheader 2>/dev/null | grep -qv '\[N/A\]' \
       && ! nvidia-smi --query-gpu=serial --format=csv,noheader 2>/dev/null | grep -q '\[N/A\]'; then
    IDENTITY="gpu-serial"
  else
    IDENTITY="gpu-uuid"
  fi
fi
case "$IDENTITY" in
  dmi|gpu-serial|gpu-uuid) ;;
  *) die "--identity : valeur inconnue '$IDENTITY' (attendu : auto, dmi, gpu-serial ou gpu-uuid)" ;;
esac
# Calculée PAR L'IMAGE elle-même, avec les cartes que verra le conteneur : une seule source de
# vérité, et ça vérifie du même coup que nvidia-smi fonctionne bien à l'intérieur.
IDENT_VALUE="$(docker run --rm --runtime nvidia \
  -e NVIDIA_VISIBLE_DEVICES="$GPUS" -e SYLEX_IDENTITY="$IDENTITY" \
  --entrypoint python3 "$IMAGE_REF" \
  -c 'import sys; sys.path.insert(0, "/opt/agora"); import sylex_station as s; print(s.machine_serial())' \
  2>/dev/null | tail -1 | tr -d '[:space:]')"
if [[ -z "$IDENT_VALUE" ]]; then
  case "$IDENTITY" in
    gpu-serial) die "Identité 'gpu-serial' incalculable : une des cartes visibles ne publie pas de numéro de série (fréquent — le GB10 rend [N/A]). Relancer avec --identity gpu-uuid." ;;
    gpu-uuid)   die "Identité 'gpu-uuid' incalculable : le conteneur ne voit aucun GPU (runtime NVIDIA ? --gpus '$GPUS' ?)." ;;
    *)          die "Identité 'dmi' incalculable : /sys/class/dmi/id/product_serial illisible. Relancer avec --identity gpu-uuid." ;;
  esac
fi
ok "Mode ${IDENTITY} · identité ${IDENT_VALUE}"
[[ "$GPUS" != "all" ]] && ok "Cartes affectées à SYLEX : ${GPUS}"

# ------------------------------------------------- 4ter. adresse d ecoute du port en clair
# DECIDE ICI, pas laisse au hasard. Eole ecoute DEUX ports : le clair (8000) et le HTTPS (8443).
# Le TLS ne couvre pas le premier, il vit A COTE. Sur une machine qui porte une adresse PUBLIQUE,
# publier le clair sur toutes les interfaces offre donc a internet une porte NON CHIFFREE vers le
# meme Eole que le HTTPS — et rien n oblige un client a prendre la bonne.
# Derriere un NAT (le cas d une Station dans les locaux d un client), l interface ne porte qu une
# adresse privee : le clair n est pas joignable de l exterieur, et c est LUI l usage nominal
# (http://<ip-de-la-station>:8000, trafic qui ne quitte pas les locaux). On ne le ferme donc pas.
# ⚠️ Et on ne publie JAMAIS sur l adresse privee explicite, qui serait plus fine : en DHCP elle
# change, et le conteneur refuserait de redemarrer (« cannot assign requested address »).
adresse_publique() {   # adresse_publique <ip> — vrai si elle est routable sur internet
  local ip="$1"
  case "$ip" in
    *:*)   # IPv6 : seules les globales (2000::/3) sortent ; le reste est local ou prive
      case "$ip" in
        ::1|fe80:*|fc??:*|fd??:*|FE80:*|FC??:*|FD??:*) return 1 ;;
        *) return 0 ;;
      esac ;;
    10.*|127.*|169.254.*|192.168.*|0.*|255.*)   return 1 ;;
    172.1[6-9].*|172.2[0-9].*|172.3[01].*)      return 1 ;;
    100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 1 ;;   # CGNAT 100.64/10
    *) return 0 ;;
  esac
}
bind_auto() {   # bind_auto <adresses...> — adresse d ecoute du clair ('' = toutes les interfaces)
  local ip
  for ip in "$@"; do
    adresse_publique "$ip" && { printf '127.0.0.1'; return; }
  done
  printf ''
}
if [[ "$API_BIND" == "auto" ]]; then
  ADRESSES="$(hostname -I 2>/dev/null || true)"
  API_BIND="$(bind_auto $ADRESSES)"
  if [[ -n "$API_BIND" ]]; then
    step "Adresse d ecoute de l API"
    warn "Adresse publique detectee — l API en clair n ecoutera que sur cette machine"
    echo "         Le HTTPS, lui, reste public : c est par la que passent les clients."
    echo "         Pour l ouvrir quand meme (banc, diagnostic) : --bind 0.0.0.0"
  fi
fi

# ---------------------------------------------------------------- 4ter. ports du TLS
# Detection de conflit AVANT d ecrire le compose : un port deja pris ferait echouer `compose up`
# apres le telechargement. On previent et on n en publie pas, plutot que d echouer — la Station
# marche tres bien sans TLS, c est le defaut.
port_occupe() {   # port_occupe <port> — vrai si quelque chose ECOUTE deja dessus sur l hote
  # `ss` (iproute2) sur la cible Linux ; `netstat -an` en repli, sous la forme PORTABLE — la forme
  # `-lnt` n existe pas sur BSD/macOS et echouait en silence, ce qui rendait la detection
  # inoperante la ou on l eprouve. On filtre sur LISTEN : `-an` liste aussi les connexions
  # sortantes, dont le port local produirait un faux positif.
  local p="$1"
  if command -v ss >/dev/null 2>&1; then
    ss -lntH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$"
  elif command -v netstat >/dev/null 2>&1; then
    netstat -an 2>/dev/null | grep -i 'LISTEN' | awk '{print $4}' | grep -qE "[:.]${p}\$"
  else
    return 1      # pas d outil : on ne bloque pas sur une inconnue
  fi
}
pub_api() {   # pub_api <bind> <ports...> — les lignes de publication du port en clair
  local bind="$1"; shift
  local p out=""
  for p in "$@"; do out+="$(printf '\n      - "%s%s:8000"' "${bind:+${bind}:}" "$p")"; done
  printf '%s' "$out"
}

port_bloque() {   # port_bloque <port> — occupe par AUTRE CHOSE que notre propre conteneur
  # ⚠️ L exception n est pas un confort : une RELANCE de l installateur est une mise a jour, et le
  # conteneur en service publie legitimement ces ports. Sans elle, la relance les declarait
  # occupes, les retirait du compose, et recreait la Station SANS HTTPS ni port de validation —
  # donc un client dont le certificat fonctionnait perdait son TLS pour avoir relance le script.
  [[ -n "$DEJA_LA" ]] && return 1
  port_occupe "$1"
}

# Notre conteneur tourne-t-il deja ? Calcule UNE fois, utilise par tous les ports.
DEJA_LA=""
docker ps --filter "name=^${CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q . && DEJA_LA="oui"

step "Ports de l API"
# Un port occupe ici ne se rattrape pas comme pour le TLS (ou l on se contente de ne pas publier) :
# le service ne serait pas joignable la ou on l annonce, et `docker compose up` echouerait sur un
# message que personne ne relie a ca. On le dit donc AVANT.
# ⚠️ Exception : notre PROPRE conteneur. Une relance de l installateur est une mise a jour, pas une
# faute — le sylex en service occupe legitimement ces ports et il va etre recree.
for _p in "${API_PORTS[@]}"; do
  if port_bloque "$_p"; then
    die "Port ${_p} deja utilise sur cette machine — en choisir un autre (--port) ou arreter le service qui l occupe."
  fi
  ok "Port ${_p} (API)${API_BIND:+ — sur ${API_BIND} uniquement}"
done
PORTS_API="$(pub_api "$API_BIND" "${API_PORTS[@]}")"

step "Ports HTTPS"
PORTS_TLS=""
if [[ "$ACME_PORT_HOST" != "0" ]]; then
  if port_bloque "$ACME_PORT_HOST"; then
    warn "Port ${ACME_PORT_HOST} deja utilise sur cette machine — non publie."
    warn "  Consequence : Let's Encrypt ne pourra pas valider (defi HTTP-01). Liberer le port et"
    warn "  relancer, ou choisir un autre port cote routeur : --acme-port <port>."
  else
    PORTS_TLS+="$(printf '\n      - "%s:80"' "$ACME_PORT_HOST")"
    ok "Port ${ACME_PORT_HOST} (validation Let's Encrypt)"
  fi
fi
if [[ "$TLS_PORT_HOST" != "0" ]]; then
  if port_bloque "$TLS_PORT_HOST"; then
    warn "Port ${TLS_PORT_HOST} deja utilise sur cette machine — non publie (HTTPS indisponible)."
  else
    PORTS_TLS+="$(printf '\n      - "%s:8443"' "$TLS_PORT_HOST")"
    ok "Port ${TLS_PORT_HOST} (HTTPS)"
  fi
fi
[[ -z "$PORTS_TLS" ]] && warn "Aucun port TLS publie : la Station servira en clair uniquement."

# ---------------------------------------------------------------- 5. service
step "Configuration du service"
ok "Console Agora : ${API_URL}"
# Variables OPTIONNELLES du conteneur. Fonction PURE (elle n ecrit que le fragment YAML) pour que
# le harnais l eprouve telle quelle. Rien n est emis quand rien n est demande : une Station
# ordinaire garde exactement le compose d avant, le defaut du produit reste le defaut.
env_optionnel() {  # env_optionnel <admin_token> <compat>
  local t="$1" c="$2" out=""
  if [[ -n "$t" ]]; then
    out+="$(printf '\n      # Jeton des routes /api/* : FIGE ici parce que celui que le superviseur genere au'
            printf '\n      # demarrage change a chaque bascule d image — un appelant exterieur ne peut pas le suivre.'
            printf '\n      ADMIN_TOKEN: "%s"' "$t")"
  fi
  [[ -n "$c" ]] && out+="$(printf '\n      COMPAT_DEFAULT: "%s"' "$c")"
  printf '%s' "$out"
}
ENV_OPT="$(env_optionnel "$ADMIN_TOKEN" "$COMPAT_DEFAULT")"
if [[ -n "$ADMIN_TOKEN" ]]; then ok "Jeton /api/* : pose (pilotage exterieur possible)"
else ok "Jeton /api/* : genere au demarrage (routes fermees)"; fi
[[ -n "$COMPAT_DEFAULT" ]] && ok "Profil de compatibilite : ${COMPAT_DEFAULT}"
UPDATE="no"
[[ -f "$INSTALL_DIR/docker-compose.yml" ]] && UPDATE="yes"

umask 077
cat > "$INSTALL_DIR/docker-compose.yml" <<EOF
# Généré par install-sylex.sh le $(date '+%d/%m/%Y %H:%M') — ne pas éditer à la main.
# Toute la configuration de SYLEX est pilotée par la console d'administration Agora.
services:
  sylex:
    image: ${IMAGE_REF}
    container_name: ${CONTAINER}
    restart: unless-stopped
    runtime: nvidia
    # Mémoire partagée de l'HÔTE : requise par vLLM dès que le moteur tourne sur plusieurs
    # cartes (tensor-parallel > 1 : l'exécuteur multiprocessus et NCCL passent par /dev/shm,
    # et Docker n'en donne que 64 Mio par défaut → le cœur du moteur meurt au boot). Sans effet
    # sur une Station à une carte (GB10). C'est le choix des confs de référence du dépôt vllm
    # (docker-compose.gpu3-preprod.yml), préféré à shm_size qu'il faudrait dimensionner.
    ipc: host
    environment:
      SYLEX_ACTIVATION_KEY: "${KEY}"
      AGORA_MODELS_USER: "${KEY_USER}"
      AGORA_MODELS_PASSWORD: "${KEY_SECRET}"
      # Canal de configuration Agora : c'est par lui que la console déploie le modèle.
      SYLEX_API_URL: "${API_URL}"
      # Cartes affectées à SYLEX. Sur un serveur partagé, en limiter la liste laisse le reste
      # de la machine disponible — et c'est aussi l'assiette de la licence en mode gpu-*.
      NVIDIA_VISIBLE_DEVICES: "${GPUS}"
      # Liaison licence↔machine, FIGÉE ici : jamais déduite à l'exécution (un repli changerait
      # l'identité et la Station serait refusée en 409 serial_mismatch).
      SYLEX_IDENTITY: "${IDENTITY}"${ENV_OPT}
    ports:${PORTS_API}
      - "127.0.0.1:${HEALTH_PORT}:8081"${PORTS_TLS}
    volumes:
      - ${DATA_DIR}:/data
      # Caches de démarrage — NE PAS RETIRER (sans eux, chaque démarrage recompile
      # ses composants et échoue faute de mémoire).
      - ${DATA_DIR}/.cache/flashinfer-121a:/root/.cache/flashinfer
      - ${DATA_DIR}/.cache/vllm-jit:/root/.cache/vllm
    healthcheck:
      test: ["CMD-SHELL", "python3 -c \\"import urllib.request; urllib.request.urlopen('http://localhost:8081/healthz', timeout=4)\\""]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
EOF
chmod 600 "$INSTALL_DIR/docker-compose.yml"
ok "$INSTALL_DIR/docker-compose.yml"

# ------------------------------------------------- unités de mise à jour : en PAUSE pendant l installation
# Le .path réveille sylex-update.sh dès que l agent écrit sa demande, le .timer toutes les 5 min.
# Vécu le 14/09 sur gpu3 : l installateur venait de poser 0.2.2, la fiche du catalogue disait 0.2.1,
# l agent a écrit sa demande, le .path a REMPLACÉ le conteneur pendant l attente de l installateur —
# qui a conclu « Le service s est arrêté » alors qu il venait d être remplacé. Les déclencheurs
# dorment donc du `compose up` au contrôle final, et sont réarmés EN DERNIER (trap EXIT : aussi
# sur un die en route — laisser une Station sans mises à jour serait pire que l échec lui-même).
maj_en_pause() {  # arrête les déclencheurs ; laisse finir une bascule déjà engagée (≤ 60 s)
  command -v systemctl >/dev/null 2>&1 || return 0
  systemctl stop sylex-update.path sylex-update.timer >/dev/null 2>&1 || true
  local i
  for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
    systemctl is-active --quiet sylex-update.service 2>/dev/null || return 0
    [[ $i -eq 1 ]] && echo "  … une bascule d image est en cours, on la laisse finir"
    sleep 5
  done
  return 0
}
maj_reprise() {   # réarme les déclencheurs (enable + start) ; 0 si systemd absent
  command -v systemctl >/dev/null 2>&1 || return 0
  systemctl enable --now sylex-update.path sylex-update.timer >/dev/null 2>&1
}
# Version que l agent demande à l hôte, si une demande attend : la fiche du catalogue impose une
# AUTRE version que celle qu on vient de poser. Fichier JSON plat (cf. jget de sylex-update.sh).
demande_en_attente() {  # demande_en_attente <fichier> → version, ou rien
  [[ -f "$1" ]] || return 0
  sed -n 's/.*"image"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1" 2>/dev/null | head -1
}

step "Démarrage"
cd "$INSTALL_DIR"
maj_en_pause
trap 'maj_reprise >/dev/null 2>&1 || true' EXIT
docker compose up -d || die "Le service n'a pas démarré (docker compose logs)."
[[ "$UPDATE" == "yes" ]] && ok "Service mis à jour" || ok "Service démarré"

# ---------------------------------------------------------------- 5bis. mises à jour automatiques
# Le script de bascule et ses unités systemd existent à DEUX endroits : embarqués ICI (section
# HOST-FILES ci-dessous, GÉNÉRÉE par tools/embed-host-files.sh — jamais éditée à la main) et dans
# l'image (/opt/agora/host/). Le plus RÉCENT gagne, d'après SYLEX_UPDATE_SCRIPT_VERSION :
#  - relancer cet installateur met l'hôte à jour SANS dépendre d'une version d'image (décision
#    Yann 30/08 : un correctif du service hôte ne doit pas exiger une nouvelle image sylex) ;
#  - une bascule d'image rafraîchit l'hôte si l'image porte plus récent (sylex-update.sh, étape 6).
# Une image antérieure au mécanisme n'a pas /opt/agora/host/ : la copie embarquée suffit.
# >>> HOST-FILES (généré par tools/embed-host-files.sh depuis sylex-update.sh + systemd/ — NE PAS ÉDITER ICI) >>>
write_embedded_host_files() {   # copies embarquées du script hôte et des unités → répertoire $1
  mkdir -p "$1"
  cat > "$1/sylex-update.sh" <<'__SYLEX_HOST_FILE__'
#!/usr/bin/env bash
# =============================================================================
# SYLEX — bascule d'image côté HÔTE
# Agora Software
#
# Déclenché par systemd, posé par install-sylex.sh : IMMÉDIATEMENT quand l'agent écrit ou
# modifie la demande (`sylex-update.path`, inotify), et toutes les 5 min (`sylex-update.timer`)
# pour les retentatives différées et les fenêtres, qu'inotify ne voit pas.
# Le conteneur ne peut pas se remplacer lui-même, et lui donner le socket Docker serait
# lui donner root sur la machine du client, exposé sur son LAN : c'est donc l'HÔTE qui
# fait le geste, à partir d'une DEMANDE que l'agent de la Station écrit sur le volume.
# Spec : backend/docs/sylex-image-update.md §4.2.
#
#   /data/update-request.json   { image, sha, requestedAt, now, serving, window?, windowStart?, windowEnd? } ← agent
#   /data/update-state.json     { applied, previous, at } | { ready, waitingFor, at } | { failed, … }   → agent
#
# SANS DÉPENDANCE (décision Yann 30/08) : bash + coreutils + docker + systemctl + curl (curl est déjà
# exigé par l'installateur). Ni python ni jq — sur la machine d'un client on ne présume de rien.
# D'où des fichiers d'échange en JSON PLAT (une clé = une valeur scalaire, jamais d'objet imbriqué) :
# l'agent les écrit ainsi, ce script les lit avec grep/sed. Un JSON imbriqué n'y serait pas lu.
#
# Ce que fait UNE exécution, dans l'ordre, et pourquoi :
#   1. rien à faire → sortie silencieuse (c'est le cas 99 % du temps) ;
#   2. TIRER l'image tout de suite (27 Go en fond, la Station continue de servir) ;
#   3. BASCULER TOUT DE SUITE (décision Yann 30/08 : un déploiement console prend effet
#      immédiatement, comme la conf moteur l'a toujours fait — on n'attend pas la nuit pour
#      ce qu'on vient de demander), SAUF si la demande porte une FENÊTRE de maintenance : la
#      console la fixe par Station, pour un client qui refuse les coupures en journée. `now`
#      (clic « Maintenant ») passe outre même la fenêtre ; une Station qui ne sert rien
#      bascule toujours sans attendre ;
#   4. attendre le MOTEUR — pas la passerelle : Éole répond en 15 s, le moteur en 4 à 15
#      min, et c'est lui qui prouve que l'image tient. Le premier boot après un changement
#      de vLLM peut recompiler ses caches JIT : c'est le moment le plus fragile ;
#   5. confirmé → état `applied` ; sinon → REVENIR à l'image précédente (encore sur le
#      disque : secondes, pas 27 Go), état `failed`.
#
# RETENTATIVES (décision Yann 30/08) — deux natures d'échec, deux règles :
#   • `kind: pull`  (image introuvable, registry injoignable) : ne coûte RIEN au client, aucune
#     coupure → on réessaie avec un délai CROISSANT (5 min, 10, 20 … plafonné à 6 h), sans
#     limite : le jour où l'image arrive au registry, la bascule suit toute seule.
#   • `kind: boot`  (moteur muet après bascule, retour arrière fait) : chaque tentative est UNE
#     COUPURE → on RÉESSAIE TOUT DE SUITE, mais 3 fois au total (BOOT_MAX_ATTEMPTS), après quoi
#     on attend une décision humaine (`rm update-state.json`, ou « Maintenant »). C'est le PLAFOND
#     qui protège d'une boucle de coupures, pas un délai : attendre n'apprend rien, et un parc
#     figé 20 h sur l'ancienne version (règle d'avant) est un état pire que trois essais rapides.
#   • ⚠️ UN SHA DIFFÉRENT EST UNE AUTRE DEMANDE, pas la répétition de l'échec : le compteur repart
#     de zéro et la bascule est immédiate. La version seule ne suffit PAS à identifier une
#     tentative — vécu le 16/09 sur spark01 : le moteur ne démarrait pas parce que la FICHE
#     pointait des poids retirés du registry ; la fiche corrigée exigeait la même image `0.2.5`,
#     donc le script a cru revoir l'échec et a refusé pendant 20 h une bascule qui aurait marché.
#     Même principe que la Station (`engine_failure` effacé par une conf DIFFÉRENTE) : une
#     nouvelle demande est une nouvelle chance.
#   • `now: true` (clic « Maintenant » dans la console) = un humain demande : il passe outre les
#     délais ET le plafond. Sans risque de boucle : le serveur efface `updateNow` au premier
#     `imageUpdate` remonté.
#
# ÉTATS écrits dans update-state.json (lus par l'agent → heartbeat `imageUpdate` → console) :
#   { ready, waitingFor, at }                                image tirée, bascule reportée à la fenêtre
#   { applied, previous, at }                                bascule confirmée
#   { failed, kind, sha, revertedTo, attempts, nextRetryAt, error, at }   échec — `ready` s'y ajoute si
#                                                            l'image est là mais la fenêtre pas encore
#
# Ce script vit à DEUX endroits : dans l'image (/opt/agora/host/) et dans l'installateur (section
# HOST-FILES, générée par tools/embed-host-files.sh — jamais éditée à la main). Le plus RÉCENT
# gagne, d'après SYLEX_UPDATE_SCRIPT_VERSION ci-dessous : relancer l'installateur met l'hôte à
# jour SANS nouvelle image (décision Yann 30/08 : pas de dépendance à une version d'Éole pour le
# service hôte), et une bascule d'image rafraîchit l'hôte si l'image porte plus récent (étape 6).
# ⚠️ INCRÉMENTER la version à chaque changement de ce fichier ou des unités, sinon la copie
# plus récente ne s'installe pas.
#
# Essais : SYLEX_UPDATE_DRYRUN=1 remplace docker/systemctl par des échos et simule le
# moteur (SYLEX_UPDATE_FAKE_ENGINE=ok|ko) ; SYLEX_UPDATE_HHMM force l'heure. Cf.
# tests/test-update-script.sh.
# =============================================================================
set -euo pipefail
SYLEX_UPDATE_SCRIPT_VERSION=9      # ← incrémenter à chaque changement (cf. en-tête) — v9 : retentative de boot immédiate, et le sha du bundle distingue deux demandes

CONF="${SYLEX_UPDATE_CONF:-/opt/sylex/update.conf}"
# shellcheck disable=SC1090
[[ -f "$CONF" ]] && source "$CONF"
IMAGE_REPO="${IMAGE_REPO:-registry.agora.tools/sylex}"
# Suffixe d'architecture, posé par l'installateur dans update.conf (`-amd64` sur x86_64, vide sur
# arm64). Une fiche du catalogue dit `0.1.10` — arch-neutre, c'est le contrat — et chaque machine
# tire l'image de la SIENNE. Sans ça, un parc mixte exigerait deux fiches par modèle.
IMAGE_SUFFIX="${IMAGE_SUFFIX:-}"
INSTALL_DIR="${INSTALL_DIR:-/opt/sylex}"
DATA_DIR="${DATA_DIR:-/data}"
CONTAINER="${CONTAINER:-sylex}"
API_PORT="${API_PORT:-8000}"
# Adresse d'écoute de l'API, telle que posée par l'installateur (`--bind`). Elle sert à la SONDE
# de fin de bascule : la viser en dur sur 127.0.0.1 ferait échouer une bascule réussie sur une
# Station dont l'API n'écoute que sur son adresse de LAN — et un échec de sonde vaut retour arrière.
API_BIND="${API_BIND:-}"
WINDOW_START="${WINDOW_START:-02:00}"      # heures par défaut SI la demande porte une fenêtre sans horaires
WINDOW_END="${WINDOW_END:-05:00}"          # (sans fenêtre dans la demande : bascule immédiate)
ENGINE_WAIT_S="${ENGINE_WAIT_S:-1200}"      # 20 min : un premier boot qui recompile ses caches
REVERT_WAIT_S="${REVERT_WAIT_S:-600}"
PULL_RETRY_BASE_S="${PULL_RETRY_BASE_S:-300}"    # 1re retentative de pull après 5 min, puis ×2…
PULL_RETRY_MAX_S="${PULL_RETRY_MAX_S:-21600}"    # … plafonné à 6 h
# 0 = retentative IMMÉDIATE après un échec de boot (décision Yann 16/09). Le garde-fou est le
# PLAFOND ci-dessous, pas l'attente. Reste réglable pour une Station qu'on voudrait plus prudente.
BOOT_RETRY_AFTER_S="${BOOT_RETRY_AFTER_S:-0}"
BOOT_MAX_ATTEMPTS="${BOOT_MAX_ATTEMPTS:-3}"
COMPOSE="$INSTALL_DIR/docker-compose.yml"
REQUEST="$DATA_DIR/update-request.json"
STATE="$DATA_DIR/update-state.json"
DRY="${SYLEX_UPDATE_DRYRUN:-0}"
# ⚠️ La simulation n'écho QUE docker/systemctl : les fichiers (état, demande, compose), elle les
# écrit et les supprime POUR DE VRAI. Lancée par mégarde sur les vrais chemins, elle a effacé la
# demande d'une Station et écrit un `applied` mensonger (30/08). Donc : en simulation, refus net
# hors d'un répertoire d'essai.
if [[ "$DRY" == "1" && ( "$DATA_DIR" == "/data" || "$INSTALL_DIR" == "/opt/sylex" ) ]]; then
  echo "[sylex-update] ⛔ simulation refusée sur les chemins réels (DATA_DIR=$DATA_DIR INSTALL_DIR=$INSTALL_DIR) : les fichiers seraient modifiés pour de vrai" >&2
  exit 2
fi

log() { echo "[sylex-update] $*"; }
now_s() { echo "${SYLEX_UPDATE_EPOCH:-$(date +%s)}"; }   # forçable pour les essais de retentative

# --- shims : en simulation, on ÉCHO au lieu d'agir --------------------------------------
# Sur STDERR : les appels réels sont souvent suivis d'un `>/dev/null` (progression de `pull`),
# qui avalerait l'écho et rendrait la simulation muette là où elle compte.
dk() {
  if [[ "$DRY" == "1" ]]; then
    echo "  (dry) docker $*" >&2
    [[ "$1" == "pull" && "${SYLEX_UPDATE_FAKE_PULL:-ok}" == "ko" ]] && return 1   # simule un registry muet
    return 0
  fi
  docker "$@"
}
sc() { if [[ "$DRY" == "1" ]]; then echo "  (dry) systemctl $*" >&2; else systemctl "$@"; fi; }

jget() {   # jget <fichier> <clé> → valeur SANS guillemets (chaîne, nombre, true/false), vide si absente.
  # JSON PLAT seulement (cf. en-tête). Fonctionne sur une ligne comme sur un fichier indenté.
  # ⚠️ Rend TOUJOURS 0 : une clé absente est un cas normal (fenêtre optionnelle…), et sous
  # `set -e -o pipefail` un grep sans résultat tuerait le script en silence à la première clé
  # optionnelle — c'est exactement ce qui est arrivé au premier essai (27 vérifications rouges).
  local v=""
  if [[ -f "$1" ]]; then
    v="$(tr -d '\n\r' < "$1" \
      | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[^,}[:space:]]+)" | head -1 \
      | sed -E 's/^"[^"]*"[[:space:]]*:[[:space:]]*//; s/^"(.*)"$/\1/')" || true
  fi
  printf '%s\n' "$v"
  return 0
}
jsafe() { printf '%s' "$1" | tr -d '"\\' | tr '\n\r\t' '   ' | cut -c1-400; }   # valeur sûre entre guillemets

write_state() {   # write_state <json plat> — écriture atomique
  local tmp="$STATE.tmp"
  printf '%s\n' "$1" > "$tmp" && mv -f "$tmp" "$STATE"
  [[ "$DRY" == "1" ]] && printf '%s\n' "$1" >> "$STATE.trace" || true   # essais : la suite des états
}
keep_failed() {   # keep_failed <version> → les champs d'échec de CETTE version à reporter (préfixe JSON), ou rien
  # Un état intermédiaire (phase, ready) ne doit JAMAIS effacer les compteurs d'échec de la version
  # en cours : sinon chaque nouvelle tentative repart de zéro et le plafond n'est jamais atteint
  # (12 vérifications rouges au premier essai — `attempts` restait à 1).
  # ⚠️ `sha` EN FAIT PARTIE (ajouté en v9) : sans lui, write_phase le perd, write_failed ne
  # reconnaît plus la demande et remet le compteur à 1 — exactement le même symptôme, retrouvé
  # à l'identique le 16/09 en écrivant la v9. Toute clé ajoutée à l'échec doit venir ici aussi.
  [[ "$(jget "$STATE" failed)" == "$1" ]] || return 0
  printf '"failed": "%s", "kind": "%s", "sha": "%s", "revertedTo": "%s", "attempts": %s, "nextRetryAt": %s, "error": "%s", ' \
    "$1" "$(jget "$STATE" kind)" "$(jsafe "$(jget "$STATE" sha)")" "$(jget "$STATE" revertedTo)" \
    "$(jget "$STATE" attempts | grep -E '^[0-9]+$' || echo 1)" \
    "$(jget "$STATE" nextRetryAt | grep -E '^[0-9]+$' || echo 0)" \
    "$(jsafe "$(jget "$STATE" error)")"
}
write_phase() {   # write_phase <image.pull|image.switch|image.wait> <image> — progression côté hôte
  # Lue par l'agent (heartbeat `progress`) tant que le conteneur n'est pas remplacé : c'est la
  # seule fenêtre où LUI ne peut rien observer. Remplacée ensuite par ready/applied/failed.
  write_state "{$(keep_failed "$2")\"phase\": \"$1\", \"image\": \"$2\", \"at\": $(now_s)}"
}

# Chemins du ménage ci-dessous, surchargeables UNIQUEMENT pour les harnais (qui ne doivent jamais
# toucher au vrai /dev/shm ni lire le vrai /proc). En service : les valeurs par défaut, toujours.
SHM_DIR="${SYLEX_SHM_DIR:-/dev/shm}"
PROC_MAPS_GLOB="${SYLEX_PROC_MAPS_GLOB:-/proc/*/maps}"

clean_offload_shm() {
  # Retire les mmap d'offload KV laissés dans le /dev/shm de l'HÔTE par un conteneur arrêté.
  # vLLM ne les supprime jamais à l'arrêt normal (`os.unlink` seulement dans son chemin d'erreur,
  # shared_offload_region.py), et `ipc: host` — requis en tensor-parallel — en fait des fichiers de
  # l'hôte : ils survivent au conteneur jusqu'au prochain reboot. Taille = le budget
  # `kv-offloading-size`, donc des Go (mesuré 09/09 sur gpu3 : 25,8 Go, RAM disponible 48 → 27 Go,
  # et plus aucun processus ne le mappait). Cf. NOTE-SYLEX-SHM-CLEANUP.md.
  # ⚠️ Motif STRICT : /dev/shm porte aussi les files de messages inter-workers de vLLM (psm_*) —
  # les toucher tue un moteur vivant. Et GARDE /proc/*/maps : un fichier encore mappé par un
  # processus (moteur en service, ou la préprod voisine sur gpu3) est CONSERVÉ. Sur l'hôte /proc
  # est complet, c'est ce qui rend la garde fiable — et ce qui interdit de faire ce ménage depuis
  # l'intérieur du conteneur, qui ne voit pas les processus des autres.
  local f nom n=0
  for f in "$SHM_DIR"/vllm_offload_*.mmap; do
    [[ -e "$f" ]] || continue
    nom="${f##*/}"
    if grep -l -- "$nom" $PROC_MAPS_GLOB >/dev/null 2>&1; then
      log "mmap d'offload ${nom} encore mappé par un processus — conservé"
      continue
    fi
    if [[ "$DRY" == "1" ]]; then
      log "  (dry) rm $f ($(du -h -- "$f" 2>/dev/null | cut -f1))"
    else
      rm -f -- "$f" && n=$((n+1))
    fi
  done
  (( n > 0 )) && log "🧹 ${n} mmap d'offload orphelin(s) retiré(s) de ${SHM_DIR}"
  return 0
}

write_failed() {   # write_failed <kind pull|boot> <target> <current> <erreur>
  # `attempts` compte les échecs de CETTE DEMANDE — version ET sha. Le sha seul distingue deux
  # déploiements qui exigent la même image : sans lui, une fiche CORRIGÉE passe pour la répétition
  # de l'échec qu'elle répare (vécu le 16/09, cf. en-tête). `nextRetryAt` dit quand on a le droit
  # de réessayer. La console lit tout ça tel quel (heartbeat `imageUpdate`).
  local kind="$1" target="$2" current="$3" err="$4" attempts=1 next
  local req_sha; req_sha="$(jget "$REQUEST" sha)"
  if [[ "$(jget "$STATE" failed)" == "$target" && "$(jget "$STATE" sha)" == "$req_sha" ]]; then
    attempts=$(( $(jget "$STATE" attempts | grep -E '^[0-9]+$' || echo 0) + 1 ))
  fi
  if [[ "$kind" == "pull" ]]; then
    local delay=$(( PULL_RETRY_BASE_S * (1 << (attempts - 1 > 12 ? 12 : attempts - 1)) ))
    (( delay > PULL_RETRY_MAX_S )) && delay=$PULL_RETRY_MAX_S
    next=$(( $(now_s) + delay ))
  else
    next=$(( $(now_s) + BOOT_RETRY_AFTER_S ))
  fi
  # Un échec efface un éventuel `ready` : l'image n'est plus « prête à basculer ».
  write_state "{\"failed\": \"${target}\", \"kind\": \"${kind}\", \"sha\": \"$(jsafe "$req_sha")\", \"revertedTo\": \"${current}\", \"attempts\": ${attempts}, \"nextRetryAt\": ${next}, \"error\": \"$(jsafe "$err")\", \"at\": $(now_s)}"
  if [[ "$kind" == "boot" && "$attempts" -ge "$BOOT_MAX_ATTEMPTS" ]]; then
    log "⛔ ${target} : ${attempts} échecs de démarrage — plus de tentative automatique (décision humaine : rm ${STATE}, ou « Maintenant »)"
  else
    log "↻ ${target} : tentative ${attempts} échouée (${kind}) — prochaine au plus tôt $(date -d "@${next}" '+%d/%m %H:%M' 2>/dev/null || date -r "${next}" '+%d/%m %H:%M')"
  fi
}

current_tag() {   # version NUE (suffixe d'architecture retiré) — comparable à celle de la fiche
  local t
  t="$(sed -n "s#^[[:space:]]*image:[[:space:]]*${IMAGE_REPO}:\([^[:space:]]*\).*#\1#p" "$COMPOSE" | head -1)"
  [[ -n "$IMAGE_SUFFIX" ]] && t="${t%$IMAGE_SUFFIX}"
  printf '%s' "$t"
}

set_tag() {   # set_tag <version nue> — réécrit la ligne image: du compose (seul endroit qui la porte)
  sed -i.bak "s#^\([[:space:]]*image:[[:space:]]*\)${IMAGE_REPO}:[^[:space:]]*#\1${IMAGE_REPO}:$1${IMAGE_SUFFIX}#" "$COMPOSE"
  rm -f "$COMPOSE.bak"
}

in_window() {   # in_window <HH:MM> <HH:MM> — gère une fenêtre qui passe minuit (22:00-05:00)
  local start="${1//:/}" end="${2//:/}" now="${SYLEX_UPDATE_HHMM:-$(date +%H%M)}"
  start=$((10#$start)); end=$((10#$end)); now=$((10#$now))
  if (( start <= end )); then (( now >= start && now < end ))
  else (( now >= start || now < end )); fi
}

api_host() {   # hôte à viser pour sonder l'API locale, déduit du bind
  local h="${API_BIND:-}"
  case "$h" in ''|0.0.0.0|'::'|'[::]') h='127.0.0.1' ;; esac
  case "$h" in
    \[*\]) ;;                # déjà entre crochets
    *:*)   h="[$h]" ;;        # IPv6 littérale : une URL l'exige entre crochets
  esac
  printf '%s' "$h"
}

engine_up() {
  # /api/version d'Éole SONDE le moteur : 200 = il répond, 503 = pas encore. Non gaté.
  if [[ "$DRY" == "1" ]]; then [[ "${SYLEX_UPDATE_FAKE_ENGINE:-ok}" == "ok" ]]; return; fi
  curl -fsS --max-time 5 "http://$(api_host):${API_PORT}/api/version" >/dev/null 2>&1
}

wait_engine() {   # wait_engine <secondes>
  if [[ "$DRY" == "1" ]]; then engine_up; return; fi     # simulation : verdict immédiat (now_s peut être figé)
  local deadline=$(( $(now_s) + $1 ))
  while (( $(now_s) < deadline )); do
    engine_up && return 0
    # Le conteneur est-il au moins vivant ? Un conteneur mort ne reviendra pas : inutile d'attendre.
    if [[ "$DRY" != "1" ]] && ! docker ps --filter "name=^${CONTAINER}$" --format '{{.Names}}' | grep -q .; then
      sleep 15   # laisse la restart policy une chance…
      docker ps --filter "name=^${CONTAINER}$" --format '{{.Names}}' | grep -q . || return 1
    fi
    sleep "${SYLEX_UPDATE_POLL_S:-15}"
  done
  return 1
}

script_version() {   # script_version <fichier> → SYLEX_UPDATE_SCRIPT_VERSION, ou 0
  local v; v="$(sed -n 's/^SYLEX_UPDATE_SCRIPT_VERSION=\([0-9][0-9]*\).*/\1/p' "$1" 2>/dev/null | head -1)"
  echo "${v:-0}"
}

refresh_host_files() {   # après une bascule réussie : prendre les fichiers hôte de la NOUVELLE image s'ils sont PLUS RÉCENTS
  [[ "$DRY" == "1" ]] && { echo "  (dry) refresh_host_files" >&2; return 0; }
  local cid
  cid="$(docker create "${IMAGE_REPO}:$1${IMAGE_SUFFIX}" 2>/dev/null)" || return 0
  local tmp; tmp="$(mktemp -d)"
  if docker cp "$cid:/opt/agora/host/." "$tmp/" 2>/dev/null; then
    local new cur
    new="$(script_version "$tmp/sylex-update.sh")"; cur="$(script_version "$INSTALL_DIR/sylex-update.sh")"
    if (( new > cur )); then
      install -m 755 "$tmp/sylex-update.sh" "$INSTALL_DIR/sylex-update.sh" 2>/dev/null || true
      if [[ -d /etc/systemd/system ]]; then
        install -m 644 "$tmp/sylex-update.service" "$tmp/sylex-update.timer" /etc/systemd/system/ 2>/dev/null || true
        [[ -f "$tmp/sylex-update.path" ]] && install -m 644 "$tmp/sylex-update.path" /etc/systemd/system/ 2>/dev/null || true
        systemctl daemon-reload 2>/dev/null || true
      fi
      log "fichiers hôte rafraîchis depuis l'image (version ${cur} → ${new})"
    else
      log "fichiers hôte conservés (version ${cur}, image : ${new})"
    fi
  fi
  docker rm "$cid" >/dev/null 2>&1 || true
  rm -rf "$tmp"
}

# ============================================================================ 1. rien à faire ?
[[ -f "$REQUEST" ]] || exit 0
[[ -f "$COMPOSE" ]] || { log "⚠️  $COMPOSE absent — Station non installée par install-sylex.sh ?"; exit 0; }

TARGET="$(jget "$REQUEST" image)"
[[ "$TARGET" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { log "⚠️  demande illisible ou version non semver : '$TARGET' — ignorée"; exit 0; }
CURRENT="$(current_tag)"
[[ -n "$CURRENT" ]] || { log "⚠️  version courante introuvable dans $COMPOSE — ignoré"; exit 0; }

if [[ "$TARGET" == "$CURRENT" ]]; then
  # Déjà basculé (l'agent efface la demande quand son bundle s'applique ; si elle traîne, on
  # la lève ici — il la réécrira de lui-même si un « maintenant » arrive entre-temps).
  rm -f "$REQUEST"
  exit 0
fi

# Une version qui a ÉCHOUÉ : retentative selon la nature de l'échec (cf. en-tête). `now: true`
# (un humain demande) passe outre délais et plafond — sans boucle possible : le serveur efface
# l'ordre au premier `imageUpdate` remonté.
NOW="$(jget "$REQUEST" now)"
# ⚠️ La version NE SUFFIT PAS à reconnaître « le même échec » : deux déploiements différents
# exigent souvent la même image. On compare donc AUSSI le sha du bundle — un sha différent est une
# autre demande, donc une nouvelle chance, immédiate et compteur à zéro (cf. en-tête, 16/09).
if [[ "$(jget "$STATE" failed)" == "$TARGET" && "$(jget "$STATE" sha)" == "$(jget "$REQUEST" sha)" && "$NOW" != "true" ]]; then
  KIND="$(jget "$STATE" kind)"; ATTEMPTS="$(jget "$STATE" attempts | grep -E '^[0-9]+$' || echo 1)"
  NEXT="$(jget "$STATE" nextRetryAt | grep -E '^[0-9]+$' || echo 0)"
  if [[ "$KIND" == "boot" && "$ATTEMPTS" -ge "$BOOT_MAX_ATTEMPTS" ]]; then
    exit 0      # plafond atteint : on attend un humain (silence — déjà journalisé à l'échec)
  fi
  if (( $(now_s) < NEXT )); then
    exit 0      # pas encore l'heure de réessayer
  fi
  log "↻ ${TARGET} : nouvelle tentative (échec précédent : ${KIND:-?}, ${ATTEMPTS} au total)"
fi

# ============================================================================ 2. tirer d'abord
if [[ "$DRY" == "1" ]] || ! docker image inspect "${IMAGE_REPO}:${TARGET}${IMAGE_SUFFIX}" >/dev/null 2>&1; then
  log "image ${TARGET} demandée (courante ${CURRENT}) — téléchargement"
  write_phase image.pull "$TARGET"
  if ! dk pull "${IMAGE_REPO}:${TARGET}${IMAGE_SUFFIX}" >/dev/null; then
    log "❌ téléchargement de ${IMAGE_REPO}:${TARGET}${IMAGE_SUFFIX} impossible (aucune bascule tentée)"
    write_failed pull "$TARGET" "$CURRENT" "pull failed — no switch attempted"
    exit 0
  fi
fi

# ============================================================================ 3. basculer ?
SERVING="$(jget "$REQUEST" serving)"
# La console a fixé une fenêtre pour cette Station : `window: true` (posé par l'agent), avec ou sans
# horaires (`windowStart`/`windowEnd`, sinon ceux de update.conf).
HAS_WINDOW="false"; [[ "$(jget "$REQUEST" window)" == "true" || -n "$(jget "$REQUEST" windowStart)" ]] && HAS_WINDOW="true"
WSTART="$(jget "$REQUEST" windowStart)"; WSTART="${WSTART:-$WINDOW_START}"
WEND="$(jget "$REQUEST" windowEnd)";     WEND="${WEND:-$WINDOW_END}"
REASON=""
if [[ "$NOW" == "true" ]]; then REASON="demandé maintenant par la console"
elif [[ "$SERVING" != "true" ]]; then REASON="aucun moteur ne sert : rien à interrompre"
elif [[ "$HAS_WINDOW" != "true" ]]; then REASON="déploiement console : effet immédiat (aucune fenêtre de maintenance fixée)"
elif in_window "$WSTART" "$WEND"; then REASON="fenêtre de maintenance ${WSTART}-${WEND}"
fi
if [[ -z "$REASON" ]]; then
  # Visible de la console (l'agent remonte cet état) : sans lui, « en attente de la nuit » et
  # « bloqué » se ressemblaient. Fusion : un échec précédent de CETTE version (kind/attempts)
  # est conservé ; celui d'une autre version est effacé.
  write_state "{$(keep_failed "$TARGET")\"ready\": \"${TARGET}\", \"waitingFor\": \"window ${WSTART}-${WEND}\", \"at\": $(now_s)}"
  log "image ${TARGET} prête ; bascule reportée à la fenêtre ${WSTART}-${WEND} (la Station sert)"
  exit 0
fi

# ============================================================================ 4. bascule
log "bascule ${CURRENT} → ${TARGET} (${REASON})"
write_phase image.switch "$TARGET"
set_tag "$TARGET"
( cd "$INSTALL_DIR" && dk compose up -d ) || {
  log "❌ docker compose up a échoué — retour à ${CURRENT}"
  set_tag "$CURRENT"; ( cd "$INSTALL_DIR" && dk compose up -d ) || true
  clean_offload_shm
  write_failed boot "$TARGET" "$CURRENT" "compose up failed"
  exit 0
}
clean_offload_shm

# ============================================================================ 5. confirmation
write_phase image.wait "$TARGET"
T0=$(now_s)
if wait_engine "$ENGINE_WAIT_S"; then
  log "✅ ${TARGET} confirmée : moteur en service après $(( $(now_s) - T0 )) s"
  write_state "{\"applied\": \"${TARGET}\", \"previous\": \"${CURRENT}\", \"at\": $(now_s)}"
  rm -f "$REQUEST"
  refresh_host_files "$TARGET"    # 6. le script et les unités de la nouvelle image
  exit 0
fi

log "❌ ${TARGET} : le moteur ne répond pas après ${ENGINE_WAIT_S} s — retour à ${CURRENT}"
LOGTAIL="$([[ "$DRY" == "1" ]] && echo "(dry)" || docker logs "$CONTAINER" --tail 5 2>&1 | tr '"\n' "' " | cut -c1-300)"
set_tag "$CURRENT"
( cd "$INSTALL_DIR" && dk compose up -d ) || true
clean_offload_shm
if wait_engine "$REVERT_WAIT_S"; then
  log "retour à ${CURRENT} confirmé"
else
  log "⚠️  ${CURRENT} ne répond pas non plus — intervention requise"
fi
write_failed boot "$TARGET" "$CURRENT" "engine not up after ${ENGINE_WAIT_S}s: ${LOGTAIL}"
exit 0
__SYLEX_HOST_FILE__
  cat > "$1/sylex-update.service" <<'__SYLEX_HOST_FILE__'
# SYLEX — bascule d'image côté hôte (déclenchée par sylex-update.timer).
# Posé par install-sylex.sh depuis /opt/agora/host/ de l'image ; rafraîchi à chaque bascule.
[Unit]
Description=SYLEX — mise à jour de l'image de la Station
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
ExecStart=/opt/sylex/sylex-update.sh
# Une bascule attend le MOTEUR (jusqu'à 20 min) puis, en cas d'échec, le retour arrière (10 min).
TimeoutStartSec=2400
# systemd ne relance pas une unité oneshot encore active : deux ticks du timer ne se chevauchent pas.
__SYLEX_HOST_FILE__
  cat > "$1/sylex-update.timer" <<'__SYLEX_HOST_FILE__'
# SYLEX — toutes les 5 min : lit /data/update-request.json, ne fait rien s'il n'y a rien.
[Unit]
Description=SYLEX — vérification périodique des mises à jour d'image

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
RandomizedDelaySec=30
AccuracySec=1min

[Install]
WantedBy=timers.target
__SYLEX_HOST_FILE__
  cat > "$1/sylex-update.path" <<'__SYLEX_HOST_FILE__'
# SYLEX — réveil IMMÉDIAT du service de mise à jour quand l'agent écrit ou modifie la demande.
# inotify sur /data/update-request.json : l'agent l'écrit par renommage atomique (tmp → replace),
# ce que PathChanged= capte (IN_MOVED_TO / IN_CLOSE_WRITE). Pas de PathExists= : il redéclencherait
# en boucle tant que le fichier existe (demande reportée à une fenêtre, ou en attente de retentative).
# Le timer (5 min) reste pour ce qu'inotify ne voit pas : retentatives différées, fenêtres.
[Unit]
Description=SYLEX — déclenchement immédiat sur demande de mise à jour

[Path]
PathChanged=/data/update-request.json
Unit=sylex-update.service

[Install]
WantedBy=paths.target
__SYLEX_HOST_FILE__
}
# <<< HOST-FILES <<<
script_version() { local v; v="$(sed -n 's/^SYLEX_UPDATE_SCRIPT_VERSION=\([0-9][0-9]*\).*/\1/p' "$1" 2>/dev/null | head -1)"; echo "${v:-0}"; }
step "Mises à jour automatiques"
umask 022
cat > "$INSTALL_DIR/update.conf" <<EOF
# Généré par install-sylex.sh — lu par sylex-update.sh (bascule d'image côté hôte).
IMAGE_REPO=${IMAGE_REPO}
# Suffixe d'architecture : une fiche catalogue dit 0.1.10, cette machine tire 0.1.10${IMAGE_SUFFIX}.
IMAGE_SUFFIX=${IMAGE_SUFFIX}
INSTALL_DIR=${INSTALL_DIR}
DATA_DIR=${DATA_DIR}
CONTAINER=${CONTAINER}
API_PORT=${API_PORT}
# Adresse d'écoute de l'API : le script hôte SONDE Éole pour valider une bascule d'image, et sa
# sonde doit viser l'adresse où l'API écoute vraiment. Vide = toutes les interfaces.
API_BIND=${API_BIND}
# Heures utilisées SEULEMENT si la console fixe une fenêtre de maintenance sans horaires pour cette
# Station. Sans fenêtre, un déploiement console prend effet immédiatement (décision 30/08).
WINDOW_START=02:00
WINDOW_END=05:00
EOF
HOSTTMP="$(mktemp -d)"
write_embedded_host_files "$HOSTTMP/embedded"
# ⚠️ $IMAGE_REF, PAS "$IMAGE_REPO:$VERSION" : sans le suffixe d'architecture, une machine amd64
# désignait l'image ARM — absente localement, donc `docker create` la TÉLÉCHARGEAIT (27 Go), en
# silence à cause du 2>/dev/null. L'installation restait bloquée sans un mot. Et on ne crée que si
# l'image est déjà là : elle vient d'être tirée plus haut, et à défaut l'embarqué suffit — ce
# chemin ne doit JAMAIS déclencher un téléchargement.
CID=""
if docker image inspect "$IMAGE_REF" >/dev/null 2>&1; then
  CID="$(docker create "$IMAGE_REF" 2>/dev/null || true)"
fi
[[ -n "$CID" ]] && docker cp "$CID:/opt/agora/host/." "$HOSTTMP/image/" 2>/dev/null || true
[[ -n "$CID" ]] && docker rm "$CID" >/dev/null 2>&1 || true
# Trois candidats : l'embarqué, celui de l'image, celui déjà installé. Le plus récent gagne — et
# à égalité on ne réécrit pas (un `install` inutile change le mtime pour rien).
V_EMB="$(script_version "$HOSTTMP/embedded/sylex-update.sh")"
V_IMG="$(script_version "$HOSTTMP/image/sylex-update.sh")"
V_CUR="$(script_version "$INSTALL_DIR/sylex-update.sh")"
SRC="$HOSTTMP/embedded"; V_NEW="$V_EMB"; ORIG="installateur"
if (( V_IMG > V_EMB )); then SRC="$HOSTTMP/image"; V_NEW="$V_IMG"; ORIG="image $VERSION"; fi
if (( V_NEW > V_CUR )); then
  install -m 755 "$SRC/sylex-update.sh" "$INSTALL_DIR/sylex-update.sh"
  ok "Script de mise à jour v${V_NEW} (source : ${ORIG}$( (( V_CUR > 0 )) && echo ", remplace v${V_CUR}"))"
else
  ok "Script de mise à jour v${V_CUR} déjà à jour"
  SRC=""
fi
if command -v systemctl >/dev/null && [[ -d /etc/systemd/system ]]; then
  if [[ -n "$SRC" || ! -f /etc/systemd/system/sylex-update.path ]]; then
    U="${SRC:-$HOSTTMP/embedded}"
    install -m 644 "$U/sylex-update.service" "$U/sylex-update.timer" "$U/sylex-update.path" /etc/systemd/system/
    systemctl daemon-reload
  fi
  # .path = réveil IMMÉDIAT quand l'agent écrit la demande ; .timer = filet (retentatives, fenêtres).
  # `enable` seul : les déclencheurs restent en pause jusqu au contrôle final (cf. maj_en_pause).
  systemctl enable sylex-update.path sylex-update.timer >/dev/null 2>&1 \
    && ok "Service de mise à jour installé (déclencheurs réarmés au terme de l installation)" \
    || warn "Unités systemd non activées (systemctl enable --now sylex-update.path sylex-update.timer)"
else
  warn "systemd absent : les mises à jour resteront manuelles (relancer ce script avec --version)"
fi
rm -rf "$HOSTTMP"

if [[ "$WAIT" == "no" ]]; then
  echo; echo "Démarrage en arrière-plan. Suivi : docker logs -f ${CONTAINER}"
  echo "Il restera à choisir le modèle dans la console Agora."
  exit 0
fi

# ---------------------------------------------------------------- 6. attente
# ⚠️ Il n'y a plus de modèle à charger ici : l'image n'en embarque aucun. On attend donc
# que la PASSERELLE réponde (quelques secondes), pas un moteur (10-15 min). Le
# téléchargement des poids commencera au déploiement depuis la console.
step "Démarrage du service"
START=$(date +%s)
while true; do
  if curl -fsS --max-time 5 "http://localhost:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
    ok "Service en ligne après $(( $(date +%s) - START )) s"
    break
  fi
  if ! docker ps --filter "name=^${CONTAINER}$" --format '{{.Names}}' | grep -q .; then
    # Filet : un remplacement par le service de mise à jour ouvre quelques secondes sans conteneur,
    # puis il revient sous une AUTRE image. Ce n est pas une panne, et il faut le dire ainsi. Les
    # déclencheurs sont en pause, donc ce cas ne devrait plus se produire — on le nomme quand même.
    sleep 5
    IMG_COURANTE="$(docker ps --filter "name=^${CONTAINER}$" --format '{{.Image}}' | head -1)"
    if [[ -n "$IMG_COURANTE" && "$IMG_COURANTE" != "$IMAGE_REF" ]]; then
      die "Conteneur remplacé par le service de mise à jour (${IMG_COURANTE}) : la fiche du modèle déployé exige une autre version que ${VERSION}. Ce n'est pas une panne — pour garder ${VERSION}, mettre la fiche du catalogue à jour."
    fi
    [[ -n "$IMG_COURANTE" ]] && continue
    die "Le service s'est arrêté. Diagnostic : docker logs ${CONTAINER} | tail -50"
  fi
  printf '.'
  sleep 3
  if (( $(date +%s) - START > 180 )); then
    echo; die "La passerelle ne répond pas après 3 min. Diagnostic : docker logs ${CONTAINER} | tail -50"
  fi
done

# ---------------------------------------------------------------- 7. contrôle
step "Contrôle final"
curl -fsS --max-time 5 "http://localhost:${HEALTH_PORT}/healthz" >/dev/null && ok "Service en bonne santé"
# ⚠️ Aucun test d'inférence ici : sans modèle déployé, l'API répond 502 — c'est l'état
# ATTENDU d'une Station neuve, pas une panne. Le test d'inférence appartient à la console,
# une fois le modèle choisi et chargé.
CODE="$(curl -s -o /tmp/sylex-models.$$ -w '%{http_code}' --max-time 5 "http://localhost:${API_PORT}/v1/models" || echo 000)"
if [[ "$CODE" == "401" ]]; then
  ok "API protégée par clé (clés publiées depuis la console)"
  rm -f /tmp/sylex-models.$$
else
  MODELS="$(tr ',' '\n' < /tmp/sylex-models.$$ | grep -o '"id":"[^"]*"' | cut -d'"' -f4 | tr '\n' ' ')"
  rm -f /tmp/sylex-models.$$
  ok "Passerelle opérationnelle (${MODELS})"
fi
curl -fsS --max-time 5 "http://localhost:${API_PORT}/metrics" >/dev/null 2>&1 \
  && warn "Route interne joignable (à signaler à Agora)" \
  || ok "Moteur d'inférence non exposé"

IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo
echo "${B}${G}SYLEX est installé.${N}"
echo
echo "  ${B}Il reste une étape, dans la console Agora :${N} choisir le modèle."
echo "  Cette Station y apparaît sous le numéro de série ci-dessous. Le téléchargement des"
echo "  poids (25 à 85 Go) démarre au déploiement et prend de 15 min à 1 h ; l'API répond"
echo "  502 jusque-là."
echo
if [[ -n "$API_BIND" ]]; then
  # Port en clair referme sur une adresse : annoncer l'IP de la machine serait faux — c'est
  # exactement le cas d'une Station a IP publique, ou seul le HTTPS doit sortir.
  echo "  Adresse de l'API   http://${API_BIND}:${API_PORT}/v1 (en clair, sur cette adresse UNIQUEMENT)"
else
  echo "  Adresse de l'API   http://${IP:-<ip-de-la-station>}:${API_PORT}/v1"
fi
# Les ports SUPPLÉMENTAIRES d'une reprise (`--port 11434,11435`) : le même Éole, d'autres points
# d'entrée. Les taire laisserait croire qu'ils n'ont pas été pris en compte.
[[ ${#API_PORTS[@]} -gt 1 ]] && echo "  Autres ports       ${API_PORTS[*]:1} (même service)"
echo "  Modèle             sylex — tout reste sur la Station"
echo "  Numéro de série    ${SERIAL}"
echo "  Identité licence   ${IDENT_VALUE} (${IDENTITY})"
echo

# ------------------------------------------------- réarmement des mises à jour, EN DERNIER
# Si l agent a déjà écrit une demande, la fiche du catalogue impose une autre version que celle
# qu on vient de poser : la bascule qui va suivre doit être annoncée, sinon elle passe pour une panne.
DEMANDE="$(demande_en_attente "$DATA_DIR/update-request.json")"
if [[ -n "$DEMANDE" && "$DEMANDE" != "$VERSION" ]]; then
  warn "La Station demande la version ${DEMANDE} : c est celle qu exige la fiche du modèle déployé dans la console."
  warn "  Le service de mise à jour va REMPLACER ${VERSION} par ${DEMANDE} dans les minutes qui viennent."
  warn "  Pour garder ${VERSION}, mettre la fiche du catalogue à ${VERSION} — c est elle qui fixe la version exacte."
fi
maj_reprise && ok "Service de mise à jour réarmé : déclenchement immédiat sur demande (+ vérification toutes les 5 min)" \
  || warn "Déclencheurs non réarmés (systemctl enable --now sylex-update.path sylex-update.timer)"
