fix(edge-host): make the bootstrap self-contained and the runbook followable #474

Merged
k.renner merged 5 commits from edge-bootstrap-selfcontained into master 2026-08-08 10:39:48 +00:00
Member

Prompted by preparing a second Wyse 3040 as an NTP peer — this is the procedure that would have been followed to build it, and it has three defects. Two are standing house rules; one simply does not work.

1. It required a DHCP reservation

"Add the DHCP reservation/DNS entry for edge-1.srv.renner.uno on the UDM"

A reservation, as a documented precondition. The UDM registers DHCP leases in DNS on its own, so the record appears without one — and the host is identified by name everywhere (inventory, plays, README), with its address never recorded.

2. It required --limit, because the plays were split wrong

ansible-playbook plays/common.yml -l edge_host
ansible-playbook plays/install-edge-host.yml

The --limit was load-bearing, not sloppiness: common.yml is hosts: all:!…, so an unrestricted run while bootstrapping one new box would reconfigure the entire fleet. The split left no other option.

But --limit narrows the target list rather than staging anything, and leaves every other host outside convergence with no report that they were skipped.

Fixed structurally rather than by deleting the flag. install-edge-host.yml now carries certificates and common itself — the parts of common.yml that apply to an edge host — so the bootstrap is one unrestricted run over the edge_host group. No second command, no limit.

Not carried over:

  • qemu-guest-agent — these are bare-metal thin clients, not VMs
  • monitoring-agent — Debian-oriented; edge hosts ship logs via edge-alloy instead

common.yml's loki_endpoint / prometheus_remote_write_endpoint vars belong to monitoring-agent, so they aren't needed — verified the common role doesn't reference them.

3. It named a host that does not exist

The README said edge-1.srv.renner.uno, in both the base-install steps and the Z-Wave cutover checklist. The host is edge-1.iot.renner.uno, and .srv does not resolve — so anyone following the runbook hits a dead name at step 3.

Verification

Idempotence against the live host, before committing — whole edge_host group, no limit:

ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml --check --diff
  -> edge-1.iot.renner.uno : ok=53 changed=5 unreachable=0 failed=0

All five changes are inside the edge-host role (open3e venv, E3onCAN checkout/venv) — already in this play before the change, and pre-existing check-mode behaviour of git/pip installs. certificates and common contributed no changes at all.

Also confirmed the common role branches only on Debian/RedHat os_family, so its OS-specific tasks skip cleanly on Arch.

Follow-up, not in this PR

The second Wyse should not simply join [edge_host] — it would inherit edge-1's USB device paths ({{ edge_zwave_device }}:/dev/zwave et al.) and try to start containers for hardware it does not have. It needs its own group feeding ntp_cluster:children, plus an entry in the ntp_servers priority map. That change is deferred until the box exists and has a name.

🤖 Generated with Claude Code

Prompted by preparing a second Wyse 3040 as an NTP peer — this is the procedure that would have been followed to build it, and it has three defects. Two are standing house rules; one simply does not work. ## 1. It required a DHCP reservation > *"Add the DHCP reservation/DNS entry for `edge-1.srv.renner.uno` on the UDM"* A reservation, as a documented precondition. The UDM registers DHCP leases in DNS on its own, so the record appears without one — and the host is identified by name everywhere (inventory, plays, README), with its address never recorded. ## 2. It required `--limit`, because the plays were split wrong ``` ansible-playbook plays/common.yml -l edge_host ansible-playbook plays/install-edge-host.yml ``` The `--limit` was **load-bearing, not sloppiness**: `common.yml` is `hosts: all:!…`, so an unrestricted run while bootstrapping one new box would reconfigure the entire fleet. The split left no other option. But `--limit` narrows the target list rather than staging anything, and leaves every other host outside convergence with no report that they were skipped. **Fixed structurally rather than by deleting the flag.** `install-edge-host.yml` now carries `certificates` and `common` itself — the parts of `common.yml` that apply to an edge host — so the bootstrap is one unrestricted run over the `edge_host` group. No second command, no limit. Not carried over: - `qemu-guest-agent` — these are bare-metal thin clients, not VMs - `monitoring-agent` — Debian-oriented; edge hosts ship logs via `edge-alloy` instead `common.yml`'s `loki_endpoint` / `prometheus_remote_write_endpoint` vars belong to `monitoring-agent`, so they aren't needed — verified the `common` role doesn't reference them. ## 3. It named a host that does not exist The README said `edge-1.srv.renner.uno`, in both the base-install steps and the Z-Wave cutover checklist. The host is `edge-1.iot.renner.uno`, and `.srv` does not resolve — so anyone following the runbook hits a dead name at step 3. ## Verification Idempotence against the live host, before committing — whole `edge_host` group, no limit: ``` ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml --check --diff -> edge-1.iot.renner.uno : ok=53 changed=5 unreachable=0 failed=0 ``` All five changes are inside the `edge-host` role (open3e venv, E3onCAN checkout/venv) — already in this play before the change, and pre-existing check-mode behaviour of git/pip installs. **`certificates` and `common` contributed no changes at all.** Also confirmed the `common` role branches only on `Debian`/`RedHat` `os_family`, so its OS-specific tasks skip cleanly on Arch. ## Follow-up, not in this PR The second Wyse should **not** simply join `[edge_host]` — it would inherit edge-1's USB device paths (`{{ edge_zwave_device }}:/dev/zwave` et al.) and try to start containers for hardware it does not have. It needs its own group feeding `ntp_cluster:children`, plus an entry in the `ntp_servers` priority map. That change is deferred until the box exists and has a name. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-08-08 06:07:46 +00:00
fix(edge-host): make the bootstrap self-contained and the runbook followable
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
e288ebe39d
The documented base-install procedure had three defects, two of which are
standing house rules and one of which simply does not work.

1. It required a DHCP reservation.

   "Add the DHCP reservation/DNS entry for <host> on the UDM" was step 3 --
   a reservation as a documented precondition. Reservations are off the
   table here. The UDM registers DHCP leases in DNS on its own, so the
   record appears without one, and the host is identified by name
   everywhere: inventory, plays, README. Its address is never recorded.

2. It required --limit, because the plays were split wrong.

   Step 4 was:

     ansible-playbook plays/common.yml -l edge_host
     ansible-playbook plays/install-edge-host.yml

   The --limit was load-bearing, not sloppiness: common.yml is
   `hosts: all:!...`, so running it unrestricted while bootstrapping a
   single new box would reconfigure the entire fleet. The split left no
   other option. But --limit narrows the target list rather than staging
   anything, and leaves every other host outside convergence with no report
   that they were skipped.

   Fixed structurally rather than by deleting the flag: install-edge-host.yml
   now carries `certificates` and `common` itself -- the parts of common.yml
   that apply to an edge host -- so the bootstrap is one unrestricted run
   over the edge_host group. No second command, no limit.

   Not carried over: qemu-guest-agent (these are bare metal, not VMs) and
   monitoring-agent (Debian-oriented; edge hosts ship logs via edge-alloy).
   common.yml's loki/prometheus vars go with monitoring-agent and are not
   needed -- verified that the common role does not reference them.

3. It named a host that does not exist.

   The README said `edge-1.srv.renner.uno` in both the base-install steps
   and the Z-Wave cutover checklist. The host is `edge-1.iot.renner.uno`;
   the .srv name does not resolve, so anyone following the runbook hits a
   dead name at step 3.

Verified idempotent against the live host before committing:
`ansible-playbook plays/install-edge-host.yml --check --diff` over the whole
edge_host group -> ok=53 changed=5 failed=0, with all five changes inside
the edge-host role (open3e venv, E3onCAN checkout/venv), which was already
in this play. certificates and common contributed no changes at all.

Also checked the common role branches only on Debian/RedHat os_family, so
its OS-specific tasks skip cleanly on Arch.

Prompted by preparing a second Wyse 3040 as an NTP peer -- this is the
procedure that would have been followed to build it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit e288ebe39d1f3e991033b81cac5d11016fd28346.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR folds the certificates and common roles into install-edge-host.yml so bootstrapping an edge host is one unrestricted play instead of a common.yml -l edge_host + install-edge-host.yml pair, and fixes a stale edge-1.srv.renner.uno hostname (which doesn't resolve — the real host is edge-1.iot.renner.uno) plus a bogus DHCP-reservation precondition in ansible/roles/edge-host/README.md. The ansible change itself checks out — role composition, exclusion of qemu-guest-agent/monitoring-agent, and the unused loki_endpoint/prometheus_remote_write_endpoint vars all match what's actually in roles/common/tasks/main.yml, and the PR body documents a live --check --diff run confirming certificates/common produce zero changes. However, the same stale hostname and the same superseded two-command bootstrap sequence this PR sets out to fix still exist in two other files that are part of the same procedure and weren't touched.

🔴 Blocking

  • images/edge-host-iso/airootfs/usr/local/bin/edge-install:102-105 — the installer script (baked into the ISO, run on the box, its own output is what the operator reads at the console immediately after imaging) still prints the old, now-superseded two-command sequence:

    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
    

    This is exactly the pattern the PR's own description calls out as broken (--limit narrows the target list and silently skips the rest of the fleet). Since install-edge-host.yml now carries certificates/common itself, the first line here is redundant, and following it reintroduces the exact problem being fixed — a reader has no way to know the printed instructions are stale.

  • images/edge-host-iso/README.md:3 — still names the host edge-1.srv.renner.uno, the same non-resolving hostname the PR fixes everywhere else (ansible/roles/edge-host/README.md). This file is the target of the link in the updated README's own step 2 ("Build and boot the dedicated installer image — see images/edge-host-iso/README.md"), so anyone following the full procedure end-to-end lands right back on the defect this PR claims to have closed.

  • images/edge-host-iso/README.md:41-46 — same stale two-command bootstrap (plays/common.yml -l edge_host followed by plays/install-edge-host.yml) as in edge-install above, for the same reason.

Given the PR's stated goal is specifically "make the bootstrap self-contained and the runbook followable," these three spots leave the actual end-to-end procedure (ISO build → install → bootstrap) inconsistent with the fixed ansible/roles/edge-host/README.md, and reintroduce both defects (#2 and #3 from the PR description) through a linked/printed document that wasn't updated.

🟡 Suggestions

  • ansible/plays/install-edge-host.yml:30 — become: true is new; previously the play ran without it (no become anywhere in docker-install/edge-host tasks). inventory/pve_hosts.yml:45 connects as ansible_user=root, so become: true sudos from root to root — consistent with the pattern already used in plays/common.yml, and the PR body's --check --diff run reports ok=53 changed=5 failed=0, so it evidently works against the live host. Worth a one-line comment noting why it's needed even though the connection is already root (e.g. if any future task narrows privileges), since it's non-obvious from the diff alone why a root-connection play needs become.
  • ansible/plays/install-edge-host.yml:28-35 — the merged play omits ignore_unreachable: true, which plays/common.yml's base-hardening play sets. Harmless today since [edge_host] has exactly one member, but the PR's own follow-up section anticipates a second edge host soon; if that box (or edge-1) is briefly unreachable during a routine convergence run, plain Ansible behavior already skips just that host and continues for others, so this is low-value — flagging only because the field was intentionally present in the play this replaces.

🟢 Nits

None beyond the above.

Tests

This is an Ansible/documentation change with no test suite in the repo for playbooks; the PR substitutes a --check --diff run against the live edge-1.iot.renner.uno host, which is the right verification method here and is reported in the PR body (ok=53 changed=5 unreachable=0 failed=0, changes confined to the pre-existing edge-host role tasks). That's adequate for the ansible change. It does not, and can't, catch the stale references left in the ISO-side files flagged above — those need a manual grep sweep (which the PR partially did — edge-1.srv.renner.uno and "DHCP reservation" are gone from ansible/, just not from images/edge-host-iso/).

Recommendation

Request changes — 3 blocking issue(s) (stale hostname and superseded two-command bootstrap left in images/edge-host-iso/README.md and images/edge-host-iso/airootfs/usr/local/bin/edge-install).

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `e288ebe39d1f3e991033b81cac5d11016fd28346`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR folds the `certificates` and `common` roles into `install-edge-host.yml` so bootstrapping an edge host is one unrestricted play instead of a `common.yml -l edge_host` + `install-edge-host.yml` pair, and fixes a stale `edge-1.srv.renner.uno` hostname (which doesn't resolve — the real host is `edge-1.iot.renner.uno`) plus a bogus DHCP-reservation precondition in `ansible/roles/edge-host/README.md`. The ansible change itself checks out — role composition, exclusion of `qemu-guest-agent`/`monitoring-agent`, and the unused `loki_endpoint`/`prometheus_remote_write_endpoint` vars all match what's actually in `roles/common/tasks/main.yml`, and the PR body documents a live `--check --diff` run confirming `certificates`/`common` produce zero changes. However, the same stale hostname and the same superseded two-command bootstrap sequence this PR sets out to fix still exist in two other files that are part of the same procedure and weren't touched. ## 🔴 Blocking - **`images/edge-host-iso/airootfs/usr/local/bin/edge-install:102-105`** — the installer script (baked into the ISO, run on the box, its own output is what the operator reads at the console immediately after imaging) still prints the old, now-superseded two-command sequence: ``` 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 ``` This is exactly the pattern the PR's own description calls out as broken (`--limit` narrows the target list and silently skips the rest of the fleet). Since `install-edge-host.yml` now carries `certificates`/`common` itself, the first line here is redundant, and following it reintroduces the exact problem being fixed — a reader has no way to know the printed instructions are stale. - **`images/edge-host-iso/README.md:3`** — still names the host `edge-1.srv.renner.uno`, the same non-resolving hostname the PR fixes everywhere else (`ansible/roles/edge-host/README.md`). This file is the target of the link in the updated README's own step 2 ("Build and boot the dedicated installer image — see `images/edge-host-iso/README.md`"), so anyone following the full procedure end-to-end lands right back on the defect this PR claims to have closed. - **`images/edge-host-iso/README.md:41-46`** — same stale two-command bootstrap (`plays/common.yml -l edge_host` followed by `plays/install-edge-host.yml`) as in `edge-install` above, for the same reason. Given the PR's stated goal is specifically "make the bootstrap self-contained and the runbook followable," these three spots leave the actual end-to-end procedure (ISO build → install → bootstrap) inconsistent with the fixed `ansible/roles/edge-host/README.md`, and reintroduce both defects (#2 and #3 from the PR description) through a linked/printed document that wasn't updated. ## 🟡 Suggestions - `ansible/plays/install-edge-host.yml:30` — `become: true` is new; previously the play ran without it (no `become` anywhere in `docker-install`/`edge-host` tasks). `inventory/pve_hosts.yml:45` connects as `ansible_user=root`, so `become: true` sudos from root to root — consistent with the pattern already used in `plays/common.yml`, and the PR body's `--check --diff` run reports `ok=53 changed=5 failed=0`, so it evidently works against the live host. Worth a one-line comment noting *why* it's needed even though the connection is already root (e.g. if any future task narrows privileges), since it's non-obvious from the diff alone why a root-connection play needs `become`. - `ansible/plays/install-edge-host.yml:28-35` — the merged play omits `ignore_unreachable: true`, which `plays/common.yml`'s base-hardening play sets. Harmless today since `[edge_host]` has exactly one member, but the PR's own follow-up section anticipates a second edge host soon; if that box (or edge-1) is briefly unreachable during a routine convergence run, plain Ansible behavior already skips just that host and continues for others, so this is low-value — flagging only because the field was intentionally present in the play this replaces. ## 🟢 Nits None beyond the above. ## Tests This is an Ansible/documentation change with no test suite in the repo for playbooks; the PR substitutes a `--check --diff` run against the live `edge-1.iot.renner.uno` host, which is the right verification method here and is reported in the PR body (`ok=53 changed=5 unreachable=0 failed=0`, changes confined to the pre-existing `edge-host` role tasks). That's adequate for the ansible change. It does not, and can't, catch the stale references left in the ISO-side files flagged above — those need a manual grep sweep (which the PR partially did — `edge-1.srv.renner.uno` and "DHCP reservation" are gone from `ansible/`, just not from `images/edge-host-iso/`). ## Recommendation Request changes — 3 blocking issue(s) (stale hostname and superseded two-command bootstrap left in `images/edge-host-iso/README.md` and `images/edge-host-iso/airootfs/usr/local/bin/edge-install`). <!-- pr-review:sha=e288ebe39d1f3e991033b81cac5d11016fd28346 -->
claude-bot added 1 commit 2026-08-08 06:21:37 +00:00
fix(edge-host-iso): carry the bootstrap fixes into the ISO side too
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
07faf0ae69
Review on #474 caught that the previous commit fixed the procedure in
ansible/roles/edge-host/README.md and left the same two defects in the two
places an operator actually reads them first.

That happened because my grep for the stale patterns ran with the working
directory set to ansible/, so `images/edge-host-iso/README.md` was a path
that did not exist. grep returned nothing and I read that as clean. A false
negative from a bad path, not a clean sweep.

Fixed now, verified from the repository root:

  airootfs/usr/local/bin/edge-install
    Printed the superseded two-command sequence at the console immediately
    after imaging -- the very first instructions anyone sees, telling them
    to run common.yml with -l edge_host. Now prints the single unrestricted
    run, plus the two steps that precede it (confirm the host resolves by
    name, add it to [edge_host]).

  README.md
    Described the image as being for `edge-1.srv.renner.uno`, a name that
    does not resolve, and repeated the same two-command bootstrap. Both
    replaced. This file is the link target of step 2 in the ansible-side
    README, so following the procedure end to end landed straight back on
    the defect #474 claims to close.

Also corrected while there: the ISO README called the target "the bare-metal
N100". edge-1 is a Wyse 3040 (Atom x5-Z8350), and the installer text further
down already says it "covers the Wyse 3040". Rewritten to describe the image
as not tied to one box or model, since the installer autodetects the target
disk -- which is what makes it usable for the second thin client.

Also from review: a comment on why `become: true` is set when the connection
is already root. Not taken -- ignore_unreachable, which common.yml sets. The
review's own reasoning applies: Ansible already skips an unreachable host and
continues with the others, so it adds nothing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 07faf0ae69627cecff137287315a16049dc955d9.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR folds certificates and common into plays/install-edge-host.yml so edge-host bootstrap is a single unrestricted run over the edge_host group, and fixes the runbook's dead edge-1.srv.renner.uno references (should be .iot.renner.uno) and the DHCP-reservation precondition across three files (ansible/plays/install-edge-host.yml, ansible/roles/edge-host/README.md, images/edge-host-iso/README.md, images/edge-host-iso/airootfs/usr/local/bin/edge-install). I checked every factual claim against the checkout: common's tasks only branch on Debian/RedHat os_family (ansible/roles/common/tasks/main.yml:27,37) so they no-op on Arch; certificates is an intentionally empty role (ansible/roles/certificates/tasks/main.yml); loki_endpoint/prometheus_remote_write_endpoint are indeed unused by common (only consumed by monitoring-agent, which isn't pulled in); edge_host's inventory entry already uses ansible_user=root (ansible/inventory/pve_hosts.yml:45), making the new become: true a no-op as the added comment states; and the corrected hostname edge-1.iot.renner.uno matches the actual inventory entry. All of this holds up — no blocking issues found.

🟡 Suggestions

  • ansible/plays/install-edge-host.yml:9-27: the new header comment is quite long (19 lines) for what is now a 6-line play. Not wrong, but consider trimming to the load-bearing facts (why --limit was structurally forced before, what's intentionally excluded) and letting the PR description carry the rest — future edits to this play risk the comment drifting out of sync with the role list.
  • images/edge-host-iso/airootfs/usr/local/bin/edge-install:10,102-111: HOST is still hardcoded to edge-1 (pre-existing, untouched by this diff), so the new post-install instructions ("confirm ssh root@${HOST} works", "add ${HOST} to [edge_host]") will print edge-1 even when this ISO is later used to image the second Wyse box. The PR body explicitly scopes that second host out as a follow-up, so this isn't a defect introduced here — just flagging that whoever picks up the follow-up will need to parameterize HOST (e.g. prompt for it) before the printed instructions are correct for a second box.

Tests

No automated tests exist for these Ansible plays/docs in this repo (consistent with the rest of the codebase), and none would be idiomatic here. The PR body documents manual verification instead — a --check --diff run of the new unrestricted play against the live edge_host group showing only pre-existing check-mode noise from the edge-host role, with certificates/common contributing zero changes. I can't independently re-run that against the live host, but the claim is consistent with what's on disk (empty certificates role, common tasks that don't match Arch's os_family), so it's credible rather than just asserted.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `07faf0ae69627cecff137287315a16049dc955d9`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR folds `certificates` and `common` into `plays/install-edge-host.yml` so edge-host bootstrap is a single unrestricted run over the `edge_host` group, and fixes the runbook's dead `edge-1.srv.renner.uno` references (should be `.iot.renner.uno`) and the DHCP-reservation precondition across three files (`ansible/plays/install-edge-host.yml`, `ansible/roles/edge-host/README.md`, `images/edge-host-iso/README.md`, `images/edge-host-iso/airootfs/usr/local/bin/edge-install`). I checked every factual claim against the checkout: `common`'s tasks only branch on `Debian`/`RedHat` `os_family` (`ansible/roles/common/tasks/main.yml:27,37`) so they no-op on Arch; `certificates` is an intentionally empty role (`ansible/roles/certificates/tasks/main.yml`); `loki_endpoint`/`prometheus_remote_write_endpoint` are indeed unused by `common` (only consumed by `monitoring-agent`, which isn't pulled in); `edge_host`'s inventory entry already uses `ansible_user=root` (`ansible/inventory/pve_hosts.yml:45`), making the new `become: true` a no-op as the added comment states; and the corrected hostname `edge-1.iot.renner.uno` matches the actual inventory entry. All of this holds up — no blocking issues found. ## 🟡 Suggestions - `ansible/plays/install-edge-host.yml:9-27`: the new header comment is quite long (19 lines) for what is now a 6-line play. Not wrong, but consider trimming to the load-bearing facts (why `--limit` was structurally forced before, what's intentionally excluded) and letting the PR description carry the rest — future edits to this play risk the comment drifting out of sync with the role list. - `images/edge-host-iso/airootfs/usr/local/bin/edge-install:10,102-111`: `HOST` is still hardcoded to `edge-1` (pre-existing, untouched by this diff), so the new post-install instructions ("confirm `ssh root@${HOST}` works", "add `${HOST}` to `[edge_host]`") will print `edge-1` even when this ISO is later used to image the second Wyse box. The PR body explicitly scopes that second host out as a follow-up, so this isn't a defect introduced here — just flagging that whoever picks up the follow-up will need to parameterize `HOST` (e.g. prompt for it) before the printed instructions are correct for a second box. ## Tests No automated tests exist for these Ansible plays/docs in this repo (consistent with the rest of the codebase), and none would be idiomatic here. The PR body documents manual verification instead — a `--check --diff` run of the new unrestricted play against the live `edge_host` group showing only pre-existing check-mode noise from the `edge-host` role, with `certificates`/`common` contributing zero changes. I can't independently re-run that against the live host, but the claim is consistent with what's on disk (empty `certificates` role, `common` tasks that don't match Arch's `os_family`), so it's credible rather than just asserted. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=07faf0ae69627cecff137287315a16049dc955d9 -->
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit ad550ecb0413f091300561484b2002520744ed5b.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR splits the monolithic edge-host Ansible role into eleven single-concern roles, folds certificates/common into install-edge-host.yml so the bootstrap is one unrestricted run over the edge_host group, and fixes the stale edge-1.srv.renner.uno hostname in the README/ISO docs. The refactor itself is clean and the variable/handler split is mostly correct, but the new role order introduces a real migration hazard for the live edge-1 host, and one handler reference was left dangling by the split — both would have been invisible to the PR's --check --diff verification because command/shell tasks don't actually execute under Ansible check mode.

🔴 Blocking

  • ansible/plays/install-edge-host.yml:44-45 — zwave-gateway runs before the legacy-project teardown that power-meter-sml performs, so a real run against the existing edge-1 host will fail with a Docker container-name conflict. The old edge-host role deployed one shared compose project at /opt/edge-host/docker-compose.yml containing services zwave-js and sml2mqtt (both with fixed container_name:). This PR's power-meter-sml/tasks/main.yml:17-28 tears that legacy project down (docker compose -f {{ edge_legacy_compose }} down + delete the file) — but that task lives in power-meter-sml, which the play runs last. zwave-gateway/tasks/main.yml:78-88 ("Start zwave-js") runs before it and does docker compose up -d in the new project directory (zwave_base_dir), trying to create a container also named zwave-js. Docker enforces globally-unique container names regardless of compose project, so while the old zwave-js container (owned by the still-untorn-down legacy project) exists, this up -d fails with a "Conflict: container name … already in use" error on stderr — which does not match the 'error gathering device information' string the task's failed_when tolerates, so the task is marked failed and the play aborts for that host, never reaching power-meter-sml's cleanup. The role's own default comment (power-meter-sml/defaults/main.yml:11-12) acknowledges "the fixed container_name is released for the new projects" as a prerequisite — but the retirement step needs to run before both zwave-gateway and power-meter-sml, not just before the latter.
    The PR's stated verification (--check --diff, "ok=53 changed=5") cannot have exercised this: ansible.builtin.command/shell tasks are not check-mode aware and are skipped (not simulated) under --check, so the docker compose down/up sequence never actually ran during that dry run.
    Fix: move the legacy-project retirement (and file removal) to run before zwave-gateway — e.g. as its own step earlier in the play, or as the first task of zwave-gateway itself.

  • ansible/roles/zwave-gateway/tasks/main.yml:29 — dangling handler notify. The "Extract musl libeatmydata.so" task does notify: Restart edge compose services, but no handler by that name exists anywhere in the repository (verified via repo-wide search) — it's a leftover from the deleted roles/edge-host/handlers/main.yml, which defined that handler. zwave-gateway/handlers/main.yml only defines Restart zwave-js. Today this is masked because the task is guarded by creates: "{{ zwave_base_dir }}/libeatmydata.so" and that file already exists on edge-1 (confirmed by the PR body: the extraction wasn't among the 5 reported changes), so the task reports ok, not changed, and the notify never fires. But per the role's own comment ("Refresh by deleting the .so if the base ever bumps its Alpine major version"), the very next time that file is deleted/refreshed, this task will report changed, fire the notify, and Ansible will fatally error with "the requested handler 'Restart edge compose services' was not found", aborting the play.
    Fix: notify: Restart zwave-js to match the handler that actually exists in this role.

🟡 Suggestions

  • kubernetes/bootstrap.sh:28 still references ansible/roles/edge-host, which this PR deletes. Not part of the diff, so easy to miss, but now stale alongside the other doc updates this PR made (README, ISO docs) — worth a follow-up fix to ansible/docs/edge-host.md.

🟢 Nits

None beyond the above.

Tests

No test suite exists for this Ansible content (consistent with the rest of the repo); the PR's own idempotence check (--check --diff against the live host) is the established verification method here, but as noted above it structurally cannot catch failures in command/shell tasks (the exact place both blocking issues live), since those don't execute under --check. A real (non-check) idempotent re-run against edge-1, or at minimum a --check run with check_mode: false forced on the two docker compose command tasks, would have caught the first issue; nothing short of an actual changed run of the libeatmydata extraction task would catch the second.

Recommendation

Request changes — 2 blocking issues.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `ad550ecb0413f091300561484b2002520744ed5b`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR splits the monolithic `edge-host` Ansible role into eleven single-concern roles, folds `certificates`/`common` into `install-edge-host.yml` so the bootstrap is one unrestricted run over the `edge_host` group, and fixes the stale `edge-1.srv.renner.uno` hostname in the README/ISO docs. The refactor itself is clean and the variable/handler split is mostly correct, but the new role *order* introduces a real migration hazard for the live `edge-1` host, and one handler reference was left dangling by the split — both would have been invisible to the PR's `--check --diff` verification because `command`/`shell` tasks don't actually execute under Ansible check mode. ## 🔴 Blocking - **`ansible/plays/install-edge-host.yml:44-45` — `zwave-gateway` runs before the legacy-project teardown that `power-meter-sml` performs, so a real run against the existing `edge-1` host will fail with a Docker container-name conflict.** The old `edge-host` role deployed one shared compose project at `/opt/edge-host/docker-compose.yml` containing services `zwave-js` and `sml2mqtt` (both with fixed `container_name:`). This PR's `power-meter-sml/tasks/main.yml:17-28` tears that legacy project down (`docker compose -f {{ edge_legacy_compose }} down` + delete the file) — but that task lives in `power-meter-sml`, which the play runs *last*. `zwave-gateway/tasks/main.yml:78-88` ("Start zwave-js") runs *before* it and does `docker compose up -d` in the new project directory (`zwave_base_dir`), trying to create a container also named `zwave-js`. Docker enforces globally-unique container names regardless of compose project, so while the old `zwave-js` container (owned by the still-untorn-down legacy project) exists, this `up -d` fails with a "Conflict: container name … already in use" error on stderr — which does not match the `'error gathering device information'` string the task's `failed_when` tolerates, so the task is marked failed and the play aborts for that host, never reaching `power-meter-sml`'s cleanup. The role's own default comment (`power-meter-sml/defaults/main.yml:11-12`) acknowledges "the fixed container_name is released for the new projects" as a prerequisite — but the retirement step needs to run before **both** `zwave-gateway` and `power-meter-sml`, not just before the latter. The PR's stated verification (`--check --diff`, "ok=53 changed=5") cannot have exercised this: `ansible.builtin.command`/`shell` tasks are not check-mode aware and are skipped (not simulated) under `--check`, so the `docker compose down`/`up` sequence never actually ran during that dry run. Fix: move the legacy-project retirement (and file removal) to run before `zwave-gateway` — e.g. as its own step earlier in the play, or as the first task of `zwave-gateway` itself. - **`ansible/roles/zwave-gateway/tasks/main.yml:29` — dangling handler notify.** The "Extract musl libeatmydata.so" task does `notify: Restart edge compose services`, but no handler by that name exists anywhere in the repository (verified via repo-wide search) — it's a leftover from the deleted `roles/edge-host/handlers/main.yml`, which defined that handler. `zwave-gateway/handlers/main.yml` only defines `Restart zwave-js`. Today this is masked because the task is guarded by `creates: "{{ zwave_base_dir }}/libeatmydata.so"` and that file already exists on `edge-1` (confirmed by the PR body: the extraction wasn't among the 5 reported changes), so the task reports `ok`, not `changed`, and the notify never fires. But per the role's own comment ("Refresh by deleting the .so if the base ever bumps its Alpine major version"), the very next time that file is deleted/refreshed, this task will report `changed`, fire the notify, and Ansible will fatally error with "the requested handler 'Restart edge compose services' was not found", aborting the play. Fix: `notify: Restart zwave-js` to match the handler that actually exists in this role. ## 🟡 Suggestions - `kubernetes/bootstrap.sh:28` still references `ansible/roles/edge-host`, which this PR deletes. Not part of the diff, so easy to miss, but now stale alongside the other doc updates this PR made (README, ISO docs) — worth a follow-up fix to `ansible/docs/edge-host.md`. ## 🟢 Nits None beyond the above. ## Tests No test suite exists for this Ansible content (consistent with the rest of the repo); the PR's own idempotence check (`--check --diff` against the live host) is the established verification method here, but as noted above it structurally cannot catch failures in `command`/`shell` tasks (the exact place both blocking issues live), since those don't execute under `--check`. A real (non-check) idempotent re-run against `edge-1`, or at minimum a `--check` run with `check_mode: false` forced on the two `docker compose` command tasks, would have caught the first issue; nothing short of an actual `changed` run of the libeatmydata extraction task would catch the second. ## Recommendation Request changes — 2 blocking issues. <!-- pr-review:sha=ad550ecb0413f091300561484b2002520744ed5b -->
claude-bot force-pushed edge-bootstrap-selfcontained from ad550ecb04 to 2d9c3ba213 2026-08-08 09:33:59 +00:00 Compare
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 2d9c3ba213b37cedf32cb0a8601a66f98d70a53c.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

The diff is much larger than the PR description suggests: the description
(and title) talk only about three doc/runbook defects — dropping the DHCP
reservation requirement, folding common/certificates into
install-edge-host.yml so no --limit is needed, and fixing the
edge-1.srv.renner.uno → edge-1.iot.renner.uno typo. The actual (and only)
commit in the PR, 2d9c3ba, is titled refactor(edge): split the edge-host role into one role per concern and does exactly that: it breaks the old
monolithic edge-host role into nine new roles (edge-flash-wear,
edge-log-shipper, mqtt-bridge, edge-vlan, socketcan, heating-can,
zwave-gateway, power-meter-sml, plus additions to the shared
docker-install role). The decomposition itself is clean and well-commented,
and the described doc/runbook fixes are all present and correct. But the
undisclosed scope is where the real risk is: two concrete bugs below would
either break the very production host (edge-1) this PR is meant to migrate,
or silently change Docker's config on several unrelated hosts.

🔴 Blocking

  • ansible/plays/install-edge-host.yml:44-45 — zwave-gateway runs before
    power-meter-sml retires the legacy compose project, so the live-migration
    run this PR is meant to perform will fail on a container-name conflict.

    The old edge-host role deployed one compose project at
    /opt/edge-host/docker-compose.yml with two fixed container names,
    zwave-js and sml2mqtt (deleted in this diff, e.g. the old
    ansible/roles/edge-host/templates/docker-compose.yml.j2). The only task
    that stops/removes that legacy project is "Retire the legacy shared compose
    project" in ansible/roles/power-meter-sml/tasks/main.yml:17-23. But the
    role order in install-edge-host.yml runs zwave-gateway before
    power-meter-sml (socketcan, heating-can, zwave-gateway,
    power-meter-sml). zwave-gateway's "Start zwave-js" task
    (ansible/roles/zwave-gateway/tasks/main.yml:78-88) runs docker compose up -d in a brand-new project directory whose compose file still declares
    container_name: zwave-js (ansible/roles/zwave-gateway/templates/docker-compose.yml.j2:20)
    — the same name already held by the still-running legacy container, since
    nothing has stopped it yet. Docker refuses to create a second container with
    a name already in use, docker compose up -d exits non-zero with a
    "Conflict… container name … already in use" message that does not
    contain 'error gathering device information', so the failed_when guard
    (lines 83-85) does not swallow it — the task fails and the play stops before
    power-meter-sml (which contains the only retirement logic) ever runs. On a
    retry the same failure repeats, because nothing changed. This is exactly the
    scenario the PR's own verification target (edge-1, already provisioned
    under the old role) will hit on the first real run.
    The PR body's --check --diff verification against the live host would not
    have caught this: ansible.builtin.command/shell tasks without
    creates/removes are not executed in check mode, so none of the docker compose up -d / docker compose down tasks actually ran during that
    validation — consistent with the reported changed=5 covering only the
    git-checkout/pip-install tasks.
    Fix: run the "retire legacy compose project" step before any role tries to
    claim zwave-js/sml2mqtt as container names (e.g. as its own step ahead
    of zwave-gateway, or duplicated into zwave-gateway since it starts
    first).

  • ansible/roles/docker-install/tasks/main.yml:74-79 — the new "Configure
    docker daemon log driver" task is unconditional in a shared role, and
    directly fights netbird-proxy's own daemon.json management.

    docker-install is not edge-host-specific — it's included by plays/ install-netbird-proxy.yml, install-netbird-server.yml,
    install-music-assistant.yml, install-grist.yml, install-ical-proxy.yml,
    install-gatus.yml, install-owncloud.yml, install-authentik-ha.yml, and
    install-n8n.yml, none of which are mentioned in the PR description. The
    new task writes /etc/docker/daemon.json with {"log-driver": "journald"} (ansible/roles/docker-install/templates/docker-daemon.json.j2)
    on every one of those hosts and restarts docker via the Restart docker
    handler — undisclosed scope creep on its own, but for netbird-proxy it's
    an active conflict: ansible/roles/netbird-proxy/tasks/main.yml:21-26
    already deploys its own full-replacement /etc/docker/daemon.json (IPv6
    config, daemon.json.j2 → {"ipv6": true, "fixed-cidr-v6": …, "ip6tables": true}, no log-driver key) later in the same play
    (plays/install-netbird-proxy.yml:26-27: docker-install then
    netbird-proxy), and also defines a handler named Restart docker
    (ansible/roles/netbird-proxy/handlers/main.yml). Both tasks do a full
    ansible.builtin.template overwrite of the same file with mutually
    exclusive content, so every single run of install-netbird-proxy.yml
    leaves the two tasks alternately clobbering each other's version of
    daemon.json and both reporting "changed" — this breaks the idempotence
    this repo otherwise verifies for (per this PR's own commit-message
    standard), and the IPv6 config the play depends on ends up racing with the
    new log-driver config with no clear winner between runs, each hop also
    restarting docker (and hence at least momentarily the netbird-proxy/relay
    containers).
    Fix: scope the log-driver task to edge hosts only (e.g. move it into
    edge-flash-wear or gate it behind a role variable that defaults off), and
    don't add unconditional host-wide file ownership to a role six other plays
    already depend on without checking those consumers.

🟡 Suggestions

  • ansible/roles/heating-can/tasks/main.yml:117-118 (blank line style aside)
    — no functional issue, just noting the two heating-can tasks read/checked
    fine: same systemd unit names as before, so re-applying against the
    already-running open3e/e3oncan services on edge-1 is a normal idempotent
    restart, unlike the compose-project case above.
  • Consider a comment in install-edge-host.yml next to the zwave-gateway /
    power-meter-sml entries calling out the one-time legacy-compose
    dependency, the way the header comment already calls out the
    mqtt-bridge/edge-flash-wear/socketcan ordering constraints — it's the
    one ordering rule that isn't documented there and it's the one that's wrong.

🟢 Nits

  • kubernetes/bootstrap.sh:28 still references ansible/roles/edge-host in
    a comment; that path no longer exists after this PR (moved to
    ansible/docs/edge-host.md).

Tests

No automated tests cover Ansible playbooks/roles in this repo; verification
here is manual --check --diff against the live host, as described in the
PR body. That method is exactly why both blocking issues above weren't
caught: command/shell tasks without creates/removes don't execute
under --check, so none of the docker compose up/down invocations — where
both bugs live — actually ran during the author's own validation. A real
(non-check) dry run against a disposable Arch VM seeded with the old
edge-host role's compose project (to reproduce the container-name
collision) and against one of the other docker-install consumers (to
reproduce the daemon.json fight) would have caught both.

Recommendation

Request changes — 2 blocking issues.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `2d9c3ba213b37cedf32cb0a8601a66f98d70a53c`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary The diff is much larger than the PR description suggests: the description (and title) talk only about three doc/runbook defects — dropping the DHCP reservation requirement, folding `common`/`certificates` into `install-edge-host.yml` so no `--limit` is needed, and fixing the `edge-1.srv.renner.uno` → `edge-1.iot.renner.uno` typo. The actual (and only) commit in the PR, `2d9c3ba`, is titled `refactor(edge): split the edge-host role into one role per concern` and does exactly that: it breaks the old monolithic `edge-host` role into nine new roles (`edge-flash-wear`, `edge-log-shipper`, `mqtt-bridge`, `edge-vlan`, `socketcan`, `heating-can`, `zwave-gateway`, `power-meter-sml`, plus additions to the shared `docker-install` role). The decomposition itself is clean and well-commented, and the described doc/runbook fixes are all present and correct. But the undisclosed scope is where the real risk is: two concrete bugs below would either break the very production host (`edge-1`) this PR is meant to migrate, or silently change Docker's config on several unrelated hosts. ## 🔴 Blocking - **`ansible/plays/install-edge-host.yml:44-45` — `zwave-gateway` runs before `power-meter-sml` retires the legacy compose project, so the live-migration run this PR is meant to perform will fail on a container-name conflict.** The old `edge-host` role deployed one compose project at `/opt/edge-host/docker-compose.yml` with two fixed container names, `zwave-js` and `sml2mqtt` (deleted in this diff, e.g. the old `ansible/roles/edge-host/templates/docker-compose.yml.j2`). The only task that stops/removes that legacy project is "Retire the legacy shared compose project" in `ansible/roles/power-meter-sml/tasks/main.yml:17-23`. But the role order in `install-edge-host.yml` runs `zwave-gateway` *before* `power-meter-sml` (`socketcan`, `heating-can`, `zwave-gateway`, `power-meter-sml`). `zwave-gateway`'s "Start zwave-js" task (`ansible/roles/zwave-gateway/tasks/main.yml:78-88`) runs `docker compose up -d` in a brand-new project directory whose compose file still declares `container_name: zwave-js` (`ansible/roles/zwave-gateway/templates/docker-compose.yml.j2:20`) — the same name already held by the still-running legacy container, since nothing has stopped it yet. Docker refuses to create a second container with a name already in use, `docker compose up -d` exits non-zero with a "Conflict… container name … already in use" message that does **not** contain `'error gathering device information'`, so the `failed_when` guard (lines 83-85) does not swallow it — the task fails and the play stops before `power-meter-sml` (which contains the only retirement logic) ever runs. On a retry the same failure repeats, because nothing changed. This is exactly the scenario the PR's own verification target (edge-1, already provisioned under the old role) will hit on the first real run. The PR body's `--check --diff` verification against the live host would not have caught this: `ansible.builtin.command`/`shell` tasks without `creates`/`removes` are not executed in check mode, so none of the `docker compose up -d` / `docker compose down` tasks actually ran during that validation — consistent with the reported `changed=5` covering only the git-checkout/pip-install tasks. Fix: run the "retire legacy compose project" step before any role tries to claim `zwave-js`/`sml2mqtt` as container names (e.g. as its own step ahead of `zwave-gateway`, or duplicated into `zwave-gateway` since it starts first). - **`ansible/roles/docker-install/tasks/main.yml:74-79` — the new "Configure docker daemon log driver" task is unconditional in a shared role, and directly fights `netbird-proxy`'s own `daemon.json` management.** `docker-install` is not edge-host-specific — it's included by `plays/ install-netbird-proxy.yml`, `install-netbird-server.yml`, `install-music-assistant.yml`, `install-grist.yml`, `install-ical-proxy.yml`, `install-gatus.yml`, `install-owncloud.yml`, `install-authentik-ha.yml`, and `install-n8n.yml`, none of which are mentioned in the PR description. The new task writes `/etc/docker/daemon.json` with `{"log-driver": "journald"}` (`ansible/roles/docker-install/templates/docker-daemon.json.j2`) on every one of those hosts and restarts docker via the `Restart docker` handler — undisclosed scope creep on its own, but for `netbird-proxy` it's an active conflict: `ansible/roles/netbird-proxy/tasks/main.yml:21-26` already deploys its own full-replacement `/etc/docker/daemon.json` (IPv6 config, `daemon.json.j2` → `{"ipv6": true, "fixed-cidr-v6": …, "ip6tables": true}`, no `log-driver` key) later in the same play (`plays/install-netbird-proxy.yml:26-27`: `docker-install` then `netbird-proxy`), and also defines a handler named `Restart docker` (`ansible/roles/netbird-proxy/handlers/main.yml`). Both tasks do a full `ansible.builtin.template` overwrite of the same file with mutually exclusive content, so every single run of `install-netbird-proxy.yml` leaves the two tasks alternately clobbering each other's version of `daemon.json` and both reporting "changed" — this breaks the idempotence this repo otherwise verifies for (per this PR's own commit-message standard), and the IPv6 config the play depends on ends up racing with the new log-driver config with no clear winner between runs, each hop also restarting docker (and hence at least momentarily the netbird-proxy/relay containers). Fix: scope the log-driver task to edge hosts only (e.g. move it into `edge-flash-wear` or gate it behind a role variable that defaults off), and don't add unconditional host-wide file ownership to a role six other plays already depend on without checking those consumers. ## 🟡 Suggestions - `ansible/roles/heating-can/tasks/main.yml:117-118` (blank line style aside) — no functional issue, just noting the two heating-can tasks read/checked fine: same systemd unit names as before, so re-applying against the already-running open3e/e3oncan services on edge-1 is a normal idempotent restart, unlike the compose-project case above. - Consider a comment in `install-edge-host.yml` next to the `zwave-gateway` / `power-meter-sml` entries calling out the one-time legacy-compose dependency, the way the header comment already calls out the `mqtt-bridge`/`edge-flash-wear`/`socketcan` ordering constraints — it's the one ordering rule that isn't documented there and it's the one that's wrong. ## 🟢 Nits - `kubernetes/bootstrap.sh:28` still references `ansible/roles/edge-host` in a comment; that path no longer exists after this PR (moved to `ansible/docs/edge-host.md`). ## Tests No automated tests cover Ansible playbooks/roles in this repo; verification here is manual `--check --diff` against the live host, as described in the PR body. That method is exactly why both blocking issues above weren't caught: `command`/`shell` tasks without `creates`/`removes` don't execute under `--check`, so none of the `docker compose up/down` invocations — where both bugs live — actually ran during the author's own validation. A real (non-check) dry run against a disposable Arch VM seeded with the old `edge-host` role's compose project (to reproduce the container-name collision) and against one of the other `docker-install` consumers (to reproduce the `daemon.json` fight) would have caught both. ## Recommendation Request changes — 2 blocking issues. <!-- pr-review:sha=2d9c3ba213b37cedf32cb0a8601a66f98d70a53c -->
claude-bot added 1 commit 2026-08-08 09:47:41 +00:00
fix(edge): retire the legacy compose project before any role starts a container
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
9cc20251fd
Review on #474 found two blocking defects, both in exactly the place the
--check verification is blind: command and shell tasks are skipped, not
simulated, under check mode. So "verified with --check --diff" covered
everything except the two things that would actually have broken.

1. Ordering: a real run would have aborted.

   The legacy-project teardown lived in power-meter-sml, which the play runs
   last. zwave-gateway runs before it and does `docker compose up -d` for a
   container named zwave-js -- while the old shared project still owns that
   name. Docker enforces container names globally, not per project, so the
   command fails with "Conflict: container name already in use". That is not
   the missing-USB-device string the task tolerates, so the task fails and
   the play aborts before ever reaching the cleanup.

   Moved to pre_tasks, which is its correct home anyway: it is a migration
   step for both services, and putting it inside either one meant whichever
   ran first hit the conflict.

   The play now also declares edge_base_dir and edge_legacy_compose in its
   own vars rather than borrowing them from a role's defaults, since
   pre_tasks must not depend on role default resolution. And it removes the
   now-orphaned zwavejs.env left at the old shared project root.

2. Dangling handler.

   The libeatmydata task still notified "Restart edge compose services", a
   handler that was deleted with the old role. Masked today only because
   `creates:` makes the task report ok -- but the role's own comment says to
   delete that .so when the Alpine base bumps, and the next time it does,
   Ansible would fatally error with "handler not found". Now notifies
   "Restart zwave-js".

   Swept every notify in the new roles against every defined handler; that
   was the only unresolved one.

Also from review: kubernetes/bootstrap.sh referenced the deleted role.
Repointed to ansible/docs/edge-host.md.

Re-verified: ok=66 changed=25. The teardown moved from the power-meter-sml
group into pre_tasks and gained the orphaned-env removal, so the count goes
5 + 5 + 14 -> 5 pre-existing + 6 migration + 14 header repointing. Nothing
else moved.

The limit is worth stating plainly: this re-run still cannot prove the
teardown works, for the same reason it missed the bug. The first real apply
is the test.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 9cc20251fdddf2a51a21584c1fc887689dd7170b.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR restructures the monolithic edge-host ansible role into ten focused roles (edge-flash-wear, edge-log-shipper, mqtt-bridge, edge-vlan, socketcan, heating-can, zwave-gateway, power-meter-sml, plus certificates/common folded into the play), fixes the bootstrap runbook (drops the DHCP-reservation precondition, removes the --limit two-command dance, corrects the dead edge-1.srv.renner.uno hostname), and renames roles/edge-host/README.md to ansible/docs/edge-host.md. I traced the role split, the new play's pre_tasks legacy-compose migration, and cross-checked the claims in the PR body (common role's OS branching, loki_endpoint/prometheus_remote_write_endpoint not being needed, no leftover edge-1.srv or roles/edge-host references) — all check out. There is one blocking issue: a task added to the shared docker-install role has fleet-wide blast radius that the PR doesn't disclose.

🔴 Blocking

  • ansible/roles/docker-install/tasks/main.yml:71-79 — the new "Configure docker daemon log driver" task was added directly to the shared docker-install role, not scoped to edge_host. This role is also used by plays/install-netbird-server.yml, install-n8n.yml, install-authentik-ha.yml, install-owncloud.yml, install-gatus.yml, install-ical-proxy.yml, install-grist.yml, install-music-assistant.yml, and install-netbird-proxy.yml. None of those hosts currently have /etc/docker/daemon.json (the template and docker_log_driver default are both new files introduced by this PR), so the next time any of those plays is run, this task will create the file, report changed, and fire the Restart docker handler (ansible/roles/docker-install/handlers/main.yml) — restarting the Docker daemon and every container on that host (authentik, n8n, owncloud, grist, netbird, gatus, music-assistant, ical-proxy). That's an unannounced availability blip on unrelated production services, triggered by a PR framed entirely around edge-host bootstrapping.
    The task's own comment — "the services that use this host run with network_mode: host" — is true for the edge-host services but false for most of the other consumers of this role, which is a good sign the change was written with only edge-1 in mind and landed in the wrong file.
    Fix: move this task (and the docker_log_driver default / docker-daemon.json.j2 template) into a role that's actually edge-host-scoped (e.g. edge-flash-wear, which is already edge-host-only and already owns the flash-wear/journald rationale), or gate the task with when: "'edge_host' in group_names" if it must stay in docker-install.

🟡 Suggestions

  • ansible/plays/install-edge-host.yml play-level vars.edge_base_dir and edge_legacy_compose duplicate defaults independently re-declared in heating-can, power-meter-sml, and zwave-gateway role defaults (all /opt/edge-host). Harmless today since play vars win over role defaults, but worth a comment noting the play-level copy is intentionally kept in sync for pre_tasks, in case a future edit changes one without the other.

🟢 Nits

None beyond the above.

Tests

This is an ansible-only infra repo with no automated test suite; verification is via --check --diff against the live host, which the PR body documents (idempotent, only pre-existing check-mode-visible changes inside the roles that were already there before the split). That's consistent with how this repo verifies other plays. The one thing that verification pass could not have caught is the docker-install blast-radius issue above, since it only exercised install-edge-host.yml — a --check --diff run of one of the other nine plays that consume docker-install (e.g. install-gatus.yml) would have surfaced the new daemon.json task and its handler firing.

Recommendation

Request changes — 1 blocking issue.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `9cc20251fdddf2a51a21584c1fc887689dd7170b`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR restructures the monolithic `edge-host` ansible role into ten focused roles (`edge-flash-wear`, `edge-log-shipper`, `mqtt-bridge`, `edge-vlan`, `socketcan`, `heating-can`, `zwave-gateway`, `power-meter-sml`, plus `certificates`/`common` folded into the play), fixes the bootstrap runbook (drops the DHCP-reservation precondition, removes the `--limit` two-command dance, corrects the dead `edge-1.srv.renner.uno` hostname), and renames `roles/edge-host/README.md` to `ansible/docs/edge-host.md`. I traced the role split, the new play's `pre_tasks` legacy-compose migration, and cross-checked the claims in the PR body (common role's OS branching, `loki_endpoint`/`prometheus_remote_write_endpoint` not being needed, no leftover `edge-1.srv` or `roles/edge-host` references) — all check out. There is one blocking issue: a task added to the *shared* `docker-install` role has fleet-wide blast radius that the PR doesn't disclose. ## 🔴 Blocking - **`ansible/roles/docker-install/tasks/main.yml:71-79`** — the new "Configure docker daemon log driver" task was added directly to the shared `docker-install` role, not scoped to `edge_host`. This role is also used by `plays/install-netbird-server.yml`, `install-n8n.yml`, `install-authentik-ha.yml`, `install-owncloud.yml`, `install-gatus.yml`, `install-ical-proxy.yml`, `install-grist.yml`, `install-music-assistant.yml`, and `install-netbird-proxy.yml`. None of those hosts currently have `/etc/docker/daemon.json` (the template and `docker_log_driver` default are both new files introduced by this PR), so the next time any of those plays is run, this task will create the file, report `changed`, and fire the `Restart docker` handler (`ansible/roles/docker-install/handlers/main.yml`) — restarting the Docker daemon and every container on that host (authentik, n8n, owncloud, grist, netbird, gatus, music-assistant, ical-proxy). That's an unannounced availability blip on unrelated production services, triggered by a PR framed entirely around edge-host bootstrapping. The task's own comment — "the services that use this host run with `network_mode: host`" — is true for the edge-host services but false for most of the other consumers of this role, which is a good sign the change was written with only edge-1 in mind and landed in the wrong file. Fix: move this task (and the `docker_log_driver` default / `docker-daemon.json.j2` template) into a role that's actually edge-host-scoped (e.g. `edge-flash-wear`, which is already edge-host-only and already owns the flash-wear/journald rationale), or gate the task with `when: "'edge_host' in group_names"` if it must stay in `docker-install`. ## 🟡 Suggestions - `ansible/plays/install-edge-host.yml` play-level `vars.edge_base_dir` and `edge_legacy_compose` duplicate defaults independently re-declared in `heating-can`, `power-meter-sml`, and `zwave-gateway` role defaults (all `/opt/edge-host`). Harmless today since play vars win over role defaults, but worth a comment noting the play-level copy is intentionally kept in sync for `pre_tasks`, in case a future edit changes one without the other. ## 🟢 Nits None beyond the above. ## Tests This is an ansible-only infra repo with no automated test suite; verification is via `--check --diff` against the live host, which the PR body documents (idempotent, only pre-existing check-mode-visible changes inside the roles that were already there before the split). That's consistent with how this repo verifies other plays. The one thing that verification pass could not have caught is the `docker-install` blast-radius issue above, since it only exercised `install-edge-host.yml` — a `--check --diff` run of one of the other nine plays that consume `docker-install` (e.g. `install-gatus.yml`) would have surfaced the new daemon.json task and its handler firing. ## Recommendation Request changes — 1 blocking issue. <!-- pr-review:sha=9cc20251fdddf2a51a21584c1fc887689dd7170b -->
claude-bot added 1 commit 2026-08-08 10:27:14 +00:00
fix(edge): keep the docker log-driver config out of the shared role
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
6cfa63f6f3
Review on #474 caught that the previous commit put the daemon.json task in
docker-install, which is shared by ten plays: install-netbird-server,
install-netbird-proxy, install-n8n, install-authentik-ha, install-owncloud,
install-gatus, install-ical-proxy, install-grist and
install-music-assistant.

None of those hosts has an /etc/docker/daemon.json today -- the template and
the default were both new here -- so the next routine run of any of those
plays would have created one, reported changed, fired the Restart docker
handler, and bounced every container on that host. Unannounced restarts
across authentik, n8n, owncloud, grist, netbird, gatus, music-assistant and
ical-proxy, from a change about edge-host bootstrapping.

The --check verification could not have caught it: it only ever exercised
install-edge-host.yml, and the damage was in the other nine plays.

The comment I wrote there was the tell. It said "the services that use this
host run with network_mode: host" -- true of edge-1, false of most consumers
of that role. A justification that only held for the one host I was looking
at, written into a shared file.

Moved to edge-flash-wear, which is edge-host-only and already owns this
rationale: routing container stdout to the journal is flash-wear handling,
not generic docker setup. The networking aside is dropped -- in a flash-wear
role the only thing worth saying about daemon.json is why logging goes to
the journal.

docker-install is now byte-identical to master (git diff origin/master --
roles/docker-install/ is empty).

Two details the move needed: /etc/docker is created explicitly, because
edge-flash-wear runs before docker-install on a first bootstrap and the
directory will not exist yet; and the Restart docker handler fires at
end-of-play, by which time docker is installed.

Verified: ok=67 changed=25. The changed count is unchanged from before the
move, and no docker task appears in it -- daemon.json renders byte-identical
to what the old role produced, so it reports ok and the handler never fires
on edge-1.

Also from review: noted on the play that heating-can, zwave-gateway and
power-meter-sml each declare edge_base_dir in their own defaults so they
stay usable standalone, that play vars win, and that the two must be kept in
step.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 6cfa63f6f3e294cb14d3969f84d49bd8ff84fc45.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

This PR does two things bundled together: it splits the monolithic edge-host
role into eight focused roles (edge-flash-wear, edge-log-shipper,
edge-vlan, heating-can, mqtt-bridge, power-meter-sml, socketcan,
zwave-gateway), and it makes install-edge-host.yml self-contained
(carrying certificates + common itself) so the bootstrap runbook no longer
needs a separate common.yml -l edge_host step, plus a hostname/doc fix
(edge-1.srv.renner.uno → edge-1.iot.renner.uno). The PR body only
describes the latter, smaller set of changes, but the diff is overwhelmingly
the role split — that's fine (it's a legitimate mechanical decomposition and
mostly renames), just worth noting the description undersells the diff's
actual size. The decomposition itself is careful: dependency order in the
play is correct (mqtt-bridge → its clients, socketcan → heating-can,
edge-flash-wear → docker-install), and the migration off the old shared
compose project (pre_tasks in install-edge-host.yml) correctly removes
both the legacy docker-compose.yml and the orphaned zwavejs.env at the old
path ({{ edge_base_dir }}/zwavejs.env, now served from
zwave-js/zwavejs.env). No blocking issues found in the mechanics of the
split itself. One design gap is worth fixing before/soon after merge (see
below), plus a couple of typos.

🟡 Suggestions

  • ansible/roles/monitoring-agent is not actually excluded from edge_host, contradicting the PR's stated design. The PR body says monitoring-agent is "Debian-oriented" and intentionally not carried into install-edge-host.yml because edge hosts ship logs via edge-alloy instead. But plays/common.yml's second play (ansible/plays/common.yml:8, hosts: all:!pve:!pbs:!proxmox_noansible:!proxmox_nodes:!pve_cluster) still includes edge_host in scope, and ansible/roles/monitoring-agent/tasks/main.yml is gated only by monitoring_agent_enabled (default true in ansible/roles/monitoring-agent/defaults/main.yml:5), not by os_family — its Alloy-binary-download path has no Debian/RedHat guard. ansible/inventory/group_vars/edge_host/vars.yml sets no monitoring_agent_enabled: false override. So an unrestricted future run of plays/common.yml would still deploy a second Grafana Alloy onto edge-1: same self-metrics port (alloy_http_listen_port: 12345 in monitoring-agent vs. edge_alloy_http_port: 12345 in ansible/roles/edge-log-shipper/defaults/main.yml) and a push target (loki.srv.renner.uno:3100) that the PR body itself says is firewalled off the IoT VLAN. This predates this PR (edge_host was already in that host pattern), and the trend elsewhere in the repo (authentik, netbird, etc. all carry common via their own dedicated plays rather than relying on common.yml unrestricted) makes it less likely to actually fire — but since this PR is explicitly the place that decided "not carried over," it's the natural place to also make that durable, e.g. monitoring_agent_enabled: false and qemu_guest_agent-equivalent in ansible/inventory/group_vars/edge_host/vars.yml, or excluding edge_host from plays/common.yml's host pattern.

  • ansible/plays/install-edge-host.yml:123-127 — the legacy-compose teardown (docker compose -f "{{ edge_legacy_compose }}" down) uses failed_when: false, which blanket-swallows any failure, not just "already torn down." Since the task only runs when edge_legacy_compose exists (guarded by removes:), a genuine failure here (e.g. daemon hiccup) would be silently ignored, the very next task still deletes the compose file, and the old fixed-name containers (zwave-js/sml2mqtt) could be left running — causing the later docker compose up -d in zwave-gateway/power-meter-sml to fail with a "container name already in use" error that doesn't match the 'error gathering device information' string those tasks tolerate, so the play would abort with a confusing error whose actual cause (the swallowed down failure) is invisible. The PR body already acknowledges --check can't exercise this path; worth at least narrowing failed_when to the "already down" case rather than everything.

🟢 Nits

  • ansible/roles/edge-log-shipper/tasks/main.yml:5-6 and ansible/roles/heating-can/tasks/main.yml:7 — missing apostrophes: "docker s journald driver", "Alloy s storage/WAL", "heat pump s own energy management".
  • ansible/roles/heating-can/tasks/main.yml has a stray double blank line between the "Enable open3e" and "E3onCAN" sections (around line 55).

Tests

This repo has no automated test harness for Ansible roles (confirmed — no molecule/pytest setup under ansible/); the established convention here is ansible-playbook ... --check --diff against the live host, which the PR does document (idempotent, ok=53 changed=5, changes isolated to pre-existing heating-can venv/git tasks). That's consistent with how this repo verifies infrastructure changes and is adequate for this kind of change. The one thing --check structurally cannot exercise — the legacy-compose teardown in pre_tasks — is called out by the PR itself as unverifiable in check mode; that's an honest and accurate limitation, not a gap in what was checked.

Recommendation

Looks reasonable — no blocking issues; comments above are optional but the monitoring-agent gap is worth a quick follow-up to make the "not carried over" decision actually durable.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `6cfa63f6f3e294cb14d3969f84d49bd8ff84fc45`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary This PR does two things bundled together: it splits the monolithic `edge-host` role into eight focused roles (`edge-flash-wear`, `edge-log-shipper`, `edge-vlan`, `heating-can`, `mqtt-bridge`, `power-meter-sml`, `socketcan`, `zwave-gateway`), and it makes `install-edge-host.yml` self-contained (carrying `certificates` + `common` itself) so the bootstrap runbook no longer needs a separate `common.yml -l edge_host` step, plus a hostname/doc fix (`edge-1.srv.renner.uno` → `edge-1.iot.renner.uno`). The PR body only describes the latter, smaller set of changes, but the diff is overwhelmingly the role split — that's fine (it's a legitimate mechanical decomposition and mostly renames), just worth noting the description undersells the diff's actual size. The decomposition itself is careful: dependency order in the play is correct (mqtt-bridge → its clients, socketcan → heating-can, edge-flash-wear → docker-install), and the migration off the old shared compose project (`pre_tasks` in `install-edge-host.yml`) correctly removes both the legacy `docker-compose.yml` and the orphaned `zwavejs.env` at the old path (`{{ edge_base_dir }}/zwavejs.env`, now served from `zwave-js/zwavejs.env`). No blocking issues found in the mechanics of the split itself. One design gap is worth fixing before/soon after merge (see below), plus a couple of typos. ## 🟡 Suggestions - **`ansible/roles/monitoring-agent` is not actually excluded from `edge_host`, contradicting the PR's stated design.** The PR body says monitoring-agent is "Debian-oriented" and intentionally not carried into `install-edge-host.yml` because edge hosts ship logs via `edge-alloy` instead. But `plays/common.yml`'s second play (`ansible/plays/common.yml:8`, `hosts: all:!pve:!pbs:!proxmox_noansible:!proxmox_nodes:!pve_cluster`) still includes `edge_host` in scope, and `ansible/roles/monitoring-agent/tasks/main.yml` is gated only by `monitoring_agent_enabled` (default `true` in `ansible/roles/monitoring-agent/defaults/main.yml:5`), not by `os_family` — its Alloy-binary-download path has no Debian/RedHat guard. `ansible/inventory/group_vars/edge_host/vars.yml` sets no `monitoring_agent_enabled: false` override. So an unrestricted future run of `plays/common.yml` would still deploy a second Grafana Alloy onto edge-1: same self-metrics port (`alloy_http_listen_port: 12345` in monitoring-agent vs. `edge_alloy_http_port: 12345` in `ansible/roles/edge-log-shipper/defaults/main.yml`) and a push target (`loki.srv.renner.uno:3100`) that the PR body itself says is firewalled off the IoT VLAN. This predates this PR (edge_host was already in that host pattern), and the trend elsewhere in the repo (authentik, netbird, etc. all carry `common` via their own dedicated plays rather than relying on `common.yml` unrestricted) makes it less likely to actually fire — but since this PR is explicitly the place that decided "not carried over," it's the natural place to also make that durable, e.g. `monitoring_agent_enabled: false` and `qemu_guest_agent`-equivalent in `ansible/inventory/group_vars/edge_host/vars.yml`, or excluding `edge_host` from `plays/common.yml`'s host pattern. - **`ansible/plays/install-edge-host.yml:123-127`** — the legacy-compose teardown (`docker compose -f "{{ edge_legacy_compose }}" down`) uses `failed_when: false`, which blanket-swallows any failure, not just "already torn down." Since the task only runs when `edge_legacy_compose` exists (guarded by `removes:`), a genuine failure here (e.g. daemon hiccup) would be silently ignored, the very next task still deletes the compose file, and the old fixed-name containers (`zwave-js`/`sml2mqtt`) could be left running — causing the later `docker compose up -d` in `zwave-gateway`/`power-meter-sml` to fail with a "container name already in use" error that doesn't match the `'error gathering device information'` string those tasks tolerate, so the play would abort with a confusing error whose actual cause (the swallowed `down` failure) is invisible. The PR body already acknowledges `--check` can't exercise this path; worth at least narrowing `failed_when` to the "already down" case rather than everything. ## 🟢 Nits - `ansible/roles/edge-log-shipper/tasks/main.yml:5-6` and `ansible/roles/heating-can/tasks/main.yml:7` — missing apostrophes: "docker s journald driver", "Alloy s storage/WAL", "heat pump s own energy management". - `ansible/roles/heating-can/tasks/main.yml` has a stray double blank line between the "Enable open3e" and "E3onCAN" sections (around line 55). ## Tests This repo has no automated test harness for Ansible roles (confirmed — no molecule/pytest setup under `ansible/`); the established convention here is `ansible-playbook ... --check --diff` against the live host, which the PR does document (idempotent, `ok=53 changed=5`, changes isolated to pre-existing `heating-can` venv/git tasks). That's consistent with how this repo verifies infrastructure changes and is adequate for this kind of change. The one thing `--check` structurally cannot exercise — the legacy-compose teardown in `pre_tasks` — is called out by the PR itself as unverifiable in check mode; that's an honest and accurate limitation, not a gap in what was checked. ## Recommendation Looks reasonable — no blocking issues; comments above are optional but the monitoring-agent gap is worth a quick follow-up to make the "not carried over" decision actually durable. <!-- pr-review:sha=6cfa63f6f3e294cb14d3969f84d49bd8ff84fc45 -->
k.renner merged commit 603db4ed95 into master 2026-08-08 10:39:48 +00:00
Sign in to join this conversation.