Set-OPS-Public/playbooks/maintenance/genome_pousser.yml
Daniel Allaire 87716cefc7
Some checks are pending
verifier / verifier (push) Waiting to run
genome : la forge du SITE fait autorite (D-81), et make genome-etat le verifie
Decision de l'exploitant : la forge du SITE fait autorite pour le genome. Toute
autre copie — y compris celle d'ou le moteur a ete pousse jusqu'ici — est un
MIROIR.

Un ecosysteme se reproduit depuis la forge de son site : c'est de la qu'il clone
son moteur, ses plans, ses modeles. Si l'autorite est ailleurs, cette forge
devient un cache qu'on croit a jour — et le 2026-08-26 elle etait quatre commits
en arriere sans que rien ne le signale, dont le correctif qui desarme le pare-feu
Proxmox.

UNE AUTORITE QU'ON NE VERIFIE PAS EST UNE AUTORITE QU'ON SUPPOSE.

`make genome-etat` confronte, depot par depot, ce que le poste porte a ce que la
forge porte. Il REFUSE en cas d'ecart plutot que de le signaler : un ecart connu
et tolere redevient un ecart oublie, et la commande qui le corrige tient en trois
mots. Il dit aussi quand la copie locale n'est pas propre — des commits pas
encore faits sont une autre forme de retard.

Mesure au passage, et traitee plutot qu'ignoree : le premier contact avec la
forge echoue une fois sur six — poignee TLS expiree, puis cinq reponses de suite.
Ce n'est pas le chemin, qui est prouve ; c'est l'acceptation TLS apres un temps
d'inactivite. Les deux cibles reessaient.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:17:24 -04:00

270 lines
12 KiB
YAML

---
# 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=<nom> 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
# LE PREMIER CONTACT AVEC LA FORGE RATE PARFOIS (mesure du 2026-08-26 : une fois sur
# six, poignee TLS expiree, puis cinq reponses de suite). Ce n'est pas le chemin —
# il est prouve — c'est l'acceptation TLS apres un temps d'inactivite. On reessaie
# plutot que de rendre un verdict sur un alea.
retries: 4
delay: 3
until: genome_forge is succeeded
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