diff --git a/AGENTS.md b/AGENTS.md index 947ec62..988d926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,12 @@ Keep it short; defer deeper detail to the README and scripts. ## Start Here - Read [README.md](README.md) before changing build or release behavior. -- Treat this repo as the public base image contract for downstream Odoo images. +- Treat this repo as the base image contract for Launchplane-managed Odoo + runtimes. Keep tenant/business policy downstream, but image-owned + Launchplane runtime substrate belongs here. - Preserve `/venv`, `/opt/project`, `/opt/project/addons`, and - `/opt/extra_addons` as stable downstream layout guarantees. + `/opt/extra_addons` as stable downstream layout guarantees. Preserve + `/opt/launchplane/addons` as the image-owned runtime addon root. ## Workflow Metadata @@ -41,7 +44,9 @@ Keep it short; defer deeper detail to the README and scripts. ## Editing Guardrails -- Keep the image contract generic. Project-specific policy belongs downstream. +- Keep the image contract tenant-agnostic. Project-specific business policy + belongs downstream; shared Launchplane runtime compatibility belongs in this + image-owned substrate layer. - Prefer small, reviewable Dockerfile and script changes. - When changing helper scripts under `scripts/`, keep the downstream contract in sync with README wording and validation coverage. diff --git a/Dockerfile b/Dockerfile index 7eef644..6b85bf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -169,6 +169,7 @@ RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000,sharing=l FROM runtime-pythondeps AS runtime COPY --from=odoo-source --chown=ubuntu:ubuntu /source/odoo /odoo +COPY --chown=ubuntu:ubuntu launchplane/addons /opt/launchplane/addons COPY scripts/odoo-bin-wrapper.sh /usr/local/bin/odoo-bin-wrapper.sh COPY scripts/configure-dev-addon-paths.sh /usr/local/bin/configure-dev-addon-paths.sh COPY scripts/odoo-python-sync.sh /usr/local/bin/odoo-python-sync.sh @@ -185,14 +186,15 @@ RUN mv /odoo/odoo-bin /odoo/odoo-bin.source \ # Remove duplicate source/build trees that confuse IDE/module indexing. RUN rm -rf /odoo/build/lib -RUN install -d -o ubuntu -g ubuntu /opt/project /opt/project/addons /opt/extra_addons /volumes/addons /volumes/config /volumes/data /volumes/logs \ +RUN install -d -o ubuntu -g ubuntu /opt/project /opt/project/addons /opt/extra_addons /opt/launchplane/addons /volumes/addons /volumes/config /volumes/data /volumes/logs \ && install -o ubuntu -g ubuntu -m 0644 /dev/null /volumes/config/_generated.conf \ && su -s /bin/bash ubuntu -c "printf '[options]\n' > /volumes/config/_generated.conf" RUN ln -sf /etc/ssl/certs/ca-certificates.crt /usr/lib/ssl/cert.pem ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt ENV ODOO_RC=/volumes/config/_generated.conf -ENV ODOO_ADDONS_PATH=/opt/project/addons,/opt/extra_addons,/odoo/addons,/odoo/odoo/addons +ENV ODOO_ADDONS_PATH=/opt/project/addons,/opt/extra_addons,/opt/launchplane/addons,/odoo/addons,/odoo/odoo/addons +ENV ODOO_SERVER_WIDE_MODULES=base,web,launchplane_runtime_health ENV ODOO_DATA_DIR=/volumes/data WORKDIR /volumes diff --git a/README.md b/README.md index f8a1b7e..fb790fb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Base Odoo runtime image. This repository owns the base runtime build for Odoo 19. It compiles a deterministic runtime from the upstream Odoo source, then layers in `uv`, PostgreSQL 17 client tools, and compatibility paths used by downstream -deployment tooling. +deployment tooling. The runtime also includes the Launchplane substrate needed +by Launchplane-managed Odoo lanes. This repository provides a stable base runtime for downstream project images. @@ -29,6 +30,32 @@ restore and SSH mount workflows. - `runtime-devtools` exposes Playwright-managed Chromium through `CHROME_BIN` at `/usr/local/bin/chromium-playwright`. +## Launchplane Runtime Substrate + +The image reserves `/opt/launchplane/addons` for image-owned Odoo addons that +support Launchplane-managed runtime behavior. These addons are separate from +upstream Odoo source, downstream project addons in `/opt/project/addons`, and +shared business addons in `/opt/extra_addons`. + +`launchplane_runtime_health` is loaded as a server-wide module by default. It +exposes `GET /launchplane/health` with `auth="none"` and `save_session=False` +so Launchplane can verify the exact runtime serving a public lane without +depending on tenant database or Website state. The endpoint returns JSON shaped +like: + +```json +{"runtime_identity":null,"status":"pass"} +``` + +When `LAUNCHPLANE_RUNTIME_IDENTITY_JSON` is present and valid, the parsed object +is returned as `runtime_identity`. If the variable is present but malformed, the +endpoint returns a failing response instead of echoing raw environment text. + +Downstream runtime overrides must preserve `/opt/launchplane/addons` in the +effective addons path and `base,web,launchplane_runtime_health` in the effective +server-wide modules. `/odoo/odoo-bin` normalizes server-mode invocations to keep +those image-owned defaults present. + ## CLI Contract - `/odoo/odoo-bin` is a compatibility wrapper over upstream @@ -37,6 +64,9 @@ restore and SSH mount workflows. - Runtime defaults (`--db_host`, `--addons-path`, etc.) are injected only for server-style invocations so non-server commands keep upstream argument parsing semantics. +- Server-style invocations always include `/opt/launchplane/addons` in the + effective addons path and `launchplane_runtime_health` in the server-wide + module list while preserving `base,web`. ## Downstream Build Contract @@ -47,6 +77,8 @@ restore and SSH mount workflows. - `/opt/project` - `/opt/project/addons` - `/opt/extra_addons` +- The image reserves `/opt/launchplane/addons` for image-owned Launchplane + runtime addons. Downstream images must not replace this directory. - `odoo-python-sync.sh ` installs root lockfile-backed dependencies plus addon `requirements*.txt` and addon `pyproject.toml` dependencies into `/venv`. diff --git a/launchplane/addons/launchplane_runtime_health/__init__.py b/launchplane/addons/launchplane_runtime_health/__init__.py new file mode 100644 index 0000000..e046e49 --- /dev/null +++ b/launchplane/addons/launchplane_runtime_health/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/launchplane/addons/launchplane_runtime_health/__manifest__.py b/launchplane/addons/launchplane_runtime_health/__manifest__.py new file mode 100644 index 0000000..4711213 --- /dev/null +++ b/launchplane/addons/launchplane_runtime_health/__manifest__.py @@ -0,0 +1,14 @@ +{ + "name": "Launchplane Runtime Health", + "version": "19.0.0.1", + "category": "Technical", + "summary": "Expose Launchplane runtime identity health evidence", + "description": "Server-wide health endpoint for Launchplane-managed Odoo runtimes.", + "author": "Shiny Computers", + "maintainers": ["cbusillo"], + "depends": ["web"], + "data": [], + "installable": True, + "application": False, + "license": "LGPL-3", +} diff --git a/launchplane/addons/launchplane_runtime_health/controllers/__init__.py b/launchplane/addons/launchplane_runtime_health/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/launchplane/addons/launchplane_runtime_health/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/launchplane/addons/launchplane_runtime_health/controllers/main.py b/launchplane/addons/launchplane_runtime_health/controllers/main.py new file mode 100644 index 0000000..141c88f --- /dev/null +++ b/launchplane/addons/launchplane_runtime_health/controllers/main.py @@ -0,0 +1,45 @@ +import json +import os + +from odoo import http +from odoo.http import request + + +RUNTIME_IDENTITY_ENV_KEY = "LAUNCHPLANE_RUNTIME_IDENTITY_JSON" + + +class LaunchplaneRuntimeHealthController(http.Controller): + @http.route( + "/launchplane/health", + type="http", + auth="none", + methods=["GET"], + save_session=False, + csrf=False, + ) + def launchplane_health(self): + body: dict[str, object] = {"status": "pass", "runtime_identity": None} + raw_runtime_identity = os.environ.get(RUNTIME_IDENTITY_ENV_KEY, "").strip() + status = 200 + + if raw_runtime_identity: + try: + parsed_runtime_identity = json.loads(raw_runtime_identity) + except json.JSONDecodeError: + body = {"status": "fail", "runtime_identity_error": "malformed"} + status = 500 + else: + if isinstance(parsed_runtime_identity, dict): + body["runtime_identity"] = parsed_runtime_identity + else: + body = {"status": "fail", "runtime_identity_error": "not_object"} + status = 500 + + return request.make_response( + json.dumps(body, separators=(",", ":"), sort_keys=True), + headers=[ + ("Content-Type", "application/json"), + ("Cache-Control", "no-store"), + ], + status=status, + ) diff --git a/scripts/configure-dev-addon-paths.sh b/scripts/configure-dev-addon-paths.sh index eb3f276..e6095f3 100644 --- a/scripts/configure-dev-addon-paths.sh +++ b/scripts/configure-dev-addon-paths.sh @@ -14,6 +14,7 @@ paths = [ "/odoo", "/opt/project/addons", "/opt/extra_addons", + "/opt/launchplane/addons", ] site_packages = Path(site.getsitepackages()[0]) diff --git a/scripts/odoo-bin-wrapper.sh b/scripts/odoo-bin-wrapper.sh index 98a4795..13da97f 100755 --- a/scripts/odoo-bin-wrapper.sh +++ b/scripts/odoo-bin-wrapper.sh @@ -5,6 +5,10 @@ odoo_script="${ODOO_SOURCE_BIN:-/odoo/odoo-bin.source}" python_executable="${ODOO_WRAPPER_PYTHON:-/venv/bin/python}" arguments=("$@") inject_runtime_defaults=true +launchplane_addons_path="/opt/launchplane/addons" +launchplane_runtime_module="launchplane_runtime_health" +default_odoo_addons_path="/opt/project/addons,/opt/extra_addons,/opt/launchplane/addons,/odoo/addons,/odoo/odoo/addons" +default_server_wide_modules="base,web" # Keep subcommand semantics intact. Odoo 19 expects the subcommand as the # first token when present (for example: `odoo-bin shell ...`). @@ -34,6 +38,94 @@ argument_present() { return 1 } +normalize_comma_list_with_first_item() { + local first_item="$1" + local raw_list="$2" + local normalized_items=("$first_item") + local item trimmed existing + + IFS=',' read -ra raw_items <<<"$raw_list" + for item in "${raw_items[@]}"; do + trimmed="${item//[[:space:]]/}" + if [[ -z "$trimmed" ]] || [[ "$trimmed" == "$first_item" ]]; then + continue + fi + for existing in "${normalized_items[@]}"; do + if [[ "$existing" == "$trimmed" ]]; then + continue 2 + fi + done + normalized_items+=("$trimmed") + done + + local IFS=',' + printf '%s' "${normalized_items[*]}" +} + +normalize_server_wide_modules() { + local raw_list="$1" + local normalized_items=("base" "web") + local item trimmed existing + + IFS=',' read -ra raw_items <<<"$raw_list" + for item in "${raw_items[@]}"; do + trimmed="${item//[[:space:]]/}" + if [[ -z "$trimmed" ]] || [[ "$trimmed" == "base" ]] || [[ "$trimmed" == "web" ]] || [[ "$trimmed" == "$launchplane_runtime_module" ]]; then + continue + fi + for existing in "${normalized_items[@]}"; do + if [[ "$existing" == "$trimmed" ]]; then + continue 2 + fi + done + normalized_items+=("$trimmed") + done + normalized_items+=("$launchplane_runtime_module") + + local IFS=',' + printf '%s' "${normalized_items[*]}" +} + +ensure_option_value() { + local option_name="$1" + local default_value="$2" + local normalizer="$3" + local updated_arguments=() + local found=false + local argument value normalized_value + local index=0 + + while [[ "$index" -lt "${#arguments[@]}" ]]; do + argument="${arguments[$index]}" + if [[ "$argument" == "$option_name="* ]]; then + value="${argument#*=}" + normalized_value="$($normalizer "$value")" + updated_arguments+=("$option_name=$normalized_value") + found=true + elif [[ "$argument" == "$option_name" ]]; then + value="${arguments[$((index + 1))]:-}" + normalized_value="$($normalizer "$value")" + updated_arguments+=("$option_name" "$normalized_value") + found=true + index=$((index + 1)) + else + updated_arguments+=("$argument") + fi + index=$((index + 1)) + done + + if [[ "$found" != "true" ]]; then + normalized_value="$($normalizer "$default_value")" + arguments=("$option_name=$normalized_value" "${updated_arguments[@]}") + else + arguments=("${updated_arguments[@]}") + fi +} + +normalize_addons_path() { + normalize_comma_list_with_first_item "$launchplane_addons_path" "$1" +} + if [[ "$inject_runtime_defaults" == "true" ]]; then if ! argument_present "-c" && ! argument_present "--config"; then if [[ -f "/volumes/config/_generated.conf" ]]; then @@ -58,9 +150,8 @@ if [[ "$inject_runtime_defaults" == "true" ]]; then arguments=("--db_password=${ODOO_DB_PASSWORD}" "${arguments[@]}") fi - if [[ -n "${ODOO_ADDONS_PATH:-}" ]] && ! argument_present "--addons-path"; then - arguments=("--addons-path=${ODOO_ADDONS_PATH}" "${arguments[@]}") - fi + ensure_option_value "--addons-path" "${ODOO_ADDONS_PATH:-$default_odoo_addons_path}" normalize_addons_path + ensure_option_value "--load" "${ODOO_SERVER_WIDE_MODULES:-$default_server_wide_modules}" normalize_server_wide_modules if [[ -n "${ODOO_DATA_DIR:-}" ]] && ! argument_present "--data-dir"; then arguments=("--data-dir=${ODOO_DATA_DIR}" "${arguments[@]}") diff --git a/scripts/smoke-db-init.sh b/scripts/smoke-db-init.sh index 4a931ad..c0cedd2 100755 --- a/scripts/smoke-db-init.sh +++ b/scripts/smoke-db-init.sh @@ -6,11 +6,14 @@ image_reference="${1:?Usage: scripts/smoke-db-init.sh }" suffix="${RANDOM:-0}-$$" network_name="odoo-db-smoke-${suffix}" postgres_container="${network_name}-postgres" +odoo_container="${network_name}-odoo" db_name="odoo_smoke_${suffix//[^[:alnum:]]/_}" +health_db_name="odoo_health_${suffix//[^[:alnum:]]/_}" db_user="odoo" db_password="odoo" cleanup() { + docker rm -f "${odoo_container}" >/dev/null 2>&1 || true docker rm -f "${postgres_container}" >/dev/null 2>&1 || true docker network rm "${network_name}" >/dev/null 2>&1 || true } @@ -71,4 +74,53 @@ if [[ "${base_state}" != "installed" ]]; then exit 1 fi +runtime_identity_json="{\"schema_version\":1,\"product\":\"odoo-smoke\",\"context\":\"smoke\",\"instance\":\"testing\",\"artifact_id\":\"artifact-smoke\"}" + +docker run -d \ + --name "${odoo_container}" \ + --network "${network_name}" \ + -e ODOO_DB_HOST="${postgres_container}" \ + -e ODOO_DB_PORT=5432 \ + -e ODOO_DB_NAME="${health_db_name}" \ + -e ODOO_DB_USER="${db_user}" \ + -e ODOO_DB_PASSWORD="${db_password}" \ + -e ODOO_MASTER_PASSWORD="safe-master" \ + -e ODOO_ADMIN_PASSWORD="safe-admin" \ + -e LAUNCHPLANE_RUNTIME_IDENTITY_JSON="${runtime_identity_json}" \ + "${image_reference}" \ + odoo-bin --http-interface=0.0.0.0 --log-level=warn >/dev/null + +health_ready=false +for _ in {1..90}; do + if docker run --rm \ + --network "${network_name}" \ + curlimages/curl:8.16.0 \ + -fsS "http://${odoo_container}:8069/launchplane/health" >/dev/null 2>&1; then + health_ready=true + break + fi + sleep 2 +done + +if [[ "${health_ready}" != "true" ]]; then + docker logs "${odoo_container}" >&2 || true + echo "Launchplane health endpoint did not become ready" >&2 + exit 1 +fi + +docker run --rm \ + --network "${network_name}" \ + curlimages/curl:8.16.0 \ + -fsS "http://${odoo_container}:8069/launchplane/health" \ + | python3 -c ' +import json +import sys + +payload = json.load(sys.stdin) +assert payload["status"] == "pass", payload +runtime_identity = payload["runtime_identity"] +assert runtime_identity["product"] == "odoo-smoke", payload +assert runtime_identity["artifact_id"] == "artifact-smoke", payload +' + echo "database-backed smoke checks passed: ${image_reference}" diff --git a/scripts/smoke-devtools.sh b/scripts/smoke-devtools.sh index ddf9006..739b3dc 100755 --- a/scripts/smoke-devtools.sh +++ b/scripts/smoke-devtools.sh @@ -29,11 +29,13 @@ assert contents == [ "/odoo", "/opt/project/addons", "/opt/extra_addons", + "/opt/launchplane/addons", ], contents PY test -d /opt/project test -d /opt/project/addons test -d /opt/extra_addons +test -d /opt/launchplane/addons ' echo "devtools smoke checks passed: ${image_reference}" diff --git a/scripts/smoke-runtime.sh b/scripts/smoke-runtime.sh index ca6d98b..2d4b7f4 100755 --- a/scripts/smoke-runtime.sh +++ b/scripts/smoke-runtime.sh @@ -14,10 +14,13 @@ test -d /venv test -d /opt/project test -d /opt/project/addons test -d /opt/extra_addons +test -d /opt/launchplane/addons +test -f /opt/launchplane/addons/launchplane_runtime_health/__manifest__.py test -f /volumes/config/_generated.conf /venv/bin/python -c "import sys; assert sys.version_info[:2] == (3, 13), sys.version" /odoo/odoo-bin --help >/dev/null /odoo/odoo-bin shell --help >/dev/null +ODOO_SOURCE_BIN=/bin/true ODOO_WRAPPER_PYTHON=/bin/echo /odoo/odoo-bin --stop-after-init | grep -F -- "--load=base,web,launchplane_runtime_health" >/dev/null ' echo "runtime smoke checks passed: ${image_reference}" diff --git a/scripts/test-odoo-bin-wrapper.sh b/scripts/test-odoo-bin-wrapper.sh index 8ddb3f7..9ad4441 100755 --- a/scripts/test-odoo-bin-wrapper.sh +++ b/scripts/test-odoo-bin-wrapper.sh @@ -43,7 +43,8 @@ grep -Fx -- "--db_host=database" "${captured_arguments_file}" >/dev/null grep -Fx -- "--db_port=5432" "${captured_arguments_file}" >/dev/null grep -Fx -- "--db_user=odoo" "${captured_arguments_file}" >/dev/null grep -Fx -- "--db_password=secret" "${captured_arguments_file}" >/dev/null -grep -Fx -- "--addons-path=/opt/project/addons" "${captured_arguments_file}" >/dev/null +grep -Fx -- "--addons-path=/opt/launchplane/addons,/opt/project/addons" "${captured_arguments_file}" >/dev/null +grep -Fx -- "--load=base,web,launchplane_runtime_health" "${captured_arguments_file}" >/dev/null run_wrapper shell -d opw --no-http second_argument="$(sed -n '2p' "${captured_arguments_file}")" @@ -62,4 +63,8 @@ if ! grep -q -- "--db_host=database" "${captured_arguments_file}"; then exit 1 fi +run_wrapper --addons-path=/custom/addons,/opt/launchplane/addons --load=web,queue_job --stop-after-init +grep -Fx -- "--addons-path=/opt/launchplane/addons,/custom/addons" "${captured_arguments_file}" >/dev/null +grep -Fx -- "--load=base,web,queue_job,launchplane_runtime_health" "${captured_arguments_file}" >/dev/null + echo "odoo-bin wrapper tests passed"