Files
one-os-dev-wayland-html5-co…/models/compositor_app.py
T
1b-agent 93a5a0a221 Initial commit: one_os_compositor Odoo 20 addon — core OWL library + backend models + JWT auth
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)
2026-10-08 08:48:46 +00:00

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."
)