mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-24 04:53:17 +00:00
feat(vip): let a VIP carry additional addresses (issue #73, v1.12.0)
A VIP held exactly one address. It can now carry up to 19 additional IPv4 addresses (with prefix) in the same virtual_ipaddress block, on the same interface, failing over together with the virtual IP. - vip_instances.additional_ips (JSONB, default []); an empty list renders exactly as before. Create/edit accept the list (omitted keeps, [] clears); it is part of the applied snapshot and restored by Reject and undo. - Every address write takes one advisory lock and checks the addresses against node IPs and every other active VIP's primary and additional addresses. - Adoption accepts an instance with several addresses: the ones after the first become additional addresses when they render back exactly (IPv4, explicit prefix, the instance's interface, no repeats); the peers must agree on them. - UI: an "Additional addresses" list in the VIP form and a "+N" tag in the table. v1.12.0: README, upgrade notes (delta migration, rolling-deploy and downgrade rules, per-node live state), version files. Fixes #73
This commit is contained in:
@@ -1096,6 +1096,7 @@ The HA / VIP page manages **highly-available virtual IPs** backed by Keepalived
|
||||
- **VIP dashboard**: lists each virtual IP with its pool, VRID, per-node interface, and a **live MASTER / BACKUP / FAULT** column per node (refreshed every 30s from the existing keepalive-state heartbeat pipeline).
|
||||
- **Creation form**: enter the virtual IP (+ prefix), pick the pool, and select which of the pool's HAProxy nodes participate — each with a network interface (from the node's reported interfaces), a VRRP role (MASTER/BACKUP) and a priority. VRID auto-allocates per pool; an optional VRRP secret can be set.
|
||||
- **Hands-off automation**: on **Apply**, each member's `keepalived.conf` is rendered and delivered; the agent installs Keepalived if missing, writes the config + a HAProxy health-check (`vrrp_script` + `track_script`), validates with `keepalived -t`, and starts the service. When HAProxy drops on the active node, the health-check lowers VRRP priority and the **VIP fails over automatically** to a backup.
|
||||
- **Several VIPs per node, several addresses per VIP** (v1.12.0): a node can belong to more than one VIP — each VIP is its own `vrrp_instance` (its own VRID, role and priority) and the node's `keepalived.conf` holds all of them. A VIP can also carry up to 19 **additional addresses** next to its virtual IP; they sit in the same `virtual_ipaddress` block, on the same interface, and fail over together.
|
||||
|
||||
**How it works (pull-based, isolated):**
|
||||
- New tables `vip_instances` + `vip_members`; the agent polls `GET /api/agents/{name}/keepalived-config` (key-authenticated, agent-bound) and converges. It is fully **isolated** from the global HAProxy apply flow — VIP changes never regenerate `haproxy.cfg`.
|
||||
@@ -2475,6 +2476,7 @@ Developed with ❤️ for the HAProxy community
|
||||
|
||||
## Release Notes
|
||||
|
||||
- **v1.12.0** (2026-10-16) — **Several keepalived VIPs on one node, and several addresses on one VIP**: a node could belong to only one VIP (the form refused a second one with "node already in VIP …") and a VIP held exactly one address. A node may now be a member of several VIPs: each VIP stays its own `vrrp_instance` with its own VRID, role and priority, and the delivery endpoint merges the node's applied VIPs into the one `keepalived.conf` the node deploys — one `global_defs`, then per VIP a `vrrp_script chk_haproxy_<id>` (each VIP keeps its own failover weight) and its `vrrp_instance`, ordered by VIP id. A node with a single VIP receives exactly the bytes and hash it received before, so existing installs see no change and no reload. A VIP can also carry up to 19 **additional addresses** (IPv4 + prefix) in the same `virtual_ipaddress` block and on the same interface; they are checked against node IPs and every other VIP's addresses under a lock, kept in the applied snapshot, and restored by Reject. The merged file passes `keepalived -t` with no output (which older agents require) on keepalived 2.0.19, 2.1.5, 2.2.4, 2.2.7 and 2.2.8; on 2.0.19, 2.1.5 and 2.2.8 a reload that adds a second VIP did not drop the first one's address, and a VIP with 20 addresses came up. If a node's VIPs cannot be merged, the node is left untouched and its members show the reason. A database error during delivery now answers a status the agent ignores instead of `not_configured`, which on a managed node meant teardown. **Adoption** refuses a `keepalived.conf` with several `vrrp_instance` blocks (taking it over would remove the others; earlier versions allowed it), now accepts an instance with several addresses (the extra ones become additional addresses), and asks agents to re-report files analysed by the older parser once. Schema `13` is a **delta** from `12`: two additive columns, built-in roles are not re-seeded. No agent change. See the upgrade notes for rolling-deploy and downgrade rules.
|
||||
- **v1.11.3** (2026-10-16) — **Config Import keeps `stick-table` and `stick` rules instead of dropping them**: the import parser had no branch for `stick-table` (frontend or backend) or for backend `stick on|match|store-request|store-response`, so those lines were discarded **without a warning**. A frontend that kept its `track-sc0` / `sc_*` counter rules then received the renderer's fallback table (`type ip size 100k expire 30s store http_req_rate(10s)`), so a re-defined table such as `size 200k expire 3h store http_req_rate(10s),gpc0_rate(10s),gpt0` was silently replaced by the default while the `gpc0` rules imported alongside it referenced counters the table did not store. These lines are now imported as-is into the frontend/backend **Options** field (no schema change) with an import warning, and an import that carries only a stick-table keeps the existing option lines. The table the platform generates itself for Rate Limit / WAF `rate_limit` rules is recognised and not stored, so a round-trip of a generated config shows no change and later rule edits still take effect. The import preview also deleted every `http-request track-sc0 src` / `sc_http_req_rate(0)` line as "auto-generated"; it now does so only where the platform regenerates them (frontend Rate Limit or a WAF `rate_limit` rule), so operator trackers on other frontends survive. Backends now render a single `stick-table` (the first; a second one is a fatal HAProxy error) and the preview warns about a table that does not store `http_req_rate` while Rate Limit is active, a duplicate table left in Request Headers, `stick on` without a table, and `peers` references. Configs without stick lines import and render byte-for-byte as before. No schema change, no agent change.
|
||||
- **v1.11.2** (2026-10-01) — **Read-only roles read SSL certificates, and private keys need `ssl.download`**: the UI never fills in `user_pool_access`, so every non-admin user created there has no pool access, and the SSL list (`?cluster_id=`) and SSL detail answered **403** on read while clusters, frontends, backends, WAF rules, agents and VIPs were readable. A role that grants `ssl.read` (or `waf.read` for WAF stats) now reads that resource on every cluster; without it the pool check still applies, and write endpoints are unchanged. Separately, `GET /api/ssl/certificates/{id}` and the SSL diff in Apply Management returned the **private key to any authenticated user**, including `viewer`. The key is now included only with `ssl.download` plus pool access; everyone else gets `private_key_content: null` with `private_key_redacted: true`, and the UI shows the key as hidden while editing keeps the stored key. Custom roles with `ssl.update` but no `ssl.download` lose sight of the key. No schema change, no agent change.
|
||||
- **v1.11.1** (2026-08-15) — **A node can no longer be hidden from adoption for good, and Config Import reaches a freshly installed agent**: the agent posts an unmanaged `keepalived.conf` for adoption and caches the hash of what it sent, so the file (which carries the VRRP password) is re-posted only when it changes. Delivery was judged by `curl`'s exit code, which is **0 for 5xx as well**, so a report the server *rejected* was recorded as delivered — and because a hand-maintained config does not change on its own, that node dropped out of *Unmanaged keepalived detected* permanently, curable only by deleting a cache file on the node by hand. Now: the report is cached only on a 2xx; `GET /agents/{name}/keepalived-config` tells the agent whether the server actually holds a discovery for it, so nodes stuck from earlier releases recover by themselves on the next poll; a **400/413/422** records the refusal so identical bytes are not re-posted forever (4xx and 5xx are never sampled out of the request log, so an unattended loop would write a row carrying the whole config every cycle), while **401 and 404 keep retrying** because in this system they mean a token rotation or an agent row briefly absent, not a bad payload. The same exit-code mistake in the *clear* path is fixed too, where it left a managed node still being offered for adoption. Separately, **Config Import silently did nothing on any agent that had never self-upgraded**: `check_config_requests` was defined in the installer and in the self-upgrade daemon but not in the body a fresh install writes, and its call site is guarded by `type`, so the operator asked a node for its `haproxy.cfg` and nothing arrived, with no error anywhere. And the cluster whose `keepalived_config_path` is handed to an agent is now resolved deterministically — a pool may hold several clusters, and the unordered join could return a different one between polls, pointing the agent at a file that does not exist. Agent-script change: sync the script from Agent Management and let the agents upgrade. No schema change.
|
||||
|
||||
@@ -1,3 +1,59 @@
|
||||
# Upgrade Notes — v1.12.0 (several keepalived VIPs on one node, several addresses on one VIP)
|
||||
|
||||
**Backend + UI change, schema `13` (delta), no agent change.** Agents of every version keep working;
|
||||
no agent upgrade is needed.
|
||||
|
||||
- **Schema 12 → 13 is a delta.** A database at 12 adds two columns —
|
||||
`vip_instances.additional_ips` (JSONB, default `[]`) and `vip_discoveries.analysis_version`
|
||||
(SMALLINT, default `0`) — and nothing else runs. **Built-in roles are not re-seeded**, so role
|
||||
customizations survive this upgrade (unlike v1.10.4 and v1.11.0). A database older than 12 runs the
|
||||
full sequence as before, including the role re-seed.
|
||||
- **Nodes with one VIP see no change.** Their `keepalived.conf` keeps the same bytes and hash, so the
|
||||
agent does not rewrite or reload anything.
|
||||
- **Check for nodes that already belong to several VIPs before upgrading.** Earlier versions refused
|
||||
this when a VIP was saved, but without a lock, so two saves at the same moment could still create
|
||||
it. Such a node switches from one VIP's file to the merged file on its first poll after the upgrade:
|
||||
```sql
|
||||
SELECT a.name, count(*) FROM vip_members m
|
||||
JOIN vip_instances v ON v.id = m.vip_id JOIN agents a ON a.id = m.agent_id
|
||||
WHERE v.is_active GROUP BY a.name HAVING count(*) > 1;
|
||||
```
|
||||
- **Rolling deploy:** do not add a second VIP to a node until **every backend replica** runs v1.12.0.
|
||||
An older replica serves a node only one of its VIPs, so the node would alternate between two files.
|
||||
An older replica applying a VIP also drops that VIP's additional addresses.
|
||||
- **Rollback / downgrade:** first reduce every node to one VIP and remove the additional addresses
|
||||
(edit, then Apply), then downgrade. The two new columns can stay; older versions ignore them.
|
||||
- **How a node with several VIPs is served.** Each VIP is still rendered and applied on its own; the
|
||||
delivery endpoint merges the node's applied VIPs into one file: `global_defs` once, then per VIP a
|
||||
`vrrp_script chk_haproxy_<id>` and a `vrrp_instance VI_<id>`, ordered by VIP id. VRIDs are unique per
|
||||
pool, so the instances never collide. One acknowledgement from the node converges all of its VIPs.
|
||||
Applying two VIPs one after the other reloads keepalived twice; measured on keepalived 2.0.19,
|
||||
2.1.5 and 2.2.8, a reload that adds or removes a VIP does not drop the other VIPs' addresses.
|
||||
- **If the merge fails** (an applied snapshot that is not in the platform's own format), the node keeps
|
||||
running what it has: the agent is told nothing new, and the node's members show
|
||||
"cannot merge this node's VIPs …" with an ERROR status. Re-apply the VIPs to fix it.
|
||||
- **Delivery errors no longer tear keepalived down.** A database error while serving
|
||||
`keepalived-config` answered `not_configured`, which on a node whose file is ours meant teardown. It
|
||||
now answers `unavailable`, which agents of every version ignore.
|
||||
- **Additional addresses:** up to 19 per VIP (IPv4, prefix 1–32), on the VIP's interface on every node.
|
||||
They must not be a node's IP or any address of another active VIP.
|
||||
- **Live state is per node, not per VIP.** The MASTER/BACKUP column comes from the node's heartbeat,
|
||||
which reports one state for the node. A node that is MASTER for one VIP and BACKUP for another shows
|
||||
the same state in both rows; with several VIPs on a node, the heartbeat's config-based fallback can
|
||||
also report MASTER on a BACKUP node. Check `ip addr` on the node when it matters. The VIPs themselves
|
||||
fail over correctly; only the display is affected.
|
||||
- **Adoption:** a `keepalived.conf` with several `vrrp_instance` blocks can no longer be adopted (it
|
||||
replaces the whole file, so the other instances would be removed — earlier versions allowed it). An
|
||||
adoption made before the upgrade from such a file is refused at Apply; reject it. An instance with
|
||||
several addresses is now adoptable; the extra addresses become additional addresses. Files analysed
|
||||
by an older version are re-reported once by v1.11.1+ agents; until then the list shows them with the
|
||||
new blocker.
|
||||
|
||||
**Rollback:** see the downgrade rule above. With one VIP per node and no additional addresses,
|
||||
rolling back is safe.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Notes — v1.11.3 (Config Import keeps `stick-table` and `stick` rules)
|
||||
|
||||
**Backend change, no schema change, no agent change.** No `SCHEMA_VERSION` bump and no agent
|
||||
|
||||
@@ -50,6 +50,38 @@ def _validate_ipv4(v: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
# Issue #73: a VIP may carry further addresses in the same VRRP instance. 20 addresses in
|
||||
# total keeps every advertisement within what VRRPv2 peers are known to accept.
|
||||
MAX_ADDITIONAL_IPS = 19
|
||||
|
||||
|
||||
class VIPAddressIn(BaseModel):
|
||||
address: str
|
||||
prefix_length: int = 24
|
||||
|
||||
@field_validator('address')
|
||||
@classmethod
|
||||
def _addr(cls, v):
|
||||
return str(ipaddress.ip_address(_validate_ipv4(v)))
|
||||
|
||||
@field_validator('prefix_length')
|
||||
@classmethod
|
||||
def _prefix(cls, v):
|
||||
if not isinstance(v, int) or not (1 <= v <= 32):
|
||||
raise ValueError('prefix_length must be an integer between 1 and 32 (IPv4)')
|
||||
return v
|
||||
|
||||
|
||||
def _validate_additional_ips(extras: List['VIPAddressIn'], primary: Optional[str]) -> None:
|
||||
if len(extras) > MAX_ADDITIONAL_IPS:
|
||||
raise ValueError(f'a VIP may carry at most {MAX_ADDITIONAL_IPS} additional addresses')
|
||||
addrs = [e.address for e in extras]
|
||||
if len(set(addrs)) != len(addrs):
|
||||
raise ValueError('each additional address may appear only once')
|
||||
if primary is not None and str(ipaddress.ip_address(primary)) in addrs:
|
||||
raise ValueError('an additional address must differ from virtual_ip')
|
||||
|
||||
|
||||
class VIPMemberIn(BaseModel):
|
||||
agent_id: int
|
||||
network_interface: str
|
||||
@@ -122,6 +154,7 @@ class VIPCreate(BaseModel):
|
||||
auth_pass: Optional[str] = None # plaintext on the wire; stored Fernet-encrypted
|
||||
use_unicast: bool = True
|
||||
track_haproxy: bool = True
|
||||
additional_ips: List[VIPAddressIn] = []
|
||||
members: List[VIPMemberIn]
|
||||
|
||||
@field_validator('name')
|
||||
@@ -172,6 +205,7 @@ class VIPCreate(BaseModel):
|
||||
@model_validator(mode='after')
|
||||
def _members_consistent(self):
|
||||
_validate_members(self.members)
|
||||
_validate_additional_ips(self.additional_ips, self.virtual_ip)
|
||||
return self
|
||||
|
||||
|
||||
@@ -187,6 +221,7 @@ class VIPUpdate(BaseModel):
|
||||
auth_pass: Optional[str] = None # provide only to rotate; omit to keep existing
|
||||
use_unicast: Optional[bool] = None
|
||||
track_haproxy: Optional[bool] = None
|
||||
additional_ips: Optional[List[VIPAddressIn]] = None # None keeps them; [] clears them
|
||||
members: Optional[List[VIPMemberIn]] = None
|
||||
|
||||
@field_validator('name')
|
||||
@@ -242,4 +277,6 @@ class VIPUpdate(BaseModel):
|
||||
def _members_consistent(self):
|
||||
if self.members is not None:
|
||||
_validate_members(self.members)
|
||||
if self.additional_ips is not None:
|
||||
_validate_additional_ips(self.additional_ips, self.virtual_ip)
|
||||
return self
|
||||
|
||||
+119
-27
@@ -16,6 +16,7 @@ Every DB access is wrapped so a (pathological) missing vip_* relation degrades t
|
||||
empty/None result instead of a 500 (B-7) — preserving the fleet-wide no-op guarantee.
|
||||
"""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -171,7 +172,7 @@ async def render_vip_config_masked(conn, vip_id: int):
|
||||
vip_dict = {"id": v["id"], "name": v["name"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"]}
|
||||
"track_haproxy": v["track_haproxy"], "additional_ips": _extra_ips(v["additional_ips"])}
|
||||
member_dicts = [{"role": m["role"], "priority": m["priority"],
|
||||
"network_interface": m["network_interface"], "agent_id": m["agent_id"],
|
||||
"ip_address": str(m["ip_address"]) if m["ip_address"] else ""} for m in members]
|
||||
@@ -241,7 +242,8 @@ async def _capture_vip_pending_state(conn, vip_id: int):
|
||||
"vip": {"name": v["name"], "description": v["description"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"]},
|
||||
"track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"],
|
||||
"additional_ips": _extra_ips(v["additional_ips"])},
|
||||
"members": [{"agent_id": m["agent_id"], "network_interface": m["network_interface"],
|
||||
"role": m["role"], "priority": m["priority"]} for m in members],
|
||||
}
|
||||
@@ -290,17 +292,23 @@ async def restore_vip_from_rejected_version(conn, version_id: int):
|
||||
prev = {r["agent_id"]: r for r in await conn.fetch(
|
||||
"SELECT agent_id, applied_config_content, applied_config_hash, last_deploy_state, "
|
||||
"last_deploy_message, last_deploy_hash, last_deploy_at FROM vip_members WHERE vip_id=$1", vip_id)}
|
||||
# Captured before v1.12.0 → no additional addresses.
|
||||
extras = _extra_ips(sv.get("additional_ips") or [])
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", *_VIP_ADDRESS_LOCK)
|
||||
reason = await _address_clash(conn, sv["virtual_ip"], extras, vip_id)
|
||||
if reason:
|
||||
return f"cannot restore — {reason}"
|
||||
await conn.execute("""
|
||||
UPDATE vip_instances SET name=$2, description=$3, virtual_ip=$4, prefix_length=$5,
|
||||
virtual_router_id=$6, advert_int=$7, use_unicast=$8, track_haproxy=$9,
|
||||
auth_pass_encrypted=$10, is_active=TRUE, last_config_status='PENDING',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
additional_ips=$11::jsonb, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
""", vip_id, sv["name"], sv.get("description"), sv["virtual_ip"], sv["prefix_length"],
|
||||
sv["virtual_router_id"], sv["advert_int"], sv["use_unicast"], sv["track_haproxy"],
|
||||
sv.get("auth_pass_encrypted"))
|
||||
sv.get("auth_pass_encrypted"), json.dumps(extras))
|
||||
await conn.execute("DELETE FROM vip_members WHERE vip_id=$1", vip_id)
|
||||
for mm in sm:
|
||||
o = prev.get(mm["agent_id"])
|
||||
@@ -383,7 +391,7 @@ async def list_vips(cluster_id: Optional[int] = None, authorization: str = Heade
|
||||
if cluster_id is not None:
|
||||
vips = await conn.fetch(f"""
|
||||
SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, v.additional_ips,
|
||||
v.is_active, v.last_config_status, v.pending_delete,
|
||||
(v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set,
|
||||
v.created_at, v.updated_at, p.name AS pool_name
|
||||
@@ -396,7 +404,7 @@ async def list_vips(cluster_id: Optional[int] = None, authorization: str = Heade
|
||||
else:
|
||||
vips = await conn.fetch(f"""
|
||||
SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, v.additional_ips,
|
||||
v.is_active, v.last_config_status, v.pending_delete,
|
||||
(v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set,
|
||||
v.created_at, v.updated_at, p.name AS pool_name
|
||||
@@ -422,6 +430,7 @@ async def list_vips(cluster_id: Optional[int] = None, authorization: str = Heade
|
||||
v["last_config_status"], members, v["pending_delete"], v["is_active"])
|
||||
result.append({
|
||||
**dict(v),
|
||||
"additional_ips": _extra_ips(v["additional_ips"]),
|
||||
# Convergence-aware status for the table (issue #27 follow-up); the raw
|
||||
# last_config_status is kept above for the PENDING gate / Apply button.
|
||||
"deploy_status": deploy_status,
|
||||
@@ -493,7 +502,7 @@ async def get_vip(vip_id: int, authorization: str = Header(None)):
|
||||
try:
|
||||
v = await conn.fetchrow("""
|
||||
SELECT v.id, v.name, v.description, v.pool_id, v.virtual_ip, v.prefix_length,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy,
|
||||
v.virtual_router_id, v.advert_int, v.use_unicast, v.track_haproxy, v.additional_ips,
|
||||
v.is_active, v.last_config_status,
|
||||
(v.auth_pass_encrypted IS NOT NULL) AS auth_pass_set,
|
||||
v.created_at, v.updated_at
|
||||
@@ -508,7 +517,8 @@ async def get_vip(vip_id: int, authorization: str = Header(None)):
|
||||
FROM vip_members m LEFT JOIN agents a ON a.id = m.agent_id
|
||||
WHERE m.vip_id = $1 ORDER BY m.priority DESC
|
||||
""", vip_id)
|
||||
return {**dict(v), "members": [dict(m) for m in members]}
|
||||
return {**dict(v), "additional_ips": _extra_ips(v["additional_ips"]),
|
||||
"members": [dict(m) for m in members]}
|
||||
finally:
|
||||
await close_database_connection(conn)
|
||||
|
||||
@@ -545,6 +555,57 @@ async def _validate_members_against_pool(conn, pool_id: int, members: List, vip_
|
||||
raise HTTPException(status_code=409, detail=f"{vip_virtual_ip} is already used by another VIP")
|
||||
|
||||
|
||||
# Serialises every write that sets a VIP's addresses. The primary address has a partial unique
|
||||
# index; the additional ones live in a JSON list no index can cover, so two concurrent writes
|
||||
# could otherwise both pass the clash check below with the same address.
|
||||
_VIP_ADDRESS_LOCK = (18181873, 0)
|
||||
|
||||
|
||||
def _norm_ip(value) -> str:
|
||||
try:
|
||||
return str(ipaddress.ip_address(str(value).strip()))
|
||||
except ValueError:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _extra_ips(value) -> List[dict]:
|
||||
"""vip_instances.additional_ips (JSONB; asyncpg hands it over as a string) as a list."""
|
||||
items = _jsonb_list(value)
|
||||
return [{"address": str(i["address"]), "prefix_length": int(i["prefix_length"])}
|
||||
for i in items if isinstance(i, dict) and i.get("address")]
|
||||
|
||||
|
||||
async def _address_clash(conn, primary: str, extras: List[dict],
|
||||
exclude_vip_id: Optional[int]) -> Optional[str]:
|
||||
"""Issue #73 — why these addresses cannot be used, or None. Covers what the primary-address
|
||||
checks do not: every additional address against node IPs and every active VIP's addresses,
|
||||
and the primary against the other VIPs' additional addresses. Call it inside the write's
|
||||
transaction after taking _VIP_ADDRESS_LOCK."""
|
||||
other = ("SELECT name FROM vip_instances WHERE is_active = TRUE "
|
||||
"AND ($2::int IS NULL OR id <> $2::int) AND {cond} LIMIT 1")
|
||||
in_extras = "additional_ips @> jsonb_build_array(jsonb_build_object('address', $1::text))"
|
||||
hit = await conn.fetchval(other.format(cond=in_extras), primary, exclude_vip_id)
|
||||
if hit:
|
||||
return f"{primary} is already an additional address of VIP '{hit}'"
|
||||
for e in extras:
|
||||
addr = e["address"]
|
||||
if await conn.fetchval("SELECT 1 FROM agents WHERE ip_address = $1::inet LIMIT 1", addr):
|
||||
return f"{addr} is already a node's IP"
|
||||
hit = await conn.fetchval(
|
||||
other.format(cond=f"(virtual_ip = $1::text OR {in_extras})"), addr, exclude_vip_id)
|
||||
if hit:
|
||||
return f"{addr} is already used by VIP '{hit}'"
|
||||
return None
|
||||
|
||||
|
||||
async def _lock_and_check_addresses(conn, primary: str, extras: List[dict],
|
||||
exclude_vip_id: Optional[int]) -> None:
|
||||
await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", *_VIP_ADDRESS_LOCK)
|
||||
reason = await _address_clash(conn, primary, extras, exclude_vip_id)
|
||||
if reason:
|
||||
raise HTTPException(status_code=409, detail=reason)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_vip(payload: VIPCreate, request: Request, authorization: str = Header(None)):
|
||||
current_user = await _require(authorization, "create")
|
||||
@@ -556,22 +617,25 @@ async def create_vip(payload: VIPCreate, request: Request, authorization: str =
|
||||
await _validate_members_against_pool(conn, payload.pool_id, payload.members, payload.virtual_ip)
|
||||
|
||||
enc = encrypt_vrrp_secret(payload.auth_pass) if payload.auth_pass else None
|
||||
extras = [e.model_dump() for e in payload.additional_ips]
|
||||
# Allocate VRID + insert, retrying once on a unique-violation race (B-1).
|
||||
last_err = None
|
||||
for _attempt in range(2):
|
||||
vrid = await _alloc_free_vrid(conn, payload.pool_id, payload.virtual_router_id)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await _lock_and_check_addresses(conn, payload.virtual_ip, extras, None)
|
||||
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, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',$11)
|
||||
is_active, last_config_status, created_by, additional_ips)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',$11,$12::jsonb)
|
||||
RETURNING id
|
||||
""", payload.name, payload.description, payload.pool_id, payload.virtual_ip,
|
||||
payload.prefix_length, vrid, payload.advert_int, enc,
|
||||
payload.use_unicast, payload.track_haproxy, current_user["id"])
|
||||
payload.use_unicast, payload.track_haproxy, current_user["id"],
|
||||
json.dumps(extras))
|
||||
for m in payload.members:
|
||||
await conn.execute("""
|
||||
INSERT INTO vip_members (vip_id, agent_id, network_interface, role, priority)
|
||||
@@ -579,6 +643,8 @@ async def create_vip(payload: VIPCreate, request: Request, authorization: str =
|
||||
""", vip_id, m.agent_id, m.network_interface, m.role, m.priority)
|
||||
last_err = None
|
||||
break
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as ie: # noqa: BLE001
|
||||
if "unique" in str(ie).lower() or "duplicate" in str(ie).lower():
|
||||
last_err = ie
|
||||
@@ -623,10 +689,17 @@ async def update_vip(vip_id: int, payload: VIPUpdate, request: Request, authoriz
|
||||
"SELECT 1 FROM vip_instances WHERE virtual_ip=$1 AND is_active=TRUE AND id<>$2", new_ip, vip_id)
|
||||
if dup:
|
||||
raise HTTPException(status_code=409, detail=f"{new_ip} is already used by another VIP")
|
||||
new_extras = ([e.model_dump() for e in payload.additional_ips]
|
||||
if payload.additional_ips is not None else None)
|
||||
extras = new_extras if new_extras is not None else _extra_ips(v["additional_ips"])
|
||||
if _norm_ip(new_ip) in {e["address"] for e in extras}:
|
||||
raise HTTPException(status_code=400, detail="an additional address must differ from virtual_ip")
|
||||
|
||||
enc_set = payload.auth_pass is not None and payload.auth_pass != ""
|
||||
try:
|
||||
async with conn.transaction():
|
||||
if payload.virtual_ip or new_extras is not None:
|
||||
await _lock_and_check_addresses(conn, new_ip, extras, vip_id)
|
||||
await conn.execute("""
|
||||
UPDATE vip_instances SET
|
||||
name = COALESCE($2, name),
|
||||
@@ -638,6 +711,7 @@ async def update_vip(vip_id: int, payload: VIPUpdate, request: Request, authoriz
|
||||
use_unicast = COALESCE($8, use_unicast),
|
||||
track_haproxy = COALESCE($9, track_haproxy),
|
||||
auth_pass_encrypted = CASE WHEN $10 THEN $11 ELSE auth_pass_encrypted END,
|
||||
additional_ips = COALESCE($12::jsonb, additional_ips),
|
||||
last_config_status = 'PENDING',
|
||||
-- Editing a VIP means you are KEEPING and changing it, so it cancels any
|
||||
-- staged deletion (otherwise a later Apply would delete instead of applying
|
||||
@@ -649,7 +723,8 @@ async def update_vip(vip_id: int, payload: VIPUpdate, request: Request, authoriz
|
||||
""", vip_id, payload.name, payload.description, payload.virtual_ip,
|
||||
payload.prefix_length, payload.virtual_router_id, payload.advert_int,
|
||||
payload.use_unicast, payload.track_haproxy,
|
||||
enc_set, encrypt_vrrp_secret(payload.auth_pass) if enc_set else None)
|
||||
enc_set, encrypt_vrrp_secret(payload.auth_pass) if enc_set else None,
|
||||
json.dumps(new_extras) if new_extras is not None else None)
|
||||
if members is not None:
|
||||
# T-1: preserve the APPLIED snapshot + deploy ack for members that REMAIN,
|
||||
# so a pending member edit never momentarily flips a running node to
|
||||
@@ -816,7 +891,6 @@ async def apply_vip(vip_id: int, request: Request, authorization: str = Header(N
|
||||
f"{multi['agent_name']}'s keepalived.conf declares {multi['n']} vrrp_instance blocks; "
|
||||
f"taking it over would remove the others from the node. Reject this adoption."))
|
||||
|
||||
|
||||
auth_plain = decrypt_vrrp_secret(v["auth_pass_encrypted"]) if v["auth_pass_encrypted"] else None
|
||||
# If a secret is set but can't be decrypted (SECRET_KEY/VIP_ENCRYPTION_KEY rotated or
|
||||
# drifted between pods), FAIL the apply rather than silently rendering a config with NO
|
||||
@@ -834,7 +908,7 @@ async def apply_vip(vip_id: int, request: Request, authorization: str = Header(N
|
||||
vip_dict = {"id": v["id"], "name": v["name"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"]}
|
||||
"track_haproxy": v["track_haproxy"], "additional_ips": _extra_ips(v["additional_ips"])}
|
||||
|
||||
snapshot_members = []
|
||||
async with conn.transaction():
|
||||
@@ -860,7 +934,8 @@ async def apply_vip(vip_id: int, request: Request, authorization: str = Header(N
|
||||
"vip": {"name": v["name"], "description": v["description"], "virtual_ip": v["virtual_ip"],
|
||||
"prefix_length": v["prefix_length"], "virtual_router_id": v["virtual_router_id"],
|
||||
"advert_int": v["advert_int"], "use_unicast": v["use_unicast"],
|
||||
"track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"]},
|
||||
"track_haproxy": v["track_haproxy"], "auth_pass_encrypted": v["auth_pass_encrypted"],
|
||||
"additional_ips": vip_dict["additional_ips"]},
|
||||
"members": snapshot_members}
|
||||
await conn.execute(
|
||||
"UPDATE vip_instances SET last_config_status='APPLIED', "
|
||||
@@ -934,17 +1009,24 @@ async def reject_vip(vip_id: int, request: Request, authorization: str = Header(
|
||||
|
||||
sv = snap["vip"]
|
||||
sm = snap.get("members", [])
|
||||
# Applied before v1.12.0 → no additional addresses.
|
||||
extras = _extra_ips(sv.get("additional_ips") or [])
|
||||
try:
|
||||
async with conn.transaction():
|
||||
await conn.execute("SELECT pg_advisory_xact_lock($1, $2)", *_VIP_ADDRESS_LOCK)
|
||||
reason = await _address_clash(conn, sv["virtual_ip"], extras, vip_id)
|
||||
if reason:
|
||||
raise HTTPException(status_code=409, detail=f"Cannot restore — {reason}")
|
||||
await conn.execute("""
|
||||
UPDATE vip_instances SET
|
||||
name=$2, description=$3, virtual_ip=$4, prefix_length=$5, virtual_router_id=$6,
|
||||
advert_int=$7, use_unicast=$8, track_haproxy=$9, auth_pass_encrypted=$10,
|
||||
additional_ips=$11::jsonb,
|
||||
last_config_status='APPLIED', updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
""", vip_id, sv["name"], sv.get("description"), sv["virtual_ip"], sv["prefix_length"],
|
||||
sv["virtual_router_id"], sv["advert_int"], sv["use_unicast"], sv["track_haproxy"],
|
||||
sv.get("auth_pass_encrypted"))
|
||||
sv.get("auth_pass_encrypted"), json.dumps(extras))
|
||||
await conn.execute("DELETE FROM vip_members WHERE vip_id=$1", vip_id)
|
||||
for m in sm:
|
||||
await conn.execute("""
|
||||
@@ -1268,18 +1350,21 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
# this VRID in the pool (the clash check above would have refused first);
|
||||
# * a node whose file carries our ownership marker (is_managed) is ours already.
|
||||
adopted_ids = [p["agent_id"] for p in participants]
|
||||
# Issue #73: every address of the instance, not only the first.
|
||||
extras = _extra_ips(vip_fields.get("additional_ips") or [])
|
||||
all_addrs = [virtual_ip] + [e["address"] for e in extras]
|
||||
stranded = await conn.fetch("""
|
||||
SELECT a.name AS agent_name, a.pool_id, COALESCE(a.enabled, TRUE) AS enabled,
|
||||
d.parse_error
|
||||
FROM vip_discoveries d
|
||||
JOIN agents a ON a.id = d.agent_id
|
||||
LEFT JOIN vip_instances av ON av.id = d.adopted_vip_id
|
||||
WHERE d.raw_config_masked LIKE '%' || $1 || '%'
|
||||
WHERE d.raw_config_masked LIKE ANY($1::text[])
|
||||
AND NOT (a.id = ANY($2::int[]))
|
||||
AND COALESCE(av.is_active, FALSE) = FALSE
|
||||
AND d.is_managed = FALSE
|
||||
ORDER BY a.name
|
||||
""", virtual_ip, adopted_ids)
|
||||
""", [f"%{a}%" for a in all_addrs], adopted_ids)
|
||||
if stranded:
|
||||
def _why(r):
|
||||
if r["parse_error"]:
|
||||
@@ -1291,7 +1376,7 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
return "it does not report this vrrp_instance"
|
||||
named = "; ".join(f"{r['agent_name']} — {_why(r)}" for r in stranded)
|
||||
raise HTTPException(status_code=422, detail=(
|
||||
f"These node(s) also reference {virtual_ip} but cannot be taken over with the rest "
|
||||
f"These node(s) also reference {', '.join(all_addrs)} but cannot be taken over with the rest "
|
||||
f"of the instance, so adopting now would rewrite the others and leave them running "
|
||||
f"an unmanaged config on the same address: {named}. Resolve that first, then adopt."))
|
||||
|
||||
@@ -1301,11 +1386,17 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
# the sharpest: the design refuses to GUESS a netmask for a live VIP, and quietly copying
|
||||
# one node's netmask onto another is the same change by another name.
|
||||
def _vfield(p, key):
|
||||
return (p["candidate"].get("vip") or {}).get(key)
|
||||
value = (p["candidate"].get("vip") or {}).get(key)
|
||||
if key == "additional_ips":
|
||||
# Hashable and order-free; an analysis from before v1.12.0 has no key → none.
|
||||
return tuple(sorted((e["address"], e["prefix_length"])
|
||||
for e in _extra_ips(value or [])))
|
||||
return value
|
||||
|
||||
for key, label in (("prefix_length", "prefix length"),
|
||||
("use_unicast", "unicast/multicast mode"),
|
||||
("track_haproxy", "HAProxy tracking")):
|
||||
("track_haproxy", "HAProxy tracking"),
|
||||
("additional_ips", "additional addresses")):
|
||||
seen = {}
|
||||
for p in participants:
|
||||
seen.setdefault(_vfield(p, key), []).append(p["agent_name"])
|
||||
@@ -1375,9 +1466,9 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
f"holding the VIP. Register those nodes as agents so they report their config, "
|
||||
f"then adopt again."))
|
||||
|
||||
# One active VIP per agent — the delivery endpoint serves a single keepalived.conf per
|
||||
# node, so a second active membership never converges. create/update enforce this via
|
||||
# _validate_members_against_pool; adoption did not call it at all.
|
||||
# Adoption hands a node's whole keepalived.conf over to OpenManager. A node that is
|
||||
# already a member of a managed VIP is not taken over a second time (issue #73 allows
|
||||
# several VIPs per node for VIPs created here, not for a second adoption).
|
||||
busy = await conn.fetchrow("""
|
||||
SELECT a.name AS agent_name, v.name AS vip_name
|
||||
FROM vip_members vm JOIN vip_instances v ON v.id = vm.vip_id
|
||||
@@ -1401,19 +1492,20 @@ async def adopt_vip(payload: dict, request: Request, authorization: str = Header
|
||||
f"config must keep its VRID, so resolve the collision first"))
|
||||
|
||||
async with conn.transaction():
|
||||
await _lock_and_check_addresses(conn, virtual_ip, extras, None)
|
||||
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)
|
||||
is_active, last_config_status, adopted_at, created_by, additional_ips)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE,'PENDING',CURRENT_TIMESTAMP,$11,$12::jsonb)
|
||||
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"])
|
||||
current_user["id"], json.dumps(extras))
|
||||
# Every node of the instance becomes a member with the role/priority/interface ITS
|
||||
# OWN file declares, and each carries its own one-shot takeover authorisation pinned
|
||||
# to the hash of the file we analysed on THAT node. The guard stays per-node: a file
|
||||
|
||||
@@ -183,6 +183,8 @@ def render_keepalived_conf(*, vip: dict, members: List[dict], this_agent: dict,
|
||||
lines.append(" }")
|
||||
lines.append(" virtual_ipaddress {")
|
||||
lines.append(f' {vip["virtual_ip"]}/{int(vip.get("prefix_length", 24))} dev {iface}')
|
||||
for extra in vip.get("additional_ips") or []:
|
||||
lines.append(f' {extra["address"]}/{int(extra["prefix_length"])} dev {iface}')
|
||||
lines.append(" }")
|
||||
if track:
|
||||
lines.append(" track_script {")
|
||||
|
||||
@@ -31,6 +31,8 @@ import ipaddress
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from models.vip import MAX_ADDITIONAL_IPS
|
||||
|
||||
# Stored with every discovery (vip_discoveries.analysis_version). Bump it whenever the
|
||||
# analysis or its blockers change: rows from an older parser make the agent re-report once.
|
||||
ANALYSIS_VERSION = 1
|
||||
@@ -424,14 +426,44 @@ def build_adoption_candidate(parsed: Dict[str, Any], instance: Dict[str, Any]) -
|
||||
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
|
||||
|
||||
# Issue #73: the addresses after the first become the VIP's additional addresses. Each one
|
||||
# must render back exactly, so nothing about it may be guessed.
|
||||
additional_ips: List[Dict[str, Any]] = []
|
||||
extra_entries = vips[1:]
|
||||
if len(extra_entries) > MAX_ADDITIONAL_IPS:
|
||||
blockers.append(
|
||||
f"the instance carries {len(vips)} addresses; a managed VIP holds at most "
|
||||
f"{MAX_ADDITIONAL_IPS + 1}")
|
||||
seen_addrs = {vip_entry["address"]} if vip_entry else set()
|
||||
for extra in extra_entries:
|
||||
addr = extra["address"]
|
||||
if ipaddress.ip_address(addr).version != 4:
|
||||
blockers.append(f"additional address `{addr}` is IPv6; only IPv4 addresses are managed")
|
||||
continue
|
||||
if addr in seen_addrs:
|
||||
blockers.append(f"additional address `{addr}` is listed more than once")
|
||||
continue
|
||||
seen_addrs.add(addr)
|
||||
prefix = extra.get("prefix_length")
|
||||
if prefix is None:
|
||||
blockers.append(
|
||||
f"additional address `{addr}` has no /prefix in the file; add it there so the "
|
||||
f"netmask cannot change on takeover, then let the agent report again")
|
||||
continue
|
||||
if not (1 <= int(prefix) <= 32):
|
||||
blockers.append(f"additional address `{addr}/{prefix}` has an invalid prefix length")
|
||||
continue
|
||||
if extra.get("dev") and instance.get("interface") and extra["dev"] != instance["interface"]:
|
||||
blockers.append(
|
||||
f"additional address `{addr}` is bound to `dev {extra['dev']}` but the instance "
|
||||
f"uses `interface {instance['interface']}`; the render always uses the instance "
|
||||
f"interface")
|
||||
continue
|
||||
additional_ips.append({"address": addr, "prefix_length": int(prefix)})
|
||||
|
||||
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")
|
||||
@@ -490,6 +522,7 @@ def build_adoption_candidate(parsed: Dict[str, Any], instance: Dict[str, Any]) -
|
||||
"use_unicast": bool(peers),
|
||||
"track_haproxy": OUR_CHECK_SCRIPT_NAME in tracked,
|
||||
"auth_pass": instance.get("auth_pass"),
|
||||
"additional_ips": additional_ips,
|
||||
},
|
||||
"member": {
|
||||
"network_interface": instance.get("interface"),
|
||||
|
||||
@@ -78,6 +78,7 @@ def test_parses_a_handwritten_config_into_model_fields():
|
||||
"use_unicast": True,
|
||||
"track_haproxy": True,
|
||||
"auth_pass": "s3cr3t",
|
||||
"additional_ips": [],
|
||||
}
|
||||
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"
|
||||
@@ -141,11 +142,46 @@ def test_directives_we_cannot_render_block_adoption():
|
||||
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 _with_extra(line):
|
||||
return HANDWRITTEN.replace(" 10.0.0.100/24 dev eth0",
|
||||
" 10.0.0.100/24 dev eth0\n" + line)
|
||||
|
||||
|
||||
def test_multiple_addresses_in_one_instance_are_adopted_as_additional_ips():
|
||||
"""Issue #73: the addresses after the first become the VIP's additional addresses."""
|
||||
cand = _only_candidate(_with_extra(" 10.0.0.101/25 dev eth0\n 10.0.0.102/24"))
|
||||
assert cand["adoptable"] is True, cand["blockers"]
|
||||
assert cand["vip"]["virtual_ip"] == "10.0.0.100"
|
||||
assert cand["vip"]["additional_ips"] == [
|
||||
{"address": "10.0.0.101", "prefix_length": 25},
|
||||
{"address": "10.0.0.102", "prefix_length": 24},
|
||||
]
|
||||
|
||||
|
||||
def test_an_additional_address_that_cannot_render_back_exactly_blocks_for_good():
|
||||
"""No prefix, another dev, IPv6 or a repeat cannot be reproduced. None of them may be
|
||||
waivable: their wording must carry neither the loss nor the prefix marker."""
|
||||
cases = {
|
||||
" 10.0.0.101": "no /prefix",
|
||||
" 10.0.0.101/24 dev eth1": "dev eth1",
|
||||
" fd00::1/64 dev eth0": "IPv6",
|
||||
" 10.0.0.100/24 dev eth0": "more than once",
|
||||
" 10.0.0.101/40 dev eth0": "invalid prefix",
|
||||
}
|
||||
for line, reason in cases.items():
|
||||
cand = _only_candidate(_with_extra(line))
|
||||
assert cand["adoptable"] is False, line
|
||||
assert reason in " | ".join(cand["blockers"]), (line, cand["blockers"])
|
||||
assert remaining_blockers(cand["blockers"], prefix_supplied=True,
|
||||
accept_data_loss=True), line
|
||||
|
||||
|
||||
def test_too_many_addresses_block_adoption():
|
||||
from models.vip import MAX_ADDITIONAL_IPS
|
||||
lines = "\n".join(f" 10.0.1.{i}/24 dev eth0" for i in range(1, MAX_ADDITIONAL_IPS + 2))
|
||||
cand = _only_candidate(_with_extra(lines))
|
||||
assert cand["adoptable"] is False
|
||||
assert f"at most {MAX_ADDITIONAL_IPS + 1}" in " | ".join(cand["blockers"])
|
||||
|
||||
|
||||
def test_missing_vrid_blocks_adoption_with_the_split_brain_reason():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Issue #73 (v1.12.0) — several VIPs on one node.
|
||||
"""Issue #73 (v1.12.0) — several VIPs on one node, several addresses on one VIP.
|
||||
|
||||
Pure tests for the merge of a node's applied single-VIP files, plus source-level guards for the delivery / ack / adoption / migration
|
||||
Pure tests for the merge of a node's applied single-VIP files, the extra-address rendering
|
||||
and validation, plus source-level guards for the delivery / ack / adoption / migration
|
||||
invariants that need a live database to exercise end to end.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -14,6 +15,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from models.vip import MAX_ADDITIONAL_IPS, VIPCreate, VIPUpdate # noqa: E402
|
||||
from services import keepalived_config as kc # noqa: E402
|
||||
from services.keepalived_parser import ( # noqa: E402
|
||||
ANALYSIS_VERSION, analyse_keepalived_conf, parse_keepalived_conf,
|
||||
@@ -107,6 +109,58 @@ class TestCompose:
|
||||
kc.compose_node_keepalived_conf([])
|
||||
|
||||
|
||||
class TestAdditionalAddresses:
|
||||
def test_empty_list_renders_byte_identical(self):
|
||||
assert _applied({**A, "additional_ips": []}) == _applied(A)
|
||||
|
||||
def test_extras_follow_the_primary_on_the_same_interface(self):
|
||||
conf = _applied({**A, "additional_ips": [
|
||||
{"address": "10.0.0.101", "prefix_length": 24},
|
||||
{"address": "192.168.5.9", "prefix_length": 32}]})
|
||||
lines = [l.strip() for l in conf.splitlines()]
|
||||
i = lines.index("10.0.0.100/24 dev eth0")
|
||||
assert lines[i + 1:i + 3] == ["10.0.0.101/24 dev eth0", "192.168.5.9/32 dev eth0"]
|
||||
|
||||
def test_rendered_extras_are_adopted_back_unchanged(self):
|
||||
extras = [{"address": "10.0.0.101", "prefix_length": 25}]
|
||||
conf = _applied({**A, "additional_ips": extras})
|
||||
cand = analyse_keepalived_conf(conf)["candidates"][0]
|
||||
assert cand["adoptable"] is True, cand["blockers"]
|
||||
assert cand["vip"]["additional_ips"] == extras
|
||||
|
||||
|
||||
def _members():
|
||||
return [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150}]
|
||||
|
||||
|
||||
class TestModels:
|
||||
def test_default_is_empty(self):
|
||||
v = VIPCreate(name="v", pool_id=1, virtual_ip="10.0.0.100", members=_members())
|
||||
assert v.additional_ips == []
|
||||
|
||||
def test_address_is_normalised_and_prefix_defaults(self):
|
||||
v = VIPCreate(name="v", pool_id=1, virtual_ip="10.0.0.100", members=_members(),
|
||||
additional_ips=[{"address": " 10.0.0.101 "}])
|
||||
assert v.additional_ips[0].model_dump() == {"address": "10.0.0.101", "prefix_length": 24}
|
||||
|
||||
@pytest.mark.parametrize("extras", [
|
||||
[{"address": "10.0.0.100"}],
|
||||
[{"address": "10.0.0.101"}, {"address": "10.0.0.101"}],
|
||||
[{"address": "fd00::1"}],
|
||||
[{"address": "nope"}],
|
||||
[{"address": "10.0.0.101", "prefix_length": 33}],
|
||||
[{"address": f"10.0.1.{i}"} for i in range(1, MAX_ADDITIONAL_IPS + 2)],
|
||||
])
|
||||
def test_invalid_extras_are_rejected(self, extras):
|
||||
with pytest.raises(ValueError):
|
||||
VIPCreate(name="v", pool_id=1, virtual_ip="10.0.0.100", members=_members(),
|
||||
additional_ips=extras)
|
||||
|
||||
def test_update_omitted_keeps_and_empty_clears(self):
|
||||
assert VIPUpdate().additional_ips is None
|
||||
assert VIPUpdate(additional_ips=[]).additional_ips == []
|
||||
|
||||
|
||||
class TestDeliveryAndAckGuards:
|
||||
def test_failures_answer_with_statuses_the_agent_ignores(self):
|
||||
assert '"status": "compose_error"' in AGENT_ROUTER
|
||||
@@ -146,6 +200,11 @@ class TestAdoptionGuards:
|
||||
row_api = VIP_ROUTER.split("def _discovery_row_to_api", 1)[1].split("\ndef ", 1)[0]
|
||||
assert 'cand["adoptable"] = False' in row_api
|
||||
|
||||
def test_address_writes_take_the_address_lock(self):
|
||||
for fn in ("create_vip", "update_vip", "adopt_vip"):
|
||||
body = VIP_ROUTER.split(f"async def {fn}", 1)[1].split("\nasync def ", 1)[0]
|
||||
assert "_lock_and_check_addresses(" in body, fn
|
||||
|
||||
|
||||
class TestDeltaMigration:
|
||||
def test_versions(self):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.11.3",
|
||||
"releaseName": "Config Import keeps stick-table and stick rules instead of dropping them",
|
||||
"version": "1.12.0",
|
||||
"releaseName": "Several keepalived VIPs on one node, and several addresses on one VIP",
|
||||
"releaseDate": "2026-10-16"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.11.3",
|
||||
"version": "1.12.0",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, WarningOutlined,
|
||||
PlayCircleOutlined, SyncOutlined, CrownOutlined, ClockCircleOutlined, FileSearchOutlined,
|
||||
InfoCircleOutlined
|
||||
InfoCircleOutlined, MinusCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useCluster } from '../contexts/ClusterContext';
|
||||
import { extractApiError } from '../utils/apiError';
|
||||
@@ -76,6 +76,10 @@ const mergeBlockers = (lists) => {
|
||||
return Array.from(seen.values());
|
||||
};
|
||||
|
||||
// Mirrors backend/models/vip.py MAX_ADDITIONAL_IPS.
|
||||
const MAX_ADDITIONAL_IPS = 19;
|
||||
const IPV4_RE = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
|
||||
// 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 "address already used by VIP X" — actually reach the user.
|
||||
@@ -351,7 +355,8 @@ const VIPManagement = () => {
|
||||
setSelectedPoolId(null);
|
||||
setMemberRows([]);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ prefix_length: 24, advert_int: 1, use_unicast: true, track_haproxy: true });
|
||||
form.setFieldsValue({ prefix_length: 24, advert_int: 1, use_unicast: true, track_haproxy: true,
|
||||
additional_ips: [] });
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -365,6 +370,7 @@ const VIPManagement = () => {
|
||||
virtual_ip: vip.virtual_ip, prefix_length: vip.prefix_length,
|
||||
virtual_router_id: vip.virtual_router_id, advert_int: vip.advert_int,
|
||||
use_unicast: vip.use_unicast, track_haproxy: vip.track_haproxy,
|
||||
additional_ips: (vip.additional_ips || []).map((a) => ({ ...a })),
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
@@ -478,7 +484,19 @@ const VIPManagement = () => {
|
||||
|
||||
const columns = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Virtual IP', key: 'vip', render: (_, v) => <Text code>{v.virtual_ip}/{v.prefix_length}</Text> },
|
||||
{ title: 'Virtual IP', key: 'vip', render: (_, v) => {
|
||||
const extra = v.additional_ips || [];
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Text code>{v.virtual_ip}/{v.prefix_length}</Text>
|
||||
{extra.length > 0 && (
|
||||
<Tooltip title={extra.map((a) => `${a.address}/${a.prefix_length}`).join(', ')}>
|
||||
<Tag>+{extra.length}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
} },
|
||||
{ title: 'Pool', dataIndex: 'pool_name', key: 'pool' },
|
||||
{ title: 'VRID', dataIndex: 'virtual_router_id', key: 'vrid' },
|
||||
{ title: 'Members / Live state', key: 'members', render: renderMembers },
|
||||
@@ -843,6 +861,37 @@ const VIPManagement = () => {
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.List name="additional_ips">
|
||||
{(fields, { add, remove }) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>Additional addresses</Text>
|
||||
<div style={{ color: '#888', fontSize: 12, marginBottom: 8 }}>
|
||||
Optional. Extra IPv4 addresses carried by the same VRRP instance — they move between
|
||||
nodes together with the virtual IP, on the same interface (up to {MAX_ADDITIONAL_IPS}).
|
||||
</div>
|
||||
{fields.map(({ key, name, ...rest }) => (
|
||||
<Space key={key} align="baseline">
|
||||
<Form.Item {...rest} name={[name, 'address']}
|
||||
rules={[{ required: true, message: 'Address required' },
|
||||
{ pattern: IPV4_RE, message: 'Enter an IPv4 address' }]}>
|
||||
<Input placeholder="10.0.0.101" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...rest} name={[name, 'prefix_length']}
|
||||
rules={[{ required: true, message: 'Prefix required' }]}>
|
||||
<InputNumber min={1} max={32} addonBefore="/" />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />}
|
||||
disabled={fields.length >= MAX_ADDITIONAL_IPS}
|
||||
onClick={() => add({ prefix_length: form.getFieldValue('prefix_length') || 24 })}>
|
||||
Add address
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<Text strong>Participating nodes</Text>
|
||||
<div style={{ color: '#888', fontSize: 12, marginBottom: 8 }}>
|
||||
These are the HAProxy servers (agents) already enrolled in this pool — toggle which join the VIP.
|
||||
|
||||
Reference in New Issue
Block a user