Merge pull request 'fix(music-assistant): reconcile publish_ip/bind_ip to node's live v30iot IP' (#131) from worktree-fix+music-assistant-sendspin-publish-ip into master

Reviewed-on: gitea_admin/homelab#131
This commit was merged in pull request #131.
This commit is contained in:
2026-07-08 17:28:07 +02:00
3 changed files with 140 additions and 0 deletions
@@ -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())
@@ -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
@@ -1,5 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- configmap-net-reconcile.yaml
- deployment.yaml
- ingress.yaml