Set-OPS-Public/scripts/cacher_paquets.py
Daniel Allaire 42becd0c02
Some checks are pending
verifier / verifier (push) Waiting to run
paquets tiers : passer par le cache du controleur, plus par Internet
Trois depots tiers etaient en HTTPS, et client_artefacts pose
Acquire::https::Proxy DIRECT — sans quoi le cache du site refuse les tunnels et
aucun depot tiers n est joignable. La ligne est juste ; sa consequence ne l avait
pas ete vue : ces trois depots CONTOURNENT le cache, et chaque VM neuve allait
les chercher sur Internet a sa naissance.

step-cli et alloy sont poses par des integrations UNIVERSELLES. Sans lien, une
machine neuve n obtenait ni son client d autorite ni ses metriques — elle n
entrait dans aucun flux chiffre. Dernier obstacle a une reconstruction hors
ligne, et il tenait dans un mot.

make cacher-paquets tire les 21 deb aux versions epinglees, empreinte SHA256
verifiee depuis l index du depot. Le role partage paquets_tiers les depose et les
installe EN UN SEUL appel a apt — il sait resoudre un ensemble de fichiers locaux
qui se dependent, la ou paquet par paquet echouerait sur l ordre (Icinga en
apporte dix-sept). Six roles branches ; chacun retombe sur le depot distant pour
ce que le cache n a PAS fourni, et rien d autre.

Eprouve pour de vrai : packages.smallstep.com renvoye vers 127.0.0.1, step-cli
desinstalle, cache local efface. Le role rejoue installe 0.30.6-1 depuis le cache
et la machine obtient ses certificats. Zero echec.

Deux defauts de mon propre outil, trouves en le construisant : Smallstep sert son
index NON COMPRESSE (404 sur .gz) donc step-cli n etait jamais mis en cache ; et
un depot injoignable faisait continue AVANT d incrementer le total, si bien que
le script rapportait 20 sur 20 alors qu il en manquait un. Une garde qui ne peut
pas echouer ne garde rien — troisieme fois cette semaine. Corrige puis eprouve en
le faisant echouer.

Reste hors ligne : NTP externe, et l expedition des alertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-05 08:22:14 -04:00

165 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Tire les paquets NON DEBIAN dans le cache du controleur, une fois, verifies.
POURQUOI CE SCRIPT EXISTE (mesure du 2026-09-03).
Trois depots tiers sont configures sur la flotte — Smallstep, Icinga, Grafana — tous en
HTTPS. Or `client_artefacts` pose `Acquire::https::Proxy "DIRECT"`, sans quoi le cache
refuse les tunnels HTTPS et aucun depot tiers n'est joignable. La consequence n'avait pas
ete vue : ces trois depots CONTOURNENT LE CACHE, et chaque VM neuve va les chercher sur
Internet a sa naissance.
`step-cli` (Smallstep) et `alloy` (Grafana) sont poses par des integrations UNIVERSELLES.
Sans Internet, une machine neuve n'obtient donc ni son client d'autorite ni ses metriques
— elle ne peut entrer dans aucun flux chiffre. C'etait le dernier obstacle a une
reconstruction hors ligne.
MEME PATRON QUE LE RESTE DU CACHE DU CONTROLEUR (Forgejo, Keycloak, Nextcloud,
oauth2-proxy) : on telecharge UNE FOIS, on verifie, on depose ensuite depuis le cache.
CE QU'IL NE FAIT PAS, ET POURQUOI. Il ne resout aucune dependance. Les dependances de ces
paquets sont des paquets DEBIAN, qui passent deja par le cache du site en HTTP — c'est
justement la difference qu'on corrige ici. Ce script ne s'occupe que de ce que Debian ne
fournit pas.
"""
from __future__ import annotations
import gzip
import hashlib
import sys
import urllib.request
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parent.parent
DECLARATION = RACINE / "paquets-tiers.yml"
CACHE = Path.home() / ".cache" / "setops" / "paquets"
def _index(uri: str, suite: str, composant: str, arch: str = "amd64") -> dict[str, dict]:
"""Les paquets d'un depot, lus dans SON index — pas devines depuis une URL.
Le chemin d'un `.deb` dans un depot n'est pas derivable de son nom : il vit dans le
champ `Filename` de l'index. Le construire a la main marcherait pour un depot et
casserait au suivant. On lit donc l'index, qui porte aussi le `SHA256` — la
verification vient avec, sans effort supplementaire.
"""
# TOUS LES DEPOTS NE COMPRESSENT PAS LEUR INDEX (mesure du 2026-09-03).
#
# Icinga et Grafana servent `Packages.gz` ; Smallstep sert `Packages` en clair, et
# rend 404 sur les autres formes. La premiere version de ce script ne demandait que
# `.gz` : le depot Smallstep echouait en silence, et `step-cli` — le paquet de CHAQUE
# machine — n'a jamais ete mis en cache. On essaie donc les formes courantes, dans
# l'ordre du moins couteux au plus.
base = f"{uri.rstrip('/')}/dists/{suite}/{composant}/binary-{arch}"
brut = None
derniere = None
for suffixe in (".gz", "", ".xz"):
try:
with urllib.request.urlopen(base + "/Packages" + suffixe, timeout=60) as rep:
donnees = rep.read()
except Exception as e: # noqa: BLE001 — on essaie la forme suivante
derniere = e
continue
if suffixe == ".gz":
donnees = gzip.decompress(donnees)
elif suffixe == ".xz":
import lzma
donnees = lzma.decompress(donnees)
brut = donnees.decode("utf-8", "replace")
break
if brut is None:
raise derniere or RuntimeError(f"aucun index lisible sous {base}")
paquets: dict[str, dict] = {}
for bloc in brut.split("\n\n"):
champs: dict[str, str] = {}
cle = None
for ligne in bloc.splitlines():
if ligne.startswith((" ", "\t")) and cle:
continue
if ":" in ligne:
cle, _, val = ligne.partition(":")
champs[cle.strip()] = val.strip()
nom, ver = champs.get("Package"), champs.get("Version")
if nom and ver:
paquets[f"{nom}={ver}"] = champs
return paquets
def _telecharger(uri: str, champs: dict, dest: Path) -> str:
url = f"{uri.rstrip('/')}/{champs['Filename'].lstrip('/')}"
with urllib.request.urlopen(url, timeout=300) as rep:
contenu = rep.read()
somme = hashlib.sha256(contenu).hexdigest()
attendue = champs.get("SHA256", "")
if attendue and somme != attendue:
raise SystemExit(
f"ERREUR: empreinte refusee pour {dest.name}\n"
f" attendue {attendue}\n obtenue {somme}\n"
" Le fichier n'est pas celui que l'index annonce — on ne le garde pas.")
dest.write_bytes(contenu)
return somme
def main() -> int:
if not DECLARATION.is_file():
print(f"ERREUR: {DECLARATION.name} absent — rien de declare a mettre en cache.",
file=sys.stderr)
return 2
decl = yaml.safe_load(DECLARATION.read_text(encoding="utf-8")) or {}
CACHE.mkdir(parents=True, exist_ok=True)
total = tires = deja = 0
manifeste: list[str] = []
for nom_depot, d in sorted((decl.get("depots") or {}).items()):
voulus = d.get("paquets") or {}
if not voulus:
continue
print(f" {nom_depot} : lecture de l'index…")
try:
index = _index(d["uri"], d["suite"], d["composant"])
except Exception as e:
# ON COMPTE QUAND MEME CE QU'IL DEVAIT FOURNIR (2026-09-03).
#
# La premiere version faisait `continue` AVANT d'incrementer `total` : un
# depot injoignable disparaissait donc du decompte, et le script concluait
# « 20 tires, 20 declares » alors qu'il en manquait un — le plus important.
# Une garde qui ne peut pas echouer ne garde rien.
total += len(voulus)
print(f" ! {nom_depot} injoignable ({type(e).__name__}: {str(e)[:60]}) — "
f"{len(voulus)} paquet(s) NON mis en cache.", file=sys.stderr)
continue
for paquet, version in sorted(voulus.items()):
total += 1
cle = f"{paquet}={version}"
champs = index.get(cle)
if champs is None:
# DIRE CE QU'ON NE TROUVE PAS. Un amont qui retire une version epinglee
# est un fait a connaitre AVANT la prochaine reconstruction, pas pendant.
print(f" ! {cle} absent de l'index de {nom_depot} — version retiree en "
"amont ? Ajuster `paquets-tiers.yml`.", file=sys.stderr)
continue
fichier = CACHE / Path(champs["Filename"]).name
if fichier.is_file() and champs.get("SHA256") == hashlib.sha256(
fichier.read_bytes()).hexdigest():
deja += 1
manifeste.append(f"{cle} {fichier.name}")
continue
somme = _telecharger(d["uri"], champs, fichier)
tires += 1
manifeste.append(f"{cle} {fichier.name}")
print(f" + {fichier.name} ({len(fichier.read_bytes()) // 1024} Ko, "
f"sha256 {somme[:12]}…)")
(CACHE / "MANIFESTE").write_text("\n".join(sorted(manifeste)) + "\n", encoding="utf-8")
print(f"\n {tires} tire(s), {deja} deja en cache, {total} declare(s).")
print(f" Cache : {CACHE}")
if tires + deja < total:
print("\n INCOMPLET — la reconstruction hors ligne echouera sur ce qui manque.")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())