--- # 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 # 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 RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20). # # C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le # moteur du site sans qu'aucun role ne passe. `serveur_ops` redemarre bien la console # quand le clone avance, mais il ne verra jamais cet avancement-ci — le clone sera # deja a jour quand il passera. Le site garderait donc une console servant l'ancien # code, exactement le defaut corrige le meme jour chez les locataires. # # `python3 scripts/inventory_gui.py` lit son code au demarrage, et seulement la. Ce # qui a avance doit etre relu, et personne d'autre que ce playbook ne sait qu'il a # avance : le verdict ci-dessus nomme la transition, il suffit de l'ecouter. - name: Retenir ce que le colis du moteur a fait bouger ansible.builtin.set_fact: genome_moteur_colis: >- {{ (genome_recu.results | default([])) | selectattr('item.dest', 'equalto', genome_moteur) | map(attribute='stdout') | list }} - name: Voir si ce runner sert une console ansible.builtin.stat: path: /etc/systemd/system/setops-gui.service register: genome_console_unite - name: Redémarrer la console du site quand son moteur a avancé ansible.builtin.systemd: name: setops-gui.service state: restarted daemon_reload: true become: true become_user: root when: - genome_console_unite.stat.exists - genome_moteur_colis | length > 0 - (genome_moteur_colis[0] | from_json).avant != (genome_moteur_colis[0] | from_json).apres # 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