Files

27 KiB

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.

                       ┌─────────────────────────────────────────────┐
                       │              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│    │
                │  └────────────────────────────────────────────┘    │
                └────────────────────────────────────────────────────┘
  • 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

{
  "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

  • 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)

// 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

<!-- 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

# 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)
# 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)
# 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

# 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

<!-- 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

# /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

# 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