mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
* Add self-healing: detect and stop rogue containers Adds the ability to detect and stop "rogue" containers - stacks running on hosts they shouldn't be according to config. Changes: - `cf refresh`: Now scans ALL hosts and warns about rogues/duplicates - `cf apply`: Stops rogue containers before migrations (new phase) - New `--no-rogues` flag to skip rogue detection Implementation: - Add StackDiscoveryResult for full host scanning results - Add discover_stack_on_all_hosts() to check all hosts in parallel - Add stop_rogue_stacks() to stop containers on unauthorized hosts - Update tests to include new no_rogues parameter * Update README.md * fix: Update refresh tests for _discover_stacks_full return type The function now returns a tuple (discovered, rogues, duplicates) for rogue/duplicate detection. Update test mocks accordingly. * Rename "rogue" terminology to "stray" for consistency Terminology update across the codebase: - rogue_hosts -> stray_hosts - is_rogue -> is_stray - stop_rogue_stacks -> stop_stray_stacks - _discover_rogues -> _discover_strays - --no-rogues -> --no-strays - _report_rogue_stacks -> _report_stray_stacks "Stray" better complements "orphaned" (both evoke lost things) while clearly indicating the stack is running somewhere it shouldn't be. * Update README.md * Move asyncio import to top level * Fix remaining rogue -> stray in docstrings and README * Refactor: Extract shared helpers to reduce duplication 1. Extract _stop_stacks_on_hosts helper in operations.py - Shared by stop_orphaned_stacks and stop_stray_stacks - Reduces ~50 lines of duplicated code 2. Refactor _discover_strays to reuse _discover_stacks_full - Removes duplicate discovery logic from lifecycle.py - Calls management._discover_stacks_full and merges duplicates * Add PR review prompt * Fix typos in PR review prompt * Move import to top level (no in-function imports) * Update README.md * Remove obvious comments
375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""Tests for sync command and related functions."""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from compose_farm import executor as executor_module
|
|
from compose_farm import state as state_module
|
|
from compose_farm.cli import management as cli_management_module
|
|
from compose_farm.config import Config, Host
|
|
from compose_farm.executor import CommandResult, check_stack_running
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config(tmp_path: Path) -> Config:
|
|
"""Create a mock config for testing."""
|
|
compose_dir = tmp_path / "stacks"
|
|
compose_dir.mkdir()
|
|
|
|
# Create stack directories with compose files
|
|
for stack in ["plex", "jellyfin", "grafana"]:
|
|
stack_dir = compose_dir / stack
|
|
stack_dir.mkdir()
|
|
(stack_dir / "compose.yaml").write_text(f"# {stack} compose file\n")
|
|
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={
|
|
"nas01": Host(address="192.168.1.10", user="admin", port=22),
|
|
"nas02": Host(address="192.168.1.11", user="admin", port=22),
|
|
},
|
|
stacks={
|
|
"plex": "nas01",
|
|
"jellyfin": "nas01",
|
|
"grafana": "nas02",
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Create a temporary state directory and patch _get_state_path."""
|
|
state_path = tmp_path / ".config" / "compose-farm"
|
|
state_path.mkdir(parents=True)
|
|
|
|
def mock_get_state_path() -> Path:
|
|
return state_path / "state.yaml"
|
|
|
|
monkeypatch.setattr(state_module, "_get_state_path", mock_get_state_path)
|
|
return state_path
|
|
|
|
|
|
class TestCheckStackRunning:
|
|
"""Tests for check_stack_running function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stack_running(self, mock_config: Config) -> None:
|
|
"""Returns True when stack has running containers."""
|
|
with patch.object(executor_module, "run_command", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = CommandResult(
|
|
stack="plex",
|
|
exit_code=0,
|
|
success=True,
|
|
stdout="abc123\ndef456\n",
|
|
)
|
|
result = await check_stack_running(mock_config, "plex", "nas01")
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stack_not_running(self, mock_config: Config) -> None:
|
|
"""Returns False when stack has no running containers."""
|
|
with patch.object(executor_module, "run_command", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = CommandResult(
|
|
stack="plex",
|
|
exit_code=0,
|
|
success=True,
|
|
stdout="",
|
|
)
|
|
result = await check_stack_running(mock_config, "plex", "nas01")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_command_failed(self, mock_config: Config) -> None:
|
|
"""Returns False when command fails."""
|
|
with patch.object(executor_module, "run_command", new_callable=AsyncMock) as mock_run:
|
|
mock_run.return_value = CommandResult(
|
|
stack="plex",
|
|
exit_code=1,
|
|
success=False,
|
|
)
|
|
result = await check_stack_running(mock_config, "plex", "nas01")
|
|
assert result is False
|
|
|
|
|
|
class TestMergeState:
|
|
"""Tests for _merge_state helper function."""
|
|
|
|
def test_merge_adds_new_stacks(self) -> None:
|
|
"""Merging adds newly discovered stacks to existing state."""
|
|
current: dict[str, str | list[str]] = {"plex": "nas01"}
|
|
discovered: dict[str, str | list[str]] = {"jellyfin": "nas02"}
|
|
removed: list[str] = []
|
|
|
|
result = cli_management_module._merge_state(current, discovered, removed)
|
|
|
|
assert result == {"plex": "nas01", "jellyfin": "nas02"}
|
|
|
|
def test_merge_updates_existing_stacks(self) -> None:
|
|
"""Merging updates stacks that changed hosts."""
|
|
current: dict[str, str | list[str]] = {"plex": "nas01", "jellyfin": "nas01"}
|
|
discovered: dict[str, str | list[str]] = {"plex": "nas02"} # plex moved to nas02
|
|
removed: list[str] = []
|
|
|
|
result = cli_management_module._merge_state(current, discovered, removed)
|
|
|
|
assert result == {"plex": "nas02", "jellyfin": "nas01"}
|
|
|
|
def test_merge_removes_stopped_stacks(self) -> None:
|
|
"""Merging removes stacks that were checked but not found."""
|
|
current: dict[str, str | list[str]] = {
|
|
"plex": "nas01",
|
|
"jellyfin": "nas01",
|
|
"grafana": "nas02",
|
|
}
|
|
discovered: dict[str, str | list[str]] = {"plex": "nas01"} # only plex still running
|
|
removed = ["jellyfin"] # jellyfin was checked and not found
|
|
|
|
result = cli_management_module._merge_state(current, discovered, removed)
|
|
|
|
# jellyfin removed, grafana untouched (wasn't in the refresh scope)
|
|
assert result == {"plex": "nas01", "grafana": "nas02"}
|
|
|
|
def test_merge_preserves_unrelated_stacks(self) -> None:
|
|
"""Merging preserves stacks that weren't part of the refresh."""
|
|
current: dict[str, str | list[str]] = {
|
|
"plex": "nas01",
|
|
"jellyfin": "nas01",
|
|
"grafana": "nas02",
|
|
}
|
|
discovered: dict[str, str | list[str]] = {"plex": "nas02"} # only refreshed plex
|
|
removed: list[str] = [] # nothing was removed
|
|
|
|
result = cli_management_module._merge_state(current, discovered, removed)
|
|
|
|
# plex updated, others preserved
|
|
assert result == {"plex": "nas02", "jellyfin": "nas01", "grafana": "nas02"}
|
|
|
|
|
|
class TestReportSyncChanges:
|
|
"""Tests for _report_sync_changes function."""
|
|
|
|
def test_reports_added(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Reports newly discovered stacks."""
|
|
cli_management_module._report_sync_changes(
|
|
added=["plex", "jellyfin"],
|
|
removed=[],
|
|
changed=[],
|
|
discovered={"plex": "nas01", "jellyfin": "nas02"},
|
|
current_state={},
|
|
)
|
|
captured = capsys.readouterr()
|
|
assert "New stacks found (2)" in captured.out
|
|
assert "+ plex on nas01" in captured.out
|
|
assert "+ jellyfin on nas02" in captured.out
|
|
|
|
def test_reports_removed(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Reports stacks that are no longer running."""
|
|
cli_management_module._report_sync_changes(
|
|
added=[],
|
|
removed=["grafana"],
|
|
changed=[],
|
|
discovered={},
|
|
current_state={"grafana": "nas01"},
|
|
)
|
|
captured = capsys.readouterr()
|
|
assert "Stacks no longer running (1)" in captured.out
|
|
assert "- grafana (was on nas01)" in captured.out
|
|
|
|
def test_reports_changed(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Reports stacks that moved to a different host."""
|
|
cli_management_module._report_sync_changes(
|
|
added=[],
|
|
removed=[],
|
|
changed=[("plex", "nas01", "nas02")],
|
|
discovered={"plex": "nas02"},
|
|
current_state={"plex": "nas01"},
|
|
)
|
|
captured = capsys.readouterr()
|
|
assert "Stacks on different hosts (1)" in captured.out
|
|
assert "~ plex: nas01 → nas02" in captured.out
|
|
|
|
|
|
class TestRefreshCommand:
|
|
"""Tests for the refresh command with stack arguments."""
|
|
|
|
def test_refresh_specific_stack_partial_merge(
|
|
self, mock_config: Config, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Refreshing specific stacks merges with existing state."""
|
|
# Mock existing state
|
|
existing_state = {"plex": "nas01", "jellyfin": "nas01", "grafana": "nas02"}
|
|
|
|
with (
|
|
patch(
|
|
"compose_farm.cli.management.get_stacks",
|
|
return_value=(["plex"], mock_config),
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management.load_state",
|
|
return_value=existing_state,
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management._discover_stacks_full",
|
|
return_value=({"plex": "nas02"}, {}, {}), # plex moved to nas02
|
|
),
|
|
patch("compose_farm.cli.management._snapshot_stacks"),
|
|
patch("compose_farm.cli.management.save_state") as mock_save,
|
|
):
|
|
# stacks=["plex"], all_stacks=False -> partial refresh
|
|
cli_management_module.refresh(
|
|
stacks=["plex"],
|
|
all_stacks=False,
|
|
config=None,
|
|
log_path=None,
|
|
dry_run=False,
|
|
)
|
|
|
|
# Should have merged: plex updated, others preserved
|
|
mock_save.assert_called_once()
|
|
saved_state = mock_save.call_args[0][1]
|
|
assert saved_state == {"plex": "nas02", "jellyfin": "nas01", "grafana": "nas02"}
|
|
|
|
def test_refresh_all_replaces_state(
|
|
self, mock_config: Config, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Refreshing all stacks replaces the entire state."""
|
|
existing_state = {"plex": "nas01", "jellyfin": "nas01", "old-service": "nas02"}
|
|
|
|
with (
|
|
patch(
|
|
"compose_farm.cli.management.get_stacks",
|
|
return_value=(["plex", "jellyfin", "grafana"], mock_config),
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management.load_state",
|
|
return_value=existing_state,
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management._discover_stacks_full",
|
|
return_value=(
|
|
{"plex": "nas01", "grafana": "nas02"},
|
|
{},
|
|
{},
|
|
), # jellyfin not running
|
|
),
|
|
patch("compose_farm.cli.management._snapshot_stacks"),
|
|
patch("compose_farm.cli.management.save_state") as mock_save,
|
|
):
|
|
# stacks=None, all_stacks=False -> defaults to all (full refresh)
|
|
cli_management_module.refresh(
|
|
stacks=None,
|
|
all_stacks=False,
|
|
config=None,
|
|
log_path=None,
|
|
dry_run=False,
|
|
)
|
|
|
|
# Should have replaced: only discovered stacks remain
|
|
mock_save.assert_called_once()
|
|
saved_state = mock_save.call_args[0][1]
|
|
assert saved_state == {"plex": "nas01", "grafana": "nas02"}
|
|
|
|
def test_refresh_with_all_flag_full_refresh(self, mock_config: Config) -> None:
|
|
"""Using --all flag forces full refresh even with stack names."""
|
|
existing_state = {"plex": "nas01", "jellyfin": "nas01"}
|
|
|
|
with (
|
|
patch(
|
|
"compose_farm.cli.management.get_stacks",
|
|
return_value=(["plex", "jellyfin", "grafana"], mock_config),
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management.load_state",
|
|
return_value=existing_state,
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management._discover_stacks_full",
|
|
return_value=({"plex": "nas01"}, {}, {}), # only plex running
|
|
),
|
|
patch("compose_farm.cli.management._snapshot_stacks"),
|
|
patch("compose_farm.cli.management.save_state") as mock_save,
|
|
):
|
|
# all_stacks=True -> full refresh (replaces state)
|
|
cli_management_module.refresh(
|
|
stacks=["plex"], # ignored when --all is set
|
|
all_stacks=True,
|
|
config=None,
|
|
log_path=None,
|
|
dry_run=False,
|
|
)
|
|
|
|
mock_save.assert_called_once()
|
|
saved_state = mock_save.call_args[0][1]
|
|
# Full refresh: only discovered stacks
|
|
assert saved_state == {"plex": "nas01"}
|
|
|
|
def test_refresh_partial_removes_stopped_stack(self, mock_config: Config) -> None:
|
|
"""Partial refresh removes a stack if it was checked but not found."""
|
|
existing_state = {"plex": "nas01", "jellyfin": "nas01", "grafana": "nas02"}
|
|
|
|
with (
|
|
patch(
|
|
"compose_farm.cli.management.get_stacks",
|
|
return_value=(["plex", "jellyfin"], mock_config),
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management.load_state",
|
|
return_value=existing_state,
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management._discover_stacks_full",
|
|
return_value=({"plex": "nas01"}, {}, {}), # jellyfin not running
|
|
),
|
|
patch("compose_farm.cli.management._snapshot_stacks"),
|
|
patch("compose_farm.cli.management.save_state") as mock_save,
|
|
):
|
|
cli_management_module.refresh(
|
|
stacks=["plex", "jellyfin"],
|
|
all_stacks=False,
|
|
config=None,
|
|
log_path=None,
|
|
dry_run=False,
|
|
)
|
|
|
|
mock_save.assert_called_once()
|
|
saved_state = mock_save.call_args[0][1]
|
|
# jellyfin removed (was checked), grafana preserved (wasn't checked)
|
|
assert saved_state == {"plex": "nas01", "grafana": "nas02"}
|
|
|
|
def test_refresh_dry_run_no_state_change(
|
|
self, mock_config: Config, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Dry run shows changes but doesn't modify state."""
|
|
existing_state = {"plex": "nas01"}
|
|
|
|
with (
|
|
patch(
|
|
"compose_farm.cli.management.get_stacks",
|
|
return_value=(["plex"], mock_config),
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management.load_state",
|
|
return_value=existing_state,
|
|
),
|
|
patch(
|
|
"compose_farm.cli.management._discover_stacks_full",
|
|
return_value=({"plex": "nas02"}, {}, {}), # would change
|
|
),
|
|
patch("compose_farm.cli.management.save_state") as mock_save,
|
|
):
|
|
cli_management_module.refresh(
|
|
stacks=["plex"],
|
|
all_stacks=False,
|
|
config=None,
|
|
log_path=None,
|
|
dry_run=True,
|
|
)
|
|
|
|
# Should not save state in dry run
|
|
mock_save.assert_not_called()
|
|
|
|
captured = capsys.readouterr()
|
|
assert "dry-run" in captured.out
|