Scope caveat up front: this hardens the keepalived path, which is legacy. It is worth landing because that path is still live today (the PVE hosts still exist, and Shellys are pointed at the VIP via plays/configure-shelly-ntp.yml), but it is explicitly not the answer to "a dead battery must never bring down internal NTP". See the last section.
The bug
The health check asked "is chronyd alive", never "is this host safe to take time from". On 2026-08-04 those came apart.
edge-1's CMOS battery is dead. After a power loss its clock came up ~16 days in the past and, with no makestep, chronyd never corrected it. All three 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 entire time. keepalived would have happily kept the VIP on a host ready to serve a clock from July.
The sharpest part — check 2 was:
if ! chronyc tracking >/dev/null 2>&1;then
The one command that knew the answer was executed and its answer discarded. During the incident that output read:
System time : 1376235.375000000 seconds slow of NTP time
The fix
Captures the output and adds two assertions:
Offset above keepalived_ntp_max_offset (new, default 1.0s) fails, so a host that knows its clock is wrong drops the VIP.
No upstream source selected in chronyc sources fails. This matters because upstream chrony docs are explicit that with local stratum N, the local reference activates immediately even if chronyd has never synchronised to anything — so a never-validated host still looks authoritative to clients.
Verification
Both predicates were validated against live output rather than assumed:
offset comparison against the real incident value (1376235.375 vs 1.0 → fail) and against a healthy value (0.000000000 → pass)
the chronyc sources selected-source column against a real run on edge-1 (^* ptbtime1.ptb.de), confirming the marker is in column 2
Failure behaviour during a WAN outage
Failing 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 while the others are healthy.
What this does NOT fix
plays/install-ntp-ha.yml documents the end state:
as the PVE hosts are retired for bare-metal Talos, edge-1 is intended to become the sole NTP server
In that end state this PR is nearly useless, and arguably harmful: with one candidate there is nothing to fail over to, so a failing check converts "serves the wrong time" into "serves no time". Internal NTP would then rest entirely on a Dell Wyse 3040 thin client with a known-dead RTC battery and no redundancy.
That needs an architectural answer, not a better health check. Raised separately.
Follow-up to #465 / #466.
> **Scope caveat up front:** this hardens the **keepalived path, which is legacy.** It is worth landing because that path is still live today (the PVE hosts still exist, and Shellys are pointed at the VIP via `plays/configure-shelly-ntp.yml`), but it is explicitly **not** the answer to "a dead battery must never bring down internal NTP". See the last section.
## The bug
The health check asked *"is chronyd alive"*, never *"is this host safe to take time from"*. On 2026-08-04 those came apart.
edge-1's CMOS battery is dead. After a power loss its clock came up ~16 days in the past and, with no `makestep`, chronyd never corrected it. All three 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 entire time. keepalived would have happily kept the VIP on a host ready to serve a clock from July.
The sharpest part — check 2 was:
```bash
if ! chronyc tracking >/dev/null 2>&1; then
```
The one command that knew the answer was executed and its answer discarded. During the incident that output read:
```
System time : 1376235.375000000 seconds slow of NTP time
```
## The fix
Captures the output and adds two assertions:
1. **Offset above `keepalived_ntp_max_offset`** (new, default `1.0`s) fails, so a host that *knows* its clock is wrong drops the VIP.
2. **No upstream source selected** in `chronyc sources` fails. This matters because [upstream chrony docs](https://chrony-project.org/doc/4.5/chrony.conf.html) are explicit that with `local stratum N`, the local reference activates **immediately even if chronyd has never synchronised to anything** — so a never-validated host still looks authoritative to clients.
## Verification
Both predicates were validated against live output rather than assumed:
- offset comparison against the real incident value (`1376235.375` vs `1.0` → fail) and against a healthy value (`0.000000000` → pass)
- the `chronyc sources` selected-source column against a real run on edge-1 (`^* ptbtime1.ptb.de`), confirming the marker is in column 2
## Failure behaviour during a WAN outage
Failing 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 while the others are healthy.
## What this does NOT fix
`plays/install-ntp-ha.yml` documents the end state:
> as the PVE hosts are retired for bare-metal Talos, edge-1 is intended to become the **sole NTP server**
In that end state this PR is nearly useless, and arguably harmful: with one candidate there is nothing to fail over to, so a failing check converts *"serves the wrong time"* into *"serves no time"*. Internal NTP would then rest entirely on a Dell Wyse 3040 thin client with a known-dead RTC battery and no redundancy.
That needs an architectural answer, not a better health check. Raised separately.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
🤖Automated review — model CLI default (complexity: complex), commit 601290dcd90ff6cccde800ed69a882620a294b2f.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
Replaces the keepalived NTP health check's liveness-only probing (service active / chronyc tracking exit code discarded / port 123 listening) with two new assertions that actually inspect chronyd's output: the reported system-clock offset against a configurable ceiling (keepalived_ntp_max_offset, default 1.0s), and whether chronyc sources shows a selected upstream source. I traced the field/column parsing (awk '{print $4}' on the System time line, and the ^.\*|^.\+ anchor on chronyc sources) against the real chronyc output format and the incident data quoted in the PR body, and both are correct. The surrounding role/playbook context (chrony.conf.j2's local stratum 10, install-ntp-ha.yml's migration note, keepalived.conf.j2's fall 2/interval 2) all corroborate the claims made in the PR description. No blocking issues found.
🟡 Suggestions
ansible/roles/keepalived-ntp/templates/check-ntp-health.sh.j2:60-65 — Check 4 silently no-ops if OFFSET fails to parse (empty string, e.g. from an unexpected chronyc output format change). Contrast with Check 5, which fails hard when its own data is ambiguous ("no upstream yet" is explicitly treated as unsafe). For consistency with that same-PR philosophy, consider also failing when OFFSET can't be extracted, rather than treating unparseable output as "offset is fine." Low risk today since the format was validated against a live host, but it's a silent gap if chronyc's output ever changes on an OS upgrade.
Tests
No automated tests exist for this change, and none are added. That's consistent with the rest of the repo — .woodpecker/ci-manifests-test.yml's shellcheck step is explicitly scoped to kubernetes/** only (see its own comment noting the gap for "shell embedded in manifests"/other trees), and there's no ansible-lint/molecule pipeline covering ansible/roles/** at all. So this isn't a regression in this PR's testing posture, just a pre-existing gap for the whole ansible tree. The PR body does describe manual verification of both new predicates against real chronyc output (the incident's actual offset value, a healthy 0.0 value, and a live chronyc sources run on edge-1), which is reasonable given no CI harness exists to automate it.
Recommendation
Looks reasonable — no blocking issues; comment above is optional.
🤖 **Automated review** — model `CLI default` (complexity: complex), commit `601290dcd90ff6cccde800ed69a882620a294b2f`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
Replaces the keepalived NTP health check's liveness-only probing (service active / `chronyc tracking` exit code discarded / port 123 listening) with two new assertions that actually inspect chronyd's output: the reported system-clock offset against a configurable ceiling (`keepalived_ntp_max_offset`, default 1.0s), and whether `chronyc sources` shows a selected upstream source. I traced the field/column parsing (`awk '{print $4}'` on the `System time` line, and the `^.\*|^.\+` anchor on `chronyc sources`) against the real `chronyc` output format and the incident data quoted in the PR body, and both are correct. The surrounding role/playbook context (`chrony.conf.j2`'s `local stratum 10`, `install-ntp-ha.yml`'s migration note, `keepalived.conf.j2`'s `fall 2`/`interval 2`) all corroborate the claims made in the PR description. No blocking issues found.
## 🟡 Suggestions
- `ansible/roles/keepalived-ntp/templates/check-ntp-health.sh.j2:60-65` — Check 4 silently no-ops if `OFFSET` fails to parse (empty string, e.g. from an unexpected `chronyc` output format change). Contrast with Check 5, which fails hard when its own data is ambiguous ("no upstream yet" is explicitly treated as unsafe). For consistency with that same-PR philosophy, consider also failing when `OFFSET` can't be extracted, rather than treating unparseable output as "offset is fine." Low risk today since the format was validated against a live host, but it's a silent gap if `chronyc`'s output ever changes on an OS upgrade.
## Tests
No automated tests exist for this change, and none are added. That's consistent with the rest of the repo — `.woodpecker/ci-manifests-test.yml`'s `shellcheck` step is explicitly scoped to `kubernetes/**` only (see its own comment noting the gap for "shell embedded in manifests"/other trees), and there's no `ansible-lint`/molecule pipeline covering `ansible/roles/**` at all. So this isn't a regression in this PR's testing posture, just a pre-existing gap for the whole ansible tree. The PR body does describe manual verification of both new predicates against real `chronyc` output (the incident's actual offset value, a healthy 0.0 value, and a live `chronyc sources` run on edge-1), which is reasonable given no CI harness exists to automate it.
## Recommendation
Looks reasonable — no blocking issues; comment above is optional.
<!-- pr-review:sha=601290dcd90ff6cccde800ed69a882620a294b2f -->
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-up to #465 / #466.
The bug
The health check asked "is chronyd alive", never "is this host safe to take time from". On 2026-08-04 those came apart.
edge-1's CMOS battery is dead. After a power loss its clock came up ~16 days in the past and, with no
makestep, chronyd never corrected it. All three checks passed continuously for ~18h:chronyc trackingrespondedand
local stratum 10meant chronyd advertised itself to clients as a valid reference the entire time. keepalived would have happily kept the VIP on a host ready to serve a clock from July.The sharpest part — check 2 was:
The one command that knew the answer was executed and its answer discarded. During the incident that output read:
The fix
Captures the output and adds two assertions:
keepalived_ntp_max_offset(new, default1.0s) fails, so a host that knows its clock is wrong drops the VIP.chronyc sourcesfails. This matters because upstream chrony docs are explicit that withlocal stratum N, the local reference activates immediately even if chronyd has never synchronised to anything — so a never-validated host still looks authoritative to clients.Verification
Both predicates were validated against live output rather than assumed:
1376235.375vs1.0→ fail) and against a healthy value (0.000000000→ pass)chronyc sourcesselected-source column against a real run on edge-1 (^* ptbtime1.ptb.de), confirming the marker is in column 2Failure behaviour during a WAN outage
Failing 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 while the others are healthy.What this does NOT fix
plays/install-ntp-ha.ymldocuments the end state:In that end state this PR is nearly useless, and arguably harmful: with one candidate there is nothing to fail over to, so a failing check converts "serves the wrong time" into "serves no time". Internal NTP would then rest entirely on a Dell Wyse 3040 thin client with a known-dead RTC battery and no redundancy.
That needs an architectural answer, not a better health check. Raised separately.
🤖 Generated with Claude Code
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>🤖 Automated review — model
CLI default(complexity: complex), commit601290dcd90ff6cccde800ed69a882620a294b2f.Summary
Replaces the keepalived NTP health check's liveness-only probing (service active /
chronyc trackingexit code discarded / port 123 listening) with two new assertions that actually inspect chronyd's output: the reported system-clock offset against a configurable ceiling (keepalived_ntp_max_offset, default 1.0s), and whetherchronyc sourcesshows a selected upstream source. I traced the field/column parsing (awk '{print $4}'on theSystem timeline, and the^.\*|^.\+anchor onchronyc sources) against the realchronycoutput format and the incident data quoted in the PR body, and both are correct. The surrounding role/playbook context (chrony.conf.j2'slocal stratum 10,install-ntp-ha.yml's migration note,keepalived.conf.j2'sfall 2/interval 2) all corroborate the claims made in the PR description. No blocking issues found.🟡 Suggestions
ansible/roles/keepalived-ntp/templates/check-ntp-health.sh.j2:60-65— Check 4 silently no-ops ifOFFSETfails to parse (empty string, e.g. from an unexpectedchronycoutput format change). Contrast with Check 5, which fails hard when its own data is ambiguous ("no upstream yet" is explicitly treated as unsafe). For consistency with that same-PR philosophy, consider also failing whenOFFSETcan't be extracted, rather than treating unparseable output as "offset is fine." Low risk today since the format was validated against a live host, but it's a silent gap ifchronyc's output ever changes on an OS upgrade.Tests
No automated tests exist for this change, and none are added. That's consistent with the rest of the repo —
.woodpecker/ci-manifests-test.yml'sshellcheckstep is explicitly scoped tokubernetes/**only (see its own comment noting the gap for "shell embedded in manifests"/other trees), and there's noansible-lint/molecule pipeline coveringansible/roles/**at all. So this isn't a regression in this PR's testing posture, just a pre-existing gap for the whole ansible tree. The PR body does describe manual verification of both new predicates against realchronycoutput (the incident's actual offset value, a healthy 0.0 value, and a livechronyc sourcesrun on edge-1), which is reasonable given no CI harness exists to automate it.Recommendation
Looks reasonable — no blocking issues; comment above is optional.