ansible: edge-host role — bare-metal Arch N100 replaces k8s-edge VM #95

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