repertoires partages : un seul mode par repertoire ; le depot de binaires du site sert de nouveau

/var/lib/setops etait tenu par quatre roles (0750 contre 0755) : apt-cacher-ng
ne le traversait plus, le depot rendait 403 et chaque runner reconstruit allait
chercher 500 Mo sur Internet. /etc/setops (0700 contre 0755) et /srv/restic au
site portaient le meme desaccord. test_repertoires_partages.py le refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-01 14:01:54 -04:00
parent c1408be9bb
commit 7a2f179f39
9 changed files with 173 additions and 5 deletions

View file

@ -1,5 +1,39 @@
# CHANGELOG — Set-OPS
## 2026-10-01 (58) — Le dépôt de binaires du site rendait 403 : quatre rôles se disputaient un répertoire
**Le symptôme** (reconstruction de Technolibre lancée par l'exploitant, 63 min au lieu de
~43) : `deployer-tout` restait 20 min sur « Télécharger le tarball Nextcloud dans le cache du
contrôleur » — 281 Mo depuis download.nextcloud.com à 130–230 Ko/s. Le runner, né à vide,
devait les prendre au dépôt de binaires du SITE ; l'étape qui l'y cherche (`failed_when:
false`, « le dépôt est une commodité ») avait rendu « ok » sur un **403**.
**La cause** : le dépôt existe et est complet depuis le 2026-09-12 (Forgejo, Keycloak,
Nextcloud 34.0.2, oauth2-proxy), `LocalDirs` est en place — mais apt-cacher-ng, qui ne tourne
pas en root, ne traversait plus `/var/lib/setops` (0750). QUATRE rôles tiennent ce
répertoire : `common_packages`, `client_journal`, `serveur_ops_site` en 0750, et
`serveur_artefacts` en 0755. Le dernier déployé gagnait ; mes déploiements au site du matin
l'avaient refermé (06:16). Le piège était documenté dans `serveur_artefacts` — pas chez les
trois autres.
**Correction** : 0755 partout (le répertoire ne tient que des états de sondes, déjà lisibles) ;
appliqué à `site-cache-01` : les quatre archives répondent 200, à la taille exacte, depuis une
VM de locataire.
**La garde, `test_repertoires_partages.py`** : un répertoire dont plusieurs rôles imposent le
mode n'en a qu'un — chemins Jinja RÉSOLUS depuis les défauts (celui de `serveur_artefacts`
s'écrit `{{ … | dirname }}`, une lecture littérale ne l'aurait pas vu). À sa première
exécution, elle a trouvé deux autres désaccords :
- `/etc/setops` : 0700 (`client_backup`, `serveur_backup`) contre 0755 (`client_sante`,
`serveur_icinga`). Il tient des secrets, tous ses lecteurs tournent en root : **0700**.
- `/srv/restic` sur `site-backup-01` : `serveur_backup` y est appliqué deux fois — en
dépendance de `serveur_backup_site` (racine `/srv/restic/site`) et par son propre groupe
(défaut `/srv/restic`, 0700 au compte `restic`). Joué seul, ce second passage refermait le
parent des dépôts de tous les locataires (sshd `StrictModes`, mesuré le 2026-09-01). Dormant
— un déploiement complet du site rejoue `serveur_backup_site` après. L'inventaire du site
donne désormais au groupe la racine de la dépendance, LUE dans son `meta/main.yml`. Le test
admet ce cas : une dépendance qui surcharge le chemin n'est pas un désaccord.
## 2026-10-01 (57) — Aucune exception : la soumission n'est ouverte que là où elle existe
J'avais présenté `site-mon-01:465/587` (« sans service ») comme une exception tolérable.

View file

@ -322,6 +322,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_proxmox_fw_externe.py
python3 scripts/tests/test_sonde_tcp.py
python3 scripts/tests/test_restauration.py
python3 scripts/tests/test_repertoires_partages.py
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine

View file

@ -225,11 +225,16 @@
# perdre ferait passer une cicatrice ancienne pour une perte en cours.
- name: Assurer le repertoire d etat des sondes
ansible.builtin.file:
# 0755, COMME PARTOUT AILLEURS (2026-10-01) : quatre roles tenaient ce repertoire, trois
# en 0750 et `serveur_artefacts` en 0755 — apt-cacher-ng doit le traverser pour servir
# le depot de binaires du site. Le dernier deploye gagnait : au site, le 2026-10-01, il
# rendait 403, et chaque runner allait chercher 500 Mo sur Internet. Le repertoire ne
# tient que des etats de sondes, deja lisibles. Garde : test_repertoires_partages.py.
path: /var/lib/setops
state: directory
owner: root
group: root
mode: "0750"
mode: "0755"
- name: Deposer la sonde d expedition des journaux
ansible.builtin.template:

View file

@ -27,11 +27,14 @@
- name: Assurer le répertoire des secrets Set-OPS
ansible.builtin.file:
# 0700, COMME `client_backup` ET `serveur_backup` (2026-10-01) : ce repertoire tient des
# secrets, et tous ses lecteurs tournent en root. Ce role etait seul a l'ouvrir en 0755,
# et le dernier deploye gagnait. Garde : test_repertoires_partages.py.
path: /etc/setops
state: directory
owner: root
group: root
mode: "0755"
mode: "0700"
- name: Déposer le mot de passe d'API Icinga
ansible.builtin.copy:

View file

@ -147,11 +147,16 @@
- name: Assurer le repertoire d etat des sondes
ansible.builtin.file:
# 0755, COMME PARTOUT AILLEURS (2026-10-01) : quatre roles tenaient ce repertoire, trois
# en 0750 et `serveur_artefacts` en 0755 — apt-cacher-ng doit le traverser pour servir
# le depot de binaires du site. Le dernier deploye gagnait : au site, le 2026-10-01, il
# rendait 403, et chaque runner allait chercher 500 Mo sur Internet. Le repertoire ne
# tient que des etats de sondes, deja lisibles. Garde : test_repertoires_partages.py.
path: /var/lib/setops
state: directory
owner: root
group: root
mode: "0750"
mode: "0755"
- name: Deposer la sonde des correctifs de securite
ansible.builtin.template:

View file

@ -451,7 +451,7 @@
state: directory
owner: root
group: root
mode: "0755"
mode: "0700" # comme client_sante et client_backup (test_repertoires_partages.py)
delegate_to: "{{ groups['serveur_backup'] | first }}"
- name: Deposer l'AC sur le depot de sauvegarde

View file

@ -156,11 +156,16 @@
# consigne l'ecart ; la sonde « fabric » le lit. Voir `templates/conformite-fabric.sh.j2`.
- name: Assurer le repertoire d'etat
ansible.builtin.file:
# 0755, COMME PARTOUT AILLEURS (2026-10-01) : quatre roles tenaient ce repertoire, trois
# en 0750 et `serveur_artefacts` en 0755 — apt-cacher-ng doit le traverser pour servir
# le depot de binaires du site. Le dernier deploye gagnait : au site, le 2026-10-01, il
# rendait 403, et chaque runner allait chercher 500 Mo sur Internet. Le repertoire ne
# tient que des etats de sondes, deja lisibles. Garde : test_repertoires_partages.py.
path: /var/lib/setops
state: directory
owner: root
group: root
mode: "0750"
mode: "0755"
- name: Deposer le constat de conformite de la fabric
ansible.builtin.template:

View file

@ -687,6 +687,15 @@ def inventaire() -> dict:
"nftables_baseline_ruleset_genere": str(
(U.chemin().resolve().parent / "flux-genere" / f"{nom}.nft")
if U.chemin() else ""),
# LA RACINE DE `serveur_backup` SUR LA MACHINE DU DEPOT DU SITE (2026-10-01). Elle y
# est appliquee DEUX fois : en dependance de `serveur_backup_site`, qui lui passe
# `/srv/restic/site`, et par son propre groupe (le temoin des depots dans Icinga),
# qui reprenait le defaut `/srv/restic` — en 0700 au compte `restic`. Joue seul, ce
# second passage refermait le parent des depots de tous les locataires : sshd
# refuse alors leurs comptes (`StrictModes`, mesure le 2026-09-01). La valeur est
# LUE dans la dependance, pas recopiee : `test_repertoires_partages.py`.
**({"serveur_backup_racine": _racine_backup_du_site()}
if "serveur_backup_site" in _groupes_de(nom) else {}),
# La liste que la sonde `connectivite` teste, ecrite a cote du ruleset et pour
# la meme raison (2026-09-30) : au site, le defaut pointait hors du depot, et la
# sonde aurait tourne sans liste.
@ -997,6 +1006,15 @@ def inventaire() -> dict:
return out
def _racine_backup_du_site() -> str:
"""La racine que `serveur_backup_site` passe a sa dependance `serveur_backup`."""
meta = Path(__file__).resolve().parent.parent / "roles" / "serveur_backup_site" / "meta" / "main.yml"
for dep in (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("dependencies") or []:
if isinstance(dep, dict) and dep.get("role") == "serveur_backup" and dep.get("serveur_backup_racine"):
return str(dep["serveur_backup_racine"])
raise SystemExit("serveur_backup_site/meta/main.yml ne passe plus `serveur_backup_racine` a sa dependance.")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--list", action="store_true")

View file

@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Un repertoire tenu par plusieurs roles a UN seul mode — sinon le dernier deploye gagne.
POURQUOI (2026-10-01). Quatre roles tenaient `/var/lib/setops` : trois en 0750, un en 0755
(`serveur_artefacts`, dont apt-cacher-ng doit traverser le chemin pour servir le depot de
binaires). Chaque deploiement basculait le mode selon l'ordre des roles. Au site, le
2026-10-01, le repertoire etait en 0750 : le depot rendait 403, et chaque runner reconstruit
allait chercher 500 Mo sur Internet — une reconstruction prenait 70 min au lieu de 43.
`/etc/setops` portait le meme desaccord (0700 contre 0755).
On releve chaque tache `file: state=directory` qui impose un `mode`, chemins Jinja RESOLUS
depuis les defauts du role (le chemin de `serveur_artefacts` est ecrit `{{ ... | dirname }}`
— une lecture litterale ne l'aurait pas vu), et on exige un seul mode par chemin.
"""
from __future__ import annotations
import collections
import os
import sys
from pathlib import Path
import jinja2
import yaml
RACINE = Path(__file__).resolve().parents[2]
def defauts(role: str) -> dict:
f = RACINE / "roles" / role / "defaults" / "main.yml"
try:
return yaml.safe_load(f.read_text(encoding="utf-8")) or {} if f.is_file() else {}
except yaml.YAMLError:
return {}
def resoudre(chemin: str, role: str) -> str | None:
if "{{" not in chemin:
return chemin
env = jinja2.Environment(undefined=jinja2.StrictUndefined)
env.filters["dirname"] = os.path.dirname
env.filters["basename"] = os.path.basename
try:
r = env.from_string(chemin).render(**defauts(role))
return r if r.startswith("/") and "{{" not in r else None
except Exception:
return None
def main() -> int:
vus: dict[str, set] = collections.defaultdict(set)
def parcourir(taches, role):
for t in taches or []:
if not isinstance(t, dict):
continue
for k in ("block", "rescue", "always"):
if k in t:
parcourir(t[k], role)
m = t.get("ansible.builtin.file") or t.get("file")
if isinstance(m, dict) and m.get("state") == "directory" and "mode" in m:
p = resoudre(str(m.get("path", "")), role)
if p:
vus[p.rstrip("/")].add((str(m["mode"]), role))
for f in sorted((RACINE / "roles").glob("*/tasks/*.yml")):
try:
parcourir(yaml.safe_load(f.read_text(encoding="utf-8")), f.parts[-3])
except yaml.YAMLError:
continue
# UNE DEPENDANCE QUI SURCHARGE LE CHEMIN N'EST PAS UN DESACCORD. `serveur_backup_site`
# passe `serveur_backup_racine: /srv/restic/site` a sa dependance `serveur_backup` (et
# l'inventaire du site l'applique aussi au groupe) : la ou les deux roles cohabitent, ils
# ne tiennent pas le meme repertoire. Le defaut de `serveur_backup` ne vaut que seul.
surcharges = set()
for meta in sorted((RACINE / "roles").glob("*/meta/main.yml")):
try:
deps = (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("dependencies") or []
except yaml.YAMLError:
continue
for d in deps:
if isinstance(d, dict) and d.get("role") and len(d) > 1:
surcharges.add(frozenset({meta.parts[-3], d["role"]}))
conflits = {p: s for p, s in vus.items()
if len({m for m, _ in s}) > 1 and frozenset(r for _, r in s) not in surcharges}
for p, s in sorted(conflits.items()):
print(f"ECHEC {p} : " + ", ".join(f"{r}={m}" for m, r in sorted(s)))
temoin = vus.get("/var/lib/setops", set())
print(("OK " if {r for _, r in temoin} >= {"serveur_artefacts", "common_packages"} else "ECHEC ")
+ f"/var/lib/setops vu chez {len(temoin)} role(s), chemin Jinja de serveur_artefacts compris")
if conflits or not {r for _, r in temoin} >= {"serveur_artefacts", "common_packages"}:
return 1
print(f"OK {len(vus)} repertoire(s) a mode impose, aucun desaccord entre roles.")
return 0
if __name__ == "__main__":
sys.exit(main())