mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-20 17:43:29 +00:00
a1192e602d
Manage highly-available virtual IPs backed by Keepalived (VRRP) directly from the OpenManager
UI — no more SSHing into nodes to install/configure Keepalived by hand. Builds on the agent
pull-architecture: define the VIP centrally, click Apply, and the agents converge.
Highlights:
- New "HA / VIP" tab: create a virtual IP, pick a per-node interface, select which pool nodes
participate (MASTER/BACKUP roles + priorities); live MASTER/BACKUP/FAULT per node.
- On Apply, agents install & configure Keepalived (unicast VRRP, cloud-safe default) across the
major distros (Debian/Ubuntu, RHEL/CentOS/Alma/Rocky, Fedora, SUSE/openSUSE, Alpine) with a
HAProxy health-check, so the VIP fails over automatically when HAProxy drops.
- Single-node (a managed floating IP without failover) and multi-node VRRP failover both work.
- VIP changes ride the standard Apply Management flow with the standard "View Change" diff.
- Approval-gated deletion (safety): deleting a running VIP is staged for approval and the VIP
keeps running, untouched, until you approve it — an agent never tears a VIP down without an
explicit human approval. Per-VIP Diagnostics view; opt-in package uninstall (only on nodes
where OpenManager installed it). A node already running a hand-managed Keepalived is detected
and never overwritten ("externally managed").
- Fully opt-in and backward compatible: nodes/clusters without a VIP are unaffected. Adds
vip_instances + vip_members tables (idempotent SCHEMA_VERSION bump; existing data and
passwords unaffected) and a `vip` RBAC permission group.
- Also includes a HAProxy config-generator robustness fix: auto-inject a stick-table when a
frontend uses a stick counter (track-sc / sc_*_rate) but declares none.
On-prem / L2 (VRRP) scope; the UI notes the cloud caveat.
130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
"""Issue #27 (v1.7.0) — unit tests for the keepalived config generator + secret crypto.
|
|
|
|
Pure-function tests; no DB. Validates the rendered keepalived.conf for MASTER/BACKUP,
|
|
unicast peers, the failover weight arithmetic, the script-security requirements, the
|
|
ownership marker, and Fernet round-trip.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from services import keepalived_config as kc # noqa: E402
|
|
|
|
|
|
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"},
|
|
]
|
|
|
|
|
|
def _render(this_idx, auth="s3cr3t"):
|
|
this_agent = MEMBERS[this_idx]
|
|
peers = [m["ip_address"] for m in MEMBERS if m["agent_id"] != this_agent["agent_id"]]
|
|
return kc.render_keepalived_conf(vip=VIP, members=MEMBERS, this_agent=this_agent,
|
|
peer_ips=peers, auth_pass_plain=auth)
|
|
|
|
|
|
class TestRender:
|
|
def test_master_state_priority_iface_vrid(self):
|
|
conf = _render(0)
|
|
assert "state MASTER" in conf
|
|
assert "priority 150" in conf
|
|
assert "interface eth0" in conf
|
|
assert "virtual_router_id 51" in conf
|
|
assert "10.0.0.100/24 dev eth0" in conf
|
|
|
|
def test_backup_state(self):
|
|
conf = _render(1)
|
|
assert "state BACKUP" in conf
|
|
assert "priority 100" in conf
|
|
|
|
def test_unicast_peers(self):
|
|
# MASTER's config lists the BACKUP as its unicast peer (and its own src ip).
|
|
conf = _render(0)
|
|
assert "unicast_src_ip 10.0.0.11" in conf
|
|
assert "unicast_peer" in conf
|
|
assert "10.0.0.12" in conf
|
|
|
|
def test_script_security_block(self):
|
|
conf = _render(0)
|
|
assert "enable_script_security" in conf
|
|
assert "script_user root" in conf
|
|
|
|
def test_ownership_marker(self):
|
|
assert kc.OWNERSHIP_MARKER in _render(0)
|
|
|
|
def test_weight_makes_failed_master_lose(self):
|
|
# On HAProxy failure the master's effective priority must drop below the backup.
|
|
conf = _render(0)
|
|
weight_line = [l for l in conf.splitlines() if l.strip().startswith("weight ")][0]
|
|
weight = int(weight_line.strip().split()[1])
|
|
assert 150 + weight < 100, "failed master must fall below every backup"
|
|
assert "track_script" in conf and "chk_haproxy" in conf
|
|
|
|
def test_track_disabled_omits_script(self):
|
|
vip = {**VIP, "track_haproxy": False}
|
|
conf = kc.render_keepalived_conf(vip=vip, members=MEMBERS, this_agent=MEMBERS[0],
|
|
peer_ips=["10.0.0.12"], auth_pass_plain=None)
|
|
assert "vrrp_script" not in conf
|
|
assert "track_script" not in conf
|
|
|
|
def test_no_auth_when_secret_absent(self):
|
|
conf = _render(0, auth=None)
|
|
assert "auth_pass" not in conf
|
|
|
|
def test_multicast_omits_unicast(self):
|
|
vip = {**VIP, "use_unicast": False}
|
|
conf = kc.render_keepalived_conf(vip=vip, members=MEMBERS, this_agent=MEMBERS[0],
|
|
peer_ips=["10.0.0.12"], auth_pass_plain="x")
|
|
assert "unicast_src_ip" not in conf
|
|
assert "unicast_peer" not in conf
|
|
|
|
def test_single_node_omits_unicast_block(self):
|
|
# Single-node VIP (no peers): even with use_unicast=True we must NOT emit a bare
|
|
# `unicast_src_ip`/`unicast_peer` — keepalived treats a unicast keyword with no peers
|
|
# as deprecated, warns, and falls back to multicast (and `keepalived -t` flags it).
|
|
# Omitting the block yields a clean multicast config that holds the VIP solo.
|
|
only = [{"role": "MASTER", "priority": 150, "network_interface": "eth0",
|
|
"agent_id": 1, "ip_address": "10.0.0.11"}]
|
|
conf = kc.render_keepalived_conf(vip=VIP, members=only, this_agent=only[0],
|
|
peer_ips=[], auth_pass_plain=None)
|
|
assert "unicast_src_ip" not in conf
|
|
assert "unicast_peer" not in conf
|
|
assert "state MASTER" in conf
|
|
assert "10.0.0.100/24 dev eth0" in conf
|
|
|
|
|
|
class TestCheckScript:
|
|
def test_default_process_name(self):
|
|
s = kc.build_haproxy_check_script()
|
|
assert "pidof haproxy" in s
|
|
assert kc.OWNERSHIP_MARKER in s
|
|
|
|
def test_process_name_from_bin_path(self):
|
|
s = kc.build_haproxy_check_script(bin_path="/opt/hap/sbin/haproxy-ent")
|
|
assert "pidof haproxy-ent" in s
|
|
|
|
def test_malicious_bin_path_falls_back(self):
|
|
s = kc.build_haproxy_check_script(bin_path="/x/haproxy; rm -rf /")
|
|
assert "rm -rf" not in s
|
|
assert "pidof haproxy" in s
|
|
|
|
|
|
class TestSecretCrypto:
|
|
def test_roundtrip(self):
|
|
kc.reset_fernet_for_tests()
|
|
token = kc.encrypt_vrrp_secret("s3cr3t")
|
|
assert token != "s3cr3t"
|
|
assert kc.decrypt_vrrp_secret(token) == "s3cr3t"
|
|
|
|
def test_decrypt_garbage_returns_none(self):
|
|
kc.reset_fernet_for_tests()
|
|
assert kc.decrypt_vrrp_secret("not-a-fernet-token") is None
|