diff --git a/README.md b/README.md index 449ac3e..a4dadb3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/UPGRADE_GUIDE.md b/UPGRADE_GUIDE.md index c3a1a95..c8778cf 100644 --- a/UPGRADE_GUIDE.md +++ b/UPGRADE_GUIDE.md @@ -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 diff --git a/backend/database/migrations.py b/backend/database/migrations.py index de0f437..926bfbd 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -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}") diff --git a/backend/routers/agent.py b/backend/routers/agent.py index 671462e..b3eebec 100644 --- a/backend/routers/agent.py +++ b/backend/routers/agent.py @@ -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""" diff --git a/backend/routers/vip.py b/backend/routers/vip.py index 223e52f..f7d51d4 100644 --- a/backend/routers/vip.py +++ b/backend/routers/vip.py @@ -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) diff --git a/backend/services/keepalived_parser.py b/backend/services/keepalived_parser.py new file mode 100644 index 0000000..aa28bf1 --- /dev/null +++ b/backend/services/keepalived_parser.py @@ -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: `[/] [dev ] [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/", entry["line"]) + elif name == "virtual_ipaddress": + for sub in entry["body"]: + if sub["kind"] != "line" or not sub["tokens"]: + unsupported("virtual_ipaddress/", 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 `/`, 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"]], + } diff --git a/backend/tests/test_keepalived_parser.py b/backend/tests/test_keepalived_parser.py new file mode 100644 index 0000000..487a8b1 --- /dev/null +++ b/backend/tests/test_keepalived_parser.py @@ -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 diff --git a/backend/utils/agent_scripts/linux_install.sh b/backend/utils/agent_scripts/linux_install.sh index 2b8240c..e69a713 100644 --- a/backend/utils/agent_scripts/linux_install.sh +++ b/backend/utils/agent_scripts/linux_install.sh @@ -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. diff --git a/backend/version.json b/backend/version.json index ef98c45..35de075 100644 --- a/backend/version.json +++ b/backend/version.json @@ -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" } diff --git a/frontend/package.json b/frontend/package.json index 2c1eec2..a05900a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/components/VIPManagement.js b/frontend/src/components/VIPManagement.js index ee028eb..8b71483 100644 --- a/frontend/src/components/VIPManagement.js +++ b/frontend/src/components/VIPManagement.js @@ -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 = () => { + {/* 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 && ( + + + Unmanaged keepalived detected on {discoveries.length} node(s) + + }> + + The agent read each keepalived.conf 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. + + } + /> +
`${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) => ( + + {r.discovery.agent_name} + {r.discovery.pool_name || 'no pool'} + + ) }, + { title: 'Instance', dataIndex: 'instance_name', key: 'instance' }, + { title: 'Virtual IP', key: 'vip', + render: (_v, r) => (r.candidate?.vip?.virtual_ip + ? {r.candidate.vip.virtual_ip} + {r.candidate.vip.prefix_length != null ? `/${r.candidate.vip.prefix_length}` : ''} + : ) }, + { title: 'VRID', key: 'vrid', + render: (_v, r) => (r.candidate?.vip?.virtual_router_id ?? ) }, + { title: 'This node', key: 'member', + render: (_v, r) => (r.candidate ? ( + + + {r.candidate.member.role} + + + prio {r.candidate.member.priority} · {r.candidate.member.network_interface} + + + ) : ) }, + { title: 'Adoptable', key: 'adoptable', + render: (_v, r) => { + if (r.discovery.parse_error) { + return unparseable; + } + if (!r.candidate) return no vrrp_instance; + if (r.candidate.adoptable) return yes; + const { hard } = splitBlockers(r.candidate.blockers); + return ( + + + {hard.length ? `${hard.length} blocker(s)` : 'needs review'} + + + ); + } }, + { title: 'Actions', key: 'actions', + render: (_v, r) => ( + + ) }, + ]} + /> + + )} + + {/* Adopt modal — shows what will be taken over, what was assumed, and what would be lost. */} + 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 && ( + + {hard.map((b, i) =>
  • {b}
  • )} + } /> + )} + {loss.length > 0 && ( + +
      + {loss.map((b, i) =>
    • {b}
    • )} +
    + setAdoptAcceptLoss(e.target.checked)}> + I understand these will be lost when the config is handed over + + + } /> + )} + {(cand.defaulted || []).length > 0 && ( + + )} +
    + + + + {prefix.length > 0 && ( + + + + )} + + + + + + Config found at {adoptTarget.discovery.config_path} — the VRRP + password is masked below and is carried over encrypted. + +
    +                {adoptTarget.discovery.config_preview || '(not available)'}
    +              
    + + ); + })()} +
    +