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 |
| billing_type | usage or limit |
Metered vs quota accounting |
| backend_id | Resource ID on the backend | Generated by _get_resource_backend_id |
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
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.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 |
|---|---|---|
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 |
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_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 |
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) |
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: This is the only hook that receives
waldur_rest_client. Other backend methods receive 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 |
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.
All methods below are abstract and must be implemented.
| 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 |
startup | startup | startup | startup |
create_resource / create_resource_with_id |
CREATE order | - | - | CREATE 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 | - |
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
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.
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 |
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
# Legacy setting (used if per-mode backends not specified)
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.
Exception:
evaluate_pending_orderreceiveswaldur_rest_clientdirectly, because the order has not been approved yet and no resource context exists at that point.
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[dict] |
Team members with usernames |
offering_users |
list[dict] |
Offering users |
ssh_keys |
dict[str, str] |
Mapping of SSH key UUID → public key text |
plan_quotas |
dict[str, int] |
Plan component quotas (component key → value) |
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
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 exception is evaluate_pending_order, which receives waldur_rest_client
for querying project or order data before approval.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 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 missing, the core will substitute zeros, and reported
usage will appear as zero in Waldur.
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, 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).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.