mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-08-02 19:31:30 +01:00
327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""Tests for operations module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from compose_farm.cli import lifecycle
|
|
from compose_farm.config import Config, Host
|
|
from compose_farm.executor import CommandResult, RemoteCheckError
|
|
from compose_farm.operations import (
|
|
PreflightResult,
|
|
_migrate_stack,
|
|
_report_preflight_failures,
|
|
build_discovery_results,
|
|
build_up_cmd,
|
|
check_stack_requirements,
|
|
up_stacks,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_config(tmp_path: Path) -> Config:
|
|
"""Create a basic test config."""
|
|
compose_dir = tmp_path / "compose"
|
|
stack_dir = compose_dir / "test-service"
|
|
stack_dir.mkdir(parents=True)
|
|
(stack_dir / "docker-compose.yml").write_text("services: {}")
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={
|
|
"host1": Host(address="localhost"),
|
|
"host2": Host(address="localhost"),
|
|
},
|
|
stacks={"test-service": "host2"},
|
|
)
|
|
|
|
|
|
class TestMigrationCommands:
|
|
"""Tests for migration command sequence."""
|
|
|
|
@pytest.fixture
|
|
def config(self, tmp_path: Path) -> Config:
|
|
"""Create a test config."""
|
|
compose_dir = tmp_path / "compose"
|
|
stack_dir = compose_dir / "test-service"
|
|
stack_dir.mkdir(parents=True)
|
|
(stack_dir / "docker-compose.yml").write_text("services: {}")
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={
|
|
"host1": Host(address="localhost"),
|
|
"host2": Host(address="localhost"),
|
|
},
|
|
stacks={"test-service": "host2"},
|
|
)
|
|
|
|
async def test_migration_uses_pull_ignore_buildable(self, config: Config) -> None:
|
|
"""Migration should use 'pull --ignore-buildable' to skip buildable images."""
|
|
commands_called: list[str] = []
|
|
|
|
async def mock_run_compose_step(
|
|
cfg: Config,
|
|
stack: str,
|
|
command: str,
|
|
*,
|
|
raw: bool,
|
|
host: str | None = None,
|
|
) -> CommandResult:
|
|
commands_called.append(command)
|
|
return CommandResult(
|
|
stack=stack,
|
|
exit_code=0,
|
|
success=True,
|
|
)
|
|
|
|
with patch(
|
|
"compose_farm.operations._run_compose_step",
|
|
side_effect=mock_run_compose_step,
|
|
):
|
|
await _migrate_stack(
|
|
config,
|
|
"test-service",
|
|
current_host="host1",
|
|
target_host="host2",
|
|
prefix="[test]",
|
|
raw=False,
|
|
)
|
|
|
|
# Migration should call pull with --ignore-buildable, then build, then down
|
|
assert "pull --ignore-buildable" in commands_called
|
|
assert "build" in commands_called
|
|
assert "down" in commands_called
|
|
# pull should come before build
|
|
pull_idx = commands_called.index("pull --ignore-buildable")
|
|
build_idx = commands_called.index("build")
|
|
assert pull_idx < build_idx
|
|
|
|
|
|
class TestBuildUpCmd:
|
|
"""Tests for build_up_cmd helper."""
|
|
|
|
def test_basic(self) -> None:
|
|
"""Basic up command without flags."""
|
|
assert build_up_cmd() == "up -d"
|
|
|
|
def test_with_pull(self) -> None:
|
|
"""Up command with pull flag."""
|
|
assert build_up_cmd(pull=True) == "up -d --pull always"
|
|
|
|
def test_with_build(self) -> None:
|
|
"""Up command with build flag."""
|
|
assert build_up_cmd(build=True) == "up -d --build"
|
|
|
|
def test_with_pull_and_build(self) -> None:
|
|
"""Up command with both flags."""
|
|
assert build_up_cmd(pull=True, build=True) == "up -d --pull always --build"
|
|
|
|
def test_with_service(self) -> None:
|
|
"""Up command targeting a specific service."""
|
|
assert build_up_cmd(service="web") == "up -d web"
|
|
|
|
def test_with_all_options(self) -> None:
|
|
"""Up command with all options."""
|
|
assert (
|
|
build_up_cmd(pull=True, build=True, service="web") == "up -d --pull always --build web"
|
|
)
|
|
|
|
|
|
class TestPreflightRequirements:
|
|
"""Tests for stack preflight checks."""
|
|
|
|
async def test_remote_check_error_is_not_reported_as_missing_paths(
|
|
self, basic_config: Config
|
|
) -> None:
|
|
"""A failed SSH check should be distinct from real missing paths."""
|
|
with patch(
|
|
"compose_farm.operations.check_paths_exist",
|
|
side_effect=RemoteCheckError(
|
|
"host2",
|
|
"mount-check",
|
|
"Permission denied for user basnijholt",
|
|
),
|
|
):
|
|
result = await check_stack_requirements(basic_config, "test-service", "host2")
|
|
|
|
assert result.ok is False
|
|
assert result.missing_paths == []
|
|
assert result.missing_networks == []
|
|
assert result.missing_devices == []
|
|
assert result.check_errors == [
|
|
"mount-check failed on host2: Permission denied for user basnijholt"
|
|
]
|
|
|
|
def test_report_preflight_failures_includes_remote_check_error(
|
|
self, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""User-facing preflight output should show the real remote-check cause."""
|
|
_report_preflight_failures(
|
|
"test-service",
|
|
"host2",
|
|
PreflightResult(
|
|
missing_paths=[],
|
|
missing_networks=[],
|
|
missing_devices=[],
|
|
check_errors=["mount-check failed on host2: Permission denied"],
|
|
),
|
|
)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "remote check failed" in captured.err
|
|
assert "Permission denied" in captured.err
|
|
assert "missing path" not in captured.err
|
|
|
|
async def test_up_stacks_preflight_failure_includes_host(
|
|
self,
|
|
basic_config: Config,
|
|
) -> None:
|
|
"""Synthetic preflight failures should still report the target host."""
|
|
preflight = PreflightResult(
|
|
missing_paths=["/mnt/data/test-service"],
|
|
missing_networks=[],
|
|
missing_devices=[],
|
|
check_errors=[],
|
|
)
|
|
|
|
with patch("compose_farm.operations.check_stack_requirements", return_value=preflight):
|
|
results = await up_stacks(basic_config, ["test-service"])
|
|
|
|
assert len(results) == 1
|
|
assert results[0].success is False
|
|
assert results[0].stack == "test-service"
|
|
assert results[0].host == "host2"
|
|
|
|
async def test_up_stacks_multi_host_forces_streamed_output(self, tmp_path: Path) -> None:
|
|
"""Even raw=True must not allocate multiple compose TTY renderers."""
|
|
compose_dir = tmp_path / "compose"
|
|
(compose_dir / "glances").mkdir(parents=True)
|
|
(compose_dir / "glances" / "docker-compose.yml").write_text("services: {}\n")
|
|
cfg = Config(
|
|
compose_dir=compose_dir,
|
|
hosts={
|
|
"host1": Host(address="192.168.1.1"),
|
|
"host2": Host(address="192.168.1.2"),
|
|
},
|
|
stacks={"glances": ["host1", "host2"]},
|
|
)
|
|
preflight = PreflightResult([], [], [], [])
|
|
|
|
async def run_result(*args: object, **kwargs: object) -> CommandResult:
|
|
return CommandResult(
|
|
stack="glances",
|
|
exit_code=0,
|
|
success=True,
|
|
host=str(kwargs.get("host_name", "")),
|
|
label=str(kwargs.get("label", "")),
|
|
)
|
|
|
|
with (
|
|
patch("compose_farm.operations.check_stack_requirements", return_value=preflight),
|
|
patch("compose_farm.operations.run_command", new_callable=AsyncMock) as mock_run,
|
|
patch("compose_farm.operations.set_multi_host_stack"),
|
|
):
|
|
mock_run.side_effect = run_result
|
|
results = await up_stacks(cfg, ["glances"], raw=True, pull=True, build=True)
|
|
|
|
assert len(results) == 2
|
|
assert all(result.success for result in results)
|
|
assert all(call.kwargs["raw"] is False for call in mock_run.call_args_list)
|
|
assert all(call.kwargs["stream"] is True for call in mock_run.call_args_list)
|
|
|
|
|
|
class TestUpdateCommandSequence:
|
|
"""Tests for update command sequence."""
|
|
|
|
def test_update_delegates_to_up_with_pull_and_build(self) -> None:
|
|
"""Update command should delegate to up with pull=True and build=True."""
|
|
source = inspect.getsource(lifecycle.update)
|
|
|
|
# Verify update calls up with pull=True and build=True
|
|
assert "up(" in source
|
|
assert "pull=True" in source
|
|
assert "build=True" in source
|
|
|
|
|
|
class TestBuildDiscoveryResults:
|
|
"""Tests for build_discovery_results function."""
|
|
|
|
@pytest.fixture
|
|
def config(self, tmp_path: Path) -> Config:
|
|
"""Create a test config with multiple stacks."""
|
|
compose_dir = tmp_path / "compose"
|
|
for stack in ["plex", "jellyfin", "sonarr"]:
|
|
(compose_dir / stack).mkdir(parents=True)
|
|
(compose_dir / stack / "docker-compose.yml").write_text("services: {}")
|
|
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={
|
|
"host1": Host(address="localhost"),
|
|
"host2": Host(address="localhost"),
|
|
},
|
|
stacks={"plex": "host1", "jellyfin": "host1", "sonarr": "host2"},
|
|
)
|
|
|
|
def test_discovers_correctly_running_stacks(self, config: Config) -> None:
|
|
"""Stacks running on correct hosts are discovered."""
|
|
running_on_host = {
|
|
"host1": {"plex", "jellyfin"},
|
|
"host2": {"sonarr"},
|
|
}
|
|
|
|
discovered, strays, duplicates = build_discovery_results(config, running_on_host)
|
|
|
|
assert discovered == {"plex": "host1", "jellyfin": "host1", "sonarr": "host2"}
|
|
assert strays == {}
|
|
assert duplicates == {}
|
|
|
|
def test_detects_stray_stacks(self, config: Config) -> None:
|
|
"""Stacks running on wrong hosts are marked as strays."""
|
|
running_on_host = {
|
|
"host1": set(),
|
|
"host2": {"plex"}, # plex should be on host1
|
|
}
|
|
|
|
discovered, strays, _duplicates = build_discovery_results(config, running_on_host)
|
|
|
|
assert "plex" not in discovered
|
|
assert strays == {"plex": ["host2"]}
|
|
|
|
def test_detects_duplicates(self, config: Config) -> None:
|
|
"""Single-host stacks running on multiple hosts are duplicates."""
|
|
running_on_host = {
|
|
"host1": {"plex"},
|
|
"host2": {"plex"}, # plex running on both hosts
|
|
}
|
|
|
|
discovered, strays, duplicates = build_discovery_results(
|
|
config, running_on_host, stacks=["plex"]
|
|
)
|
|
|
|
# plex is correctly running on host1
|
|
assert discovered == {"plex": "host1"}
|
|
# plex is also a stray on host2
|
|
assert strays == {"plex": ["host2"]}
|
|
# plex is a duplicate (single-host stack on multiple hosts)
|
|
assert duplicates == {"plex": ["host1", "host2"]}
|
|
|
|
def test_filters_to_requested_stacks(self, config: Config) -> None:
|
|
"""Only returns results for requested stacks."""
|
|
running_on_host = {
|
|
"host1": {"plex", "jellyfin"},
|
|
"host2": {"sonarr"},
|
|
}
|
|
|
|
discovered, _strays, _duplicates = build_discovery_results(
|
|
config, running_on_host, stacks=["plex"]
|
|
)
|
|
|
|
# Only plex should be in results
|
|
assert discovered == {"plex": "host1"}
|
|
assert "jellyfin" not in discovered
|
|
assert "sonarr" not in discovered
|