2026-02-27 12:34:32 +01:00
|
|
|
"""Unit tests for the HAProxy provider, mocking at socket level."""
|
|
|
|
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from nix_builder_autoscaler.providers.haproxy import HAProxyError, HAProxyRuntime
|
|
|
|
|
|
|
|
|
|
# HAProxy `show stat` CSV — trimmed to columns the parser uses.
|
|
|
|
|
# Full output has many more columns; we keep through `status` (col 17).
|
|
|
|
|
SHOW_STAT_CSV = (
|
|
|
|
|
"# pxname,svname,qcur,qmax,scur,smax,slim,stot,"
|
|
|
|
|
"bin,bout,dreq,dresp,ereq,econ,eresp,wretr,wredis,status\n"
|
|
|
|
|
"all,BACKEND,0,0,2,5,200,100,5000,6000,0,0,,0,0,0,0,UP\n"
|
|
|
|
|
"all,slot001,0,0,1,3,50,50,2500,3000,0,0,,0,0,0,0,UP\n"
|
|
|
|
|
"all,slot002,0,0,1,2,50,50,2500,3000,0,0,,0,0,0,0,DOWN\n"
|
|
|
|
|
"all,slot003,0,0,0,0,50,0,0,0,0,0,,0,0,0,0,MAINT\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSetSlotAddr:
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_sends_correct_command(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [b"\n", b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
h.set_slot_addr("slot001", "100.64.0.1", 22)
|
|
|
|
|
|
|
|
|
|
mock_sock.connect.assert_called_once_with("/tmp/test.sock")
|
|
|
|
|
mock_sock.sendall.assert_called_once_with(
|
|
|
|
|
b"set server all/slot001 addr 100.64.0.1 port 22\n"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEnableSlot:
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_sends_correct_command(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [b"\n", b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
h.enable_slot("slot001")
|
|
|
|
|
|
|
|
|
|
mock_sock.sendall.assert_called_once_with(b"enable server all/slot001\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDisableSlot:
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_sends_correct_command(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [b"\n", b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
h.disable_slot("slot001")
|
|
|
|
|
|
|
|
|
|
mock_sock.sendall.assert_called_once_with(b"disable server all/slot001\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReadSlotHealth:
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_parses_csv_correctly(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [SHOW_STAT_CSV.encode(), b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
health = h.read_slot_health()
|
|
|
|
|
|
|
|
|
|
assert len(health) == 3
|
|
|
|
|
# BACKEND row should be excluded (svname "BACKEND" doesn't start with "slot")
|
|
|
|
|
|
|
|
|
|
assert health["slot001"].status == "UP"
|
|
|
|
|
assert health["slot001"].scur == 1
|
|
|
|
|
assert health["slot001"].qcur == 0
|
|
|
|
|
|
|
|
|
|
assert health["slot002"].status == "DOWN"
|
|
|
|
|
assert health["slot002"].scur == 1
|
|
|
|
|
assert health["slot002"].qcur == 0
|
|
|
|
|
|
|
|
|
|
assert health["slot003"].status == "MAINT"
|
|
|
|
|
assert health["slot003"].scur == 0
|
|
|
|
|
assert health["slot003"].qcur == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSlotIsUp:
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_returns_true_for_up_slot(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [SHOW_STAT_CSV.encode(), b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
assert h.slot_is_up("slot001") is True
|
|
|
|
|
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_returns_false_for_down_slot(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [SHOW_STAT_CSV.encode(), b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
assert h.slot_is_up("slot002") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestErrorHandling:
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_unrecognized_slot_raises_haproxy_error(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [b"No such server.\n", b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
with pytest.raises(HAProxyError, match="No such server"):
|
|
|
|
|
h.set_slot_addr("slot999", "100.64.0.1")
|
|
|
|
|
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_socket_not_found_raises_haproxy_error(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.connect.side_effect = FileNotFoundError("No such file")
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/nonexistent.sock", "all", "slot")
|
|
|
|
|
with pytest.raises(HAProxyError, match="socket not found"):
|
|
|
|
|
h.set_slot_addr("slot001", "100.64.0.1")
|
|
|
|
|
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_connection_refused_raises_haproxy_error(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.connect.side_effect = ConnectionRefusedError("Connection refused")
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
with pytest.raises(HAProxyError, match="Connection refused"):
|
|
|
|
|
h.enable_slot("slot001")
|
|
|
|
|
|
|
|
|
|
@patch("nix_builder_autoscaler.providers.haproxy.socket.socket")
|
|
|
|
|
def test_slot_session_count_missing_slot_raises(self, mock_socket_cls):
|
|
|
|
|
mock_sock = MagicMock()
|
|
|
|
|
mock_socket_cls.return_value = mock_sock
|
|
|
|
|
mock_sock.recv.side_effect = [SHOW_STAT_CSV.encode(), b""]
|
|
|
|
|
|
|
|
|
|
h = HAProxyRuntime("/tmp/test.sock", "all", "slot")
|
|
|
|
|
with pytest.raises(HAProxyError, match="Slot not found"):
|
|
|
|
|
h.slot_session_count("slot_nonexistent")
|