Files

660 lines
27 KiB
Markdown

# 10 — Odoo 20 Integration (OWL Shell + Backend Addon + JWT + Nginx + Docker)
This chapter covers the Odoo 20 integration layer in the MIT approach.
**Critical finding**: Odoo's built-in WebSocket server is **unsuitable**
for compositor-scale traffic. We need a separate aiohttp Python process
for the compositor proxy.
## Why Odoo's built-in WebSocket server is unsuitable
Odoo does **NOT** use Werkzeug's WebSocket support (Werkzeug has no
first-party WS). Instead, Odoo ships its own pure-Python RFC 6455
implementation in `addons/bus/websocket.py` (~900 LOC) running in a
**separate gevent worker process** on port 8072.
### Hard limits that disqualify it for compositor use
- **`MESSAGE_MAX_SIZE = 2**20 = 1 MB`** per frame (cannot be configured)
— too small for H.264 video frames + multiplexed channels
- **Shared PostgreSQL connection pool** — sustained WS streaming starves
other Odoo requests
- **Single gevent process** — one Python GIL for ALL Discuss/chat/presence
traffic
- `ir.websocket` only dispatches two events: `subscribe` and
`update_presence`. New event types require forking
`addons/bus/websocket.py`.
- Odoo.sh user confirmed edits to `ir.websocket` need gevent-worker restart
### Conclusion
Run a **separate aiohttp Python process** (port 8081) for the compositor
proxy. Route via Nginx for single-origin HTTPS.
## Recommended architecture
```
┌─────────────────────────────────────────────┐
│ User's Browser │
│ ┌─────────────────────────────────────────┐ │
│ │ Odoo web client + OWL Compositor Shell │ │
│ │ (ir.actions.client, target=fullscreen) │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ <canvas> + WebGL compositor lib │ │ │
│ │ │ (MIT-licensed browser compositor) │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└────────────────────┬────────────────────────┘
│
HTTPS (single origin: https://your-odoo.example.com)
session_id cookie + JWT (in JS payload)
│
┌────────────────────▼────────────────────────┐
│ Nginx (TLS termination) │
│ / → Odoo :8069 (HTTP workers) │
│ /websocket → Odoo :8072 (gevent worker) │
│ /compositor/ → Proxy :8081 (aiohttp WS) │
└─────────┬──────────────────┬─────────────────┘
│ │
┌────────────────┴─────────┐ ┌────▼───────────────────┐
│ Odoo 20 (HTTP) │ │ Odoo 20 (geevent) │
│ N preforked workers │ │ 1 process │
│ │ │ /websocket (Discuss, │
│ Controllers: │ │ chatter, presence) │
│ /web/compositor/token │ │ bus.bus notifications│
│ /web/compositor/apps │ │ (NOT for compositor!)│
│ /web/compositor/launch │ │ 1 MB cap, pgsql pool │
│ /web/compositor/terminate│ └───────────────────────┘
│ /web/compositor/audit │
│ Models: │
│ compositor.app │
│ compositor.session │
│ (mail.thread mixin) │
│ compositor.audit.log │
│ (append-only) │
│ JWT issuer (HS256 secret│
│ in ir.config_parameter)│
└────────────┬─────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ PostgreSQL (Odoo schema) │
└────────────────────────────────────────────────────┘
▲
┌────────────────────────────────────────────────┴──┐
│ Compositor Proxy (aiohttp, :8081) │
│ Async Python (asyncio + aiohttp) │
│ ┌────────────────────────────────────────────┐ │
│ │ WS server (aiohttp.web.Application) │ │
│ │ /signal, /channel, /mkfifo, /mkstemp-mmap,│ │
│ │ /read-fd, /write-fd-as-stream, │ │
│ │ /request-keyframe │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ JWT validator (pyjwt, HS256, secret env) │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ Per-session fork: pywayland + bwrap + Gst │ │
│ │ WAYLAND_DISPLAY=wayland-N, /dev/dri │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ Audit callback → Odoo /web/compositor/audit│ │
│ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
```
## Auth bridging — JWT (HS256) is recommended
### Why JWT (not session cookie or OAuth2)
- Odoo's `session_id` cookie reuse pattern is too fragile and adds Odoo round-trips per WS message
- OAuth2 is overkill — we control both Odoo and the proxy
- JWT (HS256, symmetric) is stateless — no DB lookup per frame, fast validation
### JWT payload
```json
{
"iss": "odoo.example.com",
"aud": "compositor-proxy",
"sub": 42, // Odoo user ID
"db": "production", // Odoo database name
"company_ids": [1, 3],
"scope": "compositor",
"compositor_session_id": "uuid-here",
"iat": 1736000000,
"exp": 1736000900
}
```
- 15-minute expiry
- Signed with `compositor.jwt_secret` from Odoo's `ir.config_parameter`
(shared via env var at proxy startup)
- Validated by the proxy using `pyjwt` (MIT license)
### Issuance flow
1. OWL shell calls `/web/compositor/token` (Odoo controller)
2. Odoo backend signs JWT with the shared secret
3. Returns `{ jwt, proxy_url, expires_at }` to the OWL shell
4. OWL shell passes JWT to the compositor library
5. Compositor library includes `Authorization: Bearer <jwt>` on every WebSocket connection
## Multi-tenancy
### Recommended: one compositor proxy per Odoo database
- `db` claim in JWT scopes every WS to one Odoo database
- Per-session wayland socket (`$XDG_RUNTIME_DIR/wayland-N`) isolates Wayland displays
- Per-session forked worker process (matches Greenfield's existing model)
- For SaaS with strict SLA: Docker container per tenant + bubblewrap per session
### Optional: per-tenant Docker containers
For strict isolation between tenants:
- One Docker container per Odoo database, each running its own compositor proxy
- Nginx routes based on the JWT's `db` claim
- `--device /dev/dri:/dev/dri` for GPU passthrough
- `--cpus` and `--memory` limits per container
## OWL shell integration — `ir.actions.client` with `target=fullscreen`
### Critical Odoo 20 / OWL 3.x gotcha
`useState()` is **REMOVED** in Odoo 20. Must use `proxy()` from `@odoo/owl`
instead. This is a breaking change from Odoo 19, verified by Cybrosys
Sep 2026 blog + OWL migration guide gist.
### OWL shell component (pseudocode)
```typescript
// compositor_shell/static/src/components/compositor_shell.js
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { createCompositorSession } from "@odoo/compositor/compositor_lib";
export class CompositorShell extends Component {
setup() {
this.rpc = useService("rpc");
this.notification = useService("notification");
this.state = proxy({
surfaces: new Map(), // id → { title, appId, active }
clients: new Map(),
});
this.canvasRef = useRef("canvas");
onMounted(async () => {
// 1. Get JWT from Odoo backend
const { jwt, proxy_url, expires_at } = await this.rpc("/web/compositor/token");
this.jwt = jwt;
this.proxyUrl = proxy_url;
// 2. Initialize compositor session
const session = createCompositorSession({
jwt,
proxyUrl: proxy_url,
});
this.session = session;
// 3. Wire UserShellApi events to OWL reactive state
session.userShell.events.clientCreated = (client) => {
this.state.clients.set(client.id, { id: client.id });
};
session.userShell.events.clientDestroyed = (client) => {
this.state.clients.delete(client.id);
};
session.userShell.events.surfaceCreated = (surface) => {
this.state.surfaces.set(surface.id, {
title: "", appId: "", active: false,
});
};
session.userShell.events.surfaceDestroyed = (surface) => {
this.state.surfaces.delete(surface.id);
};
session.userShell.events.surfaceTitleUpdated = (surface, title) => {
const s = this.state.surfaces.get(surface.id);
if (s) s.title = title;
};
session.userShell.events.surfaceActivationUpdated = (surface, active) => {
// Deactivate all, then activate the new one
for (const s of this.state.surfaces.values()) {
s.active = false;
}
const s = this.state.surfaces.get(surface.id);
if (s) s.active = active;
};
// NEW vs Greenfield: surface state changes
session.userShell.events.surfaceStateChanged = (surface, state) => {
const s = this.state.surfaces.get(surface.id);
if (s) Object.assign(s, state);
};
// 4. Initialize the canvas (OWL owns the lifecycle)
const canvas = this.canvasRef.el;
session.userShell.actions.initScene(() => ({
canvas,
width: canvas.clientWidth,
height: canvas.clientHeight,
}));
});
}
// User clicks app in launcher
async launchApp(appId) {
try {
const response = await this.rpc("/web/compositor/launch", {
app_id: appId,
});
// Backend returns { signal_url, pid, name }
// The compositor library handles the rest (opens WS, etc.)
} catch (e) {
this.notification.add("Failed to launch app: " + e.message, { type: "danger" });
}
}
// User clicks taskbar entry
activateSurface(surfaceId) {
this.session.userShell.actions.activateSurface({ id: surfaceId });
}
// User closes window
closeSurface(surfaceId) {
this.session.userShell.actions.closeSurface({ id: surfaceId });
}
}
CompositorShell.template = "compositor_shell.CompositorShell";
CompositorShell.target = "fullscreen"; // Odoo ir.actions.client target
```
### Odoo action registration
```xml
<!-- compositor_shell/views/ir_actions_client_views.xml -->
<record id="action_compositor_shell" model="ir.actions.client">
<field name="name">Compositor Shell</field>
<field name="tag">compositor_shell</field>
<field name="target">fullscreen</field>
</record>
```
## Backend addon structure
### Models
```python
# compositor_shell/models/compositor_app.py
from odoo import models, fields, api
class CompositorApp(models.Model):
_name = "compositor.app"
_description = "Compositor Application"
name = fields.Char(required=True, translate=True)
app_id = fields.Char(required=True, help="Unique identifier, e.g. 'gnome-calculator'")
executable = fields.Char(required=True, help="Binary path, e.g. '/usr/bin/gnome-calculator'")
icon = fields.Image()
description = fields.Text()
allowed_group_ids = fields.Many2many("res.groups", string="Allowed Groups")
memory_limit_mb = fields.Integer(default=1024, help="Max memory per app instance (MB)")
cpu_quota_percent = fields.Integer(default=200, help="CPU quota (100% = 1 core)")
active = fields.Boolean(default=True)
```
```python
# compositor_shell/models/compositor_session.py
from odoo import models, fields, api
class CompositorSession(models.Model):
_name = "compositor.session"
_description = "Compositor Session"
_inherit = ["mail.thread"]
user_id = fields.Many2one("res.users", required=True, tracking=True)
compositor_session_id = fields.Char(required=True, help="UUID")
app_ids = fields.One2many("compositor.app.instance", "session_id", string="Apps")
app_count = fields.Integer(compute="_compute_app_count")
state = fields.Selection([
("active", "Active"),
("disconnected", "Disconnected"),
("closed", "Closed"),
], default="active", tracking=True)
started_at = fields.Datetime(default=fields.Datetime.now)
ended_at = fields.Datetime()
@api.depends("app_ids")
def _compute_app_count(self):
for record in self:
record.app_count = len(record.app_ids)
```
```python
# compositor_shell/models/compositor_audit_log.py
from odoo import models, fields
class CompositorAuditLog(models.Model):
_name = "compositor.audit.log"
_description = "Compositor Audit Log (append-only)"
_order = "create_date desc"
user_id = fields.Many2one("res.users", required=True)
app_id = fields.Char(required=True)
action = fields.Selection([
("launch", "App Launched"),
("terminate", "App Terminated"),
("crash", "App Crashed"),
], required=True)
pid = fields.Integer()
exit_code = fields.Integer()
timestamp = fields.Datetime(default=fields.Datetime.now)
def write(self, vals):
raise UserError("Audit logs are append-only and cannot be modified.")
def unlink(self):
raise UserError("Audit logs are append-only and cannot be deleted.")
```
### Controllers
```python
# compositor_shell/controllers/main.py
from odoo import http
from odoo.http import request
import jwt, datetime, uuid
class CompositorController(http.Controller):
@http.route("/web/compositor/token", type="json", auth="user")
def get_token(self):
user = request.env.user
secret = request.env["ir.config_parameter"].sudo().get_param("compositor.jwt_secret")
if not secret:
raise UserError("Compositor JWT secret not configured")
compositor_session_id = str(uuid.uuid4())
payload = {
"iss": request.http.host,
"aud": "compositor-proxy",
"sub": user.id,
"db": request.db,
"company_ids": user.company_ids.ids,
"scope": "compositor",
"compositor_session_id": compositor_session_id,
"iat": datetime.datetime.utcnow(),
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
}
token = jwt.encode(payload, secret, algorithm="HS256")
# Create session record
request.env["compositor.session"].sudo().create({
"user_id": user.id,
"compositor_session_id": compositor_session_id,
})
proxy_url = request.env["ir.config_parameter"].sudo().get_param(
"compositor.proxy_url", default="http://localhost:8081"
)
return {
"jwt": token,
"proxy_url": proxy_url,
"expires_at": payload["exp"].isoformat(),
"compositor_session_id": compositor_session_id,
}
@http.route("/web/compositor/apps", type="json", auth="user")
def list_apps(self):
user = request.env.user
apps = request.env["compositor.app"].search_read(
[("allowed_group_ids", "in", user.groups_id.ids), ("active", "=", True)],
["name", "app_id", "icon", "description"],
)
return apps
@http.route("/web/compositor/launch", type="json", auth="user")
def launch_app(self, app_id):
user = request.env.user
app = request.env["compositor.app"].search([
("app_id", "=", app_id),
("allowed_group_ids", "in", user.groups_id.ids),
], limit=1)
if not app:
raise UserError("App not found or not allowed")
# Get JWT (re-issued here for the proxy call)
# ... JWT issuance ...
# Tell the compositor proxy to spawn the app
# ... HTTP call to proxy ...
# Audit log
request.env["compositor.audit.log"].sudo().create({
"user_id": user.id,
"app_id": app_id,
"action": "launch",
})
return {"signal_url": "...", "pid": 12345, "name": app.name}
@http.route("/web/compositor/audit", type="json", auth="user")
def audit_callback(self, **kwargs):
"""Called by the compositor proxy to report events."""
# Verify this is from the proxy (shared secret or JWT)
# ...
request.env["compositor.audit.log"].sudo().create({
"user_id": kwargs.get("user_id"),
"app_id": kwargs.get("app_id"),
"action": kwargs.get("action"),
"pid": kwargs.get("pid"),
"exit_code": kwargs.get("exit_code"),
})
return {"ok": True}
```
### Security
```xml
<!-- compositor_shell/security/compositor_security.xml -->
<record id="compositor_app_group_user" model="res.groups">
<field name="name">Compositor / User</field>
<field name="category_id" ref="module_category_compositor"/>
</record>
<record id="compositor_app_group_manager" model="res.groups">
<field name="name">Compositor / Manager</field>
<field name="category_id" ref="module_category_compositor"/>
<field name="implied_ids" eval="[(4, ref('compositor_app_group_user'))]"/>
</record>
<record id="compositor_app_rule_user" model="ir.rule">
<field name="name">Compositor App: User sees allowed apps only</field>
<field name="model_id" ref="model_compositor_app"/>
<field name="domain_force">["|", ("allowed_group_ids", "=", False), ("allowed_group_ids", "in", [g.id for g in user.groups_id])]</field>
<field name="groups" eval="[(4, ref('compositor_app_group_user'))]"/>
</record>
```
## Nginx configuration
```nginx
# /etc/nginx/sites-available/odoo-compositor
server {
listen 443 ssl http2;
server_name your-odoo.example.com;
ssl_certificate /etc/letsencrypt/live/your-odoo.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-odoo.example.com/privkey.pem;
# Odoo HTTP workers
location / {
proxy_pass http://127.0.0.1:8069;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Odoo gevent worker (Discuss, chat, presence — NOT compositor!)
location /websocket {
proxy_pass http://127.0.0.1:8072;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Compositor proxy (aiohttp, high-throughput WS)
location /compositor/ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# CRITICAL: disable buffering for low-latency video streaming
proxy_buffering off;
proxy_request_buffering off;
# WebSocket keepalive
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
```
## Docker Compose
```yaml
# docker-compose.yml
version: "3.8"
services:
odoo:
image: odoo:20
ports:
- "8069:8069"
- "8072:8072" # gevent
environment:
- HOST=db
- USER=odoo
- PASSWORD=odoo
volumes:
- odoo-data:/var/lib/odoo
- ./addons:/mnt/extra-addons
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=odoo
- POSTGRES_PASSWORD=odoo
volumes:
- db-data:/var/lib/postgresql/data
compositor-proxy:
build: ./compositor-proxy
ports:
- "8081:8081"
environment:
- COMPOSITOR_JWT_SECRET=${COMPOSITOR_JWT_SECRET}
- COMPOSITOR_BIND_IP=0.0.0.0
- COMPOSITOR_BIND_PORT=8081
- COMPOSITOR_ENCODER=vaapih264 # or nvh264 or openh264
- COMPOSITOR_RENDER_DEVICE=/dev/dri/renderD128
devices:
- /dev/dri:/dev/dri # GPU passthrough
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix # XWayland (if enabled)
- compositor-runtime:/run/user/1000 # XDG_RUNTIME_DIR
depends_on:
- odoo
nginx:
image: nginx:latest
ports:
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- odoo
- compositor-proxy
volumes:
odoo-data:
db-data:
compositor-runtime:
```
## Effort estimates
| Component | Days | Notes |
|---|---|---|
| Odoo addon skeleton (models + controllers + views + security) | 2 | compositor.app, compositor.session, compositor.audit.log |
| JWT issuance + audit log model | 1.5 | HS256, 15-min expiry, pyjwt |
| OWL 3 fullscreen client action + canvas + WS wiring | 2.5 | ir.actions.client with target=fullscreen, **must use proxy() not useState()** |
| Browser compositor library integration | 2 | userShell.actions.initScene(canvasCreator) |
| Compositor proxy JWT middleware + WS handshake | 1.5 | pyjwt validation |
| Audit POSTback + Nginx + Docker Compose + GPU passthrough | 2 | |
| Multi-tenant: per-DB proxy spawning + db claim scoping | 1.5 | |
| Tests (HttpCase + TransactionCase) | 2 | |
| Documentation + buffer | 3 | |
| **Total** | **~18 days** | (parallelizable to ~10 with 2 engineers) |
Critical path: **~13 working days** for single-tenant MVP, **+5 days** for
multi-tenant + bubblewrap isolation.
## File reference
### Odoo 20 backend addon
| Path | Role |
|---|---|
| `compositor_shell/__manifest__.py` | Addon manifest |
| `compositor_shell/models/compositor_app.py` | App catalog model |
| `compositor_shell/models/compositor_session.py` | Session model (mail.thread) |
| `compositor_shell/models/compositor_audit_log.py` | Append-only audit log |
| `compositor_shell/controllers/main.py` | HTTP controllers (token, apps, launch, audit) |
| `compositor_shell/security/compositor_security.xml` | Groups + rules |
| `compositor_shell/security/ir.model.access.csv` | ACLs |
| `compositor_shell/views/compositor_app_views.xml` | App management views |
| `compositor_shell/views/compositor_session_views.xml` | Session views |
| `compositor_shell/views/ir_actions_client_views.xml` | OWL action registration |
| `compositor_shell/static/src/components/compositor_shell.js` | OWL shell component |
| `compositor_shell/static/src/components/compositor_shell.xml` | OWL template |
| `compositor_shell/static/src/components/compositor_shell.scss` | Styles |
| `compositor_shell/static/src/lib/compositor_lib/` | Custom browser compositor library (clean-room TS) |
### Python compositor proxy
| Path | Role |
|---|---|
| `compositor_proxy/main.py` | HTTP + WS hub, forks per session |
| `compositor_proxy/session.py` | Top-level proxy session |
| `compositor_proxy/session_controller.py` | aiohttp WS server with 9 endpoint paths |
| `compositor_proxy/jwt_auth.py` | JWT validation (pyjwt) |
| `compositor_proxy/native_app_context.py` | Per-app state, signaling WS, kill timers, bwrap spawn |
| ... | (see `07-native-app-bridge.md` for full file list) |
### Nginx config
| Path | Role |
|---|---|
| `nginx/sites-available/odoo-compositor` | Nginx reverse proxy with WS upgrade + proxy_buffering off |
### Docker
| Path | Role |
|---|---|
| `docker-compose.yml` | Odoo + PostgreSQL + compositor-proxy + Nginx |
| `compositor-proxy/Dockerfile` | Python proxy image with GPU passthrough |