Prerequisite for enabling keepalived on edge-1: VRRP needs layer-2 adjacency
with the other NTP members on the service VLAN, and the host has only untagged
VLAN 30. The switch port already permits VLAN 40.
Built to be additive and revertible, because enp1s0 is this headless host's
only path to the network:
- The parent's 20-wired.network is not edited. The VLAN is attached through
a 20-wired.network.d/ drop-in instead, so reverting is deleting three paths.
- The VLAN .network sorts before 20-wired.network and matches explicitly on
Name=. 20-wired.network matches broadly on Type=ether; a VLAN link types as
'vlan' rather than 'ether' (confirmed against networkctl output) so it
should not match anyway, but ordering makes that belt-and-braces.
- UseRoutes, UseDNS, UseNTP and IPv6AcceptRA are all disabled on the VLAN.
Taking a second default route and DNS from the VLAN 40 lease would make
return traffic asymmetric across the two VLANs; untagged VLAN 30 stays the
only default route.
Tasks are tagged vlan40 so they can be applied without running the whole
edge-host role, which would restart the Z-Wave, open3e and alloy services.
Reconfiguring the parent link is what materialises the VLAN child -- a bare
networkctl reload leaves existing links untouched -- so the handler does that
explicitly. That re-runs DHCP on enp1s0 and is the one genuinely risky step;
worth running with console access to hand.
Not yet applied: the apply was blocked pending authorisation, so edge-1 remains
in its original network state with only 20-wired.network and 80-can0.network.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
441 lines
15 KiB
YAML
441 lines
15 KiB
YAML
---
|
|
# 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
|