Extending the device pool
The @roku-sdk/tooling package includes a DeviceProvider interface (a seam, or extension point) so that you can plug your own device-acquisition strategy into the SDK. The built-in provider, LocalProvider, resolves to a single device from the layered devices store (~/roku-dev-config.json for the central list, with a per-worktree active-device override through .roku/roku-dev-config.json). If you operate a private device farm, an internal CI device pool, or any other custom acquisition strategy, you can register your own provider and select it with RK_DEVICE_POOL=<your-kind>.
The seam (the provider interface, the registry, and
LocalProvider) and the driving CLI subcommands (rk device lease acquire,rk device lease release,rk device lease reap, andrk device pool status) all ship in@roku-sdk/tooling. Custom providers register through the same registry that thelocalprovider uses. Once registered, you can address them withRK_DEVICE_POOL=<your-kind>from any CLI invocation. Seerk device leaseandrk device poolfor more information about the subcommands.
Deciding when you need a custom provider
The built-in local provider covers single-device development, and the existing RK_DEVICE_IP environment variable covers one-off overrides.
You should write a custom provider when you have:
- A pool of physical devices fronted by a coordination service (for example, a Selenium-grid-style allocator).
- A cloud-hosted Roku farm with a programmatic acquire-and-release API.
- A CI-specific selection strategy (for example, "round-robin across five devices, prefer ones matching the build's firmware").
If you need to pin a specific IP for a specific run, set RK_DEVICE_IP. You do not need a custom provider for this use case.
Implementing the DeviceProvider interface
DeviceProvider interfaceA provider is any class (or factory-returned object) that satisfies the following interface:
interface DeviceProvider {
readonly kind: ProviderKind; // string identifier, for example "my-org-cloud"
acquire(opts: AcquireOptions): Promise<DeviceLease>;
release(leaseId: string): Promise<void>;
status(): Promise<ProviderStatus>;
}acquire(opts)
The acquire method selects a device from your pool, marks it taken (however your pool tracks ownership), and returns a DeviceLease that downstream tooling reads to know which IP and credentials to use.
The AcquireOptions object is what the caller passes for each call. Per-call overrides (target, requireFw, and ttlSeconds) take precedence over the same-named field inside opts.resolvedConfig. You can read AcquireOptions for what was requested in this call; you do not need to work out resolution precedence again. The following interface defines AcquireOptions:
interface AcquireOptions {
target?: string; // device id / IP / tag the caller insisted on
tags?: string[]; // free-form filter hints
requireFw?: string; // semver firmware requirement
ttlSeconds: number; // lease lifetime; must be greater than 0
purpose: string; // free-form caller-stamped purpose, written into the lease
ci: { pipelineId: string; jobId: string; retryIndex: number };
resolvedConfig: ResolvedConfig; // immutable audit trail of the layered resolution
onBusy?: "wait" | "fail" | "cloud"; // hint when no device matches; provider may ignore
force?: boolean; // Opt-in sticky-state recovery: a provider can run a
// one-shot reset (DELETE sideloaded app + ECP Home)
// against a `busy` candidate and re-probe before
// declaring it un-acquirable. Optional for providers
// to honor; ignore it if your pool's "busy" semantics
// do not map to the Roku installer-surface model.
passwordRef?: string; // Opt-in. Caller-supplied passwordRef (for example,
// "env:DEVICE_PASSWORD_DESK_A") that must be
// written verbatim into the lease. Validated
// against the version 1 prefix policy (env:NAME |
// literal:VALUE). When set, your provider
// should prefer it over its internal default
// because that is the audit invariant that lets a
// custom-pool lease be byte-comparable to a
// CI-pool lease for the same device. Ignore
// if your pool has its own credential plumbing.
}Inventory schema
If your provider reads its inventory from a JSON environment variable (the canonical pattern, and what the local pool does), @roku-sdk/tooling ships a public Zod schema that you can reuse. See schemas/device-inventory.ts for more information about DeviceInventorySchema and DeviceInventoryDeviceSchema. You can use any environment variable name; the schema does not require a specific name. The shape is { schemaVersion: 1, devices: [{ id, ip, passwordRef, tags?, disabled? }] }. A provider may extend an entry with its own keys (a firmware pin, for example). The public schema strips unknown keys; therefore, one inventory can feed both a narrower provider schema and this one without forking.
The DEVICE_INVENTORY_ENV_VAR constant was exported alongside those schemas up to @roku-sdk/tooling 2.14.0 and was removed in 2.15.0. It held the environment variable name that one Roku-internal pool reads. A provider outside Roku could not usefully honor that name, and exporting it implied that the schema requires a specific name. If you imported it, declare your own constant for whichever variable your provider reads.
The returned DeviceLease must satisfy DeviceLeaseSchema:
{
schemaVersion: 1,
leaseId: string, // ULID, 26 characters Crockford base32
providerKind: string, // your provider's kind
ip: string,
passwordRef: string, // "env:NAME" or "literal:VALUE"; see Handling passwords with passwordRef
acquiredAt: string, // ISO-8601 UTC
expiresAt: string, // ISO-8601 UTC
owner: { pid: number, host: string },
ci?: { pipelineId, jobId, retryIndex } // optional for interactive use
// additional fields are tolerated via .passthrough()
}release(leaseId)
release(leaseId)The release method frees the device. It must be idempotent: calling release twice for the same lease (for example, once from a CI after_script and once from a periodic reaper) must not throw. The canonical "released" signal is the absence of the lease file. If you maintain additional pool-side state (for example, a cloud instance that you stop), do that as best effort and ignore "already released" errors.
status()
status()The status method returns a snapshot of the provider's current inventory:
interface ProviderStatus {
kind: ProviderKind;
devices: Array<{
id: string;
ip: string;
state: "free" | "busy" | "leased" | "unreachable";
fw?: string;
}>;
}The rk device pool status command uses it for triage. You should keep implementations lightweight because the command calls this method interactively.
Skeleton implementation
The following skeleton shows a provider that writes lease files. Replace the pool-specific methods with calls to your pool API:
// my-org/src/device-pool/my-org-provider.ts
import {
type DeviceProvider,
type AcquireOptions,
type DeviceLease,
type ProviderStatus,
type ResolvedConfig,
DeviceLeaseSchema,
DevicePoolError,
atomicWriteJson,
leaseFilePath,
registerDeviceProvider
} from "@roku-sdk/tooling";
import { ulid } from "ulid";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export class MyOrgProvider implements DeviceProvider {
public readonly kind = "my-org" as const;
constructor(
private readonly args: {
cwd: string;
resolvedConfig: ResolvedConfig;
leaseDir?: string;
env?: NodeJS.ProcessEnv;
}
) {
if (args.resolvedConfig.pool !== "my-org") {
throw new DevicePoolError(
"RESOLVER_FORBIDDEN_COMBO",
{ providerKind: "my-org", resolvedPool: args.resolvedConfig.pool },
`MyOrgProvider constructed with resolvedConfig.pool="${args.resolvedConfig.pool}"; expected "my-org".`
);
}
}
async acquire(opts: AcquireOptions): Promise<DeviceLease> {
if (!Number.isFinite(opts.ttlSeconds) || opts.ttlSeconds <= 0) {
throw new DevicePoolError(
"RESOLVER_FORBIDDEN_COMBO",
{ ttlSeconds: opts.ttlSeconds },
`MyOrgProvider.acquire: ttlSeconds must be a positive finite number.`
);
}
const device = await this.allocateFromYourPool(opts);
if (!device) {
throw new DevicePoolError(
"DEVICE_BUSY",
{ tags: opts.tags, requireFw: opts.requireFw },
`MyOrgProvider: no free device matches the request.`
);
}
const acquiredAt = new Date();
const lease: DeviceLease = {
schemaVersion: 1,
leaseId: ulid(),
providerKind: this.kind,
ip: device.ip,
passwordRef: "env:RK_DEVICE_PASSWORD",
acquiredAt: acquiredAt.toISOString(),
expiresAt: new Date(acquiredAt.getTime() + opts.ttlSeconds * 1000).toISOString(),
owner: { pid: process.pid, host: os.hostname() },
ci: opts.ci,
purpose: opts.purpose
};
const validated = DeviceLeaseSchema.parse(lease);
const leaseDir = this.args.leaseDir ?? path.join(this.args.cwd, ".roku", "leases");
await atomicWriteJson(leaseFilePath(lease.leaseId, leaseDir), validated);
return validated;
}
async release(leaseId: string): Promise<void> {
const filePath = leaseFilePath(leaseId, this.args.leaseDir ?? path.join(this.args.cwd, ".roku", "leases"));
try {
await fs.unlink(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
await this.releaseInYourPool(leaseId).catch(() => {
// best-effort cleanup; ignore "already released"
});
}
async status(): Promise<ProviderStatus> {
const inventory = await this.snapshotYourPool();
return {
kind: this.kind,
devices: inventory.map(d => ({ id: d.id, ip: d.ip, state: d.state, fw: d.fw }))
};
}
// ----- your pool-specific code -----
private async allocateFromYourPool(_opts: AcquireOptions): Promise<{ ip: string } | undefined> {
throw new Error("implement against your pool API");
}
private async releaseInYourPool(_leaseId: string): Promise<void> {
throw new Error("implement against your pool API");
}
private async snapshotYourPool(): Promise<Array<{ id: string; ip: string; state: ProviderStatus["devices"][number]["state"]; fw?: string }>> {
throw new Error("implement against your pool API");
}
}Registering your provider
Registering a provider takes two parts: declare the factory, and then trigger its load.
Declaring the factory (top-level call)
Add the following call in the same module as your provider class:
registerDeviceProvider(
"my-org",
(resolvedConfig) =>
new MyOrgProvider({
cwd: process.cwd(),
resolvedConfig
})
);The registry invokes the factory on every getDeviceProvider("my-org", cfg) call. Re-creating the provider for each call lets the factory bind to per-call config (region, inventory snapshot hash, and so on) without leaking state across calls.
Triggering the load (side-effect import)
Code that uses your provider must load the file once before it calls getDeviceProvider("my-org", …). A side-effect import at the top of the entry point of that code does this:
// my-org/src/test-bootstrap.ts
import "./device-pool/my-org-provider"; // self-registers
import { getDeviceProvider, resolveConfig } from "@roku-sdk/tooling";
const { config } = await resolveConfig({
defaults: { pool: "my-org", profile: "ci-default", ttlSeconds: 1800, routing: "primary-preferred" },
profilePath: ".roku/profiles.json",
envOverlay: process.env,
cliFlags: {}
});
const provider = getDeviceProvider("my-org", config);
const lease = await provider.acquire({
ttlSeconds: 600,
purpose: "smoke-test",
ci: { pipelineId: "local", jobId: String(process.pid), retryIndex: 0 },
resolvedConfig: config
});
// … use lease.ip / resolvePasswordRef(lease.passwordRef, process.env, lease.providerKind) …
await provider.release(lease.leaseId);If you are publishing your provider as its own npm package, you must ship a side-effect-only entry point (for example, import "@my-org/roku-pool") that runs the registration when imported. You must document this in your package README because module load order matters.
Handling passwords with passwordRef
passwordRefThe lease file never contains a plaintext password. Instead, it carries a reference (passwordRef) that the code reading the lease resolves at read time through resolvePasswordRef(). The schema accepts two prefixes in version 1:
| Prefix | Meaning | When allowed |
|---|---|---|
env:NAME | Read process.env[NAME] at resolve time. | Always. This is the production path. |
literal:VALUE | Embed the password directly. | Only when both RK_ALLOW_LITERAL_PASSWORD=1 and providerKind === "local" |
Other prefixes (keychain: and so on) are reserved for version 2, and the schema rejects them.
For your custom provider, always emit env:NAME:
passwordRef: "env:RK_DEVICE_PASSWORD"
// or, if you map per-device:
passwordRef: `env:MY_ORG_PASSWORD_${device.id.toUpperCase()}`Reading the password back (for example, inside device-test-client):
import { readLeaseFile, resolvePasswordRef } from "@roku-sdk/tooling";
const lease = await readLeaseFile(process.env.RK_DEVICE_LEASE_ID!, ".roku/leases");
const password = resolvePasswordRef(lease.passwordRef, process.env, lease.providerKind);The resolvePasswordRef function throws DevicePoolError("INVALID_LEASE_FILE", …) if the environment variable is unset or empty, the prefix is unknown, or the literal-policy gate fails. You must not log the resolved password.
Error handling
You must throw only DevicePoolError (or a subclass) with one of the stable codes in the following table:
| Code | When to use |
|---|---|
PROVIDER_NOT_REGISTERED | Thrown by the registry; you should not throw this directly. |
INVALID_PROFILE | Malformed .roku/profiles.json or unknown profile name |
INVALID_LEASE_FILE | The lease file is corrupt, passwordRef cannot be resolved, or the provider cannot construct a valid lease (for example, because a password source does not exist). |
RESOLVER_FORBIDDEN_COMBO | Contradictory inputs (for example, pool=cloud + RK_DEVICE_IP=…); also for input-validation failures inside your provider |
DEVICE_BUSY | All matching devices are taken. |
DEVICE_UNREACHABLE | Selected device fails network or ECP probe. |
FW_MISMATCH | Device firmware does not satisfy requireFw. |
The code strings are part of the public contract: telemetry, alerting, and code that reads the errors branch on them. New codes may be added without warning, but existing codes never get renamed.
The following example throws DEVICE_BUSY with structured context:
throw new DevicePoolError(
"DEVICE_BUSY",
{ tags: opts.tags, attemptedDevices: tried.map(d => d.id) },
`MyOrgProvider: no free device matches tags=${opts.tags?.join(",") ?? "<none>"}; tried ${tried.length} candidates.`
);The context object is for structured telemetry; the message is for human triage. You should keep context keys stable so that log queries stay valid across releases.
Verifying release invariants
You should verify that your release implementation meets the following invariants:
- The
release(leaseId)method is idempotent: the second call must not throw. - Lease-file deletion is the canonical "released" signal: the reaper checks whether the file is absent.
- Pool-side cleanup (for example, a cloud instance stop) is best effort: ignore "already released" and "not found" errors so that retries stay idempotent.
- Only the lease-file unlink failure is allowed to propagate.
Understanding the layered configuration that your provider sees
The AcquireOptions.resolvedConfig field is an immutable audit trail of how the configuration was derived. Each field carries both the value and the source layer (default | profile | env | cli). You should stamp it into your lease (through .passthrough() or by extending the schema) so that triage can answer "why did this run pick this provider?" without re-running the resolver. The following example shows a resolved configuration:
{
pool: "my-org",
poolSource: "env", // came from RK_DEVICE_POOL=my-org
profile: "ci-default",
profileSource: "default",
ttlSeconds: 1800,
ttlSecondsSource: "default",
routing: "primary-preferred",
target: "device-42",
targetSource: "cli" // came from --target=device-42
// … inventorySource, firmwareDriftDetected: populated by providers that use them
}To produce one yourself (for example, inside your bootstrap script):
import { resolveConfig } from "@roku-sdk/tooling";
const { config, warnings } = await resolveConfig({
defaults: {
pool: "my-org",
profile: "ci-default",
ttlSeconds: 1800,
routing: "primary-preferred"
},
profilePath: ".roku/profiles.json",
envOverlay: process.env,
cliFlags: { /* parsed --pool, --profile, --ttl, --target */ }
});
for (const w of warnings) console.warn(w);The resolveConfig function runs the layered precedence (default → profile → env → cli), validates the result against ResolvedConfigSchema, and throws DevicePoolError for malformed profiles or contradictory inputs.
Testing your provider
The registry is a process-wide singleton; therefore, test files normally rely on Jest's per-file module isolation. If you need to reset between cases inside one file, you can import the test-only reset function directly from the registry module (it is not in the package entry point):
import { __resetProviderRegistryForTests } from "@roku-sdk/tooling/src/device-pool/providers/registry";
beforeEach(() => __resetProviderRegistryForTests());The following example shows a complete acquire-and-release round trip:
import { jest } from "@jest/globals";
import { getDeviceProvider, readLeaseFile } from "@roku-sdk/tooling";
it("acquires and releases a device", async () => {
await jest.isolateModulesAsync(async () => {
await import("../src/device-pool/my-org-provider"); // self-registers
const provider = getDeviceProvider("my-org", {
pool: "my-org",
poolSource: "default",
profile: "ci-default",
profileSource: "default",
ttlSeconds: 1800,
ttlSecondsSource: "default",
routing: "primary-preferred"
});
const lease = await provider.acquire({
ttlSeconds: 60,
purpose: "test",
ci: { pipelineId: "test", jobId: "1", retryIndex: 0 },
resolvedConfig: /* same as above */ undefined as never
});
const persisted = await readLeaseFile(lease.leaseId, ".roku/leases");
expect(persisted.providerKind).toBe("my-org");
await provider.release(lease.leaseId);
await provider.release(lease.leaseId); // idempotent
});
});Selecting your provider at runtime
Once registered, you select your provider through RK_DEVICE_POOL=my-org (or by passing pool: "my-org" into the defaults of resolveConfig). The rk subcommands route through the same registry. The following example runs two subcommands with the provider selected:
$ RK_DEVICE_POOL=my-org rk device lease acquire --json
{ "schemaVersion": 1, "leaseId": "01HE7P5RZQ…", "providerKind": "my-org", "ip": "…", … }
$ RK_DEVICE_POOL=my-org rk device pool status
[device-pool] kind=my-org
ID IP STATE FW
device-42 10.0.0.42 free 15.2.0
device-43 10.0.0.43 busy 15.2.0If you prefer not to set an environment variable, you can pass --pool my-org explicitly to each subcommand.
Understanding current limitations
You cannot do the following:
- Subclass
LocalProvider. The class is not part of the public surface; the registry-based seam is the contract. If you need local-provider behavior plus extras, you can write your own provider that internally calls the sametooling-utilshelpers. - Override
RK_*environment variable semantics. Theenv-allowlistin@roku-sdk/toolingis closed for the public package; only the documented set of genericRK_*variables participates in resolution. Custom keys are ignored. - Use inter-process mutual exclusion through the registry. The registry is in-process. If you need cross-process exclusion (CI parallelism), do it in your provider's pool API or through a mechanism such as GitLab's
resource_group.
Related documentation
device-pool/README.md: Describes the internal navigation of the implementing module.- rk device: Describes the base
rk devicesubcommands. - rk device lease: Describes the
acquire,release, andreapsubcommands. - rk device pool: Describes the
statussubcommand and theProviderStatusoutput shape. device-pool/: Contains the doc blocks that are the source of truth for every interface, schema, and error code.
Updated about 4 hours ago