From 2b27ad73c870bee78ece4a822bc9dc23c1bd14c0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 16:19:24 -0400 Subject: [PATCH 01/10] [FIX] install s390x : pyproj exige le binaire proj, pas ses en-tetes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openSUSE eclate PROJ en trois paquets — libproj25 la bibliotheque, proj-devel les en-tetes, proj les outils. Seul proj-devel etait pose, et il ne tire PAS le troisieme. pyproj n'a pas de roue s390x : il compile, et sa configuration execute « proj » pour localiser l'installation. D'ou l'arret sur « proj executable not found. Please set the PROJ_DIR variable », en plein milieu d'un poetry install, sans que rien n'ait manque plus tot. apt nommait deja proj-bin. dnf s'en remettait a une arete transitive : elle tient sur RHEL, elle manque sur openSUSE. On la nomme donc partout plutot que d'en dependre. --- EN --- openSUSE splits PROJ into three packages — libproj25 the library, proj-devel the headers, proj the tools. Only proj-devel was installed, and it does NOT pull the third. pyproj has no s390x wheel: it builds, and its configuration runs "proj" to locate the installation. Hence the stop on "proj executable not found. Please set the PROJ_DIR variable", mid poetry install, with nothing missing earlier. apt already named proj-bin. dnf relied on a transitive edge: it holds on RHEL, it is absent on openSUSE. So we name it everywhere rather than depend on it. Assisted-by: Claude Opus 5 (cherry picked from commit c02805a42eebf23380762ff6fc56db0aec1680b5) --- script/install/install_fedora_dependency.sh | 9 ++++++++- script/install/install_suse_dependency.sh | 11 ++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/script/install/install_fedora_dependency.sh b/script/install/install_fedora_dependency.sh index eb571e4..b099307 100755 --- a/script/install/install_fedora_dependency.sh +++ b/script/install/install_fedora_dependency.sh @@ -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 diff --git a/script/install/install_suse_dependency.sh b/script/install/install_suse_dependency.sh index 40eeed8..0c3cf77 100755 --- a/script/install/install_suse_dependency.sh +++ b/script/install/install_suse_dependency.sh @@ -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 From 95d71df20472e45a89aa8db5361ac2477cadcd08 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 18:14:45 -0400 Subject: [PATCH 02/10] [FIX] systemd : lancer run.sh par bash, contre les echecs 203/EXEC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit erplibre.service mourait en 3 ms sur openSUSE s390x, « status=203/EXEC », sans jamais entrer dans le script. Ce code ne dit pas que run.sh a echoue : il dit que systemd n'a pas pu l'EXECUTER. Quatre causes le produisent — bit x absent, shebang qui ne resout pas, /home monte noexec, SELinux refusant l'execve (Leap 16 est passe a SELinux). Les distinguer demande un acces a la machine ; les traiter ensemble ne le demande pas. Passe a « /bin/bash run.sh », le fichier n'est plus qu'une donnee lue : noexec et SELinux ne portent que sur l'execve, et le bit x devient sans objet. Mesure : un script en 644 refuse en direct, execute par bash. Les trois generateurs ecrivaient la meme ligne, les trois sont corriges. Le venv n'y est pour rien — odoo_bin.sh l'active deja, et c'est celui d'Odoo, pas celui des outils. --- EN --- erplibre.service died in 3 ms on openSUSE s390x, "status=203/EXEC", never entering the script. That code does not say run.sh failed: it says systemd could not EXECUTE it. Four causes produce it — missing x bit, unresolvable shebang, /home mounted noexec, SELinux denying execve (Leap 16 switched to SELinux). Telling them apart needs access to the machine; handling them together does not. Run as "/bin/bash run.sh", the file is merely data being read: noexec and SELinux only cover execve, and the x bit becomes moot. Measured: a 644 script refused directly, executed fine through bash. All three generators wrote the same line; all three are fixed. The venv is not involved — odoo_bin.sh already activates it, and it is Odoo's, not the tooling one. Assisted-by: Claude Opus 5 (cherry picked from commit 6f8cee84b72fd016fa188d204f280b011594627c) --- script/systemd/install_daemon.py | 17 ++++++++++++++++- script/systemd/install_daemon.sh | 2 +- script/todo/todo.py | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/script/systemd/install_daemon.py b/script/systemd/install_daemon.py index 9f0ef6a..0369940 100755 --- a/script/systemd/install_daemon.py +++ b/script/systemd/install_daemon.py @@ -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 diff --git a/script/systemd/install_daemon.sh b/script/systemd/install_daemon.sh index cfc2bd2..922872e 100755 --- a/script/systemd/install_daemon.sh +++ b/script/systemd/install_daemon.sh @@ -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 diff --git a/script/todo/todo.py b/script/todo/todo.py index d8ea5c6..641dd66 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5421,7 +5421,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" From deae7d16c76d52f9838881b4557a0f617138925e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 14 Aug 2026 02:19:41 -0400 Subject: [PATCH 03/10] [ADD] install s390x : batir PROJ quand la distribution est en retard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debian 13 installe ERPLibre de bout en bout ; Debian 12 s'arrete sur pyproj : ERROR: Minimum supported PROJ version is 9.4.0, installed version is 9.1.1 bookworm livre 9.1.1, trixie 9.6 — d'ou l'ecart entre les deux. La portee est etroite : sur amd64 et arm64, pyproj pose une roue manylinux qui EMBARQUE sa propre PROJ, et la version du systeme n'entre pas en jeu. s390x n'a pas de roue et compile contre celle du systeme. lib_proj.sh est le calque de lib_qpdf.sh, meme motif et memes garde-fous : seuil, comparaison qui complete les composantes manquantes — « sort -V » classe 9.4 avant 9.4.0 — installation dans /usr/local, declaration a ld.so, et jamais de code non nul pour ne pas masquer ce que pyproj dira lui-meme. L'appel reste sous la garde s390x, verifie. --- EN --- Debian 13 installs ERPLibre end to end; Debian 12 stops on pyproj: ERROR: Minimum supported PROJ version is 9.4.0, installed version is 9.1.1 bookworm ships 9.1.1, trixie 9.6 — hence the gap between the two. The scope is narrow: on amd64 and arm64 pyproj lays down a manylinux wheel that BUNDLES its own PROJ, and the system version never comes into play. s390x has no wheel and builds against the system one. lib_proj.sh mirrors lib_qpdf.sh, same pattern and same guards: a threshold, a comparison that pads missing components — "sort -V" ranks 9.4 before 9.4.0 — installation into /usr/local, an ld.so declaration, and never a non-zero exit so as not to mask what pyproj itself will say. The call stays under the s390x guard, verified. Assisted-by: Claude Opus 5 (cherry picked from commit f779702b61ff6405bdd7efabaa1f5bac09e1166b) --- script/install/install_debian_dependency.sh | 7 +- script/install/lib_proj.sh | 89 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 script/install/lib_proj.sh diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index 52e1b4e..908f523 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -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 diff --git a/script/install/lib_proj.sh b/script/install/lib_proj.sh new file mode 100644 index 0000000..5001bf4 --- /dev/null +++ b/script/install/lib_proj.sh @@ -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 +} From 5b748e347e57d3c13044768728ab319a82986881 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 20:06:23 -0400 Subject: [PATCH 04/10] =?UTF-8?q?[ADD]=20make:=20une=20cible=20pour=20les?= =?UTF-8?q?=20tests=20unitaires,=20d=C3=A9pendance=20mobile=20d=C3=A9clar?= =?UTF-8?q?=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ces 382 tests ne tournaient que lancés à la main, donc jamais. « make test » demande une base de données et plusieurs minutes ; ceux-ci lisent le code et exécutent les fragments de shell générés, sudo, pgrep et pkill bouchonnés, en une dizaine de secondes. Deux cibles : test_unit, et test_unit_file pour la boucle d'écriture. La dépendance à mobile/erplibre_home_mobile est DITE plutôt que supposée : le lanceur l'annonce présente ou absente, et les tests du vrai transfert se déclarent ignorés — avec la commande qui manque — au lieu de passer en silence. Vérifié dans les trois états : dépôt absent, présent non compilé, compilé. Au passage, compile_and_run.sh vérifie le transfert des dépôts, comme l'installation d'une VM et par le même script. --- EN --- These 382 tests only ran when invoked by hand, so never. "make test" wants a database and several minutes; these read the code and run the generated shell fragments with sudo, pgrep and pkill stubbed, in about ten seconds. Two targets: test_unit, and test_unit_file for the writing loop. The dependency on mobile/erplibre_home_mobile is STATED rather than assumed: the runner announces it present or absent, and the real-transfer tests declare themselves skipped — naming the missing command — instead of passing quietly. Checked in all three states: repo absent, present but unbuilt, built. Along the way, compile_and_run.sh verifies the repo transfer, like a VM install and through the same script. Assisted-by: Claude Opus 5 --- .claude/skills/erplibre-commands/SKILL.md | 12 +++- conf/make.test.Makefile | 25 ++++++++ mobile/compile_and_run.sh | 22 ++++++- script/test/run_unit_test.sh | 72 ++++++++++++++++++++++ script/todo/todo.py | 10 ++- test/test_mobile_bundle_transfer.py | 74 +++++++++++++++++++++++ 6 files changed, 208 insertions(+), 7 deletions(-) create mode 100755 script/test/run_unit_test.sh diff --git a/.claude/skills/erplibre-commands/SKILL.md b/.claude/skills/erplibre-commands/SKILL.md index 1287bb7..0c5ff0d 100644 --- a/.claude/skills/erplibre-commands/SKILL.md +++ b/.claude/skills/erplibre-commands/SKILL.md @@ -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 diff --git a/conf/make.test.Makefile b/conf/make.test.Makefile index b636aa6..30625a9 100644 --- a/conf/make.test.Makefile +++ b/conf/make.test.Makefile @@ -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 diff --git a/mobile/compile_and_run.sh b/mobile/compile_and_run.sh index 6553a7f..151eca1 100755 --- a/mobile/compile_and_run.sh +++ b/mobile/compile_and_run.sh @@ -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 diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh new file mode 100755 index 0000000..fb227ea --- /dev/null +++ b/script/test/run_unit_test.sh @@ -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 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} diff --git a/script/todo/todo.py b/script/todo/todo.py index 641dd66..04f9964 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2058,13 +2058,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é. diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py index a89e490..f877e33 100644 --- a/test/test_mobile_bundle_transfer.py +++ b/test/test_mobile_bundle_transfer.py @@ -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 From 60d049dfec339e2c723e0d2aa2b5a1c304aebe05 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 05:07:06 -0400 Subject: [PATCH 05/10] =?UTF-8?q?[FIX]=20install=20:=20compiler=20pykcs11?= =?UTF-8?q?=20avec=20SWIG=204.3=20et=20au-del=C3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toute installation d'Odoo 14, 15 ou 17 échoue depuis que SWIG 4.5 est paru sur PyPI : « ‘PyInt_FromLong’ was not declared in this scope », 55 fois. pykcs11 — tiré par endesive — ne livre aucun wrapper pré-généré, et son « requires = ["swig"] » n'est pas borné : c'est la DERNIÈRE version publiée qui tourne, pas celle du système. Or SWIG 4.3 a retiré les alias Python 2 qu'il écrivait lui-même, et le typemap CK_RV de pykcs11 en utilise un. On rend l'alias au préprocesseur, identique token pour token à celui de SWIG 4.2. CPPFLAGS et non CFLAGS : un .cpp passe par compiler_so_cxx. Vérifié sur la VM et ici : poetry install rend 0, import PyKCS11 passe. --- EN --- Every Odoo 14, 15 and 17 install has been failing since SWIG 4.5 landed on PyPI: "'PyInt_FromLong' was not declared in this scope", 55 times. pykcs11 — pulled in by endesive — ships no pre-generated wrapper, and its `requires = ["swig"]` is unbounded: the LATEST published version runs, not the system one. SWIG 4.3 dropped the Python 2 aliases it used to emit itself, and pykcs11's CK_RV typemap uses one of them. We hand the alias back to the preprocessor, token for token identical to SWIG 4.2's. CPPFLAGS, not CFLAGS: a .cpp goes through compiler_so_cxx. Verified on the VM and here: poetry install returns 0, import PyKCS11 works. Assisted-by: Claude Opus 5 --- script/install/install_locally.sh | 23 ++++ script/test/run_unit_test.sh | 2 +- test/test_install_swig_compat.py | 190 ++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 test/test_install_swig_compat.py diff --git a/script/install/install_locally.sh b/script/install/install_locally.sh index d73d13d..0955fee 100755 --- a/script/install/install_locally.sh +++ b/script/install/install_locally.sh @@ -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 diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index fb227ea..f25c954 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -42,7 +42,7 @@ 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 2>/dev/null) + test/test_todo_*.py test/test_install_*.py 2>/dev/null) fi fail=0 diff --git a/test/test_install_swig_compat.py b/test/test_install_swig_compat.py new file mode 100644 index 0000000..a6eb4d0 --- /dev/null +++ b/test/test_install_swig_compat.py @@ -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 +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) From 22d30a1504edb491e4affc2e9fbbe801090977c6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 23:39:00 -0400 Subject: [PATCH 06/10] [FIX] security: redact the master password from every output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL raised seven high-severity alerts on this branch. Three were real, and the same secret was behind all of them: the Odoo master password, which db_restore appends to its command line as soon as the database declares one. The command itself was printed raw -- "print(arg)" -- so the password reached stdout, and any terminal capture with it. The probe output was logged raw too, and a refused attempt echoes the command it tried. Wider than that: the runner filtered the command it was about to run, but not what came back. A tool that reprints its own arguments -- "set -x", a traceback, odoo_bin.sh -- put the secret straight back into the terminal AND into the log file the sink writes. Every subprocess line now goes through the same filter as the command. The four remaining alerts sit on expressions already wrapped in redact_secrets(). CodeQL does not cross re.sub, so it cannot see the barrier; the mitigation is real and they are false positives. --- FR --- CodeQL a levé sept alertes de sévérité haute sur cette branche. Trois étaient réelles, et le même secret était derrière : le mot de passe maître d'Odoo, que db_restore ajoute à sa ligne de commande dès que la base en exige un. La commande elle-même était imprimée telle quelle — « print(arg) » — donc le mot de passe atteignait la sortie standard, et toute capture de terminal avec elle. La sortie de la sonde était journalisée brute également, et un essai refusé réaffiche la commande tentée. Plus large : le lanceur filtrait la commande qu'il allait exécuter, mais pas ce qui en revenait. Un outil qui réaffiche ses propres arguments — « set -x », une trace, odoo_bin.sh — remettait le secret dans le terminal ET dans le fichier de journal. Chaque ligne du sous-processus passe désormais par le même filtre que la commande. Les quatre alertes restantes portent sur des expressions déjà entourées de redact_secrets(). CodeQL ne franchit pas re.sub et ne voit donc pas la barrière ; la mitigation est réelle, ce sont des faux positifs. Assisted-by: Claude Opus 5 --- script/database/db_restore.py | 15 +++++++++------ script/execute/execute.py | 5 +++++ script/qemu/deploy_qemu.py | 8 +++++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/script/database/db_restore.py b/script/database/db_restore.py index 8cbac35..9b30b67 100755 --- a/script/database/db_restore.py +++ b/script/database/db_restore.py @@ -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__) @@ -138,7 +140,7 @@ def ask_master_password(arg_base, essais=MAX_ESSAIS_MOT_DE_PASSE): 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 +234,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 +256,9 @@ 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()) + # « arg_base » porte --master_password des que la base en exige un. + print(redact_secrets(arg)) + print(redact_secrets(check_output(arg.split(" ")).decode())) if config.ignore_cache: verify_filestore(config.database, config.image) @@ -298,7 +301,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 +311,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 diff --git a/script/execute/execute.py b/script/execute/execute.py index dcb360f..386a243 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -183,6 +183,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: diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index c253bf0..86f700e 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -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) From 6cdbd528f3feaba205fc1a7dd3e0478c0540ac9e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 00:13:10 -0400 Subject: [PATCH 07/10] [FIX] security: keep the master password out of the command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redacting what we print treats the symptom. The password was still an argument, and /proc//cmdline is readable by EVERY user on the machine for as long as the command runs -- an exposure no filter reaches. It now travels in MASTER_PWD. The probe sets it on the child it spawns; once accepted it is placed in this process's environment, so every later call inherits it without argv ever carrying it. /proc//environ is readable only by its owner. Worth knowing for anyone reading the old code: the option was appended to every invocation, but odoo's db command reads it in the drop branch alone. list, restore and clone were carrying a secret they never used. The redaction stays. It is the last line, not the first, and other options still put secrets on command lines. --- FR --- Caviarder ce qu'on affiche traite le symptôme. Le mot de passe restait un argument, et /proc//cmdline est lisible par TOUT utilisateur de la machine tant que la commande tourne — une exposition qu'aucun filtre n'atteint. Il voyage désormais dans MASTER_PWD. La sonde le pose sur l'enfant qu'elle lance ; une fois accepté, il est placé dans l'environnement de ce processus, si bien que tous les appels suivants en héritent sans qu'argv le porte jamais. /proc//environ n'est lisible que par son propriétaire. À savoir pour qui relit l'ancien code : l'option était ajoutée à chaque invocation, alors que la commande db d'odoo ne la lit que dans la branche drop. list, restore et clone portaient un secret dont ils ne faisaient rien. Le caviardage reste. Il est le dernier rempart, pas le premier, et d'autres options mettent encore des secrets sur des lignes de commande. Assisted-by: Claude Opus 5 --- script/database/db_restore.py | 20 +++++++++++++++----- script/execute/execute.py | 13 +++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/script/database/db_restore.py b/script/database/db_restore.py index 9b30b67..a394f4f 100755 --- a/script/database/db_restore.py +++ b/script/database/db_restore.py @@ -104,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//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 "") @@ -133,8 +139,7 @@ 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): @@ -256,7 +261,9 @@ def restore_or_clone(config, arg_base, cache_database, lst_db_cache): ) if config.neutralize: arg += " --neutralize" - # « arg_base » porte --master_password des que la base en exige un. + # 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: @@ -290,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") diff --git a/script/execute/execute.py b/script/execute/execute.py index 386a243..a042323 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -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 '' », -# 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 '' ». +# 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//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. From 3f97b0a49c497616755a0526dc5abfc779107a08 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 00:25:03 -0400 Subject: [PATCH 08/10] [FIX] security: the KeePass password leaves the command line too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same exposure as the master password, same fix. kdbx_manager put the Odoo password straight into the web_login command; /proc//cmdline is readable by every user on the machine, and no downstream filter reaches that. The command now carries the NAME of an environment variable, never the value. One name per entry, because several credentials go out in a single "parallel" call and a single variable could not tell them apart. get_extra_command_user therefore returns (fragments, variables), and the two call sites hand the variables to exec_command_live, which already merged an environment. Two things found on the way. web_login re-sent config.default_password_auth when it retried after dismissing a modal, ignoring whatever the caller had passed -- the retry silently fell back to "admin". And install_forgejo printed the admin password back to the terminal, hence into the install log and any CI capture; its own header already documents the default. A test pins the guarantee: the fragment must not contain the password. --- FR --- Même exposition que pour le mot de passe maître, même correctif. kdbx_manager mettait le mot de passe Odoo directement dans la commande web_login ; /proc//cmdline est lisible par tout utilisateur de la machine, et aucun filtre en aval ne l'atteint. La commande porte désormais le NOM d'une variable d'environnement, jamais la valeur. Un nom par entrée, car plusieurs identifiants partent dans un seul appel « parallel » et une variable unique ne saurait les distinguer. get_extra_command_user rend donc (fragments, variables), et les deux appelants confient les variables à exec_command_live, qui fusionnait déjà un environnement. Deux trouvailles en chemin. web_login renvoyait config.default_password_auth à la reprise après une modale, ignorant ce que l'appelant avait fourni — la reprise retombait en silence sur « admin ». Et install_forgejo réaffichait le mot de passe administrateur, donc dans le journal d'installation et toute capture de CI ; son propre en-tête documente déjà le défaut. Un test verrouille la garantie : le fragment ne doit pas porter le secret. Assisted-by: Claude Opus 5 --- script/forgejo/install_forgejo.sh | 6 ++++- script/selenium/web_login.py | 32 +++++++++++++++++++------ script/todo/kdbx_manager.py | 27 +++++++++++++++------ script/todo/todo.py | 40 +++++++++++++++++++++---------- test/test_todo.py | 27 ++++++++++++++++++--- 5 files changed, 102 insertions(+), 30 deletions(-) diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh index d2f873b..ca312c3 100755 --- a/script/forgejo/install_forgejo.sh +++ b/script/forgejo/install_forgejo.sh @@ -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é ------------------------------------------------------------- diff --git a/script/selenium/web_login.py b/script/selenium/web_login.py index e26fd67..d747711 100755 --- a/script/selenium/web_login.py +++ b/script/selenium/web_login.py @@ -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//cmdline est lisible par tout + # utilisateur de la machine, /proc//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: diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index 8a798f6..3b7ba59 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -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//cmdline est lisible par tout + utilisateur de la machine, /proc//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 diff --git a/script/todo/todo.py b/script/todo/todo.py index 04f9964..ef32dc5 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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") @@ -11401,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" @@ -11423,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) diff --git a/test/test_todo.py b/test/test_todo.py index e98c47e..5900f63 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -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//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): From 60df5ac6305fd185e571a0963e3ecffd215db68d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 00:32:37 -0400 Subject: [PATCH 09/10] [FIX] test: the retry loop pinned the password ON the command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the suite for real turned up seven errors I had just caused. The fake probe took one argument, and probe_master_password now takes two. Worse than the signature: two assertions checked that the probed command CONTAINED "--master_password=bon". They pinned exactly the exposure the previous commit removed -- a test can hold a defect in place as firmly as it holds a guarantee. They now assert the opposite, which is the property worth keeping: the password reaches the probe beside the command, and the command carries neither the value nor the option. --- FR --- Exécuter la suite pour de vrai a fait apparaître sept erreurs que je venais de causer. La fausse sonde prenait un argument, et probe_master_password en prend désormais deux. Pire que la signature : deux assertions vérifiaient que la commande sondée CONTENAIT « --master_password=bon ». Elles verrouillaient exactement l'exposition que le commit précédent a retirée — un test tient un défaut en place aussi fermement qu'une garantie. Elles affirment maintenant l'inverse, qui est la propriété à conserver : le mot de passe parvient à la sonde à CÔTÉ de la commande, et la commande ne porte ni la valeur ni l'option. Assisted-by: Claude Opus 5 --- test/test_master_password_retry.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/test_master_password_retry.py b/test/test_master_password_retry.py index 9efec85..b6fd3e3 100644 --- a/test/test_master_password_retry.py +++ b/test/test_master_password_retry.py @@ -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//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): From 355e3f044cfa4d8d3ff2f5e478822f768a173b62 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 01:06:25 -0400 Subject: [PATCH 10/10] [UPD] changelog: the 143 commits this branch actually carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unreleased section described the previous cycle. It named the COW tools and the QEMU deployment, then stopped -- and this branch is mostly what came after: an Odoo migration that drives itself, a state screen, a quality report, and the repairs each bump turned out to need. Twenty-one entries, fourteen of them Added. Read from the commits rather than from memory, and grouped by what a reader would look for: the migration run, its state, its quality, its repairs, its smoke tests. The five commits that only touch documentation or tests are left out; the ones that change what the tool does are all there. Nothing already listed is repeated. The COW tools, the analysis toolkit and the deployment form keep the entries they had. --- FR --- La section Unreleased décrivait le cycle précédent. Elle nommait les outils COW et le déploiement QEMU, puis s'arrêtait — or cette branche est surtout ce qui a suivi : une migration Odoo qui se conduit seule, un écran d'état, un rapport de qualité, et les réparations que chaque palier a révélées nécessaires. Vingt et une entrées, dont quatorze sous Ajouté. Lues dans les commits plutôt que de mémoire, et regroupées selon ce qu'un lecteur cherche : l'exécution de la migration, son état, sa qualité, ses réparations, ses tests de fumée. Les cinq commits qui ne touchent que la documentation ou les tests sont écartés ; ceux qui changent ce que fait l'outil y sont tous. Rien de déjà listé n'est redit. Les outils COW, la boîte à outils d'analyse et le formulaire de déploiement gardent leurs entrées. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 55 +++++++++++++++++++++++++++++++++++++++++++++-- CHANGELOG.fr.md | 29 +++++++++++++++++++++++-- CHANGELOG.md | 26 ++++++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 199bf0d..35ae733 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -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 tags Odoo 18 renamed to +- 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 @@ -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 qu'Odoo 18 a renommées en +- 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 ## 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 @@ -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 ## 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 @@ -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 ## Security @@ -282,11 +328,16 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - 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//environ is readable only by its owner where /proc//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 -- 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//environ n'est lisible que par son propriétaire là où /proc//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 diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0a4386c..94d56b9 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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 qu'Odoo 18 a renommées en +- 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//environ n'est lisible que par son propriétaire là où /proc//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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2917223..e3b92ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 tags Odoo 18 renamed to +- 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//environ is readable only by its owner where /proc//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