Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 931810e28b fix(trmnl): request an explicit device LB address, document the sidekiq race
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Two things the first reconcile surfaced.

Cilium IPAM allocated 10.2.99.0 for the device LoadBalancer. Nothing
requested it — the Service carried no lbipam.cilium.io/ips annotation, so
IPAM handed out the first free address in the pool block, which is the base
address of the 10.2.99.0-10.2.99.255 range. Each LB address is advertised
to the UDM as its own BGP route, so it is routable in principle, but a .0
host address is exactly the sort of thing constrained embedded IP stacks
get wrong, and the TRMNL firmware is the one client that has to work here.
Requesting .89 explicitly also brings this in line with every other LB
Service in the repo, all of which pin their address, and matches the
fd99::89 IPAM had already chosen for v6.

Omitting the annotation was my call on the original PR, on the reasoning
that the device is provisioned with a hostname so the address could move
freely. That reasoning still holds; it just is not worth spending it on an
address nobody picked.

Second, the sidekiq container exits 1 once on a fresh deploy with
`PG::UndefinedTable: relation "account" does not exist` — it loses the race
against the web container's migration, then comes up healthy on the
restart. Observed on the first deploy, converged in about a second. There
is no upstream ordering hook, and gating it behind an initContainer that
probes for a named table would couple us to Terminus's schema, so this is
left to Kubernetes' restart deliberately. Documented in place so the next
person reading `RESTARTS 1` can tell the benign case from a real one.

Verified on the live cluster before this commit: Kustomization Ready, tr-pg
"Cluster in healthy state" on PostgreSQL 18, both ExternalSecrets
SecretSynced, terminus and valkey Running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:04:17 +02:00
security-bot 2d8324b3f9 chore(image-automation): bump valkey/valkey:9.1.1-alpine
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-10 21:00:08 +00:00
k.renner b529864453 Merge pull request 'feat(trmnl): deploy Terminus BYOS server for the entrance e-ink panel' (#480) from feat-trmnl-terminus into master
Reviewed-on: k.renner/homelab#480
2026-08-10 20:59:01 +00:00
claude-botandClaude Opus 5 ac2d728108 feat(trmnl): deploy Terminus BYOS server for the entrance e-ink panel
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
Phase 1 of the TRMNL X dashboard plan: stand up the self-hosted Terminus
(BYOS) server the panel will poll. The Home Assistant dashboard, the e-ink
theme and the screenshot renderer are later phases and are not in here.

Phase 0 verification, all confirmed against upstream before writing any of
this:

  * TRMNL X IS a first-class model in Terminus (id v2 / label "TRMNL X",
    1872x1404, 16 colours, 4-bit) — the stop-and-report condition in the
    plan does not trigger. Note it is NOT in config/db/seeds.rb: the model
    list is populated by the MODEL_SYNCHRONIZER job pulling trmnl.com, so
    the Models list is empty until that job has run once.
  * The HA add-on can run standalone. Our HA is plain HA Core, not
    Supervised, but the add-on reads env vars in preference to
    /data/options.json and authenticates with an ordinary long-lived
    token — SUPERVISOR_TOKEN only flips an isAddOn boolean. So the
    fallback path in the plan (upstream container, not add-on) is viable.
    That deployment lands with Phase 4.

Deviations from the repo's house patterns, each deliberate:

  * tr-pg runs PostgreSQL 18, not the standard 16. Terminus ships a
    committed config/db/structure.sql containing `SET transaction_timeout
    = 0;`, a GUC that does not exist before PG 17, so schema load against
    a 16 server fails outright. This is the first non-16 CNPG cluster
    here. The existing 16 pin is inherited from 739a67e9 (Apr 2026) and
    has no documented rationale.
  * The device endpoint is cleartext HTTP on a Cilium BGP LoadBalancer
    (trmnl-device.renner.uno) rather than an Ingress. The firmware cannot
    be given the internal CA, and a TLS-less Ingress would be a new
    pattern for this repo — a dedicated LB address is not. The admin UI
    keeps a normal TLS Ingress at trmnl.renner.uno; both front the same
    Service. Same split as home.renner.uno / ha.renner.uno.
  * ghcr.io/usetrmnl/terminus is NOT enrolled in image automation despite
    publishing a usable tag stream. It serves the firmware and
    /api/display contract to a wall-mounted device, upstream is pre-1.0
    with no releases or changelog, and an unattended bump that breaks
    either leaves the panel dark. Bumps are manual and reviewed. The
    Valkey sidecar is enrolled — it touches no device-facing contract.
  * Egress is an FQDN allowlist (trmnl.com, plus the S3 host that serves
    firmware binaries), not toEntities: world. The S3 host is returned by
    the API at request time rather than being a source constant, so it can
    drift; the policy comment says where to look when firmware sync
    starts failing. This also means Terminus's Extensions/Poll feature,
    which fetches admin-configured URLs, will not work — accepted, since
    screen content is pushed in by the renderer.
  * The CNPG pods are deliberately left OUT of that egress policy so the
    barman WAL archive path is not behind a rule that fails closed.

Images verified to exist in their registries before pinning:
ghcr.io/usetrmnl/terminus:0.68.0, valkey/valkey:9.1-alpine,
ghcr.io/cloudnative-pg/postgresql:18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:54:38 +02:00
k.renner c8bc6380a1 Merge pull request 'fix(heating-can): give supervisord a control socket' (#478) from heating-can-supervisorctl into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#478
2026-08-10 19:53:51 +00:00
claude-botandClaude Opus 5 e3dea169b0 fix(heating-can): give supervisord a control socket
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The image shipped without [unix_http_server], [rpcinterface:supervisor] or
[supervisorctl], so supervisorctl could not connect:

  Error: .ini file does not include supervisorctl section

That means no way to query or restart an individual service inside the
container -- docker logs was the only introspection. Per-service lifecycle
management was the whole argument for running a supervisor instead of one
process per container, and the first revision did not actually deliver it.

Unix socket only, inside the container. Nothing outside needs it, and this
container runs with host networking, so a TCP listener would be exposed on
the host.

The build-time self-test now asserts the section exists and supervisorctl
runs, so this cannot regress silently the way it shipped.

NOT fixed here, because my diagnosis was wrong: the repeated
"mqtt broker disconnected. reason_code = Unspecified error" is not a client
ID collision. E3onCAN has no -mcid option at all -- verified against the
pinned source -- and generates its own ID; the native broker log showed
E3onCANclient.py-e380-7f847281 alongside open3e-edge, so the two never
collided. I nearly "fixed" it by passing a flag that does not exist.

Telemetry is flowing (each reconnect succeeds), so this is churn rather than
an outage, but the cause is still unidentified. The next step is the broker's
own log on edge-1 -- mosquitto records the disconnect reason, which the
client side reports only as "Unspecified error".

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:49:00 +02:00
k.renner 6384223415 Merge pull request 'fix(heating-can): stop the native services before removing their units' (#477) from fix-native-teardown into master
Reviewed-on: k.renner/homelab#477
2026-08-10 19:48:34 +00:00
claude-botandClaude Opus 5 1b7e3dc100 fix(heating-can): stop the native services before removing their units
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The migration did not stop anything. It ran stop + disable + mask in one
call with failed_when: false, then deleted the unit files. Masking a unit
whose file still exists conflicts, so the call failed, failed_when hid the
failure, nothing was stopped -- and the next task deleted the unit files out
from under two running processes.

That left systemctl is-enabled reporting "not-found", which looks clean,
while is-active still said "active". I reported the cutover successful on
the strength of the former.

On 2026-08-10 the result was two open3e instances issuing UDS requests on
the same CAN bus from 19:28 to 19:37, taking over each other's
open3e-edge MQTT session -- the exact failure the block's own comment said
it existed to prevent.

Fixed in three parts:

  - Stop and disable FIRST, and do not swallow the result. Only units that
    actually exist are touched (stat first), so a fresh host does not need
    an error hidden to get through.

  - No masking. It writes a symlink at the same path the next task removes,
    so the two cancel out. With the unit files gone there is nothing to
    start.

  - Assert the invariant that actually matters -- no native process on the
    CAN bus -- rather than inferring it from systemd reporting success.
    pgrep, then fail loudly with the surviving command lines and why it
    matters.

Verified against the live host: ok=15 changed=1 failed=0, the assert passing
with "No native CAN processes remain; safe to start the container", and the
run now idempotent (the single change is the compose up, not the teardown).

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:45:47 +02:00
k.renner 1ee4c2b61c Merge pull request 'refactor(edge): split the plays by capability; pin open3e and register it with the audit' (#475) from edge-capability-groups into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#475
2026-08-10 19:26:11 +00:00
claude-botandClaude Opus 5 33d92e133c fix(audit): track the heating-can image pins, not the deleted Ansible vars
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#476 merged first, which removed edge_open3e_pkg, edge_e3oncan_repo and
edge_e3oncan_version in favour of the image. The audit entry added here still
told the agent to read those, so it would have gone looking for variables
that no longer exist and reported nothing -- the same silent non-auditing the
entry was written to prevent.

Repointed at where the pins actually live now: OPEN3E_REF and E3ONCAN_REF as
ARGs in ci/images/heating-can/Containerfile. The awkward specifics carry over
unchanged, since they are properties of the upstreams rather than of how we
package them: open3e publishes no tags so the comparison must be against
commits, and E3onCAN's pin is an untagged commit later than its newest tag.

Added a note the previous version could not have made: heating_can_image is
:latest, so the node follows whatever CI last built. The Containerfile ARGs
are what actually fix the versions, and bumping one is what requires a
rebuild -- otherwise a reader could reasonably conclude the tag is the pin.

The pin commit itself is now a no-op against master and survives only as the
audit registration; its conflict on heating-can/defaults resolved in favour of
the image.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:17:42 +02:00
claude-botandClaude Opus 5 150a83ee38 fix(edge): make the capability-group invariant structural, and unstale a note
Both from review on #475.

The rule "a host in a capability group must also be in [edge_host]" was
enforced by a comment, in a file that already uses :children a few lines
below for ntp_cluster. The three capability groups are now children of
edge_host, so the relation is structural: a host added only to [heating_can]
still resolves group_vars/edge_host, where the local MQTT contract lives, and
cannot silently come up without edge_mqtt_host and friends.

That matters more than it looks. The MQTT contract only moved to group_vars
in this PR because splitting the plays exposed that the roles could not
render without each other's defaults -- so an edge host outside [edge_host]
would hit exactly the failure that change was made to fix.

Verified all four groups still resolve to edge-1, and the redundant
ansible_user on the capability entries is dropped: it is inherited from the
[edge_host] declaration rather than repeated three times.

Also unstaled a Day-2 note that still read "the pip task tracks the git
default branch (pin edge_open3e_pkg to a tag if that ever bites)". Both
services are pinned to commits now, deliberately, and pinning to a tag is not
available for open3e -- it publishes none. Replaced with the actual workflow:
the weekly update-audit reports drift, bump the pin and re-run.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:37 +02:00
claude-botandClaude Opus 5 dd6553ab07 fix(heating-can): pin open3e, and register both CAN services with the audit
open3e was installed from an unpinned git URL, so the heating bridge
silently tracked upstream main: whatever HEAD happened to be on the day a
converge ran was what ended up driving the heat pump. Now pinned to
6553c104, which was simultaneously upstream HEAD and the copy already
installed on edge-1, so the pin froze the running state and changed nothing.

A commit rather than a tag because open3e publishes none -- its tags API
returns an empty array. The package self-reports 0.7.5 at that commit, which
is useful context but is not a tag.

Honest about what this does NOT fix: pip still re-clones and rebuilds a VCS
requirement even when the SHA is pinned, because it cannot compare an
installed VCS dist against a requested URL without fetching it first.
Verified directly on the host:

  pip install --dry-run "git+...@6553c104"
    -> Cloning ... Resolved ... to commit 6553c104
       Installing build dependencies: started

So the task still reports changed and still restarts open3e on every
converge. Pinning buys deliberate versioning, not idempotence.

Pinning without an update path only converts churn into silent rot, so both
services are now registered with the weekly update-audit -- which had been
auditing neither, despite E3onCAN having been pinned since July. The audit
prompt warns that anything not listed is silently not audited, and these two
were exactly that.

The new scope entry records the awkward specifics so the agent does not
report normal states as problems: open3e has no tags at all, so it must
compare commits against the default branch; E3onCAN does tag, but sparsely,
and its pin sits between the newest tag and HEAD.

Also corrected while there: edge_e3oncan_version was commented "# V0.5.2",
which does not exist upstream. The repo's only tag is v0.5.1 (6f43b3fb), and
461dcbdf is a later untagged commit from 2026-06-21, "Fixed: MQTT client ID
collision caused stale retained values; added publish error logging".

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:37 +02:00
claude-botandClaude Opus 5 0b845e7c76 refactor(edge): split the plays by capability, not just the roles
Splitting edge-host into single-concern roles left the coupling one layer
up: install-edge-host.yml was still one play applying all eleven roles to
one group. A host in [edge_host] got the Z-Wave gateway and the CAN services
whether or not it had the hardware -- the monolith again, in the play.

Inventory groups now state which hardware a host physically has, and each
playbook targets the group for its own concern:

  [edge_host]       install-edge-host.yml       base: flash-wear, log
                                                shipper, MQTT bridge, VLAN,
                                                docker
  [heating_can]     install-heating-can.yml     Innomaker USB2CAN
  [zwave_gateway]   install-zwave-gateway.yml   Nabu Casa ZWA-2
  [power_meter]     install-power-meter.yml     FTDI meter head

No flags and no `when:` on the roles. Moving a stick to another box is a
one-line inventory change, which is what it should be. A host with nothing
attached is simply absent from the capability groups -- which is what lets a
second thin client join as an NTP peer without being handed peripherals it
does not have.

The split exposed a circular dependency the single play had been hiding: the
local MQTT contract was scattered across two roles' defaults, in both
directions. mqtt-bridge's config needs edge_open3e_mqtt_user, because the
bridge authenticates to the cluster broker AS that user; heating-can and
power-meter-sml need edge_mqtt_host, because that is where they publish.
Neither role could render without the other's defaults, and it only ever
worked because one play loaded every role at once.

Those four values are the interface between the broker and its clients, not
the property of either side, so they move to group_vars/edge_host -- where
the matching secrets (edge_open3e_mqtt_password, edge_sml2mqtt_mqtt_password)
already lived. Removed from the role defaults so there is one source of
truth. Each role keeps what is genuinely its own: mqtt-bridge the cluster
endpoint and CA path, heating-can the datapoints and poll interval.

Also dropped: the legacy shared-compose teardown pre_tasks. That migration
completed on the 2026-08-08 apply (verified: /opt/edge-host/docker-compose.yml
is gone), and carrying migration cruft in a base play would be the same
mixing this commit is undoing.

Verified by --check --diff against the live host, all four plays:

  install-edge-host      ok=33 changed=0 failed=0
  install-zwave-gateway  ok=8  changed=0 failed=0
  install-power-meter    ok=4  changed=0 failed=0
  install-heating-can    ok=18 changed=4 failed=0

Three of four are fully idempotent. heating-can's four are the known open3e
pip churn plus the E3onCAN venv, which reports changed only under check mode
(it was ok in the real apply) -- and their two restart handlers.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:16:17 +02:00
k.renner 685905b1df Merge pull request 'build(heating-can): image for the CAN readers, replacing pip-into-venv' (#476) from heating-can-image into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#476
2026-08-10 19:04:49 +00:00
claude-botandClaude Opus 5 f9848d2bf5 fix(heating-can): correct the overlay path, secret mode and writable CWD
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review on #476 found a real regression plus three things worth fixing before
this reaches edge-1.

Blocking: edge_open3e_devices.vx3.dpList still pointed at the old host path
/opt/edge-host/open3e/vx3plus.py. The compose file mounts that overlay at
/config/vx3plus.py and nothing mounts anything under /opt/edge-host inside
the container, so open3e would have looked for it at a path that does not
exist -- silently falling back to the stock vx3 datapoint list and dropping
the battery-module DIDs (vx3.1577-1582 and friends) the overlay exists to
add. Now /config/vx3plus.py.

Worth noting the build-time self-test could not have caught this: it proves
the binaries execute, not that devices.json resolves against the mounted
files at runtime.

Writable working directory. WORKDIR was /opt, which the COPY layers create
root-owned, while the process runs as open3e. udsoncan writes its log file
into the CWD -- the native install gave open3e's directory `owner: open3e`
for exactly that reason, and I dropped that when containerising. Now a
dedicated /var/lib/open3e, owned by the service user.

Secret mode. MQTT_PASSWORD was inline in docker-compose.yml, which is
rendered 0644. The env file this replaced was 0600, and the sibling roles on
this host keep secrets in a 0600 file beside a world-readable compose file.
Restored: credentials move to heating-can.env at 0600, referenced via
env_file.

edge_e3oncan_mqtt_format had become dead -- both services were fed
MQTT_FORMAT from edge_open3e_mqtt_format. The two default to the same string
so behaviour was unchanged, but overriding it did nothing. Wired through as
E3ONCAN_MQTT_FORMAT, which supervisord passes to e3oncan only.

Also from review: dropped the registered-but-unread heating_can_native_stopped,
and the migration now removes the emptied {{ edge_base_dir }}/open3e and
/e3oncan directories rather than leaving them behind.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:58:09 +02:00
security-bot 2e62216bc7 chore(image-automation): bump prompve/prometheus-pve-exporter:3.10.0
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-10 06:35:03 +00:00
claude-botandClaude Opus 5 fac46aa7ac fix(heating-can): self-test at build time instead of a CI step
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The smoke-test step I added errored the whole pipeline before any step ran.
It used the freshly built image as its own step image, but the Gitea registry
is private: the build steps authenticate kaniko for PUSH, nothing gives the
runner credentials to PULL. So the step could never have worked.

Moved into the Containerfile as a build-time RUN, which is better anyway --
it needs no registry credentials, and a broken image is never published
rather than published and then flagged.

It asserts what the previous revision got wrong: both services actually
execute, and supervisord's config carries both programs. That is exactly the
failure that shipped green -- supervisord pointed at
/opt/e3oncan/src/E3onCANcollect.py, which does not exist (the file is at the
repo root), so the image would have run open3e while e3oncan crash-looped
and the E380 meter went silently missing.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:13:59 +02:00
claude-botandClaude Opus 5 6fb760edae fix(heating-can): correct the e3oncan path, and smoke-test the image in CI
The image shipped a path that does not exist. supervisord ran
`python /opt/e3oncan/src/E3onCANcollect.py`, but that file is at the repo
ROOT -- there is no src/ directory. The `src` came from the old Ansible task,
which cloned into `{{ edge_base_dir }}/e3oncan/src`; that was the clone
DESTINATION, not part of the upstream layout, and I carried it into the
Containerfile without checking. Confirmed against the upstream tree at the
pinned commit: E3onCANcollect.py sits at the root alongside requirements.txt,
which is why the build succeeded while the runtime path was wrong.

So the image built green and would have started open3e and failed e3oncan on
every restart -- the E380 meter silently absent, with supervisord retrying
forever.

Nothing caught that, because nothing ran the image. Now CI does: a smoke-test
step uses the freshly built image as its own step image and asserts both
services execute and supervisord's config carries both programs.

It cannot exercise the CAN bus or the broker, neither of which exists on a
runner, so it verifies exactly what is verifiable without hardware. That is
still the difference between "built" and "works" -- and it belongs on a
throwaway runner, not on the production edge host, which is where I wrongly
went looking for this the first time.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:12:03 +02:00
claude-botandClaude Opus 5 96fcb426aa build(heating-can): one container, both readers supervised inside it
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Reverts my own reinterpretation. The instruction was to put both in one
container because they belong together; I argued against it on crash-isolation
grounds, then read the correction "two services, one image" as agreement with
my version rather than as a restatement of the original. It was not.

A container runs one PID 1. With two processes that PID 1 is a supervisor,
which is how a container handles multiple services. supervisord now runs both
readers: it restarts either on its own if it dies, reaps children and forwards
shutdown -- none of which a bare process as PID 1 can do for a sibling, which
was the actual answer to the concern I raised.

Also gone: the dispatch entrypoint and the argv in the compose file. Both were
the same mistake in different places -- the invocation living outside the
artifact. open3e's and E3onCAN's command lines are an implementation detail of
this image now; supervisord builds them from environment variables it expands
itself. Nothing outside the image knows either CLI, so an upstream flag rename
cannot break the compose file or an Ansible template.

The compose file supplies configuration and nothing else: one service, one
container, environment only. The credential env file is dropped -- the
credentials are environment already.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:06:43 +02:00
claude-botandClaude Opus 5 56a9859e50 refactor(heating-can): run the image, install nothing on the node
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing is installed on the host any more. The image is built in CI with
both sources pinned, published to the registry, and edge-1 pulls and runs
it. That is the whole point of building it -- installation happens at build
time, once, not on every converge.

Removed from the node: both venvs, the E3onCAN checkout, and the two systemd
units. Kept and still rendered: devices.json, the vx3 datapoint overlay and
the broker credentials, now bind-mounted into the containers rather than
read from a venv working directory.

Two services from one image, as before: open3e drives the Vitocal/VX3 over
UDS, e3oncan listens passively for the E380 meter. Separate containers so
either can crash and restart without a supervisor as PID 1.

The migration stops AND masks the old units before the containers start.
Masked rather than disabled because two open3e instances issuing UDS
requests on one CAN bus is the failure mode to avoid, and a stray
`systemctl start` or leftover dependency should not be able to bring the
native one back alongside the container.

No missing-device tolerance here, unlike the zwave and meter roles: can0 is
this host's purpose. If it is absent the heating telemetry is down and the
run should say so rather than pass quietly.

heating_can_image replaces edge_open3e_pkg / edge_e3oncan_repo /
edge_e3oncan_version. The source pins move into the Containerfile, so what
is deployed here is a tag -- and `docker compose up -d` on an unchanged tag
is a genuine no-op, which is what removes the restart-per-converge that
pinning the pip URL could not.

Verification is limited and worth stating: plays/install-heating-can.yml
lives in #475 and is not on this branch, so only the master-side play
syntax-checks here. A --check run cannot exercise this at all -- command
tasks are skipped under check mode, so neither the pull nor the compose up
would run. It cannot be meaningfully verified until the image has been built
and #475 is merged.

Depends on #475 (the capability-split playbooks, and the MQTT contract
moving to group_vars). This branch is cut from master, so heating-can's
defaults will conflict there; #475 should merge first and this be rebased
onto it.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:59:45 +02:00
claude-botandClaude Opus 5 4df1018e05 build(heating-can): make both services first-class commands in the image
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The image had no ENTRYPOINT and no CMD, with a comment claiming that was
deliberate because the commands are rendered by Ansible. That was
rationalising a defect.

An image with nothing runnable is not a self-contained artifact. The
knowledge of how to START each service leaked into Ansible, and the two were
not even symmetric: open3e installs a console script via pip, while E3onCAN
is an unpackaged script in a checkout, so the compose file would have had to
carry `python /opt/e3oncan/src/E3onCANcollect.py` -- an implementation detail
of this image's internal layout, in a file that has no business knowing it.
Move the checkout and every caller breaks.

E3onCAN now gets a wrapper on PATH, so both services are invoked the same
way and `docker run <image> e3oncan --help` works.

CMD prints what the image provides and exits 64 (EX_USAGE) rather than
silently doing nothing, so `docker run <image>` is self-describing. Which
service to run, and its arguments, stays the caller's choice -- the compose
file renders those, because the datapoint list and broker settings live
there.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:54:18 +02:00
claude-botandClaude Opus 5 0e4d516722 build(heating-can): image for the CAN readers, replacing pip-into-venv
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Builds one image containing open3e (Vitocal heat pump, Vitocharge VX3) and
E3onCAN (E380 grid energy meter), both pinned in the Containerfile.

Why: both were pip-installed into venvs on edge-1, open3e straight from an
unpinned git URL. pip cannot compare an installed VCS dist against a
requested one without fetching it, so `pip install` re-cloned and rebuilt on
every Ansible converge -- reporting changed, firing the restart handler, and
putting a gap in heating telemetry each time. Pinning the SHA does not fix
that; verified directly on the host that pip still clones and rebuilds a
pinned VCS requirement. A tagged image does: `docker compose up -d` on an
unchanged tag is a genuine no-op.

It also gives the weekly update-audit a normal artifact instead of two
awkward special cases. open3e publishes no tags at all, and E3onCAN is
pinned to an untagged commit sitting between its newest tag and HEAD -- both
of which the audit currently has to be told about in prose.

One image, two services. They read the same physical installation over the
same bus and are versioned together, so they are one artifact; but they will
run as two containers from it, so either can crash and restart on its own
without a supervisor as PID 1.

Fixes a long-standing blind spot in passing: e3oncan.service set
PYTHONUNBUFFERED=1 and open3e.service did not, so open3e's stdout was
block-buffered and effectively never reached the journal or Loki. The image
sets it for both.

Build follows the existing kaniko pattern exactly -- path-scoped `when:` so
only this image rebuilds, date tag plus latest, pushed to the Gitea registry
alongside the ci-* images.

Deliberately artifact-only. The heating-can role still deploys venvs and
systemd units; moving it to a compose project is a separate change, once
this image exists and has been pulled and run once. Rewriting the role that
drives the heat pump against an image that has never been built would be the
wrong order.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:52:53 +02:00
k.renner 603db4ed95 Merge pull request 'fix(edge-host): make the bootstrap self-contained and the runbook followable' (#474) from edge-bootstrap-selfcontained into master
ci/woodpecker/cron/update-all Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#474
2026-08-08 10:39:47 +00:00
claude-botandClaude Opus 5 6cfa63f6f3 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
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>
2026-08-08 12:27:13 +02:00
claude-botandClaude Opus 5 9cc20251fd 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
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>
2026-08-08 11:47:39 +02:00
claude-botandClaude Opus 5 2d9c3ba213 refactor(edge): split the edge-host role into one role per concern
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
edge-host was a single role doing eight unrelated jobs: flash-wear
mitigation, log shipping, VLAN networking, an MQTT bridge, a CAN interface,
the heating CAN services, a Z-Wave gateway and a power-meter reader.

That is not just untidy. It is what blocked a second thin client from
joining as an NTP peer: the host-generic work (journald to RAM, Alloy, the
VLAN sub-interface) could not be applied without also handing the box a
Z-Wave radio and a CAN bus it does not have. The role bound its services to
specific USB device paths, so a second host would have tried to start
containers for hardware that is not there.

Now:

  edge-flash-wear   journald -> RAM, noatime/lazytime, TRIM, eMMC health
  edge-log-shipper  Alloy: journal -> Loki
  edge-vlan         tagged VLAN sub-interface
  mqtt-bridge       loopback mosquitto -> cluster broker over TLS
  socketcan         can0 only
  heating-can       open3e (Vitocal/VX3) + e3oncan (E380 meter)
  zwave-gateway     zwave-js-ui, own compose project
  power-meter-sml   sml2mqtt, own compose project

The E380 reader lives with open3e rather than in its own role: it is part
of the same heating installation -- it feeds the heat pump's own energy
management -- not a separate system that happens to share a bus. can0 does
stay separate, because an interface is not the same concern as the services
consuming it.

Each role installs its own packages, so it stands alone. An edge host with
no peripherals takes the first five and none of the rest.

docker-daemon.json moves to docker-install with its own docker_log_driver
variable and Restart docker handler, since configuring the docker daemon was
never an edge-host concern. Handlers split ten ways; defaults split per
role; all sixteen templates and files moved with git mv.

Two deliberate behaviour changes on the node, both accepted:

1. Compose split. zwave-js and sml2mqtt shared a compose file, so touching
   either recreated both. They are now separate projects in their own
   directories. container_name is fixed, so the old project must release the
   names first -- a guarded one-time task downs it and removes the file.
   First apply costs a brief down/up on both. Data paths are unchanged; the
   zwave store is not migrated.

2. Header repointing. Templates and scripts carried "Managed by
   roles/edge-host" headers, which render onto the host -- they would have
   sent an operator to a role that no longer exists. Updating them changes
   file content, so it restarts the services that consume those files.

Verified by --check --diff against the live host. 24 changed items, and
every one is accounted for:

    5  pre-existing, unchanged from the baseline before this work:
       common timesyncd; open3e venv; E3onCAN venv; and the open3e /
       e3oncan restart handlers. The venv tasks install from a git URL,
       which pip cannot verify, so they report changed on every run --
       a separate defect, not introduced here.
    5  the compose split: zwave env + compose and sml compose to their
       new paths, plus the two legacy-project retirement tasks.
   14  the header repointing: journald conf, eMMC health script, alloy
       config, mosquitto bridge config, 80-can0.network, open3e overlay /
       env / unit, e3oncan unit, zwave backup script + env, sml2mqtt
       config -- and the resulting restarts of journald, alloy, mosquitto,
       open3e and e3oncan.

Nothing changed outside those three groups.

One consequence check mode cannot show: 80-can0.network.j2 changed, so the
"Reload networkd" handler fires on a real run and reconfigures can0,
briefly dropping the bus under open3e and e3oncan. It is absent from the
check-mode output only because shell handlers are skipped there. The VLAN
templates did NOT change, so the riskier reconfigure of the parent uplink
on this headless host is not triggered.

Also fixed in passing: the keepalived-ntp guard told operators to recover
with `--limit <host> --tags vlan40`, and referred to the edge-host role.
Both corrected. The role README becomes ansible/docs/edge-host.md -- it
documents the host, not any single role now.

Co-Authored-By: Claude Bot <claude-bot@renner.uno>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:33:57 +02:00
claude-botandClaude Opus 5 07faf0ae69 fix(edge-host-iso): carry the bootstrap fixes into the ISO side too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-08-08 08:21:27 +02:00
claude-botandClaude Opus 5 e288ebe39d fix(edge-host): make the bootstrap self-contained and the runbook followable
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-08-08 08:07:15 +02:00
k.renner 5b03d6b7af Merge pull request 'refactor(prometheus): address the PVE nodes by name in scrape targets' (#473) from prometheus-pve-hostnames into master
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#473
2026-08-08 05:11:29 +00:00
security-bot 343e4845e2 chore(image-automation): bump gotenberg/gotenberg:8.35.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-07 19:34:25 +00:00
security-bot 5d56c35017 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.8.1
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-07 18:36:02 +00:00
claude-botandClaude Opus 5 17c12fe13f refactor(prometheus): address the PVE nodes by name in scrape targets
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Follow-up to #472, which changed the inventory but left the same three IPv4
literals in the monitoring config and a runbook. Raised in review there.

prometheus.yml.j2:
  - pve job targets -> FQDNs
  - chrony job targets -> FQDNs, and restructured to one static_config per
    host so each carries its own hostname label

The chrony restructure is the substantive part. The previous form listed all
three targets together and recovered the hostname in relabel_configs by
regex-matching __address__ against each node's IP:

    - source_labels: [__address__]
      regex: '10\.2\.40\.201:.*'
      target_label: hostname
      replacement: 'jupiter'

That silently coupled the labels to the addressing. Swapping a target for a
name would have left every regex unmatched and the hostname label simply
absent, with the scrape still succeeding and nothing failing -- a
find-and-replace on the addresses alone would have broken the labels
quietly. Per-host static_configs remove the coupling entirely.

nodes.yml.j2: targets -> FQDNs. Already one entry per host with an explicit
hostname label, so nothing else to change.

claude-workstation/RUNBOOK.md: the kubeconfig command pointed --server at
https://10.2.40.201:6443. That is jupiter, a Proxmox host, not a control
plane node -- stale from when k8s ran as VMs on it. The cluster's endpoint
is https://k8s.renner.uno:6443 (kubernetes/talos/patches/cluster.yaml), and
the name is also what the API server certificate carries as a SAN, so an
address there fails TLS verification even when reachable. The documented
command was very likely broken as written.

Deliberately NOT changed: kubernetes/apps/pve/endpointslice.yaml. An
EndpointSlice takes literal addresses; hostnames are not valid in that
field, so those three are correct as they are.

Note this changes the `instance` label on the pve job from an address to a
name, since the relabel chain feeds __address__ into __param_target and on
to instance. Anything selecting on the old value needs updating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:52:49 +02:00
k.renner 332950b564 Merge pull request 'refactor(inventory): address the PVE nodes by name, not by IPv4 literal' (#472) from inventory-pve-hostnames into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#472
2026-08-07 17:49:05 +00:00
claude-botandClaude Opus 5 ba369a37b0 refactor(inventory): address the PVE nodes by name, not by IPv4 literal
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
jupiter, neptune and saturn were the last entries in pve_hosts.yml still
reached via ansible_host=<IPv4 literal>. Every other host here --
authentik, netbird, proxy, nas-1, grafana, edge-1 -- has always used its
FQDN, so these three were the outlier rather than the convention.

An address in an inventory is allocator state standing in for identity, and
it pins access to the legacy family: the names resolve IPv6-first, which is
the primary path on this network. DNS is authoritative for where a host is;
the inventory should say which host.

The short inventory_hostname is kept deliberately -- plays key on it
(ntp_servers[inventory_hostname] in install-ntp-ha.yml, node names in
opentofu, the prometheus targets) -- so only ansible_host changes.

No claim that this fixes the connection stalls seen while applying the NTP
work on 2026-08-07. Those were the IPS reacting to the connection pattern I
was generating (a port sweep, then rapid repeated SSH across hosts
interleaved with Ansible's own churn), not an address-family problem. This
change stands on convention and on not encoding a lease as identity.

claude-ws is left alone: its comment documents that obg.renner.uno is
split-horizon and resolves to the public proxy from home. That explains why
that particular name is unusable, not why a literal is right -- a name that
resolves to the wrong target calls for one that resolves correctly. Out of
scope here, and I do not know whether such a record exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:44:54 +02:00
k.renner eacc821222 Merge pull request 'fix(chrony): allow IPv6 clients -- the allow list was IPv4-only' (#471) from chrony-allow-ipv6 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#471
2026-08-07 17:35:07 +00:00
claude-botandClaude Opus 5 24ed2485c9 fix(chrony): allow IPv6 clients -- the allow list was IPv4-only
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
chrony_allowed_networks contained only 10.2.0.0/16, so the rendered
chrony.conf permitted IPv4 clients and nothing else. IPv6 is the primary
address family here: ntp.srv.renner.uno resolves to fd40::123, so an
IPv4-only allow list refuses every client that uses the documented name.

This was live but invisible. The hosts carried `allow fd00::/8` in
chrony.conf for a long time without it ever being in this repository.
Applying the role on 2026-08-07 rendered the template over the live config
and dropped the line on all four NTP servers at once, taking internal NTP
down over IPv6 until it was added here.

Same class of untracked host state as the anycast service address, and the
same failure: config that exists only on the box survives right up until
something re-renders it. Reading the drifted file is not enough -- the rest
of it has to be diffed against what the template would produce.

The outage was not caught by the play, which reported the v6 master as
"serving ntp.srv.renner.uno" throughout. It was caught by querying the
service:

  chronyd -Q "server ntp.srv.renner.uno iburst"
    -> No suitable source for synchronisation

and after the fix:

  chronyd -Q "server ntp.srv.renner.uno iburst"
    -> System clock wrong by 0.001999 seconds (ignored)

with a control query against ptbtime1.ptb.de to confirm the method rather
than the target was sound.

fe80::/10 is included as well: a client on the same segment may source its
query from a link-local address, and without it chrony drops the request,
which presents as the server being unreachable rather than as a policy
refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:33:22 +02:00
k.renner 6628f6eaef Merge pull request 'fix(ntp): link-local VRRP primary, and sequence the migration around DAD' (#470) from ntp-vrrp-linklocal-and-dad-sequencing into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#470
2026-08-07 17:18:11 +00:00
claude-botandClaude Opus 5 ea439b110d fix(ntp): link-local VRRP primary, and sequence the migration around DAD
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Applying #469 to edge-1 exposed two defects that only show up on real
hardware.

1. The VRRP address was unusable.

   keepalived took MASTER and assigned fd40::123, but the kernel left it:

     inet6 fd40::123/128 scope global dadfailed deprecated tentative
           proto keepalived
           valid_lft forever preferred_lft 0sec

   `-nodad` did not take effect -- no nodad flag on the address, and
   net.ipv6.conf.enp1s0/40.accept_dad = 1 -- so DAD ran and found a real
   duplicate: jupiter, saturn and neptune still held the address statically.
   edge-1 believed it was the v6 master while serving nothing.

   The flag is dropped rather than fixed. It was inherited from the previous
   design, where every NTP server carried the address at once and duplicate
   detection had to be suppressed for that to work. Under single-master VRRP
   the opposite holds: exactly one host should have it, so a duplicate is a
   genuine fault and detecting it is the point.

   What that costs is that the migration can no longer be done host by host.
   A static copy anywhere and a VRRP copy elsewhere cannot coexist, and the
   dadfailed state is sticky -- the kernel does not retry. install-ntp-ha.yml
   is therefore split in two: a first play withdraws every static assignment
   across the whole group, then the second hands the address to keepalived.
   Deliberately not serial, since it must finish everywhere before any host
   claims the address. There is a gap between the phases where nobody holds
   it, bounded by one play's run time; clients retry.

   The withdrawal tasks move to roles/keepalived-ntp/tasks/withdraw-static.yml
   for the first play to include. main.yml keeps the detect-and-assert guards,
   which still run every time and fail loudly if any static assignment
   survives.

2. keepalived warned:

     (NTP_VIP6) the first IPv6 VIP address should be link local

   RFC 5798 gives an IPv6 virtual router a link-local primary. keepalived
   carried on regardless, but the instance was sourcing VRRPv3 from something
   the protocol does not expect. fe80::123 is now the first
   virtual_ipaddress: it mirrors the service address and cannot collide with
   a NIC's EUI-64 link-local.

ntp_vip6 and ntp_vip6_linklocal move to group_vars/ntp_cluster.yml so both
plays read one definition rather than duplicating a service address.

Verified: `keepalived -t` on edge-1 (keepalived 2.4.1) parses the two-address
form clean, and the playbook passes --syntax-check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:07:22 +02:00
k.renner b1c06b9d43 Merge pull request 'feat(ntp): hold the IPv6 service address in keepalived, gated on clock health' (#469) from ntp-service-address-health-gated into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#469
2026-08-07 11:27:30 +00:00
security-bot 436c24a394 chore(image-automation): bump ghcr.io/music-assistant/server:2.9.11
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-07 02:34:14 +00:00
claude-botandClaude Opus 5 a1e3b65cbf fix(ntp): verify the ifupdown side unconditionally, not only after a match
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review on #469 found the Debian path still fails open, and was right that
my previous commit message was wrong to claim otherwise.

The whole Debian chain -- detect, withdraw, confirm, assert -- was gated on
`keepalived_ntp_static6_removed.changed`, which is only true when the
lineinfile regex matches exactly, prefix length included. The hook it
targets is hand-written and was never in version control, so any deviation
(a different mask, unusual whitespace, a second assignment elsewhere)
leaves .changed false and silently skips every following task. The static
assignment then coexists with the VRRP-managed one, which is the
double-assignment failure this PR exists to prevent -- just relocated from
edge-1 to a PVE host.

Same fail-open class as the Arch grep fixed in 5e01e2f1. The argument in
that commit message -- that the Debian path was safe because
`ip -6 addr show ... to <prefix>` matches by range -- is true of the runtime
checks but says nothing about the lineinfile text match, which is a
different mechanism and was exactly as fragile as the pre-fix Arch grep.

Fixed by checking the CONFIG FILE unconditionally, prefix-agnostic and
\b-anchored, mirroring the networkd check.

Note this is deliberately not the fix the review proposed. It suggested
running the runtime `ip -6 addr show ... to <addr>` check and its assert
unconditionally. That would break the PR: after migration the address is
legitimately present on whichever host is the elected v6 master, so an
unconditional "must be absent" assert fails on the master on every run. The
config file is the right thing to check every time precisely because
keepalived never writes there -- anything found is by definition static,
whereas the live address is expected on the master. The runtime withdrawal
stays gated on .changed, which remains correct for it.

Verified on jupiter:
  grep -rlE 'fd40::123\b' /etc/network/interfaces /etc/network/interfaces.d/
    -> /etc/network/interfaces

Sequencing holds: on the migration run the lineinfile removes the hook
first, so the grep finds nothing and the assert passes; if the regex does
not match, the grep finds it and the assert fails loudly instead of
skipping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:29:53 +02:00
claude-botandClaude Opus 5 5e01e2f1b5 fix(ntp): detect a static networkd assignment at any prefix length
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Arch guard added in the previous commit grepped for the literal
"fd40::123/128" -- the full address/prefixlen from ntp_vip6. A hand-written
[Address] using any other mask (/64, say) would not have matched, the
assert would have passed, and keepalived would have been handed an address
networkd was still assigning.

That fails open, which is the wrong direction for this particular check.
It is detect-only: there is no delete afterwards whose result gets
re-verified, so a miss is silent, and the outcome is the dual-ownership
state the guard exists to prevent.

The Debian path does not share the problem. `ip -6 addr show ... to
<prefix>` matches by range regardless of the prefix length the address was
actually assigned with, and its `ip addr del` -- which IS prefix-sensitive
-- is re-verified by a following assert, so a mismatch there fails loudly.

Now matches the bare address, anchored with \b so it cannot instead hit a
longer address sharing the prefix. Verified both directions:

  grep -rlE 'fd40::123\b' /etc/systemd/network/   on edge-1
    -> /etc/systemd/network/10-enp1s0.40.network  (still detected)
  grep -cE 'fd40::123\b' against 'Address=fd40::1234/64'
    -> 0                                          (no false positive)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:11:36 +02:00
claude-botandClaude Opus 5 40ef53fa67 fix(ntp): assert the static service address is really gone before handing over
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Second review round on #469 found two ways the migration could silently
leave the address outside VRRP control -- both of which would defeat the
health gating while the play reported success.

1. The one-shot migration had no verification.

   Gating the withdrawal on `keepalived_ntp_static6_removed.changed` is what
   makes it idempotent, but it also means it gets exactly one attempt and
   never retries. Combined with `failed_when: false` on the delete, a
   mismatch would pass silently: `ip addr del` matches on address AND prefix
   length, so a host whose static assignment used a different mask or
   interface than ntp_vip6 / keepalived_ntp_interface would see the delete
   no-op, keep the address permanently outside VRRP control, and still
   report success.

   The idempotency fix from the previous commit created this: making the
   task run once removed the retry that would otherwise have masked it.

   Now asserted explicitly after the delete, with a message naming the
   likely cause and the commands to inspect it.

2. edge-1 -- the host the incident actually happened on -- was not covered.

   The removal tasks are gated to Debian, so they never touch edge-1 (Arch).
   Its static [Address] block belongs to the edge-host role, and
   install-edge-host.yml is a separate play with no ordering relationship to
   this one. Nothing guaranteed it had been re-applied.

   edge_host already sets ntp_keepalived_enabled: true, so the next run of
   install-ntp-ha.yml gives edge-1 the NTP_VIP6 instance immediately. With
   the [Address] block still present it would hold fd40::123 statically AND
   run VRRP for it: keepalived would believe it had withdrawn the address
   while networkd kept it assigned, and the host would go on answering NTP
   with a clock check_ntp had already rejected. That is the failure of
   2026-08-04, on the same host.

   This is not hypothetical -- edge-1 still has the block right now:
     grep -rl fd40::123/128 /etc/systemd/network/
     -> /etc/systemd/network/10-enp1s0.40.network

   The role now detects this and fails fast, naming the play to run first,
   rather than proceeding into a split-ownership state.

Also documents why the post-deploy presence check is meaningful: presence
alone cannot distinguish a VRRP-held address from a static leftover, and
only means what it claims because the role asserts no static assignment
survives on either distro.

Not taken: narrowing serial: 1 so routine runs are not serialised through
the chrony settle pause. Real cost, but correctness of the address handover
comes first at four hosts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:02:26 +02:00
claude-botandClaude Opus 5 fbf5c4dcb8 fix(ntp): make the static-address withdrawal run once, not every play
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review on #469 caught a real defect. The withdrawal task used
ansible.builtin.command with no changed_when, so it reported changed and
fired `notify: restart keepalived` on every run where the address was
present.

That is fine exactly once. After this lands, fd40::123 is legitimately
present on whichever host is the elected v6 master, put there by keepalived
-- and an ungated delete cannot tell that apart from a static assignment,
because the address looks identical either way. So every later run of this
play (adding a host, changing a priority, plain re-convergence) would have
deleted the master's address and restarted keepalived, dropping the service
address and forcing a re-election each time. That is precisely the flapping
this change exists to prevent.

Both the check and the delete are now gated on the ifupdown hook having just
been removed, so they run once during the migration off static config and
never again. Steady-state runs leave the master's address alone.

Also from review:

  - failed_when: false on the delete. The desired end state is "address
    absent". If check_ntp fails in the window between the check and the
    delete, keepalived withdraws the address first and `ip addr del` errors
    on an address that is already gone -- the outcome the task wants, and no
    reason to abort the play for that host.

  - The play's post-deploy verification only ever checked the IPv4 VIP. The
    v6 address is the path clients actually use, so a broken v6 rollout --
    address never claimed, or -nodad not behaving as expected on a given
    kernel -- would not have surfaced in the play at all. It now gets the
    same check, and the message points at check_ntp and the health log for
    the case where no host holds it.

Not taken: the suggestion to narrow serial: 1 so it does not also serialise
chrony. With four hosts it costs nothing, and scoping it per-role would mean
splitting the play. Worth revisiting if the cluster grows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:28:21 +02:00
claude-botandClaude Opus 5 112299da49 fix(ntp): remove the static service address so keepalived can own it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Without this the health gating is defeated on three of the four NTP
servers. fd40::123 was assigned statically on every one of them, by hand
and in two different ways, none of it in version control:

  PVE nodes  `up ip -6 addr replace fd40::123/128 dev vmbr1.40 nodad`
             in /etc/network/interfaces
  edge-1     an [Address] block in /etc/systemd/network/

A statically assigned copy does not disappear when keepalived decides the
host is unfit to serve time. Leaving it in place means the host keeps
answering NTP on an address VRRP believes it has withdrawn -- exactly the
failure this whole change exists to prevent, with the added confusion of
keepalived reporting the address as released.

The ifupdown hook is removed here. edge-1 needs nothing: the edge-host
networkd template has never carried the address, so applying that role
drops it.

The live address is deleted as well, because removing the config line alone
does not take effect until the next network reload, and a reload on a PVE
node interrupts guest connectivity including the Talos VM and its Ceph OSD.
Deleting it is safe where keepalived already holds it: the deletion
notifies a keepalived restart, and the elected master re-adds it on the way
back up. Doing it unconditionally avoids having to tell "assigned
statically" from "assigned by keepalived", which the address alone does not
reveal.

The play now runs serial: 1. Withdrawing the address and restarting
keepalived on every NTP server at once would leave the fleet with nobody
holding fd40::123 until the elections settle; serialised, the remaining
members answer throughout.

Verified against the live hosts rather than assumed:
  - all three PVE nodes carry the identical hook (jupiter:54, saturn:55,
    neptune:56), so one task covers them
  - the removal regexp matches exactly one line per host
  - `ip -6 addr show dev <if> to <addr>` prints the matching address when
    present and nothing at all when absent, so the guard condition is sound

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:06:43 +02:00
claude-botandClaude Opus 5 01e843beef fix(ntp): apply the v6 service address to every ntp_cluster member
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The previous commit set keepalived_ntp_vip6 in group_vars/edge_host, which
would have given the v6 VRRP instance to edge-1 alone. The other NTP
servers would have rendered no NTP_VIP6 instance at all and stayed silently
outside the election -- so the address could never fail over to them, and
the health gating would have amounted to "edge-1 withdraws and nobody takes
over".

Moves it alongside ntp_vip in plays/install-ntp-ha.yml, which is already
where the service-wide address lives, and passes it to the role for every
host in ntp_cluster.

Verified that edge-1 is currently BACKUP and does not hold the IPv4 VIP: a
VRRP BACKUP receiving no advertisements promotes itself to MASTER, so the
other members are alive and one of them holds it. They are real
participants, not retired hosts.

Not verified: whether the other members also carry fd40::123 statically, as
edge-1 did. They have no DNS records -- jupiter/saturn/neptune resolve only
through the *.renner.uno wildcard to the proxy -- so there is no hostname
path to them to check. If they do carry it statically, that address must be
removed at the same time this lands, or two hosts will hold it outside VRRP
control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:07:05 +02:00
claude-botandClaude Opus 5 2fbce12b0b feat(ntp): hold the IPv6 service address in keepalived, gated on clock health
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
ntp.srv.renner.uno resolves to fd40::123 (IPv6) and to the keepalived VIP
(IPv4, legacy). IPv6 is the primary family here, so the v6 address is the
path clients actually use -- and it was the one with no health gating at
all.

It was assigned statically in /etc/systemd/network/ on edge-1, so networkd
carried it unconditionally from boot. That is how the host answered NTP
queries on 2026-08-04 while its clock was ~16 days in the past: chronyd was
running, udp/123 was listening, the address was up, and nothing anywhere
was checking whether the clock was right. Fixing the keepalived health
check does not help, because keepalived was not holding that address.

This moves it into keepalived as a second VRRP instance tracking the same
check_ntp script, so the address exists only while the host is fit to serve
time.

Three details that are load-bearing rather than stylistic:

  - A separate vrrp_instance is mandatory. keepalived requires that "all
    addresses in virtual_ipaddress must be of the same family", so the v6
    address cannot join NTP_VIP.

  - No authentication block. VRRPv3 removed authentication (RFC 5798) and
    keepalived always uses version 3 for IPv6 instances.

  - track_script uses `weight 0`, overriding the script's -20. A non-zero
    weight only subtracts from priority on failure, so the last remaining
    host -- having nobody to lose the election to -- would stay MASTER and
    keep serving a clock it had already reported as wrong. Weight 0
    transitions the instance to FAULT instead, releasing the address
    unconditionally. That is the required behaviour: a host unfit to serve
    time must stop answering whether or not a replacement exists.

Semantic change, deliberate and worth stating: VRRP is a single-master
unicast VIP, not anycast. Previously every NTP server carried fd40::123 and
clients reached whichever answered neighbour discovery first. Now exactly
one host holds it. For NTP this is the better trade -- query volume makes
load spreading pointless, and on failover keepalived sends unsolicited
Neighbour Advertisements so client neighbour caches update in seconds,
where the static address had no withdrawal signal at all and clients kept
using a dead holder until their own neighbour unreachability detection
expired. True routed anycast with health withdrawal would mean announcing
the /128 via an IGP, which the gateway would have to speak.

The edge-host role is deliberately untouched: its template already has no
[Address] block, so applying it removes the hand-placed address, and
keepalived then adds it back under health control. The service address is
NTP-role configuration and now lives there.

Validated with `keepalived -t` against keepalived 2.4.1 on edge-1 using the
config as it renders for that host; parses clean apart from the pre-existing
auth_pass truncation warning on the IPv4 instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:59:40 +02:00
k.renner ea54e558fd Merge pull request 'fix(ntp): make the keepalived health check verify the clock, not liveness' (#467) from ntp-health-clock-correctness into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#467
2026-08-05 17:49:35 +00:00
k.renner d39115a55d Merge pull request 'docs(chrony): correct the root cause recorded for the makestep fix' (#466) from chrony-comment-correction into master
Reviewed-on: k.renner/homelab#466
2026-08-05 17:49:08 +00:00
claude-botandClaude Opus 5 601290dcd9 fix(ntp): make the keepalived health check verify the clock, not liveness
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The NTP health check asked "is chronyd alive", never "is this host safe to
take time from". Those came apart on 2026-08-04.

edge-1's CMOS battery is dead. After a power loss its clock came up ~16
days in the past and, with no makestep directive, chronyd never corrected
it. All three health checks passed continuously for ~18h:

  - chronyd was active
  - `chronyc tracking` responded
  - udp/123 was listening

and `local stratum 10` meant chronyd advertised itself to clients as a
valid reference the whole time. keepalived would have kept the VIP on a
host ready to serve a clock from July.

The sharpest part: check 2 ran `chronyc tracking >/dev/null 2>&1` and
discarded the output. The one command that knew the answer was executed
and its answer thrown away. During the incident that output read:

  System time     : 1376235.375000000 seconds slow of NTP time

This captures the output and adds two assertions:

  - offset above keepalived_ntp_max_offset (default 1.0s) fails, so a host
    that knows its clock is wrong drops the VIP
  - no upstream source selected in `chronyc sources` fails, because with
    `local stratum N` chronyd advertises itself as a usable reference even
    when it has never synchronised to anything -- upstream chrony docs are
    explicit that the local reference activates immediately in that case

Both predicates were validated against live output from edge-1 rather than
assumed: the offset comparison against the real incident value, and the
`chronyc sources` selected-source column against a real run.

Failing the check costs the configured weight (-20). When the WAN is down
every host fails together and loses the same weight, so relative priority
is unchanged and the VIP stays put -- the local-reference fallback still
works. What this prevents is one host with a broken clock winning.

Scope: this hardens the keepalived path, which is legacy. It does not
address the documented end state in plays/install-ntp-ha.yml, where edge-1
becomes the SOLE NTP server -- there, a failing check means no host serves
time at all, converting "serves the wrong time" into "serves no time".
That needs an architectural answer, not a better health check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:27:59 +02:00
claude-botandClaude Opus 5 32d12f93d8 docs(chrony): correct the root cause recorded for the makestep fix
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#465 landed the makestep directive with the wrong root cause in its
comment. It claimed edge-1 "rebooted with an RTC ~16 days in the past".
That is false, and the real cause matters for what to do next.

edge-1's CMOS battery is dead. After a power loss the RTC came up reading
2018-08-20 -- roughly eight years stale, not 16 days:

  PM: RTC time: 09:18:56, date: 2018-08-20
  rtc_cmos 00:01: setting system clock to 2018-08-20T09:18:58 UTC

The clock was then reconstructed entirely in software. systemd floored it
forward to its own build date:

  systemd[1]: System time advanced to built-in epoch: Fri 2026-06-26 11:27:23 UTC

and it settled at ~2026-07-19, about 16 days behind. So the 16 days is
incidental -- it is just where the flooring landed. There is no 16-day
mechanism, and nothing "drifted". The two real faults are a dead RTC
battery and a chrony config that never steps. #465 fixed the second.

Also collapses the duplicated rationale in chrony.conf.j2 to a pointer at
defaults/main.yml, as suggested in review on #465, so the explanation
lives in exactly one place.

No behaviour change: the makestep directive and its defaults are
untouched. Comments only.

The commit message on 7fcaa4b0 still carries the wrong explanation and is
left alone rather than rewriting merged history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:35:51 +02:00
security-bot 0fd92f2fa2 chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.8.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-08-05 15:35:09 +00:00
k.renner d41f4870ad Merge pull request 'fix(chrony): step the clock at boot so a bad RTC self-corrects' (#465) from worktree-chrony-makestep into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#465
2026-08-05 15:14:21 +00:00
claude-botandClaude Opus 5 7fcaa4b035 fix(chrony): step the clock at boot so a bad RTC self-corrects
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
chrony.conf.j2 had no makestep directive, so chronyd only ever slewed.
A large offset is therefore never closed in practice.

On 2026-08-04 edge-1 rebooted with its RTC ~16 days in the past. chronyd
was synchronised to ptbtime1 at stratum 2 with a sub-millisecond RMS
offset, yet reported "System time: 1376235.375 seconds slow of NTP time"
and corrected none of it. With host time at 2026-07-20, every outbound
TLS handshake failed x509 NotBefore validation against a cluster
certificate issued 2026-07-23:

  tls: failed to verify certificate: x509: certificate has expired or is
  not yet valid: current time 2026-07-20T16:15:36+02:00 is before
  2026-07-23T06:15:01Z

That took out the local mosquitto -> cluster MQTT bridge (all Vitocal
250-A / Vitocharge VX3 / E380 heating telemetry went unavailable in Home
Assistant for ~18h) and the Alloy -> Loki log shipper simultaneously.
Because the same skew broke log shipping, the outage was invisible to
log-based alerting.

Recovery required a manual `chronyc makestep`. Adding the directive lets
this correct itself at boot instead.

The limit keeps steps confined to the first few updates after chronyd
starts, so a long-running NTP server is never jumped out from under its
workloads mid-flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:35:18 +02:00
k.renner 11bf13883f Merge pull request 'ci: validate the GitOps tree and pipeline configs on every PR' (#460) from ci-k8s-validate into master
ci/woodpecker/cron/update-all Pipeline failed
ci/woodpecker/cron/update-audit Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#460
2026-08-01 10:20:58 +00:00
security-bot 4b502daa98 chore(image-automation): bump woodpeckerci/woodpecker-server:v3.17.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-07-31 14:33:28 +00:00
security-bot 83a20bfa80 chore(image-automation): bump woodpeckerci/woodpecker-agent:v3.17.0
Updates managed by image-automation-controller.
2026-07-31 14:33:17 +00:00
security-bot c1a1a59ecc chore(image-automation): bump redis:8.10.0-alpine
ci/woodpecker/cron/e2e Pipeline was successful
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-07-30 21:32:22 +00:00
claude-botandClaude Opus 5 39dc23a999 ci: document why the shellcheck discovery floor stays at 1
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
Review flagged that shellcheck's floor (1) is far weaker than kustomize-build's
(50), so it catches total discovery failure but not partial loss.

Correct, and kept as-is with the reasoning written down. The kustomize floor is
loose relative to the tree it guards, which is what lets it catch gross partial
loss without tripping on churn. The shell-script set is small enough that any
floor tight enough to catch partial loss would sit inside normal churn:
deleting one script would fail CI spuriously, and the fix a hurried person
reaches for is lowering the floor — which is how a guard becomes decoration. A
floor that must be edited whenever the tree changes is not a guard, it is a
snapshot with an exit code, and this file just had all its snapshots removed
for the same reason.

So it catches the failure that actually occurred (discovery silently returning
nothing, step green having checked nothing) and not partial loss. Noted as
worth revisiting if kubernetes/ grows a lot more shell, when a loose
proportionate floor becomes possible.

The review's other point — that the image pins and the /bin/sh and
/bin/woodpecker-cli claims are not checkable from a static checkout, and that a
green run is the real confirmation — is now settled by pipeline 3185 on
b1f5d6dc: all four steps green, with 60 / 18 / 6 ok lines from kustomize-build,
woodpecker-lint and shellcheck respectively. Those counts are cited here as
run-time evidence, not written into the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:55:33 +02:00
claude-botandClaude Opus 5 b1f5d6dcbc ci: explain the ci-k8s tag split and track the stale pins (#461)
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
Review noted this file pins ci-k8s at a newer dated tag than e2e.yml,
e2e-test.yml, alert-monitor.yml and issue-fix.yml, and asked for either a
stated reason or a follow-up so it does not read as an oversight.

There is no reason — the other four are stale. Per the latest-stable rule all
five should sit on the newest dated tag.

Not bumped here on purpose. Those four pipelines exercise different tooling in
the image and three are cron- or event-triggered, so none of them run on this
PR. Bumping them in this branch would ship four untested changes under cover of
a green run that never touched them — the same shape as the digest decay in
#458, where breakage sat unnoticed precisely because the affected jobs did not
run for months. Each wants its own verification, so it is tracked as #461
rather than smuggled in here.

Comment added above kustomize-build so the split is explained where a reader
meets it, pointing at the issue rather than restating the whole argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:49:42 +02:00
claude-botandClaude Opus 5 ba052332bf docs(ci): drop snapshot counts from comments — they decay and prove nothing
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 canceled
The file count was meaningless and would be wrong tomorrow. Correcting it from
197 to 180 last commit was the wrong fix: a number that has to be re-derived
every time the tree changes does not belong in a comment at all. It cost two
review rounds — one to flag that 197 did not reproduce, one to correct it —
and neither round was about anything that mattered, because the count was
never the argument. Widening update.path is pointless because Flux cannot
patch non-Kubernetes YAML at all; the size of the walk was only ever a
secondary aside.

Swept every snapshot figure I had introduced, not just the one flagged:

  * "180 unrelated ansible/esphome YAMLs (179 + 1)" -> "the whole ansible/ and
    esphome/ trees", in both the pipeline comment and the README.
  * "builds 60 kustomizations" -> "builds every kustomization in the tree".
  * "verified green across all 60 kustomizations" -> "across every
    kustomization", keeping the as-of-writing hedge that made it a historical
    claim rather than a live one.
  * "expected ~60" in the discovery-floor failure message -> "floor is 50",
    which cites the actual guard on the line above instead of a tree size that
    drifts away from it. The message already prints the real $count.
  * "scratch-based (19 MB, 2 layers, no shell)" -> "ships no shell", plus what
    the reader actually needs: a step on the plain tag fails to START rather
    than failing a check, which is the confusing symptom. The byte size would
    have gone stale on the next upstream release.

The floor of 50 itself stays — it is a deliberately loose guard against
discovery breaking, not a description of the tree, and it is asserted in code
where a wrong value fails loudly instead of merely misinforming.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:47:11 +02:00
claude-botandClaude Opus 5 1379813459 docs(ci): correct the file count in the update.path argument (197 -> 180)
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 canceled
Review could not reproduce "197 unrelated ansible/esphome YAML files" and
counted 180. The reviewer is right, and the number was wrong twice over.

Recounted in this checkout: ansible/ has 179, esphome/ has 1 -> 180, which
matches the review exactly. My 197 came from also folding in .woodpecker/,
which is wrong on both counts: those files are neither ansible nor esphome,
and they are precisely the directory you WOULD want in the walk, so counting
them as "unrelated" inverts the argument the sentence is making. The composite
is also 198 rather than 197 today — the original count was taken against
master, where .woodpecker/ still had 17 files because ci-manifests-test.yml
did not exist yet.

The conclusion is unchanged (widening update.path is pointless because Flux
cannot patch non-Kubernetes YAML at all, and it would pull in a large number
of unrelated files), but the supporting figure now reproduces, and both places
that cite it are fixed with the derivation shown so it can be re-checked.

Also recorded the review's rule-of-three note where it will actually be read:
kustomize-build and shellcheck share a discovery shape, two copies is
deliberate because the two traps commented around are different, and a third
instance is the signal to factor it into scripts/ci/ — which would pick up
unit tests via ci-scripts-test.yml on the way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:42:42 +02:00
claude-botandClaude Opus 5 5115378835 ci: address review on #460 — drop an unverifiable claim, dedupe rationale
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
Review raised two 🟡 points and one nit. Both 🟡 points turned out to be
already answered by this PR's own CI run, but one of them exposed a real
accuracy problem in a comment, which is fixed here.

1. ci-k8s:2026-07-30 might never have been built. Good reasoning — build-k8s
   only mints a dated tag when ci/images/k8s/** or build-ci-images.yml changes
   on a push, and this PR touches neither; secret-rotator:2026-07-30 does not
   transfer, being a separately-gated step. Checked rather than assumed: the
   registry API is closed to this token (no read:package), so the evidence is
   pipeline 3178 on 8a580e34, where kustomize-build and no-digest-pins both ran
   green on that exact tag, with 60 real `ok` lines — a pull failure could not
   produce that. Tag exists. No change needed.

2. v3.16.0-alpine unverified for /bin/sh and the lint invocation. Same run:
   woodpecker-lint emitted 18 `ok` lines, one per .woodpecker/*.yml, which is
   only reachable if the image has a working shell and /bin/woodpecker-cli.

   But checking this found a claim I could NOT verify, which the review had
   independently guessed at: the comment asserted that several deploy-*.yml
   files emit "Additional property timeout is not allowed" warnings. That was
   observed under the v3.9.0 CLI this step used to pin. Whether v3.16.0 still
   emits it is unknown — and a green run cannot settle it, because the loop
   redirects lint output to /tmp/out and prints it only on failure, so warnings
   are invisible exactly when the step passes. Review's hypothesis (schema
   drift in a 7-minor-old CLI, not real debt in those files) is plausible and
   untested. Dropped the claim instead of restating it; the comment now
   documents only the exit-code contract the gate actually rests on.

3. Nit: the depends_on rationale was duplicated verbatim across all four
   steps. Hoisted to one note above `steps:`, with a warning that the key
   itself must stay on every step — it is per-step, so removing it from one
   silently re-serialises that step.

Also updated the PR description, which both reviews flagged as stale: it still
quoted v3.9.0-alpine / v0.10.0 as shipped, so the probing evidence in the body
referred to different tags than the diff. Corrected, with a note recording the
bump rather than quietly rewriting history.

Verified: all four steps still parse with depends_on: [] and unchanged images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:36:49 +02:00
claude-botandClaude Opus 5 8a580e341d docs(ci): record why woodpecker-cli cannot be enrolled in image-automation
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
I proposed widening ImageUpdateAutomation's `update.path` to `./` and adding
a woodpecker-cli ImagePolicy so automation would own the lint pin the way it
owns the server and agent. Checking that before building it killed the plan,
and corrected two things I had asserted without evidence.

WRONG #1 — my reason for hesitating. I claimed the 7 ansible-vault encrypted
`.yml` files would break the walk and take all 33 automated images down with
them. That was a guess about kyaml I never verified. Objects that are not
Kubernetes manifests are SKIPPED, not fatal.

WRONG #2 — and this is the one that matters: the plan cannot work at all.
Image-automation only patches YAML that is a Kubernetes manifest. Maintainer,
fluxcd/flux2#2826: "Flux can only patch Kubernetes manifests. This means the
YAML manifest must have `apiVersion` and `kind` fields." The docs agree —
markers go "in any Kubernetes custom resource stored in Git". A Woodpecker
pipeline file is plain YAML with no apiVersion/kind, so a marker in it is
silently ignored.

That is a property of the FILE, not of `update.path`. Widening the path buys
exactly nothing and would drag 197 unrelated ansible/esphome YAMLs into the
walk. So no policy, no second automation, no path change — all three would
have been motion that looked like a fix and changed nothing. The failure mode
would have been the quiet kind this repo keeps getting bitten by: a marker
sitting in the file looking maintained, never once updated.

What actually closes the gap, given the pin must stay manual:

  * README gains a "Not enrolled (structurally impossible)" section holding
    the quote and the explicit "do not try widening the path" warning, so the
    next person does not re-derive this.
  * update-audit item 5 now covers `.woodpecker/*.yml` step images and is told
    to flag woodpecker-cli whenever it trails the DEPLOYED SERVER, not merely
    the newest CLI release — the server auto-bumps and the CLI does not, so
    that skew is the thing worth reporting. Also told to compare only the
    vX.Y.Z part (the tag carries `-alpine`; the plain tag is scratch with no
    shell and would break the step) and to ignore the `next-*`/`pull_*` dev
    tags, which are the newest by date on that repo and are not releases.
  * the step comment states the real constraint instead of my earlier wrong
    one, which told the reader that widening the path was the fix.

Verified: v3.16.0-alpine is the newest 3.x alpine release tag, and matches the
deployed server v3.16.0. Config still parses; step image unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:19:02 +02:00
claude-botandClaude Opus 5 fbfaf90923 docs(ci): frame the CLI pin as the latest-stable rule, not a separate one
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
I had written that the CLI "MUST track the deployed woodpecker-server",
which invents a rule that does not exist and misstates the one that does.

The standing rule already covers it: any software uses the latest stable
version. CLI tools are software. If the CLI and the server both follow it,
they match by construction — matching is a CONSEQUENCE, not a separate
requirement. I had read "any software" as meaning installs and images but
not CLIs, and was about to propose adding a redundant rule to the wiki off
the back of that misreading.

The comment now says that, and keeps the reason the skew mattered here: the
CLI compiles against its own schema, so one behind the server can pass a
config the server rejects, and the gate stops predicting server behaviour.

Also recorded a real limitation found while checking this. The server and
agent are bumped automatically by image-automation within the 3.x range,
and their policy comment notes they must stay version-matched. This pin is
manual, because ImageUpdateAutomation is scoped to
`update.path: ./kubernetes` and cannot maintain a tag under .woodpecker/.
So the CLI will lag a server upgrade until someone bumps it. Widening that
path would let a policy own this line the same way it owns the server and
agent — noted rather than done, since it changes automation scope repo-wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:02:23 +02:00
claude-botandClaude Opus 5 7b5f244b7f ci: use latest stable versions, and match the woodpecker CLI to the server
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
Violated the standing rule: "use the latest stable version. Old versions
must not be hardcoded without a specific, documented reason."

woodpecker-cli was pinned at v3.9.0 while the deployed server and agent run
v3.16.0 — seven minor versions behind. That version was never chosen; I
picked it while probing for any tag that had a shell and then never
revisited it.

For this step the skew is not cosmetic. The CLI compiles the config against
its own schema, so one behind the server can pass a config the server later
rejects, or reject one the server accepts. Either way the gate stops
predicting what the server will do, which is the only reason it exists —
and the failure it was added to catch (#443) is precisely a
server-side compile error.

Now v3.16.0-alpine, matched to the running deployment (checked against the
live Deployment, not assumed), with a comment saying it must be bumped
alongside the server. Verified it still behaves correctly: rc=0 on a valid
config, rc=1 on the #443 construct.

shellcheck was pinned at v0.10.0 for the same non-reason — it was simply
the newer of the two tags I happened to probe. v0.11.0 exists, so bumped.
Verified first that the existing scripts still pass at -S error under the
newer version, since a shellcheck bump can add checks and I did not want to
ship a gate that fails on the current tree.

ci-k8s stays on the dated-tag convention for registry-local images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:56:37 +02:00
claude-botandClaude Opus 5 888aa60fe0 ci: run the four checks in parallel so every failure shows at once
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
Review points on #460.

Woodpecker runs steps sequentially by default, so a failing kustomize-build
hid woodpecker-lint, shellcheck and no-digest-pins entirely — a contributor
would fix one thing, repush, and only then discover the next. All four are
independent, cheap and offline, with no ordering between them, so
depends_on: [] fans them out and one run reports everything.

Verified the structure rather than the diff: all four steps now render with
depends_on=[].

Also made the discovery-floor failure actionable. The message said only
"Discovery is broken", which is wrong advice if the tree legitimately
shrank below 50 kustomizations — it now tells the reader to lower the floor
in that case, and explicitly not to delete it, since removing the guard
restores the silent-green no-op it exists to prevent.

On the ci-k8s:2026-07-30 tag, raised for the third time: confirmed by
execution, not inspection. It resolved in pipeline 3161, whose
kustomize-build and no-digest-pins steps both ran that image and logged
"discovered 60 kustomizations" with 60 ok lines. An image that failed to
pull could not have produced that output. The review is right that nothing
in THIS PR rebuilds the tag — it was published earlier today by
build-ci-images runs triggered by edits to .woodpecker/build-ci-images.yml,
which is also exactly the drift that left other pipelines pinned to
ci-k8s:2026-05-01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:25:23 +02:00
claude-botandClaude Opus 5 9d3dc152c1 fix(ci): repair the block scalar I broke in the previous commit
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
The previous commit set IFS to a literal newline by writing

        IFS="
"

inside a `- |` block scalar. The closing quote sits at column 0, which
terminates the block — so the config no longer parsed:

  go-yaml load error in scanner (while scanning a simple key) at
  L70.C1-L89.C3: could not find expected ':'

Same class as #443, in the very pipeline written to catch #443, and I
pushed it: `woodpecker lint` printed the error in the same command as the
push, and I read the push output rather than the lint output.

Replaced with `while IFS= read -r` fed from a FILE rather than a pipe.
That handles paths with spaces without needing a literal newline anywhere,
and the file redirection keeps the loop in the current shell so `fail`
still propagates — a pipe would have run it in a subshell and made a
failing kustomize build exit 0, which is the silent-pass shape this step
already shipped once with -printf.

Verified both properties in the actual image rather than reasoning about
them: a directory named "has space" iterates once intact, and a `fail=1`
set inside the loop is still 1 after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:18:39 +02:00
claude-botandClaude Opus 5 1911a20db5 ci: path-filter the manifest gate and harden its loops
Review points on #460.

Path-filter the pipeline to kubernetes/** and .woodpecker/**. It was
unfiltered, matching ci-scripts-test.yml, but that comparison does not
hold: ci-scripts-test is one step on one already-cached image, whereas this
pulls three images and builds 60 kustomizations — real cost to impose on a
docs-only PR. The filter still covers everything the steps read, and
including .woodpecker/** means an edit to this file re-runs its own checks.

Pin IFS to newline in both find-driven loops so a path containing spaces is
passed as one argument rather than split into fragments. Verified in the
actual image: a directory named "has space" now iterates once, intact.

Deliberately NOT rewritten as `printf | while read`, which is the usual
advice: that puts the loop in a subshell, so `fail` would not propagate and
a failing kustomize build would exit 0. That is the same silent-pass shape
this step already shipped once with -printf, and fixing a latent
whitespace bug by reintroducing a live correctness bug would be a poor
trade.

Also corrected the file header, which still claimed the gate runs on every
pull_request.

On the ci-k8s:2026-07-30 tag the review asked about again: confirmed by
execution rather than inspection. Pipeline 3161 ran this pipeline against a
real checkout with all five steps green, which requires all three images to
have pulled in the runner, and its kustomize-build step logged "discovered
60 kustomizations" with 60 ok lines — so the image both resolved and did
the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:17:27 +02:00
claude-botandClaude Opus 5 ddf5f5007b docs(ci): correct how the digest guard avoids self-matching
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
Review point on #460. The comment claimed "`image:` anchors the match so
the kustomization comment documenting this rule does not flag itself".
That is wrong about the mechanism, and the reviewer proved it by running
the bare grep — kustomization.yaml DOES match, because the comment
documenting the rule contains the literal string `image:.*@sha256`.

What actually excludes it is the `grep -v kustomization.yaml` filter. The
step's behaviour was always correct; only the explanation was wrong.

Worth fixing rather than shrugging at: this is the third time today the
same self-matching trap has caught me. Twice while writing the guard in
rotation/kustomization.yaml (where the final version documents it
accurately), and now once more in the comment describing it here. A
comment that misidentifies which line is load-bearing invites someone to
delete the filter as redundant tidying, at which point the step fails
permanently on its own documentation.

The comment now says the filter is load-bearing and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:11:26 +02:00
claude-botandClaude Opus 5 790fd1933a fix(ci): kustomize-build validated nothing and reported green
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
Blocking review point on #460, and it was exactly right.

`find -printf` is a GNU findutils extension. ci-k8s is alpine-based and
never installs findutils, so find is BusyBox — confirmed by running it in
the image:

  find: unrecognized: -printf
  BusyBox v1.37.0

Because the result fed a command substitution used as a word list, the
failure was SILENT: empty output, zero loop iterations, fail stayed 0, and
the step exited green having run kubectl kustomize on nothing at all.

The proof is in this PR's own pipeline run (3158). The step is recorded as
"success", and its log contains ZERO `ok` lines — sixty were expected. So
the gate I added to catch silent no-ops was itself a silent no-op, and CI
reported it as passing.

Worth stating why neither of my checks caught it: I dry-ran the loop on my
workstation, which has GNU find, and the CI run cannot distinguish a
zero-iteration loop from a working one when every kustomization already
builds — both exit 0. The review's suggested test (paste the per-directory
log lines, not the exit code) is the only one that separates them.

Fixed by replacing -printf with a portable pipe through sed. Verified in
the actual image, with a synthetic tree, that the loop now iterates.

Also added a discovery assertion to both find-based steps: fail if the
count is implausible (kustomize-build expects >=50 of ~60; shellcheck
expects >=1) rather than proceeding with an empty list. That is the general
fix — without it, ANY future breakage of discovery degrades back into a
green no-op instead of a failure, which is the trap this whole commit is
about rather than the -printf detail specifically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:04:48 +02:00
claude-botandClaude Opus 5 cd12bc16c9 ci: validate the GitOps tree and pipeline configs on every PR
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
Nothing covered kubernetes/** before this. deploy-infrastructure.yml gates
only opentofu/**, and ci-scripts-test.yml runs only the scripts/ci/ unit
tests — so a broken manifest reached the cluster and was caught by Flux
failing to reconcile, or by someone noticing. Reviewers flagged the gap on
three separate PRs today.

Four static checks, all offline (no cluster, no registry, no secrets):

  kustomize-build   every kustomization under kubernetes/ must build
  woodpecker-lint   every .woodpecker/*.yml must compile
  shellcheck        standalone .sh under kubernetes/, severity=error
  no-digest-pins    rotators must not reintroduce @sha256 image pins

Each was verified GREEN against the tree as it stands before being added,
so this gates new breakage rather than failing on pre-existing debt: all 60
kustomizations build, all pipeline configs lint, all 6 shell scripts are
clean at error severity, and no digest pins remain after #459.

The woodpecker-lint step is the one that matters most: it exits 1 on the
exact defect that took every pipeline in this repo down earlier today
(#443) — an unquoted YAML scalar containing ": ", which `yq` accepts
because it IS valid YAML, just a map rather than a string. Verified both
directions in the image the step actually uses: rc=1 on that construct,
rc=0 on the pre-existing "Additional property timeout is not allowed"
warnings in four deploy-*.yml files, so it fails on real breakage without
blocking on legacy noise.

Image choices, all version-pinned with no floating tags:

  ci-k8s:2026-07-30                       kubectl for kustomize; also the
                                          shell for the grep-based guard
  woodpecker-cli:v3.9.0-alpine            the non-alpine image is distroless
                                          and has no /bin/sh, so Woodpecker
                                          `commands:` cannot run in it; the
                                          binary is /bin/woodpecker-cli, not
                                          on PATH as `woodpecker`
  shellcheck-alpine:v0.10.0               pinned rather than :stable, which
                                          floats like :latest

Deliberately NOT included: kubeconform. It needs CRD schemas for
ExternalSecret, Certificate, HelmRelease and friends, so it either needs a
schema-fetching step (network, and a new failure mode) or
-ignore-missing-schemas, which would skip precisely the CRDs most of this
tree is made of. Worth doing properly as its own change.

Known gap, called out in the file rather than implied away: shellcheck
cannot see shell embedded in manifests (init containers, probe exec
blocks), which is where several real bugs lived this year.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:56:06 +02:00
k.renner 971ea6dc09 Merge pull request 'fix(rotation): repin every rotator to a dated tag, not a digest' (#459) from rotator-repin into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#459
2026-07-30 17:44:22 +00:00
claude-botandClaude Opus 5 5d32065b8f fix(rotation): make the documented guard actually pass
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Second attempt, and the first one was wrong for a subtler reason than I
fixed. Anchoring on `image:` did not help: this file documents the
pattern, so the comment text `image:.*@sha256` matches the regex
`image:.*@sha256`. The guard kept reporting its own documentation.

Added the `| grep -v kustomization.yaml` filter and this time ran the
command exactly as written rather than a paraphrase of it. Verified both
directions: no output across this directory as it stands, and it still
reports a file carrying a real `image: ...@sha256:` pin.

Lesson worth the two commits: a documented check has to be executed in the
form it is documented in. I had verified a variant twice and shipped a
guard whose only hit was itself — which trains people to ignore it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:33:04 +02:00
claude-botandClaude Opus 5 5035085426 fix(rotation): make the documented digest-guard not flag its own docs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Ran the check I had just documented and it returned kustomization.yaml —
because the comment explaining the rule contains the literal string it
greps for. A guard whose only hit is its own documentation trains people
to ignore it, which is worse than not having one.

Anchored on `image:` instead. Verified both directions: returns nothing
across this directory as it stands, and still flags a file containing a
real `image: ...@sha256:` pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:32:08 +02:00
claude-botandClaude Opus 5 1cad57984c docs(rotation): tell future authors how to avoid reintroducing digest pins
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
Review point on #459. Nothing stops the next rotator from copy-pasting a
@sha256 pin from an older example, and the failure only surfaces months
later on the first scheduled run — the same silent, delayed failure this
PR just cleaned up 11 instances of.

Added two copy-pasteable checks to the kustomization header: a grep that
must return nothing before adding a rotator, and a pull test to run BEFORE
committing a tag bump. The second matters because a tag that was never
built fails exactly like the dead digests did, and just as quietly.

Not a CI gate, deliberately. Nothing in CI covers kubernetes/** at all
(deploy-infrastructure.yml only covers opentofu/**), so a real guard means
standing up a lint/validate pipeline for that tree — worth doing, but as
its own change rather than smuggled into a repin PR. Documented here so
the knowledge is not lost while that waits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:31:30 +02:00
claude-botandClaude Opus 5 717a12bc18 fix(rotation): repin every rotator to a dated tag, not a digest
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Closes the breakage in #458: 11 of the 14 rotators were pinned to image
digests that no longer exist in the registry. Verified by pulling each
rather than by inspecting metadata (the registry API returns 401 for a
PAT, so metadata checks mislead here):

  gone     159e5888  authentik, crowdsec-bouncer-key, paperless,
                     shelly-manager, woodpecker, home-assistant (x3)
  gone     978fe53c  cloudflare, external-dns
  gone     49bfddd8  netbird-ingress
  gone     97c22bcf  pve-exporter
  present  b41cc364  flux-git
  present  828bf486  claude-oauth, review-bot

The two survivors are exactly the rotators that run often; every gone
digest belongs to a quarterly one. Untagged manifests are collected once
:latest and the dated tags move past them.

Impact had this not been caught: every quarterly CronJob shows
lastScheduleTime <none> — they were created 2026-07-18..23, after the last
quarterly fire — so none has ever run. Their first fire is 2026-10-01,
where ~10 would have ImagePullBackOff'd at once. A rotation that never
starts looks identical to nothing happening, so credentials would have
silently stopped rotating while the manifests kept asserting they did.

All 16 references (13 files; home-assistant has 3 CronJobs, review-bot 2)
now use secret-rotator:2026-07-30 — dated tag, no digest, no :latest, per
owner directive and matching ci-ansible:2026-04-29 and the alpine-jq /
cni-plugins / alpine-openssl pins.

Also repinned flux-git, claude-oauth and review-bot, whose digests still
resolve today. They are pinned the same way and would decay identically;
converting them now avoids a second pass when they break.

Verified before repinning that one image serves all of them: the modes in
use across these files are exactly

  cloudflare-roll gitea-token ha-token netbird-key proxmox-token random
  woodpecker-secret

and the 2026-07-30 image's usage string lists exactly those seven. So this
is a single binary and splitting the pin would serve no purpose.

Rationale recorded in kustomization.yaml rather than repeated 16 times,
including WHY digests decayed: the build triggers on
.woodpecker/build-ci-images.yml, so adding an unrelated image republishes
secret-rotator and orphans the manifest every rotator was pinned to. That
is how these went stale without anyone touching the rotator source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:26:44 +02:00
k.renner 17c9c21116 Merge pull request 'fix: [alert] KubeDeploymentRolloutStuck (paperless) (#452)' (#456) from alert-fix/issue-452 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#456
2026-07-30 16:56:41 +00:00
k.renner 31a1179537 Merge pull request 'feat(mosquitto): enrol the health broker credential in Class-A rotation' (#455) from mosquitto-rotate-health into master
Reviewed-on: k.renner/homelab#455
2026-07-30 16:54:02 +00:00
k.renner 7b23de49ce Merge pull request 'docs(mosquitto): propose enrolling broker credentials in Class-A rotation' (#453) from mosquitto-rotation-proposal into master
Reviewed-on: k.renner/homelab#453
2026-07-30 16:53:42 +00:00
claude-botandClaude Opus 5 9e75ab6b4d fix(mosquitto): pin the rotator by dated tag, not digest
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Owner directive: our own images do not need digest pinning, and :latest is
never to be used. Dated tag it is — matching ci-ansible:2026-04-29 and the
alpine-jq / cni-plugins / alpine-openssl pins added earlier today.

Now secret-rotator:2026-07-30. Verified before pinning that the tag exists,
pulls, lists `random` among its modes, and carries the RANDOM_BYTES>=16
floor this file documents (RANDOM_BYTES=8 exits "refusing to generate a
weak secret" rather than generating one).

Worth recording why the tag is today's rather than 2026-07-23, the date of
the last commit touching secret-rotator/: the build step also triggers on
.woodpecker/build-ci-images.yml, which was edited several times today while
adding the alpine-jq, cni-plugins and alpine-openssl images. So
secret-rotator was rebuilt and republished today from unchanged source.
2026-07-23 resolves to a different, older image (sha256:828bf486…) that is
still present; 2026-07-30 matches what :latest currently points at.

That incidental rebuild is also part of why the digest pins decayed: every
unrelated pipeline edit moves :latest and orphans the previous manifest,
which then gets garbage-collected. Tracked in #458 for the other 11
rotators; this change keeps the new one out of that trap rather than
joining it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:47:58 +02:00
issue-fix-bot a3dcdacd30 fix: [alert] KubeDeploymentRolloutStuck (paperless) (#452)
ci/woodpecker/pr/ci-scripts-test Pipeline is pending
ci/woodpecker/pr/pr-review Pipeline is pending
2026-07-30 14:46:09 +00:00
claude-botandClaude Opus 5 4df89506fb fix(mosquitto): pin a rotator image that actually exists
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The review asked whether copying paperless's rotator digest was safe.
Checking it turned up something worse than a version-skew question: that
digest does not exist in the registry at all.

  sha256:159e5888…  ImagePullBackOff / NotFound

Verified by pulling it, not by inspecting metadata. So this PR as written
would have shipped a CronJob that fails on its first run in three months'
time — silently, since a quarterly rotation failing looks like nothing
happening.

Now pinned to the digest :latest currently resolves to
(sha256:dcf4267a…), verified to pull, to list `random` among its modes,
and to carry the RANDOM_BYTES>=16 floor this file documents (RANDOM_BYTES=8
exits with "refusing to generate a weak secret" rather than generating one).

BROADER PROBLEM, not introduced here and not fixed here: 11 of the 14
existing rotators are pinned to digests that are equally gone. Tested each:

  gone     159e5888  authentik, crowdsec-bouncer-key, paperless,
                     shelly-manager, woodpecker, home-assistant
  gone     978fe53c  cloudflare, external-dns
  gone     49bfddd8  netbird-ingress
  gone     97c22bcf  pve-exporter
  present  b41cc364  flux-git
  present  828bf486  claude-oauth, review-bot

The two surviving digests belong to the rotators that run often; the gone
ones are all quarterly or infrequent. Untagged manifests appear to be
garbage-collected once :latest and the dated tag move past them, so a
digest pinned today stops resolving after a couple of rebuilds. Every
affected rotator will ImagePullBackOff on its next scheduled run unless the
image happens to still be cached on whichever node it lands on.

That means secret rotation across most of this cluster is probably already
broken and would fail quietly. Raised separately rather than fixed here —
it needs a decision (repin all, stop GC'ing, or tag-pin rather than
digest-pin), not a drive-by edit inside a mosquitto PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:34:59 +02:00
claude-botandClaude Opus 5 0817aaa24b docs(mosquitto): add the token_no_default_policy warning to the rotator policy
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review point on #455. Five sibling rotator policies carry this line and
mine omitted it:

  Do NOT set token_no_default_policy=true — the `bao kv patch` kv-v2
  preflight needs the default policy (see rotator.hcl).

It matters precisely because these policies are applied BY HAND, out of
band: the warning exists to stop someone tightening the role in a later
hardening pass and silently breaking the patch the rotator depends on.
Omitting it made this file the one that looks safe to harden.

Verified the live role is already correct — token_no_default_policy is
false — so this documents an existing good state rather than fixing a
broken one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:23:42 +02:00
claude-botandClaude Opus 5 2d24d0b6d1 docs(mosquitto): fix dangling proposal reference and stale Job comments
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review points on #455.

The three new files referenced PROPOSAL-mosquitto-credential-rotation.md
as though it were in the tree. It is not — it lives in PR #453, which is
still open, so anyone following the reference (including whoever enrols
broker user #2) would hit a 404. That is a sequencing mistake on my part:
I wrote the implementation against a doc that had not landed. The
references now say so explicitly rather than silently pointing at nothing.

Also fixed two comments in mosquitto-deployment.yaml that described
HEALTH_PASSWORD as coming from the mosquitto-health-credentials-bootstrap
Job. This PR deletes that Job, so those comments described infrastructure
that no longer exists. They now point at the ExternalSecret. Fixing here
rather than deferring, since this PR is what made them stale.

Deliberately did NOT touch the neighbouring zigbee2mqtt comment the review
also flagged: that Job still exists (only health is enrolled in this
phase), so the reference is still accurate. It becomes stale when
zigbee2mqtt is enrolled, and should be fixed by that change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:19:02 +02:00
claude-botandClaude Opus 5 91133c94b0 feat(mosquitto): enrol the health broker credential in Class-A rotation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Phase 1 of PROPOSAL-mosquitto-credential-rotation.md. One of eight broker
users; the other seven are untouched and keep their bootstrap Jobs.

`health` goes first deliberately: it is the broker's OWN liveness/readiness
probe credential, consumed by exec probes inside the mosquitto container
via the same Secret that seeds the passwd file. Nothing outside the pod
holds it, so rotating it needs no coordination with any client — the
smallest blast radius for proving rotator -> OpenBao -> ESO -> Reloader end
to end before touching credentials held by Shelly firmware, the edge host
or the HA config entry.

Adds, following rotation/paperless.yaml exactly:

- rotator-mosquitto-health.hcl — per-secret policy, read+patch on that ONE
  path. Deliberately not a wildcard over mosquitto-*: the users are being
  enrolled one at a time, and a wildcard would silently grant this rotator
  write access to the other seven before they are ready.
- rotation/mosquitto-health.yaml — SA + quarterly CronJob running
  secret-rotator in random mode. Scheduled one month off paperless so
  unrelated rotations do not share a window.
- mosquitto-health-credentials-externalsecret.yaml — projects the value
  back to the SAME Secret name and key, so mosquitto-deployment.yaml's
  secretKeyRef and the probes reading $HEALTH_PASSWORD are unchanged.
- A Reloader annotation on the mosquitto Deployment. Without it rotation is
  INERT: the startup shim rebuilds /mosquitto/auth/passwd from the Secrets
  on pod start, so a rotated Secret would sit unused until some unrelated
  restart while the old password stayed valid. Listed per-Secret rather
  than `auto: "true"` so enrolling each further user stays a visible edit.

Removes only the health bootstrap Job (508 -> 440 lines); the remaining six
Jobs are untouched. Deleting rather than editing also sidesteps Job
spec.template immutability, which is what made the earlier attempt (#449)
unmergeable.

Verified before committing:

- random mode fits unchanged: env contract is BAO_ROLE/BAO_PATH/BAO_FIELDS/
  RANDOM_BYTES, and it calls assert_fields_exist FIRST — so it rotates an
  existing field and never bootstraps one. Seeding stays a documented
  one-time out-of-band step, exactly as every other rotator does it.
- Charset is safe HERE but not universally: random mode emits standard
  base64 (+, /, =) whereas the old Jobs emitted alphanumeric via
  `tr -dc 'A-Za-z0-9'`. The probes pass it as -P "$HEALTH_PASSWORD" inside
  the container, so it is never parsed by a URL or config file. That must
  be re-checked per user before enrolling sml2mqtt/open3e (edge-host
  templates), shellies (device firmware) or homeassistant.
- OpenBao path already holds a `password` field, and its value still
  matches the live Secret (both 23c10b3b), so ESO adoption on merge is a
  no-op and no client sees an auth change.
- eso-read.hcl already grants homelab/data/*, so no ESO policy change.
- Policy and kubernetes auth role applied out-of-band (they are not
  Kubernetes resources): bound to SA mosquitto-health-secret-rotator in ns
  openbao, ttl 15m.

Not done here: the first actual rotation. It is safe (in-cluster only) but
restarts the broker, so it is left as a deliberate post-merge acceptance
test rather than bundled into a merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:13:09 +02:00
claude-botandClaude Opus 5 ff526f98c2 docs(mosquitto): correct the rotator count
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review point on #453. The document claimed 14 rotators, which matches
neither way of counting. Verified directly rather than accepting the
review number on trust:

  files in rotation/ (excl kustomization): 13
  resources listed in kustomization:      13
  actual CronJob objects:                 15
  Class-A:                                 5

home-assistant.yaml alone defines three CronJobs, which is where the
discrepancy comes from. Now states 13 manifests defining 15 CronJobs, of
which 5 are Class-A.

Worth fixing beyond pedantry: this document argues for verifying claims
rather than asserting them, and the count was eyeballed from a directory
listing rather than counted. A wrong number in the opening paragraph
undermines the parts that were actually verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:03:13 +02:00
claude-botandClaude Opus 5 6e47ef4368 docs(mosquitto): correct four factual claims in the rotation proposal
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Review points on #453. All four verified against the repo before changing
anything, since a proposal's only value is being precise about exactly
this kind of detail.

Line count: mosquitto-bootstrap.yaml is 508 lines, not 518, and the
ServiceAccount/Role/RoleBinding are inside that same file — the original
wording double-counted them.

Generator blast radius: adopting the ESO Password generator would rotate
the 7 Job-minted credentials, not "all 8". evcc is already
ESO-extract-backed and unaffected.

Rollout failure mode: the broker rolls with maxUnavailable: 0 /
maxSurge: 1 (confirmed in mosquitto-deployment.yaml), so mid-rollout two
broker pods with DIFFERENT passwd files sit behind the LB and a
reconnecting client can land on the old one and fail auth until the
rollout finishes. Self-healing and brief, but it will look alarming in
logs during the first rotation, so it is now called out in advance rather
than discovered live.

ESO refresh latency: added to the open questions. The evcc ExternalSecret
uses refreshInterval: 1h, so a rotation takes up to an hour to land. That
does not widen the outage window — broker and client still move together —
but it affects verifying a rotation completed and the cold-rebuild
ordering, and raises whether these want a shorter interval or a
rotator-triggered sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:50:20 +02:00
claude-botandClaude Opus 5 4b5918e87e docs(mosquitto): propose enrolling broker credentials in Class-A rotation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Documentation only — nothing implemented, no manifests changed.

The mosquitto broker passwords are the only Class-A-shaped secrets in the
cluster not enrolled in the rotation framework under
kubernetes/infrastructure/openbao/rotation/. They are minted once by seven
bootstrap Jobs and then never change: no schedule, no compromise response,
no path at all. They also live only in the Secret objects, so they are
outside the OpenBao snapshot and unrecoverable if lost.

Records why the obvious fixes do not work, since each was tried and the
reasons are not visible from the code:

- ExternalSecrets over hand-seeded OpenBao paths depend on content git
  cannot recreate; a fresh OpenBao would leave them empty and ESO would
  overwrite live credentials with nothing.
- The ESO Password generator OVERWRITES pre-existing Secrets — verified
  against a scratch Secret, not assumed — so adopting it rotates all eight
  at once, uncoordinated.
- Keeping the Jobs and fixing only the image is blocked by spec.template
  immutability; renaming them works but recurs on every future edit.

Documents the two mechanics that actually shape the design: the broker
rebuilds its passwd file from the Secrets on every pod start (so a Secret
change is inert until it restarts, and there is no Reloader annotation
today), and a mosquitto user has exactly one password (so there is no
make-before-break as the LLAT rotators use).

Also records who holds each credential, which is not visible from the
manifests and is what determines cutover cost — including that sml2mqtt
and open3e keep the same password in both a Kubernetes Secret and
ansible-vault, synchronised by hand. That duplication, not any client
limitation, is what makes rotation look impossible today.

Proposes per-user rotators following rotation/paperless.yaml, phased
health -> zigbee2mqtt -> evcc -> edge-host pair -> shellies ->
homeassistant, so blast radius is one client per rotation and the Jobs are
deleted incrementally as their users migrate rather than patched.

Open questions are listed rather than guessed at, notably whether
shelly-manager can push MQTT credentials to device firmware and how the HA
MQTT config entry is updated programmatically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:38:34 +02:00
security-bot 37de60ccff chore(image-automation): bump ghcr.io/music-assistant/server:2.9.10
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-07-30 13:32:16 +00:00
k.renner 57d333a2f1 Merge pull request 'fix(paperless): drop runtime apk add from fetch-bridge-cert' (#448) from paperless-drop-apk into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#448
2026-07-30 12:52:47 +00:00
132 changed files with 3578 additions and 936 deletions
+29
View File
@@ -217,3 +217,32 @@ steps:
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
# unpinned VCS requirement re-cloned and restarted the heating bridge on every
# Ansible converge. Not a ci-* image -- it is a runtime artifact -- but it
# builds the same way and lands in the same registry so the audit sees it.
- name: build-heating-can
image: gcr.io/kaniko-project/executor:v1.23.2-debug
directory: ci/images/heating-can
depends_on: []
when:
- event: manual
- event: push
path: ["ci/images/heating-can/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
--destination gitea.renner.uno/gitea_admin/heating-can:latest
+254
View File
@@ -0,0 +1,254 @@
# Validate the GitOps tree and the pipeline definitions on PRs that touch them.
#
# Nothing covered kubernetes/** before this. deploy-infrastructure.yml only
# gates opentofu/**, and ci-scripts-test.yml only runs scripts/ci/ unit tests —
# so a broken manifest reached the cluster and was caught by Flux failing to
# reconcile, or by a human noticing. Three separate defects on 2026-07-30 would
# have been caught here instead:
#
# * an unquoted YAML scalar containing ": " in .woodpecker/build-ci-images.yml,
# which made EVERY pipeline in the repo fail to compile (#443). `yq` parsed
# it happily — it is valid YAML, just a map instead of a string — so
# "it parses" was mistaken for "it is valid". Only woodpecker lint catches it.
# * a rotator pinned to an image digest that no longer existed (#458).
# * manifests whose kustomize wiring was only ever checked by hand.
#
# Deliberately CHEAP and offline: no cluster, no registry, no secrets. Everything
# here is a static check that either passes or fails on the checkout alone, so it
# can gate PRs without becoming a bottleneck or a flake source.
when:
# Path-filtered on purpose, unlike ci-scripts-test.yml. That pipeline is one
# step on one already-cached image, so running it everywhere is free; this one
# pulls three images and builds every kustomization in the tree, which is real
# cost to put on a docs-only PR. The filter covers everything the steps actually read:
# kubernetes/** for the manifests and scripts, .woodpecker/** for the lint (and
# so an edit to this file re-runs its own checks).
- event: pull_request
path:
- "kubernetes/**"
- ".woodpecker/**"
# ADDING A THIRD DISCOVERY CHECK: kustomize-build and shellcheck both hand-roll
# the same shape — `find`, a floor guard against empty discovery, then a
# file-fed `while read`. Two copies is deliberate: the two traps being commented
# around (BusyBox find lacking `-printf`, and a piped loop running in a subshell
# where `fail` cannot propagate) are different, and both are worth spelling out
# where they bite. A third instance is the point to stop copying and factor it
# into a shared script under `scripts/ci/` — which is already covered by
# ci-scripts-test.yml, so it would gain unit tests in the process.
#
# Every step carries `depends_on: []` so all four run in PARALLEL and every
# failure shows in one run. Woodpecker's default is sequential, which would stop
# at the first failure and make a contributor fix-and-repush to discover the
# next — and these four are independent, cheap, offline checks with no ordering
# between them. Keep it on every step: `depends_on` is per-step, so dropping it
# from one silently re-serialises that step behind the others.
steps:
# Every kustomization must build. This is the check that catches a resource
# entry pointing at a file that does not exist, a bad patch target, or YAML
# that is fine in isolation but broken once overlaid — the class of error that
# otherwise surfaces as a Flux reconcile failure after merge.
#
# Verified green across every kustomization in the tree when this was added,
# so this gates new breakage rather than pre-existing debt.
#
# ci-k8s TAG SPLIT: this file pins a newer dated tag than e2e.yml, e2e-test.yml,
# alert-monitor.yml and issue-fix.yml, which still sit on an older one. That is
# staleness in those four, not a deliberate split here — everything should be on
# the newest dated tag. They are not bumped here because none of them are
# triggered by this PR, so it would ship untested changes under cover of a green
# run that never touched them. Tracked in #461.
- name: kustomize-build
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-07-30
commands:
- |
set -eu
# NOT `find -printf '%h\n'`: that is a GNU findutils extension and this
# image ships BusyBox find (v1.37.0), which rejects it outright with
# "find: unrecognized: -printf". Because the result feeds a command
# substitution used as a word list, the failure is SILENT — empty output,
# zero loop iterations, fail=0, step exits green having validated
# nothing. That is exactly what this pipeline shipped in its first
# revision, and CI reported it as passing.
dirs=$(find kubernetes -name kustomization.yaml | sed 's|/kustomization.yaml$||' | sort)
# Assert the discovery actually found something. Without this, any future
# breakage of the find/sed above degrades back into a green no-op rather
# than a failure — the specific trap that made the first revision of this
# step useless while looking healthy.
count=$(printf '%s\n' "$dirs" | grep -c . || true)
if [ "$count" -lt 50 ]; then
echo "FAIL: found only $count kustomizations; floor is 50."
echo " If the tree legitimately shrank, lower this floor in"
echo " .woodpecker/ci-manifests-test.yml — do not delete it."
echo " Otherwise discovery is broken — refusing to report success."
exit 1
fi
echo "discovered $count kustomizations"
# `while read` fed from a FILE, not a pipe: a pipe would run the loop in
# a subshell where `fail` cannot propagate, so a failing build would exit
# 0 — the same silent-pass shape this step already shipped once. Reading
# a file keeps the loop in the current shell, and read -r handles paths
# containing spaces without IFS games.
printf '%s\n' "$dirs" > /tmp/dirs
fail=0
while IFS= read -r d; do
[ -n "$d" ] || continue
if kubectl kustomize "$d" >/dev/null 2>/tmp/err; then
echo "ok $d"
else
echo "FAIL $d"
sed 's/^/ /' /tmp/err
fail=1
fi
done < /tmp/dirs
exit $fail
# Lint the pipeline definitions themselves. This is the one that would have
# prevented the repo-wide CI outage: `woodpecker lint` exits 1 on a config that
# fails to compile, and exits 0 on mere warnings — so it fails on real breakage
# without blocking on pre-existing noise. Both behaviours were verified against
# this repo (rc=1 reproducing #443, rc=0 on the tree as it stands).
#
# An earlier revision of this comment also asserted that several deploy-*.yml
# files emit "Additional property timeout is not allowed" warnings. That was
# observed under the v3.9.0 CLI this step used to pin, and it is NOT known to
# still hold on v3.16.0 — review raised the likelihood that the warning was
# schema drift in a 7-minor-old CLI rather than real debt in those files, which
# is one of the reasons this pin moved to latest stable. It cannot be confirmed
# from a green run either, because the loop below sends lint output to /tmp/out
# and prints it only on failure, so warnings are invisible when the step passes.
# The claim is dropped rather than restated: what this step guarantees is the
# exit-code contract above, which is what the gate actually rests on.
# Latest stable, per the standing rule that any software uses the latest
# stable version. Matching the deployed woodpecker-server (v3.16.0) is a
# CONSEQUENCE of that, not a separate requirement — the server is bumped by
# image-automation within the 3.x range, so both land on the same release.
#
# It matters here because the CLI compiles the config against its own schema:
# one behind the server can pass a config the server then rejects, or reject
# one it accepts, and the gate stops predicting what the server will do —
# which is its only purpose.
#
# `-alpine` is required, not cosmetic: the plain vX.Y.Z CLI image ships no
# shell, and the loop below needs `for`, `sed` and `echo` around the binary.
# A step using the plain tag fails to start rather than failing a check.
#
# DRIFT RISK — accepted, and NOT fixable here. The server auto-bumps via
# image-automation; this pin cannot, so the two will drift apart.
# Image-automation can only patch YAML that is a Kubernetes manifest —
# fluxcd/flux2#2826: "Flux can only patch Kubernetes manifests. This means the
# YAML manifest must have `apiVersion` and `kind` fields." A Woodpecker
# pipeline file has neither, so an `$imagepolicy` marker here is silently
# ignored. That is a property of the FILE, not of `update.path`: widening the
# automation's path to `./` would buy nothing, and would additionally drag the
# whole ansible/ and esphome/ trees into the walk. Do not try it — it looks
# like the fix and is not.
#
# So this bump is manual, and drift is caught by the update-audit agent
# (ci/agents/update-audit/prompt.md, item 5) which is told to flag this image
# whenever it trails the deployed server. If you bump the server, bump here.
- name: woodpecker-lint
depends_on: [] # see note above `steps:`
image: woodpeckerci/woodpecker-cli:v3.16.0-alpine
commands:
- |
set -eu
fail=0
for f in .woodpecker/*.yml; do
if /bin/woodpecker-cli lint "$f" >/tmp/out 2>&1; then
echo "ok $f"
else
echo "FAIL $f"
sed 's/^/ /' /tmp/out
fail=1
fi
done
exit $fail
# Standalone shell scripts under kubernetes/ (sidecar and bootstrap scripts
# mounted via configMapGenerator). Severity is pinned to `error` on purpose:
# style and info findings would fail the build on existing scripts for no
# safety gain. All scripts in the tree are clean at this level today.
#
# NOTE this does NOT cover shell embedded in manifests (init containers, probe
# exec blocks) — shellcheck cannot see inside YAML. That embedded shell is
# where several real bugs lived this year, so it remains a gap worth closing
# separately rather than one this step quietly implies is covered.
- name: shellcheck
depends_on: [] # see note above `steps:`
image: koalaman/shellcheck-alpine:v0.11.0
commands:
- |
set -eu
files=$(find kubernetes -name '*.sh' | sort)
# Same no-op guard as kustomize-build: if discovery ever returns nothing,
# fail loudly instead of passing having checked no files.
#
# This floor is 1 while kustomize-build's is 50, and the asymmetry is
# deliberate rather than an oversight. That floor is loose relative to the
# tree it guards, so it catches both total failure and gross partial loss
# while tolerating normal churn. The shell-script set is small enough that
# any floor tight enough to catch PARTIAL loss would sit inside normal
# churn — deleting one script would fail CI spuriously, and the fix a
# hurried person reaches for is lowering the floor, which is how a guard
# quietly becomes decoration. A floor that has to be edited whenever the
# tree changes is not a guard, it is a snapshot with an exit code.
#
# So this catches the failure that actually happened here (discovery
# silently returning nothing, step green having checked no files) and not
# partial loss. Worth revisiting only if kubernetes/ grows a lot more
# shell, at which point a loose proportionate floor becomes possible.
count=$(printf '%s\n' "$files" | grep -c . || true)
if [ "$count" -lt 1 ]; then
echo "FAIL: no shell scripts found under kubernetes/ — discovery is broken."
exit 1
fi
echo "discovered $count shell scripts"
# Same file-fed while-read reasoning as kustomize-build above.
printf '%s\n' "$files" > /tmp/files
fail=0
while IFS= read -r f; do
[ -n "$f" ] || continue
if shellcheck -S error "$f"; then
echo "ok $f"
else
echo "FAIL $f"
fail=1
fi
done < /tmp/files
exit $fail
# Guard the convention that cost a day to diagnose: registry-local images are
# pinned by DATED TAG, never by digest. Digests here are garbage-collected once
# :latest and the dated tags move past them — and that happens on edits to
# .woodpecker/build-ci-images.yml, a shared file, so an unrelated image addition
# silently orphans the manifest a rotator was pinned to. 11 rotators were dead
# this way and would have failed on their first scheduled run months later.
#
# Scoped to the rotation directory, which is where the convention is documented
# and where the failure actually bit.
#
# The `grep -v kustomization.yaml` is load-bearing, not tidying: that file
# documents this very rule, so its comment contains the literal string
# `image:.*@sha256` and therefore MATCHES the pattern. Anchoring on `image:`
# does not prevent that — verified by running the bare grep, which returns
# kustomization.yaml. Only the filename filter excludes it. Drop the filter and
# this step fails permanently on its own documentation.
- name: no-digest-pins
depends_on: [] # see note above `steps:`
image: gitea.renner.uno/gitea_admin/ci-k8s:2026-07-30
commands:
- |
set -eu
hits=$(grep -l 'image:.*@sha256' kubernetes/infrastructure/openbao/rotation/*.yaml 2>/dev/null \
| grep -v kustomization.yaml || true)
if [ -n "$hits" ]; then
echo "FAIL: digest-pinned images found; use a dated tag (see rotation/kustomization.yaml):"
echo "$hits" | sed 's/^/ /'
exit 1
fi
echo "ok no digest pins under openbao/rotation/"
@@ -33,13 +33,43 @@ Build and boot the dedicated installer image — see
(partitions the internal disk — NVMe if present, else the eMMC; the
installer autodetects — installs the ansible-ready base system with SSH
keys, DHCP, docker — asks for confirmation before wiping).
3. Add the DHCP reservation/DNS entry for `edge-1.srv.renner.uno` on the
UDM, confirm `ssh root@edge-1.srv.renner.uno` works.
4. From the workstation:
3. Confirm the box resolves and is reachable by name — `ssh root@<host>` must
work before anything else. The UDM registers DHCP leases in DNS, so the
record appears on its own; the host is identified by name everywhere
(inventory, plays, this file) and its address is never recorded. Do not
pin it to a reservation.
4. Add the host to `[edge_host]` in `inventory/pve_hosts.yml`, by name.
That group is the base: every edge thin client belongs to it, and it
assumes no peripherals.
5. From the workstation:
```
ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
```
One unrestricted run over the whole group. The play is self-contained --
it carries `certificates` and `common` itself, so there is no second
command and no `--limit`. Running it converges every edge host, which is
the point: a run that touches only the new box tells you nothing about
the others.
## Peripherals
Each attached device is its own inventory group and its own playbook. Group
membership states which hardware a host physically has -- there are no flags
and no `when:` on the roles, so moving a stick between boxes is a one-line
inventory change.
| device | group | playbook |
|---|---|---|
| Innomaker USB2CAN (Vitocal/VX3 + E380) | `heating_can` | `plays/install-heating-can.yml` |
| Nabu Casa ZWA-2 Z-Wave radio | `zwave_gateway` | `plays/install-zwave-gateway.yml` |
| FTDI serial power-meter head | `power_meter` | `plays/install-power-meter.yml` |
Add the host to the matching group, then run that playbook. They depend on
the base play having run: all three need the local MQTT bridge, and two of
them need docker.
A host with nothing attached is simply absent from all three groups and needs
none of these commands.
## Secrets (before first play run)
@@ -85,7 +115,7 @@ zwave-js (order matters — never two servers on one radio):
`kubectl -n home-assistant exec deploy/zwave-js -- tar cf - -C /usr/src/app store | tar xf - -C /opt/edge-host/zwave-js/`
- [ ] Scale down in-cluster: `kubectl -n home-assistant scale deploy/zwave-js --replicas 0`
- [ ] Move the ZWA-2 stick to edge-1, `docker compose up -d zwave-js`
- [ ] Point HA `zwave_js` integration at `ws://edge-1.srv.renner.uno:3000`
- [ ] Point HA `zwave_js` integration at `ws://edge-1.iot.renner.uno:3000`
- [ ] Nodes alive in the UI on :8091 → remove the flux kustomization
`home-assistant-zwavejs` + PVC in a follow-up PR
@@ -113,8 +143,12 @@ Afterwards:
deliberate for sml2mqtt, acceptable for zwave-js). If a zwave-js bump
changes its Alpine major, also update `edge_zwave_alpine_base` and refresh
the preloaded `.so` (see "Logging & eMMC" → 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).
- open3e / E3onCAN updates: both are pinned to commits in
`roles/heating-can/defaults/main.yml`, so re-running the play does NOT pick
up upstream changes -- that is deliberate, these drive the heat pump. The
weekly update-audit reports when either falls behind (see
`ci/agents/update-audit/prompt.md`); bump the pin and re-run.
Pinning to a tag is not an option for open3e: it publishes none.
- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule),
restart afterwards.
- can-utils (`candump`) is AUR-only on Arch — install manually via
@@ -1,6 +1,6 @@
---
# Secret indirection — real values live in vault.yml (ansible-vault) next
# to this file; see roles/edge-host/README.md for how to populate it from
# to this file; see docs/edge-host.md for how to populate it from
# the cluster secrets.
edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
@@ -36,3 +36,18 @@ keepalived_ntp_sysctl:
# Realtek NIC on the Wyse 3040 has no hardware timestamping.
chrony_hwtimestamp: false
# --- Local MQTT contract -------------------------------------------------
# Shared between the broker and its clients, so it belongs to the inventory
# rather than to either side's role. mqtt-bridge listens here; heating-can and
# power-meter-sml publish here; and the bridge authenticates to the cluster
# broker AS the open3e user, so it needs that identity too.
#
# Keeping these in one role's defaults only worked while a single play loaded
# every role at once. Splitting the plays by capability exposed it: the bridge
# could not render without a heating-can default, and the CAN services could
# not render without an mqtt-bridge default.
edge_mqtt_host: 127.0.0.1
edge_mqtt_port: 1883
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
@@ -0,0 +1,18 @@
---
# Shared configuration for every NTP server (see plays/install-ntp-ha.yml).
# IPv6 service address -- what ntp.srv.renner.uno resolves to, and the path
# clients actually use. The IPv4 VIP is legacy.
#
# Defined at group level rather than in the play because install-ntp-ha.yml runs
# two plays over this group -- withdraw the static assignments fleet-wide, then
# hand the address to keepalived -- and both need the same value. Duplicating a
# service address across plays is exactly the sort of thing that drifts.
ntp_vip6: "fd40::123/128"
# Link-local address carried as the FIRST virtual_ipaddress of the IPv6 VRRP
# instance. RFC 5798 gives an IPv6 virtual router a link-local primary, and
# keepalived warns "the first IPv6 VIP address should be link local" without
# one. fe80::123 mirrors the service address and cannot collide with a real
# NIC's EUI-64 link-local.
ntp_vip6_linklocal: "fe80::123/64"
+42 -3
View File
@@ -1,7 +1,19 @@
# Reached by name, like every other host in this file. These three were the last
# entries still addressed by IPv4 literal; authentik, netbird, proxy, nas-1,
# grafana and edge-1 have always used their FQDN.
#
# An address in an inventory is allocator state standing in for identity, and it
# pins access to the legacy family -- the names resolve IPv6-first, which is the
# primary path on this network. DNS is the authoritative source for where a host
# is; this file should say which host, not where it currently sits.
#
# The short inventory_hostname is kept deliberately: plays key on it
# (ntp_servers[inventory_hostname] in install-ntp-ha.yml, node names in opentofu,
# the prometheus targets), so only ansible_host changes.
[pve_cluster]
jupiter ansible_host=10.2.40.201 ansible_user=root
neptune ansible_host=10.2.40.202 ansible_user=root
saturn ansible_host=10.2.40.203 ansible_user=root
jupiter ansible_host=jupiter.renner.uno ansible_user=root
neptune ansible_host=neptune.renner.uno ansible_user=root
saturn ansible_host=saturn.renner.uno ansible_user=root
[authentik_db]
authentik-db-1.srv.renner.uno ansible_user=root
@@ -29,9 +41,36 @@ nas-1.renner.uno ansible_user=ladmin ansible_python_interpreter=/usr/bin/python3
[grafana]
grafana.srv.renner.uno ansible_user=ladmin
# Every bare-metal edge thin client. Gets the host-generic roles only:
# flash-wear handling, the log shipper, the MQTT bridge, the VLAN
# sub-interface. Nothing here assumes any peripheral is attached.
[edge_host]
edge-1.iot.renner.uno ansible_user=root
# Capability groups. Membership states which hardware a host physically has,
# and each service playbook targets the group for its own concern. No flags and
# no `when:` on the roles -- moving a stick to another box is a one-line change
# here, which is what it should be.
#
# These are children of [edge_host], declared below: a peripheral is added to an
# edge client, it does not replace the base. Making that a :children relation
# rather than a comment means a host added only to a capability group still
# resolves group_vars/edge_host -- where the local MQTT contract lives -- so it
# cannot silently come up without edge_mqtt_host and friends.
[zwave_gateway]
edge-1.iot.renner.uno
[heating_can]
edge-1.iot.renner.uno
[power_meter]
edge-1.iot.renner.uno
[edge_host:children]
zwave_gateway
heating_can
power_meter
# NTP servers. Includes edge-1 so the role survives the PVE hosts being
# retired for bare-metal Talos, which cannot run chrony or keepalived itself.
# See plays/install-ntp-ha.yml for why keepalived is scoped more narrowly
+37 -9
View File
@@ -1,13 +1,41 @@
---
# Bare-metal Arch Linux edge host (Dell Wyse 3040, internal eMMC) — replaces
# the k8s-edge Talos VM
# (opentofu vm_153) as the home for hardware-bound services:
# - zwave-js-ui (ZWA-2 Z-Wave USB radio, docker compose)
# - sml2mqtt (FTDI power-meter reader, docker compose)
# - open3e (Innomaker USB2CAN -> Vitocal/VX3, native venv + systemd)
# Base install procedure and cutover checklist: ansible/roles/edge-host/README.md
- name: Install Arch edge host
# Bare-metal Arch edge hosts (Dell Wyse 3040 and similar) — the home for
# hardware-bound services, because Talos supports neither SocketCAN nor USB
# passthrough.
#
# This play is the BASE only: what every edge thin client gets regardless of
# what is plugged into it. The peripherals live in their own playbooks against
# their own inventory groups:
#
# plays/install-zwave-gateway.yml [zwave_gateway] ZWA-2 radio
# plays/install-heating-can.yml [heating_can] Innomaker USB2CAN
# plays/install-power-meter.yml [power_meter] FTDI meter head
#
# Splitting the roles without splitting the plays would have kept the coupling:
# one play applying every role to one group is the monolith again, a layer up.
# A host with no peripherals is simply absent from those groups -- no flags, no
# `when:` on the roles.
#
# Self-contained: `certificates` and `common` are the parts of plays/common.yml
# that apply to an edge host, carried here so bootstrapping one is a single
# unrestricted run over the group. Not carried over: qemu-guest-agent (bare
# metal, not VMs) and monitoring-agent (Debian-oriented; these hosts ship logs
# via edge-log-shipper).
#
# Order is dependency order: edge-flash-wear before docker, so the journal is
# volatile before containers start logging into it.
- name: Install Arch edge host (base)
hosts: edge_host
# Connection is already root (ansible_user=root), so this is a root-to-root
# no-op today. Kept explicit, matching plays/common.yml, so the roles below
# do not silently depend on the connection user staying privileged.
become: true
roles:
- certificates
- common
- edge-flash-wear
- docker-install
- edge-host
- edge-log-shipper
- mqtt-bridge
- edge-vlan
+18
View File
@@ -0,0 +1,18 @@
---
# Heating system over the Viessmann E3 CAN bus, for hosts with the Innomaker
# USB2CAN adapter attached.
#
# Targets [heating_can]: membership means the adapter is physically present.
# Two roles because they are two concerns -- the interface is host network
# state, the services consume it -- but one group, because both need the same
# hardware.
#
# Requires the base play (plays/install-edge-host.yml) to have run: these
# services publish to the local MQTT bridge it sets up.
- name: Install heating CAN services
hosts: heating_can
become: true
roles:
- socketcan
- heating-can
+88 -1
View File
@@ -22,12 +22,54 @@
# so it stays BACKUP while the PVE nodes exist and wins the VIP by default once
# they are gone -- no config change required at cutover.
- name: Install NTP Server (HA)
# Phase 1. Every static assignment of the IPv6 service address must be gone
# fleet-wide before ANY host claims it via VRRP.
#
# DAD is enabled on the VRRP address (deliberately -- under single-master VRRP a
# duplicate is a real fault worth detecting). So a static copy on one host and a
# VRRP-managed copy on another cannot coexist: the kernel marks the newcomer
# `dadfailed deprecated`, and its master then believes it is serving while
# carrying an unusable address. That is not hypothetical -- it is what edge-1
# did on 2026-08-07 when it took the address while three PVE nodes still held it
# statically.
#
# Deliberately NOT serial: this play must complete everywhere before phase 2
# starts anywhere. Doing the withdrawal per host inside phase 2 cannot avoid the
# overlap, because with serial: 1 the first host claims the address while the
# rest are still static.
#
# There is a gap between the phases where no host holds the address. It is
# bounded by one play's run time and clients simply retry.
- name: Withdraw static assignments of the NTP service address
hosts: ntp_cluster
become: true
vars:
keepalived_ntp_vip6: "{{ ntp_vip6 }}"
keepalived_ntp_interface: "{{ ntp_keepalived_interface }}"
tasks:
- name: Withdraw any static assignment of the service address
ansible.builtin.include_role:
name: keepalived-ntp
tasks_from: withdraw-static.yml
when: ntp_keepalived_enabled | default(false)
# Phase 2. Configure chrony and hand the address to keepalived.
- name: Install NTP Server (HA)
hosts: ntp_cluster
become: true
# One node at a time. The keepalived role withdraws the statically assigned
# IPv6 service address and restarts keepalived so the address is re-taken
# under VRRP control; doing that across every NTP server at once would leave
# the whole fleet with nobody holding fd40::123 until the elections settle.
# Serialised, the remaining members keep answering throughout.
serial: 1
vars:
ntp_vip: "10.2.40.4"
# ntp_vip6 / ntp_vip6_linklocal come from group_vars/ntp_cluster.yml, so
# both plays in this file see one definition.
ntp_servers:
jupiter:
priority: 100
@@ -49,6 +91,8 @@
when: ntp_keepalived_enabled | default(false)
vars:
keepalived_ntp_vip: "{{ ntp_vip }}"
keepalived_ntp_vip6: "{{ ntp_vip6 }}"
keepalived_ntp_vip6_linklocal: "{{ ntp_vip6_linklocal }}"
keepalived_ntp_state: "{{ ntp_servers[inventory_hostname].state }}"
keepalived_ntp_priority: "{{ ntp_servers[inventory_hostname].priority }}"
keepalived_ntp_interface: "{{ ntp_keepalived_interface }}"
@@ -90,6 +134,49 @@
{% endif %}
when: ntp_keepalived_enabled | default(false)
# The v6 address is the path clients actually use, so it gets the same
# check as the v4 VIP. Without this a broken v6 rollout -- the address
# never claimed, or -nodad not behaving as expected on a given kernel --
# would not surface here at all, only later by reading
# /var/log/keepalived-state.log by hand.
#
# `ip -6 addr show ... to <addr>` prints the address when present and
# nothing when absent, so emptiness is the test.
#
# Presence cannot itself distinguish a VRRP-held address from a static
# leftover -- they look identical. It is only meaningful because the
# keepalived-ntp role asserts, on both distros, that no static assignment
# survives: the play fails before reaching here if one does.
- name: Check if this server holds the IPv6 service address
ansible.builtin.command:
argv:
- ip
- -6
- addr
- show
- dev
- "{{ ntp_keepalived_interface }}"
- to
- "{{ ntp_vip6 }}"
register: vip6_check
failed_when: false
changed_when: false
when: ntp_keepalived_enabled | default(false)
- name: Display IPv6 service address status
ansible.builtin.debug:
msg: >
{% if vip6_check.stdout | default('') | length > 0 %}
This server ({{ inventory_hostname }}) holds the IPv6 service address
{{ ntp_vip6 }} -- it is the v6 MASTER and is serving ntp.srv.renner.uno
{% else %}
This server ({{ inventory_hostname }}) does not hold {{ ntp_vip6 }}.
Expected on a v6 BACKUP. If NO host holds it, check_ntp is failing
everywhere -- inspect `chronyc tracking` and
/var/log/keepalived-ntp-health.log
{% endif %}
when: ntp_keepalived_enabled | default(false)
- name: Report hosts serving NTP without participating in the VIP
ansible.builtin.debug:
msg: >
+14
View File
@@ -0,0 +1,14 @@
---
# Power-meter reader (sml2mqtt) for hosts with the FTDI serial meter head
# attached.
#
# Targets [power_meter]: membership means the meter head is physically present.
#
# Requires the base play (plays/install-edge-host.yml) to have run: it needs
# docker and publishes to the local MQTT bridge.
- name: Install power-meter reader
hosts: power_meter
become: true
roles:
- power-meter-sml
+15
View File
@@ -0,0 +1,15 @@
---
# Z-Wave gateway (zwave-js-ui) for hosts with the ZWA-2 USB radio attached.
#
# Targets [zwave_gateway]: membership means the radio is physically present.
# Moving the stick to another edge host is a one-line inventory change.
#
# Requires the base play (plays/install-edge-host.yml) to have run: it needs
# docker, and the flash-wear handling that the libeatmydata preload exists to
# complement.
- name: Install Z-Wave gateway
hosts: zwave_gateway
become: true
roles:
- zwave-gateway
@@ -1,9 +1,25 @@
---
# Chrony NTP Server Role Defaults
# Networks allowed to use this server as NTP source
# Networks allowed to use this server as NTP source.
#
# BOTH families are required. IPv6 is the primary address family here -- the
# service address ntp.srv.renner.uno resolves to fd40::123 -- so an IPv4-only
# allow list refuses every client that uses the documented name.
#
# The hosts carried `allow fd00::/8` for a long time without it ever being in
# this file. Applying the role on 2026-08-07 rendered the template over it and
# dropped the line on all four NTP servers at once, which took internal NTP
# down over IPv6 until it was added here. Same class of untracked host state as
# the anycast service address, and the same failure: config that exists only on
# the box survives right up until something re-renders it.
chrony_allowed_networks:
- "10.2.0.0/16"
- "fd00::/8"
# Link-local. A client on the same segment may source its query from fe80::,
# and without this chrony refuses it -- the request arrives and is dropped,
# which looks like the server being unreachable rather than a policy refusal.
- "fe80::/10"
# Primary NTP servers (PTB - Physikalisch-Technische Bundesanstalt, Stratum 1)
chrony_upstream_servers:
@@ -18,6 +34,26 @@ chrony_upstream_pools:
# Local stratum if disconnected from upstream
chrony_local_stratum: 10
# Step (rather than slew) the clock when the offset exceeds the threshold, but
# only for the first N updates after chronyd starts. Without a makestep
# directive chronyd only ever slews, so a large offset is never closed in
# practice.
#
# edge-1, 2026-08-04: the CMOS battery is dead, so after a power loss the RTC
# came up reading 2018-08-20. The kernel set the clock from it, systemd floored
# that forward to its own built-in epoch (2026-06-26), and it ended up at
# ~2026-07-19 -- about 16 days behind. chronyd then synced to ptbtime1, reported
# "System time: 1376235.375 seconds slow of NTP time", and corrected none of it.
# With host time before the cluster certificate's NotBefore, every outbound TLS
# handshake failed x509 validation, taking out the MQTT bridge (heating
# telemetry) and the Loki log shipper at once. The 16 days is incidental -- it
# is just where the software flooring landed; the fault is that nothing steps.
#
# Limiting to the first few updates keeps steps confined to boot, so a
# long-running server is never jumped out from under its workloads.
chrony_makestep_threshold: 1.0
chrony_makestep_limit: 3
# Hardware timestamping. Fine on the PVE nodes' Intel NICs; the Wyse 3040
# (edge-1) has a Realtek NIC that does not support it, and chronyd logs an
# error per interface when asked for it. Overridden to false in
@@ -14,6 +14,11 @@ pool {{ pool }}
# Record the rate at which the system clock gains/losses time
driftfile /var/lib/chrony/chrony.drift
# Step the clock instead of slewing it when the offset is large, limited to the
# first few updates after start so only boot-time corrections jump. Rationale
# and the incident behind it: chrony_makestep_* in the role's defaults/main.yml.
makestep {{ chrony_makestep_threshold }} {{ chrony_makestep_limit }}
# Allow NTP client access from local networks
{% for network in chrony_allowed_networks %}
allow {{ network }}
+5 -2
View File
@@ -118,8 +118,11 @@ After the `kubernetes/apps/claude-workstation/` PR merges and Flux reconciles:
NS=apps
TOKEN=$(kubectl -n $NS get secret claude-workstation-token -o jsonpath='{.data.token}' | base64 -d)
kubectl -n $NS get secret claude-workstation-token -o jsonpath='{.data.ca\.crt}' | base64 -d > /tmp/ca.crt
# server = the API endpoint reachable over the site-to-site route (a CP node or VIP):
kubectl config set-cluster homelab --server=https://10.2.40.201:6443 \
# server = the cluster's stable API endpoint (kubernetes/talos/patches/cluster.yaml).
# Use the name, not an address: it is what the API server certificate carries as a
# SAN, so an IP here fails TLS verification even when it is reachable. It must
# resolve from the workstation over the site-to-site route.
kubectl config set-cluster homelab --server=https://k8s.renner.uno:6443 \
--certificate-authority=/tmp/ca.crt --embed-certs=true --kubeconfig=/tmp/claude-ws.kubeconfig
kubectl config set-credentials claude-workstation --token="$TOKEN" --kubeconfig=/tmp/claude-ws.kubeconfig
kubectl config set-context homelab --cluster=homelab --user=claude-workstation --kubeconfig=/tmp/claude-ws.kubeconfig
@@ -0,0 +1,15 @@
---
# Flash-wear mitigation.
# --- Logging & eMMC wear mitigation ------------------------------------
# edge-1 runs on a small internal eMMC (Dell Wyse 3040, 7.3 GB), so logs are
# kept off local flash: containers + native services log to a RAM-only
# journal and Grafana Alloy ships it to Loki. TRIM, noatime and a weekly
# wear check round out the flash-longevity measures. See README "Logging &
# eMMC".
edge_journald_runtime_max: "128M" # cap the volatile (RAM) journal
# Container stdout -> journal, no json log files on the flash. The journal
# itself is volatile (see the tasks above), so container logs land in RAM and
# are shipped off the box by edge-log-shipper.
docker_log_driver: journald
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# edge-1 eMMC wear/health check. Managed by roles/edge-host.
# edge-1 eMMC wear/health check. Managed by roles/edge-flash-wear.
#
# Reads the JEDEC eMMC 5.x life-time and pre-EOL estimators exposed via sysfs
# and emits a warning-priority journal message when the flash leaves the
@@ -0,0 +1,17 @@
---
- name: Restart journald
ansible.builtin.service:
name: systemd-journald
state: restarted
# Handlers run in definition order, so this fires after 'Restart journald':
# once the journal is volatile, the on-disk store is dead weight on the flash.
- name: Purge persistent journal store
ansible.builtin.file:
path: /var/log/journal
state: absent
- name: Restart docker
ansible.builtin.service:
name: docker
state: restarted
@@ -0,0 +1,100 @@
---
# Flash-wear mitigation for hosts on small eMMC (Dell Wyse 3040 and similar).
#
# journald -> volatile (RAM) and size-capped, root mounted noatime+lazytime,
# periodic TRIM, and a weekly wear check. Shipping the volatile journal off the
# box is a separate concern -- see the edge-log-shipper role.
- name: Install mmc-utils
ansible.builtin.package:
name: mmc-utils # eMMC lifetime/EOL introspection (mmc extcsd read)
state: present
- name: Ensure journald drop-in directory exists
ansible.builtin.file:
path: /etc/systemd/journald.conf.d
state: directory
mode: "0755"
- name: Switch the systemd journal to volatile (RAM) storage
ansible.builtin.template:
src: journald-edge.conf.j2
dest: /etc/systemd/journald.conf.d/00-edge-emmc.conf
mode: "0644"
notify:
- Restart journald
- Purge persistent journal store
- name: Resolve root filesystem facts (for noatime)
ansible.builtin.set_fact:
edge_root_fs: "{{ ansible_facts.mounts | selectattr('mount', 'equalto', '/') | first }}"
- name: Refuse to touch fstab without a stable root UUID
ansible.builtin.assert:
that:
- edge_root_fs.uuid | default('') | length > 0
fail_msg: "Could not determine root UUID from facts; not rewriting fstab."
# lazytime batches inode-timestamp writeback (e.g. the zwave-js lockfile
# heartbeat that otherwise dirties the journal every couple seconds); noatime
# drops access-time writes. Neither weakens data durability.
- name: Mount root with noatime + lazytime (persisted in fstab)
ansible.posix.mount:
path: /
src: "UUID={{ edge_root_fs.uuid }}"
fstype: "{{ edge_root_fs.fstype }}"
opts: "{{ ((edge_root_fs.options.split(',') | reject('in', ['atime', 'noatime', 'relatime', 'diratime', 'lazytime', 'nolazytime']) | list) + ['noatime', 'lazytime']) | unique | join(',') }}"
dump: "0"
passno: "1" # root fs — keep boot-time fsck ordering
backup: true
state: mounted
- name: Enable periodic TRIM (helps eMMC wear-leveling)
ansible.builtin.systemd:
name: fstrim.timer
enabled: true
state: started
- name: Install eMMC health-check script
ansible.builtin.copy:
src: edge-emmc-health
dest: /usr/local/bin/edge-emmc-health
mode: "0755"
- name: Install eMMC health-check service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-emmc-health.service
- edge-emmc-health.timer
- name: Enable eMMC health-check timer
ansible.builtin.systemd:
name: edge-emmc-health.timer
enabled: true
state: started
daemon_reload: true
# Container stdout into the journal instead of json files on disk. This lives
# here, with the rest of the flash-wear handling, rather than in docker-install:
# that role is shared by ten other plays (authentik, n8n, owncloud, grist,
# netbird, gatus, music-assistant, ical-proxy), none of which have a
# daemon.json today. Adding it there would have created one on the next run of
# any of those plays and restarted every container on those hosts.
#
# /etc/docker is created explicitly because this role runs before
# docker-install on a first bootstrap, so the directory may not exist yet. The
# handler runs at the end of the play, by which time docker is installed.
- name: Ensure the docker config directory exists
ansible.builtin.file:
path: /etc/docker
state: directory
mode: "0755"
- name: Route container logs to the journal (keeps json logs off the flash)
ansible.builtin.template:
src: docker-daemon.json.j2
dest: /etc/docker/daemon.json
mode: "0644"
notify: Restart docker
@@ -0,0 +1,3 @@
{
"log-driver": "{{ docker_log_driver }}"
}
@@ -1,4 +1,4 @@
# Managed by roles/edge-host. edge-1 boots off a small internal eMMC, so the
# Managed by roles/edge-flash-wear. edge-1 boots off a small internal eMMC, so the
# journal must not persist to flash: keep it in RAM (/run/log/journal) and cap
# its size. Grafana Alloy (edge-alloy.service) ships this journal to Loki
# before it rotates out of RAM — nothing durable lives here.
-118
View File
@@ -1,118 +0,0 @@
---
edge_base_dir: /opt/edge-host
edge_tz: Europe/Berlin
# --- Logging & eMMC wear mitigation ------------------------------------
# edge-1 runs on a small internal eMMC (Dell Wyse 3040, 7.3 GB), so logs are
# kept off local flash: containers + native services log to a RAM-only
# journal and Grafana Alloy ships it to Loki. TRIM, noatime and a weekly
# wear check round out the flash-longevity measures. See README "Logging &
# eMMC".
edge_journald_runtime_max: "128M" # cap the volatile (RAM) journal
edge_docker_log_driver: journald # container stdout -> journal, no json logs on eMMC
# Grafana Alloy log shipper. The srv-network Loki (loki.srv.renner.uno:3100,
# no auth) is firewalled off the IoT VLAN; the only endpoint edge-1 can reach
# is the authenticated cluster ingress (Traefik basic-auth).
edge_alloy_config_file: /etc/alloy/edge.alloy
edge_alloy_http_port: 12345
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
edge_loki_username: edge-1
edge_alloy_labels:
environment: production
datacenter: homelab
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
# mosquitto on loopback, which bridges to the central cluster broker over TLS
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
edge_mqtt_host: 127.0.0.1
edge_mqtt_port: 1883
# Upstream cluster broker the local bridge connects to (TLS; the public
# Let's Encrypt cert is validated against the system CA bundle).
edge_mqtt_cluster_host: mqtt.renner.uno
edge_mqtt_cluster_tls_port: 8883
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
# --- USB devices (stable by-id symlinks). After physically moving the
# sticks to edge-1, verify with `ls -l /dev/serial/by-id/` — the by-id
# names travel with the device, not the host, so these should not change.
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
# --- Images. Versions mirror the k8s manifests at migration time.
# sml2mqtt stays hand-pinned (critical meter bridge — was deliberately
# excluded from image-automation in k8s, keep that property here).
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
# Alpine version the zwave-js-ui image is built on — the source for the musl
# libeatmydata.so preloaded into that container (see tasks "Extract musl
# libeatmydata.so"). Keep in sync with the base if edge_zwavejs_image bumps
# its Alpine major (check: docker exec zwave-js cat /etc/os-release).
edge_zwave_alpine_base: alpine:3.22
# --- CAN / open3e (Viessmann E3 external bus) ---
edge_can_interface: can0
edge_can_bitrate: 250000
edge_open3e_pkg: git+https://github.com/open3e/open3e.git
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
edge_open3e_poll_seconds: 30
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
# codec inherited through the vx3plus overlay.
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
# entries when added), so the multi-frame read is cheap at a 30 s poll.
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
edge_open3e_datapoints: >-
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
# ECU addresses; -dev selects codec set, tx selects the UDS address.
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
# omitting dpList makes open3e fall back to the generic datapoint list,
# which decodes all polled VCMU DIDs.
edge_open3e_devices:
vitocal: { tx: "0x680", dpList: "vcal" }
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
vx3: { tx: "0x6a1", dpList: "/opt/edge-host/open3e/vx3plus.py" }
vcmu: { tx: "0x68c" }
# --- E3onCAN: passive E380 energy-meter listener (no bus writes, so it
# coexists with the single open3e instance). The meter broadcasts at
# ~5 Hz; the gap throttles MQTT to one update per DID per interval.
edge_e3oncan_repo: https://github.com/MyHomeMyData/E3onCAN.git
edge_e3oncan_version: 461dcbdf97e8d72d2edb9929c63b39382af6c3a1 # V0.5.2
edge_e3oncan_gap_seconds: 10
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
# --- Secrets: define these in inventory/group_vars/edge_host/vault.yml
# (ansible-vault). See README.md for the kubectl one-liners that read the
# current values out of the cluster during migration.
# edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
# edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
# edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
# --- Tagged VLAN 40 sub-interface. Off by default; enabled in
# inventory/group_vars/edge_host. Exists so edge-1 can join the NTP keepalived
# VRRP group, which needs layer-2 adjacency with the other members on the
# service VLAN. The untagged VLAN 30 lease stays this host's only default
# route -- see templates/10-vlan40.network.j2.
edge_vlan40_enabled: false
edge_vlan40_parent: enp1s0
edge_vlan40_id: 40
edge_vlan40_name: enp1s0.40
edge_vlan40_parent_network: 20-wired.network
-66
View File
@@ -1,66 +0,0 @@
---
# reload alone only re-reads config files; existing links keep their old
# state until reconfigured.
- name: Reload networkd
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
changed_when: true
# Reconfiguring the parent is what actually materialises the VLAN child -- a
# bare reload leaves the existing link as-is (see above). This re-runs DHCP on
# enp1s0, which is this headless host's only path to the network, so it is the
# one genuinely risky step in the VLAN change.
- name: Reload networkd for VLAN
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_vlan40_parent }}
changed_when: true
# Ordered before 'Restart edge compose services': the daemon must pick up the
# new default log driver before the containers are recreated onto it.
- name: Restart docker
ansible.builtin.service:
name: docker
state: restarted
- name: Restart journald
ansible.builtin.service:
name: systemd-journald
state: restarted
# Handlers run in definition order, so this fires after 'Restart journald':
# once the journal is volatile, the on-disk store is dead weight on the eMMC.
- name: Purge persistent journal store
ansible.builtin.file:
path: /var/log/journal
state: absent
- name: Restart alloy
ansible.builtin.systemd:
name: edge-alloy
state: restarted
daemon_reload: true
# Ordered before the MQTT clients (compose/open3e/e3oncan) so they reconnect
# to a freshly-configured local broker.
- name: Restart mosquitto
ansible.builtin.service:
name: mosquitto
state: restarted
- name: Restart edge compose services
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ edge_base_dir }}"
changed_when: true
- name: Restart open3e
ansible.builtin.systemd:
name: open3e
state: restarted
daemon_reload: true
- name: Restart e3oncan
ansible.builtin.systemd:
name: e3oncan
state: restarted
daemon_reload: true
-440
View File
@@ -1,440 +0,0 @@
---
# Arch edge host: zwave-js-ui + sml2mqtt as docker compose services,
# open3e natively (venv + systemd) because it needs a SocketCAN interface,
# which is host network state, not something to hide in a container.
# Docker itself comes from the docker-install role (Arch-aware).
- name: Install base packages
ansible.builtin.package:
name:
- python
- python-packaging # ansible's pip module imports it on the target
- git
- mmc-utils # eMMC lifetime/EOL introspection (mmc extcsd read)
- grafana-alloy # log shipper -> Loki (see Alloy section below)
- mosquitto # local MQTT bridge broker -> cluster over TLS
state: present
# --- Local MQTT bridge broker. Edge clients publish to this over loopback;
# it bridges to the cluster broker over TLS (open3e/e3oncan can't do MQTTS).
# Configured with persistence off + stdout logging so it writes nothing to the
# eMMC. Deployed before the clients so they have somewhere to connect.
# See edge-mqtt-bridge.conf.j2 / README "MQTT bridge".
- name: Deploy local mosquitto bridge config
ansible.builtin.template:
src: edge-mqtt-bridge.conf.j2
dest: /etc/mosquitto/mosquitto.conf
owner: root
group: mosquitto
mode: "0640" # carries the cluster bridge credentials
notify: Restart mosquitto
- name: Enable local mosquitto bridge
ansible.builtin.systemd:
name: mosquitto
enabled: true
state: started
# ======================================================================
# eMMC wear mitigation + log shipping. edge-1 (Dell Wyse 3040) runs on a
# small internal eMMC, so logs must not churn local flash. Strategy:
# - journald -> volatile (RAM), size-capped (journald-edge.conf)
# - docker -> journald log driver, no json files (docker-daemon.json)
# - Grafana Alloy tails the RAM journal -> Loki, WAL on tmpfs
# plus TRIM, noatime and a weekly wear check for flash longevity.
# See roles/edge-host/README.md "Logging & eMMC".
# ======================================================================
- name: Ensure journald drop-in directory exists
ansible.builtin.file:
path: /etc/systemd/journald.conf.d
state: directory
mode: "0755"
- name: Switch the systemd journal to volatile (RAM) storage
ansible.builtin.template:
src: journald-edge.conf.j2
dest: /etc/systemd/journald.conf.d/00-edge-emmc.conf
mode: "0644"
notify:
- Restart journald
- Purge persistent journal store
- name: Resolve root filesystem facts (for noatime)
ansible.builtin.set_fact:
edge_root_fs: "{{ ansible_facts.mounts | selectattr('mount', 'equalto', '/') | first }}"
- name: Refuse to touch fstab without a stable root UUID
ansible.builtin.assert:
that:
- edge_root_fs.uuid | default('') | length > 0
fail_msg: "Could not determine root UUID from facts; not rewriting fstab."
# lazytime batches inode-timestamp writeback (e.g. the zwave-js lockfile
# heartbeat that otherwise dirties the journal every couple seconds); noatime
# drops access-time writes. Neither weakens data durability.
- name: Mount root with noatime + lazytime (persisted in fstab)
ansible.posix.mount:
path: /
src: "UUID={{ edge_root_fs.uuid }}"
fstype: "{{ edge_root_fs.fstype }}"
opts: "{{ ((edge_root_fs.options.split(',') | reject('in', ['atime', 'noatime', 'relatime', 'diratime', 'lazytime', 'nolazytime']) | list) + ['noatime', 'lazytime']) | unique | join(',') }}"
dump: "0"
passno: "1" # root fs — keep boot-time fsck ordering
backup: true
state: mounted
- name: Enable periodic TRIM (helps eMMC wear-leveling)
ansible.builtin.systemd:
name: fstrim.timer
enabled: true
state: started
- name: Install eMMC health-check script
ansible.builtin.copy:
src: edge-emmc-health
dest: /usr/local/bin/edge-emmc-health
mode: "0755"
- name: Install eMMC health-check service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-emmc-health.service
- edge-emmc-health.timer
- name: Enable eMMC health-check timer
ansible.builtin.systemd:
name: edge-emmc-health.timer
enabled: true
state: started
daemon_reload: true
# --- Grafana Alloy: ship the volatile journal to Loki. Container logs reach
# the journal via docker's journald driver (configured below), native
# services log to the journal directly, so a single journal source covers
# everything. Storage/WAL sits on /run (tmpfs) — see edge-alloy.service.
- name: Create alloy system group
ansible.builtin.group:
name: alloy
system: true
- name: Create alloy system user
ansible.builtin.user:
name: alloy
group: alloy
groups: systemd-journal
system: true
create_home: false
shell: /usr/bin/nologin
- name: Create alloy config directory
ansible.builtin.file:
path: /etc/alloy
state: directory
owner: alloy
group: alloy
mode: "0750"
- name: Deploy alloy config (journal -> Loki)
ansible.builtin.template:
src: edge-alloy.alloy.j2
dest: "{{ edge_alloy_config_file }}"
owner: alloy
group: alloy
mode: "0600" # carries the Loki basic-auth password
notify: Restart alloy
- name: Deploy alloy systemd unit
ansible.builtin.template:
src: edge-alloy.service.j2
dest: /etc/systemd/system/edge-alloy.service
mode: "0644"
notify: Restart alloy
- name: Enable edge-alloy
ansible.builtin.systemd:
name: edge-alloy
enabled: true
state: started
daemon_reload: true
# --- SocketCAN: systemd-networkd owns can0. gs_usb is an in-tree module
# and autoloads when the Innomaker adapter enumerates; networkd then
# matches the interface and applies bitrate + up. RestartSec recovers the
# link after bus-off without manual intervention.
- name: Configure can0 via systemd-networkd
ansible.builtin.template:
src: 80-can0.network.j2
dest: /etc/systemd/network/80-can0.network
mode: "0644"
notify: Reload networkd
# --- Tagged VLAN 40. Additive by construction: the parent link keeps its own
# .network file untouched and gains the VLAN via a drop-in, so reverting is a
# matter of deleting these three paths. This host is headless and enp1s0 is its
# only route to the network, hence the caution.
- name: Create VLAN sub-interface netdev
ansible.builtin.template:
src: 10-vlan40.netdev.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.netdev"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Configure VLAN sub-interface addressing
ansible.builtin.template:
src: 10-vlan40.network.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.network"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Ensure parent network drop-in directory exists
ansible.builtin.file:
path: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d"
state: directory
mode: "0755"
when: edge_vlan40_enabled
tags: [vlan40]
- name: Attach VLAN to parent link via drop-in
ansible.builtin.template:
src: vlan40-parent-dropin.conf.j2
dest: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d/10-vlan40.conf"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Enable systemd-networkd
ansible.builtin.service:
name: systemd-networkd
state: started
enabled: true
# Docker networking needs no daemon.json (both services use network_mode:
# host — real addresses, v4+v6, no bridge/NAT). The daemon.json below exists
# only to route container stdout into the journald log driver so json logs
# never touch the eMMC; the journal itself is volatile (RAM) and shipped to
# Loki by edge-alloy. Both compose services inherit this default driver.
- name: Configure docker daemon (journald log driver, eMMC-friendly)
ansible.builtin.template:
src: docker-daemon.json.j2
dest: /etc/docker/daemon.json
mode: "0644"
notify:
- Restart docker
- Restart edge compose services
# --- Directory layout ---
- name: Create service directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ edge_base_dir }}"
- "{{ edge_base_dir }}/zwave-js/store"
- "{{ edge_base_dir }}/sml2mqtt"
# --- zwave-js eMMC write reduction: batch fsync via libeatmydata ---------
# zwave-js persists its value DB with an fsync on *every* value change
# (jsonl-db, throttleFS unset by zwave-js-ui). On a chatty network that
# fsync storm churns the eMMC (~2.6 GiB/day for ~0.1 KiB/s of real data).
# Preloading libeatmydata makes fsync a no-op so the kernel batches the
# writes via normal writeback — full HA data resolution and cache
# persistence are untouched (see docker-compose.yml.j2 for the mount + env).
# The .so must match the zwave-js-ui image's musl/Alpine base, so extract it
# from that exact Alpine version (bridge networking is broken on this host,
# hence --network=host). Refresh by deleting the .so if the base ever bumps
# its Alpine major version.
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
ansible.builtin.shell:
cmd: |
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
install -m 0644 "$tmp" {{ edge_base_dir }}/zwave-js/libeatmydata.so
creates: "{{ edge_base_dir }}/zwave-js/libeatmydata.so"
notify: Restart edge compose services
# Because fsync is neutralized, a hard power-loss during the (rare) settings
# write could truncate settings.json — which holds the Z-Wave network keys.
# Keep a validated rolling backup on the eMMC so the keys stay recoverable.
- name: Install zwave-js settings.json backup script
ansible.builtin.template:
src: edge-zwave-settings-backup.j2
dest: /usr/local/bin/edge-zwave-settings-backup
mode: "0755"
- name: Install zwave-js settings backup service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-zwave-settings-backup.service
- edge-zwave-settings-backup.timer
- name: Enable zwave-js settings backup timer
ansible.builtin.systemd:
name: edge-zwave-settings-backup.timer
enabled: true
state: started
daemon_reload: true
# --- Compose services: zwave-js-ui + sml2mqtt ---
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ edge_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart edge compose services
- name: Deploy zwave-js env (session secret)
ansible.builtin.template:
src: zwavejs.env.j2
dest: "{{ edge_base_dir }}/zwavejs.env"
mode: "0600"
notify: Restart edge compose services
# sml2mqtt reads only a config file and has no env override for the
# password, so the rendered config carries it — 0600, root-owned, same
# trust boundary as the k8s init-container approach it replaces.
- name: Deploy sml2mqtt config
ansible.builtin.template:
src: sml2mqtt-config.yml.j2
dest: "{{ edge_base_dir }}/sml2mqtt/config.yml"
mode: "0600"
notify: Restart edge compose services
- name: Pull compose images
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ edge_base_dir }}"
changed_when: false
# Tolerates missing USB devices: before the physical cutover the ZWA-2 /
# FTDI sticks are still on the old k8s-edge VM, and a container whose
# device is absent must not block the rest of the role (open3e/CAN moves
# first). Re-run the play — or `docker compose up -d` — after each stick
# arrives.
- name: Start compose services
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ edge_base_dir }}"
register: compose_up
failed_when: >-
compose_up.rc != 0
and 'error gathering device information' not in compose_up.stderr
changed_when: >-
'Started' in compose_up.stderr or 'Created' in compose_up.stderr
or 'Started' in compose_up.stdout or 'Creating' in compose_up.stdout
- name: Warn about containers waiting for their USB device
ansible.builtin.debug:
msg: >-
docker compose could not start every service (USB device not present
yet — expected before cutover): {{ compose_up.stderr_lines | select('search', 'device') | list }}
when: compose_up.rc != 0
# --- open3e: native install. CAN_RAW sockets need no capabilities, so
# the service runs as an unprivileged system user.
- name: Create open3e system user
ansible.builtin.user:
name: open3e
system: true
create_home: false
shell: /usr/bin/nologin
# Owned by the service user: udsoncan writes its log file into the
# working directory.
- name: Create open3e directory
ansible.builtin.file:
path: "{{ edge_base_dir }}/open3e"
state: directory
owner: open3e
group: open3e
mode: "0755"
- name: Install open3e into a venv
ansible.builtin.pip:
name: "{{ edge_open3e_pkg }}"
virtualenv: "{{ edge_base_dir }}/open3e/venv"
virtualenv_command: python -m venv
notify: Restart open3e
- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs)
ansible.builtin.copy:
src: open3e-vx3plus.py
dest: "{{ edge_base_dir }}/open3e/vx3plus.py"
mode: "0644"
notify: Restart open3e
- name: Deploy open3e devices.json
ansible.builtin.copy:
content: "{{ edge_open3e_devices | to_nice_json }}\n"
dest: "{{ edge_base_dir }}/open3e/devices.json"
mode: "0644"
notify: Restart open3e
- name: Deploy open3e environment (broker credential)
ansible.builtin.template:
src: open3e.env.j2
dest: "{{ edge_base_dir }}/open3e/open3e.env"
owner: open3e
mode: "0600"
notify: Restart open3e
- name: Deploy open3e systemd unit
ansible.builtin.template:
src: open3e.service.j2
dest: /etc/systemd/system/open3e.service
mode: "0644"
notify: Restart open3e
- name: Enable open3e
ansible.builtin.systemd:
name: open3e
enabled: true
state: started
daemon_reload: true
# --- E3onCAN: direct E380 meter reads (passive; per-review decision not
# to trust the VX3's relayed meter values). Same broker user as open3e;
# topics land under the same open3e/ prefix as e380_<did>_<name>.
- name: Check out E3onCAN (pinned)
ansible.builtin.git:
repo: "{{ edge_e3oncan_repo }}"
dest: "{{ edge_base_dir }}/e3oncan/src"
version: "{{ edge_e3oncan_version }}"
notify: Restart e3oncan
- name: Create E3onCAN venv with requirements
ansible.builtin.pip:
requirements: "{{ edge_base_dir }}/e3oncan/src/requirements.txt"
virtualenv: "{{ edge_base_dir }}/e3oncan/venv"
virtualenv_command: python -m venv
notify: Restart e3oncan
- name: Deploy e3oncan systemd unit
ansible.builtin.template:
src: e3oncan.service.j2
dest: /etc/systemd/system/e3oncan.service
mode: "0644"
notify: Restart e3oncan
- name: Enable e3oncan
ansible.builtin.systemd:
name: e3oncan
enabled: true
state: started
daemon_reload: true
@@ -1,56 +0,0 @@
# Managed by ansible (roles/edge-host). Mirrors the former k8s manifests:
# kubernetes/apps/home-assistant-zwavejs/deployment.yaml and
# kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml.
#
# No `privileged` needed here: docker's --device handles the device-cgroup
# grant that hostPath in k8s could not (that was the only reason the pods
# ran privileged).
# Both services use host networking: real host addresses (v4+v6), no
# bridge, no NAT. The broker LB is IPv6-first and docker bridge NAT66 is
# a tarpit (unmasqueraded SYNs die silently); the host stack just works.
services:
zwave-js:
image: {{ edge_zwavejs_image }}
container_name: zwave-js
restart: unless-stopped
stop_signal: SIGINT
tty: true
environment:
- TZ={{ edge_tz }}
# Batch the value-DB fsync storm to spare the eMMC (see role tasks /
# README "Logging & eMMC"). Neutralizes fsync only for this container;
# the WS/MQTT value stream to HA and cache persistence are unchanged.
- LD_PRELOAD=/usr/lib/libeatmydata.so
env_file:
- zwavejs.env
devices:
- "{{ edge_zwave_device }}:/dev/zwave"
volumes:
# musl libeatmydata.so extracted from the image's Alpine base (ro)
- ./zwave-js/libeatmydata.so:/usr/lib/libeatmydata.so:ro
# Persistent store: settings.json, device DB, network keys. Seeded
# from the k8s PVC `zwavejs-store` at cutover (see README). Since
# this host is stateful there is no init-container merge step —
# settings.json is edited via the UI and persists here; zwave.port
# stays /dev/zwave.
- ./zwave-js/store:/usr/src/app/store
# Host networking: binds 3000 (Z-Wave JS websocket for HA) and 8091
# (web UI) directly on the host.
network_mode: host
sml2mqtt:
image: {{ edge_sml2mqtt_image }}
container_name: sml2mqtt
restart: unless-stopped
# Outbound-only (serial in, MQTT out) — host networking sidesteps the
# docker NAT66 gap: the broker LB is IPv6-first and bridge-network v6
# SYNs leave unmasqueraded and die. The host stack reaches fd99::81
# fine (open3e proves it continuously).
network_mode: host
# Serial exclusivity: this is the ONLY reader of the meter port —
# HA's edl21 integration stays disabled (see sml2mqtt-config comment
# history in the k8s repo).
devices:
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
volumes:
- ./sml2mqtt/config.yml:/sml2mqtt/config.yml:ro
@@ -1,3 +0,0 @@
{
"log-driver": "{{ edge_docker_log_driver }}"
}
@@ -1,32 +0,0 @@
# Managed by ansible (roles/edge-host).
# PASSIVE listener for the E380 energy meter's CANopen broadcasts — no
# writes on the bus, so it may run alongside the (single) open3e
# instance. Reads the meter directly instead of trusting the VX3's
# relayed values.
[Unit]
Description=E3onCAN E380 energy-meter CAN listener (read-only)
BindsTo=sys-subsystem-net-devices-{{ edge_can_interface }}.device
# mosquitto.service = the local MQTT bridge broker e3oncan publishes to (loopback).
After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target mosquitto.service
Wants=network-online.target mosquitto.service
[Service]
User=open3e
EnvironmentFile={{ edge_base_dir }}/open3e/open3e.env
WorkingDirectory={{ edge_base_dir }}/e3oncan
# Journald-friendly (python block-buffers pipes; lost on restarts).
Environment=PYTHONUNBUFFERED=1
ExecStart={{ edge_base_dir }}/e3oncan/venv/bin/python {{ edge_base_dir }}/e3oncan/src/E3onCANcollect.py \
-c {{ edge_can_interface }} \
-dev e380 \
-g {{ edge_e3oncan_gap_seconds }} \
-m {{ edge_mqtt_host }}:{{ edge_mqtt_port }}:{{ edge_open3e_mqtt_topic }} \
-muser {{ edge_open3e_mqtt_user }} \
-mpass ${OPEN3E_MQTT_PASSWORD} \
-mfstr "{{ edge_e3oncan_mqtt_format }}" \
-j
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -1,7 +0,0 @@
# Managed by ansible (roles/edge-host). Read by open3e.service and
# e3oncan.service.
# Full user:password pair as one variable so the open3e unit can pass it
# as a single -muser argument without exposing it in the unit file;
# e3oncan takes user and password separately (-muser/-mpass).
OPEN3E_MUSER={{ edge_open3e_mqtt_user }}:{{ edge_open3e_mqtt_password }}
OPEN3E_MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
@@ -1,39 +0,0 @@
# Managed by ansible (roles/edge-host).
# Read-mostly listener — no -w flag, no service 0x77. The ONE sanctioned
# write path is the MQTT command topic (-l): HA publishes the DHW
# one-time-charge command there (scope decision 2026-07-02, see vican
# CLAUDE.md). Anything beyond that single write gets its own review.
# -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
# mosquitto.service = the local MQTT bridge broker open3e publishes to (loopback).
After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target mosquitto.service
Wants=network-online.target mosquitto.service
[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 }} \
-l {{ edge_open3e_mqtt_topic }}/cmnd \
-j
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
---
# Journal -> Loki shipping.
#
# The srv-network Loki (loki.srv.renner.uno:3100, no auth) is firewalled off
# the IoT VLAN; the only endpoint these hosts can reach is the authenticated
# cluster ingress. edge_loki_password comes from the vault.
edge_alloy_config_file: /etc/alloy/edge.alloy
edge_alloy_http_port: 12345
edge_alloy_bin: /usr/bin/grafana-alloy # Arch `grafana-alloy` pkg names the binary this, not `alloy`
edge_loki_push_url: "https://loki.renner.uno/loki/api/v1/push"
edge_loki_username: edge-1
edge_alloy_labels:
environment: production
datacenter: homelab
@@ -0,0 +1,6 @@
---
- name: Restart alloy
ansible.builtin.systemd:
name: edge-alloy
state: restarted
daemon_reload: true
@@ -0,0 +1,57 @@
---
# Ship the systemd journal to Loki with Grafana Alloy.
#
# Pairs with edge-flash-wear, which makes the journal volatile: container logs
# reach the journal via docker s journald driver and native services log to it
# directly, so one journal source covers everything. Alloy s storage/WAL sits
# on /run (tmpfs) so it does not write to flash either.
- name: Install grafana-alloy
ansible.builtin.package:
name: grafana-alloy
state: present
- name: Create alloy system group
ansible.builtin.group:
name: alloy
system: true
- name: Create alloy system user
ansible.builtin.user:
name: alloy
group: alloy
groups: systemd-journal
system: true
create_home: false
shell: /usr/bin/nologin
- name: Create alloy config directory
ansible.builtin.file:
path: /etc/alloy
state: directory
owner: alloy
group: alloy
mode: "0750"
- name: Deploy alloy config (journal -> Loki)
ansible.builtin.template:
src: edge-alloy.alloy.j2
dest: "{{ edge_alloy_config_file }}"
owner: alloy
group: alloy
mode: "0600" # carries the Loki basic-auth password
notify: Restart alloy
- name: Deploy alloy systemd unit
ansible.builtin.template:
src: edge-alloy.service.j2
dest: /etc/systemd/system/edge-alloy.service
mode: "0644"
notify: Restart alloy
- name: Enable edge-alloy
ansible.builtin.systemd:
name: edge-alloy
enabled: true
state: started
daemon_reload: true
@@ -1,4 +1,4 @@
// Grafana Alloy — edge-1 log shipper. Managed by roles/edge-host.
// Grafana Alloy — edge-1 log shipper. Managed by roles/edge-log-shipper.
//
// edge-1 runs on internal eMMC, so logs are kept out of local flash: the
// systemd journal is volatile (RAM, see journald-edge.conf) and container
+11
View File
@@ -0,0 +1,11 @@
---
# --- Tagged VLAN 40 sub-interface. Off by default; enabled in
# inventory/group_vars/edge_host. Exists so edge-1 can join the NTP keepalived
# VRRP group, which needs layer-2 adjacency with the other members on the
# service VLAN. The untagged VLAN 30 lease stays this host's only default
# route -- see templates/10-vlan40.network.j2.
edge_vlan40_enabled: false
edge_vlan40_parent: enp1s0
edge_vlan40_id: 40
edge_vlan40_name: enp1s0.40
edge_vlan40_parent_network: 20-wired.network
@@ -0,0 +1,9 @@
---
# Reconfiguring the parent is what actually materialises the VLAN child -- a
# bare reload leaves the existing link as-is. This re-runs DHCP on the parent,
# which is a headless host's only path to the network, so it is the one
# genuinely risky step in the VLAN change.
- name: Reload networkd for VLAN
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_vlan40_parent }}
changed_when: true
+48
View File
@@ -0,0 +1,48 @@
---
# Tagged VLAN sub-interface via systemd-networkd.
#
# Additive by construction: the parent link keeps its own .network file and
# gains the VLAN through a drop-in, so reverting is deleting three paths. These
# hosts are headless and the parent is their only route to the network, hence
# the caution.
- name: Create VLAN sub-interface netdev
ansible.builtin.template:
src: 10-vlan40.netdev.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.netdev"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Configure VLAN sub-interface addressing
ansible.builtin.template:
src: 10-vlan40.network.j2
dest: "/etc/systemd/network/10-{{ edge_vlan40_name }}.network"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Ensure parent network drop-in directory exists
ansible.builtin.file:
path: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d"
state: directory
mode: "0755"
when: edge_vlan40_enabled
tags: [vlan40]
- name: Attach VLAN to parent link via drop-in
ansible.builtin.template:
src: vlan40-parent-dropin.conf.j2
dest: "/etc/systemd/network/{{ edge_vlan40_parent_network }}.d/10-vlan40.conf"
mode: "0644"
when: edge_vlan40_enabled
notify: Reload networkd for VLAN
tags: [vlan40]
- name: Enable systemd-networkd
ansible.builtin.service:
name: systemd-networkd
state: started
enabled: true
@@ -0,0 +1,60 @@
---
# Heating system over the Viessmann E3 CAN bus: open3e (heat pump) and
# e3oncan (E380 energy meter). The can0 interface itself is the socketcan
# role.
# Service root. Kept per-role so the role is usable on its own.
edge_base_dir: /opt/edge-host
edge_open3e_mqtt_user: open3e
edge_open3e_mqtt_topic: open3e
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
edge_open3e_poll_seconds: 30
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
# vx3.266 = ErrorDtcHistory (O3EList) — the VX3 fault log; polled to catch
# recurrences of FailureCommunicationEnergyMeter (the E380 energy-meter link
# the internal PV-surplus EMS depends on). Decodes via the master O3EList
# codec inherited through the vx3plus overlay.
# vitocal.543/2350/2543/2560: SmartGrid-/EMS-Status-Beobachtung (2026-07-06)
# — klärt, ob die Eigenstromoptimierung (EMS) bei PV-Überschuss von selbst
# den WW-Boost auslöst (2543.2 steht laut Scan bereits auf +10 K), bevor
# SG-Ready-Verdrahtung oder andere Trigger gebaut werden. Alle vier ro.
# vitocal.259/260 = InfoDtcList/InfoDtcHistory (O3EList) — the HPMU event log,
# the Vitocal-side counterpart to vx3.266. Both are capped lists (2 and 9
# entries when added), so the multi-frame read is cheap at a 30 s poll.
# Records faults/info events only (NormalOperation, LagDeviceDtcReported,
# LegionellaProtectionTemperatureReached) — NOT setpoint or program changes.
edge_open3e_datapoints: >-
vitocal.259,vitocal.260,vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.286,vitocal.288,vitocal.318,vitocal.321,vitocal.324,vitocal.335,vitocal.336,vitocal.396,vitocal.402,vitocal.403,vitocal.476,vitocal.477,vitocal.543,vitocal.548,vitocal.565,vitocal.1007,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.1416,vitocal.1417,vitocal.2320,vitocal.2346,vitocal.2350,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2486,vitocal.2487,vitocal.2488,vitocal.2543,vitocal.2560,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.266,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1577,vx3.1578,vx3.1579,vx3.1580,vx3.1581,vx3.1582,vx3.1834,vx3.1836,vx3.2214,vx3.2240,vx3.3085,vx3.3086,vx3.3087,vx3.3088,vx3.3089,vx3.3090,vcmu.320,vcmu.322,vcmu.325,vcmu.355,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571,vitocal.2791
# ECU addresses; -dev selects codec set, tx selects the UDS address.
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
# omitting dpList makes open3e fall back to the generic datapoint list,
# which decodes all polled VCMU DIDs.
edge_open3e_devices:
vitocal: { tx: "0x680", dpList: "vcal" }
# dpList als Pfad: Overlay-Datei mit den Batteriemodul-DIDs (siehe
# files/open3e-vx3plus.py) — die Stock-vx3-Liste filtert sie sonst weg.
vx3: { tx: "0x6a1", dpList: "/config/vx3plus.py" }
vcmu: { tx: "0x68c" }
edge_e3oncan_gap_seconds: 10
edge_e3oncan_mqtt_format: "{device}_{didNumber:04d}_{didName}"
# Secrets come from inventory/group_vars/edge_host/vault.yml:
# edge_open3e_mqtt_password
# The image carrying both readers, built and pinned in CI
# (ci/images/heating-can/Containerfile). The source pins live in the
# Containerfile, in the artifact -- not here -- so what is deployed is a tag,
# and `docker compose up -d` on an unchanged tag is a genuine no-op.
#
# Tracked by the weekly update-audit as an ordinary image, which is why this is
# simpler to keep current than the two git refs it replaced: one publishes no
# tags at all, the other was pinned to an untagged commit between releases.
heating_can_image: gitea.renner.uno/gitea_admin/heating-can:latest
# Compose project directory. Separate from the old native layout under
# {{ edge_base_dir }}/open3e, which this role removes.
heating_can_base_dir: "{{ edge_base_dir }}/heating-can"
@@ -1,4 +1,4 @@
# Managed by ansible (roles/edge-host). Datapoint overlay for the VX3:
# Managed by ansible (roles/heating-can). Datapoint overlay for the VX3:
# the stock vx3 list acts as a FILTER (DIDs missing from it are popped
# from the master table, Open3Eclass.py), so the per-battery-module DIDs
# were never pollable with dpList "vx3". This overlay re-adds them with
@@ -0,0 +1,10 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart heating-can
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ heating_can_base_dir }}"
changed_when: true
+164
View File
@@ -0,0 +1,164 @@
---
# Heating system over the Viessmann E3 CAN bus.
#
# Two readers of the same heating installation, supervised inside one container
# from one image (ci/images/heating-can):
# open3e UDS request/response to the Vitocal heat pump and Vitocharge VX3
# e3oncan passive CANopen listener for the E380 grid energy meter, which
# feeds the heat pump's own energy management
#
# They coexist because e3oncan issues no bus writes.
#
# Nothing is installed here. The image is built in CI with both sources pinned,
# published to the registry, and this host only pulls and runs it. That is what
# removes the churn: these were pip-installed into venvs on the node, and pip
# re-cloned and rebuilt a VCS requirement on every converge -- restarting the
# heating bridge each time -- which pinning the SHA did not fix.
#
# network_mode: host is required, not incidental: SocketCAN interfaces live in
# the host network namespace, so can0 is only reachable that way. It is also how
# the local MQTT broker on 127.0.0.1 is reached. CAN_RAW needs no capabilities,
# so both run unprivileged.
#
# The can0 interface itself belongs to the socketcan role; this role consumes it.
- name: Create heating-can directory
ansible.builtin.file:
path: "{{ heating_can_base_dir }}"
state: directory
mode: "0755"
# --- Migration off the native install ---------------------------------------
# Must happen before the containers start. Two open3e instances issuing UDS
# requests on one CAN bus is the failure mode to avoid, so the old units are
# stopped and masked rather than merely disabled -- masked so a stray
# `systemctl start` or a leftover dependency cannot bring them back alongside
# the containers.
- name: Find which native CAN units still exist
ansible.builtin.stat:
path: "/etc/systemd/system/{{ item }}.service"
loop:
- open3e
- e3oncan
register: heating_can_native_units
# Stop and disable BEFORE anything else, and do NOT swallow the result.
#
# The first version did stop + disable + mask in one call with
# failed_when: false, then deleted the unit files. Masking a unit whose file
# still exists conflicts, so the call failed, failed_when hid it, nothing was
# stopped -- and the next task deleted the unit files out from under two
# running processes. systemctl is-enabled then reported "not-found" and looked
# clean while is-active still said "active".
#
# The result on 2026-08-10 was two open3e instances issuing UDS requests on the
# same CAN bus for nine minutes, fighting over the open3e-edge client ID and
# kicking each other off the broker.
#
# Only units that actually exist are touched, so a fresh host does not need an
# error swallowed to get through this.
- name: Stop and disable the native CAN services
ansible.builtin.systemd:
name: "{{ item.item }}"
state: stopped
enabled: false
loop: "{{ heating_can_native_units.results }}"
loop_control:
label: "{{ item.item }}"
when: item.stat.exists
# Masking is deliberately NOT used. It writes a symlink at the same path the
# next task removes, so the two cancel out -- which is half of how the original
# failed. With the unit files gone there is nothing left to start.
- name: Remove the native unit files
ansible.builtin.file:
path: "/etc/systemd/system/{{ item }}.service"
state: absent
loop:
- open3e
- e3oncan
notify: Reload systemd
# The invariant that actually matters is "no native process is on the CAN bus",
# and systemd reporting success is not the same claim. Two open3e instances
# polling one bus is the failure this whole block exists to prevent, so it is
# asserted directly rather than inferred from the calls above.
- name: Check for surviving native CAN processes
ansible.builtin.command:
argv: [pgrep, -af, "edge-host/(open3e|e3oncan)"]
register: heating_can_survivors
changed_when: false
failed_when: false
- name: Assert no native CAN process survived
ansible.builtin.assert:
that:
- heating_can_survivors.stdout | default('') | length == 0
fail_msg: >-
A native open3e/e3oncan process is still running after the units were
stopped and removed:
{{ heating_can_survivors.stdout_lines | default([]) | join('; ') }}.
Starting the container now would put two readers on the same CAN bus --
two open3e instances issue competing UDS requests and take over each
other's MQTT session. Kill the survivors, then re-run.
success_msg: No native CAN processes remain; safe to start the container.
- name: Remove the native venvs
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "{{ edge_base_dir }}/open3e/venv"
- "{{ edge_base_dir }}/e3oncan/venv"
- "{{ edge_base_dir }}/e3oncan/src"
- "{{ edge_base_dir }}/open3e"
- "{{ edge_base_dir }}/e3oncan"
# --- Configuration (bind-mounted into the containers) ------------------------
- name: Deploy open3e devices.json
ansible.builtin.copy:
content: "{{ edge_open3e_devices | to_nice_json }}\n"
dest: "{{ heating_can_base_dir }}/devices.json"
mode: "0644"
notify: Restart heating-can
- name: Deploy open3e vx3 datapoint overlay (battery-module DIDs)
ansible.builtin.copy:
src: open3e-vx3plus.py
dest: "{{ heating_can_base_dir }}/vx3plus.py"
mode: "0644"
notify: Restart heating-can
# --- Compose project ---------------------------------------------------------
- name: Deploy the broker credential env file
ansible.builtin.template:
src: heating-can.env.j2
dest: "{{ heating_can_base_dir }}/heating-can.env"
mode: "0600"
notify: Restart heating-can
- name: Deploy heating-can compose project
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ heating_can_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart heating-can
- name: Pull the heating-can image
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ heating_can_base_dir }}"
changed_when: false
# Unlike the zwave and meter services there is no missing-device tolerance here:
# can0 is created by the socketcan role from a USB adapter that is part of this
# host's purpose. If it is absent the heating telemetry is down and the run
# should say so rather than pass quietly.
- name: Start heating-can services
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ heating_can_base_dir }}"
register: heating_can_up
changed_when: >-
'Started' in heating_can_up.stderr or 'Created' in heating_can_up.stderr
or 'Started' in heating_can_up.stdout or 'Creating' in heating_can_up.stdout
@@ -0,0 +1,35 @@
# Managed by ansible (roles/heating-can).
#
# One container. Both CAN readers live in the image and are supervised inside
# it -- this file supplies configuration and nothing else. It does not know
# open3e's or E3onCAN's command line.
#
# network_mode: host is required: SocketCAN interfaces live in the host network
# namespace, so {{ edge_can_interface }} is only reachable this way, and it is
# also how the local MQTT broker is reached. CAN_RAW needs no capabilities, so
# the image runs unprivileged.
services:
heating-can:
image: {{ heating_can_image }}
container_name: heating-can
restart: unless-stopped
network_mode: host
volumes:
- ./devices.json:/config/devices.json:ro
- ./vx3plus.py:/config/vx3plus.py:ro
env_file:
# Broker credential, 0600 -- see heating-can.env.j2.
- heating-can.env
environment:
CAN_INTERFACE: "{{ edge_can_interface }}"
MQTT_HOST: "{{ edge_mqtt_host }}"
MQTT_PORT: "{{ edge_mqtt_port }}"
MQTT_TOPIC: "{{ edge_open3e_mqtt_topic }}"
MQTT_FORMAT: "{{ edge_open3e_mqtt_format }}"
E3ONCAN_MQTT_FORMAT: "{{ edge_e3oncan_mqtt_format }}"
OPEN3E_CONFIG: /config/devices.json
OPEN3E_CLIENT_ID: open3e-edge
OPEN3E_POLL_SECONDS: "{{ edge_open3e_poll_seconds }}"
OPEN3E_DATAPOINTS: "{{ edge_open3e_datapoints }}"
E3ONCAN_DEVICE: e380
E3ONCAN_GAP_SECONDS: "{{ edge_e3oncan_gap_seconds }}"
@@ -0,0 +1,8 @@
# Managed by ansible (roles/heating-can). 0600 -- carries the broker credential.
#
# Kept out of docker-compose.yml, which is 0644: the sibling roles on this host
# do the same (power-meter-sml renders its config at 0600 while its compose file
# stays world-readable), and the native unit this replaced used a 0600 env file
# for exactly this value.
MQTT_USER={{ edge_open3e_mqtt_user }}
MQTT_PASSWORD={{ edge_open3e_mqtt_password }}
@@ -4,6 +4,29 @@
keepalived_ntp_router_id: 4
keepalived_ntp_vip: "10.2.40.4"
keepalived_ntp_interface: "vmbr1.40"
# IPv6 service address for NTP (ntp.srv.renner.uno). Empty disables the v6
# instance entirely. IPv6 is the primary address family on this network, so
# this is the path that actually matters -- the v4 VIP above is legacy.
#
# Held by keepalived rather than assigned statically by networkd so that it is
# gated on check_ntp: present only while this host is fit to serve time, and
# withdrawn the moment it is not. A statically configured address is carried
# unconditionally from boot, which is how edge-1 came to answer NTP queries on
# 2026-08-04 while its clock was ~16 days in the past.
#
# Needs its own virtual_router_id: it is a distinct VRRP instance, and a VRID
# collision with the IPv4 instance on the same interface would have the two
# fighting over a single election.
keepalived_ntp_vip6: ""
keepalived_ntp_router_id6: 6
# Link-local address for the IPv6 VRRP instance, carried as its FIRST
# virtual_ipaddress. RFC 5798 gives an IPv6 virtual router a link-local primary,
# and keepalived warns "the first IPv6 VIP address should be link local" without
# one. Chosen to mirror the service address rather than derived from the virtual
# MAC, since no real NIC generates fe80::123 so there is nothing to collide with.
keepalived_ntp_vip6_linklocal: "fe80::123/64"
# NOTE: plaintext VRRP auth, unvaulted. Carried over as-is from the original
# role; VRRP PASS auth is weak regardless (sent in the clear on the segment),
# so this is a documented weakness rather than a secret worth vaulting.
@@ -18,6 +41,17 @@ keepalived_ntp_check_script: "/usr/local/bin/check-ntp-health.sh"
keepalived_ntp_check_interval: 2
keepalived_ntp_check_weight: -20
# Maximum |system clock offset| in seconds before the host refuses to hold the
# VIP. chronyd reports this as "System time : <n> seconds slow/fast of NTP
# time". Anything above this means the host knows its clock is wrong and must
# not serve time to clients -- see the reasoning in check-ntp-health.sh.j2.
#
# A serving NTP host should be within milliseconds of real time, so 1s is
# already several orders of magnitude beyond normal. It is deliberately not
# tighter: a brief excursion at boot, before makestep lands, should not flap
# the VIP (fall 2 at a 2s interval also requires the failure to persist).
keepalived_ntp_max_offset: 1.0
# sysctl applied for VRRP. Default preserves the PVE hosts' existing behaviour
# exactly. ip_forward is NOT appropriate everywhere -- on a host with legs in
# two VLANs it turns the box into a router between them, bypassing the gateway's
@@ -1,22 +1,39 @@
#!/bin/bash
# Keepalived State Change Notification Script
#
# Two VRRP instances call this: NTP_VIP (IPv4, legacy) passes MASTER/BACKUP/
# FAULT, and NTP_VIP6 (IPv6, the primary path) passes the same suffixed with
# '6'. They are logged distinctly because the two can legitimately diverge --
# a host can hold one family and not the other -- and a log line that does not
# say which family it means is useless during an incident.
TYPE=$1
NAME=$(hostname)
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
log() {
echo "$TIMESTAMP: $NAME $1" >> /var/log/keepalived-state.log
logger -t keepalived "$NAME $1"
}
case $TYPE in
MASTER)
echo "$TIMESTAMP: $NAME transitioned to MASTER state (NTP)" >> /var/log/keepalived-state.log
logger -t keepalived "$NAME is now NTP MASTER"
log "transitioned to MASTER state (NTP, IPv4)"
;;
BACKUP)
echo "$TIMESTAMP: $NAME transitioned to BACKUP state (NTP)" >> /var/log/keepalived-state.log
logger -t keepalived "$NAME is now NTP BACKUP"
log "transitioned to BACKUP state (NTP, IPv4)"
;;
FAULT)
echo "$TIMESTAMP: $NAME transitioned to FAULT state (NTP)" >> /var/log/keepalived-state.log
logger -t keepalived "$NAME is now in NTP FAULT state"
log "transitioned to FAULT state (NTP, IPv4)"
;;
MASTER6)
log "transitioned to MASTER state (NTP, IPv6) - now serving ntp.srv.renner.uno"
;;
BACKUP6)
log "transitioned to BACKUP state (NTP, IPv6) - released ntp.srv.renner.uno"
;;
FAULT6)
log "transitioned to FAULT state (NTP, IPv6) - withdrew ntp.srv.renner.uno, unfit to serve time"
;;
*)
echo "$TIMESTAMP: $NAME unknown state: $TYPE" >> /var/log/keepalived-state.log
+115
View File
@@ -35,6 +35,121 @@
state: present
when: ansible_facts['os_family'] == 'Archlinux'
# --- Verify nothing else is assigning the IPv6 service address ---------------
#
# The removal itself lives in withdraw-static.yml, run as a separate fleet-wide
# play before any host reaches this role (see install-ntp-ha.yml). It has to
# complete everywhere first: DAD is enabled on the VRRP address, so a static
# copy on one host and a VRRP-managed copy on another cannot coexist -- the
# kernel marks the newcomer `dadfailed` and its master serves nothing.
#
# What remains here are the guards. They run on every execution, not just the
# migration, and fail the play if any static assignment survives -- which would
# both defeat the health gating (a static copy does not disappear when
# keepalived decides the host is unfit) and break the VRRP copy via DAD.
#
# The config file is what gets checked, not the running address, and that is
# what makes it safe to run every time: keepalived never writes to these files,
# so anything found is by definition static, whereas the live address is
# legitimately present on whichever host is master.
#
# Prefix-agnostic and \b-anchored on purpose. Matching the full
# address/prefixlen would fail open against a hand-written assignment using any
# other mask.
- name: Detect any remaining static assignment of the service address (ifupdown)
ansible.builtin.command:
argv:
- grep
- -rlE
- "{{ keepalived_ntp_vip6.split('/')[0] }}\\b"
- /etc/network/interfaces
- /etc/network/interfaces.d/
register: keepalived_ntp_static6_ifupdown
changed_when: false
failed_when: false
when:
- keepalived_ntp_vip6 | length > 0
- ansible_facts['os_family'] == 'Debian'
- name: Assert ifupdown is not also assigning the service address
ansible.builtin.assert:
that:
- keepalived_ntp_static6_ifupdown.stdout | default('') | length == 0
fail_msg: >-
{{ keepalived_ntp_vip6 }} is still assigned statically by ifupdown on
this host
({{ keepalived_ntp_static6_ifupdown.stdout_lines | default([]) | join(', ') }}).
The automatic removal only matches the exact form
`up ip -6 addr replace {{ keepalived_ntp_vip6 }} dev <if> nodad`, so a
different prefix length or layout is left in place. Handing the address
to keepalived while ifupdown also assigns it means it would NOT be
withdrawn when check_ntp fails -- the host would keep serving time on an
address VRRP believes it has released. Remove the assignment from the
file(s) above, then re-run.
success_msg: >-
No static ifupdown assignment of {{ keepalived_ntp_vip6 }}; safe for
keepalived to own it.
when:
- keepalived_ntp_vip6 | length > 0
- ansible_facts['os_family'] == 'Debian'
# On Arch (edge-1) the static assignment lives in a systemd-networkd file owned
# by the edge-vlan role, so this role must not rewrite it -- but it must not
# quietly proceed either. install-edge-host.yml is a separate play with no
# ordering relationship to this one, so nothing guarantees it has been re-run
# to drop the [Address] block. If it has not, edge-1 would end up holding
# fd40::123 statically AND running a VRRP instance for the same address:
# keepalived would believe it had withdrawn the address while networkd kept it
# assigned, and the host would go on answering NTP with a clock the health
# check had already rejected.
#
# That is the precise failure of 2026-08-04, on the host it happened to, so it
# fails fast rather than being left to inspection.
# Matched on the bare address, not the full address/prefixlen, and anchored
# with \b so it cannot also hit a longer address sharing the prefix (fd40::1234).
#
# Searching for the literal "fd40::123/128" would fail open: a hand-written
# [Address] using any other mask -- /64, say -- would not match, the assert
# below would pass, and keepalived would be handed an address networkd is
# still assigning. Since this check is detect-only, with no delete afterwards
# to catch the mistake, a miss here is silent. The Debian path does not share
# the problem: `ip -6 addr show ... to <prefix>` matches by range regardless of
# the assigned prefix length, and its delete is re-verified by assert.
- name: Detect a static assignment of the service address (systemd-networkd)
ansible.builtin.command:
argv:
- grep
- -rlE
- "{{ keepalived_ntp_vip6.split('/')[0] }}\\b"
- /etc/systemd/network/
register: keepalived_ntp_static6_networkd
changed_when: false
failed_when: false
when:
- keepalived_ntp_vip6 | length > 0
- ansible_facts['os_family'] == 'Archlinux'
- name: Assert networkd is not also assigning the service address
ansible.builtin.assert:
that:
- keepalived_ntp_static6_networkd.stdout | default('') | length == 0
fail_msg: >-
{{ keepalived_ntp_vip6 }} is still assigned statically by
systemd-networkd on this host
({{ keepalived_ntp_static6_networkd.stdout_lines | default([]) | join(', ') }}).
Handing the address to keepalived while networkd also assigns it means
it would NOT be withdrawn when check_ntp fails -- the host would keep
serving time on an address VRRP believes it has released.
Re-apply the edge-vlan role first, whose template no longer carries the
[Address] block:
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
success_msg: >-
No static networkd assignment of {{ keepalived_ntp_vip6 }}; safe for
keepalived to own it.
when:
- keepalived_ntp_vip6 | length > 0
- ansible_facts['os_family'] == 'Archlinux'
- name: Create keepalived configuration
ansible.builtin.template:
src: keepalived.conf.j2
@@ -0,0 +1,134 @@
---
# Withdraw a static assignment of the IPv6 NTP service address.
#
# Included by install-ntp-ha.yml as a FIRST play across the whole ntp_cluster,
# before any host is given the NTP_VIP6 instance. That ordering is the point of
# splitting it out.
#
# DAD is enabled on the VRRP address (see keepalived.conf.j2), so a static copy
# on any host and a VRRP-managed copy on another cannot coexist: the kernel sees
# the duplicate and marks the newcomer `dadfailed`, leaving a master that
# believes it is serving while carrying an unusable address. Observed on edge-1
# on 2026-08-07, where keepalived reported MASTER while the address sat
# `dadfailed deprecated tentative` because three PVE nodes still held it
# statically.
#
# Doing the withdrawal per host inside the main role could not avoid that: with
# serial: 1 the first host would claim the address via VRRP while the rest were
# still static. Fleet-wide first, then claim.
#
# fd40::123 was assigned by hand on all four servers, in two different ways,
# none of it in version control:
#
# PVE nodes an `up ip -6 addr replace ... nodad` hook in
# /etc/network/interfaces
# edge-1 an [Address] block in /etc/systemd/network/, owned by the
# edge-vlan role and removed by re-applying it -- not touched
# here, since this role must not rewrite another role's file
#
# There is a service gap between this play and the next, while no host holds
# the address. It is bounded by the run time of one play and clients simply
# retry; the alternative is the dadfailed state above, which does not resolve
# on its own.
- name: Remove static assignment of the NTP service address (ifupdown)
ansible.builtin.lineinfile:
path: /etc/network/interfaces
regexp: '^\s*up\s+ip\s+-6\s+addr\s+(replace|add)\s+{{ keepalived_ntp_vip6 | regex_escape }}\b'
state: absent
when:
- keepalived_ntp_vip6 | length > 0
- ansible_facts['os_family'] == 'Debian'
register: keepalived_ntp_static6_removed
# Everything below is gated on the config line having just been removed, so the
# runtime delete happens once -- on the migration -- and never again.
#
# The gate is load-bearing. Once this has landed, the address is legitimately
# present on whichever host is the elected v6 master, placed there by
# keepalived, and a delete cannot tell that apart from a static assignment (the
# address looks identical either way). Ungated, every later run would delete the
# master's address and restart keepalived, dropping the service address and
# forcing a re-election each time -- the flapping this whole change exists to
# prevent.
- name: Check whether the service address is still assigned statically
ansible.builtin.command:
argv:
- ip
- -6
- addr
- show
- dev
- "{{ keepalived_ntp_interface }}"
- to
- "{{ keepalived_ntp_vip6 }}"
register: keepalived_ntp_vip6_present
changed_when: false
failed_when: false
when:
- keepalived_ntp_vip6 | length > 0
- keepalived_ntp_static6_removed.changed | default(false)
# failed_when: false because the desired end state is "address absent". An
# `ip addr del` against an address that has already gone is the outcome this
# task wants, not a reason to abort the play for that host.
- name: Withdraw the statically assigned service address
ansible.builtin.command:
argv:
- ip
- -6
- addr
- del
- "{{ keepalived_ntp_vip6 }}"
- dev
- "{{ keepalived_ntp_interface }}"
when:
- keepalived_ntp_vip6 | length > 0
- keepalived_ntp_static6_removed.changed | default(false)
- keepalived_ntp_vip6_present.stdout | default('') | length > 0
failed_when: false
# The withdrawal gets exactly one attempt -- the gate that makes it idempotent
# also means it never retries -- and the delete swallows its own errors. So the
# result is asserted rather than assumed.
#
# `ip addr del` matches on address AND prefix length: if a host's static
# assignment used a different mask or interface than ntp_vip6 /
# keepalived_ntp_interface, the delete is a silent no-op and the address stays
# outside VRRP control permanently, with the play reporting success.
- name: Confirm the static service address was actually withdrawn
ansible.builtin.command:
argv:
- ip
- -6
- addr
- show
- dev
- "{{ keepalived_ntp_interface }}"
- to
- "{{ keepalived_ntp_vip6 }}"
register: keepalived_ntp_vip6_after
changed_when: false
failed_when: false
when:
- keepalived_ntp_vip6 | length > 0
- keepalived_ntp_static6_removed.changed | default(false)
- name: Assert the service address is no longer assigned outside keepalived
ansible.builtin.assert:
that:
- keepalived_ntp_vip6_after.stdout | default('') | length == 0
fail_msg: >-
{{ keepalived_ntp_vip6 }} is still assigned on
{{ keepalived_ntp_interface }} after the static assignment was removed.
`ip -6 addr del` matches address AND prefix length, so this usually means
the on-host assignment differs from ntp_vip6 or uses another interface.
Leaving it would both keep the address outside VRRP control and make the
VRRP-managed copy fail DAD elsewhere. Inspect with
`ip -6 addr show dev {{ keepalived_ntp_interface }}` and
`grep -rn {{ keepalived_ntp_vip6 }} /etc/network/ /etc/systemd/network/`.
success_msg: >-
{{ keepalived_ntp_vip6 }} withdrawn; safe for keepalived to claim it.
when:
- keepalived_ntp_vip6 | length > 0
- keepalived_ntp_static6_removed.changed | default(false)
@@ -6,23 +6,80 @@
# The chrony unit is named 'chrony' on Debian and 'chronyd' on Arch. This is
# templated rather than hardcoded: a wrong name here fails every check, which
# parks the host in permanent FAULT so it never takes the VIP.
#
# This check must answer "is it safe for clients to take time from this host",
# NOT merely "is chronyd alive". Those came apart on 2026-08-04: edge-1's dead
# CMOS battery left its clock ~16 days in the past, chronyd was running,
# answering queries and listening on 123, and the `local stratum` directive made
# it advertise itself to clients as a valid reference. Every liveness check
# passed for ~18h while the host stood ready to serve a clock from July. The
# earlier version of this script ran `chronyc tracking` and discarded its
# output -- the one command that knew the answer.
#
# Deliberate distinction, because these must not be conflated:
# * "provably wrong" -> chronyd HAS upstream measurements and they say the
# clock is far off. Fail: drop the VIP so a host with a
# sane clock serves instead.
# * "no upstream yet" -> nothing reachable / not yet measured. Also fail, but
# note that when the WAN is down every host fails
# together, they all lose the same weight, relative
# priority is unchanged and the VIP stays put. So the
# local-reference fallback still works; what this
# prevents is one host with a broken clock winning.
LOG=/var/log/keepalived-ntp-health.log
MAX_OFFSET={{ keepalived_ntp_max_offset }}
fail() {
echo "$(date): $1" >> "$LOG"
exit 1
}
# Check 1: Is chrony service running?
if ! systemctl is-active --quiet {{ chrony_service_name }}; then
echo "$(date): {{ chrony_service_name }} service not active" >> /var/log/keepalived-ntp-health.log
exit 1
fail "{{ chrony_service_name }} service not active"
fi
# Check 2: Is chrony synchronized or at least running?
if ! chronyc tracking >/dev/null 2>&1; then
echo "$(date): Chrony not responding to tracking query" >> /var/log/keepalived-ntp-health.log
exit 1
# Check 2: Is chrony responding, and what does it actually say?
# Captured, not discarded -- the offset below is the whole point of this script.
if ! TRACKING=$(chronyc tracking 2>/dev/null); then
fail "Chrony not responding to tracking query"
fi
# Check 3: Is NTP serving clients (port 123)?
if ! ss -lun | grep -q ':123 '; then
echo "$(date): NTP port 123 not listening" >> /var/log/keepalived-ntp-health.log
exit 1
fail "NTP port 123 not listening"
fi
# Check 4: Is the clock provably wrong?
#
# "System time : <n> seconds (slow|fast) of NTP time" is chronyd's own estimate
# of how far the system clock is from real time, derived from upstream
# measurements. On edge-1 during the incident this line read
# "1376235.375000000 seconds slow of NTP time" while every other check passed.
OFFSET=$(echo "$TRACKING" | awk '/^System time/ {print $4}')
if [ -n "$OFFSET" ]; then
if awk -v o="$OFFSET" -v m="$MAX_OFFSET" 'BEGIN {exit !(o > m)}'; then
fail "System clock off by ${OFFSET}s (max ${MAX_OFFSET}s) - refusing to serve time"
fi
fi
# Check 5: Has chronyd ever validated its clock against a real source?
#
# With `local stratum N` configured, chronyd advertises itself as a usable
# reference even when it has never synchronised to anything -- the upstream
# chrony documentation is explicit that the local reference activates
# immediately in that case. So a host that has never reached an upstream still
# looks authoritative to clients. Refuse the VIP until a real source has been
# selected.
#
# `chronyc sources` marks the selected source with '*' (best) or '+' (combined)
# in the second column. Neither present means nothing upstream is accepted.
if ! SOURCES=$(chronyc sources 2>/dev/null); then
fail "Chrony not responding to sources query"
fi
if ! echo "$SOURCES" | grep -qE '^.\*|^.\+'; then
fail "No upstream source selected - clock not validated, refusing to serve time"
fi
# All checks passed
@@ -39,3 +39,72 @@ vrrp_instance NTP_VIP {
notify_backup "/usr/local/bin/keepalived-notify.sh BACKUP"
notify_fault "/usr/local/bin/keepalived-notify.sh FAULT"
}
{% if keepalived_ntp_vip6 %}
# IPv6 service address for NTP (ntp.srv.renner.uno). IPv6 is the primary
# address family here, so this instance -- not the IPv4 one above -- is the
# path that matters.
#
# A separate instance is mandatory, not stylistic: keepalived requires that
# "all addresses in virtual_ipaddress must be of the same family", so the v6
# address cannot be added to NTP_VIP.
#
# No authentication block: VRRPv3 removed authentication (RFC 5798), and
# keepalived always uses version 3 for IPv6 instances. Configuring auth here
# is rejected rather than ignored.
#
# The first address is link-local, and must be. RFC 5798 gives an IPv6 virtual
# router a link-local address as its primary, and keepalived warns
# "the first IPv6 VIP address should be link local" otherwise. It carries on,
# but the instance is then sourcing VRRPv3 from something the protocol does not
# expect.
#
# DAD is deliberately left ON (no -nodad). The flag was inherited from the
# previous design, where every NTP server carried fd40::123 simultaneously and
# duplicate detection had to be suppressed for that to work at all. Under
# single-master VRRP the opposite is true: exactly one host should hold the
# address, so a duplicate is a genuine fault and detecting it is the point.
#
# This does mean the migration cannot leave a static copy anywhere while a
# VRRP-managed one exists -- DAD sees the two and marks the newcomer
# `dadfailed`, leaving a master that believes it is serving while carrying an
# unusable address. Observed on edge-1 on 2026-08-07. install-ntp-ha.yml
# therefore withdraws every static assignment fleet-wide in a first play
# before any host configures this instance.
#
# track_script is what makes this health-gated: the address is only held while
# check_ntp passes, and is withdrawn as soon as it fails. On withdrawal
# keepalived sends unsolicited Neighbour Advertisements so client neighbour
# caches are updated immediately -- a statically assigned address cannot do
# this, and clients would keep sending to the dead holder until their own
# neighbour unreachability detection expired.
vrrp_instance NTP_VIP6 {
state {{ keepalived_ntp_state }}
interface {{ keepalived_ntp_interface }}
virtual_router_id {{ keepalived_ntp_router_id6 }}
priority {{ keepalived_ntp_priority }}
advert_int 1
virtual_ipaddress {
{{ keepalived_ntp_vip6_linklocal }} dev {{ keepalived_ntp_interface }}
{{ keepalived_ntp_vip6 }} dev {{ keepalived_ntp_interface }}
}
# weight 0 overrides the script's own weight, and the distinction decides
# whether this works at all. A non-zero weight only SUBTRACTS from priority
# on failure -- so the last remaining host, having no one to lose the
# election to, stays MASTER and keeps serving time with a clock it has
# already reported as wrong. Weight 0 instead transitions the instance to
# FAULT after <fall> failures, which releases the address unconditionally.
#
# That is the required behaviour here: a host unfit to serve time must stop
# answering, whether or not a replacement exists.
track_script {
check_ntp weight 0
}
notify_master "/usr/local/bin/keepalived-notify.sh MASTER6"
notify_backup "/usr/local/bin/keepalived-notify.sh BACKUP6"
notify_fault "/usr/local/bin/keepalived-notify.sh FAULT6"
}
{% endif %}
@@ -0,0 +1,12 @@
---
# --- MQTT: edge clients (open3e, e3oncan, sml2mqtt) publish to a LOCAL
# mosquitto on loopback, which bridges to the central cluster broker over TLS
# (open3e/e3oncan have no native MQTTS). This keeps plaintext on loopback only
# — off the untrusted IoT VLAN — and buffers telemetry in RAM during cluster
# blips. The local broker writes nothing to the eMMC (persistence off, logs to
# the volatile journal). See edge-mqtt-bridge.conf.j2 and README "MQTT bridge".
# Upstream cluster broker the local bridge connects to (TLS; the public
# Let's Encrypt cert is validated against the system CA bundle).
edge_mqtt_cluster_host: mqtt.renner.uno
edge_mqtt_cluster_tls_port: 8883
edge_mqtt_bridge_cafile: /etc/ssl/certs/ca-certificates.crt
@@ -0,0 +1,7 @@
---
# The MQTT clients (zwave-js, sml2mqtt, open3e, e3oncan) reconnect on their
# own once the local broker is back.
- name: Restart mosquitto
ansible.builtin.service:
name: mosquitto
state: restarted
+27
View File
@@ -0,0 +1,27 @@
---
# Local MQTT bridge broker.
#
# Edge clients (open3e, e3oncan, sml2mqtt) publish here over loopback; this
# broker bridges to the cluster broker over TLS, because those clients have no
# native MQTTS. Persistence is off and logging goes to stdout so it writes
# nothing to local flash.
- name: Install mosquitto
ansible.builtin.package:
name: mosquitto
state: present
- name: Deploy local mosquitto bridge config
ansible.builtin.template:
src: edge-mqtt-bridge.conf.j2
dest: /etc/mosquitto/mosquitto.conf
owner: root
group: mosquitto
mode: "0640" # carries the cluster bridge credentials
notify: Restart mosquitto
- name: Enable local mosquitto bridge
ansible.builtin.systemd:
name: mosquitto
enabled: true
state: started
@@ -1,4 +1,4 @@
# Managed by roles/edge-host. Local mosquitto that the edge clients (open3e,
# Managed by roles/mqtt-bridge. Local mosquitto that the edge clients (open3e,
# e3oncan, sml2mqtt) publish to over loopback; it bridges to the central
# cluster broker over TLS. open3e/e3oncan have no native MQTTS, so this is how
# edge-1's MQTT gets off plaintext on the untrusted IoT VLAN, and it buffers
@@ -0,0 +1,23 @@
---
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
# Service root. Kept per-role so the role is usable on its own.
edge_base_dir: /opt/edge-host
# Own compose project directory. Unchanged from the path used under the
# previous shared project.
sml_base_dir: "{{ edge_base_dir }}/sml2mqtt"
# The compose file that used to hold both this service and zwave-js. Removed
# once, so the fixed container_name is released for the new projects.
edge_legacy_compose: "{{ edge_base_dir }}/docker-compose.yml"
# Stable by-id symlink -- see the zwave-gateway role.
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
# Hand-pinned on purpose: critical meter bridge, deliberately excluded from
# image automation.
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
# Secret from inventory/group_vars/edge_host/vault.yml:
# edge_sml2mqtt_mqtt_password
@@ -0,0 +1,6 @@
---
- name: Restart sml2mqtt
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ sml_base_dir }}"
changed_when: true
@@ -0,0 +1,55 @@
---
# Power-meter reader: sml2mqtt against the FTDI serial meter head.
#
# Its own compose project. It shared a compose file with the Z-Wave gateway,
# which meant a change to either recreated both -- two unrelated services bound
# to different USB devices. Data paths are unchanged.
- name: Create sml2mqtt directory
ansible.builtin.file:
path: "{{ sml_base_dir }}"
state: directory
mode: "0755"
# sml2mqtt reads only a config file and has no env override for the password,
# so the rendered config carries it -- 0600, root-owned, same trust boundary as
# the k8s init-container approach it replaced.
- name: Deploy sml2mqtt config
ansible.builtin.template:
src: sml2mqtt-config.yml.j2
dest: "{{ sml_base_dir }}/config.yml"
mode: "0600"
notify: Restart sml2mqtt
- name: Deploy sml2mqtt compose project
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ sml_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart sml2mqtt
- name: Pull sml2mqtt image
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ sml_base_dir }}"
changed_when: false
# Tolerates a missing USB device, as for the Z-Wave gateway.
- name: Start sml2mqtt
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ sml_base_dir }}"
register: sml_compose_up
failed_when: >-
sml_compose_up.rc != 0
and 'error gathering device information' not in sml_compose_up.stderr
changed_when: >-
'Started' in sml_compose_up.stderr or 'Created' in sml_compose_up.stderr
or 'Started' in sml_compose_up.stdout or 'Creating' in sml_compose_up.stdout
- name: Warn if sml2mqtt is waiting for its USB device
ansible.builtin.debug:
msg: >-
sml2mqtt could not start -- the FTDI meter head is not present:
{{ sml_compose_up.stderr_lines | select('search', 'device') | list }}
when: sml_compose_up.rc != 0
@@ -0,0 +1,21 @@
# Managed by ansible (roles/power-meter-sml).
#
# Its own compose project -- see the role tasks for why it no longer shares a
# file with the Z-Wave gateway.
#
# Outbound-only (serial in, MQTT out). Host networking sidesteps the docker
# NAT66 gap: the broker LB is IPv6-first and bridge-network v6 SYNs leave
# unmasqueraded and die. The host stack reaches it fine (open3e proves it
# continuously).
services:
sml2mqtt:
image: {{ edge_sml2mqtt_image }}
container_name: sml2mqtt
restart: unless-stopped
network_mode: host
# Serial exclusivity: this is the ONLY reader of the meter port --
# HA's edl21 integration stays disabled.
devices:
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
volumes:
- ./config.yml:/sml2mqtt/config.yml:ro
@@ -1,4 +1,4 @@
# Managed by ansible (roles/edge-host). Ported 1:1 from the k8s ConfigMap
# Managed by ansible (roles/power-meter-sml). Ported 1:1 from the k8s ConfigMap
# sml2mqtt-config; only host and password delivery differ (broker is now
# reached via its LoadBalancer hostname, password rendered by ansible
# instead of an init container).
@@ -7,19 +7,19 @@
# PVE cluster nodes - need direct scraping (no Alloy agent)
- targets:
- '10.2.40.201:9100'
- 'jupiter.renner.uno:9100'
labels:
job: 'node'
group: 'pve_cluster'
hostname: 'jupiter'
- targets:
- '10.2.40.202:9100'
- 'neptune.renner.uno:9100'
labels:
job: 'node'
group: 'pve_cluster'
hostname: 'neptune'
- targets:
- '10.2.40.203:9100'
- 'saturn.renner.uno:9100'
labels:
job: 'node'
group: 'pve_cluster'
@@ -51,9 +51,9 @@ scrape_configs:
scrape_timeout: 55s
static_configs:
- targets:
- '10.2.40.201' # jupiter
- '10.2.40.202' # neptune
- '10.2.40.203' # saturn
- 'jupiter.renner.uno'
- 'neptune.renner.uno'
- 'saturn.renner.uno'
metrics_path: /pve
params:
module: [default]
@@ -132,28 +132,29 @@ scrape_configs:
refresh_interval: 60s
# Chrony Exporter - NTP metrics from PVE cluster
# One static_config per host so each carries its own hostname label. The
# previous form listed all three targets together and recovered the hostname
# in relabel_configs by regex-matching __address__ against the node's IP --
# which silently coupled the labels to the addressing. Changing a target
# would have left the regex unmatched and the hostname label simply absent,
# with the scrape still succeeding.
- job_name: 'chrony'
static_configs:
- targets:
- '10.2.40.201:9123'
- '10.2.40.202:9123'
- '10.2.40.203:9123'
- targets: ['jupiter.renner.uno:9123']
labels:
group: 'ntp'
type: 'time-server'
relabel_configs:
- source_labels: [__address__]
regex: '10\.2\.40\.201:.*'
target_label: hostname
replacement: 'jupiter'
- source_labels: [__address__]
regex: '10\.2\.40\.202:.*'
target_label: hostname
replacement: 'neptune'
- source_labels: [__address__]
regex: '10\.2\.40\.203:.*'
target_label: hostname
replacement: 'saturn'
hostname: 'jupiter'
- targets: ['neptune.renner.uno:9123']
labels:
group: 'ntp'
type: 'time-server'
hostname: 'neptune'
- targets: ['saturn.renner.uno:9123']
labels:
group: 'ntp'
type: 'time-server'
hostname: 'saturn'
# Immich photo management metrics
- job_name: 'immich'
@@ -0,0 +1,5 @@
---
# SocketCAN interface. Consumed by heating-can.
# --- CAN / open3e (Viessmann E3 external bus) ---
edge_can_interface: can0
edge_can_bitrate: 250000
@@ -0,0 +1,7 @@
---
# reload alone only re-reads config files; existing links keep their old state
# until reconfigured.
- name: Reload networkd
ansible.builtin.shell:
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
changed_when: true
+20
View File
@@ -0,0 +1,20 @@
---
# SocketCAN interface, owned by systemd-networkd.
#
# gs_usb is an in-tree module and autoloads when the adapter enumerates;
# networkd then matches the interface and applies bitrate + up. Separate from
# the services that consume the bus (heating-can, e380-can-meter) because the
# interface is host network state shared by both.
- name: Configure can0 via systemd-networkd
ansible.builtin.template:
src: 80-can0.network.j2
dest: /etc/systemd/network/80-can0.network
mode: "0644"
notify: Reload networkd
- name: Enable systemd-networkd
ansible.builtin.service:
name: systemd-networkd
state: started
enabled: true
@@ -1,4 +1,4 @@
# Managed by ansible (roles/edge-host).
# Managed by ansible (roles/socketcan).
# Viessmann E3 external CAN bus via Innomaker USB2CAN (gs_usb).
# The adapter is a stub tap on an already-terminated bus (120R jumper OFF).
[Match]
@@ -0,0 +1,24 @@
---
# Z-Wave gateway: zwave-js-ui against the ZWA-2 USB radio.
# Service root. Kept per-role so the role is usable on its own.
edge_base_dir: /opt/edge-host
edge_tz: Europe/Berlin
# Own compose project directory. Unchanged from the path used under the
# previous shared project, so the store is not migrated.
zwave_base_dir: "{{ edge_base_dir }}/zwave-js"
# Stable by-id symlink: the name travels with the device, not the host, so it
# survives moving the stick between machines.
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
# Alpine version the zwave-js-ui image is built on -- the source for the musl
# libeatmydata.so preloaded into that container. Keep in sync if the image
# bumps its Alpine major (check: docker exec zwave-js cat /etc/os-release).
edge_zwave_alpine_base: alpine:3.22
# Secret from inventory/group_vars/edge_host/vault.yml:
# edge_zwavejs_session_secret
@@ -0,0 +1,6 @@
---
- name: Restart zwave-js
ansible.builtin.command:
cmd: docker compose up -d --force-recreate
chdir: "{{ zwave_base_dir }}"
changed_when: true
@@ -0,0 +1,95 @@
---
# Z-Wave gateway: zwave-js-ui against the ZWA-2 USB radio.
#
# Its own compose project, in its own directory, so the Z-Wave gateway and the
# power-meter reader are not coupled through a shared compose file. Data paths
# are unchanged -- the store stays at {{ zwave_base_dir }}/store -- so this is a
# project boundary change, not a data migration.
- name: Create zwave-js directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ zwave_base_dir }}"
- "{{ zwave_base_dir }}/store"
- name: Extract musl libeatmydata.so (matches the zwave-js-ui Alpine base)
ansible.builtin.shell:
cmd: |
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
docker run --rm --network=host {{ edge_zwave_alpine_base }} \
sh -c 'apk add --no-cache libeatmydata >/dev/null 2>&1 && cat /usr/lib/libeatmydata.so' > "$tmp"
test "$(stat -c%s "$tmp")" -ge 8000 # sanity: real .so is ~14 KB, guards against a failed/empty pull
install -m 0644 "$tmp" {{ zwave_base_dir }}/libeatmydata.so
creates: "{{ zwave_base_dir }}/libeatmydata.so"
notify: Restart zwave-js
# Because fsync is neutralized, a hard power-loss during the (rare) settings
# write could truncate settings.json — which holds the Z-Wave network keys.
# Keep a validated rolling backup on the eMMC so the keys stay recoverable.
- name: Install zwave-js settings.json backup script
ansible.builtin.template:
src: edge-zwave-settings-backup.j2
dest: /usr/local/bin/edge-zwave-settings-backup
mode: "0755"
- name: Install zwave-js settings backup service + timer
ansible.builtin.copy:
src: "{{ item }}"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- edge-zwave-settings-backup.service
- edge-zwave-settings-backup.timer
- name: Enable zwave-js settings backup timer
ansible.builtin.systemd:
name: edge-zwave-settings-backup.timer
enabled: true
state: started
daemon_reload: true
- name: Deploy zwave-js env (session secret)
ansible.builtin.template:
src: zwavejs.env.j2
dest: "{{ zwave_base_dir }}/zwavejs.env"
mode: "0600"
notify: Restart zwave-js
- name: Deploy zwave-js compose project
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ zwave_base_dir }}/docker-compose.yml"
mode: "0644"
notify: Restart zwave-js
- name: Pull zwave-js image
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ zwave_base_dir }}"
changed_when: false
# Tolerates a missing USB device: if the ZWA-2 is absent the container cannot
# start, and that must not fail the run for everything else on the host.
- name: Start zwave-js
ansible.builtin.command:
cmd: docker compose up -d
chdir: "{{ zwave_base_dir }}"
register: zwave_compose_up
failed_when: >-
zwave_compose_up.rc != 0
and 'error gathering device information' not in zwave_compose_up.stderr
changed_when: >-
'Started' in zwave_compose_up.stderr or 'Created' in zwave_compose_up.stderr
or 'Started' in zwave_compose_up.stdout or 'Creating' in zwave_compose_up.stdout
- name: Warn if zwave-js is waiting for its USB device
ansible.builtin.debug:
msg: >-
zwave-js could not start -- the ZWA-2 radio is not present:
{{ zwave_compose_up.stderr_lines | select('search', 'device') | list }}
when: zwave_compose_up.rc != 0
@@ -0,0 +1,39 @@
# Managed by ansible (roles/zwave-gateway).
#
# Its own compose project. zwave-js and the power-meter reader used to share
# one file under {{ edge_base_dir | default('/opt/edge-host') }}; they are
# unrelated services bound to different USB devices, so a change to one had no
# business recreating the other.
#
# No `privileged` needed: docker's --device handles the device-cgroup grant
# that hostPath in k8s could not (that was the only reason the pod ran
# privileged).
#
# Host networking: real host addresses (v4+v6), no bridge, no NAT. The broker
# LB is IPv6-first and docker bridge NAT66 is a tarpit (unmasqueraded SYNs die
# silently); the host stack just works.
services:
zwave-js:
image: {{ edge_zwavejs_image }}
container_name: zwave-js
restart: unless-stopped
stop_signal: SIGINT
tty: true
environment:
- TZ={{ edge_tz }}
# Batch the value-DB fsync storm to spare the eMMC (see the edge-flash-wear
# role). Neutralizes fsync only for this container; the WS/MQTT value
# stream to HA and cache persistence are unchanged.
- LD_PRELOAD=/usr/lib/libeatmydata.so
env_file:
- zwavejs.env
devices:
- "{{ edge_zwave_device }}:/dev/zwave"
volumes:
# musl libeatmydata.so extracted from the image's Alpine base (ro)
- ./libeatmydata.so:/usr/lib/libeatmydata.so:ro
# Persistent store: settings.json, device DB, network keys. Path is
# unchanged from the previous shared project, so nothing is migrated.
- ./store:/usr/src/app/store
# Binds 3000 (Z-Wave JS websocket for HA) and 8091 (web UI) on the host.
network_mode: host
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Rolling backup of zwave-js settings.json (holds the Z-Wave network keys).
# Managed by roles/edge-host. edge-1 runs zwave-js under libeatmydata (fsync
# Managed by roles/zwave-gateway. edge-1 runs zwave-js under libeatmydata (fsync
# neutralized to spare the eMMC), so a hard power-loss during the rare
# settings write could truncate settings.json. Keep a validated backup on the
# eMMC so the keys are always recoverable. Runs hourly via a systemd timer.
+39
View File
@@ -59,9 +59,48 @@ For each, find the **current pin in the repo** (Read/Glob/Grep), then look up th
5. **Images deliberately excluded from automation** — listed in the
image-automation `README.md` (e.g. vsftpd, otbr, ical-proxy) and any image with
no `# {"$imagepolicy": ...}` marker. Check whether a newer usable tag exists.
This includes **CI step images in `.woodpecker/*.yml`**, which are all manual:
image-automation cannot patch those files at all (they are not Kubernetes
manifests — no `apiVersion`/`kind` — so markers there are ignored), and no
amount of `update.path` widening changes that. Grep `.woodpecker/` for
`image:` and check each pin.
Give `woodpeckerci/woodpecker-cli` in `.woodpecker/ci-manifests-test.yml`
particular attention: the woodpecker **server** IS auto-bumped (`3.x`), the
CLI is not, so they silently drift apart. The CLI lints pipeline configs
against its own schema, so one behind the server can pass a config the server
then rejects — the gate stops predicting server behaviour. Report the CLI as
needing an update whenever it trails the server version, not only when it
trails the newest CLI release. Its tag carries an `-alpine` suffix (the plain
tag is scratch-based with no shell and would break the step) — compare the
`vX.Y.Z` part, and ignore the `next-*` / `pull_*` development tags, which are
the newest by date on that repo and are not releases.
6. **Ansible roles & OpenTofu providers** — `ansible/requirements.yml` (Galaxy
roles/collections) and `opentofu/providers.tf` + `opentofu/.terraform.lock.hcl`.
Report any with a newer published version.
7. **Edge heating CAN services** — open3e (Vitocal heat pump, Vitocharge VX3)
and E3onCAN (E380 grid energy meter), shipped as one image built from
`ci/images/heating-can/Containerfile` and deployed by
`ansible/roles/heating-can`. Nothing else tracks them. They read and (for two
datapoints) write the heat pump over its CAN bus, so silent drift here
matters more than most.
Both upstream pins are `ARG`s in the Containerfile -- `OPEN3E_REF` and
`E3ONCAN_REF` -- not Ansible variables. Read them there, and note the
awkward bits so normal states are not reported as problems:
- open3e publishes **no tags at all** (its tags API returns an empty array),
so compare `OPEN3E_REF` against the default branch:
`https://api.github.com/repos/open3e/open3e/commits?per_page=1`. Report how
many commits behind and the dates. "No release found" is normal here.
- E3onCAN tags, but sparsely, and `E3ONCAN_REF` is an untagged commit later
than the newest tag. Compare against both
`https://api.github.com/repos/MyHomeMyData/E3onCAN/tags` and the default
branch, and say which the pin sits between.
The deployed image tag is `heating_can_image` in
`ansible/roles/heating-can/defaults/main.yml`. It is `:latest` today, so the
node follows whatever CI last built -- the pins above are what actually fix
the versions, and bumping them is what requires a rebuild.
## Method & rigor
+122
View File
@@ -0,0 +1,122 @@
# Heating system over the Viessmann E3 CAN bus: open3e (Vitocal heat pump and
# Vitocharge VX3) and E3onCAN (E380 grid energy meter).
#
# One image, two services. They read the same physical installation over the
# same bus and are versioned together, so they are one artifact -- but they run
# as two containers from this image so either can crash and restart on its own
# without a supervisor as PID 1.
#
# Why an image at all: both were pip-installed into venvs on the host, open3e
# straight from a git URL. pip cannot compare an installed VCS dist against a
# requested one without fetching it, so `pip install` re-cloned and rebuilt on
# every Ansible converge -- reporting changed, restarting the service, and
# putting a gap in heating telemetry each time. Pinning the SHA did not fix
# that (verified: pip still clones and rebuilds a pinned VCS requirement). A
# tagged image does: `docker compose up -d` on an unchanged tag is a genuine
# no-op.
#
# It also gives the weekly update-audit a normal artifact to track instead of
# two awkward special cases -- open3e publishes no tags at all, and E3onCAN is
# pinned to an untagged commit between releases.
#
# Runtime needs host networking: SocketCAN interfaces live in the host network
# namespace, so can0 is only reachable with network_mode: host. That is also
# how the local MQTT broker on 127.0.0.1:1883 is reached. CAN_RAW sockets need
# no capabilities, so both run unprivileged.
FROM python:3.13-slim AS build
# Pins live here, in the artifact, rather than in Ansible variables.
#
# open3e: a commit, because the project publishes NO tags (its tags API returns
# an empty array). Self-reported package version at this commit: 0.7.5.
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
# E3onCAN: an untagged commit on the default branch (2026-06-21, "Fixed: MQTT
# client ID collision caused stale retained values"). Later than the repo's
# only tag, v0.5.1.
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
# git is needed to FETCH the sources and exists only in this stage. The runtime
# stage below copies the built venv and the E3onCAN source out, so neither git
# nor the apt cache ends up in the published image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir supervisor \
&& pip install --no-cache-dir "git+https://github.com/open3e/open3e.git@${OPEN3E_REF}"
# E3onCAN is not packaged -- it is run as a script out of its checkout, so the
# source has to travel into the runtime image alongside its dependencies.
RUN git clone --quiet https://github.com/MyHomeMyData/E3onCAN.git /opt/e3oncan \
&& git -C /opt/e3oncan checkout --quiet "${E3ONCAN_REF}" \
&& pip install --no-cache-dir -r /opt/e3oncan/requirements.txt \
&& rm -rf /opt/e3oncan/.git
FROM python:3.13-slim
ARG OPEN3E_REF=6553c10453a6bae1210908f38a5af2481a46a3e5
ARG E3ONCAN_REF=461dcbdf97e8d72d2edb9929c63b39382af6c3a1
LABEL org.opencontainers.image.title="heating-can" \
org.opencontainers.image.description="open3e + E3onCAN, Viessmann E3 CAN bus readers" \
org.opencontainers.image.source="https://gitea.renner.uno/k.renner/homelab" \
uno.renner.open3e.ref="${OPEN3E_REF}" \
uno.renner.e3oncan.ref="${E3ONCAN_REF}"
# Everything is installed at build time. The runtime image carries the finished
# venv and source only -- no git, no compiler, no package index.
COPY --from=build /opt/venv /opt/venv
COPY --from=build /opt/e3oncan /opt/e3oncan
ENV PATH="/opt/venv/bin:$PATH"
# Both services log to stdout, which docker's journald driver forwards to the
# journal and edge-log-shipper ships to Loki. open3e's systemd unit did NOT set
# this, so its output was block-buffered and effectively never reached the
# journal -- a long-standing blind spot that this fixes for both.
ENV PYTHONUNBUFFERED=1
# One container, two services, so PID 1 is a supervisor. It restarts either
# reader on its own if it dies, reaps children and forwards shutdown -- which a
# bare process as PID 1 cannot do for a sibling.
#
# Configuration arrives as environment variables and supervisord expands them,
# so nothing outside this image constructs open3e's or E3onCAN's argv. Those
# CLIs are an implementation detail here; a compose file spelling them out
# would break whenever either upstream renamed a flag.
COPY supervisord.conf /etc/supervisord.conf
# Build-time self-test. Fails the build if either service cannot actually run,
# which is exactly what shipped before: supervisord pointed at
# /opt/e3oncan/src/E3onCANcollect.py, a path that does not exist -- the file is
# at the repo root. The image built green and would have run open3e while
# e3oncan crash-looped, the E380 meter silently absent.
#
# Done here rather than as a CI step because this needs no registry
# credentials and no runner able to pull a private image -- and because a
# broken image should never be published in the first place.
RUN open3e --help > /dev/null \
&& python /opt/e3oncan/E3onCANcollect.py --help > /dev/null \
&& supervisord --help > /dev/null \
&& grep -q '^\[program:open3e\]' /etc/supervisord.conf \
&& grep -q '^\[program:e3oncan\]' /etc/supervisord.conf \
&& grep -q '^\[supervisorctl\]' /etc/supervisord.conf \
&& supervisorctl --help > /dev/null \
&& echo "self-test OK: both services execute; supervisord has both programs"
# CAN_RAW needs no capabilities, so nothing here runs as root.
RUN useradd --system --no-create-home --shell /usr/sbin/nologin open3e
# A writable working directory, owned by the service user. udsoncan writes its
# log file into the CWD, and the native install this replaces gave open3e's
# directory `owner: open3e` for exactly that reason. /opt is root-owned from the
# COPY layers, so running there would fail that write.
RUN install -d -o open3e -g open3e -m 0755 /var/lib/open3e
USER open3e
WORKDIR /var/lib/open3e
ENTRYPOINT ["supervisord", "-c", "/etc/supervisord.conf"]
+80
View File
@@ -0,0 +1,80 @@
# Both CAN readers, supervised in one container.
#
# They read the same heating installation over the same bus and are versioned
# and deployed together, so they are one container. A container runs one PID 1;
# with two processes that PID 1 is a supervisor, which is what this is --
# supervisord restarts either service on its own if it dies, reaps children,
# and forwards shutdown.
#
# Configuration comes from the environment (see the compose file). supervisord
# expands %(ENV_NAME)s at start, so neither the compose file nor a wrapper
# script constructs open3e's or E3onCAN's argv -- those CLIs stay an
# implementation detail of this image.
# Control socket. Without these two sections supervisorctl cannot connect, so
# there is no way to query or restart an individual service inside the
# container -- which is the entire reason for running a supervisor rather than
# one process per container. The first revision shipped without them.
#
# Unix socket only, inside the container: nothing outside needs to reach it,
# and the container runs with host networking, so a TCP listener would be
# exposed on the host.
[unix_http_server]
file=/tmp/supervisor.sock
chmod=0700
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock
[supervisord]
nodaemon=true
user=open3e
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
# open3e: UDS request/response to the Vitocal heat pump and Vitocharge VX3.
[program:open3e]
command=open3e
-c %(ENV_CAN_INTERFACE)s
-cnfg %(ENV_OPEN3E_CONFIG)s
-m %(ENV_MQTT_HOST)s:%(ENV_MQTT_PORT)s:%(ENV_MQTT_TOPIC)s
-muser %(ENV_MQTT_USER)s:%(ENV_MQTT_PASSWORD)s
-mcid %(ENV_OPEN3E_CLIENT_ID)s
-mfstr %(ENV_MQTT_FORMAT)s
-r %(ENV_OPEN3E_DATAPOINTS)s
-t %(ENV_OPEN3E_POLL_SECONDS)s
-l %(ENV_MQTT_TOPIC)s/cmnd
-j
autostart=true
autorestart=true
startsecs=10
stopsignal=INT
# Straight to the container's stdout/stderr, so docker's journald driver picks
# them up and edge-log-shipper ships them to Loki.
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
# e3oncan: passive CANopen listener for the E380 grid energy meter, which feeds
# the heat pump's own energy management. Issues no bus writes, which is why the
# two can share the bus.
[program:e3oncan]
command=python /opt/e3oncan/E3onCANcollect.py
-c %(ENV_CAN_INTERFACE)s
-dev %(ENV_E3ONCAN_DEVICE)s
-g %(ENV_E3ONCAN_GAP_SECONDS)s
-m %(ENV_MQTT_HOST)s:%(ENV_MQTT_PORT)s:%(ENV_MQTT_TOPIC)s
-muser %(ENV_MQTT_USER)s
-mpass %(ENV_MQTT_PASSWORD)s
-mfstr %(ENV_E3ONCAN_MQTT_FORMAT)s
-j
autostart=true
autorestart=true
startsecs=10
stopsignal=INT
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
+17 -6
View File
@@ -1,7 +1,12 @@
# edge-host installer ISO
Bootable Arch installer image for `edge-1.srv.renner.uno` (the bare-metal
N100 that replaces the k8s-edge VM — see `ansible/roles/edge-host`).
Bootable Arch installer image for the bare-metal edge hosts — the machines
that replaced the k8s-edge VM, because Talos cannot do CAN or USB
passthrough. See `ansible/docs/edge-host.md`.
Not tied to one box or one model: the installer autodetects the target disk
(NVMe, else eMMC, else the first non-USB disk), which covers both the Wyse
3040 thin clients and larger hardware.
The image is the official archiso **releng** profile plus:
- `edge-install` in the live environment — partitions the NVMe
@@ -12,7 +17,7 @@ The image is the official archiso **releng** profile plus:
Division of labour: the ISO produces an **ansible-ready base system**;
all service configuration (zwave-js-ui, sml2mqtt, open3e/CAN) stays in
`ansible/roles/edge-host` as the single source of truth. Rebuilding the
`ansible/docs/edge-host.md` as the single source of truth. Rebuilding the
box is: boot ISO → `edge-install` → run the play.
## Build
@@ -38,10 +43,16 @@ archiso updates flow in automatically.
(NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk),
shows the target and requires typing the hostname before wiping
anything. Override with `edge-install /dev/sdY`.
4. Reboot, then from the workstation:
4. Reboot. Confirm the box is reachable by name — `ssh root@<host>` — before
going further; it is identified by name everywhere and its address is
never recorded. Then add it to `[edge_host]` in
`ansible/inventory/pve_hosts.yml` and, from the workstation:
```
cd ansible
ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
```
5. Continue with the cutover checklist in `ansible/roles/edge-host/README.md`.
One run over the whole group, no `--limit`: the play carries
`certificates` and `common` itself, so there is no separate `common.yml`
step. Running it converges every edge host, which is the point — a run
that touches only the new box tells you nothing about the others.
5. Continue with the cutover checklist in `ansible/docs/edge-host.md`.
@@ -50,7 +50,7 @@ mount "$(part 2)" /mnt
mount --mkdir "$(part 1)" /mnt/boot
# Everything the edge-host ansible role expects to find; the role itself
# is applied over SSH after first boot (see ansible/roles/edge-host).
# is applied over SSH after first boot (see ansible/docs/edge-host.md).
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \
docker docker-compose
@@ -99,7 +99,13 @@ EOF
umount -R /mnt
echo
echo "${HOST} installed. Remove the USB stick, reboot, then from the"
echo "workstation: cd ansible &&"
echo " ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host"
echo " ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml"
echo "${HOST} installed. Remove the USB stick and reboot."
echo
echo "Then, from the workstation:"
echo " 1. confirm 'ssh root@${HOST}' works (it must be reachable by name)"
echo " 2. add ${HOST} to [edge_host] in ansible/inventory/pve_hosts.yml"
echo " 3. cd ansible && ansible-playbook -i inventory/pve_hosts.yml \\"
echo " plays/install-edge-host.yml"
echo
echo "One run, whole group, no --limit: the play carries certificates and"
echo "common itself, so there is no separate common.yml step."
+18
View File
@@ -160,6 +160,24 @@ data:
conditions:
- "[STATUS] == any(200, 301, 302)"
# Terminus admin UI. Checks the TLS route only — the cleartext
# device endpoint (trmnl-device.renner.uno) is the same pod, so a
# failure here covers both. Terminus exposes no health endpoint, so
# this is a root-page check: the login page answers 200, and an
# unauthenticated request may redirect.
- name: Web Service 10
group: services
url: https://trmnl.renner.uno
interval: 60s
ui:
hide-hostname: true
hide-url: true
hide-port: true
hide-conditions: true
hide-errors: true
conditions:
- "[STATUS] == any(200, 301, 302)"
# Removed 2026-05-21: Jellyfin (media.renner.uno), Wiki (wiki.renner.uno),
# InfluxDB (influx.renner.uno) — hostnames NXDOMAIN, no Ingress; services
# no longer deployed. Re-add when/if they come back.
@@ -0,0 +1,179 @@
# Proposal: enrol the mosquitto broker credentials in Class-A rotation
**Status:** proposal, nothing implemented.
**Scope:** the 8 MQTT broker users and the credentials behind them.
## Problem
Every self-generated secret in this cluster rotates automatically except these.
`kubernetes/infrastructure/openbao/rotation/` holds 13 manifests defining 15
rotator CronJobs (`home-assistant.yaml` alone defines three). Five are **Class-A**
— authentik, crowdsec, paperless, shelly-manager and woodpecker — meaning
"self-generated random secret", which is exactly what a broker password is.
The mosquitto credentials are the only Class-A-shaped secrets not enrolled.
They are currently minted once by seven near-identical bootstrap Jobs
(`mosquitto-bootstrap.yaml`, 508 lines — the ServiceAccount, Role and RoleBinding
are inside that same file) which generate a password, hand-build Secret JSON with
`jq` and POST it to the Kubernetes API with `curl`. Once created they never
change again.
Three consequences:
1. **They never rotate.** Not on a schedule, not on compromise, not ever.
2. **They exist only in the Secret object** — not in OpenBao, so not covered by
the OpenBao snapshot CronJob, and unrecoverable if lost.
3. **The Jobs cannot be modified.** A Job's `spec.template` is immutable, so
changing even the container image makes the whole `home-assistant`
Kustomization fail to reconcile with `field is immutable`. Any edit requires
deleting or renaming the Jobs.
## Why the obvious fixes do not work
Recorded because each was tried and rejected on evidence, and the reasons are
not obvious from the code.
| Approach | Why it fails |
| --- | --- |
| ExternalSecrets reading hand-seeded OpenBao paths | Depends on content copied in by hand. Git cannot recreate it; a fresh OpenBao leaves the paths empty and ESO then overwrites live broker credentials with nothing. |
| ESO `Password` generator | **Overwrites pre-existing Secrets.** Verified: a Secret holding `KNOWN-PREEXISTING-VALUE` was replaced with a freshly generated password on first sync. Adopting it rotates the 7 Job-minted credentials at once, uncoordinated (`evcc` is already ESO-extract-backed and so unaffected). |
| Keep the Jobs, fix only the image | Blocked by `spec.template` immutability. Renaming the Jobs works but is a hack that recurs on every future edit. |
The common thread: each treats the credential as something to *place*, when the
real requirement is a credential that can be *replaced*.
## How rotation actually has to work here
The broker rebuilds its password file from scratch on **every pod start**
(`mosquitto-deployment.yaml`): `rm -f /mosquitto/auth/passwd` followed by
`mosquitto_passwd -c -b …` for each of the 8 users, sourced from env vars backed
by the 8 Secrets.
Two things follow, and they shape the whole design:
- **A Secret change has no effect until the broker restarts.** There is no
Reloader annotation on the mosquitto Deployment today, unlike the consumers of
the existing Class-A rotators.
- **There is no make-before-break.** A mosquitto user has exactly one password.
The LLAT rotators can mint a second token and revoke the first afterwards; a
broker user cannot hold old and new simultaneously. Unless a transitional
second user is created, broker and client must cut over together.
### Who holds each credential
This is the part that determines cutover cost, and it is not visible from the
manifests alone.
| Broker user | Held by | Cutover |
| --- | --- | --- |
| `health` | the broker's own liveness/readiness probes | in-cluster, same pod restart |
| `zigbee2mqtt` | zigbee2mqtt Deployment (`secretKeyRef`) | in-cluster, pod restart |
| `evcc` | evcc app; already OpenBao-backed via ESO | in-cluster, pod restart |
| `sml2mqtt` | edge host, from `vault_edge_sml2mqtt_mqtt_password` | re-run the edge-host play |
| `open3e` | edge host, from `vault_edge_open3e_mqtt_password` | re-run the edge-host play |
| `homeassistant` | HA MQTT integration — a config entry created in the UI | needs an API/WebSocket update, or a manual re-entry |
| `shellies` | Shelly device firmware | pushed per-device; `shelly-manager` is deployed and is the natural mechanism |
| `admin` | a workstation client, held by a human | read the new value from OpenBao |
Note `sml2mqtt` and `open3e` currently keep **the same password in two unlinked
places** — the Kubernetes Secret for the broker, and `ansible-vault` for the
client — synchronised by hand. That duplication, not any client limitation, is
what makes rotation feel impossible today.
## Proposed design
Follow the existing Class-A pattern exactly (`rotation/paperless.yaml` is the
closest model): *generate random bytes → merge-patch into OpenBao → ESO syncs the
Secret → restart the consumer*.
1. **One rotator CronJob per broker user**, running the existing `secret-rotator`
binary in random mode, writing `apps/home-assistant/mosquitto-<user>-credentials`
in OpenBao. Reuses the established policy/role/SA pattern; no new machinery.
2. **ExternalSecrets** project each OpenBao entry to the existing Secret name and
key, so `mosquitto-deployment.yaml` and the in-cluster consumers are unchanged.
Key names stay as they are (`homeassistant-password` on one, `password` on the
rest) — consumers reference them, so normalising is a separate change.
3. **Add a Reloader annotation to the mosquitto Deployment** so a Secret change
restarts the broker, instead of silently taking effect at some unrelated future
restart.
4. **Move the edge host off `ansible-vault` for these two values**, reading the
same OpenBao entry instead, so there is one source of truth per credential.
5. **Rotate one user at a time.** Each rotation then affects exactly one client,
not all eight.
### The cutover window
With no make-before-break, each rotation has a window where broker and client
disagree. Two options:
- **Accept a brief window** (recommended for most users). Order: rotator writes
OpenBao → client picks up the new value → broker restarts. MQTT clients
reconnect automatically; the window is seconds. Because rotation is per-user,
only that one client is affected.
One failure mode to expect rather than be alarmed by: the broker rolls with
`maxUnavailable: 0` / `maxSurge: 1`, so mid-rollout **two broker pods with
different passwd files sit behind the LB**. A client reconnecting during that
window can land on the old pod and fail auth until the rollout completes. It is
self-healing and lasts seconds to a minute, but it will look alarming in logs
during the first `health` / `zigbee2mqtt` rotation, so it is worth knowing in
advance.
- **Transitional dual user** (`<user>` and `<user>-next` in the passwd file and
ACL) for any credential where even a brief outage is unacceptable. More moving
parts; probably only worth it for `homeassistant`, which carries everything.
## Suggested phasing
Ordered so the risky work happens after the pattern is proven, and so each phase
is independently valuable.
1. **`health` and `zigbee2mqtt` first.** Both are entirely in-cluster and pick up
a new password on pod restart with no external coordination. Proves rotator →
OpenBao → ESO → Reloader end to end with the smallest blast radius. `health`
is the safest possible start: only the broker's own probes consume it.
2. **`evcc`.** Already OpenBao-backed via ESO; only the rotator is missing.
3. **`sml2mqtt` and `open3e`**, together with switching the edge host to read from
OpenBao. This is where the duplicate-source-of-truth problem gets fixed.
4. **`shellies`**, via shelly-manager pushing to devices. Needs investigation:
shelly-manager has its own Class-A rotator today, but that rotates
shelly-manager's own secret and does not touch device MQTT credentials.
5. **`homeassistant`** last. Highest blast radius, and the config entry is UI-created,
so it needs an API/WebSocket update path — the HA LLAT rotators
(`rotation/home-assistant.yaml`) already talk to HA over WebSocket and are the
obvious precedent.
6. **`admin`** whenever convenient; a human reads the new value from OpenBao.
Only once a user is enrolled does its bootstrap Job become dead code. Delete the
Jobs incrementally as their users migrate, rather than in one step — that also
avoids the immutability problem entirely, since the Jobs are deleted, never
patched.
## Open questions
- **Does `secret-rotator` random mode fit unchanged?** The existing rotators write
a field into an OpenBao entry; here the entry holds a single password field, so
it should, but this needs confirming against the binary rather than assumed.
- **Can shelly-manager push MQTT credentials to devices**, and does it cover both
Gen1 (`/settings/mqtt`) and Gen2+ (`Mqtt.SetConfig`) firmwares? If not, the
`shellies` user may need a different mechanism or stay manual longest.
- **How is the HA MQTT config entry updated programmatically?** Reusing the
WebSocket approach from the LLAT rotator is plausible but unverified.
- **Rotation cadence.** The existing rotators' schedules should be the reference
rather than inventing a new one.
- **ESO refresh latency.** Rotator-writes-OpenBao → Secret-updates →
Reloader-restarts is delayed by up to one `refreshInterval`; the `evcc`
ExternalSecret uses `1h`. That does not widen the outage window — broker and
client still move together — but it does stretch how long a rotation takes to
land, which matters for verifying one completed and for the cold-rebuild
ordering question above. Worth deciding whether these ExternalSecrets want a
shorter interval than 1h, or whether the rotator should trigger the sync
directly.
- **Bootstrap on a cold rebuild.** On a fresh cluster the rotator must create the
OpenBao entry before ESO can sync it; check this against
`kubernetes/RUNBOOK-cold-rebuild.md` so broker bring-up is not blocked.
## What this does not change
- Secret names, key names and every `secretKeyRef` in `mosquitto-deployment.yaml`.
- The ACL model and the per-user topic scoping.
- The `evcc` ExternalSecret, which already follows the target pattern.
@@ -38,7 +38,7 @@ spec:
# gets the heavy HA pod (8Gi) off the soon-to-be-tiny edge box.
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.7.4 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.8.1 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -13,6 +13,9 @@ resources:
- prometheus-token-externalsecret.yaml
- config-sync-token-externalsecret.yaml
- mosquitto-bootstrap.yaml
# health broker credential: OpenBao-backed + Class-A rotated (first of the
# eight users to migrate off mosquitto-bootstrap.yaml).
- mosquitto-health-credentials-externalsecret.yaml
- mosquitto-config.yaml
- mosquitto-certificate.yaml
- mosquitto-service.yaml
@@ -370,74 +370,6 @@ spec:
limits:
memory: 64Mi
---
# Broker user `health`: a dedicated, least-privilege credential used ONLY by
# mosquitto's own liveness/readiness probes (mosquitto-deployment.yaml). Its
# ACL is `write health/#` and nothing else — it cannot read any topic or
# write outside the throwaway probe subtree, so a leak is worthless (nothing
# subscribes to health/#). Kept on its own Secret, like every other user, so
# the probe never has to borrow the admin credential.
apiVersion: batch/v1
kind: Job
metadata:
name: mosquitto-health-credentials-bootstrap
namespace: home-assistant
spec:
backoffLimit: 4
template:
metadata:
labels:
app.kubernetes.io/name: mosquitto-bootstrap
spec:
serviceAccountName: mosquitto-bootstrap
restartPolicy: OnFailure
containers:
- name: bootstrap
image: alpine:3.21
command:
- sh
- -c
- |
set -eu
apk add --no-cache curl jq >/dev/null
NS=home-assistant
NAME=mosquitto-health-credentials
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API=https://kubernetes.default.svc
if curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NS/secrets/$NAME" >/dev/null 2>&1; then
echo "$NAME already exists; nothing to do."
exit 0
fi
PW=$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32)
PW_B64=$(printf '%s' "$PW" | base64 | tr -d '\n')
body=$(jq -nc \
--arg name "$NAME" \
--arg ns "$NS" \
--arg pw "$PW_B64" \
'{apiVersion: "v1",
kind: "Secret",
metadata: {name: $name, namespace: $ns},
type: "Opaque",
data: {password: $pw}}')
curl -fsSL --cacert "$CA" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -X POST \
--data "$body" \
"$API/api/v1/namespaces/$NS/secrets" >/dev/null
echo "generated $NAME"
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
# Broker user `zigbee2mqtt` — the Zigbee2MQTT bridge (ZHA replacement). Scoped
# to its own `zigbee2mqtt/#` subtree plus `write homeassistant/#` (so it can
# publish HA MQTT-discovery configs, exactly like the `shellies` user) and
@@ -5,6 +5,18 @@ metadata:
namespace: home-assistant
labels:
app.kubernetes.io/name: mosquitto
annotations:
# REQUIRED for credential rotation to take effect at all. The startup shim
# rebuilds /mosquitto/auth/passwd from these Secrets on every pod start, so
# a rotated Secret is inert until the pod restarts — without this the new
# password would sit unused until some unrelated restart, while the old one
# stayed valid indefinitely.
#
# Listed per-Secret rather than using the blanket `reloader.stakater.com/
# auto: "true"`, so that enrolling each remaining broker user in rotation is
# a visible, reviewable edit to this line rather than something that starts
# happening implicitly.
secret.reloader.stakater.com/reload: mosquitto-health-credentials
spec:
replicas: 1
strategy:
@@ -177,7 +189,9 @@ spec:
secretKeyRef:
name: mosquitto-evcc-credentials
key: password
# Probe-only credential (mosquitto-health-credentials-bootstrap).
# Probe-only credential. Now OpenBao-backed and Class-A rotated (see
# mosquitto-health-credentials-externalsecret.yaml); its bootstrap Job
# is gone.
# Present here for the same reason as the others — the startup
# shim hashes it into the passwd file — and reused by the exec
# probes below, which run inside this container.
@@ -230,7 +244,7 @@ spec:
#
# Uses the dedicated least-privilege `health` user (ACL: readwrite
# health/# only — see the passwd/acl build above and the
# mosquitto-health-credentials-bootstrap Job). Its password is in
# mosquitto-health-credentials ExternalSecret). Its password is in
# this container's env (HEALTH_PASSWORD) because the startup shim
# hashes every user into the passwd file; the exec probes run
# inside this container and read it directly. A leak of this
@@ -0,0 +1,36 @@
# Projects the mosquitto `health` broker credential from OpenBao into the Secret
# the broker already consumes. FIRST of the eight broker users to move off the
# bootstrap Jobs and onto Class-A rotation (see
# PROPOSAL-mosquitto-credential-rotation.md (PR #453 — not yet merged; this
# file lands with it)); the rotator lives at
# kubernetes/infrastructure/openbao/rotation/mosquitto-health.yaml.
#
# Secret name and key are UNCHANGED (`password`), so mosquitto-deployment.yaml's
# secretKeyRef and the exec probes that read $HEALTH_PASSWORD need no edit.
#
# refreshInterval is 10m rather than the 1h used by mosquitto-evcc-credentials:
# a rotation only lands once ESO re-reads, so 1h would mean up to an hour between
# the rotator running and the broker actually using the new password, with the
# old one still valid in the meantime. 10m keeps that window short without
# polling OpenBao pointlessly hard.
#
# ⚠️ OWNERSHIP: creationPolicy is implicitly Owner, so ESO sets an ownerReference
# on the Secret. Deleting this ExternalSecret therefore DELETES the live Secret
# and with it the broker's probe credential — the bootstrap Job it replaces had
# no such coupling. To detach without destroying, set creationPolicy: Orphan
# before removing it.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mosquitto-health-credentials
namespace: home-assistant
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: mosquitto-health-credentials
dataFrom:
- extract:
key: apps/home-assistant/mosquitto-health-credentials
@@ -27,7 +27,7 @@ spec:
type: RuntimeDefault
containers:
- name: pve-exporter
image: prompve/prometheus-pve-exporter:3.9.0 # {"$imagepolicy": "flux-system:pve-exporter"}
image: prompve/prometheus-pve-exporter:3.10.0 # {"$imagepolicy": "flux-system:pve-exporter"}
# Image ENTRYPOINT is already `pve_exporter`; pass flags only.
args:
- --config.file=/etc/pve-exporter/pve.yml
@@ -52,7 +52,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.9.9 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.9.11 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -62,7 +62,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.9.9 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.9.11 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+1 -1
View File
@@ -22,7 +22,7 @@ spec:
type: RuntimeDefault
containers:
- name: gotenberg
image: gotenberg/gotenberg:8.34.0 # {"$imagepolicy": "flux-system:gotenberg"}
image: gotenberg/gotenberg:8.35.0 # {"$imagepolicy": "flux-system:gotenberg"}
args:
- "gotenberg"
- "--chromium-disable-javascript=true"
+1 -1
View File
@@ -25,7 +25,7 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:8.8.1-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
image: redis:8.10.0-alpine # {"$imagepolicy": "flux-system:redis-paperless"}
args:
- redis-server
- --save
+41
View File
@@ -0,0 +1,41 @@
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: tr-pg
namespace: trmnl
spec:
instances: 1
smartShutdownTimeout: 10
stopDelay: 60
# PG 18, NOT the repo-standard 16. Terminus ships a committed
# config/db/structure.sql containing `SET transaction_timeout = 0;` — a GUC
# that does not exist before PostgreSQL 17, so schema load against a 16
# server dies with `unrecognized configuration parameter`. Upstream's
# compose.yml develops against 18.4, so 18 is the tested target; 17 is the
# bare minimum. Bare major tag matches the other clusters (minor security
# bumps flow automatically, major upgrades stay an explicit decision).
imageName: ghcr.io/cloudnative-pg/postgresql:18
enableSuperuserAccess: true
storage:
storageClass: ceph-block
size: 5Gi
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: tr-pg-store
bootstrap:
initdb:
database: terminus
owner: terminus
postgresql:
parameters:
shared_buffers: "128MB"
max_connections: "50"
wal_compression: "on"
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
+48
View File
@@ -0,0 +1,48 @@
# Device-facing endpoint. Cleartext HTTP, deliberately.
#
# The TRMNL firmware initiates every request and cannot be handed the
# internal CA, so terminating TLS here would simply break polling. Scope of
# the exposure: this Service is LAN-only (no public DNS label, no Cloudflare
# record), it carries no admin session — the device authenticates with its
# MAC in the `ID` header plus a per-device api_key — and the Terminus admin
# UI stays on the TLS route at trmnl.renner.uno.
#
# Same shape as home-assistant-bgp: a dedicated address on the Cilium BGP
# pool rather than a TLS-less Ingress, which would be a new pattern for this
# repo. The device is provisioned with the HOSTNAME, not the address.
#
# The address is requested explicitly, matching every other LB service here.
# Left to allocate freely, Cilium IPAM handed out the first free address in
# the pool block (10.2.99.0-10.2.99.255) — which is 10.2.99.0, the base
# address of the range. Each LB address is advertised to the UDM as its own
# BGP route so that is routable in principle, but a .0 host address is the
# kind of thing constrained embedded IP stacks get wrong, and the TRMNL
# firmware is the single client that has to work here. Not worth the risk
# for an address nobody chose on purpose.
#
# .89 continues the existing sequence (.81-.88 are in use) and matches the
# fd99::89 that IPAM had already picked for v6.
apiVersion: v1
kind: Service
metadata:
name: terminus-device
namespace: trmnl
labels:
app.kubernetes.io/name: terminus
lb-pool: bgp
annotations:
external-dns.alpha.kubernetes.io/hostname: trmnl-device.renner.uno
lbipam.cilium.io/ips: "10.2.99.89,fd99::89"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
- IPv6
selector:
app.kubernetes.io/name: terminus
ports:
- name: http
port: 80
targetPort: http
+18
View File
@@ -0,0 +1,18 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: terminus-secrets
namespace: trmnl
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: terminus-secrets
# Holds APP_SECRET only (session/cookie/CSRF key). DATABASE_URL is not in
# here — it comes straight off the CNPG-generated `tr-pg-app` Secret's
# `uri` key, so the password rotates with the cluster and is never copied.
dataFrom:
- extract:
key: apps/trmnl
@@ -0,0 +1,15 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: trmnl
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: seed/trmnl/hetzner-creds
+28
View File
@@ -0,0 +1,28 @@
# Admin UI only — login, device registration, playlists, screen previews.
# The device never touches this route: the firmware speaks plain HTTP and
# would reject the internal CA, so it uses the cleartext LoadBalancer in
# device-service.yaml instead. Both front the same Service.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: terminus
namespace: trmnl
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- trmnl.renner.uno
secretName: trmnl-tls
rules:
- host: trmnl.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: terminus
port:
number: 80
+15
View File
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- pvcs.yaml
- valkey.yaml
- terminus.yaml
- ingress.yaml
- device-service.yaml
# tr-pg CNPG Postgres + offsite Barman backup. Note this cluster runs
# PostgreSQL 18, not the repo-standard 16 — see the comment in cluster.yaml.
- cluster.yaml
- objectstore.yaml
- scheduled-backup.yaml
- hetzner-creds-externalsecret.yaml
+30
View File
@@ -0,0 +1,30 @@
---
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: tr-pg-store
namespace: trmnl
spec:
configuration:
# Offsite Hetzner Object Storage (fsn1), same bucket as the other CNPG
# clusters. No SSE: Hetzner supports only SSE-C, unusable by barman-cloud
# (plugin #646), so at-rest encryption is unavailable; transit is TLS.
destinationPath: s3://renner-uno-k8s-cnpg/tr-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
# Region is required (REGION=fsn1) — without it boto3's HeadBucket
# (barman-cloud-check-wal-archive during bootstrap.recovery) 400s.
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
retentionPolicy: "30d"
+36
View File
@@ -0,0 +1,36 @@
---
# Uploads + synchronised fonts for the Terminus pod. Mounted twice by
# subPath (uploads/, fonts/) into both the web and sidekiq containers, which
# share one pod — so RWO is sufficient and the Deployment uses `Recreate`.
#
# Contents: rendered device screens and the font set pulled by the
# FONT_SYNCHRONIZER job. Persisting fonts here (rather than an emptyDir)
# means a pod restart does not depend on upstream being reachable.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: terminus-data
namespace: trmnl
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 5Gi
---
# Valkey RDB snapshots. The Sidekiq queue is reconstructable — the
# synchroniser jobs re-enqueue on boot — so this is small and losing it is
# an inconvenience, not data loss.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: terminus-valkey-data
namespace: trmnl
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block
resources:
requests:
storage: 1Gi

Some files were not shown because too many files have changed in this diff Show More