Set-OPS-Public/roles/serveur_ops/tasks/main.yml
Daniel Allaire 3fc16c845d serveur_ops : le dossier SSH avant d'y epingler la forge
La reconstruction de Technolibre s'est arretee a l'insemination : la cle d'hote de la
forge s'ecrivait dans /opt/setops/.ssh, que le role ne creait que plus loin. Les runners
existants l'avaient deja ; seule une machine neuve le montre.

95 preuves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:18:52 -04:00

1091 lines
47 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
# LE DOSSIER SSH D'ABORD (mesure du 2026-10-11, insemination de Technolibre). Il n'etait cree
# que plus bas, avec la cle du poste : sur un runner existant il etait la, et les trois
# deploiements comme l'epreuve du colis n'ont rien vu. Une machine NEUVE s'est arretee ici :
# « Destination directory /opt/setops/.ssh does not exist ». La tache du bas reste, inchangee.
- name: Assurer le repertoire SSH du poste (avant d'y epingler la forge)
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.ssh"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0700"
when: serveur_ops_forge_cle_hote | 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)