What an add-on author uses: routes, parameters, validation, UI tabs
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-14 09:00:00 +02:00
debian Initial import 2026-09-14 09:00:00 +02:00
src/pmx_cork_sdk Initial import 2026-09-14 09:00:00 +02:00
tests Initial import 2026-09-14 09:00:00 +02:00
.gitignore Initial import 2026-09-14 09:00:00 +02:00
LICENSE Initial import 2026-09-14 09:00:00 +02:00
pyproject.toml Initial import 2026-09-14 09:00:00 +02:00
README.md Initial import 2026-09-14 09:00:00 +02:00

pmx-cork-sdk

The library an add-on is written against. It declares routes with decorators, in the shape of PVE::RESTHandler, and carries the request-time machinery the server runs per request: parameter validation after PVE::JSONSchema (validation) and route resolution (route_source, including the RouteSource contract). The permission check and cluster forwarding are done by the runtime. Handlers are plain functions and can be called directly in tests; the decorators return the function unchanged.

Installation

The SDK ships as python3-pmx-cork-sdk, built and installed with the rest of the suite from the repository root:

make install       # the base packages, the SDK among them
make install-all   # every package

The module lands under /usr/lib/python3/dist-packages/pmx_cork_sdk. There is no venv and no pip. See docs/INSTALL.md.

For development, make dev creates a venv with --system-site-packages and installs every package editable, the foundation first so the others resolve their in-suite dependencies locally.

Quickstart

import subprocess

from pmx_cork_tasks.tools import run_command
from pmx_cork_sdk import Api

api = Api()

@api.param('node', type='string', format='pve-node', description='Cluster node')
@api.route('status', method='GET',
    permissions={'check': ['perm', '/nodes/{node}', ['Sys.Audit']]},
    proxyto='node')
def get_status(node=None, **kwargs):
    try:
        result = run_command(['/usr/bin/uptime'])
        uptime = result.stdout.strip() if result.returncode == 0 else 'unknown'
    except (subprocess.TimeoutExpired, OSError):
        uptime = 'unknown'
    return {'uptime': uptime}

@api.param('timeout', type='integer', minimum=1, maximum=3600, optional=True)
@api.param('mode', type='string', enum=['fast', 'safe'])
@api.route('config', method='POST', protected=True,
    permissions={'check': ['perm', '/', ['Sys.Modify']]})
def update_config(**kwargs):
    return {'updated': True}

API

@api.route(path, method='GET', **kwargs)

Parameter Type Meaning
path str URL path of the route. The first segment must match [a-z0-9][a-z0-9_-]*; further segments may contain {param} templates (vms/{vmid})
method str GET, POST, PUT or DELETE (default GET)
protected bool run in the root daemon (default False)
task bool run as a background task: the handler runs in a detached worker process and the request returns the task's UPID at once. Implies protected=True and needs a write method (default False)
taskid str the name of the parameter whose value becomes the UPID's id field, the object the task acts on, as PVE carries a VMID or a unit name there. The value must fit one UPID field, so declare that parameter with an enum or a pattern
description str route description
permissions dict PVE permission spec, see below
proxyto str name of the parameter holding the target node, for cluster forwarding
allowtoken bool whether API tokens may call the route (default True); False requires a login ticket, PVE's convention for auth-sensitive routes
max_body_bytes int per-route request-body cap in bytes (default None, the runtime's global cap); for a route that legitimately takes a large body, such as an upload

Checked at registration, raising RouteRegistrationError:

  • an invalid path or method
  • a duplicate route: the same method and path, where two templates that differ only in the placeholder name count as the same path
  • an unknown keyword argument, so protectd=True fails instead of being ignored
  • malformed template braces (vms/{id, a/{1})
  • task=True on a GET route, or together with protected=False
  • taskid without task=True
  • max_body_bytes that is not an int >= 1

Checked when get_routes() is called, because the @param decorators run after @route and the parameter set is not known before:

  • proxyto or taskid naming an undeclared parameter
  • a path template such as vms/{vmid} without a declared vmid
  • the same template name twice in one path
  • a duplicate @api.param(name) on one handler

The same path with different methods is allowed.

@api.param(name, **kwargs)

Declares a parameter. Stack the decorators above @api.route().

Parameter Type Meaning
type str string, integer, number, boolean or array (default string)
description str description
optional bool default False
default any default value for an optional parameter, checked against the schema
enum list allowed values
pattern str regex the whole value must match
minimum, maximum number numeric bounds
minLength, maxLength int string length bounds
items dict element schema for type='array' (its own type, format, enum and constraints); default {'type': 'string'}
minItems, maxItems int array length bounds
format str or dict a named format (pve-node, pve-vmid, ...), or a dict of per-key sub-schemas for a PVE property string
default_key str for a property string, the key a bare token maps to
title, verbose_description str documentation only, never validated against

A constraint of the wrong type (minimum='1') is refused at declaration, as is an unknown option or type. An unknown format name is refused too, with the list of known formats: CIDR, CIDRv4, CIDRv6, dns-name, email, ip, ipv4, ipv6, mac-addr, pve-bridge-id, pve-configid, pve-node, pve-storage-id, pve-vmid, each with a -list and a -opt variant.

@api.param('vmids', type='array', items={'type': 'integer'}, minItems=1)
@api.route('bulk', method='POST', protected=True,
    permissions={'check': ['perm', '/', ['Sys.Modify']]})
def bulk(vmids=None, **kwargs):
    return {'count': len(vmids)}

Unknown parameters are rejected per request by the validation module, which the runtime's dispatch pipeline calls.

Permissions

The spec follows PVE's format. This library only stores it; the check against /etc/pve/user.cfg is done by the configured auth provider (pmx-cork-access by default).

permissions={'check': ['perm', '/nodes/{node}', ['Sys.Audit']]}
permissions={'check': ['perm', '/', ['Sys.Audit', 'Sys.Modify'], {'any': True}]}
permissions={'check': ['and',
    ['perm', '/nodes/{node}', ['Sys.Audit']],
    ['perm', '/', ['Sys.Modify']],
]}
permissions={'check': ['or',
    ['perm', '/', ['Sys.Modify']],
    ['perm', '/nodes/{node}', ['Sys.Audit']],
]}
permissions={'user': 'all'}    # any authenticated user

Templates such as {vmid} are filled from the validated parameters. {node} is filled with the node that will execute the request, the proxyto target; a route without proxyto cannot use {node} in its permission path, and the runtime refuses such a route with a 500. A route that acts on a node named in its URL without forwarding there checks the privilege inside the handler.

Proxyto

proxyto='node' reads the node parameter and, when it names another cluster node, forwards the request there over HTTPS with the ticket and the CSRF token. The runtime does this, not the library.

@api.param('node', type='string', format='pve-node')
@api.route('inventory', method='GET', proxyto='node',
    permissions={'check': ['perm', '/nodes/{node}', ['Sys.Audit']]})
def get_inventory(node=None, **kwargs):
    return {'cpu': get_cpu_info()}

api.get_routes()

The registered routes keyed by (method, path). Calling it runs the cross-validation described above. The returned dicts are shallow copies: replacing a key is safe, while the permissions spec object is shared with the registry.

routes = api.get_routes()
# {('GET', 'status'): {
#     'method': 'GET', 'handler': <func>, 'protected': False, 'task': False,
#     'taskid': None, 'parameters': {'node': {'type': 'string'}},
#     'permissions': {'check': ['perm', '/nodes/{node}', ['Sys.Audit']]},
#     'proxyto': 'node', 'allowtoken': True, 'max_body_bytes': None,
#     'description': 'Get status'
# }}

Testing handlers

The decorators return the handler unchanged, so a handler can be called in a test without the runtime. Calling it directly applies no type casting, no enum, pattern, format or range check, no permission check and no forwarding; those are the runtime's, and the runtime's own tests cover them.

def test_my_addon():
    api = Api()

    @api.param('node', type='string')
    @api.route('status')
    def handler(node=None, **kwargs):
        return {'node': node}

    assert handler(node='pve1') == {'node': 'pve1'}

Utils (pmx_cork_sdk.utils)

Function Meaning
read_file(path) the file's text, stripped, or None when it cannot be read
check_binary_exists(path) whether a regular, executable file exists at the path

Subprocesses are run with pmx_cork_tasks.tools.run_command, which returns a CompletedProcess and lets TimeoutExpired and FileNotFoundError propagate. Use absolute paths: systemd's default PATH puts /usr/local/bin ahead of /usr/bin, and the daemon runs as root.

UI tabs (pmx_cork_sdk.ui)

An add-on's ui.py exports a UI_SPEC built from the tab DSL, and the web layer embeds it into the UI core it serves.

from pmx_cork_sdk.ui import UI, NativeTab

UI_SPEC = UI(tabs=[
    NativeTab('My Add-on', xtype='myAddonPanel'),
])

NativeTab(title, xtype, ...) describes a tab rendered by an ExtJS widget the add-on ships in its www/. target='node' (the default) puts the tab into the node config, target='dc' into the Datacenter config. The other options are described in docs/PLUGIN_DEVELOPMENT.md.

Errors and downloads

A handler may raise ApiError(message, status=400, errors=None) instead of returning an error dict; the server renders it into the Proxmox error envelope with that status. The return-value form is {'error': <str>} or {'error': <str>, 'status': <4xx or 5xx>}, which the pipeline and the task worker judge with pmx_cork_sdk.api.handler_error. Any other return value is the success payload.

from pmx_cork_sdk import Api, ApiError

@api.route('things/{thing_id}', method='GET', permissions={'user': 'all'})
def thing_info(thing_id='', **kwargs):
    if thing_id not in THINGS:
        raise ApiError(f'unknown thing: {thing_id}', 404)
    return THINGS[thing_id]

To send a file instead of the JSON envelope, return a DownloadResponse with either content= (bytes or str) or path= (streamed from disk), plus content_type and filename. Synchronous routes only; a task route returns a UPID.

Notes

  • protected=True routes run in the daemon as root; the others run in the proxy as www-data.
  • Permissions are checked before the handler runs and before a request is forwarded to another node.
  • Query-string values are cast to the declared type (int, float, bool).
  • root@pam passes every permission check, as in PVE.

License

AGPL-3.0-only. See LICENSE.