paquets tiers : passer par le cache du controleur, plus par Internet
Some checks are pending
verifier / verifier (push) Waiting to run

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
This commit is contained in:
Daniel Allaire 2026-09-05 08:22:14 -04:00
parent 872b5e91aa
commit 42becd0c02
16 changed files with 700 additions and 10 deletions

View file

@ -1,5 +1,71 @@
# CHANGELOG — Set-OPS
## 2026-09-03 — Les paquets non Debian passent par le cache : le dernier obstacle au hors-ligne
**56 preuves.** Trois depots tiers etaient configures sur la flotte — Smallstep, Icinga,
Grafana — **tous en HTTPS**. Or `client_artefacts` pose `Acquire::https::Proxy "DIRECT"`,
sans quoi le cache du site refuse les tunnels HTTPS (« 403 CONNECT denied ») et aucun
depot tiers n'est joignable.
Cette ligne est juste, et elle a ete posee pour une bonne raison. Sa CONSEQUENCE n'avait
pas ete vue : **ces trois depots contournent le cache par conception**, et chaque VM neuve
allait les chercher sur Internet a sa naissance.
`step-cli` (Smallstep) et `alloy` (Grafana) 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. C'etait le dernier obstacle a une
reconstruction hors ligne, et il tenait dans un mot : `DIRECT`.
### Ce qui change
`make cacher-paquets` lit `paquets-tiers.yml`, va chercher l'index de chaque depot, tire
les 21 `.deb` aux **versions epinglees** et verifie l'empreinte SHA256 que l'index annonce.
Tout atterrit dans `~/.cache/setops/paquets/`, a cote de Forgejo, Keycloak et Nextcloud —
meme patron depuis toujours : telecharger une fois, verifier, deposer ensuite.
Le role partage `paquets_tiers` les depose et les installe **en un seul appel a apt** : apt
sait resoudre un ensemble de fichiers locaux qui se dependent mutuellement, la ou une
installation paquet par paquet echouerait sur l'ordre. Icinga en apporte dix-sept dans ce
cas.
Six roles y sont branches — `client_pki`, `client_journal`, `serveur_icinga`,
`serveur_icingaweb2`, `serveur_grafana`, `serveur_loki`. Chacun retombe sur le depot
distant pour **ce que le cache n'a pas fourni, et rien d'autre** : reinstaller ce que le
cache vient de poser ferait un `update_cache` inutile qui, hors ligne, echouerait APRES un
travail deja fait.
### La coupure, eprouvee pour de vrai
Sur `web-dorsal-01` : `packages.smallstep.com` renvoye vers `127.0.0.1`, `step-cli`
desinstalle, cache local efface. Le role rejoue :
step-cli : 0.30.6-1
installe depuis : step-cli_0.30.6-1_amd64_...deb
certificat present : 2
Zero echec. Le depot etait injoignable et la machine a obtenu son certificat.
### Deux defauts de mon propre outil, trouves en le construisant
**Smallstep sert son index NON COMPRESSE.** Icinga et Grafana servent `Packages.gz` ;
Smallstep sert `Packages` en clair et rend 404 sur le reste. La premiere version ne
demandait que `.gz` : le depot echouait, et `step-cli` — le paquet de CHAQUE machine — n'a
jamais ete mis en cache.
**Et le script rapportait « 20 tires, 20 declares ».** Un depot injoignable faisait
`continue` AVANT d'incrementer le total : il disparaissait du decompte, et l'echec se
lisait comme un succes. Une garde qui ne peut pas echouer ne garde rien — troisieme
occurrence cette semaine, cette fois dans un outil ecrit le jour meme.
Corrige, puis **eprouve en le faisant echouer** : depot rendu injoignable, le script rend
`INCOMPLET` et sort en 1 ; tout present, il sort en 0.
### Ce qui reste hors ligne
Deux dependances mineures, nommees pour qu'on sache : le temps (NTP externe, la derive est
lente) et l'expedition des alertes (le relais livre directement aux MX). La supervision
continuerait de VOIR sans pouvoir le DIRE.
## 2026-09-02 (6) — Deuxieme reconstruction de validation : une course que la premiere n'avait pas montree
**56 preuves. Reconstruction : 14/14 hotes, 0 echec. `make valider` : 0 echec sur 13

View file

@ -562,6 +562,15 @@ site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declaree
python3 scripts/orchestrer.py verifier
.PHONY: flux flux-verifier
cacher-paquets: ## Tire les paquets NON DEBIAN dans le cache du controleur (a faire en ligne)
@# CE QUI REND LA RECONSTRUCTION HORS LIGNE POSSIBLE. Trois depots tiers sont en
@# HTTPS et contournent le cache du site (`Acquire::https::Proxy "DIRECT"`). Sans
@# cette cible, chaque VM neuve va chercher `step-cli` et `alloy` sur Internet — deux
@# integrations UNIVERSELLES. A lancer depuis un poste connecte ; le deploiement, lui,
@# n'en aura plus besoin.
python3 scripts/cacher_paquets.py
.PHONY: flux
flux: ansible-runtime ## Regenere le registre des flux et les regles nftables depuis les meta/flux.yml
python3 scripts/resoudre_flux.py registre
python3 scripts/resoudre_flux.py nftables

View file

@ -0,0 +1,93 @@
# Preuve de conformite — Set-OPS — 2026-09-03
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ❌ NON CONFORME (55 OK · 1 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 40 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 99 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 35 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 13 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 80 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 55 scripts expliques et atteignables, 109 cibles make documentees, 65 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (31 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 51 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 104 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ❌ ECHEC | La carte d'orientation ne dit plus vrai :
- « pieces d'audit » : la carte annonce 33, le depot en compte 34 |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (117 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 169 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-09-03._

View file

@ -23,10 +23,10 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 64 | `roles/*/` |
| README de rôles | 64 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| rôles | 65 | `roles/*/` |
| README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` |
| pièces d'audit | 33 | `docs/audit/*` |
| pièces d'audit | 34 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 79 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

59
paquets-tiers.yml Normal file
View file

@ -0,0 +1,59 @@
---
# LES PAQUETS QUI NE VIENNENT PAS DE DEBIAN — et pourquoi ils sont ici.
#
# Trois dépôts tiers sont configurés sur la flotte, tous en HTTPS. Or `client_artefacts`
# pose `Acquire::https::Proxy "DIRECT"` — obligatoire, parce que le cache refuse les
# tunnels HTTPS (« 403 CONNECT denied ») et que sans cette ligne aucun dépôt tiers
# n'était joignable. La conséquence n'avait pas été vue : **ces trois dépôts contournent
# le cache par conception**, et chaque construction de VM va les chercher sur Internet.
#
# CE QUE ÇA COÛTAIT. `client_pki` est une intégration UNIVERSELLE : chaque machine de
# chaque écosystème installe `step-cli` depuis Smallstep au moment de sa naissance. Sans
# Internet, une VM neuve n'obtient pas son client d'AC, donc pas de certificat, donc
# n'entre dans aucun flux chiffré. `alloy` (métriques) est dans le même cas.
#
# Les versions sont ÉPINGLÉES, comme les collections Ansible. Une mise à niveau devient
# alors un geste délibéré — `make cacher-paquets` — au lieu d'arriver toute seule le jour
# où l'amont publie.
depots:
smallstep:
uri: https://packages.smallstep.com/stable/debian
suite: debs
composant: main
# Sur CHAQUE machine, via `client_pki`. Le paquet le plus critique de la liste.
paquets:
step-cli: 0.30.6-1
icinga:
uri: https://packages.icinga.com/debian
suite: icinga-trixie
composant: main
# La supervision et sa vue web, avec leurs dépendances PHP propres au dépôt.
paquets:
icinga2: 2.16.5-1+debian13
icinga2-bin: 2.16.5-1+debian13
icinga2-common: 2.16.5-1+debian13
icinga2-doc: 2.16.5-1+debian13
icinga-archive-keyring: 2.0.0-1+debian13
icingacli: 2.14.0-2+debian13
icingadb: 1.5.1-8+debian13
icingadb-redis: 8.2.9-1+debian13
icingadb-web: 1.4.0-1+debian13
icinga-l10n: 1.4.0-1+debian13
icinga-php-legacy: 1.1.0-1+debian13
icinga-php-library: 1.0.0-2+debian13
icinga-php-thirdparty: 1.0.0-2+debian13
icingaweb2: 2.14.0-2+debian13
icingaweb2-common: 2.14.0-2+debian13
icingaweb2-module-monitoring: 2.12.6-1+debian13
php-icinga: 2.14.0-2+debian13
grafana:
uri: https://apt.grafana.com
suite: stable
composant: main
# `alloy` est universel comme `step-cli` : `client_metrique` le pose partout.
paquets:
alloy: 1.19.2-1
grafana: 13.2.0
loki: 3.7.7

View file

@ -36,12 +36,30 @@
filename: grafana
state: present
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT POUR LE RESTE (2026-09-03).
#
# Les depots tiers sont en HTTPS, et `client_artefacts` pose
# `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur
# Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions
# epinglees et empreintes verifiees ; ce role les depose depuis ce cache.
- name: Poser les paquets tiers depuis le cache du controleur
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ client_journal_paquets }}"
- name: Installer Grafana Alloy
ansible.builtin.apt:
name: "{{ client_journal_paquets }}"
# CE QUE LE CACHE N'A PAS FOURNI, ET RIEN D'AUTRE. Les dependances Debian passent
# par le cache du site en HTTP ; seuls les paquets tiers non caches exigent Internet.
# Reinstaller ce que le cache vient de poser ferait un `update_cache` inutile — et
# hors ligne, il echouerait APRES un travail deja fait.
name: "{{ client_journal_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
state: present
update_cache: true
when: (client_journal_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
- name: Autoriser Alloy a lire le journal systeme
ansible.builtin.user:
name: "{{ client_journal_utilisateur }}"

View file

@ -88,11 +88,39 @@
group: root
mode: "0644"
- name: Installer step-cli
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT ENSUITE (2026-09-03).
#
# `client_pki` est une integration UNIVERSELLE : chaque machine de chaque ecosysteme
# installe `step-cli` ici, a sa naissance. Le depot Smallstep est en HTTPS, et
# `client_artefacts` pose `Acquire::https::Proxy "DIRECT"` — il CONTOURNE donc le cache
# du site et sort sur Internet. Sans lien, une VM neuve n'obtenait pas son client
# d'autorite, donc pas de certificat, donc n'entrait dans aucun flux chiffre.
#
# C'etait le dernier obstacle a une reconstruction hors ligne, et il tenait dans un mot :
# `DIRECT`, pose a juste titre pour une autre raison.
- name: Poser step-cli depuis le cache du controleur, s'il y est
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ client_pki_paquets }}"
# FILET, ET SEULEMENT SI LE CACHE N'A RIEN DONNE.
#
# `update_cache: true` interroge TOUS les depots configures, Smallstep compris. Hors
# ligne, cette tache echouerait donc APRES que le cache ait deja pose le paquet — le
# deploiement tomberait sur un travail deja fait. Le repli ne doit exister que quand il
# y a quelque chose a rattraper.
#
# Cache vide — poste jamais connecte, paquet retire de la declaration — on retombe sur le
# depot distant comme avant. Degrader, jamais deviner.
- name: Installer depuis le depot distant ce que le cache n'a pas fourni
ansible.builtin.apt:
name: "{{ client_pki_paquets }}"
name: "{{ client_pki_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
state: present
update_cache: true
when: (client_pki_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
- name: Creer le repertoire STEPPATH des certificats
ansible.builtin.file:

View file

@ -0,0 +1,69 @@
# `paquets_tiers` — poser ce que Debian ne fournit pas, sans Internet
> **Pour qui :** celui qui prépare une reconstruction hors ligne, ou qui se demande
> pourquoi une VM neuve va chercher un paquet sur Internet.
## Le problème qu'il ferme
Trois dépôts tiers sont configurés sur la flotte — Smallstep, Icinga, Grafana — **tous en
HTTPS**. Or `client_artefacts` pose `Acquire::https::Proxy "DIRECT"`, sans quoi le cache
du site refuse les tunnels HTTPS (« 403 CONNECT denied ») et aucun dépôt tiers n'est
joignable.
Cette ligne est juste. Sa conséquence n'avait pas été vue : **ces trois dépôts contournent
le cache par conception**, et chaque VM allait les chercher sur Internet à sa naissance.
`step-cli` (Smallstep) et `alloy` (Grafana) sont posés par des intégrations
**universelles**. Sans lien, une machine neuve n'obtenait ni son client d'autorité ni ses
métriques — elle n'entrait dans aucun flux chiffré. C'était le dernier obstacle à une
reconstruction hors ligne.
## Comment il s'en sort
`make cacher-paquets` lit `paquets-tiers.yml`, va chercher l'index de chaque dépôt, tire
les `.deb` aux **versions épinglées** et vérifie leur empreinte SHA256 — celle que l'index
annonce. Tout atterrit dans `~/.cache/setops/paquets/`, à côté de Forgejo, Keycloak et
Nextcloud, qui suivent le même patron depuis toujours.
Ce rôle dépose ensuite ces fichiers sur l'hôte et les installe **en un seul appel à
`apt`** — c'est le point : apt sait résoudre un ensemble de fichiers locaux qui se
dépendent mutuellement, alors qu'une installation paquet par paquet échouerait sur
l'ordre. Icinga en apporte dix-sept dans ce cas.
Les **dépendances Debian** restent l'affaire d'apt et du cache du site, en HTTP. Ce rôle
ne s'occupe que de ce que Debian ne fournit pas.
## Ce qu'il fait quand le cache est vide
Rien, et il le **dit**. L'appelant retombe alors sur le dépôt distant, comme avant. Un
déploiement connecté ne casse pas parce qu'un cache manque — mais on sait, à la lecture du
journal, qu'une reconstruction hors ligne échouerait là.
## Usage
```yaml
- name: Poser les paquets tiers depuis le cache du contrôleur
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ mon_role_paquets }}"
```
Puis, pour le reste seulement :
```yaml
name: "{{ mon_role_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
```
## Éprouver la coupure
Rendre le dépôt injoignable sur un hôte, retirer le paquet, rejouer le rôle :
```bash
echo "127.0.0.1 packages.smallstep.com" >> /etc/hosts
apt-get remove -y step-cli
```
Mesuré le 2026-09-03 : `step-cli 0.30.6-1` s'installe depuis le cache, la machine obtient
ses certificats, zéro échec.

View file

@ -0,0 +1,22 @@
---
# LES PAQUETS QUE DEBIAN NE FOURNIT PAS, SERVIS DEPUIS LE CACHE DU CONTRÔLEUR.
#
# Trois dépôts tiers sont configurés sur la flotte — Smallstep, Icinga, Grafana — tous en
# HTTPS. Or `client_artefacts` pose `Acquire::https::Proxy "DIRECT"`, sans quoi le cache
# du site refuse les tunnels HTTPS et aucun dépôt tiers n'est joignable. La conséquence
# n'avait pas été vue : **ces trois dépôts contournent le cache par conception**, et
# chaque VM neuve allait les chercher sur Internet à sa naissance.
#
# `step-cli` (Smallstep) et `alloy` (Grafana) sont posés par des intégrations
# UNIVERSELLES : sans Internet, une machine neuve n'obtenait ni son client d'autorité ni
# ses métriques. C'était le dernier obstacle à une reconstruction hors ligne.
#
# `make cacher-paquets` les tire UNE FOIS, versions épinglées et empreintes vérifiées.
# Ce rôle les dépose depuis ce cache. Les DÉPENDANCES restent l'affaire d'apt : ce sont
# des paquets Debian, qui passent déjà par le cache du site en HTTP.
paquets_tiers_noms: []
# Le cache vit chez le CONTRÔLEUR, comme celui de Forgejo, Keycloak et Nextcloud : on
# télécharge une fois depuis un poste qui a Internet, on déploie ensuite sans en avoir.
paquets_tiers_cache: "{{ lookup('env', 'HOME') }}/.cache/setops/paquets"
paquets_tiers_dest: "/var/cache/setops/paquets"

View file

@ -0,0 +1,3 @@
---
# Aucune dépendance : ce rôle est appelé PAR ceux qui installent, jamais l'inverse.
dependencies: []

View file

@ -0,0 +1,85 @@
---
# DÉGRADER, JAMAIS DEVINER. Sans cache sur le contrôleur, ce rôle ne fait rien et le DIT :
# l'appelant installera depuis le dépôt distant, comme avant. On ne casse pas un
# déploiement connecté parce qu'un cache manque.
- name: Lire le manifeste du cache du contrôleur
ansible.builtin.slurp:
src: "{{ paquets_tiers_cache }}/MANIFESTE"
delegate_to: localhost
become: false
register: paquets_tiers_manifeste
failed_when: false
changed_when: false
# REMISE A ZERO AVANT DE REMPLIR. Un `set_fact` survit au role : appele deux fois dans
# le meme play, ce dictionnaire garderait les paquets du premier appelant et les
# reinstallerait chez le second. Une variable qui traine est une action qu'on ne demande
# plus mais qui a toujours lieu.
- name: Repartir d'un inventaire vide
ansible.builtin.set_fact:
paquets_tiers_disponibles: {}
# Le manifeste porte une ligne `nom=version fichier.deb` par paquet mis en cache. On n'en
# retient que les noms que l'appelant a DEMANDES : un role n'installe pas ce qu'il n'a
# pas declare vouloir, meme si le cache le contient.
- name: Établir ce que le cache peut fournir
ansible.builtin.set_fact:
paquets_tiers_disponibles: >-
{{ paquets_tiers_disponibles
| combine({ (item.split(' ')[0].split('=')[0]): item.split(' ')[1] }) }}
loop: "{{ (paquets_tiers_manifeste.content | default('') | b64decode).splitlines() }}"
when:
- item is search('=')
- item.split(' ') | length > 1
- item.split(' ')[0].split('=')[0] in paquets_tiers_noms
loop_control:
label: "{{ item.split(' ')[0] }}"
- name: Dire que le cache ne couvre pas ces paquets
ansible.builtin.debug:
msg: >-
Aucun paquet en cache pour {{ paquets_tiers_noms | join(', ') }} — ils seront tirés
du dépôt distant. Une reconstruction SANS INTERNET échouera ici.
Lancer `make cacher-paquets` depuis un poste connecté.
when:
- paquets_tiers_noms | length > 0
- paquets_tiers_disponibles | length == 0
- name: Assurer le répertoire des paquets déposés
ansible.builtin.file:
path: "{{ paquets_tiers_dest }}"
state: directory
owner: root
group: root
mode: "0755"
when: paquets_tiers_disponibles | length > 0
- name: Déposer les paquets depuis le cache du contrôleur
ansible.builtin.copy:
src: "{{ paquets_tiers_cache }}/{{ item.value }}"
dest: "{{ paquets_tiers_dest }}/{{ item.value }}"
owner: root
group: root
mode: "0644"
loop: "{{ paquets_tiers_disponibles | dict2items }}"
loop_control:
label: "{{ item.key }}"
# EN UN SEUL APPEL, ET C'EST LE POINT. `apt` sait résoudre un ENSEMBLE de fichiers locaux
# — l'un dépendant de l'autre — alors qu'une installation paquet par paquet échouerait sur
# l'ordre. Icinga en apporte dix-sept qui se dépendent mutuellement : les poser un à un
# demanderait de connaître leur graphe, que seul apt connaît.
#
# Les dépendances Debian, elles, viennent du cache du site en HTTP, comme d'habitude.
- name: Installer les paquets tiers depuis le cache
ansible.builtin.command:
argv: >-
{{ ["apt-get", "install", "-y", "--no-install-recommends"]
+ (paquets_tiers_disponibles.values()
| map('regex_replace', '^', paquets_tiers_dest ~ '/') | list) }}
environment:
DEBIAN_FRONTEND: noninteractive
register: paquets_tiers_pose
changed_when: "'0 nouvellement installés' not in paquets_tiers_pose.stdout
and '0 newly installed' not in paquets_tiers_pose.stdout"
when: paquets_tiers_disponibles | length > 0

View file

@ -50,12 +50,30 @@
filename: grafana
state: present
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT POUR LE RESTE (2026-09-03).
#
# Les depots tiers sont en HTTPS, et `client_artefacts` pose
# `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur
# Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions
# epinglees et empreintes verifiees ; ce role les depose depuis ce cache.
- name: Poser les paquets tiers depuis le cache du controleur
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ serveur_grafana_paquets }}"
- name: Installer Grafana
ansible.builtin.apt:
name: "{{ serveur_grafana_paquets }}"
# CE QUE LE CACHE N'A PAS FOURNI, ET RIEN D'AUTRE. Les dependances Debian passent
# par le cache du site en HTTP ; seuls les paquets tiers non caches exigent Internet.
# Reinstaller ce que le cache vient de poser ferait un `update_cache` inutile — et
# hors ligne, il echouerait APRES un travail deja fait.
name: "{{ serveur_grafana_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
state: present
update_cache: true
when: (serveur_grafana_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
- name: Provisionner les datasources (Prometheus + Loki)
ansible.builtin.template:
src: datasources.yaml.j2

View file

@ -31,12 +31,30 @@
filename: icinga
state: present
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT POUR LE RESTE (2026-09-03).
#
# Les depots tiers sont en HTTPS, et `client_artefacts` pose
# `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur
# Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions
# epinglees et empreintes verifiees ; ce role les depose depuis ce cache.
- name: Poser les paquets tiers depuis le cache du controleur
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ serveur_icinga_paquets }}"
- name: Installer Icinga 2, Icinga DB et Redis dedie
ansible.builtin.apt:
name: "{{ serveur_icinga_paquets }}"
# CE QUE LE CACHE N'A PAS FOURNI, ET RIEN D'AUTRE. Les dependances Debian passent
# par le cache du site en HTTP ; seuls les paquets tiers non caches exigent Internet.
# Reinstaller ce que le cache vient de poser ferait un `update_cache` inutile — et
# hors ligne, il echouerait APRES un travail deja fait.
name: "{{ serveur_icinga_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
state: present
update_cache: true
when: (serveur_icinga_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
- name: Resoudre la base de donnees depuis le registre (role partage)
ansible.builtin.include_role:
name: resoudre_base

View file

@ -19,11 +19,30 @@
serveur_icingaweb2_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}"
no_log: true
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT POUR LE RESTE (2026-09-03).
#
# Les depots tiers sont en HTTPS, et `client_artefacts` pose
# `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur
# Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions
# epinglees et empreintes verifiees ; ce role les depose depuis ce cache.
- name: Poser les paquets tiers depuis le cache du controleur
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ serveur_icingaweb2_paquets }}"
- name: Installer Icinga Web 2 + module IcingaDB + PHP-FPM + nginx
ansible.builtin.apt:
name: "{{ serveur_icingaweb2_paquets }}"
# CE QUE LE CACHE N'A PAS FOURNI, ET RIEN D'AUTRE. Les dependances Debian passent
# par le cache du site en HTTP ; seuls les paquets tiers non caches exigent Internet.
# Reinstaller ce que le cache vient de poser ferait un `update_cache` inutile — et
# hors ligne, il echouerait APRES un travail deja fait.
name: "{{ serveur_icingaweb2_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
state: present
update_cache: true
when: (serveur_icingaweb2_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
- name: Ajouter www-data au groupe icingaweb2 (lecture de la config)
ansible.builtin.user:

View file

@ -36,12 +36,30 @@
filename: grafana
state: present
# LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT POUR LE RESTE (2026-09-03).
#
# Les depots tiers sont en HTTPS, et `client_artefacts` pose
# `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur
# Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions
# epinglees et empreintes verifiees ; ce role les depose depuis ce cache.
- name: Poser les paquets tiers depuis le cache du controleur
ansible.builtin.include_role:
name: paquets_tiers
vars:
paquets_tiers_noms: "{{ serveur_loki_paquets }}"
- name: Installer Loki
ansible.builtin.apt:
name: "{{ serveur_loki_paquets }}"
# CE QUE LE CACHE N'A PAS FOURNI, ET RIEN D'AUTRE. Les dependances Debian passent
# par le cache du site en HTTP ; seuls les paquets tiers non caches exigent Internet.
# Reinstaller ce que le cache vient de poser ferait un `update_cache` inutile — et
# hors ligne, il echouerait APRES un travail deja fait.
name: "{{ serveur_loki_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list) }}"
state: present
update_cache: true
when: (serveur_loki_paquets
| difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0
- name: Assurer le groupe loki (le paquet cree l'utilisateur en nogroup)
ansible.builtin.group:
name: loki

165
scripts/cacher_paquets.py Normal file
View file

@ -0,0 +1,165 @@
#!/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())