From 6c7d94740cc17b2ba2ec04a3fd42ff4589c55b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Mon, 16 Mar 2026 04:00:54 -0300 Subject: [PATCH 1/2] Upgrade Odoo appliance from v16 to v18 Major changes: - Odoo 18 from official nightly apt repo (was v16 from backports) - Added WebSocket proxy support for live chat/notifications - Updated odoo.conf with production-optimized defaults - Added /opt/odoo/custom-addons for OCA/custom modules - Updated inithook for Odoo 18 password hashing - Installed rtlcss and wkhtmltopdf 0.12.6 - Updated README and changelog --- README.rst | 63 +++---- changelog | 29 ++++ conf.d/main | 163 ++++++++++-------- overlay/etc/apache2/sites-available/odoo.conf | 46 +++-- overlay/etc/odoo/odoo.conf | 30 ++++ overlay/usr/lib/inithooks/bin/odoo.py | 47 +++-- plan/main | 63 +++++-- 7 files changed, 271 insertions(+), 170 deletions(-) create mode 100644 overlay/etc/odoo/odoo.conf diff --git a/README.rst b/README.rst index c68e98f..0d03ba7 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ Odoo - From ERP to CRM, eCommerce to CMS -======================================== +========================================== `Odoo`_ is an all-in-one business management suite of mobile-friendly web apps that integrates everything you need to grow your business: CRM, @@ -7,75 +7,60 @@ website content management, project management, human resources, accounting, invoicing and more. Odoo apps integrate seamlessly to provide a full-featured open source ERP, but can also be used stand-alone. Python programmers can develop their own app modules, or -choose from an array of free open source such as those provided by OCA_; +choose from an array of free open source such as those provided by `OCA`_; or paid commercial ones. This appliance includes all the standard features in `TurnKey Core`_: - Odoo configurations for TurnKey v18.x: - - Odoo v16 installed from debian backports apt repo (v18.x). - - Includes modules from base install of Odoo. + - **Odoo v18** installed from official Odoo 18.0 nightly apt + repository. + - Includes modules from base install of 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**: Updates to Odoo are available via standard + ``apt-get upgrade`` from the Odoo nightly repository. - 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. +- Custom addons directory at ``/opt/odoo/custom-addons``. +- WebSocket support for Odoo 18 live chat and notifications. -**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 - - change value of "db_name" from "TurnkeylinuxExample" to your desired DB +1. Edit /etc/odoo/odoo.conf + + * change value of "db_name" from "TurnkeylinuxExample" to your desired DB name -2. Restart odoo.service -3. Reload Odoo login screen in your browser and you should be good to go -Webmin steps: -1. - - Browse to 'Tools' >> 'FileManager' - - Navigate to '/etc/odoo' and edit the 'odoo.conf' file - - Edit as per step 1 above - - Save changes -2. - - Navigate to 'System' >> 'Bootup and Shutdown' - - Find the "odoo.service" in the list and select it via the checkbox - - Scroll to the bottom and select 'Restart' -3. - - As per step 3 above - -CLI steps: -1. - - Edit /etc/odoo/odoo as per step 1 -2. - - Restart odoo.service:: - - systemctl restart odoo -3. - - As per step 3 above +2. Restart odoo.service:: + + systemctl restart odoo + +3. Reload Odoo login screen in your browser and you should be good to go **Notice for special Odoo Localization** In case you run a L10n Odoo Localization you will need other PIP Packages and Libs installed on your system, please check your localization support on -OCA_. +`OCA`_. Credentials *(passwords set at first boot)* -------------------------------------------- +-------------------------------------------- **Note**: the Odoo password set at firstboot applies to both the Odoo -admin account (example app) AND the masterpassword - however these can be +admin account (example app) AND the masterpassword - however these can be changed individually after firstboot. -- Webmin, SSH: username **root** -- PostgreSQL, Adminer: username **postgres** -- Odoo Master Account: **admin** +- Webmin, SSH: username **root** +- PostgreSQL, Adminer: username **postgres** +- Odoo Master Account: **admin** .. _Odoo: https://www.odoo.com/ .. _TurnKey Core: https://www.turnkeylinux.org/core diff --git a/changelog b/changelog index 37dd4e1..1a176df 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,32 @@ +turnkey-odoo-19.0 (1) turnkey; urgency=low + + * Upgraded Odoo to v18 from official Odoo 18.0 nightly apt repository + (previously v16 from Debian backports). + + * Python 3.11 (Debian Bookworm native) meets Odoo 18 requirement of + Python 3.10+. + + * PostgreSQL 15 (Debian Bookworm native) meets Odoo 18 requirement. + + * Added /opt/odoo/custom-addons directory for custom/OCA modules. + + * Updated odoo.conf with Odoo 18 optimized defaults (workers, memory + limits, proxy_mode, gevent_port for WebSocket). + + * Added Apache WebSocket proxy support (mod_proxy_wstunnel) for Odoo 18 + live chat, discuss, and real-time notifications. + + * Updated inithook for Odoo 18 compatibility (pbkdf2_sha512 hashing). + + * Installed rtlcss via npm for right-to-left language support. + + * Installed wkhtmltopdf 0.12.6 for PDF report generation. + + * Note: Please refer to turnkey-core's changelog for changes common to + all appliances. + + -- Marcos Méndez Mon, 16 Mar 2026 04:00:54 -0300 + 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..ad887bc 100755 --- a/conf.d/main +++ b/conf.d/main @@ -1,87 +1,106 @@ #!/bin/bash -ex - -# Set variables -# - -DB_USER=odoo -DB_PASS=$(mcookie) +# TurnKey Odoo 18 — Main Configuration Script + +SRC=/usr/local/src + +# ----------------------------------------------- +# 1. Add Odoo 18 official nightly repository +# ----------------------------------------------- +wget -qO - https://nightly.odoo.com/odoo.key \ + | gpg --dearmor -o /usr/share/keyrings/odoo-archive-keyring.gpg + +echo 'deb [signed-by=/usr/share/keyrings/odoo-archive-keyring.gpg] https://nightly.odoo.com/18.0/nightly/deb/ ./' \ + > /etc/apt/sources.list.d/odoo.list + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y odoo + +# ----------------------------------------------- +# 2. Install wkhtmltopdf (patched QT version) +# ----------------------------------------------- +if ! wkhtmltopdf --version 2>/dev/null | grep -q "0.12.6"; then + WKHTMLTOPDF_DEB="wkhtmltox_0.12.6.1-2.bookworm_amd64.deb" + WKHTMLTOPDF_URL="https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2" + cd /tmp + wget "${WKHTMLTOPDF_URL}/${WKHTMLTOPDF_DEB}" || true + if [ -f "${WKHTMLTOPDF_DEB}" ]; then + dpkg -i "${WKHTMLTOPDF_DEB}" || apt-get install -f -y + rm -f "${WKHTMLTOPDF_DEB}" + fi + cd - +fi + +# ----------------------------------------------- +# 3. Install rtlcss (for right-to-left languages) +# ----------------------------------------------- +npm install -g rtlcss + +# ----------------------------------------------- +# 4. Stop Odoo for initial configuration +# ----------------------------------------------- +systemctl stop odoo || true + +# ----------------------------------------------- +# 5. Create custom addons directory +# ----------------------------------------------- +mkdir -p /opt/odoo/custom-addons +chown odoo:odoo /opt/odoo/custom-addons + +mkdir -p /var/log/odoo +chown odoo:odoo /var/log/odoo + +# ----------------------------------------------- +# 6. Configure Apache proxy for Odoo 18 +# ----------------------------------------------- +# Enable required Apache modules +a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl + +# ----------------------------------------------- +# 7. Configure PostgreSQL and create example DB +# ----------------------------------------------- DB_NAME="TurnkeylinuxExample" -# We will use a random password for now. Reset during inithook -ODOO_ADMIN_PASSWORD=$(mcookie) - -# User name that Odoo will run under -SERVICE_USER=$DB_USER - -CONF=/etc/odoo/odoo.conf - -# Create database role for odoo -# ******************************************* - -# Start postgresql server systemctl start postgresql -# Create db user -su postgres -c "createuser --superuser --createdb --no-createrole $DB_USER" - -# Set db user password -su postgres -c "psql postgres" << EOF -alter user $DB_USER with encrypted password '$DB_PASS'; -EOF -su postgres -c "psql postgres" << EOF -create database "$DB_NAME" owner $DB_USER; -EOF - -# Set password/name in config file -sed -i "s|db_password =.*|db_password = $DB_PASS|" $CONF -sed -i "s|db_host =.*|db_host = localhost|" $CONF -echo "db_name = $DB_NAME" >> $CONF - -# Stop postgresql server -systemctl stop postgresql - -# Set admin password to random value for now -sed -i "s|admin_passwd =.*|admin_passwd = $ODOO_ADMIN_PASSWORD|" $CONF -chown odoo:odoo $CONF - -# Add and disable service user -usermod -L $SERVICE_USER - -# Enable needed modules -a2enmod proxy_http headers rewrite - -# Configure sites -a2dissite 000-default -a2ensite odoo.conf - -# Populate database -systemctl start postgresql -systemctl start apache2 +su - postgres -c "createdb -O odoo ${DB_NAME}" || true -su odoo -g odoo -s '/bin/bash' -c 'odoo -i base \ - --stop-after-init --db_host=localhost \ - -d '"\"$DB_NAME\""' \ - -r '"\"$DB_USER\""' \ - -w '"\"$DB_PASS\""' \ - -s -c '"\"$CONF\"" +su - odoo -s /bin/bash -c \ + "odoo --database=${DB_NAME} \ + --init=base \ + --without-demo=all \ + --stop-after-init \ + --config=/etc/odoo/odoo.conf" || true -# enable and start odoo -systemctl enable odoo +# ----------------------------------------------- +# 8. Start services and set master password +# ----------------------------------------------- systemctl start odoo -while [[ "$(curl -Lk http://localhost)" = *"Unavailable"* ]]; do - sleep 1 +CURL="curl -s -k -L --max-time 5" +URL="http://127.0.0.1:8069" +MAX_TRIES=60 +COUNT=0 + +echo "Waiting for Odoo to start..." +while [ $COUNT -lt $MAX_TRIES ]; do + if $CURL $URL/web/login 2>/dev/null | grep -q "login"; then + echo "Odoo is ready." + break + fi + COUNT=$((COUNT + 1)) + sleep 2 done -URL="https://127.0.0.1/web" -CURL="curl --insecure -c /tmp/cookie -b /tmp/cookie" +if [ $COUNT -ge $MAX_TRIES ]; then + echo "WARNING: Odoo may not have started within expected time" +fi -$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}" +ODOO_ADMIN_PASSWORD="${ODOO_ADMIN_PASSWORD:-admin}" +$CURL $URL/database/change_password \ + --data-raw "master_pwd=admin&master_pwd_new=${ODOO_ADMIN_PASSWORD}" || true +# ----------------------------------------------- +# 9. Stop for appliance packaging +# ----------------------------------------------- systemctl stop odoo systemctl stop postgresql -systemctl stop apache2 diff --git a/overlay/etc/apache2/sites-available/odoo.conf b/overlay/etc/apache2/sites-available/odoo.conf index e1cbb55..4d36003 100644 --- a/overlay/etc/apache2/sites-available/odoo.conf +++ b/overlay/etc/apache2/sites-available/odoo.conf @@ -1,34 +1,32 @@ -ServerName localhost - - - RewriteEngine on - ReWriteCond %{SERVER_PORT} !^443$ - RewriteRule ^/(.*) https://%{HTTP_HOST}/$1 [NC,R=301,L] - - - SSLEngine on ServerAdmin webmaster@localhost - ProxyRequests Off + SSLEngine on - - Order deny,allow - Allow from all - - - ProxyVia On + # Proxy headers + RequestHeader set X-Forwarded-Proto "https" + RequestHeader set X-Forwarded-Port "443" - # 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/ + ProxyRequests Off + ProxyPreserveHost On + + # WebSocket support (Odoo 18 live chat, discuss, notifications) + ProxyPass /websocket ws://127.0.0.1:8072/websocket + ProxyPassReverse /websocket ws://127.0.0.1:8072/websocket - ProxyPass / http://127.0.0.1:8069/ timeout=200 + # Standard HTTP proxy + ProxyPass / http://127.0.0.1:8069/ ProxyPassReverse / http://127.0.0.1:8069/ - RequestHeader set "X-Forwarded-Proto" "https" + ProxyTimeout 600 + TimeOut 600 + + ErrorLog ${APACHE_LOG_DIR}/odoo-error.log + CustomLog ${APACHE_LOG_DIR}/odoo-access.log combined + - # Fix IE problem (http error 408/409) - SetEnv proxy-nokeepalive 1 + + ServerAdmin webmaster@localhost + RewriteEngine On + RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L] diff --git a/overlay/etc/odoo/odoo.conf b/overlay/etc/odoo/odoo.conf new file mode 100644 index 0000000..92c5cce --- /dev/null +++ b/overlay/etc/odoo/odoo.conf @@ -0,0 +1,30 @@ +[options] +admin_passwd = admin +db_host = False +db_port = False +db_user = odoo +db_password = False +db_name = TurnkeylinuxExample + +addons_path = /usr/lib/python3/dist-packages/odoo/addons,/opt/odoo/custom-addons + +proxy_mode = True +list_db = True + +logfile = /var/log/odoo/odoo-server.log +log_level = info +logrotate = True + +workers = 2 +max_cron_threads = 1 +limit_memory_hard = 2684354560 +limit_memory_soft = 2147483648 +limit_time_cpu = 60 +limit_time_real = 120 +limit_request = 8192 + +xmlrpc_port = 8069 +gevent_port = 8072 + +data_dir = /var/lib/odoo/.local/share/Odoo +csv_internal_sep = , diff --git a/overlay/usr/lib/inithooks/bin/odoo.py b/overlay/usr/lib/inithooks/bin/odoo.py index 76cbf67..fa371b3 100755 --- a/overlay/usr/lib/inithooks/bin/odoo.py +++ b/overlay/usr/lib/inithooks/bin/odoo.py @@ -1,23 +1,18 @@ #!/usr/bin/python3 +"""Set Odoo 18 admin password and master password -"""Set Odoo Admin Password Option: - --pass= unless provided, will ask interactively + --pass= unless provided, will ask interactively """ -import re import sys import getopt - -import crypt -import random -import hashlib -import configparser - import subprocess -from libinithooks.dialog_wrapper import Dialog -from pgsqlconf import PostgreSQL + from passlib.context import CryptContext +from dialog_wrapper import Dialog +from pgsqlconf import PostgreSQL + def usage(s=None): if s: @@ -26,6 +21,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", @@ -40,38 +36,39 @@ def main(): 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", - "Enter new password for Odoo Database Management - create/delete/manage Odoo DBs. " - "This password will also login to 'admin' account of default/example Odoo.", + "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) 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") + print(f"Default DB ({default_db}) not found " + "- skipping setting password 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() + conf_file = '/etc/odoo/odoo.conf' + subprocess.run([ + 'sed', '-i', + 's|^admin_passwd.*|admin_passwd = {}|'.format(password), + conf_file + ], check=True) - # restart odoo to apply updated password - subprocess.run(['systemctl', 'restart', 'odoo']) + subprocess.run(['systemctl', 'restart', 'odoo'], check=True) - if not default_db_exists: - sys.exit(1) if __name__ == "__main__": main() diff --git a/plan/main b/plan/main index db0e703..950dec0 100644 --- a/plan/main +++ b/plan/main @@ -1,13 +1,56 @@ -#include -#include +# TurnKey Odoo 18 - Package Plan +# Odoo 18 installed via official nightly apt repo in conf.d/main -odoo-16 -xfonts-75dpi -wkhtmltopdf -python3-psycogreen +postgresql +postgresql-client -adduser -postgresql-client -python3 +# Python3 dependencies for Odoo 18 +python3-babel +python3-chardet +python3-dateutil +python3-decorator +python3-docutils +python3-feedparser +python3-gevent +python3-greenlet +python3-html2text +python3-idna +python3-jinja2 +python3-libsass +python3-lxml +python3-mako +python3-markupsafe +python3-num2words +python3-ofxparse +python3-openssl +python3-passlib +python3-pil +python3-polib +python3-psutil +python3-psycopg2 +python3-pydot +python3-pypdf2 +python3-qrcode +python3-reportlab +python3-requests +python3-stdnum +python3-tz +python3-vobject +python3-werkzeug +python3-xlrd +python3-xlsxwriter +python3-xlwt +python3-zeep -python3-passlib /* Needed to set admin password */ +# System dependencies +node-less +nodejs +npm +libldap2-dev +libsasl2-dev +libpq-dev +libxml2-dev +libxslt1-dev + +# Adminer for PostgreSQL management +adminer From a5d05cfa14af0c395acf3d028bde4a5f87d1a4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20M=C3=A9ndez?= Date: Wed, 12 Aug 2026 01:55:16 -0300 Subject: [PATCH 2/2] feat: firstboot custom DB name, demo data choice & OCA localizations Rebuild of this PR's features on top of the Odoo 18 base, replacing the Odoo 16 implementation. The inithook is rewritten around three opt-in options, all of which also work pre-seeded (TurnKey Hub): --dbname / ODOO_DB_NAME database name (default: keep configured one) --demo / ODOO_DEMO demo data for a new database (default: no) --l10n / ODOO_L10N OCA localization addons to download Behaviour: * New databases are initialized via 'odoo -i base --stop-after-init' (with or without demo data) instead of a bare CREATE DATABASE, which used to leave an uninitialized database behind. * The example database is only dropped once a custom database has been successfully initialized to replace it; no other database is ever dropped, and an existing database that is not an Odoo database is refused rather than adopted. * Re-running turnkey-init defaults to the database configured in odoo.conf, so a custom database is never orphaned. * If initialization fails, the partial database is removed and odoo is restarted before exiting non-zero. * OCA l10n-* repos are cloned (as the odoo user) from the pinned 18.0 branch into /opt/odoo/custom-addons together with their oca_dependencies chain, python requirements are installed and addons_path is updated. Repo names are validated and clone destinations are confined to the addons dir; the whole step is best-effort and never fatal to firstboot. Non-interactive support (the original review blocker): when a password is pre-seeded no dialog is ever shown and unset options fall back to safe defaults, so existing Hub deployments keep working with APP_PASS alone. Interactive runs use the standard Dialog wrapper. Security: * master password stored hashed (pbkdf2_sha512) instead of plaintext * password passed via ODOO_PASS, not argv (/proc/PID/cmdline is world readable), and removed from the environment before any subprocess * odoo.conf chmod 0640 - it holds the db password and master pw hash * database names validated before reaching SQL; 'admin' user matched by login rather than the hardcoded id=2 Adds tests/test_inithook.py: 18 tests covering the pre-seeded and interactive paths, database decisions, failure recovery and rejection of hostile oca_dependencies.txt entries. Runs without an appliance. --- README.rst | 35 +- changelog | 18 +- conf.d/main | 4 + overlay/usr/lib/inithooks/bin/odoo.py | 415 +++++++++++++++++-- overlay/usr/lib/inithooks/firstboot.d/40odoo | 25 +- plan/main | 2 + tests/test_inithook.py | 391 +++++++++++++++++ 7 files changed, 846 insertions(+), 44 deletions(-) create mode 100644 tests/test_inithook.py diff --git a/README.rst b/README.rst index 0d03ba7..0cad55f 100644 --- a/README.rst +++ b/README.rst @@ -28,6 +28,33 @@ This appliance includes all the standard features in `TurnKey Core`_: - Custom addons directory at ``/opt/odoo/custom-addons``. - WebSocket support for Odoo 18 live chat and notifications. +**First boot configuration** + +Beyond the password, first boot (``turnkey-init``) lets you: + +- **Choose the database name** - keep the default + (``TurnkeylinuxExample``) or enter a new name. A new name initializes + a fresh database and removes the unused example database. +- **Choose demo data** - when a new database is initialized you can load + Odoo demo data (choose "No" for production). +- **Download OCA localization addons** - e.g. ``brazil``, ``spain``. + Selected `OCA`_ ``l10n-*`` repos (18.0 branch) are cloned into + ``/opt/odoo/custom-addons``, their python requirements installed and + registered in ``addons_path``; the addons then show up in the Odoo + Apps menu, ready to install per database. Requires internet access. + +For unattended/pre-seeded deployments (e.g. TurnKey Hub) the same +options can be pre-seeded via ``inithooks.conf``:: + + export APP_PASS=SecretPassword1! + export ODOO_DB_NAME=production + export ODOO_DEMO=no + export ODOO_L10N=brazil + +Unset options fall back to safe defaults (example database, no demo +data, no extra addons), so existing Hub deployments keep working +unchanged. + **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 @@ -47,9 +74,11 @@ Overview of process: **Notice for special Odoo Localization** -In case you run a L10n Odoo Localization you will need other PIP Packages -and Libs installed on your system, please check your localization support on -`OCA`_. +Localization (l10n) addons can be downloaded at first boot (see above) +or at any time by re-running ``turnkey-init``. Some localizations may +need additional PIP packages and system libs - ``requirements.txt`` of +each selected OCA repo is installed automatically, but please check your +localization's documentation on `OCA`_ for extra system dependencies. Credentials *(passwords set at first boot)* -------------------------------------------- diff --git a/changelog b/changelog index 1a176df..ed4b52f 100644 --- a/changelog +++ b/changelog @@ -16,7 +16,23 @@ turnkey-odoo-19.0 (1) turnkey; urgency=low * Added Apache WebSocket proxy support (mod_proxy_wstunnel) for Odoo 18 live chat, discuss, and real-time notifications. - * Updated inithook for Odoo 18 compatibility (pbkdf2_sha512 hashing). + * Updated inithook for Odoo 18 compatibility (pbkdf2_sha512 hashing); + master password now stored hashed in odoo.conf. + + * Inithook: support custom database name at firstboot (--dbname / + ODOO_DB_NAME) - initializes a fresh database via 'odoo -i base' and + removes the unused example database. + + * Inithook: optional demo data for newly initialized databases + (--demo / ODOO_DEMO; default no - production safe). + + * Inithook: optional download of OCA localization addons (--l10n / + ODOO_L10N) - clones selected l10n-* repos (18.0 branch) plus their + oca_dependencies into /opt/odoo/custom-addons, installs python + requirements and registers them in addons_path. + + * Inithook: fully non-interactive when pre-seeded (TurnKey Hub + compatible) - unset options fall back to safe defaults. * Installed rtlcss via npm for right-to-left language support. diff --git a/conf.d/main b/conf.d/main index ad887bc..6812d23 100755 --- a/conf.d/main +++ b/conf.d/main @@ -49,6 +49,10 @@ chown odoo:odoo /opt/odoo/custom-addons mkdir -p /var/log/odoo chown odoo:odoo /var/log/odoo +# odoo.conf holds the db password & master password hash +chown odoo:odoo /etc/odoo/odoo.conf +chmod 0640 /etc/odoo/odoo.conf + # ----------------------------------------------- # 6. Configure Apache proxy for Odoo 18 # ----------------------------------------------- diff --git a/overlay/usr/lib/inithooks/bin/odoo.py b/overlay/usr/lib/inithooks/bin/odoo.py index fa371b3..a5d003c 100755 --- a/overlay/usr/lib/inithooks/bin/odoo.py +++ b/overlay/usr/lib/inithooks/bin/odoo.py @@ -1,18 +1,70 @@ #!/usr/bin/python3 -"""Set Odoo 18 admin password and master password +"""Configure Odoo: passwords, database name, demo data & OCA localization -Option: - --pass= unless provided, will ask interactively +Options: + --pass= Odoo master password & 'admin' account password + unless provided, will ask interactively + (prefer the ODOO_PASS env var - see below) + --dbname= Odoo database name - must match [A-Za-z][A-Za-z0-9_-]* + default: the database currently set in odoo.conf + in interactive mode, will ask + --demo= load demo data when initializing a new database (yes|no) + default: no - only applies when a new database is created + --l10n= comma-separated OCA localization repos to download, e.g. + 'brazil,spain' (see https://github.com/OCA?q=l10n) or + 'none' (default: none) + +Environment: + ODOO_PASS same as --pass; preferred as it avoids exposing the + password via /proc//cmdline (world readable) + +Non-interactive mode (password provided): no dialog is ever shown and +unset options fall back to safe defaults, so pre-seeded/Hub deployments +keep working with only APP_PASS set. """ +import os +import re import sys import getopt import subprocess +import configparser from passlib.context import CryptContext -from dialog_wrapper import Dialog + +try: + from libinithooks.dialog_wrapper import Dialog +except ImportError: + from dialog_wrapper import Dialog + from pgsqlconf import PostgreSQL +CONF_FILE = '/etc/odoo/odoo.conf' +DEFAULT_DB = 'TurnkeylinuxExample' +ODOO_HOME = '/var/lib/odoo' +CORE_ADDONS = '/usr/lib/python3/dist-packages/odoo/addons' +CUSTOM_ADDONS_DIR = '/opt/odoo/custom-addons' +OCA_BRANCH = '18.0' +# hard cap on the oca_dependencies.txt chain - stops a runaway/hostile +# dependency graph from cloning indefinitely at firstboot +MAX_OCA_REPOS = 50 + +DB_NAME_RE = re.compile(r'^[A-Za-z][A-Za-z0-9_-]*$') +# OCA repo names reachable via oca_dependencies.txt are third party +# content: only ever accept a bare, relative repo name +REPO_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$') + +OCA_L10N = [ + 'argentina', 'austria', 'belarus', 'belgium', 'brazil', 'bulgaria', + 'cambodia', 'canada', 'chile', 'china', 'colombia', 'costa-rica', + 'croatia', 'ecuador', 'estonia', 'ethiopia', 'finland', 'france', + 'germany', 'greece', 'india', 'indonesia', 'iran', 'ireland', 'italy', + 'japan', 'macedonia', 'mexico', 'morocco', 'netherlands', 'norway', + 'peru', 'poland', 'portugal', 'romania', 'russia', 'slovenia', 'spain', + 'switzerland', 'taiwan', 'thailand', 'turkey', 'ukraine', + 'united-kingdom', 'uruguay', 'usa', 'venezuela', 'vietnam', +] + def usage(s=None): if s: @@ -22,52 +74,349 @@ def usage(s=None): sys.exit(1) +def warn(msg): + print(f"WARNING: {msg}", file=sys.stderr) + + +def fatal(msg): + print(f"FATAL: {msg}", file=sys.stderr) + sys.exit(1) + + +def service(action, name): + # 'service' rather than systemctl - lxc/ovz may not run systemd + subprocess.run(['service', name, action], check=False) + + +def run_as(user, args): + return subprocess.run( + ['runuser', '-u', user, '--'] + args, capture_output=True, text=True) + + +def pg_command(args): + return run_as('postgres', args) + + +def db_exists(name): + result = pg_command( + ['psql', '-tAc', + "SELECT 1 FROM pg_database WHERE datname = '{}'".format(name)]) + if result.returncode != 0: + fatal(f"could not query PostgreSQL: {result.stderr.strip()}") + return result.stdout.strip() == '1' + + +def is_odoo_database(name): + """True if name looks like an initialized Odoo database.""" + result = pg_command( + ['psql', '-d', name, '-tAc', + "SELECT 1 FROM information_schema.tables" + " WHERE table_name = 'res_users'"]) + return result.returncode == 0 and result.stdout.strip() == '1' + + +def init_database(db_name, demo): + """Create db_name (owned by odoo) and initialize it with the base + module, with or without demo data. Odoo must be stopped.""" + result = pg_command(['createdb', '-O', 'odoo', db_name]) + if result.returncode != 0: + warn(f"createdb {db_name} failed: {result.stderr.strip()}") + return False + cmd = ['odoo', '--config', CONF_FILE, '--database', db_name, + '--init', 'base', '--stop-after-init', '--workers=0'] + if not demo: + cmd.append('--without-demo=all') + result = run_as('odoo', cmd) + if result.returncode != 0: + warn(f"initializing database {db_name} failed:" + f" {result.stderr.strip()[-500:]}") + # leave no half-initialized database behind - it would be + # mistaken for a valid one on the next turnkey-init run + pg_command(['dropdb', '--if-exists', db_name]) + return False + return True + + +def set_odoo_admin_password(db_name, hashed_password): + assert DB_NAME_RE.match(db_name) + try: + p = PostgreSQL(db_name) + p.execute("UPDATE res_users SET password='{}'" + " WHERE login='admin'".format(hashed_password) + .encode('utf8')) + return True + except subprocess.CalledProcessError: + warn(f"could not set 'admin' password in database {db_name}") + return False + + +def within_custom_addons(path): + base = os.path.realpath(CUSTOM_ADDONS_DIR) + target = os.path.realpath(path) + return target.startswith(base + os.sep) + + +def clone_oca_repo(repo, branch=OCA_BRANCH): + """Clone https://github.com/OCA/ into CUSTOM_ADDONS_DIR. + Returns the addons path or None. Never fatal.""" + if not REPO_NAME_RE.match(repo): + warn(f"ignoring OCA repo with unsafe name: {repo!r}") + return None + dest = os.path.join(CUSTOM_ADDONS_DIR, repo) + if not within_custom_addons(dest): + warn(f"ignoring OCA repo escaping {CUSTOM_ADDONS_DIR}: {repo!r}") + return None + if os.path.isdir(dest): + print(f"{dest} already exists - skipping clone") + return dest + result = run_as('odoo', [ + 'env', f'HOME={ODOO_HOME}', + 'git', 'clone', '--depth', '1', '--branch', branch, '--', + f"https://github.com/OCA/{repo}", dest]) + if result.returncode != 0: + warn(f"cloning OCA/{repo} ({branch}) failed:" + f" {result.stderr.strip()}") + return None + return dest + + +def install_repo_requirements(repo_dir): + requirements = os.path.join(repo_dir, 'requirements.txt') + if not os.path.isfile(requirements): + return + result = subprocess.run( + ['pip3', 'install', '--break-system-packages', '-r', requirements], + capture_output=True, text=True) + if result.returncode != 0: + warn(f"pip requirements install failed for {repo_dir}:" + f" {result.stderr.strip()[-300:]}") + + +def parse_oca_dependencies(repo_dir): + """Parse OCA oca_dependencies.txt: 'repo_name [url] [branch]' lines. + Repo names are third party content - only bare names are accepted.""" + deps_file = os.path.join(repo_dir, 'oca_dependencies.txt') + deps = [] + if not os.path.isfile(deps_file): + return deps + with open(deps_file) as fob: + for line in fob: + line = line.strip() + if not line or line.startswith('#'): + continue + name = line.split()[0] + if not REPO_NAME_RE.match(name): + warn(f"{deps_file}: ignoring unsafe dependency {name!r}") + continue + deps.append(name) + return deps + + +def install_l10n_repos(selections): + """Clone OCA l10n repos plus their OCA dependencies into + CUSTOM_ADDONS_DIR. Returns list of addons paths to register. + Best-effort: failures are warnings, never fatal to firstboot.""" + if not selections: + return [] + os.makedirs(CUSTOM_ADDONS_DIR, exist_ok=True) + subprocess.run(['chown', 'odoo:odoo', CUSTOM_ADDONS_DIR], check=False) + addons_paths = [] + to_install = [f"l10n-{name}" for name in selections] + seen = set() + while to_install: + if len(seen) >= MAX_OCA_REPOS: + warn(f"reached the {MAX_OCA_REPOS} OCA repo limit -" + f" skipping: {', '.join(sorted(set(to_install)))}") + break + repo = to_install.pop(0) + if repo in seen: + continue + seen.add(repo) + dest = clone_oca_repo(repo) + if not dest: + continue + install_repo_requirements(dest) + addons_paths.append(dest) + to_install.extend(parse_oca_dependencies(dest)) + if addons_paths: + subprocess.run( + ['chown', '-R', 'odoo:odoo', CUSTOM_ADDONS_DIR], check=False) + return addons_paths + + +def read_conf(): + config = configparser.ConfigParser(interpolation=None) + config.read(CONF_FILE) + return config + + +def configured_db_name(): + return read_conf().get( + 'options', 'db_name', fallback=DEFAULT_DB) or DEFAULT_DB + + +def update_odoo_conf(db_name, hashed_password, extra_addons_paths): + config = read_conf() + if 'options' not in config: + config.add_section('options') + config.set('options', 'db_name', db_name) + config.set('options', 'admin_passwd', hashed_password) + current = config.get('options', 'addons_path', + fallback=f"{CORE_ADDONS},{CUSTOM_ADDONS_DIR}") + paths = [p.strip() for p in current.split(',') if p.strip()] + for path in extra_addons_paths: + if path not in paths: + paths.append(path) + config.set('options', 'addons_path', ','.join(paths)) + with open(CONF_FILE, 'w') as fob: + config.write(fob) + # holds the master password hash and the postgres password + subprocess.run(['chown', 'odoo:odoo', CONF_FILE], check=False) + subprocess.run(['chmod', '0640', CONF_FILE], check=False) + + +def parse_l10n(value): + if value.strip().lower() in ('', 'none'): + return [] + selections = [item.strip().lower() for item in value.split(',') + if item.strip()] + invalid = [s for s in selections if s not in OCA_L10N] + if invalid: + return None + return selections + + +def ask_l10n(d): + if not d.yesno( + "OCA localization addons", + "Download OCA (Odoo Community Association) localization" + " addons for your country?\n\nThey will be made available" + " for installation via the Odoo Apps menu." + " Requires internet access."): + return [] + text = ("Enter a comma separated list of localizations, e.g.:" + "\n\n brazil,spain\n\nAvailable options:\n\n" + + ', '.join(OCA_L10N)) + while True: + value = d.get_input("OCA localizations", text) or '' + selections = parse_l10n(value) + if selections is not None: + return selections + d.error(f"Invalid selection: {value!r}\n\nPlease only use names" + " from the list, separated by commas.") + + +def ask_db_name(d, current): + while True: + value = d.get_input( + "Odoo database name", + "Enter the name of the Odoo database.\n\nKeep the current" + " value to leave the existing database as is. Entering a new" + " name will initialize a fresh database (and remove the" + f" unused example database '{DEFAULT_DB}').", + init=current) or current + if DB_NAME_RE.match(value): + return value + d.error(f"Invalid database name: {value!r}\n\nUse only letters," + " digits, '_' or '-'; must start with a letter.") + + def main(): try: - opts, args = getopt.gnu_getopt(sys.argv[1:], "h", - ['help', 'pass=']) + opts, args = getopt.gnu_getopt( + sys.argv[1:], "h", + ['help', 'pass=', 'dbname=', 'demo=', 'l10n=']) except getopt.GetoptError as e: usage(e) - password = "" + # pop so the password is not inherited by subprocesses + password = os.environ.pop('ODOO_PASS', '') + db_name = '' + demo = None + l10n_selections = None for opt, val in opts: if opt in ('-h', '--help'): usage() elif opt == '--pass': password = val + elif opt == '--dbname': + db_name = val + elif opt == '--demo': + demo = val + elif opt == '--l10n': + l10n_selections = parse_l10n(val) + if l10n_selections is None: + usage(f"invalid --l10n: {val}") + + # a pre-seeded password means non-interactive (e.g. Hub deployment): + # never prompt; unset options use safe defaults + interactive = not password + + if db_name and not DB_NAME_RE.match(db_name): + usage(f"invalid --dbname: {db_name}") + if demo is not None and demo.lower() not in ('yes', 'no'): + usage(f"invalid --demo (must be yes|no): {demo}") - if not password: + # default to the database already in use, not the example one, so + # re-running turnkey-init never orphans a custom database + current_db = configured_db_name() + + d = None + if interactive: d = Dialog('TurnKey Linux - First boot configuration') password = d.get_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.", + "Odoo Database Management & 'admin' Password", + "Enter new password for Odoo Database Management -" + " create/delete/manage Odoo DBs. This password will also" + " login to the 'admin' account of the Odoo database.", blacklist=['\\', '/']) + if not db_name: + db_name = ask_db_name(d, current_db) + if l10n_selections is None: + l10n_selections = ask_l10n(d) - processed_password = CryptContext(['pbkdf2_sha512']).hash(password) + db_name = db_name or current_db + l10n_selections = l10n_selections or [] + hashed_password = CryptContext(['pbkdf2_sha512']).hash(password) - default_db = 'TurnkeylinuxExample' - default_db_exists = True + service('start', 'postgresql') - 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 password for that") - - conf_file = '/etc/odoo/odoo.conf' - subprocess.run([ - 'sed', '-i', - 's|^admin_passwd.*|admin_passwd = {}|'.format(password), - conf_file - ], check=True) - - subprocess.run(['systemctl', 'restart', 'odoo'], check=True) + create_db = not db_exists(db_name) + if create_db and interactive and demo is None: + demo = 'yes' if d.yesno( + "Demo data", + f"Load Odoo demo data into the new database '{db_name}'?" + "\n\nChoose 'No' for production deployments.", + yes_label="Yes", no_label="No (production)") else 'no' + with_demo = bool(demo) and demo.lower() == 'yes' + + if create_db: + service('stop', 'odoo') + if not init_database(db_name, with_demo): + service('start', 'odoo') + fatal(f"could not initialize database {db_name}") + if db_name != DEFAULT_DB and db_exists(DEFAULT_DB): + result = pg_command(['dropdb', DEFAULT_DB]) + if result.returncode != 0: + warn(f"could not remove example database {DEFAULT_DB}:" + f" {result.stderr.strip()}") + else: + if demo: + print(f"Database {db_name} already exists" + " - ignoring demo data setting") + if not is_odoo_database(db_name): + fatal(f"database {db_name} exists but is not an Odoo database" + " - refusing to use it") + + admin_password_set = set_odoo_admin_password(db_name, hashed_password) + + extra_addons_paths = install_l10n_repos(l10n_selections) + update_odoo_conf(db_name, hashed_password, extra_addons_paths) + + service('restart', 'odoo') + + if not admin_password_set: + sys.exit(1) if __name__ == "__main__": diff --git a/overlay/usr/lib/inithooks/firstboot.d/40odoo b/overlay/usr/lib/inithooks/firstboot.d/40odoo index dd8f6d9..a1e52bc 100755 --- a/overlay/usr/lib/inithooks/firstboot.d/40odoo +++ b/overlay/usr/lib/inithooks/firstboot.d/40odoo @@ -1,13 +1,24 @@ -#!/bin/bash -e -# set Odoo Admin password +#!/bin/bash -e +# configure Odoo: passwords, database name, demo data & OCA localizations . /etc/default/inithooks [ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF -$INITHOOKS_PATH/bin/odoo.py --pass="$APP_PASS" +# Optional pre-seeds (e.g. via inithooks.conf / TurnKey Hub): +# APP_PASS Odoo master & 'admin' password (empty = ask interactively) +# ODOO_DB_NAME database name (default: keep the configured one) +# ODOO_DEMO load demo data in new database (yes|no; default: no) +# ODOO_L10N comma separated OCA localizations (e.g. brazil,spain) -# Since we reset the password, reload Odoo -# Don't use systemd/systemctl right now as lxc/ovz does not -# use systemd -service odoo restart +ARGS=() +[ -n "$ODOO_DB_NAME" ] && ARGS+=(--dbname="$ODOO_DB_NAME") +[ -n "$ODOO_DEMO" ] && ARGS+=(--demo="$ODOO_DEMO") +[ -n "$ODOO_L10N" ] && ARGS+=(--l10n="$ODOO_L10N") + +# password via the environment rather than argv - /proc//cmdline is +# world readable, /proc//environ is not +export ODOO_PASS="$APP_PASS" + +# odoo.py restarts the odoo service itself after (re)configuration +$INITHOOKS_PATH/bin/odoo.py "${ARGS[@]}" diff --git a/plan/main b/plan/main index 950dec0..b1dc886 100644 --- a/plan/main +++ b/plan/main @@ -43,6 +43,8 @@ python3-xlwt python3-zeep # System dependencies +git /* clone OCA localization addons at firstboot */ +python3-pip /* install OCA addons python requirements */ node-less nodejs npm diff --git a/tests/test_inithook.py b/tests/test_inithook.py new file mode 100644 index 0000000..dc07785 --- /dev/null +++ b/tests/test_inithook.py @@ -0,0 +1,391 @@ +#!/usr/bin/python3 +"""Tests for the Odoo firstboot inithook. + +Stubs out passlib / dialog_wrapper / pgsqlconf and intercepts subprocess, +so the inithook control flow can be exercised without an appliance: + + python3 tests/test_inithook.py + +Covers the pre-seeded (TurnKey Hub) path, the interactive path, database +create/adopt/refuse decisions, failure recovery and the OCA addon +download - including rejection of hostile oca_dependencies.txt entries. +""" +import os +import re +import sys +import types +import shutil +import tempfile +import unittest +from unittest import mock + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, os.pardir, + 'overlay/usr/lib/inithooks/bin/odoo.py') + + +# --- stub external inithooks/passlib deps ------------------------------- +def install_stubs(): + passlib = types.ModuleType('passlib') + context = types.ModuleType('passlib.context') + + class CryptContext: + def __init__(self, schemes): + self.schemes = schemes + + def hash(self, password): + return '$pbkdf2-sha512$25000$fakehash$for%testing' + + context.CryptContext = CryptContext + passlib.context = context + sys.modules['passlib'] = passlib + sys.modules['passlib.context'] = context + + dw = types.ModuleType('dialog_wrapper') + + class Dialog: + script = None # list of answers, consumed in order + + def __init__(self, title): + self.title = title + self.asked = [] + + def _next(self, kind): + self.asked.append(kind) + if not Dialog.script: + raise AssertionError(f"unexpected dialog: {kind}") + return Dialog.script.pop(0) + + def get_password(self, *a, **kw): + return self._next('password') + + def get_input(self, title, text, init=""): + val = self._next(f'input:{title}') + return init if val is None else val + + def yesno(self, title, text, **kw): + return self._next(f'yesno:{title}') + + def error(self, text): + self.asked.append('error') + + dw.Dialog = Dialog + sys.modules['dialog_wrapper'] = dw + + pgc = types.ModuleType('pgsqlconf') + + class PostgreSQL: + executed = [] + + def __init__(self, database): + self.database = database + + def execute(self, sql): + PostgreSQL.executed.append((self.database, sql.decode())) + + pgc.PostgreSQL = PostgreSQL + sys.modules['pgsqlconf'] = pgc + return Dialog, PostgreSQL + + +Dialog, PostgreSQL = install_stubs() + +# import the hook (its filename shadows the odoo package, hence the copy) +_pkg = tempfile.mkdtemp() +shutil.copyfile(SRC, os.path.join(_pkg, 'hook_under_test.py')) +sys.path.insert(0, _pkg) +import hook_under_test as hook # noqa: E402 + + +class FakeSystem: + """Fake postgres/odoo/service/git/pip world.""" + + def __init__(self, databases=None, odoo_dbs=None): + self.databases = set(databases or []) + self.odoo_dbs = set(odoo_dbs if odoo_dbs is not None + else self.databases) + self.calls = [] + self.fail = {} # substring -> returncode + self.services = [] + + def result(self, rc=0, out='', err=''): + return types.SimpleNamespace(returncode=rc, stdout=out, stderr=err) + + def __call__(self, args, **kw): + self.calls.append(list(args)) + joined = ' '.join(args) + for needle, rc in self.fail.items(): + if needle in joined: + return self.result(rc, err=f'simulated failure: {needle}') + + if args[0] == 'service': + self.services.append((args[2], args[1])) + return self.result() + if args[0] in ('chown', 'chmod'): + return self.result() + if args[0] == 'pip3': + return self.result() + + if args[:3] == ['runuser', '-u', 'postgres']: + return self._postgres(args[4:]) + if args[:3] == ['runuser', '-u', 'odoo']: + return self._odoo(args[4:]) + raise AssertionError(f"unexpected command: {args}") + + def _postgres(self, args): + if args[0] == 'createdb': + name = args[-1] + if name in self.databases: + return self.result(1, err='already exists') + self.databases.add(name) + return self.result() + if args[0] == 'dropdb': + name = args[-1] + self.databases.discard(name) + self.odoo_dbs.discard(name) + return self.result() + if args[0] == 'psql': + sql = args[-1] + if 'pg_database' in sql: + name = re.search(r"datname = '([^']*)'", sql).group(1) + return self.result(out='1\n' if name in self.databases + else '\n') + if 'information_schema' in sql: + name = args[args.index('-d') + 1] + return self.result(out='1\n' if name in self.odoo_dbs + else '\n') + raise AssertionError(f"unexpected postgres command: {args}") + + def _odoo(self, args): + if args[0] == 'odoo': + db = args[args.index('--database') + 1] + self.odoo_dbs.add(db) + return self.result() + if args[0] == 'env': # git clone + dest = args[-1] + os.makedirs(dest, exist_ok=True) + return self.result() + raise AssertionError(f"unexpected odoo command: {args}") + + +class InithookTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.conf = os.path.join(self.tmp, 'odoo.conf') + with open(self.conf, 'w') as fob: + fob.write( + "[options]\n" + "admin_passwd = admin\n" + "db_name = TurnkeylinuxExample\n" + "db_password = False\n" + "addons_path = /usr/lib/python3/dist-packages/odoo/addons," + "/opt/odoo/custom-addons\n" + "limit_memory_hard = 2684354560\n") + self.addons = os.path.join(self.tmp, 'custom-addons') + hook.CONF_FILE = self.conf + hook.CUSTOM_ADDONS_DIR = self.addons + PostgreSQL.executed = [] + Dialog.script = [] + os.environ.pop('ODOO_PASS', None) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def run_hook(self, argv, system, env=None): + with mock.patch.object(sys, 'argv', ['odoo.py'] + argv), \ + mock.patch.object(hook.subprocess, 'run', system), \ + mock.patch.dict(os.environ, env or {}): + hook.main() + + def conf_value(self, key): + import configparser + cfg = configparser.ConfigParser(interpolation=None) + cfg.read(self.conf) + return cfg.get('options', key, fallback=None) + + # -- non-interactive (Hub) ------------------------------------------ + def test_preseeded_password_only_keeps_example_db(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + self.run_hook([], sysm, env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(self.conf_value('db_name'), 'TurnkeylinuxExample') + self.assertTrue( + self.conf_value('admin_passwd').startswith('$pbkdf2-sha512$')) + self.assertEqual(PostgreSQL.executed[0][0], 'TurnkeylinuxExample') + self.assertIn("login='admin'", PostgreSQL.executed[0][1]) + # no dialog was constructed, nothing dropped, nothing created + self.assertFalse(any(c[3:4] == ['createdb'] for c in sysm.calls)) + + def test_password_not_leaked_to_argv_or_subprocess_env(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + self.run_hook([], sysm, env={'ODOO_PASS': 'Secret1!'}) + self.assertNotIn('ODOO_PASS', os.environ) + for call in sysm.calls: + self.assertNotIn('Secret1!', ' '.join(call)) + + def test_preseeded_custom_db_initialized_and_example_dropped(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + self.run_hook(['--dbname=production', '--demo=no'], sysm, + env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(self.conf_value('db_name'), 'production') + self.assertIn('production', sysm.databases) + self.assertNotIn('TurnkeylinuxExample', sysm.databases) + init = [c for c in sysm.calls if 'odoo' in c and '--init' in c][0] + self.assertIn('--without-demo=all', init) + + def test_demo_yes_omits_without_demo(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + self.run_hook(['--dbname=demo1', '--demo=yes'], sysm, + env={'ODOO_PASS': 'Secret1!'}) + init = [c for c in sysm.calls if 'odoo' in c and '--init' in c][0] + self.assertNotIn('--without-demo=all', init) + + def test_rerun_without_dbname_keeps_custom_db(self): + """turnkey-init re-run must not orphan the configured database.""" + sysm = FakeSystem(['production']) + with open(self.conf, 'a') as fob: + pass + import configparser + cfg = configparser.ConfigParser(interpolation=None) + cfg.read(self.conf) + cfg.set('options', 'db_name', 'production') + with open(self.conf, 'w') as fob: + cfg.write(fob) + self.run_hook([], sysm, env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(self.conf_value('db_name'), 'production') + self.assertFalse(any('createdb' in c for c in sysm.calls)) + + def test_existing_non_odoo_db_is_refused(self): + sysm = FakeSystem(['legacy'], odoo_dbs=[]) + with self.assertRaises(SystemExit) as cm: + self.run_hook(['--dbname=legacy'], sysm, + env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(cm.exception.code, 1) + self.assertEqual(self.conf_value('db_name'), 'TurnkeylinuxExample') + + def test_failed_init_restarts_odoo_and_leaves_no_partial_db(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + sysm.fail['--init base'] = 1 + with self.assertRaises(SystemExit) as cm: + self.run_hook(['--dbname=broken'], sysm, + env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(cm.exception.code, 1) + self.assertNotIn('broken', sysm.databases) + self.assertIn(('start', 'odoo'), sysm.services) + self.assertEqual(self.conf_value('db_name'), 'TurnkeylinuxExample') + + def test_invalid_dbname_rejected(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + for bad in ['; DROP DATABASE x', '../etc', '1db', 'a b']: + with self.assertRaises(SystemExit): + self.run_hook([f'--dbname={bad}'], sysm, + env={'ODOO_PASS': 'Secret1!'}) + + def test_invalid_l10n_rejected(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + with self.assertRaises(SystemExit): + self.run_hook(['--l10n=narnia'], sysm, + env={'ODOO_PASS': 'Secret1!'}) + + def test_l10n_clone_registers_addons_path(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + self.run_hook(['--l10n=brazil'], sysm, env={'ODOO_PASS': 'Secret1!'}) + clone = [c for c in sysm.calls if 'git' in c][0] + self.assertEqual(clone[:3], ['runuser', '-u', 'odoo']) + self.assertIn('--branch', clone) + self.assertIn('18.0', clone) + self.assertIn('--', clone[clone.index('clone'):]) + self.assertIn('https://github.com/OCA/l10n-brazil', clone) + self.assertIn(os.path.join(self.addons, 'l10n-brazil'), + self.conf_value('addons_path')) + + def test_l10n_clone_failure_is_not_fatal(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + sysm.fail['git clone'] = 128 + self.run_hook(['--l10n=brazil'], sysm, env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(self.conf_value('db_name'), 'TurnkeylinuxExample') + + def test_hostile_oca_dependency_names_are_ignored(self): + """The CRITICAL finding: oca_dependencies.txt is third party.""" + sysm = FakeSystem(['TurnkeylinuxExample']) + repo = os.path.join(self.addons, 'l10n-brazil') + + real_odoo = sysm._odoo + + def odoo_with_deps(args): + res = real_odoo(args) + if args[0] == 'env' and args[-1] == repo: + with open(os.path.join(repo, 'oca_dependencies.txt'), + 'w') as fob: + fob.write('# comment\n' + '/etc/cron.d/pwn\n' + '../../../etc/cron.d/pwn2\n' + '--upload-pack=touch /tmp/pwn\n' + 'server-tools\n') + return res + + sysm._odoo = odoo_with_deps + self.run_hook(['--l10n=brazil'], sysm, env={'ODOO_PASS': 'Secret1!'}) + cloned = [c[-1] for c in sysm.calls if 'git' in c] + self.assertEqual( + sorted(cloned), + sorted([repo, os.path.join(self.addons, 'server-tools')])) + for path in self.conf_value('addons_path').split(','): + self.assertFalse(path.startswith('/etc'), path) + + def test_conf_permissions_tightened(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + self.run_hook([], sysm, env={'ODOO_PASS': 'Secret1!'}) + self.assertIn(['chmod', '0640', self.conf], sysm.calls) + self.assertIn(['chown', 'odoo:odoo', self.conf], sysm.calls) + + def test_unrelated_conf_keys_preserved(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + self.run_hook([], sysm, env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(self.conf_value('limit_memory_hard'), '2684354560') + + # -- interactive ------------------------------------------------------ + def test_interactive_full_flow(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + Dialog.script = [ + 'Secret1!', # password + 'production', # db name + True, # want l10n? + 'brazil,spain', # which + False, # demo data? -> no (production) + ] + self.run_hook([], sysm) + self.assertEqual(self.conf_value('db_name'), 'production') + init = [c for c in sysm.calls if 'odoo' in c and '--init' in c][0] + self.assertIn('--without-demo=all', init) + self.assertNotIn('TurnkeylinuxExample', sysm.databases) + self.assertEqual(Dialog.script, []) + + def test_interactive_defaults_to_configured_db(self): + sysm = FakeSystem(['production']) + import configparser + cfg = configparser.ConfigParser(interpolation=None) + cfg.read(self.conf) + cfg.set('options', 'db_name', 'production') + with open(self.conf, 'w') as fob: + cfg.write(fob) + Dialog.script = ['Secret1!', None, False] # None = accept init + self.run_hook([], sysm) + self.assertEqual(self.conf_value('db_name'), 'production') + self.assertIn('production', sysm.databases) + + def test_interactive_rejects_bad_db_name_then_accepts(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + Dialog.script = ['Secret1!', '1bad', 'good_db', False, False] + self.run_hook([], sysm) + self.assertEqual(self.conf_value('db_name'), 'good_db') + + def test_preseeded_never_prompts(self): + sysm = FakeSystem(['TurnkeylinuxExample']) + Dialog.script = [] # any dialog call raises + self.run_hook(['--dbname=production'], sysm, + env={'ODOO_PASS': 'Secret1!'}) + self.assertEqual(self.conf_value('db_name'), 'production') + + +if __name__ == '__main__': + unittest.main(verbosity=2)