site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler

J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en
reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai,
c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et
ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan.

SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que
la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent
du registre des applications, les gardes ont suivi le plan (4 controles negatifs).

Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout :
  - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ;
  - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ;
  - le certificat, PUBLIC par nature, restait en 0600 ;
  - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ;
  - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ;
  - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ;
  - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ;
  - le flux declarait 3000 en dur.

Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une
permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher
s'ecrivait vide.

ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec
CAP_NET_BIND_SERVICE, et son URL n'a plus de port.

Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200.

42 preuves vertes, flux coherents (34 roles, 92 flux).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-25 13:15:34 -04:00
parent add94f2cbd
commit 43666cff6e
13 changed files with 452 additions and 134 deletions

View file

@ -1,5 +1,68 @@
# CHANGELOG — Set-OPS
## 2026-08-25 — Un SITE a bien un plan
J'ai passé la journée à répéter qu'un SITE n'est pas un plan. **C'était faux**, et le
dépôt le démontrait à mesure : j'ai reconstruit un plan morceau par morceau dans
`underlay.yml` — machines, services, variables, intégrations — sans jamais le nommer.
Ce qui est vrai, c'est qu'un site ne **dérive** pas. Un tenant tire tout de son `index` ;
un site déclare ses adresses, parce qu'il *est* le terrain. Il ne passe donc pas par
`instancier` et ne partage pas la superclasse du tenant. Mais « ne pas dériver » n'est
pas « ne pas avoir de plan » — et faute d'avoir fait la distinction, chaque manque est
arrivé par surprise au lieu d'être prévisible.
```
SITE-Chezlepro/
plan/10-intrants.yml domaine, organisation, rebond, clé, amorçage, exemptions
plan/serveurs.yml 5 machines — adressage DÉCLARÉ, pas dérivé
plan/applications.yml 7 services, leur placement, et leurs `expose:`
underlay.yml la fabric seule : ce qui est MESURÉ
```
`underlay.yml` décrit ce qui est **mesuré**, le plan ce qui est **voulu**. Les groupes
viennent du registre des applications, comme chez un tenant. Les gardes ont suivi le plan
— une garde loin de ce qu'elle garde finit par garder autre chose — et quatre contrôles
négatifs les éprouvent, dont une collision d'IP avec la frontière.
### Ce que le site a fait tomber, et qu'aucun tenant ne pouvait révéler
Le site est le premier écosystème qui n'a que le strict nécessaire : pas de plan (jusqu'à
aujourd'hui), pas de Keycloak, pas de PostgreSQL, pas d'edge, pas de rôles colocalisés.
| défaut | pourquoi il était invisible |
|---|---|
| `client_pki` codait `infra-pki-01` **en dur** | la nomenclature d'un tenant nomme toujours sa PKI ainsi — le littéral avait raison par coïncidence |
| aucun moyen pour un service non-root de lire la clé | nginx, Postfix, Dovecot lisent en root avant de déprivilégier ; Forgejo tourne en `git` d'emblée |
| le certificat — **public par nature** — restait en `0600` | même cause |
| l'unité Forgejo n'avait pas d'`ExecReload` | chez un tenant c'est nginx qui consomme le cert, et il sait se recharger |
| le rôle Forgejo ne savait pas servir TLS | il y avait toujours un edge devant |
| le certificat ne couvrait pas le nom de **service** | `expose:` n'existait pas, faute de plan |
| chargement des registres en **tout-ou-rien** | `domaines.yml` existe toujours chez un tenant |
| le flux déclarait `3000` en dur | vrai tant qu'il y avait toujours un edge |
*Le message d'erreur accusait presque toujours autre chose : le DNS quand c'était un nom
faux, une permission de fichier quand c'était un port privilégié, rien du tout quand le
plancher s'écrivait vide.*
### `ROOT_URL` est gravée dans les URL de clonage
La forge servait son TLS sur `3000` — le port qu'elle écoute *derrière* un edge. Coût
réel : chaque écosystème descendant aurait porté `:3000` dans son `origin`, pour toujours.
Elle sert désormais sur **443**, `ROOT_URL = https://forge.genese.internal/`, avec
`CAP_NET_BIND_SERVICE` dans son unité — Forgejo tournant en `git` ne peut pas se lier à un
port privilégié autrement, et l'échec parlait de « permission denied » sur le *port*.
### Sans edge déclaré, le service se sert lui-même
`expositions_des_applications` résolvait l'edge par le **domaine**. Sans `domaines.yml`,
elle rendait `edge: None`, le gabarit cherchait `groups[None]` et écrivait un plancher
**sans alias, sans erreur**. Le repli dit une vérité générale, et corrige aussi un silence
chez les tenants dont un domaine ne déclare pas d'edge.
Chaîne complète, vérifiée depuis le réseau : `expose:` → `/etc/hosts` → SAN du certificat
→ `Verify return code: 0 (ok)` → `https://forge.genese.internal/ → 200`.
## 2026-08-25 — Un SITE n'a pas d'index : le vestige est retiré
Le site portait `index: 17`. Ça n'a jamais voulu dire « voici mon adressage » — un site

View file

@ -21,7 +21,7 @@
| 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). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 91 flux, schéma + matrice OK. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 92 flux, schéma + matrice OK. |
| 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_oauth2_proxy |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -36,7 +36,7 @@
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 8 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 88 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 65 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,10.29.19.41/32,192.168.254.2/32,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 79 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 5 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
@ -44,7 +44,7 @@
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, 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. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 13 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (16 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (23 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 0 application(s) exigeant une base l'ont toutes (0 entree(s) au registre). |

View file

@ -4,8 +4,23 @@ client_pki_paquets:
client_pki_steppath: "/etc/step"
# AC interne (serveur_step_ca / infra-pki-01).
client_pki_ca_url: "https://infra-pki-01.{{ domaine_interne }}:8443"
# L'AC INTERNE SE DÉRIVE DU GROUPE QUI LA PORTE, elle ne se nomme pas (2026-08-25).
#
# Cette URL écrivait `infra-pki-01` EN DUR. Chez un tenant ça marchait toujours : la
# nomenclature nomme sa PKI ainsi, donc le littéral et la réalité coïncidaient. Le SITE
# est le premier à l'appeler autrement — `site-pki-01` — et les cinq machines ont essayé
# de s'enrôler auprès d'un hôte qui n'existe nulle part :
#
# lookup infra-pki-01.genese.internal ... no such host
#
# Le message accusait le DNS, alors que c'était le nom qui était faux. Un littéral qui a
# raison par coïncidence est un bogue qui attend son premier cas particulier.
#
# `groups['serveur_step_ca']` dit qui porte l'autorité, dans un tenant comme sur un site.
# Pas de repli : sans autorité déclarée, s'adresser à un nom inventé produirait exactement
# la panne qu'on vient de corriger. Le rôle refuse (voir tasks/main.yml).
client_pki_ca_hote: "{{ (groups['serveur_step_ca'] | default([])) | first | default('', true) }}"
client_pki_ca_url: "https://{{ client_pki_ca_hote }}.{{ domaine_interne }}:8443"
client_pki_provisioner: "admin@{{ domaine_interne }}"
# Identite de l'hote.
@ -35,6 +50,27 @@ client_pki_depot_cle_fichier: "/etc/apt/keyrings/smallstep.asc"
client_pki_depot_uri: "https://packages.smallstep.com/stable/debian"
client_pki_depot_suite: "debs"
# QUI PEUT LIRE LA CLE PRIVEE (2026-08-25).
#
# Elle est en `0600 root:root`, et c'est le bon defaut. Les consommateurs TLS habituels —
# nginx, Postfix, Dovecot — demarrent en root, lisent la cle, puis deprivilegient : ils
# n'ont jamais eu besoin d'autre chose.
#
# Forgejo tourne en `git` DES LE DEPART. Sur la forge du site, qui sert le genome sans
# edge devant elle, la cle etait donc illisible par le seul processus qui en a besoin.
#
# Un GROUPE lecteur et `0640` reglent ca sans ouvrir la cle a tout le monde. On declare le
# groupe explicitement, service par service : elargir par defaut serait exactement le
# genre de commodite qui finit par rendre une cle privee lisible par `nogroup`.
client_pki_cle_groupe: "root"
client_pki_cle_mode: "0600"
# LE CERTIFICAT, LUI, EST PUBLIC — et l'etait deja avant d'etre sur ce disque : il est
# presente a chaque poignee de main, a quiconque se connecte. Le garder en `0600` ne
# protegeait rien et empechait tout service non-root de le lire. Le certificat racine est
# d'ailleurs deja en `0644` juste a cote, pour la meme raison.
client_pki_cert_mode: "0644"
# Services a recharger apres un renouvellement de cert : les VRAIS consommateurs
# (nginx sur l'edge, postfix/dovecot sur le mail, slapd sur l'annuaire). Sans ca,
# le cert est renouvele sur disque mais le service sert l'ancien jusqu'a un reload.

View file

@ -1,4 +1,22 @@
---
# SANS AUTORITÉ DÉCLARÉE, ON NE DEVINE PAS SON NOM.
#
# L'URL de l'AC se dérivait d'un littéral (`infra-pki-01`), vrai chez tout tenant par
# coïncidence de nomenclature. Le premier écosystème à nommer sa PKI autrement a vu ses
# cinq machines s'enrôler auprès d'un hôte inexistant, et le message accusait le DNS.
#
# Elle se dérive maintenant de `groups['serveur_step_ca']`. Si ce groupe est vide, il n'y
# a pas d'autorité du tout : le dire ici vaut mieux que de laisser `step ca bootstrap`
# échouer sur un nom tronqué, trois tâches plus loin, en parlant de résolution.
- name: Une autorité de certification est-elle déclarée ?
ansible.builtin.assert:
that:
- client_pki_ca_hote | length > 0
fail_msg: >-
Aucun hôte ne porte `serveur_step_ca` dans cet inventaire : il n'y a pas d'autorité
interne à qui demander un certificat. Déclarer une PKI, ou exempter cet écosystème
de `client_pki` — mais avec une raison, jamais en silence.
# L'empreinte du root CA est la SOURCE DE VÉRITÉ de l'autorité elle-même : on la
# dérive à chaud (robuste au from-zero — une AC régénérée a une empreinte neuve).
# `client_pki_ca_fingerprint_override` permet d'épingler explicitement si besoin.
@ -179,6 +197,32 @@
changed_when: true
notify: Recharger les consommateurs du cert
# POSÉ À CHAQUE PASSAGE, PAS SEULEMENT À L'ÉMISSION.
#
# `step ca certificate` réécrit la clé avec ses propres droits, et le renouvellement
# 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,
# sans rapport visible avec cette tâche.
- name: Donner accès à la clé privée au service qui doit la lire
ansible.builtin.file:
path: "{{ client_pki_cle }}"
owner: root
group: "{{ client_pki_cle_groupe }}"
mode: "{{ client_pki_cle_mode }}"
when: client_pki_cle_groupe != 'root' or client_pki_cle_mode != '0600'
notify: Recharger les consommateurs du cert
# 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
# parle de permission sur un fichier que rien ne justifie de proteger.
- name: Rendre le certificat d'hôte lisible (il est public par nature)
ansible.builtin.file:
path: "{{ client_pki_cert }}"
owner: root
group: root
mode: "{{ client_pki_cert_mode }}"
notify: Recharger les consommateurs du cert
- name: Deployer l'unite systemd de renouvellement
ansible.builtin.template:
src: cert-renewer@.service.j2

View file

@ -44,18 +44,29 @@
- hosts_statiques_publier_expositions | bool
- setops_plan_dir is defined
# CHAQUE REGISTRE EST CHARGÉ POUR LUI-MÊME (2026-08-25).
#
# La condition exigeait que les DEUX fichiers existent (`length == 2`). Un tout-ou-rien
# sans raison : `domaines.yml` déclare les domaines PUBLICS, et un écosystème peut
# légitimement n'en avoir aucun — c'est le cas du SITE, dont tous les services sont
# internes.
#
# Conséquence : `applications.yml` n'était pas chargé non plus, la dérivation rendait une
# liste vide, et le plancher s'écrivait SANS AUCUN ALIAS — sans erreur. Le certificat de
# la forge portait `forge.genese.internal` et ce nom ne résolvait nulle part.
#
# On charge donc ce qui existe, fichier par fichier. Un registre absent n'est pas une
# faute ; il est simplement absent.
- name: Charger les registres applications et domaines (si présents)
ansible.builtin.include_vars:
file: "{{ item }}"
loop:
- "{{ setops_plan_dir }}/applications.yml"
- "{{ setops_plan_dir }}/domaines.yml"
file: "{{ item.item }}"
loop: "{{ hosts_statiques_plan.results | default([]) }}"
loop_control:
label: "{{ item | basename }}"
label: "{{ item.item | basename }}"
when:
- hosts_statiques_actif | bool
- hosts_statiques_publier_expositions | bool
- (hosts_statiques_plan.results | default([]) | selectattr('stat.exists', 'defined') | selectattr('stat.exists') | list | length) == 2
- item.stat.exists | default(false)
- name: Dériver les alias d'exposition (FQDN exposés → edge qui les sert)
ansible.builtin.set_fact:

View file

@ -25,6 +25,24 @@ serveur_forgejo_branding: false
serveur_forgejo_http_addr: "0.0.0.0"
serveur_forgejo_http_port: 3000
# --- SERVIR TLS SOI-MEME, QUAND IL N'Y A PAS D'EDGE DEVANT (2026-08-25) --------
#
# Le patron habituel est un nginx d'edge qui termine le TLS et parle en clair a la forge
# sur la boucle locale. Le gabarit posait donc `ROOT_URL = https://…` sans jamais
# configurer de certificat : c'etait vrai PARCE QUE quelqu'un d'autre s'en chargeait.
#
# La forge du SITE n'a pas d'edge devant elle — le site n'en a pas — et elle porte LE
# GENOME. Le code qui fabrique tous les ecosystemes voyageait donc en clair sur le
# reseau, alors que l'AC du site tourne a trois adresses de la.
#
# `false` par defaut : chez un tenant, rien ne change, l'edge garde son role. C'est
# l'ecosysteme sans edge qui doit le demander.
serveur_forgejo_tls: false
# Le certificat de la MACHINE, pose par `client_pki` — pas un cert propre a la forge.
# Une machine, une identite : c'est ce que l'AC a signe, avec ses SAN.
serveur_forgejo_tls_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
serveur_forgejo_tls_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
# Base de donnees : resolue depuis le registre par le groupe consommateur.
serveur_forgejo_groupe: "serveur_forgejo"
serveur_forgejo_db_host: "" # dérivé (FQDN) par resoudre_base au déploiement

View file

@ -1,12 +1,42 @@
---
# Flux réseau de Forgejo (forge Git). Voir docs/flux-conception.md.
flux:
# LE PORT DE LA FORGE N'EST PAS LE MÊME PARTOUT (2026-08-25).
#
# `3000` est le port qu'elle écoute DERRIÈRE un edge, en clair : le nginx termine le TLS
# et lui parle sur la boucle locale. C'était vrai de toutes les forges tant qu'il y avait
# toujours un edge devant.
#
# La forge du SITE n'en a pas — le site n'a pas d'edge — et sert son propre TLS sur le
# port du schéma, `443`. Écrire `3000` en dur ici serait donc devenu faux pour elle,
# sans que rien ne le signale : ce flux ne traverse pas la frontière, donc aucun devis
# ne s'en plaint. Une déclaration fausse qui ne gêne personne est une déclaration qui
# dérive jusqu'au jour où quelqu'un s'y fie.
#
# `derive` dit la vérité : le port vient de `serveur_forgejo_http_port`, et
# `verifier_ports` sait qu'il n'y a pas d'écoute fixe à comparer.
# DEUX ENTREES, PARCE QUE CE SONT DEUX SITUATIONS — pas une ligne qui dirait « ça
# dépend ». Le vocabulaire des flux n'a pas de mot pour l'indécision, et c'est tant
# mieux : un flux se lit pour savoir ce qui circule et comment. Une valeur ambiguë
# rendrait l'audit impossible sans aller lire le déploiement.
- sens: ingress
port: 3000
protocole: tcp
pair: edge
chiffrement: clair
raison: "Interface web + Git HTTP servis via l'edge (TLS terminé à l'edge)."
raison: >-
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.
- sens: ingress
port: derive
protocole: tcp
pair: flotte
chiffrement: tls
raison: >-
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.
- sens: egress
port: 5432
protocole: tcp

View file

@ -6,9 +6,19 @@ WORK_PATH = {{ serveur_forgejo_data }}
[server]
DOMAIN = {{ serveur_forgejo_hostname }}
ROOT_URL = https://{{ serveur_forgejo_hostname }}/
{# ROOT_URL EST GRAVEE DANS LES URL DE CLONAGE, les webhooks et les courriels. Un port
qui n'est pas celui du schema y reste pour toujours : chaque ecosysteme descendant
porterait `:3000` dans son `origin`. On ne l'ecrit donc que s'il est vraiment atypique. #}
ROOT_URL = https://{{ serveur_forgejo_hostname }}{{ (':' ~ serveur_forgejo_http_port | string) if (serveur_forgejo_tls and serveur_forgejo_http_port | int != 443) else '' }}/
HTTP_ADDR = {{ serveur_forgejo_http_addr }}
HTTP_PORT = {{ serveur_forgejo_http_port }}
{% if serveur_forgejo_tls %}
; TLS SERVI PAR LA FORGE ELLE-MEME : il n'y a pas d'edge devant elle. Le certificat est
; celui de la MACHINE, pose et renouvele par `client_pki` depuis l'AC interne.
PROTOCOL = https
CERT_FILE = {{ serveur_forgejo_tls_cert }}
KEY_FILE = {{ serveur_forgejo_tls_cle }}
{% endif %}
SSH_DOMAIN = {{ serveur_forgejo_hostname }}
[database]

View file

@ -10,6 +10,28 @@ User={{ serveur_forgejo_utilisateur }}
Group={{ serveur_forgejo_utilisateur }}
WorkingDirectory={{ serveur_forgejo_data }}
ExecStart={{ serveur_forgejo_binaire }} web --config {{ serveur_forgejo_config }}
; RELOAD GRACIEUX PAR SIGHUP (2026-08-25).
;
; Sans `ExecReload`, systemd repond « Job type reload is not applicable for unit
; forgejo.service » — et le handler qui recharge les consommateurs apres un
; renouvellement de certificat ECHOUE. Le cert serait renouvele sur disque et servi
; perime en memoire : la cicatrice est deja dans ce depot, on ne la refait pas.
;
; Forgejo traite SIGHUP comme un redemarrage gracieux : il reprend ses ecoutes, donc
; relit le certificat, sans couper les connexions en cours.
{% if serveur_forgejo_http_port | int < 1024 %}
; SE LIER A UN PORT PRIVILEGIE SANS ETRE ROOT.
;
; Forgejo tourne en `{{ serveur_forgejo_utilisateur }}` des le demarrage — il ne peut donc
; pas ouvrir un port < 1024 sans y etre autorise. `CAP_NET_BIND_SERVICE` accorde
; exactement ce droit, et rien d'autre : pas de root, pas de setuid.
;
; Sans elle, le service echoue au `listen` avec « permission denied » sur le PORT, un
; message qui ressemble a s'y meprendre a celui d'un fichier illisible.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
{% endif %}
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
Environment=USER={{ serveur_forgejo_utilisateur }}

View file

@ -480,7 +480,18 @@ def expositions_des_applications(applications: dict, domaines: dict, edge: str |
for fqdn in (app.get("expose") or []):
dom = domaine_parent(fqdn, doms)
conf = doms.get(dom, {}) if dom else {}
edge_dom = conf.get("edge")
# SANS EDGE DECLARE, LE SERVICE SE SERT LUI-MEME (2026-08-25).
#
# `edge` se resout par le DOMAINE : chez un tenant, `domaines.yml` dit quel
# nginx front telle zone, et tout FQDN de cette zone y aboutit. Le SITE n'a
# pas d'edge — chaque service ecoute lui-meme, avec son propre certificat.
#
# Sans ce repli, la derivation rendait `edge: None` : le gabarit de
# `hosts_statiques` cherchait alors `groups[None]`, ne trouvait rien, et
# ecrivait un plancher SANS alias. Le certificat de la forge portait
# `forge.genese.internal`, et ce nom ne resolvait nulle part — du TLS correct
# sur un nom que personne ne pouvait appeler.
edge_dom = conf.get("edge") or app.get("groupe")
if edge is not None and edge_dom != edge:
continue
resultat.append({

View file

@ -29,7 +29,9 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import yaml # noqa: E402
import underlay as U # noqa: E402
from inventory_rules import integrations_universelles # noqa: E402
# L'inventaire des tenants passe par ce compte ; le site n'a aucune raison d'en differer.
UTILISATEUR_DEFAUT = "ansible"
@ -52,6 +54,31 @@ GROUPE_SOCLE = "serveur_debian"
GROUPE_FLOTTE = "hotes_actifs"
def plan_dir() -> Path | None:
"""Le `plan/` du SITE, a cote de son `underlay.yml` — derive du symlink, jamais redit.
UN SITE A BIEN UN PLAN (2026-08-25). Ce qui le distingue d'un tenant n'est pas
l'absence de plan mais l'absence de DERIVATION : un tenant tire son adressage de son
`index`, un site le declare, parce qu'il EST le terrain. Il ne passe donc pas par
`instancier` — mais il a bien des machines, des services et des intrants a declarer.
Faute d'avoir fait cette distinction, ce plan a d'abord ete reconstruit morceau par
morceau dans `underlay.yml`, sans etre nomme : chaque manque est arrive par surprise
au lieu d'etre previsible. `underlay.yml` decrit ce qui est MESURE (la fabric) ; le
plan decrit ce qui est VOULU.
"""
c = U.chemin()
return (c.resolve().parent / "plan") if c else None
def _charger(nom: str) -> dict:
d = plan_dir()
f = (d / nom) if d else None
if not f or not f.is_file():
return {}
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def inventaire() -> dict:
u = U.charger()
if u is None:
@ -59,73 +86,132 @@ def inventaire() -> dict:
# etre lance sans site sans que ca ressemble a une panne.
return {"_meta": {"hostvars": {}}}
# La carte est-elle seulement valide ? Un inventaire tire d'un underlay fautif
# produirait des machines plausibles et fausses — le pire des deux mondes.
erreurs = [e for e in U.valider(u) if e.startswith("machine")]
if erreurs:
raise SystemExit("underlay.yml refuse :\n - " + "\n - ".join(erreurs))
# L'IDENTITE DU SITE. Un tenant tire `domaine_interne` et `organisation` de son plan,
# via `instancier`. Un site n'a pas de plan : il les declare dans sa carte, et c'est
# ici qu'ils deviennent des variables d'inventaire. Sans eux, un role partage comme
# `serveur_forgejo` construirait des noms d'hote a partir de rien.
site = u.get("site") or {}
communes: dict = {}
if site.get("domaine"):
communes["domaine_interne"] = site["domaine"]
if site.get("organisation"):
communes["organisation"] = site["organisation"]
# LE REBOND, DECLARE PLUTOT QUE PASSE EN LIGNE DE COMMANDE. Le poste n'a pas de route
# vers le segment du site ; la frontiere y est adjacente. Le jour ou une route directe
# existe, on retire `rebond:` de la carte et rien d'autre ne bouge.
# Le socle ne pose `/etc/resolv.conf` que si cette variable existe. Sans elle, les
# machines naissent sans resolveur — et l'echec ressemble a un probleme de reseau.
if site.get("dns_amorcage"):
communes["dns_amorcage"] = str(site["dns_amorcage"])
if site.get("cle_ssh"):
communes["ansible_ssh_private_key_file"] = site["cle_ssh"]
if site.get("rebond"):
communes["ansible_ssh_common_args"] = (
f"-o ProxyJump={site['rebond']} -o StrictHostKeyChecking=no")
intrants = _charger("10-intrants.yml")
serveurs = (_charger("serveurs.yml") or {}).get("serveurs") or {}
applications = (_charger("applications.yml") or {}).get("applications") or {}
if not serveurs:
return {"_meta": {"hostvars": {}}}
par_reseau = {r.get("nom"): r for r in U.reseaux(u)}
# Ce que TOUTE machine du site recoit, des intrants du plan.
communes: dict = {}
for cle in ("domaine_interne", "organisation", "dns_amorcage"):
if intrants.get(cle):
communes[cle] = str(intrants[cle])
if intrants.get("cle_ssh"):
communes["ansible_ssh_private_key_file"] = intrants["cle_ssh"]
if intrants.get("rebond"):
communes["ansible_ssh_common_args"] = (
f"-o ProxyJump={intrants['rebond']} -o StrictHostKeyChecking=no")
# Les integrations universelles, moins celles que le site ne peut pas honorer.
exemptees = dict(intrants.get("integrations_exemptes") or {})
universelles = [r for r in integrations_universelles() if r not in exemptees]
# LES EXPOSITIONS : le nom du SERVICE, pas celui de la machine. Un nom de service
# survit au demenagement du service ; un nom de machine, non.
#
# `edge` nomme le GROUPE qui sert ce FQDN — c'est ce qu'attend `hosts_statiques`.
# Chez un tenant c'est l'edge nginx, qui termine le TLS pour tout le monde. Le site
# n'a pas d'edge : chaque service se sert lui-meme, donc le groupe est le sien. La
# forme reste la meme, ce qui la remplit change.
expositions: list[dict] = []
for nom_app, app in applications.items():
for fqdn in (app.get("expose") or []):
expositions.append({"fqdn": str(fqdn), "edge": str(app.get("groupe") or ""),
"application": nom_app})
# --- LES GARDES DU PLAN, LA OU LE PLAN EST LU ----------------------------
#
# Elles vivaient dans `underlay.valider()` tant que les machines habitaient la carte.
# Le plan les emporte avec lui : une garde loin de ce qu'elle garde finit par garder
# autre chose. Elles refusent ici, avant que quoi que ce soit ne soit clone.
erreurs: list[str] = []
noeuds = {h.get("nom") for h in U.hotes(u) if h.get("role") == "hyperviseur"}
vus_ip: dict[str, str] = {str(h.get("ip")): str(h.get("nom")) for h in U.hotes(u)}
vus_vmid: dict[int, str] = {}
for nom, srv in serveurs.items():
if str(srv.get("etat", "actif")) != "actif":
continue
r = par_reseau.get(srv.get("reseau"))
if not r:
erreurs.append(f"serveur '{nom}': reseau '{srv.get('reseau')}' inconnu de "
f"`underlay.yml` — la fabric est la source de ces valeurs")
continue
if not str(r.get("pont", "") or "").strip():
erreurs.append(f"serveur '{nom}': le reseau '{r.get('nom')}' ne declare aucun "
f"`pont:` — aucun pont d'hyperviseur ne le porte, une VM y "
f"naitrait sourde")
if srv.get("noeud") not in noeuds:
erreurs.append(f"serveur '{nom}': noeud '{srv.get('noeud')}' n'est pas un "
f"hyperviseur declare dans `underlay.hotes`")
ip = str(srv.get("ip") or "")
if ip in vus_ip:
erreurs.append(f"serveur '{nom}': IP {ip} deja prise par '{vus_ip[ip]}'")
vus_ip[ip] = nom
vmid = srv.get("vmid")
if not isinstance(vmid, int):
erreurs.append(f"serveur '{nom}': `vmid` absent ou non entier")
elif vmid in vus_vmid:
erreurs.append(f"serveur '{nom}': vmid {vmid} en double avec "
f"'{vus_vmid[vmid]}'")
else:
vus_vmid[vmid] = nom
for nom_app, app in applications.items():
if app.get("hote") not in serveurs:
erreurs.append(f"application '{nom_app}': hote '{app.get('hote')}' n'est pas "
f"declare dans `plan/serveurs.yml`")
sans_service = [n for n in serveurs
if not any(a.get("hote") == n for a in applications.values())]
if sans_service:
erreurs.append("serveur(s) sans aucune application : " + ", ".join(sorted(sans_service))
+ " — une machine du site existe POUR un role")
if erreurs:
raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs))
hostvars: dict[str, dict] = {}
groupes: dict[str, list[str]] = {}
for m in U.machines(u):
nom = m["nom"]
r = par_reseau.get(m.get("reseau"), {})
for nom, srv in serveurs.items():
if str(srv.get("etat", "actif")) != "actif":
continue
r = par_reseau.get(srv.get("reseau")) or {}
hostvars[nom] = {
"ansible_host": str(m["ip"]),
"ansible_user": m.get("utilisateur", UTILISATEUR_DEFAUT),
# De quoi qu'un role puisse se situer sans redemander la carte.
"site_reseau": m.get("reseau"),
"ansible_host": str(srv["ip"]),
"ansible_user": srv.get("utilisateur", UTILISATEUR_DEFAUT),
"site_reseau": srv.get("reseau"),
"site_pont": r.get("pont"),
"site_noeud": m.get("noeud"),
"proxmox_vmid": m.get("vmid"),
# CE QUI DISTINGUE CES MACHINES DE TOUTES LES AUTRES. Un role partage entre
# un site et un tenant peut avoir besoin de savoir de quel cote il tourne —
# par exemple pour ne PAS chercher un plan la ou il n'y en a pas.
"site_noeud": srv.get("noeud"),
"proxmox_vmid": srv.get("vmid"),
# L'espace d'adressage de cet ecosysteme. Un tenant le derive de son index ;
# le site le tient du reseau ou vivent ses machines. Meme sens, autre source.
# `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger.
"setops_supernet": str(r.get("sous_reseau") or ""),
# Un role partage peut avoir besoin de savoir de quel cote il tourne.
"setops_site": True,
# Le plan du site, pour les roles qui lisent des registres.
"setops_plan_dir": str(plan_dir() or ""),
"hosts_statiques_expositions": expositions,
**communes,
# CE QUE LA MACHINE DECLARE POUR SES PROPRES ROLES.
#
# Un tenant configure ses services par son plan, que `instancier` traduit en
# group_vars. Le site n'a pas de plan — c'est tout le sens de « un SITE n'est
# pas un plan » — mais ses machines ont quand meme des choix a faire : la
# forge du genome tourne en SQLite, sans annuaire ni PostgreSQL derriere elle.
#
# Ces valeurs viennent donc de la carte, a cote de la machine qu'elles
# concernent. En dernier pour qu'elles l'emportent : ce que la machine declare
# d'elle-meme prime sur ce que le site declare pour tous.
**(m.get("variables") or {}),
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le
# plan declare pour toutes.
**(srv.get("variables") or {}),
}
groupes.setdefault(GROUPE_SOCLE, []).append(nom)
groupes.setdefault(GROUPE_FLOTTE, []).append(nom)
for service in m.get("services") or []:
groupes.setdefault(service, []).append(nom)
for integ in universelles:
groupes.setdefault(integ, []).append(nom)
for integ in (srv.get("integrations") or []):
groupes.setdefault(str(integ), []).append(nom)
out: dict = {g: {"hosts": h} for g, h in groupes.items()}
# LES GROUPES VIENNENT DU REGISTRE DES APPLICATIONS, comme chez un tenant.
for app in applications.values():
hote, groupe = app.get("hote"), app.get("groupe")
if hote in hostvars and groupe:
groupes.setdefault(str(groupe), []).append(hote)
out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()}
out["site"] = {"hosts": sorted(hostvars)}
out["_meta"] = {"hostvars": hostvars}
return out

View file

@ -62,7 +62,44 @@ def materialisation() -> dict:
def _machines() -> dict[str, dict]:
return {m["nom"]: m for m in U.machines(U.charger())}
"""Les machines du PLAN du site — plus de l'underlay (2026-08-25).
Elles y avaient vecu quelques heures, faute d'avoir vu qu'un SITE A BIEN UN PLAN. Ce
qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence de DERIVATION.
`underlay.yml` decrit ce qui est MESURE ; le plan, ce qui est VOULU.
"""
import site_inventaire as SI
d = SI.plan_dir()
f = (d / "serveurs.yml") if d else None
if not f or not f.is_file():
raise SystemExit(
"Aucun `plan/serveurs.yml` a cote de `underlay.yml` : le site n'a pas de plan, "
"il n'y a donc aucune machine a materialiser.")
import yaml
srv = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("serveurs") or {}
return {nom: {**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"}
def _services_par_hote() -> dict[str, list[str]]:
"""Quels roles chaque machine porte — lu dans `plan/applications.yml`.
Les services ne vivent plus sur la machine mais dans le registre des applications,
comme chez un tenant : une machine dit OU elle est, une application dit CE QU'ELLE
FAIT et sur quelle machine. La jointure se fait ici.
"""
import site_inventaire as SI
import yaml
d = SI.plan_dir()
f = (d / "applications.yml") if d else None
if not f or not f.is_file():
return {}
apps = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return {h: sorted(g) for h, g in out.items()}
def _reseau_de(machine: dict) -> dict:
@ -135,10 +172,11 @@ def main() -> int:
return 0
print("{:16s} {:18s} {:16s} {:8s} {:>7s} {}".format(
"machine", "reseau", "adresse", "noeud", "vmid", "services"))
services = _services_par_hote()
for nom, m in ms.items():
print("{:16s} {:18s} {:16s} {:8s} {:>7s} {}".format(
nom, str(m.get("reseau")), str(m.get("ip")), str(m.get("noeud")),
str(m.get("vmid")), ", ".join(m.get("services") or [])))
str(m.get("vmid")), ", ".join(services.get(nom, []))))
else:
for k, v in parametres(a.machine).items():
print(f"{k}={shlex.quote(v)}")

View file

@ -21,9 +21,11 @@ Trois cas, et un seul est une erreur :
- dans le supernet d'un AUTRE tenant -> ERREUR : collision entre sites
- hors de tout supernet (10.0.x, 192.168.x) -> conforme (heritage, et stockage)
Le site declare son index par `underlay.index`. **Sans lui**, on retombe sur la regle
stricte d'avant D-77 (aucun chevauchement) : c'est le comportement sur pour un
underlay qui n'a pas encore migre.
UN SITE N'A PAS D'INDEX (2026-08-25). Il en portait un, et c'etait un vestige : il ne
derive rien, ses machines vivent sur un reseau de fabric. Cette valeur ne disait qu'une
chose — quel supernet de tenant est le sien — et elle le disait pour le site ENTIER alors
qu'un seul reseau etait concerne. L'exception se declare desormais sur le RESEAU, et elle
NOMME le tenant : `bande_basse_de: OPS-Chezlepro`, verifie contre le registre `tenants:`.
DESTINATION OU CHEMIN (D-78). Ce qui sort de l'espace derive n'est pas « le stockage »
mais tout reseau qui n'est jamais une DESTINATION. Le critere n'est pas la presence de
@ -104,22 +106,17 @@ def hotes(underlay: dict | None) -> list[dict]:
def machines(underlay: dict | None) -> list[dict]:
"""LES MACHINES DE L'HEBERGEUR — le site, et rien d'autre.
"""TOUJOURS VIDE : les machines du site vivent dans son PLAN (2026-08-25).
UN SITE N'EST PAS UN PLAN (2026-08-24). Un tenant se DERIVE : de son seul `index`
descendent son supernet, ses VLAN, ses VMID, ses VNet. Un site ne derive de rien —
il n'a pas d'index, il EST le terrain sur lequel les tenants derivent.
Elles ont habite `underlay.yml` quelques heures, faute d'avoir vu qu'un SITE A BIEN
UN PLAN. Ce qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence
de DERIVATION : un tenant tire son adressage de son `index`, un site le declare.
Les faire passer par le generateur de tenants imposait une branche « si l'adresse est
declaree, elle gagne » : deux mondes dans une meme moulinette, un `nomenclature.yml`
de site vide de sens, et un site exclu des devis par ABSENCE d'index plutot que par
nature. Une exclusion fondee sur un manque casse au premier ajout innocent.
Ces machines sont donc du MOBILIER DE FABRIC, declare a cote des switches et des
hyperviseurs qui les portent. Meme fichier, meme carte, meme secrets. Ce qui se
partage avec les tenants, ce sont les ROLES — pas la forme du plan.
Cet accesseur reste pour qu'un appelant oublie ne trouve pas un attribut manquant,
mais il ne ment pas : la carte ne porte plus de machines. Voir
`site_inventaire.plan_dir()` et `plan/serveurs.yml`.
"""
return (underlay or {}).get("machines", []) or []
return []
FABRIC_DEFAUT = "principal"
@ -717,54 +714,6 @@ def valider(underlay: dict | None,
except (ValueError, KeyError):
erreurs.append(f"hote '{hn}': IP absente ou invalide")
# --- LES MACHINES DE L'HEBERGEUR ----------------------------------------
#
# Rien ici ne se derive : tout est declare, et donc tout se verifie. La garde est
# plus stricte que celle des tenants pour cette raison meme — un tenant qui se trompe
# d'adresse ne peut pas, sa nomenclature le lui interdit ; un site, si.
occupees = {h.get("ip"): h.get("nom", "?") for h in hotes(underlay)}
vus_vmid: dict[int, str] = {}
noms_hyperviseurs = {h.get("nom") for h in hotes(underlay)
if h.get("role") == "hyperviseur"}
for m in machines(underlay):
mn = m.get("nom", "?")
r = par_nom.get(m.get("reseau"))
if not r:
erreurs.append(f"machine '{mn}': reseau '{m.get('reseau')}' inconnu")
continue
# LE PONT EST LA QUESTION QU'ON NE PEUT PAS ESQUIVER. Une machine se rattache a un
# pont d'hyperviseur ; un reseau que AUCUN pont ne porte ne peut voir naitre
# aucune VM. C'est le cas mesure du plan d'administration de Chezlepro : la
# frontiere l'a sur un port a elle, les hyperviseurs n'y touchent pas. Refuser ici
# est la seule facon de ne pas creer une VM sourde.
if not str(r.get("pont", "") or "").strip():
erreurs.append(
f"machine '{mn}': le reseau '{r.get('nom')}' ne declare aucun `pont:` — "
f"aucun pont d'hyperviseur ne le porte, une VM y naitrait sourde")
if m.get("noeud") not in noms_hyperviseurs:
erreurs.append(f"machine '{mn}': noeud '{m.get('noeud')}' n'est pas un "
f"hyperviseur declare dans `hotes`")
try:
ip = ipaddress.ip_address(m["ip"])
if ip not in ipaddress.ip_network(r["sous_reseau"], strict=False):
erreurs.append(f"machine '{mn}': IP {m.get('ip')} hors de {r['sous_reseau']}")
except (ValueError, KeyError):
erreurs.append(f"machine '{mn}': IP absente ou invalide")
else:
if str(ip) in occupees:
erreurs.append(f"machine '{mn}': IP {ip} deja prise par "
f"'{occupees[str(ip)]}'")
occupees[str(ip)] = mn
vmid = m.get("vmid")
if not isinstance(vmid, int):
erreurs.append(f"machine '{mn}': `vmid` absent ou non entier")
elif vmid in vus_vmid:
erreurs.append(f"machine '{mn}': vmid {vmid} en double avec '{vus_vmid[vmid]}'")
else:
vus_vmid[vmid] = mn
if not (m.get("services") or []):
erreurs.append(f"machine '{mn}': aucun `services:` — une machine du site "
f"existe POUR un role, sinon elle n'a pas lieu d'etre")
return erreurs