Skip to content

Commit 1d4e428

Browse files
fix(agent): keep acknowledging once converged, so a lost report self-heals (v1.10.14)
The deploy report is the server's only evidence that a member node applied its keepalived.conf, and it was sent on the write path alone. Once the rendered config was on disk the agent took the idempotency early return every cycle and never reported again, so a single lost report - a backend restart, a 5xx, a network blip - left the VIP reading SYNCING with an empty "Last ack" forever while the node was demonstrably running the right config. Nothing would ever reconcile the two; the only escape was to change the rendered config so the agent wrote it again, which means touching a live VIP to fix a display problem. The agent now re-asserts its state on that path too: one request per node per poll cycle (~2.5 min), nothing written, keepalived not reloaded. This gap dates from the original HA/VIP work rather than this release series; it only became visible when acknowledgements were dropped for an unrelated reason. A test pins that both daemon copies report BEFORE the early return, since placing it after would silently restore the old behaviour. Verified end to end on a real HA pair: discovery, instance-based adoption of both nodes, PENDING, Apply, agent pull, the validation gate, the hash-pinned takeover, the acknowledgement, and retirement of the one-shot authorisation.
1 parent 0eb587d commit 1d4e428

6 files changed

Lines changed: 62 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2474,6 +2474,7 @@ Developed with ❤️ for the HAProxy community
24742474

24752475
## Release Notes
24762476

2477+
- **v1.10.14** (2026-08-14) — **A converged node keeps acknowledging**: the deploy report is the server's only evidence that a member node applied its `keepalived.conf`, and it was sent on the write path alone. Once the rendered config was on disk the agent took the idempotency early return on every cycle and never reported again, so a **single lost report** — a backend restart, a 5xx, a network blip — left the VIP reading `SYNCING (0/n)` with an empty *Last ack* forever, while the node was demonstrably running the right config. Nothing would ever reconcile the two: the node was correct, the page was not, and the only way out was to change the rendered config so the agent wrote it again. The agent now re-asserts its state on the idempotent path too, which costs one request per node per ~2.5 minutes and touches nothing on the node — keepalived is not reloaded and the file is not rewritten. This is a long-standing gap from the original HA/VIP work, surfaced when acknowledgements were dropped for an unrelated reason in v1.10.12. Agent-script change: sync the script from Agent Management and let the agents upgrade. No schema or API change.
24772478
- **v1.10.13** (2026-08-14) — **Agent deploy acknowledgements were silently dropped** (regression in v1.10.12, fix it before or with that release): the takeover-retirement clause added to `POST /agents/{name}/keepalived-status` in v1.10.12 reused one query placeholder for both the assignment `last_deploy_hash=$n` and the comparison inside its `CASE`. PostgreSQL deduces a type per **use**, so the same placeholder came out as `text` in one and `character varying` in the other, and asyncpg rejected the statement with `AmbiguousParameterError`. The failure was not partial: the whole UPDATE never ran, so **no member ever recorded an acknowledgement**. Every VIP sat at `SYNCING (0/n)` with an empty *Last ack*, even after the nodes had deployed the config successfully, and teardown acknowledgements were lost the same way. The hash is now bound to its own placeholder, which is only ever compared against the column and therefore unambiguous. Verified against a real PostgreSQL: both statements execute, a matching hash retires the takeover authorisation, a non-matching hash and a NULL `applied_config_hash` both leave it in place, and every case records the acknowledgement. A test now asserts every `$n` in these statements is bound exactly once and that the count matches the arguments passed. Backend only: no schema, agent or API-shape change.
24782479
- **v1.10.12** (2026-08-14) — **A valid keepalived config is no longer rejected by its own warning**: before writing a rendered `keepalived.conf` the agent validates it with `keepalived -t` and, on failure, keeps the running config and does not restart keepalived. That fail-safe is right, but it treated **any** non-zero exit as invalid, and keepalived's config-test exit code does not separate fatal from benign. Measured on 2.2.8: a clean config exits 0, but `Truncating auth_pass to 8 characters` exits **5** and so does a missing `}` or an `Unknown keyword`. A VRRP password longer than eight characters was therefore enough to make every apply fail, including on nodes whose own running config produces the same warning and has been serving the VIP for weeks. The gate now judges the **output**: messages known to be benign are dropped and anything that remains still fails, so it fails **closed** and an unrecognised message is treated as fatal. Verified against real keepalived: a truncation warning passes while a missing brace, an unknown keyword and a `SECURITY VIOLATION` are all still refused. The agent also **reports what keepalived said** now, in the log and in the status the HA/VIP page shows; discarding it left a correct refusal with no way to act on it. Agent-script change: sync the script from Agent Management and let the agents upgrade for it to take effect. No schema or API change.
24792480
- **v1.10.11** (2026-08-14) — **The *Adoptable* tag names the problem that actually blocks adoption**: the tag and the disabled *Adopt* button were computed separately and could disagree. A pair blocked because its peer's `keepalived.conf` could not be parsed was labelled **MASTER missing** — technically true, since the unreadable node's `state MASTER` had not been counted, but it pointed the operator at the wrong node while the real reason sat in the button's own tooltip. Both now come from one ordered decision, so the label, its colour and the tooltip always describe the condition that stops the adoption; a group held up by an unreadable or unreachable peer reads **blocked by peer**, and two MASTERs is now distinct from none. Display only: what the endpoint accepts or refuses is unchanged. On the public repo this is the first artifact carrying v1.10.4 through v1.10.10: none was released separately, because VIP adoption did not work end to end until these fixes landed.

UPGRADE_GUIDE.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
# Upgrade Notes — v1.10.14 (a converged node keeps acknowledging)
2+
3+
**Agent-script change, no schema change.** No `SCHEMA_VERSION` bump. After deploying, sync the
4+
Linux agent script from **Agent Management** and let the agents upgrade, or the fix does not
5+
reach the nodes.
6+
7+
- **Symptom:** a VIP shows `SYNCING (0/n)` with an empty *Last ack* even though every member node
8+
has the rendered `keepalived.conf` on disk, keepalived is running and the VIP is held.
9+
- **Cause:** the deploy report was sent only when the agent actually wrote the config. Once the
10+
node matched, it took the idempotency early return every cycle and never reported again, so any
11+
report lost in transit was never retried and the server's view stayed stale permanently.
12+
- **Fix:** the agent re-asserts its state on the idempotent path as well. One request per node
13+
per poll cycle (~2.5 min); nothing is written and keepalived is not reloaded.
14+
- **Recovery is automatic.** A VIP stuck at SYNCING converges on the first poll after the agents
15+
pick up the new script. No action on the nodes, no re-apply, no edit to force a rewrite.
16+
- **This is not new in 1.10.12.** The gap dates from the original HA/VIP work; it only became
17+
visible when acknowledgements were dropped for an unrelated reason.
18+
19+
**Rollback:** safe. Reverting restores the previous behaviour, in which a lost acknowledgement is
20+
never recovered.
21+
22+
---
23+
124
# Upgrade Notes — v1.10.13 (deploy acknowledgements were dropped)
225

326
**Backend only, no schema change.** No `SCHEMA_VERSION` bump, no agent change. If you deployed

backend/tests/test_vip_adoption_instance.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,31 @@ def test_validation_failure_reports_what_keepalived_said():
335335
)
336336

337337

338+
def test_converged_node_keeps_acknowledging():
339+
"""The idempotent path must still report, or a lost ack is never recovered.
340+
341+
The status report is the server's ONLY evidence that a member converged, and it used to be
342+
sent solely on the write path. Once the rendered config was on disk the agent took the
343+
idempotency early return every cycle and never spoke again, so a single lost report - a
344+
backend restart, a 5xx, a network blip - left the VIP reading SYNCING forever with an empty
345+
"Last ack" while the node was demonstrably running the right config. Seen in the field after
346+
acks were dropped for an unrelated reason: the node was correct, the page was not, and
347+
nothing would ever reconcile them.
348+
"""
349+
script = (BACKEND / "utils" / "agent_scripts" / "linux_install.sh").read_text()
350+
assert script.count('_kp_report "enabled" "$vip_id" "$new_hash" "already converged"') == 2, (
351+
"both daemon copies must re-assert the deploy state on the idempotent path; without it "
352+
"the server can never recover a lost acknowledgement"
353+
)
354+
# The report has to come BEFORE the early return in both copies.
355+
for m in re.finditer(r'if \[\[ -n "\$cur_hash" && "\$cur_hash" == "\$would_hash" \]\]; then(.*?)fi',
356+
script, re.S):
357+
body = m.group(1)
358+
assert body.index("_kp_report") < body.index("return 0"), (
359+
"the acknowledgement must be sent before returning, or the early return skips it"
360+
)
361+
362+
338363
def test_status_ack_statements_bind_each_placeholder_once():
339364
"""Every `$n` in the keepalived-status UPDATEs must be used exactly once, and the count must
340365
match the arguments passed.

backend/utils/agent_scripts/linux_install.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1865,6 +1865,13 @@ fetch_and_deploy_keepalived_config() {
18651865
cur_hash=$(md5sum "$conf" 2>/dev/null | awk '{print $1}')
18661866
would_hash=$(printf '%s' "$new_conf" | md5sum 2>/dev/null | awk '{print $1}')
18671867
if [[ -n "$cur_hash" && "$cur_hash" == "$would_hash" ]]; then
1868+
# STILL ACK. This report is the server's only evidence that the node converged, and
1869+
# it used to be sent on the write path alone — so a single lost ack (a backend
1870+
# restart, a 5xx, a network blip) left the VIP reading SYNCING forever: the node was
1871+
# already correct on disk, took this early return every cycle, and never spoke again.
1872+
# Re-asserting the state makes the loop self-healing, costs one request per ~2.5
1873+
# minutes, and touches nothing on the node.
1874+
_kp_report "enabled" "$vip_id" "$new_hash" "already converged"
18681875
return 0
18691876
fi
18701877
fi
@@ -3495,6 +3502,9 @@ CONFIG_RESPONSE_EOF
34953502
cur_hash=$(md5sum "$conf" 2>/dev/null | awk '{print $1}')
34963503
would_hash=$(printf '%s' "$new_conf" | md5sum 2>/dev/null | awk '{print $1}')
34973504
if [[ -n "$cur_hash" && "$cur_hash" == "$would_hash" ]]; then
3505+
# See the heredoc copy: the ack must be re-asserted here or a single lost report
3506+
# leaves the VIP reading SYNCING forever.
3507+
_kp_report "enabled" "$vip_id" "$new_hash" "already converged"
34983508
return 0
34993509
fi
35003510
fi

backend/version.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "1.10.13",
3-
"releaseName": "Agent deploy acknowledgements were silently dropped",
2+
"version": "1.10.14",
3+
"releaseName": "A converged node keeps acknowledging, so a lost report self-heals",
44
"releaseDate": "2026-08-14"
55
}

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "haproxy-openmanager-frontend",
3-
"version": "1.10.13",
3+
"version": "1.10.14",
44
"description": "HAProxy Load Balancer Management UI",
55
"license": "AGPL-3.0-or-later",
66
"dependencies": {

0 commit comments

Comments
 (0)