mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-12 05:48:58 +00:00
Merge pull request #58 from mustafaulukaya/feature/keepalived-vip-adoption
Adopt an existing keepalived VIP (Issue #27 follow-up)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user