diff --git a/CHANGELOG.md b/CHANGELOG.md index 3447a8d..4fcb3db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # CHANGELOG — Set-OPS +## 2026-08-10 — `make versions-mesurer` : l'écart avec l'amont devient lisible + +Question de l'exploitant : *« ne devrait-on pas prendre les versions les plus récentes ? »* + +**Non**, et la réponse tient en trois points. Résoudre « la dernière » au moment du +déploiement **détruirait la reproductibilité** — celle-là même qu'on vient de prouver en +rasant et remontant deux écosystèmes. Ça transformerait chaque déploiement en **loterie** : +une heure de reconstruction ne doit pas dépendre de ce qu'un tiers a publié cette nuit. Et +six versions majeures de Forgejo ne s'avalent pas en effet de bord d'un `make` — ça se fait +délibérément, avec une sauvegarde avant et une vérification après. + +**Mais le vrai problème était ailleurs, et l'exploitant avait raison de tirer le fil : +l'écart était invisible.** Il a fallu quatre requêtes à la main pour découvrir l'état réel : + +| Composant | Épinglé | Publié | +|---|---|---| +| oauth2-proxy | `v7.15.3` | **à jour** | +| Nextcloud | `34.0.1` | `34.0.2` | +| Keycloak | `26.0.7` | `26.7.1` | +| Forgejo | `10.0.0` | **`v16.0.2`** | + +Le devis ne juge pas, il **renseigne**. Un retard n'est pas une faute ; il sort donc en 0. + +**Ce qui le distingue d'une liste écrite à la main** : les versions épinglées sont *dérivées* +du dépôt (`roles/*/defaults/*_version`). La source amont, elle, ne peut pas se dériver — +elle dépend de l'éditeur — et vit dans une table. **Toute version épinglée sans entrée dans +cette table fait sortir en erreur.** Sans cette garde, un épinglage ajouté demain vieillirait +sans que personne ne le voie, et on croirait tout surveillé alors qu'on ne verrait plus rien. +Une exemption reste possible, mais **écrite et motivée** — deux le sont déjà (la série PHP de +Debian, la version PostgreSQL vide par défaut). + +Éprouvé dans les deux sens : un `serveur_redis_version` ajouté temporairement fait sortir en +**1** en le nommant ; retiré, retour à **0**. + +**Et il rappelle ce qu'on n'épingle pas.** Grafana n'a aucune version dans le dépôt — le +`13.1.1` vu dans l'historique `apt` était simplement ce que le dépôt Grafana servait ce +jour-là. Debian, Grafana, smallstep et Icinga prennent tous ce qu'on leur donne au moment du +déploiement. **Deux régimes coexistent dans la même flotte**, et les taire donnerait +l'illusion que tout est maîtrisé. + ## 2026-08-10 — `raser` n'annonce plus des destructions qui n'ont pas eu lieu Le défaut noté la veille est corrigé. `raser` lisait l'**accusé de réception** de l'API et diff --git a/Makefile b/Makefile index 0e689ec..ecf7a1d 100644 --- a/Makefile +++ b/Makefile @@ -419,7 +419,7 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S -a "step certificate fingerprint /etc/step-ca/certs/root_ca.crt" 2>/dev/null \ | tail -1 | tr -d ' \r' -.PHONY: frontiere-plan frontiere-appliquer frontiere-mesurer mtu-mesurer +.PHONY: frontiere-plan frontiere-appliquer frontiere-mesurer mtu-mesurer versions-mesurer courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture) @rm -f $(SETOPS_INSTANCE)/devis-courriel.json.* @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null @@ -434,6 +434,9 @@ expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (ed @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null @python3 scripts/devis_expositions.py +versions-mesurer: ## De combien nos epinglages ont-ils vieilli ? (interroge les amonts, aucune ecriture) + @python3 scripts/devis_versions.py + mtu-mesurer: ansible-runtime ## L'invite porte-t-il le MTU de sa zone SDN ? (aucune ecriture) @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-mtu.yml >/dev/null @python3 scripts/devis_mtu.py diff --git a/docs/devis-services.md b/docs/devis-services.md index ba7ab90..1b283fa 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -13,6 +13,7 @@ make postgresql-plan # chiffrement imposé, et à quels réseaux make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP make frontiere-mesurer # ce qui n'est pas déclaré à la frontière est-il refusé ? make mtu-mesurer # l'invité porte-t-il le MTU de sa zone SDN ? +make versions-mesurer # de combien nos épinglages ont-ils vieilli ? ``` ## Le trou qu'il comble @@ -69,6 +70,28 @@ elle-même, le certificat était **expiré depuis plus de huit heures** et le re échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`. Rien ne le signalait. +## Le devis des versions + +Il répond à une question posée le 2026-08-10 : *« ne devrait-on pas prendre les versions les +plus récentes ? »* Non — résoudre « la dernière » au moment du déploiement détruirait la +reproductibilité et ferait de chaque reconstruction une loterie. Mais **l'écart, lui, était +invisible** : il a fallu quatre requêtes à la main pour découvrir que Forgejo avait six +versions majeures de retard. + +Ce devis ne juge pas, il **renseigne**. Un retard n'est pas une faute ; monter de version +reste un acte explicite — on édite, on relit, on reconstruit, on repasse les devis. + +La liste des versions épinglées est **dérivée** du dépôt (`roles/*/defaults/*_version`). La +source amont de chacune, elle, ne peut pas se dériver — elle dépend de l'éditeur — et vit +donc dans une table. **Toute version épinglée sans entrée dans cette table fait sortir le +devis en erreur** : sans cette garde, un épinglage ajouté demain vieillirait sans que +personne ne le voie, et on croirait tout surveillé. Une exemption reste possible, mais +**écrite et motivée**. + +Il rappelle enfin ce que l'on n'épingle pas : les paquets Debian, Grafana, smallstep et +Icinga prennent ce que leur dépôt sert au moment du déploiement. Deux régimes coexistent, et +les taire donnerait l'illusion que tout est maîtrisé. + ## Le devis d'étanchéité de la frontière Le devis des expositions pose la question **positive** — chaque exposition déclarée diff --git a/scripts/devis_versions.py b/scripts/devis_versions.py new file mode 100644 index 0000000..9524254 --- /dev/null +++ b/scripts/devis_versions.py @@ -0,0 +1,140 @@ +#!/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())