660 lines
27 KiB
Markdown
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 |
|