diff --git a/Makefile b/Makefile index ba971ab..7d62ba6 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,4 @@ CREDIT_ANCHORTEXT = Turnkey Odoo Appliance -BACKPORTS=y # install Odoo v16.x from backports -BACKPORTS_PINS=odoo-16 - include $(FAB_PATH)/common/mk/turnkey/lapp.mk include $(FAB_PATH)/common/mk/turnkey.mk diff --git a/README.rst b/README.rst index c68e98f..7efc805 100644 --- a/README.rst +++ b/README.rst @@ -12,26 +12,29 @@ or paid commercial ones. This appliance includes all the standard features in `TurnKey Core`_: -- Odoo configurations for TurnKey v18.x: +- Odoo configurations for TurnKey v19.x: - - Odoo v16 installed from debian backports apt repo (v18.x). + - Odoo 19 Community installed from Odoo's official package repository. - Includes modules from base install of Odoo. + - Includes the patched wkhtmltopdf 0.12.6 report renderer required by Odoo. -- **Security note**: As of 18.0, due to using the debian backports repo - Updates to Odoo **ARE NOT** configured to install automatically. +- **Security note**: Odoo application updates require supervision and are not + configured to install automatically. Refresh APT metadata, check the official + daily channel with ``odoo-update --check``, back up the database, then apply + the selected APT update. - SSL support out of the box. - `Adminer`_ administration frontend for PostgreSQL (listening on port 12322 - uses SSL). - Webmin modules for configuring Apache2, PostgreSQL and Postfix. -**To create a new Odoo Databse (i.e. site)** +**To create a new Odoo Database (i.e. site)** To create a new Odoo DB, the Odoo config file needs to be edited and the Odoo service restarted. Overview of process: -1. Edit /etc/odoo/odoo +1. Edit /etc/odoo/odoo.conf - change value of "db_name" from "TurnkeylinuxExample" to your desired DB name 2. Restart odoo.service @@ -52,7 +55,7 @@ Webmin steps: CLI steps: 1. - - Edit /etc/odoo/odoo as per step 1 + - Edit /etc/odoo/odoo.conf as per step 1 2. - Restart odoo.service:: diff --git a/changelog b/changelog index 37dd4e1..5ba83bc 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,24 @@ +turnkey-odoo-19.0 (1) turnkey; urgency=low + + * Install supported Odoo 19 Community from its official daily package + channel with an exact package digest and bound repository key. + + * Install Odoo's required patched wkhtmltopdf 0.12.6 series from an exact + upstream package digest for PDF report rendering. + + * Bridge Odoo's stale python3-pypdf2 package dependency to Trixie's + maintained python3-pypdf implementation without modifying Odoo's payload. + + * Use a non-superuser PostgreSQL application role and keep generated + database and firstboot credentials out of process arguments and traces. + + * Add a non-mutating update check, v19 acceptance coverage, and README + evidence crosswalk. + + * Upgrade the base distribution to Debian 13 Trixie. + + -- TurnKey Linux release engineering Tue, 25 Aug 2026 00:00:00 +0000 + turnkey-odoo-18.0 (1) turnkey; urgency=low * Install Odoo v16.x from Debian backports (bookworm-backports) apt diff --git a/conf.d/main b/conf.d/main index 74d377a..3127e7a 100755 --- a/conf.d/main +++ b/conf.d/main @@ -1,11 +1,124 @@ #!/bin/bash -ex +set -o pipefail + +# Install the supported Odoo Community package from Odoo's official channel. +ODOO_VERSION=19.0.20260825 +ODOO_REPO=https://nightly.odoo.com/19.0/nightly/deb +ODOO_PACKAGE_URL="$ODOO_REPO/odoo_${ODOO_VERSION}_all.deb" +ODOO_PACKAGE_SHA256=e9d89da0fc94cd752b08b1e5501d97f464b834229ff8d68c7fecf24304e1da69 +ODOO_KEY_URL=https://nightly.odoo.com/odoo.key +ODOO_KEY_SHA256=1d169c013a727fa00238ba3f6417d5f33f162f8fbae32159a87ecab6966bfe8e +ODOO_KEY_FINGERPRINT=5D134C924CB06330DCEFE2A1DEF2A2198183CBB5 +WKHTMLTOX_VERSION=1:0.12.6.1-3.bookworm +WKHTMLTOX_ARCHITECTURE=amd64 +WKHTMLTOX_URL=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb +WKHTMLTOX_SHA256=98ba0d157b50d36f23bd0dedf4c0aa28c7b0c50fcdcdc54aa5b6bbba81a3941d +PYPDF_COMPAT_PACKAGE=turnkey-odoo-pypdf-compat +PYPDF_COMPAT_VERSION=1.0+turnkey19.0.1 +SOURCE_RECORD=/usr/local/share/turnkey-odoo/source + +key=$(mktemp) +package=$(mktemp --suffix=.deb) +wkhtmltox=$(mktemp --suffix=.deb) +pypdf_compat_root=$(mktemp -d) +pypdf_compat=$(mktemp --suffix=.deb) +trap 'find "$key" "$package" "$wkhtmltox" "$pypdf_compat_root" "$pypdf_compat" -depth -delete' EXIT +curl -fsSL "$ODOO_KEY_URL" -o "$key" +echo "$ODOO_KEY_SHA256 $key" | sha256sum -c - +test "$(gpg --show-keys --with-colons "$key" | + awk -F: '$1 == "fpr" && !fingerprint { fingerprint=$10 } END { print fingerprint }')" = \ + "$ODOO_KEY_FINGERPRINT" +gpg --batch --yes --dearmor --output /usr/share/keyrings/odoo-archive-keyring.gpg "$key" + +cat >/etc/apt/sources.list.d/odoo.list </etc/apt/preferences.d/odoo <"$pypdf_compat_root/DEBIAN/control" < +Architecture: all +Depends: python3-pypdf +Provides: python3-pypdf2 +Description: Odoo 19 pypdf dependency compatibility + Satisfies Odoo's stale python3-pypdf2 package dependency with Trixie's + maintained python3-pypdf implementation. +EOF +dpkg-deb --build --root-owner-group "$pypdf_compat_root" "$pypdf_compat" +test "$(dpkg-deb --field "$pypdf_compat" Package)" = "$PYPDF_COMPAT_PACKAGE" +test "$(dpkg-deb --field "$pypdf_compat" Version)" = "$PYPDF_COMPAT_VERSION" +test "$(dpkg-deb --field "$pypdf_compat" Architecture)" = all +test "$(dpkg-deb --field "$pypdf_compat" Depends)" = python3-pypdf +test "$(dpkg-deb --field "$pypdf_compat" Provides)" = python3-pypdf2 +DEBIAN_FRONTEND=noninteractive apt-get install -y "$pypdf_compat" +test "$(dpkg-query -W -f='${Version}' "$PYPDF_COMPAT_PACKAGE")" = \ + "$PYPDF_COMPAT_VERSION" +test "$(dpkg-query -W -f='${Provides}' "$PYPDF_COMPAT_PACKAGE")" = \ + python3-pypdf2 +python3 -c 'import pypdf; assert pypdf.__version__' + +curl -fsSL "$ODOO_PACKAGE_URL" -o "$package" +echo "$ODOO_PACKAGE_SHA256 $package" | sha256sum -c - +DEBIAN_FRONTEND=noninteractive apt-get install -y "$package" +test "$(dpkg-query -W -f='${Version}' odoo)" = "$ODOO_VERSION" +apt-get check +runuser -u odoo -- python3 -c \ + 'from odoo.tools import pdf; assert pdf.SUBMOD == "._pypdf"; assert pdf.pypdf.__version__' +systemctl stop odoo + +mkdir -p "$(dirname "$SOURCE_RECORD")" +cat >"$SOURCE_RECORD" <&2 + exit 1 +fi +runuser -u postgres -- createdb --owner="$DB_USER" "$DB_NAME" + +runuser -u postgres -- psql --no-psqlrc --set ON_ERROR_STOP=on \ + "$DB_NAME" <> $CONF +cat >>"$CONF" </dev/null && break + sleep 2 done - -URL="https://127.0.0.1/web" -CURL="curl --insecure -c /tmp/cookie -b /tmp/cookie" - -$CURL $URL/login -sleep 1 -$CURL $URL/database/manager -sleep 1 -$CURL $URL/database/change_password --data-raw "master_pwd=admin&master_pwd_new=${ODOO_ADMIN_PASSWORD}" +curl --insecure --fail --silent https://127.0.0.1/web/login >/dev/null systemctl stop odoo systemctl stop postgresql -systemctl stop apache2 +systemctl stop apache2 +set -x diff --git a/docs/v19.0-testing.md b/docs/v19.0-testing.md new file mode 100644 index 0000000..2db573c --- /dev/null +++ b/docs/v19.0-testing.md @@ -0,0 +1,449 @@ +# Odoo v19 acceptance + +## Source decision + +Debian Trixie does not package Odoo. The appliance installs Odoo 19 Community +from Odoo's official daily Debian channel. Odoo 19 receives standard on-premise +support and security updates through September 2028. The build pins the exact +daily package, verifies its SHA-256, and binds the signed repository key used +for later updates. Odoo documents the +[official Community repository](https://www.odoo.com/documentation/19.0/administration/on_premise/packages.html), +[September 2028 support horizon](https://www.odoo.com/documentation/19.0/administration/supported_versions.html), +and the recommended prefork plus cron-worker deployment in its +[system configuration guide](https://www.odoo.com/documentation/19.0/administration/on_premise/deploy.html). +Debian Trixie no longer packages `wkhtmltopdf`; Odoo requires a manually +installed patched 0.12.6 renderer for report headers and footers. The appliance +therefore pins the latest +[upstream 0.12.6.1-3 release](https://github.com/wkhtmltopdf/packaging/releases/tag/0.12.6.1-3) +Bookworm package, whose legacy library dependency names are provided by +Trixie's ABI-compatible `t64` packages. +The official Odoo payload imports modern `pypdf` and Odoo's own Trixie upgrade +script installs Debian's `python3-pypdf`, but the daily package metadata still +depends on Bookworm's removed `python3-pypdf2` name. A minimal versioned +`turnkey-odoo-pypdf-compat` package therefore depends on Trixie's maintained +implementation and provides only the stale package name. It does not replace +or modify the hash-verified Odoo payload. + +The selected evidence is: + +- version `19.0.20260825`; +- repository key fingerprint + `5D134C924CB06330DCEFE2A1DEF2A2198183CBB5`; +- repository key SHA-256 + `1d169c013a727fa00238ba3f6417d5f33f162f8fbae32159a87ecab6966bfe8e`; +- package SHA-256 + `e9d89da0fc94cd752b08b1e5501d97f464b834229ff8d68c7fecf24304e1da69`. +- patched wkhtmltox AMD64 version `1:0.12.6.1-3.bookworm` and package SHA-256 + `98ba0d157b50d36f23bd0dedf4c0aa28c7b0c50fcdcdc54aa5b6bbba81a3941d`. +- pypdf compatibility package `turnkey-odoo-pypdf-compat` version + `1.0+turnkey19.0.1`, backed by Debian Trixie's `python3-pypdf`. + +## README crosswalk + +| Contract | Focused acceptance | Required result | +| --- | --- | --- | +| Example administrator | Authenticate to the Odoo JSON session through Apache HTTPS with `TKL_TEST_APP_PASS` | Odoo identifies the example `admin` user | +| Integrated business records | Create and read a throwaway contact through Odoo's authenticated model API | Its name and email round trip through Odoo | +| Database creation and privilege | Inspect PostgreSQL's catalog and initialized Odoo schema | The example database belongs to a login/create-database role without superuser, role-create, or replication privileges | +| PostgreSQL persistence | Read the contact directly, restart PostgreSQL and Odoo, then read it again | The database row survives the service restart | +| Workers and background jobs | Require two prefork HTTP workers and one cron worker, then schedule a throwaway action | The cron worker updates the contact through Odoo and the value is readable through the model API | +| PDF report renderer | Verify the patched upstream version and render a minimal HTML document | `wkhtmltopdf` produces a PDF on Trixie | +| Database management password | Verify the configured master password with Odoo's own configuration API | `TKL_TEST_APP_PASS` verifies without exposing the stored hash | +| HTTPS proxy | Validate Apache and use only its HTTPS endpoint for application calls | Apache configuration passes and proxies Odoo normally | +| Adminer and Webmin | Require Adminer plus Apache, PostgreSQL, and Postfix module directories | Appliance-specific administration components are installed | +| Outbound mail | Require the Postfix unit | Postfix is enabled and active; public delivery is outside local acceptance | +| Supervised updates | Refresh APT metadata, then run `odoo-update --check` | The installed daily version, signed candidate, channel, and simulated dependency resolution are reported without changing the installed application | +| Inherited administration | Cite unchanged SSH, Webmin, and common platform behavior | Core 19 PASS run `20260824t010251z-1634-32241` at source `24c82ee3540ce545422742b0e28ba6b687c53ec2` remains applicable | + +The acceptance fixture deletes its contact through Odoo on exit. + +## Exact acceptance + +```sh +TKLDEV_CONTAINER=tkldev19-wave2-2 \ +TKL_HARNESS_STATE_DIR=/home/agent/.local/state/turnkey-v19-harness-wave2-2 \ +TKL_HARNESS_LOCK_FILE=/home/agent/.local/state/turnkey-v19-harness-wave2-2/build.lock \ +TKL_HARNESS_DOCKER_LIMIT_BYTES=137438953472 \ +TKL_HARNESS_DOCKER_OBJECT_LABEL=org.turnkeylinux.v19-harness.wave2-2 \ +/home/agent/.local/worktrees/turnkey/harness-wave2-2-152bc9b/tools/test-v19-appliance odoo \ + --source /home/agent/.local/worktrees/turnkey-apps/odoo/wish-odoo-v19-trixie +``` + +Loop 1 (`20260826t143314z-3386451-1936`) passed HTTPS preflight but stopped in +package resolution because Debian Trixie has no `wkhtmltopdf` package. Cleanup, +builder identity, and disk checks passed. The fix replaced that nonexistent +plan package with the exact upstream renderer described above. + +Loop 1 used source `6b6c43f72fa63cc4ed74075f27635936a57871d4` +and harness `152bc9b876557266b90ed4ded77b611d9b817aff`. Its source commit +and transport archive SHA-256 was +`01b97ba3c5e46ad3189df690e1ca525f2c43088b6cb0f9fb15bf6e07d73eb981`; +the staged input tree SHA-256 was +`3ccee560801b3262174c0d782d3ef1d9a6edfa025646e1b899720ba5a567c15a`. +The retained failure manifest validated every retained file with +`sha256sum -c RETAINED-SHA256SUMS` before the retry. Loop 2 +(`20260826t145637z-3507144-30487`) used source +`9a09c276be5570006c560aada43ff44445eabccf` with the same exact harness. +The patched renderer installed and verified, then the official Odoo package +failed dependency resolution because Trixie has no `python3-pypdf2`. Cleanup, +builder identity, and disk checks passed. Its source commit and transport +archive SHA-256 was +`4ee53cf5f9d8ea0a14d605071087bbd0ded3f580707f1993382708d0e4b3553e`; +the staged input tree SHA-256 was +`70f30087f61d835cb3ce034eb565043a6a3f4442b1d1e15fef8da3c318a39a59`. +The retained loop 2 manifest also validated every retained file before the +compatibility fix. + +A disposable `debian:trixie-slim` probe installed Debian +`python3-pypdf` `5.4.0-1`, the generated compatibility provider, and the exact +hash-verified Odoo package. `apt-get check`, `dpkg --audit`, Odoo's `._pypdf` +selection, and a one-page PDF write/read roundtrip all passed; the labeled +probe containers were removed on exit. + +The compatibility candidate then passed exact build, configured-root import, +inithooks, and supervised runtime in run +`20260826t160045z-3839726-26947`. The acceptance script stopped at its first +Python assertion because Odoo 19 lazily exposes `tools`; the fixture now +imports `config` explicitly at both call sites. No application assertion had +run, cleanup, builder identity, and disk checks passed, and the retained +manifest validated all configured-root and runtime evidence before the retry. + +The test-only retry `20260826t164023z-4008888-12740` again passed build, +configured-root import, and supervised boot, then correctly detected that +`odoo.service` was inactive. Its retained journal showed the production +`40odoo` firstboot hook had the same stale top-level `odoo.tools` access and +failed after rotating the database credential. The hook now imports Odoo's +`config` object explicitly before hashing the example administrator password, +setting the database-management password, and restarting Odoo. Acceptance also +reports a failing line and shell command without expanding secret variables. + +The production retry `20260826t172526z-4178915-31801` proved that the hook's +own `odoo.py` filename can shadow the installed package in the exact inithooks +layout. The loader now removes its script directory from module search and +evicts only a same-file `odoo` module before importing the official package. +The same retained journal showed Apache racing firstboot TLS replacement: it +started, exited while the certificate was being regenerated, and the common +hook skipped it because it was no longer active. `40odoo` now starts Apache +after the certificate and Odoo configuration are stable. Unit failures print +the exact unit and full status in later acceptance. + +A disposable exact-layout Trixie probe installed the official Odoo package, +the pypdf compatibility provider, TurnKey `inithooks` `2.3.6`, and the exact +product hook path. With the hook deliberately pre-registered as module +`odoo`, the loader evicted the shadow and resolved the installed Odoo namespace +through Python's `NamespaceLoader`; the imported object was Odoo's real +`configmanager`. `apt-get check` and `dpkg --audit` passed. A separate +firstboot-order probe proved Apache recovery occurs only after successful Odoo +configuration and does not record the application password. All labeled probe +containers were removed. + +The product-loop ledger before the next retry is four of six: renderer package +availability, stale Odoo pypdf metadata, firstboot's Odoo 19 config API, and +the namespace-shadow plus firstboot TLS race. The explicit-config acceptance +import retry was instrumentation-only and did not consume a product loop. + +Run `20260826t180138z-98149-31228` at source +`7e562aac607d28bb5c73e6220850601beb31d932` passed the exact build, +configured-root import, firstboot, and supervised runtime boundaries. Runtime +reported ready, multi-user active, inithooks complete, and application service +active, proving the namespace loader and late Apache recovery in the exact +appliance. Acceptance then exited with status 127 before its first JSON request +because the fixture's `jq` dependency was not installed. The plan now declares +that dependency and acceptance checks it before starting. This is another +instrumentation-only retry, so the product-loop ledger remains four of six. +Cleanup, builder identity, and disk checks passed, and the retained manifest +validated every file. The retained report SHA-256 was +`491615a0bee1a8484fbfe8c1b806a4bcd03df60d678b60a0dff878db1ddf4628`; +the run log SHA-256 was +`6419ebceb9a069b22473670adb1f9264930eed7545d7efe3155b88a9a3097f8b`. +A disposable Trixie probe installed Debian `jq` `1.7.1-6+deb13u3`, built and +queried the fixture's login JSON shape, passed `apt-get check` and +`dpkg --audit`, and removed its scoped container. + +The instrumentation retry `20260826t203245z-644554-15759` waited behind +Canvas for 1,648,174 ms, then used source +`aee4e49fa3f4d13560c4441371654cbd25fc02c5`. Its source archive SHA-256 +was `bc9bb4ef550be5c91c5b5a58a8b1b7732ab076a3593ee7f5f8d1bdb50d58741f` +and its input tree SHA-256 was +`f76ac1470114285c74d5410218c9b0a5502ecdae84b96ca8eebfc5ca73d41ab1`. +Build, configured-root import, supervised boot, and the `jq` boundary passed. +The first login then failed because the retained journal showed that Odoo 19's +configuration object no longer exposes `crypt_context`; the firstboot hook had +therefore not changed the example administrator password. Cleanup, builder +identity, and disk checks passed, and the retained manifest validated every +file. The retained report SHA-256 was +`c73037eee6e3248714e1a9525b9cce45fc8fac77b6b1e3e12cd56b86d75f1b79`; +the run log SHA-256 was +`ce81aff7e6f3af6323ccac2a59274d82ee1e81103f828140b3dc68afcd83e7ad`. + +Odoo's official 19.0 +[`res.users` implementation](https://github.com/odoo/odoo/blob/19.0/odoo/addons/base/models/res_users.py) +owns password hashing, and its +[`service.db` implementation](https://github.com/odoo/odoo/blob/19.0/odoo/service/db.py) +writes `base.user_admin.password` through the ORM. The hook now follows that +supported path through Odoo's noninteractive shell, passing the cleartext only +in the child environment and never in argv or SQL. An exact-package disposable +Trixie probe initialized a real PostgreSQL database, ran the complete hook with +a password containing spaces, a dollar sign, and quotes, and proved the stored +value was a non-plaintext Odoo hash that the model's own crypt context verifies. +It also verified the database-management password, supervised Odoo restart, +`apt-get check`, `dpkg --audit`, absence of the secret from process argv, and +scoped container cleanup. Acceptance independently requires the stored +administrator credential to be hashed. This password-API correction is product +loop five of six. + +The failed validation run `20260826t213718z-906461-9321` used source +`f2a9f6eeddf10af05398f8f83c4e36b148a96043`. Its source archive SHA-256 was +`5a360b8c3da000e5cba2ea9fcad6472fe7d3b6c6f647ab19518d977ec7424943`, +its staged input tree SHA-256 was +`7d84b1e0d7605f8bcd540cf5de222d24b39bb9c77c2ab4aeb969f8de2398f618`, +and its built tree SHA-256 was +`bf77408fa2de3b9b03f3a27eb1a291d4310609a3f0adf18dde0c498533e9d8ef`. +Build, import, supervised boot, source integrity, renderer, pypdf compatibility, +PostgreSQL ownership, and the non-plaintext example-administrator hash all +passed. The first proxied authentication request returned HTTP 500 before a +JSON-RPC result was available. The retained evidence did not include Odoo's +file log, Apache's request logs, or curl's discarded error response, so it could +not distinguish an Odoo failure from the proxy or readiness boundary. Cleanup, +builder identity, and disk checks passed; the retained manifest validated every +file. Its report SHA-256 was +`4da32708cec400bacfc327c6316438fd20ec09ff1f70df96815e919c525fee02` and +run-log SHA-256 was +`7718e99d3c831ccc94382d48596a1ceb5550d510baf58de6a66a245eba5f06d1`. + +Per the execution wish, that failed validation did not consume the remaining +product loop: the ledger remained five of six. The diagnostic fixture keeps the +proxied response headers and body, compares the same request directly with Odoo +on `127.0.0.1:8069`, and emits secret- and session-cookie-redacted copies plus +Odoo and Apache log tails on failure. This instrumentation does not retry the +initial request or change application behavior. + +The diagnostic candidate `fe9e7fd563737295225bc51e3e964851bbbc7b9a` +then ran as `20260826t224548z-1167796-3851`, after waiting 2,199,151 ms behind +GitLab and Canvas. The proxied and direct authentication requests both returned +HTTP 500, excluding Apache. Odoo's retained traceback identified the product +boundary before JSON-RPC dispatch: its worker resolved `config.session_dir` +below `/root/.local` and could not create it as user `odoo`. The firstboot hook +had restarted the SysV service directly from a root process, so that daemon +inherited root's home instead of the service account's home. The retained +manifest validated every evidence file; report SHA-256 was +`5339281f27422065ec503b15fef8c226f4645f2553a171bdab1468c89cbaa830` and +run-log SHA-256 was +`bef2b23e5afb642d4e22c3f42de539a888ea6e8e30946687fab6b60ee99d4bc3`. +Cleanup, builder identity, and disk checks passed. + +The final product correction sets `HOME=/var/lib/odoo` on the supervised Odoo +service and makes firstboot restart that unit through systemd. All service +starts therefore derive Odoo's XDG data and session paths below the `odoo` +account's writable home. Acceptance checks both the live main process +environment and the owner/mode of the session directory created by a real +login. This service-environment correction is product loop six of six. + +A disposable `debian:trixie-slim` systemd runtime then installed the exact +`odoo` `19.0.20260825` package after validating its pinned SHA-256, Trixie's +PostgreSQL 17, and the pinned `python3-pypdf2` compatibility provider. It +initialized `TurnkeylinuxExample`, set the administrator password through the +official Odoo ORM, and proved all of the final correction's boundaries: +`systemctl show` and `/proc//environ` both reported +`HOME=/var/lib/odoo`; the main process ran as `odoo`; direct JSON-RPC +authentication returned UID 2 before and after a supervised restart; and Odoo +created `/var/lib/odoo/.local/share/Odoo/sessions` as `odoo:odoo` mode `0700`. +`apt-get check` passed, and the probe's uniquely labelled containers and image +were removed after the run. + +The final exact run `20260826t235821z-1471535-357` used source +`42d8365bb7653d0f7d8c822a7f82a4cb1c1a2e1e` and pinned harness +`152bc9b876557266b90ed4ded77b611d9b817aff`. Its source commit and transport +archive SHA-256 was +`f5140cee6032ef649c6cd63635251b4f1d7927b3118660fcf3357a0455391eb2`; +the staged input tree SHA-256 was +`b4e6336379583c69ba7b8b313ac51fd22e139dfce1c30409c331e2cb4b8c370a`; +and the built tree SHA-256 was +`df796578f7afea8784d32ff63d5098821ce5f8370284db3715982df4b525bdd0`. +Build, configured-root import, supervised boot, and runtime health passed. The +acceptance checks before authentication also proved that the systemd drop-in +was loaded: both the unit environment and the live Odoo main process reported +`HOME=/var/lib/odoo`. The first proxied authentication request and the same +request sent directly to Odoo on `127.0.0.1:8069` then both returned HTTP 500, +excluding Apache from the failure boundary. + +The retained Odoo traceback shows that the HTTP worker nevertheless tried to +create its session directory below `/root/.local`. The residual cause is the +root-run `40odoo` inithook: importing and parsing Odoo's configuration derives +the default `data_dir` from the hook process's root home, and its unscoped +`config.save()` persists that root-derived value in `/etc/odoo/odoo.conf`. +After the hook restarts the correctly supervised unit, that explicit +configuration value overrides the effective service `HOME`. This is a product +failure in firstboot configuration serialization, not a systemd-drop-in, +Apache, or acceptance-fixture failure. + +The sealed retained evidence is under +`/home/agent/.local/state/turnkey-v19-harness-wave2-2/failures/odoo/current`. +Its validating `RETAINED-SHA256SUMS` manifest has SHA-256 +`4e155f9f6dbaf7b360a3f69dee4b65b1e0341e0deef22deb4b831b18f190b696`; +the retained report has SHA-256 +`ab307a552f53b0f7c3872d83dc64064efb77d47ca2169c8725b22958f2db19b2`; +and the retained run log has SHA-256 +`3a59883f7590196d97959be452197ed93b3242291cb679d954eb25f6e47c341a`. +The retained manifest validates every retained object. Harness cleanup, +builder-identity, and disk checks all exited zero, and the report records Docker +usage falling from 50,863,747,072 to 50,862,923,776 bytes. + +This failed final product correction consumes product loop six of six. The +Odoo v19 execution lifecycle is **BLOCKED** with the task left in progress; +changing the root inithook's configuration-save behavior or running another +product candidate requires an explicit wish amendment and a renewed loop +allowance. + +Review approval of this blocker dossier does **not** constitute `SHIP` for the +Odoo v19 migration and must not be counted as a shipped migration in aggregate +reporting. It confirms only that the blocker evidence and handoff are complete; +the migration itself remains **BLOCKED**. + +## Deferred issues + +- The Odoo repository publishes supported branch packages daily. Core updates + remain supervised so administrators can back up PostgreSQL first. +- The patched `wkhtmltopdf` project is archived upstream. Its exact package is + retained because Odoo 19 still requires the 0.12.6 series; future renderer + changes require explicit compatibility and security review. +- Public Postfix delivery and optional localization dependencies are not + exercised by local acceptance. +- Docker acceptance does not repeat installer, kernel, or hardware checks. + +## Epoch-two amendment + +The 2026-08-27 execution amendment preserves the complete epoch-one history +above, including its six-of-six product-loop ledger, and authorizes a separate +epoch-two ledger starting at zero of six. Before epoch-two source work, the +sealed epoch-one failure was copied atomically to +`/home/agent/.local/state/turnkey-v19-harness-wave2-2/archive/odoo/epoch1-20260826t235821z-1471535-357`. +The archive's `RETAINED-SHA256SUMS` SHA-256 is +`4e155f9f6dbaf7b360a3f69dee4b65b1e0341e0deef22deb4b831b18f190b696`, +its `report.txt` SHA-256 is +`ab307a552f53b0f7c3872d83dc64064efb77d47ca2169c8725b22958f2db19b2`, +and its `run.log` SHA-256 is +`3a59883f7590196d97959be452197ed93b3242291cb679d954eb25f6e47c341a`. +Every retained object validated against the archived manifest before the +correction. + +The epoch-one exact run reproduced one residual product defect: the root-run +firstboot hook parsed and saved Odoo configuration in root's XDG context, +persisting `/root/.local/share/Odoo` as `data_dir`. Epoch two binds the hook and +its Odoo subprocess to `HOME=/var/lib/odoo` and +`XDG_DATA_HOME=/var/lib/odoo/.local/share` before importing Odoo. After parsing +the existing configuration, it replaces `data_dir` with the exact +`/var/lib/odoo/.local/share/Odoo` path and saves only `admin_passwd` plus +`data_dir`, preserving unrelated file options. Firstboot reads the serialized +file back and fails before permission changes or service restart if the exact +data directory was not persisted. The existing supervised-service HOME, +`root:odoo` mode `0640` configuration, and `odoo:odoo` mode `0700` session +directory contracts remain unchanged. This reproduced product correction is +epoch-two product loop one of six. + +The focused firstboot fixture starts with root-derived HOME, XDG, and persisted +`data_dir` values. It proves that the hook fixes the process and subprocess +contexts, removes the persisted root path, preserves `proxy_mode`, scopes the +save, keeps the password out of argv, restarts the systemd unit only after a +successful persistence check, and fails closed when the root value remains. +Python compilation, Bash syntax, the firstboot fixture, the authentication +diagnostics fixture, the updater fixture, and `git diff --check` passed. +`shellcheck` was unavailable in the execution environment. + +A disposable Debian Trixie probe then installed the exact Odoo package +`19.0.20260825` after validating package SHA-256 +`e9d89da0fc94cd752b08b1e5501d97f464b834229ff8d68c7fecf24304e1da69` +and the same pypdf compatibility-provider boundary used by the appliance. The +modified production hook ran against Odoo's real `configmanager`; its imported +default and serialized `data_dir` were both exactly +`/var/lib/odoo/.local/share/Odoo`, the pre-existing `proxy_mode` survived, +Odoo verified the new database-management password, no `/root` value remained, +and `apt-get check` plus `dpkg --audit` passed. The first probe invocation used +a fresh Odoo singleton after leaving its module sandbox and therefore failed +only its final password assertion; keeping that assertion inside the same +module lifetime made the corrected probe pass without a product change. Both +uniquely labelled probe containers were removed. The probe-fixture correction +does not consume an epoch-two product loop. + +Epoch-two exact acceptance uses a separate state directory while retaining the +shared builder and serialization lock: + +```sh +TKLDEV_CONTAINER=tkldev19-wave2-2 \ +TKL_HARNESS_STATE_DIR=/home/agent/.local/state/turnkey-v19-harness-wave2-2-odoo-epoch2 \ +TKL_HARNESS_LOCK_FILE=/home/agent/.local/state/turnkey-v19-harness-wave2-2/build.lock \ +TKL_HARNESS_DOCKER_LIMIT_BYTES=137438953472 \ +TKL_HARNESS_DOCKER_OBJECT_LABEL=org.turnkeylinux.v19-harness.wave2-2.odoo-epoch2 \ +/home/agent/.local/worktrees/turnkey/harness-wave2-2-152bc9b/tools/test-v19-appliance odoo \ + --source /home/agent/.local/worktrees/turnkey-apps/odoo/wish-odoo-v19-trixie +``` + +The first epoch-two harness attempt +`20260827t054148z-2622542-27908` overlapped a separately launched Core +self-test that did not use the required shared lock. While Odoo's build process +was still active, that test's cleanup deleted Odoo's staged source directory. +The retained log records repeated missing-working-directory errors, absent +source-local release inputs, and cleanup's failure to enter the deleted staging +path. This attempt is classified **INFRA-ORCHESTRATION**, not a product result, +and consumes no product loop. Its evidence was copied atomically to +`/home/agent/.local/state/turnkey-v19-harness-wave2-2-odoo-epoch2/archive/odoo/infra-orchestration-20260827t054148z-2622542-27908`. +The retained-manifest SHA-256 is +`c1cb80f55a815a3ab06160f7412dd5b76f1506407d4b344c193a379e78aa5120`, +the report SHA-256 is +`5d656ac450322b01a9c2c2178652cbcf2d441e6d8bcca24cf2585af26c8e8b1d`, +and the run-log SHA-256 is +`a56eec4056b7a59273adcce3c451124be13eeb9fdbc829b8fd2cfd07a50e5ee5`. +Every retained object validates. The unchanged retry waited until a correctly +serialized Core validation passed and released the shared lock. + +The unchanged exact retry `20260827t055146z-2730655-29726` used source commit +`9bfc97e00328f3d78bfd1c33a2d5a5caa802dc0f` and pinned harness +`152bc9b876557266b90ed4ded77b611d9b817aff`. Its commit and transport archive +SHA-256 was +`a2019907e0df204ca2d0b112eb05de9f7164bb0bdcf5a32cad20aefe64ef6c6b`, +its input tree SHA-256 was +`1704aba6124f7dd7aaa8106fc039328b0dac7ae529996bdcf2174e748a312733`, +and its built tree SHA-256 was +`030b872932cef0fd9fbfd1b8c8b74a65df3220742c126106bb378a57ff9a34ae`. +Build, configured-root import, supervised boot, multi-user health, inithooks, +and Odoo service health all passed. The strict runtime acceptance returned +zero, proving the exact persisted `data_dir` with no `/root` residue, the +service HOME and session owner/mode, proxied administrator authentication as +UID 2, contact create/read plus PostgreSQL readback, persistence through +supervised PostgreSQL and Odoo restarts, two HTTP workers and a real cron +update, the least-privileged PostgreSQL role, the pinned PDF renderer and pypdf +provider, the database-management password, Adminer, Webmin modules, Postfix, +and the supervised update check. The report records Odoo +`19.0.20260825` as up to date and repeats all source-integrity identifiers. +Scoped runtime cleanup and disk checks passed with no retained container or +image. + +The PASS artifact manifest SHA-256 is +`3c514ee1bfd8fa6fd042c9aef5052ee568a1829bd4c57d28baabeb74b2433943`, +the PASS report SHA-256 is +`463df35bd34575d29b89c5818c6da26c74d7a2bc1b2047147efe4a9d1f8cafd4`, +and its run-log SHA-256 is +`72a1065d834b0a676d57519b4684991f24e5b81b40f994ae5b5e26c6a4338ac8`. +Every PASS artifact validates against its manifest. + +On successful authentication, the diagnostic helper returns after the Apache +request and reserves its separate direct request for failure diagnosis. A +supplemental disposable Trixie runtime therefore installed the same exact Odoo +package, initialized a real PostgreSQL database, and ran the production hook +with SHA-256 +`ae64f27d1f5e46c81ac87109bfeaa05ebf9e76113dd49211b3689555b02de856` +against a deliberately root-derived configuration. The hook persisted the +exact Odoo-owned path with `root:odoo` mode `0640`; a request sent directly to +`127.0.0.1:8069` authenticated as UID 2 and created the session directory as +`odoo:odoo` mode `0700`. Package and dependency audits passed, and the uniquely +labelled container was removed. This supplemental validation changed no +product source and consumes no product loop. Its evidence manifest SHA-256 is +`ac885b0152a41334b12a849ed8bbb2a742cb9b074940ceec12bab9aeafb3fa98`, +and its run-log SHA-256 is +`940a9867ee30b77e3a9f1e8cb7629cae4f21a8ac94615dabe16d65e0b22aad02`. + +The epoch-one archive was validated again after all epoch-two execution and +retains its original manifest, report, and run-log hashes. Epoch two finishes +with one reproduced product correction out of the six authorized loops; the +probe-fixture correction, orchestration-invalid attempt, unchanged exact +retry, and supplemental direct-login validation consume no product loops. diff --git a/overlay/etc/apache2/sites-available/odoo.conf b/overlay/etc/apache2/sites-available/odoo.conf index e1cbb55..a452435 100644 --- a/overlay/etc/apache2/sites-available/odoo.conf +++ b/overlay/etc/apache2/sites-available/odoo.conf @@ -19,10 +19,9 @@ ServerName localhost ProxyVia On - # Needed for real time message / chat feature (longpolling) - ProxyPass /longpolling/poll http://127.0.0.1:8072/longpolling/poll/ timeout=200 - ProxyPass /longpolling/poll/ http://127.0.0.1:8072/longpolling/poll/ timeout=200 - ProxyPassReverse /longpolling/poll/ http://127.0.0.1:8072/longpolling/poll/ + # Odoo's gevent worker serves websocket traffic separately from HTTP. + ProxyPass /websocket ws://127.0.0.1:8072/websocket retry=0 timeout=200 + ProxyPassReverse /websocket ws://127.0.0.1:8072/websocket ProxyPass / http://127.0.0.1:8069/ timeout=200 ProxyPassReverse / http://127.0.0.1:8069/ diff --git a/overlay/etc/systemd/system/odoo.service.d/turnkey.conf b/overlay/etc/systemd/system/odoo.service.d/turnkey.conf new file mode 100644 index 0000000..70696e0 --- /dev/null +++ b/overlay/etc/systemd/system/odoo.service.d/turnkey.conf @@ -0,0 +1,7 @@ +[Unit] +Requires=postgresql.service +After=postgresql.service + +[Service] +Environment=HOME=/var/lib/odoo +Environment=ODOO_NOTIFY_CRON_CHANGES=1 diff --git a/overlay/usr/lib/inithooks/bin/odoo.py b/overlay/usr/lib/inithooks/bin/odoo.py index 76cbf67..495e37c 100755 --- a/overlay/usr/lib/inithooks/bin/odoo.py +++ b/overlay/usr/lib/inithooks/bin/odoo.py @@ -5,19 +5,82 @@ --pass= unless provided, will ask interactively """ -import re -import sys -import getopt - -import crypt -import random -import hashlib import configparser - +import getopt +import os import subprocess +import sys + from libinithooks.dialog_wrapper import Dialog -from pgsqlconf import PostgreSQL -from passlib.context import CryptContext + +ODOO_DIST_PACKAGES = '/usr/lib/python3/dist-packages' +ODOO_CONFIG = '/etc/odoo/odoo.conf' +ODOO_HOME = '/var/lib/odoo' +ODOO_DATA_DIR = f'{ODOO_HOME}/.local/share/Odoo' +ODOO_XDG_DATA_HOME = f'{ODOO_HOME}/.local/share' +ODOO_PASSWORD_ENV = 'TURNKEY_ODOO_ADMIN_PASSWORD' + + +def set_example_admin_password(database, password): + """Set the example administrator password through Odoo's ORM.""" + environment = os.environ.copy() + environment[ODOO_PASSWORD_ENV] = password + script = f"""\ +import os +password = os.environ.pop({ODOO_PASSWORD_ENV!r}) +admin = env.ref('base.user_admin') +admin.write({{'password': password}}) +env.cr.commit() +""" + subprocess.run( + [ + '/usr/sbin/runuser', '-u', 'odoo', '--', '/usr/bin/odoo', + 'shell', f'--config={ODOO_CONFIG}', f'--database={database}', + '--no-http', + ], + input=script, + text=True, + env=environment, + check=True, + ) + + +def load_odoo_config(): + """Load Odoo without resolving this inithook as the ``odoo`` module.""" + script = os.path.realpath(__file__) + script_dir = os.path.dirname(script) + sys.path[:] = [ + path for path in sys.path + if os.path.realpath(path or os.curdir) != script_dir + ] + sys.path.insert(0, ODOO_DIST_PACKAGES) + + loaded = sys.modules.get('odoo') + if loaded and os.path.realpath(getattr(loaded, '__file__', '')) == script: + del sys.modules['odoo'] + + from odoo.tools import config + return config + + +def set_odoo_config_context(): + """Resolve Odoo's user-scoped defaults below its service account home.""" + os.environ['HOME'] = ODOO_HOME + os.environ['XDG_DATA_HOME'] = ODOO_XDG_DATA_HOME + + +def assert_persisted_data_dir(): + """Fail firstboot unless the serialized data directory is Odoo-owned.""" + persisted = configparser.RawConfigParser() + if not persisted.read(ODOO_CONFIG): + raise RuntimeError(f'cannot read Odoo configuration: {ODOO_CONFIG}') + + data_dir = persisted.get('options', 'data_dir', fallback=None) + if data_dir != ODOO_DATA_DIR: + raise RuntimeError( + f'unexpected Odoo data_dir in {ODOO_CONFIG}: {data_dir!r}' + ) + def usage(s=None): if s: @@ -26,6 +89,7 @@ def usage(s=None): print(__doc__, file=sys.stderr) sys.exit(1) + def main(): try: opts, args = getopt.gnu_getopt(sys.argv[1:], "h", @@ -33,45 +97,43 @@ def main(): except getopt.GetoptError as e: usage(e) - password = "" + if args: + usage("unexpected positional arguments") + + password = os.environ.pop("APP_PASS", "") for opt, val in opts: if opt in ('-h', '--help'): usage() elif opt == '--pass': password = val - if not password: d = Dialog('TurnKey Linux - First boot configuration') password = d.get_password( - "Odoo Database Managment & example 'admin' Password", + "Odoo Database Management & example 'admin' Password", "Enter new password for Odoo Database Management - create/delete/manage Odoo DBs. " "This password will also login to 'admin' account of default/example Odoo.", blacklist=['\\', '/']) - processed_password = CryptContext(['pbkdf2_sha512']).hash(password) + # The hook runs as root, but Odoo must derive and persist user-scoped paths + # from the same home as its supervised service and subprocesses. + set_odoo_config_context() + config = load_odoo_config() default_db = 'TurnkeylinuxExample' - default_db_exists = True - try: - p = PostgreSQL(default_db) - p.execute("UPDATE res_users SET password='{}' WHERE id=2".format( - processed_password).encode('utf8')) - except subprocess.CalledProcessError as e: - default_db_exists = False - print(f"Default DB ({default_db}) not found - skipping setting passsword for that") - - sys.path.insert(0, '/usr/lib/python3/dist-packages') - import odoo - odoo.tools.config.parse_config(['--config=/etc/odoo/odoo.conf']) - odoo.tools.config.set_admin_password(password) - odoo.tools.config.save() + set_example_admin_password(default_db, password) + + config.parse_config([f'--config={ODOO_CONFIG}']) + config['data_dir'] = ODOO_DATA_DIR + config.set_admin_password(password) + config.save(['admin_passwd', 'data_dir']) + assert_persisted_data_dir() + subprocess.run(['chown', 'root:odoo', ODOO_CONFIG], check=True) + subprocess.run(['chmod', '0640', ODOO_CONFIG], check=True) # restart odoo to apply updated password - subprocess.run(['systemctl', 'restart', 'odoo']) + subprocess.run(['systemctl', 'restart', 'odoo.service'], check=True) - if not default_db_exists: - sys.exit(1) if __name__ == "__main__": main() diff --git a/overlay/usr/lib/inithooks/firstboot.d/20-odoo-db-secrets b/overlay/usr/lib/inithooks/firstboot.d/20-odoo-db-secrets index 3a3b039..5d875ec 100755 --- a/overlay/usr/lib/inithooks/firstboot.d/20-odoo-db-secrets +++ b/overlay/usr/lib/inithooks/firstboot.d/20-odoo-db-secrets @@ -1,6 +1,9 @@ #!/bin/bash -e # regenerate odoo pgsql password +set +x +umask 077 + . /etc/default/inithooks CONF=/etc/odoo/odoo.conf @@ -8,17 +11,17 @@ DB_USER=odoo # Create new password DB_PASS=$(mcookie) +[[ $DB_PASS =~ ^[[:xdigit:]]+$ ]] # Set new password in config file sed -i "s|db_password =.*|db_password = $DB_PASS|" $CONF -# Update the pastgres user password -$INITHOOKS_PATH/bin/pgsqlconf.py --user=$DB_USER --pass="$DB_PASS" - -# Since we reset the password, reload Odoo -# Don't use systemd/systemctl right now as lxc/ovz does not -# use systemd -service odoo restart +# Update the PostgreSQL role without exposing the generated password in argv. +runuser -u postgres -- psql --no-psqlrc --set ON_ERROR_STOP=on postgres <&2 + exit 2 +fi + +source_record=/usr/local/share/turnkey-odoo/source +keyring=/usr/share/keyrings/odoo-archive-keyring.gpg + +# shellcheck disable=SC1090 +. "$source_record" +: "${installed_version:?installed_version is missing from $source_record}" +: "${repository_key_fingerprint:?repository_key_fingerprint is missing from $source_record}" +: "${wkhtmltox_version:?wkhtmltox_version is missing from $source_record}" +: "${wkhtmltox_architecture:?wkhtmltox_architecture is missing from $source_record}" +: "${wkhtmltox_sha256:?wkhtmltox_sha256 is missing from $source_record}" +: "${pypdf_compat_package:?pypdf_compat_package is missing from $source_record}" +: "${pypdf_compat_version:?pypdf_compat_version is missing from $source_record}" + +installed=$(dpkg-query -W -f='${Version}' odoo) +renderer=$(dpkg-query -W -f='${Version}' wkhtmltox) +renderer_architecture=$(dpkg-query -W -f='${Architecture}' wkhtmltox) +compat_version=$(dpkg-query -W -f='${Version}' "$pypdf_compat_package") +compat_provides=$(dpkg-query -W -f='${Provides}' "$pypdf_compat_package") +pypdf_version=$(dpkg-query -W -f='${Version}' python3-pypdf) +policy=$(apt-cache policy odoo) +candidate=$(awk ' + /^[[:space:]]*Candidate:/ && !candidate { candidate=$2 } + END { print candidate } +' <<<"$policy") +fingerprint=$(gpg --show-keys --with-colons "$keyring" | + awk -F: '$1 == "fpr" && !fingerprint { fingerprint=$10 } END { print fingerprint }') + +test "$installed" = "$installed_version" +test "$renderer" = "$wkhtmltox_version" +test "$renderer_architecture" = "$wkhtmltox_architecture" +test "$compat_version" = "$pypdf_compat_version" +grep -Eq '(^|, )[[:space:]]*python3-pypdf2([[:space:]]|$)' <<<"$compat_provides" +test -n "$pypdf_version" +test "$fingerprint" = "$repository_key_fingerprint" +test -n "$candidate" +test "$candidate" != "(none)" +dpkg --compare-versions "$candidate" ge "$installed" +grep -Fq 'https://nightly.odoo.com/19.0/nightly/deb/ ./' \ + /etc/apt/sources.list.d/odoo.list +DEBIAN_FRONTEND=noninteractive apt-get --simulate --quiet=2 \ + install "odoo=$candidate" >/dev/null + +if dpkg --compare-versions "$candidate" eq "$installed"; then + status=up-to-date +else + status=supervised-update-available +fi + +cat < #include -odoo-16 xfonts-75dpi -wkhtmltopdf python3-psycogreen adduser +jq /* JSON-RPC acceptance and administrative diagnostics */ postgresql-client python3 diff --git a/tests/odoo-auth-diagnostics-fixture.sh b/tests/odoo-auth-diagnostics-fixture.sh new file mode 100755 index 0000000..9eb1943 --- /dev/null +++ b/tests/odoo-auth-diagnostics-fixture.sh @@ -0,0 +1,128 @@ +#!/bin/bash +set -Eeuo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd -P) +fixture_root=$(mktemp -d /tmp/odoo-auth-diagnostics.XXXXXXXX) +trap 'find "$fixture_root" -depth -delete' EXIT +mkdir -p "$fixture_root/bin" "$fixture_root/work" + +app_password='fixture password with spaces and $pecial characters' +database=TurnkeylinuxExample +work=$fixture_root/work +cookie=$work/cookie +auth_proxy_url=https://proxy.invalid/web/session/authenticate +auth_direct_url=http://127.0.0.1:8069/web/session/authenticate +auth_log_files=( + "$fixture_root/odoo-server.log" + "$fixture_root/apache-access.log" + "$fixture_root/apache-error.log" +) + +cat >"$fixture_root/bin/curl" <<'EOF' +#!/bin/bash +set -Eeuo pipefail + +headers= +body= +stderr= +payload= +url= +while (($#)); do + case $1 in + --dump-header) + headers=$2 + shift 2 + ;; + --output) + body=$2 + shift 2 + ;; + --stderr) + stderr=$2 + shift 2 + ;; + --data) + payload=$2 + shift 2 + ;; + --cookie|--cookie-jar|--header|--max-time|--write-out) + shift 2 + ;; + --insecure|--silent|--show-error) + shift + ;; + *) + url=$1 + shift + ;; + esac +done + +printf '%s\t%s\n' "$url" "$payload" >>"$FIXTURE_CALLS" +: >"$stderr" +if [[ $url == https://* ]]; then + printf 'HTTP/1.1 %s Fixture\r\nSet-Cookie: session_id=fixture-session-secret; HttpOnly\r\n\r\n' \ + "${FIXTURE_PROXY_STATUS:-500}" >"$headers" + if [[ -v FIXTURE_PROXY_BODY ]]; then + printf '%s' "$FIXTURE_PROXY_BODY" >"$body" + else + printf '{"error":{"data":{"debug":"password=%s"}}}' \ + "$FIXTURE_SECRET" >"$body" + fi + printf '%s' "${FIXTURE_PROXY_STATUS:-500}" + exit "${FIXTURE_PROXY_CURL_STATUS:-0}" +fi + +printf 'HTTP/1.1 %s Fixture\r\nSet-Cookie: session_id=direct-session-secret; HttpOnly\r\n\r\n' \ + "${FIXTURE_DIRECT_STATUS:-200}" >"$headers" +if [[ -v FIXTURE_DIRECT_BODY ]]; then + printf '%s' "$FIXTURE_DIRECT_BODY" >"$body" +else + printf '%s' '{"result":{"uid":2}}' >"$body" +fi +printf '%s' "${FIXTURE_DIRECT_STATUS:-200}" +exit "${FIXTURE_DIRECT_CURL_STATUS:-0}" +EOF +chmod 0755 "$fixture_root/bin/curl" + +printf 'Odoo traceback containing %s\n' "$app_password" >"${auth_log_files[0]}" +printf 'proxy access fixture\n' >"${auth_log_files[1]}" +printf 'proxy error fixture\n' >"${auth_log_files[2]}" + +# shellcheck source=odoo-auth-diagnostics.sh +. "$repo_root/tests/odoo-auth-diagnostics.sh" + +export FIXTURE_CALLS=$fixture_root/calls +export FIXTURE_SECRET=$app_password +export PATH="$fixture_root/bin:/usr/bin:/bin" + +if authenticate diagnose >"$fixture_root/failure.out" 2>&1; then + echo 'proxy HTTP 500 unexpectedly authenticated' >&2 + exit 1 +fi +grep -Fxq 'auth_probe=proxy curl_status=0 http_status=500' "$fixture_root/failure.out" +grep -Fxq 'auth_probe=direct curl_status=0 http_status=200' "$fixture_root/failure.out" +grep -Fq -- '--- proxy response headers ---' "$fixture_root/failure.out" +grep -Fq -- '--- direct response body ---' "$fixture_root/failure.out" +grep -Fq -- '--- log tail:' "$fixture_root/failure.out" +grep -Fq '"result":{"uid":2}' "$fixture_root/failure.out" +grep -Fq 'Set-Cookie: [REDACTED]; HttpOnly' "$fixture_root/failure.out" +grep -Fq 'Odoo traceback containing [REDACTED]' "$fixture_root/failure.out" +! grep -Fq "$app_password" "$fixture_root/failure.out" +! grep -Fq 'fixture-session-secret' "$fixture_root/failure.out" +grep -Fq "$app_password" "$work/auth-proxy.body" +grep -Fq 'fixture-session-secret' "$work/auth-proxy.headers" +test "$(wc -l <"$fixture_root/calls")" -eq 2 +test "$(cut -f2 "$fixture_root/calls" | sort -u | wc -l)" -eq 1 + +rm -f "$fixture_root/calls" +rm -f "$work"/auth-* "$work"/cookie "$work"/direct-cookie +FIXTURE_PROXY_STATUS=200 \ +FIXTURE_PROXY_BODY='{"result":{"uid":2}}' \ + authenticate diagnose >"$fixture_root/success.out" 2>&1 +test "$(wc -l <"$fixture_root/calls")" -eq 1 +grep -Fq 'https://proxy.invalid/web/session/authenticate' "$fixture_root/calls" +! grep -Fq '127.0.0.1:8069' "$fixture_root/calls" +test ! -s "$fixture_root/success.out" + +echo 'odoo auth diagnostics fixture: PASS' diff --git a/tests/odoo-auth-diagnostics.sh b/tests/odoo-auth-diagnostics.sh new file mode 100755 index 0000000..b8783bc --- /dev/null +++ b/tests/odoo-auth-diagnostics.sh @@ -0,0 +1,120 @@ +#!/bin/bash + +# Diagnostic support for the Odoo authentication acceptance boundary. The +# caller owns `work`, `cookie`, `database`, and `app_password`. + +auth_proxy_url=${auth_proxy_url:-https://127.0.0.1/web/session/authenticate} +auth_direct_url=${auth_direct_url:-http://127.0.0.1:8069/web/session/authenticate} +if ! declare -p auth_log_files >/dev/null 2>&1; then + auth_log_files=( + /var/log/odoo/odoo-server.log + /var/log/apache2/access.log + /var/log/apache2/error.log + ) +fi + +sanitize_auth_diagnostic() { + APP_SECRET=$app_password perl -pe ' + BEGIN { $secret = $ENV{"APP_SECRET"} // ""; } + s/\Q$secret\E/[REDACTED]/g if length $secret; + s/^(Set-Cookie:\s*)[^;]*/${1}[REDACTED]/i; + s/(session_id=)[^;\s"]+/${1}[REDACTED]/ig; + ' +} + +capture_auth_response() { + local name=$1 + local url=$2 + local payload=$3 + local -n http_status_ref=$4 + local -n curl_status_ref=$5 + local response_cookie=$cookie + local prefix=$work/auth-$name + + if [[ $name == direct ]]; then + response_cookie=$work/direct-cookie + fi + + if http_status_ref=$(curl --insecure --silent --show-error \ + --max-time 30 \ + --cookie "$response_cookie" --cookie-jar "$response_cookie" \ + --header 'Content-Type: application/json' \ + --dump-header "$prefix.headers" \ + --output "$prefix.body" \ + --stderr "$prefix.curl-error" \ + --write-out '%{http_code}' \ + --data "$payload" "$url"); then + curl_status_ref=0 + else + curl_status_ref=$? + fi +} + +print_auth_file() { + local label=$1 + local file=$2 + + printf '%s\n' "--- $label ---" + if [[ -r $file ]]; then + sanitize_auth_diagnostic <"$file" + else + printf 'unavailable: %s\n' "$file" + fi +} + +print_auth_response() { + local name=$1 + local curl_status=$2 + local http_status=$3 + local prefix=$work/auth-$name + + printf 'auth_probe=%s curl_status=%s http_status=%s\n' \ + "$name" "$curl_status" "${http_status:-absent}" + print_auth_file "$name response headers" "$prefix.headers" + print_auth_file "$name response body" "$prefix.body" + print_auth_file "$name curl stderr" "$prefix.curl-error" +} + +print_auth_logs() { + local logfile + + for logfile in "${auth_log_files[@]}"; do + printf '%s\n' "--- log tail: $logfile ---" + if [[ -r $logfile ]]; then + if ! tail -n 240 "$logfile" 2>&1 | sanitize_auth_diagnostic; then + printf 'unable to read complete log tail: %s\n' "$logfile" + fi + else + printf 'unavailable: %s\n' "$logfile" + fi + done +} + +authenticate() { + local diagnostics=${1:-diagnose} + local payload proxy_http_status= proxy_curl_status= + local direct_http_status= direct_curl_status= + + payload=$(jq -cn \ + --arg db "$database" \ + --arg login admin \ + --arg password "$app_password" \ + '{jsonrpc:"2.0", method:"call", params:{db:$db, login:$login, password:$password}}') + capture_auth_response proxy "$auth_proxy_url" "$payload" \ + proxy_http_status proxy_curl_status + + if (( proxy_curl_status == 0 )) && [[ $proxy_http_status == 200 ]] && + jq -e '.result.uid == 2 and (.error | not)' \ + "$work/auth-proxy.body" >/dev/null; then + return 0 + fi + + if [[ $diagnostics == diagnose ]]; then + print_auth_response proxy "$proxy_curl_status" "$proxy_http_status" + capture_auth_response direct "$auth_direct_url" "$payload" \ + direct_http_status direct_curl_status + print_auth_response direct "$direct_curl_status" "$direct_http_status" + print_auth_logs + fi + return 1 +} diff --git a/tests/odoo-firstboot-config-fixture.py b/tests/odoo-firstboot-config-fixture.py new file mode 100755 index 0000000..9c8774f --- /dev/null +++ b/tests/odoo-firstboot-config-fixture.py @@ -0,0 +1,150 @@ +#!/usr/bin/python3 + +"""Exercise the root-run firstboot hook's Odoo configuration boundary.""" + +import configparser +import importlib.util +import os +from pathlib import Path +import sys +import tempfile +import types +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parent.parent +HOOK_PATH = REPO_ROOT / 'overlay/usr/lib/inithooks/bin/odoo.py' +EXPECTED_HOME = '/var/lib/odoo' +EXPECTED_DATA_DIR = f'{EXPECTED_HOME}/.local/share/Odoo' +PASSWORD = 'fixture password with spaces and $pecial characters' + + +class FixtureConfig: + """Minimal configmanager contract with real config-file persistence.""" + + def __init__(self): + self.options = {'data_dir': '/root/.local/share/Odoo'} + self.config_path = None + self.save_keys = None + + def parse_config(self, arguments): + assert os.environ['HOME'] == EXPECTED_HOME + assert os.environ['XDG_DATA_HOME'] == f'{EXPECTED_HOME}/.local/share' + assert len(arguments) == 1 + self.config_path = arguments[0].removeprefix('--config=') + + persisted = configparser.RawConfigParser() + assert persisted.read(self.config_path) + self.options['data_dir'] = persisted.get('options', 'data_dir') + + def __setitem__(self, key, value): + self.options[key] = value + + def set_admin_password(self, password): + assert password == PASSWORD + self.options['admin_passwd'] = 'fixture-hash' + + def save(self, keys=None): + self.save_keys = keys + persisted = configparser.RawConfigParser() + assert persisted.read(self.config_path) + for key in keys: + persisted.set('options', key, self.options[key]) + with open(self.config_path, 'w', encoding='utf-8') as config_file: + persisted.write(config_file) + + +def load_hook(fixture_config): + dialog_wrapper = types.ModuleType('libinithooks.dialog_wrapper') + dialog_wrapper.Dialog = object + libinithooks = types.ModuleType('libinithooks') + libinithooks.dialog_wrapper = dialog_wrapper + odoo = types.ModuleType('odoo') + odoo.__file__ = '/usr/lib/python3/dist-packages/odoo/__init__.py' + odoo.__path__ = [] + tools = types.ModuleType('odoo.tools') + tools.config = fixture_config + odoo.tools = tools + + modules = { + 'libinithooks': libinithooks, + 'libinithooks.dialog_wrapper': dialog_wrapper, + 'odoo': odoo, + 'odoo.tools': tools, + } + with mock.patch.dict(sys.modules, modules): + spec = importlib.util.spec_from_file_location( + 'turnkey_odoo_inithook', HOOK_PATH + ) + hook = importlib.util.module_from_spec(spec) + spec.loader.exec_module(hook) + return hook, modules + + +def main(): + fixture_config = FixtureConfig() + hook, modules = load_hook(fixture_config) + calls = [] + + def record_run(arguments, **kwargs): + calls.append((arguments, kwargs)) + if arguments[0] == '/usr/sbin/runuser': + assert kwargs['env']['HOME'] == EXPECTED_HOME + assert kwargs['env']['XDG_DATA_HOME'] == \ + f'{EXPECTED_HOME}/.local/share' + assert PASSWORD not in arguments + return types.SimpleNamespace(returncode=0) + + with tempfile.TemporaryDirectory(prefix='odoo-firstboot-config.') as work: + config_path = Path(work) / 'odoo.conf' + config_path.write_text( + '[options]\n' + 'data_dir = /root/.local/share/Odoo\n' + 'proxy_mode = True\n', + encoding='utf-8', + ) + hook.ODOO_CONFIG = str(config_path) + + environment = { + 'APP_PASS': PASSWORD, + 'HOME': '/root', + 'XDG_DATA_HOME': '/root/.local/share', + } + with ( + mock.patch.dict(os.environ, environment, clear=True), + mock.patch.dict(sys.modules, modules), + mock.patch.object(sys, 'argv', [str(HOOK_PATH)]), + mock.patch.object(hook.subprocess, 'run', side_effect=record_run), + ): + hook.main() + assert os.environ['HOME'] == EXPECTED_HOME + assert os.environ['XDG_DATA_HOME'] == f'{EXPECTED_HOME}/.local/share' + + persisted = configparser.RawConfigParser() + assert persisted.read(config_path) + assert persisted.get('options', 'data_dir') == EXPECTED_DATA_DIR + assert persisted.getboolean('options', 'proxy_mode') is True + assert '/root' not in config_path.read_text(encoding='utf-8') + assert fixture_config.save_keys == ['admin_passwd', 'data_dir'] + + assert calls[0][0][:4] == [ + '/usr/sbin/runuser', '-u', 'odoo', '--' + ] + assert calls[-1][0] == ['systemctl', 'restart', 'odoo.service'] + + config_path.write_text( + '[options]\ndata_dir = /root/.local/share/Odoo\n', + encoding='utf-8', + ) + try: + hook.assert_persisted_data_dir() + except RuntimeError as error: + assert 'unexpected Odoo data_dir' in str(error) + else: + raise AssertionError('root-derived data_dir did not fail closed') + + print('odoo firstboot config fixture: PASS') + + +if __name__ == '__main__': + main() diff --git a/tests/odoo-update-fixture.sh b/tests/odoo-update-fixture.sh new file mode 100755 index 0000000..f575d18 --- /dev/null +++ b/tests/odoo-update-fixture.sh @@ -0,0 +1,130 @@ +#!/bin/bash +set -Eeuo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd -P) +work=$(mktemp -d /tmp/odoo-update-fixture.XXXXXXXX) +trap 'find "$work" -depth -delete' EXIT +mkdir -p "$work/bin" + +cat >"$work/source" <<'EOF' +installed_version=19.0.20260825 +repository_key_fingerprint=5D134C924CB06330DCEFE2A1DEF2A2198183CBB5 +wkhtmltox_version=1:0.12.6.1-3.bookworm +wkhtmltox_architecture=amd64 +wkhtmltox_sha256=98ba0d157b50d36f23bd0dedf4c0aa28c7b0c50fcdcdc54aa5b6bbba81a3941d +pypdf_compat_package=turnkey-odoo-pypdf-compat +pypdf_compat_version=1.0+turnkey19.0.1 +EOF +cat >"$work/odoo.list" <<'EOF' +deb [signed-by=/usr/share/keyrings/odoo-archive-keyring.gpg] https://nightly.odoo.com/19.0/nightly/deb/ ./ +EOF +touch "$work/keyring" + +cat >"$work/bin/dpkg-query" <<'EOF' +#!/bin/bash +case $* in + *wkhtmltox*) + if [[ $* == *Architecture* ]]; then + printf '%s' "${FIXTURE_RENDERER_ARCHITECTURE:-amd64}" + else + printf '%s' "${FIXTURE_RENDERER:-1:0.12.6.1-3.bookworm}" + fi + ;; + *turnkey-odoo-pypdf-compat*) + if [[ $* == *Provides* ]]; then + printf '%s' "${FIXTURE_COMPAT_PROVIDES:-python3-pypdf2}" + else + printf '%s' "${FIXTURE_COMPAT_VERSION:-1.0+turnkey19.0.1}" + fi + ;; + *python3-pypdf*) + printf '%s' "${FIXTURE_PYPDF_VERSION-5.4.0-1}" + ;; + *) + printf '%s' "${FIXTURE_INSTALLED:-19.0.20260825}" + ;; +esac +EOF +cat >"$work/bin/apt-cache" <<'EOF' +#!/bin/bash +printf 'odoo:\n Installed: %s\n Candidate: %s\n' \ + "${FIXTURE_INSTALLED:-19.0.20260825}" \ + "${FIXTURE_CANDIDATE-19.0.20260825}" +awk -v lines="${FIXTURE_TRAILER_LINES:-0}" \ + 'BEGIN { for (i = 0; i < lines; i++) print " fixture-source " i }' +EOF +cat >"$work/bin/gpg" <<'EOF' +#!/bin/bash +printf 'fpr:::::::::%s:\n' \ + "${FIXTURE_FINGERPRINT:-5D134C924CB06330DCEFE2A1DEF2A2198183CBB5}" +printf 'fpr:::::::::ABA924A9766870116E97090D78958C39ADE51428:\n' +EOF +cat >"$work/bin/dpkg" <<'EOF' +#!/bin/bash +exec /usr/bin/dpkg "$@" +EOF +cat >"$work/bin/apt-get" <<'EOF' +#!/bin/bash +if [[ ${FIXTURE_RESOLUTION_FAILURE:-0} == 1 ]]; then + exit 100 +fi +[[ $* == *--simulate* ]] +[[ $* == *odoo=* ]] +EOF +chmod 0755 "$work/bin/"* + +sed \ + -e "s|^source_record=.*|source_record=$work/source|" \ + -e "s|^keyring=.*|keyring=$work/keyring|" \ + -e "s| /etc/apt/sources.list.d/odoo.list| $work/odoo.list|" \ + "$repo_root/overlay/usr/local/sbin/odoo-update" >"$work/odoo-update" +chmod 0755 "$work/odoo-update" + +run_check() { + env PATH="$work/bin:/usr/bin:/bin" "$@" "$work/odoo-update" --check +} + +expect_failure() { + local name=$1 + shift + + if run_check "$@" >"$work/$name.out" 2>"$work/$name.err"; then + echo "$name unexpectedly passed" >&2 + exit 1 + fi +} + +if env PATH="$work/bin:/usr/bin:/bin" "$work/odoo-update" \ + >"$work/usage.out" 2>"$work/usage.err"; then + echo 'missing argument unexpectedly passed' >&2 + exit 1 +else + test $? -eq 2 +fi + +run_check env FIXTURE_TRAILER_LINES=100000 >"$work/current.out" +grep -Fxq 'status=up-to-date' "$work/current.out" +grep -Fxq 'renderer=wkhtmltox-1:0.12.6.1-3.bookworm' "$work/current.out" +grep -Fxq 'renderer_architecture=amd64' "$work/current.out" +grep -Fxq 'renderer_policy=pinned-manual-security-review' "$work/current.out" +grep -Fxq 'dependency_bridge=turnkey-odoo-pypdf-compat-1.0+turnkey19.0.1' \ + "$work/current.out" +grep -Fxq 'dependency_bridge_provides=python3-pypdf2' "$work/current.out" +grep -Fxq 'pypdf=python3-pypdf-5.4.0-1' "$work/current.out" +grep -Fxq 'candidate_resolution=apt-simulated' "$work/current.out" +run_check env FIXTURE_CANDIDATE=19.0.20260826 >"$work/newer.out" +grep -Fxq 'status=supervised-update-available' "$work/newer.out" + +expect_failure missing-candidate env FIXTURE_CANDIDATE= +expect_failure no-candidate env FIXTURE_CANDIDATE='(none)' +expect_failure downgrade env FIXTURE_CANDIDATE=19.0.20260824 +expect_failure wrong-key env FIXTURE_FINGERPRINT=0000000000000000000000000000000000000000 +expect_failure wrong-install env FIXTURE_INSTALLED=19.0.20260824 +expect_failure wrong-renderer env FIXTURE_RENDERER=1:0.12.6.1-2.bookworm +expect_failure wrong-renderer-architecture env FIXTURE_RENDERER_ARCHITECTURE=arm64 +expect_failure wrong-compat env FIXTURE_COMPAT_VERSION=1.0+turnkey19.0.0 +expect_failure wrong-provides env FIXTURE_COMPAT_PROVIDES=python3-pypdf +expect_failure missing-pypdf env FIXTURE_PYPDF_VERSION= +expect_failure unresolved-candidate env FIXTURE_RESOLUTION_FAILURE=1 + +echo 'odoo updater fixture: PASS' diff --git a/tests/v19.sh b/tests/v19.sh new file mode 100755 index 0000000..86a22ea --- /dev/null +++ b/tests/v19.sh @@ -0,0 +1,291 @@ +#!/bin/bash +set -Eeuo pipefail +umask 077 + +trap 'status=$?; printf "odoo acceptance failed: line=%s status=%s command=%q\n" "$LINENO" "$status" "$BASH_COMMAND" >&2; exit "$status"' ERR + +result=${TKL_TEST_RESULT:?TKL_TEST_RESULT is required} +app_password=${TKL_TEST_APP_PASS:?TKL_TEST_APP_PASS is required} +source_file=/usr/local/share/turnkey-odoo/source +database=TurnkeylinuxExample +fixture="TurnKey v19 contact $(date +%s)-$$" +email="odoo-v19-$$@example.invalid" +cron_marker="TKL-v19-cron-$(date +%s)-$$" +work=$(mktemp -d /tmp/odoo-v19.XXXXXXXX) +cookie=$work/cookie +partner_id= +cron_id= + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +# shellcheck source=odoo-auth-diagnostics.sh +. "$script_dir/odoo-auth-diagnostics.sh" + +command -v jq >/dev/null + +ocurl() { + curl --insecure --fail --silent --show-error \ + --cookie "$cookie" --cookie-jar "$cookie" \ + --header 'Content-Type: application/json' "$@" +} + +rpc_result() { + jq -er ' + if has("error") then + error(.error.data.message // .error.message // "Odoo RPC failed") + else + .result + end + ' +} + +ocall() { + local model=$1 + local method=$2 + local args=$3 + local kwargs=${4:-'{}'} + local payload + + payload=$(jq -cn \ + --arg model "$model" \ + --arg method "$method" \ + --argjson args "$args" \ + --argjson kwargs "$kwargs" \ + '{jsonrpc:"2.0", method:"call", params:{model:$model, method:$method, args:$args, kwargs:$kwargs}}') + ocurl --data "$payload" \ + "https://127.0.0.1/web/dataset/call_kw/$model/$method" +} + +cleanup() { + trap - ERR + set +e + if [[ -n $cron_id ]]; then + ocall ir.cron unlink "$(jq -cn --argjson id "$cron_id" '[[$id]]')" | + rpc_result >/dev/null + fi + if [[ -n $partner_id ]]; then + ocall res.partner unlink "$(jq -cn --argjson id "$partner_id" '[[$id]]')" | + rpc_result >/dev/null + fi + find "$work" -depth -delete +} +trap cleanup EXIT + +for unit in apache2.service postgresql.service odoo.service postfix.service; do + if ! systemctl --quiet is-active "$unit"; then + echo "$unit is not active" >&2 + systemctl --no-pager --full status "$unit" >&2 || true + exit 1 + fi + if ! systemctl --quiet is-enabled "$unit"; then + echo "$unit is not enabled" >&2 + systemctl --no-pager --full status "$unit" >&2 || true + exit 1 + fi +done +apache2ctl configtest +apache_modules=$(apache2ctl -M) +grep -Fq 'proxy_module' <<<"$apache_modules" +grep -Fq 'proxy_http_module' <<<"$apache_modules" +grep -Fq 'proxy_wstunnel_module' <<<"$apache_modules" +grep -Fxq 'VERSION_CODENAME=trixie' /etc/os-release +grep -Eq '^turnkey-odoo-19\.0' /etc/turnkey_version +test "$(stat -c '%U:%G:%a' /etc/odoo/odoo.conf)" = root:odoo:640 +test "$(stat -c '%U:%G:%a' "$source_file")" = root:root:644 +test -d /usr/share/adminer +for module in apache postgresql postfix; do + test -d "/usr/share/webmin/$module" +done + +runuser -u odoo -- python3 <<'PY' +from odoo.tools import config + +config.parse_config(['--config=/etc/odoo/odoo.conf']) +assert config['proxy_mode'] is True +assert config['workers'] == 2 +assert config['max_cron_threads'] == 1 +assert config['gevent_port'] == 8072 +assert config['data_dir'] == '/var/lib/odoo/.local/share/Odoo' +PY +! grep -Fq '/root' /etc/odoo/odoo.conf + +service_environment=$(systemctl show odoo.service --property=Environment --value) +[[ $service_environment == *ODOO_NOTIFY_CRON_CHANGES=1* ]] +[[ $service_environment == *HOME=/var/lib/odoo* ]] +service_main_pid=$(systemctl show odoo.service --property=MainPID --value) +[[ $service_main_pid =~ ^[1-9][0-9]*$ ]] +tr '\0' '\n' <"/proc/$service_main_pid/environ" | + grep -Fxq 'HOME=/var/lib/odoo' + +# shellcheck disable=SC1090 +. "$source_file" +: "${package_source:?package_source is missing from $source_file}" +: "${installed_version:?installed_version is missing from $source_file}" +: "${package_url:?package_url is missing from $source_file}" +: "${package_sha256:?package_sha256 is missing from $source_file}" +: "${repository_key_fingerprint:?repository_key_fingerprint is missing from $source_file}" +: "${wkhtmltox_version:?wkhtmltox_version is missing from $source_file}" +: "${wkhtmltox_architecture:?wkhtmltox_architecture is missing from $source_file}" +: "${wkhtmltox_url:?wkhtmltox_url is missing from $source_file}" +: "${wkhtmltox_sha256:?wkhtmltox_sha256 is missing from $source_file}" +: "${pypdf_compat_package:?pypdf_compat_package is missing from $source_file}" +: "${pypdf_compat_version:?pypdf_compat_version is missing from $source_file}" +test "$installed_version" = 19.0.20260825 +test "$(dpkg-query -W -f='${Version}' odoo)" = "$installed_version" +test "$package_sha256" = e9d89da0fc94cd752b08b1e5501d97f464b834229ff8d68c7fecf24304e1da69 +test "$(gpg --show-keys --with-colons /usr/share/keyrings/odoo-archive-keyring.gpg | + awk -F: '$1 == "fpr" && !fingerprint { fingerprint=$10 } END { print fingerprint }')" = \ + "$repository_key_fingerprint" +grep -Fq 'signed-by=/usr/share/keyrings/odoo-archive-keyring.gpg' \ + /etc/apt/sources.list.d/odoo.list +odoo --version | grep -Fq '19.0' +test "$wkhtmltox_version" = '1:0.12.6.1-3.bookworm' +test "$wkhtmltox_architecture" = amd64 +test "$wkhtmltox_url" = https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb +test "$(dpkg-query -W -f='${Version}' wkhtmltox)" = "$wkhtmltox_version" +test "$(dpkg-query -W -f='${Architecture}' wkhtmltox)" = "$wkhtmltox_architecture" +test "$wkhtmltox_sha256" = 98ba0d157b50d36f23bd0dedf4c0aa28c7b0c50fcdcdc54aa5b6bbba81a3941d +wkhtmltopdf --version | grep -Fq 'wkhtmltopdf 0.12.6.1 (with patched qt)' +wkhtmltopdf --quiet - "$work/report.pdf" <<'EOF' +

TurnKey Odoo v19 report probe

+EOF +test "$(head -c 4 "$work/report.pdf")" = '%PDF' +test "$pypdf_compat_package" = turnkey-odoo-pypdf-compat +test "$pypdf_compat_version" = 1.0+turnkey19.0.1 +test "$(dpkg-query -W -f='${Version}' "$pypdf_compat_package")" = \ + "$pypdf_compat_version" +test "$(dpkg-query -W -f='${Provides}' "$pypdf_compat_package")" = \ + python3-pypdf2 +apt-get check +runuser -u odoo -- python3 <<'PY' +import io + +import pypdf +from odoo.tools import pdf as odoo_pdf + +assert odoo_pdf.SUBMOD == '._pypdf' +writer = pypdf.PdfWriter() +writer.add_blank_page(width=72, height=72) +stream = io.BytesIO() +writer.write(stream) +stream.seek(0) +assert len(pypdf.PdfReader(stream).pages) == 1 +PY + +role_state=$(runuser -u postgres -- psql --no-psqlrc --tuples-only \ + --no-align postgres --command=" + SELECT rolsuper, rolcreatedb, rolcreaterole, rolreplication + FROM pg_roles WHERE rolname = 'odoo';") +test "$role_state" = 'f|t|f|f' +database_owner=$(runuser -u postgres -- psql --no-psqlrc --tuples-only \ + --no-align postgres --command=" + SELECT pg_get_userbyid(datdba) FROM pg_database + WHERE datname = '$database';") +test "$database_owner" = odoo +runuser -u postgres -- psql --no-psqlrc --tuples-only --no-align \ + "$database" --command='SELECT 1 FROM res_users LIMIT 1;' | + grep -Fxq 1 +admin_password_hash=$(runuser -u postgres -- psql --no-psqlrc --tuples-only \ + --no-align "$database" --command=" + SELECT password FROM res_users + WHERE id = (SELECT res_id FROM ir_model_data + WHERE module = 'base' AND name = 'user_admin');") +[[ $admin_password_hash == \$* ]] +[[ $admin_password_hash != "$app_password" ]] + +authenticate +test "$(stat -c '%U:%G:%a' /var/lib/odoo/.local/share/Odoo/sessions)" = \ + odoo:odoo:700 + +contact_args=$(jq -cn --arg name "$fixture" --arg email "$email" \ + '[{name:$name, email:$email}]') +partner_id=$(ocall res.partner create "$contact_args" | rpc_result) +[[ $partner_id =~ ^[0-9]+$ ]] + +contact_domain=$(jq -cn --argjson id "$partner_id" '[[["id","=",$id]]]') +contact_fields='{"fields":["id","name","email","phone"]}' +readback=$(ocall res.partner search_read "$contact_domain" "$contact_fields" | + rpc_result) +jq -e --arg name "$fixture" --arg email "$email" \ + '.[0].name == $name and .[0].email == $email' <<<"$readback" >/dev/null +runuser -u postgres -- psql --no-psqlrc --tuples-only --no-align \ + "$database" --command="SELECT name FROM res_partner WHERE id = $partner_id;" | + grep -Fxq "$fixture" + +systemctl restart postgresql.service +systemctl restart odoo.service +ready= +for attempt in {1..60}; do + if authenticate quiet >/dev/null 2>&1; then + ready=1 + break + fi + sleep 2 +done +if [[ $ready != 1 ]]; then + authenticate diagnose || true +fi +test "$ready" = 1 + +main_pid=$(systemctl show odoo.service --property=MainPID --value) +[[ $main_pid =~ ^[1-9][0-9]*$ ]] +worker_processes=$(ps --no-headers --ppid "$main_pid" -o pid= | awk 'END { print NR }') +(( worker_processes >= 4 )) + +runuser -u postgres -- psql --no-psqlrc --tuples-only --no-align \ + "$database" --command="SELECT name FROM res_partner WHERE id = $partner_id;" | + grep -Fxq "$fixture" + +model_domain='[[["model","=","res.partner"]]]' +model_id=$(ocall ir.model search_read "$model_domain" \ + '{"fields":["id"],"limit":1}' | rpc_result | jq -er '.[0].id') +cron_code="model.browse($partner_id).write({'phone': '$cron_marker'})" +cron_args=$(jq -cn \ + --arg name "TurnKey v19 cron $partner_id" \ + --arg code "$cron_code" \ + --arg nextcall "$(date -u '+%Y-%m-%d %H:%M:%S')" \ + --argjson model_id "$model_id" \ + '[{name:$name, model_id:$model_id, state:"code", code:$code, interval_number:1, interval_type:"months", nextcall:$nextcall}]') +cron_id=$(ocall ir.cron create "$cron_args" | rpc_result) +[[ $cron_id =~ ^[0-9]+$ ]] + +cron_complete= +for attempt in {1..60}; do + readback=$(ocall res.partner search_read "$contact_domain" "$contact_fields" | + rpc_result) + if jq -e --arg marker "$cron_marker" \ + '.[0].phone == $marker' <<<"$readback" >/dev/null; then + cron_complete=1 + break + fi + sleep 2 +done +test "$cron_complete" = 1 + +printf '%s\n' "$app_password" | runuser -u odoo -- python3 -c \ + 'import sys; from odoo.tools import config; config.parse_config(["--config=/etc/odoo/odoo.conf"]); assert config.verify_admin_password(sys.stdin.readline().rstrip("\n"))' + +odoo-update --check >"$work/update" +candidate=$(sed -n 's/^candidate=//p' "$work/update") +status=$(sed -n 's/^status=//p' "$work/update") +test -n "$candidate" +grep -Fxq 'channel=official-odoo-19-community-daily' "$work/update" +grep -Fxq "integrity=APT-signed-by-$repository_key_fingerprint" "$work/update" +grep -Fxq "renderer=wkhtmltox-$wkhtmltox_version" "$work/update" +grep -Fxq "renderer_architecture=$wkhtmltox_architecture" "$work/update" +grep -Fxq 'renderer_policy=pinned-manual-security-review' "$work/update" +grep -Fxq "renderer_integrity=SHA256-$wkhtmltox_sha256" "$work/update" +grep -Fxq "dependency_bridge=$pypdf_compat_package-$pypdf_compat_version" \ + "$work/update" +grep -Fxq 'dependency_bridge_provides=python3-pypdf2' "$work/update" +grep -Eq '^pypdf=python3-pypdf-.+' "$work/update" +grep -Fxq 'candidate_resolution=apt-simulated' "$work/update" + +cat >"$result" <