Set-OPS-Public/scripts/devis_versions.py
Daniel Allaire a86a82b7ae devis : make versions-mesurer — l'ecart avec l'amont devient lisible
Question de l'exploitant : « ne devrait-on pas prendre les versions les plus
recentes ? » Non — resoudre « la derniere » au deploiement detruirait la
reproductibilite qu'on vient de prouver en rasant et remontant deux
ecosystemes, et ferait de chaque reconstruction une loterie. Six majeures de
Forgejo ne s'avalent pas en effet de bord d'un `make`.

Mais il avait raison de tirer le fil : L'ECART ETAIT INVISIBLE. Il a fallu
quatre requetes a la main pour decouvrir Forgejo 10.0.0 contre v16.0.2 publie,
Keycloak 26.0.7 contre 26.7.1, Nextcloud 34.0.1 contre 34.0.2, oauth2-proxy a
jour.

Le devis ne juge pas, il RENSEIGNE : un retard n'est pas une faute, il sort
donc en 0. Monter de version reste un acte explicite.

Ce qui le distingue d'une liste ecrite a la main : les versions epinglees sont
DERIVEES du depot. La source amont, elle, ne peut pas se deriver — elle depend
de l'editeur — et vit dans une table. TOUTE VERSION EPINGLEE SANS ENTREE DANS
CETTE TABLE FAIT SORTIR EN ERREUR : sans cette garde, un epinglage ajoute
demain vieillirait sans que personne ne le voie. Une exemption reste possible,
mais ecrite et motivee (deux le sont : la serie PHP de Debian, la version
PostgreSQL vide).

Eprouve dans les deux sens : un serveur_redis_version ajoute temporairement
fait sortir en 1 en le nommant ; retire, retour a 0.

Et il rappelle ce qu'on n'epingle PAS. Grafana n'a aucune version dans le
depot — le 13.1.1 vu dans l'historique apt etait ce que son depot servait ce
jour-la. Debian, Grafana, smallstep et Icinga prennent ce qu'on leur donne.
Deux regimes coexistent dans la meme flotte, et les taire donnerait l'illusion
que tout est maitrise.

Verifie : versions-mesurer code 0, garde eprouvee, prouver.py 35 OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 23:02:16 -04:00

140 lines
5.9 KiB
Python

#!/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())