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>
140 lines
5.9 KiB
Python
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())
|