Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cfa63f6f3 | ||
|
|
9cc20251fd | ||
|
|
2d9c3ba213 | ||
|
|
07faf0ae69 | ||
|
|
e288ebe39d |
@@ -33,13 +33,21 @@ Build and boot the dedicated installer image — see
|
||||
(partitions the internal disk — NVMe if present, else the eMMC; the
|
||||
installer autodetects — 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:
|
||||
3. Confirm the box resolves and is reachable by name — `ssh root@<host>` must
|
||||
work before anything else. The UDM registers DHCP leases in DNS, so the
|
||||
record appears on its own; the host is identified by name everywhere
|
||||
(inventory, plays, this file) and its address is never recorded. Do not
|
||||
pin it to a reservation.
|
||||
4. Add the host to `[edge_host]` in `inventory/pve_hosts.yml`, by name.
|
||||
5. 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
|
||||
```
|
||||
One unrestricted run over the whole group. The play is self-contained --
|
||||
it carries `certificates` and `common` itself, so there is no second
|
||||
command and no `--limit`. Running it converges every edge host, which is
|
||||
the point: a run that touches only the new box tells you nothing about
|
||||
the others.
|
||||
|
||||
## Secrets (before first play run)
|
||||
|
||||
@@ -85,7 +93,7 @@ zwave-js (order matters — never two servers on one radio):
|
||||
`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 edge-1, `docker compose up -d zwave-js`
|
||||
- [ ] Point HA `zwave_js` integration at `ws://edge-1.srv.renner.uno:3000`
|
||||
- [ ] Point HA `zwave_js` integration at `ws://edge-1.iot.renner.uno:3000`
|
||||
- [ ] Nodes alive in the UI on :8091 → remove the flux kustomization
|
||||
`home-assistant-zwavejs` + PVC in a follow-up PR
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
# 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
|
||||
# to this file; see docs/edge-host.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 }}"
|
||||
|
||||
@@ -1,13 +1,89 @@
|
||||
---
|
||||
# Bare-metal Arch Linux edge host (Dell Wyse 3040, internal eMMC) — 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
|
||||
# Bare-metal Arch edge hosts (Dell Wyse 3040 and similar) — the home for
|
||||
# hardware-bound services, because Talos supports neither SocketCAN nor USB
|
||||
# passthrough.
|
||||
#
|
||||
# One role per concern. Previously all of this lived in a single `edge-host`
|
||||
# role, which meant a host could not take the flash-wear handling or the log
|
||||
# shipper without also being handed the Z-Wave radio and the CAN bus. That is
|
||||
# what blocked a second thin client from joining as an NTP peer.
|
||||
#
|
||||
# Self-contained: `certificates` and `common` are the parts of plays/common.yml
|
||||
# that apply to an edge host, carried here so bootstrapping one is a single
|
||||
# unrestricted run over the group. Not carried over: qemu-guest-agent (bare
|
||||
# metal, not VMs) and monitoring-agent (Debian-oriented; these hosts ship logs
|
||||
# via edge-log-shipper).
|
||||
#
|
||||
# Order is dependency order, not alphabetical:
|
||||
# mqtt-bridge before its clients, so they have somewhere to publish
|
||||
# edge-flash-wear before docker, so the journal is volatile before
|
||||
# containers start logging into it
|
||||
# socketcan before heating-can, which consumes can0
|
||||
- name: Install Arch edge host
|
||||
hosts: edge_host
|
||||
# Connection is already root (ansible_user=root), so this is a root-to-root
|
||||
# no-op today. Kept explicit, matching plays/common.yml, so the roles below
|
||||
# do not silently depend on the connection user staying privileged.
|
||||
become: true
|
||||
|
||||
# Defined on the play, not taken from a role's defaults: these are used by
|
||||
# pre_tasks, which must not depend on role default resolution.
|
||||
#
|
||||
# heating-can, zwave-gateway and power-meter-sml each declare edge_base_dir
|
||||
# in their own defaults too, so they stay usable standalone. Play vars win,
|
||||
# so these are the effective values -- keep them in step if the path moves.
|
||||
vars:
|
||||
edge_base_dir: /opt/edge-host
|
||||
edge_legacy_compose: "{{ edge_base_dir }}/docker-compose.yml"
|
||||
|
||||
# One-time migration off the shared compose project, BEFORE any role tries
|
||||
# to start a container.
|
||||
#
|
||||
# zwave-js and sml2mqtt used to live in one project at edge_legacy_compose,
|
||||
# both with a fixed container_name. Docker enforces container names globally,
|
||||
# not per project, so while the old project still owns those names the new
|
||||
# per-service projects cannot create them -- `docker compose up -d` fails with
|
||||
# "Conflict: container name already in use", which is not the missing-USB-device
|
||||
# error the roles tolerate, so the play would abort.
|
||||
#
|
||||
# It belongs here rather than in either role: it is a migration step for both
|
||||
# of them, and putting it in one meant whichever role ran first hit the
|
||||
# conflict. Guarded on the legacy file, so it runs once and then never again.
|
||||
#
|
||||
# check mode cannot verify this: command tasks are skipped, not simulated, so
|
||||
# a --check run neither hits the conflict nor proves the teardown works.
|
||||
pre_tasks:
|
||||
- name: Retire the legacy shared compose project
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose -f "{{ edge_legacy_compose }}" down
|
||||
args:
|
||||
removes: "{{ edge_legacy_compose }}"
|
||||
failed_when: false
|
||||
|
||||
- name: Remove the legacy shared compose file
|
||||
ansible.builtin.file:
|
||||
path: "{{ edge_legacy_compose }}"
|
||||
state: absent
|
||||
|
||||
- name: Remove the legacy zwave-js env from the shared project root
|
||||
ansible.builtin.file:
|
||||
path: "{{ edge_base_dir }}/zwavejs.env"
|
||||
state: absent
|
||||
|
||||
roles:
|
||||
- certificates
|
||||
- common
|
||||
|
||||
# Host-generic. An edge host with no attached peripherals takes these and
|
||||
# nothing below them.
|
||||
- edge-flash-wear
|
||||
- docker-install
|
||||
- edge-host
|
||||
- edge-log-shipper
|
||||
- mqtt-bridge
|
||||
- edge-vlan
|
||||
|
||||
# Hardware-bound. Each needs its own device present.
|
||||
- socketcan
|
||||
- heating-can
|
||||
- zwave-gateway
|
||||
- power-meter-sml
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# Flash-wear mitigation.
|
||||
|
||||
# --- Logging & eMMC wear mitigation ------------------------------------
|
||||
# edge-1 runs on a small internal eMMC (Dell Wyse 3040, 7.3 GB), so logs are
|
||||
# kept off local flash: containers + native services log to a RAM-only
|
||||
# journal and Grafana Alloy ships it to Loki. TRIM, noatime and a weekly
|
||||
# wear check round out the flash-longevity measures. See README "Logging &
|
||||
# eMMC".
|
||||
edge_journald_runtime_max: "128M" # cap the volatile (RAM) journal
|
||||
|
||||
# Container stdout -> journal, no json log files on the flash. The journal
|
||||
# itself is volatile (see the tasks above), so container logs land in RAM and
|
||||
# are shipped off the box by edge-log-shipper.
|
||||
docker_log_driver: journald
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# edge-1 eMMC wear/health check. Managed by roles/edge-host.
|
||||
# edge-1 eMMC wear/health check. Managed by roles/edge-flash-wear.
|
||||
#
|
||||
# Reads the JEDEC eMMC 5.x life-time and pre-EOL estimators exposed via sysfs
|
||||
# and emits a warning-priority journal message when the flash leaves the
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
- name: Restart journald
|
||||
ansible.builtin.service:
|
||||
name: systemd-journald
|
||||
state: restarted
|
||||
|
||||
# Handlers run in definition order, so this fires after 'Restart journald':
|
||||
# once the journal is volatile, the on-disk store is dead weight on the flash.
|
||||
- name: Purge persistent journal store
|
||||
ansible.builtin.file:
|
||||
path: /var/log/journal
|
||||
state: absent
|
||||
|
||||
- name: Restart docker
|
||||
ansible.builtin.service:
|
||||
name: docker
|
||||
state: restarted
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
# Flash-wear mitigation for hosts on small eMMC (Dell Wyse 3040 and similar).
|
||||
#
|
||||
# journald -> volatile (RAM) and size-capped, root mounted noatime+lazytime,
|
||||
# periodic TRIM, and a weekly wear check. Shipping the volatile journal off the
|
||||
# box is a separate concern -- see the edge-log-shipper role.
|
||||
- name: Install mmc-utils
|
||||
ansible.builtin.package:
|
||||
name: mmc-utils # eMMC lifetime/EOL introspection (mmc extcsd read)
|
||||
state: present
|
||||
|
||||
- name: Ensure journald drop-in directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/journald.conf.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Switch the systemd journal to volatile (RAM) storage
|
||||
ansible.builtin.template:
|
||||
src: journald-edge.conf.j2
|
||||
dest: /etc/systemd/journald.conf.d/00-edge-emmc.conf
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Restart journald
|
||||
- Purge persistent journal store
|
||||
|
||||
- name: Resolve root filesystem facts (for noatime)
|
||||
ansible.builtin.set_fact:
|
||||
edge_root_fs: "{{ ansible_facts.mounts | selectattr('mount', 'equalto', '/') | first }}"
|
||||
|
||||
- name: Refuse to touch fstab without a stable root UUID
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- edge_root_fs.uuid | default('') | length > 0
|
||||
fail_msg: "Could not determine root UUID from facts; not rewriting fstab."
|
||||
|
||||
# lazytime batches inode-timestamp writeback (e.g. the zwave-js lockfile
|
||||
# heartbeat that otherwise dirties the journal every couple seconds); noatime
|
||||
# drops access-time writes. Neither weakens data durability.
|
||||
- name: Mount root with noatime + lazytime (persisted in fstab)
|
||||
ansible.posix.mount:
|
||||
path: /
|
||||
src: "UUID={{ edge_root_fs.uuid }}"
|
||||
fstype: "{{ edge_root_fs.fstype }}"
|
||||
opts: "{{ ((edge_root_fs.options.split(',') | reject('in', ['atime', 'noatime', 'relatime', 'diratime', 'lazytime', 'nolazytime']) | list) + ['noatime', 'lazytime']) | unique | join(',') }}"
|
||||
dump: "0"
|
||||
passno: "1" # root fs — keep boot-time fsck ordering
|
||||
backup: true
|
||||
state: mounted
|
||||
|
||||
- name: Enable periodic TRIM (helps eMMC wear-leveling)
|
||||
ansible.builtin.systemd:
|
||||
name: fstrim.timer
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Install eMMC health-check script
|
||||
ansible.builtin.copy:
|
||||
src: edge-emmc-health
|
||||
dest: /usr/local/bin/edge-emmc-health
|
||||
mode: "0755"
|
||||
|
||||
- name: Install eMMC health-check service + timer
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
mode: "0644"
|
||||
loop:
|
||||
- edge-emmc-health.service
|
||||
- edge-emmc-health.timer
|
||||
|
||||
- name: Enable eMMC health-check timer
|
||||
ansible.builtin.systemd:
|
||||
name: edge-emmc-health.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
# Container stdout into the journal instead of json files on disk. This lives
|
||||
# here, with the rest of the flash-wear handling, rather than in docker-install:
|
||||
# that role is shared by ten other plays (authentik, n8n, owncloud, grist,
|
||||
# netbird, gatus, music-assistant, ical-proxy), none of which have a
|
||||
# daemon.json today. Adding it there would have created one on the next run of
|
||||
# any of those plays and restarted every container on those hosts.
|
||||
#
|
||||
# /etc/docker is created explicitly because this role runs before
|
||||
# docker-install on a first bootstrap, so the directory may not exist yet. The
|
||||
# handler runs at the end of the play, by which time docker is installed.
|
||||
- name: Ensure the docker config directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/docker
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Route container logs to the journal (keeps json logs off the flash)
|
||||
ansible.builtin.template:
|
||||
src: docker-daemon.json.j2
|
||||
dest: /etc/docker/daemon.json
|
||||
mode: "0644"
|
||||
notify: Restart docker
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"log-driver": "{{ docker_log_driver }}"
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by roles/edge-host. edge-1 boots off a small internal eMMC, so the
|
||||
# Managed by roles/edge-flash-wear. edge-1 boots off a small internal eMMC, so the
|
||||
# journal must not persist to flash: keep it in RAM (/run/log/journal) and cap
|
||||
# its size. Grafana Alloy (edge-alloy.service) ships this journal to Loki
|
||||
# before it rotates out of RAM — nothing durable lives here.
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
edge_base_dir: /opt/edge-host
|
||||
edge_tz: Europe/Berlin
|
||||
|
||||
# --- Logging & eMMC wear mitigation ------------------------------------
|
||||
# edge-1 runs on a small internal eMMC (Dell Wyse 3040, 7.3 GB), so logs are
|
||||
# kept off local flash: containers + native services log to a RAM-only
|
||||
# journal and Grafana Alloy ships it to Loki. TRIM, noatime and a weekly
|
||||
# wear check round out the flash-longevity measures. See README "Logging &
|
||||
# eMMC".
|
||||
edge_journald_runtime_max: "128M" # cap the volatile (RAM) journal
|
||||
edge_docker_log_driver: journald # container stdout -> journal, no json logs on eMMC
|
||||
|
||||
# Grafana Alloy log shipper. The srv-network Loki (loki.srv.renner.uno:3100,
|
||||
# no auth) is firewalled off the IoT VLAN; the only endpoint edge-1 can reach
|
||||
# is the authenticated cluster ingress (Traefik basic-auth).
|
||||
edge_alloy_config_file: /etc/alloy/edge.alloy
|
||||
edge_alloy_http_port: 12345
|
||||
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
|
||||
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
|
||||
edge_loki_username: edge-1
|
||||
edge_alloy_labels:
|
||||
environment: production
|
||||
datacenter: homelab
|
||||
|
||||
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
|
||||
# mosquitto on loopback, which bridges to the central cluster broker over TLS
|
||||
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
|
||||
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
|
||||
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
|
||||
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
|
||||
edge_mqtt_host: 127.0.0.1
|
||||
edge_mqtt_port: 1883
|
||||
# Upstream cluster broker the local bridge connects to (TLS; the public
|
||||
# Let's Encrypt cert is validated against the system CA bundle).
|
||||
edge_mqtt_cluster_host: mqtt.renner.uno
|
||||
edge_mqtt_cluster_tls_port: 8883
|
||||
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# --- USB devices (stable by-id symlinks). After physically moving the
|
||||
# sticks to edge-1, 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
|
||||
# Alpine version the zwave-js-ui image is built on — the source for the musl
|
||||
# libeatmydata.so preloaded into that container (see tasks "Extract musl
|
||||
# libeatmydata.so"). Keep in sync with the base if edge_zwavejs_image bumps
|
||||
# its Alpine major (check: docker exec zwave-js cat /etc/os-release).
|
||||
edge_zwave_alpine_base: alpine:3.22
|
||||
|
||||
# --- 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.
|
||||
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
|
||||
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
|
||||
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
|
||||
# codec inherited through the vx3plus overlay.
|
||||
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
|
||||
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
|
||||
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
|
||||
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
|
||||
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
|
||||
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
|
||||
# entries when added), so the multi-frame read is cheap at a 30 s poll.
|
||||
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
|
||||
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
|
||||
edge_open3e_datapoints: >-
|
||||
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,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.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
|
||||
# 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" }
|
||||
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
|
||||
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
|
||||
vx3: { tx: "0x6a1", dpList: "/opt/edge-host/open3e/vx3plus.py" }
|
||||
vcmu: { tx: "0x68c" }
|
||||
|
||||
# --- E3onCAN: passive E380 energy-meter listener (no bus writes, so it
|
||||
# coexists with the single open3e instance). The meter broadcasts at
|
||||
# ~5 Hz; the gap throttles MQTT to one update per DID per interval.
|
||||
edge_e3oncan_repo: https://github.com/MyHomeMyData/E3onCAN.git
|
||||
edge_e3oncan_version: 461dcbdf97e8d72d2edb9929c63b39382af6c3a1 # V0.5.2
|
||||
edge_e3oncan_gap_seconds: 10
|
||||
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
|
||||
# --- 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 }}"
|
||||
|
||||
# --- Tagged VLAN 40 sub-interface. Off by default; enabled in
|
||||
# inventory/group_vars/edge_host. Exists so edge-1 can join the NTP keepalived
|
||||
# VRRP group, which needs layer-2 adjacency with the other members on the
|
||||
# service VLAN. The untagged VLAN 30 lease stays this host's only default
|
||||
# route -- see templates/10-vlan40.network.j2.
|
||||
edge_vlan40_enabled: false
|
||||
edge_vlan40_parent: enp1s0
|
||||
edge_vlan40_id: 40
|
||||
edge_vlan40_name: enp1s0.40
|
||||
edge_vlan40_parent_network: 20-wired.network
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
|
||||
# Reconfiguring the parent is what actually materialises the VLAN child -- a
|
||||
# bare reload leaves the existing link as-is (see above). This re-runs DHCP on
|
||||
# enp1s0, which is this headless host's only path to the network, so it is the
|
||||
# one genuinely risky step in the VLAN change.
|
||||
- name: Reload networkd for VLAN
|
||||
ansible.builtin.shell:
|
||||
cmd: networkctl reload && networkctl reconfigure {{ edge_vlan40_parent }}
|
||||
changed_when: true
|
||||
|
||||
# Ordered before 'Restart edge compose services': the daemon must pick up the
|
||||
# new default log driver before the containers are recreated onto it.
|
||||
- name: Restart docker
|
||||
ansible.builtin.service:
|
||||
name: docker
|
||||
state: restarted
|
||||
|
||||
- name: Restart journald
|
||||
ansible.builtin.service:
|
||||
name: systemd-journald
|
||||
state: restarted
|
||||
|
||||
# Handlers run in definition order, so this fires after 'Restart journald':
|
||||
# once the journal is volatile, the on-disk store is dead weight on the eMMC.
|
||||
- name: Purge persistent journal store
|
||||
ansible.builtin.file:
|
||||
path: /var/log/journal
|
||||
state: absent
|
||||
|
||||
- name: Restart alloy
|
||||
ansible.builtin.systemd:
|
||||
name: edge-alloy
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
# Ordered before the MQTT clients (compose/open3e/e3oncan) so they reconnect
|
||||
# to a freshly-configured local broker.
|
||||
- name: Restart mosquitto
|
||||
ansible.builtin.service:
|
||||
name: mosquitto
|
||||
state: restarted
|
||||
|
||||
- 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
|
||||
|
||||
- name: Restart e3oncan
|
||||
ansible.builtin.systemd:
|
||||
name: e3oncan
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -1,440 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
- mmc-utils # eMMC lifetime/EOL introspection (mmc extcsd read)
|
||||
- grafana-alloy # log shipper -> Loki (see Alloy section below)
|
||||
- mosquitto # local MQTT bridge broker -> cluster over TLS
|
||||
state: present
|
||||
|
||||
# --- Local MQTT bridge broker. Edge clients publish to this over loopback;
|
||||
# it bridges to the cluster broker over TLS (open3e/e3oncan can't do MQTTS).
|
||||
# Configured with persistence off + stdout logging so it writes nothing to the
|
||||
# eMMC. Deployed before the clients so they have somewhere to connect.
|
||||
# See edge-mqtt-bridge.conf.j2 / README "MQTT bridge".
|
||||
- name: Deploy local mosquitto bridge config
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge.conf.j2
|
||||
dest: /etc/mosquitto/mosquitto.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the cluster bridge credentials
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Enable local mosquitto bridge
|
||||
ansible.builtin.systemd:
|
||||
name: mosquitto
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# ======================================================================
|
||||
# eMMC wear mitigation + log shipping. edge-1 (Dell Wyse 3040) runs on a
|
||||
# small internal eMMC, so logs must not churn local flash. Strategy:
|
||||
# - journald -> volatile (RAM), size-capped (journald-edge.conf)
|
||||
# - docker -> journald log driver, no json files (docker-daemon.json)
|
||||
# - Grafana Alloy tails the RAM journal -> Loki, WAL on tmpfs
|
||||
# plus TRIM, noatime and a weekly wear check for flash longevity.
|
||||
# See roles/edge-host/README.md "Logging & eMMC".
|
||||
# ======================================================================
|
||||
|
||||
- name: Ensure journald drop-in directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/journald.conf.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Switch the systemd journal to volatile (RAM) storage
|
||||
ansible.builtin.template:
|
||||
src: journald-edge.conf.j2
|
||||
dest: /etc/systemd/journald.conf.d/00-edge-emmc.conf
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Restart journald
|
||||
- Purge persistent journal store
|
||||
|
||||
- name: Resolve root filesystem facts (for noatime)
|
||||
ansible.builtin.set_fact:
|
||||
edge_root_fs: "{{ ansible_facts.mounts | selectattr('mount', 'equalto', '/') | first }}"
|
||||
|
||||
- name: Refuse to touch fstab without a stable root UUID
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- edge_root_fs.uuid | default('') | length > 0
|
||||
fail_msg: "Could not determine root UUID from facts; not rewriting fstab."
|
||||
|
||||
# lazytime batches inode-timestamp writeback (e.g. the zwave-js lockfile
|
||||
# heartbeat that otherwise dirties the journal every couple seconds); noatime
|
||||
# drops access-time writes. Neither weakens data durability.
|
||||
- name: Mount root with noatime + lazytime (persisted in fstab)
|
||||
ansible.posix.mount:
|
||||
path: /
|
||||
src: "UUID={{ edge_root_fs.uuid }}"
|
||||
fstype: "{{ edge_root_fs.fstype }}"
|
||||
opts: "{{ ((edge_root_fs.options.split(',') | reject('in', ['atime', 'noatime', 'relatime', 'diratime', 'lazytime', 'nolazytime']) | list) + ['noatime', 'lazytime']) | unique | join(',') }}"
|
||||
dump: "0"
|
||||
passno: "1" # root fs — keep boot-time fsck ordering
|
||||
backup: true
|
||||
state: mounted
|
||||
|
||||
- name: Enable periodic TRIM (helps eMMC wear-leveling)
|
||||
ansible.builtin.systemd:
|
||||
name: fstrim.timer
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Install eMMC health-check script
|
||||
ansible.builtin.copy:
|
||||
src: edge-emmc-health
|
||||
dest: /usr/local/bin/edge-emmc-health
|
||||
mode: "0755"
|
||||
|
||||
- name: Install eMMC health-check service + timer
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
mode: "0644"
|
||||
loop:
|
||||
- edge-emmc-health.service
|
||||
- edge-emmc-health.timer
|
||||
|
||||
- name: Enable eMMC health-check timer
|
||||
ansible.builtin.systemd:
|
||||
name: edge-emmc-health.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
# --- Grafana Alloy: ship the volatile journal to Loki. Container logs reach
|
||||
# the journal via docker's journald driver (configured below), native
|
||||
# services log to the journal directly, so a single journal source covers
|
||||
# everything. Storage/WAL sits on /run (tmpfs) — see edge-alloy.service.
|
||||
- name: Create alloy system group
|
||||
ansible.builtin.group:
|
||||
name: alloy
|
||||
system: true
|
||||
|
||||
- name: Create alloy system user
|
||||
ansible.builtin.user:
|
||||
name: alloy
|
||||
group: alloy
|
||||
groups: systemd-journal
|
||||
system: true
|
||||
create_home: false
|
||||
shell: /usr/bin/nologin
|
||||
|
||||
- name: Create alloy config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/alloy
|
||||
state: directory
|
||||
owner: alloy
|
||||
group: alloy
|
||||
mode: "0750"
|
||||
|
||||
- name: Deploy alloy config (journal -> Loki)
|
||||
ansible.builtin.template:
|
||||
src: edge-alloy.alloy.j2
|
||||
dest: "{{ edge_alloy_config_file }}"
|
||||
owner: alloy
|
||||
group: alloy
|
||||
mode: "0600" # carries the Loki basic-auth password
|
||||
notify: Restart alloy
|
||||
|
||||
- name: Deploy alloy systemd unit
|
||||
ansible.builtin.template:
|
||||
src: edge-alloy.service.j2
|
||||
dest: /etc/systemd/system/edge-alloy.service
|
||||
mode: "0644"
|
||||
notify: Restart alloy
|
||||
|
||||
- name: Enable edge-alloy
|
||||
ansible.builtin.systemd:
|
||||
name: edge-alloy
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
# --- 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
|
||||
|
||||
# --- Tagged VLAN 40. Additive by construction: the parent link keeps its own
|
||||
# .network file untouched and gains the VLAN via a drop-in, so reverting is a
|
||||
# matter of deleting these three paths. This host is headless and enp1s0 is its
|
||||
# only route to the network, hence the caution.
|
||||
- name: Create VLAN sub-interface netdev
|
||||
ansible.builtin.template:
|
||||
src: 10-vlan40.netdev.j2
|
||||
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.netdev"
|
||||
mode: "0644"
|
||||
when: edge_vlan40_enabled
|
||||
notify: Reload networkd for VLAN
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Configure VLAN sub-interface addressing
|
||||
ansible.builtin.template:
|
||||
src: 10-vlan40.network.j2
|
||||
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.network"
|
||||
mode: "0644"
|
||||
when: edge_vlan40_enabled
|
||||
notify: Reload networkd for VLAN
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Ensure parent network drop-in directory exists
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: edge_vlan40_enabled
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Attach VLAN to parent link via drop-in
|
||||
ansible.builtin.template:
|
||||
src: vlan40-parent-dropin.conf.j2
|
||||
dest: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d/10-vlan40.conf"
|
||||
mode: "0644"
|
||||
when: edge_vlan40_enabled
|
||||
notify: Reload networkd for VLAN
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Enable systemd-networkd
|
||||
ansible.builtin.service:
|
||||
name: systemd-networkd
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
# Docker networking needs no daemon.json (both services use network_mode:
|
||||
# host — real addresses, v4+v6, no bridge/NAT). The daemon.json below exists
|
||||
# only to route container stdout into the journald log driver so json logs
|
||||
# never touch the eMMC; the journal itself is volatile (RAM) and shipped to
|
||||
# Loki by edge-alloy. Both compose services inherit this default driver.
|
||||
- name: Configure docker daemon (journald log driver, eMMC-friendly)
|
||||
ansible.builtin.template:
|
||||
src: docker-daemon.json.j2
|
||||
dest: /etc/docker/daemon.json
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Restart docker
|
||||
- Restart edge compose services
|
||||
|
||||
# --- 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"
|
||||
|
||||
# --- zwave-js eMMC write reduction: batch fsync via libeatmydata ---------
|
||||
# zwave-js persists its value DB with an fsync on *every* value change
|
||||
# (jsonl-db, throttleFS unset by zwave-js-ui). On a chatty network that
|
||||
# fsync storm churns the eMMC (~2.6 GiB/day for ~0.1 KiB/s of real data).
|
||||
# Preloading libeatmydata makes fsync a no-op so the kernel batches the
|
||||
# writes via normal writeback — full HA data resolution and cache
|
||||
# persistence are untouched (see docker-compose.yml.j2 for the mount + env).
|
||||
# The .so must match the zwave-js-ui image's musl/Alpine base, so extract it
|
||||
# from that exact Alpine version (bridge networking is broken on this host,
|
||||
# hence --network=host). Refresh by deleting the .so if the base ever bumps
|
||||
# its Alpine major version.
|
||||
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
|
||||
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
|
||||
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
|
||||
install -m 0644 "$tmp" {{ edge_base_dir }}/zwave-js/libeatmydata.so
|
||||
creates: "{{ edge_base_dir }}/zwave-js/libeatmydata.so"
|
||||
notify: Restart edge compose services
|
||||
|
||||
# Because fsync is neutralized, a hard power-loss during the (rare) settings
|
||||
# write could truncate settings.json — which holds the Z-Wave network keys.
|
||||
# Keep a validated rolling backup on the eMMC so the keys stay recoverable.
|
||||
- name: Install zwave-js settings.json backup script
|
||||
ansible.builtin.template:
|
||||
src: edge-zwave-settings-backup.j2
|
||||
dest: /usr/local/bin/edge-zwave-settings-backup
|
||||
mode: "0755"
|
||||
|
||||
- name: Install zwave-js settings backup service + timer
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
mode: "0644"
|
||||
loop:
|
||||
- edge-zwave-settings-backup.service
|
||||
- edge-zwave-settings-backup.timer
|
||||
|
||||
- name: Enable zwave-js settings backup timer
|
||||
ansible.builtin.systemd:
|
||||
name: edge-zwave-settings-backup.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
# --- 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 vx3 datapoint overlay (battery-module DIDs)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-vx3plus.py
|
||||
dest: "{{ edge_base_dir }}/open3e/vx3plus.py"
|
||||
mode: "0644"
|
||||
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
|
||||
|
||||
# --- E3onCAN: direct E380 meter reads (passive; per-review decision not
|
||||
# to trust the VX3's relayed meter values). Same broker user as open3e;
|
||||
# topics land under the same open3e/ prefix as e380_<did>_<name>.
|
||||
- name: Check out E3onCAN (pinned)
|
||||
ansible.builtin.git:
|
||||
repo: "{{ edge_e3oncan_repo }}"
|
||||
dest: "{{ edge_base_dir }}/e3oncan/src"
|
||||
version: "{{ edge_e3oncan_version }}"
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Create E3onCAN venv with requirements
|
||||
ansible.builtin.pip:
|
||||
requirements: "{{ edge_base_dir }}/e3oncan/src/requirements.txt"
|
||||
virtualenv: "{{ edge_base_dir }}/e3oncan/venv"
|
||||
virtualenv_command: python -m venv
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Deploy e3oncan systemd unit
|
||||
ansible.builtin.template:
|
||||
src: e3oncan.service.j2
|
||||
dest: /etc/systemd/system/e3oncan.service
|
||||
mode: "0644"
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Enable e3oncan
|
||||
ansible.builtin.systemd:
|
||||
name: e3oncan
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
@@ -1,56 +0,0 @@
|
||||
# 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).
|
||||
# Both services use host networking: real host addresses (v4+v6), no
|
||||
# bridge, no NAT. The broker LB is IPv6-first and docker bridge NAT66 is
|
||||
# a tarpit (unmasqueraded SYNs die silently); the host stack just works.
|
||||
services:
|
||||
zwave-js:
|
||||
image: {{ edge_zwavejs_image }}
|
||||
container_name: zwave-js
|
||||
restart: unless-stopped
|
||||
stop_signal: SIGINT
|
||||
tty: true
|
||||
environment:
|
||||
- TZ={{ edge_tz }}
|
||||
# Batch the value-DB fsync storm to spare the eMMC (see role tasks /
|
||||
# README "Logging & eMMC"). Neutralizes fsync only for this container;
|
||||
# the WS/MQTT value stream to HA and cache persistence are unchanged.
|
||||
- LD_PRELOAD=/usr/lib/libeatmydata.so
|
||||
env_file:
|
||||
- zwavejs.env
|
||||
devices:
|
||||
- "{{ edge_zwave_device }}:/dev/zwave"
|
||||
volumes:
|
||||
# musl libeatmydata.so extracted from the image's Alpine base (ro)
|
||||
- ./zwave-js/libeatmydata.so:/usr/lib/libeatmydata.so:ro
|
||||
# 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
|
||||
# Host networking: binds 3000 (Z-Wave JS websocket for HA) and 8091
|
||||
# (web UI) directly on the host.
|
||||
network_mode: host
|
||||
|
||||
sml2mqtt:
|
||||
image: {{ edge_sml2mqtt_image }}
|
||||
container_name: sml2mqtt
|
||||
restart: unless-stopped
|
||||
# Outbound-only (serial in, MQTT out) — host networking sidesteps the
|
||||
# docker NAT66 gap: the broker LB is IPv6-first and bridge-network v6
|
||||
# SYNs leave unmasqueraded and die. The host stack reaches fd99::81
|
||||
# fine (open3e proves it continuously).
|
||||
network_mode: host
|
||||
# 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
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"log-driver": "{{ edge_docker_log_driver }}"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# Journal -> Loki shipping.
|
||||
#
|
||||
# The srv-network Loki (loki.srv.renner.uno:3100, no auth) is firewalled off
|
||||
# the IoT VLAN; the only endpoint these hosts can reach is the authenticated
|
||||
# cluster ingress. edge_loki_password comes from the vault.
|
||||
|
||||
edge_alloy_config_file: /etc/alloy/edge.alloy
|
||||
edge_alloy_http_port: 12345
|
||||
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
|
||||
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
|
||||
edge_loki_username: edge-1
|
||||
edge_alloy_labels:
|
||||
environment: production
|
||||
datacenter: homelab
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart alloy
|
||||
ansible.builtin.systemd:
|
||||
name: edge-alloy
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
# Ship the systemd journal to Loki with Grafana Alloy.
|
||||
#
|
||||
# Pairs with edge-flash-wear, which makes the journal volatile: container logs
|
||||
# reach the journal via docker s journald driver and native services log to it
|
||||
# directly, so one journal source covers everything. Alloy s storage/WAL sits
|
||||
# on /run (tmpfs) so it does not write to flash either.
|
||||
|
||||
- name: Install grafana-alloy
|
||||
ansible.builtin.package:
|
||||
name: grafana-alloy
|
||||
state: present
|
||||
|
||||
- name: Create alloy system group
|
||||
ansible.builtin.group:
|
||||
name: alloy
|
||||
system: true
|
||||
|
||||
- name: Create alloy system user
|
||||
ansible.builtin.user:
|
||||
name: alloy
|
||||
group: alloy
|
||||
groups: systemd-journal
|
||||
system: true
|
||||
create_home: false
|
||||
shell: /usr/bin/nologin
|
||||
|
||||
- name: Create alloy config directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/alloy
|
||||
state: directory
|
||||
owner: alloy
|
||||
group: alloy
|
||||
mode: "0750"
|
||||
|
||||
- name: Deploy alloy config (journal -> Loki)
|
||||
ansible.builtin.template:
|
||||
src: edge-alloy.alloy.j2
|
||||
dest: "{{ edge_alloy_config_file }}"
|
||||
owner: alloy
|
||||
group: alloy
|
||||
mode: "0600" # carries the Loki basic-auth password
|
||||
notify: Restart alloy
|
||||
|
||||
- name: Deploy alloy systemd unit
|
||||
ansible.builtin.template:
|
||||
src: edge-alloy.service.j2
|
||||
dest: /etc/systemd/system/edge-alloy.service
|
||||
mode: "0644"
|
||||
notify: Restart alloy
|
||||
|
||||
- name: Enable edge-alloy
|
||||
ansible.builtin.systemd:
|
||||
name: edge-alloy
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Grafana Alloy — edge-1 log shipper. Managed by roles/edge-host.
|
||||
// Grafana Alloy — edge-1 log shipper. Managed by roles/edge-log-shipper.
|
||||
//
|
||||
// edge-1 runs on internal eMMC, so logs are kept out of local flash: the
|
||||
// systemd journal is volatile (RAM, see journald-edge.conf) and container
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
# --- Tagged VLAN 40 sub-interface. Off by default; enabled in
|
||||
# inventory/group_vars/edge_host. Exists so edge-1 can join the NTP keepalived
|
||||
# VRRP group, which needs layer-2 adjacency with the other members on the
|
||||
# service VLAN. The untagged VLAN 30 lease stays this host's only default
|
||||
# route -- see templates/10-vlan40.network.j2.
|
||||
edge_vlan40_enabled: false
|
||||
edge_vlan40_parent: enp1s0
|
||||
edge_vlan40_id: 40
|
||||
edge_vlan40_name: enp1s0.40
|
||||
edge_vlan40_parent_network: 20-wired.network
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
# Reconfiguring the parent is what actually materialises the VLAN child -- a
|
||||
# bare reload leaves the existing link as-is. This re-runs DHCP on the parent,
|
||||
# which is a headless host's only path to the network, so it is the one
|
||||
# genuinely risky step in the VLAN change.
|
||||
- name: Reload networkd for VLAN
|
||||
ansible.builtin.shell:
|
||||
cmd: networkctl reload && networkctl reconfigure {{ edge_vlan40_parent }}
|
||||
changed_when: true
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
# Tagged VLAN sub-interface via systemd-networkd.
|
||||
#
|
||||
# Additive by construction: the parent link keeps its own .network file and
|
||||
# gains the VLAN through a drop-in, so reverting is deleting three paths. These
|
||||
# hosts are headless and the parent is their only route to the network, hence
|
||||
# the caution.
|
||||
|
||||
- name: Create VLAN sub-interface netdev
|
||||
ansible.builtin.template:
|
||||
src: 10-vlan40.netdev.j2
|
||||
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.netdev"
|
||||
mode: "0644"
|
||||
when: edge_vlan40_enabled
|
||||
notify: Reload networkd for VLAN
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Configure VLAN sub-interface addressing
|
||||
ansible.builtin.template:
|
||||
src: 10-vlan40.network.j2
|
||||
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.network"
|
||||
mode: "0644"
|
||||
when: edge_vlan40_enabled
|
||||
notify: Reload networkd for VLAN
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Ensure parent network drop-in directory exists
|
||||
ansible.builtin.file:
|
||||
path: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: edge_vlan40_enabled
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Attach VLAN to parent link via drop-in
|
||||
ansible.builtin.template:
|
||||
src: vlan40-parent-dropin.conf.j2
|
||||
dest: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d/10-vlan40.conf"
|
||||
mode: "0644"
|
||||
when: edge_vlan40_enabled
|
||||
notify: Reload networkd for VLAN
|
||||
tags: [vlan40]
|
||||
|
||||
- name: Enable systemd-networkd
|
||||
ansible.builtin.service:
|
||||
name: systemd-networkd
|
||||
state: started
|
||||
enabled: true
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus: open3e (heat pump) and
|
||||
# e3oncan (E380 energy meter). The can0 interface itself is the socketcan
|
||||
# role.
|
||||
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
|
||||
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.
|
||||
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
|
||||
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
|
||||
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
|
||||
# codec inherited through the vx3plus overlay.
|
||||
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
|
||||
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
|
||||
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
|
||||
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
|
||||
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
|
||||
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
|
||||
# entries when added), so the multi-frame read is cheap at a 30 s poll.
|
||||
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
|
||||
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
|
||||
edge_open3e_datapoints: >-
|
||||
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,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.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
|
||||
# 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" }
|
||||
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
|
||||
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
|
||||
vx3: { tx: "0x6a1", dpList: "/opt/edge-host/open3e/vx3plus.py" }
|
||||
vcmu: { tx: "0x68c" }
|
||||
|
||||
# --- E3onCAN: passive E380 energy-meter listener (no bus writes, so it
|
||||
# coexists with the single open3e instance). The meter broadcasts at
|
||||
# ~5 Hz; the gap throttles MQTT to one update per DID per interval.
|
||||
edge_e3oncan_repo: https://github.com/MyHomeMyData/E3onCAN.git
|
||||
edge_e3oncan_version: 461dcbdf97e8d72d2edb9929c63b39382af6c3a1 # V0.5.2
|
||||
edge_e3oncan_gap_seconds: 10
|
||||
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
|
||||
# Secrets come from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_open3e_mqtt_password
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host). Datapoint overlay for the VX3:
|
||||
# Managed by ansible (roles/heating-can). Datapoint overlay for the VX3:
|
||||
# the stock vx3 list acts as a FILTER (DIDs missing from it are popped
|
||||
# from the master table, Open3Eclass.py), so the per-battery-module DIDs
|
||||
# were never pollable with dpList "vx3". This overlay re-adds them with
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Restart open3e
|
||||
ansible.builtin.systemd:
|
||||
name: open3e
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart e3oncan
|
||||
ansible.builtin.systemd:
|
||||
name: e3oncan
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
# Heating system over the Viessmann E3 CAN bus.
|
||||
#
|
||||
# Two readers of the same heating installation:
|
||||
# open3e UDS request/response to the Vitocal heat pump and Vitocharge VX3
|
||||
# e3oncan passive CANopen listener for the E380 grid energy meter, which
|
||||
# feeds the heat pump s own energy management
|
||||
#
|
||||
# They coexist because e3oncan issues no bus writes. Both are native installs
|
||||
# (venv + systemd) rather than containers: they need a SocketCAN interface,
|
||||
# which is host network state. CAN_RAW sockets need no capabilities, so they
|
||||
# run as unprivileged system users.
|
||||
#
|
||||
# The can0 interface itself belongs to the socketcan role -- an interface is
|
||||
# not the same concern as the services that consume it -- so this role must
|
||||
# run after it.
|
||||
|
||||
- name: Install build/runtime packages for the CAN services
|
||||
ansible.builtin.package:
|
||||
name:
|
||||
- python
|
||||
- python-packaging
|
||||
- git
|
||||
state: present
|
||||
|
||||
- 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 vx3 datapoint overlay (battery-module DIDs)
|
||||
ansible.builtin.copy:
|
||||
src: open3e-vx3plus.py
|
||||
dest: "{{ edge_base_dir }}/open3e/vx3plus.py"
|
||||
mode: "0644"
|
||||
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
|
||||
|
||||
|
||||
# --- E3onCAN: direct E380 meter reads (passive; per-review decision not
|
||||
# to trust the VX3's relayed meter values). Same broker user as open3e;
|
||||
# topics land under the same open3e/ prefix as e380_<did>_<name>.
|
||||
- name: Check out E3onCAN (pinned)
|
||||
ansible.builtin.git:
|
||||
repo: "{{ edge_e3oncan_repo }}"
|
||||
dest: "{{ edge_base_dir }}/e3oncan/src"
|
||||
version: "{{ edge_e3oncan_version }}"
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Create E3onCAN venv with requirements
|
||||
ansible.builtin.pip:
|
||||
requirements: "{{ edge_base_dir }}/e3oncan/src/requirements.txt"
|
||||
virtualenv: "{{ edge_base_dir }}/e3oncan/venv"
|
||||
virtualenv_command: python -m venv
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Deploy e3oncan systemd unit
|
||||
ansible.builtin.template:
|
||||
src: e3oncan.service.j2
|
||||
dest: /etc/systemd/system/e3oncan.service
|
||||
mode: "0644"
|
||||
notify: Restart e3oncan
|
||||
|
||||
- name: Enable e3oncan
|
||||
ansible.builtin.systemd:
|
||||
name: e3oncan
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host).
|
||||
# Managed by ansible (roles/heating-can).
|
||||
# PASSIVE listener for the E380 energy meter's CANopen broadcasts — no
|
||||
# writes on the bus, so it may run alongside the (single) open3e
|
||||
# instance. Reads the meter directly instead of trusting the VX3's
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host). Read by open3e.service and
|
||||
# Managed by ansible (roles/heating-can). Read by open3e.service and
|
||||
# e3oncan.service.
|
||||
# Full user:password pair as one variable so the open3e unit can pass it
|
||||
# as a single -muser argument without exposing it in the unit file;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host).
|
||||
# Managed by ansible (roles/heating-can).
|
||||
# Read-mostly listener — no -w flag, no service 0x77. The ONE sanctioned
|
||||
# write path is the MQTT command topic (-l): HA publishes the DHW
|
||||
# one-time-charge command there (scope decision 2026-07-02, see vican
|
||||
@@ -94,7 +94,7 @@
|
||||
- ansible_facts['os_family'] == 'Debian'
|
||||
|
||||
# On Arch (edge-1) the static assignment lives in a systemd-networkd file owned
|
||||
# by the edge-host role, so this role must not rewrite it -- but it must not
|
||||
# by the edge-vlan role, so this role must not rewrite it -- but it must not
|
||||
# quietly proceed either. install-edge-host.yml is a separate play with no
|
||||
# ordering relationship to this one, so nothing guarantees it has been re-run
|
||||
# to drop the [Address] block. If it has not, edge-1 would end up holding
|
||||
@@ -140,9 +140,9 @@
|
||||
Handing the address to keepalived while networkd also assigns it means
|
||||
it would NOT be withdrawn when check_ntp fails -- the host would keep
|
||||
serving time on an address VRRP believes it has released.
|
||||
Re-apply the edge-host role first, whose template no longer carries the
|
||||
[Address] block: ansible-playbook -i inventory/ plays/install-edge-host.yml
|
||||
--limit {{ inventory_hostname }} --tags vlan40
|
||||
Re-apply the edge-vlan role first, whose template no longer carries the
|
||||
[Address] block:
|
||||
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
|
||||
success_msg: >-
|
||||
No static networkd assignment of {{ keepalived_ntp_vip6 }}; safe for
|
||||
keepalived to own it.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# PVE nodes an `up ip -6 addr replace ... nodad` hook in
|
||||
# /etc/network/interfaces
|
||||
# edge-1 an [Address] block in /etc/systemd/network/, owned by the
|
||||
# edge-host role and removed by re-applying it -- not touched
|
||||
# edge-vlan role and removed by re-applying it -- not touched
|
||||
# here, since this role must not rewrite another role's file
|
||||
#
|
||||
# There is a service gap between this play and the next, while no host holds
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
|
||||
# mosquitto on loopback, which bridges to the central cluster broker over TLS
|
||||
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
|
||||
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
|
||||
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
|
||||
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
|
||||
edge_mqtt_host: 127.0.0.1
|
||||
edge_mqtt_port: 1883
|
||||
# Upstream cluster broker the local bridge connects to (TLS; the public
|
||||
# Let's Encrypt cert is validated against the system CA bundle).
|
||||
edge_mqtt_cluster_host: mqtt.renner.uno
|
||||
edge_mqtt_cluster_tls_port: 8883
|
||||
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
# The MQTT clients (zwave-js, sml2mqtt, open3e, e3oncan) reconnect on their
|
||||
# own once the local broker is back.
|
||||
- name: Restart mosquitto
|
||||
ansible.builtin.service:
|
||||
name: mosquitto
|
||||
state: restarted
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
# Local MQTT bridge broker.
|
||||
#
|
||||
# Edge clients (open3e, e3oncan, sml2mqtt) publish here over loopback; this
|
||||
# broker bridges to the cluster broker over TLS, because those clients have no
|
||||
# native MQTTS. Persistence is off and logging goes to stdout so it writes
|
||||
# nothing to local flash.
|
||||
|
||||
- name: Install mosquitto
|
||||
ansible.builtin.package:
|
||||
name: mosquitto
|
||||
state: present
|
||||
|
||||
- name: Deploy local mosquitto bridge config
|
||||
ansible.builtin.template:
|
||||
src: edge-mqtt-bridge.conf.j2
|
||||
dest: /etc/mosquitto/mosquitto.conf
|
||||
owner: root
|
||||
group: mosquitto
|
||||
mode: "0640" # carries the cluster bridge credentials
|
||||
notify: Restart mosquitto
|
||||
|
||||
- name: Enable local mosquitto bridge
|
||||
ansible.builtin.systemd:
|
||||
name: mosquitto
|
||||
enabled: true
|
||||
state: started
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by roles/edge-host. Local mosquitto that the edge clients (open3e,
|
||||
# Managed by roles/mqtt-bridge. Local mosquitto that the edge clients (open3e,
|
||||
# e3oncan, sml2mqtt) publish to over loopback; it bridges to the central
|
||||
# cluster broker over TLS. open3e/e3oncan have no native MQTTS, so this is how
|
||||
# edge-1's MQTT gets off plaintext on the untrusted IoT VLAN, and it buffers
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
|
||||
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
|
||||
# Own compose project directory. Unchanged from the path used under the
|
||||
# previous shared project.
|
||||
sml_base_dir: "{{ edge_base_dir }}/sml2mqtt"
|
||||
|
||||
# The compose file that used to hold both this service and zwave-js. Removed
|
||||
# once, so the fixed container_name is released for the new projects.
|
||||
edge_legacy_compose: "{{ edge_base_dir }}/docker-compose.yml"
|
||||
|
||||
# Stable by-id symlink -- see the zwave-gateway role.
|
||||
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
|
||||
|
||||
# Hand-pinned on purpose: critical meter bridge, deliberately excluded from
|
||||
# image automation.
|
||||
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
|
||||
|
||||
# Secret from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_sml2mqtt_mqtt_password
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart sml2mqtt
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
changed_when: true
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
|
||||
#
|
||||
# Its own compose project. It shared a compose file with the Z-Wave gateway,
|
||||
# which meant a change to either recreated both -- two unrelated services bound
|
||||
# to different USB devices. Data paths are unchanged.
|
||||
|
||||
- name: Create sml2mqtt directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ sml_base_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
# 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 replaced.
|
||||
- name: Deploy sml2mqtt config
|
||||
ansible.builtin.template:
|
||||
src: sml2mqtt-config.yml.j2
|
||||
dest: "{{ sml_base_dir }}/config.yml"
|
||||
mode: "0600"
|
||||
notify: Restart sml2mqtt
|
||||
|
||||
- name: Deploy sml2mqtt compose project
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ sml_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart sml2mqtt
|
||||
|
||||
- name: Pull sml2mqtt image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
# Tolerates a missing USB device, as for the Z-Wave gateway.
|
||||
- name: Start sml2mqtt
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ sml_base_dir }}"
|
||||
register: sml_compose_up
|
||||
failed_when: >-
|
||||
sml_compose_up.rc != 0
|
||||
and 'error gathering device information' not in sml_compose_up.stderr
|
||||
changed_when: >-
|
||||
'Started' in sml_compose_up.stderr or 'Created' in sml_compose_up.stderr
|
||||
or 'Started' in sml_compose_up.stdout or 'Creating' in sml_compose_up.stdout
|
||||
|
||||
- name: Warn if sml2mqtt is waiting for its USB device
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
sml2mqtt could not start -- the FTDI meter head is not present:
|
||||
{{ sml_compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: sml_compose_up.rc != 0
|
||||
@@ -0,0 +1,21 @@
|
||||
# Managed by ansible (roles/power-meter-sml).
|
||||
#
|
||||
# Its own compose project -- see the role tasks for why it no longer shares a
|
||||
# file with the Z-Wave gateway.
|
||||
#
|
||||
# Outbound-only (serial in, MQTT out). Host networking sidesteps the docker
|
||||
# NAT66 gap: the broker LB is IPv6-first and bridge-network v6 SYNs leave
|
||||
# unmasqueraded and die. The host stack reaches it fine (open3e proves it
|
||||
# continuously).
|
||||
services:
|
||||
sml2mqtt:
|
||||
image: {{ edge_sml2mqtt_image }}
|
||||
container_name: sml2mqtt
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# Serial exclusivity: this is the ONLY reader of the meter port --
|
||||
# HA's edl21 integration stays disabled.
|
||||
devices:
|
||||
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
|
||||
volumes:
|
||||
- ./config.yml:/sml2mqtt/config.yml:ro
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host). Ported 1:1 from the k8s ConfigMap
|
||||
# Managed by ansible (roles/power-meter-sml). 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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
# SocketCAN interface. Consumed by heating-can.
|
||||
# --- CAN / open3e (Viessmann E3 external bus) ---
|
||||
edge_can_interface: can0
|
||||
edge_can_bitrate: 250000
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
# 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
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
# SocketCAN interface, owned by systemd-networkd.
|
||||
#
|
||||
# gs_usb is an in-tree module and autoloads when the adapter enumerates;
|
||||
# networkd then matches the interface and applies bitrate + up. Separate from
|
||||
# the services that consume the bus (heating-can, e380-can-meter) because the
|
||||
# interface is host network state shared by both.
|
||||
|
||||
- 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
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host).
|
||||
# Managed by ansible (roles/socketcan).
|
||||
# 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]
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
# Z-Wave gateway: zwave-js-ui against the ZWA-2 USB radio.
|
||||
|
||||
# Service root. Kept per-role so the role is usable on its own.
|
||||
edge_base_dir: /opt/edge-host
|
||||
edge_tz: Europe/Berlin
|
||||
|
||||
# Own compose project directory. Unchanged from the path used under the
|
||||
# previous shared project, so the store is not migrated.
|
||||
zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
|
||||
|
||||
# Stable by-id symlink: the name travels with the device, not the host, so it
|
||||
# survives moving the stick between machines.
|
||||
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
|
||||
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
|
||||
|
||||
# Alpine version the zwave-js-ui image is built on -- the source for the musl
|
||||
# libeatmydata.so preloaded into that container. Keep in sync if the image
|
||||
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
|
||||
edge_zwave_alpine_base: alpine:3.22
|
||||
|
||||
# Secret from inventory/group_vars/edge_host/vault.yml:
|
||||
# edge_zwavejs_session_secret
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- name: Restart zwave-js
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ zwave_base_dir }}"
|
||||
changed_when: true
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
# Z-Wave gateway: zwave-js-ui against the ZWA-2 USB radio.
|
||||
#
|
||||
# Its own compose project, in its own directory, so the Z-Wave gateway and the
|
||||
# power-meter reader are not coupled through a shared compose file. Data paths
|
||||
# are unchanged -- the store stays at {{ zwave_base_dir }}/store -- so this is a
|
||||
# project boundary change, not a data migration.
|
||||
|
||||
- name: Create zwave-js directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ zwave_base_dir }}"
|
||||
- "{{ zwave_base_dir }}/store"
|
||||
|
||||
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
|
||||
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
|
||||
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
|
||||
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
|
||||
creates: "{{ zwave_base_dir }}/libeatmydata.so"
|
||||
notify: Restart zwave-js
|
||||
|
||||
# Because fsync is neutralized, a hard power-loss during the (rare) settings
|
||||
# write could truncate settings.json — which holds the Z-Wave network keys.
|
||||
# Keep a validated rolling backup on the eMMC so the keys stay recoverable.
|
||||
- name: Install zwave-js settings.json backup script
|
||||
ansible.builtin.template:
|
||||
src: edge-zwave-settings-backup.j2
|
||||
dest: /usr/local/bin/edge-zwave-settings-backup
|
||||
mode: "0755"
|
||||
|
||||
- name: Install zwave-js settings backup service + timer
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item }}"
|
||||
dest: "/etc/systemd/system/{{ item }}"
|
||||
mode: "0644"
|
||||
loop:
|
||||
- edge-zwave-settings-backup.service
|
||||
- edge-zwave-settings-backup.timer
|
||||
|
||||
- name: Enable zwave-js settings backup timer
|
||||
ansible.builtin.systemd:
|
||||
name: edge-zwave-settings-backup.timer
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Deploy zwave-js env (session secret)
|
||||
ansible.builtin.template:
|
||||
src: zwavejs.env.j2
|
||||
dest: "{{ zwave_base_dir }}/zwavejs.env"
|
||||
mode: "0600"
|
||||
notify: Restart zwave-js
|
||||
|
||||
- name: Deploy zwave-js compose project
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ zwave_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart zwave-js
|
||||
|
||||
- name: Pull zwave-js image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ zwave_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
# Tolerates a missing USB device: if the ZWA-2 is absent the container cannot
|
||||
# start, and that must not fail the run for everything else on the host.
|
||||
- name: Start zwave-js
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ zwave_base_dir }}"
|
||||
register: zwave_compose_up
|
||||
failed_when: >-
|
||||
zwave_compose_up.rc != 0
|
||||
and 'error gathering device information' not in zwave_compose_up.stderr
|
||||
changed_when: >-
|
||||
'Started' in zwave_compose_up.stderr or 'Created' in zwave_compose_up.stderr
|
||||
or 'Started' in zwave_compose_up.stdout or 'Creating' in zwave_compose_up.stdout
|
||||
|
||||
- name: Warn if zwave-js is waiting for its USB device
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
zwave-js could not start -- the ZWA-2 radio is not present:
|
||||
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: zwave_compose_up.rc != 0
|
||||
@@ -0,0 +1,39 @@
|
||||
# Managed by ansible (roles/zwave-gateway).
|
||||
#
|
||||
# Its own compose project. zwave-js and the power-meter reader used to share
|
||||
# one file under {{ edge_base_dir | default('/opt/edge-host') }}; they are
|
||||
# unrelated services bound to different USB devices, so a change to one had no
|
||||
# business recreating the other.
|
||||
#
|
||||
# No `privileged` needed: docker's --device handles the device-cgroup grant
|
||||
# that hostPath in k8s could not (that was the only reason the pod ran
|
||||
# privileged).
|
||||
#
|
||||
# Host networking: real host addresses (v4+v6), no bridge, no NAT. The broker
|
||||
# LB is IPv6-first and docker bridge NAT66 is a tarpit (unmasqueraded SYNs die
|
||||
# silently); the host stack just works.
|
||||
services:
|
||||
zwave-js:
|
||||
image: {{ edge_zwavejs_image }}
|
||||
container_name: zwave-js
|
||||
restart: unless-stopped
|
||||
stop_signal: SIGINT
|
||||
tty: true
|
||||
environment:
|
||||
- TZ={{ edge_tz }}
|
||||
# Batch the value-DB fsync storm to spare the eMMC (see the edge-flash-wear
|
||||
# role). Neutralizes fsync only for this container; the WS/MQTT value
|
||||
# stream to HA and cache persistence are unchanged.
|
||||
- LD_PRELOAD=/usr/lib/libeatmydata.so
|
||||
env_file:
|
||||
- zwavejs.env
|
||||
devices:
|
||||
- "{{ edge_zwave_device }}:/dev/zwave"
|
||||
volumes:
|
||||
# musl libeatmydata.so extracted from the image's Alpine base (ro)
|
||||
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
|
||||
# Persistent store: settings.json, device DB, network keys. Path is
|
||||
# unchanged from the previous shared project, so nothing is migrated.
|
||||
- ./store:/usr/src/app/store
|
||||
# Binds 3000 (Z-Wave JS websocket for HA) and 8091 (web UI) on the host.
|
||||
network_mode: host
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rolling backup of zwave-js settings.json (holds the Z-Wave network keys).
|
||||
# Managed by roles/edge-host. edge-1 runs zwave-js under libeatmydata (fsync
|
||||
# Managed by roles/zwave-gateway. edge-1 runs zwave-js under libeatmydata (fsync
|
||||
# neutralized to spare the eMMC), so a hard power-loss during the rare
|
||||
# settings write could truncate settings.json. Keep a validated backup on the
|
||||
# eMMC so the keys are always recoverable. Runs hourly via a systemd timer.
|
||||
@@ -1,7 +1,12 @@
|
||||
# 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`).
|
||||
Bootable Arch installer image for the bare-metal edge hosts — the machines
|
||||
that replaced the k8s-edge VM, because Talos cannot do CAN or USB
|
||||
passthrough. See `ansible/docs/edge-host.md`.
|
||||
|
||||
Not tied to one box or one model: the installer autodetects the target disk
|
||||
(NVMe, else eMMC, else the first non-USB disk), which covers both the Wyse
|
||||
3040 thin clients and larger hardware.
|
||||
|
||||
The image is the official archiso **releng** profile plus:
|
||||
- `edge-install` in the live environment — partitions the NVMe
|
||||
@@ -12,7 +17,7 @@ The image is the official archiso **releng** profile plus:
|
||||
|
||||
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
|
||||
`ansible/docs/edge-host.md` as the single source of truth. Rebuilding the
|
||||
box is: boot ISO → `edge-install` → run the play.
|
||||
|
||||
## Build
|
||||
@@ -38,10 +43,16 @@ archiso updates flow in automatically.
|
||||
(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:
|
||||
4. Reboot. Confirm the box is reachable by name — `ssh root@<host>` — before
|
||||
going further; it is identified by name everywhere and its address is
|
||||
never recorded. Then add it to `[edge_host]` in
|
||||
`ansible/inventory/pve_hosts.yml` and, 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`.
|
||||
One run over the whole group, no `--limit`: the play carries
|
||||
`certificates` and `common` itself, so there is no separate `common.yml`
|
||||
step. Running it converges every edge host, which is the point — a run
|
||||
that touches only the new box tells you nothing about the others.
|
||||
5. Continue with the cutover checklist in `ansible/docs/edge-host.md`.
|
||||
|
||||
@@ -50,7 +50,7 @@ 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).
|
||||
# is applied over SSH after first boot (see ansible/docs/edge-host.md).
|
||||
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \
|
||||
docker docker-compose
|
||||
|
||||
@@ -99,7 +99,13 @@ EOF
|
||||
|
||||
umount -R /mnt
|
||||
echo
|
||||
echo "${HOST} installed. Remove the USB stick, reboot, then from the"
|
||||
echo "workstation: cd ansible &&"
|
||||
echo " ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host"
|
||||
echo " ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml"
|
||||
echo "${HOST} installed. Remove the USB stick and reboot."
|
||||
echo
|
||||
echo "Then, from the workstation:"
|
||||
echo " 1. confirm 'ssh root@${HOST}' works (it must be reachable by name)"
|
||||
echo " 2. add ${HOST} to [edge_host] in ansible/inventory/pve_hosts.yml"
|
||||
echo " 3. cd ansible && ansible-playbook -i inventory/pve_hosts.yml \\"
|
||||
echo " plays/install-edge-host.yml"
|
||||
echo
|
||||
echo "One run, whole group, no --limit: the play carries certificates and"
|
||||
echo "common itself, so there is no separate common.yml step."
|
||||
|
||||
@@ -25,7 +25,7 @@ ACCESS_STAGING="$REPO_ROOT/kubernetes/access-staging"
|
||||
NODES=(k8s-1 k8s-2 k8s-3)
|
||||
# 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).
|
||||
# (ansible/docs/edge-host.md).
|
||||
WORKERS=()
|
||||
ALL_NODES=("${NODES[@]}" "${WORKERS[@]}")
|
||||
DOMAIN="srv.renner.uno"
|
||||
|
||||
Reference in New Issue
Block a user