This guide covers everything needed to build a custom backend plugin for Waldur Site Agent. It is written for both human developers and LLM-based code generators.
Before implementing a plugin, understand how Waldur Mastermind concepts map to plugin operations.
| Waldur concept | Description | Plugin relevance |
|---|---|---|
| Offering | Service catalog entry | Config block per offering; picks backend plugin |
| Resource | Allocation from an offering | CRUD via BaseBackend; keyed by backend_id |
| Order | Create/update/terminate request | Triggers order_process mode |
| Component | Measurable dimension (CPU, RAM) | Defined in backend_components config |
| OfferingUser | User linked to an offering | Username backend generates usernames |
| accounting_type | usage, limit or one |
Metered, quota, or one-time accounting per component |
| backend_id | Resource ID on the backend | See Resource identifiers |
A resource-management plugin consists of two main classes:
BaseBackend): Orchestrates high-level operations
(create resource, collect usage, manage users).BaseClient): Handles low-level communication with
the external system (CLI commands, API calls).A separate plugin family covers username management
(AbstractUsernameManagementBackend) — see the dedicated section below.
A single distribution may register both, but the entry point groups are
distinct.
graph TB
WM[Waldur Mastermind<br/>REST API] <-->|Orders, Resources,<br/>Usage, Keys| SA[Site Agent Core<br/>Processor]
SA -->|"user_context<br/>(ssh_keys, plan_quotas)"| BE[YourBackend<br/>BaseBackend]
BE --> CL[YourClient<br/>BaseClient]
CL --> EXT[External System<br/>CLI / API]
BE -.->|backend_metadata| SA
classDef waldur fill:#1E3A8A,stroke:#3B82F6,stroke-width:2px,color:#FFFFFF
classDef core fill:#065F46,stroke:#10B981,stroke-width:2px,color:#FFFFFF
classDef plugin fill:#581C87,stroke:#8B5CF6,stroke-width:2px,color:#FFFFFF
classDef external fill:#92400E,stroke:#F59E0B,stroke-width:2px,color:#FFFFFF
class WM waldur
class SA core
class BE,CL plugin
class EXT external
This is the step-by-step path for a developer building a new backend plugin from scratch. It links out to the detailed reference sections below for the specifics of each step.
Study a reference plugin. Pick the closest match to your target
system: plugins/slurm/ for a CLI-based backend, plugins/mup/ for
an HTTP API-based one, or plugins/waldur/ if you need async orders
or federation-style hooks. Read its backend.py, client.py, and
tests/ together — the tests show the expected call shapes.
Scaffold the package from the template. Copy docs/plugin-template/
to a new directory (e.g. plugins/mycustom/) and rename the package
(waldur_site_agent_mycustom → waldur_site_agent_<yours>) and the
class names throughout.
Design the config. Decide what goes in backend_settings (backend
behaviour, e.g. account prefixes) versus backend_components (billable
dimensions, e.g. cpu/storage). See the annotated YAML configuration below, and pick unit_factor values
carefully — see Common pitfalls.
Implement BaseClient. This is the thin layer that talks to the
external system. Start with get_resource, create_resource,
delete_resource, and list_resources — see the BaseClient method
reference.
Implement BaseBackend. Call super().__init__(backend_settings,
backend_components) first, then implement the abstract methods in
this order: ping, _pre_create_resource, _collect_resource_limits,
_get_usage_report. Fill in the remaining abstract methods, and only
override hook methods (post_create_resource, _pre_delete_resource,
etc.) if your backend needs the extra behaviour — see the BaseBackend
method reference and Agent mode method
matrix for what each mode calls.
Opt into capability flags as needed. supports_decreasing_usage,
supports_async_orders, supports_user_homedirs,
supports_periodic_settings, handled_resource_states, etc. — see
Capability flags and class attributes.
Leave them at their defaults unless your backend actually needs the
behaviour change. If your system assigns its own resource IDs, read
Resource identifiers before writing any create
code, and decide which failures should mark a resource ERRED — see
Error handling and ERRED.
Add a username management backend, if needed. Only required if
your system needs its own username generation/lookup logic instead of
the shared base backend — see Username management
backends.
Register entry points in pyproject.toml. At minimum
waldur_site_agent.backends; add username_management_backends,
component_schemas, or backend_settings_schemas groups as needed —
see Entry point registration.
Write tests. Mock BaseClient, and assert the usage-report shape,
the limit-conversion math, and error handling per mode — see Testing
guidance.
Install and verify.
uv sync --all-packages
cd plugins/mycustom && uv run pytest tests/
uv run waldur_site_diagnostics # confirms the entry point resolves
uvx prek run --all-files
Plugin tests only resolve their entry points when run from inside the
plugin directory — see the “Run plugin tests from inside the plugin
dir” gotcha in the repo’s CLAUDE.md.
Wire it into a config file. Add an offerings entry pointing
order_processing_backend / reporting_backend /
membership_sync_backend at your entry point name, and run the agent
against a real or staging offering to confirm end-to-end behaviour.
Before opening a merge request, skim Common pitfalls once more — most plugin review comments trace back to one of those six.
ping(raise_exception: bool = False) -> boolFalse.diagnostics() -> boolTrue.list_components() -> list[str][]._get_usage_report(resource_backend_ids: list[str]) -> dictreport, membership_sync{
"resource_backend_id_1": {
"TOTAL_ACCOUNT_USAGE": {"cpu": 1000, "mem": 2048},
"user1": {"cpu": 500, "mem": 1024},
"user2": {"cpu": 500, "mem": 1024},
}
}
backend_components config keys.unit_factor conversion).TOTAL_ACCOUNT_USAGE is required and must equal the sum of per-user values.{}._collect_resource_limits(waldur_resource) -> tuple[dict, dict]order_process (resource creation)(backend_limits, waldur_limits) where backend_limits has
values multiplied by unit_factor.({}, {})._pre_create_resource(waldur_resource, user_context=None) -> Noneorder_process (resource creation)user_context contains pre-resolved data: ssh_keys (UUID → public key),
plan_quotas (component → value), team, offering_users._on_backend_resource_created.pass.downscale_resource(resource_backend_id: str) -> boolmembership_syncTrue.pause_resource(resource_backend_id: str) -> boolmembership_syncTrue.restore_resource(resource_backend_id: str) -> boolmembership_syncTrue.get_resource_metadata(resource_backend_id: str) -> dictmembership_sync{}.These have default implementations in BaseBackend. Override only when your
backend needs custom behavior.
| Method | Default | When to override |
|---|---|---|
_on_backend_resource_created |
No-op | Create what is addressed by the backend ID later (group, directory, QoS) |
post_create_resource |
No-op | Post-creation setup; set resource.backend_metadata to push data to Waldur |
_pre_delete_resource |
No-op | Pre-deletion cleanup (cancel jobs) |
post_delete_resource |
No-op | Post-deletion cleanup (e.g., remove child offerings linked to the resource) |
_pre_delete_user_actions |
No-op | Per-user cleanup before removal |
process_existing_users |
No-op | Process existing users (homedirs) |
check_pending_order |
Returns True |
Non-blocking order creation (see below) |
evaluate_pending_order |
Returns ACCEPT |
Custom approval logic for pending orders (see below) |
setup_target_event_subscriptions |
Returns [] |
STOMP subscriptions to target systems |
get_usage_report_for_period |
Returns {} |
Historical usage queries for past billing periods |
get_daily_usage_report_for_period |
Returns {} |
A month’s usage per UTC day (see supports_daily_usage) |
has_prepaid_components |
Returns False |
Enable duration-aware limit calculation for prepaid billing |
sync_resource_end_date |
No-op | Synchronise end_date between source and target Waldur instances |
sync_project_end_date |
No-op | Synchronise the project’s end_date (receives the pre-fetched source project) |
sync_resource_limits |
Pulls backend limits into Waldur | Change the limits source of truth |
sync_resource_effective_id |
No-op | Reflect downstream backend_id as effective_id on the source resource |
sync_resource_project |
No-op | Push project metadata to backends that manage their own projects |
sync_resource_key_assignees |
No-op | Act on API key assignees (see requires_resource_key_assignees) |
update_user_attributes |
No-op | Forward OFFERING_USER attribute updates to the backend |
sync_offering_user_usernames |
Returns False |
Pull backend-assigned usernames into Waldur (federation) |
create_user_homedirs |
Provided | Customise homedir quota or path logic (see supports_user_homedirs) |
apply_periodic_settings |
Reports failure | Apply periodic usage-policy settings (see supports_periodic_settings) |
run_preflight |
ping(), else BackendNotReadyError |
Probe endpoints before an order cycle |
create_resource_with_id |
Creates under the given id | Required if the backend assigns ids (see below) |
recreate_missing_resource |
Re-creates under the old id | Return False if ids cannot be reused (see below) |
remote_waldur_base_urls |
Returns [] |
Waldur-to-Waldur: return the target’s base URL (see below) |
expects_target_event_subscriptions |
Returns False |
True if you override setup_target_event_subscriptions |
For create_resource_with_id, recreate_missing_resource and
remote_waldur_base_urls see Resource identifiers and
Error handling and ERRED. sync_resource_limits backs
the waldur_sync_resource_limits command. expects_target_event_subscriptions lets the
event-mode watchdog retry a missing target subscription.
These hooks receive the agent’s waldur_rest_client: evaluate_pending_order,
sync_resource_end_date, sync_project_end_date, sync_resource_limits,
sync_resource_effective_id and sync_offering_user_usernames, and on username
backends reconcile_offering, reconcile_project_groups and release_users.
No other method does — sync_resource_project and sync_user_profiles included.
Backends that create resources via remote APIs can use non-blocking order
creation. Instead of blocking until the remote operation completes, the backend
returns immediately with a pending_order_id in BackendResourceInfo.
To opt in, set the supports_async_orders class attribute to True. The
processor only inspects order.backend_id for async tracking when this flag
is enabled, which prevents conflicts with external systems (e.g. SharePoint)
that may set order.backend_id for unrelated purposes.
class MyAsyncBackend(BaseBackend):
supports_async_orders = True
When enabled, the core processor:
backend_id to the pending_order_idEXECUTING statecheck_pending_order(backend_id) to check completioncheck_pending_order() returns True, marks the source order as DONEBackends that opt in will typically also override handled_resource_states
to include ResourceState.CREATING so that user/limit sync runs while the
remote order is still in flight.
check_pending_order(order_backend_id: str) -> boolTrue (no async orders, always “complete”)True if the remote order completed, False if still pendingBackendError if the remote order failed or was cancelledExample (Waldur federation plugin):
def check_pending_order(self, order_backend_id: str) -> bool:
target_order = self.client.get_order(UUID(order_backend_id))
if target_order.state == OrderState.DONE:
return True
if target_order.state in {OrderState.ERRED, OrderState.CANCELED}:
raise BackendError(f"Target order failed: {target_order.state}")
return False # Still pending
setup_target_event_subscriptions(source_offering, user_agent, global_proxy) -> list[] (no target subscriptions)StompConsumer tuples for lifecycle managementevent_process mode during STOMP setupWhen an order arrives in PENDING_PROVIDER state, the agent calls
evaluate_pending_order on the backend before taking any action. The
default implementation returns ACCEPT, which preserves the existing
auto-approve behaviour. Override this method to implement custom
approval logic.
evaluate_pending_order(order, waldur_rest_client) -> PendingOrderDecisionPendingOrderDecision.ACCEPTorder (OrderDetails) — full order data including project_uuid,
customer_uuid, created_by_*, attributes, and
consumer_message / provider_message fields.waldur_rest_client (AuthenticatedClient) — authenticated client
for fetching additional data from the Waldur API (e.g., project
members and roles).PendingOrderDecision.ACCEPT — approve the orderPendingOrderDecision.REJECT — reject the orderPendingOrderDecision.PENDING — keep waiting; the order will be
re-evaluated on the next polling cycleNote: Only a few other hooks receive
waldur_rest_client; see the list under Hook methods. Resource lifecycle methods get Waldur data viauser_contextinstead.
| Scenario | Approach |
|---|---|
| Wait for a PI | Query project members, return PENDING until a PI role exists |
| Reject unprocessable orders | Inspect order.attributes, return REJECT |
| Require a signed agreement | Set provider_message, return PENDING until consumer_message is set |
from waldur_api_client.api.marketplace_provider_resources import (
marketplace_provider_resources_team_list,
)
from waldur_site_agent.backend.backends import BaseBackend, PendingOrderDecision
class MyBackend(BaseBackend):
def evaluate_pending_order(self, order, waldur_rest_client):
team = marketplace_provider_resources_team_list.sync(
client=waldur_rest_client,
uuid=order.marketplace_resource_uuid.hex,
)
has_pi = any(
member.role_name == "PI" for member in (team or [])
)
if not has_pi:
return PendingOrderDecision.PENDING
return PendingOrderDecision.ACCEPT
from waldur_site_agent.backend.backends import BaseBackend, PendingOrderDecision
class MyBackend(BaseBackend):
def evaluate_pending_order(self, order, waldur_rest_client):
attrs = getattr(order, "attributes", None) or {}
if not attrs.get("project_justification"):
return PendingOrderDecision.REJECT
return PendingOrderDecision.ACCEPT
Username management is handled by a separate plugin family inheriting
from AbstractUsernameManagementBackend (defined in
waldur_site_agent/backend/backends.py). These backends generate or look
up local-IDP usernames for OfferingUser records and are wired in the
config via username_management_backend.
| Method | Purpose |
|---|---|
generate_username(offering_user) -> str |
Create a new local username. Return "" if generation is not supported. |
get_username(offering_user) -> Optional[str] |
Look up the existing local username for the user, or None. |
get_or_create_username is provided by the base class and calls
get_username first, then generate_username only if no username is
found.
| Method | Default | When to override |
|---|---|---|
sync_user_profiles(offering_users) |
No-op | Push user profiles to the IDP before membership sync runs |
deactivate_users(usernames) |
No-op | Remove departed users from the external system |
reconcile_offering(waldur_rest_client) |
No-op | Offering-wide reconcile, once per periodic cycle |
reconciles_project_groups() |
Returns False |
The backend writes the provider’s project groups |
reconcile_project_groups(waldur_rest_client) |
No-op | Write project groups on project-group events |
release_users(offering_users, waldur_rest_client) |
No-op | Release accounts of offering users whose access ended |
reconcile_offering runs even when the offering has no offering users, so
state that must be cleaned up after the last account is gone has a place.reconciles_project_groups() returns True, event mode subscribes the
offering to project-group events and calls reconcile_project_groups on them.
It must tolerate running concurrently with reconcile_offering.release_users is called after the users’ associations were removed. Raise
on failure, so core does not acknowledge the deletion to Waldur.reconcile_offering, reconcile_project_groups and release_users receive
the agent’s waldur_rest_client so the backend can ask Waldur before acting
(for example, whether the person still holds another project).
is_username_authoritativeDefaults to True: the backend mints usernames and the value it returns is
written back to Waldur. Set it to False (or override as a property) for
backends where Waldur is the source of truth; core then skips username
generation and the write-back for that offering.
When username generation requires the user to take an action (e.g. link
an existing IdP account, complete a validation form), raise one of these
exceptions from waldur_site_agent.backend.exceptions:
OfferingUserAccountLinkingRequiredError(comment, comment_url=None) —
user must link an existing account.OfferingUserAdditionalValidationRequiredError(comment, comment_url=None) —
additional validation is required.The processor moves the offering user into a PENDING_ACCOUNT_LINKING /
PENDING_ADDITIONAL_VALIDATION state and surfaces the comment (and URL)
to the operator. See docs/offering-users.md for the full state machine.
Register username management plugins under a different entry point group than resource backends:
[project.entry-points."waldur_site_agent.username_management_backends"]
mycustom = "waldur_site_agent_mycustom.username_backend:MyCustomUsernameBackend"
The fallback entry point name is base, provided by the
waldur-site-agent-basic-username-management plugin.
Implement every method below. They are marked @abstractmethod, but
BaseClient is a plain class rather than an abc.ABC, so the marker is not
enforced: a client missing a method still instantiates, and the inherited
stub returns None when called. The core and BaseBackend call all of them.
| Method | Signature | Purpose |
|---|---|---|
list_resources |
() -> list[ClientResource] |
List all resources on backend |
get_resource |
(resource_id) -> ClientResource or None |
Get single resource or None |
create_resource |
(name, description, organization, parent_name=None) -> str |
Create resource |
delete_resource |
(name) -> str |
Delete resource |
set_resource_limits |
(resource_id, limits_dict) -> str or None |
Set limits (backend units) |
get_resource_limits |
(resource_id) -> dict[str, int] |
Get limits (backend units) |
get_resource_user_limits |
(resource_id) -> dict[str, dict[str, int]] |
Per-user limits |
set_resource_user_limits |
(resource_id, username, limits_dict) -> str |
Set per-user limits |
get_association |
(user, resource_id) -> Association or None |
Check user-resource link |
create_association |
(username, resource_id, default_account=None) -> str |
Create user-resource link |
delete_association |
(username, resource_id) -> str |
Remove user-resource link |
get_usage_report |
(resource_ids, timezone=None) -> list |
Raw usage data from backend |
list_resource_users |
(resource_id) -> list[str] |
List usernames for resource |
Important: BaseClient also provides:
execute_command(command, silent=False) for running CLI commands with
error handling — use it for CLI-based backends.create_linux_user_homedir(username, umask="") which shells out to
/sbin/mkhomedir_helper. Override only if your backend creates home
directories some other way; otherwise it works as-is for SLURM-style
Linux deployments.This table shows which BaseBackend methods are called by each agent mode.
| Method | order_process | report | membership_sync | event_process |
|---|---|---|---|---|
ping (via run_preflight) |
each cycle ¹ | - | - | each order cycle ¹ |
create_resource_with_id |
CREATE order | - | - | CREATE event |
recreate_missing_resource |
- | - | - | forced resource sync (OFFERING_RESOURCES_SYNC event) |
_pre_create_resource |
CREATE order | - | - | CREATE event |
post_create_resource |
CREATE order | - | - | CREATE event |
_collect_resource_limits |
CREATE order | - | - | CREATE event |
check_pending_order |
CREATE order (async) | - | - | CREATE event (async) |
evaluate_pending_order |
pending-provider orders | - | - | - |
set_resource_limits |
UPDATE order | - | - | UPDATE event |
delete_resource |
TERMINATE order | - | - | TERMINATE event |
_pre_delete_resource |
TERMINATE order | - | - | TERMINATE event |
pull_resource / pull_resources |
CREATE order | usage pull | sync cycle | various events |
_get_usage_report |
- | usage pull | sync cycle | - |
add_users_to_resource |
post-create | - | user sync | role events |
remove_users_from_resource |
- | - | user sync | role events |
add_user / remove_user |
- | - | role changes | role events |
downscale_resource |
- | - | status sync | - |
pause_resource |
- | - | status sync | - |
restore_resource |
- | - | status sync | - |
get_resource_metadata |
- | - | status sync | - |
setup_target_event_subscriptions |
- | - | - | STOMP setup |
list_resources |
- | import | - | import event |
get_resource_limits |
- | import | - | import event |
get_resource_user_limits |
- | - | limits sync | - |
set_resource_user_limits |
- | - | limits sync | - |
process_existing_users |
- | - | user sync | - |
¹ Only for backends with supports_cycle_preflight = True.
add_user and remove_user share one contract: return True when the user is
associated (or removed) afterwards, False only when there was nothing to do
(blank username, feature disabled), and raise BackendError when the backend
refused. A failure folded into False reads as a skip and is never reported, so
nobody learns the user is missing.
Raise UserNotProvisionedError (a BackendError subclass) when the user does not
exist in the identity provider yet, e.g. no first Keycloak sign-in. The base
add_users_to_resource skips such users and retries them on the next pass.
The base add_users_to_resource / remove_users_from_resource try every user
and record each failure with its cause. Membership sync logs them at ERROR (the
users, capped at ten, and the first cause) and retries them on the next pass.
The resource state is not changed and last_sync is still refreshed: one
member’s problem is not a failure of an allocation that works for everyone else.
A backend that overrides these bulk methods reports nothing unless it returns
AddedUsers / RemovedUsers with failed filled in.
The _get_usage_report method must return data in this exact structure:
{
"<resource_backend_id>": {
"TOTAL_ACCOUNT_USAGE": {
"<component_key>": <int_value>, # Sum of all per-user values
...
},
"<username_1>": {
"<component_key>": <int_value>,
...
},
"<username_2>": {
"<component_key>": <int_value>,
...
},
},
"<another_resource_backend_id>": { ... },
}
backend_components YAML config.unit_factor).TOTAL_ACCOUNT_USAGE is a required key and must equal the sum of all
per-user values for each component.{"TOTAL_ACCOUNT_USAGE": {"cpu": 0, "mem": 0, ...}}.{} (empty dict).Given config:
backend_components:
cpu:
unit_factor: 60000
measured_unit: "k-Hours"
mem:
unit_factor: 61440
measured_unit: "gb-Hours"
If SLURM reports 120000 cpu-minutes and 122880 MB-minutes for user1:
{
"hpc_my_allocation": {
"TOTAL_ACCOUNT_USAGE": {"cpu": 2, "mem": 2},
"user1": {"cpu": 2, "mem": 2},
}
}
Calculation: 120000 / 60000 = 2, 122880 / 61440 = 2.
BaseBackend exposes class-level capability flags that change how
the core processor treats your backend.
supports_decreasing_usage: bool = FalseSet to True if usage values can decrease between reports (e.g., a
storage backend reporting current disk usage rather than accumulated
compute time).
class MyStorageBackend(BaseBackend):
supports_decreasing_usage = True
When False (default), the reporting processor skips updates where the
new usage value is lower than the previously reported value, treating it
as a data anomaly.
supports_cycle_preflight: bool = FalseSet to True for backends that call a remote API during order processing.
The order processor runs run_preflight() once per offering per cycle
before listing orders. The default implementation calls ping() and raises
BackendNotReadyError on failure so orders stay pending instead of ERRED.
Override run_preflight() to probe specific endpoints. Opt in from plugins
such as Waldur federation and other HTTP backends can enable it when needed.
supports_async_orders: bool = FalseSet to True for backends that complete order creation asynchronously
on a remote system and report progress via pending_order_id. See the
“Non-blocking order creation” section above for the full flow.
class MyAsyncBackend(BaseBackend):
supports_async_orders = True
Three flags cover API keys that a resource hands to its consumers, such as gateway keys or S3 access keys. The agent mints every key. It applies the key to the backend first and reports it to Waldur second, so Waldur only stores a key the backend already accepts.
| Flag | What the backend implements |
|---|---|
supports_resource_api_keys |
generate_resource_keys at provisioning, and rotate_resource_key |
supports_resource_api_key_lifecycle |
Single keys: mint_, pause_, resume_, delete_, update_resource_key |
supports_resource_api_key_usage |
Reporting backend: get_resource_key_usage_report |
supports_spending_limit_periods |
The per-key methods also take spending_limits, {component: {period: amount}} |
supports_daily_usage on a reporting backend means it reports usage per UTC day:
get_daily_usage_report_for_period, and get_resource_key_daily_usage_report per key. A
Waldur with spending limits then gets usage-based components as daily_usages, every day of
the month so far with zeros filled in, which it needs to check day and week limits. A Waldur
without them gets month totals as before.
Each per-key method changes the backend and returns. The core then sends
Waldur the acknowledgement for that command: set_key, set_paused,
set_ok or set_deleted. If the method raises, the core reports
set_erred instead. The methods must be safe to call again after they
succeeded, because the agent replays a command whose acknowledgement never
reached Waldur.
Key commands are not orders, but the agent picks them up alongside orders,
and a plugin does not choose how. order_process mode applies every pending
command on each cycle, after the orders. event_process mode applies each
command as its STOMP message arrives. Its reconciliation sweep replays a
command left pending for 30 minutes, in case the message was lost.
pause_resource and restore_resource run around
it. The membership sync calls restore_resource on every cycle for a
resource that is not paused.mint_resource_key receives every client_id
Waldur holds for the resource, including those of deleted keys. Waldur
attributes usage by client_id and refuses one that was already used.allowed_models is ["*"] for every model, [] for
no model, or a list of names; None, which a Waldur older than ["*"]
sends, also means every model. An empty list is a restriction like any
other: a backend without per-key model rules refuses it too, or Waldur
would show “No models” for a key that can call all of them. The list is the
key’s effective one: Waldur narrows the key’s own list by its resource’s
model selection, in commands and in the listing’s effective_models, which
is what the reconciliation sweep replays.Before mint_resource_key, rotate_resource_key, resume_resource_key and
update_resource_key, the core reads the resource from Waldur and calls
pause_resource, downscale_resource or restore_resource on the same
backend instance, so a backend that cannot tell its resource’s pause from its
keys alone (all of them paused on their own, or none left) still knows it.
Those three methods therefore run once per such command, and must be cheap
and safe to repeat.
A backend without supports_resource_api_key_lifecycle keeps generation and
rotation only. The core answers any other command for its keys with
set_erred. ceph-s3 is such a backend: its access key rotates together with
the secret, and pausing a key there is a no-op.
Per-key usage is reported only when the offering sets the Waldur plugin
option enable_api_key_provisioning. The report maps each resource to
{client_id: {component: usage}}, and those figures must add up to the
resource total. Map a resource to None when its usage cannot be attributed
to keys; the core then skips per-key reporting for that resource. Waldur
pauses a key once its reported usage reaches the key’s limit.
At provisioning, the core asks generate_resource_keys for two keys, so one
keeps working while the other is rotated. It asks for none when the offering
sets enable_api_key_provisioning and the backend declares
supports_resource_api_key_lifecycle: users then request keys one at a time
through mint_resource_key. generate_resource_keys must therefore accept
count=0. prune_unknown_resource_keys runs before it either way. A restored
resource follows the same rule, and existing resources are never topped up or
trimmed when the option changes.
supports_user_homedirs: bool = FalseSet to True for backends that can create POSIX home directories for
their users. The standalone waldur_site_create_homedirs command iterates
over every configured offering and calls create_user_homedirs on each
backend that declares support — the gate is the capability, not the
backend type. The per-offering enable_user_homedir_account_creation
setting can still opt out.
class MyHpcBackend(BaseBackend):
supports_user_homedirs = True
A backend that opts in should also inherit HomedirSettingsSchema from
waldur_site_agent.common.plugin_schemas in its settings schema, so
enable_user_homedir_account_creation, default_homedir_umask,
homedir_base_path and homedir_quota validate identically everywhere:
from waldur_site_agent.common.plugin_schemas import HomedirSettingsSchema
class MyHpcBackendSettingsSchema(HomedirSettingsSchema):
model_config = ConfigDict(extra="allow")
SLURM declares the flag today. Leave it False for API-only backends: the
inherited create_linux_user_homedir shells out to the local
/sbin/mkhomedir_helper, which is meaningless where the users have no
account on the agent’s host.
Note that enabling the flag on an existing backend is a behaviour change for
its deployments — enable_user_homedir_account_creation defaults to True,
so operators already running waldur_site_create_homedirs will start getting
home directories with no config change of their own. Land it with a release
note.
supports_periodic_settings: bool = FalseSet to True for backends that implement apply_periodic_settings, the
hook Waldur calls with periodic usage-policy settings (fairshare, limits,
usage resets). Override the method alongside the flag:
class MyBackend(BaseBackend):
supports_periodic_settings = True
def apply_periodic_settings(self, resource_id, settings, config=None):
...
return {"success": True, "commands_executed": [...]}
The return value is relayed to Waldur’s report-command-result endpoint, so
it must carry a success key and, on failure, an error message. The
default implementation returns an explicit failure — an unsupported backend
still reports a verdict rather than leaving the policy without an answer.
handled_resource_states: list = [ResourceState.OK, ResourceState.ERRED]Controls which resource states the membership processor fetches and
processes. Override when your backend needs to manage users on resources
that are still being provisioned (e.g., async backends that include
CREATING).
from waldur_api_client.models.resource_state import ResourceState
class MyAsyncBackend(BaseBackend):
handled_resource_states = [ResourceState.OK, ResourceState.ERRED, ResourceState.CREATING]
team_fetch_attempts: int = 1 and team_fetch_delay: float = 3.0Control retry behaviour when _fetch_user_context_for_resource gets back
an empty team list. The retry only fires on a completely empty response —
it covers the race where a create-order event arrives before Waldur has
committed the first membership row to the database.
The default of 1 (no retries) is correct for synchronous backends where
the Waldur API is always consistent by the time the agent reads it. Raise
team_fetch_attempts for backends that receive work over STOMP, where the
event and the membership row may arrive out of order:
class MyStompBackend(BaseBackend):
team_fetch_attempts = 4 # retry up to 4 times
team_fetch_delay = 3.0 # seconds between retries
These are backend class attributes, not YAML configuration keys. They cannot be set per-offering in the configuration file.
requires_source_project: bool = FalseSet to True for backends that mirror project metadata. Membership sync
then pre-fetches the source project from Waldur and passes it to
sync_resource_project and sync_project_end_date, so the plugin never
calls Waldur for it.
requires_resource_key_assignees: bool = FalseSet to True for backends that need to know who each of a resource’s API
keys is assigned to, such as a chat surface that follows the person’s key.
On an offering with enable_api_key_provisioning, the core then calls
sync_resource_key_assignees(waldur_resource, assignments) on every
membership sync of the resource (except one restricted for its members) and
right after a key is created. Keys not yet minted are left out.
assignments maps the client_id of every key not deleted to its assignee’s
offering username (the name membership sync adds them under), or to None
when the key is assigned to nobody, or to someone with no actionable offering
user or no username yet. Waldur
sends no command when a key is reassigned, so the membership sync is what
carries a reassignment to the backend.
shared_project_membership: bool = FalseSet to True when every resource federated from one source project lands
in one shared backend project (Waldur-to-Waldur federation). Membership sync
then reconciles against the union of consented teams across all the
project’s resources, so a user is kept while they consent to any offering
with a resource in the project.
The membership processor also reads these attributes with getattr, so they
work only when set on your backend class:
skip_resource_team_diff = True — the backend manages membership below the
resource level; core skips the team-vs-backend diff, so it adds and removes
nobody.user_resolve_method = "identity_bridge" — the diff is keyed on the user’s
CUID (ProjectUser.username) instead of the offering username (federation).fetch_consented_users_only = True — the resource team is fetched with
consenting users only.If your backend does not support a certain operation, use these return values:
| Method | No-op return | Meaning |
|---|---|---|
ping |
False |
Backend has no health check |
diagnostics |
True |
Diagnostics not implemented but OK |
list_components |
[] |
No component discovery |
_get_usage_report |
{} |
No usage reporting |
_collect_resource_limits |
({}, {}) |
No limits support |
_pre_create_resource |
pass |
No pre-creation setup |
downscale_resource |
True |
No downscaling concept |
pause_resource |
True |
No pausing concept |
restore_resource |
True |
No restore concept |
get_resource_metadata |
{} |
No metadata |
Every ```yaml block that contains offerings: in the repository’s Markdown, and every file
under examples/ and plugins/*/examples/, is loaded by tests/test_docs_config_examples.py
in the core test job. A block fails when it does not load through the real configuration
loader, uses a key the configuration model does not define (the loader would ignore it
silently), names a backend that is not a registered entry point, sets no *_backend at all,
or produces a settings-schema warning. A deliberately partial snippet opts out with a comment
on the line before its opening fence (a blank line in between is fine):
<!-- docs-check: skip -->
A top-level key starting with . (for example .ldap: &ldap_settings) is accepted as a YAML
anchor holder.
The mycustom entry point below is the template plugin’s; it resolves only
once you register your own, so the block is marked to be skipped.
offerings:
- name: "My Custom Offering" # Human-readable name for logging
# Waldur Mastermind connection
waldur_api_url: "https://waldur.example.com/api/"
waldur_api_token: "your-api-token" # Service provider token
waldur_offering_uuid: "uuid-here" # UUID from Waldur offering page
# Backend selection (entry point names from pyproject.toml)
order_processing_backend: "mycustom" # For create/update/terminate
reporting_backend: "mycustom" # For usage reporting
membership_sync_backend: "mycustom" # For user sync
username_management_backend: "base" # Username generation
# Required. Selects the plugin schemas that validate backend_settings
# and backend_components; it does not choose the backend for a mode.
backend_type: "mycustom"
# Event processing (optional)
stomp_enabled: false
# Backend-specific settings (passed to __init__ as backend_settings)
backend_settings:
default_account: "root" # DefaultAccount= on user associations
customer_prefix: "cust_" # Prefix for customer-level accounts
project_prefix: "proj_" # Prefix for project-level accounts
allocation_prefix: "alloc_" # Prefix for allocation-level accounts
# Component definitions (passed to __init__ as backend_components)
backend_components:
cpu:
limit: 100 # Default limit in Waldur units
measured_unit: "k-Hours" # Display unit in Waldur UI
unit_factor: 60000 # Waldur-to-backend conversion factor
accounting_type: "usage" # "usage" = metered, "limit" = quota
label: "CPU" # Display label in Waldur UI
# Optional Waldur offering component fields:
# description: "CPU time" # Component description
# min_value: 0 # Minimum allowed value
# max_value: 10000 # Maximum allowed value
# max_available_limit: 5000 # Maximum available limit
# default_limit: 100 # Default limit value
# limit_period: "month" # "annual", "month", "quarterly", "total"
# article_code: "CPU-001" # Billing article code
# is_boolean: false # Boolean (on/off) component
# is_prepaid: false # Prepaid billing
storage:
limit: 1000
measured_unit: "GB"
unit_factor: 1
accounting_type: "limit"
label: "Storage"
unit_factor explainedThe unit_factor converts between Waldur display units and backend-native units:
backend_value = waldur_value * unit_factorwaldur_value = backend_value / unit_factorExamples:
unit_factor = 60000 (60 min x 1000)unit_factor = 61440 (60 min x 1024 MB)unit_factor = 1Register your plugin in pyproject.toml:
[project]
name = "waldur-site-agent-mycustom"
version = "0.1.0"
dependencies = ["waldur-site-agent>=0.7.0"]
[project.entry-points."waldur_site_agent.backends"]
mycustom = "waldur_site_agent_mycustom.backend:MyCustomBackend"
# Optional: register a username management backend
[project.entry-points."waldur_site_agent.username_management_backends"]
mycustom = "waldur_site_agent_mycustom.username_backend:MyCustomUsernameBackend"
# Optional: component schema validation
[project.entry-points."waldur_site_agent.component_schemas"]
mycustom = "waldur_site_agent_mycustom.schemas:MyCustomComponentSchema"
# Optional: backend settings schema validation
[project.entry-points."waldur_site_agent.backend_settings_schemas"]
mycustom = "waldur_site_agent_mycustom.schemas:MyCustomBackendSettingsSchema"
The entry point name (e.g., mycustom) is what users put in
backend_type, order_processing_backend, or
username_management_backend in the config YAML. The four entry-point
groups are independent — a single distribution may register some or all
of them.
Plugins generally do not have direct access to the Waldur API client.
The core processor pre-resolves any Waldur data the plugin might need and
passes it via user_context. Plugins return metadata to Waldur by setting
resource.backend_metadata.
Exceptions:
evaluate_pending_orderreceiveswaldur_rest_clientdirectly, because the order has not been approved yet and no resource context exists at that point. The hooks listed under Hook methods receive it too, because they exist to reconcile state with Waldur.
user_contextThe processor enriches the user_context dict before calling backend
methods. Plugins read from it without making API calls:
| Key | Type | Contents |
|---|---|---|
team |
list[ProjectUser] |
Team members of the resource’s project (SDK objects) |
offering_users |
list[OfferingUser] |
Offering users of the team members (SDK objects) |
user_mappings |
dict[UUID, ProjectUser] |
Team members keyed by user UUID |
offering_user_mappings |
dict[UUID, OfferingUser] |
The team’s offering users keyed by user UUID |
ssh_keys |
dict[str, str] |
Mapping of SSH key UUID → public key text |
plan_quotas |
dict[str, int] |
Plan component quotas (component key → value) |
team, offering_users and both mappings are empty when the team could not
be fetched; never assume a key is present in unit tests.
backend_metadataTo push metadata back to Waldur (e.g., access credentials, connection
endpoints), set resource.backend_metadata in post_create_resource:
def post_create_resource(self, resource, waldur_resource, user_context=None):
# ... create credentials, gather endpoints ...
resource.backend_metadata = {
"username": "admin",
"password": generated_password,
"endpoint": "https://service.example.com",
}
# The processor pushes this to Waldur automatically
sequenceDiagram
participant P as Processor
participant B as YourBackend
participant W as Waldur API
participant E as External System
P->>P: Fetch service provider
P->>B: Set service_provider_uuid
Note over P,B: Resource creation order arrives
P->>W: Resolve SSH keys, plan quotas
W-->>P: Pre-resolved data
P->>B: _pre_create_resource(resource, user_context)
B->>B: Read ssh_keys, plan_quotas from user_context
B->>E: Create resource with resolved data
E-->>B: Resource created
B->>B: _on_backend_resource_created(backend_id, resource)
P->>B: post_create_resource(resource, waldur_resource, user_context)
B->>B: Set resource.backend_metadata
B-->>P: Return
P->>W: Push backend_metadata to Waldur
user_contextWhen a resource attribute contains a UUID reference (e.g., an SSH key UUID
from the order form), look it up in the pre-resolved ssh_keys dict:
from uuid import UUID
class MyBackend(BaseBackend):
@staticmethod
def _resolve_ssh_key(key_value: str, ssh_keys: dict[str, str]) -> str:
"""Resolve SSH key from pre-resolved context.
If key_value is a UUID, look it up. Otherwise treat as raw key text.
"""
try:
key_uuid = UUID(key_value.strip())
except ValueError:
return key_value # Raw public key text, use as-is
return ssh_keys.get(str(key_uuid), "") or ssh_keys.get(key_uuid.hex, "")
def _pre_create_resource(self, waldur_resource, user_context=None):
user_context = user_context or {}
ssh_keys = user_context.get("ssh_keys", {})
raw_key = waldur_resource.attributes.get("ssh_public_key", "")
resolved_key = self._resolve_ssh_key(raw_key, ssh_keys)
# Use resolved_key for resource setup ...
waldur_api_client for runtime API calls.
All Waldur data should come via user_context or BaseBackend attributes.
The exceptions are the hooks that are handed waldur_rest_client
(listed under Hook methods); use that client rather than building your own, so
authentication (static token or OIDC) and global_proxy apply.service_provider_uuid is still set on BaseBackend by the processor
and can be read by plugins for constructing backend-side identifiers.user_context may be None or
missing keys in unit tests. Always default to {} or empty values.The order processor never calls create_resource. Every creation goes
through create_resource_with_id(waldur_resource, resource_backend_id,
user_context), from three places:
backend_id. Core passes a
suggestion from _get_resource_backend_id(slug), retries with the next
suggestion on DuplicateResourceError, and stores the returned
BackendResourceInfo.backend_id in Waldur.backend_id is set but pull_resource finds
nothing. Core passes the existing backend_id and does not store a new
id — it expects the resource back under the same one.OFFERING_RESOURCES_SYNC event), through
recreate_missing_resource. Core passes the existing backend_id and
discards the result.The base implementation creates the resource under the id it is given, which is right for backends where the agent names things (SLURM accounts, LDAP groups, Harbor projects).
If your system assigns its own ids (cloud VMs, droplets, remote orders), override both:
create_resource_with_id: ignore a suggested id and return the id the
system assigned. When it is called with the resource’s own existing
backend_id, raise BackendError instead of creating — a new object
would get a new id that core never stores.recreate_missing_resource: return False (with a log line) instead of
creating a replacement that Waldur would never reference.Skipping either leaks orphaned, possibly billed, objects on every retry or
forced sync. plugins/digitalocean implements both; plugins/azure
overrides both but does not refuse the existing-id case.
Core decides from the exception whether a failure marks a resource or order ERRED.
Membership sync classifies each exception in this order:
httpx.TransportError, or an HTTP 5xx or 429 raised
through the Waldur client library (UnexpectedStatus): the resource is
skipped this pass, its state unchanged. There is no URL check, so this also
covers a network error or 5xx from your backend if it uses httpx or the
Waldur client (SLURM REST, a Waldur-to-Waldur target).last_sync is not refreshed.BackendError, a CLI failure, a 4xx from a remote
Waldur: the resource is marked ERRED with the error; the other resources
still run.An ERRED resource is set back to OK once every backend step runs again. So
raise when the backend failed; never wrap a Waldur API failure in
BackendError, or a Waldur hiccup marks healthy allocations ERRED. If a
backend outage should mark resources ERRED, raise BackendError rather than
letting a raw httpx error escape.
Waldur-to-Waldur backends call a remote Waldur with the same client
library. Return its base URL from remote_waldur_base_urls(), so a 4xx from
it stays a backend error (ERRED) even when the remote Waldur shares the
agent’s host. Its 5xx, 429 and network errors are transient (rule 1).
Order processing:
BackendNotReadyError (for example from run_preflight) to keep
orders pending while the backend is unreachable.The unit_factor converts from Waldur units to backend units by multiplication.
When reporting usage back, you must divide by unit_factor. Getting this
backwards causes limits to be set at 1/60000th of the intended value
or usage to be reported 60000x too high.
The _get_usage_report return dict must include a "TOTAL_ACCOUNT_USAGE" key
for each resource. If the key is missing, the reporter logs
No TOTAL_ACCOUNT_USAGE for <resource> … skipping and submits nothing for
that resource and period — no total and no per-user usage. (Zeros are filled
in only when the whole resource entry is missing from the report.)
Common causes:
uv sync --all-packages)"waldur_site_agent.backends"; username management uses
"waldur_site_agent.username_management_backends" (note the plural and
the suffix). Validation schemas use
"waldur_site_agent.component_schemas" /
"waldur_site_agent.backend_settings_schemas".Debug with:
from importlib.metadata import entry_points
print(list(entry_points(group="waldur_site_agent.backends")))
print(list(entry_points(group="waldur_site_agent.username_management_backends")))
Your backend __init__ must call super().__init__(backend_settings, backend_components).
This sets up self.backend_settings, self.backend_components, and
self.client. Then assign your own client:
def __init__(self, backend_settings, backend_components):
super().__init__(backend_settings, backend_components)
self.backend_type = "mycustom"
self.client = MyCustomClient()
get_resource must return None (not raise) when resource is absent.get_association must return None (not raise) when no association exists.list_resources must return list[ClientResource], not raw dicts.Component keys in _get_usage_report must exactly match the keys in
backend_components config. If config has "cpu" but you report "CPU",
the usage will be silently ignored.
| Mode | Test focus |
|---|---|
order_process |
create_resource_with_id (the method core calls), delete_resource, limit conversion |
report |
_get_usage_report format, unit conversion math |
membership_sync |
add_user, remove_user, pause/restore |
| All | ping, error handling, edge cases |
Mock the client to avoid needing a real backend:
from unittest.mock import MagicMock, patch
from waldur_site_agent.backend.structures import ClientResource, Association
def test_create_resource():
backend = MyCustomBackend(
backend_settings={"default_account": "root", "allocation_prefix": "test_"},
backend_components={"cpu": {"unit_factor": 60000, "limit": 10}},
)
backend.client = MagicMock()
backend.client.get_resource.return_value = None # Resource doesn't exist yet
backend.client.create_resource.return_value = "created"
# ... test resource creation
import pytest
@pytest.fixture
def backend_settings():
return {
"default_account": "root",
"customer_prefix": "c_",
"project_prefix": "p_",
"allocation_prefix": "a_",
}
@pytest.fixture
def backend_components():
return {
"cpu": {
"limit": 10,
"measured_unit": "k-Hours",
"unit_factor": 60000,
"accounting_type": "usage",
"label": "CPU",
},
}
@pytest.fixture
def backend(backend_settings, backend_components):
b = MyCustomBackend(backend_settings, backend_components)
b.client = MagicMock()
return b
# Usage report format
report = backend._get_usage_report(["alloc_1"])
assert "TOTAL_ACCOUNT_USAGE" in report["alloc_1"]
assert all(k in report["alloc_1"]["TOTAL_ACCOUNT_USAGE"]
for k in backend.backend_components)
# Limit conversion
backend_limits, waldur_limits = backend._collect_resource_limits(mock_resource)
assert backend_limits["cpu"] == waldur_limits["cpu"] * 60000
When implementing a new backend plugin with an LLM, follow these steps in order:
plugins/slurm/ and plugins/mup/ for patterns.docs/plugin-template/ and rename.__init__: Call super().__init__(), set backend_type, create client.BaseClient methods: Start with get_resource, create_resource,
delete_resource, list_resources.BaseBackend abstract methods: Start with ping, then
_pre_create_resource, then _collect_resource_limits, then _get_usage_report.unit_factor math in both directions.pyproject.toml.uv sync --all-packages and run
waldur_site_diagnostics.uv run pytest and uvx prek run --all-files.waldur_site_agent/backend/backends.py — BaseBackend (resource
backends) and AbstractUsernameManagementBackend (username plugins),
plus PendingOrderDecision enum.waldur_site_agent/backend/clients.py — Base client class.waldur_site_agent/backend/structures.py — Data structures
(ClientResource, Association, BackendResourceInfo with fields
backend_id, parent_id, effective_id, users, usage, limits,
pending_order_id, backend_metadata, endpoints — a list of
{"name": …, "url": …} access endpoints pushed to Waldur).waldur_site_agent/backend/exceptions.py — BackendError,
DuplicateResourceError, and the
OfferingUser*RequiredError exceptions used by username backends.waldur_site_agent/common/plugin_schemas.py — PluginComponentSchema
and PluginBackendSettingsSchema base classes for optional config
validation entry points.plugins/slurm/waldur_site_agent_slurm/backend.py — Reference
implementation (CLI-based).plugins/mup/waldur_site_agent_mup/backend.py — Reference
implementation (API-based).plugins/waldur/waldur_site_agent_waldur/backend.py — Reference for
supports_async_orders, handled_resource_states, and the
sync_resource_* hooks.plugins/basic_username_management/ — Minimal reference for
AbstractUsernameManagementBackend.super().__init__(backend_settings, backend_components).list_resources; return ClientResource objects.get_resource when resource is absent; return None."TOTAL_ACCOUNT_USAGE" key in usage reports._collect_resource_limits.self.backend_components.create_resource: core calls create_resource_with_id.
If your system assigns ids, override recreate_missing_resource too.BackendError for a failed Waldur API call; it marks a
healthy resource ERRED.