#!/usr/bin/env python3 """Devis des versions : de combien nos epinglages ont-ils vieilli ? L'exploitant, le 2026-08-10 : « ne devrait-on pas prendre les versions les plus recentes ? » La reponse est non — resoudre « la derniere » au moment du deploiement detruirait la reproductibilite, et transformerait chaque reconstruction en loterie. Mais l'ECART, lui, etait invisible : il a fallu quatre requetes a la main pour decouvrir que Forgejo avait six versions majeures de retard. C'est ca que ce devis corrige. Il ne juge pas, il RENSEIGNE — un retard n'est pas une faute, c'est une information qu'on doit pouvoir lire en dix secondes. CE QU'IL FAIT, et ce qui le distingue d'une liste ecrite a la main : - la liste des versions epinglees est DERIVEE du depot (`roles/*/defaults/*_version`) ; - la source amont de chacune est declaree ici, dans SOURCES — la seule chose qui ne peut pas se deriver, puisqu'elle depend de l'editeur ; - **toute version epinglee sans entree dans SOURCES fait sortir en erreur.** Sans cette garde, ajouter un epinglage demain le rendrait invisible a ce devis, et on croirait tout surveille alors qu'on ne verrait plus rien. Une exemption reste possible, mais elle doit etre ECRITE et motivee. CE QU'IL NE FAIT PAS : monter de version. C'est un acte explicite — on edite, on relit, on reconstruit, on repasse les devis. make versions-mesurer """ from __future__ import annotations import json import re import sys import urllib.error import urllib.request from pathlib import Path RACINE = Path(__file__).resolve().parent.parent DELAI = 20 # Ou demander « la derniere version publiee ». `type` dit comment lire la reponse. # Une entree `exempt` porte la RAISON de ne pas verifier — jamais un simple silence. SOURCES: dict[str, dict] = { "serveur_keycloak_version": { "type": "github", "depot": "keycloak/keycloak"}, "serveur_oauth2_proxy_version": { "type": "github", "depot": "oauth2-proxy/oauth2-proxy"}, "serveur_forgejo_version": { "type": "codeberg", "depot": "forgejo/forgejo"}, "serveur_nextcloud_version": { "type": "nextcloud"}, "serveur_nextcloud_php_version": { "exempt": "serie de paquets Debian : elle suit la distribution, pas un editeur"}, "serveur_postgresql_version": { "exempt": "vide par defaut — la version vient du paquet Debian"}, } def _http(url: str, entete: dict | None = None) -> str | None: req = urllib.request.Request(url, headers=entete or {"User-Agent": "setops-devis"}) try: with urllib.request.urlopen(req, timeout=DELAI) as r: return r.read().decode("utf-8", "replace") except (urllib.error.URLError, OSError, TimeoutError): return None def _amont(src: dict) -> str | None: """La derniere version publiee, ou None si l'amont n'a pas repondu.""" t = src.get("type") if t == "github": c = _http(f"https://api.github.com/repos/{src['depot']}/releases/latest") return (json.loads(c).get("tag_name") if c else None) if t == "codeberg": c = _http(f"https://codeberg.org/api/v1/repos/{src['depot']}/releases?limit=1") d = json.loads(c) if c else [] return (d[0].get("tag_name") if d else None) if t == "nextcloud": c = _http("https://download.nextcloud.com/server/releases/") if not c: return None v = re.findall(r"nextcloud-(\d+\.\d+\.\d+)\.tar\.bz2", c) return max(v, key=lambda x: [int(n) for n in x.split(".")]) if v else None return None def epinglees() -> dict[str, tuple[str, str]]: """{variable: (valeur, role)} — derive du depot, jamais saisi.""" trouve: dict[str, tuple[str, str]] = {} for f in sorted((RACINE / "roles").glob("*/defaults/main.yml")): for ligne in f.read_text(encoding="utf-8", errors="ignore").splitlines(): m = re.match(r'^([a-z0-9_]+_version):\s*"?([^"#\n]*)"?\s*$', ligne) if m and m.group(1) not in trouve: trouve[m.group(1)] = (m.group(2).strip(), f.parent.parent.name) return trouve def main() -> int: pins = epinglees() print(f"Devis des versions — {len(pins)} epinglage(s) trouve(s) dans les roles\n") inconnues = [v for v in pins if v not in SOURCES] lignes, retards, muets = [], 0, 0 for var, (val, role) in sorted(pins.items()): src = SOURCES.get(var) if src is None: continue if "exempt" in src: lignes.append(f" -- {role:<22} {val or '(vide)':<12} exempte : {src['exempt']}") continue derniere = _amont(src) if derniere is None: lignes.append(f" ? {role:<22} {val:<12} l'amont n'a pas repondu") muets += 1 elif derniere.lstrip("v") == val.lstrip("v"): lignes.append(f" ok {role:<22} {val:<12} a jour") else: lignes.append(f" RETARD {role:<21} {val:<12} -> {derniere} publie") retards += 1 print("\n".join(lignes)) print(f"\n {retards} en retard, {muets} sans reponse de l'amont.") print(" Un retard n'est pas une faute : monter de version est un acte explicite —\n" " on edite, on relit, on reconstruit, on repasse les devis.") # Ce qui n'est PAS epingle merite d'etre dit : le depot melange deux regimes, et # l'ignorer donnerait l'illusion que tout est maitrise. print("\n Rappel : seuls ces artefacts sont epingles. Les paquets (Debian, Grafana,\n" " smallstep, Icinga) prennent ce que leur depot sert au moment du deploiement.") if inconnues: print(f"\nNON COUVERT : {len(inconnues)} version(s) epinglee(s) qu'aucune source ne " f"surveille :\n - " + "\n - ".join(sorted(inconnues)) + "\n Ajouter une entree dans SOURCES (ou une exemption motivee) : sans cela,\n" " cet epinglage vieillirait sans que personne ne le voie.") return 1 return 0 if __name__ == "__main__": sys.exit(main())