retire k8s-edge: cluster cleanup after edge-1 cutover #100

Merged
k.renner merged 1 commits from retire-k8s-edge into master 2026-07-02 16:22:08 +00:00
14 changed files with 4 additions and 516 deletions
@@ -1,114 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: zwave-js
namespace: home-assistant
labels:
app.kubernetes.io/name: zwave-js
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: zwave-js
template:
metadata:
labels:
app.kubernetes.io/name: zwave-js
spec:
nodeSelector:
topology.kubernetes.io/zone: edge
tolerations:
- key: workload-class
operator: Equal
value: pinned
effect: NoSchedule
initContainers:
# Reconcile settings.json on every start. Our managed keys
# (zwave.port, zwave.serverEnabled/serverPort, security keys)
# come from a SOPS-encrypted Secret and must always be in
# effect; any fields the user adjusts via the zwave-js-ui UI
# (gateway/mqtt/ui sections, custom zwave overrides, etc.) are
# preserved through the deep-merge.
- name: bootstrap-settings
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache jq >/dev/null
mkdir -p /usr/src/app/store
if [ -s /usr/src/app/store/settings.json ]; then
echo "merging managed keys into existing settings.json"
jq -s '.[0] * .[1]' \
/usr/src/app/store/settings.json \
/etc/zwjs-bootstrap/settings.json \
> /tmp/settings.json
mv /tmp/settings.json /usr/src/app/store/settings.json
else
echo "writing initial settings.json"
cp /etc/zwjs-bootstrap/settings.json /usr/src/app/store/settings.json
fi
volumeMounts:
- name: store
mountPath: /usr/src/app/store
- name: zwjs-bootstrap
mountPath: /etc/zwjs-bootstrap
readOnly: true
containers:
- name: zwave-js
image: ghcr.io/zwave-js/zwave-js-ui:11.21.1 # {"$imagepolicy": "flux-system:zwave-js-ui"}
env:
- name: TZ
value: Europe/Berlin
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
name: zwave-js-session
key: session-secret
ports:
- name: ui
containerPort: 8091
protocol: TCP
- name: websocket
containerPort: 3000
protocol: TCP
volumeMounts:
- name: store
mountPath: /usr/src/app/store
- name: zwave-stick
mountPath: /dev/zwave
livenessProbe:
tcpSocket:
port: ui
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: ui
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
securityContext:
privileged: true
volumes:
- name: store
persistentVolumeClaim:
claimName: zwavejs-store
- name: zwave-stick
hostPath:
path: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
- name: zwjs-bootstrap
secret:
secretName: zwave-js-session
items:
- key: settings.json
path: settings.json
@@ -1,7 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc.yaml
- deployment.yaml
- service.yaml
- secret.sops.yaml
@@ -1,15 +0,0 @@
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: zwavejs-store
namespace: home-assistant
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 2Gi
@@ -1,24 +0,0 @@
apiVersion: ENC[AES256_GCM,data:6Lo=,iv:BFnlADFCQUkJhYMbBDsadPpVzpriP4IlY/c/JPnTfaw=,tag:G99xkPnA0Zo0+mjcbUGNrA==,type:str]
kind: ENC[AES256_GCM,data:v1AZMTMy,iv:3Bccu9++hZGp+unhysM1lUlEICv48EzD0MRT+3SeUpo=,tag:PR2x/jNtwaHKSwxrbfp9Og==,type:str]
metadata:
name: ENC[AES256_GCM,data:EsIq6rFM1nflhQzWCFx+BQ==,iv:9NV9dujzYyKX/atXXhkLFB8NaGm3FD/WMBEneW1zAAI=,tag:K9MFRGOKNn54LMFBdMGPJQ==,type:str]
namespace: ENC[AES256_GCM,data:a25NXKyQtB7v3NLEG4g=,iv:R3yoraMsMYLfGbQLs8NZIrCobs9wsMa2d4/bysFr+og=,tag:Eya8wLLsbVRMyPBIxZsXNw==,type:str]
type: ENC[AES256_GCM,data:yz/7JBb2,iv:Tpk7EnyUfjoXgJCokYL/rdnrTqaeFXUKmX2a9WEN2GM=,tag:1GhYa74EnIqj95zBw9IZqg==,type:str]
stringData:
session-secret: ENC[AES256_GCM,data:iYBSO+IE98bYhTF4Qw3REB/IBnxosTzxuVYWXtsBfDe3noKiuoswwIqo2V4+W+MCAgbhmEQ1mQd5N7hBNFsX/g==,iv:QJCf8RwcQZw8P+Kt1ptYiZe+fKAdNmDKsLcaSX/56zk=,tag:2R4lavEPbiRok6p3/VVYcQ==,type:str]
settings.json: ENC[AES256_GCM,data:bBxaIkIZE8bZxXIJnDLnRqidV3wPlJGcdmEE/1AmGUHDG8Qf3KxcDUDzkVw+9tc5DQ7424xApspJD3lmkGmAs2wGCLhIhlFgvIa20iBmM2fRdx7Y/H8DJxCYl2ZeRSNvqxcGfyvvc69+/7mLw/imrl2l34z1a/AA2IHdEBx+zZY1f/qGThHb0yrzHoQhnwpJ1x41uQ+P+djCR1IAfYqAiubE1hPAfJjRe7hzRYMguICK+gSrfglBBtZZKEhzU9UiRPXCJBRDS3+srXQXmZFFaF/jhEBYDzqT0BORJD0kn48kQP05feGW8SrYpItDUj6oJyNKJOAc/Ns5A2/qrwDUh5z2RRAUyUfaE/fGxzVbw257zgVpUnsEhrNfUFwmy7XI4edeLcgISR1va2vAbraFICGbWMqa7XQAd+njNZWPsIneHV15hI9w1q/xQcludkqq249Hkbj1GjIMT5LOz/RksslMSJYvkrZDpnJuB5J6H89PSIPGf0qLvW/fPP/2xRNE1CycoQKZh5WMY2g4VsdyVw6oq6kLOniXHfi7zRO6T0g4RKvM0ISSvEYJevD7bfWrfxN3en9rAXqm2g2RmZpzZgwY/xN+1hSkPYlQ/NmsiDoDRSB034uAUDUtk1Fg70QcTWjHDeF4p9Ku+GHn0D5QBNZjFbPHWviKQlXprcSlmL3ynReK2XkowuklD44ATMAFQQ==,iv:xYJAFECJ16IIZ/TebTMtPOhx7s8PsdZsZHpgp65CbRQ=,tag:UzYSBsih6HT9t9PbZ2zOrw==,type:str]
sops:
age:
- recipient: age1yqjvqkwwwxdj7mr546zfs5ckzuwndp69ya2n2drd4vypwns8w4ys04qfy3
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUaWtCeFViQ2hKajJ0YzFk
Wi8zZ1A3alh2S2JsUXhlc1c0Zy81RXc2aEdnCmkzZ1d2bVNkTFdnQ3VUOGQ1Tmxp
Qnk5blJqWjFJcytZRWdoN2dlRlEyTzQKLS0tIEVQMmZmWEFNcGxnYUxRVThpSWVq
Z0tSTU8xRERQZmg1TE43M2JSN3hFU2MKAAwYACp8jJ9B10vB5rvHVNnoIVkIpDZI
6ATb3FfVb0nsenJkVv7iN634oWFxvaorbNKR+bajvN3FTuUhyJ5wuw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-05-09T10:08:53Z"
mac: ENC[AES256_GCM,data:I/TLsO2wsPVrIYaPuOKJjHM9eSg+dAup4qmEK4W6pkpokgZaVr6HQcUm72ynsk/NrnqeTX40FO1he+/mWGajs58pph74KLW/xBAZ7gcvkfiL4r07JOSRwYRUet4y7YOAWZOZE89OKfZUIADh62phf2UpdPy+vc9xiq7GqumeZc8=,iv:95/a5aKXzFrfehf/Rhjm0OQF51QVom0oB478CAIxMec=,tag:bgD4xyW7/Zv3zn5KCfs9jQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.10.2
@@ -1,17 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: zwave-js
namespace: home-assistant
labels:
app.kubernetes.io/name: zwave-js
spec:
selector:
app.kubernetes.io/name: zwave-js
ports:
- name: websocket
port: 3000
targetPort: websocket
- name: ui
port: 8091
targetPort: ui
@@ -18,8 +18,6 @@ resources:
- mosquitto-service.yaml
- mosquitto-iot-service.yaml
- mosquitto-deployment.yaml
- sml2mqtt-config.yaml
- sml2mqtt-deployment.yaml
- shelly-manager-pvc.yaml
- shelly-manager-network-attachment.yaml
- shelly-manager-deployment.yaml
@@ -1,44 +0,0 @@
---
# sml2mqtt v3 config TEMPLATE. The broker password is injected at pod
# start by the deployment's init container (sed of __MQTT_PASSWORD__ from
# the mosquitto-sml2mqtt-credentials Secret) into an emptyDir, because
# sml2mqtt reads only a config file and has no env-var override for the
# password. The generated password is [A-Za-z0-9] only, so it is sed-safe.
#
# `Wh in kWh: false` is deliberate: the legacy edl21 sensors publish Wh,
# and the new MQTT sensors reuse the same entity_ids — keeping Wh means
# Home Assistant long-term statistics continue with zero unit discontinuity.
apiVersion: v1
kind: ConfigMap
metadata:
name: sml2mqtt-config
namespace: home-assistant
data:
config.yml: |
logging:
level: INFO
file: stdout # 'stdout' -> kubectl logs / Loki (NOT '' — that opens the dir)
mqtt:
connection:
identifier: sml2mqtt-meter
host: mosquitto.home-assistant.svc.cluster.local
port: 1883
user: sml2mqtt
password: __MQTT_PASSWORD__
topic prefix: sml2mqtt
defaults:
qos: 0
retain: false
last will:
topic: status
general:
Wh in kWh: false # keep raw Wh to match edl21 history (see header)
republish after: 120 # re-emit unchanged values every 120 s
inputs:
- type: serial
url: /dev/stromzaehler # FTDI FT230X, mounted by the deployment
timeout: 3
# 9600 8N1 are sml2mqtt defaults for SML/EDL21 — no override needed.
@@ -1,106 +0,0 @@
---
# sml2mqtt — reads the German SML/EDL21 power meter off its USB FTDI
# serial reader and publishes OBIS values to the in-cluster Mosquitto
# broker. This replaces Home Assistant's built-in `edl21` integration so
# the heavy HA pod no longer needs the USB device and can leave k8s-edge.
#
# Pinned to k8s-edge because the meter's USB reader is physically on that
# node (a genuine hardware constraint, not a stability pin). The pod is
# tiny, so it's an acceptable resident of the soon-to-be-tiny edge box.
#
# SERIAL EXCLUSIVITY: the meter's /dev/ttyUSB0 is a single serial stream;
# edl21 (in the HA pod) and sml2mqtt cannot both read it without
# corrupting each other's SML framing. edl21 has now been disabled in HA,
# freeing the port, so this is activated (replicas: 1). Remaining cutover:
# add HA MQTT sensors reusing the old entity_ids -> evict HA from edge.
apiVersion: apps/v1
kind: Deployment
metadata:
name: sml2mqtt
namespace: home-assistant
labels:
app.kubernetes.io/name: sml2mqtt
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: sml2mqtt
template:
metadata:
labels:
app.kubernetes.io/name: sml2mqtt
spec:
# USB meter reader is on k8s-edge; tolerate the pinned taint to land there.
nodeSelector:
topology.kubernetes.io/zone: edge
tolerations:
- key: workload-class
operator: Equal
value: pinned
effect: NoSchedule
enableServiceLinks: false
# The injected config.yml (with the broker password) is written here
# by the init container and read by the main container at /sml2mqtt.
initContainers:
- name: render-config
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
sed "s/__MQTT_PASSWORD__/${MQTT_PASSWORD}/" \
/tmpl/config.yml > /sml2mqtt/config.yml
env:
- name: MQTT_PASSWORD
valueFrom:
secretKeyRef:
name: mosquitto-sml2mqtt-credentials
key: password
volumeMounts:
- name: config-tmpl
mountPath: /tmpl
readOnly: true
- name: rendered
mountPath: /sml2mqtt
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
containers:
- name: sml2mqtt
image: spacemanspiff2007/sml2mqtt:3.6.0 # pinned; no image-automation by design (critical meter bridge)
volumeMounts:
- name: rendered
mountPath: /sml2mqtt
- name: stromzaehler
mountPath: /dev/stromzaehler
securityContext:
# The meter device is root:root 0600 on the node. Opening it
# needs BOTH: runAsUser 0 (file DAC) AND privileged (the device
# cgroup blocks a hostPath-mounted char device for non-privileged
# containers even as root — there's no device-plugin here). This
# mirrors how the HA pod accesses the same device today; the win
# is that it's now a tiny pod instead of the 8Gi HA pod.
privileged: true
runAsUser: 0
runAsGroup: 0
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
memory: 128Mi
volumes:
- name: config-tmpl
configMap:
name: sml2mqtt-config
- name: rendered
emptyDir: {}
- name: stromzaehler
hostPath:
path: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
+4 -1
View File
@@ -15,7 +15,10 @@ TALOS_DIR="$(cd "$(dirname "$0")/talos" && pwd)"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ACCESS_STAGING="$REPO_ROOT/kubernetes/access-staging"
NODES=(k8s-1 k8s-2 k8s-3)
WORKERS=(k8s-edge)
# No dedicated workers since the k8s-edge VM was retired (2026-07-02) —
# hardware-bound services live on the bare-metal edge-1 Arch host
# (ansible/roles/edge-host).
WORKERS=()
ALL_NODES=("${NODES[@]}" "${WORKERS[@]}")
DOMAIN="srv.renner.uno"
DNS_SERVER="10.2.40.53"
@@ -1,31 +1,5 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: home-assistant-zwavejs
namespace: flux-system
spec:
interval: 10m
retryInterval: 1m
timeout: 10m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/home-assistant-zwavejs
prune: true
# Manifests apply, but don't block on Deployment health — the ZWA-2 hostPath
# is a TBD placeholder until the USB is physically moved to saturn at cutover.
wait: false
decryption:
provider: sops
secretRef:
name: sops-age
dependsOn:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: home-assistant
namespace: flux-system
@@ -23,4 +23,3 @@ resources:
- velero-plugin-for-aws.yaml
- vsftpd.yaml
- wyoming.yaml
- zwave-js-ui.yaml
@@ -1,20 +0,0 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: zwave-js-ui
namespace: flux-system
spec:
image: ghcr.io/zwave-js/zwave-js-ui
interval: 1h
---
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: zwave-js-ui
namespace: flux-system
spec:
imageRepositoryRef:
name: zwave-js-ui
policy:
semver:
range: "11.x"
-44
View File
@@ -1,44 +0,0 @@
machine:
network:
hostname: k8s-edge
# k8s-edge has two NICs: eth0 on the primary LAN (10.2.40.0/24) and
# eth1 on v30iot. eth1 exists for OTBR/matter-server macvlan parents
# and for music-assistant's host-networked L2 locality with audio
# renderers — see opentofu/vm_153_k8s_edge.tf and the
# k8s-edge-node-pattern wiki note for the full design.
#
# `worker.yaml` is `network: {}`, so both NICs default to DHCP at
# the Talos-default routeMetric=1024. The IoT VLAN's DHCP server
# returns a default gateway too, so two same-priority default
# RouteSpecs land at metric 1024 (10.2.40.1 via eth0, 10.2.30.1 via
# eth1). The kernel can't install both at the same metric — one
# wins, the other returns EEXIST, and RouteSpecController retries
# the loser every ~30-70s, spamming the controller-runtime log.
#
# Talos v1.12.6's DHCPOptions exposes only `routeMetric`, `ipv4`,
# `ipv6`, `duidv6` — no discrete "ignore default gateway" toggle —
# so the lever is metric. Bumping eth1's routeMetric makes both
# default routes coexist at different metrics: eth0's stays at 1024
# and wins normal egress; eth1's at 4096 is a fallback the kernel
# only consults if eth0 is down. Connected /24 routes on eth1 still
# work (Cilium L2-IPAM, OTBR/matter-server macvlan parents, music-
# assistant L2 reach to renderers all unaffected — they use the
# connected route, not the default).
interfaces:
- interface: eth1
dhcp: true
dhcpOptions:
routeMetric: 4096
install:
disk: /dev/vda
certSANs:
- k8s-edge.srv.renner.uno
nodeLabels:
topology.kubernetes.io/zone: edge
# NodeRestriction admission blocks post-join taint modification by worker
# kubelets, so `machine.nodeTaints` has no effect here. Pass the taint at
# kubelet registration instead — kubelets are allowed to self-register with
# taints via --register-with-taints.
kubelet:
extraArgs:
register-with-taints: workload-class=pinned:NoSchedule
-95
View File
@@ -1,95 +0,0 @@
# Dedicated "edge" K8s node for pinned workloads:
# - latency-sensitive services (e.g. music-assistant) that need isolation
# from noisy neighbours
# - services bound to host USB devices (home-assistant with Z-Wave + EDI21)
#
# Topology contract:
# - Not a Ceph OSD (no second disk)
# - Not HA — USB passthrough pins the node to one physical host, so
# migration offers nothing and would lose the USB sticks
# - Tainted `workload-class=pinned:NoSchedule`; only pods that tolerate
# that taint land here (see patches/k8s-edge.yaml)
#
# Pinned to neptune: VM 103 (legacy HAOS) currently holds the ZWA-2 +
# stromzaehler USB mappings on neptune; at HA cutover the USBs rebind
# from VM 103 to this VM in-place on the same host (no physical move).
# DEPRECATION PATH (decision 2026-07-02, reverses 2026-05-05): this VM is
# being replaced by a bare-metal Arch N100 (`edge-1`, see
# ansible/roles/edge-host) because the new CAN/open3e service needs a
# kernel with CONFIG_CAN, which stock Talos does not ship. Remove this
# file after the cutover checklist in roles/edge-host/README.md is done.
module "k8s_edge" {
source = "./modules/proxmox_vm"
name = "k8s-edge.srv.renner.uno"
target_node = "neptune"
vmid = 153
# ~10k-entity HA at 4Gi pod limit OOM-looped (see deployment.yaml). The
# legacy HAOS VM 103 that ran this same workload had 16Gi; bump this VM so
# the HA pod can have an 8Gi limit with headroom for the other pinned pods
# (matter-server, otbr, zwave-js, music-assistant). Freed RAM comes from
# the now-stopped VM 103.
memory = 12288
cores = 2
template_name = null
cloud_init = false
full_clone = false
bios = "ovmf"
boot = "order=virtio0"
agent_enabled = true
# Second NIC on the IoT VLAN. Two independent tenants need this bridge
# and both constraints land on the same NIC:
# 1. Music Assistant — uses host networking for audio-streaming latency.
# Multi-room audio sync (Snapcast / SlimProto / Chromecast groups)
# cannot cross L3 boundaries without breaking timing, so the host-
# networked pod has to reach the audio renderers at L2; the renderers
# live on v30iot, so the host stack on k8s-edge needs to be there too.
# mDNS / uPnP / DLNA / SlimProto discovery working as a result is a
# side effect of being on the players' broadcast domain, not the
# reason hostNetwork was chosen.
# 2. OTBR (and matter-server) — attach a Multus macvlan NAD with eth1
# as the parent (static IPAM on 10.2.30.0/24, no gateway) so the BR
# and Matter SDK have real L2 presence on the IoT VLAN: mDNS/SRP
# advertisement, IPv6 RA reception, and OMR-prefix publication into
# the Thread mesh.
# Removing either tenant would not let this NIC go away.
extra_network_bridges = ["v30iot"]
disks = [
{
storage = "local-zfs"
size = "64G"
format = "raw"
slot = "virtio0"
file_id = local.talos_image
}
]
# USB passthrough — both devices are now bound to this VM (cutover
# complete; vm_103 no longer holds them). Block order and usb3 setting
# match the live Proxmox state captured 2026-05-09 to avoid drift on
# tofu plan.
usb = [
{
mapping_id = "ZWA-2"
usb3 = true
id = "0"
},
{
mapping_id = "stromzaehler"
usb3 = true
id = "1"
}
]
tags = "kubernetes;talos;worker;edge"
ssh_public_keys = local.common_ssh_keys
vm_state = "running"
# Rebuild pipeline issues `-replace`; skip graceful shutdown so a
# non-responsive Talos guest can't hang the destroy for 30 minutes.
stop_on_destroy = true
}