Merge pull request #58 from mustafaulukaya/feature/keepalived-vip-adoption

Adopt an existing keepalived VIP (Issue #27 follow-up)
This commit is contained in:
taylanbakircioglu
2026-08-13 18:52:39 +03:00
11 changed files with 1620 additions and 12 deletions
+1
View File
@@ -2474,6 +2474,7 @@ Developed with ❤️ for the HAProxy community
## Release Notes
- **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**.
- **v1.10.3** (2026-08-08) — **Multi-account ACME: the certificate wizard honours the account you pick**: with more than one ACME account registered, picking an **HTTP-01** account in *Request ACME Certificate* still produced a **DNS-01** request. Three faults compounded. (1) `Form.useWatch` reports only fields that are currently **rendered**, and the account `Select` lives on the *Configuration* step — so as soon as the wizard advanced to *Review* the watch read `undefined` and the wizard silently reverted to the default account, even though the value was still in the form store; the watches now pass `preserve: true`. The same fault disabled the **wildcard guard** on *Review*, the one step where Submit lives. (2) The UI and the backend disagreed on which account is the *default*: the backend takes the **newest** valid account (`ORDER BY created_at DESC`), the UI took the **oldest** entry of a list ordered by id — the opposite account whenever the two differ. The wizard now resolves the same one, and sends `account_id` **explicitly** so there is no guess left to disagree about. (3) `account_id` was read from the form store while `challenge_type` came from the reverted account object, so the request asked for DNS-01 validation on an HTTP-01 account and the API answered `The selected ACME account has no DNS provider configured for DNS-01.` — both are now derived from one resolved account. The *Review* step also showed the default account's address instead of the chosen one, and Submit stayed enabled for a deactivated account; both fixed. Frontend only — no schema, API-shape, agent or rendered-config changes, and single-account installations behave exactly as before.
- **v1.10.2** (2026-08-08) — **Dark mode fixes on Apply Management**: several panels on the Apply Management page were painted with light-mode colour literals, so in dark mode the **Pending Changes** box rendered as a cream panel with light text on it — measured contrast **1.03:1**, effectively unreadable, now **11.50:1**. The same bug affected the added/removed rows in the *View Change* diff (2.21:1 and 2.99:1, now 5.49:1 and 4.01:1), the ACME and pending-version panels, the VIP pending-delete row, and the agent-error recommendation box; all now derive from theme tokens. Separately, **static confirm dialogs came up white in dark mode**: in Ant Design 5 the static `Modal.confirm` / `message` / `notification` APIs render into their own detached root and never see the app's `ConfigProvider`, so they always used the light algorithm. Registering `ConfigProvider.config({ holderRender })` once at the app root fixes **every** static dialog in the application (12 components use them), not only this page. Light mode is byte-identical — each token resolves under the default algorithm to exactly the literal it replaced. Frontend only: no schema, API, environment or agent change.
- **v1.10.1** (2026-08-08) — **CSR private key encrypted at rest** (Issue #53): the private key of a **pending** CSR is now Fernet-encrypted in the database instead of stored as PEM. It is the one key in the system worth protecting this way — it sits idle for the entire signing window (days to weeks), is never transmitted to an agent, and is destroyed the moment the signed certificate is imported; `ssl_certificates.private_key_content` and the ACME order keys are unchanged, because agents must receive those in plaintext on every poll. The token replaces the PEM in the **same column**, so there is **no schema change and no `SCHEMA_VERSION` bump** (and therefore no re-seed of the built-in roles). CSRs created before this release keep a raw PEM and are still read transparently, so anything already out for signature imports normally with no data migration. The key derives from `SECRET_KEY` via HKDF with its own info string, independent of the VIP/MFA/DNS keys, and an optional `CSR_ENCRYPTION_KEY` enables independent rotation — rotating `SECRET_KEY` without it makes pending CSR keys unrecoverable, which now fails with an explicit "delete and re-create this CSR" error rather than a misleading key-mismatch. `.env.template` now documents all four per-purpose encryption keys. No API, UI or agent change.
+49
View File
@@ -1,3 +1,52 @@
# Upgrade Notes — v1.10.4 (Adopt an existing keepalived VIP)
**Additive, but this release DOES bump the schema — read the role warning below.** Nothing on any
node changes until you adopt a VIP and apply it.
- **Schema:** `SCHEMA_VERSION` bumps to `11`, so on first start the (idempotent) migration
sequence re-runs once and adds **one new table** (`vip_discoveries`) plus two additive columns
(`vip_instances.adopted_at`, `vip_members.takeover_expected_hash`). **No existing table is
altered**, no existing row changes, and the admin password is not reset.
- **⚠️ Built-in roles are re-seeded to their defaults** — the pre-existing behaviour of every
`SCHEMA_VERSION` bump. If you customised `super_admin` / `operator` / `security_admin` /
`viewer`, **re-apply those changes after upgrading**. (The three previous releases did not bump
the version, so this is the first re-seed since v1.9.0.) No new permission strings are
introduced: discovery and adoption are governed by the existing `vip.read` / `vip.create`.
- **⚠️ The Linux agent script changed, and discovery does not start until nodes run it.** The
fallback latest Linux agent version moves `2.0.0``2.1.0`, so nodes will pull the new script
through the normal agent-upgrade path. The addition is **read-only**: the agent reads the
`keepalived.conf` it does not own and reports it, rate-limited to once per content change. It
writes nothing new to the node. Until a node has upgraded, it simply never appears under
*Unmanaged keepalived detected*.
- **Nothing is taken over implicitly.** The agent still refuses to overwrite a `keepalived.conf`
that lacks OpenManager's ownership marker. Adoption authorises exactly **one** takeover of
exactly the file that was analysed, pinned to its md5: if the file changes between adoption and
Apply, the agent refuses again and reports `externally_managed` rather than clobbering your
edit. Re-adopt to pick up the current file.
- **Adoption can refuse, on purpose.** It replaces the file with OpenManager's render, so anything
the renderer cannot reproduce would be destroyed. Those directives are listed as blockers —
`notify_*` failover hooks, `vrrp_sync_group`, LVS `virtual_server` sections, a second address in
one instance, a custom `track_script`, extra `global_defs`. You can accept that loss explicitly
with a tick, but a value that is *unknown* rather than lost (an absent `virtual_router_id`, or
an address with no prefix length) cannot be waived — the VRID is fatal to guess and the prefix
has to be supplied, because picking a netmask for a live VIP would change its routing.
- **Multi-node VIPs need every node.** Adoption covers the node that reported. Its unicast peers
hold their own `keepalived.conf`, so adopt or add them as members before applying — otherwise
the render has no peers. The UI says so after a successful adopt.
- **Secrets:** the reported config may contain the VRRP `auth_pass`. It is split at ingest — the
password is Fernet-encrypted into its own column (same key path as `vip_instances`,
`VIP_ENCRYPTION_KEY` falling back to a key derived from `SECRET_KEY`) and the stored copy of the
file has it masked, so nothing readable through the API, the UI preview or a DB dump carries it
in cleartext.
- **Rollback:** downgrading to 1.10.3 leaves `vip_discoveries` as an unused table and the two new
columns unread; managed VIPs keep working. One caveat: a VIP adopted on 1.10.4 but **not yet
applied** loses its takeover authorisation on downgrade, so the node's original config stays in
place and the VIP sits PENDING — harmless, but re-adopt after upgrading again. Agents already on
script 2.1.0 keep reporting discoveries to an endpoint that no longer exists; the report fails
quietly and nothing on the node is affected.
---
# Upgrade Notes — v1.10.3 (Multi-account ACME wizard fix)
**Frontend only. Nothing to do on upgrade.** No schema, no `SCHEMA_VERSION` bump, no API change, no
+46 -1
View File
@@ -1758,7 +1758,13 @@ async def ensure_agent_activity_logs_table():
# until the operator imports the CA-signed certificate; the import creates a
# normal ssl_certificates row and NULLs the key copy here. Additive + idempotent;
# no existing table is altered, agents never read this table.
SCHEMA_VERSION = 10
# v1.10.4 (VIP adoption): bumped 10 -> 11 for the new `vip_discoveries` table plus two
# additive columns (`vip_instances.adopted_at`, `vip_members.takeover_expected_hash`).
# Holds the keepalived.conf an agent found already on a node so an existing VIP can be
# adopted instead of retyped. Additive + idempotent; no existing table is altered and no
# existing row changes. NOTE for the upgrade notes: a SCHEMA_VERSION bump re-seeds the four
# built-in roles to their defaults, so role customizations are lost on this upgrade.
SCHEMA_VERSION = 11
async def run_all_migrations():
@@ -2161,6 +2167,45 @@ async def ensure_vip_tables():
"CREATE INDEX IF NOT EXISTS idx_vip_members_agent ON vip_members(agent_id);"
)
# ── v1.10.4 — VIP adoption: what the agent found already on the node ──────────
# A node with a hand-maintained keepalived.conf reports it here so an existing VIP can
# be adopted instead of retyped. One row per agent (the file is per-node); the agent
# only reports a config it does NOT own, and only when the content changed.
#
# SECRETS: `raw_config` is stored MASKED (auth_pass replaced) because it is served to
# the UI. The real VRRP password is Fernet-encrypted in auth_pass_encrypted, mirroring
# vip_instances, so adoption can carry it into the managed VIP without it ever being
# readable through the API or a DB dump. `analysis` is the parser output with auth_pass
# stripped out.
await conn.execute("""
CREATE TABLE IF NOT EXISTS vip_discoveries (
id SERIAL PRIMARY KEY,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
config_path VARCHAR(500) NOT NULL,
config_hash VARCHAR(64) NOT NULL,
is_managed BOOLEAN NOT NULL DEFAULT FALSE,
raw_config_masked TEXT,
auth_pass_encrypted TEXT,
analysis JSONB,
parse_error TEXT,
adopted_vip_id INTEGER REFERENCES vip_instances(id) ON DELETE SET NULL,
reported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT vip_discovery_agent_unique UNIQUE (agent_id)
);
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_vip_discoveries_agent ON vip_discoveries(agent_id);"
)
# Adoption provenance + the one-shot takeover authorisation. The agent refuses to
# overwrite a keepalived.conf that lacks our ownership marker, which is exactly the
# guard adoption has to pass. Rather than weaken it, an adopted VIP carries the hash of
# the file we analysed: the agent takes over ONLY if the file on disk still hashes to
# that value, so a config that changed after adoption is never clobbered.
await conn.execute(
"ALTER TABLE vip_instances ADD COLUMN IF NOT EXISTS adopted_at TIMESTAMP;")
await conn.execute(
"ALTER TABLE vip_members ADD COLUMN IF NOT EXISTS takeover_expected_hash VARCHAR(64);")
logger.info("✅ VIP tables ensured (Issue #27 — HA/VIP Keepalived management)")
except Exception as e:
logger.error(f"Failed to ensure VIP tables: {e}")
+111 -2
View File
@@ -9,8 +9,15 @@ import os
import json
import ipaddress
import hashlib
import re
# Pipeline trigger - force backend redeploy v2
# v1.10.4 — a discovered keepalived.conf is stored and served to the UI, so the VRRP password is
# masked out of the stored copy (the real value lives Fernet-encrypted in its own column). Mask
# the WHOLE remainder of the line, mirroring vip.py's version-diff masking, so a password
# containing whitespace cannot partially leak.
_AUTH_PASS_MASK_RE = re.compile(r"(auth_pass\s+).*")
from models import AgentCreate
from models.agent import AgentToggle, AgentHeartbeat, AgentScriptRequest, AgentUpgradeRequest
from database.connection import get_database_connection, close_database_connection
@@ -26,7 +33,7 @@ logger = logging.getLogger(__name__)
# Global version storage (acts as in-memory database)
AGENT_VERSIONS = {
"macos": "2.1.0", # Updated via endpoint
"linux": "2.0.0"
"linux": "2.1.0"
}
@@ -2332,7 +2339,7 @@ async def get_agent_keepalived_config(agent_name: str, x_api_key: Optional[str]
row = await conn.fetchrow("""
SELECT v.id AS vip_id, v.name AS vip_name, v.is_active, v.track_haproxy,
v.purge_on_teardown,
m.applied_config_content, m.applied_config_hash
m.applied_config_content, m.applied_config_hash, m.takeover_expected_hash
FROM vip_members m JOIN vip_instances v ON v.id = m.vip_id
WHERE m.agent_id = $1
-- Active VIP first (an agent has at most one). With NO active VIP, pick the most
@@ -2367,6 +2374,14 @@ async def get_agent_keepalived_config(agent_name: str, x_api_key: Optional[str]
"config_content": row['applied_config_content'],
"config_hash": row['applied_config_hash'],
"check_script": check_script,
# v1.10.4 adoption handoff. The agent refuses to overwrite a keepalived.conf
# without our ownership marker — the guard that protects a hand-maintained
# setup. Adoption does not weaken it: it authorises exactly ONE takeover, of
# exactly the file we analysed, by pinning its hash. If the file changed since
# adoption the hashes differ and the agent keeps refusing, so an edit made
# between adoption and Apply can never be silently overwritten.
"allow_takeover": bool(row['takeover_expected_hash']),
"takeover_expected_hash": row['takeover_expected_hash'],
},
}
except HTTPException:
@@ -2431,6 +2446,100 @@ async def agent_keepalived_status(agent_name: str, status_data: dict, x_api_key:
if conn:
await close_database_connection(conn)
@router.post("/{agent_name}/keepalived-discovery")
async def agent_keepalived_discovery(agent_name: str, payload: dict, x_api_key: Optional[str] = Header(None)):
"""v1.10.4 — the agent reports a keepalived.conf it found on the node but does NOT own.
This is what makes adopting a hand-maintained VIP possible: the heartbeat only carries the
VIP address and a best-effort MASTER/BACKUP, while rendering a node's config needs eleven
fields, so the file itself has to be read. Read-only on the agent side — reporting never
changes anything on the node.
Auth mirrors /keepalived-status: a MISSING key is rejected outright, and because the token
is a shared install token a name mismatch is an advisory audit log rather than a 403.
SECRETS: the reported content may contain the VRRP `auth_pass`. It is split immediately —
the password is Fernet-encrypted into its own column and the stored copy of the file has it
masked, so nothing readable through the API or a DB dump carries it in cleartext. The
parse result is never logged.
"""
conn = None
try:
from auth_middleware import validate_agent_api_key
agent_auth = await validate_agent_api_key(x_api_key)
if not x_api_key or not agent_auth:
raise HTTPException(status_code=401, detail="Invalid API key")
if agent_auth['name'] != agent_name:
logger.info(f"Agent '{agent_name}' reporting keepalived discovery using API key "
f"from agent '{agent_auth['name']}'")
conn = await get_database_connection()
agent = await conn.fetchrow("SELECT id FROM agents WHERE name = $1", agent_name)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
config_path = (payload.get("config_path") or "/etc/keepalived/keepalived.conf")[:500]
exists = bool(payload.get("exists"))
if not exists:
# The file is gone (keepalived removed, or we adopted and now own it) — drop the row
# so the UI stops offering a stale candidate.
await conn.execute("DELETE FROM vip_discoveries WHERE agent_id = $1", agent['id'])
return {"status": "cleared"}
content = payload.get("config_content") or ""
if len(content) > 256_000:
raise HTTPException(status_code=413, detail="keepalived.conf too large to analyse")
is_managed = bool(payload.get("is_managed"))
config_hash = hashlib.md5(content.encode("utf-8", "replace")).hexdigest()
from services.keepalived_parser import analyse_keepalived_conf, KeepalivedParseError
from services.keepalived_config import encrypt_vrrp_secret
parse_error = None
analysis = None
auth_enc = None
try:
analysis = analyse_keepalived_conf(content)
# Split the secret out of everything we persist or serve.
for cand in analysis.get("candidates", []):
secret = (cand.get("vip") or {}).pop("auth_pass", None)
cand["vip"]["has_auth_pass"] = bool(secret)
if secret and auth_enc is None:
auth_enc = encrypt_vrrp_secret(secret)
except KeepalivedParseError as exc:
parse_error = str(exc)[:500]
except Exception as exc: # noqa: BLE001 — a malformed file must not 500 the agent loop
parse_error = f"could not analyse the config ({type(exc).__name__})"
masked = _AUTH_PASS_MASK_RE.sub(r"\1********", content)
await conn.execute("""
INSERT INTO vip_discoveries
(agent_id, config_path, config_hash, is_managed, raw_config_masked,
auth_pass_encrypted, analysis, parse_error, reported_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,CURRENT_TIMESTAMP)
ON CONFLICT (agent_id) DO UPDATE SET
config_path = EXCLUDED.config_path,
config_hash = EXCLUDED.config_hash,
is_managed = EXCLUDED.is_managed,
raw_config_masked = EXCLUDED.raw_config_masked,
auth_pass_encrypted = EXCLUDED.auth_pass_encrypted,
analysis = EXCLUDED.analysis,
parse_error = EXCLUDED.parse_error,
reported_at = CURRENT_TIMESTAMP
""", agent['id'], config_path, config_hash, is_managed, masked, auth_enc,
json.dumps(analysis) if analysis is not None else None, parse_error)
return {"status": "recorded", "config_hash": config_hash}
except HTTPException:
raise
except Exception as e:
logger.error(f"keepalived-discovery failed for '{agent_name}': {e}")
raise HTTPException(status_code=500, detail="keepalived-discovery failed")
finally:
if conn:
await close_database_connection(conn)
@router.get("/script-version")
async def get_latest_script_version(platform: str = "macos"):
"""Get the latest available agent script version for specified platform"""
+215
View File
@@ -985,3 +985,218 @@ async def vip_status(vip_id: int, authorization: str = Header(None)):
# endpoint (cluster.py get_config_version_diff, vip-* branch) via render_vip_config_masked
# above — there is no bespoke VIP preview endpoint, so VIP changes use the product's
# standard "View Change" like every other entity (issue #27 follow-up).
# ---------------------------------------------------------------------------
# v1.10.4 — Adoption of a keepalived setup that already exists on the nodes
# ---------------------------------------------------------------------------
# The HA/VIP page starts empty on a fleet that already runs keepalived, because the flow is
# one-way: VIPs are declared here and pushed to the node, and nothing read what was already
# there. The agent now reports the keepalived.conf it found (read-only) into vip_discoveries;
# these two endpoints list those findings and turn one into a managed VIP.
#
# Adoption REPLACES the operator's file with our render, so it is gated hard: the parser
# reports every directive we cannot reproduce and every value we cannot know, and adoption
# refuses while any remain. See services/keepalived_parser.py for the reasoning.
def _discovery_row_to_api(row) -> dict:
"""Shape a vip_discoveries row for the UI. Never includes the VRRP password: the stored
config copy is masked and the analysis has the secret replaced by a boolean."""
analysis = row["analysis"]
if isinstance(analysis, str):
try:
analysis = json.loads(analysis)
except (json.JSONDecodeError, TypeError):
analysis = None
return {
"agent_id": row["agent_id"],
"agent_name": row["agent_name"],
"pool_id": row["pool_id"],
"pool_name": row["pool_name"],
"config_path": row["config_path"],
"config_hash": row["config_hash"],
"is_managed": row["is_managed"],
"parse_error": row["parse_error"],
"adopted_vip_id": row["adopted_vip_id"],
"reported_at": row["reported_at"].isoformat() if row["reported_at"] else None,
"config_preview": row["raw_config_masked"],
"analysis": analysis,
}
@router.get("/discoveries")
async def list_vip_discoveries(authorization: str = Header(None)):
"""Unmanaged keepalived configs the agents found on their nodes.
Read-only and safe to poll: this is what the HA/VIP page shows so an existing VIP is
visible before anyone adopts it.
"""
await _require(authorization, "read")
conn = await get_database_connection()
try:
try:
rows = await conn.fetch("""
SELECT d.*, a.name AS agent_name, a.pool_id, p.name AS pool_name
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
ORDER BY d.reported_at DESC, d.id DESC
""")
except Exception as exc: # noqa: BLE001 — a missing relation degrades to empty (B-7)
logger.debug(f"vip_discoveries unavailable: {exc}")
return {"discoveries": []}
return {"discoveries": [_discovery_row_to_api(r) for r in rows]}
finally:
await close_database_connection(conn)
def _find_candidate(analysis: Optional[dict], instance_name: str) -> Optional[dict]:
for cand in ((analysis or {}).get("candidates") or []):
if cand.get("instance_name") == instance_name:
return cand
return None
@router.post("/adopt")
async def adopt_vip(payload: dict, request: Request, authorization: str = Header(None)):
"""Turn one discovered vrrp_instance into a managed VIP.
Body: agent_id, instance_name, name, [description], [prefix_length], [accept_data_loss].
Refuses while the parser reports blockers. Two of them are resolvable by the operator
rather than fatal:
* a missing prefix length can be supplied as `prefix_length` (we never guess a netmask
for a live VIP);
* "we would delete this directive" can be accepted with `accept_data_loss: true`, which
is an explicit choice to lose e.g. a notify hook. Everything else an unknown VRID, a
fractional advert_int, an unsupported auth_type is not a loss but an impossibility,
and no flag overrides it.
The VIP is created PENDING like any other, so nothing reaches the node until the operator
applies it from Apply Management.
"""
current_user = await _require(authorization, "create")
agent_id = payload.get("agent_id")
instance_name = (payload.get("instance_name") or "").strip()
name = (payload.get("name") or "").strip()
if not agent_id or not instance_name or not name:
raise HTTPException(status_code=400, detail="agent_id, instance_name and name are required")
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
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"]:
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")
if disc["parse_error"]:
raise HTTPException(status_code=422,
detail=f"Config could not be parsed: {disc['parse_error']}")
analysis = disc["analysis"]
if isinstance(analysis, str):
analysis = json.loads(analysis)
cand = _find_candidate(analysis, instance_name)
if not cand:
raise HTTPException(status_code=404,
detail=f"No vrrp_instance '{instance_name}' in the report")
vip_fields = dict(cand.get("vip") or {})
member = dict(cand.get("member") or {})
# The operator may supply the one value we refuse to guess.
supplied_prefix = payload.get("prefix_length")
if vip_fields.get("prefix_length") is None and supplied_prefix is not None:
try:
vip_fields["prefix_length"] = int(supplied_prefix)
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")
if vrid is None or not virtual_ip or not member.get("network_interface"):
raise HTTPException(status_code=422,
detail="Incomplete candidate (vrid/address/interface)")
# 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.
clash = await conn.fetchrow("""
SELECT id, name FROM vip_instances
WHERE pool_id = $1 AND is_active = TRUE AND virtual_router_id = $2
""", disc["pool_id"], int(vrid))
if clash:
raise HTTPException(status_code=409, detail=(
f"VRID {vrid} is already used by VIP '{clash['name']}' in this pool; the adopted "
f"config must keep its VRID, so resolve the collision first"))
async with conn.transaction():
vip_id = await conn.fetchval("""
INSERT INTO vip_instances
(name, description, pool_id, virtual_ip, prefix_length, virtual_router_id,
advert_int, auth_pass_encrypted, use_unicast, track_haproxy,
is_active, last_config_status, adopted_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',CURRENT_TIMESTAMP,$11)
RETURNING id
""", name, payload.get("description") or f"Adopted from {disc['agent_name']}",
disc["pool_id"], virtual_ip, int(vip_fields["prefix_length"]), int(vrid),
int(vip_fields.get("advert_int") or 1),
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)
await _stage_vip_version(conn, vip_id, "adopt", current_user["id"])
await log_user_activity(
user_id=current_user["id"], action="adopt", resource_type="vip",
resource_id=str(vip_id),
details={"name": name, "virtual_ip": virtual_ip, "vrid": vrid,
"adopted_from_agent": disc["agent_name"],
"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 [])
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,
}
finally:
await close_database_connection(conn)
+533
View File
@@ -0,0 +1,533 @@
"""Issue #27 follow-up — parse an EXISTING keepalived.conf so a hand-maintained VIP can be
adopted into OpenManager's model (v1.10.4).
Standalone and DB-free, like keepalived_config.py: the agent reports the file it found on a
node, this module turns it into the fields `vip_instances` / `vip_members` need, and the
adoption endpoint decides whether taking ownership is safe.
WHY A PARSER AND NOT THE HEARTBEAT. The heartbeat carries two keepalived facts
`keepalive_state` (MASTER/BACKUP, best-effort from logs) and `keepalive_ip` (the first
address grepped out of `virtual_ipaddress`). Rendering a node's config needs eleven:
virtual_router_id, auth_pass, interface, priority, prefix_length, advert_int, unicast
peers, track_haproxy, role and the address itself. Guessing the missing ones is not a
cosmetic risk a wrong VRID puts the nodes in two separate VRRP domains and a wrong
auth_pass makes them reject each other, and either way both nodes claim the VIP.
THE SAFETY CONTRACT. Adoption REPLACES the operator's file with our render, so anything in
their file that `render_keepalived_conf` cannot reproduce would be silently destroyed on
takeover a `notify_master` failover hook, an LVS `virtual_server` section, a second
address in one instance, a sync group. Extracting the fields is the easy half; the half
that matters is `unsupported`, the list of directives we would drop. The caller must treat
a non-empty `unsupported` as a refusal to adopt, not a warning to log.
Secrets: a parsed instance carries `auth_pass` in cleartext because that is the only way to
re-render an identical config. NEVER log a parse result. Callers persist it through
`encrypt_vrrp_secret` and mask it in anything UI-facing, exactly as the VIP router already
does for `auth_pass` in version diffs.
"""
from __future__ import annotations
import ipaddress
import re
from typing import Any, Dict, List, Optional, Tuple
# Directives `render_keepalived_conf` emits, and therefore the only ones a takeover can
# reproduce. Anything else found in a vrrp_instance is reported in `unsupported`.
_SUPPORTED_INSTANCE_KEYS = {
"state", "interface", "virtual_router_id", "priority", "advert_int",
"authentication", "unicast_src_ip", "unicast_peer", "virtual_ipaddress", "track_script",
}
# Top-level blocks we can account for. `vrrp_script` is reproduced only when it is the
# check script we generate ourselves (see _classify_script).
_SUPPORTED_TOP_KEYS = {"global_defs", "vrrp_script", "vrrp_instance"}
# global_defs entries our render emits. An operator's file usually carries more (notification
# email, router_id, ...) and losing those is a real change, so they are reported too.
_SUPPORTED_GLOBAL_KEYS = {"enable_script_security", "script_user"}
_IDENT_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
class KeepalivedParseError(ValueError):
"""The text is not a keepalived.conf we can reason about (unbalanced braces etc.)."""
# ---------------------------------------------------------------------------
# Tokenizer / block reader
# ---------------------------------------------------------------------------
def _strip_comment(line: str) -> str:
"""Drop a trailing comment. keepalived treats BOTH `#` and `!` as comment starters, and
neither is meaningful inside the quoted script paths we care about, so a quote-aware
scan is enough (a `#` inside quotes stays)."""
out: List[str] = []
quote: Optional[str] = None
for ch in line:
if quote:
out.append(ch)
if ch == quote:
quote = None
continue
if ch in ('"', "'"):
quote = ch
out.append(ch)
continue
if ch in ("#", "!"):
break
out.append(ch)
return "".join(out)
def _split_tokens(line: str) -> List[str]:
"""Whitespace split that keeps quoted strings whole and isolates braces, so
`virtual_ipaddress { 10.0.0.1/24 dev eth0 }` tokenizes the same as its multi-line form."""
tokens: List[str] = []
buf: List[str] = []
quote: Optional[str] = None
def flush() -> None:
if buf:
tokens.append("".join(buf))
buf.clear()
for ch in line:
if quote:
if ch == quote:
quote = None
else:
buf.append(ch)
continue
if ch in ('"', "'"):
quote = ch
continue
if ch.isspace():
flush()
elif ch in ("{", "}"):
flush()
tokens.append(ch)
else:
buf.append(ch)
flush()
return tokens
def _read_blocks(text: str) -> List[Dict[str, Any]]:
"""Parse the file into nested entries.
Each entry is either
{"kind": "block", "name": str, "args": [str], "body": [entries], "line": int}
{"kind": "line", "tokens": [str], "line": int}
Line boundaries matter: inside `virtual_ipaddress` and `unicast_peer` each line is one
bare value, so a flat token stream could not tell two addresses apart.
"""
root: List[Dict[str, Any]] = []
stack: List[List[Dict[str, Any]]] = [root]
# Blocks whose opening `{` we have seen, so a stray `}` can be reported with context.
open_blocks: List[str] = []
for lineno, raw in enumerate(text.splitlines(), start=1):
pending: List[str] = []
for tok in _split_tokens(_strip_comment(raw)):
if tok == "{":
name = pending[0] if pending else ""
args = pending[1:]
block = {"kind": "block", "name": name, "args": args, "body": [], "line": lineno}
stack[-1].append(block)
stack.append(block["body"])
open_blocks.append(name)
pending = []
elif tok == "}":
if pending:
stack[-1].append({"kind": "line", "tokens": pending, "line": lineno})
pending = []
if len(stack) == 1:
raise KeepalivedParseError(f"unbalanced '}}' on line {lineno}")
stack.pop()
open_blocks.pop()
else:
pending.append(tok)
if pending:
stack[-1].append({"kind": "line", "tokens": pending, "line": lineno})
if len(stack) != 1:
raise KeepalivedParseError(f"unclosed block '{open_blocks[-1] or '?'}' at end of file")
return root
# ---------------------------------------------------------------------------
# Interpretation
# ---------------------------------------------------------------------------
def _as_int(tokens: List[str]) -> Optional[int]:
if len(tokens) < 2:
return None
try:
return int(tokens[1])
except (TypeError, ValueError):
return None
def _parse_vip_entry(tokens: List[str]) -> Optional[Dict[str, Any]]:
"""One `virtual_ipaddress` line: `<addr>[/<prefix>] [dev <iface>] [label ...]`.
Returns None when the first token is not an address a shape we do not understand must
surface as unsupported rather than be silently dropped.
"""
spec = tokens[0]
addr, _, prefix = spec.partition("/")
try:
ip = ipaddress.ip_address(addr)
except ValueError:
return None
entry: Dict[str, Any] = {
"address": str(ip),
"prefix_length": None,
"dev": None,
"extra": [],
}
if prefix:
try:
entry["prefix_length"] = int(prefix)
except ValueError:
return None
rest = tokens[1:]
i = 0
while i < len(rest):
if rest[i] == "dev" and i + 1 < len(rest):
entry["dev"] = rest[i + 1]
i += 2
continue
# `label`, `scope`, `brd`, ... — all real directives we do not render.
entry["extra"].append(rest[i])
i += 1
return entry
def _classify_script(block: Dict[str, Any]) -> Tuple[str, Optional[str]]:
"""Return (name, script_path) for a vrrp_script block."""
name = block["args"][0] if block["args"] else (block["name"] or "")
path = None
for entry in block["body"]:
if entry["kind"] == "line" and entry["tokens"] and entry["tokens"][0] == "script":
path = " ".join(entry["tokens"][1:]) or None
return name, path
def _parse_instance(block: Dict[str, Any]) -> Dict[str, Any]:
"""Interpret one `vrrp_instance` block into VIP-model fields plus its own unsupported list."""
inst: Dict[str, Any] = {
"instance_name": block["args"][0] if block["args"] else "",
"state": None,
"interface": None,
"virtual_router_id": None,
"priority": None,
"advert_int": None,
"auth_type": None,
"auth_pass": None,
"unicast_src_ip": None,
"unicast_peers": [],
"virtual_ips": [],
"track_scripts": [],
"unsupported": [],
"line": block["line"],
}
def unsupported(what: str, lineno: int) -> None:
inst["unsupported"].append({"directive": what, "line": lineno})
for entry in block["body"]:
if entry["kind"] == "line":
tokens = entry["tokens"]
key = tokens[0]
if key == "state":
inst["state"] = (tokens[1].upper() if len(tokens) > 1 else None)
elif key == "interface":
inst["interface"] = tokens[1] if len(tokens) > 1 else None
elif key == "virtual_router_id":
inst["virtual_router_id"] = _as_int(tokens)
elif key == "priority":
inst["priority"] = _as_int(tokens)
elif key == "advert_int":
# keepalived accepts sub-second floats; our model column is an integer.
raw = tokens[1] if len(tokens) > 1 else ""
try:
val = float(raw)
except (TypeError, ValueError):
val = None
if val is None:
unsupported(f"advert_int {raw}", entry["line"])
elif val != int(val):
# Rounding would change VRRP timing, so refuse rather than adopt-and-alter.
unsupported(f"advert_int {raw} (fractional; model stores whole seconds)",
entry["line"])
else:
inst["advert_int"] = int(val)
elif key == "unicast_src_ip":
inst["unicast_src_ip"] = tokens[1] if len(tokens) > 1 else None
else:
unsupported(" ".join(tokens), entry["line"])
continue
name = entry["name"]
if name == "authentication":
for sub in entry["body"]:
if sub["kind"] != "line" or not sub["tokens"]:
continue
k = sub["tokens"][0]
if k == "auth_type":
inst["auth_type"] = (sub["tokens"][1].upper() if len(sub["tokens"]) > 1 else None)
elif k == "auth_pass":
# Everything after the keyword: a VRRP password may contain spaces.
inst["auth_pass"] = " ".join(sub["tokens"][1:]) or None
else:
unsupported(f"authentication/{' '.join(sub['tokens'])}", sub["line"])
elif name == "unicast_peer":
for sub in entry["body"]:
if sub["kind"] == "line" and sub["tokens"]:
inst["unicast_peers"].append(sub["tokens"][0])
else:
unsupported("unicast_peer/<block>", entry["line"])
elif name == "virtual_ipaddress":
for sub in entry["body"]:
if sub["kind"] != "line" or not sub["tokens"]:
unsupported("virtual_ipaddress/<block>", entry["line"])
continue
parsed = _parse_vip_entry(sub["tokens"])
if parsed is None:
unsupported(f"virtual_ipaddress/{' '.join(sub['tokens'])}", sub["line"])
else:
if parsed["extra"]:
unsupported(
f"virtual_ipaddress/{parsed['address']} "
f"({' '.join(parsed['extra'])})", sub["line"])
inst["virtual_ips"].append(parsed)
elif name == "track_script":
for sub in entry["body"]:
if sub["kind"] == "line" and sub["tokens"]:
inst["track_scripts"].append(sub["tokens"][0])
else:
unsupported(f"{name} {{...}}", entry["line"])
return inst
def parse_keepalived_conf(text: str) -> Dict[str, Any]:
"""Parse a keepalived.conf into VIP-model fields plus everything we could not model.
Raises KeepalivedParseError on structurally broken input. Never log the result: parsed
instances carry `auth_pass` in cleartext.
"""
root = _read_blocks(text or "")
result: Dict[str, Any] = {
"instances": [],
"scripts": {},
"global_defs": {},
"unsupported": [], # top-level directives our render would drop
"sync_groups": [],
}
for entry in root:
if entry["kind"] == "line":
# A bare top-level directive (e.g. `include /etc/keepalived/conf.d/*.conf`).
result["unsupported"].append(
{"directive": " ".join(entry["tokens"]), "line": entry["line"]})
continue
name = entry["name"]
if name == "global_defs":
for sub in entry["body"]:
if sub["kind"] == "line" and sub["tokens"]:
key = sub["tokens"][0]
result["global_defs"][key] = " ".join(sub["tokens"][1:])
if key not in _SUPPORTED_GLOBAL_KEYS:
result["unsupported"].append(
{"directive": f"global_defs/{' '.join(sub['tokens'])}",
"line": sub["line"]})
else:
result["unsupported"].append(
{"directive": f"global_defs/{sub.get('name', '?')} {{...}}",
"line": sub["line"]})
elif name == "vrrp_script":
script_name, path = _classify_script(entry)
result["scripts"][script_name] = {"script": path, "line": entry["line"]}
elif name == "vrrp_instance":
result["instances"].append(_parse_instance(entry))
elif name == "vrrp_sync_group":
# A sync group ties instances together so they fail over as a unit. Our render has
# no equivalent, and dropping it changes failover semantics — never adopt silently.
group = entry["args"][0] if entry["args"] else ""
result["sync_groups"].append({"name": group, "line": entry["line"]})
result["unsupported"].append(
{"directive": f"vrrp_sync_group {group}", "line": entry["line"]})
else:
# virtual_server (LVS), static_routes, bfd_instance, ...
args = " ".join(entry["args"])
result["unsupported"].append(
{"directive": f"{name} {args} {{...}}".replace(" ", " "), "line": entry["line"]})
return result
# ---------------------------------------------------------------------------
# Mapping to the VIP model + the adoption gate
# ---------------------------------------------------------------------------
# keepalived defaults we are willing to apply when a directive is absent, because the value
# is unambiguous and re-rendering it changes nothing on the wire.
_DEFAULT_ADVERT_INT = 1
_DEFAULT_PRIORITY = 100
_DEFAULT_STATE = "BACKUP"
# The only track_script our renderer emits (keepalived_config.build_haproxy_check_script).
OUR_CHECK_SCRIPT_NAME = "chk_haproxy"
def build_adoption_candidate(parsed: Dict[str, Any], instance: Dict[str, Any]) -> Dict[str, Any]:
"""Map one parsed `vrrp_instance` onto vip_instances / vip_members fields.
Returns `adoptable` plus `blockers`. A blocker means taking ownership would change what
is running either because our render cannot reproduce something in the file, or because
a value we must write is not knowable from the file. Adoption REPLACES the operator's
config, so "we could not read it" and "we would change it" are the same hazard, and both
have to stop the flow rather than be logged.
Never log the return value: `vip.auth_pass` is cleartext.
"""
blockers: List[str] = []
# Directives we would drop. Report the file's own line numbers so the operator can look.
dropped = list(parsed.get("unsupported") or []) + list(instance.get("unsupported") or [])
for d in dropped:
blockers.append(
f"line {d['line']}: `{d['directive']}` — OpenManager's renderer cannot reproduce "
f"this, so adopting would delete it")
vips = instance.get("virtual_ips") or []
if len(vips) == 0:
blockers.append("the instance declares no virtual_ipaddress — nothing to adopt")
elif len(vips) > 1:
addrs = ", ".join(v["address"] for v in vips)
blockers.append(
f"the instance carries {len(vips)} addresses ({addrs}); a managed VIP holds exactly "
f"one, so adopting would drop all but the first")
vip_entry = vips[0] if vips else None
if instance.get("virtual_router_id") is None:
blockers.append("no virtual_router_id — it cannot be guessed: a wrong VRID puts the "
"nodes in separate VRRP domains and both would claim the VIP")
if not instance.get("interface"):
blockers.append("no interface — required to render the instance and the address")
# An explicit prefix is required. Our renderer ALWAYS writes `<addr>/<prefix>`, the model
# column defaults to 24, and keepalived's own default for a bare address is a host route.
# Picking either one for the operator would silently change the VIP's netmask, so ask.
if vip_entry is not None and vip_entry.get("prefix_length") is None:
blockers.append(
f"`{vip_entry['address']}` has no explicit prefix length; state it during adoption "
f"so the netmask cannot change on takeover")
# The address must live on the instance's interface — that is the only `dev` we can render.
if vip_entry is not None and vip_entry.get("dev") and instance.get("interface") \
and vip_entry["dev"] != instance["interface"]:
blockers.append(
f"the address is bound to `dev {vip_entry['dev']}` but the instance uses "
f"`interface {instance['interface']}`; the render always uses the instance interface")
auth_type = instance.get("auth_type")
if auth_type not in (None, "PASS"):
blockers.append(f"auth_type {auth_type} is not supported (only PASS is rendered)")
# A tracked script that is not ours would be replaced by our HAProxy check.
tracked = [t for t in (instance.get("track_scripts") or [])]
foreign = [t for t in tracked if t != OUR_CHECK_SCRIPT_NAME]
if foreign:
blockers.append(
f"track_script {', '.join(foreign)} would be replaced by OpenManager's HAProxy "
f"health check")
state = instance.get("state") or _DEFAULT_STATE
if state not in ("MASTER", "BACKUP"):
blockers.append(f"state {state} is not MASTER or BACKUP")
peers = list(instance.get("unicast_peers") or [])
src = instance.get("unicast_src_ip")
# Our renderer emits unicast_src_ip and unicast_peer together, or neither.
if bool(src) != bool(peers):
which = "unicast_src_ip without unicast_peer" if src else "unicast_peer without unicast_src_ip"
blockers.append(f"{which} — the render emits both or neither")
candidate: Dict[str, Any] = {
"instance_name": instance.get("instance_name") or "",
"adoptable": not blockers,
"blockers": blockers,
"dropped_directives": dropped,
"vip": {
"virtual_ip": vip_entry["address"] if vip_entry else None,
"prefix_length": vip_entry.get("prefix_length") if vip_entry else None,
"virtual_router_id": instance.get("virtual_router_id"),
"advert_int": instance.get("advert_int") if instance.get("advert_int") is not None
else _DEFAULT_ADVERT_INT,
"use_unicast": bool(peers),
"track_haproxy": OUR_CHECK_SCRIPT_NAME in tracked,
"auth_pass": instance.get("auth_pass"),
},
"member": {
"network_interface": instance.get("interface"),
"role": state,
"priority": instance.get("priority") if instance.get("priority") is not None
else _DEFAULT_PRIORITY,
},
"peers": peers,
"unicast_src_ip": src,
# Which values came from a keepalived default rather than the file, so the UI can say so.
"defaulted": [
k for k, present in (
("advert_int", instance.get("advert_int") is not None),
("priority", instance.get("priority") is not None),
("state", instance.get("state") is not None),
) if not present
],
}
return candidate
# Substrings that identify the two blocker classes an operator is allowed to resolve. They are
# matched rather than typed because the blocker text is what the UI shows; keeping the marker in
# the sentence means the message and the rule cannot drift apart.
_LOSS_MARKER = "would delete it"
_PREFIX_MARKER = "no explicit prefix length"
def remaining_blockers(blockers: List[str], *, prefix_supplied: bool = False,
accept_data_loss: bool = False) -> List[str]:
"""Blockers that survive what the operator is permitted to resolve.
Exactly two classes are resolvable, and the distinction is the whole safety argument:
* a missing prefix length is *unknown*, and the operator can supply it we refuse to pick
a netmask for a live VIP ourselves;
* "our renderer cannot reproduce this, so adopting would delete it" is a *loss*, and losing
it can be an informed choice.
Everything else an unknown virtual_router_id, a fractional advert_int, an unsupported
auth_type, an address on a different interface is neither unknown nor a loss but an
impossibility, and no flag may wave it through. This is the single source of truth for that
rule; the endpoint and the UI both derive from it.
"""
out: List[str] = []
for b in blockers or []:
if prefix_supplied and _PREFIX_MARKER in b:
continue
if accept_data_loss and _LOSS_MARKER in b:
continue
out.append(b)
return out
def analyse_keepalived_conf(text: str) -> Dict[str, Any]:
"""Parse + map in one call: the shape the discovery endpoint stores and the UI renders."""
parsed = parse_keepalived_conf(text)
return {
"instance_count": len(parsed["instances"]),
"sync_groups": parsed["sync_groups"],
"global_defs": parsed["global_defs"],
"candidates": [build_adoption_candidate(parsed, inst) for inst in parsed["instances"]],
}
+341
View File
@@ -0,0 +1,341 @@
"""Issue #27 follow-up (v1.10.4) — unit tests for parsing an EXISTING keepalived.conf so a
hand-maintained VIP can be adopted.
Pure-function tests; no DB, no network. The parser exists because the heartbeat carries only
the VIP address and a best-effort MASTER/BACKUP, while rendering a node's config needs eleven
fields and because adoption REPLACES the operator's file, so anything our renderer cannot
reproduce has to be reported as a blocker rather than silently dropped.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-keepalived-parser-tests")
from services import keepalived_config as kc # noqa: E402
from services.keepalived_parser import ( # noqa: E402
KeepalivedParseError, analyse_keepalived_conf, build_adoption_candidate,
parse_keepalived_conf,
)
# A realistic hand-maintained config: two nodes, unicast VRRP, password auth, HAProxy check.
HANDWRITTEN = """\
! Configuration File for keepalived
global_defs {
enable_script_security
script_user root
}
vrrp_script chk_haproxy {
script "/etc/keepalived/check_haproxy.sh"
interval 2
weight -21
}
vrrp_instance VI_1 {
state MASTER
interface eth0 # public leg
virtual_router_id 51
priority 150
advert_int 1
authentication {
auth_type PASS
auth_pass s3cr3t
}
unicast_src_ip 10.0.0.11
unicast_peer {
10.0.0.12
}
virtual_ipaddress {
10.0.0.100/24 dev eth0
}
track_script {
chk_haproxy
}
}
"""
def _only_candidate(text):
parsed = parse_keepalived_conf(text)
assert len(parsed["instances"]) == 1
return build_adoption_candidate(parsed, parsed["instances"][0])
def test_parses_a_handwritten_config_into_model_fields():
cand = _only_candidate(HANDWRITTEN)
assert cand["adoptable"] is True, cand["blockers"]
assert cand["blockers"] == []
assert cand["vip"] == {
"virtual_ip": "10.0.0.100",
"prefix_length": 24,
"virtual_router_id": 51,
"advert_int": 1,
"use_unicast": True,
"track_haproxy": True,
"auth_pass": "s3cr3t",
}
assert cand["member"] == {"network_interface": "eth0", "role": "MASTER", "priority": 150}
assert cand["peers"] == ["10.0.0.12"] and cand["unicast_src_ip"] == "10.0.0.11"
assert cand["defaulted"] == [] # every value came from the file, nothing assumed
def test_comment_and_layout_variants():
# `!` and `#` both start comments; a block may open and close on one line; a quoted
# script path keeps its spaces. None of this may change the parse.
text = """\
#!/not/a/shebang — this whole line is a comment
vrrp_script chk { script "/opt/my scripts/chk.sh" }
vrrp_instance VI_1 { state BACKUP
interface eth1 ! trailing bang comment
virtual_router_id 7
priority 90
virtual_ipaddress { 192.168.5.9/32 dev eth1 }
}
"""
parsed = parse_keepalived_conf(text)
assert parsed["scripts"]["chk"]["script"] == "/opt/my scripts/chk.sh"
inst = parsed["instances"][0]
assert inst["state"] == "BACKUP" and inst["interface"] == "eth1"
assert inst["virtual_router_id"] == 7 and inst["priority"] == 90
assert inst["virtual_ips"] == [
{"address": "192.168.5.9", "prefix_length": 32, "dev": "eth1", "extra": []}
]
def test_documented_defaults_are_applied_and_flagged():
# keepalived's own defaults for absent directives. Applying them re-renders the same
# behaviour, so they are allowed — but the UI must be able to say they were assumed.
text = """\
vrrp_instance VI_1 {
interface eth0
virtual_router_id 12
virtual_ipaddress { 10.1.1.5/24 dev eth0 }
}
"""
cand = _only_candidate(text)
assert cand["adoptable"] is True, cand["blockers"]
assert cand["member"]["role"] == "BACKUP" and cand["member"]["priority"] == 100
assert cand["vip"]["advert_int"] == 1
assert sorted(cand["defaulted"]) == ["advert_int", "priority", "state"]
# No authentication block and no track_script — both legal, both faithfully represented.
assert cand["vip"]["auth_pass"] is None and cand["vip"]["track_haproxy"] is False
assert cand["vip"]["use_unicast"] is False
def _blockers_for(text):
return " | ".join(_only_candidate(text)["blockers"])
def test_directives_we_cannot_render_block_adoption():
# THE central safety property: adoption overwrites the file, so a failover hook we do not
# render would be destroyed. It must stop the flow, not warn.
text = HANDWRITTEN.replace(
" track_script {", ' notify_master "/usr/local/bin/promote.sh"\n track_script {')
blockers = _blockers_for(text)
assert "notify_master" in blockers and "would delete it" in blockers
assert _only_candidate(text)["adoptable"] is False
def test_multiple_addresses_in_one_instance_block_adoption():
text = HANDWRITTEN.replace(" 10.0.0.100/24 dev eth0",
" 10.0.0.100/24 dev eth0\n 10.0.0.101/24 dev eth0")
blockers = _blockers_for(text)
assert "2 addresses" in blockers and "10.0.0.101" in blockers
def test_missing_vrid_blocks_adoption_with_the_split_brain_reason():
text = HANDWRITTEN.replace(" virtual_router_id 51\n", "")
blockers = _blockers_for(text)
assert "no virtual_router_id" in blockers and "separate VRRP domains" in blockers
def test_missing_prefix_blocks_adoption():
# Our renderer always writes an explicit prefix; guessing one would change the netmask of a
# live VIP, so the operator has to state it.
text = HANDWRITTEN.replace("10.0.0.100/24 dev eth0", "10.0.0.100 dev eth0")
blockers = _blockers_for(text)
assert "no explicit prefix length" in blockers
def test_address_on_a_different_dev_blocks_adoption():
text = HANDWRITTEN.replace("10.0.0.100/24 dev eth0", "10.0.0.100/24 dev eth1")
blockers = _blockers_for(text)
assert "dev eth1" in blockers and "interface eth0" in blockers
def test_foreign_track_script_blocks_adoption():
text = HANDWRITTEN.replace(" chk_haproxy", " chk_custom")
blockers = _blockers_for(text)
assert "chk_custom" in blockers and "replaced by OpenManager" in blockers
def test_unsupported_auth_type_blocks_adoption():
text = HANDWRITTEN.replace("auth_type PASS", "auth_type AH")
assert "auth_type AH" in _blockers_for(text)
def test_fractional_advert_int_blocks_adoption():
# Rounding 0.5s to 1s changes VRRP timing, so adopt-and-alter is not acceptable.
text = HANDWRITTEN.replace("advert_int 1", "advert_int 0.5")
blockers = _blockers_for(text)
assert "advert_int 0.5" in blockers and "fractional" in blockers
def test_half_configured_unicast_blocks_adoption():
text = HANDWRITTEN.replace(" unicast_peer {\n 10.0.0.12\n }\n", "")
assert "unicast_src_ip without unicast_peer" in _blockers_for(text)
def test_sync_group_and_lvs_sections_block_adoption():
text = HANDWRITTEN + """
vrrp_sync_group VG1 {
group {
VI_1
}
}
virtual_server 10.0.0.100 80 {
lb_algo rr
}
"""
parsed = parse_keepalived_conf(text)
assert [g["name"] for g in parsed["sync_groups"]] == ["VG1"]
directives = " ".join(d["directive"] for d in parsed["unsupported"])
assert "vrrp_sync_group VG1" in directives and "virtual_server" in directives
# Both are top-level, so EVERY candidate in the file is blocked — a sync group changes
# failover semantics for the instances it groups.
cand = build_adoption_candidate(parsed, parsed["instances"][0])
assert cand["adoptable"] is False
def test_extra_global_defs_are_reported_as_losses():
text = HANDWRITTEN.replace(" script_user root",
" script_user root\n router_id LVS_DEVEL")
parsed = parse_keepalived_conf(text)
directives = " ".join(d["directive"] for d in parsed["unsupported"])
assert "global_defs/router_id LVS_DEVEL" in directives
assert parsed["global_defs"]["router_id"] == "LVS_DEVEL"
def test_multiple_instances_yield_one_candidate_each():
text = HANDWRITTEN + """
vrrp_instance VI_2 {
state BACKUP
interface eth0
virtual_router_id 52
priority 100
advert_int 1
virtual_ipaddress { 10.0.0.200/24 dev eth0 }
}
"""
analysed = analyse_keepalived_conf(text)
assert analysed["instance_count"] == 2
names = [c["instance_name"] for c in analysed["candidates"]]
assert names == ["VI_1", "VI_2"]
assert [c["vip"]["virtual_ip"] for c in analysed["candidates"]] == ["10.0.0.100", "10.0.0.200"]
assert all(c["adoptable"] for c in analysed["candidates"])
def test_unbalanced_braces_raise():
for bad in ("vrrp_instance VI_1 {\n state MASTER\n", "}\n"):
raised = False
try:
parse_keepalived_conf(bad)
except KeepalivedParseError:
raised = True
assert raised, f"should have raised for {bad!r}"
def test_our_own_render_round_trips_with_zero_blockers():
"""The invariant that keeps the parser honest: a config WE generated must parse back into
the same model with nothing unsupported. If a future change to render_keepalived_conf emits
a directive the parser does not know, this fails instead of adoption silently reporting
that OpenManager's own output is unadoptable."""
vip = {"id": 3, "name": "web-vip", "virtual_ip": "10.0.0.100", "prefix_length": 24,
"virtual_router_id": 51, "advert_int": 1, "use_unicast": True, "track_haproxy": True}
members = [{"role": "MASTER", "priority": 150, "network_interface": "eth0",
"agent_id": 1, "ip_address": "10.0.0.11"},
{"role": "BACKUP", "priority": 100, "network_interface": "eth0",
"agent_id": 2, "ip_address": "10.0.0.12"}]
rendered = kc.render_keepalived_conf(
vip=vip, members=members, this_agent=members[0],
peer_ips=["10.0.0.12"], auth_pass_plain="s3cr3t")
cand = _only_candidate(rendered)
assert cand["adoptable"] is True, cand["blockers"]
assert cand["vip"]["virtual_ip"] == vip["virtual_ip"]
assert cand["vip"]["prefix_length"] == vip["prefix_length"]
assert cand["vip"]["virtual_router_id"] == vip["virtual_router_id"]
assert cand["vip"]["track_haproxy"] is True and cand["vip"]["use_unicast"] is True
assert cand["member"] == {"network_interface": "eth0", "role": "MASTER", "priority": 150}
assert cand["vip"]["auth_pass"] == "s3cr3t"
# And the same for the no-auth / multicast / untracked shape, which renders fewer blocks.
plain = kc.render_keepalived_conf(
vip={**vip, "use_unicast": False, "track_haproxy": False},
members=members, this_agent=members[1], peer_ips=[], auth_pass_plain=None)
cand2 = _only_candidate(plain)
assert cand2["adoptable"] is True, cand2["blockers"]
assert cand2["vip"]["use_unicast"] is False and cand2["vip"]["track_haproxy"] is False
assert cand2["vip"]["auth_pass"] is None
# --- v1.10.4 adoption gate: which blockers an operator may resolve --------------------------
def test_only_prefix_and_data_loss_are_waivable():
from services.keepalived_parser import remaining_blockers
loss = "line 9: `notify_master \"/x.sh\"` — OpenManager's renderer cannot reproduce this, so adopting would delete it"
prefix = "`10.0.0.5` has no explicit prefix length; state it during adoption so the netmask cannot change on takeover"
hard_vrid = "no virtual_router_id — it cannot be guessed: a wrong VRID puts the nodes in separate VRRP domains"
hard_auth = "auth_type AH is not supported (only PASS is rendered)"
all_four = [loss, prefix, hard_vrid, hard_auth]
# Nothing waived: everything survives.
assert remaining_blockers(all_four) == all_four
# A supplied prefix resolves ONLY the prefix blocker.
assert remaining_blockers(all_four, prefix_supplied=True) == [loss, hard_vrid, hard_auth]
# Accepting data loss resolves ONLY the loss blocker.
assert remaining_blockers(all_four, accept_data_loss=True) == [prefix, hard_vrid, hard_auth]
# Both together still cannot wave through an impossibility — this is the property that stops
# a UI flag from destroying a VIP whose VRID or auth_type we could not reproduce.
assert remaining_blockers(all_four, prefix_supplied=True, accept_data_loss=True) == \
[hard_vrid, hard_auth]
# And an adoptable candidate stays adoptable.
assert remaining_blockers([]) == []
def test_waiver_markers_match_the_messages_the_parser_actually_emits():
# The gate matches on substrings of the blocker prose, so a reworded message would silently
# stop being waivable. Pin both directions against real parser output.
from services.keepalived_parser import remaining_blockers
no_prefix = HANDWRITTEN.replace("10.0.0.100/24 dev eth0", "10.0.0.100 dev eth0")
blockers = _only_candidate(no_prefix)["blockers"]
assert blockers, "expected a prefix blocker"
assert remaining_blockers(blockers, prefix_supplied=True) == []
with_hook = HANDWRITTEN.replace(
" track_script {", ' notify_master "/usr/local/bin/promote.sh"\n track_script {')
blockers = _only_candidate(with_hook)["blockers"]
assert blockers, "expected a data-loss blocker"
assert remaining_blockers(blockers, accept_data_loss=True) == []
def test_auth_pass_masking_leaves_no_trace_of_the_secret():
# The discovered config is stored and served to the UI, so the ingest endpoint masks the VRRP
# password. Reuse the router's own regex so the test breaks if it is loosened.
from routers.agent import _AUTH_PASS_MASK_RE
secret = "s3cr3t with spaces"
text = HANDWRITTEN.replace("auth_pass s3cr3t", f"auth_pass {secret}")
masked = _AUTH_PASS_MASK_RE.sub(r"\1********", text)
assert secret not in masked and "s3cr3t" not in masked
assert "auth_pass ********" in masked
# Everything else survives, so the preview is still useful.
assert "virtual_router_id 51" in masked and "10.0.0.100/24 dev eth0" in masked
+61 -3
View File
@@ -1711,6 +1711,45 @@ fetch_and_deploy_keepalived_config() {
chk="$(dirname "$conf")/check_haproxy.sh"
if [[ -f "$conf" ]] && grep -q "$marker" "$conf" 2>/dev/null; then we_own="true"; fi
# v1.10.4 — VIP adoption discovery. Report a keepalived.conf we do NOT own so an existing
# VIP can be adopted from the UI instead of retyped. STRICTLY READ-ONLY: this never writes
# to the node. The heartbeat cannot carry this — it has the VIP address and a best-effort
# MASTER/BACKUP, while rendering a node's config needs eleven fields.
#
# Rate limited by content: the hash of the last report is cached next to the config, so the
# file (which may contain the VRRP password) is posted only when it actually changes, not on
# every cycle. Once we own the file there is nothing to adopt, so the record is cleared once.
_kp_discover() {
local cache="$(dirname "$conf")/.hom_discovery_hash" cur_hash="" body content_json
if [[ "$we_own" == "true" || ! -f "$conf" ]]; then
# Nothing adoptable here. Clear a previous report exactly once.
[[ -f "$cache" ]] || return 0
curl -k -s --connect-timeout 10 --max-time 30 -X POST \
"$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-discovery" \
-H "X-API-Key: $AGENT_TOKEN" -H "Content-Type: application/json" \
-d "{\"config_path\":\"$conf\",\"exists\":false}" >/dev/null 2>&1 || return 0
rm -f "$cache"
return 0
fi
cur_hash=$(md5sum "$conf" 2>/dev/null | awk '{print $1}')
[[ -z "$cur_hash" ]] && return 0
[[ -f "$cache" && "$(cat "$cache" 2>/dev/null)" == "$cur_hash" ]] && return 0
# jq -Rs makes the file a single JSON string with its newlines intact, so the content the
# server hashes is byte-identical to what is on disk — the takeover authorisation is
# pinned to that hash.
content_json=$(jq -Rs . < "$conf" 2>/dev/null) || return 0
body=$(jq -n --arg p "$conf" --argjson c "$content_json" \
'{config_path:$p, exists:true, is_managed:false, config_content:$c}' 2>/dev/null) || return 0
if curl -k -s --connect-timeout 10 --max-time 30 -o /dev/null -X POST \
"$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-discovery" \
-H "X-API-Key: $AGENT_TOKEN" -H "Content-Type: application/json" \
--data-binary "$body" 2>/dev/null; then
printf '%s' "$cur_hash" > "$cache" 2>/dev/null
log "INFO" "KEEPALIVED: reported an unmanaged keepalived.conf for adoption"
fi
}
_kp_discover
_kp_report() { # $1=state $2=vip_id(or empty) $3=hash $4=message
local vid="${2:-null}"; [[ -z "$2" ]] && vid="null"
curl -k -s --connect-timeout 10 --max-time 30 -X POST "$MANAGEMENT_URL/api/agents/$AGENT_NAME/keepalived-status" \
@@ -1774,10 +1813,29 @@ fetch_and_deploy_keepalived_config() {
[[ -z "$new_conf" ]] && return 0
# Ownership guard: never overwrite a keepalived.conf we don't own.
#
# v1.10.4 adoption is the ONE exception, and it does not weaken the guard: the server
# authorises a single takeover of a specific file by pinning the md5 the operator adopted
# from. We overwrite only when that hash still matches what is on disk, so a config edited
# between adoption and Apply is still refused — the operator's later edit wins over a stale
# adoption rather than being silently destroyed.
if [[ -f "$conf" && "$we_own" != "true" ]]; then
log "WARN" "KEEPALIVED: $conf is externally managed — refusing to overwrite"
_kp_report "externally_managed" "$vip_id" "" "pre-existing unmanaged keepalived.conf"
return 0
local allow_takeover expected_hash disk_hash
allow_takeover=$(echo "$resp" | jq -r '.keepalived.allow_takeover // false' 2>/dev/null)
expected_hash=$(echo "$resp" | jq -r '.keepalived.takeover_expected_hash // empty' 2>/dev/null)
disk_hash=$(md5sum "$conf" 2>/dev/null | awk '{print $1}')
if [[ "$allow_takeover" == "true" && -n "$expected_hash" && "$disk_hash" == "$expected_hash" ]]; then
log "INFO" "KEEPALIVED: adopting $conf (one-shot takeover authorised; on-disk hash matches)"
elif [[ "$allow_takeover" == "true" ]]; then
log "WARN" "KEEPALIVED: adoption authorised but $conf changed since it was adopted — refusing"
_kp_report "externally_managed" "$vip_id" "$disk_hash" \
"config changed after adoption; re-adopt to pick up the current file"
return 0
else
log "WARN" "KEEPALIVED: $conf is externally managed — refusing to overwrite"
_kp_report "externally_managed" "$vip_id" "" "pre-existing unmanaged keepalived.conf"
return 0
fi
fi
# Hybrid install: install keepalived only if missing.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.10.3",
"releaseName": "Multi-account ACME — the wizard honours the selected account",
"version": "1.10.4",
"releaseName": "Adopt an existing keepalived VIP into HA/VIP management",
"releaseDate": "2026-08-08"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.10.3",
"version": "1.10.4",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
+260 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Button, Space, Modal, Form, Input, InputNumber, Select, Tag, message,
Switch, Typography, Card, Alert, Tooltip, Spin
Switch, Typography, Card, Alert, Tooltip, Spin, Checkbox
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, WarningOutlined,
@@ -46,6 +46,23 @@ const parseArr = (v) => {
return [];
};
// v1.10.4 — adoption blockers come back as prose from the parser. Two classes are resolvable by
// the operator and the rest are not, so the modal has to tell them apart:
// * `loss` — "our renderer cannot reproduce this, so adopting would delete it". A deliberate
// choice, waivable with an explicit tick.
// * `prefix` — the address has no explicit prefix length. Supplying it resolves the blocker;
// we never guess a netmask for a live VIP.
// * `hard` — an unknown VRID, a fractional advert_int, an unsupported auth_type. Not losses
// but impossibilities; nothing in the UI may override them.
const splitBlockers = (blockers) => {
const list = blockers || [];
return {
loss: list.filter((b) => b.includes('would delete it')),
prefix: list.filter((b) => b.includes('no explicit prefix length')),
hard: list.filter((b) => !b.includes('would delete it') && !b.includes('no explicit prefix length')),
};
};
// This component uses raw fetch(), but extractApiError expects an axios-shaped error
// (err.response.data). Read the fetch Response body and reuse the envelope-aware extractor
// so backend messages — e.g. the 409 "node already in VIP X" — actually reach the user.
@@ -67,6 +84,12 @@ const VIPManagement = () => {
// Delete confirmation (with opt-in package uninstall) + diagnostics modal state.
const [deleteTarget, setDeleteTarget] = useState(null);
const [showL2Note, setShowL2Note] = useState(false);
// v1.10.4 — VIP adoption from what the agents found on their nodes.
const [discoveries, setDiscoveries] = useState([]);
const [adoptTarget, setAdoptTarget] = useState(null); // { discovery, candidate }
const [adoptAcceptLoss, setAdoptAcceptLoss] = useState(false);
const [adopting, setAdopting] = useState(false);
const [adoptForm] = Form.useForm();
const [diagVip, setDiagVip] = useState(null);
const [diagData, setDiagData] = useState(null);
const [diagLoading, setDiagLoading] = useState(false);
@@ -98,11 +121,77 @@ const VIPManagement = () => {
}
}, []);
// v1.10.4 — keepalived configs the agents found on their nodes but do NOT manage. This is why
// the page could be empty on a fleet that already runs keepalived: the flow was one-way, so
// nothing ever read what was already there.
const fetchDiscoveries = useCallback(async () => {
try {
const res = await fetch('/api/vip/discoveries', { headers: authHeaders() });
if (!res.ok) { setDiscoveries([]); return; }
const data = await res.json();
setDiscoveries((data.discoveries || []).filter((d) => !d.is_managed && !d.adopted_vip_id));
} catch (e) {
console.error('fetchDiscoveries failed', e);
}
}, []);
useEffect(() => {
fetchVips();
const t = setInterval(fetchVips, 30000); // live MASTER/BACKUP via existing detection pipeline
fetchDiscoveries();
const t = setInterval(() => { fetchVips(); fetchDiscoveries(); }, 30000); // live MASTER/BACKUP via existing detection pipeline
return () => clearInterval(t);
}, [fetchVips]);
}, [fetchVips, fetchDiscoveries]);
const openAdopt = (discovery, candidate) => {
setAdoptTarget({ discovery, candidate });
setAdoptAcceptLoss(false);
adoptForm.setFieldsValue({
name: `${discovery.agent_name}-${candidate?.vip?.virtual_ip || 'vip'}`,
prefix_length: candidate?.vip?.prefix_length ?? undefined,
});
};
const submitAdopt = async () => {
if (!adoptTarget) return;
let values;
try { values = await adoptForm.validateFields(); } catch { return; }
setAdopting(true);
try {
const res = await fetch('/api/vip/adopt', {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
agent_id: adoptTarget.discovery.agent_id,
instance_name: adoptTarget.candidate.instance_name,
name: values.name,
description: values.description || undefined,
prefix_length: values.prefix_length ?? undefined,
accept_data_loss: adoptAcceptLoss || undefined,
}),
});
if (!res.ok) {
message.error(await fetchApiError(res, 'Adoption failed'), 8);
return;
}
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();
} catch (e) {
message.error('Adoption failed');
} finally {
setAdopting(false);
}
};
// Build the participating-nodes table from the pool's EXISTING agents (installed via the
// standard Agent Management process). On edit, pre-select the VIP's current members.
@@ -410,6 +499,174 @@ const VIPManagement = () => {
<Table rowKey="id" columns={columns} dataSource={vips} loading={loading} pagination={{ pageSize: 10 }} />
</Card>
{/* v1.10.4 keepalived that already exists on a node. Shown separately from managed VIPs
because OpenManager is NOT managing these: the agent found them, reported them, and
deliberately left them untouched. */}
{discoveries.length > 0 && (
<Card style={{ marginTop: 16 }} title={
<Space>
<FileSearchOutlined />
<span>Unmanaged keepalived detected on {discoveries.length} node(s)</span>
</Space>
}>
<Alert
type="info" showIcon style={{ marginBottom: 12 }}
message="These nodes already run keepalived, configured outside OpenManager"
description={
<span>
The agent read each <Text code>keepalived.conf</Text> and left it untouched nothing
on these nodes has been changed. Adopting one creates a managed VIP from the values
in that file, and the node's config is only handed over when you apply it from
Apply Management. Adoption replaces the file with OpenManager's render, so anything
it cannot reproduce is listed as a blocker rather than silently dropped.
</span>
}
/>
<Table
rowKey={(r) => `${r.agent_id}:${r.instance_name}`}
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,
}));
})}
columns={[
{ title: 'Node', dataIndex: ['discovery', 'agent_name'], key: 'agent',
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>
</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>
: <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>
</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>;
}
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);
return (
<Tooltip title={(r.candidate.blockers || []).join(' · ')}>
<Tag color={hard.length ? 'red' : 'gold'}>
{hard.length ? `${hard.length} blocker(s)` : '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>
) },
]}
/>
</Card>
)}
{/* 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'}
open={!!adoptTarget}
onCancel={() => setAdoptTarget(null)}
onOk={submitAdopt}
confirmLoading={adopting}
okText="Adopt as PENDING"
width={720}
okButtonProps={{
disabled: !!adoptTarget && (() => {
const { loss, hard } = splitBlockers(adoptTarget.candidate.blockers);
return hard.length > 0 || (loss.length > 0 && !adoptAcceptLoss);
})(),
}}
>
{adoptTarget && (() => {
const cand = adoptTarget.candidate;
const { loss, prefix, hard } = splitBlockers(cand.blockers);
return (
<>
{hard.length > 0 && (
<Alert type="error" showIcon style={{ marginBottom: 12 }}
message="This config cannot be adopted"
description={<ul style={{ margin: 0, paddingLeft: 18 }}>
{hard.map((b, i) => <li key={i}>{b}</li>)}
</ul>} />
)}
{loss.length > 0 && (
<Alert type="warning" showIcon style={{ marginBottom: 12 }}
message="Adopting would delete these directives from the node's config"
description={
<>
<ul style={{ margin: '0 0 8px', paddingLeft: 18 }}>
{loss.map((b, i) => <li key={i}>{b}</li>)}
</ul>
<Checkbox checked={adoptAcceptLoss} onChange={(e) => setAdoptAcceptLoss(e.target.checked)}>
I understand these will be lost when the config is handed over
</Checkbox>
</>
} />
)}
{(cand.defaulted || []).length > 0 && (
<Alert type="info" showIcon style={{ marginBottom: 12 }}
message={`Assumed from keepalived's defaults (absent from the file): ${cand.defaulted.join(', ')}`} />
)}
<Form form={adoptForm} layout="vertical">
<Form.Item name="name" label="VIP name"
rules={[{ required: true, message: 'Give the managed VIP a name' }]}>
<Input placeholder="e.g. dmz-web-vip" />
</Form.Item>
{prefix.length > 0 && (
<Form.Item name="prefix_length" label="Prefix length"
extra="The file has no explicit prefix, and guessing one would change this VIP's netmask on takeover. State it here."
rules={[{ required: true, message: 'Required — the file does not state one' }]}>
<InputNumber min={1} max={32} style={{ width: 160 }} />
</Form.Item>
)}
<Form.Item name="description" label="Description (optional)">
<Input placeholder={`Adopted from ${adoptTarget.discovery.agent_name}`} />
</Form.Item>
</Form>
<Text type="secondary" style={{ fontSize: 12 }}>
Config found at <Text code>{adoptTarget.discovery.config_path}</Text> the VRRP
password is masked below and is carried over encrypted.
</Text>
<pre style={{ marginTop: 8, maxHeight: 220, overflow: 'auto', fontSize: 12,
background: 'rgba(127,127,127,0.08)', padding: 8, borderRadius: 4 }}>
{adoptTarget.discovery.config_preview || '(not available)'}
</pre>
</>
);
})()}
</Modal>
<Modal
title={editing ? `Edit VIP — ${editing.name}` : 'Create VIP'}
open={modalVisible}