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>
This commit is contained in:
Daniel Allaire 2026-08-10 23:02:16 -04:00
parent a1908f2c63
commit a86a82b7ae
4 changed files with 207 additions and 1 deletions

View file

@ -1,5 +1,45 @@
# CHANGELOG — Set-OPS # 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 ## 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 Le défaut noté la veille est corrigé. `raser` lisait l'**accusé de réception** de l'API et

View file

@ -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 \ -a "step certificate fingerprint /etc/step-ca/certs/root_ca.crt" 2>/dev/null \
| tail -1 | tr -d ' \r' | 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) courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture)
@rm -f $(SETOPS_INSTANCE)/devis-courriel.json.* @rm -f $(SETOPS_INSTANCE)/devis-courriel.json.*
@ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null @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 @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null
@python3 scripts/devis_expositions.py @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) 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 @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-mtu.yml >/dev/null
@python3 scripts/devis_mtu.py @python3 scripts/devis_mtu.py

View file

@ -13,6 +13,7 @@ make postgresql-plan # chiffrement imposé, et à quels réseaux
make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP 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 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 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 ## 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`. échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`.
Rien ne le signalait. 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 d'étanchéité de la frontière
Le devis des expositions pose la question **positive** — chaque exposition déclarée Le devis des expositions pose la question **positive** — chaque exposition déclarée

140
scripts/devis_versions.py Normal file
View file

@ -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())