Files
taylanbakircioglu a1192e602d feat: HA / VIP (Keepalived) management from the UI (#27)
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.
2026-06-07 01:52:20 +03:00

92 lines
3.6 KiB
Python

"""Issue #27 (v1.7.0) — unit tests for VIP request-model validation (pure, no DB)."""
from __future__ import annotations
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from models.vip import VIPCreate, VIPMemberIn # noqa: E402
def _members(master_prio=150, backup_prio=100, n_backup=1):
members = [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": master_prio}]
for i in range(n_backup):
members.append({"agent_id": 2 + i, "network_interface": "eth0", "role": "BACKUP", "priority": backup_prio})
return members
def _create(**over):
base = dict(name="web-vip", pool_id=1, virtual_ip="10.0.0.100", members=_members())
base.update(over)
return VIPCreate(**base)
class TestField:
def test_ok(self):
v = _create()
assert v.virtual_ip == "10.0.0.100"
assert v.use_unicast is True # cloud-safe default
def test_ipv6_rejected(self):
with pytest.raises(ValueError):
_create(virtual_ip="fd00::1")
def test_bad_ip_rejected(self):
with pytest.raises(ValueError):
_create(virtual_ip="not-an-ip")
def test_auth_pass_max_8(self):
_create(auth_pass="12345678")
with pytest.raises(ValueError):
_create(auth_pass="123456789")
def test_interface_forbidden_char(self):
with pytest.raises(ValueError):
VIPMemberIn(agent_id=1, network_interface="eth0; rm -rf /", role="BACKUP", priority=100)
def test_priority_range(self):
with pytest.raises(ValueError):
VIPMemberIn(agent_id=1, network_interface="eth0", role="BACKUP", priority=255)
def test_role_normalized(self):
m = VIPMemberIn(agent_id=1, network_interface="eth0", role="master", priority=150)
assert m.role == "MASTER"
def test_vrid_range(self):
with pytest.raises(ValueError):
_create(virtual_router_id=300)
class TestMembers:
def test_single_node_allowed(self):
# A single-node VIP (one MASTER, no BACKUP) is valid: a keepalived-managed floating
# IP without failover (Issue #27 follow-up — relaxed from >=2 members to >=1, e.g. a
# one-box cluster that wants a stable VIP, or before a 2nd node is added for real HA).
v = VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5",
members=[{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150}])
assert len(v.members) == 1 and v.members[0].role == "MASTER"
def test_needs_at_least_one(self):
# An empty membership is still rejected — a VIP must have at least one node.
with pytest.raises(ValueError):
VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5", members=[])
def test_exactly_one_master(self):
bad = [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150},
{"agent_id": 2, "network_interface": "eth0", "role": "MASTER", "priority": 140}]
with pytest.raises(ValueError):
VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5", members=bad)
def test_master_must_be_highest(self):
with pytest.raises(ValueError):
_create(members=_members(master_prio=100, backup_prio=120))
def test_no_duplicate_agent(self):
dup = [{"agent_id": 1, "network_interface": "eth0", "role": "MASTER", "priority": 150},
{"agent_id": 1, "network_interface": "eth1", "role": "BACKUP", "priority": 100}]
with pytest.raises(ValueError):
VIPCreate(name="x", pool_id=1, virtual_ip="10.0.0.5", members=dup)