mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
## Summary Optimize `cf refresh` SSH calls from O(stacks) to O(hosts): - Discovery: 1 SSH call per host (unchanged) - Snapshots: 1 SSH call per host (was 1 per stack) For 50 stacks across 4 hosts: 54 → 8 SSH calls. ## Changes **Performance:** - Use `docker ps` + `docker image inspect` instead of `docker compose images` per stack - Batch snapshot collection by host in `collect_stacks_entries_on_host()` **Architecture:** - Add `build_discovery_results()` to `operations.py` (business logic) - Keep progress bar wrapper in `cli/management.py` (presentation) - Remove dead code: `discover_all_stacks_on_all_hosts()`, `collect_all_stacks_entries()`
271 lines
9.8 KiB
Python
271 lines
9.8 KiB
Python
"""Tests for snapshot logging."""
|
|
|
|
import json
|
|
import tomllib
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from compose_farm.config import Config, Host
|
|
from compose_farm.executor import CommandResult
|
|
from compose_farm.logs import (
|
|
_SECTION_SEPARATOR,
|
|
collect_stacks_entries_on_host,
|
|
isoformat,
|
|
load_existing_entries,
|
|
merge_entries,
|
|
write_toml,
|
|
)
|
|
|
|
|
|
def _make_mock_output(
|
|
project_images: dict[str, list[str]], image_info: list[dict[str, object]]
|
|
) -> str:
|
|
"""Build mock output matching the 2-docker-command format."""
|
|
# Section 1: project|image pairs from docker ps
|
|
ps_lines = [
|
|
f"{project}|{image}" for project, images in project_images.items() for image in images
|
|
]
|
|
|
|
# Section 2: JSON array from docker image inspect
|
|
image_json = json.dumps(image_info)
|
|
|
|
return f"{chr(10).join(ps_lines)}\n{_SECTION_SEPARATOR}\n{image_json}"
|
|
|
|
|
|
class TestCollectStacksEntriesOnHost:
|
|
"""Tests for collect_stacks_entries_on_host (2 docker commands per host)."""
|
|
|
|
@pytest.fixture
|
|
def config_with_stacks(self, tmp_path: Path) -> Config:
|
|
"""Create a config with multiple stacks."""
|
|
compose_dir = tmp_path / "compose"
|
|
compose_dir.mkdir()
|
|
for stack in ["plex", "jellyfin", "sonarr"]:
|
|
stack_dir = compose_dir / stack
|
|
stack_dir.mkdir()
|
|
(stack_dir / "docker-compose.yml").write_text("services: {}\n")
|
|
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={"host1": Host(address="localhost"), "host2": Host(address="localhost")},
|
|
stacks={"plex": "host1", "jellyfin": "host1", "sonarr": "host2"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_ssh_call(
|
|
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Verify only 1 SSH call is made regardless of stack count."""
|
|
call_count = {"count": 0}
|
|
|
|
async def mock_run_command(
|
|
host: Host, command: str, stack: str, *, stream: bool, prefix: str
|
|
) -> CommandResult:
|
|
call_count["count"] += 1
|
|
output = _make_mock_output(
|
|
{"plex": ["plex:latest"], "jellyfin": ["jellyfin:latest"]},
|
|
[
|
|
{
|
|
"RepoTags": ["plex:latest"],
|
|
"Id": "sha256:aaa",
|
|
"RepoDigests": ["plex@sha256:aaa"],
|
|
},
|
|
{
|
|
"RepoTags": ["jellyfin:latest"],
|
|
"Id": "sha256:bbb",
|
|
"RepoDigests": ["jellyfin@sha256:bbb"],
|
|
},
|
|
],
|
|
)
|
|
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
|
|
|
|
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
|
|
|
|
now = datetime(2025, 1, 1, tzinfo=UTC)
|
|
entries = await collect_stacks_entries_on_host(
|
|
config_with_stacks, "host1", {"plex", "jellyfin"}, now=now
|
|
)
|
|
|
|
assert call_count["count"] == 1
|
|
assert len(entries) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_filters_to_requested_stacks(
|
|
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Only return entries for stacks we asked for, even if others are running."""
|
|
|
|
async def mock_run_command(
|
|
host: Host, command: str, stack: str, *, stream: bool, prefix: str
|
|
) -> CommandResult:
|
|
# Docker ps shows 3 stacks, but we only want plex
|
|
output = _make_mock_output(
|
|
{
|
|
"plex": ["plex:latest"],
|
|
"jellyfin": ["jellyfin:latest"],
|
|
"other": ["other:latest"],
|
|
},
|
|
[
|
|
{
|
|
"RepoTags": ["plex:latest"],
|
|
"Id": "sha256:aaa",
|
|
"RepoDigests": ["plex@sha256:aaa"],
|
|
},
|
|
{
|
|
"RepoTags": ["jellyfin:latest"],
|
|
"Id": "sha256:bbb",
|
|
"RepoDigests": ["j@sha256:bbb"],
|
|
},
|
|
{
|
|
"RepoTags": ["other:latest"],
|
|
"Id": "sha256:ccc",
|
|
"RepoDigests": ["o@sha256:ccc"],
|
|
},
|
|
],
|
|
)
|
|
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
|
|
|
|
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
|
|
|
|
now = datetime(2025, 1, 1, tzinfo=UTC)
|
|
entries = await collect_stacks_entries_on_host(
|
|
config_with_stacks, "host1", {"plex"}, now=now
|
|
)
|
|
|
|
assert len(entries) == 1
|
|
assert entries[0].stack == "plex"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_images_per_stack(
|
|
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Stack with multiple containers/images returns multiple entries."""
|
|
|
|
async def mock_run_command(
|
|
host: Host, command: str, stack: str, *, stream: bool, prefix: str
|
|
) -> CommandResult:
|
|
output = _make_mock_output(
|
|
{"plex": ["plex:latest", "redis:7"]},
|
|
[
|
|
{
|
|
"RepoTags": ["plex:latest"],
|
|
"Id": "sha256:aaa",
|
|
"RepoDigests": ["p@sha256:aaa"],
|
|
},
|
|
{"RepoTags": ["redis:7"], "Id": "sha256:bbb", "RepoDigests": ["r@sha256:bbb"]},
|
|
],
|
|
)
|
|
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
|
|
|
|
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
|
|
|
|
now = datetime(2025, 1, 1, tzinfo=UTC)
|
|
entries = await collect_stacks_entries_on_host(
|
|
config_with_stacks, "host1", {"plex"}, now=now
|
|
)
|
|
|
|
assert len(entries) == 2
|
|
images = {e.image for e in entries}
|
|
assert images == {"plex:latest", "redis:7"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_stacks_returns_empty(self, config_with_stacks: Config) -> None:
|
|
"""Empty stack set returns empty entries without making SSH call."""
|
|
now = datetime(2025, 1, 1, tzinfo=UTC)
|
|
entries = await collect_stacks_entries_on_host(config_with_stacks, "host1", set(), now=now)
|
|
assert entries == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ssh_failure_returns_empty(
|
|
self, config_with_stacks: Config, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""SSH failure returns empty list instead of raising."""
|
|
|
|
async def mock_run_command(
|
|
host: Host, command: str, stack: str, *, stream: bool, prefix: str
|
|
) -> CommandResult:
|
|
return CommandResult(stack=stack, exit_code=1, success=False, stdout="", stderr="error")
|
|
|
|
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
|
|
|
|
now = datetime(2025, 1, 1, tzinfo=UTC)
|
|
entries = await collect_stacks_entries_on_host(
|
|
config_with_stacks, "host1", {"plex"}, now=now
|
|
)
|
|
|
|
assert entries == []
|
|
|
|
|
|
class TestSnapshotMerging:
|
|
"""Tests for merge_entries preserving first_seen."""
|
|
|
|
@pytest.fixture
|
|
def config(self, tmp_path: Path) -> Config:
|
|
compose_dir = tmp_path / "compose"
|
|
compose_dir.mkdir()
|
|
stack_dir = compose_dir / "svc"
|
|
stack_dir.mkdir()
|
|
(stack_dir / "docker-compose.yml").write_text("services: {}\n")
|
|
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={"local": Host(address="localhost")},
|
|
stacks={"svc": "local"},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preserves_first_seen(
|
|
self, tmp_path: Path, config: Config, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Repeated snapshots preserve first_seen timestamp."""
|
|
|
|
async def mock_run_command(
|
|
host: Host, command: str, stack: str, *, stream: bool, prefix: str
|
|
) -> CommandResult:
|
|
output = _make_mock_output(
|
|
{"svc": ["redis:latest"]},
|
|
[
|
|
{
|
|
"RepoTags": ["redis:latest"],
|
|
"Id": "sha256:abc",
|
|
"RepoDigests": ["r@sha256:abc"],
|
|
}
|
|
],
|
|
)
|
|
return CommandResult(stack=stack, exit_code=0, success=True, stdout=output)
|
|
|
|
monkeypatch.setattr("compose_farm.logs.run_command", mock_run_command)
|
|
|
|
log_path = tmp_path / "dockerfarm-log.toml"
|
|
|
|
# First snapshot
|
|
first_time = datetime(2025, 1, 1, tzinfo=UTC)
|
|
first_entries = await collect_stacks_entries_on_host(
|
|
config, "local", {"svc"}, now=first_time
|
|
)
|
|
first_iso = isoformat(first_time)
|
|
merged = merge_entries([], first_entries, now_iso=first_iso)
|
|
meta = {"generated_at": first_iso, "compose_dir": str(config.compose_dir)}
|
|
write_toml(log_path, meta=meta, entries=merged)
|
|
|
|
after_first = tomllib.loads(log_path.read_text())
|
|
first_seen = after_first["entries"][0]["first_seen"]
|
|
|
|
# Second snapshot
|
|
second_time = datetime(2025, 2, 1, tzinfo=UTC)
|
|
second_entries = await collect_stacks_entries_on_host(
|
|
config, "local", {"svc"}, now=second_time
|
|
)
|
|
second_iso = isoformat(second_time)
|
|
existing = load_existing_entries(log_path)
|
|
merged = merge_entries(existing, second_entries, now_iso=second_iso)
|
|
meta = {"generated_at": second_iso, "compose_dir": str(config.compose_dir)}
|
|
write_toml(log_path, meta=meta, entries=merged)
|
|
|
|
after_second = tomllib.loads(log_path.read_text())
|
|
entry = after_second["entries"][0]
|
|
assert entry["first_seen"] == first_seen
|
|
assert entry["last_seen"].startswith("2025-02-01")
|