fix(music-assistant): reconcile publish_ip/bind_ip to node's live v30iot IP #131

Merged
k.renner merged 1 commits from worktree-fix+music-assistant-sendspin-publish-ip into master 2026-07-08 15:28:07 +00:00
Owner

Problem

Music Assistant isn't finding Sendspin (ESP32 / HA Voice PE) players.

MA persists publish_ip (the literal IP advertised to players) and bind_ip in /data/settings.json. A prior hotfix pinned both to 10.2.30.137, because MA's auto-detect picks the mgmt default-route IP (eth1/v30iot carries routeMetric 4096, so it's never the default route) — which IoT players can't reach.

MA now runs on k8s-2, whose DHCP eth1 lease is 10.2.30.193, so the stored .137 is no longer a local address. On startup:

  • the stream server falls back to all-interfaces and survives;
  • the Sendspin server (aiosendspin, :8927) has no fallback — it fails to bind and dies, and sendspin_proxy.py then targets the dead .137 instead of 127.0.0.1. Result, on every player request:
ERROR [aiosendspin.server.server] Failed to start server on 10.2.30.137:8927: could not bind on any address
ERROR [music_assistant.sendspin_proxy] Failed to connect to internal Sendspin server

eth1 is DHCP and MA is not node-pinned, so any stored IP goes stale on the next reschedule or lease change — pinning an IP just recreates this bug.

Fix

A boot-time initContainer that, before MA starts:

  1. discovers this node's current 10.2.30.0/24 address from the live interface (hostNetwork sees eth1 directly; selects by subnet, not a fixed IP or interface name);
  2. writes publish_ip=<that IP> and bind_ip=0.0.0.0 into core/streams/values in settings.json (idempotent; preserves all other keys; atomic replace).

bind_ip=0.0.0.0 is MA's own default and makes the Sendspin server bind all interfaces + its proxy use loopback, so it can never die on a bad bind again. publish_ip is the only genuinely dynamic value, recomputed every boot — no static IP, no node pinning, no DHCP reservation.

Reuses the MA image (Python stdlib only; the $imagepolicy marker keeps the init tag in lockstep with the main container) and fails closed if no v30iot address is found, so MA never advertises a wrong IP.

Validation

  • kubectl kustomize renders cleanly (ConfigMap + Service + Deployment + Ingress).
  • reconcile.py syntax-checked; fail-closed path exercised; JSON patch + idempotency tested against a mock settings.json (sibling keys survive, .137 -> host IP, bind_ip -> 0.0.0.0).

Rollout note

Single deployment, Recreate strategy. After merge/reconcile, confirm the reconcile-publish-ip initContainer logs the discovered IP and the change, and that Sendspin players reappear, before considering it done.

## Problem Music Assistant isn't finding Sendspin (ESP32 / HA Voice PE) players. MA persists `publish_ip` (the literal IP advertised to players) and `bind_ip` in `/data/settings.json`. A prior hotfix pinned both to `10.2.30.137`, because MA's auto-detect picks the **mgmt default-route IP** (eth1/v30iot carries `routeMetric 4096`, so it's never the default route) — which IoT players can't reach. MA now runs on **k8s-2**, whose DHCP `eth1` lease is `10.2.30.193`, so the stored `.137` is no longer a local address. On startup: - the **stream server** falls back to all-interfaces and survives; - the **Sendspin server** (`aiosendspin`, `:8927`) has **no fallback** — it fails to bind and dies, and `sendspin_proxy.py` then targets the dead `.137` instead of `127.0.0.1`. Result, on every player request: ``` ERROR [aiosendspin.server.server] Failed to start server on 10.2.30.137:8927: could not bind on any address ERROR [music_assistant.sendspin_proxy] Failed to connect to internal Sendspin server ``` `eth1` is DHCP and MA is **not** node-pinned, so any stored IP goes stale on the next reschedule or lease change — pinning an IP just recreates this bug. ## Fix A boot-time `initContainer` that, before MA starts: 1. discovers this node's **current** `10.2.30.0/24` address from the live interface (hostNetwork sees `eth1` directly; selects by *subnet*, not a fixed IP or interface name); 2. writes `publish_ip=<that IP>` and `bind_ip=0.0.0.0` into `core/streams/values` in `settings.json` (idempotent; preserves all other keys; atomic replace). `bind_ip=0.0.0.0` is MA's own default and makes the Sendspin server bind all interfaces + its proxy use loopback, so it can never die on a bad bind again. `publish_ip` is the only genuinely dynamic value, recomputed every boot — no static IP, no node pinning, no DHCP reservation. Reuses the MA image (Python stdlib only; the `$imagepolicy` marker keeps the init tag in lockstep with the main container) and **fails closed** if no v30iot address is found, so MA never advertises a wrong IP. ## Validation - `kubectl kustomize` renders cleanly (ConfigMap + Service + Deployment + Ingress). - `reconcile.py` syntax-checked; fail-closed path exercised; JSON patch + idempotency tested against a mock `settings.json` (sibling keys survive, `.137` -> host IP, `bind_ip` -> `0.0.0.0`). ## Rollout note Single deployment, `Recreate` strategy. After merge/reconcile, confirm the `reconcile-publish-ip` initContainer logs the discovered IP and the change, and that Sendspin players reappear, before considering it done.
k.renner added 1 commit 2026-07-08 15:26:41 +00:00
Music Assistant persists publish_ip (the literal IP advertised to players)
and bind_ip in /data/settings.json. A prior hotfix pinned both to
10.2.30.137 because MA's auto-detect picks the mgmt default-route IP (eth1
carries routeMetric 4096, so v30iot is never the default route), which IoT
players can't reach.

After MA moved to k8s-2, whose DHCP eth1 lease is 10.2.30.193, the stored
.137 is no longer local. The stream server falls back to all-interfaces and
survives, but the Sendspin server (aiosendspin :8927) has no fallback: it
fails to bind and dies, and its proxy then targets the dead .137 instead of
loopback -> Sendspin/Voice PE players never register.

eth1 is DHCP and MA is not node-pinned, so any stored IP goes stale on the
next reschedule or lease change. Instead of pinning an IP, an initContainer
recomputes the current v30iot (10.2.30.0/24) address from the live interface
on every boot and writes publish_ip=<that IP> and bind_ip=0.0.0.0 into
settings.json before MA reads it. hostNetwork means it sees eth1 directly;
it reuses the MA image (stdlib only) and fails closed if no v30iot address
is present, so MA never comes up advertising a wrong IP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
k.renner merged commit c012e8d730 into master 2026-07-08 15:28:07 +00:00
Sign in to join this conversation.