Includes:
- compositor.app, compositor.session, compositor.session.app, compositor.audit.log models
- /web/compositor/{token,apps,launch,audit} HTTP controllers with JWT (HS256)
- CompositorService OWL service (reusable via useService('compositor'))
- CompositorCanvas OWL component (reusable canvas wrapper)
- Security: compositor_group_user, compositor_group_manager groups + ir.access.csv rules
- 5 demo apps pre-loaded (gnome-calculator, xterm, gtk4-demo, glxgears, featherpad)
- Audit log capturing JWT issuance + app launch events
- Stub mode: launching apps returns a stub SurfaceHandle when proxy isn't connected
- Tested against Odoo 20 + PostgreSQL 17
Author: 1bspace.com
Parent company: 1B Global
License: Proprietary (1B Space internal use)
230 lines
9.6 KiB
Python
230 lines
9.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""``compositor.app`` — Application catalog.
|
|
|
|
Each record represents one Linux application that can be launched and
|
|
displayed inside the Odoo web client.
|
|
"""
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from odoo import api, fields, models
|
|
from odoo.exceptions import ValidationError, UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CompositorApp(models.Model):
|
|
_name = "compositor.app"
|
|
_description = "1B Space Compositor Application"
|
|
_order = "name"
|
|
_rec_name = "name"
|
|
|
|
# ─── Identity ────────────────────────────────────────────────────────────
|
|
name = fields.Char(
|
|
string="Application Name",
|
|
required=True,
|
|
translate=True,
|
|
help="Human-readable name shown in the launcher.",
|
|
)
|
|
app_id = fields.Char(
|
|
string="Application ID",
|
|
required=True,
|
|
copy=False,
|
|
help="Unique identifier (e.g. 'gnome-calculator'). Used by the proxy "
|
|
"to look up the executable when the user clicks 'Launch'.",
|
|
)
|
|
description = fields.Text(
|
|
string="Description",
|
|
translate=True,
|
|
)
|
|
|
|
# ─── Executable ────────────────────────────────────────────────────────
|
|
executable = fields.Char(
|
|
string="Executable Path",
|
|
required=True,
|
|
help="Absolute path to the binary, e.g. '/usr/bin/gnome-calculator'. "
|
|
"The path must exist on the compositor proxy host, NOT on the "
|
|
"Odoo server (they may be different machines).",
|
|
)
|
|
args = fields.Char(
|
|
string="Command-line Arguments",
|
|
help="Space-separated arguments passed to the executable. "
|
|
"Use shell-safe quoting; the proxy parses via shlex.split.",
|
|
)
|
|
env_vars = fields.Text(
|
|
string="Environment Variables",
|
|
help="One VAR=value per line. Applied on top of the proxy's default env. "
|
|
"WAYLAND_DISPLAY is set automatically by the proxy.",
|
|
)
|
|
|
|
# ─── Resource limits (per-instance) ────────────────────────────────────
|
|
memory_limit_mb = fields.Integer(
|
|
string="Memory Limit (MB)",
|
|
default=1024,
|
|
required=True,
|
|
help="Maximum RSS for the sandboxed app. Enforced via systemd-run --scope "
|
|
"with MemoryMax. Default 1024 MB.",
|
|
)
|
|
cpu_quota_percent = fields.Integer(
|
|
string="CPU Quota (%)",
|
|
default=200,
|
|
required=True,
|
|
help="CPU quota. 100% = 1 core. Default 200% (2 cores). "
|
|
"Enforced via systemd-run --scope with CPUQuota.",
|
|
)
|
|
tasks_max = fields.Integer(
|
|
string="Max Tasks (PIDs)",
|
|
default=256,
|
|
required=True,
|
|
help="Maximum number of processes/threads. Default 256. "
|
|
"Enforced via systemd-run --scope with TasksMax.",
|
|
)
|
|
|
|
# ─── Sandbox flags ─────────────────────────────────────────────────────
|
|
sandbox_network = fields.Boolean(
|
|
string="Allow Network Access",
|
|
default=False,
|
|
help="If checked, the app is allowed network access (veth + nftables "
|
|
"filter). Default unchecked — most GTK apps don't need network.",
|
|
)
|
|
sandbox_gpu = fields.Boolean(
|
|
string="Allow GPU Access",
|
|
default=True,
|
|
help="If checked, the app gets read-write access to /dev/dri/renderD128. "
|
|
"Required for GL apps (glxgears, gtk4-demo).",
|
|
)
|
|
|
|
# ─── Access control ────────────────────────────────────────────────────
|
|
allowed_group_ids = fields.Many2many(
|
|
"res.groups",
|
|
"compositor_app_group_rel",
|
|
"app_id",
|
|
"gid",
|
|
string="Allowed Groups",
|
|
help="Only users in one of these groups can see and launch this app. "
|
|
"Empty = all internal users.",
|
|
)
|
|
|
|
# ─── UI ────────────────────────────────────────────────────────────────
|
|
icon = fields.Image(
|
|
string="Icon",
|
|
help="Application icon (PNG, 256x256 recommended).",
|
|
)
|
|
color = fields.Char(
|
|
string="Accent Color",
|
|
default="#6f42c1",
|
|
help="Color used for the app tile in the launcher.",
|
|
)
|
|
|
|
# ─── Lifecycle ─────────────────────────────────────────────────────────
|
|
active = fields.Boolean(
|
|
string="Active",
|
|
default=True,
|
|
help="If unchecked, the app is hidden from the launcher but not deleted.",
|
|
)
|
|
company_id = fields.Many2one(
|
|
"res.company",
|
|
string="Company",
|
|
default=lambda self: self.env.company,
|
|
)
|
|
|
|
# ─── Counts ─────────────────────────────────────────────────────────────
|
|
running_session_count = fields.Integer(
|
|
string="Currently Running",
|
|
compute="_compute_running_session_count",
|
|
help="Number of currently-active instances of this app across all sessions.",
|
|
)
|
|
|
|
@api.depends("app_id")
|
|
def _compute_running_session_count(self):
|
|
SessionAppInst = self.env["compositor.session.app"]
|
|
for app in self:
|
|
app.running_session_count = SessionAppInst.search_count([
|
|
("app_id", "=", app.id),
|
|
("state", "=", "running"),
|
|
])
|
|
|
|
# ─── Constraints ───────────────────────────────────────────────────────
|
|
@api.constrains("app_id")
|
|
def _check_app_id_unique(self):
|
|
for app in self:
|
|
if self.search_count([("app_id", "=", app.app_id), ("id", "!=", app.id)]):
|
|
raise ValidationError("Application ID must be unique.")
|
|
|
|
@api.constrains("memory_limit_mb", "cpu_quota_percent", "tasks_max")
|
|
def _check_limits(self):
|
|
for app in self:
|
|
if app.memory_limit_mb < 64:
|
|
raise ValidationError("Memory limit must be at least 64 MB.")
|
|
if app.cpu_quota_percent < 50:
|
|
raise ValidationError("CPU quota must be at least 50%.")
|
|
if app.tasks_max < 16:
|
|
raise ValidationError("Max tasks must be at least 16.")
|
|
|
|
# ─── Public methods ────────────────────────────────────────────────────
|
|
def get_launch_config(self):
|
|
"""Return the launch config dict sent to the Python compositor proxy.
|
|
|
|
Called by ``CompositorController.launch_app`` when the user clicks
|
|
"Launch" in the OWL shell. The proxy uses this to spawn the app
|
|
with bubblewrap + seccomp + systemd-run.
|
|
"""
|
|
self.ensure_one()
|
|
env_vars = {}
|
|
if self.env_vars:
|
|
for line in self.env_vars.strip().splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "=" not in line:
|
|
raise UserError(f"Invalid env var line: {line!r}")
|
|
key, _, val = line.partition("=")
|
|
env_vars[key.strip()] = val.strip()
|
|
return {
|
|
"app_id": self.app_id,
|
|
"name": self.name,
|
|
"executable": self.executable,
|
|
"args": self.args or "",
|
|
"env": env_vars,
|
|
"memory_limit_mb": self.memory_limit_mb,
|
|
"cpu_quota_percent": self.cpu_quota_percent,
|
|
"tasks_max": self.tasks_max,
|
|
"sandbox_network": self.sandbox_network,
|
|
"sandbox_gpu": self.sandbox_gpu,
|
|
}
|
|
|
|
def action_view_running_sessions(self):
|
|
"""Open the sessions view filtered to this app's running instances."""
|
|
self.ensure_one()
|
|
return {
|
|
"type": "ir.actions.act_window",
|
|
"name": f"Running {self.name} sessions",
|
|
"res_model": "compositor.session.app",
|
|
"view_mode": "list,form",
|
|
"domain": [("app_id", "=", self.id), ("state", "=", "running")],
|
|
}
|
|
|
|
def action_test_executable(self):
|
|
"""Check if the executable exists on this Odoo host.
|
|
|
|
NOTE: This is informational only — the executable must exist on the
|
|
COMPOSITOR PROXY host (which may be a different machine). For dev,
|
|
they're often the same.
|
|
"""
|
|
self.ensure_one()
|
|
if shutil.which(self.executable.split()[0]) or os.path.exists(self.executable):
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "display_notification",
|
|
"params": {
|
|
"title": "Executable found",
|
|
"message": f"{self.executable} is accessible on this host.",
|
|
"type": "success",
|
|
"sticky": False,
|
|
},
|
|
}
|
|
raise UserError(
|
|
f"Executable {self.executable!r} not found on this Odoo host. "
|
|
f"If your compositor proxy runs on a different machine, this is OK."
|
|
)
|