Files
haproxy-openmanager/backend/tests/test_keepalived_parser.py
T
taylanbakircioglu 78641d87ff 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
2026-10-17 13:05:29 +03:00

383 lines
16 KiB
Python

"""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, remaining_blockers,
)
# 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",
"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"
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 _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():
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"]
# Issue #73: adoption takes over the whole file, so adopting either instance would remove
# the other from the node. Neither may be adoptable, and no flag may waive it.
for c in analysed["candidates"]:
assert c["adoptable"] is False
assert remaining_blockers(c["blockers"], prefix_supplied=True, accept_data_loss=True)
assert any("2 vrrp_instance blocks" in b for b in c["blockers"])
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