mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-11 21:38:55 +00:00
fix(vip): adopt the whole VRRP instance, not one node (v1.10.8)
Four defects found while tracing the adoption flow end to end after v1.10.4
reached a live HA pair.
B3/B4 (one root, one fix). Adoption took only the node whose row was clicked:
- adopting the BACKUP alone produced a VIP that apply always rejects, because
apply requires exactly one MASTER member;
- adopting the MASTER alone left the peer unmanaged, and adopting it
afterwards hit the VRID-collision guard with 409, so a pair could never be
completed from the panel;
- on a UNICAST instance the single-member render dropped the unicast block
entirely (render_keepalived_conf emits it only when peer_ips is non-empty),
so keepalived fell back to multicast on the adopted node while its peer
stayed unicast. They stop seeing each other and BOTH claim the VIP.
Adoption now resolves the whole instance via _collect_instance_participants,
keyed on (virtual_router_id, virtual address) - the same key keepalived uses to
group nodes. Each participant becomes a member with the role, priority and
interface its own file declares, and its own one-shot takeover hash, so the
per-node overwrite guard is unchanged. It refuses, naming the reason, when the
group has other than one MASTER, when advert_int differs across nodes, when a
declared unicast peer is not among the nodes being adopted, or when a node is
already in a live VIP - the one-active-VIP-per-agent rule that create/update
enforce via _validate_members_against_pool and adoption never called.
B1. The Apply Management "View Change" regex matched vip-(create|update|delete)
only, so an adopt version fell through to the generic HAProxy diff and rendered
the cluster's whole haproxy.cfg as removed. Display-only, but alarming. A test
now asserts every action _stage_vip_version can stage is in that alternation.
B2. Rejecting an adoption hid the node from the panel permanently:
vip_discoveries.adopted_vip_id is write-once, a VIP is only ever soft-deleted so
the column's ON DELETE SET NULL never fires, and the agent does not re-report a
file whose hash has not changed. Rather than clearing the column on each path,
adoptability is derived from whether the linked VIP is still active, which
self-heals reject, undo-reject and approved teardown alike.
The panel now lists one row per instance instead of per node, and the adopt
dialog names every node that will be taken over. Blockers are aggregated across
all of them, matching what the endpoint checks.
No schema change, no agent change, no API-shape break: /api/vip/discoveries
gains a derived field and /api/vip/adopt keeps its request body.
Backend suite: 1359 passed, 152 skipped. Frontend build clean (no new lint
warnings in VIPManagement.js).
This commit is contained in:
@@ -2474,7 +2474,8 @@ Developed with ❤️ for the HAProxy community
|
||||
|
||||
## Release Notes
|
||||
|
||||
- **v1.10.7** (2026-08-13) — **HA / VIP follows the selected cluster**: the page ignored the cluster picker in the header. On a multi-cluster install both the VIP table and the new *Unmanaged keepalived detected* panel listed every cluster's nodes at once and did not change when the selection did, so the panel appeared to be stuck on one cluster's keepalived. Both lists now send `cluster_id`, resolved to that cluster's pool exactly as the Apply Management view already did. The API parameter is **optional**: a caller that omits it still receives the whole fleet, so nothing outside the page changes. This is a deliberate behaviour change for the VIP table, which was fleet-wide before. Backend and frontend only: no schema, no agent change. On the public repo this is the first artifact carrying v1.10.4, v1.10.5 and v1.10.6: none was released separately, because the adoption panel could not work until these fixes landed.
|
||||
- **v1.10.8** (2026-08-13) — **VIP adoption takes the whole VRRP instance**: adoption used to take only the node whose row was clicked, which broke the exact case the feature exists for, a running HA pair. Adopting the **BACKUP** alone produced a VIP that could never be applied (*exactly one member must be MASTER*); adopting the **MASTER** alone left the peer unmanaged, and adopting it afterwards hit the VRID-collision guard with 409, so the pair could not be completed from the panel at all. Most serious, on a **unicast** instance the single-member render dropped the unicast block entirely — the renderer emits it only when it has peer addresses — so keepalived fell back to **multicast** on the adopted node while its peer stayed unicast: they stop seeing each other and **both** claim the VIP. Adoption now resolves the whole instance, keyed on `(virtual_router_id, virtual address)` exactly as keepalived groups nodes, and every participating node becomes a member with the role, priority and interface its own file declares and its own one-shot takeover hash. It refuses, with the reason, when the group does not have exactly one MASTER, when the nodes disagree on `advert_int`, when a declared unicast peer is not among the nodes being adopted, or when a node already belongs to a live VIP — a rule create/edit enforced and adoption did not. The panel now lists one row per **instance** instead of per node. Two further fixes: the Apply Management **View Change** diff did not recognise the `adopt` action, so it fell through to the generic HAProxy diff and rendered the cluster's entire `haproxy.cfg` as removed; and **rejecting** an adoption hid the node from the panel permanently, because `adopted_vip_id` is write-once, a VIP is only ever soft-deleted (so the column's `ON DELETE SET NULL` never fires) and the agent does not re-report an unchanged file — adoptability is now derived from whether the linked VIP is still active, which self-heals reject, undo-reject and approved teardown alike. No schema change, no agent change. On the public repo this is the first artifact carrying v1.10.4 through v1.10.7: none was released separately, because VIP adoption did not work end to end until these fixes landed.
|
||||
- **v1.10.7** (2026-08-13) — **HA / VIP follows the selected cluster**: the page ignored the cluster picker in the header. On a multi-cluster install both the VIP table and the new *Unmanaged keepalived detected* panel listed every cluster's nodes at once and did not change when the selection did, so the panel appeared to be stuck on one cluster's keepalived. Both lists now send `cluster_id`, resolved to that cluster's pool exactly as the Apply Management view already did. The API parameter is **optional**: a caller that omits it still receives the whole fleet, so nothing outside the page changes. This is a deliberate behaviour change for the VIP table, which was fleet-wide before. Backend and frontend only: no schema, no agent change.
|
||||
- **v1.10.6** (2026-08-13) — **VIP adoption panel was unreachable**: v1.10.4's *Unmanaged keepalived detected* panel never appeared, even on a fleet where the agents had reported their configs correctly. `GET /discoveries` was declared **after** `GET /{vip_id}` in `routers/vip.py`, and FastAPI matches routes in declaration order, so every request for the discovery list was answered by the get-one-VIP handler, which takes `vip_id: int` and rejected `"discoveries"` with **422** before the real handler ran. Nothing surfaced the failure: the agents reported normally, the rows landed in `vip_discoveries`, and the HA/VIP page treats any non-OK response as "nothing to show" — so the whole feature was invisible with no error anywhere. The route is moved above the parameterised ones, and a static source scan now asserts that **no** literal path in **any** router is shadowed by an earlier parameterised one, so the class of bug cannot come back silently. Data reported under v1.10.4 is not lost: existing `vip_discoveries` rows appear as soon as the fixed backend is deployed, with no agent action needed. Backend-only fix. No schema, API-shape or agent change.
|
||||
- **v1.10.5** (2026-08-09) — **HTTP-01 challenge backend on split deployments**: on a deployment where the HAProxy nodes and the management stack are on different hosts, HTTP-01 issuance could fail silently for weeks while DNS-01 kept working — the rendered config pointed `server _acme_mgmt` at an address that resolves **on the HAProxy node**, defaulting to loopback, and every diagnostic still reported success. The per-cluster `acme_backend_url` now has a UI field, changing it actually mints a config version, and the value is validated where it is written. Three adjacent bugs are fixed with it: a config-generation failure was returned as `# Error ...` text and then stored as an APPLIED version and pushed to agents as the cluster's whole `haproxy.cfg` (both call sites now refuse with 422); a nullable `frontends.mode` was interpolated raw and emitted `mode None`, which HAProxy rejects and which takes down the entire cluster config; and cluster creation silently dropped the ACME fields. `docker-compose.yml` now interpolates `PUBLIC_URL` / `MANAGEMENT_BASE_URL` instead of hardcoding them, with the old literals as defaults. Diagnostics read the response body so an SPA answering 200 is no longer counted as healthy, and every new condition is a warning rather than a failure so no install is locked on upgrade. No schema, API-shape or agent change.
|
||||
- **v1.10.4** (2026-08-08) — **Adopt an existing keepalived VIP** (Issue #27 follow-up): on a fleet that already runs keepalived, the **HA / VIP** page came up empty, because the flow was one-way — VIPs were declared in OpenManager and pushed to the node, and nothing ever read what was already there. Agents now **report the `keepalived.conf` they find and do not own** (strictly read-only; the node is never touched), the page lists those nodes under *Unmanaged keepalived detected*, and **Adopt** turns one `vrrp_instance` into a managed VIP with the values from the file instead of retyping them. The heartbeat could not drive this: it carries the VIP address and a best-effort MASTER/BACKUP, while rendering a node's config needs **eleven** fields, and guessing them is not cosmetic — a wrong `virtual_router_id` puts the nodes in separate VRRP domains and a wrong `auth_pass` makes them reject each other, so both would claim the VIP. Because adoption **replaces** the operator's file with OpenManager's render, the parser reports every directive it cannot reproduce — a `notify_master` hook, an LVS `virtual_server` section, a `vrrp_sync_group`, a second address in one instance, a custom `track_script` — and **refuses** while any remain; the operator can waive that class explicitly, but a value that is simply *unknown* (an absent VRID or prefix length) can never be waived, only supplied. keepalived's own documented defaults (`state BACKUP`, `priority 100`, `advert_int 1`) are applied and shown as assumed. The agent's ownership guard is **not** weakened: adoption authorises exactly **one** takeover of exactly the file that was analysed, pinned to its hash, so a config edited between adoption and Apply is still refused. The adopted VIP is created **PENDING** like any other, so nothing reaches the node until it is applied from Apply Management. VRRP passwords are Fernet-encrypted at ingest and masked in the stored copy and the preview. Schema change: one new table `vip_discoveries` plus two additive columns (SCHEMA_VERSION 10 → 11, auto-migrated, no existing table altered) — **see the upgrade notes: this bump re-seeds the four built-in roles, and the Linux agent script must reach the nodes before discovery starts**.
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
# Upgrade Notes — v1.10.8 (VIP adoption takes the whole VRRP instance)
|
||||
|
||||
**Backend + frontend, no schema change.** No `SCHEMA_VERSION` bump, so the built-in roles are
|
||||
**not** re-seeded. No agent impact: nothing about what the agent reports or how it takes a
|
||||
config over changes.
|
||||
|
||||
- **Adoption is now per VRRP instance, not per node.** Every node in the pool reporting the same
|
||||
`virtual_router_id` and virtual address becomes a member of one VIP, each with the role,
|
||||
priority and interface its own `keepalived.conf` declares, and each with its own one-shot
|
||||
takeover hash. The panel lists one row per instance.
|
||||
- **Why this mattered:** single-node adoption could not produce a working pair. The BACKUP alone
|
||||
failed apply, the MASTER alone left the peer unmanaged and the peer could not then be adopted
|
||||
(VRID collision). On a **unicast** instance it was worse than inconvenient: the render drops
|
||||
the unicast block when there are no peers, so the adopted node fell back to multicast while its
|
||||
peer stayed unicast and both could hold the address.
|
||||
- **New refusals, each with the reason in the message:** the group does not have exactly one
|
||||
MASTER; the nodes disagree on `advert_int`; a declared unicast peer is not among the nodes being
|
||||
adopted; a node is already a member of a live VIP.
|
||||
- **Apply Management "View Change" now renders the adopt diff correctly.** It did not recognise
|
||||
the `adopt` action and fell through to the generic HAProxy diff, which compared the staged
|
||||
`keepalived.conf` against the cluster's previous `haproxy.cfg` and showed the whole HAProxy
|
||||
config as removed. Alarming, but display-only — nothing was ever applied from that view.
|
||||
- **Rejecting an adoption is recoverable again.** It used to hide the node from the panel
|
||||
permanently. Nothing clears `vip_discoveries.adopted_vip_id`, a VIP is only soft-deleted so the
|
||||
column's `ON DELETE SET NULL` never fires, and the agent does not re-report a file whose hash
|
||||
has not changed. Adoptability is now derived from whether the linked VIP is still active.
|
||||
- **If you adopted a VIP on 1.10.4-1.10.7**, check it before applying: it may have only one
|
||||
member. Add the peer from the VIP's edit form, or reject the pending adoption and adopt again —
|
||||
the node reappears in the panel under this release.
|
||||
|
||||
**Rollback:** safe. No schema or data change; reverting restores the previous single-node
|
||||
adoption behaviour.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Notes — v1.10.7 (HA / VIP follows the selected cluster)
|
||||
|
||||
**Backend + frontend, no schema change.** No `SCHEMA_VERSION` bump, so the built-in roles are
|
||||
|
||||
@@ -2704,7 +2704,11 @@ async def get_config_version_diff(cluster_id: int, version_id: int, authorizatio
|
||||
# HA/VIP (Issue #27): vip-{id}-{action} versions show the generated keepalived.conf
|
||||
# each member node will deploy as the change content (VRRP secret masked). Mirrors
|
||||
# the ssl-* special case above so VIP uses the STANDARD View Change diff modal.
|
||||
vip_match = re.search(r'vip-(\d+)-(create|update|delete)', current_version['version_name'])
|
||||
# `adopt` MUST stay in this alternation. A vip-* action missing here does not degrade
|
||||
# gracefully: the version falls through to the generic HAProxy diff, which compares this
|
||||
# row's keepalived.conf against the cluster's previous haproxy.cfg and shows the whole
|
||||
# HAProxy config as removed. v1.10.4 added `adopt` without it (fixed in v1.10.8).
|
||||
vip_match = re.search(r'vip-(\d+)-(create|update|delete|adopt)', current_version['version_name'])
|
||||
if vip_match:
|
||||
vip_id = int(vip_match.group(1))
|
||||
vip_action = vip_match.group(2)
|
||||
|
||||
+178
-36
@@ -480,10 +480,12 @@ async def list_vip_discoveries(cluster_id: Optional[int] = None, authorization:
|
||||
try:
|
||||
try:
|
||||
rows = await conn.fetch("""
|
||||
SELECT d.*, a.name AS agent_name, a.pool_id, p.name AS pool_name
|
||||
SELECT d.*, a.name AS agent_name, a.pool_id, p.name AS pool_name,
|
||||
av.is_active AS adopted_vip_active
|
||||
FROM vip_discoveries d
|
||||
JOIN agents a ON a.id = d.agent_id
|
||||
LEFT JOIN haproxy_cluster_pools p ON p.id = a.pool_id
|
||||
LEFT JOIN vip_instances av ON av.id = d.adopted_vip_id
|
||||
WHERE $1::int IS NULL
|
||||
OR a.pool_id = (SELECT pool_id FROM haproxy_clusters WHERE id = $1::int)
|
||||
ORDER BY d.reported_at DESC, d.id DESC
|
||||
@@ -1057,6 +1059,14 @@ def _discovery_row_to_api(row) -> dict:
|
||||
"is_managed": row["is_managed"],
|
||||
"parse_error": row["parse_error"],
|
||||
"adopted_vip_id": row["adopted_vip_id"],
|
||||
# v1.10.8 — adoptability is derived from whether the linked VIP is STILL active, not
|
||||
# from the link existing. `adopted_vip_id` is write-once and nothing clears it, and a
|
||||
# VIP is only ever soft-deleted (is_active=FALSE), so the column's ON DELETE SET NULL
|
||||
# never fires. Keying the UI on the link alone made a rejected adoption hide the node
|
||||
# from the panel forever: the VIP was gone from the VIP list too, and the agent does not
|
||||
# re-report an unchanged file. Deriving it here self-heals reject, undo-reject, approved
|
||||
# teardown and anything added later, without a write on each path.
|
||||
"adopted_vip_active": bool(row["adopted_vip_active"]) if row["adopted_vip_id"] else False,
|
||||
"reported_at": row["reported_at"].isoformat() if row["reported_at"] else None,
|
||||
"config_preview": row["raw_config_masked"],
|
||||
"analysis": analysis,
|
||||
@@ -1074,6 +1084,59 @@ def _find_candidate(analysis: Optional[dict], instance_name: str) -> Optional[di
|
||||
return None
|
||||
|
||||
|
||||
async def _collect_instance_participants(conn, *, pool_id: int, vrid: int, virtual_ip: str):
|
||||
"""Every discovered node in the pool that reports the SAME vrrp_instance.
|
||||
|
||||
v1.10.8. Adoption used to take only the node whose row was clicked, which broke the exact
|
||||
case the feature exists for — a running HA pair:
|
||||
|
||||
* adopting the BACKUP alone produced a VIP whose apply fails outright, because apply
|
||||
requires exactly one MASTER member;
|
||||
* adopting the MASTER alone left the peer unmanaged, and adopting it afterwards hit the
|
||||
VRID-collision guard with a 409, so the pair could never be completed from the panel;
|
||||
* worst, on a UNICAST pair the single-member render silently drops the unicast block —
|
||||
render_keepalived_conf only emits it when peer_ips is non-empty — so keepalived falls
|
||||
back to multicast on the adopted node while its peer stays unicast. They stop seeing
|
||||
each other and BOTH claim the VIP.
|
||||
|
||||
Identity is (virtual_router_id, virtual address), which is what keepalived itself uses to
|
||||
decide two nodes belong to one VRRP group, so it is the correct key. A node whose config
|
||||
failed to parse cannot be matched and is therefore skipped — the unicast peer check in the
|
||||
caller is what stops that turning into a silent half-adoption.
|
||||
"""
|
||||
rows = await conn.fetch("""
|
||||
SELECT d.agent_id, d.config_hash, d.analysis, d.parse_error, d.adopted_vip_id,
|
||||
a.name AS agent_name, a.ip_address, av.is_active AS adopted_vip_active
|
||||
FROM vip_discoveries d
|
||||
JOIN agents a ON a.id = d.agent_id
|
||||
LEFT JOIN vip_instances av ON av.id = d.adopted_vip_id
|
||||
WHERE a.pool_id = $1 AND COALESCE(a.enabled, TRUE) = TRUE
|
||||
""", pool_id)
|
||||
|
||||
participants = []
|
||||
for r in rows:
|
||||
if r["parse_error"]:
|
||||
continue
|
||||
if r["adopted_vip_id"] and r["adopted_vip_active"]:
|
||||
continue # already under management by a VIP that still stands
|
||||
analysis = r["analysis"]
|
||||
if isinstance(analysis, str):
|
||||
try:
|
||||
analysis = json.loads(analysis)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for cand in ((analysis or {}).get("candidates") or []):
|
||||
v = cand.get("vip") or {}
|
||||
if v.get("virtual_router_id") == vrid and v.get("virtual_ip") == virtual_ip:
|
||||
participants.append({
|
||||
"agent_id": r["agent_id"], "agent_name": r["agent_name"],
|
||||
"ip_address": r["ip_address"], "config_hash": r["config_hash"],
|
||||
"candidate": cand,
|
||||
})
|
||||
break
|
||||
return participants
|
||||
|
||||
|
||||
@router.post("/adopt")
|
||||
async def adopt_vip(payload: dict, request: Request, authorization: str = Header(None)):
|
||||
"""Turn one discovered vrrp_instance into a managed VIP.
|
||||
@@ -1102,13 +1165,19 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
conn = await get_database_connection()
|
||||
try:
|
||||
disc = await conn.fetchrow("""
|
||||
SELECT d.*, a.name AS agent_name, a.pool_id
|
||||
FROM vip_discoveries d JOIN agents a ON a.id = d.agent_id
|
||||
SELECT d.*, a.name AS agent_name, a.pool_id, a.ip_address,
|
||||
av.is_active AS adopted_vip_active
|
||||
FROM vip_discoveries d
|
||||
JOIN agents a ON a.id = d.agent_id
|
||||
LEFT JOIN vip_instances av ON av.id = d.adopted_vip_id
|
||||
WHERE d.agent_id = $1
|
||||
""", int(agent_id))
|
||||
if not disc:
|
||||
raise HTTPException(status_code=404, detail="No discovered keepalived config for that agent")
|
||||
if disc["adopted_vip_id"]:
|
||||
# Only an adoption that is still STANDING blocks a new one. A rejected adoption leaves
|
||||
# adopted_vip_id pointing at a soft-deleted VIP, and nothing clears it, so keying on the
|
||||
# link alone made the node permanently unadoptable (v1.10.8).
|
||||
if disc["adopted_vip_id"] and disc["adopted_vip_active"]:
|
||||
raise HTTPException(status_code=409, detail="This discovery has already been adopted")
|
||||
if not disc["pool_id"]:
|
||||
raise HTTPException(status_code=400, detail="The agent is not in a pool; assign it first")
|
||||
@@ -1135,20 +1204,7 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="prefix_length must be an integer")
|
||||
|
||||
# One source of truth for which blockers an operator may resolve (see the docstring on
|
||||
# remaining_blockers): a supplied prefix, and an explicit acceptance of directives our
|
||||
# renderer would delete. Nothing else is waivable.
|
||||
from services.keepalived_parser import remaining_blockers
|
||||
blockers = remaining_blockers(
|
||||
list(cand.get("blockers") or []),
|
||||
prefix_supplied=vip_fields.get("prefix_length") is not None,
|
||||
accept_data_loss=bool(payload.get("accept_data_loss")),
|
||||
)
|
||||
if blockers:
|
||||
raise HTTPException(status_code=422, detail={
|
||||
"message": "This keepalived config cannot be adopted as-is",
|
||||
"blockers": blockers,
|
||||
})
|
||||
|
||||
vrid = vip_fields.get("virtual_router_id")
|
||||
virtual_ip = vip_fields.get("virtual_ip")
|
||||
@@ -1156,6 +1212,88 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
raise HTTPException(status_code=422,
|
||||
detail="Incomplete candidate (vrid/address/interface)")
|
||||
|
||||
# v1.10.8 — adopt the whole VRRP instance. Every node in the pool reporting this same
|
||||
# VRID + address becomes a member, each with the role, priority and interface ITS OWN
|
||||
# file declares. See _collect_instance_participants for why single-node adoption was
|
||||
# unsafe on a unicast pair.
|
||||
participants = await _collect_instance_participants(
|
||||
conn, pool_id=disc["pool_id"], vrid=int(vrid), virtual_ip=virtual_ip)
|
||||
if not any(p["agent_id"] == int(agent_id) for p in participants):
|
||||
# The reporting node must be in its own instance; if it is not, something changed
|
||||
# underneath us (re-report, concurrent adoption) — refuse rather than guess.
|
||||
raise HTTPException(status_code=409,
|
||||
detail="The discovery changed while adopting; refresh and try again")
|
||||
|
||||
# One source of truth for which blockers an operator may resolve (see the docstring on
|
||||
# remaining_blockers): a supplied prefix, and an explicit acceptance of directives our
|
||||
# renderer would delete. Nothing else is waivable. Checked for EVERY node we are about
|
||||
# to overwrite, not only the one that was clicked.
|
||||
for p in participants:
|
||||
rem = remaining_blockers(
|
||||
list(p["candidate"].get("blockers") or []),
|
||||
prefix_supplied=vip_fields.get("prefix_length") is not None,
|
||||
accept_data_loss=bool(payload.get("accept_data_loss")),
|
||||
)
|
||||
if rem:
|
||||
raise HTTPException(status_code=422, detail={
|
||||
"message": f"This keepalived config cannot be adopted as-is ({p['agent_name']})",
|
||||
"node": p["agent_name"],
|
||||
"blockers": rem,
|
||||
})
|
||||
if not (p["candidate"].get("member") or {}).get("network_interface"):
|
||||
raise HTTPException(status_code=422,
|
||||
detail=f"{p['agent_name']} does not declare an interface for this instance")
|
||||
|
||||
# keepalived requires one MASTER and a matching advertisement interval across the group.
|
||||
# Both are checked here so the operator learns at adoption time instead of at apply.
|
||||
roles = [(p["candidate"].get("member") or {}).get("role") or "BACKUP" for p in participants]
|
||||
if roles.count("MASTER") != 1:
|
||||
listed = ", ".join(f"{p['agent_name']}={r}" for p, r in zip(participants, roles))
|
||||
raise HTTPException(status_code=422, detail=(
|
||||
f"VRID {vrid} is reported by {len(participants)} node(s) with "
|
||||
f"{roles.count('MASTER')} MASTER ({listed}); exactly one must be MASTER. If a "
|
||||
f"member is missing, install or enable its agent so it reports its keepalived.conf, "
|
||||
f"then adopt again."))
|
||||
adverts = {int((p["candidate"].get("vip") or {}).get("advert_int") or 1) for p in participants}
|
||||
if len(adverts) > 1:
|
||||
raise HTTPException(status_code=422, detail=(
|
||||
f"The nodes disagree on advert_int ({sorted(adverts)}). keepalived needs the same "
|
||||
f"advertisement interval across a VRRP group, so align the files first."))
|
||||
|
||||
# UNICAST SAFETY. Our renderer emits the unicast block only when it has peer addresses,
|
||||
# so a peer that is not a member would be dropped and keepalived would fall back to
|
||||
# multicast on this node while the real peer stays unicast — both would then claim the
|
||||
# VIP. Refuse instead, naming the address that is unaccounted for.
|
||||
declared_peers = {str(x) for p in participants for x in (p["candidate"].get("peers") or [])}
|
||||
if declared_peers:
|
||||
missing_ip = [p["agent_name"] for p in participants if not p["ip_address"]]
|
||||
if missing_ip:
|
||||
raise HTTPException(status_code=422, detail=(
|
||||
f"These nodes have not reported an IP address yet: {', '.join(missing_ip)}. "
|
||||
f"The unicast peer list cannot be verified until they do."))
|
||||
member_ips = {str(p["ip_address"]) for p in participants}
|
||||
unknown = sorted(declared_peers - member_ips)
|
||||
if unknown:
|
||||
raise HTTPException(status_code=422, detail=(
|
||||
f"This instance is unicast and lists peer(s) {', '.join(unknown)} that are not "
|
||||
f"among the nodes being adopted. Adopting would drop them from the peer list and "
|
||||
f"keepalived would silently fall back to multicast, so both sides could end up "
|
||||
f"holding the VIP. Register those nodes as agents so they report their config, "
|
||||
f"then adopt again."))
|
||||
|
||||
# One active VIP per agent — the delivery endpoint serves a single keepalived.conf per
|
||||
# node, so a second active membership never converges. create/update enforce this via
|
||||
# _validate_members_against_pool; adoption did not call it at all.
|
||||
busy = await conn.fetchrow("""
|
||||
SELECT a.name AS agent_name, v.name AS vip_name
|
||||
FROM vip_members vm JOIN vip_instances v ON v.id = vm.vip_id
|
||||
JOIN agents a ON a.id = vm.agent_id
|
||||
WHERE vm.agent_id = ANY($1::int[]) AND v.is_active = TRUE LIMIT 1
|
||||
""", [p["agent_id"] for p in participants])
|
||||
if busy:
|
||||
raise HTTPException(status_code=409, detail=(
|
||||
f"node '{busy['agent_name']}' is already a member of VIP '{busy['vip_name']}'"))
|
||||
|
||||
# Adoption must keep the VRRP identity it found. A different VRID would create a second
|
||||
# VRRP domain on the wire, so a collision inside the pool is a hard conflict, never an
|
||||
# auto-reallocation the way create_vip does it.
|
||||
@@ -1182,18 +1320,22 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
disc["auth_pass_encrypted"], # already Fernet-encrypted at ingest
|
||||
bool(vip_fields.get("use_unicast")), bool(vip_fields.get("track_haproxy")),
|
||||
current_user["id"])
|
||||
# The reporting node becomes a member with the role/priority its own file declares,
|
||||
# and carries the one-shot takeover authorisation pinned to the hash we analysed.
|
||||
await conn.execute("""
|
||||
INSERT INTO vip_members
|
||||
(vip_id, agent_id, network_interface, role, priority, takeover_expected_hash)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
""", vip_id, int(agent_id), member["network_interface"],
|
||||
member.get("role") or "BACKUP", int(member.get("priority") or 100),
|
||||
disc["config_hash"])
|
||||
await conn.execute(
|
||||
"UPDATE vip_discoveries SET adopted_vip_id = $2 WHERE agent_id = $1",
|
||||
int(agent_id), vip_id)
|
||||
# Every node of the instance becomes a member with the role/priority/interface ITS
|
||||
# OWN file declares, and each carries its own one-shot takeover authorisation pinned
|
||||
# to the hash of the file we analysed on THAT node. The guard stays per-node: a file
|
||||
# edited on one member between adoption and Apply is still refused there.
|
||||
for p in participants:
|
||||
pm = p["candidate"].get("member") or {}
|
||||
await conn.execute("""
|
||||
INSERT INTO vip_members
|
||||
(vip_id, agent_id, network_interface, role, priority, takeover_expected_hash)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
""", vip_id, p["agent_id"], pm["network_interface"],
|
||||
pm.get("role") or "BACKUP", int(pm.get("priority") or 100),
|
||||
p["config_hash"])
|
||||
await conn.execute(
|
||||
"UPDATE vip_discoveries SET adopted_vip_id = $2 WHERE agent_id = $1",
|
||||
p["agent_id"], vip_id)
|
||||
|
||||
await _stage_vip_version(conn, vip_id, "adopt", current_user["id"])
|
||||
await log_user_activity(
|
||||
@@ -1201,18 +1343,18 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
resource_id=str(vip_id),
|
||||
details={"name": name, "virtual_ip": virtual_ip, "vrid": vrid,
|
||||
"adopted_from_agent": disc["agent_name"],
|
||||
"members": [p["agent_name"] for p in participants],
|
||||
"accepted_data_loss": bool(payload.get("accept_data_loss"))},
|
||||
ip_address=_client_ip(request), user_agent=_user_agent(request))
|
||||
|
||||
peers = list(cand.get("peers") or [])
|
||||
node_names = [p["agent_name"] for p in participants]
|
||||
return {
|
||||
"id": vip_id,
|
||||
"message": ("VIP adopted (PENDING — review it in Apply Management, then apply to hand "
|
||||
"the node's keepalived.conf over to OpenManager)"),
|
||||
# Adoption covers the node that reported. Its peers hold their own keepalived.conf,
|
||||
# so they must be added as members (or adopted from their own report) before the VIP
|
||||
# renders a complete unicast group.
|
||||
"peers_to_add": peers,
|
||||
"members": node_names,
|
||||
"message": (
|
||||
f"VIP adopted with {len(node_names)} member node(s): {', '.join(node_names)} "
|
||||
f"(PENDING — review it in Apply Management, then apply to hand their "
|
||||
f"keepalived.conf over to OpenManager)"),
|
||||
}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
v1.10.8 — the four adoption defects found while testing v1.10.4 on a live HA pair.
|
||||
|
||||
B1 The Apply Management "View Change" diff did not recognise the `adopt` action, so the
|
||||
version fell through to the generic HAProxy diff and rendered the cluster's whole
|
||||
haproxy.cfg as removed.
|
||||
|
||||
B2 `vip_discoveries.adopted_vip_id` is write-once and nothing clears it, while a VIP is only
|
||||
ever SOFT-deleted — so `ON DELETE SET NULL` never fires. Rejecting an adoption therefore
|
||||
hid the node from the panel permanently: the VIP was gone from the VIP list too, and the
|
||||
agent does not re-report a file whose hash has not changed. Adoptability is now derived
|
||||
from whether the linked VIP is still active.
|
||||
|
||||
B3 Adoption took only the node that was clicked. On a two-node pair that meant: adopting the
|
||||
BACKUP produced a VIP whose apply fails ("exactly one member must be MASTER"), adopting the
|
||||
MASTER left the peer unmanaged, and adopting the peer afterwards hit the VRID-collision
|
||||
guard with 409. The pair could never be completed from the panel.
|
||||
|
||||
B4 Worst of the four. `render_keepalived_conf` emits the unicast block only when it has peer
|
||||
addresses, so a single-member adoption of a UNICAST instance silently dropped it and
|
||||
keepalived fell back to multicast on that node while its peer stayed unicast — they stop
|
||||
seeing each other and BOTH claim the VIP.
|
||||
|
||||
B3 and B4 share one root and one fix: adoption now resolves the whole VRRP instance, keyed on
|
||||
(virtual_router_id, virtual address) exactly as keepalived groups nodes.
|
||||
|
||||
These are source-level and unit tests: the adoption endpoint needs a live database, so the
|
||||
behaviour that can be exercised without one is pinned here, and the SQL/flow invariants are
|
||||
pinned by reading the module.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND = pathlib.Path(__file__).resolve().parents[1]
|
||||
VIP_ROUTER = (BACKEND / "routers" / "vip.py").read_text()
|
||||
CLUSTER_ROUTER = (BACKEND / "routers" / "cluster.py").read_text()
|
||||
RENDERER = (BACKEND / "services" / "keepalived_config.py").read_text()
|
||||
|
||||
|
||||
def _adopt_body() -> str:
|
||||
start = VIP_ROUTER.index("async def adopt_vip")
|
||||
return VIP_ROUTER[start:]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# B1 — the diff must recognise `adopt`
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_view_change_diff_recognises_the_adopt_action():
|
||||
match = re.search(r"vip_match = re\.search\(r'vip-\(\\d\+\)-\(([^)]+)\)'", CLUSTER_ROUTER)
|
||||
assert match, "the vip version regex moved; re-point this test"
|
||||
actions = set(match.group(1).split("|"))
|
||||
assert actions == {"create", "update", "delete", "adopt"}, (
|
||||
f"the View Change diff recognises {sorted(actions)}. An action missing here does not "
|
||||
f"degrade gracefully: the version falls through to the generic HAProxy diff and shows "
|
||||
f"the cluster's whole haproxy.cfg as removed."
|
||||
)
|
||||
|
||||
|
||||
def test_every_staged_vip_action_is_covered_by_the_diff_regex():
|
||||
"""Whatever _stage_vip_version can be called with must be in that alternation."""
|
||||
staged = set(re.findall(r'_stage_vip_version\(conn, vip_id, "(\w+)"', VIP_ROUTER))
|
||||
match = re.search(r"vip_match = re\.search\(r'vip-\(\\d\+\)-\(([^)]+)\)'", CLUSTER_ROUTER)
|
||||
recognised = set(match.group(1).split("|"))
|
||||
assert staged, "no _stage_vip_version call sites found; re-point this test"
|
||||
assert staged <= recognised, (
|
||||
f"staged action(s) {sorted(staged - recognised)} are not recognised by the View Change "
|
||||
f"diff regex {sorted(recognised)}"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# B2 — adoptability follows the VIP's liveness, not the bare link
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_discovery_api_exposes_whether_the_adopted_vip_still_stands():
|
||||
assert "adopted_vip_active" in VIP_ROUTER, (
|
||||
"the discovery payload no longer reports whether the adopted VIP is still active; the "
|
||||
"UI would go back to hiding a rejected adoption forever"
|
||||
)
|
||||
assert re.search(r"LEFT JOIN vip_instances av ON av\.id = d\.adopted_vip_id", VIP_ROUTER), (
|
||||
"the discovery query must join the linked VIP to report its is_active"
|
||||
)
|
||||
|
||||
|
||||
def test_adopt_refuses_only_while_the_previous_adoption_still_stands():
|
||||
body = _adopt_body()
|
||||
assert re.search(r'if disc\["adopted_vip_id"\] and disc\["adopted_vip_active"\]', body), (
|
||||
"adopt must refuse only when the linked VIP is still ACTIVE — refusing on the bare link "
|
||||
"makes a rejected adoption impossible to retry, because nothing ever clears the column"
|
||||
)
|
||||
|
||||
|
||||
def test_nothing_clears_adopted_vip_id_so_the_derivation_is_load_bearing():
|
||||
"""If a future change starts clearing the column, this test should be revisited rather than
|
||||
silently left in place — the derived flag is what makes reject recoverable today."""
|
||||
writes = re.findall(r"UPDATE vip_discoveries SET adopted_vip_id = (\S+)", VIP_ROUTER)
|
||||
assert writes, "no adopted_vip_id write found; re-point this test"
|
||||
assert all(w != "NULL" for w in writes), (
|
||||
"adopted_vip_id is now cleared somewhere — re-check that the adopted_vip_active "
|
||||
"derivation and this test still describe reality"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# B3 — the whole VRRP instance is adopted, not one node
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_participants_are_resolved_by_vrid_and_address():
|
||||
assert "async def _collect_instance_participants" in VIP_ROUTER
|
||||
start = VIP_ROUTER.index("async def _collect_instance_participants")
|
||||
end = VIP_ROUTER.index("@router.post(\"/adopt\")", start)
|
||||
body = VIP_ROUTER[start:end]
|
||||
assert 'v.get("virtual_router_id") == vrid' in body and 'v.get("virtual_ip") == virtual_ip' in body, (
|
||||
"instance identity must be (VRID, address) — the same key keepalived uses to decide two "
|
||||
"nodes are one VRRP group"
|
||||
)
|
||||
assert 'if r["parse_error"]' in body, "a node whose config failed to parse must not become a member"
|
||||
assert 'r["adopted_vip_id"] and r["adopted_vip_active"]' in body, (
|
||||
"a node already held by a STANDING VIP must not be pulled into a second one"
|
||||
)
|
||||
|
||||
|
||||
def test_adopt_inserts_one_member_per_participant():
|
||||
body = _adopt_body()
|
||||
insert = body.index("INSERT INTO vip_members")
|
||||
preceding = body[:insert]
|
||||
assert "for p in participants:" in preceding, (
|
||||
"members must be inserted in a loop over the resolved participants; a single insert is "
|
||||
"the half-adoption bug"
|
||||
)
|
||||
assert 'p["config_hash"]' in body[insert:insert + 800], (
|
||||
"each member must carry ITS OWN takeover hash — the one-shot takeover guard is per node"
|
||||
)
|
||||
|
||||
|
||||
def test_adopt_requires_exactly_one_master_across_the_instance():
|
||||
body = _adopt_body()
|
||||
assert 'roles.count("MASTER") != 1' in body, (
|
||||
"adoption must reject an instance that does not have exactly one MASTER, instead of "
|
||||
"letting apply fail later with 'exactly one member must be MASTER'"
|
||||
)
|
||||
|
||||
|
||||
def test_adopt_enforces_one_active_vip_per_agent():
|
||||
body = _adopt_body()
|
||||
assert "already a member of VIP" in body, (
|
||||
"adoption must enforce the one-active-VIP-per-agent rule that create/update enforce via "
|
||||
"_validate_members_against_pool; a second membership never converges"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# B4 — a unicast instance can never be half-adopted
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_renderer_only_emits_unicast_when_it_has_peers():
|
||||
"""The property that makes B4 dangerous. Pinned so the guard below keeps its reason."""
|
||||
assert re.search(r"if use_unicast and peer_ips:", RENDERER), (
|
||||
"the renderer no longer gates the unicast block on having peers; re-derive whether the "
|
||||
"adoption guard is still needed"
|
||||
)
|
||||
|
||||
|
||||
def test_adopt_refuses_a_unicast_peer_that_is_not_being_adopted():
|
||||
body = _adopt_body()
|
||||
assert "declared_peers" in body and "member_ips" in body, (
|
||||
"adoption must verify every declared unicast peer is among the nodes being adopted"
|
||||
)
|
||||
assert "fall back to multicast" in body, (
|
||||
"the refusal must explain the consequence — silently dropping a peer puts both nodes in "
|
||||
"MASTER state on the same address"
|
||||
)
|
||||
|
||||
|
||||
def test_adopt_requires_reported_ips_before_trusting_the_peer_check():
|
||||
body = _adopt_body()
|
||||
assert "have not reported an IP address yet" in body, (
|
||||
"the unicast peer check compares against member IPs, so a member without a reported IP "
|
||||
"must block the check rather than silently pass it"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Backward compatibility
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("guard", [
|
||||
"version_name NOT LIKE 'vip-%'", # bulk apply/reject still skip VIP versions
|
||||
])
|
||||
def test_vip_versions_stay_excluded_from_the_haproxy_apply_flow(guard):
|
||||
assert guard in CLUSTER_ROUTER, (
|
||||
"vip-* versions must stay out of the HAProxy apply/reject sweep; they are owned by the "
|
||||
"VIP endpoints and are never served as haproxy.cfg"
|
||||
)
|
||||
|
||||
|
||||
def test_vip_version_transition_matches_any_action():
|
||||
"""_transition_vip_versions must key on the VIP id alone, or a new action's PENDING row
|
||||
would be stranded in Apply Management after apply/reject."""
|
||||
assert 'f"vip-{vip_id}-%"' in VIP_ROUTER, (
|
||||
"the PENDING -> APPLIED/REJECTED transition must match every action for the VIP"
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.10.7",
|
||||
"releaseName": "HA/VIP follows the selected cluster",
|
||||
"version": "1.10.8",
|
||||
"releaseName": "VIP adoption takes the whole VRRP instance",
|
||||
"releaseDate": "2026-08-13"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.10.7",
|
||||
"version": "1.10.8",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -134,7 +134,11 @@ const VIPManagement = () => {
|
||||
const res = await fetch(`/api/vip/discoveries${scopeQuery}`, { headers: authHeaders() });
|
||||
if (!res.ok) { setDiscoveries([]); return; }
|
||||
const data = await res.json();
|
||||
setDiscoveries((data.discoveries || []).filter((d) => !d.is_managed && !d.adopted_vip_id));
|
||||
// v1.10.8 — hide a node only while its adoption still STANDS. Filtering on adopted_vip_id
|
||||
// alone hid it forever after a reject: nothing clears that column, the VIP is only ever
|
||||
// soft-deleted, and the agent does not re-report an unchanged file.
|
||||
setDiscoveries((data.discoveries || [])
|
||||
.filter((d) => !d.is_managed && !d.adopted_vip_active));
|
||||
} catch (e) {
|
||||
console.error('fetchDiscoveries failed', e);
|
||||
}
|
||||
@@ -147,12 +151,62 @@ const VIPManagement = () => {
|
||||
return () => clearInterval(t);
|
||||
}, [fetchVips, fetchDiscoveries]);
|
||||
|
||||
const openAdopt = (discovery, candidate) => {
|
||||
setAdoptTarget({ discovery, candidate });
|
||||
// v1.10.8 — one row per VRRP INSTANCE, not per node. Adoption now takes the whole instance
|
||||
// (every node in the pool reporting the same VRID + address), so listing the nodes as separate
|
||||
// adoptable rows invited exactly the half-adoption the backend refuses: adopting the BACKUP
|
||||
// alone cannot be applied, and on a unicast pair adopting one side drops the peer list and
|
||||
// drops both nodes into a split brain. Identity is (VRID, address), same as keepalived's.
|
||||
const discoveryGroups = React.useMemo(() => {
|
||||
const groups = new Map();
|
||||
(discoveries || []).forEach((d) => {
|
||||
const cands = d.analysis?.candidates || [];
|
||||
if (cands.length === 0) {
|
||||
const key = `solo:${d.agent_id}`;
|
||||
groups.set(key, { key, instance_name: '—', vip: null, members: [{ discovery: d, candidate: null }] });
|
||||
return;
|
||||
}
|
||||
cands.forEach((c) => {
|
||||
const vrid = c.vip?.virtual_router_id;
|
||||
const addr = c.vip?.virtual_ip;
|
||||
const key = (vrid != null && addr) ? `${vrid}|${addr}` : `solo:${d.agent_id}:${c.instance_name}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { key, instance_name: c.instance_name, vip: c.vip, members: [] });
|
||||
}
|
||||
groups.get(key).members.push({ discovery: d, candidate: c });
|
||||
});
|
||||
});
|
||||
return Array.from(groups.values());
|
||||
}, [discoveries]);
|
||||
|
||||
// What stops a whole instance from being adopted. Mirrors the backend's checks so the button
|
||||
// state and the 422 it would return cannot drift apart.
|
||||
const groupState = (g) => {
|
||||
const parseFailed = g.members.filter((m) => m.discovery.parse_error);
|
||||
const noCandidate = g.members.filter((m) => !m.candidate);
|
||||
const blockers = g.members.flatMap((m) => m.candidate?.blockers || []);
|
||||
const { hard, loss, prefix } = splitBlockers(blockers);
|
||||
const masters = g.members.filter((m) => m.candidate?.member?.role === 'MASTER').length;
|
||||
let reason = null;
|
||||
if (parseFailed.length) reason = `${parseFailed.map((m) => m.discovery.agent_name).join(', ')}: config could not be parsed`;
|
||||
else if (noCandidate.length) reason = 'no vrrp_instance in the report';
|
||||
else if (hard.length) reason = hard.join(' · ');
|
||||
else if (masters !== 1) {
|
||||
reason = masters === 0
|
||||
? 'no node in this instance declares state MASTER — enable the missing node\'s agent so it reports its config'
|
||||
: `${masters} nodes declare MASTER; exactly one must`;
|
||||
}
|
||||
return { parseFailed, noCandidate, hard, loss, prefix, masters, blockers, reason };
|
||||
};
|
||||
|
||||
const openAdopt = (group) => {
|
||||
// Any member can carry the request: the backend resolves the whole instance from it. Prefer
|
||||
// the MASTER so the suggested name and the preview show the authoritative node.
|
||||
const primary = group.members.find((m) => m.candidate?.member?.role === 'MASTER') || group.members[0];
|
||||
setAdoptTarget({ group, primary, discovery: primary.discovery, candidate: primary.candidate });
|
||||
setAdoptAcceptLoss(false);
|
||||
adoptForm.setFieldsValue({
|
||||
name: `${discovery.agent_name}-${candidate?.vip?.virtual_ip || 'vip'}`,
|
||||
prefix_length: candidate?.vip?.prefix_length ?? undefined,
|
||||
name: `${group.vip?.virtual_ip || primary.discovery.agent_name}-vip`,
|
||||
prefix_length: group.vip?.prefix_length ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -180,14 +234,6 @@ const VIPManagement = () => {
|
||||
}
|
||||
const data = await res.json();
|
||||
message.success(data.message || 'VIP adopted', 8);
|
||||
if ((data.peers_to_add || []).length > 0) {
|
||||
// The adopted node's peers keep their own keepalived.conf, so the VIP is not a complete
|
||||
// VRRP group until they are members too.
|
||||
message.info(
|
||||
`This instance has ${data.peers_to_add.length} unicast peer(s) (${data.peers_to_add.join(', ')}). `
|
||||
+ 'Adopt or add those nodes as members before applying, or the rendered config will have no peers.',
|
||||
12);
|
||||
}
|
||||
setAdoptTarget(null);
|
||||
fetchVips();
|
||||
fetchDiscoveries();
|
||||
@@ -528,70 +574,85 @@ const VIPManagement = () => {
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey={(r) => `${r.agent_id}:${r.instance_name}`}
|
||||
rowKey={(r) => r.key}
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={discoveries.flatMap((d) => {
|
||||
const cands = (d.analysis?.candidates || []);
|
||||
if (cands.length === 0) {
|
||||
return [{ agent_id: d.agent_id, discovery: d, instance_name: '—', candidate: null }];
|
||||
}
|
||||
return cands.map((c) => ({
|
||||
agent_id: d.agent_id, discovery: d, instance_name: c.instance_name, candidate: c,
|
||||
}));
|
||||
})}
|
||||
dataSource={discoveryGroups}
|
||||
columns={[
|
||||
{ title: 'Node', dataIndex: ['discovery', 'agent_name'], key: 'agent',
|
||||
{ title: 'Nodes', key: 'agents',
|
||||
render: (_v, r) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{r.discovery.agent_name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{r.discovery.pool_name || 'no pool'}</Text>
|
||||
{r.members.map((m) => (
|
||||
<Text strong key={m.discovery.agent_id}>{m.discovery.agent_name}</Text>
|
||||
))}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.members[0]?.discovery.pool_name || 'no pool'}
|
||||
</Text>
|
||||
</Space>
|
||||
) },
|
||||
{ title: 'Instance', dataIndex: 'instance_name', key: 'instance' },
|
||||
{ title: 'Virtual IP', key: 'vip',
|
||||
render: (_v, r) => (r.candidate?.vip?.virtual_ip
|
||||
? <Text code>{r.candidate.vip.virtual_ip}
|
||||
{r.candidate.vip.prefix_length != null ? `/${r.candidate.vip.prefix_length}` : ''}</Text>
|
||||
render: (_v, r) => (r.vip?.virtual_ip
|
||||
? <Text code>{r.vip.virtual_ip}
|
||||
{r.vip.prefix_length != null ? `/${r.vip.prefix_length}` : ''}</Text>
|
||||
: <Text type="secondary">—</Text>) },
|
||||
{ title: 'VRID', key: 'vrid',
|
||||
render: (_v, r) => (r.candidate?.vip?.virtual_router_id ?? <Text type="secondary">—</Text>) },
|
||||
{ title: 'This node', key: 'member',
|
||||
render: (_v, r) => (r.candidate ? (
|
||||
<Space size={4}>
|
||||
<Tag color={r.candidate.member.role === 'MASTER' ? 'green' : 'default'}>
|
||||
{r.candidate.member.role}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
prio {r.candidate.member.priority} · {r.candidate.member.network_interface}
|
||||
</Text>
|
||||
render: (_v, r) => (r.vip?.virtual_router_id ?? <Text type="secondary">—</Text>) },
|
||||
{ title: 'Members', key: 'member',
|
||||
render: (_v, r) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
{r.members.map((m) => (
|
||||
<Space size={4} key={m.discovery.agent_id}>
|
||||
{m.candidate ? (
|
||||
<>
|
||||
<Tag color={m.candidate.member.role === 'MASTER' ? 'green' : 'default'}>
|
||||
{m.candidate.member.role}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
prio {m.candidate.member.priority} · {m.candidate.member.network_interface}
|
||||
</Text>
|
||||
</>
|
||||
) : <Text type="secondary">—</Text>}
|
||||
</Space>
|
||||
))}
|
||||
</Space>
|
||||
) : <Text type="secondary">—</Text>) },
|
||||
) },
|
||||
{ title: 'Adoptable', key: 'adoptable',
|
||||
render: (_v, r) => {
|
||||
if (r.discovery.parse_error) {
|
||||
return <Tooltip title={r.discovery.parse_error}><Tag color="red">unparseable</Tag></Tooltip>;
|
||||
const st = groupState(r);
|
||||
if (st.parseFailed.length) {
|
||||
return (
|
||||
<Tooltip title={st.parseFailed.map((m) => `${m.discovery.agent_name}: ${m.discovery.parse_error}`).join(' · ')}>
|
||||
<Tag color="red">unparseable</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (!r.candidate) return <Tag>no vrrp_instance</Tag>;
|
||||
if (r.candidate.adoptable) return <Tag color="green">yes</Tag>;
|
||||
const { hard } = splitBlockers(r.candidate.blockers);
|
||||
if (st.noCandidate.length) return <Tag>no vrrp_instance</Tag>;
|
||||
if (st.hard.length || st.masters !== 1) {
|
||||
return <Tooltip title={st.reason}><Tag color="red">
|
||||
{st.hard.length ? `${st.hard.length} blocker(s)` : 'MASTER missing'}
|
||||
</Tag></Tooltip>;
|
||||
}
|
||||
if (!st.blockers.length) return <Tag color="green">yes</Tag>;
|
||||
return (
|
||||
<Tooltip title={(r.candidate.blockers || []).join(' · ')}>
|
||||
<Tag color={hard.length ? 'red' : 'gold'}>
|
||||
{hard.length ? `${hard.length} blocker(s)` : 'needs review'}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={st.blockers.join(' · ')}><Tag color="gold">needs review</Tag></Tooltip>
|
||||
);
|
||||
} },
|
||||
{ title: 'Actions', key: 'actions',
|
||||
render: (_v, r) => (
|
||||
<Button size="small" type="primary" ghost
|
||||
disabled={!r.candidate || !!r.discovery.parse_error
|
||||
|| splitBlockers(r.candidate.blockers).hard.length > 0}
|
||||
onClick={() => openAdopt(r.discovery, r.candidate)}>
|
||||
Adopt
|
||||
</Button>
|
||||
) },
|
||||
render: (_v, r) => {
|
||||
const st = groupState(r);
|
||||
const btn = (
|
||||
<Button size="small" type="primary" ghost
|
||||
disabled={!!st.reason} onClick={() => openAdopt(r)}>
|
||||
Adopt
|
||||
</Button>
|
||||
);
|
||||
// A disabled antd Button swallows mouse events, so the tooltip needs a live
|
||||
// wrapper or the operator never learns WHY adoption is unavailable.
|
||||
return st.reason
|
||||
? <Tooltip title={st.reason}><span style={{ display: 'inline-block' }}>{btn}</span></Tooltip>
|
||||
: btn;
|
||||
} },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
@@ -599,7 +660,9 @@ const VIPManagement = () => {
|
||||
|
||||
{/* Adopt modal — shows what will be taken over, what was assumed, and what would be lost. */}
|
||||
<Modal
|
||||
title={adoptTarget ? `Adopt ${adoptTarget.candidate.instance_name} from ${adoptTarget.discovery.agent_name}` : 'Adopt VIP'}
|
||||
title={adoptTarget
|
||||
? `Adopt ${adoptTarget.group.instance_name} — ${adoptTarget.group.members.length} node(s)`
|
||||
: 'Adopt VIP'}
|
||||
open={!!adoptTarget}
|
||||
onCancel={() => setAdoptTarget(null)}
|
||||
onOk={submitAdopt}
|
||||
@@ -608,16 +671,34 @@ const VIPManagement = () => {
|
||||
width={720}
|
||||
okButtonProps={{
|
||||
disabled: !!adoptTarget && (() => {
|
||||
const { loss, hard } = splitBlockers(adoptTarget.candidate.blockers);
|
||||
const { loss, hard } = splitBlockers(
|
||||
adoptTarget.group.members.flatMap((m) => m.candidate?.blockers || []));
|
||||
return hard.length > 0 || (loss.length > 0 && !adoptAcceptLoss);
|
||||
})(),
|
||||
}}
|
||||
>
|
||||
{adoptTarget && (() => {
|
||||
const cand = adoptTarget.candidate;
|
||||
const { loss, prefix, hard } = splitBlockers(cand.blockers);
|
||||
// Blockers are aggregated across EVERY node of the instance, because adoption
|
||||
// overwrites every one of their files — the backend refuses on the same combined set.
|
||||
const { loss, prefix, hard } = splitBlockers(
|
||||
adoptTarget.group.members.flatMap((m) => m.candidate?.blockers || []));
|
||||
return (
|
||||
<>
|
||||
<Alert type="info" showIcon style={{ marginBottom: 12 }}
|
||||
message={`These ${adoptTarget.group.members.length} node(s) will be taken over together`}
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{adoptTarget.group.members.map((m) => (
|
||||
<li key={m.discovery.agent_id}>
|
||||
<Text strong>{m.discovery.agent_name}</Text>
|
||||
{' — '}{m.candidate?.member?.role} · prio {m.candidate?.member?.priority}
|
||||
{' · '}{m.candidate?.member?.network_interface}
|
||||
{' · '}<Text code>{m.discovery.config_path}</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
} />
|
||||
{hard.length > 0 && (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 12 }}
|
||||
message="This config cannot be adopted"
|
||||
|
||||
Reference in New Issue
Block a user