WorkstationPool
A set of workstations Ringleader builds from one template and lends out one at a time, for CI jobs and other work that needs a machine for a while and then gives it back.
A WorkstationPool is a set of workstations that Ringleader builds from one template and lends out one at a time. A CI job, a script or a person asks the pool for a workstation, works on it, and hands it back. The pool then deletes that workstation, or, when you tell it to recycle, cleans it and keeps it for the next request. Between requests the pool keeps as many idle workstations ready as you declare, running or stopped, so the next request waits seconds instead of the minutes a fresh machine takes.
Three words recur on this page. A member is a workstation the pool created and owns. A
lease is a request for one member, recorded as a WorkstationPoolLease object. The
lease’s holder is whoever created it.
Lease a workstation from a pool
Create a pool from a workstation you already have, lease a member, work on it, and release it:
rl workstationpool create agents-linux --from-workstation ci-runner-1 --size-max 8 --warm-running 2
rl workstationpool lease agents-linux --name my-build --wait
workstationpoollease/my-build Ready on agents-linux-e5f6a7b8
rl shell agents-linux-e5f6a7b8
rl workstationpoollease release my-buildThe pool copies the workstation’s configuration into its template and builds up to eight
members from it, keeping two idle ones running. The lease carries your device’s SSH key, so
you can open a shell on the member as soon as the lease is Ready. Releasing the lease deletes
the member. If that is all you need, you are done. The rest of this page covers the manifest,
what a holder can and cannot do, keeping members warm, recycling, and what the pool does when
something fails. The commands are on the
CLI page.
Declare a pool
A pool needs a template and a maximum size. Everything else has a default:
apiVersion: workstations.ringleader.dev/v1
kind: WorkstationPool
metadata:
name: agents-linux
namespace: acme-dev
spec:
template:
metadata:
labels:
app: agent-runner
spec:
requirements: [provider:gcp]
providerConfig:
gcp: {machineType: n2-standard-4, diskGiB: 100}
configs:
- {name: agent-runner, priority: 100}
size:
max: 6
warm:
stopped: 2spec.template.spec is a Workstation spec, and the pool checks it the way it checks a
Workstation you apply directly. It may not set
stopped, lifecycle, ttl, ttlAction or any of the nonce fields, because the pool decides
when a member runs and how long it lives. The labels under spec.template.metadata go onto
every member. A label starting with pools.ringleader.dev/ is refused there, because Ringleader
writes those itself.
Every member belongs to the pool’s owner. The owner can open a shell on every member, including one someone else is holding, from every device they have registered. A pool several people share is therefore usually owned by a service account rather than a person.
A configuration applies to a member the way it applies to any workstation. A
WorkstationConfig that selects by label
reaches a member when its author’s reach covers the pool’s owner, and one the template names in
configs reaches every member. A config marked as personal to its author never reaches a
member. rl workstationpool create --from-workstation names the selector-matched configs that
would otherwise stop applying once the pool, rather than you, owns the machines.
What a lease is
A lease names a pool and says what its holder wants. The pool binds one member to it and reports progress in the lease’s status:
apiVersion: workstations.ringleader.dev/v1
kind: WorkstationPoolLease
metadata:
name: my-build
namespace: acme-dev
spec:
workstationPoolRef: {name: agents-linux}
state: Needed # Needed, then Done or Recycle
expiresAt: "2026-10-20T18:00:00Z" # write a later time to renew
authorizedKeys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK... build-controllerspec.state moves forward only: from Needed to Done or Recycle, and from Recycle to
Done. Done is final. spec.workstationPoolRef never changes after the lease is created.
spec.expiresAt is when the lease ends if nobody releases it. Leave it out and the pool sets it
one lease.maxTerm after the write, a day by default. To renew, write a later time before the
current one passes. A time in the past, or more than one lease.maxTerm ahead of now, is
refused. A holder that stops renewing loses its member at most one term after its last renewal.
The pool picks a member for a waiting lease in this order:
- An idle running member, the most recently used first.
- A member that is already starting or being built.
- An idle stopped member, which the pool starts.
- A new member, while the pool is below
size.maxand its template is healthy.
When none applies, the lease waits as Pending, and its status.message says what it is
waiting for. Waiting leases are served oldest first.
| Phase | Meaning |
|---|---|
Pending | Waiting for a member. |
Bound | A member is assigned and is starting, configuring, or taking on this lease’s keys. |
Ready | The member is usable. Its Ready condition is True, and status.workstation names it. |
Releasing | The holder asked for Done or Recycle. Access is being revoked and the member deleted or recycled. The lease is deleted when that finishes. |
Failed | The lease ended without the holder asking. status.release.reason says why. |
A lease ends in one of these ways:
- The holder writes
Done. The pool revokes the holder’s access and deletes the member. The lease goes once the delete is accepted, while the member finishes shutting down. - The holder writes
Recycle. The pool revokes the access and recycles the member, or deletes it when it cannot recycle. Recycling says which. - The holder deletes the lease. That is a release:
Done, unless the holder had already writtenRecycle. - The lease expires. It goes
Failedwith reasonExpired, and the pool deletes its member. A member nobody vouched for is never recycled. - The holder writes
DoneorRecycleon a lease that is stillPending. That is a cancellation, and the pool deletes the lease.
A Failed lease stays so its holder can read what happened: until the holder writes Done or
Recycle, or deletes it, or until lease.failedRetention passes. A pool keeps at most
lease.maxPending failed leases and drops the oldest beyond that.
What a holder can do
A lease gives its holder two things while it holds a member, and nothing else:
- Every key in
spec.authorizedKeysis authorized for the member’s login user. Therl workstationpool leasecommand puts your device’s own key there unless you pass--key, sorl shell --script,rl shell --copy,rl file cpandrl logswork against the member at once. This is how a service account reaches a member. - A person who holds a lease also gets an SSH share on the member, the same
Grant that
rl workstation sharecreates, unless they own the pool, in which case they already have it.
The holder does not own the member and cannot edit or delete it. Any personal configuration the holder has declared for their own workstations stays off the member. When the lease ends, the pool removes the keys and deletes the share before it deletes or recycles the member. Removing a key stops new logins. It does not close a session that is already open.
Leasing is a right on the pool. Its owner holds it, a namespace admin holds it on every pool in
the namespace, and anyone else needs a Grant on the pool with the lease right, which
rl workstationpool share
creates. Taking the Grant away stops new leases and leaves running ones alone. A lease is
refused while its pool is being deleted, and when the pool already has lease.maxPending
leases waiting.
Keep members warm
An idle member costs money while it exists, so the pool keeps only what you declare and lets the rest go on timers. These fields decide how many members wait and for how long:
| Field | Default | What it does |
|---|---|---|
warm.running | 0 | Idle members kept running. The pool starts a stopped member, or builds one, to reach it. |
warm.stopped | 0 | Idle members kept stopped, configured and ready to start. |
idle.stopAfter | 15m | How long an idle running member above warm.running stays running before the pool stops it. |
idle.deleteAfter | 72h | How long a stopped idle member above the floors stays before the pool deletes it. |
maxAge | 168h | A member older than this is deleted once it is idle, and replaced if a floor needs it. It is never recycled. |
size.max | required | The most members at once, leased or idle. Members still being deleted do not count. The ceiling is 1000. |
warm.running plus warm.stopped may not exceed size.max. A waiting lease takes an idle
member even when that empties a floor, and the pool refills the floor afterwards. When the pool
lets members go, it keeps the ones with the most life left: the fewest recycles first, then the
youngest. A new member always boots running, so a member built for the stopped floor is
configured first and stopped once it is ready.
Recycling
A pool deletes every released member unless its spec declares a recycle block. With one, a
lease released with Recycle returns its member to the pool after a cleanup:
spec:
recycle:
maxReuses: 20 # after this many recycles the member is deleted instead
timeout: 10m # a recycle that takes longer deletes the member
reboot: false # true stops and starts the member after the scriptsA recycle runs these steps, in order, on the member:
- The agent on the member rewrites the block of authorized keys it manages, without the lease’s keys and the holder’s devices, so nothing the lease granted can log in once the cleanup starts.
- It runs every script declared with
runPolicy: onRecycle, from the template or from any configuration that reaches the member, in configuration order. - It brings the member back to its template, as an ordinary configuration pass does.
- With
reboot: true, the pool stops and starts the member, and the agent configures it again when it boots.
Recycling cleans nothing else. The next holder can read whatever the scripts leave behind: files
the scripts did not remove, processes still running, SSH sessions still open, container images
and volumes, and the login user’s scheduled jobs. A holder with sudo can also leave a way back
in, such as a key outside the block the agent manages, a key for root, or a new user account. A
pool whose holders must not reach each other’s work releases with Done instead. Declare the
cleanup you need as onRecycle scripts on the
configuration.
A recycle that fails deletes the member. That happens when a script fails, when the member does
not reach Configured again, or when the whole recycle runs past recycle.timeout. The lease
goes Failed with reason RecycleFailed or RecycleTimedOut, and the pool records an event
naming the member and the failed script. A recycle is never retried on the same member.
A Recycle request deletes the member instead of recycling it in six cases, and the lease’s
status.release.reason names the one that applied:
RecycleDisabled: the pool has norecycleblock.MaxReusesReached: the member has been recycledmaxReusestimes.MaxAgeReached: the member is older thanmaxAge.TemplateChanged: the member was built from an older template.Cordoned: the member is cordoned.RecycleUnsupported: the agent on the member is too old to recycle.
Each member carries the annotation pools.ringleader.dev/reuses, which the pool raises after
every successful recycle. rl workstation describe <member> prints it beside the member’s pool,
state, lease and cordon.
Hold configuration changes until a lease ends
By default a change to a configuration that reaches a member lands on it at once, as it would
on any workstation, even in the middle of a CI job. Set spec.lease.configChanges: Hold to make
a leased member keep the configuration it had when its lease was bound. The change lands when
the lease ends, in the recycle or the replacement that also removes the holder’s access. The
pool’s status.configChanges shows which mode is in force, and a member’s
status.config.configHold reads Held while a hold applies.
A hold covers the content of the configuration layers, including the text of a ConfigMap a
layer reads. It never covers access or control: the lease’s keys, the holder’s share, the
owner’s devices, the member’s power state, its egress policy, what an attached Integration
makes it reach, and its declared capabilities all change at once. Flipping the pool back to
Apply ends every hold on the next pass. A change to spec.template never reaches a leased
member in either mode. It replaces idle members only.
Take a member out of service
Cordon a member to stop the pool from leasing it again:
rl workstation cordon agents-linux-e5f6a7b8
rl workstation uncordon agents-linux-e5f6a7b8A cordoned member that is leased takes no new lease once its current one ends, and is then
deleted, whether the holder asked for Done or Recycle. A cordoned member that is idle stays
exactly as it is, outside the warm floors, the idle timers, a template change and a lower
size.max, until you uncordon it. Nothing deletes it in the meantime, so you can work on it. The
pool’s owner and a namespace admin may cordon a member.
Change a pool
Any change to spec.template gives the pool a new template hash. The pool replaces idle members
built from the old one, creating the new member first while it is below size.max, so a floor
never drops to zero. A leased member finishes its lease and is then deleted rather than recycled.
Lowering size.max deletes idle members above the new maximum, those with the least life left
first. It never touches a leased or cordoned member, so a pool can sit above its new maximum
until those are released or uncordoned. Every other field takes effect on the pool’s next pass.
When something fails
A new member has 30 minutes to report Configured. One that fails, or runs out of time, is
deleted, and the pool records a TemplateFailed warning event with the member’s reason. A lease
that was bound to it and not yet Ready is bound to another member. After three failures in a
row the pool’s TemplateHealthy condition goes False, its phase reads Degraded, and it waits
before building again: one minute, doubling up to an hour. Existing idle members keep serving
leases. A change to the template clears the failure count.
| What happened | What the pool does | What the holder sees |
|---|---|---|
A leased member’s machine is lost, its Workstation is deleted, or it goes Failed | Deletes the member. A Ready lease fails and is never bound again, because work may have run on it. A lease not yet Ready goes back to Pending and is bound to another member. | Failed, reason WorkstationLost, if it was Ready |
| Nobody renews a lease | Fails it at spec.expiresAt and deletes its member. | Failed, reason Expired |
| The namespace’s workstation limit is reached | Builds nothing, sets WithinQuota to False, and asks again once a minute. | Pending, with the reason |
| The pool’s owner may no longer create workstations in the namespace | Sets OwnerAuthorized to False, reads Degraded, builds no member, and withholds the owner’s device keys from existing members. | Pending, naming the owner |
| The holder’s own program crashes | Nothing. Leases persist, and each ends at its expiry unless the restarted program releases it first. | Its leases, unchanged |
Delete a pool
Deleting a pool deletes its members, machine and disk, the way deleting a Workstation does, so
rl workstationpool delete asks you to confirm. The pool fails every waiting lease with
PoolDeleted, deletes its idle members at once, and deletes each leased member when its lease
ends. It never recycles one. New leases are refused. The pool object stays in Terminating
until the last leased member is gone, and its status.message says how many leases it is
waiting for and when the last one expires. The delete prints the command that releases those
leases early:
workstationpool/agents-linux: 2 members stay until their leases end. Release those leases with `rl workstationpoollease release -n acme-dev -l pools.ringleader.dev/pool=agents-linux`.Usage history
A pool keeps a week of hourly counters so you can size its warm floors. Each hour records three groups of numbers:
- Demand: leases created and bound, the most held at once, the most waiting, how long a lease
waited to become
Ready, and leases that expired before they were bound. - Endings: leases released with
Done, recycled, whose recycle failed, expired, and whose member was lost. - Supply: members built and deleted, and the seconds members spent idle running, idle stopped, leased, being built and being recycled.
Anyone who can read the pool can read them with
rl workstationpool usage.
A control plane that keeps no usage store says so instead of printing a table. The history
belongs to the pool object, so a deleted pool’s rows are no longer served, and a new pool under
the same name starts afresh.
The counters are an accounting record rather than a bill. A count can be lost when the control plane restarts at the wrong moment. It is never counted twice.
Ringleader does not yet change a pool’s warm floors on its own. The history is there for you, or a script of yours, to set them by.
Spec fields
| Field | Type | Description |
|---|---|---|
template.metadata.labels | map | Labels every member carries, beside the pool’s own pools.ringleader.dev/pool label. |
template.spec | object | Required. Each member’s spec, checked as a Workstation’s would be. stopped, lifecycle, ttl, ttlAction and the nonce fields are refused. |
size.max | int | Required. The most members at once, from 1 to 1000. |
warm.running, warm.stopped | int | Idle members kept running and kept stopped. Default 0 each. Their sum may not exceed size.max. |
idle.stopAfter, idle.deleteAfter | string | How long an idle member above the floors stays running, then how long it stays stopped. Default 15m and 72h. |
maxAge | string | How long any member is kept. An idle member past it is deleted and never recycled. Default 168h. |
lease.maxTerm | string | How far ahead a lease’s expiresAt may be set, and its default when left out. Default 24h, at most 30 days. |
lease.failedRetention | string | How long a Failed lease is kept. Default 24h. |
lease.maxPending | int | How many leases may wait at once, and how many Failed ones are kept. Default 100, at most 1000. |
lease.configChanges | string | Apply or Hold. Whether a configuration change reaches a leased member at once, or waits until its lease ends. Absent means Apply. |
recycle | object | Present only when the pool recycles. {maxReuses, timeout, reboot}, default 20, 10m and false. |
Status fields
The pool writes a constant-size summary. Members and leases are listed by name with
rl workstationpool describe, and each member’s own Workstation carries its pool state as
annotations.
| Field | Type | Description |
|---|---|---|
phase | string | Active, Degraded when the template keeps failing or the owner may not create workstations, or Terminating. |
message | string | The one-line headline. A Terminating pool says how many leases it waits for and when the last one expires. |
members | object | Counts of provisioning, idleRunning, idleStopped, leased, recycling, retiring and cordoned members. |
warm | object | {running, stopped}: the idle members built from the current template, and not cordoned, that fill the two floors. |
leases | object | {pending, oldestPendingSince}. |
capacity | int | How many leases the pool can serve at once. size.max less cordoned members while the pool can build, the members that can serve while it cannot, and 0 while it is being deleted. |
templateHash | string | The hash of the current template. A member built from another hash is replaced once idle. |
configChanges | string | The mode in force, Apply or Hold. |
observedGeneration | int | The metadata.generation this status was computed for. |
conditions | []object | TemplateHealthy, WithinQuota and OwnerAuthorized. |
rl get workstationpool prints the phase, the capacity, and the idle, leased and pending counts.
rl workstationpool wait without --for waits until the template is healthy, the namespace has
room, and both warm floors are filled.
Lease status fields
| Field | Type | Description |
|---|---|---|
phase | string | Pending, Bound, Ready, Releasing or Failed. |
message | string | Why a lease waits, or what ended it. |
workstation, workstationUID | string | The bound member. |
boundAt, readyAt | string | When the member was bound and became ready. |
expiresAt | string | The expiry in force, the default included. It follows a renewal while the lease is Pending, Bound or Ready. |
failedAt | string | When the lease reached Failed, which starts lease.failedRetention. |
release | object | {action, reason}. action is Deleted when the member was deleted. reason is Requested, Expired, WorkstationLost, PoolDeleted, RecycleFailed, RecycleTimedOut, or one of the six reasons a Recycle request deletes the member instead. A successful recycle deletes the lease, so it leaves no reason; the pool’s Recycled event records it. |
conditions | []object | Ready. |
rl get workstationpoollease prints each lease’s holder, pool, phase, member and expiry, and
--mine narrows the list to your own. rl workstationpoollease wait without --for waits for
Ready, and a Failed lease ends the wait.
See also
- Workstation lifecycle & access: the
workstationpool,workstationpoolleaseandcordoncommands. - Sharing & access: letting a teammate lease from your pool.
- Access control: the
leaseright and who holds it. - Workstation: the members, and the
recycleNoncefield a recycle uses. - WorkstationConfig: declaring an
onRecyclescript.