- Une coquille ne touche que des lettres latines, des espaces et la ponctuation ordinaire ; tout le reste est figé : chiffres de toute écriture, signes, relations, parenthèses, sosies (U+201A, U+2044, pleine chasse), marques superposées (U+0336, U+0338), autres alphabets, rayure Markdown, contenu entre accents graves, séparateurs et tirets collés à un chiffre. - Adresses (domaines, liens, courriels) comparées à l'identique. - Glissement de clavier au cœur du mot seulement (ni les deux premières ni les deux dernières lettres : préfixes, nombre, genre, temps, mode) ; mots de position ajoutés (dessus/dessous, avant/après…). - Chaque correction est mesurée contre le texte voté (dernière révision initiale ou de fond) : les glissements ne s'additionnent plus ; le texte soumis d'un brouillon devient sa révision initiale. - Déploiement : « previous » = l'image du conteneur en service, pas la dernière construite ; migrations inconnues tolérées pour qu'une image précédente démarre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
248 lines
11 KiB
YAML
248 lines
11 KiB
YAML
# libreDecision v3 — le produit (Rust + Svelte) sur decision.librodrome.org.
|
|
# Une seule image : la construction rejoue toute la boucle (fmt, clippy -D warnings,
|
|
# tests Rust, svelte-check, vitest) — un contrôle rouge, pas d'image.
|
|
# Données runtime : volume nommé lpk-libredecision-app_data (SQLite, clone du dépôt
|
|
# des textes, clé de publication). Jamais `down -v`, jamais recréé.
|
|
|
|
when:
|
|
- branch: main
|
|
event: push
|
|
|
|
# Le produit passe en premier : tant que son image n'est pas construite et testée, la
|
|
# maquette continue de servir la racine. La maquette (maquette.yml) attend ce workflow
|
|
# et ne part sur maquette.decision.librodrome.org qu'après un déploiement réussi.
|
|
|
|
steps:
|
|
|
|
- name: security-check
|
|
image: alpine:3.20
|
|
commands:
|
|
- |
|
|
if [ -f .env ]; then
|
|
echo "ERREUR: .env ne doit pas etre commite dans le depot"
|
|
exit 1
|
|
fi
|
|
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
|
|
- echo "Security check OK"
|
|
|
|
# NOTE: volumes + pas de from_secret : compatible
|
|
- name: build-app
|
|
image: docker:27-cli
|
|
depends_on:
|
|
- security-check
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
commands:
|
|
- BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
|
- docker build -t libredecision-app:latest -f docker/app.Dockerfile --target production --build-arg LD_BUILD=$BUILD .
|
|
- echo "Image app construite ($BUILD)"
|
|
|
|
# NOTE: volumes + pas de from_secret : compatible
|
|
- name: sbom-generate
|
|
image: alpine:3.20
|
|
depends_on:
|
|
- build-app
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
commands:
|
|
- apk add --no-cache curl
|
|
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
|
|
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
|
|
- mkdir -p .reports
|
|
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
|
- syft libredecision-app:latest -o cyclonedx-json@1.5 --file .reports/sbom-app.cyclonedx.json
|
|
- echo "SBOM genere"
|
|
|
|
# NOTE: volumes + pas de from_secret : compatible
|
|
- name: sbom-scan
|
|
image: aquasec/trivy:latest
|
|
depends_on:
|
|
- sbom-generate
|
|
volumes:
|
|
- /home/syoul/trivy-cache:/root/.cache/trivy
|
|
commands:
|
|
- trivy sbom --format json --output .reports/trivy-app.json .reports/sbom-app.cyclonedx.json
|
|
- echo "Scan CVE termine"
|
|
|
|
# NOTE: from_secret + pas de volumes : compatible
|
|
# failure: ignore — télémétrie, ne bloque pas le déploiement (cf. maquette.yml).
|
|
- name: sbom-publish
|
|
image: alpine/curl:latest
|
|
failure: ignore
|
|
depends_on:
|
|
- sbom-scan
|
|
environment:
|
|
DTRACK_TOKEN:
|
|
from_secret: dependency_track_token
|
|
DTRACK_DOMAIN:
|
|
from_secret: dtrack_domain
|
|
commands:
|
|
- |
|
|
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
|
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
|
|
-H "X-Api-Key: $DTRACK_TOKEN" \
|
|
-F "autoCreate=true" \
|
|
-F "projectName=libredecision-app" \
|
|
-F "projectVersion=$VERSION" \
|
|
-F "bom=@.reports/sbom-app.cyclonedx.json")
|
|
echo "HTTP $HTTP (app) : $(cat /tmp/dtrack-resp.txt)"
|
|
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
|
|
|
|
# NOTE: volumes + pas de from_secret : compatible
|
|
# Fabio route via les labels SERVICE_* de docker/app-compose.yml.
|
|
- name: deploy
|
|
image: docker:27-cli
|
|
depends_on:
|
|
- sbom-publish
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- /opt/libredecision-app:/opt/libredecision-app
|
|
commands:
|
|
- DOMAIN=decision.librodrome.org
|
|
# Témoin : à partir d'ici, un échec appelle « retablir ». Le healthcheck réussi l'efface.
|
|
- touch /opt/libredecision-app/deploying
|
|
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
|
|
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
|
|
# Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son
|
|
# déplacement, d'anciennes stacks ehv-decision-main-*) est arrêté et sort de
|
|
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
|
|
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
|
|
# recréée par son propre workflow, sous son nouveau domaine.
|
|
# Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici :
|
|
# un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance.
|
|
- |
|
|
RETIRES=/opt/libredecision-app/retires
|
|
touch "$RETIRES"
|
|
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
|
|
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
|
|
NAME=$(docker inspect --format '{{.Name}}' "$C")
|
|
[ "$NAME" = "/lpk-libredecision-app" ] && continue
|
|
echo "Retrait de $NAME de la racine (revendique $TAG)"
|
|
if [ "$(docker inspect --format '{{.State.Running}}' "$C")" = "true" ]; then
|
|
echo "$C" >> "$RETIRES"
|
|
fi
|
|
docker update --restart=no "$C" >/dev/null
|
|
docker stop "$C" >/dev/null
|
|
done
|
|
done
|
|
# La version en service (celle qui tourne, validée par son propre healthcheck) est
|
|
# gardée sous « previous » : « retablir » la relance si ce déploiement échoue.
|
|
- |
|
|
IMG=$(docker inspect --format '{{if .State.Running}}{{.Image}}{{end}}' lpk-libredecision-app 2>/dev/null || true)
|
|
if [ -n "$IMG" ]; then docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)"; fi
|
|
# Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1.
|
|
- cd /opt/libredecision-app && docker compose stop
|
|
- |
|
|
ACME_EXIT=0
|
|
docker exec sonic-acme-1 /app/acme.sh \
|
|
--home /etc/acme.sh \
|
|
--issue -d "$DOMAIN" \
|
|
--webroot /usr/share/nginx/html \
|
|
--server letsencrypt \
|
|
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
|
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
|
|
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
|
|
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
|
|
echo "TLS OK (acme exit $ACME_EXIT)"
|
|
elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then
|
|
echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve"
|
|
else
|
|
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
|
|
exit 1
|
|
fi
|
|
# Image construite localement dans la pipeline : pas de docker compose pull
|
|
- cd /opt/libredecision-app && docker compose up -d --remove-orphans --force-recreate
|
|
- cd /opt/libredecision-app && docker compose ps
|
|
|
|
- name: test-deploy
|
|
image: docker:27-cli
|
|
depends_on:
|
|
- deploy
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
commands:
|
|
- |
|
|
STATUS=$(docker inspect --format '{{.State.Status}}' lpk-libredecision-app 2>/dev/null || echo "absent")
|
|
echo "lpk-libredecision-app : $STATUS"
|
|
[ "$STATUS" = "running" ] || { docker logs --tail 50 lpk-libredecision-app 2>&1; echo "FAIL: non running"; exit 1; }
|
|
echo "PASS: conteneur running"
|
|
|
|
# Le produit répond, et c'est bien ce commit qui répond.
|
|
# NOTE: volumes + pas de from_secret : compatible
|
|
- name: healthcheck
|
|
image: alpine:3.20
|
|
depends_on:
|
|
- test-deploy
|
|
volumes:
|
|
- /opt/libredecision-app:/opt/libredecision-app
|
|
commands:
|
|
- apk add --no-cache --quiet curl
|
|
- |
|
|
BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
|
TARGET="https://decision.librodrome.org/api/health"
|
|
MAX=60
|
|
i=0
|
|
until [ $i -ge $MAX ]; do
|
|
BODY=$(curl -sS -m 10 "$TARGET" 2>/dev/null || true)
|
|
echo "Tentative $((i+1))/$MAX - $BODY"
|
|
case "$BODY" in
|
|
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
|
|
echo "PASS: $BUILD repond sur $TARGET"
|
|
: > /opt/libredecision-app/retires
|
|
rm -f /opt/libredecision-app/deploying
|
|
exit 0 ;;
|
|
esac
|
|
i=$((i+1))
|
|
sleep 10
|
|
done
|
|
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
|
|
exit 1
|
|
|
|
# Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire.
|
|
# Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la
|
|
# racine repart (restart: always rendu). Échec après, plus tard : la version précédente du
|
|
# produit (image « previous ») est relancée.
|
|
# NOTE: volumes + pas de from_secret : compatible
|
|
- name: retablir
|
|
image: docker:27-cli
|
|
# En graphe (depends_on), une étape sans dépendance démarrerait au début : celle-ci
|
|
# attend la fin du healthcheck (fait, échoué ou sauté).
|
|
depends_on:
|
|
- healthcheck
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- /opt/libredecision-app:/opt/libredecision-app
|
|
commands:
|
|
- |
|
|
D=/opt/libredecision-app
|
|
if [ ! -e "$D/deploying" ]; then echo "Echec avant le deploiement : rien a retablir"; exit 0; fi
|
|
if [ -s "$D/retires" ]; then
|
|
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
|
|
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
|
|
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
|
|
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
|
|
for C in $(cat "$D/retires"); do
|
|
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
|
|
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
|
|
done
|
|
: > "$D/retires"
|
|
elif docker image inspect libredecision-app:previous >/dev/null 2>&1; then
|
|
echo "Retour a la version precedente du produit"
|
|
docker tag libredecision-app:previous libredecision-app:latest
|
|
cd "$D" && docker compose up -d --force-recreate
|
|
else
|
|
echo "Aucune version precedente a relancer"
|
|
fi
|
|
rm -f "$D/deploying"
|
|
when:
|
|
- status: failure
|
|
|
|
- name: notify-failure
|
|
image: alpine:3.20
|
|
depends_on:
|
|
- retablir
|
|
commands:
|
|
- 'echo "ECHEC pipeline app #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
|
when:
|
|
- status: failure
|