diff --git a/ansible/inventory/group_vars/edge_host/vars.yml b/ansible/inventory/group_vars/edge_host/vars.yml new file mode 100644 index 00000000..c8d429e8 --- /dev/null +++ b/ansible/inventory/group_vars/edge_host/vars.yml @@ -0,0 +1,7 @@ +--- +# Secret indirection — real values live in vault.yml (ansible-vault) next +# to this file; see roles/edge-host/README.md for how to populate it from +# the cluster secrets. +edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}" +edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}" +edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}" diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 503292b0..4a8fbaf1 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -39,3 +39,6 @@ woodpecker.srv.renner.uno ansible_user=root [woodpecker_agents] wp-agent-1.srv.renner.uno ansible_user=root wp-agent-2.srv.renner.uno ansible_user=root + +[edge_host] +edge-1.iot.renner.uno ansible_user=root diff --git a/ansible/plays/install-edge-host.yml b/ansible/plays/install-edge-host.yml new file mode 100644 index 00000000..cb00babb --- /dev/null +++ b/ansible/plays/install-edge-host.yml @@ -0,0 +1,12 @@ +--- +# Bare-metal Arch Linux edge host (N100) — replaces the k8s-edge Talos VM +# (opentofu vm_153) as the home for hardware-bound services: +# - zwave-js-ui (ZWA-2 Z-Wave USB radio, docker compose) +# - sml2mqtt (FTDI power-meter reader, docker compose) +# - open3e (Innomaker USB2CAN -> Vitocal/VX3, native venv + systemd) +# Base install procedure and cutover checklist: ansible/roles/edge-host/README.md +- name: Install Arch edge host + hosts: edge_host + roles: + - docker-install + - edge-host diff --git a/ansible/roles/edge-host/README.md b/ansible/roles/edge-host/README.md new file mode 100644 index 00000000..a7fc4f63 --- /dev/null +++ b/ansible/roles/edge-host/README.md @@ -0,0 +1,107 @@ +# edge-host — bare-metal Arch N100 (replaces the k8s-edge Talos VM) + +Runs the hardware-bound services that used to be pinned to the `k8s-edge` +Talos node (opentofu `vm_153_k8s_edge.tf`), plus the new CAN bridge: + +| Service | How it runs | Hardware | Talks to | +|---|---|---|---| +| zwave-js-ui | docker compose | ZWA-2 Z-Wave USB radio | HA `zwave_js` via ws://:3000, UI :8091 | +| sml2mqtt | docker compose | FTDI FT230X meter reader | Mosquitto (`sml2mqtt` user) | +| open3e | venv + systemd | Innomaker USB2CAN (gs_usb) | Mosquitto (`open3e` user) | + +Everything else that once lived on k8s-edge (HA core, matter-server, OTBR, +music-assistant) already moved to the general node pool and stays in the +cluster — they need VLAN L2 presence, not USB, so a single LAN NIC on the +N100 is sufficient. + +Rationale for leaving Kubernetes for the CAN part: the stock Talos kernel +ships with `CONFIG_CAN` unset (siderolabs/talos#7686), so SocketCAN cannot +work in any pod without a custom kernel image per Talos release. + +## Base install (once, from the edge-host installer ISO) + +Build and boot the dedicated installer image — see +`images/edge-host-iso/README.md`. Short version: + +1. `sudo pacman -S archiso && sudo images/edge-host-iso/build.sh` +2. Write ISO to a USB stick, boot the N100 from it, run `edge-install` + (partitions the NVMe, installs the ansible-ready base system with SSH + keys, DHCP, docker — asks for confirmation before wiping). +3. Add the DHCP reservation/DNS entry for `edge-1.srv.renner.uno` on the + UDM, confirm `ssh root@edge-1.srv.renner.uno` works. +4. From the workstation: + ``` + ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host + ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml + ``` + +## Secrets (before first play run) + +Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the +current live values come straight out of the cluster: + +``` +# sml2mqtt broker password +kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d +# open3e broker password +kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d +# zwave-js session secret +kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d +``` + +```yaml +# vault.yml +vault_edge_sml2mqtt_mqtt_password: "..." +vault_edge_open3e_mqtt_password: "..." +vault_edge_zwavejs_session_secret: "..." +``` + +The Z-Wave network keys do NOT need separate handling — they live in +`settings.json` inside the store that gets migrated below. + +## Cutover checklist (service downtime: minutes, do per-service) + +Preparation: +- [ ] `vault.yml` populated (above), play runs green against the N100 +- [ ] CAN: Innomaker adapter connected to the VX3 external CAN port, + `ip -details link show can0` shows `state ERROR-ACTIVE bitrate 250000`, + `candump can0 -n 5` shows frames + +zwave-js (order matters — never two servers on one radio): +- [ ] Copy the store out of the PVC while the pod still runs: + `kubectl -n home-assistant exec deploy/zwave-js -- tar cf - -C /usr/src/app store | tar xf - -C /opt/edge-host/zwave-js/` +- [ ] Scale down in-cluster: `kubectl -n home-assistant scale deploy/zwave-js --replicas 0` +- [ ] Move the ZWA-2 stick to the N100, `docker compose up -d zwave-js` +- [ ] Point HA `zwave_js` integration at `ws://edge-1.srv.renner.uno:3000` +- [ ] Nodes alive in the UI on :8091 → remove the flux kustomization + `home-assistant-zwavejs` + PVC in a follow-up PR + +sml2mqtt: +- [ ] Scale down in-cluster: `kubectl -n home-assistant scale deploy/sml2mqtt --replicas 0` +- [ ] Move the FTDI stick, `docker compose up -d sml2mqtt` +- [ ] HA meter entities update again (same entity_ids — config is a 1:1 + port, Wh stays Wh) → remove sml2mqtt from the home-assistant + kustomization in the follow-up PR + +open3e: +- [ ] Stop any workstation listener (ONE instance per bus, hard rule) +- [ ] Move the USB2CAN adapter, `systemctl start open3e` +- [ ] Topics under `open3e/#` update on the broker + +Afterwards: +- [ ] Retire VM 153: remove `vm_153_k8s_edge.tf`, `talos/patches/k8s-edge.yaml`, + node from the cluster (`kubectl delete node k8s-edge`, tofu apply) +- [ ] Update wiki (k8s-edge-node-pattern note is then historical) + +## Day-2 notes + +- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in + defaults and re-run the play (no flux image-automation out here — + deliberate for sml2mqtt, acceptable for zwave-js). +- open3e updates: re-run the play; the pip task tracks the git default + branch (pin `edge_open3e_pkg` to a tag if that ever bites). +- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule), + restart afterwards. +- can-utils (`candump`) is AUR-only on Arch — install manually via + `pamac build can-utils` if needed for debugging; the role deliberately + doesn't manage AUR packages. diff --git a/ansible/roles/edge-host/defaults/main.yml b/ansible/roles/edge-host/defaults/main.yml new file mode 100644 index 00000000..4d3b7145 --- /dev/null +++ b/ansible/roles/edge-host/defaults/main.yml @@ -0,0 +1,49 @@ +--- +edge_base_dir: /opt/edge-host +edge_tz: Europe/Berlin + +# --- MQTT: all services publish to the central in-cluster Mosquitto via +# its LoadBalancer hostname (the edge host is outside the cluster). +edge_mqtt_host: mqtt.renner.uno +edge_mqtt_port: 1883 + +# --- USB devices (stable by-id symlinks). After physically moving the +# sticks to the N100, verify with `ls -l /dev/serial/by-id/` — the by-id +# names travel with the device, not the host, so these should not change. +edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00 +edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0 + +# --- Images. Versions mirror the k8s manifests at migration time. +# sml2mqtt stays hand-pinned (critical meter bridge — was deliberately +# excluded from image-automation in k8s, keep that property here). +edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1 +edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0 + +# --- CAN / open3e (Viessmann E3 external bus) --- +edge_can_interface: can0 +edge_can_bitrate: 250000 +edge_open3e_pkg: git+https://github.com/open3e/open3e.git +edge_open3e_mqtt_user: open3e +edge_open3e_mqtt_topic: open3e +edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}" +edge_open3e_poll_seconds: 30 +# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on +# the commissioning workstation; see vican repo CLAUDE.md for the rationale). +# NO -w / write DIDs here — live heating + battery, writes are Phase 3. +edge_open3e_datapoints: >- + vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.318,vitocal.321,vitocal.324,vitocal.548,vitocal.565,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.2320,vitocal.2346,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1834,vx3.1836,vx3.2240,vcmu.322,vcmu.325,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571 +# ECU addresses; -dev selects codec set, tx selects the UDS address. +# vcmu (refrigerant-circuit controller) has no dedicated codec set — +# omitting dpList makes open3e fall back to the generic datapoint list, +# which decodes all polled VCMU DIDs. +edge_open3e_devices: + vitocal: { tx: "0x680", dpList: "vcal" } + vx3: { tx: "0x6a1", dpList: "vx3" } + vcmu: { tx: "0x68c" } + +# --- Secrets: define these in inventory/group_vars/edge_host/vault.yml +# (ansible-vault). See README.md for the kubectl one-liners that read the +# current values out of the cluster during migration. +# edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}" +# edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}" +# edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}" diff --git a/ansible/roles/edge-host/handlers/main.yml b/ansible/roles/edge-host/handlers/main.yml new file mode 100644 index 00000000..8962059a --- /dev/null +++ b/ansible/roles/edge-host/handlers/main.yml @@ -0,0 +1,19 @@ +--- +# reload alone only re-reads config files; existing links keep their old +# state until reconfigured. +- name: Reload networkd + ansible.builtin.shell: + cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }} + changed_when: true + +- name: Restart edge compose services + ansible.builtin.command: + cmd: docker compose up -d --force-recreate + chdir: "{{ edge_base_dir }}" + changed_when: true + +- name: Restart open3e + ansible.builtin.systemd: + name: open3e + state: restarted + daemon_reload: true diff --git a/ansible/roles/edge-host/tasks/main.yml b/ansible/roles/edge-host/tasks/main.yml new file mode 100644 index 00000000..b88c47b0 --- /dev/null +++ b/ansible/roles/edge-host/tasks/main.yml @@ -0,0 +1,151 @@ +--- +# Arch edge host: zwave-js-ui + sml2mqtt as docker compose services, +# open3e natively (venv + systemd) because it needs a SocketCAN interface, +# which is host network state, not something to hide in a container. +# Docker itself comes from the docker-install role (Arch-aware). + +- name: Install base packages + ansible.builtin.package: + name: + - python + - python-packaging # ansible's pip module imports it on the target + - git + state: present + +# --- SocketCAN: systemd-networkd owns can0. gs_usb is an in-tree module +# and autoloads when the Innomaker adapter enumerates; networkd then +# matches the interface and applies bitrate + up. RestartSec recovers the +# link after bus-off without manual intervention. +- name: Configure can0 via systemd-networkd + ansible.builtin.template: + src: 80-can0.network.j2 + dest: /etc/systemd/network/80-can0.network + mode: "0644" + notify: Reload networkd + +- name: Enable systemd-networkd + ansible.builtin.service: + name: systemd-networkd + state: started + enabled: true + +# --- Directory layout --- +- name: Create service directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ edge_base_dir }}" + - "{{ edge_base_dir }}/zwave-js/store" + - "{{ edge_base_dir }}/sml2mqtt" + +# --- Compose services: zwave-js-ui + sml2mqtt --- +- name: Deploy docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ edge_base_dir }}/docker-compose.yml" + mode: "0644" + notify: Restart edge compose services + +- name: Deploy zwave-js env (session secret) + ansible.builtin.template: + src: zwavejs.env.j2 + dest: "{{ edge_base_dir }}/zwavejs.env" + mode: "0600" + notify: Restart edge compose services + +# sml2mqtt reads only a config file and has no env override for the +# password, so the rendered config carries it — 0600, root-owned, same +# trust boundary as the k8s init-container approach it replaces. +- name: Deploy sml2mqtt config + ansible.builtin.template: + src: sml2mqtt-config.yml.j2 + dest: "{{ edge_base_dir }}/sml2mqtt/config.yml" + mode: "0600" + notify: Restart edge compose services + +- name: Pull compose images + ansible.builtin.command: + cmd: docker compose pull + chdir: "{{ edge_base_dir }}" + changed_when: false + +# Tolerates missing USB devices: before the physical cutover the ZWA-2 / +# FTDI sticks are still on the old k8s-edge VM, and a container whose +# device is absent must not block the rest of the role (open3e/CAN moves +# first). Re-run the play — or `docker compose up -d` — after each stick +# arrives. +- name: Start compose services + ansible.builtin.command: + cmd: docker compose up -d + chdir: "{{ edge_base_dir }}" + register: compose_up + failed_when: >- + compose_up.rc != 0 + and 'error gathering device information' not in compose_up.stderr + changed_when: >- + 'Started' in compose_up.stderr or 'Created' in compose_up.stderr + or 'Started' in compose_up.stdout or 'Creating' in compose_up.stdout + +- name: Warn about containers waiting for their USB device + ansible.builtin.debug: + msg: >- + docker compose could not start every service (USB device not present + yet — expected before cutover): {{ compose_up.stderr_lines | select('search', 'device') | list }} + when: compose_up.rc != 0 + +# --- open3e: native install. CAN_RAW sockets need no capabilities, so +# the service runs as an unprivileged system user. +- name: Create open3e system user + ansible.builtin.user: + name: open3e + system: true + create_home: false + shell: /usr/bin/nologin + +# Owned by the service user: udsoncan writes its log file into the +# working directory. +- name: Create open3e directory + ansible.builtin.file: + path: "{{ edge_base_dir }}/open3e" + state: directory + owner: open3e + group: open3e + mode: "0755" + +- name: Install open3e into a venv + ansible.builtin.pip: + name: "{{ edge_open3e_pkg }}" + virtualenv: "{{ edge_base_dir }}/open3e/venv" + virtualenv_command: python -m venv + notify: Restart open3e + +- name: Deploy open3e devices.json + ansible.builtin.copy: + content: "{{ edge_open3e_devices | to_nice_json }}\n" + dest: "{{ edge_base_dir }}/open3e/devices.json" + mode: "0644" + notify: Restart open3e + +- name: Deploy open3e environment (broker credential) + ansible.builtin.template: + src: open3e.env.j2 + dest: "{{ edge_base_dir }}/open3e/open3e.env" + owner: open3e + mode: "0600" + notify: Restart open3e + +- name: Deploy open3e systemd unit + ansible.builtin.template: + src: open3e.service.j2 + dest: /etc/systemd/system/open3e.service + mode: "0644" + notify: Restart open3e + +- name: Enable open3e + ansible.builtin.systemd: + name: open3e + enabled: true + state: started + daemon_reload: true diff --git a/ansible/roles/edge-host/templates/80-can0.network.j2 b/ansible/roles/edge-host/templates/80-can0.network.j2 new file mode 100644 index 00000000..b091154a --- /dev/null +++ b/ansible/roles/edge-host/templates/80-can0.network.j2 @@ -0,0 +1,15 @@ +# Managed by ansible (roles/edge-host). +# Viessmann E3 external CAN bus via Innomaker USB2CAN (gs_usb). +# The adapter is a stub tap on an already-terminated bus (120R jumper OFF). +[Match] +Name={{ edge_can_interface }} + +[Link] +# networkd applies [CAN] settings but does not raise a CAN link on its +# own (observed on edge-1); force it up, and back up after any down. +ActivationPolicy=always-up + +[CAN] +BitRate={{ edge_can_bitrate }} +# Auto-recover the link if the controller ever goes bus-off. +RestartSec=1s diff --git a/ansible/roles/edge-host/templates/docker-compose.yml.j2 b/ansible/roles/edge-host/templates/docker-compose.yml.j2 new file mode 100644 index 00000000..53c510aa --- /dev/null +++ b/ansible/roles/edge-host/templates/docker-compose.yml.j2 @@ -0,0 +1,42 @@ +# Managed by ansible (roles/edge-host). Mirrors the former k8s manifests: +# kubernetes/apps/home-assistant-zwavejs/deployment.yaml and +# kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml. +# +# No `privileged` needed here: docker's --device handles the device-cgroup +# grant that hostPath in k8s could not (that was the only reason the pods +# ran privileged). +services: + zwave-js: + image: {{ edge_zwavejs_image }} + container_name: zwave-js + restart: unless-stopped + stop_signal: SIGINT + tty: true + environment: + - TZ={{ edge_tz }} + env_file: + - zwavejs.env + devices: + - "{{ edge_zwave_device }}:/dev/zwave" + volumes: + # Persistent store: settings.json, device DB, network keys. Seeded + # from the k8s PVC `zwavejs-store` at cutover (see README). Since + # this host is stateful there is no init-container merge step — + # settings.json is edited via the UI and persists here; zwave.port + # stays /dev/zwave. + - ./zwave-js/store:/usr/src/app/store + ports: + - "3000:3000" # Z-Wave JS websocket — HA zwave_js integration + - "8091:8091" # web UI + + sml2mqtt: + image: {{ edge_sml2mqtt_image }} + container_name: sml2mqtt + restart: unless-stopped + # Serial exclusivity: this is the ONLY reader of the meter port — + # HA's edl21 integration stays disabled (see sml2mqtt-config comment + # history in the k8s repo). + devices: + - "{{ edge_stromzaehler_device }}:/dev/stromzaehler" + volumes: + - ./sml2mqtt/config.yml:/sml2mqtt/config.yml:ro diff --git a/ansible/roles/edge-host/templates/open3e.env.j2 b/ansible/roles/edge-host/templates/open3e.env.j2 new file mode 100644 index 00000000..5eb4f8c1 --- /dev/null +++ b/ansible/roles/edge-host/templates/open3e.env.j2 @@ -0,0 +1,4 @@ +# Managed by ansible (roles/edge-host). Read by open3e.service. +# Full user:password pair as one variable so the unit can pass it as a +# single -muser argument without exposing it in the unit file. +OPEN3E_MUSER={{ edge_open3e_mqtt_user }}:{{ edge_open3e_mqtt_password }} diff --git a/ansible/roles/edge-host/templates/open3e.service.j2 b/ansible/roles/edge-host/templates/open3e.service.j2 new file mode 100644 index 00000000..ea29f87b --- /dev/null +++ b/ansible/roles/edge-host/templates/open3e.service.j2 @@ -0,0 +1,35 @@ +# Managed by ansible (roles/edge-host). +# READ-ONLY listener — no -w, no service 0x77. Writes are Phase 3 and get +# their own review; this unit must never grow write flags casually. +# -j is load-bearing: without it open3e flattens JSON payloads into +# per-key subtopics and the HA package (homeassistant repo, +# packages/open3e.yaml) receives nothing. +# Exactly ONE open3e instance may be on the bus at a time (CAN collisions +# stop open3e) — stop this service before running ad-hoc scans. +[Unit] +Description=open3e Viessmann E3 CAN-to-MQTT bridge (read-only) +# Tie the service to the CAN device: stops on unplug, starts on plug via +# the device unit rather than polling. +BindsTo=sys-subsystem-net-devices-{{ edge_can_interface }}.device +After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target +Wants=network-online.target + +[Service] +User=open3e +EnvironmentFile={{ edge_base_dir }}/open3e/open3e.env +WorkingDirectory={{ edge_base_dir }}/open3e +ExecStart={{ edge_base_dir }}/open3e/venv/bin/open3e \ + -c {{ edge_can_interface }} \ + -cnfg {{ edge_base_dir }}/open3e/devices.json \ + -m {{ edge_mqtt_host }}:{{ edge_mqtt_port }}:{{ edge_open3e_mqtt_topic }} \ + -muser ${OPEN3E_MUSER} \ + -mcid open3e-edge \ + -mfstr "{{ edge_open3e_mqtt_format }}" \ + -r {{ edge_open3e_datapoints }} \ + -t {{ edge_open3e_poll_seconds }} \ + -j +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/edge-host/templates/sml2mqtt-config.yml.j2 b/ansible/roles/edge-host/templates/sml2mqtt-config.yml.j2 new file mode 100644 index 00000000..1eefe315 --- /dev/null +++ b/ansible/roles/edge-host/templates/sml2mqtt-config.yml.j2 @@ -0,0 +1,32 @@ +# Managed by ansible (roles/edge-host). Ported 1:1 from the k8s ConfigMap +# sml2mqtt-config; only host and password delivery differ (broker is now +# reached via its LoadBalancer hostname, password rendered by ansible +# instead of an init container). +logging: + level: INFO + file: stdout # 'stdout' -> docker logs (NOT '' — that opens the dir) + +mqtt: + connection: + identifier: sml2mqtt-meter + host: {{ edge_mqtt_host }} + port: {{ edge_mqtt_port }} + user: sml2mqtt + password: {{ edge_sml2mqtt_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 — HA long-term + # stats continue with zero unit discontinuity + republish after: 120 # re-emit unchanged values every 120 s + +inputs: + - type: serial + url: /dev/stromzaehler # FTDI FT230X, mapped by docker compose + timeout: 3 + # 9600 8N1 are sml2mqtt defaults for SML/EDL21 — no override needed. diff --git a/ansible/roles/edge-host/templates/zwavejs.env.j2 b/ansible/roles/edge-host/templates/zwavejs.env.j2 new file mode 100644 index 00000000..cd203657 --- /dev/null +++ b/ansible/roles/edge-host/templates/zwavejs.env.j2 @@ -0,0 +1 @@ +SESSION_SECRET={{ edge_zwavejs_session_secret }} diff --git a/images/edge-host-iso/.gitignore b/images/edge-host-iso/.gitignore new file mode 100644 index 00000000..89f9ac04 --- /dev/null +++ b/images/edge-host-iso/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/images/edge-host-iso/README.md b/images/edge-host-iso/README.md new file mode 100644 index 00000000..66a70dd0 --- /dev/null +++ b/images/edge-host-iso/README.md @@ -0,0 +1,47 @@ +# edge-host installer ISO + +Bootable Arch installer image for `edge-1.srv.renner.uno` (the bare-metal +N100 that replaces the k8s-edge VM — see `ansible/roles/edge-host`). + +The image is the official archiso **releng** profile plus: +- `edge-install` in the live environment — partitions the NVMe + (ESP + ext4 root, systemd-boot), pacstraps the base system incl. + docker/python, configures DHCP via systemd-networkd, enables sshd and + drops the common homelab SSH keys for root (key-only, no root password) +- the extra disk tools the installer needs + +Division of labour: the ISO produces an **ansible-ready base system**; +all service configuration (zwave-js-ui, sml2mqtt, open3e/CAN) stays in +`ansible/roles/edge-host` as the single source of truth. Rebuilding the +box is: boot ISO → `edge-install` → run the play. + +## Build + +``` +sudo pacman -S archiso +cd images/edge-host-iso +sudo ./build.sh # ISO lands in ./out/ +``` + +The profile is assembled at build time from `/usr/share/archiso/configs/releng` +plus the overlay in `airootfs/` — the repo carries only the delta, so +archiso updates flow in automatically. + +## Install + +1. Write the ISO to a USB stick: `cp out/edge-host-*.iso /dev/sdX` +2. Boot the target from it (UEFI, Secure Boot off; one-time F12/setup + visit if the box doesn't already boot USB first) +3. Either on the console (auto-login as root) or headless — + `ssh root@` works out of the box, the live env carries the + homelab keys — run `edge-install`. It auto-detects the internal disk + (NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk), + shows the target and requires typing the hostname before wiping + anything. Override with `edge-install /dev/sdY`. +4. Reboot, then from the workstation: + ``` + cd ansible + ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host + ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml + ``` +5. Continue with the cutover checklist in `ansible/roles/edge-host/README.md`. diff --git a/images/edge-host-iso/airootfs/etc/modules-load.d/edge-mmc.conf b/images/edge-host-iso/airootfs/etc/modules-load.d/edge-mmc.conf new file mode 100644 index 00000000..9a0526e4 --- /dev/null +++ b/images/edge-host-iso/airootfs/etc/modules-load.d/edge-mmc.conf @@ -0,0 +1,5 @@ +# The Wyse 3040's eMMC card is detected by sdhci_acpi, but the live env +# does not reliably autoload mmc_block — without it no /dev/mmcblk* +# appears and edge-install finds no target. Load explicitly. +mmc_block +sdhci_acpi diff --git a/images/edge-host-iso/airootfs/root/.ssh/authorized_keys b/images/edge-host-iso/airootfs/root/.ssh/authorized_keys new file mode 100644 index 00000000..0852b517 --- /dev/null +++ b/images/edge-host-iso/airootfs/root/.ssh/authorized_keys @@ -0,0 +1,4 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310 diff --git a/images/edge-host-iso/airootfs/root/edge-authorized_keys b/images/edge-host-iso/airootfs/root/edge-authorized_keys new file mode 100644 index 00000000..0852b517 --- /dev/null +++ b/images/edge-host-iso/airootfs/root/edge-authorized_keys @@ -0,0 +1,4 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310 diff --git a/images/edge-host-iso/airootfs/usr/local/bin/edge-install b/images/edge-host-iso/airootfs/usr/local/bin/edge-install new file mode 100755 index 00000000..135e8836 --- /dev/null +++ b/images/edge-host-iso/airootfs/usr/local/bin/edge-install @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Installer for edge-1 (bare-metal edge host, replaces the k8s-edge VM). +# Runs from the edge-host ISO live environment. WIPES the target disk. +# Usage: edge-install [disk] +# Default: the internal disk — first NVMe, else eMMC (Wyse 3040), else +# the first non-USB, non-optical disk. The live USB stick itself is +# excluded via the ISO9660 label mount. +set -euo pipefail + +HOST=edge-1 + +detect_disk() { + # eMMC needs mmc_block before any /dev/mmcblk* exists (see + # modules-load.d/edge-mmc.conf; belt-and-braces here for older sticks). + modprobe -q mmc_block sdhci_acpi 2>/dev/null || true + udevadm settle 2>/dev/null || true + [ -b /dev/nvme0n1 ] && { echo /dev/nvme0n1; return; } + for d in /dev/mmcblk0 /dev/mmcblk1 /dev/mmcblk2; do + [ -b "$d" ] && { echo "$d"; return; } + done + # NAME,TYPE first — TRAN is often empty (eMMC, virtio) and would shift + # whitespace-split fields if it sat in the middle. + lsblk -dno NAME,TYPE,TRAN | awk '$2=="disk" && $3!="usb" {print "/dev/"$1; exit}' +} + +DISK="${1:-$(detect_disk)}" +[ -n "$DISK" ] || { echo "no internal disk found; pass one explicitly"; exit 1; } +[ -b "$DISK" ] || { echo "no such block device: $DISK"; exit 1; } + +echo "Target disk: $DISK" +lsblk "$DISK" +echo +read -rp "This WIPES ${DISK} and installs ${HOST}. Type '${HOST}' to continue: " ans +[ "$ans" = "$HOST" ] || { echo "aborted"; exit 1; } + +part() { + case "$DISK" in + *nvme*|*mmcblk*) echo "${DISK}p$1" ;; + *) echo "${DISK}$1" ;; + esac +} + +sgdisk --zap-all "$DISK" +sgdisk -n1:0:+512M -t1:ef00 -c1:ESP -N2 -t2:8304 -c2:root "$DISK" +partprobe "$DISK" +mkfs.fat -F32 "$(part 1)" +mkfs.ext4 -F "$(part 2)" + +mount "$(part 2)" /mnt +mount --mkdir "$(part 1)" /mnt/boot + +# Everything the edge-host ansible role expects to find; the role itself +# is applied over SSH after first boot (see ansible/roles/edge-host). +pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \ + docker docker-compose + +genfstab -U /mnt >> /mnt/etc/fstab + +# eMMC boot: autodetect does not reliably include the mmc stack, and a +# root on /dev/mmcblk0p2 without mmc_block in the initramfs is unbootable +# (observed on the Wyse 3040). Pin the modules and regenerate. +sed -i 's/^MODULES=.*/MODULES=(sdhci_acpi sdhci_pci mmc_block)/' /mnt/etc/mkinitcpio.conf +arch-chroot /mnt mkinitcpio -P +echo "$HOST" > /mnt/etc/hostname +ln -sf /usr/share/zoneinfo/Europe/Berlin /mnt/etc/localtime +echo 'en_US.UTF-8 UTF-8' > /mnt/etc/locale.gen +echo 'LANG=en_US.UTF-8' > /mnt/etc/locale.conf +arch-chroot /mnt locale-gen + +cat > /mnt/etc/systemd/network/20-wired.network <<'EOF' +[Match] +Type=ether + +[Network] +DHCP=yes +EOF +ln -sf /run/systemd/resolve/stub-resolv.conf /mnt/etc/resolv.conf + +# Key-only root SSH (no root password is set — console login stays locked, +# which is fine for a headless box; recovery goes through this ISO). +install -d -m700 /mnt/root/.ssh +install -m600 /root/edge-authorized_keys /mnt/root/.ssh/authorized_keys + +arch-chroot /mnt systemctl enable \ + systemd-networkd systemd-resolved systemd-timesyncd sshd docker + +arch-chroot /mnt bootctl install +cat > /mnt/boot/loader/loader.conf <<'EOF' +default edge.conf +timeout 3 +EOF +cat > /mnt/boot/loader/entries/edge.conf <> "$profile/packages.x86_64" +done + +# Brand the image and make the installer executable inside the ISO. +sed -i 's/^iso_name=.*/iso_name="edge-host"/' "$profile/profiledef.sh" +sed -i '/^file_permissions=(/a\ ["/usr/local/bin/edge-install"]="0:0:755"' "$profile/profiledef.sh" +# Headless install path: releng enables sshd; with the homelab keys on the +# live root you can `ssh root@` and run edge-install monitor-free. +sed -i '/^file_permissions=(/a\ ["/root/.ssh"]="0:0:700"' "$profile/profiledef.sh" +sed -i '/^file_permissions=(/a\ ["/root/.ssh/authorized_keys"]="0:0:600"' "$profile/profiledef.sh" + +mkarchiso -v -w /tmp/work -o /out "$profile" diff --git a/images/edge-host-iso/build.sh b/images/edge-host-iso/build.sh new file mode 100755 index 00000000..4aa9008e --- /dev/null +++ b/images/edge-host-iso/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Build the edge-host installer ISO inside an Arch container. +# Building must happen against real Arch repos — a Manjaro (or any +# non-Arch) host can't feed mkarchiso, so the container is the default +# and only path. Needs docker; --privileged because pacstrap bind-mounts +# into the chroot. +# ./build.sh [output-dir] +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +out="${1:-$here/out}" +mkdir -p "$out" + +docker run --rm --privileged \ + -v "$here:/src:ro" \ + -v "$out:/out" \ + archlinux:latest /src/build-inner.sh + +echo +echo "ISO in $out — write it with:" +echo " sudo sh -c 'cp $out/edge-host-*.iso /dev/sdX && sync' # USB stick, NOT the NVMe" diff --git a/opentofu/vm_153_k8s_edge.tf b/opentofu/vm_153_k8s_edge.tf index 163bb375..d336c379 100644 --- a/opentofu/vm_153_k8s_edge.tf +++ b/opentofu/vm_153_k8s_edge.tf @@ -13,8 +13,11 @@ # 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). -# k8s-edge stays a Proxmox VM (decision 2026-05-05) — bare-metal N100 -# trajectory is shelved. +# 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"