Merge branch 'dev_fix_install_system_and_security'

[FIX] release: what the build chain still lacked

Four blockers met on real machines, each silent in its own way.

pyproj needs the proj BINARY, not only its headers, and the distributions
that lag behind the version it wants now get PROJ built from source rather
than a failure three steps later.

A systemd unit launching run.sh died on 203/EXEC -- the code systemd
returns when it cannot execute, which says nothing about why. Going
through bash makes the reason visible.

pykcs11 does not compile with SWIG 4.3 and above without help.

A make target runs the unit tests, with the mobile dependency declared, so
the suite can be run without knowing which extras it needs. One test was
checking the mode umask decides rather than the exec bit git stores, and
passed or failed depending on the machine.

--- FR ---

Quatre blocages rencontrés sur des machines réelles, chacun muet à sa
façon.

pyproj exige le BINAIRE proj, pas seulement ses en-têtes, et les
distributions en retard sur la version voulue reçoivent désormais un PROJ
bâti depuis les sources plutôt qu'un échec trois étapes plus loin.

Une unité systemd lançant run.sh mourait sur 203/EXEC — le code que
systemd rend quand il ne peut pas exécuter, et qui ne dit rien du
pourquoi. Passer par bash rend la raison visible.

pykcs11 ne se compile pas sans aide avec SWIG 4.3 et au-delà.

Une cible make lance les tests unitaires, avec la dépendance mobile
déclarée, pour que la suite se lance sans savoir quels extras elle exige.
Un test vérifiait le mode que décide umask plutôt que le bit d'exécution
que stocke git, et passait ou échouait selon la machine.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 02:12:09 -04:00
commit b352f6fbf1
25 changed files with 816 additions and 67 deletions

View file

@ -34,7 +34,17 @@ make db_clone_test_to_test2 # Cloner test -> test2
```
## Tests
### Suite complète
### Unitaires python (rapides, sans base de données)
```bash
make test_unit # tout ce qui tourne sans DB ni Odoo
make test_unit_file F=test/test_qemu_motd.py # un seul fichier
```
Ils lisent le code et exécutent les fragments de shell générés par `todo.py`,
avec `sudo`, `pgrep` et `pkill` bouchonnés — quelques secondes. Le lanceur
annonce sa dépendance à `mobile/erplibre_home_mobile` : absent, les tests du
transfert mobile se déclarent ignorés au lieu de passer en silence.
### Suite complète (base de données requise)
```bash
make test # Tests de base + format
make test_full_fast # Tests complets en parallèle

View file

@ -98,6 +98,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced
- The website copy analysis diffs each copy against the view it shadows
- A test checks that what the tooling imports is declared
- Automated Odoo migration: the tool drives the whole run -- repair, replay, an auto-run that takes the default after five seconds, and a countdown that names the answer it is about to take
- Migration state screen: « t » shows where a run stands, with coloured commands, elapsed time, the server log read for you, and one log file per step on disk
- Migration quality: what a run gained and lost step by step, the missing files named, Odoo's own redesigns told apart from real losses, and OpenUpgrade's declared changes laid over the real ones
- Migration repairs: the customized SCSS the next bump breaks, predicted then fixed; themes uninstalled before the first bump; DMS visibility restored; views whose stored type contradicts their parent; and the <tree> tags Odoo 18 renamed to <list>
- Smoke tests after a migration: every public URL requested, /my and every app opened as the neutralization test user, the views behind a failing URL named, and the OCA database cleanup run first
- Filestore check: whether the attachment files landed, whether the record still exists, and the cleanups offered on the spot
- Analysis: the modules a database lacks against the default package, with an offer to install those that are ready, and the attachment files that are truly unrecoverable
- Debian on s390x through debian-installer, no cloud image being published for that architecture
- A mobile development VM: PyCharm, Android Studio, GNOME extensions, an Android emulator and an adb tunnel for scrcpy, with the mobile app built and tested inside it
- Forgejo, a git forge installable from the deployment menu
- VM hardware set per machine: the host GPU, the CPU mode, the screens and the network
- 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
<!-- [fr] -->
@ -160,6 +174,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce
- L'analyse des copies de site compare chaque copie à la vue qu'elle masque
- Un test vérifie que ce qu'importe l'outillage est bien déclaré
- Migration Odoo automatisée : l'outil mène toute l'exécution — réparer, rejouer, un déroulement automatique qui prend le défaut au bout de cinq secondes, et un compte à rebours qui nomme la réponse qu'il va prendre
- Écran d'état de migration : « t » montre où en est une exécution, avec les commandes en couleur, la durée écoulée, le journal du serveur lu pour vous, et un fichier de journal par étape sur disque
- Qualité de migration : ce qu'une exécution a gagné et perdu étape par étape, les fichiers manquants nommés, les refontes propres à Odoo distinguées des vraies pertes, et les changements déclarés par OpenUpgrade superposés aux réels
- Réparations de migration : le SCSS personnalisé que le palier suivant casse, prévu puis corrigé ; les thèmes désinstallés avant le premier palier ; la visibilité DMS rétablie ; les vues dont le type stocké contredit leur parent ; et les balises <tree> qu'Odoo 18 a renommées en <list>
- Tests de fumée après une migration : chaque URL publique demandée, /my et chaque application ouverte sous l'utilisateur de test de neutralisation, les vues derrière une URL en échec nommées, et le nettoyage de base OCA passé d'abord
- Vérification du filestore : si les fichiers joints sont bien arrivés, si l'enregistrement existe encore, et les nettoyages proposés sur place
- Analyse : les modules qui manquent à une base par rapport au paquet par défaut, avec une offre d'installer ceux qui sont prêts, et les fichiers joints réellement irrécupérables
- Debian sur s390x par debian-installer, aucune image cloud n'étant publiée pour cette architecture
- Une VM de développement mobile : PyCharm, Android Studio, extensions GNOME, un émulateur Android et un tunnel adb pour scrcpy, l'application mobile étant compilée et testée dedans
- Forgejo, une forge git installable depuis le menu de déploiement
- Matériel réglé par VM : le GPU de l'hôte, le mode CPU, les écrans et le réseau
- 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
<!-- [en] -->
## Removed
@ -204,6 +232,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Copyright year updated to 2026
- Canadian pacman mirrors placed first on Arch, the official geographic mirror measuring four times slower from Montréal
- A Poetry dependency can be declined per architecture: factur-x is pinned to 3.x on s390x, where saxonche publishes no wheel, and PyMuPDF is set aside there
- Enter targets the highest supported Odoo version, the default being computed from the menu
- A make target runs the unit tests, with the mobile dependency declared
<!-- [fr] -->
@ -232,6 +262,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Année de copyright portée à 2026
- Miroirs pacman canadiens placés en tête sur Arch, le miroir « géographique » officiel mesurant quatre fois plus lent depuis Montréal
- Une dépendance Poetry peut être déclinée par architecture : factur-x est épinglé en 3.x sur s390x, où saxonche ne publie pas de roue, et PyMuPDF y est écarté
- Entrée cible la version d'Odoo la plus élevée supportée, le défaut étant calculé depuis le menu
- Une cible make lance les tests unitaires, avec la dépendance mobile déclarée
<!-- [en] -->
## Fixed
@ -255,6 +287,13 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported
- The COW migration tools and the database upgrade speak the system language
- The analysis and migration tools are executable
- The forgejo installer no longer echoes the administrator password it has just set
- The Selenium login re-sent the configured default instead of the credentials it was given, when it retried after dismissing a modal
- db_restore asks the master password again instead of dying on a typo
- pyproj needs the proj binary, not only its headers, and PROJ is built where the distribution lags behind
- run.sh is launched through bash, against systemd's 203/EXEC failures
- pykcs11 compiles with SWIG 4.3 and above
- os-release replaces lsb_release, and an IP collision is easier to see
<!-- [fr] -->
@ -274,6 +313,13 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées
- Les outils de migration COW et la mise à niveau de la base parlent la langue du système
- Les outils d'analyse et de migration sont exécutables
- L'installateur forgejo ne réaffiche plus le mot de passe administrateur qu'il vient de poser
- La connexion Selenium renvoyait le défaut de configuration au lieu des identifiants reçus, lors de la reprise après une modale
- db_restore redemande le mot de passe maître au lieu de mourir sur une faute de frappe
- pyproj exige le binaire proj, pas seulement ses en-têtes, et PROJ est bâti là où la distribution est en retard
- run.sh est lancé par bash, contre les échecs 203/EXEC de systemd
- pykcs11 se compile avec SWIG 4.3 et au-delà
- os-release remplace lsb_release, et une collision d'IP se voit mieux
<!-- [en] -->
## Security
@ -282,11 +328,16 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
<!-- [en] -->
- Passwords and tokens are redacted before a command is displayed or logged
- The Odoo master password no longer travels on the command line: MASTER_PWD carries it, and /proc/<pid>/environ is readable only by its owner where /proc/<pid>/cmdline is readable by every user on the machine (needs the matching commit in the odoo fork)
- The KeePass password reaches the Selenium login the same way: the command carries the NAME of an environment variable, never the value
- What a command PRINTS is redacted like the command itself: a tool that reprints its own arguments no longer puts the secret back into the terminal and into the log file
<!-- [fr] -->
- Les mots de passe et jetons sont caviardés avant l'affichage ou la
journalisation d'une commande
- Les mots de passe et jetons sont caviardés avant l'affichage ou la journalisation d'une commande
- Le mot de passe maître d'Odoo ne voyage plus sur la ligne de commande : MASTER_PWD le porte, et /proc/<pid>/environ n'est lisible que par son propriétaire là où /proc/<pid>/cmdline l'est par tout utilisateur de la machine (exige le commit correspondant dans le fork odoo)
- Le mot de passe KeePass parvient à la connexion Selenium de la même façon : la commande porte le NOM d'une variable d'environnement, jamais la valeur
- Ce qu'une commande AFFICHE est caviardé comme la commande elle-même : un outil qui réaffiche ses propres arguments ne remet plus le secret dans le terminal ni dans le fichier de journal
<!-- [common] -->

View file

@ -74,6 +74,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce
- L'analyse des copies de site compare chaque copie à la vue qu'elle masque
- Un test vérifie que ce qu'importe l'outillage est bien déclaré
- Migration Odoo automatisée : l'outil mène toute l'exécution — réparer, rejouer, un déroulement automatique qui prend le défaut au bout de cinq secondes, et un compte à rebours qui nomme la réponse qu'il va prendre
- Écran d'état de migration : « t » montre où en est une exécution, avec les commandes en couleur, la durée écoulée, le journal du serveur lu pour vous, et un fichier de journal par étape sur disque
- Qualité de migration : ce qu'une exécution a gagné et perdu étape par étape, les fichiers manquants nommés, les refontes propres à Odoo distinguées des vraies pertes, et les changements déclarés par OpenUpgrade superposés aux réels
- Réparations de migration : le SCSS personnalisé que le palier suivant casse, prévu puis corrigé ; les thèmes désinstallés avant le premier palier ; la visibilité DMS rétablie ; les vues dont le type stocké contredit leur parent ; et les balises <tree> qu'Odoo 18 a renommées en <list>
- Tests de fumée après une migration : chaque URL publique demandée, /my et chaque application ouverte sous l'utilisateur de test de neutralisation, les vues derrière une URL en échec nommées, et le nettoyage de base OCA passé d'abord
- Vérification du filestore : si les fichiers joints sont bien arrivés, si l'enregistrement existe encore, et les nettoyages proposés sur place
- Analyse : les modules qui manquent à une base par rapport au paquet par défaut, avec une offre d'installer ceux qui sont prêts, et les fichiers joints réellement irrécupérables
- Debian sur s390x par debian-installer, aucune image cloud n'étant publiée pour cette architecture
- Une VM de développement mobile : PyCharm, Android Studio, extensions GNOME, un émulateur Android et un tunnel adb pour scrcpy, l'application mobile étant compilée et testée dedans
- Forgejo, une forge git installable depuis le menu de déploiement
- Matériel réglé par VM : le GPU de l'hôte, le mode CPU, les écrans et le réseau
- 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
## Retiré
@ -106,6 +120,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Année de copyright portée à 2026
- Miroirs pacman canadiens placés en tête sur Arch, le miroir « géographique » officiel mesurant quatre fois plus lent depuis Montréal
- Une dépendance Poetry peut être déclinée par architecture : factur-x est épinglé en 3.x sur s390x, où saxonche ne publie pas de roue, et PyMuPDF y est écarté
- Entrée cible la version d'Odoo la plus élevée supportée, le défaut étant calculé depuis le menu
- Une cible make lance les tests unitaires, avec la dépendance mobile déclarée
## Corrigé
@ -125,11 +141,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées
- Les outils de migration COW et la mise à niveau de la base parlent la langue du système
- Les outils d'analyse et de migration sont exécutables
- L'installateur forgejo ne réaffiche plus le mot de passe administrateur qu'il vient de poser
- La connexion Selenium renvoyait le défaut de configuration au lieu des identifiants reçus, lors de la reprise après une modale
- db_restore redemande le mot de passe maître au lieu de mourir sur une faute de frappe
- pyproj exige le binaire proj, pas seulement ses en-têtes, et PROJ est bâti là où la distribution est en retard
- run.sh est lancé par bash, contre les échecs 203/EXEC de systemd
- pykcs11 se compile avec SWIG 4.3 et au-delà
- os-release remplace lsb_release, et une collision d'IP se voit mieux
## Sécurité
- Les mots de passe et jetons sont caviardés avant l'affichage ou la
journalisation d'une commande
- Les mots de passe et jetons sont caviardés avant l'affichage ou la journalisation d'une commande
- Le mot de passe maître d'Odoo ne voyage plus sur la ligne de commande : MASTER_PWD le porte, et /proc/<pid>/environ n'est lisible que par son propriétaire là où /proc/<pid>/cmdline l'est par tout utilisateur de la machine (exige le commit correspondant dans le fork odoo)
- Le mot de passe KeePass parvient à la connexion Selenium de la même façon : la commande porte le NOM d'une variable d'environnement, jamais la valeur
- Ce qu'une commande AFFICHE est caviardé comme la commande elle-même : un outil qui réaffiche ses propres arguments ne remet plus le secret dans le terminal ni dans le fichier de journal
## [1.6.0] - 2025-04-25

View file

@ -72,6 +72,20 @@ Recreating the virtual environment, use installation guide from tool `make`.
- Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced
- The website copy analysis diffs each copy against the view it shadows
- A test checks that what the tooling imports is declared
- Automated Odoo migration: the tool drives the whole run -- repair, replay, an auto-run that takes the default after five seconds, and a countdown that names the answer it is about to take
- Migration state screen: « t » shows where a run stands, with coloured commands, elapsed time, the server log read for you, and one log file per step on disk
- Migration quality: what a run gained and lost step by step, the missing files named, Odoo's own redesigns told apart from real losses, and OpenUpgrade's declared changes laid over the real ones
- Migration repairs: the customized SCSS the next bump breaks, predicted then fixed; themes uninstalled before the first bump; DMS visibility restored; views whose stored type contradicts their parent; and the <tree> tags Odoo 18 renamed to <list>
- Smoke tests after a migration: every public URL requested, /my and every app opened as the neutralization test user, the views behind a failing URL named, and the OCA database cleanup run first
- Filestore check: whether the attachment files landed, whether the record still exists, and the cleanups offered on the spot
- Analysis: the modules a database lacks against the default package, with an offer to install those that are ready, and the attachment files that are truly unrecoverable
- Debian on s390x through debian-installer, no cloud image being published for that architecture
- A mobile development VM: PyCharm, Android Studio, GNOME extensions, an Android emulator and an adb tunnel for scrcpy, with the mobile app built and tested inside it
- Forgejo, a git forge installable from the deployment menu
- VM hardware set per machine: the host GPU, the CPU mode, the screens and the network
- 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
## Removed
@ -104,6 +118,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
- Copyright year updated to 2026
- Canadian pacman mirrors placed first on Arch, the official geographic mirror measuring four times slower from Montréal
- A Poetry dependency can be declined per architecture: factur-x is pinned to 3.x on s390x, where saxonche publishes no wheel, and PyMuPDF is set aside there
- Enter targets the highest supported Odoo version, the default being computed from the menu
- A make target runs the unit tests, with the mobile dependency declared
## Fixed
@ -123,10 +139,20 @@ Recreating the virtual environment, use installation guide from tool `make`.
- The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported
- The COW migration tools and the database upgrade speak the system language
- The analysis and migration tools are executable
- The forgejo installer no longer echoes the administrator password it has just set
- The Selenium login re-sent the configured default instead of the credentials it was given, when it retried after dismissing a modal
- db_restore asks the master password again instead of dying on a typo
- pyproj needs the proj binary, not only its headers, and PROJ is built where the distribution lags behind
- run.sh is launched through bash, against systemd's 203/EXEC failures
- pykcs11 compiles with SWIG 4.3 and above
- os-release replaces lsb_release, and an IP collision is easier to see
## Security
- Passwords and tokens are redacted before a command is displayed or logged
- The Odoo master password no longer travels on the command line: MASTER_PWD carries it, and /proc/<pid>/environ is readable only by its owner where /proc/<pid>/cmdline is readable by every user on the machine (needs the matching commit in the odoo fork)
- The KeePass password reaches the Selenium login the same way: the command carries the NAME of an environment variable, never the value
- What a command PRINTS is redacted like the command itself: a tool that reprints its own arguments no longer puts the secret back into the terminal and into the log file
## [1.6.0] - 2025-04-25

View file

@ -4,6 +4,31 @@
# TODO load specific test file : ./run.sh -d test_file --log-level=test --test-enable --stop-after-init --test-file ./.venv.erplibre/test.py
###########################
# Tests unitaires python #
###########################
# Ni base de données, ni Odoo, ni VM : ils lisent le code et exécutent les
# fragments de shell que todo.py génère, « sudo », « pgrep » et « pkill »
# bouchonnés. Une dizaine de secondes, là où « make test » demande une base et
# plusieurs minutes — d'où une cible à part, faite pour être lancée souvent.
#
# DÉPENDANCE : les tests du transfert mobile lisent
# mobile/erplibre_home_mobile. Le lanceur l'annonce présent ou absent avant de
# commencer, et les tests concernés se déclarent ignorés plutôt que de passer
# en silence — un test vert sans son dépôt ne prouve rien.
#
# « test_todo.py » n'en fait PAS partie : il demande une base de données. Son
# nom sans souligné le laisse hors du motif, et c'est voulu.
.PHONY: test_unit
test_unit:
./script/test/run_unit_test.sh
# Un seul fichier, pour la boucle d'écriture :
# make test_unit_file F=test/test_qemu_forgejo.py
.PHONY: test_unit_file
test_unit_file:
./script/test/run_unit_test.sh $(F)
.PHONY: open_test_coverage
open_test_coverage:
-$(BROWSER) htmlcov/index.html

View file

@ -5,10 +5,26 @@ if [[ ! -d "./mobile/erplibre_home_mobile" ]]; then
exit 1
fi
cd mobile/erplibre_home_mobile
WORKSPACE="$(pwd)"
cd mobile/erplibre_home_mobile || exit 1
npm install
npm run build && npx cap sync
npm run build || exit 1
# Le transfert des dépôts du manifeste DANS l'application est ce qui fait
# l'intérêt de son navigateur de code hors ligne, et il peut être vide sans que
# la compilation le dise. Ces dépôts entrent en packs — un APK est un ZIP borné
# à 65535 entrées, quand un fichier par source en réclamait 123 678 — et le
# vérificateur relit un échantillon depuis les tranches pour le comparer à la
# source. Trois pannes qu'un « build OK » passe sous silence : transfert vide,
# tranche absente, index qui promet des octets que sa tranche n'a pas.
#
# Même vérification que l'installation d'une VM, même script : une seule
# autorité.
"${WORKSPACE}/script/mobile/check_bundle_transfer.py" . --workspace "${WORKSPACE}" || exit 1
npx cap sync || exit 1
npx cap run android
cd -
cd - || exit 1

View file

@ -16,6 +16,8 @@ sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.execute.execute import redact_secrets
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
_logger = logging.getLogger(__name__)
@ -102,17 +104,23 @@ def password_refused(sortie):
return "AccessDenied" in (sortie or "")
def probe_master_password(arg_base):
def probe_master_password(arg_base, mot):
"""(accepté, sortie) — éprouver le mot de passe sur `--list`.
La commande la plus inoffensive : elle ne touche à rien et rend le
même refus qu'une restauration. Valider ici évite d'échouer à
mi-parcours, une fois la base déjà supprimée.
Le secret passe par l'environnement, jamais par argv :
/proc/<pid>/cmdline est lisible par tout utilisateur de la machine.
"""
env = os.environ.copy()
env["MASTER_PWD"] = mot
done = subprocess.run(
f"{arg_base} --list".split(" "),
capture_output=True,
text=True,
env=env,
)
return done.returncode == 0, (done.stdout or "") + (done.stderr or "")
@ -131,14 +139,13 @@ def ask_master_password(arg_base, essais=MAX_ESSAIS_MOT_DE_PASSE):
mot = get_master_password()
if not mot:
return None
candidat = f"{arg_base} --master_password={mot}"
accepte, sortie = probe_master_password(candidat)
accepte, sortie = probe_master_password(arg_base, mot)
if accepte:
return mot
if not password_refused(sortie):
# Autre chose est cassé : le dire, et ne pas noyer la panne
# sous dix invites de mot de passe.
_logger.error(sortie.strip()[-1500:])
_logger.error(redact_secrets(sortie.strip()[-1500:]))
return None
restants = essais - tour
if restants:
@ -232,7 +239,7 @@ def restore_or_clone(config, arg_base, cache_database, lst_db_cache):
f"{arg_base} --restore"
f" --restore_image {config.image} --database {cache_database}"
)
print(check_output(arg.split(" ")).decode())
print(redact_secrets(check_output(arg.split(" ")).decode()))
verify_filestore(cache_database, config.image)
if config.ignore_cache:
@ -254,8 +261,11 @@ def restore_or_clone(config, arg_base, cache_database, lst_db_cache):
)
if config.neutralize:
arg += " --neutralize"
print(arg)
print(check_output(arg.split(" ")).decode())
# Le secret ne traverse plus argv (il est dans MASTER_PWD), mais la
# commande peut porter d'autres options sensibles : on filtre quand
# même, le coût est nul et la garantie ne dépend alors d'aucun appelant.
print(redact_secrets(arg))
print(redact_secrets(check_output(arg.split(" ")).decode()))
if config.ignore_cache:
verify_filestore(config.database, config.image)
@ -287,7 +297,10 @@ def main():
if not master_password:
_logger.error("Missing master password, cancel transaction.")
sys.exit(1)
arg_base += f" --master_password={master_password}"
# Dans l'ENVIRONNEMENT, pas dans arg_base : tous les appels
# suivants sont des enfants de ce processus et en héritent,
# sans que le secret traverse jamais argv.
os.environ["MASTER_PWD"] = master_password
else:
_logger.info("No master password needed... Continue")
@ -298,7 +311,7 @@ def main():
for db in lst_db_cache:
_logger.info(f"## Delete {db} ##")
arg = f"{arg_base} --drop --database {db}"
out = check_output(arg.split(" ")).decode()
out = redact_secrets(check_output(arg.split(" ")).decode())
print(out)
lst_db, lst_db_cache = get_list_db_cache(arg_base)
@ -308,7 +321,7 @@ def main():
if config.database in lst_db:
_logger.info(f"## Drop {config.database} ##")
arg = f"{arg_base} --drop --database {config.database}"
out = check_output(arg.split(" ")).decode()
out = redact_secrets(check_output(arg.split(" ")).decode())
print(out)
if config.only_drop:
return

View file

@ -19,10 +19,15 @@ except ModuleNotFoundError as e:
VENV_ERPLIBRE = ".venv.erplibre"
# Une commande construite ailleurs peut porter un secret en clair : todo.py et
# kdbx_manager.py y mettent « --default_password_auth '<mot de passe KeePass>' »,
# db_restore.py « --master_password=… ». Cette commande est affichée avant et
# après l'exécution, et journalisée en erreur : le secret finissait donc dans le
# terminal, dans les journaux et dans toute sortie CI qui les capture.
# kdbx_manager.py y mettent « --default_password_auth '<mot de passe KeePass>' ».
# Cette commande est affichée avant et après l'exécution, et journalisée en
# erreur : le secret finissait donc dans le terminal, dans les journaux et dans
# toute sortie CI qui les capture.
#
# Ce filtre reste le dernier rempart, pas le premier : un secret n'a rien à
# faire sur argv, que /proc/<pid>/cmdline expose à tout utilisateur de la
# machine et qu'aucun caviardage n'atteint. db_restore.py est passé à
# MASTER_PWD dans l'environnement pour cette raison.
#
# On caviarde la VALEUR, jamais le nom de l'option : la commande reste lisible et
# reproductible, il ne manque que ce qui ne doit pas être lu.
@ -183,6 +188,11 @@ class Execute:
line = process.stdout.readline()
if not line:
break
# La sortie du sous-processus passe par le meme filtre que
# la commande : un outil qui reaffiche ses propres arguments
# (« set -x », une trace, odoo_bin.sh) y remettrait le secret
# que la ligne 165 venait d'ecarter.
line = redact_secrets(line)
if not quiet:
print(line, end="")
if sink:

View file

@ -341,7 +341,11 @@ else
--email "$ADMIN_EMAIL" --must-change-password=false \
--config "$CONF" >/dev/null \
|| die "création de l'administrateur impossible"
say "${Green}administrateur créé : $ADMIN_USER / $ADMIN_PASSWORD${Color_Off}"
# Le mot de passe n'est PAS réaffiché : cette sortie part dans les
# journaux d'installation et dans toute capture de CI. Celui qui a
# posé FORGEJO_ADMIN_PASSWORD le connaît déjà ; les autres ont le
# défaut, documenté en tête de ce fichier.
say "${Green}administrateur créé : $ADMIN_USER${Color_Off}"
fi
# --- 9. Résumé -------------------------------------------------------------

View file

@ -2,6 +2,7 @@
. ./env_var.sh
. ./script/install/lib_qpdf.sh
. ./script/install/lib_proj.sh
. ./script/install/lib_lowmem.sh
EL_USER=${USER}
@ -163,7 +164,7 @@ if [ "$(uname -m)" = "s390x" ]; then
apt_install_batch rust-all libqpdf-dev libgeos-dev libproj-dev proj-bin \
proj-data "${GEO_DEV}" freetds-dev freetds-bin libkrb5-dev libssl-dev \
pkg-config build-essential zlib1g-dev libjpeg-dev libtbb-dev cmake \
ninja-build
ninja-build libsqlite3-dev sqlite3 libtiff-dev libcurl4-openssl-dev
if [[ -n "${APT_FAILED}" ]]; then
# Seul un paquet dont dépend la SUITE immédiate est bloquant. Les autres
# servent des modules Odoo optionnels : les rendre fatals immobiliserait
@ -202,6 +203,10 @@ if [ "$(uname -m)" = "s390x" ]; then
# -- seuil, version batie, chemin d'installation -- est dans lib_qpdf.sh,
# partage avec les scripts dnf et zypper qui butaient sur le meme mur.
el_qpdf_ensure
# pyproj exige PROJ 9.4 ; bookworm en livre 9.1.1. Meme mecanique que
# qpdf, et meme portee etroite : ailleurs pyproj pose une roue qui
# embarque sa propre PROJ, ici il compile contre celle du systeme.
el_proj_ensure
# cryptography ne publie aucune roue s390x : elle se compile, et son
# Cargo.lock est en version 4, que seul cargo >= 1.78 sait lire. Ubuntu 24.04
# livre 1.75 et s'arrête sur « lock file version 4 requires

View file

@ -115,9 +115,16 @@ if [ "$(uname -m)" = "s390x" ]; then
# roues masquent le besoin ; ici tout compile, et bcrypt s'arrête net sur
# « error: can't find Rust compiler ». Les versions livrées suffisent
# (AlmaLinux 1.92, Fedora plus récent) au Cargo.lock v4 qui exige 1.78.
# « proj » nommé À CÔTÉ de « proj-devel » : pyproj n'a pas de roue s390x, il
# compile, et sa configuration EXÉCUTE le binaire « proj » pour localiser
# l'installation — les en-têtes seules ne suffisent pas. Ici le paquet
# principal porte /usr/bin/proj et « proj-devel » l'exige, donc l'arriver
# transitivement fonctionnerait ; on le nomme quand même, comme apt le fait
# avec « proj-bin », parce que openSUSE a prouvé que cette arête n'existe pas
# partout — elle y manque, et l'échec n'apparaît qu'au build.
${DNF} \
rust cargo \
libjpeg-turbo-devel zlib-devel geos-devel proj-devel \
libjpeg-turbo-devel zlib-devel geos-devel proj proj-devel \
krb5-devel tbb-devel ninja-build clang-devel llvm-devel \
GeographicLib-devel pkgconf-pkg-config cmake

View file

@ -166,6 +166,29 @@ if [[ "${EL_PHASE}" != "setup" ]]; then
"${POETRY_ODOO_PATH}" --version
# To fix keyring problem when installation is blocked, use
export PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring
# pykcs11 — tiré par endesive, donc présent dans les locks 14, 15 et 17 —
# ne livre AUCUN wrapper pré-généré dans son sdist : SWIG tourne à CHAQUE
# installation. Et ce n'est pas le SWIG du système qui tourne : le
# « requires = ["setuptools", "swig"] » de pykcs11 n'est pas borné, donc
# Poetry télécharge la DERNIÈRE version publiée sur PyPI (4.5.0 le 21 août
# 2026, vue dans le journal d'installation).
#
# Or SWIG a retiré en 4.3 les alias Python 2 que ses versions antérieures
# écrivaient dans le code généré (PyInt_FromLong, PyString_Check…), et le
# typemap CK_RV de pykcs11 en utilise un. D'où, sur une VM Ubuntu 26.04:
# « ‘PyInt_FromLong’ was not declared in this scope », 55 fois, et
# install_odoo_17 s'arrête. Aucune sonde locale ne peut le prévoir — le
# SWIG qui tourne est choisi au moment du build, pas ici.
#
# On redonne l'alias au préprocesseur, dans la forme EXACTE que SWIG 4.2
# écrivait : définition identique token pour token, donc aucun
# avertissement de redéfinition là où SWIG la fournit encore (vérifié en
# -Werror contre un wrapper généré par SWIG 4.2).
#
# CPPFLAGS et non CFLAGS : un .cpp passe par « compiler_so_cxx », qui lit
# CXXFLAGS et CPPFLAGS — CFLAGS ne l'atteint JAMAIS. Mesuré sur setuptools
# 84, et c'est ce qui a fait échouer le premier correctif.
export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }-DPyInt_FromLong(x)=PyLong_FromLong(x)"
# « poetry install » reste à Poetry : uv ne lit pas poetry.lock
# (astral-sh/uv#1804, « not planned ») et Poetry 2.1.3 n'a plus « export ».
if [[ ${WITH_POETRY_INSTALLATION} -ne 0 ]]; then

View file

@ -262,8 +262,17 @@ if [ "$(uname -m)" = "s390x" ]; then
#
# GeographicLib est absent : openSUSE n'empaquette que le binding Python,
# pas la bibliothèque C++. Rien à poser, contrairement à apt et dnf.
# proj le BINAIRE, pas seulement « proj-devel ». openSUSE éclate
# PROJ en trois paquets : libproj25 la bibliothèque, proj-devel
# les en-têtes, proj les outils en ligne de commande. pyproj ne
# se contente pas des en-têtes — il EXÉCUTE « proj » pour
# localiser l'installation, et sans lui s'arrête sur « proj
# executable not found. Please set the PROJ_DIR variable ».
# proj-devel ne le tire pas : la dépendance manque en silence
# jusqu'au build, et aucune roue s390x ne vient l'éviter.
zyp_soft qpdf-devel libjpeg8-devel cmake pkgconf-pkg-config \
tbb-devel geos-devel proj-devel krb5-devel ninja clang-devel llvm-devel
tbb-devel geos-devel proj proj-devel krb5-devel ninja clang-devel \
llvm-devel
# Tumbleweed livre qpdf 12.3.2 : l'appel ne fait que le constater. Il est là
# pour que les trois familles suivent la même règle.
el_qpdf_ensure

View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# PROJ assez récent pour pyproj, compilé depuis les sources quand la
# distribution est en retard. Calque de lib_qpdf.sh, pour la même raison et
# avec les mêmes garde-fous.
#
# QUI est concerné, et qui ne l'est pas : sur amd64 et arm64, pyproj publie
# des roues manylinux qui EMBARQUENT leur propre PROJ — rien n'est compilé et
# la version du système n'a aucune importance. s390x n'a pas de roue : pyproj
# se construit contre la PROJ du système, et refuse net si elle est trop
# vieille.
#
# ERROR: Minimum supported PROJ version is 9.4.0, installed version is 9.1.1
#
# Mesuré sur Debian 12 (bookworm) s390x. Debian 13 livre 9.6 et passe sans
# rien faire ; ce fichier ne s'y déclenche donc pas.
# Seuil réclamé par pyproj 3.7.x. Le monter suppose de vérifier ce qu'exige la
# version de pyproj réellement verrouillée dans poetry.lock.
EL_PROJ_MIN=9.4.0
# Version bâtie quand le seuil n'est pas atteint. 9.6.x est la branche stable
# la plus récente à ce jour et couvre largement le seuil.
EL_PROJ_VER=9.6.2
# Vrai si la version passée en argument atteint le seuil. Même précaution que
# pour qpdf : « sort -V » classe « 9.4 » AVANT « 9.4.0 », donc une version
# numérotée sur deux composantes déclencherait une compilation inutile — et
# celle de PROJ se compte en dizaines de minutes sous émulation.
el_proj_ge_min() {
local v="$1" dots
[ -n "${v}" ] || return 1
dots="${v//[^.]/}"
while [ "${#dots}" -lt 2 ]; do
v="${v}.0"
dots="${dots}."
done
[ "$(printf '%s\n%s\n' "${EL_PROJ_MIN}" "${v}" | sort -V | head -1)" = "${EL_PROJ_MIN}" ]
}
# Version actuellement visible, ou « 0 ». /usr/local n'est pas dans le chemin
# par défaut de pkg-config partout : sans ces entrées, une PROJ déjà compilée
# passerait inaperçue et serait rebâtie à chaque passage.
el_proj_version() {
PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig:${PKG_CONFIG_PATH}" \
pkg-config --modversion proj 2> /dev/null || echo 0
}
# Compile et installe PROJ dans /usr/local si, et seulement si, ce qui est
# présent ne suffit pas. Ne rend jamais un code non nul : l'échec est signalé
# et l'installation continue, pyproj dira lui-même ce qui manque.
el_proj_ensure() {
local have build dir
have="$(el_proj_version)"
if el_proj_ge_min "${have}"; then
echo "PROJ ${have} >= ${EL_PROJ_MIN} : rien a compiler pour pyproj."
return 0
fi
echo "PROJ ${have} < ${EL_PROJ_MIN} requis par pyproj : compilation de PROJ ${EL_PROJ_VER} (long en emulation)."
build="$(mktemp -d)"
# TESTING=OFF et les outils en moins : seule la bibliothèque intéresse
# pyproj, et la suite de tests de PROJ double le temps de compilation.
if curl -fsSL --max-time 900 -o "${build}/proj.tar.gz" \
"https://download.osgeo.org/proj/proj-${EL_PROJ_VER}.tar.gz" \
&& tar -xzf "${build}/proj.tar.gz" -C "${build}" \
&& cmake -S "${build}/proj-${EL_PROJ_VER}" -B "${build}/build" \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local \
-DBUILD_TESTING=OFF -DBUILD_APPS=ON -DBUILD_SHARED_LIBS=ON \
&& cmake --build "${build}/build" -j"$(nproc)" \
&& sudo cmake --install "${build}/build"; then
# CMake choisit « lib » ou « lib64 » selon la famille, et /usr/local/lib64
# n'est pas dans le chemin de ld.so partout : sans cette déclaration,
# pyproj se construirait pour échouer au CHARGEMENT, plus loin de la cause.
for dir in /usr/local/lib64 /usr/local/lib; do
if [ -e "${dir}/libproj.so" ]; then
echo "${dir}" | sudo tee /etc/ld.so.conf.d/proj-local.conf > /dev/null
break
fi
done
sudo ldconfig
echo "PROJ $(el_proj_version) installe dans /usr/local."
else
echo "Attention : compilation de PROJ echouee, pyproj ne pourra pas se construire."
fi
rm -rf "${build}"
return 0
}

View file

@ -2712,7 +2712,13 @@ def build_installer_initrd(
)
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "preseed.cfg").write_text(preseed, encoding="utf-8")
# Le mot de passe utilisateur y est HACHÉ (user-password-crypted).
# Reste celui de network-console, une valeur fixe et publique dont
# le composant est désactivé plus bas. Le répertoire temporaire est
# déjà en 0700 ; le mode explicite vaut pour qui lirait ce code.
cfg = work / "preseed.cfg"
cfg.touch(mode=0o600)
cfg.write_text(preseed, encoding="utf-8")
members = ["preseed.cfg"]
for path, _mode, content, _owner in guide or []:
name = installer_guide_name(path)

View file

@ -32,7 +32,19 @@ def fill_parser(parser):
group_login.add_argument(
"--default_password_auth",
default="admin",
help="Password to use to authenticate with admin.",
help=(
"Password to use to authenticate with admin. Prefer"
" --default_password_auth_env: a value given here travels"
" through argv, which every user on the machine can read."
),
)
group_login.add_argument(
"--default_password_auth_env",
default=None,
help=(
"NAME of an environment variable holding the password. Only"
" the name reaches the command line; the value never does."
),
)
@ -71,10 +83,14 @@ def run(
email_auth = (
default_email_auth if default_email_auth else config.default_email_auth
)
# L'environnement l'emporte : /proc/<pid>/cmdline est lisible par tout
# utilisateur de la machine, /proc/<pid>/environ par son seul
# propriétaire. Le nom de la variable, lui, n'est pas un secret.
pass_env = getattr(config, "default_password_auth_env", None)
pass_auth = (
default_password_auth
if default_password_auth
else config.default_password_auth
(pass_env and os.environ.get(pass_env))
or default_password_auth
or config.default_password_auth
)
courriel_input.clear()
mot_de_passe_input.clear()
@ -100,9 +116,11 @@ def run(
)
error_button.click()
# Remplissez le courriel et le mot de passe
courriel_input.send_keys(config.default_email_auth)
mot_de_passe_input.send_keys(config.default_password_auth)
# Les valeurs RÉSOLUES, pas celles du config : la reprise
# renvoyait le défaut « admin » dès qu'un identifiant avait été
# fourni autrement, et échouait sans dire pourquoi.
courriel_input.send_keys(email_auth)
mot_de_passe_input.send_keys(pass_auth)
connexion_button.click()
else:

View file

@ -9,6 +9,20 @@ import subprocess
import sys
from pathlib import Path
# « ExecStart=/bin/bash …/run.sh » et non le script seul.
#
# Lancé seul, systemd doit EXÉCUTER le fichier, et l'échoue en « 203/EXEC »
# dans quatre cas au moins : bit x absent, shebang qui ne résout pas, /home
# monté noexec, SELinux refusant l'execve. Vécu sur openSUSE s390x — le
# processus mourait en 3 ms, sans jamais entrer dans le script, ce qui rend le
# diagnostic très pénible : aucune sortie, et un code qui ressemble à une
# erreur d'application.
#
# Passé à bash, run.sh n'est plus qu'une DONNÉE lue : les quatre causes
# disparaissent ensemble, y compris noexec et SELinux, qui ne portent que sur
# l'execve. Rien n'est perdu au passage — le shebang du script désigne déjà
# bash. C'est aussi ce que font install_daemon.sh et le générateur de todo.py,
# les trois écrivant la même unité.
UNIT_TEMPLATE = """[Unit]
Description=ERPLibre for {user}
Requires=postgresql.service
@ -23,7 +37,8 @@ Group={user}
Restart=always
RestartSec=5
PIDFile={home_erplibre}/.venv.erplibre/service.pid
ExecStart={home_erplibre}/run.sh{EXEC_PARAM}
# bash explicite : voir install_daemon.py (evite 203/EXEC).
ExecStart=/bin/bash {home_erplibre}/run.sh{EXEC_PARAM}
WorkingDirectory={home_erplibre}
StandardOutput=journal+console

View file

@ -31,7 +31,7 @@ Group=${EL_USER}
Restart=always
RestartSec=5
PIDFile=${EL_HOME_ERPLIBRE}/.venv.erplibre/service.pid
ExecStart=${EL_HOME_ERPLIBRE}/run.sh
ExecStart=/bin/bash ${EL_HOME_ERPLIBRE}/run.sh
WorkingDirectory=${EL_HOME_ERPLIBRE}
StandardOutput=journal+console

72
script/test/run_unit_test.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Tests unitaires python du dépôt : ni base de données, ni Odoo, ni VM.
#
# Ils lisent le code et exécutent les fragments de shell que todo.py génère,
# avec « sudo », « pgrep » et « pkill » bouchonnés — c'est ce qui les rend
# lançables partout et en quelques secondes, là où « make test » demande une
# base et plusieurs minutes.
#
# DÉPENDANCE DÉCLARÉE : les tests du transfert mobile lisent
# mobile/erplibre_home_mobile. Absent, ils se disent ignorés plutôt que de
# passer en silence — un test vert sans son dépôt ne prouve rien. Ce script
# l'annonce donc avant de commencer.
#
# ./script/test/run_unit_test.sh [fichiers...]
set -uo pipefail
Red='\033[0;31m'
Green='\033[0;32m'
Yellow='\033[0;33m'
Color_Off='\033[0m'
cd "$(dirname "$0")/../.." || exit 1
PY=./.venv.erplibre/bin/python
if [[ ! -x "${PY}" ]]; then
echo -e "${Red}✗ ${PY} absent : lancer l'installation ERPLibre d'abord.${Color_Off}"
exit 1
fi
MOBILE=mobile/erplibre_home_mobile
if [[ -d "${MOBILE}" ]]; then
echo -e " dépendance ${MOBILE} : ${Green}présente${Color_Off}"
else
echo -e " dépendance ${MOBILE} : ${Yellow}absente${Color_Off}"
echo " (les tests du transfert mobile s'en passeront et le diront)"
fi
FILES=("$@")
if [[ ${#FILES[@]} -eq 0 ]]; then
# Aucun argument : tout ce que le dépôt sait tester sans base de données.
mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \
test/test_todo_*.py test/test_install_*.py 2>/dev/null)
fi
fail=0
total=0
for f in "${FILES[@]}"; do
out=$(PYTHONPATH=. "${PY}" "${f}" 2>&1)
ran=$(echo "${out}" | grep -oE 'Ran [0-9]+' | grep -oE '[0-9]+' | tail -1)
skipped=$(echo "${out}" | grep -oE 'skipped=[0-9]+' | tail -1)
if echo "${out}" | grep -qE '^OK'; then
state="${Green}OK${Color_Off}"
else
state="${Red}ÉCHEC${Color_Off}"
fail=1
fi
total=$((total + ${ran:-0}))
printf " %-42s %5s tests %-14s %b\n" \
"$(basename "${f}")" "${ran:-?}" "${skipped:-}" "${state}"
[[ "${state}" == *"ÉCHEC"* ]] && echo "${out}" | tail -12
done
echo " ─────"
if [[ ${fail} -eq 0 ]]; then
echo -e " ${Green}${total} tests, tout vert${Color_Off}"
else
echo -e " ${Red}des échecs ci-dessus${Color_Off}"
fi
exit ${fail}

View file

@ -91,12 +91,24 @@ class KdbxManager:
def get_extra_command_user(
self, kdbx_key: str | list | None
) -> str | list:
) -> tuple[str | list, dict]:
"""(fragments de commande, variables d'environnement à poser).
Le mot de passe ne rejoint PAS la ligne de commande : seul le NOM
d'une variable y figure. /proc/<pid>/cmdline est lisible par tout
utilisateur de la machine, /proc/<pid>/environ par son seul
propriétaire — et un mot de passe KeePass n'a rien à faire dans la
liste des processus.
Un nom par entrée : plusieurs identifiants partent dans UNE seule
commande « parallel », donc une variable unique ne suffirait pas.
"""
values = []
env = {}
if kdbx_key:
kp = self.get_kdbx()
if not kp:
return ""
return "", {}
if type(kdbx_key) is not list:
kdbx_keys = [kdbx_key]
else:
@ -111,13 +123,14 @@ class KdbxManager:
odoo_password = entry.password
except AttributeError:
_logger.error(f"Cannot find password from keys {key}")
var = f"EL_WEB_LOGIN_PWD_{len(values)}"
env[var] = odoo_password
values.append(
" --default_email_auth"
f" {odoo_user} --default_password_auth"
f" '{odoo_password}'"
f" {odoo_user} --default_password_auth_env {var}"
)
if len(values) == 0:
return ""
return "", {}
elif len(values) == 1:
return values[0]
return values
return values[0], env
return values, env

View file

@ -510,14 +510,19 @@ class TODO:
odoo_user = instance.get("user")
odoo_password = instance.get("password")
# Le mot de passe voyage par l'environnement, jamais par argv : la
# ligne de commande est lisible par tout utilisateur de la machine.
web_login_env = {}
if kdbx_key:
extra_cmd_web_login = self.kdbx_manager.get_extra_command_user(
kdbx_key
)
(
extra_cmd_web_login,
web_login_env,
) = self.kdbx_manager.get_extra_command_user(kdbx_key)
elif odoo_user and odoo_password:
web_login_env = {"EL_WEB_LOGIN_PWD_0": odoo_password}
extra_cmd_web_login = (
f" --default_email_auth {odoo_user} --default_password_auth"
f" '{odoo_password}'"
f" --default_email_auth {odoo_user}"
" --default_password_auth_env EL_WEB_LOGIN_PWD_0"
)
else:
extra_cmd_web_login = ""
@ -538,7 +543,9 @@ class TODO:
if exec_run_db:
db_name = instance.get("database")
self.prompt_execute_selenium_and_run_db(
db_name, extra_cmd_web_login=extra_cmd_web_login
db_name,
extra_cmd_web_login=extra_cmd_web_login,
web_login_env=web_login_env,
)
bash_command = instance.get("bash_command")
@ -554,7 +561,9 @@ class TODO:
command = instance.get("command")
if command:
self.prompt_execute_selenium(
command=command, extra_cmd_web_login=extra_cmd_web_login
command=command,
extra_cmd_web_login=extra_cmd_web_login,
web_login_env=web_login_env,
)
callback = instance.get("callback")
@ -2058,13 +2067,17 @@ class TODO:
host, from_ssh = self._qemu_self_address()
user = os.environ.get("USER", "user")
print(f"\n {t('No display here; run this on YOUR workstation:')}")
print(f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n")
print(
f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n"
)
if not from_ssh:
print(f" ⚠ {t('Not in an SSH session: check the host address.')}")
print(f" {t('A ~/.ssh/config alias works there too.')}")
print(f" {t('It builds its own tunnel; no ssh -L to keep open.')}")
print(f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman"
" / zypper")
print(
f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman"
" / zypper"
)
def _qemu_console_tunnel(self, name, src):
"""Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité.
@ -5421,7 +5434,7 @@ class TODO:
"Group=$SVC_GROUP\n"
"Restart=always\n"
"RestartSec=5\n"
"ExecStart=$SVC_DIR/run.sh\n"
"ExecStart=/bin/bash $SVC_DIR/run.sh\n"
"WorkingDirectory=$SVC_DIR\n"
"StandardOutput=journal+console\n"
"$SELINUX_LINE\n"
@ -11397,16 +11410,20 @@ class TODO:
)
def prompt_execute_selenium_and_run_db(
self, db_name, extra_cmd_web_login=""
self, db_name, extra_cmd_web_login="", web_login_env=None
):
cmd_server = f"./run.sh -d {db_name};bash"
self.execute.exec_command_live(cmd_server)
cmd_client = (
f"sleep 3;./script/selenium/web_login.py{extra_cmd_web_login};bash"
)
self.execute.exec_command_live(cmd_client)
self.execute.exec_command_live(
cmd_client, new_env=web_login_env or None
)
def prompt_execute_selenium(self, command=None, extra_cmd_web_login=""):
def prompt_execute_selenium(
self, command=None, extra_cmd_web_login="", web_login_env=None
):
commands = []
if not command:
cmd = "./script/selenium/web_login.py"
@ -11419,13 +11436,16 @@ class TODO:
else:
commands.append(cmd + extra_cmd_web_login)
env = web_login_env or None
if len(commands) == 1:
self.execute.exec_command_live(commands[0])
self.execute.exec_command_live(commands[0], new_env=env)
elif len(commands) > 1:
new_cmd = "parallel ::: "
for i, cmd in enumerate(commands):
new_cmd += f' "sleep {1 * i};{cmd}"'
self.execute.exec_command_live(new_cmd)
# « parallel » hérite de l'environnement, et chaque entrée lit
# SA variable : un nom par identifiant, d'où EL_WEB_LOGIN_PWD_N.
self.execute.exec_command_live(new_cmd, new_env=env)
def crash_diagnostic(self, e):
# TODO show message at start if os.path.exists(ERROR_LOG_PATH)

View file

@ -0,0 +1,190 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'installation Poetry survit à SWIG >= 4.3.
pykcs11 — tiré par endesive, donc dans les locks Odoo 14, 15 et 17 — ne livre
aucun wrapper pré-généré : SWIG tourne à chaque installation. Son
« requires = ["setuptools", "swig"] » n'étant pas borné, Poetry prend la
DERNIÈRE version de PyPI. SWIG 4.3 a retiré les alias Python 2 que les
versions antérieures écrivaient dans le code généré, et le typemap CK_RV de
pykcs11 en utilise un : « ‘PyInt_FromLong’ was not declared in this scope »,
55 fois, et install_odoo_17 s'arrête.
Ces tests gardent les trois choses que l'enquête a coûté :
- CPPFLAGS, pas CFLAGS. Un .cpp est compilé par « compiler_so_cxx », qui lit
CXXFLAGS et CPPFLAGS ; CFLAGS ne l'atteint JAMAIS. Le premier correctif a
échoué exactement là.
- La définition doit être identique TOKEN POUR TOKEN à celle de SWIG 4.2,
sinon les hôtes qui ont encore un vieux SWIG récoltent un avertissement de
redéfinition à chaque fichier.
- Le drapeau doit être posé AVANT « poetry install », et s'ajouter à un
CPPFLAGS existant au lieu de l'écraser.
"""
import os
import re
import shutil
import subprocess
import sysconfig
import tempfile
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
SCRIPT = RACINE / "script/install/install_locally.sh"
SOURCE = SCRIPT.read_text(encoding="utf-8")
# La ligne que SWIG <= 4.2 plaçait lui-même dans le wrapper généré.
SWIG_42 = "#define PyInt_FromLong(x) PyLong_FromLong(x)"
# Ce que produit SWIG >= 4.3 : l'appel, sans l'alias.
APPEL_SWIG_43 = """
#include <Python.h>
PyObject *sonde(long v) { return PyInt_FromLong(v); }
"""
def _drapeau():
"""Le -D tel qu'il est écrit dans le script — source unique."""
m = re.search(r"export CPPFLAGS=\"[^\"]*?(-DPyInt_FromLong[^\" ]*)\"", SOURCE)
return m.group(1) if m else ""
def _compilateur():
return shutil.which("c++") or shutil.which("g++")
def _compile(source, drapeaux):
"""(succès, sortie) d'une compilation de syntaxe seule."""
inc = sysconfig.get_paths()["include"]
if not Path(inc, "Python.h").exists():
return None, "Python.h absent"
with tempfile.TemporaryDirectory() as tmp:
fichier = Path(tmp, "sonde.cpp")
fichier.write_text(source, encoding="utf-8")
res = subprocess.run(
[_compilateur(), "-fsyntax-only", *drapeaux, "-I", inc, str(fichier)],
capture_output=True,
text=True,
timeout=120,
)
return res.returncode == 0, res.stdout + res.stderr
class TestLeDrapeauDansLeScript(unittest.TestCase):
def test_it_is_exported(self):
self.assertTrue(_drapeau(), "aucun -DPyInt_FromLong dans le script")
def test_it_is_set_before_poetry_install(self):
"""Posé après, il n'atteindrait rien : c'est « poetry install » qui
lance les compilations."""
export = SOURCE.index("export CPPFLAGS=")
install = SOURCE.index('install --no-root ${POETRY_VERBOSE}')
self.assertLess(export, install)
def test_it_appends_instead_of_replacing(self):
"""Écraser CPPFLAGS jetterait ce que l'appelant y avait mis — sur
s390x et openSUSE, des chemins d'en-têtes y passent."""
self.assertIn('${CPPFLAGS:+${CPPFLAGS} }', SOURCE)
def test_it_is_cppflags_and_not_cflags(self):
"""Le piège qui a fait échouer le premier correctif : le fichier fautif
est un .cpp, et CFLAGS ne va qu'aux .c."""
self.assertNotIn("export CFLAGS=", SOURCE)
def test_the_shell_builds_the_value_it_claims(self):
"""Les parenthèses du -D sont des métacaractères du shell : mal
protégées, la variable serait tronquée ou le script casserait."""
for depart, attendu in (
("", "-DPyInt_FromLong(x)=PyLong_FromLong(x)"),
("-DDEJA=1", "-DDEJA=1 -DPyInt_FromLong(x)=PyLong_FromLong(x)"),
):
res = subprocess.run(
[
"bash",
"-c",
f'export CPPFLAGS="{depart}"; '
'export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }'
f'{_drapeau()}"; printf %s "$CPPFLAGS"',
],
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(attendu, res.stdout)
def test_the_script_still_parses(self):
res = subprocess.run(
["bash", "-n", str(SCRIPT)], capture_output=True, text=True
)
self.assertEqual(0, res.returncode, res.stderr)
@unittest.skipUnless(_compilateur(), "aucun compilateur C++")
class TestLeDrapeauCompile(unittest.TestCase):
"""Le drapeau fait-il ce qu'on lui prête ? Compilé, pas supposé."""
def test_swig_43_output_fails_without_it(self):
"""Sans quoi le test suivant ne prouverait rien : c'est bien CE
symptôme que le drapeau soigne."""
ok, sortie = _compile(APPEL_SWIG_43, [])
if ok is None:
self.skipTest(sortie)
self.assertFalse(ok)
self.assertIn("PyInt_FromLong", sortie)
def test_the_same_source_compiles_with_it(self):
ok, sortie = _compile(APPEL_SWIG_43, [_drapeau()])
if ok is None:
self.skipTest(sortie)
self.assertTrue(ok, sortie[-400:])
def test_it_does_not_clash_with_an_older_swig(self):
"""Un hôte dont le SWIG écrit encore l'alias verrait, sinon, un
avertissement de redéfinition par fichier compilé. En -Werror, ce
serait un échec."""
ok, sortie = _compile(
SWIG_42 + APPEL_SWIG_43, ["-Werror", _drapeau()]
)
if ok is None:
self.skipTest(sortie)
self.assertTrue(ok, sortie[-400:])
self.assertNotIn("redefined", sortie)
class TestHypotheseSetuptools(unittest.TestCase):
"""L'hypothèse dont dépend le choix de CPPFLAGS, écrite noir sur blanc.
Si un setuptools futur change de câblage, c'est ici qu'on l'apprend — pas
au milieu d'une installation de VM.
"""
def test_cppflags_reaches_the_cxx_compiler_but_cflags_does_not(self):
try:
from setuptools._distutils.ccompiler import new_compiler
from setuptools._distutils.sysconfig import customize_compiler
except ImportError as exc: # pragma: no cover - setuptools trop vieux
self.skipTest(f"distutils vendu introuvable : {exc}")
garde = {v: os.environ.get(v) for v in ("CFLAGS", "CPPFLAGS")}
os.environ["CFLAGS"] = "-DVU_PAR_CFLAGS=1"
os.environ["CPPFLAGS"] = "-DVU_PAR_CPPFLAGS=1"
try:
compilateur = new_compiler()
customize_compiler(compilateur)
cxx = getattr(compilateur, "compiler_so_cxx", None)
if not cxx:
self.skipTest("pas de compiler_so_cxx dans ce setuptools")
self.assertIn("-DVU_PAR_CPPFLAGS=1", cxx)
self.assertNotIn("-DVU_PAR_CFLAGS=1", cxx)
finally:
for var, val in garde.items():
if val is None:
os.environ.pop(var, None)
else:
os.environ[var] = val
if __name__ == "__main__":
unittest.main(verbosity=1)

View file

@ -79,8 +79,8 @@ class TestTheRetryLoop(unittest.TestCase):
self.demandes += 1
return next(suite, "")
def sonder(arg_base):
self.sondes.append(arg_base)
def sonder(arg_base, mot):
self.sondes.append((arg_base, mot))
return next(rep, (False, "AccessDenied"))
db_restore.get_master_password = demander
@ -136,7 +136,13 @@ class TestTheRetryLoop(unittest.TestCase):
self.branche(["bon"], [(True, "db1")])
self.lance()
self.assertEqual(len(self.sondes), 1)
self.assertIn("--master_password=bon", self.sondes[0])
arg_base, mot = self.sondes[0]
self.assertEqual(mot, "bon")
# Le secret est passé À CÔTÉ de la commande, jamais dedans :
# /proc/<pid>/cmdline est lisible par tout utilisateur de la
# machine. C'est la garantie que ce test tient.
self.assertNotIn("bon", arg_base)
self.assertNotIn("--master_password", arg_base)
def test_each_attempt_probes_with_ITS_password(self):
self.branche(
@ -144,8 +150,9 @@ class TestTheRetryLoop(unittest.TestCase):
[(False, "AccessDenied"), (True, "db1")],
)
self.lance()
self.assertIn("--master_password=un", self.sondes[0])
self.assertIn("--master_password=deux", self.sondes[1])
self.assertEqual([mot for _, mot in self.sondes], ["un", "deux"])
for arg_base, _ in self.sondes:
self.assertNotIn("--master_password", arg_base)
class TestTheWiring(unittest.TestCase):

View file

@ -263,6 +263,80 @@ class TestTheCommandLine(unittest.TestCase):
self.assertNotIn("Traceback", buf.getvalue())
REPO = Path(__file__).resolve().parent.parent
MOBILE = REPO / "mobile" / "erplibre_home_mobile"
class TestTheRealBundle(unittest.TestCase):
"""Le VRAI transfert, quand le dépôt mobile est installé et compilé.
C'est ici que ces tests DÉCLARENT leur dépendance : sans
mobile/erplibre_home_mobile, ils se disent ignorés plutôt que de passer en
silence — un test vert sans son dépôt ne prouve rien. Le lanceur
(script/test/run_unit_test.sh) annonce la même dépendance avant de
commencer.
Ce qu'ils gardent : qu'une compilation réelle produise bien des PACKS. Un
retour au fichier-par-source ferait disparaître le champ « chunk » des
index, et la limite du ZIP reviendrait — 123 678 entrées pour un plafond de
65 535, silencieusement, jusqu'à l'APK.
"""
@classmethod
def setUpClass(cls):
if not MOBILE.is_dir():
# Pas de « relative_to » : il lève quand le chemin sort du
# dépôt, et une erreur n'est pas un « ignoré » — mesuré en
# simulant l'absence.
raise unittest.SkipTest(
"mobile/erplibre_home_mobile absent :"
" ./mobile/install_mobile_dev.sh"
)
cls.repos = MOBILE / "dist" / "repos"
if not (cls.repos / "manifest.json").is_file():
raise unittest.SkipTest(
"dépôt mobile présent mais pas compilé :"
" ./mobile/compile_and_run.sh (ou npm run build)"
)
def test_the_transfer_is_coherent(self):
rep = cbt.check(MOBILE, REPO)
self.assertGreater(rep["repos"], 1)
self.assertGreater(rep["files"], cbt.MIN_FILES)
self.assertGreater(rep["packs"], 0)
def test_a_sample_matches_the_source(self):
"""La seule vérification qui prouve un transfert FIDÈLE."""
rep = cbt.check(MOBILE, REPO)
self.assertGreater(rep["compared"], 0)
def test_the_indexes_are_packed_not_file_per_source(self):
"""Le garde-fou de la limite du ZIP : chaque fichier doit porter sa
tranche. Sans « chunk », c'est un fichier par source, et l'APK sera
refusé — mais bien plus tard, et sans dire pourquoi."""
man = json.loads((self.repos / "manifest.json").read_text())
checked = 0
for proj in man[:5]:
index = self.repos / proj["slug"] / "index.json"
entries = json.loads(index.read_text())
files = [e for e in entries if e.get("type") == "file"]
if not files:
continue
self.assertTrue(
all("chunk" in e for e in files),
f"{proj['slug']} : des fichiers sans tranche",
)
checked += 1
self.assertGreater(checked, 0, "aucun dépôt à vérifier")
def test_no_bundled_test_file_lingers_as_a_source(self):
"""Effet de bord mesuré, et il compte : empaquetés, les 1 599 fichiers
de test des dépôts Odoo ne sont plus ramassés par vitest — 1 423
fichiers de test ramenés à 75, 35 s ramenées à 3 s."""
stray = list(self.repos.glob("*/**/*.test.ts"))
self.assertEqual([], stray)
import contextlib as _contextlib # noqa: E402
import unittest.mock # noqa: E402

View file

@ -403,21 +403,42 @@ class TestTestMenuDispatch(unittest.TestCase):
class TestKdbxGetExtraCommandUser(unittest.TestCase):
"""La fonction rend (fragments, variables d'environnement).
Le mot de passe ne doit JAMAIS revenir dans les fragments : ils
deviennent une ligne de commande, que tout utilisateur de la machine
peut lire dans /proc/<pid>/cmdline. Seul le NOM d'une variable y a sa
place, et c'est ce que le dernier test verrouille.
"""
def test_empty_kdbx_key(self):
todo = TODO()
result = todo.kdbx_manager.get_extra_command_user("")
self.assertEqual(result, "")
self.assertEqual(result, ("", {}))
def test_none_kdbx_key(self):
todo = TODO()
result = todo.kdbx_manager.get_extra_command_user(None)
self.assertEqual(result, "")
self.assertEqual(result, ("", {}))
def test_kdbx_not_available(self):
todo = TODO()
todo.kdbx_manager.get_kdbx = MagicMock(return_value=None)
result = todo.kdbx_manager.get_extra_command_user("some_key")
self.assertEqual(result, "")
self.assertEqual(result, ("", {}))
def test_password_never_reaches_the_command_line(self):
todo = TODO()
entry = MagicMock(username="odoo", password="s3cr3t")
kp = MagicMock()
kp.find_entries_by_title = MagicMock(return_value=entry)
todo.kdbx_manager.get_kdbx = MagicMock(return_value=kp)
fragment, env = todo.kdbx_manager.get_extra_command_user("une_cle")
self.assertNotIn("s3cr3t", fragment)
self.assertIn(
"--default_password_auth_env EL_WEB_LOGIN_PWD_0", fragment
)
self.assertEqual(env, {"EL_WEB_LOGIN_PWD_0": "s3cr3t"})
class TestSetupClaudeCommit(unittest.TestCase):