Files
ENI-projet-piscine/scripts/provision-host.sh
T
Johan LEROY dbcd5c4240 fix(deploy): passe la prod sur prod.enervision-g3.dynv6.net
dynv6 sert mal un TXT _acme-challenge à la racine de la zone : l'API ne
le liste ni ne le supprime, et un seul de ses trois serveurs le renvoie.
Le défi DNS-01 de la prod échouait donc à chaque essai, alors que rec. et
dev. passaient. La prod rejoint ses voisines en sous-domaine, ce qui aligne
aussi les trois noms sur les environnements.

- provision-host.sh : hôte prod.$DOMAINE, enregistrement A prod publié.
- Makefile : --dnssleep 90, le temps que les trois serveurs de dynv6
  servent le TXT avant la validation multi-réseaux de Let's Encrypt.
- deploy.yml, ADR 0018, 10-infra.md, infra/README.md, Terraform.
2026-09-23 12:35:20 +02:00

229 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Pourquoi : la machine porte trois environnements, chacun un clone du dépôt, un `.env` et un
# projet Compose (ADR 0009, 0017), derrière un frontal SNI sur 443 (ADR 0018). Ce script prépare
# la machine et les trois dossiers sans démarrer aucune stack : c'est le rôle du runner.
# Piège : le script, et non le `.env.example` du clone, fait foi pour les secrets (GENERATEURS)
# et l'adressage (tableau du bas). Un `.env` existant garde ses secrets, reçoit ceux qui lui
# manquent et voit son adressage réaligné : sans ça, un `.env` né avant une clé ne la reçoit
# jamais, et le clone de la prod, en retard sur `main`, ne connaîtrait pas les nouvelles.
# Piège : lancé en root, git refuse un clone déjà chowné au runner (propriété douteuse). D'où
# `safe.directory` passé en ligne de commande, seule portée où git l'accepte - preparer().
# Rejouable : un certificat n'est refait que s'il ne couvre plus l'hôte, et celui de Let's Encrypt
# n'est renouvelé qu'à échéance.
set -euo pipefail
DEPOT="${REPO_URL:-https://github.com/ineszang/ProjetPiscine_EnerVision.git}"
RACINE="${RACINE:-/srv/enervision}"
DOMAINE="${DOMAINE:-enervision-g3.dynv6.net}"
ADRESSE="${PUBLIC_IP:-$(hostname -I | awk '{print $1}')}"
PROPRIETAIRE="${PROPRIETAIRE:-${SUDO_USER:-}}"
JETON_DNS="$RACINE/dns.token"
COMPOSE_MINIMALE="2.24.4"
erreur() { echo "erreur : $*" >&2; exit 1; }
secret() { openssl rand -base64 48 | tr -d '/+=\n' | cut -c1-48; }
court() { secret | cut -c1-20; }
# Clé Fernet : 32 octets en base64 urlsafe, padding compris.
fernet() { openssl rand -base64 32 | tr '+/' '-_'; }
declare -A GENERATEURS=(
[POSTGRES_PASSWORD]=secret [APP_SECRET_KEY]=secret [AIRFLOW_FERNET_KEY]=fernet
[AIRFLOW_API_SECRET_KEY]=secret [AIRFLOW_JWT_SECRET]=secret [AIRFLOW_ADMIN_PASSWORD]=court
[AIRFLOW_APP_SECRET_KEY]=secret [APP_METRICS_TOKEN]=secret [GRAFANA_ADMIN_PASSWORD]=court
[SUPERVISION_DB_PASSWORD]=secret
)
verifier_outils() {
for outil in git make openssl curl; do
command -v "$outil" >/dev/null || erreur "$outil absent (apt-get install $outil)"
done
command -v docker >/dev/null || erreur "Docker absent : https://docs.docker.com/engine/install/debian/"
docker info >/dev/null 2>&1 || erreur "le démon Docker ne répond pas, ou l'utilisateur n'est pas dans le groupe docker"
local version
version="$(docker compose version --short 2>/dev/null || true)"
[[ -n "$version" ]] || erreur "plugin docker compose absent (paquet docker-compose-plugin)"
[[ "$(printf '%s\n%s\n' "$COMPOSE_MINIMALE" "${version#v}" | sort -V | head -1)" == "$COMPOSE_MINIMALE" ]] \
|| erreur "docker compose $version trop ancien : $COMPOSE_MINIMALE requis pour !override et !reset"
curl -fsSI --max-time 10 https://github.com >/dev/null || erreur "pas de sortie HTTPS vers github.com"
echo "docker compose $version, sortie Internet : ok"
}
valeur() { sed -n "s/^$2=//p" "$1" | tail -1; }
poser() {
local fichier="$1" cle="$2" contenu="$3"
if grep -q "^$cle=" "$fichier"; then
CLE="$cle" CONTENU="$contenu" awk -F= '
$1 == ENVIRON["CLE"] { print ENVIRON["CLE"] "=" ENVIRON["CONTENU"]; next } { print }
' "$fichier" > "$fichier.tmp"
mv "$fichier.tmp" "$fichier"
else
printf '%s=%s\n' "$cle" "$contenu" >> "$fichier"
fi
}
preparer() {
local env="$1" branche="$2" hote="$3"
local port_https="$4" port_http="$5" port_front="$6" port_pg="$7" port_mailpit="$8"
local port_airflow="$9" profils="${10}" port_grafana="${11}" port_prometheus="${12}"
local port_alertmanager="${13}"
local dossier="$RACINE/$env"
local fichier="$dossier/.env" brouillon="$dossier/.env.brouillon" cle oubliees ajoutees=""
if [[ -d "$dossier/.git" ]]; then
local git=(git -c "safe.directory=$dossier" -C "$dossier")
"${git[@]}" fetch --quiet origin "$branche"
"${git[@]}" checkout --quiet "$branche"
"${git[@]}" reset --quiet --hard "origin/$branche"
else
git clone --quiet --branch "$branche" "$DEPOT" "$dossier"
fi
local masque
masque="$(umask)"
umask 077
if [[ -f "$fichier" ]]; then
cp -p "$fichier" "$brouillon"
awk -F= 'NR == FNR { connues[$1]; next } /^[A-Z_][A-Z0-9_]*=/ && !($1 in connues)' \
"$fichier" "$dossier/.env.example" >> "$brouillon"
else
cp "$dossier/.env.example" "$brouillon"
fi
for cle in "${!GENERATEURS[@]}"; do
case "$(valeur "$brouillon" "$cle")" in
"" | change_me) poser "$brouillon" "$cle" "$("${GENERATEURS[$cle]}")"; ajoutees+=" $cle" ;;
esac
done
declare -A adressage=(
[PUBLIC_HOST]="$hote" [PUBLIC_ORIGIN]="https://$hote" [COMPOSE_PROJECT_NAME]="enervision-$env"
[PROXY_HTTPS_PORT]="$port_https" [PROXY_HTTP_PORT]="$port_http" [PROXY_FRONT_PORT]="$port_front"
[POSTGRES_PORT]="$port_pg" [MAILPIT_UI_PORT]="$port_mailpit" [AIRFLOW_PORT]="$port_airflow"
[COMPOSE_PROFILES]="$profils" [GRAFANA_PORT]="$port_grafana"
[PROMETHEUS_PORT]="$port_prometheus" [ALERTMANAGER_PORT]="$port_alertmanager"
)
for cle in "${!adressage[@]}"; do
poser "$brouillon" "$cle" "${adressage[$cle]}"
done
# Piège : une clé renommée en amont garde sa valeur d'exemple, que le `:?` du compose ne voit
# pas puisqu'elle n'est pas vide. Cas vécu : AIRFLOW_WEBSERVER_SECRET_KEY, Airflow 3.
oubliees="$(grep '=change_me$' "$brouillon" | grep -v '^APP_MOCK_API_' | cut -d= -f1 | tr '\n' ' ' || true)"
if [[ -n "$oubliees" ]]; then
rm -f "$brouillon"
erreur "$env : clés sans générateur, .env inchangé : $oubliees"
fi
chmod 600 "$brouillon"
if [[ -f "$fichier" ]]; then
cat "$brouillon" > "$fichier"
rm -f "$brouillon"
echo "$env : .env réaligné sur le tableau${ajoutees:+, secrets ajoutés :$ajoutees}"
else
mv "$brouillon" "$fichier"
echo "$env : .env généré. Reste à renseigner APP_MOCK_API_USERNAME et APP_MOCK_API_PASSWORD."
fi
umask "$masque"
if ! openssl x509 -in "$dossier/infra/proxy/tls/fullchain.pem" -noout -checkhost "$hote" 2>/dev/null \
| grep -q " does match"; then
(cd "$dossier" && PUBLIC_HOST="$hote" PUBLIC_IP="$ADRESSE" ./scripts/tls-selfsigned.sh --force)
fi
if [[ -r "$JETON_DNS" && "$hote" != *.local ]]; then
make -C "$dossier" --no-print-directory tls-dns01 PUBLIC_HOST="$hote" \
|| echo "$env : pas de certificat Let's Encrypt, l'auto-signé reste en place" >&2
fi
echo "$env : $dossier sur $branche, https://$hote"
}
# Pourquoi : dynv6 est le fournisseur que le filtrage de l'école laisse passer (ADR 0018). Le
# jeton passe par l'environnement du seul processus Python, jamais par `argv`.
publier_dns() {
[[ -r "$JETON_DNS" ]] || { echo "pas de jeton $JETON_DNS : ni DNS ni Let's Encrypt"; return 0; }
DNS_TOKEN="$(tr -d '[:space:]' < "$JETON_DNS")" python3 - "$DOMAINE" "$ADRESSE" prod rec dev <<'PY' \
|| echo "DNS : dynv6 refuse la mise à jour de $DOMAINE, enregistrements inchangés" >&2
import json, os, sys, time, urllib.request
domaine, adresse, *sous_noms = sys.argv[1:]
def appel(methode, chemin, corps=None):
requete = urllib.request.Request(
f"https://dynv6.com/api/v2/{chemin}", method=methode,
data=None if corps is None else json.dumps(corps).encode(),
headers={"Authorization": f"Bearer {os.environ['DNS_TOKEN']}", "User-Agent": "enervision-provision",
"Content-Type": "application/json", "Accept": "application/json"})
with urllib.request.urlopen(requete, timeout=60) as reponse:
contenu = reponse.read()
return json.loads(contenu) if contenu else None
def synchroniser():
zone = appel("GET", f"zones/by-name/{domaine}")
if zone.get("ipv4address") != adresse:
appel("PATCH", f"zones/{zone['id']}", {"ipv4address": adresse})
existants = {(r["type"], r["name"]): r for r in appel("GET", f"zones/{zone['id']}/records")}
for nom in sous_noms:
actuel = existants.get(("A", nom))
if actuel is None:
appel("POST", f"zones/{zone['id']}/records", {"type": "A", "name": nom, "data": adresse})
elif actuel["data"] != adresse:
appel("PATCH", f"zones/{zone['id']}/records/{actuel['id']}", {"data": adresse})
# dynv6 laisse parfois une écriture sans réponse, appliquée ou non : chaque essai relit l'état
# avant d'écrire, si bien qu'une création aboutie malgré le délai n'est jamais dupliquée.
for essai in range(3):
try:
synchroniser()
break
except OSError:
if essai == 2:
raise
time.sleep(5)
print(f"DNS : {domaine}, {', '.join(sous_noms)} visent {adresse}")
PY
}
planifier_renouvellement() {
[[ "$(id -u)" -eq 0 && -n "$PROPRIETAIRE" && -d /etc/cron.d ]] || return 0
cat > /etc/cron.d/enervision-tls <<CRON
# Renouvellement Let's Encrypt des trois environnements (ADR 0018), écrit par provision-host.sh.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
23 4 * * * $PROPRIETAIRE for e in prod rec dev; do make -C $RACINE/\$e --no-print-directory tls-dns01; done 2>&1 | logger -t enervision-tls
CRON
chmod 644 /etc/cron.d/enervision-tls
echo "renouvellement planifié : /etc/cron.d/enervision-tls"
}
verifier_outils
mkdir -p "$RACINE"
publier_dns
# Supervision active en prod seulement (ADR 0016). La prod vit sur `prod.` et non à la racine :
# dynv6 ne sert pas de façon fiable un TXT `_acme-challenge` à la racine de la zone (ADR 0018).
# env branche hôte https http front pg mailpit airflow profils grafana prometheus alertmanager
preparer prod main "prod.$DOMAINE" 127.0.0.1:10443 127.0.0.1:10080 127.0.0.1:10444 5433 8025 8080 monitoring 3001 9090 9093
preparer rec dev "rec.$DOMAINE" 127.0.0.1:8443 127.0.0.1:8081 127.0.0.1:8444 5434 8026 8082 "" 3002 9091 9094
preparer dev dev "dev.$DOMAINE" 127.0.0.1:9443 127.0.0.1:8083 127.0.0.1:9444 5435 8027 8084 "" 3003 9092 9095
planifier_renouvellement
if [[ -n "$PROPRIETAIRE" && "$(id -u)" -eq 0 ]]; then
chown -R "$PROPRIETAIRE" "$RACINE"
fi
cat <<FIN
Démarrage, dans chaque dossier : make stack-up, qui applique aussi les migrations. Puis, une
fois, depuis $RACINE/prod : make front-up, que chaque déploiement de la prod rejoue ensuite.
Premier administrateur, stack démarrée, dans chaque dossier :
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec backend \\
python -m app.cli create-admin --email <adresse>
Le runner GitHub Actions (label eni-g3) rejouera le déploiement à chaque push sur dev et main,
et déploiera dans dev toute autre branche lancée à la main depuis l'onglet Actions.
L'installer sous le propriétaire de $RACINE, sinon git refuse ces dépôts et le .env en 600 lui
échappe : relancer au besoin ce script avec PROPRIETAIRE=<utilisateur du runner>.
Données historiques : git ne porte pas data/raw, déposer les fichiers dans chaque dossier avant
de déclencher le DAG historical_import.
Noms et certificats : le jeton dynv6 de la zone $DOMAINE doit se trouver dans $JETON_DNS (600,
propriétaire du runner). Sans lui, ni enregistrement DNS ni Let's Encrypt : auto-signé.
FIN