fix(music-assistant): reconcile publish_ip/bind_ip to node's live v30iot IP #131
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user