Guide: VirtualMachine Claims¶
Take one warm VM out of a pool, for one identity, once — and destroy it when you are done.
A VirtualMachineClaim is the only way a member ever leaves a
VirtualMachinePool
(ADR-0047).
Without one, a pool is inert capacity: it fills, self-heals and rolls, and
nothing can consume it.
Prefer diagrams?
The Claim Flow, End to End draws this whole path —
the kubelogin OIDC flow that establishes who you are, admission, the
bind race, the state machine, release, and the proposed credential
handshake — as eleven Mermaid diagrams.
The rule everything else follows from¶
A member is bound at most once in its life. Release is always deletion.
There is no unbind, no return-to-pool, and no code path that makes a claimed member claimable again. The VM is the isolation boundary between subjects, so the moment a member has been exposed to one subject it can never be given to another.
Every other behaviour on this page is a consequence of that one sentence.
Quick start¶
examples/20-virtualmachineclaim.yaml is a complete claim against the pool
in examples/19-virtualmachinepool.yaml.
kubectl apply -f examples/19-virtualmachinepool.yaml
kubectl apply -f examples/20-virtualmachineclaim.yaml
kubectl get vmclaim -n banlieue-system -w
NAME POOL VM PHASE EXPIRES
sandbox-for-alice sandbox-pool Pending
sandbox-for-alice sandbox-pool sandbox-pool-9dmtx Bound 2026-09-20T14:17:11Z
Once Bound, everything a consumer needs is in one GET:
status:
phase: Bound
virtualMachineRef: { name: sandbox-pool-9dmtx }
addresses:
- type: InternalIP
address: 192.0.2.17
boundAt: "2026-09-20T14:02:11Z"
expiresAt: "2026-09-20T14:17:11Z"
spec.subject is an identifier, never a credential¶
This is the one field that can be misused in a way nothing will warn you about.
subject:
issuer: https://issuer.example.com # who authenticated the subject
id: 0c3b7f2e-1d4a-4b6c-9e8f-5a2d1c0b9e7f # the `sub` / `oid` claim
banlieue records it, mirrors it onto the member as annotations so "who was this VM for" survives on the object an operator actually looks at, and never interprets it.
Never put a token, password or session key in subject
A claim is a plain API object. Anything written here is readable by every reader of the namespace, is copied onto the member, and lands in the API server's audit log. A credential placed here is a credential disclosed.
Delivering the subject's token to the guest is the consumer's job,
over its own attested channel, anchored on the member's EK certificate
(status.tpmEndorsementCertificates).
status.nonce is retired¶
Up to v0.4.13 a bind minted a 128-bit status.nonce for ADR-0049's
quote-over-nonce handshake. That handshake was never used: the consumer
(mediatore) attests each guest with SPIRE's TPM node attestation, whose
credential-activation challenge is its own replay guard. Since v0.4.14 no
nonce is minted; the field stays in the schema, deprecated, so older claims
still parse, and goes with the next API version
(ADR-0049,
amended 2026-11-05).
Verifying the guest: where the trust anchor comes from¶
A bound tpmEnabled member publishes its vTPM's endorsement-key certificate
on the claim (status.tpmEndorsementCertificates,
ADR-0045).
That certificate is what a verifier checks the guest's attestation against, but a
certificate is only as good as its issuer, and which issuers count is a
per-backend decision the administrator makes explicitly on the Provider:
spec:
attestation:
ekTrustBundle: # exactly one of inline / configMapRef / secretRef
configMapRef:
name: swtpm-localca-issuers
# key: ca.crt
ekTrustBundle
(ADR-0049
Decision 10) is the same value-or-source shape as connection.caBundle. The
issuing CA differs by backend: on vSphere it is vCenter's; on libvirt it is
each host's own swtpm_localca issuer certificate
(/var/lib/swtpm-localca/issuercert.pem), one entry per host.
Three things it deliberately is not:
- Not discovered. An anchor read off the hypervisor would let whoever controls the hypervisor choose the anchor that vouches for its own guests.
- Not verified by banlieue. banlieue publishes configuration; the verifier (the broker, mediatore) resolves the referenced ConfigMap/Secret in the Provider's namespace and does the checking.
- Not subject to expiry. swtpm EK certificates carry
notAfter 9999-12-31, so removing a host's issuer from this bundle is the revocation mechanism on libvirt.
ttlSeconds is mandatory, and it is a deadline¶
No default, on purpose. A claim without a deadline is a leaked VM waiting to happen, and there is no number banlieue could pick that is not wrong for somebody's workload.
Two properties worth knowing:
- It runs from binding, not creation. A claim that waited ten minutes for capacity still gets its full TTL.
- It is not a grace period. At expiry the member is deleted whether or not the consumer is finished with it. Then the claim is deleted too, so an expired hold cannot be mistaken for a live one.
kubectl'sEXPIREScolumn is the absolute deadline, not a countdown. It is astringprinter column on purpose:kubectlrenders adatecolumn as time since the timestamp, which for a deadline in the future is negative and prints<invalid>.AGEbeside it is a realdatecolumn, and correctly so — that one looks backwards, this one looks forwards.
Phases¶
| Phase | Means |
|---|---|
Pending |
No Ready, unclaimed member yet. Retries, unboundedly. |
Bound |
A member is held. status.virtualMachineRef names it. |
Releasing |
Deleted or expired; the member is being destroyed. |
Failed |
The bound member vanished. Terminal. |
Nothing moves backwards. In particular a Failed claim is never rebound
to a different member: a consumer holding that claim believes it is talking
to one specific VM, and silently handing it another would be worse than
failing.
Pending does not time out¶
A claim with no member available waits, because the pool may simply be filling — which is the normal state after a burst. Rather than guessing, the claim reports the pool's own diagnosis:
Bound=False reason=NoMemberAvailable
message: no Ready, unclaimed member in pool sandbox-pool
(pool: ReadinessSignalAbsent: no member has published the
"GuestReady" condition)
That parenthesis is the point. "The pool is busy" and "the pool will never warm" look identical from a claim, and the second one needs a human. A missing pool is reported separately:
Which member you get¶
In order: current image revision, then longest Ready, then name.
- Current revision first, so a rebuild that was meant to retire an unpatched build actually does.
- Longest Ready next, so the member closest to being reaped for staleness is the one handed out and idle expiry rarely has to fire.
- Name last, purely so the choice is deterministic.
A stale-revision member is still handed out when nothing fresher is Ready. An available sandbox on yesterday's image beats no sandbox, and its life is bounded by the TTL anyway — refusing would mean a consumer waits out a full install during exactly the rollout the pool exists to hide.
What happens when two consumers race¶
Nothing special, and deliberately so. Binding is a merge patch on the member
carrying its resourceVersion, so if anyone — including another claim —
wrote that member since the controller listed it, the patch is rejected with
409 Conflict and the loser simply picks again.
Optimistic concurrency, no lock, no lease. The API server is already the serialisation point and it is the only participant that cannot disagree with itself.
Deleting a claim destroys the VM¶
This blocks until the VM is really gone from the hypervisor, not until
the delete is issued. The claim holds a finalizer
(banlieue.io/claim-protection) until its member is gone from the API
server, and the member's own finalizer holds it until the backend VM is
destroyed
(ADR-0026,
ADR-0050).
So "claim deleted" means "sandbox destroyed", transitively. A claim deletion can therefore take as long as a hypervisor takes — that is the contract working, not a hang.
The pool refills on its own afterwards.
Deleting the pool does not kill claimed VMs¶
At bind time the member's ownerReferences are re-parented from the pool
to the claim. A claimed member lives and dies with its claim, so deleting
the pool underneath a live sandbox garbage-collects only the warm, unclaimed
members.
Who may create a claim¶
Ordinary Kubernetes RBAC on virtualmachineclaims. The controller itself
deliberately has no create and no update on them: minting a claim
attributes a sandbox to a named subject, and a controller that could do that
could forge the audit trail.
subject is enforced against the caller, by
deploy/admission/virtualmachineclaim-subject-authorization.yaml:
spec.subject.idmust equal the authenticated username;spec.subject.issuermust be in an operator-maintained allowlist;specis immutable, so the check cannot be undone by patching the claim afterwards.
$ kubectl apply -f claim-for-someone-else.yaml
Error from server (Forbidden): ... denied request: spec.subject.id is
alice-uuid but you are authenticated as bob. A claim records who a sandbox
VM was handed to, so it may only name its own creator ...
A broker — a service account whose job is handing sandboxes to other
people — is exempt from the id check. Add its username to the brokers list
in ConfigMap banlieue-claim-subject-policy.
Two things the policy cannot do
It does not verify the issuer. The API server does not reveal which issuer minted the caller's token, so the allowlist only stops a claim naming an issuer your site does not use. Verifying the subject's token is the in-guest agent's job, after the guest has attested against the member's EK certificate (ADR-0049).
A broker is trusted for attribution. Anyone on the brokers list can
attribute a sandbox to any identity, which is what brokering means.
That ConfigMap is as sensitive as the audit trail it underwrites — it is
empty by default, and worth alerting on.
To exercise this against a real identity rather than a certificate CN, see Testing claim authorization — it sets up a dev cluster that logs you in with your GitHub account.
Install it
The policy lives in deploy/admission/, which banlieue bootstrap does
not apply. Applying deploy/admission/ is a separate, mandatory
step. The binding is parameterNotFoundAction: Deny, so a missing
ConfigMap blocks claims rather than silently allowing any subject — and
the shipped issuers list is a placeholder you must edit.
Claims inherit the pool's readiness caveat¶
A claim binds what the pool considers available, which depends on the
pool's spec.readiness. InfrastructureReady is correct for
installMode: Immediate images and wrong for Deferred ones, where it
fires when the install starts.
GuestReady (ADR-0043)
is the correct choice for a Deferred pool, and works on both backends: on
libvirt provided the image carries the guest-phase layer and
qemu-guest-agent, and on vSphere from guestinfo.banlieue.phase
(verified live against a real vCenter, 2026-09-23). See
VirtualMachine Pools.
Troubleshooting¶
| Symptom | Cause |
|---|---|
Pending forever, reason=PoolNotFound |
spec.poolRef.name names a pool that does not exist in this namespace. |
Pending forever, reason=NoMemberAvailable |
Read the pool reason in the message. Filling means wait; ReadinessSignalAbsent means the pool will never warm. |
Phase: Failed |
The bound member was deleted out from under the claim. Terminal by design — create a new claim. |
kubectl delete vmclaim hangs |
Expected while the backend VM is being destroyed. Check the member: kubectl get virtualmachine <name> -o yaml. If its own finalizer is stuck, the provider cannot reach the backend. |
| Claim bound to an old image | Nothing fresher was Ready. Expected; see "Which member you get". |
Bound, but status.addresses is empty |
The member has no address yet. The claim mirrors what the VM reports. |
status.tpmEndorsementCertificates always empty |
The member's class is not tpmEnabled — publication gates GuestReady for tpmEnabled members (ADR-0045), so a bound one always carries its certificate. |
Reference¶
VirtualMachineClaimAPI reference- ADR-0047 —
VirtualMachineClaim: bound once, released by deletion - The Claim Flow, End to End — every actor and message, end to end
- VirtualMachine Pools guide — where the members come from