From 0994b4c9d77dca4812a130b340952dea6c4fcd72 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Tue, 1 Sep 2026 12:04:27 +0200 Subject: [PATCH] Add Odoo sample app --- sample-apps/README.md | 3 + sample-apps/odoo-postgres/Dockerfile | 19 +++ sample-apps/odoo-postgres/Makefile | 30 ++++ sample-apps/odoo-postgres/README.md | 83 +++++++++++ .../addons/aikido_zen_bootstrap/__init__.py | 1 + .../aikido_zen_bootstrap/__manifest__.py | 9 ++ .../addons/aikido_zen_bootstrap/hooks.py | 10 ++ .../odoo-postgres/addons/zen_test/__init__.py | 2 + .../addons/zen_test/__manifest__.py | 12 ++ .../addons/zen_test/controllers/__init__.py | 1 + .../addons/zen_test/controllers/main.py | 141 ++++++++++++++++++ .../addons/zen_test/data/test_users.xml | 9 ++ .../addons/zen_test/models/__init__.py | 1 + .../addons/zen_test/models/side_effect.py | 8 + .../zen_test/security/ir.model.access.csv | 2 + sample-apps/odoo-postgres/config/odoo.conf | 11 ++ sample-apps/odoo-postgres/docker-compose.yml | 108 ++++++++++++++ .../scripts/initialize-database.sh | 14 ++ .../odoo-postgres/scripts/smoke-test.sh | 125 ++++++++++++++++ 19 files changed, 589 insertions(+) create mode 100644 sample-apps/odoo-postgres/Dockerfile create mode 100644 sample-apps/odoo-postgres/Makefile create mode 100644 sample-apps/odoo-postgres/README.md create mode 100644 sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__init__.py create mode 100644 sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__manifest__.py create mode 100644 sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/hooks.py create mode 100644 sample-apps/odoo-postgres/addons/zen_test/__init__.py create mode 100644 sample-apps/odoo-postgres/addons/zen_test/__manifest__.py create mode 100644 sample-apps/odoo-postgres/addons/zen_test/controllers/__init__.py create mode 100644 sample-apps/odoo-postgres/addons/zen_test/controllers/main.py create mode 100644 sample-apps/odoo-postgres/addons/zen_test/data/test_users.xml create mode 100644 sample-apps/odoo-postgres/addons/zen_test/models/__init__.py create mode 100644 sample-apps/odoo-postgres/addons/zen_test/models/side_effect.py create mode 100644 sample-apps/odoo-postgres/addons/zen_test/security/ir.model.access.csv create mode 100644 sample-apps/odoo-postgres/config/odoo.conf create mode 100644 sample-apps/odoo-postgres/docker-compose.yml create mode 100755 sample-apps/odoo-postgres/scripts/initialize-database.sh create mode 100755 sample-apps/odoo-postgres/scripts/smoke-test.sh diff --git a/sample-apps/README.md b/sample-apps/README.md index 2b688be82..73bbf4663 100644 --- a/sample-apps/README.md +++ b/sample-apps/README.md @@ -54,3 +54,6 @@ Overview : - it runs 4 processes, called workers, (**multi-process**) which handles requests using ASGI protocol - This application uses **ASGI** (Asynchronous Server Gateway Interface) - Runs on 8114. Without Aikido runs on 8115 +- `odoo-postgres/` is a real Odoo app using PostgreSQL and an installed Zen wheel. + - It supports Odoo's threaded mode and configurable prefork workers. + - Runs on 8116. Without Aikido runs on 8117. diff --git a/sample-apps/odoo-postgres/Dockerfile b/sample-apps/odoo-postgres/Dockerfile new file mode 100644 index 000000000..9995326cc --- /dev/null +++ b/sample-apps/odoo-postgres/Dockerfile @@ -0,0 +1,19 @@ +ARG ODOO_IMAGE=odoo:16.0@sha256:0f36a5002a200bb1649771c2cb9403ca5392d7ac4bb23f9ad500b339df3f5a3a +FROM ${ODOO_IMAGE} + +USER root + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends build-essential python3-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY dist/*.whl /tmp/aikido-zen/ +RUN python3 -m pip install --no-cache-dir /tmp/aikido-zen/*.whl \ + && rm -rf /tmp/aikido-zen + +COPY sample-apps/odoo-postgres/config/odoo.conf /etc/odoo/odoo.conf +COPY --chown=odoo:odoo sample-apps/odoo-postgres/addons/ /mnt/extra-addons/ +COPY sample-apps/odoo-postgres/scripts/initialize-database.sh /usr/local/bin/initialize-odoo-database +RUN chmod +x /usr/local/bin/initialize-odoo-database + +USER odoo diff --git a/sample-apps/odoo-postgres/Makefile b/sample-apps/odoo-postgres/Makefile new file mode 100644 index 000000000..f59f9f3bb --- /dev/null +++ b/sample-apps/odoo-postgres/Makefile @@ -0,0 +1,30 @@ +ROOT_DIR := ../.. +COMPOSE := docker compose + +.PHONY: build build-wheel up run runZenDisabled smoke logs down clean + +build-wheel: + $(MAKE) -C $(ROOT_DIR) build + +build: build-wheel + $(COMPOSE) build + +up: build + $(COMPOSE) up --detach --wait odoo odoo-disabled + +run: build + $(COMPOSE) up odoo + +runZenDisabled: build + $(COMPOSE) up odoo-disabled + +smoke: build + ./scripts/smoke-test.sh + +logs: + $(COMPOSE) logs --follow odoo odoo-disabled + +down: + $(COMPOSE) down --volumes --remove-orphans + +clean: down diff --git a/sample-apps/odoo-postgres/README.md b/sample-apps/odoo-postgres/README.md new file mode 100644 index 000000000..a2f3ccd1e --- /dev/null +++ b/sample-apps/odoo-postgres/README.md @@ -0,0 +1,83 @@ +# Odoo/PostgreSQL sample + +> **Warning:** This application contains intentional command- and SQL-injection vulnerabilities. Run it only in an isolated development environment. Never deploy it or expose it to an untrusted network. + +This sample runs the official Odoo server with PostgreSQL. The image installs the locally built `aikido_zen` wheel and loads Zen through the `post_load` hook of the server-wide `aikido_zen_bootstrap` addon. It does not use a source bind mount, `PYTHONSTARTUP`, a standalone WSGI application, or middleware around Odoo. + +The Compose project creates two isolated Odoo databases and data volumes: + +- `odoo` uses the Zen bootstrap and listens on port `8116`. +- `odoo-disabled` loads the same bootstrap with `AIKIDO_DISABLE=true` and listens on port `8117`. + +Database initialization installs the `zen_test` addon without loading the Zen bootstrap. The runtime processes then start `/usr/bin/odoo` with `--load=base,web,aikido_zen_bootstrap` before Odoo preloads registries or database connections. + +## Requirements + +- Docker with Compose +- GNU Make +- Poetry, as required by the repository build + +## Run the sample + +```bash +make up +curl http://localhost:8116/zen/status +curl http://localhost:8117/zen/status +make down +``` + +Use prefork mode by setting the worker count before starting the services: + +```bash +ODOO_WORKERS=2 make up +``` + +Override the host ports when necessary: + +```bash +ODOO_PORT=8216 ODOO_DISABLED_PORT=8217 make up +``` + +The default image is the official Odoo 16 image pinned to an immutable digest. A different official image can be supplied for compatibility work: + +```bash +ODOO_IMAGE=odoo:17.0 make build +``` + +## Smoke test + +```bash +make smoke +``` + +The smoke test runs fresh `workers=0` and `workers=2` environments. It verifies that PostgreSQL is healthy, both databases contain the installed test addon, the wheel is installed under `/usr/local`, `/usr/bin/odoo` is the real server process, the bootstrap runs only at runtime, form/JSON/JSON-RPC/route inputs reach controllers unchanged, the disabled service starts, and shutdown removes the project containers and volumes. + +Set `ODOO_WORKER_COUNTS` to test a subset while developing: + +```bash +ODOO_WORKER_COUNTS=0 make smoke +``` + +## Test endpoints + +| Endpoint | Input | Behavior | +| --- | --- | --- | +| `GET /zen/status` | None | Returns `ok`. | +| `GET /zen/shell/query?command=...` | Query string | Passes `command` to a shell. | +| `POST /zen/shell/form` | Form field `command` | Passes `command` to a shell. | +| `POST /zen/shell/json` | JSON field `command` | Passes `command` to a shell through an HTTP route. | +| `POST /zen/shell/jsonrpc` | JSON-RPC `params.command` | Passes `command` to a shell. | +| `GET /zen/shell/header` | `X-Command` header | Passes the header to a shell. | +| `GET /zen/shell/cookie` | `command` cookie | Passes the cookie to a shell. | +| `GET /zen/shell/route/` | Route parameter | Passes the route value to a shell. | +| `GET /zen/sql?query=...` | Query string | Executes raw SQL through `request.env.cr.execute`. | +| `GET /zen/error` | None | Raises an intentional application exception. | +| `GET /zen/stream` | None | Returns a two-chunk streaming response. | +| `GET /zen/user` | Authenticated session | Returns the stable Odoo user ID. | +| `POST /zen/request-block-side-effect` | None | Records a request-block test side effect. | +| `POST /zen/rate-limit-side-effect` | None | Records a rate-limit test side effect. | +| `GET /zen/side-effects/` | Route parameter | Returns the persisted side-effect count. | + +The installed test account is `zen-test-user` with password `zen-test-password`. It exists only for authentication lifecycle checks in this isolated sample. + +Phase 1 validates the real runtime and bootstrap only. Odoo-specific Zen request blocking is added and asserted in later phases. diff --git a/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__init__.py b/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__init__.py new file mode 100644 index 000000000..7001103db --- /dev/null +++ b/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__init__.py @@ -0,0 +1 @@ +from .hooks import post_load diff --git a/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__manifest__.py b/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__manifest__.py new file mode 100644 index 000000000..c6b76fbb1 --- /dev/null +++ b/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/__manifest__.py @@ -0,0 +1,9 @@ +{ + "name": "Aikido Zen Bootstrap", + "version": "1.0.0", + "license": "AGPL-3", + "depends": ["base"], + "post_load": "post_load", + "installable": True, + "application": False, +} diff --git a/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/hooks.py b/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/hooks.py new file mode 100644 index 000000000..e7ac4f560 --- /dev/null +++ b/sample-apps/odoo-postgres/addons/aikido_zen_bootstrap/hooks.py @@ -0,0 +1,10 @@ +import logging + +import aikido_zen + +logger = logging.getLogger(__name__) + + +def post_load(): + aikido_zen.protect() + logger.info("Aikido Zen bootstrap post_load completed") diff --git a/sample-apps/odoo-postgres/addons/zen_test/__init__.py b/sample-apps/odoo-postgres/addons/zen_test/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/sample-apps/odoo-postgres/addons/zen_test/__manifest__.py b/sample-apps/odoo-postgres/addons/zen_test/__manifest__.py new file mode 100644 index 000000000..de248db23 --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/__manifest__.py @@ -0,0 +1,12 @@ +{ + "name": "Zen Odoo Test Application", + "version": "1.0.0", + "license": "AGPL-3", + "depends": ["base", "web"], + "data": [ + "security/ir.model.access.csv", + "data/test_users.xml", + ], + "installable": True, + "application": False, +} diff --git a/sample-apps/odoo-postgres/addons/zen_test/controllers/__init__.py b/sample-apps/odoo-postgres/addons/zen_test/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/sample-apps/odoo-postgres/addons/zen_test/controllers/main.py b/sample-apps/odoo-postgres/addons/zen_test/controllers/main.py new file mode 100644 index 000000000..c836158b7 --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/controllers/main.py @@ -0,0 +1,141 @@ +import subprocess + +from odoo import http, release +from odoo.http import Response, request + +JSON_ROUTE_TYPE = "jsonrpc" if release.version_info[0] >= 19 else "json" + + +def _text_response(value, status=200): + return request.make_response( + value, + headers=[("Content-Type", "text/plain; charset=utf-8")], + status=status, + ) + + +def _execute_command(command): + subprocess.run(command, capture_output=True, check=False, shell=True, text=True) + return command + + +def _record_side_effect(name): + request.env["zen.test.side.effect"].sudo().create({"name": name}) + + +class ZenTestController(http.Controller): + @http.route("/zen/status", type="http", auth="public", methods=["GET"]) + def status(self): + return _text_response("ok") + + @http.route("/zen/shell/query", type="http", auth="public", methods=["GET"]) + def shell_query(self, command=""): + return _text_response(_execute_command(command)) + + @http.route( + "/zen/shell/form", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def shell_form(self, command=""): + return _text_response(_execute_command(command)) + + @http.route( + "/zen/shell/json", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def shell_json(self): + command = request.get_json_data().get("command", "") + return _text_response(_execute_command(command)) + + @http.route( + "/zen/shell/jsonrpc", + type=JSON_ROUTE_TYPE, + auth="public", + methods=["POST"], + csrf=False, + ) + def shell_jsonrpc(self, command=""): + return _execute_command(command) + + @http.route("/zen/shell/header", type="http", auth="public", methods=["GET"]) + def shell_header(self): + command = request.httprequest.headers.get("X-Command", "") + return _text_response(_execute_command(command)) + + @http.route("/zen/shell/cookie", type="http", auth="public", methods=["GET"]) + def shell_cookie(self): + command = request.httprequest.cookies.get("command", "") + return _text_response(_execute_command(command)) + + @http.route( + "/zen/shell/route/", + type="http", + auth="public", + methods=["GET"], + ) + def shell_route(self, command): + return _text_response(_execute_command(command)) + + @http.route("/zen/sql", type="http", auth="public", methods=["GET"]) + def unsafe_sql(self, query="SELECT 1"): + request.env.cr.execute(query) + result = request.env.cr.fetchone() if request.env.cr.description else None + return _text_response(repr(result)) + + @http.route("/zen/error", type="http", auth="public", methods=["GET"]) + def error(self): + raise RuntimeError("Intentional exception from the Zen Odoo test addon") + + @http.route("/zen/stream", type="http", auth="public", methods=["GET"]) + def stream(self): + def chunks(): + yield b"first\n" + yield b"second\n" + + return Response(chunks(), content_type="text/plain") + + @http.route("/zen/user", type="http", auth="user", methods=["GET"]) + def user(self): + return _text_response(str(request.session.uid)) + + @http.route( + "/zen/request-block-side-effect", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def request_block_side_effect(self): + _record_side_effect("request-block") + return _text_response("recorded") + + @http.route( + "/zen/rate-limit-side-effect", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def rate_limit_side_effect(self): + _record_side_effect("rate-limit") + return _text_response("recorded") + + @http.route( + "/zen/side-effects/", + type="http", + auth="public", + methods=["GET"], + ) + def side_effect_count(self, name): + count = ( + request.env["zen.test.side.effect"] + .sudo() + .search_count([("name", "=", name)]) + ) + return _text_response(str(count)) diff --git a/sample-apps/odoo-postgres/addons/zen_test/data/test_users.xml b/sample-apps/odoo-postgres/addons/zen_test/data/test_users.xml new file mode 100644 index 000000000..fec253f45 --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/data/test_users.xml @@ -0,0 +1,9 @@ + + + + Zen Test User + zen-test-user + zen-test-password + + + diff --git a/sample-apps/odoo-postgres/addons/zen_test/models/__init__.py b/sample-apps/odoo-postgres/addons/zen_test/models/__init__.py new file mode 100644 index 000000000..3ec50798a --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/models/__init__.py @@ -0,0 +1 @@ +from . import side_effect diff --git a/sample-apps/odoo-postgres/addons/zen_test/models/side_effect.py b/sample-apps/odoo-postgres/addons/zen_test/models/side_effect.py new file mode 100644 index 000000000..bf9ec0c84 --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/models/side_effect.py @@ -0,0 +1,8 @@ +from odoo import fields, models + + +class ZenTestSideEffect(models.Model): + _name = "zen.test.side.effect" + _description = "Zen Test Side Effect" + + name = fields.Char(required=True, index=True) diff --git a/sample-apps/odoo-postgres/addons/zen_test/security/ir.model.access.csv b/sample-apps/odoo-postgres/addons/zen_test/security/ir.model.access.csv new file mode 100644 index 000000000..4f753c473 --- /dev/null +++ b/sample-apps/odoo-postgres/addons/zen_test/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_zen_test_side_effect_system,zen.test.side.effect.system,model_zen_test_side_effect,base.group_system,1,1,1,1 diff --git a/sample-apps/odoo-postgres/config/odoo.conf b/sample-apps/odoo-postgres/config/odoo.conf new file mode 100644 index 000000000..28f1665f0 --- /dev/null +++ b/sample-apps/odoo-postgres/config/odoo.conf @@ -0,0 +1,11 @@ +[options] +addons_path = /usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons +data_dir = /var/lib/odoo +admin_passwd = admin +db_host = postgres +db_port = 5432 +db_user = odoo +db_password = odoo +http_port = 8069 +list_db = False +log_level = info diff --git a/sample-apps/odoo-postgres/docker-compose.yml b/sample-apps/odoo-postgres/docker-compose.yml new file mode 100644 index 000000000..d440b919d --- /dev/null +++ b/sample-apps/odoo-postgres/docker-compose.yml @@ -0,0 +1,108 @@ +x-odoo-image: &odoo-image + image: ${ODOO_SAMPLE_IMAGE:-firewall-python-odoo-sample:16} + build: + context: ../.. + dockerfile: sample-apps/odoo-postgres/Dockerfile + args: + ODOO_IMAGE: ${ODOO_IMAGE:-odoo:16.0@sha256:0f36a5002a200bb1649771c2cb9403ca5392d7ac4bb23f9ad500b339df3f5a3a} + +x-odoo-environment: &odoo-environment + HOST: postgres + PORT: 5432 + USER: odoo + PASSWORD: odoo + +services: + postgres: + image: postgres:15@sha256:9b1d34adbce1dd07ee6e94b4a2cf698884b89bd44a6c9c12f5da8f3acbfe4957 + environment: + POSTGRES_DB: postgres + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + healthcheck: + test: ["CMD-SHELL", "pg_isready --username=odoo --dbname=postgres"] + interval: 2s + timeout: 5s + retries: 30 + volumes: + - postgres-data:/var/lib/postgresql/data + + odoo-init-enabled: + <<: *odoo-image + command: ["/usr/local/bin/initialize-odoo-database"] + environment: + <<: *odoo-environment + ODOO_DATABASE: odoo_zen + depends_on: + postgres: + condition: service_healthy + volumes: + - odoo-enabled-data:/var/lib/odoo + + odoo-init-disabled: + <<: *odoo-image + command: ["/usr/local/bin/initialize-odoo-database"] + environment: + <<: *odoo-environment + ODOO_DATABASE: odoo_without_zen + depends_on: + postgres: + condition: service_healthy + volumes: + - odoo-disabled-data:/var/lib/odoo + + odoo: + <<: *odoo-image + command: + - odoo + - --config=/etc/odoo/odoo.conf + - --database=odoo_zen + - --db-filter=^odoo_zen$$ + - --load=base,web,aikido_zen_bootstrap + - --workers=${ODOO_WORKERS:-0} + environment: + <<: *odoo-environment + AIKIDO_BLOCK: "false" + AIKIDO_DEBUG: "true" + depends_on: + odoo-init-enabled: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "curl --fail --silent http://localhost:8069/zen/status >/dev/null"] + interval: 2s + timeout: 5s + retries: 60 + ports: + - "${ODOO_PORT:-8116}:8069" + volumes: + - odoo-enabled-data:/var/lib/odoo + + odoo-disabled: + <<: *odoo-image + command: + - odoo + - --config=/etc/odoo/odoo.conf + - --database=odoo_without_zen + - --db-filter=^odoo_without_zen$$ + - --load=base,web,aikido_zen_bootstrap + - --workers=${ODOO_WORKERS:-0} + environment: + <<: *odoo-environment + AIKIDO_DISABLE: "true" + depends_on: + odoo-init-disabled: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "curl --fail --silent http://localhost:8069/zen/status >/dev/null"] + interval: 2s + timeout: 5s + retries: 60 + ports: + - "${ODOO_DISABLED_PORT:-8117}:8069" + volumes: + - odoo-disabled-data:/var/lib/odoo + +volumes: + postgres-data: + odoo-enabled-data: + odoo-disabled-data: diff --git a/sample-apps/odoo-postgres/scripts/initialize-database.sh b/sample-apps/odoo-postgres/scripts/initialize-database.sh new file mode 100755 index 000000000..87cb5865a --- /dev/null +++ b/sample-apps/odoo-postgres/scripts/initialize-database.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${ODOO_DATABASE:?ODOO_DATABASE must be set}" + +exec /usr/bin/odoo \ + --config=/etc/odoo/odoo.conf \ + --database="${ODOO_DATABASE}" \ + --db-filter="^${ODOO_DATABASE}$" \ + --init=zen_test \ + --without-demo=all \ + --stop-after-init \ + --no-http \ + --load=base,web diff --git a/sample-apps/odoo-postgres/scripts/smoke-test.sh b/sample-apps/odoo-postgres/scripts/smoke-test.sh new file mode 100755 index 000000000..8ccc11551 --- /dev/null +++ b/sample-apps/odoo-postgres/scripts/smoke-test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +export COMPOSE_PROJECT_NAME="firewall-python-odoo-smoke-$$" +ODOO_PORT="${ODOO_PORT:-8116}" +ODOO_DISABLED_PORT="${ODOO_DISABLED_PORT:-8117}" +ODOO_START_TIMEOUT="${ODOO_START_TIMEOUT:-300}" +ODOO_WORKER_COUNTS="${ODOO_WORKER_COUNTS:-0 2}" + +compose() { + docker compose "$@" +} + +cleanup() { + compose down --volumes --remove-orphans >/dev/null 2>&1 || true +} + +finish() { + status=$? + if [ "$status" -ne 0 ]; then + compose logs --no-color || true + fi + cleanup + exit "$status" +} + +assert_equals() { + expected="$1" + actual="$2" + label="$3" + if [ "$actual" != "$expected" ]; then + printf 'Expected %s to be %q, got %q\n' "$label" "$expected" "$actual" >&2 + return 1 + fi +} + +assert_response() { + expected="$1" + shift + response="$(curl --fail --silent --show-error "$@")" + assert_equals "$expected" "$response" "response" +} + +assert_addon_installed() { + database="$1" + state="$( + compose exec -T postgres \ + psql --username=odoo --dbname="$database" --tuples-only --no-align \ + --command="SELECT state FROM ir_module_module WHERE name = 'zen_test'" + )" + assert_equals "installed" "$state" "zen_test state in $database" +} + +assert_clean_shutdown() { + cleanup + if [ -n "$(compose ps --all --quiet)" ]; then + printf 'Odoo smoke-test containers are still present after shutdown\n' >&2 + return 1 + fi + if [ -n "$(docker volume ls --quiet --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}")" ]; then + printf 'Odoo smoke-test volumes are still present after shutdown\n' >&2 + return 1 + fi +} + +trap finish EXIT + +for workers in $ODOO_WORKER_COUNTS; do + printf 'Starting Odoo sample with workers=%s\n' "$workers" + export ODOO_WORKERS="$workers" + compose up --detach --wait --wait-timeout "$ODOO_START_TIMEOUT" odoo odoo-disabled + + compose exec -T postgres pg_isready --username=odoo --dbname=postgres >/dev/null + compose exec -T odoo sh -c \ + "tr '\\000' ' ' /dev/null + compose exec -T odoo python3 -c \ + "from importlib.metadata import version; import aikido_zen; assert version('aikido_zen'); assert aikido_zen.__file__.startswith('/usr/local/lib/')" + + assert_addon_installed odoo_zen + assert_addon_installed odoo_without_zen + + compose logs --no-color odoo | grep -F "Aikido Zen bootstrap post_load completed" >/dev/null + if compose logs --no-color odoo-init-enabled | grep -F "Aikido Zen bootstrap post_load completed" >/dev/null; then + printf 'Zen bootstrap ran during database initialization\n' >&2 + exit 1 + fi + + assert_response "ok" "http://localhost:${ODOO_PORT}/zen/status" + assert_response "ok" "http://localhost:${ODOO_DISABLED_PORT}/zen/status" + assert_response \ + "printf form-value" \ + --request POST \ + --data-urlencode "command=printf form-value" \ + "http://localhost:${ODOO_PORT}/zen/shell/form" + assert_response \ + "printf json-value" \ + --request POST \ + --header "Content-Type: application/json" \ + --data '{"command":"printf json-value"}' \ + "http://localhost:${ODOO_PORT}/zen/shell/json" + assert_response \ + "printf route-value" \ + "http://localhost:${ODOO_PORT}/zen/shell/route/printf%20route-value" + + jsonrpc_response="$( + curl --fail --silent --show-error \ + --request POST \ + --header "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"call","params":{"command":"printf jsonrpc-value"},"id":1}' \ + "http://localhost:${ODOO_PORT}/zen/shell/jsonrpc" + )" + if [[ "$jsonrpc_response" != *"printf jsonrpc-value"* ]]; then + printf 'JSON-RPC controller changed its command argument: %s\n' "$jsonrpc_response" >&2 + exit 1 + fi + + assert_clean_shutdown + printf 'Odoo sample passed with workers=%s\n' "$workers" +done + +trap - EXIT +printf 'Odoo sample smoke test passed\n'