Merge branch 'migration-todo-vm-anonymisation'
[REF] déploiement : par VM, l'anonymisation, et le redémarrage Proxmox Douze commits pour finir. La branche, le profil et le type se choisissent désormais par VM : ils étaient globaux, ce qui obligeait à tout basculer pour en déployer une seule autrement. L'anonymisation d'une copie arrive sans IA et sans rien casser — c'est la contrainte qui a dicté la forme. Une copie de production sert à reproduire un défaut, donc les identifiants doivent rester cohérents entre les tables même une fois les noms remplacés. Proxmox VE n'existe qu'après un redémarrage, et install_proxmox.sh s'arrête avant : lancé par ssh, un reboot couperait sa session. Le redémarrage revient donc à l'enveloppe de lancement, qui survit à celui de la VM, et le ✅ ne s'écrit qu'après vérification du noyau. Le garde du pont NAT part avec, là où la conséquence est plutôt qu'à la confirmation de l'hôte. Le reste ferme des portes trouvées ouvertes, et le journal des modifications rattrape les 67 commits qu'il ignorait. --- EN --- Twelve commits to finish. Branch, profile and type are now chosen per VM: they were global, which meant switching everything to deploy a single one differently. Anonymising a copy arrives without AI and without breaking anything — that constraint dictated the shape. A production copy exists to reproduce a defect, so identifiers must stay consistent across tables even once the names are replaced. Proxmox VE only exists after a reboot, and install_proxmox.sh stops before it: launched over ssh, a reboot would cut its session. The reboot therefore moves to the launching wrapper, which survives the VM's, and the ✅ is written only after the kernel is verified. The NAT-bridge guard moves with it, to where the consequence is rather than to host confirmation. The rest closes doors found open, and the changelog catches up on the 67 commits it had ignored. Assisted-by: Claude Opus 5
This commit is contained in:
commit
bb9e640504
24 changed files with 2863 additions and 320 deletions
|
|
@ -112,6 +112,13 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
- virt-viewer opens a VM screen from the menu
|
||||
- The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent
|
||||
- A VM greets its SSH login with the distribution's own commands
|
||||
- Proxmox VE as a deployment target: deploy a VM on a remote Proxmox host from a screen that recaps in the terminal before creating, creates the missing bridge, follows the VM and changes its state, shows its Odoo column and the web link, and deletes it from the host. Every remote VM now comes with its connection guide, which was missing from the start
|
||||
- Repairs for what an Odoo bump leaves behind: website copies that no longer render — predicted BEFORE the bump rather than found as a 500 after it — the indexes Odoo 17 creates twice, and the settings no event restores
|
||||
- Analyses that read a database rather than count it: which module depends on which, on screen; what is installed, in progress and applied; the state of an instance read for the use made of it; and the examination of a database that is not local
|
||||
- Anonymising a copy without AI: a production copy exists to reproduce a defect, so identifiers stay consistent across tables once the names are replaced
|
||||
- Per-VM statistics: writes, RAM and disk
|
||||
- Choosing the database at startup without ever blocking on the choice
|
||||
- The reboot is part of installing Proxmox VE: install_proxmox.sh lays down the kernel and stops, rightly so, because launched over ssh a reboot would cut its session and make the install look failed. The reboot now belongs to the launching wrapper, which runs on OUR machine and survives the VM's — install, reboot, wait for `uname -r` to carry the Proxmox kernel, then verify. The ✅ is written only after that, so it means "usable hypervisor"
|
||||
|
||||
<!-- [fr] -->
|
||||
|
||||
|
|
@ -188,6 +195,29 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
- virt-viewer ouvre l'écran d'une VM depuis le menu
|
||||
- Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet
|
||||
- Une VM accueille sa connexion SSH avec les commandes propres à sa distribution
|
||||
- Proxmox VE comme cible de déploiement : déployer une VM sur un hôte Proxmox distant depuis un écran qui récapitule dans le terminal avant de créer, crée le pont manquant, suit la VM et change son état, montre sa colonne Odoo et le lien web, et la supprime depuis l'hôte. Toute VM distante vient désormais avec son guide de connexion, qui manquait depuis le début
|
||||
- Des réparations pour ce qu'un palier Odoo laisse derrière : les copies de site qui ne savent plus se rendre — prédites AVANT le palier plutôt que découvertes en 500 après — les index qu'Odoo 17 crée en double, et les réglages qu'aucun événement ne remet
|
||||
- Des analyses qui lisent une base plutôt que de la compter : qui dépend de qui, à l'écran ; ce qui est installé, en cours et appliqué ; l'état d'une instance lu pour l'usage qu'on en fait ; et l'auscultation d'une base qui n'est pas ici
|
||||
- L'anonymisation d'une copie sans IA : une copie de production sert à reproduire un défaut, les identifiants restent donc cohérents entre les tables une fois les noms remplacés
|
||||
- Les statistiques de chaque VM : écriture, RAM, disque
|
||||
- Le choix de la base au démarrage, sans jamais bloquer sur ce choix
|
||||
- Le redémarrage fait partie de l'installation de Proxmox VE : install_proxmox.sh pose le noyau puis s'arrête, à raison, car lancé par ssh un reboot couperait sa session et ferait passer l'installation pour un échec. Le redémarrage revient à l'enveloppe de lancement, qui tourne sur NOTRE machine et survit à celle de la VM — installation, reboot, attente que « uname -r » porte le noyau Proxmox, puis vérification. Le ✅ ne s'écrit qu'après, et veut donc dire « hyperviseur utilisable »
|
||||
|
||||
<!-- [en] -->
|
||||
## Changed
|
||||
<!-- [fr] -->
|
||||
## Modifié
|
||||
<!-- [en] -->
|
||||
|
||||
- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes
|
||||
- Branch, profile and type are chosen per VM. They were global, which meant switching everything to deploy a single machine differently
|
||||
- One shared base describes the guest system, where each form used to describe it again
|
||||
|
||||
<!-- [fr] -->
|
||||
|
||||
- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement
|
||||
- La branche, le profil et le type se choisissent par VM. Ils étaient globaux, ce qui obligeait à tout basculer pour déployer une seule machine autrement
|
||||
- Un socle commun décrit le système invité, là où chaque formulaire le redécrivait
|
||||
|
||||
<!-- [en] -->
|
||||
## Fixed
|
||||
|
|
@ -198,11 +228,29 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
|
||||
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
|
||||
|
||||
- The 13-to-18 migration rested on assumptions: a percent-encoded page anchor the parser could not read, web_responsive that does not survive the bump to 18, a failed OpenUpgrade that passed for done, a rebuilt clone that kept the old one's preparation, and one faulty module taking the whole uninstall batch down
|
||||
- Proxmox aimed at the wrong machine: the install went to the host instead of the VM, the disk it reported was the host's, and four screens spoke of a local machine while driving a remote one. The jump host is now the only route to a VM — aiming directly worked only while the VM had a routable address. Six further defects came from an audit rather than from use
|
||||
- One name per `~/.ssh/config` entry, and the old one leaves with the convention that replaced it
|
||||
- Monitoring no longer bins a VM before being sure, and deleting from a reopened monitor checks the VM's identity first
|
||||
- sshfs announced a mount that had not happened
|
||||
- Odoo 15 declares xlsxwriter, which report_xlsx has always needed
|
||||
- The db_restore master-password probe validated nothing
|
||||
- An analysis looked for the price list's external identifier rather than the list itself, and reported fields that had never held data
|
||||
- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation
|
||||
<!-- [fr] -->
|
||||
|
||||
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
|
||||
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
|
||||
|
||||
- La migration des paliers 13 à 18 tenait sur des suppositions : une ancre de page encodée en pourcent que l'analyseur ne lisait pas, web_responsive qui ne survit pas au passage en 18, un OpenUpgrade en échec qui passait pour fait, un clone rebâti qui gardait la préparation de l'ancien, et un module fautif qui emportait tout le lot de désinstallation
|
||||
- Proxmox visait la mauvaise machine : l'installation partait sur l'hôte plutôt que sur la VM, le disque annoncé était celui de l'hôte, et quatre écrans parlaient d'une machine locale alors qu'ils pilotaient une machine distante. Le rebond est désormais le seul chemin vers une VM — viser directement ne marchait que tant que la VM avait une adresse routable. Six autres défauts sont venus d'un audit, pas de l'usage
|
||||
- Un seul nom par entrée `~/.ssh/config`, et l'ancienne s'en va avec la convention qui l'a remplacée
|
||||
- Le suivi ne met plus une VM à la poubelle avant d'en être sûr, et effacer depuis un suivi rouvert vérifie d'abord l'identité de la VM
|
||||
- sshfs annonçait un montage qui n'avait pas eu lieu
|
||||
- Odoo 15 déclare xlsxwriter, dont report_xlsx a toujours eu besoin
|
||||
- La sonde du mot de passe maître de db_restore ne validait rien
|
||||
- Une analyse cherchait l'identifiant externe de la liste de prix plutôt que la liste, et signalait des champs qui n'avaient jamais porté de donnée
|
||||
- Le pont NAT s'écrivait avant de savoir si le NAT existe. Six lignes d'iptables et « code de retour 1 » arrivaient après que la strophe soit déjà posée dans /etc/network/interfaces, et rien dans ce bruit ne disait qu'il fallait redémarrer : l'hôte tournait le noyau cloud de Debian, dépouillé de netfilter. C'est notre propre install_proxmox.sh qui produit cet état, donc une Proxmox imbriquée fraîchement installée y est TOUJOURS — le garde va désormais là où la conséquence est, et non à la confirmation de l'hôte
|
||||
<!-- [en] -->
|
||||
## Removed
|
||||
<!-- [fr] -->
|
||||
|
|
|
|||
|
|
@ -88,12 +88,34 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
- virt-viewer ouvre l'écran d'une VM depuis le menu
|
||||
- Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet
|
||||
- Une VM accueille sa connexion SSH avec les commandes propres à sa distribution
|
||||
- Proxmox VE comme cible de déploiement : déployer une VM sur un hôte Proxmox distant depuis un écran qui récapitule dans le terminal avant de créer, crée le pont manquant, suit la VM et change son état, montre sa colonne Odoo et le lien web, et la supprime depuis l'hôte. Toute VM distante vient désormais avec son guide de connexion, qui manquait depuis le début
|
||||
- Des réparations pour ce qu'un palier Odoo laisse derrière : les copies de site qui ne savent plus se rendre — prédites AVANT le palier plutôt que découvertes en 500 après — les index qu'Odoo 17 crée en double, et les réglages qu'aucun événement ne remet
|
||||
- Des analyses qui lisent une base plutôt que de la compter : qui dépend de qui, à l'écran ; ce qui est installé, en cours et appliqué ; l'état d'une instance lu pour l'usage qu'on en fait ; et l'auscultation d'une base qui n'est pas ici
|
||||
- L'anonymisation d'une copie sans IA : une copie de production sert à reproduire un défaut, les identifiants restent donc cohérents entre les tables une fois les noms remplacés
|
||||
- Les statistiques de chaque VM : écriture, RAM, disque
|
||||
- Le choix de la base au démarrage, sans jamais bloquer sur ce choix
|
||||
- Le redémarrage fait partie de l'installation de Proxmox VE : install_proxmox.sh pose le noyau puis s'arrête, à raison, car lancé par ssh un reboot couperait sa session et ferait passer l'installation pour un échec. Le redémarrage revient à l'enveloppe de lancement, qui tourne sur NOTRE machine et survit à celle de la VM — installation, reboot, attente que « uname -r » porte le noyau Proxmox, puis vérification. Le ✅ ne s'écrit qu'après, et veut donc dire « hyperviseur utilisable »
|
||||
|
||||
## Modifié
|
||||
|
||||
- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement
|
||||
- La branche, le profil et le type se choisissent par VM. Ils étaient globaux, ce qui obligeait à tout basculer pour déployer une seule machine autrement
|
||||
- Un socle commun décrit le système invité, là où chaque formulaire le redécrivait
|
||||
|
||||
## Corrigé
|
||||
|
||||
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
|
||||
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
|
||||
|
||||
- La migration des paliers 13 à 18 tenait sur des suppositions : une ancre de page encodée en pourcent que l'analyseur ne lisait pas, web_responsive qui ne survit pas au passage en 18, un OpenUpgrade en échec qui passait pour fait, un clone rebâti qui gardait la préparation de l'ancien, et un module fautif qui emportait tout le lot de désinstallation
|
||||
- Proxmox visait la mauvaise machine : l'installation partait sur l'hôte plutôt que sur la VM, le disque annoncé était celui de l'hôte, et quatre écrans parlaient d'une machine locale alors qu'ils pilotaient une machine distante. Le rebond est désormais le seul chemin vers une VM — viser directement ne marchait que tant que la VM avait une adresse routable. Six autres défauts sont venus d'un audit, pas de l'usage
|
||||
- Un seul nom par entrée `~/.ssh/config`, et l'ancienne s'en va avec la convention qui l'a remplacée
|
||||
- Le suivi ne met plus une VM à la poubelle avant d'en être sûr, et effacer depuis un suivi rouvert vérifie d'abord l'identité de la VM
|
||||
- sshfs annonçait un montage qui n'avait pas eu lieu
|
||||
- Odoo 15 déclare xlsxwriter, dont report_xlsx a toujours eu besoin
|
||||
- La sonde du mot de passe maître de db_restore ne validait rien
|
||||
- Une analyse cherchait l'identifiant externe de la liste de prix plutôt que la liste, et signalait des champs qui n'avaient jamais porté de donnée
|
||||
- Le pont NAT s'écrivait avant de savoir si le NAT existe. Six lignes d'iptables et « code de retour 1 » arrivaient après que la strophe soit déjà posée dans /etc/network/interfaces, et rien dans ce bruit ne disait qu'il fallait redémarrer : l'hôte tournait le noyau cloud de Debian, dépouillé de netfilter. C'est notre propre install_proxmox.sh qui produit cet état, donc une Proxmox imbriquée fraîchement installée y est TOUJOURS — le garde va désormais là où la conséquence est, et non à la confirmation de l'hôte
|
||||
## Retiré
|
||||
|
||||
- Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x
|
||||
|
|
|
|||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -86,12 +86,34 @@ Recreating the virtual environment, use installation guide from tool `make`.
|
|||
- virt-viewer opens a VM screen from the menu
|
||||
- The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent
|
||||
- A VM greets its SSH login with the distribution's own commands
|
||||
- Proxmox VE as a deployment target: deploy a VM on a remote Proxmox host from a screen that recaps in the terminal before creating, creates the missing bridge, follows the VM and changes its state, shows its Odoo column and the web link, and deletes it from the host. Every remote VM now comes with its connection guide, which was missing from the start
|
||||
- Repairs for what an Odoo bump leaves behind: website copies that no longer render — predicted BEFORE the bump rather than found as a 500 after it — the indexes Odoo 17 creates twice, and the settings no event restores
|
||||
- Analyses that read a database rather than count it: which module depends on which, on screen; what is installed, in progress and applied; the state of an instance read for the use made of it; and the examination of a database that is not local
|
||||
- Anonymising a copy without AI: a production copy exists to reproduce a defect, so identifiers stay consistent across tables once the names are replaced
|
||||
- Per-VM statistics: writes, RAM and disk
|
||||
- Choosing the database at startup without ever blocking on the choice
|
||||
- The reboot is part of installing Proxmox VE: install_proxmox.sh lays down the kernel and stops, rightly so, because launched over ssh a reboot would cut its session and make the install look failed. The reboot now belongs to the launching wrapper, which runs on OUR machine and survives the VM's — install, reboot, wait for `uname -r` to carry the Proxmox kernel, then verify. The ✅ is written only after that, so it means "usable hypervisor"
|
||||
|
||||
## Changed
|
||||
|
||||
- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes
|
||||
- Branch, profile and type are chosen per VM. They were global, which meant switching everything to deploy a single machine differently
|
||||
- One shared base describes the guest system, where each form used to describe it again
|
||||
|
||||
## Fixed
|
||||
|
||||
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
|
||||
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
|
||||
|
||||
- The 13-to-18 migration rested on assumptions: a percent-encoded page anchor the parser could not read, web_responsive that does not survive the bump to 18, a failed OpenUpgrade that passed for done, a rebuilt clone that kept the old one's preparation, and one faulty module taking the whole uninstall batch down
|
||||
- Proxmox aimed at the wrong machine: the install went to the host instead of the VM, the disk it reported was the host's, and four screens spoke of a local machine while driving a remote one. The jump host is now the only route to a VM — aiming directly worked only while the VM had a routable address. Six further defects came from an audit rather than from use
|
||||
- One name per `~/.ssh/config` entry, and the old one leaves with the convention that replaced it
|
||||
- Monitoring no longer bins a VM before being sure, and deleting from a reopened monitor checks the VM's identity first
|
||||
- sshfs announced a mount that had not happened
|
||||
- Odoo 15 declares xlsxwriter, which report_xlsx has always needed
|
||||
- The db_restore master-password probe validated nothing
|
||||
- An analysis looked for the price list's external identifier rather than the list itself, and reported fields that had never held data
|
||||
- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation
|
||||
## Removed
|
||||
|
||||
- Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Color_Off='\033[0m' # Text Reset
|
|||
# This script will remove mail configuration, remove backup configuration, and force admin user to test/test
|
||||
echo "Update prod to dev on BD '$1'"
|
||||
|
||||
./script/addons/install_addons_dev.sh "$1" user_test,disable_mail_server,disable_auto_backup
|
||||
./script/addons/install_addons_dev.sh "$1" user_test,disable_mail_server,disable_auto_backup,disable_payment_provider
|
||||
|
||||
retVal=$?
|
||||
if [[ $retVal -ne 0 ]]; then
|
||||
|
|
@ -15,7 +15,7 @@ fi
|
|||
|
||||
echo "Update trace of prod to dev on BD '$1'"
|
||||
|
||||
./script/addons/uninstall_addons.sh "$1" user_test,disable_mail_server,disable_auto_backup
|
||||
./script/addons/uninstall_addons.sh "$1" user_test,disable_mail_server,disable_auto_backup,disable_payment_provider
|
||||
|
||||
retVal=$?
|
||||
if [[ $retVal -ne 0 ]]; then
|
||||
|
|
|
|||
550
script/analyse/anonymize.py
Executable file
550
script/analyse/anonymize.py
Executable file
|
|
@ -0,0 +1,550 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Remplacer les données d'une copie par des données sans intérêt.
|
||||
|
||||
Aucune IA, aucun appel réseau : des mots pris dans une liste et des
|
||||
nombres tirés au hasard, écrits par des UPDATE SQL. Ce qui rend la chose
|
||||
délicate n'est pas le remplacement, c'est de savoir CE QU'ON N'A PAS LE
|
||||
DROIT DE TOUCHER.
|
||||
|
||||
Quatre pièges, tous mesurés sur une base réelle
|
||||
-----------------------------------------------
|
||||
1. « Tous les champs string » n'existe pas. 505 champs `selection` sont
|
||||
stockés en varchar : `res.partner.lang`, `sale.order.invoice_status`.
|
||||
Y écrire un mot au hasard casse l'ORM, pas la confidentialité. On ne
|
||||
se fie donc jamais au type PostgreSQL seul, mais au `ttype` que
|
||||
`ir_model_fields` déclare.
|
||||
2. 2693 champs `many2one` sont des ENTIERS. Les tirer au hasard
|
||||
mélangerait toutes les relations de la base. Les nombres qu'on touche
|
||||
sont ceux qu'Odoo appelle integer, float ou monetary — jamais une
|
||||
relation.
|
||||
3. 194 champs texte sont en `jsonb` depuis Odoo 17, un objet par langue.
|
||||
Écrire une chaîne par-dessus détruit la colonne ; on reconstruit
|
||||
l'objet, clé par clé. C'est le piège qui a déjà coûté un /contact
|
||||
réparé en anglais et resté cassé en français.
|
||||
4. 301 contraintes d'unicité. Deux lignes qui reçoivent le même mot font
|
||||
échouer tout l'UPDATE. Sur une colonne unique, l'identifiant est
|
||||
collé au mot.
|
||||
|
||||
Ce qu'on ne touche jamais
|
||||
-------------------------
|
||||
Les modèles `ir.*` — vues, champs, xmlid : la base ne s'ouvrirait plus.
|
||||
Les langues, devises et pays : ce ne sont pas des données personnelles,
|
||||
et les casser casse les adresses et les montants. Cette liste est un
|
||||
PLANCHER : aucune liste blanche ne la lève.
|
||||
|
||||
`res.users.login` et le mot de passe restent en place par défaut. On
|
||||
anonymise pour POUVOIR partager une copie utilisable ; personne ne
|
||||
pourrait plus s'y connecter. `--include-logins` pour l'autre choix.
|
||||
|
||||
Rien n'est écrit sans `--apply` ET `--confirm <nom de la base>`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
)
|
||||
|
||||
try:
|
||||
from script.todo.todo_i18n import t
|
||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||
|
||||
def t(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
from script.analyse import lib_analyse # noqa: E402
|
||||
|
||||
TYPES_TEXTE = ("char", "text", "html")
|
||||
TYPES_NOMBRE = ("integer", "float", "monetary")
|
||||
|
||||
# Le plancher : aucune liste blanche ne le lève.
|
||||
PREFIXES_INTERDITS = ("ir.",)
|
||||
MODELES_INTERDITS = frozenset(
|
||||
{
|
||||
"res.groups",
|
||||
"res.lang",
|
||||
"res.currency",
|
||||
"res.currency.rate",
|
||||
"res.country",
|
||||
"res.country.state",
|
||||
"res.country.group",
|
||||
"res.config.settings",
|
||||
"base.language.install",
|
||||
"decimal.precision",
|
||||
"uom.uom",
|
||||
"uom.category",
|
||||
}
|
||||
)
|
||||
CHAMPS_INTERDITS = frozenset(
|
||||
{
|
||||
"id",
|
||||
"create_uid",
|
||||
"write_uid",
|
||||
"create_date",
|
||||
"write_date",
|
||||
"sequence",
|
||||
"active",
|
||||
"display_name",
|
||||
"__last_update",
|
||||
"arch_db",
|
||||
"arch_fs",
|
||||
"key",
|
||||
"model",
|
||||
"res_model",
|
||||
"res_field",
|
||||
"state",
|
||||
"color",
|
||||
"company_id",
|
||||
}
|
||||
)
|
||||
CHAMPS_CONNEXION = frozenset({"login", "password"})
|
||||
|
||||
# Le point de départ du mode hybride : ce qui porte des données
|
||||
# personnelles dans une base Odoo ordinaire.
|
||||
MODELES_PAR_DEFAUT = (
|
||||
"res.partner",
|
||||
"res.users",
|
||||
"res.company",
|
||||
"res.bank",
|
||||
"res.partner.bank",
|
||||
"crm.lead",
|
||||
"hr.employee",
|
||||
"mail.message",
|
||||
"mail.tracking.value",
|
||||
"account.move",
|
||||
"sale.order",
|
||||
"purchase.order",
|
||||
"project.task",
|
||||
"calendar.event",
|
||||
"survey.user_input",
|
||||
)
|
||||
|
||||
MOTS_PAR_DEFAUT = (
|
||||
"alouette",
|
||||
"bruyere",
|
||||
"cascade",
|
||||
"dolmen",
|
||||
"erable",
|
||||
"fougere",
|
||||
"givre",
|
||||
"hameau",
|
||||
"iris",
|
||||
"jonquille",
|
||||
"lichen",
|
||||
"marais",
|
||||
"nenuphar",
|
||||
"orme",
|
||||
"pinson",
|
||||
"roseau",
|
||||
"sureau",
|
||||
"tourbe",
|
||||
"varech",
|
||||
"zephyr",
|
||||
)
|
||||
|
||||
MODES = ("whitelist", "blacklist", "hybrid")
|
||||
|
||||
|
||||
def modele_interdit(modele):
|
||||
"""Le plancher, en une question."""
|
||||
if any(modele.startswith(p) for p in PREFIXES_INTERDITS):
|
||||
return True
|
||||
return modele in MODELES_INTERDITS
|
||||
|
||||
|
||||
def choisir_modeles(tous, mode, whitelist=(), blacklist=(), defauts=None):
|
||||
"""Quels modèles anonymiser, selon le mode.
|
||||
|
||||
whitelist : ceux-là et rien d'autre.
|
||||
blacklist : tous SAUF ceux-là.
|
||||
hybrid : la liste par défaut, plus la blanche, moins la noire —
|
||||
c'est le mode utile en pratique : on part de ce qui porte
|
||||
des données personnelles et on ajuste aux marges.
|
||||
|
||||
Le plancher s'applique aux trois : un modèle `ir.*` ne passe par
|
||||
aucun chemin, même nommé explicitement.
|
||||
"""
|
||||
tous = set(tous)
|
||||
blanche, noire = set(whitelist), set(blacklist)
|
||||
if mode == "whitelist":
|
||||
choisis = blanche & tous
|
||||
elif mode == "blacklist":
|
||||
choisis = tous - noire
|
||||
elif mode == "hybrid":
|
||||
base = set(MODELES_PAR_DEFAUT if defauts is None else defauts)
|
||||
choisis = ((base | blanche) - noire) & tous
|
||||
else:
|
||||
raise ValueError(f"{t('Unknown mode:')} {mode}")
|
||||
return sorted(m for m in choisis if not modele_interdit(m))
|
||||
|
||||
|
||||
def champ_retenu(champ, inclure_connexion=False):
|
||||
"""Ce champ-là se remplace-t-il ?
|
||||
|
||||
`champ` : dict avec model, name, ttype, pg_type, unique.
|
||||
"""
|
||||
if champ["name"] in CHAMPS_INTERDITS:
|
||||
return False
|
||||
if champ["name"] in CHAMPS_CONNEXION and not inclure_connexion:
|
||||
return False
|
||||
if champ["name"].endswith("_id") or champ["name"].endswith("_ids"):
|
||||
# Une relation qui aurait échappé au filtre de ttype.
|
||||
return False
|
||||
if champ.get("checked"):
|
||||
# Une contrainte CHECK dit ce que la colonne a le droit de valoir.
|
||||
# Mesuré : crm_lead.probability doit rester entre 0 et 100, et un
|
||||
# tirage à 1000 fait échouer l'UPDATE — donc, transaction unique
|
||||
# oblige, TOUTE l'anonymisation. Lire l'expression du CHECK pour
|
||||
# tirer dedans serait deviner ; on s'abstient et on le dit.
|
||||
return False
|
||||
if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb":
|
||||
# Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut
|
||||
# vivre dans un jsonb par société. Y écrire un nombre nu ferait
|
||||
# échouer l'UPDATE — et donc, transaction unique oblige, TOUTE
|
||||
# l'anonymisation. On s'abstient plutôt que de deviner sa forme.
|
||||
return False
|
||||
return champ["ttype"] in TYPES_TEXTE + TYPES_NOMBRE
|
||||
|
||||
|
||||
def mots_pour(nom_champ, mots):
|
||||
"""La liste de mots à utiliser pour ce champ.
|
||||
|
||||
`mots` peut être une simple liste — la même partout — ou un
|
||||
dictionnaire par nom de champ avec une entrée `*` en repli. Le
|
||||
dictionnaire permet de garder des courriels qui ressemblent à des
|
||||
courriels, ce qu'une liste unique ne sait pas faire.
|
||||
"""
|
||||
if isinstance(mots, dict):
|
||||
choix = mots.get(nom_champ) or mots.get("*") or MOTS_PAR_DEFAUT
|
||||
else:
|
||||
choix = mots or MOTS_PAR_DEFAUT
|
||||
return tuple(str(m) for m in choix) or MOTS_PAR_DEFAUT
|
||||
|
||||
|
||||
def litteral(texte):
|
||||
"""Un littéral SQL. Le seul endroit où du texte entre dans la requête."""
|
||||
return "'" + str(texte).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def expression_texte(champ, mots):
|
||||
"""Le SQL qui remplace un champ texte, en préservant les NULL.
|
||||
|
||||
Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en
|
||||
avait pas : la copie mentirait dans l'autre sens.
|
||||
"""
|
||||
nom = champ["name"]
|
||||
liste = mots_pour(nom, mots)
|
||||
# Les parenthèses ne sont pas décoratives : PostgreSQL refuse
|
||||
# d'indexer un constructeur ARRAY[...] directement.
|
||||
tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])"
|
||||
tirage = f"{tableau}[(id % {len(liste)}) + 1]"
|
||||
if champ.get("unique"):
|
||||
# Deux lignes qui reçoivent le même mot feraient échouer TOUT
|
||||
# l'UPDATE sur une colonne unique.
|
||||
tirage = f"{tirage} || '-' || id::text"
|
||||
if champ.get("pg_type") == "jsonb":
|
||||
# Un objet par langue depuis Odoo 17 : on le reconstruit clé à
|
||||
# clé. Écrire une chaîne par-dessus détruirait la colonne.
|
||||
return (
|
||||
f"CASE WHEN {nom} IS NULL THEN NULL ELSE"
|
||||
f" (SELECT jsonb_object_agg(kv.key, {tirage})"
|
||||
f" FROM jsonb_each_text({nom}) AS kv) END"
|
||||
)
|
||||
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
|
||||
|
||||
|
||||
def expression_nombre(champ):
|
||||
"""Le SQL qui remplace un nombre : au hasard, entre 0 et 1000."""
|
||||
nom = champ["name"]
|
||||
if champ["ttype"] == "integer":
|
||||
tirage = "floor(random() * 1001)::integer"
|
||||
else:
|
||||
tirage = "round((random() * 1000)::numeric, 2)"
|
||||
return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END"
|
||||
|
||||
|
||||
def sql_pour_table(table, champs, mots):
|
||||
"""Un seul UPDATE par table : toutes ses colonnes d'un coup."""
|
||||
morceaux = []
|
||||
for champ in champs:
|
||||
if champ["ttype"] in TYPES_TEXTE:
|
||||
morceaux.append(
|
||||
f"{champ['name']} = {expression_texte(champ, mots)}"
|
||||
)
|
||||
else:
|
||||
morceaux.append(f"{champ['name']} = {expression_nombre(champ)}")
|
||||
if not morceaux:
|
||||
return None
|
||||
return f"UPDATE {table} SET " + ", ".join(morceaux) + ";"
|
||||
|
||||
|
||||
def table_de(modele):
|
||||
"""Le nom de table qu'Odoo donne à ce modèle."""
|
||||
return modele.replace(".", "_")
|
||||
|
||||
|
||||
SEP = "\x1f"
|
||||
|
||||
# On croise TROIS sources, et c'est la raison d'être de cette requête :
|
||||
# `ir_model_fields` dit ce qu'Odoo croit (le ttype, seul capable de
|
||||
# distinguer un `selection` d'un vrai texte), `pg_attribute` dit ce que
|
||||
# PostgreSQL a vraiment (jsonb ou varchar), et `pg_constraint` dit ce qui
|
||||
# doit rester unique. Aucune des trois ne suffit seule.
|
||||
REQUETE_CHAMPS = """
|
||||
SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f'
|
||||
|| a.atttypid::regtype::text || '\x1f'
|
||||
|| CASE WHEN EXISTS (
|
||||
SELECT 1 FROM pg_constraint k
|
||||
WHERE k.conrelid = c.oid
|
||||
AND k.contype IN ('u', 'p')
|
||||
AND a.attnum = ANY(k.conkey)
|
||||
) THEN '1' ELSE '0' END || '\x1f'
|
||||
|| CASE WHEN EXISTS (
|
||||
SELECT 1 FROM pg_constraint k
|
||||
WHERE k.conrelid = c.oid
|
||||
AND k.contype = 'c'
|
||||
AND a.attnum = ANY(k.conkey)
|
||||
) THEN '1' ELSE '0' END
|
||||
FROM ir_model_fields f
|
||||
JOIN pg_class c ON c.relname = replace(f.model, '.', '_')
|
||||
AND c.relkind = 'r'
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid
|
||||
AND a.attname = f.name
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
WHERE f.store
|
||||
AND f.ttype IN ('char','text','html','integer','float','monetary')
|
||||
ORDER BY f.model, f.name
|
||||
"""
|
||||
|
||||
|
||||
def inspect(database, config_path=None):
|
||||
"""Tous les champs remplaçables de la base, avec leurs trois vérités."""
|
||||
brut = lib_analyse.run_psql(
|
||||
database, REQUETE_CHAMPS, config_path=config_path
|
||||
)
|
||||
champs = []
|
||||
for ligne in brut.splitlines():
|
||||
parts = ligne.split(SEP)
|
||||
if len(parts) != 6:
|
||||
continue
|
||||
champs.append(
|
||||
{
|
||||
"model": parts[0],
|
||||
"name": parts[1],
|
||||
"ttype": parts[2],
|
||||
"pg_type": parts[3],
|
||||
"unique": parts[4] == "1",
|
||||
"checked": parts[5] == "1",
|
||||
}
|
||||
)
|
||||
return champs
|
||||
|
||||
|
||||
def plan(
|
||||
champs,
|
||||
mode,
|
||||
whitelist=(),
|
||||
blacklist=(),
|
||||
inclure_connexion=False,
|
||||
mots=None,
|
||||
):
|
||||
"""Ce qui sera écrit, table par table — avant d'écrire quoi que ce soit.
|
||||
|
||||
Rendu séparément de l'exécution pour que le mode « à blanc » montre
|
||||
EXACTEMENT ce que `--apply` ferait, et non une approximation.
|
||||
"""
|
||||
modeles = choisir_modeles(
|
||||
{c["model"] for c in champs}, mode, whitelist, blacklist
|
||||
)
|
||||
retenus = set(modeles)
|
||||
par_modele = {}
|
||||
for champ in champs:
|
||||
if champ["model"] not in retenus:
|
||||
continue
|
||||
if not champ_retenu(champ, inclure_connexion):
|
||||
continue
|
||||
par_modele.setdefault(champ["model"], []).append(champ)
|
||||
etapes = []
|
||||
for modele in modeles:
|
||||
liste = par_modele.get(modele)
|
||||
if not liste:
|
||||
continue
|
||||
sql = sql_pour_table(table_de(modele), liste, mots)
|
||||
if sql:
|
||||
etapes.append({"model": modele, "fields": liste, "sql": sql})
|
||||
return etapes
|
||||
|
||||
|
||||
def render(etapes, applique=False, verbeux=False):
|
||||
"""Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris."""
|
||||
if not etapes:
|
||||
return f"✅ {t('Nothing to anonymise with these lists.')}"
|
||||
total = sum(len(e["fields"]) for e in etapes)
|
||||
tete = (
|
||||
f"🎭 {len(etapes)} {t('model(s)')}, {total} {t('column(s)')}"
|
||||
f" — {t('written') if applique else t('dry run, nothing written')}"
|
||||
)
|
||||
lignes = [tete, ""]
|
||||
for etape in etapes:
|
||||
textes = [f for f in etape["fields"] if f["ttype"] in TYPES_TEXTE]
|
||||
nombres = [f for f in etape["fields"] if f["ttype"] in TYPES_NOMBRE]
|
||||
traduits = [f for f in textes if f["pg_type"] == "jsonb"]
|
||||
uniques = [f for f in etape["fields"] if f["unique"]]
|
||||
detail = f"{len(textes)} {t('text')}, {len(nombres)} {t('numeric')}"
|
||||
if traduits:
|
||||
detail += f", {len(traduits)} {t('translated (jsonb)')}"
|
||||
if uniques:
|
||||
detail += f", {len(uniques)} {t('unique')}"
|
||||
lignes.append(f" {etape['model']:<34} {detail}")
|
||||
if verbeux:
|
||||
for champ in etape["fields"]:
|
||||
lignes.append(
|
||||
f" {champ['name']:<30} {champ['ttype']}"
|
||||
f" / {champ['pg_type']}"
|
||||
)
|
||||
if not applique:
|
||||
lignes.append("")
|
||||
lignes.append(f" {t('Use --apply --confirm <database> to write.')}")
|
||||
return "\n".join(lignes)
|
||||
|
||||
|
||||
def charger_mots(chemin):
|
||||
"""Lire un fichier Python qui déclare MOTS.
|
||||
|
||||
Une liste — les mêmes mots partout — ou un dictionnaire par nom de
|
||||
champ avec un repli `*`. Aucun réseau, aucun modèle : des mots.
|
||||
"""
|
||||
if not chemin:
|
||||
return None
|
||||
espace = {}
|
||||
with open(chemin, "r", encoding="utf-8") as handle:
|
||||
exec(compile(handle.read(), chemin, "exec"), espace) # noqa: S102
|
||||
mots = espace.get("MOTS")
|
||||
if not mots:
|
||||
raise ValueError(f"{t('This file declares no MOTS:')} {chemin}")
|
||||
return mots
|
||||
|
||||
|
||||
def ecrire(database, etapes, config_path=None, timeout=900):
|
||||
"""Écrire les UPDATE — TOUS, ou AUCUN. None si tout a réussi.
|
||||
|
||||
Une seule transaction (`-1`) et `ON_ERROR_STOP=1`. Sans cela, une
|
||||
collision d'unicité au dixième modèle laisserait une base à moitié
|
||||
anonymisée — c'est-à-dire une base dont plus personne ne peut dire ce
|
||||
qui est vrai, et que rien ne rattrape sinon une restauration.
|
||||
|
||||
On reprend `pg_env` pour la connexion — hôte, port, mot de passe lus
|
||||
dans config.conf — et l'on ne lève QUE la lecture seule. La
|
||||
redéclarer ici, ce serait accepter qu'elle diverge un jour.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
env = lib_analyse.pg_env(config_path, timeout=timeout)
|
||||
env["PGOPTIONS"] = f"-c statement_timeout={timeout}s"
|
||||
sql = "\n".join(etape["sql"] for etape in etapes)
|
||||
done = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-X",
|
||||
"-w",
|
||||
"-1",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-d",
|
||||
database,
|
||||
"-tA",
|
||||
"-c",
|
||||
sql,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=timeout + 60,
|
||||
)
|
||||
if done.returncode:
|
||||
detail = (done.stderr or "").strip().splitlines()
|
||||
return detail[0][:200] if detail else "psql"
|
||||
return None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=t("Replace the data of a COPY with meaningless data."),
|
||||
)
|
||||
parser.add_argument("-d", "--database", required=True)
|
||||
parser.add_argument("-c", "--config", help="odoo config file")
|
||||
parser.add_argument("--mode", choices=MODES, default="hybrid")
|
||||
parser.add_argument(
|
||||
"--models", default="", help=t("comma separated, adds to the mode")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude", default="", help=t("comma separated, removed from it")
|
||||
)
|
||||
parser.add_argument("--words", help=t("python file declaring MOTS"))
|
||||
parser.add_argument("--include-logins", action="store_true")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument(
|
||||
"--confirm",
|
||||
default="",
|
||||
help=t("repeat the database name; --apply refuses without it"),
|
||||
)
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Le garde-fou, AVANT toute lecture : personne ne doit découvrir en
|
||||
# route qu'il a lancé cela sur la mauvaise base.
|
||||
if args.apply and args.confirm != args.database:
|
||||
print(
|
||||
f"❌ {t('Refusing to write: --confirm must repeat')}"
|
||||
f" '{args.database}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
try:
|
||||
lib_analyse.require_odoo_database(
|
||||
args.database, config_path=args.config
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"❌ {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
mots = charger_mots(args.words)
|
||||
champs = inspect(args.database, args.config)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"❌ {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
etapes = plan(
|
||||
champs,
|
||||
args.mode,
|
||||
[m.strip() for m in args.models.split(",") if m.strip()],
|
||||
[m.strip() for m in args.exclude.split(",") if m.strip()],
|
||||
args.include_logins,
|
||||
mots,
|
||||
)
|
||||
if not args.apply:
|
||||
print(render(etapes, applique=False, verbeux=args.verbose))
|
||||
return 1 if etapes else 0
|
||||
|
||||
erreur = ecrire(args.database, etapes, args.config)
|
||||
if erreur:
|
||||
print(f"❌ {t('Nothing was written:')} {erreur}", file=sys.stderr)
|
||||
return 2
|
||||
print(render(etapes, applique=True, verbeux=args.verbose))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -35,9 +35,22 @@ parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures :
|
|||
liste de prix par défaut absente 0 → 1 `product` installé, son
|
||||
xmlid pas là
|
||||
|
||||
Zéro avant, non nul après : aucun de ces quatre ne peut s'expliquer
|
||||
Zéro avant, non nul après : aucun de ces trois ne peut s'expliquer
|
||||
autrement que par la migration.
|
||||
|
||||
Un quatrième a été RETIRÉ après vérification
|
||||
--------------------------------------------
|
||||
« ir_model_relation nomme une table absente » : 0 avant, 68 après, le
|
||||
profil idéal. Et sans la moindre conséquence. Son unique consommateur,
|
||||
`_module_data_uninstall` dans `base/models/ir_model.py`, teste
|
||||
`sql.table_exists(...)` AVANT de supprimer : la ligne périmée est
|
||||
ignorée, puis effacée. Les 68 appartiennent en outre à des modules
|
||||
INSTALLÉS, que `database_cleanup` ne touche pas — la réparation que ce
|
||||
fichier désignait n'en aurait réparé aucune.
|
||||
|
||||
Un constat sans conséquence et sans geste possible est du bruit, quelle
|
||||
que soit la netteté du signal. Il est parti.
|
||||
|
||||
Chaque constat nomme l'outil qui le répare. Un rapport qui montre un
|
||||
dégât sans dire quoi lancer oblige à chercher, et on ne cherche pas.
|
||||
|
||||
|
|
@ -101,16 +114,6 @@ CONTROLES = (
|
|||
"gravity": "broken",
|
||||
"repair": None,
|
||||
},
|
||||
{
|
||||
"key": "orphan_relation",
|
||||
"title": "Many-to-many tables named but missing",
|
||||
"why": "ir_model_relation still names a table PostgreSQL does not"
|
||||
" have; the next module update tries to alter it and fails.",
|
||||
"sql": "SELECT count(*) FROM ir_model_relation r WHERE NOT EXISTS"
|
||||
" (SELECT 1 FROM pg_class c WHERE c.relname = r.name)",
|
||||
"gravity": "broken",
|
||||
"repair": "script/analyse/database_cleanup.py",
|
||||
},
|
||||
{
|
||||
"key": "duplicate_index",
|
||||
"title": "Indexes duplicated by the Odoo 17 renaming",
|
||||
|
|
@ -131,10 +134,15 @@ CONTROLES = (
|
|||
"title": "Default pricelist missing while product is installed",
|
||||
"why": "product.list0 was declared up to Odoo 16 only; nothing"
|
||||
" recreates it, and a quotation has no price list to pick.",
|
||||
# On cherche une LISTE, pas son xmlid. Mesuré : la réparation
|
||||
# laisse Odoo créer « Par défaut » sans poser `product.list0` —
|
||||
# chercher l'xmlid signalait donc une base parfaitement saine, et
|
||||
# aurait signalé de même celle d'un client qui a créé la sienne à
|
||||
# la main.
|
||||
"sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_module_module"
|
||||
" WHERE name='product' AND state='installed')"
|
||||
" AND NOT EXISTS (SELECT 1 FROM ir_model_data"
|
||||
" WHERE module='product' AND name='list0')"
|
||||
" AND to_regclass('public.product_pricelist') IS NOT NULL"
|
||||
" AND NOT EXISTS (SELECT 1 FROM product_pricelist)"
|
||||
" THEN 1 ELSE 0 END",
|
||||
"gravity": "broken",
|
||||
"repair": "script/odoo/migration/restore_config_defaults.py --apply",
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ ANALYSES = (
|
|||
" step-by-step databases.",
|
||||
"script": "script/analyse/check_migration_residue.py",
|
||||
"kinds": (KIND_DATABASE,),
|
||||
"needs_sql": "It reads pg_catalog — indexes and real tables — which"
|
||||
"why_not": "It reads pg_catalog — indexes and real tables — which"
|
||||
" no RPC session exposes.",
|
||||
},
|
||||
{
|
||||
|
|
@ -78,13 +78,28 @@ ANALYSES = (
|
|||
" administrator — read for the use you intend.",
|
||||
"script": "script/analyse/check_instance_state.py",
|
||||
"kinds": (KIND_DATABASE,),
|
||||
"needs_sql": "Several checks read tables no RPC session exposes,"
|
||||
"why_not": "Several checks read tables no RPC session exposes,"
|
||||
" and the lateness of a job is computed in SQL.",
|
||||
# Le même chiffre veut dire deux choses opposées selon qu'on
|
||||
# ausculte une copie ou une production : l'attente ne se devine
|
||||
# pas, elle se demande.
|
||||
"asks_expect": True,
|
||||
},
|
||||
{
|
||||
"key": "anonymize",
|
||||
"title": "Anonymise a copy",
|
||||
"why": "Replace names, texts and numbers with meaningless ones so"
|
||||
" the copy can be shared. No AI: words from a list, numbers drawn"
|
||||
" at random, written by SQL.",
|
||||
"script": "script/analyse/anonymize.py",
|
||||
"kinds": (KIND_DATABASE,),
|
||||
# La seule entrée qui ÉCRIT. Le refus d'une instance vivante n'a
|
||||
# rien à voir avec le SQL : on ne détruit pas les données d'une
|
||||
# production, même à la demande.
|
||||
"why_not": "This one WRITES. It is offered only for a database"
|
||||
" you restored here — never for an instance in service.",
|
||||
"writes": True,
|
||||
},
|
||||
{
|
||||
"key": "cow_views",
|
||||
"title": "Customised views, website copies included",
|
||||
|
|
@ -92,7 +107,7 @@ ANALYSES = (
|
|||
" shadows a module view.",
|
||||
"script": "script/analyse/analyse_view_custom.py",
|
||||
"kinds": (KIND_DATABASE,),
|
||||
"needs_sql": "Comparing a copy with the module view it hides is a"
|
||||
"why_not": "Comparing a copy with the module view it hides is a"
|
||||
" join on arch_db, done in SQL.",
|
||||
},
|
||||
{
|
||||
|
|
@ -102,7 +117,7 @@ ANALYSES = (
|
|||
" them have no column behind them.",
|
||||
"script": "script/analyse/analyse_custom_field.py",
|
||||
"kinds": (KIND_DATABASE,),
|
||||
"needs_sql": "Telling a declared field from a real column means"
|
||||
"why_not": "Telling a declared field from a real column means"
|
||||
" reading pg_attribute.",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def rows(kind):
|
|||
analyse["key"],
|
||||
t(analyse["title"]),
|
||||
utilisable,
|
||||
"" if utilisable else t(analyse["needs_sql"]),
|
||||
"" if utilisable else t(analyse["why_not"]),
|
||||
)
|
||||
)
|
||||
return lignes
|
||||
|
|
@ -79,7 +79,7 @@ def detail(key, kind):
|
|||
morceaux.append(f"▶ {t('Enter to run it.')}")
|
||||
else:
|
||||
morceaux.append(f"✖ {t('Not available for this source.')}")
|
||||
morceaux.append(f" {t(analyse['needs_sql'])}")
|
||||
morceaux.append(f" {t(analyse['why_not'])}")
|
||||
morceaux.append("")
|
||||
morceaux.append(f" {analyse['script']}")
|
||||
return "\n".join(morceaux)
|
||||
|
|
|
|||
|
|
@ -218,6 +218,38 @@ def parse_kernel(text: str) -> str:
|
|||
return trouve.group(1) if trouve else ""
|
||||
|
||||
|
||||
# Ce qu'il faut savoir AVANT d'écrire un pont NAT, en un aller-retour.
|
||||
#
|
||||
# Le noyau seul ne suffit pas à juger : « -pve » dans son nom est un indice,
|
||||
# pas une preuve, et l'inverse non plus — c'est la table NAT elle-même qu'on
|
||||
# interroge. « iptables -t nat -S » échoue avec « Table does not exist » quand
|
||||
# aucun module netfilter n'est chargeable, et réussit sinon.
|
||||
NAT_CHECK_CMD = (
|
||||
"uname -r; echo '---ERPLIBRE-NAT---'; "
|
||||
"iptables -t nat -S >/dev/null 2>&1 && echo NAT-OK || echo NAT-KO; "
|
||||
"echo '---ERPLIBRE-PVE-KERNEL---'; "
|
||||
"ls -1 /lib/modules 2>/dev/null | grep -- -pve | sort -V | tail -1"
|
||||
)
|
||||
|
||||
|
||||
def parse_nat_check(text: str) -> dict:
|
||||
"""{"kernel": …, "nat": bool, "pve_kernel": …} depuis NAT_CHECK_CMD.
|
||||
|
||||
`nat` à False sans `pve_kernel` veut dire que l'installation Proxmox n'est
|
||||
pas allée au bout ; avec, qu'elle attend un redémarrage."""
|
||||
brut = strip_ssh_noise(text or "")
|
||||
parts = brut.split("---ERPLIBRE-NAT---")
|
||||
kernel = parts[0].strip().splitlines()
|
||||
reste = parts[1] if len(parts) > 1 else ""
|
||||
suite = reste.split("---ERPLIBRE-PVE-KERNEL---")
|
||||
pve_kernel = suite[1].strip().splitlines() if len(suite) > 1 else []
|
||||
return {
|
||||
"kernel": kernel[-1].strip() if kernel else "",
|
||||
"nat": "NAT-OK" in (suite[0] if suite else ""),
|
||||
"pve_kernel": pve_kernel[-1].strip() if pve_kernel else "",
|
||||
}
|
||||
|
||||
|
||||
def parse_qm_list(text: str) -> list:
|
||||
"""Sortie de « qm list » -> [{vmid, name, status, mem, disk}].
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ tous les fragments, et lire un widget absent ne doit pas casser l'écran.
|
|||
|
||||
from script.todo.deploy_form_lib import FREE, t
|
||||
|
||||
# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le
|
||||
# rendrait indistinguable de la sentinelle « rien de sélectionné ».
|
||||
SERVER = "__server__"
|
||||
|
||||
# Ce que l'écran lit du contexte. Une seule liste, parce que les deux
|
||||
# formulaires doivent en recevoir autant : c'est en fournissant un
|
||||
# sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages.
|
||||
|
|
@ -122,9 +126,18 @@ class ExtrasMixin:
|
|||
`render_extras()` après chaque recalcul et `extras_values()` au moment de
|
||||
bâtir sa spec."""
|
||||
|
||||
def extras_init(self, ctx) -> None:
|
||||
"""À appeler dans `__init__`, avant tout `compose`."""
|
||||
def extras_init(self, ctx, branches=(), profiles=()) -> None:
|
||||
"""À appeler dans `__init__`, avant tout `compose`.
|
||||
|
||||
`branches` et `profiles` viennent du formulaire et non du contexte :
|
||||
chacun les ordonne à sa façon (branch_order) et l'écran les propose
|
||||
dans cet ordre-là. Ce sont les mêmes listes que portent les rangées."""
|
||||
self._extras = extras_tables(ctx)
|
||||
self._extras["branches"] = list(branches)
|
||||
self._extras["profiles"] = list(profiles)
|
||||
self._extras["distro_profiles"] = dict(
|
||||
ctx.get("distro_profiles") or {}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Les widgets
|
||||
|
|
@ -310,6 +323,165 @@ class ExtrasMixin:
|
|||
def _extras_disk_gb(self, vm, tools) -> int:
|
||||
return extras_disk_gb(vm, tools, self._extras)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Une rangée du plan : branche, profil, type — par VM
|
||||
# ------------------------------------------------------------------ #
|
||||
def _branch(self) -> str:
|
||||
"""Branche du formulaire : le défaut de chaque VM."""
|
||||
widget = self._widget("#f_branch")
|
||||
valeur = widget.value if widget is not None else None
|
||||
branches = self._extras["branches"]
|
||||
return valeur if isinstance(valeur, str) else (branches or [""])[0]
|
||||
|
||||
def _profile_cmd(self) -> str:
|
||||
"""Commande du profil choisi en haut : le défaut de chaque VM."""
|
||||
profiles = self._extras["profiles"]
|
||||
widget = self._widget("#f_profile_install")
|
||||
if not profiles or widget is None:
|
||||
return ""
|
||||
index = widget.value
|
||||
return profiles[index if isinstance(index, int) else 0][1]
|
||||
|
||||
def _row_default_cmd(self, i) -> str:
|
||||
"""Commande qu'une rangée prend d'elle-même : celle que son système
|
||||
impose, sinon le choix commun d'en haut.
|
||||
|
||||
C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM
|
||||
Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est
|
||||
aussi la valeur commune."""
|
||||
if i < len(self.rows):
|
||||
impose = self._extras["distro_profiles"].get(
|
||||
self.rows[i]["vm"]["distro"]
|
||||
)
|
||||
if impose:
|
||||
return impose[1]
|
||||
return self._profile_cmd()
|
||||
|
||||
def _row_profile_index(self, i) -> int:
|
||||
"""Rang du profil que la rangée doit AFFICHER."""
|
||||
cmd = ""
|
||||
if i < len(self.rows):
|
||||
cmd = self.rows[i]["vm"].get("install_cmd") or ""
|
||||
cmd = cmd or self._row_default_cmd(i)
|
||||
for k, (_lbl, c) in enumerate(self._extras["profiles"]):
|
||||
if c == cmd:
|
||||
return k
|
||||
return 0
|
||||
|
||||
def _type_options(self):
|
||||
return [(t("Server"), SERVER)] + [
|
||||
(label, key) for key, label in self._extras["desktops"]
|
||||
]
|
||||
|
||||
def install_row_widgets(self, i, null=None):
|
||||
"""Les trois choix qu'une VM peut prendre à elle seule.
|
||||
|
||||
Ici et non dans un formulaire : ce sont les mêmes trois des deux
|
||||
côtés, et ils manquaient à l'écran Proxmox — où l'on déploie pourtant
|
||||
le plus souvent un parc MIXTE, un hyperviseur imbriqué à côté de VM
|
||||
ERPLibre."""
|
||||
from textual.widgets import Select, Static
|
||||
|
||||
tab = self._extras
|
||||
if null is None:
|
||||
null = getattr(Select, "NULL", Select.BLANK)
|
||||
vm = self.rows[i]["vm"] if i < len(self.rows) else {}
|
||||
widgets = [
|
||||
Select(
|
||||
[(b, b) for b in tab["branches"]],
|
||||
classes="vmbranch",
|
||||
# Repli sur la branche du FORMULAIRE, jamais sur branches[0] :
|
||||
# les rangées sont remontées dès que le jeu de VM change (une
|
||||
# entrée cochée, une copie ajoutée, un renommage), et elles
|
||||
# retombaient alors toutes sur « develop » quel que soit le
|
||||
# choix commun.
|
||||
value=vm.get("branch") or self._branch(),
|
||||
allow_blank=False,
|
||||
id=f"v{i}_branch",
|
||||
),
|
||||
(
|
||||
Select(
|
||||
[(lbl, k) for k, (lbl, _c) in enumerate(tab["profiles"])],
|
||||
value=self._row_profile_index(i),
|
||||
allow_blank=False,
|
||||
classes="vmprof",
|
||||
id=f"v{i}_prof",
|
||||
)
|
||||
if tab["profiles"]
|
||||
else Static("", classes="vmprof")
|
||||
),
|
||||
]
|
||||
if tab["desktops"]:
|
||||
widgets.append(
|
||||
Select(
|
||||
self._type_options(),
|
||||
value=vm.get("desktop") or SERVER,
|
||||
allow_blank=False,
|
||||
id=f"v{i}_type",
|
||||
)
|
||||
)
|
||||
return widgets
|
||||
|
||||
def extras_on_row_select(self, event, index, field, null=None) -> bool:
|
||||
"""Traite branche / profil / type d'UNE rangée. True quand c'est fait.
|
||||
|
||||
Ce que ces trois partagent, et qui n'est pas évident : poser
|
||||
« value= » au montage fait émettre un Changed que Textual délivre
|
||||
APRÈS coup, et un verrou temporel ne l'attrape pas — mesuré, les
|
||||
champs de chaque VM se retrouvaient surchargés dès l'affichage et le
|
||||
profil x1..x4 devenait inopérant. On compare donc à ce que le modèle
|
||||
dit DÉJÀ : une valeur identique n'est pas une saisie, c'est l'écho.
|
||||
|
||||
Cas limite assumé : choisir explicitement la valeur que le profil
|
||||
donne déjà n'enregistre pas de surcharge. La VM suivra donc le profil
|
||||
s'il change — ce qui est aussi le plus attendu quand on n'a rien
|
||||
changé de visible."""
|
||||
if field not in ("branch", "prof", "type"):
|
||||
return False
|
||||
vm_now = self.rows[index]["vm"]
|
||||
if field == "prof":
|
||||
# L'écho se reconnaît à ceci : la valeur est CELLE QU'ON VIENT
|
||||
# D'AFFICHER. Comparer les commandes ne suffisait pas — quand la
|
||||
# commande imposée par le système n'est pas dans la liste
|
||||
# proposée, la liste retombe sur le rang 0, et l'écho de ce
|
||||
# rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait
|
||||
# ainsi ERPLibre et Odoo 18, le défaut qu'on venait de corriger.
|
||||
if event.value == self._row_profile_index(index):
|
||||
return True
|
||||
label, cmd = self._extras["profiles"][event.value]
|
||||
defaut = self._row_default_cmd(index)
|
||||
if cmd == (vm_now.get("install_cmd") or defaut):
|
||||
return True
|
||||
meme = cmd == defaut
|
||||
self._set_override(index, "install_cmd", "" if meme else cmd)
|
||||
self._set_override(index, "install_label", "" if meme else label)
|
||||
self._recompute()
|
||||
return True
|
||||
if field == "branch":
|
||||
# « la branche du formulaire » n'est pas une surcharge : la VM
|
||||
# doit suivre si on la change en haut.
|
||||
if event.value == (vm_now.get("branch") or self._branch()):
|
||||
return True
|
||||
self._set_override(
|
||||
index,
|
||||
"branch",
|
||||
"" if event.value == self._branch() else event.value,
|
||||
)
|
||||
self._recompute()
|
||||
return True
|
||||
nouveau = "" if event.value == SERVER else event.value
|
||||
if nouveau == (vm_now.get("desktop") or ""):
|
||||
return True
|
||||
self._set_override(index, "desktop", nouveau)
|
||||
# « Serveur » est un choix légitime, pas un retrait : on le note
|
||||
# explicitement pour qu'il tienne face au défaut.
|
||||
if event.value == SERVER:
|
||||
cle = self._row_key(index)
|
||||
if cle is not None:
|
||||
self.overrides.setdefault(cle, {})["desktop"] = ""
|
||||
self._recompute()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Redessiner
|
||||
# ------------------------------------------------------------------ #
|
||||
|
|
|
|||
|
|
@ -187,7 +187,14 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
+ """
|
||||
SelectionList { height: 10; border: solid $panel; }
|
||||
RadioSet { height: auto; layout: horizontal; }
|
||||
/* Ces deux règles portent « .vmrow Select » EN PLUS de leur
|
||||
classe : « .vmrow Select » (une classe + un type) l'emporte sur
|
||||
« .vmbranch » (une classe) par spécificité CSS. Écrites simplement,
|
||||
elles étaient silencieusement écrasées. La branche porte des noms
|
||||
longs (« 1.6.0 », « develop », « feature/xyz ») : trop étroite, la
|
||||
liste les tronque et on ne sait plus ce qu'on a choisi. */
|
||||
.vmrow Select.vmbranch { width: 34; }
|
||||
.vmrow Select.vmprof { width: 40; }
|
||||
#hostline { height: 1; color: $accent; padding: 0 1; }
|
||||
"""
|
||||
)
|
||||
|
|
@ -210,7 +217,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
self._syncing = False
|
||||
# La liste des ponts GRANDIT : l'écran sait en créer un.
|
||||
self._ponts = list(ponts)
|
||||
self.extras_init(ctx)
|
||||
self.extras_init(ctx, branches, profiles)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# L'écran
|
||||
|
|
@ -594,6 +601,11 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
if item.get("instance")
|
||||
else Static("", classes="vmcopy")
|
||||
),
|
||||
# Branche, profil, type — par VM. On déploie ici le plus
|
||||
# souvent un parc MIXTE : un hyperviseur Proxmox imbriqué
|
||||
# à côté de VM ERPLibre, et l'écran n'offrait qu'un choix
|
||||
# commun pour les deux.
|
||||
*self.install_row_widgets(i, SELECT_NULL),
|
||||
classes="vmrow",
|
||||
)
|
||||
cartes.append(
|
||||
|
|
@ -739,6 +751,14 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
if ident in ("f_storage", "f_bridge"):
|
||||
self._refresh_after()
|
||||
return
|
||||
if ident in ("f_branch", "f_profile_install"):
|
||||
# Un réglage commun reprend la main sur les VM non figées :
|
||||
# c'est le sens même du mot « commun ».
|
||||
self._clear_overrides(
|
||||
("branch",) if ident == "f_branch" else ("install_cmd",)
|
||||
)
|
||||
self._refresh_after(remonter=True)
|
||||
return
|
||||
# Réglage commun : « libre… » révèle la saisie, une valeur
|
||||
# s'applique à toutes les VM non figées.
|
||||
champ = SELECT_TO_FIELD.get(ident)
|
||||
|
|
@ -755,9 +775,16 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
# Réglage d'UNE rangée.
|
||||
if ident.startswith("v") and "_" in ident:
|
||||
rang, champ = ident[1:].split("_", 1)
|
||||
if not rang.isdigit() or champ not in RES_FIELDS:
|
||||
if not rang.isdigit():
|
||||
return
|
||||
index = int(rang)
|
||||
if index >= len(self.rows):
|
||||
return
|
||||
if self.extras_on_row_select(event, index, champ):
|
||||
self._refresh_after()
|
||||
return
|
||||
if champ not in RES_FIELDS:
|
||||
return
|
||||
if event.value is FREE:
|
||||
self._row_free(index, champ, True)
|
||||
self._set_override(
|
||||
|
|
|
|||
|
|
@ -648,6 +648,56 @@ class ProxmoxMenuMixin:
|
|||
parts = (sortie or "").split()
|
||||
return parts[parts.index("dev") + 1] if "dev" in parts else ""
|
||||
|
||||
def _pve_nat_ready(self, host):
|
||||
"""(prêt ?, lignes à dire). La table NAT existe-t-elle sur cet hôte ?
|
||||
|
||||
Posée ICI, au moment d'écrire un pont NAT, et non à la connexion : le
|
||||
noyau est vérifié quand on confirme l'hôte, mais l'hôte est ensuite
|
||||
MÉMORISÉ — on revient des jours plus tard créer un pont, et plus
|
||||
personne ne rappelle rien. Le garde doit être là où la conséquence
|
||||
tombe.
|
||||
|
||||
Sans cette question, ifupdown2 rendait six lignes d'iptables et « code
|
||||
de retour 1 », après avoir déjà écrit la strophe dans
|
||||
/etc/network/interfaces. Rien dans ce bruit ne dit qu'il faut
|
||||
redémarrer.
|
||||
|
||||
Le cas n'a rien d'exotique : notre propre install_proxmox.sh pose le
|
||||
noyau Proxmox sans redémarrer — lancé par ssh, un reboot couperait la
|
||||
session. Une Proxmox imbriquée fraîchement installée est donc TOUJOURS
|
||||
dans cet état, sur le noyau cloud de Debian, qui est dépouillé de tout
|
||||
netfilter."""
|
||||
from script.proxmox import proxmox_deploy as pve
|
||||
|
||||
_c, out = pve.run(host, pve.NAT_CHECK_CMD, 40)
|
||||
etat = pve.parse_nat_check(out)
|
||||
if etat["nat"]:
|
||||
return True, []
|
||||
lignes = [
|
||||
f"✗ {t('No NAT table on this host: the bridge would lead nowhere.')}",
|
||||
f" {t('Running kernel:')} {etat['kernel'] or '?'}",
|
||||
]
|
||||
if etat["pve_kernel"]:
|
||||
lignes += [
|
||||
f" {t('The distribution kernel carries no netfilter module.')}",
|
||||
f" {t('Proxmox kernel installed:')} {etat['pve_kernel']}"
|
||||
f" — {t('taken at next boot')}",
|
||||
f"→ ssh {host.get('target', '')} sudo reboot,"
|
||||
f" {t('then come back here.')}",
|
||||
]
|
||||
else:
|
||||
lignes.append(
|
||||
f" {t('No Proxmox kernel installed: finish the install first.')}"
|
||||
)
|
||||
return False, lignes
|
||||
|
||||
def _pve_nat_reason(self, host):
|
||||
"""La même chose en UNE ligne, pour l'écran Textual."""
|
||||
ok, lignes = self._pve_nat_ready(host)
|
||||
if ok:
|
||||
return ""
|
||||
return " ".join(ligne.strip("✗→ ") for ligne in lignes[:2])
|
||||
|
||||
def _pve_make_internal_bridge(self):
|
||||
"""Crée le pont INTERNE et le rend, ou ('', raison). SANS rien demander.
|
||||
|
||||
|
|
@ -662,6 +712,11 @@ class ProxmoxMenuMixin:
|
|||
host = self._pve_host(ask=False)
|
||||
if not host:
|
||||
return "", t("No Proxmox host.")
|
||||
# AVANT d'écrire quoi que ce soit : une strophe posée puis un
|
||||
# « ifup » qui échoue laisse le fichier modifié et le pont absent.
|
||||
raison = self._pve_nat_reason(host)
|
||||
if raison:
|
||||
return "", raison
|
||||
uplink = self._pve_uplink()
|
||||
for cmd in pve.bridge_setup_cmds(uplink=uplink):
|
||||
code, sortie = pve.run(host, cmd, 180)
|
||||
|
|
@ -704,6 +759,13 @@ class ProxmoxMenuMixin:
|
|||
f" ⚠ {t('This moves the host address: do it from a console.')}"
|
||||
)
|
||||
return ""
|
||||
host = self._pve_host(ask=False)
|
||||
ok, lignes = self._pve_nat_ready(host) if host else (True, [])
|
||||
if not ok:
|
||||
print()
|
||||
for ligne in lignes:
|
||||
print(f" {ligne}")
|
||||
return ""
|
||||
uplink = self._pve_uplink()
|
||||
print(f" {t('uplink for NAT')} : {uplink or t('none')}")
|
||||
for cmd in pve.bridge_setup_cmds(uplink=uplink):
|
||||
|
|
@ -1121,35 +1183,41 @@ class ProxmoxMenuMixin:
|
|||
fh.write("\n".join(entete) + "\n")
|
||||
return chemin
|
||||
|
||||
def _pve_alias_names(self, nom, chaine, locaux, rebond=""):
|
||||
"""UN seul nom pour l'entrée ~/.ssh/config, et lequel.
|
||||
def _pve_alias_names(self, nom, chaine, locaux=(), rebond=""):
|
||||
"""UN seul nom pour l'entrée ~/.ssh/config : « hôte+vm ».
|
||||
|
||||
Deux noms sur la même ligne « Host » — le court et le chaîné
|
||||
« hôte+vm » — étaient un doublon dès que le court était libre : ssh
|
||||
n'a besoin que d'un nom, et le second n'ajoutait qu'une façon de plus
|
||||
d'écrire la même adresse. Rapporté.
|
||||
Deux noms sur la même ligne « Host » — le chaîné et le court —
|
||||
étaient un doublon : ssh n'a besoin que d'un nom, et le second
|
||||
n'ajoutait qu'une façon de plus d'écrire la même adresse. Rapporté.
|
||||
|
||||
Le court quand il est LIBRE, c'est celui qu'on tape ; le chaîné
|
||||
sinon, car un nom déjà pris désigne une AUTRE machine — une VM locale
|
||||
du même nom, ou la VM d'un autre hôte Proxmox. Vécu : « ssh » partait
|
||||
vers la machine locale qui partageait le nom.
|
||||
Reste à choisir lequel, et c'est le chaîné. Prendre le nom court
|
||||
quand il se trouvait libre donnait un parc INCOHÉRENT : sur un même
|
||||
déploiement, deux VM recevaient « hôte+vm » — leurs noms étaient pris
|
||||
par des domaines locaux — et la troisième son nom court. Rapporté
|
||||
aussi. Une convention qui dépend de ce qui traîne dans le fichier
|
||||
n'est pas une convention.
|
||||
|
||||
« Pris » se juge sur le ProxyJump du bloc et non sur sa seule
|
||||
présence : notre propre entrée, réécrite à chaque déploiement, se
|
||||
serait autrement prise pour une rivale — et le nom aurait basculé
|
||||
d'une fois sur l'autre.
|
||||
Le chaîné est donc systématique. Il dit où la machine vit, il ne peut
|
||||
rien voler à un domaine local, et deux VM du même nom sur deux hôtes
|
||||
Proxmox différents se distinguent d'elles-mêmes.
|
||||
|
||||
Rend (noms, volé) — `volé` nomme ce qui a forcé le nom chaîné, pour
|
||||
que l'appelant le dise plutôt que de laisser la surprise."""
|
||||
if nom in locaux:
|
||||
return [chaine], t("a local VM")
|
||||
Rend (noms, volé) — la seconde valeur reste pour l'appelant, qui
|
||||
signale au passage un nom qu'une VM locale porte aussi."""
|
||||
return [chaine], (t("a local VM") if nom in locaux else "")
|
||||
|
||||
def _pve_alias_perime(self, nom, rebond):
|
||||
"""Le nom court à RETIRER, s'il désigne encore cette VM-ci.
|
||||
|
||||
La convention a changé — le nom court d'abord, puis « hôte+vm » — et
|
||||
rien ne retirerait l'ancien bloc : il ne porte pas le nom qu'on
|
||||
écrit. Deux entrées mèneraient alors à la même machine, ce qu'on
|
||||
venait justement d'enlever.
|
||||
|
||||
Le ProxyJump tranche : un bloc qui rebondit par CET hôte est le nôtre.
|
||||
Celui d'une VM locale homonyme n'en a pas, et on n'y touche donc
|
||||
jamais."""
|
||||
bloc = self._ssh_config_block(nom)
|
||||
notre = (
|
||||
not bloc
|
||||
or chaine in bloc.get("names", ())
|
||||
or (rebond and bloc.get("proxyjump") == rebond)
|
||||
)
|
||||
return ([nom], "") if notre else ([chaine], "~/.ssh/config")
|
||||
return [nom] if bloc and bloc.get("proxyjump") == rebond else []
|
||||
|
||||
def _pve_set_timezone(self, cible, spec):
|
||||
"""Pose le fuseau DANS la VM, par ssh.
|
||||
|
|
@ -1286,6 +1354,22 @@ class ProxmoxMenuMixin:
|
|||
)
|
||||
if vm.get("alias"):
|
||||
print(f" ssh {vm['alias']}")
|
||||
# Une VM qui vient de recevoir Proxmox tourne encore le noyau de
|
||||
# son image cloud : celui-ci n'a AUCUN module netfilter, donc ni
|
||||
# pont NAT ni VM à l'intérieur.
|
||||
#
|
||||
# Le suivi s'en charge : son enveloppe tourne sur NOTRE machine,
|
||||
# donc elle survit au redémarrage de la VM, l'attend et vérifie le
|
||||
# noyau avant de conclure. La note ne sert donc QUE sans suivi —
|
||||
# la voie en série, elle, s'arrête à la fin du script. L'afficher
|
||||
# dans les deux cas demanderait un redémarrage déjà fait.
|
||||
if not spec.get("monitor", True) and self._pve_installs_proxmox(
|
||||
vm, spec
|
||||
):
|
||||
print(
|
||||
f" ⚠ {t('reboot it to boot the Proxmox kernel:')}"
|
||||
f" ssh {vm.get('alias') or vm['name']} sudo reboot"
|
||||
)
|
||||
if spec.get("install"):
|
||||
print(
|
||||
f" {t('Install:')} {spec['install'].get('label') or ''}"
|
||||
|
|
@ -1293,6 +1377,16 @@ class ProxmoxMenuMixin:
|
|||
)
|
||||
print(f" {t('Log:')} {session}")
|
||||
|
||||
@staticmethod
|
||||
def _pve_installs_proxmox(vm, spec) -> bool:
|
||||
"""Cette VM reçoit-elle l'hyperviseur Proxmox VE ?
|
||||
|
||||
Jugé sur la commande EFFECTIVE de la VM — celle que son système lui
|
||||
impose, sinon le choix commun — et non sur son nom ni sur sa
|
||||
distribution : un parc mixte est le cas normal ici."""
|
||||
cmd = vm.get("install_cmd") or (spec.get("install") or {}).get("cmd")
|
||||
return "install_proxmox.sh" in (cmd or "")
|
||||
|
||||
def _pve_confirm_spec(self, host, spec):
|
||||
"""Récapitulatif puis confirmation, dans le TERMINAL.
|
||||
|
||||
|
|
@ -1392,6 +1486,9 @@ class ProxmoxMenuMixin:
|
|||
ip,
|
||||
identity_file=self._ssh_private_key(cle_locale),
|
||||
proxy_jump=host["target"],
|
||||
also_drop=self._pve_alias_perime(
|
||||
vm["name"], host["target"]
|
||||
),
|
||||
)
|
||||
alias[vm["name"]] = noms_alias[0]
|
||||
print(f" ✓ ~/.ssh/config : ssh {noms_alias[0]}")
|
||||
|
|
@ -1427,6 +1524,21 @@ class ProxmoxMenuMixin:
|
|||
}
|
||||
finale = cartes if self._qemu_per_vm(cartes, commun) else commun
|
||||
branche = (install or {}).get("branch") or ""
|
||||
# Même règle pour la branche et pour le type de VM : depuis que le
|
||||
# plan les porte PAR RANGÉE, lire la seule valeur commune revenait à
|
||||
# jeter le choix. Un parc mixte — un hyperviseur imbriqué à côté de VM
|
||||
# ERPLibre — est justement ce qu'on déploie ici le plus souvent.
|
||||
branches_vm = {
|
||||
vm["name"]: (vm.get("branch") or branche) for vm in joignables
|
||||
}
|
||||
if self._qemu_per_vm(branches_vm, branche):
|
||||
branche = branches_vm
|
||||
bureau = spec.get("desktop") or ""
|
||||
bureaux = {
|
||||
vm["name"]: (vm.get("desktop") or bureau) for vm in joignables
|
||||
}
|
||||
if self._qemu_per_vm(bureaux, bureau):
|
||||
bureau = bureaux
|
||||
if suivi:
|
||||
# Rien à installer ? La commande distante regarde alors la VM
|
||||
# ARRIVER (cloud-init, puis relevé système) : c'est ce que le
|
||||
|
|
@ -1458,7 +1570,7 @@ class ProxmoxMenuMixin:
|
|||
# Les réglages du système invité, qui n'atteignaient pas la
|
||||
# commande distante : la VM naissait serveur nu, sans outils.
|
||||
prod=bool(spec.get("prod")),
|
||||
desktop=spec.get("desktop") or "",
|
||||
desktop=bureau,
|
||||
python_provider=spec.get("python_provider") or "",
|
||||
app_store=spec.get("app_store") or "deb",
|
||||
vm_tools=spec.get("vm_tools") or (),
|
||||
|
|
@ -1477,16 +1589,17 @@ class ProxmoxMenuMixin:
|
|||
return resultat
|
||||
# Sans suivi mais avec quelque chose à installer : en série, sortie à
|
||||
# l'écran. C'est le pendant exact de la voie QEMU/KVM.
|
||||
print(f"\n{t('Installing ERPLibre on each VM')} ({branche})…")
|
||||
etiquette = branche if isinstance(branche, str) else t("per VM")
|
||||
print(f"\n{t('Installing ERPLibre on each VM')} ({etiquette})…")
|
||||
for vm in joignables:
|
||||
self._qemu_install_erplibre_vm(
|
||||
vm["name"],
|
||||
cle_locale,
|
||||
branche,
|
||||
branches_vm.get(vm["name"], ""),
|
||||
alias.get(vm["name"], vm["name"]),
|
||||
vm.get("install_cmd") or commun,
|
||||
bool(spec.get("prod")),
|
||||
desktop=spec.get("desktop") or "",
|
||||
desktop=bureaux.get(vm["name"], ""),
|
||||
python_provider=spec.get("python_provider") or "",
|
||||
app_store=spec.get("app_store") or "deb",
|
||||
vm_tools=spec.get("vm_tools") or (),
|
||||
|
|
@ -1705,6 +1818,7 @@ class ProxmoxMenuMixin:
|
|||
ip,
|
||||
identity_file=cle,
|
||||
proxy_jump=host["target"],
|
||||
also_drop=self._pve_alias_perime(vm["name"], host["target"]),
|
||||
)
|
||||
print(
|
||||
f" ✓ ssh {noms[0]} ({ip} {t('through')} {host['target']})"
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401
|
|||
|
||||
# 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 ExtrasMixin
|
||||
from script.todo.deploy_form_extras import SERVER, ExtrasMixin
|
||||
from script.todo.deploy_form_plan import ( # noqa: F401
|
||||
PlanMixin,
|
||||
preview_screen,
|
||||
|
|
@ -128,8 +128,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
total_disk = ctx.get("total_disk") or 0
|
||||
base_vcpus = ctx.get("base_vcpus") or 2
|
||||
extra_disk = ctx.get("extra_disk_gb") or 0
|
||||
# [(clé, libellé)] — la liste vient de todo.py, source unique.
|
||||
desktops = list(ctx.get("desktops") or [])
|
||||
# {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les
|
||||
# saveurs — on ne le redéfinit pas ici.
|
||||
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
|
||||
|
|
@ -137,9 +135,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
result = {"spec": None}
|
||||
|
||||
AUTO = "__auto__"
|
||||
# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le
|
||||
# rendrait indistinguable de la sentinelle « rien de sélectionné ».
|
||||
SERVER = "__server__"
|
||||
|
||||
def entry_label(e):
|
||||
star = " *" if e.get("default") else ""
|
||||
|
|
@ -194,7 +189,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self._syncing = False
|
||||
# Jeu de VM actuellement monté dans le panneau droit.
|
||||
self._shown_ids = ()
|
||||
self.extras_init(ctx)
|
||||
self.extras_init(ctx, branches, profiles)
|
||||
# Génération du jeu de rangées monté. Les identifiants de widgets
|
||||
# portent un RANG, et le rang se décale quand on coche ou décoche
|
||||
# une entrée : un événement émis par un widget déjà détruit
|
||||
|
|
@ -518,48 +513,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self._render_plan()
|
||||
self.render_extras()
|
||||
|
||||
def _profile_cmd(self):
|
||||
"""Commande du profil choisi en haut : le défaut de chaque VM."""
|
||||
if not profiles:
|
||||
return ""
|
||||
index = self.query_one("#f_profile_install", Select).value
|
||||
return profiles[index if isinstance(index, int) else 0][1]
|
||||
|
||||
def _row_default_cmd(self, i):
|
||||
"""Commande qu'une rangée prend d'elle-même : celle que son
|
||||
système impose, sinon le choix commun d'en haut.
|
||||
|
||||
C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM
|
||||
Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est
|
||||
aussi la valeur commune."""
|
||||
if i < len(self.rows):
|
||||
impose = distro_profiles.get(self.rows[i]["vm"]["distro"])
|
||||
if impose:
|
||||
return impose[1]
|
||||
return self._profile_cmd()
|
||||
|
||||
def _row_profile_index(self, i):
|
||||
"""Rang du profil que la rangée doit AFFICHER."""
|
||||
cmd = ""
|
||||
if i < len(self.rows):
|
||||
cmd = self.rows[i]["vm"].get("install_cmd") or ""
|
||||
cmd = cmd or self._row_default_cmd(i)
|
||||
for k, (_lbl, c) in enumerate(profiles):
|
||||
if c == cmd:
|
||||
return k
|
||||
return 0
|
||||
|
||||
def _branch(self):
|
||||
"""Branche du formulaire : le défaut de chaque VM."""
|
||||
value = self.query_one("#f_branch", Select).value
|
||||
return value if isinstance(value, str) else branches[0]
|
||||
|
||||
# -- panneau droit : une rangée de widgets par VM ---------------- #
|
||||
def _type_options(self):
|
||||
return [(t("Server"), SERVER)] + [
|
||||
(label, key) for key, label in desktops
|
||||
]
|
||||
|
||||
def _mount_rows(self) -> None:
|
||||
"""(Re)construit le panneau droit.
|
||||
|
||||
|
|
@ -610,40 +564,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
if item.get("instance")
|
||||
else Static("", classes="vmcopy")
|
||||
),
|
||||
Select(
|
||||
[(b, b) for b in branches],
|
||||
classes="vmbranch",
|
||||
# Repli sur la branche du FORMULAIRE, jamais sur
|
||||
# branches[0] : les rangées sont remontées dès que le
|
||||
# jeu de VM change (une entrée cochée, une copie
|
||||
# ajoutée, un renommage), et elles retombaient alors
|
||||
# toutes sur « develop » quel que soit le choix commun.
|
||||
value=(
|
||||
self.rows[i]["vm"].get("branch")
|
||||
if i < len(self.rows)
|
||||
else ""
|
||||
)
|
||||
or self._branch(),
|
||||
allow_blank=False,
|
||||
id=f"v{i}_branch",
|
||||
),
|
||||
(
|
||||
Select(
|
||||
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
|
||||
value=self._row_profile_index(i),
|
||||
allow_blank=False,
|
||||
classes="vmprof",
|
||||
id=f"v{i}_prof",
|
||||
)
|
||||
if profiles
|
||||
else Static("", classes="vmprof")
|
||||
),
|
||||
Select(
|
||||
self._type_options(),
|
||||
value=vm.get("desktop") or SERVER,
|
||||
allow_blank=False,
|
||||
id=f"v{i}_type",
|
||||
),
|
||||
*self.install_row_widgets(i, SELECT_NULL),
|
||||
classes="vmrow",
|
||||
)
|
||||
widgets.append(
|
||||
|
|
@ -854,63 +775,18 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
# profil donne déjà n'enregistre pas de surcharge. La VM
|
||||
# suivra donc le profil s'il change — ce qui est aussi le plus
|
||||
# attendu quand on n'a rien changé de visible.
|
||||
vm_now = self.rows[index]["vm"]
|
||||
if field == "prof":
|
||||
label, cmd = profiles[event.value]
|
||||
if cmd == (
|
||||
vm_now.get("install_cmd")
|
||||
or self._row_default_cmd(index)
|
||||
):
|
||||
return
|
||||
same = cmd == self._row_default_cmd(index)
|
||||
self._set_override(
|
||||
index, "install_cmd", "" if same else cmd
|
||||
)
|
||||
self._set_override(
|
||||
index, "install_label", "" if same else label
|
||||
)
|
||||
self._recompute()
|
||||
if self.extras_on_row_select(event, index, field):
|
||||
return
|
||||
if field == "branch":
|
||||
# « la branche du formulaire » n'est pas une surcharge :
|
||||
# la VM doit suivre si on la change en haut.
|
||||
current = vm_now.get("branch") or self._branch()
|
||||
if event.value == current:
|
||||
return
|
||||
self._set_override(
|
||||
index,
|
||||
"branch",
|
||||
"" if event.value == self._branch() else event.value,
|
||||
)
|
||||
self._recompute()
|
||||
return
|
||||
if field == "type":
|
||||
new_desk = "" if event.value == SERVER else event.value
|
||||
if new_desk == (vm_now.get("desktop") or ""):
|
||||
return
|
||||
elif (
|
||||
event.value is not FREE and event.value is not SELECT_NULL
|
||||
):
|
||||
if self._row_echo(index, field, event.value):
|
||||
return
|
||||
if field == "type":
|
||||
self._set_override(
|
||||
index,
|
||||
"desktop",
|
||||
"" if event.value == SERVER else event.value,
|
||||
)
|
||||
# « Serveur » est un choix légitime, pas un retrait : on le
|
||||
# note explicitement pour qu'il tienne face au défaut.
|
||||
if event.value == SERVER:
|
||||
key = self._row_key(index)
|
||||
if key is not None:
|
||||
self.overrides.setdefault(key, {})["desktop"] = ""
|
||||
elif event.value is FREE:
|
||||
# Ne restent ici que les RESSOURCES : elles n'ont pas de
|
||||
# défaut à comparer, mais une saisie libre à révéler.
|
||||
if event.value is FREE:
|
||||
self._row_free(index, field, True)
|
||||
self._set_override(
|
||||
index, field, self._read_row_free(index, field)
|
||||
)
|
||||
elif event.value is not SELECT_NULL:
|
||||
if self._row_echo(index, field, event.value):
|
||||
return
|
||||
self._row_free(index, field, False)
|
||||
self._set_override(index, field, event.value)
|
||||
self._recompute()
|
||||
|
|
|
|||
|
|
@ -33,6 +33,36 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
|
||||
|
||||
EXIT_MARKER = "__ERPLIBRE_EXIT__"
|
||||
|
||||
# Installations qui posent un NOYAU : elles ne valent rien avant un
|
||||
# redémarrage, et le script ne peut pas survivre au sien. La table dit quoi
|
||||
# attendre APRÈS — un motif à trouver dans « uname -r », donc une preuve et
|
||||
# non une supposition.
|
||||
#
|
||||
# Proxmox VE en est le seul cas aujourd'hui, et il est systématique : notre
|
||||
# install_proxmox.sh pose proxmox-default-kernel sans redémarrer — lancé par
|
||||
# ssh, un reboot couperait la session et ferait passer l'installation pour un
|
||||
# échec. La VM restait donc sur le noyau cloud de Debian, dépouillé de tout
|
||||
# netfilter : ni pont NAT, ni invité. On le découvrait des jours plus tard.
|
||||
REBOOT_AFTER = (("install_proxmox.sh", "-pve"),)
|
||||
|
||||
# Attente maximale du retour de la machine, en tours de cinq secondes.
|
||||
# Généreuse : un hyperviseur imbriqué redémarre lentement, et échouer trop
|
||||
# tôt marquerait rouge une installation qui a réussi.
|
||||
REBOOT_TOURS = 180
|
||||
|
||||
|
||||
def reboot_expected(remote_cmd) -> str:
|
||||
"""Motif à trouver dans « uname -r » après redémarrage, ou "".
|
||||
|
||||
Jugé sur la COMMANDE effective de la VM, pas sur sa distribution : un parc
|
||||
mixte est le cas normal, et c'est ce qu'on installe qui décide."""
|
||||
for marque, motif in REBOOT_AFTER:
|
||||
if marque in (remote_cmd or ""):
|
||||
return motif
|
||||
return ""
|
||||
|
||||
|
||||
SSH_OPTS = (
|
||||
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
||||
"-o ConnectTimeout=8"
|
||||
|
|
@ -79,6 +109,56 @@ def list_install_runs() -> list:
|
|||
return runs
|
||||
|
||||
|
||||
def _reboot_steps(log_q: str, motif: str, tours: int = REBOOT_TOURS) -> str:
|
||||
"""Shell qui redémarre la VM, attend son retour, et vérifie son noyau.
|
||||
|
||||
Trois choses valent d'être dites.
|
||||
|
||||
Le redémarrage n'a lieu QUE si l'installation a réussi : redémarrer après
|
||||
un échec effacerait la seule machine sur laquelle on pouvait chercher.
|
||||
|
||||
On n'attend pas que ssh « revienne » — sshd répond encore une seconde ou
|
||||
deux après l'ordre de redémarrage, et on lirait alors l'ANCIEN noyau en
|
||||
croyant avoir la réponse. On attend que « uname -r » porte le motif ; tant
|
||||
qu'il porte l'ancien, la machine n'est pas revenue.
|
||||
|
||||
`tours` est un paramètre pour que ce shell soit ÉPROUVABLE : un garde
|
||||
qu'on ne sait pas exécuter s'ouvre le jour où il casse.
|
||||
|
||||
Et l'échec est un vrai échec : sans le noyau attendu, l'hyperviseur n'a ni
|
||||
table NAT ni module bridge. Le dire ✅ serait le mensonge qui a coûté deux
|
||||
jours à le comprendre."""
|
||||
msg_reboot = t("Rebooting to boot the new kernel")
|
||||
msg_wait = t("waiting for the machine to come back")
|
||||
msg_ok = t("kernel booted:")
|
||||
msg_ko = t("the machine did not come back on the expected kernel:")
|
||||
return (
|
||||
'if [ "$rc" = 0 ]; then '
|
||||
f"echo {shlex.quote('== ' + msg_reboot + ' ==')} >> {log_q}; "
|
||||
# « || true » : la session MEURT avec le redémarrage, et son code 255
|
||||
# ne dit rien de l'ordre lui-même.
|
||||
f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" '
|
||||
# « sudo -n » : cette enveloppe tourne DÉTACHÉE, sans terminal. Un
|
||||
# sudo qui demande son mot de passe échoue alors tout de suite au lieu
|
||||
# d'attendre une frappe que personne ne fera.
|
||||
"'sudo -n systemctl reboot' "
|
||||
f">> {log_q} 2>&1 || true; "
|
||||
f"echo {shlex.quote(' ' + msg_wait)} >> {log_q}; "
|
||||
"krn=''; "
|
||||
f"for i in $(seq 1 {tours}); do sleep ${{ERPLIBRE_REBOOT_SLEEP:-5}}; "
|
||||
f'k=$(ssh {SSH_OPTS_BATCH} -o BatchMode=yes "erplibre@$ip" '
|
||||
"'uname -r' 2>/dev/null); "
|
||||
f'case "$k" in *{motif}*) krn="$k"; break;; esac; '
|
||||
"if [ $((i % 6)) -eq 0 ]; then "
|
||||
f'echo " ... $((i*5))s" >> {log_q}; fi; '
|
||||
"done; "
|
||||
'if [ -n "$krn" ]; then '
|
||||
f'echo " {msg_ok} $krn" >> {log_q}; '
|
||||
f'else echo " ⚠ {msg_ko} {motif}" >> {log_q}; rc=1; fi; '
|
||||
"fi; "
|
||||
)
|
||||
|
||||
|
||||
def _launch_one(
|
||||
ip: str,
|
||||
remote_cmd: str,
|
||||
|
|
@ -86,10 +166,17 @@ def _launch_one(
|
|||
name: str = "",
|
||||
installs: bool = True,
|
||||
pve: bool = False,
|
||||
reboot: str = "",
|
||||
) -> None:
|
||||
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
|
||||
la sortie puis écrit le marqueur de fin avec le code de sortie.
|
||||
|
||||
`reboot` : motif attendu dans « uname -r » APRÈS un redémarrage. Non
|
||||
vide, l'installation réussie est suivie d'un reboot, de l'attente du
|
||||
retour, et d'une vérification du noyau — le succès n'est écrit qu'ensuite.
|
||||
C'est ici et non dans la VM parce qu'un script ne survit pas à son propre
|
||||
redémarrage : cette enveloppe, elle, tourne sur NOTRE machine.
|
||||
|
||||
`pve` : la VM vit sur un hôte Proxmox. On ne RÉ-RÉSOUT alors PAS son
|
||||
adresse par virsh — et c'est vital. Vécu le 24 août 2026 : une VM
|
||||
« erplibre-ubuntu-2604 » déployée sur Proxmox portait le nom d'un domaine
|
||||
|
|
@ -225,8 +312,9 @@ def _launch_one(
|
|||
f"else echo {shlex.quote('== ' + msg_giveup + ' ==')} >> {log_q}; fi; "
|
||||
f'echo " → $ip" >> {log_q}; '
|
||||
f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" {shlex.quote(remote_cmd)} '
|
||||
f">> {log_q} 2>&1; "
|
||||
f'echo "{EXIT_MARKER} $?" >> {log_q}'
|
||||
f">> {log_q} 2>&1; rc=$?; "
|
||||
+ (_reboot_steps(log_q, reboot) if reboot else "")
|
||||
+ f'echo "{EXIT_MARKER} $rc" >> {log_q}'
|
||||
)
|
||||
# setsid -f : le process survit à la fermeture du menu / du dashboard.
|
||||
# stdin sur /dev/null : SANS lui, le descripteur 0 du processus détaché
|
||||
|
|
@ -293,13 +381,17 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
|||
# ou bureau) se choisit machine par machine, le script distant n'est
|
||||
# plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse
|
||||
# intacts les appelants qui n'en fournissent qu'une.
|
||||
cmd_vm = vm.get("remote_cmd") or remote_cmd
|
||||
_launch_one(
|
||||
vm["ip"],
|
||||
vm.get("remote_cmd") or remote_cmd,
|
||||
cmd_vm,
|
||||
log_path,
|
||||
vm["name"],
|
||||
installs=bool(branch),
|
||||
pve=bool(vm.get("pve")),
|
||||
# Une installation qui pose un NOYAU ne vaut rien avant le
|
||||
# redémarrage : l'enveloppe s'en charge et ne conclut qu'après.
|
||||
reboot=reboot_expected(cmd_vm),
|
||||
)
|
||||
entree = {
|
||||
"name": vm["name"],
|
||||
|
|
@ -313,6 +405,12 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
|||
# Une VM posée sur un hôte Proxmox : c'est LUI qui connaît son état.
|
||||
if vm.get("pve"):
|
||||
entree["pve"] = vm["pve"]
|
||||
else:
|
||||
# L'UUID du domaine, relevé MAINTENANT : c'est le seul instant où
|
||||
# l'on sait que ce nom désigne bien cette machine. Rouvert des
|
||||
# semaines plus tard, le suivi ne peut plus le savoir — et c'est
|
||||
# lui qui arme le garde de la suppression.
|
||||
entree["uuid"] = local_uuid(vm["name"])
|
||||
entries.append(entree)
|
||||
manifest = {
|
||||
"branch": branch,
|
||||
|
|
@ -324,6 +422,26 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
|||
return manifest_path
|
||||
|
||||
|
||||
def local_uuid(name: str) -> str:
|
||||
"""UUID du domaine libvirt local, ou "" s'il est illisible.
|
||||
|
||||
Sans sudo d'abord : l'appartenance au groupe libvirt suffit souvent. Une
|
||||
chaîne vide DÉSARME le garde plutôt que de bloquer — mieux vaut la
|
||||
protection d'avant que refuser toute suppression sur un poste où virsh
|
||||
demande un mot de passe."""
|
||||
base = ["virsh", "--connect", "qemu:///system", "domuuid", name]
|
||||
for argv in (base, ["sudo", "-n"] + base):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
argv, capture_output=True, text=True, timeout=15
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if res.returncode == 0 and res.stdout.strip():
|
||||
return res.stdout.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def finished_at(log_path: str, fallback: float) -> float:
|
||||
"""Instant où l'installation s'est RÉELLEMENT arrêtée.
|
||||
|
||||
|
|
@ -1474,6 +1592,25 @@ def read_pvestats(vms, now=None) -> dict:
|
|||
return _read_pvestats(vms, now)[0]
|
||||
|
||||
|
||||
def drop_local_twins(stats, vms) -> dict:
|
||||
"""Retire des relevés LOCAUX ceux d'une VM qui vit ailleurs.
|
||||
|
||||
« virsh domstats » indexe par NOM, et un nom se partage : une VM posée
|
||||
sur un Proxmox distant héritait des chiffres du domaine local homonyme.
|
||||
Vécu sur trois VM — « erplibre-ubuntu-2604 » affichait 1,5 Gio de RAM sur
|
||||
12 et 58 Gio de disque sur 65, tout cela appartenant à la machine locale
|
||||
du même nom, pendant que la vraie tournait avec 3 Gio et 25.
|
||||
|
||||
Retirés AVANT d'ajouter ceux de l'hôte : ainsi un hôte muet laisse la
|
||||
colonne VIDE — ce qui est vrai — au lieu de la remplir avec la mauvaise
|
||||
machine. Une colonne vide se remarque ; une colonne juste et fausse, non.
|
||||
"""
|
||||
for vm in vms or ():
|
||||
if vm.get("pve"):
|
||||
stats.pop(vm.get("name"), None)
|
||||
return stats
|
||||
|
||||
|
||||
def _read_pvestats(vms, now=None):
|
||||
"""({nom: relevé}, succès). Un appel par hôte, mis en cache
|
||||
PVE_STATS_INTERVAL secondes.
|
||||
|
|
@ -1521,17 +1658,25 @@ def _read_pvestats(vms, now=None):
|
|||
== target
|
||||
)
|
||||
]
|
||||
code, sortie = pve.run(
|
||||
_code, sortie = pve.run(
|
||||
{"target": target, "sudo": sudo, "jump": info.get("jump", "")},
|
||||
pve_stats_cmd(siennes),
|
||||
40,
|
||||
)
|
||||
# « code == 0 » ne suffit PAS : la commande est une SUITE
|
||||
# (pvesh ; echo ; du ; echo ; boucle), et son code est celui du DERNIER
|
||||
# maillon. Un pvesh en panne rendait donc « l'hôte a répondu, la VM
|
||||
# n'y est plus » — et trois tours plus tard, la poubelle. Ce qui prouve
|
||||
# une réponse, c'est une LISTE de ressources analysable.
|
||||
if code == 0 and _resources_parsable(sortie):
|
||||
# Le code de sortie ne prouve RIEN, dans AUCUN sens. La commande
|
||||
# est une SUITE (pvesh ; echo ; du ; echo ; boucle) et son code est
|
||||
# celui du DERNIER maillon — la sonde Odoo. Un pvesh en panne rendait
|
||||
# donc 0, « l'hôte a répondu, la VM n'y est plus », et trois tours
|
||||
# plus tard la poubelle ; c'est ce qu'on avait corrigé. Mais
|
||||
# l'exiger à 0 était l'erreur SYMÉTRIQUE : tant qu'Odoo n'écoute pas
|
||||
# — c'est-à-dire pendant TOUTE l'installation, précisément quand on
|
||||
# regarde — la boucle finit en échec et le relevé, parfait, était
|
||||
# jeté. Mesuré sur trois VM : colonnes vides côté Proxmox, et les
|
||||
# lignes qui avaient un homonyme LOCAL affichaient ses chiffres.
|
||||
#
|
||||
# Ce qui prouve une réponse, c'est une LISTE de ressources
|
||||
# analysable. Rien d'autre, et surtout pas le code.
|
||||
if _resources_parsable(sortie):
|
||||
ok = True
|
||||
releves = parse_pvestats(sortie)
|
||||
ouverts = parse_odoo_probe(sortie)
|
||||
|
|
@ -1743,15 +1888,41 @@ def restart_odoo_cmd() -> str:
|
|||
)
|
||||
|
||||
|
||||
def delete_vm_cmd_pve(info, purge: bool = True) -> str:
|
||||
def pve_identity_guard(vmid: int, name: str) -> str:
|
||||
"""Shell qui S'ARRÊTE si le VMID ne porte plus ce nom.
|
||||
|
||||
Un VMID libéré est RÉATTRIBUÉ, et le suivi se rouvre sur un manifeste qui
|
||||
peut avoir des semaines : effacer « le 101 » d'un run de mars, c'est
|
||||
effacer ce qui porte le 101 aujourd'hui.
|
||||
|
||||
Une fonction à part, et exécutable telle quelle : c'est ce qui la rend
|
||||
vérifiable. Enfouie dans la commande, elle ne se testait qu'à travers deux
|
||||
« shlex.quote » — et un garde qu'on ne sait pas éprouver s'OUVRE le jour
|
||||
où il casse, au lieu de se fermer."""
|
||||
q = shlex.quote(name)
|
||||
return (
|
||||
f"vu=$(qm config {int(vmid)} 2>/dev/null"
|
||||
" | sed -n 's/^name: //p' | head -1); "
|
||||
f'if [ "$vu" != {q} ]; then '
|
||||
f'echo "REFUS : le VMID {int(vmid)} porte maintenant $vu,"'
|
||||
f' "et non {name}. Rien n\'a ete efface."; exit 1; fi; '
|
||||
)
|
||||
|
||||
|
||||
def delete_vm_cmd_pve(info, purge: bool = True, name: str = "") -> str:
|
||||
"""Efface une VM sur son hôte PROXMOX, par son VMID.
|
||||
|
||||
« virsh undefine <nom> » y aurait effacé le domaine LOCAL homonyme — le
|
||||
même piège que partout ailleurs, avec la pire conséquence."""
|
||||
même piège que partout ailleurs, avec la pire conséquence.
|
||||
|
||||
`name` arme le garde d'identité (voir `pve_identity_guard`) : sans lui, la
|
||||
commande efface le VMID quoi qu'il porte aujourd'hui."""
|
||||
vmid = int((info or {}).get("vmid") or 0)
|
||||
suite = (
|
||||
suite = pve_identity_guard(vmid, name) if name else ""
|
||||
suite += (
|
||||
f"qm stop {vmid} --skiplock 1 || true; "
|
||||
f"qm destroy {vmid}{' --purge 1 --destroy-unreferenced-disks 1' if purge else ''}"
|
||||
f"qm destroy {vmid}"
|
||||
f"{' --purge 1 --destroy-unreferenced-disks 1' if purge else ''}"
|
||||
)
|
||||
return pve_host_cmd(info, suite)
|
||||
|
||||
|
|
@ -1789,12 +1960,26 @@ def delete_lines(vm) -> list:
|
|||
]
|
||||
|
||||
|
||||
def delete_vm_cmd(name: str, with_disks: bool) -> str:
|
||||
def delete_vm_cmd(name: str, with_disks: bool, uuid: str = "") -> str:
|
||||
"""Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » :
|
||||
arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les
|
||||
disques à la demande."""
|
||||
disques à la demande.
|
||||
|
||||
`uuid` arme un GARDE. Le suivi se rouvre sur un manifeste passé, et un nom
|
||||
de domaine se réemploie : « erplibre-ubuntu-2604 » d'un run de mars n'est
|
||||
pas forcément celui d'aujourd'hui. L'UUID, lui, naît avec le domaine et
|
||||
meurt avec lui — c'est la seule chose qui distingue deux machines du même
|
||||
nom."""
|
||||
q = shlex.quote(name)
|
||||
cmd = (
|
||||
cmd = ""
|
||||
if uuid:
|
||||
cmd = (
|
||||
f"vu=$(sudo virsh domuuid {q} 2>/dev/null | tr -d '[:space:]'); "
|
||||
f'if [ "$vu" != {shlex.quote(uuid)} ]; then '
|
||||
f'echo "REFUS : {name} n\'est plus le même domaine"'
|
||||
f' "($vu). Rien n\'a été effacé."; exit 1; fi; '
|
||||
)
|
||||
cmd += (
|
||||
f"sudo virsh destroy {q} 2>/dev/null; "
|
||||
f"sudo virsh undefine {q} --nvram 2>/dev/null "
|
||||
f"|| sudo virsh undefine {q}"
|
||||
|
|
@ -2301,6 +2486,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
# calcule sur les relevés successifs, donc il faut échantillonner
|
||||
# à chaque tour (2 s) et non au rythme lent des états.
|
||||
stats = parse_domstats(read_domstats())
|
||||
drop_local_twins(stats, vms)
|
||||
# Les VM d'un hôte Proxmox distant : virsh ne les voit pas, leurs
|
||||
# colonnes restaient vides. Même forme de relevé, donc la suite ne
|
||||
# change pas d'un iota.
|
||||
|
|
@ -3081,10 +3267,13 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
if not yes:
|
||||
return
|
||||
info = vm.get("pve")
|
||||
# Le garde d'identité voyage avec la VM : c'est ce qui
|
||||
# rend une suppression sûre depuis un suivi ROUVERT, dont le
|
||||
# manifeste peut avoir des semaines.
|
||||
cmd = (
|
||||
delete_vm_cmd_pve(info)
|
||||
delete_vm_cmd_pve(info, name=vm["name"])
|
||||
if info
|
||||
else delete_vm_cmd(vm["name"], True)
|
||||
else delete_vm_cmd(vm["name"], True, vm.get("uuid") or "")
|
||||
)
|
||||
with self.suspend():
|
||||
print(f"\n=== Suppression — {vm['name']} ===")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,94 @@ import time
|
|||
from script.todo.todo_i18n import t
|
||||
|
||||
|
||||
def parse_ssh_blocks(content) -> dict:
|
||||
"""{nom: {"hostname": …, "proxyjump": …}} pour CHAQUE nom déclaré.
|
||||
|
||||
Une ligne « Host » peut en porter plusieurs : ils partagent alors le même
|
||||
corps, donc la même entrée. Les motifs (« * », « ? ») sont écartés — ce
|
||||
sont des règles, pas des machines."""
|
||||
blocs, courant = {}, []
|
||||
for ligne in (content or "").splitlines():
|
||||
if re.match(r"^[ \t]*Host[ \t]+", ligne):
|
||||
corps = {}
|
||||
courant = [
|
||||
n for n in ligne.split()[1:] if "*" not in n and "?" not in n
|
||||
]
|
||||
for nom in courant:
|
||||
blocs[nom] = corps
|
||||
continue
|
||||
if not courant:
|
||||
continue
|
||||
if ligne.strip() and not ligne[:1].isspace():
|
||||
courant = []
|
||||
continue
|
||||
mots = ligne.split()
|
||||
if len(mots) >= 2 and mots[0].lower() in ("hostname", "proxyjump"):
|
||||
blocs[courant[0]][mots[0].lower()] = mots[1]
|
||||
return blocs
|
||||
|
||||
|
||||
def ssh_orphans(blocs, juge, prefixe="erplibre-"):
|
||||
"""(gardées, orphelines) — chacune [(nom, raison)].
|
||||
|
||||
Un ProxyJump valait preuve de vie À LUI SEUL : « écrite pour une VM
|
||||
imbriquée, que virsh ne connaîtra jamais ». Le raisonnement oubliait que
|
||||
le rebond, lui, peut avoir disparu. Vécu : la VM Proxmox locale effacée,
|
||||
le nettoyage a retiré son entrée — correctement — et GARDÉ les trois
|
||||
entrées qui rebondissaient par elle. Trois culs-de-sac, présentés comme
|
||||
« mènent encore quelque part ».
|
||||
|
||||
D'où le point fixe : retirer un parent peut orpheliner ses enfants, et
|
||||
ceux-ci peuvent en orpheliner d'autres. On tourne jusqu'à ce que plus
|
||||
rien ne bouge.
|
||||
|
||||
`juge(nom)` ne rend que les preuves DIRECTES — un domaine vivant, une
|
||||
adresse qui mène à l'un d'eux, une VM de l'hôte Proxmox. Si le rebond
|
||||
comptait comme preuve directe, une chaîne de rebonds morts se soutiendrait
|
||||
toute seule."""
|
||||
noms = [n for n in blocs if n.startswith(prefixe)]
|
||||
raisons = {n: juge(n) for n in noms}
|
||||
|
||||
def rebond_vivant(saut):
|
||||
if saut in blocs:
|
||||
# Une entrée qu'on ne gère PAS — hôte personnel — n'est jamais
|
||||
# notre affaire : on la suppose vivante plutôt que d'effacer sur
|
||||
# une supposition.
|
||||
return (
|
||||
bool(raisons.get(saut)) if saut.startswith(prefixe) else True
|
||||
)
|
||||
# Plus AUCUNE entrée de ce nom. Deux lectures, et il faut les
|
||||
# séparer : une adresse ou un nom DNS, ssh saura le joindre et ce
|
||||
# n'est pas notre affaire ; un nom de NOTRE nommage, en revanche,
|
||||
# n'existe que par son entrée — celle-ci partie, le rebond ne mène
|
||||
# nulle part. C'est l'état exact laissé par un nettoyage précédent,
|
||||
# qui avait retiré le parent et gardé les enfants.
|
||||
if not saut.startswith(prefixe):
|
||||
return True
|
||||
return bool(juge(saut))
|
||||
|
||||
bouge = True
|
||||
while bouge:
|
||||
bouge = False
|
||||
for nom in noms:
|
||||
if raisons[nom]:
|
||||
continue
|
||||
saut = blocs[nom].get("proxyjump")
|
||||
if saut and rebond_vivant(saut):
|
||||
raisons[nom] = t("reached through a jump host")
|
||||
bouge = True
|
||||
gardes, orphelines = [], []
|
||||
for nom in noms:
|
||||
if raisons[nom]:
|
||||
gardes.append((nom, raisons[nom]))
|
||||
else:
|
||||
saut = blocs[nom].get("proxyjump")
|
||||
orphelines.append(
|
||||
(nom, f"{t('its jump host is gone:')} {saut}" if saut else "")
|
||||
)
|
||||
return gardes, orphelines
|
||||
|
||||
|
||||
class QemuManageMixin:
|
||||
"""Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici."""
|
||||
|
||||
|
|
@ -1978,9 +2066,13 @@ class QemuManageMixin:
|
|||
preuves valent mieux :
|
||||
|
||||
* son adresse est celle d'un domaine vivant ;
|
||||
* elle porte un ProxyJump, donc elle a été écrite pour une VM
|
||||
imbriquée ou distante, que virsh ne connaîtra jamais ;
|
||||
* son nom est celui d'une VM de l'hôte Proxmox retenu.
|
||||
|
||||
Le ProxyJump n'en fait PAS partie, et c'est le second défaut de cette
|
||||
fonction : il valait preuve à lui seul, sans qu'on regarde jamais si
|
||||
le rebond existait encore. Cette question-là se traite dans
|
||||
`ssh_orphans`, qui seule peut la poser — la réponse dépend des autres
|
||||
entrées, et de celles qu'on s'apprête à retirer.
|
||||
"""
|
||||
if nom in domains:
|
||||
return nom
|
||||
|
|
@ -1993,8 +2085,6 @@ class QemuManageMixin:
|
|||
ip = re.search(r"(?mi)^[ \t]*HostName[ \t]+(\S+)", corps)
|
||||
if ip and ip.group(1) in adresses:
|
||||
return adresses[ip.group(1)]
|
||||
if re.search(r"(?mi)^[ \t]*ProxyJump[ \t]+\S+", corps):
|
||||
return t("reached through a jump host")
|
||||
if nom in distantes:
|
||||
return t("a VM of the Proxmox host")
|
||||
return ""
|
||||
|
|
@ -2023,23 +2113,25 @@ class QemuManageMixin:
|
|||
}
|
||||
except Exception:
|
||||
pass
|
||||
hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content)
|
||||
orphans, gardes = [], []
|
||||
for h in hosts:
|
||||
if not h.startswith("erplibre-"):
|
||||
continue
|
||||
raison = self._ssh_entry_alive(
|
||||
gardes, orphelines = ssh_orphans(
|
||||
parse_ssh_blocks(content),
|
||||
lambda h: self._ssh_entry_alive(
|
||||
content, h, domains, adresses, distantes
|
||||
)
|
||||
(gardes if raison else orphans).append((h, raison))
|
||||
),
|
||||
)
|
||||
if gardes:
|
||||
print(f"\n{t('Kept (still leads somewhere):')}")
|
||||
for h, raison in gardes:
|
||||
print(f" {h} ← {raison}")
|
||||
orphans = [h for h, _r in orphans]
|
||||
if not orphans:
|
||||
if not orphelines:
|
||||
return
|
||||
print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}")
|
||||
print(f"\n{t('Orphan ~/.ssh/config entries:')}")
|
||||
# Avec la RAISON : « son rebond n'existe plus » explique pourquoi une
|
||||
# entrée qu'on croyait bonne s'en va, et c'est la seule chose qui
|
||||
# permet de répondre non en connaissance de cause.
|
||||
for h, raison in orphelines:
|
||||
print(f" {h}" + (f" ← {raison}" if raison else ""))
|
||||
orphans = [h for h, _r in orphelines]
|
||||
if not self._is_yes(
|
||||
input(t("Remove these ~/.ssh/config entries? (y/N): "))
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1430,13 +1430,24 @@ class TODO(
|
|||
return "".join(out)
|
||||
|
||||
def _write_ssh_config_entry(
|
||||
self, host, user, ip, proxy_jump=None, identity_file=None
|
||||
self,
|
||||
host,
|
||||
user,
|
||||
ip,
|
||||
proxy_jump=None,
|
||||
identity_file=None,
|
||||
also_drop=(),
|
||||
):
|
||||
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config.
|
||||
|
||||
`host` peut être une liste de noms : ils partagent alors un seul bloc.
|
||||
Sert aux VM imbriquées, joignables par leur nom court ET par leur nom
|
||||
chaîné « parent+enfant », qui montre où elles vivent.
|
||||
|
||||
`also_drop` : noms dont le bloc doit DISPARAÎTRE sans être réécrit.
|
||||
Sert quand une convention de nommage change : l'ancienne entrée ne
|
||||
désigne pas le nom qu'on écrit, donc rien ne la retirerait, et deux
|
||||
blocs finiraient par mener à la même machine — ce qu'on venait
|
||||
justement d'enlever. L'appelant vérifie que l'ancien bloc est BIEN le
|
||||
sien avant de le nommer ici.
|
||||
|
||||
`proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est
|
||||
joignable que depuis son hôte. OpenSSH enchaîne les ProxyJump tout
|
||||
|
|
@ -1452,7 +1463,9 @@ class TODO(
|
|||
if os.path.exists(cfg):
|
||||
with open(cfg, encoding="utf-8") as fh:
|
||||
existing = fh.read()
|
||||
existing = self._ssh_config_drop_hosts(existing, names).rstrip("\n")
|
||||
existing = self._ssh_config_drop_hosts(
|
||||
existing, names + [n for n in also_drop if n not in names]
|
||||
).rstrip("\n")
|
||||
block = (
|
||||
f"Host {' '.join(names)}\n"
|
||||
f" HostName {ip}\n"
|
||||
|
|
@ -1710,9 +1723,9 @@ class TODO(
|
|||
"""Le bloc « Host … » qui déclare `name`, ou {}.
|
||||
|
||||
Rend ses noms ET ses directives : savoir qu'un nom est pris ne suffit
|
||||
pas, il faut savoir PAR QUI. Un nom court déjà déclaré peut être notre
|
||||
propre entrée qu'on réécrit — auquel cas il n'y a rien de volé — ou
|
||||
celle d'une autre machine, et c'est le ProxyJump qui les distingue.
|
||||
pas, il faut savoir PAR QUI. Le ProxyJump distingue notre propre
|
||||
entrée — celle d'une VM derrière tel hôte — de celle d'une machine
|
||||
qui se trouve porter le même nom.
|
||||
|
||||
{"names": [...], "proxyjump": "...", "hostname": "..."}."""
|
||||
path = os.path.expanduser("~/.ssh/config")
|
||||
|
|
@ -3590,8 +3603,80 @@ class TODO(
|
|||
extra = None
|
||||
if analyse.get("asks_expect"):
|
||||
extra = ["--expect", self._monitoring_expect(kind)]
|
||||
if analyse.get("writes"):
|
||||
self._monitoring_write_flow(analyse, target)
|
||||
return
|
||||
monitoring.run_analysis(analyse, target, extra=extra)
|
||||
|
||||
def _monitoring_write_flow(self, analyse, database):
|
||||
"""La seule analyse qui écrit : montrer, puis demander.
|
||||
|
||||
On lance TOUJOURS la marche à blanc d'abord, et l'on demande
|
||||
ensuite. Une question posée avant de savoir ce qui sera touché
|
||||
n'est pas un consentement : c'est un pari. Le rapport dit combien
|
||||
de modèles et de colonnes, et lesquels sont traduits ou uniques.
|
||||
|
||||
La confirmation redemande le NOM de la base. Une frappe sur « o »
|
||||
se donne par réflexe ; recopier « chezlepro_neutralize_upgrade_18 »
|
||||
oblige à regarder ce qu'on détruit.
|
||||
"""
|
||||
from script.analyse import monitoring
|
||||
|
||||
choix = self._monitoring_anonymize_options()
|
||||
if choix is None:
|
||||
return
|
||||
print()
|
||||
if monitoring.run_analysis(analyse, database, extra=choix) == 2:
|
||||
return
|
||||
print()
|
||||
print(
|
||||
f"⚠️ {t('This DESTROYS the data of')} '{database}'"
|
||||
f" — {t('there is no undo.')}"
|
||||
)
|
||||
tape = input(
|
||||
f"💬 {t('Type the database name to confirm (empty to cancel): ')}"
|
||||
).strip()
|
||||
if tape != database:
|
||||
print(f"↩️ {t('Cancelled: nothing was written.')}")
|
||||
return
|
||||
monitoring.run_analysis(
|
||||
analyse, database, extra=choix + ["--apply", "--confirm", database]
|
||||
)
|
||||
|
||||
def _monitoring_anonymize_options(self):
|
||||
"""Le mode et ses listes, ou None si l'on renonce."""
|
||||
print()
|
||||
print(f"[1] {t('Hybrid: the default personal-data models, adjusted')}")
|
||||
print(f"[2] {t('Whitelist: only the models I name')}")
|
||||
print(f"[3] {t('Blacklist: every model except those I name')}")
|
||||
print(f"[0] {t('Back')}")
|
||||
answer = click.prompt(t("Command:"))
|
||||
print()
|
||||
mode = {"1": "hybrid", "2": "whitelist", "3": "blacklist"}.get(answer)
|
||||
if not mode:
|
||||
return None
|
||||
extra = ["--mode", mode]
|
||||
invite = (
|
||||
t("Models to ADD, comma separated (empty for none): ")
|
||||
if mode != "blacklist"
|
||||
else t("Models to EXCLUDE, comma separated: ")
|
||||
)
|
||||
noms = input(f"💬 {invite}").strip()
|
||||
if noms:
|
||||
extra += ["--exclude" if mode == "blacklist" else "--models", noms]
|
||||
elif mode == "whitelist":
|
||||
print(f"❌ {t('A whitelist with no model would do nothing.')}")
|
||||
return None
|
||||
mots = input(
|
||||
f"💬 {t('Python file declaring MOTS (empty for the built-in): ')}"
|
||||
).strip()
|
||||
if mots:
|
||||
if not os.path.isfile(os.path.expanduser(mots)):
|
||||
print(f"❌ {t('No such file: ')}{mots}")
|
||||
return None
|
||||
extra += ["--words", os.path.expanduser(mots)]
|
||||
return extra
|
||||
|
||||
def _monitoring_expect(self, kind):
|
||||
"""Copie de développement, ou instance en service ?
|
||||
|
||||
|
|
|
|||
|
|
@ -3362,6 +3362,54 @@ TRANSLATIONS = {
|
|||
"fr": "Aucun stockage capable d'héberger un disque de VM.",
|
||||
"en": "No storage able to hold a VM disk.",
|
||||
},
|
||||
"No NAT table on this host: the bridge would lead nowhere.": {
|
||||
"fr": "Pas de table NAT sur cet hôte : le pont ne mènerait nulle part.",
|
||||
"en": "No NAT table on this host: the bridge would lead nowhere.",
|
||||
},
|
||||
"reboot it to boot the Proxmox kernel:": {
|
||||
"fr": "à redémarrer pour amorcer le noyau Proxmox :",
|
||||
"en": "reboot it to boot the Proxmox kernel:",
|
||||
},
|
||||
"Rebooting to boot the new kernel": {
|
||||
"fr": "Redémarrage pour amorcer le nouveau noyau",
|
||||
"en": "Rebooting to boot the new kernel",
|
||||
},
|
||||
"waiting for the machine to come back": {
|
||||
"fr": "attente du retour de la machine",
|
||||
"en": "waiting for the machine to come back",
|
||||
},
|
||||
"kernel booted:": {
|
||||
"fr": "noyau amorcé :",
|
||||
"en": "kernel booted:",
|
||||
},
|
||||
"the machine did not come back on the expected kernel:": {
|
||||
"fr": "la machine n'est pas revenue sur le noyau attendu :",
|
||||
"en": "the machine did not come back on the expected kernel:",
|
||||
},
|
||||
"Running kernel:": {
|
||||
"fr": "Noyau en cours :",
|
||||
"en": "Running kernel:",
|
||||
},
|
||||
"The distribution kernel carries no netfilter module.": {
|
||||
"fr": "Le noyau de la distribution ne porte aucun module netfilter.",
|
||||
"en": "The distribution kernel carries no netfilter module.",
|
||||
},
|
||||
"Proxmox kernel installed:": {
|
||||
"fr": "Noyau Proxmox installé :",
|
||||
"en": "Proxmox kernel installed:",
|
||||
},
|
||||
"taken at next boot": {
|
||||
"fr": "pris au prochain démarrage",
|
||||
"en": "taken at next boot",
|
||||
},
|
||||
"then come back here.": {
|
||||
"fr": "puis revenir ici.",
|
||||
"en": "then come back here.",
|
||||
},
|
||||
"No Proxmox kernel installed: finish the install first.": {
|
||||
"fr": "Aucun noyau Proxmox installé : terminer l'installation d'abord.",
|
||||
"en": "No Proxmox kernel installed: finish the install first.",
|
||||
},
|
||||
"No network bridge on this host.": {
|
||||
"fr": "Aucun pont réseau sur cet hôte.",
|
||||
"en": "No network bridge on this host.",
|
||||
|
|
@ -4137,6 +4185,10 @@ TRANSLATIONS = {
|
|||
"fr": "Effacer ces images en double ? (o/N, défaut : non) : ",
|
||||
"en": "Delete these duplicate images? (y/N, default: no): ",
|
||||
},
|
||||
"its jump host is gone:": {
|
||||
"fr": "son rebond n'existe plus :",
|
||||
"en": "its jump host is gone:",
|
||||
},
|
||||
"Orphan ~/.ssh/config entries:": {
|
||||
"fr": "Entrées ~/.ssh/config orphelines :",
|
||||
"en": "Orphan ~/.ssh/config entries:",
|
||||
|
|
@ -4351,6 +4403,10 @@ TRANSLATIONS = {
|
|||
"fr": "Clonage d'ERPLibre sur chaque VM",
|
||||
"en": "Cloning ERPLibre on each VM",
|
||||
},
|
||||
"per VM": {
|
||||
"fr": "par VM",
|
||||
"en": "per VM",
|
||||
},
|
||||
"Installing ERPLibre on each VM": {
|
||||
"fr": "Installation d'ERPLibre sur chaque VM",
|
||||
"en": "Installing ERPLibre on each VM",
|
||||
|
|
@ -9564,14 +9620,6 @@ TRANSLATIONS = {
|
|||
"fr": "Un booléen qui n'est ni vrai ni faux : la langue n'est listée nulle part et ne peut plus être réactivée depuis l'interface.",
|
||||
"en": "A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.",
|
||||
},
|
||||
"Many-to-many tables named but missing": {
|
||||
"fr": "Tables plusieurs-à-plusieurs nommées mais absentes",
|
||||
"en": "Many-to-many tables named but missing",
|
||||
},
|
||||
"ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.": {
|
||||
"fr": "ir_model_relation nomme encore une table que PostgreSQL n'a pas ; la prochaine mise à jour de module tente de la modifier et échoue.",
|
||||
"en": "ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.",
|
||||
},
|
||||
"Indexes duplicated by the Odoo 17 renaming": {
|
||||
"fr": "Index doublés par le renommage d'Odoo 17",
|
||||
"en": "Indexes duplicated by the Odoo 17 renaming",
|
||||
|
|
@ -9644,6 +9692,130 @@ TRANSLATIONS = {
|
|||
"fr": "Une instance en service",
|
||||
"en": "An instance in service",
|
||||
},
|
||||
"Anonymise a copy": {
|
||||
"fr": "🎭 Anonymiser une copie",
|
||||
"en": "🎭 Anonymise a copy",
|
||||
},
|
||||
"Replace names, texts and numbers with meaningless ones so the copy can be shared. No AI: words from a list, numbers drawn at random, written by SQL.": {
|
||||
"fr": "Remplacer noms, textes et nombres par des valeurs sans intérêt, pour pouvoir partager la copie. Sans IA : des mots pris dans une liste, des nombres tirés au hasard, écrits en SQL.",
|
||||
"en": "Replace names, texts and numbers with meaningless ones so the copy can be shared. No AI: words from a list, numbers drawn at random, written by SQL.",
|
||||
},
|
||||
"This one WRITES. It is offered only for a database you restored here — never for an instance in service.": {
|
||||
"fr": "Celle-ci ÉCRIT. Elle n'est offerte que pour une base restaurée ici — jamais pour une instance en service.",
|
||||
"en": "This one WRITES. It is offered only for a database you restored here — never for an instance in service.",
|
||||
},
|
||||
"This DESTROYS the data of": {
|
||||
"fr": "Ceci DÉTRUIT les données de",
|
||||
"en": "This DESTROYS the data of",
|
||||
},
|
||||
"there is no undo.": {
|
||||
"fr": "il n'y a pas de retour en arrière.",
|
||||
"en": "there is no undo.",
|
||||
},
|
||||
"Type the database name to confirm (empty to cancel): ": {
|
||||
"fr": "Retapez le nom de la base pour confirmer (vide pour annuler) : ",
|
||||
"en": "Type the database name to confirm (empty to cancel): ",
|
||||
},
|
||||
"Cancelled: nothing was written.": {
|
||||
"fr": "Annulé : rien n'a été écrit.",
|
||||
"en": "Cancelled: nothing was written.",
|
||||
},
|
||||
"Hybrid: the default personal-data models, adjusted": {
|
||||
"fr": "Hybride : les modèles à données personnelles par défaut, ajustés",
|
||||
"en": "Hybrid: the default personal-data models, adjusted",
|
||||
},
|
||||
"Whitelist: only the models I name": {
|
||||
"fr": "Liste blanche : seulement les modèles que je nomme",
|
||||
"en": "Whitelist: only the models I name",
|
||||
},
|
||||
"Blacklist: every model except those I name": {
|
||||
"fr": "Liste noire : tous les modèles sauf ceux que je nomme",
|
||||
"en": "Blacklist: every model except those I name",
|
||||
},
|
||||
"Models to ADD, comma separated (empty for none): ": {
|
||||
"fr": "Modèles à AJOUTER, séparés par des virgules (vide pour aucun) : ",
|
||||
"en": "Models to ADD, comma separated (empty for none): ",
|
||||
},
|
||||
"Models to EXCLUDE, comma separated: ": {
|
||||
"fr": "Modèles à EXCLURE, séparés par des virgules : ",
|
||||
"en": "Models to EXCLUDE, comma separated: ",
|
||||
},
|
||||
"A whitelist with no model would do nothing.": {
|
||||
"fr": "Une liste blanche sans modèle ne ferait rien.",
|
||||
"en": "A whitelist with no model would do nothing.",
|
||||
},
|
||||
"Python file declaring MOTS (empty for the built-in): ": {
|
||||
"fr": "Fichier Python déclarant MOTS (vide pour la liste intégrée) : ",
|
||||
"en": "Python file declaring MOTS (empty for the built-in): ",
|
||||
},
|
||||
"Nothing to anonymise with these lists.": {
|
||||
"fr": "Rien à anonymiser avec ces listes.",
|
||||
"en": "Nothing to anonymise with these lists.",
|
||||
},
|
||||
"column(s)": {
|
||||
"fr": "colonne(s)",
|
||||
"en": "column(s)",
|
||||
},
|
||||
"dry run, nothing written": {
|
||||
"fr": "marche à blanc, rien n'est écrit",
|
||||
"en": "dry run, nothing written",
|
||||
},
|
||||
"text": {
|
||||
"fr": "texte",
|
||||
"en": "text",
|
||||
},
|
||||
"numeric": {
|
||||
"fr": "nombre",
|
||||
"en": "numeric",
|
||||
},
|
||||
"translated (jsonb)": {
|
||||
"fr": "traduit (jsonb)",
|
||||
"en": "translated (jsonb)",
|
||||
},
|
||||
"unique": {
|
||||
"fr": "unique",
|
||||
"en": "unique",
|
||||
},
|
||||
"Use --apply --confirm <database> to write.": {
|
||||
"fr": "Utiliser --apply --confirm <base> pour écrire.",
|
||||
"en": "Use --apply --confirm <database> to write.",
|
||||
},
|
||||
"Replace the data of a COPY with meaningless data.": {
|
||||
"fr": "Remplacer les données d'une COPIE par des données sans intérêt.",
|
||||
"en": "Replace the data of a COPY with meaningless data.",
|
||||
},
|
||||
"comma separated, adds to the mode": {
|
||||
"fr": "séparés par des virgules, s'ajoutent au mode",
|
||||
"en": "comma separated, adds to the mode",
|
||||
},
|
||||
"comma separated, removed from it": {
|
||||
"fr": "séparés par des virgules, retirés du mode",
|
||||
"en": "comma separated, removed from it",
|
||||
},
|
||||
"python file declaring MOTS": {
|
||||
"fr": "fichier python déclarant MOTS",
|
||||
"en": "python file declaring MOTS",
|
||||
},
|
||||
"repeat the database name; --apply refuses without it": {
|
||||
"fr": "répéter le nom de la base ; --apply refuse sans lui",
|
||||
"en": "repeat the database name; --apply refuses without it",
|
||||
},
|
||||
"Refusing to write: --confirm must repeat": {
|
||||
"fr": "Écriture refusée : --confirm doit répéter",
|
||||
"en": "Refusing to write: --confirm must repeat",
|
||||
},
|
||||
"Nothing was written:": {
|
||||
"fr": "Rien n'a été écrit :",
|
||||
"en": "Nothing was written:",
|
||||
},
|
||||
"This file declares no MOTS:": {
|
||||
"fr": "Ce fichier ne déclare aucun MOTS :",
|
||||
"en": "This file declares no MOTS:",
|
||||
},
|
||||
"Unknown mode:": {
|
||||
"fr": "Mode inconnu :",
|
||||
"en": "Unknown mode:",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
368
test/test_anonymize.py
Normal file
368
test/test_anonymize.py
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Anonymiser : ce qui compte, c'est ce qu'on REFUSE de toucher.
|
||||
|
||||
Remplacer des mots est facile. Ce qui casse une base, c'est de croire que
|
||||
« tous les champs string » veut dire quelque chose. Mesuré sur une base
|
||||
réelle en 18 : 505 champs `selection` sont stockés en varchar
|
||||
(`res.partner.lang`, `sale.order.invoice_status`), 2693 many2one sont des
|
||||
entiers, 194 textes sont des `jsonb` par langue, et 301 contraintes
|
||||
d'unicité attendent une collision.
|
||||
|
||||
Trois de ces pièges ont été trouvés en LANÇANT l'outil sur une copie
|
||||
jetable, pas en le relisant : PostgreSQL refuse d'indexer un
|
||||
`ARRAY[...]` sans parenthèses, `res_partner.credit_limit` est un jsonb
|
||||
alors qu'Odoo l'appelle `float`, et `crm_lead.probability` porte un CHECK
|
||||
qui interdit 1000. Chacun aurait fait échouer l'écriture — et l'écriture
|
||||
étant transactionnelle, chaque fois la base est restée intacte. Ce
|
||||
fichier fige ces trois-là pour qu'ils ne reviennent pas.
|
||||
|
||||
Le plancher est l'objet du premier bloc : aucun mode, aucune liste
|
||||
blanche, aucune insistance ne doit permettre d'écrire dans `ir.*`.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from script.analyse import anonymize as anon
|
||||
|
||||
MOTEUR = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "script"
|
||||
/ "analyse"
|
||||
/ "anonymize.py"
|
||||
)
|
||||
|
||||
|
||||
def champ(
|
||||
nom,
|
||||
ttype="char",
|
||||
pg="character varying",
|
||||
unique=False,
|
||||
checked=False,
|
||||
modele="res.partner",
|
||||
):
|
||||
return {
|
||||
"model": modele,
|
||||
"name": nom,
|
||||
"ttype": ttype,
|
||||
"pg_type": pg,
|
||||
"unique": unique,
|
||||
"checked": checked,
|
||||
}
|
||||
|
||||
|
||||
class TestTheFloorNobodyCanLift(unittest.TestCase):
|
||||
"""`ir.*` reste intouchable, quel que soit le chemin."""
|
||||
|
||||
def test_ir_models_are_refused_even_when_whitelisted(self):
|
||||
tous = {"ir.ui.view", "ir.model.fields", "res.partner"}
|
||||
for mode in anon.MODES:
|
||||
choisis = anon.choisir_modeles(
|
||||
tous, mode, whitelist=list(tous), blacklist=[]
|
||||
)
|
||||
self.assertNotIn("ir.ui.view", choisis, mode)
|
||||
self.assertNotIn("ir.model.fields", choisis, mode)
|
||||
|
||||
def test_languages_and_currencies_are_refused_too(self):
|
||||
"""Ce ne sont pas des données personnelles, et les casser casse
|
||||
les adresses et les montants."""
|
||||
tous = {"res.lang", "res.currency", "res.country", "res.partner"}
|
||||
choisis = anon.choisir_modeles(tous, "whitelist", whitelist=list(tous))
|
||||
self.assertEqual(choisis, ["res.partner"])
|
||||
|
||||
def test_a_blacklist_of_nothing_still_respects_the_floor(self):
|
||||
tous = {"ir.ui.view", "res.partner", "res.lang"}
|
||||
self.assertEqual(
|
||||
anon.choisir_modeles(tous, "blacklist", blacklist=[]),
|
||||
["res.partner"],
|
||||
)
|
||||
|
||||
|
||||
class TestWhatIsNeverReplaced(unittest.TestCase):
|
||||
"""Le cœur : distinguer un vrai texte d'un varchar qui n'en est pas un."""
|
||||
|
||||
def test_a_selection_stored_as_varchar_is_left_alone(self):
|
||||
"""505 dans la base mesurée. Y écrire un mot casse l'ORM."""
|
||||
self.assertFalse(anon.champ_retenu(champ("lang", "selection")))
|
||||
self.assertFalse(
|
||||
anon.champ_retenu(champ("invoice_status", "selection"))
|
||||
)
|
||||
|
||||
def test_a_many2one_is_left_alone(self):
|
||||
"""2693 entiers qui sont des relations."""
|
||||
self.assertFalse(
|
||||
anon.champ_retenu(champ("parent_id", "many2one", "integer"))
|
||||
)
|
||||
|
||||
def test_a_many2one_is_refused_by_its_type_not_only_its_name(self):
|
||||
"""Sur la base mesurée, tous les many2one finissent par `_id` — mais
|
||||
un module maison peut en nommer un `owner`, et la convention ne
|
||||
peut pas être la seule barrière. C'est le TYPE qui décide."""
|
||||
self.assertFalse(
|
||||
anon.champ_retenu(champ("owner", "many2one", "integer"))
|
||||
)
|
||||
self.assertFalse(
|
||||
anon.champ_retenu(champ("responsable", "many2one", "integer"))
|
||||
)
|
||||
|
||||
def test_anything_named_like_a_relation_is_left_alone(self):
|
||||
for nom in ("company_id", "tag_ids", "partner_id"):
|
||||
self.assertFalse(anon.champ_retenu(champ(nom, "integer")), nom)
|
||||
|
||||
def test_a_number_living_in_a_jsonb_is_left_alone(self):
|
||||
"""Mesuré : res_partner.credit_limit est un float DANS un jsonb."""
|
||||
self.assertFalse(
|
||||
anon.champ_retenu(champ("credit_limit", "float", "jsonb"))
|
||||
)
|
||||
|
||||
def test_a_column_under_a_check_constraint_is_left_alone(self):
|
||||
"""Mesuré : crm_lead.probability doit rester entre 0 et 100."""
|
||||
self.assertFalse(
|
||||
anon.champ_retenu(
|
||||
champ("probability", "float", "numeric", checked=True)
|
||||
)
|
||||
)
|
||||
|
||||
def test_technical_columns_are_left_alone(self):
|
||||
for nom in (
|
||||
"id",
|
||||
"create_uid",
|
||||
"write_date",
|
||||
"state",
|
||||
"sequence",
|
||||
"arch_db",
|
||||
"active",
|
||||
):
|
||||
self.assertFalse(anon.champ_retenu(champ(nom, "char")), nom)
|
||||
|
||||
def test_logins_stay_unless_asked(self):
|
||||
"""Sinon personne ne peut plus ouvrir la copie qu'on anonymise."""
|
||||
self.assertFalse(anon.champ_retenu(champ("login")))
|
||||
self.assertTrue(
|
||||
anon.champ_retenu(champ("login"), inclure_connexion=True)
|
||||
)
|
||||
|
||||
def test_a_real_text_is_taken(self):
|
||||
for ttype in ("char", "text", "html"):
|
||||
self.assertTrue(anon.champ_retenu(champ("name", ttype)), ttype)
|
||||
|
||||
def test_a_real_number_is_taken(self):
|
||||
for ttype in ("integer", "float", "monetary"):
|
||||
self.assertTrue(
|
||||
anon.champ_retenu(champ("amount", ttype, "numeric")), ttype
|
||||
)
|
||||
|
||||
|
||||
class TestTheSqlItWrites(unittest.TestCase):
|
||||
def test_a_null_stays_null(self):
|
||||
"""Un NULL devenu mot créerait de la donnée là où il n'y en avait
|
||||
pas : la copie mentirait dans l'autre sens."""
|
||||
sql = anon.expression_texte(champ("name"), ["a"])
|
||||
self.assertIn("IS NULL THEN NULL", sql)
|
||||
self.assertIn(
|
||||
"IS NULL THEN NULL",
|
||||
anon.expression_nombre(champ("x", "integer", "integer")),
|
||||
)
|
||||
|
||||
def test_the_array_is_parenthesised(self):
|
||||
"""PostgreSQL refuse d'indexer un ARRAY[...] nu — mesuré."""
|
||||
sql = anon.expression_texte(champ("name"), ["a", "b"])
|
||||
self.assertIn("(ARRAY[", sql)
|
||||
self.assertNotIn("] ARRAY[", sql)
|
||||
self.assertRegex(sql, r"\(ARRAY\[[^\]]*\]\)\[")
|
||||
|
||||
def test_a_unique_column_gets_the_id_appended(self):
|
||||
"""Deux lignes au même mot feraient échouer TOUT l'UPDATE."""
|
||||
sql = anon.expression_texte(champ("ref", unique=True), ["a"])
|
||||
self.assertIn("id::text", sql)
|
||||
self.assertNotIn(
|
||||
"id::text", anon.expression_texte(champ("ref"), ["a"])
|
||||
)
|
||||
|
||||
def test_a_translated_column_is_rebuilt_key_by_key(self):
|
||||
"""Écrire une chaîne dans un jsonb détruirait la colonne."""
|
||||
sql = anon.expression_texte(champ("comment", "html", "jsonb"), ["a"])
|
||||
self.assertIn("jsonb_object_agg", sql)
|
||||
self.assertIn("jsonb_each_text", sql)
|
||||
|
||||
def test_a_plain_text_column_is_not_treated_as_json(self):
|
||||
sql = anon.expression_texte(champ("comment", "text", "text"), ["a"])
|
||||
self.assertNotIn("jsonb", sql)
|
||||
|
||||
def test_numbers_land_between_zero_and_a_thousand(self):
|
||||
entier = anon.expression_nombre(champ("n", "integer", "integer"))
|
||||
self.assertIn("1001", entier)
|
||||
decimal = anon.expression_nombre(champ("x", "float", "numeric"))
|
||||
self.assertIn("1000", decimal)
|
||||
|
||||
def test_a_word_with_a_quote_cannot_break_out(self):
|
||||
"""Une liste de mots vient d'un fichier : elle n'est pas de confiance."""
|
||||
sql = anon.expression_texte(champ("name"), ["l'ete"])
|
||||
self.assertIn("'l''ete'", sql)
|
||||
|
||||
def test_one_update_per_table_not_per_column(self):
|
||||
sql = anon.sql_pour_table(
|
||||
"res_partner", [champ("name"), champ("ref")], None
|
||||
)
|
||||
self.assertEqual(sql.count("UPDATE"), 1)
|
||||
self.assertTrue(sql.endswith(";"))
|
||||
|
||||
def test_no_column_means_no_statement(self):
|
||||
self.assertIsNone(anon.sql_pour_table("res_partner", [], None))
|
||||
|
||||
|
||||
class TestTheModes(unittest.TestCase):
|
||||
TOUS = {"res.partner", "crm.lead", "sale.order", "ir.ui.view"}
|
||||
|
||||
def test_whitelist_takes_only_what_it_names(self):
|
||||
self.assertEqual(
|
||||
anon.choisir_modeles(self.TOUS, "whitelist", ["crm.lead"]),
|
||||
["crm.lead"],
|
||||
)
|
||||
|
||||
def test_blacklist_takes_everything_else(self):
|
||||
choisis = anon.choisir_modeles(
|
||||
self.TOUS, "blacklist", blacklist=["crm.lead"]
|
||||
)
|
||||
self.assertNotIn("crm.lead", choisis)
|
||||
self.assertIn("res.partner", choisis)
|
||||
|
||||
def test_hybrid_starts_from_the_defaults_and_adjusts(self):
|
||||
choisis = anon.choisir_modeles(
|
||||
self.TOUS,
|
||||
"hybrid",
|
||||
whitelist=["sale.order"],
|
||||
blacklist=["res.partner"],
|
||||
)
|
||||
self.assertIn("sale.order", choisis)
|
||||
self.assertIn("crm.lead", choisis) # dans les défauts
|
||||
self.assertNotIn("res.partner", choisis) # retiré
|
||||
|
||||
def test_an_unknown_mode_raises_rather_than_guessing(self):
|
||||
with self.assertRaises(ValueError):
|
||||
anon.choisir_modeles(self.TOUS, "peut-etre")
|
||||
|
||||
|
||||
class TestTheWordList(unittest.TestCase):
|
||||
def test_a_flat_list_is_used_everywhere(self):
|
||||
self.assertEqual(anon.mots_pour("name", ["a", "b"]), ("a", "b"))
|
||||
|
||||
def test_a_dictionary_can_answer_per_field(self):
|
||||
mots = {"email": ["a@b.c"], "*": ["mot"]}
|
||||
self.assertEqual(anon.mots_pour("email", mots), ("a@b.c",))
|
||||
self.assertEqual(anon.mots_pour("name", mots), ("mot",))
|
||||
|
||||
def test_an_empty_list_falls_back_to_the_built_in(self):
|
||||
self.assertEqual(anon.mots_pour("name", []), anon.MOTS_PAR_DEFAUT)
|
||||
|
||||
|
||||
class TestThereIsNoModelInTheLoop(unittest.TestCase):
|
||||
"""« sans passer par un GPT » : vérifié sur le code, pas sur parole."""
|
||||
|
||||
def test_the_engine_imports_nothing_that_could_call_out(self):
|
||||
arbre = ast.parse(MOTEUR.read_text(encoding="utf-8"))
|
||||
interdits = {
|
||||
"requests",
|
||||
"urllib",
|
||||
"urllib3",
|
||||
"http",
|
||||
"httpx",
|
||||
"socket",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"xmlrpc",
|
||||
"json",
|
||||
}
|
||||
for noeud in ast.walk(arbre):
|
||||
noms = []
|
||||
if isinstance(noeud, ast.Import):
|
||||
noms = [a.name.split(".")[0] for a in noeud.names]
|
||||
elif isinstance(noeud, ast.ImportFrom) and noeud.module:
|
||||
noms = [noeud.module.split(".")[0]]
|
||||
for nom in noms:
|
||||
self.assertNotIn(nom, interdits, nom)
|
||||
|
||||
def test_the_write_is_one_transaction(self):
|
||||
"""Une collision au dixième modèle laisserait une base à moitié
|
||||
anonymisée, que rien ne rattrape sinon une restauration."""
|
||||
arbre = ast.parse(MOTEUR.read_text(encoding="utf-8"))
|
||||
fonction = [
|
||||
n
|
||||
for n in ast.walk(arbre)
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "ecrire"
|
||||
]
|
||||
self.assertEqual(len(fonction), 1)
|
||||
# Le corps SANS la docstring : le texte la mentionne, l'argument
|
||||
# doit être réellement passé.
|
||||
corps = [
|
||||
n
|
||||
for n in fonction[0].body
|
||||
if not (
|
||||
isinstance(n, ast.Expr)
|
||||
and isinstance(n.value, ast.Constant)
|
||||
and isinstance(n.value.value, str)
|
||||
)
|
||||
]
|
||||
litteraux = {
|
||||
n.value
|
||||
for bloc in corps
|
||||
for n in ast.walk(bloc)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, str)
|
||||
}
|
||||
self.assertIn("-1", litteraux)
|
||||
self.assertIn("ON_ERROR_STOP=1", litteraux)
|
||||
|
||||
|
||||
class TestTheRefusalToWrite(unittest.TestCase):
|
||||
"""Le refus doit précéder la connexion.
|
||||
|
||||
Sinon ces deux tests passent au vert parce que la base n'existe pas,
|
||||
et ne prouvent rien du garde. On lit donc le message rendu, et l'on
|
||||
vérifie qu'aucun psql n'a été appelé.
|
||||
"""
|
||||
|
||||
def _refus(self, argv):
|
||||
import contextlib
|
||||
import io as flux
|
||||
|
||||
appels = []
|
||||
vrai = anon.lib_analyse.require_odoo_database
|
||||
|
||||
def espion(*a, **k):
|
||||
appels.append(a)
|
||||
return vrai(*a, **k)
|
||||
|
||||
anon.lib_analyse.require_odoo_database = espion
|
||||
sortie = flux.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stderr(sortie):
|
||||
code = anon.main(argv)
|
||||
finally:
|
||||
anon.lib_analyse.require_odoo_database = vrai
|
||||
return code, sortie.getvalue(), appels
|
||||
|
||||
def test_apply_without_a_matching_confirm_is_refused(self):
|
||||
code, message, appels = self._refus(
|
||||
["-d", "une_base", "--apply", "--confirm", "une_autre"]
|
||||
)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn(
|
||||
anon.t("Refusing to write: --confirm must repeat"), message
|
||||
)
|
||||
self.assertEqual(appels, [], "la base a été contactée pour refuser")
|
||||
|
||||
def test_apply_with_no_confirm_at_all_is_refused(self):
|
||||
code, message, appels = self._refus(["-d", "une_base", "--apply"])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn(
|
||||
anon.t("Refusing to write: --confirm must repeat"), message
|
||||
)
|
||||
self.assertEqual(appels, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -63,7 +63,7 @@ class TestWhatASourceAdmits(unittest.TestCase):
|
|||
|
||||
def test_every_analysis_says_why_it_cannot_do_live(self):
|
||||
for analyse in monitoring.ANALYSES:
|
||||
self.assertTrue(analyse["needs_sql"].strip(), analyse["key"])
|
||||
self.assertTrue(analyse["why_not"].strip(), analyse["key"])
|
||||
|
||||
def test_available_and_unavailable_cover_every_analysis(self):
|
||||
for genre in (monitoring.KIND_DATABASE, monitoring.KIND_LIVE):
|
||||
|
|
|
|||
|
|
@ -619,5 +619,59 @@ class TestLeMenu(unittest.TestCase):
|
|||
self.assertEqual(0, res.returncode, res.stderr)
|
||||
|
||||
|
||||
class TestLaTableNat(unittest.TestCase):
|
||||
"""« Table does not exist » : six lignes d'iptables et « code de retour 1 »,
|
||||
après avoir déjà écrit la strophe dans /etc/network/interfaces.
|
||||
|
||||
Rien dans ce bruit ne dit qu'il faut redémarrer. Et le cas n'a rien
|
||||
d'exotique : notre propre install_proxmox.sh pose le noyau Proxmox sans
|
||||
redémarrer — lancé par ssh, un reboot couperait la session. Une Proxmox
|
||||
imbriquée fraîchement installée est donc TOUJOURS sur le noyau cloud de
|
||||
Debian, qui est dépouillé de tout netfilter.
|
||||
|
||||
On demande donc à la table NAT elle-même, et non au NOM du noyau : « -pve »
|
||||
est un indice, pas une preuve."""
|
||||
|
||||
def _sortie(self, kernel, nat, pve_kernel=""):
|
||||
return (
|
||||
f"{kernel}\n---ERPLIBRE-NAT---\n"
|
||||
f"{'NAT-OK' if nat else 'NAT-KO'}\n"
|
||||
f"---ERPLIBRE-PVE-KERNEL---\n{pve_kernel}\n"
|
||||
)
|
||||
|
||||
def test_a_working_host(self):
|
||||
lu = pve.parse_nat_check(
|
||||
self._sortie("7.0.14-14-pve", True, "7.0.14-14-pve")
|
||||
)
|
||||
self.assertTrue(lu["nat"])
|
||||
self.assertEqual(lu["kernel"], "7.0.14-14-pve")
|
||||
|
||||
def test_the_cloud_kernel_waiting_for_a_reboot(self):
|
||||
# L'état exact rapporté : le noyau Proxmox est POSÉ, pas amorcé.
|
||||
lu = pve.parse_nat_check(
|
||||
self._sortie("6.12.101+deb13-cloud-amd64", False, "7.0.14-14-pve")
|
||||
)
|
||||
self.assertFalse(lu["nat"])
|
||||
self.assertEqual(lu["pve_kernel"], "7.0.14-14-pve")
|
||||
|
||||
def test_an_unfinished_install_has_no_pve_kernel(self):
|
||||
lu = pve.parse_nat_check(
|
||||
self._sortie("6.12.101+deb13-cloud-amd64", False)
|
||||
)
|
||||
self.assertFalse(lu["nat"])
|
||||
self.assertEqual(lu["pve_kernel"], "")
|
||||
|
||||
def test_ssh_noise_does_not_become_a_kernel(self):
|
||||
brut = (
|
||||
"Warning: Permanently added 'x' (ED25519) to the list of known"
|
||||
" hosts.\n" + self._sortie("7.0.14-14-pve", True, "7.0.14-14-pve")
|
||||
)
|
||||
self.assertEqual(pve.parse_nat_check(brut)["kernel"], "7.0.14-14-pve")
|
||||
|
||||
def test_the_probe_asks_the_table_not_the_name(self):
|
||||
self.assertIn("iptables -t nat", pve.NAT_CHECK_CMD)
|
||||
self.assertIn("uname -r", pve.NAT_CHECK_CMD)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=1)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ aucun hôte Proxmox n'est joint.
|
|||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.proxmox_deploy_form import ( # noqa: E402
|
||||
|
|
@ -598,6 +598,241 @@ class TestDeuxVmDuMemeNom(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class TestUnParcMixte(unittest.TestCase):
|
||||
"""Le plan porte branche, profil et type PAR RANGÉE — le déploiement
|
||||
lisait encore la seule valeur commune.
|
||||
|
||||
C'est le cas qu'on déploie le plus souvent sur un Proxmox : un
|
||||
hyperviseur imbriqué à côté de VM ERPLibre. Une seule VM qui porte sa
|
||||
propre valeur suffit à rendre la carte nécessaire — « len(set) > 1 » ne
|
||||
l'aurait pas vu, et tout le parc serait retombé sur le commun."""
|
||||
|
||||
def _capture(self, vms):
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.todo import TODO
|
||||
|
||||
vu = {}
|
||||
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._qemu_list_domains = lambda: []
|
||||
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
|
||||
|
||||
def prise(noms, branche, alias, finale, **kw):
|
||||
vu.update(branche=branche, finale=finale, kw=kw)
|
||||
|
||||
todo._qemu_install_erplibre_monitored = prise
|
||||
spec = {
|
||||
"host": {"target": "pve1"},
|
||||
"vms": vms,
|
||||
"user": "erplibre",
|
||||
"add_ssh_config": True,
|
||||
"install": {
|
||||
"branch": "develop",
|
||||
"cmd": "make install_odoo_18",
|
||||
"label": "X",
|
||||
},
|
||||
"monitor": True,
|
||||
"desktop": "",
|
||||
}
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
todo._pve_after_create(
|
||||
spec["host"], spec, [v["name"] for v in vms], ""
|
||||
)
|
||||
return vu
|
||||
|
||||
def _vm(self, nom, **extra):
|
||||
base = {
|
||||
"name": nom,
|
||||
"vmid": 100,
|
||||
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
|
||||
"install_cmd": "",
|
||||
}
|
||||
base.update(extra)
|
||||
return base
|
||||
|
||||
def test_a_single_vm_with_its_own_branch_forces_the_map(self):
|
||||
vu = self._capture(
|
||||
[
|
||||
self._vm("vm-a", branch="master"),
|
||||
self._vm("vm-b", vmid=101),
|
||||
]
|
||||
)
|
||||
self.assertEqual(vu["branche"], {"vm-a": "master", "vm-b": "develop"})
|
||||
|
||||
def test_a_uniform_fleet_keeps_the_common_value(self):
|
||||
vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)])
|
||||
self.assertEqual(vu["branche"], "develop")
|
||||
|
||||
def test_a_per_vm_desktop_reaches_the_install(self):
|
||||
vu = self._capture(
|
||||
[
|
||||
self._vm("vm-a", desktop="gnome"),
|
||||
self._vm("vm-b", vmid=101),
|
||||
]
|
||||
)
|
||||
self.assertEqual(vu["kw"]["desktop"], {"vm-a": "gnome", "vm-b": ""})
|
||||
|
||||
def test_a_uniform_fleet_keeps_the_common_desktop(self):
|
||||
vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)])
|
||||
self.assertEqual(vu["kw"]["desktop"], "")
|
||||
|
||||
|
||||
class TestLePontQuiNeMeneraitNullePart(unittest.TestCase):
|
||||
"""Le pont NAT était écrit AVANT qu'on sache si le NAT existe.
|
||||
|
||||
Résultat rapporté : la strophe posée dans /etc/network/interfaces, le
|
||||
pont absent, et six lignes d'iptables qui ne parlent pas de redémarrage.
|
||||
L'avertissement sur le noyau existait — mais à la CONFIRMATION de l'hôte,
|
||||
et l'hôte est ensuite mémorisé : on revient des jours plus tard créer un
|
||||
pont, et plus personne ne rappelle rien."""
|
||||
|
||||
def _todo(self, sortie):
|
||||
import sys
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.todo import TODO
|
||||
|
||||
todo = TODO.__new__(TODO)
|
||||
vu = []
|
||||
todo._pve_host = lambda ask=True: {"target": "pve9", "sudo": ""}
|
||||
todo._pve_uplink = lambda: "eth0"
|
||||
|
||||
def faux_run(host, cmd, timeout=120):
|
||||
vu.append(cmd)
|
||||
from script.proxmox import proxmox_deploy as pve
|
||||
|
||||
if cmd == pve.NAT_CHECK_CMD:
|
||||
return 0, sortie
|
||||
return 0, ""
|
||||
|
||||
return todo, vu, faux_run
|
||||
|
||||
def _sortie(self, nat, pve_kernel="7.0.14-14-pve"):
|
||||
return (
|
||||
f"{'7.0.14-14-pve' if nat else '6.12.101+deb13-cloud-amd64'}\n"
|
||||
f"---ERPLIBRE-NAT---\n{'NAT-OK' if nat else 'NAT-KO'}\n"
|
||||
f"---ERPLIBRE-PVE-KERNEL---\n{pve_kernel}\n"
|
||||
)
|
||||
|
||||
def test_nothing_is_written_when_there_is_no_nat(self):
|
||||
todo, vu, faux = self._todo(self._sortie(nat=False))
|
||||
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
||||
nom, raison = todo._pve_make_internal_bridge()
|
||||
self.assertEqual(nom, "")
|
||||
self.assertTrue(raison)
|
||||
# Une seule commande : la sonde. Rien n'a touché au fichier.
|
||||
self.assertEqual(len(vu), 1, vu)
|
||||
self.assertNotIn(
|
||||
"interfaces", " ".join(vu), "la strophe ne doit pas être écrite"
|
||||
)
|
||||
|
||||
def test_the_reason_names_the_kernel_to_boot(self):
|
||||
todo, _vu, faux = self._todo(self._sortie(nat=False))
|
||||
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
||||
ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""})
|
||||
self.assertFalse(ok)
|
||||
texte = " ".join(lignes)
|
||||
self.assertIn("6.12.101+deb13-cloud-amd64", texte)
|
||||
self.assertIn("7.0.14-14-pve", texte)
|
||||
self.assertIn("reboot", texte)
|
||||
|
||||
def test_an_unfinished_install_says_so_instead(self):
|
||||
todo, _vu, faux = self._todo(self._sortie(nat=False, pve_kernel=""))
|
||||
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
||||
_ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""})
|
||||
texte = " ".join(lignes)
|
||||
self.assertNotIn("reboot", texte, "rien à redémarrer, rien de posé")
|
||||
|
||||
def test_a_working_host_goes_through(self):
|
||||
todo, vu, faux = self._todo(self._sortie(nat=True))
|
||||
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
|
||||
todo._pve_make_internal_bridge()
|
||||
self.assertGreater(len(vu), 1, "la création doit suivre la sonde")
|
||||
|
||||
|
||||
class TestUneProxmoxImbriqueeDoitRedemarrer(unittest.TestCase):
|
||||
"""Le sommaire ne disait pas qu'une VM qui vient de recevoir Proxmox
|
||||
tourne encore le noyau de son image cloud.
|
||||
|
||||
On le redécouvrait des jours plus tard, en créant un pont, devant six
|
||||
lignes d'iptables."""
|
||||
|
||||
def _juge(self, vm, commun=""):
|
||||
import sys
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.todo import TODO
|
||||
|
||||
return TODO._pve_installs_proxmox(
|
||||
vm, {"install": {"cmd": commun}} if commun else {}
|
||||
)
|
||||
|
||||
def test_a_vm_that_gets_the_hypervisor(self):
|
||||
self.assertTrue(
|
||||
self._juge({"install_cmd": "./script/proxmox/install_proxmox.sh"})
|
||||
)
|
||||
|
||||
def test_through_the_common_choice_too(self):
|
||||
self.assertTrue(self._juge({}, "./script/proxmox/install_proxmox.sh"))
|
||||
|
||||
def test_an_erplibre_vm_is_left_alone(self):
|
||||
self.assertFalse(
|
||||
self._juge({}, "make install_os && make install_odoo_18")
|
||||
)
|
||||
|
||||
def test_the_note_only_shows_when_nothing_reboots_it(self):
|
||||
"""Avec suivi, l'enveloppe redémarre elle-même : réclamer un
|
||||
redémarrage déjà fait est une consigne fausse. Sans suivi, la voie en
|
||||
série s'arrête à la fin du script, et la note est la seule chose qui
|
||||
dit que l'hyperviseur n'est pas encore utilisable."""
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.todo import TODO
|
||||
|
||||
def sommaire(monitor):
|
||||
todo = TODO.__new__(TODO)
|
||||
spec = {
|
||||
"vms": [{"name": "pve-imbrique"}],
|
||||
"install": {
|
||||
"cmd": "./script/proxmox/install_proxmox.sh",
|
||||
"label": "Proxmox VE",
|
||||
"branch": "develop",
|
||||
},
|
||||
"monitor": monitor,
|
||||
"storage": "local",
|
||||
"bridge": "vmbr0",
|
||||
}
|
||||
vm = dict(spec["vms"][0], alias="pve9+pve-imbrique", vmid=102)
|
||||
tampon = io.StringIO()
|
||||
with contextlib.redirect_stdout(tampon):
|
||||
todo._pve_print_summary(spec, [vm], "")
|
||||
return tampon.getvalue()
|
||||
|
||||
self.assertIn("reboot", sommaire(monitor=False))
|
||||
self.assertNotIn("reboot", sommaire(monitor=True))
|
||||
|
||||
def test_a_vm_of_its_own_overrides_the_common_choice(self):
|
||||
# Parc mixte : la commande de la VM l'emporte sur celle du parc.
|
||||
self.assertFalse(
|
||||
self._juge(
|
||||
{"install_cmd": "make install_odoo_18"},
|
||||
"./script/proxmox/install_proxmox.sh",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestLEcranDUneVmProxmox(unittest.TestCase):
|
||||
"""« Console de l'hyperviseur » conseillait des commandes virsh sur une
|
||||
machine qui n'a pas libvirt.
|
||||
|
|
@ -647,92 +882,56 @@ class TestLEcranDUneVmProxmox(unittest.TestCase):
|
|||
|
||||
|
||||
class TestUnSeulNomDansSshConfig(unittest.TestCase):
|
||||
"""L'entrée portait DEUX noms sur sa ligne « Host » : le nom chaîné
|
||||
« hôte+vm » et le nom court.
|
||||
"""L'entrée portait DEUX noms sur sa ligne « Host », puis le mauvais.
|
||||
|
||||
Rapporté : « Host erplibre-proxmox-9+erplibre-arch-latest
|
||||
erplibre-arch-latest ». Le second est un doublon dès que le premier
|
||||
suffit — ssh n'a besoin que d'un nom, et le doubler n'ajoute qu'une façon
|
||||
de plus d'écrire la même adresse.
|
||||
D'abord le doublon : « Host erplibre-proxmox-9+erplibre-arch-latest
|
||||
erplibre-arch-latest ». ssh n'a besoin que d'un nom, et le second
|
||||
n'ajoutait qu'une façon de plus d'écrire la même adresse.
|
||||
|
||||
Un seul, donc, et le bon : le court quand il est LIBRE, le chaîné quand
|
||||
il désignerait une autre machine. « Pris » se juge sur le ProxyJump du
|
||||
bloc, pas sur sa seule présence — sinon notre propre entrée, réécrite à
|
||||
chaque déploiement, se prendrait pour une rivale et le nom basculerait
|
||||
d'une fois sur l'autre."""
|
||||
Puis le choix. Prendre le nom COURT quand il se trouvait libre donnait un
|
||||
parc incohérent : sur un même déploiement de trois VM, deux recevaient
|
||||
« hôte+vm » — leurs noms étaient pris par des domaines locaux — et la
|
||||
troisième son nom court. Une convention qui dépend de ce qui traîne dans
|
||||
le fichier n'est pas une convention. Le chaîné est systématique."""
|
||||
|
||||
def setUp(self):
|
||||
def _choisit(self, nom, locaux=()):
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.todo import TODO
|
||||
|
||||
self.maison = tempfile.mkdtemp()
|
||||
os.makedirs(os.path.join(self.maison, ".ssh"))
|
||||
self._vrai_home = os.environ.get("HOME")
|
||||
os.environ["HOME"] = self.maison
|
||||
self.todo = TODO.__new__(TODO)
|
||||
todo = TODO.__new__(TODO)
|
||||
return todo._pve_alias_names(nom, f"pve9+{nom}", set(locaux), "pve9")
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
if self._vrai_home is not None:
|
||||
os.environ["HOME"] = self._vrai_home
|
||||
shutil.rmtree(self.maison, ignore_errors=True)
|
||||
|
||||
def _ecrit(self, noms, rebond=""):
|
||||
self.todo._write_ssh_config_entry(
|
||||
noms, "erplibre", "10.10.10.150", proxy_jump=rebond or None
|
||||
)
|
||||
|
||||
def _lignes_host(self):
|
||||
with open(
|
||||
os.path.join(self.maison, ".ssh/config"), encoding="utf-8"
|
||||
) as fh:
|
||||
return [
|
||||
ligne.rstrip() for ligne in fh if ligne.startswith("Host ")
|
||||
]
|
||||
|
||||
def _choisit(self, nom, locaux=(), rebond="pve9"):
|
||||
return self.todo._pve_alias_names(
|
||||
nom, f"pve9+{nom}", set(locaux), rebond
|
||||
)
|
||||
|
||||
def test_a_free_name_is_written_alone(self):
|
||||
def test_one_name_and_it_is_the_chained_one(self):
|
||||
noms, vole = self._choisit("erplibre-arch-latest")
|
||||
self.assertEqual(noms, ["erplibre-arch-latest"])
|
||||
self.assertFalse(vole)
|
||||
self._ecrit(noms, "pve9")
|
||||
self.assertEqual(self._lignes_host(), ["Host erplibre-arch-latest"])
|
||||
|
||||
def test_redeploying_the_same_vm_keeps_the_same_name(self):
|
||||
# Le piège du correctif : notre propre bloc déclare déjà le nom.
|
||||
self._ecrit(["erplibre-arch-latest"], "pve9")
|
||||
noms, vole = self._choisit("erplibre-arch-latest")
|
||||
self.assertEqual(noms, ["erplibre-arch-latest"], "le nom a basculé")
|
||||
self.assertEqual(noms, ["pve9+erplibre-arch-latest"])
|
||||
self.assertFalse(vole)
|
||||
|
||||
def test_a_local_vm_keeps_its_name(self):
|
||||
# Vécu : « ssh » partait vers la machine locale du même nom.
|
||||
noms, vole = self._choisit(
|
||||
def test_a_fleet_gets_one_single_convention(self):
|
||||
# Le défaut rapporté : trois VM du même déploiement, deux nommées
|
||||
# d'une façon et la troisième d'une autre.
|
||||
noms = [
|
||||
self._choisit(n, locaux=("erplibre-ubuntu-2604",))[0][0]
|
||||
for n in (
|
||||
"erplibre-ubuntu-2604",
|
||||
"erplibre-arch-latest",
|
||||
"erplibre-proxmox-9",
|
||||
)
|
||||
]
|
||||
self.assertTrue(
|
||||
all(n.startswith("pve9+") for n in noms),
|
||||
f"un parc, une convention : {noms}",
|
||||
)
|
||||
|
||||
def test_a_local_namesake_is_still_named(self):
|
||||
# Le nom chaîné ne lui vole rien, mais on le DIT : c'est ce qui
|
||||
# explique pourquoi « ssh <nom court> » va ailleurs.
|
||||
_noms, vole = self._choisit(
|
||||
"erplibre-arch-latest", locaux=("erplibre-arch-latest",)
|
||||
)
|
||||
self.assertEqual(noms, ["pve9+erplibre-arch-latest"])
|
||||
self.assertTrue(vole)
|
||||
|
||||
def test_another_proxmox_host_keeps_its_name(self):
|
||||
self._ecrit(["erplibre-ubuntu-2604"], "pve7")
|
||||
noms, vole = self._choisit("erplibre-ubuntu-2604")
|
||||
self.assertEqual(noms, ["pve9+erplibre-ubuntu-2604"])
|
||||
self.assertEqual(vole, "~/.ssh/config")
|
||||
self._ecrit(noms, "pve9")
|
||||
# Les deux machines cohabitent, chacune sous son nom.
|
||||
self.assertEqual(
|
||||
self._lignes_host(),
|
||||
["Host erplibre-ubuntu-2604", "Host pve9+erplibre-ubuntu-2604"],
|
||||
)
|
||||
|
||||
def test_no_deploy_path_writes_two_names_anymore(self):
|
||||
import re
|
||||
from pathlib import Path as P
|
||||
|
|
@ -744,6 +943,81 @@ class TestUnSeulNomDansSshConfig(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class TestLAncienNomSEnVa(unittest.TestCase):
|
||||
"""La convention a changé : les entrées écrites AVANT portent le nom
|
||||
court, et rien ne les retirerait — elles ne portent pas le nom qu'on
|
||||
écrit maintenant. Deux blocs mèneraient à la même machine, ce qu'on
|
||||
venait justement d'enlever."""
|
||||
|
||||
def setUp(self):
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.todo import TODO
|
||||
|
||||
self.maison = tempfile.mkdtemp()
|
||||
os.makedirs(os.path.join(self.maison, ".ssh"))
|
||||
self._vrai = os.environ.get("HOME")
|
||||
os.environ["HOME"] = self.maison
|
||||
self.todo = TODO.__new__(TODO)
|
||||
|
||||
def tearDown(self):
|
||||
import os
|
||||
import shutil
|
||||
|
||||
if self._vrai is not None:
|
||||
os.environ["HOME"] = self._vrai
|
||||
shutil.rmtree(self.maison, ignore_errors=True)
|
||||
|
||||
def _hosts(self):
|
||||
import os
|
||||
|
||||
with open(
|
||||
os.path.join(self.maison, ".ssh/config"), encoding="utf-8"
|
||||
) as fh:
|
||||
return [
|
||||
ligne.rstrip() for ligne in fh if ligne.startswith("Host ")
|
||||
]
|
||||
|
||||
def test_the_old_short_entry_is_retired(self):
|
||||
# L'état d'avant : une entrée écrite sous l'ancienne convention.
|
||||
self.todo._write_ssh_config_entry(
|
||||
["vm-a"], "erplibre", "10.10.10.151", proxy_jump="pve9"
|
||||
)
|
||||
perime = self.todo._pve_alias_perime("vm-a", "pve9")
|
||||
self.assertEqual(perime, ["vm-a"])
|
||||
self.todo._write_ssh_config_entry(
|
||||
["pve9+vm-a"],
|
||||
"erplibre",
|
||||
"10.10.10.151",
|
||||
proxy_jump="pve9",
|
||||
also_drop=perime,
|
||||
)
|
||||
self.assertEqual(self._hosts(), ["Host pve9+vm-a"])
|
||||
|
||||
def test_a_local_vm_of_the_same_name_is_left_alone(self):
|
||||
# Sans ProxyJump vers cet hôte, le bloc n'est pas le nôtre : on n'y
|
||||
# touche pas, même s'il porte exactement ce nom.
|
||||
self.todo._write_ssh_config_entry(["vm-a"], "erplibre", "192.168.1.9")
|
||||
self.assertEqual(self.todo._pve_alias_perime("vm-a", "pve9"), [])
|
||||
self.todo._write_ssh_config_entry(
|
||||
["pve9+vm-a"],
|
||||
"erplibre",
|
||||
"10.10.10.151",
|
||||
proxy_jump="pve9",
|
||||
also_drop=self.todo._pve_alias_perime("vm-a", "pve9"),
|
||||
)
|
||||
self.assertEqual(self._hosts(), ["Host vm-a", "Host pve9+vm-a"])
|
||||
|
||||
def test_another_hosts_vm_is_left_alone(self):
|
||||
self.todo._write_ssh_config_entry(
|
||||
["vm-a"], "erplibre", "10.0.0.9", proxy_jump="pve7"
|
||||
)
|
||||
self.assertEqual(self.todo._pve_alias_perime("vm-a", "pve9"), [])
|
||||
|
||||
|
||||
class TestLeGuideDeConnexion(unittest.TestCase):
|
||||
"""Une VM Proxmox n'avait AUCUN guide, quelle que soit sa distribution.
|
||||
|
||||
|
|
@ -920,10 +1194,9 @@ class TestLeSuivi(unittest.TestCase):
|
|||
nom
|
||||
)
|
||||
todo._ssh_private_key = lambda k: None
|
||||
# Hermétique : le choix du nom lit ~/.ssh/config et la liste des
|
||||
# domaines locaux. Sans ces deux bouchons, le test dépendrait de
|
||||
# la machine qui le lance.
|
||||
todo._ssh_config_block = lambda nom: {}
|
||||
# Hermétique : le choix du nom lit la liste des domaines
|
||||
# locaux. Sans ce bouchon, le test dépendrait de la machine qui
|
||||
# le lance.
|
||||
todo._qemu_list_domains = lambda: []
|
||||
todo._pve_guest_ip = lambda vmid, attente=120: ""
|
||||
todo._qemu_install_erplibre_monitored = lambda *a, **k: None
|
||||
|
|
@ -948,16 +1221,16 @@ class TestLeSuivi(unittest.TestCase):
|
|||
return ecrites
|
||||
|
||||
cmd = {"branch": "develop", "cmd": "make x", "label": "X"}
|
||||
# UN nom : le court, puisque rien ne le porte déjà. Le chaîné
|
||||
# « hôte+vm » ne sort que lorsqu'il faut départager (voir
|
||||
# UN nom, et le chaîné : « hôte+vm » dit où la machine vit et ne
|
||||
# dépend pas de ce qui traîne dans ~/.ssh/config (voir
|
||||
# TestUnSeulNomDansSshConfig).
|
||||
self.assertEqual(essai(False, cmd, False), [["vm-a"]])
|
||||
self.assertEqual(essai(False, cmd, False), [["pve1+vm-a"]])
|
||||
# Décoché, suivi demandé : le suivi entre aussi par le rebond.
|
||||
self.assertEqual(essai(False, None, True), [["vm-a"]])
|
||||
self.assertEqual(essai(False, None, True), [["pve1+vm-a"]])
|
||||
# Décoché et rien à faire dans la VM : le choix est respecté.
|
||||
self.assertEqual(essai(False, None, False), [])
|
||||
# Coché : écrite, évidemment.
|
||||
self.assertEqual(essai(True, None, False), [["vm-a"]])
|
||||
self.assertEqual(essai(True, None, False), [["pve1+vm-a"]])
|
||||
|
||||
def test_a_local_vm_of_the_same_name_keeps_its_alias(self):
|
||||
"""Le piège qui a fait installer ERPLibre sur la MAUVAISE machine.
|
||||
|
|
|
|||
|
|
@ -223,10 +223,12 @@ class TestLesEntreesSsh(unittest.TestCase):
|
|||
def test_a_name_that_matches_a_domain_is_kept(self):
|
||||
self.assertTrue(self._juge("erplibre-ubuntu-2404-MIGRATION"))
|
||||
|
||||
def test_a_jump_entry_is_kept(self):
|
||||
# Écrite pour une VM imbriquée ou distante : virsh ne la connaîtra
|
||||
# jamais, et son adresse n'est pas routable d'ici.
|
||||
self.assertTrue(self._juge("erplibre-imbriquee"))
|
||||
def test_a_jump_is_not_a_direct_proof(self):
|
||||
# Un ProxyJump valait preuve À LUI SEUL. Il n'en est plus une ICI :
|
||||
# la réponse dépend du rebond, donc des AUTRES entrées, et elle se
|
||||
# décide dans ssh_orphans. Sinon une chaîne de rebonds morts se
|
||||
# soutiendrait toute seule.
|
||||
self.assertEqual(self._juge("erplibre-imbriquee"), "")
|
||||
|
||||
def test_a_vm_of_the_proxmox_host_is_kept(self):
|
||||
self.assertTrue(
|
||||
|
|
@ -237,6 +239,126 @@ class TestLesEntreesSsh(unittest.TestCase):
|
|||
self.assertEqual(self._juge("erplibre-partie"), "")
|
||||
|
||||
|
||||
class TestLeRebondQuiNExistePlus(unittest.TestCase):
|
||||
"""Le nettoyage a effacé la VM Proxmox locale, retiré son entrée — c'était
|
||||
juste — et GARDÉ les trois entrées qui rebondissaient par elle.
|
||||
|
||||
Trois culs-de-sac, présentés comme « mènent encore quelque part ». Deux
|
||||
fautes : un ProxyJump valait preuve de vie sans qu'on regarde jamais si le
|
||||
rebond existait, et chaque entrée était jugée ISOLÉMENT — retirer le
|
||||
parent ne faisait pas réexaminer les enfants."""
|
||||
|
||||
CONFIG = """
|
||||
Host erplibre-vivante
|
||||
HostName 192.168.123.170
|
||||
|
||||
Host erplibre-proxmox-9
|
||||
HostName 192.168.123.208
|
||||
|
||||
Host erplibre-proxmox-9+enfant
|
||||
HostName 10.10.10.150
|
||||
ProxyJump erplibre-proxmox-9
|
||||
|
||||
Host erplibre-petit-enfant
|
||||
HostName 10.10.20.1
|
||||
ProxyJump erplibre-proxmox-9+enfant
|
||||
|
||||
Host erplibre-par-hote-personnel
|
||||
HostName 10.10.30.1
|
||||
ProxyJump mon-serveur-perso
|
||||
|
||||
Host mon-serveur-perso
|
||||
HostName 203.0.113.9
|
||||
"""
|
||||
|
||||
def _passe(self, vivants):
|
||||
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
|
||||
|
||||
return ssh_orphans(
|
||||
parse_ssh_blocks(self.CONFIG),
|
||||
lambda nom: nom if nom in vivants else "",
|
||||
)
|
||||
|
||||
def test_the_children_go_with_their_jump(self):
|
||||
_gardes, orphelines = self._passe({"erplibre-vivante"})
|
||||
noms = [n for n, _r in orphelines]
|
||||
self.assertIn("erplibre-proxmox-9", noms)
|
||||
self.assertIn("erplibre-proxmox-9+enfant", noms)
|
||||
|
||||
def test_and_so_do_the_grandchildren(self):
|
||||
# Le point fixe : retirer un parent orpheline ses enfants, qui
|
||||
# orphelinent les leurs. Une seule passe n'aurait vu que le premier
|
||||
# étage.
|
||||
_gardes, orphelines = self._passe({"erplibre-vivante"})
|
||||
self.assertIn("erplibre-petit-enfant", [n for n, _r in orphelines])
|
||||
|
||||
def test_the_reason_names_the_missing_jump(self):
|
||||
# Seule chose qui permet de répondre non en connaissance de cause.
|
||||
_gardes, orphelines = self._passe({"erplibre-vivante"})
|
||||
raison = dict(orphelines)["erplibre-proxmox-9+enfant"]
|
||||
self.assertIn("erplibre-proxmox-9", raison)
|
||||
|
||||
def test_a_living_jump_keeps_its_children(self):
|
||||
gardes, _orphelines = self._passe(
|
||||
{"erplibre-vivante", "erplibre-proxmox-9"}
|
||||
)
|
||||
noms = [n for n, _r in gardes]
|
||||
self.assertIn("erplibre-proxmox-9+enfant", noms)
|
||||
self.assertIn("erplibre-petit-enfant", noms)
|
||||
|
||||
def test_a_jump_whose_entry_is_already_gone(self):
|
||||
"""L'état laissé par le nettoyage précédent : le parent RETIRÉ, les
|
||||
enfants gardés.
|
||||
|
||||
Le rebond ne désigne alors plus rien du tout — ni entrée, ni domaine.
|
||||
Le prendre pour « un rebond qu'on ne gère pas » laissait les trois
|
||||
culs-de-sac en place une seconde fois."""
|
||||
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
|
||||
|
||||
sans_parent = """
|
||||
Host erplibre-vivante
|
||||
HostName 192.168.123.170
|
||||
|
||||
Host erplibre-proxmox-9+enfant
|
||||
HostName 10.10.10.150
|
||||
ProxyJump erplibre-proxmox-9
|
||||
"""
|
||||
_gardes, orphelines = ssh_orphans(
|
||||
parse_ssh_blocks(sans_parent),
|
||||
lambda nom: nom if nom == "erplibre-vivante" else "",
|
||||
)
|
||||
self.assertEqual(
|
||||
[n for n, _r in orphelines], ["erplibre-proxmox-9+enfant"]
|
||||
)
|
||||
|
||||
def test_a_jump_that_is_a_living_domain_without_an_entry(self):
|
||||
# Le nom est du nôtre et n'a pas d'entrée, mais le domaine TOURNE :
|
||||
# on ne coupe pas.
|
||||
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
|
||||
|
||||
cfg = """
|
||||
Host erplibre-enfant
|
||||
HostName 10.10.10.150
|
||||
ProxyJump erplibre-hote
|
||||
"""
|
||||
gardes, _o = ssh_orphans(
|
||||
parse_ssh_blocks(cfg),
|
||||
lambda nom: nom if nom == "erplibre-hote" else "",
|
||||
)
|
||||
self.assertEqual([n for n, _r in gardes], ["erplibre-enfant"])
|
||||
|
||||
def test_a_jump_we_do_not_manage_is_never_our_call(self):
|
||||
# Hôte personnel, adresse, nom DNS : on le suppose vivant plutôt que
|
||||
# d'effacer sur une supposition.
|
||||
gardes, _orphelines = self._passe({"erplibre-vivante"})
|
||||
self.assertIn("erplibre-par-hote-personnel", [n for n, _r in gardes])
|
||||
|
||||
def test_entries_outside_the_prefix_are_never_judged(self):
|
||||
gardes, orphelines = self._passe({"erplibre-vivante"})
|
||||
tous = [n for n, _r in gardes] + [n for n, _r in orphelines]
|
||||
self.assertNotIn("mon-serveur-perso", tous)
|
||||
|
||||
|
||||
class TestLAdresseDUneVm(unittest.TestCase):
|
||||
"""« --source arp » remonte les passerelles des ponts : la dernière
|
||||
candidate n'est pas la bonne.
|
||||
|
|
|
|||
|
|
@ -230,6 +230,296 @@ class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase):
|
|||
self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2)
|
||||
|
||||
|
||||
class TestLeRedemarrageQuiFaitPartieDeLInstallation(unittest.TestCase):
|
||||
"""Proxmox VE n'existe qu'après un redémarrage, et le script ne peut pas
|
||||
survivre au sien.
|
||||
|
||||
install_proxmox.sh pose le noyau puis s'arrête, à raison — lancé par ssh,
|
||||
un reboot couperait la session et ferait passer l'installation pour un
|
||||
échec. La VM restait donc sur le noyau cloud de Debian, dépouillé de tout
|
||||
netfilter, et on le découvrait des jours plus tard en créant un pont.
|
||||
|
||||
L'enveloppe, elle, tourne sur NOTRE machine : elle survit au redémarrage
|
||||
de la VM. Elle redémarre, attend, vérifie le noyau, et ne conclut
|
||||
qu'ensuite — le ✅ veut donc dire « hyperviseur utilisable »."""
|
||||
|
||||
def _joue(self, rc, faux_ssh, tours=4):
|
||||
"""Exécute le shell RÉEL, avec ssh bouchonné par une fonction."""
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
log = tempfile.NamedTemporaryFile(suffix=".log", delete=False)
|
||||
log.close()
|
||||
cpt = tempfile.NamedTemporaryFile("w+", delete=False)
|
||||
cpt.write("0")
|
||||
cpt.close()
|
||||
shell = (
|
||||
f"CPT={cpt.name}\n{faux_ssh}\n"
|
||||
f"ip=10.0.0.1; rc={rc}; "
|
||||
+ mon._reboot_steps(log.name, "-pve", tours=tours)
|
||||
+ f'echo "{mon.EXIT_MARKER} $rc" >> {log.name}'
|
||||
)
|
||||
subprocess.run(
|
||||
["bash", "-c", shell],
|
||||
env=dict(os.environ, ERPLIBRE_REBOOT_SLEEP="0"),
|
||||
timeout=60,
|
||||
)
|
||||
with open(log.name, encoding="utf-8") as fh:
|
||||
texte = fh.read()
|
||||
etat = mon.read_status(log.name)
|
||||
os.unlink(log.name)
|
||||
os.unlink(cpt.name)
|
||||
return etat, texte
|
||||
|
||||
# Le compteur vit dans un FICHIER : « k=$(ssh …) » tourne en sous-shell.
|
||||
REVIENT = """ssh() {
|
||||
case "$*" in
|
||||
*"uname -r"*)
|
||||
n=$(cat "$CPT"); n=$((n+1)); echo "$n" > "$CPT"
|
||||
if [ "$n" -ge 3 ]; then echo "7.0.14-14-pve";
|
||||
else echo "6.12.101+deb13-cloud-amd64"; fi;;
|
||||
*reboot*) return 255;;
|
||||
esac
|
||||
}"""
|
||||
RESTE = """ssh() {
|
||||
case "$*" in
|
||||
*"uname -r"*) echo "6.12.101+deb13-cloud-amd64";;
|
||||
*reboot*) return 255;;
|
||||
esac
|
||||
}"""
|
||||
|
||||
def test_only_the_pve_kernel_ends_the_wait(self):
|
||||
# Le piège : sshd répond encore une seconde ou deux après l'ordre de
|
||||
# redémarrage. Lire « uname -r » et s'arrêter là donnerait l'ANCIEN
|
||||
# noyau en croyant avoir la réponse. Ici les deux premières lectures
|
||||
# rendent le noyau cloud et doivent être REJETÉES.
|
||||
(etat, code), texte = self._joue(0, self.REVIENT)
|
||||
self.assertEqual((etat, code), ("done", 0))
|
||||
self.assertIn("7.0.14-14-pve", texte)
|
||||
|
||||
def test_a_machine_that_stays_on_the_old_kernel_fails(self):
|
||||
# Sans le noyau attendu, l'hyperviseur n'a ni table NAT ni module
|
||||
# bridge. Le dire ✅ serait le mensonge qui a coûté deux jours.
|
||||
(etat, code), texte = self._joue(0, self.RESTE)
|
||||
self.assertEqual(etat, "failed")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("-pve", texte)
|
||||
|
||||
def test_a_machine_that_never_answers_fails(self):
|
||||
(etat, _c), _texte = self._joue(0, "ssh() { return 255; }")
|
||||
self.assertEqual(etat, "failed")
|
||||
|
||||
def test_a_failed_install_is_never_rebooted(self):
|
||||
# Redémarrer après un échec effacerait la seule machine sur laquelle
|
||||
# on pouvait chercher.
|
||||
(etat, code), texte = self._joue(
|
||||
2, 'ssh() { echo "NE DEVRAIT PAS ETRE APPELE"; }'
|
||||
)
|
||||
self.assertEqual((etat, code), ("failed", 2))
|
||||
self.assertNotIn("NE DEVRAIT PAS", texte)
|
||||
self.assertNotIn("Redémarrage", texte)
|
||||
|
||||
def test_which_installs_ask_for_it(self):
|
||||
self.assertEqual(
|
||||
mon.reboot_expected("./script/proxmox/install_proxmox.sh"), "-pve"
|
||||
)
|
||||
self.assertEqual(
|
||||
mon.reboot_expected("make install_os && make install_odoo_18"), ""
|
||||
)
|
||||
self.assertEqual(mon.reboot_expected(""), "")
|
||||
|
||||
def test_the_wrapper_only_reboots_when_asked(self):
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(mon._launch_one)
|
||||
self.assertIn("_reboot_steps(log_q, reboot) if reboot else", src)
|
||||
|
||||
|
||||
class TestTroisVmSurUnProxmox(unittest.TestCase):
|
||||
"""Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait
|
||||
ses colonnes vides — et les deux autres montraient les chiffres d'une
|
||||
AUTRE machine.
|
||||
|
||||
Deux fautes, dont une était le miroir d'un correctif précédent."""
|
||||
|
||||
def test_a_reading_stands_even_when_odoo_is_not_up_yet(self):
|
||||
# Le code de sortie de la suite distante est celui de son DERNIER
|
||||
# maillon : la sonde Odoo. Tant qu'Odoo n'écoute pas — c'est-à-dire
|
||||
# pendant TOUTE l'installation, précisément quand on regarde — la
|
||||
# boucle finit en échec et le relevé, parfait, était jeté.
|
||||
#
|
||||
# On avait corrigé l'erreur inverse (code 0 pris pour une réponse) ;
|
||||
# exiger 0 était la même faute, retournée.
|
||||
sortie = (
|
||||
'[{"vmid":101,"name":"vm-a","status":"running","maxmem":1024,'
|
||||
'"mem":512,"maxdisk":2048,"diskwrite":10}]\n'
|
||||
"---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n"
|
||||
)
|
||||
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
|
||||
vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 101}}
|
||||
with mock.patch(
|
||||
"script.proxmox.proxmox_deploy.run", return_value=(1, sortie)
|
||||
):
|
||||
stats, ok = mon.read_pvestats_detail([vm], now=10.0)
|
||||
self.assertTrue(
|
||||
ok, "un code non nul ne réfute pas une réponse lisible"
|
||||
)
|
||||
self.assertIn("vm-a", stats)
|
||||
|
||||
def test_the_odoo_flag_survives_a_partly_closed_fleet(self):
|
||||
"""Le cas rapporté, et il est le cas NORMAL.
|
||||
|
||||
La sonde est le dernier maillon : elle boucle sur toutes les adresses
|
||||
et son code est celui de la DERNIÈRE. Un parc où une seule VM n'a pas
|
||||
d'Odoo — un hyperviseur imbriqué, par exemple — finit donc en échec,
|
||||
et le relevé entier partait, drapeaux Odoo compris. Le navigateur, lui,
|
||||
répondait 303."""
|
||||
sortie = (
|
||||
'[{"vmid":100,"name":"vm-a","status":"running","maxmem":1024,'
|
||||
'"mem":512,"maxdisk":2048,"diskwrite":10},'
|
||||
'{"vmid":102,"name":"pve-imbrique","status":"running",'
|
||||
'"maxmem":1024,"mem":512,"maxdisk":2048,"diskwrite":10}]\n'
|
||||
"---ERPLIBRE-DU---\n"
|
||||
"---ERPLIBRE-ODOO---\nODOO 10.10.10.150\n"
|
||||
)
|
||||
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
|
||||
vms = [
|
||||
{
|
||||
"name": "vm-a",
|
||||
"pve": {
|
||||
"target": "h",
|
||||
"sudo": "",
|
||||
"vmid": 100,
|
||||
"addr": "10.10.10.150",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "pve-imbrique",
|
||||
"pve": {
|
||||
"target": "h",
|
||||
"sudo": "",
|
||||
"vmid": 102,
|
||||
"addr": "10.10.10.152",
|
||||
},
|
||||
},
|
||||
]
|
||||
with mock.patch(
|
||||
"script.proxmox.proxmox_deploy.run", return_value=(1, sortie)
|
||||
):
|
||||
stats, ok = mon.read_pvestats_detail(vms, now=30.0)
|
||||
self.assertTrue(ok)
|
||||
self.assertTrue(stats["vm-a"]["odoo"], "la VM qui répond doit être 🟢")
|
||||
self.assertFalse(
|
||||
stats["pve-imbrique"]["odoo"], "un hyperviseur n'a pas d'Odoo"
|
||||
)
|
||||
|
||||
def test_a_broken_pvesh_is_still_refuted(self):
|
||||
# L'autre sens tient toujours : sans liste analysable, pas de réponse.
|
||||
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
|
||||
vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 101}}
|
||||
with mock.patch(
|
||||
"script.proxmox.proxmox_deploy.run",
|
||||
return_value=(0, "permission denied\n---ERPLIBRE-DU---\n"),
|
||||
):
|
||||
_stats, ok = mon.read_pvestats_detail([vm], now=20.0)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_a_remote_vm_never_borrows_a_local_namesake(self):
|
||||
# « virsh domstats » indexe par NOM, et un nom se partage. Mesuré :
|
||||
# « erplibre-ubuntu-2604 » sur Proxmox affichait 1,5 Gio sur 12 et
|
||||
# 58 Gio de disque — ceux de la machine locale du même nom — quand la
|
||||
# vraie tournait avec 3 Gio et 25.
|
||||
locaux = {
|
||||
"erplibre-ubuntu-2604": {
|
||||
"ram_used": 1 << 30,
|
||||
"ram_total": 12 << 30,
|
||||
},
|
||||
"erplibre-arch-latest": {"ram_used": 5, "ram_total": 9},
|
||||
"vm-locale": {"ram_used": 7, "ram_total": 8},
|
||||
}
|
||||
vms = [
|
||||
{"name": "erplibre-ubuntu-2604", "pve": {"vmid": 100}},
|
||||
{"name": "vm-locale"},
|
||||
]
|
||||
reste = mon.drop_local_twins(dict(locaux), vms)
|
||||
self.assertNotIn("erplibre-ubuntu-2604", reste)
|
||||
# Une VM locale garde les siens, et une VM étrangère au manifeste
|
||||
# n'est pas touchée.
|
||||
self.assertIn("vm-locale", reste)
|
||||
self.assertIn("erplibre-arch-latest", reste)
|
||||
|
||||
def test_a_silent_host_leaves_the_column_empty(self):
|
||||
# Vide, c'est vrai. Une colonne vide se remarque ; une colonne juste
|
||||
# et fausse, non — c'est ce qui a fait remonter le défaut.
|
||||
stats = {"vm-a": {"ram_used": 1, "ram_total": 2}}
|
||||
mon.drop_local_twins(stats, [{"name": "vm-a", "pve": {"vmid": 1}}])
|
||||
self.assertEqual(stats, {})
|
||||
|
||||
|
||||
class TestEffacerDepuisUnSuiviRouvert(unittest.TestCase):
|
||||
"""Le suivi se ROUVRE sur un manifeste passé — c'est fait pour.
|
||||
|
||||
Mais un nom de domaine se réemploie, et un VMID libéré est RÉATTRIBUÉ.
|
||||
Effacer « le 101 » d'un run de mars, c'est effacer ce qui porte le 101
|
||||
aujourd'hui, et « erplibre-ubuntu-2604 » de mars n'est pas celui
|
||||
d'aujourd'hui. Même famille que tout le reste : on jugeait sur le nom.
|
||||
|
||||
La commande porte donc son garde. Dans la commande et non dans l'écran :
|
||||
elle protège ainsi tous ses appelants, et la vérification se fait SUR la
|
||||
machine, à l'instant d'effacer."""
|
||||
|
||||
def test_a_proxmox_delete_checks_the_vmid_still_bears_the_name(self):
|
||||
cmd = mon.delete_vm_cmd_pve(
|
||||
{"target": "pve9", "vmid": 101}, name="vm-a"
|
||||
)
|
||||
self.assertIn("qm config 101", cmd)
|
||||
self.assertIn("exit 1", cmd)
|
||||
self.assertIn("qm destroy 101", cmd)
|
||||
# Le garde vient AVANT la destruction, sinon il ne garde rien.
|
||||
self.assertLess(cmd.index("qm config 101"), cmd.index("qm destroy"))
|
||||
|
||||
def test_a_local_delete_checks_the_uuid(self):
|
||||
cmd = mon.delete_vm_cmd("vm-a", True, "5d55d05a-1e77")
|
||||
self.assertIn("virsh domuuid vm-a", cmd)
|
||||
self.assertIn("5d55d05a-1e77", cmd)
|
||||
self.assertLess(cmd.index("domuuid"), cmd.index("virsh destroy vm-a"))
|
||||
|
||||
def test_an_old_manifest_without_identity_still_deletes(self):
|
||||
# Un manifeste écrit avant ce correctif n'a pas d'UUID. Refuser toute
|
||||
# suppression y serait une régression : on retombe sur la protection
|
||||
# d'avant, la confirmation à deux mains.
|
||||
cmd = mon.delete_vm_cmd("vm-a", True)
|
||||
self.assertNotIn("domuuid", cmd)
|
||||
self.assertIn("virsh undefine vm-a", cmd)
|
||||
sans_nom = mon.delete_vm_cmd_pve({"target": "pve9", "vmid": 101})
|
||||
self.assertNotIn("qm config", sans_nom)
|
||||
self.assertIn("qm destroy 101", sans_nom)
|
||||
|
||||
def test_the_guard_is_shell_correct(self):
|
||||
"""Le garde est EXÉCUTÉ, « qm » bouchonné par une fonction shell.
|
||||
|
||||
Il traverse ensuite deux « shlex.quote » avant d'atteindre un dash :
|
||||
chaque niveau est une occasion de le casser, et un garde cassé
|
||||
s'OUVRE au lieu de se fermer. Éprouvé aussi sur le vrai hôte, où il a
|
||||
refusé un nom périmé et laissé passer le bon."""
|
||||
import subprocess
|
||||
|
||||
garde = mon.pve_identity_guard(101, "vm-a")
|
||||
for vu, attendu in (("vm-a", 0), ("autre-vm", 1)):
|
||||
res = subprocess.run(
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
f"qm() {{ echo 'name: {vu}'; }}; {garde} exit 0",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(res.returncode, attendu, f"{vu} : {res.stdout}")
|
||||
self.assertIn("vm-a", garde)
|
||||
|
||||
|
||||
class TestCeQueLaConfirmationPromet(unittest.TestCase):
|
||||
"""La confirmation de suppression annonçait un fichier qcow2 local à
|
||||
TOUTE VM, Proxmox comprise.
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ def releve(fabrique, ctx, gestes=None):
|
|||
vu["ids"] = {w.id for w in app.query("#fields *") if w.id}
|
||||
vu["spec"] = app._form_values()
|
||||
vu["noms"] = [r["vm"]["name"] for r in app.rows]
|
||||
vu["rangee"] = [w.id for w in app.query("#plan *") if w.id]
|
||||
vu["disques"] = [r["disk_gb"] for r in app.rows]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
|
@ -163,6 +164,17 @@ class TestLesDeuxEcrans(unittest.TestCase):
|
|||
vide.extras_init({})
|
||||
return vide
|
||||
|
||||
def test_both_screens_offer_the_same_per_vm_choices(self):
|
||||
"""Branche, profil, type : ce qu'une VM peut prendre à elle seule.
|
||||
|
||||
L'écran Proxmox n'en offrait aucun, alors qu'on y déploie le plus
|
||||
souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM
|
||||
ERPLibre — c'est-à-dire le cas où ils servent le plus."""
|
||||
for ident in ("v0_branch", "v0_prof", "v0_type"):
|
||||
with self.subTest(reglage=ident):
|
||||
self.assertIn(ident, self.qemu["rangee"], "QEMU/KVM")
|
||||
self.assertIn(ident, self.pve["rangee"], "Proxmox")
|
||||
|
||||
def test_both_screens_bind_the_same_catalog_shortcuts(self):
|
||||
# « Versions principales » (F7) manquait à l'écran Proxmox, qui
|
||||
# affiche pourtant le même catalogue, drapeau « default » compris.
|
||||
|
|
|
|||
Loading…
Reference in a new issue