From a08bfe8f558fd29072ef288089552f73dbe6c970 Mon Sep 17 00:00:00 2001 From: Konstantin Renner Date: Wed, 8 Jul 2026 17:25:07 +0200 Subject: [PATCH] fix(music-assistant): reconcile publish_ip/bind_ip to node's live v30iot IP 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= 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) --- .../configmap-net-reconcile.yaml | 111 ++++++++++++++++++ .../apps/music-assistant/deployment.yaml | 28 +++++ .../apps/music-assistant/kustomization.yaml | 1 + 3 files changed, 140 insertions(+) create mode 100644 kubernetes/apps/music-assistant/configmap-net-reconcile.yaml diff --git a/kubernetes/apps/music-assistant/configmap-net-reconcile.yaml b/kubernetes/apps/music-assistant/configmap-net-reconcile.yaml new file mode 100644 index 00000000..92f3a163 --- /dev/null +++ b/kubernetes/apps/music-assistant/configmap-net-reconcile.yaml @@ -0,0 +1,111 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: music-assistant-net-reconcile + namespace: music-assistant + labels: + app.kubernetes.io/name: music-assistant +data: + # Run by an initContainer before Music Assistant starts. See the initContainer + # comment in deployment.yaml for the full rationale. + reconcile.py: | + #!/usr/bin/env python3 + """Pin Music Assistant's stored streams config to the node's current v30iot + (10.2.30.0/24) address, every boot, before MA starts. + + MA persists publish_ip / bind_ip in /data/settings.json. + + - publish_ip is the *literal* IP advertised to players (Sendspin / Snapcast / + SlimProto). It must be a v30iot address the players can reach. MA's + auto-detect always picks the mgmt default-route IP instead (eth1/v30iot + carries routeMetric 4096, so it is never the default route), which players + on the IoT VLAN cannot route to. + - bind_ip must be 0.0.0.0. The Sendspin server (aiosendspin, port 8927) has + NO all-interfaces fallback: if bind_ip is a specific IP that is not local + it fails to bind and the server never starts, and its proxy then targets + that dead IP instead of 127.0.0.1. Binding 0.0.0.0 avoids both. + + The v30iot IP is DHCP and node-dependent (MA is not node-pinned), so a stored + IP is a stale assumption the moment MA reschedules or the lease changes - + exactly the failure this replaces. We therefore recompute it from the live + interface on every start. hostNetwork means this initContainer sees eth1 + directly. Fail closed if no v30iot address is present, so MA can never come + up advertising a wrong IP. + """ + import fcntl + import ipaddress + import json + import os + import socket + import struct + import sys + + SETTINGS = "/data/settings.json" + V30IOT = ipaddress.ip_network("10.2.30.0/24") + SIOCGIFADDR = 0x8915 # linux ioctl: get interface IPv4 address + + + def iface_ipv4(sock, name): + try: + packed = struct.pack("256s", name.encode()[:15]) + res = fcntl.ioctl(sock.fileno(), SIOCGIFADDR, packed) + return socket.inet_ntoa(res[20:24]) + except OSError: + return None + + + def discover_v30iot_ip(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + for _idx, name in socket.if_nameindex(): + ip = iface_ipv4(sock, name) + if ip and ipaddress.ip_address(ip) in V30IOT: + return name, ip + finally: + sock.close() + return None, None + + + def main(): + iface, ip = discover_v30iot_ip() + if not ip: + print( + f"[reconcile] FATAL: no address in {V30IOT} on any interface; " + "refusing to start so MA never advertises a wrong publish IP", + file=sys.stderr, + ) + return 1 + print(f"[reconcile] v30iot address {ip} on {iface}") + + data = {} + if os.path.exists(SETTINGS): + with open(SETTINGS, encoding="utf-8") as fh: + data = json.load(fh) + + values = ( + data.setdefault("core", {}) + .setdefault("streams", {}) + .setdefault("values", {}) + ) + old_pub, old_bind = values.get("publish_ip"), values.get("bind_ip") + if (old_pub, old_bind) == (ip, "0.0.0.0"): + print("[reconcile] publish_ip/bind_ip already correct; no change") + return 0 + + values["publish_ip"] = ip + values["bind_ip"] = "0.0.0.0" + print( + f"[reconcile] publish_ip {old_pub!r} -> {ip!r}, " + f"bind_ip {old_bind!r} -> '0.0.0.0'" + ) + + tmp = SETTINGS + ".reconcile.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + os.replace(tmp, SETTINGS) # atomic; leaves MA's settings.json.backup intact + print(f"[reconcile] wrote {SETTINGS}") + return 0 + + + if __name__ == "__main__": + sys.exit(main()) diff --git a/kubernetes/apps/music-assistant/deployment.yaml b/kubernetes/apps/music-assistant/deployment.yaml index 523a2bd0..abcdab88 100644 --- a/kubernetes/apps/music-assistant/deployment.yaml +++ b/kubernetes/apps/music-assistant/deployment.yaml @@ -36,6 +36,30 @@ spec: # dropping the workload-class=pinned toleration — which lets the # edge taint keep it off the tiny edge box — is the main capacity # win for this step. + initContainers: + # MA advertises a stored publish_ip to players (Sendspin / Snapcast / + # SlimProto) and binds its stream + Sendspin servers to a stored + # bind_ip. Both must track this node's *current* v30iot address, but: + # - MA's auto-detect picks the mgmt default-route IP (v30iot/eth1 has + # routeMetric 4096, never the default route), which IoT players + # can't reach; and + # - eth1 is DHCP and MA is not node-pinned, so any hard-coded IP goes + # stale on the next reschedule or lease change (the Sendspin server + # then fails to bind and dies, with no fallback). + # So we recompute the v30iot IP from the live interface every boot and + # write it into /data/settings.json before MA reads it. Runs in the + # pod's hostNetwork namespace, so it sees eth1 directly. Reuses the MA + # image (python + stdlib only; Flux keeps the tag in lockstep via the + # shared image policy) — no extra dependency. + - name: reconcile-publish-ip + image: ghcr.io/music-assistant/server:2.9.5 # {"$imagepolicy": "flux-system:music-assistant"} + command: ["/app/venv/bin/python", "/scripts/reconcile.py"] + volumeMounts: + - name: data + mountPath: /data + - name: net-reconcile + mountPath: /scripts + readOnly: true containers: - name: music-assistant image: ghcr.io/music-assistant/server:2.9.5 # {"$imagepolicy": "flux-system:music-assistant"} @@ -97,6 +121,10 @@ spec: - name: data persistentVolumeClaim: claimName: music-assistant-data + - name: net-reconcile + configMap: + name: music-assistant-net-reconcile + defaultMode: 0555 --- apiVersion: v1 kind: Service diff --git a/kubernetes/apps/music-assistant/kustomization.yaml b/kubernetes/apps/music-assistant/kustomization.yaml index ab78bd62..5c801071 100644 --- a/kubernetes/apps/music-assistant/kustomization.yaml +++ b/kubernetes/apps/music-assistant/kustomization.yaml @@ -1,5 +1,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - configmap-net-reconcile.yaml - deployment.yaml - ingress.yaml -- 2.54.0