Plugin Development Guide
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.
Waldur Mastermind concepts
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 |
Architecture overview
A resource-management plugin consists of two main classes:
- Backend (inherits
BaseBackend): Orchestrates high-level operations (create resource, collect usage, manage users). - Client (inherits
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
Steps to implement a plugin
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, orplugins/waldur/if you need async orders or federation-style hooks. Read itsbackend.py,client.py, andtests/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) versusbackend_components(billable dimensions, e.g. cpu/storage). See the annotated YAML configuration below, and pickunit_factorvalues carefully — see Common pitfalls. -
Implement
BaseClient. This is the thin layer that talks to the external system. Start withget_resource,create_resource,delete_resource, andlist_resources— see the BaseClient method reference. -
Implement
BaseBackend. Callsuper().__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
basebackend — see Username management backends. -
Register entry points in
pyproject.toml. At minimumwaldur_site_agent.backends; addusername_management_backends,component_schemas, orbackend_settings_schemasgroups 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.
1 2 3 4
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-filesPlugin 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
offeringsentry pointingorder_processing_backend/reporting_backend/membership_sync_backendat 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.
BaseBackend method reference
Abstract methods (must implement)
ping(raise_exception: bool = False) -> bool
- Mode: All (health check)
- Purpose: Verify backend connectivity.
- No-op: Return
False.
diagnostics() -> bool
- Mode: Diagnostics CLI
- Purpose: Log diagnostic info and return health status.
- No-op: Log a message, return
True.
list_components() -> list[str]
- Mode: Diagnostics
- Purpose: Return component types available on the backend.
- No-op: Return
[].
_get_usage_report(resource_backend_ids: list[str]) -> dict
- Mode:
report,membership_sync - Purpose: Collect usage data for resources.
- Return format:
1 2 3 4 5 6 7 | |
- Key rules:
- Component keys must match
backend_componentsconfig keys. - Values must be in Waldur units (after
unit_factorconversion). TOTAL_ACCOUNT_USAGEis required and must equal the sum of per-user values.- No-op: Return
{}.
_collect_resource_limits(waldur_resource) -> tuple[dict, dict]
- Mode:
order_process(resource creation) - Purpose: Convert Waldur limits to backend limits and back.
- Returns:
(backend_limits, waldur_limits)wherebackend_limitshas values multiplied byunit_factor. - No-op: Return
({}, {}).
_pre_create_resource(waldur_resource, user_context=None) -> None
- Mode:
order_process(resource creation) - Purpose: Set up prerequisites before resource creation (e.g., parent accounts).
user_contextcontains pre-resolved data:ssh_keys(UUID → public key),plan_quotas(component → value),team,offering_users.- Runs once for every candidate backend ID the uniqueness retry tries, including
the ones the backend then rejects. Do not create anything named after the
resource's own backend ID here; use
_on_backend_resource_created. - No-op: Use
pass.
downscale_resource(resource_backend_id: str) -> bool
- Mode:
membership_sync - Purpose: Restrict resource capabilities (e.g., set restrictive QoS).
- No-op: Return
True.
pause_resource(resource_backend_id: str) -> bool
- Mode:
membership_sync - Purpose: Prevent all usage of the resource.
- No-op: Return
True.
restore_resource(resource_backend_id: str) -> bool
- Mode:
membership_sync - Purpose: Restore resource to normal operation.
- No-op: Return
True.
get_resource_metadata(resource_backend_id: str) -> dict
- Mode:
membership_sync - Purpose: Return backend-specific metadata for Waldur.
- No-op: Return
{}.
Hook methods (override as needed)
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 |
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 |
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.
Non-blocking order creation (optional)
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.
1 2 | |
When enabled, the core processor:
- Sets the source order's
backend_idto thepending_order_id - Keeps the order in
EXECUTINGstate - On subsequent polling cycles, calls
check_pending_order(backend_id)to check completion - When
check_pending_order()returnsTrue, marks the source order asDONE
Backends 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) -> bool
- Default: Returns
True(no async orders, always "complete") - Override when: Your backend uses non-blocking resource creation
- Returns:
Trueif the remote order completed,Falseif still pending - Raises:
BackendErrorif the remote order failed or was cancelled
Example (Waldur federation plugin):
1 2 3 4 5 6 7 | |
setup_target_event_subscriptions(source_offering, user_agent, global_proxy) -> list
- Default: Returns
[](no target subscriptions) - Override when: Your backend supports STOMP events from a target system
- Returns: List of
StompConsumertuples for lifecycle management - Called by:
event_processmode during STOMP setup
Pending order evaluation (optional)
When 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) -> PendingOrderDecision
- Default: Returns
PendingOrderDecision.ACCEPT - Override when: You need to inspect or gate orders before approval
- Parameters:
order(OrderDetails) — full order data includingproject_uuid,customer_uuid,created_by_*,attributes, andconsumer_message/provider_messagefields.waldur_rest_client(AuthenticatedClient) — authenticated client for fetching additional data from the Waldur API (e.g., project members and roles).- Returns one of:
PendingOrderDecision.ACCEPT— approve the orderPendingOrderDecision.REJECT— reject the orderPendingOrderDecision.PENDING— keep waiting; the order will be re-evaluated on the next polling cycle
Note: Only a few other hooks receive
waldur_rest_client; see the list under Hook methods. Resource lifecycle methods get Waldur data viauser_contextinstead.
Use cases
| 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 |
Example: wait for a PI before approving
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Example: reject orders that lack a required attribute
1 2 3 4 5 6 7 8 9 | |
Username management backends
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.
Required methods
| 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.
Optional hook methods
| 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_offeringruns even when the offering has no offering users, so state that must be cleaned up after the last account is gone has a place.- When
reconciles_project_groups()returnsTrue, event mode subscribes the offering to project-group events and callsreconcile_project_groupson them. It must tolerate running concurrently withreconcile_offering. release_usersis 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).
Class attribute is_username_authoritative
Defaults 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.
Error signalling for user-action gates
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.
Entry point group
Register username management plugins under a different entry point group than resource backends:
1 2 | |
The fallback entry point name is base, provided by the
waldur-site-agent-basic-username-management plugin.
BaseClient method reference
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.
Agent mode method matrix
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.
Reporting user add/remove failures
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.
Usage report format specification
The _get_usage_report method must return data in this exact structure:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Rules
- Component keys must exactly match those in the
backend_componentsYAML config. - Values must be integers in Waldur units (i.e., divide raw backend values
by
unit_factor). TOTAL_ACCOUNT_USAGEis a required key and must equal the sum of all per-user values for each component.- If a resource has no usage, return
{"TOTAL_ACCOUNT_USAGE": {"cpu": 0, "mem": 0, ...}}. - If usage reporting is not supported, return
{}(empty dict).
Example: SLURM CPU and memory
Given config:
1 2 3 4 5 6 7 | |
If SLURM reports 120000 cpu-minutes and 122880 MB-minutes for user1:
1 2 3 4 5 6 | |
Calculation: 120000 / 60000 = 2, 122880 / 61440 = 2.
Capability flags and class attributes
BaseBackend exposes class-level capability flags that change how
the core processor treats your backend.
supports_decreasing_usage: bool = False
Set to True if usage values can decrease between reports (e.g., a
storage backend reporting current disk usage rather than accumulated
compute time).
1 2 | |
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 = False
Set 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 = False
Set 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.
1 2 | |
Resource API keys
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 |
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.
- Pausing a key is not pausing the resource. A key paused on its own
must stay paused when
pause_resourceandrestore_resourcerun around it. The membership sync callsrestore_resourceon every cycle for a resource that is not paused. - Never reuse a client_id.
mint_resource_keyreceives 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. - Refuse what you cannot enforce. If a backend is given a setting it cannot apply, such as a model allowlist on a gateway with no per-key model rule, it must raise. Accepting the setting silently would show the key in Waldur as restricted when it is not.
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.
supports_user_homedirs: bool = False
Set 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.
1 2 | |
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:
1 2 3 4 | |
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 = False
Set 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:
1 2 3 4 5 6 | |
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).
1 2 3 4 | |
team_fetch_attempts: int = 1 and team_fetch_delay: float = 3.0
Control 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:
1 2 3 | |
These are backend class attributes, not YAML configuration keys. They cannot be set per-offering in the configuration file.
requires_source_project: bool = False
Set 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.
shared_project_membership: bool = False
Set 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.
Attributes read without a base-class default
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.
Decision matrix for no-op implementations
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 |
Annotated YAML configuration
Every ``yaml block that containsofferings:in the repository's Markdown, and every file
underexamples/andplugins//examples/, is loaded bytests/test_docs_config_examples.pyin 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):
1 | |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | |
unit_factor explained
The unit_factor converts between Waldur display units and backend-native units:
backend_value = waldur_value * unit_factorwaldur_value = backend_value / unit_factor
Examples:
- CPU k-Hours to SLURM cpu-minutes:
unit_factor = 60000(60 min x 1000) - GB-Hours to SLURM MB-minutes:
unit_factor = 61440(60 min x 1024 MB) - GB to GB (no conversion):
unit_factor = 1
Entry point registration
Register your plugin in pyproject.toml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
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.
Processor-plugin data flow
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.
Pre-resolved data in user_context
The 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.
Returning metadata via backend_metadata
To push metadata back to Waldur (e.g., access credentials, connection
endpoints), set resource.backend_metadata in post_create_resource:
1 2 3 4 5 6 7 8 | |
Data flow
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
Example: resolving an SSH key UUID from user_context
When 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
Design principles
- Plugins should avoid importing
waldur_api_clientfor runtime API calls. All Waldur data should come viauser_contextorBaseBackendattributes. The exceptions are the hooks that are handedwaldur_rest_client(listed under Hook methods); use that client rather than building your own, so authentication (static token or OIDC) andglobal_proxyapply. service_provider_uuidis still set onBaseBackendby the processor and can be read by plugins for constructing backend-side identifiers.- Handle missing context gracefully —
user_contextmay beNoneor missing keys in unit tests. Always default to{}or empty values.
Resource identifiers
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:
- CREATE order for a resource without a
backend_id. Core passes a suggestion from_get_resource_backend_id(slug), retries with the next suggestion onDuplicateResourceError, and stores the returnedBackendResourceInfo.backend_idin Waldur. - CREATE/RESTORE order whose
backend_idis set butpull_resourcefinds nothing. Core passes the existingbackend_idand does not store a new id — it expects the resource back under the same one. - Forced resource sync (
OFFERING_RESOURCES_SYNCevent), throughrecreate_missing_resource. Core passes the existingbackend_idand 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 existingbackend_id, raiseBackendErrorinstead of creating — a new object would get a new id that core never stores.recreate_missing_resource: returnFalse(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.
Error handling and ERRED
Core decides from the exception whether a failure marks a resource or order ERRED.
Membership sync classifies each exception in this order:
- Transient — any
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 useshttpxor the Waldur client (SLURM REST, a Waldur-to-Waldur target). - A 4xx from the agent's own Waldur API (wrong permission, missing
endpoint): the step is skipped and logged, the other steps still run, the
resource state is unchanged and
last_syncis not refreshed. - Anything else —
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:
- A transient error (rule 1 above) never errs an order: it is retried and the order keeps its state.
- Before provisioning starts (approval, pending-order evaluation), any other failure leaves the order pending for the next cycle.
- After provisioning starts, a backend failure marks the order ERRED — unless Waldur already shows it finished (done, canceled, rejected, erred), which core re-reads first.
- Once an order is DONE, failures in post-processing (adding users, accounts) are logged and left to membership sync; the order stays DONE.
- Raise
BackendNotReadyError(for example fromrun_preflight) to keep orders pending while the backend is unreachable.
Common pitfalls
1. Unit factor direction
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.
2. Missing TOTAL_ACCOUNT_USAGE
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.)
3. Entry point not discovered
Common causes:
- Package not installed (
uv sync --all-packages) - Entry point group name misspelled. Resource backends use
"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". - Entry point value points to wrong class or module
Debug with:
1 2 3 | |
4. Forgetting super().init()
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:
1 2 3 4 | |
5. Returning wrong types from client methods
get_resourcemust returnNone(not raise) when resource is absent.get_associationmust returnNone(not raise) when no association exists.list_resourcesmust returnlist[ClientResource], not raw dicts.
6. Component key mismatch
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.
Testing guidance
What to test per mode
| 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 patterns
Mock the client to avoid needing a real backend:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Fixtures
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
Key assertions
1 2 3 4 5 6 7 8 9 | |
LLM implementation checklist
When implementing a new backend plugin with an LLM, follow these steps in order:
- Read existing plugins: Study
plugins/slurm/andplugins/mup/for patterns. - Copy the template: Start from
docs/plugin-template/and rename. - Implement
__init__: Callsuper().__init__(), setbackend_type, create client. - Implement
BaseClientmethods: Start withget_resource,create_resource,delete_resource,list_resources. - Implement
BaseBackendabstract methods: Start withping, then_pre_create_resource, then_collect_resource_limits, then_get_usage_report. - Handle unit conversion: Verify
unit_factormath in both directions. - Write tests: Mock the client, test each abstract method.
- Register entry points: Add to
pyproject.toml. - Test integration: Install with
uv sync --all-packagesand runwaldur_site_diagnostics. - Verify: Run
uv run pytestanduvx prek run --all-files.
Files to study
waldur_site_agent/backend/backends.py—BaseBackend(resource backends) andAbstractUsernameManagementBackend(username plugins), plusPendingOrderDecisionenum.waldur_site_agent/backend/clients.py— Base client class.waldur_site_agent/backend/structures.py— Data structures (ClientResource,Association,BackendResourceInfowith fieldsbackend_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 theOfferingUser*RequiredErrorexceptions used by username backends.waldur_site_agent/common/plugin_schemas.py—PluginComponentSchemaandPluginBackendSettingsSchemabase 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 forsupports_async_orders,handled_resource_states, and thesync_resource_*hooks.plugins/basic_username_management/— Minimal reference forAbstractUsernameManagementBackend.
Common mistakes to avoid
- Do not forget
super().__init__(backend_settings, backend_components). - Do not return raw dicts from
list_resources; returnClientResourceobjects. - Do not raise exceptions from
get_resourcewhen resource is absent; returnNone. - Do not forget the
"TOTAL_ACCOUNT_USAGE"key in usage reports. - Do not confuse Waldur units with backend units in
_collect_resource_limits. - Do not hardcode component keys; read them from
self.backend_components. - Do not override only
create_resource: core callscreate_resource_with_id. If your system assigns ids, overriderecreate_missing_resourcetoo. - Do not raise
BackendErrorfor a failed Waldur API call; it marks a healthy resource ERRED.