- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| debian | ||
| src/pmx_cork_sdk | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
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=Truefails instead of being ignored - malformed template braces (
vms/{id,a/{1}) task=Trueon aGETroute, or together withprotected=Falsetaskidwithouttask=Truemax_body_bytesthat is not anint >= 1
Checked when get_routes() is called, because the @param decorators run
after @route and the parameter set is not known before:
proxytoortaskidnaming an undeclared parameter- a path template such as
vms/{vmid}without a declaredvmid - 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=Trueroutes run in the daemon as root; the others run in the proxy aswww-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@pampasses every permission check, as in PVE.
Related
- pmx-cork-host, the services that serve these routes
- pmx-cork-plugin-manager, the plugin system built on the runtime
- extensions, the bundled plugins
License
AGPL-3.0-only. See LICENSE.