Command and configure your devices
Define a command type, send it to a device or a group, approve it, undo it, roll out a signed policy in stages and let a playbook tighten it for a while.
Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
You will define a command, send it to devices that already enrolled, watch the receipt come back, undo it, then publish a policy bundle that rolls out in stages. It takes about 20 minutes. Devices always pull; nothing here opens a port on a device.
You need
- Devices enrolled with
mainsdk/device(specdevice-identity), each in a group such aslab. - A
devicesection inmain.yaml; every key has a default, so an empty one works:
device:
commands: { ttl: 15m, max-targets: 100, reaction-max: 50 }
policy:
rollout: { stages: ["5%", "25%", "100%"], hold: 1h, rollback-if: { failed: 5% } }
approve: 1
tighten: { report.interval: higher, upload.enabled: false }1. Define a command type
import { device } from "mainsdk/device";
import { mountCommands } from "mainsdk/device/commands";
import { s } from "mainsdk/schema";
mountCommands(device); // once, where the app starts: builds `device.commands` and serves its routes
device.commands.define("prices.clear", {
input: s.object({ region: s.string().max(16) }),
risk: "write",
undo: "prices.restore",
reaction: true,
});mountCommands is the one call that adds commands to device; mainsdk/device carries none of that code until you make it, so apps that only enrol devices stay small. A type is data and a name. There is no built-in command that runs a program or a script, and a type under the mainsdk. prefix is refused. Bound every string: an unbounded field named command, cmd, script, code, shell, exec or args fails mainsdk check with MAIN-DEVICE-0067.
2. Register the handler on the device
import { deviceClient, memoryKeyStore } from "mainsdk/device/client";
import { mountCommands } from "mainsdk/device/commands/client";
import { s } from "mainsdk/schema";
const client = deviceClient({ url: "https://app.example.com/mainsdk/device", keys: memoryKeyStore() });
mountCommands(client); // once, before `client.start()`: builds `client.commands`
async function dropLocalCopies(region: string): Promise<number> {
return region.length;
}
client.commands.handle("prices.clear", {
input: s.object({ region: s.string().max(16) }),
risk: "write",
run: async (input) => ({ cleared: await dropLocalCopies(String(input.region)) }),
});A handler that passes its input to system.run, eval, new Function, vm, child_process or a dynamic import() fails check with MAIN-DEVICE-0066. The device refuses a command it has no handler for, one signed by a key it does not trust, one past its expiry and one whose risk differs from its handler's; each refusal is a signed refused receipt with MAIN-DEVICE-0063.
3. Send it
import { device } from "mainsdk/device";
const batch = await device.commands.create("prices.clear", {
to: { group: "lab" },
input: { region: "eu" },
reason: "stale prices",
});You get one signed command per device. A group over max-targets needs confirm: <count> and otherwise fails with MAIN-DEVICE-0061. A destructive type waits for the people its approve names; an agent's create always waits for one person.
4. Read, cancel and undo
import { device } from "mainsdk/device";
const id = "cmd_01JC8Q4Z7N0ABCDEFGHJKMNPQR";
await device.commands.get(id); // state, result, approvals
await device.commands.cancel(id, { reason: "wrong group" }); // only before delivery
await device.commands.revert(id, { reason: "restore" }); // sends the type's undo commandEvery step writes an audit record with the input's digest, never the input. Each device keeps a hash-chained log of what it ran; if its head differs from the one the server computes from receipts, you get MAIN-DEVICE-0070 and a high device.command_log_mismatch event.
5. Publish a policy
import { device } from "mainsdk/device";
import { mountPolicy } from "mainsdk/device/policy";
import { s } from "mainsdk/schema";
mountPolicy(device); // once, where the app starts: builds `device.policy` and serves its route
device.policy.define(s.object({ report: s.object({ interval: s.number() }) }), {
tighten: { "report.interval": "higher" },
});
await device.policy.publish({ report: { interval: 60 } }, { reason: "baseline" });The bundle is signed and versioned. Devices only ever move forward, keep the last good policy when the app is unreachable and report what they run in every heartbeat. A version waits for approve people (MAIN-DEVICE-0051), rolls out stage by stage and backs itself out when the failed share passes rollback-if (MAIN-DEVICE-0049). rollback publishes an old version's content as a new version.
6. Let a playbook tighten, never loosen
playbooks:
rules:
quiet-uploads:
when: { signal: attack.suspected }
run: auto
do:
- device.policy.tighten: { path: upload.enabled, value: false }
for: 30mThe overlay lasts at most 24 hours, is carried inside the signed bundle and is checked again on the device. A step that would publish, roll back or loosen a policy is refused by check (MAIN-GUARD-0002). A device.command step takes only reaction: true types, at most reaction-max devices (the rest are refused with MAIN-GUARD-0003), and never a destructive one in a rule that runs auto (MAIN-GUARD-0001). Register the playbook actions once, after the two mount calls of steps 1 and 5:
import { app } from "mainsdk";
import { device } from "mainsdk/device";
import { deviceReactionActions } from "mainsdk/device/commands";
for (const def of deviceReactionActions({ commands: device.commands, policy: device.policy })) app().action(def);Test it
createCommandsRig() and createPolicyRig() from mainsdk/testing wire the app to simulated devices on the test clock, so rollouts, expiry and restarts run in milliseconds.
Next
- Spec
device-commandsanddevice-policyfor the criteria and wire formats. - Device for every call.
Was this page helpful?