deploy/vektor-deploy.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# vektor-deploy.sh — déploiement pull-based de Vektor depuis GitHub.
#
# MODÈLE (aucun secret chez GitHub, aucune clé SSH privée dans les
# secrets du dépôt) :
# - Le VPS tire HTTPS (public) : github -> VPS, jamais l'inverse.
# - Ne déploie qu'un tag v* dont la CI GitHub est verte (API publique,
# sans authentification).
# - Jamais touché : /opt/vektor/.env (secrets), secrets/, knowledge-live/
# (contenu live remplacé au besoin depuis le repo knowledge/).
# - Rétention : les KEEP_RELEASES dernières releases sont conservées
# dans /opt/vektor-releases/<tag>, les plus anciennes purgées après
# chaque déploiement réussi (jamais la release courante, plan de
# rollback — voir DEPLOIEMENT.md).
#
# Installation (VPS) :
# install -m 0755 deploy/vektor-deploy.sh /usr/local/bin/vektor-deploy
# cp deploy/vektor-deploy.timer deploy/vektor-deploy.service /etc/systemd/system/
# systemctl daemon-reload && systemctl enable --now vektor-deploy.timer
#
# Manuel : vektor-deploy [--force] (--force : déploie HEAD même sans tag)
set -euo pipefail
REPO_SLUG="Tom1419-git/vektor"
DEFAULT_BRANCH="main"
DEPLOY_DIR="/opt/vektor"
RELEASES_DIR="/opt/vektor-releases"
COMPOSE_FILE="compose.yml"
API_CONTAINER="vektor-api"
LOCK_FILE="/run/vektor-deploy.lock"
HEALTH_TIMEOUT=90
KEEP_RELEASES=3
log() { echo "[vektor-deploy] $(date '+%F %T') $*"; }
# L'API ne publie aucun port vers l'hôte (Caddy passe par le réseau
# Docker) : le health check s'exécute DANS le conteneur, en loopback.
health_ok() {
docker exec "$API_CONTAINER" python - <<'PY' >/dev/null 2>&1
import urllib.request
urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=5)
PY
}
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "another deploy is running, exiting"
exit 0
fi
force=0
[[ "${1:-}" == "--force" ]] && force=1
# Version courante (pointeur écrit après health OK) : sert au skip des
# tours inutiles ET à épargner la release de rollback pendant la purge.
current=""
[[ -f "$DEPLOY_DIR/version" ]] && current=$(cat "$DEPLOY_DIR/version")
command -v docker >/dev/null || { log "docker absent"; exit 1; }
command -v curl >/dev/null || { log "curl absent"; exit 1; }
command -v jq >/dev/null || { log "jq absent (apt install jq)"; exit 1; }
[[ -f "$DEPLOY_DIR/.env" ]] || { log ".env absent — abort"; exit 1; }
# ── 1. Tag cible : le plus récent, CI verte, pas encore déployé ─────────
tag=""
if [[ $force -eq 1 ]]; then
tag="HEAD"
log "--force : déploiement de HEAD (sans vérification CI)"
else
latest_tag=$(curl -fsS --max-time 15 \
"https://api.github.com/repos/$REPO_SLUG/tags?per_page=5" \
| jq -r '[.[].name | select(test("^v[0-9]"))][0] // empty')
[[ -n "$latest_tag" ]] || { log "aucun tag v* trouvé"; exit 0; }
if [[ "$latest_tag" == "$current" ]]; then
log " déjà déployé ($current) — rien à faire"
exit 0
fi
ci_state=$(curl -fsS --max-time 15 \
"https://api.github.com/repos/$REPO_SLUG/commits/$latest_tag/check-runs" \
| jq -r '[.check_runs[].conclusion] | if length == 0 then "none" else (if all(. == "success") then "success" else "not-green" end) end')
case "$ci_state" in
success) log "CI verte sur $latest_tag → déploiement" ;;
none) log "CI pas encore lancée sur $latest_tag — retentera au prochain tour"; exit 0 ;;
*) log "CI PAS VERTE sur $latest_tag ($ci_state) — pas de déploiement"; exit 0 ;;
esac
tag="$latest_tag"
fi
# ── 2. Télécharger et préparer le répertoire du release ────────────────
stage="$RELEASES_DIR/.staging-$$"
release_dir="$RELEASES_DIR/$tag"
mkdir -p "$stage"
cleanup() { rm -rf "$stage"; }
trap cleanup EXIT
# Archive du tag ; en --force, archive de la branche par défaut (il
# n'existe pas de refs/tags/HEAD.tar.gz : 404 prouvé en réel).
archive="refs/tags/$tag.tar.gz"
[[ $force -eq 1 ]] && archive="refs/heads/$DEFAULT_BRANCH.tar.gz"
log "téléchargement du tarball $archive"
curl -fL --max-time 120 -o "$stage/src.tar.gz" \
"https://github.com/$REPO_SLUG/archive/$archive" \
|| { log "téléchargement impossible — abort"; exit 1; }
tar -xzf "$stage/src.tar.gz" -C "$stage"
src=$(echo "$stage"/*/)
mkdir -p "$RELEASES_DIR"
rm -rf "$release_dir"
mv "$src" "$release_dir"
echo "$tag" > "$release_dir/version"
chmod +x "$release_dir/deploy/vektor-deploy.sh" 2>/dev/null || true
# ── 3. Copier les pièces à ne jamais retélécharger ──────────────────────
# .env (secrets) et secrets/ (clés) vivent dans /opt/vektor, jamais dans
# git. knowledge-live/ est régénéré depuis knowledge/ du repo.
install -m 600 "$DEPLOY_DIR/.env" "$release_dir/.env"
[[ -d "$DEPLOY_DIR/secrets" ]] && cp -a "$DEPLOY_DIR/secrets" "$release_dir/secrets"
rm -rf "$release_dir/knowledge-live"
mkdir -p "$release_dir/knowledge-live"
cp -a "$DEPLOY_DIR/knowledge-live/." "$release_dir/knowledge-live/" 2>/dev/null || \
cp -a "$release_dir/knowledge/." "$release_dir/knowledge-live/"
chmod 600 "$release_dir/.env"
# ── 4. Basculer et rebuild ──────────────────────────────────────────────
log "rebuild + recreate des conteneurs depuis $release_dir"
# --profile telegram sur le BUILD AUSSI : sans lui, compose ne construit
# pas les services en profil -> image du bot figée, jamais recréer
# (vécu en v1.5.8 : bot resté sur une vieille image silencieusement).
if ! (cd "$release_dir" && docker compose -p vektor -f "$COMPOSE_FILE" --profile telegram build --pull \
&& docker compose -p vektor -f "$COMPOSE_FILE" --profile telegram up -d); then
log "ÉCHEC du build/up — ancienne version laissée en place"
exit 1
fi
# ── 5. Health check post-deploy ─────────────────────────────────────────
log "health check (< ${HEALTH_TIMEOUT}s)"
healthy=0
for _ in $(seq 1 $((HEALTH_TIMEOUT / 5))); do
if health_ok; then
healthy=1; break
fi
sleep 5
done
if [[ $healthy -eq 1 ]]; then
# Pointeur de version courante : c'est lui qui empêche le re-déploiement
# du même tag à chaque tour du watcher. SANS lui, le watcher reboucle.
echo "$tag" > "$DEPLOY_DIR/version"
log "✅ $tag déployé et healthy"
# Conteneurs orphelins d'anciennes versions : nettoyage best-effort.
# ⚠️ --profile telegram OBLIGATOIRE : sans lui, compose détache le
# conteneur telegram (service en profil) au lieu de le recréer —
# vécu en v1.5.8 : API neuve, bot resté 20 h sur l'ancienne image.
(cd "$release_dir" && docker compose -p vektor -f "$COMPOSE_FILE" --profile telegram up -d --remove-orphans) >/dev/null 2>&1 || true
# ── 6. Purge des anciennes releases (garder les KEEP_RELEASES dernières) ─
# Uniquement après un deploy RÉUSSI (le chemin rollback ci-dessus peut
# encore avoir besoin d'une ancienne release). La release courante est
# épargnée explicitement : mtime et ordre logique peuvent diverger.
while read -r old; do
base=$(basename "$old")
if [[ "$base" != "${current:-}" ]]; then
log "purge ancienne release $base"
rm -rf "$old"
fi
done < <(ls -1dt "$RELEASES_DIR"/v* 2>/dev/null | tail -n +$((KEEP_RELEASES + 1)))
# ── 7. Self-update : réinstalle le script depuis la release déployée ──
# La release ne met jamais à jour l'outil de déploiement lui-même :
# sans ce bloc, une nouvelle version du script ne prend effet qu'après
# une installation manuelle. Écrit via install + mv (rename atomique) :
# l'instance EN COURS garde son inode (bash lit le fichier au fil de
# l'eau, une écriture sur place la tronquerait), la nouvelle version
# prend effet au prochain tour du watcher. Best-effort : un échec
# n'annule pas le déploiement déjà réussi.
deployed_script="$release_dir/deploy/vektor-deploy.sh"
if [[ -f "$deployed_script" ]]; then
if ! cmp -s "$deployed_script" /usr/local/bin/vektor-deploy; then
install -m 0755 "$deployed_script" /usr/local/bin/vektor-deploy.new \
&& mv /usr/local/bin/vektor-deploy.new /usr/local/bin/vektor-deploy \
&& log "self-update : /usr/local/bin/vektor-deploy remplacé (actif au prochain tour)" \
|| log "⚠️ self-update impossible — garder l'installation manuelle en tête"
fi
fi
else
log "🔴 health check KO après $HEALTH_TIMEOUT s — ROLLBACK automatique"
if [[ -n "${current:-}" && -d "$RELEASES_DIR/$current" ]]; then
(cd "$RELEASES_DIR/$current" && docker compose -p vektor -f "$COMPOSE_FILE" --profile telegram up -d) \
&& log "rollback vers $current effectué" \
|| log "ÉCHEC DU ROLLBACK — intervention manuelle requise (recreate depuis $RELEASES_DIR/$current)"
else
log "pas de release précédente connue — intervention manuelle requise"
fi
exit 1
fi