Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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 <prod|dev>` installs root lockfile-backed dependencies
plus addon `requirements*.txt` and addon `pyproject.toml` dependencies into
`/venv`.
Expand Down
1 change: 1 addition & 0 deletions launchplane/addons/launchplane_runtime_health/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import controllers
14 changes: 14 additions & 0 deletions launchplane/addons/launchplane_runtime_health/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
45 changes: 45 additions & 0 deletions launchplane/addons/launchplane_runtime_health/controllers/main.py
Original file line number Diff line number Diff line change
@@ -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,
)
1 change: 1 addition & 0 deletions scripts/configure-dev-addon-paths.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ paths = [
"/odoo",
"/opt/project/addons",
"/opt/extra_addons",
"/opt/launchplane/addons",
]

site_packages = Path(site.getsitepackages()[0])
Expand Down
97 changes: 94 additions & 3 deletions scripts/odoo-bin-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...`).
Expand Down Expand Up @@ -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
Expand All @@ -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[@]}")
Expand Down
52 changes: 52 additions & 0 deletions scripts/smoke-db-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ image_reference="${1:?Usage: scripts/smoke-db-init.sh <image-reference>}"
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
}
Expand Down Expand Up @@ -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}"
2 changes: 2 additions & 0 deletions scripts/smoke-devtools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Loading