Deploying over MQTT
For fleets behind NAT, on cellular links, or wherever a broker already exists. The device connects out; you never need to reach in.
The conversation
sequenceDiagram
autonumber
participant FM as Fleet manager
participant BR as Broker
participant AG as Agent
AG->>BR: connect, LWT on .../status
AG->>BR: publish "online" on .../status
FM->>BR: publish .../cmd/apply
BR->>AG: deliver (QoS 1)
AG->>AG: store recipes, then reconcile
AG->>BR: publish .../resp/apply
BR->>FM: deliver response
AG->>BR: .../events/state every 10sIf the device drops off the network, the broker publishes offline on its
behalf ā presence without polling, and it works precisely when the device cannot
tell you anything itself.
Recipes and plan in one message
This is the payload worth knowing about. cmd/apply accepts the recipes it needs
alongside the plan, stored before the plan is reconciled, in the same message:
Without that, you would publish cmd/add-recipe for each recipe, then cmd/apply,
and hope they arrive in order ā a race with no clean fix over a lossy link. One
message removes it.
Fields (ApplyRequest):
| Field | Meaning |
|---|---|
correlationId | Echoed back in the response, so you can match them |
commandId | Identifies the command. A second delivery of the same id is ignored |
content | The plan TOML |
recipes | Recipe TOML documents, stored (with force) before reconciling |
dry | true validates and reports without changing anything |
There is no planPath. It named a file on the device for the agent to read and
execute, which is a local-file-inclusion vector, and it was removed ā the HTTP
adapter had refused it for the same reason for some time. A request that still
carries one is rejected by name rather than falling through to “content
required”.
Duplicates and retained messages
Two things about MQTT that matter more for commands than for telemetry:
A command must never be published with
retain. A retained message is redelivered on every subscribe, so a device that has been offline for weeks executes the order the moment it reconnects ā including one that was withdrawn in the meantime. The agent refuses retained commands outright.Testing this needs the agent stopped. MQTT only sets the retained flag on delivery to a client that subscribes afterwards; publishing to a topic an agent is already subscribed to delivers an ordinary message with the flag clear. So publishing with
-rwhile the agent runs does not exercise the guard at all ā the command is simply executed, and any refusal you see came from somewhere else. To see it work, stop the agent, publish the retained message, then start it. That is also the situation the guard exists for: a gateway reconnecting to an order that has been sitting on the broker.Remember to clear a retained message afterwards (
mosquitto_pub -r -t <topic> -n), or every reconnect keeps meeting it.QoS 1 is at-least-once by design. A duplicate delivery is the protocol working correctly, not a broker fault. For a status query that is noise; for an apply it is a second deployment. Send a
commandIdand the agent executes it once. Without one it cannot tell a retry from a deliberate repeat, and will run both.
Both checks apply to the commands that change something ā apply, stop, restart, stop-comp, add-recipe ā and not to the read-only ones, which are the commands an operator reaches for when things are already going wrong.
Building and publishing it
Assembling that JSON by hand is unpleasant; let jq do the escaping:
The response, on keystone/acme/edge-001/resp/apply:
On failure, success is false and error carries the reason ā including the
rollback summary if one happened.
The other commands
Every command is keystone/{tenant}/{deviceId}/cmd/<name> with the response on
keystone/{tenant}/{deviceId}/resp/<name>:
Commands that need no arguments still take {} ā or a {"correlationId":"ā¦"} if
you want the response matched.
Watching a fleet
Two things to get right
QoS 1 means duplicates. A command can be delivered twice. That is safe here because applying the same plan twice changes nothing ā but if you wrap Keystone in your own automation, keep that property.
Broker ACLs are part of your security. Restrict each device’s credentials to
its own keystone/{tenant}/{deviceId}/# subtree, and make each device subscribe-only
on its command topics. A device publishes responses and events; it never issues
commands. If a compromised gateway can publish to a command topic ā its own or
another’s ā the ACL is decoration, and that is the property that makes sharing a
broker with the data plane acceptable.