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-build

The 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: 2

spec.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-controller

spec.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:

  1. An idle running member, the most recently used first.
  2. A member that is already starting or being built.
  3. An idle stopped member, which the pool starts.
  4. A new member, while the pool is below size.max and 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.

PhaseMeaning
PendingWaiting for a member.
BoundA member is assigned and is starting, configuring, or taking on this lease’s keys.
ReadyThe member is usable. Its Ready condition is True, and status.workstation names it.
ReleasingThe holder asked for Done or Recycle. Access is being revoked and the member deleted or recycled. The lease is deleted when that finishes.
FailedThe 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 written Recycle.
  • The lease expires. It goes Failed with reason Expired, and the pool deletes its member. A member nobody vouched for is never recycled.
  • The holder writes Done or Recycle on a lease that is still Pending. 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.authorizedKeys is authorized for the member’s login user. The rl workstationpool lease command puts your device’s own key there unless you pass --key, so rl shell --script, rl shell --copy, rl file cp and rl logs work 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 share creates, 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:

FieldDefaultWhat it does
warm.running0Idle members kept running. The pool starts a stopped member, or builds one, to reach it.
warm.stopped0Idle members kept stopped, configured and ready to start.
idle.stopAfter15mHow long an idle running member above warm.running stays running before the pool stops it.
idle.deleteAfter72hHow long a stopped idle member above the floors stays before the pool deletes it.
maxAge168hA member older than this is deleted once it is idle, and replaced if a floor needs it. It is never recycled.
size.maxrequiredThe 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 scripts

A recycle runs these steps, in order, on the member:

  1. 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.
  2. It runs every script declared with runPolicy: onRecycle, from the template or from any configuration that reaches the member, in configuration order.
  3. It brings the member back to its template, as an ordinary configuration pass does.
  4. 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 no recycle block.
  • MaxReusesReached: the member has been recycled maxReuses times.
  • MaxAgeReached: the member is older than maxAge.
  • 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-e5f6a7b8

A 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 happenedWhat the pool doesWhat the holder sees
A leased member’s machine is lost, its Workstation is deleted, or it goes FailedDeletes 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 leaseFails it at spec.expiresAt and deletes its member.Failed, reason Expired
The namespace’s workstation limit is reachedBuilds 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 namespaceSets 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 crashesNothing. 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

FieldTypeDescription
template.metadata.labelsmapLabels every member carries, beside the pool’s own pools.ringleader.dev/pool label.
template.specobjectRequired. Each member’s spec, checked as a Workstation’s would be. stopped, lifecycle, ttl, ttlAction and the nonce fields are refused.
size.maxintRequired. The most members at once, from 1 to 1000.
warm.running, warm.stoppedintIdle members kept running and kept stopped. Default 0 each. Their sum may not exceed size.max.
idle.stopAfter, idle.deleteAfterstringHow long an idle member above the floors stays running, then how long it stays stopped. Default 15m and 72h.
maxAgestringHow long any member is kept. An idle member past it is deleted and never recycled. Default 168h.
lease.maxTermstringHow far ahead a lease’s expiresAt may be set, and its default when left out. Default 24h, at most 30 days.
lease.failedRetentionstringHow long a Failed lease is kept. Default 24h.
lease.maxPendingintHow many leases may wait at once, and how many Failed ones are kept. Default 100, at most 1000.
lease.configChangesstringApply or Hold. Whether a configuration change reaches a leased member at once, or waits until its lease ends. Absent means Apply.
recycleobjectPresent 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.

FieldTypeDescription
phasestringActive, Degraded when the template keeps failing or the owner may not create workstations, or Terminating.
messagestringThe one-line headline. A Terminating pool says how many leases it waits for and when the last one expires.
membersobjectCounts of provisioning, idleRunning, idleStopped, leased, recycling, retiring and cordoned members.
warmobject{running, stopped}: the idle members built from the current template, and not cordoned, that fill the two floors.
leasesobject{pending, oldestPendingSince}.
capacityintHow 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.
templateHashstringThe hash of the current template. A member built from another hash is replaced once idle.
configChangesstringThe mode in force, Apply or Hold.
observedGenerationintThe metadata.generation this status was computed for.
conditions[]objectTemplateHealthy, 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

FieldTypeDescription
phasestringPending, Bound, Ready, Releasing or Failed.
messagestringWhy a lease waits, or what ended it.
workstation, workstationUIDstringThe bound member.
boundAt, readyAtstringWhen the member was bound and became ready.
expiresAtstringThe expiry in force, the default included. It follows a renewal while the lease is Pending, Bound or Ready.
failedAtstringWhen the lease reached Failed, which starts lease.failedRetention.
releaseobject{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[]objectReady.

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