Converge the two containerised fleet-box stacks (M3's broker.sh --docker vs Ms's compose) - #83
Conversation
M3 and Ms each grew a way to run mosquitto in a container and did not know about each other. Ms had already folded the two pixi tasks into one and pointed compose at `server/mosquitto.conf`, so the config was shared; what was left was an image tag pinned in two places and docs that led with the workstation command rather than the deployment. The compose stack is the fleet box's deployment. `broker.sh` is the workstation convenience and now defers to it in both directions: it already used the shared mosquitto.conf, and it reads the image tag out of docker-compose.yml rather than carrying a default of its own. A literal here would be a second pin nobody would remember to bump. --local resolves nothing from the deploy directory, so a box with no docker is unaffected. The tag names a minor series, `2.1-alpine`, not the floating `:2`. What must not move under us is how websockets is built in: 2.0 links libwebsockets, 2.1 implements it natively, and `:2` follows whatever comes next. `-alpine` is not a variant choice — upstream publishes 2.1 only as `-alpine` tags, and `:2`, `:2.1-alpine` and `:2.1.2-alpine` are one manifest today, so this changes no bytes. The broker healthcheck now probes 9001 as well as 1883, because that failure is silent: mosquitto opens the MQTT listener and keeps running when the websockets one is refused, so robots and fleetctl stay healthy while the dashboard has nothing to subscribe to — and `fleet-deploy.sh up` gates on this probe, so it would have reported a good deploy. Measured on a broker with the websockets stanza stripped: the MQTT-only check reports healthy, the new one reports unhealthy. 9001 is a TCP probe because the failure being caught is "the listener never opened" and mosquitto_sub cannot speak websockets. README §6 now leads with compose and presents the pixi tasks as the same thing in the foreground. Verified: compose broker up --wait passes the new health gate on the pinned image; both listeners open under 2.1.2; the dashboard's own MQTT codec (server/ui/mqtt.mjs) completes CONNECT/SUBSCRIBE over the published WebSocket port. test_deploy_config.py holds the invariants without needing docker — one pin, no second tag, broker.sh reaching for the pinned image against a stub docker, one mounted config, the websockets stanza and its strip markers, no log_type override, and both ports in the healthcheck. 194 passed, 6 skipped across mote_fleet; pre-commit clean. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdGDhvwcDBmcgK9Gc294th
| assert result.returncode == 0, result.stderr | ||
| assert "DOCKER_ARGS: run " in result.stdout | ||
| assert pin in result.stdout, ( | ||
| f"broker.sh ran something other than the pinned {pin}:\n{result.stdout}" | ||
| ) |
There was a problem hiding this comment.
This assertion doesn't verify what the docstring claims — it can't detect a divergence between the image broker.sh logs and the image docker run actually receives.
broker.sh prints echo "broker: $IMAGE (docker) ..." to stdout at broker.sh#L88 before invoking docker run "$IMAGE" ... at broker.sh#L94. Since assert pin in result.stdout searches the whole combined stdout, it's already satisfied by that diagnostic echo alone — regardless of what argv the stubbed docker actually received. For example, if "$IMAGE" were accidentally dropped from the docker run invocation, or a typo referenced a different variable there, this test would still pass (the DOCKER_ARGS: run check only proves some docker invocation happened, not which image).
To actually test "which image it reaches for" (per the docstring), the assertion should be scoped to the stub's DOCKER_ARGS: line rather than the full stdout, e.g.:
argv_line = next(l for l in result.stdout.splitlines() if l.startswith("DOCKER_ARGS:"))
assert pin in argv_line.split(), f"docker run was handed something other than {pin}: {argv_line}"
Converge the fleet box on one containerised broker.
The premise had already moved
Two of the three symptoms in the task were stale. Ms (#63) had already folded
fleet-broker-wsintofleet-brokerand pointed compose atserver/mosquitto.conf, so the config was already shared.eclipse-mosquitto:2.0was never in the repo, and the 18831/19011/18080 ports are from the Ms
verification transcript (shifted +10000 to dodge a live broker), not shipped
config — the containers seen on the machine were that session's.
What was genuinely unconverged: the image tag pinned in two places, a stale
fleet-broker-wsreference in the compose file, docs leading with theworkstation command, and a healthcheck that could not see the failure the whole
websockets story exists to prevent.
Decision: compose is the deployment
broker.shis the workstation convenience and now defers to it in bothdirections — it already used the shared
mosquitto.conf, and it now reads theimage tag out of
docker-compose.ymlinstead of carrying a default. A literalthere would be a second pin nobody would remember to bump; if the line is
unreadable it fails loudly rather than falling back.
--localresolves nothingfrom the deploy directory, so a box with no docker is unaffected.
The tag names a minor series
2.1-alpine, not the floating:2. What must not move under us is howwebsockets is built in: 2.0 links libwebsockets, 2.1 implements it natively, and
:2follows whatever comes next.-alpineis not a variant choice — upstreampublishes 2.1 only as
-alpinetags, and:2,:2.1-alpineand:2.1.2-alpineare one manifest today, so this changes no bytes.
The silent failure is now caught
The broker healthcheck probes 9001 as well as 1883. Mosquitto opens the MQTT
listener and keeps running when the websockets one is refused, so robots and
fleetctlstay healthy while the dashboard has nothing to subscribe to — andfleet-deploy.sh upgates on this probe, so it would have reported a gooddeploy. Measured on a broker with the websockets stanza stripped:
9001 is a TCP probe because the failure being caught is "the listener never
opened", and
mosquitto_subcannot speak websockets to do better.Verified
docker compose up broker --waitpasses the new gate on the pinned image;both listeners open under 2.1.2.
server/ui/mqtt.mjs) completesCONNECT -> CONNACK -> SUBSCRIBE -> SUBACK over the published WebSocket port.
broker.shresolves the pin, honours aMOTE_BROKER_IMAGEoverride, andfails loudly on an unreadable compose file.
mote_fleet/test/test_deploy_config.py(8 tests, no docker needed) holdsthe invariants: one pin, no second tag anywhere,
broker.shreaching for thepinned image against a stub docker, one mounted config, the websockets stanza
and its strip markers, no
log_typeoverride (M3's silent-error guard), bothports in the healthcheck, and the published WS port reaching the browser.
It caught a real duplicate while being written — an example tag in
broker.sh's error message — which was fixed rather than excused.mote_fleet; pre-commit clean.Docs:
README.md§6 now leads with compose and presents the pixi tasks as thesame thing in the foreground;
server-pipelines.mdandCLAUDE.mdrecord thesingle pin and the two-listener gate.
NOT PUSHED — no remote writes were made, per the standing git rule. Branch
fleet-converge-broker(commit 3a25ef6) is local; worktree left at/home/michael/.claude/jobs/7d2e9f4f/wt.