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 (local or a custom kind registered with registerDeviceProvider). Defaults to $RK_DEVICE_POOL, then to local. Accepted by acquire, release, and reap, and by pool status (see rk device pool for more information).
  • --profile <name>: Sets the profile name from .roku/profiles.json. Defaults to $RK_DEVICE_POOL_PROFILE, then to ci-default. Only the acquire subcommand accepts this flag. The release, reap, and pool status subcommands do not need a profile (release reads its lease file, reap iterates the lease directory, and status takes only --pool) and reject --profile as an unknown option.
  • --lease-dir <path>: Overrides the directory that holds the <id>.json lease files. Defaults to $RK_DEVICE_LEASE_DIR, then to <cwd>/.roku/leases. Accepted by acquire, release, and reap. The pool status subcommand 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

The 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 the local pool, the command acquires an IPv4 address that is not in .roku/roku-dev-config.json as an ephemeral device (the device does not have persistent state, and you do not need to run rk device add). Custom pools resolve targets according to their provider implementation.

  • --password-ref <ref>: Pins the lease's passwordRef to a caller-supplied reference (for example, env:DEVICE_PASSWORD_DESK_A). The command validates the reference against the v1 prefix policy: env:NAME is always allowed, and literal:VALUE is allowed only on the local pool with RK_ALLOW_LITERAL_PASSWORD=1. For env:NAME, the environment variable must be non-empty at acquire time; otherwise, the call fails with INVALID_LEASE_FILE before 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's passwordRef for 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's ttlSeconds (1800 in ci-default).

  • --purpose <text>: Writes a free-form caller annotation into the lease for triage.

  • --force: Opts into one-shot sticky-state recovery on a busy candidate 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 ECP keypress/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 --force after a first attempt found no free device. The LocalProvider ignores this flag (it does not run a busy probe). When the provider invokes recovery, the resulting lease carries a stickyRecovery audit 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 the stickyRecovery audit 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 the pre-recovery line. If the channelId is the same, recovery did not take (likely hardware wedge; escalate to PDU). If the channelId is different, a sibling pipeline raced (a follow-up acquire will probably get a different device). If state=free, recovery worked.

The acquire subcommand returns the following exit codes:

CodeMeaning
0Lease written; id printed (or JSON on --json)
1Resolver 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) for busy, or (recovery requires the device be answering on installer & ECP ports) for unreachable (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 usable software-version string (rare; usually means the device is mid-reboot or its device-info body 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 pinned fw. Audit also rides on the eventually-acquired lease as firmwareDrift.

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.000Z

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

The 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_ID when unset; if neither is set, the subcommand exits with code 1 and MISSING_LEASE_ID.
  • --pool <kind>: Adds an optional safety check. If you supply it, it must match the lease's providerKind; otherwise, the subcommand exits with RESOLVER_FORBIDDEN_COMBO. The subcommand always reads the provider kind from the lease file itself; therefore, --pool never 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:

CodeMeaning
0Lease released, or already released (no-op)
1Lease file present but malformed or unreadable; provider release crashed

reap

The 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 readLeaseFile fails (the lease is corrupt or malformed), the command deletes the file (or, with --dry-run, records a (would delete) … action).
  • If expiresAt is in the past, the command calls the owning provider's release(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 own providerKind from the on-disk lease file; therefore, --pool is rarely needed here. The parser still accepts it to keep the flag set symmetric with acquire and release.
  • --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:

CodeMeaning
0Reap loop completed; no release-failed actions
1At 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

The following table shows how the RK_DEVICE_POOL value affects the provider:

RK_DEVICE_POOLProviderLease fileLayer-2 ECP probeInventory source
unset or localLocalProvider (built-in)yesno.roku/roku-dev-config.json
any other kindthe factory you registeredyesimplementation-definedimplementation-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 $LEASE

The 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


Did this page help you?