From 7a2f179f3990ad4a1ea965275c55e48e42eb6184 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 1 Oct 2026 14:01:54 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 34 ++++++++ Makefile | 1 + roles/client_journal/tasks/main.yml | 7 +- roles/client_sante/tasks/main.yml | 5 +- roles/common_packages/tasks/main.yml | 7 +- roles/serveur_icinga/tasks/main.yml | 2 +- roles/serveur_ops_site/tasks/main.yml | 7 +- scripts/site_inventaire.py | 18 ++++ scripts/tests/test_repertoires_partages.py | 97 ++++++++++++++++++++++ 9 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 scripts/tests/test_repertoires_partages.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f44dddd..91ffe97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Makefile b/Makefile index 9bef0d4..75f53c4 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/roles/client_journal/tasks/main.yml b/roles/client_journal/tasks/main.yml index b5defa3..020e8be 100644 --- a/roles/client_journal/tasks/main.yml +++ b/roles/client_journal/tasks/main.yml @@ -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: diff --git a/roles/client_sante/tasks/main.yml b/roles/client_sante/tasks/main.yml index f539bd6..7b9976c 100644 --- a/roles/client_sante/tasks/main.yml +++ b/roles/client_sante/tasks/main.yml @@ -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: diff --git a/roles/common_packages/tasks/main.yml b/roles/common_packages/tasks/main.yml index 14ea6a2..595b449 100644 --- a/roles/common_packages/tasks/main.yml +++ b/roles/common_packages/tasks/main.yml @@ -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: diff --git a/roles/serveur_icinga/tasks/main.yml b/roles/serveur_icinga/tasks/main.yml index 6448fb9..689931d 100644 --- a/roles/serveur_icinga/tasks/main.yml +++ b/roles/serveur_icinga/tasks/main.yml @@ -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 diff --git a/roles/serveur_ops_site/tasks/main.yml b/roles/serveur_ops_site/tasks/main.yml index 61a4133..d172a53 100644 --- a/roles/serveur_ops_site/tasks/main.yml +++ b/roles/serveur_ops_site/tasks/main.yml @@ -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: diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 0bab6b5..14eeb05 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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") diff --git a/scripts/tests/test_repertoires_partages.py b/scripts/tests/test_repertoires_partages.py new file mode 100644 index 0000000..6058b0a --- /dev/null +++ b/scripts/tests/test_repertoires_partages.py @@ -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())