diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index d7df600..8f48eb6 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -53,6 +53,23 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - An anonymised number can keep its digit count, on the file anonymiser and on `anonymize.py` alike (`--keep-digits`): 8839 then draws in 1000..9999, so the copy keeps columns of the same width, which an export read by eye or imported into a bounded field asks for. Each value keeps ITS own width and not its column's — 7 stays at one digit where 12 keeps two — and below the unit the rule does not apply, 0.15 having no digit before the point. The option trades one guarantee for another: the measured extent no longer bounds anything, so a rate or a year can leave its range, and the preview SAYS so rather than letting it be found at reimport. Uniqueness is what yields when a band fills up, a duplicate of the same width beating a value of a different one, and the band never exceeds what the type RECEIVING the draw holds, which is not always the column's: an Odoo `integer` over a column PostgreSQL did not create as an integer — what an upgraded base carries — is poured into `numeric`, which has no ceiling, where `integer` stops at 2 147 483 647 and a draw past it aborts the whole write, that write being one transaction - The anonymiser asks four questions instead of eleven, seven of the eleven having had an obvious default. Answering them one by one to arrive at the same place makes prompts be passed by reflex, and a prompt passed by reflex consents to nothing; they now live behind « Advanced options? ». The short path DECLARES what it takes, in sentences rather than in yes/no, since those are decisions taken and not questions whose answer was lost. Macros and charts are asked on both paths: they are not visible in the cells - `markdown-it-py` is declared as a functional dependency: it renders the language model's markdown to HTML, before sanitising, on the assistant page. It reached the environment transitively through `bandit` → `rich`, a lint tool, so removing a lint dependency removed a portal page's rendering engine +- A download cache shared by the QEMU VMs of a host, installed from **Deployment › QEMU cache**. Two VMs of the same distribution stop pulling the same hundreds of megabytes twice: a package file is served from disk, while an index is always taken from upstream, so a withdrawn package can never turn into a « failed retrieving file … 404 ». An index is stored all the same and only comes back out when upstream is unreachable, which is what makes an offline deployment possible. The cache never shrinks by itself: `--status` says what it occupies +- Interception is transparent and covers the whole host bridge, so a VM cannot opt out from the inside. Every VM trusts the cache's authority as long as the service runs. To take ONE machine out, tick « keep this VM out of the download cache » when deploying: its MAC address is fixed before creation and an exception is posted on the host. To take them all out, stop the service — its rules leave with it. A Proxmox host that is itself a VM here is no exception: the machines it carries come out behind its address, so the deployment poses the authority in each of them, by ssh, before installing anything +- The measurement is not limited to Arch: any catalogue system whose package family is known, and either a batch of packages or the real install of ERPLibre and Odoo 18. Measured on Ubuntu 24.04 with that real install, the second VM pulled **zero byte** of package from the network and finished 19 % faster. Verified on the seven systems of the catalogue +- Git is MIRRORED rather than cached: its protocol is a negotiation, the server computing its answer from what the client already holds, so no answer is reusable. A bare mirror per upstream repository is kept on the host and served locally, and a mirror already held serves with no network at all — measured, a repository cloned inside a VM while the cache's upstream was cut. Measured on a full ERPLibre install: the second VM pulled **zero** git request from upstream, where git had been four fifths of the traffic. **Deployment › QEMU cache › Git mirrors** fills them ahead from the manifests, so the first VM does not pay every clone +- An offline deployment also covers the base suite of a cloud image. Its apt REVALIDATES the index it ships instead of downloading it, upstream answers 304, and the cache had no body to keep — so that one suite, and only it, was missing offline while its `-updates` and `-security` neighbours came off the disk. A conditional request for something the cache does not hold now goes upstream without its condition, once, and a client that holds its own copy gets 304 rather than 504 when upstream is mute. +- What an offline deployment covers, exactly: packages, repository indexes and git. NOT what the cache is forbidden to decrypt — a client carrying its own trust store, npm and poetry among them, is tunnelled opaquely, and a tunnel carries nothing once the network is gone. A full ERPLibre install therefore still needs the network, though everything the cache holds is served from disk +- A mirror is COMPLETE where `repo sync` clones at depth one, so it costs tens of gigabytes. Below ten gigabytes free, no new mirror is created and the request goes back upstream. The diagnosis says what the objects and the mirrors each occupy +- **Deployment › QEMU cache › Age and cleanup** groups the cache by age of last use (day, week or month; objects and git repositories apart) and gives back what has not served for a chosen delay, or everything. A served object has its date renewed, so « old » means « no longer used ». Both cleanups say what would go before erasing anything, and entry 5 lists the mirrors heaviest first to remove one +- `long_test/qemu_cache.py` measures whether the cache really serves the second VM, and `--hors-ligne` cuts the upstream of the cache service alone to prove a third VM still builds from the stored index +- Before cutting, the form says whether the cache holds the base suite of each system asked for: F5 warns « cache holds nothing for ubuntu 26.04 » and a second F5 goes ahead anyway. The verdict is given only where a release is named unambiguously in the URL — the apt families — rather than reassuring wrongly elsewhere. The verdict is given component by component — `main`, `universe`, `restricted`, `multiverse`, on the base suite and on its `-updates` and `-security` companions — because a single stored URL used to silence it while a whole component was missing, and apt only said so twenty minutes later, by « Unable to locate package » +- **Deployment › QEMU cache › Copy it to another machine** carries the store to another ERPLibre host in a single stream — `tar`, compressed, over ssh, no intermediate file for tens of gigabytes — and hands the files to the service account on arrival, the same account rarely bearing the same number on two machines. Only the STORE travels: an object is keyed by its URL and never by the machine that fetched it, and a git mirror is a repository. The settings stay behind — bridge, subnet and authority belong to the host, and entry 1 poses them there +- The deployment form carries a **Network** section with « No internet connection »: for the whole deployment, install included, the cache service loses its way out AND the VMs lose theirs — ping, other ports, UDP, IPv6 — so a step that takes any path but the cache fails instead of quietly succeeding online. Names no longer resolve through the internet either: the host answers every name itself with an address the cache intercepts, so only what the cache holds can be reached; dnsmasq must be installed on the host. A VM behind the cache pulls from one fixed apt mirror: cloud-init's mirror search discards every mirror behind a resolver that answers every name, and would fall back on another mirror than the one the previous VMs filled the cache from. The cut refuses rather than drops, so an address the cache does not hold answers 504 at once instead of after a connection timeout; a kernel that cannot load the reject module gets the dropping set instead. Offered only where the cache runs, and the deployment refuses rather than run with the upstream still up, a VM built that way succeeding for the wrong reason. **Proxmox VE** carries the same box, offered where its host is itself a VM of this bridge: its guests come out behind its address, so the local cut covers them and nothing is posted on the remote host. A Proxmox host that does not live here is never offered it — nothing here can cut its way out +- A Proxmox VE deployment can ask for **3D acceleration**. The VM is created with an accelerated screen (`--vga virtio-gl`) rather than the serial console as its display, and the account joins the GPU groups INSIDE the guest: the render node belongs to `root:render`, so without them every GL application falls back to software rendering while the VIRGL negotiation reports success, and nothing says so. The box is offered only where the remote host has a render node AND the three libraries Proxmox loads for that screen — VIRGL, GL and EGL. It names the missing ones and refuses to start the machine, once its disk is written and its configuration posted, so a host carrying GL without EGL would fail at the very last step. Where a piece is missing the form says which, and the package to install on the host: a box that vanishes without a word reads as a regression. A button installs them without leaving the screen — the form hands the terminal back so sudo can ask for a password and apt can be watched — then reads the host again rather than taking apt's word for it, and the box appears in place of the message. The serial port stays posted, so `qm terminal` keeps working +- Offline, the cache replays what an online pass saw, not only 200 bodies: redirects, definitive refusals (404/410) and HEAD answers of volatile URLs are kept without a body, under keys of their own, and served ONLY when upstream is mute. A TUF client probing the next version of its root gets the 404 it expects instead of a 504, so mise installs Python offline with its Sigstore verification intact; GNOME extensions, Claude Code's installer and rtk's version lookup follow their redirects offline +- The offline cut ends when the last installation ends, not when the monitor closes: a root unit, handed the lift at launch, waits for every installation's exit marker and survives the monitor closed early, todo.py killed or the terminal gone — 12 h at most. The monitor is therefore required while offline. A second offline deployment is refused while one runs, and an online deployment started meanwhile is told it would run offline +- F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache +- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give @@ -78,6 +95,23 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Un nombre anonymisé peut garder son nombre de chiffres, sur l'anonymiseur de fichiers comme sur `anonymize.py` (`--keep-digits`) : 8839 tire alors dans 1000..9999, si bien que la copie garde des colonnes de la même largeur, ce qu'un export relu à l'œil ou importé dans un champ borné demande. Chaque valeur garde SA largeur et non celle de sa colonne — 7 reste à un chiffre là où 12 en garde deux — et sous l'unité la règle ne s'applique pas, 0,15 n'ayant pas de chiffre avant la virgule. L'option échange une garantie contre une autre : l'étendue mesurée ne borne plus rien, donc un taux ou une année peut sortir de sa plage, et l'aperçu le DIT au lieu de le laisser découvrir à la réimportation. C'est l'unicité qui cède quand une bande se remplit, un doublon de la même largeur valant mieux qu'une valeur d'une autre, et la bande ne dépasse jamais ce que tient le type qui ACCUEILLE le tirage, lequel n'est pas toujours celui de la colonne : un `integer` d'Odoo posé sur une colonne que PostgreSQL n'a pas créée entière — ce qu'une base montée de version porte — se coule en `numeric`, qui n'a pas de plafond, là où `integer` s'arrête à 2 147 483 647 et où un tirage au-delà fait retomber toute l'écriture, celle-ci tenant en une transaction - L'anonymiseur pose quatre questions au lieu de onze, sept des onze ayant eu un défaut évident. Les répondre une par une pour arriver au même endroit fait passer les invites par réflexe, et une invite qu'on passe par réflexe ne consent à rien : elles vivent désormais derrière « Options avancées ? ». Le chemin court DIT ce qu'il prend, en phrases et non en oui/non, puisque ce sont des décisions prises et non des questions dont on aurait perdu la réponse. Macros et graphiques sont demandés dans les deux chemins : ils ne se voient pas dans les cellules - `markdown-it-py` est déclaré en dépendance fonctionnelle : il rend en HTML le markdown du modèle de langage, avant assainissement, sur la page de l'assistant. Il arrivait dans l'environnement en transitif par `bandit` → `rich`, un outil de lint, si bien que retirer une dépendance de lint retirait le moteur de rendu d'une page portail +- Un cache de téléchargement partagé par les VM QEMU d'un hôte, installé depuis **Déploiement › Cache QEMU**. Deux VM de la même distribution cessent de tirer deux fois les mêmes centaines de mégaoctets : un fichier de paquet est servi du disque, tandis qu'un index est toujours repris à l'amont, si bien qu'un paquet retiré ne peut jamais devenir un « failed retrieving file … 404 ». L'index est stocké quand même et ne ressort que si l'amont est injoignable, ce qui rend un déploiement hors ligne possible. Le cache ne diminue jamais de lui-même : `--status` dit ce qu'il occupe +- L'interception est transparente et vaut pour tout le pont de l'hôte : une VM ne peut pas s'y soustraire de l'intérieur. Toutes approuvent l'autorité du cache tant que le service tourne. Pour en soustraire UNE, cocher « soustraire cette VM au cache » au déploiement : son adresse MAC est fixée avant la création et une exception est posée sur l'hôte. Pour les soustraire toutes, arrêter le service — ses règles partent avec lui. Un hôte Proxmox qui est lui-même une VM d'ici n'y échappe pas : les machines qu'il porte sortent derrière son adresse, et le déploiement pose donc l'autorité dans chacune, par ssh, avant d'installer quoi que ce soit +- La mesure ne se limite plus à Arch : tout système du catalogue dont la famille de paquets est connue, et au choix un lot de paquets ou l'installation réelle d'ERPLibre et d'Odoo 18. Mesuré sur Ubuntu 24.04 avec cette installation réelle, la seconde VM n'a tiré **aucun octet** de paquet du réseau et a fini 19 % plus vite. Vérifié sur les sept systèmes du catalogue +- Git est mis en MIROIR plutôt que caché : son protocole est une négociation, le serveur calculant sa réponse d'après ce que le client détient déjà, si bien qu'aucune réponse ne se réutilise. Un miroir nu par dépôt amont est tenu sur l'hôte et servi localement, et un miroir déjà détenu sert sans aucun réseau — mesuré, un dépôt cloné dans une VM alors que l'amont du cache était coupé. Mesuré sur une installation complète d'ERPLibre : la seconde VM n'a tiré **aucune** requête git de l'amont, là où git pesait quatre cinquièmes du trafic. **Déploiement › Cache QEMU › Miroirs git** les remplit d'avance depuis les manifestes, pour que la première VM ne paie pas tous les clonages +- Un déploiement hors ligne couvre aussi la suite de base d'une image cloud. Son apt REVALIDE l'index qu'elle livre au lieu de le télécharger, l'amont rend 304, et le cache n'avait donc aucun corps à garder — cette suite-là, et elle seule, manquait hors ligne pendant que ses voisines `-updates` et `-security` sortaient du disque. Une requête conditionnelle sur ce que le cache ne détient pas part désormais sans sa condition, une fois ; et un client qui détient sa propre copie reçoit 304 plutôt que 504 quand l'amont est muet. +- Ce qu'un déploiement hors ligne couvre, exactement : les paquets, les index de dépôts et git. PAS ce que le cache n'a pas le droit de déchiffrer — un client qui porte son propre magasin de confiance, npm et poetry en sont, passe en tunnel opaque, et un tunnel ne porte rien une fois le réseau coupé. Une installation complète d'ERPLibre demande donc toujours le réseau, même si tout ce que le cache détient est servi du disque +- Un miroir est COMPLET là où `repo sync` clone en profondeur un : il coûte donc des dizaines de gigaoctets. Sous dix gigaoctets libres, aucun miroir neuf n'est créé et la requête repart vers l'amont. Le diagnostic dit ce qu'occupent les objets et les miroirs, séparément +- **Déploiement › Cache QEMU › Âge et nettoyage** groupe le cache par âge du dernier usage (jour, semaine ou mois ; objets et dépôts git séparément) et rend ce qui n'a plus servi depuis un délai choisi, ou tout. Un objet servi voit sa date remise à jour : « vieux » veut donc dire « n'a plus servi ». Les deux nettoyages disent ce qui partirait avant d'effacer quoi que ce soit, et l'entrée 5 liste les miroirs du plus lourd au plus léger pour en effacer un +- `long_test/qemu_cache.py` mesure si le cache sert vraiment la seconde VM, et `--hors-ligne` coupe l'amont du seul service du cache pour prouver qu'une troisième se bâtit encore sur l'index stocké +- Avant de couper, le formulaire dit si le cache détient la suite de base de chaque système demandé : F5 prévient « le cache ne détient rien pour ubuntu 26.04 » et un second F5 passe outre. Le verdict n'est rendu que là où une version se nomme sans ambiguïté dans l'URL — les familles apt — plutôt que de rassurer à tort ailleurs. Le verdict est rendu composant par composant — `main`, `universe`, `restricted`, `multiverse`, sur la suite de base et sur ses compagnes `-updates` et `-security` —, car une seule URL en réserve le rendait muet quand un composant entier manquait, et apt ne le disait que vingt minutes plus tard, par « Unable to locate package » +- **Déploiement › Cache QEMU › L'emporter sur une autre machine** porte le magasin vers un autre hôte ERPLibre en un seul flux — `tar`, compressé, par ssh, sans fichier intermédiaire pour des dizaines de gigaoctets — et rend les fichiers au compte du service à l'arrivée, le même compte portant rarement le même numéro sur deux machines. Seul le MAGASIN voyage : un objet est rangé sous son URL et jamais sous la machine qui l'a pris, et un miroir git est un dépôt. Les réglages restent — pont, sous-réseau et autorité appartiennent à l'hôte, et l'entrée 1 les pose là-bas +- Le formulaire de déploiement porte une section **Réseau** avec « Sans connexion internet » : pour tout le déploiement, installation comprise, le service du cache perd sa sortie ET les VM perdent la leur — ping, autres ports, UDP, IPv6 —, si bien qu'un pas qui prendrait un autre chemin que le cache échoue au lieu de réussir en ligne sans rien dire. Les noms ne se résolvent plus par l'internet non plus : l'hôte répond lui-même à tout nom une adresse que le cache intercepte, si bien que seul ce que le cache détient est joignable ; dnsmasq doit être installé sur l'hôte. Une VM derrière le cache tire d'un seul miroir apt, fixe : la recherche de miroir de cloud-init écarte tout miroir derrière un résolveur qui répond à tout nom, et retomberait sur un autre miroir que celui dont les VM précédentes ont rempli le cache. La coupure refuse au lieu de jeter : une adresse que le cache ne détient pas répond 504 sur-le-champ, et non après un délai de connexion ; un noyau qui ne peut pas charger le module du refus reçoit le jeu qui jette. Offerte seulement là où le cache tourne, et le déploiement refuse plutôt que de partir avec l'amont debout, une VM bâtie ainsi réussissant pour la mauvaise raison. **Proxmox VE** porte la même case, offerte là où son hôte est lui-même une VM de ce pont : ses invités sortent derrière son adresse, si bien que la coupure locale les couvre et que rien n'est posé sur l'hôte distant. Un hôte Proxmox qui ne vit pas ici ne la reçoit jamais — rien ici ne sait couper sa sortie +- Un déploiement Proxmox VE peut demander l'**accélération 3D**. La VM est créée avec un écran accéléré (`--vga virtio-gl`) plutôt que la console série comme affichage, et le compte entre dans les groupes du GPU DANS l'invité : le nœud de rendu appartient à « root:render », si bien que sans eux toute application GL retombe en rendu logiciel alors que la négociation VIRGL a réussi, et rien ne le signale. La case n'est offerte que là où l'hôte distant a le nœud de rendu ET les trois bibliothèques que Proxmox charge pour cet écran — VIRGL, GL et EGL. Il nomme celles qui manquent et refuse de démarrer la machine, une fois son disque écrit et sa configuration posée : un hôte portant GL sans EGL échouerait donc à la toute dernière étape. Là où une pièce manque, le formulaire dit laquelle et quel paquet poser sur l'hôte — une case qui disparaît sans un mot se lit comme une régression. Un bouton les pose sans quitter l'écran — le formulaire rend le terminal pour que sudo puisse demander un mot de passe et qu'on voie apt travailler —, puis relit l'hôte au lieu de croire apt sur parole, et la case prend la place du message. Le port série reste posé, donc « qm terminal » fonctionne toujours +- Hors ligne, le cache rejoue ce qu'un passage en ligne a vu, et non plus les seuls corps en 200 : redirections, refus définitifs (404/410) et réponses HEAD des adresses volatiles sont gardés sans corps, sous des clés à eux, et servis SEULEMENT quand l'amont est muet. Un client TUF qui sonde la version suivante de sa racine reçoit le 404 qu'il attend au lieu d'un 504 : mise pose Python hors ligne, vérification Sigstore intacte ; les extensions GNOME, l'installateur de Claude Code et la version de rtk suivent leurs redirections hors ligne +- La coupure hors ligne tombe avec la dernière installation, et non à la fermeture du suivi : une unité root, qui reçoit la levée au lancement, attend le marqueur de fin de chaque installation et survit au suivi fermé tôt, à todo.py tué ou au terminal perdu — 12 h au plus. Le suivi est donc obligatoire hors ligne. Un second déploiement hors ligne est refusé pendant qu'un premier tourne, et un déploiement en ligne lancé entre-temps est prévenu qu'il tournerait hors ligne +- F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache +- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera ## Changed @@ -86,10 +120,22 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `Assistant › [1]` no longer sends every question to a single remote API on a fixed model: it asks whichever server is configured, and falls back to the remote one only when no local server answers +- The comment hygiene check reads Go comments, not only `#` ones: `//` outside a string, the raw string between backticks, and `/* … */` blocks +- Debian and Ubuntu `by-hash` index files are served from disk, their name being the digest of their content; `…/releases/latest/download/…` is no longer pinned to the first version seen +- An upstream that refuses or drops connections is remembered for 20 s: a request with a stored answer is served at once instead of waiting its connect timeout, which made up most of an offline install's time; a request with nothing stored still tries upstream. A git mirror skips its refresh while its forge is unreachable +- Mirror prefetch runs under the cache's service account, never as root +- The long-test menu asks before creating real machines, and asks again, in words of its own, before `--detruire` removes machines with their disks. The command is shown first, which is what makes the question answerable; a dry run or a performance report creates nothing and asks nothing +- Every entry of the Proxmox VE menu carries an icon, the same picture meaning the same action as in the other menus of the tool - `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond +- Le contrôle d'hygiène des commentaires lit le Go, et non les seuls `#` : `//` hors d'une chaîne, la chaîne brute entre accents graves, et les blocs `/* … */` +- Les index `by-hash` de Debian et d'Ubuntu sont servis du disque, leur nom étant l'empreinte de leur contenu ; `…/releases/latest/download/…` n'est plus figé sur la première version vue +- Un amont qui refuse ou ignore les connexions est retenu 20 s : une requête qui a une réponse gardée est servie aussitôt au lieu d'attendre son délai d'établissement, qui faisait l'essentiel du temps d'une installation hors ligne ; une requête sans rien en réserve tente toujours l'amont. Un miroir git saute son rafraîchissement tant que sa forge est injoignable +- Le pré-remplissage des miroirs tourne sous le compte du service du cache, jamais en root +- Le menu des tests longs demande avant de créer de vraies machines, et redemande, avec des mots à lui, avant que `--detruire` efface des machines avec leurs disques. La commande est montrée d'abord, c'est elle qui rend la question répondable ; un plan à blanc ou un rapport de performance ne crée rien et ne demande rien +- Chaque entrée du menu Proxmox VE porte une icône, la même image voulant dire la même action que dans les autres menus de l'outil ## Fixed @@ -107,6 +153,33 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Restoring a backup no longer raises `AttributeError` before running the command: `_monitoring_restore` read `self._execute` where `TODO` sets `self.execute`, and both entries leading there — the local backup and the remote one — were broken. A check refuses the next one: every `self.X` that `TODO` READS must be set by TODO or by one of its bases. Searching the name across `script/todo/` did not see the fault, another object of the package setting `_execute`; it has to be searched in the classes TODO INHERITS from - The Selenium scripts run on a Python built without `tkinter` — any server with no graphical toolkit package. That module is only needed by the vault's file picker, so it is now optional: with no `tkinter` and no configured KDBX path, opening the vault logs an error and returns, instead of breaking the import of every browser-automation script - Dark mode works again in a private window on a current Firefox. The « Run in Private Windows » permission is granted when the addon is installed, through the `allowPrivateBrowsing` field, rather than clicked through the `about:addons` interface: Firefox refuses navigation to `about:addons` from the content context, and the chrome context demands `-remote-allow-system-access`, which geckodriver rejects through capabilities, so both routes to that checkbox are closed. A geckodriver that ignores the field installs without the permission instead of killing the session +- The « - Default » label appears again at the version and environment menus: both reads asked for a capitalised key the version file never writes, and a missing key returns nothing without a word +- The cache's « nothing in store » message is inert as a shell script, every line being a comment. Fed to an installer built on `curl … | bash`, it used to become a cascade of « command not found » that hid the real cause. Such a download now also asks curl to fail on an HTTP error rather than execute the error page +- The wait for a VM to be ready now covers the guest-agent install too. That one is launched as a DETACHED unit so cloud-init returns in seconds, and it runs an `apt-get update`: `cloud-init status --wait` said « done » while the package lock was still held, the next step burnt through its retries, and the install then ran on an index never refreshed — « Unable to locate package », a message that blames the repository rather than the lock. An update that never succeeds now says so on the spot +- A desktop install no longer waits minutes on the apt lock: the apt-daily SERVICE is stopped and not only its timer, a timer being disabled without interrupting the apt-get it already started; and the retry comes back every two seconds rather than every ten, `DPkg::Lock::Timeout` not covering the list lock at all +- Fedora VMs boot again: the firmware loads and starts their loader, then freezes without writing a byte — no console, no DHCP lease, a machine "running" that does nothing. Fedora is booted in legacy BIOS, where the same image starts its kernel; `--bios` still wins when asked +- A VM receives a hostname it can accept — an underscore, which a libvirt domain name tolerates, made it keep its image's generic name — and a timezone its own distribution knows, a legacy alias having left it in UTC +- starship installs in a VM: its installer runs as root, bounded by a root timeout, so it never reaches the `sudo -v` that sudo-rs refuses; its shell hook no longer prints « command not found », nor fails a sourced rc, when starship is absent +- Each optional tool says whether it was installed, and a GNOME extension whose download failed is no longer reported as unavailable for this GNOME +- mise, pyenv and GNOME extensions are downloaded, then run: without pipefail, `curl | sh` could neither report a failed download nor reach its fallback +- The guest-agent unit no longer ends in failure after a successful install +- The cache answers 508 to a request that targets the cache itself, instead of calling itself until it runs out of descriptors +- The cache diagnosis no longer reports a stopped service as running +- The wait for `apt-get update` is bounded by a deadline rather than by a number of attempts. An attempt fails in under a second on a held lock, but takes minutes when the cache answers 504 on every index it does not hold: sixty attempts were then worth hours of silence where five minutes were promised, and the install went on to fail on unmet dependencies +- A Proxmox guest is pinned to the same apt mirror as the rest of the fleet, written over ssh before anything downloads. The store keys its indexes by HOST, so a VM left on the default repositories of its image found none of what the cache had been filled with — offline, every one of those indexes was missing +- The cache installer accepts a bridge and a subnet given by hand. It probed libvirt first and died on « network default not found », so the workaround its own header advertised — `EL_BRIDGE` and `EL_SUBNET` — could never be reached. A machine whose libvirt network is not started, or which carries its bridge otherwise, can now lay the cache down by naming it; and when the probe does run and fails, it names both ways out +- **Deployment › QEMU cache › Install or reinstall** no longer announces « installed and started » when the installer failed. It caught exceptions only: a non-zero exit — a missing libvirt network, a build that gave way — printed the success line right under the error itself, along with the path of an authority that does not exist +- A Proxmox guest receives its guide, its timezone, its apt mirror and the cache authority again. All four go by ssh and used to start as soon as an address was known, while cloud-init was still creating accounts and keys: they failed together, and the VM was born in UTC, guideless, without the authority and on its image's repositories. The deployment now waits for the machine to answer ssh — five minutes at most — and says so when it never does, rather than failing four times in a row +- The cache no longer serves a repository index newer than the signature that announces it. Offline, each object came out with its own date: an index refreshed on Saturday under a Friday `InRelease` made apt fail on « File has unexpected size » or « Hash Sum mismatch », and the install stopped on unmet dependencies — a message that blames the repository, never the cache. The comparison is made on the upstream `Last-Modified`, never on the storage date, which is renewed on every hit +- The refusal to copy the cache to another machine names the gesture that lifts it, and that gesture is not entry 1 — entry 1 installs the cache HERE, so following it reinstalled the host that already had one while the target stayed empty. Three situations were reported as a single « no cache installed »: an ssh link that never ran the probe, a target carrying no cache, and a target whose cache lacks its service account. Each has its own message now, and the missing-cache one lists the steps to run ON the target, the two ways past an installer that reads the « default » libvirt network included — start libvirt, or name the bridge, a stopped libvirt making it die on a network that exists. The steps also name the branch to put the target on, read from this host: the installer is a file of the repository, so a machine left on another branch answers « no such file », which looks nothing like a missing cache. A fourth case is checked before a single byte leaves: sudo asking for a password on the target, which no terminal can answer since the store itself occupies ssh's standard input — the message gives the ticket to obtain there first. The arrival is asked for the privilege ONCE, `tar` and `chown` under a single invocation. Where sudo there wants a password, the entry offers two ways out instead of failing: the one-off sudoers line that allows it, or a two-step mode — the store is sent into the target's own account, which needs no privilege at all, and a printed command extracts it from a terminal there, where a password can be typed. That mode needs twice the store on the target, checked before a byte leaves, a cache holding only already-compressed packages and git archives. The guide carries the same section +- The Proxmox VE deployment form checks the cache before cutting the network, as the libvirt one already did: what the store lacks, no cut VM will read, and the failure used to land an hour later, at the desktop step — a message that blames the repository, never the cache. F5 again means going ahead anyway. Both forms now share a single verdict instead of two copies of it, which would have drifted apart at the first adjustment +- The cache keeps its repository indexes when a mirror list rotates. An index published under the hash of its content — `by-hash/SHA256/…` — was stored under a key carrying the host, so the same bytes served by a second mirror were fetched again; offline they were simply missing, and the install failed on files the store already held, with a message that blames the repository. Such an object is now keyed by its path alone, its name BEING the checksum of its content +- The cache diagnosis no longer reports « no redirection rule is posted » when it simply could not read them. On a host whose sudo asks for a password, `sudo -n nft` returns nothing, and that silence was read as an absence of rules — sending the operator to reinstall a cache that was redirecting correctly. The reading now carries a third state, « cannot tell », marked with a dot rather than a cross, exactly as the upstream-cut reading already did +- A lifted cut gives the upstreams their chance back at once. The service remembers, for a short while, which upstreams just failed to connect, so that an offline install does not pay the connection delay hundreds of times; nothing told it the cut was over, and the first requests after the lift fell back on the store while the network was already back. The lift now touches a witness file inside the store, which that memory consults — there is no channel at all to the running service +- Before cutting, the form also warns about what no index can reveal: a package the deployment lays down OUTSIDE the watched thread — the guest agent, installed by a detached unit whose failure surfaces nowhere — and the git repositories declared by the manifests that have no mirror yet. A held suite index was enough to call the cache complete while not one byte of that package had ever crossed it; and a git negotiation is never stored, so a repository without a mirror simply cannot be cloned once the network is gone +- The mirror warning counts only the repositories of the Odoo version being deployed. It used to add up every manifest in the repository — the deprecated one included — and announced 170 missing mirrors where a real Odoo 18 deployment meets four: an alarm that fires for nothing is one that stops being read. Filling the mirrors still takes every version ahead, which is its purpose +- A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes +- **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones @@ -120,6 +193,33 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Restaurer une sauvegarde ne lève plus `AttributeError` avant de lancer la commande : `_monitoring_restore` lisait `self._execute` là où `TODO` pose `self.execute`, et les deux entrées qui y mènent — la sauvegarde locale et la distante — étaient cassées. Un contrôle refuse le suivant : tout `self.X` que `TODO` LIT doit être posé par TODO ou par une de ses bases. Chercher le nom dans tout `script/todo/` ne voyait pas la faute, un autre objet du paquet posant bien `_execute` ; il faut le chercher dans les classes dont TODO HÉRITE - Les scripts Selenium tournent sur un Python bâti sans `tkinter` — tout serveur dépourvu de paquet d'interface graphique. Ce module ne sert qu'au sélecteur de fichier du coffre, il est donc désormais optionnel : sans `tkinter` et sans chemin KDBX configuré, l'ouverture du coffre journalise une erreur et rend la main, au lieu de casser l'import de tous les scripts de pilotage de navigateur - Le mode sombre repart en fenêtre privée sur un Firefox récent. La permission « Exécuter dans les fenêtres privées » est accordée à l'installation de l'extension, par le champ `allowPrivateBrowsing`, au lieu d'être cochée dans l'interface `about:addons` : Firefox refuse la navigation vers `about:addons` depuis le contexte contenu, et le contexte chrome exige `-remote-allow-system-access`, que geckodriver refuse via les capabilities, si bien que les deux voies vers cette case sont fermées. Un geckodriver qui ignore le champ installe sans la permission au lieu d'interrompre la session +- L'étiquette « - Default » reparaît aux menus des versions et des environnements : les deux lectures demandaient une clé à majuscule que le fichier des versions n'écrit pas, et une clé absente ne rend rien sans rien dire +- Le message « rien en réserve » du cache est inerte pour un interpréteur de commandes, chaque ligne étant un commentaire. Livré à un installateur bâti sur `curl … | bash`, il devenait une cascade de « command not found » qui masquait la cause. Un tel téléchargement demande en outre à curl d'échouer sur une erreur HTTP plutôt que d'exécuter la page d'erreur +- L'attente qu'une VM soit prête couvre aussi la pose de l'agent invité. Celle-ci part en unité DÉTACHÉE pour que cloud-init rende la main en quelques secondes, et elle fait un `apt-get update` : « cloud-init status --wait » disait « done » alors que le verrou des paquets était encore tenu, l'étape suivante épuisait ses reprises, puis installait sur un index jamais rafraîchi — « Impossible de trouver le paquet », un message qui accuse le dépôt et non le verrou. Un update qui n'aboutit pas le dit désormais sur le champ +- Une installation de bureau n'attend plus des minutes sur le verrou apt : le SERVICE apt-daily est arrêté et non son seul minuteur, un minuteur désactivé n'interrompant pas l'apt-get qu'il a déjà lancé ; et la reprise repasse toutes les deux secondes au lieu de dix, « DPkg::Lock::Timeout » ne couvrant pas ce verrou-là +- Les VM Fedora démarrent de nouveau : le micrologiciel charge et démarre leur chargeur, puis se fige sans écrire un octet — pas de console, pas de bail DHCP, une machine « en cours d'exécution » qui ne fait rien. Fedora est amorcée en BIOS hérité, où la même image démarre son noyau ; `--bios` garde le dernier mot +- Une VM reçoit un nom d'hôte qu'elle accepte — un souligné, que le nom de domaine libvirt tolère, lui faisait garder le nom générique de son image — et un fuseau que sa distribution connaît, un alias hérité la laissant en UTC +- starship s'installe dans une VM : son installateur tourne en root, borné par un délai root, et n'atteint jamais le `sudo -v` que sudo-rs refuse ; son crochet de shell n'écrit plus « command not found », ni ne fait échouer un rc sourcé, quand starship est absent +- Chaque outil optionnel dit s'il a été posé, et une extension GNOME dont le téléchargement a échoué n'est plus déclarée indisponible pour ce GNOME +- mise, pyenv et les extensions GNOME sont téléchargés, puis exécutés : sans pipefail, `curl | sh` ne pouvait ni signaler un téléchargement raté ni atteindre son repli +- L'unité de l'agent invité ne finit plus en échec après une pose réussie +- Le cache répond 508 à une requête qui le vise lui-même, au lieu de s'appeler jusqu'à épuiser ses descripteurs +- Le diagnostic du cache ne donne plus un service arrêté pour actif +- L'attente d'`apt-get update` est bornée par une échéance et non par un nombre d'essais. Un essai échoue en moins d'une seconde sur un verrou tenu, mais dure des minutes quand le cache rend 504 sur chaque index qu'il ne détient pas : soixante essais valaient alors des heures de silence là où cinq minutes étaient promises, et l'installation échouait ensuite sur des dépendances introuvables +- Un invité Proxmox est fixé sur le même miroir apt que le reste du parc, écrit par ssh avant tout téléchargement. Le magasin range ses index par HÔTE : une VM restée sur les dépôts par défaut de son image ne retrouvait rien de ce dont le cache avait été rempli — hors ligne, chacun de ces index manquait +- L'installateur du cache accepte un pont et un sous-réseau donnés à la main. Il sondait libvirt d'abord et mourait sur « réseau default introuvable » : le contournement annoncé dans son propre en-tête — `EL_BRIDGE` et `EL_SUBNET` — était donc hors d'atteinte. Une machine dont le réseau libvirt n'est pas démarré, ou qui porte son pont autrement, peut désormais poser le cache en le nommant ; et quand la sonde tourne et échoue, elle nomme les deux issues +- **Déploiement › Cache QEMU › Installer ou réinstaller** n'annonce plus « installé et démarré » quand l'installateur a échoué. Il n'attrapait que les exceptions : un code de sortie non nul — réseau libvirt absent, compilation qui cède — imprimait la ligne de réussite juste sous l'erreur elle-même, avec le chemin d'une autorité qui n'existe pas +- Un invité Proxmox reçoit de nouveau son guide, son fuseau, son miroir apt et l'autorité du cache. Ces quatre gestes passent par ssh et partaient dès qu'une adresse était connue, pendant que cloud-init posait encore les comptes et les clés : ils échouaient ensemble, et la VM naissait en UTC, sans guide, sans autorité et sur les dépôts de son image. Le déploiement attend désormais que la machine réponde en ssh — cinq minutes au plus — et le dit quand elle ne répond jamais, au lieu d'échouer quatre fois de suite +- Le cache ne sert plus un index de dépôt plus récent que la signature qui l'annonce. Hors ligne, chaque objet sortait avec sa propre date : un index rafraîchi samedi sous un `InRelease` de vendredi faisait échouer apt sur « File has unexpected size » ou « Hash Sum mismatch », et l'installation s'arrêtait sur des dépendances introuvables — un message qui accuse le dépôt, jamais le cache. La comparaison porte sur le `Last-Modified` de l'amont, jamais sur la date de stockage, renouvelée à chaque service +- Le refus d'emporter le cache sur une autre machine nomme le geste qui le lève, et ce geste n'est pas l'entrée 1 — elle pose le cache ICI, si bien que la suivre faisait réinstaller l'hôte qui en avait déjà un pendant que l'arrivée restait sans rien. Trois situations étaient rendues par un seul « pas de cache » : un lien ssh qui n'a jamais exécuté la sonde, une arrivée sans cache, une arrivée dont le cache n'a pas son compte de service. Chacune a désormais son message, et celle du cache absent énumère les gestes à faire SUR l'arrivée, avec les deux issues d'un installateur qui lit le réseau libvirt « default » — lever libvirt, ou nommer le pont, un libvirt arrêté le faisant mourir sur un réseau qui existe. Les gestes nomment aussi la branche à donner à l'arrivée, lue sur cet hôte : l'installateur est un fichier du dépôt, si bien qu'une machine restée sur une autre branche répond « fichier introuvable », ce qui ne ressemble en rien à un cache absent. Un quatrième cas est éprouvé avant qu'un seul octet ne parte : un sudo qui réclame un mot de passe à l'arrivée, auquel aucun terminal ne peut répondre puisque le magasin occupe lui-même l'entrée standard de ssh — le message donne le ticket à y obtenir d'abord. Le privilège n'est demandé qu'UNE fois à l'arrivée, « tar » et « chown » sous une seule invocation. Quand le sudo de là-bas réclame un mot de passe, l'entrée offre deux issues au lieu d'échouer : la ligne sudoers à poser une fois, ou un mode en deux temps — le magasin part dans le compte de l'arrivée, qui n'exige aucun privilège, et une commande affichée l'extrait depuis un terminal de là-bas, où un mot de passe se tape. Ce mode réclame deux fois le magasin à l'arrivée, vérifié avant qu'un octet ne parte, un cache ne contenant que des paquets et des archives git déjà comprimés. Le guide porte la même section +- Le formulaire de déploiement Proxmox VE éprouve le cache avant de couper le réseau, comme celui de libvirt le faisait déjà : ce que le magasin n'a pas, aucune VM coupée ne le lira, et l'échec tombait une heure plus tard, à la pose du bureau — un message qui accuse le dépôt, jamais le cache. F5 à nouveau vaut passage outre. Les deux formulaires partagent désormais un seul verdict au lieu de deux copies, qui auraient divergé au premier ajustement +- Le cache garde ses index de dépôt quand une liste de miroirs tourne. Un index publié sous l'empreinte de son contenu — « by-hash/SHA256/… » — était rangé sous une clé portant l'hôte : les mêmes octets servis par un second miroir étaient repris à l'amont, et hors ligne ils manquaient tout simplement — l'installation échouait sur des fichiers que le magasin détenait, avec un message qui accuse le dépôt. Un tel objet est désormais rangé sous son seul chemin, son nom ÉTANT la somme de son contenu +- Le diagnostic du cache n'annonce plus « aucune règle de détournement n'est posée » quand il n'a simplement pas pu les lire. Sur un hôte dont le sudo réclame un mot de passe, « sudo -n nft » ne rend rien, et ce silence était lu comme une absence de règle — ce qui envoyait réinstaller un cache qui détournait correctement. La lecture porte désormais un troisième état, « impossible de savoir », marqué d'un point et non d'une croix, comme le faisait déjà la lecture de la coupure d'amont +- Une coupure levée rend aussitôt leur chance aux amonts. Le service retient un court moment ceux dont la connexion vient d'échouer, pour qu'une installation hors ligne ne paie pas le délai d'établissement des centaines de fois ; rien ne lui disait que la coupure était finie, et les premières requêtes d'après la levée se rabattaient sur le magasin alors que le réseau était déjà revenu. La levée touche désormais un témoin dans le magasin, que cette mémoire consulte — il n'existe aucun canal vers le service en marche +- Avant de couper, le formulaire avertit aussi de ce qu'aucun index ne peut révéler : un paquet que le déploiement pose HORS du fil observé — l'agent invité, posé par une unité détachée dont l'échec ne remonte nulle part — et les dépôts git déclarés par les manifestes qui n'ont pas encore de miroir. Un index de suite en réserve suffisait à faire passer le cache pour complet alors qu'aucun octet de ce paquet ne l'avait jamais traversé ; et une négociation git ne se garde jamais, si bien qu'un dépôt sans miroir ne peut tout simplement pas être cloné une fois le réseau coupé +- L'avertissement sur les miroirs ne compte que les dépôts de la version d'Odoo déployée. Il additionnait tous les manifestes du dépôt — le déprécié compris — et annonçait 170 miroirs manquants là où un vrai déploiement Odoo 18 en rencontre quatre : une alarme qui sonne pour rien est une alarme qu'on cesse de lire. Le remplissage des miroirs, lui, prend toujours de l'avance pour toutes les versions, ce qui est son rôle +- Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes +- **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais ## Removed @@ -140,10 +240,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - An API key and a bearer token are redacted too before a command is displayed, logged or reprinted: `OPENAI_API_KEY=` went out in the clear, and a header token escaped by construction, carrying neither an option name nor a variable name +- Following a redirect, the cache no longer forwards the client's credentials (Authorization, Cookie, Proxy-Authorization) to another host - Une clé d'API et un jeton Bearer sont caviardés eux aussi avant qu'une commande soit affichée, journalisée ou réimprimée : `OPENAI_API_KEY=` partait en clair, et un jeton d'en-tête échappait par construction, ne portant ni nom d'option ni nom de variable +- En suivant une redirection, le cache ne transmet plus les identifiants du client (Authorization, Cookie, Proxy-Authorization) à un autre hôte diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 8f41abc..62bb155 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -33,10 +33,33 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Un nombre anonymisé peut garder son nombre de chiffres, sur l'anonymiseur de fichiers comme sur `anonymize.py` (`--keep-digits`) : 8839 tire alors dans 1000..9999, si bien que la copie garde des colonnes de la même largeur, ce qu'un export relu à l'œil ou importé dans un champ borné demande. Chaque valeur garde SA largeur et non celle de sa colonne — 7 reste à un chiffre là où 12 en garde deux — et sous l'unité la règle ne s'applique pas, 0,15 n'ayant pas de chiffre avant la virgule. L'option échange une garantie contre une autre : l'étendue mesurée ne borne plus rien, donc un taux ou une année peut sortir de sa plage, et l'aperçu le DIT au lieu de le laisser découvrir à la réimportation. C'est l'unicité qui cède quand une bande se remplit, un doublon de la même largeur valant mieux qu'une valeur d'une autre, et la bande ne dépasse jamais ce que tient le type qui ACCUEILLE le tirage, lequel n'est pas toujours celui de la colonne : un `integer` d'Odoo posé sur une colonne que PostgreSQL n'a pas créée entière — ce qu'une base montée de version porte — se coule en `numeric`, qui n'a pas de plafond, là où `integer` s'arrête à 2 147 483 647 et où un tirage au-delà fait retomber toute l'écriture, celle-ci tenant en une transaction - L'anonymiseur pose quatre questions au lieu de onze, sept des onze ayant eu un défaut évident. Les répondre une par une pour arriver au même endroit fait passer les invites par réflexe, et une invite qu'on passe par réflexe ne consent à rien : elles vivent désormais derrière « Options avancées ? ». Le chemin court DIT ce qu'il prend, en phrases et non en oui/non, puisque ce sont des décisions prises et non des questions dont on aurait perdu la réponse. Macros et graphiques sont demandés dans les deux chemins : ils ne se voient pas dans les cellules - `markdown-it-py` est déclaré en dépendance fonctionnelle : il rend en HTML le markdown du modèle de langage, avant assainissement, sur la page de l'assistant. Il arrivait dans l'environnement en transitif par `bandit` → `rich`, un outil de lint, si bien que retirer une dépendance de lint retirait le moteur de rendu d'une page portail +- Un cache de téléchargement partagé par les VM QEMU d'un hôte, installé depuis **Déploiement › Cache QEMU**. Deux VM de la même distribution cessent de tirer deux fois les mêmes centaines de mégaoctets : un fichier de paquet est servi du disque, tandis qu'un index est toujours repris à l'amont, si bien qu'un paquet retiré ne peut jamais devenir un « failed retrieving file … 404 ». L'index est stocké quand même et ne ressort que si l'amont est injoignable, ce qui rend un déploiement hors ligne possible. Le cache ne diminue jamais de lui-même : `--status` dit ce qu'il occupe +- L'interception est transparente et vaut pour tout le pont de l'hôte : une VM ne peut pas s'y soustraire de l'intérieur. Toutes approuvent l'autorité du cache tant que le service tourne. Pour en soustraire UNE, cocher « soustraire cette VM au cache » au déploiement : son adresse MAC est fixée avant la création et une exception est posée sur l'hôte. Pour les soustraire toutes, arrêter le service — ses règles partent avec lui. Un hôte Proxmox qui est lui-même une VM d'ici n'y échappe pas : les machines qu'il porte sortent derrière son adresse, et le déploiement pose donc l'autorité dans chacune, par ssh, avant d'installer quoi que ce soit +- La mesure ne se limite plus à Arch : tout système du catalogue dont la famille de paquets est connue, et au choix un lot de paquets ou l'installation réelle d'ERPLibre et d'Odoo 18. Mesuré sur Ubuntu 24.04 avec cette installation réelle, la seconde VM n'a tiré **aucun octet** de paquet du réseau et a fini 19 % plus vite. Vérifié sur les sept systèmes du catalogue +- Git est mis en MIROIR plutôt que caché : son protocole est une négociation, le serveur calculant sa réponse d'après ce que le client détient déjà, si bien qu'aucune réponse ne se réutilise. Un miroir nu par dépôt amont est tenu sur l'hôte et servi localement, et un miroir déjà détenu sert sans aucun réseau — mesuré, un dépôt cloné dans une VM alors que l'amont du cache était coupé. Mesuré sur une installation complète d'ERPLibre : la seconde VM n'a tiré **aucune** requête git de l'amont, là où git pesait quatre cinquièmes du trafic. **Déploiement › Cache QEMU › Miroirs git** les remplit d'avance depuis les manifestes, pour que la première VM ne paie pas tous les clonages +- Un déploiement hors ligne couvre aussi la suite de base d'une image cloud. Son apt REVALIDE l'index qu'elle livre au lieu de le télécharger, l'amont rend 304, et le cache n'avait donc aucun corps à garder — cette suite-là, et elle seule, manquait hors ligne pendant que ses voisines `-updates` et `-security` sortaient du disque. Une requête conditionnelle sur ce que le cache ne détient pas part désormais sans sa condition, une fois ; et un client qui détient sa propre copie reçoit 304 plutôt que 504 quand l'amont est muet. +- Ce qu'un déploiement hors ligne couvre, exactement : les paquets, les index de dépôts et git. PAS ce que le cache n'a pas le droit de déchiffrer — un client qui porte son propre magasin de confiance, npm et poetry en sont, passe en tunnel opaque, et un tunnel ne porte rien une fois le réseau coupé. Une installation complète d'ERPLibre demande donc toujours le réseau, même si tout ce que le cache détient est servi du disque +- Un miroir est COMPLET là où `repo sync` clone en profondeur un : il coûte donc des dizaines de gigaoctets. Sous dix gigaoctets libres, aucun miroir neuf n'est créé et la requête repart vers l'amont. Le diagnostic dit ce qu'occupent les objets et les miroirs, séparément +- **Déploiement › Cache QEMU › Âge et nettoyage** groupe le cache par âge du dernier usage (jour, semaine ou mois ; objets et dépôts git séparément) et rend ce qui n'a plus servi depuis un délai choisi, ou tout. Un objet servi voit sa date remise à jour : « vieux » veut donc dire « n'a plus servi ». Les deux nettoyages disent ce qui partirait avant d'effacer quoi que ce soit, et l'entrée 5 liste les miroirs du plus lourd au plus léger pour en effacer un +- `long_test/qemu_cache.py` mesure si le cache sert vraiment la seconde VM, et `--hors-ligne` coupe l'amont du seul service du cache pour prouver qu'une troisième se bâtit encore sur l'index stocké +- Avant de couper, le formulaire dit si le cache détient la suite de base de chaque système demandé : F5 prévient « le cache ne détient rien pour ubuntu 26.04 » et un second F5 passe outre. Le verdict n'est rendu que là où une version se nomme sans ambiguïté dans l'URL — les familles apt — plutôt que de rassurer à tort ailleurs. Le verdict est rendu composant par composant — `main`, `universe`, `restricted`, `multiverse`, sur la suite de base et sur ses compagnes `-updates` et `-security` —, car une seule URL en réserve le rendait muet quand un composant entier manquait, et apt ne le disait que vingt minutes plus tard, par « Unable to locate package » +- **Déploiement › Cache QEMU › L'emporter sur une autre machine** porte le magasin vers un autre hôte ERPLibre en un seul flux — `tar`, compressé, par ssh, sans fichier intermédiaire pour des dizaines de gigaoctets — et rend les fichiers au compte du service à l'arrivée, le même compte portant rarement le même numéro sur deux machines. Seul le MAGASIN voyage : un objet est rangé sous son URL et jamais sous la machine qui l'a pris, et un miroir git est un dépôt. Les réglages restent — pont, sous-réseau et autorité appartiennent à l'hôte, et l'entrée 1 les pose là-bas +- Le formulaire de déploiement porte une section **Réseau** avec « Sans connexion internet » : pour tout le déploiement, installation comprise, le service du cache perd sa sortie ET les VM perdent la leur — ping, autres ports, UDP, IPv6 —, si bien qu'un pas qui prendrait un autre chemin que le cache échoue au lieu de réussir en ligne sans rien dire. Les noms ne se résolvent plus par l'internet non plus : l'hôte répond lui-même à tout nom une adresse que le cache intercepte, si bien que seul ce que le cache détient est joignable ; dnsmasq doit être installé sur l'hôte. Une VM derrière le cache tire d'un seul miroir apt, fixe : la recherche de miroir de cloud-init écarte tout miroir derrière un résolveur qui répond à tout nom, et retomberait sur un autre miroir que celui dont les VM précédentes ont rempli le cache. La coupure refuse au lieu de jeter : une adresse que le cache ne détient pas répond 504 sur-le-champ, et non après un délai de connexion ; un noyau qui ne peut pas charger le module du refus reçoit le jeu qui jette. Offerte seulement là où le cache tourne, et le déploiement refuse plutôt que de partir avec l'amont debout, une VM bâtie ainsi réussissant pour la mauvaise raison. **Proxmox VE** porte la même case, offerte là où son hôte est lui-même une VM de ce pont : ses invités sortent derrière son adresse, si bien que la coupure locale les couvre et que rien n'est posé sur l'hôte distant. Un hôte Proxmox qui ne vit pas ici ne la reçoit jamais — rien ici ne sait couper sa sortie +- Un déploiement Proxmox VE peut demander l'**accélération 3D**. La VM est créée avec un écran accéléré (`--vga virtio-gl`) plutôt que la console série comme affichage, et le compte entre dans les groupes du GPU DANS l'invité : le nœud de rendu appartient à « root:render », si bien que sans eux toute application GL retombe en rendu logiciel alors que la négociation VIRGL a réussi, et rien ne le signale. La case n'est offerte que là où l'hôte distant a le nœud de rendu ET les trois bibliothèques que Proxmox charge pour cet écran — VIRGL, GL et EGL. Il nomme celles qui manquent et refuse de démarrer la machine, une fois son disque écrit et sa configuration posée : un hôte portant GL sans EGL échouerait donc à la toute dernière étape. Là où une pièce manque, le formulaire dit laquelle et quel paquet poser sur l'hôte — une case qui disparaît sans un mot se lit comme une régression. Un bouton les pose sans quitter l'écran — le formulaire rend le terminal pour que sudo puisse demander un mot de passe et qu'on voie apt travailler —, puis relit l'hôte au lieu de croire apt sur parole, et la case prend la place du message. Le port série reste posé, donc « qm terminal » fonctionne toujours +- Hors ligne, le cache rejoue ce qu'un passage en ligne a vu, et non plus les seuls corps en 200 : redirections, refus définitifs (404/410) et réponses HEAD des adresses volatiles sont gardés sans corps, sous des clés à eux, et servis SEULEMENT quand l'amont est muet. Un client TUF qui sonde la version suivante de sa racine reçoit le 404 qu'il attend au lieu d'un 504 : mise pose Python hors ligne, vérification Sigstore intacte ; les extensions GNOME, l'installateur de Claude Code et la version de rtk suivent leurs redirections hors ligne +- La coupure hors ligne tombe avec la dernière installation, et non à la fermeture du suivi : une unité root, qui reçoit la levée au lancement, attend le marqueur de fin de chaque installation et survit au suivi fermé tôt, à todo.py tué ou au terminal perdu — 12 h au plus. Le suivi est donc obligatoire hors ligne. Un second déploiement hors ligne est refusé pendant qu'un premier tourne, et un déploiement en ligne lancé entre-temps est prévenu qu'il tournerait hors ligne +- F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache +- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera ## Modifié - `Assistant › [1]` n'envoie plus chaque question à une seule API distante sur un modèle figé : elle interroge le serveur configuré, et ne retombe sur le distant que lorsqu'aucun serveur local ne répond +- Le contrôle d'hygiène des commentaires lit le Go, et non les seuls `#` : `//` hors d'une chaîne, la chaîne brute entre accents graves, et les blocs `/* … */` +- Les index `by-hash` de Debian et d'Ubuntu sont servis du disque, leur nom étant l'empreinte de leur contenu ; `…/releases/latest/download/…` n'est plus figé sur la première version vue +- Un amont qui refuse ou ignore les connexions est retenu 20 s : une requête qui a une réponse gardée est servie aussitôt au lieu d'attendre son délai d'établissement, qui faisait l'essentiel du temps d'une installation hors ligne ; une requête sans rien en réserve tente toujours l'amont. Un miroir git saute son rafraîchissement tant que sa forge est injoignable +- Le pré-remplissage des miroirs tourne sous le compte du service du cache, jamais en root +- Le menu des tests longs demande avant de créer de vraies machines, et redemande, avec des mots à lui, avant que `--detruire` efface des machines avec leurs disques. La commande est montrée d'abord, c'est elle qui rend la question répondable ; un plan à blanc ou un rapport de performance ne crée rien et ne demande rien +- Chaque entrée du menu Proxmox VE porte une icône, la même image voulant dire la même action que dans les autres menus de l'outil ## Corrigé @@ -50,6 +73,33 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Restaurer une sauvegarde ne lève plus `AttributeError` avant de lancer la commande : `_monitoring_restore` lisait `self._execute` là où `TODO` pose `self.execute`, et les deux entrées qui y mènent — la sauvegarde locale et la distante — étaient cassées. Un contrôle refuse le suivant : tout `self.X` que `TODO` LIT doit être posé par TODO ou par une de ses bases. Chercher le nom dans tout `script/todo/` ne voyait pas la faute, un autre objet du paquet posant bien `_execute` ; il faut le chercher dans les classes dont TODO HÉRITE - Les scripts Selenium tournent sur un Python bâti sans `tkinter` — tout serveur dépourvu de paquet d'interface graphique. Ce module ne sert qu'au sélecteur de fichier du coffre, il est donc désormais optionnel : sans `tkinter` et sans chemin KDBX configuré, l'ouverture du coffre journalise une erreur et rend la main, au lieu de casser l'import de tous les scripts de pilotage de navigateur - Le mode sombre repart en fenêtre privée sur un Firefox récent. La permission « Exécuter dans les fenêtres privées » est accordée à l'installation de l'extension, par le champ `allowPrivateBrowsing`, au lieu d'être cochée dans l'interface `about:addons` : Firefox refuse la navigation vers `about:addons` depuis le contexte contenu, et le contexte chrome exige `-remote-allow-system-access`, que geckodriver refuse via les capabilities, si bien que les deux voies vers cette case sont fermées. Un geckodriver qui ignore le champ installe sans la permission au lieu d'interrompre la session +- L'étiquette « - Default » reparaît aux menus des versions et des environnements : les deux lectures demandaient une clé à majuscule que le fichier des versions n'écrit pas, et une clé absente ne rend rien sans rien dire +- Le message « rien en réserve » du cache est inerte pour un interpréteur de commandes, chaque ligne étant un commentaire. Livré à un installateur bâti sur `curl … | bash`, il devenait une cascade de « command not found » qui masquait la cause. Un tel téléchargement demande en outre à curl d'échouer sur une erreur HTTP plutôt que d'exécuter la page d'erreur +- L'attente qu'une VM soit prête couvre aussi la pose de l'agent invité. Celle-ci part en unité DÉTACHÉE pour que cloud-init rende la main en quelques secondes, et elle fait un `apt-get update` : « cloud-init status --wait » disait « done » alors que le verrou des paquets était encore tenu, l'étape suivante épuisait ses reprises, puis installait sur un index jamais rafraîchi — « Impossible de trouver le paquet », un message qui accuse le dépôt et non le verrou. Un update qui n'aboutit pas le dit désormais sur le champ +- Une installation de bureau n'attend plus des minutes sur le verrou apt : le SERVICE apt-daily est arrêté et non son seul minuteur, un minuteur désactivé n'interrompant pas l'apt-get qu'il a déjà lancé ; et la reprise repasse toutes les deux secondes au lieu de dix, « DPkg::Lock::Timeout » ne couvrant pas ce verrou-là +- Les VM Fedora démarrent de nouveau : le micrologiciel charge et démarre leur chargeur, puis se fige sans écrire un octet — pas de console, pas de bail DHCP, une machine « en cours d'exécution » qui ne fait rien. Fedora est amorcée en BIOS hérité, où la même image démarre son noyau ; `--bios` garde le dernier mot +- Une VM reçoit un nom d'hôte qu'elle accepte — un souligné, que le nom de domaine libvirt tolère, lui faisait garder le nom générique de son image — et un fuseau que sa distribution connaît, un alias hérité la laissant en UTC +- starship s'installe dans une VM : son installateur tourne en root, borné par un délai root, et n'atteint jamais le `sudo -v` que sudo-rs refuse ; son crochet de shell n'écrit plus « command not found », ni ne fait échouer un rc sourcé, quand starship est absent +- Chaque outil optionnel dit s'il a été posé, et une extension GNOME dont le téléchargement a échoué n'est plus déclarée indisponible pour ce GNOME +- mise, pyenv et les extensions GNOME sont téléchargés, puis exécutés : sans pipefail, `curl | sh` ne pouvait ni signaler un téléchargement raté ni atteindre son repli +- L'unité de l'agent invité ne finit plus en échec après une pose réussie +- Le cache répond 508 à une requête qui le vise lui-même, au lieu de s'appeler jusqu'à épuiser ses descripteurs +- Le diagnostic du cache ne donne plus un service arrêté pour actif +- L'attente d'`apt-get update` est bornée par une échéance et non par un nombre d'essais. Un essai échoue en moins d'une seconde sur un verrou tenu, mais dure des minutes quand le cache rend 504 sur chaque index qu'il ne détient pas : soixante essais valaient alors des heures de silence là où cinq minutes étaient promises, et l'installation échouait ensuite sur des dépendances introuvables +- Un invité Proxmox est fixé sur le même miroir apt que le reste du parc, écrit par ssh avant tout téléchargement. Le magasin range ses index par HÔTE : une VM restée sur les dépôts par défaut de son image ne retrouvait rien de ce dont le cache avait été rempli — hors ligne, chacun de ces index manquait +- L'installateur du cache accepte un pont et un sous-réseau donnés à la main. Il sondait libvirt d'abord et mourait sur « réseau default introuvable » : le contournement annoncé dans son propre en-tête — `EL_BRIDGE` et `EL_SUBNET` — était donc hors d'atteinte. Une machine dont le réseau libvirt n'est pas démarré, ou qui porte son pont autrement, peut désormais poser le cache en le nommant ; et quand la sonde tourne et échoue, elle nomme les deux issues +- **Déploiement › Cache QEMU › Installer ou réinstaller** n'annonce plus « installé et démarré » quand l'installateur a échoué. Il n'attrapait que les exceptions : un code de sortie non nul — réseau libvirt absent, compilation qui cède — imprimait la ligne de réussite juste sous l'erreur elle-même, avec le chemin d'une autorité qui n'existe pas +- Un invité Proxmox reçoit de nouveau son guide, son fuseau, son miroir apt et l'autorité du cache. Ces quatre gestes passent par ssh et partaient dès qu'une adresse était connue, pendant que cloud-init posait encore les comptes et les clés : ils échouaient ensemble, et la VM naissait en UTC, sans guide, sans autorité et sur les dépôts de son image. Le déploiement attend désormais que la machine réponde en ssh — cinq minutes au plus — et le dit quand elle ne répond jamais, au lieu d'échouer quatre fois de suite +- Le cache ne sert plus un index de dépôt plus récent que la signature qui l'annonce. Hors ligne, chaque objet sortait avec sa propre date : un index rafraîchi samedi sous un `InRelease` de vendredi faisait échouer apt sur « File has unexpected size » ou « Hash Sum mismatch », et l'installation s'arrêtait sur des dépendances introuvables — un message qui accuse le dépôt, jamais le cache. La comparaison porte sur le `Last-Modified` de l'amont, jamais sur la date de stockage, renouvelée à chaque service +- Le refus d'emporter le cache sur une autre machine nomme le geste qui le lève, et ce geste n'est pas l'entrée 1 — elle pose le cache ICI, si bien que la suivre faisait réinstaller l'hôte qui en avait déjà un pendant que l'arrivée restait sans rien. Trois situations étaient rendues par un seul « pas de cache » : un lien ssh qui n'a jamais exécuté la sonde, une arrivée sans cache, une arrivée dont le cache n'a pas son compte de service. Chacune a désormais son message, et celle du cache absent énumère les gestes à faire SUR l'arrivée, avec les deux issues d'un installateur qui lit le réseau libvirt « default » — lever libvirt, ou nommer le pont, un libvirt arrêté le faisant mourir sur un réseau qui existe. Les gestes nomment aussi la branche à donner à l'arrivée, lue sur cet hôte : l'installateur est un fichier du dépôt, si bien qu'une machine restée sur une autre branche répond « fichier introuvable », ce qui ne ressemble en rien à un cache absent. Un quatrième cas est éprouvé avant qu'un seul octet ne parte : un sudo qui réclame un mot de passe à l'arrivée, auquel aucun terminal ne peut répondre puisque le magasin occupe lui-même l'entrée standard de ssh — le message donne le ticket à y obtenir d'abord. Le privilège n'est demandé qu'UNE fois à l'arrivée, « tar » et « chown » sous une seule invocation. Quand le sudo de là-bas réclame un mot de passe, l'entrée offre deux issues au lieu d'échouer : la ligne sudoers à poser une fois, ou un mode en deux temps — le magasin part dans le compte de l'arrivée, qui n'exige aucun privilège, et une commande affichée l'extrait depuis un terminal de là-bas, où un mot de passe se tape. Ce mode réclame deux fois le magasin à l'arrivée, vérifié avant qu'un octet ne parte, un cache ne contenant que des paquets et des archives git déjà comprimés. Le guide porte la même section +- Le formulaire de déploiement Proxmox VE éprouve le cache avant de couper le réseau, comme celui de libvirt le faisait déjà : ce que le magasin n'a pas, aucune VM coupée ne le lira, et l'échec tombait une heure plus tard, à la pose du bureau — un message qui accuse le dépôt, jamais le cache. F5 à nouveau vaut passage outre. Les deux formulaires partagent désormais un seul verdict au lieu de deux copies, qui auraient divergé au premier ajustement +- Le cache garde ses index de dépôt quand une liste de miroirs tourne. Un index publié sous l'empreinte de son contenu — « by-hash/SHA256/… » — était rangé sous une clé portant l'hôte : les mêmes octets servis par un second miroir étaient repris à l'amont, et hors ligne ils manquaient tout simplement — l'installation échouait sur des fichiers que le magasin détenait, avec un message qui accuse le dépôt. Un tel objet est désormais rangé sous son seul chemin, son nom ÉTANT la somme de son contenu +- Le diagnostic du cache n'annonce plus « aucune règle de détournement n'est posée » quand il n'a simplement pas pu les lire. Sur un hôte dont le sudo réclame un mot de passe, « sudo -n nft » ne rend rien, et ce silence était lu comme une absence de règle — ce qui envoyait réinstaller un cache qui détournait correctement. La lecture porte désormais un troisième état, « impossible de savoir », marqué d'un point et non d'une croix, comme le faisait déjà la lecture de la coupure d'amont +- Une coupure levée rend aussitôt leur chance aux amonts. Le service retient un court moment ceux dont la connexion vient d'échouer, pour qu'une installation hors ligne ne paie pas le délai d'établissement des centaines de fois ; rien ne lui disait que la coupure était finie, et les premières requêtes d'après la levée se rabattaient sur le magasin alors que le réseau était déjà revenu. La levée touche désormais un témoin dans le magasin, que cette mémoire consulte — il n'existe aucun canal vers le service en marche +- Avant de couper, le formulaire avertit aussi de ce qu'aucun index ne peut révéler : un paquet que le déploiement pose HORS du fil observé — l'agent invité, posé par une unité détachée dont l'échec ne remonte nulle part — et les dépôts git déclarés par les manifestes qui n'ont pas encore de miroir. Un index de suite en réserve suffisait à faire passer le cache pour complet alors qu'aucun octet de ce paquet ne l'avait jamais traversé ; et une négociation git ne se garde jamais, si bien qu'un dépôt sans miroir ne peut tout simplement pas être cloné une fois le réseau coupé +- L'avertissement sur les miroirs ne compte que les dépôts de la version d'Odoo déployée. Il additionnait tous les manifestes du dépôt — le déprécié compris — et annonçait 170 miroirs manquants là où un vrai déploiement Odoo 18 en rencontre quatre : une alarme qui sonne pour rien est une alarme qu'on cesse de lire. Le remplissage des miroirs, lui, prend toujours de l'avance pour toutes les versions, ce qui est son rôle +- Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes +- **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais ## Retiré @@ -58,6 +108,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Sécurité - Une clé d'API et un jeton Bearer sont caviardés eux aussi avant qu'une commande soit affichée, journalisée ou réimprimée : `OPENAI_API_KEY=` partait en clair, et un jeton d'en-tête échappait par construction, ne portant ni nom d'option ni nom de variable +- En suivant une redirection, le cache ne transmet plus les identifiants du client (Authorization, Cookie, Proxy-Authorization) à un autre hôte ## [1.8.0] - 2026-09-04 diff --git a/CHANGELOG.md b/CHANGELOG.md index b83a662..0ef5258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,10 +33,33 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - An anonymised number can keep its digit count, on the file anonymiser and on `anonymize.py` alike (`--keep-digits`): 8839 then draws in 1000..9999, so the copy keeps columns of the same width, which an export read by eye or imported into a bounded field asks for. Each value keeps ITS own width and not its column's — 7 stays at one digit where 12 keeps two — and below the unit the rule does not apply, 0.15 having no digit before the point. The option trades one guarantee for another: the measured extent no longer bounds anything, so a rate or a year can leave its range, and the preview SAYS so rather than letting it be found at reimport. Uniqueness is what yields when a band fills up, a duplicate of the same width beating a value of a different one, and the band never exceeds what the type RECEIVING the draw holds, which is not always the column's: an Odoo `integer` over a column PostgreSQL did not create as an integer — what an upgraded base carries — is poured into `numeric`, which has no ceiling, where `integer` stops at 2 147 483 647 and a draw past it aborts the whole write, that write being one transaction - The anonymiser asks four questions instead of eleven, seven of the eleven having had an obvious default. Answering them one by one to arrive at the same place makes prompts be passed by reflex, and a prompt passed by reflex consents to nothing; they now live behind « Advanced options? ». The short path DECLARES what it takes, in sentences rather than in yes/no, since those are decisions taken and not questions whose answer was lost. Macros and charts are asked on both paths: they are not visible in the cells - `markdown-it-py` is declared as a functional dependency: it renders the language model's markdown to HTML, before sanitising, on the assistant page. It reached the environment transitively through `bandit` → `rich`, a lint tool, so removing a lint dependency removed a portal page's rendering engine +- A download cache shared by the QEMU VMs of a host, installed from **Deployment › QEMU cache**. Two VMs of the same distribution stop pulling the same hundreds of megabytes twice: a package file is served from disk, while an index is always taken from upstream, so a withdrawn package can never turn into a « failed retrieving file … 404 ». An index is stored all the same and only comes back out when upstream is unreachable, which is what makes an offline deployment possible. The cache never shrinks by itself: `--status` says what it occupies +- Interception is transparent and covers the whole host bridge, so a VM cannot opt out from the inside. Every VM trusts the cache's authority as long as the service runs. To take ONE machine out, tick « keep this VM out of the download cache » when deploying: its MAC address is fixed before creation and an exception is posted on the host. To take them all out, stop the service — its rules leave with it. A Proxmox host that is itself a VM here is no exception: the machines it carries come out behind its address, so the deployment poses the authority in each of them, by ssh, before installing anything +- The measurement is not limited to Arch: any catalogue system whose package family is known, and either a batch of packages or the real install of ERPLibre and Odoo 18. Measured on Ubuntu 24.04 with that real install, the second VM pulled **zero byte** of package from the network and finished 19 % faster. Verified on the seven systems of the catalogue +- Git is MIRRORED rather than cached: its protocol is a negotiation, the server computing its answer from what the client already holds, so no answer is reusable. A bare mirror per upstream repository is kept on the host and served locally, and a mirror already held serves with no network at all — measured, a repository cloned inside a VM while the cache's upstream was cut. Measured on a full ERPLibre install: the second VM pulled **zero** git request from upstream, where git had been four fifths of the traffic. **Deployment › QEMU cache › Git mirrors** fills them ahead from the manifests, so the first VM does not pay every clone +- An offline deployment also covers the base suite of a cloud image. Its apt REVALIDATES the index it ships instead of downloading it, upstream answers 304, and the cache had no body to keep — so that one suite, and only it, was missing offline while its `-updates` and `-security` neighbours came off the disk. A conditional request for something the cache does not hold now goes upstream without its condition, once, and a client that holds its own copy gets 304 rather than 504 when upstream is mute. +- What an offline deployment covers, exactly: packages, repository indexes and git. NOT what the cache is forbidden to decrypt — a client carrying its own trust store, npm and poetry among them, is tunnelled opaquely, and a tunnel carries nothing once the network is gone. A full ERPLibre install therefore still needs the network, though everything the cache holds is served from disk +- A mirror is COMPLETE where `repo sync` clones at depth one, so it costs tens of gigabytes. Below ten gigabytes free, no new mirror is created and the request goes back upstream. The diagnosis says what the objects and the mirrors each occupy +- **Deployment › QEMU cache › Age and cleanup** groups the cache by age of last use (day, week or month; objects and git repositories apart) and gives back what has not served for a chosen delay, or everything. A served object has its date renewed, so « old » means « no longer used ». Both cleanups say what would go before erasing anything, and entry 5 lists the mirrors heaviest first to remove one +- `long_test/qemu_cache.py` measures whether the cache really serves the second VM, and `--hors-ligne` cuts the upstream of the cache service alone to prove a third VM still builds from the stored index +- Before cutting, the form says whether the cache holds the base suite of each system asked for: F5 warns « cache holds nothing for ubuntu 26.04 » and a second F5 goes ahead anyway. The verdict is given only where a release is named unambiguously in the URL — the apt families — rather than reassuring wrongly elsewhere. The verdict is given component by component — `main`, `universe`, `restricted`, `multiverse`, on the base suite and on its `-updates` and `-security` companions — because a single stored URL used to silence it while a whole component was missing, and apt only said so twenty minutes later, by « Unable to locate package » +- **Deployment › QEMU cache › Copy it to another machine** carries the store to another ERPLibre host in a single stream — `tar`, compressed, over ssh, no intermediate file for tens of gigabytes — and hands the files to the service account on arrival, the same account rarely bearing the same number on two machines. Only the STORE travels: an object is keyed by its URL and never by the machine that fetched it, and a git mirror is a repository. The settings stay behind — bridge, subnet and authority belong to the host, and entry 1 poses them there +- The deployment form carries a **Network** section with « No internet connection »: for the whole deployment, install included, the cache service loses its way out AND the VMs lose theirs — ping, other ports, UDP, IPv6 — so a step that takes any path but the cache fails instead of quietly succeeding online. Names no longer resolve through the internet either: the host answers every name itself with an address the cache intercepts, so only what the cache holds can be reached; dnsmasq must be installed on the host. A VM behind the cache pulls from one fixed apt mirror: cloud-init's mirror search discards every mirror behind a resolver that answers every name, and would fall back on another mirror than the one the previous VMs filled the cache from. The cut refuses rather than drops, so an address the cache does not hold answers 504 at once instead of after a connection timeout; a kernel that cannot load the reject module gets the dropping set instead. Offered only where the cache runs, and the deployment refuses rather than run with the upstream still up, a VM built that way succeeding for the wrong reason. **Proxmox VE** carries the same box, offered where its host is itself a VM of this bridge: its guests come out behind its address, so the local cut covers them and nothing is posted on the remote host. A Proxmox host that does not live here is never offered it — nothing here can cut its way out +- A Proxmox VE deployment can ask for **3D acceleration**. The VM is created with an accelerated screen (`--vga virtio-gl`) rather than the serial console as its display, and the account joins the GPU groups INSIDE the guest: the render node belongs to `root:render`, so without them every GL application falls back to software rendering while the VIRGL negotiation reports success, and nothing says so. The box is offered only where the remote host has a render node AND the three libraries Proxmox loads for that screen — VIRGL, GL and EGL. It names the missing ones and refuses to start the machine, once its disk is written and its configuration posted, so a host carrying GL without EGL would fail at the very last step. Where a piece is missing the form says which, and the package to install on the host: a box that vanishes without a word reads as a regression. A button installs them without leaving the screen — the form hands the terminal back so sudo can ask for a password and apt can be watched — then reads the host again rather than taking apt's word for it, and the box appears in place of the message. The serial port stays posted, so `qm terminal` keeps working +- Offline, the cache replays what an online pass saw, not only 200 bodies: redirects, definitive refusals (404/410) and HEAD answers of volatile URLs are kept without a body, under keys of their own, and served ONLY when upstream is mute. A TUF client probing the next version of its root gets the 404 it expects instead of a 504, so mise installs Python offline with its Sigstore verification intact; GNOME extensions, Claude Code's installer and rtk's version lookup follow their redirects offline +- The offline cut ends when the last installation ends, not when the monitor closes: a root unit, handed the lift at launch, waits for every installation's exit marker and survives the monitor closed early, todo.py killed or the terminal gone — 12 h at most. The monitor is therefore required while offline. A second offline deployment is refused while one runs, and an online deployment started meanwhile is told it would run offline +- F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache +- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give ## Changed - `Assistant › [1]` no longer sends every question to a single remote API on a fixed model: it asks whichever server is configured, and falls back to the remote one only when no local server answers +- The comment hygiene check reads Go comments, not only `#` ones: `//` outside a string, the raw string between backticks, and `/* … */` blocks +- Debian and Ubuntu `by-hash` index files are served from disk, their name being the digest of their content; `…/releases/latest/download/…` is no longer pinned to the first version seen +- An upstream that refuses or drops connections is remembered for 20 s: a request with a stored answer is served at once instead of waiting its connect timeout, which made up most of an offline install's time; a request with nothing stored still tries upstream. A git mirror skips its refresh while its forge is unreachable +- Mirror prefetch runs under the cache's service account, never as root +- The long-test menu asks before creating real machines, and asks again, in words of its own, before `--detruire` removes machines with their disks. The command is shown first, which is what makes the question answerable; a dry run or a performance report creates nothing and asks nothing +- Every entry of the Proxmox VE menu carries an icon, the same picture meaning the same action as in the other menus of the tool ## Fixed @@ -50,6 +73,33 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Restoring a backup no longer raises `AttributeError` before running the command: `_monitoring_restore` read `self._execute` where `TODO` sets `self.execute`, and both entries leading there — the local backup and the remote one — were broken. A check refuses the next one: every `self.X` that `TODO` READS must be set by TODO or by one of its bases. Searching the name across `script/todo/` did not see the fault, another object of the package setting `_execute`; it has to be searched in the classes TODO INHERITS from - The Selenium scripts run on a Python built without `tkinter` — any server with no graphical toolkit package. That module is only needed by the vault's file picker, so it is now optional: with no `tkinter` and no configured KDBX path, opening the vault logs an error and returns, instead of breaking the import of every browser-automation script - Dark mode works again in a private window on a current Firefox. The « Run in Private Windows » permission is granted when the addon is installed, through the `allowPrivateBrowsing` field, rather than clicked through the `about:addons` interface: Firefox refuses navigation to `about:addons` from the content context, and the chrome context demands `-remote-allow-system-access`, which geckodriver rejects through capabilities, so both routes to that checkbox are closed. A geckodriver that ignores the field installs without the permission instead of killing the session +- The « - Default » label appears again at the version and environment menus: both reads asked for a capitalised key the version file never writes, and a missing key returns nothing without a word +- The cache's « nothing in store » message is inert as a shell script, every line being a comment. Fed to an installer built on `curl … | bash`, it used to become a cascade of « command not found » that hid the real cause. Such a download now also asks curl to fail on an HTTP error rather than execute the error page +- The wait for a VM to be ready now covers the guest-agent install too. That one is launched as a DETACHED unit so cloud-init returns in seconds, and it runs an `apt-get update`: `cloud-init status --wait` said « done » while the package lock was still held, the next step burnt through its retries, and the install then ran on an index never refreshed — « Unable to locate package », a message that blames the repository rather than the lock. An update that never succeeds now says so on the spot +- A desktop install no longer waits minutes on the apt lock: the apt-daily SERVICE is stopped and not only its timer, a timer being disabled without interrupting the apt-get it already started; and the retry comes back every two seconds rather than every ten, `DPkg::Lock::Timeout` not covering the list lock at all +- Fedora VMs boot again: the firmware loads and starts their loader, then freezes without writing a byte — no console, no DHCP lease, a machine "running" that does nothing. Fedora is booted in legacy BIOS, where the same image starts its kernel; `--bios` still wins when asked +- A VM receives a hostname it can accept — an underscore, which a libvirt domain name tolerates, made it keep its image's generic name — and a timezone its own distribution knows, a legacy alias having left it in UTC +- starship installs in a VM: its installer runs as root, bounded by a root timeout, so it never reaches the `sudo -v` that sudo-rs refuses; its shell hook no longer prints « command not found », nor fails a sourced rc, when starship is absent +- Each optional tool says whether it was installed, and a GNOME extension whose download failed is no longer reported as unavailable for this GNOME +- mise, pyenv and GNOME extensions are downloaded, then run: without pipefail, `curl | sh` could neither report a failed download nor reach its fallback +- The guest-agent unit no longer ends in failure after a successful install +- The cache answers 508 to a request that targets the cache itself, instead of calling itself until it runs out of descriptors +- The cache diagnosis no longer reports a stopped service as running +- The wait for `apt-get update` is bounded by a deadline rather than by a number of attempts. An attempt fails in under a second on a held lock, but takes minutes when the cache answers 504 on every index it does not hold: sixty attempts were then worth hours of silence where five minutes were promised, and the install went on to fail on unmet dependencies +- A Proxmox guest is pinned to the same apt mirror as the rest of the fleet, written over ssh before anything downloads. The store keys its indexes by HOST, so a VM left on the default repositories of its image found none of what the cache had been filled with — offline, every one of those indexes was missing +- The cache installer accepts a bridge and a subnet given by hand. It probed libvirt first and died on « network default not found », so the workaround its own header advertised — `EL_BRIDGE` and `EL_SUBNET` — could never be reached. A machine whose libvirt network is not started, or which carries its bridge otherwise, can now lay the cache down by naming it; and when the probe does run and fails, it names both ways out +- **Deployment › QEMU cache › Install or reinstall** no longer announces « installed and started » when the installer failed. It caught exceptions only: a non-zero exit — a missing libvirt network, a build that gave way — printed the success line right under the error itself, along with the path of an authority that does not exist +- A Proxmox guest receives its guide, its timezone, its apt mirror and the cache authority again. All four go by ssh and used to start as soon as an address was known, while cloud-init was still creating accounts and keys: they failed together, and the VM was born in UTC, guideless, without the authority and on its image's repositories. The deployment now waits for the machine to answer ssh — five minutes at most — and says so when it never does, rather than failing four times in a row +- The cache no longer serves a repository index newer than the signature that announces it. Offline, each object came out with its own date: an index refreshed on Saturday under a Friday `InRelease` made apt fail on « File has unexpected size » or « Hash Sum mismatch », and the install stopped on unmet dependencies — a message that blames the repository, never the cache. The comparison is made on the upstream `Last-Modified`, never on the storage date, which is renewed on every hit +- The refusal to copy the cache to another machine names the gesture that lifts it, and that gesture is not entry 1 — entry 1 installs the cache HERE, so following it reinstalled the host that already had one while the target stayed empty. Three situations were reported as a single « no cache installed »: an ssh link that never ran the probe, a target carrying no cache, and a target whose cache lacks its service account. Each has its own message now, and the missing-cache one lists the steps to run ON the target, the two ways past an installer that reads the « default » libvirt network included — start libvirt, or name the bridge, a stopped libvirt making it die on a network that exists. The steps also name the branch to put the target on, read from this host: the installer is a file of the repository, so a machine left on another branch answers « no such file », which looks nothing like a missing cache. A fourth case is checked before a single byte leaves: sudo asking for a password on the target, which no terminal can answer since the store itself occupies ssh's standard input — the message gives the ticket to obtain there first. The arrival is asked for the privilege ONCE, `tar` and `chown` under a single invocation. Where sudo there wants a password, the entry offers two ways out instead of failing: the one-off sudoers line that allows it, or a two-step mode — the store is sent into the target's own account, which needs no privilege at all, and a printed command extracts it from a terminal there, where a password can be typed. That mode needs twice the store on the target, checked before a byte leaves, a cache holding only already-compressed packages and git archives. The guide carries the same section +- The Proxmox VE deployment form checks the cache before cutting the network, as the libvirt one already did: what the store lacks, no cut VM will read, and the failure used to land an hour later, at the desktop step — a message that blames the repository, never the cache. F5 again means going ahead anyway. Both forms now share a single verdict instead of two copies of it, which would have drifted apart at the first adjustment +- The cache keeps its repository indexes when a mirror list rotates. An index published under the hash of its content — `by-hash/SHA256/…` — was stored under a key carrying the host, so the same bytes served by a second mirror were fetched again; offline they were simply missing, and the install failed on files the store already held, with a message that blames the repository. Such an object is now keyed by its path alone, its name BEING the checksum of its content +- The cache diagnosis no longer reports « no redirection rule is posted » when it simply could not read them. On a host whose sudo asks for a password, `sudo -n nft` returns nothing, and that silence was read as an absence of rules — sending the operator to reinstall a cache that was redirecting correctly. The reading now carries a third state, « cannot tell », marked with a dot rather than a cross, exactly as the upstream-cut reading already did +- A lifted cut gives the upstreams their chance back at once. The service remembers, for a short while, which upstreams just failed to connect, so that an offline install does not pay the connection delay hundreds of times; nothing told it the cut was over, and the first requests after the lift fell back on the store while the network was already back. The lift now touches a witness file inside the store, which that memory consults — there is no channel at all to the running service +- Before cutting, the form also warns about what no index can reveal: a package the deployment lays down OUTSIDE the watched thread — the guest agent, installed by a detached unit whose failure surfaces nowhere — and the git repositories declared by the manifests that have no mirror yet. A held suite index was enough to call the cache complete while not one byte of that package had ever crossed it; and a git negotiation is never stored, so a repository without a mirror simply cannot be cloned once the network is gone +- The mirror warning counts only the repositories of the Odoo version being deployed. It used to add up every manifest in the repository — the deprecated one included — and announced 170 missing mirrors where a real Odoo 18 deployment meets four: an alarm that fires for nothing is one that stops being read. Filling the mirrors still takes every version ahead, which is its purpose +- A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes +- **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones ## Removed @@ -58,6 +108,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Security - An API key and a bearer token are redacted too before a command is displayed, logged or reprinted: `OPENAI_API_KEY=` went out in the clear, and a header token escaped by construction, carrying neither an option name nor a variable name +- Following a redirect, the cache no longer forwards the client's credentials (Authorization, Cookie, Proxy-Authorization) to another host ## [1.8.0] - 2026-09-04 diff --git a/long_test/README.base.md b/long_test/README.base.md index 295b45e..c1370b1 100644 --- a/long_test/README.base.md +++ b/long_test/README.base.md @@ -161,6 +161,48 @@ level must prove, not assume: unloaded module, not a permissions problem. A level that fails these stops the descent instead of prolonging it into the void. +## qemu_cache.py — does the download cache really serve the second VM? + +Two sibling VMs, the same distribution, the same packages. The first fills the +cache, the second must be served by it. + +**Zero upstream bytes is the headline, not the criterion.** Arch is a rolling +release: between the two deployments a mirror can publish a newer version, +which the second VM legitimately fetches — the cache never serves an index +while upstream answers, so the VM sees it. A criterion built on volume alone +would call the cache broken while it works. + +The criterion is therefore: **no URL requested by BOTH VMs is fetched upstream +a second time.** What the second VM discovers on its own is counted, shown, +and does not fail. + +`--hors-ligne` adds the counter-proof, which is what makes the test worth its +hours: it cuts the upstream of the cache SERVICE alone — by its system +account, not by a blanket rule that would take down the ssh session running +the test — and deploys a third VM, which must build from the stored index. + +``` +./long_test/qemu_cache.py # two VMs +./long_test/qemu_cache.py --dry-run # the plan, nothing created +./long_test/qemu_cache.py --hors-ligne # + the third VM, upstream cut +./long_test/qemu_cache.py --detruire # undo it +``` + +It needs the cache installed and running — `TODO › Deployment › QEMU cache` — +and it refuses to create anything before saying which prerequisite is missing. +Among those prerequisites: the rules must target the subnet libvirt actually +serves, which is not always 192.168.122.0/24. + +What governs the duration is the FIRST VM's download, everything else being +boot and install: minutes on a machine with nested KVM and a nearby mirror, +much longer on a slow link. The second VM does not download at all — that is +what is being measured. + +One limit the counter-proof exposes: with upstream cut, the repository +database SIGNATURES are missing from the cache, the mirror answering 404 for +them, so the cache returns its named 504. pacman treats them as optional and +carries on. A distribution that required them would stop there. + ## Starting from a host you already have Both scripts take `--hote`. Creating a head VM to host a hypervisor you @@ -355,6 +397,48 @@ Chaque étage doit donc prouver, et non supposer : module non chargé, pas un problème de permission. Un étage qui échoue à cela arrête la descente au lieu de la prolonger dans le vide. +## qemu_cache.py — le cache de téléchargement sert-il vraiment la seconde VM ? + +Deux machines sœurs, la même distribution, les mêmes paquets. La première +remplit le cache, la seconde doit être servie par lui. + +**« Zéro octet d'amont » est la manchette, pas le critère.** Arch est une +publication continue : entre les deux déploiements, un miroir peut publier une +version neuve, que la seconde VM tire légitimement — le cache ne sert jamais +un index tant que l'amont répond, donc elle la voit. Un critère fondé sur le +seul volume déclarerait le cache en panne alors qu'il fonctionne. + +Le critère est donc : **aucune URL demandée par les DEUX VM n'est retirée de +l'amont une seconde fois.** Ce que la seconde découvre seule est compté, +montré, et n'échoue pas. + +« --hors-ligne » ajoute la contre-épreuve, qui fait la valeur de ces heures : +elle coupe l'amont du SEUL service du cache — par son compte système, non par +une règle générale qui emporterait la session ssh depuis laquelle le test se +lance — et déploie une troisième VM, qui doit se bâtir sur l'index stocké. + +``` +./long_test/qemu_cache.py # deux VM +./long_test/qemu_cache.py --dry-run # le plan, rien de créé +./long_test/qemu_cache.py --hors-ligne # + la troisième VM, amont coupé +./long_test/qemu_cache.py --detruire # défaire +``` + +Il exige le cache installé et actif — « TODO › Déploiement › Cache QEMU » — et +refuse de rien créer avant d'avoir dit lequel des préalables manque. Parmi +eux : les règles doivent viser le sous-réseau que libvirt sert vraiment, qui +n'est pas toujours 192.168.122.0/24. + +Ce qui gouverne la durée est le téléchargement de la PREMIÈRE VM, le reste +n'étant que démarrage et installation : quelques minutes sur une machine à +KVM imbriqué et miroir proche, bien davantage sur une liaison lente. La +seconde VM ne télécharge rien — c'est précisément ce qu'on mesure. + +Une limite que la contre-épreuve met au jour : amont coupé, les SIGNATURES +des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache +rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit. +Une distribution qui les exigerait s'arrêterait là. + ## Partir d'un hôte qu'on possède déjà Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un diff --git a/long_test/README.fr.md b/long_test/README.fr.md index 939f62d..280c580 100644 --- a/long_test/README.fr.md +++ b/long_test/README.fr.md @@ -166,6 +166,48 @@ Chaque étage doit donc prouver, et non supposer : module non chargé, pas un problème de permission. Un étage qui échoue à cela arrête la descente au lieu de la prolonger dans le vide. +## qemu_cache.py — le cache de téléchargement sert-il vraiment la seconde VM ? + +Deux machines sœurs, la même distribution, les mêmes paquets. La première +remplit le cache, la seconde doit être servie par lui. + +**« Zéro octet d'amont » est la manchette, pas le critère.** Arch est une +publication continue : entre les deux déploiements, un miroir peut publier une +version neuve, que la seconde VM tire légitimement — le cache ne sert jamais +un index tant que l'amont répond, donc elle la voit. Un critère fondé sur le +seul volume déclarerait le cache en panne alors qu'il fonctionne. + +Le critère est donc : **aucune URL demandée par les DEUX VM n'est retirée de +l'amont une seconde fois.** Ce que la seconde découvre seule est compté, +montré, et n'échoue pas. + +« --hors-ligne » ajoute la contre-épreuve, qui fait la valeur de ces heures : +elle coupe l'amont du SEUL service du cache — par son compte système, non par +une règle générale qui emporterait la session ssh depuis laquelle le test se +lance — et déploie une troisième VM, qui doit se bâtir sur l'index stocké. + +``` +./long_test/qemu_cache.py # deux VM +./long_test/qemu_cache.py --dry-run # le plan, rien de créé +./long_test/qemu_cache.py --hors-ligne # + la troisième VM, amont coupé +./long_test/qemu_cache.py --detruire # défaire +``` + +Il exige le cache installé et actif — « TODO › Déploiement › Cache QEMU » — et +refuse de rien créer avant d'avoir dit lequel des préalables manque. Parmi +eux : les règles doivent viser le sous-réseau que libvirt sert vraiment, qui +n'est pas toujours 192.168.122.0/24. + +Ce qui gouverne la durée est le téléchargement de la PREMIÈRE VM, le reste +n'étant que démarrage et installation : quelques minutes sur une machine à +KVM imbriqué et miroir proche, bien davantage sur une liaison lente. La +seconde VM ne télécharge rien — c'est précisément ce qu'on mesure. + +Une limite que la contre-épreuve met au jour : amont coupé, les SIGNATURES +des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache +rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit. +Une distribution qui les exigerait s'arrêterait là. + ## Partir d'un hôte qu'on possède déjà Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un diff --git a/long_test/README.md b/long_test/README.md index c5bcb10..5828754 100644 --- a/long_test/README.md +++ b/long_test/README.md @@ -156,6 +156,48 @@ level must prove, not assume: unloaded module, not a permissions problem. A level that fails these stops the descent instead of prolonging it into the void. +## qemu_cache.py — does the download cache really serve the second VM? + +Two sibling VMs, the same distribution, the same packages. The first fills the +cache, the second must be served by it. + +**Zero upstream bytes is the headline, not the criterion.** Arch is a rolling +release: between the two deployments a mirror can publish a newer version, +which the second VM legitimately fetches — the cache never serves an index +while upstream answers, so the VM sees it. A criterion built on volume alone +would call the cache broken while it works. + +The criterion is therefore: **no URL requested by BOTH VMs is fetched upstream +a second time.** What the second VM discovers on its own is counted, shown, +and does not fail. + +`--hors-ligne` adds the counter-proof, which is what makes the test worth its +hours: it cuts the upstream of the cache SERVICE alone — by its system +account, not by a blanket rule that would take down the ssh session running +the test — and deploys a third VM, which must build from the stored index. + +``` +./long_test/qemu_cache.py # two VMs +./long_test/qemu_cache.py --dry-run # the plan, nothing created +./long_test/qemu_cache.py --hors-ligne # + the third VM, upstream cut +./long_test/qemu_cache.py --detruire # undo it +``` + +It needs the cache installed and running — `TODO › Deployment › QEMU cache` — +and it refuses to create anything before saying which prerequisite is missing. +Among those prerequisites: the rules must target the subnet libvirt actually +serves, which is not always 192.168.122.0/24. + +What governs the duration is the FIRST VM's download, everything else being +boot and install: minutes on a machine with nested KVM and a nearby mirror, +much longer on a slow link. The second VM does not download at all — that is +what is being measured. + +One limit the counter-proof exposes: with upstream cut, the repository +database SIGNATURES are missing from the cache, the mirror answering 404 for +them, so the cache returns its named 504. pacman treats them as optional and +carries on. A distribution that required them would stop there. + ## Starting from a host you already have Both scripts take `--hote`. Creating a head VM to host a hypervisor you diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py new file mode 100755 index 0000000..18af5c5 --- /dev/null +++ b/long_test/qemu_cache.py @@ -0,0 +1,1262 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le cache de téléchargement sert-il vraiment la seconde VM ? + +Deux machines sœurs, la même distribution, les mêmes paquets. La première +remplit le cache, la seconde doit être servie par lui. Ce script crée de +VRAIES VM et dure des dizaines de minutes : il vit dans « long_test/ » et non +dans « test/ », que le lanceur unitaire balaie en secondes. + +## Ce qui est mesuré, et pourquoi pas ce qu'on croirait + +« Zéro octet tiré de l'amont » est la manchette, pas le critère. Arch est une +publication continue : entre les deux déploiements, un miroir peut publier une +version neuve, que la seconde VM tirera légitimement — l'index n'est jamais +servi du cache tant que l'amont répond, donc elle la VERRA. Un critère fondé +sur le seul volume déclarerait alors le cache en panne alors qu'il fonctionne. + +Le critère est donc : **aucune URL demandée par les DEUX VM n'est retirée de +l'amont une seconde fois.** Un paquet que la première a tiré et que la seconde +redemande doit venir du disque, sans exception. Ce que la seconde découvre +seule est compté, montré, et n'échoue pas. + +## La contre-épreuve, qui fait la valeur du test + +Un cache qui accélère ne prouve pas qu'il permet de travailler sans réseau. +« --hors-ligne » coupe l'accès de l'amont AU SEUL service du cache — par son +compte système, pas par une règle générale qui emporterait la session ssh de +l'opérateur — et déploie une troisième VM. Elle doit réussir sur l'index +stocké, et le journal doit dire sur quel instantané elle se bâtit. + +``` +./long_test/qemu_cache.py # deux VM +./long_test/qemu_cache.py --dry-run # le plan, rien de créé +./long_test/qemu_cache.py --hors-ligne # + la troisième VM, amont coupé +./long_test/qemu_cache.py --detruire # défaire +``` +""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys +import time + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(RACINE, "long_test")) +sys.path.insert(0, RACINE) + +from descente import ( # noqa: E402 + Descente, + cle_publique, + detruire_etage1, + dire, +) + +# Le catalogue des systèmes vient du DÉPLOIEMENT et n'est pas recopié ici : +# distributions, versions par défaut, gestionnaire de paquets et libellés y +# sont déjà tenus à jour, et une seconde table dériverait en silence — le test +# proposerait alors un système que le déploiement ne sait pas installer. +from script.qemu import cache_offline # noqa: E402 +from script.qemu.deploy_qemu import ( # noqa: E402 + DISTRO_PKG, + DISTROS, + cache_env_reload, + distro_label, +) + +OUTIL = "qemu_cache" + +# Le nom d'une VM du test porte TOUT ce qui la distingue : le mode, le système +# et la charge. Trois champs séparés par des tirets, chacun pouvant grouper ses +# mots par des soulignés — « el-cache-ubuntu_2404-erplibre_odoo_18-1 ». +# +# Il y a deux raisons, et la seconde est la vraie. Lire « virsh list » doit +# suffire à savoir d'où vient chaque machine. Et surtout, deux essais qui ne +# portent pas sur la même chose ne se disputent plus les mêmes noms : mesurer +# Ubuntu alors qu'un essai Arch survit ne bute plus sur « machine(s) d'un essai +# précédent encore là », alors que ces machines n'avaient rien à voir. +NOM_BASE = "el-cache" +NOM_BASE_SANS_CACHE = "el-no-cache" +NOM_BASE_HORS_LIGNE = "el-offline" + +# Ce que la charge met dans le nom. Une table plutôt qu'une déduction : le +# nom doit dire quelle version d'Odoo a été installée, et personne ne devine +# « erplibre_odoo_18 » à partir de « erplibre ». +NOM_DE_CHARGE = {"minimum": "minimum", "erplibre": "erplibre_odoo_18"} +DISTRO = "arch" +VERSION = "latest" + +CLI = os.path.join(RACINE, "script/qemu/deploy_qemu.py") +CA = "/var/lib/erplibre_go_qemu_cache/ca.crt" +CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache" +SERVICE = "erplibre-go-qemu-cache.service" +CONF = "/etc/erplibre_go_qemu_cache/env" + +# La CHARGE : ce que les deux VM téléchargent, et donc ce que la mesure +# regarde. Elle doit être identique d'une VM à l'autre, sans quoi la +# comparaison ne compare rien. +# +# « minimum » est un lot volumineux mais court : un compilateur, rust et cmake +# pèsent quelques centaines de mégaoctets, ce qui suffit à faire apparaître le +# gain en quelques minutes. « erplibre » installe ce que l'on déploie vraiment, +# et coûte des heures : c'est la mesure du cas réel, pas celle qu'on lance +# pour vérifier que le cache fonctionne. +# +# Les noms de paquets changent par famille, et se tromper de nom fait échouer +# l'installation loin de sa cause. Chaque entrée est (rafraîchir, installer). +PAQUETS_MINIMUM = { + "pacman": ( + "sudo pacman -Syu --noconfirm", + "sudo pacman -S --needed --noconfirm base-devel git python rust cmake", + ), + "apt": ( + # « apt-get update » rend ZÉRO même quand un index n'a pas pu être + # récupéré : il n'émet qu'un avertissement, que « -qq » cachait. Le lot + # suivant échouait alors sur « Unable to locate package + # build-essential », très loin de sa cause. « Error-Mode=any » fait de + # tout index manquant une erreur, donc un arrêt qui se lit. + "sudo apt-get update -o APT::Update::Error-Mode=any", + "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y" + " build-essential git python3 rustc cargo cmake", + ), + "dnf": ( + "sudo dnf -y makecache", + "sudo dnf -y install gcc gcc-c++ make git python3 rust cargo cmake", + ), + "zypper": ( + "sudo zypper -n refresh", + "sudo zypper -n install gcc gcc-c++ make git python3 rust cargo cmake", + ), +} + +# Ce qui doit être fini AVANT de toucher au gestionnaire de paquets. +# +# cloud-init réécrit la liste des dépôts à son premier démarrage — il y +# substitue un miroir géographique. Une mise à jour lancée pendant ce +# remplacement récupère une partie des index et s'arrête là, sans échouer : +# l'installation qui suit ne trouve alors plus les paquets de « main », et le +# message accuse le paquet plutôt que le moment. sshd répond bien avant que +# cloud-init ait fini, si bien que rien n'empêche d'arriver trop tôt. +# +# Le code de sortie est ignoré à dessein : cloud-init sort en erreur pour un +# module accessoire — un fuseau que l'invité ne connaît pas, par exemple — et +# ce n'est pas une raison de renoncer à la mesure. +# Un travail de fond tient le verrou du gestionnaire de paquets juste après le +# démarrage — sur Ubuntu, « apt-daily » se déclenche au boot et cloud-init ne +# l'attend pas. « DPkg::Lock::Timeout » ne couvre pas le verrou des LISTES : +# deux mises à jour concurrentes échouent toutes les deux en moins d'une +# seconde, que l'option soit posée ou non. +# +# La reprise vaut donc pour toutes les familles sans connaître leur mécanisme +# de verrou. Elle ne masque rien : une source réellement en panne épuise les +# tentatives et rend le même code d'erreur, message visible, cinq minutes plus +# tard. +REPRISES = 20 +PAUSE_REPRISE = 15 + + +def avec_reprises(commande): + """La commande, réessayée tant qu'un verrou la refuse.""" + return ( + f"n=0; until {commande}; do n=$((n+1));" + f" [ $n -ge {REPRISES} ] && exit 1;" + ' echo " reprise $n : le gestionnaire de paquets est occupé";' + f" sleep {PAUSE_REPRISE}; done" + ) + + +ATTENDRE_CLOUD_INIT = ( + "if command -v cloud-init >/dev/null 2>&1; then" + " sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 || true; fi" + # La session s'ouvre avant que cloud-init n'écrive les variables du cache : + # sans les relire, un npm lancé sans sudo rejette l'autorité du cache. + f"; {cache_env_reload()}" +) + +# La charge réelle : le dépôt cloné dans la VM, puis la cible qui l'installe. +# La même paire que le déploiement emploie — clone puis « make » — pour que ce +# qui est mesuré ici soit ce qui se passe vraiment. +DEPOT = "https://github.com/erplibre/erplibre" +BRANCHE = "master" +CIBLE_ERPLIBRE = "make install_os && make install_odoo_18" + +DELAI_CREATION = 1800 +DELAI_SSH = 600 +# La charge minimale se compte en minutes, ERPLibre en heures : un délai +# unique ferait échouer l'une ou laisserait l'autre pendre indéfiniment. +DELAI_CHARGE = {"minimum": 2400, "erplibre": 14400} + +# Le gabarit des VM, par charge : (vCPU, Mo, disque). +# +# Le lot minimal se contente de peu — il télécharge, il ne bâtit pas. ERPLibre +# compile son interpréteur et pose ses dépendances : deux cœurs y passeraient +# des heures de plus, et ERPLibre seul occupe plusieurs gigaoctets, ce qui ne +# laisse rien à Odoo sur un disque de vingt. +GABARIT = { + "minimum": (2, 4096, "20G"), + "erplibre": (4, 8192, "40G"), +} + + +def journal_neuf(): + chemin = os.path.expanduser( + f"~/.erplibre/longtest/{OUTIL}-{time.strftime('%Y%m%d-%H%M%S')}.log" + ) + os.makedirs(os.path.dirname(chemin), exist_ok=True) + return chemin + + +def chemin_rapport(horodatage=None): + horodatage = horodatage or time.strftime("%Y%m%d-%H%M%S") + return os.path.expanduser( + f"~/.erplibre/longtest/{OUTIL}-{horodatage}.json" + ) + + +def machines_a_defaire(limite=20): + """Tout ce que les rapports récents nomment, avec l'UUID quand il existe. + + Le rapport le plus récent ne suffit pas. Une exécution qui échoue à la + création écrit un rapport qui NOMME une machine sans la connaître : le nom + y est noté avant la création, justement pour qu'une création interrompue à + mi-chemin laisse une trace. Ce rapport-là masquerait celui d'une exécution + antérieure qui, elle, détient l'UUID — et la destruction retomberait sur + le nom, ce que ce dépôt a appris à ne plus faire. + + Les rapports sont donc parcourus du plus ANCIEN au plus récent, l'UUID + d'un rapport qui en a un l'emportant sur l'absence d'un autre. Rend + {nom: uuid ou ""} et la liste des fichiers lus. + """ + machines, lus = {}, [] + rep = os.path.expanduser("~/.erplibre/longtest") + fichiers = ( + sorted( + f + for f in os.listdir(rep) + if f.startswith(OUTIL) and f.endswith(".json") + )[-limite:] + if os.path.isdir(rep) + else [] + ) + for nom in fichiers: + chemin = os.path.join(rep, nom) + try: + with open(chemin, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + continue + if not data.get("vms"): + continue + lus.append(chemin) + uuids = data.get("uuids") or {} + for vm in data["vms"]: + # Un UUID connu ne se perd jamais au profit d'un rapport muet. + if uuids.get(vm) or vm not in machines: + machines[vm] = uuids.get(vm, machines.get(vm, "")) + + # Les rapports ne suffisent pas, et le balayage qui suit se fait DANS TOUS + # LES CAS — dossier de rapports absent compris, qui est justement l'état + # où une machine vivante n'est nommée nulle part. Ils sont bornés à + # `limite` : une machine nommée par un rapport plus ancien que cette + # fenêtre ne serait jamais défaite et bloquerait tous les essais suivants + # sans qu'aucune commande sache la retirer. Ce qui VIT tranche. + for prefixe in (NOM_BASE, NOM_BASE_SANS_CACHE, NOM_BASE_HORS_LIGNE): + for vm in machines_vivantes(prefixe): + machines.setdefault(vm, "") + return machines, lus + + +def rapports_recents(limite=12): + """Les rapports d'exécution, du plus récent au plus ancien.""" + rep = os.path.expanduser("~/.erplibre/longtest") + if not os.path.isdir(rep): + return [] + out = [] + for nom in sorted( + ( + f + for f in os.listdir(rep) + if f.startswith(OUTIL) and f.endswith(".json") + ), + reverse=True, + )[:limite]: + try: + with open(os.path.join(rep, nom), encoding="utf-8") as fh: + d = json.load(fh) + except (OSError, ValueError): + continue + if not mesure_reelle(d): + continue + d["_fichier"] = nom + out.append(d) + return out + + +def mesure_reelle(rapport): + """Ce rapport porte-t-il une mesure, ou seulement un plan ? + + La marque « dry_run » tranche pour les rapports récents. Les plus anciens + ne la portent pas : une durée nulle partout les trahit, car une + installation de paquets qui prend zéro seconde n'a pas eu lieu. + """ + if rapport.get("dry_run"): + return False + durees = rapport.get("durees") or {} + return any(d > 0 for d in durees.values()) + + +def rapport_comparatif(): + """Ce que le cache fait gagner, mesuré et non annoncé. + + Deux exécutions suffisent : une avec cache, une sans. Sans le témoin, un + temps ne dit rien — une installation rapide peut l'être parce que le + miroir est proche, pas parce qu'un cache a servi. + """ + rapports = rapports_recents() + if not rapports: + print("\n Aucune exécution mesurée. Lancer le test, puis le témoin :") + print(" ./long_test/qemu_cache.py") + print(" ./long_test/qemu_cache.py --sans-cache\n") + return 1 + + print("\n ── Rapport de performance ──\n") + print( + f" {'exécution':<18}{'cache':<7}{'VM':<40}" + f"{'durée':>7}{'amont':>12}{'du cache':>12}" + ) + print(" " + "─" * 98) + # Ni colonne « système » ni colonne « charge » : le NOM de la machine les + # porte désormais tous les deux, et les répéter à côté volerait la largeur + # dont ce nom a besoin. Les rapports d'avant ce nommage montrent un nom + # court — c'est exactement ce qu'ils savaient de leur propre essai. + for r in rapports[:6]: + etiquette = r["debut"][:16].replace("T", " ") + for nom, duree in (r.get("durees") or {}).items(): + o = (r.get("octets") or {}).get(nom, {}) + print( + f" {etiquette:<18}" + f"{'oui' if r.get('cache') else 'non':<7}" + f"{nom:<40}{duree:>6.0f}s" + f"{humain(o.get('amont', 0)):>12}" + f"{humain(o.get('cache', 0)):>12}" + ) + etiquette = "" + + # Le gain se calcule PAR CONDITION, jamais sur l'ensemble : une même + # moyenne mêlerait une installation d'ERPLibre sur Ubuntu, qui dure des + # minutes, à un lot de paquets sur Arch, qui dure vingt secondes. Le + # rapport annoncerait alors un gain qui ne mesure que le mélange. + conditions = {} + for r in rapports: + cle = (r.get("distro") or "?", r.get("charge") or "minimum") + conditions.setdefault(cle, {"avec": [], "sans": []})[ + "avec" if r.get("cache") else "sans" + ].append(r) + + compare = False + for (distro, charge), groupe in sorted(conditions.items()): + da = moyenne_seconde_vm(groupe["avec"]) + ds = moyenne_seconde_vm(groupe["sans"]) + if not (da and ds): + continue + compare = True + print(f"\n {distro} · {charge} — seconde VM") + print(f" avec cache : {da:.0f} s") + print(f" sans cache : {ds:.0f} s") + if ds > da: + print( + f" gain : {ds - da:.0f} s," + f" soit {100 * (ds - da) / ds:.0f} %" + ) + else: + # Un gain nul est un RÉSULTAT, pas une erreur : sur un lien + # rapide, le temps est dominé par l'installation et non par le + # téléchargement. + print( + " aucun gain de TEMPS : sur ce lien, le téléchargement ne" + " domine pas.\n Le gain porte alors sur les octets, colonne" + " « amont »." + ) + if not compare: + print("\n Aucune condition ne réunit une exécution avec cache et son") + print(" témoin. Lancer les deux sur le MÊME système et la même") + print(" charge, par exemple :") + print( + " ./long_test/qemu_cache.py --distro ubuntu --charge erplibre" + ) + print( + " ./long_test/qemu_cache.py --distro ubuntu --charge erplibre" + " --sans-cache" + ) + print() + return 0 + + +def moyenne_seconde_vm(rapports): + """La durée de la SECONDE VM, celle que le cache doit servir.""" + valeurs = [] + for r in rapports: + for nom, d in (r.get("durees") or {}).items(): + if nom.endswith("-2"): + valeurs.append(d) + return sum(valeurs) / len(valeurs) if valeurs else 0 + + +def humain(n): + for unite in ("o", "Kio", "Mio", "Gio"): + if n < 1024 or unite == "Gio": + return f"{n:.0f} {unite}" if unite == "o" else f"{n:.1f} {unite}" + n /= 1024 + return f"{n:.1f} Tio" + + +def executer(cmd, delai, journal=None, montrer=False): + """Une commande locale. Rend (code, sortie).""" + if journal: + dire(f" $ {cmd}", journal) + try: + p = subprocess.run( + cmd, + shell=True, + capture_output=not montrer, + text=True, + timeout=delai, + ) + except subprocess.TimeoutExpired: + return 124, f"délai dépassé après {delai} s" + return p.returncode, (p.stdout or "") + (p.stderr or "") + + +# -------------------------------------------------------------------------- +# Contrôles préalables +# -------------------------------------------------------------------------- + + +def prealables(journal, base=NOM_BASE): + """Ce qui doit être vrai AVANT de créer la moindre machine. + + Chaque manque est dit avec son remède : découvrir au bout de vingt minutes + que le cache n'écoutait pas est le genre d'échec qui ne se pardonne pas. + """ + manques = [] + if not os.path.isfile(CA): + manques.append( + f"autorité du cache absente ({CA}) — TODO › Déploiement › Cache QEMU" + ) + code, _ = executer(f"systemctl is-active --quiet {SERVICE}", 15) + if code: + manques.append( + f"le service {SERVICE} ne tourne pas — systemctl start {SERVICE}" + ) + if not cle_publique(): + manques.append("aucune clé publique ssh dans ~/.ssh") + code, _ = executer("virsh -c qemu:///system list --all", 30) + if code: + manques.append("libvirt injoignable — virsh -c qemu:///system list") + if not os.path.isfile(CLI): + manques.append(f"deploy_qemu.py absent ({CLI})") + ecart = desaccord_de_reseau() + if ecart: + manques.append(ecart) + restes = machines_vivantes(base) + if restes: + # Sans cette garde, la création bute sur le disque de la machine + # restante et rend un « existe déjà » qui ne dit pas quoi faire. + manques.append( + f"machine(s) d'un essai précédent encore là : {', '.join(restes)}" + f" — les défaire d'abord : {sys.argv[0]} --detruire" + ) + for m in manques: + dire(f" ✗ {m}", journal) + return not manques + + +def prefixe_du_mode(args): + """Le champ « mode » du nom, seul. C'est lui que le démontage balaie.""" + if getattr(args, "sans_cache", False): + return NOM_BASE_SANS_CACHE + if getattr(args, "hors_ligne", False): + return NOM_BASE_HORS_LIGNE + return NOM_BASE + + +def segment_systeme(distro, version): + """« ubuntu_2404 », « arch », « opensuse_160 ». + + Le point saute, comme dans les noms de VM du parc. « latest » saute aussi : + une distribution en publication continue n'en a qu'une, si bien que le + segment ne distinguerait aucune machine d'une autre. + """ + if not version or version == "latest": + return distro + return f"{distro}_{version.replace('.', '')}" + + +def nom_de_base(prefixe, distro, version, charge): + """Le début du nom, commun aux VM d'un même essai. Le rang s'ajoute après. + + Fonction PURE, sans arguments de ligne de commande : le menu s'en sert pour + annoncer les machines qui vont naître, et annoncer un nom qui ne serait pas + celui qui naît vaut moins que de ne rien annoncer. + """ + return ( + f"{prefixe}-{segment_systeme(distro, version)}" + f"-{NOM_DE_CHARGE.get(charge, charge)}" + ) + + +def base_des_noms(args): + """Le début du nom des VM de CET essai, mode, système et charge compris.""" + return nom_de_base( + prefixe_du_mode(args), + getattr(args, "distro", DISTRO), + getattr(args, "version", "") or VERSION, + getattr(args, "charge", "minimum"), + ) + + +def machines_vivantes(base=NOM_BASE): + """Les machines de CE mode qui existent encore, par leur nom. + + De ce mode SEULEMENT : une machine laissée par une autre expérience + n'entre en conflit avec rien, et refuser de partir à cause d'elle + obligerait à tout défaire pour lancer une mesure indépendante. + """ + code, sortie = executer("virsh -c qemu:///system list --all --name", 30) + if code: + return [] + return [ + l.strip() + for l in (sortie or "").split("\n") + if l.strip().startswith(base) + ] + + +def desaccord_de_reseau(): + """Les règles visent-elles le sous-réseau que libvirt sert VRAIMENT ? + + Le réseau « default » ne sert pas toujours 192.168.122.0/24 : il est + déplacé sur un /24 libre dès que ce préfixe entre en collision, ce qui est + le cas de tout orchestrateur qui est lui-même une VM. Des règles posées + sur l'autre préfixe existent, l'installation réussit, et aucune VM ne + traverse le cache. Rend un message, ou "" si l'accord est fait. + """ + code, xml = executer("virsh -c qemu:///system net-dumpxml default", 30) + if code: + return "" + m = re.search(r"= 0: + en_bloc = False + continue + quote = None + precedent = "" + index = 0 + while index < len(ligne): + caractere = ligne[index] + if precedent == "\\" and quote in ('"', "'"): + # La chaîne brute ignore l'échappement : seules les deux + # autres formes le connaissent. + precedent = "" + index += 1 + continue + if quote: + if caractere == quote: + quote = None + elif caractere in "\"'`": + quote = caractere + elif caractere == "/" and ligne[index : index + 2] == "//": + texte = ligne[index:].lstrip("/").strip() + if texte: + commentaires.append((numero, texte)) + break + elif caractere == "/" and ligne[index : index + 2] == "/*": + reste = ligne[index + 2 :] + fin = reste.find("*/") + texte = (reste if fin < 0 else reste[:fin]).strip() + if texte: + commentaires.append((numero, texte)) + if fin < 0: + en_bloc = True + break + index += 2 + fin + 2 + precedent = "" + continue + precedent = caractere + index += 1 + return _regroupe(commentaires) + + def blocs(chemin, source): """Les commentaires d'un fichier, selon son suffixe.""" if chemin.endswith(".py"): return blocs_python(source) + if chemin.endswith(".go"): + return blocs_go(source) return blocs_shell(source) diff --git a/script/install/install_qemu_cache.sh b/script/install/install_qemu_cache.sh new file mode 100755 index 0000000..7d471bd --- /dev/null +++ b/script/install/install_qemu_cache.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Pose erplibre_go_qemu_cache : le miroir de téléchargement des VM QEMU +# locales. Compile depuis les sources du dépôt, installe une unité systemd, +# et laisse le service poser lui-même ses règles de détournement. +# +# Les règles de pare-feu ne sont PAS écrites par ce script. L'unité les pose +# à son démarrage et les retire à son arrêt, en tubant « --print-nft » dans +# nft : une VM ne peut donc pas rester détournée vers un cache éteint, et le +# jeu de règles vient du code Go que les tests vérifient, jamais d'une copie. +# +# Usage : +# sudo bash install_qemu_cache.sh +# sudo EL_HTTP_PORT=8898 EL_TLS_PORT=8899 EL_BRIDGE=virbr0 \ +# EL_SUBNET=192.168.122.0/24 bash install_qemu_cache.sh + +set -e + +EL_HTTP_PORT="${EL_HTTP_PORT:-8898}" +EL_TLS_PORT="${EL_TLS_PORT:-8899}" +# Vides par défaut : le pont et le sous-réseau sont LUS dans libvirt plus bas. +# Les supposer est le défaut qui rend l'installation silencieusement inutile — +# un hôte dont le réseau par défaut a été déplacé sur un /24 libre, ce qui +# arrive dès que 192.168.122 entre en collision, voit ses VM échapper au +# détournement sans que rien ne le signale. +EL_BRIDGE="${EL_BRIDGE:-}" +EL_SUBNET="${EL_SUBNET:-}" +EL_NET="${EL_NET:-default}" +EL_CACHE_DIR="${EL_CACHE_DIR:-/var/cache/erplibre_go_qemu_cache}" +EL_CA_DIR="${EL_CA_DIR:-/var/lib/erplibre_go_qemu_cache}" +EL_ACCESS_LOG="${EL_ACCESS_LOG:-/var/log/erplibre_go_qemu_cache.jsonl}" +EL_EXCLUDE="${EL_EXCLUDE:-}" +EL_BYPASS_FILE="${EL_BYPASS_FILE:-/etc/erplibre_go_qemu_cache/bypass}" +# Les dépôts git tenus en miroir. Sous le répertoire du cache : c'est le +# seul que le service peut écrire, et le miroir grossit comme le reste. +EL_GIT_MIRROR_DIR="${EL_GIT_MIRROR_DIR:-${EL_CACHE_DIR:-/var/cache/erplibre_go_qemu_cache}/git}" + +BIN="/usr/local/bin/erplibre_go_qemu_cache" +CONF_DIR="/etc/erplibre_go_qemu_cache" +UNIT="/etc/systemd/system/erplibre-go-qemu-cache.service" +SERVICE_USER="elqcache" +GO_MIN_MAJOR=1 +GO_MIN_MINOR=21 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# EL_SRC_DIR passe outre la déduction : le script se retrouve ainsi lançable +# depuis un chemin copié, et une vérification peut appeler UNE de ses +# fonctions sans que la déduction échoue faute de dépôt autour. +SRC_DIR="${EL_SRC_DIR:-$(cd "${SCRIPT_DIR}/../qemu_cache" && pwd)}" + +log() { echo "[cache QEMU] $*"; } +die() { + echo "[cache QEMU] ERREUR : $*" >&2 + exit 1 +} + +check_root() { + if [ "$EUID" -ne 0 ]; then + die "à lancer en root : sudo bash $0" + fi +} + +detect_os() { + [ -f /etc/os-release ] || die "système inconnu (pas de /etc/os-release)" + # shellcheck disable=SC1091 + . /etc/os-release + OS="${ID}" + OS_LIKE="${ID_LIKE:-}" + log "système : ${OS}" +} + +is_arch_like() { + case "$OS" in + arch | manjaro | endeavouros | artix | garuda) return 0 ;; + *) echo "$OS_LIKE" | grep -q "arch" && return 0 || return 1 ;; + esac +} + +is_debian_like() { + case "$OS" in + ubuntu | debian | linuxmint | pop | elementary | raspbian) return 0 ;; + *) echo "$OS_LIKE" | grep -qE "debian|ubuntu" && return 0 || return 1 ;; + esac +} + +is_fedora_like() { + case "$OS" in + fedora | rhel | centos | almalinux | rocky) return 0 ;; + *) echo "$OS_LIKE" | grep -qE "fedora|rhel" && return 0 || return 1 ;; + esac +} + +# --------------------------------------------------------------------------- +# Le réseau que les VM utilisent VRAIMENT +# --------------------------------------------------------------------------- + +# detecter_reseau remplit EL_BRIDGE et EL_SUBNET depuis libvirt. +# +# Le réseau « default » ne sert pas toujours 192.168.122.0/24 : il est déplacé +# sur un /24 libre quand ce préfixe entre en collision avec ce que l'hôte +# route déjà — le cas de tout orchestrateur qui est lui-même une VM. Poser des +# règles sur le mauvais préfixe laisse une installation qui réussit, des +# règles bien présentes dans le noyau, et un cache que personne ne traverse. +detecter_reseau() { + local xml adresse masque + # Donnés à la main, les deux se suffisent. Une machine dont le réseau + # libvirt n'est pas démarré — ou qui gère son pont autrement — doit pouvoir + # poser le cache en nommant ce pont. Sonder quand même faisait mourir + # l'installation sur une absence qui n'empêchait rien, et le contournement + # annoncé en tête de ce fichier ne servait à rien. + if [ -n "$EL_BRIDGE" ] && [ -n "$EL_SUBNET" ]; then + log "réseau donné à la main : ${EL_BRIDGE}, ${EL_SUBNET}" + return 0 + fi + xml="$(virsh -c qemu:///system net-dumpxml "$EL_NET" 2>/dev/null)" || { + die "réseau libvirt « ${EL_NET} » introuvable — virsh -c qemu:///system net-list" \ + "le démarrer : sudo virsh -c qemu:///system net-start ${EL_NET}" \ + "ou nommer le pont : EL_BRIDGE=virbr0 EL_SUBNET=192.168.122.0/24" + } + [ -n "$EL_BRIDGE" ] || EL_BRIDGE="$(echo "$xml" | + sed -n "s/.*/dev/null || true)" \ + /usr/local/go/bin/go \ + /usr/lib/go/bin/go; do + [ -n "$candidat" ] && [ -x "$candidat" ] && echo "$candidat" && return 0 + done + return 1 +} + +go_assez_recent() { + local go_bin="$1" v major minor + v="$("$go_bin" version 2>/dev/null | sed -n 's/.*go\([0-9]*\)\.\([0-9]*\).*/\1 \2/p')" + [ -z "$v" ] && return 1 + major="${v% *}" + minor="${v#* }" + [ "$major" -gt "$GO_MIN_MAJOR" ] && return 0 + [ "$major" -eq "$GO_MIN_MAJOR" ] && [ "$minor" -ge "$GO_MIN_MINOR" ] && return 0 + return 1 +} + +installer_go() { + log "Go absent ou trop ancien : installation par le gestionnaire de paquets" + if is_arch_like; then + pacman -S --needed --noconfirm go + elif is_debian_like; then + DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 update -qq + DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=600 \ + install -y golang-go + elif is_fedora_like; then + dnf install -y golang + else + die "distribution non prévue : installer Go ${GO_MIN_MAJOR}.${GO_MIN_MINOR}+ à la main" + fi +} + +assurer_go() { + local go_bin + if go_bin="$(find_go)" && go_assez_recent "$go_bin"; then + GO="$go_bin" + log "Go : $("$GO" version)" + return + fi + installer_go + go_bin="$(find_go)" || die "Go reste introuvable après installation" + go_assez_recent "$go_bin" || + die "le Go de la distribution est plus ancien que ${GO_MIN_MAJOR}.${GO_MIN_MINOR} ; poser une version récente à la main" + GO="$go_bin" + log "Go : $("$GO" version)" +} + +# --------------------------------------------------------------------------- +# Compilation +# --------------------------------------------------------------------------- + +compiler() { + [ -f "${SRC_DIR}/go.mod" ] || die "sources absentes : ${SRC_DIR}" + log "compilation depuis ${SRC_DIR}" + # Le cache de compilation dans un temporaire : root n'a pas à laisser + # d'état dans son compte pour une compilation unique. + local gocache + gocache="$(mktemp -d)" + ( + cd "$SRC_DIR" + GOCACHE="$gocache" GOFLAGS=-mod=mod "$GO" build -o "$BIN" . + ) + rm -rf "$gocache" + chmod 0755 "$BIN" + log "posé : ${BIN} ($("$BIN" --version))" +} + +# --------------------------------------------------------------------------- +# Compte, répertoires, autorité +# --------------------------------------------------------------------------- + +preparer_etat() { + if ! id "$SERVICE_USER" >/dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" \ + 2>/dev/null || + useradd --system --no-create-home --shell /sbin/nologin "$SERVICE_USER" + log "compte de service créé : ${SERVICE_USER}" + fi + mkdir -p "$EL_CACHE_DIR" "$EL_CA_DIR" "$CONF_DIR" "$EL_GIT_MIRROR_DIR" + touch "$EL_ACCESS_LOG" + # La liste des exceptions appartient à root : elle décide qui échappe au + # cache, et le compte du service ne doit pas pouvoir s'y ajouter. + mkdir -p "$(dirname "$EL_BYPASS_FILE")" + [ -f "$EL_BYPASS_FILE" ] || cat >"$EL_BYPASS_FILE" <<'BYPASS' +# Exceptions du cache de téléchargement des VM QEMU. +# Une ligne « » par machine que le détournement doit +# ignorer. Relu à chaque démarrage du service. +BYPASS + chmod 0644 "$EL_BYPASS_FILE" + chown -R "${SERVICE_USER}:${SERVICE_USER}" "$EL_CACHE_DIR" "$EL_CA_DIR" "$EL_ACCESS_LOG" "$EL_GIT_MIRROR_DIR" + chmod 0755 "$EL_CACHE_DIR" "$EL_CA_DIR" +} + +# L'autorité est créée ICI plutôt qu'au premier démarrage : l'installation +# doit pouvoir en dire l'empreinte, que l'opérateur compare ensuite dans la VM. +creer_autorite() { + runuser -u "$SERVICE_USER" -- "$BIN" --init-ca --ca-dir "$EL_CA_DIR" || + die "l'autorité n'a pas pu être créée dans ${EL_CA_DIR}" +} + +# --------------------------------------------------------------------------- +# Configuration et unité +# --------------------------------------------------------------------------- + +ecrire_config() { + cat >"${CONF_DIR}/env" </dev/null 2>&1; then + echo "${BIN} --print-nft --bridge \${EL_BRIDGE} --subnet \${EL_SUBNET} --http-port \${EL_HTTP_PORT} --tls-port \${EL_TLS_PORT} --bypass-file \${EL_BYPASS_FILE} | nft -f -" + else + echo "${BIN} --print-iptables --bridge \${EL_BRIDGE} --subnet \${EL_SUBNET} --http-port \${EL_HTTP_PORT} --tls-port \${EL_TLS_PORT} --bypass-file \${EL_BYPASS_FILE} | sh -s" + fi +} + +regles_clear_cmd() { + if command -v nft >/dev/null 2>&1; then + echo "nft delete table ip erplibre_qemu_cache 2>/dev/null || true" + else + echo "${BIN} --print-iptables --bridge \${EL_BRIDGE} --subnet \${EL_SUBNET} --http-port \${EL_HTTP_PORT} --tls-port \${EL_TLS_PORT} --bypass-file \${EL_BYPASS_FILE} | sed 's/ -A / -D /' | sh -s || true" + fi +} + +ecrire_unite() { + # « + » devant une commande la fait tourner en root même sous User= : poser + # une règle de pare-feu demande un privilège que le service n'a pas, et ne + # doit pas avoir, pendant qu'il sert des fichiers. + cat >"$UNIT" </dev/null 2>&1 || true + systemctl restart erplibre-go-qemu-cache.service + + # Un service qui ne tient pas doit le dire ici, et non se découvrir au + # prochain déploiement de VM. + sleep 2 + if ! systemctl is-active --quiet erplibre-go-qemu-cache.service; then + systemctl status --no-pager -l erplibre-go-qemu-cache.service || true + die "le service ne tient pas ; les règles ont été retirées à son arrêt" + fi + + log "" + log "cache actif sur ${EL_HTTP_PORT} (http) et ${EL_TLS_PORT} (tls)" + log "détournement : ${EL_BRIDGE}, depuis ${EL_SUBNET}, sortie du /24 seule" + log "autorité à faire approuver dans chaque VM :" + log " ${EL_CA_DIR}/ca.crt" + "$BIN" --status --cache-dir "$EL_CACHE_DIR" --ca-dir "$EL_CA_DIR" \ + --git-mirror-dir "$EL_GIT_MIRROR_DIR" || true +} + +main "$@" diff --git a/script/install/lib_python_provider.sh b/script/install/lib_python_provider.sh index 211d62e..5aa83c1 100755 --- a/script/install/lib_python_provider.sh +++ b/script/install/lib_python_provider.sh @@ -107,13 +107,36 @@ el_mise_install() { # Installe la version via pyenv, en posant pyenv lui-même au besoin. el_pyenv_install() { - local version="$1" root exe + local version="$1" root exe installer root="$(el_pyenv_root)" exe="$(el_pyenv_exec_path "${version}")" if [[ ! -d "${root}" ]]; then echo "---- Installation de pyenv dans ${root} ----" >&2 - curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer \ - | bash >&2 || return 1 + # Téléchargé dans un fichier, PUIS exécuté. Quand curl alimente bash par + # un tube, le statut du tube est celui de bash, qui rend 0 sur une entrée + # vide : sans pipefail — que rien ne pose dans cette chaîne —, un + # « || return 1 » placé après le tube ne se déclencherait jamais. Un + # téléchargement raté mènerait alors à « pyenv: command not found », puis + # à un échec de compilation, deux messages qui accusent la mauvaise + # étape. Lu seul, le statut de curl nomme la vraie cause. + # + # « -f » : sans lui, curl écrit le CORPS d'une erreur HTTP — page d'erreur + # de miroir, portail captif, 504 d'un cache hors ligne —, que bash + # exécuterait ensuite comme un script. + installer="$(mktemp)" || return 1 + if ! curl -fsSL -o "${installer}" \ + https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer; then + echo "Telechargement de l'installateur pyenv impossible" \ + "(reseau ou cache) : pyenv n'est pas pose." >&2 + rm -f "${installer}" + return 1 + fi + if ! bash "${installer}" >&2; then + echo "L'installateur de pyenv a echoue (voir ci-dessus)." >&2 + rm -f "${installer}" + return 1 + fi + rm -f "${installer}" fi export PATH="${root}/bin:$PATH" eval "$(pyenv init - 2> /dev/null)" || true diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index ab49347..06518e7 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -963,17 +963,25 @@ def create_cmds(vmid: int, spec: dict) -> list: nom = spec["name"] stockage = spec["storage"] image = f"{spec.get('image_dir', IMAGE_DIR)}/{spec['image']}" + # L'écran de la VM. « serial0 » fait de la console série l'affichage : + # c'est ce que « qm terminal » attend, et c'est le défaut d'une machine + # de serveur. La 3D demande un vrai périphérique vidéo — « virtio-gl » + # pose un virtio-gpu que le VIRGL de l'hôte accélère. Le port série reste + # posé dans les deux cas, donc la console série ne se perd jamais ; seule + # la nature de l'écran change. + vga = "virtio-gl" if spec.get("gpu3d") else "serial0" cmds = [ # 1. La coquille : processeur, mémoire, réseau, contrôleur, agent. "qm create {id} --name {nom} --memory {mem} --cores {cpu}" " --cpu host --ostype l26 --scsihw virtio-scsi-single" " --net0 virtio,bridge={pont} --agent enabled=1" - " --serial0 socket --vga serial0".format( + " --serial0 socket --vga {vga}".format( id=vmid, nom=shlex.quote(nom), mem=int(spec["memory"]), cpu=int(spec["vcpus"]), pont=spec["bridge"], + vga=vga, ), # 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+) # remplace l'ancien « qm importdisk » en une seule étape et attache diff --git a/script/qemu/cache_journal.py b/script/qemu/cache_journal.py new file mode 100644 index 0000000..a137727 --- /dev/null +++ b/script/qemu/cache_journal.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le journal d'accès du cache, rendu lisible ligne à ligne. + +Lit les lignes JSON du journal sur l'entrée standard et en écrit une par +requête servie. La flèche est la colonne qui compte sous une coupure : « ↑ » +dit que la requête est SORTIE vers l'internet, « · » qu'elle a été servie du +disque de l'hôte. Un déploiement hors ligne n'en produit aucune en « ↑ », et +« --amont » ne garde que celles-là : la commande reste alors muette tant que +rien ne sort. + +Chaque ligne est écrite dès qu'elle est lue. « tail -f » y passe un flux sans +fin, et une sortie mise en tampon ne montrerait rien avant plusieurs +kilo-octets, c'est-à-dire des minutes sur un journal qui avance doucement. + +Une ligne illisible est passée : le journal s'écrit pendant qu'on le lit, et +la dernière ligne d'un fichier en cours d'écriture est parfois tronquée. +""" + +import argparse +import json +import sys + + +def taille(octets): + """Une taille lisible, « - » quand la réponse n'avait pas de corps.""" + n = float(octets or 0) + if not n: + return "-" + for unite in ("o", "Kio", "Mio", "Gio"): + if n < 1024 or unite == "Gio": + return f"{n:.0f} {unite}" if unite == "o" else f"{n:.1f} {unite}" + n /= 1024 + return f"{n:.1f} Tio" + + +def ligne_lisible(brut, amont_seul=False): + """La ligne à écrire pour une ligne du journal, "" s'il n'y a rien. + + Rend "" sur une ligne illisible et, sous « amont_seul », sur toute + requête qui n'est pas sortie vers l'internet. + """ + try: + d = json.loads(brut) + except (TypeError, ValueError): + return "" + if not isinstance(d, dict): + return "" + if amont_seul and not d.get("upstream"): + return "" + # L'horodatage du journal est en UTC, au format ISO : les positions 11 à + # 19 en sont l'heure. + heure = str(d.get("time", ""))[11:19] or "--:--:--" + client = str(d.get("client") or "—") + fleche = "↑" if d.get("upstream") else "·" + url = str(d.get("url", "")) + return ( + f"{heure} {client:<15} {fleche} {d.get('status') or 0:>3} " + f"{str(d.get('outcome', '?')):<13}{taille(d.get('bytes')):>9} " + f"{url[-70:]}" + ) + + +def main(argv=None, entree=None, sortie=None): + analyseur = argparse.ArgumentParser( + description="Met en forme le journal d'accès du cache QEMU." + ) + analyseur.add_argument( + "--amont", + action="store_true", + help="ne montrer que les requêtes sorties vers l'internet", + ) + args = analyseur.parse_args(argv) + entree = entree if entree is not None else sys.stdin + sortie = sortie if sortie is not None else sys.stdout + for brut in entree: + ligne = ligne_lisible(brut, args.amont) + if not ligne: + continue + sortie.write(ligne + "\n") + sortie.flush() + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (BrokenPipeError, KeyboardInterrupt): + # Ctrl-C arrête le suivi, et « tail » fermé ferme le tube : ni l'un + # ni l'autre n'est une erreur à afficher. + sys.exit(0) diff --git a/script/qemu/cache_offline.py b/script/qemu/cache_offline.py new file mode 100644 index 0000000..d02cf40 --- /dev/null +++ b/script/qemu/cache_offline.py @@ -0,0 +1,1297 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Couper l'internet d'un déploiement hors ligne, et le rendre. + +La coupure ferme trois sorties et laisse l'hôte joignable : +- l'amont du SEUL service du cache, qui ne peut plus rien tirer de + l'internet : ce qu'il sert encore vient de son disque ; +- la sortie DIRECTE des VM, celle que l'hôte relaie : ping, autres ports, + UDP, IPv6. Un pas d'installation qui prendrait un autre chemin que le + cache — un clone par ssh, un dépôt sur un port à lui, du QUIC — échoue + donc, au lieu de réussir en ligne sans que rien ne le montre ; +- la résolution des noms par l'internet : un résolveur à part, sur l'hôte, + répond à TOUT nom une adresse fictive. Le détournement du cache vise un + PORT et non une adresse, et le cache reconnaît le site à son nom (SNI, + en-tête Host) : la VM joint donc encore ce que le cache détient, et rien + d'autre. Aucune requête DNS d'une VM ne quitte l'hôte. + +Reste ce qu'une VM demande à l'HÔTE lui-même : son 80 et son 443, détournés +vers le cache avant tout routage ; les noms, auxquels l'hôte répond seul ; +la session ssh de l'opérateur ; le trafic entre VM du même pont. + +La coupure du cache porte sur son COMPTE, jamais sur le port. Une règle +générale sur le 443 de l'orchestrateur emporterait la session ssh depuis +laquelle le déploiement est lancé, et la machine se couperait au milieu de la +commande qui la configure. + +Le module REND le texte des règles et l'applique à part : un test vérifie +alors les règles au caractère près sans toucher au pare-feu de la machine qui +exécute les tests. +""" + +import re +import shlex +import shutil + +# Le compte sous lequel le service tourne. Le script d'installation porte la +# même valeur — il est en shell et ne peut pas la lire ici ; un test compare +# les deux, la dérive entre deux copies étant le seul risque de cette +# duplication. +SERVICE_USER = "elqcache" + +# Une table à nous, effaçable d'un geste. Distincte de celle du détournement : +# la coupure est temporaire et la redirection ne l'est pas, les mêler ferait +# tomber le détournement de tout le pont en rebranchant l'amont. +TABLE = "erplibre_qemu_cache_offline" + +# Le pont des VM quand le service n'en nomme aucun : celui que libvirt donne +# à son réseau par défaut. +PONT_PAR_DEFAUT = "virbr0" + +# Un nom d'interface tel que le noyau les accepte. Il entre tel quel dans les +# règles : une apostrophe ou un guillemet y casserait le jeu entier, et un nom +# faux donnerait une règle qui ne viserait rien — des VM restées en ligne +# sous une coupure annoncée. +_NOM_DE_PONT = re.compile(r"[A-Za-z0-9_.:-]{1,15}") + +# Le résolveur fictif. Toute question reçoit la même adresse, que le +# détournement conduit au cache quelle qu'elle soit : 192.0.2.1 est réservée à +# la documentation (RFC 5737) et jamais routée. L'AAAA répond 100::1, du +# préfixe réservé au rejet (RFC 6666) : sans réponse, dnsmasq REFUSE la +# question, et un résolveur qui reçoit un refus peut écarter le serveur. Sans +# route IPv6, le client repasse aussitôt en IPv4 ; avec une route, la chaîne +# « transit » jette ce trafic. +ADRESSE_FICTIVE_V4 = "192.0.2.1" +ADRESSE_FICTIVE_V6 = "100::1" + +# Hors des ports que tiennent déjà le résolveur de libvirt (53) et le mDNS +# de l'hôte (5353). +PORT_DNS = 10053 + +UNITE_DNS = "erplibre-qemu-offline-dns" + + +def nft_rules( + user: str = SERVICE_USER, + table: str = TABLE, + pont: str = PONT_PAR_DEFAUT, + refus: bool = True, +) -> str: + """Le jeu de règles à passer à « nft -f - ». + + Chaîne « sortie » : « meta skuid » filtre sur l'UID du processus + émetteur, si bien que seul ce que le service du cache envoie tombe ; la + session ssh et le reste de l'hôte ne sont pas touchés. Les deux ports : le + cache tire aussi en clair, et ne couper que le 443 laisserait passer tout + un miroir Debian. + + Les deux chaînes de filtrage REFUSENT au lieu de jeter. Un paquet jeté + fait pendre l'établissement jusqu'au délai de celui qui compose : le + cache paierait ce délai à chaque adresse qu'il ne détient pas, et un + « apt-get update » en enchaîne des centaines. Refusé, l'établissement + échoue sur-le-champ, et le cache rend son 504 — ou sa copie — aussitôt. + « refus=False » rend la variante qui jette : un noyau mis à jour sans + redémarrage n'a plus ses modules sur le disque, et ne peut pas charger + celui du refus. `cut_cmd` la pose alors en repli. + + Chaîne « transit » : elle refuse ce que le pont relaie vers une AUTRE + interface, c'est-à-dire la sortie directe des VM. Ce qui vise l'hôte — le + 80 et le 443 détournés vers le cache avant le routage, le résolveur, la + session ssh — passe par « input » et n'est pas touché ; le trafic entre + VM ne quitte pas le pont. Une chaîne à soi suffit : un « accept » de + libvirt dans SA table n'empêche pas ce refus de jouer, un paquet + traversant toutes les chaînes de base de son point d'accroche. + + Chaîne « noms » : le DNS des VM — UDP et TCP 53, quelle que soit la + destination, un résolveur public compris — est détourné vers le + résolveur fictif de `dns_cmd`. Elle ne touche ni le 80 ni le 443, que la + table du cache détourne de son côté. + + Lève ValueError sur un nom de pont qu'une interface ne peut pas porter. + """ + if not _NOM_DE_PONT.fullmatch(pont or ""): + raise ValueError(f"nom de pont refusé : {pont!r}") + sortie, transit = ( + ("reject with tcp reset", "reject") if refus else ("drop", "drop") + ) + return ( + f"table inet {table} {{\n" + f" chain sortie {{\n" + f" type filter hook output priority 0; policy accept;\n" + f" meta skuid {user} tcp dport {{ 80, 443 }} {sortie}\n" + f" }}\n" + f" chain noms {{\n" + f" type nat hook prerouting priority dstnat; policy accept;\n" + f' iifname "{pont}" udp dport 53 redirect to :{PORT_DNS}\n' + f' iifname "{pont}" tcp dport 53 redirect to :{PORT_DNS}\n' + f" }}\n" + f" chain transit {{\n" + f" type filter hook forward priority 0; policy accept;\n" + f' iifname "{pont}" oifname != "{pont}" {transit}\n' + f" }}\n" + f"}}\n" + ) + + +def pont_des_vm() -> str: + """Le pont que le service du cache détourne, lu dans ses réglages.""" + return reglage("EL_BRIDGE") or PONT_PAR_DEFAUT + + +def cut_cmd( + user: str = SERVICE_USER, table: str = TABLE, pont: str = "", dns: str = "" +) -> str: + """La commande qui pose la coupure : la table, PUIS le résolveur fictif. + + Tout ou rien. Si le résolveur ne part pas, la table est retirée aussitôt + et la commande échoue : sans lui, le port 53 des VM mènerait à un port + muet, et l'installation échouerait sur la résolution des noms — une + raison qui n'est pas celle qu'on mesure. Sans dnsmasq sur l'hôte, la + commande échoue d'emblée, pour la même raison. + + Le jeu qui refuse est tenté d'abord, celui qui jette en repli, dans le + même sudo : nft pose un jeu entier ou rien, si bien qu'un refus que le + noyau ne sait pas charger ne laisse aucune demi-coupure derrière lui. + """ + pont = pont or pont_des_vm() + regles = nft_rules(user, table, pont) + repli = nft_rules(user, table, pont, refus=False) + dns = dns or dns_cmd(pont) + if not dns: + return "false" + pose = "sudo sh -c " + shlex.quote( + f"printf %s {shlex.quote(regles)} | nft -f - 2>/dev/null" + f" || printf %s {shlex.quote(repli)} | nft -f -" + ) + return f"{pose} && {{ {dns} || {{ {restore_cmd(table)}; false; }}; }}" + + +def dnsmasq() -> str: + """Le chemin de dnsmasq sur l'hôte, ou "" : la coupure des noms en + dépend, et le déploiement le vérifie avant de rien poser.""" + return shutil.which("dnsmasq") or "" + + +def dns_cmd( + pont: str = PONT_PAR_DEFAUT, + binaire: str = "", + duree: int = 0, + unite: str = UNITE_DNS, +) -> str: + """La commande qui fait répondre l'hôte à tout nom, le temps de la coupure. + + Un dnsmasq à part, et non celui de libvirt, auquel on ne touche pas : il + reprend son rôle dès que la redirection du port 53 tombe avec la table. + Le nôtre écoute le seul pont, sur PORT_DNS, sans fichier de réglages, + sans amont (« --no-resolv »), sans /etc/hosts, avec un TTL nul : aucune + réponse fictive ne survit dans une VM à la levée. Il tourne en unité + transitoire bornée par RuntimeMaxSec, au-delà de celle du guet. + + Rend "" quand dnsmasq est introuvable. + """ + binaire = binaire or dnsmasq() + if not binaire: + return "" + if not _NOM_DE_PONT.fullmatch(pont or ""): + raise ValueError(f"nom de pont refusé : {pont!r}") + parties = [ + "sudo", + "systemd-run", + f"--unit={unite}", + "--collect", + "--description=ERPLibre QEMU offline DNS", + "-p", + f"RuntimeMaxSec={duree or DUREE_MAX_GUET + 3600}", + binaire, + "--keep-in-foreground", + "--conf-file=/dev/null", + "--no-resolv", + "--no-hosts", + "--no-poll", + f"--interface={pont}", + # dnsmasq ajoute la boucle locale à toute liste « --interface » : + # l'en retirer le cantonne au seul pont. + "--except-interface=lo", + "--bind-interfaces", + f"--port={PORT_DNS}", + f"--address=/#/{ADRESSE_FICTIVE_V4}", + f"--address=/#/{ADRESSE_FICTIVE_V6}", + "--local-ttl=0", + "--pid-file=", + "--log-facility=-", + ] + return " ".join(shlex.quote(x) for x in parties) + + +# Le témoin que la levée touche pour annuler la mémoire des amonts muets du +# service. Son nom est tenu en accord avec « SentinelleAmonts » du code Go par +# un test : toucher un fichier que rien ne lit ne réveillerait personne. +SENTINELLE_AMONTS = ".amonts-oublies" +# Le magasin, quand les réglages posés ne se lisent pas. Le service et la +# levée doivent désigner le MÊME fichier, et la levée tourne souvent sans +# l'environnement de l'unité. +CACHE_DIR_DEFAUT = "/var/cache/erplibre_go_qemu_cache" + + +def sentinelle_amonts(cache_dir: str = "") -> str: + """Le chemin du témoin, sous le magasin. + + Le réglage posé prime : un magasin déplacé emporte son témoin, sans quoi + la levée toucherait un fichier hors du répertoire que le service lit. + """ + import os + + racine = cache_dir or reglage("EL_CACHE_DIR") or CACHE_DIR_DEFAUT + return os.path.join(racine, SENTINELLE_AMONTS) + + +def restore_cmd(table: str = TABLE) -> str: + """La commande qui la retire, muette si elle n'était pas là. + + Toujours vraie : le rebranchement se fait dans un « finally », et une + coupure déjà retirée ne doit pas y lever d'erreur qui masquerait celle + d'origine. + """ + return f"sudo sh -c {shlex.quote(_retrait(table))}" + + +def _retrait(table: str = TABLE) -> str: + """Le retrait nu, sans sudo : `restore_cmd` le lance sous « sudo sh -c », + le guet le lance déjà en root. + + Il retire la table, arrête le résolveur fictif, ET touche le témoin des + amonts muets. Arrêter le résolveur seul laisserait le port 53 des VM + détourné vers un port muet ; retirer la table seule laisserait tourner un + processus que plus rien n'interroge ; et sans le témoin, les amonts notés + muets pendant la coupure le resteraient jusqu'à la fin de leur fenêtre, + la première requête d'après la levée tombant dans le repli alors que le + réseau est revenu. Aucun canal n'existe vers le service en marche. + """ + return ( + f"nft delete table inet {table} 2>/dev/null || true; " + f"systemctl stop {UNITE_DNS} 2>/dev/null || true; " + f"touch {shlex.quote(sentinelle_amonts())} 2>/dev/null || true" + ) + + +# Le guet : une unité systemd transitoire, posée par root au lancement des +# installations, qui attend que chaque journal porte son marqueur de sortie +# puis lève la coupure. Les installations tournent détachées et survivent au +# tableau de bord : lever la coupure à sa fermeture les ferait finir en ligne +# sans que rien ne le dise, et l'y garder la ferait durer tant que l'écran +# reste ouvert. Un processus Python ne tient pas ce rôle : SIGKILL, une +# session ssh perdue ou une panne l'emportent sans dérouler aucun « finally ». +UNITE_GUET = "erplibre-qemu-offline-lift" + +# Durée maximale du guet, en secondes. Au-delà, systemd l'arrête et la levée +# (ExecStopPost) court quand même : une installation dont le marqueur ne vient +# jamais — session ssh pendue, VM supprimée — ne prive pas le cache d'amont +# indéfiniment. +DUREE_MAX_GUET = 12 * 3600 + +# Période de relecture des journaux. Le marqueur est la dernière ligne écrite ; +# elle borne le retard de la levée sur la fin réelle. +PAS_GUET = 10 + + +# systemd réécrit « ${VAR} », un « $VAR » pris comme argument entier et +# « $$ » dans les arguments qu'il exécute, et les spécificateurs « % » dans +# les valeurs passées par « -p ». Un chemin de journal qui en porte serait +# réécrit : le guet ne le trouverait jamais, et la coupure tiendrait jusqu'à +# RuntimeMaxSec au lieu de tomber avec la dernière installation. +CARACTERES_REECRITS = ("$", "%") + + +def chemins_surs(journaux) -> bool: + """Vrai si aucun chemin ne porte un caractère que systemd réécrirait. + + Refuser vaut mieux qu'échapper : « $$ » n'est juste que si systemd + substitue réellement, ce qui dépend de sa version et de la voie par + laquelle la commande lui arrive. Un refus ramène la levée au « finally », + dont le comportement est connu. + """ + return all( + not any(c in str(j) for c in CARACTERES_REECRITS) for j in journaux + ) + + +def script_attente(marqueur: str, pas: int = PAS_GUET) -> str: + """Le script du guet : il rend 0 quand CHAQUE journal passé en argument + porte `marqueur` dans ses derniers octets, et repasse toutes les `pas` + secondes sinon. + + Un journal ABSENT compte pour non fini. Le guet part juste après le + lancement des installations, et leurs journaux peuvent ne pas exister + encore : les compter pour finis lèverait la coupure sur-le-champ, et + l'installation se terminerait en ligne sans que rien ne le dise. Un + journal qui ne viendra jamais — son répertoire effacé — tient donc la + coupure jusqu'à RuntimeMaxSec, et la levée immédiate est affichée. + + Sans pipefail, « tail | grep » rend le statut de grep : un fichier absent + ne livre rien, et le marqueur n'y est pas trouvé. + """ + return ( + "while :; do fini=1; " + 'for f in "$@"; do ' + f'tail -c 4096 "$f" 2>/dev/null | grep -qF {shlex.quote(marqueur)}' + " || fini=0; " + 'done; [ "$fini" = 1 ] && exit 0; ' + f"sleep {int(pas)}; done" + ) + + +def guet_cmd( + journaux, + marqueur: str, + duree: int = DUREE_MAX_GUET, + table: str = TABLE, + unite: str = UNITE_GUET, +) -> str: + """La commande qui confie la levée à root, par une unité transitoire. + + `journaux` : chemins des journaux d'installation, un par VM ; `marqueur` : + ce que l'enveloppe détachée écrit en dernière ligne à la fin de chacune. + Les chemins voyagent en ARGUMENTS du shell (« sh -c script sh j1 j2 … ») + et non dans le script : un espace ou une apostrophe dans un chemin ne + peut rien y casser. + + ExecStopPost court à la fin normale, au dépassement de RuntimeMaxSec et à + un « systemctl stop » : la coupure tombe dans les trois cas. « --collect » + efface l'unité même en échec, sans quoi son nom resterait pris. + + Le script d'attente vient de `script_attente` : un journal absent y + compte pour NON fini. + + systemd remplace « ${VAR} » et « $$ » dans les arguments qu'il exécute : le + script n'en contient aucun, seules les formes « $f » et « "$@" », qu'il + laisse intactes, y figurent. + """ + attente = script_attente(marqueur) + parties = [ + "sudo", + "systemd-run", + f"--unit={unite}", + "--collect", + "--description=ERPLibre QEMU offline cut lift", + "-p", + f"RuntimeMaxSec={duree}", + "-p", + f'ExecStopPost=/bin/sh -c "{_retrait(table)}"', + "/bin/sh", + "-c", + attente, + "sh", + *journaux, + ] + return " ".join(shlex.quote(p) for p in parties) + + +def guet_actif_cmd(unite: str = UNITE_GUET) -> str: + """Rend 0 tant que le guet tourne. Sans sudo : lire l'état d'une unité + est ouvert à tout compte.""" + return f"systemctl is-active --quiet {shlex.quote(unite)}" + + +def lever_maintenant_cmd(unite: str = UNITE_GUET) -> str: + """La levée immédiate quand le guet tourne : l'arrêter fait courir son + ExecStopPost, qui retire la table. Retirer la table seule laisserait le + guet attendre pour rien.""" + return f"sudo systemctl stop {shlex.quote(unite)}" + + +def table_posee_cmd(table: str = TABLE) -> str: + """Rend 0 si la coupure est posée, 1 si elle ne l'est pas, 2 si on ne + peut pas le savoir. + + Le troisième cas est la raison d'être de la commande : « ! nft list + table » conclurait à l'absence dès que sudo refuse ou que nft manque, et + un rebranchement raté se lirait comme réussi. La table est cherchée + ligne entière dans « nft list tables » : un nom qui la prolongerait ne + compte pas. + """ + ligne = shlex.quote(f"table inet {table}") + return ( + "(l=$(sudo nft list tables) || exit 2; " + f"printf '%s\\n' \"$l\" | grep -qxF {ligne} && exit 0; exit 1)" + ) + + +# Là où le service écrit ses réglages. Le journal d'accès y est nommé : le +# chercher là plutôt que de recopier son chemin, qui est réglable. +CONF = "/etc/erplibre_go_qemu_cache/env" + +# Les issues qui prouvent qu'un CORPS est en réserve. « fetched » n'en est +# pas : elle couvre aussi le « 304 » d'une revalidation, qui n'a pas de corps. +# « stored-status » et « stale-status » n'en sont pas davantage : elles +# portent un objet de STATUT seul — une redirection ou un refus gardés, sans +# corps — et l'index d'une suite gardé en 404 ne la rend pas lisible. +ISSUES_EN_RESERVE = ("stored", "hit", "stale") + + +def journal(conf: str = CONF) -> str: + """Chemin du journal d'accès, ou "" s'il n'est pas lisible.""" + try: + with open(conf, encoding="utf-8") as fh: + for ligne in fh: + if ligne.startswith("EL_ACCESS_LOG="): + return ligne.split("=", 1)[1].strip() + except OSError: + pass + return "" + + +def jeton_de_suite(distro: str, version: str) -> str: + """Ce qui, dans une URL, identifie la SUITE de dépôt d'un système. + + Rend "" quand aucun jeton ne l'identifie de façon sûre : mieux vaut ne + pas se prononcer que rassurer à tort. Un dépôt Fedora et un dépôt openSUSE + se ressemblent dans l'URL — leur « /repodata/ » ne dit pas quelle version + il sert — et un cache rempli pour une version passerait pour rempli pour + toutes. + + Les familles apt, elles, portent le nom de code dans « /dists// », + et il est unique. Le nom vient du CATALOGUE du déploiement, jamais d'une + table recopiée ici. + """ + from script.qemu.deploy_qemu import DISTROS, cache_family + + if cache_family(distro) != "apt": + return "" + versions = (DISTROS.get(distro) or [{}])[0] + entree = versions.get(version) or [] + return f"/dists/{entree[0]}/" if entree else "" + + +def detient_la_suite(distro: str, version: str, chemin: str = "") -> bool: + """Le cache a-t-il un corps en réserve pour cette suite de dépôt ? + + Le journal d'accès EST la mesure : il porte l'URL et l'issue de chaque + requête, et une issue de la liste ci-dessus prouve qu'un corps existe. + + Rend True dès qu'on ne sait pas juger — jeton inconnu, journal illisible : + un avertissement qui se déclenche sans savoir apprend à passer outre, et + c'est alors celui qui compte qu'on ne lit plus. + """ + jeton = jeton_de_suite(distro, version) + if not jeton: + return True + chemin = chemin or journal() + if not chemin: + return True + import json + import os + + if not os.path.exists(chemin): + return True + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + for ligne in fh: + if jeton not in ligne: + continue + try: + d = json.loads(ligne) + except ValueError: + continue + if d.get("outcome") in ISSUES_EN_RESERVE and jeton in ( + d.get("url") or "" + ): + return True + except OSError: + return True + return False + + +def suites_absentes(vms) -> list: + """Les (système, version) dont le cache n'a rien, sans doublon. + + Ce qu'une VM hors ligne ne pourra pas lire : son apt échouera sur l'index + de sa suite de base, et aucun paquet de cette suite ne sera trouvable. + """ + vus, manquantes = set(), [] + for vm in vms or (): + cle = (vm.get("distro") or "", vm.get("version") or "") + if not cle[0] or cle in vus: + continue + vus.add(cle) + if not detient_la_suite(*cle): + manquantes.append(cle) + return manquantes + + +# Ce qu'un bureau installe vraiment. Les quatre composants, sur la suite de +# base ET sur ses deux compagnes : « xrdp » vit dans « universe », ses +# dépendances dans « restricted », et les correctifs dans « -security ». Un +# cache rempli pour « main » seul laisse apt sans rien de tout cela. +COMPOSANTS_APT = ("main", "universe", "restricted", "multiverse") +SUFFIXES_APT = ("", "-updates", "-security") + +# « /dists///binary-/ » : ce que porte l'URL d'un +# index de paquets, et la seule forme qui dise à la fois la suite, le +# composant et l'architecture. +_INDEX_APT = re.compile(r"/dists/([^/]+)/([^/]+)/binary-([^/]+)/") + + +def composants_absents(vms, chemin: str = "") -> list: + """[(système, version, ["suite/composant", …])] — ce dont le cache n'a rien. + + Le verdict par SUITE ne suffit pas : une seule URL en réserve sous + « /dists// » le rend muet, alors que « restricted » ou + « -security » peuvent manquer en entier. apt ne le dit qu'à + l'installation, par « Unable to locate package », un message qui accuse + le dépôt et jamais le cache — vingt minutes après la coupure. + + Muet quand on ne sait pas juger — famille sans jeton, journal illisible. + Muet aussi quand le cache n'a RIEN de ce système : `suites_absentes` le + dit déjà, et deux avertissements pour une cause apprennent à les + enchaîner. + """ + import json + import os + + chemin = chemin or journal() + if not chemin or not os.path.exists(chemin): + return [] + vus = set() + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + for ligne in fh: + if "/dists/" not in ligne: + continue + try: + d = json.loads(ligne) + except ValueError: + continue + if d.get("outcome") not in ISSUES_EN_RESERVE: + continue + trouve = _INDEX_APT.search(d.get("url") or "") + if trouve: + vus.add(trouve.groups()) + except OSError: + return [] + + deja, sortie = set(), [] + for vm in vms or (): + distro = vm.get("distro") or "" + version = vm.get("version") or "" + arche = vm.get("arch") or "amd64" + cle = (distro, version, arche) + if not distro or cle in deja: + continue + deja.add(cle) + jeton = jeton_de_suite(distro, version) + if not jeton: + continue + code = jeton.strip("/").split("/")[-1] + attendus = [ + (code + suffixe, composant) + for suffixe in SUFFIXES_APT + for composant in COMPOSANTS_APT + ] + manque = [ + f"{suite}/{composant}" + for suite, composant in attendus + if (suite, composant, arche) not in vus + ] + # Tout manque : le cache ignore ce système, et `suites_absentes` le + # dira mieux. + if manque and len(manque) < len(attendus): + sortie.append((distro, version, manque)) + return sortie + + +# Les paquets que le déploiement pose HORS du fil observé : leur pose part en +# unité détachée pour que cloud-init rende la main en quelques secondes, si +# bien qu'un échec ne remonte nulle part — ni au suivi, ni au journal des +# manques, qui ne connaît que ce qu'une coupure a déjà fait rater. Le pré-vol +# est donc le seul endroit où leur absence peut encore se dire À TEMPS. +PAQUETS_HORS_SUIVI = ("qemu-guest-agent",) + + +def paquets_absents(vms, noms=(), chemin: str = "") -> list: + """Les paquets nommés dont le magasin n'a rien, ou []. + + Le verdict par index ne descend jamais au FICHIER : un cache qui détient + l'index d'une suite passe pour complet alors qu'aucun octet du paquet + lui-même n'a jamais traversé. Hors ligne, l'installation échoue vingt + minutes plus tard sur « Unable to locate package », un message qui accuse + le dépôt et jamais le cache. + + Jugé sur le journal, comme les autres verdicts : une URL en réserve qui + porte le nom du paquet suffit. Le nom d'un fichier de paquet porte sa + version et son architecture, jamais son miroir, si bien que la recherche + vaut quel que soit le miroir qui l'a servi. + + Muet sans VM et quand le journal ne se lit pas : accuser un cache qu'on + ne peut pas interroger ferait taire l'avertissement le jour où il compte. + """ + import json + import os + + noms = tuple(noms) or PAQUETS_HORS_SUIVI + chemin = chemin or journal() + if not (vms or ()) or not chemin or not os.path.exists(chemin): + return [] + trouves = set() + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + for ligne in fh: + restants = [n for n in noms if n not in trouves] + if not restants: + break + # Le test de chaîne AVANT le décodage : un journal de + # déploiement porte des dizaines de milliers de lignes, et + # les décoder toutes coûterait plus que le verdict ne vaut. + for nom in restants: + if nom not in ligne: + continue + try: + d = json.loads(ligne) + except ValueError: + continue + if d.get("outcome") in ISSUES_EN_RESERVE and nom in ( + d.get("url") or "" + ): + trouves.add(nom) + except OSError: + return [] + return [nom for nom in noms if nom not in trouves] + + +def miroirs_absents(depots, racine: str = "") -> list: + """Les dépôts git déclarés qui n'ont pas encore de miroir, ou []. + + Une négociation git ne se garde pas : le cache tient un dépôt NU par + amont et le sert localement. Un dépôt jamais mirroré n'a donc rien à + servir une fois le réseau coupé, et le clone échoue — sans que rien ne + l'ait annoncé, le verdict par index ne parlant que d'apt et le journal + des manques ne connaissant que ce qu'une coupure a déjà fait rater. + + Le chemin d'un miroir est « /.git » sous la racine : la + même règle que celle qui les pose, l'hôte en faisant partie parce que + deux forges peuvent servir le même chemin. + + Muet quand la racine ne se lit pas : un magasin absent n'est pas un + miroir manquant, et deux causes sous un seul message font cesser de lire. + """ + import os + from urllib.parse import urlsplit + + racine = racine or os.path.join( + reglage("EL_CACHE_DIR") or CACHE_DIR_DEFAUT, "git" + ) + if not os.path.isdir(racine): + return [] + manque = [] + for depot in depots or (): + morceaux = urlsplit(depot) + chemin = morceaux.path.strip("/") + if chemin.endswith(".git"): + chemin = chemin[: -len(".git")] + if not morceaux.netloc or not chemin: + continue + attendu = os.path.join(racine, morceaux.netloc, chemin + ".git") + if not os.path.isdir(attendu): + manque.append(f"{morceaux.netloc}/{chemin}") + return manque + + +# --------------------------------------------------------------------------- +# Ce que les déploiements hors ligne précédents n'ont pas trouvé +# --------------------------------------------------------------------------- +# +# Une ligne « offline-miss » du journal d'accès PROUVE qu'un invité a demandé +# une adresse que le cache n'avait pas, pendant que l'amont était muet. Jointe +# à un déploiement — l'adresse de sa VM et sa fenêtre [début, dernière +# écriture de son log] — elle dit ce que ce déploiement a manqué, donc ce que +# le suivant manquera encore si rien n'a été rempli entre-temps. +# +# Une liste de manques est une BORNE BASSE : l'installation s'arrête au premier +# manque fatal, et ce qui la suivait n'a jamais été demandé. + +# Le binaire du service. Le menu du cache porte le même chemin ; un test +# compare les deux. +BINAIRE = "/usr/local/bin/erplibre_go_qemu_cache" + +# Un répertoire par déploiement, écrit par le suivi des installations : un +# « session.json » et un log par VM. +RUNS = "~/.erplibre/qemu-install" + +# Les méthodes que le cache sait garder. Toute autre le traverse sans copie : +# un POST manqué hors ligne le sera toujours, et le compter dans « au moins +# N » promettrait un remplissage impossible. +METHODES_GARDABLES = ("GET", "HEAD") + +# Les points de négociation git (gitSmartPaths, côté Go) ; un test compare. +# Le cache ne garde pas ces échanges : c'est le DÉPÔT qu'il tient, dans son +# miroir, et l'entrée 5 du menu du cache le remplit. +GIT_NEGOCIATION = ("/info/refs", "/git-upload-pack", "/git-receive-pack") + +ISSUE_MANQUE = "offline-miss" + +# Les issues d'un amont muet : servies du disque faute de réponse. Elles +# naissent d'une coupure, mais aussi EN LIGNE : un amont qui vient de refuser +# une connexion est tenu pour muet quelques secondes, et les requêtes qui ont +# de quoi se rabattre sont servies du disque sans l'appeler. Leur présence +# dans la fenêtre d'une VM ne prouve donc pas qu'elle a tourné coupée ; seul +# le « offline » du manifeste le dit. +ISSUES_AMONT_MUET = ("stale", "stale-status", "keep", ISSUE_MANQUE) + +# Ce qui prouve qu'une RÉPONSE est gardée, plus large que ISSUES_EN_RESERVE : +# la VM hors ligne qui reçoit la redirection ou le 404 que l'amont avait rendu +# obtient ce qu'elle aurait eu en ligne. +ISSUES_REPONSE_GARDEE = ISSUES_EN_RESERVE + ("stored-status", "stale-status") + +# Les verdicts de « --detient » qui valent détention : un corps, ou un objet +# de statut seul. +DETENTION = ("garde", "statut") + +# Combien de déploiements renseignés d'un même nom sont réunis, et jusqu'à +# quel âge. Au-delà, branche, catalogue et cache ont changé, et l'avertissement +# parlerait d'un autre système. +RUNS_RETENUS = 3 +AGE_MAX = 30 * 86400 + + +def reglage(nom: str, conf: str = CONF) -> str: + """Valeur d'un réglage du service (« EL_… »), ou "" s'il est illisible.""" + prefixe = f"{nom}=" + try: + with open(conf, encoding="utf-8") as fh: + for ligne in fh: + if ligne.startswith(prefixe): + return ligne.split("=", 1)[1].strip() + except OSError: + pass + return "" + + +def instant(texte): + """Secondes depuis l'époque d'une date RFC 3339, ou None. + + Le journal date en UTC ; une date sans fuseau est lue comme telle. + """ + import datetime + + try: + d = datetime.datetime.fromisoformat(str(texte).replace("Z", "+00:00")) + except ValueError: + return None + if d.tzinfo is None: + d = d.replace(tzinfo=datetime.timezone.utc) + return d.timestamp() + + +def _horodatage(label: str): + """L'instant local que porte le nom d'un répertoire de déploiement.""" + import time + + try: + return time.mktime(time.strptime(label, "%Y%m%d-%H%M%S")) + except (ValueError, OverflowError): + return None + + +def lire_runs(racine: str = RUNS) -> list: + """Les déploiements passés qui ont installé quelque chose. + + Rend [{"label", "debut", "hors_ligne", "vms": [{"nom", "ip", "fin", + "code"}]}]. + + « hors_ligne » est le « offline » du manifeste : True ou False quand il + le porte, None sinon — un manifeste qui ne le porte pas ne dit pas si + son déploiement a tourné coupé. + + « debut » est le « deploy_started » du manifeste quand il le porte : + l'instant où le déploiement commence, AVANT la création des VM. Le + premier démarrage d'une VM coupée — pose de l'agent invité, paquets de + cloud-init — passe par le cache avant le lancement des installations, et + ses manques tomberaient hors d'une fenêtre ouverte au lancement. À + défaut, « debut » est le plus tôt de l'horodatage du répertoire et du + « started », tous deux écrits au lancement des installations. + + « fin » est la dernière écriture du log de la VM, c'est-à-dire son + marqueur de sortie ; « code » est ce marqueur, None quand il manque. Un + déploiement sans branche ne fait que démarrer ses VM, n'installe rien, + et n'est pas rendu. + """ + import glob + import json + import os + + from script.todo.qemu_install_monitor import read_status + + out = [] + motif = os.path.join(os.path.expanduser(racine), "*", "session.json") + for manifeste in glob.glob(motif): + try: + with open(manifeste, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + continue + if not isinstance(data, dict) or not data.get("branch"): + continue + label = os.path.basename(os.path.dirname(manifeste)) + debut = data.get("deploy_started") + if not isinstance(debut, (int, float)) or isinstance(debut, bool): + debuts = [ + d + for d in (data.get("started"), _horodatage(label)) + if isinstance(d, (int, float)) + ] + if not debuts: + continue + debut = min(debuts) + vms = [] + for vm in data.get("vms") or (): + if not isinstance(vm, dict): + continue + log = vm.get("log") or "" + try: + fin = os.path.getmtime(log) + except (OSError, TypeError): + continue + vms.append( + { + "nom": vm.get("name") or "", + "ip": vm.get("ip") or "", + "fin": fin, + "code": read_status(log)[1], + } + ) + hors_ligne = data.get("offline") + out.append( + { + "label": label, + "debut": debut, + "hors_ligne": ( + hors_ligne if isinstance(hors_ligne, bool) else None + ), + "vms": vms, + } + ) + return out + + +def _lignes(chemin: str, indices) -> list: + """Les lignes du journal, décodées, dont le TEXTE contient un des indices. + + Le filtre sur le texte précède le décodage : le journal n'est pas tourné, + il compte des dizaines de milliers de lignes, et seule une poignée + concerne la question posée. Un journal illisible rend une liste vide. + """ + import json + + if not chemin or not indices: + return [] + out = [] + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + for ligne in fh: + if not any(i in ligne for i in indices): + continue + try: + d = json.loads(ligne) + except ValueError: + continue + if isinstance(d, dict): + out.append(d) + except OSError: + return [] + return out + + +def releve_amont_muet(chemin: str, clients) -> list: + """Les lignes servies amont muet à ces clients. + + Rend [{"client", "t", "issue", "methode", "url"}], « t » en secondes. + """ + out = [] + for d in _lignes(chemin, [f'"{i}"' for i in ISSUES_AMONT_MUET]): + if d.get("outcome") not in ISSUES_AMONT_MUET: + continue + if d.get("client") not in clients: + continue + quand = instant(d.get("time")) + if quand is None: + continue + out.append( + { + "client": d["client"], + "t": quand, + "issue": d["outcome"], + "methode": d.get("method") or "", + "url": d.get("url") or "", + } + ) + return out + + +def bilan_du_nom( + runs, + nom: str, + releve, + maintenant: float, + retenus: int = RUNS_RETENUS, + age_max: float = AGE_MAX, +): + """Ce que les derniers déploiements hors ligne de la VM « nom » ont manqué. + + Le NOM est la clé : il porte le système, sa version et la saveur du + bureau, là où le manifeste peut laisser système et version vides. Une + ligne du relevé appartient à un déploiement quand son client est l'adresse + de sa VM et que son instant tombe dans la fenêtre de celle-ci : une + adresse IP se réattribue, et seule la fenêtre dit à qui elle était. + + Les déploiements sont lus du plus récent au plus ancien : + · un déploiement qui a RÉUSSI coupé (manifeste « offline » vrai, code + 0, des lignes d'amont muet) clôt la lecture des PLUS ANCIENS — ce + qui leur manquait a été rempli depuis, ou n'est plus demandé. Ses + PROPRES manques sont gardés : les blocs d'outils facultatifs + préviennent et rendent 0, et une installation qui en a manqué + plusieurs réussit quand même ; + · une réussite qu'on ne sait pas coupée ne clôt rien, le manifeste + l'ignorât-il seulement : les lignes d'amont muet naissent aussi en + ligne, et clore à tort ferait taire des manques réels. Ne pas clore + coûte peu — la lecture reste bornée par « retenus » et « age_max », + et ce que le magasin détient depuis est ôté plus loin ; + · un déploiement sans manque ne renseigne pas : arrêté avant d'avoir + rien demandé, sur un verrou apt par exemple, ou mené en ligne ; + · les autres sont RÉUNIS, jusqu'à « retenus ». Prendre le seul dernier + rendrait muet l'avertissement dès qu'un déploiement s'arrête tôt. + + Rend None quand aucun déploiement ne renseigne. Sinon {"nom", "age", + "runs", "manques"} : « age » est celui du plus récent retenu, « manques » + associe chaque (méthode, URL) au dernier instant où elle a manqué, le + déploiement le plus récent d'abord. + """ + candidats = sorted( + ( + (run, vm) + for run in runs + for vm in run["vms"] + if vm["nom"] == nom and vm["ip"] + ), + key=lambda rv: rv[1]["fin"], + reverse=True, + ) + pris = [] + for run, vm in candidats: + if maintenant - vm["fin"] > age_max: + break + dedans = [ + ligne + for ligne in releve + if ligne["client"] == vm["ip"] + and run["debut"] <= ligne["t"] <= vm["fin"] + ] + manques = [l for l in dedans if l["issue"] == ISSUE_MANQUE] + if dedans and vm["code"] == 0 and run.get("hors_ligne") is True: + if manques: + pris.append((run, vm, manques)) + break + if not manques: + continue + pris.append((run, vm, manques)) + if len(pris) >= retenus: + break + if not pris: + return None + manques = {} + for _run, _vm, lignes in pris: + for ligne in sorted(lignes, key=lambda l: l["t"]): + cle = (ligne["methode"], ligne["url"]) + manques[cle] = max(manques.get(cle, 0.0), ligne["t"]) + return { + "nom": nom, + "age": maintenant - pris[0][1]["fin"], + "runs": [run["label"] for run, _vm, _l in pris], + "manques": manques, + } + + +def tenus_selon_journal(chemin: str, manques) -> set: + """Les (méthode, URL) dont le journal dit la réponse gardée APRÈS le manque. + + « manques » associe chaque (méthode, URL) à l'instant de son dernier + manque. Le journal dit « a été gardé », pas « est encore là » : une purge + efface l'objet et laisse ses lignes. Seule une ligne postérieure au manque + compte — l'objet est entré, ou a été servi, après — ce qui écarte l'objet + purgé avant le manque. Une purge postérieure reste invisible, d'où + l'étiquette « selon le journal » partout où ce verdict est montré. + """ + from urllib.parse import urlsplit + + hotes = set() + for _methode, url in manques: + try: + hote = urlsplit(url).hostname + except ValueError: + hote = None + if hote: + hotes.add(f"//{hote}") + tenus = set() + for d in _lignes(chemin, sorted(hotes)): + cle = (d.get("method") or "", d.get("url") or "") + if cle not in manques or d.get("outcome") not in ISSUES_REPONSE_GARDEE: + continue + quand = instant(d.get("time")) + if quand is not None and quand > manques[cle]: + tenus.add(cle) + return tenus + + +def detient_rendu(sortie: str) -> dict: + """La sortie de « --detient », par (méthode, URL). + + Une ligne par question, six champs séparés par des tabulations : + verdict, statut, date de garde (RFC 3339 ou « - »), classe, méthode, URL. + Une ligne mal formée est sautée plutôt que devinée. + """ + out = {} + for ligne in (sortie or "").splitlines(): + champs = ligne.split("\t", 5) + if len(champs) < 6: + continue + verdict, statut, garde_le, classe, methode, url = champs + out[(methode, url)] = { + "verdict": verdict, + "statut": statut, + "garde_le": garde_le, + "classe": classe, + } + return out + + +def detient_interroger(paires, binaire: str = BINAIRE, cache_dir: str = ""): + """Ce que le MAGASIN détient maintenant, par (méthode, URL). + + Le magasin et non le journal : un objet effacé après son entrée garde ses + lignes « stored ». Rend None quand le binaire manque, ne connaît pas + « --detient » — il se sonde dans son aide — ou échoue : l'appelant se + rabat alors sur le journal, et le DIT. La question ne demande aucun + privilège, les casiers étant lisibles par tous. + """ + import os + import subprocess + + paires = list(paires) + if not paires: + return {} + if not os.path.isfile(binaire): + return None + try: + aide = subprocess.run( + [binaire, "--help"], capture_output=True, text=True, timeout=15 + ) + except (OSError, subprocess.SubprocessError): + return None + if "-detient" not in (aide.stdout or "") + (aide.stderr or ""): + return None + argv = [binaire] + cache_dir = cache_dir or reglage("EL_CACHE_DIR") + if cache_dir: + argv += ["--cache-dir", cache_dir] + argv.append("--detient") + try: + p = subprocess.run( + argv, + input="".join(f"{m} {u}\n" for m, u in paires), + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return None + if p.returncode != 0: + return None + return detient_rendu(p.stdout) + + +def depot_git(url: str) -> str: + """L'adresse du dépôt dont `url` est une négociation git, ou "". + + Le suffixe de négociation est retiré du chemin, et la requête avec lui : + « …/d.git/info/refs?service=git-upload-pack » et « …/d.git/git-upload-pack » + rendent le même dépôt, compté une fois. Une adresse illisible rend "". + """ + from urllib.parse import urlsplit, urlunsplit + + try: + morceaux = urlsplit(url or "") + except ValueError: + return "" + for suffixe in GIT_NEGOCIATION: + if morceaux.path.endswith(suffixe): + chemin = morceaux.path[: -len(suffixe)] or "/" + return urlunsplit( + (morceaux.scheme, morceaux.netloc, chemin, "", "") + ) + return "" + + +def manques_hors_ligne( + vms, racine: str = RUNS, chemin: str = "", maintenant=None, detient=None +) -> list: + """Par VM de la spec, ce que ses derniers déploiements hors ligne ont + manqué et que le cache ne détient toujours pas. + + Rend [{"nom", "age", "runs", "manquants", "git", "jamais", + "selon_journal"}], une entrée par nom qui manque encore quelque chose de + comblable. « manquants » ne porte que des méthodes gardables que le cache + ne détient pas. « git » : les dépôts, sans doublon, dont une négociation + a manqué — le miroir les remplit, pas un rejeu, et le magasin n'est pas + interrogé à leur sujet. « jamais » : ce que le cache ne gardera jamais. + Les deux derniers sont nommés à part, pour ne pas promettre un + remplissage impossible, et ne font pas parler l'avertissement à eux + seuls : ni le magasin ni le journal ne disent si un miroir a été rempli + depuis. + + « detient » est la question posée au magasin (detient_interroger par + défaut) ; None en retour fait lire le journal à la place, et + « selon_journal » le dit. + + Muet — [] — sans déploiement qui renseigne, et alors sans même lire le + journal ni lancer le binaire : un avertissement qui parle sans savoir + apprend à passer outre. + """ + import time + + noms = [] + for vm in vms or (): + nom = vm.get("name") or "" + if nom and nom not in noms: + noms.append(nom) + if not noms: + return [] + runs = lire_runs(racine) + clients = { + vm["ip"] + for run in runs + for vm in run["vms"] + if vm["nom"] in noms and vm["ip"] + } + if not clients: + return [] + chemin = chemin or journal() + releve = releve_amont_muet(chemin, clients) + maintenant = time.time() if maintenant is None else maintenant + bilans = [ + b + for b in (bilan_du_nom(runs, n, releve, maintenant) for n in noms) + if b + ] + if not bilans: + return [] + + derniers = {} + for b in bilans: + for cle, quand in b["manques"].items(): + if _gardable(cle) and not depot_git(cle[1]): + derniers[cle] = max(derniers.get(cle, 0.0), quand) + verdicts = (detient or detient_interroger)(sorted(derniers)) + selon_journal = verdicts is None + if selon_journal: + tenus, jamais_gardes = tenus_selon_journal(chemin, derniers), set() + else: + tenus = {c for c, v in verdicts.items() if v["verdict"] in DETENTION} + jamais_gardes = { + c for c, v in verdicts.items() if v["verdict"] == "non-cachable" + } + out = [] + for b in bilans: + git, jamais, manquants = _trier_manques( + b["manques"], jamais_gardes, tenus + ) + if manquants: + out.append( + { + "nom": b["nom"], + "age": b["age"], + "runs": b["runs"], + "manquants": manquants, + "git": git, + "jamais": jamais, + "selon_journal": selon_journal, + } + ) + return out + + +def _gardable(cle) -> bool: + """La méthode d'un (méthode, URL) est-elle de celles que le cache + garde ?""" + return cle[0].upper() in METHODES_GARDABLES + + +def _trier_manques(manques, jamais_gardes, tenus): + """Répartit des (méthode, URL) manqués en (git, jamais, manquants). + + « git » : les dépôts, triés et sans doublon, dont une négociation a + manqué. « jamais » : les méthodes que le cache ne garde pas, et ce que + le magasin dit non-cachable. « manquants » : le reste, moins `tenus`. + La négociation git est reconnue AVANT la méthode : un POST sur + « git-upload-pack » se remplit par le miroir, comme le GET qui le + précède. + """ + git, jamais, manquants = set(), [], [] + for cle in manques: + depot = depot_git(cle[1]) + if depot: + git.add(depot) + elif not _gardable(cle) or cle in jamais_gardes: + jamais.append(cle) + elif cle not in tenus: + manquants.append(cle) + return sorted(git), jamais, manquants + + +def manques_recents(chemin: str, sous_reseau: str = "", depuis: float = 0.0): + """Ce que les invités n'ont pas obtenu hors ligne depuis « depuis ». + + Rend [{"methode", "url", "dernier", "n", "clients"}], une entrée par + (méthode, URL), le manque le plus récent d'abord. Ne comptent que les + clients du sous-réseau des VM quand il est connu, et jamais la boucle + locale : un rejeu depuis l'hôte écrit ses propres lignes, et les + recompter ferait rejouer sans fin ce qui vient de l'être. + """ + import ipaddress + + reseau = None + if sous_reseau: + try: + reseau = ipaddress.ip_network(sous_reseau, strict=False) + except ValueError: + reseau = None + par = {} + for d in _lignes(chemin, [f'"{ISSUE_MANQUE}"']): + if d.get("outcome") != ISSUE_MANQUE: + continue + client = d.get("client") or "" + try: + adresse = ipaddress.ip_address(client) + except ValueError: + continue + if adresse.is_loopback or ( + reseau is not None and adresse not in reseau + ): + continue + quand = instant(d.get("time")) + if quand is None or quand < depuis: + continue + cle = (d.get("method") or "", d.get("url") or "") + entree = par.setdefault( + cle, + { + "methode": cle[0], + "url": cle[1], + "dernier": quand, + "n": 0, + "clients": set(), + }, + ) + entree["n"] += 1 + entree["dernier"] = max(entree["dernier"], quand) + entree["clients"].add(client) + return sorted(par.values(), key=lambda e: -e["dernier"]) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 83c50b5..01a1447 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -53,11 +53,12 @@ import grp import gzip import hashlib import ipaddress -import zlib import os -import re -import shutil import pwd +import re +import secrets +import shlex +import shutil import socket import stat as stat_mod import subprocess @@ -68,6 +69,7 @@ import time import urllib.error import urllib.request import warnings +import zlib from pathlib import Path # --------------------------------------------------------------------------- # @@ -1681,24 +1683,38 @@ APT_MIRRORS_MAIN = [ PORTS_ARCHES = ("s390x", "arm64", "aarch64", "ppc64el", "riscv64") -def apt_mirror_lines(arch: str, override: str | None = None) -> list[str]: +def apt_mirror_lines( + arch: str, override: str | None = None, fixe: bool = False +) -> list[str]: """Bloc « apt: » du cloud-config, ou [] si rien à écrire. Ubuntu seulement : Debian, Fedora et Arch ont leurs propres dépôts, et « search » y écrirait des URI qui n'existent pas. + + Un miroir imposé (« override »), ou le premier de la liste quand « fixe » + est vrai, s'écrit « uri: » et non « search: ». La recherche de cloud-init + écarte tout miroir dont le nom se résout comme un nom inexistant : + derrière un résolveur qui répond à tout nom, elle les écarte tous et + retombe sur le dépôt officiel. Le cache range les index sous leur hôte, + si bien qu'une VM derrière lui doit tirer du même miroir que les + précédentes pour retrouver ce qu'elles ont gardé, en ligne comme hors + ligne. """ mirrors = ( [override] if override else (APT_MIRRORS_PORTS if arch in PORTS_ARCHES else APT_MIRRORS_MAIN) ) - lines = ["apt:", " primary:", " - arches: [default]", " search:"] - lines += [f" - {m}" for m in mirrors] + + def bloc(nom): + tete = [f" {nom}:", " - arches: [default]"] + if override or fixe: + return tete + [f" uri: {mirrors[0]}"] + return tete + [" search:"] + [f" - {m}" for m in mirrors] + # La sécurité suit le même dépôt pour les arches ports ; sur amd64 elle a # son propre hôte, que les miroirs répliquent sous le même chemin. - lines += [" security:", " - arches: [default]", " search:"] - lines += [f" - {m}" for m in mirrors] - return lines + return ["apt:"] + bloc("primary") + bloc("security") def kvm_available() -> bool: @@ -1741,6 +1757,70 @@ def nested_module() -> str: return "kvm_amd" if " svm" in info else "kvm_intel" +def hostname_valide(nom: str) -> str: + """Un nom d'hôte acceptable, tiré du nom de la VM. + + Tout ce qui n'est ni lettre, ni chiffre, ni tiret devient un tiret ; les + tirets de tête et de queue tombent, un nom d'hôte ne pouvant pas en porter. + Vide au bout du compte, « vm » sert de repli plutôt que de laisser passer + un nom que l'invité refusera. + """ + propre = re.sub(r"[^A-Za-z0-9-]", "-", nom).strip("-") + propre = re.sub(r"-{2,}", "-", propre) + return propre[:63] or "vm" + + +# La table des alias de fuseaux, telle que tzdata la publie. Nommée ici pour +# qu'un test puisse en fournir une autre sans dépendre du tzdata de la machine +# qui l'exécute. +TZ_ALIASES = "/usr/share/zoneinfo/tzdata.zi" + + +# Distributions dont la chaîne UEFI ne démarre pas sur les OVMF courants. +# +# L'image de Fedora charge et DÉMARRE son chargeur — le micrologiciel l'annonce +# — puis se fige sans écrire un octet sur le disque. La même image en BIOS +# démarre son noyau normalement : ce n'est donc ni l'image, ni la partition +# EFI, dont le chemin de repli est bien là. Ni l'entropie ni la machine q35 n'y +# changent rien. +# +# Le symptôme visible depuis le déploiement est muet : aucune console, aucun +# bail DHCP, une VM « en cours d'exécution » qui ne fait rien. D'où cette table +# plutôt qu'un diagnostic à refaire. +BIOS_OBLIGATOIRE = {"fedora"} + + +def amorcage_bios(distro: str, demande: bool) -> bool: + """Faut-il amorcer en BIOS ? La demande explicite l'emporte toujours.""" + return bool(demande) or distro in BIOS_OBLIGATOIRE + + +def canonical_timezone(tz: str, table: str = TZ_ALIASES) -> str: + """Le nom canonique d'un fuseau, quand le système sait le dire. + + Un alias hérité comme « Canada/Eastern » n'existe plus dans le tzdata de + plusieurs distributions récentes, qui l'ont relégué à un paquet séparé. La + VM refuse alors le fuseau, cloud-init marque son exécution en erreur et la + machine reste en UTC — ce qui ne se voit qu'après coup, sur des horodatages + à +0000. + + La table des alias est « /usr/share/zoneinfo/tzdata.zi », dont chaque ligne + de lien s'écrit « L ». Absente ou illisible, le nom est + rendu tel quel : un fuseau non traduit vaut mieux qu'un déploiement refusé. + """ + if not tz: + return tz + try: + with open(table, encoding="utf-8") as fh: + for ligne in fh: + champs = ligne.split() + if len(champs) >= 3 and champs[0] == "L" and champs[2] == tz: + return champs[1] + except OSError: + pass + return tz + + def host_timezone() -> str: """Fuseau de l'hôte, au format zoneinfo (« America/Montreal »). @@ -1761,13 +1841,13 @@ def host_timezone() -> str: timeout=5, ).stdout.strip() if out: - return out + return canonical_timezone(out) except (OSError, subprocess.SubprocessError): pass try: tz = Path("/etc/timezone").read_text(encoding="utf-8").strip() if tz: - return tz + return canonical_timezone(tz) except OSError: pass try: @@ -1775,7 +1855,9 @@ def host_timezone() -> str: target = Path("/etc/localtime").resolve() parts = target.parts if "zoneinfo" in parts: - return "/".join(parts[parts.index("zoneinfo") + 1 :]) + return canonical_timezone( + "/".join(parts[parts.index("zoneinfo") + 1 :]) + ) except OSError: pass return "UTC" @@ -2400,6 +2482,296 @@ def installer_guide_name(path: str) -> str: return INSTALLER_GUIDE_PREFIX + path.strip("/").replace("/", "-") +# Où chaque famille de distribution range ses ancres de confiance, et par +# quelle commande elle les relit. La même table vit dans +# script/qemu_cache/rules.go, côté cache ; un test les compare, la dérive +# entre deux copies étant le seul risque de cette duplication. +# +# Les familles portent le nom de leur gestionnaire de paquets, comme +# « _QEMU_DISTRO_FAMILY » du menu. +# Trois valeurs par famille : où poser l'ancre, quelle commande relit le +# magasin, et quel FAISCEAU cette commande régénère. +CACHE_TRUST = { + "pacman": ( + "/etc/ca-certificates/trust-source/anchors", + "trust extract-compat", + "/etc/ssl/certs/ca-certificates.crt", + ), + "apt": ( + "/usr/local/share/ca-certificates", + "update-ca-certificates", + "/etc/ssl/certs/ca-certificates.crt", + ), + "dnf": ( + "/etc/pki/ca-trust/source/anchors", + "update-ca-trust", + "/etc/pki/tls/certs/ca-bundle.crt", + ), + "zypper": ( + "/etc/pki/trust/anchors", + "update-ca-certificates", + "/etc/ssl/certs/ca-certificates.crt", + ), +} + +# pip embarque son propre jeu de certificats et IGNORE le magasin système ; +# npm fait de même. Poser l'autorité suffit à pacman et à apt, pas à eux. +# +# Les variables visent le FAISCEAU, non le certificat du cache : pointer +# celui-là ferait perdre à pip toutes les autres autorités — il échouerait sur +# le premier hôte que le cache ne déchiffre pas, et le jour où le cache +# disparaît alors que la VM garde sa variable. +CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS") + +CACHE_CERT_NAME = "erplibre-cache.crt" + + +def cache_family(distro: str) -> str: + """Famille de gestionnaire de paquets d'un système du catalogue. + + Lue de DISTRO_PKG, qui est LA table du catalogue, plutôt que recopiée + ici : une copie dérive, et le système qu'elle oublie est déployé SANS + l'autorité du cache, alors que le détournement, lui, porte sur tout le + pont. Chaque téléchargement HTTPS y échoue sur « self-signed certificate + in certificate chain », message qui ne dit rien d'une table incomplète. + """ + return DISTRO_PKG.get(distro, "") + + +# --------------------------------------------------------------------------- +# Soustraire une VM au cache : l'exception par adresse MAC +# --------------------------------------------------------------------------- + +# Le détournement du cache est TRANSPARENT et vaut pour tout le pont. Ne pas +# donner l'autorité à une VM ne la dispense donc pas d'être interceptée : elle +# reçoit un certificat qu'elle ne reconnaît pas et échoue sur « self-signed +# certificate in certificate chain ». La seule exception qui vaille est posée +# sur l'HÔTE, et elle a besoin d'un identifiant stable — l'adresse MAC, fixée +# dans la définition du domaine, là où l'adresse IP vient d'un bail. +# +# D'où l'ordre imposé ici : la MAC est CHOISIE avant la création, l'exception +# est posée, et la VM démarre ensuite. L'inverse — créer puis lire la MAC — +# laisserait la fenêtre où cloud-init télécharge déjà. +CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache" +CACHE_SERVICE = "erplibre-go-qemu-cache.service" + +# Le préfixe que QEMU/KVM se voit attribuer. S'en écarter ferait passer la VM +# pour une machine d'un autre constructeur auprès de ce qui lit les OUI. +MAC_PREFIXE = "52:54:00" + + +def mac_du_network(network: str) -> str: + """La MAC déjà demandée dans l'argument --network, ou "". + + Une MAC posée à la main l'emporte : l'appelant sait ce qu'il veut, et lui + en substituer une autre casserait une réservation DHCP. + """ + for champ in network.split(","): + cle, _, valeur = champ.partition("=") + if cle.strip() == "mac": + return valeur.strip() + return "" + + +def macs_deja_prises(runner: Runner) -> set[str]: + """Les MAC que portent les domaines existants, en minuscules. + + Libvirt refuse une MAC en double, mais l'erreur arrive au moment de la + création, après le téléchargement de l'image. La lire avant coûte deux + appels et rend le refus immédiat. + """ + code, sortie = runner.run( + ["virsh", "-c", LIBVIRT_URI, "list", "--all", "--name"], + privileged=True, + check=False, + capture=True, + ) + if code: + return set() + prises: set[str] = set() + for nom in (sortie or "").split("\n"): + nom = nom.strip() + if not nom: + continue + code, xml = runner.run( + ["virsh", "-c", LIBVIRT_URI, "domiflist", nom], + privileged=True, + check=False, + capture=True, + ) + if code: + continue + prises.update( + m.lower() + for m in re.findall( + r"\b([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b", xml or "" + ) + ) + return prises + + +def mac_neuve(prises: set[str]) -> str: + """Une MAC du préfixe QEMU qu'aucun domaine ne porte. + + Le tirage est aléatoire sur trois octets : la collision est improbable, + mais elle n'apparaîtrait qu'à la création, une fois l'image téléchargée. + Une poignée d'essais suffit à la rendre impossible en pratique. + """ + for _ in range(64): + octets = [secrets.randbelow(256) for _ in range(3)] + mac = MAC_PREFIXE + ":" + ":".join(f"{o:02x}" for o in octets) + if mac not in prises: + return mac + raise RuntimeError("aucune adresse MAC libre après 64 tirages") + + +def network_avec_mac(network: str, mac: str) -> str: + """Ajoute « mac=… » à l'argument --network, sans toucher au reste.""" + if mac_du_network(network): + return network + return f"{network},mac={mac}" + + +def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str: + """Pose l'exception et rend la MAC retenue, ou "" si rien n'est à faire. + + Rend "" sans se plaindre quand le cache n'est pas là ou ne tourne pas : + dans ce cas rien n'intercepte, et la VM télécharge en direct — ce que + l'appelant demandait. Se plaindre alors serait exiger d'installer un cache + pour pouvoir s'en passer. + + L'exception est écrite dans un fichier ET posée à chaud dans l'ensemble + nftables. Le fichier la fait survivre au redémarrage du service ; + l'ensemble évite d'avoir à reposer les règles, ce qui couperait les + téléchargements des autres VM en cours. + """ + if not getattr(args, "cache_bypass", False): + return "" + if not os.path.isfile(CACHE_BIN): + print( + " cache absent de cet hôte : rien n'intercepte, rien à excepter" + ) + return "" + code, _ = runner.run( + ["systemctl", "is-active", "--quiet", CACHE_SERVICE], + check=False, + capture=True, + ) + if code and not args.dry_run: + print(" cache arrêté : rien n'intercepte, rien à excepter") + return "" + + mac = mac_du_network(args.network) or mac_neuve(macs_deja_prises(runner)) + args.network = network_avec_mac(args.network, mac) + + geste = ( + f"{shlex.quote(CACHE_BIN)} --bypass-add {shlex.quote(mac)}" + f" --bypass-name {shlex.quote(args.name)}" + ) + if shutil.which("nft"): + # Le binaire écrit le fichier et rend sur sa sortie le geste à chaud. + runner.run(["sh", "-c", f"{geste} | nft -f -"], privileged=True) + else: + # Sans nft, les règles sont des « -A » iptables sans ensemble nommé : + # l'exception ne peut entrer qu'en reposant la chaîne entière, ce que + # fait le redémarrage du service. + runner.run(["sh", "-c", geste], privileged=True) + runner.run(["systemctl", "restart", CACHE_SERVICE], privileged=True) + print(f" VM soustraite au cache : {mac}") + return mac + + +def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: + """L'autorité du cache, posée par cloud-init à l'étape init. + + Rend une liste vide quand aucune autorité n'est demandée, ou quand la + distribution n'est pas dans la table : mieux vaut une VM qui télécharge + en direct qu'une VM dont le magasin de confiance a reçu un fichier au + mauvais endroit, où il ne servirait à rien sans que rien ne le dise. + + Le fichier est écrit à l'étape INIT, donc avant « runcmd » et avant tout + téléchargement : ce dépôt n'installe aucun paquet par cloud-init et laisse + « package_update » à faux, si bien que rien ne sort sur le réseau entre le + démarrage et la commande de confiance. + """ + if not args.cache_ca: + return [] + # Les deux ensemble n'ont pas de sens : une VM exceptée ne rencontre + # jamais le cache, et lui faire approuver cette autorité poserait dans son + # magasin une signature dont rien ne se sert. L'exception l'emporte, étant + # la demande la plus précise. + if getattr(args, "cache_bypass", False): + return [] + famille = cache_family(args.distro) + if famille not in CACHE_TRUST: + return [] + try: + with open(args.cache_ca, encoding="utf-8") as fh: + pem = fh.read() + except OSError: + # Une autorité illisible ne doit pas faire échouer un déploiement : + # sans elle, la VM télécharge en direct, ce qui marche. + return [] + if "BEGIN CERTIFICATE" not in pem: + return [] + anchors = CACHE_TRUST[famille][0] + return [(f"{anchors}/{CACHE_CERT_NAME}", "0644", pem, "")] + + +def cache_commands(args: argparse.Namespace) -> list[str]: + """Ce qui rend l'autorité effective, et ce que pip et npm exigent en plus. + + Des commandes SHELL, dans cet ordre : relire le magasin de confiance, puis + écrire les variables dans /etc/environment — que PAM lit pour TOUTE + session ssh, interactive ou non, ce qui est la seule façon d'atteindre le + bootstrap d'installation lancé par commande distante. + + Du shell et non du YAML : cloud-init n'est pas la seule voie de livraison. + Une VM née sur un hôte Proxmox reçoit les mêmes gestes par ssh, « qm set » + ne sachant écrire aucun fichier. Une source unique, deux emballages. + + Chaque commande est tolérante à son propre échec : une autorité déjà + approuvée, ou une variable déjà écrite, n'est pas une raison d'arrêter. + """ + if not cache_files(args): + return [] + _, commande, faisceau = CACHE_TRUST[cache_family(args.distro)] + commandes = [f"{commande} || true"] + for var in CACHE_ENV_VARS: + commandes.append( + f"sh -c 'grep -q ^{var}= /etc/environment" + f" || echo {var}={faisceau} >> /etc/environment'" + ) + return commandes + + +def cache_runcmd(args: argparse.Namespace) -> list[str]: + """Les mêmes gestes, emballés en éléments de « runcmd » pour cloud-init.""" + return [f" - {c}" for c in cache_commands(args)] + + +def cache_env_reload(fichier: str = "/etc/environment") -> str: + """Relit les variables du cache dans la session shell EN COURS. + + PAM lit /etc/environment à l'ouverture d'une session, jamais après. Une + commande distante qui attend cloud-init s'ouvre AVANT que runcmd n'y écrive + les variables : sa session ne les reçoit pas, alors que chaque « sudo », qui + rouvre une session PAM, les voit. Un « npm install » lancé sans sudo rejette + alors l'autorité du cache — « self-signed certificate in certificate chain » + — là où « sudo npm install -g » réussit une ligne plus haut. + + Seules les variables du cache sont exportées : relire le fichier entier + remplacerait aussi le PATH de la session. Rend une instruction shell sans + séparateur final, sans effet quand le fichier est absent ou ne les porte + pas, et qui ne fait pas échouer une commande sous « set -e ». + """ + motif = "|".join(CACHE_ENV_VARS) + return ( + f'if [ -r {fichier} ]; then eval "$(grep -E "^({motif})=" {fichier}' + ' | sed "s/^/export /")"; fi' + ) + + def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: """Fichiers d'accueil de la VM : le guide de connexion, l'identité git. @@ -2483,7 +2855,9 @@ def build_cloud_config( lines.append(f"timezone: {args.timezone}") if getattr(args, "distro", "ubuntu") == "ubuntu": lines += apt_mirror_lines( - getattr(args, "arch", "amd64"), getattr(args, "apt_mirror", None) + getattr(args, "arch", "amd64"), + getattr(args, "apt_mirror", None), + fixe=bool(getattr(args, "cache_ca", None)), ) lines += [ "keyboard:", @@ -2494,7 +2868,7 @@ def build_cloud_config( # dès le PREMIER boot. C'est le point : ils sont là avant l'installation # d'ERPLibre, et encore là si elle échoue — le moment où l'on se connecte # justement à la main. - lines += write_files_lines(guide_files(args)) + lines += write_files_lines(guide_files(args) + cache_files(args)) # apt update/upgrade désactivés par défaut : sur un réseau lent/instable # ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH # est déjà présent dans les images cloud ; on l'active via runcmd sans apt. @@ -2517,8 +2891,11 @@ def build_cloud_config( # Active et démarre SSH quel que soit le nom du service (ssh sur # Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter # sans SSH accessible. + lines += ["runcmd:"] + # En TÊTE : ce qui suit peut télécharger, et sans magasin de confiance à + # jour un invité rejette le certificat que le cache présente. + lines += cache_runcmd(args) lines += [ - "runcmd:", " - systemctl enable --now ssh 2>/dev/null" " || systemctl enable --now sshd 2>/dev/null || true", # Getty sur la console qui EXISTE VRAIMENT. @@ -2537,10 +2914,9 @@ def build_cloud_config( " 2>/dev/null && break; done || true", # qemu-guest-agent : installé APRÈS sshd, et surtout HORS de cloud-init. # - # Mesuré sur Ubuntu 26.04 s390x : « apt-get install qemu-guest-agent » - # tire liburing2, ubuntu-helper-virt-hwe et ubuntu-virt depuis - # ports.ubuntu.com, et cloud-final tourne 9 min 47. Or le suivi - # d'installation attend « cloud-init status: done » : dix minutes + # Son installation tire plusieurs paquets de virtualisation, et sur une + # architecture émulée elle tient cloud-final une dizaine de minutes. Or + # le suivi d'installation attend « cloud-init status: done » : autant # d'attente pour un paquet accessoire, avant même de commencer le # travail utile. # @@ -2548,13 +2924,20 @@ def build_cloud_config( # en quelques secondes et l'agent apparaît quand il apparaît. On saute # aussi l'installation quand qemu-ga est déjà là, ce qui est le cas de # beaucoup d'images. Repli en ligne si systemd-run manque. + # + # Chaque branche de gestionnaire est entre accolades. Sans elles, « && » + # et « || » se lisent à égalité de gauche à droite : une pose apt + # réussie enchaîne sur « dnf install », puis sur le « command -v » + # d'un gestionnaire absent, qui rend 127 sous dash — et le service + # finit en échec après avoir posé l'agent. Le nom du service est celui + # qu'attend _qemu_cloud_init_wait, côté hôte. " - command -v qemu-ga >/dev/null 2>&1 ||" " systemd-run --no-block --unit=erplibre-qga --collect" - " /bin/sh -c 'command -v apt-get >/dev/null && { apt-get update -qq" - " || true; apt-get install -y qemu-guest-agent; }" - " || command -v dnf >/dev/null && dnf install -y qemu-guest-agent" - " || command -v pacman >/dev/null && pacman -Sy --noconfirm" - " qemu-guest-agent' 2>/dev/null" + " /bin/sh -c '{ command -v apt-get >/dev/null && { apt-get update -qq" + " || true; apt-get install -y qemu-guest-agent; }; }" + " || { command -v dnf >/dev/null && dnf install -y qemu-guest-agent; }" + " || { command -v pacman >/dev/null && pacman -Sy --noconfirm" + " qemu-guest-agent; }' 2>/dev/null" " || (command -v apt-get >/dev/null && (timeout 120 apt-get update -qq" " || true; timeout 300 apt-get install -y qemu-guest-agent)) ||" " (command -v dnf >/dev/null && timeout 300 dnf install -y" @@ -3654,11 +4037,10 @@ def virt_install( elif not args.bios: # Boot UEFI par défaut (x86) : Debian 13 (trixie) et les images cloud # récentes n'embarquent plus le chargeur BIOS/GRUB-pc et partent en - # boucle « Booting... » en SeaBIOS. UEFI (OVMF) fonctionne pour - # Ubuntu/Debian/Fedora. --bios force l'ancien BIOS si OVMF est absent. + # boucle « Booting... » en SeaBIOS. --bios force l'ancien BIOS, que + # certaines distributions exigent — voir BIOS_OBLIGATOIRE. # Secure Boot DÉSACTIVÉ : le chargeur d'Arch (GRUB) n'est pas signé et # OVMF Secure Boot le refuse (« Access Denied » -> pas de boot). - # Ubuntu/Debian/Fedora bootent aussi sans Secure Boot. cmd += [ "--boot", "uefi,firmware.feature0.name=secure-boot," @@ -3974,8 +4356,9 @@ def build_parser() -> argparse.ArgumentParser: g_vm.add_argument( "--bios", action="store_true", - help="Force l'amorçage BIOS hérité au lieu d'UEFI (par défaut UEFI ; " - "n'utiliser que si le firmware OVMF est absent).", + help="Force l'amorçage BIOS hérité au lieu d'UEFI. UEFI est le " + "défaut, sauf pour les distributions dont la chaîne UEFI ne démarre " + "pas (Fedora), où le BIOS est retenu d'office.", ) g_cloud = p.add_argument_group("cloud-init") @@ -4088,6 +4471,23 @@ def build_parser() -> argparse.ArgumentParser: help="N'injecte pas l'identité git de l'hôte (user.name, user.email, " "core.editor) dans le ~/.gitconfig de la VM.", ) + g_cloud.add_argument( + "--cache-ca", + default="", + help="Chemin, SUR L'HÔTE, du certificat de l'autorité du cache de " + "téléchargement (erplibre_go_qemu_cache). Fourni, la VM approuve " + "cette autorité dès son premier démarrage et ses téléchargements " + "passent par le cache. Absent, rien n'est posé.", + ) + g_cloud.add_argument( + "--cache-bypass", + action="store_true", + help="Soustrait CETTE VM au cache de téléchargement : une exception " + "par adresse MAC est posée sur l'hôte avant la création, et la VM " + "télécharge en direct. Sans cela, retirer --cache-ca ne suffit pas — " + "le détournement est transparent et la VM échouerait sur un " + "certificat inconnu.", + ) g_cloud.add_argument( "--apt-update", action="store_true", @@ -4300,7 +4700,12 @@ def main() -> None: "Erreur : --name est requis pour déployer une VM " "(ou utilisez --download-only)." ) - args.hostname = args.hostname or args.name + # Un nom d'hôte ne connaît que lettres, chiffres et tirets : le souligné + # y est refusé, et la VM garde alors le nom générique de son image sans que + # rien d'autre qu'un avertissement de cloud-init ne le dise. Le nom de + # DOMAINE, lui, peut le porter — les deux ne se ressemblent qu'en général. + args.hostname = args.hostname or hostname_valide(args.name) + args.bios = amorcage_bios(args.distro, args.bios) pw_hash = resolve_password(args) ssh_keys = load_ssh_keys(args.ssh_key) @@ -4404,6 +4809,8 @@ def main() -> None: resolved_osinfo = osinfo_arg(osinfo, args.distro) print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==") ensure_network(network_name(args.network), runner) + # Avant la création, et non après : la VM télécharge dès cloud-init. + cache_bypass_apply(args, runner) virt_install(args, disk, seed, resolved_osinfo, runner, installer) if installer: watch_and_restart(args.name, runner) diff --git a/script/qemu_cache/.gitignore b/script/qemu_cache/.gitignore new file mode 100644 index 0000000..168de54 --- /dev/null +++ b/script/qemu_cache/.gitignore @@ -0,0 +1,3 @@ +# Le binaire compilé : « go build » l'écrit ici, il pèse ~9 Mo et se refait +# d'une commande. L'installation le pose dans /usr/local/bin. +/erplibre_go_qemu_cache diff --git a/script/qemu_cache/age.go b/script/qemu_cache/age.go new file mode 100644 index 0000000..eacf5c5 --- /dev/null +++ b/script/qemu_cache/age.go @@ -0,0 +1,287 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Le cache ne diminue jamais de lui-même — c'est assumé, et la place se +// surveille à la main. Encore faut-il pouvoir la surveiller : savoir CE QUI +// occupe, et depuis QUAND, avant de décider ce qui peut partir. +// +// L'âge retenu est la date du dernier USAGE et non celle du stockage. Un +// paquet servi tous les jours depuis un an n'est pas vieux : le supprimer +// obligerait à le retélécharger le lendemain. C'est ce qu'un opérateur veut +// dire par « nettoyer ce qui ne sert plus », et c'est pour cela que le service +// remet la date d'un objet à chaque fois qu'il le sert. + +// Tranche est un groupe d'objets d'un même âge. +type Tranche struct { + // Debut est le premier instant de la tranche ; Libelle la nomme. + Debut time.Time + Libelle string + Objets int + Octets int64 +} + +// Granularite dit comment les tranches sont découpées. +type Granularite string + +const ( + ParJour Granularite = "jour" + ParSemaine Granularite = "semaine" + ParMois Granularite = "mois" +) + +// LireGranularite tolère l'anglais comme le français : le CLI est bilingue +// partout ailleurs dans ce dépôt. +func LireGranularite(s string) (Granularite, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "jour", "day", "j", "d": + return ParJour, nil + case "semaine", "week", "s", "w": + return ParSemaine, nil + case "mois", "month", "m": + return ParMois, nil + } + return "", fmt.Errorf("granularité inconnue %q : jour, semaine ou mois", s) +} + +// debutDeTranche ramène un instant au premier de sa tranche. +// +// La semaine commence le LUNDI : c'est la convention ISO, et celle que +// « %V » et les outils du système emploient. Ramener au dimanche donnerait des +// tranches qui ne correspondent à aucun autre relevé de la machine. +func debutDeTranche(t time.Time, g Granularite) time.Time { + t = t.Local() + jour := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + switch g { + case ParSemaine: + recul := (int(jour.Weekday()) + 6) % 7 + return jour.AddDate(0, 0, -recul) + case ParMois: + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) + } + return jour +} + +func libelleDeTranche(t time.Time, g Granularite) string { + switch g { + case ParSemaine: + an, sem := t.ISOWeek() + return fmt.Sprintf("%d-S%02d", an, sem) + case ParMois: + return t.Format("2006-01") + } + return t.Format("2006-01-02") +} + +// TranchesStore groupe les objets du magasin par âge, du plus récent au plus +// ancien. +func (s *Store) Tranches(g Granularite) []Tranche { + par := map[time.Time]*Tranche{} + filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if horsCasier(s.Dir, p, info) { + return filepath.SkipDir + } + if info.IsDir() || filepath.Ext(p) != ".body" { + return nil + } + d := debutDeTranche(info.ModTime(), g) + tr, ok := par[d] + if !ok { + tr = &Tranche{Debut: d, Libelle: libelleDeTranche(d, g)} + par[d] = tr + } + tr.Objets++ + tr.Octets += info.Size() + return nil + }) + return trier(par) +} + +// TranchesMiroirs fait de même pour les dépôts git. +// +// Un dépôt entier par tranche, pas ses fichiers : ce qui s'efface est un +// dépôt, et le compter au fichier donnerait un relevé qu'on ne peut pas +// suivre d'un geste. +func (g *GitMirror) Tranches(gran Granularite) []Tranche { + if g == nil || g.Dir == "" { + return nil + } + par := map[time.Time]*Tranche{} + for _, d := range g.Depots() { + debut := debutDeTranche(d.Maj, gran) + tr, ok := par[debut] + if !ok { + tr = &Tranche{Debut: debut, Libelle: libelleDeTranche(debut, gran)} + par[debut] = tr + } + tr.Objets++ + tr.Octets += d.Octets + } + return trier(par) +} + +func trier(par map[time.Time]*Tranche) []Tranche { + out := make([]Tranche, 0, len(par)) + for _, tr := range par { + out = append(out, *tr) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Debut.After(out[j].Debut) + }) + return out +} + +// Purger efface les objets dont le dernier usage précède `avant`. +// +// Le méta part AVEC le corps : un méta orphelin ferait croire à une copie +// présente, et la lecture échouerait au moment de servir, c'est-à-dire au pire +// moment. Rend (objets effacés, octets rendus). +func (s *Store) Purger(avant time.Time) (int, int64, error) { + n, octets := 0, int64(0) + err := filepath.Walk(s.Dir, func( + p string, info os.FileInfo, err error, + ) error { + if err != nil || info == nil { + return nil + } + if horsCasier(s.Dir, p, info) { + return filepath.SkipDir + } + if info.IsDir() || filepath.Ext(p) != ".body" { + return nil + } + if !info.ModTime().Before(avant) { + return nil + } + taille := info.Size() + if e := os.Remove(p); e != nil { + return nil + } + os.Remove(strings.TrimSuffix(p, ".body") + ".meta") + n++ + octets += taille + return nil + }) + return n, octets, err +} + +// PurgerMiroirs efface les dépôts qui n'ont pas été rafraîchis depuis +// `avant`. Rend (dépôts effacés, octets rendus). +func (g *GitMirror) PurgerMiroirs(avant time.Time) (int, int64, error) { + if g == nil || g.Dir == "" { + return 0, 0, nil + } + n, octets := 0, int64(0) + for _, d := range g.Depots() { + if !d.Maj.Before(avant) { + continue + } + if err := g.Retirer(d.Chemin); err != nil { + return n, octets, err + } + n++ + octets += d.Octets + } + return n, octets, nil +} + +// LireDuree accepte les suffixes de Go et, en plus, « j » pour un jour. +// +// « 30d » n'existe pas dans la bibliothèque, et un opérateur qui nettoie +// raisonne en jours, pas en sept cent vingt heures. +func LireDuree(s string) (time.Duration, error) { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return 0, fmt.Errorf("durée vide") + } + if n, unite := s[:len(s)-1], s[len(s)-1]; unite == 'j' || unite == 'd' { + var jours float64 + if _, err := fmt.Sscanf(n, "%g", &jours); err != nil || jours < 0 { + return 0, fmt.Errorf("durée illisible %q", s) + } + return time.Duration(jours * 24 * float64(time.Hour)), nil + } + d, err := time.ParseDuration(s) + if err != nil || d < 0 { + return 0, fmt.Errorf( + "durée illisible %q : essayer 30j, 12h, 90m", s) + } + return d, nil +} + +// printAge écrit le relevé par âge : les objets, puis les dépôts. +// +// Les deux SÉPARÉMENT : ils ne s'effacent pas de la même façon — un objet est +// un fichier, un dépôt est un arbre — et un total commun cacherait qu'un seul +// dépôt pèse plus que mille objets. +func printAge(store *Store, miroir *GitMirror, gran Granularite) { + fmt.Printf("Âge du dernier usage, par %s.\n", gran) + fmt.Printf( + "\nUn objet servi voit sa date remise à jour : « vieux » veut donc " + + "dire\n« n'a plus servi », et non « est entré il y a " + + "longtemps ».\n") + + ecrire := func(titre string, tranches []Tranche, quoi string) { + fmt.Printf("\n%s\n", titre) + if len(tranches) == 0 { + fmt.Println(" rien") + return + } + var n int + var octets int64 + for _, tr := range tranches { + fmt.Printf(" %-12s %6d %-8s %10s\n", + tr.Libelle, tr.Objets, quoi, HumanBytes(tr.Octets)) + n += tr.Objets + octets += tr.Octets + } + fmt.Printf(" %-12s %6d %-8s %10s\n", "total", n, quoi, + HumanBytes(octets)) + } + ecrire("Objets du cache :", store.Tranches(gran), "objets") + if miroir.Actif() { + ecrire("Dépôts en miroir :", miroir.Tranches(gran), "dépôts") + } +} + +// printPurgeABlanc dit ce qu'une purge emporterait, sans rien effacer. +// +// Une purge ne se rattrape pas — les octets sont rendus, il faut les +// retélécharger — et c'est le genre de geste qu'on veut relire avant. +func printPurgeABlanc(store *Store, miroir *GitMirror, avant time.Time) { + var n int + var octets int64 + for _, tr := range store.Tranches(ParJour) { + if tr.Debut.Before(avant) { + n += tr.Objets + octets += tr.Octets + } + } + fmt.Printf("[à blanc] objets qui partiraient : %d, %s\n", + n, HumanBytes(octets)) + + var nd int + var octd int64 + for _, d := range miroir.Depots() { + if d.Maj.Before(avant) { + nd++ + octd += d.Octets + } + } + fmt.Printf("[à blanc] dépôts qui partiraient : %d, %s\n", + nd, HumanBytes(octd)) + fmt.Println("[à blanc] rien n'a été effacé.") +} diff --git a/script/qemu_cache/age_test.go b/script/qemu_cache/age_test.go new file mode 100644 index 0000000..bbca0f3 --- /dev/null +++ b/script/qemu_cache/age_test.go @@ -0,0 +1,204 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +func TestLireGranularite(t *testing.T) { + for _, cas := range []struct { + brut string + attendue Granularite + }{ + {"jour", ParJour}, {"day", ParJour}, {"J", ParJour}, + {"semaine", ParSemaine}, {"week", ParSemaine}, {" W ", ParSemaine}, + {"mois", ParMois}, {"month", ParMois}, {"m", ParMois}, + } { + g, err := LireGranularite(cas.brut) + if err != nil || g != cas.attendue { + t.Errorf("%q → %q, %v", cas.brut, g, err) + } + } + if _, err := LireGranularite("trimestre"); err == nil { + t.Error("une granularité inconnue est acceptée") + } +} + +// « 30d » n'existe pas dans la bibliothèque, et un opérateur qui nettoie +// raisonne en jours, pas en sept cent vingt heures. +func TestLireDuree(t *testing.T) { + for _, cas := range []struct { + brut string + attendue time.Duration + }{ + {"30j", 30 * 24 * time.Hour}, + {"7d", 7 * 24 * time.Hour}, + {"12h", 12 * time.Hour}, + {"90m", 90 * time.Minute}, + {"0.5j", 12 * time.Hour}, + } { + d, err := LireDuree(cas.brut) + if err != nil || d != cas.attendue { + t.Errorf("%q → %v, %v (attendu %v)", cas.brut, d, err, cas.attendue) + } + } + for _, brut := range []string{"", "3lunes", "-5j", "abc"} { + if d, err := LireDuree(brut); err == nil { + t.Errorf("%q accepté et rendu %v", brut, d) + } + } +} + +// La semaine commence le LUNDI : c'est la convention ISO, celle des outils du +// système. La ramener au dimanche donnerait des tranches qui ne correspondent +// à aucun autre relevé de la machine. +func TestLaSemaineCommenceLundi(t *testing.T) { + // Un mercredi, et le dimanche qui le suit. + mercredi := time.Date(2026, 9, 2, 15, 0, 0, 0, time.Local) + dimanche := time.Date(2026, 9, 6, 15, 0, 0, 0, time.Local) + a := debutDeTranche(mercredi, ParSemaine) + b := debutDeTranche(dimanche, ParSemaine) + if !a.Equal(b) { + t.Errorf("mercredi et le dimanche suivant tombent dans deux"+ + " semaines : %s contre %s", a, b) + } + if a.Weekday() != time.Monday { + t.Errorf("la semaine commence un %s", a.Weekday()) + } +} + +// magasinDate pose un objet du magasin avec une date choisie. +func magasinDate(t *testing.T, s *Store, cle string, quand time.Time, n int) { + t.Helper() + meta, corps := s.paths(cle) + if err := os.MkdirAll(filepath.Dir(corps), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(corps, make([]byte, n), 0o644); err != nil { + t.Fatal(err) + } + // La taille est écrite dans le méta : le magasin refuse un corps dont + // elle ne correspond plus, y voyant une écriture interrompue. + if err := os.WriteFile( + meta, []byte(fmt.Sprintf(`{"size":%d}`, n)), 0o644, + ); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(corps, quand, quand); err != nil { + t.Fatal(err) + } +} + +func TestTranchesGroupentParAge(t *testing.T) { + s := &Store{Dir: t.TempDir()} + maintenant := time.Now() + magasinDate(t, s, "aaaa1111", maintenant, 100) + magasinDate(t, s, "bbbb2222", maintenant, 200) + magasinDate(t, s, "cccc3333", maintenant.AddDate(0, 0, -40), 300) + + jours := s.Tranches(ParJour) + if len(jours) != 2 { + t.Fatalf("%d tranches par jour : %+v", len(jours), jours) + } + // Du plus récent au plus ancien : c'est l'ordre de lecture. + if !jours[0].Debut.After(jours[1].Debut) { + t.Error("les tranches ne descendent pas dans le temps") + } + if jours[0].Objets != 2 || jours[0].Octets != 300 { + t.Errorf("tranche du jour : %+v", jours[0]) + } + if n := len(s.Tranches(ParMois)); n != 2 { + t.Errorf("%d tranches par mois, deux attendues", n) + } +} + +// Le méta part AVEC le corps : un méta orphelin ferait croire à une copie +// présente, et la lecture échouerait au moment de servir. +func TestPurgerEmporteLeMetaAvecLeCorps(t *testing.T) { + s := &Store{Dir: t.TempDir()} + vieux := time.Now().AddDate(0, 0, -40) + magasinDate(t, s, "aaaa1111", vieux, 500) + magasinDate(t, s, "bbbb2222", time.Now(), 700) + + n, octets, err := s.Purger(time.Now().AddDate(0, 0, -30)) + if err != nil { + t.Fatal(err) + } + if n != 1 || octets != 500 { + t.Fatalf("purge : %d objets, %d octets", n, octets) + } + meta, corps := s.paths("aaaa1111") + for _, f := range []string{meta, corps} { + if _, err := os.Stat(f); !os.IsNotExist(err) { + t.Errorf("%s subsiste", filepath.Base(f)) + } + } + // Et le récent est INTACT : une purge par âge ne touche pas au reste. + if _, f, err := s.Get("bbbb2222"); err != nil { + t.Errorf("l'objet récent a été emporté : %v", err) + } else { + f.Close() + } +} + +func TestPurgerToutNeLaisseRien(t *testing.T) { + s := &Store{Dir: t.TempDir()} + for i, cle := range []string{"aaaa1111", "bbbb2222", "cccc3333"} { + magasinDate(t, s, cle, time.Now().AddDate(0, 0, -i), 100) + } + if n, _, err := s.Purger(time.Now().Add(time.Minute)); err != nil || + n != 3 { + t.Fatalf("purge totale : %d objets, %v", n, err) + } + if st, err := s.Stat(); err != nil || st.Objects != 0 { + t.Errorf("il reste %d objets", st.Objects) + } +} + +// L'âge d'un objet est celui de son dernier USAGE, pas de son entrée. +// +// Sans cela, « nettoyer ce qui ne sert plus » emporterait un paquet servi tous +// les jours depuis un an, et il faudrait le retélécharger le lendemain — le +// contraire de ce qu'un cache est là pour faire. +func TestServirRajeunitUnObjet(t *testing.T) { + a := nouvelAmont(t, "charge utile") + p := proxyDeTest(t) + chemin := "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst" + + if w := demande(t, p, a.hote(), chemin); w.Code != 200 { + t.Fatalf("premier appel : %d", w.Code) + } + // L'objet est vieilli à la main, comme s'il dormait depuis quarante jours. + var corps string + filepath.Walk(p.Store.Dir, func(q string, i os.FileInfo, e error) error { + if e == nil && i != nil && filepath.Ext(q) == ".body" { + corps = q + } + return nil + }) + if corps == "" { + t.Fatal("aucun objet gardé") + } + vieux := time.Now().AddDate(0, 0, -40) + if err := os.Chtimes(corps, vieux, vieux); err != nil { + t.Fatal(err) + } + + if w := demande(t, p, a.hote(), chemin); w.Code != 200 { + t.Fatalf("second appel : %d", w.Code) + } + info, err := os.Stat(corps) + if err != nil { + t.Fatal(err) + } + if time.Since(info.ModTime()) > time.Minute { + t.Errorf("l'objet servi garde sa vieille date (%s) : un nettoyage"+ + " par âge l'emporterait alors qu'il sert", info.ModTime()) + } +} diff --git a/script/qemu_cache/boucle.go b/script/qemu_cache/boucle.go new file mode 100644 index 0000000..da283f3 --- /dev/null +++ b/script/qemu_cache/boucle.go @@ -0,0 +1,146 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Le cache écoute sur toutes les adresses de l'hôte. Une requête qui le +// désigne LUI — son port, sur une adresse de cette machine — serait relayée +// vers sa propre écoute, qui la relaierait à son tour : chaque tour ouvre une +// connexion de plus, et la boucle épuise les descripteurs jusqu'à arrêter le +// service entier. Ce fichier la reconnaît et la refuse. + +// viseLeCache dit si une URL désigne le cache lui-même. +// +// Le port d'abord : il ne coûte rien, et seul un port d'écoute du cache peut +// boucler. L'hôte ensuite — « localhost », une adresse de bouclage, une +// adresse non spécifiée ou celle d'une interface de cette machine. Un NOM +// n'est résolu que dans ce cas rare où le port concorde : le résoudre à +// chaque requête coûterait un aller-retour DNS à tout le trafic. +func (p *Proxy) viseLeCache(u *url.URL) bool { + if p == nil || len(p.Ecoutes) == 0 || u == nil { + return false + } + port := portDe(u) + concorde := false + for _, e := range p.Ecoutes { + if e == port { + concorde = true + break + } + } + if !concorde { + return false + } + hote := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + if hote == "localhost" || strings.HasSuffix(hote, ".localhost") { + return true + } + if ip := net.ParseIP(hote); ip != nil { + return estAdresseLocale(ip) + } + ctx, annule := context.WithTimeout(context.Background(), 2*time.Second) + defer annule() + ips, err := net.DefaultResolver.LookupIPAddr(ctx, hote) + if err != nil { + return false + } + for _, a := range ips { + if estAdresseLocale(a.IP) { + return true + } + } + return false +} + +// portDe rend le port d'une URL, déduit du schéma quand elle n'en porte pas, +// et -1 quand il ne se déduit pas. +func portDe(u *url.URL) int { + if brut := u.Port(); brut != "" { + n, err := strconv.Atoi(brut) + if err != nil { + return -1 + } + return n + } + switch strings.ToLower(u.Scheme) { + case "https": + return 443 + case "http": + return 80 + } + return -1 +} + +// estAdresseLocale dit si une adresse joint cette machine : bouclage, +// adresse non spécifiée — qu'une connexion traite comme l'hôte local — ou +// adresse de l'une de ses interfaces. +func estAdresseLocale(ip net.IP) bool { + if ip.IsLoopback() || ip.IsUnspecified() { + return true + } + adresses, err := net.InterfaceAddrs() + if err != nil { + return false + } + for _, a := range adresses { + if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) { + return true + } + } + return false +} + +// boucle répond « 508 Loop Detected » sans rien demander à personne, et le +// journal le compte comme une erreur. Le message est inerte pour un shell, +// comme celui du hors-ligne : il peut finir dans « curl … | bash ». +func (p *Proxy) boucle( + w http.ResponseWriter, u *url.URL, class Class, method, client string, +) { + msg := enCommentaire(fmt.Sprintf( + "erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n"+ + " demandé : %s\n"+ + "La relayer la renverrait vers cette même écoute, sans fin.\n", + u)) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-ERPLibre-Cache", OutcomeError) + w.WriteHeader(http.StatusLoopDetected) + fmt.Fprint(w, msg) + + p.record(accessLine{ + Method: method, URL: u.String(), Class: class.String(), + Outcome: OutcomeError, Status: http.StatusLoopDetected, + Client: client, + }) + log.Printf("boucle refusée : %s vise le cache lui-même", u) +} + +// memeAdresse compare deux « hôte:port » par leur VALEUR : « ::ffff:a.b.c.d » +// et « a.b.c.d » désignent la même machine, et une comparaison de textes les +// séparerait. +func memeAdresse(a, b string) bool { + ha, pa, err := net.SplitHostPort(a) + if err != nil { + return false + } + hb, pb, err := net.SplitHostPort(b) + if err != nil || pa != pb { + return false + } + ia, ib := net.ParseIP(ha), net.ParseIP(hb) + if ia == nil || ib == nil { + return strings.EqualFold(ha, hb) + } + return ia.Equal(ib) +} diff --git a/script/qemu_cache/boucle_test.go b/script/qemu_cache/boucle_test.go new file mode 100644 index 0000000..5f5f48a --- /dev/null +++ b/script/qemu_cache/boucle_test.go @@ -0,0 +1,236 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +// portDeLAmont rend le port d'un amont de test, sous ses deux formes. +func portDeLAmont(t *testing.T, a *amontScripte) (int, string) { + t.Helper() + _, brut, err := net.SplitHostPort(a.hote()) + if err != nil { + t.Fatal(err) + } + n, err := strconv.Atoi(brut) + if err != nil { + t.Fatal(err) + } + return n, brut +} + +// Une requête qui vise le cache lui-même est refusée sans être relayée : la +// relayer la renverrait vers cette même écoute, qui la relaierait encore. +// +// L'amont de test JOUE le cache : son port est déclaré comme écoute. Un seul +// appel reçu prouve que la requête est partie. +func TestUneRequeteAuCacheLuiMemeEstRefusee(t *testing.T) { + soi := nouvelAmontScripte(t, servir("ne doit jamais répondre")) + port, brut := portDeLAmont(t, soi) + p := proxyDeTest(t) + journal := journalDeTest(t, p) + p.Ecoutes = []int{port} + + for _, hote := range []string{"127.0.0.1:" + brut, "localhost:" + brut} { + w := joue(t, p, "GET", hote, "/boucle") + if w.Code != http.StatusLoopDetected { + t.Fatalf("%s : code %d, attendu 508", hote, w.Code) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeError { + t.Errorf("%s : servi « %s », attendu « %s »", hote, got, OutcomeError) + } + for _, l := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") { + if !strings.HasPrefix(l, "#") { + t.Errorf("ligne exécutable par un shell : %q", l) + } + } + } + if n := soi.appels(); n != 0 { + t.Errorf("la requête est partie %d fois vers le cache lui-même", n) + } + for _, l := range lignesDuJournal(t, journal) { + if l.Outcome != OutcomeError || l.Status != http.StatusLoopDetected { + t.Errorf("journal : %s %d, attendu error 508", l.Outcome, l.Status) + } + } + + // Un autre port de la même machine reste un amont comme un autre. + autre := nouvelAmontScripte(t, servir("amont légitime")) + if w := joue(t, p, "GET", autre.hote(), "/fichier"); w.Code != http.StatusOK { + t.Errorf("un amont local sur un autre port est refusé : %d", w.Code) + } +} + +// Une redirection qui pointe vers le cache n'est pas suivie par lui. +func TestUneRedirectionVersLeCacheNEstPasSuivie(t *testing.T) { + soi := nouvelAmontScripte(t, servir("ne doit jamais répondre")) + port, brut := portDeLAmont(t, soi) + pub := nouvelAmontScripte(t, rediriger(http.StatusFound, + "http://127.0.0.1:"+brut+"/outil_1.0_amd64.deb")) + p := proxyDeTest(t) + p.Ecoutes = []int{port} + + w := joue(t, p, "GET", pub.hote(), "/outil_1.0_amd64.deb") + if w.Code != http.StatusFound { + t.Errorf("code %d, attendu la redirection rendue au client", w.Code) + } + if n := soi.appels(); n != 0 { + t.Errorf("la redirection a été suivie %d fois vers le cache", n) + } +} + +func TestViseLeCache(t *testing.T) { + p := &Proxy{Ecoutes: []int{8898, 8899}} + cas := map[string]bool{ + "http://127.0.0.1:8898/x": true, + "http://localhost:8898/x": true, + "https://LOCALHOST.:8899/x": true, + "http://[::1]:8899/x": true, + "http://0.0.0.0:8898/x": true, + "http://127.0.0.1:8897/x": false, + "http://192.0.2.10:8898/x": false, + "https://miroir.example/x.deb": false, + } + for brut, attendu := range cas { + u, _ := url.Parse(brut) + if got := p.viseLeCache(u); got != attendu { + t.Errorf("%s : %v, attendu %v", brut, got, attendu) + } + } + // Une adresse d'une interface de CETTE machine joint aussi l'écoute. + if adresses, err := net.InterfaceAddrs(); err == nil { + for _, a := range adresses { + n, ok := a.(*net.IPNet) + if !ok || n.IP.IsLoopback() || n.IP.To4() == nil { + continue + } + u, _ := url.Parse("http://" + n.IP.String() + ":8898/x") + if !p.viseLeCache(u) { + t.Errorf("%s, adresse de cette machine, n'est pas reconnue", u) + } + break + } + } + // Sans écoute déclarée, rien n'est refusé. + u, _ := url.Parse("http://127.0.0.1:8898/x") + if (&Proxy{}).viseLeCache(u) { + t.Error("un proxy sans écoute déclarée refuse une requête") + } +} + +func TestMemeAdresse(t *testing.T) { + cas := []struct { + a, b string + attendu bool + }{ + {"127.0.0.1:8899", "127.0.0.1:8899", true}, + {"127.0.0.1:8899", "[::ffff:127.0.0.1]:8899", true}, + {"127.0.0.1:8899", "127.0.0.1:8898", false}, + {"192.0.2.1:443", "127.0.0.1:443", false}, + {"pas une adresse", "127.0.0.1:443", false}, + } + for _, c := range cas { + if got := memeAdresse(c.a, c.b); got != c.attendu { + t.Errorf("%s / %s : %v, attendu %v", c.a, c.b, got, c.attendu) + } + } +} + +// ecouteComptee compte dans Accept et rend la socket TCP BRUTE : une +// connexion enveloppée ferait échouer originalDst avant le tunnel, et le test +// ne prouverait plus rien. Au plafond, elle se ferme : une boucle doit rester +// bornée, pour que l'échec du test ne fasse pas tomber la machine qui le +// lance. +type ecouteComptee struct { + *net.TCPListener + acceptees int64 + plafond int64 +} + +func (l *ecouteComptee) Accept() (net.Conn, error) { + c, err := l.AcceptTCP() + if err != nil { + return nil, err + } + if atomic.AddInt64(&l.acceptees, 1) >= l.plafond { + l.TCPListener.Close() + } + return c, nil +} + +// sondeOriginalDst saute le test là où le noyau ne rend pas, pour une +// connexion locale non détournée, l'écoute elle-même comme destination +// d'origine : sans suivi de connexion, originalDst échoue avant le tunnel et +// la garde n'a rien à garder. +func sondeOriginalDst(t *testing.T) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + client, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + c, err := ln.Accept() + if err != nil { + t.Fatal(err) + } + defer c.Close() + dst, err := originalDst(c) + if err != nil || !memeAdresse(dst, ln.Addr().String()) { + t.Skipf("destination d'origine indisponible ici (%q, %v)", dst, err) + } +} + +// Une connexion non détournée vers le front TLS, pour un hôte en tunnel, a +// pour destination d'origine l'écoute elle-même. Le tunnel la rappelait : +// chaque tour acceptait une connexion de plus. +func TestUnTunnelNeSeRappellePasLuiMeme(t *testing.T) { + sondeOriginalDst(t) + brut, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ln := &ecouteComptee{TCPListener: brut.(*net.TCPListener), plafond: 16} + t.Cleanup(func() { ln.Close() }) + + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatal(err) + } + const hote = "tunnel.example.invalid" + front := &TLSFront{ + CA: ca, Proxy: proxyDeTest(t), Refusals: NewRefusals([]string{hote}), + } + go front.Serve(ln) + + c, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err := c.Write(clientHelloBrut(t, hote)); err != nil { + t.Fatal(err) + } + c.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, err := io.ReadAll(c); err != nil { + t.Errorf("la connexion n'est pas refermée : %v", err) + } + if n := atomic.LoadInt64(&ln.acceptees); n != 1 { + t.Errorf("%d connexions acceptées pour une seule demande :"+ + " le tunnel s'est rappelé lui-même", n) + } +} diff --git a/script/qemu_cache/bypass.go b/script/qemu_cache/bypass.go new file mode 100644 index 0000000..fb297c4 --- /dev/null +++ b/script/qemu_cache/bypass.go @@ -0,0 +1,253 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "bufio" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "strings" +) + +// Le détournement est TRANSPARENT : il vaut pour tout ce qui sort du pont, et +// une VM n'a aucun moyen de s'y soustraire depuis l'intérieur. Ne pas lui +// donner l'autorité ne la dispense pas d'être interceptée — cela lui fait +// seulement refuser un certificat qu'elle ne reconnaît pas, et échouer sur +// « self-signed certificate in certificate chain ». +// +// Une exception doit donc être posée sur l'HÔTE, avant que la VM démarre, et +// elle a besoin d'un identifiant que la VM porte de façon stable. L'adresse +// IP ne convient pas : elle vient d'un bail qui change. L'adresse MAC est +// fixée dans la définition du domaine et ne bouge plus. +// +// Elle est tenue dans un ensemble nommé nftables plutôt que dans les règles +// elles-mêmes : un élément s'ajoute et se retire à chaud, sans reposer le +// jeu de règles, donc sans couper les téléchargements des autres VM. + +// BypassSetName : le nom de l'ensemble à l'intérieur de la table. +const BypassSetName = "bypass" + +// BypassEntry associe l'adresse MAC soustraite au nom de la VM qui la porte. +// +// Le nom n'entre dans aucune règle ; il existe pour que la liste soit +// relisible et pour que l'on sache quelle entrée retirer quand la VM meurt. +// Une MAC orpheline est le mode de défaillance de ce fichier : réattribuée à +// une VM neuve, elle la soustrairait au cache sans que personne l'ait +// demandé, et rien ne le dirait. +type BypassEntry struct { + MAC string + Name string +} + +// NormalizeMAC rend la forme canonique — six octets, minuscules, +// deux-points — ou dit pourquoi l'entrée ne peut pas en être une. +// +// Deux refus qui ne sont pas des caprices. L'adresse nulle correspond à tout +// ce qui n'a pas d'adresse et n'identifie donc rien. Une adresse de diffusion +// ou de groupe — bit de poids faible du premier octet à 1 — ne peut JAMAIS +// apparaître comme adresse SOURCE : l'entrée serait morte à l'écriture, et +// une exception qui ne s'applique jamais est pire qu'absente, puisqu'on la +// croit posée. +func NormalizeMAC(s string) (string, error) { + brut := strings.TrimSpace(s) + if brut == "" { + return "", fmt.Errorf("adresse MAC vide") + } + // Douze caractères hexadécimaux sans séparateur : la forme que rendent + // plusieurs outils, que net.ParseMAC ne lit pas. + if len(brut) == 12 && !strings.ContainsAny(brut, ":-.") { + var parts []string + for i := 0; i < 12; i += 2 { + parts = append(parts, brut[i:i+2]) + } + brut = strings.Join(parts, ":") + } + adr, err := net.ParseMAC(brut) + if err != nil { + return "", fmt.Errorf("adresse MAC illisible %q", s) + } + if len(adr) != 6 { + return "", fmt.Errorf("adresse MAC de %d octets, six attendus", len(adr)) + } + if adr[0]&1 == 1 { + return "", fmt.Errorf( + "%s est une adresse de groupe : jamais une adresse source", adr) + } + nulle := true + for _, o := range adr { + if o != 0 { + nulle = false + break + } + } + if nulle { + return "", fmt.Errorf("l'adresse nulle n'identifie aucune machine") + } + return strings.ToLower(adr.String()), nil +} + +// BypassFile est la liste DURABLE. L'ensemble nftables vit dans le noyau et +// disparaît avec la table ; ce fichier est ce que le service relit à chaque +// démarrage pour reposer les mêmes exceptions. +type BypassFile struct { + Path string +} + +// Load rend les entrées, triées par MAC. Un fichier absent n'est pas une +// erreur : c'est l'état d'une installation où personne n'a rien excepté. +// +// Une ligne illisible est SAUTÉE et non fatale. Le fichier est éditable à la +// main, et refuser de démarrer le service pour une faute de frappe dans une +// exception coûterait plus que d'ignorer la ligne. +func (b BypassFile) Load() ([]BypassEntry, error) { + fh, err := os.Open(b.Path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer fh.Close() + + vues := map[string]bool{} + var out []BypassEntry + sc := bufio.NewScanner(fh) + for sc.Scan() { + ligne := strings.TrimSpace(sc.Text()) + if i := strings.IndexByte(ligne, '#'); i >= 0 { + ligne = strings.TrimSpace(ligne[:i]) + } + if ligne == "" { + continue + } + champs := strings.Fields(ligne) + mac, err := NormalizeMAC(champs[0]) + if err != nil || vues[mac] { + continue + } + vues[mac] = true + nom := "" + if len(champs) > 1 { + nom = strings.Join(champs[1:], " ") + } + out = append(out, BypassEntry{MAC: mac, Name: nom}) + } + if err := sc.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].MAC < out[j].MAC }) + return out, nil +} + +// Save réécrit le fichier en entier, par un remplacement atomique : une +// écriture coupée en deux laisserait une liste tronquée que le prochain +// démarrage prendrait pour la vérité. +func (b BypassFile) Save(entries []BypassEntry) error { + if err := os.MkdirAll(filepath.Dir(b.Path), 0o755); err != nil { + return err + } + var sb strings.Builder + sb.WriteString("# Exceptions du cache de téléchargement des VM QEMU.\n") + sb.WriteString("# Une ligne « » par machine que le\n") + sb.WriteString("# détournement doit ignorer. Relu à chaque démarrage du\n") + sb.WriteString("# service ; une MAC dont la VM n'existe plus est à retirer.\n") + for _, e := range entries { + if e.Name == "" { + sb.WriteString(e.MAC + "\n") + continue + } + fmt.Fprintf(&sb, "%s %s\n", e.MAC, e.Name) + } + tmp := b.Path + ".part" + if err := os.WriteFile(tmp, []byte(sb.String()), 0o644); err != nil { + return err + } + return os.Rename(tmp, b.Path) +} + +// Add pose une exception et rend la MAC canonique. Reposer une MAC déjà +// présente met son nom à jour au lieu de créer un doublon : le déploiement +// est relancé, et deux lignes pour une machine rendraient le retrait +// incomplet. +func (b BypassFile) Add(mac, nom string) (string, error) { + canon, err := NormalizeMAC(mac) + if err != nil { + return "", err + } + entries, err := b.Load() + if err != nil { + return "", err + } + remplacee := false + for i := range entries { + if entries[i].MAC == canon { + entries[i].Name = nom + remplacee = true + break + } + } + if !remplacee { + entries = append(entries, BypassEntry{MAC: canon, Name: nom}) + sort.Slice(entries, func(i, j int) bool { + return entries[i].MAC < entries[j].MAC + }) + } + return canon, b.Save(entries) +} + +// Del retire une exception. Rend faux si elle n'y était pas — retirer deux +// fois n'est pas une erreur, mais l'appelant doit pouvoir le dire. +func (b BypassFile) Del(mac string) (string, bool, error) { + canon, err := NormalizeMAC(mac) + if err != nil { + return "", false, err + } + entries, err := b.Load() + if err != nil { + return "", false, err + } + var reste []BypassEntry + trouvee := false + for _, e := range entries { + if e.MAC == canon { + trouvee = true + continue + } + reste = append(reste, e) + } + if !trouvee { + return canon, false, nil + } + return canon, true, b.Save(reste) +} + +// MACs rend les seules adresses, dans l'ordre du fichier. +func MACs(entries []BypassEntry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.MAC) + } + return out +} + +// BypassAddElement et BypassDelElement rendent le geste À CHAUD, à passer à +// « nft -f - ». Modifier l'ensemble ne repose pas les règles : les +// téléchargements des autres VM ne sont pas coupés, ce qu'un redémarrage du +// service ferait. +// +// Comme le reste de ce paquet, ces lignes sont RENDUES et jamais exécutées : +// l'appelant les applique, et un test les vérifie sans toucher au pare-feu de +// la machine qui l'exécute. +func BypassAddElement(mac string) string { + return fmt.Sprintf("add element ip %s %s { %s }", + TableName, BypassSetName, mac) +} + +func BypassDelElement(mac string) string { + return fmt.Sprintf("delete element ip %s %s { %s }", + TableName, BypassSetName, mac) +} diff --git a/script/qemu_cache/bypass_test.go b/script/qemu_cache/bypass_test.go new file mode 100644 index 0000000..ed92720 --- /dev/null +++ b/script/qemu_cache/bypass_test.go @@ -0,0 +1,186 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeMAC(t *testing.T) { + bons := map[string]string{ + "52:54:00:AA:BB:CC": "52:54:00:aa:bb:cc", + "52-54-00-aa-bb-cc": "52:54:00:aa:bb:cc", + "525400aabbcc": "52:54:00:aa:bb:cc", + " 52:54:00:01:02:03 ": "52:54:00:01:02:03", + } + for brut, attendu := range bons { + got, err := NormalizeMAC(brut) + if err != nil { + t.Errorf("%q refusée : %v", brut, err) + continue + } + if got != attendu { + t.Errorf("%q → %q, attendu %q", brut, got, attendu) + } + } +} + +// Deux refus qui protègent d'une exception morte à l'écriture. +func TestNormalizeMACRefuse(t *testing.T) { + for _, brut := range []string{ + "", + "pas une adresse", + "52:54:00:aa:bb", // cinq octets + "52:54:00:aa:bb:cc:dd:ee", // huit : une adresse InfiniBand + "00:00:00:00:00:00", // n'identifie rien + "ff:ff:ff:ff:ff:ff", // diffusion : jamais une source + "01:00:5e:00:00:01", // groupe : jamais une source + } { + if got, err := NormalizeMAC(brut); err == nil { + t.Errorf("%q acceptée et rendue %q", brut, got) + } + } +} + +func fichierNeuf(t *testing.T) BypassFile { + t.Helper() + return BypassFile{Path: filepath.Join(t.TempDir(), "bypass")} +} + +// Un fichier absent est l'état normal d'une installation neuve, pas une +// panne : le service doit démarrer. +func TestChargerUnFichierAbsent(t *testing.T) { + b := BypassFile{Path: filepath.Join(t.TempDir(), "jamais-ecrit")} + entries, err := b.Load() + if err != nil { + t.Fatalf("un fichier absent lève : %v", err) + } + if len(entries) != 0 { + t.Errorf("%d entrées sorties de rien", len(entries)) + } +} + +func TestAjouterPuisRetirer(t *testing.T) { + b := fichierNeuf(t) + mac, err := b.Add("52:54:00:AA:BB:CC", "vm-essai") + if err != nil { + t.Fatalf("ajout : %v", err) + } + if mac != "52:54:00:aa:bb:cc" { + t.Errorf("MAC rendue %q", mac) + } + entries, _ := b.Load() + if len(entries) != 1 || entries[0].Name != "vm-essai" { + t.Fatalf("relecture : %+v", entries) + } + // La forme d'écriture ne doit pas décider du retrait. + _, avait, err := b.Del("525400AABBCC") + if err != nil || !avait { + t.Fatalf("retrait : avait=%v err=%v", avait, err) + } + entries, _ = b.Load() + if len(entries) != 0 { + t.Errorf("%d entrée(s) survivent au retrait", len(entries)) + } +} + +// Le déploiement est relancé : la même VM ne doit pas produire deux lignes, +// sans quoi un seul retrait laisserait l'exception en place. +func TestReposerLaMemeMACMetAJour(t *testing.T) { + b := fichierNeuf(t) + if _, err := b.Add("52:54:00:aa:bb:cc", "ancien"); err != nil { + t.Fatal(err) + } + if _, err := b.Add("52:54:00:AA:BB:CC", "neuf"); err != nil { + t.Fatal(err) + } + entries, _ := b.Load() + if len(entries) != 1 { + t.Fatalf("%d entrées pour une machine : %+v", len(entries), entries) + } + if entries[0].Name != "neuf" { + t.Errorf("nom resté %q", entries[0].Name) + } +} + +func TestRetirerCeQuiNyEstPas(t *testing.T) { + b := fichierNeuf(t) + mac, avait, err := b.Del("52:54:00:00:00:09") + if err != nil { + t.Fatalf("un retrait à vide lève : %v", err) + } + if avait { + t.Error("dit avoir retiré une exception absente") + } + if mac == "" { + t.Error("la MAC canonique doit être rendue pour le geste à chaud") + } +} + +// Le fichier est éditable à la main : une faute de frappe ne doit pas +// empêcher le service de démarrer, seulement perdre sa ligne. +func TestUneLigneIllisibleEstSautee(t *testing.T) { + b := fichierNeuf(t) + contenu := strings.Join([]string{ + "# un commentaire", + "", + "52:54:00:11:22:33 vm-une # en fin de ligne", + "ceci n'est pas une adresse", + "ff:ff:ff:ff:ff:ff diffusion", + "52:54:00:44:55:66", + "52:54:00:11:22:33 doublon", + }, "\n") + if err := os.WriteFile(b.Path, []byte(contenu), 0o644); err != nil { + t.Fatal(err) + } + entries, err := b.Load() + if err != nil { + t.Fatalf("lecture : %v", err) + } + if len(entries) != 2 { + t.Fatalf("%d entrées retenues : %+v", len(entries), entries) + } + if entries[0].MAC != "52:54:00:11:22:33" || entries[0].Name != "vm-une" { + t.Errorf("première entrée : %+v", entries[0]) + } + if entries[1].MAC != "52:54:00:44:55:66" { + t.Errorf("seconde entrée : %+v", entries[1]) + } +} + +// Le nom voyage avec la MAC : c'est lui qui dit quelle entrée retirer quand +// la VM meurt. Une entrée orpheline soustrairait au cache une machine neuve +// qui hériterait de la MAC, sans que personne l'ait demandé. +func TestLeNomSurvitAuxAllersRetours(t *testing.T) { + b := fichierNeuf(t) + if _, err := b.Add("52:54:00:77:88:99", "vm de démonstration"); err != nil { + t.Fatal(err) + } + entries, _ := b.Load() + if len(entries) != 1 || entries[0].Name != "vm de démonstration" { + t.Fatalf("nom perdu : %+v", entries) + } +} + +func TestGestesAChaud(t *testing.T) { + mac := "52:54:00:aa:bb:cc" + ajout := BypassAddElement(mac) + retrait := BypassDelElement(mac) + for _, l := range []string{ajout, retrait} { + if !strings.Contains(l, TableName) || + !strings.Contains(l, BypassSetName) || + !strings.Contains(l, mac) { + t.Errorf("geste incomplet : %q", l) + } + } + if !strings.HasPrefix(ajout, "add element") { + t.Errorf("ajout : %q", ajout) + } + if !strings.HasPrefix(retrait, "delete element") { + t.Errorf("retrait : %q", retrait) + } +} diff --git a/script/qemu_cache/classify.go b/script/qemu_cache/classify.go new file mode 100644 index 0000000..f333b12 --- /dev/null +++ b/script/qemu_cache/classify.go @@ -0,0 +1,233 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "net/url" + "path" + "regexp" + "strings" +) + +// Class dit ce que le cache a le droit de faire d'une réponse. +// +// La distinction porte tout l'outil. Un fichier de paquet est IMMUABLE : +// son nom porte sa version, son contenu ne change jamais, et le servir du +// disque est exactement le gain cherché. Un index de dépôt est VOLATILE : +// il nomme les versions qui existent en ce moment, et servir un index périmé +// fait échouer l'installation sur un fichier retiré des miroirs — « failed +// retrieving file … 404 », le mode de défaillance que la préparation d'un +// invité Arch documente déjà. +// +// D'où la règle : seul l'immuable est servi du cache tant que l'amont répond. +// Le volatile est STOCKÉ quand même, et ne ressort que si l'amont est +// injoignable, ce qui rend le déploiement hors ligne possible sans jamais +// ouvrir de fenêtre de péremption quand le réseau est là. +type Class int + +const ( + // ClassImmutable : servi du disque dès qu'il y est. + ClassImmutable Class = iota + // ClassVolatile : toujours pris à l'amont, stocké, servi hors ligne seul. + ClassVolatile + // ClassNoStore : ni servi ni stocké. + ClassNoStore +) + +func (c Class) String() string { + switch c { + case ClassImmutable: + return "immutable" + case ClassVolatile: + return "volatile" + default: + return "no-store" + } +} + +// Suffixes d'un fichier dont le nom porte sa version. L'ordre n'importe pas ; +// le premier qui correspond gagne. +var immutableSuffixes = []string{ + // paquets de distribution + ".pkg.tar.zst", ".pkg.tar.xz", ".deb", ".rpm", ".apk", + // signatures détachées, aussi figées que ce qu'elles signent + ".pkg.tar.zst.sig", ".pkg.tar.xz.sig", + // écosystèmes Python et Node + ".whl", ".tgz", + // images et supports d'installation + ".qcow2", ".iso", ".img", ".raw", ".vmdk", + // archives amont + ".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".zip", +} + +// Noms et suffixes d'un index de dépôt. Un « .db » d'Arch, un « InRelease » +// de Debian et un « repomd.xml » de Fedora décrivent tous l'état COURANT du +// miroir. +var volatileNames = []string{ + "inrelease", "release", "release.gpg", + "packages", "packages.gz", "packages.xz", "packages.bz2", + "sources", "sources.gz", "sources.xz", + "repomd.xml", "repomd.xml.asc", "repomd.xml.key", + "index.json", "index.html", +} + +var volatileSuffixes = []string{ + ".db", ".db.sig", ".db.tar.gz", ".files", ".files.tar.gz", + ".xml.gz", ".xml.zck", ".sqlite.bz2", ".sqlite.gz", +} + +// parEmpreinte reconnaît un index Debian publié sous l'empreinte de son +// contenu : « …/by-hash/SHA256/ ». Le nom EST la somme du +// contenu, si bien qu'un contenu différent porte un autre nom : le fichier est +// aussi figé qu'un paquet, quoiqu'il n'ait aucune extension. +// +// Il se range SANS son hôte, et c'est ce qui le distingue d'un index nommé +// par sa suite : deux miroirs qui servent ce chemin servent le même octet, +// ou l'un des deux est corrompu et son client le rejette sur la somme. Le +// raisonnement inverse — « le même chemin ne vaut que si le miroir publie la +// même suite » — vaut pour « Packages », dont le contenu varie d'un miroir à +// l'autre sous un nom identique ; il ne vaut pas pour un nom qui EST la somme +// de son contenu, et l'appliquer ici vide le cache de ses index dès qu'une +// liste de miroirs tourne. +var parEmpreinte = regexp.MustCompile( + `/by-hash/(MD5Sum|SHA1|SHA256|SHA512)/[0-9a-fA-F]{32,128}$`) + +// dernierePublication reconnaît « ///releases/latest/ +// download/ » : un POINTEUR vers la dernière version publiée, dont +// la cible change à chaque publication. +// +// Son suffixe — « .tar.gz », « .zip » — le ferait passer pour figé : servi du +// disque sans jamais redemander, il resterait à la première version vue, et +// rangé sans son hôte il répondrait pour n'importe quelle forge. Il est donc +// volatile et attaché à son hôte, et la règle passe AVANT les suffixes. +var dernierePublication = regexp.MustCompile( + `^/[^/]+/[^/]+/releases/latest/download/[^/]+$`) + +// Chemins du protocole « smart HTTP » de git. Ce sont des points de +// NÉGOCIATION : le serveur calcule sa réponse en fonction de ce que le client +// détient déjà. Rien n'y est réutilisable d'une requête à l'autre, et servir +// une réponse gardée y ferait croire à des références qui n'existent plus. +// +// Les fichiers du protocole « dumb », eux, restent cachables : un objet +// « .../objects/ab/cdef… » porte son empreinte dans son nom. +var gitSmartPaths = []string{ + "/info/refs", "/git-upload-pack", "/git-receive-pack", +} + +// EstGitSmart dit si l'URL vise l'un de ces points de négociation. +// +// Sert deux fois. Le contenu n'est ni gardé ni servi du cache. Et l'amont y a +// droit à BEAUCOUP plus de patience : un serveur git énumère ses références à +// la demande, ce qui prend des dizaines de secondes sur un dépôt chargé, là où +// un miroir de paquets répond en quelques centaines de millisecondes. +func EstGitSmart(u *url.URL) bool { + if u == nil { + return false + } + for _, s := range gitSmartPaths { + if strings.HasSuffix(u.Path, s) { + return true + } + } + return false +} + +// Classify tranche pour une URL, sans regarder la réponse : la décision doit +// être prise AVANT d'interroger l'amont, puisqu'elle décide s'il faut +// l'interroger. +// +// Le doute profite au volatile. Une URL inconnue est donc toujours reprise à +// l'amont quand il répond, et ne sert de copie que hors ligne : le pire cas +// est une requête inutile, jamais une réponse fausse. +func Classify(u *url.URL) Class { + if u == nil { + return ClassNoStore + } + if EstGitSmart(u) { + return ClassNoStore + } + name := strings.ToLower(path.Base(u.Path)) + + // Une requête portant une chaîne de requête décrit un appel d'API et non + // un fichier : deux paramètres différents rendent deux réponses, et son + // nom de fichier ne dit rien de son contenu. + if u.RawQuery != "" { + return ClassVolatile + } + // Les deux règles de CHEMIN l'emportent sur celles du nom : l'une porte un + // suffixe figé qui ment, l'autre n'en porte aucun et dit pourtant vrai. + if dernierePublication.MatchString(u.Path) { + return ClassVolatile + } + if parEmpreinte.MatchString(u.Path) { + return ClassImmutable + } + for _, n := range volatileNames { + if name == n { + return ClassVolatile + } + } + for _, s := range volatileSuffixes { + if strings.HasSuffix(name, s) { + return ClassVolatile + } + } + // L'index « simple » de PyPI est une page sans extension sous /simple/. + if strings.Contains(u.Path, "/simple/") { + return ClassVolatile + } + for _, s := range immutableSuffixes { + if strings.HasSuffix(name, s) { + return ClassImmutable + } + } + return ClassVolatile +} + +// PortableParChemin dit si le NOM du fichier suffit à l'identifier sur +// n'importe quel miroir de la même distribution. +// +// Vrai pour un paquet — sa version et son architecture sont dans son nom — et +// pour un index de dépôt, dont le chemin est le même partout. Faux pour le +// reste : « /index.html » n'identifie rien. +func PortableParChemin(u *url.URL) bool { + if u == nil || u.RawQuery != "" { + return false + } + if dernierePublication.MatchString(u.Path) { + return false + } + // Un objet adressé par son empreinte se range sans son hôte. La règle est + // POSITIVE parce qu'aucune des tables suivantes ne le reconnaîtrait : une + // somme hexadécimale n'a pas d'extension, et retirer la seule exclusion ne + // suffirait donc pas à le rendre portable. + if parEmpreinte.MatchString(u.Path) { + return true + } + name := strings.ToLower(path.Base(u.Path)) + for _, s := range immutableSuffixes { + if strings.HasSuffix(name, s) { + return true + } + } + for _, s := range volatileSuffixes { + if strings.HasSuffix(name, s) { + return true + } + } + for _, n := range volatileNames { + // « index.html » et « index.json » nomment n'importe quoi : ils sont + // dans la table des index, mais pas portables pour autant. + if name == n && !strings.HasPrefix(n, "index.") { + return true + } + } + return false +} + +// CacheableMethod : seules les lectures entrent au cache. Un POST ou un PUT +// change un état à l'amont et n'a pas de copie qui vaille. +func CacheableMethod(method string) bool { + return method == "GET" || method == "HEAD" +} diff --git a/script/qemu_cache/classify_test.go b/script/qemu_cache/classify_test.go new file mode 100644 index 0000000..c33a3bc --- /dev/null +++ b/script/qemu_cache/classify_test.go @@ -0,0 +1,249 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "net/url" + "strings" + "testing" +) + +func TestClassify(t *testing.T) { + cas := []struct { + brut string + attendue Class + pourquoi string + }{ + // Le nom porte la version : le contenu ne changera jamais. + {"https://miroir.example/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst", + ClassImmutable, "paquet Arch"}, + {"https://miroir.example/debian/pool/main/b/bash/bash_5.2-1_amd64.deb", + ClassImmutable, "paquet Debian"}, + {"https://miroir.example/fedora/Packages/b/bash-5.2-1.fc43.x86_64.rpm", + ClassImmutable, "paquet Fedora"}, + {"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl", + ClassImmutable, "roue Python"}, + {"https://images.example/arch/Arch-Linux-x86_64-cloudimg.qcow2", + ClassImmutable, "image cloud"}, + + // L'index décrit l'état COURANT du miroir. + {"https://miroir.example/arch/core/os/x86_64/core.db", + ClassVolatile, "base Arch"}, + {"https://miroir.example/arch/core/os/x86_64/core.db.sig", + ClassVolatile, "signature de la base"}, + {"https://miroir.example/debian/dists/trixie/InRelease", + ClassVolatile, "index Debian"}, + {"https://miroir.example/debian/dists/trixie/main/binary-amd64/Packages.gz", + ClassVolatile, "liste Debian"}, + {"https://miroir.example/fedora/repodata/repomd.xml", + ClassVolatile, "index Fedora"}, + {"https://pypi.example/simple/requests/", + ClassVolatile, "index simple de PyPI"}, + + // Le doute profite au volatile : une requête paramétrée décrit un + // appel d'API, pas un fichier. + {"https://api.example/v1/paquet?nom=bash&version=5.2", + ClassVolatile, "chaîne de requête"}, + {"https://exemple.example/page-quelconque", + ClassVolatile, "inconnu"}, + } + + for _, c := range cas { + u, err := url.Parse(c.brut) + if err != nil { + t.Fatalf("URL de test invalide %q : %v", c.brut, err) + } + if got := Classify(u); got != c.attendue { + t.Errorf("%s : %s classé « %s », attendu « %s »", + c.pourquoi, c.brut, got, c.attendue) + } + } +} + +// Une extension immuable portée par une chaîne de requête ne suffit pas : le +// paramètre peut changer la réponse. +func TestChaineDeRequeteEmportreSurExtension(t *testing.T) { + u, _ := url.Parse("https://m.example/bash-5.2-1-x86_64.pkg.tar.zst?mirror=2") + if got := Classify(u); got != ClassVolatile { + t.Errorf("classé « %s », attendu « volatile »", got) + } +} + +func TestCacheableMethod(t *testing.T) { + for _, m := range []string{"GET", "HEAD"} { + if !CacheableMethod(m) { + t.Errorf("%s devrait être cachable", m) + } + } + for _, m := range []string{"POST", "PUT", "DELETE", "PATCH", "CONNECT"} { + if CacheableMethod(m) { + t.Errorf("%s ne doit pas être cachable", m) + } + } +} + +func TestClassifyURLNulle(t *testing.T) { + if got := Classify(nil); got != ClassNoStore { + t.Errorf("une URL absente vaut « %s », attendu « no-store »", got) + } +} + +// Un même fichier, deux miroirs : le cache doit le reconnaître. +// +// Une liste de miroirs tourne, et pacman a réellement tiré « extra.db » de +// « geo » alors que le cache ne détenait que la copie de « fastly » — même +// fichier, autre nom d'hôte, défaut de cache et 504 hors ligne. +func TestMemeCheminSurDeuxMiroirs(t *testing.T) { + a, _ := url.Parse("https://fastly.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst") + b, _ := url.Parse("https://geo.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst") + if !PortableParChemin(a) { + t.Fatal("un paquet n'est pas reconnu portable") + } + if KeySansHote("GET", a) != KeySansHote("GET", b) { + t.Error("deux miroirs du même fichier donnent deux clés") + } + if Key("GET", a.String()) == Key("GET", b.String()) { + t.Error("la clé complète devrait, elle, distinguer les deux URL") + } +} + +// L'index aussi : c'est lui qui a échoué hors ligne. +func TestIndexPortableEntreMiroirs(t *testing.T) { + a, _ := url.Parse("https://fastly.example/extra/os/x86_64/extra.db") + b, _ := url.Parse("https://geo.example/extra/os/x86_64/extra.db") + if !PortableParChemin(a) { + t.Fatal("un index de dépôt n'est pas reconnu portable") + } + if KeySansHote("GET", a) != KeySansHote("GET", b) { + t.Error("deux miroirs du même index donnent deux clés") + } +} + +// Mais pas n'importe quoi : « /index.html » ne nomme rien, et deux sites sans +// rapport en portent un. +func TestUnePageQuelconqueNestPasPortable(t *testing.T) { + for _, brut := range []string{ + "https://a.example/index.html", + "https://a.example/quelque-chose", + "https://a.example/simple/requests/", + "https://a.example/core.db?miroir=2", + } { + u, _ := url.Parse(brut) + if PortableParChemin(u) { + t.Errorf("%s est jugé portable, à tort", brut) + } + } +} + +// Le protocole « smart » de git est une NÉGOCIATION : le serveur calcule sa +// réponse d'après ce que le client détient déjà. Rien n'y est réutilisable, et +// servir une réponse gardée ferait croire à des références disparues. +func TestGitSmartNestJamaisGarde(t *testing.T) { + for _, brut := range []string{ + "https://git.example/org/depot.git/info/refs?service=git-upload-pack", + "https://git.example/org/depot.git/git-upload-pack", + "https://git.example/org/depot.git/git-receive-pack", + } { + u, _ := url.Parse(brut) + if !EstGitSmart(u) { + t.Errorf("%s n'est pas reconnu comme négociation git", brut) + } + if got := Classify(u); got != ClassNoStore { + t.Errorf("%s classé « %s », attendu « no-store »", brut, got) + } + } +} + +// Le protocole « dumb », lui, sert des fichiers : un objet porte son empreinte +// dans son nom et se garde comme n'importe quel fichier figé. +func TestLesObjetsGitRestentCachables(t *testing.T) { + for _, brut := range []string{ + "https://git.example/org/depot.git/objects/ab/cdef0123456789", + "https://git.example/org/depot.git/objects/pack/pack-abc.pack", + } { + u, _ := url.Parse(brut) + if EstGitSmart(u) { + t.Errorf("%s pris pour une négociation, à tort", brut) + } + } +} + +func TestEstGitSmartURLNulle(t *testing.T) { + if EstGitSmart(nil) { + t.Error("une URL absente est prise pour du git") + } +} + +// Un index Debian publié sous l'empreinte de son contenu est aussi figé +// qu'un paquet : son nom EST sa somme. Il se range donc SANS son hôte, un +// contenu différent portant forcément un autre nom. +func TestLesIndexParEmpreinteSontImmuables(t *testing.T) { + sha256 := strings.Repeat("0123456789abcdef", 4) + md5 := strings.Repeat("0123456789abcdef", 2) + for _, brut := range []string{ + "https://miroir.example/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + sha256, + "https://miroir.example/debian/dists/trixie/main/i18n/by-hash/MD5Sum/" + md5, + } { + u, _ := url.Parse(brut) + if got := Classify(u); got != ClassImmutable { + t.Errorf("%s classé « %s », attendu « immutable »", brut, got) + } + if !PortableParChemin(u) { + t.Errorf("%s n'est pas jugé portable", brut) + } + } + // Ce qui n'est pas une empreinte garde la règle d'avant. + for _, brut := range []string{ + "https://miroir.example/debian/dists/trixie/by-hash/SHA256/pas-une-somme", + "https://miroir.example/debian/dists/trixie/by-hash/SHA256/" + sha256 + "?v=2", + } { + u, _ := url.Parse(brut) + if got := Classify(u); got != ClassVolatile { + t.Errorf("%s classé « %s », attendu « volatile »", brut, got) + } + } +} + +// Le même index par empreinte, servi par deux miroirs. Sans clé portable, +// changer de miroir vide le cache de ses index : une installation hors ligne +// échoue alors sur des octets que le magasin détient pourtant, et le message +// accuse le dépôt. +func TestUnIndexParEmpreinteVautSurDeuxMiroirs(t *testing.T) { + somme := strings.Repeat("0123456789abcdef", 4) + chemin := "/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + somme + a, _ := url.Parse("https://miroir-a.example" + chemin) + b, _ := url.Parse("https://miroir-b.example" + chemin) + if CleDe("GET", a) != CleDe("GET", b) { + t.Error("deux miroirs du même index donnent deux clés") + } + // La clé complète doit, elle, continuer de les distinguer : c'est ce qui + // prouve que le rangement passe bien par la clé SANS hôte, et non que les + // deux fonctions se sont mises à rendre la même chose. + if Key("GET", a.String()) == Key("GET", b.String()) { + t.Error("la clé complète ne distingue plus les deux URL") + } +} + +// Un pointeur vers la dernière version publiée change de cible à chaque +// publication : son suffixe d'archive ne doit pas le figer sur le disque, ni +// le ranger sans son hôte. +func TestUnPointeurDeDerniereVersionEstVolatile(t *testing.T) { + for _, brut := range []string{ + "https://forge.example/o/d/releases/latest/download/outil-x86_64-linux.tar.gz", + "https://forge.example/o/d/releases/latest/download/outil.zip", + } { + u, _ := url.Parse(brut) + if got := Classify(u); got != ClassVolatile { + t.Errorf("%s classé « %s », attendu « volatile »", brut, got) + } + if PortableParChemin(u) { + t.Errorf("%s jugé portable", brut) + } + } + // Une version NOMMÉE reste figée par son suffixe. + u, _ := url.Parse("https://forge.example/o/d/releases/download/v1.2.3/outil.tar.gz") + if got := Classify(u); got != ClassImmutable { + t.Errorf("une version nommée classée « %s », attendu « immutable »", got) + } +} diff --git a/script/qemu_cache/detient.go b/script/qemu_cache/detient.go new file mode 100644 index 0000000..d2c9209 --- /dev/null +++ b/script/qemu_cache/detient.go @@ -0,0 +1,153 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "bufio" + "fmt" + "io" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// « --detient » répond depuis le MAGASIN et non depuis le journal d'accès. +// Le journal dit qu'un objet est entré un jour ; un objet effacé depuis — par +// une purge, par âge ou en entier — y garde ses lignes « stored ». Seul le +// magasin sait ce qui sortira hors ligne. +// +// La lecture ne passe pas par Store.Get, qui remet la date du corps à +// maintenant : le relevé rajeunirait tout ce qu'il regarde, et l'âge du +// dernier usage ne voudrait plus rien dire. Elle ne lit que des métas (0644) +// et des casiers (0755) : aucun privilège n'est requis. + +// Verdicts de « --detient ». +const ( + // VerdictGarde : un corps 200 est en réserve, il sortira hors ligne. + VerdictGarde = "garde" + // VerdictStatut : un statut seul — redirection, refus, 200 d'un HEAD — + // est en réserve, rejoué sans corps quand l'amont est muet. + VerdictStatut = "statut" + // VerdictAbsent : la requête est cachable, rien n'est en réserve. + VerdictAbsent = "absent" + // VerdictNonCachable : le magasin ne tiendra jamais cette requête — + // méthode d'écriture, négociation git, URL illisible, ou HEAD d'une + // classe qui ne garde pas de statut seul. + VerdictNonCachable = "non-cachable" +) + +// Detention dit ce que le magasin tient pour une méthode et une URL. +type Detention struct { + Verdict string + // Statut et StockeLe sont nuls quand rien n'est en réserve. + Statut int + StockeLe time.Time + Classe string + Methode string + URL string +} + +// Ligne rend la détention en une ligne séparée par des tabulations : +// verdict, statut, date de stockage en RFC 3339, classe, méthode, URL. Un +// champ sans valeur vaut « - », pour que chaque ligne ait six champs. +func (d Detention) Ligne() string { + statut, date, classe := "-", "-", "-" + if d.Statut != 0 { + statut = strconv.Itoa(d.Statut) + } + if !d.StockeLe.IsZero() { + date = d.StockeLe.UTC().Format(time.RFC3339) + } + if d.Classe != "" { + classe = d.Classe + } + return strings.Join( + []string{d.Verdict, statut, date, classe, d.Methode, d.URL}, "\t") +} + +// Detenir dit ce que le magasin tient pour une requête, sans rien modifier. +// +// Les clés sont calculées comme le service les calcule — classe, méthode +// cachable, clé portable ou non —, par les mêmes fonctions, et lues dans le +// même ordre que la branche hors ligne : la clé du corps d'abord, qui ne +// compte que pour un 200 ; la clé du statut seul (CleStatut) ensuite. Un +// objet n'est tenu que si son corps a la taille que son méta annonce : c'est +// la condition à laquelle le service le sert, et un objet que le service +// refuserait n'est pas en réserve. L'URL est rendue telle que reçue, pour que +// l'appelant retrouve sa ligne. +// +// Un HEAD n'a jamais de corps gardé, et son statut ne se garde que pour une +// classe volatile sous une clé attachée à l'hôte : ailleurs, il est +// non-cachable et non absent, sans quoi l'appelant le croirait à remplir et +// le rejouerait pour rien, indéfiniment. C'est la négation exacte de la +// condition du statut seul dans Proxy.serve. +func (s *Store) Detenir(methode, brut string) Detention { + methode = strings.ToUpper(methode) + d := Detention{Verdict: VerdictNonCachable, Methode: methode, URL: brut} + u, err := url.Parse(brut) + if err != nil || !u.IsAbs() || u.Host == "" { + return d + } + class := Classify(u) + d.Classe = class.String() + if !CacheableMethod(methode) || class == ClassNoStore { + return d + } + if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) { + return d + } + d.Verdict = VerdictAbsent + if m, ok := s.lirePresent(CleDe(methode, u)); ok && !m.StatutSeul() { + d.Verdict, d.Statut, d.StockeLe = VerdictGarde, m.StatutReel(), m.StoredAt + return d + } + if m, ok := s.lirePresent(CleStatut(methode, u)); ok && m.StatutSeul() { + d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt + } + return d +} + +// lirePresent rend le méta d'une clé quand l'objet est complet : méta lisible +// et corps de la taille annoncée. Ni l'un ni l'autre n'est modifié. +func (s *Store) lirePresent(key string) (*Meta, bool) { + m, err := s.LireMeta(key) + if err != nil { + return nil, false + } + _, bodyPath := s.paths(key) + if fi, err := os.Stat(bodyPath); err != nil || fi.Size() != m.Size { + return nil, false + } + return m, true +} + +// EcrireDetentions lit des lignes « MÉTHODE URL » et écrit une ligne de +// détention pour chacune, dans l'ordre. Une ligne vide ou commençant par +// « # » est sautée ; une ligne d'un seul champ vaut « GET ». Chaque +// réponse part aussitôt écrite, pour qu'un appelant qui lit au fil de ses +// questions ne reste pas bloqué. +func EcrireDetentions(s *Store, entree io.Reader, sortie io.Writer) error { + sc := bufio.NewScanner(entree) + // Une URL signée dépasse volontiers les 64 Kio de la ligne par défaut. + sc.Buffer(make([]byte, 64*1024), 1<<20) + w := bufio.NewWriter(sortie) + for sc.Scan() { + ligne := strings.TrimSpace(sc.Text()) + if ligne == "" || strings.HasPrefix(ligne, "#") { + continue + } + champs := strings.Fields(ligne) + methode, brut := "GET", champs[0] + if len(champs) >= 2 { + methode, brut = champs[0], champs[1] + } + fmt.Fprintln(w, s.Detenir(methode, brut).Ligne()) + if err := w.Flush(); err != nil { + return err + } + } + return sc.Err() +} diff --git a/script/qemu_cache/detient_test.go b/script/qemu_cache/detient_test.go new file mode 100644 index 0000000..f3af9f7 --- /dev/null +++ b/script/qemu_cache/detient_test.go @@ -0,0 +1,189 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "bytes" + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" +) + +// « --detient » dit ce que le magasin tient, une ligne par question, avec la +// clé que le service calcule. Le magasin est rempli ici PAR le service, pour +// que toute divergence entre les deux calculs de clé se voie. +func TestDetientDitCeQueLeMagasinTient(t *testing.T) { + paquet := "/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst" + a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/installer.sh": + rediriger(http.StatusFound, "https://ailleurs.example/bootstrap.sh")(w, r) + case "/depot/etat": + servir("état courant")(w, r) + default: + servir("contenu du paquet")(w, r) + } + }) + p := proxyDeTest(t) + for _, c := range []string{"/installer.sh", "/depot/etat", paquet} { + joue(t, p, "GET", a.hote(), c) + } + joue(t, p, "HEAD", a.hote(), "/installer.sh") + + base := "http://" + a.hote() + questions := []string{ + "GET " + base + "/depot/etat", + "GET " + base + "/installer.sh", + "HEAD " + base + "/installer.sh", + "get " + base + "/jamais-vu.sh", + "POST " + base + "/depot/etat", + "GET " + base + "/o/d.git/info/refs?service=git-upload-pack", + // Un paquet est portable : le même chemin sur un autre miroir est + // le même fichier. + "GET http://autre-miroir.example.invalid" + paquet, + "", + "# une ligne de commentaire", + base + "/depot/etat", + "GET pas-une-url", + } + var sortie bytes.Buffer + if err := EcrireDetentions( + p.Store, strings.NewReader(strings.Join(questions, "\n")), &sortie, + ); err != nil { + t.Fatal(err) + } + + attendus := []struct { + verdict, statut, classe, methode, url string + date bool + }{ + {"garde", "200", "volatile", "GET", base + "/depot/etat", true}, + {"statut", "302", "volatile", "GET", base + "/installer.sh", true}, + {"statut", "302", "volatile", "HEAD", base + "/installer.sh", true}, + {"absent", "-", "volatile", "GET", base + "/jamais-vu.sh", false}, + {"non-cachable", "-", "volatile", "POST", base + "/depot/etat", false}, + {"non-cachable", "-", "no-store", "GET", + base + "/o/d.git/info/refs?service=git-upload-pack", false}, + {"garde", "200", "immutable", "GET", + "http://autre-miroir.example.invalid" + paquet, true}, + {"garde", "200", "volatile", "GET", base + "/depot/etat", true}, + {"non-cachable", "-", "-", "GET", "pas-une-url", false}, + } + lignes := strings.Split(strings.TrimRight(sortie.String(), "\n"), "\n") + if len(lignes) != len(attendus) { + t.Fatalf("%d lignes, attendu %d :\n%s", len(lignes), len(attendus), sortie.String()) + } + for i, a := range attendus { + champs := strings.Split(lignes[i], "\t") + if len(champs) != 6 { + t.Errorf("ligne %d : %d champs, attendu 6 : %q", i+1, len(champs), lignes[i]) + continue + } + if champs[0] != a.verdict || champs[1] != a.statut || + champs[3] != a.classe || champs[4] != a.methode || champs[5] != a.url { + t.Errorf("ligne %d : %q, attendu %s %s … %s %s %s", + i+1, lignes[i], a.verdict, a.statut, a.classe, a.methode, a.url) + } + if a.date { + if _, err := time.Parse(time.RFC3339, champs[2]); err != nil { + t.Errorf("ligne %d : date %q illisible", i+1, champs[2]) + } + } else if champs[2] != "-" { + t.Errorf("ligne %d : date %q pour un objet absent", i+1, champs[2]) + } + } +} + +// Le relevé ne rajeunit rien : l'âge d'un objet est celui de son dernier +// SERVICE, et une question posée au magasin n'en est pas un. +func TestDetientNeRajeunitPas(t *testing.T) { + a := nouvelAmontScripte(t, servir("état courant")) + p, _ := proxyEtCasier(t) + joue(t, p, "GET", a.hote(), "/depot/etat") + + u, _ := url.Parse("http://" + a.hote() + "/depot/etat") + _, corps := p.Store.paths(CleDe("GET", u)) + ancien := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + if err := os.Chtimes(corps, ancien, ancien); err != nil { + t.Fatal(err) + } + var sortie bytes.Buffer + EcrireDetentions(p.Store, strings.NewReader("GET "+u.String()+"\n"), &sortie) + if !strings.HasPrefix(sortie.String(), VerdictGarde+"\t") { + t.Fatalf("l'objet n'a pas été vu : %q", sortie.String()) + } + fi, err := os.Stat(corps) + if err != nil { + t.Fatal(err) + } + if !fi.ModTime().Equal(ancien) { + t.Errorf("le relevé a rajeuni l'objet : %v au lieu de %v", fi.ModTime(), ancien) + } +} + +// Un HEAD que le service ne gardera jamais est non-cachable, pas absent : +// l'appelant le croirait sinon à remplir, et le rejouerait pour rien. Le +// magasin est rempli ici PAR le service, l'amont répondant 200 à chaque +// HEAD : seul celui dont la classe garde un statut seul doit y entrer, et +// c'est le seul que « --detient » ne dit pas non-cachable. +func TestDetientUnHeadQueLeMagasinNeGardeJamais(t *testing.T) { + a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1234") + w.WriteHeader(http.StatusOK) + }) + p, dir := proxyEtCasier(t) + base := "http://" + a.hote() + attendus := map[string]string{ + // Immuable et portable : un paquet. + "/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst": VerdictNonCachable, + // Immuable attaché à son hôte : un index par empreinte. + "/debian/dists/trixie/by-hash/SHA256/" + strings.Repeat("ab", 32): VerdictNonCachable, + // Volatile mais portable : un index de dépôt. + "/arch/core/os/x86_64/core.db": VerdictNonCachable, + // Volatile attaché à son hôte : le seul qui se garde. + "/api/bonjour": VerdictStatut, + } + for chemin, attendu := range attendus { + if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != VerdictNonCachable && + got != VerdictAbsent { + t.Errorf("%s avant remplissage : %q", chemin, got) + } + joue(t, p, "HEAD", a.hote(), chemin) + if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != attendu { + t.Errorf("HEAD %s : %q, attendu %q", chemin, got, attendu) + } + // Le GET de la même URL reste à remplir : un corps s'y garde. + if got := p.Store.Detenir("GET", base+chemin).Verdict; got != VerdictAbsent { + t.Errorf("GET %s : %q, attendu %q", chemin, got, VerdictAbsent) + } + } + if n := corpsGardes(t, dir); n != 1 { + t.Errorf("%d objet(s) gardé(s), attendu 1 : le service et --detient"+ + " divergent sur ce qu'un HEAD laisse au magasin", n) + } + if got := p.Store.Detenir("HEAD", base+"/api/jamais-vu").Verdict; got != VerdictAbsent { + t.Errorf("HEAD volatile jamais vu : %q, attendu %q", got, VerdictAbsent) + } +} + +// Un corps dont la taille ne correspond plus à son méta n'est pas en +// réserve : le service refuserait de le servir. +func TestDetientRefuseUnCorpsAltere(t *testing.T) { + a := nouvelAmontScripte(t, servir("état courant")) + p, _ := proxyEtCasier(t) + joue(t, p, "GET", a.hote(), "/depot/etat") + u, _ := url.Parse("http://" + a.hote() + "/depot/etat") + _, corps := p.Store.paths(CleDe("GET", u)) + if err := os.WriteFile(corps, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if d := p.Store.Detenir("GET", u.String()); d.Verdict != VerdictAbsent { + t.Errorf("verdict %q pour un corps altéré", d.Verdict) + } + EcrireDetentions(p.Store, strings.NewReader(""), io.Discard) +} diff --git a/script/qemu_cache/front_test.go b/script/qemu_cache/front_test.go new file mode 100644 index 0000000..252f62d --- /dev/null +++ b/script/qemu_cache/front_test.go @@ -0,0 +1,184 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// Le front TLS de bout en bout : un vrai client, une vraie poignée de main, +// une vraie réponse. +// +// Les autres tests appellent le proxy en direct et ne traversent jamais ce +// chemin. Il portait un défaut qu'aucun d'eux ne pouvait voir : « Serve » +// traite la connexion dans une goroutine puis reboucle sur « Accept », si +// bien qu'un écouteur rendant la fin de flux tout de suite faisait fermer la +// connexion pendant que la réponse s'écrivait. Le client recevait « Empty +// reply from server » et le journal ne disait rien, la requête n'étant jamais +// arrivée au bout. + +// frontDeTest monte le front TLS devant un amont qui compte ses requêtes. +// +// L'amont parle TLS, comme un vrai miroir : le front étiquette « https » ce +// qui lui arrive, et un amont en clair ferait échouer la reprise. Et le +// composeur du proxy est détourné vers cet amont, parce que le SNI doit +// porter un NOM — un client qui se connecte à une adresse IP n'envoie aucun +// SNI, et le front n'aurait alors rien à certifier. +const HOTE_AMONT = "miroir.example" + +func frontDeTest(t *testing.T, corps string) (*amont, string, *CA) { + t.Helper() + a := &amont{corps: corps} + a.srv = httptest.NewTLSServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&a.appel, 1) + w.Header().Set("Content-Type", "application/octet-stream") + io.WriteString(w, a.corps) + })) + t.Cleanup(a.srv.Close) + + alog, err := OpenAccessLog("") + if err != nil { + t.Fatalf("journal : %v", err) + } + proxy := NewProxy(&Store{Dir: t.TempDir()}, alog) + tr := proxy.Client.Transport.(*http.Transport) + cible := a.hote() + tr.DialContext = func(ctx context.Context, reseau, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, reseau, cible) + } + // L'amont de test signe lui-même son certificat : le vérifier n'apprendrait + // rien sur le cache, qui est ce qu'on mesure ici. + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatalf("autorité : %v", err) + } + front := &TLSFront{CA: ca, Proxy: proxy, Refusals: NewRefusals(nil)} + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("écoute : %v", err) + } + t.Cleanup(func() { ln.Close() }) + go front.Serve(ln) + return a, ln.Addr().String(), ca +} + +// dialogueTLS fait UNE requête au front, en TLS, et rend la réponse. +func dialogueTLS( + t *testing.T, ca *CA, ecoute, hoteAmont, chemin string, +) (*http.Response, string) { + t.Helper() + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + + brut, err := net.DialTimeout("tcp", ecoute, 5*time.Second) + if err != nil { + t.Fatalf("connexion : %v", err) + } + defer brut.Close() + // Le SNI porte le nom de l'AMONT : c'est lui qui dit au front quel + // certificat fabriquer et où aller chercher. + tc := tls.Client(brut, &tls.Config{ + ServerName: hoteAmont, + RootCAs: pool, + }) + tc.SetDeadline(time.Now().Add(15 * time.Second)) + if err := tc.Handshake(); err != nil { + t.Fatalf("poignée de main : %v", err) + } + fmt.Fprintf(tc, "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", + chemin, hoteAmont) + brutRep, err := io.ReadAll(tc) + if err != nil && len(brutRep) == 0 { + t.Fatalf("lecture : %v", err) + } + texte := string(brutRep) + if texte == "" { + t.Fatal("réponse vide : la connexion s'est fermée avant la réponse") + } + return nil, texte +} + +// L'invariant que le défaut violait : une requête TLS reçoit sa réponse. +func TestFrontTLSRendUneReponse(t *testing.T) { + a, ecoute, ca := frontDeTest(t, "contenu du paquet") + + _, rep := dialogueTLS( + t, ca, ecoute, HOTE_AMONT, + "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst", + ) + if !strings.HasPrefix(rep, "HTTP/1.1 200") { + t.Errorf("réponse inattendue :\n%s", rep[:min(len(rep), 200)]) + } + if !strings.Contains(rep, "contenu du paquet") { + t.Error("le corps de l'amont n'est pas parvenu au client") + } + if atomic.LoadInt64(&a.appel) != 1 { + t.Errorf("l'amont a reçu %d requêtes, attendu 1", a.appel) + } +} + +// Et la seconde demande vient du disque, sans toucher au réseau : le gain de +// l'outil doit exister À TRAVERS le front, pas seulement dans le proxy nu. +func TestFrontTLSSertDuCacheALaSeconde(t *testing.T) { + a, ecoute, ca := frontDeTest(t, "contenu du paquet") + chemin := "/arch/core/os/x86_64/git-2.51-1-x86_64.pkg.tar.zst" + + dialogueTLS(t, ca, ecoute, HOTE_AMONT, chemin) + _, rep := dialogueTLS(t, ca, ecoute, HOTE_AMONT, chemin) + + if !strings.Contains(rep, "X-Erplibre-Cache: hit") && + !strings.Contains(rep, "X-ERPLibre-Cache: hit") { + t.Errorf("la seconde demande n'a pas été servie du disque :\n%s", + rep[:min(len(rep), 300)]) + } + if n := atomic.LoadInt64(&a.appel); n != 1 { + t.Errorf("l'amont a reçu %d requêtes, attendu 1", n) + } +} + +// Le certificat présenté doit porter le nom demandé, sinon le client le +// rejette avant même d'envoyer sa requête. +func TestFrontTLSPresenteLeBonNom(t *testing.T) { + _, ecoute, ca := frontDeTest(t, "x") + hote := HOTE_AMONT + + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + brut, err := net.DialTimeout("tcp", ecoute, 5*time.Second) + if err != nil { + t.Fatalf("connexion : %v", err) + } + defer brut.Close() + tc := tls.Client(brut, &tls.Config{ServerName: hote, RootCAs: pool}) + tc.SetDeadline(time.Now().Add(10 * time.Second)) + if err := tc.Handshake(); err != nil { + t.Fatalf("poignée de main : %v", err) + } + vu := tc.ConnectionState().PeerCertificates[0] + if vu.Subject.CommonName != hote { + t.Errorf("certificat pour %q, attendu %q", vu.Subject.CommonName, hote) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/script/qemu_cache/gitmirror.go b/script/qemu_cache/gitmirror.go new file mode 100644 index 0000000..d03dc9b --- /dev/null +++ b/script/qemu_cache/gitmirror.go @@ -0,0 +1,705 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "net/http/cgi" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "time" +) + +// Pourquoi un MIROIR et non un cache pour git. +// +// Le protocole « smart HTTP » de git est une NÉGOCIATION : le client annonce +// ce qu'il détient, le serveur calcule et envoie ce qui manque. Deux clients +// dans des états différents reçoivent des octets différents pour la même URL, +// si bien qu'aucune réponse ne se réutilise — c'est pour cela que le cache ne +// garde rien de ces échanges. +// +// Or c'est là que part l'essentiel du réseau d'une installation ERPLibre : le +// dépôt tire plus de trois cents dépôts, et le trafic git y pèse plusieurs +// fois celui des paquets. Un cache qui l'ignore laisse donc le gros du travail +// sortir deux fois. +// +// La réponse n'est pas de garder des réponses mais de tenir des DÉPÔTS. Un +// miroir nu par dépôt amont, rafraîchi quand l'amont répond, et le protocole +// servi depuis ce miroir par « git http-backend » — le programme de git +// lui-même, plutôt qu'une réimplémentation qui divergerait à la première +// version du protocole. +// +// Ce que cela coûte, et qu'il faut savoir : un miroir est COMPLET. Trois cents +// dépôts Odoo pèsent plusieurs gigaoctets sur le disque de l'orchestrateur, et +// rien ne les efface, comme pour le reste de ce cache. + +// GitMirror tient les dépôts nus et les sert. +type GitMirror struct { + // Dir est la racine des miroirs. Vide, le miroir est éteint et git + // retombe sur le simple relais vers l'amont. + Dir string + // Frais borne le rafraîchissement : deux requêtes rapprochées sur le même + // dépôt ne déclenchent qu'une récupération. « repo sync » interroge chaque + // dépôt plusieurs fois de suite, et sans cette borne chacune paierait un + // aller-retour vers l'amont. + Frais time.Duration + // Backend est le chemin de « git-http-backend ». Vide, il est cherché aux + // endroits usuels. + Backend string + // Delai borne un CLONAGE. Un dépôt Odoo complet descend en minutes, pas + // en secondes, et l'abandonner à mi-chemin ne laisse rien d'utilisable. + Delai time.Duration + // DelaiMaj borne un RAFRAÎCHISSEMENT, et il est court à dessein. + // + // Un miroir qui existe est déjà servable : si l'amont ne répond pas, la + // bonne réponse est de servir ce qu'on a, tout de suite. Avec le délai du + // clonage, un amont coupé ferait attendre chaque dépôt jusqu'à son terme — + // pour les trois cents dépôts d'une installation, des heures d'attente + // pour un déploiement qui aurait pu être servi en entier depuis le disque. + DelaiMaj time.Duration + // PlancherLibre est la place qu'on refuse d'entamer. En dessous, aucun + // NOUVEAU miroir n'est créé et la requête repart vers l'amont : le cache + // perd son avance, il ne remplit pas le disque de l'orchestrateur. + // + // Un miroir est complet là où « repo sync » clone en profondeur un : le + // facteur entre les deux est celui de l'historique, et il ne se devine + // pas. Les miroirs DÉJÀ tenus continuent d'être rafraîchis et servis — + // une mise à jour ne coûte que ce qui a changé. + PlancherLibre int64 + // Muets est la mémoire des amonts injoignables, partagée avec le relais. + // Elle ne retient que des établissements manqués. Nulle, rien n'est + // retenu d'une requête à l'autre. + Muets *Joignabilite + // Sonder vérifie qu'un amont accepte une connexion. Nul, une connexion + // TCP est ouverte puis refermée. + Sonder func(ctx context.Context, adresse string) error + // Mandataire dit par quel mandataire HTTP une URL sortirait. Nul, c'est + // http.ProxyFromEnvironment : l'environnement même que « git remote + // update » lit. + Mandataire func(*http.Request) (*url.URL, error) + + mu sync.Mutex + verrous map[string]*sync.Mutex + vus map[string]time.Time + // figes retient les hôtes dont une sonde manquée a déjà été dite au + // journal : une ligne par panne, pas une par requête. + figes map[string]bool +} + +// backendsUsuels : les chemins où les distributions posent git-http-backend. +// Aucune ne les met au même endroit, et il n'est pas dans le PATH. +var backendsUsuels = []string{ + "/usr/lib/git-core/git-http-backend", + "/usr/libexec/git-core/git-http-backend", + "/usr/local/libexec/git-core/git-http-backend", +} + +// TrouverBackend rend le chemin de git-http-backend, ou "" s'il manque. +func TrouverBackend() string { + for _, c := range backendsUsuels { + if st, err := os.Stat(c); err == nil && !st.IsDir() { + return c + } + } + return "" +} + +// Actif dit si le miroir peut servir. Un répertoire sans le programme de git +// ne sert à rien : mieux vaut le relais vers l'amont, qui fonctionne. +func (g *GitMirror) Actif() bool { + return g != nil && g.Dir != "" && g.backend() != "" +} + +func (g *GitMirror) backend() string { + if g.Backend == "" { + return TrouverBackend() + } + // Le chemin imposé est vérifié comme les autres : un miroir qui se dirait + // actif avec un programme absent servirait des 500 à chaque dépôt, là où + // le relais vers l'amont, lui, fonctionne. + if st, err := os.Stat(g.Backend); err == nil && !st.IsDir() { + return g.Backend + } + return "" +} + +// DepotDeURL découpe une URL de négociation en (URL du dépôt amont, reste). +// +// « https://h/o/d.git/info/refs?service=… » rend « https://h/o/d.git » et +// « /info/refs ». Rend faux quand l'URL n'est pas une négociation : le +// découpage n'aurait alors aucun sens. +func DepotDeURL(u *url.URL) (string, string, bool) { + if u == nil { + return "", "", false + } + for _, s := range gitSmartPaths { + if strings.HasSuffix(u.Path, s) { + base := strings.TrimSuffix(u.Path, s) + if base == "" || base == "/" { + return "", "", false + } + amont := *u + amont.Path = base + amont.RawQuery = "" + return amont.String(), s, true + } + } + return "", "", false +} + +// CheminMiroir rend le répertoire du miroir d'un dépôt. +// +// L'hôte fait partie du chemin : deux forges peuvent servir « /odoo/odoo », et +// les confondre donnerait à l'une le contenu de l'autre. Le « .git » final est +// posé une seule fois, l'amont l'écrivant tantôt et tantôt non. +func (g *GitMirror) CheminMiroir(depot string) (string, error) { + u, err := url.Parse(depot) + if err != nil || u.Host == "" { + return "", fmt.Errorf("dépôt illisible %q", depot) + } + chemin := strings.Trim(u.Path, "/") + chemin = strings.TrimSuffix(chemin, ".git") + if chemin == "" { + return "", fmt.Errorf("dépôt sans chemin %q", depot) + } + // Un « .. » dans le chemin ferait écrire hors de la racine. + for _, seg := range strings.Split(chemin, "/") { + if seg == "." || seg == ".." || seg == "" { + return "", fmt.Errorf("chemin de dépôt refusé %q", depot) + } + } + return filepath.Join(g.Dir, u.Host, chemin+".git"), nil +} + +func (g *GitMirror) verrou(cle string) *sync.Mutex { + g.mu.Lock() + defer g.mu.Unlock() + if g.verrous == nil { + g.verrous = map[string]*sync.Mutex{} + } + if _, ok := g.verrous[cle]; !ok { + g.verrous[cle] = &sync.Mutex{} + } + return g.verrous[cle] +} + +// Assurer rend le miroir prêt et dit s'il est utilisable. +// +// Trois issues, et la troisième est celle qui rend le hors-ligne possible : +// le miroir est créé, le miroir est rafraîchi, ou l'amont est muet mais un +// miroir existe déjà — il sert alors tel quel. Un amont muet SANS miroir rend +// faux, et l'appelant retombe sur le relais, qui donnera au client la vraie +// erreur du réseau plutôt qu'une erreur inventée ici. +func (g *GitMirror) Assurer(ctx context.Context, depot string) (string, bool) { + chemin, err := g.CheminMiroir(depot) + if err != nil { + return "", false + } + v := g.verrou(chemin) + v.Lock() + defer v.Unlock() + + _, statErr := os.Stat(filepath.Join(chemin, "HEAD")) + existe := statErr == nil + + if existe && g.recent(chemin) { + return chemin, true + } + if !existe { + if !g.placeSuffisante() { + log.Printf( + "miroir refusé pour %s : moins de %s libres sur le disque", + depot, HumanBytes(g.PlancherLibre)) + return "", false + } + if err := os.MkdirAll(filepath.Dir(chemin), 0o755); err != nil { + return "", false + } + if err := g.git(ctx, "", "clone", "--mirror", depot, chemin); err != nil { + // Un clonage à moitié fait laisserait un répertoire que la + // prochaine requête prendrait pour un miroir valide. + os.RemoveAll(chemin) + return "", false + } + g.noter(chemin) + return chemin, true + } + if !g.amontJoignable(ctx, depot) { + // Amont connu muet, ou qui vient de refuser la sonde : le miroir + // sert tel quel, sans payer le délai d'une mise à jour vouée à + // l'échec. + return chemin, true + } + if err := g.gitBorne( + ctx, g.delaiMaj(), chemin, "remote", "update", "--prune", + ); err != nil { + // Le miroir d'hier vaut mieux que rien : il sert, et c'est ce qui + // permet de déployer sans réseau. La tentative n'est notée comme un + // rafraîchissement — les requêtes suivantes du dépôt servies sans + // la rejouer pendant « Frais » — que si la sonde échoue désormais. + // Un amont qui l'accepte a échoué autrement : un 5xx, une + // poignée de main coupée, un délai sur un gros dépôt. La requête + // suivante retente alors, sans quoi le miroir servirait une branche + // en retard à une installation dont la forge répond. + if g.sondeRefusee(ctx, depot) { + g.noter(chemin) + } + return chemin, true + } + g.noter(chemin) + return chemin, true +} + +// amontJoignable dit si l'amont d'un dépôt vaut une tentative de mise à jour. +// +// Faux quand la mémoire partagée le tient pour muet, ou quand la sonde se +// voit refuser la connexion (sondeRefusee) ; l'échec est alors retenu, et les +// dépôts suivants du même hôte n'essaient même plus. Sans cette sonde, chaque +// dépôt d'un amont coupé paie le délai entier d'une mise à jour : pour les +// centaines de dépôts d'une installation, l'attente domine tout le reste. +// +// Vrai quand l'adresse ne se déduit pas : git tentera, et son propre délai +// bornera l'attente. +func (g *GitMirror) amontJoignable(ctx context.Context, depot string) bool { + u, adresse, ok := adresseDuDepot(depot) + if !ok { + return true + } + if g.Muets.ConnuMuet(adresse) { + return false + } + return !g.sonderAmont(ctx, u, adresse) +} + +// adresseDuDepot rend l'URL d'un dépôt et son « hôte:port », faux quand +// l'adresse ne se déduit pas. +func adresseDuDepot(depot string) (*url.URL, string, bool) { + u, err := url.Parse(depot) + if err != nil || u.Hostname() == "" { + return nil, "", false + } + adresse := adresseAmont(u) + if !strings.Contains(adresse, ":") { + return nil, "", false + } + return u, adresse, true +} + +// sondeRefusee sonde l'amont d'un dépôt, sans consulter la mémoire, et dit +// si la connexion a échoué. Faux quand l'adresse ne se déduit pas, ou quand +// un mandataire porte l'URL. +func (g *GitMirror) sondeRefusee(ctx context.Context, depot string) bool { + u, adresse, ok := adresseDuDepot(depot) + if !ok { + return false + } + return g.sonderAmont(ctx, u, adresse) +} + +// sonderAmont ouvre une connexion vers l'amont et dit si elle a échoué. +// +// Un échec d'établissement est retenu dans la mémoire partagée, et dit au +// journal UNE fois par panne et par hôte : un miroir servi sans +// rafraîchissement doit se voir, sans qu'une installation de trois cents +// dépôts n'y écrive trois cents lignes. La première réussite efface l'un et +// l'autre. +// +// Aucune sonde quand un mandataire porte l'URL : « git remote update » passe +// par lui, et une connexion directe, refusée sur un hôte dont c'est la seule +// sortie, figerait tous ses miroirs pour toujours. L'amont est alors tenu +// pour joignable, et c'est git qui tranche. +func (g *GitMirror) sonderAmont( + ctx context.Context, u *url.URL, adresse string, +) bool { + if g.parMandataire(u) { + return false + } + sonder := g.Sonder + if sonder == nil { + sonder = sonderTCP + } + err := sonder(ctx, adresse) + g.mu.Lock() + defer g.mu.Unlock() + if err == nil { + g.Muets.Reussite(adresse) + delete(g.figes, adresse) + return false + } + g.Muets.Echec(adresse, err) + if !g.figes[adresse] { + if g.figes == nil { + g.figes = map[string]bool{} + } + g.figes[adresse] = true + log.Printf("miroir git : sonde de %s en échec (%v) ; ses miroirs"+ + " sont servis sans rafraîchissement jusqu'à ce qu'il réponde", + adresse, err) + } + return true +} + +// parMandataire dit si l'URL sortirait par un mandataire HTTP. +func (g *GitMirror) parMandataire(u *url.URL) bool { + mandataire := g.Mandataire + if mandataire == nil { + mandataire = http.ProxyFromEnvironment + } + par, err := mandataire(&http.Request{URL: u, Header: http.Header{}}) + return err == nil && par != nil +} + +func (g *GitMirror) recent(chemin string) bool { + if g.Frais <= 0 { + return false + } + g.mu.Lock() + defer g.mu.Unlock() + t, ok := g.vus[chemin] + return ok && time.Since(t) < g.Frais +} + +func (g *GitMirror) noter(chemin string) { + g.mu.Lock() + defer g.mu.Unlock() + if g.vus == nil { + g.vus = map[string]time.Time{} + } + g.vus[chemin] = time.Now() +} + +// DelaiMajParDefaut : de quoi laisser un amont sain répondre, pas de quoi +// attendre un amont absent. +const DelaiMajParDefaut = 45 * time.Second + +func (g *GitMirror) delaiMaj() time.Duration { + if g.DelaiMaj > 0 { + return g.DelaiMaj + } + return DelaiMajParDefaut +} + +func (g *GitMirror) git(ctx context.Context, dir string, args ...string) error { + delai := g.Delai + if delai <= 0 { + delai = 30 * time.Minute + } + return g.gitBorne(ctx, delai, dir, args...) +} + +func (g *GitMirror) gitBorne( + ctx context.Context, delai time.Duration, dir string, args ...string, +) error { + ctx, annule := context.WithTimeout(ctx, delai) + defer annule() + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + // Le délai tue « git », mais git délègue le réseau à un auxiliaire — + // « git-remote-https » — qui SURVIT et garde le tube ouvert. Sans ce + // second délai, la lecture de la sortie attend cet auxiliaire, donc pour + // toujours quand l'amont accepte la connexion et ne répond jamais : le + // délai qu'on vient de poser ne borne alors plus rien. + cmd.WaitDelay = 5 * time.Second + // Aucune invite : un dépôt privé doit ÉCHOUER et retomber sur le relais, + // et non bloquer le service en attendant un mot de passe que personne ne + // tapera jamais. + cmd.Env = append(os.Environ(), + "GIT_TERMINAL_PROMPT=0", + "GIT_ASKPASS=/bin/true", + "GCM_INTERACTIVE=never", + ) + sortie, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s : %v : %s", + strings.Join(args, " "), err, court(string(sortie))) + } + return nil +} + +func court(s string) string { + s = strings.TrimSpace(s) + if len(s) > 300 { + return s[:300] + "…" + } + return s +} + +// Servir répond depuis le miroir, par le programme de git lui-même. +// +// « git http-backend » est une passerelle CGI : il attend le chemin du dépôt +// dans PATH_INFO, la racine dans GIT_PROJECT_ROOT, et rend le protocole exact, +// version 0 comme version 2. Le réimplémenter reviendrait à le suivre à chaque +// version. +// compteur retient ce qu'une réponse a réellement pesé. +// +// La passerelle CGI écrit directement dans la réponse : sans ce compteur, le +// journal note zéro octet pour tout ce que le miroir sert. Un outil dont le +// journal EST la mesure ne peut pas avoir un chemin qui ne compte pas — c'est +// justement celui qui porte l'essentiel du trafic d'une installation. +type compteur struct { + http.ResponseWriter + n int64 +} + +func (c *compteur) Write(p []byte) (int, error) { + n, err := c.ResponseWriter.Write(p) + c.n += int64(n) + return n, err +} + +// Servir répond depuis le miroir et rend ce que la réponse a pesé. +func (g *GitMirror) Servir( + w http.ResponseWriter, r *http.Request, chemin, reste string, +) int64 { + racine := filepath.Dir(chemin) + h := &cgi.Handler{ + Path: g.backend(), + Dir: racine, + Env: []string{ + "GIT_PROJECT_ROOT=" + racine, + // Le miroir n'a pas de « git-daemon-export-ok », et n'en aura + // pas : il ne sert que le pont des VM de cet hôte. + "GIT_HTTP_EXPORT_ALL=1", + }, + InheritEnv: []string{"PATH"}, + } + // La passerelle lit le chemin du dépôt dans l'URL qu'on lui présente : ce + // n'est pas celle que la VM a demandée, mais celle du miroir local. + r2 := r.Clone(r.Context()) + r2.URL = &url.URL{ + Path: "/" + filepath.Base(chemin) + reste, + RawQuery: r.URL.RawQuery, + } + r2.RequestURI = "" + c := &compteur{ResponseWriter: w} + h.ServeHTTP(c, r2) + return c.n +} + +// Occupation rend (nombre de dépôts, octets) du miroir. +// +// Le disque de l'orchestrateur est surveillé À LA MAIN — aucune éviction n'est +// écrite, ici pas plus qu'ailleurs — et un miroir est COMPLET : il pèse ce que +// pèse le dépôt amont, historique compris. Le relevé doit donc le dire, sans +// quoi la place disparaît sans que rien ne l'annonce. +func (g *GitMirror) Occupation() (int, int64) { + if g == nil || g.Dir == "" { + return 0, 0 + } + depots, octets := 0, int64(0) + filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if info.IsDir() { + if strings.HasSuffix(p, ".git") { + depots++ + } + return nil + } + octets += info.Size() + return nil + }) + return depots, octets +} + +// Prefetch tient en miroir toute une liste de dépôts, d'avance. +// +// À la demande, le miroir se remplit au fil des requêtes : la PREMIÈRE machine +// paie chaque clonage, et pour un dépôt qui en tire trois cents cela déplace +// le coût plutôt que de le supprimer. Le pré-remplissage le paie une fois, à +// l'heure choisie par l'opérateur. +// +// Les dépôts sont pris à PLUSIEURS à la fois : un clonage passe l'essentiel de +// son temps à attendre le réseau, et les enchaîner un par un tiendrait des +// heures là où la bande passante n'est pas le facteur. +// +// Un dépôt qui échoue ne fait pas échouer les autres : sur une liste de cette +// taille, il y a toujours un dépôt privé, déplacé ou retiré, et tout arrêter +// pour lui perdrait le travail déjà fait. Rend (réussis, échoués). +func (g *GitMirror) Prefetch( + ctx context.Context, depots []string, parallele int, dire func(string), +) (int, int) { + if parallele < 1 { + parallele = 1 + } + type resultat struct { + depot string + ok bool + } + taches := make(chan string) + sorties := make(chan resultat) + + var wg sync.WaitGroup + for i := 0; i < parallele; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for d := range taches { + _, ok := g.Assurer(ctx, d) + sorties <- resultat{d, ok} + } + }() + } + go func() { + defer close(taches) + for _, d := range depots { + select { + case taches <- d: + case <-ctx.Done(): + return + } + } + }() + go func() { wg.Wait(); close(sorties) }() + + reussis, echoues, vus := 0, 0, 0 + for r := range sorties { + vus++ + if r.ok { + reussis++ + } else { + echoues++ + } + if dire != nil { + etat := "✓" + if !r.ok { + etat = "✗" + } + dire(fmt.Sprintf(" %s %d/%d %s", etat, vus, len(depots), r.depot)) + } + } + return reussis, echoues +} + +// DepotsDuFichier lit une liste de dépôts, un par ligne. +// +// Les lignes vides et les commentaires sautent, les doublons aussi : un même +// dépôt figure dans plusieurs manifestes, et le cloner deux fois ne ferait que +// perdre du temps. +func DepotsDuFichier(chemin string) ([]string, error) { + data, err := os.ReadFile(chemin) + if err != nil { + return nil, err + } + vus := map[string]bool{} + var out []string + for _, l := range strings.Split(string(data), "\n") { + l = strings.TrimSpace(l) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + if !vus[l] { + vus[l] = true + out = append(out, l) + } + } + return out, nil +} + +// PlancherParDefaut : ce qu'on laisse au disque de l'orchestrateur. Assez pour +// qu'une VM en cours de déploiement finisse, et pour que le système respire. +const PlancherParDefaut int64 = 10 << 30 + +// placeSuffisante dit s'il reste de quoi créer un miroir de plus. +// +// La place est relue à CHAQUE appel : le disque se remplit pendant qu'on le +// remplit, et une valeur retenue au démarrage ne dirait rien de l'état où l'on +// est rendu. +func (g *GitMirror) placeSuffisante() bool { + plancher := g.PlancherLibre + if plancher <= 0 { + plancher = PlancherParDefaut + } + var st syscall.Statfs_t + if err := syscall.Statfs(g.Dir, &st); err != nil { + // Illisible : on laisse passer plutôt que de bloquer sur une mesure + // qu'on ne sait pas faire. + return true + } + return int64(st.Bavail)*int64(st.Bsize) > plancher +} + +// Depot décrit un miroir tenu sur le disque. +type Depot struct { + // Chemin est le répertoire du dépôt nu. + Chemin string + // Nom est ce qu'il vaut mieux montrer : « github.com/OCA/server-tools ». + Nom string + // Octets est ce qu'il occupe, Maj la dernière fois qu'il a été rafraîchi. + Octets int64 + Maj time.Time +} + +// Depots rend les miroirs tenus, du plus lourd au plus léger. +// +// Par la TAILLE et non par le nom : la place se surveille à la main — aucune +// éviction n'est écrite — et ce qu'on cherche en la surveillant, c'est ce qui +// pèse. Trois dépôts font ici les trois quarts du total ; les lister par ordre +// alphabétique obligerait à les chercher. +func (g *GitMirror) Depots() []Depot { + if g == nil || g.Dir == "" { + return nil + } + var out []Depot + filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil || !info.IsDir() || + !strings.HasSuffix(p, ".git") { + return nil + } + d := Depot{ + Chemin: p, + Nom: strings.TrimSuffix( + strings.TrimPrefix(p, g.Dir+string(os.PathSeparator)), ".git"), + Maj: info.ModTime(), + } + filepath.Walk(p, func(_ string, i os.FileInfo, e error) error { + if e == nil && i != nil && !i.IsDir() { + d.Octets += i.Size() + } + return nil + }) + out = append(out, d) + // Un dépôt nu n'en contient pas d'autre : inutile de descendre. + return filepath.SkipDir + }) + sort.Slice(out, func(i, j int) bool { return out[i].Octets > out[j].Octets }) + return out +} + +// Retirer efface un miroir. Il se refera au prochain besoin, au prix du +// clonage — c'est la propriété qui rend l'effacement sans danger. +// +// Le chemin est vérifié comme appartenant à la racine des miroirs : un appel +// mal formé ne doit pas pouvoir effacer autre chose. +func (g *GitMirror) Retirer(chemin string) error { + if g == nil || g.Dir == "" { + return fmt.Errorf("miroir éteint") + } + abs, err := filepath.Abs(chemin) + if err != nil { + return err + } + racine, err := filepath.Abs(g.Dir) + if err != nil { + return err + } + if !strings.HasPrefix(abs, racine+string(os.PathSeparator)) || + !strings.HasSuffix(abs, ".git") { + return fmt.Errorf("hors des miroirs : %s", chemin) + } + v := g.verrou(abs) + v.Lock() + defer v.Unlock() + return os.RemoveAll(abs) +} diff --git a/script/qemu_cache/gitmirror_test.go b/script/qemu_cache/gitmirror_test.go new file mode 100644 index 0000000..9b417f9 --- /dev/null +++ b/script/qemu_cache/gitmirror_test.go @@ -0,0 +1,506 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "context" + "net" + "net/http" + "net/http/cgi" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDepotDeURL(t *testing.T) { + cas := []struct { + brut, depot, reste string + }{ + {"https://h/o/d.git/info/refs?service=git-upload-pack", + "https://h/o/d.git", "/info/refs"}, + {"https://h/o/d/info/refs?service=git-upload-pack", + "https://h/o/d", "/info/refs"}, + {"https://h/o/d.git/git-upload-pack", "https://h/o/d.git", + "/git-upload-pack"}, + } + for _, c := range cas { + u, _ := url.Parse(c.brut) + depot, reste, ok := DepotDeURL(u) + if !ok || depot != c.depot || reste != c.reste { + t.Errorf("%s → (%q, %q, %v)", c.brut, depot, reste, ok) + } + } +} + +func TestDepotDeURLRefuse(t *testing.T) { + for _, brut := range []string{ + "https://h/o/d.git/objects/ab/cd", + "https://h/info/refs", + "https://h/miroir/core.db", + } { + u, _ := url.Parse(brut) + if _, _, ok := DepotDeURL(u); ok { + t.Errorf("%s pris pour une négociation", brut) + } + } +} + +// Le chemin du miroir porte l'HÔTE : deux forges peuvent servir « /odoo/odoo », +// et les confondre donnerait à l'une le contenu de l'autre. +func TestCheminMiroirSepareLesForges(t *testing.T) { + g := &GitMirror{Dir: "/var/x"} + a, err := g.CheminMiroir("https://github.com/odoo/odoo.git") + if err != nil { + t.Fatal(err) + } + b, _ := g.CheminMiroir("https://autre.example/odoo/odoo.git") + if a == b { + t.Error("deux forges partagent un miroir") + } + if !strings.HasSuffix(a, ".git") { + t.Errorf("le miroir n'est pas un dépôt nu : %s", a) + } + // « .git » écrit ou non par l'amont donne le même miroir. + c, _ := g.CheminMiroir("https://github.com/odoo/odoo") + if a != c { + t.Errorf("« .git » change le miroir : %s contre %s", a, c) + } +} + +// Un chemin qui remonte écrirait hors de la racine. +func TestCheminMiroirRefuseCeQuiRemonte(t *testing.T) { + g := &GitMirror{Dir: "/var/x"} + for _, brut := range []string{ + "https://h/../../etc/passwd", + "https://h/o/../../..", + "https://h/", + "pas une url", + } { + if chemin, err := g.CheminMiroir(brut); err == nil { + t.Errorf("%s accepté et rendu %q", brut, chemin) + } + } +} + +func TestMiroirEteintQuandRienNestConfigure(t *testing.T) { + if (&GitMirror{}).Actif() { + t.Error("un miroir sans répertoire se dit actif") + } + // Un répertoire sans le programme de git ne sert à rien : mieux vaut le + // relais vers l'amont, qui fonctionne. + g := &GitMirror{Dir: "/var/x", Backend: "/nexiste/pas"} + if g.Actif() { + t.Error("un miroir sans git-http-backend se dit actif") + } +} + +// amontGit monte un dépôt nu ET le serveur qui le publie, comme le ferait une +// forge. L'URL rendue est celle qu'un client — ou le miroir — interroge. +func amontGit(t *testing.T) (string, *httptest.Server) { + t.Helper() + if TrouverBackend() == "" { + t.Skip("git-http-backend absent de cette machine") + } + nu := depotDEssai(t) + racine := filepath.Dir(nu) + h := &cgi.Handler{ + Path: TrouverBackend(), + Dir: racine, + Env: []string{ + "GIT_PROJECT_ROOT=" + racine, + "GIT_HTTP_EXPORT_ALL=1", + }, + InheritEnv: []string{"PATH"}, + } + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv.URL + "/" + filepath.Base(nu), srv +} + +// depotDEssai fabrique un dépôt nu local, servi comme s'il était l'amont. +func depotDEssai(t *testing.T) string { + t.Helper() + racine := t.TempDir() + nu := filepath.Join(racine, "amont.git") + travail := filepath.Join(racine, "travail") + lancer := func(dir string, args ...string) { + t.Helper() + c := exec.Command("git", args...) + c.Dir = dir + c.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e") + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v : %v : %s", args, err, out) + } + } + lancer(racine, "init", "-q", "--bare", "--initial-branch=main", nu) + lancer(racine, "init", "-q", "--initial-branch=main", travail) + if err := os.WriteFile( + filepath.Join(travail, "f.txt"), []byte("bonjour"), 0o644, + ); err != nil { + t.Fatal(err) + } + lancer(travail, "add", "f.txt") + lancer(travail, "commit", "-qm", "premier") + lancer(travail, "push", "-q", nu, "HEAD:refs/heads/main") + return nu +} + +// L'épreuve qui compte : un client clone à travers le miroir, sans que rien +// dans l'invité soit configuré, et le dépôt arrive complet. +func TestClonerAuTraversDuMiroir(t *testing.T) { + if TrouverBackend() == "" { + t.Skip("git-http-backend absent de cette machine") + } + amont, _ := amontGit(t) + g := &GitMirror{Dir: t.TempDir(), Delai: 2 * time.Minute} + + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + u := &url.URL{Path: r.URL.Path, RawQuery: r.URL.RawQuery} + _, reste, ok := DepotDeURL(u) + if !ok { + http.NotFound(w, r) + return + } + // Tout chemin demandé désigne le dépôt qui joue l'amont : ce test + // mesure le miroir, pas le routage. + chemin, pret := g.Assurer(r.Context(), amont) + if !pret { + http.Error(w, "miroir indisponible", 502) + return + } + g.Servir(w, r, chemin, reste) + })) + defer srv.Close() + + for essai := 1; essai <= 2; essai++ { + dest := filepath.Join(t.TempDir(), "copie") + c := exec.Command("git", "clone", "-q", srv.URL+"/essai.git", dest) + c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("clone %d : %v : %s", essai, err, out) + } + contenu, err := os.ReadFile(filepath.Join(dest, "f.txt")) + if err != nil || string(contenu) != "bonjour" { + t.Fatalf("clone %d : contenu %q, err %v", essai, contenu, err) + } + } +} + +// Un amont muet mais un miroir déjà là : le miroir sert. C'est ce qui rend un +// déploiement sans réseau possible pour git, ce qu'aucun cache de réponses ne +// peut faire de ce protocole. +func TestUnMiroirExistantSertQuandLAmontEstMuet(t *testing.T) { + amont, srvAmont := amontGit(t) + g := &GitMirror{Dir: t.TempDir(), Delai: time.Minute} + if _, pret := g.Assurer(context.Background(), amont); !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + // L'amont disparaît : le serveur qui le publiait est fermé, exactement ce + // que voit une VM quand le réseau tombe. + srvAmont.Close() + g.Frais = 0 // forcer une tentative de rafraîchissement + chemin, pret := g.Assurer(context.Background(), amont) + if !pret { + t.Error("le miroir refuse de servir alors qu'il existe") + } + if _, err := os.Stat(filepath.Join(chemin, "HEAD")); err != nil { + t.Errorf("le miroir a été effacé : %v", err) + } +} + +// Un amont muet SANS miroir doit rendre faux : l'appelant retombe alors sur le +// relais, qui donnera au client la vraie erreur du réseau plutôt qu'une erreur +// inventée ici. +func TestAucunMiroirEtAucunAmont(t *testing.T) { + g := &GitMirror{Dir: t.TempDir(), Delai: 20 * time.Second} + chemin, pret := g.Assurer( + context.Background(), "https://127.0.0.1:1/nexiste/pas.git") + if pret { + t.Errorf("se dit prêt avec %q", chemin) + } + // Aucun DÉPÔT ne doit rester derrière : un clonage à moitié fait serait + // pris pour un miroir valide à la requête suivante. Un répertoire vide, + // lui, ne trompe personne. + filepath.Walk(g.Dir, func(p string, info os.FileInfo, err error) error { + if err == nil && info != nil && info.Name() == "HEAD" { + t.Errorf("un miroir incomplet subsiste : %s", p) + } + return nil + }) +} + +func TestDepotsDuFichier(t *testing.T) { + f := filepath.Join(t.TempDir(), "liste") + contenu := strings.Join([]string{ + "# un commentaire", + "", + "https://h/a.git", + " https://h/b.git ", + "https://h/a.git", // le même dépôt figure dans plusieurs manifestes + }, "\n") + if err := os.WriteFile(f, []byte(contenu), 0o644); err != nil { + t.Fatal(err) + } + got, err := DepotsDuFichier(f) + if err != nil { + t.Fatal(err) + } + attendu := []string{"https://h/a.git", "https://h/b.git"} + if len(got) != len(attendu) { + t.Fatalf("%d dépôts, %d attendus : %v", len(got), len(attendu), got) + } + for i := range attendu { + if got[i] != attendu[i] { + t.Errorf("dépôt %d : %q, attendu %q", i, got[i], attendu[i]) + } + } +} + +// Un dépôt qui échoue ne doit pas emporter les autres : sur une liste de +// trois cents, il y a toujours un dépôt privé, déplacé ou retiré, et tout +// arrêter pour lui perdrait le travail déjà fait. +func TestUnDepotEnEchecNEmportePasLesAutres(t *testing.T) { + bon, _ := amontGit(t) + g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second} + depots := []string{ + bon, + "https://127.0.0.1:1/absent.git", + bon + "-autre", + } + reussis, echoues := g.Prefetch(context.Background(), depots, 3, nil) + if reussis < 1 { + t.Errorf("%d réussite(s) : le dépôt joignable n'a pas été pris", + reussis) + } + if echoues < 1 { + t.Error("aucun échec compté alors qu'un dépôt est injoignable") + } + if reussis+echoues != len(depots) { + t.Errorf("%d + %d ne fait pas %d", reussis, echoues, len(depots)) + } +} + +// Le pré-remplissage rend le miroir prêt : la machine suivante n'a plus qu'à +// être servie, ce qui est tout l'objet de l'avance. +func TestApresPrefetchLeMiroirEstPret(t *testing.T) { + amont, _ := amontGit(t) + g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second} + if r, e := g.Prefetch( + context.Background(), []string{amont}, 2, nil, + ); r != 1 || e != 0 { + t.Fatalf("pré-remplissage : %d réussis, %d échoués", r, e) + } + depots, octets := g.Occupation() + if depots != 1 || octets == 0 { + t.Errorf("occupation : %d dépôts, %d octets", depots, octets) + } +} + +// Le plancher protège le disque de l'orchestrateur. +// +// Un miroir est COMPLET là où « repo sync » clone en profondeur un : le +// facteur entre les deux est celui de l'historique, et il ne se devine pas. +// Sans plancher, une seule installation qui tire trois cents dépôts peut +// remplir le disque de la machine qui héberge toutes les VM. +func TestLePlancherRefuseUnMiroirDeplus(t *testing.T) { + amont, _ := amontGit(t) + g := &GitMirror{ + Dir: t.TempDir(), + Delai: 30 * time.Second, + // Plus que tout disque n'en offre : aucun miroir NEUF ne doit passer. + PlancherLibre: 1 << 62, + } + if chemin, pret := g.Assurer(context.Background(), amont); pret { + t.Errorf("un miroir a été créé sous le plancher : %s", chemin) + } + if depots, _ := g.Occupation(); depots != 0 { + t.Errorf("%d dépôt(s) créés malgré le plancher", depots) + } +} + +// Un miroir DÉJÀ tenu continue d'être servi : une mise à jour ne coûte que ce +// qui a changé, et le refuser priverait de tout ce qui est déjà là. +func TestLePlancherNEmpechePasDeServirLexistant(t *testing.T) { + amont, _ := amontGit(t) + g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second} + if _, pret := g.Assurer(context.Background(), amont); !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + g.PlancherLibre = 1 << 62 + g.Frais = 0 + if _, pret := g.Assurer(context.Background(), amont); !pret { + t.Error("un miroir existant est refusé à cause du plancher") + } +} + +// Ce que le miroir sert doit être COMPTÉ. +// +// La passerelle CGI écrit directement dans la réponse : le journal notait zéro +// octet pour tout ce que le miroir servait, et c'est le chemin qui porte +// l'essentiel du trafic d'une installation. Un outil dont le journal EST la +// mesure ne peut pas avoir un chemin muet. +func TestCeQueLeMiroirSertEstCompte(t *testing.T) { + amont, _ := amontGit(t) + g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second} + chemin, pret := g.Assurer(context.Background(), amont) + if !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + + var pese int64 + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + u := &url.URL{Path: r.URL.Path, RawQuery: r.URL.RawQuery} + _, reste, ok := DepotDeURL(u) + if !ok { + http.NotFound(w, r) + return + } + pese += g.Servir(w, r, chemin, reste) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "copie") + c := exec.Command("git", "clone", "-q", srv.URL+"/essai.git", dest) + c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("clone : %v : %s", err, out) + } + if pese <= 0 { + t.Error("le miroir a servi un clone entier et le journal dirait zéro") + } +} + +// La liste est triée par TAILLE : c'est ce qu'on cherche quand on surveille +// la place à la main, et trois dépôts font les trois quarts du total. +func TestLesDepotsSontTriesParTaille(t *testing.T) { + g := &GitMirror{Dir: t.TempDir()} + for nom, poids := range map[string]int{ + "h/petit.git": 10, "h/gros.git": 5000, "h/moyen.git": 500, + } { + d := filepath.Join(g.Dir, nom) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(d, "HEAD"), make([]byte, poids), 0o644, + ); err != nil { + t.Fatal(err) + } + } + depots := g.Depots() + if len(depots) != 3 { + t.Fatalf("%d dépôts vus", len(depots)) + } + for i, attendu := range []string{"h/gros", "h/moyen", "h/petit"} { + if depots[i].Nom != attendu { + t.Errorf("rang %d : %q, attendu %q", i, depots[i].Nom, attendu) + } + } + if depots[0].Octets <= depots[2].Octets { + t.Error("les tailles ne sont pas mesurées") + } +} + +// Effacer un miroir est sans danger : il se refait au prochain besoin. Mais +// l'effacement doit rester DANS les miroirs — un appel mal formé ne doit pas +// pouvoir emporter autre chose. +func TestRetirerRefuseCeQuiEstDehors(t *testing.T) { + g := &GitMirror{Dir: t.TempDir()} + dehors := filepath.Join(t.TempDir(), "ailleurs.git") + if err := os.MkdirAll(dehors, 0o755); err != nil { + t.Fatal(err) + } + for _, cible := range []string{dehors, "/etc", g.Dir, g.Dir + "/x"} { + if err := g.Retirer(cible); err == nil { + t.Errorf("%s a été accepté", cible) + } + } + if _, err := os.Stat(dehors); err != nil { + t.Errorf("un répertoire hors des miroirs a été effacé : %v", err) + } +} + +func TestRetirerEffaceLeMiroir(t *testing.T) { + amont, _ := amontGit(t) + g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second} + chemin, pret := g.Assurer(context.Background(), amont) + if !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + if err := g.Retirer(chemin); err != nil { + t.Fatalf("effacement : %v", err) + } + if depots, _ := g.Occupation(); depots != 0 { + t.Errorf("%d dépôt(s) subsistent", depots) + } + // Et il se refait : c'est ce qui rend l'effacement sans danger. + if _, pret := g.Assurer(context.Background(), amont); !pret { + t.Error("le miroir ne se refait pas après effacement") + } +} + +// Un miroir qui existe est déjà servable : quand l'amont ne répond pas, il +// doit sortir TOUT DE SUITE, pas au terme du délai d'un clonage. +// +// Sans ce délai propre, un amont coupé faisait attendre chaque dépôt jusqu'à +// son terme : pour les trois cents dépôts d'une installation, des heures pour +// un déploiement que le disque pouvait servir en entier. +func TestUnRafraichissementNattendPasCommeUnClonage(t *testing.T) { + amont, srv := amontGit(t) + g := &GitMirror{ + Dir: t.TempDir(), + Delai: 30 * time.Minute, + DelaiMaj: 2 * time.Second, + } + if _, pret := g.Assurer(context.Background(), amont); !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + // L'amont devient MUET : il accepte la connexion et ne répond jamais, + // exactement ce que fait un paquet jeté sans être refusé. + srv.Close() + muet, err := net.Listen("tcp", hoteDe(t, amont)) + if err != nil { + t.Skipf("le port de l'amont n'a pas pu être repris : %v", err) + } + defer muet.Close() + go func() { + for { + c, err := muet.Accept() + if err != nil { + return + } + _ = c // accepté, jamais répondu + } + }() + + g.Frais = 0 + debut := time.Now() + chemin, pret := g.Assurer(context.Background(), amont) + ecoule := time.Since(debut) + if !pret || chemin == "" { + t.Fatal("le miroir existant n'a pas été servi") + } + if ecoule > 20*time.Second { + t.Errorf("le rafraîchissement a duré %v : le délai du clonage a"+ + " été appliqué", ecoule) + } +} + +func hoteDe(t *testing.T, brut string) string { + t.Helper() + u, err := url.Parse(brut) + if err != nil { + t.Fatal(err) + } + return u.Host +} diff --git a/script/qemu_cache/go.mod b/script/qemu_cache/go.mod new file mode 100644 index 0000000..b6be1bb --- /dev/null +++ b/script/qemu_cache/go.mod @@ -0,0 +1,3 @@ +module erplibre_go_qemu_cache + +go 1.23 diff --git a/script/qemu_cache/instantane.go b/script/qemu_cache/instantane.go new file mode 100644 index 0000000..3df41e5 --- /dev/null +++ b/script/qemu_cache/instantane.go @@ -0,0 +1,91 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +// dossierSuite isole « …/dists// » du chemin d'un index apt. +var dossierSuite = regexp.MustCompile(`^(.*/dists/[^/]+/)`) + +// indexIncoherent dit si le corps gardé sous cette clé appartient à un +// instantané PLUS RÉCENT que la signature gardée pour la même suite. +// +// apt lit d'abord « InRelease », puis exige de chaque index la taille et la +// somme qui y sont écrites. Hors ligne, chaque objet sort du magasin avec sa +// propre date : un index rafraîchi samedi, servi sous une signature gardée +// depuis vendredi, fait échouer apt sur « File has unexpected size » ou +// « Hash Sum mismatch ». L'installation s'arrête plus bas sur des +// dépendances introuvables — un message qui accuse le dépôt, jamais le +// cache. Ne rien servir vaut mieux : apt garde alors ses propres listes, +// dit « old ones used instead » et poursuit. +// +// La comparaison porte sur le « Last-Modified » de l'AMONT, jamais sur la +// date de stockage : celle-ci est renouvelée à chaque service, si bien +// qu'une signature servie après l'index paraîtrait plus jeune que lui. +// +// Trois objets échappent au jugement : ceux qui sont adressés par leur +// contenu (« by-hash »), dont le nom EST la somme ; les signatures +// elles-mêmes, qui sont l'étalon ; et tout ce qui ne vit pas sous +// « /dists/ ». +func (p *Proxy) indexIncoherent(u *url.URL, key string) bool { + if p == nil || p.Store == nil || u == nil { + return false + } + if strings.Contains(u.Path, "/by-hash/") { + return false + } + if strings.HasSuffix(u.Path, "/InRelease") || + strings.HasSuffix(u.Path, "/Release") || + strings.HasSuffix(u.Path, "/Release.gpg") { + return false + } + trouve := dossierSuite.FindStringSubmatch(u.Path) + if trouve == nil { + return false + } + signature := *u + signature.Path = trouve[1] + "InRelease" + signature.RawQuery = "" + metaSig, err := p.Store.LireMeta(CleDe("GET", &signature)) + if err != nil { + // Aucune signature gardée : rien à contredire, et l'index gardé est + // tout ce que le hors-ligne possède. + return false + } + metaIdx, err := p.Store.LireMeta(key) + if err != nil { + return false + } + dateSig, okSig := dateAmont(metaSig) + dateIdx, okIdx := dateAmont(metaIdx) + if !okSig || !okIdx { + // Sans les deux dates, on ne sait pas : servir reste le comportement + // qui rend un déploiement hors ligne possible. + return false + } + return dateIdx.After(dateSig) +} + +// dateAmont rend le « Last-Modified » écrit par l'amont, et dit s'il y en a +// un. C'est la seule date que le magasin ne retouche jamais. +func dateAmont(m *Meta) (t time.Time, ok bool) { + if m == nil || m.Header == nil { + return time.Time{}, false + } + brut := http.Header(m.Header).Get("Last-Modified") + if brut == "" { + return time.Time{}, false + } + quand, err := http.ParseTime(brut) + if err != nil { + return time.Time{}, false + } + return quand, true +} diff --git a/script/qemu_cache/instantane_test.go b/script/qemu_cache/instantane_test.go new file mode 100644 index 0000000..2cf4a5b --- /dev/null +++ b/script/qemu_cache/instantane_test.go @@ -0,0 +1,141 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "net/http" + "net/url" + "testing" +) + +// vendredi et samedi : deux instantanés d'un dépôt apt, à un jour d'écart. +const ( + vendredi = "Fri, 11 Sep 2026 07:18:59 GMT" + samedi = "Sat, 12 Sep 2026 13:06:19 GMT" +) + +// garnir range un corps sous son URL, avec la date que l'amont lui donne. +func garnir(t *testing.T, p *Proxy, brut, corps, lastModified string) { + t.Helper() + u, err := url.Parse(brut) + if err != nil { + t.Fatal(err) + } + w, err := p.Store.NewWriter(CleDe("GET", u), Meta{ + URL: u.String(), Method: "GET", Status: http.StatusOK, + Header: http.Header{"Last-Modified": []string{lastModified}}, + Class: ClassVolatile.String(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(corps)); err != nil { + t.Fatal(err) + } + if err := w.Commit(int64(len(corps))); err != nil { + t.Fatal(err) + } +} + +// Un index plus récent que la signature qui l'annonce n'est pas servi. +// +// apt exige de chaque index la taille et la somme écrites dans la +// signature : un index pris à un autre instantané le fait échouer sur +// « File has unexpected size », et l'installation s'arrête plus bas sur des +// dépendances introuvables — un message qui accuse le dépôt, jamais le +// cache. +func TestUnIndexPlusRecentQueSaSignatureNeSortPas(t *testing.T) { + a := nouvelAmont(t, "peu importe") + p := proxyDeTest(t) + base := "http://" + a.hote() + "/ubuntu/dists/resolute-updates/" + garnir(t, p, base+"InRelease", "signature de vendredi", vendredi) + garnir(t, p, base+"main/binary-amd64/Packages.xz", "index de samedi", samedi) + hote := a.hote() + a.srv.Close() + + w := joue(t, p, "GET", hote, "/ubuntu/dists/resolute-updates/InRelease") + if w.Code != http.StatusOK { + t.Fatalf("la signature gardée doit sortir : code %d", w.Code) + } + w = joue(t, p, "GET", hote, + "/ubuntu/dists/resolute-updates/main/binary-amd64/Packages.xz") + if w.Code == http.StatusOK { + t.Errorf("l'index de samedi est sorti sous la signature de vendredi") + } +} + +// L'inverse est le cas NORMAL : les deux viennent du même passage en ligne, +// et refuser de servir là romprait le déploiement hors ligne. +func TestUnIndexAussiVieuxQueSaSignatureSort(t *testing.T) { + a := nouvelAmont(t, "peu importe") + p := proxyDeTest(t) + base := "http://" + a.hote() + "/ubuntu/dists/resolute/" + garnir(t, p, base+"InRelease", "signature", vendredi) + garnir(t, p, base+"main/binary-amd64/Packages.xz", "index", vendredi) + hote := a.hote() + a.srv.Close() + + w := joue(t, p, "GET", hote, + "/ubuntu/dists/resolute/main/binary-amd64/Packages.xz") + if w.Code != http.StatusOK || w.Body.String() != "index" { + t.Fatalf("code %d, corps %q : l'index du même instantané doit sortir", + w.Code, w.Body.String()) + } +} + +// Un objet adressé par son contenu ne se juge pas : son nom EST sa somme, +// et apt l'accepte quelle que soit la date de la signature. +func TestUnObjetParEmpreinteSortQuandMeme(t *testing.T) { + a := nouvelAmont(t, "peu importe") + p := proxyDeTest(t) + base := "http://" + a.hote() + "/ubuntu/dists/resolute-updates/" + garnir(t, p, base+"InRelease", "signature de vendredi", vendredi) + parHash := base + "main/binary-amd64/by-hash/SHA256/" + + "e55707cc7a5ad03d7f700ccb6646fd5c6ecbc852c4c826f5edbb5a321358d0ee" + garnir(t, p, parHash, "index de samedi", samedi) + hote := a.hote() + a.srv.Close() + + w := joue(t, p, "GET", hote, + "/ubuntu/dists/resolute-updates/main/binary-amd64/by-hash/SHA256/"+ + "e55707cc7a5ad03d7f700ccb6646fd5c6ecbc852c4c826f5edbb5a321358d0ee") + if w.Code != http.StatusOK { + t.Errorf("un objet par empreinte a été retenu : code %d", w.Code) + } +} + +// Sans signature gardée, il n'y a rien à contredire : l'index gardé est tout +// ce que le hors-ligne possède, et le retenir ne servirait personne. +func TestSansSignatureLIndexSortQuandMeme(t *testing.T) { + a := nouvelAmont(t, "peu importe") + p := proxyDeTest(t) + garnir(t, p, "http://"+a.hote()+ + "/ubuntu/dists/orpheline/main/binary-amd64/Packages.xz", + "index seul", samedi) + hote := a.hote() + a.srv.Close() + + w := joue(t, p, "GET", hote, + "/ubuntu/dists/orpheline/main/binary-amd64/Packages.xz") + if w.Code != http.StatusOK || w.Body.String() != "index seul" { + t.Errorf("code %d, corps %q : l'index sans signature doit sortir", + w.Code, w.Body.String()) + } +} + +// Hors de « /dists/ », aucune signature n'entre en jeu : un paquet ne se +// juge pas sur la date d'un index. +func TestHorsDeDistsRienNestJuge(t *testing.T) { + a := nouvelAmont(t, "peu importe") + p := proxyDeTest(t) + garnir(t, p, "http://"+a.hote()+"/ubuntu/pool/main/x/xz_5.6_amd64.deb", + "le paquet", samedi) + hote := a.hote() + a.srv.Close() + + w := joue(t, p, "GET", hote, "/ubuntu/pool/main/x/xz_5.6_amd64.deb") + if w.Code != http.StatusOK { + t.Errorf("un paquet a été retenu : code %d", w.Code) + } +} diff --git a/script/qemu_cache/joignable.go b/script/qemu_cache/joignable.go new file mode 100644 index 0000000..e31cf57 --- /dev/null +++ b/script/qemu_cache/joignable.go @@ -0,0 +1,221 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "context" + "errors" + "net" + "net/url" + "os" + "strings" + "sync" + "syscall" + "time" +) + +// DelaiEtablissement borne l'établissement d'une connexion vers l'amont, pour +// le relais comme pour la sonde du miroir git. +// +// Un pare-feu qui JETTE les paquets sans les refuser fait pendre +// l'établissement jusqu'à ce délai : c'est le prix que paie chaque tentative +// vers un amont coupé, et la raison d'être de la mémoire qui suit. +const DelaiEtablissement = 4 * time.Second + +// FenetreMuetParDefaut : combien de temps un amont dont l'établissement vient +// d'échouer est tenu pour muet sans être retenté. +// +// Courte à dessein. Elle suffit à épargner le délai d'établissement aux +// dizaines de requêtes qui visent le même hôte en rafale, et elle rend l'amont +// à sa première chance dès qu'il revient. +const FenetreMuetParDefaut = 20 * time.Second + +// SentinelleAmonts nomme le témoin que la levée d'une coupure touche dans le +// magasin pour annuler la mémoire des amonts muets. Le nom est tenu en accord +// avec le code qui pose la coupure : toucher un fichier que rien ne lit +// laisserait les amonts muets jusqu'à la fin de leur fenêtre. +const SentinelleAmonts = ".amonts-oublies" + +// errAmontConnuMuet dit qu'aucune connexion n'a été tentée : l'établissement +// vers cet amont a échoué il y a moins d'une fenêtre. +var errAmontConnuMuet = errors.New( + "amont connu muet : l'établissement vient d'échouer, il n'est pas retenté") + +// Joignabilite retient, par « hôte:port », l'instant du dernier +// établissement de connexion manqué. +// +// Sans elle, chaque requête vers un amont coupé repaie le délai +// d'établissement entier avant de rabattre sur la copie gardée : une +// installation hors ligne en enchaîne des centaines, et l'attente domine +// alors tout le reste. Avec elle, la première requête paie, les suivantes +// passent directement au repli. +// +// Seul un échec d'ÉTABLISSEMENT compte : délai de connexion, refus, hôte ou +// réseau injoignable. Un serveur lent à rendre ses en-têtes, une poignée de +// main TLS manquée ou un statut HTTP disent que l'amont est LÀ, et le tenir +// pour muet servirait une copie périmée à la place d'une réponse qui venait. +// +// Partagée entre le relais et le miroir git : un hôte coupé l'est pour les +// deux. Toutes les méthodes acceptent un récepteur nul, qui ne retient rien. +type Joignabilite struct { + // Fenetre borne la mémoire d'un échec. Nulle, la valeur par défaut + // s'applique. + Fenetre time.Duration + // Maintenant rend l'heure courante ; les tests la remplacent pour faire + // passer une fenêtre sans l'attendre. + Maintenant func() time.Time + // Sentinelle est un fichier dont la date annule la mémoire : tout échec + // antérieur est oublié. Il n'existe aucun canal vers le service en + // marche, et sans ce témoin les amonts notés muets PENDANT une coupure + // le restent jusqu'à la fin de la fenêtre — la première requête d'après + // la levée tombe alors dans le repli alors que le réseau est revenu. + // Vide, rien n'est consulté. + Sentinelle string + + mu sync.Mutex + echecs map[string]time.Time +} + +// NouvelleJoignabilite rend une mémoire vide, à la fenêtre par défaut. +func NouvelleJoignabilite() *Joignabilite { + return &Joignabilite{Fenetre: FenetreMuetParDefaut} +} + +func (j *Joignabilite) maintenant() time.Time { + if j.Maintenant != nil { + return j.Maintenant() + } + return time.Now() +} + +func (j *Joignabilite) fenetre() time.Duration { + if j.Fenetre > 0 { + return j.Fenetre + } + return FenetreMuetParDefaut +} + +// ConnuMuet dit si l'établissement vers cette adresse a échoué depuis moins +// d'une fenêtre. Une entrée échue est oubliée au passage. +func (j *Joignabilite) ConnuMuet(adresse string) bool { + if j == nil { + return false + } + j.mu.Lock() + defer j.mu.Unlock() + quand, ok := j.echecs[adresse] + if !ok { + return false + } + if j.maintenant().Sub(quand) >= j.fenetre() { + delete(j.echecs, adresse) + return false + } + // Le témoin touché après l'échec : la coupure a été levée depuis, et + // l'amont mérite une nouvelle chance immédiate. Un témoin illisible ou + // absent ne change rien — la fenêtre reprend seule son office. + if j.Sentinelle != "" { + if info, err := os.Stat(j.Sentinelle); err == nil && + info.ModTime().After(quand) { + delete(j.echecs, adresse) + return false + } + } + return true +} + +// Echec retient l'adresse si l'erreur est un échec d'établissement, et dit +// si elle l'a été. Toute autre erreur est ignorée. +func (j *Joignabilite) Echec(adresse string, err error) bool { + if j == nil || !estEchecDEtablissement(err) { + return false + } + j.mu.Lock() + defer j.mu.Unlock() + if j.echecs == nil { + j.echecs = map[string]time.Time{} + } + j.echecs[adresse] = j.maintenant() + return true +} + +// Reussite oublie l'adresse : l'amont vient de répondre. +func (j *Joignabilite) Reussite(adresse string) { + if j == nil { + return + } + j.mu.Lock() + defer j.mu.Unlock() + delete(j.echecs, adresse) +} + +// estEchecDEtablissement dit si l'erreur vient de l'ÉTABLISSEMENT de la +// connexion, et non de ce qui la suit. +// +// La bibliothèque rend un établissement manqué comme un « *net.OpError » +// d'opération « dial », enveloppé par le client HTTP dans un « *url.Error » : +// errors.As traverse les deux. Le délai compte, et parmi les codes système +// ceux qui disent « personne ne répond à cette adresse ». Un nom qui n'existe +// pas n'en est pas : ce n'est pas un amont coupé, c'est un amont inexistant. +func estEchecDEtablissement(err error) bool { + var op *net.OpError + if err == nil || !errors.As(err, &op) || op.Op != "dial" { + return false + } + if op.Timeout() { + return true + } + var errno syscall.Errno + if errors.As(op.Err, &errno) { + switch errno { + case syscall.ECONNREFUSED, syscall.EHOSTUNREACH, + syscall.ENETUNREACH, syscall.ETIMEDOUT: + return true + } + } + return false +} + +// adresseAmont rend la clé « hôte:port » d'une URL, le port déduit du schéma +// quand l'URL n'en porte pas : « https://h/x » et « https://h:443/y » visent +// la même machine et doivent partager leur mémoire. +func adresseAmont(u *url.URL) string { + if u == nil { + return "" + } + hote := strings.ToLower(u.Hostname()) + port := u.Port() + if port == "" { + switch strings.ToLower(u.Scheme) { + case "https": + port = "443" + case "http": + port = "80" + default: + return hote + } + } + return net.JoinHostPort(hote, port) +} + +// sonderTCP ouvre puis referme une connexion vers l'adresse, sans rien y +// dire. Rend l'erreur d'établissement, nulle quand l'amont a accepté. +func sonderTCP(ctx context.Context, adresse string) error { + d := net.Dialer{Timeout: DelaiEtablissement} + c, err := d.DialContext(ctx, "tcp", adresse) + if err != nil { + return err + } + return c.Close() +} + +// decrireMuet rend la phrase du message hors ligne qui dit que l'amont n'a +// pas été retenté, ou "" quand la cause est un vrai échec du réseau. +func decrireMuet(cause error) string { + if !errors.Is(cause, errAmontConnuMuet) { + return "" + } + return "L'établissement vers cet amont vient d'échouer : aucune connexion\n" + + "n'a été tentée pour cette requête, l'amont sera retenté sous peu.\n" +} diff --git a/script/qemu_cache/joignable_test.go b/script/qemu_cache/joignable_test.go new file mode 100644 index 0000000..c5230a1 --- /dev/null +++ b/script/qemu_cache/joignable_test.go @@ -0,0 +1,617 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "bytes" + "context" + "errors" + "log" + "net" + "net/http" + "net/http/cgi" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" +) + +// refusEtablissement est l'erreur que rend un établissement refusé : un +// « *net.OpError » d'opération « dial » portant ECONNREFUSED. +func refusEtablissement() error { + return &net.OpError{Op: "dial", Net: "tcp", Err: &os.SyscallError{ + Syscall: "connect", Err: syscall.ECONNREFUSED, + }} +} + +// Seul un échec d'ÉTABLISSEMENT fait tenir un amont pour muet. Un serveur +// lent, une poignée de main manquée ou un nom inexistant disent autre chose. +func TestEstEchecDEtablissement(t *testing.T) { + enveloppe := func(err error) error { + return &url.Error{Op: "Get", URL: "http://amont.example/x", Err: err} + } + muets := []error{ + refusEtablissement(), + enveloppe(refusEtablissement()), + &net.OpError{Op: "dial", Err: os.ErrDeadlineExceeded}, + &net.OpError{Op: "dial", Err: &os.SyscallError{Err: syscall.EHOSTUNREACH}}, + &net.OpError{Op: "dial", Err: &os.SyscallError{Err: syscall.ENETUNREACH}}, + } + for _, err := range muets { + if !estEchecDEtablissement(err) { + t.Errorf("%v n'est pas reconnu comme un établissement manqué", err) + } + } + vivants := []error{ + nil, + // Un serveur lent à répondre est LÀ : le tenir pour muet servirait + // une copie périmée à la place d'une réponse qui venait. + &net.OpError{Op: "read", Err: os.ErrDeadlineExceeded}, + enveloppe(errors.New("net/http: timeout awaiting response headers")), + errors.New("net/http: TLS handshake timeout"), + errors.New("remote error: tls: handshake failure"), + &net.OpError{Op: "dial", Err: &net.DNSError{ + Err: "no such host", Name: "absent.example.invalid", IsNotFound: true, + }}, + &net.OpError{Op: "dial", Err: context.Canceled}, + } + for _, err := range vivants { + if estEchecDEtablissement(err) { + t.Errorf("%v pris pour un amont muet", err) + } + } +} + +// La mémoire tient une fenêtre, puis rend l'amont à sa première chance. +func TestLaMemoireDUnAmontMuetSEteintAvecLaFenetre(t *testing.T) { + maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + j := &Joignabilite{ + Fenetre: 20 * time.Second, + Maintenant: func() time.Time { return maintenant }, + } + const a = "amont.example:443" + if !j.Echec(a, refusEtablissement()) || !j.ConnuMuet(a) { + t.Fatal("un établissement refusé n'est pas retenu") + } + maintenant = maintenant.Add(19 * time.Second) + if !j.ConnuMuet(a) { + t.Error("l'amont est oublié avant la fin de la fenêtre") + } + maintenant = maintenant.Add(time.Second) + if j.ConnuMuet(a) { + t.Error("l'amont reste muet au-delà de la fenêtre") + } + + j.Echec(a, refusEtablissement()) + j.Reussite(a) + if j.ConnuMuet(a) { + t.Error("une réussite n'efface pas l'échec") + } + if j.Echec(a, errors.New("net/http: timeout awaiting response headers")) || + j.ConnuMuet(a) { + t.Error("une erreur qui n'est pas un établissement est retenue") + } + var nulle *Joignabilite + nulle.Echec(a, refusEtablissement()) + if nulle.ConnuMuet(a) { + t.Error("une mémoire nulle retient quelque chose") + } +} + +// La levée d'une coupure touche un témoin : les amonts notés muets pendant +// qu'elle tenait méritent leur chance tout de suite, sans attendre la fin de +// leur fenêtre. Rien d'autre ne le dit au service, qui n'a aucun canal. +func TestLeTemoinDeLeveeOublieLesAmontsMuets(t *testing.T) { + temoin := filepath.Join(t.TempDir(), SentinelleAmonts) + maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + j := &Joignabilite{ + Fenetre: 20 * time.Second, + Maintenant: func() time.Time { return maintenant }, + Sentinelle: temoin, + } + const a = "amont.example:443" + j.Echec(a, refusEtablissement()) + if !j.ConnuMuet(a) { + t.Fatal("l'échec n'est pas retenu") + } + if err := os.WriteFile(temoin, nil, 0o644); err != nil { + t.Fatal(err) + } + // Un témoin ANTÉRIEUR à l'échec ne dit rien : c'est une levée d'avant, et + // l'amont a été trouvé muet depuis. + vieux := maintenant.Add(-time.Hour) + if err := os.Chtimes(temoin, vieux, vieux); err != nil { + t.Fatal(err) + } + if !j.ConnuMuet(a) { + t.Error("un témoin plus vieux que l'échec l'efface") + } + // Touché APRÈS : la coupure vient d'être levée. + neuf := maintenant.Add(time.Hour) + if err := os.Chtimes(temoin, neuf, neuf); err != nil { + t.Fatal(err) + } + if j.ConnuMuet(a) { + t.Error("le témoin ne rend pas sa chance à l'amont") + } + // Un témoin absent laisse la fenêtre faire son office, sans erreur. + j2 := &Joignabilite{ + Fenetre: 20 * time.Second, + Maintenant: func() time.Time { return maintenant }, + Sentinelle: filepath.Join(t.TempDir(), "jamais-cree"), + } + j2.Echec(a, refusEtablissement()) + if !j2.ConnuMuet(a) { + t.Error("un témoin absent efface la mémoire") + } +} + +// Un amont muet n'est pas recomposé à chaque requête : la première paie le +// délai d'établissement, les suivantes vont droit au repli — et le repli +// sert la copie gardée comme avant. +func TestUnAmontMuetNEstPasRecomposeAChaqueRequete(t *testing.T) { + a := nouvelAmontScripte(t, servir("index courant")) + p := proxyDeTest(t) + maintenant := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + p.Muets = &Joignabilite{Maintenant: func() time.Time { return maintenant }} + + var coupe atomic.Bool + var composes int64 + cible := a.hote() + tr := p.Client.Transport.(*http.Transport) + tr.Proxy = nil + // Chaque requête compose : une connexion gardée ouverte depuis le + // remplissage répondrait à la place de l'amont coupé. + tr.DisableKeepAlives = true + tr.DialContext = func(ctx context.Context, reseau, _ string) (net.Conn, error) { + if coupe.Load() { + atomic.AddInt64(&composes, 1) + return nil, refusEtablissement() + } + return (&net.Dialer{}).DialContext(ctx, reseau, cible) + } + const hote = "miroir.example.invalid" + + if w := joue(t, p, "GET", hote, "/depot/etat"); w.Code != http.StatusOK { + t.Fatalf("remplissage : code %d", w.Code) + } + coupe.Store(true) + for i := 1; i <= 3; i++ { + w := joue(t, p, "GET", hote, "/depot/etat") + if w.Code != http.StatusOK || w.Body.String() != "index courant" { + t.Fatalf("hors ligne %d : %d %q", i, w.Code, w.Body.String()) + } + } + if n := atomic.LoadInt64(&composes); n != 1 { + t.Errorf("%d tentatives d'établissement pour trois requêtes, attendu 1", n) + } + + // Sans copie, rien ne remplace l'amont : la requête compose malgré la + // mémoire, et le 504 dit la vraie cause, pas un amont non retenté. + w := joue(t, p, "GET", hote, "/jamais-vu") + if w.Code != http.StatusGatewayTimeout { + t.Fatalf("code %d, attendu 504", w.Code) + } + if strings.Contains(w.Body.String(), "aucune connexion") { + t.Errorf("le message dit l'amont non retenté alors qu'il l'a été :\n%s", + w.Body.String()) + } + if n := atomic.LoadInt64(&composes); n != 2 { + t.Errorf("%d tentatives, attendu 2 : une requête sans repli n'a pas"+ + " composé", n) + } + + // La fenêtre passée, l'amont a de nouveau sa chance, copie ou non. + maintenant = maintenant.Add(FenetreMuetParDefaut) + joue(t, p, "GET", hote, "/depot/etat") + if n := atomic.LoadInt64(&composes); n != 3 { + t.Errorf("%d tentatives après la fenêtre, attendu 3", n) + } +} + +// La mémoire ne fait sauter l'amont qu'aux requêtes qui ont un repli. Ici +// l'amont répond pendant tout le test : seul l'hôte est retenu comme muet, +// ce que laisse un établissement manqué une fois. +// +// Avec un repli — corps gardé, copie du client, statut gardé —, la réponse +// sort sans composer. Sans repli — rien en réserve, méthode d'écriture —, la +// requête compose, et l'amont la sert : un aléa passager ne devient pas un +// « 504 » pour tout ce que le magasin n'a pas. +func TestUnAmontConnuMuetNEstSauteQuAvecUnRepli(t *testing.T) { + a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/refus" { + refuser(http.StatusNotFound)(w, r) + return + } + servir("réponse de l'amont")(w, r) + }) + p := proxyDeTest(t) + p.Muets = NouvelleJoignabilite() + hote := a.hote() + for _, c := range []string{"/depot/etat", "/refus"} { + joue(t, p, "GET", hote, c) + } + muet := func() { p.Muets.Echec(hote, refusEtablissement()) } + + muet() + avant := a.appels() + for _, c := range []struct { + chemin, issue string + code int + entetes []string + }{ + {"/depot/etat", OutcomeStale, http.StatusOK, nil}, + {"/refus", OutcomeStaleStatus, http.StatusNotFound, nil}, + {"/autre/etat", OutcomeKeep, http.StatusNotModified, + []string{"If-None-Match", `"v1"`}}, + } { + w := joue(t, p, "GET", hote, c.chemin, c.entetes...) + if w.Code != c.code || w.Header().Get("X-ERPLibre-Cache") != c.issue { + t.Errorf("%s : %d « %s », attendu %d « %s »", c.chemin, w.Code, + w.Header().Get("X-ERPLibre-Cache"), c.code, c.issue) + } + } + if n := a.appels() - avant; n != 0 { + t.Errorf("%d requête(s) vers un amont connu muet, alors qu'un repli"+ + " existait", n) + } + + w := joue(t, p, "GET", hote, "/jamais-vu") + if w.Code != http.StatusOK || w.Body.String() != "réponse de l'amont" { + t.Errorf("sans repli : %d %q, attendu la réponse de l'amont", + w.Code, w.Body.String()) + } + if p.Muets.ConnuMuet(hote) { + t.Error("la réponse de l'amont n'a pas effacé l'hôte de la mémoire") + } + + muet() + avant = a.appels() + if w := joue(t, p, "POST", hote, "/api/envoi"); w.Code != http.StatusOK { + t.Errorf("POST : code %d, attendu la réponse de l'amont", w.Code) + } + if n := a.appels() - avant; n != 1 { + t.Errorf("POST : %d requête(s) à l'amont, attendu 1", n) + } +} + +// Un serveur lent n'est pas un serveur mort : l'attente des en-têtes échoue, +// mais la requête suivante le retente. +func TestUnServeurLentNEstPasTenuPourMuet(t *testing.T) { + var recues int64 + libere := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&recues, 1) + <-libere + })) + t.Cleanup(srv.Close) + t.Cleanup(func() { close(libere) }) + + p := proxyDeTest(t) + p.Muets = NouvelleJoignabilite() + p.Client.Transport.(*http.Transport).ResponseHeaderTimeout = 200 * time.Millisecond + u, _ := url.Parse(srv.URL) + + for i := 0; i < 2; i++ { + joue(t, p, "GET", u.Host, "/api/lente") + } + if n := atomic.LoadInt64(&recues); n != 2 { + t.Errorf("le serveur a reçu %d requêtes, attendu 2 : un serveur lent"+ + " a été tenu pour muet", n) + } +} + +// amontGitCompte monte un amont git qui compte ses requêtes, et qu'on peut +// mettre en panne en cours de test. +func amontGitCompte(t *testing.T) (string, *int64, *atomic.Bool) { + t.Helper() + depot, _, n, panne := amontGitEnPanne(t) + return depot, n, panne +} + +// amontGitEnPanne est amontGitCompte qui rend aussi le chemin du dépôt nu +// servi, pour y pousser des commits en cours de test. +func amontGitEnPanne(t *testing.T) (string, string, *int64, *atomic.Bool) { + t.Helper() + if TrouverBackend() == "" { + t.Skip("git-http-backend absent de cette machine") + } + nu := depotDEssai(t) + racine := filepath.Dir(nu) + h := &cgi.Handler{ + Path: TrouverBackend(), + Dir: racine, + Env: []string{ + "GIT_PROJECT_ROOT=" + racine, + "GIT_HTTP_EXPORT_ALL=1", + }, + InheritEnv: []string{"PATH"}, + } + var n int64 + var panne atomic.Bool + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&n, 1) + if panne.Load() { + http.Error(w, "panne", http.StatusInternalServerError) + return + } + h.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv.URL + "/" + filepath.Base(nu), nu, &n, &panne +} + +// Un amont connu muet ne fait pas payer au miroir le délai d'une mise à jour +// vouée à l'échec : le miroir existant sert tel quel. +func TestUnAmontConnuMuetNeRafraichitPasLeMiroir(t *testing.T) { + depot, appels, _ := amontGitCompte(t) + g := &GitMirror{ + Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(), + } + if _, pret := g.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + avant := atomic.LoadInt64(appels) + + g.Muets.Echec(hoteDe(t, depot), refusEtablissement()) + g.Frais = 0 + if _, pret := g.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir existant n'est pas servi") + } + if n := atomic.LoadInt64(appels); n != avant { + t.Errorf("%d requête(s) vers un amont connu muet", n-avant) + } +} + +// Une sonde refusée retient l'hôte et sert le miroir sans le rafraîchir ; le +// dépôt suivant du même hôte ne sonde même plus. +func TestUneSondeRefuseeRetientLHoteEtSertLeMiroir(t *testing.T) { + depot, appels, _ := amontGitCompte(t) + g := &GitMirror{ + Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(), + } + if _, pret := g.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + avant := atomic.LoadInt64(appels) + + var sondes int64 + g.Sonder = func(context.Context, string) error { + atomic.AddInt64(&sondes, 1) + return refusEtablissement() + } + g.Frais = 0 + for i := 0; i < 2; i++ { + if _, pret := g.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir existant n'est pas servi") + } + } + if n := atomic.LoadInt64(appels); n != avant { + t.Errorf("%d requête(s) vers un amont qui refuse la sonde", n-avant) + } + if n := atomic.LoadInt64(&sondes); n != 1 { + t.Errorf("%d sondes, attendu 1 : l'échec n'a pas été retenu", n) + } + if !g.Muets.ConnuMuet(hoteDe(t, depot)) { + t.Error("l'hôte n'est pas retenu comme muet") + } +} + +// sansMandataire : aucune URL ne sort par un mandataire, quel que soit +// l'environnement du test. +func sansMandataire(*http.Request) (*url.URL, error) { return nil, nil } + +// miroirCree tient un miroir du dépôt dans un répertoire neuf et le rend. +func miroirCree(t *testing.T, depot string) string { + t.Helper() + dir := t.TempDir() + creation := &GitMirror{Dir: dir, Delai: 30 * time.Second} + if _, pret := creation.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir n'a pas pu être créé") + } + return dir +} + +// Une mise à jour manquée sur un amont qui accepte la connexion — un 5xx, une +// poignée de main coupée — n'est pas notée : la requête suivante retente, et +// l'hôte n'est pas tenu pour muet. +func TestUnRafraichissementManqueSurUnAmontJoignableEstRetente(t *testing.T) { + depot, appels, panne := amontGitCompte(t) + g := &GitMirror{ + Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute, + Muets: NouvelleJoignabilite(), + Sonder: func(context.Context, string) error { return nil }, + } + panne.Store(true) + g.Assurer(context.Background(), depot) + avant := atomic.LoadInt64(appels) + if _, pret := g.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir existant n'est pas servi") + } + if n := atomic.LoadInt64(appels); n == avant { + t.Error("la mise à jour manquée sur un amont joignable n'est pas" + + " retentée : elle a été notée comme un rafraîchissement") + } + if g.Muets.ConnuMuet(hoteDe(t, depot)) { + t.Error("un 500 a fait tenir l'hôte pour muet") + } +} + +// Une mise à jour manquée sur un amont qui refuse désormais la connexion est +// notée, et l'hôte retenu : pendant « Frais », le dépôt ne la retente pas. +func TestUnRafraichissementManqueSurUnAmontQuiRefuseEstNote(t *testing.T) { + depot, appels, panne := amontGitCompte(t) + var sondes int64 + g := &GitMirror{ + Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute, + Muets: NouvelleJoignabilite(), + // La sonde d'avant la mise à jour passe ; celle d'après trouve + // l'amont tombé entre les deux. + Sonder: func(context.Context, string) error { + if atomic.AddInt64(&sondes, 1) == 1 { + return nil + } + return refusEtablissement() + }, + } + panne.Store(true) + chemin, _ := g.Assurer(context.Background(), depot) + if n := atomic.LoadInt64(&sondes); n != 2 { + t.Errorf("%d sonde(s), attendu 2 : l'échec de la mise à jour n'a pas"+ + " été sondé", n) + } + if !g.recent(chemin) { + t.Error("la mise à jour manquée sur un amont qui refuse n'est pas notée") + } + if !g.Muets.ConnuMuet(hoteDe(t, depot)) { + t.Error("l'hôte qui refuse n'est pas retenu comme muet") + } + avant := atomic.LoadInt64(appels) + if _, pret := g.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir existant n'est pas servi") + } + if n := atomic.LoadInt64(appels); n != avant { + t.Errorf("%d requête(s) dans la fraîcheur d'un amont qui refuse", n-avant) + } +} + +// pousserUnCommit ajoute un commit au dépôt nu que sert l'amont et rend son +// empreinte. Le dépôt de travail est celui que depotDEssai laisse à côté. +func pousserUnCommit(t *testing.T, nu string) string { + t.Helper() + travail := filepath.Join(filepath.Dir(nu), "travail") + lancer := func(args ...string) string { + t.Helper() + c := exec.Command("git", args...) + c.Dir = travail + c.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e") + out, err := c.CombinedOutput() + if err != nil { + t.Fatalf("git %v : %v : %s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + if err := os.WriteFile( + filepath.Join(travail, "g.txt"), []byte("suite"), 0o644, + ); err != nil { + t.Fatal(err) + } + lancer("add", "g.txt") + lancer("commit", "-qm", "second") + lancer("push", "-q", nu, "HEAD:refs/heads/main") + return lancer("rev-parse", "HEAD") +} + +// Un 500 de la forge ne fige pas le miroir : l'amont publie un commit juste +// après, et la requête suivante le voit, sans attendre la fin de « Frais ». +// La sonde est la vraie, vers l'amont local qui accepte la connexion. +func TestUnRafraichissementEn500NeFigePasLeMiroir(t *testing.T) { + depot, nu, _, panne := amontGitEnPanne(t) + g := &GitMirror{ + Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute, + Muets: NouvelleJoignabilite(), Mandataire: sansMandataire, + } + panne.Store(true) + if _, pret := g.Assurer(context.Background(), depot); !pret { + t.Fatal("le miroir existant n'est pas servi pendant la panne") + } + panne.Store(false) + attendu := pousserUnCommit(t, nu) + + chemin, pret := g.Assurer(context.Background(), depot) + if !pret { + t.Fatal("le miroir n'est pas servi") + } + out, err := exec.Command( + "git", "--git-dir", chemin, "rev-parse", "refs/heads/main", + ).CombinedOutput() + if err != nil { + t.Fatalf("rev-parse : %v : %s", err, out) + } + if got := strings.TrimSpace(string(out)); got != attendu { + t.Errorf("le miroir sert %s, attendu %s : il est resté figé après"+ + " le 500", got, attendu) + } +} + +// Quand un mandataire porte l'URL du dépôt, aucune sonde directe : git passe +// par lui, et la connexion directe, refusée sur un hôte dont c'est la seule +// sortie, figerait le miroir pour toujours. +func TestUnMandataireDispenseDeLaSonde(t *testing.T) { + var sondes int64 + g := &GitMirror{ + Sonder: func(context.Context, string) error { + atomic.AddInt64(&sondes, 1) + return refusEtablissement() + }, + Mandataire: func(*http.Request) (*url.URL, error) { + return url.Parse("http://mandataire.example:3128") + }, + } + const depot = "https://forge.example/o/d.git" + ctx := context.Background() + if !g.amontJoignable(ctx, depot) || g.sondeRefusee(ctx, depot) { + t.Error("l'amont derrière un mandataire est tenu pour injoignable") + } + if n := atomic.LoadInt64(&sondes); n != 0 { + t.Errorf("%d sonde(s) directe(s) malgré le mandataire", n) + } + + g.Mandataire = sansMandataire + if g.amontJoignable(ctx, depot) { + t.Error("sans mandataire, une sonde refusée laisse l'amont joignable") + } + if n := atomic.LoadInt64(&sondes); n != 1 { + t.Errorf("%d sonde(s) sans mandataire, attendu 1", n) + } +} + +// Une sonde manquée se dit au journal UNE fois par panne et par hôte : un +// miroir servi sans rafraîchissement doit se voir, sans une ligne par dépôt. +// La réponse de l'hôte clôt la panne ; la suivante se dit de nouveau. +func TestUneSondeManqueeSeDitUneFoisParPanne(t *testing.T) { + var journal bytes.Buffer + avant := log.Writer() + log.SetOutput(&journal) + t.Cleanup(func() { log.SetOutput(avant) }) + + var enPanne atomic.Bool + enPanne.Store(true) + g := &GitMirror{ + Mandataire: sansMandataire, + Sonder: func(context.Context, string) error { + if enPanne.Load() { + return refusEtablissement() + } + return nil + }, + } + const depot = "https://forge.example/o/d.git" + const adresse = "forge.example:443" + ctx := context.Background() + for i := 0; i < 3; i++ { + g.amontJoignable(ctx, depot) + } + if n := strings.Count(journal.String(), adresse); n != 1 { + t.Errorf("%d ligne(s) pour trois sondes manquées, attendu 1 :\n%s", + n, journal.String()) + } + enPanne.Store(false) + g.amontJoignable(ctx, depot) + enPanne.Store(true) + g.amontJoignable(ctx, depot) + if n := strings.Count(journal.String(), adresse); n != 2 { + t.Errorf("%d ligne(s) après une seconde panne, attendu 2", n) + } +} diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go new file mode 100644 index 0000000..f4e3d49 --- /dev/null +++ b/script/qemu_cache/main.go @@ -0,0 +1,468 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +// erplibre_go_qemu_cache — miroir de téléchargement pour les VM QEMU locales. +// +// L'orchestrateur détourne le 80 et le 443 de ses VM vers cet outil, qui +// garde ce qui descend et sert la copie à la VM suivante. Deux VM de la même +// distribution ne tirent donc qu'une fois les mêmes paquets. +// +// Ce que l'outil NE fait pas, et qu'il faut savoir : il n'efface rien. Aucune +// éviction, aucun plafond de disque — « --status » dit ce qu'il occupe, la +// surveillance est manuelle et le cache vit sur le disque de l'orchestrateur. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const version = "0.2.4" + +func main() { + var ( + cacheDir = flag.String("cache-dir", "/var/cache/erplibre_go_qemu_cache", + "répertoire des objets gardés") + caDir = flag.String("ca-dir", "/var/lib/erplibre_go_qemu_cache", + "répertoire de l'autorité de certification") + httpPort = flag.Int("http-port", 8898, "écoute pour le 80 détourné") + tlsPort = flag.Int("tls-port", 8899, "écoute pour le 443 détourné") + bridge = flag.String("bridge", "virbr0", "pont libvirt des VM") + subnet = flag.String("subnet", "192.168.122.0/24", "sous-réseau des VM") + logPath = flag.String("access-log", "", "journal d'accès JSON par ligne") + exclude = flag.String("exclude", "", + "hôtes à ne jamais déchiffrer, séparés par des virgules") + verbose = flag.Bool("verbose", false, "dire chaque requête") + status = flag.Bool("status", false, + "dire ce que le cache occupe, puis sortir") + dryRun = flag.Bool("dry-run", false, + "montrer les gestes privilégiés sans en faire un") + initCA = flag.Bool("init-ca", false, + "créer l'autorité si elle manque, puis sortir") + printNft = flag.Bool("print-nft", false, + "écrire les règles nft seules, à passer à « nft -f - »") + printIptables = flag.Bool("print-iptables", false, + "écrire les commandes iptables seules, une par ligne") + bypassFile = flag.String("bypass-file", + "/etc/erplibre_go_qemu_cache/bypass", + "liste des VM soustraites au détournement, une MAC par ligne") + bypassAdd = flag.String("bypass-add", "", + "soustraire cette adresse MAC au détournement") + bypassName = flag.String("bypass-name", "", + "nom de la VM, écrit à côté de la MAC ajoutée") + bypassDel = flag.String("bypass-del", "", + "rendre cette adresse MAC au détournement") + gitMirrorDir = flag.String("git-mirror-dir", "", + "racine des dépôts git tenus en miroir ; vide, git est"+ + " simplement relayé vers l'amont") + gitMirrorFresh = flag.Duration("git-mirror-fresh", 60*time.Second, + "délai en deçà duquel un dépôt n'est pas re-interrogé") + gitPrefetch = flag.String("git-mirror-prefetch", "", + "fichier de dépôts, un par ligne, à tenir en miroir d'avance") + gitPrefetchJobs = flag.Int("git-mirror-jobs", 4, + "dépôts clonés en parallèle par le pré-remplissage") + ageReport = flag.Bool("age-report", false, + "dire ce que le cache occupe, groupé par âge du dernier usage") + agePar = flag.String("age-par", "semaine", + "découpage du relevé par âge : jour, semaine ou mois") + purgeTout = flag.Bool("purge", false, + "effacer TOUT le cache : objets et dépôts en miroir") + purgeAvant = flag.String("purge-older-than", "", + "n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)") + gitList = flag.Bool("git-mirror-list", false, + "dire les dépôts tenus en miroir, du plus lourd au plus léger") + gitRemove = flag.String("git-mirror-remove", "", + "effacer le miroir de ce dépôt ; il se refera au prochain besoin") + bypassList = flag.Bool("bypass-list", false, + "dire les exceptions en place, une « MAC nom » par ligne") + showVersion = flag.Bool("version", false, "dire la version, puis sortir") + detient = flag.Bool("detient", false, + "lire des lignes « MÉTHODE URL » sur l'entrée standard et dire,"+ + " pour chacune, ce que le magasin tient : une ligne séparée"+ + " par des tabulations « verdict statut stored_at classe"+ + " méthode url », verdict garde (corps 200), statut (statut"+ + " seul, sans corps), absent ou non-cachable. Lecture seule :"+ + " --cache-dir suffit, sans privilège, et l'âge des objets"+ + " n'est pas touché") + ) + flag.Parse() + + if *showVersion { + fmt.Printf("erplibre_go_qemu_cache %s\n", version) + return + } + // Traité AVANT tout ce qui lit une configuration : la question ne + // porte que sur le magasin, et un fichier d'exceptions illisible pour + // l'appelant ne doit pas l'empêcher d'y répondre. + if *detient { + store := &Store{Dir: *cacheDir} + if err := EcrireDetentions(store, os.Stdin, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "entrée illisible : %v\n", err) + } + return + } + + bypass := BypassFile{Path: *bypassFile} + // Les exceptions entrent dans les règles dès leur RENDU : le service les + // repose telles quelles à chaque démarrage, et une exception ne survit + // donc pas au seul noyau. + exceptions, err := bypass.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "exceptions illisibles : %v\n", err) + os.Exit(1) + } + rules := RuleSet{ + Bridge: *bridge, Subnet: *subnet, + HTTPPort: *httpPort, TLSPort: *tlsPort, + Bypass: MACs(exceptions), + } + store := &Store{Dir: *cacheDir} + + // Les règles sortent d'ici et de nulle part ailleurs : le service les + // pose en tubant cette sortie dans nft. Une seconde copie dans un script + // d'installation dériverait de celle que les tests vérifient. + if *printNft { + for _, l := range rules.NftLines() { + fmt.Println(l) + } + return + } + if *printIptables { + for _, l := range rules.IptablesLines() { + fmt.Println(l) + } + return + } + + // Les trois gestes de la liste écrivent le fichier et rendent sur la + // SORTIE le geste à chaud correspondant, à tuber dans « nft -f - ». Ce + // paquet ne touche pas au pare-feu : c'est l'invariant qui rend les + // règles vérifiables par un test sans privilège. + if *bypassAdd != "" { + mac, err := bypass.Add(*bypassAdd, *bypassName) + if err != nil { + fmt.Fprintf(os.Stderr, "exception refusée : %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "exception posée : %s %s\n", mac, *bypassName) + fmt.Println(BypassAddElement(mac)) + return + } + if *bypassDel != "" { + mac, avait, err := bypass.Del(*bypassDel) + if err != nil { + fmt.Fprintf(os.Stderr, "exception refusée : %v\n", err) + os.Exit(1) + } + if !avait { + fmt.Fprintf(os.Stderr, "aucune exception pour %s\n", mac) + } + // Le geste à chaud est rendu même si le fichier ne l'avait pas : + // l'ensemble du noyau peut porter ce que le fichier a perdu, et le + // retrait doit alors pouvoir le rattraper. + fmt.Println(BypassDelElement(mac)) + return + } + if *bypassList { + for _, e := range exceptions { + fmt.Printf("%s %s\n", e.MAC, e.Name) + } + return + } + + // Une seule mémoire des amonts muets, partagée par le relais et le + // miroir : un hôte coupé l'est pour les deux, et le premier qui le + // constate en épargne le délai à l'autre. + muets := NouvelleJoignabilite() + // Le témoin que la levée d'une coupure touche. Il vit dans le magasin, + // seul répertoire que le service et la levée connaissent tous deux. + muets.Sentinelle = filepath.Join(*cacheDir, SentinelleAmonts) + miroir := &GitMirror{ + Dir: *gitMirrorDir, Frais: *gitMirrorFresh, Muets: muets, + } + + if *ageReport { + gran, err := LireGranularite(*agePar) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + printAge(store, miroir, gran) + return + } + if *purgeTout || *purgeAvant != "" { + avant := time.Now() + if *purgeAvant != "" { + d, err := LireDuree(*purgeAvant) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + avant = time.Now().Add(-d) + fmt.Printf("efface ce qui n'a pas servi depuis %s (avant %s)\n", + *purgeAvant, avant.Format("2006-01-02 15:04")) + } else { + fmt.Println("efface TOUT le cache") + } + if *dryRun { + printPurgeABlanc(store, miroir, avant) + return + } + n, oct, err := store.Purger(avant) + if err != nil { + fmt.Fprintf(os.Stderr, "purge des objets : %v\n", err) + } + fmt.Printf("objets effacés : %d, %s rendus\n", n, HumanBytes(oct)) + nd, octd, err := miroir.PurgerMiroirs(avant) + if err != nil { + fmt.Fprintf(os.Stderr, "purge des miroirs : %v\n", err) + } + fmt.Printf("dépôts effacés : %d, %s rendus\n", nd, HumanBytes(octd)) + return + } + if *gitList { + depots := miroir.Depots() + if len(depots) == 0 { + fmt.Println("aucun dépôt en miroir") + return + } + var total int64 + for _, d := range depots { + total += d.Octets + fmt.Printf("%10s %s %s\n", + HumanBytes(d.Octets), d.Maj.Format("2006-01-02"), d.Nom) + } + fmt.Printf("%10s %d dépôts\n", HumanBytes(total), len(depots)) + return + } + if *gitRemove != "" { + // Le nom suffit : on ne demande pas à l'opérateur de retrouver le + // chemin d'un répertoire qu'il n'a pas choisi. + cible := *gitRemove + if !strings.HasPrefix(cible, miroir.Dir) { + for _, d := range miroir.Depots() { + if d.Nom == strings.TrimSuffix(cible, ".git") { + cible = d.Chemin + break + } + } + } + if err := miroir.Retirer(cible); err != nil { + fmt.Fprintf(os.Stderr, "effacement : %v\n", err) + os.Exit(1) + } + fmt.Printf("miroir effacé : %s\n", cible) + return + } + if *gitPrefetch != "" { + if !miroir.Actif() { + fmt.Fprintln(os.Stderr, + "miroir git éteint : passer --git-mirror-dir") + os.Exit(1) + } + depots, err := DepotsDuFichier(*gitPrefetch) + if err != nil { + fmt.Fprintf(os.Stderr, "liste illisible : %v\n", err) + os.Exit(1) + } + fmt.Printf("%d dépôts à tenir en miroir sous %s\n", + len(depots), miroir.Dir) + reussis, echoues := miroir.Prefetch( + context.Background(), depots, *gitPrefetchJobs, + func(l string) { fmt.Println(l) }, + ) + _, octets := miroir.Occupation() + fmt.Printf("%d en miroir, %d en échec, %s occupés\n", + reussis, echoues, HumanBytes(octets)) + // Un dépôt mort ne fait pas échouer l'opération : sur une liste de + // trois cents, il y en a toujours un — privé, déplacé, retiré — et + // rendre une erreur ferait passer pour ratée une avance qui a pris. + // Seule une liste dont RIEN n'a été tenu est un échec. + if reussis == 0 && echoues > 0 { + os.Exit(1) + } + return + } + + if *status { + if err := printStatus(store, *caDir, rules, miroir); err != nil { + fmt.Fprintf(os.Stderr, "état illisible : %v\n", err) + os.Exit(1) + } + return + } + + if *dryRun { + printDryRun(store, *caDir, rules) + return + } + + if *initCA { + ca, err := LoadOrCreateCA(*caDir) + if err != nil { + fmt.Fprintf(os.Stderr, "autorité : %v\n", err) + os.Exit(1) + } + fmt.Printf("autorité : %s\n", CertPath(*caDir)) + fmt.Printf("empreinte : %s\n", ca.Fingerprint()) + return + } + + if *gitMirrorDir != "" && !miroir.Actif() { + // Le dire plutôt que de laisser croire à un miroir : sans le + // programme de git, le service marche mais git repart à l'amont à + // chaque VM, ce qui est exactement ce que le miroir devait éviter. + log.Printf( + "miroir git demandé mais « git-http-backend » est introuvable :"+ + " git sera relayé vers l'amont (%s)", *gitMirrorDir) + } + if err := serve( + store, *caDir, rules, *logPath, *exclude, *verbose, miroir, muets, + ); err != nil { + log.Fatalf("le cache s'arrête : %v", err) + } +} + +func serve( + store *Store, caDir string, rules RuleSet, + logPath, exclude string, verbose bool, miroir *GitMirror, + muets *Joignabilite, +) error { + if err := os.MkdirAll(store.Dir, 0o755); err != nil { + return err + } + // Un objet à moitié écrit ne vaut rien et occupe : le démarrage est le + // seul moment où un « .part » n'appartient à aucune écriture vivante. + if n := store.SweepPartials(); n > 0 { + log.Printf("%d écriture(s) interrompue(s) retirée(s)", n) + } + + ca, err := LoadOrCreateCA(caDir) + if err != nil { + return err + } + alog, err := OpenAccessLog(logPath) + if err != nil { + return err + } + defer alog.Close() + + proxy := NewProxy(store, alog) + proxy.Git = miroir + proxy.Verbose = verbose + proxy.Muets = muets + // Les ports d'écoute sont ceux que la requête d'une boucle viserait. + proxy.Ecoutes = []int{rules.HTTPPort, rules.TLSPort} + + refusals := NewRefusals(append(DefaultExclusions, splitList(exclude)...)) + front := &TLSFront{CA: ca, Proxy: proxy, Refusals: refusals} + + httpLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.HTTPPort)) + if err != nil { + return fmt.Errorf("écoute HTTP : %w", err) + } + tlsLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.TLSPort)) + if err != nil { + return fmt.Errorf("écoute TLS : %w", err) + } + + log.Printf("cache : %s", store.Dir) + log.Printf("autorité : %s (%s)", CertPath(caDir), ca.Fingerprint()) + log.Printf("écoutes : http %d, tls %d", rules.HTTPPort, rules.TLSPort) + + errc := make(chan error, 2) + go func() { + srv := &http.Server{ + Handler: proxy.handler("http"), + ReadHeaderTimeout: 30 * time.Second, + } + errc <- srv.Serve(httpLn) + }() + go func() { errc <- front.Serve(tlsLn) }() + return <-errc +} + +func printStatus( + store *Store, caDir string, rules RuleSet, miroirStatut *GitMirror, +) error { + st, err := store.Stat() + if err != nil { + return err + } + fmt.Printf("répertoire : %s\n", store.Dir) + fmt.Printf("objets : %d\n", st.Objects) + fmt.Printf("occupation : %s\n", HumanBytes(st.Bytes)) + if st.Oldest.IsZero() { + fmt.Printf("plus ancien : aucun objet\n") + } else { + fmt.Printf("plus ancien : %s\n", st.Oldest.Format(time.RFC3339)) + } + fmt.Printf("autorité : %s\n", CertPath(caDir)) + if ca, err := LoadOrCreateCA(caDir); err == nil { + fmt.Printf("empreinte : %s\n", ca.Fingerprint()) + } else { + fmt.Printf("empreinte : autorité absente\n") + } + if depots, octets := miroirStatut.Occupation(); depots > 0 { + fmt.Printf("dépôts git : %d en miroir, %s\n", + depots, HumanBytes(octets)) + } + if len(rules.Bypass) == 0 { + fmt.Printf("exceptions : aucune\n") + } else { + fmt.Printf("exceptions : %d VM soustraite(s) au détournement\n", + len(rules.Bypass)) + for _, m := range rules.Bypass { + fmt.Printf(" %s\n", m) + } + } + fmt.Printf("\nAucune éviction n'est écrite : ce cache ne diminue jamais\n") + fmt.Printf("de lui-même, et il vit sur le disque de l'orchestrateur.\n") + return nil +} + +func printDryRun(store *Store, caDir string, rules RuleSet) { + fmt.Printf("À blanc — rien n'est écrit, rien n'est posé.\n\n") + fmt.Printf("Répertoire du cache, créé au démarrage :\n %s\n\n", store.Dir) + fmt.Printf("Autorité, créée si elle manque :\n %s (clé en 0600)\n\n", + CertPath(caDir)) + fmt.Printf("Règles nft à poser sur l'hôte :\n") + for _, l := range rules.NftLines() { + fmt.Printf(" %s\n", l) + } + fmt.Printf("\nÀ défaut de nft :\n") + for _, l := range rules.IptablesLines() { + fmt.Printf(" %s\n", l) + } + fmt.Printf("\nRetrait :\n %s\n", rules.NftDeleteLine()) + fmt.Printf("\nDans chaque VM qui utilise le cache :\n") + for _, f := range []string{"pacman", "apt", "dnf", "zypper"} { + dir, cmd, bundle, _ := GuestTrustCommand(f) + fmt.Printf(" %-7s %s/erplibre-cache.crt puis %s\n", f, dir, cmd) + for _, l := range GuestEnvLines(bundle) { + fmt.Printf(" %s\n", l) + } + } +} + +func splitList(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/script/qemu_cache/mitm.go b/script/qemu_cache/mitm.go new file mode 100644 index 0000000..3bba239 --- /dev/null +++ b/script/qemu_cache/mitm.go @@ -0,0 +1,680 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// CA signe les certificats que le cache présente aux invités. +// +// Déchiffrer suppose que l'invité approuve cette autorité, ce qui est la +// SEULE configuration que l'interception transparente ne peut pas éviter : la +// redirection TCP est invisible, la confiance TLS ne l'est pas. La clé reste +// en 0600 et ne quitte jamais l'orchestrateur ; seul le certificat part dans +// les VM. +type CA struct { + cert *x509.Certificate + key *rsa.PrivateKey + certPEM []byte + + mu sync.Mutex + leaves map[string]*tls.Certificate +} + +// LoadOrCreateCA lit l'autorité, et la fabrique si elle manque. +// +// Une autorité créée à l'installation et jamais remplacée : la régénérer +// invaliderait les certificats de toutes les VM déjà configurées, qui +// tomberaient sur une erreur de certificat sans rapport apparent avec le +// cache. +func LoadOrCreateCA(dir string) (*CA, error) { + certPath := filepath.Join(dir, "ca.crt") + keyPath := filepath.Join(dir, "ca.key") + + if certPEM, err := os.ReadFile(certPath); err == nil { + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("clé de l'autorité illisible : %w", err) + } + cb, _ := pem.Decode(certPEM) + kb, _ := pem.Decode(keyPEM) + if cb == nil || kb == nil { + return nil, errors.New("autorité illisible : PEM invalide") + } + cert, err := x509.ParseCertificate(cb.Bytes) + if err != nil { + return nil, err + } + key, err := x509.ParsePKCS1PrivateKey(kb.Bytes) + if err != nil { + return nil, err + } + return &CA{cert: cert, key: key, certPEM: certPEM, + leaves: map[string]*tls.Certificate{}}, nil + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "ERPLibre QEMU cache", + Organization: []string{"ERPLibre"}, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + return nil, err + } + // 0600 : la clé de cette autorité permet de se faire passer pour + // n'importe quel site auprès d'une VM qui l'approuve. + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return nil, err + } + return &CA{cert: cert, key: key, certPEM: certPEM, + leaves: map[string]*tls.Certificate{}}, nil +} + +// CertPath rend le chemin du certificat à poser dans les VM. +func CertPath(dir string) string { return filepath.Join(dir, "ca.crt") } + +// Fingerprint permet de vérifier de visu que la VM approuve BIEN cette +// autorité et non une autre. +func (c *CA) Fingerprint() string { + sum := sha256.Sum256(c.cert.Raw) + h := hex.EncodeToString(sum[:]) + var b strings.Builder + for i := 0; i < len(h); i += 2 { + if i > 0 { + b.WriteByte(':') + } + b.WriteString(strings.ToUpper(h[i : i+2])) + } + return b.String() +} + +// leafFor fabrique, et garde en mémoire, le certificat d'un nom d'hôte. Les +// feuilles sont en ECDSA : une VM demande des dizaines d'hôtes pendant une +// installation, et générer une clé RSA à chacun se verrait. +func (c *CA) leafFor(host string) (*tls.Certificate, error) { + c.mu.Lock() + if crt, ok := c.leaves[host]; ok { + c.mu.Unlock() + return crt, nil + } + c.mu.Unlock() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: host}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = []net.IP{ip} + } else { + tmpl.DNSNames = []string{host} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, c.cert, &key.PublicKey, c.key) + if err != nil { + return nil, err + } + crt := &tls.Certificate{Certificate: [][]byte{der, c.cert.Raw}, PrivateKey: key} + + c.mu.Lock() + c.leaves[host] = crt + c.mu.Unlock() + return crt, nil +} + +// Refusals retient les hôtes dont le client a refusé notre certificat. +// +// Certains clients épinglent leur autorité et n'accepteront jamais la nôtre — +// snapd est le cas connu. Plutôt qu'une liste à tenir à jour, le repli ne se +// déclare pas d'avance : la première poignée de main échoue, l'hôte est +// retenu, et toutes les suivantes passent en tunnel opaque. La première +// requête est perdue, c'est le prix de n'avoir rien à configurer. +// Un refus APPRIS s'oublie, un refus DÉCLARÉ jamais. +// +// Les deux ne disent pas la même chose. Un hôte déclaré épingle son autorité +// pour toujours, et le réessayer coûterait une requête à chaque fois. Un refus +// appris, lui, peut n'avoir rien de définitif : une VM dont le magasin de +// confiance n'est pas encore posé refuse la première poignée de main, et +// l'hôte se retrouvait alors condamné au tunnel POUR LA VIE DU SERVICE — donc +// à ne plus jamais être caché, y compris pour toutes les VM suivantes, qui +// elles font confiance. +// +// L'oubli remet le doute : au bout du délai, une poignée de main est retentée. +// Un vrai épingleur la refuse à nouveau et le refus est réappris, au prix +// d'une requête perdue par délai. Un hôte qui n'avait qu'un magasin en retard +// redevient cachable. +// refus retient QUAND un hôte a été appris, et POURQUOI. +// +// La nature commande la durée. Une alerte est une décision : le client a +// regardé notre certificat et l'a rejeté, il ne se ravisera pas. Une +// répétition de coupures n'est qu'un SOUPÇON — trois erreurs de transport +// ressemblent à un épingleur sans en être un — et un soupçon doit pouvoir se +// rouvrir. Les confondre condamne un miroir de distribution aussi +// définitivement qu'un épingleur, et prive alors le cache de tout ce qu'il +// détient pour lui : un tunnel recopie des octets, il ne consulte jamais le +// magasin. +type refus struct { + quand time.Time + alerte bool +} + +type Refusals struct { + mu sync.RWMutex + declares map[string]bool + apprises map[string]refus + echecs map[string]int + // Oubli borne la mémoire d'un refus de TRANSPORT. Nul, il ne s'oublie + // jamais. Une ALERTE ne s'oublie en aucun cas, quel que soit ce réglage. + Oubli time.Duration + // Seuil : combien de poignées de main de suite doivent échouer avant de + // conclure. Nul, la valeur par défaut s'applique. + Seuil int +} + +// SeuilParDefaut : trois échecs de suite avant de renoncer à déchiffrer. +// +// Un client qui ne nous fera jamais confiance échoue à CHAQUE fois ; une +// coupure de transport, elle, ne se répète pas. Compter permet de servir les +// deux sans les distinguer à la première vue — ce qui est impossible, npm +// rejetant notre certificat sans envoyer d'alerte : le serveur ne voit qu'un +// EOF, exactement comme sur une VM qui démarre et coupe. +const SeuilParDefaut = 3 + +// OubliParDefaut : au bout de ce délai, un refus de TRANSPORT est rouvert. +// +// Une ALERTE, elle, ne s'oublie jamais, et ce réglage ne l'atteint pas : le +// client a regardé notre certificat et l'a rejeté. Ceux qui font cela portent +// leur propre magasin de confiance et ne changeront pas d'avis ; les +// ré-intercepter ferait échouer de nouveau une installation qui les traverse. +// +// Un refus appris par SEUIL n'est pas de cette nature. Trois coupures de +// suite — un flux corrompu, une fin de flux — ne disent rien de ce que le +// client pense de nous ; estRefusTLS le sait, mais le seuil conclut quand +// même. Condamner sur ce soupçon prive le cache de TOUT ce qu'il détient pour +// cet hôte, un tunnel ne servant jamais le magasin : un miroir de +// distribution banni sur une rafale renvoie alors tout son trafic à l'amont, +// y compris ce qui est en réserve. +// +// Dix minutes : assez pour ne pas repayer le délai pendant la rafale qui a +// condamné l'hôte, assez peu pour que l'installation suivante le retrouve. Le +// prix du doute est une poignée de main perdue de loin en loin. +const OubliParDefaut = 10 * time.Minute + +func NewRefusals(static []string) *Refusals { + r := &Refusals{ + declares: map[string]bool{}, + apprises: map[string]refus{}, + echecs: map[string]int{}, + Oubli: OubliParDefaut, + Seuil: SeuilParDefaut, + } + for _, h := range static { + if h = strings.TrimSpace(strings.ToLower(h)); h != "" { + r.declares[h] = true + } + } + return r +} + +// DefaultExclusions : les hôtes dont l'épinglage est connu d'avance. Les y +// mettre évite de perdre une requête pour l'apprendre. +var DefaultExclusions = []string{ + "api.snapcraft.io", + "dashboard.snapcraft.io", + "login.ubuntu.com", +} + +func (r *Refusals) Has(host string) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.declares[host] { + return true + } + // Un suffixe couvre un domaine entier : « .snapcraft.io » vaut pour tous + // ses sous-domaines. + for h := range r.declares { + if strings.HasPrefix(h, ".") && strings.HasSuffix(host, h) { + return true + } + } + vu, appris := r.apprises[host] + if !appris { + return false + } + // Une décision ne se révise pas ; un soupçon, si. + if vu.alerte { + return true + } + if r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli { + r.oublier(host) + return false + } + return true +} + +// oublier rouvre un soupçon de transport, compte compris. Garder le compte +// ferait condamner l'hôte rouvert dès sa première coupure : le doute rendu +// par l'oubli doit se reconstruire sur un seuil entier. Appelant verrouillé. +func (r *Refusals) oublier(host string) { + delete(r.apprises, host) + delete(r.echecs, host) +} + +// Echec note une poignée de main manquée et dit si l'hôte passe en tunnel. +// +// Une ALERTE tranche tout de suite : le client a regardé notre certificat et +// l'a rejeté, il n'y a rien à réessayer. Tout le reste — coupure, fin de flux — +// doit se RÉPÉTER pour compter : un client qui ne nous fera jamais confiance +// échoue à chaque fois, une VM qui démarre coupe une fois puis réussit. +// +// La raison journalisée est celle que la bibliothèque rend, jamais une +// interprétation : « certificat refusé » a longtemps été écrit là où l'erreur +// disait autre chose, et l'on cherchait une autorité manquante alors que la +// poignée de main échouait pour un motif sans rapport. +func (r *Refusals) Echec(host string, raison error) bool { + r.mu.Lock() + defer r.mu.Unlock() + seuil := r.Seuil + if seuil < 1 { + seuil = SeuilParDefaut + } + alerte := estRefusTLS(raison) + r.echecs[host]++ + if !alerte && r.echecs[host] < seuil { + return false + } + if _, deja := r.apprises[host]; !deja { + log.Printf("tunnel opaque retenu pour %s (%d échec(s)) : %v", + host, r.echecs[host], raison) + } + // La NATURE est retenue avec l'instant : elle décide si ce refus se + // rouvrira. Un seuil atteint sur des coupures reste un soupçon, même + // répété trois fois. + r.apprises[host] = refus{quand: time.Now(), alerte: alerte} + return true +} + +// Reussite efface le compte d'un hôte : la coupure d'avant n'était qu'un +// incident, et deux incidents éloignés ne doivent pas s'additionner jusqu'au +// seuil. +func (r *Refusals) Reussite(host string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.echecs, host) +} + +func (r *Refusals) List() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, 0, len(r.declares)+len(r.apprises)) + for h := range r.declares { + out = append(out, h) + } + for h, vu := range r.apprises { + if !vu.alerte && r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli { + r.oublier(h) + continue + } + out = append(out, h) + } + return out +} + +// estRefusTLS dit si l'échec vient d'une DÉCISION du client. +// +// Le pair qui refuse envoie une alerte, que la bibliothèque rend comme une +// « remote error ». Tout le reste — coupure, fin de flux, délai — est du +// transport, et ne dit rien de ce que le client pense de notre autorité. +func estRefusTLS(err error) bool { + if err == nil { + return false + } + var alerte tls.AlertError + if errors.As(err, &alerte) { + return true + } + return strings.Contains(err.Error(), "remote error: tls:") +} + +// TLSFront écoute le port vers lequel le 443 des invités est détourné. +type TLSFront struct { + CA *CA + Proxy *Proxy + Refusals *Refusals + // Origine rend la destination qu'avait la connexion avant le détournement. + // Nulle, originalDst s'applique ; les tests la remplacent pour viser une + // destination sans poser de règle de détournement. + Origine func(net.Conn) (string, error) +} + +// Serve accepte et traite chaque connexion détournée. +func (t *TLSFront) Serve(ln net.Listener) error { + for { + c, err := ln.Accept() + if err != nil { + return err + } + go t.handle(c) + } +} + +func (t *TLSFront) handle(c net.Conn) { + defer c.Close() + + // Le premier enregistrement TLS est lu en entier avant toute décision : + // il porte le SNI, donc le nom d'hôte, donc la réponse à « déchiffrer ou + // laisser passer ». Ses octets sont rejoués ensuite, l'invité ne devant + // pas s'apercevoir qu'on les a regardés. + raw, err := readFirstRecord(c) + if err != nil { + return + } + host := "" + if hello, err := peekSNI(raw); err == nil && hello.ServerName != "" { + host = strings.ToLower(hello.ServerName) + } + peeked := &replayed{Conn: c, buf: bytes.NewBuffer(raw)} + + if host == "" || t.Refusals.Has(host) { + // Sans SNI il n'y a pas de nom à certifier ; avec un refus connu il + // n'y a rien à tenter. Les deux passent en tunnel vers la + // destination que le noyau a gardée. + // + // Un tunnel dont l'amont ne répond pas ne peut que couper le client, + // alors que le magasin détient peut-être ce qu'il demande. Rien n'a + // encore été écrit vers le client, et le ClientHello est gardé : la + // même connexion est alors déchiffrée. Un client qui refuse vraiment + // notre autorité échoue de toute façon, l'amont étant coupé. + if !t.tunnel(peeked, host) || host == "" { + return + } + } + + cfg := &tls.Config{ + GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { + name := strings.ToLower(chi.ServerName) + if name == "" { + name = host + } + return t.CA.leafFor(name) + }, + MinVersion: tls.VersionTLS12, + } + tc := tls.Server(peeked, cfg) + if err := tc.Handshake(); err != nil { + // Un REFUS et une COUPURE ne disent pas la même chose, et les + // confondre coûte cher. Le client qui rejette notre autorité envoie + // une alerte TLS : c'est une décision, et l'hôte doit passer en + // tunnel. Une connexion coupée en cours de route — « connection reset + // by peer », une fin de flux — n'est qu'un incident de transport, que + // la requête suivante ne reproduira pas. + // + // Les traiter pareil condamnait un miroir de distribution au tunnel + // sur une seule coupure, et tout son trafic repartait à l'amont. + if !t.Refusals.Echec(host, err) { + log.Printf("poignée de main interrompue pour %s : %v", host, err) + } + return + } + t.Refusals.Reussite(host) + defer tc.Close() + + // Chaque requête de la connexion est servie comme du HTTPS : le schéma + // compte, l'URL stockée devant être celle que l'invité a demandée. + serveConn(tc, t.Proxy.handler("https")) +} + +// serveConn fait traiter une connexion DÉJÀ établie par le serveur HTTP de la +// bibliothèque standard, en la lui présentant comme un écouteur d'une seule +// connexion. Passer par « Serve » plutôt que par une lecture à la main donne +// gratuitement la persistance, le pipelining et les délais. +func serveConn(c net.Conn, h http.Handler) { + srv := &http.Server{ + Handler: h, + ReadHeaderTimeout: 30 * time.Second, + } + // « Serve » traite chaque connexion dans une GOROUTINE puis reboucle sur + // « Accept ». Un écouteur qui rendrait la fin de flux tout de suite ferait + // donc rendre la main à « Serve » — et à l'appelant, dont le « defer + // Close » couperait la connexion pendant que le handler y écrit encore : + // le client reçoit « Empty reply from server », sans une ligne au journal. + // + // Le second « Accept » attend donc la fermeture de la connexion servie. + srv.Serve(&oneConn{c: c, fini: make(chan struct{})}) +} + +// oneConn présente une connexion unique sous la forme d'un écouteur. +type oneConn struct { + c net.Conn + fini chan struct{} + pris bool + mu sync.Mutex +} + +func (l *oneConn) Accept() (net.Conn, error) { + l.mu.Lock() + if l.pris { + l.mu.Unlock() + // La connexion est déjà partie : on attend qu'elle soit refermée + // avant d'annoncer la fin, sans quoi « Serve » rendrait la main + // pendant que la réponse s'écrit. + <-l.fini + return nil, io.EOF + } + l.pris = true + l.mu.Unlock() + return &connSignalee{Conn: l.c, fini: l.fini}, nil +} + +func (l *oneConn) Close() error { return nil } +func (l *oneConn) Addr() net.Addr { return l.c.LocalAddr() } + +// connSignalee prévient l'écouteur de sa fermeture, qui est le seul moment où +// « Serve » peut rendre la main sans couper une réponse. +type connSignalee struct { + net.Conn + une sync.Once + fini chan struct{} +} + +func (c *connSignalee) Close() error { + err := c.Conn.Close() + c.une.Do(func() { close(c.fini) }) + return err +} + +// tunnel relie l'invité à sa destination sans rien comprendre à ce qui passe. +// +// Rend vrai quand l'établissement vers l'amont a échoué — refus, délai, réseau +// injoignable — et que rien n'a été écrit vers le client : la connexion reste +// alors utilisable par l'appelant. Faux dans tous les autres cas, connexion +// relayée ou abandonnée. +func (t *TLSFront) tunnel(c net.Conn, host string) bool { + origine := t.Origine + if origine == nil { + origine = originalDst + } + dst, err := origine(c) + if err != nil { + log.Printf("tunnel impossible pour %q : destination inconnue (%v)", host, err) + return false + } + // Une connexion NON détournée — ouverte directement sur l'écoute — a pour + // destination d'origine l'écoute elle-même. La relayer la renverrait ici, + // où elle serait relayée de nouveau, sans fin : chaque tour ouvre une + // connexion, jusqu'à épuiser les descripteurs et arrêter le service. + if memeAdresse(dst, c.LocalAddr().String()) { + log.Printf("tunnel refusé pour %q : connexion non détournée, sa"+ + " destination %s est cette écoute même", host, dst) + t.Proxy.record(accessLine{ + Method: "CONNECT", URL: "tcp://" + dst, Class: "tunnel", + Outcome: OutcomeError, Status: http.StatusLoopDetected, + Client: clientDe(c.RemoteAddr().String()), + }) + return false + } + up, err := net.DialTimeout("tcp", dst, 10*time.Second) + if err != nil { + injoignable := estEchecDEtablissement(err) + if injoignable && host != "" { + log.Printf("tunnel vers %s : %v ; %s est déchiffré à la place", + dst, err, host) + } else { + log.Printf("tunnel vers %s : %v", dst, err) + } + return injoignable + } + defer up.Close() + + t.Proxy.record(accessLine{ + Method: "CONNECT", URL: "tcp://" + dst, Class: "tunnel", + Outcome: OutcomePassthrough, Upstream: true, + Client: clientDe(c.RemoteAddr().String()), + }) + + done := make(chan struct{}, 2) + go func() { io.Copy(up, c); done <- struct{}{} }() + go func() { io.Copy(c, up); done <- struct{}{} }() + <-done + return false +} + +// readFirstRecord lit l'en-tête de cinq octets d'un enregistrement TLS puis +// exactement la longueur qu'il annonce. Aucune heuristique : la taille est +// écrite dans le protocole. +func readFirstRecord(c net.Conn) ([]byte, error) { + head := make([]byte, 5) + if _, err := io.ReadFull(c, head); err != nil { + return nil, err + } + if head[0] != 0x16 { // handshake + return nil, fmt.Errorf("ce n'est pas une poignée de main TLS (type %d)", head[0]) + } + n := int(head[3])<<8 | int(head[4]) + if n <= 0 || n > 1<<16 { + return nil, fmt.Errorf("longueur d'enregistrement invraisemblable : %d", n) + } + body := make([]byte, n) + if _, err := io.ReadFull(c, body); err != nil { + return nil, err + } + return append(head, body...), nil +} + +// peekSNI fait analyser le ClientHello par la bibliothèque standard plutôt +// que par un analyseur écrit à la main : le format a des extensions, des +// versions et des pièges, et un analyseur maison les découvrirait un par un. +func peekSNI(raw []byte) (*tls.ClientHelloInfo, error) { + var hello *tls.ClientHelloInfo + err := tls.Server(&readOnly{r: bytes.NewReader(raw)}, &tls.Config{ + GetConfigForClient: func(chi *tls.ClientHelloInfo) (*tls.Config, error) { + clone := *chi + hello = &clone + return nil, nil + }, + }).Handshake() + if hello != nil { + return hello, nil + } + return nil, err +} + +// replayed rejoue les octets déjà lus avant de rendre la main à la +// connexion. +type replayed struct { + net.Conn + buf *bytes.Buffer +} + +func (c *replayed) Read(p []byte) (int, error) { + if c.buf.Len() > 0 { + return c.buf.Read(p) + } + return c.Conn.Read(p) +} + +// readOnly sert le ClientHello à l'analyseur et refuse d'écrire : la poignée +// de main s'arrête donc juste après l'analyse, ce qui est tout ce qu'on veut. +type readOnly struct { + r io.Reader +} + +var errNoWrite = errors.New("analyse seule") + +func (c *readOnly) Read(p []byte) (int, error) { return c.r.Read(p) } +func (c *readOnly) Write([]byte) (int, error) { return 0, errNoWrite } +func (c *readOnly) Close() error { return nil } +func (c *readOnly) LocalAddr() net.Addr { return dummyAddr{} } +func (c *readOnly) RemoteAddr() net.Addr { return dummyAddr{} } +func (c *readOnly) SetDeadline(time.Time) error { return nil } +func (c *readOnly) SetReadDeadline(time.Time) error { return nil } +func (c *readOnly) SetWriteDeadline(time.Time) error { return nil } + +type dummyAddr struct{} + +func (dummyAddr) Network() string { return "peek" } +func (dummyAddr) String() string { return "peek" } diff --git a/script/qemu_cache/mitm_test.go b/script/qemu_cache/mitm_test.go new file mode 100644 index 0000000..1675ef2 --- /dev/null +++ b/script/qemu_cache/mitm_test.go @@ -0,0 +1,499 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +func TestCreationEtRelectureDeLAutorite(t *testing.T) { + dir := t.TempDir() + ca1, err := LoadOrCreateCA(dir) + if err != nil { + t.Fatalf("création : %v", err) + } + // Relire ne doit PAS régénérer : une autorité neuve invaliderait les + // certificats de toutes les VM déjà configurées. + ca2, err := LoadOrCreateCA(dir) + if err != nil { + t.Fatalf("relecture : %v", err) + } + if ca1.Fingerprint() != ca2.Fingerprint() { + t.Errorf("l'autorité a été régénérée :\n %s\n %s", + ca1.Fingerprint(), ca2.Fingerprint()) + } + if !ca1.cert.IsCA { + t.Error("le certificat n'est pas une autorité") + } +} + +// La clé permet de se faire passer pour n'importe quel site auprès d'une VM +// qui approuve l'autorité : elle ne doit être lisible que par son +// propriétaire. +func TestCleDeLAutoriteEnMode0600(t *testing.T) { + dir := t.TempDir() + if _, err := LoadOrCreateCA(dir); err != nil { + t.Fatalf("création : %v", err) + } + st, err := os.Stat(filepath.Join(dir, "ca.key")) + if err != nil { + t.Fatalf("clé absente : %v", err) + } + if mode := st.Mode().Perm(); mode != 0o600 { + t.Errorf("clé en %o, attendu 600", mode) + } + // Le certificat, lui, part dans les VM : il doit rester lisible. + stc, err := os.Stat(CertPath(dir)) + if err != nil { + t.Fatalf("certificat absent : %v", err) + } + if mode := stc.Mode().Perm(); mode != 0o644 { + t.Errorf("certificat en %o, attendu 644", mode) + } +} + +// Une feuille doit se vérifier CONTRE l'autorité, sans quoi l'invité la +// rejette malgré la confiance accordée. +func TestFeuilleVerifieeParLAutorite(t *testing.T) { + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatalf("autorité : %v", err) + } + crt, err := ca.leafFor("miroir.example") + if err != nil { + t.Fatalf("feuille : %v", err) + } + leaf, err := x509.ParseCertificate(crt.Certificate[0]) + if err != nil { + t.Fatalf("feuille illisible : %v", err) + } + + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + if _, err := leaf.Verify(x509.VerifyOptions{ + DNSName: "miroir.example", + Roots: pool, + }); err != nil { + t.Errorf("la feuille ne se vérifie pas : %v", err) + } + + // La chaîne envoyée porte l'autorité après la feuille : un invité qui ne + // la connaît pas encore doit pouvoir la voir. + if len(crt.Certificate) != 2 { + t.Errorf("la chaîne porte %d certificats, attendu 2", len(crt.Certificate)) + } +} + +// Un hôte demandé deux fois ne coûte qu'une génération : une installation +// touche des dizaines d'hôtes. +func TestFeuilleGardeeEnMemoire(t *testing.T) { + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatalf("autorité : %v", err) + } + a, _ := ca.leafFor("miroir.example") + b, _ := ca.leafFor("miroir.example") + if a != b { + t.Error("la feuille est régénérée à chaque demande") + } +} + +// Un SNI qui est une adresse IP ne peut pas aller dans un nom DNS. +func TestFeuillePourUneAdresseIP(t *testing.T) { + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatalf("autorité : %v", err) + } + crt, err := ca.leafFor("192.168.122.50") + if err != nil { + t.Fatalf("feuille : %v", err) + } + leaf, _ := x509.ParseCertificate(crt.Certificate[0]) + if len(leaf.IPAddresses) != 1 { + t.Errorf("%d adresse(s) dans le certificat, attendu 1", len(leaf.IPAddresses)) + } + if len(leaf.DNSNames) != 0 { + t.Errorf("une adresse IP est passée en nom DNS : %v", leaf.DNSNames) + } +} + +// errRefus est l'alerte qu'envoie un client qui a REGARDÉ notre certificat. +var errRefus = errors.New("remote error: tls: unknown certificate authority") + +// Le repli ne se déclare pas d'avance : un hôte qui refuse le certificat est +// retenu, et la requête suivante vers lui n'essaie plus de le déchiffrer. +func TestRefusRetenu(t *testing.T) { + r := NewRefusals(nil) + if r.Has("api.example") { + t.Fatal("un hôte est exclu avant tout refus") + } + r.Echec("api.example", errRefus) + if !r.Has("api.example") { + t.Error("le refus n'est pas retenu") + } + // Deux fois le même hôte ne double pas l'entrée. + r.Echec("api.example", errRefus) + if n := len(r.List()); n != 1 { + t.Errorf("%d hôtes retenus, attendu 1", n) + } +} + +// Un suffixe couvre un domaine entier, sous-domaines compris. +func TestExclusionParSuffixe(t *testing.T) { + r := NewRefusals([]string{".snapcraft.io"}) + for _, h := range []string{"api.snapcraft.io", "dashboard.snapcraft.io"} { + if !r.Has(h) { + t.Errorf("%s n'est pas couvert par le suffixe", h) + } + } + if r.Has("snapcraft.io.example.com") { + t.Error("le suffixe attrape un domaine qui ne fait que le contenir") + } +} + +// Les hôtes dont l'épinglage est connu d'avance évitent de perdre une requête +// pour l'apprendre. +func TestExclusionsParDefaut(t *testing.T) { + r := NewRefusals(DefaultExclusions) + if !r.Has("api.snapcraft.io") { + t.Error("snapd n'est pas exclu d'avance, alors qu'il épingle") + } +} + +// La casse d'un nom d'hôte n'a pas de sens en DNS. +func TestExclusionInsensibleALaCasse(t *testing.T) { + r := NewRefusals([]string{"API.Example"}) + if !r.Has("api.example") { + t.Error("l'exclusion dépend de la casse") + } +} + +// L'empreinte sert à vérifier de visu qu'une VM approuve BIEN cette autorité. +func TestEmpreinteLisible(t *testing.T) { + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatalf("autorité : %v", err) + } + fp := ca.Fingerprint() + if n := strings.Count(fp, ":"); n != 31 { + t.Errorf("%d séparateurs, attendu 31 pour un SHA-256", n) + } + if fp != strings.ToUpper(fp) { + t.Errorf("empreinte en minuscules : %s", fp) + } +} + +// Un premier enregistrement qui n'est pas une poignée de main TLS est refusé +// tôt : le port du 443 détourné ne reçoit rien d'autre. +func TestPremierEnregistrementNonTLS(t *testing.T) { + if _, err := peekSNI([]byte{0x17, 0x03, 0x03, 0x00, 0x01, 0x00}); err == nil { + t.Error("un enregistrement qui n'est pas un handshake est accepté") + } +} + +// Une coupure isolée ne condamne pas ; une coupure QUI SE RÉPÈTE, oui. +// +// Les deux se ressemblent au serveur : npm rejette notre certificat sans +// envoyer d'alerte, si bien qu'on ne voit qu'un EOF — exactement ce que +// produit une VM qui démarre et coupe. Seule la répétition les sépare. +func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) { + r := NewRefusals(nil) + coupure := errors.New("read: connection reset by peer") + for i := 1; i < r.Seuil; i++ { + if r.Echec("miroir.example", coupure) { + t.Fatalf("condamné dès l'échec %d, seuil %d", i, r.Seuil) + } + } + if r.Has("miroir.example") { + t.Error("condamné avant le seuil") + } +} + +func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) { + r := NewRefusals(nil) + coupure := errors.New("EOF") + for i := 0; i < r.Seuil; i++ { + r.Echec("npm.example", coupure) + } + if !r.Has("npm.example") { + t.Error("un client qui échoue à chaque fois n'est jamais mis en" + + " tunnel : son installation s'arrêtera là") + } +} + +// Un soupçon se rouvre ; une décision, jamais. +// +// Trois coupures de suite condamnent l'hôte, et c'est voulu — mais elles ne +// disent rien de ce que le client pense de notre autorité. Le garder banni +// pour toujours prive le cache de tout ce qu'il détient pour lui : un tunnel +// recopie des octets sans consulter le magasin. Un miroir de distribution +// banni sur une rafale de flux corrompus renvoie alors à l'amont jusqu'à ce +// qui est en réserve. +func TestUnRefusDeTransportSeRouvre(t *testing.T) { + r := NewRefusals(nil) + r.Oubli = 50 * time.Millisecond + coupure := errors.New("local error: tls: bad record MAC") + for i := 0; i < r.Seuil; i++ { + r.Echec("miroir.example", coupure) + } + if !r.Has("miroir.example") { + t.Fatal("le seuil ne condamne plus") + } + time.Sleep(60 * time.Millisecond) + if r.Has("miroir.example") { + t.Error("un soupçon de transport ne se rouvre jamais") + } + if slices.Contains(r.List(), "miroir.example") { + t.Error("un refus expiré figure encore dans la liste") + } +} + +// L'oubli rouvre le doute ENTIER : un hôte rouvert qui coupe une fois n'est pas +// recondamné sur-le-champ. Le compte d'avant l'oubli, s'il survivait, ferait +// d'une coupure isolée — un processus qui se termine en abandonnant ses +// poignées de main — un nouveau bannissement de toute la durée d'oubli. +func TestLOubliRemetLeCompteAZero(t *testing.T) { + // Has et List constatent l'oubli chacun de leur côté : les deux doivent + // rouvrir le compte. + lectures := map[string]func(r *Refusals, h string) bool{ + "Has": func(r *Refusals, h string) bool { return r.Has(h) }, + "List": func(r *Refusals, h string) bool { return slices.Contains(r.List(), h) }, + } + for nom, lire := range lectures { + t.Run(nom, func(t *testing.T) { + r := NewRefusals(nil) + r.Oubli = 50 * time.Millisecond + for i := 0; i < r.Seuil; i++ { + r.Echec("registre.example", io.EOF) + } + time.Sleep(60 * time.Millisecond) + if lire(r, "registre.example") { + t.Fatal("le soupçon ne s'est pas rouvert") + } + if r.Echec("registre.example", io.EOF) { + t.Error("une seule coupure après l'oubli recondamne l'hôte") + } + }) + } +} + +// Un hôte en tunnel dont l'amont ne répond pas est DÉCHIFFRÉ sur la même +// connexion : le tunnel ne pourrait que couper le client, alors que le magasin +// détient peut-être ce qu'il demande. +func TestUnTunnelSansAmontSeRabatSurLeDechiffrement(t *testing.T) { + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // Une adresse qui refuse : l'écoute est fermée aussitôt ouverte. + ferme, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + coupee := ferme.Addr().String() + ferme.Close() + + const hote = "registre.example.invalid" + r := NewRefusals(nil) + for i := 0; i < r.Seuil; i++ { + r.Echec(hote, io.EOF) + } + front := &TLSFront{ + CA: ca, Proxy: proxyDeTest(t), Refusals: r, + Origine: func(net.Conn) (string, error) { return coupee, nil }, + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go front.Serve(ln) + + brut, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer brut.Close() + brut.SetDeadline(time.Now().Add(5 * time.Second)) + pool := x509.NewCertPool() + pool.AddCert(ca.cert) + tc := tls.Client(brut, &tls.Config{ServerName: hote, RootCAs: pool}) + if err := tc.Handshake(); err != nil { + t.Fatalf("l'amont coupé laisse le client sans poignée de main : %v", err) + } + if !r.Has(hote) { + t.Error("le repli a effacé le soupçon : il ne vaut que pour cette" + + " connexion") + } +} + +// Une ALERTE ne se rouvre pas, quel que soit le réglage d'oubli : le client a +// REGARDÉ notre certificat. Le ré-intercepter ferait échouer de nouveau +// l'installation qui le traverse. +func TestUneAlerteNeSeRouvreJamais(t *testing.T) { + r := NewRefusals(nil) + r.Oubli = time.Millisecond + r.Echec("epingleur.example", errRefus) + time.Sleep(10 * time.Millisecond) + if !r.Has("epingleur.example") { + t.Error("une décision du client a été oubliée") + } + if !slices.Contains(r.List(), "epingleur.example") { + t.Error("une alerte a disparu de la liste") + } +} + +// Le défaut n'est plus « jamais » : sans lui, une rafale condamne un hôte +// jusqu'au redémarrage du service. +func TestLOubliEstActifParDefaut(t *testing.T) { + if NewRefusals(nil).Oubli <= 0 { + t.Error("un refus de transport ne se rouvre jamais par défaut") + } +} + +// Une réussite efface le compte : deux incidents éloignés ne doivent pas +// s'additionner jusqu'au seuil. +func TestUneReussiteEffaceLeCompte(t *testing.T) { + r := NewRefusals(nil) + coupure := errors.New("EOF") + for i := 1; i < r.Seuil; i++ { + r.Echec("h.example", coupure) + } + r.Reussite("h.example") + for i := 1; i < r.Seuil; i++ { + r.Echec("h.example", coupure) + } + if r.Has("h.example") { + t.Error("des incidents éloignés se sont additionnés") + } +} + +// Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat. +func TestUneAlerteCondamneDesLePremierEchec(t *testing.T) { + r := NewRefusals(nil) + if !r.Echec("epingleur.example", errors.New("remote error: tls: bad certificate")) { + t.Error("une alerte n'est pas retenue immédiatement") + } +} + +// Un REFUS et une COUPURE ne disent pas la même chose. +// +// Le code les confondait : « connection reset by peer » était journalisé +// « certificat refusé » et faisait passer l'hôte en tunnel opaque. Un miroir +// de distribution se retrouve alors soustrait au cache sur une seule coupure, +// et tout son trafic repart à l'amont. +func TestUneCoupureNestPasUnRefus(t *testing.T) { + coupures := []error{ + errors.New("read tcp 10.0.0.1:8899->10.0.0.2:33918: read:" + + " connection reset by peer"), + io.EOF, + errors.New("read tcp: i/o timeout"), + nil, + } + for _, err := range coupures { + if estRefusTLS(err) { + t.Errorf("%v pris pour un refus du client", err) + } + } +} + +func TestUneAlerteEstUnRefus(t *testing.T) { + refus := []error{ + errors.New("remote error: tls: bad certificate"), + errors.New("remote error: tls: unknown certificate authority"), + tls.AlertError(42), + } + for _, err := range refus { + if !estRefusTLS(err) { + t.Errorf("%v n'est pas reconnu comme un refus", err) + } + } +} + +// L'erreur enveloppée compte autant : la bibliothèque en emballe parfois. +func TestUnRefusEnveloppeEstReconnu(t *testing.T) { + err := fmt.Errorf("poignée de main : %w", tls.AlertError(48)) + if !estRefusTLS(err) { + t.Error("un refus enveloppé n'est pas reconnu") + } +} + +// clientHelloBrut fabrique un ClientHello valide portant ce nom, sans rien +// engager : les octets sont capturés sur un tuyau, puis rejoués à la main. +func clientHelloBrut(t *testing.T, nom string) []byte { + t.Helper() + a, b := net.Pipe() + defer a.Close() + go func() { + tc := tls.Client(a, &tls.Config{ServerName: nom}) + _ = tc.Handshake() + }() + b.SetReadDeadline(time.Now().Add(2 * time.Second)) + brut, err := readFirstRecord(b) + b.Close() + if err != nil { + t.Fatalf("ClientHello : %v", err) + } + return brut +} + +// Le contrôle précédent vérifie la RÈGLE ; celui-ci vérifie qu'elle est +// branchée. Sans lui, remettre les deux cas dans le même sac laisse les tests +// verts et recondamne un miroir au tunnel dès la première coupure. +func TestUneCoupureNeCondamnePasLHote(t *testing.T) { + ca, err := LoadOrCreateCA(t.TempDir()) + if err != nil { + t.Fatal(err) + } + refus := NewRefusals(nil) + front := &TLSFront{CA: ca, Refusals: refus, Proxy: proxyDeTest(t)} + hello := clientHelloBrut(t, "miroir.example") + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + fini := make(chan struct{}) + go func() { + if c, err := ln.Accept(); err == nil { + front.handle(c) + } + close(fini) + }() + + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + // Le ClientHello part, puis la connexion est coupée BRUTALEMENT : c'est + // la coupure de transport que le code prenait pour un rejet d'autorité. + // Aucune alerte n'est émise — ce serait l'autre cas. + if _, err := c.Write(hello); err != nil { + t.Fatal(err) + } + c.(*net.TCPConn).SetLinger(0) + c.Close() + + select { + case <-fini: + case <-time.After(3 * time.Second): + t.Fatal("la connexion n'a pas été traitée") + } + if refus.Has("miroir.example") { + t.Error("une coupure a condamné l'hôte au tunnel opaque") + } +} diff --git a/script/qemu_cache/origdst_linux.go b/script/qemu_cache/origdst_linux.go new file mode 100644 index 0000000..b3ef54e --- /dev/null +++ b/script/qemu_cache/origdst_linux.go @@ -0,0 +1,72 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +//go:build linux + +package main + +import ( + "errors" + "fmt" + "net" + "syscall" +) + +// SO_ORIGINAL_DST : l'option que Netfilter renseigne sur une socket +// redirigée. Elle n'est pas dans le paquet « syscall », d'où la constante. +const soOriginalDst = 80 + +// originalDst rend l'adresse que l'invité voulait joindre. +// +// En interception transparente le client croit parler au serveur amont : la +// socket acceptée porte l'adresse du cache, et la destination d'origine n'est +// connue que du noyau, qui la garde pour cette redirection. Sans elle, un +// tunnel opaque n'a nulle part à aller. +func originalDst(c net.Conn) (string, error) { + // La connexion peut être enveloppée par le rejeu du ClientHello : c'est + // la socket dessous qui porte l'option. + for { + if r, ok := c.(*replayed); ok { + c = r.Conn + continue + } + break + } + tcp, ok := c.(*net.TCPConn) + if !ok { + return "", errors.New("la connexion n'est pas une socket TCP") + } + raw, err := tcp.SyscallConn() + if err != nil { + return "", err + } + var addr string + var inner error + err = raw.Control(func(fd uintptr) { + // GetsockoptIPv6Mreq rend seize octets bruts, ce qui est exactement + // la taille d'un « sockaddr_in » : famille, port, adresse. Le nom de + // l'appel ne correspond pas à l'usage, mais c'est le seul du paquet + // « syscall » qui rende un tampon de cette taille. + mreq, e := syscall.GetsockoptIPv6Mreq(int(fd), syscall.IPPROTO_IP, soOriginalDst) + if e != nil { + inner = e + return + } + port := int(mreq.Multiaddr[2])<<8 | int(mreq.Multiaddr[3]) + ip := net.IPv4( + mreq.Multiaddr[4], mreq.Multiaddr[5], + mreq.Multiaddr[6], mreq.Multiaddr[7], + ) + addr = fmt.Sprintf("%s:%d", ip.String(), port) + }) + if err != nil { + return "", err + } + if inner != nil { + return "", inner + } + if addr == "" || addr == "0.0.0.0:0" { + return "", errors.New("aucune destination d'origine : la connexion n'est pas redirigée") + } + return addr, nil +} diff --git a/script/qemu_cache/origdst_other.go b/script/qemu_cache/origdst_other.go new file mode 100644 index 0000000..3c3af4e --- /dev/null +++ b/script/qemu_cache/origdst_other.go @@ -0,0 +1,18 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +//go:build !linux + +package main + +import ( + "errors" + "net" +) + +// originalDst n'a de sens que là où Netfilter garde la destination d'une +// connexion redirigée. Ailleurs, le tunnel opaque est impossible et le dire +// vaut mieux que de deviner une adresse. +func originalDst(net.Conn) (string, error) { + return "", errors.New("interception transparente : Linux seulement") +} diff --git a/script/qemu_cache/proxy.go b/script/qemu_cache/proxy.go new file mode 100644 index 0000000..d1e3824 --- /dev/null +++ b/script/qemu_cache/proxy.go @@ -0,0 +1,777 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// Issues d'une requête, telles qu'elles paraissent au journal d'accès. Le +// journal EST la mesure : un test qui veut prouver que la seconde VM n'a rien +// tiré de l'amont compte les lignes, sans avoir à instrumenter l'invité. +const ( + OutcomeHit = "hit" // servi du disque + OutcomeStored = "stored" // pris à l'amont, gardé + OutcomeFetched = "fetched" // pris à l'amont, non gardé + OutcomeStale = "stale" // amont muet, copie stockée servie + OutcomeOfflineMiss = "offline-miss" // amont muet, rien en réserve + OutcomeKeep = "keep" // amont muet, le client garde la sienne + OutcomePassthrough = "passthrough" // méthode ou requête non cachable + OutcomeError = "error" // amont joignable, mais en erreur + OutcomeMirror = "mirror" // servi d'un dépôt git tenu sur l'hôte + + // Un statut gardé sans corps a ses PROPRES noms : les lecteurs du + // journal tiennent « stored » et « stale » pour la preuve qu'un corps est + // en réserve, et un refus gardé ne l'est pas. + OutcomeStoredStatus = "stored-status" // pris à l'amont, statut seul gardé + OutcomeStaleStatus = "stale-status" // amont muet, statut seul rejoué +) + +// AccessLog écrit une ligne JSON par requête. Un format à une ligne par +// requête se lit par « grep » et se compte par « wc », ce qui est exactement +// ce qu'un test de bout en bout a besoin de faire. +type AccessLog struct { + mu sync.Mutex + f *os.File +} + +func OpenAccessLog(path string) (*AccessLog, error) { + if path == "" { + return &AccessLog{}, nil + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, err + } + return &AccessLog{f: f}, nil +} + +type accessLine struct { + Time string `json:"time"` + Method string `json:"method"` + URL string `json:"url"` + Class string `json:"class"` + Outcome string `json:"outcome"` + Status int `json:"status"` + Bytes int64 `json:"bytes"` + // Upstream dit si l'octet a traversé le réseau. C'est le champ que la + // mesure regarde. + Upstream bool `json:"upstream"` + // Client est l'adresse de l'invité qui a demandé. + // + // Sans elle, le journal dit ce que le cache a fait mais pas POUR QUI. Un + // doute sur l'accélération reste alors sans réponse : rien ne sépare ce + // qu'une VM a tiré du réseau de ce qu'une autre a été servie du disque. + // Le port est retiré — il change à chaque connexion et empêcherait tout + // regroupement. + Client string `json:"client,omitempty"` +} + +// clientDe rend l'adresse de l'invité, sans son port. +func clientDe(adresse string) string { + if h, _, err := net.SplitHostPort(adresse); err == nil { + return h + } + return adresse +} + +func (a *AccessLog) Write(l accessLine) { + if a == nil || a.f == nil { + return + } + l.Time = time.Now().UTC().Format(time.RFC3339) + raw, err := json.Marshal(l) + if err != nil { + return + } + a.mu.Lock() + defer a.mu.Unlock() + a.f.Write(append(raw, '\n')) +} + +func (a *AccessLog) Close() { + if a != nil && a.f != nil { + a.f.Close() + } +} + +// Proxy sert les requêtes détournées vers lui. +type Proxy struct { + Store *Store + Log *AccessLog + Client *http.Client + // ClientPatient sert les échanges où l'amont CALCULE avant de répondre. + // Un serveur git énumère ses références à la demande, ce qui demande des + // dizaines de secondes avant le premier octet sur un dépôt chargé, quand + // un miroir de paquets répond en quelques centaines de millisecondes. Le + // délai court, + // qui existe pour que le repli hors ligne arrive avant que le client + // renonce, prenait ce calcul pour un amont injoignable et rendait un 504 : + // « repo sync » échouait alors sur un dépôt parfaitement joignable. + ClientPatient *http.Client + // Git tient les dépôts en miroir sur l'hôte. Nul ou éteint, la + // négociation git est simplement relayée vers l'amont. + Git *GitMirror + // Verbose fait parler chaque requête sur la sortie standard, ce qu'un + // service systemd envoie au journal. + Verbose bool + // Muets retient les amonts dont l'établissement vient d'échouer, et la + // mémoire est partagée avec le miroir git. Nulle, chaque requête retente + // l'amont et repaie le délai d'établissement. + Muets *Joignabilite + // Ecoutes porte les ports où le cache lui-même écoute. Une requête qui + // vise l'un d'eux sur une adresse de cette machine est une boucle. Vide, + // rien n'est refusé. + Ecoutes []int +} + +// NewProxy monte le client amont. Aucun délai GLOBAL n'est posé : une image +// qcow2 met des minutes à descendre, et un délai global la couperait au +// milieu. Les délais portent donc sur l'établissement et sur l'attente des +// en-têtes, jamais sur la durée du corps. +func NewProxy(store *Store, alog *AccessLog) *Proxy { + // Les délais sont bornés par la PATIENCE DU CLIENT, pas par la nôtre. + // pacman abandonne un fichier après dix secondes sous un octet par + // seconde ; si notre repli sur la copie stockée arrive plus tard, il + // n'arrive jamais — et un pare-feu qui jette les paquets sans les refuser + // fait justement pendre l'établissement jusqu'au délai. + tr := &http.Transport{ + DialContext: (&net.Dialer{Timeout: DelaiEtablissement}).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 8 * time.Second, + MaxIdleConnsPerHost: 8, + Proxy: http.ProxyFromEnvironment, + } + // Le même transport, la seule attente des en-têtes allongée : un serveur + // injoignable est toujours détecté à l'établissement, en quatre secondes. + trPatient := tr.Clone() + trPatient.ResponseHeaderTimeout = 120 * time.Second + + // Une redirection est RENDUE au client plutôt que suivie : il la + // redemandera au travers du cache, et la copie reste rangée sous l'URL que + // l'invité a réellement demandée. + sansSuivre := func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + return &Proxy{ + Store: store, + Log: alog, + Client: &http.Client{Transport: tr, CheckRedirect: sansSuivre}, + ClientPatient: &http.Client{ + Transport: trPatient, CheckRedirect: sansSuivre, + }, + } +} + +// En-têtes que la norme réserve à un saut : les recopier vers l'amont ou vers +// le client casse la connexion. +var hopByHop = []string{ + "Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate", + "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade", +} + +func copyHeader(dst, src http.Header) { + for k, vs := range src { + for _, v := range vs { + dst.Add(k, v) + } + } + for _, h := range hopByHop { + dst.Del(h) + } +} + +// absoluteURL reconstruit l'adresse demandée. En interception transparente le +// client parle comme s'il tenait le serveur en face de lui : la ligne de +// requête ne porte qu'un chemin, et l'hôte vient de l'en-tête « Host » pour +// HTTP, du SNI pour TLS. +func absoluteURL(r *http.Request, scheme string) (*url.URL, error) { + if r.URL.IsAbs() { + return r.URL, nil + } + host := r.Host + if host == "" { + return nil, fmt.Errorf("requête sans hôte : ni ligne absolue ni en-tête Host") + } + u := *r.URL + u.Scheme = scheme + u.Host = host + return &u, nil +} + +func (p *Proxy) handler(scheme string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.serve(w, r, scheme) + }) +} + +func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { + u, err := absoluteURL(r, scheme) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + class := Classify(u) + key := CleDe(r.Method, u) + cacheable := CacheableMethod(r.Method) && class != ClassNoStore + + // Une requête adressée au cache lui-même serait relayée vers sa propre + // écoute, qui la relaierait de nouveau, sans fin : chaque tour ouvre une + // connexion, jusqu'à épuiser les descripteurs et arrêter le service. Elle + // est refusée avant toute autre chose. + if p.viseLeCache(u) { + p.boucle(w, u, class, r.Method, clientDe(r.RemoteAddr)) + return + } + + // Une requête partielle n'est servie du cache que si le corps ENTIER y + // est ; sinon elle passe et ne se garde pas, un fragment ne valant rien + // pour la requête suivante. + partial := r.Header.Get("Range") != "" + + if cacheable && class == ClassImmutable { + if p.serveFromStore(w, r, u, key, class, OutcomeHit) { + return + } + } + + // La négociation git n'est pas relayée quand un miroir peut la servir : + // c'est un DÉPÔT que l'on tient, pas des réponses, aucune réponse de ce + // protocole ne se réutilisant d'un client à l'autre. + if p.Git.Actif() && EstGitSmart(u) && !strings.HasSuffix( + u.Path, "/git-receive-pack", + ) { + if depot, reste, ok := DepotDeURL(u); ok { + if chemin, pret := p.Git.Assurer(r.Context(), depot); pret { + // Le relevé vient APRÈS : c'est la seule façon de dire ce que + // la réponse a réellement pesé, et ce chemin porte l'essentiel + // du trafic d'une installation. + n := p.Git.Servir(w, r, chemin, reste) + p.record(accessLine{ + URL: u.String(), Method: r.Method, Class: class.String(), + Outcome: OutcomeMirror, Status: http.StatusOK, Bytes: n, + Client: clientDe(r.RemoteAddr), + }) + return + } + } + } + + // Le client détient-il déjà une copie ? Relevé AVANT tout, et jamais + // relu sur la requête d'amont : celle-ci peut avoir perdu sa condition + // juste en dessous, et l'oubli ferait rendre « 504 » à un client qui + // avait de quoi se passer de nous. + conditionnelle := estConditionnelle(r) + + // Une requête conditionnelle sur une ressource dont NOUS n'avons pas le + // corps rapporte « 304 », donc rien à garder. Le cache resterait vide + // aussi longtemps que ses clients en détiennent une copie — c'est-à-dire + // toujours, une image cloud livrant déjà l'index de sa suite de base. + // La condition est donc retirée pour ce seul aller : l'amont envoie le + // corps entier, une fois, et toute VM suivante est servie, hors ligne + // comprise. Le corps une fois en réserve, la condition repart et le + // « 304 » économise de nouveau la bande passante. + detient := cacheable && p.Store.Detient(key) + amont := r + if cacheable && r.Method == "GET" && conditionnelle && !detient { + amont = sansCondition(r) + } + + // Un statut seul vit sous sa propre clé, que les lecteurs de corps ne + // calculent pas : voir CleStatut. + cleStatut := CleStatut(r.Method, u) + + // repli dit si la branche hors ligne a de quoi répondre : un corps 200, + // la copie du client (requête conditionnelle d'ORIGINE) ou un statut + // gardé. Seul un repli autorise à ne pas composer vers un amont connu + // muet ; sans lui, sauter la tentative changerait un aléa passager en + // « 504 » certain, pour une requête que l'amont revenu aurait servie. + repli := cacheable && + (detient || conditionnelle || p.Store.TientStatut(cleStatut)) + + resp, upErr := p.fetch(amont, u, repli) + // Une redirection est SUIVIE quand le nom du fichier demandé porte déjà + // son identité, et le contenu est gardé sous l'URL DEMANDÉE. + // + // Sans cela, un paquet publié derrière une redirection n'entre jamais au + // cache : la cible est une URL SIGNÉE qui change à chaque requête, si bien + // que la machine suivante ne retrouve rien et retélécharge. Rendre la + // redirection au client suppose une cible stable, ce qu'une signature à + // péremption n'est pas. + // + // Chaque étape part de la requête d'AMONT : ses conditions sont retirées + // quand le cache ne détient rien, et la requête d'origine ferait répondre + // « 304 » à la cible — il n'y aurait alors rien à garder. + if upErr == nil && class == ClassImmutable && r.Method == "GET" { + resp = p.suivreRedirections(amont, u, resp) + } + if upErr != nil { + // L'amont est injoignable : DNS muet, connexion refusée, délai + // dépassé. C'est ici, et seulement ici, qu'une copie périmée sort — + // y compris un index, ce qui rend le déploiement hors ligne possible. + // Un index plus récent que la signature qui l'annonce n'est PAS + // servi : voir indexIncoherent. Le client tombe alors sur le « 304 » + // qui le laisse garder ses listes, ou sur le refus qui suit. + if cacheable && !p.indexIncoherent(u, key) && + p.serveFromStore(w, r, u, key, class, OutcomeStale) { + return + } + // Le client a posé une condition : il DÉTIENT déjà une copie, et ne + // demandait qu'à savoir si elle avait changé. Ne pouvant plus le + // vérifier, lui rendre « 304 » le laisse garder la sienne — c'est ce + // que « stale-if-error » veut dire pour une requête conditionnelle. + // + // Un « 504 » à sa place fait échouer toute la suite de dépôt : apt + // ne trouve alors plus un paquet de la suite de base, alors que la + // machine avait chez elle de quoi le nommer. + if conditionnelle { + w.Header().Set("X-ERPLibre-Cache", OutcomeKeep) + w.WriteHeader(http.StatusNotModified) + p.record(accessLine{ + Method: r.Method, URL: u.String(), Class: class.String(), + Outcome: OutcomeKeep, Status: http.StatusNotModified, + Client: clientDe(r.RemoteAddr), + }) + return + } + // Ni corps en réserve ni copie chez le client : reste le STATUT que + // l'amont a rendu la dernière fois, une redirection ou un refus. Le + // client qui suit la redirection, ou qui s'arrête sur le 404, reçoit + // ce qu'il aurait reçu en ligne, là où un 504 l'arrêterait net. + // + // Rejoué en DERNIER recours : un corps gardé l'emporte toujours, et le + // client qui détient sa copie garde son 304 plutôt qu'un refus. + if cacheable && p.rejouerStatut(w, r, u, cleStatut, class) { + return + } + p.offlineMiss( + w, u, class, r.Method, clientDe(r.RemoteAddr), upErr, + ) + return + } + defer resp.Body.Close() + + store := cacheable && !partial && resp.StatusCode == http.StatusOK && + r.Method == "GET" + // Un statut est gardé SEUL, sans corps, quand il porte une réponse + // qu'aucun corps ne remplace : redirection ou refus définitif pour un GET, + // et en plus le 200 d'un HEAD, qui n'a jamais de corps. Il est rangé + // sous CleStatut, jamais sous la clé du corps, et ne ressort que l'amont + // muet. Trois bornes : + // - le volatile seul : l'immuable suit ses redirections, et un 404 + // servi du disque y masquerait le fichier publié ensuite ; + // - jamais sous une clé portable : partagée par tous les miroirs, elle + // recevrait le refus d'un miroir en retard à la place de l'index + // qu'un autre a rendu ; + // - jamais quand la clé du CORPS tient un 200 : le rejeu ne passe + // qu'après lui, et le garder ne servirait qu'à le faire mentir le + // jour où ce corps disparaît. + // Un statut passager — 403, 429, 5xx — ne se garde pas : le rejouer + // figerait une panne qui n'a duré qu'un moment. + // + // Detenir, dans « --detient », tient la négation exacte de cette + // condition pour un HEAD : les deux sont à changer ensemble. + statutSeul := !store && cacheable && !partial && + class == ClassVolatile && !PortableParChemin(u) && + statutSansCorps(r.Method, resp) && !detient + + var cw *Writer + if store || statutSeul { + cle := key + if statutSeul { + cle = cleStatut + } + m := Meta{ + URL: u.String(), + Method: r.Method, + Status: resp.StatusCode, + Header: resp.Header.Clone(), + Class: class.String(), + } + if statutSeul { + m.StatusOnly = true + sansLongueurMenteuse(r.Method, m.Header) + // Le témoin de session appartient à la machine qui l'a reçu. + m.Header.Del("Set-Cookie") + } + if cw, err = p.Store.NewWriter(cle, m); err != nil { + log.Printf("cache : écriture impossible pour %s : %v", u, err) + cw = nil + } + } + + copyHeader(w.Header(), resp.Header) + w.Header().Set("X-ERPLibre-Cache", "miss") + w.WriteHeader(resp.StatusCode) + + // Le corps d'un statut seul va au client tel quel, jamais au disque. + var sink *Writer + if cw != nil && store { + sink = cw + } + var n int64 + if sink != nil { + n, err = copyTee(w, sink, resp.Body) + } else { + n, err = copyTee(w, nil, resp.Body) + } + + outcome := OutcomeFetched + switch { + case err != nil: + // Le client s'est déconnecté ou l'amont a coupé : rien de partiel + // n'entre au cache. + if cw != nil { + cw.Abort() + } + outcome = OutcomeError + case cw != nil: + // Un statut seul se publie VIDE, quelle que soit la longueur + // annoncée : pour un HEAD, la bibliothèque la tire de l'en-tête alors + // qu'aucun corps ne suit, et la comparer ferait tout refuser. + attendu := resp.ContentLength + if statutSeul { + attendu = 0 + } + if cerr := cw.Commit(attendu); cerr != nil { + log.Printf("cache : %s non gardé : %v", u, cerr) + outcome = OutcomeFetched + } else if statutSeul { + outcome = OutcomeStoredStatus + } else { + outcome = OutcomeStored + } + case !cacheable: + outcome = OutcomePassthrough + } + + p.record(accessLine{ + Method: r.Method, URL: u.String(), Class: class.String(), + Outcome: outcome, Status: resp.StatusCode, Bytes: n, Upstream: true, + Client: clientDe(r.RemoteAddr), + }) +} + +// serveFromStore rend vrai quand la réponse est partie du disque. +func (p *Proxy) serveFromStore( + w http.ResponseWriter, r *http.Request, u *url.URL, key string, + class Class, outcome string, +) bool { + m, f, err := p.Store.Get(key) + if err != nil { + return false + } + defer f.Close() + // Seul un corps 200 sort par ici. Un statut seul a son propre chemin, + // réservé à l'amont muet : ServeContent en ferait un 200 vide, et sur le + // chemin de l'immuable il sortirait même quand l'amont répond. + if m.StatutSeul() { + return false + } + + copyHeader(w.Header(), m.Header) + w.Header().Set("X-ERPLibre-Cache", outcome) + if outcome == OutcomeStale { + // L'opérateur doit pouvoir dire de quand datent les octets sur + // lesquels sa VM se bâtit. + w.Header().Set("X-ERPLibre-Cache-Date", m.StoredAt.Format(time.RFC3339)) + } + // ServeContent tient les requêtes partielles et le code 206 : pacman + // reprend un téléchargement interrompu par une plage. + http.ServeContent(w, r, "", m.StoredAt, f) + + p.record(accessLine{ + Method: r.Method, URL: u.String(), Class: class.String(), + Outcome: outcome, Status: http.StatusOK, Bytes: m.Size, Upstream: false, + Client: clientDe(r.RemoteAddr), + }) + if p.Verbose { + log.Printf("%s %s -> %s (%s)", r.Method, u, outcome, HumanBytes(m.Size)) + } + return true +} + +// rejouerStatut rend, sans corps, le statut gardé sous la clé — une clé de +// CleStatut : ses en-têtes, « Location » compris et tel que l'amont l'a +// écrit, puis le code. Rend faux quand la clé ne porte pas de statut seul. +// +// ServeContent est évité entièrement : il réécrirait le code en 200, et +// ferait d'une redirection un « 304 » ou un « 206 » selon ce que le client +// pose. La longueur d'un GET est retirée une seconde fois, un méta écrit à +// la main pouvant la porter ; celle d'un HEAD ressort telle que l'amont l'a +// annoncée. +func (p *Proxy) rejouerStatut( + w http.ResponseWriter, r *http.Request, u *url.URL, key string, + class Class, +) bool { + m, f, err := p.Store.Get(key) + if err != nil { + return false + } + f.Close() + if !m.StatutSeul() { + return false + } + + copyHeader(w.Header(), m.Header) + sansLongueurMenteuse(m.Method, w.Header()) + w.Header().Set("X-ERPLibre-Cache", OutcomeStaleStatus) + w.Header().Set("X-ERPLibre-Cache-Date", m.StoredAt.Format(time.RFC3339)) + w.WriteHeader(m.Status) + + p.record(accessLine{ + Method: r.Method, URL: u.String(), Class: class.String(), + Outcome: OutcomeStaleStatus, Status: m.Status, Upstream: false, + Client: clientDe(r.RemoteAddr), + }) + if p.Verbose { + log.Printf("%s %s -> %s (%d)", r.Method, u, OutcomeStaleStatus, m.Status) + } + return true +} + +// sansLongueurMenteuse retire la longueur d'un statut seul, sauf pour un HEAD. +// +// Pour un GET, la longueur annoncée sans corps mentirait : le client +// attendrait des octets qui ne viendront jamais, et échouerait sur une fin de +// flux inattendue. Pour un HEAD, elle ne ment pas — la norme (RFC 9110) en +// fait la taille du corps que rendrait le GET, et aucun client ne lit de +// corps après un HEAD. C'est même la seule chose qu'il apprend du corps : +// l'installateur qui compare une taille par « curl -I » recevrait sinon hors +// ligne une autre réponse qu'en ligne. +func sansLongueurMenteuse(methode string, h http.Header) { + if methode != http.MethodHead { + h.Del("Content-Length") + } +} + +// statutSansCorps dit si la réponse porte un statut qui vaut d'être gardé +// seul : une redirection qui dit où aller, un refus définitif, et pour un +// HEAD aussi le 200 — le HEAD n'a de toute façon jamais de corps. +// +// Une redirection sans « Location » ne mène nulle part : elle n'est pas +// gardée. +func statutSansCorps(methode string, resp *http.Response) bool { + switch resp.StatusCode { + case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, + http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + return resp.Header.Get("Location") != "" + case http.StatusNotFound, http.StatusGone: + return true + case http.StatusOK: + return methode == "HEAD" + } + return false +} + +// offlineMiss dit CE QUI manque. Un 404 nu ferait accuser le miroir : le +// client n'a aucun moyen de savoir qu'un cache s'est interposé, et le message +// est la seule chance de le lui apprendre. +func (p *Proxy) offlineMiss( + w http.ResponseWriter, u *url.URL, class Class, method, client string, + cause error, +) { + msg := enCommentaire(fmt.Sprintf( + "erplibre_go_qemu_cache : amont injoignable et rien en réserve.\n"+ + " demandé : %s\n"+ + " classe : %s\n"+ + " cause : %v\n"+ + "%s"+ + "Ce fichier n'a jamais traversé ce cache. Rétablir le réseau, ou\n"+ + "déployer une VM identique à celle qui a rempli le cache.\n", + u, class, cause, decrireMuet(cause))) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-ERPLibre-Cache", OutcomeOfflineMiss) + w.WriteHeader(http.StatusGatewayTimeout) + fmt.Fprint(w, msg) + + p.record(accessLine{ + Method: method, URL: u.String(), Class: class.String(), + Outcome: OutcomeOfflineMiss, Status: http.StatusGatewayTimeout, + Client: client, + }) + log.Printf("hors ligne, absent du cache : %s", u) +} + +// maxRedirections borne la chaîne : une boucle de redirections tournerait +// jusqu'à épuiser la mémoire, et aucune publication légitime n'en enchaîne +// autant. +const maxRedirections = 5 + +// suivreRedirections rend la réponse FINALE d'une chaîne de redirections, ou +// la dernière obtenue si quelque chose s'y oppose. +// +// Chaque étape rejoue `r`, la requête d'amont partie vers `depart`. Les +// identifiants — « Authorization », « Cookie », « Proxy-Authorization » — ne +// suivent pas une étape qui quitte l'hôte de départ : ils ont été confiés à +// cet hôte, et une redirection vers un stockage tiers les lui livrerait. Le +// client HTTP de la bibliothèque les retire de même ; un suivi à la main doit +// le faire lui-même. +// +// Une cible qui désigne le cache lui-même arrête la chaîne : la suivre +// renverrait la requête ici. +// +// Le corps de chaque étape est refermé : une redirection en porte un, court, +// que personne ne lira. Une erreur en route rend l'étape courante plutôt que +// rien : le client verra la redirection et se débrouillera, ce qui est le +// comportement d'avant. +func (p *Proxy) suivreRedirections( + r *http.Request, depart *url.URL, resp *http.Response, +) *http.Response { + origine := adresseAmont(depart) + for i := 0; i < maxRedirections; i++ { + if resp.StatusCode < 300 || resp.StatusCode > 399 { + return resp + } + cible, err := resp.Location() + if err != nil || cible == nil || p.viseLeCache(cible) { + return resp + } + etape := r + if adresseAmont(cible) != origine { + etape = sansIdentifiants(r) + } + // Sans repli : l'étape qui échoue rend la redirection au client, + // qui la redemandera au travers du cache — la tenter coûte au plus + // le délai d'établissement, la sauter ne ferait que le déplacer. + suivante, err := p.fetch(etape, cible, false) + if err != nil { + return resp + } + resp.Body.Close() + resp = suivante + } + return resp +} + +// enTetesDIdentite : ce par quoi un client s'authentifie auprès d'un hôte. +var enTetesDIdentite = []string{ + "Authorization", "Cookie", "Proxy-Authorization", +} + +// sansIdentifiants rend une COPIE de la requête, ses identifiants retirés. +func sansIdentifiants(r *http.Request) *http.Request { + out := r.Clone(r.Context()) + for _, h := range enTetesDIdentite { + out.Header.Del(h) + } + return out +} + +// enCommentaire rend un texte INERTE pour un interpréteur de commandes. +// +// Un corps d'erreur finit régulièrement dans un shell : l'idiome +// « curl … | bash » est celui de la moitié des installateurs, et « curl » +// sans « -f » lui livre le corps d'un 504 comme s'il l'avait demandé. Chaque +// ligne du message devenait alors une commande, et le lecteur recevait une +// cascade de « command not found » à la place de la cause. +// +// Chaque ligne est donc préfixée — y compris celles d'une cause qui en +// porterait plusieurs, sans quoi la première suffirait à sortir du +// commentaire. Le texte reste lisible pour l'humain, et ne fait rien. +func enCommentaire(texte string) string { + lignes := strings.Split(strings.TrimRight(texte, "\n"), "\n") + for i, l := range lignes { + lignes[i] = "# " + l + } + return strings.Join(lignes, "\n") + "\n" +} + +// enTetesConditionnels : ce par quoi un client dit « seulement si ça a +// changé ». Le « Range » n'en est pas — il demande un fragment, pas une +// validation, et il est traité ailleurs. +var enTetesConditionnels = []string{ + "If-None-Match", "If-Modified-Since", "If-Match", "If-Unmodified-Since", +} + +// estConditionnelle dit si le client détient déjà une copie de la ressource. +func estConditionnelle(r *http.Request) bool { + for _, h := range enTetesConditionnels { + if r.Header.Get(h) != "" { + return true + } + } + return false +} + +// sansCondition rend une COPIE de la requête, ses conditions retirées. +// +// Une copie : la requête d'origine est celle du serveur HTTP, et la modifier +// changerait ce que voit tout ce qui la lit ensuite — le relevé, notamment. +func sansCondition(r *http.Request) *http.Request { + out := r.Clone(r.Context()) + for _, h := range enTetesConditionnels { + out.Header.Del(h) + } + return out +} + +// fetch interroge l'amont. +// +// repli dit que l'appelant a de quoi répondre sans l'amont : corps gardé, +// copie du client ou statut gardé. Avec lui, un amont dont l'établissement +// vient d'échouer n'est pas retenté : fetch rend aussitôt une erreur qui +// enveloppe errAmontConnuMuet, et l'appelant sert son repli sans repayer le +// délai d'établissement. Un pare-feu qui jette les paquets fait attendre ce +// délai entier à chaque tentative ; le repli, lui, n'a rien à attendre. +// +// Sans repli, fetch compose toujours, mémoire ou non : un échec +// d'établissement passager — un paquet perdu, un miroir qui redémarre — +// ferait sinon rendre « 504 » à toute requête vers cet hôte pendant la +// fenêtre, alors que l'amont revenu l'aurait servie. +// +// Chaque échec d'établissement est retenu et chaque réponse efface l'hôte, +// avec ou sans repli : la mémoire ne retient que les établissements manqués. +func (p *Proxy) fetch( + r *http.Request, u *url.URL, repli bool, +) (*http.Response, error) { + adresse := adresseAmont(u) + if repli && p.Muets.ConnuMuet(adresse) { + return nil, fmt.Errorf("%w (%s)", errAmontConnuMuet, adresse) + } + out, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), r.Body) + if err != nil { + return nil, err + } + copyHeader(out.Header, r.Header) + // L'identité de l'invité est conservée : certains miroirs répondent + // différemment selon l'agent, et un paquet servi à un agent n'est pas + // forcément celui servi à un autre. + out.Header.Del("Accept-Encoding") + client := p.Client + if EstGitSmart(u) { + client = p.ClientPatient + } + resp, err := client.Do(out) + if err != nil { + p.Muets.Echec(adresse, err) + return nil, err + } + p.Muets.Reussite(adresse) + return resp, nil +} + +func (p *Proxy) record(l accessLine) { + p.Log.Write(l) + if p.Verbose && l.Upstream { + log.Printf("%s %s -> %s (%s)", l.Method, l.URL, l.Outcome, HumanBytes(l.Bytes)) + } +} + +// hostOnly retire le port d'une autorité, le SNI n'en portant pas. +func hostOnly(hostport string) string { + if h, _, err := net.SplitHostPort(hostport); err == nil { + return h + } + return strings.TrimSuffix(hostport, ":") +} diff --git a/script/qemu_cache/proxy_test.go b/script/qemu_cache/proxy_test.go new file mode 100644 index 0000000..8de0aa8 --- /dev/null +++ b/script/qemu_cache/proxy_test.go @@ -0,0 +1,510 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +// amont monte un serveur qui compte ce qu'on lui demande. Le compteur EST la +// mesure de tous ces tests : le cache n'a d'intérêt que s'il fait baisser le +// nombre de requêtes qui sortent. +type amont struct { + srv *httptest.Server + appel int64 + corps string +} + +func nouvelAmont(t *testing.T, corps string) *amont { + t.Helper() + a := &amont{corps: corps} + a.srv = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&a.appel, 1) + w.Header().Set("Content-Type", "application/octet-stream") + io.WriteString(w, a.corps) + })) + t.Cleanup(a.srv.Close) + return a +} + +func (a *amont) appels() int64 { return atomic.LoadInt64(&a.appel) } + +func (a *amont) hote() string { + u, _ := url.Parse(a.srv.URL) + return u.Host +} + +func proxyDeTest(t *testing.T) *Proxy { + t.Helper() + alog, err := OpenAccessLog("") + if err != nil { + t.Fatalf("journal : %v", err) + } + return NewProxy(&Store{Dir: t.TempDir()}, alog) +} + +// demande joue une requête telle que l'interception transparente la présente : +// une ligne de requête sans hôte, l'hôte venant de l'en-tête. +func demande(t *testing.T, p *Proxy, hote, chemin string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("GET", chemin, nil) + r.Host = hote + w := httptest.NewRecorder() + p.serve(w, r, "http") + return w +} + +// Invariant 1 : un index n'est JAMAIS servi du cache tant que l'amont répond. +// +// C'est la règle qui empêche le cache de provoquer le « failed retrieving +// file … 404 » sur un paquet retiré des miroirs : servir un index périmé +// promet des fichiers qui n'existent plus. +func TestIndexJamaisServiQuandAmontRepond(t *testing.T) { + a := nouvelAmont(t, "base de paquets") + p := proxyDeTest(t) + + for i := 1; i <= 3; i++ { + w := demande(t, p, a.hote(), "/arch/core/os/x86_64/core.db") + if w.Code != 200 { + t.Fatalf("requête %d : code %d", i, w.Code) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" { + t.Errorf("requête %d : servie « %s », attendu « miss »", i, got) + } + } + if n := a.appels(); n != 3 { + t.Errorf("l'amont a reçu %d requêtes, attendu 3 : un index a été servi du cache", n) + } +} + +// Invariant 2 : un fichier de paquet est servi du cache dès la seconde +// demande. C'est le gain que l'outil existe pour produire. +func TestPaquetServiDuCache(t *testing.T) { + a := nouvelAmont(t, "contenu du paquet") + p := proxyDeTest(t) + chemin := "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst" + + w1 := demande(t, p, a.hote(), chemin) + if w1.Header().Get("X-ERPLibre-Cache") != "miss" { + t.Fatalf("première demande servie du cache : %s", + w1.Header().Get("X-ERPLibre-Cache")) + } + w2 := demande(t, p, a.hote(), chemin) + if got := w2.Header().Get("X-ERPLibre-Cache"); got != OutcomeHit { + t.Errorf("seconde demande servie « %s », attendu « %s »", got, OutcomeHit) + } + if w2.Body.String() != a.corps { + t.Errorf("corps servi %q, attendu %q", w2.Body.String(), a.corps) + } + if n := a.appels(); n != 1 { + t.Errorf("l'amont a reçu %d requêtes, attendu 1", n) + } +} + +// Invariant 3 : l'amont muet fait sortir la copie stockée, index compris. +// C'est ce qui rend un déploiement possible sans réseau du tout. +func TestIndexServiQuandAmontMuet(t *testing.T) { + a := nouvelAmont(t, "base de paquets") + p := proxyDeTest(t) + chemin := "/arch/core/os/x86_64/core.db" + + if w := demande(t, p, a.hote(), chemin); w.Code != 200 { + t.Fatalf("remplissage : code %d", w.Code) + } + hote := a.hote() + a.srv.Close() // l'amont devient injoignable + + w := demande(t, p, hote, chemin) + if w.Code != 200 { + t.Fatalf("hors ligne : code %d, attendu 200", w.Code) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale) + } + if w.Header().Get("X-ERPLibre-Cache-Date") == "" { + t.Error("la date de l'instantané manque : l'opérateur ne peut pas savoir de quand datent ses octets") + } + if w.Body.String() != a.corps { + t.Errorf("corps servi %q, attendu %q", w.Body.String(), a.corps) + } +} + +// Invariant 4 : hors ligne et sans copie, l'erreur NOMME le fichier. +// +// Un 404 nu ferait accuser le miroir : le client n'a aucun moyen de savoir +// qu'un cache s'est interposé. +func TestDefautHorsLigneNommeLeFichier(t *testing.T) { + a := nouvelAmont(t, "peu importe") + hote := a.hote() + a.srv.Close() + p := proxyDeTest(t) + + w := demande(t, p, hote, "/arch/core/os/x86_64/jamais-vu.pkg.tar.zst") + if w.Code != http.StatusGatewayTimeout { + t.Fatalf("code %d, attendu %d", w.Code, http.StatusGatewayTimeout) + } + corps := w.Body.String() + for _, attendu := range []string{"jamais-vu.pkg.tar.zst", hote, "erplibre_go_qemu_cache"} { + if !strings.Contains(corps, attendu) { + t.Errorf("le message ne dit pas %q :\n%s", attendu, corps) + } + } +} + +// Une requête partielle ne remplit pas le cache : un fragment ne sert à rien +// à la demande suivante, et le garder comme un corps entier servirait un +// paquet tronqué. +func TestRequetePartielleNonGardee(t *testing.T) { + a := nouvelAmont(t, "0123456789") + p := proxyDeTest(t) + chemin := "/x/paquet-1-1-x86_64.pkg.tar.zst" + + r := httptest.NewRequest("GET", chemin, nil) + r.Host = a.hote() + r.Header.Set("Range", "bytes=0-4") + p.serve(httptest.NewRecorder(), r, "http") + + // Une demande entière ensuite doit ressortir à l'amont. + w := demande(t, p, a.hote(), chemin) + if got := w.Header().Get("X-ERPLibre-Cache"); got == OutcomeHit { + t.Error("un fragment est entré au cache et a été servi comme un corps entier") + } + if n := a.appels(); n != 2 { + t.Errorf("l'amont a reçu %d requêtes, attendu 2", n) + } +} + +// Un POST ne se cache pas : il change un état à l'amont. +func TestPostNonCache(t *testing.T) { + a := nouvelAmont(t, "réponse") + p := proxyDeTest(t) + + for i := 0; i < 2; i++ { + r := httptest.NewRequest("POST", "/api/quelque-chose.whl", strings.NewReader("x")) + r.Host = a.hote() + p.serve(httptest.NewRecorder(), r, "http") + } + if n := a.appels(); n != 2 { + t.Errorf("l'amont a reçu %d requêtes, attendu 2 : un POST a été caché", n) + } +} + +// Un transport qui note le client par lequel la requête est passée. +type transportTemoin struct { + nom string + vues *[]string +} + +func (t transportTemoin) RoundTrip(r *http.Request) (*http.Response, error) { + *t.vues = append(*t.vues, t.nom) + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("")), + Request: r, + }, nil +} + +// Le délai court existe pour que le repli hors ligne arrive AVANT que le +// client renonce. Il prenait le calcul d'un serveur git pour un amont +// injoignable — mesuré à seize secondes avant le premier octet — et rendait un +// 504 sur un dépôt parfaitement joignable, faisant échouer « repo sync ». +func TestGitPasseParLeClientPatient(t *testing.T) { + var vues []string + p := &Proxy{ + Client: &http.Client{ + Transport: transportTemoin{"court", &vues}, + }, + ClientPatient: &http.Client{ + Transport: transportTemoin{"patient", &vues}, + }, + } + cas := []struct { + brut string + attendu string + }{ + {"https://git.example/o/d.git/info/refs?service=git-upload-pack", + "patient"}, + {"https://git.example/o/d.git/git-upload-pack", "patient"}, + {"https://miroir.example/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst", + "court"}, + {"https://miroir.example/core/os/x86_64/core.db", "court"}, + } + for _, c := range cas { + vues = nil + u, _ := url.Parse(c.brut) + r, _ := http.NewRequest("GET", c.brut, nil) + if _, err := p.fetch(r, u, false); err != nil { + t.Fatalf("%s : %v", c.brut, err) + } + if len(vues) != 1 || vues[0] != c.attendu { + t.Errorf("%s passé par %v, attendu « %s »", + c.brut, vues, c.attendu) + } + } +} + +// Les deux clients ne se distinguent que par leur patience : un amont +// injoignable reste détecté à l'établissement, en quatre secondes, dans les +// deux cas. +func TestLeClientPatientLestUniquementSurLesEnTetes(t *testing.T) { + p := NewProxy(&Store{Dir: t.TempDir()}, nil) + court := p.Client.Transport.(*http.Transport) + patient := p.ClientPatient.Transport.(*http.Transport) + if patient.ResponseHeaderTimeout <= court.ResponseHeaderTimeout { + t.Errorf("le client patient n'attend pas plus : %v contre %v", + patient.ResponseHeaderTimeout, court.ResponseHeaderTimeout) + } + if patient.TLSHandshakeTimeout != court.TLSHandshakeTimeout { + t.Error("le délai de poignée de main ne doit pas changer") + } +} + +// Un paquet publié derrière une redirection vers une URL SIGNÉE. +// +// La cible change à chaque requête — c'est ce que fait une signature à +// péremption. Rendre la redirection au client, en comptant qu'il la +// redemandera au travers du cache, suppose une cible stable : ici la machine +// suivante ne retrouve rien et retélécharge le paquet en entier. +func TestPaquetDerriereUneRedirectionSignee(t *testing.T) { + var telechargements, signature int64 + contenu := strings.Repeat("charge utile", 64) + + stockage := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&telechargements, 1) + io.WriteString(w, contenu) + })) + t.Cleanup(stockage.Close) + + publication := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt64(&signature, 1) + http.Redirect(w, r, + stockage.URL+"/objet?signature="+string(rune('a'+n)), + http.StatusFound) + })) + t.Cleanup(publication.Close) + + u, _ := url.Parse(publication.URL) + p := proxyDeTest(t) + chemin := "/paquets/releases/download/1.0/outil_1.0_amd64.deb" + + for i := 1; i <= 3; i++ { + w := demande(t, p, u.Host, chemin) + if w.Code != 200 { + t.Fatalf("requête %d : code %d, attendu 200", i, w.Code) + } + if w.Body.String() != contenu { + t.Fatalf("requête %d : corps de %d octets", i, w.Body.Len()) + } + } + if n := atomic.LoadInt64(&telechargements); n != 1 { + t.Errorf("le paquet est descendu %d fois, attendu 1 :"+ + " la redirection empêche le cache de servir", n) + } +} + +// Un index, lui, garde l'ancien comportement : la redirection lui est rendue, +// et il la redemandera. Rien n'est gardé d'un index tant que l'amont répond, +// donc suivre la chaîne n'apporterait rien et masquerait au client l'hôte qui +// lui a réellement répondu. +func TestUnIndexRecoitSaRedirection(t *testing.T) { + cible := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "base") + })) + t.Cleanup(cible.Close) + publication := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, cible.URL+"/vraie.db", http.StatusFound) + })) + t.Cleanup(publication.Close) + + u, _ := url.Parse(publication.URL) + w := demande(t, proxyDeTest(t), u.Host, "/arch/core/os/x86_64/core.db") + if w.Code != http.StatusFound { + t.Errorf("code %d, attendu 302 : la redirection a été suivie", w.Code) + } +} + +// Une boucle de redirections doit rendre la main plutôt que tourner. +func TestUneBoucleDeRedirectionsSarrete(t *testing.T) { + var vues int64 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&vues, 1) + http.Redirect(w, r, srv.URL+"/tourne.deb", http.StatusFound) + })) + t.Cleanup(srv.Close) + + u, _ := url.Parse(srv.URL) + w := demande(t, proxyDeTest(t), u.Host, "/tourne.deb") + if w.Code != http.StatusFound { + t.Errorf("code %d, attendu 302 au bout de la chaîne", w.Code) + } + if n := atomic.LoadInt64(&vues); n > maxRedirections+1 { + t.Errorf("%d requêtes pour une boucle, borne %d", + n, maxRedirections+1) + } +} + +// proxyEtCasier rend un proxy et le répertoire de son casier : les contrôles +// qui suivent ont besoin de constater ce qui a été GARDÉ, pas seulement ce +// qui a été rendu. +func proxyEtCasier(t *testing.T) (*Proxy, string) { + t.Helper() + alog, err := OpenAccessLog("") + if err != nil { + t.Fatalf("journal : %v", err) + } + dir := t.TempDir() + return NewProxy(&Store{Dir: dir}, alog), dir +} + +func corpsGardes(t *testing.T, dir string) int { + t.Helper() + n := 0 + _ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error { + if err == nil && !fi.IsDir() && strings.HasSuffix(p, ".body") { + n++ + } + return nil + }) + return n +} + +// Une image cloud livre déjà l'index de sa suite de base. Son apt le REVALIDE +// au lieu de le télécharger, l'amont rend « 304 », et le cache n'a donc +// jamais de corps à garder pour cette ressource — aussi longtemps que ses +// clients en détiennent une copie, c'est-à-dire toujours. +// +// Hors ligne, la suite de base est alors la seule à manquer : ses voisines +// « -updates », « -security » et « -backports » sortent périmées du cache, et +// apt ne trouve plus un paquet de la base pendant que le reste marche. +func TestUneConditionnelleNeLaissePasLeCasierVide(t *testing.T) { + var recues []string + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + recues = append(recues, r.Header.Get("If-Modified-Since")) + if r.Header.Get("If-Modified-Since") != "" { + w.WriteHeader(http.StatusNotModified) + return + } + _, _ = w.Write([]byte("Origin: Ubuntu\nSuite: base\n")) + })) + defer srv.Close() + hote, _ := url.Parse(srv.URL) + + p, dir := proxyEtCasier(t) + joue := func() *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil) + r.Host = hote.Host + r.Header.Set("If-Modified-Since", "Wed, 01 Jan 2020 00:00:00 GMT") + w := httptest.NewRecorder() + p.serve(w, r, "http") + return w + } + + if w := joue(); w.Code != http.StatusOK { + t.Fatalf("statut rendu %d, attendu 200", w.Code) + } + if len(recues) != 1 || recues[0] != "" { + t.Fatalf("la condition est partie à l'amont : %q", recues) + } + if corpsGardes(t, dir) == 0 { + t.Fatal("rien n'est gardé : le cache restera vide pour cette suite") + } + + // Le corps est là : la condition peut repartir, et un « 304 » ne coûte + // alors plus rien — c'est ce qui économise la bande passante. + recues = nil + joue() + if len(recues) != 1 || recues[0] == "" { + t.Fatalf("la condition a encore été retirée : %q", recues) + } +} + +// Amont muet et client qui détient déjà sa copie : lui rendre « 504 » le +// prive d'une suite entière qu'il pouvait lire. « 304 » le laisse garder la +// sienne, ce que « stale-if-error » veut dire pour une conditionnelle. +func TestAmontMuetEtClientQuiDetientDejaSaCopie(t *testing.T) { + p, _ := proxyEtCasier(t) + r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil) + // Rien n'écoute : l'amont est injoignable. + r.Host = "127.0.0.1:1" + r.Header.Set("If-None-Match", `"abc"`) + w := httptest.NewRecorder() + p.serve(w, r, "http") + + if w.Code != http.StatusNotModified { + t.Fatalf("statut rendu %d, attendu 304", w.Code) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeKeep { + t.Fatalf("issue %q, attendue %q", got, OutcomeKeep) + } +} + +// Sans condition, le client n'a rien à garder : le 504 reste la réponse +// juste, et il dit pourquoi. +func TestAmontMuetSansCopieChezLeClient(t *testing.T) { + p, _ := proxyEtCasier(t) + r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil) + r.Host = "127.0.0.1:1" + w := httptest.NewRecorder() + p.serve(w, r, "http") + + if w.Code != http.StatusGatewayTimeout { + t.Fatalf("statut rendu %d, attendu 504", w.Code) + } +} + +// Un corps d'erreur finit régulièrement dans un shell : « curl … | bash » +// est l'idiome de la moitié des installateurs, et « curl » sans « -f » lui +// livre le corps d'un 504 comme s'il l'avait demandé. +// +// Le message doit donc ne RIEN faire une fois exécuté. Sans cela, l'échec +// se lit « bash: line 1: erplibre_go_qemu_cache: command not found » et la +// cause véritable, elle, ne se lit plus. +func TestLeMessageHorsLigneEstInerteDansUnShell(t *testing.T) { + p, _ := proxyEtCasier(t) + r := httptest.NewRequest("GET", "/pyenv-installer", nil) + r.Host = "127.0.0.1:1" + w := httptest.NewRecorder() + p.serve(w, r, "http") + + if w.Code != http.StatusGatewayTimeout { + t.Fatalf("statut rendu %d, attendu 504", w.Code) + } + for i, l := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") { + if !strings.HasPrefix(l, "#") { + t.Fatalf("ligne %d exécutable par un shell : %q", i+1, l) + } + } + // Inerte ne veut pas dire muet : la cause reste lisible. + if !strings.Contains(w.Body.String(), "amont injoignable") { + t.Fatal("le message ne dit plus pourquoi") + } +} + +// Une cause qui porte plusieurs lignes ne doit pas sortir du commentaire : +// il suffirait de la première pour rendre la suite exécutable. +func TestUneCauseMultiligneResteCommentee(t *testing.T) { + got := enCommentaire("premier\nseconde\ntroisième\n") + for _, l := range strings.Split(strings.TrimRight(got, "\n"), "\n") { + if !strings.HasPrefix(l, "# ") { + t.Fatalf("ligne non commentée : %q", l) + } + } +} diff --git a/script/qemu_cache/rules.go b/script/qemu_cache/rules.go new file mode 100644 index 0000000..c4d3c03 --- /dev/null +++ b/script/qemu_cache/rules.go @@ -0,0 +1,159 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "fmt" + "strings" +) + +// RuleSet décrit le détournement à poser sur le pont de l'orchestrateur. +// +// Les règles sont RENDUES sous forme de texte et jamais exécutées depuis ce +// paquet : le script d'installation les applique, et un test peut donc les +// vérifier au caractère près sans toucher au pare-feu de la machine qui +// exécute les tests. +type RuleSet struct { + // Bridge est le pont que libvirt donne à ses VM, « virbr0 » par défaut. + Bridge string + // Subnet est le /24 que ce réseau sert. + Subnet string + // HTTPPort et TLSPort sont les deux écoutes du cache. + HTTPPort int + TLSPort int + // Bypass : les adresses MAC soustraites au détournement, déjà + // canoniques. Vide, l'ensemble existe quand même — il est peuplé à + // chaud, et une règle qui viserait un ensemble absent ferait échouer + // TOUT le jeu de règles, donc le démarrage du service. + Bypass []string +} + +// TableName : une table à nous, effaçable d'un geste, plutôt que des règles +// ajoutées dans les chaînes d'autrui — que le retrait devrait alors retrouver +// une par une. +const TableName = "erplibre_qemu_cache" + +// NftLines rend les lignes à passer à « nft -f - ». +// +// La clause « ip daddr != » est la garde qui compte : sans elle, une +// VM qui joint l'orchestrateur lui-même — ssh, Odoo, un service quelconque +// sur la passerelle du /24 — se retrouve détournée vers le cache, qui ne sait +// rien en faire. Seul ce qui SORT du sous-réseau est intercepté. Une règle +// réseau posée trop large sur le pont d'un hôte est la classe de panne qui +// prive une machine de son propre réseau. +// +// L'exception par MAC passe AVANT les deux détournements, sans quoi elle +// n'aurait aucun effet : la première règle qui rend un verdict l'emporte. +// « accept » clôt cette chaîne-ci et non le hook : les chaînes des autres +// tables — le masquage de libvirt, notamment — sont évaluées ensuite, donc +// une VM exceptée garde son réseau normal. +func (r RuleSet) NftLines() []string { + lignes := []string{ + fmt.Sprintf("table ip %s {", TableName), + fmt.Sprintf(" set %s {", BypassSetName), + " type ether_addr", + } + if len(r.Bypass) > 0 { + lignes = append(lignes, fmt.Sprintf( + " elements = { %s }", strings.Join(r.Bypass, ", "))) + } + lignes = append(lignes, + " }", + " chain prerouting {", + " type nat hook prerouting priority dstnat; policy accept;", + fmt.Sprintf( + ` iifname "%s" ether saddr @%s accept`, + r.Bridge, BypassSetName), + fmt.Sprintf( + ` iifname "%s" ip saddr %s ip daddr != %s tcp dport 80 redirect to :%d`, + r.Bridge, r.Subnet, r.Subnet, r.HTTPPort), + fmt.Sprintf( + ` iifname "%s" ip saddr %s ip daddr != %s tcp dport 443 redirect to :%d`, + r.Bridge, r.Subnet, r.Subnet, r.TLSPort), + " }", + "}", + ) + return lignes +} + +// NftDeleteLine rend le geste de retrait. Une seule table à effacer, donc un +// seul geste : le désarmement ne peut pas être à moitié fait. +func (r RuleSet) NftDeleteLine() string { + return fmt.Sprintf("nft delete table ip %s", TableName) +} + +// IptablesLines rend l'équivalent pour un hôte qui n'a pas nft. Le script +// d'installation choisit selon ce qui répond ; les deux jeux disent la même +// chose. +// +// Les exceptions sont émises en tête : iptables n'a pas d'ensemble nommé, la +// chaîne est bâtie par « -A » successifs, et un RETURN posé après les +// redirections ne serait jamais atteint. Le corollaire est qu'ici une +// exception neuve demande de reposer les règles, là où nft en ajoute une à +// chaud. +func (r RuleSet) IptablesLines() []string { + var lignes []string + for _, mac := range r.Bypass { + lignes = append(lignes, fmt.Sprintf( + "iptables -t nat -A PREROUTING -i %s -m mac --mac-source %s "+ + "-j RETURN", r.Bridge, mac)) + } + return append(lignes, + fmt.Sprintf( + "iptables -t nat -A PREROUTING -i %s -s %s ! -d %s "+ + "-p tcp --dport 80 -j REDIRECT --to-ports %d", + r.Bridge, r.Subnet, r.Subnet, r.HTTPPort), + fmt.Sprintf( + "iptables -t nat -A PREROUTING -i %s -s %s ! -d %s "+ + "-p tcp --dport 443 -j REDIRECT --to-ports %d", + r.Bridge, r.Subnet, r.Subnet, r.TLSPort), + ) +} + +// GuestEnvLines rend ce qu'une VM doit poser en plus de l'autorité. +// +// pip embarque son propre jeu de certificats et IGNORE le magasin système ; +// npm fait de même. Poser l'autorité ne suffit donc pas pour eux, alors +// qu'elle suffit pour pacman et pour apt, qui lisent le magasin. +// +// La valeur visée est le FAISCEAU système, que la commande de confiance +// régénère avec notre autorité dedans — et non le seul certificat du cache : +// pointer celui-là ferait perdre à pip toutes les autres autorités, donc +// échouer sur le premier hôte que le cache ne déchiffre pas, et le jour où le +// cache disparaît. +func GuestEnvLines(caPath string) []string { + return []string{ + fmt.Sprintf("PIP_CERT=%s", caPath), + fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", caPath), + fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caPath), + } +} + +// GuestTrustCommand rend la commande qui fait approuver l'autorité, par +// famille de distribution. Le chemin ET la commande changent, et se tromper +// laisse une VM qui échoue sur chaque téléchargement HTTPS. +// +// Les familles portent le nom de leur gestionnaire de paquets, comme partout +// ailleurs dans le dépôt. La même table existe côté déploiement, qui écrit le +// fichier dans l'invité ; un test les compare, la dérive entre deux copies +// étant le seul risque de cette duplication. +func GuestTrustCommand(family string) (dir, cmd, bundle string, ok bool) { + switch family { + case "pacman": + return "/etc/ca-certificates/trust-source/anchors", "trust extract-compat", + "/etc/ssl/certs/ca-certificates.crt", true + case "apt": + return "/usr/local/share/ca-certificates", "update-ca-certificates", + "/etc/ssl/certs/ca-certificates.crt", true + case "dnf": + return "/etc/pki/ca-trust/source/anchors", "update-ca-trust", + "/etc/pki/tls/certs/ca-bundle.crt", true + case "zypper": + // openSUSE range ses ancres ailleurs que la famille RHEL, tout en + // employant la même commande que Debian. + return "/etc/pki/trust/anchors", "update-ca-certificates", + "/etc/ssl/certs/ca-certificates.crt", true + } + return "", "", "", false +} diff --git a/script/qemu_cache/rules_test.go b/script/qemu_cache/rules_test.go new file mode 100644 index 0000000..24a9eaa --- /dev/null +++ b/script/qemu_cache/rules_test.go @@ -0,0 +1,235 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "strings" + "testing" +) + +func jeuDeTest() RuleSet { + return RuleSet{ + Bridge: "virbr0", Subnet: "192.168.122.0/24", + HTTPPort: 8898, TLSPort: 8899, + } +} + +// La garde qui compte : seul ce qui SORT du sous-réseau est détourné. Sans +// elle, une VM qui joint l'orchestrateur lui-même — ssh, Odoo, un service sur +// la passerelle du /24 — part vers le cache, qui n'en sait rien faire. +func TestNftEpargneLeTraficInterne(t *testing.T) { + lignes := jeuDeTest().NftLines() + texte := strings.Join(lignes, "\n") + + if !strings.Contains(texte, "ip daddr != 192.168.122.0/24") { + t.Errorf("l'exclusion du trafic interne manque :\n%s", texte) + } + for _, port := range []string{"tcp dport 80", "tcp dport 443"} { + if !strings.Contains(texte, port) { + t.Errorf("%q absent des règles", port) + } + } + if !strings.Contains(texte, "redirect to :8898") { + t.Error("le 80 ne part pas vers l'écoute HTTP") + } + if !strings.Contains(texte, "redirect to :8899") { + t.Error("le 443 ne part pas vers l'écoute TLS") + } + if !strings.Contains(texte, `iifname "virbr0"`) { + t.Error("les règles ne sont pas bornées au pont des VM") + } +} + +// Chaque règle porte l'exclusion : une seule qui l'oublie suffit à couper le +// trafic interne du port qu'elle vise. +func TestChaqueRegleDeRedirectionPorteLExclusion(t *testing.T) { + for _, l := range jeuDeTest().NftLines() { + if !strings.Contains(l, "redirect to") { + continue + } + if !strings.Contains(l, "ip daddr != 192.168.122.0/24") { + t.Errorf("règle sans exclusion : %s", l) + } + } + for _, l := range jeuDeTest().IptablesLines() { + if !strings.Contains(l, "! -d 192.168.122.0/24") { + t.Errorf("règle iptables sans exclusion : %s", l) + } + } +} + +// Le retrait est UN geste : un désarmement à moitié fait laisserait une VM +// détournée vers un cache éteint. +func TestRetraitEnUnGeste(t *testing.T) { + l := jeuDeTest().NftDeleteLine() + if !strings.Contains(l, "delete table ip "+TableName) { + t.Errorf("le retrait n'efface pas la table : %s", l) + } + if strings.Count(l, "\n") != 0 { + t.Errorf("le retrait tient sur plusieurs lignes : %q", l) + } +} + +// La table porte un nom à nous : des règles ajoutées dans les chaînes +// d'autrui devraient être retrouvées une par une pour être retirées. +func TestTableNommee(t *testing.T) { + if !strings.Contains(jeuDeTest().NftLines()[0], TableName) { + t.Error("les règles ne vivent pas dans une table nommée") + } +} + +// pip et npm ignorent le magasin système : poser l'autorité ne suffit pas +// pour eux, alors qu'elle suffit pour pacman et pour apt. +func TestVariablesDeLInvite(t *testing.T) { + lignes := GuestEnvLines("/etc/ssl/certs/ca-certificates.crt") + attendues := []string{"PIP_CERT=", "REQUESTS_CA_BUNDLE=", "NODE_EXTRA_CA_CERTS="} + if len(lignes) != len(attendues) { + t.Fatalf("%d variables, attendu %d", len(lignes), len(attendues)) + } + texte := strings.Join(lignes, "\n") + for _, a := range attendues { + if !strings.Contains(texte, a) { + t.Errorf("%q manque :\n%s", a, texte) + } + } + for _, l := range lignes { + if !strings.HasSuffix(l, "/etc/ssl/certs/ca-certificates.crt") { + t.Errorf("la variable ne pointe pas l'autorité : %s", l) + } + } +} + +// Le chemin ET la commande changent par famille : se tromper laisse une VM +// qui échoue sur chaque téléchargement HTTPS. +func TestCommandeDeConfiance(t *testing.T) { + cas := map[string][3]string{ + "pacman": { + "/etc/ca-certificates/trust-source/anchors", "trust extract-compat", + "/etc/ssl/certs/ca-certificates.crt", + }, + "apt": { + "/usr/local/share/ca-certificates", "update-ca-certificates", + "/etc/ssl/certs/ca-certificates.crt", + }, + "dnf": { + "/etc/pki/ca-trust/source/anchors", "update-ca-trust", + "/etc/pki/tls/certs/ca-bundle.crt", + }, + "zypper": { + "/etc/pki/trust/anchors", "update-ca-certificates", + "/etc/ssl/certs/ca-certificates.crt", + }, + } + for famille, attendu := range cas { + dir, cmd, bundle, ok := GuestTrustCommand(famille) + if !ok { + t.Errorf("%s : famille inconnue", famille) + continue + } + if dir != attendu[0] { + t.Errorf("%s : répertoire %q, attendu %q", famille, dir, attendu[0]) + } + if cmd != attendu[1] { + t.Errorf("%s : commande %q, attendu %q", famille, cmd, attendu[1]) + } + if bundle != attendu[2] { + t.Errorf("%s : faisceau %q, attendu %q", famille, bundle, attendu[2]) + } + } + if _, _, _, ok := GuestTrustCommand("plan9"); ok { + t.Error("une famille inconnue est pourtant acceptée") + } +} + +// L'exception doit passer AVANT les détournements : nftables rend le verdict +// de la première règle qui correspond, et une exception placée après ne +// serait jamais atteinte. +func TestExceptionAvantDetournement(t *testing.T) { + r := RuleSet{ + Bridge: "virbr0", Subnet: "192.168.122.0/24", + HTTPPort: 8898, TLSPort: 8899, + Bypass: []string{"52:54:00:aa:bb:cc"}, + } + lignes := r.NftLines() + iException, iRedirect := -1, -1 + for i, l := range lignes { + if strings.Contains(l, "ether saddr @"+BypassSetName) { + iException = i + } + if iRedirect < 0 && strings.Contains(l, "redirect to :") { + iRedirect = i + } + } + if iException < 0 { + t.Fatal("aucune règle d'exception rendue") + } + if iRedirect < 0 { + t.Fatal("aucun détournement rendu") + } + if iException > iRedirect { + t.Errorf("l'exception (ligne %d) suit le détournement (ligne %d)", + iException, iRedirect) + } +} + +// L'ensemble existe même vide : une règle qui viserait un ensemble absent +// ferait échouer le jeu ENTIER, donc le démarrage du service — un cache muet +// là où l'on n'a jamais demandé d'exception. +func TestEnsembleRenduMemeVide(t *testing.T) { + r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24", + HTTPPort: 8898, TLSPort: 8899} + texte := strings.Join(r.NftLines(), "\n") + if !strings.Contains(texte, "set "+BypassSetName+" {") { + t.Error("l'ensemble n'est pas déclaré quand aucune MAC n'est exceptée") + } + if !strings.Contains(texte, "type ether_addr") { + t.Error("l'ensemble n'a pas de type") + } + if strings.Contains(texte, "elements =") { + t.Error("un ensemble vide ne doit pas déclarer d'éléments") + } +} + +func TestElementsRendusQuandIlYEnA(t *testing.T) { + r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24", + HTTPPort: 8898, TLSPort: 8899, + Bypass: []string{"52:54:00:aa:bb:cc", "52:54:00:11:22:33"}} + texte := strings.Join(r.NftLines(), "\n") + if !strings.Contains(texte, + "elements = { 52:54:00:aa:bb:cc, 52:54:00:11:22:33 }") { + t.Errorf("éléments mal rendus :\n%s", texte) + } +} + +// iptables n'a pas d'ensemble nommé : la chaîne se bâtit par « -A » +// successifs, et un RETURN posé après les redirections ne serait jamais +// atteint. +func TestIptablesExceptionEnTete(t *testing.T) { + r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24", + HTTPPort: 8898, TLSPort: 8899, + Bypass: []string{"52:54:00:aa:bb:cc"}} + lignes := r.IptablesLines() + if len(lignes) != 3 { + t.Fatalf("%d lignes rendues, trois attendues : %v", len(lignes), lignes) + } + if !strings.Contains(lignes[0], "--mac-source 52:54:00:aa:bb:cc") || + !strings.Contains(lignes[0], "-j RETURN") { + t.Errorf("la première ligne n'est pas l'exception : %q", lignes[0]) + } + for _, l := range lignes[1:] { + if !strings.Contains(l, "REDIRECT") { + t.Errorf("ligne inattendue : %q", l) + } + } +} + +// Sans exception, le jeu iptables est celui d'avant : trois lignes là où deux +// suffisent trahirait une ligne vide rendue pour rien. +func TestIptablesSansExceptionInchange(t *testing.T) { + r := RuleSet{Bridge: "virbr0", Subnet: "192.168.122.0/24", + HTTPPort: 8898, TLSPort: 8899} + if n := len(r.IptablesLines()); n != 2 { + t.Errorf("%d lignes sans exception, deux attendues", n) + } +} diff --git a/script/qemu_cache/statut_test.go b/script/qemu_cache/statut_test.go new file mode 100644 index 0000000..d8c4e5f --- /dev/null +++ b/script/qemu_cache/statut_test.go @@ -0,0 +1,647 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// amontScripte monte un amont dont la réponse est dictée par le test, et qui +// compte ce qu'on lui demande. La réponse peut changer en cours de test : +// c'est ce qui fait rendre un 200 puis un 404 à la même URL. +type amontScripte struct { + srv *httptest.Server + appel int64 + mu sync.Mutex + h http.HandlerFunc +} + +func nouvelAmontScripte(t *testing.T, h http.HandlerFunc) *amontScripte { + t.Helper() + a := &amontScripte{h: h} + a.srv = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt64(&a.appel, 1) + a.mu.Lock() + h := a.h + a.mu.Unlock() + h(w, r) + })) + t.Cleanup(a.srv.Close) + return a +} + +func (a *amontScripte) repondre(h http.HandlerFunc) { + a.mu.Lock() + a.h = h + a.mu.Unlock() +} + +func (a *amontScripte) appels() int64 { return atomic.LoadInt64(&a.appel) } + +func (a *amontScripte) hote() string { + u, _ := url.Parse(a.srv.URL) + return u.Host +} + +// couper rend l'amont injoignable et rend son adresse, qui refuse désormais +// toute connexion. +func (a *amontScripte) couper() string { + h := a.hote() + a.srv.Close() + return h +} + +func rediriger(code int, cible string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", cible) + w.Header().Set("Set-Cookie", "session=temoin") + w.WriteHeader(code) + io.WriteString(w, "déplacé") + } +} + +func refuser(code int) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(code) + io.WriteString(w, "introuvable") + } +} + +func servir(corps string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + io.WriteString(w, corps) + } +} + +// joue fait une requête de méthode et d'en-têtes choisis, telle que +// l'interception transparente la présente. Les en-têtes vont par paires : +// nom, valeur. +func joue( + t *testing.T, p *Proxy, methode, hote, chemin string, entetes ...string, +) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(methode, chemin, nil) + r.Host = hote + for i := 0; i+1 < len(entetes); i += 2 { + r.Header.Set(entetes[i], entetes[i+1]) + } + w := httptest.NewRecorder() + p.serve(w, r, "http") + return w +} + +// journalDeTest branche un journal d'accès sur fichier et rend son chemin. +func journalDeTest(t *testing.T, p *Proxy) string { + t.Helper() + chemin := filepath.Join(t.TempDir(), "acces.jsonl") + alog, err := OpenAccessLog(chemin) + if err != nil { + t.Fatalf("journal : %v", err) + } + t.Cleanup(alog.Close) + p.Log = alog + return chemin +} + +func lignesDuJournal(t *testing.T, chemin string) []accessLine { + t.Helper() + raw, err := os.ReadFile(chemin) + if err != nil { + t.Fatalf("journal illisible : %v", err) + } + var out []accessLine + for _, l := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + if l == "" { + continue + } + var a accessLine + if err := json.Unmarshal([]byte(l), &a); err != nil { + t.Fatalf("ligne illisible %q : %v", l, err) + } + out = append(out, a) + } + return out +} + +// Une redirection volatile est gardée, mais ne sort QUE l'amont muet : tant +// qu'il répond, chaque requête lui revient, et le client reçoit ce que +// l'amont dit aujourd'hui. +func TestUneRedirectionVolatileNeSortQueHorsLigne(t *testing.T) { + const cible = "https://ailleurs.example/bootstrap.sh" + a := nouvelAmontScripte(t, rediriger(http.StatusFound, cible)) + p := proxyDeTest(t) + journal := journalDeTest(t, p) + + for i := 1; i <= 2; i++ { + w := joue(t, p, "GET", a.hote(), "/installer.sh") + if w.Code != http.StatusFound { + t.Fatalf("en ligne %d : code %d, attendu 302", i, w.Code) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" { + t.Errorf("en ligne %d : servi « %s », attendu « miss »", i, got) + } + } + if n := a.appels(); n != 2 { + t.Errorf("l'amont a reçu %d requêtes, attendu 2 : un statut gardé"+ + " est sorti alors qu'il répondait", n) + } + + hote := a.couper() + w := joue(t, p, "GET", hote, "/installer.sh") + if w.Code != http.StatusFound { + t.Fatalf("hors ligne : code %d, attendu 302", w.Code) + } + if got := w.Header().Get("Location"); got != cible { + t.Errorf("Location %q, attendu %q", got, cible) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStaleStatus { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeStaleStatus) + } + if w.Header().Get("X-ERPLibre-Cache-Date") == "" { + t.Error("la date du statut rejoué manque") + } + if w.Body.Len() != 0 { + t.Errorf("un statut rejoué porte un corps de %d octets", w.Body.Len()) + } + if got := w.Header().Get("Set-Cookie"); got != "" { + t.Errorf("le témoin d'une autre machine est rejoué : %q", got) + } + if got := w.Header().Get("Content-Length"); got != "" { + t.Errorf("une longueur %q est annoncée sans corps", got) + } + + // Le journal dit le VRAI statut, et sous des noms qui ne se confondent + // pas avec ceux d'un corps. + var garde, rejoue bool + for _, l := range lignesDuJournal(t, journal) { + switch l.Outcome { + case OutcomeStoredStatus: + garde = l.Status == http.StatusFound && l.Upstream + case OutcomeStaleStatus: + rejoue = l.Status == http.StatusFound && !l.Upstream + case OutcomeStored, OutcomeStale: + t.Errorf("un statut seul est journalisé « %s »", l.Outcome) + } + } + if !garde || !rejoue { + t.Errorf("journal : gardé=%v rejoué=%v, attendu les deux avec 302", + garde, rejoue) + } +} + +// Un refus définitif ressort tel quel : un client qui sonde un fichier +// optionnel s'arrête sur le 404 et poursuit, là où un 504 l'arrête net. +func TestUnRefusDefinitifSeRejoueHorsLigne(t *testing.T) { + for _, code := range []int{http.StatusNotFound, http.StatusGone} { + t.Run(fmt.Sprint(code), func(t *testing.T) { + a := nouvelAmontScripte(t, refuser(code)) + p := proxyDeTest(t) + if w := joue(t, p, "GET", a.hote(), "/sonde/2.root.json"); w.Code != code { + t.Fatalf("en ligne : code %d, attendu %d", w.Code, code) + } + hote := a.couper() + w := joue(t, p, "GET", hote, "/sonde/2.root.json") + if w.Code != code { + t.Fatalf("hors ligne : code %d, attendu %d", w.Code, code) + } + if w.Body.Len() != 0 { + t.Errorf("corps de %d octets rejoué", w.Body.Len()) + } + }) + } +} + +// Un refus n'écrase jamais un corps : un amont qui perd un fichier un jour +// ne doit pas priver le hors-ligne de la copie qu'il avait rendue. +func TestUnRefusNeRemplacePasUnCorps(t *testing.T) { + a := nouvelAmontScripte(t, servir("corps publié")) + p := proxyDeTest(t) + chemin := "/metadonnees/etat.json" + + joue(t, p, "GET", a.hote(), chemin) + a.repondre(refuser(http.StatusNotFound)) + if w := joue(t, p, "GET", a.hote(), chemin); w.Code != http.StatusNotFound { + t.Fatalf("en ligne, le refus de l'amont n'est pas relayé : %d", w.Code) + } + + hote := a.couper() + w := joue(t, p, "GET", hote, chemin) + if w.Code != http.StatusOK || w.Body.String() != "corps publié" { + t.Fatalf("hors ligne : %d %q, attendu le corps gardé", w.Code, w.Body.String()) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale) + } +} + +// Un corps, lui, l'emporte sur un statut seul — y compris quand le client +// pose une condition : le statut ne vaut pas détention, la condition est donc +// retirée, et l'amont rend le corps entier au lieu d'un 304. +func TestUnCorpsRemplaceUnStatutSeul(t *testing.T) { + a := nouvelAmontScripte(t, refuser(http.StatusNotFound)) + p := proxyDeTest(t) + chemin := "/metadonnees/etat.json" + joue(t, p, "GET", a.hote(), chemin) + + a.repondre(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-None-Match") != "" { + w.WriteHeader(http.StatusNotModified) + return + } + io.WriteString(w, "corps publié") + }) + w := joue(t, p, "GET", a.hote(), chemin, "If-None-Match", `"v1"`) + if w.Code != http.StatusOK { + t.Fatalf("code %d, attendu 200 : la condition est partie à l'amont"+ + " alors que le cache n'a qu'un statut", w.Code) + } + + hote := a.couper() + w = joue(t, p, "GET", hote, chemin) + if w.Code != http.StatusOK || w.Body.String() != "corps publié" { + t.Fatalf("hors ligne : %d %q, attendu le corps", w.Code, w.Body.String()) + } +} + +// Un statut seul vit sous sa propre clé. Un lecteur qui ne connaît que les +// corps — qui ne calcule que Key — n'y trouve rien, et ne peut donc pas le +// servir en « 200 » vide. Le rejeu, lui, le trouve ; et un corps gardé plus +// tard sous la clé du corps coexiste avec lui et sort en premier. +func TestUnStatutSeulResteHorsDesClesDeCorps(t *testing.T) { + a := nouvelAmontScripte(t, rediriger(http.StatusFound, + "https://ailleurs.example/bootstrap.sh")) + p := proxyDeTest(t) + p.Muets = NouvelleJoignabilite() + chemin := "/installer.sh" + u, _ := url.Parse("http://" + a.hote() + chemin) + joue(t, p, "GET", a.hote(), chemin) + + if _, err := p.Store.LireMeta(Key("GET", u.String())); err == nil { + t.Error("un statut seul est lisible sous la clé d'un corps") + } + if !p.Store.TientStatut(CleStatut("GET", u)) { + t.Fatal("le statut seul n'est pas sous sa propre clé") + } + + // L'hôte retenu muet : le repli sort sans composer, et c'est le rejeu. + p.Muets.Echec(a.hote(), refusEtablissement()) + w := joue(t, p, "GET", a.hote(), chemin) + if w.Code != http.StatusFound || + w.Header().Get("X-ERPLibre-Cache") != OutcomeStaleStatus { + t.Fatalf("rejeu : %d « %s », attendu 302 « %s »", w.Code, + w.Header().Get("X-ERPLibre-Cache"), OutcomeStaleStatus) + } + p.Muets.Reussite(a.hote()) + + a.repondre(servir("echo bonjour")) + if w := joue(t, p, "GET", a.hote(), chemin); w.Code != http.StatusOK { + t.Fatalf("en ligne : code %d, attendu 200", w.Code) + } + if !p.Store.Detient(Key("GET", u.String())) { + t.Fatal("le corps n'est pas gardé sous la clé du corps") + } + if !p.Store.TientStatut(CleStatut("GET", u)) { + t.Error("le statut seul a disparu : il devait coexister avec le corps") + } + + hote := a.couper() + w = joue(t, p, "GET", hote, chemin) + if w.Code != http.StatusOK || w.Body.String() != "echo bonjour" { + t.Fatalf("hors ligne : %d %q, attendu le corps", w.Code, w.Body.String()) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale) + } +} + +// Un statut passager ne se garde pas : le rejouer figerait une panne d'un +// moment, et le hors-ligne dirait 403 ou 503 pour toujours. +func TestUnStatutPassagerNestPasGarde(t *testing.T) { + for _, code := range []int{403, 429, 500, 503} { + t.Run(fmt.Sprint(code), func(t *testing.T) { + a := nouvelAmontScripte(t, refuser(code)) + p, dir := proxyEtCasier(t) + joue(t, p, "GET", a.hote(), "/api/etat") + hote := a.couper() + if w := joue(t, p, "GET", hote, "/api/etat"); w.Code != http.StatusGatewayTimeout { + t.Errorf("hors ligne : code %d, attendu 504", w.Code) + } + if n := corpsGardes(t, dir); n != 0 { + t.Errorf("%d objet(s) gardé(s) pour un %d", n, code) + } + }) + } +} + +// Une clé portable est partagée par tous les miroirs : le refus d'un miroir +// en retard y prendrait la place de l'index qu'un autre a rendu. +func TestUnCheminPortableNeGardeNiRedirectionNiRefus(t *testing.T) { + for nom, h := range map[string]http.HandlerFunc{ + "redirection": rediriger(http.StatusFound, "https://ailleurs.example/core.db"), + "refus": refuser(http.StatusNotFound), + } { + t.Run(nom, func(t *testing.T) { + a := nouvelAmontScripte(t, h) + p, dir := proxyEtCasier(t) + joue(t, p, "GET", a.hote(), "/arch/core/os/x86_64/core.db") + hote := a.couper() + w := joue(t, p, "GET", hote, "/arch/core/os/x86_64/core.db") + if w.Code != http.StatusGatewayTimeout { + t.Errorf("hors ligne : code %d, attendu 504", w.Code) + } + if n := corpsGardes(t, dir); n != 0 { + t.Errorf("%d objet(s) gardé(s) sous une clé portable", n) + } + }) + } +} + +// L'immuable ne garde pas son refus : servi du disque sans jamais +// redemander, un 404 masquerait le fichier publié ensuite. +// +// L'index par empreinte est le cas qui isole la règle : immuable mais +// attaché à son hôte, la borne des clés portables ne l'écarte pas. +func TestLImmuableNeGardePasSonRefus(t *testing.T) { + for _, chemin := range []string{ + "/outils/outil-2.0.tar.gz", + "/debian/dists/trixie/by-hash/SHA256/" + strings.Repeat("ab", 32), + } { + a := nouvelAmontScripte(t, refuser(http.StatusNotFound)) + p, dir := proxyEtCasier(t) + joue(t, p, "GET", a.hote(), chemin) + hote := a.couper() + if w := joue(t, p, "GET", hote, chemin); w.Code != http.StatusGatewayTimeout { + t.Errorf("%s hors ligne : code %d, attendu 504", chemin, w.Code) + } + if n := corpsGardes(t, dir); n != 0 { + t.Errorf("%s : %d objet(s) gardé(s) pour un 404 immuable", chemin, n) + } + } +} + +// Un HEAD garde son statut : un installateur lit la version publiée dans le +// « Location » d'un HEAD, et n'a besoin de rien d'autre. +// +// L'amont annonce une longueur sans corps — c'est ce que fait un HEAD. La +// comparer au corps écrit ferait refuser l'objet comme tronqué. +func TestUnHeadGardeSonStatut(t *testing.T) { + a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/o/d/releases/latest" { + rediriger(http.StatusFound, "/o/d/releases/tag/v1.2.3")(w, r) + return + } + w.Header().Set("Content-Length", "1234") + w.WriteHeader(http.StatusOK) + }) + p := proxyDeTest(t) + joue(t, p, "HEAD", a.hote(), "/o/d/releases/latest") + joue(t, p, "HEAD", a.hote(), "/api/bonjour") + + hote := a.couper() + w := joue(t, p, "HEAD", hote, "/o/d/releases/latest") + if w.Code != http.StatusFound { + t.Fatalf("HEAD hors ligne : code %d, attendu 302", w.Code) + } + if got := w.Header().Get("Location"); got != "/o/d/releases/tag/v1.2.3" { + t.Errorf("Location %q : une cible relative doit ressortir telle quelle", got) + } + w = joue(t, p, "HEAD", hote, "/api/bonjour") + if w.Code != http.StatusOK { + t.Fatalf("HEAD 200 hors ligne : code %d, attendu 200", w.Code) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStaleStatus { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeStaleStatus) + } + // La longueur d'un HEAD est celle du corps que rendrait le GET : elle + // ressort telle que l'amont l'a annoncée, comme en ligne. + if got := w.Header().Get("Content-Length"); got != "1234" { + t.Errorf("HEAD rejoué avec la longueur %q, attendu « 1234 »", got) + } + // Le HEAD n'a rien donné à garder pour un GET. + if w := joue(t, p, "GET", hote, "/api/bonjour"); w.Code != http.StatusGatewayTimeout { + t.Errorf("GET hors ligne : code %d, attendu 504", w.Code) + } +} + +// Un client qui détient sa copie garde son 304, même quand un statut seul est +// en réserve : le lui remplacer par un refus lui ferait jeter une copie +// valide. +func TestUneConditionnelleGardeSaCopieMalgreUnStatutSeul(t *testing.T) { + for nom, h := range map[string]http.HandlerFunc{ + "refus": refuser(http.StatusNotFound), + "redirection": rediriger(http.StatusFound, "https://ailleurs.example/x"), + } { + t.Run(nom, func(t *testing.T) { + a := nouvelAmontScripte(t, h) + p := proxyDeTest(t) + joue(t, p, "GET", a.hote(), "/depot/etat") + hote := a.couper() + + w := joue(t, p, "GET", hote, "/depot/etat", "If-None-Match", `"v1"`) + if w.Code != http.StatusNotModified { + t.Fatalf("code %d, attendu 304", w.Code) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeKeep { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeKeep) + } + // Sans condition, le statut ressort. + if w := joue(t, p, "GET", hote, "/depot/etat"); w.Code == http.StatusGatewayTimeout { + t.Error("sans condition, le statut gardé ne ressort pas") + } + }) + } +} + +// Le chemin de l'immuable ne sert qu'un corps 200 : un méta de statut sous +// une clé immuable sortirait sinon en « 200 » vide, même l'amont joignable. +func TestLeCheminDeLImmuableRefuseUnStatut(t *testing.T) { + a := nouvelAmontScripte(t, servir("vrai corps")) + p, _ := proxyEtCasier(t) + chemin := "/outils/outil-1.0.tar.gz" + u, _ := url.Parse("http://" + a.hote() + chemin) + cw, err := p.Store.NewWriter(CleDe("GET", u), Meta{ + URL: u.String(), Method: "GET", Status: http.StatusFound, + Header: http.Header{"Location": []string{"https://ailleurs.example/"}}, + Class: ClassImmutable.String(), + }) + if err != nil { + t.Fatal(err) + } + if err := cw.Commit(0); err != nil { + t.Fatal(err) + } + + w := joue(t, p, "GET", a.hote(), chemin) + if w.Code != http.StatusOK || w.Body.String() != "vrai corps" { + t.Fatalf("code %d, corps %q : le statut a été servi comme un corps", + w.Code, w.Body.String()) + } + if a.appels() != 1 { + t.Errorf("l'amont a reçu %d requêtes, attendu 1", a.appels()) + } +} + +// clientIntercepte rend un client HTTP dont toute connexion aboutit au cache, +// quelle que soit l'adresse demandée : c'est ce que fait le détournement du +// 80 pour une VM. +func clientIntercepte(t *testing.T, p *Proxy) *http.Client { + t.Helper() + front := httptest.NewServer(p.handler("http")) + t.Cleanup(front.Close) + cache := front.Listener.Addr().String() + return &http.Client{ + Transport: &http.Transport{ + DialContext: func( + ctx context.Context, reseau, _ string, + ) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, reseau, cache) + }, + DisableKeepAlives: true, + }, + Timeout: 10 * time.Second, + } +} + +// Sur le fil, un refus rejoué est une réponse BIEN FORMÉE : une longueur +// annoncée sans corps laisse le client attendre des octets qui ne viennent +// pas, et il échoue sur une fin de flux inattendue. +func TestLeRejeuDUnRefusEstBienFormeSurLeFil(t *testing.T) { + a := nouvelAmontScripte(t, refuser(http.StatusNotFound)) + p := proxyDeTest(t) + client := clientIntercepte(t, p) + adresse := a.srv.URL + "/sonde/2.root.json" + + rep, err := client.Get(adresse) + if err != nil { + t.Fatalf("en ligne : %v", err) + } + io.Copy(io.Discard, rep.Body) + rep.Body.Close() + + a.couper() + rep, err = client.Get(adresse) + if err != nil { + t.Fatalf("hors ligne : %v", err) + } + defer rep.Body.Close() + corps, err := io.ReadAll(rep.Body) + if err != nil { + t.Fatalf("lecture du corps : %v", err) + } + if rep.StatusCode != http.StatusNotFound || len(corps) != 0 { + t.Errorf("reçu %d et %d octets, attendu 404 sans corps", + rep.StatusCode, len(corps)) + } +} + +// Hors ligne, un vrai client suit la redirection rejouée jusqu'au corps +// gardé de sa cible : c'est la chaîne entière d'un installateur publié +// derrière une redirection qui redevient servable. +func TestHorsLigneLeClientSuitLaRedirectionJusquAuCorps(t *testing.T) { + cible := nouvelAmontScripte(t, servir("echo bonjour")) + pub := nouvelAmontScripte(t, rediriger(http.StatusFound, cible.srv.URL+"/bootstrap.sh")) + p := proxyDeTest(t) + client := clientIntercepte(t, p) + + lire := func(quand string) string { + t.Helper() + rep, err := client.Get(pub.srv.URL + "/installer.sh") + if err != nil { + t.Fatalf("%s : %v", quand, err) + } + defer rep.Body.Close() + corps, _ := io.ReadAll(rep.Body) + if rep.StatusCode != http.StatusOK { + t.Fatalf("%s : code %d", quand, rep.StatusCode) + } + return string(corps) + } + if got := lire("en ligne"); got != "echo bonjour" { + t.Fatalf("en ligne : corps %q", got) + } + cible.couper() + pub.couper() + if got := lire("hors ligne"); got != "echo bonjour" { + t.Errorf("hors ligne : corps %q, attendu celui de la cible", got) + } +} + +// Une redirection suivie par le cache repart SANS la condition du client : +// sinon la cible répond « 304 » et il n'y a rien à garder. Et les +// identifiants confiés à l'hôte demandé ne suivent pas vers un autre hôte. +func TestLesRedirectionsSuiviesPartentSansConditionNiIdentifiants(t *testing.T) { + contenu := strings.Repeat("charge utile", 32) + var mu sync.Mutex + var conditions, identites []string + stockage := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + conditions = append(conditions, r.Header.Get("If-None-Match")) + identites = append(identites, r.Header.Get("Authorization")) + mu.Unlock() + if r.Header.Get("If-None-Match") != "" { + w.WriteHeader(http.StatusNotModified) + return + } + io.WriteString(w, contenu) + }) + pub := nouvelAmontScripte(t, + rediriger(http.StatusFound, stockage.srv.URL+"/objet?signature=a")) + p, dir := proxyEtCasier(t) + + w := joue(t, p, "GET", pub.hote(), "/paquets/outil_1.0_amd64.deb", + "If-None-Match", `"v0"`, "Authorization", "Bearer jeton-de-test") + if w.Code != http.StatusOK || w.Body.String() != contenu { + t.Fatalf("code %d, %d octets : le corps n'est pas venu", w.Code, w.Body.Len()) + } + mu.Lock() + defer mu.Unlock() + if len(conditions) != 1 || conditions[0] != "" { + t.Errorf("la cible a reçu la condition : %q", conditions) + } + if len(identites) != 1 || identites[0] != "" { + t.Errorf("l'identifiant a suivi vers un autre hôte : %q", identites) + } + if corpsGardes(t, dir) != 1 { + t.Error("rien n'est gardé derrière la redirection") + } +} + +// Sur le MÊME hôte, l'identifiant suit : c'est à lui qu'il a été confié. +func TestUneRedirectionSurLeMemeHoteGardeLIdentifiant(t *testing.T) { + var recu atomic.Value + a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/a/outil_1.0_amd64.deb" { + rediriger(http.StatusFound, "/b/outil_1.0_amd64.deb")(w, r) + return + } + recu.Store(r.Header.Get("Authorization")) + io.WriteString(w, "paquet") + }) + p := proxyDeTest(t) + w := joue(t, p, "GET", a.hote(), "/a/outil_1.0_amd64.deb", + "Authorization", "Bearer jeton-de-test") + if w.Code != http.StatusOK { + t.Fatalf("code %d", w.Code) + } + if got, _ := recu.Load().(string); got != "Bearer jeton-de-test" { + t.Errorf("identifiant reçu %q sur le même hôte", got) + } +} diff --git a/script/qemu_cache/store.go b/script/qemu_cache/store.go new file mode 100644 index 0000000..d5222c0 --- /dev/null +++ b/script/qemu_cache/store.go @@ -0,0 +1,430 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// Store garde les réponses sur disque, un objet valant deux fichiers : les +// métadonnées en JSON et le corps brut. +// +// Le corps ne passe jamais en mémoire : une image qcow2 pèse des gigaoctets, +// et un cache qui la charge pour la servir tue l'orchestrateur. Écriture par +// fichier temporaire puis renommage, si bien qu'un objet visible est toujours +// un objet complet — une interruption ne laisse qu'un temporaire, que le +// démarrage suivant balaie. +type Store struct { + Dir string +} + +// Meta accompagne chaque corps stocké. +type Meta struct { + URL string `json:"url"` + Method string `json:"method"` + Status int `json:"status"` + Header http.Header `json:"header"` + Size int64 `json:"size"` + StoredAt time.Time `json:"stored_at"` + Class string `json:"class"` + // StatusOnly marque un objet gardé SANS corps, qui ne rejoue que son + // statut. Le statut seul ne suffit pas à le dire : le 200 d'un HEAD est + // un statut seul, et se lirait sinon comme un corps vide. + // + // Le marqueur décrit l'objet, il ne le protège pas : ce qui tient un + // lecteur plus ancien à l'écart d'un statut seul, c'est la clé sous + // laquelle il est rangé (voir CleStatut), pas un champ qu'un tel lecteur + // ignore. + StatusOnly bool `json:"status_only,omitempty"` +} + +// StatutReel rend le statut gardé, 200 quand le méta n'en porte pas : un méta +// écrit avant que le magasin garde autre chose que des corps n'a pas de +// statut, et il décrivait toujours un 200. +func (m *Meta) StatutReel() int { + if m.Status == 0 { + return http.StatusOK + } + return m.Status +} + +// StatutSeul dit si l'objet ne porte qu'un statut : marqué comme tel, ou de +// statut autre que 200 — seul un 200 a jamais été gardé avec son corps. +func (m *Meta) StatutSeul() bool { + return m.StatusOnly || m.StatutReel() != http.StatusOK +} + +// Stats répond au besoin de surveillance manuelle : aucune éviction n'est +// écrite, donc l'outil doit au moins dire ce qu'il occupe. +type Stats struct { + Objects int + Bytes int64 + Oldest time.Time +} + +var errMiss = errors.New("cache: absent") + +// Key mêle la méthode et l'URL : un HEAD et un GET sur la même adresse ne +// portent pas le même corps. +func Key(method, rawURL string) string { + sum := sha256.Sum256([]byte(method + " " + rawURL)) + return hex.EncodeToString(sum[:]) +} + +// KeySansHote range un objet sous son CHEMIN, l'hôte écarté. +// +// Une liste de miroirs tourne : pacman tire un fichier de « fastly », le +// suivant de « geo », et une clé qui porte l'hôte réduit alors le cache à +// néant — même fichier, autre nom, défaut de cache. Le chemin, lui, identifie +// le fichier sur TOUS les miroirs d'une même distribution : +// « /core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst » nomme le même octet +// partout, sa version et son architecture étant dans son nom. +// +// Réservé aux fichiers dont le NOM porte l'identité — paquets, index de +// dépôt. L'appliquer à tout ferait entrer en collision les « /index.html » de +// deux sites sans rapport. +func KeySansHote(method string, u *url.URL) string { + chemin := u.Path + if u.RawQuery != "" { + chemin += "?" + u.RawQuery + } + sum := sha256.Sum256([]byte(method + " path " + chemin)) + return hex.EncodeToString(sum[:]) +} + +// CleDe rend la clé sous laquelle une réponse est rangée et cherchée. +// +// La clé écarte l'hôte quand le NOM du fichier l'identifie partout : une +// liste de miroirs tourne, et une clé qui porte l'hôte ferait manquer le +// cache au fichier déjà gardé sous un autre nom de miroir. Le service et la +// lecture « --detient » passent tous deux par ici : deux calculs de la clé +// finiraient par diverger, et le relevé dirait absent ce que le service sert. +func CleDe(method string, u *url.URL) string { + if PortableParChemin(u) { + return KeySansHote(method, u) + } + return Key(method, u.String()) +} + +// CleStatut rend la clé sous laquelle un STATUT SEUL est rangé et cherché. +// +// Un espace de clés à part, et non la clé du corps : un lecteur qui ne +// connaît que les corps ne lit que Key et KeySansHote, et ne tombe donc +// jamais sur un statut seul. Rangé sous la clé du corps, un tel objet — un +// méta 302 et un corps de zéro octet de la bonne taille — ressortirait chez +// lui en « 200 » vide, que « curl … | bash » exécuterait comme un script +// vide qui réussit. +// +// Aucune clé de corps ne peut la rejoindre : une méthode HTTP ne porte pas +// d'espace, si bien que « STATUT GET … » n'est la méthode d'aucune requête. +// L'URL entière, hôte compris : un statut seul ne se garde jamais sous une +// clé portable. +func CleStatut(method string, u *url.URL) string { + return Key("STATUT "+method, u.String()) +} + +// TientStatut dit si la clé porte un statut seul que le rejeu servirait : un +// méta lisible, marqué statut seul, dont le corps a la taille annoncée — les +// conditions mêmes auxquelles Get le rend. +func (s *Store) TientStatut(key string) bool { + m, err := s.LireMeta(key) + if err != nil || !m.StatutSeul() { + return false + } + _, bodyPath := s.paths(key) + fi, err := os.Stat(bodyPath) + return err == nil && fi.Size() == m.Size +} + +func (s *Store) paths(key string) (metaPath, bodyPath string) { + // Deux niveaux de répertoires : un seul répertoire de cent mille entrées + // ralentit chaque ouverture sur la plupart des systèmes de fichiers. + dir := filepath.Join(s.Dir, key[0:2], key[2:4]) + return filepath.Join(dir, key+".meta"), filepath.Join(dir, key+".body") +} + +// LireMeta rend les métadonnées d'une clé sans toucher au corps. +// +// Get, lui, remet la date du corps à maintenant : un relevé qui passerait par +// lui rajeunirait tout ce qu'il regarde, et l'âge du dernier usage ne +// voudrait plus rien dire. Un méta illisible vaut absent. +func (s *Store) LireMeta(key string) (*Meta, error) { + metaPath, _ := s.paths(key) + raw, err := os.ReadFile(metaPath) + if err != nil { + return nil, errMiss + } + var m Meta + if err := json.Unmarshal(raw, &m); err != nil { + return nil, errMiss + } + return &m, nil +} + +// Get rend les métadonnées et un lecteur positionné sur le corps. Le lecteur +// est à refermer par l'appelant. +func (s *Store) Get(key string) (*Meta, *os.File, error) { + metaPath, bodyPath := s.paths(key) + raw, err := os.ReadFile(metaPath) + if err != nil { + return nil, nil, errMiss + } + var m Meta + if err := json.Unmarshal(raw, &m); err != nil { + // Métadonnées illisibles : l'objet vaut absent plutôt que faux. + return nil, nil, errMiss + } + f, err := os.Open(bodyPath) + if err != nil { + return nil, nil, errMiss + } + // La date du corps est remise à MAINTENANT parce qu'on va le servir. + // + // C'est ce qui permet à un nettoyage par âge de vouloir dire « ce qui ne + // sert plus » et non « ce qui est entré il y a longtemps ». Un paquet + // servi tous les jours depuis un an n'est pas vieux : l'effacer + // obligerait à le retélécharger le lendemain, ce qui est exactement le + // contraire de ce qu'un cache est là pour faire. + // + // L'échec est ignoré : un magasin en lecture seule doit servir, pas + // refuser parce qu'il n'a pas pu noter une date. + maintenant := time.Now() + _ = os.Chtimes(bodyPath, maintenant, maintenant) + st, err := f.Stat() + if err != nil || st.Size() != m.Size { + // Un corps dont la taille ne correspond plus est une écriture + // interrompue par un moyen qui a contourné le renommage. + f.Close() + return nil, nil, errMiss + } + return &m, f, nil +} + +// Writer accumule un corps dans un temporaire et ne le publie qu'à la +// fermeture réussie. +type Writer struct { + store *Store + key string + meta Meta + tmp *os.File + written int64 + finished bool +} + +// Detient dit si une clé porte le corps d'une réponse 200, sans l'ouvrir. +// +// Sert à décider si une requête conditionnelle peut partir telle quelle : +// sans corps en réserve, un « 304 » de l'amont ne laisserait rien à garder, +// et le cache resterait vide pour cette ressource aussi longtemps que ses +// clients en détiennent une copie — c'est-à-dire toujours. +// +// Un objet de statut seul ne compte pas : il vit sous sa propre clé +// (CleStatut), et un méta de statut qui se trouverait sous une clé de corps +// n'en fait pas un corps. La condition est alors retirée, pour que le premier +// 200 que l'amont rendra vienne entier. C'est aussi le test qui interdit de +// garder un refus quand un corps est en réserve : un statut seul ne s'écrit +// que là où Detient est faux pour la clé du corps. +func (s *Store) Detient(key string) bool { + m, err := s.LireMeta(key) + if err != nil || m.StatutSeul() { + return false + } + _, bodyPath := s.paths(key) + fi, err := os.Stat(bodyPath) + return err == nil && fi.Size() > 0 +} + +// NewWriter ouvre un temporaire dans le répertoire de destination : un +// renommage n'est atomique qu'au sein d'un même système de fichiers. +func (s *Store) NewWriter(key string, m Meta) (*Writer, error) { + metaPath, _ := s.paths(key) + if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil { + return nil, err + } + tmp, err := os.CreateTemp(filepath.Dir(metaPath), key+".part-*") + if err != nil { + return nil, err + } + return &Writer{store: s, key: key, meta: m, tmp: tmp}, nil +} + +func (w *Writer) Write(p []byte) (int, error) { + n, err := w.tmp.Write(p) + w.written += int64(n) + return n, err +} + +// Commit publie l'objet. La taille annoncée par l'amont, quand il l'annonce, +// est vérifiée : une réponse tronquée par une coupure réseau ressemble à une +// réponse complète pour tout le reste du code. +func (w *Writer) Commit(expected int64) error { + defer w.cleanup() + if expected >= 0 && w.written != expected { + return fmt.Errorf("corps tronqué : %d octets sur %d", w.written, expected) + } + if err := w.tmp.Sync(); err != nil { + return err + } + if err := w.tmp.Close(); err != nil { + return err + } + w.meta.Size = w.written + w.meta.StoredAt = time.Now().UTC() + raw, err := json.Marshal(w.meta) + if err != nil { + return err + } + metaPath, bodyPath := w.store.paths(w.key) + if err := os.Rename(w.tmp.Name(), bodyPath); err != nil { + return err + } + // Les métadonnées en DERNIER : leur présence est ce qui rend l'objet + // visible, et un corps sans métadonnées est simplement ignoré. + if err := os.WriteFile(metaPath+".part", raw, 0o644); err != nil { + return err + } + w.finished = true + return os.Rename(metaPath+".part", metaPath) +} + +// Abort jette le temporaire. Appelé quand le client se déconnecte ou que +// l'amont coupe : rien de partiel n'entre au cache. +func (w *Writer) Abort() { + w.cleanup() +} + +func (w *Writer) cleanup() { + if w.finished { + return + } + name := w.tmp.Name() + w.tmp.Close() + os.Remove(name) +} + +// Stat parcourt le cache. Coûteux sur un grand cache, donc appelé à la +// demande et non à chaque requête. +// horsCasier écarte, à la racine du magasin, tout répertoire qui n'est pas un +// casier à lui. +// +// Les objets sont rangés sous deux niveaux de deux caractères hexadécimaux. +// Ce qui vit à côté — les dépôts git tenus en miroir, par exemple — n'a rien à +// faire dans un parcours du magasin : le traverser coûterait un appel système +// par fichier de chaque dépôt, à chaque relevé et à chaque démarrage. +func horsCasier(racine, chemin string, info os.FileInfo) bool { + if !info.IsDir() || filepath.Dir(chemin) != filepath.Clean(racine) { + return false + } + nom := filepath.Base(chemin) + if len(nom) != 2 { + return true + } + for _, c := range nom { + if !strings.ContainsRune("0123456789abcdefABCDEF", c) { + return true + } + } + return false +} + +func (s *Store) Stat() (Stats, error) { + var st Stats + err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if horsCasier(s.Dir, p, info) { + return filepath.SkipDir + } + if info.IsDir() { + return nil + } + if filepath.Ext(p) != ".body" { + return nil + } + st.Objects++ + st.Bytes += info.Size() + if st.Oldest.IsZero() || info.ModTime().Before(st.Oldest) { + st.Oldest = info.ModTime() + } + return nil + }) + if os.IsNotExist(err) { + return st, nil + } + return st, err +} + +// SweepPartials retire ce qu'une interruption a laissé. Lancé au démarrage, +// jamais pendant le service : un « .part » y appartient à une écriture vivante. +func (s *Store) SweepPartials() int { + n := 0 + filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if horsCasier(s.Dir, p, info) { + return filepath.SkipDir + } + if info.IsDir() { + return nil + } + name := filepath.Base(p) + if filepath.Ext(p) == ".part" || containsPart(name) { + if os.Remove(p) == nil { + n++ + } + } + return nil + }) + return n +} + +func containsPart(name string) bool { + for i := 0; i+5 <= len(name); i++ { + if name[i:i+5] == ".part" { + return true + } + } + return false +} + +// HumanBytes rend une taille lisible par un opérateur, la surveillance du +// disque étant manuelle. +func HumanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d o", n) + } + units := []string{"Kio", "Mio", "Gio", "Tio"} + v := float64(n) + for _, u := range units { + v /= unit + if v < unit { + return fmt.Sprintf("%.1f %s", v, u) + } + } + return fmt.Sprintf("%.1f Pio", v/unit) +} + +// copyTee écrit dans le cache ET vers le client en une seule lecture de +// l'amont : lire deux fois doublerait le trafic que l'outil existe pour +// supprimer. +func copyTee(dst io.Writer, cache io.Writer, src io.Reader) (int64, error) { + if cache == nil { + return io.Copy(dst, src) + } + return io.Copy(io.MultiWriter(dst, cache), src) +} diff --git a/script/qemu_cache/store_test.go b/script/qemu_cache/store_test.go new file mode 100644 index 0000000..b156178 --- /dev/null +++ b/script/qemu_cache/store_test.go @@ -0,0 +1,232 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func ecrire(t *testing.T, s *Store, key, corps string, taille int64) error { + t.Helper() + w, err := s.NewWriter(key, Meta{ + URL: "https://exemple.example/x", Method: "GET", Status: 200, + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + Class: ClassImmutable.String(), + }) + if err != nil { + t.Fatalf("ouverture : %v", err) + } + io.WriteString(w, corps) + return w.Commit(taille) +} + +func TestStorePuisGet(t *testing.T) { + s := &Store{Dir: t.TempDir()} + key := Key("GET", "https://exemple.example/x") + if err := ecrire(t, s, key, "bonjour", 7); err != nil { + t.Fatalf("commit : %v", err) + } + m, f, err := s.Get(key) + if err != nil { + t.Fatalf("get : %v", err) + } + defer f.Close() + if m.Size != 7 { + t.Errorf("taille %d, attendu 7", m.Size) + } + raw, _ := io.ReadAll(f) + if string(raw) != "bonjour" { + t.Errorf("corps %q, attendu %q", raw, "bonjour") + } + if m.StoredAt.IsZero() { + t.Error("la date de stockage manque") + } +} + +// Une réponse tronquée par une coupure réseau ressemble à une réponse +// complète pour tout le reste du code : la taille annoncée est donc vérifiée. +func TestCorpsTronqueRefuse(t *testing.T) { + s := &Store{Dir: t.TempDir()} + key := Key("GET", "https://exemple.example/tronque") + err := ecrire(t, s, key, "court", 4096) + if err == nil { + t.Fatal("un corps tronqué a été accepté") + } + if !strings.Contains(err.Error(), "tronqué") { + t.Errorf("erreur %q, attendu qu'elle dise « tronqué »", err) + } + if _, _, err := s.Get(key); err == nil { + t.Error("l'objet refusé est pourtant lisible") + } +} + +// Une taille inconnue à l'amont — pas de Content-Length — vaut -1 et ne +// déclenche aucune vérification. +func TestTailleInconnueAcceptee(t *testing.T) { + s := &Store{Dir: t.TempDir()} + key := Key("GET", "https://exemple.example/inconnue") + if err := ecrire(t, s, key, "quoi que ce soit", -1); err != nil { + t.Fatalf("commit : %v", err) + } + if _, f, err := s.Get(key); err != nil { + t.Errorf("get : %v", err) + } else { + f.Close() + } +} + +// Un objet dont le corps ne correspond plus aux métadonnées vaut absent +// plutôt que faux. +func TestCorpsAlteréVautAbsent(t *testing.T) { + s := &Store{Dir: t.TempDir()} + key := Key("GET", "https://exemple.example/altere") + if err := ecrire(t, s, key, "bonjour", 7); err != nil { + t.Fatalf("commit : %v", err) + } + _, body := s.paths(key) + if err := os.WriteFile(body, []byte("plus court"), 0o644); err != nil { + t.Fatalf("altération : %v", err) + } + if _, _, err := s.Get(key); err == nil { + t.Error("un corps de taille inattendue a été servi") + } +} + +func TestAbortNeLaisseRien(t *testing.T) { + dir := t.TempDir() + s := &Store{Dir: dir} + key := Key("GET", "https://exemple.example/abandon") + w, err := s.NewWriter(key, Meta{URL: "x", Method: "GET", Status: 200}) + if err != nil { + t.Fatalf("ouverture : %v", err) + } + io.WriteString(w, "à moitié") + w.Abort() + + if _, _, err := s.Get(key); err == nil { + t.Error("un objet abandonné est lisible") + } + var restes []string + filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + restes = append(restes, p) + } + return nil + }) + if len(restes) != 0 { + t.Errorf("l'abandon laisse %v", restes) + } +} + +func TestSweepPartials(t *testing.T) { + dir := t.TempDir() + s := &Store{Dir: dir} + sub := filepath.Join(dir, "ab", "cd") + os.MkdirAll(sub, 0o755) + os.WriteFile(filepath.Join(sub, "clef.part-123"), []byte("x"), 0o644) + os.WriteFile(filepath.Join(sub, "clef.body"), []byte("x"), 0o644) + + if n := s.SweepPartials(); n != 1 { + t.Errorf("%d fichier(s) balayé(s), attendu 1", n) + } + if _, err := os.Stat(filepath.Join(sub, "clef.body")); err != nil { + t.Error("le balayage a emporté un corps complet") + } +} + +func TestStat(t *testing.T) { + s := &Store{Dir: t.TempDir()} + for _, u := range []string{"a", "b", "c"} { + if err := ecrire(t, s, Key("GET", u), "12345", 5); err != nil { + t.Fatalf("commit %s : %v", u, err) + } + } + st, err := s.Stat() + if err != nil { + t.Fatalf("stat : %v", err) + } + if st.Objects != 3 { + t.Errorf("%d objets, attendu 3", st.Objects) + } + if st.Bytes != 15 { + t.Errorf("%d octets, attendu 15", st.Bytes) + } + if st.Oldest.IsZero() { + t.Error("aucune date pour le plus ancien objet") + } +} + +// Un répertoire de cache qui n'existe pas encore rend un état vide et non une +// erreur : « --status » doit répondre avant le premier démarrage. +func TestStatSansRepertoire(t *testing.T) { + s := &Store{Dir: filepath.Join(t.TempDir(), "jamais-cree")} + st, err := s.Stat() + if err != nil { + t.Fatalf("stat : %v", err) + } + if st.Objects != 0 || st.Bytes != 0 { + t.Errorf("état %+v, attendu vide", st) + } +} + +func TestKeyDistingueMethode(t *testing.T) { + if Key("GET", "https://x/y") == Key("HEAD", "https://x/y") { + t.Error("GET et HEAD partagent une clé : un HEAD servirait un corps") + } +} + +// Detient veut dire « tient un corps 200 ». Un statut seul n'en est pas un, +// quel que soit ce que son fichier de corps contient ; un méta sans statut, +// écrit avant que le magasin garde autre chose que des corps, en est un. +func TestDetientNeCompteQueLesCorps200(t *testing.T) { + s := &Store{Dir: t.TempDir()} + cas := []struct { + statut int + seul bool + attendu bool + }{ + {http.StatusFound, false, false}, + {http.StatusNotFound, false, false}, + // Le 200 d'un HEAD : un statut seul malgré son code. + {http.StatusOK, true, false}, + {http.StatusOK, false, true}, + {0, false, true}, + } + for i, c := range cas { + key := Key("GET", fmt.Sprintf("https://exemple.example/%d", i)) + w, err := s.NewWriter(key, Meta{ + URL: "x", Method: "GET", Status: c.statut, StatusOnly: c.seul, + }) + if err != nil { + t.Fatal(err) + } + io.WriteString(w, "corps") + if err := w.Commit(5); err != nil { + t.Fatal(err) + } + if got := s.Detient(key); got != c.attendu { + t.Errorf("statut %d : Detient %v, attendu %v", c.statut, got, c.attendu) + } + } +} + +func TestHumanBytes(t *testing.T) { + cas := map[int64]string{ + 512: "512 o", + 2048: "2.0 Kio", + 5 * 1024 * 1024: "5.0 Mio", + 3 * 1024 * 1024 * 1024: "3.0 Gio", + } + for n, attendu := range cas { + if got := HumanBytes(n); got != attendu { + t.Errorf("%d octets rendus %q, attendu %q", n, got, attendu) + } + } +} diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index 3e3e665..2bebec1 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -363,6 +363,169 @@ def disk_note(plan_gb, free_gb, total_gb=0) -> str: return f"~{plan_gb} G / {free_gb} G {t('free of')} {total_gb} G" +def _depots_declares(version=""): + """Les dépôts git que les manifestes déclarent, ou [] si on ne sait pas. + + La lecture vit dans le menu du cache, qui la porte déjà ; l'import est + DIFFÉRÉ pour que le socle des formulaires ne traîne pas ce menu entier + quand personne ne coupe le réseau. Toute défaillance rend une liste vide : + le verdict se tait plutôt que d'annoncer des miroirs manquants sur une + lecture qui a échoué. + + La lecture est BORNÉE à une version d'Odoo : un déploiement n'en installe + qu'une, et compter les dépôts des autres versions — les dépréciées + comprises — nomme comme manquants des dépôts que personne ne clonera. + Vide, celle que le dépôt porte dans « .odoo-version », qui est ce qu'un + déploiement pose par défaut. + """ + import os + + try: + from script.todo.qemu_cache_menu import depots_des_manifestes + except Exception: + return [] + racine = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if not version: + try: + with open( + os.path.join(racine, ".odoo-version"), encoding="utf-8" + ) as fh: + version = fh.read().strip() + except OSError: + version = "" + try: + return depots_des_manifestes(racine, version) + except Exception: + return [] + + +def motifs_hors_ligne(vms): + """Ce qu'une coupure fera échouer, en phrases, ou [] si rien ne manque. + + Les deux formulaires la partagent : une VM privée de réseau échoue de la + même façon quel que soit l'hyperviseur qui la porte, et deux copies de ce + texte divergeraient au premier ajustement. + + Trois verdicts se complètent, et un seul avertissement les porte — deux + confirmations d'affilée apprendraient à les enchaîner sans les lire. + L'appelant ajoute sa propre invite à confirmer. + """ + from script.qemu import cache_offline + + absentes = cache_offline.suites_absentes(vms) + # Le verdict par suite se tait dès qu'UNE url est en réserve : il ne voit + # pas « restricted » ou « -security » absents, qui font pourtant échouer + # l'installation sur « Unable to locate package », vingt minutes plus tard. + composants = cache_offline.composants_absents(vms) + # Ce que les derniers déploiements hors ligne des MÊMES VM ont manqué, + # moins ce que le cache détient depuis. + manques = cache_offline.manques_hors_ligne(vms) + # Un paquet posé hors du fil observé : son absence ne remonte nulle part, + # et l'index de sa suite peut être en réserve sans lui. + paquets = cache_offline.paquets_absents(vms) + # Les dépôts git déclarés qui n'ont pas de miroir. Une négociation git ne + # se garde pas : sans miroir, le clone échoue une fois le réseau coupé. + depots = cache_offline.miroirs_absents(_depots_declares()) + if not (absentes or composants or manques or paquets or depots): + return [] + + motifs = [] + if absentes: + quoi = ", ".join(f"{d} {v}" for d, v in absentes) + motifs.append( + t("cache holds nothing for") + + f" {quoi} — " + + t("an offline VM will fail") + ) + for distro, version, manque in composants: + # Trois au plus : la liste entière tiendrait douze entrées et + # personne ne lirait la douzième. + exemples = ", ".join(manque[:3]) + if len(manque) > 3: + exemples += f" (+{len(manque) - 3})" + motifs.append( + t("cache holds no index for") + + f" {distro} {version} : {exemples} — " + + t("those packages will not be found") + ) + if paquets: + motifs.append( + t("the cache has no package named") + + " " + + ", ".join(paquets) + + " — " + + t("an offline VM will not install it") + ) + if depots: + # Trois au plus, comme pour les composants : une liste de cinquante + # dépôts ne se lit pas, et le geste est le même pour tous. + exemples = ", ".join(depots[:3]) + if len(depots) > 3: + exemples += f" (+{len(depots) - 3})" + motifs.append( + t("these git repositories are not mirrored:") + + f" {exemples} — " + + t("an offline VM cannot clone them") + ) + motifs.append(t("fill them from Cache › Git mirrors")) + for b in manques: + heures = int(b["age"] // 3600) + if heures < 1: + age = f"{int(b['age'] // 60)} min" + elif heures < 72: + age = f"{heures} h" + else: + age = f"{heures // 24} " + t("days") + # « au moins » : l'installation s'arrête au premier manque fatal, ce + # qui suivait n'a pas été demandé. + motif = ( + t("the last offline run of") + + f" {b['nom']} (" + + t("age:") + + f" {age}) " + + t("lacked at least") + + f" {len(b['manquants'])} " + + t("addresses") + ) + # Nommés à part : ils ne se comblent pas par l'entrée « Combler ». + # Une négociation git se remplit par le miroir, dépôt par dépôt ; le + # reste est nommé par sa méthode, un GET que le cache ne garde pas + # n'étant pas un POST. + extras = [] + if b.get("git"): + extras.append( + f"+{len(b['git'])} " + + t( + "git repositories not mirrored: fill" + " them from entry 5 of the cache menu" + ) + ) + if b["jamais"]: + methodes = sorted({m.upper() for m, _u in b["jamais"]}) + extras.append( + f"+{len(b['jamais'])} " + + t("requests the cache never keeps:") + + " " + + ", ".join(methodes) + ) + if extras: + motif += " (" + "; ".join(extras) + ")" + if b["selon_journal"]: + motif += ", " + t( + "according to the log: a purge can make it wrong" + ) + motifs.append(motif) + if manques: + exemples = [url for b in manques for _m, url in b["manquants"]][:3] + motifs.append(t("e.g.") + " " + ", ".join(exemples)) + motifs.append( + t("fill them from Cache › Fill what offline runs lacked") + ) + return motifs + + def plan_totals(rows): """Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de neuf) : (nb, vcpus, ram_mo, disque_go).""" @@ -401,6 +564,12 @@ def build_spec(vms, domains, form): "ai_agent": form.get("ai_agent", ""), "git_name": form.get("git_name", ""), "git_email": form.get("git_email", ""), + # Au niveau du déploiement : soustraire une VM au cache se décide + # avant sa création, l'exception portant sur son adresse MAC. + "cache_bypass": form.get("cache_bypass", False), + # Au niveau du déploiement : la coupure vise le service du cache et + # dure le temps de la spec entière, installation comprise. + "offline": form.get("offline", False), "add_ssh_config": form["add_ssh_config"], "parallelism": form["parallelism"], } diff --git a/script/todo/dev_tools.py b/script/todo/dev_tools.py index 7402899..13b0b68 100644 --- a/script/todo/dev_tools.py +++ b/script/todo/dev_tools.py @@ -34,14 +34,58 @@ STARSHIP_UPSTREAM = f"curl -fsSL {STARSHIP_URL} | sh" # Sans terminal — une pose par SSH dans une VM — l'installateur demande une # confirmation que personne ne donnera. « -y » la donne d'avance. -STARSHIP_UPSTREAM_YES = f"curl -fsSL {STARSHIP_URL} | sh -s -- -y" +# +# Et il tourne en root. Sans droit d'écriture sur /usr/local/bin, son +# répertoire par défaut, l'installateur appelle « sudo -v » AVANT de +# télécharger quoi que ce soit. Or « sudo -v » exige un mot de passe dès +# qu'une seule règle sudoers qui vise le compte n'est pas NOPASSWD — celle +# du groupe d'administration ne l'est pas —, et sudo-rs ignore « verifypw », +# le réglage qui l'en dispenserait. En root le répertoire est inscriptible : +# aucun « sudo -v » n'est atteint, et le binaire atterrit dans un répertoire +# que porte le PATH de tout compte, SSH non interactif compris. +# +# La borne de temps passe DERRIÈRE sudo. Un « timeout » lancé sans privilège +# ne peut pas tuer un installateur root : son signal au groupe de processus +# échoue en EPERM sur les processus root, et sudo ne relaie pas un signal +# venu de son propre groupe. Un « timeout » root, lui, tue l'installateur et +# ses enfants. Il reste en deçà de la borne extérieure de la pose, qui ne +# couvre plus que la moitié non privilégiée du tube : curl. +# +# « -k » : SIGTERM se laisse ignorer, et « timeout » seul attendrait alors +# sans fin. Passé ce délai, il envoie SIGKILL, qui ne s'ignore pas. Borne et +# délai additionnés restent sous la borne extérieure. +STARSHIP_ROOT_TIMEOUT = 280 +STARSHIP_ROOT_KILL_AFTER = 10 +STARSHIP_UPSTREAM_VM = ( + f"curl -fsSL {STARSHIP_URL}" + f" | sudo timeout -k {STARSHIP_ROOT_KILL_AFTER}" + f" {STARSHIP_ROOT_TIMEOUT} sh -s -- -y" +) # Ce que chaque shell écrit pour lancer starship. La ligne va en FIN de # fichier : starship compose le prompt et doit passer après tout ce qui y # touche. +# +# bash et zsh la gardent par « command -v » : sans binaire — une pose qui a +# échoué, un binaire retiré depuis —, « eval "$(starship init …)" » écrirait +# « command not found » à l'ouverture de chaque shell. La garde est DANS la +# ligne, et non autour de son écriture : elle vaut aussi pour le binaire qui +# disparaît après coup. « starship init » y reste en clair, parce que c'est +# le motif qui dédoublonne, dans le menu de l'hôte comme dans la VM. +# +# Un « if » et non un « && » : la ligne est la DERNIÈRE du fichier, et le +# fichier rend son statut. « a && b » rend 1 quand starship manque, si bien +# qu'un script sous « set -e » qui lit ce fichier s'arrêterait là ; un « if » +# sans branche prise rend 0. STARSHIP_LINE = { - "bash": 'eval "$(starship init bash)"', - "zsh": 'eval "$(starship init zsh)"', + "bash": ( + "if command -v starship >/dev/null 2>&1; then" + ' eval "$(starship init bash)"; fi' + ), + "zsh": ( + "if command -v starship >/dev/null 2>&1; then" + ' eval "$(starship init zsh)"; fi' + ), "fish": "starship init fish | source", } diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 018584b..04bcf91 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -30,12 +30,38 @@ class LongTestMenuMixin: chemin = os.path.join(os.getcwd(), LONGTEST_DIR, nom) return chemin if os.path.exists(chemin) else "" - def _longtest_run(self, nom, args=""): + # Ce qui ne crée aucune machine : un plan, un rapport, une liste. Ces + # commandes-là ne méritent pas de question — une invite qu'on apprend à + # confirmer sans lire ne protège plus rien le jour où elle compte. + _LONGTEST_SANS_EFFET = ("--dry-run", "--rapport") + + @staticmethod + def _longtest_question(args): + """L'avertissement et la question qui vont avec ces arguments. + + Rend un couple de CLÉS de traduction, jamais du texte : l'invite est + bilingue comme le reste du menu. + """ + if "--detruire" in (args or ""): + return ( + "This destroys the machines of this test and their disks.", + "Destroy the machines of this test?", + ) + return ( + "This creates real VMs and takes a while.", + "Run this long test?", + ) + + def _longtest_run(self, nom, args="", demander=None): """Lance un test long, sortie en DIRECT. En direct parce qu'il dure des heures : capturer sa sortie pour l'afficher à la fin, c'est ne rien montrer pendant tout ce temps — et c'est justement la progression étage par étage qui intéresse. + + `demander` : None laisse la commande décider — on confirme dès qu'elle + peut créer de vraies machines. Un appelant qui a DÉJÀ posé sa question + passe False, sans quoi l'opérateur répondrait deux fois à la même. """ chemin = self._longtest_script(nom) if not chemin: @@ -45,6 +71,19 @@ class LongTestMenuMixin: if args: cmd += f" {args}" print(f"\n{t('Will execute:')} {cmd}") + if demander is None: + demander = not any( + d in (args or "") for d in self._LONGTEST_SANS_EFFET + ) + if demander: + # Une frappe ne doit suffire ni à créer de vraies machines, ni à + # en effacer. La question doit dire LAQUELLE des deux on fait : + # confirmer « lancer ce test long » devant une destruction fait + # répondre oui à autre chose que ce qui va arriver. + avertissement, question = self._longtest_question(args) + print(f" {t(avertissement)}") + if not click.confirm(t(question)): + return self.execute.exec_command_live(cmd, source_erplibre=False) def prompt_execute_longtest(self): @@ -62,8 +101,23 @@ class LongTestMenuMixin: ) }, {"prompt_description": t("Nested QEMU depth: run it")}, + {"prompt_description": t("Download cache: plan only (dry-run)")}, + {"prompt_description": t("Download cache: two VMs, measure")}, + { + "prompt_description": t( + "Download cache: measure, then cut the upstream" + ) + }, {"prompt_description": t("Undo what the descent created")}, ] + # Le cache n'est pas une descente : ni profondeur, ni hôte de départ. + # Ses entrées sont donc traitées à part plutôt que pliées dans la + # table des piles imbriquées. + cache = { + "5": "--dry-run", + "6": "", + "7": "--hors-ligne", + } # Chaque choix : le script, et s'il faut demander d'où l'on part. scripts = { "1": ("deep_proxmox.py", True), @@ -77,6 +131,9 @@ class LongTestMenuMixin: print() if status == "0": return False + if status in cache: + self._longtest_run("qemu_cache.py", cache[status]) + continue if status in scripts: script, demander = scripts[status] # La profondeur est DEMANDÉE : c'est le réglage qui décide de @@ -88,7 +145,7 @@ class LongTestMenuMixin: if status in ("1", "3"): args += " --dry-run" self._longtest_run(script, args) - elif status == "5": + elif status == "8": self._longtest_defaire() else: print(t("Command not found !")) @@ -104,10 +161,10 @@ class LongTestMenuMixin: on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne mène pas directement à un « qm destroy --purge ». """ - for script in ("deep_proxmox.py", "deep_qemu.py"): - self._longtest_run(script, "--detruire --dry-run") + for script in ("deep_proxmox.py", "deep_qemu.py", "qemu_cache.py"): + self._longtest_run(script, "--detruire --dry-run", demander=False) if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): - self._longtest_run(script, "--detruire") + self._longtest_run(script, "--detruire", demander=False) def _longtest_depart(self, script): """D'où part la descente : une VM neuve, ou un hôte qu'on a déjà. diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index 5b0e861..dcf9d62 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -34,6 +34,7 @@ from script.todo.deploy_form_lib import ( disk_note, entry_key, gib, + motifs_hors_ligne, plan_rows, plan_totals, res_row_widgets, @@ -111,6 +112,11 @@ def build_spec(vms, existants, form): "prod": bool((form.get("install") or {}).get("prod")), "monitor": form["monitor"], "parallelism": form["parallelism"], + # La coupure d'amont demandée pour TOUT le déploiement, installation + # comprise. Absente de la spec, le déploiement part en ligne. + "offline": bool(form.get("offline")), + # L'écran accéléré, posé à la création de chaque VM. + "gpu3d": bool(form.get("gpu3d")), } @@ -150,6 +156,19 @@ def run_proxmox_form(ctx, run_app: bool = True): desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) stockages = ctx.get("storages") or [] ponts = ctx.get("bridges") or [] + # La case « Sans connexion internet » ne s'offre que là où la coupure a + # un effet : l'hôte Proxmox est alors une VM de notre pont, et le menu + # l'a établi avant d'ouvrir cet écran. + cache_offert = bool(ctx.get("cache_offert")) + # La 3D ne s'offre que là où l'hôte distant peut la rendre : nœud de + # rendu et VIRGL. Le menu l'a sondé avant d'ouvrir cet écran. + gpu_offert = bool(ctx.get("gpu_offert")) + # Ce qui manque à l'hôte, nommé par la sonde : sans lui, la case + # disparaîtrait sans que rien ne dise pourquoi. + gpu_manque = str(ctx.get("gpu_manque") or "") + # Ce qui s'INSTALLE là-dedans : le nœud de rendu vient du matériel ou + # d'un GPU transmis, et « apt install noeud » enverrait dans le mur. + paquets_gpu = " ".join(p for p in gpu_manque.split() if p != "noeud") # {système: (libellé, commande)} — ce qu'un système impose d'installer. distro_profiles = ctx.get("distro_profiles") or {} # Les commandes qui ne posent PAS ERPLibre : sa marge disque ne les suit @@ -311,6 +330,40 @@ def run_proxmox_form(ctx, run_app: bool = True): placeholder="100", id="f_vmid", ) + # L'écran de la VM se choisit à la CRÉATION : « qm » pose + # « --vga virtio-gl » au lieu de la console série, et le + # changer ensuite demande d'éteindre la machine. La + # console série reste posée dans les deux cas. + if gpu_offert: + yield Checkbox( + t( + "3D acceleration (host GPU), even without a" + " screen" + ), + value=False, + id="f_gpu3d", + ) + elif gpu_manque: + # Une case qui disparaît sans un mot se lit comme une + # régression : on dit ce qui manque, et le paquet à + # poser sur l'HÔTE, pas dans la VM. + yield Static( + f" {t('No 3D: the host lacks')} {gpu_manque}", + id="t_gpu_manque", + ) + if paquets_gpu: + yield Static( + f" sudo apt install {paquets_gpu}", + id="t_gpu_geste", + ) + # Le bouton n'existe que si le menu a fourni de + # quoi poser : un bouton sans effet vaut moins + # qu'une commande à recopier. + if ctx.get("installer_gpu"): + yield Button( + t("Install on the host"), + id="f_gpu_poser", + ) yield Static(t("Access"), classes="grouptitle") yield Static(f" {t('SSH public key')}") yield Input( @@ -390,6 +443,57 @@ def run_proxmox_form(ctx, run_app: bool = True): disabled=True, id="f_par", ) + # La coupure est posée ICI, sur le pont local, et non sur + # l'hôte Proxmox : ses invités sortent derrière son + # adresse, donc couper ce pont les coupe aussi. Offerte + # seulement là où cela vaut — le contexte le dit. + if cache_offert: + yield Static( + t("Network"), + id="t_network", + classes="grouptitle", + ) + yield Checkbox( + t("No internet connection"), + value=False, + id="f_offline", + ) + yield Static( + f" {t('Cuts internet for the cache and the VMs: proves')}" + ) + yield Static( + f" {t('the install builds from what the cache holds.')}" + ) + # Découvert par la case : ce qui suit ne concerne que + # celui qui vient de la cocher. + yield Static( + f" ⚠ {t('The cut hits every user of the cache:')}", + id="t_offline_w1", + ) + yield Static( + f" {t('a deployment run from another terminal')}", + id="t_offline_w2", + ) + yield Static( + f" {t('goes offline too, without asking for it.')}", + id="t_offline_w3", + ) + yield Static( + f" {t('Nothing is cut before F5: the upstream falls')}", + id="t_offline_w4", + ) + yield Static( + f" {t('at launch and comes back when the last install')}", + id="t_offline_w5", + ) + yield Static( + f" {t('ends (12 h at most), even with the monitor closed.')}", + id="t_offline_w6", + ) + yield Static( + f" {t('The monitor stays ticked: it is what arms that return.')}", + id="t_offline_w7", + ) with Vertical(id="right"): yield VerticalScroll(id="plan") yield Static("", id="totals") @@ -450,9 +554,40 @@ def run_proxmox_form(ctx, run_app: bool = True): self.notify(f"✓ {nom}") self._refresh_after() + # L'avertissement que la case « Sans connexion internet » découvre. + _OFFLINE_WIDGETS = tuple(f"#t_offline_w{n}" for n in range(1, 8)) + + def _sync_offline(self) -> None: + """Montre l'avertissement quand la coupure est demandée, et y + force le suivi. + + Il dit ce qu'on ne devine pas : la coupure vaut pour TOUS les + usagers du cache, elle ne tombe qu'au lancement, et elle ne se + lève qu'à la fin de la dernière installation. + + Cette dernière promesse n'est tenue que par le déploiement suivi, + le seul qui confie la levée à une unité systemd. Le suivi est donc + coché et grisé tant que la case l'est ; la décocher le rend + modifiable, avec la valeur qu'il avait avant. + """ + case = self.query("#f_offline") + vu = bool(case) and bool(case.first(Checkbox).value) + for sel in self._OFFLINE_WIDGETS: + for widget in self.query(sel): + widget.display = vu + suivi = self.query_one("#f_monitor", Checkbox) + if vu and not suivi.disabled: + self._suivi_avant = suivi.value + suivi.value = True + suivi.disabled = True + elif not vu and suivi.disabled: + suivi.disabled = False + suivi.value = getattr(self, "_suivi_avant", True) + def on_mount(self) -> None: self._reload_catalog() self._sync_install_deps() + self._sync_offline() # ---------------------------------------------------------------- # # Le plan @@ -715,6 +850,8 @@ def run_proxmox_form(ctx, run_app: bool = True): self._refresh_after() elif event.checkbox.id == "f_par_all": self.query_one("#f_par", Select).disabled = event.value + elif event.checkbox.id == "f_offline": + self._sync_offline() elif str(event.checkbox.id or "").startswith("f_tool_"): # Un IDE de plus, c'est un disque plus grand : le plan doit # le montrer AVANT de déployer, pas après une heure. @@ -807,9 +944,50 @@ def run_proxmox_form(ctx, run_app: bool = True): self._set_override(index, champ, event.value) self._refresh_after() + def _poser_gpu(self) -> None: + """Pose ce qui manque sur l'hôte, terminal rendu, puis RELIT. + + « suspend() » rend le clavier à sudo, qui peut demander un mot de + passe, et laisse apt s'afficher : la moitié de la confiance tient + à voir le travail se faire. + + L'hôte est SONDÉ de nouveau au retour. Croire apt sur parole + offrirait une case que Proxmox refuserait ensuite — et il ne la + refuse qu'après avoir écrit le disque de la VM. + """ + poser = ctx.get("installer_gpu") + if not poser: + return + with self.suspend(): + poser(paquets_gpu) + sonder = ctx.get("sonder_gpu") + possible, reste = sonder() if sonder else (False, gpu_manque) + if not possible: + self.notify( + f"{t('Still missing on the host:')} {reste or gpu_manque}", + severity="warning", + ) + return + # Monter la case AVANT de retirer les lignes : le bloc n'est + # jamais vide, et l'œil suit ce qui remplace quoi. + self.query_one("#fields").mount( + Checkbox( + t("3D acceleration (host GPU), even without a screen"), + value=True, + id="f_gpu3d", + ), + after=self.query_one("#t_gpu_manque"), + ) + for sel in ("#t_gpu_manque", "#t_gpu_geste", "#f_gpu_poser"): + for widget in self.query(sel): + widget.remove() + self.notify(t("3D is now available: the box is here.")) + def on_button_pressed(self, event) -> None: ident = event.button.id or "" - if ident == "go": + if ident == "f_gpu_poser": + self._poser_gpu() + elif ident == "go": self.action_deploy() elif ident == "no": self.action_cancel() @@ -852,6 +1030,18 @@ def run_proxmox_form(ctx, run_app: bool = True): def _form_values(self): cle = self.query_one("#f_key", Input).value.strip() + # La case n'existe que là où le cache tourne : la chercher sans + # la trouver vaut « en ligne ». + offline = bool( + self.query("#f_offline") + and self.query_one("#f_offline", Checkbox).value + ) + # Même règle que pour la coupure : la case n'existe que là où + # elle a un effet, et son absence vaut « non ». + gpu3d = bool( + self.query("#f_gpu3d") + and self.query_one("#f_gpu3d", Checkbox).value + ) return { "host": ctx["host"], "storage": self._storage(), @@ -865,7 +1055,13 @@ def run_proxmox_form(ctx, run_app: bool = True): **self.extras_values(), # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans # ERPLibre se suit aussi (cloud-init, puis relevé système). - "monitor": self.query_one("#f_monitor", Checkbox).value, + "offline": offline, + "gpu3d": gpu3d, + # Hors ligne, le suivi est d'office : seule sa voie confie la + # levée de la coupure au guet, qui la tient jusqu'à la fin de + # la dernière installation. + "monitor": offline + or self.query_one("#f_monitor", Checkbox).value, # Une exécution par installation : le nombre de VM retenues # fait foi. Le déploiement le borne ensuite à ce même nombre, # donc une valeur haute ne crée aucun travailleur inutile. @@ -881,6 +1077,23 @@ def run_proxmox_form(ctx, run_app: bool = True): if not spec["vms"]: self.notify(t("Nothing to deploy."), severity="warning") return + # Hors ligne : ce que le cache ne détient pas, aucune VM ne + # pourra le lire, et une VM Proxmox échoue aussi loin de son + # lancement qu'une VM libvirt — à la pose du bureau, une heure + # plus tard. F5 à nouveau vaut passage outre : le journal peut + # avoir tourné, ou le magasin avoir été rempli autrement. + if spec.get("offline") and not getattr( + self, "_offline_ack", False + ): + motifs = motifs_hors_ligne(spec["vms"]) + if motifs: + self._offline_ack = True + self.notify( + " — ".join(motifs + [t("press F5 again to confirm")]), + severity="error", + timeout=20, + ) + return if not spec["storage"]: self.notify( t("No storage able to hold a VM disk."), severity="error" diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index f128fc0..c675a9c 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -361,6 +361,55 @@ class ProxmoxMenuMixin: print(f" ⚠ {t('exit code')} {code}") return code, out + # Ce que « --vga virtio-gl » exige de l'hôte : le chemin qui le prouve, + # et le nom qui sert à le poser. Les noms sont ceux des paquets, car + # c'est ce qu'un opérateur tape ; « noeud » n'en est pas un, le nœud de + # rendu venant du matériel ou d'un GPU transmis. + _PVE_GPU_PIECES = ( + ("/dev/dri/renderD*", "noeud"), + ("/usr/lib/*/libvirglrenderer.so.*", "libvirglrenderer1"), + ("/usr/lib/*/libGL.so.1", "libgl1"), + ("/usr/lib/*/libEGL.so.1", "libegl1"), + ) + + def _pve_gpu_dispo(self): + """Ce qui MANQUE à l'hôte Proxmox pour donner de la 3D à ses invités. + + Rend (possible, manque) : « manque » nomme les pièces absentes et + vaut "" quand tout est là. Une case qui disparaît sans un mot ne se + devine pas — le formulaire s'en sert pour DIRE pourquoi la 3D n'est + pas offerte, et quoi installer. + + Une sonde qui n'aboutit pas rend (False, "") : on ne promet rien, et + on n'accuse rien non plus. Le jeton « FIN » distingue une sonde qui a + tout trouvé — donc muette — d'une sonde qui n'a pas tourné. + + Quatre pièces, et il les faut TOUTES. Un NŒUD DE RENDU + (« /dev/dri/renderD* ») : un hôte sans GPU, ou lui-même virtualisé + sans GPU transmis, n'en expose aucun. Puis les trois bibliothèques + que Proxmox charge pour « --vga virtio-gl » : VIRGL, GL et EGL. Il + les réclame nommément — « missing libraries for 'virtio-gl' + detected! Please install 'libgl1' and 'libegl1' » — et refuse de + démarrer la machine, une fois son disque écrit et sa configuration + posée. La case promettrait alors une accélération que rien ne + fournit, et le déploiement échouerait à la dernière étape. + + Un hôte peut en porter une sans l'autre : VIRGL et GL viennent avec + d'autres paquets, EGL non. + """ + sonde = "; ".join( + f"ls {chemin} >/dev/null 2>&1 || echo {jeton}" + for chemin, jeton in self._PVE_GPU_PIECES + ) + code, sortie = self._pve_show(f"{sonde}; echo FIN", quiet=True) + dites = [l.strip() for l in (sortie or "").splitlines() if l.strip()] + if code or "FIN" not in dites: + return False, "" + manque = [ + jeton for _c, jeton in self._PVE_GPU_PIECES if jeton in dites + ] + return (not manque), " ".join(manque) + def _pve_vms(self): """[{vmid, name, status, …}] des VM de l'hôte, ou [].""" from script.proxmox import proxmox_deploy as pve @@ -1065,9 +1114,31 @@ class ProxmoxMenuMixin: print(t("Cancelled.")) return if spec: - return self._pve_deploy_spec(host, spec, mod, dry_run) + return self._pve_run_spec(host, spec, mod, dry_run) return self._pve_deploy_prompts(dry_run) + def _pve_run_spec(self, host, spec, mod, dry_run=False): + """Enveloppe le déploiement de la coupure d'amont qu'il demande. + + La coupure est la MÊME qu'en QEMU/KVM, et elle est posée ICI, pas sur + l'hôte distant : un hôte Proxmox qui porte l'autorité du cache est + une VM de ce pont, et ses invités sortent derrière son adresse. Les + règles qui coupent ce pont les couvrent donc tous. + + Le formulaire n'offre la case que dans ce cas ; une spec qui la porte + quand même — invites textuelles, spec écrite à la main — coupe ce + pont-ci, ce qui reste vrai pour les VM qui le traversent. + """ + from script.todo.qemu_deploy import _SansInternetImpossible + + try: + with self._qemu_sans_internet(bool(spec.get("offline"))) as coupee: + return self._pve_deploy_spec( + host, spec, mod, dry_run, coupee=coupee + ) + except _SansInternetImpossible: + return + def _pve_form_context(self, mod, host): """Tout ce que l'écran doit savoir, LU AVANT de l'ouvrir. @@ -1147,6 +1218,38 @@ class ProxmoxMenuMixin: """Les commandes qui seraient lancées pour CETTE VM.""" return self._pve_vm_commands(mod, vm, spec) + # UNE sonde, deux réponses : ce que l'hôte peut faire, et ce qui lui + # manque pour le faire. Sondé deux fois, l'écran pourrait offrir la + # case et nommer en même temps ce qui l'empêche. + gpu_possible, gpu_manque = self._pve_gpu_dispo() + + def poser_gpu(paquets): + """Pose les paquets manquants SUR L'HÔTE. Rend True si apt a fini. + + Interactive à dessein : le terminal est rendu par l'écran avant + l'appel, « ssh -t » ouvre un vrai terminal distant, et sudo peut + donc demander son mot de passe. Rien n'est capturé — l'opérateur + voit apt travailler, ce qui est la moitié de la confiance. + + Le nœud de rendu n'est pas un paquet et ne s'installe pas : il + est écarté, et une liste qui n'en contient pas d'autre ne lance + rien plutôt que d'appeler « apt install » les mains vides. + """ + noms = [ + p + for p in str(paquets or "").split() + if p != "noeud" and re.fullmatch(r"[A-Za-z0-9.+_-]+", p) + ] + if not noms: + return False + remote = pve.wrap_privilege( + "apt-get update && apt-get install -y " + " ".join(noms), + host.get("sudo") or "", + ) + argv = pve.ssh_argv(host, remote, tty=True) + print("\n" + " ".join(shlex.quote(a) for a in argv) + "\n") + return subprocess.call(argv) == 0 + return { "host": dict(host, label=self._pve_label(host)), "node": self._pve_node_name(), @@ -1206,6 +1309,24 @@ class ProxmoxMenuMixin: "host_cpu": cpu, "free_ram": ram_libre, "extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB, + # La case « Sans connexion internet » ne s'offre que là où la + # coupure a un effet. Un hôte Proxmox qui reçoit l'autorité du + # cache est une VM de CE pont : ses invités sortent derrière son + # adresse, donc la coupure de ce pont les couvre. Un hôte qui ne + # vit pas ici ne traverse rien qu'on sache couper, et la case y + # promettrait un hors-ligne que personne ne tient. + "cache_offert": bool(self._pve_cache_ca(host)), + # Lu ICI, terminal encore à nous : la sonde passe par ssh, et une + # invite de mot de passe pendant que l'écran affiche le casserait. + "gpu_offert": gpu_possible, + # Ce qui manque, nommé : une case qui disparaît sans un mot se + # lit comme une régression, et l'opérateur n'a rien à corriger. + "gpu_manque": gpu_manque, + # De quoi poser ce qui manque sans quitter l'écran, et de quoi + # RELIRE l'hôte ensuite : sans la seconde, l'écran croirait sur + # parole qu'apt a réussi. + "installer_gpu": poser_gpu, + "sonder_gpu": self._pve_gpu_dispo, } def _pve_capacity(self): @@ -1291,6 +1412,10 @@ class ProxmoxMenuMixin: # Le DNS de l'hôte : « --ipconfig0 » ne le porte pas, et une VM # en adresse fixe se retrouvait sans résolveur. "nameservers": spec.get("nameservers") or (), + # L'accélération 3D se décide à la CRÉATION : l'écran d'une VM + # Proxmox est un choix de « qm create », et le changer ensuite + # demande de l'éteindre. + "gpu3d": bool(spec.get("gpu3d")), } if spec.get("sshkey_path"): detail["sshkey_path"] = spec["sshkey_path"] @@ -1298,9 +1423,14 @@ class ProxmoxMenuMixin: vm["vmid"], detail ) - def _pve_deploy_spec(self, host, spec, mod, dry_run=False): + def _pve_deploy_spec(self, host, spec, mod, dry_run=False, coupee=False): """Exécute la spec rendue par l'écran. + `coupee` : l'amont du cache est coupé autour de cet appel. La levée + est alors confiée au guet, au lancement des installations, comme sur + la voie QEMU/KVM — sans quoi elle tomberait avec ce processus, avant + la fin de ce qui télécharge. + Les images D'ABORD, une par une : deux téléchargements simultanés du même fichier se marcheraient dessus. Les VM ensuite, en parallèle si on l'a demandé — chacune est une suite « qm » indépendante. @@ -1308,6 +1438,11 @@ class ProxmoxMenuMixin: from script.proxmox import proxmox_deploy as pve from script.todo.deploy_form_lib import run_deploy_progress + # L'instant où CE déploiement commence : le manifeste le porte, et le + # bilan hors ligne s'en sert pour ne relire que ce qui s'est passé + # depuis. Pris avant la première commande, création comprise. + debut = time.time() + # Le stockage et le pont AVANT tout : l'écran les vérifie déjà, mais # cette méthode s'appelle aussi d'ailleurs. Sans ce garde-fou, on # téléchargeait 350 Mio d'image pour finir sur « net0: invalid format @@ -1413,7 +1548,9 @@ class ProxmoxMenuMixin: print(f" {ligne}") if not reussies: return - joignables = self._pve_after_create(host, spec, reussies, cle_locale) + joignables = self._pve_after_create( + host, spec, reussies, cle_locale, coupee=coupee, debut=debut + ) self._pve_print_summary(spec, joignables or [], session) @staticmethod @@ -1534,6 +1671,173 @@ class ProxmoxMenuMixin: print(f" ✓ {t('Timezone')} : {fuseau}") return True + def _pve_cache_ca(self, host): + """L'autorité du cache à poser dans les VM de cet hôte, ou ''. + + Le cache détourne tout ce qui sort de SON pont. Un hôte Proxmox qui + est lui-même une VM d'ici y est branché, et les machines qu'il porte + sortent derrière son adresse : elles sont donc interceptées, sans que + rien à l'intérieur ne l'annonce. Un hôte Proxmox qui ne vit pas ici ne + traverse pas ce pont, et son invité n'a que faire de cette autorité. + + Le déséquilibre décide du doute : une autorité approuvée en trop ne + signe jamais rien, tandis qu'un détournement sans autorité fait + échouer chaque téléchargement HTTPS sur « self-signed certificate in + certificate chain ». En cas d'hésitation, on la pose. + """ + nom = (host.get("target") or "").split("@")[-1] + if not nom or nom not in set(self._qemu_list_domains()): + return "" + return self._qemu_cache_ca_path() + + def _pve_set_cache_ca(self, cible, vm, ca): + """Pose l'autorité du cache DANS la VM, par ssh. + + Même source que la voie libvirt — `cache_files` et `cache_commands` de + deploy_qemu — livrée autrement : « qm set » ne sait écrire aucun + fichier, comme pour le guide et pour le fuseau. + + AVANT l'installation : c'est elle qui télécharge. Un magasin de + confiance relu ensuite ne rattrape rien de ce qui a déjà échoué. + """ + import types + + try: + mod = self._qemu_import_module() + except Exception: # pragma: no cover - dépend du module + return False + args = types.SimpleNamespace( + distro=vm.get("distro") or "", cache_ca=ca, cache_bypass=False + ) + fichiers = mod.cache_files(args) + if not fichiers: + # Distribution hors table, ou autorité illisible : la VM + # télécharge en direct, ce qui marche tant qu'aucune règle ne la + # vise. Poser le fichier au mauvais endroit ne marcherait pas et + # ne dirait rien. + return False + morceaux = [] + for chemin, mode, contenu, _proprio in fichiers: + q = shlex.quote(chemin) + morceaux.append( + f"printf '%s' {shlex.quote(contenu)} | sudo tee {q} " + f">/dev/null && sudo chmod {mode} {q}" + ) + morceaux += [ + f"sudo sh -c {shlex.quote(c)}" for c in mod.cache_commands(args) + ] + code, _o = self._pve_ssh(cible, " && ".join(morceaux), timeout=120) + if code: + print( + f" ⚠ {t('download cache authority not installed')} ({code})" + ) + return False + print(f" ✓ {t('download cache authority installed')}") + return True + + def _pve_attendre_ssh(self, cible, delai=300, pas=10): + """Attend que la VM réponde en ssh. Rend False si elle ne répond pas. + + Une VM tout juste créée a une adresse bien avant d'avoir un sshd : + cloud-init pose les comptes et les clés, et cela prend des minutes + sur une machine émulée. Les étapes qui suivent passent toutes par + ssh, et les lancer trop tôt les fait échouer ENSEMBLE, chacune avec + son propre message — la panne ressemble alors à quatre pannes. + + Bornée par le TEMPS : un essai coûte le délai de connexion de ssh, + que rien ici ne borne à l'avance. + """ + fin = time.time() + delai + premier = True + while time.time() < fin: + code, _o = self._pve_ssh(cible, "true", timeout=20) + if code == 0: + return True + if premier: + print(f" … {t('waiting for the VM to answer ssh')}") + premier = False + time.sleep(pas) + return False + + def _pve_set_apt_mirror(self, cible, vm, mod=None): + """Fixe le miroir apt de la VM sur celui que le cache a rempli. + + Le magasin range ses index sous l'HÔTE demandé : une VM qui réclame + « archive.ubuntu.com » ne retrouve rien de ce qu'une autre a gardé + depuis un miroir, et hors ligne chacun de ces index manque — la suite + échoue alors sur des dépendances introuvables, ce qui accuse le dépôt + et non le miroir. La voie libvirt écrit le miroir dans le + cloud-config ; « qm set » ne sait écrire aucun fichier, d'où ce + passage par ssh. + + Ubuntu seulement : Debian, Fedora et Arch ont leurs propres dépôts, et + y réécrire une URI ubuntu ne viserait rien. Les deux formats sont + couverts — le « .sources » deb822 des images récentes et le + « sources.list » des anciennes — et « security » suit le même miroir, + que les miroirs répliquent sous le même chemin. + """ + if (vm.get("distro") or "") != "ubuntu": + return False + ports = vm.get("arch") in (getattr(mod, "PORTS_ARCHES", ()) or ()) + miroirs = ( + getattr(mod, "APT_MIRRORS_PORTS", ()) + if ports + else getattr(mod, "APT_MIRRORS_MAIN", ()) + ) or () + if not miroirs: + return False + miroir = miroirs[0] + # Les arches « ports » ne sont pas sur archive.ubuntu.com, et amd64 + # n'est pas sur ports.ubuntu.com : le motif suit l'architecture. + motif = ( + r"https?://ports\.ubuntu\.com/ubuntu-ports" + if ports + else r"https?://(archive|security)\.ubuntu\.com/ubuntu" + ) + # « # » comme séparateur de sed : une URL en est dépourvue, alors + # qu'elle porte des « / » en quantité. + geste = ( + f"sudo sed -i -E 's#{motif}#{miroir}#g'" + " /etc/apt/sources.list /etc/apt/sources.list.d/*.sources" + " /etc/apt/sources.list.d/*.list 2>/dev/null; true" + ) + code, _o = self._pve_ssh(cible, geste, timeout=60) + if code: + print(f" ⚠ {t('apt mirror not pinned')} ({code})") + return False + print(f" ✓ {t('apt mirror pinned')} : {miroir}") + return True + + def _pve_set_gpu_groups(self, cible, utilisateur, mod=None): + """Met le compte de la VM dans les groupes du GPU, par ssh. + + Le nœud de rendu appartient à « root:render » en 0660 : un compte qui + n'y est pas retombe en rendu logiciel alors même que la négociation + VIRGL entre l'hôte et l'invité a réussi, et rien ne le signale — le + matériel virtuel est bien accéléré, seul l'accès manque. + + La voie libvirt pose ces groupes par le cloud-config ; « qm set » ne + sait écrire aucun fichier, d'où ce passage par ssh. Les groupes sont + CRÉÉS au besoin : « render » manque des images les plus anciennes, et + « usermod -aG » sur un groupe inconnu échoue. + + Les appartenances ne valent qu'à la PROCHAINE session : celle qui + tourne garde les siennes, ce que l'installation qui suit ne subit pas, + chacune de ses commandes ouvrant sa propre session. + """ + groupes = list(getattr(mod, "GPU_GROUPS", ()) or ("render", "video")) + gestes = [f"sudo groupadd -f {g}" for g in groupes] + gestes.append( + f"sudo usermod -aG {','.join(groupes)}" + f" {shlex.quote(utilisateur)}" + ) + code, _o = self._pve_ssh(cible, " && ".join(gestes), timeout=60) + if code: + print(f" ⚠ {t('GPU groups not set')} ({code})") + return False + print(f" ✓ {t('GPU groups set')} : {', '.join(groupes)}") + return True + def _pve_write_guide(self, cible, vm, spec, mod): """Pose le guide de connexion et l'identité git DANS la VM. @@ -1708,9 +2012,15 @@ class ProxmoxMenuMixin: input(f"\n{t('Deploy this VM now? (Y/n): ')}") ) - def _pve_after_create(self, host, spec, reussies, cle_locale): + def _pve_after_create( + self, host, spec, reussies, cle_locale, coupee=False, debut=None + ): """Ce qui suit la création : l'adresse, ~/.ssh/config, l'installation. + `coupee` et `debut` suivent jusqu'à l'installateur : le premier lui + fait confier la levée de la coupure au guet, le second date le + déploiement dans le manifeste, où le bilan hors ligne le lit. + L'alias et non l'IP dans les étapes suivantes : ssh y lit le rebond par l'hôte Proxmox, et le suivi d'installation en a besoin pour entrer dans une VM qui n'est pas sur notre réseau.""" @@ -1736,6 +2046,7 @@ class ProxmoxMenuMixin: # qu'on a RÉELLEMENT écrit, pas par le nom. alias = {} joignables = [] + ca_cache = self._pve_cache_ca(host) for vm in spec["vms"]: if vm["name"] not in reussies: continue @@ -1752,8 +2063,8 @@ class ProxmoxMenuMixin: # Un nom qui existe DÉJÀ comme domaine local est un piège : l'alias # ~/.ssh/config serait volé à la VM locale, et le suivi # d'installation — qui ré-résout par virsh — irait installer - # ERPLibre sur ELLE. Vécu : « erplibre-ubuntu-2604 » déployée sur - # Proxmox, installation partie sur la VM locale du même nom. + # ERPLibre sur ELLE : une VM Proxmox homonyme d'un domaine + # local lui prend son alias, et l'installation part sur elle. noms_alias, vole = self._pve_alias_names( vm["name"], alias_chaine(vm["name"]), @@ -1789,12 +2100,38 @@ class ProxmoxMenuMixin: print(f" ✓ ~/.ssh/config : ssh {noms_alias[0]}") vm["adresse"] = ip vm["alias"] = alias.get(vm["name"], vm["name"]) + # Une adresse n'est pas une machine prête : cloud-init tourne + # encore, et sshd n'écoute pas toujours. Les quatre étapes qui + # suivent passent TOUTES par ssh — sans cette attente, elles + # échouaient ensemble sur une VM qui n'avait pas fini de naître, + # et la machine partait sans guide, en UTC, sans autorité et sur + # le miroir de son image. + if vm["alias"] and not self._pve_attendre_ssh(vm["alias"]): + print(f" ⚠ {t('No ssh answer: guest left as created.')}") + vm["alias"] = "" # Le guide AVANT l'installation : il doit être là même si rien ne # s'installe, et l'installation ne le touche pas. if vm["alias"] and mod_qemu: self._pve_write_guide(vm["alias"], vm, spec, mod_qemu) if vm["alias"]: self._pve_set_timezone(vm["alias"], spec) + # Après le fuseau et avant l'installation : c'est + # l'installation qui télécharge. + if ca_cache: + # Le miroir AVANT l'autorité et l'installation : le cache + # range ses index sous l'hôte demandé, et une VM qui en + # réclame un autre ne retrouve rien de ce qui est gardé. + self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu) + self._pve_set_cache_ca(vm["alias"], vm, ca_cache) + # Après la création, qui a posé l'écran accéléré : l'accès au + # nœud de rendu est une affaire de COMPTE, et il se donne + # dans l'invité. + if spec.get("gpu3d"): + self._pve_set_gpu_groups( + vm["alias"], + spec.get("user") or "erplibre", + mod_qemu, + ) joignables.append(vm) install = spec.get("install") # Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été @@ -1870,6 +2207,11 @@ class ProxmoxMenuMixin: app_store=spec.get("app_store") or "deb", vm_tools=spec.get("vm_tools") or (), pve=cartes_pve, + guet_hors_ligne=coupee, + deploy_started=debut, + # La coupure TENUE, et non la case de la spec : c'est elle + # qui fait qu'une réussite prouve le hors ligne. + hors_ligne=bool(coupee), # Ce que sont ces VM, pris de la SPEC. Le suivi le demandait # à virsh, qui ne connaît que les domaines d'ici. meta={ diff --git a/script/todo/qemu_cache_menu.py b/script/todo/qemu_cache_menu.py new file mode 100644 index 0000000..daf4a23 --- /dev/null +++ b/script/todo/qemu_cache_menu.py @@ -0,0 +1,2356 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le cache des VM QEMU : poser, constater, conduire, comprendre, mesurer. + +Cinq gestes qui ne se ressemblent pas. L'installation touche au système et +demande sudo ; le diagnostic ne fait que LIRE ; conduire le service allume et +éteint ; le guide n'exécute rien ; les tests créent de vraies machines. Les +mêler dans une seule entrée obligeait à lancer une installation pour savoir +si le cache tournait. + +Le service a son propre sous-menu parce que l'ARRÊTER est le seul moyen de +désactiver le cache : l'unité retire ses règles en partant. Retirer l'autorité +d'une VM ne la soustrait pas au détournement, cela lui fait seulement refuser +un certificat qu'elle ne reconnaît plus. + +Le diagnostic existe pour une panne précise, et elle est silencieuse : le +réseau libvirt « default » ne sert pas toujours 192.168.122.0/24 — il est +déplacé sur un /24 libre dès que ce préfixe entre en collision, ce qui est le +cas de tout orchestrateur qui est lui-même une VM. Des règles posées sur +l'autre préfixe existent bel et bien dans le noyau, l'installation réussit, +et aucune VM ne traverse le cache. Rien ne le dit. Comparer les deux +préfixes est donc le premier contrôle, pas le dernier. +""" + +import json +import os +import re +import shlex +import subprocess +import time +from urllib.parse import urljoin, urlsplit + +import click + +from script.qemu import cache_offline +from script.todo.todo_i18n import t + +# Ce que l'installateur pose. Ces chemins sont comparés à ceux du script par +# un test : le menu qui chercherait ailleurs annoncerait un cache absent. +CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache" +CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt" +CACHE_SERVICE = "erplibre-go-qemu-cache.service" +CACHE_CONF = "/etc/erplibre_go_qemu_cache/env" +CACHE_TABLE = "erplibre_qemu_cache" +CACHE_BYPASS = "/etc/erplibre_go_qemu_cache/bypass" +CACHE_MIROIR_GIT = "/var/cache/erplibre_go_qemu_cache/git" +CACHE_DIR = "/var/cache/erplibre_go_qemu_cache" +# L'installateur, tel qu'il se lance depuis un checkout ERPLibre. Le chemin +# reste RELATIF : il est imprimé pour une AUTRE machine, dont le répertoire +# de travail n'a aucune raison d'être celui d'ici. +INSTALLATEUR = "script/install/install_qemu_cache.sh" +# Le fichier que le mode en deux temps dépose dans le compte d'arrivée. Il +# pèse autant que le magasin — un cache ne contient que des paquets et des +# archives git, déjà comprimés — et la dernière commande le retire, le pic +# d'occupation valant sinon deux fois le magasin. +TRANSFERT_FICHIER = "erplibre_cache.tar.zst" +# La racine du dépôt, d'où se lance le lecteur du journal d'accès : le menu +# tourne depuis n'importe quel répertoire, et un chemin relatif n'y survit pas. +RACINE_DEPOT = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) +# L'unité que l'installateur écrit. Remplir un miroir lui reprend +# l'environnement qu'elle donne à git, plutôt que d'en tenir une copie. +CACHE_UNITE = f"/etc/systemd/system/{CACHE_SERVICE}" + +# Combler ce qui a manqué hors ligne : les jours de journal relus, le délai +# d'un rejeu, et les redirections suivies — autant que le cache en suit +# lui-même (maxRedirections) avant de renoncer. +JOURS_RECENTS = 7 +REJEU_DELAI = 60 +SAUTS_MAX = 5 +REDIRECTIONS = (301, 302, 303, 307, 308) +# Les points de négociation git : la liste vit dans cache_offline, que le +# pré-vol du formulaire lit aussi ; un test la compare au Go. +GIT_NEGOCIATION = cache_offline.GIT_NEGOCIATION +# Les marques que les lectures de la coupure et du guet écrivent quand leur +# commande réussit. Une marque à nous plutôt qu'un message de nft, de sudo +# ou de systemctl : les leurs se traduisent selon la langue du compte. +NFT_LISIBLE = "erplibre-nft-lisible" +GUET_ACTIF = "erplibre-guet-actif" +# La source Go qui déclare les hôtes passés d'office en tunnel. +MITM_GO = os.path.join("script", "qemu_cache", "mitm.go") + +# L'ordre d'affichage des issues du journal. Ce n'est PAS une liste de ce qui +# existe : tout ce que le journal porte est montré, ce qui n'est pas nommé ici +# venant à la fin. Une liste fermée avait déjà tu les deux issues les plus +# nombreuses, faute d'avoir été relue quand elles sont apparues. +ORDRE_ISSUES = ( + "hit", + "mirror", + "stored", + "stale", + "stored-status", + "stale-status", + "keep", + "offline-miss", + "fetched", + "passthrough", + "error", +) +CACHE_SET = "bypass" +LONGTEST = "long_test/qemu_cache.py" + + +class QemuCacheMenuMixin: + # ------------------------------------------------------------------ + # Lectures : aucune ne modifie quoi que ce soit + # ------------------------------------------------------------------ + + @staticmethod + def _cache_lire(cmd, delai=15): + """Sortie d'une commande, ou "". Ne lève jamais : un diagnostic qui + s'interrompt sur sa première mesure absente ne diagnostique rien.""" + try: + p = subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=delai, + ) + return (p.stdout or "") + (p.stderr or "") + except (OSError, subprocess.SubprocessError): + return "" + + @classmethod + def _cache_prefixe_regles(cls): + """Les trois premiers octets que les règles détournent, "" quand + aucune règle n'est posée, None quand on ne peut pas le savoir. + + Même lecture que `_cache_amont_coupe`, et pour la même raison : sur + un hôte où sudo exige un mot de passe, « sudo -n » échoue sans rien + rendre. Confondre ce silence avec une absence de règle fait annoncer + « aucun détournement » à un cache qui détourne pourtant, et envoie + réinstaller ce qui marche. La marque n'est écrite que si nft a + répondu ; sans elle, l'appelant tranche. + """ + vu = cls._cache_lire( + f"sudo -n nft list tables >/dev/null 2>&1 && echo {NFT_LISIBLE};" + f" sudo -n nft list table ip {CACHE_TABLE}" + ) + m = re.search(r"saddr (\d+\.\d+\.\d+)\.", vu) + if m: + return m.group(1) + return "" if NFT_LISIBLE in vu.split() else None + + @classmethod + def _cache_amont_coupe(cls): + """La coupure d'amont est-elle posée ? True, False, ou None quand on + ne peut pas le savoir. + + Lue par « sudo -n », qui échoue plutôt que de demander un mot de + passe. Sur un hôte où sudo en exige un, rien ne se lit : conclure + « pas coupé » ferait rejouer sous la coupure, et chaque rejeu + n'ajouterait qu'un manque. La marque NFT_LISIBLE n'est écrite que si + nft a répondu ; sans elle, la réponse est None, et l'appelant + tranche. None est faux en contexte booléen : un diagnostic ne crie + pas à la coupure sur une lecture impossible. + + La coupure survit LÉGITIMEMENT au déploiement qui la pose tant que le + guet tourne (`_cache_guet_actif`) : les installations sont + détachées, et le guet ne la lève qu'à la fin de la dernière, 12 h au + plus. Sans guet, elle ne survit qu'à ce qu'un « finally » ne rattrape + pas : un processus tué net, une panne de courant. Le cache rend alors + « 504 » à chaque VM, l'installation échoue sur « failed retrieving + file … 504 » depuis TOUS les miroirs, et rien dans ce message ne + parle d'une règle de pare-feu. + """ + vu = cls._cache_lire( + f"sudo -n nft list tables >/dev/null 2>&1 && echo {NFT_LISIBLE};" + f" sudo -n nft list table inet {cache_offline.TABLE}" + ) + if "meta skuid" in vu: + return True + return False if NFT_LISIBLE in vu.split() else None + + @classmethod + def _cache_guet_actif(cls): + """Le guet d'un déploiement hors ligne tourne-t-il ? + + Tant qu'il tourne, la coupure est tenue exprès, jusqu'à la fin de la + dernière installation détachée. Sans sudo : l'état d'une unité se lit + de tout compte. Passe par `_cache_lire`, qui ne lève jamais : une + lecture impossible rend False. + """ + vu = cls._cache_lire( + f"{cache_offline.guet_actif_cmd()} && echo {GUET_ACTIF}" + ) + return GUET_ACTIF in vu.split() + + @staticmethod + def _cache_dire_coupure_tenue(marque, *entre): + """Les lignes d'une coupure que le guet tient. + + Le geste donné ARRÊTE le guet, dont la levée retire la table. Retirer + la table seule laisserait le guet tourner pour rien, et le + déploiement hors ligne suivant serait refusé tant qu'il tourne. + `entre` : lignes ajoutées avant le coût de la levée. + """ + print( + f" {marque} {t('Upstream CUT by an offline deployment still installing,')}" + ) + print( + f" {t('held until its last installation ends (12 h at most).')}" + ) + for ligne in entre: + print(f" {ligne}") + print( + f" {t('Lifting it now makes those installations finish online.')}" + ) + print( + f" {t('Lift it now with:')} {cache_offline.lever_maintenant_cmd()}" + ) + + def _cache_diag_coupure(self): + """Les lignes du diagnostic sur la coupure d'amont et le guet. + + Muet quand tout va bien — une ligne « amont branché » à chaque + diagnostic n'apprendrait rien. Le guet d'abord : tant qu'il tourne, + la coupure est tenue exprès, et donner le retrait de la table ferait + finir en ligne les installations qui tournent encore. Une lecture de + nft impossible (None) ne dément pas le guet. Un guet sans coupure est + nommé : resté sans rien à lever, il ferait refuser le déploiement + hors ligne suivant. + """ + coupe = self._cache_amont_coupe() + guet = self._cache_guet_actif() + if guet and coupe is not False: + self._cache_dire_coupure_tenue("⚠") + elif guet: + print( + f" ⚠ {t('The lift watcher still runs, with no cut left to lift.')}" + ) + print( + f" {t('Stop it with:')} {cache_offline.lever_maintenant_cmd()}" + ) + elif coupe: + print( + f" ✗ {t('Upstream CUT: the cache can pull nothing from the internet')}" + ) + print(f" {t('Every VM then gets a 504 from every mirror.')}") + print( + f" {t('The VMs have no direct way out either: only the host answers them.')}" + ) + print(f" {t('Lift it with:')} {cache_offline.restore_cmd()}") + elif coupe is None: + print( + f" · {t('Cannot tell whether the upstream is cut: reading nft needs a sudo password here.')}" + ) + + def _cache_combler_permis(self): + """Le rejeu de l'entrée 9 peut-il partir ? Dit pourquoi sinon. + + Il faut le binaire et le service. Sous la coupure, un rejeu + n'ajouterait que des manques au journal. Le guet est lu d'abord, + sans sudo : tant qu'il tourne, la coupure est tenue exprès, et le + geste donné l'arrête. Une coupure illisible — sudo exige un mot de + passe — n'est pas prise pour une absence : le menu le dit et + demande, non par défaut. + """ + if not os.path.isfile(CACHE_BIN): + print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n") + return False + if not self._cache_actif(): + print(f" ✗ {t('Service:')} {t('Service is stopped')}") + print(f" {t('Start it from entry 3 of this menu.')}\n") + return False + if self._cache_guet_actif(): + self._cache_dire_coupure_tenue( + "✗", t("A replay now would only record more misses.") + ) + print() + return False + coupe = self._cache_amont_coupe() + if coupe: + print( + f" ✗ {t('Upstream CUT: the cache can pull nothing from the internet')}" + ) + print(f" {t('A replay now would only record more misses.')}") + print(f" {t('Lift it with:')} {cache_offline.restore_cmd()}\n") + return False + if coupe is None: + print( + f" ⚠ {t('Cannot tell whether the upstream is cut: reading nft needs a sudo password here.')}" + ) + print( + f" {t('Under the cut, a replay would only record more misses.')}" + ) + if not click.confirm(t("Replay anyway?"), default=False): + print() + return False + return True + + @classmethod + def _cache_prefixe_libvirt(cls): + """Les trois premiers octets que libvirt sert vraiment, ou "".""" + vu = cls._cache_lire("virsh -c qemu:///system net-dumpxml default") + m = re.search(r"address='(\d+\.\d+\.\d+)\.", vu) + return m.group(1) if m else "" + + @classmethod + def _cache_actif(cls): + """Le service tourne-t-il ? La première ligne doit valoir + « active » ENTIÈRE : « inactive » la contient, et un test + d'inclusion lirait un service arrêté comme en marche.""" + vu = cls._cache_lire(f"systemctl is-active {CACHE_SERVICE}") + return vu.split("\n")[0].strip() == "active" + + @staticmethod + def _cache_journal(): + """Chemin du journal d'accès, lu dans la configuration du service.""" + try: + with open(CACHE_CONF, encoding="utf-8") as fh: + for ligne in fh: + if ligne.startswith("EL_ACCESS_LOG="): + return ligne.split("=", 1)[1].strip() + except OSError: + pass + return "" + + @classmethod + def _cache_compte_issues(cls): + """Ce que le cache a fait, par issue. Le journal EST la mesure.""" + chemin = cls._cache_journal() + compte = {} + if not chemin or not os.path.exists(chemin): + return compte + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + for ligne in fh: + try: + issue = json.loads(ligne).get("outcome", "?") + except ValueError: + continue + compte[issue] = compte.get(issue, 0) + 1 + except OSError: + pass + return compte + + # ------------------------------------------------------------------ + # Le menu + # ------------------------------------------------------------------ + + def prompt_execute_qemu_cache(self): + print(f"📦 {t('QEMU download cache for local VMs')}") + choices = [ + {"prompt_description": t("Cache - Install or reinstall")}, + {"prompt_description": t("Cache - Diagnose: does it serve?")}, + {"prompt_description": t("Cache - Service state")}, + {"prompt_description": t("Cache - VMs kept out of the cache")}, + {"prompt_description": t("Cache - Git mirrors: fill them ahead")}, + {"prompt_description": t("Cache - Age and cleanup")}, + {"prompt_description": t("Cache - Guide: how it works")}, + {"prompt_description": t("Cache - Tests and performance report")}, + {"prompt_description": t("Cache - Fill what offline runs lacked")}, + {"prompt_description": t("Cache - Logs")}, + {"prompt_description": t("Cache - Copy it to another machine")}, + ] + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._deploy_qemu_cache() + elif status == "2": + self._cache_diagnostic() + elif status == "3": + self._cache_service() + elif status == "4": + self._cache_exceptions() + elif status == "5": + self._cache_miroir_git() + elif status == "6": + self._cache_age() + elif status == "7": + self._cache_guide() + elif status == "8": + self._cache_tests() + elif status == "9": + self._cache_combler() + elif status == "10": + self._cache_journaux() + elif status == "11": + self._cache_transfert() + else: + print(t("Command not found !")) + + # ------------------------------------------------------------------ + # [2] Diagnostic + # ------------------------------------------------------------------ + + def _cache_diagnostic(self): + """Constate, ne répare pas. Chaque ligne est une lecture.""" + print(f"\n{t('Diagnosis of the QEMU download cache')}\n") + + actif = self._cache_actif() + print( + f" {'✓' if actif else '✗'} {t('Service:')} " + f"{t('Service is running') if actif else t('Service is stopped')}" + ) + if not os.path.isfile(CACHE_BIN): + print(f" ✗ {t('Not installed:')} {CACHE_BIN}") + print(f"\n {t('Install it from entry 1 of this menu.')}\n") + return + + # LE contrôle. Deux préfixes qui divergent, et le cache ne sert + # personne pendant que tout paraît réussi. + regles = self._cache_prefixe_regles() + libvirt = self._cache_prefixe_libvirt() + if regles is None: + # Ni ✓ ni ✗ : on ne sait pas. Le point marque ce que le + # diagnostic n'a pas pu lire, comme pour la coupure d'amont. + print( + " · " + f"{t('Cannot tell where the rules point: reading nft needs a sudo password here.')}" + ) + elif regles and regles == libvirt: + print(f" ✓ {t('Redirection:')} {libvirt}.x → {t('the cache')}") + elif not regles: + print(f" ✗ {t('No redirection rule is posted')}") + else: + print( + f" ✗ {t('MISMATCH — rules on')} {regles}.x, " + f"{t('libvirt serves')} {libvirt or '?'}.x" + ) + print(f" {t('Reinstall: the cache reads libvirt by itself.')}") + + # Après le détournement : c'est la même classe de fait, une règle + # posée sur l'hôte. + self._cache_diag_coupure() + + print(f" · {t('Authority:')} {CACHE_CA}") + # Le répertoire du miroir est passé au relevé : sans lui, le binaire + # ne mesure que les objets, et les dépôts git — qui pèsent bien plus — + # disparaissent du seul endroit où l'on surveille la place. + releve = self._cache_lire( + f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}", + delai=60, + ) + for ligne in [ + l + for l in releve.split("\n") + if l.strip() + and not l.startswith(("autorité", "empreinte", "exceptions")) + ][:5]: + if ligne.strip(): + print(f" · {ligne.strip()}") + + # Une VM exceptée ne traverse pas le cache, et c'est voulu ; une + # exception dont la VM n'existe plus ne l'est pas, et elle est + # invisible partout ailleurs — la machine qui hérite de la MAC + # télécharge normalement, le journal reste seulement muet sur elle. + exceptions = self._cache_bypass_lire() + if exceptions: + orphelines = self._cache_bypass_orphelines(exceptions) + marque = "⚠" if orphelines else "·" + print( + f" {marque} {t('Exceptions:')} {len(exceptions)}" + f" ({len(orphelines)} {t('with no VM left')})" + ) + if orphelines: + print(f" {t('Remove them from entry 4 of this menu.')}") + + # Par MACHINE, et pas seulement en tout. Un doute sur + # l'accélération ne s'instruit pas sur un total : il faut pouvoir + # séparer ce qu'une VM a tiré du réseau de ce qu'une autre a été + # servie du disque, et le journal ne le disait pas. + par_vm = self._cache_par_machine() + if par_vm: + print(f"\n {t('What each VM pulled:')}") + print( + f" {t('address'):<18}{t('from disk'):>12}" + f"{t('upstream'):>12}" + ) + for adresse, (disque, amont) in par_vm: + print( + f" {adresse:<18}{self._cache_humain(disque):>12}" + f"{self._cache_humain(amont):>12}" + ) + + compte = self._cache_compte_issues() + if compte: + print(f"\n {t('What the cache has done:')}") + # L'ordre est celui de la lecture — ce qui a servi d'abord, ce qui + # est sorti ensuite. Mais TOUT ce que le journal porte est montré, + # y compris une issue que cette liste ne connaît pas : la version + # d'avant en écrivait cinq en dur et taisait les deux plus + # nombreuses, dont celle qui porte le trafic git. + for issue in ORDRE_ISSUES + tuple( + sorted(set(compte) - set(ORDRE_ISSUES)) + ): + if issue in compte: + print(f" {issue:<14} {compte[issue]}") + else: + print( + f"\n ⚠ {t('The access log is empty: nothing has gone through the cache.')}" + ) + print( + f" {t('A VM that installs while this stays at zero does not use it.')}" + ) + print() + + @staticmethod + def _cache_humain(n): + for unite in ("o", "Kio", "Mio", "Gio"): + if n < 1024 or unite == "Gio": + return ( + f"{n:.0f} {unite}" if unite == "o" else f"{n:.1f} {unite}" + ) + n /= 1024 + return f"{n:.1f} Tio" + + @classmethod + def _cache_par_machine(cls, limite=8): + """(adresse, (octets du disque, octets de l'amont)) par VM. + + Les plus gros consommateurs d'abord : c'est ce qu'on cherche quand on + se demande si une machine a été servie ou si elle a téléchargé. Les + lignes sans client viennent d'un journal écrit avant que le champ + existe — elles sont écartées plutôt que rangées sous un nom faux. + """ + chemin = cls._cache_journal() + if not chemin or not os.path.exists(chemin): + return [] + par = {} + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + for ligne in fh: + try: + d = json.loads(ligne) + except ValueError: + continue + client = d.get("client") + if not client: + continue + disque, amont = par.get(client, (0, 0)) + octets = d.get("bytes", 0) or 0 + if d.get("upstream"): + amont += octets + else: + disque += octets + par[client] = (disque, amont) + except OSError: + return [] + return sorted(par.items(), key=lambda kv: -(kv[1][0] + kv[1][1]))[ + :limite + ] + + # ------------------------------------------------------------------ + # [3] État du service + # ------------------------------------------------------------------ + + def _cache_systemctl(self, verbe, montrer=True): + """Un geste systemd, la commande annoncée avant d'être lancée. + + Arrêter n'éteint pas seulement le service : l'unité retire ses règles + en partant, donc plus aucune VM n'est détournée. C'est ce qui fait de + « stop » le seul moyen vrai de désactiver le cache, et c'est dit à + l'écran plutôt que dans une note qu'on ne lit pas. + """ + cmd = f"sudo systemctl {verbe} {CACHE_SERVICE}" + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + if montrer: + print(f"\n {t('Service:')} {self._cache_etat_court()}") + + def _cache_etat_court(self): + """« actif, au démarrage » et ce qu'il en manque, en une ligne.""" + actif = self._cache_actif() + # « is-enabled » rend un mot par ligne : enabled, enabled-runtime, + # disabled, static, masked. Comparer le MOT et non l'y chercher — + # une sous-chaîne ferait passer « masked » pour un service au boot le + # jour où systemd ajoute un état composé. + au_boot = self._cache_lire( + f"systemctl is-enabled {CACHE_SERVICE}" + ).split("\n")[0].strip() in ("enabled", "enabled-runtime") + return ( + f"{t('Service is running') if actif else t('Service is stopped')}" + f", {t('starts at boot') if au_boot else t('not at boot')}" + ) + + def _cache_service(self): + print(f"\n⚙ {t('State of the cache service')}") + print(f" {self._cache_etat_court()}") + print( + f" {t('Stopping it removes the rules: no VM is redirected.')}\n" + ) + choices = [ + {"prompt_description": t("Service - Start (start)")}, + {"prompt_description": t("Service - Start at boot (enable)")}, + { + "prompt_description": t( + "Service - Do not start at boot (disable)" + ) + }, + {"prompt_description": t("Service - Stop (stop)")}, + {"prompt_description": t("Service - Detailed state (status)")}, + {"prompt_description": t("Service - Logs (log)")}, + ] + verbes = {"1": "start", "2": "enable", "3": "disable", "4": "stop"} + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status in verbes: + self._cache_systemctl(verbes[status]) + elif status == "5": + self._cache_systemctl("status --no-pager", montrer=False) + elif status == "6": + self._cache_journal_service() + else: + print(t("Command not found !")) + + def _cache_journal_service(self): + """Deux journaux, et ils ne disent pas la même chose. + + Celui de systemd porte ce que le service dit de lui-même — démarrages, + erreurs, hôtes retenus en tunnel. Le journal d'ACCÈS porte ce qu'il a + servi, une ligne par requête : c'est celui qui prouve qu'une VM le + traverse. + """ + cmd = f"sudo journalctl -u {CACHE_SERVICE} -n 40 --no-pager" + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + chemin = self._cache_journal() + if not chemin or not os.path.exists(chemin): + return + print(f"\n {t('Access log, last requests:')} {chemin}") + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + lignes = fh.readlines()[-10:] + except OSError: + return + for ligne in lignes: + try: + d = json.loads(ligne) + except ValueError: + continue + print( + f" {d.get('outcome', '?'):<13}" + f"{str(d.get('url', '')).rsplit('/', 1)[-1][:58]}" + ) + + # ------------------------------------------------------------------ + # [10] Journaux + # ------------------------------------------------------------------ + + # ------------------------------------------------------------------ + # [11] Emporter le cache sur une autre machine + # ------------------------------------------------------------------ + + def _cache_transfert(self): + """Copie le magasin vers une autre machine qui porte ERPLibre. + + Ce qui voyage est le MAGASIN, pas le service : les objets sont rangés + sous une clé tirée de l'URL, jamais de la machine qui les a pris, et + un dépôt git miroir est un dépôt. Les réglages, eux, restent : le + pont, le sous-réseau et l'autorité appartiennent à l'hôte, et les + emporter ferait servir une autorité dont aucune VM de là-bas n'a la + clé. + + « tar » et non « rsync » : le second manque sur bien des hôtes, et le + premier est partout. Le flux est compressé au passage — un magasin + se compte en dizaines de gigaoctets, et un lien lent le rend + autrement en une nuit. + """ + print(f"\n📦 {t('Copy the cache to another machine')}") + if not os.path.isfile(CACHE_BIN): + print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n") + return + cache_dir = cache_offline.reglage("EL_CACHE_DIR", CACHE_CONF) or ( + CACHE_DIR + ) + cible = click.prompt( + t("Target machine (user@host, or an ssh alias)"), default="" + ).strip() + if not cible: + print(f" {t('Cancelled.')}\n") + return + # Trois pannes qu'un seul code de retour confondait : une machine + # injoignable, un cache absent, un cache posé sans son compte de + # service. Chacune appelle un geste différent, donc chacune a son + # message. Le jeton « FIN » termine toujours la sonde : son absence + # dénonce le LIEN, là où un code non nul seul accusait le cache. + sonde = ( + f"test -x {shlex.quote(CACHE_BIN)} && echo binaire;" + f" id -u {shlex.quote(cache_offline.SERVICE_USER)}" + " >/dev/null 2>&1 && echo compte;" + " sudo -n true 2>/dev/null && echo sudo;" + " echo compte_ssh=$(id -un);" + " echo place_magasin=$(df -B1 --output=avail" + f" {shlex.quote(cache_dir)} 2>/dev/null | tail -1);" + ' echo place_compte=$(df -B1 --output=avail "$HOME"' + " 2>/dev/null | tail -1); echo FIN" + ) + code, sortie = self._cache_ssh(cible, sonde) + if code or "FIN" not in sortie: + self._cache_dire_ssh_muet(cible) + return + if "binaire" not in sortie: + self._cache_dire_poser_la_bas(cible) + return + # Le compte de service porte le magasin : sans lui, les fichiers + # arriveraient à root et le service ne les lirait pas. + if "compte" not in sortie: + print( + f" ✗ {t('The cache is there but its service account is not:')}" + f" {cache_offline.SERVICE_USER}" + ) + print( + " " + f"{t('Reinstall it there: the installer creates the account.')}" + ) + print(f" sudo bash {INSTALLATEUR}\n") + return + # Le magasin voyage dans l'entrée standard de ssh, qui porte des + # octets et non un terminal : un sudo qui réclame un mot de passe + # là-bas n'échoue pas à l'arrivée du flux, il l'empêche de partir. + if "sudo" not in sortie: + self._cache_sans_sudo_la_bas(cible, cache_dir, sortie) + return + print(f" {t('What travels:')} {cache_dir}") + for quoi, chemin in ( + (t("objects"), cache_dir), + (t("git mirrors"), os.path.join(cache_dir, "git")), + ): + print(f" {quoi:<14}{self._cache_poids(chemin)}") + cmd = self._cache_transfert_cmd(cible, cache_dir) + print(f"\n{t('Will execute:')} {cmd}") + print(f" {t('The settings stay here: bridge, subnet and authority')}") + print(f" {t('belong to this host, and are posed by entry 1 there.')}") + if not click.confirm(t("Copy now?"), default=False): + print(f" {t('Cancelled.')}\n") + return + self.execute.exec_command_live(cmd, source_erplibre=False) + + @staticmethod + def _cache_dire_ssh_muet(cible): + """Le LIEN est en cause, pas le cache : la sonde n'a pas tourné. + + Le transfert tube « tar » dans ssh, sans terminal : un accès qui + réclame un mot de passe échouerait au milieu du flux, après des + gigaoctets. Il s'éprouve avant, par une commande qui ne coûte rien. + """ + q = shlex.quote(cible) + print(f" ✗ {t('Cannot reach it over ssh:')} {cible}") + print(f" {t('This entry needs a password-less ssh access:')}") + print(f" ssh {q} true") + print(f" {t('If it asks for a password, post a key there:')}") + print(f" ssh-copy-id {q}\n") + + @staticmethod + def _cache_branche_ici(): + """La branche de CE dépôt, ou '' si git ne répond pas. + + L'installateur et le menu sont des fichiers du dépôt : une machine + restée sur une branche qui ne les porte pas ne les a tout + simplement pas, et « sudo bash … » y répond « fichier introuvable » + — une panne qui ne ressemble en rien à un cache manquant. Nommer la + branche d'ici épargne de la deviner. + """ + try: + res = subprocess.run( + [ + "git", + "-C", + RACINE_DEPOT, + "rev-parse", + "--abbrev-ref", + "HEAD", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return "" + nom = (res.stdout or "").strip() + return "" if res.returncode or nom == "HEAD" else nom + + @classmethod + def _cache_dire_poser_la_bas(cls, cible): + """Les gestes à faire SUR la machine d'arrivée, un par ligne. + + L'entrée 1 pose le cache ICI : y renvoyer fait relancer une + installation sur l'hôte qui en a déjà une, et la machine d'arrivée + reste sans rien. Le magasin voyage ; le service, lui, se compile + là-bas, contre la distribution de là-bas. + + L'installateur lit le réseau libvirt « default » pour trouver le + pont. Sur un hôte où libvirt est arrêté, il meurt donc sur un réseau + « introuvable » qui existe pourtant, et démarrer ce réseau seul ne + suffit pas : sans hyperviseur joignable, rien ne répond. D'où les + deux issues nommées ensemble — lever libvirt, ou nommer le pont. + """ + q = shlex.quote(cible) + # La branche d'ici, faute de pouvoir lire celle de là-bas : c'est + # celle qui porte l'installateur, et un dépôt en tête détachée n'en + # nomme aucune — la phrase générique vaut alors mieux qu'un nom faux. + branche = cls._cache_branche_ici() or t("the branch used here") + for ligne in ( + f" ✗ {t('The target has no cache installed:')} {cible}", + " " + f"{t('Entry 1 installs the cache HERE; the target needs its own.')}", + f" {t('Steps, ON the target machine:')}", + f" 1. ssh {q}", + f" 2. {t('go to its ERPLibre checkout, then:')}", + f" git fetch && git switch {branche}", + f" {t('without that branch, the installer is not there')}", + f" 3. sudo bash {INSTALLATEUR}", + " " + f"{t('or, in its own TODO: Execute > Deploy > QEMU cache, entry 1')}", + "", + f" {t('The installer reads the « default » libvirt network to find')}", + f" {t('the bridge; a stopped libvirt makes it die on « not found »:')}", + " sudo systemctl start libvirtd.socket", + " sudo virsh -c qemu:///system net-start default", + f" {t('Or name the bridge by hand, libvirt being optional then:')}", + " sudo EL_BRIDGE=virbr0 EL_SUBNET=192.168.122.0/24 \\", + f" bash {INSTALLATEUR}", + "", + f" {t('Then come back to this entry.')}", + "", + ): + print(ligne) + + @staticmethod + def _cache_jeton(sortie, nom): + """La valeur d'un « nom=valeur » rendu par la sonde, ou ''.""" + for ligne in sortie.splitlines(): + if ligne.startswith(f"{nom}="): + return ligne.split("=", 1)[1].strip() + return "" + + def _cache_octets(self, chemin): + """La taille du magasin en octets, ou 0 si elle ne se lit pas. + + Le privilège est nécessaire : les objets appartiennent au compte de + service et ne sont pas lisibles autrement, si bien qu'un « du » + ordinaire rendrait un total très inférieur au vrai — et ferait + croire que la place suffit à l'arrivée. + """ + try: + res = subprocess.run( + ["sudo", "-n", "du", "-sb", chemin], + capture_output=True, + text=True, + timeout=120, + ) + except (OSError, subprocess.SubprocessError): + return 0 + morceau = (res.stdout or "").split("\t")[0].strip() + return int(morceau) if morceau.isdigit() else 0 + + def _cache_sans_sudo_la_bas(self, cible, cache_dir, sortie): + """Sudo réclame un mot de passe à l'arrivée : deux issues, au choix. + + Le magasin occupe l'entrée standard de ssh — un canal d'octets, pas + un terminal — et sudo refuse de lire un mot de passe ailleurs que + sur un terminal. Un ticket pris d'avance n'y change rien : sudo + l'attache au terminal qui l'a obtenu, et la session qui porte le + flux n'en a aucun. + + Restent deux voies. Élargir les droits une fois, et le flux direct + redevient possible. Ou passer par un fichier que le compte + d'arrivée écrit lui-même — aucun privilège pendant le transfert — + puis l'extraire dans SON terminal, où le mot de passe se tape. La + seconde ne coûte aucun droit, mais demande de la place : le fichier + et le magasin extrait coexistent le temps de l'extraction. + """ + q = shlex.quote(cible) + compte = self._cache_jeton(sortie, "compte_ssh") or "" + print(f" ✗ {t('sudo asks for a password on the target:')} {cible}") + print( + " " + f"{t('The store travels on ssh stdin, which carries no terminal,')}" + ) + print(f" {t('so nothing can type it. Two ways out:')}\n") + print(f" {t('1) Allow it there without a password, once:')}") + print(f" ssh -t {q} \\") + print( + f" \"echo '{compte} ALL=(root) NOPASSWD: ALL'" + ' | sudo tee /etc/sudoers.d/erplibre_cache"' + ) + print(f" {t('Then come back to this entry.')}\n") + print( + " " + f"{t('2) Carry it in two steps, the last one in your terminal there:')}" + ) + taille = self._cache_octets(cache_dir) + libre = min( + int(self._cache_jeton(sortie, "place_magasin") or 0), + int(self._cache_jeton(sortie, "place_compte") or 0), + ) + if taille: + print( + f" {t('needed there:')} {self._cache_humain(2 * taille)}" + f" {t('free there:')} {self._cache_humain(libre)}" + ) + if taille and libre < 2 * taille: + print( + f" ✗ {t('Not enough room there: the two-step mode is out.')}\n" + ) + return + if not click.confirm( + t("Send it now, and print the command to finish there?"), + default=False, + ): + print(f" {t('Cancelled.')}\n") + return + code = self.execute.exec_command_live( + self._cache_envoi_fichier_cmd(cible, cache_dir), + source_erplibre=False, + ) + if code: + print(f"\n ✗ {t('The send failed; nothing was extracted.')}\n") + return + print( + f"\n {t('Sent. To finish, ON the target machine, in a terminal:')}" + ) + print(f" {self._cache_finir_la_bas_cmd(cache_dir)}") + print(f" {t('The last command removes the file.')}\n") + + @staticmethod + def _cache_envoi_fichier_cmd(cible, cache_dir): + """Le temps 1 : le magasin part dans le compte d'arrivée. + + « cat » écrit dans le répertoire personnel du compte ssh, qui lui + appartient : aucun privilège n'est donc demandé là-bas pendant le + flux, et c'est exactement ce qui rend ce mode possible sans + terminal. + """ + q = shlex.quote + return ( + f"sudo tar -C {q(cache_dir)} -cf - . | zstd -T0 -3" + f" | ssh {q(cible)} {q('cat > ~/' + TRANSFERT_FICHIER)}" + ) + + @staticmethod + def _cache_finir_la_bas_cmd(cache_dir): + """Le temps 2, à taper dans le terminal de la machine d'arrivée. + + Une seule invocation privilégiée porte l'extraction ET le + changement de propriétaire : en deux, la seconde redemanderait le + mot de passe après des dizaines de minutes. Le fichier est retiré + ensuite, son séjour étant ce qui double l'occupation. + """ + q = shlex.quote + interne = ( + f"tar -C {q(cache_dir)} -xf - --numeric-owner" + f" && chown -R {cache_offline.SERVICE_USER}:" + f"{cache_offline.SERVICE_USER} {q(cache_dir)}" + ) + return ( + f"zstd -dc ~/{TRANSFERT_FICHIER} | sudo sh -c {q(interne)}" + f" && rm -f ~/{TRANSFERT_FICHIER}" + ) + + @staticmethod + def _cache_transfert_cmd(cible, cache_dir): + """La commande qui emporte le magasin, en un seul flux. + + Lue d'un bout à l'autre : « tar » lit le magasin ici, « zstd » le + comprime, « ssh » le porte, et là-bas le même trio le repose avant de + rendre les fichiers au compte du service. Rien n'est écrit sur le + disque entre les deux — un magasin de dizaines de gigaoctets n'a pas + à exister deux fois. + + « --numeric-owner » à l'écriture et le « chown » à l'arrivée : le + même compte porte rarement le même numéro d'une machine à l'autre. + """ + q = shlex.quote + # UNE seule invocation privilégiée à l'arrivée. En deux — « tar » + # puis « chown » — un ticket sudo obtenu juste avant expire pendant + # le transfert, et le second geste réclame alors un mot de passe que + # plus rien ne peut saisir : le magasin serait posé, mais resterait + # illisible pour le compte qui doit le servir. + interne = ( + f"tar -C {q(cache_dir)} -xf - --numeric-owner" + f" && chown -R {cache_offline.SERVICE_USER}:" + f"{cache_offline.SERVICE_USER} {q(cache_dir)}" + ) + distant = f"zstd -d | sudo sh -c {q(interne)}" + return ( + f"sudo tar -C {q(cache_dir)} -cf - . | zstd -T0 -3" + f" | ssh {q(cible)} {q(distant)}" + ) + + def _cache_ssh(self, cible, commande, timeout=30): + """Une commande sur la machine d'arrivée. Rend (code, sortie).""" + try: + p = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + cible, + commande, + ], + capture_output=True, + text=True, + timeout=timeout, + ) + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return p.returncode, p.stdout + + @staticmethod + def _cache_poids(chemin): + """La place qu'occupe un chemin, ou « ? » quand on ne peut pas lire.""" + try: + p = subprocess.run( + ["du", "-sh", chemin], + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return "?" + return p.stdout.split("\t")[0] if p.returncode == 0 else "?" + + def _cache_journaux(self): + """Les journaux en direct, pour regarder une installation passer. + + Le journal d'ACCÈS porte une ligne par requête et dit, pour chacune, + si elle est sortie vers l'internet : c'est lui qui prouve qu'une VM + traverse le cache, et lui qui ne montre plus aucune sortie sous une + coupure. Le journal du SERVICE porte ce que le service dit de + lui-même — démarrages, erreurs, hôtes retenus en tunnel. + """ + chemin = self._cache_journal() + print(f"\n📜 {t('Logs of the download cache')}") + print(f" {t('Access log:')} {chemin or '—'}") + print(f" {t('Ctrl-C ends a live follow.')}\n") + choices = [ + {"prompt_description": t("Logs - Requests, live")}, + { + "prompt_description": t( + "Logs - Only requests that went to the internet, live" + ) + }, + {"prompt_description": t("Logs - Last 40 requests")}, + {"prompt_description": t("Logs - Service journal, live")}, + ] + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status in ("1", "2", "3") and not ( + chemin and os.path.exists(chemin) + ): + print( + f" ✗ {t('No access log yet:')} {chemin or CACHE_CONF}\n" + ) + continue + if status == "1": + self._cache_suivre(chemin) + elif status == "2": + self._cache_suivre(chemin, amont=True) + elif status == "3": + self._cache_suivre(chemin, suivre=False) + elif status == "4": + cmd = f"sudo journalctl -u {CACHE_SERVICE} -n 20 -f" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + else: + print(t("Command not found !")) + + def _cache_suivre(self, chemin, amont=False, suivre=True): + """Le journal d'accès, mis en forme par `cache_journal.py`. + + « tail » garde le fichier ouvert et le lecteur met en forme ligne à + ligne : un journal de plusieurs dizaines de Mio n'est jamais chargé + en entier, et le suivi écrit dès qu'une requête est servie. + """ + lecteur = os.path.join( + RACINE_DEPOT, "script", "qemu", "cache_journal.py" + ) + # « -u » : sans lui, Python met sa sortie en tampon dès qu'elle n'est + # pas un terminal, et un tube l'est rarement. + lire = f"python3 -u {shlex.quote(lecteur)}" + ( + " --amont" if amont else "" + ) + cmd = ( + f"tail -n 40 {'-f ' if suivre else ''}{shlex.quote(chemin)}" + f" | {lire}" + ) + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + # ------------------------------------------------------------------ + # [4] Exceptions : les VM soustraites au détournement + # ------------------------------------------------------------------ + + @classmethod + def _cache_bypass_lire(cls): + """Les exceptions posées, en couples (MAC, nom de VM). + + Lues par le BINAIRE et non par ce fichier : lui seul sait normaliser + une adresse et sauter une ligne fautive, et une seconde lecture écrite + ici dériverait de la sienne. + """ + if not os.path.isfile(CACHE_BIN): + return [] + sortie = cls._cache_lire( + f"{CACHE_BIN} --bypass-list --bypass-file {CACHE_BYPASS}" + ) + out = [] + for ligne in sortie.split("\n"): + champs = ligne.split(None, 1) + if champs and ":" in champs[0]: + out.append((champs[0], champs[1] if len(champs) > 1 else "")) + return out + + @staticmethod + def _cache_domaines(): + """Les noms de domaine que libvirt connaît, VM éteintes comprises.""" + sortie = QemuCacheMenuMixin._cache_lire( + "virsh -c qemu:///system list --all --name" + ) + return {l.strip() for l in sortie.split("\n") if l.strip()} + + @classmethod + def _cache_bypass_orphelines(cls, entrees=None): + """Les exceptions dont la VM n'existe plus. + + C'est LE danger de cette liste. Une adresse MAC se réattribue : une + exception laissée derrière une VM détruite soustrairait au cache une + machine neuve qui hériterait de l'adresse, sans que personne l'ait + demandé et sans que rien ne le dise. Une entrée sans nom ne peut pas + être jugée — elle a été posée à la main — et n'est jamais orpheline. + """ + vivants = cls._cache_domaines() + return [ + (mac, nom) + for mac, nom in ( + entrees if entrees is not None else cls._cache_bypass_lire() + ) + if nom and nom not in vivants + ] + + def _cache_bypass_retirer(self, mac): + cmd = bypass_retrait_cmd(mac) + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _cache_exceptions(self): + print(f"\n🎫 {t('VMs kept out of the download cache')}\n") + if not os.path.isfile(CACHE_BIN): + print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n") + return + entrees = self._cache_bypass_lire() + if not entrees: + print(f" {t('No exception: every VM goes through the cache.')}") + print(f" {t('Tick the box when deploying to add one.')}\n") + return + + orphelines = dict(self._cache_bypass_orphelines(entrees)) + print(f" {'MAC':<20}{t('VM')}") + print(" " + "─" * 52) + for mac, nom in entrees: + marque = " ⚠ " + t("VM gone") if mac in orphelines else "" + print(f" {mac:<20}{nom or '—'}{marque}") + print() + if orphelines: + print(f" ⚠ {t('A freed MAC gets reused: such an entry would')}") + print(f" {t('quietly keep a NEW VM out of the cache.')}\n") + + choices = [ + {"prompt_description": t("Exceptions - Remove the stale ones")}, + {"prompt_description": t("Exceptions - Remove one by its MAC")}, + ] + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status == "1": + if not orphelines: + print(t("Nothing is stale.")) + continue + for mac in orphelines: + self._cache_bypass_retirer(mac) + return True + if status == "2": + mac = click.prompt(t("MAC to give back to the cache")).strip() + if mac: + self._cache_bypass_retirer(mac) + return True + print(t("Command not found !")) + + # ------------------------------------------------------------------ + # [5] Miroirs git + # ------------------------------------------------------------------ + + def _cache_miroir_git(self): + """Prendre l'avance sur les clonages, plutôt que les subir. + + À la demande, le miroir se remplit au fil des requêtes : la PREMIÈRE + machine paie chaque clonage. Pour un dépôt qui en tire trois cents, ce + n'est pas un coût qu'on supprime, c'est un coût qu'on déplace — sur la + machine qui, justement, attend. + """ + print(f"\n🪞 {t('Git mirrors of the ERPLibre manifests')}\n") + if not os.path.isfile(CACHE_BIN): + print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n") + return + depots, octets = self._cache_miroir_occupation() + print(f" {t('Already mirrored:')} {depots}, {octets}") + + racine = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + tous = depots_des_manifestes(racine) + if not tous: + print(f" ✗ {t('No repository found in manifest/')}\n") + return + # La version active décide de ce qu'un déploiement clone : sa base + # d'office, son extra seulement sur demande. Chaque liste est montrée + # avec ce qui lui manque, pour que le choix se fasse sur un compte et + # non sur un nom — remplir toutes les versions coûte des heures, là où + # la base d'une seule est souvent déjà complète. + version = version_active(racine) + base = depots_des_manifestes(racine, version) if version else [] + extra = ( + depots_des_manifestes(racine, fichiers=[manifeste_extra(version)]) + if version + else [] + ) + print() + for libelle, liste in ( + (f"{t('Base of Odoo')} {version or '?'}", base), + (f"{t('Extra of Odoo')} {version or '?'}", extra), + (t("Every manifest, all versions"), tous), + ): + manque = cache_offline.miroirs_absents(liste) if liste else [] + print( + f" {libelle:<36}{len(liste):>4} {t('repositories declared')}," + f" {len(manque):>4} {t('without a mirror')}" + ) + # Un miroir est COMPLET : le dire en gigaoctets, pas en dépôts. Aucune + # éviction n'est écrite, et la place ne se rend pas toute seule. + print(f"\n ⚠ {t('A mirror is complete: this can take tens of GiB')}") + print(f" {t('and hours on the first run. Nothing erases it.')}") + print(f" {t('Free space:')} {self._cache_place_libre()}\n") + + choices = [ + { + "prompt_description": t( + "Mirrors - Fill the base of the active Odoo version" + ) + }, + { + "prompt_description": t( + "Mirrors - Fill the extra of the active Odoo version" + ) + }, + { + "prompt_description": t( + "Mirrors - Fill every manifest, all versions" + ) + }, + {"prompt_description": t("Mirrors - List them, heaviest first")}, + {"prompt_description": t("Mirrors - Remove one")}, + ] + help_info = self.fill_help_info(choices) + sans_version = t("No .odoo-version: no active version to fill.") + rien = t("Nothing declared for this version.") + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status == "1": + if not version: + print(f" {sans_version}\n") + elif not base: + print(f" {rien}\n") + else: + self._cache_miroir_remplir(base) + elif status == "2": + if not version: + print(f" {sans_version}\n") + elif not extra: + print(f" {rien}\n") + else: + print( + f" {t('Extra modules install only with --with_extra.')}" + ) + self._cache_miroir_remplir(extra) + elif status == "3": + self._cache_miroir_remplir(tous) + elif status == "4": + self._cache_miroir_lister() + elif status == "5": + self._cache_miroir_retirer() + else: + print(t("Command not found !")) + + def _cache_miroir_remplir(self, liste): + """Remplir les miroirs sous le compte du SERVICE, jamais sous root. + + Le service rafraîchit ensuite ces dépôts sous son propre compte. Un + objet que root y a posé lui est interdit en écriture : le + rafraîchissement échoue, il est pris pour un amont muet, et le + miroir se fige sans rien dire. L'environnement est celui que l'unité + donne à git, relu dans l'unité plutôt que recopié ici. + """ + fichier = os.path.join( + os.path.expanduser("~/.erplibre"), "miroirs_git.txt" + ) + os.makedirs(os.path.dirname(fichier), exist_ok=True) + with open(fichier, "w", encoding="utf-8") as fh: + fh.write("\n".join(liste) + "\n") + # La liste n'a que des URL publiques. Lisible de tous, le compte du + # service peut la rouvrir par /dev/stdin, qui ne parcourt pas le + # répertoire personnel de l'opérateur, souvent fermé aux autres. + os.chmod(fichier, 0o644) + cmd = miroir_prefetch_cmd(fichier, environnement_de_l_unite()) + print(f"{t('Will execute:')} {cmd}") + if not click.confirm(t("Fill the git mirrors now?")): + return + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _cache_miroir_lister(self): + """Du plus lourd au plus léger : c'est ce qu'on cherche quand on + surveille la place, et quelques dépôts font l'essentiel du total.""" + cmd = ( + f"{CACHE_BIN} --git-mirror-dir {CACHE_MIROIR_GIT}" + f" --git-mirror-list" + ) + print(f"{t('Will execute:')} {cmd}\n") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _cache_miroir_retirer(self): + """Effacer un miroir est sans danger : il se refait au prochain + besoin, au prix du clonage. C'est ce qui permet de rendre de la place + sans tout perdre.""" + nom = click.prompt(t("Repository to remove (as listed)")).strip() + if not nom: + return + cmd = ( + f"sudo {CACHE_BIN} --git-mirror-dir {CACHE_MIROIR_GIT}" + f" --git-mirror-remove {shlex.quote(nom)}" + ) + print(f"\n{t('Will execute:')} {cmd}") + print(f" {t('It will be mirrored again when a VM needs it.')}") + if not click.confirm(t("Remove this mirror?")): + return + self.execute.exec_command_live(cmd, source_erplibre=False) + + @classmethod + def _cache_miroir_occupation(cls): + """(nombre de dépôts, taille lisible) du miroir, lus du binaire.""" + for ligne in cls._cache_lire( + f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}", + delai=120, + ).split("\n"): + if ligne.startswith("dépôts git"): + valeur = ligne.split(":", 1)[1].strip() + nombre = valeur.split()[0] + return nombre, valeur.split(",", 1)[-1].strip() + return "0", "0 o" + + @staticmethod + def _cache_place_libre(): + """Ce qui reste sur le système de fichiers qui porte le cache.""" + try: + st = os.statvfs(os.path.dirname(CACHE_MIROIR_GIT)) + except OSError: + return "?" + libre = st.f_bavail * st.f_frsize + for unite in ("o", "Kio", "Mio", "Gio", "Tio"): + if libre < 1024 or unite == "Tio": + return f"{libre:.1f} {unite}" + libre /= 1024 + return "?" + + # ------------------------------------------------------------------ + # [6] Âge et nettoyage + # ------------------------------------------------------------------ + + def _cache_age(self): + """Ce qui occupe, depuis quand, et de quoi en rendre. + + L'âge retenu est celui du dernier USAGE : le service remet la date + d'un objet chaque fois qu'il le sert. « Vieux » veut donc dire « n'a + plus servi », et non « est entré il y a longtemps » — un paquet servi + tous les jours depuis un an n'est pas à jeter, l'effacer obligerait à + le retélécharger le lendemain. + """ + print(f"\n🧭 {t('Age of the cache, and cleanup')}\n") + if not os.path.isfile(CACHE_BIN): + print(f" ✗ {t('Not installed:')} {CACHE_BIN}\n") + return + print(f" {t('Free space:')} {self._cache_place_libre()}\n") + choices = [ + {"prompt_description": t("Age - By day")}, + {"prompt_description": t("Age - By week")}, + {"prompt_description": t("Age - By month")}, + { + "prompt_description": t( + "Clean - What has not served for a while" + ) + }, + {"prompt_description": t("Clean - Everything")}, + ] + grains = {"1": "jour", "2": "semaine", "3": "mois"} + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status in grains: + self._cache_lancer( + f"--age-report --age-par {grains[status]}", sudo=False + ) + elif status == "4": + self._cache_nettoyer_age() + elif status == "5": + self._cache_nettoyer_tout() + else: + print(t("Command not found !")) + + def _cache_lancer(self, options, sudo=True): + """La commande du cache, annoncée puis lancée.""" + cmd = ( + f"{'sudo ' if sudo else ''}{CACHE_BIN}" + f" --cache-dir {CACHE_DIR} --git-mirror-dir {CACHE_MIROIR_GIT}" + f" {options}" + ) + print(f"{t('Will execute:')} {cmd}\n") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _cache_nettoyer_age(self): + """Le délai est DEMANDÉ, puis montré à blanc avant d'effacer. + + Une purge ne se rattrape pas : les octets sont rendus, il faut les + retélécharger — et pour un dépôt en miroir, cela se compte en minutes. + Voir d'abord ce qui partirait est le seul moyen de répondre à la + question posée. + """ + delai = click.prompt( + t("Not served since (e.g. 30j, 12h)"), default="30j" + ).strip() + if not delai: + return + self._cache_lancer( + f"--purge-older-than {shlex.quote(delai)} --dry-run" + ) + if not click.confirm(t("Erase what is listed above?")): + return + self._cache_lancer(f"--purge-older-than {shlex.quote(delai)}") + + def _cache_nettoyer_tout(self): + """Tout, objets ET dépôts. Montré à blanc d'abord, comme le reste.""" + print(f" ⚠ {t('This empties the objects AND the git mirrors.')}") + print(f" {t('Refilling the mirrors takes minutes to hours.')}\n") + self._cache_lancer("--purge --dry-run") + if not click.confirm(t("Erase the whole cache?")): + return + self._cache_lancer("--purge") + + # ------------------------------------------------------------------ + # [7] Guide + # ------------------------------------------------------------------ + + def _cache_guide(self): + """N'exécute rien. Dit ce qui n'est pas devinable en lisant l'écran.""" + for ligne in ( + "", + t("How the QEMU download cache works"), + "", + t(" Two VMs of the same distribution pull the same packages."), + t( + " The cache keeps what comes down and serves the copy to the next." + ), + "", + f" {t('What is served from disk')}", + t( + " A package file: its name carries its version, so it never changes." + ), + t( + " The host name is ignored: a rotating mirror list still hits." + ), + "", + f" {t('What is always taken from upstream')}", + t( + " A repository index: it names the versions that exist right now." + ), + t( + " Serving a stale one makes an install fail on a withdrawn package." + ), + t( + " It is stored anyway, and only comes back out when upstream is mute." + ), + "", + f" {t('Where things live')}", + f" {t('Objects:')} /var/cache/erplibre_go_qemu_cache", + f" {t('Authority:')} {CACHE_CA}", + f" {t('Settings:')} {CACHE_CONF}", + f" {t('Access log:')} {self._cache_journal() or '—'}", + t(" Entry 10 follows it live: a request that goes out to the"), + t(" internet shows there, and a cut leaves that view empty."), + f" {t('Git mirrors:')} {CACHE_MIROIR_GIT}", + "", + f" {t('Git is mirrored, not cached')}", + t(" Git's protocol is a negotiation: the server computes its"), + t( + " answer from what the client already holds, so no answer is" + ), + t( + " reusable. A bare mirror per upstream repo is kept instead," + ), + t(" and served locally. A mirror already held serves with no"), + t(" network at all — but NOT what the cache may not decrypt:"), + t(" npm and poetry carry their own trust store, so they are"), + t(" tunnelled, and a tunnel carries nothing once cut."), + t(" A mirror is COMPLETE: it weighs what the upstream repo"), + t(" weighs, history included, and a few repositories make"), + t(" most of the total. Entry 5 lists them heaviest first and"), + t(" removes one — it comes back at the next need."), + "", + f" {t('No eviction is written')}", + t(" Neither the objects nor the mirrors shrink by themselves,"), + t(" and both live on the orchestrator's disk. The diagnosis"), + t(" entry says what each of the two occupies."), + t(" Entry 6 groups them by AGE OF LAST USE — an object served"), + t(" has its date renewed, so « old » means « no longer used »"), + t(" — and gives back what has not served for a while, or all."), + "", + f" {t('Turning it off')}", + t(" Interception is transparent and covers the whole bridge:"), + t( + " a VM cannot opt out from the inside. Omitting the authority" + ), + t( + " does not bypass anything — the VM is redirected all the same" + ), + t(" and fails on « self-signed certificate in chain »."), + "", + t(" For ONE VM: tick « keep this VM out of the cache » when"), + t( + " deploying, or pass --cache-bypass. Its MAC address is fixed" + ), + t( + " before creation and an exception is posted on the host, so" + ), + t( + " nothing redirects it. Entry 4 lists them; an exception whose" + ), + t(" VM is gone must be removed, a freed MAC being reused."), + "", + t(" For EVERY VM: stop the service, entry 3 or"), + f" systemctl stop {CACHE_SERVICE}", + t(" The rules leave with it, so no VM stays redirected."), + "", + f" {t('Proxmox')}", + t( + " A Proxmox host that is itself a VM of this orchestrator crosses" + ), + t( + " this bridge: the machines it carries come out behind its address," + ), + t( + " so the cache serves them, and the deployment poses the authority" + ), + t( + " in each of them. A host that lives elsewhere is not concerned —" + ), + t( + " install the cache ON it, the script being generic and Proxmox a" + ), + t( + " Debian. Reserve: a bridge switched onto the LAN is only seen by" + ), + t(" the rules when br_netfilter is enabled."), + "", + f" {t('Carrying it to another machine')}", + t(" Entry 11 copies the STORE, not the service: an object is"), + t( + " keyed by URL and a git mirror is a repository, so both are" + ), + t( + " worth the same elsewhere. The settings stay here — bridge," + ), + t(" subnet and authority belong to the host that serves them."), + t(" The target must already carry the cache, installed from"), + t(" ITS own checkout, and answer ssh without a password."), + "", + ): + print(ligne) + + # ------------------------------------------------------------------ + # [8] Tests + # ------------------------------------------------------------------ + + # Les trois essais, dans l'ordre où l'assistant les propose et les enchaîne. + # Chaque entrée est (option de ligne de commande, libellé) : le NOM des + # machines n'est pas écrit ici, il est demandé au script. Une seconde + # fabrique du nom dériverait, et le menu annoncerait alors des VM qui ne + # sont pas celles qui naissent — pire que de ne rien annoncer. + _CACHE_ESSAIS = ( + ("", "The cache: two VMs, measure the gain"), + ("--hors-ligne", "The offline counter-proof"), + ("--sans-cache", "The control: two VMs WITHOUT the cache"), + ) + + _CACHE_CHARGES = ( + ("minimum", "Minimum: a batch of packages, minutes"), + ("erplibre", "ERPLibre + Odoo 18: the real thing, hours"), + ) + + @staticmethod + def _cache_module_test(): + """Le script du test long, chargé comme module. + + À l'appel et non au démarrage du menu : il tire le catalogue du + déploiement, qui est lourd, et personne ne doit le payer pour afficher + un menu qu'il ne visite pas. + + C'est LUI qui décide des systèmes offerts et du nom des machines. Le + menu ne recopie ni l'un ni l'autre : ce qui est annoncé à l'écran est + alors, par construction, ce qui va se passer. + """ + import importlib.util + + chemin = os.path.join( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ), + LONGTEST, + ) + spec = importlib.util.spec_from_file_location( + "qemu_cache_long", chemin + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + # Une icône par système, pour le menu SEULEMENT. `distro_label` sert aussi + # aux formulaires de déploiement et aux journaux : l'icône y changerait + # des libellés que d'autres lisent. Une distribution ajoutée au catalogue + # sans icône retombe sur _CACHE_ICONE_REPLI, et une épreuve le signale. + _CACHE_ICONES_SYSTEMES = { + "almalinux": "🌸", + "arch": "🏹", + "debian": "🌀", + "fedora": "🎩", + "opensuse": "🦎", + "rocky": "⛰", + "ubuntu": "🟠", + } + _CACHE_ICONE_REPLI = "🐧" + + @classmethod + def _cache_systemes(cls): + """Les systèmes que le TEST accepte, avec leur libellé et son icône.""" + module = cls._cache_module_test() + return [ + ( + d, + f"{cls._CACHE_ICONES_SYSTEMES.get(d, cls._CACHE_ICONE_REPLI)}" + f" {module.distro_label(d, module.DISTROS[d][1])}", + ) + for d in sorted(module.systemes_mesurables()) + ] + + @classmethod + def _cache_nom_des_machines(cls, options, distro, charge): + """Le début du nom des VM de cet essai, demandé au script.""" + module = cls._cache_module_test() + prefixe = { + "--sans-cache": module.NOM_BASE_SANS_CACHE, + "--hors-ligne": module.NOM_BASE_HORS_LIGNE, + }.get(options, module.NOM_BASE) + return module.nom_de_base( + prefixe, distro, module.DISTROS[distro][1], charge + ) + + def _cache_choisir(self, titre, options): + """Une question numérotée, le premier choix par défaut. + + Rend l'indice choisi, ou None si l'on renonce. Les trois questions de + l'assistant partagent cette forme : une seule façon de répondre, et + « 0 » ramène en arrière partout. + """ + print(f"\n{titre}") + for i, libelle in enumerate(options, 1): + print(f" [{i}] {libelle}") + print(f" [0] {t('Back')}") + while True: + reponse = click.prompt(t("Choice"), default="1").strip() + if reponse == "0": + return None + if reponse.isdigit() and 1 <= int(reponse) <= len(options): + return int(reponse) - 1 + print(t("Command not found !")) + + def _cache_assistant(self): + """Trois questions, puis les essais choisis, l'un après l'autre. + + Une seule confirmation à la fin, et non une par essai : la question + porte sur le LOT, et la reposer trois fois la rendrait machinale + — c'est ce qui fait qu'on cesse de la lire. + """ + essais = self._cache_choisir( + t("Which test?"), + [t(e[1]) for e in self._CACHE_ESSAIS] + + [t("All three, one after another")], + ) + if essais is None: + return + choisis = ( + list(self._CACHE_ESSAIS) + if essais == len(self._CACHE_ESSAIS) + else [self._CACHE_ESSAIS[essais]] + ) + + charge = self._cache_choisir( + t("Which load?"), [t(c[1]) for c in self._CACHE_CHARGES] + ) + if charge is None: + return + charge = self._CACHE_CHARGES[charge][0] + + systemes = self._cache_systemes() + systeme = self._cache_choisir( + t("Which system?"), [libelle for _d, libelle in systemes] + ) + if systeme is None: + return + distro = systemes[systeme][0] + + commun = f"--distro {distro} --charge {charge}" + print(f"\n{t('About to run, one after another:')}") + for options, _libelle in choisis: + ligne = f"{LONGTEST} {commun} {options}".rstrip() + print(f" {ligne}") + nom = self._cache_nom_des_machines(options, distro, charge) + print(f" {t('Machines created:')} {nom}-1, -2…") + if charge == "erplibre": + # Des heures et non des minutes : une VM qui installe ERPLibre + # entier n'a rien à voir avec le lot de paquets, et découvrir la + # différence en cours de route est trop tard. + print( + f"\n ⚠ {t('The real load takes hours per VM, not minutes.')}" + ) + if not click.confirm(t("Run these long tests?")): + return + for options, _libelle in choisis: + self._longtest_run( + "qemu_cache.py", f"{commun} {options}".rstrip(), demander=False + ) + + def _cache_tests(self): + print(f"\n{t('Cache tests: real VMs, several minutes')}\n") + choices = [ + {"prompt_description": t("Test - Choose and run")}, + {"prompt_description": t("Test - The plan only (dry-run)")}, + {"prompt_description": t("Test - Performance report")}, + {"prompt_description": t("Test - Undo the machines created")}, + ] + args = {"2": "--dry-run", "3": "--rapport", "4": "--detruire"} + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status == "1": + self._cache_assistant() + elif status in args: + self._longtest_run("qemu_cache.py", args[status]) + else: + print(t("Command not found !")) + + # ------------------------------------------------------------------ + # [9] Combler ce qui a manqué hors ligne + # ------------------------------------------------------------------ + + @staticmethod + def _cache_raison(verdict): + """Ce qu'un verdict de rejeu veut dire, dans la langue du menu.""" + return { + "rejouable": t("replay through the cache"), + "jamais": t("never kept: the cache keeps only GET and HEAD"), + "non-cachable": t("the cache does not keep this address"), + "tunnel": t("host in tunnel: nothing to keep"), + "git": t("git negotiation: fill the mirror from entry 5"), + "adresse": t( + "not a host name: a replay could loop back into the cache" + ), + }.get(verdict, verdict) + + @classmethod + def _cache_refus_appris(cls): + """Les hôtes que le service a appris à passer en tunnel. + + Ces refus ne vivent qu'en mémoire du service, qui ne les oublie pas, + et seul son journal systemd les nomme. Lu sans privilège, puis par + « sudo -n », qui échoue plutôt que de demander un mot de passe. Un + journal illisible rend une liste vide. + """ + lire = ( + f"journalctl -u {CACHE_SERVICE} -o cat --no-pager" + " | grep -F 'tunnel opaque retenu pour'" + ) + vu = cls._cache_lire(lire) + cls._cache_lire(f"sudo -n {lire}") + return sorted( + set(re.findall(r"tunnel opaque retenu pour (\S+) \(", vu)) + ) + + def _cache_exclus(self): + """Les hôtes que le cache passe en tunnel : déclarés dans ses + sources, ajoutés par EL_EXCLUDE (séparés par des virgules, comme le + service les lit), et appris au fil des refus.""" + racine = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + exclus = list(exclusions_declarees(racine)) + exclus += [ + e.strip() + for e in cache_offline.reglage("EL_EXCLUDE", CACHE_CONF).split(",") + if e.strip() + ] + appris = self._cache_refus_appris() + if appris: + print( + f" {t('Tunnel refusals learned by the service:')}" + f" {', '.join(appris)}\n" + ) + return exclus + appris + + @staticmethod + def _cache_curl(argv, delai=REJEU_DELAI): + """Les en-têtes que curl a reçus, ou "" s'il n'a rien obtenu. + + Les variables de mandataire héritées de l'opérateur sont retirées : + un « https_proxy » enverrait le rejeu ailleurs que dans le cache, et + un « no_proxy » ferait ignorer « -x ». + """ + env = { + k: v + for k, v in os.environ.items() + if not k.lower().endswith("_proxy") + } + try: + p = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=delai + 15, + env=env, + ) + except (OSError, subprocess.SubprocessError): + return "" + return p.stdout or "" + + def _cache_rejouer(self, methode, url, ports, exclus): + """Rejoue une adresse, puis sa chaîne de redirections, saut par saut. + + Rend [(url, statut)], le statut None quand curl n'a rien obtenu. + + Pas de « -L » : curl suivrait seul, et un saut vers l'autre schéma + partirait par une connexion que le cache ne mène pas. Chaque saut + est donc une commande de la même forme que la première, et n'est + rejoué que s'il est lui-même rejouable, jamais deux fois. Seul un GET + suit : un HEAD lit sa redirection et s'arrête, comme le client qui + l'a émis. + """ + sauts = [] + cible = url + for _ in range(SAUTS_MAX + 1): + sortie = self._cache_curl( + rejeu_cmd(methode, cible, ports[0], ports[1], CACHE_CA) + ) + statut, suivante = statut_et_cible(sortie, cible) + sauts.append((cible, statut)) + if ( + methode.upper() != "GET" + or statut not in REDIRECTIONS + or not suivante + or verdict_de_rejeu("GET", suivante, exclus) != "rejouable" + or suivante in [u for u, _s in sauts] + ): + break + cible = suivante + return sauts + + def _cache_combler(self): + """Rejouer, amont branché, ce que les VM hors ligne n'ont pas trouvé. + + Chaque rejeu traverse le cache sous la clé qu'une VM produirait : le + nom d'hôte reste celui de l'URL, seule la connexion est menée à + l'écoute locale. Ce qui entre alors au cache est ce que la VM y + trouvera au prochain déploiement hors ligne. + + Refusé sous la coupure : le rejeu n'ajouterait que des manques au + journal, et l'on croirait avoir rempli. Le guet est lu d'abord, sans + sudo : tant qu'il tourne, la coupure est tenue exprès, et le geste + donné est l'arrêt du guet. Une coupure illisible — sudo exige un mot + de passe — n'est pas prise pour une absence : le menu le dit, et + demande, non par défaut. Le plan est montré avant la confirmation du + rejeu, avec ce qui ne se rejoue pas et pourquoi. + """ + print(f"\n🩹 {t('What offline runs lacked')}\n") + if not self._cache_combler_permis(): + return + + def reglage(nom, defaut=""): + return cache_offline.reglage(nom, CACHE_CONF) or defaut + + chemin = self._cache_journal() + manques = cache_offline.manques_recents( + chemin, reglage("EL_SUBNET"), time.time() - JOURS_RECENTS * 86400 + ) + if not manques: + print( + f" {t('No offline miss in the recent window: nothing to fill.')}\n" + ) + return + cache_dir = reglage("EL_CACHE_DIR", CACHE_DIR) + verdicts = cache_offline.detient_interroger( + [(m["methode"], m["url"]) for m in manques], CACHE_BIN, cache_dir + ) + selon_journal = verdicts is None + if selon_journal: + verdicts = {} + tenus = cache_offline.tenus_selon_journal( + chemin, + {(m["methode"], m["url"]): m["dernier"] for m in manques}, + ) + else: + tenus = { + c + for c, v in verdicts.items() + if v["verdict"] in cache_offline.DETENTION + } + restants = [ + m for m in manques if (m["methode"], m["url"]) not in tenus + ] + avis_journal = t("according to the log: a purge can make it wrong") + if not restants: + print(f" ✓ {t('Everything that was missed is held now.')}") + if selon_journal: + print(f" {avis_journal}") + print() + return + + exclus = self._cache_exclus() + plan = [] + for m in restants: + cle = (m["methode"], m["url"]) + if verdicts.get(cle, {}).get("verdict") == "non-cachable": + verdict = "non-cachable" + else: + verdict = verdict_de_rejeu(m["methode"], m["url"], exclus) + plan.append((m, verdict)) + marque = "↻" if verdict == "rejouable" else "·" + print(f" {marque} {m['methode']:<5}{m['url']}") + print(f" {self._cache_raison(verdict)} ({m['n']}×)") + if selon_journal: + print(f"\n ⚠ {avis_journal}") + rejouables = [m for m, v in plan if v == "rejouable"] + if not rejouables: + print(f"\n {t('Nothing here can be replayed.')}\n") + return + + # Le magasin ne tient compte ni de Vary ni des en-têtes de la + # requête : il garde ce que curl reçoit, et le sert tel quel à la VM. + entetes_1 = t( + "The replay sends curl's own headers: a server that varies on" + ) + entetes_2 = t( + "User-Agent or Accept may keep another answer than the VM's." + ) + print(f"\n ⚠ {entetes_1}") + print(f" {entetes_2}") + # Les ports par défaut de l'installateur, quand le réglage manque. + ports = ( + reglage("EL_HTTP_PORT", "8898"), + reglage("EL_TLS_PORT", "8899"), + ) + premier = rejouables[0] + exemple = rejeu_cmd( + premier["methode"], premier["url"], ports[0], ports[1], CACHE_CA + ) + print(f"\n{t('Will execute:')} {shlex.join(exemple)}") + if not click.confirm( + t("Replay these addresses through the cache now?") + ): + return + + resultats = [ + (m, self._cache_rejouer(m["methode"], m["url"], ports, exclus)) + for m in rejouables + ] + # Revérifié au MAGASIN : un 200 reçu par curl ne prouve pas que + # l'objet a été gardé. + verification = cache_offline.detient_interroger( + [ + (m["methode"], url) + for m, sauts in resultats + for url, _s in sauts + ], + CACHE_BIN, + cache_dir, + ) + print() + for m, sauts in resultats: + for rang, (url, statut) in enumerate(sauts): + cle = (m["methode"], url) + if verification is None: + etat = t("not re-checked: this binary has no --detient") + elif ( + verification.get(cle, {}).get("verdict") + in cache_offline.DETENTION + ): + etat = "✓ " + t("held") + else: + etat = "✗ " + t("not held") + recu = ( + statut if statut is not None else t("curl got no answer") + ) + debut = f" {m['methode']:<5}" if rang == 0 else " → " + print(f"{debut}{url}") + print(f" [{recu}] {etat}") + print() + + +def exclusions_declarees(racine): + """Les hôtes que le cache passe d'office en tunnel, lus dans SA source. + + DefaultExclusions vit en Go. Le lire là plutôt que d'en tenir une copie + empêche qu'un hôte ajouté côté Go soit rejoué d'ici. Une source illisible + rend une liste vide. + """ + try: + with open(os.path.join(racine, MITM_GO), encoding="utf-8") as fh: + src = fh.read() + except OSError: + return [] + bloc = re.search(r"var DefaultExclusions = \[\]string\{([^}]*)\}", src) + return re.findall(r'"([^"]+)"', bloc.group(1)) if bloc else [] + + +def hote_exclu(hote, exclus): + """La règle du service : le nom exact, ou un suffixe qui commence par un + point et couvre alors tout le domaine.""" + hote = (hote or "").lower() + for e in exclus: + e = (e or "").strip().lower() + if e and (hote == e or (e.startswith(".") and hote.endswith(e))): + return True + return False + + +def _est_une_adresse_ip(hote): + import ipaddress + + try: + ipaddress.ip_address(hote) + except ValueError: + return False + return True + + +def verdict_de_rejeu(methode, url, exclus=()): + """Que faire d'une adresse manquée : « rejouable », « jamais », « git », + « tunnel » ou « adresse ». + + Seuls GET et HEAD se gardent. Une négociation git ne se garde pas : c'est + un DÉPÔT que le cache tient, et l'entrée 5 le remplit. Un hôte en tunnel + — https sur une adresse IP, sans SNI, ou hôte exclu — n'a rien à garder, + et rejoué depuis l'hôte il revient dans le cache : sans détournement, la + destination d'origine d'une connexion locale est l'écoute elle-même, et + le tunnel s'y rappelle sans fin. Pour la même raison, une adresse IP ou + « localhost » en http n'est pas rejouée. + """ + if (methode or "").upper() not in cache_offline.METHODES_GARDABLES: + return "jamais" + try: + morceaux = urlsplit(url) + hote = (morceaux.hostname or "").lower() + except ValueError: + return "adresse" + if morceaux.scheme not in ("http", "https") or not hote: + return "adresse" + if _est_une_adresse_ip(hote): + return "tunnel" if morceaux.scheme == "https" else "adresse" + if hote == "localhost": + return "adresse" + if cache_offline.depot_git(url): + return "git" + if morceaux.scheme == "https" and hote_exclu(hote, exclus): + return "tunnel" + return "rejouable" + + +def rejeu_cmd(methode, url, http_port, tls_port, ca, delai=REJEU_DELAI): + """La commande curl qui rejoue une adresse PAR le cache, en arguments. + + http passe par le mandataire du cache (« -x »), qui rebâtit l'adresse + depuis la ligne de requête. https garde son nom — SNI et en-tête Host — + et seule sa connexion est menée à l'écoute TLS : « --connect-to » sans + hôte ni port d'origine vaut pour tous. La clé est alors celle qu'une VM + produirait. L'autorité du cache est APPROUVÉE par « --cacert », jamais + contournée. + + « -D - » rend les en-têtes, qui portent la redirection ; le corps va à + /dev/null, le cache le garde de son côté. + + « -q » en PREMIER argument — curl ne l'honore qu'à cette place — écarte + le ~/.curlrc de l'opérateur : un « proxy » y enverrait le rejeu https + ailleurs que dans le cache, et « location », « compressed » ou un + en-tête changeraient ce qui entre au magasin sous la clé de la VM. + """ + argv = ["curl", "-q", "-sS", "-o", "/dev/null", "-D", "-"] + argv += ["--max-time", str(delai)] + if urlsplit(url).scheme == "https": + argv += ["--connect-to", f"::127.0.0.1:{tls_port}", "--cacert", ca] + else: + argv += ["-x", f"http://127.0.0.1:{http_port}"] + if (methode or "").upper() == "HEAD": + argv.append("-I") + argv.append(url) + return argv + + +def statut_et_cible(entetes, url): + """(statut, cible absolue de la redirection ou "") lus dans « curl -D - ». + + Le DERNIER bloc compte : un « 100 Continue » précède la vraie réponse. + Une cible relative est résolue contre l'adresse demandée. Rend (None, "") + quand curl n'a rien reçu. + """ + statut, cible = None, "" + for ligne in (entetes or "").splitlines(): + ligne = ligne.strip() + if ligne.startswith("HTTP/"): + champs = ligne.split() + statut = ( + int(champs[1]) + if len(champs) > 1 and champs[1].isdigit() + else None + ) + cible = "" + elif ligne.lower().startswith("location:"): + cible = urljoin(url, ligne.split(":", 1)[1].strip()) + return statut, cible + + +def environnement_de_l_unite(unite=None): + """Les « NOM=valeur » que l'unité systemd donne au service, dans l'ordre. + + Lus dans l'unité posée, qui porte les valeurs résolues à l'installation. + Une unité illisible rend une liste vide. + """ + out = [] + try: + with open(unite or CACHE_UNITE, encoding="utf-8") as fh: + for ligne in fh: + ligne = ligne.strip() + if not ligne.startswith("Environment="): + continue + try: + out.extend(shlex.split(ligne.split("=", 1)[1])) + except ValueError: + continue + except OSError: + pass + return out + + +def miroir_prefetch_cmd(fichier, environnement): + """La commande qui remplit les miroirs sous le compte du service. + + La liste arrive sur l'entrée standard, ouverte par le shell de + l'opérateur : « /dev/stdin » la rouvre sans parcourir le chemin, là où + le compte du service ne traverse pas le répertoire personnel qui la + porte. + """ + mots = ["sudo", "-u", cache_offline.SERVICE_USER, "env"] + mots += [shlex.quote(e) for e in environnement] + mots += [ + CACHE_BIN, + "--git-mirror-dir", + CACHE_MIROIR_GIT, + "--git-mirror-prefetch", + "/dev/stdin", + "<", + shlex.quote(fichier), + ] + return " ".join(mots) + + +def bypass_retrait_cmd(mac): + """La commande qui retire une exception du fichier ET du noyau. + + Les deux, parce qu'ils ne disent pas la même chose : le fichier est ce que + le service reposera au prochain démarrage, l'ensemble du noyau est ce qui + s'applique en ce moment. N'en faire qu'un laisse l'exception vivante + jusqu'au redémarrage, ou la fait revenir après. + """ + return ( + f"sudo {CACHE_BIN} --bypass-del {shlex.quote(mac)}" + f" --bypass-file {CACHE_BYPASS} | sudo nft -f -" + ) + + +def bypass_menage(execute): + """Retire les exceptions dont la VM n'existe plus. Rend leur nombre. + + Appelée après une suppression de VM. Sans ce ménage, une adresse MAC + libérée puis réattribuée soustrairait au cache une machine neuve que + personne n'a exceptée, et rien ne le dirait : ni la VM, qui télécharge + normalement, ni le cache, dont le journal reste simplement muet à son + sujet. + + Ne fait rien quand le cache n'est pas posé : il n'y a alors aucune liste. + """ + if not os.path.isfile(CACHE_BIN): + return 0 + orphelines = QemuCacheMenuMixin._cache_bypass_orphelines() + for mac, _nom in orphelines: + execute.exec_command_live( + bypass_retrait_cmd(mac), source_erplibre=False + ) + return len(orphelines) + + +def version_active(racine): + """La version d'Odoo que le checkout porte (« .odoo-version »), ou "".""" + try: + with open( + os.path.join(racine, ".odoo-version"), encoding="utf-8" + ) as fh: + return fh.read().strip() + except OSError: + return "" + + +def manifeste_extra(version): + """Le manifeste des modules extra d'une version, relatif à la racine. + + Il ne s'installe que sur demande (« --with_extra ») : ni la base ni le + verdict du pré-vol ne le comptent, seul son propre remplissage le vise. + """ + return os.path.join("manifest", f"git_manifest_extra_odoo{version}.xml") + + +def manifestes_de_version(racine, version, extra=False): + """Les manifestes qu'une installation de cette version fusionne. + + La règle est celle de script/git/git_merge_repo_manifest.py. Les + manifestes communs sont lus dans les MÊMES listes que lui — + conf/git_manifest_odoo.csv et conf/git_manifest.csv — plutôt que recopiés + ici : une copie divergerait au premier manifeste ajouté. S'y ajoutent + celui de la version et son « _dev ». + + Deux manifestes n'entrent pas d'office, comme dans la fusion : l'extra, + qui ne s'installe que sur demande, et le mobile, qui n'entre que si son + répertoire existe — ce qui n'est pas le cas d'une VM fraîchement clonée. + Les compter ferait annoncer comme manquants des dépôts qu'aucun + déploiement par défaut ne clone. + """ + import csv + + fichiers = [] + + def ajouter(chemin): + if chemin and chemin not in fichiers: + fichiers.append(chemin) + + def lire_liste(nom): + try: + with open( + os.path.join(racine, "conf", nom), encoding="utf-8" + ) as fh: + for ligne in csv.DictReader(fh): + ajouter(ligne.get("filepath") or "") + except OSError: + pass + + lire_liste("git_manifest_odoo.csv") + ajouter(os.path.join("manifest", f"git_manifest_odoo{version}.xml")) + ajouter(os.path.join("manifest", f"git_manifest_odoo{version}_dev.xml")) + lire_liste("git_manifest.csv") + if extra: + ajouter(manifeste_extra(version)) + return fichiers + + +def depots_des_manifestes(racine, version="", fichiers=None): + """Les dépôts git que les manifestes du dépôt déclarent, sans doublon. + + Un manifeste Google Repo nomme des « remote » — l'URL de base d'une forge — + et des « project » qui s'y rattachent. L'URL complète est la concaténation + des deux, et un même projet figure dans plusieurs manifestes, un par + version d'Odoo. + + `fichiers` nomme exactement les manifestes à lire, relatifs à la racine. + À défaut, `version` les déduit par `manifestes_de_version` : un + déploiement n'installe qu'une version, et additionner les dépôts des + autres fait compter comme manquant ce que personne ne clonera. Sans l'un + ni l'autre, tous les manifestes sont lus — ce dont le remplissage complet + a besoin pour prendre de l'avance sur toutes les versions. + + Un manifeste illisible ou absent est SAUTÉ plutôt que fatal : la liste + sert à prendre de l'avance, et en perdre une partie vaut mieux que de ne + rien prendre. + """ + import glob + import xml.etree.ElementTree as ET + + if fichiers is None and version: + fichiers = manifestes_de_version(racine, version) + if fichiers is None: + chemins = sorted(glob.glob(os.path.join(racine, "manifest", "*.xml"))) + else: + chemins = [os.path.join(racine, f) for f in fichiers] + + vus = set() + out = [] + for fichier in chemins: + try: + arbre = ET.parse(fichier).getroot() + except (ET.ParseError, OSError): + continue + bases = { + e.get("name"): (e.get("fetch") or "") + for e in arbre.findall("remote") + } + for projet in arbre.findall("project"): + base = bases.get(projet.get("remote"), "") + nom = projet.get("name") or "" + if not base or not nom: + continue + url = base.rstrip("/") + "/" + nom.lstrip("/") + if url not in vus: + vus.add(url) + out.append(url) + return out diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 44f3440..2ade1e6 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -3,13 +3,17 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Menu QEMU/KVM : d\u00e9cider et lancer un d\u00e9ploiement.\n\nLe chemin complet d'une cr\u00e9ation : les ressources (pr\u00e9r\u00e9glages vCPU/RAM/disque\net saisie libre), le plan et son r\u00e9capitulatif, les v\u00e9rifications de l'h\u00f4te\n(groupe libvirt, KVM), le contexte du formulaire TUI, la collecte en ligne, et\nl'ex\u00e9cution d'une spec \u2014 la M\u00caME structure quelle que soit l'interface, ce qui\npermet aux invites et au formulaire de partager tout le reste.\n\nFronti\u00e8re claire : ici on d\u00e9cide ; dans qemu_install.py on \u00e9crit ce qui sera\nex\u00e9cut\u00e9 dans l'invit\u00e9.""" +import contextlib +import fcntl import getpass import grp import json import os import re import shlex +import signal import subprocess +import threading import time from script.todo import todo_prefs @@ -17,6 +21,15 @@ from script.todo.qemu_privilege import sudo_prefix from script.todo.todo_i18n import get_lang, t +class _SansInternetImpossible(Exception): + """La coupure demandée n'a pas pu être posée : rien n'est déployé. + + Une exception et non un code de retour : le déploiement est enveloppé + d'un gestionnaire de contexte, et seule une exception l'empêche d'entrer + dans le bloc. + """ + + class QemuDeployMixin: """Menu QEMU/KVM : d\u00e9cider et lancer un d\u00e9ploiement.\n\nLe chemin complet d'une cr\u00e9ation : les ressources (pr\u00e9r\u00e9glages vCPU/RAM/disque\net saisie libre), le plan et son r\u00e9capitulatif, les v\u00e9rifications de l'h\u00f4te\n(groupe libvirt, KVM), le contexte du formulaire TUI, la collecte en ligne, et\nl'ex\u00e9cution d'une spec \u2014 la M\u00caME structure quelle que soit l'interface, ce qui\npermet aux invites et au formulaire de partager tout le reste.\n\nFronti\u00e8re claire : ici on d\u00e9cide ; dans qemu_install.py on \u00e9crit ce qui sera\nex\u00e9cut\u00e9 dans l'invit\u00e9.""" @@ -181,9 +194,14 @@ class QemuDeployMixin: # (« Could not get lock … lists/lock ») -> lists vides -> « Unable # to locate package git ». On RÉESSAIE donc update jusqu'à ce que # le verrou se libère (et les lists soient peuplées), borné à ~5 min. - "n=0; until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " - "n=$((n+1)); [ $n -ge 30 ] && break; " - 'echo "apt verrouille (tentative $n), attente 10s..."; sleep 10; ' + # Bornée par le TEMPS : trente essais valent cinq minutes quand + # chacun échoue en une seconde sur un verrou, mais des heures + # quand le cache répond 504 sur chaque index et qu'un essai dure + # des minutes. + "fin=$(( $(date +%s) + 300 )); " + "until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " + '[ "$(date +%s)" -ge "$fin" ] && break; ' + 'echo "apt indisponible, nouvel essai dans 10s..."; sleep 10; ' "done; " "sudo apt-get -o DPkg::Lock::Timeout=600 install -y $PKGS; " "elif command -v dnf >/dev/null 2>&1; then " @@ -252,26 +270,55 @@ class QemuDeployMixin: + tools_cmd # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. + # Un dépôt déjà là est gardé tel quel, et le journal le dit : la + # branche choisie n'y est alors PAS rechargée. + ( ( "sudo mkdir -p /opt; " "if [ ! -d /opt/erplibre/.git ]; then " f"sudo git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; " - "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; " - f"cd /opt/erplibre && {install_chain}" + "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; " + f"else {self._qemu_checkout_garde('/opt/erplibre')}; fi; " + + self._qemu_commit_line("/opt/erplibre") + + f"cd /opt/erplibre && {install_chain}" ) if prod else ( "mkdir -p ~/git; " "if [ ! -d ~/git/erplibre/.git ]; then " f"git clone --branch {shlex.quote(branch)} " - f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " - f"cd ~/git/erplibre && {install_chain}" + f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; " + f"else {self._qemu_checkout_garde('~/git/erplibre')}; fi; " + + self._qemu_commit_line("~/git/erplibre") + + f"cd ~/git/erplibre && {install_chain}" ) ) ) + @staticmethod + def _qemu_checkout_garde(depot): + """Ligne du journal quand le clone est sauté : le dépôt existant est + gardé, sans mise à jour.""" + note = f" {t('Existing checkout kept, not updated:')} {depot}" + return f"echo {shlex.quote(note)}" + + @staticmethod + def _qemu_commit_line(depot): + """Ligne du journal qui nomme le commit que la VM exécute vraiment. + + Lu DANS la VM, après le clone : c'est la seule source sûre. Hors + ligne, le clone vient du miroir du cache, qui peut retarder sur le + dépôt distant comme sur le checkout de l'hôte. `depot` n'est pas + cité : « ~ » doit s'y développer. « || true » : sous « set -e », une + ligne d'information ne doit jamais faire échouer l'installation. + """ + fmt = shlex.quote(" Commit : %h %s") + return ( + f"git -C {depot} log -1 --abbrev=12 --format={fmt} " + "2>/dev/null || true; " + ) + def _qemu_install_erplibre_monitored( self, names, @@ -286,9 +333,19 @@ class QemuDeployMixin: pve=None, meta=None, ai_agent="", + guet_hors_ligne=False, + deploy_started=None, + hors_ligne=None, ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. + `guet_hors_ligne` : l'amont du cache est coupé ; sa levée est confiée + au guet entre le lancement et l'ouverture du tableau de bord. + `deploy_started` : début du déploiement (epoch), recopié dans le + manifeste ; None n'y écrit rien. + `hors_ligne` : l'amont du cache était-il coupé pour ce déploiement ? + Recopié dans le manifeste, où le bilan hors ligne le lit ; None, pour + un appelant qui ne le sait pas, n'y écrit rien. `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). `final_cmd` : commande d'install selon le profil choisi. `prod` : install /opt/erplibre + service SELinux confiné. @@ -387,9 +444,21 @@ class QemuDeployMixin: if not vms: print(t("No VM to install.")) return + # Le mot-clé ne part que s'il porte une valeur : un lanceur de + # remplacement à trois arguments reste alors appelable. + debut = {} + if deploy_started is not None: + debut["deploy_started"] = deploy_started + if hors_ligne is not None: + debut["hors_ligne"] = bool(hors_ligne) manifest = launch_installs( - vms, branch_def or next(iter(branch_map.values()), ""), remote + vms, + branch_def or next(iter(branch_map.values()), ""), + remote, + **debut, ) + if guet_hors_ligne: + self._qemu_confier_la_levee(manifest) print(f"\n🖥 {t('Opening the interactive monitor...')}") # Affiche tous les chemins de log (pour les consulter/partager même si # on quitte le dashboard avant la fin). @@ -926,6 +995,9 @@ class QemuDeployMixin: for vm in spec["vms"] } varies = t("varies, see each line") + # L'écart se mesure sur les branches RÉELLES : « varie » n'est + # qu'un libellé, et le miroir n'a aucune branche de ce nom. + ecart_de = sorted(used_br) if len(used_br) > 1 else None br_txt = used_br.pop() if len(used_br) == 1 else varies lb_txt = used_lb.pop() if len(used_lb) == 1 else varies print( @@ -933,10 +1005,13 @@ class QemuDeployMixin: f"{t('profile')} {lb_txt}, {env}" ) # La VM ne reçoit pas CE checkout : elle CLONE la branche depuis - # le dépôt distant. Un correctif commité ici et non poussé n'y est - # donc pas, et le défaut « revient » alors qu'il est corrigé — - # vécu deux fois de suite sur install_proxmox.sh. - for ligne in self._qemu_branch_gap_lines(br_txt): + # le dépôt distant, ou hors ligne depuis le miroir du cache. Un + # correctif commité ici et non poussé n'y est donc pas, et le + # défaut « revient » alors qu'il est corrigé. + for ligne in self._qemu_branch_gap_lines( + br_txt if ecart_de is None else ecart_de, + hors_ligne=bool(spec.get("offline")), + ): print(f" {ligne}") else: print(f" {t('Install:')} {t('no')}") @@ -1037,6 +1112,8 @@ class QemuDeployMixin: gpu3d=False, git_name="", git_email="", + cache_ca="", + cache_bypass=False, ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -1076,6 +1153,16 @@ class QemuDeployMixin: # « on » et non « auto » : auto s'abstient sur une VM sans écran, # or c'est précisément ce que la case permet de demander. parts += ["--gpu", "on"] + if cache_bypass: + # L'exception l'emporte sur l'autorité : la VM ne rencontrera + # jamais le cache, lui faire approuver cette signature ne servirait + # à rien. deploy_qemu.py pose l'exception AVANT de créer la VM. + parts.append("--cache-bypass") + elif cache_ca: + # L'autorité du cache de téléchargement de l'hôte. La VM + # l'approuve dès son premier démarrage, sans quoi le détournement + # lui présente un certificat qu'elle rejette. + parts += ["--cache-ca", cache_ca] # L'identité git de la VM. Sans ces options, deploy_qemu recopie celle # de l'HÔTE : le formulaire la montre et permet de la changer, il ne # la remplace pas par du vide. @@ -1157,8 +1244,69 @@ class QemuDeployMixin: gpu3d=bool(spec.get("gpu3d")), git_name=spec.get("git_name") or "", git_email=spec.get("git_email") or "", + # L'autorité est posée dès que le service TOURNE, sans égard à + # la case : l'interception est transparente et vaut pour tout le + # pont, si bien qu'une VM privée de l'autorité est quand même + # détournée et échoue sur « self-signed certificate in + # certificate chain » à chaque téléchargement HTTPS. Le seul + # contournement vrai est d'arrêter le service, qui emporte ses + # règles avec lui. + # + # Le chemin est relu à chaque commande : désinstaller le cache + # entre deux déploiements ne doit pas laisser une VM approuver une + # autorité disparue. + cache_ca=( + self._qemu_cache_ca_path() if self._qemu_cache_active() else "" + ), + cache_bypass=bool(spec.get("cache_bypass")), ) + # Où l'installateur du cache pose son autorité. Un test compare cette + # valeur au défaut du script d'installation : les deux séparées, la case + # s'offrirait sans que la VM reçoive rien. + QEMU_CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt" + QEMU_CACHE_SERVICE = "erplibre-go-qemu-cache.service" + + @classmethod + def _qemu_cache_ca_path(cls): + """Chemin de l'autorité du cache, ou '' si le cache n'est pas posé. + + L'existence du FICHIER suffit à décider : une autorité approuvée alors + que le cache est arrêté ne coûte rien à la VM, qui télécharge en + direct. C'est l'inverse qui casse — un détournement actif sans + autorité dans l'invité. + """ + return cls.QEMU_CACHE_CA if os.path.isfile(cls.QEMU_CACHE_CA) else "" + + @classmethod + def _qemu_cache_active(cls): + """Le service du cache tourne-t-il ? Sert au DÉFAUT de la case. + + Jamais à l'offrir : c'est le fichier d'autorité qui décide de son + existence, et l'état d'un service change entre l'affichage du + formulaire et le déploiement. + """ + if not cls._qemu_cache_ca_path(): + return False + try: + return ( + subprocess.run( + [ + "systemctl", + "is-active", + "--quiet", + cls.QEMU_CACHE_SERVICE, + ], + check=False, + timeout=5, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + # Hôte sans systemd, ou systemctl injoignable : on ne prétend pas + # savoir, et la case s'offre décochée. + return False + def _qemu_arches_for(self, distro, arch): """Architectures à déployer pour cette distro selon le choix global. « all » = uniquement celles que la distro publie réellement.""" @@ -1392,6 +1540,15 @@ class QemuDeployMixin: if self._qemu_distro_profile(d) }, "ssh_key": self._qemu_default_ssh_key(), + # Le cache de téléchargement de CET hôte. Lu ici, comme le reste + # des mesures : l'écran ne doit rien interroger pendant qu'il + # affiche. Absent, le formulaire n'offre pas la case. + "cache_ca": self._qemu_cache_ca_path(), + "cache_active": self._qemu_cache_active(), + # La case « hors cache » ne s'offre que là où elle a un effet. + # Sans service actif rien n'intercepte, et une case sans effet + # est ce que ce menu a déjà eu, à tort. + "cache_offert": self._qemu_cache_active(), "host_cpu": os.cpu_count() or 2, "free_ram": self._host_free_ram_mb(), # La place du système de fichiers qui portera les qcow2. Mesurée @@ -1958,6 +2115,14 @@ class QemuDeployMixin: ) ) + # Posée seulement quand un cache tourne : ailleurs, la réponse ne + # changerait rien et la question ferait croire le contraire. + cache_bypass = False + if self._qemu_cache_active(): + cache_bypass = self._is_yes( + input(t("Keep this VM out of the download cache? (y/N): ")) + ) + # Ces trois réponses n'ont d'objet que si l'outil est coché : les # poser toujours ferait trois questions de plus à qui n'en veut pas. ai_agent, git_name, git_email = self._qemu_ask_ai_tools(vm_tools) @@ -2025,6 +2190,7 @@ class QemuDeployMixin: # décochée (voir _qemu_run_spec). "monitor": monitor, "gpu3d": gpu3d, + "cache_bypass": cache_bypass, "ai_agent": ai_agent, "git_name": git_name, "git_email": git_email, @@ -2122,12 +2288,406 @@ class QemuDeployMixin: except ImportError: return None + @contextlib.contextmanager + def _qemu_sans_internet(self, actif): + """Coupe l'amont du cache le temps du bloc, et le rebranche toujours. + + Deux sorties tombent : celle du service du cache, et celle que l'hôte + relaie pour les VM, et la résolution des noms par l'internet. Ce + qu'une VM demande à l'hôte lui-même reste joignable : le cache, et + les noms, auxquels l'hôte répond seul une adresse que le cache + intercepte. Tout ce qui arrive encore dans une VM vient donc du + disque du cache, et un pas qui prendrait un autre chemin échoue. + + La coupure vaut pour la spec ENTIÈRE, installation comprise : c'est + l'installation qui télécharge, et une coupure levée avant elle ne + mesurerait plus rien. + + Elle vaut aussi pour les AUTRES usagers du cache pendant ce temps — + un déploiement mené en parallèle depuis un autre terminal se + retrouvera hors ligne sans l'avoir demandé. + + La table est UNIQUE et partagée : deux coupures n'en font qu'une, et + la première levée ôte les deux. D'où le refus quand un guet tourne + encore — sa fin lèverait la coupure de ce déploiement-ci en cours de + route — et la question quand une table est posée sans guet. + + Sur la voie suivie, la levée est confiée au guet dès le lancement des + installations (`_qemu_confier_la_levee`) ; le « finally » ne la fait + alors pas, il dit jusqu'à quand l'amont reste coupé. Ailleurs, il lève + et VÉRIFIE : « rebranché » ne s'affiche que constaté. + + Hors ligne, un verrou de fichier tient le bloc ENTIER, relevés + compris : deux déploiements lancés ensemble passeraient sinon tous + deux les relevés avant que l'un ait posé son guet. Tant que la + coupure est tenue, SIGHUP et SIGTERM déroulent le « finally » au lieu + de tuer le processus sur place. La commande de coupure elle-même + tourne DANS le « try » qui lève : un signal reçu pendant qu'elle + s'exécute, la table déjà posée, trouve la levée sur son chemin. Seule + une coupure qui rend un échec n'est pas levée — le sudo qui l'a + refusée refuserait le retrait aussi, et redemanderait un mot de passe. + + En ligne, rien n'est coupé, mais une coupure tenue par un autre + déploiement ferait tourner celui-ci hors ligne : on le dit et on + demande (`_qemu_en_ligne_malgre_la_coupure`). + """ + if not actif: + if not self._qemu_en_ligne_malgre_la_coupure(): + raise _SansInternetImpossible() + yield False + return + from script.qemu import cache_offline + + # Sans dnsmasq, la coupure des noms ne peut pas se poser, et + # `cut_cmd` échouerait sans dire pourquoi : on le dit avant de rien + # toucher. + if not cache_offline.dnsmasq(): + print(f"\n ✗ {t('dnsmasq is missing on the host: names cannot')}") + print( + f" {t('be cut. Install the dnsmasq package, then F5.')} " + f"{t('Nothing deployed.')}" + ) + raise _SansInternetImpossible() + with self._qemu_verrou_hors_ligne() as libre: + if not libre: + print( + f"\n ✗ {t('Another offline deployment is starting or')}" + ) + print( + f" {t('running in another terminal.')} " + f"{t('Nothing deployed.')}" + ) + raise _SansInternetImpossible() + if not self._qemu_coupure_libre(): + raise _SansInternetImpossible() + anciens = self._qemu_signaux_de_sortie() + lever = True + try: + try: + if self._qemu_shell(cache_offline.cut_cmd()): + lever = False + # Refuser plutôt que déployer quand même : une VM + # bâtie avec l'amont debout se bâtit toujours, et son + # succès se lirait comme une preuve hors ligne qu'elle + # n'est pas. + print( + f"\n ✗ {t('Upstream not cut: nothing deployed.')}" + ) + faux = t( + "The result would look offline without being so." + ) + print(f" {faux}") + raise _SansInternetImpossible() + print( + f"\n ✂ {t('Cache upstream cut for this deployment.')}" + ) + yield True + finally: + if lever: + self._qemu_rebrancher() + finally: + self._qemu_signaux_rendus(anciens) + + @staticmethod + def _qemu_verrou_hors_ligne_chemin(): + """Fichier du verrou des déploiements hors ligne, dans le répertoire + des sessions d'installation.""" + from script.todo.qemu_install_monitor import session_dir + + return session_dir() / ".coupure-hors-ligne.lock" + + @contextlib.contextmanager + def _qemu_verrou_hors_ligne(self): + """Tient le verrou exclusif des déploiements hors ligne le temps du + bloc. Rend False s'il est déjà tenu ailleurs, True sinon. + + `flock` non bloquant : un second terminal est refusé sur-le-champ + plutôt que mis en attente d'un déploiement qui dure des heures. Le + verrou tombe avec le descripteur, donc aussi à la mort du processus, + même par SIGKILL : aucun reste à nettoyer. Le descripteur n'est pas + hérité (PEP 446) : les installations détachées ne le gardent pas. + + Un fichier impossible à ouvrir rend True : le verrou ne ferme que la + course entre deux terminaux, les relevés qui suivent tiennent encore. + """ + try: + fd = os.open( + self._qemu_verrou_hors_ligne_chemin(), + os.O_RDWR | os.O_CREAT, + 0o600, + ) + except OSError: + yield True + return + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + libre = True + except BlockingIOError: + libre = False + except OSError: + # Système de fichiers sans flock : même règle qu'un fichier + # impossible à ouvrir. + libre = True + try: + yield libre + finally: + os.close(fd) + + def _qemu_verrou_tenu_ailleurs(self): + """Un déploiement hors ligne tient-il le verrou ? Lecture seule. + + Le fichier n'est pas créé : absent, personne ne le tient. Le verrou + n'est pris qu'un instant pour le sonder, puis rendu. + """ + try: + fd = os.open(self._qemu_verrou_hors_ligne_chemin(), os.O_RDWR) + except OSError: + return False + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True + except OSError: + return False + finally: + os.close(fd) + return False + + def _qemu_en_ligne_malgre_la_coupure(self): + """Un déploiement EN LIGNE peut-il partir ? True si oui. + + La table est partagée par tout le cache : tant qu'un autre + déploiement la tient — son guet attend ses installations, ou il est + encore en train de se lancer —, celui-ci tournerait hors ligne sans + l'avoir demandé, et ses échecs de téléchargement se liraient comme + des pannes. On le dit et on demande, « non » par défaut. + + Aucun sudo ici : `guet_actif_cmd` lit l'état d'une unité, et le + verrou est un fichier de l'utilisateur. Sans guet ni verrou tenu, + rien n'est demandé. + """ + from script.qemu import cache_offline + + if self._qemu_shell(cache_offline.guet_actif_cmd()) == 0: + heures = cache_offline.DUREE_MAX_GUET // 3600 + print(f"\n ⚠ {t('An offline deployment is still installing.')}") + print(f" {t('The cache upstream stays cut until the last')}") + print(f" {t('installation ends, at most')} {heures} h.") + print(f" {t('This deployment would therefore run offline.')}") + print( + f" {t('Lift it now with:')} " + f"{cache_offline.lever_maintenant_cmd()}" + ) + elif self._qemu_verrou_tenu_ailleurs(): + print(f"\n ⚠ {t('An offline deployment is starting in another')}") + print( + f" {t('terminal: the cache upstream is cut until it ends.')}" + ) + print(f" {t('This deployment would therefore run offline.')}") + else: + return True + if not self._is_yes( + input(f" {t('Deploy anyway, offline? (y/N): ')}") + ): + print(f" {t('Nothing deployed.')}") + return False + return True + + @staticmethod + def _qemu_signaux_de_sortie(): + """Fait de SIGHUP et SIGTERM une sortie qui déroule les « finally ». + + Leur action par défaut tue le processus sur place : un terminal fermé + ou une session ssh perdue laisse alors la coupure posée sans fin. + Ici, le premier signal lève SystemExit(128 + signal) ; les suivants + sont sans effet, pour qu'un second signal n'interrompe pas la levée en + cours. Un signal déjà ignoré le reste : lancé sous « nohup », le + déploiement doit survivre à son terminal. + + Rend {signal: ancien gestionnaire}, à passer à `_qemu_signaux_rendus`. + Hors du fil principal, `signal.signal` lève ValueError : rien n'est + posé et le dictionnaire est vide. + """ + if threading.current_thread() is not threading.main_thread(): + return {} + deja = [] + + def sortir(signum, _frame): + if deja: + return + deja.append(signum) + raise SystemExit(128 + signum) + + anciens = {} + for nom in ("SIGHUP", "SIGTERM"): + num = getattr(signal, nom, None) + if num is None or signal.getsignal(num) == signal.SIG_IGN: + continue + try: + anciens[num] = signal.signal(num, sortir) + except (ValueError, OSError): + continue + return anciens + + @staticmethod + def _qemu_signaux_rendus(anciens): + """Remet les gestionnaires que rend `_qemu_signaux_de_sortie`. + + Un ancien gestionnaire posé hors de Python se lit None : il redevient + l'action par défaut, la seule qu'on sache rétablir. + """ + for num, ancien in anciens.items(): + try: + signal.signal( + num, signal.SIG_DFL if ancien is None else ancien + ) + except (ValueError, OSError, TypeError): + continue + + def _qemu_coupure_libre(self): + """Peut-on poser une coupure ? True si oui, False si rien ne doit + être déployé — l'appelant n'a plus rien à dire, tout est affiché. + + Un guet actif refuse d'office, que la table soit posée ou non : il la + retirera à la fin des installations qu'il attend, et celle de ce + déploiement avec. Une table posée sans guet est un reste — processus + tué, installation synchrone menée depuis un autre terminal — que + seul l'utilisateur peut juger : on demande, « non » par défaut. + """ + from script.qemu import cache_offline + + if self._qemu_shell(cache_offline.guet_actif_cmd()) == 0: + print( + f"\n ✗ {t('An offline deployment is still running: the cut')}" + ) + print( + f" {t('is shared, and its end would lift yours.')} " + f"{t('Nothing deployed.')}" + ) + print( + f" {t('Lift it now with:')} " + f"{cache_offline.lever_maintenant_cmd()}" + ) + return False + if self._qemu_shell(cache_offline.table_posee_cmd()) != 0: + return True + print(f"\n ⚠ {t('The cache upstream is already cut, and nothing')}") + print(f" {t('will lift it: an interrupted deployment, or one')}") + print(f" {t('still installing without the monitor.')}") + if not self._is_yes(input(f" {t('Lift it and continue? (y/N): ')}")): + print(f" {t('Nothing deployed.')}") + return False + self._qemu_shell(cache_offline.restore_cmd()) + return True + + def _qemu_rebrancher(self): + """Le « finally » de la coupure : lever, ou dire qui lèvera. + + Guet actif : les installations tournent encore, détachées, et c'est + lui qui lèvera à la dernière. Lever ici les ferait finir en ligne. + + Sinon, TOUJOURS lever — une coupure laissée en place prive le cache de + réseau bien après, et la panne se découvre ailleurs — puis constater : + `restore_cmd` rend 0 même quand sudo refuse, et seul un relevé de la + table dit si l'amont est revenu. + """ + from script.qemu import cache_offline + + if self._qemu_shell(cache_offline.guet_actif_cmd()) == 0: + heures = cache_offline.DUREE_MAX_GUET // 3600 + print(f"\n ✂ {t('The cache upstream stays cut until the last')}") + print( + f" {t('installation ends, at most')} {heures} h." + f" {t('Lift it now with:')}" + ) + print(f" {cache_offline.lever_maintenant_cmd()}") + return + self._qemu_shell(cache_offline.restore_cmd()) + if self._qemu_shell(cache_offline.table_posee_cmd()) == 1: + print(f" {t('Cache upstream restored.')}") + return + print(f"\n ✗ {t('The cache upstream may still be cut.')}") + print(f" {t('Lift it with:')} {cache_offline.restore_cmd()}") + + def _qemu_confier_la_levee(self, manifest): + """Confie la levée de la coupure à root, le temps des installations. + + Appelée juste après leur lancement et AVANT le tableau de bord : le + terminal est encore libre pour une invite de mot de passe. L'unité + attend le marqueur de sortie de chaque journal du manifeste, puis + lève ; elle survit à la fermeture du tableau de bord comme à la mort + de ce processus, et le tableau de bord reste détachable. + + Rend True si le guet est posé. En cas d'échec, la coupure reste au + « finally », c'est-à-dire à la fermeture du tableau de bord : le dire, + puisque le fermer avant la fin fait finir les installations en ligne. + """ + from script.qemu import cache_offline + from script.todo.qemu_install_monitor import EXIT_MARKER + + try: + with open(manifest, encoding="utf-8") as fh: + journaux = [vm["log"] for vm in json.load(fh)["vms"]] + except (OSError, ValueError, KeyError, TypeError): + journaux = None + heures = cache_offline.DUREE_MAX_GUET // 3600 + if ( + journaux + and cache_offline.chemins_surs(journaux) + and not self._qemu_shell( + cache_offline.guet_cmd(journaux, EXIT_MARKER) + ) + ): + print(f"\n ✂ {t('The cache upstream comes back when the last')}") + print( + f" {t('installation ends, at most')} {heures} h," + f" {t('even if the monitor is closed.')}" + ) + return True + print(f"\n ⚠ {t('Could not hand the lift over to systemd-run:')}") + print(f" {t('the cache upstream comes back when the monitor')}") + print( + f" {t('closes; closing it early finishes the installs online.')}" + ) + return False + + @staticmethod + def _qemu_shell(cmd, timeout=60): + """Code de retour d'une commande shell locale, 255 si elle n'a pas + pu être lancée du tout.""" + try: + return subprocess.run( + cmd, + shell=True, + capture_output=True, + text=True, + timeout=timeout, + ).returncode + except (OSError, subprocess.SubprocessError): + return 255 + def _qemu_run_spec(self, spec): + """Enveloppe le déploiement de la coupure d'amont qu'il demande. + + Séparée du déploiement lui-même : la spec entière doit tenir dans le + bloc, et un « with » autour de deux cents lignes déjà indentées se + relit mal. + """ + try: + with self._qemu_sans_internet(bool(spec.get("offline"))) as coupee: + return self._qemu_deploie_spec(spec, coupee=coupee) + except _SansInternetImpossible: + return + + def _qemu_deploie_spec(self, spec, coupee=False): """Exécute une spec de déploiement : création des VM en parallèle, résolution des IP, ~/.ssh/config, installation ERPLibre. Ne pose AUCUNE question — tous les choix sont dans la spec, d'où - qu'elle vienne (invites en ligne ou formulaire TUI).""" + qu'elle vienne (invites en ligne ou formulaire TUI). + + `coupee` : l'amont du cache est coupé autour de cet appel. La voie + suivie en confie alors la levée au guet, dès le lancement.""" pending = spec["vms"] deployed = list(spec.get("existing") or []) install = spec.get("install") @@ -2235,6 +2795,13 @@ class QemuDeployMixin: # de bord — rapporté, et c'est ce qui donnait « le suivi ne fonctionne # plus ». Le choix vient du déploiement, pas de l'installation. monitor = install["monitor"] if install else spec.get("monitor", True) + # Hors ligne, le suivi est d'office : seule sa voie confie la levée de + # la coupure au guet, qui la tient jusqu'à la fin de la dernière + # installation. Sans lui, la voie synchrone n'a aucun guet, et une spec + # sans rien à installer lèverait la coupure dès les IP connues, avant + # que cloud-init et l'agent invité aient fini de télécharger. + if spec.get("offline"): + monitor = True if install or desktop or monitor: if monitor: # Installs détachées en parallèle + dashboard Textual. @@ -2249,6 +2816,11 @@ class QemuDeployMixin: app_store=app_store, vm_tools=vm_tools, ai_agent=ai_agent, + guet_hors_ligne=coupee, + deploy_started=deploy_start, + # La coupure TENUE, et non la case de la spec : c'est + # elle qui fait qu'une réussite prouve le hors ligne. + hors_ligne=bool(coupee), ) elif install: print( diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 4e4b505..9ac7f99 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -31,6 +31,9 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté +# tel quel : les appelants historiques importent encore ces noms ICI. +from script.todo.deploy_form_extras import SERVER, ExtrasMixin from script.todo.deploy_form_lib import ( # noqa: F401 CLIP_LIMIT, CSS_BASE, @@ -54,6 +57,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401 expand_copies, fmt_dur, gib, + motifs_hors_ligne, parse_disk, parse_ram, plan_rows, @@ -67,10 +71,6 @@ from script.todo.deploy_form_lib import ( # noqa: F401 vm_name, vm_status, ) - -# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté -# tel quel : les appelants historiques importent encore ces noms ICI. -from script.todo.deploy_form_extras import SERVER, ExtrasMixin from script.todo.deploy_form_plan import ( # noqa: F401 PlanMixin, preview_screen, @@ -133,6 +133,11 @@ def run_deploy_form(ctx, run_app: bool = True): # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) defaults = ctx.get("defaults") or {} + # Le cache est-il en marche sur cet hôte ? Lu du CONTEXTE, comme les + # autres mesures de la machine, et non de « defaults » : celui-là ne porte + # que ce qu'on veut PRÉ-COCHER, et il est vide au premier affichage — la + # case ne paraissait donc jamais, quel que soit l'état du cache. + cache_offert = bool(ctx.get("cache_offert")) result = {"spec": None} AUTO = "__auto__" @@ -318,6 +323,12 @@ def run_deploy_form(ctx, run_app: bool = True): id="f_profile_install", ) yield from self.compose_install_extras() + # Aucune case pour le cache : l'interception est + # transparente et vaut pour tout le pont, donc une VM ne + # peut pas s'y soustraire. Décocher aurait produit une VM + # détournée SANS l'autorité, cassée sur chaque + # téléchargement HTTPS. Le seul contournement est + # d'arrêter le service, ce que le guide dit. yield from self.compose_timezone() yield Static("SSH", classes="grouptitle") yield Input( @@ -355,6 +366,15 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("gpu3d", False), id="f_gpu3d", ) + # Offerte seulement là où elle a un effet : sans cache + # actif, rien n'intercepte, et une case qui ne change + # rien apprend au lecteur une chose fausse. + if cache_offert: + yield Checkbox( + t("Keep this VM out of the download cache"), + value=defaults.get("cache_bypass", False), + id="f_cache_bypass", + ) # Révélés par la case « AI coding tools » du bloc des # outils : sans elle, ni l'agent ni l'identité git n'ont # d'objet, et trois widgets de plus encombrent un écran @@ -402,6 +422,68 @@ def run_deploy_form(ctx, run_app: bool = True): disabled=True, id="f_par", ) + # Offerte seulement là où le cache tourne : sans lui il + # n'y a pas d'amont à couper, et la case ne ferait rien. + # Elle coupe l'internet du cache, la sortie directe des VM + # et la résolution des noms par l'internet, le temps du + # déploiement ; les VM gardent l'hôte : le cache, et des + # noms que l'hôte leur répond seul. + if cache_offert: + yield Static( + t("Network"), + id="t_network", + classes="grouptitle", + ) + yield Checkbox( + t("No internet connection"), + value=defaults.get("offline", False), + id="f_offline", + ) + yield Static( + f" {t('Cuts internet for the cache and the VMs: proves')}" + ) + yield Static( + f" {t('the install builds from what the cache holds.')}" + ) + # Découvert par la case, comme le bloc IA : ce qui + # suit ne concerne que celui qui vient de la cocher. + yield Static( + f" ⚠ {t('The cut hits every user of the cache:')}", + id="t_offline_w1", + ) + yield Static( + f" {t('a deployment run from another terminal')}", + id="t_offline_w2", + ) + yield Static( + f" {t('goes offline too, without asking for it.')}", + id="t_offline_w3", + ) + # La fin de la coupure suit celle des installations, + # pas celle du tableau de bord : c'est ce qu'on ne + # devine pas, le tableau de bord étant détachable. + yield Static( + f" {t('Nothing is cut before F5: the upstream falls')}", + id="t_offline_w4", + ) + yield Static( + f" {t('at launch and comes back when the last install')}", + id="t_offline_w5", + ) + yield Static( + f" {t('ends (12 h at most), even with the monitor closed.')}", + id="t_offline_w6", + ) + # Dit pourquoi le suivi est grisé : seul le chemin + # suivi confie ce retour à une unité systemd. Sans + # lui, l'amont revient dès que les VM ont une adresse + # quand rien ne s'installe, ou reste coupé pour de + # bon si le terminal se ferme pendant une + # installation synchrone. + yield Static( + f" {t('The monitor stays ticked: it is what arms that return.')}", + id="t_offline_w7", + ) with Vertical(id="right"): # Une liste de widgets, pas un tableau : chaque VM porte # SES listes déroulantes, modifiables sur place. Un @@ -413,6 +495,37 @@ def run_deploy_form(ctx, run_app: bool = True): # Les widgets que la case « AI coding tools » découvre. _AI_WIDGETS = ("#t_ai", "#f_ai_agent", "#f_git_name", "#f_git_email") + # L'avertissement que la case « Sans connexion internet » découvre. + _OFFLINE_WIDGETS = tuple(f"#t_offline_w{n}" for n in range(1, 8)) + + def _sync_offline(self) -> None: + """Montre l'avertissement quand la coupure est demandée, et y + force le suivi. + + Il dit ce qu'on ne devine pas : la coupure vaut pour TOUS les + usagers du cache ; elle ne tombe qu'au lancement — cocher la case + ne coupe rien, l'écran reste utilisable — et ne se lève qu'à la + fin de la dernière installation, tableau de bord fermé ou non. + + Cette dernière promesse n'est tenue que par le déploiement suivi, + le seul qui confie la levée à une unité systemd. Le suivi est donc + coché et grisé tant que la case l'est ; la décocher le rend + modifiable, avec la valeur qu'il avait avant. + """ + case = self.query("#f_offline") + vu = bool(case) and bool(case.first(Checkbox).value) + for sel in self._OFFLINE_WIDGETS: + for widget in self.query(sel): + widget.display = vu + suivi = self.query_one("#f_monitor", Checkbox) + if vu and not suivi.disabled: + self._suivi_avant = suivi.value + suivi.value = True + suivi.disabled = True + elif not vu and suivi.disabled: + suivi.disabled = False + suivi.value = getattr(self, "_suivi_avant", True) + def _sync_ai(self) -> None: """Montre ou cache le bloc IA selon la case des outils. @@ -429,6 +542,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._reload_catalog(first_load=True) self._sync_install_deps() self._sync_ai() + self._sync_offline() # -- catalogue et recalcul ------------------------------------- # def _entries(self): @@ -932,6 +1046,8 @@ def run_deploy_form(ctx, run_app: bool = True): elif event.checkbox.id == "f_tool_aidev": self._sync_ai() self._recompute() + elif event.checkbox.id == "f_offline": + self._sync_offline() elif str(event.checkbox.id or "").startswith("f_tool_"): # Un IDE de plus, c'est un disque plus grand : le plan doit le # montrer AVANT de déployer, pas après une heure d'installation. @@ -955,6 +1071,18 @@ def run_deploy_form(ctx, run_app: bool = True): self._mount_rows() def _form_values(self): + # La case n'existe que si le cache tourne : la chercher toujours + # ferait lever le formulaire là où il n'y a pas de cache, + # c'est-à-dire sur la plupart des hôtes. + offline = bool( + self.query("#f_offline") # type: ignore[union-attr] + and self.query_one("#f_offline", Checkbox).value + ) + # Hors ligne, le suivi est exigé : seul son chemin confie la levée + # à une unité systemd. L'écran force déjà la case ; la valeur + # l'est aussi ici, pour qu'aucun état du widget ne mène à une + # coupure sans levée bornée. + monitor = offline or self.query_one("#f_monitor", Checkbox).value install = None if self.query_one("#f_install", Checkbox).value and profiles: index = self.query_one("#f_profile_install", Select).value @@ -964,15 +1092,23 @@ def run_deploy_form(ctx, run_app: bool = True): "prod": self.query_one("#f_prod", Checkbox).value, "label": label, "cmd": cmd, - "monitor": self.query_one("#f_monitor", Checkbox).value, + "monitor": monitor, } key = self.query_one("#f_key", Input).value.strip() return { # Le suivi est demandé au NIVEAU DU DÉPLOIEMENT, pas de # l'installation : décocher ERPLibre emportait la case avec # elle, et le tableau de bord ne s'ouvrait plus du tout. - "monitor": self.query_one("#f_monitor", Checkbox).value, + "monitor": monitor, "gpu3d": self.query_one("#f_gpu3d", Checkbox).value, + # La case n'existe que si le cache tourne : la chercher + # toujours ferait lever le formulaire là où il n'y a pas de + # cache, c'est-à-dire sur la plupart des hôtes. + "cache_bypass": bool( + self.query("#f_cache_bypass") # type: ignore[union-attr] + and self.query_one("#f_cache_bypass", Checkbox).value + ), + "offline": offline, "ai_agent": self.query_one("#f_ai_agent", Select).value, "git_name": self.query_one("#f_git_name", Input).value.strip(), "git_email": self.query_one( @@ -1073,6 +1209,23 @@ def run_deploy_form(ctx, run_app: bool = True): severity="warning", ) return + # Hors ligne : ce que le cache ne détient pas, aucune VM ne + # pourra le lire. Dit MAINTENANT, et non après une heure + # d'installation qui échoue sur « Impossible de trouver le + # paquet ». F5 à nouveau vaut passage outre — le journal peut + # avoir tourné, ou le cache avoir été rempli autrement. + if spec.get("offline") and not getattr( + self, "_offline_ack", False + ): + motifs = motifs_hors_ligne(spec["vms"]) + if motifs: + self._offline_ack = True + self.notify( + " — ".join(motifs + [t("press F5 again to confirm")]), + severity="error", + timeout=20, + ) + return orphans = [r for r in self.rows if r["state"] == "orphan"] if orphans and not getattr(self, "_orphan_ack", False): # Un qcow2 orphelin fait échouer deploy_qemu : on prévient une diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 04da228..97d9d2c 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -415,7 +415,18 @@ class QemuInstallMixin: L'attente dure jusqu'à 15 min et n'écrivait RIEN : sur une architecture émulée, le log restait muet un quart d'heure juste après avoir annoncé le début de l'installation, ce qui se lit comme un blocage. Deux lignes - l'encadrent, et le « status » final dit si elle a abouti ou expiré.""" + l'encadrent, et le « status » final dit si elle a abouti ou expiré. + + « cloud-init status --wait » NE SUFFIT PAS. La pose de l'agent invité + est lancée en service DÉTACHÉ — « systemd-run --no-block » — pour que + cloud-init rende la main en quelques secondes ; ce service, lui, fait + un « apt-get update » puis une installation, et garde donc le verrou + des paquets bien après que cloud-init s'est dit terminé. L'étape + suivante trouvait le verrou pris, échouait jusqu'à sa borne, puis + installait sur un index jamais rafraîchi : « Impossible de trouver le + paquet », un message qui n'accuse personne.""" + from script.qemu.deploy_qemu import cache_env_reload + return ( "if command -v cloud-init >/dev/null 2>&1; then " 'echo "== ' @@ -426,6 +437,17 @@ class QemuInstallMixin: + f'echo " {t("cloud-init:")} $(cloud-init status 2>/dev/null ' '| head -1)"; ' "fi; " + # Le service détaché de l'agent invité, s'il court encore. Le nom + # est celui que le déploiement lui donne ; « --collect » l'efface + # une fois fini, donc « is-active » redevient faux tout seul. + "if systemctl is-active --quiet erplibre-qga 2>/dev/null; then " + f'echo " {t("waiting for the guest agent install (apt lock)")}"; ' + "n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; " + "do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; " + "fi; " + # Les variables du cache sont écrites par cloud-init PENDANT + # l'attente : cette session, ouverte avant, ne les a pas reçues. + + cache_env_reload() + "; " ) @staticmethod @@ -468,6 +490,13 @@ class QemuInstallMixin: return "" return ( "if command -v apt-get >/dev/null 2>&1; then " + # Les SERVICES autant que les minuteurs. « disable --now » sur un + # minuteur l'empêche de repartir mais n'interrompt pas l'apt-get + # qu'il a DÉJÀ lancé : celui-ci garde /var/lib/apt/lists/lock + # jusqu'au bout de sa mise à jour, et l'installation qui suit + # répète « Impossible d'obtenir le verrou » pendant des minutes. + "sudo systemctl stop apt-daily.service apt-daily-upgrade.service " + ">/dev/null 2>&1 || true; " "sudo systemctl disable --now unattended-upgrades.service " "apt-daily.timer apt-daily-upgrade.timer " ">/dev/null 2>&1 || true; " @@ -669,8 +698,25 @@ class QemuInstallMixin: return ( f'echo "== {t("Installing the desktop (long):")} {label} =="; ' "if command -v apt-get >/dev/null 2>&1; then " - "n=0; until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " - "n=$((n+1)); [ $n -ge 30 ] && break; sleep 10; done; " + # « DPkg::Lock::Timeout » ne couvre PAS le verrou des listes : + # il ne vaut que pour celui de dpkg. « apt-get update » échoue + # donc en moins d'une seconde quand une tâche quotidienne le + # tient, et dormir dix secondes entre deux essais coûte des + # minutes à ne rien faire. On repasse plus souvent, et on rend la + # main dès que le verrou se libère. + # Bornée par le TEMPS, et non par un nombre d'essais. Un essai + # coûte moins d'une seconde quand le verrou est tenu, mais des + # MINUTES quand le cache répond 504 sur chaque index : soixante + # essais valaient alors des heures d'attente muette, là où on + # voulait cinq minutes. + "fin=$(( $(date +%s) + 300 )); " + "until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " + '[ "$(date +%s)" -ge "$fin" ] && ' + # Le dire ICI. Sans cette ligne, l'installation continue sur un + # index jamais rafraîchi et échoue plus bas sur « Impossible de + # trouver le paquet », qui accuse le dépôt et non le verrou. + f'{{ echo " ⚠ {t("apt-get update never succeeded in 5 min (lock held, or nothing served)")}"; ' + "break; }; sleep 2; done; " "sudo DEBIAN_FRONTEND=noninteractive " "apt-get -o DPkg::Lock::Timeout=600 install -y " f"{de['apt']} {rem['apt']['packages']} " @@ -1546,6 +1592,13 @@ class QemuInstallMixin: Le tout dans un groupe gardé : ni une panne de réseau ni une extension retirée du site ne doivent faire échouer une installation d'une heure. + + Deux échecs, deux messages. Un téléchargement raté — réseau coupé, + cache qui n'a pas l'archive — n'apprend rien sur la version de GNOME : + le site sert une archive même à une version qu'il ne connaît pas. Seul + un refus de « gnome-extensions install » la met en cause. Et l'appel à + se reconnecter ne vient que si au moins une extension a été posée : + sans quoi il n'y a rien à charger. """ uuids = " ".join(self._QEMU_GNOME_EXT_UUIDS) site = self._QEMU_GNOME_EXT_SITE @@ -1571,19 +1624,31 @@ class QemuInstallMixin: 'gx() { if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && ' "command -v dbus-run-session >/dev/null 2>&1; then " 'dbus-run-session -- gnome-extensions "$@"; ' - 'else gnome-extensions "$@"; fi; }; ' + f"for u in {uuids}; do " - # « || echo » DANS la substitution : un mktemp qui échoue rendrait - # l'affectation non nulle, et « set -e » couperait toute la suite. - + "z=$(mktemp -p /var/tmp gext-XXXX.zip || echo /var/tmp/gext.zip); " - + 'if curl -fsSL --max-time 120 "' + 'else gnome-extensions "$@"; fi; }; ' + # `n` compte les extensions réellement posées. + + f"n=0; for u in {uuids}; do " + # L'archive ne va que dans un nom tiré par mktemp, créé par ce + # compte seul : un nom fixe dans /var/tmp, ouvert à tous, pourrait + # y être posé d'avance par un autre. L'affectation est DANS la + # condition, si bien qu'un mktemp qui échoue ne fait pas tomber + # « set -e » ; « z » reste alors vide, et l'extension est sautée + # comme un téléchargement raté, sans rien à effacer. + + "if ! z=$(mktemp -p /var/tmp gext-XXXX.zip 2>/dev/null); " + + 'then z=""; fi; ' + + 'if [ -z "$z" ] || ! curl -fsSL --max-time 120 "' + site - + '/$u.shell-extension.zip?shell_version=$sv" -o "$z" ' - + '&& gx install --force "$z" >/dev/null 2>&1; then ' - + 'gx enable "$u" >/dev/null 2>&1 || true; ' - + f'echo " {t("installed and enabled:")} $u"; else ' + + '/$u.shell-extension.zip?shell_version=$sv" -o "$z"; then ' + + f'echo " ⚠ {t("download impossible (network or cache):")} ' + + '$u"; ' + + 'elif ! gx install --force "$z" >/dev/null 2>&1; then ' + f'echo " {t("not available for this GNOME, skipped:")} ' - + '$u (GNOME $sv)"; fi; rm -f "$z"; done; ' - + f'echo " {t("log out and back in to load them")}"; ' + + '$u (GNOME $sv)"; ' + + "else " + + 'gx enable "$u" >/dev/null 2>&1 || true; n=$((n+1)); ' + + f'echo " {t("installed and enabled:")} $u"; ' + + 'fi; if [ -n "$z" ]; then rm -f "$z"; fi; done; ' + + 'if [ "$n" -gt 0 ]; then ' + + f'echo " {t("log out and back in to load them")}"; fi; ' + "fi; } || true; " ) @@ -2164,6 +2229,13 @@ class QemuInstallMixin: « > {fichier}; " ) + def verdict(var, nom, repli=""): + # `var` reçoit le chemin du binaire : « command -v » d'abord, puis + # `repli`, le chemin où l'installateur le pose hors du PATH de ce + # shell. Aucune ligne ne peut faire tomber « set -e » : l'« if » + # absorbe le test, et la version se lit sous « timeout », sans + # entrée, comme une pose. + trouve = f'echo "{repli}"' if repli else "true" + return ( + f'{var}="$(command -v {nom} || {trouve})"; ' + f'if [ -x "${var}" ]; then echo " {nom}: ' + f'$(timeout 10 "${var}" --version &1' + ' | head -n 1 || true)"; ' + f'else echo " ⚠ {nom} {t("not installed (see above)")}"; ' + "fi; " + ) + return ( f'echo "== {t("AI coding tools")} =="; ' + self._qemu_pkg_install_cmd(self._QEMU_AIDEV_PKGS) @@ -2210,12 +2300,18 @@ class QemuInstallMixin: # commande distante a été figé au démarrage du shell SSH, avant que # l'installateur ne pose le binaire. Le code 127 qu'on obtiendrait # sinon ne dirait pas que le hook n'a pas été écrit. - + f'RTK="$(command -v rtk || echo "{local_bin}/rtk")"; ' + + verdict("RTK", "rtk", f"{local_bin}/rtk") + '[ -x "$RTK" ] && timeout 60 "$RTK" init --global' " /dev/null 2>&1 || true; " - + pose(dev_tools.STARSHIP_UPSTREAM_YES, 300) + # En root, starship atterrit dans /usr/local/bin, que le PATH de + # ce shell porte déjà : « command -v » le trouve sans repli. Ces + # 300 s ne bornent que curl : l'installateur root porte sa propre + # borne, plus courte, derrière sudo — voir STARSHIP_UPSTREAM_VM. + + pose(dev_tools.STARSHIP_UPSTREAM_VM, 300) + + verdict("STARSHIP", "starship") + une_fois(prompt, "starship init bash") + pose(commande, 600) + + verdict("AGENT", nom_agent, f"{repertoire}/{nom_agent}") + une_fois(local_line, local_bin) # Claude Code s'installe DANS ~/.local/bin : la ligne serait la # même, écrite deux fois dans le journal pour un seul effet. @@ -2403,7 +2499,20 @@ class QemuInstallMixin: défaut — même raison que pour cargo et rustc. Sans mise utilisable, rien n'est écrit : lib_python_provider.sh - retombe alors sur pyenv toute seule.""" + retombe alors sur pyenv toute seule. + + L'installateur est téléchargé dans un fichier, PUIS exécuté. Dans + « curl … | sh || repli », le statut du tube est celui de sh, qui rend + 0 sur une entrée vide : sans pipefail, le repli ne se déclencherait + jamais, et un téléchargement raté passerait pour une pose réussie. Le + statut de curl, lu seul, distingue les deux échecs — rien obtenu, + ou un installateur qui a échoué — et chacun a son message. Aucun ne + fait tomber « set -e » : ils sont testés dans un « if ». + + Le fichier ne s'obtient que de mktemp : un nom aléatoire, créé par ce + compte seul. Sans mktemp, rien n'est téléchargé. Un nom fixe dans + /tmp, qu'un autre compte peut créer d'avance, serait exécuté par + root.""" if python_provider == "pyenv": # Explicite : même si mise se trouvait déjà dans l'image, on ne # l'utilise pas. Sans cela le mode « auto » du dépôt le prendrait. @@ -2415,11 +2524,23 @@ class QemuInstallMixin: "if command -v mise >/dev/null 2>&1; then " 'echo " mise: $(mise --version)"; ' "else " + # L'affectation est DANS la condition : un mktemp qui échoue prend + # la branche du téléchargement impossible au lieu de faire tomber + # « set -e ». `f` y reste vide, et « rm -f "" » rend 0. + "if ! f=$(mktemp 2>/dev/null) " + '|| ! curl -fsSL https://mise.run -o "$f"; then ' + 'echo " ⚠ ' + + t( + "mise download impossible (network or cache): " + "pyenv will take over" + ) + + '"; ' # La variable est passée À sudo, pas exportée avant : « sudo -E » # dépend de env_reset dans sudoers et n'est pas garanti. - "curl -fsSL https://mise.run " - "| sudo MISE_INSTALL_PATH=/usr/local/bin/mise sh " - '|| echo " mise indisponible ici : pyenv prendra le relais"; ' + + 'elif ! sudo MISE_INSTALL_PATH=/usr/local/bin/mise sh "$f"' + " str: ) -def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: +def launch_installs( + vms: list[dict], + branch: str, + remote_cmd: str, + deploy_started: float | None = None, + hors_ligne: bool | None = None, +) -> str: """vms : [{name, ip, distro?, version?, arch?}]. Lance chaque install détachée, écrit un manifeste et retourne son chemin. remote_cmd : script - exécuté dans chaque VM.""" + exécuté dans chaque VM. + + `deploy_started` : instant (epoch, secondes) où le déploiement a commencé, + AVANT la création des VM. Écrit tel quel sous « deploy_started », et + seulement s'il est donné : « started » n'est posé qu'ici, après le + premier démarrage des VM, et une fenêtre ouverte à « started » laisse + dehors ce que cloud-init et l'agent invité ont déjà demandé au cache. + + `hors_ligne` : l'amont du cache était coupé pendant ce déploiement. + Écrit sous « offline », true ou false, et seulement s'il est donné : un + manifeste sans la clé ne dit pas s'il a tourné coupé, et le bilan hors + ligne le traite comme tel.""" sdir = session_dir() stamp = time.strftime("%Y%m%d-%H%M%S") when = time.strftime("%Y-%m-%d %H:%M:%S") @@ -423,6 +437,10 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: "started": time.time(), "vms": entries, } + if deploy_started is not None: + manifest["deploy_started"] = float(deploy_started) + if hors_ligne is not None: + manifest["offline"] = bool(hors_ligne) manifest_path = str(logdir / "session.json") Path(manifest_path).write_text(json.dumps(manifest, indent=2)) return manifest_path diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 84428c1..c768977 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -13,6 +13,7 @@ import subprocess import time from script.todo import ssh_config, todo_install +from script.todo.qemu_cache_menu import bypass_menage from script.todo.qemu_privilege import ( LIBVIRT_URI as URI, sudo_prefix, @@ -2569,6 +2570,14 @@ class QemuManageMixin: print(f" ⚠ {name} : {t('no disk file found for this VM')}") print(f"\n▶ {name}: {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) + # Une exception du cache survit à la VM qu'elle nommait, et une MAC + # libérée se réattribue : l'exception soustrairait alors au cache une + # machine neuve, sans que personne l'ait demandé et sans que rien ne le + # dise. Le ménage est fait ICI parce que c'est le seul endroit qui SAIT + # que la VM vient de disparaître. + retirees = bypass_menage(self.execute) + if retirees: + print(f" {t('Cache exceptions removed:')} {retirees}") print(f"\n✅ {t('Deletion done.')}") @staticmethod @@ -3311,8 +3320,163 @@ class QemuManageMixin: ] return len(sujets), sujets - def _qemu_branch_gap_lines(self, branche, limite=3): - """Les lignes à dire avant de déployer, ou [].""" + def _qemu_miroir_erplibre(self): + """Le miroir du cache qui sert le clone d'ERPLibre, ou "" s'il manque. + + Même chemin que celui que le cache calcule : //.git. Absent sur un hôte sans cache, ou tant qu'aucune + VM n'a cloné ERPLibre à travers lui. + """ + from urllib.parse import urlparse + + from script.todo.qemu_cache_menu import CACHE_MIROIR_GIT + + url = urlparse(self.ERPLIBRE_GIT_URL) + chemin = url.path.strip("/") + if chemin.endswith(".git"): + chemin = chemin[: -len(".git")] + if not url.netloc or not chemin: + return "" + miroir = os.path.join(CACHE_MIROIR_GIT, url.netloc, chemin + ".git") + return miroir if os.path.isdir(miroir) else "" + + def _qemu_ecart_miroir(self, branche): + """Ce qu'une VM hors ligne recevra, face à ce que l'hôte connaît. + + Hors ligne, le clone est servi par le miroir du cache TEL QUEL : son + rafraîchissement échoue, l'amont étant coupé. Ce que le miroir porte + est donc ce que la VM exécute, poussé ou non depuis. + + Rend None quand on ne sait pas — pas de miroir, miroir illisible — + et l'appelant retombe alors sur l'écart avec origin. Sinon + (sha_miroir, sha_ici, nombre) : `sha_miroir` vide si le miroir n'a + pas la branche ; `sha_ici` celui de la branche locale, à défaut + d'origin/, vide si l'hôte n'a ni l'une ni l'autre ; + `nombre` les commits de `sha_ici` absents du miroir, -1 si + incalculable. Tout est lu, rien n'est écrit : le miroir appartient + au compte du service. + + « ls-remote --exit-code » rend 2 quand aucune référence ne répond au + motif, et seul ce code vaut absence : une requête qui échoue autrement + rend None. Le motif se compare à la FIN des références, d'où la + recherche de la référence exacte dans ce qui revient. + """ + miroir = self._qemu_miroir_erplibre() if branche else "" + if not miroir: + return None + + def lancer(*args): + try: + return subprocess.run( + ["git", *args], capture_output=True, text=True, timeout=15 + ) + except (OSError, subprocess.SubprocessError): + return None + + def git(*args): + res = lancer(*args) + if res is None or res.returncode: + return None + return (res.stdout or "").strip() + + ref = f"refs/heads/{branche}" + vu = lancer("ls-remote", "--exit-code", miroir, ref) + if vu is None or vu.returncode not in (0, 2): + return None + sha_miroir = "" + if vu.returncode == 0: + for ligne in (vu.stdout or "").splitlines(): + champs = ligne.split() + if len(champs) == 2 and champs[1] == ref: + sha_miroir = champs[0] + break + sha_ici = "" + for ref in (f"refs/heads/{branche}", f"refs/remotes/origin/{branche}"): + sha_ici = git("rev-parse", "--verify", "-q", f"{ref}^{{commit}}") + if sha_ici: + break + sha_ici = sha_ici or "" + nombre = -1 + if sha_miroir and sha_ici and sha_miroir != sha_ici: + compte = git("rev-list", "--count", f"{sha_miroir}..{sha_ici}") + nombre = int(compte) if (compte or "").isdigit() else -1 + return sha_miroir, sha_ici, nombre + + @staticmethod + def _qemu_lignes_miroir(ecarts): + """Les lignes à dire face au miroir, pour [(branche, écart)]. + + `écart` est ce que rend `_qemu_ecart_miroir`, None compris. Chaque + ligne nomme sa branche. Une branche absente du miroir a sa ligne ; + une branche à jour, ou dont on ne sait rien, n'en a aucune. Le + conseil sur « git push » ne vient qu'une fois, et seulement si le + miroir porte, pour une branche au moins, un autre commit que l'hôte. + """ + lignes, en_retard = [], False + for branche, ecart in ecarts: + if ecart is None: + continue + sha_miroir, sha_ici, nombre = ecart + if not sha_miroir: + lignes.append( + f"⚠ {t('The cache mirror has no branch')} {branche}: " + f"{t('an offline clone will fail.')}" + ) + continue + if sha_miroir == sha_ici: + continue + en_retard = True + lignes.append( + f"⚠ {t('Offline, the VM clones the cache mirror of')} " + f"{branche}: {sha_miroir[:12]}" + ) + if sha_ici: + lignes.append( + f" {t('this checkout has')} {sha_ici[:12]}" + + ( + f", {nombre} {t('commit(s) missing from the mirror')}" + if nombre > 0 + else "" + ) + ) + if en_retard: + lignes.append( + f" → {t('A push alone changes nothing: the mirror is')}" + ) + lignes.append( + f" {t('refreshed only when a VM clones it online.')}" + ) + return lignes + + def _qemu_branch_gap_lines(self, branche, limite=3, hors_ligne=False): + """Les lignes à dire avant de déployer, ou []. + + `branche` : un nom, ou la collection des branches que les VM + clonent réellement. Plusieurs branches ne sont comparées qu'au + miroir, une par une, chaque ligne nommant la sienne : l'écart avec + origin se mesure contre HEAD, qui n'est qu'une seule branche, et lui + attribuerait les commits d'une autre. Une branche dont le miroir ne + dit rien reste alors muette. + + `hors_ligne` : la VM clonera depuis le miroir du cache. L'écart qui + compte est alors celui du miroir, et « git push » seul n'y change + rien : le miroir ne se rafraîchit que quand une VM le clone amont + branché. + """ + if isinstance(branche, str): + branches = [branche] + else: + branches = sorted({b for b in branche if b}) + if len(branches) != 1: + if not hors_ligne: + return [] + return self._qemu_lignes_miroir( + [(b, self._qemu_ecart_miroir(b)) for b in branches] + ) + branche = branches[0] + ecart = self._qemu_ecart_miroir(branche) if hors_ligne else None + if ecart is not None: + return self._qemu_lignes_miroir([(branche, ecart)]) nombre, sujets = self._qemu_branch_gap(branche) if not nombre: return [] diff --git a/script/todo/todo.py b/script/todo/todo.py index d0c9eba..1c2a81b 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -33,6 +33,7 @@ from script.todo.kdbx_manager import KdbxManager from script.todo.longtest_menu import LongTestMenuMixin from script.todo.proxmox_menu import ProxmoxMenuMixin from script.todo.qemu_access import QemuAccessMixin +from script.todo.qemu_cache_menu import QemuCacheMenuMixin from script.todo.qemu_deploy import QemuDeployMixin from script.todo.qemu_install import QemuInstallMixin from script.todo.qemu_manage import QemuManageMixin @@ -96,6 +97,7 @@ class TODO( # L'ordre est celui de la lecture, pas de la résolution : aucun nom n'est # défini deux fois (une classe unique jusqu'ici), donc aucune priorité à # arbitrer. Chaque fichier porte un sujet, et son en-tête dit sa frontière. + QemuCacheMenuMixin, QemuMenuMixin, QemuDeployMixin, QemuInstallMixin, @@ -424,7 +426,7 @@ class TODO( label += " - Installed" if odoo_version == odoo_installed_version: label += " - Actual" - if version_info.get("Default"): + if version_info.get("default"): label += " - Default" if version_info.get("is_deprecated"): label += " - Deprecated" @@ -606,6 +608,13 @@ class TODO( "prompt_execute_update": "Update", "prompt_execute_deploy": "Deploy", "prompt_execute_deploy_ssh": "SSH", + "prompt_execute_qemu_cache": "QEMU cache", + "_cache_service": "Service", + "_cache_tests": "Tests", + "_cache_age": "Age and cleanup", + "_cache_exceptions": "Exceptions", + "_cache_journaux": "Logs", + "_cache_miroir_git": "Git mirrors", "prompt_execute_qemu": "QEMU/KVM", "prompt_execute_proxmox": "Proxmox VE", "prompt_execute_vpn": "VPN", @@ -971,6 +980,11 @@ class TODO( "Deploy - Install NTFY notification server" ) }, + { + "prompt_description": t( + "QEMU cache - Download mirror for local VMs" + ) + }, {"section": t("VPN & tunnels")}, { "prompt_description": t( @@ -1000,6 +1014,8 @@ class TODO( elif status == "7": self._deploy_ntfy_server() elif status == "8": + self.prompt_execute_qemu_cache() + elif status == "9": self.prompt_execute_vpn() else: print(t("Command not found !")) @@ -1756,6 +1772,92 @@ class TODO( except Exception as e: print(f"{t('Error installing NTFY server: ')}{e}") + def _deploy_qemu_cache(self): + """Pose le miroir de téléchargement partagé par les VM QEMU de l'hôte. + + Idempotente : l'installateur recompile, réécrit l'unité et redémarre le + service, qu'il existe déjà ou non. + + Ce que l'entrée ANNONCE avant de demander sudo, et pourquoi : elle fait + écrire des règles sur le pont de l'hôte. Une règle trop large y prive la + machine de son propre réseau, et l'invite de sudo tombe entre deux + lignes de journal sans dire ce qu'elle sert à faire.""" + print( + f"\n{t('Install the download cache shared by the QEMU VMs of this host')}" + ) + + http_port = ( + input(t("HTTP port of the cache (default: 8898): ")).strip() + or "8898" + ) + tls_port = ( + input(t("TLS port of the cache (default: 8899): ")).strip() + or "8899" + ) + cache_dir = ( + input( + t( + "Cache directory (default: /var/cache/erplibre_go_qemu_cache): " + ) + ).strip() + or "/var/cache/erplibre_go_qemu_cache" + ) + + script_path = os.path.realpath( + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "install", + "install_qemu_cache.sh", + ) + ) + if not os.path.isfile(script_path): + print(f"{t('QEMU cache install script not found: ')}{script_path}") + return + + print(f"\n{t('Will write network rules on the host bridge:')}") + print(f" {t('Only what leaves the VM subnet is redirected')}") + print(f" {t('The rules exist only while the service runs')}") + if not shutil.which("go"): + print(f" {t('Go is absent; the installer lays it down')}") + print( + f" {t('No eviction is written: this cache never shrinks by itself')}" + ) + + cmd = ( + f"sudo EL_HTTP_PORT={http_port}" + f" EL_TLS_PORT={tls_port}" + f" EL_CACHE_DIR={cache_dir}" + f" bash {script_path}" + ) + print(f"\n{t('Will execute:')} {cmd}\n") + if not click.confirm(t("Install the QEMU download cache?")): + return + + print( + f"\n{t('Installing the QEMU download cache (requires sudo)...')}" + ) + try: + code = self.execute.exec_command_live(cmd, source_erplibre=False) + except Exception as e: + print(f"{t('The cache install failed, nothing is started')} : {e}") + return + # Le CODE de sortie, et pas seulement l'absence d'exception. Un + # installateur qui meurt — réseau libvirt absent, compilation qui + # cède — rend un code non nul sans rien lever, et l'entrée annonçait + # « installé et démarré » au-dessus de son propre message d'erreur. + # Rien n'est plus coûteux qu'un succès annoncé à tort : on cherche + # ensuite la panne partout sauf là où elle est. + if code: + print(f"\n ✗ {t('The cache install failed, nothing is started')}") + print(f" {t('Read the error above, fix it, and run entry 1.')}") + return + print(f"\n{t('QEMU download cache installed and started')}") + print( + f"{t('Certificate authority a VM must trust: ')}" + "/var/lib/erplibre_go_qemu_cache/ca.crt" + ) + @staticmethod def _ssh_config_hosts(): """Noms d'hôtes déclarés dans ~/.ssh/config, dans l'ordre du fichier. @@ -5262,7 +5364,7 @@ class TODO( label = f"{key_s}: {erplibre_version}" if odoo_version == odoo_installed_version: label += f" - {t('Current')}" - if version_info.get("Default"): + if version_info.get("default"): label += f" - {t('Default')}" environments[key_s] = { diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8e6ec0d..0b7599b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1395,6 +1395,755 @@ TRANSLATIONS = { "fr": "Script d'installation NTFY introuvable : ", "en": "NTFY install script not found: ", }, + # Cache de téléchargement des VM QEMU (erplibre_go_qemu_cache) + "QEMU cache - Download mirror for local VMs": { + "fr": "📦 Cache QEMU - Miroir de téléchargement des VM locales", + "en": "📦 QEMU cache - Download mirror for local VMs", + }, + "QEMU download cache for local VMs": { + "fr": "Cache de téléchargement QEMU pour les VM locales", + "en": "QEMU download cache for local VMs", + }, + "Cache - Install or reinstall": { + "fr": "📥 Cache - Installer ou réinstaller", + "en": "📥 Cache - Install or reinstall", + }, + "Cache - Diagnose: does it serve?": { + "fr": "🔍 Cache - Diagnostic : sert-il vraiment ?", + "en": "🔍 Cache - Diagnose: does it serve?", + }, + " or entry 3 of this menu, which does it for you.": { + "fr": " ou l'entrée 3 de ce menu, qui le fait pour vous.", + "en": " or entry 3 of this menu, which does it for you.", + }, + "Cache - Service state": { + "fr": "⚙ Cache - État du service", + "en": "⚙ Cache - Service state", + }, + "State of the cache service": { + "fr": "État du service du cache", + "en": "State of the cache service", + }, + "Stopping it removes the rules: no VM is redirected.": { + "fr": "L'arrêter retire les règles : plus aucune VM n'est détournée.", + "en": "Stopping it removes the rules: no VM is redirected.", + }, + "starts at boot": {"fr": "démarre au boot", "en": "starts at boot"}, + "not at boot": {"fr": "pas au boot", "en": "not at boot"}, + "Service - Start (start)": { + "fr": "▶ Service - Démarrer (start)", + "en": "▶ Service - Start (start)", + }, + "Service - Start at boot (enable)": { + "fr": "🔗 Service - Démarrer au boot (enable)", + "en": "🔗 Service - Start at boot (enable)", + }, + "Service - Do not start at boot (disable)": { + "fr": "🚫 Service - Ne plus démarrer au boot (disable)", + "en": "🚫 Service - Do not start at boot (disable)", + }, + "Service - Stop (stop)": { + "fr": "⏹ Service - Arrêter (stop)", + "en": "⏹ Service - Stop (stop)", + }, + "Service - Detailed state (status)": { + "fr": "📋 Service - État détaillé (status)", + "en": "📋 Service - Detailed state (status)", + }, + "Service - Logs (log)": { + "fr": "📜 Service - Journal (log)", + "en": "📜 Service - Logs (log)", + }, + "Access log, last requests:": { + "fr": "Journal d'accès, dernières requêtes :", + "en": "Access log, last requests:", + }, + " Interception is transparent and covers the whole bridge:": { + "fr": " Le détournement est transparent et vaut pour tout le pont :", + "en": " Interception is transparent and covers the whole bridge:", + }, + " a VM cannot opt out from the inside. Omitting the authority": { + "fr": " une VM ne peut pas s'y soustraire de l'intérieur. Retirer l'autorité", + "en": " a VM cannot opt out from the inside. Omitting the authority", + }, + " does not bypass anything — the VM is redirected all the same": { + "fr": " ne contourne rien — la VM est détournée quand même et échoue", + "en": " does not bypass anything — the VM is redirected all the same", + }, + " and fails on « self-signed certificate in chain ».": { + "fr": " sur « self-signed certificate in certificate chain ».", + "en": " and fails on « self-signed certificate in chain ».", + }, + " For ONE VM: tick « keep this VM out of the cache » when": { + "fr": " Pour UNE VM : cocher « soustraire cette VM au cache » au", + "en": " For ONE VM: tick « keep this VM out of the cache » when", + }, + " deploying, or pass --cache-bypass. Its MAC address is fixed": { + "fr": " déploiement, ou passer --cache-bypass. Son adresse MAC est fixée", + "en": " deploying, or pass --cache-bypass. Its MAC address is fixed", + }, + " before creation and an exception is posted on the host, so": { + "fr": " avant la création et une exception est posée sur l'hôte : plus", + "en": " before creation and an exception is posted on the host, so", + }, + " nothing redirects it. Entry 4 lists them; an exception whose": { + "fr": " rien ne la détourne. L'entrée 4 les liste ; une exception dont", + "en": " nothing redirects it. Entry 4 lists them; an exception whose", + }, + " VM is gone must be removed, a freed MAC being reused.": { + "fr": " la VM n'existe plus est à retirer, une MAC libérée se réattribuant.", + "en": " VM is gone must be removed, a freed MAC being reused.", + }, + " For EVERY VM: stop the service, entry 3 or": { + "fr": " Pour TOUTES les VM : arrêter le service, entrée 3 ou", + "en": " For EVERY VM: stop the service, entry 3 or", + }, + "Cache - VMs kept out of the cache": { + "fr": "🎫 Cache - Les VM soustraites au cache", + "en": "🎫 Cache - VMs kept out of the cache", + }, + "Exceptions - Remove the stale ones": { + "fr": "🧹 Exceptions - Retirer celles dont la VM n'existe plus", + "en": "🧹 Exceptions - Remove the stale ones", + }, + "Exceptions - Remove one by its MAC": { + "fr": "✂ Exceptions - En retirer une par sa MAC", + "en": "✂ Exceptions - Remove one by its MAC", + }, + "VMs kept out of the download cache": { + "fr": "Les VM soustraites au cache de téléchargement", + "en": "VMs kept out of the download cache", + }, + "VM gone": { + "fr": "VM disparue", + "en": "VM gone", + }, + "No exception: every VM goes through the cache.": { + "fr": "Aucune exception : toutes les VM passent par le cache.", + "en": "No exception: every VM goes through the cache.", + }, + "Tick the box when deploying to add one.": { + "fr": "Cocher la case au déploiement pour en poser une.", + "en": "Tick the box when deploying to add one.", + }, + "A freed MAC gets reused: such an entry would": { + "fr": "Une MAC libérée se réattribue : une telle entrée soustrairait", + "en": "A freed MAC gets reused: such an entry would", + }, + "quietly keep a NEW VM out of the cache.": { + "fr": "au cache une VM NEUVE, sans que rien ne le dise.", + "en": "quietly keep a NEW VM out of the cache.", + }, + "Nothing is stale.": { + "fr": "Rien n'est orphelin.", + "en": "Nothing is stale.", + }, + "MAC to give back to the cache": { + "fr": "MAC à rendre au cache", + "en": "MAC to give back to the cache", + }, + "Keep this VM out of the download cache? (y/N): ": { + "fr": "Soustraire cette VM au cache de téléchargement ? (o/N) : ", + "en": "Keep this VM out of the download cache? (y/N): ", + }, + "Keep this VM out of the download cache": { + "fr": "Soustraire cette VM au cache de téléchargement", + "en": "Keep this VM out of the download cache", + }, + "Cache exceptions removed:": { + "fr": "Exceptions du cache retirées :", + "en": "Cache exceptions removed:", + }, + "Exceptions:": {"fr": "Exceptions :", "en": "Exceptions:"}, + "with no VM left": { + "fr": "sans VM", + "en": "with no VM left", + }, + "Remove them from entry 4 of this menu.": { + "fr": "Les retirer depuis l'entrée 4 de ce menu.", + "en": "Remove them from entry 4 of this menu.", + }, + "This destroys the machines of this test and their disks.": { + "fr": "Cela détruit les machines de ce test et leurs disques.", + "en": "This destroys the machines of this test and their disks.", + }, + "Destroy the machines of this test?": { + "fr": "Détruire les machines de ce test ?", + "en": "Destroy the machines of this test?", + }, + "Machines created:": { + "fr": "Machines créées :", + "en": "Machines created:", + }, + "Test - Choose and run": { + "fr": "🧭 Test - Choisir et lancer", + "en": "🧭 Test - Choose and run", + }, + "Which test?": { + "fr": "Quel essai ?", + "en": "Which test?", + }, + "The cache: two VMs, measure the gain": { + "fr": "⚡ Le cache : deux VM, mesurer le gain", + "en": "⚡ The cache: two VMs, measure the gain", + }, + "The offline counter-proof": { + "fr": "📴 La contre-épreuve hors ligne", + "en": "📴 The offline counter-proof", + }, + "The control: two VMs WITHOUT the cache": { + "fr": "⚖ Le témoin : deux VM SANS le cache", + "en": "⚖ The control: two VMs WITHOUT the cache", + }, + "All three, one after another": { + "fr": "🔁 Les trois, l'un après l'autre", + "en": "🔁 All three, one after another", + }, + "Which load?": { + "fr": "Quelle charge ?", + "en": "Which load?", + }, + "Minimum: a batch of packages, minutes": { + "fr": "🪶 Minimum : un lot de paquets, quelques minutes", + "en": "🪶 Minimum: a batch of packages, minutes", + }, + "ERPLibre + Odoo 18: the real thing, hours": { + "fr": "🏗 ERPLibre + Odoo 18 : l'installation réelle, des heures", + "en": "🏗 ERPLibre + Odoo 18: the real thing, hours", + }, + "Which system?": { + "fr": "Quel système ?", + "en": "Which system?", + }, + "About to run, one after another:": { + "fr": "Va exécuter, l'un après l'autre :", + "en": "About to run, one after another:", + }, + "The real load takes hours per VM, not minutes.": { + "fr": "La charge réelle prend des heures par VM, pas des minutes.", + "en": "The real load takes hours per VM, not minutes.", + }, + "Run these long tests?": { + "fr": "Lancer ces tests longs ?", + "en": "Run these long tests?", + }, + "Git mirrors:": { + "fr": "Miroirs git :", + "en": "Git mirrors:", + }, + "Git is mirrored, not cached": { + "fr": "Git est mis en miroir, pas caché", + "en": "Git is mirrored, not cached", + }, + " Git's protocol is a negotiation: the server computes its": { + "fr": " Le protocole git est une négociation : le serveur calcule sa", + "en": " Git's protocol is a negotiation: the server computes its", + }, + " answer from what the client already holds, so no answer is": { + "fr": " réponse d'après ce que le client détient déjà, si bien qu'aucune", + "en": " answer from what the client already holds, so no answer is", + }, + " reusable. A bare mirror per upstream repo is kept instead,": { + "fr": " réponse ne se réutilise. Un miroir nu par dépôt amont est tenu à la", + "en": " reusable. A bare mirror per upstream repo is kept instead,", + }, + " and served locally — which also works with no network.": { + "fr": " place, et servi localement — ce qui marche aussi sans réseau.", + "en": " and served locally — which also works with no network.", + }, + " A mirror is COMPLETE: it weighs what the upstream repo": { + "fr": " Un miroir est COMPLET : il pèse ce que pèse le dépôt amont,", + "en": " A mirror is COMPLETE: it weighs what the upstream repo", + }, + " weighs, history included.": { + "fr": " historique compris.", + "en": " weighs, history included.", + }, + " Neither the objects nor the mirrors shrink by themselves,": { + "fr": " Ni les objets ni les miroirs ne diminuent d'eux-mêmes, et les deux", + "en": " Neither the objects nor the mirrors shrink by themselves,", + }, + " and both live on the orchestrator's disk. The diagnosis": { + "fr": " vivent sur le disque de l'orchestrateur. Le diagnostic dit ce", + "en": " and both live on the orchestrator's disk. The diagnosis", + }, + " entry says what each of the two occupies.": { + "fr": " qu'occupe chacun des deux.", + "en": " entry says what each of the two occupies.", + }, + "Cache - Git mirrors: fill them ahead": { + "fr": "🪞 Cache - Miroirs git : prendre l'avance", + "en": "🪞 Cache - Git mirrors: fill them ahead", + }, + "Git mirrors of the ERPLibre manifests": { + "fr": "Miroirs git des manifestes ERPLibre", + "en": "Git mirrors of the ERPLibre manifests", + }, + "Already mirrored:": { + "fr": "Déjà en miroir :", + "en": "Already mirrored:", + }, + "No repository found in manifest/": { + "fr": "Aucun dépôt trouvé dans manifest/", + "en": "No repository found in manifest/", + }, + "A mirror is complete: this can take tens of GiB": { + "fr": "Un miroir est complet : cela peut prendre des dizaines de Gio", + "en": "A mirror is complete: this can take tens of GiB", + }, + "and hours on the first run. Nothing erases it.": { + "fr": "et des heures au premier passage. Rien ne l'efface.", + "en": "and hours on the first run. Nothing erases it.", + }, + "Free space:": { + "fr": "Place libre :", + "en": "Free space:", + }, + "Fill the git mirrors now?": { + "fr": "Remplir les miroirs git maintenant ?", + "en": "Fill the git mirrors now?", + }, + "Mirrors - Fill the base of the active Odoo version": { + "fr": "📥 Miroirs - Remplir la base de la version d'Odoo active", + "en": "📥 Mirrors - Fill the base of the active Odoo version", + }, + "Mirrors - Fill the extra of the active Odoo version": { + "fr": "🧩 Miroirs - Remplir l'extra de la version d'Odoo active", + "en": "🧩 Mirrors - Fill the extra of the active Odoo version", + }, + "Mirrors - Fill every manifest, all versions": { + "fr": "📚 Miroirs - Remplir tous les manifestes, toutes versions", + "en": "📚 Mirrors - Fill every manifest, all versions", + }, + "Base of Odoo": { + "fr": "Base d'Odoo", + "en": "Base of Odoo", + }, + "Extra of Odoo": { + "fr": "Extra d'Odoo", + "en": "Extra of Odoo", + }, + "Every manifest, all versions": { + "fr": "Tous les manifestes, toutes versions", + "en": "Every manifest, all versions", + }, + "repositories declared": { + "fr": "dépôts déclarés", + "en": "repositories declared", + }, + "without a mirror": { + "fr": "sans miroir", + "en": "without a mirror", + }, + "No .odoo-version: no active version to fill.": { + "fr": "Aucun .odoo-version : pas de version active à remplir.", + "en": "No .odoo-version: no active version to fill.", + }, + "Nothing declared for this version.": { + "fr": "Rien n'est déclaré pour cette version.", + "en": "Nothing declared for this version.", + }, + "Extra modules install only with --with_extra.": { + "fr": "Les modules extra ne s'installent qu'avec --with_extra.", + "en": "Extra modules install only with --with_extra.", + }, + "Mirrors - List them, heaviest first": { + "fr": "📋 Miroirs - Les lister, du plus lourd au plus léger", + "en": "📋 Mirrors - List them, heaviest first", + }, + "Mirrors - Remove one": { + "fr": "🧹 Miroirs - En effacer un", + "en": "🧹 Mirrors - Remove one", + }, + "Repository to remove (as listed)": { + "fr": "Dépôt à effacer (tel qu'affiché)", + "en": "Repository to remove (as listed)", + }, + "It will be mirrored again when a VM needs it.": { + "fr": "Il sera remis en miroir dès qu'une VM en aura besoin.", + "en": "It will be mirrored again when a VM needs it.", + }, + "Remove this mirror?": { + "fr": "Effacer ce miroir ?", + "en": "Remove this mirror?", + }, + " weighs, history included, and a few repositories make": { + "fr": " historique compris, et quelques dépôts font l'essentiel du", + "en": " weighs, history included, and a few repositories make", + }, + " most of the total. Entry 5 lists them heaviest first and": { + "fr": " total. L'entrée 5 les liste du plus lourd au plus léger et en", + "en": " most of the total. Entry 5 lists them heaviest first and", + }, + " removes one — it comes back at the next need.": { + "fr": " efface un — il revient au prochain besoin.", + "en": " removes one — it comes back at the next need.", + }, + " and served locally. A mirror already held serves with no": { + "fr": " place, et servi localement. Un miroir déjà détenu sert sans", + "en": " and served locally. A mirror already held serves with no", + }, + " network at all — but NOT what the cache may not decrypt:": { + "fr": " aucun réseau — mais PAS ce que le cache n'a pas le droit de", + "en": " network at all — but NOT what the cache may not decrypt:", + }, + " npm and poetry carry their own trust store, so they are": { + "fr": " déchiffrer : npm et poetry portent leur propre magasin, passent", + "en": " npm and poetry carry their own trust store, so they are", + }, + " tunnelled, and a tunnel carries nothing once cut.": { + "fr": " en tunnel, et un tunnel ne porte rien une fois coupé.", + "en": " tunnelled, and a tunnel carries nothing once cut.", + }, + "Cache - Age and cleanup": { + "fr": "🧭 Cache - Âge et nettoyage", + "en": "🧭 Cache - Age and cleanup", + }, + "Age of the cache, and cleanup": { + "fr": "Âge du cache, et nettoyage", + "en": "Age of the cache, and cleanup", + }, + "Age - By day": { + "fr": "📅 Âge - Par jour", + "en": "📅 Age - By day", + }, + "Age - By week": { + "fr": "🗓 Âge - Par semaine", + "en": "🗓 Age - By week", + }, + "Age - By month": { + "fr": "📆 Âge - Par mois", + "en": "📆 Age - By month", + }, + "Clean - What has not served for a while": { + "fr": "🧹 Nettoyer - Ce qui n'a plus servi depuis un temps", + "en": "🧹 Clean - What has not served for a while", + }, + "Clean - Everything": { + "fr": "🔥 Nettoyer - Tout", + "en": "🔥 Clean - Everything", + }, + "Not served since (e.g. 30j, 12h)": { + "fr": "Plus servi depuis (ex. 30j, 12h)", + "en": "Not served since (e.g. 30j, 12h)", + }, + "Erase what is listed above?": { + "fr": "Effacer ce qui est listé ci-dessus ?", + "en": "Erase what is listed above?", + }, + "This empties the objects AND the git mirrors.": { + "fr": "Cela vide les objets ET les miroirs git.", + "en": "This empties the objects AND the git mirrors.", + }, + "Refilling the mirrors takes minutes to hours.": { + "fr": "Remplir de nouveau les miroirs prend des minutes à des heures.", + "en": "Refilling the mirrors takes minutes to hours.", + }, + "Erase the whole cache?": { + "fr": "Effacer tout le cache ?", + "en": "Erase the whole cache?", + }, + " Entry 6 groups them by AGE OF LAST USE — an object served": { + "fr": " L'entrée 6 les groupe par ÂGE DU DERNIER USAGE — un objet servi", + "en": " Entry 6 groups them by AGE OF LAST USE — an object served", + }, + " has its date renewed, so « old » means « no longer used »": { + "fr": " voit sa date remise à jour, « vieux » veut donc dire « n'a plus", + "en": " has its date renewed, so « old » means « no longer used »", + }, + " — and gives back what has not served for a while, or all.": { + "fr": " servi » — et rend ce qui ne sert plus, ou tout.", + "en": " — and gives back what has not served for a while, or all.", + }, + "What each VM pulled:": { + "fr": "Ce que chaque VM a tiré :", + "en": "What each VM pulled:", + }, + "from disk": { + "fr": "du disque", + "en": "from disk", + }, + "upstream": { + "fr": "de l'amont", + "en": "upstream", + }, + "Cache - Guide: how it works": { + "fr": "📖 Cache - Guide : comment il fonctionne", + "en": "📖 Cache - Guide: how it works", + }, + "Cache - Tests and performance report": { + "fr": "🧪 Cache - Tests et rapport de performance", + "en": "🧪 Cache - Tests and performance report", + }, + "Diagnosis of the QEMU download cache": { + "fr": "Diagnostic du cache de téléchargement QEMU", + "en": "Diagnosis of the QEMU download cache", + }, + "Service:": {"fr": "Service :", "en": "Service:"}, + "Service is running": {"fr": "actif", "en": "running"}, + "Service is stopped": {"fr": "arrêté", "en": "stopped"}, + "Not installed:": {"fr": "Non installé :", "en": "Not installed:"}, + "Install it from entry 1 of this menu.": { + "fr": "L'installer depuis l'entrée 1 de ce menu.", + "en": "Install it from entry 1 of this menu.", + }, + "Redirection:": {"fr": "Détournement :", "en": "Redirection:"}, + "the cache": {"fr": "le cache", "en": "the cache"}, + "No redirection rule is posted": { + "fr": "Aucune règle de détournement n'est posée", + "en": "No redirection rule is posted", + }, + "Cannot tell where the rules point: reading nft needs a sudo password here.": { + "fr": "Impossible de savoir où pointent les règles : lire nft exige un mot de passe sudo ici.", + "en": "Cannot tell where the rules point: reading nft needs a sudo password here.", + }, + "MISMATCH — rules on": { + "fr": "DÉSACCORD — règles sur", + "en": "MISMATCH — rules on", + }, + "libvirt serves": {"fr": "libvirt sert", "en": "libvirt serves"}, + "Reinstall: the cache reads libvirt by itself.": { + "fr": "Réinstaller : le cache lit libvirt tout seul.", + "en": "Reinstall: the cache reads libvirt by itself.", + }, + "Authority:": {"fr": "Autorité :", "en": "Authority:"}, + "What the cache has done:": { + "fr": "Ce que le cache a fait :", + "en": "What the cache has done:", + }, + "The access log is empty: nothing has gone through the cache.": { + "fr": "Le journal d'accès est vide : rien n'a traversé le cache.", + "en": "The access log is empty: nothing has gone through the cache.", + }, + "A VM that installs while this stays at zero does not use it.": { + "fr": "Une VM qui installe pendant que cela reste à zéro ne l'utilise pas.", + "en": "A VM that installs while this stays at zero does not use it.", + }, + "How the QEMU download cache works": { + "fr": "Comment fonctionne le cache de téléchargement QEMU", + "en": "How the QEMU download cache works", + }, + " Two VMs of the same distribution pull the same packages.": { + "fr": " Deux VM de la même distribution tirent les mêmes paquets.", + "en": " Two VMs of the same distribution pull the same packages.", + }, + " The cache keeps what comes down and serves the copy to the next.": { + "fr": " Le cache garde ce qui descend et sert la copie à la suivante.", + "en": " The cache keeps what comes down and serves the copy to the next.", + }, + "What is served from disk": { + "fr": "Ce qui est servi du disque", + "en": "What is served from disk", + }, + " A package file: its name carries its version, so it never changes.": { + "fr": " Un fichier de paquet : son nom porte sa version, il ne change jamais.", + "en": " A package file: its name carries its version, so it never changes.", + }, + " The host name is ignored: a rotating mirror list still hits.": { + "fr": " Le nom d'hôte est ignoré : une liste de miroirs qui tourne sert quand même.", + "en": " The host name is ignored: a rotating mirror list still hits.", + }, + "What is always taken from upstream": { + "fr": "Ce qui est toujours repris à l'amont", + "en": "What is always taken from upstream", + }, + " A repository index: it names the versions that exist right now.": { + "fr": " Un index de dépôt : il nomme les versions qui existent en ce moment.", + "en": " A repository index: it names the versions that exist right now.", + }, + " Serving a stale one makes an install fail on a withdrawn package.": { + "fr": " En servir un périmé fait échouer une installation sur un paquet retiré.", + "en": " Serving a stale one makes an install fail on a withdrawn package.", + }, + " It is stored anyway, and only comes back out when upstream is mute.": { + "fr": " Il est stocké quand même, et ne ressort que si l'amont est muet.", + "en": " It is stored anyway, and only comes back out when upstream is mute.", + }, + "Where things live": { + "fr": "Où vivent les choses", + "en": "Where things live", + }, + "Objects:": {"fr": "Objets :", "en": "Objects:"}, + "Settings:": {"fr": "Réglages :", "en": "Settings:"}, + "Access log:": {"fr": "Journal d'accès :", "en": "Access log:"}, + "No eviction is written": { + "fr": "Aucune éviction n'est écrite", + "en": "No eviction is written", + }, + " This cache never shrinks by itself, and it lives on the": { + "fr": " Ce cache ne diminue jamais de lui-même, et il vit sur le", + "en": " This cache never shrinks by itself, and it lives on the", + }, + " orchestrator's disk. Watch it with the diagnosis entry.": { + "fr": " disque de l'orchestrateur. Le surveiller par le diagnostic.", + "en": " orchestrator's disk. Watch it with the diagnosis entry.", + }, + "Turning it off": {"fr": "Le désactiver", "en": "Turning it off"}, + " Interception is transparent and covers the whole bridge: a VM": { + "fr": " L'interception est transparente et couvre tout le pont : une VM", + "en": " Interception is transparent and covers the whole bridge: a VM", + }, + " cannot opt out of it. Omitting the authority does not bypass": { + "fr": " ne peut pas s'y soustraire. Omettre l'autorité ne contourne pas", + "en": " cannot opt out of it. Omitting the authority does not bypass", + }, + " the cache — the VM is redirected all the same, and fails on": { + "fr": " le cache — la VM est détournée quand même, et échoue sur", + "en": " the cache — the VM is redirected all the same, and fails on", + }, + " « self-signed certificate in certificate chain ».": { + "fr": " « self-signed certificate in certificate chain ».", + "en": " « self-signed certificate in certificate chain ».", + }, + " The only true bypass stops the service:": { + "fr": " Le seul contournement vrai arrête le service :", + "en": " The only true bypass stops the service:", + }, + " The rules leave with it, so no VM stays redirected.": { + "fr": " Les règles partent avec lui : aucune VM ne reste détournée.", + "en": " The rules leave with it, so no VM stays redirected.", + }, + "Proxmox": {"fr": "Proxmox", "en": "Proxmox"}, + " A Proxmox host that is itself a VM of this orchestrator crosses": { + "fr": " Un hôte Proxmox qui est lui-même une VM d'ici traverse ce pont :", + "en": " A Proxmox host that is itself a VM of this orchestrator crosses", + }, + " this bridge: the machines it carries come out behind its address,": { + "fr": " les machines qu'il porte sortent derrière son adresse, le cache", + "en": " this bridge: the machines it carries come out behind its address,", + }, + " so the cache serves them, and the deployment poses the authority": { + "fr": " les sert donc, et le déploiement pose l'autorité dans chacune.", + "en": " so the cache serves them, and the deployment poses the authority", + }, + " in each of them. A host that lives elsewhere is not concerned —": { + "fr": " Un hôte qui vit ailleurs n'est pas concerné — y installer le", + "en": " in each of them. A host that lives elsewhere is not concerned —", + }, + " install the cache ON it, the script being generic and Proxmox a": { + "fr": " cache, le script étant générique et Proxmox un Debian. Réserve :", + "en": " install the cache ON it, the script being generic and Proxmox a", + }, + " Debian. Reserve: a bridge switched onto the LAN is only seen by": { + "fr": " un pont basculé sur le LAN n'est vu par les règles que si", + "en": " Debian. Reserve: a bridge switched onto the LAN is only seen by", + }, + " the rules when br_netfilter is enabled.": { + "fr": " br_netfilter est activé.", + "en": " the rules when br_netfilter is enabled.", + }, + "Cache tests: real VMs, several minutes": { + "fr": "Tests du cache : de vraies VM, quelques minutes", + "en": "Cache tests: real VMs, several minutes", + }, + "Test - The plan only (dry-run)": { + "fr": "📋 Test - Le plan seul (à blanc)", + "en": "📋 Test - The plan only (dry-run)", + }, + "Test - Two VMs, measure the gain": { + "fr": "📊 Test - Deux VM, mesurer le gain", + "en": "📊 Test - Two VMs, measure the gain", + }, + "Test - Add the offline counter-proof": { + "fr": "🔌 Test - Ajouter la contre-épreuve hors ligne", + "en": "🔌 Test - Add the offline counter-proof", + }, + "Test - Control run: two VMs WITHOUT the cache": { + "fr": "⚖ Test - Témoin : deux VM SANS le cache", + "en": "⚖ Test - Control run: two VMs WITHOUT the cache", + }, + "Test - Performance report": { + "fr": "📈 Test - Rapport de performance", + "en": "📈 Test - Performance report", + }, + "Test - Undo the machines created": { + "fr": "🧹 Test - Défaire les machines créées", + "en": "🧹 Test - Undo the machines created", + }, + "The control run measures what NOT caching costs.": { + "fr": "Le témoin mesure ce que coûte l'ABSENCE de cache.", + "en": "The control run measures what NOT caching costs.", + }, + "This creates real VMs and takes a while.": { + "fr": "Cela crée de vraies VM et prend du temps.", + "en": "This creates real VMs and takes a while.", + }, + "Run this long test?": { + "fr": "Lancer ce test long ?", + "en": "Run this long test?", + }, + "Install the download cache shared by the QEMU VMs of this host": { + "fr": "Installer le cache de téléchargement partagé par les VM QEMU de cet hôte", + "en": "Install the download cache shared by the QEMU VMs of this host", + }, + "HTTP port of the cache (default: 8898): ": { + "fr": "Port HTTP du cache (défaut : 8898) : ", + "en": "HTTP port of the cache (default: 8898): ", + }, + "TLS port of the cache (default: 8899): ": { + "fr": "Port TLS du cache (défaut : 8899) : ", + "en": "TLS port of the cache (default: 8899): ", + }, + "Cache directory (default: /var/cache/erplibre_go_qemu_cache): ": { + "fr": "Répertoire du cache (défaut : /var/cache/erplibre_go_qemu_cache) : ", + "en": "Cache directory (default: /var/cache/erplibre_go_qemu_cache): ", + }, + "Will write network rules on the host bridge:": { + "fr": "Va écrire des règles réseau sur le pont de l'hôte :", + "en": "Will write network rules on the host bridge:", + }, + "Only what leaves the VM subnet is redirected": { + "fr": "Seul ce qui sort du sous-réseau des VM est détourné", + "en": "Only what leaves the VM subnet is redirected", + }, + "The rules exist only while the service runs": { + "fr": "Les règles n'existent que pendant que le service tourne", + "en": "The rules exist only while the service runs", + }, + "Go is absent; the installer lays it down": { + "fr": "Go est absent ; l'installateur le pose", + "en": "Go is absent; the installer lays it down", + }, + "Install the QEMU download cache?": { + "fr": "Installer le cache de téléchargement QEMU ?", + "en": "Install the QEMU download cache?", + }, + "Installing the QEMU download cache (requires sudo)...": { + "fr": "Installation du cache de téléchargement QEMU (sudo requis)...", + "en": "Installing the QEMU download cache (requires sudo)...", + }, + "QEMU download cache installed and started": { + "fr": "Cache de téléchargement QEMU installé et démarré", + "en": "QEMU download cache installed and started", + }, + "Certificate authority a VM must trust: ": { + "fr": "Autorité de certification qu'une VM doit approuver : ", + "en": "Certificate authority a VM must trust: ", + }, + "The cache install failed, nothing is started": { + "fr": "L'installation du cache a échoué, rien n'est démarré", + "en": "The cache install failed, nothing is started", + }, + "QEMU cache install script not found: ": { + "fr": "Script d'installation du cache QEMU introuvable : ", + "en": "QEMU cache install script not found: ", + }, + "No eviction is written: this cache never shrinks by itself": { + "fr": "Aucune éviction n'est écrite : ce cache ne diminue jamais de lui-même", + "en": "No eviction is written: this cache never shrinks by itself", + }, + "Download cache: plan only (dry-run)": { + "fr": "📦 Cache de téléchargement : le plan seul (à blanc)", + "en": "📦 Download cache: plan only (dry-run)", + }, + "Download cache: two VMs, measure": { + "fr": "📦 Cache de téléchargement : deux VM, et la mesure", + "en": "📦 Download cache: two VMs, measure", + }, + "Download cache: measure, then cut the upstream": { + "fr": "📦 Cache de téléchargement : mesurer, puis couper l'amont", + "en": "📦 Download cache: measure, then cut the upstream", + }, # QEMU / KVM (libvirt) VM deployment "QEMU/KVM - Deploy an Ubuntu VM (libvirt)": { "fr": "💻 QEMU/KVM - Déployer une VM Ubuntu (libvirt)", @@ -3589,48 +4338,48 @@ TRANSLATIONS = { "en": "Selection (number): ", }, "Deploy a VM on the Proxmox host": { - "fr": "Déployer une VM sur l'hôte Proxmox", - "en": "Deploy a VM on the Proxmox host", + "fr": "🚀 Déployer une VM sur l'hôte Proxmox", + "en": "🚀 Deploy a VM on the Proxmox host", }, "Preview a deployment (dry-run, nothing sent)": { - "fr": "Prévisualiser un déploiement (dry-run, rien n'est envoyé)", - "en": "Preview a deployment (dry-run, nothing sent)", + "fr": "🔍 Prévisualiser un déploiement (dry-run, rien n'est envoyé)", + "en": "🔍 Preview a deployment (dry-run, nothing sent)", }, "Download a cloud image on the host": { - "fr": "Télécharger une image cloud sur l'hôte", - "en": "Download a cloud image on the host", + "fr": "📥 Télécharger une image cloud sur l'hôte", + "en": "📥 Download a cloud image on the host", }, "List VMs (qm list)": { - "fr": "Lister les VM (qm list)", - "en": "List VMs (qm list)", + "fr": "📋 Lister les VM (qm list)", + "en": "📋 List VMs (qm list)", }, "Clean up (orphan disks)": { - "fr": "Nettoyer (disques orphelins)", - "en": "Clean up (orphan disks)", + "fr": "🧹 Nettoyer (disques orphelins)", + "en": "🧹 Clean up (orphan disks)", }, "Statistics (host and VMs)": { - "fr": "Statistiques (hôte et VM)", - "en": "Statistics (host and VMs)", + "fr": "📊 Statistiques (hôte et VM)", + "en": "📊 Statistics (host and VMs)", }, "Remote desktop tunnel (VNC/RDP over SSH)": { - "fr": "Tunnel bureau distant (VNC/RDP par SSH)", - "en": "Remote desktop tunnel (VNC/RDP over SSH)", + "fr": "🖥 Tunnel bureau distant (VNC/RDP par SSH)", + "en": "🖥 Remote desktop tunnel (VNC/RDP over SSH)", }, "List available images and their specs": { - "fr": "Lister les images disponibles et leurs specs", - "en": "List available images and their specs", + "fr": "🗂 Lister les images disponibles et leurs specs", + "en": "🗂 List available images and their specs", }, "Proxmox - example sequence (dry-run)": { - "fr": "Proxmox - exemple de séquence (dry-run)", - "en": "Proxmox - example sequence (dry-run)", + "fr": "🎬 Proxmox - exemple de séquence (dry-run)", + "en": "🎬 Proxmox - example sequence (dry-run)", }, "Host": { - "fr": "Hôte", - "en": "Host", + "fr": "🏠 Hôte", + "en": "🏠 Host", }, "Change the Proxmox host": { - "fr": "Changer d'hôte Proxmox", - "en": "Change the Proxmox host", + "fr": "🔀 Changer d'hôte Proxmox", + "en": "🔀 Change the Proxmox host", }, "VM name (default: erplibre-): ": { "fr": "Nom de la VM (défaut : erplibre-) : ", @@ -5674,10 +6423,694 @@ TRANSLATIONS = { "fr": "Chemin de la clé publique SSH", "en": "SSH public key path", }, + "not installed (see above)": { + "fr": "non installé (voir ci-dessus)", + "en": "not installed (see above)", + }, + "download impossible (network or cache):": { + "fr": "téléchargement impossible (réseau ou cache) :", + "en": "download impossible (network or cache):", + }, + "mise download impossible (network or cache): pyenv will take over": { + "fr": "téléchargement de mise impossible (réseau ou cache) : pyenv prendra le relais", + "en": "mise download impossible (network or cache): pyenv will take over", + }, + "mise installer failed: pyenv will take over": { + "fr": "l'installateur de mise a échoué : pyenv prendra le relais", + "en": "mise installer failed: pyenv will take over", + }, "timezone not set": { "fr": "fuseau non posé", "en": "timezone not set", }, + "No internet connection": { + "fr": "Sans connexion internet", + "en": "No internet connection", + }, + "Cache - Fill what offline runs lacked": { + "fr": "🩹 Cache - Combler ce qui a manqué hors ligne", + "en": "🩹 Cache - Fill what offline runs lacked", + }, + "Cache - Logs": { + "fr": "📜 Cache - Journaux", + "en": "📜 Cache - Logs", + }, + "Read the error above, fix it, and run entry 1.": { + "fr": "Lire l'erreur ci-dessus, la corriger, et relancer l'entrée 1.", + "en": "Read the error above, fix it, and run entry 1.", + }, + "Cache - Copy it to another machine": { + "fr": "📦 Cache - L'emporter sur une autre machine", + "en": "📦 Cache - Copy it to another machine", + }, + "Copy the cache to another machine": { + "fr": "Emporter le cache sur une autre machine", + "en": "Copy the cache to another machine", + }, + "Target machine (user@host, or an ssh alias)": { + "fr": "Machine d'arrivée (utilisateur@hôte, ou un alias ssh)", + "en": "Target machine (user@host, or an ssh alias)", + }, + "The target has no cache installed:": { + "fr": "La machine d'arrivée n'a pas le cache :", + "en": "The target has no cache installed:", + }, + "Cannot reach it over ssh:": { + "fr": "Injoignable par ssh :", + "en": "Cannot reach it over ssh:", + }, + "This entry needs a password-less ssh access:": { + "fr": "Cette entrée exige un accès ssh sans mot de passe :", + "en": "This entry needs a password-less ssh access:", + }, + "If it asks for a password, post a key there:": { + "fr": "S'il réclame un mot de passe, y déposer une clé :", + "en": "If it asks for a password, post a key there:", + }, + "Entry 1 installs the cache HERE; the target needs its own.": { + "fr": "L'entrée 1 pose le cache ICI ; l'arrivée doit avoir le sien.", + "en": "Entry 1 installs the cache HERE; the target needs its own.", + }, + "Steps, ON the target machine:": { + "fr": "Les gestes, SUR la machine d'arrivée :", + "en": "Steps, ON the target machine:", + }, + "go to its ERPLibre checkout, then:": { + "fr": "aller dans son checkout ERPLibre, puis :", + "en": "go to its ERPLibre checkout, then:", + }, + "without that branch, the installer is not there": { + "fr": "sans cette branche, l'installateur n'y est pas", + "en": "without that branch, the installer is not there", + }, + "the branch used here": { + "fr": "la branche utilisée ici", + "en": "the branch used here", + }, + "sudo asks for a password on the target:": { + "fr": "sudo réclame un mot de passe à l'arrivée :", + "en": "sudo asks for a password on the target:", + }, + "The store travels on ssh stdin, which carries no terminal,": { + "fr": "Le magasin voyage dans l'entrée de ssh, qui n'a pas de terminal,", + "en": "The store travels on ssh stdin, which carries no terminal,", + }, + "so nothing can type it. Two ways out:": { + "fr": "donc rien ne peut le taper. Deux issues :", + "en": "so nothing can type it. Two ways out:", + }, + "1) Allow it there without a password, once:": { + "fr": "1) L'y autoriser sans mot de passe, une fois :", + "en": "1) Allow it there without a password, once:", + }, + "2) Carry it in two steps, the last one in your terminal there:": { + "fr": "2) L'emporter en deux temps, le dernier dans ton terminal là-bas :", + "en": "2) Carry it in two steps, the last one in your terminal there:", + }, + "needed there:": { + "fr": "place requise :", + "en": "needed there:", + }, + "free there:": { + "fr": "place libre :", + "en": "free there:", + }, + "Not enough room there: the two-step mode is out.": { + "fr": "Place insuffisante là-bas : le mode en deux temps est exclu.", + "en": "Not enough room there: the two-step mode is out.", + }, + "Send it now, and print the command to finish there?": { + "fr": "Envoyer maintenant, et afficher la commande à finir là-bas ?", + "en": "Send it now, and print the command to finish there?", + }, + "The send failed; nothing was extracted.": { + "fr": "L'envoi a échoué ; rien n'a été extrait.", + "en": "The send failed; nothing was extracted.", + }, + "Sent. To finish, ON the target machine, in a terminal:": { + "fr": "Envoyé. Pour finir, SUR la machine d'arrivée, en terminal :", + "en": "Sent. To finish, ON the target machine, in a terminal:", + }, + "The last command removes the file.": { + "fr": "La dernière commande retire le fichier.", + "en": "The last command removes the file.", + }, + "or, in its own TODO: Execute > Deploy > QEMU cache, entry 1": { + "fr": "ou, dans son TODO : Exécuter > Déploiement > Cache QEMU, entrée 1", + "en": "or, in its own TODO: Execute > Deploy > QEMU cache, entry 1", + }, + "The installer reads the « default » libvirt network to find": { + "fr": "L'installateur lit le réseau libvirt « default » pour trouver", + "en": "The installer reads the « default » libvirt network to find", + }, + "the bridge; a stopped libvirt makes it die on « not found »:": { + "fr": "le pont ; libvirt arrêté, il meurt sur un « introuvable » :", + "en": "the bridge; a stopped libvirt makes it die on « not found »:", + }, + "Or name the bridge by hand, libvirt being optional then:": { + "fr": "Ou nommer le pont à la main, libvirt devenant facultatif :", + "en": "Or name the bridge by hand, libvirt being optional then:", + }, + "Then come back to this entry.": { + "fr": "Puis revenir à cette entrée.", + "en": "Then come back to this entry.", + }, + "The cache is there but its service account is not:": { + "fr": "Le cache est là, mais pas son compte de service :", + "en": "The cache is there but its service account is not:", + }, + "Reinstall it there: the installer creates the account.": { + "fr": "L'y réinstaller : l'installateur crée le compte.", + "en": "Reinstall it there: the installer creates the account.", + }, + "Carrying it to another machine": { + "fr": "L'emporter sur une autre machine", + "en": "Carrying it to another machine", + }, + " Entry 11 copies the STORE, not the service: an object is": { + "fr": " L'entrée 11 copie le MAGASIN, pas le service : un objet est", + "en": " Entry 11 copies the STORE, not the service: an object is", + }, + " keyed by URL and a git mirror is a repository, so both are": { + "fr": " rangé sous une clé d'URL et un miroir git est un dépôt : les", + "en": " keyed by URL and a git mirror is a repository, so both are", + }, + " worth the same elsewhere. The settings stay here — bridge,": { + "fr": " deux valent ailleurs. Les réglages restent ici — le pont,", + "en": " worth the same elsewhere. The settings stay here — bridge,", + }, + " subnet and authority belong to the host that serves them.": { + "fr": " le sous-réseau et l'autorité sont à l'hôte qui les sert.", + "en": " subnet and authority belong to the host that serves them.", + }, + " The target must already carry the cache, installed from": { + "fr": " L'arrivée doit déjà porter le cache, posé depuis SON propre", + "en": " The target must already carry the cache, installed from", + }, + " ITS own checkout, and answer ssh without a password.": { + "fr": " checkout, et répondre à ssh sans mot de passe.", + "en": " ITS own checkout, and answer ssh without a password.", + }, + "What travels:": { + "fr": "Ce qui voyage :", + "en": "What travels:", + }, + "objects": { + "fr": "objets", + "en": "objects", + }, + "git mirrors": { + "fr": "miroirs git", + "en": "git mirrors", + }, + "The settings stay here: bridge, subnet and authority": { + "fr": "Les réglages restent ici : pont, sous-réseau et autorité", + "en": "The settings stay here: bridge, subnet and authority", + }, + "belong to this host, and are posed by entry 1 there.": { + "fr": "appartiennent à cet hôte ; l'entrée 1 les pose là-bas.", + "en": "belong to this host, and are posed by entry 1 there.", + }, + "Copy now?": { + "fr": "Emporter maintenant ?", + "en": "Copy now?", + }, + "Logs of the download cache": { + "fr": "Journaux du cache de téléchargement", + "en": "Logs of the download cache", + }, + "Ctrl-C ends a live follow.": { + "fr": "Ctrl-C arrête un suivi en direct.", + "en": "Ctrl-C ends a live follow.", + }, + "Logs - Requests, live": { + "fr": "Journaux - Les requêtes, en direct", + "en": "Logs - Requests, live", + }, + "Logs - Only requests that went to the internet, live": { + "fr": "Journaux - Seulement les requêtes sorties sur l'internet, en direct", + "en": "Logs - Only requests that went to the internet, live", + }, + "Logs - Last 40 requests": { + "fr": "Journaux - Les 40 dernières requêtes", + "en": "Logs - Last 40 requests", + }, + "Logs - Service journal, live": { + "fr": "Journaux - Journal du service, en direct", + "en": "Logs - Service journal, live", + }, + "No access log yet:": { + "fr": "Pas encore de journal d'accès :", + "en": "No access log yet:", + }, + "GPU groups set": { + "fr": "groupes du GPU posés", + "en": "GPU groups set", + }, + "apt mirror pinned": { + "fr": "miroir apt fixé", + "en": "apt mirror pinned", + }, + "the cache has no package named": { + "fr": "le cache n'a aucun paquet nommé", + "en": "the cache has no package named", + }, + "an offline VM will not install it": { + "fr": "une VM hors ligne ne l'installera pas", + "en": "an offline VM will not install it", + }, + "these git repositories are not mirrored:": { + "fr": "ces dépôts git ne sont pas en miroir :", + "en": "these git repositories are not mirrored:", + }, + "an offline VM cannot clone them": { + "fr": "une VM hors ligne ne peut pas les cloner", + "en": "an offline VM cannot clone them", + }, + "fill them from Cache › Git mirrors": { + "fr": "les remplir depuis Cache › Miroirs git", + "en": "fill them from Cache › Git mirrors", + }, + "cache holds no index for": { + "fr": "le cache n'a aucun index pour", + "en": "cache holds no index for", + }, + "those packages will not be found": { + "fr": "ces paquets ne seront pas trouvés", + "en": "those packages will not be found", + }, + "waiting for the VM to answer ssh": { + "fr": "attente que la VM réponde en ssh", + "en": "waiting for the VM to answer ssh", + }, + "No ssh answer: guest left as created.": { + "fr": "Pas de réponse ssh : l'invité reste tel qu'il est né.", + "en": "No ssh answer: guest left as created.", + }, + "No 3D: the host lacks": { + "fr": "Pas de 3D : il manque à l'hôte", + "en": "No 3D: the host lacks", + }, + "Install on the host": { + "fr": "Installer sur l'hôte", + "en": "Install on the host", + }, + "3D is now available: the box is here.": { + "fr": "La 3D est maintenant possible : la case est là.", + "en": "3D is now available: the box is here.", + }, + "Still missing on the host:": { + "fr": "Il manque encore à l'hôte :", + "en": "Still missing on the host:", + }, + "apt mirror not pinned": { + "fr": "miroir apt non fixé", + "en": "apt mirror not pinned", + }, + "GPU groups not set": { + "fr": "groupes du GPU non posés", + "en": "GPU groups not set", + }, + " Entry 10 follows it live: a request that goes out to the": { + "fr": " L'entrée 10 le suit en direct : une requête qui sort sur", + "en": " Entry 10 follows it live: a request that goes out to the", + }, + " internet shows there, and a cut leaves that view empty.": { + "fr": " l'internet s'y voit, et une coupure y laisse le vide.", + "en": " internet shows there, and a cut leaves that view empty.", + }, + "What offline runs lacked": { + "fr": "Ce qui a manqué hors ligne", + "en": "What offline runs lacked", + }, + "Start it from entry 3 of this menu.": { + "fr": "Le démarrer depuis l'entrée 3 de ce menu.", + "en": "Start it from entry 3 of this menu.", + }, + "A replay now would only record more misses.": { + "fr": "Un rejeu maintenant ne ferait qu'ajouter des manques.", + "en": "A replay now would only record more misses.", + }, + "No offline miss in the recent window: nothing to fill.": { + "fr": "Aucun manque hors ligne récent : rien à combler.", + "en": "No offline miss in the recent window: nothing to fill.", + }, + "Everything that was missed is held now.": { + "fr": "Tout ce qui a manqué est désormais détenu.", + "en": "Everything that was missed is held now.", + }, + "according to the log: a purge can make it wrong": { + "fr": "selon le journal : une purge peut le rendre faux", + "en": "according to the log: a purge can make it wrong", + }, + "replay through the cache": { + "fr": "à rejouer par le cache", + "en": "replay through the cache", + }, + "never kept: the cache keeps only GET and HEAD": { + "fr": "jamais gardée : le cache ne garde que GET et HEAD", + "en": "never kept: the cache keeps only GET and HEAD", + }, + "the cache does not keep this address": { + "fr": "le cache ne garde pas cette adresse", + "en": "the cache does not keep this address", + }, + "host in tunnel: nothing to keep": { + "fr": "hôte en tunnel : rien à garder", + "en": "host in tunnel: nothing to keep", + }, + "git negotiation: fill the mirror from entry 5": { + "fr": "négociation git : remplir le miroir depuis l'entrée 5", + "en": "git negotiation: fill the mirror from entry 5", + }, + "not a host name: a replay could loop back into the cache": { + "fr": "pas un nom d'hôte : le rejeu pourrait revenir dans le cache", + "en": "not a host name: a replay could loop back into the cache", + }, + "Nothing here can be replayed.": { + "fr": "Rien ici ne peut être rejoué.", + "en": "Nothing here can be replayed.", + }, + "The replay sends curl's own headers: a server that varies on": { + "fr": "Le rejeu envoie les en-têtes de curl : un serveur qui varie selon", + "en": "The replay sends curl's own headers: a server that varies on", + }, + "User-Agent or Accept may keep another answer than the VM's.": { + "fr": "User-Agent ou Accept peut garder une autre réponse que celle de la VM.", + "en": "User-Agent or Accept may keep another answer than the VM's.", + }, + "Replay these addresses through the cache now?": { + "fr": "Rejouer ces adresses par le cache maintenant ?", + "en": "Replay these addresses through the cache now?", + }, + "held": { + "fr": "détenu", + "en": "held", + }, + "not held": { + "fr": "non détenu", + "en": "not held", + }, + "not re-checked: this binary has no --detient": { + "fr": "non revérifié : ce binaire n'a pas --detient", + "en": "not re-checked: this binary has no --detient", + }, + "Tunnel refusals learned by the service:": { + "fr": "Refus de tunnel appris par le service :", + "en": "Tunnel refusals learned by the service:", + }, + "curl got no answer": { + "fr": "curl n'a obtenu aucune réponse", + "en": "curl got no answer", + }, + "the last offline run of": { + "fr": "le dernier déploiement hors ligne de", + "en": "the last offline run of", + }, + "age:": { + "fr": "âge :", + "en": "age:", + }, + "lacked at least": { + "fr": "a manqué d'au moins", + "en": "lacked at least", + }, + "addresses": { + "fr": "adresses", + "en": "addresses", + }, + "e.g.": { + "fr": "par exemple", + "en": "e.g.", + }, + "fill them from Cache › Fill what offline runs lacked": { + "fr": "les combler depuis Cache › Combler ce qui a manqué hors ligne", + "en": "fill them from Cache › Fill what offline runs lacked", + }, + "Upstream CUT: the cache can pull nothing from the internet": { + "fr": "Amont COUPÉ : le cache ne peut rien tirer de l'internet", + "en": "Upstream CUT: the cache can pull nothing from the internet", + }, + "Every VM then gets a 504 from every mirror.": { + "fr": "Toute VM reçoit alors un 504 depuis tous les miroirs.", + "en": "Every VM then gets a 504 from every mirror.", + }, + "Lift it with:": { + "fr": "La lever par :", + "en": "Lift it with:", + }, + "waiting for the guest agent install (apt lock)": { + "fr": "attente de la pose de l'agent invité (verrou apt)", + "en": "waiting for the guest agent install (apt lock)", + }, + "apt-get update never succeeded in 5 min (lock held, or nothing served)": { + "fr": "apt-get update n'a jamais abouti : le verrou est resté pris", + "en": "apt-get update never succeeded in 5 min (lock held, or nothing served)", + }, + "An offline deployment is still running: the cut": { + "fr": "Un déploiement hors ligne tourne encore : la coupure", + "en": "An offline deployment is still running: the cut", + }, + "is shared, and its end would lift yours.": { + "fr": "est partagée, et sa fin lèverait la vôtre.", + "en": "is shared, and its end would lift yours.", + }, + "Nothing deployed.": { + "fr": "Rien n'est déployé.", + "en": "Nothing deployed.", + }, + "Lift it now with:": { + "fr": "La lever maintenant par :", + "en": "Lift it now with:", + }, + "The cache upstream is already cut, and nothing": { + "fr": "L'amont du cache est déjà coupé, et rien", + "en": "The cache upstream is already cut, and nothing", + }, + "will lift it: an interrupted deployment, or one": { + "fr": "ne le rebranchera : un déploiement interrompu, ou un", + "en": "will lift it: an interrupted deployment, or one", + }, + "still installing without the monitor.": { + "fr": "autre qui installe encore sans le suivi.", + "en": "still installing without the monitor.", + }, + "Lift it and continue? (y/N): ": { + "fr": "Lever la coupure et continuer ? (o/N) : ", + "en": "Lift it and continue? (y/N): ", + }, + "The cache upstream stays cut until the last": { + "fr": "L'amont du cache reste coupé jusqu'à la fin de la", + "en": "The cache upstream stays cut until the last", + }, + "installation ends, at most": { + "fr": "dernière installation, au plus", + "en": "installation ends, at most", + }, + "The cache upstream comes back when the last": { + "fr": "L'amont du cache revient à la fin de la", + "en": "The cache upstream comes back when the last", + }, + "even if the monitor is closed.": { + "fr": "même si le suivi est fermé.", + "en": "even if the monitor is closed.", + }, + "Could not hand the lift over to systemd-run:": { + "fr": "Impossible de confier la levée à systemd-run :", + "en": "Could not hand the lift over to systemd-run:", + }, + "the cache upstream comes back when the monitor": { + "fr": "l'amont du cache revient à la fermeture du suivi ;", + "en": "the cache upstream comes back when the monitor", + }, + "closes; closing it early finishes the installs online.": { + "fr": "le fermer avant la fin fait finir les installations en ligne.", + "en": "closes; closing it early finishes the installs online.", + }, + "The cache upstream may still be cut.": { + "fr": "L'amont du cache est peut-être encore coupé.", + "en": "The cache upstream may still be cut.", + }, + "Existing checkout kept, not updated:": { + "fr": "Dépôt existant gardé, sans mise à jour :", + "en": "Existing checkout kept, not updated:", + }, + "The cache mirror has no branch": { + "fr": "Le miroir du cache n'a pas la branche", + "en": "The cache mirror has no branch", + }, + "an offline clone will fail.": { + "fr": "un clone hors ligne échouera.", + "en": "an offline clone will fail.", + }, + "Offline, the VM clones the cache mirror of": { + "fr": "Hors ligne, la VM clone le miroir du cache de", + "en": "Offline, the VM clones the cache mirror of", + }, + "this checkout has": { + "fr": "ce checkout est à", + "en": "this checkout has", + }, + "commit(s) missing from the mirror": { + "fr": "commit(s) absent(s) du miroir", + "en": "commit(s) missing from the mirror", + }, + "A push alone changes nothing: the mirror is": { + "fr": "Un push seul n'y change rien : le miroir ne se", + "en": "A push alone changes nothing: the mirror is", + }, + "refreshed only when a VM clones it online.": { + "fr": "rafraîchit que quand une VM le clone en ligne.", + "en": "refreshed only when a VM clones it online.", + }, + "Nothing is cut before F5: the upstream falls": { + "fr": "Rien n'est coupé avant F5 : l'amont tombe", + "en": "Nothing is cut before F5: the upstream falls", + }, + "at launch and comes back when the last install": { + "fr": "au lancement et revient à la fin de la dernière", + "en": "at launch and comes back when the last install", + }, + "ends (12 h at most), even with the monitor closed.": { + "fr": "installation (12 h au plus), suivi fermé ou non.", + "en": "ends (12 h at most), even with the monitor closed.", + }, + "Another offline deployment is starting or": { + "fr": "Un autre déploiement hors ligne démarre ou", + "en": "Another offline deployment is starting or", + }, + "running in another terminal.": { + "fr": "tourne dans un autre terminal.", + "en": "running in another terminal.", + }, + "An offline deployment is still installing.": { + "fr": "Un déploiement hors ligne installe encore.", + "en": "An offline deployment is still installing.", + }, + "This deployment would therefore run offline.": { + "fr": "Ce déploiement tournerait donc hors ligne.", + "en": "This deployment would therefore run offline.", + }, + "An offline deployment is starting in another": { + "fr": "Un déploiement hors ligne se lance dans un autre", + "en": "An offline deployment is starting in another", + }, + "terminal: the cache upstream is cut until it ends.": { + "fr": "terminal : l'amont du cache est coupé jusqu'à sa fin.", + "en": "terminal: the cache upstream is cut until it ends.", + }, + "Deploy anyway, offline? (y/N): ": { + "fr": "Déployer quand même, hors ligne ? (o/N) : ", + "en": "Deploy anyway, offline? (y/N): ", + }, + "Upstream CUT by an offline deployment still installing,": { + "fr": "Amont COUPÉ par un déploiement hors ligne qui installe encore,", + "en": "Upstream CUT by an offline deployment still installing,", + }, + "held until its last installation ends (12 h at most).": { + "fr": "coupure tenue jusqu'à la fin de sa dernière installation (12 h au plus).", + "en": "held until its last installation ends (12 h at most).", + }, + "Lifting it now makes those installations finish online.": { + "fr": "La lever maintenant fait finir ces installations en ligne.", + "en": "Lifting it now makes those installations finish online.", + }, + "The lift watcher still runs, with no cut left to lift.": { + "fr": "Le guet de levée tourne encore, sans coupure à lever.", + "en": "The lift watcher still runs, with no cut left to lift.", + }, + "Stop it with:": { + "fr": "L'arrêter par :", + "en": "Stop it with:", + }, + "Cannot tell whether the upstream is cut: reading nft needs a sudo password here.": { + "fr": "Impossible de savoir si l'amont est coupé : lire nft exige ici le mot de passe de sudo.", + "en": "Cannot tell whether the upstream is cut: reading nft needs a sudo password here.", + }, + "Under the cut, a replay would only record more misses.": { + "fr": "Sous la coupure, un rejeu n'ajouterait que des manques.", + "en": "Under the cut, a replay would only record more misses.", + }, + "Replay anyway?": { + "fr": "Rejouer quand même ?", + "en": "Replay anyway?", + }, + "The monitor stays ticked: it is what arms that return.": { + "fr": "Le suivi reste coché : c'est lui qui arme ce retour.", + "en": "The monitor stays ticked: it is what arms that return.", + }, + "git repositories not mirrored: fill them from entry 5 of the cache menu": { + "fr": "dépôts git absents du miroir : les remplir depuis l'entrée 5 du menu du cache", + "en": "git repositories not mirrored: fill them from entry 5 of the cache menu", + }, + "requests the cache never keeps:": { + "fr": "requêtes que le cache ne garde jamais :", + "en": "requests the cache never keeps:", + }, + "Cuts internet for the cache and the VMs: proves": { + "fr": "Coupe l'internet du cache et des VM : prouve que", + "en": "Cuts internet for the cache and the VMs: proves", + }, + "the install builds from what the cache holds.": { + "fr": "l'installation se bâtit avec ce que le cache détient.", + "en": "the install builds from what the cache holds.", + }, + "The VMs have no direct way out either: only the host answers them.": { + "fr": "Les VM n'ont plus de sortie directe non plus : seul l'hôte leur répond.", + "en": "The VMs have no direct way out either: only the host answers them.", + }, + "cache holds nothing for": { + "fr": "le cache ne détient rien pour", + "en": "cache holds nothing for", + }, + "an offline VM will fail": { + "fr": "une VM hors ligne échouera", + "en": "an offline VM will fail", + }, + "The cut hits every user of the cache:": { + "fr": "La coupure vaut pour tous les usagers du cache :", + "en": "The cut hits every user of the cache:", + }, + "a deployment run from another terminal": { + "fr": "un déploiement mené depuis un autre terminal", + "en": "a deployment run from another terminal", + }, + "goes offline too, without asking for it.": { + "fr": "sera hors ligne aussi, sans l'avoir demandé.", + "en": "goes offline too, without asking for it.", + }, + "Cache upstream cut for this deployment.": { + "fr": "Amont du cache coupé pour ce déploiement.", + "en": "Cache upstream cut for this deployment.", + }, + "Cache upstream restored.": { + "fr": "Amont du cache rebranché.", + "en": "Cache upstream restored.", + }, + "Upstream not cut: nothing deployed.": { + "fr": "Amont non coupé : rien n'est déployé.", + "en": "Upstream not cut: nothing deployed.", + }, + "The result would look offline without being so.": { + "fr": "Le résultat se lirait comme hors ligne sans l'être.", + "en": "The result would look offline without being so.", + }, + "dnsmasq is missing on the host: names cannot": { + "fr": "dnsmasq manque sur l'hôte : les noms ne peuvent", + "en": "dnsmasq is missing on the host: names cannot", + }, + "be cut. Install the dnsmasq package, then F5.": { + "fr": "pas être coupés. Installer le paquet dnsmasq, puis F5.", + "en": "be cut. Install the dnsmasq package, then F5.", + }, + "download cache authority installed": { + "fr": "autorité du cache de téléchargement posée", + "en": "download cache authority installed", + }, + "download cache authority not installed": { + "fr": "autorité du cache de téléchargement non posée", + "en": "download cache authority not installed", + }, "Timezone": { "fr": "Fuseau horaire", "en": "Timezone", diff --git a/test/test_check_comment_hygiene.py b/test/test_check_comment_hygiene.py index ae248a3..84d5173 100644 --- a/test/test_check_comment_hygiene.py +++ b/test/test_check_comment_hygiene.py @@ -134,6 +134,67 @@ class TestLeTemoignage(unittest.TestCase): self.assertEqual(2, len({t[1].lower() for t in trouves})) +class TestLeGo(unittest.TestCase): + """Go est entré dans le dépôt, et l'outil ne le lisait pas. + + Sondé avec des motifs interdits, il restait muet sur un « .go » et sortait + 0 : les commentaires du cache de téléchargement n'ont donc jamais été + relus par personne d'autre que leur auteur. + """ + + def texte(self, source): + """Le texte recollé de tous les blocs. Un bloc est un dict : + « line », « text », « lines », « offsets ».""" + return " ".join(b["text"] for b in hygiene.blocs_go(source)) + + def test_un_commentaire_de_ligne(self): + trouve = hygiene.inspect("x.go", "// Vécu : la panne\npackage main\n") + self.assertTrue(trouve, "un commentaire Go n'est pas inspecté") + + def test_une_url_en_chaine_nouvre_rien(self): + """« https:// » porte deux barres obliques : c'est LE piège du Go.""" + source = 'package main\n\nconst a = "https://vecu.example/hier"\n' + self.assertEqual(hygiene.blocs_go(source), []) + + def test_une_chaine_brute_nouvre_rien(self): + """L'accent grave délimite une chaîne où rien ne s'échappe.""" + source = ( + "package main\n\nconst a = `https://mesure.example/nous avons`\n" + ) + self.assertEqual(hygiene.blocs_go(source), []) + + def test_un_commentaire_apres_une_chaine(self): + source = ( + 'package main\n\nconst a = "https://x.example" // Vécu : ici\n' + ) + trouve = hygiene.recits(self.texte(source)) + self.assertTrue(trouve, "le commentaire qui suit une URL est perdu") + + def test_un_bloc_sur_plusieurs_lignes(self): + source = ( + "package main\n\n/*\nVécu : la semaine où\nnous avons vu.\n*/\n" + ) + self.assertTrue( + hygiene.blocs_go(source), "un commentaire /* */ n'est pas lu" + ) + texte = self.texte(source) + self.assertIn("Vécu", texte) + self.assertIn("nous avons", texte) + + def test_un_bloc_sur_une_seule_ligne(self): + source = "package main\n\nvar x = 1 /* Vécu : ici */\nvar y = 2\n" + self.assertTrue(hygiene.blocs_go(source)) + self.assertIn("Vécu", self.texte(source)) + + def test_le_code_apres_un_bloc_ferme_est_relu(self): + """Un « // » qui suit un bloc fermé sur la même ligne compte encore.""" + source = "package main\n\nvar x = 1 /* rien */ // Vécu : là\n" + self.assertIn("Vécu", self.texte(source)) + + def test_le_suffixe_est_balaye(self): + self.assertIn(".go", hygiene.SUFFIXES) + + class TestLesIdentifiants(unittest.TestCase): def test_un_courriel(self): self.assertIn( diff --git a/test/test_i18n_duplicates.py b/test/test_i18n_duplicates.py new file mode 100644 index 0000000..8439f17 --- /dev/null +++ b/test/test_i18n_duplicates.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Une clé de traduction en double écrase la précédente, sans un mot. + +Python construit un dictionnaire littéral de haut en bas : la dernière +occurrence gagne. Une clé posée deux fois avec des valeurs différentes fait +donc qu'un menu affiche la traduction d'un autre, et rien ne le signale — ni +au chargement, ni à l'exécution, ni au test qui exerce le premier menu. + +C'est arrivé : « running » et « stopped » existaient déjà, et des clés +homonymes ajoutées pour un service ont été avalées par elles. Le symptôme +était un « Service : en cours » là où le fichier disait « actif ». + +Le contrôle lit le fichier par son ARBRE et non par une expression +régulière : la mienne n'attrapait que la forme multiligne, et la collision +était écrite sur une seule ligne. +""" + +import ast +import collections +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +I18N = RACINE / "script" / "todo" / "todo_i18n.py" +sys.path.insert(0, str(RACINE / "script" / "todo")) + + +def cles_litterales(): + """Les clés du dictionnaire TRANSLATIONS, dans l'ordre du fichier. + + Par l'arbre syntaxique : une clé écrite sur une ligne et une clé écrite + sur plusieurs sont le même nœud, alors qu'elles n'ont pas la même forme + dans le texte. + """ + arbre = ast.parse(I18N.read_text(encoding="utf-8")) + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Assign): + continue + cibles = [c.id for c in noeud.targets if isinstance(c, ast.Name)] + if "TRANSLATIONS" not in cibles: + continue + if not isinstance(noeud.value, ast.Dict): + continue + return [ + k.value + for k in noeud.value.keys + if isinstance(k, ast.Constant) and isinstance(k.value, str) + ] + raise AssertionError("TRANSLATIONS introuvable") + + +class TestClesUniques(unittest.TestCase): + def test_aucune_cle_nest_ecrite_deux_fois(self): + compte = collections.Counter(cles_litterales()) + doubles = sorted(k for k, n in compte.items() if n > 1) + self.assertEqual( + doubles, + [], + "clés en double — la dernière écrase les précédentes, sans un " + f"mot : {doubles}", + ) + + def test_le_controle_lit_bien_quelque_chose(self): + """Un test qui ne trouve aucune clé passerait toujours.""" + self.assertGreater(len(cles_litterales()), 1000) + + def test_chaque_cle_porte_les_deux_langues(self): + import todo_i18n + + manques = [ + k + for k, v in todo_i18n.TRANSLATIONS.items() + if not isinstance(v, dict) or "fr" not in v or "en" not in v + ] + self.assertEqual(manques, [], f"traductions incomplètes : {manques}") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_install_pipe_to_shell.py b/test/test_install_pipe_to_shell.py new file mode 100644 index 0000000..84aa6f1 --- /dev/null +++ b/test/test_install_pipe_to_shell.py @@ -0,0 +1,639 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qui est téléchargé puis EXÉCUTÉ doit d'abord avoir été obtenu. + +« curl … | bash » est l'idiome de la moitié des installateurs. Sans « -f », +curl rend 0 sur une erreur HTTP et livre le CORPS de l'erreur à bash, qui +l'exécute : une page de miroir en panne, un portail captif ou le 504 d'un +cache hors ligne devient une suite de commandes. Le lecteur reçoit alors +« command not found » et la cause véritable ne se lit plus nulle part. + +Et « -f » ne suffit pas à tout. Le statut d'un tube est celui de son DERNIER +membre : sans pipefail, « curl -f … | sh || repli » rend celui de sh, 0 sur +une entrée vide, et le repli ne se déclenche jamais. Il en va de même de +« tube && a || repli » et du « else » d'un « if » dont le tube est la +condition. Un téléchargement raté passe alors pour une pose réussie. Là où +un échec doit avoir une suite, le téléchargement va dans un fichier, et +c'est le statut de curl qu'on lit. + +Le contrôle porte sur la PROPRIÉTÉ et non sur un fichier : tout script du +dépôt, et toute commande distante que l'hôte compose pour une VM, qui tube +un téléchargement dans un interpréteur doit demander à curl d'échouer, et +ne rien attendre d'un « || » posé derrière le tube. +""" + +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +sys.argv = ["todo.py"] +from script.todo import dev_tools # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + +# « curl » suivi de ce qui n'est pas une nouvelle commande, jusqu'à un tube +# vers un interpréteur. Le « \\ » de continuation de ligne est traversé. +# +# Entre le tube et l'interpréteur, un « sudo » éventuel, avec ses options +# (« -E », « -n ») et ses affectations (« VAR=valeur ») : c'est la forme d'un +# installateur qui doit écrire hors du HOME. Limite connue : une option qui +# prend son argument à part — « sudo -u compte bash » — n'est pas reconnue, +# faute de savoir, sans la table des options de sudo, quel mot est un argument +# et lequel est la commande. +# +# Puis un « timeout » éventuel, avec ses options et sa durée : la borne d'un +# installateur root se pose derrière sudo, seule place d'où elle peut le tuer. +TUBE = re.compile( + r"curl\s+((?:[^\n|;&]|\\\n)*?)" + r"\|\s*(?:sudo(?:\s+(?:-\S+|\w+=\S+))*\s+)?" + r"(?:timeout(?:\s+(?:-[ks]\s+\S+|-\S+))*\s+\d+(?:\.\d+)?[smhd]?\s+)?" + r"(?:ba)?sh\b" +) + +# Ce qui suit le tube jusqu'à la fin de la liste de commandes — « ; », fin de +# ligne non continuée —, puis un « || » et le repli qu'il porte. Un « | » seul +# prolonge le tube, un « & » seul appartient à une redirection (« >&2 »). Un +# « && » est traversé : dans « tube && a || repli », le repli suit aussi un +# tube qui a « réussi » sur une entrée vide, c'est-à-dire jamais. +REPLI = re.compile(r"(?:[^\n;&|]|\\\n|\|(?!\|)|&&|&(?!&))*\|\|\s*([^\n;]*)") + +# Un « set » et ses arguments, jusqu'à la fin de la commande. +SET = re.compile(r"\bset((?:[ \t]+[^\s;&|()]+)+)") + +# Le tube est-il la condition d'un « if » ou d'un « elif » ? Rien ne sépare +# le mot-clé du tube, sinon un « && » ou un « || ». Groupe 1 : un « ! » qui +# nie la condition. +SI = re.compile(r"\b(?:if|elif)\s+(!\s+)?([^;\n]*)$") + +# Jusqu'au « then » de ce « if » : la fin de la condition. +ALORS = re.compile(r"(?:[^\n;]|\\\n)*[;\n]\s*then\b") + +MOTS_SI = re.compile(r"\b(if|elif|else|fi)\b") + + +def bascules_pipefail(texte: str): + """(position, allumé) de chaque réglage de pipefail, hors commentaire. + + « set -o pipefail » et ses formes groupées (« -eo », « -euo ») allument : + le « o » est la dernière lettre d'une option à MOINS, et le mot suivant + est « pipefail ». « set +o pipefail » éteint. Dans un même « set », le + dernier réglage l'emporte, comme pour le shell. + """ + for m in SET.finditer(texte): + debut_ligne = texte.rfind("\n", 0, m.start()) + 1 + if re.search(r"(?:^|\s)#", texte[debut_ligne : m.start()]): + continue + mots = m.group(1).split() + for mot, suivant in zip(mots, mots[1:]): + if suivant == "pipefail" and re.fullmatch(r"[-+]\w*o", mot): + yield m.start(), mot[0] == "-" + + +def debut_de_ligne_logique(src: str, pos: int) -> int: + """Le début de la ligne qui porte `pos`, continuations « \\ » comprises.""" + i = src.rfind("\n", 0, pos) + while i > 0 and src[i - 1] == "\\": + i = src.rfind("\n", 0, i - 1) + return i + 1 + + +def prologue(src: str) -> int: + """La fin de l'en-tête du fichier : lignes vides, commentaires et « set » + qui précèdent la première autre commande.""" + fin = 0 + for ligne in src.splitlines(keepends=True): + nue = ligne.strip() + if nue and not nue.startswith("#") and not re.match(r"set\b", nue): + break + fin += len(ligne) + return fin + + +def pipefail_actif(src: str, pos: int) -> bool: + """pipefail vaut-il pour le tube qui commence à `pos` ? + + Deux portées, et deux seulement. D'abord la ligne logique du tube, avant + lui : le dernier réglage l'emporte. Les sous-shells refermés « ( … ) », + « $( … ) » et les chaînes refermées « '…' » en sont retirés, parce qu'un + « set » posé là ne vaut que pour eux. Sans réglage sur la ligne, l'en-tête + du fichier : il doit allumer pipefail, et aucun « set +o pipefail » ne + doit l'éteindre entre lui et le tube. + + Un pipefail posé ailleurs — dans une fonction, plus bas dans le fichier — + ne compte pas. L'erreur penche du côté du signalement : un repli exempté + à tort serait un repli sans effet que plus rien ne signale. + """ + ligne = src[debut_de_ligne_logique(src, pos) : pos] + retire = 1 + while retire: + ligne, retire = re.subn(r"\([^()]*\)", "", ligne) + ligne = re.sub(r"'[^']*'", "", ligne) + sur_la_ligne = list(bascules_pipefail(ligne)) + if sur_la_ligne: + return sur_la_ligne[-1][1] + fin = prologue(src) + en_tete = list(bascules_pipefail(src[:fin])) + if not en_tete or not en_tete[-1][1]: + return False + return all(allume for _p, allume in bascules_pipefail(src[fin:pos])) + + +def branche_de_repli(src: str, debut: int, niee: bool): + """La branche qu'un « if » réserve à l'échec de sa condition. + + `debut` suit le « then ». Condition niée (« if ! … »), c'est la branche + « then » elle-même ; sinon, le « else » ou l'« elif » de ce même « if », + les « if » imbriqués étant sautés. None s'il n'y en a pas. + """ + profondeur = 0 + for m in MOTS_SI.finditer(src, debut): + mot = m.group(1) + if mot == "if": + profondeur += 1 + elif mot == "fi" and profondeur: + profondeur -= 1 + elif profondeur == 0 and niee: + return src[debut : m.start()].strip(" \t\n;") + elif mot == "fi": + return None + elif profondeur == 0: + fin = src.find("\n", m.end()) + corps = src[m.end() : fin if fin >= 0 else len(src)] + corps = corps.strip(" \t;") + return corps if mot == "else" else f"elif {corps}" + return None + + +def repli_du_tube(src: str, m): + """Le texte du repli qui lit le statut du tube `m`, ou None. + + Deux formes : un « || » derrière le tube, et un « if » dont le tube est + la condition.""" + suite = REPLI.match(src, m.end()) + if suite: + return suite.group(1) + debut = debut_de_ligne_logique(src, m.start()) + si = SI.search(src[debut : m.start()].replace("\\\n", " ")) + if not si: + return None + alors = ALORS.match(src, m.end()) + if not alors: + return None + niee = bool(si.group(1)) and not re.search(r"&&|\|\|", si.group(2)) + return branche_de_repli(src, alors.end(), niee) + + +def demande_lechec(options: str) -> bool: + """« -f » y est-il demandé, sous l'une de ses formes ? + + Les options courtes se GROUPENT : « -sSf » vaut « -s -S -f », et + chercher le seul jeton « -f » manquerait la moitié des appels du dépôt. + """ + for mot in options.split(): + if mot == "--fail" or mot.startswith("--fail-"): + return True + if mot.startswith("-") and not mot.startswith("--") and "f" in mot[1:]: + return True + return False + + +def replis_sans_effet(src: str): + """Les replis qui lisent le statut d'un tube vers un shell, sans pipefail. + + Rend le texte de chaque repli. « || true » et « || : » n'en sont pas : + ils absorbent un échec, ils n'y réagissent pas, et ne promettent donc + rien que le tube ne tienne. + """ + trouves = [] + for m in TUBE.finditer(src): + if pipefail_actif(src, m.start()): + continue + repli = repli_du_tube(src, m) + if repli is None: + continue + mots = repli.split() + premier = mots[0].strip("'\")") if mots else "" + if premier not in ("true", ":"): + trouves.append(repli.strip()) + return trouves + + +def scripts(): + for chemin in sorted(RACINE.glob("script/**/*.sh")): + yield chemin, chemin.read_text(encoding="utf-8", errors="replace") + + +def commandes_de_lhote(): + """Les commandes que l'hôte compose et envoie à une VM, par leur nom. + + Elles ne vivent dans aucun .sh : un tube y est coupé entre plusieurs + littéraux Python, qu'une lecture ligne à ligne du source ne recolle pas. + On les construit donc, comme le fait le déploiement. + """ + todo = TODO.__new__(TODO) + yield "dev_tools.RTK_UPSTREAM", dev_tools.RTK_UPSTREAM + yield "dev_tools.STARSHIP_UPSTREAM", dev_tools.STARSHIP_UPSTREAM + yield "dev_tools.STARSHIP_UPSTREAM_VM", dev_tools.STARSHIP_UPSTREAM_VM + for agent, (commande, _repertoire) in sorted(dev_tools.AGENTS.items()): + yield f"dev_tools.AGENTS[{agent}]", commande + yield ( + f"_qemu_aidev_remote_cmd({agent})", + todo._qemu_aidev_remote_cmd(agent), + ) + yield "_qemu_mise_remote_cmd(mise)", todo._qemu_mise_remote_cmd("mise") + yield "_qemu_gnome_ext_remote_cmd", todo._qemu_gnome_ext_remote_cmd() + yield "_qemu_pycharm_remote_cmd", todo._qemu_pycharm_remote_cmd() + yield ( + "_qemu_android_studio_remote_cmd", + todo._qemu_android_studio_remote_cmd(), + ) + + +def sources(): + for chemin, src in scripts(): + yield str(chemin.relative_to(RACINE)), src + yield from commandes_de_lhote() + + +class TestUnTelechargementTubeDansUnShell(unittest.TestCase): + def test_curl_doit_echouer_sur_une_erreur_http(self): + fautifs = [] + for nom, src in sources(): + for options in TUBE.findall(src): + if not demande_lechec(options): + fautifs.append(f"{nom} : curl {options.strip()}") + self.assertEqual( + fautifs, + [], + "le corps d'une erreur HTTP y serait exécuté :\n " + + "\n ".join(fautifs), + ) + + def test_aucun_repli_derriere_un_tube_sans_pipefail(self): + """Le repli d'un tube sans pipefail ne se déclenche jamais : c'est + le statut de l'interpréteur qu'il lit, 0 sur une entrée vide.""" + fautifs = [ + f"{nom} : || {repli}" + for nom, src in sources() + for repli in replis_sans_effet(src) + ] + self.assertEqual( + fautifs, + [], + "repli sans effet, télécharger dans un fichier :\n " + + "\n ".join(fautifs), + ) + + def test_les_options_groupees_sont_reconnues(self): + """« -sSf » vaut « -s -S -f » : les traiter comme un seul jeton + signalerait à tort la moitié des appels du dépôt.""" + for bon in ("-fsSL", "-sSf", "-f", "--fail", "--fail-with-body"): + self.assertTrue(demande_lechec(f"{bon} https://x"), bon) + for mauvais in ("-L", "-sSL", "", "--silent"): + self.assertFalse(demande_lechec(f"{mauvais} https://x"), mauvais) + + def test_le_motif_voit_un_sudo_devant_linterpreteur(self): + """Un installateur qui écrit hors du HOME passe par sudo, avec ses + options et ses variables : le motif doit le voir quand même.""" + for tube in ( + "curl -sL https://example.invalid/i | sh", + "curl -sL https://example.invalid/i | bash -s -- -y", + "curl -sL https://example.invalid/i | sudo sh", + "curl -sL https://example.invalid/i | sudo -E bash", + "curl -sL https://example.invalid/i | sudo -n -E bash", + "curl -sL https://example.invalid/i | sudo A=/opt/a sh", + "curl -sL https://example.invalid/i | sudo -E A=1 B=2 sh -s", + "curl -sL https://example.invalid/i | sudo timeout 280 sh -s -- -y", + "curl -sL https://example.invalid/i" + " | sudo timeout -k 10 280 sh -s -- -y", + "curl -sL https://example.invalid/i | timeout 60 bash", + "curl -sL https://example.invalid/i | sudo -n timeout -k 5 9m sh", + "curl -sL https://example.invalid/i | sudo timeout --kill-after=5" + " 280 sh", + ): + self.assertEqual( + ["-sL https://example.invalid/i "], TUBE.findall(tube), tube + ) + for pas_un_shell in ( + "curl -sL https://example.invalid/i | shasum", + "curl -sL https://example.invalid/i | sudo tee /x", + "curl -sL https://example.invalid/i | sudo timeout 280 tee /x", + "curl -sL https://example.invalid/i -o /tmp/i; sh /tmp/i", + ): + self.assertEqual([], TUBE.findall(pas_un_shell), pas_un_shell) + + def test_un_repli_derriere_le_tube_est_reconnu(self): + """Les formes qui promettent un repli qu'elles ne tiennent pas.""" + for tube in ( + "curl -fsSL https://example.invalid/i | sh || echo repli", + "curl -fsSL https://example.invalid/i | bash >&2 || return 1", + "curl -fsSL https://example.invalid/i | sudo A=1 sh || exit 1", + "curl -fsSL https://example.invalid/i \\\n | bash \\\n || return 1", + "timeout 9 sh -c 'curl -fsSL https://example.invalid/i | sh'" + " > "{self.trace}"\nexit 1\n') + + def _faux(self, nom, corps): + chemin = self.faux / nom + chemin.write_text("#!/bin/bash\n" + corps, encoding="utf-8") + chemin.chmod(0o755) + + def _installer(self): + env = { + "PATH": str(self.faux), + "HOME": str(self.dir), + "TMPDIR": str(self.dir), + "PYENV_ROOT": str(self.dir / "pyenv"), + } + return subprocess.run( + [ + str(self.faux / "bash"), + "-c", + f'source "{self.LIB}"; el_pyenv_install 3.12.10', + ], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + def test_un_telechargement_rate_sarrete_et_le_dit(self): + self._faux( + "curl", + 'echo "curl: (22) The requested URL returned error: 504" >&2\n' + "exit 22\n", + ) + fini = self._installer() + self.assertNotEqual(0, fini.returncode) + self.assertIn("installateur pyenv impossible", fini.stderr) + self.assertFalse( + self.trace.exists(), + "la suite a tourné sans pyenv : " + + (self.trace.read_text() if self.trace.exists() else ""), + ) + + def test_un_installateur_qui_echoue_sarrete_et_le_dit(self): + """Le fichier téléchargé est bien celui qui s'exécute, et son échec + arrête la fonction avant toute compilation.""" + marque = self.dir / "installateur.a.tourne" + self._faux( + "curl", + # curl … -o FICHIER URL : on écrit l'installateur dans FICHIER. + 'while [ "$#" -gt 0 ]; do [ "$1" = -o ] && dest="$2"; shift;' + " done\n" + f"printf 'touch \"%s\"\\nexit 3\\n' '{marque}' > \"$dest\"\n", + ) + fini = self._installer() + self.assertNotEqual(0, fini.returncode) + self.assertTrue(marque.exists(), fini.stderr) + self.assertIn("installateur de pyenv a echoue", fini.stderr) + self.assertFalse(self.trace.exists()) + + +class TestLeReplisDeMiseSeDeclenche(unittest.TestCase): + """La pose de mise dans une VM, composée par l'hôte. + + Deux échecs, deux messages, et aucun ne fait tomber « set -e » : pyenv + prend le relais. La commande tourne pour de vrai dans un PATH où curl et + sudo sont faux — et où mise n'est PAS, pour que la pose soit tentée même + sur un hôte qui l'a. + """ + + def setUp(self): + from unittest import mock + + with mock.patch("script.todo.qemu_install.t", lambda k: k): + self.cmd = TODO.__new__(TODO)._qemu_mise_remote_cmd("mise") + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dir = Path(self.tmp.name) + self.faux = self.dir / "bin" + self.faux.mkdir() + for outil in ("sh", "bash", "env", "mktemp", "rm", "cat", "touch"): + os.symlink(shutil.which(outil), self.faux / outil) + self.marque = self.dir / "installateur.a.tourne" + self.trace = self.dir / "sudo.trace" + self._faux( + "curl", + 'if [ -n "$ECHEC" ]; then exit 22; fi\n' + 'while [ "$#" -gt 0 ]; do [ "$1" = -o ] && dest="$2"; shift;' + " done\n" + f"printf 'touch \"%s\"\\nexit ${{RC_INSTALL:-0}}\\n' " + f"'{self.marque}' > \"$dest\"\n", + ) + # Trace, puis exécute SANS privilège ; « env » lit les + # « VAR=valeur » de tête comme le fait sudo. + self._faux("sudo", f'echo "$*" >> "{self.trace}"\nexec env "$@"\n') + + def _faux(self, nom, corps): + chemin = self.faux / nom + chemin.write_text("#!/bin/sh\n" + corps, encoding="utf-8") + chemin.chmod(0o755) + + def _lancer(self, **env_en_plus): + env = { + "PATH": str(self.faux), + "HOME": str(self.dir), + "TMPDIR": str(self.dir), + **env_en_plus, + } + script = ( + "set -e\n" + self.cmd + '\necho "PROVIDER=$EL_PYTHON_PROVIDER"' + ) + return subprocess.run( + [str(self.faux / "bash"), "-c", script], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + + def test_un_telechargement_rate_le_dit_sans_rien_lancer(self): + fini = self._lancer(ECHEC="1") + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertIn("⚠ mise download impossible", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + self.assertFalse(self.trace.exists(), "sudo lancé sans installateur") + + def test_un_installateur_qui_echoue_le_dit(self): + fini = self._lancer(RC_INSTALL="5") + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertTrue(self.marque.exists()) + self.assertIn("⚠ mise installer failed", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + self.assertIn( + "MISE_INSTALL_PATH=/usr/local/bin/mise sh", self.trace.read_text() + ) + + def test_une_pose_reussie_se_tait(self): + fini = self._lancer() + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertTrue(self.marque.exists()) + self.assertNotIn("⚠", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + + def test_sans_mktemp_rien_nest_telecharge_ni_lance(self): + """Aucun nom de repli ne remplace mktemp : root exécuterait ce qu'un + autre compte aurait pu déposer d'avance sous ce nom.""" + # Le lien vers le vrai mktemp est retiré AVANT d'écrire le faux : + # écrire à travers le lien viserait le binaire de l'hôte. + (self.faux / "mktemp").unlink() + self._faux("mktemp", "exit 1\n") + fini = self._lancer() + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertIn("⚠ mise download impossible", fini.stdout) + self.assertIn("PROVIDER=auto", fini.stdout) + self.assertFalse(self.marque.exists(), "installateur lancé") + self.assertFalse(self.trace.exists(), "sudo lancé sans installateur") + + def test_aucun_chemin_fixe_dans_tmp(self): + """Le fichier que root exécute ne vient que de mktemp.""" + self.assertNotIn("/tmp/", self.cmd) + self.assertIn("f=$(mktemp 2>/dev/null)", self.cmd) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_longtest_confirm.py b/test/test_longtest_confirm.py new file mode 100644 index 0000000..94599a5 --- /dev/null +++ b/test/test_longtest_confirm.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Un test long ne se lance pas sur une seule frappe. + +Ces scripts créent de vraies machines et durent. Le menu affichait la +commande puis l'exécutait aussitôt : un chiffre tapé de travers partait donc +créer trois VM, et il n'y avait plus qu'à attendre pour les détruire. + +La question n'est pas posée pour tout : un plan à blanc ou un rapport ne crée +rien, et une invite qu'on apprend à confirmer sans lire ne protège plus rien +le jour où elle compte. Le partage se fait sur les arguments, et ce test le +vérifie dans les deux sens. +""" + +import sys +import unittest +from pathlib import Path +from unittest import mock + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.todo import todo_i18n # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + + +class FauxExecute: + """Retient ce qu'on lui demande de lancer, sans rien lancer.""" + + def __init__(self): + self.commandes = [] + + def exec_command_live(self, cmd, **_kw): + self.commandes.append(cmd) + + +def menu(reponse=True): + todo = TODO.__new__(TODO) + todo.execute = FauxExecute() + return todo, mock.patch( + "script.todo.longtest_menu.click.confirm", return_value=reponse + ) + + +class TestConfirmationDesTestsLongs(unittest.TestCase): + def test_une_vraie_execution_demande(self): + todo, patch = menu(reponse=True) + with patch as confirm: + todo._longtest_run("qemu_cache.py", "") + self.assertTrue(confirm.called, "aucune confirmation demandée") + self.assertEqual(len(todo.execute.commandes), 1) + + def test_un_refus_ne_lance_rien(self): + todo, patch = menu(reponse=False) + with patch: + todo._longtest_run("qemu_cache.py", "") + self.assertEqual( + todo.execute.commandes, [], "le test a démarré malgré le refus" + ) + + def test_le_plan_a_blanc_ne_demande_pas(self): + """Il ne crée rien : demander l'aurait rendue machinale.""" + todo, patch = menu() + with patch as confirm: + todo._longtest_run("qemu_cache.py", "--dry-run") + self.assertFalse(confirm.called) + self.assertEqual(len(todo.execute.commandes), 1) + + def test_le_rapport_ne_demande_pas(self): + todo, patch = menu() + with patch as confirm: + todo._longtest_run("qemu_cache.py", "--rapport") + self.assertFalse(confirm.called) + self.assertEqual(len(todo.execute.commandes), 1) + + def test_un_appelant_peut_couper_la_question(self): + """La destruction pose déjà la sienne : la doubler ferait répondre + deux fois à la même chose.""" + todo, patch = menu() + with patch as confirm: + todo._longtest_run("qemu_cache.py", "--detruire", demander=False) + self.assertFalse(confirm.called) + self.assertEqual(len(todo.execute.commandes), 1) + + def test_la_question_dit_ce_qui_va_arriver(self): + """Détruire n'est pas lancer. + + L'invite était la même pour les deux : « Cela crée de vraies VM… + Lancer ce test long ? » s'affichait devant « --detruire », qui efface + des machines et leurs disques. On répondait oui à autre chose que ce + qui allait arriver, et c'est l'acte le moins rattrapable des deux. + """ + todo, patch = menu() + with patch as confirm: + todo._longtest_run("qemu_cache.py", "--detruire") + pose = confirm.call_args.args[0] + self.assertIn("Détruire", pose, f"question posée : {pose}") + self.assertNotIn("Lancer", pose) + + def test_une_creation_pose_toujours_la_sienne(self): + todo, patch = menu() + with patch as confirm: + todo._longtest_run("qemu_cache.py", "--sans-cache") + pose = confirm.call_args.args[0] + self.assertIn("Lancer", pose, f"question posée : {pose}") + + def test_les_deux_avertissements_different(self): + """L'un annonce une création, l'autre un effacement : les confondre + est ce qui rend une confirmation machinale.""" + from script.todo.longtest_menu import LongTestMenuMixin as L + + creer = L._longtest_question("") + defaire = L._longtest_question("--detruire") + self.assertNotEqual(creer, defaire) + for cle in creer + defaire: + self.assertIn( + cle, + todo_i18n.TRANSLATIONS, + f"« {cle} » n'est pas une clé de traduction", + ) + + def test_la_commande_reste_affichee(self): + """Elle l'était déjà, et c'est ce qui rend la question répondable.""" + todo, patch = menu() + with patch, mock.patch("builtins.print") as ecrit: + todo._longtest_run("qemu_cache.py", "--dry-run") + dit = " ".join(str(a) for c in ecrit.call_args_list for a in c.args) + self.assertIn("qemu_cache.py --dry-run", dit) + + def test_la_destruction_ne_demande_pas_deux_fois(self): + """Les deux appels de _longtest_defaire portent demander=False.""" + src = (RACINE / "script" / "todo" / "longtest_menu.py").read_text( + encoding="utf-8" + ) + bloc = src[ + src.index("def _longtest_defaire") : src.index( + "def _longtest_depart" + ) + ] + for appel in ("--detruire --dry-run", '"--detruire"'): + self.assertIn( + "demander=False", + bloc, + f"l'appel {appel} de la destruction pose une seconde question", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 86489cb..b4065bb 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -356,6 +356,27 @@ class TestLesCommandes(unittest.TestCase): self.assertIn("qm resize 100 scsi0 12G", joint) self.assertTrue(cmds[-1].endswith("qm start 100")) + def test_without_3d_the_screen_stays_the_serial_console(self): + """Le défaut d'une machine de serveur : « qm terminal » en dépend, et + poser un périphérique vidéo d'office changerait son matériel.""" + creation = pve.create_cmds(100, self._spec())[0] + self.assertIn("--vga serial0", creation) + self.assertIn("--serial0 socket", creation) + + def test_3d_asks_for_an_accelerated_screen(self): + """« virtio-gl » donne à l'invité un virtio-gpu que le VIRGL de + l'hôte accélère.""" + creation = pve.create_cmds(100, self._spec(gpu3d=True))[0] + self.assertIn("--vga virtio-gl", creation) + self.assertNotIn("--vga serial0", creation) + + def test_the_serial_port_survives_the_accelerated_screen(self): + """Le port série reste posé : sans lui, « qm terminal » n'a plus rien + où s'attacher, et une VM qui ne démarre pas devient muette.""" + self.assertIn( + "--serial0 socket", pve.create_cmds(100, self._spec(gpu3d=True))[0] + ) + def test_the_agent_and_the_serial_console_are_asked_for(self): """Sans agent, aucune adresse ; sans serial0, « qm terminal » est inutilisable et il ne reste que l'interface web.""" diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 34e1b71..a519495 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -132,6 +132,23 @@ class TestSpec(unittest.TestCase): self.assertNotEqual(res_label("custom"), "xcustom") +def setUpModule(): + """L'attente ssh ne part JAMAIS pour de vrai depuis les tests. + + Le déploiement attend qu'une VM fraîche réponde en ssh avant de lui poser + le guide, le fuseau, le miroir et l'autorité — une adresse n'est pas une + machine prête. Les harnais d'ici remplacent chaque geste distant mais + appellent le vrai `_pve_ssh` : sans ce remplacement, chacun tenterait une + connexion vers un alias inventé et la suite unitaire y perdrait des + minutes. Les tests de l'attente elle-même la rappellent sur place. + """ + from script.todo.todo import TODO + + patch = mock.patch.object(TODO, "_pve_attendre_ssh", lambda *a, **k: True) + patch.start() + unittest.addModuleCleanup(patch.stop) + + def contexte(): def entree(distro, version, arch="amd64"): return { @@ -172,6 +189,12 @@ def contexte(): }, "bridges": ["vmbr0"], "bridge": "vmbr0", + # L'hôte d'essai est une VM de notre pont : la case « Sans connexion + # internet » est donc offerte, comme sur un Proxmox imbriqué. + "cache_offert": True, + # L'hôte d'essai a un nœud de rendu et le VIRGL : la case 3D est + # donc offerte, comme sur un hôte Proxmox capable. + "gpu_offert": True, "ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24", "build_command": lambda vm, spec: [f"qm create {vm['vmid']}"], "branches": ["develop", "master"], @@ -598,6 +621,752 @@ class TestDeuxVmDuMemeNom(unittest.TestCase): ) +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLePreVolDuCacheSurProxmox(unittest.TestCase): + """Ce que le cache ne détient pas, aucune VM coupée ne le lira. + + Le formulaire libvirt le dit depuis toujours ; celui de Proxmox partait + sans rien vérifier, et l'échec tombait une heure plus tard, à la pose du + bureau — un message qui accuse le dépôt, jamais le cache. F5 à nouveau + vaut passage outre : le journal peut avoir tourné, ou le magasin avoir + été rempli autrement. + """ + + def _deployer(self, absentes=(), hors_ligne=True): + """Deux F5 d'affilée. Rend ce que la spec valait après chacun.""" + import asyncio + from unittest import mock + + from script.qemu import cache_offline + + ctx = contexte() + vu = {} + + async def scenario(): + from textual.widgets import Checkbox, SelectionList + + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(0).value) + await pilote.pause() + if hors_ligne: + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + app.action_deploy() + vu["premier"] = getattr(app, "result", None) + app.action_deploy() + vu["second"] = getattr(app, "result", None) + + with mock.patch.object( + cache_offline, "suites_absentes", return_value=list(absentes) + ), mock.patch.object( + cache_offline, "composants_absents", return_value=[] + ), mock.patch.object( + cache_offline, "manques_hors_ligne", return_value=[] + ), mock.patch.object( + cache_offline, "paquets_absents", return_value=[] + ), mock.patch.object( + cache_offline, "miroirs_absents", return_value=[] + ): + asyncio.run(scenario()) + return vu + + def test_le_premier_f5_avertit_au_lieu_de_partir(self): + vu = self._deployer(absentes=[("ubuntu", "26.04")]) + self.assertFalse(vu["premier"], "parti sans rien dire du manque") + self.assertTrue(vu["second"], "le second F5 ne passe pas outre") + + def test_un_magasin_complet_ne_retarde_personne(self): + """Un avertissement qui tombe quand rien ne manque s'apprend par + cœur, et c'est ainsi qu'on cesse de le lire.""" + vu = self._deployer(absentes=[]) + self.assertTrue(vu["premier"], "avertissement sans manque") + + def test_en_ligne_le_pre_vol_ne_se_pose_pas(self): + """Le cache n'est qu'un raccourci tant que l'amont répond : ce qui + lui manque se télécharge, et rien n'échoue.""" + vu = self._deployer(absentes=[("ubuntu", "26.04")], hors_ligne=False) + self.assertTrue(vu["premier"], "le pré-vol s'est posé hors coupure") + + +class TestLeHorsLigneSurProxmox(unittest.TestCase): + """La coupure d'amont, offerte sur Proxmox VE comme sur QEMU/KVM. + + Elle est posée ICI, sur le pont local, et jamais sur l'hôte distant : un + hôte Proxmox qui reçoit l'autorité du cache est une VM de ce pont, et ses + invités sortent derrière son adresse. Un hôte qui ne vit pas ici ne doit + donc PAS se voir offrir la case — rien ici ne sait couper sa sortie, et + une VM qui s'y bâtirait réussirait en ligne sous une promesse de + hors-ligne. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + todo._write_ssh_config_entry = lambda *a, **k: None + todo._ssh_private_key = lambda k: None + todo._ssh_config_block = lambda nom: {} + todo._pve_guest_ip = lambda vmid, attente=120: "" + todo._pve_write_guide = lambda *a, **k: True + todo._pve_set_timezone = lambda *a, **k: True + todo._qemu_import_module = lambda: None + return todo + + def test_la_case_ne_sort_que_pour_un_hote_qui_vit_ici(self): + """C'est le même verdict que celui de l'autorité du cache : là où + elle est posée, le trafic traverse notre pont.""" + todo = self._todo() + todo._qemu_cache_ca_path = lambda: "/var/lib/cache/ca.crt" + todo._qemu_list_domains = lambda: ["pve-imbrique"] + self.assertTrue( + todo._pve_cache_ca({"target": "erplibre@pve-imbrique"}) + ) + self.assertFalse(todo._pve_cache_ca({"target": "erplibre@ailleurs"})) + + def test_le_contexte_offre_la_case_selon_ce_verdict(self): + from pathlib import Path + + racine = Path(__file__).resolve().parent.parent + src = (racine / "script" / "todo" / "proxmox_menu.py").read_text( + encoding="utf-8" + ) + self.assertIn('"cache_offert": bool(self._pve_cache_ca(host))', src) + + def _capture(self, **kw): + """Ce que l'installateur reçoit, la coupure tenue ou non.""" + import contextlib + import io + + todo = self._todo() + vu = {} + todo._qemu_install_erplibre_monitored = lambda *a, **k: vu.update(k) + spec = { + "host": {"target": "pve1"}, + "vms": [ + { + "name": "vm-a", + "vmid": 100, + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + "install_cmd": "", + } + ], + "user": "erplibre", + "add_ssh_config": True, + "install": {"branch": "develop", "cmd": "make", "label": "X"}, + "monitor": True, + } + todo._qemu_list_domains = lambda: [] + with contextlib.redirect_stdout(io.StringIO()): + todo._pve_after_create(spec["host"], spec, ["vm-a"], "", **kw) + return vu + + def test_la_coupure_tenue_atteint_linstallateur(self): + """Le guet reçoit la levée, et le manifeste porte la coupure : sans + cela, la coupure tomberait avec ce processus — avant la fin de ce qui + télécharge — et le bilan hors ligne ne saurait pas quoi relire.""" + vu = self._capture(coupee=True, debut=1789000000.0) + self.assertTrue(vu["guet_hors_ligne"]) + self.assertTrue(vu["hors_ligne"]) + self.assertEqual(vu["deploy_started"], 1789000000.0) + + def test_sans_coupure_rien_nest_promis(self): + vu = self._capture() + self.assertFalse(vu["guet_hors_ligne"]) + self.assertFalse(vu["hors_ligne"]) + + def test_le_deploiement_passe_par_lenveloppe(self): + """La spec ENTIÈRE tient dans le bloc coupé, création comprise : une + coupure levée avant l'installation ne prouverait rien.""" + import contextlib + + todo = self._todo() + vu = {} + + @contextlib.contextmanager + def fausse_coupure(actif): + vu["demandee"] = actif + yield actif + + todo._qemu_sans_internet = fausse_coupure + todo._pve_deploy_spec = lambda *a, **k: vu.setdefault( + "coupee", k.get("coupee") + ) + todo._pve_run_spec({"target": "pve1"}, {"offline": True}, None) + self.assertTrue(vu["demandee"], "la coupure n'a pas été demandée") + self.assertTrue(vu["coupee"], "le déploiement ignore la coupure") + + def test_une_coupure_impossible_ne_deploie_rien(self): + """Le refus vient de la coupure elle-même — amont debout, verrou pris, + dnsmasq absent. Déployer quand même bâtirait une VM en ligne sous une + promesse de hors-ligne.""" + import contextlib + + from script.todo.qemu_deploy import _SansInternetImpossible + + todo = self._todo() + vu = {} + + @contextlib.contextmanager + def refus(actif): + raise _SansInternetImpossible() + yield # pragma: no cover - jamais atteint + + todo._qemu_sans_internet = refus + todo._pve_deploy_spec = lambda *a, **k: vu.setdefault("parti", True) + self.assertIsNone( + todo._pve_run_spec({"target": "pve1"}, {"offline": True}, None) + ) + self.assertNotIn("parti", vu) + + def test_la_case_atteint_la_spec(self): + form = { + "host": {"target": "pve1"}, + "storage": "local-lvm", + "bridge": "vmbr0", + "res_label": "x1", + "ssh_key": "", + "start": True, + "add_ssh_config": True, + "install": None, + "monitor": True, + "parallelism": 1, + "offline": True, + } + self.assertTrue(build_spec([{"name": "a"}], [], form)["offline"]) + form["offline"] = False + self.assertFalse(build_spec([{"name": "a"}], [], form)["offline"]) + + def _ecran(self, cache_offert, cocher=False): + """Monte l'écran avec — ou sans — le cache offert, et relève l'état + DANS le contexte : `run_test` démonte les widgets en sortant.""" + from textual.widgets import Checkbox + + ctx = contexte() + ctx["cache_offert"] = cache_offert + vu = {} + + async def scenario(): + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + cases = app.query("#f_offline") + vu["offerte"] = bool(cases) + if cocher and cases: + cases.first(Checkbox).value = True + await pilote.pause() + suivi = app.query_one("#f_monitor", Checkbox) + vu["suivi"] = suivi.value + vu["suivi_fige"] = suivi.disabled + vu["avertissement"] = [ + w.display for w in app.query("#t_offline_w1") + ] + + asyncio.run(scenario()) + return vu + + def test_sans_cache_aucune_case(self): + """Une case sans effet est pire que pas de case : elle promet.""" + self.assertFalse(self._ecran(False)["offerte"]) + + def test_avec_le_cache_la_case_est_la_et_muette_tant_quon_ny_touche_pas( + self, + ): + vu = self._ecran(True) + self.assertTrue(vu["offerte"]) + self.assertEqual(vu["avertissement"], [False]) + self.assertFalse(vu["suivi_fige"]) + + def test_cocher_decouvre_lavertissement_et_fige_le_suivi(self): + """Seule la voie suivie confie la levée au guet : sans suivi, la + coupure tomberait avec le tableau de bord.""" + vu = self._ecran(True, cocher=True) + self.assertEqual(vu["avertissement"], [True]) + self.assertTrue(vu["suivi"]) + self.assertTrue(vu["suivi_fige"]) + + +class TestLaTroisDSurProxmox(unittest.TestCase): + """L'accélération 3D, offerte sur Proxmox VE comme sur QEMU/KVM. + + Deux moitiés, et l'une sans l'autre ne donne rien. L'ÉCRAN se pose à la + création (« --vga virtio-gl ») ; l'ACCÈS au nœud de rendu est une affaire + de groupes DANS l'invité, que « qm set » ne sait pas écrire. Sans les + groupes, toute application GL retombe en rendu logiciel alors que la + négociation VIRGL a réussi — et rien ne le signale. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + def test_les_groupes_sont_crees_avant_detre_donnes(self): + """« usermod -aG » sur un groupe inconnu échoue, et « render » manque + des images les plus anciennes.""" + todo = self._todo() + vu = {} + + def faux_ssh(cible, cmd, timeout=120): + vu["cible"], vu["cmd"] = cible, cmd + return 0, "" + + todo._pve_ssh = faux_ssh + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(todo._pve_set_gpu_groups("pve+vm-a", "erplibre")) + self.assertEqual(vu["cible"], "pve+vm-a") + self.assertLess( + vu["cmd"].index("groupadd -f render"), + vu["cmd"].index("usermod -aG"), + ) + self.assertIn("groupadd -f video", vu["cmd"]) + self.assertIn("usermod -aG render,video erplibre", vu["cmd"]) + + def test_un_echec_est_dit_et_non_tu(self): + todo = self._todo() + todo._pve_ssh = lambda *a, **k: (255, "") + import contextlib + import io + + sortie = io.StringIO() + with contextlib.redirect_stdout(sortie): + self.assertFalse(todo._pve_set_gpu_groups("pve+vm-a", "erplibre")) + self.assertIn("255", sortie.getvalue()) + + def test_la_sonde_exige_le_noeud_ET_les_trois_bibliotheques(self): + """Un hôte sans GPU n'expose aucun nœud de rendu ; sans VIRGL, GL ou + EGL, Proxmox refuse de démarrer la machine — « missing libraries for + 'virtio-gl' detected! Please install 'libgl1' and 'libegl1' » —, et + il le refuse APRÈS avoir écrit le disque. Un hôte peut porter GL sans + EGL : les exiger ensemble est le seul contrôle qui vaille.""" + todo = self._todo() + vus = [] + + def faux_show(remote, timeout=120, quiet=False): + vus.append(remote) + # Rien de manquant : la sonde ne dit que son jeton final. + return 0, "FIN\n" + + todo._pve_show = faux_show + # Tout est là : la sonde ne dit que « FIN ». + self.assertEqual(todo._pve_gpu_dispo(), (True, "")) + for attendu in ( + "/dev/dri/renderD*", + "libvirglrenderer.so.*", + "libGL.so.1", + "libEGL.so.1", + ): + self.assertIn(attendu, vus[0]) + + def test_la_sonde_nomme_ce_qui_manque(self): + """Le cas vécu : un hôte porte GL mais pas EGL, et Proxmox refuse de + démarrer la machine APRÈS avoir écrit son disque. La case ne doit pas + disparaître en silence — ce qui manque se nomme.""" + todo = self._todo() + todo._pve_show = lambda *a, **k: (0, "libegl1\nFIN\n") + self.assertEqual(todo._pve_gpu_dispo(), (False, "libegl1")) + todo._pve_show = lambda *a, **k: (0, "noeud\nlibgl1\nlibegl1\nFIN\n") + self.assertEqual( + todo._pve_gpu_dispo(), (False, "noeud libgl1 libegl1") + ) + + def test_une_sonde_qui_naboutit_pas_naccuse_rien(self): + """Sans le jeton final, une sortie vide voudrait dire « tout est + là » : c'est ssh qui a échoué, et on ne promet rien.""" + todo = self._todo() + todo._pve_show = lambda *a, **k: (0, "") + self.assertEqual(todo._pve_gpu_dispo(), (False, "")) + todo._pve_show = lambda *a, **k: (255, "FIN") + self.assertEqual(todo._pve_gpu_dispo(), (False, "")) + + def test_le_choix_atteint_la_commande_de_creation(self): + from pathlib import Path + + racine = Path(__file__).resolve().parent.parent + src = (racine / "script" / "todo" / "proxmox_menu.py").read_text( + encoding="utf-8" + ) + self.assertIn('"gpu3d": bool(spec.get("gpu3d"))', src) + # UNE sonde, deux clés : l'écran ne doit pas pouvoir offrir la case + # et nommer en même temps ce qui l'empêche. + self.assertIn("gpu_possible, gpu_manque = self._pve_gpu_dispo()", src) + self.assertIn('"gpu_offert": gpu_possible', src) + self.assertIn('"gpu_manque": gpu_manque', src) + + def test_la_case_atteint_la_spec(self): + form = { + "host": {"target": "pve1"}, + "storage": "local-lvm", + "bridge": "vmbr0", + "res_label": "x1", + "ssh_key": "", + "start": True, + "add_ssh_config": True, + "install": None, + "monitor": True, + "parallelism": 1, + "gpu3d": True, + } + self.assertTrue(build_spec([{"name": "a"}], [], form)["gpu3d"]) + form["gpu3d"] = False + self.assertFalse(build_spec([{"name": "a"}], [], form)["gpu3d"]) + + def _ecran(self, gpu_offert, cocher=False, manque=""): + """Monte l'écran avec — ou sans — la 3D possible sur l'hôte.""" + from textual.widgets import Checkbox, Static + + ctx = contexte() + ctx["gpu_offert"] = gpu_offert + ctx["gpu_manque"] = manque + vu = {} + + async def scenario(): + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 50)) as pilote: + await pilote.pause() + cases = app.query("#f_gpu3d") + vu["offerte"] = bool(cases) + if cocher and cases: + cases.first(Checkbox).value = True + await pilote.pause() + vu["valeur"] = app._form_values()["gpu3d"] + # Relevé DANS le contexte : « run_test » démonte les widgets + # en sortant, et le texte n'existerait plus après. + vu["explication"] = " ".join( + str(getattr(w, "_content", "") or w.render()) + for w in app.query("#t_gpu_manque") + ) + vu["geste"] = " ".join( + str(getattr(w, "_content", "") or w.render()) + for w in app.query("#t_gpu_geste") + ) + assert Static # l'import sert au typage de la requête + + asyncio.run(scenario()) + return vu + + def test_sans_gpu_sur_lhote_aucune_case(self): + """Une case qui promettrait une accélération que l'hôte ne peut pas + rendre vaut moins que pas de case.""" + vu = self._ecran(False) + self.assertFalse(vu["offerte"]) + self.assertFalse(vu["valeur"]) + + def test_ce_qui_manque_est_nomme_avec_son_paquet(self): + """Le cas vécu : la case avait disparu après correction de la sonde, + sans que rien ne dise pourquoi. Elle nomme désormais la pièce ET la + commande qui la pose — sur l'HÔTE, pas dans la VM.""" + vu = self._ecran(False, manque="libegl1") + self.assertFalse(vu["offerte"]) + self.assertIn("libegl1", vu["explication"]) + self.assertIn("apt install libegl1", vu["geste"]) + + def test_un_noeud_de_rendu_absent_ne_propose_aucun_paquet(self): + """Le nœud vient du matériel ou d'un GPU transmis : « apt install + noeud » enverrait l'opérateur dans le mur.""" + vu = self._ecran(False, manque="noeud") + self.assertIn("noeud", vu["explication"]) + self.assertEqual(vu["geste"], "") + + def test_rien_nest_dit_quand_la_sonde_na_pas_abouti(self): + """Sonde muette : on ne promet rien, et on n'accuse rien non plus.""" + vu = self._ecran(False) + self.assertEqual(vu["explication"], "") + self.assertEqual(vu["geste"], "") + + def test_avec_un_gpu_la_case_est_la_et_decochee(self): + vu = self._ecran(True) + self.assertTrue(vu["offerte"]) + self.assertFalse(vu["valeur"]) + + def test_cocher_porte_le_choix_jusqua_la_spec(self): + self.assertTrue(self._ecran(True, cocher=True)["valeur"]) + + def _bouton(self, manque="libegl1", apres=(True, ""), moyen=True): + """Monte l'écran, presse « Installer sur l'hôte », relève la suite. + + « suspend() » est remplacé : un écran monté sans terminal ne peut pas + le rendre, et ce n'est pas lui qu'on éprouve. Ce qu'on éprouve, c'est + que le paquet parte, que l'hôte soit RELU, et que l'écran suive. + """ + import contextlib + + from textual.widgets import Button + + ctx = contexte() + ctx["gpu_offert"] = False + ctx["gpu_manque"] = manque + vu = {"recu": None, "notes": []} + if moyen: + ctx["installer_gpu"] = ( + lambda paquets: vu.update(recu=paquets) or True + ) + ctx["sonder_gpu"] = lambda: apres + + async def scenario(): + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + app.suspend = lambda: contextlib.nullcontext() + app.notify = lambda m, **k: vu["notes"].append(str(m)) + boutons = app.query("#f_gpu_poser") + vu["bouton"] = bool(boutons) + if boutons: + boutons.first(Button).press() + await pilote.pause() + await pilote.pause() + vu["case"] = bool(app.query("#f_gpu3d")) + vu["lignes"] = bool(app.query("#t_gpu_manque")) + + asyncio.run(scenario()) + return vu + + def test_le_bouton_pose_le_paquet_et_la_case_apparait(self): + """Ce que l'opérateur demande : ne pas quitter l'écran pour une + commande que l'écran vient de lui montrer.""" + vu = self._bouton() + self.assertTrue(vu["bouton"]) + self.assertEqual(vu["recu"], "libegl1") + self.assertTrue(vu["case"], "la case n'est pas apparue") + self.assertFalse(vu["lignes"], "le message est resté sous la case") + + def test_seuls_les_paquets_partent_a_linstallation(self): + """Le nœud de rendu ne s'installe pas : l'envoyer à apt ferait + échouer la pose des paquets qui, eux, existent.""" + self.assertEqual( + self._bouton(manque="noeud libegl1")["recu"], "libegl1" + ) + + def test_un_noeud_seul_ne_donne_aucun_bouton(self): + vu = self._bouton(manque="noeud") + self.assertFalse(vu["bouton"]) + self.assertFalse(vu["case"]) + + def test_sans_moyen_de_poser_aucun_bouton(self): + """Un bouton sans effet vaut moins qu'une commande à recopier.""" + self.assertFalse(self._bouton(moyen=False)["bouton"]) + + def test_lhote_est_relu_et_la_case_ne_vient_pas_sur_parole(self): + """Croire apt sur parole offrirait une case que Proxmox refuserait + ensuite — après avoir écrit le disque de la VM.""" + vu = self._bouton(apres=(False, "libgl1")) + self.assertFalse(vu["case"]) + self.assertTrue(vu["lignes"], "le message a disparu pour rien") + self.assertTrue(any("libgl1" in n for n in vu["notes"]), vu["notes"]) + + +class TestLeMiroirAptDesVmProxmox(unittest.TestCase): + """Une VM Proxmox tire du miroir que le cache a rempli. + + Le magasin range ses index sous l'HÔTE demandé. Une VM qui réclame + « archive.ubuntu.com » ne retrouve donc rien de ce qu'une autre a gardé + depuis un miroir : hors ligne, chacun de ces index manque, et + l'installation échoue plus bas sur des dépendances introuvables — un + message qui accuse le dépôt, jamais le miroir. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + class _Mod: + APT_MIRRORS_MAIN = [ + "http://miroir.invalid/ubuntu", + "http://second.invalid/ubuntu", + ] + APT_MIRRORS_PORTS = ["http://miroir.invalid/ubuntu-ports"] + PORTS_ARCHES = ("arm64", "s390x") + + def _poser(self, vm, code=0): + import contextlib + import io + + todo = self._todo() + vu = {} + + def faux_ssh(cible, cmd, timeout=120): + vu["cible"], vu["cmd"] = cible, cmd + return code, "" + + todo._pve_ssh = faux_ssh + with contextlib.redirect_stdout(io.StringIO()) as sortie: + vu["rendu"] = todo._pve_set_apt_mirror("pve+vm-a", vm, self._Mod) + vu["ecrit"] = sortie.getvalue() + return vu + + def test_le_premier_miroir_remplace_les_depots_officiels(self): + vu = self._poser({"distro": "ubuntu", "arch": "amd64"}) + self.assertTrue(vu["rendu"]) + self.assertIn("miroir.invalid/ubuntu", vu["cmd"]) + self.assertIn("archive|security", vu["cmd"]) + self.assertNotIn("second.invalid", vu["cmd"]) + + def test_les_deux_formats_de_sources_sont_couverts(self): + """Le « .sources » deb822 des images récentes, et le + « sources.list » des anciennes : n'en réécrire qu'un laisse l'autre + pointer ailleurs.""" + cmd = self._poser({"distro": "ubuntu", "arch": "amd64"})["cmd"] + self.assertIn("/etc/apt/sources.list ", cmd) + self.assertIn("sources.list.d/*.sources", cmd) + self.assertIn("sources.list.d/*.list", cmd) + + def test_une_arche_ports_prend_son_propre_miroir(self): + """Les arches « ports » ne sont pas sur archive.ubuntu.com, et amd64 + n'est pas sur ports.ubuntu.com.""" + cmd = self._poser({"distro": "ubuntu", "arch": "arm64"})["cmd"] + # Le motif est une EXPRESSION : ses points sont échappés, sans quoi + # ils vaudraient « n'importe quel caractère ». + self.assertIn(r"ports\.ubuntu\.com/ubuntu-ports", cmd) + self.assertIn("miroir.invalid/ubuntu-ports", cmd) + + def test_les_autres_distributions_sont_laissees_tranquilles(self): + """Debian, Fedora et Arch ont leurs propres dépôts : y réécrire une + URI ubuntu ne viserait rien.""" + todo = self._todo() + todo._pve_ssh = lambda *a, **k: self.fail("ssh lancé pour rien") + self.assertFalse( + todo._pve_set_apt_mirror( + "pve+vm-a", {"distro": "debian", "arch": "amd64"}, self._Mod + ) + ) + + def test_un_echec_est_dit(self): + vu = self._poser({"distro": "ubuntu", "arch": "amd64"}, code=255) + self.assertFalse(vu["rendu"]) + self.assertIn("255", vu["ecrit"]) + + def test_le_miroir_est_pose_avant_lautorite_du_cache(self): + """L'ordre est le sujet : l'autorité sert aux téléchargements, et le + miroir décide OÙ ils vont. Posé après, il ne vaudrait que pour ce qui + reste à venir.""" + from pathlib import Path + + racine = Path(__file__).resolve().parent.parent + src = (racine / "script" / "todo" / "proxmox_menu.py").read_text( + encoding="utf-8" + ) + self.assertLess( + src.index('self._pve_set_apt_mirror(vm["alias"]'), + src.index('self._pve_set_cache_ca(vm["alias"]'), + ) + + +class TestLAttenteAvantLesGestesDansLInvite(unittest.TestCase): + """Une adresse n'est pas une machine prête. + + Vécu : une VM Proxmox est née sans guide, en UTC, sans l'autorité du + cache et sur le miroir de son image. Les quatre gestes passent tous par + ssh et partaient dès l'adresse connue, pendant que cloud-init posait + encore les comptes et les clés. Ils échouaient donc ENSEMBLE, et la panne + ressemblait à quatre pannes sans lien. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + @property + def attendre(self): + """La VRAIE attente : le module de test la remplace partout + ailleurs, et c'est elle qu'on éprouve ici.""" + from script.todo.proxmox_menu import ProxmoxMenuMixin + + return ProxmoxMenuMixin._pve_attendre_ssh + + def test_elle_rend_vrai_des_que_le_ssh_repond(self): + todo = self._todo() + essais = [] + todo._pve_ssh = lambda c, cmd, timeout=120: ( + essais.append(cmd), + (0, ""), + )[1] + self.assertTrue(self.attendre(todo, "pve+vm-a")) + self.assertEqual(essais, ["true"], "une seule sonde suffit") + + def test_elle_rend_faux_au_bout_du_delai(self): + """Bornée par le TEMPS : un essai coûte le délai de connexion de ssh, + que rien ici ne borne à l'avance.""" + import contextlib + import io + + from script.todo import proxmox_menu + + todo = self._todo() + todo._pve_ssh = lambda c, cmd, timeout=120: (255, "") + horloge = iter([0, 0, 5, 10, 15, 20, 25, 30, 35, 40]) + with mock.patch.object(proxmox_menu.time, "sleep", lambda _s: None): + with mock.patch.object( + proxmox_menu.time, "time", lambda: next(horloge) + ): + with contextlib.redirect_stdout(io.StringIO()) as sortie: + rendu = self.attendre(todo, "pve+vm-a", delai=20) + self.assertFalse(rendu) + self.assertIn("ssh", sortie.getvalue()) + + def test_sans_reponse_les_gestes_sont_sautes_et_dits(self): + """Quatre échecs silencieux valent moins qu'un refus qui se nomme.""" + import contextlib + import io + + todo = self._todo() + faits = [] + todo._write_ssh_config_entry = lambda *a, **k: None + todo._ssh_private_key = lambda k: None + todo._qemu_list_domains = lambda: [] + todo._pve_guest_ip = lambda vmid, attente=120: "" + todo._qemu_import_module = lambda: None + todo._pve_attendre_ssh = lambda *a, **k: False + for nom in ( + "_pve_write_guide", + "_pve_set_timezone", + "_pve_set_apt_mirror", + "_pve_set_cache_ca", + ): + setattr( + todo, nom, (lambda n: lambda *a, **k: faits.append(n))(nom) + ) + todo._qemu_install_erplibre_monitored = lambda *a, **k: None + spec = { + "host": {"target": "pve1"}, + "vms": [ + { + "name": "vm-a", + "vmid": 100, + "ipconfig": "ip=10.0.0.2/24,gw=10.0.0.1", + "distro": "ubuntu", + "arch": "amd64", + } + ], + "user": "erplibre", + "add_ssh_config": True, + "install": None, + "monitor": False, + } + with contextlib.redirect_stdout(io.StringIO()) as sortie: + todo._pve_after_create(spec["host"], spec, ["vm-a"], "") + self.assertEqual(faits, [], f"des gestes sont partis : {faits}") + self.assertIn("ssh", sortie.getvalue()) + + class TestUnParcMixte(unittest.TestCase): """Le plan porte branche, profil et type PAR RANGÉE — le déploiement lisait encore la seule valeur commune. @@ -1524,5 +2293,223 @@ class TestLeSuivi(unittest.TestCase): self.assertIn("tableau", vus) +class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase): + """Une VM née sur un Proxmox imbriqué est interceptée sans le savoir. + + Le cache détourne tout ce qui sort de son pont. Un hôte Proxmox qui est + lui-même une VM d'ici y est branché, et les machines qu'il porte sortent + derrière son adresse : elles traversent donc le cache, alors que rien à + l'intérieur ne leur a donné son autorité. Le mode de défaillance est + trompeur — un dépôt apt en clair passe, si bien que l'installation + démarre, et seuls les téléchargements HTTPS échouent, sur « self-signed + certificate in certificate chain ». + + « qm set » ne sait écrire aucun fichier : l'autorité part par ssh, depuis + la MÊME source que la voie libvirt (`cache_files`, `cache_commands`). + """ + + def _todo(self, domaines=("pve-local",), ca="/tmp/ca.crt"): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + todo._qemu_list_domains = lambda: list(domaines) + todo._qemu_cache_ca_path = classmethod(lambda cls: ca).__get__( + todo, type(todo) + ) + return todo + + # -- Qui est concerné ---------------------------------------------- + + def test_a_nested_proxmox_host_gets_the_authority(self): + todo = self._todo() + self.assertEqual( + todo._pve_cache_ca({"target": "root@pve-local"}), + "/tmp/ca.crt", + ) + + def test_a_proxmox_host_that_lives_elsewhere_gets_nothing(self): + """Son trafic ne traverse pas ce pont : l'autorité n'y servirait à + rien, et le cache doit s'installer sur cet hôte-là.""" + todo = self._todo() + self.assertEqual(todo._pve_cache_ca({"target": "root@10.0.0.5"}), "") + + def test_no_cache_installed_here_means_no_authority(self): + todo = self._todo(ca="") + self.assertEqual(todo._pve_cache_ca({"target": "pve-local"}), "") + + # -- Ce qui est réellement posé ------------------------------------ + + def _pose(self, distro="ubuntu", cache_files=None): + import contextlib + import io + import tempfile + + todo = self._todo() + vus = {} + todo._pve_ssh = lambda cible, remote, timeout=60: ( + vus.update(cible=cible, remote=remote, timeout=timeout) or (0, "") + ) + mod = todo._qemu_import_module() + with tempfile.NamedTemporaryFile( + "w", suffix=".crt", delete=False + ) as fh: + fh.write("-----BEGIN CERTIFICATE-----\nZm F1eA==\n") + fh.write("-----END CERTIFICATE-----\n") + ca = fh.name + patch = ( + mock.patch.object(mod, "cache_files", cache_files) + if cache_files + else contextlib.nullcontext() + ) + with contextlib.redirect_stdout(io.StringIO()), patch: + vus["ok"] = todo._pve_set_cache_ca( + "hote+vm-a", {"name": "vm-a", "distro": distro}, ca + ) + return vus + + def test_the_authority_goes_where_the_family_reads_it(self): + vus = self._pose() + self.assertTrue(vus["ok"]) + # Par l'ALIAS : lui seul porte le rebond vers le réseau interne. + self.assertEqual(vus["cible"], "hote+vm-a") + self.assertIn( + "/usr/local/share/ca-certificates/erplibre-cache.crt", + vus["remote"], + ) + + def test_arch_does_not_get_the_debian_path(self): + vus = self._pose(distro="arch") + self.assertIn( + "/etc/ca-certificates/trust-source/anchors", vus["remote"] + ) + self.assertNotIn("/usr/local/share/ca-certificates", vus["remote"]) + + def test_the_store_is_reread_then_the_variables_are_written(self): + """Dans cet ordre : les variables visent le faisceau que la commande + de confiance vient de régénérer.""" + vus = self._pose() + confiance = vus["remote"].index("update-ca-certificates") + for var in ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"): + self.assertLess(confiance, vus["remote"].index(var), var) + + def test_the_content_comes_from_cache_files_and_is_not_rebuilt_here(self): + """Une seule source pour les deux voies de livraison : ce que + cloud-init écrirait est ce que ssh pose.""" + vus = self._pose( + cache_files=lambda args: [ + ("/etc/anchors/temoin.crt", "0644", "PEM-TEMOIN", "") + ] + ) + self.assertIn("/etc/anchors/temoin.crt", vus["remote"]) + self.assertIn("PEM-TEMOIN", vus["remote"]) + + def test_a_distro_out_of_the_table_poses_nothing(self): + """Le fichier au mauvais endroit ne servirait à rien sans rien + dire ; la VM télécharge en direct, ce qui marche.""" + vus = self._pose(distro="plan9") + self.assertFalse(vus["ok"]) + self.assertNotIn("remote", vus) + + # -- Le câblage ---------------------------------------------------- + + def test_the_authority_is_posed_before_the_install(self): + """Le contrôle porte sur l'ORDRE : c'est l'installation qui + télécharge, et un magasin relu ensuite ne rattrape rien.""" + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + ordre = [] + todo = TODO.__new__(TODO) + todo._write_ssh_config_entry = lambda *a, **k: None + todo._ssh_private_key = lambda k: None + todo._pve_alias_perime = lambda *a, **k: [] + todo._qemu_list_domains = lambda: ["pve-local"] + todo._qemu_cache_ca_path = classmethod( + lambda cls: "/tmp/ca.crt" + ).__get__(todo, type(todo)) + todo._pve_guest_ip = lambda vmid, attente=120: "" + todo._pve_write_guide = lambda *a, **k: True + todo._pve_set_timezone = lambda *a, **k: True + todo._qemu_import_module = lambda: None + todo._pve_set_cache_ca = lambda cible, vm, ca: ordre.append( + ("autorité", cible, ca) + ) + todo._qemu_install_erplibre_monitored = lambda *a, **k: ordre.append( + ("installation",) + ) + spec = { + "host": {"target": "root@pve-local"}, + "vms": [ + { + "name": "vm-a", + "vmid": 100, + "distro": "ubuntu", + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + "install_cmd": "", + } + ], + "user": "erplibre", + "add_ssh_config": True, + "install": {"branch": "develop", "cmd": "make x", "label": "X"}, + "monitor": True, + "desktop": "", + } + with contextlib.redirect_stdout(io.StringIO()): + todo._pve_after_create(spec["host"], spec, ["vm-a"], "") + self.assertEqual([e[0] for e in ordre], ["autorité", "installation"]) + self.assertEqual(ordre[0][2], "/tmp/ca.crt") + + def test_a_remote_host_does_not_get_the_step_at_all(self): + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + appels = [] + todo = TODO.__new__(TODO) + todo._write_ssh_config_entry = lambda *a, **k: None + todo._ssh_private_key = lambda k: None + todo._pve_alias_perime = lambda *a, **k: [] + todo._qemu_list_domains = lambda: [] + todo._qemu_cache_ca_path = classmethod( + lambda cls: "/tmp/ca.crt" + ).__get__(todo, type(todo)) + todo._pve_guest_ip = lambda vmid, attente=120: "" + todo._pve_write_guide = lambda *a, **k: True + todo._pve_set_timezone = lambda *a, **k: True + todo._qemu_import_module = lambda: None + todo._pve_set_cache_ca = lambda *a: appels.append(a) + todo._qemu_install_erplibre_monitored = lambda *a, **k: None + spec = { + "host": {"target": "root@10.0.0.5"}, + "vms": [ + { + "name": "vm-a", + "vmid": 100, + "distro": "ubuntu", + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + "install_cmd": "", + } + ], + "user": "erplibre", + "add_ssh_config": True, + "install": None, + "monitor": False, + "desktop": "", + } + with contextlib.redirect_stdout(io.StringIO()): + todo._pve_after_create(spec["host"], spec, ["vm-a"], "") + self.assertEqual(appels, []) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/test/test_qemu_ai_tools.py b/test/test_qemu_ai_tools.py index c0506b0..d7a34d3 100644 --- a/test/test_qemu_ai_tools.py +++ b/test/test_qemu_ai_tools.py @@ -27,6 +27,7 @@ Ce que ces tests gardent : import importlib.util import shlex +import shutil import subprocess import sys import unittest @@ -133,14 +134,21 @@ class LaCommandeDistante(unittest.TestCase): rallonge son ~/.bashrc — ou son historique — d'une ligne identique. Aucun ajout n'échappe à la règle : on compte les « >> » et non les - greps, pour qu'une ligne ajoutée sans garde fasse tomber le test.""" + greps, pour qu'une ligne ajoutée sans garde fasse tomber le test. + Chaque « >> » doit clore l'idiome ENTIER — grep, puis « || echo » de + la ligne citée —, lu d'un seul motif : la ligne citée peut porter + elle-même des « ; », et un découpage sur « ; » la couperait.""" + import re + + cite = r"(?:'(?:[^']|'\"'\"')*'|[^\s']+)" + garde = re.compile( + rf"grep -qF {cite} \S+ 2>/dev/null \|\| echo {cite} >> \S+" + ) for agent in ("claude", "opencode"): cmd = self._cmd(agent) with self.subTest(agent=agent): self.assertEqual(cmd.count(">> "), cmd.count("grep -qF")) - for morceau in cmd.split("; "): - if ">> " in morceau: - self.assertIn("grep -qF", morceau) + self.assertEqual(cmd.count(">> "), len(garde.findall(cmd))) def test_the_local_bin_is_on_the_path_for_every_agent(self): """rtk se pose dans ~/.local/bin. La ligne de l'agent ne couvre ce @@ -441,5 +449,259 @@ class LaSpec(unittest.TestCase): self.assertEqual("qui@exemple.invalid", spec["git_email"]) +# Outils réels prêtés à la commande distante dans un bac à sable : aucun ne +# touche au réseau, aux paquets ni aux droits. +_OUTILS_INOFFENSIFS = ( + "sh", + "bash", + "env", + "timeout", + "grep", + "chmod", + "head", + "mktemp", + "rm", + "cat", + "mkdir", + "touch", +) + + +def _bac_a_sable(test, faux, outils=_OUTILS_INOFFENSIFS): + """Un PATH qui ne porte QUE des faux et des outils inoffensifs. + + `faux` : nom -> corps d'un script sh posé dans ce PATH. curl, sudo et + les gestionnaires de paquets n'y sont que FAUX, ou pas du tout : la + commande distante tourne pour de vrai sans jamais atteindre le réseau + ni l'hôte. Le HOME est jetable. Rend (env, répertoire du PATH, HOME). + """ + import os + import shutil + import tempfile + + tmp = tempfile.TemporaryDirectory() + test.addCleanup(tmp.cleanup) + racine = Path(tmp.name) + faux_bin = racine / "bin" + home = racine / "home" + faux_bin.mkdir() + home.mkdir() + for outil in outils: + os.symlink(shutil.which(outil), faux_bin / outil) + for nom, corps in faux.items(): + chemin = faux_bin / nom + chemin.write_text("#!/bin/sh\n" + corps, encoding="utf-8") + chemin.chmod(0o755) + env = { + "PATH": str(faux_bin), + "HOME": str(home), + "TMPDIR": str(racine), + "FAUXBIN": str(faux_bin), + } + return env, faux_bin, home + + +# Faux curl : à chaque installateur amont, un script qui pose un binaire +# factice là où le vrai le poserait — starship dans le PATH, comme en root. +# Avec ECHEC, il rend 22 sans rien écrire, comme sur un 504. +_FAUX_CURL = ( + 'if [ -n "$ECHEC" ]; then echo "curl: (22) 504" >&2; exit 22; fi\n' + 'case "$*" in\n' + " *rtk*) echo 'poser rtk \"$HOME/.local/bin\"' ;;\n" + " *starship*) echo 'poser starship \"$FAUXBIN\"' ;;\n" + " *claude.ai*) echo 'poser claude \"$HOME/.local/bin\"' ;;\n" + " *opencode*) echo 'poser opencode \"$HOME/.opencode/bin\"' ;;\n" + " *) exit 22 ;;\n" + "esac\n" +) +_FAUX_POSER = ( + 'mkdir -p "$2"\n' + 'printf \'#!/bin/sh\\necho "%s 0.0.1"\\n\' "$1" > "$2/$1"\n' + 'chmod +x "$2/$1"\n' +) +# Trace, puis exécute SANS privilège ; « env » lit les « VAR=valeur » de +# tête comme le fait sudo. +_FAUX_SUDO = 'echo "$*" >> "$HOME/sudo.trace"\nexec env "$@"\n' + + +class LeVerdictDesOutilsAmont(unittest.TestCase): + """Après chaque installateur amont, une ligne qui dit s'il a abouti. + + Le code de sortie de la pose ne le peut pas : sans pipefail, un tube + rend le statut de l'interpréteur, 0 sur une entrée vide. La commande + distante tourne ici pour de vrai, sous « set -e », dans un bac à sable + où curl et sudo sont faux. + """ + + def _lancer(self, agent, **env_en_plus): + from unittest import mock + + with mock.patch("script.todo.qemu_install.t", lambda k: k): + cmd = TODO.__new__(TODO)._qemu_aidev_remote_cmd(agent) + env, faux_bin, home = _bac_a_sable( + self, + {"curl": _FAUX_CURL, "poser": _FAUX_POSER, "sudo": _FAUX_SUDO}, + ) + env.update(env_en_plus) + fini = subprocess.run( + [str(faux_bin / "bash"), "-c", "set -e\n" + cmd + "\necho FIN"], + env=env, + capture_output=True, + text=True, + timeout=120, + ) + return fini, home + + def test_without_a_download_each_tool_is_named_missing(self): + """Et l'installation continue : aucun outil optionnel ne la fait + tomber, pas même sa ligne de verdict.""" + for agent in ("claude", "opencode"): + with self.subTest(agent=agent): + fini, _home = self._lancer(agent, ECHEC="1") + self.assertEqual(0, fini.returncode, fini.stderr[-400:]) + self.assertIn("FIN", fini.stdout) + for nom in ("rtk", "starship", agent): + self.assertIn( + f"⚠ {nom} not installed (see above)", fini.stdout + ) + + def test_a_posed_tool_gives_its_version(self): + """opencode s'installe hors du PATH de ce shell : c'est le chemin + de repli qui le trouve.""" + for agent in ("claude", "opencode"): + with self.subTest(agent=agent): + fini, _home = self._lancer(agent) + self.assertEqual(0, fini.returncode, fini.stderr[-400:]) + for nom in ("rtk", "starship", agent): + self.assertIn(f"{nom}: {nom} 0.0.1", fini.stdout) + self.assertNotIn("⚠", fini.stdout) + + def test_starship_is_installed_as_root(self): + """En root, /usr/local/bin est inscriptible : l'installateur n'atteint + jamais « sudo -v », que sudo-rs refuse sans mot de passe même à un + compte NOPASSWD. L'hôte, lui, garde son installateur sans sudo.""" + self.assertIn( + shlex.quote(dev_tools.STARSHIP_UPSTREAM_VM), + TODO.__new__(TODO)._qemu_aidev_remote_cmd("claude"), + ) + self.assertIn("| sudo timeout -k ", dev_tools.STARSHIP_UPSTREAM_VM) + self.assertNotIn("sudo", dev_tools.STARSHIP_UPSTREAM) + _fini, home = self._lancer("claude") + self.assertIn( + f"timeout -k {dev_tools.STARSHIP_ROOT_KILL_AFTER}" + f" {dev_tools.STARSHIP_ROOT_TIMEOUT} sh -s -- -y", + (home / "sudo.trace").read_text(), + ) + + def test_the_root_installer_is_bounded_behind_sudo(self): + """Un « timeout » sans privilège ne tue pas un processus root : la + borne de l'installateur doit passer DERRIÈRE sudo. « -k » envoie + SIGKILL à un installateur qui ignore SIGTERM ; borne et délai + additionnés tombent avant la borne de la pose, qui ne tient plus que + curl.""" + import re + + vm = dev_tools.STARSHIP_UPSTREAM_VM + dedans = re.search(r"\| sudo timeout -k (\d+) (\d+) sh -s -- -y$", vm) + self.assertIsNotNone(dedans, vm) + cmd = TODO.__new__(TODO)._qemu_aidev_remote_cmd("claude") + dehors = re.search( + r"timeout (\d+) sh -c " + re.escape(shlex.quote(vm)), cmd + ) + self.assertIsNotNone(dehors, "la pose de starship n'est plus bornée") + self.assertLess( + int(dedans.group(1)) + int(dedans.group(2)), int(dehors.group(1)) + ) + + +class LeHookDuPrompt(unittest.TestCase): + """La ligne que le fichier du shell reçoit pour starship, lue par un vrai + shell. + + Le fichier est lu SEUL, sous « set -e », et rien ne suit la lecture : la + ligne est la dernière du fichier, dont le statut est donc le sien. Une + commande placée après masquerait ce statut. + """ + + def _sourcer(self, faux, shell="bash"): + binaire = shutil.which(shell) + if binaire is None: + self.skipTest(f"{shell} absent de cet hôte") + env, faux_bin, home = _bac_a_sable(self, faux, outils=()) + rc = home / f".{shell}rc" + rc.write_text(dev_tools.STARSHIP_LINE[shell] + "\n", encoding="utf-8") + # Ni ~/.bashrc ni ~/.zshrc lus d'office : seul `rc` est lu. + options = ["--norc"] if shell == "bash" else ["-f"] + return subprocess.run( + [binaire, *options, "-c", f'set -e; . "{rc}"'], + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + def test_without_the_binary_it_stays_silent_and_returns_zero(self): + """Une pose ratée, un binaire retiré : sans garde, chaque shell + ouvert écrirait « command not found ». Et la lecture rend 0 : un + script sous « set -e » qui lit ce fichier ne s'y arrête pas.""" + for shell in ("bash", "zsh"): + with self.subTest(shell=shell): + fini = self._sourcer({}, shell) + self.assertEqual("", fini.stderr) + self.assertEqual(0, fini.returncode) + + def test_with_the_binary_it_starts_starship(self): + for shell in ("bash", "zsh"): + with self.subTest(shell=shell): + fini = self._sourcer( + {"starship": 'echo "echo INIT-$2"\n'}, shell + ) + self.assertEqual(0, fini.returncode, fini.stderr) + self.assertIn(f"INIT-{shell}", fini.stdout) + + def test_the_guard_is_an_if_in_every_posix_shell(self): + """Le même « if … fi » pour bash et zsh : un hôte sans zsh ne lit + la ligne de zsh par aucun shell, sa forme reste donc vérifiée.""" + for shell in ("bash", "zsh"): + with self.subTest(shell=shell): + ligne = dev_tools.STARSHIP_LINE[shell] + self.assertTrue(ligne.startswith("if command -v starship ")) + self.assertTrue(ligne.endswith("; fi"), ligne) + + def test_the_dedup_pattern_survives_the_guard(self): + """L'hôte comme la VM reconnaissent une ligne déjà écrite à + « starship init » : la garde ne doit pas le masquer.""" + for shell, ligne in dev_tools.STARSHIP_LINE.items(): + with self.subTest(shell=shell): + self.assertIn(f"starship init {shell}", ligne) + + +class UneApostropheTraduite(unittest.TestCase): + def test_the_tool_blocks_stay_valid_shell(self): + """Les messages sont traduits, et le français est plein + d'apostrophes : une seule mal placée casse la commande distante + ENTIÈRE. On remplace la traduction elle-même — « set_lang » la + persisterait dans env_var.sh.""" + from unittest import mock + + # Un nombre IMPAIR d'apostrophes : entre apostrophes, un nombre pair + # se referme de lui-même, et « bash -n » ne verrait rien. + piege = "l'outil n'a pas « fini » aujourd'hui" + with mock.patch("script.todo.qemu_install.t", lambda k: piege): + todo = TODO.__new__(TODO) + blocs = { + f"aidev({agent})": todo._qemu_aidev_remote_cmd(agent) + for agent in dev_tools.AGENTS + } + blocs["mise"] = todo._qemu_mise_remote_cmd("mise") + for nom, cmd in blocs.items(): + with self.subTest(bloc=nom): + self.assertIn(piege, cmd) + fini = subprocess.run( + ["bash", "-n"], input=cmd, text=True, capture_output=True + ) + self.assertEqual(0, fini.returncode, fini.stderr[:400]) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_apt_mirror.py b/test/test_qemu_apt_mirror.py new file mode 100644 index 0000000..3957d3f --- /dev/null +++ b/test/test_qemu_apt_mirror.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le miroir apt d'une VM Ubuntu, tel que son cloud-config l'écrit. + +Derrière le cache, le miroir est fixe (« uri: ») : la recherche de +cloud-init (« search: ») écarte tout miroir derrière un résolveur qui répond +à tout nom, et une VM qui tire d'un autre miroir que les précédentes ne +retrouve rien de ce que le cache a gardé. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.qemu import deploy_qemu as DQ # noqa: E402 + + +class TestLeMiroirApt(unittest.TestCase): + def _apt(self, *extra): + args = DQ.build_parser().parse_args( + ["--distro", "ubuntu", "--hostname", "vm", *extra] + ) + return yaml.safe_load(DQ.build_cloud_config(args, None, []))["apt"] + + def _ca(self): + f = tempfile.NamedTemporaryFile( + "w", suffix=".pem", delete=False, encoding="utf-8" + ) + self.addCleanup(Path(f.name).unlink) + f.write( + "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" + ) + f.close() + return f.name + + def test_sans_cache_cloud_init_cherche(self): + apt = self._apt() + for bloc in ("primary", "security"): + self.assertEqual(apt[bloc][0]["search"], DQ.APT_MIRRORS_MAIN) + self.assertNotIn("uri", apt[bloc][0]) + + def test_derriere_le_cache_le_premier_miroir_est_fixe(self): + apt = self._apt("--cache-ca", self._ca()) + for bloc in ("primary", "security"): + self.assertEqual(apt[bloc][0]["uri"], DQ.APT_MIRRORS_MAIN[0]) + self.assertNotIn("search", apt[bloc][0]) + + def test_un_miroir_impose_est_fixe(self): + miroir = "http://miroir.invalid/ubuntu" + apt = self._apt("--apt-mirror", miroir) + self.assertEqual(apt["primary"][0]["uri"], miroir) + self.assertNotIn("search", apt["primary"][0]) + + def test_les_arches_ports_fixent_leur_propre_miroir(self): + lignes = DQ.apt_mirror_lines("arm64", fixe=True) + self.assertIn(f" uri: {DQ.APT_MIRRORS_PORTS[0]}", lignes) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_bypass.py b/test/test_qemu_cache_bypass.py new file mode 100644 index 0000000..1bb9107 --- /dev/null +++ b/test/test_qemu_cache_bypass.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Soustraire UNE VM au cache : l'exception par adresse MAC. + +Le détournement est transparent et vaut pour tout le pont. Une VM ne peut pas +s'y soustraire de l'intérieur, et lui retirer l'autorité ne la dispense de +rien : elle est interceptée quand même et échoue sur un certificat qu'elle ne +reconnaît pas. L'exception se pose donc sur l'HÔTE, et elle a besoin d'un +identifiant que la VM porte de façon stable. + +D'où ce que ces tests tiennent. L'adresse MAC est choisie AVANT la création : +la lire après laisserait la fenêtre où cloud-init télécharge déjà. Une MAC +posée à la main l'emporte sur le tirage. Et l'exception l'emporte sur +l'autorité, qu'il serait absurde de faire approuver à une VM qui ne +rencontrera jamais le cache. +""" + +import argparse +import sys +import unittest +from pathlib import Path +from unittest import mock + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.qemu import deploy_qemu # noqa: E402 + + +class FauxRunner: + """Retient les commandes. Rend ce qu'on lui a dit de rendre.""" + + def __init__(self, reponses=None): + self.commandes = [] + self.reponses = reponses or {} + self.use_sudo = True + self.dry_run = False + + def run(self, cmd, *, privileged=False, check=True, capture=False): + self.commandes.append(list(cmd)) + for motif, reponse in self.reponses.items(): + if motif in " ".join(cmd): + return reponse + return (0, "") if capture else None + + +def args_neufs(**kw): + base = dict( + name="vm-essai", + network="network=default,model=virtio", + cache_bypass=True, + cache_ca="", + distro="arch", + dry_run=False, + ) + base.update(kw) + return argparse.Namespace(**base) + + +class TestLaMACChoisie(unittest.TestCase): + def test_une_mac_deja_demandee_lemporte(self): + """Une MAC posée à la main sert une réservation DHCP : la remplacer + casserait ce que l'appelant a réglé ailleurs.""" + reseau = "network=default,model=virtio,mac=52:54:00:de:ad:be" + self.assertEqual( + deploy_qemu.mac_du_network(reseau), "52:54:00:de:ad:be" + ) + self.assertEqual( + deploy_qemu.network_avec_mac(reseau, "52:54:00:11:11:11"), reseau + ) + + def test_sans_mac_elle_est_ajoutee(self): + reseau = deploy_qemu.network_avec_mac( + "network=default,model=virtio", "52:54:00:11:22:33" + ) + self.assertEqual( + reseau, "network=default,model=virtio,mac=52:54:00:11:22:33" + ) + self.assertEqual( + deploy_qemu.mac_du_network(reseau), "52:54:00:11:22:33" + ) + + def test_la_mac_tiree_porte_le_prefixe_qemu(self): + mac = deploy_qemu.mac_neuve(set()) + self.assertTrue( + mac.startswith(deploy_qemu.MAC_PREFIXE), + f"{mac} n'est pas une adresse QEMU", + ) + self.assertEqual(len(mac.split(":")), 6) + + def test_une_mac_prise_nest_pas_retiree(self): + """Libvirt refuse un doublon, mais à la création — après le + téléchargement de l'image. Le refus doit venir avant.""" + prises = set() + for _ in range(30): + mac = deploy_qemu.mac_neuve(prises) + self.assertNotIn(mac, prises) + prises.add(mac) + + +class TestLExceptionEstPoseeAvant(unittest.TestCase): + def test_la_mac_entre_dans_le_reseau_avant_la_creation(self): + args = args_neufs() + runner = FauxRunner({"is-active": (0, "")}) + with mock.patch.object( + deploy_qemu.os.path, "isfile", return_value=True + ), mock.patch.object( + deploy_qemu.shutil, "which", return_value="/usr/bin/nft" + ), mock.patch.object( + deploy_qemu, "macs_deja_prises", return_value=set() + ): + mac = deploy_qemu.cache_bypass_apply(args, runner) + self.assertTrue(mac, "aucune MAC retenue") + self.assertIn(f"mac={mac}", args.network) + pose = [c for c in runner.commandes if "--bypass-add" in " ".join(c)] + self.assertTrue(pose, "l'exception n'a pas été posée") + self.assertIn("nft -f -", " ".join(pose[0])) + + def test_sans_cache_installe_rien_nest_pose(self): + """Exiger un cache pour pouvoir s'en passer n'aurait aucun sens : + sans lui, rien n'intercepte.""" + args = args_neufs() + runner = FauxRunner() + with mock.patch.object( + deploy_qemu.os.path, "isfile", return_value=False + ): + self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "") + self.assertEqual(runner.commandes, []) + self.assertNotIn("mac=", args.network) + + def test_service_arrete_rien_nest_pose(self): + args = args_neufs() + runner = FauxRunner({"is-active": (3, "")}) + with mock.patch.object( + deploy_qemu.os.path, "isfile", return_value=True + ): + self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "") + self.assertNotIn("mac=", args.network) + + def test_sans_la_demande_rien_ne_bouge(self): + args = args_neufs(cache_bypass=False) + runner = FauxRunner() + self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "") + self.assertEqual(runner.commandes, []) + + def test_sans_nft_le_service_est_repose(self): + """iptables n'a pas d'ensemble nommé : l'exception n'entre qu'en + reposant la chaîne, et seul un redémarrage la repose.""" + args = args_neufs() + runner = FauxRunner({"is-active": (0, "")}) + with mock.patch.object( + deploy_qemu.os.path, "isfile", return_value=True + ), mock.patch.object( + deploy_qemu.shutil, "which", return_value=None + ), mock.patch.object( + deploy_qemu, "macs_deja_prises", return_value=set() + ): + deploy_qemu.cache_bypass_apply(args, runner) + dit = [" ".join(c) for c in runner.commandes] + self.assertTrue( + any("systemctl" in c and "restart" in c for c in dit), + f"le service n'est pas reposé : {dit}", + ) + + +class TestLExceptionLemporteSurLAutorite(unittest.TestCase): + def test_pas_dautorite_dans_une_vm_exceptee(self): + """Elle ne rencontrera jamais le cache : poser cette signature dans + son magasin n'aurait aucun objet.""" + args = args_neufs(cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt") + self.assertEqual(deploy_qemu.cache_files(args), []) + + def test_la_ligne_de_commande_ne_porte_pas_les_deux(self): + from script.todo.qemu_deploy import QemuDeployMixin + + parts = QemuDeployMixin._qemu_build_deploy_parts( + mock.MagicMock(_qemu_script_path=lambda: "deploy_qemu.py"), + "arch", + "latest", + "x86_64", + "vm", + 4096, + 2, + "20G", + "", + "master", + True, + cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt", + cache_bypass=True, + ) + self.assertIn("--cache-bypass", parts) + self.assertNotIn("--cache-ca", parts) + + def test_sans_exception_lautorite_passe(self): + """Le cas courant : la VM approuve l'autorité et traverse le cache.""" + from script.todo.qemu_deploy import QemuDeployMixin + + parts = QemuDeployMixin._qemu_build_deploy_parts( + mock.MagicMock(_qemu_script_path=lambda: "deploy_qemu.py"), + "arch", + "latest", + "x86_64", + "vm", + 4096, + 2, + "20G", + "", + "master", + True, + cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt", + ) + self.assertIn("--cache-ca", parts) + self.assertNotIn("--cache-bypass", parts) + + +class TestLeMenageDesExceptions(unittest.TestCase): + """Une exception qui survit à sa VM est le danger de cette liste. + + L'adresse MAC est libérée par la destruction et se réattribue : l'entrée + restée derrière soustrairait au cache une machine neuve que personne n'a + exceptée. Ni la VM ni le cache ne le diraient — la VM télécharge + normalement, le journal du cache reste seulement muet à son sujet. + """ + + def test_une_vm_disparue_rend_son_entree_orpheline(self): + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + entrees = [ + ("52:54:00:00:00:01", "vm-vivante"), + ("52:54:00:00:00:02", "vm-detruite"), + ] + with mock.patch.object( + M, "_cache_domaines", return_value={"vm-vivante"} + ): + orphelines = M._cache_bypass_orphelines(entrees) + self.assertEqual(orphelines, [("52:54:00:00:00:02", "vm-detruite")]) + + def test_une_entree_sans_nom_nest_jamais_orpheline(self): + """Posée à la main : rien ne dit à quelle VM elle se rapporte, et la + retirer d'office déferait le travail de quelqu'un.""" + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + with mock.patch.object(M, "_cache_domaines", return_value=set()): + self.assertEqual( + M._cache_bypass_orphelines([("52:54:00:00:00:03", "")]), [] + ) + + def test_le_menage_retire_du_fichier_et_du_noyau(self): + """Les deux : le fichier est ce que le service reposera, l'ensemble + est ce qui s'applique en ce moment.""" + from script.todo import qemu_cache_menu as menu + + execute = mock.MagicMock() + with mock.patch.object( + menu.os.path, "isfile", return_value=True + ), mock.patch.object( + menu.QemuCacheMenuMixin, + "_cache_bypass_orphelines", + return_value=[("52:54:00:00:00:04", "partie")], + ): + self.assertEqual(menu.bypass_menage(execute), 1) + cmd = execute.exec_command_live.call_args.args[0] + self.assertIn("--bypass-del 52:54:00:00:00:04", cmd) + self.assertIn("nft -f -", cmd) + + def test_sans_cache_pose_le_menage_ne_fait_rien(self): + from script.todo import qemu_cache_menu as menu + + execute = mock.MagicMock() + with mock.patch.object(menu.os.path, "isfile", return_value=False): + self.assertEqual(menu.bypass_menage(execute), 0) + execute.exec_command_live.assert_not_called() + + def test_la_liste_est_lue_du_binaire(self): + """Lui seul normalise une adresse et saute une ligne fautive : une + seconde lecture écrite ici dériverait de la sienne.""" + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + sortie = "52:54:00:aa:bb:cc vm-une\n52:54:00:11:22:33\n\n" + with mock.patch.object( + M, "_cache_lire", return_value=sortie + ), mock.patch("os.path.isfile", return_value=True): + self.assertEqual( + M._cache_bypass_lire(), + [("52:54:00:aa:bb:cc", "vm-une"), ("52:54:00:11:22:33", "")], + ) + + +class TestLaSuppressionFaitLeMenage(unittest.TestCase): + def test_la_suppression_de_vm_appelle_le_menage(self): + """Le seul endroit qui SAIT que la VM vient de disparaître.""" + src = (RACINE / "script" / "todo" / "qemu_manage.py").read_text( + encoding="utf-8" + ) + bloc = src[src.index("def _qemu_delete_vm(") :] + bloc = bloc[: bloc.index("\n def ")] + self.assertIn("bypass_menage(self.execute)", bloc) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py new file mode 100644 index 0000000..79ff885 --- /dev/null +++ b/test/test_qemu_cache_charge.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que le test long télécharge, et sur quel système. + +Trois axes désormais : l'essai (cache, contre-épreuve, témoin), la charge +(un lot de paquets ou l'installation réelle) et la distribution. Chacun a sa +façon de se tromper en silence. + +La charge doit être IDENTIQUE d'une VM à l'autre, sans quoi la comparaison ne +compare rien. Elle doit aussi correspondre à la famille de la distribution : +un nom de paquet d'une autre famille fait échouer l'installation loin de sa +cause, après le déploiement d'une machine entière. + +Et le catalogue des systèmes n'est PAS recopié : il vient du déploiement. Une +seconde table proposerait un système que le déploiement ne sait pas installer, +ce que l'on ne découvrirait qu'en le lançant. +""" + +import importlib.util +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + + +def charger(): + chemin = RACINE / "long_test" / "qemu_cache.py" + spec = importlib.util.spec_from_file_location("qemu_cache_long", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +QC = charger() + + +class TestLeCatalogueVientDuDeploiement(unittest.TestCase): + def test_aucun_systeme_inconnu_du_deploiement(self): + from script.qemu.deploy_qemu import DISTROS + + for d in QC.systemes_mesurables(): + self.assertIn( + d, DISTROS, f"« {d} » n'est pas au catalogue du déploiement" + ) + + def test_chaque_systeme_a_une_famille_servie(self): + """Proposer un système dont on ne sait pas installer les paquets, c'est + déployer une VM entière pour échouer à la dernière étape.""" + for d in QC.systemes_mesurables(): + self.assertIn( + QC.famille_de(d), + QC.PAQUETS_MINIMUM, + f"« {d} » n'a pas de lot de paquets", + ) + + def test_proxmox_est_ecarte(self): + """Un hyperviseur : on n'y installe ni ERPLibre ni un lot de + développement, et le déploiement lui impose déjà son profil.""" + self.assertNotIn("proxmox", QC.systemes_mesurables()) + + def test_les_quatre_familles_sont_couvertes(self): + self.assertEqual( + set(QC.PAQUETS_MINIMUM), {"pacman", "apt", "dnf", "zypper"} + ) + + +class TestLaCharge(unittest.TestCase): + def test_chaque_famille_rend_une_commande(self): + for d in sorted(QC.systemes_mesurables()): + self.assertTrue( + QC.commande_de_charge(d, "minimum"), + f"aucune charge minimale pour « {d} »", + ) + + def test_un_systeme_hors_catalogue_ne_rend_rien(self): + """Rendre une commande vide plutôt qu'une commande fausse : le test + s'arrête en le disant, au lieu de lancer un shell vide dans la VM.""" + self.assertEqual(QC.commande_de_charge("haiku", "minimum"), "") + + def test_la_charge_reelle_contient_la_minimale(self): + """Elles partagent leur début, ce qui rend leurs mesures comparables + sur cette portion — et « make » n'existe pas avant que git l'ait + cloné.""" + for d in sorted(QC.systemes_mesurables()): + minimum = QC.commande_de_charge(d, "minimum") + reelle = QC.commande_de_charge(d, "erplibre") + self.assertTrue( + reelle.startswith(minimum), + f"« {d} » : la charge réelle ne part pas de la minimale", + ) + + def test_la_charge_reelle_clone_puis_installe(self): + cmd = QC.commande_de_charge("arch", "erplibre") + self.assertIn("git clone", cmd) + self.assertIn("install_odoo_18", cmd) + self.assertLess( + cmd.index("git clone"), + cmd.index("install_odoo_18"), + "la cible make est lancée avant que le dépôt existe", + ) + + def test_chaque_charge_a_son_delai(self): + """Un délai unique ferait échouer la courte ou laisserait la longue + pendre : ERPLibre se compte en heures, le lot en minutes.""" + self.assertEqual(set(QC.DELAI_CHARGE), {"minimum", "erplibre"}) + self.assertGreater( + QC.DELAI_CHARGE["erplibre"], QC.DELAI_CHARGE["minimum"] + ) + + def test_la_charge_attend_cloud_init_avant_tout(self): + """cloud-init réécrit la liste des dépôts à son premier démarrage. + + Une mise à jour lancée pendant ce remplacement récupère une partie des + index et s'arrête là SANS échouer : l'installation qui suit ne trouve + plus les paquets de « main », et le message accuse le paquet plutôt que + le moment. sshd répond bien avant que cloud-init ait fini. + """ + for d in sorted(QC.systemes_mesurables()): + cmd = QC.commande_de_charge(d, "minimum") + self.assertTrue( + cmd.startswith(QC.ATTENDRE_CLOUD_INIT), + f"« {d} » touche au gestionnaire de paquets sans attendre", + ) + + def test_lattente_tolere_un_cloud_init_en_erreur(self): + """Il sort en erreur pour un module accessoire — un fuseau que + l'invité ne connaît pas — et ce n'est pas une raison de renoncer.""" + self.assertIn("|| true", QC.ATTENDRE_CLOUD_INIT) + + def test_lattente_relit_les_variables_du_cache(self): + """La session de la charge s'ouvre avant que cloud-init n'écrive les + variables du cache : « sudo npm » les relit, pas un npm sans sudo.""" + from script.qemu.deploy_qemu import cache_env_reload + + i = QC.ATTENDRE_CLOUD_INIT.index("status --wait") + self.assertIn(cache_env_reload(), QC.ATTENDRE_CLOUD_INIT[i:]) + + def test_la_charge_est_du_shell_valide(self): + """Une instruction collée sans séparateur casse la commande entière, + et la VM ne dit alors pas pourquoi elle n'a rien installé.""" + import subprocess + + for d in sorted(QC.systemes_mesurables()): + for charge in ("minimum", "erplibre"): + cmd = QC.commande_de_charge(d, charge) + res = subprocess.run( + ["bash", "-n", "-c", cmd], capture_output=True, text=True + ) + self.assertEqual( + res.returncode, 0, f"« {d} », {charge} : {res.stderr}" + ) + + def test_apt_ne_cache_plus_ses_echecs(self): + """« apt-get update » rend ZÉRO même quand un index n'a pas pu être + récupéré : il n'émet qu'un avertissement, que « -qq » cachait.""" + rafraichir = QC.PAQUETS_MINIMUM["apt"][0] + self.assertIn("APT::Update::Error-Mode=any", rafraichir) + self.assertNotIn("-qq", rafraichir) + + def test_le_plan_a_blanc_montre_la_commande_exacte(self): + """Un plan qui montre autre chose que ce qui sera lancé n'est pas un + plan.""" + from unittest import mock + + with mock.patch.object(QC, "dire") as dit: + QC.poser_les_paquets( + "10.0.0.1", + None, + dry_run=True, + distro="debian", + charge="erplibre", + ) + annonce = " ".join(str(a) for c in dit.call_args_list for a in c.args) + self.assertIn(QC.commande_de_charge("debian", "erplibre"), annonce) + + +class TestLesOptions(unittest.TestCase): + def test_la_version_vide_vient_du_catalogue(self): + from script.qemu.deploy_qemu import DISTROS + + for d in sorted(QC.systemes_mesurables()): + defaut = DISTROS[d][1] + self.assertTrue( + defaut, f"« {d} » n'a pas de version par défaut au catalogue" + ) + + +class TestLeRapportSeClotSurUnEchec(unittest.TestCase): + """Une étape qui échoue arrête la boucle : le rapport doit le dire. + + Sans « fin » ni « verdict », un rapport laissé par une VM qui n'a pas + installé se lit comme une exécution encore en cours. + """ + + def boucler(self, echoue): + import argparse + import json + import tempfile + from unittest import mock + + args = argparse.Namespace( + dry_run=False, + sans_cache=False, + hors_ligne=False, + distro="debian", + version="12", + charge="minimum", + ) + with tempfile.TemporaryDirectory() as rep: + fichier = str(Path(rep) / "rapport.json") + rapport = {"_fichier": fichier, "vms": []} + with mock.patch.object(QC, "dire"), mock.patch.object( + QC, "noter_uuid" + ), mock.patch.object( + QC, + "deployer", + return_value="" if echoue == "deployer" else "10.0.0.1", + ), mock.patch.object( + QC, "attendre_ssh", return_value=echoue != "attendre_ssh" + ), mock.patch.object( + QC, + "poser_les_paquets", + return_value=echoue != "poser_les_paquets", + ): + code = QC._boucle(args, rapport, None, "", 0) + with open(fichier, encoding="utf-8") as fh: + return code, json.load(fh) + + def test_chaque_etape_en_echec_ecrit_fin_et_verdict(self): + for etape, mot in ( + ("deployer", "déploiement"), + ("attendre_ssh", "ssh"), + ("poser_les_paquets", "paquets"), + ): + with self.subTest(etape=etape): + code, ecrit = self.boucler(etape) + self.assertEqual(code, 1) + self.assertEqual(ecrit.get("verdict"), "échec") + self.assertTrue(ecrit.get("fin")) + self.assertIn(mot, ecrit.get("etape_en_echec", "")) + + def test_un_echec_n_est_pas_un_succes(self): + """Le verdict « ok » reste réservé à la boucle menée à son terme.""" + code, ecrit = self.boucler("deployer") + self.assertNotEqual(ecrit.get("verdict"), "ok") + self.assertEqual(len(ecrit["vms"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_form.py b/test/test_qemu_cache_form.py new file mode 100644 index 0000000..88844d5 --- /dev/null +++ b/test/test_qemu_cache_form.py @@ -0,0 +1,820 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""La case « cache » du formulaire QEMU, et ce qu'elle change à la commande. + +Trois propriétés, chacune pour une panne : + + 1. la case n'est offerte que là où elle a un effet — une case qui ne change + rien apprend au lecteur une chose fausse ; + 2. cochée, la commande porte « --cache-bypass » et JAMAIS l'autorité en + même temps : une VM exceptée ne rencontrera pas le cache, et cette + signature n'aurait rien à faire dans son magasin ; + 3. le chemin de l'autorité est le MÊME que celui où l'installateur la pose. + Les deux séparés, la VM approuverait un fichier qui n'existe pas — et + rien ne le dirait. + +L'écran Proxmox n'a pas cette case, et c'est voulu : sa VM naît sur un hôte +distant, que le cache local ne sert pas. +""" + +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path +from unittest import mock + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +QEMU_FORM = RACINE / "script" / "todo" / "qemu_deploy_form.py" +PROXMOX_FORM = RACINE / "script" / "todo" / "proxmox_deploy_form.py" +QEMU_DEPLOY = RACINE / "script" / "todo" / "qemu_deploy.py" +INSTALLATEUR = RACINE / "script" / "install" / "install_qemu_cache.sh" + +from script.todo.qemu_deploy import QemuDeployMixin # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + + +class TestLaCaseNePrometQueCeQuElleTient(unittest.TestCase): + """Une case a existé qui ne tenait pas sa promesse, et c'est l'histoire + de celle-ci. + + Décocher n'omettait que l'AUTORITÉ. L'interception étant transparente et + couvrant tout le pont, la VM était détournée quand même et échouait sur + « self-signed certificate in certificate chain » à chaque téléchargement + HTTPS : la case fabriquait une machine cassée. + + La case revient parce qu'elle a désormais de quoi tenir : une exception + par adresse MAC, posée sur l'hôte avant la création. Ce qui se vérifie + ici, c'est donc le lien — cochée, la commande porte « --cache-bypass », + et jamais l'autorité en même temps. + """ + + def test_la_case_mene_a_lexception_et_non_au_seul_retrait(self): + src = QEMU_FORM.read_text(encoding="utf-8") + self.assertIn("f_cache_bypass", src, "la case a disparu du formulaire") + self.assertIn( + "cache_bypass", + QEMU_DEPLOY.read_text(encoding="utf-8"), + "la case ne mène à rien dans la commande", + ) + + def test_lecran_proxmox_ne_loffre_pas(self): + """Sa VM naît sur un hôte distant, que le cache local ne sert pas.""" + self.assertNotIn( + "cache_bypass", PROXMOX_FORM.read_text(encoding="utf-8") + ) + + def test_la_case_est_gardee_par_letat_du_cache(self): + """Sans service actif rien n'intercepte : une case qui ne change rien + apprend au lecteur une chose fausse. + + Le drapeau est lu du CONTEXTE. La version d'avant le cherchait dans + « defaults », qui ne porte que ce qu'on veut pré-cocher et reste vide + au premier affichage : la case ne s'affichait jamais, et ce contrôle — + qui ne lisait que le source — l'attestait quand même. + """ + src = QEMU_FORM.read_text(encoding="utf-8") + self.assertIn('cache_offert = bool(ctx.get("cache_offert"))', src) + bloc = src[: src.index("f_cache_bypass")] + self.assertIn( + "if cache_offert:", + bloc[-500:], + "la case s'affiche sans égard à l'état du cache", + ) + + def test_lautorite_suit_le_service_et_non_une_case(self): + src = QEMU_DEPLOY.read_text(encoding="utf-8") + self.assertIn("_qemu_cache_active()", src) + self.assertNotIn( + 'spec.get("use_cache")', + src, + "l'autorité dépend encore d'un choix qui ne peut pas être tenu", + ) + + +class TestCommandeProduite(unittest.TestCase): + """Le drapeau passe par le POINT DE PASSAGE UNIQUE des deux interfaces.""" + + def vm(self): + return { + "distro": "arch", + "version": "latest", + "arch": "amd64", + "name": "essai", + "ram": 4096, + "vcpus": 2, + "disk": "20G", + } + + def parts(self, spec, ca="", actif=True): + # « TODO.__new__ » sans __init__ : l'idiome des tests du dépôt, qui + # donne toutes les méthodes du menu sans ouvrir d'écran. + todo = TODO.__new__(TODO) + # Ni l'autorité ni le service ne sont ceux de la machine de test : la + # détection est remplacée, le reste du chemin reste intact. + todo._qemu_cache_ca_path = lambda: ca + todo._qemu_cache_active = lambda: actif and bool(ca) + return todo._qemu_deploy_parts_for(self.vm(), spec, dry_run=True) + + def test_service_arrete_aucun_drapeau(self): + """Sans interception, l'autorité n'a rien à faire dans la VM.""" + parts = self.parts({"install": None}, ca="/tmp/ca.crt", actif=False) + self.assertNotIn("--cache-ca", parts) + + def test_service_actif_le_drapeau_et_le_chemin(self): + """Le service tourne : la VM SERA détournée, donc elle doit approuver + l'autorité, quoi qu'on ait coché.""" + parts = self.parts({"install": None}, ca="/tmp/essai-ca.crt") + self.assertIn("--cache-ca", parts) + self.assertEqual( + parts[parts.index("--cache-ca") + 1], + "/tmp/essai-ca.crt", + "le chemin de l'autorité ne suit pas le drapeau", + ) + + def test_cache_disparu_entre_temps(self): + """Le chemin est relu à CHAQUE commande : un cache désinstallé entre + le formulaire et le déploiement ne doit pas faire approuver une + autorité qui n'existe plus.""" + parts = self.parts({"install": None}, ca="") + self.assertNotIn("--cache-ca", parts) + + +class TestLInstallationNeSannonceQueSiElleAEuLieu(unittest.TestCase): + """Un code de sortie non nul n'est pas une réussite. + + Vécu : l'installateur est mort sur « réseau libvirt default introuvable », + a rendu 1, et l'entrée a imprimé « Cache de téléchargement QEMU installé + et démarré » juste en dessous, avec le chemin d'une autorité qui n'existe + pas. Un succès annoncé à tort coûte plus qu'une panne : on cherche + ensuite partout sauf là où elle est. + """ + + def _installer(self, code): + """Joue l'entrée 1 avec un installateur qui rend `code`.""" + import contextlib + import io + + import click + + todo = TODO.__new__(TODO) + todo.execute = mock.Mock() + todo.execute.exec_command_live = mock.Mock(return_value=code) + with mock.patch("builtins.input", return_value=""), mock.patch.object( + click, "confirm", return_value=True + ): + with contextlib.redirect_stdout(io.StringIO()) as sortie: + todo._deploy_qemu_cache() + return sortie.getvalue() + + def test_un_echec_ne_sannonce_pas_comme_une_reussite(self): + dit = self._installer(1) + self.assertNotIn("installed and started", dit) + self.assertNotIn("installé et démarré", dit) + self.assertNotIn("ca.crt", dit, "une autorité inexistante est nommée") + + def test_un_echec_dit_quoi_faire(self): + self.assertIn("1", self._installer(1)) + + def test_une_reussite_sannonce_et_nomme_lautorite(self): + dit = self._installer(0) + self.assertIn("ca.crt", dit) + + +class TestLeReseauDonneALaMain(unittest.TestCase): + """Un pont nommé à la main suffit : l'installateur ne sonde plus libvirt. + + Vécu ailleurs : sur une machine dont le réseau libvirt « default » n'est + pas démarré, l'installation mourait sur « réseau introuvable » — et le + contournement annoncé en tête du fichier, EL_BRIDGE et EL_SUBNET, ne + servait à rien, la sonde tombant AVANT que ces variables soient lues. + """ + + def _jouer(self, env): + """Exécute la VRAIE fonction, extraite du script, avec un « virsh » + qui échoue et des « log »/« die » de doublure.""" + source = INSTALLATEUR.read_text(encoding="utf-8") + corps = re.search( + r"^detecter_reseau\(\) \{.*?^\}", source, re.S | re.M + ) + self.assertIsNotNone(corps, "detecter_reseau introuvable") + script = ( + 'log() { echo "LOG: $*"; }\n' + 'die() { echo "DIE: $*" >&2; exit 1; }\n' + "virsh() { return 1; }\n" + f"{corps.group(0)}\n" + "detecter_reseau\n" + ) + return subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + env=dict(os.environ, EL_NET="default", **env), + timeout=30, + ) + + def test_les_deux_donnes_la_sonde_est_sautee(self): + res = self._jouer({"EL_BRIDGE": "virbr9", "EL_SUBNET": "192.0.2.0/24"}) + self.assertEqual(0, res.returncode, res.stderr) + self.assertIn("virbr9", res.stdout) + self.assertIn("192.0.2.0/24", res.stdout) + + def test_sans_eux_la_mort_nomme_les_deux_issues(self): + """Mourir est juste ; mourir sans dire quoi faire ne l'est pas.""" + res = self._jouer({"EL_BRIDGE": "", "EL_SUBNET": ""}) + self.assertNotEqual(0, res.returncode) + for issue in ("net-start", "EL_BRIDGE", "EL_SUBNET"): + self.assertIn(issue, res.stderr, res.stderr) + + def test_un_seul_des_deux_ne_suffit_pas(self): + """Le pont sans le sous-réseau laisserait des règles sans préfixe.""" + res = self._jouer({"EL_BRIDGE": "virbr9", "EL_SUBNET": ""}) + self.assertNotEqual(0, res.returncode) + + def test_la_garde_precede_la_sonde(self): + """L'ordre EST le correctif : lue après, la garde ne sauverait rien.""" + source = INSTALLATEUR.read_text(encoding="utf-8") + self.assertLess( + source.index('if [ -n "$EL_BRIDGE" ] && [ -n "$EL_SUBNET" ]'), + source.index("net-dumpxml"), + ) + + +class TestAccordAvecLInstallateur(unittest.TestCase): + def test_meme_chemin_dautorite(self): + """`EL_CA_DIR` du script et `QEMU_CACHE_CA` du menu doivent désigner + le même fichier.""" + texte = INSTALLATEUR.read_text(encoding="utf-8") + m = re.search(r'EL_CA_DIR="\$\{EL_CA_DIR:-([^}]+)\}"', texte) + self.assertIsNotNone(m, "EL_CA_DIR introuvable dans l'installateur") + attendu = f"{m.group(1)}/ca.crt" + self.assertEqual( + QemuDeployMixin.QEMU_CACHE_CA, + attendu, + "le menu cherche l'autorité là où l'installateur ne la pose pas", + ) + + def test_meme_nom_de_service(self): + texte = INSTALLATEUR.read_text(encoding="utf-8") + m = re.search(r"UNIT=\"/etc/systemd/system/([^\"]+)\"", texte) + self.assertIsNotNone(m, "le nom de l'unité est introuvable") + self.assertEqual( + QemuDeployMixin.QEMU_CACHE_SERVICE, + m.group(1), + "le menu interroge un service que l'installateur ne pose pas", + ) + + +class TestDetectionHote(unittest.TestCase): + def test_pas_dautorite_pas_de_chemin(self): + class Absent(QemuDeployMixin): + QEMU_CACHE_CA = "/inexistant/ca.crt" + + self.assertEqual(Absent._qemu_cache_ca_path(), "") + self.assertFalse( + Absent._qemu_cache_active(), + "un service est déclaré actif alors qu'aucune autorité n'existe", + ) + + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - Textual absent + TEXTUAL = False + + +def contexte_du_formulaire(): + todo = TODO.__new__(TODO) + mod = todo._qemu_import_module() + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + return todo._qemu_form_context(mod) + + +def choisir_une_vm(app): + """Coche la première entrée du catalogue et refait le plan. + + Sans sélection, « action_deploy » sort sur « Rien de sélectionné » et + n'atteint aucun des contrôles qu'on veut éprouver. + """ + from textual.widgets import SelectionList + + liste = app.query_one("#f_catalog", SelectionList) + liste.select(liste.options[0].value) + app._recompute() + return app.vms + + +def champs_affiches(ctx): + """Les identifiants réellement montés dans le panneau, sans écran.""" + import asyncio + + from script.todo.qemu_deploy_form import run_deploy_form + + vu = [] + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + vu.extend(w.id for w in app.query("#fields *") if w.id) + + asyncio.run(scenario()) + return vu + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLaCaseEstVraimentLa(unittest.TestCase): + """La case a existé sans jamais s'afficher, et personne ne le voyait. + + Elle était gardée par « defaults.get('cache_offert') ». Or « defaults » ne + porte que ce qu'on veut PRÉ-COCHER et il est vide au premier affichage : la + condition valait toujours faux. Les contrôles de source, eux, la trouvaient + bien dans le fichier — d'où un écran qui invitait à cocher une case + introuvable. + + Ces contrôles-ci MONTENT le formulaire et regardent ce qu'il affiche. + """ + + @classmethod + def setUpClass(cls): + cls.ctx = contexte_du_formulaire() + + def test_elle_saffiche_quand_le_cache_tourne(self): + champs = champs_affiches(dict(self.ctx, cache_offert=True)) + self.assertIn( + "f_cache_bypass", + champs, + "la case n'est pas montée alors que le cache tourne", + ) + + def test_elle_disparait_quand_le_cache_est_eteint(self): + """Une case sans effet apprend au lecteur une chose fausse.""" + champs = champs_affiches(dict(self.ctx, cache_offert=False)) + self.assertNotIn("f_cache_bypass", champs) + + def test_cochee_elle_arrive_dans_la_spec(self): + """Le dernier maillon : c'est la spec qui décide de la commande. + + Une case affichée mais dont la valeur n'est pas relue serait aussi + inutile qu'une case absente, et la panne se lirait au déploiement. + """ + import asyncio + + from textual.widgets import Checkbox + + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(self.ctx, cache_offert=True) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + case = app.query_one("#f_cache_bypass", Checkbox) + vu["defaut"] = case.value + case.value = True + await pilote.pause() + vu["spec"] = app._form_values().get("cache_bypass") + + asyncio.run(scenario()) + self.assertIs( + vu["defaut"], + False, + "la case est cochée d'avance : le cache" + " serait contourné sans qu'on l'ait demandé", + ) + self.assertIs(vu["spec"], True) + + def test_le_contexte_porte_bien_la_cle(self): + """L'autre moitié de la dérive : le menu doit la fournir là où le + formulaire la lit, c'est-à-dire au niveau du contexte.""" + self.assertIn("cache_offert", self.ctx) + self.assertNotIn( + "cache_offert", + self.ctx.get("defaults") or {}, + "la clé est repartie dans « defaults », où elle est ignorée", + ) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestRienDeCollecteNeSePerd(unittest.TestCase): + """Une liste blanche perd ce qu'on oublie d'y écrire, et sans un mot. + + Le formulaire collecte ses champs, puis « build_spec » assemble la spec — + clé par clé, nommées à la main. Un champ ajouté au formulaire et pas à + cette assemblée est réglé par l'opérateur, affiché, relu… et jeté. La + trace en porte déjà deux : le suivi et la 3D, chacun réparé après coup. + + Le contrôle ne vérifie plus une clé mais la PROPRIÉTÉ : tout ce que le + formulaire collecte doit se retrouver dans la spec. + """ + + def test_toute_cle_collectee_arrive_dans_la_spec(self): + import asyncio + + from script.todo.deploy_form_lib import build_spec + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(contexte_du_formulaire(), cache_offert=True) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + valeurs = app._form_values() + vu["perdues"] = sorted( + set(valeurs) - set(build_spec([], set(), valeurs)) + ) + vu["collectees"] = len(valeurs) + + asyncio.run(scenario()) + self.assertGreater( + vu["collectees"], 10, "le relevé n'a presque rien trouvé" + ) + self.assertEqual( + vu["perdues"], + [], + "des réglages du formulaire n'atteignent jamais le déploiement :" + f" {vu['perdues']}", + ) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLaSectionReseau(unittest.TestCase): + """« Sans connexion internet » : la même chose que « --hors-ligne ». + + Ce n'est pas le réseau de la VM qui tombe — elle en a besoin pour joindre + le cache — mais l'amont du service, le temps du déploiement. + """ + + @classmethod + def setUpClass(cls): + cls.ctx = contexte_du_formulaire() + + def setUp(self): + """Aucun test de cette classe ne pose de règle de pare-feu. + + Ils cochent tous la case, et le jour où le formulaire régresse en + coupant au clic, la coupure resterait sur la machine de test — aucun + « finally » ne court sur un test qui vient d'échouer. Le cache + rendrait 504 à toute VM déployée ensuite, et le message ne parlerait + pas d'une règle oubliée. + + La garde est ici, sur la CLASSE, et non dans le seul test qui + surveille : c'est celui qui ne surveillait pas qui a posé la règle. + + L'espion ne relaie RIEN à la vraie commande : ce qu'il n'attend pas + reçoit un échec vide. Le pré-vol de F5 lit le suivi des déploiements + et le journal du cache, puis interroge le binaire du service ; un + ajout futur au pré-vol ne peut donc pas atteindre la machine. Le + pré-vol par les essais précédents est remplacé pour toute la classe : + il lirait le vrai ~/.erplibre et le vrai journal, et le résultat + dépendrait de la machine qui lance les tests. + """ + from script.qemu import cache_offline + + self.lancees = [] + + def espion(cmd, *a, **kw): + texte = cmd if isinstance(cmd, str) else " ".join(map(str, cmd)) + self.lancees.append(texte) + if "nft" in texte: + return subprocess.CompletedProcess(cmd, 0, "", "") + return subprocess.CompletedProcess(cmd, 127, "", "") + + for patch in ( + mock.patch("subprocess.run", espion), + mock.patch.object( + cache_offline, "manques_hors_ligne", lambda vms: [] + ), + ): + patch.start() + self.addCleanup(patch.stop) + + def test_elle_saffiche_quand_le_cache_tourne(self): + champs = champs_affiches(dict(self.ctx, cache_offert=True)) + self.assertIn("f_offline", champs) + self.assertIn("t_network", champs, "la section n'a pas de titre") + + def test_elle_disparait_sans_cache(self): + """Sans cache il n'y a pas d'amont à couper : la case ne ferait + rien, et une case sans effet apprend une chose fausse.""" + champs = champs_affiches(dict(self.ctx, cache_offert=False)) + self.assertNotIn("f_offline", champs) + + def test_elle_vient_apres_le_parallelisme(self): + """« à la toute fin » : la section est la dernière du panneau.""" + champs = champs_affiches(dict(self.ctx, cache_offert=True)) + self.assertLess(champs.index("f_par"), champs.index("t_network")) + + def test_cochee_elle_arrive_dans_la_spec(self): + import asyncio + + from textual.widgets import Checkbox + + from script.todo.deploy_form_lib import build_spec + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(self.ctx, cache_offert=True) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + case = app.query_one("#f_offline", Checkbox) + vu["defaut"] = case.value + case.value = True + await pilote.pause() + valeurs = app._form_values() + vu["spec"] = build_spec([], set(), valeurs)["offline"] + + asyncio.run(scenario()) + self.assertIs( + vu["defaut"], + False, + "cochée d'avance, elle couperait l'amont sans qu'on l'ait" + " demandé", + ) + self.assertIs(vu["spec"], True) + + def test_lavertissement_ne_parait_que_cochee(self): + """Il dit deux choses qu'on ne devine pas : la coupure vaut pour + TOUS les usagers du cache, et elle ne tombe qu'au lancement.""" + import asyncio + + from textual.widgets import Checkbox + + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(self.ctx, cache_offert=True) + + def visibles(app): + return [ + w.id + for w in app.query("#fields *") + if str(w.id or "").startswith("t_offline_w") and w.display + ] + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 70)) as pilote: + await pilote.pause() + vu["decochee"] = visibles(app) + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + vu["cochee"] = visibles(app) + app.query_one("#f_offline", Checkbox).value = False + await pilote.pause() + vu["redecochee"] = visibles(app) + + asyncio.run(scenario()) + self.assertEqual( + vu["decochee"], [], "l'avertissement s'affiche sans être demandé" + ) + self.assertEqual(len(vu["cochee"]), 7, f"vu : {vu['cochee']}") + self.assertEqual( + vu["redecochee"], [], "il reste affiché après décochage" + ) + + def suivi_selon_la_case(self, suivi_avant): + """Coche puis décoche la case, le suivi réglé d'abord à + `suivi_avant` ; rend ce que le suivi et la spec disent à chaque + étape.""" + import asyncio + + from textual.widgets import Checkbox + + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(self.ctx, cache_offert=True) + + def etat(app): + case = app.query_one("#f_monitor", Checkbox) + return (case.value, case.disabled, app._form_values()["monitor"]) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 70)) as pilote: + await pilote.pause() + app.query_one("#f_monitor", Checkbox).value = suivi_avant + await pilote.pause() + vu["avant"] = etat(app) + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + vu["cochee"] = etat(app) + app.query_one("#f_offline", Checkbox).value = False + await pilote.pause() + vu["decochee"] = etat(app) + + asyncio.run(scenario()) + return vu + + def test_hors_ligne_le_suivi_est_force_et_grise(self): + """Seul le déploiement suivi confie la levée à une unité systemd : + sans lui, la promesse de l'avertissement — l'amont revient à la fin + de la dernière installation, 12 h au plus — ne tient pas.""" + vu = self.suivi_selon_la_case(False) + self.assertEqual(vu["avant"], (False, False, False)) + self.assertEqual( + vu["cochee"], + (True, True, True), + "hors ligne, le suivi reste décochable ou n'est pas forcé", + ) + + def test_decocher_rend_le_suivi_tel_quil_etait(self): + vu = self.suivi_selon_la_case(False) + self.assertEqual( + vu["decochee"], + (False, False, False), + "décocher la case ne rend pas au suivi sa valeur ni sa main", + ) + vu = self.suivi_selon_la_case(True) + self.assertEqual(vu["decochee"], (True, False, True)) + + def test_la_spec_exige_le_suivi_hors_ligne(self): + """La défense derrière l'écran : un suivi décoché par un chemin qui + contourne la case grisée part quand même suivi.""" + import asyncio + + from textual.widgets import Checkbox + + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(self.ctx, cache_offert=True) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 70)) as pilote: + await pilote.pause() + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + app.query_one("#f_monitor", Checkbox).value = False + await pilote.pause() + vu["valeurs"] = app._form_values() + + asyncio.run(scenario()) + self.assertIs(vu["valeurs"]["monitor"], True) + if vu["valeurs"]["install"]: + self.assertIs(vu["valeurs"]["install"]["monitor"], True) + + def test_cocher_la_case_ne_coupe_rien(self): + """La coupure tombe à F5, pas au clic. + + Couper depuis le formulaire priverait le cache de réseau pendant + qu'on remplit l'écran — et pour de bon si l'écran est annulé, aucun + « finally » ne courant sur une case cochée. + """ + import asyncio + + from textual.widgets import Checkbox + + from script.todo.qemu_deploy_form import run_deploy_form + + ctx = dict(self.ctx, cache_offert=True) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 70)) as pilote: + await pilote.pause() + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + app._form_values() + + asyncio.run(scenario()) + coupures = [c for c in self.lancees if "nft" in c] + self.assertEqual( + coupures, + [], + f"le formulaire a coupé l'amont tout seul : {coupures}", + ) + + def test_f5_previent_avant_de_couper_pour_rien(self): + """Une suite que le cache n'a jamais servie fait échouer la VM une + heure plus tard, sur « Impossible de trouver le paquet » — un message + qui ne parle ni du cache ni du hors ligne. + + Même idiome que les disques orphelins : on prévient une fois, F5 à + nouveau vaut passage outre. Passer outre reste possible — le journal + peut avoir tourné, ou le cache avoir été rempli autrement. + """ + import asyncio + + from textual.widgets import Checkbox + + from script.qemu import cache_offline + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(self.ctx, cache_offert=True) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 70)) as pilote: + await pilote.pause() + choisir_une_vm(app) + await pilote.pause() + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + app.action_deploy() + vu["premier"] = app._result.get("spec") + app.action_deploy() + vu["second"] = app._result.get("spec") + + # Les quatre autres verdicts sont truqués à vide : cette épreuve + # nomme la suite absente, et elle seule. Un verdict laissé libre lit + # la machine qui exécute les tests — son magasin, ses miroirs — et + # l'épreuve passerait alors pour une raison qui n'est pas la sienne. + with mock.patch.object( + cache_offline, "suites_absentes", lambda vms: [("ubuntu", "26.04")] + ), mock.patch.object( + cache_offline, "composants_absents", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "manques_hors_ligne", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "paquets_absents", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "miroirs_absents", lambda *a, **k: [] + ): + asyncio.run(scenario()) + self.assertIsNone( + vu["premier"], "le déploiement est parti sans prévenir" + ) + self.assertIsNotNone( + vu["second"], "un second F5 ne passe pas outre l'avertissement" + ) + + def test_f5_ne_previent_pas_quand_le_cache_a_de_quoi(self): + import asyncio + + from textual.widgets import Checkbox + + from script.qemu import cache_offline + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + ctx = dict(self.ctx, cache_offert=True) + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 70)) as pilote: + await pilote.pause() + choisir_une_vm(app) + await pilote.pause() + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + app.action_deploy() + vu["premier"] = app._result.get("spec") + + # Les CINQ verdicts se taisent : c'est la seule façon d'éprouver + # qu'un cache pourvu ne retarde personne. En laisser un libre le fait + # lire la machine qui exécute les tests — son magasin, ses miroirs, + # son journal — et l'épreuve échoue selon le poste. + with mock.patch.object( + cache_offline, "suites_absentes", lambda vms: [] + ), mock.patch.object( + cache_offline, "composants_absents", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "manques_hors_ligne", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "paquets_absents", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "miroirs_absents", lambda *a, **k: [] + ): + asyncio.run(scenario()) + self.assertIsNotNone( + vu["premier"], "un avertissement sans motif apprend à passer outre" + ) + + def test_le_formulaire_ne_sait_pas_couper(self): + """La garde structurelle : le formulaire LIT ce que le cache détient + — il en a besoin pour prévenir avant le lancement — mais il n'a + aucun moyen de poser ni de lever la coupure, quoi qu'on y ajoute. + + La frontière est là et non sur le module entier : c'est le geste qui + est interdit à cet écran, pas la connaissance. + """ + src = QEMU_FORM.read_text(encoding="utf-8") + for interdit in ("cut_cmd", "restore_cmd", "nft"): + self.assertNotIn( + interdit, + src, + f"le formulaire peut couper l'amont ({interdit})", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py new file mode 100644 index 0000000..35a7d23 --- /dev/null +++ b/test/test_qemu_cache_guest.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que le cache pose DANS la VM, et l'ordre dans lequel il le pose. + +L'interception est transparente au niveau TCP mais pas au niveau TLS : un +invité qui n'approuve pas l'autorité du cache rejette son certificat, et tout +téléchargement HTTPS échoue. Trois choses doivent donc arriver dans la VM, et +la troisième est un piège que la table seule ne dit pas : + + 1. le certificat, à l'endroit où la famille de distribution range ses ancres ; + 2. la commande qui fait relire ce magasin, AVANT tout téléchargement ; + 3. les variables que pip et npm exigent, parce qu'ils embarquent leur propre + jeu de certificats et ignorent le magasin système. + +La table des chemins existe deux fois — ici en Python, et dans +script/qemu_cache/rules.go côté cache. La duplication est assumée : deploy +n'a pas à dépendre du binaire pour générer un user-data. Le dernier test la +rend sûre en comparant les deux. +""" + +import argparse +import re +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.qemu.deploy_qemu import ( # noqa: E402 + CACHE_CERT_NAME, + CACHE_ENV_VARS, + CACHE_TRUST, + cache_env_reload, + cache_family, + cache_files, + cache_runcmd, +) + +RULES_GO = RACINE / "script" / "qemu_cache" / "rules.go" + +# Une autorité inventée : un certificat réel dans un test le figerait pour +# toujours, et celui d'un parc n'a rien à faire dans le dépôt. +PEM_DE_TEST = ( + "-----BEGIN CERTIFICATE-----\n" + "MIIBkTCB+wIJAOk0000000000MA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMTCXRl\n" + "c3QtY2FjaGUwHhcNMjYwMTAxMDAwMDAwWhcNMzYwMTAxMDAwMDAwWjAUMRIwEAYD\n" + "-----END CERTIFICATE-----\n" +) + + +def faux_args(tmp, distro="arch", ca=True): + """Le strict nécessaire : ces fonctions ne lisent que deux champs.""" + chemin = "" + if ca: + chemin = str(tmp / "ca.crt") + (tmp / "ca.crt").write_text(PEM_DE_TEST, encoding="utf-8") + return argparse.Namespace(distro=distro, cache_ca=chemin) + + +class TestFamilles(unittest.TestCase): + """Le catalogue est vérifié par TestAucunSystemeNestOublie, plus bas. + + La liste qui était écrite ici nommait « linuxmint », qui n'est pas un + système déployable, et taisait Proxmox, qui l'est : recopier le catalogue + dans un test le fige au jour où on l'a recopié. + """ + + def test_distribution_inconnue_ne_pose_rien(self): + self.assertEqual(cache_family("plan9"), "") + + +class TestCertificatPose(unittest.TestCase): + def test_pose_au_bon_endroit_par_famille(self): + import tempfile + + for distro, attendu in ( + ("arch", "/etc/ca-certificates/trust-source/anchors"), + ("debian", "/usr/local/share/ca-certificates"), + ("fedora", "/etc/pki/ca-trust/source/anchors"), + ("opensuse", "/etc/pki/trust/anchors"), + ): + with tempfile.TemporaryDirectory() as d: + fichiers = cache_files(faux_args(Path(d), distro)) + self.assertEqual(len(fichiers), 1, f"{distro} : rien de posé") + chemin, mode, contenu, _ = fichiers[0] + self.assertEqual(chemin, f"{attendu}/{CACHE_CERT_NAME}") + self.assertEqual( + mode, + "0644", + "le mode doit être une CHAÎNE : cloud-init lit un entier " + "non quoté en décimal et pose des droits absurdes", + ) + self.assertIn("BEGIN CERTIFICATE", contenu) + + def test_rien_sans_autorite_demandee(self): + import tempfile + + with tempfile.TemporaryDirectory() as d: + self.assertEqual(cache_files(faux_args(Path(d), ca=False)), []) + self.assertEqual(cache_runcmd(faux_args(Path(d), ca=False)), []) + + def test_autorite_illisible_ne_casse_pas_le_deploiement(self): + """Sans autorité la VM télécharge en direct, ce qui marche : un + chemin fautif ne doit pas empêcher de créer la machine.""" + args = argparse.Namespace(distro="arch", cache_ca="/inexistant/ca.crt") + self.assertEqual(cache_files(args), []) + + def test_fichier_qui_nest_pas_un_certificat_refuse(self): + import tempfile + + with tempfile.TemporaryDirectory() as d: + faux = Path(d) / "ca.crt" + faux.write_text("ceci n'est pas un certificat\n", encoding="utf-8") + args = argparse.Namespace(distro="arch", cache_ca=str(faux)) + self.assertEqual(cache_files(args), []) + + def test_distribution_hors_table_ne_pose_rien(self): + """Poser le fichier au hasard le rendrait inopérant sans le dire.""" + import tempfile + + with tempfile.TemporaryDirectory() as d: + self.assertEqual(cache_files(faux_args(Path(d), "plan9")), []) + + +class TestRuncmd(unittest.TestCase): + def setUp(self): + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.lignes = cache_runcmd(faux_args(Path(self.tmp.name), "arch")) + + def tearDown(self): + self.tmp.cleanup() + + def test_la_confiance_est_relue_en_premier(self): + """Tout ce qui suit peut télécharger : la commande doit précéder.""" + self.assertTrue(self.lignes, "aucune commande générée") + self.assertIn("trust extract-compat", self.lignes[0]) + + def test_les_trois_variables_sont_ecrites(self): + texte = "\n".join(self.lignes) + for var in CACHE_ENV_VARS: + self.assertIn(var, texte, f"{var} manque") + + def test_les_variables_visent_le_faisceau_pas_notre_ancre(self): + """Viser le seul certificat du cache ferait perdre à pip toutes les + autres autorités, et le casserait le jour où le cache disparaît.""" + _, _, faisceau = CACHE_TRUST["pacman"] + texte = "\n".join(self.lignes[1:]) + self.assertIn(faisceau, texte) + self.assertNotIn(CACHE_CERT_NAME, texte) + + def test_les_variables_vont_dans_etc_environment(self): + """PAM lit /etc/environment pour TOUTE session ssh, non interactive + comprise : c'est la seule voie qui atteint une commande distante.""" + for ligne in self.lignes[1:]: + self.assertIn("/etc/environment", ligne) + + def test_ecriture_idempotente(self): + """runcmd ne tourne qu'une fois par instance, mais un opérateur peut + rejouer la commande : elle ne doit pas empiler les doublons.""" + for ligne in self.lignes[1:]: + self.assertIn("grep -q", ligne) + + def test_aucune_commande_ne_peut_faire_echouer_le_boot(self): + """Une VM qui ne démarre pas pour un confort est un mauvais échange.""" + self.assertIn("|| true", self.lignes[0]) + + +class TestLesVariablesRelues(unittest.TestCase): + """Une session ouverte avant cloud-init ne reçoit pas ce qu'il écrit. + + PAM lit /etc/environment à l'ouverture, et la commande distante qui attend + cloud-init s'ouvre avant runcmd. La relecture est jouée ici dans un vrai + shell, sur un fichier de test : c'est son EFFET qui compte, pas son texte. + """ + + def relire(self, contenu): + """Lance la relecture sous « set -e » et rend (code, variables vues + par un processus ENFANT, PATH de la session).""" + import subprocess + import tempfile + + with tempfile.TemporaryDirectory() as rep: + fichier = Path(rep) / "environment" + if contenu is not None: + fichier.write_text(contenu, encoding="utf-8") + script = ( + f"set -e; {cache_env_reload(str(fichier))};" + ' env | grep -E "^(PIP_CERT|REQUESTS_CA_BUNDLE|NODE_EXTRA_CA_CERTS)="' + ' || true; echo "PATH=$PATH"' + ) + res = subprocess.run( + ["sh", "-c", script], + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin"}, + ) + return res.returncode, res.stdout, res.stderr + + def test_les_variables_atteignent_un_processus_enfant(self): + """npm est un ENFANT du shell : une variable posée sans « export » + ne l'atteindrait pas.""" + faisceau = "/etc/ssl/certs/ca-certificates.crt" + contenu = "".join(f"{v}={faisceau}\n" for v in CACHE_ENV_VARS) + code, sortie, err = self.relire(contenu) + self.assertEqual(code, 0, err) + for var in CACHE_ENV_VARS: + self.assertIn(f"{var}={faisceau}", sortie) + + def test_le_path_de_la_session_est_garde(self): + """Relire le fichier entier remplacerait le PATH de la session.""" + code, sortie, err = self.relire( + 'PATH="/nulle/part"\nNODE_EXTRA_CA_CERTS=/x.crt\n' + ) + self.assertEqual(code, 0, err) + self.assertIn("PATH=/usr/bin:/bin", sortie) + + def test_sans_fichier_ni_variable_rien_n_echoue(self): + """Une VM sans cache n'a ni les variables ni, parfois, le fichier : + sous « set -e », l'installation ne doit pas s'arrêter là.""" + for contenu in (None, "LANG=C\n"): + with self.subTest(contenu=contenu): + code, _, err = self.relire(contenu) + self.assertEqual(code, 0, err) + + +class TestAccordAvecLeGo(unittest.TestCase): + """La table Python et la table Go doivent dire la même chose. + + Sans ce test, la duplication dérive : un chemin corrigé d'un seul côté + laisse soit un cache qui annonce le mauvais répertoire, soit une VM dont + le certificat atterrit là où personne ne le lit. + """ + + def test_memes_chemins_et_memes_commandes(self): + go = RULES_GO.read_text(encoding="utf-8") + bloc = go[go.index("func GuestTrustCommand") :] + trouve = dict( + (famille, (dossier, commande, faisceau)) + for famille, dossier, commande, faisceau in re.findall( + r'case "([a-z]+)":\s*\n(?:\s*//[^\n]*\n)*' + r'\s*return "([^"]+)", "([^"]+)",\s*\n?\s*"([^"]+)", true', + bloc, + ) + ) + self.assertTrue(trouve, "aucune famille lue dans rules.go") + self.assertEqual( + trouve, + {k: tuple(v) for k, v in CACHE_TRUST.items()}, + "les tables Python et Go ont divergé", + ) + + def test_memes_variables(self): + go = RULES_GO.read_text(encoding="utf-8") + for var in CACHE_ENV_VARS: + self.assertIn( + var, go, f"{var} est écrite côté VM mais absente du Go" + ) + + +class TestAucunSystemeNestOublie(unittest.TestCase): + """Tout système déployable doit pouvoir recevoir l'autorité du cache. + + Le détournement s'applique à TOUT le pont : un invité qui ne reçoit pas + l'autorité est intercepté quand même et échoue sur « self-signed + certificate in certificate chain » à chaque téléchargement HTTPS. Le + message ne dit rien d'une table incomplète, et c'est ainsi que Proxmox est + resté sans autorité — la famille de paquets était recopiée à côté du + catalogue, et la copie l'avait oublié. + + Le contrôle porte donc sur la PROPRIÉTÉ : le catalogue et la table des + familles doivent couvrir les mêmes systèmes. + """ + + def test_chaque_systeme_du_catalogue_a_une_famille(self): + from script.qemu.deploy_qemu import DISTROS, cache_family + + sans = sorted(d for d in DISTROS if not cache_family(d)) + self.assertEqual( + sans, + [], + "ces systèmes seraient déployés SANS l'autorité du cache, et" + f" chaque téléchargement HTTPS y échouerait : {sans}", + ) + + def test_chaque_famille_sait_poser_lautorite(self): + from script.qemu.deploy_qemu import CACHE_TRUST, DISTROS, cache_family + + manquantes = sorted( + {cache_family(d) for d in DISTROS} - set(CACHE_TRUST) + ) + self.assertEqual( + manquantes, + [], + f"familles sans commande de confiance : {manquantes}", + ) + + def test_la_famille_vient_du_catalogue_et_nest_pas_recopiee(self): + """Deux tables qui disent la même chose dérivent : c'est ce qui a + laissé Proxmox de côté.""" + from script.qemu.deploy_qemu import DISTRO_PKG, cache_family + + for d, attendue in DISTRO_PKG.items(): + self.assertEqual(cache_family(d), attendue, d) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_install.py b/test/test_qemu_cache_install.py new file mode 100644 index 0000000..9b42f62 --- /dev/null +++ b/test/test_qemu_cache_install.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'installateur du cache QEMU pose-t-il ce qu'il faut, et rien de plus ? + +Ce script écrit des règles de pare-feu sur le pont de l'hôte. Une règle trop +large y prive la machine de son propre réseau, et une règle laissée derrière +un service arrêté envoie les VM vers un cache éteint. Les propriétés qui +empêchent ces deux pannes se lisent dans le texte du script et de l'unité, ce +qui se vérifie en secondes et sans toucher au pare-feu de la machine qui +exécute les tests. + +Le jeu de règles lui-même est vérifié en Go, où il vit : voir +script/qemu_cache/rules_test.go. Ce test contrôle qu'il n'en existe pas de +seconde copie ici. +""" + +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +SCRIPT = RACINE / "script" / "install" / "install_qemu_cache.sh" +SOURCES = RACINE / "script" / "qemu_cache" + + +class TestScriptDInstallation(unittest.TestCase): + def setUp(self): + self.assertTrue(SCRIPT.is_file(), f"script absent : {SCRIPT}") + self.texte = SCRIPT.read_text(encoding="utf-8") + + def test_syntaxe_bash(self): + r = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(r.returncode, 0, r.stderr) + + def test_executable(self): + self.assertTrue( + SCRIPT.stat().st_mode & 0o111, + "le script n'est pas exécutable : le menu l'appelle par bash, mais " + "un opérateur le lance directement", + ) + + def test_refuse_de_tourner_sans_root(self): + self.assertIn("check_root", self.texte) + self.assertRegex( + self.texte, + r"EUID.*-ne 0", + "rien ne vérifie que le script tourne en root", + ) + + def test_les_regles_ne_sont_pas_recopiees(self): + """Les règles viennent du binaire, seule source que les tests Go + vérifient. Une copie littérale ici dériverait en silence.""" + for motif in ("redirect to :", "REDIRECT --to-ports", "ip daddr !="): + self.assertNotIn( + motif, + self.texte, + f"le script porte une copie des règles ({motif!r}) : " + "elles doivent sortir de --print-nft / --print-iptables", + ) + self.assertIn("--print-nft", self.texte) + self.assertIn("--print-iptables", self.texte) + + def test_go_verifie_avant_de_compiler(self): + self.assertIn("GO_MIN_MAJOR", self.texte) + self.assertIn("GO_MIN_MINOR", self.texte) + self.assertIn( + "go_assez_recent", + self.texte, + "la version de Go n'est pas contrôlée : un golang trop ancien " + "échoue à la compilation avec un message qui ne le dit pas", + ) + + def test_compile_avant_de_toucher_au_systeme(self): + """L'ordre compte : une compilation échouée ne doit laisser ni unité, + ni règle, ni service à moitié posé. « set -e » y suffit, à condition + que la compilation précède.""" + self.assertIn("set -e", self.texte) + ordre = [ + self.texte.index(" compiler\n"), + self.texte.index(" ecrire_unite\n"), + ] + self.assertLess( + ordre[0], ordre[1], "l'unité est écrite avant la compilation" + ) + + def test_sources_verifiees(self): + self.assertIn( + "go.mod", + self.texte, + "le script ne vérifie pas que les sources sont là", + ) + + +class TestUniteSystemd(unittest.TestCase): + """L'unité telle qu'elle est ÉCRITE, et non telle qu'elle est écrite. + + Le heredoc est un gabarit : les règles y arrivent par substitution de + commande, et le compte de service par une variable. Lire le gabarit + reviendrait à vérifier autre chose que ce que systemd lira. La fonction + est donc appelée pour de vrai, vers un fichier temporaire, avec les + commandes du système neutralisées. + """ + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp() + unit = Path(cls.tmp) / "erplibre-go-qemu-cache.service" + # Le script se termine par « main "$@" » : la ligne est retirée pour + # pouvoir appeler UNE fonction sans rien installer. systemctl est + # neutralisé, la machine de test n'ayant pas à recharger systemd. + prelude = ( + # EL_SRC_DIR évite que la déduction du chemin des sources échoue : + # « source <(...) » fait pointer BASH_SOURCE sur /dev/fd. + f'export EL_SRC_DIR="{SOURCES}"\n' + f'source <(sed "/^main \\"\\$@\\"/d" {SCRIPT})\n' + "systemctl() { :; }\n" + f'UNIT="{unit}"\n' + "ecrire_unite\n" + ) + r = subprocess.run( + ["bash", "-c", prelude], capture_output=True, text=True + ) + if r.returncode != 0 or not unit.is_file(): + raise AssertionError( + f"l'unité n'a pas pu être générée : {r.stdout}{r.stderr}" + ) + cls.unite_texte = unit.read_text(encoding="utf-8") + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def setUp(self): + self.unite = self.unite_texte + + def test_les_regles_partent_a_larret(self): + """Sans cela, arrêter le cache laisse les VM détournées vers un port + que plus personne n'écoute : elles perdent tout téléchargement.""" + self.assertIn("ExecStopPost=", self.unite) + self.assertRegex( + self.unite, + r"ExecStopPost=\+", + "le retrait des règles ne demande pas le privilège root", + ) + self.assertIn("delete table ip erplibre_qemu_cache", self.unite) + + def test_les_regles_sont_posees_en_root_seulement(self): + """« + » fait tourner CETTE commande en root, alors que le service + sert les fichiers sous un compte sans privilège.""" + self.assertRegex(self.unite, r"ExecStartPre=\+") + self.assertRegex(self.unite, re.compile(r"^User=\S+$", re.M)) + self.assertNotRegex( + self.unite, + re.compile(r"^User=root$", re.M), + "le service sert des fichiers en root alors qu'il n'en a pas besoin", + ) + + def test_durcissement(self): + for directive in ( + "NoNewPrivileges=true", + "ProtectSystem=strict", + "ProtectHome=true", + ): + self.assertIn(directive, self.unite, f"{directive} manque") + + def test_les_repertoires_ecrivables_sont_declares(self): + """ProtectSystem=strict rend tout le système en lecture seule : sans + ReadWritePaths, le cache ne peut rien écrire et le service tourne en + échouant sur chaque objet.""" + m = re.search(r"ReadWritePaths=(.+)", self.unite) + self.assertIsNotNone(m, "ReadWritePaths manque") + chemins = m.group(1) + for attendu in ( + "/var/cache/erplibre_go_qemu_cache", + "/var/lib/erplibre_go_qemu_cache", + "/var/log/erplibre_go_qemu_cache.jsonl", + ): + self.assertIn(attendu, chemins, f"{attendu} n'est pas écrivable") + + def test_redemarre_apres_le_reseau(self): + self.assertIn("After=network.target", self.unite) + + def test_aucun_chemin_de_compte_dans_lunite(self): + """L'unité est un fichier système : y figer le checkout de celui qui a + installé la casse dès que le dépôt bouge, et fait entrer un nom de + compte dans /etc.""" + self.assertNotIn( + "/home/", + self.unite, + "l'unité porte un chemin de compte", + ) + self.assertNotIn("/root/", self.unite, "l'unité porte le compte root") + + +class TestSourcesGo(unittest.TestCase): + def test_module_present(self): + self.assertTrue( + (SOURCES / "go.mod").is_file(), "le module Go du cache est absent" + ) + + def test_binaire_non_versionne(self): + """Un « go build » dans les sources y laisse un binaire de plusieurs + mégaoctets : il ne doit pas pouvoir entrer dans un commit.""" + ignore = SOURCES / ".gitignore" + self.assertTrue(ignore.is_file(), ".gitignore absent des sources Go") + self.assertIn( + "erplibre_go_qemu_cache", ignore.read_text(encoding="utf-8") + ) + + @unittest.skipIf( + shutil.which("go") is None, + "Go absent : la suite unitaire doit rester lançable sans lui", + ) + def test_go_test(self): + r = subprocess.run( + ["go", "test", "./..."], + cwd=str(SOURCES), + capture_output=True, + text=True, + ) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_journal.py b/test/test_qemu_cache_journal.py new file mode 100644 index 0000000..7099b56 --- /dev/null +++ b/test/test_qemu_cache_journal.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le lecteur du journal d'accès : ce qu'il montre, et ce qu'il tait. + +La flèche est ce qui se lit sous une coupure : une requête sortie vers +l'internet est la seule chose qu'un déploiement hors ligne ne doit jamais +produire. +""" + +import io +import json +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.qemu import cache_journal # noqa: E402 + + +def _ligne(**kw): + d = { + "time": "2026-09-12T04:47:09Z", + "method": "GET", + "url": "http://miroir.invalid/ubuntu/pool/main/x/xz_5.6_amd64.deb", + "class": "immutable", + "outcome": "hit", + "status": 200, + "bytes": 2048, + "upstream": False, + "client": "192.168.0.2", + } + d.update(kw) + return json.dumps(d) + + +class TestLaMiseEnForme(unittest.TestCase): + def test_une_requete_servie_du_disque_ne_porte_pas_la_fleche(self): + ligne = cache_journal.ligne_lisible(_ligne()) + self.assertIn("·", ligne) + self.assertNotIn("↑", ligne) + self.assertIn("04:47:09", ligne) + self.assertIn("192.168.0.2", ligne) + self.assertIn("xz_5.6_amd64.deb", ligne) + + def test_une_requete_sortie_porte_la_fleche(self): + """La seule chose qu'un déploiement hors ligne ne doit pas produire.""" + self.assertIn("↑", cache_journal.ligne_lisible(_ligne(upstream=True))) + + def test_une_ligne_illisible_est_passee(self): + """Le journal s'écrit pendant qu'on le lit : sa dernière ligne est + parfois tronquée.""" + for brut in ('{"time": "2026-09', "", "\n", "[]", None): + self.assertEqual(cache_journal.ligne_lisible(brut), "") + + def test_sans_corps_la_taille_est_un_tiret(self): + ligne = cache_journal.ligne_lisible(_ligne(bytes=0, status=504)) + self.assertIn("504", ligne) + self.assertIn("-", ligne) + + def test_une_ligne_sans_client_reste_lisible(self): + d = json.loads(_ligne()) + del d["client"] + self.assertIn("—", cache_journal.ligne_lisible(json.dumps(d))) + + +class TestLeFiltreDeLAmont(unittest.TestCase): + def test_amont_ne_garde_que_ce_qui_est_sorti(self): + self.assertEqual( + cache_journal.ligne_lisible(_ligne(), amont_seul=True), "" + ) + self.assertNotEqual( + cache_journal.ligne_lisible( + _ligne(upstream=True), amont_seul=True + ), + "", + ) + + def test_le_filtre_se_demande_en_ligne_de_commande(self): + entree = io.StringIO( + _ligne() + "\n" + _ligne(upstream=True) + "\n" + _ligne() + "\n" + ) + sortie = io.StringIO() + self.assertEqual( + cache_journal.main(["--amont"], entree, sortie), + 0, + ) + self.assertEqual(len(sortie.getvalue().splitlines()), 1) + + +class TestLeFluxSansFin(unittest.TestCase): + """« tail -f » ne rend jamais la main : ce qui est lu doit sortir tout de + suite, sans quoi l'écran reste vide pendant des minutes.""" + + def test_chaque_ligne_est_vidangee_aussitot(self): + vidanges = [] + + class Sortie(io.StringIO): + def flush(self): + vidanges.append(self.getvalue().count("\n")) + + sortie = Sortie() + cache_journal.main( + [], io.StringIO(_ligne() + "\n" + _ligne() + "\n"), sortie + ) + self.assertEqual(vidanges, [1, 2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_menu.py b/test/test_qemu_cache_menu.py new file mode 100644 index 0000000..2208789 --- /dev/null +++ b/test/test_qemu_cache_menu.py @@ -0,0 +1,1054 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'entrée « cache QEMU » du menu Déploiement mène-t-elle où elle le dit ? + +Le sous-menu est écrit deux fois — une liste de `prompt_description` qui +numérote l'affichage, et une chaîne d'`elif status == "N"` qui dispatche. +Insérer une entrée au milieu décale les deux, et une seule erreur envoie +l'opérateur dans un autre écran sans que rien ne proteste : l'entrée du cache +est arrivée en 8, ce qui a poussé le VPN en 9. + +Le test vérifie aussi que chaque clé i18n de l'entrée résout DANS LES DEUX +LANGUES. Une clé absente rend sa propre chaîne anglaise, donc un menu +français qui affiche de l'anglais est le symptôme d'une clé oubliée, et rien +ne lève. +""" + +import ast +import re +import sys +import unittest +from pathlib import Path +from unittest import mock + +RACINE = Path(__file__).resolve().parent.parent +TODO_PY = RACINE / "script" / "todo" / "todo.py" +sys.path.insert(0, str(RACINE / "script" / "todo")) + +import todo_i18n # noqa: E402 + +# Les clés que l'entrée emploie. Les tenir ICI, et non les relire du code, +# fait échouer le test quand une clé disparaît du dictionnaire. +CLES = ( + "QEMU cache - Download mirror for local VMs", + "QEMU download cache for local VMs", + "Cache - Install or reinstall", + "Cache - Diagnose: does it serve?", + "Cache - Service state", + "Cache - VMs kept out of the cache", + "Cache - Git mirrors: fill them ahead", + "Cache - Age and cleanup", + "Cache - Guide: how it works", + "Cache - Tests and performance report", + "Install the download cache shared by the QEMU VMs of this host", + "HTTP port of the cache (default: 8898): ", + "TLS port of the cache (default: 8899): ", + "Cache directory (default: /var/cache/erplibre_go_qemu_cache): ", + "Will write network rules on the host bridge:", + "Only what leaves the VM subnet is redirected", + "The rules exist only while the service runs", + "Go is absent; the installer lays it down", + "Install the QEMU download cache?", + "Installing the QEMU download cache (requires sudo)...", + "QEMU download cache installed and started", + "Certificate authority a VM must trust: ", + "The cache install failed, nothing is started", + "QEMU cache install script not found: ", + "No eviction is written: this cache never shrinks by itself", + # Le sous-menu du service : allumer, éteindre, lire. + "State of the cache service", + "Stopping it removes the rules: no VM is redirected.", + "starts at boot", + "not at boot", + "Service - Start (start)", + "Service - Start at boot (enable)", + "Service - Do not start at boot (disable)", + "Service - Stop (stop)", + "Service - Detailed state (status)", + "Service - Logs (log)", + "Access log, last requests:", + # Âge et nettoyage : ce qui occupe, depuis quand, et de quoi en rendre. + "Age of the cache, and cleanup", + "Age - By day", + "Age - By week", + "Age - By month", + "Clean - What has not served for a while", + "Clean - Everything", + "Not served since (e.g. 30j, 12h)", + "Erase what is listed above?", + "Erase the whole cache?", + # Les exceptions par adresse MAC : une VM soustraite au détournement. + "VMs kept out of the download cache", + "Exceptions - Remove the stale ones", + "Exceptions - Remove one by its MAC", + "No exception: every VM goes through the cache.", + "MAC to give back to the cache", + "VM gone", + "Keep this VM out of the download cache", + "Keep this VM out of the download cache? (y/N): ", + # Combler ce qui a manqué hors ligne : l'entrée 9 et ses verdicts. + "Cache - Fill what offline runs lacked", + "What offline runs lacked", + "Start it from entry 3 of this menu.", + "A replay now would only record more misses.", + "No offline miss in the recent window: nothing to fill.", + "Everything that was missed is held now.", + "according to the log: a purge can make it wrong", + "replay through the cache", + "never kept: the cache keeps only GET and HEAD", + "the cache does not keep this address", + "host in tunnel: nothing to keep", + "git negotiation: fill the mirror from entry 5", + "not a host name: a replay could loop back into the cache", + "Nothing here can be replayed.", + "The replay sends curl's own headers: a server that varies on", + "User-Agent or Accept may keep another answer than the VM's.", + "Replay these addresses through the cache now?", + "held", + "not held", + "not re-checked: this binary has no --detient", + "Tunnel refusals learned by the service:", + "curl got no answer", + # La coupure que le guet tient, et celle qu'on ne peut pas lire : + # diagnostic et entrée 9. + "Upstream CUT by an offline deployment still installing,", + "held until its last installation ends (12 h at most).", + "Lifting it now makes those installations finish online.", + "Lift it now with:", + "The lift watcher still runs, with no cut left to lift.", + "Stop it with:", + "Cannot tell whether the upstream is cut: reading nft needs a sudo" + " password here.", + "Under the cut, a replay would only record more misses.", + "Replay anyway?", +) + +CACHE_PY = RACINE / "script" / "todo" / "qemu_cache_menu.py" + + +def corps_de(nom, suivant): + """Le corps d'une méthode de qemu_cache_menu.py, dispatch compris.""" + src = CACHE_PY.read_text(encoding="utf-8") + debut = src.index(f"def {nom}(self):") + return src[debut : src.index(f"def {suivant}(self", debut)] + + +def affichage_et_dispatch(corps): + """Rend (nombre d'entrées affichées, numéros atteignables, triés). + + Un numéro s'atteint de deux façons : une branche « status == "N" », ou + une entrée d'une table qui associe le numéro à un verbe. Ne compter que + les branches ferait passer pour un trou ce qu'une table couvre. + + Le zéro sort : il ferme le menu et n'est jamais affiché. + """ + affichees = len(re.findall(r'"prompt_description": t\(', corps)) + numeros = set(re.findall(r'if status == "(\d+)":', corps)) + # Toute table qui associe un numéro à quelque chose compte : le dispatch + # passe tantôt par une branche, tantôt par une table de verbes ou de + # granularités. N'en connaître qu'une ferait passer pour un trou ce + # qu'une autre couvre. + for table in re.findall(r"=\s*\{([^}]*)\}", corps): + numeros |= set(re.findall(r'"(\d+)"\s*:', table)) + numeros.discard("0") + return affichees, sorted(int(n) for n in numeros) + + +def corps_du_sous_menu(): + """Le corps de prompt_execute_deploy(), affichage et dispatch compris.""" + src = TODO_PY.read_text(encoding="utf-8") + debut = src.index("def prompt_execute_deploy(self):") + fin = src.index("def prompt_execute_deploy_ssh(self):", debut) + return src[debut:fin] + + +class TestEntreeDuCache(unittest.TestCase): + def setUp(self): + self.corps = corps_du_sous_menu() + + def test_entree_affichee(self): + self.assertIn( + "QEMU cache - Download mirror for local VMs", + self.corps, + "l'entrée du cache ne s'affiche pas dans le menu Déploiement", + ) + + def test_entree_dispatchee(self): + self.assertRegex( + self.corps, + r'elif status == "8":\s*\n\s*self\.prompt_execute_qemu_cache\(\)', + "l'entrée 8 ne mène pas au sous-menu du cache", + ) + + def test_vpn_decale_en_neuf(self): + """L'entrée insérée pousse le VPN : sans quoi deux entrées se + partagent le numéro 8 et la seconde est inatteignable.""" + self.assertRegex( + self.corps, + r'elif status == "9":\s*\n\s*self\.prompt_execute_vpn\(\)', + "le VPN n'a pas été décalé en 9", + ) + + def test_numeros_sans_trou_ni_doublon(self): + numeros = [ + int(n) for n in re.findall(r'elif status == "(\d+)":', self.corps) + ] + self.assertEqual( + numeros, + sorted(numeros), + f"les numéros du dispatch ne sont pas croissants : {numeros}", + ) + self.assertEqual( + len(numeros), + len(set(numeros)), + f"un numéro est dispatché deux fois : {numeros}", + ) + self.assertEqual( + numeros, + list(range(1, len(numeros) + 1)), + f"les numéros ne sont pas consécutifs à partir de 1 : {numeros}", + ) + + def test_methode_existe(self): + src = TODO_PY.read_text(encoding="utf-8") + self.assertIn( + "def _deploy_qemu_cache(self):", + src, + "la méthode que le dispatch appelle n'existe pas", + ) + + +class TestSousMenusDuCache(unittest.TestCase): + """Affichage et dispatch sont écrits deux fois, et rien ne les relie. + + La liste de `prompt_description` numérote l'écran ; la chaîne d'`elif + status` décide où l'on va. Une entrée insérée au milieu de l'une sans + l'autre envoie l'opérateur ailleurs qu'où il a lu, ou rend la dernière + entrée inatteignable — dans les deux cas sans un mot d'erreur. + """ + + def verifier(self, nom, suivant, attendues): + affichees, numeros = affichage_et_dispatch(corps_de(nom, suivant)) + self.assertEqual( + affichees, attendues, f"{nom} n'affiche pas {attendues} entrées" + ) + self.assertEqual( + numeros, + list(range(1, attendues + 1)), + f"{nom} : le dispatch {numeros} ne suit pas l'affichage", + ) + + def test_le_menu_du_cache(self): + self.verifier("prompt_execute_qemu_cache", "_cache_systemctl", 11) + + def test_le_menu_du_service(self): + self.verifier("_cache_service", "_cache_journal_service", 6) + + def test_le_menu_des_exceptions(self): + self.verifier("_cache_exceptions", "_cache_miroir_git", 2) + + def test_le_menu_des_miroirs(self): + self.verifier("_cache_miroir_git", "_cache_miroir_remplir", 5) + + def test_le_menu_de_lage(self): + self.verifier("_cache_age", "_cache_lancer", 5) + + def test_letat_du_service_est_la_troisieme(self): + """Sous le diagnostic, comme demandé : le décalage du guide et des + tests est la moitié du changement, et c'est celle qui casse.""" + corps = corps_de("prompt_execute_qemu_cache", "_cache_systemctl") + for numero, methode in ( + ("3", "_cache_service"), + ("4", "_cache_exceptions"), + ("5", "_cache_miroir_git"), + ("6", "_cache_age"), + ("7", "_cache_guide"), + ("8", "_cache_tests"), + ("9", "_cache_combler"), + ("10", "_cache_journaux"), + ("11", "_cache_transfert"), + ): + self.assertRegex( + corps, + rf'elif status == "{numero}":\s*\n\s*self\.{methode}\(\)', + f"l'entrée {numero} ne mène pas à {methode}", + ) + + def test_les_quatre_verbes_systemd(self): + """start, enable, disable et stop, et pas un cinquième par erreur.""" + corps = corps_de("_cache_service", "_cache_journal_service") + verbes = re.search(r"verbes = \{([^}]*)\}", corps) + self.assertIsNotNone(verbes, "la table des verbes a disparu") + self.assertEqual( + re.findall(r'"(\w+)"', verbes.group(1))[1::2], + ["start", "enable", "disable", "stop"], + ) + + +class TestLeTransfertDuCache(unittest.TestCase): + """Le magasin s'emporte ; les réglages restent. + + Un objet est rangé sous une clé tirée de l'URL, jamais de la machine qui + l'a pris : il vaut donc ailleurs. Le pont, le sous-réseau et l'autorité, + eux, appartiennent à l'hôte — emporter l'autorité ferait servir là-bas + une signature dont aucune VM locale n'a la clé. + """ + + def _menu(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + def test_le_flux_va_dun_tar_a_lautre_sans_fichier_intermediaire(self): + """Un magasin de dizaines de gigaoctets n'a pas à exister deux fois.""" + cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x") + # Un chemin sans caractère spécial ressort tel quel : c'est la forme + # RENDUE qu'on éprouve, pas celle qu'on imagine. + self.assertIn("tar -C /var/cache/x -cf - .", cmd) + self.assertIn("| zstd", cmd) + self.assertIn("ssh op@ailleurs", cmd) + self.assertIn("tar -C /var/cache/x -xf -", cmd) + self.assertNotIn(">", cmd, "un fichier intermédiaire est écrit") + + def test_larrivee_rend_les_fichiers_au_compte_du_service(self): + """Le même compte porte rarement le même numéro d'une machine à + l'autre : sans ce « chown », le service ne lirait pas son magasin.""" + from script.qemu import cache_offline + + cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x") + self.assertIn("--numeric-owner", cmd) + self.assertIn( + f"chown -R {cache_offline.SERVICE_USER}:" + f"{cache_offline.SERVICE_USER}", + cmd, + ) + + def test_une_cible_hostile_ne_secrit_pas_dans_la_commande(self): + """Le nom vient d'une invite : une apostrophe y casserait la ligne, + et le reste s'exécuterait sur CETTE machine.""" + import shlex + + mechant = "op@x'; rm -rf /; #" + cmd = self._menu()._cache_transfert_cmd(mechant, "/var/c") + # La preuve de l'échappement : le shell rend la cible en UN seul + # argument, identique à ce qui a été tapé. Chercher le texte dangereux + # dans la ligne ne prouverait rien — il y est, enfermé. + mots = shlex.split(cmd[cmd.index("ssh ") :]) + self.assertEqual(mots[0], "ssh") + self.assertEqual(mots[1], mechant) + + def test_larrivee_ne_demande_le_privilege_quune_fois(self): + """Un ticket sudo se périme ; le transfert, lui, dure. En deux + invocations, la seconde — le « chown » — tomberait après des + dizaines de minutes sur une demande que plus rien ne peut saisir : + le magasin serait posé, et illisible pour le compte qui le sert.""" + import shlex + + cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x") + distant = shlex.split(cmd[cmd.index("ssh ") :])[2] + self.assertEqual(distant.count("sudo "), 1, distant) + self.assertIn("chown -R", distant) + + def test_le_premier_temps_ne_demande_aucun_privilege_a_larrivee(self): + """C'est ce qui rend ce mode possible sans terminal : « cat » écrit + dans le répertoire personnel du compte ssh, qui lui appartient. Un + sudo à l'arrivée y ramènerait la panne qu'on contourne.""" + import shlex + + cmd = self._menu()._cache_envoi_fichier_cmd( + "op@ailleurs", "/var/cache/x" + ) + distant = shlex.split(cmd[cmd.index("ssh ") :])[2] + self.assertNotIn("sudo", distant, distant) + self.assertIn("cat > ~/", distant) + + def test_le_second_temps_extrait_rend_et_retire(self): + """Trois gestes, dans cet ordre : extraire, rendre au compte du + service, retirer le fichier. En oublier le dernier laisse le double + de l'occupation sur une machine qui n'avait déjà que la place.""" + cmd = self._menu()._cache_finir_la_bas_cmd("/var/cache/x") + self.assertIn("zstd -dc ~/", cmd) + self.assertIn("tar -C /var/cache/x -xf -", cmd) + self.assertIn("chown -R", cmd) + self.assertIn("rm -f ~/", cmd) + self.assertEqual(cmd.count("sudo "), 1, cmd) + + def test_les_reglages_ne_voyagent_pas(self): + """Ni l'autorité, ni le pont, ni le sous-réseau.""" + cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x") + for reste in ("ca.crt", "EL_BRIDGE", "EL_SUBNET", "/etc/"): + self.assertNotIn(reste, cmd) + + +class TestCeQuOnDitQuandLArriveeNeSuitPas(unittest.TestCase): + """Un refus doit nommer le geste qui le lève, et il n'est pas ici. + + L'entrée 1 pose le cache sur CETTE machine : y renvoyer fait réinstaller + l'hôte qui en a déjà un, pendant que la machine d'arrivée reste sans + rien. Trois situations appellent trois gestes — un lien ssh muet, un + cache absent, un cache privé de son compte de service — et un code de + retour unique les confondait sous un seul message. + + Les assertions portent sur des ancres qui ne se traduisent pas : une + commande, un chemin, un nom de variable. Comparer du texte traduit + ferait échouer le test au premier changement de langue. + """ + + def refus(self, code, sortie, confirmer=False, taille=0): + """Exerce l'entrée avec une sonde truquée. Rend (texte, lancées).""" + import contextlib + import io + + from script.todo.todo import TODO + + lancees = [] + sondes = [] + + class Faux(TODO): + def __init__(self): + self.execute = self + + def exec_command_live(self, cmd, **_kw): + lancees.append(cmd) + return 0 + + def _cache_ssh(self, cible, commande, timeout=30): + sondes.append(commande) + return code, sortie + + # La taille du magasin se lit par « sudo du » sur des dizaines + # de gigaoctets : le test la DONNE, sinon il mesurerait la + # machine qui l'exécute et durerait le temps d'un parcours. + def _cache_octets(self, chemin): + return taille + + tampon = io.StringIO() + with contextlib.ExitStack() as pile: + pile.enter_context( + mock.patch("click.prompt", return_value="op@ailleurs") + ) + pile.enter_context( + mock.patch("click.confirm", return_value=confirmer) + ) + # Le cache local et ses réglages ne sont pas le sujet : le test + # doit rendre le même verdict sur une machine qui n'en a pas. + pile.enter_context( + mock.patch( + "script.todo.qemu_cache_menu.os.path.isfile", + return_value=True, + ) + ) + pile.enter_context( + mock.patch( + "script.qemu.cache_offline.reglage", + return_value="/var/cache/x", + ) + ) + pile.enter_context(contextlib.redirect_stdout(tampon)) + Faux()._cache_transfert() + self.sondes = sondes + return tampon.getvalue(), lancees + + def test_un_lien_ssh_muet_nest_pas_un_cache_absent(self): + """Sans le jeton final, c'est la sonde qui n'a pas tourné : accuser + le cache enverrait installer ce qui est peut-être déjà là.""" + texte, lancees = self.refus(255, "") + self.assertIn("ssh-copy-id op@ailleurs", texte) + self.assertNotIn("install_qemu_cache.sh", texte) + self.assertEqual(lancees, [], "le magasin est parti malgré le refus") + + def test_un_ssh_qui_rend_zero_sans_rien_dire_compte_pour_muet(self): + """La sonde se termine TOUJOURS par son jeton. Un canal qui rend 0 + sans lui ne l'a donc pas exécutée : l'état du cache de là-bas est + inconnu, et l'annoncer absent serait inventer.""" + texte, lancees = self.refus(0, "") + self.assertIn("ssh-copy-id op@ailleurs", texte) + self.assertNotIn("install_qemu_cache.sh", texte) + self.assertEqual(lancees, []) + + def test_un_cache_absent_donne_les_gestes_a_faire_la_bas(self): + """Le geste est SUR l'arrivée, et l'installateur y meurt sans + libvirt : les deux issues sont nommées, pas seulement la première.""" + texte, lancees = self.refus(0, "FIN\n") + self.assertIn("1. ssh op@ailleurs", texte) + self.assertIn("git fetch && git switch ", texte) + self.assertIn("sudo bash script/install/install_qemu_cache.sh", texte) + self.assertIn("systemctl start libvirtd.socket", texte) + self.assertIn("net-start default", texte) + self.assertIn("EL_BRIDGE=", texte) + self.assertNotIn("ssh-copy-id", texte) + self.assertEqual(lancees, []) + + def test_la_branche_nommee_est_celle_de_cet_hote(self): + """L'installateur est un FICHIER du dépôt : une machine restée sur + une branche qui ne le porte pas répond « fichier introuvable », ce + qui ne ressemble en rien à un cache manquant et fait chercher la + panne ailleurs. Le nom ne se devine donc pas, il se lit ici.""" + from script.todo.todo import TODO + + branche = TODO._cache_branche_ici() + if not branche: + self.skipTest("dépôt en tête détachée : aucune branche à nommer") + texte, _lancees = self.refus(0, "FIN\n") + self.assertIn(f"git switch {branche}", texte) + + def test_un_cache_sans_son_compte_se_dit_autrement(self): + """Les fichiers arriveraient à root : le service ne les lirait pas. + Ce n'est pas une absence de cache, et le remède n'est pas le même.""" + from script.qemu import cache_offline + + texte, lancees = self.refus(0, "binaire\nFIN\n") + self.assertIn(cache_offline.SERVICE_USER, texte) + self.assertIn("sudo bash script/install/install_qemu_cache.sh", texte) + # Les lignes de libvirt appartiennent à l'autre cas : les voir ici + # dirait de réparer un réseau qui n'est pour rien dans la panne. + self.assertNotIn("libvirtd.socket", texte) + self.assertEqual(lancees, []) + + def test_une_arrivee_complete_mene_a_la_commande(self): + """La contre-épreuve : un jeton renommé ferait refuser une machine + prête, et le refus ne se verrait que le jour du transfert.""" + texte, lancees = self.refus(0, "binaire\ncompte\nsudo\nFIN\n") + self.assertIn("tar -C /var/cache/x -cf - .", texte) + self.assertNotIn("install_qemu_cache.sh", texte) + self.assertEqual(lancees, [], "la confirmation a été refusée") + + def test_un_sudo_qui_reclame_un_mot_de_passe_offre_les_deux_issues(self): + """Le magasin occupe l'entrée standard de ssh, qui porte des octets + et non un terminal : sudo refuse de lire un mot de passe ailleurs. + Un ticket pris d'avance n'y peut rien — sudo l'attache au terminal + qui l'a obtenu — donc les deux issues offertes sont d'élargir les + droits une fois, ou de passer par un fichier que le compte + d'arrivée écrit lui-même.""" + texte, lancees = self.refus(0, "binaire\ncompte\ncompte_ssh=op\nFIN\n") + self.assertIn("NOPASSWD", texte) + self.assertIn("/etc/sudoers.d/erplibre_cache", texte) + self.assertIn("op ALL=(root)", texte, "le compte lu n'est pas repris") + self.assertNotIn( + "sudo -v", texte, "le ticket ne marche pas, ne pas le conseiller" + ) + self.assertEqual(lancees, []) + + def test_la_place_manquante_ecarte_le_mode_en_deux_temps(self): + """Le fichier et le magasin extrait coexistent : il faut DEUX fois + la taille. L'annoncer après l'envoi laisserait une machine pleine + et un magasin à moitié posé.""" + texte, lancees = self.refus( + 0, + "binaire\ncompte\nplace_magasin=1000\nplace_compte=1000\nFIN\n", + confirmer=True, + taille=800, + ) + self.assertIn("✗", texte) + self.assertEqual(lancees, [], "l'envoi est parti malgré la place") + + def test_la_sonde_annonce_les_jetons_que_la_lecture_attend(self): + """La sonde et sa lecture sont les deux moitiés d'un accord : en + renommer un jeton d'un seul côté ferait refuser toute machine, et + le refus ne se verrait qu'au moment d'emporter le magasin. + + Les autres épreuves truquent la sonde pour choisir la situation : + aucune ne regarde ce qui part vraiment sur le lien. Celle-ci le + lit.""" + from script.qemu import cache_offline + from script.todo import qemu_cache_menu + + self.refus(0, "binaire\ncompte\nsudo\nFIN\n") + sonde = self.sondes[0] + for jeton in ( + "echo binaire", + "echo compte", + "sudo -n true", + "echo sudo", + "compte_ssh=", + "place_magasin=", + "place_compte=", + "echo FIN", + ): + self.assertIn(jeton, sonde) + self.assertIn(qemu_cache_menu.CACHE_BIN, sonde) + self.assertIn(cache_offline.SERVICE_USER, sonde) + + +class TestLesEntreesDesMiroirsVisentLeurListe(unittest.TestCase): + """Chaque entrée de remplissage passe SA liste, et pas une voisine. + + La base, l'extra et le remplissage complet partagent tout — l'en-tête, + la confirmation, la commande — sauf la liste qu'ils transmettent. Une + entrée « extra » qui passerait la base remplirait des dépôts déjà + complets et laisserait ceux qui manquent, sans que rien à l'écran ne le + trahisse : la commande affichée a la même forme dans les deux cas. + """ + + def remplir(self, choix): + """Pilote le sous-menu. Rend la liste reçue par le remplissage.""" + import contextlib + import io + + from script.todo.todo import TODO + + vu = {} + + class Faux(TODO): + def __init__(self): + pass + + def _cache_miroir_occupation(self): + return 0, "0 o" + + def _cache_place_libre(self): + return "?" + + def fill_help_info(self, choices): + return "" + + def _cache_miroir_remplir(self, liste): + vu["liste"] = list(liste) + + reponses = iter([choix, "0"]) + with contextlib.ExitStack() as pile: + pile.enter_context( + mock.patch( + "click.prompt", side_effect=lambda *a, **k: next(reponses) + ) + ) + # Le binaire installé et l'état des miroirs ne sont pas le sujet : + # le verdict doit tenir sur une machine qui n'a ni l'un ni l'autre. + pile.enter_context( + mock.patch( + "script.todo.qemu_cache_menu.os.path.isfile", + return_value=True, + ) + ) + pile.enter_context( + mock.patch( + "script.qemu.cache_offline.miroirs_absents", + return_value=[], + ) + ) + pile.enter_context(contextlib.redirect_stdout(io.StringIO())) + Faux()._cache_miroir_git() + return vu.get("liste") + + def version(self): + from script.todo.qemu_cache_menu import version_active + + v = version_active(str(RACINE)) + if not v: + self.skipTest("aucune version d'Odoo active dans ce checkout") + return v + + def test_lentree_base_passe_la_base(self): + from script.todo.qemu_cache_menu import depots_des_manifestes + + v = self.version() + self.assertEqual( + self.remplir("1"), depots_des_manifestes(str(RACINE), v) + ) + + def test_lentree_extra_passe_lextra(self): + from script.todo.qemu_cache_menu import ( + depots_des_manifestes, + manifeste_extra, + ) + + v = self.version() + extra = depots_des_manifestes( + str(RACINE), fichiers=[manifeste_extra(v)] + ) + if not extra: + self.skipTest(f"aucun manifeste extra pour {v}") + self.assertEqual(self.remplir("2"), extra) + + def test_lentree_complete_passe_tous_les_manifestes(self): + from script.todo.qemu_cache_menu import depots_des_manifestes + + self.assertEqual(self.remplir("3"), depots_des_manifestes(str(RACINE))) + + +class TestLesIconesDeLAssistant(unittest.TestCase): + """Chaque choix de l'assistant porte une icône, système compris. + + Les icônes des systèmes vivent dans le menu et non dans `distro_label`, + qui sert aussi aux formulaires de déploiement et aux journaux. Une + distribution ajoutée au catalogue sans icône retomberait sur l'icône de + repli sans que rien ne le dise : c'est ce que ces épreuves surveillent. + """ + + def test_chaque_systeme_mesurable_a_son_icone(self): + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + module = M._cache_module_test() + sans = sorted( + set(module.systemes_mesurables()) - set(M._CACHE_ICONES_SYSTEMES) + ) + self.assertEqual(sans, [], f"systèmes sans icône : {sans}") + + def test_le_libelle_du_catalogue_reste_intact(self): + """L'icône précède le libellé, elle ne le remplace pas.""" + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + module = M._cache_module_test() + for distro, libelle in M._cache_systemes(): + self.assertEqual( + libelle, + f"{M._CACHE_ICONES_SYSTEMES[distro]}" + f" {module.distro_label(distro, module.DISTROS[distro][1])}", + ) + + def test_les_essais_et_les_charges_portent_une_icone(self): + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + cles = [e[1] for e in M._CACHE_ESSAIS] + [ + c[1] for c in M._CACHE_CHARGES + ] + cles.append("All three, one after another") + for cle in cles: + for langue in ("fr", "en"): + valeur = todo_i18n.TRANSLATIONS[cle][langue] + self.assertFalse( + valeur[0].isalnum(), f"« {cle} » ({langue}) sans icône" + ) + + +class TestLAssistantDesTests(unittest.TestCase): + """Trois questions — quel essai, quelle charge, quel système — puis les + essais choisis, l'un après l'autre. + + Deux propriétés que rien d'autre ne tient. « Les trois » doit lancer TROIS + commandes, chacune portant le système et la charge choisis : en oublier un + ferait mesurer autre chose que ce qui a été demandé, sans rien dire. Et la + confirmation porte sur le LOT : la reposer à chaque essai la rendrait + machinale, ce qui est exactement ce qui fait qu'on cesse de la lire. + """ + + def assistant(self, reponses): + from script.todo.todo import TODO + + lancees = [] + + class Faux(TODO): + def __init__(self): + self.execute = self + + def exec_command_live(self, cmd, **_kw): + lancees.append(cmd) + + it = iter(reponses) + # « click.confirm » et « longtest_menu.click.confirm » sont le MÊME + # objet : un seul mock les couvre, et c'est ce qui rend le compte + # d'appels lisible — une question en tout, pas une par essai. + with mock.patch( + "click.prompt", side_effect=lambda *a, **k: next(it) + ), mock.patch("click.confirm", return_value=True) as confirme: + Faux()._cache_assistant() + return lancees, confirme + + def test_les_trois_lancent_trois_commandes(self): + lancees, _c = self.assistant(["4", "1", "2"]) + self.assertEqual(len(lancees), 3, f"lancées : {lancees}") + options = {"", "--hors-ligne", "--sans-cache"} + for attendu in options: + self.assertTrue( + any(c.rstrip().endswith(attendu) for c in lancees) + or attendu == "", + f"« {attendu} » n'a pas été lancé : {lancees}", + ) + + def test_le_systeme_et_la_charge_suivent_chaque_essai(self): + lancees, _c = self.assistant(["4", "2", "3"]) + for cmd in lancees: + self.assertIn("--distro debian", cmd) + self.assertIn("--charge erplibre", cmd) + + def test_un_seul_essai_ne_lance_que_lui(self): + lancees, _c = self.assistant(["3", "1", "2"]) + self.assertEqual(len(lancees), 1) + self.assertIn("--sans-cache", lancees[0]) + + def test_une_seule_question_pour_tout_le_lot(self): + """Trois essais, une question. La reposer à chaque essai la rendrait + machinale, ce qui est exactement ce qui fait qu'on cesse de la lire.""" + lancees, confirme = self.assistant(["4", "1", "2"]) + self.assertEqual(len(lancees), 3) + self.assertEqual( + confirme.call_count, + 1, + f"{confirme.call_count} confirmations pour trois essais", + ) + + def test_renoncer_ne_lance_rien(self): + for reponses in (["0"], ["4", "0"], ["4", "1", "0"]): + lancees, _c = self.assistant(reponses) + self.assertEqual(lancees, [], f"réponses {reponses}") + + +class TestToutesLesClesDuFichier(unittest.TestCase): + """La liste CLES est tenue à la main, donc elle oublie. + + Ce contrôle-ci ne tient aucune liste : il relève par l'ARBRE tout appel + « t("…") » du module et vérifie que le dictionnaire répond. Une clé + absente ne lève pas — t() rend sa propre chaîne anglaise — et le symptôme + est un menu français qui affiche une ligne en anglais, ce qu'aucun test de + numérotation ne voit. + """ + + def test_chaque_appel_a_sa_traduction(self): + arbre = ast.parse(CACHE_PY.read_text(encoding="utf-8")) + cles = { + n.args[0].value + for n in ast.walk(arbre) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Name) + and n.func.id == "t" + and n.args + and isinstance(n.args[0], ast.Constant) + and isinstance(n.args[0].value, str) + } + self.assertGreater(len(cles), 50, "le relevé n'a presque rien trouvé") + manquantes = sorted(c for c in cles if c not in todo_i18n.TRANSLATIONS) + self.assertEqual( + manquantes, + [], + "clés employées mais absentes du dictionnaire — le menu français" + f" affichera l'anglais : {manquantes}", + ) + + +class TestClesI18n(unittest.TestCase): + def test_cles_presentes(self): + manquantes = [c for c in CLES if c not in todo_i18n.TRANSLATIONS] + self.assertEqual( + manquantes, [], f"clés absentes du dictionnaire : {manquantes}" + ) + + def test_les_deux_langues_repondent(self): + for cle in CLES: + entree = todo_i18n.TRANSLATIONS[cle] + for langue in ("fr", "en"): + self.assertIn(langue, entree, f"« {cle} » n'a pas de {langue}") + self.assertTrue( + entree[langue].strip(), + f"« {cle} » a un {langue} vide", + ) + + def test_le_francais_est_traduit(self): + """Une valeur française identique à l'anglaise trahit une clé posée + sans traduction. Les libellés purement techniques y échappent.""" + sans_traduction = [ + c + for c in CLES + if todo_i18n.TRANSLATIONS[c]["fr"] + == todo_i18n.TRANSLATIONS[c]["en"] + ] + self.assertEqual( + sans_traduction, [], f"non traduites : {sans_traduction}" + ) + + def test_icones_des_entrees(self): + """L'icône vit DANS la chaîne traduite, comme partout ailleurs dans le + menu : les deux langues doivent donc la porter.""" + attendues = { + "QEMU cache - Download mirror for local VMs": "📦", + "Cache - Install or reinstall": "📥", + "Cache - Diagnose: does it serve?": "🔍", + "Cache - Service state": "⚙", + "Cache - VMs kept out of the cache": "🎫", + "Cache - Git mirrors: fill them ahead": "🪞", + "Cache - Age and cleanup": "🧭", + "Age - By day": "📅", + "Clean - Everything": "🔥", + "Cache - Guide: how it works": "📖", + "Exceptions - Remove the stale ones": "🧹", + "Exceptions - Remove one by its MAC": "✂", + "Service - Start (start)": "▶", + "Service - Start at boot (enable)": "🔗", + "Service - Do not start at boot (disable)": "🚫", + "Service - Stop (stop)": "⏹", + "Service - Detailed state (status)": "📋", + "Service - Logs (log)": "📜", + "Cache - Tests and performance report": "🧪", + "Cache - Fill what offline runs lacked": "🩹", + } + for cle, icone in attendues.items(): + entree = todo_i18n.TRANSLATIONS[cle] + for langue in ("fr", "en"): + self.assertTrue( + entree[langue].startswith(icone), + f"« {cle} » en {langue} ne porte pas {icone} :" + f" {entree[langue]}", + ) + + def test_aucune_cle_en_double(self): + """Une clé en double écrase silencieusement la précédente.""" + src = (RACINE / "script" / "todo" / "todo_i18n.py").read_text( + encoding="utf-8" + ) + for cle in CLES: + litteral = ' "%s": {' % cle.replace('"', '\\"') + self.assertEqual( + src.count(litteral), + 1, + f"« {cle} » apparaît {src.count(litteral)} fois", + ) + + +class TestLesIssuesDuJournalSontToutesMontrees(unittest.TestCase): + """Le diagnostic répond « le cache sert-il ? » : il ne peut rien taire. + + La version d'avant écrivait cinq issues en dur. Deux sont apparues depuis — + le tunnel opaque et le miroir git — et l'écran les taisait, alors que le + miroir portait à lui seul le tiers des lignes du journal. Une liste fermée + dérive dès qu'on ne la relit pas. + + Ce contrôle lit les issues que le CACHE définit, en Go, et exige que + l'ordre d'affichage les nomme toutes. + """ + + def issues_du_cache(self): + src = (RACINE / "script" / "qemu_cache" / "proxy.go").read_text( + encoding="utf-8" + ) + bloc = src[ + src.index("Outcome") : src.index("\n)", src.index("Outcome")) + ] + return set(re.findall(r'Outcome\w+\s*=\s*"([a-z-]+)"', bloc)) + + def test_lordre_nomme_toutes_les_issues(self): + from script.todo.qemu_cache_menu import ORDRE_ISSUES + + issues = self.issues_du_cache() + self.assertGreaterEqual(len(issues), 6, "le relevé n'a rien trouvé") + manquantes = sorted(issues - set(ORDRE_ISSUES)) + self.assertEqual( + manquantes, + [], + "des issues du journal ne seraient pas montrées à leur rang :" + f" {manquantes}", + ) + + def test_le_diagnostic_ne_filtre_pas_sur_cet_ordre(self): + """Une issue inconnue de l'ordre doit quand même paraître : c'est ce + qui empêche la liste de retaire quelque chose un jour.""" + src = (RACINE / "script" / "todo" / "qemu_cache_menu.py").read_text( + encoding="utf-8" + ) + bloc = src[src.index("def _cache_diagnostic") :] + bloc = bloc[: bloc.index("\n @")] + self.assertIn("set(compte) - set(ORDRE_ISSUES)", bloc) + + def test_les_objets_de_statut_suivent_stale(self): + """Une redirection ou un refus gardés se lisent à côté de ce qui a + été gardé puis resservi, et non à la fin parmi les inconnues.""" + from script.todo.qemu_cache_menu import ORDRE_ISSUES + + rang = ORDRE_ISSUES.index("stale") + self.assertEqual( + ORDRE_ISSUES[rang + 1 : rang + 3], + ("stored-status", "stale-status"), + ) + + +class TestCeQueChaqueMachineATire(unittest.TestCase): + """Un doute sur l'accélération ne s'instruit pas sur un total. + + Le journal disait ce que le cache avait fait, jamais POUR QUI. On ne + pouvait donc pas séparer ce qu'une VM a tiré du réseau de ce qu'une autre a + été servie du disque — et répondre demandait d'aller lire le journal à la + main, hors de l'outil. + """ + + def journal(self, lignes): + import json as _json + import tempfile + + f = tempfile.NamedTemporaryFile( + "w", suffix=".jsonl", delete=False, encoding="utf-8" + ) + for l in lignes: + f.write(_json.dumps(l) + "\n") + f.close() + self.addCleanup(lambda: Path(f.name).unlink(missing_ok=True)) + return f.name + + def par_machine(self, lignes, **kw): + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + chemin = self.journal(lignes) + with mock.patch.object(M, "_cache_journal", return_value=chemin): + return M._cache_par_machine(**kw) + + def test_le_disque_et_lamont_sont_separes(self): + got = self.par_machine( + [ + {"client": "10.0.0.1", "bytes": 100, "upstream": True}, + {"client": "10.0.0.1", "bytes": 900, "upstream": False}, + {"client": "10.0.0.2", "bytes": 50, "upstream": True}, + ] + ) + self.assertEqual( + dict(got), {"10.0.0.1": (900, 100), "10.0.0.2": (0, 50)} + ) + + def test_les_plus_gros_dabord(self): + """C'est la machine qui a le plus consommé qu'on cherche.""" + got = self.par_machine( + [ + {"client": "petit", "bytes": 10, "upstream": False}, + {"client": "gros", "bytes": 10000, "upstream": False}, + {"client": "moyen", "bytes": 500, "upstream": True}, + ] + ) + self.assertEqual([a for a, _ in got], ["gros", "moyen", "petit"]) + + def test_une_ligne_sans_client_est_ecartee(self): + """Le journal d'avant n'avait pas ce champ : ranger ses lignes sous un + nom inventé donnerait un relevé faux, pas un relevé incomplet.""" + got = self.par_machine( + [ + {"bytes": 5000, "upstream": True}, + {"client": "10.0.0.1", "bytes": 7, "upstream": False}, + ] + ) + self.assertEqual(dict(got), {"10.0.0.1": (7, 0)}) + + def test_la_liste_est_bornee(self): + got = self.par_machine( + [ + {"client": f"10.0.0.{i}", "bytes": i, "upstream": False} + for i in range(1, 30) + ], + limite=3, + ) + self.assertEqual(len(got), 3) + + def test_un_journal_absent_ne_casse_pas(self): + from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M + + with mock.patch.object(M, "_cache_journal", return_value=""): + self.assertEqual(M._cache_par_machine(), []) + with mock.patch.object( + M, "_cache_journal", return_value="/nexiste/pas.jsonl" + ): + self.assertEqual(M._cache_par_machine(), []) + + def test_le_cache_note_le_client(self): + """L'autre moitié : le service doit écrire le champ que ceci lit.""" + src = (RACINE / "script" / "qemu_cache" / "proxy.go").read_text( + encoding="utf-8" + ) + self.assertIn('Client string `json:"client,omitempty"`', src) + self.assertGreaterEqual( + src.count("Client: clientDe(") + src.count("Client: client,"), + 4, + "un chemin du journal n'écrit pas le client", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_mesure.py b/test/test_qemu_cache_mesure.py new file mode 100644 index 0000000..92fdde4 --- /dev/null +++ b/test/test_qemu_cache_mesure.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le verdict du test long dit-il la vérité sur ce qu'il a mesuré ? + +Le test long crée deux VM et dure des dizaines de minutes ; son CRITÈRE, lui, +est une fonction pure sur des lignes de journal. La tester ici coûte des +millisecondes et évite la seule chose qu'un test de quarante minutes ne +pardonne pas : un verdict faux au bout de la course. + +Le piège que ces cas verrouillent : Arch est une publication continue. Entre +les deux déploiements, un miroir publie des versions neuves que la seconde VM +tirera légitimement — le cache ne sert jamais un index tant que l'amont +répond, donc elle les VERRA. Un critère fondé sur « zéro octet d'amont » +déclarerait le cache en panne alors qu'il fonctionne. +""" + +import argparse +import importlib.util +import json +import re +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE / "long_test")) + + +def charger(): + """Le script long chargé comme module : il n'est pas un paquet.""" + chemin = RACINE / "long_test" / "qemu_cache.py" + spec = importlib.util.spec_from_file_location("qemu_cache_long", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +QC = charger() + + +def ligne(url, upstream, octets=1000, issue="stored", classe="immutable"): + """Une ligne du journal d'accès du cache. + + La CLASSE est ce que le filtre lit désormais : c'est le cache qui décide + si un fichier est figé, et la tenir ici en dur revenait à laisser un index + se faire passer pour un paquet. + """ + return { + "url": url, + "upstream": upstream, + "bytes": octets, + "outcome": issue, + "class": classe, + } + + +PAQUET_A = ( + "https://miroir.example/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst" +) +PAQUET_B = ( + "https://miroir.example/arch/core/os/x86_64/git-2.51-1-x86_64.pkg.tar.zst" +) +PAQUET_NEUF = ( + "https://miroir.example/arch/core/os/x86_64/rust-1.91-1-x86_64.pkg.tar.zst" +) +INDEX = "https://miroir.example/arch/core/os/x86_64/core.db" + + +class TestFiltre(unittest.TestCase): + def test_les_index_sont_ecartes(self): + """Un index n'est JAMAIS servi du cache quand l'amont répond : le + compter ferait échouer un test qui mesure autre chose.""" + lignes = [ligne(INDEX, True, classe="volatile"), ligne(PAQUET_A, True)] + gardees = QC.paquets_seulement(lignes) + self.assertEqual(len(gardees), 1) + self.assertEqual(gardees[0]["url"], PAQUET_A) + + def test_toutes_les_familles_de_paquets(self): + """Le filtre ne connaissait que les extensions d'Arch : sur Ubuntu ou + Fedora, la mesure ne trouvait AUCUN fichier et déclarait que rien + n'avait traversé le cache, alors qu'il venait de servir une + installation entière. Il lit maintenant la classe que le cache pose.""" + for url in ( + PAQUET_A.replace(".pkg.tar.zst", ".pkg.tar.xz"), + "http://miroir.example/pool/main/b/bash/bash_5.2-1_amd64.deb", + "http://miroir.example/Packages/b/bash-5.2-1.fc43.x86_64.rpm", + "https://pypi.example/ab/cd/requests-2.33.0-py3-none-any.whl", + ): + self.assertEqual( + len(QC.paquets_seulement([ligne(url, True)])), + 1, + f"« {url} » n'est pas compté comme fichier figé", + ) + + def test_le_filtre_ne_lit_pas_lextension(self): + """Un fichier que le cache dit volatile est écarté quel que soit son + nom : la décision lui appartient, et une seconde table dériverait.""" + self.assertEqual( + QC.paquets_seulement([ligne(PAQUET_A, True, classe="volatile")]), + [], + ) + + def test_une_url_absente_ne_casse_pas(self): + self.assertEqual(QC.paquets_seulement([{"upstream": True}]), []) + + +class TestVerdict(unittest.TestCase): + def test_le_cache_a_servi(self): + premier = [ligne(PAQUET_A, True), ligne(PAQUET_B, True)] + second = [ + ligne(PAQUET_A, False, issue="hit"), + ligne(PAQUET_B, False, issue="hit"), + ] + self.assertTrue(QC.verdict(premier, second, None)) + + def test_un_paquet_deja_vu_ressorti_est_un_echec(self): + premier = [ligne(PAQUET_A, True)] + second = [ligne(PAQUET_A, True)] + self.assertFalse( + QC.verdict(premier, second, None), + "un fichier déjà tiré est ressorti sur le réseau sans que le " + "verdict s'en plaigne", + ) + + def test_un_paquet_neuf_ne_fait_pas_echouer(self): + """LE cas qui justifie le critère : le miroir a publié entre les deux + déploiements. La seconde VM tire légitimement du neuf, et le cache a + pourtant parfaitement servi ce qu'il avait.""" + premier = [ligne(PAQUET_A, True)] + second = [ + ligne(PAQUET_A, False, issue="hit"), + ligne(PAQUET_NEUF, True, octets=90_000_000), + ] + self.assertTrue( + QC.verdict(premier, second, None), + "un paquet publié entre les deux VM a été pris pour une panne", + ) + + def test_une_mesure_vide_est_un_echec(self): + """Le cas qui a coûté vingt minutes : un détournement posé sur le + mauvais sous-réseau laisse les VM sortir en direct. Le cache ne voit + rien, le verdict n'a « aucune faute » à signaler — et rendre vrai + déclarerait un succès qu'il n'a jamais mesuré.""" + self.assertFalse( + QC.verdict([], [], None), + "un cache que personne ne traverse est déclaré bon", + ) + + def test_une_seconde_vm_muette_est_un_echec(self): + """La première a rempli, la seconde n'a rien demandé : il n'y a pas + de mesure, donc pas de succès.""" + self.assertFalse(QC.verdict([ligne(PAQUET_A, True)], [], None)) + + def test_le_melange(self): + premier = [ligne(PAQUET_A, True), ligne(PAQUET_B, True)] + second = [ + ligne(PAQUET_A, False, issue="hit"), + ligne(PAQUET_B, True), + ligne(PAQUET_NEUF, True), + ] + self.assertFalse( + QC.verdict(premier, second, None), + "un seul fichier déjà vu ressorti suffit à faire échouer", + ) + + +class TestLectureDuJournal(unittest.TestCase): + def test_ne_lit_que_ce_qui_suit_le_decalage(self): + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "acces.jsonl" + p.write_text( + json.dumps(ligne(PAQUET_A, True)) + "\n", encoding="utf-8" + ) + decalage = p.stat().st_size + with p.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(ligne(PAQUET_B, False)) + "\n") + lues, neuf = QC.lignes_depuis(str(p), decalage) + self.assertEqual([x["url"] for x in lues], [PAQUET_B]) + self.assertEqual(neuf, p.stat().st_size) + + def test_journal_tourne_repart_du_debut(self): + """Un journal plus court que le décalage a été remplacé : lire à + l'ancienne position rendrait n'importe quoi, ou rien.""" + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "acces.jsonl" + p.write_text( + json.dumps(ligne(PAQUET_A, True)) + "\n", encoding="utf-8" + ) + lues, _ = QC.lignes_depuis(str(p), 10_000_000) + self.assertEqual(len(lues), 1) + + def test_derniere_ligne_incomplete_sautee(self): + """Le service écrit pendant qu'on lit : la dernière ligne peut être + tronquée, et une exception ici perdrait toute la mesure.""" + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "acces.jsonl" + p.write_text( + json.dumps(ligne(PAQUET_A, True)) + '\n{"url": "tron', + encoding="utf-8", + ) + lues, _ = QC.lignes_depuis(str(p), 0) + self.assertEqual(len(lues), 1) + + def test_journal_absent(self): + lues, decalage = QC.lignes_depuis("/inexistant/acces.jsonl", 42) + self.assertEqual(lues, []) + self.assertEqual(decalage, 42) + + +class TestConventionsDuTestLong(unittest.TestCase): + """Ce que « long_test/ » exige de ses scripts.""" + + def setUp(self): + self.source = (RACINE / "long_test" / "qemu_cache.py").read_text( + encoding="utf-8" + ) + + def test_defaire_et_a_blanc(self): + for drapeau in ("--dry-run", "--detruire"): + self.assertIn(drapeau, self.source, f"{drapeau} manque") + + def test_destruction_par_uuid(self): + """Un nom se réutilise, et « --remove-all-storage » efface un disque + pour de bon : le dépôt identifie ses machines par UUID.""" + self.assertIn("detruire_etage1", self.source) + self.assertIn("attendu=", self.source) + self.assertIn("uuid_libvirt", self.source) + + def test_la_coupure_est_deleguee_et_non_recopiee(self): + """Les règles vivent dans `script/qemu/cache_offline.py`, que le + formulaire de déploiement emploie aussi : la case « Sans connexion + internet » coupe donc exactement ce que cette mesure mesure. + + Leur contenu — le compte visé plutôt que le port, sans quoi la + session ssh de l'opérateur tomberait — est éprouvé chez elles, dans + `test_qemu_cache_offline.py`. + """ + self.assertIn("cache_offline.cut_cmd()", self.source) + self.assertIn("cache_offline.restore_cmd()", self.source) + self.assertNotIn("meta skuid", self.source) + + def test_la_coupure_est_toujours_retiree(self): + self.assertIn("finally:", self.source) + self.assertIn("rebrancher_lamont", self.source) + + def test_hors_du_lanceur_unitaire(self): + """Le lanceur balaie « test/test_*.py » : ce script ne doit pas y + être, ni porter un nom qu'il ramasserait.""" + self.assertFalse( + (RACINE / "test" / "test_qemu_cache_long.py").exists(), + "un test qui crée de vraies machines est entré dans test/", + ) + + +class TestCeQuIlFautDefaire(unittest.TestCase): + """Un échec ne doit pas faire perdre le moyen de défaire ce qui existe. + + Le nom d'une machine est noté AVANT sa création, pour qu'une création + interrompue à mi-chemin laisse une trace. Une exécution qui échoue tout de + suite écrit donc un rapport qui NOMME une machine sans la connaître, et + s'en tenir au dernier rapport ferait retomber la destruction sur le nom — + ce que ce dépôt a appris à ne plus faire, « --remove-all-storage » + effaçant un disque pour de bon. + """ + + def rapports(self, *contenus): + """Écrit des rapports datés dans un faux dépôt de rapports. + + Le balayage des machines VIVANTES est neutralisé ici : sans cela ces + contrôles liraient le libvirt de la machine qui les exécute, et + passeraient ou tomberaient selon ce qui y tourne. Le balayage a ses + propres contrôles, où il est la chose mesurée. + """ + import contextlib + import json as _json + import tempfile + from unittest import mock + + d = tempfile.mkdtemp() + for i, c in enumerate(contenus): + nom = f"qemu_cache-2026090{i + 1}-000000.json" + (Path(d) / nom).write_text(_json.dumps(c), encoding="utf-8") + + @contextlib.contextmanager + def isole(): + with mock.patch.object( + QC.os.path, + "expanduser", + lambda p: d if "longtest" in p else p, + ), mock.patch.object(QC, "machines_vivantes", return_value=[]): + yield + + return d, isole() + + def test_un_uuid_connu_survit_a_un_rapport_muet(self): + d, patch = self.rapports( + {"vms": ["vm-1"], "uuids": {"vm-1": "UUID-1"}}, + {"vms": ["vm-1"]}, # l'échec qui suit, sans UUID + ) + with patch: + machines, lus = QC.machines_a_defaire() + self.assertEqual(len(lus), 2) + self.assertEqual( + machines.get("vm-1"), + "UUID-1", + "l'échec le plus récent a fait perdre l'UUID qui permet de" + " détruire sans risque", + ) + + def test_les_machines_de_plusieurs_essais_sont_reunies(self): + d, patch = self.rapports( + {"vms": ["vm-1", "vm-2"], "uuids": {"vm-1": "U1", "vm-2": "U2"}}, + {"vms": ["vm-3"], "uuids": {"vm-3": "U3"}}, + ) + with patch: + machines, _ = QC.machines_a_defaire() + self.assertEqual( + sorted(machines), + ["vm-1", "vm-2", "vm-3"], + "s'en tenir au dernier rapport laisserait vivantes les machines" + " d'un essai antérieur", + ) + + def test_un_rapport_sans_machine_est_saute(self): + d, patch = self.rapports({"vms": []}, {"vms": ["vm-1"]}) + with patch: + machines, lus = QC.machines_a_defaire() + self.assertEqual(len(lus), 1) + self.assertEqual(sorted(machines), ["vm-1"]) + + def test_aucun_rapport_et_aucune_machine(self): + """Rien à lire, rien qui vit : il n'y a rien à défaire.""" + d, patch = self.rapports() + with patch: + machines, lus = QC.machines_a_defaire() + self.assertEqual((machines, lus), ({}, [])) + + +class TestUnPlanNestPasUneMesure(unittest.TestCase): + """Un essai à blanc écrit un rapport, et le comparatif le comptait. + + Le plan ne crée aucune VM et n'installe aucun paquet : ses durées valent + zéro. Comptées comme des mesures, elles montrent dans le tableau des + exécutions qui n'ont jamais eu lieu et tirent la moyenne vers le bas — un + témoin à blanc faisait ainsi croire à une comparaison qui n'existait pas. + """ + + def test_un_rapport_marque_a_blanc_est_ecarte(self): + self.assertFalse( + QC.mesure_reelle({"dry_run": True, "durees": {"vm-1": 42.0}}) + ) + + def test_un_rapport_ancien_se_trahit_par_ses_zeros(self): + """Les rapports d'avant la marque : une installation de paquets qui + prend zéro seconde n'a pas eu lieu.""" + self.assertFalse( + QC.mesure_reelle({"durees": {"vm-1": 0.0, "vm-2": 0.0}}) + ) + + def test_une_vraie_mesure_passe(self): + self.assertTrue( + QC.mesure_reelle({"durees": {"vm-1": 19.6, "vm-2": 20.6}}) + ) + + def test_un_rapport_sans_duree_ne_mesure_rien(self): + self.assertFalse(QC.mesure_reelle({})) + + def test_le_comparatif_applique_bien_le_filtre(self): + """Le contrôle précédent vérifie la RÈGLE ; celui-ci vérifie qu'elle + est branchée. Sans lui, retirer l'appel laisse les tests verts et + remet les lignes fantômes dans le tableau.""" + with tempfile.TemporaryDirectory() as rep: + rapports = { + "qemu_cache-20260101-000001.json": { + "outil": "qemu_cache", + "debut": "2026-01-01T00:00:01", + "dry_run": True, + "durees": {"vm-1": 0.0}, + }, + "qemu_cache-20260101-000002.json": { + "outil": "qemu_cache", + "debut": "2026-01-01T00:00:02", + "durees": {"vm-1": 12.5}, + }, + } + for nom, contenu in rapports.items(): + with open(Path(rep) / nom, "w", encoding="utf-8") as fh: + json.dump(contenu, fh) + with unittest.mock.patch.object( + QC.os.path, "expanduser", return_value=rep + ): + vus = QC.rapports_recents() + self.assertEqual( + [r["durees"] for r in vus], + [{"vm-1": 12.5}], + "un plan à blanc revient dans le rapport de performance", + ) + + +class TestUnPrefixeParMode(unittest.TestCase): + """Les trois modes ne doivent plus se disputer les mêmes machines. + + Ils créent tous une « première » et une « seconde » VM. Sous un préfixe + unique, lancer le témoin après la mesure butait sur « machine(s) d'un + essai précédent encore là » alors qu'il s'agissait d'une AUTRE expérience, + et il fallait tout défaire pour comparer — ce que la comparaison exige + justement de ne pas faire. + """ + + def mode(self, **kw): + base = { + "sans_cache": False, + "hors_ligne": False, + "distro": "arch", + "version": "latest", + "charge": "minimum", + } + base.update(kw) + return argparse.Namespace(**base) + + def test_les_trois_preflxes_different(self): + noms = { + QC.base_des_noms(self.mode()), + QC.base_des_noms(self.mode(sans_cache=True)), + QC.base_des_noms(self.mode(hors_ligne=True)), + } + self.assertEqual( + len(noms), 3, f"des modes partagent un préfixe : {noms}" + ) + + def test_le_temoin_se_nomme_sans_cache(self): + self.assertTrue( + QC.base_des_noms(self.mode(sans_cache=True)).startswith( + "el-no-cache-" + ) + ) + + def test_le_nom_porte_le_systeme_et_la_charge(self): + """Lire « virsh list » doit suffire à savoir d'où vient une machine, + et deux essais qui ne portent pas sur la même chose ne doivent plus se + disputer les mêmes noms.""" + self.assertEqual( + QC.base_des_noms( + self.mode(distro="ubuntu", version="24.04", charge="erplibre") + ), + "el-cache-ubuntu_2404-erplibre_odoo_18", + ) + + def test_latest_ne_figure_pas_dans_le_nom(self): + """Une distribution en publication continue n'a qu'une version : le + segment ne distinguerait aucune machine d'une autre.""" + self.assertEqual( + QC.base_des_noms(self.mode(distro="arch", version="latest")), + "el-cache-arch-minimum", + ) + + def test_un_essai_ne_bloque_pas_un_essai_different(self): + """Le grief exact : mesurer Ubuntu butait sur des machines Arch.""" + arch = QC.base_des_noms(self.mode()) + ubuntu = QC.base_des_noms( + self.mode(distro="ubuntu", version="24.04", charge="erplibre") + ) + self.assertFalse(ubuntu.startswith(arch)) + self.assertFalse(arch.startswith(ubuntu)) + + def test_le_prealable_ne_regarde_que_son_propre_prefixe(self): + """Une machine d'une autre expérience n'entre en conflit avec rien.""" + vivantes = "el-cache-test-1\nel-no-cache-test-1\nautre-vm\n" + with unittest.mock.patch.object( + QC, "executer", return_value=(0, vivantes) + ): + self.assertEqual( + QC.machines_vivantes("el-no-cache-test"), + ["el-no-cache-test-1"], + ) + + def test_la_destruction_balaie_les_trois(self): + """Les rapports sont bornés : une machine plus ancienne que la fenêtre + ne serait jamais défaite et bloquerait tous les essais suivants.""" + vivantes = "el-cache-test-9\nel-offline-test-9\nel-no-cache-test-9\n" + with unittest.mock.patch.object( + QC, "executer", return_value=(0, vivantes) + ), unittest.mock.patch.object( + QC.os.path, "expanduser", return_value="/inexistant" + ): + machines, _ = QC.machines_a_defaire() + self.assertEqual( + sorted(machines), + ["el-cache-test-9", "el-no-cache-test-9", "el-offline-test-9"], + ) + + +class TestLeMenuNeRefabriquePasLesNoms(unittest.TestCase): + """Le menu annonce les machines qui vont naître : il les DEMANDE. + + Deux fabriques de noms dériveraient en silence, et le menu annoncerait + alors des VM qui ne sont pas celles qui apparaissent dans « virsh list » — + pire que de ne rien annoncer. Ce contrôle interdit donc au menu d'écrire un + préfixe en dur. + """ + + def menu_source(self): + return ( + Path(__file__).resolve().parent.parent + / "script" + / "todo" + / "qemu_cache_menu.py" + ).read_text(encoding="utf-8") + + def test_aucun_prefixe_ecrit_en_dur(self): + trouves = set(re.findall(r'"(el-[a-z-]+)"', self.menu_source())) + self.assertEqual( + trouves, + set(), + f"le menu écrit des noms de machine en dur : {trouves}", + ) + + def test_le_menu_appelle_la_fabrique_du_script(self): + self.assertIn("module.nom_de_base(", self.menu_source()) + + +class TestLeGainSeCalculeParCondition(unittest.TestCase): + """Comparer une installation d'ERPLibre à un lot de paquets ne mesure que + le mélange. + + Le rapport annonçait 46 % de gain en moyennant une seconde VM d'Ubuntu qui + installe ERPLibre en six minutes avec une seconde VM d'Arch qui pose cinq + paquets en vingt secondes. À condition égale, le même essai en donne 21 %. + """ + + def rapport(self, distro, charge, cache, duree): + return { + "debut": "2026-01-01T00:00:00", + "distro": distro, + "charge": charge, + "cache": cache, + "durees": {f"vm-1": duree, f"vm-2": duree}, + "octets": {}, + } + + def sortie(self, rapports): + import contextlib + import io as _io + + tampon = _io.StringIO() + with unittest.mock.patch.object( + QC, "rapports_recents", return_value=rapports + ), contextlib.redirect_stdout(tampon): + QC.rapport_comparatif() + return tampon.getvalue() + + def test_deux_conditions_ne_se_melangent_pas(self): + texte = self.sortie( + [ + self.rapport("ubuntu", "erplibre", True, 360), + self.rapport("ubuntu", "erplibre", False, 450), + self.rapport("arch", "minimum", True, 20), + self.rapport("arch", "minimum", False, 30), + ] + ) + self.assertIn("ubuntu · erplibre", texte) + self.assertIn("arch · minimum", texte) + # 450 → 360 : vingt pour cent. 30 → 20 : trente-trois. + self.assertIn("20 %", texte) + self.assertIn("33 %", texte) + + def test_une_condition_sans_temoin_ne_produit_pas_de_gain(self): + texte = self.sortie( + [ + self.rapport("ubuntu", "erplibre", True, 360), + self.rapport("arch", "minimum", False, 30), + ] + ) + self.assertNotIn("gain", texte) + self.assertIn("MÊME système", texte) + + def test_le_temoin_dune_autre_condition_ne_sert_pas(self): + """Le piège exact : un témoin Arch faisait croire à une comparaison + pour une exécution Ubuntu.""" + texte = self.sortie( + [ + self.rapport("ubuntu", "erplibre", True, 360), + self.rapport("arch", "minimum", False, 450), + ] + ) + self.assertNotIn("gain", texte) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_miroir.py b/test/test_qemu_cache_miroir.py new file mode 100644 index 0000000..bb624ba --- /dev/null +++ b/test/test_qemu_cache_miroir.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les dépôts git que le miroir doit tenir, lus des manifestes du dépôt. + +Le protocole git est une négociation : rien ne s'y cache, et c'est un MIROIR +qu'il faut tenir. À la demande, il se remplit au fil des requêtes — la première +machine paie chaque clonage. Pour un dépôt qui en tire trois cents, ce n'est +pas un coût supprimé mais déplacé, sur la machine qui attend. + +La liste vient donc des manifestes, seule source qui dise ce que le dépôt clone +vraiment. Un manifeste Google Repo sépare la forge (« remote ») du projet +(« project ») : l'URL est la concaténation des deux, et un même projet figure +dans plusieurs manifestes, un par version d'Odoo. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.todo.qemu_cache_menu import ( # noqa: E402 + depots_des_manifestes, + manifeste_extra, + manifestes_de_version, +) + + +def faux_depot(manifestes, listes=None): + """Un dépôt de manifestes en dur, pour ne pas dépendre des vrais. + + `listes` pose les fichiers de conf que la fusion lit — {nom: [chemins]} — + faute de quoi une version n'aurait aucun manifeste commun. + """ + d = tempfile.mkdtemp() + (Path(d) / "manifest").mkdir() + for nom, contenu in manifestes.items(): + (Path(d) / "manifest" / nom).write_text(contenu, encoding="utf-8") + if listes: + (Path(d) / "conf").mkdir() + for nom, chemins in listes.items(): + (Path(d) / "conf" / nom).write_text( + '"filepath"\n' + "".join(f"{c}\n" for c in chemins), + encoding="utf-8", + ) + return d + + +def manifeste_de(projet): + """Un manifeste minimal déclarant un seul projet.""" + return ( + "" + '' + f'' + "" + ) + + +class TestLaBorneParVersion(unittest.TestCase): + """Ce qu'un déploiement clone, et rien d'autre. + + La règle est celle de la fusion des manifestes : les communs que listent + ses fichiers de conf, la version et son « _dev ». L'extra ne s'installe + que sur demande, le mobile seulement si son répertoire existe — ce qui + n'est pas le cas d'une VM fraîchement clonée. Les compter fait annoncer + comme manquants des dépôts qu'aucun déploiement par défaut ne clonera, et + un avertissement qui crie pour rien cesse d'être lu. + + Sans version, rien n'est soustrait : le remplissage complet prend de + l'avance pour toutes les versions à la fois. + """ + + MANIFESTES = { + "git_manifest_odoo18.0.xml": manifeste_de("dix-huit"), + "git_manifest_odoo18.0_dev.xml": manifeste_de("dix-huit-dev"), + "git_manifest_odoo12.0.xml": manifeste_de("douze"), + "git_manifest_erplibre_odoo.xml": manifeste_de("commun"), + "git_manifest_extra_odoo18.0.xml": manifeste_de("extra"), + "git_manifest_mobile_home.xml": manifeste_de("mobile"), + "default.staged.deprecated.xml": manifeste_de("deprecie"), + } + LISTES = { + "git_manifest_odoo.csv": ["manifest/git_manifest_erplibre_odoo.xml"], + "git_manifest.csv": [], + } + + def depot(self, listes=None): + return faux_depot(self.MANIFESTES, listes or self.LISTES) + + def test_la_base_retient_la_version_son_dev_et_les_communs(self): + urls = depots_des_manifestes(self.depot(), "18.0") + for nom in ("dix-huit", "dix-huit-dev", "commun"): + self.assertIn(f"https://f.example/{nom}.git", urls) + self.assertNotIn("https://f.example/douze.git", urls) + + def test_ni_extra_ni_mobile_ni_deprecie_dans_la_base(self): + urls = depots_des_manifestes(self.depot(), "18.0") + for nom in ("extra", "mobile", "deprecie"): + self.assertNotIn(f"https://f.example/{nom}.git", urls) + + def test_les_communs_viennent_des_listes_de_la_fusion(self): + """Un manifeste sans numéro que les listes ne nomment pas reste + dehors : c'est la liste qui fait foi, pas le nom du fichier.""" + d = self.depot({"git_manifest_odoo.csv": [], "git_manifest.csv": []}) + self.assertNotIn( + "https://f.example/commun.git", depots_des_manifestes(d, "18.0") + ) + + def test_lextra_ne_vient_que_sur_demande(self): + d = self.depot() + extra = manifeste_extra("18.0") + self.assertNotIn(extra, manifestes_de_version(d, "18.0")) + self.assertIn(extra, manifestes_de_version(d, "18.0", extra=True)) + self.assertEqual( + depots_des_manifestes(d, fichiers=[extra]), + ["https://f.example/extra.git"], + ) + + def test_sans_version_rien_nest_soustrait(self): + self.assertEqual(len(depots_des_manifestes(self.depot())), 7) + + +class TestExtraction(unittest.TestCase): + def test_lurl_est_la_forge_plus_le_projet(self): + d = faux_depot( + { + "a.xml": "" + '' + '' + "" + } + ) + self.assertEqual( + depots_des_manifestes(d), ["https://forge.example/org/outil.git"] + ) + + def test_un_projet_dans_deux_manifestes_ne_compte_quune_fois(self): + """Un même projet figure dans un manifeste par version d'Odoo : le + cloner deux fois ne ferait que perdre du temps.""" + commun = ( + '' + '' + ) + d = faux_depot({"odoo17.xml": commun, "odoo18.xml": commun}) + self.assertEqual(depots_des_manifestes(d), ["https://f.example/p.git"]) + + def test_les_barres_obliques_ne_se_doublent_pas(self): + d = faux_depot( + { + "a.xml": "" + '' + '' + } + ) + self.assertEqual( + depots_des_manifestes(d), ["https://f.example/org/p.git"] + ) + + def test_un_projet_sans_forge_connue_est_saute(self): + """Sinon l'URL serait un nom de projet nu, que git ne sait pas + cloner et dont l'échec ne se lirait qu'à l'exécution.""" + d = faux_depot( + { + "a.xml": "" + '' + '' + '' + } + ) + self.assertEqual( + depots_des_manifestes(d), ["https://f.example/bon.git"] + ) + + def test_un_manifeste_illisible_est_saute(self): + """La liste sert à prendre de l'avance : en perdre une part vaut + mieux que de ne rien prendre.""" + d = faux_depot( + { + "casse.xml": "" + '' + '', + } + ) + self.assertEqual(depots_des_manifestes(d), ["https://f.example/p.git"]) + + def test_aucun_manifeste_rend_une_liste_vide(self): + self.assertEqual(depots_des_manifestes(tempfile.mkdtemp()), []) + + +class TestLeRepliSurLaVersionDuDepot(unittest.TestCase): + """Sans version donnée, le verdict prend celle que le checkout porte. + + C'est ce qu'un déploiement pose par défaut. Sans ce repli, la lecture + redeviendrait celle de TOUS les manifestes, et l'avertissement se + remettrait à nommer des dépôts qu'aucun déploiement ne clonera — ce qui + apprend à ne plus le lire. + """ + + def test_le_verdict_est_borne_par_defaut(self): + from script.todo.deploy_form_lib import _depots_declares + + tous = depots_des_manifestes(str(RACINE)) + bornes = _depots_declares() + self.assertTrue(tous, "aucun manifeste lu") + self.assertTrue(bornes, "la borne a tout supprimé") + self.assertLess( + len(bornes), + len(tous), + "la lecture par défaut n'est pas bornée à une version", + ) + + def test_la_version_lue_est_celle_du_fichier(self): + """« .odoo-version » est la source, et non une constante figée : + changer de version d'Odoo doit changer le verdict.""" + from script.todo.deploy_form_lib import _depots_declares + + version = ( + (RACINE / ".odoo-version").read_text(encoding="utf-8").strip() + ) + self.assertEqual( + sorted(_depots_declares()), + sorted(depots_des_manifestes(str(RACINE), version)), + ) + + +class TestLaBaseReelle(unittest.TestCase): + """Sur les vrais manifestes, la base ne porte pas l'extra de la version + active. Sinon le pré-vol annonce des miroirs manquants pour des modules + qu'une installation par défaut ne clone jamais.""" + + def test_la_base_ne_porte_pas_lextra(self): + version = ( + (RACINE / ".odoo-version").read_text(encoding="utf-8").strip() + ) + base = set(depots_des_manifestes(str(RACINE), version)) + extra = set( + depots_des_manifestes( + str(RACINE), fichiers=[manifeste_extra(version)] + ) + ) + if not extra: + self.skipTest(f"aucun manifeste extra pour {version}") + self.assertTrue(base, "la base de la version active est vide") + self.assertTrue(extra.isdisjoint(base), extra & base) + + +class TestLesVraisManifestes(unittest.TestCase): + """Le dépôt lui-même : la liste ne doit pas se vider en silence.""" + + def test_le_depot_declare_bien_des_projets(self): + depots = depots_des_manifestes(str(RACINE)) + self.assertGreater( + len(depots), 100, "les manifestes ne déclarent presque rien" + ) + + def test_toutes_les_urls_sont_absolues(self): + for u in depots_des_manifestes(str(RACINE)): + self.assertTrue( + u.startswith(("https://", "http://", "git@", "ssh://")), + f"« {u} » n'est pas une URL que git sait cloner", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_offline.py b/test/test_qemu_cache_offline.py new file mode 100644 index 0000000..d396741 --- /dev/null +++ b/test/test_qemu_cache_offline.py @@ -0,0 +1,1950 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Déployer avec l'amont du cache coupé. + +Trois sorties tombent : l'amont du service du cache, la sortie directe des +VM que l'hôte relaie, et la résolution des noms par l'internet — l'hôte +répond lui-même à tout nom une adresse que le cache intercepte. Tout ce qui +arrive encore dans une VM vient donc du disque du cache, et un pas qui +prendrait un autre chemin échoue. + +Les règles sont VÉRIFIÉES au caractère près et jamais appliquées : la machine +qui exécute les tests garde son pare-feu intact. +""" + +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path +from unittest import mock + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.qemu import cache_offline # noqa: E402 + +# Gardée avant que setUpModule ne la remplace pour tout le module. +_VRAI_DNSMASQ = cache_offline.dnsmasq + + +class TestLeTemoinDesAmontsMuets(unittest.TestCase): + """La levée d'une coupure doit rendre leur chance aux amonts. + + Le service retient les amonts dont l'établissement vient d'échouer, pour + ne pas repayer le délai à chaque requête d'une installation hors ligne. + Rien ne le prévient qu'une coupure est levée : sans témoin, ces amonts + restent muets jusqu'à la fin de leur fenêtre, et les premières requêtes + d'après la levée se rabattent sur le magasin alors que le réseau est + revenu. Aucun canal n'existe vers le service en marche. + """ + + def test_le_nom_du_temoin_est_le_meme_des_deux_cotes(self): + """Deux noms qui divergent font toucher un fichier que personne ne + lit : la levée paraît faite, et rien ne change.""" + src = (RACINE / "script" / "qemu_cache" / "joignable.go").read_text( + encoding="utf-8" + ) + trouve = re.search(r'SentinelleAmonts = "([^"]+)"', src) + self.assertIsNotNone(trouve, "le Go ne déclare plus le témoin") + self.assertEqual(trouve.group(1), cache_offline.SENTINELLE_AMONTS) + + def test_la_levee_touche_le_temoin(self): + cmd = cache_offline.restore_cmd() + self.assertIn("touch ", cmd) + self.assertIn(cache_offline.SENTINELLE_AMONTS, cmd) + + def test_le_temoin_vit_sous_le_magasin_regle(self): + """Un magasin déplacé emporte son témoin : sinon la levée écrirait + hors du répertoire que le service lit.""" + self.assertEqual( + cache_offline.sentinelle_amonts("/ailleurs/magasin"), + os.path.join("/ailleurs/magasin", cache_offline.SENTINELLE_AMONTS), + ) + + +class TestLesRegles(unittest.TestCase): + def test_la_coupure_vise_le_compte_et_non_le_port(self): + """Une règle générale sur le 443 de l'orchestrateur emporterait la + session ssh depuis laquelle le déploiement est lancé.""" + regles = cache_offline.nft_rules() + self.assertIn(f"meta skuid {cache_offline.SERVICE_USER}", regles) + self.assertIn("hook output", regles) + self.assertNotIn("hook input", regles) + + def test_les_deux_ports_tombent(self): + """Ne couper que le 443 laisserait passer tout un miroir en clair.""" + self.assertIn("tcp dport { 80, 443 }", cache_offline.nft_rules()) + + def test_la_politique_reste_permissive(self): + """« policy accept » : seule la ligne ciblée jette. Une politique + « drop » couperait tout ce que l'hôte émet.""" + self.assertIn("policy accept", cache_offline.nft_rules()) + + def test_la_table_est_distincte_de_celle_du_detournement(self): + """Les mêler ferait tomber la redirection de tout le pont en + rebranchant l'amont.""" + self.assertNotEqual(cache_offline.TABLE, "erplibre_qemu_cache") + + def test_la_sortie_directe_des_vm_tombe(self): + """Ping, autres ports, UDP, IPv6 : tout ce que le pont relaie vers + l'extérieur. Sans cette chaîne, un pas qui contourne le cache réussit + en ligne pendant une coupure annoncée, et le test ment.""" + regles = cache_offline.nft_rules(pont="virbr9") + self.assertIn("hook forward", regles) + self.assertIn('iifname "virbr9" oifname != "virbr9" reject', regles) + + def test_la_coupure_refuse_au_lieu_de_jeter(self): + """Un paquet jeté fait pendre l'établissement jusqu'au délai du + cache, à chaque adresse qu'il ne détient pas : des centaines pendant + un « apt-get update ». Refusé, il échoue sur-le-champ.""" + regles = cache_offline.nft_rules() + self.assertIn("tcp dport { 80, 443 } reject with tcp reset", regles) + self.assertNotIn(" drop\n", regles) + + def test_ce_qui_vise_lhote_reste_joignable(self): + """Le détournement vers le cache se fait avant le routage, le + résolveur et la session ssh passent par « input » : aucune de ces + routes ne doit être visée, sans quoi la VM ne joindrait plus le cache + et échouerait pour une raison qui n'est pas le hors-ligne.""" + regles = cache_offline.nft_rules() + for accroche in ("hook input", "hook postrouting"): + self.assertNotIn(accroche, regles) + # En tête de routage, cette table ne détourne que le port 53 : le 80 + # et le 443 restent à la table du cache, qui les mène au cache. + self.assertNotIn("dport 80", regles) + self.assertNotIn("dport 443 redirect", regles) + # Le trafic entre VM du même pont n'est pas relayé vers l'extérieur. + self.assertIn('oifname != "', regles) + + def test_le_dns_des_vm_va_au_resolveur_fictif(self): + """UDP et TCP : une réponse DNS trop grande repasse en TCP, et une + requête qui y échapperait joindrait l'internet par le résolveur de + libvirt.""" + regles = cache_offline.nft_rules(pont="virbr9") + self.assertIn("hook prerouting", regles) + for proto in ("udp", "tcp"): + self.assertIn( + f'iifname "virbr9" {proto} dport 53 redirect to' + f" :{cache_offline.PORT_DNS}", + regles, + ) + + def test_le_pont_vient_des_reglages_du_service(self): + with mock.patch.object( + cache_offline, "reglage", lambda nom, conf="": "virbr7" + ): + self.assertIn('iifname "virbr7"', cache_offline.cut_cmd()) + with mock.patch.object( + cache_offline, "reglage", lambda nom, conf="": "" + ): + self.assertIn( + f'iifname "{cache_offline.PONT_PAR_DEFAUT}"', + cache_offline.cut_cmd(), + ) + + def test_un_nom_de_pont_douteux_est_refuse(self): + """Il entre tel quel dans les règles : un guillemet casserait le jeu, + un nom faux donnerait une règle qui ne vise rien.""" + for douteux in ('vir"br0', "virbr0 drop", "", "x" * 16): + with self.assertRaises(ValueError, msg=douteux): + cache_offline.nft_rules(pont=douteux) + + def test_le_retrait_est_muet_sur_une_table_absente(self): + """Il se fait dans un « finally » : une erreur y masquerait celle + d'origine.""" + self.assertIn("|| true", cache_offline.restore_cmd()) + self.assertIn(cache_offline.TABLE, cache_offline.restore_cmd()) + + +class TestLeResolveurFictif(unittest.TestCase): + """Pendant la coupure, l'hôte répond LUI-MÊME à tout nom : sans + résolution, la VM ne se connecterait à rien et le cache ne verrait jamais + la requête ; avec la vraie, les noms sortiraient par l'internet.""" + + def test_un_dnsmasq_sans_amont_qui_repond_tout_nom(self): + cmd = cache_offline.dns_cmd(pont="virbr9", binaire="/usr/bin/dnsmasq") + for attendu in ( + f"--unit={cache_offline.UNITE_DNS}", + "--collect", + "RuntimeMaxSec=", + "--conf-file=/dev/null", + "--no-resolv", + "--no-hosts", + "--interface=virbr9", + "--except-interface=lo", + "--bind-interfaces", + f"--port={cache_offline.PORT_DNS}", + f"--address=/#/{cache_offline.ADRESSE_FICTIVE_V4}", + f"--address=/#/{cache_offline.ADRESSE_FICTIVE_V6}", + "--local-ttl=0", + ): + self.assertIn(attendu, cmd) + + def test_sans_dnsmasq_la_coupure_est_refusee(self): + """Une coupure qui laisserait les noms sortir mentirait sur ce + qu'elle prouve.""" + with mock.patch.object(cache_offline, "dnsmasq", lambda: ""): + self.assertEqual(cache_offline.dns_cmd(pont="virbr9"), "") + cmd = cache_offline.cut_cmd(pont="virbr9") + self.assertNotEqual(subprocess.run(["sh", "-c", cmd]).returncode, 0) + + def test_dnsmasq_se_cherche_dans_le_path(self): + """Le module le remplace pour tous les tests : c'est la VRAIE + fonction qu'on éprouve ici.""" + with mock.patch.object(cache_offline.shutil, "which", lambda n: None): + self.assertEqual(_VRAI_DNSMASQ(), "") + with mock.patch.object( + cache_offline.shutil, "which", lambda n: f"/opt/bin/{n}" + ): + self.assertEqual(_VRAI_DNSMASQ(), "/opt/bin/dnsmasq") + + def test_le_retrait_arrete_aussi_le_resolveur(self): + for cmd in (cache_offline._retrait(), cache_offline.restore_cmd()): + self.assertIn(f"systemctl stop {cache_offline.UNITE_DNS}", cmd) + self.assertIn(f"nft delete table inet {cache_offline.TABLE}", cmd) + self.assertTrue(cache_offline.restore_cmd().startswith("sudo sh -c ")) + # Le guet lève par le même retrait : il arrête donc le résolveur. + guet = cache_offline.guet_cmd(["/srv/run/vm.log"], "__FIN__") + self.assertIn(cache_offline.UNITE_DNS, guet) + + def _poser(self, systemd_run_rc, nft_refuse_le_refus=False): + """Exécute la VRAIE commande de pose avec de faux sudo, nft, + systemd-run et systemctl EN TÊTE du PATH : aucun vrai outil n'est + atteint, et le journal dit qui a été appelé, dans quel ordre.""" + import tempfile + + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__("shutil").rmtree(d, True)) + journal = os.path.join(d, "appels") + faux = { + "sudo": 'exec "$@"', + # Le journal dit quel jeu la pose a reçu : « refus » ou « jet ». + "nft": ( + 'case "$1" in -f) jeu=$(cat);; esac; ' + 'case "$jeu" in *reject*) v=refus;; *drop*) v=jet;; esac; ' + f'echo "nft $* $v" >> {journal}; ' + f'[ "$v" = refus ] && exit {1 if nft_refuse_le_refus else 0}; ' + "exit 0" + ), + "systemctl": f'echo "systemctl $*" >> {journal}', + "systemd-run": f'echo "systemd-run" >> {journal}; exit {systemd_run_rc}', + } + for nom, corps in faux.items(): + chemin = os.path.join(d, nom) + with open(chemin, "w", encoding="utf-8") as fh: + fh.write(f"#!/bin/sh\n{corps}\n") + os.chmod(chemin, 0o755) + dns = cache_offline.dns_cmd( + pont="virbr9", binaire="/inexistant/dnsmasq" + ) + cmd = cache_offline.cut_cmd(pont="virbr9", dns=dns) + env = {"PATH": f"{d}:/usr/bin:/bin"} + rc = subprocess.run(["/bin/sh", "-c", cmd], env=env).returncode + with open(journal, encoding="utf-8") as fh: + return rc, fh.read().splitlines() + + def test_un_resolveur_qui_ne_part_pas_retire_la_table(self): + rc, appels = self._poser(systemd_run_rc=1) + self.assertNotEqual(rc, 0, "la pose a réussi sans résolveur") + self.assertEqual(appels[0], "nft -f - refus") + self.assertEqual(appels[1], "systemd-run") + self.assertIn( + f"nft delete table inet {cache_offline.TABLE}", appels[2] + ) + self.assertIn(f"systemctl stop {cache_offline.UNITE_DNS}", appels[3]) + + def test_une_pose_complete_ne_retire_rien(self): + rc, appels = self._poser(systemd_run_rc=0) + self.assertEqual(rc, 0) + self.assertEqual(appels, ["nft -f - refus", "systemd-run"]) + + def test_un_noyau_sans_module_de_refus_recoit_le_jeu_qui_jette(self): + """Un noyau mis à jour sans redémarrage ne charge plus le module du + refus : la pose retombe sur le jeu qui jette, sans demi-coupure.""" + rc, appels = self._poser(systemd_run_rc=0, nft_refuse_le_refus=True) + self.assertEqual(rc, 0) + self.assertEqual( + appels, ["nft -f - refus", "nft -f - jet", "systemd-run"] + ) + + def test_le_repli_jette_partout(self): + repli = cache_offline.nft_rules(refus=False) + self.assertNotIn("reject", repli) + self.assertIn("tcp dport { 80, 443 } drop", repli) + self.assertIn('oifname != "virbr0" drop', repli) + + +class TestLeCompteDuService(unittest.TestCase): + """Le script d'installation est en shell et ne peut pas lire la valeur + d'ici : les deux copies doivent dire la même chose, sans quoi la coupure + viserait un compte qui n'émet rien et le test passerait pour hors ligne + en étant en ligne.""" + + def test_le_meme_compte_que_le_script_dinstallation(self): + src = ( + RACINE / "script" / "install" / "install_qemu_cache.sh" + ).read_text(encoding="utf-8") + trouve = re.search(r'^SERVICE_USER="([^"]+)"', src, re.M) + self.assertIsNotNone(trouve, "SERVICE_USER absent du script") + self.assertEqual(trouve.group(1), cache_offline.SERVICE_USER) + + +class TestLeTestLongEtLeFormulaireCoupentPareil(unittest.TestCase): + """Une seule source : ce que la case « Sans connexion internet » fait est + exactement ce que la contre-épreuve du test long mesure.""" + + def test_le_test_long_ne_recopie_pas_les_regles(self): + src = (RACINE / "long_test" / "qemu_cache.py").read_text( + encoding="utf-8" + ) + self.assertIn("cache_offline.cut_cmd()", src) + self.assertNotIn("meta skuid", src) + + +class TestLeCablageDuDeploiement(unittest.TestCase): + """La coupure couvre la spec ENTIÈRE, installation comprise, et se retire + quoi qu'il arrive.""" + + def _lance(self, offline, echec_coupure=False, plante=False): + sys.argv = ["todo.py"] + from script.todo.qemu_deploy import _SansInternetImpossible # noqa + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + vu = {"shell": [], "ordre": []} + + def shell(cmd, timeout=60): + vu["shell"].append(cmd) + # Les relevés ne comptent pas dans l'ordre : aucun guet ne + # tourne, aucune table n'est posée avant ni après. + if "systemctl is-active" in cmd or "nft list tables" in cmd: + return 1 + if "nft -f -" in cmd: + vu["ordre"].append("coupure") + return 1 if echec_coupure else 0 + vu["ordre"].append("rebranchement") + return 0 + + todo._qemu_shell = shell + + def deploie(spec, **kw): + vu["ordre"].append("déploiement") + if plante: + raise RuntimeError("le déploiement a échoué") + return "fait" + + todo._qemu_deploie_spec = deploie + import contextlib + import io + + with contextlib.redirect_stdout(io.StringIO()) as sortie: + if plante: + with self.assertRaises(RuntimeError): + todo._qemu_run_spec({"offline": offline}) + else: + vu["rendu"] = todo._qemu_run_spec({"offline": offline}) + vu["ecrit"] = sortie.getvalue() + return vu + + def test_sans_la_case_rien_nest_touche(self): + """Seul l'état du guet est relu, sans sudo : une coupure tenue par un + autre déploiement ferait tourner celui-ci hors ligne.""" + vu = self._lance(offline=False) + self.assertEqual(vu["ordre"], ["déploiement"]) + self.assertEqual(vu["shell"], [cache_offline.guet_actif_cmd()]) + + def test_la_coupure_precede_le_deploiement(self): + """Après lui, elle ne mesurerait plus rien : c'est l'installation qui + télécharge.""" + vu = self._lance(offline=True) + self.assertEqual( + vu["ordre"], ["coupure", "déploiement", "rebranchement"] + ) + + def test_le_rebranchement_a_lieu_meme_si_le_deploiement_plante(self): + """Une coupure laissée en place prive le cache de réseau bien après, + et la panne se découvre ailleurs.""" + vu = self._lance(offline=True, plante=True) + self.assertEqual( + vu["ordre"], ["coupure", "déploiement", "rebranchement"] + ) + + def test_une_coupure_impossible_ne_deploie_rien(self): + """Une VM bâtie avec l'amont debout se bâtit toujours : son succès se + lirait comme une preuve hors ligne qu'elle n'est pas.""" + vu = self._lance(offline=True, echec_coupure=True) + self.assertEqual(vu["ordre"], ["coupure"]) + self.assertIn("✗", vu["ecrit"]) + + def test_la_spec_porte_bien_la_cle(self): + from script.todo.deploy_form_lib import build_spec + + spec = build_spec( + [], + set(), + { + "res_label": "", + "ssh_key": "", + "install": None, + "add_ssh_config": True, + "parallelism": 1, + "offline": True, + }, + ) + self.assertIs(spec["offline"], True) + + +class TestLeDiagnosticVoitLaCoupure(unittest.TestCase): + """Une coupure restée en place est muette là où on la cherche. + + Le rebranchement est dans un « finally », mais un « finally » ne court + pas sur un processus tué net. Le cache rend alors « 504 » à chaque VM et + l'installation échoue sur « failed retrieving file … 504 » depuis TOUS + les miroirs — un message qui accuse les miroirs, jamais une règle de + pare-feu posée sur l'hôte. + + Le journal du cache écrivait bien « offline-miss », et le diagnostic le + montrait ; encore fallait-il savoir le lire. Il le dit maintenant. + """ + + TABLE_POSEE = ( + f"table inet {cache_offline.TABLE} {{\n" + " chain sortie {\n" + " type filter hook output priority filter; policy accept;\n" + " meta skuid 959 tcp dport { 80, 443 } drop\n" + " }\n" + "}\n" + ) + TABLE_ABSENTE = "Error: No such file or directory" + + def _menu(self, listing): + sys.argv = ["todo.py"] + from script.todo.qemu_cache_menu import QemuCacheMenuMixin + + class Faux(QemuCacheMenuMixin): + @staticmethod + def _cache_lire(cmd, delai=15): + return listing if cache_offline.TABLE in cmd else "" + + return Faux + + def test_elle_est_vue_quand_elle_est_posee(self): + self.assertTrue(self._menu(self.TABLE_POSEE)._cache_amont_coupe()) + + def test_rien_nest_annonce_quand_elle_ne_lest_pas(self): + self.assertFalse(self._menu(self.TABLE_ABSENTE)._cache_amont_coupe()) + + def test_une_lecture_impossible_ne_crie_pas_au_loup(self): + """Sans sudo, « nft list » ne rend rien : annoncer une coupure + enverrait chercher une règle qui n'existe pas.""" + self.assertFalse(self._menu("")._cache_amont_coupe()) + + def test_le_diagnostic_la_nomme_et_donne_le_geste(self): + import contextlib + import io as _io + + menu = self._menu(self.TABLE_POSEE) + faux = menu.__new__(menu) + faux._cache_actif = lambda: True + with mock.patch("os.path.isfile", return_value=True), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_par_machine", + return_value=[], + ), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin" + "._cache_compte_issues", + return_value={}, + ), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin" + "._cache_bypass_lire", + return_value=[], + ), contextlib.redirect_stdout( + _io.StringIO() + ) as sortie: + faux._cache_diagnostic() + ecrit = sortie.getvalue() + self.assertIn( + "504", ecrit, "le symptôme n'est pas rattaché à sa cause" + ) + self.assertIn( + cache_offline.restore_cmd(), + ecrit, + "le diagnostic constate sans donner le geste qui lève la coupure", + ) + + +class TestCeQueLeCacheDetient(unittest.TestCase): + """Savoir AVANT de couper si une VM hors ligne a une chance. + + Une VM déployée hors ligne sur une suite que le cache n'a jamais servie + échoue une heure plus tard, sur « Impossible de trouver le paquet » — un + message qui ne parle ni du cache ni du hors ligne. + """ + + def _journal(self, lignes): + import json + import tempfile + + fh = tempfile.NamedTemporaryFile( + "w", suffix=".jsonl", delete=False, encoding="utf-8" + ) + for l in lignes: + fh.write(json.dumps(l) + "\n") + fh.close() + return fh.name + + def test_le_nom_de_code_vient_du_catalogue(self): + """Le recopier ici le ferait dériver du déploiement, qui l'a déjà.""" + self.assertEqual( + cache_offline.jeton_de_suite("ubuntu", "26.04"), + "/dists/resolute/", + ) + self.assertEqual( + cache_offline.jeton_de_suite("debian", "13"), "/dists/trixie/" + ) + + def test_aucun_verdict_hors_des_familles_apt(self): + """« /repodata/ » ne dit pas quelle VERSION il sert : un cache rempli + pour l'une passerait pour rempli pour toutes.""" + for d, v in (("arch", "latest"), ("fedora", "42"), ("rocky", "9")): + self.assertEqual(cache_offline.jeton_de_suite(d, v), "", d) + + def test_une_suite_servie_est_vue_en_reserve(self): + chemin = self._journal( + [ + { + "url": "http://m/ubuntu/dists/resolute/InRelease", + "outcome": "stored", + } + ] + ) + self.assertTrue( + cache_offline.detient_la_suite("ubuntu", "26.04", chemin) + ) + + def test_une_suite_jamais_servie_est_signalee(self): + chemin = self._journal( + [ + { + "url": "http://m/ubuntu/dists/noble/InRelease", + "outcome": "stored", + } + ] + ) + self.assertFalse( + cache_offline.detient_la_suite("ubuntu", "26.04", chemin) + ) + + def test_un_304_ne_compte_pas_pour_une_reserve(self): + """« fetched » couvre aussi la revalidation, qui n'a pas de corps : + c'est exactement le cas qui laissait le cache vide.""" + chemin = self._journal( + [ + { + "url": "http://m/ubuntu/dists/resolute/InRelease", + "outcome": "fetched", + "status": 304, + } + ] + ) + self.assertFalse( + cache_offline.detient_la_suite("ubuntu", "26.04", chemin) + ) + + def test_un_journal_illisible_ne_crie_pas_au_loup(self): + """Un avertissement qui se déclenche sans savoir apprend à passer + outre, et c'est alors celui qui compte qu'on ne lit plus.""" + self.assertTrue( + cache_offline.detient_la_suite("ubuntu", "26.04", "/pas/la.jsonl") + ) + + def test_les_manquantes_sont_nommees_sans_doublon(self): + chemin = self._journal( + [ + { + "url": "http://m/ubuntu/dists/noble/InRelease", + "outcome": "hit", + } + ] + ) + with mock.patch.object( + cache_offline, + "detient_la_suite", + lambda d, v, c="": (d, v) == ("ubuntu", "24.04"), + ): + manquantes = cache_offline.suites_absentes( + [ + {"distro": "ubuntu", "version": "24.04"}, + {"distro": "ubuntu", "version": "26.04"}, + {"distro": "ubuntu", "version": "26.04"}, + {"distro": "", "version": "x"}, + ] + ) + self.assertEqual(manquantes, [("ubuntu", "26.04")]) + self.assertEqual(chemin, chemin) + + +class _SansSysteme: + """Garde de CLASSE : aucune commande ne part vers l'hôte. + + Le code sous test pose et lève des règles de pare-feu et des unités + systemd. Une régression qui contournerait un faux `_qemu_shell` les + poserait pour de bon sur la machine de test ; l'espion les intercepte + toutes et rend un échec. + """ + + def setUp(self): + self.lancees = [] + + def espion(cmd, *a, **kw): + texte = cmd if isinstance(cmd, str) else " ".join(map(str, cmd)) + self.lancees.append(texte) + return subprocess.CompletedProcess(cmd, 1, "", "") + + for cible in ("subprocess.run", "subprocess.Popen"): + patch = mock.patch(cible, espion) + patch.start() + self.addCleanup(patch.stop) + + +class _FauxHote: + """L'hôte vu par `_qemu_shell` : la table et le guet sont deux booléens. + + Chaque commande rendue par cache_offline y est reconnue à sa forme, et + son effet simulé ; rien n'est exécuté. + """ + + def __init__(self, table=False, guet=False, retrait=True, lecture=True): + self.table, self.guet = table, guet + self.retrait, self.lecture = retrait, lecture + self.ordre = [] + + def shell(self, cmd, timeout=60): + if "systemctl is-active" in cmd: + return 0 if self.guet else 1 + if "nft list tables" in cmd: + if not self.lecture: + return 2 + return 0 if self.table else 1 + # La pose passe AVANT le guet : elle lance aussi une unité (le + # résolveur fictif), mais c'est la table qu'elle pose. + if "nft -f -" in cmd: + self.ordre.append("coupure") + self.table = True + return 0 + if "systemd-run" in cmd: + self.ordre.append("guet") + self.guet = True + return 0 + if "nft delete" in cmd: + self.ordre.append("rebranchement") + if self.retrait: + self.table = False + return 0 + self.ordre.append(f"? {cmd}") + return 0 + + +def _todo(): + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + +def _joue_la_spec( + hote, guet_pendant=False, reponse=None, hors_ligne=True, pendant=None +): + """`_qemu_run_spec`, le déploiement remplacé par un faux. + + `guet_pendant` : le faux déploiement pose le guet, comme la voie suivie + le fait après le lancement. `reponse` : ce que l'utilisateur tape si on + lui pose une question ; None la rend interdite. `hors_ligne` : la case + de la spec. `pendant(vu)` : appelé DANS le bloc, par le faux + déploiement. Chaque commande passée à `_qemu_shell` est ajoutée à + vu["lances"] ; le code d'un SystemExit sorti du bloc va dans + vu["sortie"]. + """ + import contextlib + import io + + todo = _todo() + lances = [] + + def shell(cmd, timeout=60): + lances.append(cmd) + return hote.shell(cmd, timeout) + + todo._qemu_shell = shell + vu = {"lances": lances, "todo": todo} + + def deploie(spec, **kw): + hote.ordre.append("déploiement") + vu["kw"] = kw + if guet_pendant: + hote.shell(cache_offline.guet_cmd(["/srv/essai/vm-a.log"], "X")) + if pendant: + pendant(vu) + + todo._qemu_deploie_spec = deploie + question = ( + mock.patch("builtins.input", return_value=reponse) + if reponse is not None + else mock.patch( + "builtins.input", + side_effect=AssertionError("question posée sans motif"), + ) + ) + try: + with question, contextlib.redirect_stdout(io.StringIO()) as sortie: + todo._qemu_run_spec({"offline": hors_ligne}) + except SystemExit as fin: + vu["sortie"] = fin.code + vu["ecrit"] = sortie.getvalue() + return vu + + +class TestLeGuetRendu(unittest.TestCase): + """La levée confiée à root est une commande RENDUE : lue ici au + caractère près, jamais lancée.""" + + JOURNAUX = [ + "/srv/essai/journaux d'installation/vm-a.log", + '/srv/essai/un "autre" dossier/vm b.log', + ] + MARQUEUR = "__FIN_ESSAI__" + + def _argv(self): + import shlex + + return shlex.split( + cache_offline.guet_cmd(self.JOURNAUX, self.MARQUEUR) + ) + + def _attente(self, argv): + """Le script d'attente : l'argument qui suit « /bin/sh -c ».""" + debut = argv.index("/bin/sh") + self.assertEqual(argv[debut + 1], "-c") + return debut + 2 + + def test_une_unite_nommee_posee_par_root(self): + argv = self._argv() + self.assertEqual(argv[:2], ["sudo", "systemd-run"]) + self.assertIn(f"--unit={cache_offline.UNITE_GUET}", argv) + self.assertIn( + "--collect", argv, "une unité en échec garderait son nom pris" + ) + + def test_elle_ne_vit_pas_plus_de_douze_heures(self): + """Un marqueur qui ne vient jamais ne prive pas le cache d'amont + indéfiniment.""" + argv = self._argv() + self.assertEqual(cache_offline.DUREE_MAX_GUET, 12 * 3600) + rang = argv.index("RuntimeMaxSec=43200") + self.assertEqual(argv[rang - 1], "-p") + + def test_sa_fin_leve_la_table_de_la_coupure(self): + """ExecStopPost court à la fin, au dépassement et à l'arrêt.""" + argv = self._argv() + post = [a for a in argv if a.startswith("ExecStopPost=")] + self.assertEqual(len(post), 1) + self.assertEqual(argv[argv.index(post[0]) - 1], "-p") + self.assertIn(f"nft delete table inet {cache_offline.TABLE} ", post[0]) + + def test_lattente_couvre_chaque_journal_tel_quel(self): + """Espaces, apostrophes et guillemets : les chemins voyagent en + arguments et arrivent intacts, un par VM.""" + argv = self._argv() + rang = self._attente(argv) + self.assertEqual(argv[rang + 1], "sh") + self.assertEqual(argv[rang + 2 :], self.JOURNAUX) + script = argv[rang] + self.assertIn('for f in "$@"', script) + self.assertIn(self.MARQUEUR, script) + self.assertNotIn("vm-a.log", script, "un chemin est dans le script") + + def test_rien_que_systemd_remplacerait(self): + """systemd remplace « ${VAR} » et « $$ » dans ce qu'il exécute, et + « % » dans ce qu'il lit d'une unité : le script tomberait en + morceaux sans que rien ne le dise.""" + argv = self._argv() + script = argv[self._attente(argv)] + post = next(a for a in argv if a.startswith("ExecStopPost=")) + for texte in (script, post): + for motif in ("${", "$$", "%"): + self.assertNotIn(motif, texte) + + def test_la_commande_est_du_shell_valide(self): + """« bash -n » lit sans exécuter.""" + argv = self._argv() + for texte in ( + cache_offline.guet_cmd(self.JOURNAUX, self.MARQUEUR), + argv[self._attente(argv)], + cache_offline.table_posee_cmd(), + ): + res = subprocess.run( + ["bash", "-n"], input=texte, text=True, capture_output=True + ) + self.assertEqual(res.returncode, 0, res.stderr) + + def test_les_releves_et_la_levee_immediate(self): + actif = cache_offline.guet_actif_cmd() + self.assertIn("systemctl is-active --quiet", actif) + self.assertIn(cache_offline.UNITE_GUET, actif) + self.assertNotIn("sudo", actif, "lire un état n'a pas besoin de root") + self.assertEqual( + cache_offline.lever_maintenant_cmd(), + f"sudo systemctl stop {cache_offline.UNITE_GUET}", + ) + + def test_une_lecture_ratee_nest_pas_une_absence(self): + """« ! nft list table » conclurait à l'absence dès que sudo refuse, + et un rebranchement raté se lirait comme réussi.""" + cmd = cache_offline.table_posee_cmd() + self.assertIn("$(sudo nft list tables) || exit 2", cmd) + self.assertIn(f"grep -qxF 'table inet {cache_offline.TABLE}'", cmd) + self.assertNotIn("!", cmd) + + +class TestLaLeveeConfieeAuGuet(_SansSysteme, unittest.TestCase): + """Sur la voie suivie, la levée part chez root APRÈS le lancement — les + journaux existent alors — et AVANT le tableau de bord, qui prend le + terminal : une invite de mot de passe n'y aurait nulle part où + s'afficher.""" + + def _suivi(self, guet, rc_guet=0): + import contextlib + import io + import json + import shlex + import tempfile + from pathlib import Path + + import script.todo.qemu_install_monitor as mon + + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + journal = str(Path(tmp.name) / "journal d'essai" / "vm-a.log") + vu = {"ordre": [], "guet": []} + + def lance(vms, branche, remote): + vu["ordre"].append("lancement") + Path(journal).parent.mkdir() + Path(journal).write_text("", encoding="utf-8") + manifeste = Path(tmp.name) / "session.json" + manifeste.write_text( + json.dumps({"vms": [{"name": "vm-a", "log": journal}]}), + encoding="utf-8", + ) + return str(manifeste) + + def shell(cmd, timeout=60): + if "systemd-run" in cmd: + vu["ordre"].append("guet") + vu["guet"].append(shlex.split(cmd)) + return rc_guet + vu["ordre"].append(f"? {cmd}") + return 1 + + todo = _todo() + todo._qemu_shell = shell + todo._qemu_import_module = lambda: None + todo._qemu_erplibre_remote_cmd = lambda *a, **kw: "true" + with mock.patch.object( + mon, "launch_installs", lance + ), mock.patch.object( + mon, "run_monitor", lambda m: vu["ordre"].append("tableau") + ), contextlib.redirect_stdout( + io.StringIO() + ) as sortie: + todo._qemu_install_erplibre_monitored( + ["vm-a"], + "develop", + {"vm-a": "192.0.2.10"}, + guet_hors_ligne=guet, + ) + vu["ecrit"] = sortie.getvalue() + vu["journal"] = journal + return vu + + def test_le_guet_part_entre_le_lancement_et_le_tableau(self): + vu = self._suivi(guet=True) + self.assertEqual(vu["ordre"], ["lancement", "guet", "tableau"]) + self.assertEqual( + vu["guet"][0][-1], + vu["journal"], + "le guet n'attend pas le journal du manifeste", + ) + self.assertEqual(self.lancees, []) + + def test_sans_coupure_aucun_guet(self): + vu = self._suivi(guet=False) + self.assertEqual(vu["ordre"], ["lancement", "tableau"]) + + def test_un_guet_refuse_le_dit_et_le_suivi_souvre_quand_meme(self): + """La coupure reste alors au « finally » : fermer le tableau de bord + avant la fin fait finir les installations en ligne, et on le dit.""" + from script.todo.todo_i18n import t + + vu = self._suivi(guet=True, rc_guet=1) + self.assertEqual(vu["ordre"], ["lancement", "guet", "tableau"]) + self.assertIn( + t("closes; closing it early finishes the installs online."), + vu["ecrit"], + ) + + def test_la_spec_coupee_arme_le_guet_du_suivi(self): + """Le drapeau vient du bloc de la coupure, pas d'une relecture de la + spec : sans coupure posée, aucun guet.""" + import contextlib + import io + + for hors_ligne in (True, False): + with self.subTest(hors_ligne=hors_ligne): + hote = _FauxHote() + todo = _todo() + todo._qemu_shell = hote.shell + vus = [] + todo._qemu_install_erplibre_monitored = ( + lambda *a, **kw: vus.append(kw) + ) + todo._qemu_resolve_ips = lambda names, labels=None: {} + with contextlib.redirect_stdout(io.StringIO()): + todo._qemu_run_spec( + { + "vms": [], + "existing": ["vm-a"], + "install": None, + "add_ssh_config": False, + "parallelism": 1, + "monitor": True, + "offline": hors_ligne, + } + ) + self.assertIs(vus[0]["guet_hors_ligne"], hors_ligne) + self.assertIs(vus[0].get("hors_ligne"), hors_ligne) + + +class TestLeFinallyDuGuet(_SansSysteme, unittest.TestCase): + """À la sortie du bloc : ne pas lever sous un guet actif, et ne dire + « rebranché » que constaté.""" + + def test_guet_actif_la_coupure_reste_et_le_geste_est_donne(self): + """Les installations tournent encore, détachées : lever ici les ferait + finir en ligne.""" + hote = _FauxHote() + vu = _joue_la_spec(hote, guet_pendant=True) + self.assertEqual(hote.ordre, ["coupure", "déploiement", "guet"]) + self.assertTrue(hote.table) + self.assertIn(cache_offline.lever_maintenant_cmd(), vu["ecrit"]) + self.assertIn("12 h", vu["ecrit"]) + self.assertIs(vu["kw"]["coupee"], True) + + def test_sans_guet_le_finally_leve_et_constate(self): + from script.todo.todo_i18n import t + + hote = _FauxHote() + vu = _joue_la_spec(hote) + self.assertEqual( + hote.ordre, ["coupure", "déploiement", "rebranchement"] + ) + self.assertFalse(hote.table) + self.assertIn(t("Cache upstream restored."), vu["ecrit"]) + + def test_un_retrait_refuse_ne_se_dit_pas_rebranche(self): + """`restore_cmd` rend 0 même quand sudo refuse.""" + from script.todo.todo_i18n import t + + hote = _FauxHote(retrait=False) + vu = _joue_la_spec(hote) + self.assertNotIn(t("Cache upstream restored."), vu["ecrit"]) + self.assertIn(cache_offline.restore_cmd(), vu["ecrit"]) + + def test_une_table_illisible_ne_se_dit_pas_rebranchee(self): + from script.todo.todo_i18n import t + + hote = _FauxHote(lecture=False) + vu = _joue_la_spec(hote) + self.assertEqual( + hote.ordre, ["coupure", "déploiement", "rebranchement"] + ) + self.assertNotIn(t("Cache upstream restored."), vu["ecrit"]) + + +class TestUneSecondeCoupure(_SansSysteme, unittest.TestCase): + """La table est unique : deux coupures n'en font qu'une, et la première + levée ôte les deux.""" + + def test_un_guet_actif_refuse_tout(self): + """Sa fin lèverait la coupure de ce déploiement en cours de route.""" + from script.todo.todo_i18n import t + + hote = _FauxHote(table=True, guet=True) + vu = _joue_la_spec(hote) + self.assertEqual(hote.ordre, [], "une seconde coupure a été posée") + self.assertIn( + t("An offline deployment is still running: the cut"), vu["ecrit"] + ) + + def test_un_guet_actif_refuse_meme_table_levee_a_la_main(self): + hote = _FauxHote(table=False, guet=True) + _joue_la_spec(hote) + self.assertEqual(hote.ordre, []) + + def test_un_reste_sans_guet_se_demande_et_non_ne_deploie_rien(self): + from script.todo.todo_i18n import t + + hote = _FauxHote(table=True) + vu = _joue_la_spec(hote, reponse="n") + self.assertEqual(hote.ordre, []) + self.assertTrue(hote.table, "le reste a été levé sans accord") + self.assertIn(t("Nothing deployed."), vu["ecrit"]) + + def test_un_reste_leve_sur_accord_puis_la_coupure_suit(self): + hote = _FauxHote(table=True) + _joue_la_spec(hote, reponse="o") + self.assertEqual( + hote.ordre, + ["rebranchement", "coupure", "déploiement", "rebranchement"], + ) + + def test_rien_nest_demande_quand_la_voie_est_libre(self): + # `_joue_la_spec` sans réponse rend toute question fatale. + hote = _FauxHote() + _joue_la_spec(hote) + self.assertEqual(hote.ordre[0], "coupure") + + +class TestLaLigneDeCommit(unittest.TestCase): + """Le journal nomme le commit que la VM exécute : hors ligne, le clone + vient du miroir du cache, qui peut retarder sur le distant.""" + + def _script(self, prod=False): + return _todo()._qemu_erplibre_remote_cmd("develop", None, prod) + + def test_la_ligne_suit_le_clone_et_precede_le_make(self): + for prod, depot in ( + (False, "~/git/erplibre"), + (True, "/opt/erplibre"), + ): + with self.subTest(prod=prod): + script = self._script(prod) + ligne = script.index(f"git -C {depot} log -1") + self.assertLess(script.index("git clone --branch"), ligne) + self.assertLess(ligne, script.index("make install_os")) + self.assertIn("Commit : %h %s", script) + + def test_un_depot_garde_se_dit_garde(self): + from script.todo.todo_i18n import t + + script = self._script() + clone = script.index("git clone --branch") + garde = script.index(t("Existing checkout kept, not updated:")) + self.assertLess(clone, script.index("else echo", clone)) + self.assertLess(script.index("else echo", clone), garde) + self.assertLess(garde, script.index("fi;", clone)) + + def test_la_ligne_ne_fait_jamais_echouer_linstallation(self): + """Sous « set -e », un « git log » qui échoue — dépôt absent, git + refusant un dépôt d'un autre compte — arrêterait tout. Lu sur un + dépôt qui n'existe pas : git ne fait que lire.""" + import tempfile + + from script.todo.qemu_deploy import QemuDeployMixin + + with tempfile.TemporaryDirectory() as tmp: + absent = f"{tmp}/pas_de_depot" + res = subprocess.run( + [ + "bash", + "-c", + "set -e; " + + QemuDeployMixin._qemu_commit_line(absent) + + "echo SUITE", + ], + text=True, + capture_output=True, + timeout=30, + ) + self.assertIn("SUITE", res.stdout, res.stderr) + + def test_le_script_reste_du_shell_valide(self): + for prod in (False, True): + res = subprocess.run( + ["bash", "-n"], + input=self._script(prod), + text=True, + capture_output=True, + ) + self.assertEqual(res.returncode, 0, res.stderr) + + +class TestLecartHorsLigne(_SansSysteme, unittest.TestCase): + """Hors ligne, la VM clone le miroir du cache TEL QUEL : « git push » + seul n'y change rien, et c'est son sha qui compte.""" + + MIROIR = "/srv/essai/miroir/erplibre.git" + SHA_MIROIR = "a" * 40 + SHA_ICI = "b" * 40 + + def _git( + self, + sha_miroir=SHA_MIROIR, + sha_ici=SHA_ICI, + compte="3", + refs=None, + rc_ls=None, + sortie_ls=None, + ): + """Un faux git. `refs` : {branche: sha du miroir}, "" pour une + branche que le miroir n'a pas — « ls-remote --exit-code » rend alors + 2 ; par défaut la seule « dev ». `rc_ls` : une requête qui échoue + avec ce code. `sortie_ls` : ce que rend un ls-remote qui réussit.""" + self.appels = [] + refs = {"dev": sha_miroir} if refs is None else refs + + def run(argv, *a, **kw): + self.appels.append(list(argv)) + fait = subprocess.CompletedProcess + if argv[:2] == ["git", "ls-remote"]: + if rc_ls is not None: + return fait(argv, rc_ls, "", "fatal: illisible\n") + if sortie_ls is not None: + return fait(argv, 0, sortie_ls, "") + sha = refs.get(argv[-1][len("refs/heads/") :]) + if not sha: + return fait(argv, 2, "", "") + return fait(argv, 0, f"{sha}\t{argv[-1]}\n", "") + if argv[:2] == ["git", "rev-parse"]: + return fait(argv, 0 if sha_ici else 1, sha_ici, "") + if argv[:2] == ["git", "rev-list"]: + return fait(argv, 0, f"{compte}\n", "") + if argv[:2] == ["git", "log"]: + return fait(argv, 0, "c1 [FIX] un correctif\n", "") + raise AssertionError(f"commande inattendue : {argv}") + + return run + + def _lignes(self, hors_ligne, miroir=MIROIR, **git): + todo = _todo() + todo._qemu_miroir_erplibre = lambda: miroir + with mock.patch("subprocess.run", self._git(**git)): + return todo._qemu_branch_gap_lines("dev", hors_ligne=hors_ligne) + + def test_hors_ligne_le_sha_du_miroir_est_dit(self): + from script.todo.todo_i18n import t + + texte = "\n".join(self._lignes(True)) + self.assertIn(self.SHA_MIROIR[:12], texte) + self.assertIn(self.SHA_ICI[:12], texte) + self.assertIn(f"3 {t('commit(s) missing from the mirror')}", texte) + self.assertIn(t("A push alone changes nothing: the mirror is"), texte) + self.assertNotIn(t("to deploy your own work."), texte) + self.assertIn( + ["git", "ls-remote", "--exit-code", self.MIROIR, "refs/heads/dev"], + self.appels, + ) + + def test_en_ligne_le_conseil_reste_git_push(self): + from script.todo.todo_i18n import t + + texte = "\n".join(self._lignes(False)) + self.assertIn(f"git push {t('to deploy your own work.')}", texte) + self.assertFalse( + [a for a in self.appels if a[:2] == ["git", "ls-remote"]] + ) + + def test_sans_miroir_on_se_tait_et_retombe_sur_origin(self): + from script.todo.todo_i18n import t + + texte = "\n".join(self._lignes(True, miroir="")) + self.assertIn(f"git push {t('to deploy your own work.')}", texte) + self.assertFalse( + [a for a in self.appels if a[:2] == ["git", "ls-remote"]] + ) + + def test_un_miroir_a_jour_na_rien_a_dire(self): + self.assertEqual(self._lignes(True, sha_ici=self.SHA_MIROIR), []) + + def test_une_branche_absente_du_miroir_fera_echouer_le_clone(self): + from script.todo.todo_i18n import t + + texte = "\n".join(self._lignes(True, sha_miroir="")) + self.assertIn(t("an offline clone will fail."), texte) + + def test_une_requete_ratee_ne_dit_pas_la_branche_absente(self): + """Seul le code 2 de « --exit-code » vaut absence : un miroir + illisible ne sait rien, et l'écart retombe sur origin.""" + from script.todo.todo_i18n import t + + texte = "\n".join(self._lignes(True, rc_ls=128)) + self.assertNotIn(t("an offline clone will fail."), texte) + self.assertIn(f"git push {t('to deploy your own work.')}", texte) + + def test_une_reference_qui_finit_pareil_nest_pas_la_branche(self): + """Le motif de ls-remote se compare à la FIN des références.""" + from script.todo.todo_i18n import t + + sortie = f"{self.SHA_MIROIR}\trefs/heads/x/refs/heads/dev\n" + texte = "\n".join(self._lignes(True, sortie_ls=sortie)) + self.assertIn(f"{t('The cache mirror has no branch')} dev", texte) + + VM_RECAP = { + "distro": "debian", + "version": "13", + "arch": "amd64", + "vcpus": 2, + "ram": 2048, + "disk": "20G", + } + + def _recap(self, hors_ligne=True, **git): + """Le récapitulatif de deux VM sur deux branches : « dev » par + défaut, « stable » pour la seconde.""" + import contextlib + import io + + todo = _todo() + todo._qemu_miroir_erplibre = lambda: self.MIROIR + todo._qemu_sudo_lines = lambda: [] + spec = { + "vms": [ + dict(self.VM_RECAP, name="vm-a"), + dict(self.VM_RECAP, name="vm-b", branch="stable"), + ], + "install": { + "branch": "dev", + "prod": False, + "label": "x", + "cmd": "make x", + }, + "add_ssh_config": False, + "parallelism": 1, + "offline": hors_ligne, + } + with mock.patch( + "subprocess.run", self._git(**git) + ), contextlib.redirect_stdout(io.StringIO()) as sortie: + todo._qemu_print_recap(spec, []) + return sortie.getvalue() + + def _refs_lues(self): + return [a[-1] for a in self.appels if a[:2] == ["git", "ls-remote"]] + + def test_deux_branches_chacune_face_au_miroir(self): + """« varie, voir chaque ligne » est un libellé : le miroir n'a aucune + branche de ce nom, et l'annoncer absente serait faux.""" + from script.todo.todo_i18n import t + + texte = self._recap(refs={"dev": self.SHA_ICI, "stable": ""}) + self.assertEqual( + self._refs_lues(), ["refs/heads/dev", "refs/heads/stable"] + ) + self.assertIn( + f"{t('The cache mirror has no branch')} stable: " + f"{t('an offline clone will fail.')}", + texte, + ) + self.assertNotIn(f"{t('The cache mirror has no branch')} dev", texte) + self.assertNotIn( + f"{t('The cache mirror has no branch')} " + f"{t('varies, see each line')}", + texte, + ) + + def test_deux_branches_a_jour_rien_a_dire(self): + from script.todo.todo_i18n import t + + texte = self._recap(refs={"dev": self.SHA_ICI, "stable": self.SHA_ICI}) + self.assertNotIn(t("The cache mirror has no branch"), texte) + self.assertNotIn( + t("Offline, the VM clones the cache mirror of"), texte + ) + + def test_deux_branches_le_retard_nomme_la_sienne(self): + from script.todo.todo_i18n import t + + texte = self._recap( + refs={"dev": self.SHA_MIROIR, "stable": self.SHA_ICI} + ) + self.assertIn( + f"{t('Offline, the VM clones the cache mirror of')} dev: " + f"{self.SHA_MIROIR[:12]}", + texte, + ) + self.assertNotIn( + f"{t('Offline, the VM clones the cache mirror of')} stable", + texte, + ) + self.assertEqual( + 1, texte.count(t("A push alone changes nothing: the mirror is")) + ) + + def test_deux_branches_une_requete_ratee_se_tait(self): + from script.todo.todo_i18n import t + + texte = self._recap(rc_ls=128) + self.assertNotIn(t("The cache mirror has no branch"), texte) + self.assertNotIn(t("to deploy your own work."), texte) + self.assertFalse([a for a in self.appels if a[:2] == ["git", "log"]]) + + def test_deux_branches_en_ligne_rien_nest_demande(self): + """En ligne, l'écart se mesure contre HEAD, une seule branche : il + attribuerait à l'autre des commits qui ne la concernent pas.""" + texte = self._recap(hors_ligne=False) + self.assertEqual(self.appels, []) + self.assertNotIn("⚠", texte) + + def test_le_recapitulatif_passe_le_hors_ligne(self): + """Le récapitulatif est le dernier écran avant de déployer : c'est là + que l'écart doit parler du miroir.""" + import contextlib + import io + + for hors_ligne in (True, False): + with self.subTest(hors_ligne=hors_ligne): + todo = _todo() + vus = [] + todo._qemu_branch_gap_lines = lambda br, **kw: ( + vus.append(kw) or [] + ) + todo._qemu_sudo_lines = lambda: [] + spec = { + "vms": [ + { + "name": "vm-a", + "distro": "debian", + "version": "13", + "arch": "amd64", + "vcpus": 2, + "ram": 2048, + "disk": "20G", + } + ], + "install": { + "branch": "dev", + "prod": False, + "label": "x", + "cmd": "make x", + }, + "add_ssh_config": False, + "parallelism": 1, + "offline": hors_ligne, + } + with contextlib.redirect_stdout(io.StringIO()): + todo._qemu_print_recap(spec, []) + self.assertIs(vus[0].get("hors_ligne"), hors_ligne) + + def test_le_chemin_est_celui_que_le_cache_calcule(self): + from script.todo.qemu_cache_menu import CACHE_MIROIR_GIT + + attendu = f"{CACHE_MIROIR_GIT}/github.com/erplibre/erplibre.git" + todo = _todo() + with mock.patch("os.path.isdir", lambda p: p == attendu): + self.assertEqual(todo._qemu_miroir_erplibre(), attendu) + with mock.patch("os.path.isdir", return_value=False): + self.assertEqual(todo._qemu_miroir_erplibre(), "") + + +class TestUnCheminQueSystemdReecrirait(unittest.TestCase): + """Le guet reçoit les chemins de journaux en arguments, que systemd + réécrit quand ils portent « $ » ou « % » : le journal ne serait jamais + trouvé et la coupure tiendrait douze heures. Un tel chemin ne part donc + pas au guet, et la levée revient au « finally ».""" + + def _confier(self, chemin_journal): + import contextlib + import io as _io + import json + import tempfile + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + lances = [] + todo._qemu_shell = lambda cmd, timeout=60: lances.append(cmd) or 0 + with tempfile.NamedTemporaryFile( + "w", suffix=".json", delete=False, encoding="utf-8" + ) as fh: + json.dump({"vms": [{"log": chemin_journal}]}, fh) + manifeste = fh.name + with contextlib.redirect_stdout(_io.StringIO()): + rendu = todo._qemu_confier_la_levee(manifeste) + return rendu, lances + + def test_les_caracteres_reecrits_sont_refuses(self): + self.assertTrue(cache_offline.chemins_surs(["/srv/run/vm-a.log"])) + for piege in ("/srv/$HOME/vm.log", "/srv/run/%h.log", "/a/${X}/b"): + self.assertFalse(cache_offline.chemins_surs([piege]), piege) + + def test_un_chemin_sur_part_au_guet(self): + rendu, lances = self._confier("/srv/run/vm-a.log") + self.assertTrue(rendu) + self.assertTrue(any("systemd-run" in c for c in lances), lances) + + def test_un_chemin_reecrit_ne_part_pas_au_guet(self): + rendu, lances = self._confier("/srv/run/$HOME/vm-a.log") + self.assertFalse(rendu, "le guet a été posé sur un chemin réécrit") + self.assertFalse( + any("systemd-run" in c for c in lances), + f"systemd-run a été lancé : {lances}", + ) + + +class TestLeGuetAttendLesJournauxQuiNExistentPasEncore(unittest.TestCase): + """Le script du guet est EXÉCUTÉ ici, par un « sh » local : ni root, ni + systemd, ni pare-feu. Le guet part juste après le lancement des + installations ; un journal qui n'existe pas encore compté pour fini + lèverait la coupure sur-le-champ, et l'installation finirait en ligne.""" + + MARQUEUR = "__FIN_DE_TEST__" + + def _lancer(self, *journaux): + import subprocess as sp + + script = cache_offline.script_attente(self.MARQUEUR, pas=1) + proc = sp.Popen(["sh", "-c", script, "sh", *journaux]) + self.addCleanup(lambda: proc.poll() is None and proc.kill()) + return proc + + def _tmp(self): + import tempfile + + d = tempfile.mkdtemp() + self.addCleanup(lambda: __import__("shutil").rmtree(d, True)) + return d + + def test_un_journal_absent_nest_pas_fini(self): + import os + import time + + proc = self._lancer(os.path.join(self._tmp(), "pas-encore.log")) + time.sleep(1.5) + self.assertIsNone( + proc.poll(), "le guet a rendu la main sur un journal absent" + ) + + def test_un_journal_fini_et_un_absent_attendent_encore(self): + import os + import time + + d = self._tmp() + fini = os.path.join(d, "a.log") + with open(fini, "w", encoding="utf-8") as fh: + fh.write(f"installation\n{self.MARQUEUR} 0\n") + proc = self._lancer(fini, os.path.join(d, "b.log")) + time.sleep(1.5) + self.assertIsNone(proc.poll()) + + def test_le_guet_rend_la_main_quand_tous_sont_finis(self): + import os + import time + + d = self._tmp() + a, b = os.path.join(d, "a.log"), os.path.join(d, "b.log") + proc = self._lancer(a, b) + time.sleep(0.3) + for chemin in (a, b): + with open(chemin, "w", encoding="utf-8") as fh: + fh.write(f"installation\n{self.MARQUEUR} 2\n") + self.assertEqual(proc.wait(timeout=5), 0) + + +def setUpModule(): + """Les sessions d'installation, et le verrou des déploiements hors ligne + qui vit parmi elles, vont dans un répertoire temporaire : aucun test ne + crée de fichier sous le répertoire personnel, et un déploiement réel en + cours sur la machine de test ne tient pas le verrou des tests.""" + import tempfile + + import script.todo.qemu_install_monitor as mon + + sessions = tempfile.TemporaryDirectory() + unittest.addModuleCleanup(sessions.cleanup) + patch = mock.patch.object(mon, "session_dir", lambda: Path(sessions.name)) + patch.start() + unittest.addModuleCleanup(patch.stop) + # Les commandes rendues ne dépendent pas de l'hôte de test : sans ce + # remplacement, un hôte sans dnsmasq rendrait « false » pour la pose. + dns = mock.patch.object( + cache_offline, "dnsmasq", lambda: "/usr/sbin/dnsmasq" + ) + dns.start() + unittest.addModuleCleanup(dns.stop) + + +def _chemin_du_verrou(): + return _todo()._qemu_verrou_hors_ligne_chemin() + + +def _verrou_pris_ailleurs(test): + """Prend le verrou comme le ferait un autre terminal : un second + descripteur ouvert sur le même fichier, `flock` exclusif.""" + import fcntl + import os + + fd = os.open(_chemin_du_verrou(), os.O_RDWR | os.O_CREAT, 0o600) + test.addCleanup(os.close, fd) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _verrou_libre(): + """True si le verrou peut être pris maintenant ; il est aussitôt rendu.""" + import fcntl + import os + + fd = os.open(_chemin_du_verrou(), os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return False + finally: + os.close(fd) + return True + + +class TestUnDeploiementEnLigneSousLaCoupure(_SansSysteme, unittest.TestCase): + """La table est partagée par tout le cache : un déploiement EN LIGNE + lancé pendant qu'un autre la tient tournerait hors ligne. Il ne part + pas en silence, et cette voie n'appelle jamais sudo.""" + + def _sans_sudo(self, vu): + for cmd in vu["lances"]: + self.assertNotIn("sudo", cmd, "la voie en ligne a appelé sudo") + + def test_un_guet_actif_se_dit_et_non_ne_deploie_rien(self): + from script.todo.todo_i18n import t + + hote = _FauxHote(table=True, guet=True) + vu = _joue_la_spec(hote, reponse="n", hors_ligne=False) + self.assertEqual(hote.ordre, [], "déployé sans accord") + self.assertIn( + t("This deployment would therefore run offline."), vu["ecrit"] + ) + self.assertIn(cache_offline.lever_maintenant_cmd(), vu["ecrit"]) + self.assertIn("12 h", vu["ecrit"]) + self.assertIn(t("Nothing deployed."), vu["ecrit"]) + self._sans_sudo(vu) + + def test_sur_accord_il_part_sans_rien_couper_ni_lever(self): + hote = _FauxHote(table=True, guet=True) + vu = _joue_la_spec(hote, reponse="o", hors_ligne=False) + self.assertEqual(hote.ordre, ["déploiement"]) + self.assertIs(vu["kw"]["coupee"], False) + self.assertTrue(hote.table, "la coupure de l'autre a été levée") + self._sans_sudo(vu) + + def test_sans_guet_ni_verrou_rien_nest_demande(self): + # `_joue_la_spec` sans réponse rend toute question fatale. + hote = _FauxHote() + vu = _joue_la_spec(hote, hors_ligne=False) + self.assertEqual(hote.ordre, ["déploiement"]) + self.assertEqual(vu["lances"], [cache_offline.guet_actif_cmd()]) + + def test_un_deploiement_hors_ligne_qui_se_lance_se_dit_aussi(self): + """Entre la coupure et le guet, seul le verrou témoigne : le guet ne + part qu'au lancement des installations.""" + from script.todo.todo_i18n import t + + _verrou_pris_ailleurs(self) + hote = _FauxHote(table=True) + vu = _joue_la_spec(hote, reponse="n", hors_ligne=False) + self.assertEqual(hote.ordre, []) + self.assertIn( + t("terminal: the cache upstream is cut until it ends."), + vu["ecrit"], + ) + self._sans_sudo(vu) + + def test_le_sondage_ne_cree_pas_le_fichier_du_verrou(self): + # Le répertoire des sessions est commun au module : un test précédent + # a pu y laisser le fichier. + _chemin_du_verrou().unlink(missing_ok=True) + hote = _FauxHote() + _joue_la_spec(hote, hors_ligne=False) + self.assertFalse(_chemin_du_verrou().exists()) + + +class TestLeVerrouDesDeploiementsHorsLigne(_SansSysteme, unittest.TestCase): + """Deux déploiements hors ligne lancés ensemble passeraient tous deux + les relevés avant que l'un ait posé son guet : un verrou tient le bloc + entier, relevés compris.""" + + def test_un_verrou_tenu_refuse_avant_tout_releve(self): + from script.todo.todo_i18n import t + + _verrou_pris_ailleurs(self) + hote = _FauxHote() + vu = _joue_la_spec(hote) + self.assertEqual(vu["lances"], [], "un relevé a précédé le verrou") + self.assertEqual(hote.ordre, []) + self.assertIn(t("running in another terminal."), vu["ecrit"]) + self.assertIn(t("Nothing deployed."), vu["ecrit"]) + + def test_tenu_pendant_le_bloc_et_rendu_apres(self): + hote = _FauxHote() + vu = _joue_la_spec( + hote, pendant=lambda vu: vu.update(libre=_verrou_libre()) + ) + self.assertIs(vu["libre"], False, "le verrou n'est pas tenu") + self.assertTrue(_verrou_libre(), "le verrou n'est pas rendu") + + def test_rendu_quand_la_coupure_est_refusee(self): + hote = _FauxHote(guet=True) + _joue_la_spec(hote) + self.assertEqual(hote.ordre, []) + self.assertTrue(_verrou_libre()) + + +class TestLesSignauxPendantLaCoupure(_SansSysteme, unittest.TestCase): + """SIGHUP et SIGTERM tuent sur place par défaut : un terminal fermé + laisserait la coupure posée sans fin. Tant qu'elle est tenue, ils + déroulent le « finally ». Aucun signal n'est envoyé au processus de + test : le gestionnaire est relu et appelé directement.""" + + SIGNAUX = ("SIGHUP", "SIGTERM") + + def setUp(self): + import signal + + super().setUp() + for nom in self.SIGNAUX: + num = getattr(signal, nom) + self.addCleanup(signal.signal, num, signal.getsignal(num)) + signal.signal(num, signal.SIG_DFL) + + @staticmethod + def _releve(vu): + import signal + + vu["pendant"] = { + nom: signal.getsignal(getattr(signal, nom)) + for nom in ("SIGHUP", "SIGTERM") + } + + def _rendus(self): + import signal + + for nom in self.SIGNAUX: + self.assertEqual( + signal.getsignal(getattr(signal, nom)), + signal.SIG_DFL, + f"{nom} n'est pas rendu", + ) + + def test_le_premier_signal_sort_le_suivant_ne_coupe_pas_la_levee(self): + import signal + + def pendant(vu): + self._releve(vu) + try: + vu["pendant"]["SIGTERM"](signal.SIGTERM, None) + except SystemExit as fin: + vu["code"] = fin.code + vu["second"] = vu["pendant"]["SIGHUP"](signal.SIGHUP, None) + + vu = _joue_la_spec(_FauxHote(), pendant=pendant) + for nom in self.SIGNAUX: + self.assertTrue( + callable(vu["pendant"][nom]), f"{nom} non intercepté" + ) + self.assertEqual(vu["code"], 128 + signal.SIGTERM) + self.assertIsNone(vu["second"]) + self._rendus() + + def test_un_signal_dans_le_bloc_leve_la_coupure(self): + import signal + + def pendant(vu): + signal.getsignal(signal.SIGHUP)(signal.SIGHUP, None) + + hote = _FauxHote() + vu = _joue_la_spec(hote, pendant=pendant) + self.assertEqual(vu["sortie"], 128 + signal.SIGHUP) + self.assertEqual( + hote.ordre, ["coupure", "déploiement", "rebranchement"] + ) + self.assertFalse(hote.table) + self._rendus() + self.assertTrue(_verrou_libre()) + + def test_un_signal_pendant_la_commande_de_coupure_leve_quand_meme(self): + """La table est posée, la commande n'a pas encore rendu la main : + le signal reçu là doit trouver la levée sur son chemin.""" + import signal + + hote = _FauxHote() + dessous = hote.shell + + def shell(cmd, timeout=60): + rc = dessous(cmd, timeout) + if cmd == cache_offline.cut_cmd(): + signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None) + return rc + + hote.shell = shell + vu = _joue_la_spec(hote) + self.assertEqual(vu["sortie"], 128 + signal.SIGTERM) + self.assertEqual(hote.ordre, ["coupure", "rebranchement"]) + self.assertFalse(hote.table, "la coupure est restée posée") + self._rendus() + self.assertTrue(_verrou_libre()) + + def test_une_coupure_refusee_nest_pas_levee(self): + """Le sudo qui a refusé la coupure refuserait le retrait, et + redemanderait un mot de passe pour rien.""" + from script.todo.todo_i18n import t + + hote = _FauxHote() + dessous = hote.shell + + def shell(cmd, timeout=60): + rc = dessous(cmd, timeout) + return 1 if cmd == cache_offline.cut_cmd() else rc + + hote.shell = shell + vu = _joue_la_spec(hote) + self.assertEqual(hote.ordre, ["coupure"]) + self.assertIn(t("Upstream not cut: nothing deployed."), vu["ecrit"]) + self._rendus() + self.assertTrue(_verrou_libre()) + + def test_sans_dnsmasq_rien_nest_touche_et_on_dit_pourquoi(self): + """La pose échouerait sans motif lisible : le refus vient avant, et + nomme ce qui manque.""" + from script.todo.todo_i18n import t + + hote = _FauxHote() + with mock.patch.object(cache_offline, "dnsmasq", lambda: ""): + vu = _joue_la_spec(hote) + self.assertEqual(hote.ordre, [], "une commande est partie") + self.assertEqual(vu["lances"], []) + self.assertIn( + t("dnsmasq is missing on the host: names cannot"), vu["ecrit"] + ) + self._rendus() + self.assertTrue(_verrou_libre()) + + def test_un_signal_ignore_le_reste(self): + """Lancé sous « nohup », le déploiement survit à son terminal.""" + import signal + + signal.signal(signal.SIGHUP, signal.SIG_IGN) + vu = _joue_la_spec(_FauxHote(), pendant=self._releve) + self.assertEqual(vu["pendant"]["SIGHUP"], signal.SIG_IGN) + self.assertTrue(callable(vu["pendant"]["SIGTERM"])) + self.assertEqual(signal.getsignal(signal.SIGHUP), signal.SIG_IGN) + + def test_en_ligne_aucun_gestionnaire_nest_pose(self): + import signal + + vu = _joue_la_spec(_FauxHote(), hors_ligne=False, pendant=self._releve) + self.assertEqual( + vu["pendant"], {nom: signal.SIG_DFL for nom in self.SIGNAUX} + ) + + def test_hors_du_fil_principal_le_deploiement_passe(self): + """`signal.signal` lève ValueError hors du fil principal.""" + import threading + + hote = _FauxHote() + erreurs = [] + + def fil(): + try: + _joue_la_spec(hote) + except BaseException as exc: # noqa: B902 + erreurs.append(exc) + + fil_de_test = threading.Thread(target=fil) + fil_de_test.start() + fil_de_test.join(timeout=30) + self.assertEqual(erreurs, []) + self.assertEqual( + hote.ordre, ["coupure", "déploiement", "rebranchement"] + ) + self._rendus() + + +class TestLeDebutDuDeploiementDansLaSession(_SansSysteme, unittest.TestCase): + """« deploy_started » ouvre la fenêtre du bilan hors ligne AVANT la + création des VM : « started » n'est posé qu'après leur premier + démarrage, quand cloud-init et l'agent invité ont déjà demandé.""" + + VM = { + "name": "vm-a", + "ip": "192.0.2.10", + "distro": "ubuntu", + "version": "24.04", + "arch": "amd64", + } + + def _session(self, **kw): + import json + import tempfile + + import script.todo.qemu_install_monitor as mon + + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + with mock.patch.object( + mon, "session_dir", lambda: Path(tmp.name) + ), mock.patch.object( + mon, "_launch_one", lambda *a, **k: None + ), mock.patch.object( + mon, "local_uuid", lambda nom: "" + ): + chemin = mon.launch_installs( + [dict(self.VM)], "develop", "true", **kw + ) + with open(chemin, encoding="utf-8") as fh: + return json.load(fh) + + def test_ecrit_quand_il_est_donne(self): + data = self._session(deploy_started=1234) + self.assertEqual(data["deploy_started"], 1234.0) + self.assertIsInstance(data["deploy_started"], float) + self.assertIn("started", data) + + def test_absent_sinon(self): + data = self._session() + self.assertNotIn("deploy_started", data) + self.assertNotIn("offline", data) + self.assertIn("started", data) + + def test_le_hors_ligne_est_ecrit_quand_il_est_donne(self): + """Seul un « offline » vrai permet au bilan de clore la lecture : + les lignes d'amont muet naissent aussi en ligne.""" + for valeur in (True, False): + with self.subTest(hors_ligne=valeur): + data = self._session(hors_ligne=valeur) + self.assertIs(data["offline"], valeur) + + def _suivi(self, **kw): + import contextlib + import io + import json + import tempfile + + import script.todo.qemu_install_monitor as mon + + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + recus = [] + + def lance(vms, branche, remote, **options): + recus.append(options) + manifeste = Path(tmp.name) / "session.json" + manifeste.write_text(json.dumps({"vms": []}), encoding="utf-8") + return str(manifeste) + + todo = _todo() + todo._qemu_shell = lambda cmd, timeout=60: 1 + todo._qemu_import_module = lambda: None + todo._qemu_erplibre_remote_cmd = lambda *a, **k: "true" + with mock.patch.object( + mon, "launch_installs", lance + ), mock.patch.object( + mon, "run_monitor", lambda m: None + ), contextlib.redirect_stdout( + io.StringIO() + ): + todo._qemu_install_erplibre_monitored( + ["vm-a"], "develop", {"vm-a": "192.0.2.10"}, **kw + ) + return recus + + def test_le_suivi_le_transmet_au_lanceur(self): + self.assertEqual( + self._suivi(deploy_started=1234.5), [{"deploy_started": 1234.5}] + ) + + def test_sans_valeur_le_lanceur_ne_recoit_rien_de_plus(self): + self.assertEqual(self._suivi(), [{}]) + + def test_le_suivi_transmet_le_hors_ligne(self): + for valeur in (True, False): + with self.subTest(hors_ligne=valeur): + self.assertEqual( + self._suivi(hors_ligne=valeur), [{"hors_ligne": valeur}] + ) + + def test_la_spec_le_fournit(self): + import contextlib + import io + import time + + todo = _todo() + vus = [] + todo._qemu_install_erplibre_monitored = lambda *a, **k: vus.append(k) + avant = time.time() + with contextlib.redirect_stdout(io.StringIO()): + todo._qemu_deploie_spec( + { + "vms": [], + "existing": ["vm-a"], + "install": None, + "add_ssh_config": False, + "parallelism": 1, + "monitor": True, + } + ) + apres = time.time() + self.assertLessEqual(avant, vus[0]["deploy_started"]) + self.assertLessEqual(vus[0]["deploy_started"], apres) + self.assertIs(vus[0].get("hors_ligne"), False) + + +class TestLeSuiviDOfficeHorsLigne(_SansSysteme, unittest.TestCase): + """Hors ligne, seule la voie suivie confie la levée au guet : sans elle, + la voie synchrone n'a aucun guet, et une spec sans rien à installer + lèverait la coupure dès les IP connues.""" + + def _joue(self, spec): + import contextlib + import io + + todo = _todo() + appels = [] + todo._qemu_install_erplibre_monitored = lambda *a, **k: appels.append( + "suivi" + ) + todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet") + todo._qemu_resolve_ips = lambda names, labels=None: {} + base = { + "vms": [], + "existing": ["vm-a"], + "install": None, + "add_ssh_config": False, + "parallelism": 1, + } + base.update(spec) + with contextlib.redirect_stdout(io.StringIO()): + todo._qemu_deploie_spec(base) + return appels + + INSTALL_MUETTE = { + "branch": "develop", + "prod": False, + "cmd": "make x", + "monitor": False, + } + + def test_rien_a_installer_le_suivi_souvre_quand_meme(self): + self.assertEqual( + self._joue({"monitor": False, "offline": True}), ["suivi"] + ) + self.assertEqual(self._joue({"monitor": False}), []) + + def test_une_install_sans_suivi_prend_la_voie_suivie(self): + spec = {"monitor": False, "install": self.INSTALL_MUETTE} + self.assertEqual(self._joue(dict(spec, offline=True)), ["suivi"]) + self.assertEqual(self._joue(spec), ["muet"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_cache_preflight.py b/test/test_qemu_cache_preflight.py new file mode 100644 index 0000000..2dbc736 --- /dev/null +++ b/test/test_qemu_cache_preflight.py @@ -0,0 +1,1668 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Savoir avant de couper ce qui a manqué ; le combler tant qu'on est en ligne. + +Un déploiement hors ligne qui a manqué une adresse la manquera encore au +suivant, si rien ne l'a remplie entre-temps. Le journal d'accès le sait — +chaque « offline-miss » porte son client et son instant — et le suivi des +installations sait quelle VM portait quelle adresse, et quand. Réunis, ils +disent quoi annoncer avant la coupure, et quoi rejouer par le cache tant que +l'amont répond. + +Aucun test ne touche la machine qui les exécute : ni pare-feu, ni service, ni +réseau. Toute commande est interceptée pour la CLASSE entière, et la doublure +ne relaie jamais vers la vraie : un chemin qui lancerait une commande imprévue +échoue sur une réponse vide au lieu d'agir. +""" + +import contextlib +import io +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.qemu import cache_offline # noqa: E402 +from script.todo import qemu_cache_menu as menu # noqa: E402 +from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M # noqa: E402 +from script.todo.qemu_install_monitor import EXIT_MARKER # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + +try: + import textual # noqa: F401 + + TEXTUAL = True +except ImportError: + TEXTUAL = False + +INSTALLATEUR = RACINE / "script" / "install" / "install_qemu_cache.sh" +QEMU_FORM = RACINE / "script" / "todo" / "qemu_deploy_form.py" + +# Des valeurs inventées : un nom de VM qu'aucun parc ne porte, des adresses +# de documentation (RFC 5737) et des domaines réservés (RFC 2606). +MAINTENANT = 1_900_000_000.0 +NOM = "vm-essai-hl" +AUTRE = "vm-essai-autre" +IP = "192.0.2.10" +URL_A = "https://example.com/depot/a.tar.gz?v=1&w=2" +URL_A2 = "https://example.com/depot/cible.tar.gz" +URL_B = "http://example.org/liste" +URL_C = "https://example.net/racine/16.json" +URL_POST = "https://example.net/api/stats" +URL_IP = "https://192.0.2.99/objet" + + +def iso(instant): + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(instant)) + + +def ligne(instant, issue, url, methode="GET", client=IP): + """Une ligne du journal d'accès, telle que le service l'écrit.""" + return { + "time": iso(instant), + "method": methode, + "url": url, + "class": "volatile", + "outcome": issue, + "status": 504 if issue == "offline-miss" else 200, + "bytes": 0, + "upstream": False, + "client": client, + } + + +class SansSysteme(unittest.TestCase): + """subprocess.run est remplacé pour toute la classe, sans relais. + + La doublure note chaque appel et répond par « repondre », qui rend par + défaut un échec vide. Le jour où le code sous test lance une commande + que le test n'attend pas, il reçoit ce vide — jamais la machine réelle. + """ + + def setUp(self): + self.lancees = [] + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.dossier = Path(self.tmp.name) + + def doublure(cmd, *a, **kw): + texte = cmd if isinstance(cmd, str) else " ".join(map(str, cmd)) + self.lancees.append((texte, kw)) + rc, sortie = self.repondre(texte, kw) + return subprocess.CompletedProcess(cmd, rc, sortie, "") + + patch = mock.patch("subprocess.run", doublure) + patch.start() + self.addCleanup(patch.stop) + + def repondre(self, texte, kw): + return 127, "" + + def journal(self, lignes): + chemin = self.dossier / "acces.jsonl" + with open(chemin, "w", encoding="utf-8") as fh: + for d in lignes: + # Compact, comme le json.Marshal de Go. + fh.write(json.dumps(d, separators=(",", ":")) + "\n") + return str(chemin) + + def deploiement( + self, + label, + debut, + vms, + branche="develop", + deploy_started=None, + hors_ligne=None, + ): + """Un répertoire de déploiement : session.json et un log par VM. + + vms : [(nom, ip, fin, code)]. « fin » date le log ; un code None le + laisse sans marqueur de sortie, comme une installation en cours. + « deploy_started » et « hors_ligne », quand ils sont donnés, sont + écrits au manifeste comme le fait le suivi des installations — le + second sous « offline ». + """ + d = self.dossier / "runs" / label + d.mkdir(parents=True) + entrees = [] + for nom, ip, fin, code in vms: + log = d / f"{nom}.log" + texte = "en-tête\n" + if code is not None: + texte += f"{EXIT_MARKER} {code}\n" + log.write_text(texte) + os.utime(log, (fin, fin)) + entrees.append({"name": nom, "ip": ip, "log": str(log)}) + manifeste = {"branch": branche, "started": debut, "vms": entrees} + if deploy_started is not None: + manifeste["deploy_started"] = deploy_started + if hors_ligne is not None: + manifeste["offline"] = hors_ligne + (d / "session.json").write_text(json.dumps(manifeste)) + return str(self.dossier / "runs") + + +# --------------------------------------------------------------------------- +# Le choix des déploiements qui renseignent +# --------------------------------------------------------------------------- + + +class TestLeBilanDesDeploiements(SansSysteme): + """Quels déploiements parlent, et pour quelle VM. + + Deux pannes muettes sont gardées ici. Prendre le seul dernier déploiement + rend l'avertissement muet dès qu'il s'est arrêté avant d'avoir rien + demandé. Et une adresse IP se réattribue : sans la fenêtre du déploiement, + les manques d'une VM seraient comptés à une autre. + """ + + def bilan(self, racine, lignes, nom=NOM): + runs = cache_offline.lire_runs(racine) + releve = cache_offline.releve_amont_muet( + self.journal(lignes), {IP, "192.0.2.11"} + ) + return cache_offline.bilan_du_nom(runs, nom, releve, MAINTENANT) + + def test_les_deploiements_renseignes_sont_reunis(self): + t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600 + racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)]) + self.deploiement("recent", t2 - 600, [(NOM, IP, t2, 1)]) + b = self.bilan( + racine, + [ + ligne(t1 - 300, "offline-miss", URL_A), + ligne(t2 - 300, "offline-miss", URL_B), + ligne(t2 - 200, "offline-miss", URL_A), + ], + ) + self.assertEqual(set(b["manques"]), {("GET", URL_A), ("GET", URL_B)}) + self.assertEqual(b["runs"], ["recent", "ancien"]) + self.assertAlmostEqual(b["age"], 3600, delta=1) + + def test_un_deploiement_arrete_tot_ne_renseigne_pas(self): + """Coupé, arrêté sur un verrou apt avant d'avoir rien manqué : il ne + dit rien, et ne doit pas faire taire le précédent.""" + t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600 + racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)]) + self.deploiement("verrou", t2 - 600, [(NOM, IP, t2, 100)]) + b = self.bilan( + racine, + [ + ligne(t1 - 300, "offline-miss", URL_A), + ligne(t2 - 300, "stale", URL_B), + ], + ) + self.assertIsNotNone(b, "l'arrêt précoce a fait taire l'avertissement") + self.assertEqual(b["runs"], ["ancien"]) + self.assertEqual(list(b["manques"]), [("GET", URL_A)]) + + def test_une_reussite_hors_ligne_clot_la_lecture(self): + """Réussi coupé : ce qui manquait avant a été rempli, ou n'est plus + demandé. Le rappeler serait un avertissement sans motif.""" + t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600 + racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)]) + self.deploiement( + "reussi", t2 - 600, [(NOM, IP, t2, 0)], hors_ligne=True + ) + b = self.bilan( + racine, + [ + ligne(t1 - 300, "offline-miss", URL_A), + ligne(t2 - 300, "stale", URL_B), + ], + ) + self.assertIsNone(b) + + def test_une_reussite_quon_ne_sait_pas_coupee_ne_clot_rien(self): + """Un amont qui vient de refuser une connexion est tenu pour muet + quelques secondes : des lignes « stale » naissent alors EN LIGNE. Une + réussite n'est une preuve hors ligne que si le manifeste le dit.""" + t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600 + for hors_ligne in (False, None): + with self.subTest(hors_ligne=hors_ligne): + racine = self.deploiement( + f"ancien-{hors_ligne}", t1 - 600, [(NOM, IP, t1, 1)] + ) + self.deploiement( + f"recent-{hors_ligne}", + t2 - 600, + [(NOM, IP, t2, 0)], + hors_ligne=hors_ligne, + ) + b = self.bilan( + racine, + [ + ligne(t1 - 300, "offline-miss", URL_A), + ligne(t2 - 300, "stale", URL_B), + ], + ) + self.assertIsNotNone(b, "une réussite en ligne a tout tu") + self.assertEqual(list(b["manques"]), [("GET", URL_A)]) + shutil.rmtree(racine) + + def test_le_hors_ligne_du_manifeste_est_lu(self): + """Un booléen seul fait foi : toute autre valeur ne dit rien.""" + for ecrit, lu in ((True, True), (False, False), ("oui", None)): + with self.subTest(ecrit=ecrit): + racine = self.deploiement( + "seul", + MAINTENANT - 600, + [(NOM, IP, MAINTENANT, 1)], + hors_ligne=ecrit, + ) + runs = cache_offline.lire_runs(racine) + self.assertIs(runs[0]["hors_ligne"], lu) + shutil.rmtree(racine) + racine = self.deploiement( + "sans", MAINTENANT - 600, [(NOM, IP, MAINTENANT, 1)] + ) + self.assertIsNone(cache_offline.lire_runs(racine)[0]["hors_ligne"]) + + def test_une_reussite_hors_ligne_garde_ses_propres_manques(self): + """Les blocs d'outils facultatifs préviennent et rendent 0 : une + installation coupée qui en a manqué plusieurs réussit quand même. + Ses manques parlent ; seuls les déploiements plus anciens se + taisent.""" + t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600 + racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)]) + self.deploiement( + "reussi", t2 - 600, [(NOM, IP, t2, 0)], hors_ligne=True + ) + b = self.bilan( + racine, + [ + ligne(t1 - 300, "offline-miss", URL_A), + ligne(t2 - 400, "stale", URL_C), + ligne(t2 - 300, "offline-miss", URL_B), + ], + ) + self.assertIsNotNone(b, "les manques d'une réussite sont perdus") + self.assertEqual(b["runs"], ["reussi"]) + self.assertEqual(list(b["manques"]), [("GET", URL_B)]) + + def test_la_fenetre_souvre_au_debut_du_deploiement(self): + """Le premier démarrage d'une VM coupée passe par le cache avant le + lancement des installations : « deploy_started » ouvre la fenêtre + là, et un manque de ce démarrage est attribué.""" + fin = MAINTENANT - 3600 + racine = self.deploiement( + "seul", fin - 600, [(NOM, IP, fin, 1)], deploy_started=fin - 1500 + ) + b = self.bilan(racine, [ligne(fin - 1200, "offline-miss", URL_A)]) + self.assertIsNotNone(b, "le manque du premier démarrage est perdu") + self.assertEqual(list(b["manques"]), [("GET", URL_A)]) + + def test_sans_deploy_started_la_fenetre_souvre_au_lancement(self): + """Un manifeste qui ne le porte pas garde la règle d'avant : le plus + tôt du répertoire et de « started ».""" + fin = MAINTENANT - 3600 + racine = self.deploiement("seul", fin - 600, [(NOM, IP, fin, 1)]) + self.assertIsNone( + self.bilan(racine, [ligne(fin - 1200, "offline-miss", URL_A)]) + ) + self.assertIsNotNone( + self.bilan(racine, [ligne(fin - 300, "offline-miss", URL_A)]) + ) + + def test_une_reussite_en_ligne_ne_clot_rien(self): + """Réussi amont branché : il ne prouve rien du hors ligne.""" + t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600 + racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)]) + self.deploiement("en_ligne", t2 - 600, [(NOM, IP, t2, 0)]) + b = self.bilan(racine, [ligne(t1 - 300, "offline-miss", URL_A)]) + self.assertIsNotNone(b) + self.assertEqual(b["runs"], ["ancien"]) + + def test_la_cle_est_le_nom_et_la_fenetre(self): + """La même adresse, portée par deux VM à deux moments.""" + t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600 + racine = self.deploiement("autre", t1 - 600, [(AUTRE, IP, t1, 1)]) + self.deploiement("la_notre", t2 - 600, [(NOM, IP, t2, 1)]) + lignes = [ + ligne(t1 - 300, "offline-miss", URL_C), + ligne(t2 - 300, "offline-miss", URL_A), + ] + self.assertEqual( + list(self.bilan(racine, lignes)["manques"]), [("GET", URL_A)] + ) + self.assertEqual( + list(self.bilan(racine, lignes, nom=AUTRE)["manques"]), + [("GET", URL_C)], + ) + + def test_la_fenetre_ecarte_avant_et_apres(self): + """Une ligne écrite après la fin du log vient d'un autre geste sur la + VM, pas du déploiement.""" + fin = MAINTENANT - 3600 + racine = self.deploiement("seul", fin - 600, [(NOM, IP, fin, 1)]) + b = self.bilan( + racine, + [ + ligne(fin - 900, "offline-miss", URL_B), + ligne(fin - 300, "offline-miss", URL_A), + ligne(fin + 60, "offline-miss", URL_C), + ], + ) + self.assertEqual(list(b["manques"]), [("GET", URL_A)]) + + def test_le_debut_est_le_plus_tot_des_deux(self): + """« started » est écrit après le lancement de toutes les VM ; le nom + du répertoire date le début du lancement.""" + fin = MAINTENANT - 3600 + label = time.strftime("%Y%m%d-%H%M%S", time.localtime(fin - 900)) + racine = self.deploiement(label, fin - 600, [(NOM, IP, fin, 1)]) + b = self.bilan(racine, [ligne(fin - 800, "offline-miss", URL_A)]) + self.assertIsNotNone(b, "la première requête a été perdue") + + def test_trop_vieux_se_tait(self): + fin = MAINTENANT - cache_offline.AGE_MAX - 60 + racine = self.deploiement("vieux", fin - 600, [(NOM, IP, fin, 1)]) + self.assertIsNone( + self.bilan(racine, [ligne(fin - 300, "offline-miss", URL_A)]) + ) + + def test_le_nombre_de_deploiements_est_borne(self): + lignes = [] + racine = "" + for i in range(4): + fin = MAINTENANT - 3600 * (i + 1) + racine = self.deploiement(f"r{i}", fin - 600, [(NOM, IP, fin, 1)]) + lignes.append(ligne(fin - 300, "offline-miss", f"{URL_B}/{i}")) + b = self.bilan(racine, lignes) + self.assertEqual(len(b["runs"]), cache_offline.RUNS_RETENUS) + + def test_sans_branche_rien_nest_installe(self): + fin = MAINTENANT - 3600 + racine = self.deploiement( + "demarrage", fin - 600, [(NOM, IP, fin, 1)], branche="" + ) + self.assertIsNone( + self.bilan(racine, [ligne(fin - 300, "offline-miss", URL_A)]) + ) + + +# --------------------------------------------------------------------------- +# Ce que le magasin détient, ou le journal à défaut +# --------------------------------------------------------------------------- + + +class TestCeQueLeMagasinDetient(SansSysteme): + """Le bilan moins ce que le cache détient MAINTENANT.""" + + def preflight(self, detient, extra=()): + fin = MAINTENANT - 3600 + racine = self.deploiement("seul", fin - 600, [(NOM, IP, fin, 1)]) + lignes = [ + ligne(fin - 400, "offline-miss", URL_A), + ligne(fin - 350, "offline-miss", URL_B), + ligne(fin - 300, "offline-miss", URL_POST, methode="POST"), + ligne(fin - 250, "offline-miss", URL_C, methode="HEAD"), + ] + list(extra) + return cache_offline.manques_hors_ligne( + [{"name": NOM}], + racine=racine, + chemin=self.journal(lignes), + maintenant=MAINTENANT, + detient=detient, + ) + + def test_le_magasin_soustrait_ce_quil_detient(self): + demandes = [] + + def detient(paires): + demandes.extend(paires) + return { + ("GET", URL_A): {"verdict": "garde"}, + ("HEAD", URL_C): {"verdict": "statut"}, + ("GET", URL_B): {"verdict": "absent"}, + } + + (b,) = self.preflight(detient) + self.assertEqual(b["manquants"], [("GET", URL_B)]) + self.assertFalse(b["selon_journal"]) + self.assertNotIn( + ("POST", URL_POST), demandes, "un POST n'a rien à demander" + ) + + def test_un_post_nest_pas_compte_mais_nomme(self): + (b,) = self.preflight(lambda paires: {}) + self.assertNotIn(("POST", URL_POST), b["manquants"]) + self.assertEqual(b["jamais"], [("POST", URL_POST)]) + + def test_ce_que_le_cache_ne_garde_pas_va_avec_les_post(self): + (b,) = self.preflight( + lambda paires: {("GET", URL_B): {"verdict": "non-cachable"}} + ) + self.assertIn(("GET", URL_B), b["jamais"]) + self.assertNotIn(("GET", URL_B), b["manquants"]) + + def test_une_negociation_git_nomme_son_depot_a_part(self): + """Un GET de négociation n'est ni un POST ni une adresse à + rejouer : c'est un dépôt que le miroir remplit. Compté par dépôt, et + jamais demandé au magasin.""" + depot = "https://example.com/o/d.git" + demandes = [] + + def detient(paires): + demandes.extend(paires) + return {} + + (b,) = self.preflight( + detient, + extra=[ + ligne( + MAINTENANT - 3900, + "offline-miss", + f"{depot}/info/refs?service=git-upload-pack", + ), + ligne( + MAINTENANT - 3850, + "offline-miss", + f"{depot}/git-upload-pack", + methode="POST", + ), + ], + ) + self.assertEqual(b["git"], [depot]) + for cle in b["jamais"] + b["manquants"] + demandes: + self.assertNotIn(depot, cle[1], cle) + self.assertEqual(b["jamais"], [("POST", URL_POST)]) + + def test_selon_le_journal_une_negociation_git_nest_pas_comblable(self): + """Sans « --detient », le journal ne dit rien d'une négociation : la + compter parmi les manquants promettrait un rejeu qui ne garde rien.""" + url = "https://example.com/o/d.git/info/refs?service=git-upload-pack" + (b,) = self.preflight( + lambda paires: None, + extra=[ligne(MAINTENANT - 3900, "offline-miss", url)], + ) + self.assertNotIn(("GET", url), b["manquants"]) + self.assertEqual(b["git"], ["https://example.com/o/d.git"]) + + def test_le_depot_dune_negociation(self): + for url, attendu in ( + ( + "https://example.com/o/d.git/info/refs?service=x", + "https://example.com/o/d.git", + ), + ("http://example.org/d/git-receive-pack", "http://example.org/d"), + ("https://example.com/info/refs", "https://example.com/"), + ("https://example.com/o/d.git/HEAD", ""), + ("", ""), + ): + self.assertEqual(cache_offline.depot_git(url), attendu, url) + + def test_selon_le_journal_une_garde_anterieure_ne_compte_pas(self): + """Un objet gardé puis purgé laisse ses lignes « stored » : seule + une garde POSTÉRIEURE au manque vaut détention.""" + fin = MAINTENANT - 3600 + (b,) = self.preflight( + lambda paires: None, + extra=[ + ligne(fin - 5000, "stored", URL_A), + ligne(fin + 900, "stored", URL_B), + ligne(fin + 950, "stored-status", URL_C, methode="HEAD"), + ], + ) + self.assertTrue(b["selon_journal"]) + self.assertEqual(b["manquants"], [("GET", URL_A)]) + + def test_tout_detenu_se_tait(self): + self.assertEqual( + self.preflight( + lambda paires: {p: {"verdict": "garde"} for p in paires} + ), + [], + ) + + def test_sans_deploiement_rien_nest_lu_ni_lance(self): + fin = MAINTENANT - 3600 + racine = self.deploiement("autre", fin - 600, [(AUTRE, IP, fin, 1)]) + + def interdit(paires): + raise AssertionError("le magasin a été interrogé pour rien") + + self.assertEqual( + cache_offline.manques_hors_ligne( + [{"name": NOM}], + racine=racine, + chemin=str(self.dossier / "absent.jsonl"), + maintenant=MAINTENANT, + detient=interdit, + ), + [], + ) + self.assertEqual(self.lancees, []) + + +class TestLesComposantsQueLeCacheNaPas(SansSysteme): + """Le verdict par SUITE ne suffit pas. + + Une seule URL en réserve sous « /dists// » le rend muet, alors que + « restricted » ou « -security » peuvent manquer en entier. apt ne le dit + qu'à l'installation — « Unable to locate package gnome-core » —, vingt + minutes après la coupure, et ce message accuse le dépôt. + """ + + VM = {"distro": "ubuntu", "version": "26.04", "arch": "amd64"} + + @property + def code(self): + """Le nom de code vient du CATALOGUE, jamais d'une table recopiée + ici : le test reste vrai quand le catalogue change.""" + jeton = cache_offline.jeton_de_suite("ubuntu", "26.04") + return jeton.strip("/").split("/")[-1] + + def _journal(self, paires, issue="hit"): + """Un journal où chaque (suite, composant) donné est en réserve.""" + return self.journal( + [ + { + "url": f"http://m.invalid/ubuntu/dists/{suite}" + f"/{composant}/binary-amd64/Packages.xz", + "outcome": issue, + } + for suite, composant in paires + ] + ) + + def _tout(self): + return [ + (self.code + suffixe, composant) + for suffixe in cache_offline.SUFFIXES_APT + for composant in cache_offline.COMPOSANTS_APT + ] + + def test_main_seul_ne_suffit_pas(self): + """Le cas vécu : « main » de la suite de base était en réserve, et + l'écran s'est tu ; « restricted » et « -security » manquaient.""" + chemin = self._journal([(self.code, "main")]) + rendu = cache_offline.composants_absents([self.VM], chemin) + self.assertEqual(len(rendu), 1) + distro, version, manque = rendu[0] + self.assertEqual((distro, version), ("ubuntu", "26.04")) + self.assertIn(f"{self.code}/restricted", manque) + self.assertIn(f"{self.code}-security/main", manque) + self.assertNotIn(f"{self.code}/main", manque) + + def test_un_cache_complet_se_tait(self): + chemin = self._journal(self._tout()) + self.assertEqual( + cache_offline.composants_absents([self.VM], chemin), [] + ) + + def test_un_cache_qui_ignore_tout_se_tait_aussi(self): + """« suites_absentes » le dit déjà : deux avertissements pour une + cause apprennent à les enchaîner.""" + chemin = self._journal([("autrechose", "main")]) + self.assertEqual( + cache_offline.composants_absents([self.VM], chemin), [] + ) + + def test_une_issue_sans_corps_ne_compte_pas(self): + """« offline-miss » prouve le contraire de ce qu'on cherche.""" + chemin = self._journal(self._tout(), issue="offline-miss") + self.assertEqual( + cache_offline.composants_absents([self.VM], chemin), [] + ) + + def test_larchitecture_est_respectee(self): + """Un index amd64 ne dit rien d'une VM arm64 : tout lui manque, donc + on se tait — c'est le cas que « suites_absentes » couvre.""" + chemin = self._journal(self._tout()) + arm = dict(self.VM, arch="arm64") + self.assertEqual(cache_offline.composants_absents([arm], chemin), []) + + def test_deux_vm_du_meme_systeme_ne_parlent_quune_fois(self): + chemin = self._journal([(self.code, "main")]) + rendu = cache_offline.composants_absents( + [self.VM, dict(self.VM)], chemin + ) + self.assertEqual(len(rendu), 1) + + def test_un_systeme_sans_jeton_se_tait(self): + """Fedora et openSUSE se ressemblent dans l'URL : ne pas se prononcer + vaut mieux que rassurer à tort.""" + chemin = self._journal([(self.code, "main")]) + autre = {"distro": "fedora", "version": "44", "arch": "amd64"} + self.assertEqual(cache_offline.composants_absents([autre], chemin), []) + + def test_un_journal_illisible_se_tait(self): + self.assertEqual( + cache_offline.composants_absents([self.VM], "/inexistant.jsonl"), + [], + ) + + +class TestUnObjetDeStatutNestPasUnCorps(unittest.TestCase): + """Une redirection ou un refus gardés ne rendent pas une suite lisible. + + Le contrôle de la suite compte les issues qui prouvent un CORPS ; y mêler + les objets de statut ferait taire « le cache n'a rien pour ce système » + devant un index gardé en 404. Pour le bilan hors ligne, en revanche, ils + valent réponse : la VM reçoit ce que l'amont avait rendu. + """ + + def test_les_issues_de_statut_restent_hors_de_la_reserve(self): + for issue in ("stored-status", "stale-status"): + self.assertNotIn(issue, cache_offline.ISSUES_EN_RESERVE) + self.assertIn(issue, cache_offline.ISSUES_REPONSE_GARDEE) + + +class TestLaQuestionAuMagasin(SansSysteme): + """« --detient » : sondé dans l'aide, interrogé sur l'entrée standard.""" + + def setUp(self): + super().setUp() + self.binaire = self.dossier / "binaire" + self.binaire.write_text("") + self.aide = " -cache-dir string\n -detient\n lit l'entrée\n" + self.rc = 0 + self.sortie = ( + f"garde\t200\t2030-01-01T00:00:00Z\tvolatile\tGET\t{URL_A}\n" + f"absent\t0\t-\tvolatile\tGET\t{URL_B}\n" + ) + self.entree = None + + def repondre(self, texte, kw): + if texte.endswith("--help"): + return 0, self.aide + if texte.endswith("--detient"): + self.entree = kw.get("input") + return self.rc, self.sortie + return 127, "" + + def interroger(self, paires=(("GET", URL_A), ("GET", URL_B))): + return cache_offline.detient_interroger( + list(paires), str(self.binaire), "/casiers/essai" + ) + + def test_le_rendu_lit_les_six_champs(self): + rendu = cache_offline.detient_rendu(self.sortie + "ligne mal formée\n") + self.assertEqual(rendu[("GET", URL_A)]["verdict"], "garde") + self.assertEqual(rendu[("GET", URL_B)]["garde_le"], "-") + self.assertEqual(len(rendu), 2) + + def test_la_question_part_sur_lentree_standard(self): + rendu = self.interroger() + self.assertEqual(rendu[("GET", URL_A)]["verdict"], "garde") + self.assertEqual(self.entree, f"GET {URL_A}\nGET {URL_B}\n") + (argv,) = [c for c, _kw in self.lancees if c.endswith("--detient")] + self.assertIn("--cache-dir /casiers/essai", argv) + self.assertNotIn("sudo", argv) + + def test_un_binaire_sans_detient_rend_none(self): + self.aide = " -cache-dir string\n -status\n" + self.assertIsNone(self.interroger()) + self.assertFalse( + any(c.endswith("--detient") for c, _kw in self.lancees), + "la question est partie vers un binaire qui ne la connaît pas", + ) + + def test_un_echec_rend_none(self): + self.rc = 2 + self.assertIsNone(self.interroger()) + + def test_un_binaire_absent_rend_none_sans_rien_lancer(self): + self.binaire.unlink() + self.assertIsNone(self.interroger()) + self.assertEqual(self.lancees, []) + + def test_rien_a_demander_ne_lance_rien(self): + self.assertEqual(self.interroger(()), {}) + self.assertEqual(self.lancees, []) + + +class TestLesManquesRecents(SansSysteme): + """Ce que le menu relit pour combler : les invités seuls, sans doublon.""" + + def test_le_releve(self): + maintenant = time.time() + chemin = self.journal( + [ + ligne(maintenant - 60, "offline-miss", URL_A), + ligne( + maintenant - 30, "offline-miss", URL_A, client="192.0.2.11" + ), + ligne( + maintenant - 20, "offline-miss", URL_B, client="127.0.0.1" + ), + ligne( + maintenant - 10, + "offline-miss", + URL_C, + client="198.51.100.7", + ), + ligne(maintenant - 9 * 86400, "offline-miss", URL_POST), + ligne(maintenant - 5, "stored", URL_A2), + ] + ) + releve = cache_offline.manques_recents( + chemin, "192.0.2.0/24", maintenant - 7 * 86400 + ) + self.assertEqual( + [(e["methode"], e["url"]) for e in releve], [("GET", URL_A)] + ) + self.assertEqual(releve[0]["n"], 2) + self.assertEqual(releve[0]["clients"], {IP, "192.0.2.11"}) + + def test_sans_sous_reseau_la_boucle_locale_reste_ecartee(self): + maintenant = time.time() + chemin = self.journal( + [ligne(maintenant - 20, "offline-miss", URL_B, client="127.0.0.1")] + ) + self.assertEqual(cache_offline.manques_recents(chemin, "", 0.0), []) + + +# --------------------------------------------------------------------------- +# Le verdict de rejeu et la commande +# --------------------------------------------------------------------------- + + +def exclusions_du_go(): + """DefaultExclusions, relues dans la source Go comme le fait le test qui + lit proxy.go.""" + src = (RACINE / "script" / "qemu_cache" / "mitm.go").read_text( + encoding="utf-8" + ) + bloc = re.search(r"var DefaultExclusions = \[\]string\{([^}]*)\}", src) + return re.findall(r'"([^"]+)"', bloc.group(1)) + + +class TestLeVerdictDeRejeu(unittest.TestCase): + """Ce qui se rejoue, et ce qui ne doit JAMAIS l'être. + + Un hôte en tunnel rejoué depuis l'hôte revient dans le cache : sans + détournement, la destination d'origine d'une connexion locale est + l'écoute elle-même, et le tunnel s'y rappelle sans fin. + """ + + def test_get_et_head_sur_un_nom_se_rejouent(self): + self.assertEqual(menu.verdict_de_rejeu("GET", URL_A), "rejouable") + self.assertEqual(menu.verdict_de_rejeu("HEAD", URL_B), "rejouable") + + def test_un_post_ne_se_garde_jamais(self): + for methode in ("POST", "PUT", "DELETE"): + self.assertEqual(menu.verdict_de_rejeu(methode, URL_A), "jamais") + + def test_une_adresse_ip_en_https_est_un_tunnel(self): + self.assertEqual(menu.verdict_de_rejeu("GET", URL_IP), "tunnel") + + def test_une_adresse_ip_en_http_ou_localhost_nest_pas_rejouee(self): + for url in ( + "http://192.0.2.99/x", + "http://localhost/x", + "ftp://example.com/x", + ): + self.assertEqual(menu.verdict_de_rejeu("GET", url), "adresse", url) + + def test_les_exclusions_declarees_passent_en_tunnel(self): + exclus = menu.exclusions_declarees(str(RACINE)) + self.assertEqual(sorted(exclus), sorted(exclusions_du_go())) + self.assertGreater(len(exclus), 0) + for hote in exclus: + self.assertEqual( + menu.verdict_de_rejeu("GET", f"https://{hote}/v1/x", exclus), + "tunnel", + hote, + ) + + def test_une_exclusion_en_suffixe_couvre_le_domaine(self): + exclus = [".example.org"] + self.assertEqual( + menu.verdict_de_rejeu("GET", "https://a.example.org/x", exclus), + "tunnel", + ) + self.assertEqual( + menu.verdict_de_rejeu( + "GET", "https://example.org.invalid/x", exclus + ), + "rejouable", + ) + + def test_une_negociation_git_va_au_miroir(self): + url = "https://example.com/o/d.git/info/refs?service=git-upload-pack" + self.assertEqual(menu.verdict_de_rejeu("GET", url), "git") + + def test_les_chemins_git_suivent_le_go(self): + src = (RACINE / "script" / "qemu_cache" / "classify.go").read_text( + encoding="utf-8" + ) + bloc = re.search(r"var gitSmartPaths = \[\]string\{([^}]*)\}", src) + self.assertEqual( + tuple(re.findall(r'"([^"]+)"', bloc.group(1))), + menu.GIT_NEGOCIATION, + ) + + +class TestLaCommandeDeRejeu(unittest.TestCase): + """La connexion va à l'écoute locale ; le nom d'hôte, lui, ne change pas. + + C'est ce qui donne au rejeu la clé qu'une VM produirait. L'autorité est + APPROUVÉE, jamais contournée : « -k » ferait passer n'importe quoi pour le + cache. + """ + + CA = "/var/lib/essai/ca.crt" + + def cmd(self, methode, url): + return menu.rejeu_cmd(methode, url, "8898", "8899", self.CA) + + def test_http_passe_par_le_mandataire_du_cache(self): + argv = self.cmd("GET", URL_B) + self.assertEqual(argv[0], "curl") + self.assertIn("-x", argv) + self.assertEqual(argv[argv.index("-x") + 1], "http://127.0.0.1:8898") + self.assertNotIn("--connect-to", argv) + self.assertNotIn("-I", argv) + self.assertEqual(argv[-1], URL_B) + self.assertIn("--max-time", argv) + + def test_https_mene_la_connexion_a_lecoute_tls(self): + argv = self.cmd("GET", URL_A) + self.assertEqual( + argv[argv.index("--connect-to") + 1], "::127.0.0.1:8899" + ) + self.assertEqual(argv[argv.index("--cacert") + 1], self.CA) + for interdit in ("-k", "--insecure", "-L", "-x"): + self.assertNotIn(interdit, argv) + self.assertEqual(argv[-1], URL_A) + + def test_head_se_rejoue_en_head(self): + self.assertIn("-I", self.cmd("HEAD", URL_A)) + self.assertIn("-I", self.cmd("HEAD", URL_B)) + + def test_le_curlrc_de_loperateur_est_ecarte(self): + """curl n'honore « -q » qu'en tout premier argument. Ailleurs, un + « proxy » du ~/.curlrc enverrait le rejeu https hors du cache, et + un en-tête ou « compressed » changerait ce qui entre au magasin.""" + for methode, url in (("GET", URL_A), ("GET", URL_B), ("HEAD", URL_A)): + argv = self.cmd(methode, url) + self.assertEqual(argv[:2], ["curl", "-q"], argv) + + def test_la_redirection_se_lit_dans_le_dernier_bloc(self): + entetes = ( + "HTTP/1.1 100 Continue\r\n\r\n" + "HTTP/1.1 302 Found\r\nLocation: /depot/cible.tar.gz\r\n\r\n" + ) + self.assertEqual(menu.statut_et_cible(entetes, URL_A), (302, URL_A2)) + self.assertEqual( + menu.statut_et_cible("HTTP/2 200\r\n\r\n", URL_A), (200, "") + ) + self.assertEqual(menu.statut_et_cible("", URL_A), (None, "")) + + +# --------------------------------------------------------------------------- +# L'entrée 9 : refuser sous la coupure, montrer, confirmer, rejouer, vérifier +# --------------------------------------------------------------------------- + + +class Faux(M): + def __init__(self): + self.execute = self + self.executees = [] + + def exec_command_live(self, cmd, **_kw): + self.executees.append(cmd) + + +class TestLeComblement(SansSysteme): + def setUp(self): + super().setUp() + self.binaire = self.dossier / "binaire" + self.binaire.write_text("") + conf = self.dossier / "env" + conf.write_text( + "EL_SUBNET=192.0.2.0/24\nEL_HTTP_PORT=8898\nEL_TLS_PORT=8899\n" + f"EL_CACHE_DIR={self.dossier / 'casiers'}\nEL_EXCLUDE=\n" + ) + maintenant = time.time() + self.chemin = self.journal( + [ + ligne(maintenant - 300, "offline-miss", URL_A), + ligne(maintenant - 200, "offline-miss", URL_B), + ligne( + maintenant - 150, "offline-miss", URL_POST, methode="POST" + ), + ligne(maintenant - 100, "offline-miss", URL_IP), + ] + ) + self.coupe = False + self.guet = False + self.actif = True + self.rejoue = False + for cible, valeur in ( + ("CACHE_BIN", str(self.binaire)), + ("CACHE_CONF", str(conf)), + ): + p = mock.patch.object(menu, cible, valeur) + p.start() + self.addCleanup(p.stop) + for nom, fonction in ( + ("_cache_journal", staticmethod(lambda: self.chemin)), + ("_cache_actif", classmethod(lambda cls: self.actif)), + ("_cache_amont_coupe", classmethod(lambda cls: self.coupe)), + ("_cache_guet_actif", classmethod(lambda cls: self.guet)), + ): + p = mock.patch.object(M, nom, fonction) + p.start() + self.addCleanup(p.stop) + + def repondre(self, texte, kw): + if texte.endswith("--help"): + return 0, " -detient\n" + if texte.endswith("--detient"): + lignes = [] + for question in (kw.get("input") or "").splitlines(): + methode, url = question.split(" ", 1) + garde = self.rejoue and url in (URL_A2, URL_B) + verdict = "garde" if garde else "absent" + lignes.append(f"{verdict}\t200\t-\tvolatile\t{methode}\t{url}") + return 0, "\n".join(lignes) + "\n" + if texte.startswith("curl "): + self.rejoue = True + if texte.endswith(" " + URL_A): + return 0, f"HTTP/1.1 302 Found\r\nLocation: {URL_A2}\r\n\r\n" + return 0, "HTTP/1.1 200 OK\r\n\r\n" + return 0, "" + + def combler(self, confirmer=True): + sortie = io.StringIO() + faux = Faux() + with mock.patch( + "click.confirm", return_value=confirmer + ) as confirme, contextlib.redirect_stdout(sortie): + faux._cache_combler() + return sortie.getvalue(), confirme, faux + + def curls(self): + return [(c, kw) for c, kw in self.lancees if c.startswith("curl ")] + + def test_sous_la_coupure_rien_ne_part(self): + self.coupe = True + texte, confirme, faux = self.combler() + self.assertEqual( + self.curls(), [], "un rejeu est parti sous la coupure" + ) + confirme.assert_not_called() + self.assertIn(cache_offline.restore_cmd(), texte) + self.assertEqual(faux.executees, []) + + def test_sous_le_guet_rien_ne_part_et_le_geste_arrete_le_guet(self): + """Le guet tient la coupure jusqu'à la fin de la dernière + installation détachée. Le geste donné l'arrête ; retirer la table + seule ferait finir ces installations en ligne et laisserait le guet + tourner pour rien.""" + self.guet = True + texte, confirme, faux = self.combler() + self.assertEqual(self.curls(), [], "un rejeu est parti sous le guet") + confirme.assert_not_called() + self.assertIn(cache_offline.lever_maintenant_cmd(), texte) + self.assertNotIn(cache_offline.restore_cmd(), texte) + self.assertIn( + t("Lifting it now makes those installations finish online."), texte + ) + self.assertEqual(faux.executees, []) + + def test_une_coupure_illisible_fait_demander_non_par_defaut(self): + """sudo exige un mot de passe : la coupure ne se lit pas, et la + prendre pour absente ferait rejouer sous la coupure.""" + self.coupe = None + texte, confirme, _f = self.combler(confirmer=False) + self.assertEqual(self.curls(), []) + confirme.assert_called_once() + self.assertIs(confirme.call_args.kwargs.get("default"), False) + self.assertIn( + t( + "Cannot tell whether the upstream is cut: reading nft needs a" + " sudo password here." + ), + texte, + ) + self.assertEqual( + [c for c, _kw in self.lancees if c.endswith("--detient")], + [], + "le magasin a été interrogé avant la réponse", + ) + + def test_une_coupure_illisible_acceptee_rejoue(self): + self.coupe = None + _texte, confirme, _f = self.combler(confirmer=True) + self.assertEqual(confirme.call_count, 2) + self.assertNotEqual(self.curls(), []) + + def test_service_arrete_rien_ne_part(self): + self.actif = False + _texte, confirme, _f = self.combler() + self.assertEqual(self.curls(), []) + confirme.assert_not_called() + + def test_sans_binaire_rien_ne_part(self): + self.binaire.unlink() + _texte, confirme, _f = self.combler() + self.assertEqual(self.lancees, []) + confirme.assert_not_called() + + def test_refuser_la_confirmation_ne_lance_rien(self): + texte, confirme, _f = self.combler(confirmer=False) + confirme.assert_called_once() + self.assertEqual(self.curls(), []) + self.assertIn(URL_POST, texte, "le plan tait ce qui ne se rejoue pas") + + def test_le_rejeu_passe_par_le_cache_et_se_verifie(self): + with mock.patch.dict( + os.environ, {"https_proxy": "http://198.51.100.1:3128"} + ): + texte, _c, _f = self.combler() + curls = self.curls() + cibles = [c.rsplit(" ", 1)[-1] for c, _kw in curls] + # Le manque le plus récent d'abord ; une redirection est suivie + # aussitôt, avant l'adresse suivante. + self.assertEqual(cibles, [URL_B, URL_A, URL_A2]) + for c, kw in curls: + argv = c.split() + self.assertTrue( + ("--connect-to" in argv and "--cacert" in argv) + or "http://127.0.0.1:8898" in argv, + c, + ) + for interdit in ("-k", "--insecure", "-L"): + self.assertNotIn(interdit, argv) + self.assertNotIn( + "https_proxy", kw.get("env") or {}, "le mandataire hérité" + ) + # Le rejeu et sa vérification tournent sans privilège. Le seul sudo + # admis est la lecture non interactive du journal du service, qui + # nomme les refus de tunnel appris. + for c, _kw in self.lancees: + self.assertNotIn("nft", c) + if c.startswith("sudo"): + self.assertTrue(c.startswith("sudo -n journalctl "), c) + for c, _kw in curls: + self.assertNotIn("sudo", c) + self.assertNotIn(URL_POST, cibles) + self.assertNotIn(URL_IP, cibles) + self.assertIn(t("held"), texte) + self.assertIn(t("not held"), texte) + self.assertIn( + t("User-Agent or Accept may keep another answer than the VM's."), + texte, + ) + + +class TestLesMotifsPartagesParLesDeuxFormulaires(SansSysteme): + """Le texte de l'avertissement vit dans le socle commun aux deux + formulaires, qui l'affichent tel quel : une épreuve du texte vaut donc + pour les deux. + + Éprouver un verdict ne suffit pas : une fonction juste dont le résultat + n'atteint jamais l'écran laisse partir la VM sans un mot, et c'est + précisément ce que l'avertissement doit empêcher. + """ + + def motifs(self, **verdicts): + """Les phrases rendues, tous les verdicts truqués sauf ceux nommés.""" + from script.todo.deploy_form_lib import motifs_hors_ligne + + valeurs = { + "suites_absentes": [], + "composants_absents": [], + "manques_hors_ligne": [], + "paquets_absents": [], + "miroirs_absents": [], + } + valeurs.update(verdicts) + with contextlib.ExitStack() as pile: + for nom, valeur in valeurs.items(): + pile.enter_context( + mock.patch.object( + cache_offline, nom, lambda *a, _v=valeur, **k: _v + ) + ) + return motifs_hors_ligne( + [{"distro": "ubuntu", "version": "26.04"}] + ) + + def test_un_paquet_manquant_atteint_lecran(self): + motifs = self.motifs(paquets_absents=["qemu-guest-agent"]) + self.assertTrue(any("qemu-guest-agent" in m for m in motifs), motifs) + + def test_un_depot_sans_miroir_atteint_lecran(self): + motifs = self.motifs(miroirs_absents=["forge.example/g/p"]) + self.assertTrue(any("forge.example/g/p" in m for m in motifs), motifs) + self.assertTrue( + any(t("fill them from Cache › Git mirrors") in m for m in motifs), + "le geste qui lève le manque n'est pas donné", + ) + + def test_beaucoup_de_depots_ne_deroulent_pas_la_liste(self): + """Cinquante dépôts ne se lisent pas, et le geste est le même pour + tous : trois exemples, puis le compte.""" + motifs = self.motifs( + miroirs_absents=[f"forge.example/g/p{i}" for i in range(50)] + ) + joints = " ".join(motifs) + self.assertIn("(+47)", joints) + self.assertNotIn("p49", joints) + + def test_rien_ne_manque_rien_ne_sort(self): + """Un avertissement qui tombe quand rien ne manque s'apprend par + cœur, et c'est ainsi qu'on cesse de le lire.""" + self.assertEqual(self.motifs(), []) + + +class TestLesPaquetsQueLeCacheNaPas(SansSysteme): + """Le verdict par index ne descend jamais au FICHIER. + + Un cache qui détient l'index d'une suite passe pour complet alors + qu'aucun octet du paquet lui-même ne l'a traversé. Hors ligne, + l'installation échoue vingt minutes plus tard sur « Unable to locate + package », un message qui accuse le dépôt et jamais le cache. Le cas qui + le montre : un paquet posé par une unité DÉTACHÉE, dont l'échec ne + remonte nulle part. + """ + + VM = {"distro": "ubuntu", "version": "26.04", "arch": "amd64"} + URL = "http://m/pool/main/q/qemu/qemu-guest-agent_9.2_amd64.deb" + + def _journal(self, urls, issue="hit"): + return self.journal([{"url": u, "outcome": issue} for u in urls]) + + def test_un_paquet_en_reserve_ne_se_dit_pas(self): + chemin = self._journal([self.URL]) + self.assertEqual( + cache_offline.paquets_absents([self.VM], chemin=chemin), [] + ) + + def test_un_paquet_jamais_vu_est_nomme(self): + chemin = self._journal(["http://m/pool/main/a/a/autre_1_amd64.deb"]) + self.assertEqual( + cache_offline.paquets_absents([self.VM], chemin=chemin), + list(cache_offline.PAQUETS_HORS_SUIVI), + ) + + def test_un_manque_hors_ligne_ne_vaut_pas_une_reserve(self): + """« offline-miss » dit qu'on l'a CHERCHÉ sans le trouver : le + compter comme détenu ferait taire l'avertissement précisément quand + il a raison.""" + chemin = self._journal([self.URL], issue="offline-miss") + self.assertIn( + "qemu-guest-agent", + cache_offline.paquets_absents([self.VM], chemin=chemin), + ) + + def test_sans_vm_ou_sans_journal_le_verdict_se_tait(self): + """Accuser un cache qu'on ne peut pas interroger ferait cesser de + lire l'avertissement le jour où il compte.""" + chemin = self._journal([]) + self.assertEqual(cache_offline.paquets_absents([], chemin=chemin), []) + self.assertEqual( + cache_offline.paquets_absents( + [self.VM], chemin=str(self.dossier / "absent.jsonl") + ), + [], + ) + + +class TestLesMiroirsGitQuiManquent(SansSysteme): + """Une négociation git ne se garde pas : le cache tient un dépôt NU par + amont et le sert localement. Sans miroir, le clone échoue une fois le + réseau coupé, et aucun verdict ne l'annonçait — celui par index ne parle + que d'apt, et le journal des manques ne connaît que ce qu'une coupure a + déjà fait rater. + """ + + def _racine(self, presents=()): + racine = self.dossier / "git" + racine.mkdir(parents=True, exist_ok=True) + for nom in presents: + (racine / nom).mkdir(parents=True, exist_ok=True) + return str(racine) + + def test_un_depot_sans_miroir_est_nomme(self): + self.assertEqual( + cache_offline.miroirs_absents( + ["https://forge.example/groupe/projet.git"], self._racine() + ), + ["forge.example/groupe/projet"], + ) + + def test_un_depot_deja_en_miroir_se_tait(self): + racine = self._racine(["forge.example/groupe/projet.git"]) + self.assertEqual( + cache_offline.miroirs_absents( + ["https://forge.example/groupe/projet.git"], racine + ), + [], + ) + + def test_lhote_fait_partie_du_chemin(self): + """Deux forges peuvent servir « /odoo/odoo » : les confondre + donnerait à l'une le contenu de l'autre.""" + racine = self._racine(["forge-a.example/odoo/odoo.git"]) + self.assertEqual( + cache_offline.miroirs_absents( + ["https://forge-b.example/odoo/odoo"], racine + ), + ["forge-b.example/odoo/odoo"], + ) + + def test_une_racine_absente_ne_dit_rien(self): + """Un magasin absent n'est pas un miroir manquant, et deux causes + sous un seul message font cesser de lire.""" + self.assertEqual( + cache_offline.miroirs_absents( + ["https://forge.example/g/p"], str(self.dossier / "nulle") + ), + [], + ) + + +class TestLeMenuLitLaCoupureEtLeGuet(SansSysteme): + """Ce que le menu du cache croit de la coupure et du guet. + + Trois lectures sans privilège ou sans mot de passe, chacune réduite à + une marque que la commande écrit quand elle réussit : les messages de + nft, de sudo et de systemctl se traduisent, et une inclusion de texte + lit « inactive » comme « active ». + """ + + def setUp(self): + super().setUp() + self.sorties = {} + + def repondre(self, texte, kw): + for motif, sortie in self.sorties.items(): + if motif in texte: + return 0, sortie + return 1, "" + + def test_la_coupure_a_trois_etats(self): + posee = ( + "table inet x {\n meta skuid 900 tcp dport { 80, 443 } drop\n}\n" + ) + for sortie, attendu in ( + (f"{menu.NFT_LISIBLE}\n{posee}", True), + (f"{menu.NFT_LISIBLE}\nError: la table n'existe pas\n", False), + ("sudo: un mot de passe est nécessaire\n", None), + ("", None), + ): + self.sorties = {"nft list": sortie} + self.assertIs(M._cache_amont_coupe(), attendu, sortie) + for c, _kw in self.lancees: + self.assertNotRegex(c, r"sudo (?!-n )", "sudo peut demander") + + def test_le_prefixe_des_regles_a_trois_etats(self): + """Confondre « illisible » avec « aucune règle » fait annoncer un + détournement absent par un cache qui détourne pourtant, et envoie + réinstaller ce qui fonctionne déjà.""" + posee = ( + "table ip erplibre_qemu_cache {\n" + " chain prerouting {\n" + " ip saddr 192.168.122.0/24 tcp dport 80 dnat to :8898\n" + " }\n" + "}\n" + ) + for sortie, attendu in ( + (f"{menu.NFT_LISIBLE}\n{posee}", "192.168.122"), + (f"{menu.NFT_LISIBLE}\nError: la table n'existe pas\n", ""), + ("sudo: un mot de passe est nécessaire\n", None), + ("", None), + ): + self.sorties = {"nft list": sortie} + self.assertEqual(M._cache_prefixe_regles(), attendu, sortie) + for c, _kw in self.lancees: + self.assertNotRegex(c, r"sudo (?!-n )", "sudo peut demander") + + def test_le_diagnostic_ne_crie_pas_labsence_dune_regle_illisible(self): + """« Aucune règle » et « je n'ai pas pu lire » appellent deux gestes + opposés : le premier fait réinstaller, le second fait donner un mot + de passe. Les confondre envoie refaire ce qui fonctionne.""" + import contextlib + import io as _io + + class Muet(M): + @classmethod + def _cache_prefixe_regles(cls): + return None + + @classmethod + def _cache_prefixe_libvirt(cls): + return "192.168.122" + + tampon = _io.StringIO() + with contextlib.ExitStack() as pile: + pile.enter_context(mock.patch("os.path.isfile", return_value=True)) + pile.enter_context(contextlib.redirect_stdout(tampon)) + Muet()._cache_diagnostic() + texte = tampon.getvalue() + self.assertIn( + t( + "Cannot tell where the rules point: reading nft needs a" + " sudo password here." + ), + texte, + ) + self.assertNotIn(t("No redirection rule is posted"), texte) + + def test_le_guet_se_lit_sans_sudo(self): + self.sorties = {cache_offline.guet_actif_cmd(): menu.GUET_ACTIF} + self.assertTrue(M._cache_guet_actif()) + self.sorties = {} + self.assertFalse(M._cache_guet_actif()) + for c, _kw in self.lancees: + self.assertIn(cache_offline.guet_actif_cmd(), c) + self.assertNotIn("sudo", c) + + def test_un_service_inactif_nest_pas_actif(self): + for sortie, attendu in ( + ("active\n", True), + ("inactive\n", False), + ("failed\n", False), + ("", False), + ): + self.sorties = {"systemctl is-active": sortie} + self.assertIs(M._cache_actif(), attendu, sortie) + + def diagnostic(self, coupe, guet): + binaire = self.dossier / "binaire" + binaire.write_text("") + sortie = io.StringIO() + faux = Faux() + with mock.patch.object(menu, "CACHE_BIN", str(binaire)), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_amont_coupe", + classmethod(lambda cls: coupe), + ), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_guet_actif", + classmethod(lambda cls: guet), + ), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_par_machine", + return_value=[], + ), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_compte_issues", + return_value={}, + ), mock.patch( + "script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_bypass_lire", + return_value=[], + ), contextlib.redirect_stdout( + sortie + ): + faux._cache_diagnostic() + return sortie.getvalue() + + def test_sous_le_guet_le_diagnostic_donne_larret_du_guet(self): + """Donner le retrait de la table ferait finir en ligne les + installations qui tournent, et le guet resterait sans rien à + lever.""" + for coupe in (True, None): + texte = self.diagnostic(coupe, guet=True) + self.assertIn(cache_offline.lever_maintenant_cmd(), texte) + self.assertNotIn(cache_offline.restore_cmd(), texte) + self.assertIn( + t("held until its last installation ends (12 h at most)."), + texte, + ) + + def test_sans_guet_le_diagnostic_donne_le_retrait(self): + texte = self.diagnostic(True, guet=False) + self.assertIn(cache_offline.restore_cmd(), texte) + self.assertNotIn(cache_offline.lever_maintenant_cmd(), texte) + + def test_un_guet_sans_coupure_est_nomme(self): + texte = self.diagnostic(False, guet=True) + self.assertIn( + t("The lift watcher still runs, with no cut left to lift."), texte + ) + self.assertIn(cache_offline.lever_maintenant_cmd(), texte) + + def test_une_coupure_illisible_est_dite_sans_alarme(self): + texte = self.diagnostic(None, guet=False) + self.assertNotIn(cache_offline.restore_cmd(), texte) + self.assertNotIn( + t("Upstream CUT: the cache can pull nothing from the internet"), + texte, + ) + self.assertIn( + t( + "Cannot tell whether the upstream is cut: reading nft needs a" + " sudo password here." + ), + texte, + ) + + +# --------------------------------------------------------------------------- +# Remplir les miroirs sous le compte du service +# --------------------------------------------------------------------------- + + +class TestLeRemplissageDesMiroirs(SansSysteme): + """Un objet posé par root dans un miroir est interdit en écriture au + service : son rafraîchissement échoue, il le prend pour un amont muet, et + le miroir se fige sans rien dire.""" + + def unite_de_linstallateur(self): + src = INSTALLATEUR.read_text(encoding="utf-8") + unite = self.dossier / "unite.service" + unite.write_text( + "\n".join( + l for l in src.splitlines() if l.startswith("Environment=") + ) + ) + return str(unite) + + def test_lunite_donne_son_environnement_a_git(self): + env = menu.environnement_de_l_unite(self.unite_de_linstallateur()) + self.assertEqual( + {e.split("=", 1)[0] for e in env}, + {"HOME", "GIT_CONFIG_GLOBAL", "GIT_TERMINAL_PROMPT"}, + ) + + def test_la_commande_tourne_sous_le_compte_du_service(self): + cmd = menu.miroir_prefetch_cmd( + "/liste/miroirs.txt", ["HOME=/casiers", "GIT_TERMINAL_PROMPT=0"] + ) + self.assertTrue( + cmd.startswith(f"sudo -u {cache_offline.SERVICE_USER} env "), cmd + ) + self.assertIn("HOME=/casiers GIT_TERMINAL_PROMPT=0 ", cmd) + self.assertIn( + "--git-mirror-prefetch /dev/stdin < /liste/miroirs.txt", cmd + ) + self.assertNotIn(f"sudo {menu.CACHE_BIN}", cmd) + + def test_le_menu_lance_cette_commande(self): + maison = self.dossier / "maison" + vraie = os.path.expanduser + with mock.patch.object( + menu, "CACHE_UNITE", self.unite_de_linstallateur() + ), mock.patch( + "os.path.expanduser", + lambda p: str(maison) + p[1:] if p.startswith("~") else vraie(p), + ), mock.patch( + "click.confirm", return_value=True + ): + faux = Faux() + with contextlib.redirect_stdout(io.StringIO()): + faux._cache_miroir_remplir(["https://example.com/o/d.git"]) + (cmd,) = faux.executees + self.assertTrue( + cmd.startswith(f"sudo -u {cache_offline.SERVICE_USER} ") + ) + self.assertIn("GIT_CONFIG_GLOBAL=/dev/null", cmd) + fichier = maison / ".erplibre" / "miroirs_git.txt" + self.assertEqual(stat.S_IMODE(fichier.stat().st_mode), 0o644) + + def test_les_chemins_suivent_linstallateur(self): + src = INSTALLATEUR.read_text(encoding="utf-8") + self.assertEqual( + re.search(r'^UNIT="([^"]+)"', src, re.M).group(1), menu.CACHE_UNITE + ) + self.assertEqual(cache_offline.BINAIRE, menu.CACHE_BIN) + + +# --------------------------------------------------------------------------- +# F5 : prévenir avant la coupure, une fois, pour les deux motifs +# --------------------------------------------------------------------------- + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLAvertissementAvantLaCoupure(SansSysteme): + """Même idiome que la suite absente : un premier F5 prévient, un second + passe outre. Les deux motifs partagent UN avertissement — deux + confirmations d'affilée apprendraient à les enchaîner sans lire.""" + + MANQUE = { + "nom": "vm-essai-hl", + "age": 7200, + "runs": ["r"], + "manquants": [("GET", URL_A), ("GET", URL_B)], + "git": ["https://example.com/o/d.git"], + "jamais": [("POST", URL_POST), ("GET", URL_C)], + "selon_journal": False, + } + + def deployer(self, absentes, manques): + import asyncio + + from textual.widgets import Checkbox, SelectionList + + from script.todo.qemu_deploy_form import run_deploy_form + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + mod = todo._qemu_import_module() + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + with contextlib.redirect_stdout(io.StringIO()): + ctx = dict(todo._qemu_form_context(mod), cache_offert=True) + vu = {"notes": []} + + async def scenario(): + app = run_deploy_form(ctx, run_app=False) + async with app.run_test(size=(200, 70)) as pilote: + await pilote.pause() + liste = app.query_one("#f_catalog", SelectionList) + liste.select(liste.options[0].value) + app._recompute() + await pilote.pause() + app.query_one("#f_offline", Checkbox).value = True + await pilote.pause() + app.notify = lambda message, **kw: vu["notes"].append(message) + app.action_deploy() + vu["premier"] = app._result.get("spec") + app.action_deploy() + vu["second"] = app._result.get("spec") + + # Les cinq verdicts sont truqués, et non deux : cette épreuve isole + # ce qu'elle nomme. Un verdict laissé libre lit la machine qui + # exécute les tests — son magasin, ses miroirs — et le résultat + # change alors d'un poste à l'autre. + with mock.patch.object( + cache_offline, "suites_absentes", lambda vms: absentes + ), mock.patch.object( + cache_offline, "manques_hors_ligne", lambda vms: manques + ), mock.patch.object( + cache_offline, "composants_absents", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "paquets_absents", lambda *a, **k: [] + ), mock.patch.object( + cache_offline, "miroirs_absents", lambda *a, **k: [] + ): + asyncio.run(scenario()) + return vu + + def test_les_manques_bloquent_le_premier_f5(self): + vu = self.deployer([], [self.MANQUE]) + self.assertIsNone( + vu["premier"], "le déploiement est parti sans prévenir" + ) + self.assertIsNotNone(vu["second"], "un second F5 ne passe pas outre") + (note,) = vu["notes"] + self.assertIn("vm-essai-hl", note) + self.assertIn(f"{t('lacked at least')} 2 ", note) + self.assertIn("2 h", note) + self.assertIn(URL_A, note) + + def test_git_et_jamais_gardees_sont_nommes_pour_ce_quils_sont(self): + """Une négociation git se comble par le miroir, pas par un rejeu ; + et un GET que le cache ne garde pas n'est pas un POST.""" + vu = self.deployer([], [self.MANQUE]) + (note,) = vu["notes"] + self.assertIn( + "+1 " + + t( + "git repositories not mirrored: fill them from entry 5 of" + " the cache menu" + ), + note, + ) + self.assertIn( + "+2 " + t("requests the cache never keeps:") + " GET, POST", note + ) + # L'ancien libellé, qui rangeait tout sous « POST », n'a plus de + # clé : t() le rendrait tel quel, en anglais, et la sonde ne verrait + # jamais le français. Les deux langues sont donc cherchées en clair. + for ancien in ( + "POST requests are never kept", + "requêtes POST ne sont jamais gardées", + ): + self.assertNotIn(ancien, note) + + def test_sans_git_ni_jamais_rien_nest_ajoute(self): + vu = self.deployer([], [dict(self.MANQUE, git=[], jamais=[])]) + (note,) = vu["notes"] + self.assertNotIn(t("requests the cache never keeps:"), note) + self.assertNotIn( + t( + "git repositories not mirrored: fill them from entry 5 of" + " the cache menu" + ), + note, + ) + self.assertNotIn(" (+", note) + + def test_un_seul_avertissement_pour_les_deux(self): + vu = self.deployer([("ubuntu", "26.04")], [self.MANQUE]) + self.assertIsNone(vu["premier"]) + self.assertIsNotNone(vu["second"]) + (note,) = vu["notes"] + self.assertIn("ubuntu 26.04", note) + self.assertIn(URL_A, note) + + def test_le_journal_seul_est_dit(self): + vu = self.deployer([], [dict(self.MANQUE, selon_journal=True)]) + (note,) = vu["notes"] + self.assertIn( + t("according to the log: a purge can make it wrong"), note + ) + + def test_rien_a_dire_rien_ne_bloque(self): + vu = self.deployer([], []) + self.assertIsNotNone( + vu["premier"], "un avertissement sans motif apprend à passer outre" + ) + self.assertEqual(vu["notes"], []) + + def test_le_formulaire_ne_sait_toujours_pas_couper(self): + src = QEMU_FORM.read_text(encoding="utf-8") + for interdit in ("cut_cmd", "restore_cmd", "nft", "curl"): + self.assertNotIn(interdit, src, interdit) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_deploy_hote.py b/test/test_qemu_deploy_hote.py new file mode 100644 index 0000000..44ceb55 --- /dev/null +++ b/test/test_qemu_deploy_hote.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'une VM reçoit de l'hôte : son nom d'hôte et son fuseau. + +Deux réglages que cloud-init applique au premier démarrage, et qui échouent +tous les deux SANS arrêter le déploiement. La VM démarre, sshd répond, tout a +l'air d'aller — et l'on découvre après coup qu'elle porte le nom générique de +son image, ou qu'elle horodate en UTC pendant que le reste du dépôt est en +heure locale. + +Le nom d'hôte n'accepte ni souligné ni point d'exclamation, là où un nom de +domaine libvirt les tolère : les deux ne se ressemblent qu'en général, et un +nom de VM lisible peut donc être un nom d'hôte invalide. + +Le fuseau, lui, doit exister DANS L'INVITÉ. Un alias hérité peut vivre sur +l'hôte et manquer à la VM, plusieurs distributions récentes ayant relégué ces +alias à un paquet séparé. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.qemu.deploy_qemu import ( # noqa: E402 + amorcage_bios, + canonical_timezone, + hostname_valide, +) + + +class TestNomDHote(unittest.TestCase): + def test_le_souligne_devient_un_tiret(self): + """Le nom des VM du test long en porte : « el-cache-ubuntu_2404-… ».""" + self.assertEqual( + hostname_valide("el-cache-ubuntu_2404-erplibre_odoo_18-1"), + "el-cache-ubuntu-2404-erplibre-odoo-18-1", + ) + + def test_un_nom_deja_valide_ne_bouge_pas(self): + self.assertEqual(hostname_valide("erplibre-arch"), "erplibre-arch") + + def test_les_tirets_de_bord_tombent(self): + """Un nom d'hôte ne peut ni commencer ni finir par un tiret.""" + self.assertEqual(hostname_valide("_vm_"), "vm") + self.assertEqual(hostname_valide("--essai--"), "essai") + + def test_les_tirets_ne_sattroupent_pas(self): + self.assertEqual(hostname_valide("a__.__b"), "a-b") + + def test_un_nom_vide_a_un_repli(self): + """Rendre du vide ferait refuser le nom par l'invité, sans que rien + d'autre qu'un avertissement ne le dise.""" + for entree in ("", "___", "..."): + self.assertEqual(hostname_valide(entree), "vm") + + def test_le_nom_est_borne(self): + """Une étiquette de nom d'hôte tient en 63 octets.""" + self.assertEqual(len(hostname_valide("x" * 200)), 63) + + +class TestFuseau(unittest.TestCase): + def table(self, lignes): + f = tempfile.NamedTemporaryFile( + "w", suffix=".zi", delete=False, encoding="utf-8" + ) + f.write("\n".join(lignes) + "\n") + f.close() + self.addCleanup(lambda: Path(f.name).unlink(missing_ok=True)) + return f.name + + def test_un_alias_herite_est_traduit(self): + """Un fuseau que l'invité refuse fait marquer l'exécution de + cloud-init en erreur, et la VM reste en UTC.""" + table = self.table( + ["# commentaire", "L America/Toronto Canada/Eastern", "Z autre"] + ) + self.assertEqual( + canonical_timezone("Canada/Eastern", table), "America/Toronto" + ) + + def test_un_nom_canonique_ne_bouge_pas(self): + table = self.table(["L America/Toronto Canada/Eastern"]) + self.assertEqual( + canonical_timezone("America/Toronto", table), "America/Toronto" + ) + + def test_une_table_absente_rend_le_nom_tel_quel(self): + """Un fuseau non traduit vaut mieux qu'un déploiement refusé.""" + self.assertEqual( + canonical_timezone("Canada/Eastern", "/nexiste/pas.zi"), + "Canada/Eastern", + ) + + def test_le_vide_reste_vide(self): + self.assertEqual(canonical_timezone("", "/nexiste/pas.zi"), "") + + def test_seules_les_lignes_de_lien_comptent(self): + """« Z » ouvre une zone, « R » une règle : les confondre traduirait un + fuseau en n'importe quoi.""" + table = self.table( + ["Z Canada/Eastern -5:00 Canada E%sT", "R Canada 1974 ma"] + ) + self.assertEqual( + canonical_timezone("Canada/Eastern", table), "Canada/Eastern" + ) + + +class TestAmorcage(unittest.TestCase): + """Quelle image démarre en UEFI, laquelle exige le BIOS. + + L'échec est MUET : le micrologiciel charge et démarre le chargeur, puis se + fige sans écrire un octet. Vu du déploiement, il ne reste qu'une VM « en + cours d'exécution » sans console et sans bail DHCP, et la cause est à + chercher pendant une heure. D'où une table plutôt qu'un diagnostic à + refaire. + """ + + def test_une_distribution_qui_lexige_part_en_bios(self): + self.assertTrue(amorcage_bios("fedora", False)) + + def test_les_autres_gardent_uefi(self): + """UEFI reste le défaut : les images cloud récentes n'embarquent plus + le chargeur BIOS et partent en boucle sous SeaBIOS.""" + for d in ( + "debian", + "ubuntu", + "arch", + "rocky", + "almalinux", + "opensuse", + ): + self.assertFalse(amorcage_bios(d, False), d) + + def test_la_demande_explicite_lemporte(self): + """« --bios » sert aussi quand OVMF manque de la machine hôte.""" + for d in ("debian", "fedora", "inconnue"): + self.assertTrue(amorcage_bios(d, True), d) + + def test_une_distribution_inconnue_garde_le_defaut(self): + self.assertFalse(amorcage_bios("inconnue", False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_deploy_monitor.py b/test/test_qemu_deploy_monitor.py index 9ea30b8..1ae2c66 100644 --- a/test/test_qemu_deploy_monitor.py +++ b/test/test_qemu_deploy_monitor.py @@ -128,6 +128,16 @@ class TestLaDecisionDuDeploiement(unittest.TestCase): ) todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet") todo._qemu_resolve_ips = lambda names, labels=None: {} + # Même en ligne, le déploiement relit l'état de la coupure : un hôte + # simulé sans guet et un verrou introuvable tiennent la machine de + # test hors du jeu. + import tempfile + from pathlib import Path + + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + todo._qemu_shell = lambda cmd, timeout=60: 1 + todo._qemu_verrou_hors_ligne_chemin = lambda: Path(tmp.name) / "absent" base = { "vms": [], "existing": ["vm-a"], diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 2d9ba76..d9b96ba 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -13,6 +13,7 @@ import pathlib import subprocess import sys import unittest +from unittest import mock sys.argv = ["todo.py"] from script.todo.todo import TODO # noqa: E402 @@ -1328,6 +1329,333 @@ class TestGnomeSiteExtensions(unittest.TestCase): l'activation ne peut rien ecrire dans dconf.""" self.assertIn("dbus-run-session", self.block) + def _lancer(self, **env_en_plus): + """Le bloc, pour de vrai, sous « set -e », dans un PATH où + gnome-shell, curl, mktemp et gnome-extensions sont faux : ni + réseau, ni session, ni fichier hors du répertoire du test.""" + import os + import shutil + import tempfile + + with mock.patch("script.todo.qemu_install.t", lambda k: k): + bloc = TODO.__new__(TODO)._qemu_gnome_ext_remote_cmd() + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + racine = pathlib.Path(tmp.name) + self.racine = racine + faux = racine / "bin" + faux.mkdir() + for outil in ("sh", "bash", "awk", "cut", "rm"): + os.symlink(shutil.which(outil), faux / outil) + corps = { + "gnome-shell": 'echo "GNOME Shell 50.1"\n', + "mktemp": 'if [ -n "$MKTEMP_ECHEC" ]; then exit 1; fi\n' + 'f="$TMPDIR/gext.$$"; : > "$f"; echo "$f"\n', + "curl": 'echo "$*" >> "$HOME/curl.trace"\n' + 'if [ -n "$ECHEC" ]; then exit 22; fi\n' + 'while [ "$#" -gt 0 ]; do [ "$1" = -o ] && dest="$2"; shift;' + ' done\n: > "$dest"\n', + "gnome-extensions": '[ "$1" = install ] && exit "${GX_RC:-0}"\n' + "exit 0\n", + } + for nom, texte in corps.items(): + chemin = faux / nom + chemin.write_text("#!/bin/sh\n" + texte, encoding="utf-8") + chemin.chmod(0o755) + env = { + "PATH": str(faux), + "HOME": str(racine), + "TMPDIR": str(racine), + **env_en_plus, + } + fini = subprocess.run( + [str(faux / "bash"), "-c", "set -e\n" + bloc + "\necho FIN"], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(0, fini.returncode, fini.stderr[-400:]) + self.assertIn("FIN", fini.stdout) + return fini.stdout + + def test_a_failed_download_does_not_blame_gnome(self): + """Le site sert une archive même à un GNOME qu'il ne connaît pas : + un téléchargement raté ne dit rien de la version.""" + sortie = self._lancer(ECHEC="1") + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("⚠ download impossible (network or cache):"), + ) + self.assertNotIn("not available for this GNOME", sortie) + + def test_a_failed_mktemp_skips_like_a_failed_download(self): + """Sans nom tiré par mktemp, rien n'est téléchargé : un nom fixe dans + /var/tmp, ouvert à tous, pourrait y être posé d'avance.""" + sortie = self._lancer(MKTEMP_ECHEC="1") + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("⚠ download impossible (network or cache):"), + ) + self.assertNotIn("installed and enabled:", sortie) + self.assertFalse( + (self.racine / "curl.trace").exists(), "curl lancé sans fichier" + ) + + def test_no_fixed_path_in_the_shared_tmp(self): + """/var/tmp n'apparaît que comme répertoire donné à mktemp, et le + bloc reste valide sous une traduction à apostrophe impaire.""" + import re + + piege = "l'extension n'a pas « fini » aujourd'hui" + with mock.patch("script.todo.qemu_install.t", lambda k: piege): + bloc = TODO.__new__(TODO)._qemu_gnome_ext_remote_cmd() + self.assertEqual( + {"/var/tmp"}, set(re.findall(r"/var/tmp[^\s;\"')|]*", bloc)) + ) + self.assertIn("mktemp -p /var/tmp gext-XXXX.zip", bloc) + res = subprocess.run( + ["bash", "-n"], input=bloc, text=True, capture_output=True + ) + self.assertEqual(0, res.returncode, res.stderr[:400]) + + def test_a_refused_install_names_the_gnome_version(self): + sortie = self._lancer(GX_RC="1") + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("not available for this GNOME, skipped:"), + ) + self.assertIn("(GNOME 50)", sortie) + self.assertNotIn("download impossible", sortie) + + def test_log_out_is_asked_only_when_something_was_installed(self): + """Se reconnecter pour charger des extensions jamais posées est + une consigne qui ment.""" + for echec in ({"ECHEC": "1"}, {"GX_RC": "1"}): + with self.subTest(echec=echec): + sortie = self._lancer(**echec) + self.assertNotIn("log out and back in", sortie) + sortie = self._lancer() + self.assertEqual( + len(TODO._QEMU_GNOME_EXT_UUIDS), + sortie.count("installed and enabled:"), + ) + self.assertEqual(1, sortie.count("log out and back in to load them")) + + def test_a_translated_apostrophe_keeps_the_block_valid(self): + """Une apostrophe mal placée casse la commande distante ENTIÈRE. On + remplace la traduction — « set_lang » la persisterait. + + Un nombre IMPAIR d'apostrophes : entre apostrophes, un nombre pair + se referme de lui-même, et « bash -n » ne verrait rien.""" + piege = "l'extension n'a pas « fini » aujourd'hui" + with mock.patch("script.todo.qemu_install.t", lambda k: piege): + bloc = TODO.__new__(TODO)._qemu_gnome_ext_remote_cmd() + self.assertIn(piege, bloc) + res = subprocess.run( + ["bash", "-n"], input=bloc, text=True, capture_output=True + ) + self.assertEqual(0, res.returncode, res.stderr[:400]) + + +class TestLeServiceDeLAgentInvite(unittest.TestCase): + """Le service détaché qui pose qemu-guest-agent, lancé par cloud-init. + + Son script essaie un gestionnaire de paquets après l'autre. Sans + accolades autour de chaque branche, une pose réussie enchaîne sur le + gestionnaire suivant, absent, et le service finit en échec — 127 sous + dash — alors que l'agent est posé. Le script est extrait de la vraie + configuration cloud-init, lue en YAML, et tourne dans un PATH où seul le + gestionnaire choisi existe, faux : aucun paquet n'est posé, aucun + service lancé. + """ + + def _script(self): + import shlex + + import yaml + + from script.qemu import deploy_qemu + + args = deploy_qemu.build_parser().parse_args( + ["--distro", "ubuntu", "--hostname", "vm"] + ) + doc = yaml.safe_load(deploy_qemu.build_cloud_config(args, None, [])) + ligne = next( + c + for c in doc["runcmd"] + if isinstance(c, str) and "--unit=erplibre-qga" in c + ) + mots = shlex.split(ligne) + # « systemd-run … /bin/sh -c '