rk device lease
The rk device lease command acquires, releases, and reaps device leases against a configured device pool.
Leases are the mechanism for sharing physical or cloud-hosted Roku devices across parallel jobs and operators. They replace the legacy "single-active-device" workflow when RK_DEVICE_POOL is set to anything other than local.
Synopsis
The following syntax shows the subcommands:
rk device lease acquire [options]
rk device lease release [options]
rk device lease reap [options]The subcommands share the following flags, and the sections below note which subcommands support each flag. The parser rejects unknown flags with Unknown option: --<name>; therefore, a mistyped flag fails the command:
--pool <kind>: Sets the provider kind to route through (localor a custom kind registered withregisterDeviceProvider). Defaults to$RK_DEVICE_POOL, then tolocal. Accepted byacquire,release, andreap, and bypool status(seerkdevice pool for more information).--profile <name>: Sets the profile name from.roku/profiles.json. Defaults to$RK_DEVICE_POOL_PROFILE, then toci-default. Only theacquiresubcommand accepts this flag. Therelease,reap, andpool statussubcommands do not need a profile (releasereads its lease file,reapiterates the lease directory, andstatustakes only--pool) and reject--profileas an unknown option.--lease-dir <path>: Overrides the directory that holds the<id>.jsonlease files. Defaults to$RK_DEVICE_LEASE_DIR, then to<cwd>/.roku/leases. Accepted byacquire,release, andreap. Thepool statussubcommand does not accept this flag because it does not read the lease directory.--json: Emits machine-readable JSON on stdout and never prints the ASCII banner or human-readable[device-lease]log lines. Every subcommand accepts this flag.
Lease file shape
A lease is a JSON document at <lease-dir>/<leaseId>.json validated by the public Zod schema DeviceLeaseSchema:
{
"schemaVersion": 1,
"leaseId": "01HE7P5RZQ…", // ULID, 26 characters
"providerKind": "custom-pool", // "local" or a custom kind
"ip": "192.0.2.197",
"passwordRef": "env:DEVICE_PASSWORD_UHD_01", // never plaintext
"acquiredAt": "2026-05-12T22:00:00.000Z",
"expiresAt": "2026-05-12T22:30:00.000Z",
"owner": { "pid": 12345, "host": "ci-runner-7" },
"ci": { "pipelineId": "…", "jobId": "…", "retryIndex": 0 }
}The passwordRef field is always a reference, never the password itself. Resolution happens at read time through resolvePasswordRef(...) (for example, device-test-client reads env:VAR_NAME from process.env).
acquire
acquireThe acquire subcommand resolves a free device from the configured pool, writes a lease file, and prints its id:
rk device lease acquire [--pool <kind>] [--profile <name>]
[--target <id|ipv4|name>] [--tag <t> ...]
[--require-fw <semver>] [--ttl <seconds>]
[--purpose <text>] [--lease-dir <path>]
[--password-ref <ref>] [--force] [--json]The acquire subcommand accepts the following flags:
-
--target <id|ipv4|name>: Pins the lease to a specific device. Accepts a configured device id or name, or a raw IPv4 literal. For thelocalpool, the command acquires an IPv4 address that is not in.roku/roku-dev-config.jsonas an ephemeral device (the device does not have persistent state, and you do not need to runrk device add). Custom pools resolve targets according to their provider implementation. -
--password-ref <ref>: Pins the lease'spasswordRefto a caller-supplied reference (for example,env:DEVICE_PASSWORD_DESK_A). The command validates the reference against the v1 prefix policy:env:NAMEis always allowed, andliteral:VALUEis allowed only on the local pool withRK_ALLOW_LITERAL_PASSWORD=1. Forenv:NAME, the environment variable must be non-empty at acquire time; otherwise, the call fails withINVALID_LEASE_FILEbefore any side effect (the command does not leave a half-written lease or a leaked advisory lock). When you omit the flag, the local provider falls back to its internal precedence (RK_DEVICE_PASSWORD, then the stored literal under the policy gate), and an inventory-driven provider uses the inventory'spasswordReffor each device. -
--tag <t>: Sets a required-subset tag filter. You can pass the flag multiple times to require multiple tags (for example,--tag uhd --tag hdr). The parser does not parse comma-separated lists because it uses the repeated-flag style. Every requested tag must appear in the inventory entry's tag set. You can omit the flag to match every device. -
--require-fw <semver>: Pins the firmware version with strict equality. The command intentionally does not support range syntax (for example,^15.0.0) because pinning to an exact version is the only way to keep planner-time and acquire-time consistency. -
--ttl <seconds>: Sets the lease time to live (TTL). Defaults to the profile'sttlSeconds(1800 inci-default). -
--purpose <text>: Writes a free-form caller annotation into the lease for triage. -
--force: Opts into one-shot sticky-state recovery on abusycandidate before declaring it un-acquirable. The provider attempts an authenticated DELETE of any sideloaded channel through the installer surface (/plugin_install, port 80, 25-second timeout; Roku's installer surface routinely takes 15 to 20 seconds for multipart deletes), sends an ECPkeypress/Home(port 8060, 5-second timeout; ECP is fast) twice with a gap of about 300 milliseconds between presses (defensive, because Roku's Application Installer page that the DELETE step itself surfaces sometimes swallows the first Home), waits about 3 seconds for the device UI to transition off the current app, and re-probes. You should use this flag only as a CI fallback. Passing it from an interactive shell kills an in-progress sideloaded session that a human developer holds. The usual shape is a CI job that retries once with--forceafter a first attempt found no free device. TheLocalProviderignores this flag (it does not run a busy probe). When the provider invokes recovery, the resulting lease carries astickyRecoveryaudit field with{ stepsAttempted, stepsSucceeded, perStep: [{ step, ok, status?, reason? }] }; absent when recovery was not needed. The provider's stderr trail is symmetric with three line types, and operators can grep on each independently:[force-clear] <id>: pre-recovery probe: state=busy, channelId="…", channelName="…"— Shows what the device was foregrounding before recovery, so triage knows which app stuck.[force-clear] <id>: <step>: <OK|FAIL> (status=<n>, reason="<msg>"); …— Shows the per-step outcome (mirrors thestickyRecoveryaudit field).[force-clear] <id>: post-recovery probe: state=<free|busy|unreachable>[, channelId="…", channelName="…"][, reason="…"]— Shows what the device is foregrounding after recovery. Compare it with thepre-recoveryline. If thechannelIdis the same, recovery did not take (likely hardware wedge; escalate to PDU). If thechannelIdis different, a sibling pipeline raced (a follow-up acquire will probably get a different device). Ifstate=free, recovery worked.
The acquire subcommand returns the following exit codes:
| Code | Meaning |
|---|---|
0 | Lease written; id printed (or JSON on --json) |
1 | Resolver or provider error; see stderr for the DevicePoolErrorCode |
When a probing provider exhausts every matching candidate without acquiring one, it throws its own no-free-device code, and the stderr trail explains each skip through one of three grep-able prefixes (the [force-clear] lines above are emitted only on the --force path; the two below cover the no-recovery paths, so an empty stderr never accompanies that failure). In these prefixes, <kind> is the provider kind:
[<kind>-acquire] <id> (ip=<ip>): probe state=<busy|unreachable>[, channelId="…", channelName="…"][, reason="…"] — skipping (<reason>): The command emits this line when the busy probe rejects the candidate and recovery is not applicable. The trailing(<reason>)is the operator's actionable hint:(--force not set; pass --force to attempt one-shot sticky-state recovery)forbusy, or(recovery requires the device be answering on installer & ECP ports)forunreachable(the recovery primitives cannot help an unreachable device; that is a power or network issue).[<kind>-acquire] <id> (ip=<ip>): firmware probe <threw: <msg> | returned empty/non-string (got <value>)> — skipping— The command emits this line when the busy probe passed but the firmware probe could not produce a usablesoftware-versionstring (rare; usually means the device is mid-reboot or itsdevice-infobody is malformed).[fw-drift] FW_DRIFT device=<id> ip=<ip> expectedFw=<a> liveFw=<b> detectedAt=<iso>— The command emits this line when both probes succeed but the live firmware version does not match the inventory's pinnedfw. Audit also rides on the eventually-acquired lease asfirmwareDrift.
By default, the subcommand prints text output:
[device-lease] acquired leaseId=01HE7P5RZQ… pool=shelf ip=192.0.2.197 ttlSeconds=1800 expiresAt=2026-05-12T22:30:00.000ZWith --json, the subcommand prints JSON output:
{ "schemaVersion": 1, "leaseId": "…", "providerKind": "shelf", "ip": "…", "passwordRef": "<redacted>", … }The command redacts the passwordRef in the JSON output on stdout to keep CI logs free of variable names. The on-disk lease file keeps the original passwordRef.
release
releaseThe release subcommand releases a lease by id. It is idempotent (running it again has the same result): releasing a missing or already-released lease exits with code 0 and a no-op log line. The following syntax shows how to run the subcommand:
rk device lease release [--lease-id <id>] [--pool <kind>] [--lease-dir <path>] [--json]The release subcommand accepts the following flags:
--lease-id <id>: Sets the lease id to release. Falls back to$RK_DEVICE_LEASE_IDwhen unset; if neither is set, the subcommand exits with code 1 andMISSING_LEASE_ID.--pool <kind>: Adds an optional safety check. If you supply it, it must match the lease'sproviderKind; otherwise, the subcommand exits withRESOLVER_FORBIDDEN_COMBO. The subcommand always reads the provider kind from the lease file itself; therefore,--poolnever changes routing.--lease-dir <path>: Overrides the directory where the command reads the lease file. Defaults to$RK_DEVICE_LEASE_DIR, then<cwd>/.roku/leases.
The release subcommand returns the following exit codes:
| Code | Meaning |
|---|---|
0 | Lease released, or already released (no-op) |
1 | Lease file present but malformed or unreadable; provider release crashed |
reap
reapThe reap subcommand scans the lease directory and releases expired or corrupt leases. It is designed for periodic scheduled jobs and after_script teardowns. The command makes the following decisions for each lease:
- If
readLeaseFilefails (the lease is corrupt or malformed), the command deletes the file (or, with--dry-run, records a(would delete) …action). - If
expiresAtis in the past, the command calls the owning provider'srelease(leaseId)(idempotent, ENOENT-tolerant). - If the lease is live (still within its TTL), the command skips it.
The following syntax shows how to run the subcommand:
rk device lease reap [--pool <kind>] [--lease-dir <path>] [--dry-run] [--json]The reap subcommand accepts the following flags:
--pool <kind>: Accepts an optional provider kind. The command routes each lease through its ownproviderKindfrom the on-disk lease file; therefore,--poolis rarely needed here. The parser still accepts it to keep the flag set symmetric withacquireandrelease.--lease-dir <path>: Overrides the directory that the command scans. Defaults to$RK_DEVICE_LEASE_DIR, then<cwd>/.roku/leases.--dry-run: Reports what would be reaped without modifying any files. You can use it for alerting and dashboards.
With --json, the subcommand prints JSON output:
{
"leaseDir": "/abs/path/to/.roku/leases",
"dryRun": false,
"actions": [
// Each action carries `kind` plus kind-specific fields:
{ "kind": "released", "leaseId": "01HE…", "providerKind": "shelf" },
{ "kind": "deleted-corrupt", "leaseId": "01HF…", "reason": "INVALID_LEASE_FILE: …" },
{ "kind": "skipped-live", "leaseId": "01HG…", "expiresAt": "2026-05-12T22:30:00.000Z" },
{ "kind": "release-failed", "leaseId": "01HH…", "providerKind": "shelf", "reason": "…" }
]
}With --dry-run, the command emits the same shape, with dryRun: true and kind: "deleted-corrupt" reasons prefixed (would delete) … for files that it would have removed.
By default, the subcommand prints text output:
[device-lease] reap leaseDir=/abs/.roku/leases dryRun=false examined=3
released 01HE… (shelf)
skipped 01HG… (expiresAt=2026-05-12T22:30:00.000Z)
FAILED 01HH… shelf -> NO_SUCH_LEASE: …The reap subcommand returns the following exit codes:
| Code | Meaning |
|---|---|
0 | Reap loop completed; no release-failed actions |
1 | At least one lease produced a release-failed action (partial reap). The provider release error message lives in the action's reason field. |
RK_DEVICE_POOL behavior matrix
RK_DEVICE_POOL behavior matrixThe following table shows how the RK_DEVICE_POOL value affects the provider:
RK_DEVICE_POOL | Provider | Lease file | Layer-2 ECP probe | Inventory source |
|---|---|---|---|---|
unset or local | LocalProvider (built-in) | yes | no | .roku/roku-dev-config.json |
| any other kind | the factory you registered | yes | implementation-defined | implementation-defined |
The rk tool registers only LocalProvider; therefore, any other kind must come from your own registerDeviceProvider call. Asking for a kind that is not registered fails with PROVIDER_NOT_REGISTERED. See Extending the device pool for more information.
Examples
The following examples show common ways to use the subcommands.
The following command acquires a lease interactively from the built-in local pool, which uses .roku/roku-dev-config.json:
$ rk device lease acquire --pool local --ttl 600
[device-lease] acquired leaseId=01HE7P5RZQ… pool=local ip=192.168.1.42 ttlSeconds=600 …The following commands acquire a lease in continuous integration (CI) from a registered pool, and set RK_DEVICE_LEASE_ID for the resolver's Layer 0:
$ LEASE=$(rk device lease acquire --pool shelf --json | jq -r .leaseId)
$ export RK_DEVICE_LEASE_ID=$LEASE
$ # …run tests…
$ rk device lease release --lease-id $LEASEThe following command reaps leases periodically, for example from a cron or scheduled job. A lease directory that mixes several provider kinds routes cleanly because the reaper resolves each lease's providerKind independently:
$ rk device lease reap --json
{ "leaseDir": "/abs/path/to/.roku/leases", "dryRun": false, "actions": [...] }Related documentation
- rk device pool: Describes how to query the inventory and live state with
pool status. - Extending the device pool: Describes how to register a custom
DeviceProvider. - rk device: Describes the base
rk devicesubcommands.
Updated about 6 hours ago