Apparence
06. Exploitation
Comment la plateforme est construite, contrôlée, déployée, certifiée et surveillée.
Environnements
| Environnement | Ce qui y tourne | Frontend | TLS | Portail de documentation |
|---|---|---|---|---|
| Poste de développement | La stack entière en Docker | Serveur Vite, rechargement à chaud | Non, et volontairement (voir plus bas) | Servi, sans garde |
Serveur de développement (sirh-dev.apajh81.org) | La stack entière, déployée automatiquement depuis develop | Build statique | Oui, Let's Encrypt | Servi, derrière auth_basic |
Serveur de préproduction (sirh-preprod.apajh81.org) | La stack entière, déployée automatiquement depuis preprod | Build statique | Oui, Let's Encrypt | Servi, derrière auth_basic |
Il n'y a pas d'environnement de production à ce jour. La branche preprod est contrôlée et déployée automatiquement sur son serveur dédié. La branche main reste contrôlée par l'intégration continue, sans déploiement.
Intégration continue
Trois travaux tournent sur chaque pull request visant develop, preprod ou main, et de nouveau avant tout déploiement :
| Travail | Ce qu'il vérifie |
|---|---|
| Backend | ruff (lint), mypy en mode strict, la suite pytest avec base et cache réels |
| Frontend | Vérification de types et build Vite, contrôle de formatage |
| Documentation | Régénère les documents dérivés et échoue sur une dérive ; joue les invariants documentaires ; construit le portail ; lint et formatage des scripts |
Le travail de documentation mérite une explication, parce que son échec surprend : il régénère puis compare aux octets commités. Un document généré modifié à la main est donc écrasé et le contrôle passe ; ce qu'il attrape est le cas inverse — du code changé sans que le document soit régénéré. Il a aussi besoin de l'historique complet et des tags, que le changelog lit.
Il construit également le portail, et c'est ce qui attrape les liens morts et les pages qu'aucune barre latérale ne cite. Un lien interne cassé est le défaut le plus fréquent d'une pull request documentaire, et il ne se voit pas à la relecture ; l'exclusion configurée ne couvre que les liens qui sortent de docs/ vers un fichier du dépôt.
La même séquence tourne en local par make check (sans stack) et make check-full (avec, pour les tests de bout en bout Playwright).
Déploiement
Un push sur develop ou preprod déclenche l'intégration continue puis le déploiement du commit contrôlé. Les deux chaînes divergent après cette étape : seul develop publie une version.
Décision : le déploiement porte sur le commit qui l'a déclenché, pas sur la pointe de la branche au moment où il s'exécute. La nuance a l'air théorique et ne l'est pas : entre le début d'un déploiement et sa fin, quelqu'un peut fusionner. En déployant la pointe, la plateforme mettrait en ligne un commit dont l'intégration continue n'est peut-être pas terminée, et le tag posé juste après désignerait un commit différent de l'arbre réellement parti — deux mensonges que rien ne signalerait ensuite. Chaque travail qui touche un serveur nomme donc un commit précis : celui du déclencheur pour les deux déploiements, celui du changelog pour la republication du portail develop.
Décision : la construction des images se fait sur le serveur. Il n'y a ni registre d'images, ni archive transférée. Le déploiement consiste à mettre à jour le clone du dépôt présent sur le serveur et à laisser Docker Compose reconstruire et recréer ce qui a changé. C'est ce qui rend le déploiement identique à ce qu'un développeur fait en local, au prix d'une construction à chaque mise en ligne.
Décision : les déploiements passent par des runners auto-hébergés. Le pare-feu du serveur de développement n'accepte que des adresses françaises, ce que les runners hébergés par GitHub n'ont pas. Develop utilise le runner étiqueté apt ; préprod a son runner dédié, étiqueté preprod, et ses propres secrets de connexion dans l'environnement GitHub du même nom. Le travail develop qui ne parle qu'à GitHub — la pose du tag et la publication de la Release — tourne, lui, sur un runner hébergé : il n'a aucune raison de dépendre d'une machine de l'association.
Il n'y a pas d'étape de migration. L'entrée du conteneur backend applique les migrations à chaque démarrage. Un déploiement qui recrée ce conteneur migre donc de lui-même. La conséquence à connaître : une migration longue retarde la disponibilité du service, et une migration qui échoue laisse le conteneur en échec de démarrage — ce qui est visible, contrairement à une migration oubliée.
Le déploiement écarte explicitement la surcouche de développement et applique celle du TLS avec le profil tls. C'est nécessaire dans les deux sens : sans cela, le proxy serait réconcilié contre le fichier de base seul et perdrait silencieusement la publication du port 443 à chaque déploiement, cassant HTTPS même après une correction manuelle.
Versions et changelog
Aucun numéro de version n'est décidé à la main : un déploiement develop réussi est la livraison. Le travail qui suit ce déploiement pose un tag v<AAAA.MM.JJ>.<n>, régénère le changelog à partir des tags, le commite sur develop et publie une GitHub Release dont le corps est la section qui vient d'être rendue. Préprod ne crée ni tag, ni section de changelog, ni GitHub Release : elle valide un commit déjà passé par develop sans ouvrir une seconde numérotation concurrente.
Deux conséquences :
- Le sujet d'un commit est une surface de documentation. Il est publié tel quel à un lecteur extérieur. Le format attendu est décrit dans
references/documentation.md. - Le fuseau utilisé est celui de l'exploitation : un déploiement à 23 h 30 appartient au jour où l'équipe l'a lancé, pas au lendemain en UTC.
Le portail de documentation, et la boucle qu'il fallait couper autrement
Le portail est déployé par le même mécanisme que le reste : le service docs fait partie du fichier Compose que chaque déploiement reconstruit. Il n'y a rien de plus à câbler, et surtout aucune seconde chaîne de publication où la documentation pourrait prendre du retard sur le code. La republication décrite ci-dessous ne concerne que develop, car préprod ne produit aucun commit de changelog.
Cela a une conséquence sur le déclencheur. Le travail de version commite docs/changelog.md sur develop ; si ce commit relance un déploiement, la boucle « déploie → tague → régénère → commite » ne s'arrête jamais. Elle était coupée par un paths-ignore sur docs/**, qui ne peut plus tenir : depuis que la documentation est servie par un conteneur, un changement documentaire doit déclencher un déploiement, sans quoi le portail en ligne diverge du dépôt.
La coupure porte donc sur le commit et non sur les chemins : le déclencheur ignore un push dont le sujet est celui que le travail de version écrit. C'est une condition explicite, lisible dans le workflow à côté de la ligne qui produit ce sujet.
Cette coupure a une conséquence qu'il faut traiter, sans quoi elle se paye sur le portail. Le site est construit depuis l'arbre déployé, donc depuis un commit qui précède sa propre note de version : le changelog en ligne serait en retard d'exactement une version, en permanence, et personne ne le verrait puisque la page existe et paraît complète. Un quatrième travail suit donc celui de version et republie le portail — le seul service docs, puisque le commit de changelog ne diffère du commit déployé que par un fichier. Il est sauté quand le changelog n'a pas bougé, ce qui est le cas d'un redéploiement sans nouveau commit.
C'est aussi la réponse à une question qu'un lecteur se pose ici : pourquoi ne pas générer le changelog avant de déployer, ce qui éviterait le va-et-vient ? Parce que le changelog se construit à partir du tag, et que le tag n'est posé qu'une fois le déploiement réussi. L'inverse voudrait dire publier une version avant de savoir si elle est en ligne.
Pourquoi une condition explicite alors que GitHub coupe déjà la boucle. Un push authentifié par le jeton par défaut ne déclenche aucun workflow, donc la boucle est déjà coupée aujourd'hui sans rien écrire. Mais cette protection disparaît le jour où quelqu'un passe à un jeton personnel ou d'application pour contourner une règle de branche — un changement qui n'a aucun rapport apparent avec ce fichier, fait par quelqu'un qui n'a aucune raison de connaître cette conséquence. Ne compter que sur un comportement de plateforme, c'est laisser une boucle de déploiement infinie derrière une modification sans lien avec elle.
TLS
La terminaison TLS est en option, pas absente : la surcouche docker-compose.tls.yml publie le port 443 et le profil tls démarre le service certbot, qui obtient un certificat Let's Encrypt puis le renouvelle deux fois par jour. Les serveurs de développement et de préproduction tournent dans cette configuration.
Sur chaque environnement, un seul certificat couvre tous les hôtes — celui du SPA, celui de l'API, et celui du portail de documentation quand DOCS_HOST est renseignée — et l'ordre des noms compte : la configuration du proxy lit toujours le répertoire du certificat au nom de FRONTEND_HOST, qui doit donc être demandé en premier.
Chaque serveur possède son propre volume certbot_certs. Develop et préprod ont donc deux lignées et deux certificats distincts, même si leur triplet de noms suit la même convention. Les noms sirh-dev.apajh81.org, api.sirh-dev.apajh81.org, docs.sirh-dev.apajh81.org et leurs équivalents sous sirh-preprod.apajh81.org sont publiquement résolvables pour le défi HTTP-01.
Pas de TLS en développement local, et ce n'est pas un oubli
Let's Encrypt ne certifie pas un nom en .localhost, qui n'est pas résolvable publiquement — le défi HTTP-01 ne peut pas aboutir. Le développement local reste donc en HTTP simple, et doit y rester : une redirection forcée vers HTTPS casse les appels d'API entre les deux origines dès la requête de pré-vol, ce que le navigateur signale comme une erreur CORS alors que CORS est correctement configuré. Une heure perdue à chercher au mauvais endroit, d'où cette note.
Le piège de l'ajout d'un nom d'hôte
À lire avant d'ajouter un -d à la demande de certificat. L'entrée du conteneur certbot ne demande un certificat que s'il n'existe pas déjà de fichier de renouvellement pour FRONTEND_HOST. C'est ce qui l'empêche de redemander un certificat à chaque démarrage et de consommer le quota de Let's Encrypt.
Conséquence sur un serveur déjà déployé : ajouter un nom d'hôte à la liste -d du script ne suffit pas. Le fichier de renouvellement existe, la branche de demande n'est pas prise, et le certificat continue de ne couvrir que les hôtes d'origine — sans aucune erreur, le nouveau nom se contentant de servir un certificat invalide.
Deux façons de s'en sortir, à faire une fois, à la main sur le serveur :
étendre la lignée existante :
bashdocker compose run --rm --entrypoint certbot certbot certonly \ --webroot -w /var/www/certbot \ --expand --cert-name "$FRONTEND_HOST" \ -d "$FRONTEND_HOST" -d "$API_HOST" -d "<le nouveau nom>"ou supprimer la lignée (
/etc/letsencrypt/live/$FRONTEND_HOST, sonarchive/et sonrenewal/) et laisser l'entrée du conteneur redemander un certificat au prochain démarrage. Plus simple, mais consomme un certificat neuf du quota.
L'option --cert-name est ce qui garantit que le certificat reste écrit dans le répertoire que la configuration du proxy lit. Sans elle, certbot crée une lignée -0001 que le proxy ne regarde jamais.
Surveillance
Ce qui existe :
- une sonde de santé HTTP publique sur
/api/health/, utilisée par Docker et par le proxy ; - des sondes de santé Docker sur la base, le cache et le stockage objet, dont le démarrage du backend dépend ;
- les journaux des conteneurs (
make logs), qui reçoivent aussi les courriels en développement.
Ce qui n'existe pas : pas de collecte centralisée des journaux, pas de métriques, pas d'alerte. Un service arrêté n'avertit personne.
Le cas le plus difficile à remarquer est le service beat : son arrêt suspend les deux traitements quotidiens sans qu'aucune requête n'échoue. Les signes observables sont l'écran Jours fériés, dont la date du dernier enregistrement cesse d'avancer, et la liste Imports de données › Lots d'import, dont le dernier succès n'évolue plus malgré les dépôts attendus. Aucun de ces retards ne déclenche aujourd'hui d'alerte : APAJH-192 planifie l'import, mais le point aveugle de supervision reste à traiter par APAJH-233.
Surveiller et reprendre l'import CEGI
La liste Imports de données ▸ Lots d'import du back-office affiche le dernier succès, le dernier échec et le nombre de lots en cours ou à reprendre. Un fichier rejeté conserve son code d'erreur, sa ligne et sa colonne. Après correction du référentiel, un administrateur disposant de la permission dédiée peut lancer Rejouer les imports sélectionnés ; la commande équivalente est make retry-import ID=<UUID>.
make poll-sftp-imports lance une collecte immédiate, utile après une intervention sur le service. make purge-import-archives applique immédiatement les durées de conservation. Le fonctionnement normal repose sur Celery Beat : collecte quotidienne à 04 h 37, puis purge quotidienne. Les valeurs exactes sont en annexe D.
L'interface SFTPGo s'ouvre depuis un poste d'administration avec un tunnel, par exemple ssh -L 8088:127.0.0.1:8088 <serveur>, puis http://127.0.0.1:8088/web/admin. Le pare-feu de l'hôte n'ouvre pas ce port. À la première installation :
- connectez-vous avec l'administrateur d'amorçage défini dans
.env; - créez un administrateur nommé, activez son TOTP et supprimez le compte d'amorçage ;
- créez le déposant, chrooté dans
/srv/sftpgo/data/imports/incoming, avec le seul droit de téléversement ; - créez l'importeur, chrooté dans
/srv/sftpgo/data/imports, avec les droits de lecture, création de répertoire, déplacement et suppression nécessaires ; - relevez
/var/lib/sftpgo/id_ed25519.pubdans le conteneur, placez sa clé publique dansSFTP_HOST_PUBLIC_KEY, puis redémarrez le backend et le worker.
Une rotation se fait compte par compte : changez d'abord le secret dans SFTPGo, mettez à jour le .env consommateur, redémarrez et contrôlez une collecte avant de révoquer l'ancien secret. Une rotation de clé hôte exige de distribuer la nouvelle clé épinglée avant de retirer l'ancienne instance ; sinon toute collecte est volontairement refusée.
Sauvegarde et restauration
Rien dans le dépôt ne sauvegarde ni ne restaure. Ce qui serait à couvrir, par ordre de conséquence :
| À sauvegarder | Volume | Si perdu |
|---|---|---|
| Données applicatives | mysql_data | Perte définitive et totale du métier |
| Fichiers joints | minio_data | Perte définitive, non reconstructible |
| Certificats | certbot_certs | Reconstructible, dans la limite du quota Let's Encrypt |
| Dépôts et archives CEGI | sftpgo_data | Reprise impossible pour les fichiers non expirés |
| Comptes, clés hôte et configuration SFTPGo | sftpgo_config | Accès de dépôt et clé épinglée à reprovisionner |
Le reste est reconstructible depuis le dépôt et le fichier d'environnement. Ce dernier est non suivi et n'existe que sur le serveur : il n'est reconstructible qu'à partir du gabarit, secrets à redéfinir.
Manques d'exploitation
| Manque | Conséquence |
|---|---|
| Aucune sauvegarde définie dans le dépôt | Une perte de volume est une perte de données |
| Aucune supervision ni alerte | Un arrêt de service n'est vu que par un usager |
| Pas de séparation des configurations par environnement | Les réglages de durcissement HTTPS du § 05 ne peuvent pas être activés sans casser le développement local |
| Aucun environnement de production | main est contrôlée, mais pas déployée |
Ces quatre points sont des travaux identifiés, non des oublis. La séparation des configurations est celle qui débloque les autres : c'est elle qui permettrait d'activer la redirection HTTPS, HSTS et les cookies Secure en production sans toucher au développement local.