L'epreuve du colis sur le runner de Technolibre : git bundle verify exige un depot existant, et l'insemination est le cas ou il n'existe pas encore. Le clone verifie le colis lui-meme ; un depot existant le verifie avant de tirer. Les colis restes d'un passage interrompu sont retires. make forge-cles rend aussi chaque depot prive ou public selon le role que lui donne le plan du site, et seulement une fois les cles de lecture en place. 95 preuves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1078 lines
46 KiB
YAML
1078 lines
46 KiB
YAML
---
|
|
# UN POSTE QUI NE SAIT PAS SUR QUOI IL TRAVAILLE N'A RIEN À PILOTER.
|
|
#
|
|
# On exige explicitement ce qu'il pilote, plutôt que de retomber sur un défaut : le
|
|
# défaut nommait un écosystème précis, 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
|
|
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`, 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:
|
|
name: "{{ serveur_ops_paquets }}"
|
|
state: present
|
|
update_cache: true
|
|
when: not ansible_check_mode
|
|
|
|
- name: Créer l'utilisateur d'exploitation
|
|
ansible.builtin.user:
|
|
name: "{{ serveur_ops_utilisateur }}"
|
|
home: "{{ serveur_ops_racine }}"
|
|
shell: /bin/bash
|
|
create_home: true
|
|
system: false
|
|
|
|
- name: Poser les droits de la racine d'exploitation
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_racine }}"
|
|
state: directory
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0750"
|
|
|
|
# L'ENVIRONNEMENT PYTHON EST ISOLÉ, PAS SYSTÈME. Debian gère ansible en paquet, mais la
|
|
# version qu'il propose suit son propre calendrier : reconstruire un écosystème avec un
|
|
# ansible plus récent que celui qui l'a construit, c'est changer la recette sans le dire.
|
|
# Le venv permet d'épingler exactement la famille utilisée par le mainteneur.
|
|
- name: Créer l'environnement Python isolé
|
|
ansible.builtin.command:
|
|
cmd: "python3 -m venv {{ serveur_ops_venv }}"
|
|
creates: "{{ serveur_ops_venv }}/bin/python"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
|
|
# LES BIBLIOTHEQUES DU CONTROLEUR SE DECLARENT, ELLES AUSSI (2026-08-27). Cette liste
|
|
# etait ecrite en dur — `ansible-core` et `pyyaml`, rien de plus — alors que les modules
|
|
# Proxmox du moteur exigent `proxmoxer` sur la machine qui les execute. Le runner du SITE
|
|
# a donc echoue a sa premiere materialisation de VM, sur une bibliotheque que le poste du
|
|
# mainteneur possedait par son paquet systeme sans que personne ne l'ait declaree.
|
|
# `requirements-python.txt` est desormais la source, au meme titre que `requirements.yml`
|
|
# pour les collections. Voir la preuve P51.
|
|
- name: Deposer la liste des bibliotheques Python du controleur
|
|
ansible.builtin.copy:
|
|
src: "{{ playbook_dir }}/../../requirements-python.txt"
|
|
dest: "{{ serveur_ops_racine }}/requirements-python.txt"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0644"
|
|
|
|
# --- LA ROUE HORS-LIGNE ------------------------------------------------------
|
|
#
|
|
# MEME IDIOME QUE LES COLLECTIONS, quelques lignes plus bas : le CONTROLEUR telecharge
|
|
# une fois dans son cache, puis pousse par SSH. La cible n'ouvre aucun flux, et
|
|
# l'artefact devient deployable hors ligne.
|
|
|
|
- name: Preparer le cache des roues Python sur le controleur
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_cache_roues }}"
|
|
state: directory
|
|
mode: "0755"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
|
|
# LE VERROU DU RUNNER (2026-10-09) — `verrou-runner.yml`, a la racine du moteur.
|
|
#
|
|
# Les roues ne sont plus resolues par `pip download` contre PyPI, a chaque passage : elles
|
|
# sont ECRITES, une par une, avec leur somme, et le SITE les tient dans son depot de
|
|
# binaires. Le runner de Technolibre portait deux versions de six bibliotheques — PyPI avait
|
|
# bouge entre l'insemination et le montage. Desormais, ce qui s'installe est ce que le
|
|
# verrou nomme, et rien d'autre.
|
|
- name: Lire le verrou du runner (roues Python, collections Ansible)
|
|
ansible.builtin.include_vars:
|
|
file: "{{ playbook_dir }}/../../verrou-runner.yml"
|
|
name: serveur_ops_verrou
|
|
|
|
# LA CIBLE DOIT PORTER LE PYTHON DES ROUES VERROUILLEES. `pyyaml`, `cffi`, `markupsafe`...
|
|
# portent du C compile pour un Python donne : une roue `cp313` posee sur un 3.12 ne
|
|
# s'installe pas, et l'echec accuserait le depot local. Le controleur, lui, ne compile plus
|
|
# rien : il ne fait que transporter des fichiers ecrits.
|
|
- name: Exiger que la cible porte le Python des roues verrouillees
|
|
ansible.builtin.assert:
|
|
that:
|
|
- ansible_python_version.split('.')[0:2] | join('.') == serveur_ops_verrou.python_cible | string
|
|
fail_msg: >-
|
|
{{ inventory_hostname }} tourne Python {{ ansible_python_version }} ; les roues de
|
|
`verrou-runner.yml` sont faites pour Python {{ serveur_ops_verrou.python_cible }}.
|
|
Monter le verrou (nouvelles roues, nouvelles sommes), ou garder la cible sur ce Python.
|
|
|
|
# LE DEPOT DU SITE D'ABORD, L'AMONT ENSUITE — la somme verifiee dans les deux cas.
|
|
# `failed_when: false` : le depot est une COMMODITE ; sans lui, on retombe sur l'amont, et on
|
|
# le dit (tache suivante).
|
|
- name: Prendre les roues verrouillees au depot du site
|
|
ansible.builtin.get_url:
|
|
url: "{{ setops_depot_binaires }}/{{ item.nom }}"
|
|
dest: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
|
|
checksum: "sha256:{{ item.sha256 }}"
|
|
mode: "0644"
|
|
timeout: 30
|
|
loop: "{{ serveur_ops_verrou.python }}"
|
|
loop_control:
|
|
label: "{{ item.nom }}"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
failed_when: false
|
|
when:
|
|
- setops_depot_binaires | default('') | length > 0
|
|
- not ansible_check_mode
|
|
|
|
- name: Les roues verrouillees sont-elles dans le cache du controleur ?
|
|
ansible.builtin.stat:
|
|
path: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
|
|
checksum_algorithm: sha256
|
|
loop: "{{ serveur_ops_verrou.python }}"
|
|
loop_control:
|
|
label: "{{ item.nom }}"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
register: serveur_ops_roues_cache
|
|
|
|
# ON LE DIT, FORT : une reconstruction qui sort sur Internet doit se voir.
|
|
- name: Dire les roues absentes du depot du site (prises en amont)
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ serveur_ops_roues_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
|
|
— absentes du depot du site ({{ setops_depot_binaires | default('aucun depot declare') }}) : prises sur Internet.
|
|
run_once: true
|
|
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
|
|
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
|
|
when:
|
|
- not ansible_check_mode
|
|
- serveur_ops_roues_cache.results | rejectattr('stat.exists') | list | length > 0
|
|
|
|
- name: Prendre en amont les roues que le site n'a pas (repli)
|
|
ansible.builtin.get_url:
|
|
url: "{{ item.item.url }}"
|
|
dest: "{{ serveur_ops_cache_roues }}/{{ item.item.nom }}"
|
|
checksum: "sha256:{{ item.item.sha256 }}"
|
|
mode: "0644"
|
|
timeout: 60
|
|
# ABSENTE OU ALTEREE : `pip --no-index` ne verifie pas les sommes, un fichier present mais
|
|
# different passerait. On le reprend, et `get_url` le verifie.
|
|
loop: "{{ serveur_ops_roues_cache.results }}"
|
|
loop_control:
|
|
label: "{{ item.item.nom }}"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
when:
|
|
- not ansible_check_mode
|
|
- not item.stat.exists or item.stat.checksum != item.item.sha256
|
|
|
|
# LES ROUES DU VERROU, ET ELLES SEULES. Copier le cache entier deposait aussi les versions
|
|
# d'hier (deux `cryptography`, deux `urllib3`...) ; `--no-index` choisissait alors la plus
|
|
# haute, que personne n'avait choisie.
|
|
# LE DOSSIER D'ABORD (2026-10-09). L'ancienne copie du cache ENTIER (`src: dossier/`) creait
|
|
# sa destination ; une copie fichier par fichier ne la cree pas. Sur un runner neuf, la
|
|
# reconstruction de Technolibre s'est arretee la (« Destination directory … does not exist »)
|
|
# — invisible en simulant sur un runner ou le dossier existait deja.
|
|
- name: Assurer le depot des roues sur le runner
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_roues_depot }}"
|
|
state: directory
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0755"
|
|
|
|
- name: Deposer sur le runner les roues du verrou
|
|
ansible.builtin.copy:
|
|
src: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
|
|
dest: "{{ serveur_ops_roues_depot }}/{{ item.nom }}"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0644"
|
|
loop: "{{ serveur_ops_verrou.python }}"
|
|
loop_control:
|
|
label: "{{ item.nom }}"
|
|
when: not ansible_check_mode
|
|
|
|
- name: Relever les roues deposees sur le runner
|
|
ansible.builtin.find:
|
|
paths: "{{ serveur_ops_roues_depot }}"
|
|
patterns: "*.whl"
|
|
register: serveur_ops_roues_deposees
|
|
|
|
- name: Retirer du runner les roues que le verrou ne nomme pas
|
|
ansible.builtin.file:
|
|
path: "{{ item.path }}"
|
|
state: absent
|
|
loop: >-
|
|
{{ serveur_ops_roues_deposees.files
|
|
| rejectattr('path', 'search', '/(' ~ (serveur_ops_verrou.python | map(attribute='nom')
|
|
| map('regex_escape') | join('|')) ~ ')$') | list }}
|
|
loop_control:
|
|
label: "{{ item.path | basename }}"
|
|
|
|
# `--no-index` EST LE POINT, PAS UNE OPTIMISATION. Sans lui, pip resterait capable de
|
|
# sortir vers PyPI le jour ou le depot local serait incomplet — et la reussite dependrait
|
|
# alors d'un flux que ce tenant n'a pas le droit d'avoir. Un repli silencieux vers
|
|
# l'Internet transformerait une lignee autonome en lignee qui a l'air autonome.
|
|
- name: Installer Ansible dans l'environnement isolé (hors ligne)
|
|
ansible.builtin.pip:
|
|
name:
|
|
- "{{ serveur_ops_ansible }}"
|
|
- "pyyaml"
|
|
virtualenv: "{{ serveur_ops_venv }}"
|
|
state: present
|
|
extra_args: "--no-index --find-links {{ serveur_ops_roues_depot }}"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when: not ansible_check_mode
|
|
register: serveur_ops_pip
|
|
retries: 3
|
|
delay: 6
|
|
until: serveur_ops_pip is succeeded
|
|
|
|
# TACHE SEPAREE : dans `ansible.builtin.pip`, `name` et `requirements` sont MUTUELLEMENT
|
|
# EXCLUSIFS. Les fusionner ne produit pas une erreur parlante — le module ignore l'un des
|
|
# deux, et la bibliotheque manquante ne se revele qu'au premier module qui en depend.
|
|
- name: Installer les bibliotheques Python du controleur (source declaree, hors ligne)
|
|
ansible.builtin.pip:
|
|
requirements: "{{ serveur_ops_racine }}/requirements-python.txt"
|
|
virtualenv: "{{ serveur_ops_venv }}"
|
|
state: present
|
|
extra_args: "--no-index --find-links {{ serveur_ops_roues_depot }}"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when: not ansible_check_mode
|
|
register: serveur_ops_pip_controleur
|
|
retries: 3
|
|
delay: 6
|
|
until: serveur_ops_pip_controleur is succeeded
|
|
|
|
# L'OUTILLAGE DOIT ETRE SUR LE CHEMIN DE CELUI QUI L'EXPLOITE (2026-08-26).
|
|
#
|
|
# Ansible vit dans un venv isole — c'est voulu, il est epingle. Mais rien ne le mettait sur
|
|
# le PATH du compte d'exploitation : `make instancier` y echouait sur
|
|
# « [Errno 2] No such file or directory: 'ansible-inventory' », un message qui accuse un
|
|
# fichier manquant alors que le fichier est la, deux dossiers plus loin.
|
|
#
|
|
# Mesure du 2026-08-26, en eprouvant la sequence depuis le runner du site. Un exploitant y
|
|
# serait tombe exactement de la meme facon — et c'est precisement ce qu'un outil « utilisable
|
|
# sans IA » ne doit pas faire.
|
|
#
|
|
# Un fragment `profile.d` plutot qu'un `.bashrc` : il vaut pour toute session du compte, y
|
|
# compris `sudo -iu setops`, et se retire d'un seul geste.
|
|
- name: Mettre l'outillage du poste sur le chemin du compte d'exploitation
|
|
ansible.builtin.copy:
|
|
dest: /etc/profile.d/50-setops-venv.sh
|
|
content: |
|
|
# GÉNÉRÉ par Set-OPS (rôle serveur_ops). NE PAS éditer à la main.
|
|
# Ansible vit dans un venv épinglé ; sans cette ligne, `make` ne le trouve pas.
|
|
if [ "$(id -un)" = "{{ serveur_ops_utilisateur }}" ]; then
|
|
PATH="{{ serveur_ops_venv }}/bin:$PATH"
|
|
export PATH
|
|
fi
|
|
owner: root
|
|
group: root
|
|
mode: "0644"
|
|
|
|
# LA CONFIANCE, AVANT LE CLONE — et limitee a une seule url.
|
|
#
|
|
# Voir defaults/main.yml : on ne pose pas cette racine dans le magasin systeme. `git
|
|
# config http.<url>.sslCAInfo` ne l'applique qu'aux echanges avec CETTE forge.
|
|
- name: Deposer la racine de la forge amont
|
|
ansible.builtin.copy:
|
|
src: "{{ serveur_ops_forge_amont_ac }}"
|
|
dest: "{{ serveur_ops_forge_amont_ac_depot }}"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0644"
|
|
when:
|
|
- serveur_ops_forge_amont_ac | length > 0
|
|
- not ansible_check_mode
|
|
|
|
- name: Limiter la confiance a la forge amont, et a elle seule
|
|
community.general.git_config:
|
|
name: "http.{{ serveur_ops_forge_url }}/.sslCAInfo"
|
|
scope: global
|
|
value: "{{ serveur_ops_forge_amont_ac_depot }}"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when:
|
|
- serveur_ops_forge_amont_ac | length > 0
|
|
- not ansible_check_mode
|
|
|
|
# --- LE GÉNOME, DEPUIS SA PROPRE FORGE ---------------------------------------
|
|
#
|
|
# Cloné en anonyme : les dépôts du génome sont lisibles sur la forge de l'écosystème, et
|
|
# le poste n'a donc AUCUN justificatif à détenir pour se reconstruire. C'est délibéré —
|
|
# un secret de moins sur la machine qui en concentre déjà beaucoup.
|
|
#
|
|
# La confiance TLS vient de `client_pki` quand la forge est celle de CET écosystème
|
|
# (racine step-ca dans le magasin système). Quand elle est celle d'un voisin, elle vient
|
|
# de la tâche ci-dessus — limitée à cette seule url. Sans l'une ou l'autre, `git clone`
|
|
# refuse le certificat, et c'est bien qu'il refuse.
|
|
- name: Cloner le génome depuis la forge de l'écosystème
|
|
ansible.builtin.git:
|
|
repo: "{{ serveur_ops_forge_url }}/{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git"
|
|
dest: "{{ serveur_ops_racine }}/{{ item.dest }}"
|
|
version: "{{ item.branche | default('main') }}"
|
|
update: true
|
|
force: false
|
|
# LES DEPOTS PUBLICS SEULEMENT (2026-10-11). Les prives suivent, plus bas, en SSH.
|
|
loop: "{{ serveur_ops_depots | rejectattr('role', 'in', serveur_ops_roles_prives) | list }}"
|
|
loop_control:
|
|
label: "{{ item.depot }} → {{ item.dest }}"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when: not ansible_check_mode
|
|
register: serveur_ops_clone
|
|
retries: 3
|
|
delay: 6
|
|
until: serveur_ops_clone is succeeded
|
|
|
|
# --- LES DEPOTS PRIVES : EN SSH, OU EN COLIS TANT QUE LE RUNNER NE PEUT PAS ---------------
|
|
#
|
|
# (2026-10-11, `docs/depots-prives-conception.md`.) Le plan d'un ecosysteme et la carte du
|
|
# site sont prives sur la forge du site. Le runner les lit en SSH, avec SA cle, que le site a
|
|
# enregistree comme cle de deploiement sur ce seul depot (`make forge-cles`).
|
|
#
|
|
# MAIS PAS ENCORE A L'INSEMINATION. Le site amorce le runner d'un locataire AVANT l'armement,
|
|
# donc avant que la voute lui remette sa cle stable : la forge ne le connait pas. Le
|
|
# controleur qui joue ce role — le runner du site, ou le poste — porte deja ce depot, frere
|
|
# du moteur ; il le lui apporte en COLIS (`git bundle`). Le runner pointe ensuite `origin`
|
|
# vers la forge en SSH : une fois arme, il tire lui-meme. Rien n'est devine : si le
|
|
# controleur n'a pas le depot, on refuse.
|
|
- name: Exiger la cle d'hote de la forge pour lire un depot prive
|
|
ansible.builtin.assert:
|
|
that: serveur_ops_forge_cle_hote | length > 0
|
|
fail_msg: >-
|
|
Ce runner lit des depots prives ({{ serveur_ops_depots
|
|
| selectattr('role', 'in', serveur_ops_roles_prives) | map(attribute='depot') | join(', ') }}),
|
|
mais `serveur_ops_forge_cle_hote` est vide. Au site : `forge_ssh_cle_hote` dans son plan.
|
|
Chez un locataire : la copier depuis sa fiche (`make site-intrants`).
|
|
when: serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list | length > 0
|
|
|
|
- name: Epingler la cle d'hote de la forge
|
|
ansible.builtin.copy:
|
|
content: "{{ serveur_ops_forge_ssh_hote }} {{ serveur_ops_forge_cle_hote | trim }}\n"
|
|
dest: "{{ serveur_ops_forge_known_hosts }}"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0644"
|
|
when: serveur_ops_forge_cle_hote | length > 0
|
|
|
|
- name: Le runner lit-il deja chaque depot prive en SSH ?
|
|
ansible.builtin.command:
|
|
argv: [git, ls-remote, "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git", HEAD]
|
|
environment:
|
|
GIT_SSH_COMMAND: "{{ serveur_ops_ssh_prive }}"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
|
|
loop_control:
|
|
label: "{{ item.depot }}"
|
|
register: serveur_ops_prives_ssh
|
|
changed_when: false
|
|
failed_when: false
|
|
check_mode: false
|
|
|
|
- name: Tirer en SSH les depots prives que le runner peut lire
|
|
ansible.builtin.git:
|
|
repo: "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
|
|
dest: "{{ serveur_ops_racine }}/{{ item.item.dest }}"
|
|
version: "{{ item.item.branche | default('main') }}"
|
|
key_file: "{{ serveur_ops_cle_privee }}"
|
|
ssh_opts: "-o StrictHostKeyChecking=yes -o UserKnownHostsFile={{ serveur_ops_forge_known_hosts }} -o IdentitiesOnly=yes"
|
|
accept_hostkey: false
|
|
update: true
|
|
force: false
|
|
loop: "{{ serveur_ops_prives_ssh.results | selectattr('rc', 'equalto', 0) | list }}"
|
|
loop_control:
|
|
label: "{{ item.item.depot }} (SSH)"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when: not ansible_check_mode
|
|
register: serveur_ops_prives_tires
|
|
|
|
- name: Faire le colis des depots prives que le runner ne lit pas encore (controleur)
|
|
ansible.builtin.command:
|
|
argv: [git, -C, "{{ serveur_ops_freres }}/{{ item.item.dest }}", bundle, create,
|
|
"{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle",
|
|
"refs/heads/{{ item.item.branche | default('main') }}"]
|
|
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
loop_control:
|
|
label: "{{ item.item.depot }} (colis)"
|
|
delegate_to: localhost
|
|
become: false
|
|
changed_when: false
|
|
when: not ansible_check_mode
|
|
vars:
|
|
serveur_ops_freres: "{{ role_path | dirname | dirname | dirname }}"
|
|
register: serveur_ops_colis_faits
|
|
|
|
- name: Assurer le dossier des colis sur le runner
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_racine }}/.colis"
|
|
state: directory
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0700"
|
|
when:
|
|
- not ansible_check_mode
|
|
- serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length > 0
|
|
|
|
- name: Porter les colis au runner
|
|
ansible.builtin.copy:
|
|
src: "{{ serveur_ops_colis_local }}/{{ item.item.depot }}.bundle"
|
|
dest: "{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0600"
|
|
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
loop_control:
|
|
label: "{{ item.item.depot }} (colis)"
|
|
when: not ansible_check_mode
|
|
|
|
- name: Recevoir chaque colis (clone neuf, ou avance rapide), puis viser la forge en SSH
|
|
ansible.builtin.shell: |
|
|
set -eu
|
|
d="{{ serveur_ops_racine }}/{{ item.item.dest }}"
|
|
c="{{ serveur_ops_racine }}/.colis/{{ item.item.depot }}.bundle"
|
|
b="{{ item.item.branche | default('main') }}"
|
|
# `git bundle verify` EXIGE UN DEPOT (mesure du 2026-10-11, a l'epreuve du colis) : hors
|
|
# d'un depot il refuse, et c'est justement le cas de l'insemination. Le clone verifie le
|
|
# colis lui-meme (un colis tronque y echoue) ; un depot existant le verifie avant de tirer.
|
|
if [ ! -d "$d/.git" ]; then
|
|
git clone -q --branch "$b" "$c" "$d"
|
|
echo "CLONE"
|
|
else
|
|
git -C "$d" bundle verify -q "$c" >/dev/null
|
|
avant=$(git -C "$d" rev-parse HEAD)
|
|
git -C "$d" fetch -q "$c" "refs/heads/$b:refs/remotes/colis/$b"
|
|
git -C "$d" merge -q --ff-only "colis/$b"
|
|
[ "$avant" = "$(git -C "$d" rev-parse HEAD)" ] || echo "AVANCE"
|
|
fi
|
|
git -C "$d" remote set-url origin "git@{{ serveur_ops_forge_ssh_hote }}:{{ serveur_ops_forge_organisation }}/{{ item.item.depot }}.git"
|
|
git -C "$d" fetch -q origin 2>/dev/null && git -C "$d" branch -q --set-upstream-to "origin/$b" || true
|
|
rm -f "$c"
|
|
args:
|
|
executable: /bin/bash
|
|
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
loop_control:
|
|
label: "{{ item.item.depot }} (colis)"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when: not ansible_check_mode
|
|
register: serveur_ops_colis_recus
|
|
changed_when: "'CLONE' in serveur_ops_colis_recus.stdout or 'AVANCE' in serveur_ops_colis_recus.stdout"
|
|
|
|
- name: Dire quels depots prives sont venus par colis
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ item.item.depot }} : le runner ne le lit pas encore en SSH ({{ (item.stderr | default('')
|
|
| trim).splitlines()[:1] | join('') }}). Apporte en colis par le controleur. S'il est
|
|
arme et que ca dure : `make forge-cles` au site, et la cle de sa voute.
|
|
loop: "{{ serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list }}"
|
|
loop_control:
|
|
label: "{{ item.item.depot }}"
|
|
|
|
- name: Retirer les colis qui resteraient (un passage interrompu en laisse)
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_racine }}/.colis"
|
|
state: absent
|
|
when:
|
|
- not ansible_check_mode
|
|
- serveur_ops_prives_ssh.results | rejectattr('rc', 'equalto', 0) | list | length == 0
|
|
|
|
- name: Poser la commande SSH de la forge dans chaque depot prive
|
|
community.general.git_config:
|
|
name: core.sshCommand
|
|
scope: local
|
|
repo: "{{ serveur_ops_racine }}/{{ item.dest }}"
|
|
value: "{{ serveur_ops_ssh_prive | trim }}"
|
|
loop: "{{ serveur_ops_depots | selectattr('role', 'in', serveur_ops_roles_prives) | list }}"
|
|
loop_control:
|
|
label: "{{ item.depot }}"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when: not ansible_check_mode
|
|
|
|
# CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20).
|
|
#
|
|
# SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas
|
|
# `inventory_gui.py` ; le redemarrer alors couperait les pages ouvertes pour rien. Le
|
|
# dossier du moteur est celui que `serveur_ops_depot_moteur` derive de la liste des
|
|
# depots — jamais un chemin recopie ici.
|
|
- name: Retenir si le moteur du génome a avancé
|
|
ansible.builtin.set_fact:
|
|
serveur_ops_moteur_avance: >-
|
|
{{ (serveur_ops_clone.results | default([]))
|
|
| selectattr('item.dest', 'equalto', serveur_ops_depot_moteur)
|
|
| selectattr('changed', 'defined') | selectattr('changed')
|
|
| list | length > 0 }}
|
|
|
|
# --- LES COLLECTIONS, SANS DEPENDRE DE GALAXY ---------------------------------
|
|
#
|
|
# UN POSTE D'EXPLOITATION QUI APPELLE galaxy.ansible.com POUR SE CONSTRUIRE N'EST PAS
|
|
# SOUVERAIN. Mesure du 2026-08-23, premier deploiement : `ansible-galaxy collection
|
|
# install` a echoue depuis l'overlay d'un écosystème —
|
|
# `SSL: UNEXPECTED_EOF_WHILE_READING` — parce que la frontiere ne laisse pas passer ce
|
|
# flux, et c'est tres bien ainsi. Ouvrir une regle vers un serveur americain pour que
|
|
# l'ecosysteme sache se reconstruire aurait ete la mauvaise reponse.
|
|
#
|
|
# MEME IDIOME QUE serveur_forgejo DEVANT SON PROPRE TIERS : le CONTROLEUR telecharge une
|
|
# fois, dans son cache, puis pousse par SSH. Aucun flux nouveau depuis la cible, et
|
|
# l'artefact devient deployable hors ligne — un poste peut se reconstruire sans internet
|
|
# du tout, ce qui est le sens meme d'une lignee autonome.
|
|
- name: Preparer le cache des collections sur le controleur
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_cache_collections }}"
|
|
state: directory
|
|
mode: "0755"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
|
|
# LES COLLECTIONS DU VERROU (2026-10-09), meme chemin que les roues : le depot du site
|
|
# d'abord, Galaxy en repli dit, la somme verifiee. `collection download` sortait sur Galaxy
|
|
# a chaque runner neuf.
|
|
- name: Prendre les collections verrouillees au depot du site
|
|
ansible.builtin.get_url:
|
|
url: "{{ setops_depot_binaires }}/{{ item.nom }}"
|
|
dest: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
|
|
checksum: "sha256:{{ item.sha256 }}"
|
|
mode: "0644"
|
|
timeout: 30
|
|
loop: "{{ serveur_ops_verrou.collections }}"
|
|
loop_control:
|
|
label: "{{ item.nom }}"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
failed_when: false
|
|
when:
|
|
- setops_depot_binaires | default('') | length > 0
|
|
- not ansible_check_mode
|
|
|
|
- name: Les collections verrouillees sont-elles dans le cache du controleur ?
|
|
ansible.builtin.stat:
|
|
path: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
|
|
checksum_algorithm: sha256
|
|
loop: "{{ serveur_ops_verrou.collections }}"
|
|
loop_control:
|
|
label: "{{ item.nom }}"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
register: serveur_ops_collections_cache
|
|
|
|
- name: Dire les collections absentes du depot du site (prises sur Galaxy)
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ serveur_ops_collections_cache.results | rejectattr('stat.exists') | map(attribute='item.nom') | list }}
|
|
— absentes du depot du site : prises sur Internet.
|
|
run_once: true
|
|
# PAS EN SIMULATION : `--check` ne telecharge rien depuis le site, le cache reste vide et
|
|
# ce message accuserait le depot a tort (vu le 2026-10-09 sur le runner de Technolibre).
|
|
when:
|
|
- not ansible_check_mode
|
|
- serveur_ops_collections_cache.results | rejectattr('stat.exists') | list | length > 0
|
|
|
|
- name: Prendre sur Galaxy les collections que le site n'a pas (repli)
|
|
ansible.builtin.get_url:
|
|
url: "{{ item.item.url }}"
|
|
dest: "{{ serveur_ops_cache_collections }}/{{ item.item.nom }}"
|
|
checksum: "sha256:{{ item.item.sha256 }}"
|
|
mode: "0644"
|
|
timeout: 60
|
|
loop: "{{ serveur_ops_collections_cache.results }}"
|
|
loop_control:
|
|
label: "{{ item.item.nom }}"
|
|
delegate_to: localhost
|
|
become: false
|
|
run_once: true
|
|
when:
|
|
- not ansible_check_mode
|
|
- not item.stat.exists or item.stat.checksum != item.item.sha256
|
|
|
|
- name: Assurer le depot des collections sur le runner
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_racine }}/.collections-hors-ligne"
|
|
state: directory
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0755"
|
|
|
|
- name: Deposer sur le poste les collections du verrou
|
|
register: serveur_ops_depot_collections
|
|
ansible.builtin.copy:
|
|
src: "{{ serveur_ops_cache_collections }}/{{ item.nom }}"
|
|
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/{{ item.nom }}"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0644"
|
|
loop: "{{ serveur_ops_verrou.collections }}"
|
|
loop_control:
|
|
label: "{{ item.nom }}"
|
|
when: not ansible_check_mode
|
|
|
|
# Le meme format que celui qu'ecrivait `collection download` : des archives en RELATIF,
|
|
# que l'installation lit depuis ce dossier (`chdir`).
|
|
- name: Ecrire la liste hors ligne des collections du verrou
|
|
register: serveur_ops_depot_collections_liste
|
|
ansible.builtin.copy:
|
|
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/requirements.yml"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0644"
|
|
content: |
|
|
# Gere par Set-OPS (role serveur_ops), d'apres verrou-runner.yml. Ne pas editer.
|
|
collections:
|
|
{% for c in serveur_ops_verrou.collections %}
|
|
- name: {{ c.nom }}
|
|
version: {{ (c.nom | replace('.tar.gz', '')).split('-') | last }}
|
|
{% endfor %}
|
|
when: not ansible_check_mode
|
|
|
|
- name: Voir si les collections sont deja la ou le moteur les cherche
|
|
ansible.builtin.stat:
|
|
path: >-
|
|
{{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur')
|
|
| first).dest }}/.ansible/collections/ansible_collections/community/general/MANIFEST.json
|
|
register: serveur_ops_collections_en_place
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
|
|
- name: Installer les collections depuis le depot local (hors ligne)
|
|
ansible.builtin.command:
|
|
cmd: >-
|
|
{{ serveur_ops_venv }}/bin/ansible-galaxy collection install
|
|
-r requirements.yml --force
|
|
-p {{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur')
|
|
| first).dest }}/.ansible/collections
|
|
chdir: "{{ serveur_ops_racine }}/.collections-hors-ligne"
|
|
# INSTALLER LA OU LE MOTEUR REGARDE (2026-08-27). Le `Makefile` pose
|
|
# `export ANSIBLE_HOME ?= $(CURDIR)/.ansible` : sous `make`, Ansible ne cherche donc ses
|
|
# collections QUE dans `<moteur>/.ansible/collections`. Ce role les deposait dans
|
|
# `<racine>/.ansible/collections` — a cote, jamais lu. Le runner du SITE echouait ainsi
|
|
# sur `couldn't resolve module/action community.general.proxmox_pool` alors que la
|
|
# collection etait bel et bien installee, complete, et visible par `ansible-doc`.
|
|
#
|
|
# POURQUOI LE MAINTENEUR NE VOYAIT RIEN : sur son poste, les collections viennent du
|
|
# paquet systeme (`/usr/lib/python3/dist-packages/ansible_collections`), toujours dans
|
|
# le chemin de recherche quel que soit `ANSIBLE_HOME`. Le reglage du Makefile n'y a
|
|
# jamais eu de consequence. Sur un runner, ou rien n'est installe par la distribution,
|
|
# il decide de tout. Encore un ecart qu'on ne voit qu'en portant le moteur ailleurs.
|
|
#
|
|
# `--force` PARCE QU'UN POSTE PEUT ETRE EN AVANCE, PAS SEULEMENT EN RETARD (2026-08-27).
|
|
# Sans lui, `ansible-galaxy` laisse en place une collection deja installee dans une
|
|
# version SUPERIEURE a celle demandee : il ne retrograde pas. Le runner du SITE, monte
|
|
# avant que `requirements.yml` n'epingle ses versions, portait `community.general` 13.3.0
|
|
# — une version d'ou les modules Proxmox ont ete RETIRES. La premiere materialisation de
|
|
# VM depuis le runner a echoue la-dessus. Le depot doit faire autorite dans les DEUX
|
|
# sens : ce que `requirements.yml` declare est ce qui est installe, ni plus haut ni plus
|
|
# bas. Voir la preuve P51.
|
|
#
|
|
# LE GARDE-FOU SUIVAIT LA MAUVAISE CHOSE (2026-08-24). Il etait `creates: …/community/general` :
|
|
# une collection AJOUTEE a requirements.yml n'aurait jamais ete installee, puisque le
|
|
# repertoire temoin existait deja. Le runner serait reste en retard sur le moteur, en
|
|
# silence. On suit desormais le DEPOT des archives : si les collections deposees
|
|
# changent, on reinstalle.
|
|
changed_when: true
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
# DEUX `when` SUR UNE MEME TACHE, C'EST UN SEUL — LE DERNIER (2026-08-24). En ajoutant
|
|
# la condition sur le depot, j'ai laisse celle du check-mode juste en dessous : YAML
|
|
# garde la derniere cle et jette l'autre, en silence. `ansible-lint` l'a vu ; personne
|
|
# d'autre ne l'aurait vu. Les conditions vont donc dans UNE liste.
|
|
when:
|
|
- serveur_ops_depot_collections is changed
|
|
or serveur_ops_depot_collections_liste is changed
|
|
or not serveur_ops_collections_en_place.stat.exists
|
|
- not ansible_check_mode
|
|
|
|
# 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 }}"
|
|
dest: >-
|
|
{{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}/instance
|
|
state: link
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
force: true
|
|
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
|
|
# entre exploiter et engendrer.
|
|
- name: Désigner la fabric sur laquelle l'écosystème repose
|
|
ansible.builtin.file:
|
|
src: "{{ serveur_ops_racine }}/{{ serveur_ops_underlay }}/underlay.yml"
|
|
dest: "{{ serveur_ops_chemin_underlay }}"
|
|
state: link
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
force: true
|
|
when:
|
|
- serveur_ops_underlay | length > 0
|
|
- not ansible_check_mode
|
|
|
|
# LA DECLARATION VAUT DANS LES DEUX SENS (2026-09-20).
|
|
#
|
|
# `serveur_ops_underlay` vide dit « ce poste exploite sans engendrer ». Le role se
|
|
# contentait alors de NE PAS POSER le lien — et un runner qui l'avait deja le gardait,
|
|
# avec le pouvoir que son plan ne lui donnait plus. Mesure du jour : le runner de
|
|
# Chezlepro-LOCATAIRE montait encore la carte du site, et sa console offrait ses 126
|
|
# etapes, creation et destruction de machines comprises.
|
|
#
|
|
# Retirer une declaration doit retirer l'artefact, sinon la declaration ne decrit plus
|
|
# rien. Meme patron que la sonde de console, qui suit son exposition dans les deux sens.
|
|
#
|
|
# ON NE RETIRE QU'UN LIEN, JAMAIS UN FICHIER. Sur un runner, `underlay.yml` a la racine du
|
|
# moteur n'est que le symlink pose ci-dessus. Si quelqu'un y a depose une vraie carte, ce
|
|
# n'est plus le meme objet — on le laisse et on le dit, plutot que de detruire un contenu
|
|
# qu'on n'a pas ecrit.
|
|
- name: Voir ce que porte la place de la fabric
|
|
ansible.builtin.stat:
|
|
path: "{{ serveur_ops_chemin_underlay }}"
|
|
register: serveur_ops_place_fabric
|
|
when: serveur_ops_underlay | length == 0
|
|
|
|
- name: Retirer la fabric quand cet écosystème ne l'engendre plus
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_chemin_underlay }}"
|
|
state: absent
|
|
when:
|
|
- serveur_ops_underlay | length == 0
|
|
- serveur_ops_place_fabric.stat.islnk | default(false)
|
|
- not ansible_check_mode
|
|
|
|
- name: Dire qu'une vraie carte occupe la place, au lieu de la détruire
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ serveur_ops_chemin_underlay }} est un FICHIER, pas le lien que ce rôle pose.
|
|
Ce plan ne déclare plus de fabric ; ce fichier n'a donc pas été touché, et ce
|
|
runner garde de quoi lire une fabric qu'il ne déclare plus. À trancher à la main.
|
|
when:
|
|
- serveur_ops_underlay | length == 0
|
|
- serveur_ops_place_fabric.stat.exists | default(false)
|
|
- not (serveur_ops_place_fabric.stat.islnk | default(false))
|
|
|
|
# --- LA CLÉ DU POSTE ---------------------------------------------------------
|
|
#
|
|
# Le poste se fabrique sa propre paire, distincte de celle du mainteneur. Deux
|
|
# exploitants, deux clés : on peut révoquer l'un sans couper l'autre — et l'accès du
|
|
# poste se lit dans les `authorized_keys` de la flotte, au lieu de se confondre avec
|
|
# celui d'un humain.
|
|
#
|
|
# `ssh-keygen` plutôt que `community.crypto.openssh_keypair` : le dépôt ne requiert que
|
|
# `community.postgresql` et `community.general`, et fabriquer une paire de clés ne
|
|
# justifie pas d'imposer une collection de plus à tout écosystème qui déploie un poste.
|
|
- name: Assurer le répertoire SSH du poste
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_racine }}/.ssh"
|
|
state: directory
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0700"
|
|
|
|
- name: Fabriquer la clé SSH du poste
|
|
ansible.builtin.command:
|
|
cmd: >-
|
|
ssh-keygen -t {{ serveur_ops_cle_type }} -N ''
|
|
-C "setops@{{ inventory_hostname }}.{{ domaine_interne }}"
|
|
-f {{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}
|
|
creates: "{{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}"
|
|
become: true
|
|
become_user: "{{ serveur_ops_utilisateur }}"
|
|
when:
|
|
- serveur_ops_cle_generer | bool
|
|
- not ansible_check_mode
|
|
register: serveur_ops_cle
|
|
|
|
- name: Relire la clé publique du poste
|
|
ansible.builtin.slurp:
|
|
src: "{{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}.pub"
|
|
register: serveur_ops_pub
|
|
when:
|
|
- serveur_ops_cle_generer | bool
|
|
- not ansible_check_mode
|
|
|
|
# ÉCRIRE, PUIS RELIRE (D-68) — et rendre le résultat EXPLOITABLE. Cette clé ne sert à
|
|
# rien tant qu'elle n'est pas autorisée sur la flotte : on l'affiche pour que l'exploitant
|
|
# la reprenne dans les intrants, au lieu de la laisser dormir sur le disque.
|
|
- name: La clé publique à autoriser sur la flotte
|
|
ansible.builtin.debug:
|
|
msg:
|
|
- "Clé publique du poste d'exploitation — à ajouter aux intrants SSH du plan :"
|
|
- "{{ serveur_ops_pub.content | b64decode | trim }}"
|
|
when:
|
|
- serveur_ops_cle_generer | bool
|
|
- not ansible_check_mode
|
|
|
|
- name: Déposer le mode d'emploi sur le poste
|
|
ansible.builtin.template:
|
|
src: LISEZ-MOI.md.j2
|
|
dest: "{{ serveur_ops_racine }}/LISEZ-MOI.md"
|
|
owner: "{{ serveur_ops_utilisateur }}"
|
|
group: "{{ serveur_ops_utilisateur }}"
|
|
mode: "0644"
|
|
|
|
# --- Sonde de supervision (docs/supervision-conception.md) --------------------------
|
|
# Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la
|
|
# fait tourner et pousse le verdict, sans savoir ce qu'elle mesure.
|
|
- name: Assurer le repertoire des sondes de supervision
|
|
ansible.builtin.file:
|
|
path: /usr/local/lib/setops/sondes
|
|
state: directory
|
|
owner: root
|
|
group: root
|
|
mode: "0755"
|
|
|
|
- name: Deposer la sonde « runner »
|
|
ansible.builtin.template:
|
|
src: sonde-runner.sh.j2
|
|
dest: /usr/local/lib/setops/sondes/runner.sh
|
|
owner: root
|
|
group: root
|
|
mode: "0750"
|
|
|
|
# --- LA CONSOLE D'EXPLOITATION, SERVIE PAR CE RUNNER (2026-09-14) ---------------------
|
|
#
|
|
# CE QU'ELLE ETAIT : `make inventaire-ui`, lancee a la main, ecoutant la boucle locale du
|
|
# POSTE. Pour la voir, il fallait etre assis devant le controleur.
|
|
#
|
|
# CE QU'ELLE DEVIENT : un service du runner, publie par l'edge de son ecosysteme sous le
|
|
# nom que le plan lui donne. Chaque ecosysteme a sa console, chez lui.
|
|
#
|
|
# ELLE RESTE SUR LA BOUCLE LOCALE. Voir `serveur_ops_gui_actif` dans les defauts : le GUI
|
|
# sert sa page a qui la demande, jeton compris — ce jeton garde contre le CSRF, pas
|
|
# contre un visiteur. Ce qui est publie, c'est le nginx qui authentifie devant.
|
|
- name: Exiger un vestibule devant la console
|
|
ansible.builtin.assert:
|
|
that:
|
|
- (serveur_ops_gui_auth != 'locale') or (serveur_ops_gui_admin_motdepasse | length > 0)
|
|
fail_msg: >-
|
|
`serveur_ops_gui_auth: locale` mais `serveur_ops_gui_admin_motdepasse` est vide.
|
|
Cette console lance des deploiements et peut raser un ecosysteme : elle ne s'ouvre
|
|
pas avec un mot de passe devine. Le renseigner depuis la voute de l'ECOSYSTEME QUI
|
|
LA PUBLIE — ce role n'en nomme aucune, il est insemine par un site qui ne la
|
|
detient pas — ou passer en `oidc`.
|
|
when: serveur_ops_gui_actif | bool
|
|
|
|
- name: Installer le vestibule de la console
|
|
ansible.builtin.apt:
|
|
name: "{{ serveur_ops_gui_paquets }}"
|
|
state: present
|
|
when: serveur_ops_gui_actif | bool
|
|
|
|
- name: Déployer le service de la console
|
|
ansible.builtin.template:
|
|
src: setops-gui.service.j2
|
|
dest: /etc/systemd/system/setops-gui.service
|
|
owner: root
|
|
group: root
|
|
mode: "0644"
|
|
when: serveur_ops_gui_actif | bool
|
|
notify: Redemarrer la console Set-OPS
|
|
|
|
# LE MOT DE PASSE DU VESTIBULE, QUAND IL N'Y A PAS D'ANNUAIRE.
|
|
#
|
|
# `htpasswd -v` VERIFIE sans rien reecrire : c'est lui qui rend la pose idempotente. Une
|
|
# comparaison de fichiers ne le pourrait pas — bcrypt sale a chaque hachage, et deux
|
|
# fichiers justes different toujours.
|
|
- name: Le mot de passe deja pose est-il le bon ?
|
|
ansible.builtin.command:
|
|
argv:
|
|
- htpasswd
|
|
- -vb
|
|
- "{{ serveur_ops_gui_htpasswd }}"
|
|
- "{{ serveur_ops_gui_admin_utilisateur }}"
|
|
- "{{ serveur_ops_gui_admin_motdepasse }}"
|
|
register: serveur_ops_gui_verif
|
|
changed_when: false
|
|
failed_when: false
|
|
no_log: true
|
|
when:
|
|
- serveur_ops_gui_actif | bool
|
|
- serveur_ops_gui_auth == 'locale'
|
|
|
|
- name: Poser le mot de passe du vestibule
|
|
ansible.builtin.command:
|
|
argv:
|
|
- htpasswd
|
|
- -cbB
|
|
- "{{ serveur_ops_gui_htpasswd }}"
|
|
- "{{ serveur_ops_gui_admin_utilisateur }}"
|
|
- "{{ serveur_ops_gui_admin_motdepasse }}"
|
|
no_log: true
|
|
# Elle ne tourne que si la verification a echoue : quand elle tourne, elle change
|
|
# vraiment quelque chose. L'idempotence vit dans le `when`, pas dans un test de sortie.
|
|
changed_when: true
|
|
when:
|
|
- serveur_ops_gui_actif | bool
|
|
- serveur_ops_gui_auth == 'locale'
|
|
- (serveur_ops_gui_verif.rc | default(1)) != 0
|
|
notify: Recharger le vestibule de la console
|
|
|
|
- name: Refermer le fichier de mots de passe sur nginx
|
|
ansible.builtin.file:
|
|
path: "{{ serveur_ops_gui_htpasswd }}"
|
|
owner: root
|
|
group: www-data
|
|
mode: "0640"
|
|
when:
|
|
- serveur_ops_gui_actif | bool
|
|
- serveur_ops_gui_auth == 'locale'
|
|
|
|
- name: Déployer le vestibule de la console
|
|
ansible.builtin.template:
|
|
src: nginx-gui.conf.j2
|
|
dest: /etc/nginx/sites-available/setops-gui.conf
|
|
owner: root
|
|
group: root
|
|
mode: "0644"
|
|
when: serveur_ops_gui_actif | bool
|
|
notify: Recharger le vestibule de la console
|
|
|
|
- name: Activer le vestibule de la console
|
|
ansible.builtin.file:
|
|
src: /etc/nginx/sites-available/setops-gui.conf
|
|
dest: /etc/nginx/sites-enabled/setops-gui.conf
|
|
state: link
|
|
when: serveur_ops_gui_actif | bool
|
|
notify: Recharger le vestibule de la console
|
|
|
|
# LE SITE PAR DEFAUT SERT TOUT CE QU'ON NE LUI A PAS DEMANDE. Le laisser en place
|
|
# donnerait une seconde porte, sans vestibule, sur le meme port.
|
|
- name: Retirer le site nginx par defaut
|
|
ansible.builtin.file:
|
|
path: /etc/nginx/sites-enabled/default
|
|
state: absent
|
|
when: serveur_ops_gui_actif | bool
|
|
notify: Recharger le vestibule de la console
|
|
|
|
- name: Activer et demarrer la console
|
|
ansible.builtin.systemd:
|
|
name: setops-gui.service
|
|
enabled: true
|
|
state: started
|
|
daemon_reload: true
|
|
when:
|
|
- serveur_ops_gui_actif | bool
|
|
- not ansible_check_mode
|
|
|
|
# LE CODE NEUF SUR LE DISQUE N'EST PAS LE CODE SERVI (2026-09-20).
|
|
#
|
|
# `state: started` ne fait rien quand le service tourne deja, et le clone du genome ne
|
|
# notifiait rien. Un runner pouvait donc porter le moteur du jour et servir, depuis sa
|
|
# memoire, celui d'il y a quatre jours. Mesure de ce jour-la sur les trois runners :
|
|
# moteur 6367d85 sur le disque, console demarree le 16 septembre a 16h36. Le handler
|
|
# « Redemarrer la console Set-OPS » existait deja ; il n'etait branche que sur le
|
|
# fichier d'unite, qui lui ne changeait pas.
|
|
#
|
|
# LE PIEGE EST CONNU AILLEURS : un certificat renouvele qu'un nginx non recharge continue
|
|
# de servir perime. Ecrire ne suffit pas — il faut que le consommateur relise.
|
|
# `python3 scripts/inventory_gui.py` lit son code au demarrage, et seulement la.
|
|
- name: Redémarrer la console quand le moteur a avancé
|
|
ansible.builtin.systemd:
|
|
name: setops-gui.service
|
|
state: restarted
|
|
daemon_reload: true
|
|
when:
|
|
- serveur_ops_gui_actif | bool
|
|
- serveur_ops_moteur_avance | default(false) | bool
|
|
- not ansible_check_mode
|
|
|
|
# UN RUNNER QUI CESSE DE PUBLIER SA CONSOLE DOIT L'ETEINDRE. Sans ce retrait, retirer
|
|
# l'exposition du plan laisserait le service tourner et le vestibule ouvert — la console
|
|
# resterait joignable alors que plus rien ne la declare.
|
|
- name: Éteindre la console quand ce runner ne la publie plus
|
|
ansible.builtin.systemd:
|
|
name: setops-gui.service
|
|
enabled: false
|
|
state: stopped
|
|
failed_when: false
|
|
when:
|
|
- not (serveur_ops_gui_actif | bool)
|
|
- not ansible_check_mode
|
|
|
|
- name: Retirer le vestibule quand ce runner ne publie plus
|
|
ansible.builtin.file:
|
|
path: /etc/nginx/sites-enabled/setops-gui.conf
|
|
state: absent
|
|
when: not (serveur_ops_gui_actif | bool)
|
|
notify: Recharger le vestibule de la console
|
|
|
|
- name: Deposer la sonde « console-ops »
|
|
ansible.builtin.template:
|
|
src: sonde-console-ops.sh.j2
|
|
dest: /usr/local/lib/setops/sondes/console-ops.sh
|
|
owner: root
|
|
group: root
|
|
mode: "0750"
|
|
when: serveur_ops_gui_actif | bool
|
|
|
|
# LA SONDE SUIT LA DECLARATION, DANS LES DEUX SENS. Un runner qui cesse de publier sa
|
|
# console garderait sinon une sonde orpheline : `client_sante` continuerait de la jouer
|
|
# et de pousser un resultat pour un service qu'Icinga ne definit plus.
|
|
- name: Retirer la sonde « console-ops » quand ce runner ne publie plus
|
|
ansible.builtin.file:
|
|
path: /usr/local/lib/setops/sondes/console-ops.sh
|
|
state: absent
|
|
when: not (serveur_ops_gui_actif | bool)
|