Waldur Site Agent - K8s UT Namespace Plugin
This plugin enables integration between Waldur Site Agent and Kubernetes clusters for managing
ManagedNamespace custom resources (CRD: provisioning.hpc.ut.ee/v1) with optional Keycloak
RBAC group integration, and optionally bills each namespace by the consumption Prometheus
measured over the billing period.
At a glance
| Entry point | Group | Role |
|---|---|---|
k8s-namespace |
waldur_site_agent.backends |
order processing, membership sync, reporting (preferred name) |
k8s-ut-namespace |
waldur_site_agent.backends |
the same backend under its former name |
Modes: order_process, membership_sync, event_process, and report, which reports
consumption only when a prometheus: block is configured; without one it reports nothing and the
offering bills by limits (see Usage reporting).
| Operation | Behaviour |
|---|---|
| Create resource | Keycloak groups (when enabled) and a ManagedNamespace CR with quotas and optional spec.security |
| Terminate resource | Deletes the CR and the Keycloak groups |
| Update limits | Patches the quota quantities; spec.quota.enabled and the security level are left as they are |
| Add / remove members | Keycloak group membership; optionally user lists in the CR (sync_users_to_cr) |
| Downscale | Quota set to cpu=1, memory=1Gi, storage=1Gi, and enforced |
| Pause | Quota set to zero, and enforced |
| Restore | Puts back the whole quota (quantities and enabled) that pause or downscale recorded |
| Usage reporting | Consumption per namespace from Prometheus with a prometheus: block; no-op otherwise |
Backend names
The plugin registers one backend under two names, k8s-namespace and k8s-ut-namespace, in both
waldur_site_agent.backends and waldur_site_agent.backend_settings_schemas. Use k8s-namespace for new
offerings: the ManagedNamespace operator is not specific to one deployment. Existing configurations that use
k8s-ut-namespace keep working and need no change.
Both names share one settings schema (K8sUtNamespaceBackendSettingsSchema), so settings are validated the same
way under either, and only once when an offering mixes the two names. k8s-namespace loads
K8sNamespaceBackend, a subclass of K8sUtNamespaceBackend whose only change is backend_type. Settings,
components and behaviour are identical. The two names differ only in how the backend identifies itself:
- the raw processor class path the agent registers in Waldur is
waldur_site_agent_k8s_ut_namespace.backend.K8sNamespaceBackendor...K8sUtNamespaceBackend; backend.backend_typeisk8s-namespaceork8s-ut-namespace, which is the name the core's generic log lines use for the backend;- the plugin catalogue (
plugin_catalogue.build_catalogue()) listsk8s-namespaceandk8s-ut-namespaceas two separate backends with identical roles and settings. Pickk8s-namespace.
Waldur's own backend name for the offering does not change: anything Waldur derives from the reported class path
(for example the limit-precision advisory) reads only the plugin package, so it is k8s-ut-namespace under either
name. Neither name is written into resource metadata. The Python package and the PyPI distribution keep the
k8s-ut-namespace name.
Features
- ManagedNamespace Lifecycle: Creates, updates, and deletes
ManagedNamespacecustom resources - Resource Quotas: Sets CPU, memory, storage, and GPU limits as namespace quotas, with an offering switch (and, where the offering allows it, a per-order one) to record them without enforcing them
- Pod Security: Applies a Pod Security Admission level per namespace, from the offering or, within the levels the offering allows, from the order
- Role-Based Access Control: Creates 3 Keycloak groups per namespace (admin, readwrite, readonly)
- Waldur Role Mapping: Maps Waldur roles to namespace access levels automatically
- User Management: Adds/removes users from Keycloak groups, reconciles role changes
- Namespace Labels & Annotations: Configurable labels and annotations propagated to created namespaces
- Status Monitoring: Parses operator Ready condition and exposes readiness in Waldur metadata
- Configurable User Identity: Choose which user attribute (email, civil_number, etc.) populates CR user fields
- Namespace Name Validation: Validates generated names against RFC 1123 before CR creation
- Consumption Billing: Reads core-hours, GiB-hours and GPU-hours per namespace from Prometheus
- Status Operations: Supports downscale (minimal quota), pause (zero quota), and restore
Architecture
The plugin follows the Waldur Site Agent plugin architecture and consists of:
- K8sUtNamespaceBackend: Main backend implementation that orchestrates namespace and user management
- K8sUtNamespaceClient: Handles Kubernetes API operations for
ManagedNamespaceCRs - PrometheusClient: Reads period consumption from the Prometheus HTTP API
- KeycloakClient: Manages Keycloak groups and user memberships (shared package)
Role Mapping
Waldur roles are mapped to namespace access levels. The default mapping is:
| Waldur Role | Namespace Role |
|---|---|
manager |
admin |
admin |
admin |
member |
readwrite |
This mapping is configurable via the role_mapping setting in backend_settings.
Custom entries are merged with the defaults, so you only need to specify overrides or additions:
1 2 3 4 | |
Users whose Waldur role is not in the mapping fall back to default_role (default: readwrite).
Component Mapping
Waldur component keys are mapped to Kubernetes quota fields. The default mapping is:
| Waldur Component | K8s Quota Field | Unit Format |
|---|---|---|
cpu |
cpu |
Integer |
ram |
memory |
{value}Gi |
storage |
storage |
{value}Gi |
gpu |
gpu |
Integer |
This mapping is configurable via the component_quota_mapping setting in backend_settings.
Custom entries are merged with the defaults:
1 2 3 | |
When billing is configured, each component's type also selects the Prometheus query; the
component's key is the name Waldur sees. A component named vCPU hours with type: "cpu" is
metered by the CPU query and reported under vCPU hours.
Installation
Install the plugin using uv:
1 | |
The plugin will be automatically discovered via Python entry points.
Setup Requirements
Kubernetes Cluster Setup
- Kubernetes Cluster: Accessible cluster with the
ManagedNamespaceCRD installed (provisioning.hpc.ut.ee/v1) - Access Method: Either a kubeconfig file or in-cluster service account
- CR Namespace: A namespace where
ManagedNamespaceCRs will be created (default:waldur-system)
Keycloak Setup (Optional)
Required for RBAC group integration:
- Keycloak Server: Accessible Keycloak instance
- Target Realm: Where user accounts and groups will be managed
- Service User: User with group management permissions
Creating Keycloak Service User
- Login to Keycloak Admin Console
- Select Target Realm
- Create User:
- Username:
waldur-site-agent-k8s - Email Verified: Yes
- Enabled: Yes
- Set Password: In Credentials tab (temporary: No)
- Assign Roles: In Role Mappings tab
- Client Roles ->
realm-management - Add:
manage-users(sufficient for group operations)
Prometheus Setup (Optional)
Required only for consumption billing:
- Prometheus: reachable over HTTP with the query API, holding cAdvisor
(
container_cpu_usage_seconds_total,container_memory_working_set_bytesfrom the kubelet's/metrics/cadvisor), kubelet volume stats (kubelet_volume_stats_used_bytesfrom/metrics) and kube-state-metrics (kube_pod_container_resource_requests,kube_pod_init_container_resource_requests,kube_pod_status_phase) series. The defaultselectorsmatch kube-prometheus-stack's job labels. - Trust: tenants must not be able to write series with labels of their choosing into it; see Security
- Credential: a bearer token, or a username and password, if the API is authenticated
- Retention: at least 31 days, and N × 31 days with
reporting_periods: N; see Retention - Waldur:
INVOICE_FINALIZATION_GRACE_PERIOD_HOURSabove 0 and at leastclosed_period_hours, or the last pass before midnight is final; see Closed months
Waldur Marketplace Setup
- Marketplace Offering: Created with appropriate type (e.g.,
Marketplace.Basic) - Components: Configured via
waldur_site_load_components - Offering State: Must be
Activefor order processing
Configuration
Minimal Configuration (K8s Only)
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 | |
Full Configuration (with Keycloak)
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 53 54 55 56 57 58 59 | |
Configuration with Prometheus billing
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 53 54 | |
Configuration Reference
Backend Settings
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
kubeconfig_path |
string | No | - | Path to kubeconfig file (omit for in-cluster config) |
cr_namespace |
string | No | waldur-system |
Namespace where ManagedNamespace CRs are created |
namespace_prefix |
string | No | waldur- |
Prefix for created namespace names |
default_role |
string | No | readwrite |
Default namespace role for users without explicit role |
role_mapping |
object | No | See Role Mapping | Custom Waldur role to namespace role mapping (merged with defaults) |
component_quota_mapping |
object | No | See Component Mapping | Custom component to K8s quota field mapping |
quota_enabled |
boolean | No | true |
Enforce the namespace's ResourceQuota (spec.quota.enabled) |
allow_quota_override |
boolean | No | false |
Let the order's quota_enabled attribute differ from the setting |
default_security_level |
string | No | - | Pod Security level of new namespaces; unset leaves it to the operator |
allowed_security_levels |
list | No | [] |
Levels the order's security_level attribute may choose |
keycloak_use_user_id |
boolean | No | true |
Use Keycloak user ID for lookup (false = use username) |
sync_users_to_cr |
boolean | No | false |
Sync user identities to CR adminUsers/rwUsers/roUsers fields |
cr_user_identity_field |
string | No | email |
User attribute for CR user fields |
cr_user_identity_lowercase |
bool | No | false |
Lowercase the identity value before writing to CR |
namespace_labels |
object | No | {} |
Labels to set on created namespaces (e.g., tenant: waldur) |
namespace_annotations |
object | No | {} |
Annotations to set on created namespaces |
prometheus |
object | No | - | Consumption billing; usage is not reported when unset (see below) |
Prometheus Settings (Optional)
The block is validated by PrometheusSettingsSchema, which forbids unknown keys: a misspelt key
such as bearer_tokn is reported against the prometheus block when the agent loads its
configuration.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string | Yes | - | Prometheus base URL; queries are posted to {url}/api/v1/query. No credentials, query string or fragment |
bearer_token |
string | No | - | Sent as Authorization: Bearer …; not together with basic auth |
username |
string | No | - | HTTP basic auth user; password must be set with it |
password |
string | No | - | HTTP basic auth password; username must be set with it |
ssl_verify |
boolean or path | No | true |
Verify Prometheus's TLS certificate; a path names a CA bundle; only an explicit false disables it |
timeout |
number | No | 30 |
Seconds per request, in total; Prometheus is given the same evaluation timeout |
step |
string or number | No | 1m |
Resolution of the gauge integrals, as a PromQL duration or seconds; at most 5m |
retention |
string or number | No | read from Prometheus | How long Prometheus keeps samples; see Retention |
closed_period_hours |
number | No | 24 |
Hours after a month ends during which it is measured again; see Closed months |
selectors |
object | No | see below | Label matchers that pin each metric's source, merged over the defaults |
queries |
object | No | built-in | PromQL per component type, merged over the built-in queries |
The default selectors, as kube-prometheus-stack labels its scrape jobs:
1 2 3 4 5 | |
Each becomes the placeholder {<name>_selector}; an empty one matches any source. Further entries
can be added for overrides, such as dcgm: 'job="dcgm-exporter"' used as {dcgm_selector}.
An empty value (ssl_verify:, timeout:, step:, retention:, queries:) is read as unset and keeps
the default; only ssl_verify: false turns certificate verification off. An empty block
(prometheus: {}), a block without url, credentials in url, both a token and basic auth, a step
over 5m or a duration that does not parse all raise when the backend is built, rather than silently
turning billing off.
Keycloak Settings (Optional)
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
keycloak_enabled |
boolean | No | false |
Enable Keycloak RBAC integration |
keycloak.keycloak_url |
string | No | https://localhost/auth/ |
Keycloak server URL |
keycloak.keycloak_realm |
string | No | waldur |
Realm the groups are managed in |
keycloak.keycloak_user_realm |
string | No | master |
Realm the admin user authenticates against |
keycloak.client_id |
string | No | admin-cli |
Client used for the admin login |
keycloak.keycloak_username |
string | No | empty | Keycloak admin username |
keycloak.keycloak_password |
string | No | empty | Keycloak admin password |
keycloak.keycloak_ssl_verify |
boolean or path | No | true |
Verify TLS; a path names a CA bundle |
The settings are validated by
waldur_site_agent_k8s_ut_namespace.schemas.K8sUtNamespaceBackendSettingsSchema (the
keycloak: block by the keycloak-client schema); a
misspelt key is logged as a warning when the agent loads its configuration.
Quota enforcement (quota_enabled)
The CRD's spec.quota.enabled decides whether the operator enforces the namespace's ResourceQuota.
An offering that bills by consumption may not want a cap: a quota sized to the limits blocks rolling
updates, which need headroom above steady state.
This requires ManagedNamespace operator 1.4.3 or later, the first version with spec.quota.enabled. An
older operator's CRD does not declare the field and prunes it on admission, so the quota stays enforced
without any error; on those versions the setting has to be made through namespace labels instead. The plugin
does not manage those labels, so pause and downscale cannot cap a namespace whose quota was turned off that
way: they force spec.quota.enabled, which such an operator ignores.
The plugin resolves the switch per resource when it is created:
- When the order carries a
quota_enabledattribute equal to the offering'squota_enabledsetting, that value is used. - When the attribute differs from the setting, it is used if the offering sets
allow_quota_override: true; otherwise the order fails with aBackendErrorthat names the attribute and says the offering does not allow it. The request is never silently dropped. - When the attribute is absent (or
null, or a whitespace-only string), the offering'squota_enabledsetting is used, which defaults totrue.
A refused order is refused before any Keycloak group or CR is created, so nothing is left behind.
Values are accepted as Waldur's own boolean fields accept them: a JSON boolean, 0/1 (also 0.0/1.0),
or the strings true/t/yes/y/on/1 and false/f/no/n/off/0 (case-insensitive,
surrounding whitespace ignored). Anything else fails the order with a message naming the attribute or setting
and the rejected value. An empty quota_enabled: in the YAML keeps the default of true.
enabled is always sent explicitly, because the CRD defaults it to true and an omitted field would
silently re-enable a quota that was turned off. The quantities are sent even when it is off: they are the
entitlement, and the switch only decides whether it is enforced, so turning it back on later needs no
second write.
The attribute is resolved at creation only. A limit update is a merge patch of the quota quantities, so it
leaves enabled as it is. Changing the offering's settings later does not touch namespaces that already
exist either; see Why the gate exists for finding and re-capping them.
Pause, downscale and restore
Pause and downscale work by shrinking the quota, which restricts nothing while the quota is not enforced. So
both also set spec.quota.enabled: true. A namespace with quota_enabled: false cannot escape a pause that
way. The whole spec.quota in force before (quantities and enabled) is recorded as JSON in the CR annotation
waldur.io/quota-before-pause; a second pause or downscale keeps the first snapshot, so downscale followed by
pause still restores the original quota.
Restore writes the snapshot back and removes the annotation; without the annotation it is a no-op. Quantities
the namespace did not have before the pause are removed again, so a namespace without quantities has none
after a restore. The quantities have to come back from the snapshot: an offering billed by usage never sends a
limit update that would restore them. A limit update that arrives while the namespace is paused or
downscaled is written into the snapshot, not the spec, so it neither lifts the pause nor gets undone by the
restore. The recorded enabled is re-checked against the offering's settings as
they are at restore time: if the offering no longer allows orders to differ from its default
(allow_quota_override turned off since), the offering's current quota_enabled is restored instead, with a
warning. Likewise, re-creating a resource resolves its attributes afresh against the current settings.
A namespace paused by an agent that wrote the earlier waldur.io/quota-enabled-before-pause annotation (which
recorded only enabled) gets enabled back on restore; its pre-pause quantities were not recorded and come
back with the next limit update.
Why the quota switch is gated
Waldur stores order attributes that the offering's order form does not declare: it validates the declared
options and passes every other key through. Leaving quota_enabled out of the form therefore does not stop
an orderer from sending it through the API. Without allow_quota_override, any orderer could get an
uncapped namespace on a limit-billed offering, so the plugin honours the attribute only where the offering
says so, and refuses the order otherwise rather than silently ignoring the request.
Turning allow_quota_override off stops new uncapped orders; it does not re-cap namespaces created while it
was on. List them and re-enable their quota by hand where needed:
1 2 3 | |
Quota order-form option
To let customers choose, set allow_quota_override: true and declare the option on the offering
(options in the offering's order form, set through the offering editor or the API):
1 2 3 4 5 6 7 8 9 10 11 12 | |
A select_string records the choice as a string in either direction, which the plugin parses. Waldur
validates the declared choices, and an unanswered optional field is left out of the order entirely, which
keeps the offering default.
Pod Security level
spec.security.level sets the namespace's Pod Security Admission level, one of privileged, baseline and
restricted. This requires ManagedNamespace operator 1.4.3 or later, the first version that supports
spec.security.level and reports the applied level in status.securityLevel. On older operators the level
has to be set through namespace labels instead (see Namespace Labels & Annotations).
Remove such pod-security.kubernetes.io/* labels when moving to spec.security.level: which of the two wins
is up to the operator, not the agent.
The plugin resolves the level per resource when it is created:
- When the order carries a
security_levelattribute that equalsdefault_security_levelor is listed inallowed_security_levels, that level is used. - When the attribute names any other level, the order fails with a
BackendErrornaming the attribute and the levels the offering allows. The request is never silently dropped. - When the attribute is absent (or
null, or a whitespace-only string), the offering'sdefault_security_levelis used. - When there is no default either,
spec.securityis left out and the operator applies the installation's own default, which the agent does not know and does not guess.
The allow-list is empty by default, so an order cannot choose a level unless the offering says so, and
privileged is never implied: an offering that wants to offer it lists it. default_security_level is the
offering's own choice and needs no allow-list entry. Values are matched case-insensitively after trimming
whitespace. An offering with an allow-list should also set default_security_level: otherwise an order that
leaves the attribute out gets the operator's default, which may be looser than anything on the list.
A refused order is refused before any Keycloak group or CR is created. A default_security_level or
allowed_security_levels entry that is not one of the three levels fails the order with a message naming
the setting (the agent also warns about it when it loads its configuration; case and surrounding whitespace
are normalised first, so Restricted is fine). A blank default_security_level means unset.
After creating the CR the plugin checks the stored object. If the cluster's CRD dropped a requested
spec.security.level, or spec.quota.enabled: false, the operator is older than 1.4.3: the plugin deletes the
CR and the Keycloak groups it just created and fails the order with an error saying the operator does not
support the field. (enabled: true is not checked, since a CRD without the field enforces the quota anyway.)
If removing the CR fails too, the error says so and names the CR to delete by hand.
That check only sees the CRD. A CRD upgraded ahead of its controller stores spec.security.level, but the old
controller ignores it; the agent then logs a warning for every ready namespace that requests a level and has
no status.securityLevel.
The level actually in force is read back from the CR's status.securityLevel and published as
security_level in the resource's backend_metadata; the key is absent while the operator has not set it.
When it differs from a requested spec.security.level, the agent logs a warning.
The level is a creation-time choice. A limit update patches spec.quota only, and changing
default_security_level affects only namespaces created afterwards.
Why the security level is gated
Waldur stores order attributes that the offering's order form does not declare, so leaving security_level
out of the form does not stop an orderer from sending it through the API. An ungated
security_level: privileged would let any orderer run privileged pods, hostPath mounts and host networking in
their namespace, which is effectively node-level access to a shared cluster. The plugin therefore honours the
attribute only for the levels the offering lists, and refuses the order otherwise.
Security level order-form option
To let customers choose, list the levels in allowed_security_levels and declare the option on the offering
with choices limited to those levels and the default. Waldur validates the declared choices, so the form
cannot submit any other level; the plugin's check covers orders that bypass the form. For example, to let
orders loosen the default restricted to baseline:
1 2 3 | |
and the matching order-form option:
1 2 3 4 5 6 7 8 9 10 11 12 | |
Consumption billing from Prometheus
With a prometheus: block the plugin reports what each namespace consumed in the billing period.
Without the block nothing is queried and nothing is reported.
Tenants must not be able to write labelled series into the billing Prometheus. The bill is computed from every series that carries the billed namespace's label. If a tenant can choose the labels of what Prometheus ingests — a
ServiceMonitororPodMonitorwithhonorLabels: trueor withrelabelingsthat setjobornamespace, a Pushgateway, or remote-write — it can pose as the kubelet or kube-state-metrics in another tenant's namespace and inflate that bill, or keep a finished GPU pod billable. Check this before enabling billing; see Security for the setups that rule it out, and for the one setting that looks right but switches billing off.
Only components with accounting_type: "usage" are measured and reported. A limit-billed
component is priced from its limit, and a consumption figure under its name — 1488 core-hours
against a two-core limit — would only mislead.
The figures are in native units — core-hours, GiB-hours, GPU-hours — because that is what a
price list is written against. Each component's unit_factor divides them into the unit Waldur
shows, and the result is rounded to two decimals.
Built-in queries
One query per component type, keyed on the component's type (which defaults to its key):
| Type | Metrics | Source | Unit | How |
|---|---|---|---|---|
cpu |
container_cpu_usage_seconds_total |
{cadvisor_selector} |
core-hours | increase over the period, ÷ 3600 |
ram |
container_memory_working_set_bytes |
{cadvisor_selector} |
GiB-hours | integrated over the period |
storage |
kubelet_volume_stats_used_bytes |
{kubelet_selector} |
GiB-hours | integrated; bytes stored, each claim once |
gpu |
kube_pod_container_resource_requests, kube_pod_init_container_resource_requests, kube_pod_status_phase |
{kube_state_metrics_selector} |
GPU-hours | integrated; per pod, while Running |
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 | |
- Sources are pinned. Each query selects only the scrape job that produces its metric, through
the
selectorssetting (defaults below). This keeps out a tenant's own exporter publishing the same metric names, and the kubelet's/metrics/resourceendpoint, which repeats cAdvisor's containers and would bill them twice. See Security. - Every input series is clamped at zero before it is summed, and a negative answer is dropped with a warning, so no series can lower a bill.
- cpu, ram:
container!=""drops cAdvisor's pod- and namespace-level rollup rows, which repeat the same usage;image!=""drops the pause container and image-less rows.increase()needs two samples of a series inside the window, so a container that lives shorter than two scrape intervals is not counted. - storage bills what is stored, not the capacity granted. A ReadWriteMany claim mounted on
several nodes is reported by each node's kubelet, so each claim counts once, at its largest
reading. The kubelet reports only mounted filesystem volumes: an unmounted claim and a
Block-mode volume have nokubelet_volume_stats_*series and are not billed. To bill reserved capacity instead, override the query with kube-state-metrics' request:
1 2 3 4 5 6 | |
- gpu bills what the scheduler holds for a pod — the larger of its largest init-container
request and the sum of its containers' requests — and only while the pod is
Running. APendingpod holds no GPU yet, and aSucceededorFailedpod keeps its request in kube-state-metrics until it is deleted. A container reported by two kube-state-metrics replicas counts once.
A component type with no query at all is reported as 0 with a warning.
Query templates
queries maps a component type to PromQL, merged over the built-in queries. Templates are filled by
literal placeholder replacement, so PromQL's own braces survive:
| Placeholder | Filled with |
|---|---|
{namespaces} |
The backend ids being measured, as one regex alternation (waldur-a\|waldur-b), escaped for RE2 and for the PromQL string |
{window} |
The period's length as a PromQL duration (2678400s) |
{window_seconds} |
The same as a bare number, for arithmetic |
{step} |
The step setting as a PromQL duration |
{step_seconds} |
The same as a bare number |
{<name>_selector} |
Each entry of selectors: {cadvisor_selector}, {kubelet_selector}, {kube_state_metrics_selector}, and any added |
A template must use {namespaces}, and one that names an unknown placeholder is refused when the
backend is built. A query must return one series per namespace with a namespace label
(sum by (namespace)); a series without one is dropped with a warning, because there is no way to
tell whose it is. Only backend ids that are valid namespace names (RFC 1123 labels) are queried.
Step and accuracy
A step point measures the state at one instant, and each point bills the step after it. Whatever
runs between two points is not seen, and whatever covers a point is billed for the whole step: with
step: 1h, a job running :10–:40 every hour bills nothing and one running :55–:05 bills six times
its usage. The default is therefore 1m, and a step longer than 5m — Prometheus's default
lookback — is refused.
At a minute, the error is about one step per pod start or stop. A series that stops is marked stale
at the next scrape. One that stops without a staleness marker — backfilled data, a Prometheus
restart — stays visible for the lookback (5 minutes) and is billed for it. GPU figures are not
affected: the pod's phase changes from Running when it finishes.
A 31-day month at 1m is 44,640 points per series, evaluated in one query per component type. On
a large cluster, watch Prometheus's query time (--query.timeout) against the agent's timeout, or
point the overrides at recording rules.
Which period is measured
Each query is evaluated at the period's end. The current period runs from the start of the month in
the agent's timezone to the reporting instant, and counts only completed steps: the figure lags by
up to one step and never decreases from one pass to the next.
Consecutive months tile without overlap. increase covers the samples in (start, end], and
offset {step} places the gauges' step points on [start, end), so a namespace created at midnight
on the 1st is not billed a step to the month before. Ranges are left-open from Prometheus 3 on; on
Prometheus 2 the point on a boundary falls into both months. A month that crosses a daylight-saving
change is measured in real seconds.
Closed months
The current-period figure stops at the last pass before midnight, so a month is measured once more
after it ends — but only within closed_period_hours (default 24) of its end. Waldur takes usage for
a past month only while that month's invoice is still open, which lasts
INVOICE_FINALIZATION_GRACE_PERIOD_HOURS and is 0 by default: with the default, Waldur refuses
the re-measured month and the last pass before midnight is final, so up to one reporting interval
at the end of each month goes unbilled. Set the grace period above 0 and at least as long as
closed_period_hours. Outside the window the month is not queried, and the processor skips it.
waldur_site_load_historical_usage gets an error rather than an empty report for a month outside the
window — an empty report with --reconcile-stale would zero the month — and measures months in the
agent's timezone, as the processor does. To load an older month, raise closed_period_hours in
the configuration the loader reads.
Retention
A period that Prometheus does not hold in full is not reported: measured from the part that is left, it would come out low, and for the current month a retention shorter than the month turns the window into a sliding one that the anomaly guard then freezes at whatever the retention covers.
- With
retentionunset, the plugin reads the server's time-based retention (/api/v1/status/runtimeinfo) and its oldest sample (/api/v1/status/tsdb/blocks, or the server's ownprometheus_tsdb_lowest_timestamp_seconds), once per cycle, and uses the later of the two. That also catches a size-based retention and a server younger than its retention. A server that exposes neither is assumed to keep Prometheus's default 15 days, with a warning. - With
retentionset, the value is trusted as is. Set it for Thanos, Mimir and other stores whose status endpoints describe only the local TSDB, not the data they serve.
The current month needs at least 31 days of data; with reporting_periods: N, keep N × 31 days. An
uncovered current month is logged as an error and reported with an empty total, which changes
nothing in Waldur, and waldur_site_diagnostics fails. An uncovered closed month raises: the
processor logs it and skips the month, and the historical loader stops for it.
No data and failures
A namespace that no query returned a series for is left out of the report: Prometheus holds
nothing for it in that period. For a closed month the processor then skips the period rather than
storing zeros; for the current one it is reported with an empty total. A namespace that some query
found series for is reported with every usage-billed component, and a component nothing was found
for is a real 0: a namespace whose pods request no GPU used no GPU.
- A query Prometheus refuses (
400,422) reports nothing for that pass — a partial report would contradict the figures Waldur holds for the other components. The other component types are still asked for, so the log names every broken query. - A query Prometheus gives up on (
503witherrorType: timeout) fails that resource's pass only, and its other component types are not asked for. Usually the query is the cause, not the server: a namespace with many series makes its own month-to-date subqueries slow, and that must not stop every other namespace's billing. Three such timeouts in a row count as the server being down. - A transport error, a
timeoutthe agent itself runs out, a rejected credential or any other5xxends the pass at once, and Prometheus is not asked again for the rest of the reporting cycle: every further query would wait out the same timeout. The current period is then reported with an empty total; a closed month raises. - Each request is bounded by
timeoutin total, and an answer larger than 16 MiB is refused. Prometheus is told 80 % oftimeout, so a slow query comes back as its timeout error before the agent stops waiting.
The reporting processor calls pull_resource once per resource, so a pass costs one query per
component type per resource and period. membership_sync pulls the same resources but never reads
usage: pull_resource asks include_usage_requested() and makes no Prometheus query for those
pulls.
Security
A bill is only as trustworthy as the series it is computed from. Pinning the scrape job and clamping
at zero keep out a tenant's exporter that publishes the billed metric names under its own job — to
cancel its own bill with negative values, say. They do not help when a tenant can set the labels of
what the billing Prometheus ingests: a ServiceMonitor or PodMonitor with honorLabels: true, or
with relabelings that set job, metrics_path or namespace, a Pushgateway, or remote-write. A
tenant could then publish job="kubelet" series in another tenant's namespace and inflate that bill,
or a kube_pod_status_phase that keeps a finished GPU pod billable.
So the Prometheus the agent reads must not pick up tenant monitors, or must confine them:
- A system-only Prometheus (the strongest): it scrapes the kubelet, cAdvisor, kube-state-metrics
and itself, and nothing a tenant defines — with the Prometheus Operator,
serviceMonitorNamespaceSelectorandpodMonitorNamespaceSelectorlimited to the system namespaces — and has no Pushgateway or remote-write receiver. Pointurlat it. - Confined tenant monitors: with the Prometheus Operator,
enforcedNamespaceLabel: namespace, with the kubelet and kube-state-metrics monitors listed inexcludedFromEnforcement. A tenant monitor's series are then labelled with the tenant's own namespace, so it can at most raise its own bill. Check on the running Prometheus that a tenant monitor's series do end up that way.
Do not use overrideHonorLabels: true for this. It applies to every ServiceMonitor and
PodMonitor, the system ones included, and the kubelet and kube-state-metrics monitors need
honorLabels: true to keep the workload's namespace: their series would carry it as
exported_namespace, every namespace would look empty, and nothing would be billed — logged only as
"no samples". enforcedNamespaceLabel without the excludedFromEnforcement entries does the same.
Narrower selectors are no substitute either: a tenant that sets labels can set whatever a selector
pins. Behind Thanos or Mimir, an external label does not identify the source, because a series that
already carries the label keeps its own value; query the system Prometheus directly, or make sure no
Prometheus that tenants can write into feeds the same store.
The coverage check reads the oldest sample from /api/v1/status/tsdb/blocks. Where that endpoint is
missing (Prometheus before 2.15, or a store that does not serve it), it falls back to the scraped
prometheus_tsdb_lowest_timestamp_seconds, narrowed by selectors.prometheus, and takes the smallest
value. A forged value lower than the real one only disables the check. One higher than every real
one — possible when the selector is left open and nothing real matches — makes the current month look
uncovered and stops its reporting. Set selectors.prometheus to Prometheus's own scrape job, or set
retention.
Credentials go in bearer_token or username/password, which are secret settings, never in
url. Sending them over http://, and turning ssl_verify off, are logged as warnings.
Offering-side billing
Waldur prices a component whose accounting_type (Waldur's billing_type) is usage from the
reported figure, and a limit component from its limit. Switching an offering to consumption billing
is an offering change: set accounting_type: "usage" on the components and run
waldur_site_load_components. Switch a component over rather than adding a usage-billed one beside
it: cpu billed by cores allocated plus cpu-hours billed by consumption bills the same cores
twice.
Usage-billed components carry no limits — Waldur sends a limit only for a limit-billed component —
so with every component usage-billed the CR's spec.quota holds no quantities, and the operator's
own default decides whether a ResourceQuota is enforced. An offering billed by consumption
normally wants no quota at all: set quota_enabled: false (see
Quota enforcement).
Known limitations
- Usage between the last report and a namespace's termination is not billed: the processor does not report usage before it processes a termination order. That is a core follow-up.
- Usage is reported per namespace only, not per user.
- The default queries can be under-reported on purpose, and a provider that bills strangers should weigh these:
cpumisses containers shorter than two scrape intervals and each container's usage before its first scrape, so work split into many short pods largely escapes it.storagebills bytes in mounted volumes: a claim whose pods are scaled to zero, or aBlock-mode volume, costs nothing. The capacity-based override above bills what is reserved instead.gpubills GPUs requested. Where the NVIDIA container runtime is the default and acceptsNVIDIA_VISIBLE_DEVICESfrom unprivileged pods (its default), a pod can reach GPUs without requesting any; setaccept-nvidia-visible-devices-envvar-when-unprivileged = false.
Usage
Running the Agent
Start the agent with your configuration file:
1 | |
Diagnostics
Run diagnostics to check connectivity:
1 | |
Diagnostics prints whether Prometheus billing is configured, its URL, and from when Prometheus holds
data and why. It pings the query API — a vector(1) query rather than /-/healthy, which is
unauthenticated and would report a rejected token as healthy — and fails when Prometheus is
unreachable or rejects the credential, or when it does not hold all of the current month. The probe
is diagnostics only: the backend does not hold orders back while Prometheus is down.
Supported Agent Modes
- order_process: Creates and manages ManagedNamespace CRs based on Waldur resource orders
- membership_sync: Synchronizes user memberships between Waldur and Keycloak groups
- report: Reports each namespace's period consumption when a
prometheus:block is configured, and nothing otherwise (see Usage reporting)
Resource Lifecycle
Namespace Creation
When a Waldur resource order is processed:
- Resource slug is validated (required for naming)
- The
quota_enabledandsecurity_levelorder attributes are resolved against the offering; an invalid or disallowed value fails the order before anything is created - Three Keycloak groups are created:
ns_{slug}_admin,ns_{slug}_readwrite,ns_{slug}_readonly - A
ManagedNamespaceCR is created with quota (includingenabled) and group references in the spec, andspec.securitywhen a level was resolved - The namespace name is
{namespace_prefix}{slug}(e.g.,waldur-my-project) - If CR creation fails, Keycloak groups are cleaned up (compensating transaction)
Namespace Deletion
When a Waldur resource termination order is processed:
- The
ManagedNamespaceCR is deleted - All 3 Keycloak groups are deleted
Limit Updates
When resource limits are updated in Waldur:
- Limits are converted to K8s resource quantities
- The CR's
spec.quotais patched with the new values
The patch is a merge patch, so spec.quota.enabled keeps the value it was created with and
spec.security is not touched.
User Management
When users are added to a Waldur resource:
- Each user's Waldur role is mapped to a namespace role (admin/readwrite/readonly)
- User is looked up in Keycloak
- User is removed from any incorrect role groups (role reconciliation)
- User is added to the correct role group
Direct CR User Sync
When sync_users_to_cr is enabled, user identities from Waldur are written directly to the
ManagedNamespace CR's adminUsers, rwUsers, and roUsers fields.
The managed-namespace-operator then creates RoleBindings with these identities as
User subjects (optionally prefixed with CONTROLLER_USER_PREFIX on the operator side).
The cr_user_identity_field setting controls which user attribute is used as the
identity value. The default is email, but any attribute exposed by the offering's
user attribute config can be used (e.g., civil_number, username).
Each user's Waldur role is mapped to a namespace role using the same
role_mapping configuration (see Role Mapping), and the
identity value is placed in the corresponding CR field:
| Namespace Role | CR Field |
|---|---|
admin |
adminUsers |
readwrite |
rwUsers |
readonly |
roUsers |
On each membership sync cycle, the full current set of team members from Waldur is written to the CR. Users removed from the Waldur project team are automatically removed from the CR on the next sync, because empty lists are sent for roles with no members.
This can be used alongside Keycloak groups (both mechanisms populate the
same RoleBindings) or without Keycloak (keycloak_enabled: false) for
deployments that rely solely on OIDC-based authentication.
1 2 3 4 5 | |
The chosen field must be enabled in the offering's user attribute config
(expose_civil_number: true) in Waldur. Users missing the configured
attribute are skipped with a warning log.
When cr_user_identity_lowercase is enabled, the identity value is
lowercased before writing to the CR (e.g., EE12345678901 becomes
ee12345678901). This is useful when OIDC subject matching is
case-sensitive and the identity source has mixed case.
When users are removed:
- User is removed from all 3 Keycloak groups
Usage Reporting
Reporting is opt-in per offering. With a prometheus: block, _get_usage_report returns the
current period's consumption per namespace and get_usage_report_for_period re-measures a closed
month, both as TOTAL_ACCOUNT_USAGE only: namespaces are billed as a whole, not per user. See
Consumption billing from Prometheus.
Without the block the plugin reports nothing: both return {}, and offerings using it bill by
limits (allocation), as before.
Namespace Labels & Annotations
Labels and annotations configured in backend_settings are included in the
ManagedNamespace CR spec. The operator propagates them to the actual namespace.
This is useful for cluster policies (e.g., Kyverno requiring a tenant label):
1 2 3 4 5 6 | |
Status Operations
| Operation | Effect |
|---|---|
| Downscale | Quota set to minimal: cpu=1, memory=1Gi, storage=1Gi; spec.quota.enabled forced to true |
| Pause | Quota set to zero: cpu=0, memory=0Gi, storage=0Gi; spec.quota.enabled forced to true |
| Restore | Writes back the quota recorded by pause/downscale (quantities and enabled); no-op without it |
The quota in force before pause or downscale is kept in the waldur.io/quota-before-pause annotation on the CR
(see Pause, downscale and restore).
Error Handling
- Kubernetes connectivity issues are logged and raised as
BackendError - Keycloak initialization failure logs a warning; user management operations become no-ops
- CR creation failure triggers automatic Keycloak group cleanup
- Missing users in Keycloak are logged as warnings and skipped
- Missing backend ID on deletion is logged and skipped gracefully
- A failed Prometheus query leaves every namespace of that pass out of the report, and names the component type in the log; an unreachable Prometheus is not asked again for the rest of the cycle
- An invalid
quota_enabledorsecurity_levelvalue, or one the offering does not allow the order to set, fails the order and leaves no Keycloak groups or CR behind
Development
Running Tests
1 2 | |
Code Quality
1 | |