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:]))