Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
394 lines
15 KiB
Python
394 lines
15 KiB
Python
"""Tests for Docker service functionality."""
|
|
import pytest
|
|
from datetime import datetime, timezone
|
|
from contextlib import asynccontextmanager
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from app.models.docker_container import DockerContainer
|
|
from app.models.docker_image import DockerImage
|
|
from app.models.docker_volume import DockerVolume
|
|
from app.models.docker_alert import DockerAlert
|
|
from app.crud.docker_container import DockerContainerRepository
|
|
from app.crud.docker_image import DockerImageRepository
|
|
from app.crud.docker_volume import DockerVolumeRepository
|
|
from app.crud.docker_alert import DockerAlertRepository
|
|
|
|
|
|
class TestDockerContainerRepository:
|
|
"""Tests for DockerContainerRepository."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_creates_new_container(self, db_session):
|
|
"""Test that upsert creates a new container when it doesn't exist."""
|
|
repo = DockerContainerRepository(db_session)
|
|
|
|
container = await repo.upsert(
|
|
host_id="test-host-1",
|
|
container_id="abc123def456",
|
|
name="nginx",
|
|
image="nginx:latest",
|
|
state="running",
|
|
status="Up 2 hours"
|
|
)
|
|
|
|
assert container.name == "nginx"
|
|
assert container.state == "running"
|
|
assert container.container_id == "abc123def456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_updates_existing_container(self, db_session):
|
|
"""Test that upsert updates an existing container."""
|
|
repo = DockerContainerRepository(db_session)
|
|
|
|
# Create first
|
|
await repo.upsert(
|
|
host_id="test-host-1",
|
|
container_id="abc123def456",
|
|
name="nginx",
|
|
state="running"
|
|
)
|
|
await db_session.commit()
|
|
|
|
# Update
|
|
container = await repo.upsert(
|
|
host_id="test-host-1",
|
|
container_id="abc123def456",
|
|
name="nginx",
|
|
state="exited"
|
|
)
|
|
|
|
assert container.state == "exited"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_by_host(self, db_session):
|
|
"""Test listing containers by host."""
|
|
repo = DockerContainerRepository(db_session)
|
|
|
|
await repo.upsert(host_id="host-1", container_id="c1", name="container1", state="running")
|
|
await repo.upsert(host_id="host-1", container_id="c2", name="container2", state="exited")
|
|
await repo.upsert(host_id="host-2", container_id="c3", name="container3", state="running")
|
|
await db_session.commit()
|
|
|
|
host1_containers = await repo.list_by_host("host-1")
|
|
assert len(host1_containers) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_count_by_host(self, db_session):
|
|
"""Test counting containers by host."""
|
|
repo = DockerContainerRepository(db_session)
|
|
|
|
await repo.upsert(host_id="host-1", container_id="c1", name="container1", state="running")
|
|
await repo.upsert(host_id="host-1", container_id="c2", name="container2", state="running")
|
|
await repo.upsert(host_id="host-1", container_id="c3", name="container3", state="exited")
|
|
await db_session.commit()
|
|
|
|
counts = await repo.count_by_host("host-1")
|
|
assert counts["total"] == 3
|
|
assert counts["running"] == 2
|
|
|
|
|
|
class TestDockerAlertRepository:
|
|
"""Tests for DockerAlertRepository."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_alert(self, db_session):
|
|
"""Test creating a new alert."""
|
|
repo = DockerAlertRepository(db_session)
|
|
|
|
alert = await repo.create(
|
|
host_id="test-host-1",
|
|
container_name="nginx",
|
|
severity="error",
|
|
message="Container is down"
|
|
)
|
|
await db_session.commit()
|
|
|
|
assert alert.id is not None
|
|
assert alert.state == "open"
|
|
assert alert.severity == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_open_alert(self, db_session):
|
|
"""Test getting an open alert for a container."""
|
|
repo = DockerAlertRepository(db_session)
|
|
|
|
await repo.create(
|
|
host_id="test-host-1",
|
|
container_name="nginx",
|
|
severity="error",
|
|
message="Container is down"
|
|
)
|
|
await db_session.commit()
|
|
|
|
alert = await repo.get_open_alert("test-host-1", "nginx")
|
|
assert alert is not None
|
|
assert alert.container_name == "nginx"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acknowledge_alert(self, db_session):
|
|
"""Test acknowledging an alert."""
|
|
repo = DockerAlertRepository(db_session)
|
|
|
|
alert = await repo.create(
|
|
host_id="test-host-1",
|
|
container_name="nginx",
|
|
severity="error",
|
|
message="Container is down"
|
|
)
|
|
await db_session.commit()
|
|
|
|
acknowledged = await repo.acknowledge(alert.id, "admin")
|
|
assert acknowledged is not None
|
|
assert acknowledged.state == "acknowledged"
|
|
assert acknowledged.acknowledged_by == "admin"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_alert(self, db_session):
|
|
"""Test closing an alert."""
|
|
repo = DockerAlertRepository(db_session)
|
|
|
|
alert = await repo.create(
|
|
host_id="test-host-1",
|
|
container_name="nginx",
|
|
severity="error",
|
|
message="Container is down"
|
|
)
|
|
await db_session.commit()
|
|
|
|
closed = await repo.close(alert.id)
|
|
assert closed is not None
|
|
assert closed.state == "closed"
|
|
assert closed.closed_at is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_for_container(self, db_session):
|
|
"""Test closing all alerts for a container."""
|
|
repo = DockerAlertRepository(db_session)
|
|
|
|
await repo.create(host_id="host-1", container_name="nginx", severity="error", message="Down")
|
|
await repo.create(host_id="host-1", container_name="nginx", severity="warning", message="Health check")
|
|
await db_session.commit()
|
|
|
|
count = await repo.close_for_container("host-1", "nginx")
|
|
assert count == 2
|
|
|
|
|
|
class TestDockerAlertDetection:
|
|
"""Tests for Docker alert detection logic."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detect_container_down(self):
|
|
"""Test detection of container that should be running but is stopped."""
|
|
container = MagicMock()
|
|
container.state = "exited"
|
|
container.health = None
|
|
container.labels = {"homelab.monitor": "true", "homelab.desired": "running"}
|
|
container.name = "nginx"
|
|
container.host_id = "host-1"
|
|
|
|
# Container is exited but should be running -> should trigger alert
|
|
expected_state = container.labels.get("homelab.desired", "running")
|
|
should_alert = expected_state == "running" and container.state != "running"
|
|
|
|
assert should_alert is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detect_unhealthy_container(self):
|
|
"""Test detection of unhealthy container."""
|
|
container = MagicMock()
|
|
container.state = "running"
|
|
container.health = "unhealthy"
|
|
container.labels = {"homelab.monitor": "true"}
|
|
container.name = "nginx"
|
|
|
|
# Container is unhealthy -> should trigger warning
|
|
should_alert = container.health == "unhealthy"
|
|
|
|
assert should_alert is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_alert_for_healthy_running_container(self):
|
|
"""Test that healthy running containers don't trigger alerts."""
|
|
container = MagicMock()
|
|
container.state = "running"
|
|
container.health = "healthy"
|
|
container.labels = {"homelab.monitor": "true", "homelab.desired": "running"}
|
|
|
|
expected_state = container.labels.get("homelab.desired", "running")
|
|
should_close_alert = container.state == "running" and container.health in ("healthy", None)
|
|
|
|
assert should_close_alert is True
|
|
|
|
|
|
class TestDockerServiceSSH:
|
|
"""Tests for Docker service SSH functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parse_docker_json_lines(self):
|
|
"""Test parsing Docker JSON output."""
|
|
from app.services.docker_service import DockerService
|
|
|
|
service = DockerService()
|
|
|
|
output = '{"ID":"abc123","Names":"nginx","State":"running"}\n{"ID":"def456","Names":"redis","State":"exited"}'
|
|
|
|
result = await service._parse_docker_json_lines(output)
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["ID"] == "abc123"
|
|
assert result[1]["State"] == "exited"
|
|
|
|
def test_parse_size(self):
|
|
"""Test parsing Docker size strings."""
|
|
from app.services.docker_service import DockerService
|
|
|
|
service = DockerService()
|
|
|
|
assert service._parse_size("100MB") == 100 * 1024 * 1024
|
|
assert service._parse_size("1.5GB") == int(1.5 * 1024 * 1024 * 1024)
|
|
assert service._parse_size("500KB") == 500 * 1024
|
|
assert service._parse_size("") == 0
|
|
assert service._parse_size("invalid") == 0
|
|
|
|
|
|
class TestDockerServiceHostFiltering:
|
|
"""Tests for Docker host listing and filtering."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_docker_hosts_filters_role_docker(self):
|
|
"""Only hosts in inventory group role_docker must be visible in Docker section."""
|
|
from app.services.docker_service import docker_service
|
|
|
|
host1 = MagicMock()
|
|
host1.id = "host-1"
|
|
host1.name = "docker-1"
|
|
host1.ip_address = "10.0.0.1"
|
|
host1.deleted_at = None
|
|
host1.docker_enabled = True
|
|
host1.docker_version = None
|
|
host1.docker_status = None
|
|
host1.docker_last_collect_at = None
|
|
|
|
host2 = MagicMock()
|
|
host2.id = "host-2"
|
|
host2.name = "docker-2"
|
|
host2.ip_address = "10.0.0.2"
|
|
host2.deleted_at = None
|
|
host2.docker_enabled = False
|
|
host2.docker_version = None
|
|
host2.docker_status = None
|
|
host2.docker_last_collect_at = None
|
|
|
|
host3 = MagicMock()
|
|
host3.id = "host-3"
|
|
host3.name = "not-docker"
|
|
host3.ip_address = "10.0.0.3"
|
|
host3.deleted_at = None
|
|
host3.docker_enabled = True
|
|
host3.docker_version = None
|
|
host3.docker_status = None
|
|
host3.docker_last_collect_at = None
|
|
|
|
inv_h1 = MagicMock()
|
|
inv_h1.name = "docker-1"
|
|
inv_h1.groups = ["role_docker"]
|
|
|
|
inv_h2 = MagicMock()
|
|
inv_h2.name = "docker-2"
|
|
inv_h2.groups = ["role_docker"]
|
|
|
|
inv_h3 = MagicMock()
|
|
inv_h3.name = "not-docker"
|
|
inv_h3.groups = ["role_web"]
|
|
|
|
@asynccontextmanager
|
|
async def _dummy_session_ctx():
|
|
yield MagicMock()
|
|
|
|
host_repo_instance = MagicMock()
|
|
host_repo_instance.list = AsyncMock(return_value=[host1, host2, host3])
|
|
|
|
container_repo_instance = MagicMock()
|
|
container_repo_instance.count_by_host = AsyncMock(return_value={"total": 0, "running": 0})
|
|
|
|
image_repo_instance = MagicMock()
|
|
image_repo_instance.count_by_host = AsyncMock(return_value={"total": 0, "total_size": 0})
|
|
|
|
volume_repo_instance = MagicMock()
|
|
volume_repo_instance.count_by_host = AsyncMock(return_value=0)
|
|
|
|
alert_repo_instance = MagicMock()
|
|
alert_repo_instance.count_open_by_host = AsyncMock(return_value=0)
|
|
|
|
with patch("app.services.docker_service.async_session_maker", new=_dummy_session_ctx):
|
|
with patch("app.services.docker_service.ansible_service.get_hosts_from_inventory", return_value=[inv_h1, inv_h2, inv_h3]):
|
|
with patch("app.services.docker_service.HostRepository", return_value=host_repo_instance):
|
|
with patch("app.services.docker_service.DockerContainerRepository", return_value=container_repo_instance):
|
|
with patch("app.services.docker_service.DockerImageRepository", return_value=image_repo_instance):
|
|
with patch("app.services.docker_service.DockerVolumeRepository", return_value=volume_repo_instance):
|
|
with patch("app.services.docker_service.DockerAlertRepository", return_value=alert_repo_instance):
|
|
hosts = await docker_service.get_docker_hosts()
|
|
|
|
assert [h["host_name"] for h in hosts] == ["docker-1", "docker-2"]
|
|
by_id = {h["host_id"]: h for h in hosts}
|
|
assert by_id["host-1"]["docker_enabled"] is True
|
|
assert by_id["host-2"]["docker_enabled"] is False
|
|
|
|
|
|
class TestDockerActions:
|
|
"""Tests for Docker container actions."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_container_success(self):
|
|
"""Test successful container start."""
|
|
from app.services.docker_actions import DockerActionsService
|
|
|
|
service = DockerActionsService()
|
|
|
|
with patch.object(service, '_get_host_info', new_callable=AsyncMock) as mock_host:
|
|
mock_host.return_value = {"id": "host-1", "name": "test-host", "ip": "192.168.1.10"}
|
|
|
|
with patch.object(service, '_ssh_connect', new_callable=AsyncMock) as mock_connect:
|
|
mock_conn = AsyncMock()
|
|
mock_connect.return_value = mock_conn
|
|
|
|
with patch.object(service, '_ssh_exec', new_callable=AsyncMock) as mock_exec:
|
|
mock_exec.return_value = {
|
|
"success": True,
|
|
"stdout": "abc123",
|
|
"stderr": "",
|
|
"exit_code": 0
|
|
}
|
|
|
|
result = await service.start_container("host-1", "abc123")
|
|
|
|
assert result["success"] is True
|
|
assert result["action"] == "start"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_container_success(self):
|
|
"""Test successful container stop."""
|
|
from app.services.docker_actions import DockerActionsService
|
|
|
|
service = DockerActionsService()
|
|
|
|
with patch.object(service, '_get_host_info', new_callable=AsyncMock) as mock_host:
|
|
mock_host.return_value = {"id": "host-1", "name": "test-host", "ip": "192.168.1.10"}
|
|
|
|
with patch.object(service, '_ssh_connect', new_callable=AsyncMock) as mock_connect:
|
|
mock_conn = AsyncMock()
|
|
mock_connect.return_value = mock_conn
|
|
|
|
with patch.object(service, '_ssh_exec', new_callable=AsyncMock) as mock_exec:
|
|
mock_exec.return_value = {
|
|
"success": True,
|
|
"stdout": "abc123",
|
|
"stderr": "",
|
|
"exit_code": 0
|
|
}
|
|
|
|
result = await service.stop_container("host-1", "abc123")
|
|
|
|
assert result["success"] is True
|
|
assert result["action"] == "stop"
|