From 6a5a49f924ef5d4035032e6d3b1dee6cf6a6fc9f Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 26 Aug 2026 08:52:37 -0400 Subject: [PATCH] site : le runner travaille, et le genome remonte chez lui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serveur_ops` et `serveur_ops_site` sont deployes sur `site-ops-01` : le moteur, les plans des trois tenants, les collections hors ligne, et la voute de l'underlay deposee CHIFFREE. Le site a son runner. DEUX FORMES DE RUNNER, ET UNE SEULE ETAIT PREVUE. `serveur_ops` exigeait un depot `role: instance` et un `serveur_ops_instance` — la forme d'un runner de TENANT, qui pilote un ecosysteme, monte son plan par le lien `instance`, detient sa voute. Un runner de SITE ne pilote rien : il MATERIALISE le terrain que d'autres occuperont, et son inventaire est dynamique. Son plan declarait donc ses depots de tenants en `role: tenant`, a dessein et avec ses raisons ecrites — et le role le refusait au nom d'une exigence qui ne le concernait pas. Il exige desormais que le runner pilote QUELQUE CHOSE, sans prescrire quoi. Et il posait le lien quand meme : `serveur_ops_instance: ""` donnait `instance -> /opt/setops/`, un repertoire qui n'est l'ecosysteme de personne. Un lien qui existe et ne designe rien est pire qu'un lien absent : tout ce qui le suit croit avoir trouve une instance. LE CERTIFICAT COUVRE LES NOMS DU SERVICE. `client_pki` ne mettait dans ses SAN que le FQDN, le nom court et l'IP. Le clonage du genome a bute dessus : « certificate subject name (site-forge-01.genese.internal) does not match target hostname 'forge.genese.internal' ». Le nom declare `expose:` etait publie partout — plancher, zone DNS — et couvert nulle part. Les expositions que cet hote SERT REELLEMENT entrent maintenant dans le certificat. Un certificat qui revendiquerait le nom d'un service rendu ailleurs serait une usurpation, pas une commodite. `make genome-pousser` — LE RUNNER POUSSE, LE POSTE NE ROUTE PAS. La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant ne route pas. On ne perce pas un chemin pour lui : on lui retire le role. Le poste emballe les commits dans un `git bundle` — un fichier, verifiable, qui ne demande aucun reseau — et le runner verifie, avance EN AVANCE RAPIDE SEULEMENT, puis pousse avec SA cle, autorisee en ecriture sur ces depots-la seulement. Ce qui est pousse se DERIVE de `serveur_ops_depots`. `DEPOT=` en cible un seul. Trois pieges rencontres, tous inscrits dans le code : l'outil ne peut pas etre supposé present sur le runner (il ne recevrait cette version qu'APRES la poussee qu'elle sert a faire — le controleur le porte) ; la branche n'est pas toujours `main` (deux depots vivent sur `master`, et le plan le declarait deja) ; le dossier des depots freres contient un espace, d'ou `argv` et non `cmd`. Le juge n'est pas le journal du playbook mais LA FORGE : on demande a son API ce qu'elle porte, et on refuse si ca differe de ce que porte le runner. Six depots verifies. Pourquoi ca comptait : la forge du site etait restee quatre commits derriere le poste, sans que rien ne le signale — dont le correctif qui desarme le pare-feu Proxmox. Un ecosysteme qui se reproduit depuis une forge en retard reproduit ses defauts. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 59 +++++ Makefile | 25 +++ docs/audit/preuve-2026-08-26.md | 83 +++++++ playbooks/maintenance/genome_pousser.yml | 263 +++++++++++++++++++++++ roles/client_pki/defaults/main.yml | 32 ++- roles/serveur_ops/tasks/main.yml | 67 +++++- scripts/genome_colis.py | 129 +++++++++++ 7 files changed, 642 insertions(+), 16 deletions(-) create mode 100644 docs/audit/preuve-2026-08-26.md create mode 100644 playbooks/maintenance/genome_pousser.yml create mode 100644 scripts/genome_colis.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f0104fa..bdb795a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # CHANGELOG — Set-OPS +## 2026-08-26 — Le runner du site travaille, et le génome remonte chez lui + +**46 preuves.** `serveur_ops` et `serveur_ops_site` sont déployés sur `site-ops-01` : le +moteur, les plans des trois tenants, les collections hors ligne, et la voûte de l'underlay +déposée **chiffrée**. Le site a son runner. + +### Deux formes de runner, et une seule était prévue + +`serveur_ops` exigeait un dépôt `role: instance` et un `serveur_ops_instance`. C'est la +forme d'un runner de **tenant** : il pilote un écosystème, monte son plan par le lien +`instance`, détient sa voûte. + +Un runner de **site** ne pilote rien — il **matérialise** le terrain que d'autres +occuperont, et son inventaire est dynamique. Son plan déclarait donc ses dépôts de tenants +en `role: tenant`, à dessein et avec ses raisons écrites, et le rôle le refusait au nom +d'une exigence qui ne le concernait pas. Il exige désormais que le runner pilote +*quelque chose*, sans prescrire quoi : un `instance`, ou un `hebergeur`. + +Et il posait le lien quand même : `serveur_ops_instance: ""` donnait `instance -> +/opt/setops/`, un répertoire qui n'est l'écosystème de personne. *Un lien qui existe et ne +désigne rien est pire qu'un lien absent : tout ce qui le suit croit avoir trouvé une +instance.* + +### Le certificat couvre les noms du service, pas seulement ceux de la machine + +`client_pki` ne mettait dans ses SAN que le FQDN, le nom court et l'IP. Le clonage du +génome a buté dessus : *« certificate subject name (site-forge-01.genese.internal) does not +match target hostname 'forge.genese.internal' »*. Le nom déclaré `expose:` était publié +partout — plancher, zone DNS — et couvert nulle part. + +Les expositions **que cet hôte sert réellement** entrent maintenant dans le certificat. +Un certificat qui revendiquerait le nom d'un service rendu ailleurs serait une usurpation, +pas une commodité. + +### `make genome-pousser` — le runner pousse, le poste ne route pas + +La forge du génome vit **dans** le site, sur un réseau que le poste de l'exploitant ne +route pas. On ne perce pas un chemin pour lui : on lui retire le rôle. Le poste emballe les +commits dans un `git bundle` — un fichier, vérifiable, qui ne demande aucun réseau — et le +runner vérifie, avance **en avance rapide seulement**, puis pousse avec **sa** clé. + +Ce qui est poussé se **dérive** de `serveur_ops_depots` : les dépôts que le runner porte et +dont une copie existe à côté du moteur. Six, ici. `DEPOT=` en cible un seul. + +Trois pièges rencontrés, tous inscrits dans le code : + +- l'outil ne peut pas être supposé présent sur le runner — il ne recevrait cette version + qu'**après** la poussée qu'elle sert à faire. Le contrôleur le porte ; +- la branche n'est pas toujours `main` — deux dépôts vivent sur `master`, et le plan le + déclarait déjà ; +- le dossier des dépôts frères contient un espace : `argv` et non `cmd`. + +Le juge n'est pas le journal du playbook mais **la forge** : on lui demande son API ce +qu'elle porte, et on refuse si ça diffère de ce que porte le runner. + +*Pourquoi ça comptait : la forge du site était restée quatre commits derrière le poste, +sans que rien ne le signale — dont le correctif qui désarme le pare-feu Proxmox. Un +écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.* + ## 2026-08-25 — Quatre zones inverses, et rien de plus **47 preuves.** Le site ne servait aucun PTR. `serveur_powerdns_zone_inverse` dérivait diff --git a/Makefile b/Makefile index b98c6fb..281a406 100644 --- a/Makefile +++ b/Makefile @@ -749,6 +749,31 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE= pour un seul + @set -e; \ + voute="$$(dirname "$$(readlink -f underlay.yml)")/underlay.vault.yml"; \ + supp=(); [[ -f "$$voute" ]] && supp+=( -e "@$$voute" ); \ + SETOPS_DEPOT="$(DEPOT)" ansible-playbook -i scripts/site_inventaire.py \ + playbooks/maintenance/genome_pousser.yml "$${supp[@]}" $(ARGS) + .PHONY: site-creer site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true @set -e; \ diff --git a/docs/audit/preuve-2026-08-26.md b/docs/audit/preuve-2026-08-26.md new file mode 100644 index 0000000..5d0bbfe --- /dev/null +++ b/docs/audit/preuve-2026-08-26.md @@ -0,0 +1,83 @@ +# Preuve de conformite — Set-OPS — 2026-08-26 + +> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer +> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ; +> aucune validation n'est reimplementee ici. Voir le mode d'emploi : +> [`docs/audit/README.md`](README.md), et le registre trace : +> [`docs/audit/affirmations.md`](affirmations.md). + +- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml` +- **Verdict** : ✅ CONFORME (46 OK · 0 echec · 1 saute) + +## Preuves + +| # | Preuve | Affirmations | Statut | Detail | +|---|---|---|---|---| +| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | +| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee | +| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. | +| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | +| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | | +| 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, 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. | +| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | +| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | +| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | +| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). | +| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 18 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | +| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 14 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | +| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | +| 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 : 11 reseau(x), aucune collision avec la plage tenant. | +| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. | +| 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. | +| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | +| 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 | 50 scripts expliques et atteignables, 103 cibles make documentees, 60 roles avec README. | +| 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). | +| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 2 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). | +| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. | +| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 35 role(s) serveur/client tous nommes, 36 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. | +| 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 : 46 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 | +| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. | +| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 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). | +| 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). | + +## Couverture des affirmations ✅ du registre + +Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus. +Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005 +`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite +d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ; +elles restent hors du harnais recurrent (rien d'executable a rejouer). + +## Declarations d'intention (⚪ invérifiables localement — assumees) + +Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees** +comme declarations d'intention, non comme preuves : + +- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement + contre une flotte vivante. +- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles. +- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee. +- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume. + +_Rapport genere le 2026-08-26._ diff --git a/playbooks/maintenance/genome_pousser.yml b/playbooks/maintenance/genome_pousser.yml new file mode 100644 index 0000000..10751bf --- /dev/null +++ b/playbooks/maintenance/genome_pousser.yml @@ -0,0 +1,263 @@ +--- +# LE RUNNER DU SITE POUSSE LE GENOME SUR LA FORGE DU SITE. +# +# POURQUOI CE DETOUR, ET POURQUOI IL EST BON (2026-08-26). +# +# La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant ne route +# pas — et c'est voulu : le poste n'a de patte que sur l'administration, la frontiere +# police le reste. Il ne peut donc pas pousser lui-meme. +# +# On ne perce pas un chemin pour lui. Le RUNNER existe exactement pour ca : il est dans le +# site, il a le genome, il a une identite. On lui PORTE les commits dans un `git bundle` — +# un fichier, verifiable, qui ne demande aucun reseau — et c'est LUI qui pousse. +# +# Mesure du 2026-08-26 : la forge du site etait restee quatre commits en arriere du poste, +# sans que rien ne le signale. Le runner clonait donc un moteur perime — dont, ce jour-la, +# le correctif qui desarme le pare-feu Proxmox. Un ecosysteme qui se reproduit depuis une +# forge en retard reproduit ses defauts. +# +# CE QUI EST POUSSE SE DERIVE, il ne s'ecrit pas : `serveur_ops_depots` (au plan du site) +# dit ce que le runner porte. On pousse ceux dont une copie locale existe a cote du +# moteur, et rien d'autre. +# +# make genome-pousser tous les depots portes par le runner +# make genome-pousser DEPOT= un seul (le nom du depot sur la forge) +# +# L'ECRITURE S'AUTHENTIFIE, LA LECTURE NON. `serveur_ops` clone en anonyme, par doctrine. +# Pousser est un autre acte : le runner s'y presente avec SA cle — celle que `serveur_ops` +# lui a generee — declaree en cle de depot, en ecriture, sur ces depots-la seulement. +# `url` reste en HTTPS anonyme ; seul `pushurl` passe par SSH. + +- name: Le runner du site pousse le genome sur la forge du site + hosts: serveur_ops_site + become: true + become_user: "{{ serveur_ops_utilisateur | default('setops') }}" + gather_facts: false + + vars: + genome_racine: "{{ serveur_ops_racine | default('/opt/setops') }}" + genome_depot_demande: "{{ lookup('env', 'SETOPS_DEPOT') | default('', true) }}" + # Le dossier qui contient le moteur ET ses depots freres, sur le CONTROLEUR. + genome_freres: "{{ playbook_dir | dirname | dirname | dirname }}" + # Le dossier du moteur, tel que le plan le nomme — jamais ecrit en dur. + genome_moteur: "{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}" + + tasks: + - name: Exiger de savoir quoi pousser + ansible.builtin.assert: + that: + - (serveur_ops_depots | default([])) | length > 0 + fail_msg: >- + Ce poste ne déclare aucun dépôt (`serveur_ops_depots`). Rien à pousser — et + probablement rien à piloter non plus : voir le plan de cette machine. + + # Ce que le runner porte, restreint a ce dont une copie existe sur le controleur. + # Un depot declare mais absent d'ici n'est pas une faute : il vit ailleurs. + - name: Voir lesquels de ces dépôts existent sur le contrôleur + ansible.builtin.stat: + path: "{{ genome_freres }}/{{ item.dest }}/.git" + delegate_to: localhost + become: false + loop: "{{ serveur_ops_depots }}" + loop_control: + label: "{{ item.dest }}" + register: genome_locaux + + - name: Retenir ceux qu'on peut effectivement porter + ansible.builtin.set_fact: + genome_a_pousser: >- + {{ genome_locaux.results + | selectattr('stat.exists') + | map(attribute='item') | list + | selectattr('depot', 'equalto', genome_depot_demande) + | list + if genome_depot_demande | length > 0 + else genome_locaux.results | selectattr('stat.exists') + | map(attribute='item') | list }} + + - name: Dire ce qui va être poussé + ansible.builtin.debug: + msg: >- + {{ genome_a_pousser | map(attribute='depot') | list + | ternary(genome_a_pousser | map(attribute='depot') | list, + 'aucun dépôt à pousser') }} + + - name: Refuser un DEPOT= qui ne désigne rien + ansible.builtin.assert: + that: genome_a_pousser | length > 0 + fail_msg: >- + Aucun dépôt à pousser. {{ 'Le dépôt `' ~ genome_depot_demande ~ '` n est pas + porté par ce runner, ou n a pas de copie locale à côté du moteur.' + if genome_depot_demande | length > 0 else + 'Aucun des dépôts déclarés n a de copie locale à côté du moteur.' }} + + # LA CLE DU RUNNER DOIT ETRE AUTORISEE SUR CE QU'IL POUSSE — sinon la forge refuse, + # depot par depot, et l'exploitant doit aller cliquer. La cible s'en charge : la + # declaration est idempotente (422 = deja la), et sa portee est exactement ce qu'on + # pousse, jamais plus large. + - name: Lire la clé publique du runner + ansible.builtin.slurp: + src: "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}.pub" + register: genome_cle + + - name: Autoriser cette clé en écriture sur les dépôts poussés + ansible.builtin.uri: + url: >- + https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation + | default('genome') }}/{{ item.depot }}/keys + method: POST + url_username: forgejo-admin + url_password: "{{ vault_forgejo_admin }}" + force_basic_auth: true + validate_certs: false + body_format: json + body: + title: "runner du site — {{ inventory_hostname }}" + key: "{{ genome_cle.content | b64decode | trim }}" + read_only: false + status_code: [201, 422] + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + register: genome_cles + changed_when: false + no_log: true + + - name: Un endroit pour fabriquer les colis, sur le contrôleur + ansible.builtin.tempfile: + state: directory + suffix: -genome + delegate_to: localhost + become: false + register: genome_atelier + + - name: Un endroit pour déposer les colis + ansible.builtin.file: + path: "{{ genome_racine }}/.bundles" + state: directory + owner: "{{ serveur_ops_utilisateur | default('setops') }}" + group: "{{ serveur_ops_utilisateur | default('setops') }}" + mode: "0700" + + # LA LOGIQUE GIT VIT DANS `scripts/genome_colis.py`, comme partout ici : le playbook + # orchestre, le script fait. Il voyage AVEC le moteur — le runner en a donc une copie + # dès qu'il l'a cloné, et rien de spécial n'est déposé sur la machine. + - name: Emballer chaque dépôt sur le contrôleur + # `argv` ET NON `cmd` : le dossier des dépôts frères peut contenir des espaces — + # c'est le cas ici (« Espace Chezlepro »). Une chaîne `cmd` est découpée sur les + # blancs, et python recevait « /home/…/Espace » comme nom de fichier. + ansible.builtin.command: + argv: + - python3 + - "{{ genome_freres }}/{{ genome_moteur }}/scripts/genome_colis.py" + - emballer + - "{{ genome_freres }}/{{ item.dest }}" + - "{{ genome_atelier.path }}/{{ item.depot }}.bundle" + - --branche + - "{{ item.branche | default('main') }}" + delegate_to: localhost + become: false + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.dest }}" + changed_when: true + + - name: Porter les colis jusqu'au runner + ansible.builtin.copy: + src: "{{ genome_atelier.path }}/{{ item.depot }}.bundle" + dest: "{{ genome_racine }}/.bundles/{{ item.depot }}.bundle" + owner: "{{ serveur_ops_utilisateur | default('setops') }}" + group: "{{ serveur_ops_utilisateur | default('setops') }}" + mode: "0600" + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + + # L'OUTIL VOYAGE AVEC LE COLIS, il n'est pas supposé déjà là (2026-08-26). + # + # Première version : on lançait `scripts/genome_colis.py` depuis le moteur cloné sur + # le runner. Circulaire — le runner ne reçoit cette version du script qu'APRÈS la + # poussée qu'elle sert à faire. Et ça se reproduirait à chaque évolution de l'outil : + # le runner en retard ne saurait jamais rattraper son retard. + # + # C'est le contrôleur qui détient la version qui fait autorité. Il la porte. + - name: Porter l'outil lui-même jusqu'au runner + ansible.builtin.copy: + src: "{{ genome_freres }}/{{ genome_moteur }}/scripts/genome_colis.py" + dest: "{{ genome_racine }}/.bundles/genome_colis.py" + owner: "{{ serveur_ops_utilisateur | default('setops') }}" + group: "{{ serveur_ops_utilisateur | default('setops') }}" + mode: "0700" + + - name: Le runner vérifie, avance en avance rapide, et pousse + ansible.builtin.command: + argv: + - python3 + - "{{ genome_racine }}/.bundles/genome_colis.py" + - recevoir + - "{{ genome_racine }}/{{ item.dest }}" + - "{{ genome_racine }}/.bundles/{{ item.depot }}.bundle" + - "git@forge.{{ domaine_interne }}:{{ serveur_ops_forge_organisation | default('genome') }}/{{ item.depot }}.git" + - --cle + - "{{ genome_racine }}/.ssh/id_{{ serveur_ops_cle_type | default('ed25519') }}" + - --branche + - "{{ item.branche | default('main') }}" + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + register: genome_recu + # `changed_when: false` ET C'EST VOULU. Dans une boucle, ni le registre (pas encore + # defini) ni `stdout` nu ne sont lisibles ici — les deux ont ete essayes, les deux + # ont leve. Plutot qu'une condition qui ment ou qui casse, on ne pretend rien : ce + # qui a change est dit par le VERDICT, plus bas, qui compare la forge au runner et + # nomme la transition. La verite vient de la forge, pas d'un drapeau de tache. + changed_when: false + + - name: Demander à la forge ce qu'elle porte maintenant + # On interroge son API plutôt que `git ls-remote` : c'est la forge elle-même qui + # répond, et la lecture est anonyme — aucun justificatif n'entre ici. + ansible.builtin.uri: + url: >- + https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation + | default('genome') }}/{{ item.depot }}/branches/{{ item.branche | default('main') }} + validate_certs: false + status_code: [200] + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + register: genome_forge + changed_when: false + + - name: La forge porte-t-elle exactement ce que le runner porte ? + ansible.builtin.assert: + that: + - genome_forge.results[idx].json.commit.id + == (genome_recu.results[idx].stdout | from_json).apres + fail_msg: >- + {{ item.depot }} : la forge porte + {{ genome_forge.results[idx].json.commit.id[:8] | default('rien') }} alors que + le runner porte + {{ (genome_recu.results[idx].stdout | from_json).apres[:8] }}. La poussée n'a pas + abouti — ne pas cloner depuis cette forge tant que ce n'est pas réglé. + success_msg: >- + {{ item.depot }} : + {{ (genome_recu.results[idx].stdout | from_json).avant[:7] }} → + {{ (genome_recu.results[idx].stdout | from_json).apres[:7] }}, et la forge le porte. + loop: "{{ genome_a_pousser }}" + loop_control: + label: "{{ item.depot }}" + index_var: idx + + # Le genome n'a pas a trainer en double sur la machine : ce qui compte est dans les + # depots et sur la forge. + - name: Retirer les colis + ansible.builtin.file: + path: "{{ genome_racine }}/.bundles" + state: absent + + - name: Retirer l'atelier du contrôleur + ansible.builtin.file: + path: "{{ genome_atelier.path }}" + state: absent + delegate_to: localhost + become: false diff --git a/roles/client_pki/defaults/main.yml b/roles/client_pki/defaults/main.yml index a72080d..1d84cf7 100644 --- a/roles/client_pki/defaults/main.yml +++ b/roles/client_pki/defaults/main.yml @@ -28,12 +28,32 @@ client_pki_nom_cert: "{{ ansible_fqdn | default(ansible_hostname) }}" client_pki_cert: "{{ client_pki_steppath }}/certs/{{ client_pki_nom_cert }}.crt" client_pki_cle: "{{ client_pki_steppath }}/certs/{{ client_pki_nom_cert }}.key" -# SANs du certificat (FQDN + nom court + IP). Requis pour un mTLS moderne : -# les clients TLS valident par le SAN, pas par le CN. -client_pki_sans: - - "{{ client_pki_nom_cert }}" - - "{{ ansible_hostname | default('') }}" - - "{{ ansible_host | default('') }}" +# LE CERTIFICAT DOIT COUVRIR LES NOMS SOUS LESQUELS ON APPELLE LE SERVICE (2026-08-25). +# +# Les SANs ne portaient que l'identite de la MACHINE — FQDN, nom court, IP. Or un service +# se declare `expose:` au plan, sous un nom qui lui survivra : `forge.genese.internal` +# reste le nom de la forge meme si elle demenage de `site-forge-01` a `site-forge-02`. +# +# Ce nom etait publie partout — plancher /etc/hosts, zone DNS — et couvert nulle part. Le +# clonage du genome a bute dessus : « certificate subject name (site-forge-01.genese +# .internal) does not match target hostname 'forge.genese.internal' ». Du TLS correct, sur +# un nom que personne ne peut appeler : la meme panne que le depot a deja rencontree cote +# resolution, ici cote certificat. +# +# ON N'AJOUTE QUE CE QUE CET HOTE SERT REELLEMENT : `edge` nomme le groupe qui rend le +# service, et l'hote doit en faire partie. Un certificat qui revendiquerait le nom d'un +# service rendu ailleurs serait une usurpation, pas une commodite. +client_pki_expositions: "{{ hosts_statiques_expositions | default([]) }}" +client_pki_sans: >- + {{ ([client_pki_nom_cert, + ansible_hostname | default(''), + ansible_host | default('')] + + (client_pki_expositions + | selectattr('edge', 'defined') + | selectattr('fqdn', 'defined') + | selectattr('edge', 'in', group_names) + | map(attribute='fqdn') | list)) + | select | unique | list }} # L'empreinte du root CA n'est PAS un intrant : elle est DERIVEE a chaud depuis l'AC # (tasks/main.yml), parce qu'un from-zero regenere l'autorite avec une empreinte neuve. diff --git a/roles/serveur_ops/tasks/main.yml b/roles/serveur_ops/tasks/main.yml index 4d52405..7fc701f 100644 --- a/roles/serveur_ops/tasks/main.yml +++ b/roles/serveur_ops/tasks/main.yml @@ -1,23 +1,48 @@ --- -# UN POSTE QUI NE SAIT PAS QUEL ÉCOSYSTÈME IL PILOTE N'A RIEN À PILOTER. +# UN POSTE QUI NE SAIT PAS SUR QUOI IL TRAVAILLE N'A RIEN À PILOTER. # -# On exige explicitement un dépôt d'instance, plutôt que de retomber sur un défaut : le +# On exige explicitement ce qu'il pilote, plutôt que de retomber sur un défaut : le # défaut nommait patient 0, et un écosystème distrait aurait cloné le génome d'un autre # sans qu'aucune erreur ne le signale. +# +# DEUX FORMES DE RUNNER, ET UNE SEULE ÉTAIT PRÉVUE (2026-08-25). +# +# Un runner de TENANT pilote un écosystème : il monte son plan par le lien `instance`, +# détient sa voûte, et configure ses services. Il lui faut donc un dépôt `role: instance` +# et `serveur_ops_instance`. +# +# Un runner de SITE ne pilote aucun écosystème — il MATÉRIALISE le terrain que d'autres +# occuperont : VNets, VM, routes, flux. Son propre inventaire est DYNAMIQUE +# (`site_inventaire.py`), il n'a pas de lien `instance` à monter, et les dépôts de +# tenants qu'il porte sont marqués `role: tenant` précisément pour dire qu'il ne les +# pilote pas. Ce qu'il pilote, c'est la CARTE de l'hébergeur : `role: hebergeur` et +# `serveur_ops_underlay`. +# +# L'assertion n'admettait que la première forme. Le plan du site déclarait pourtant tout +# ce qu'il fallait, correctement et avec ses raisons écrites — et le rôle le refusait au +# nom d'une exigence qui ne le concernait pas. +# +# On exige donc que le runner pilote QUELQUE CHOSE, sans prescrire quoi. - name: Exiger les intrants du poste d'exploitation ansible.builtin.assert: that: - domaine_interne | default('') | length > 0 - (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | list | length) == 1 - - (serveur_ops_depots | selectattr('role', 'eq', 'instance') | list | length) >= 1 - - serveur_ops_instance | default('') | length > 0 + - >- + ((serveur_ops_depots | selectattr('role', 'eq', 'instance') | list | length) >= 1 + and serveur_ops_instance | default('') | length > 0) + or + ((serveur_ops_depots | selectattr('role', 'eq', 'hebergeur') | list | length) >= 1 + and serveur_ops_underlay | default('') | length > 0) - not (serveur_ops_forge_externe | bool) or (serveur_ops_forge_amont | length > 0) fail_msg: >- - serveur_ops exige `domaine_interne`, exactement un dépôt `role: moteur`, au moins - un dépôt `role: instance`, et `serveur_ops_instance` (le dossier que le lien - `instance` doit désigner). Et si `serveur_ops_forge_externe` est levé — écosystème - au premier âge, qui lit son génome chez son hôte — `serveur_ops_forge_amont` doit - nommer cette forge. Déclarer le tout dans group_vars/serveur_ops.yml. + serveur_ops exige `domaine_interne`, exactement un dépôt `role: moteur`, et de + savoir sur quoi il travaille — SOIT un dépôt `role: instance` avec + `serveur_ops_instance` (runner de TENANT : il pilote un écosystème), SOIT un dépôt + `role: hebergeur` avec `serveur_ops_underlay` (runner de SITE : il matérialise le + terrain, et son inventaire est dynamique). Et si `serveur_ops_forge_externe` est + levé — écosystème au premier âge, qui lit son génome chez son hôte — + `serveur_ops_forge_amont` doit nommer cette forge. - name: Installer l'outillage du poste ansible.builtin.apt: @@ -206,6 +231,15 @@ # LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par # défaut ; l'exploitant le bascule comme sur le poste du mainteneur. +# +# UN RUNNER DE SITE N'EN A PAS (2026-08-26). Il ne pilote aucun écosystème — il matérialise +# le terrain — et son propre inventaire est DYNAMIQUE (`site_inventaire.py`). Son plan +# déclare donc `serveur_ops_instance: ""`, et c'est une réponse, pas un oubli. +# +# Sans la garde ci-dessous, la chaîne rendait `{{ racine }}/` + `""` : le lien `instance` +# pointait sur `/opt/setops/` lui-même — un répertoire qui n'est l'écosystème de personne. +# Mesuré le 2026-08-26 : `instance -> /opt/setops/`. Un lien qui existe et ne désigne rien +# est pire qu'un lien absent : tout ce qui le suit croit avoir trouvé une instance. - name: Désigner l'instance pilotée par défaut ansible.builtin.file: src: "{{ serveur_ops_racine }}/{{ serveur_ops_instance }}" @@ -215,7 +249,20 @@ owner: "{{ serveur_ops_utilisateur }}" group: "{{ serveur_ops_utilisateur }}" force: true - when: not ansible_check_mode + when: + - serveur_ops_instance | default('') | trim | length > 0 + - not ansible_check_mode + +# Et s'il n'y en a pas, le lien ne doit pas SURVIVRE d'un passage precedent : un runner +# qu'on reconfigure en runner de site garderait sinon un lien perime vers un tenant. +- name: Retirer le lien `instance` quand le poste ne pilote aucun écosystème + ansible.builtin.file: + path: >- + {{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}/instance + state: absent + when: + - serveur_ops_instance | default('') | trim | length == 0 + - not ansible_check_mode # LE SECOND SYMLINK. Sans lui, le poste sait configurer des machines qui existent ; il ne # sait pas en creer, faute de savoir sur quelle fabric les poser. C'est la difference diff --git a/scripts/genome_colis.py b/scripts/genome_colis.py new file mode 100644 index 0000000..241f884 --- /dev/null +++ b/scripts/genome_colis.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Porter des commits jusqu'a une forge qu'on ne route pas — par colis, pas par reseau. + +POURQUOI CE DETOUR, ET POURQUOI IL EST BON. + +La forge du genome vit DANS le site, sur un reseau que le poste de l'exploitant ne route +pas — et c'est voulu : le poste n'a de patte que sur l'administration, la frontiere police +le reste. Il ne peut donc pas pousser lui-meme. + +On ne perce pas un chemin pour lui. Le RUNNER existe exactement pour ca : il est dans le +site, il porte le genome, il a une identite. On lui PORTE les commits dans un `git bundle` +— un fichier, verifiable, qui ne demande aucun reseau — et c'est LUI qui pousse. + +Mesure du 2026-08-26 : la forge du site etait restee quatre commits derriere le poste, sans +que rien ne le signale. Le runner clonait donc un moteur perime — dont, ce jour-la, le +correctif qui desarme le pare-feu Proxmox. Un ecosysteme qui se reproduit depuis une forge +en retard reproduit ses defauts. + +DEUX GESTES, DEUX COTES : + + genome_colis.py emballer + Sur le CONTROLEUR. Fabrique le colis a partir de `main`. + + genome_colis.py recevoir [--cle ] + Sur le RUNNER. Verifie le colis, avance `main` EN AVANCE RAPIDE SEULEMENT, + puis pousse. Rend du JSON : {avant, apres, pousse}. + +`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de +fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement +ce qu'il faut reconcilier. + +CE SCRIPT VIT DANS LE MOTEUR, donc le runner en a une copie des qu'il l'a clone : on ne +depose rien de special sur la machine pour qu'elle sache recevoir. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def _git(depot: Path, *args: str, env: dict | None = None) -> str: + """Un git, dans un depot, qui parle clair quand il echoue.""" + r = subprocess.run(["git", "-C", str(depot), *args], + capture_output=True, text=True, env=env) + if r.returncode != 0: + sortie = (r.stderr or r.stdout or "").strip() + raise SystemExit(f"git {' '.join(args)} a echoue dans {depot} :\n {sortie}") + return (r.stdout or "").strip() + + +def emballer(depot: Path, colis: Path, branche: str) -> int: + if not (depot / ".git").exists(): + raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.") + colis.parent.mkdir(parents=True, exist_ok=True) + _git(depot, "bundle", "create", str(colis), branche) + print(json.dumps({"depot": str(depot), "colis": str(colis), + "tete": _git(depot, "log", "-1", "--format=%H")})) + return 0 + + +def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None, + branche: str) -> int: + if not (depot / ".git").exists(): + raise SystemExit(f"{depot} n'est pas un depot git — rien a recevoir.") + if not colis.is_file(): + raise SystemExit(f"{colis} est introuvable : le colis n'est pas arrive.") + + # UN COLIS DOIT ETRE VERIFIABLE AVANT D'ETRE CRU. `git bundle verify` exige d'etre + # lance DEPUIS un depot — il verifie que les prerequis du colis sont deja dans cette + # histoire-ci. Lance ailleurs : « la verification d'un colis requiert un depot ». + _git(depot, "bundle", "verify", str(colis)) + + avant = _git(depot, "log", "-1", "--format=%H") + _git(depot, "fetch", str(colis), + f"refs/heads/{branche}:refs/remotes/colis/{branche}") + _git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}") + apres = _git(depot, "log", "-1", "--format=%H") + + env = None + if cle: + import os + env = dict(os.environ) + env["GIT_SSH_COMMAND"] = ( + f"ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes -i {cle}") + + # `-c remote.origin.pushurl=…` plutot qu'un remote ecrit : la LECTURE reste anonyme + # (doctrine de `serveur_ops` — aucun justificatif a detenir pour se reconstruire), + # seule l'ECRITURE s'authentifie, et seulement le temps de cette commande. + r = subprocess.run( + ["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}", + "push", "origin", branche], + capture_output=True, text=True, env=env) + sortie = ((r.stdout or "") + (r.stderr or "")).strip() + if r.returncode != 0: + raise SystemExit(f"la poussee a echoue :\n {sortie}") + + print(json.dumps({"avant": avant, "apres": apres, + "pousse": f"{branche} -> {branche}" in sortie, "sortie": sortie[:400]})) + return 0 + + +def main(argv: list[str]) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sous = p.add_subparsers(dest="geste", required=True) + + e = sous.add_parser("emballer", help="fabriquer un colis depuis `main` (controleur)") + e.add_argument("depot") + e.add_argument("colis") + e.add_argument("--branche", default="main") + + r = sous.add_parser("recevoir", help="verifier, avancer, pousser (runner)") + r.add_argument("depot") + r.add_argument("colis") + r.add_argument("pushurl") + r.add_argument("--cle", default=None, help="cle SSH du runner") + r.add_argument("--branche", default="main") + + a = p.parse_args(argv) + if a.geste == "emballer": + return emballer(Path(a.depot), Path(a.colis), a.branche) + return recevoir(Path(a.depot), Path(a.colis), a.pushurl, a.cle, a.branche) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))