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