runner : roues et collections prises au site, plus a PyPI ni a Galaxy

verrou-runner.yml fige les 15 roues et les 3 collections qui tournent sur le
runner de Technolibre, avec URL amont et somme SHA-256. Le cache du runner
tenait deux versions de six bibliotheques selon le jour du montage.

- serveur_artefacts depose ces 18 fichiers au depot de binaires du site.
- serveur_ops les prend au site d'abord, somme verifiee ; repli sur Internet
  dit en clair ; plus aucun pip download ni collection download ; seuls les
  fichiers du verrou sont deposes sur le runner.
- ansible-core epingle a 2.18.19 (la version deployee).
- P70 exige le verrou au site ; test_verrou_runner.py dans make test.

AGENTS.md et CLAUDE.md : version allegee par l'operateur.

94 preuves. CHANGELOG (121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-09 10:24:38 -04:00
parent 279e583701
commit 4ebca13058
11 changed files with 816 additions and 715 deletions

996
AGENTS.md

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,53 @@
# CHANGELOG — Set-OPS
## 2026-10-09 (121) — Le runner prend ses roues et ses collections au site, plus à PyPI ni à Galaxy
**94 preuves.** Les deux dernières dépendances Internet d'une reconstruction, mesurées le
2026-10-05, se ferment comme l'app store de Nextcloud (entrée (117)) : **le site fournit
tous les artefacts**.
### Pourquoi un verrou
`serveur_ops` laissait `pip download` et `ansible-galaxy collection download` résoudre les
versions au moment du montage. Mesuré sur le runner de Technolibre : le cache tenait **deux
versions de six bibliothèques** (`cryptography` 50.0.1 et 50.0.2, `urllib3` 2.7.0 et
2.8.0…) : PyPI avait bougé entre l'insémination et le montage.
On ne peut pas déposer au site « ce que PyPI répondra demain ». D'où `verrou-runner.yml`, à
la racine : les **15 roues** (Python 3.13) et les **3 collections** qui tournent aujourd'hui
sur ce runner, chacune avec son URL amont et sa somme SHA-256. `ansible-core` passe de
`>=2.18,<2.19` à `==2.18.19`, la version déjà déployée : aucun changement de version sur
la flotte.
### Ce qui change
- **`serveur_artefacts`** lit le verrou et dépose les 18 fichiers au dépôt de binaires du
site (`/setops-binaires`), somme vérifiée au téléchargement.
- **`serveur_ops`** les prend **au site d'abord**, somme vérifiée ; un fichier absent ou
dont la somme diffère est pris sur Internet **en le disant** (« absentes du dépôt du
site … : prises sur Internet »), et sa somme est encore vérifiée. Plus aucun
`pip download` ni `collection download`. Seuls les fichiers du verrou sont déposés sur
le runner ; les roues d'anciennes versions sont retirées. Les collections s'installent
depuis un `requirements.yml` généré du verrou.
- **P70** exige les fichiers du verrou au dépôt de binaires, et que la tâche du site lise
le verrou. **`test_verrou_runner.py`** (dans `make test`) : URL et somme bien formées, une
seule version par bibliothèque, roues compilées pour le Python cible, épinglages de
`requirements-python.txt` et de `serveur_ops_ansible` égaux au verrou, collections
égales à `requirements.yml`, plus aucun téléchargement résolu dans `serveur_ops`.
### Témoins
Trois, chacun échoue comme attendu puis repasse une fois remis : l'ancien `serveur_ops`
(« download » encore présent) ; un verrou à deux `urllib3` (« une seule version par
bibliothèque … {'urllib3': ['2.7.0', '2.8.0']} ») ; P70 quand le site ne lit plus le
verrou. Le deuxième a d'abord fait planter le test : une somme fictive de zéros est lue
par YAML comme un entier ; le test convertit désormais en texte.
### Pas encore fait
Rien n'est déployé. Il reste : `serveur_artefacts` au site (simulé d'abord), vérifier les
18 sommes servies depuis un runner de locataire, puis reconstruire les deux locataires sur
ce commit pour prouver qu'aucun montage ne sort vers PyPI ni Galaxy.
## 2026-10-09 (120) — Chezlepro reconstruite sur `3afb756` : les deux locataires sur le même commit
**94 preuves.** `make reconstruire-locataire TENANT=OPS-Chezlepro` (44 min, journal

View file

@ -4,24 +4,3 @@
`AGENTS.md` est la **source d'autorité unique** de ce dépôt. Claude Code doit le lire
et le respecter intégralement avant toute modification.
En cas de contradiction entre `CLAUDE.md` et `AGENTS.md`, **suivre `AGENTS.md`**.
Ce fichier est volontairement mince : il ne redéclare pas la doctrine (template, SSH,
pare-feu, cloud-init, handlers, Makefile, groupes…). Toute cette doctrine vit dans
`AGENTS.md`, pour éviter la duplication et les dérives qu'elle provoque.
## Les cinq règles absolues
1. **Un seul agent IA** travaille dans ce dépôt à la fois (Codex *ou* Claude, jamais les deux).
2. **`instance/inventories/*/hosts.yml` est un artefact GÉNÉRÉ** depuis le plan
(`instance/plan/`). On l'édite jamais à la main : on édite le plan, puis
`make instancier` → `make instancier-appliquer`.
3. **Aucun secret en clair** (mots de passe, clés privées, tokens, certificats) — Vault
ou stockage hors dépôt uniquement.
4. **Toute action destructive ou risquée exige une confirmation explicite** (ex.
`CONFIRMER=true`, `confirm_destructive_action: true`). Sans elle, refuser l'exécution.
5. **Rien n'est « prêt » sans validation.** Au minimum `--syntax-check` du playbook
touché, `ansible-lint` si disponible, et l'entrée `CHANGELOG.md` correspondante.
Le détail de chacune de ces règles, et tout le reste, est dans `AGENTS.md`.

View file

@ -321,6 +321,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_genome_colis.py
python3 scripts/tests/test_verifier_depot.py
python3 scripts/tests/test_apps_nextcloud.py
python3 scripts/tests/test_verrou_runner.py
python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py

View file

@ -19,9 +19,13 @@ quarante minutes sur la **première** machine.
| | |
|---|---|
| **dépôts apt** | ce rôle — un cache, servi à la flotte |
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud, oauth2-proxy, collections) | le **cache du contrôleur** : il télécharge une fois et pousse par SSH |
| **téléchargements directs** (Forgejo, Keycloak, Nextcloud et ses applications, oauth2-proxy) | ce rôle — le **dépôt de binaires** (`/var/lib/setops/artefacts-directs`, servi sous `/setops-binaires` sur le même port) |
| **ce que le runner installe** (roues Python, collections Ansible, `verrou-runner.yml`) | ce rôle — le même dépôt de binaires |
Deux mécanismes parce que ce sont deux problèmes : on ne sert pas un dépôt apt par `scp`.
Les rôles consommateurs prennent ces fichiers au dépôt du site d'abord, vérifient leur somme
(ou leur signature), et ne sortent sur Internet qu'en repli, en le disant. Depuis le
2026-10-09, une reconstruction ne demande plus rien à PyPI, Galaxy ni à l'app store de
Nextcloud quand le dépôt est rempli.
## Ce qu'il ne couvre pas encore

View file

@ -199,6 +199,14 @@
label: "{{ item }}"
when: serveur_artefacts_directs_actif | bool
# CE QUE LE RUNNER INSTALLE (2026-10-09) : ses roues Python et ses collections, verrouillees
# a l'octet dans `verrou-runner.yml`, a la racine du moteur. Lues, pas recopiees.
- name: Lire le verrou du runner (roues Python, collections Ansible)
ansible.builtin.include_vars:
file: "{{ role_path }}/../../verrou-runner.yml"
name: serveur_artefacts_verrou_runner
when: serveur_artefacts_directs_actif | bool
# `force: false` ET UN `dest` NOMME PAR LA VERSION : une publication ne change pas sous
# le meme nom, donc un fichier deja present est le bon. Une nouvelle version porte un
# autre nom et se telecharge d'elle-meme — le depot se met a jour en suivant les roles,
@ -211,12 +219,18 @@
ansible.builtin.get_url:
url: "{{ item.url }}"
dest: "{{ serveur_artefacts_directs_dir }}/{{ item.nom }}"
checksum: "{{ ('sha256:' ~ item.sha256) if item.sha256 is defined else omit }}"
mode: "0644"
force: false
timeout: 60
# Les applications de Nextcloud (2026-10-08) : declarees par `serveur_nextcloud`, lues
# telles quelles — meme `nom`, meme `url`, rien de recopie ici.
loop: "{{ serveur_artefacts_directs + (serveur_nextcloud_apps | default([])) }}"
# Et le verrou du runner (2026-10-09). Un artefact qui declare sa somme est verifie ici :
# le site ne tient pas un fichier altere en chemin.
loop: >-
{{ serveur_artefacts_directs + (serveur_nextcloud_apps | default([]))
+ (serveur_artefacts_verrou_runner.python | default([]))
+ (serveur_artefacts_verrou_runner.collections | default([])) }}
loop_control:
label: "{{ item.nom }}"
register: serveur_artefacts_directs_tires

View file

@ -30,7 +30,7 @@ serveur_ops_paquets:
# Ansible est ÉPINGLÉ sur la même famille que le poste du mainteneur (core 2.18) : un
# écosystème qui se reconstruit avec une version différente de celle qui l'a construit ne
# reproduit pas la même chose, et l'écart ne se voit qu'au premier échec.
serveur_ops_ansible: "ansible-core>=2.18,<2.19"
serveur_ops_ansible: "ansible-core==2.18.19"
# --- D'OÙ VIENT LE GÉNOME ----------------------------------------------------
#

View file

@ -108,80 +108,124 @@
become: false
run_once: true
# LE CONTROLEUR ET LA CIBLE DOIVENT PARTAGER LEUR PYTHON, et ca ne se devine pas.
# LE VERROU DU RUNNER (2026-10-09) — `verrou-runner.yml`, a la racine du moteur.
#
# `pip download` rend les roues du systeme qui telecharge. `ansible-core`, `proxmoxer` et
# `requests` sont universelles (`py3-none-any`), mais `pyyaml` porte du C compile : une
# roue `cp313` posee sur un python 3.12 ne s'installe pas. L'echec serait « No matching
# distribution found » a l'installation — un message qui accuse le depot local alors que
# le coupable est l'ecart entre deux machines. On le mesure ici, ou on peut encore le dire.
- name: Lire le Python du controleur
ansible.builtin.command:
argv: ["python3", "-c", "import sys; print('%d.%d' % sys.version_info[:2])"]
delegate_to: localhost
become: false
run_once: true
changed_when: false
# LIRE UNE VERSION N'EST PAS CHANGER, ET `--check` RENDAIT LA GARDE MUETTE (2026-09-14).
#
# Sans `check_mode: false`, cette commande est sautee en mode idempotent : la garde qui
# suit compare alors contre du VIDE et refuse.
#
# Le controleur tourne Python et site-ops-01 tourne Python 3.13
#
# L'espace apres « Python » est tout le diagnostic — il n'y avait rien a comparer. Un
# essai a blanc rendait donc un echec sur le runner, alors que le deploiement reel passe.
check_mode: false
register: serveur_ops_python_controleur
# 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
- name: Exiger que le controleur et la cible partagent leur version de Python
# 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:
- serveur_ops_python_controleur.stdout | trim
== ansible_python_version.split('.')[0:2] | join('.')
- ansible_python_version.split('.')[0:2] | join('.') == serveur_ops_verrou.python_cible | string
fail_msg: >-
Le controleur tourne Python {{ serveur_ops_python_controleur.stdout | trim }} et
{{ inventory_hostname }} tourne Python
{{ ansible_python_version.split('.')[0:2] | join('.') }}. Les roues telechargees ne
s'installeraient pas sur la cible, et l'echec accuserait le depot local au lieu de
cet ecart. Materialiser depuis un controleur de meme famille — pendant une
insemination, c'est le runner du SITE, qui porte le meme Debian que la machine
qu'il amorce.
{{ 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.
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
# Chezlepro/… » chez le mainteneur). Meme piege que pour ansible-galaxy le 2026-08-23.
- name: Telecharger les roues sur le controleur (une seule fois par empreinte)
ansible.builtin.command:
argv:
- "python3"
- "-m"
- "pip"
- "download"
- "--dest"
- "{{ serveur_ops_cache_roues }}"
- "--requirement"
- "{{ serveur_ops_roues_source }}"
- "{{ serveur_ops_ansible }}"
- "pyyaml"
# 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
register: serveur_ops_roues_telechargees
changed_when: "'Saved' in serveur_ops_roues_telechargees.stdout"
# LE GARDE-FOU SUIT LE RESULTAT, PAS LE GESTE (lecon du 2026-08-27) : on saute quand le
# cache PORTE deja des roues, pas quand une commande a deja tourne un jour.
when: not ansible_check_mode
failed_when: false
when:
- setops_depot_binaires | default('') | length > 0
- not ansible_check_mode
- name: Deposer les roues sur le runner
- 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
when: 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.
- name: Deposer sur le runner les roues du verrou
ansible.builtin.copy:
src: "{{ serveur_ops_cache_roues }}/"
dest: "{{ serveur_ops_roues_depot }}/"
src: "{{ serveur_ops_cache_roues }}/{{ item.nom }}"
dest: "{{ serveur_ops_roues_depot }}/{{ item.nom }}"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
directory_mode: "0755"
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
@ -336,48 +380,95 @@
become: false
run_once: true
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
# Chezlepro/… » chez le mainteneur), et `cmd` la lit comme un separateur d'arguments.
# Constate le 2026-08-23 : ansible-galaxy recevait « /home/…/Espace » puis
# « Chezlepro/… » comme deux arguments, et refusait — « positional collection_name arg
# and --requirements-file are mutually exclusive ». Le message ne parlait pas du tout du
# vrai probleme.
- name: Telecharger les collections dans le cache du controleur (une seule fois)
ansible.builtin.command:
argv:
- "ansible-galaxy"
- "collection"
- "download"
- "-r"
- "{{ serveur_ops_requirements_controleur }}"
- "-p"
- "{{ serveur_ops_cache_collections }}"
creates: "{{ serveur_ops_cache_collections }}/requirements.yml"
# 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
when: not ansible_check_mode
failed_when: false
when:
- setops_depot_binaires | default('') | length > 0
- not ansible_check_mode
- name: Deposer les collections sur le poste
- 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
when: 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: Deposer sur le poste les collections du verrou
register: serveur_ops_depot_collections
ansible.builtin.copy:
src: "{{ serveur_ops_cache_collections }}/"
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/"
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"
directory_mode: "0755"
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 | regex_replace('^.*-([0-9][^-]*)[.]tar[.]gz$', '\\1') }}
{% endfor %}
when: not ansible_check_mode
# `chdir` OBLIGATOIRE : le `requirements.yml` produit par `collection download` nomme les
# archives en RELATIF (`community-postgresql-4.2.0.tar.gz`), et ansible-galaxy les cherche
# depuis le repertoire COURANT, pas depuis celui du fichier. Sans chdir : « Could not find
# community-postgresql-4.2.0.tar.gz » alors que l'archive est bien la, a cote.
# LE GARDE-FOU DOIT SUIVRE LE RESULTAT, PAS LE GESTE (2026-08-27). La condition
# d'installation ne regardait que le DEPOT des archives. Le jour ou l'on a corrige le
# CHEMIN d'installation, les archives etaient inchangees : le role a donc conclu qu'il
# n'y avait rien a faire, et les collections sont restees a l'ancien endroit. Le meme
# piege qu'en 2026-08-24, deplace d'un cran. On mesure desormais la DESTINATION.
- name: Voir si les collections sont deja la ou le moteur les cherche
ansible.builtin.stat:
path: >-
@ -431,6 +522,7 @@
# 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

View file

@ -3898,6 +3898,14 @@ def preuve_depot_binaires_complet() -> tuple[bool, str]:
tenus |= set(noms_de[modele])
attendus: dict[str, str] = {}
# LE VERROU DU RUNNER (2026-10-09) : ses roues et ses collections, que `serveur_ops` va
# chercher. Tenues seulement si le site lit ce verrou.
verrou = _yaml.safe_load((RACINE / "verrou-runner.yml").read_text(encoding="utf-8")) or {}
noms_verrou = [str(e["nom"]) for k in ("python", "collections") for e in verrou.get(k) or []]
for nom in noms_verrou:
attendus[nom] = "serveur_ops (verrou-runner.yml)"
if "verrou-runner.yml" in taches_site:
tenus |= set(noms_verrou)
for role in sorted(p.name for p in roles_dir.iterdir() if p.is_dir()):
for tf in sorted((roles_dir / role / "tasks").glob("*.yml")) if (roles_dir / role / "tasks").is_dir() else []:
texte = tf.read_text(encoding="utf-8")

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""`verrou-runner.yml` : ce que le runner installe, une version par bibliotheque, coherent avec
les epingles — et plus aucune resolution contre PyPI ou Galaxy dans les taches.
POURQUOI (2026-10-09). Le runner de Technolibre portait deux versions de six bibliotheques :
`pip download` resolvait contre PyPI a chaque passage, et PyPI avait bouge entre
l'insemination et le montage. Le verrou nomme chaque fichier ; ce test garde qu'il reste un
verrou (une version par bibliotheque), que ses roues vont au Python declare, et que les
epingles ecrites ailleurs disent la meme chose que lui.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[2]
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def norme(nom: str) -> str:
return re.sub(r"[-_.]+", "_", nom).lower()
def main() -> int:
v = yaml.safe_load((RACINE / "verrou-runner.yml").read_text())
roues, colls = v["python"], v["collections"]
for e in roues + colls:
verifier(e["url"].endswith("/" + e["nom"]) and re.fullmatch(r"[0-9a-f]{64}", str(e["sha256"])) is not None,
f"{e['nom']} : url qui le nomme, sha256 bien formee")
versions: dict[str, set[str]] = {}
for e in roues:
dist, ver = e["nom"].split("-")[0], e["nom"].split("-")[1]
versions.setdefault(norme(dist), set()).add(ver)
doubles = {d: sorted(vs) for d, vs in versions.items() if len(vs) > 1}
verifier(not doubles, f"une seule version par bibliotheque ({len(versions)} bibliotheques){' : ' + str(doubles) if doubles else ''}")
cible = "cp" + str(v["python_cible"]).replace(".", "")
hors = [e["nom"] for e in roues if e["nom"].split("-")[2].startswith("cp") and "abi3" not in e["nom"]
and e["nom"].split("-")[2] != cible]
verifier(not hors, f"les roues compilees vont a Python {v['python_cible']} ({cible}){' : ' + str(hors) if hors else ''}")
epingles = {}
for l in (RACINE / "requirements-python.txt").read_text().splitlines():
m = re.match(r"^\s*([A-Za-z0-9_.-]+)==([^\s#]+)", l)
if m:
epingles[norme(m.group(1))] = m.group(2)
defauts = yaml.safe_load((RACINE / "roles/serveur_ops/defaults/main.yml").read_text())
m = re.fullmatch(r"\s*([A-Za-z0-9_.-]+)==(\S+)\s*", defauts["serveur_ops_ansible"])
verifier(m is not None, f"serveur_ops_ansible est une epingle exacte ({defauts['serveur_ops_ansible']})")
if m:
epingles[norme(m.group(1))] = m.group(2)
for dist, ver in sorted(epingles.items()):
verifier(versions.get(dist) == {ver}, f"{dist}=={ver} epingle, et c'est la version du verrou ({sorted(versions.get(dist, []))})")
req = yaml.safe_load((RACINE / "requirements.yml").read_text())["collections"]
attendus = sorted(f"{c['name'].replace('.', '-')}-{c['version']}.tar.gz" for c in req)
verifier(sorted(e["nom"] for e in colls) == attendus, f"les collections du verrou sont celles de requirements.yml ({attendus})")
taches = [l for l in (RACINE / "roles/serveur_ops/tasks/main.yml").read_text().splitlines()
if not l.lstrip().startswith("#")]
texte = "\n".join(taches)
verifier('"download"' not in texte and "collection download" not in texte,
"plus aucune resolution contre PyPI ou Galaxy dans serveur_ops (pip / collection download)")
verifier("verrou-runner.yml" in (RACINE / "roles/serveur_artefacts/tasks/main.yml").read_text(),
"le site tient le verrou du runner")
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLe verrou nomme chaque fichier du runner, une version par bibliotheque, et le site le tient.")
return 0
if __name__ == "__main__":
sys.exit(main())

79
verrou-runner.yml Normal file
View file

@ -0,0 +1,79 @@
---
# CE QUE LE RUNNER INSTALLE, A L'OCTET PRES — tenu par le site, verifie a la reception.
#
# POURQUOI (2026-10-09). A chaque reconstruction, le runner sortait sur Internet : `pip
# download` interrogeait PyPI, et un runner neuf tirait ses collections de Galaxy. Seules
# deux bibliotheques etaient epinglees ; `ansible-core` etait borne (>=2.18,<2.19), le reste
# libre. Mesure sur le runner de Technolibre : son depot de roues portait DEUX versions de
# six bibliotheques (cryptography 50.0.1 et 50.0.2, urllib3 2.7.0 et 2.8.0...) — PyPI avait
# bouge entre l'insemination et le montage, et personne n'avait choisi celle qui tournait.
#
# CE VERROU EST CE QUI TOURNE : les quinze roues installees dans le venv de ce runner (pip
# freeze), et les trois collections qu'il a recues, avec leurs sommes, releves le
# 2026-10-09. Chaque URL a ete telechargee depuis le poste et rend exactement sa somme.
#
# QUI LE LIT : `serveur_artefacts` (le site tient ces fichiers dans son depot de binaires),
# `serveur_ops` (le runner les prend au site, ne depose QUE ceux-ci, installe hors ligne),
# P70 (le site tient tout ce que le runner va chercher), `test_verrou_runner.py`.
#
# MONTER UNE VERSION est un geste delibere : remplacer la ligne (nom, url, sha256), et les
# epingles de `requirements-python.txt`, `serveur_ops_ansible` et `requirements.yml`, que
# le test confronte a ce verrou.
# Le Python pour lequel les roues sont faites (`cp313`) ; la cible doit le porter.
python_cible: "3.13"
python:
- nom: ansible_core-2.18.19-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/a/ansible-core/ansible_core-2.18.19-py3-none-any.whl
sha256: 00f53c10d97ce001f6e49857820901b3e8d2f234cb9f5fd9fdf8092c14109fa0
- nom: certifi-2026.7.22-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/c/certifi/certifi-2026.7.22-py3-none-any.whl
sha256: 62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775
- nom: cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/c/cffi/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
sha256: a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2
- nom: charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/c/charset-normalizer/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl # yamllint disable-line rule:line-length
sha256: 7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4
- nom: cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl
url: https://files.pythonhosted.org/packages/cp311/c/cryptography/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl
sha256: 9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079
- nom: idna-3.20-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/i/idna/idna-3.20-py3-none-any.whl
sha256: ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
- nom: jinja2-3.1.6-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/j/jinja2/jinja2-3.1.6-py3-none-any.whl
sha256: 85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
- nom: markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/m/markupsafe/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl # yamllint disable-line rule:line-length
sha256: 434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6
- nom: packaging-26.3-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/p/packaging/packaging-26.3-py3-none-any.whl
sha256: d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
- nom: proxmoxer-2.2.0-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/p/proxmoxer/proxmoxer-2.2.0-py3-none-any.whl
sha256: 7e4431fd38e1a9321eedf6d860ddc373fc2b29ce1845fa8b072d7580d99f6b90
- nom: pycparser-3.1-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/p/pycparser/pycparser-3.1-py3-none-any.whl
sha256: f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882
- nom: pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
url: https://files.pythonhosted.org/packages/cp313/p/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
sha256: 0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6
- nom: requests-2.32.3-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/r/requests/requests-2.32.3-py3-none-any.whl
sha256: 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
- nom: resolvelib-1.0.1-py2.py3-none-any.whl
url: https://files.pythonhosted.org/packages/py2.py3/r/resolvelib/resolvelib-1.0.1-py2.py3-none-any.whl
sha256: d2da45d1a8dfee81bdd591647783e340ef3bcb104b54c383f70d422ef5cc7dbf
- nom: urllib3-2.8.0-py3-none-any.whl
url: https://files.pythonhosted.org/packages/py3/u/urllib3/urllib3-2.8.0-py3-none-any.whl
sha256: 0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3
collections:
- nom: ansible-posix-1.6.2.tar.gz
url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/ansible-posix-1.6.2.tar.gz
sha256: 3c6b4d4f326cb42490b75644179e809ed09f3040ddaf0763bd6ea9e0f2ef8c47
- nom: community-general-10.3.0.tar.gz
url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-general-10.3.0.tar.gz
sha256: f1df87cca61c7c22aa1d66fbb3864185da71d0141f825a1385b8c4b7a635b129
- nom: community-postgresql-3.10.2.tar.gz
url: https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-postgresql-3.10.2.tar.gz
sha256: f0ad8976e502d28c901b8bbeb5a4a85a82f86e28fac245c4caa3d60a618e14e2