le site reconstruit depuis zero : 7/7, 0 echec, et seize corrections

SITE-Chezlepro n avait jamais ete rase. La limite qu on repetait partout —
l infrastructure d accueil n a jamais ete reconstruite depuis zero — se
lisait comme de la prudence. C etait seize defauts que rien d autre n aurait
pu reveler.

Un locataire naît dans un monde deja peuple : le site lui fournit paquets,
noms, genome, heure et depot. Un site n a personne au-dessus, sauf sa
frontiere. Onze des seize murs viennent de la.

DEUX CAPACITES QUI N EXISTAIENT PAS. make site-raser — rien ne detruisait les
machines du site, donc la limite etait un trou d outillage. make
forge-amorcer — la forge naît vide et le runner y clone ; l amorcage part du
poste, seul endroit qui detienne alors le genome.

TROIS GARDES QUI VERIFIAIENT LA FORME. La plus couteuse des familles : elles
donnent l apparence d une verification. Le resolveur comparait des adresses
au lieu de mesurer si la resolution aboutit, et protegeait ainsi l etat
casse. L administration etait reconnue a son port. Un flux a deux paires
n obtenait qu une branche.

UN ECART DE SECURITE. Le PostgreSQL du site servait le certificat auto-signe
de Debian, sans reseaux autorises ni hostssl — invisible tant qu aucun client
n exigeait la verification. Le defaut n a pas casse la construction : la
construction a revele le defaut.

UN ACCES ACCIDENTEL. Celui de l exploitant tenait au chevauchement d
adressage que le renumerotage a supprime. Separer les index n a pas cause le
probleme, il a retire le hasard qui le masquait.

La sequence du premier jour est ecrite : runbooks §9, avec les seize murs et
ce que chacun enseigne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-12 19:08:31 -04:00
parent 0c55aafc40
commit bef0655112
13 changed files with 597 additions and 13 deletions

View file

@ -1,5 +1,90 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-09-12 (2) — Le site est reconstruit depuis zero, et il a fallu seize corrections
`SITE-Chezlepro` n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des
semaines, avec un service deja debout a chaque etape. La limite qu'on repetait partout —
« l'infrastructure d'accueil n'a jamais ete reconstruite depuis zero » — se lisait comme de
la prudence.
7 machines 0 echec 0 injoignable 46 couches
16:31 -> 19:00 creation 12 min 08 s, puis HUIT passages de deploiement
### Ce qui rend un site different d'un locataire
Un locataire naît dans un monde deja peuple : le site lui fournit les paquets, les noms, le
genome, l'heure et le depot de sauvegarde. **Un site n'a personne au-dessus de lui**, sauf
sa frontiere. Tout ce qu'un locataire recoit, un site doit se le donner — et pendant qu'il
se le donne, il ne l'a pas.
Onze des quinze murs viennent de la.
### Deux capacites qui n'existaient pas
**`make site-raser`.** `raser.py` ne visait que l'instance active — un locataire. Rien ne
detruisait les machines du site. Ce n'est donc pas que personne n'avait essaye de le
reconstruire : **l'outil n'en offrait pas le moyen**. La limite etait un trou d'outillage,
pas une fatalite. Les quatre verrous de `raser.py` sont repris tels quels.
**`make forge-amorcer`.** Le runner clone le genome depuis SA PROPRE forge, et
`serveur_forgejo` ne cree ni organisation ni depot. A froid la forge naît vide. Le geste
part du POSTE, et c'est structurel : a cet instant il est le seul endroit qui detienne le
genome. Un amorcage vient toujours de l'exterieur de ce qu'il amorce.
### Trois gardes qui verifiaient la forme au lieu du resultat
C'est la famille la plus couteuse : elles donnent l'apparence d'une verification.
**Le resolveur.** « Le `resolv.conf` nomme-t-il deja le resolveur declare ? » — juste en
regime etabli. A froid, les sept machines naissent avec `nameserver <site-dns-01>`, qui est
l'une des sept et n'a aucun resolveur. La garde concluait « elle pointe deja au bon
endroit » et **protegeait l'etat casse**. Elle mesure desormais si la resolution ABOUTIT.
**L'administration reconnue a son port.** `elif "22" in _ports(fl)` — juste tant que le seul
flux administratif etait SSH. L'exploitant declare `admin` sur le 443 de sa forge, la machine
accepte, la frontiere refuse, et les deux couches se croient d'accord.
**Un flux a deux paires n'obtient qu'une branche.** La forge declare `pair: [flotte, admin]`;
la chaine de `elif` rangeait le flux dans le premier cas et la part administrative
disparaissait en silence. La portee administrative s'AJOUTE desormais.
### Un ecart de securite, que seule la construction pouvait montrer
Le PostgreSQL du SITE servait le certificat AUTO-SIGNE du paquet Debian, pas celui de son
AC — `serveur_postgresql_tls_actif` vaut `false` par defaut et le site ne le declarait
nulle part, quand le locataire le met a `true`. Ni `reseaux_autorises`, ni `tls_force`.
Personne ne l'avait vu parce qu'aucun client n'exigeait la verification. Le premier a
l'exiger — l'import du schema Icinga DB — a echoue sur `certificate verify failed`.
**Le defaut n'a pas casse la construction : la construction a revele le defaut.**
### L'acces de l'exploitant etait accidentel
L'ancien plan d'administration `10.17.0.0/24` vivait DANS `10.17.0.0/16`, que les
expositions du site acceptent deja pour les locataires. Sortir le site de ce supernet a
revele qu'**aucune declaration n'avait jamais accorde cet acces**.
C'est l'explication complete d'une mesure du matin meme — « le site a une console deployee
et sans chemin d'acces ». Ce n'etait pas Grafana : c'etait tout le site. La decision de
separer les index n'a pas cause le probleme, elle a **retire le hasard qui le masquait**.
### Deux passages, au minimum
`client_pki` pose les droits d'une cle pour un consommateur qu'une couche ULTERIEURE cree.
Au premier passage le groupe `git` n'existe pas, la cle reste `root:root` — donc FERMEE,
jamais plus ouverte — et Forgejo ne demarre qu'au second.
C'etait d'abord un blocage CIRCULAIRE : l'echec arretait le play avant la couche qui aurait
cree le groupe. Rejouer n'y changeait rien. La tache constate desormais l'absence et
reporte, au lieu d'echouer.
### La sequence est ecrite
`docs/runbooks-exploitation.md` §9 — « Le premier jour d'un site », avec les seize murs et
ce que chacun enseigne. Le seizieme s'est montre en ECRIVANT cette entree : `make
wiki-publier` refuse, parce que Forgejo ne cree `<depot>.wiki.git` qu'a la premiere page.
Sans cette section, le prochain site les rencontrerait tous.
## 2026-09-12 (1) — Un site prend son propre index, et la garde les compte enfin ## 2026-09-12 (1) — Un site prend son propre index, et la garde les compte enfin
Decision de l'exploitant : **sites et locataires se partagent la classe A**, chacun avec Decision de l'exploitant : **sites et locataires se partagent la classe A**, chacun avec

View file

@ -1109,6 +1109,22 @@ genome-pousser: ## Le runner du site pousse le genome sur la forge du site — D
playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS) playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS)
.PHONY: site-creer .PHONY: site-creer
forge-amorcer: ## Amorce la forge d'un SITE neuf avec le genome — CONFIRMER=true
@# LE MAILLON QUI N'EXISTAIT PAS (2026-09-12, premiere reconstruction du site depuis
@# zero). Le runner clone le genome depuis SA PROPRE forge, et `serveur_forgejo` ne
@# cree ni organisation ni depot : a froid la forge naît vide et le clone echoue.
@#
@# Le deploiement n'avait jamais rencontre ce cas — la forge existait depuis le premier
@# jour, remplie a la main, a une date que personne n'a notee.
@#
@# DEPUIS LE POSTE, et c'est structurel : a cet instant il est le SEUL endroit qui
@# detienne le genome. Un amorcage vient toujours de l'exterieur de ce qu'il amorce.
@#
@# export SETOPS_FORGE_MDP=… puis make forge-amorcer CONFIRMER=true
python3 scripts/forge_amorcer.py $(if $(filter true,$(CONFIRMER)),--confirmer) \
$(if $(FORGE),--forge "$(FORGE)")
.PHONY: forge-amorcer
site-raser: ansible-runtime ## DESTRUCTIF : detruit les VM du SITE — CONFIRMER=true ET SITE=<depot> site-raser: ansible-runtime ## DESTRUCTIF : detruit les VM du SITE — CONFIRMER=true ET SITE=<depot>
@# CETTE CIBLE MANQUAIT, ET SON ABSENCE AVAIT UN NOM (2026-09-12). `raser` ne vise que @# CETTE CIBLE MANQUAIT, ET SON ABSENCE AVAIT UN NOM (2026-09-12). `raser` ne vise que
@# l'instance ACTIVE — un locataire. Rien ne detruisait les machines du site : ce n'est @# l'instance ACTIVE — un locataire. Rien ne detruisait les machines du site : ce n'est

View file

@ -21,7 +21,7 @@
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. | | P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. | | P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. | | P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 107 flux, schéma + matrice OK. | | P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 109 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). | | P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site | | P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | | P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. | | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 61 scripts expliques et atteignables, 118 cibles make documentees, 68 roles avec README. | | P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 62 scripts expliques et atteignables, 119 cibles make documentees, 68 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (138 cle(s) declaree(s) par l'instance). | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (138 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (38 genere(s) exempte(s)). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (38 genere(s) exempte(s)). |
@ -53,16 +53,16 @@
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. | | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. | | P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | | P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 57 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 58 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 151 regle(s) du site. | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 153 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | | P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos | | P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | | P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (125 lignes). | | P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (127 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 219 regles `pass`), tous non consignes et tous motives. | | P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 239 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |

View file

@ -29,6 +29,8 @@
| `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). | | `serveur_debian` | ingress | 22 | tcp | flotte, externe | ssh | Plan de gestion : administration et déploiement Ansible par SSH (inter-nœud ; l'accès depuis l'extérieur est filtré à l'OPNsense). |
| `serveur_debian` | ingress | echo-request | icmp | serveur_icinga | n-a | La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications. | | `serveur_debian` | ingress | echo-request | icmp | serveur_icinga | n-a | La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications. |
| `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. | | `serveur_debian` | ingress | frag-needed | icmp | externe | n-a | ICMP « fragmentation nécessaire » entrant : sans lui, un distant ne peut pas nous demander de réduire nos paquets — les transferts se figent. |
| `serveur_debian` | egress | 53 | udp | frontiere | n-a | Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe. Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même. |
| `serveur_debian` | egress | 53 | tcp | frontiere | n-a | Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP donne une résolution qui marche jusqu'à la première réponse tronquée. |
| `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). | | `serveur_debian` | egress | 80 | tcp | externe | clair | Dépôts apt en clair et redirections HTTP des miroirs (l'intégrité vient de la signature des paquets, pas du transport). |
| `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. | | `serveur_debian` | egress | 123 | udp | frontiere | n-a | Synchronisation d'horloge (NTP) contre la frontière, autorité de temps de l'écosystème. Une dérive fait échouer la validation des certificats step-ca et l'authentification SSO. |
| `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. | | `serveur_debian` | egress | 443 | tcp | externe | tls-requis | Dépôts apt en HTTPS (Debian, Smallstep, Grafana, Icinga, Forgejo, Nextcloud) — sans quoi aucun correctif de sécurité n'entre. |
@ -39,7 +41,7 @@
| `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). | | `serveur_dovecot` | egress | 636 | tcp | serveur_openldap | tls-requis | userdb/passdb : Dovecot résout et authentifie les comptes sur l'annuaire (LDAPS). |
| `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. | | `serveur_forge_site` | ingress | 443 | tcp | voisins_site | tls-requis | Servir le génome aux écosystèmes de ce site : c'est de cette forge qu'ils clonent leur moteur, leurs modèles et la carte de la fabric (D-81). Sans ce flux, un écosystème neuf ne peut pas se reproduire. |
| `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. | | `serveur_forgejo` | ingress | 3000 | tcp | edge | clair | Interface web + Git HTTP derrière un edge : le nginx termine le TLS et parle en clair à la forge. C'est le cas de tout tenant. |
| `serveur_forgejo` | ingress | derive | tcp | flotte | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. | | `serveur_forgejo` | ingress | derive | tcp | flotte, admin | tls | Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du schéma (443), avec le certificat de la machine. `derive` parce que le port vient de `serveur_forgejo_http_port` : écrire 3000 en dur ici serait faux pour elle, et rien ne le signalerait puisque ce flux ne traverse pas la frontière. |
| `serveur_forgejo` | egress | 25 | tcp | serveur_postfix | starttls | Notifications courriel (relais via le MTA Postfix). | | `serveur_forgejo` | egress | 25 | tcp | serveur_postfix | starttls | Notifications courriel (relais via le MTA Postfix). |
| `serveur_forgejo` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC et jetons auprès de Keycloak (via son FQDN publié à l'edge). | | `serveur_forgejo` | egress | 443 | tcp | edge | tls-requis | Découverte OIDC et jetons auprès de Keycloak (via son FQDN publié à l'edge). |
| `serveur_forgejo` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Forgejo (verify-full). | | `serveur_forgejo` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base de données Forgejo (verify-full). |
@ -117,7 +119,7 @@
## Synthèse chiffrement ## Synthèse chiffrement
- **clair** : 39 flux - **clair** : 39 flux
- **n-a** : 6 flux - **n-a** : 8 flux
- **ssh** : 8 flux - **ssh** : 8 flux
- **starttls** : 6 flux - **starttls** : 6 flux
- **tls** : 11 flux - **tls** : 11 flux

View file

@ -326,3 +326,93 @@ laissé le premier renommage, jusqu'au passage de `client_pki`.
> Un `INJOIGNABLE` ne condamne pas le service : il dit que **ce poste** n'a pas pu ouvrir > Un `INJOIGNABLE` ne condamne pas le service : il dit que **ce poste** n'a pas pu ouvrir
> la connexion. Les zones du SITE ne sont pas routées depuis le plan d'administration du > la connexion. Les zones du SITE ne sont pas routées depuis le plan d'administration du
> locataire — le mur est la frontière, pas le vhost. > locataire — le mur est la frontière, pas le vhost.
## 9. Le premier jour d'un site — la séquence, et les seize murs
> **Écrit le 2026-09-12**, au sortir de la première reconstruction d'un site depuis zéro.
> Avant elle, `SITE-Chezlepro` n'avait jamais été rasé : il avait été monté par ajouts
> successifs, sur des semaines, avec un service déjà debout à chaque étape.
>
> La limite qu'on répétait — « l'infrastructure d'accueil n'a jamais été reconstruite
> depuis zéro » — se lisait comme de la prudence. C'était **seize défauts** que rien
> d'autre n'aurait pu révéler.
### Ce qui rend un site différent d'un locataire
Un locataire naît dans un monde déjà peuplé : le site lui fournit les paquets, les noms,
le génome, l'heure et le dépôt de sauvegarde. **Un site n'a personne au-dessus de lui**,
sauf sa frontière. Tout ce qu'un locataire reçoit, un site doit se le donner — et pendant
qu'il se le donne, il ne l'a pas.
C'est de là que viennent onze des quinze murs.
### La séquence, dans l'ordre
```bash
# 0. AVANT TOUT — l'état sort du bâtiment
make depot-hors-site VERS=<support hors site>
# 1. Les VM, depuis l'underlay
make site-creer CONFIRMER=true # ~12 min pour 7 machines
# 2. Le résolveur d'amorçage, DÉCLARÉ dans le plan du site
# plan/10-intrants.yml : dns_amorcage: <patte de la frontière>
# Sans lui, les machines pointent sur le DNS du site — qui est l'une d'elles.
# 3. Le déploiement, DEUX FOIS au minimum
ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml \
-e "@$(dirname "$(readlink -f underlay.yml)")/underlay.vault.yml"
# 4. Entre les deux passages : amorcer la forge
export SETOPS_FORGE_MDP=… # jamais en argument de ligne de commande
make forge-amorcer CONFIRMER=true
# 5. Rejouer jusqu'à 0 échec
```
**La voûte se passe en `-e @`** — `make site-appliquer` la dérive du symlink `underlay.yml`,
mais il n'existe aucune cible qui déploie le site EN ENTIER. Un `ansible-playbook` direct
l'oublie, et l'échec parle d'une assertion, jamais d'un fichier manquant.
**Deux passages, au minimum, et ce n'est pas un contournement.** `client_pki` pose les
droits d'une clé pour un consommateur qu'une couche ultérieure crée : au premier passage le
groupe `git` n'existe pas, la clé reste fermée, et Forgejo ne démarre qu'au second.
### Les quinze murs, et ce que chacun enseigne
| # | Le mur | Ce qu'il enseigne |
|---|---|---|
| 1 | Aucun moyen de raser le site | `make site-raser` — la limite était un trou d'outillage, pas une fatalité |
| 2 | La forge naît vide, le runner y clone | `make forge-amorcer` — un amorçage vient de l'extérieur de ce qu'il amorce |
| 3 | `dns_amorcage` pointe sur le DNS du site | le mécanisme existait, la **surcharge** n'avait jamais été posée |
| 4 | La garde du résolveur teste l'adresse écrite | **une adresse écrite ne prouve pas qu'elle répond** |
| 5 | Aucune cible « déployer tout le site » | la voûte n'était jointe nulle part à la séquence complète |
| 6 | `client_pki` bloque sur un groupe absent | blocage **circulaire** : l'échec empêchait d'atteindre ce qui créait le groupe |
| 7 | PostgreSQL du site sans TLS de l'AC | un écart de **sécurité**, révélé par le premier client exigeant `verify-full` |
| 8 | `pg_hba` n'autorisait personne | le site ne déclarait aucun réseau client |
| 9 | `/etc/setops` absent sur le dépôt | un rôle supposait qu'une couche ultérieure était déjà passée |
| 10 | Forgejo attend 300 s une clé illisible | une attente devrait abandonner quand la cause est déjà au journal |
| 11 | La clé SSH de l'exploitant inconnue de la forge | une forge neuve ne connaît personne |
| 12 | L'accès de l'exploitant était **accidentel** | il tenait au chevauchement d'adressage que le renumérotage a supprimé |
| 13 | Le devis reconnaît l'administration à son port | `"22" in ports` plutôt que `"admin" in pairs` |
| 14 | Un flux à deux paires n'obtient qu'une branche | la chaîne de `elif` rangeait `[flotte, admin]` dans un seul cas |
| 15 | Dépôts créés privés, runner anonyme | `could not read Username` — un message qui pointe ailleurs que sa cause |
| 16 | Le wiki n'existe pas sur une forge neuve | Forgejo ne crée `<dépôt>.wiki.git` qu'à la **première page**, posée à la main dans l'interface |
### Le motif
Onze des seize sont **du code juste en régime établi**, faux le premier jour : un résolveur
qui se pointe sur lui-même, une clé dont le consommateur n'existe pas encore, un répertoire
créé par une couche ultérieure, une forge vide qu'on croit remplie.
Trois sont des **gardes qui vérifiaient la forme au lieu du résultat**. C'est la famille la
plus coûteuse : elles donnent l'apparence d'une vérification.
Un seul touchait la sécurité — et il était invisible tant qu'aucun client n'exigeait la
vérification. **Le défaut n'a pas cassé la construction : la construction a révélé le
défaut.**
> **Pour le prochain site.** Poser `dns_amorcage` dès le départ, prévoir deux passages,
> amorcer la forge entre les deux, déclarer `nftables_admin_ssh` — sans quoi l'exploitant ne
> peut pas atteindre ce qu'il vient de construire — et créer la première page du wiki dans
> l'interface avant `make wiki-publier`.

View file

@ -104,6 +104,29 @@
failed_when: false failed_when: false
changed_when: false changed_when: false
# UNE ADRESSE ECRITE NE PROUVE PAS QU'ELLE REPOND (2026-09-12).
#
# La garde ci-dessous se contentait de comparer des ADRESSES : « le resolv.conf
# nomme-t-il deja le resolveur declare ? ». En regime etabli c'est juste — on ne
# clobbere pas un resolveur qui marche. A froid c'est exactement a l'envers.
#
# Mesure a la premiere reconstruction du site depuis zero : les sept machines
# naissent avec `nameserver 10.37.34.11`, l'adresse de `site-dns-01` — qui est
# l'une des sept et n'a encore aucun resolveur. La garde concluait « elle pointe
# deja au bon endroit », n'ecrivait rien, et `apt update` echouait sur les sept.
# La garde protegeait l'etat casse.
#
# ON MESURE DONC LE RESULTAT, pas la forme : le resolveur en place resout-il ?
# C'est la meme lecon que `client_resolveur` enonce a cote — « un resolv.conf qui
# pointe vers un service muet » — appliquee un cran plus tot.
- name: Le résolveur en place répond-il ?
ansible.builtin.command:
argv: ["getent", "ahostsv4", "deb.debian.org"]
register: serveur_debian_resolution
failed_when: false
changed_when: false
timeout: 15
- name: Poser le résolveur d'amorçage (avant tout apt) - name: Poser le résolveur d'amorçage (avant tout apt)
ansible.builtin.copy: ansible.builtin.copy:
dest: /etc/resolv.conf dest: /etc/resolv.conf
@ -144,8 +167,8 @@
- dns_amorcage is defined - dns_amorcage is defined
- dns_amorcage | string | length > 0 - dns_amorcage | string | length > 0
- "'127.0.0.1' not in serveur_debian_resolv_texte" - "'127.0.0.1' not in serveur_debian_resolv_texte"
- serveur_debian_resolveurs_declares # Le resolveur en place ne resout pas : peu importe l'adresse qu'il porte.
| select('in', serveur_debian_resolv_texte) | list | length == 0 - serveur_debian_resolution.rc | default(1) != 0
roles: roles:
# LE PLANCHER AVANT LE PREMIER `apt` (2026-08-25). # LE PLANCHER AVANT LE PREMIER `apt` (2026-08-25).

View file

@ -313,15 +313,55 @@
# automatique aussi. Ne régler les droits qu'au moment où le certificat change les # automatique aussi. Ne régler les droits qu'au moment où le certificat change les
# perdrait au premier renouvellement — une panne qui surviendrait des semaines plus tard, # perdrait au premier renouvellement — une panne qui surviendrait des semaines plus tard,
# sans rapport visible avec cette tâche. # sans rapport visible avec cette tâche.
# LE CONSOMMATEUR N'EXISTE PAS ENCORE AU PREMIER JOUR (2026-09-12).
#
# `client_pki` (couche `pki_client`) precede `serveur_forgejo` (couche `apps`), et c'est
# le paquet de Forgejo qui cree le groupe `git`. Sur une machine deja construite le
# groupe est la, herite d'un passage precedent ; a froid il n'existe pas :
#
# chgrp failed: failed to look up group git
#
# ET C'ETAIT UN BLOCAGE CIRCULAIRE, pas un ordre qui converge : l'echec arretait le play
# pour cet hote AVANT la couche qui aurait cree le groupe. Rejouer n'y changeait rien —
# mesure deux fois, le 2026-09-12.
#
# ON NE FABRIQUE PAS LE GROUPE ICI : le paquet du service le creera avec le GID qu'il
# veut, et un groupe pose d'avance l'obligerait a composer avec le notre. On constate
# son absence, on laisse la cle en `root:root` — donc FERMEE, jamais plus ouverte — et
# le passage suivant, une fois le service installe, donne l'acces.
- name: Le groupe qui doit lire la clé existe-t-il ?
ansible.builtin.getent:
database: group
key: "{{ client_pki_cle_groupe }}"
fail_key: false
register: client_pki_groupe_lu
when: client_pki_cle_groupe != 'root'
changed_when: false
- name: Donner accès à la clé privée au service qui doit la lire - name: Donner accès à la clé privée au service qui doit la lire
ansible.builtin.file: ansible.builtin.file:
path: "{{ client_pki_cle }}" path: "{{ client_pki_cle }}"
owner: root owner: root
group: "{{ client_pki_cle_groupe }}" group: "{{ client_pki_cle_groupe }}"
mode: "{{ client_pki_cle_mode }}" mode: "{{ client_pki_cle_mode }}"
when: client_pki_cle_groupe != 'root' or client_pki_cle_mode != '0600' when:
- client_pki_cle_groupe != 'root' or client_pki_cle_mode != '0600'
- client_pki_cle_groupe == 'root'
or (client_pki_groupe_lu.ansible_facts.getent_group | default({}))
.get(client_pki_cle_groupe) is not none
notify: Recharger les consommateurs du cert notify: Recharger les consommateurs du cert
- name: Dire que l'accès à la clé attend son consommateur
ansible.builtin.debug:
msg: >-
Le groupe « {{ client_pki_cle_groupe }} » n'existe pas encore : la clé reste en
root:root, donc FERMEE. Le service qui doit la lire n'est pas installé — le
passage suivant, après sa couche, lui donnera l'accès.
when:
- client_pki_cle_groupe != 'root'
- (client_pki_groupe_lu.ansible_facts.getent_group | default({}))
.get(client_pki_cle_groupe) is none
# Le certificat est public. `step` l'ecrit en 0600 comme la cle ; on le rend lisible, sans # Le certificat est public. `step` l'ecrit en 0600 comme la cle ; on le rend lisible, sans
# quoi un service non-root echoue sur le CERT apres avoir obtenu la CLE — et le message # quoi un service non-root echoue sur le CERT apres avoir obtenu la CLE — et le message
# parle de permission sur un fichier que rien ne justifie de proteger. # parle de permission sur un fichier que rien ne justifie de proteger.

View file

@ -55,6 +55,42 @@ flux:
partage: true partage: true
raison: "La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications." raison: "La supervision verifie que ce noeud repond (hostalive). Sans lui, elle le tient pour mort et supprime ses notifications."
# LA FRONTIÈRE EST AUSSI L'AUTORITÉ DE NOMS D'AMORÇAGE (2026-09-12).
#
# TROUVÉ À LA PREMIÈRE RECONSTRUCTION DU SITE DEPUIS ZÉRO. Les sept machines du site
# naissent avec `nameserver 10.37.34.11` — `site-dns-01`, qui est elle-même une des
# sept et n'a pas encore de résolveur. `site-dns-01` pointe donc sur elle-même, et
# `apt update` échoue sur les sept :
#
# Erreur temporaire de résolution de « deb.debian.org »
#
# Le locataire n'a jamais ce problème : son résolveur est le SITE, qui existe déjà.
# Le site, lui, n'a personne au-dessus — sauf la frontière, dont l'Unbound tourne et
# écoute sur `*:53`. Il ne manquait que le passage.
#
# CE QUE CETTE RÈGLE NE DIT PAS. « Toute la flotte résout ici, et nulle part ailleurs »
# (`serveur_resolveur/meta/flux.yml`) reste la règle en régime établi : le résolveur
# de l'écosystème est le seul que les machines INTERROGENT, parce que c'est lui que
# leur `/etc/resolv.conf` nomme. Cette règle n'ouvre qu'une possibilité, celle qui
# rend l'amorçage possible — et sans elle, un écosystème neuf ne peut pas naître.
- sens: egress
port: 53
protocole: udp
pair: frontiere
chiffrement: n-a
raison: >-
Résolution de noms à l'amorçage, avant que le résolveur de l'écosystème n'existe.
Sans elle, les machines d'un site neuf ne peuvent pas résoudre leurs dépôts de
paquets — et rien ne peut donc s'installer, y compris le résolveur lui-même.
- sens: egress
port: 53
protocole: tcp
pair: frontiere
chiffrement: n-a
raison: >-
Réponses longues et bascule TCP, obligatoires en DNS. Déclarer l'UDP sans le TCP
donne une résolution qui marche jusqu'à la première réponse tronquée.
# L'HEURE VIENT DE LA FRONTIÈRE, PAS D'INTERNET (2026-09-11). # L'HEURE VIENT DE LA FRONTIÈRE, PAS D'INTERNET (2026-09-11).
# #
# C'était `pair: externe` : les quatorze machines interrogeaient `2.debian.pool.ntp.org`, # C'était `pair: externe` : les quatorze machines interrogeaient `2.debian.pool.ntp.org`,

View file

@ -30,7 +30,16 @@ flux:
- sens: ingress - sens: ingress
port: derive port: derive
protocole: tcp protocole: tcp
pair: flotte # `admin` EN PLUS DE `flotte` (2026-09-12). Sans edge devant elle, la forge du SITE
# n'etait joignable que par les machines de l'ecosysteme — jamais par l'exploitant.
# Ca passait inapercu tant que son plan d'administration vivait dans le supernet d'un
# locataire, que les expositions acceptent deja. Une fois le site sorti de ce
# supernet, l'exploitant ne pouvait plus ni amorcer sa forge ni y pousser le genome.
#
# `admin` se resout depuis `nftables_admin_ssh` : un ecosysteme qui ne declare aucun
# administrateur n'emet aucune regle, et c'est le comportement voulu — une source
# vide ouvrirait le port a tous.
pair: [flotte, admin]
chiffrement: tls chiffrement: tls
raison: >- raison: >-
Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du Sans edge devant elle — la forge du SITE — elle sert son propre TLS sur le port du

View file

@ -378,6 +378,25 @@
src: "{{ serveur_icinga_ca }}" src: "{{ serveur_icinga_ca }}"
register: serveur_icinga_ca_contenu register: serveur_icinga_ca_contenu
# LE REPERTOIRE N'EXISTE PAS AU PREMIER JOUR (2026-09-12). `/etc/setops` est cree par
# `client_sante`, qui vit dans une couche POSTERIEURE. Sur une machine deja construite il
# est la ; a froid, non — et `copy` ne cree pas ses parents :
#
# Destination directory /etc/setops does not exist
#
# On le pose donc ici, avec les memes droits que `client_sante` lui donnerait. Deux roles
# qui creent le meme repertoire ne se genent pas ; un role qui suppose qu'un autre est
# deja passe, si.
- name: Assurer le repertoire d'accueil sur le depot de sauvegarde
when: groups['serveur_backup'] | default([]) | length > 0
ansible.builtin.file:
path: "{{ serveur_icinga_ca_destination_depot | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
delegate_to: "{{ groups['serveur_backup'] | first }}"
- name: Deposer l'AC sur le depot de sauvegarde - name: Deposer l'AC sur le depot de sauvegarde
when: groups['serveur_backup'] | default([]) | length > 0 when: groups['serveur_backup'] | default([]) | length > 0
ansible.builtin.copy: ansible.builtin.copy:

View file

@ -796,6 +796,17 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
# jamais la zone entiere. # jamais la zone entiere.
_portees = [("SETOPS_SITE", z) for z in _portees = [("SETOPS_SITE", z) for z in
sorted({_if_de(m.get("reseau")) for m in _machines_site})] sorted({_if_de(m.get("reseau")) for m in _machines_site})]
# UN FLUX A DEUX PAIRES N'OBTIENT QU'UNE BRANCHE (2026-09-12).
#
# La chaine de `elif` range chaque flux dans UN cas. La forge du site
# declare `pair: [flotte, admin]` : `flotte` capte la premiere, et la
# part administrative disparait sans que rien ne le dise. Mesure a la
# premiere reconstruction — la machine acceptait l'exploitant, la
# frontiere le refusait, et les deux couches se croyaient d'accord.
#
# On AJOUTE la portee administrative au lieu d'en faire un cas exclusif.
if "admin" in _pairs_fl and _admin_site:
_portees += [("SETOPS_ADMIN_SITE", if_gestion)]
elif _entrant and "fabric" in _pairs_fl: elif _entrant and "fabric" in _pairs_fl:
# LA FABRIC PARLE AUSSI AU SITE (2026-09-10). # LA FABRIC PARLE AUSSI AU SITE (2026-09-10).
# #
@ -828,7 +839,18 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
_portees = [("SETOPS_FABRIC", _if_de(r)) for r in _res_fabric] _portees = [("SETOPS_FABRIC", _if_de(r)) for r in _res_fabric]
if not _portees: if not _portees:
continue continue
elif _entrant and "22" in _ports(_fl) and _admin_site: elif _entrant and ("admin" in _pairs_fl or "22" in _ports(_fl)) and _admin_site:
# `admin` N'EST PAS QUE LE SSH (2026-09-12). Cette branche testait le
# PORT — « si c'est du 22, ça vient de l'administration ». Juste tant
# que le seul flux administratif etait SSH.
#
# Mesure a la premiere reconstruction du site : l'exploitant declare
# `admin` sur le 443 de sa forge pour pouvoir l'amorcer, la machine
# accepte, et la frontiere continue de refuser — parce que le devis ne
# reconnaissait l'administration qu'a son port. Deux couches, une seule
# au courant : c'est la panne la plus silencieuse de cette pile.
#
# On teste desormais la PAIRE DECLAREE, et le port reste ce qu'il est.
# Le SSH de gestion arrive par la patte d'ADMINISTRATION de la # Le SSH de gestion arrive par la patte d'ADMINISTRATION de la
# frontiere, pas par celle du site : c'est la que le paquet penetre # frontiere, pas par celle du site : c'est la que le paquet penetre
# le pare-feu. Une regle posee ailleurs ne correspondrait jamais. # le pare-feu. Une regle posee ailleurs ne correspondrait jamais.

212
scripts/forge_amorcer.py Normal file
View file

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Amorce la forge d'un SITE neuf avec le genome. Le maillon qui n'existait pas.
POURQUOI CE SCRIPT EXISTE (2026-09-12, premiere reconstruction du site depuis zero).
Le runner du site clone le genome depuis SA PROPRE forge — `serveur_ops_forge_hote`
derive de `groups['serveur_forgejo']`. L'ordre des couches est juste : `serveur_forgejo`
precede `serveur_ops`. Mais `roles/serveur_forgejo` ne cree NI organisation NI depot : a
froid, la forge naît vide, et le `git clone` du runner echoue apres trois essais.
LE DEPLOIEMENT N'AVAIT JAMAIS RENCONTRE CE CAS parce que la forge existait depuis le
premier jour, remplie a la main, a une date que personne n'a notee. C'est exactement ce
qu'une limite « jamais reconstruit depuis zero » cache : pas une fragilite, un MAILLON
QUI N'AVAIT JAMAIS ETE FABRIQUE PAR LE CODE.
POURQUOI DEPUIS LE POSTE, ET PAS DEPUIS UN ROLE. A cet instant precis, le poste est le
SEUL endroit qui detienne le genome : la forge est vide et le runner n'existe pas encore.
Un role qui tenterait de se servir lui-meme tournerait en rond. C'est un geste d'amorcage,
et un amorcage vient toujours de l'exterieur de ce qu'il amorce.
LA LISTE VIENT DE LA MEME DECLARATION QUE LE RUNNER LIT — `serveur_ops_depots` dans le
plan du site. Ecrire une seconde liste ici la ferait diverger de celle qui compte, et ce
depot passe son temps a supprimer ce genre de paire.
Usage :
python3 scripts/forge_amorcer.py # montre ce qui serait pousse
python3 scripts/forge_amorcer.py --confirmer
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
FRERES = RACINE.parent
sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod # noqa: E402
def _depots_declares() -> list[dict]:
"""`serveur_ops_depots` du plan du SITE — la source que le runner lit."""
import yaml
plan = underlay_mod.lire_plan_site("serveurs.yml") or {}
for srv in (plan.get("serveurs") or {}).values():
d = ((srv.get("variables") or {}).get("serveur_ops_depots"))
if d:
return list(d)
return []
def _api(base: str, chemin: str, methode: str = "GET", corps: dict | None = None,
auth: str = "") -> tuple[int, object]:
req = urllib.request.Request(f"{base}/api/v1{chemin}", method=methode)
req.add_header("Authorization", f"Basic {auth}")
req.add_header("Content-Type", "application/json")
donnees = json.dumps(corps).encode() if corps is not None else None
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # la racine step-ca n'est pas encore au magasin du poste
try:
with urllib.request.urlopen(req, donnees, timeout=25, context=ctx) as r:
return r.status, json.loads(r.read() or b"null")
except urllib.error.HTTPError as e:
return e.code, (e.read() or b"").decode()[:200]
except OSError as e:
return 0, str(e)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--forge", help="URL de la forge (defaut : derivee du plan du site)")
ap.add_argument("--organisation", default="genome")
ap.add_argument("--utilisateur", default="forgejo-admin")
ap.add_argument("--cle-publique",
default=str(Path.home() / ".ssh" / "id_ed25519_ansible_chezlepro.pub"),
help="cle publique a enregistrer dans la forge neuve (pour le push SSH)")
ap.add_argument("--confirmer", action="store_true")
a = ap.parse_args()
depots = _depots_declares()
if not depots:
print("Aucun `serveur_ops_depots` au plan du site : rien a amorcer.")
return 2
# L'hote de la forge vient du plan, comme pour le runner.
import yaml
apps = (underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {}
hote = next((str(x.get("hote")) for x in apps.values()
if str(x.get("groupe")) == "serveur_forgejo"), "")
fqdn = next((str(f) for x in apps.values()
if str(x.get("groupe")) == "serveur_forgejo"
for f in (x.get("expose") or [])), "")
base = a.forge or (f"https://{fqdn}" if fqdn else "")
if not base:
print("La forge du site ne se derive pas du plan : passer --forge.")
return 2
print(f"Forge : {base} (machine {hote or '?'})")
print(f"Organisation : {a.organisation}\n")
manquants, prets = [], []
for d in depots:
local = FRERES / str(d.get("dest"))
if not (local / ".git").is_dir():
manquants.append(f"{d['dest']} — absent du poste ({local})")
else:
prets.append((str(d["depot"]), local, str(d.get("branche") or "main")))
for nom, local, branche in prets:
print(f" {nom:22} <- {local.name:20} branche {branche}")
for m in manquants:
print(f" MANQUANT {m}")
if manquants:
print(f"\nREFUS : {len(manquants)} depot(s) declare(s) n'existent pas sur ce poste.\n"
f"Amorcer une forge avec une liste incomplete la rendrait credible et fausse :\n"
f"le runner clonerait ce qui est la et echouerait sur le reste, plus tard.")
return 2
if not a.confirmer:
print(f"\n{len(prets)} depot(s) seraient crees puis pousses.\n"
f"Relancer avec : make forge-amorcer CONFIRMER=true")
return 0
mdp = os.environ.get("SETOPS_FORGE_MDP", "")
if not mdp:
print("\nREFUS : mot de passe de l'administrateur de la forge absent.\n"
" export SETOPS_FORGE_MDP=\"$(ansible-vault view … | …)\" puis relancer.\n"
"Il n'est pas lu du depot : un secret ne traverse pas un argument de ligne\n"
"de commande, ou il finirait dans l'historique du shell.")
return 2
auth = base64.b64encode(f"{a.utilisateur}:{mdp}".encode()).decode()
# LA FORGE NEUVE NE CONNAIT PERSONNE (2026-09-12). Les remotes `origin` poussent en
# `ssh://git@…` ; une Forgejo fraiche rejette la cle de l'exploitant — `Permission
# denied (publickey)` — parce qu'aucune cle n'y est enregistree. Amorcer sans cela
# revient a creer des depots qu'on ne peut pas remplir.
cle = Path(a.cle_publique)
if cle.is_file():
st, rep = _api(base, "/user/keys", "POST",
{"title": f"amorcage-{cle.stem}", "key": cle.read_text().strip()}, auth)
if st in (201, 200):
print(f" cle publique {cle.name} : enregistree")
elif st == 422:
print(f" cle publique {cle.name} : deja la")
else:
print(f" cle publique {cle.name} : ECHEC {st} {rep}")
return 2
else:
print(f" cle publique absente ({cle}) — le push SSH echouera")
return 2
st, _ = _api(base, f"/orgs/{a.organisation}", auth=auth)
if st == 404:
st, rep = _api(base, "/orgs", "POST", {"username": a.organisation}, auth)
print(f" organisation {a.organisation} : "
+ ("creee" if st in (201, 200) else f"ECHEC {st} {rep}"))
elif st == 200:
print(f" organisation {a.organisation} : deja la")
else:
print(f" organisation {a.organisation} : ECHEC {st}")
return 2
echecs = 0
for nom, local, branche in prets:
st, _ = _api(base, f"/repos/{a.organisation}/{nom}", auth=auth)
if st == 404:
st, rep = _api(base, f"/orgs/{a.organisation}/repos", "POST",
{"name": nom, "private": False}, auth)
# PUBLIC DANS LE PERIMETRE DE LA FORGE, ET C'EST VOULU (2026-09-12).
#
# Cree en `private` au premier essai — par reflexe. Le runner clone
# ANONYMEMENT : la tache `git` de `serveur_ops` ne porte aucun identifiant,
# et l'echec ne dit pas « depot prive », il dit :
#
# could not read Username for 'https://forge.genese.internal'
#
# La forge n'est joignable que depuis les zones du site, les supernets
# locataires et le plan d'administration : « public » y signifie « lisible
# par ceux qui ont deja franchi la frontiere ». Les secrets, eux, sont dans
# des voutes chiffrees — c'est la qu'est la protection, pas dans un drapeau
# de visibilite qui casserait la reproduction.
if st not in (201, 200):
print(f" {nom:22} ECHEC creation {st} {rep}")
echecs += 1
continue
# `--force` JAMAIS : si le depot porte deja quelque chose, on veut le savoir,
# pas l'ecraser. Un amorcage n'a de sens que sur une forge vide.
r = subprocess.run(["git", "-C", str(local), "push", "origin", branche],
capture_output=True, text=True, timeout=180)
if r.returncode == 0:
print(f" {nom:22} pousse ({branche})")
else:
print(f" {nom:22} ECHEC push : {(r.stderr or '').strip().splitlines()[-1][:90]}")
echecs += 1
print(f"\n{len(prets) - echecs}/{len(prets)} depot(s) amorces.")
return 1 if echecs else 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -479,6 +479,36 @@ def inventaire() -> dict:
# Les deux lignes vont ensemble : eteindre l'une sans allumer l'autre # Les deux lignes vont ensemble : eteindre l'une sans allumer l'autre
# laisse un Grafana sans aucune porte. Le role refuse desormais cette # laisse un Grafana sans aucune porte. Le role refuse desormais cette
# combinaison, plutot que de la deployer. # combinaison, plutot que de la deployer.
# LE POSTGRESQL DU SITE ETAIT HORS DE LA DOCTRINE (2026-09-12).
#
# `serveur_postgresql_tls_actif` vaut `false` par defaut, et le tenant le
# met a `true` dans ses group_vars. Le SITE ne le mettait nulle part : son
# PostgreSQL servait le certificat AUTO-SIGNE du paquet Debian
# (`ssl-cert-snakeoil.pem`), pas celui de son AC.
#
# PERSONNE NE L'AVAIT VU parce qu'aucun client du site n'exigeait la
# verification. Le premier a l'exiger — l'import du schema Icinga DB, qui
# se connecte par le FQDN — a echoue sur `certificate verify failed`, et
# c'est ainsi que l'ecart s'est montre. Le defaut n'a pas casse la
# construction : la construction a revele le defaut.
#
# Ce n'est pas un blocage d'amorcage, c'est un trou de zero-confiance
# est-ouest : le site chiffrait sans que personne ne verifie a qui.
# ET LES CLIENTS DOIVENT ETRE NOMMES. Le tenant declare
# `serveur_postgresql_reseaux_autorises: [supernet]` ; le site ne declarait
# rien, donc `pg_hba` n'autorisait que le local :
#
# aucune entree dans pg_hba.conf pour l hote 10.37.36.11,
# utilisateur icingadb, chiffrement SSL
#
# Les zones du site, et elles seules — pas un `0.0.0.0/0` qui rendrait le
# verrou `hostssl` decoratif.
"serveur_postgresql_reseaux_autorises": _zones_du_site,
# `tls_force` pose `hostssl` : toute connexion NON chiffree est refusee.
# On ne l'active qu'apres avoir confirme que le certificat servi vient bien
# de l'AC — c'est fait, mesure a 17:31 ce jour.
"serveur_postgresql_tls_force": True,
"serveur_postgresql_tls_actif": True,
"serveur_grafana_oidc_actif": False, "serveur_grafana_oidc_actif": False,
"serveur_grafana_connexion_locale": True, "serveur_grafana_connexion_locale": True,
# Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y # Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y