mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Some checks failed
Update README.md / update_readme (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (macos-latest, 3.13) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.13) (push) Has been cancelled
CI / browser-tests (push) Has been cancelled
CI / lint (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
* fix: --host filter now limits multi-host stack operations to single host
Previously, when using `-H host` with a multi-host stack like `glances: all`,
the command would find the stack (correct) but then operate on ALL hosts for
that stack (incorrect). For example, `cf down -H nas` with `glances` would
stop glances on all 5 hosts instead of just nas.
Now, when `--host` is specified:
- `cf down -H nas` only stops stacks on nas, including only the nas instance
of multi-host stacks
- `cf up -H nas` only starts stacks on nas (skips migration logic since
host is explicitly specified)
Added tests for the new filter_host behavior in both executor and CLI.
* fix: Apply filter_host to logs and ps commands as well
Same bug as up/down: when using `-H host` with multi-host stacks,
logs and ps would show results from all hosts instead of just the
filtered host.
* fix: Don't remove multi-host stacks from state when host-filtered
When using `-H host` with a multi-host stack, we only stop one instance.
The stack is still running on other hosts, so we shouldn't remove it
from state entirely.
This prevents issues where:
- `cf apply` would try to re-start the stack
- `cf ps` would show incorrect running status
- Orphan detection would be confused
Added tests to verify state is preserved for host-filtered multi-host
operations and removed for full stack operations.
* refactor: Introduce StackSelection dataclass for cleaner context passing
Instead of passing filter_host separately through multiple layers,
bundle the selection context into a StackSelection dataclass:
- stacks: list of selected stack names
- config: the loaded Config
- host_filter: optional host filter from -H flag
This provides:
1. Cleaner APIs - context travels together instead of being scattered
2. is_instance_level() method - encapsulates the check for whether
this is an instance-level operation (host-filtered multi-host stack)
3. Future extensibility - can add more context (dry_run, verbose, etc.)
Updated all callers of get_stacks() to use the new return type.
* Revert "refactor: Introduce StackSelection dataclass for cleaner context passing"
This reverts commit e6e9eed93e.
* feat: Proper per-host state tracking for multi-host stacks
- Add `remove_stack_host()` to remove a single host from a multi-host stack's state
- Add `add_stack_host()` to add a single host to a stack's state
- Update `down` command to use `remove_stack_host` for host-filtered multi-host stacks
- Update `up` command to use `add_stack_host` for host-filtered operations
This ensures the state file accurately reflects which hosts each stack is running on,
rather than just tracking if it's running at all.
* fix: Use set comparisons for host list tests
Host lists may be reordered during YAML save/load, so test for
set equality rather than list equality.
* refactor: Merge remove_stack_host into remove_stack as optional parameter
Instead of a separate function, `remove_stack` now takes an optional
`host` parameter. When specified, it removes only that host from
multi-host stacks. This reduces API surface and follows the existing
pattern.
* fix: Restore deterministic host list sorting and add filter_host test
- Restore sorting of list values in _sorted_dict for consistent YAML output
- Add test for logs --host passing filter_host to run_on_stacks
253 lines
9.2 KiB
Python
253 lines
9.2 KiB
Python
"""Tests for CLI logs command."""
|
|
|
|
from collections.abc import Coroutine
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from compose_farm.cli.monitoring import logs
|
|
from compose_farm.config import Config, Host
|
|
from compose_farm.executor import CommandResult
|
|
|
|
|
|
def _make_config(tmp_path: Path) -> Config:
|
|
"""Create a minimal config for testing."""
|
|
compose_dir = tmp_path / "compose"
|
|
compose_dir.mkdir()
|
|
for svc in ("svc1", "svc2", "svc3"):
|
|
svc_dir = compose_dir / svc
|
|
svc_dir.mkdir()
|
|
(svc_dir / "docker-compose.yml").write_text("services: {}\n")
|
|
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={"local": Host(address="localhost"), "remote": Host(address="192.168.1.10")},
|
|
stacks={"svc1": "local", "svc2": "local", "svc3": "remote"},
|
|
)
|
|
|
|
|
|
def _make_multi_host_config(tmp_path: Path) -> Config:
|
|
"""Create a config with a multi-host stack for testing."""
|
|
compose_dir = tmp_path / "compose"
|
|
compose_dir.mkdir()
|
|
for svc in ("single-host", "multi-host"):
|
|
svc_dir = compose_dir / svc
|
|
svc_dir.mkdir()
|
|
(svc_dir / "docker-compose.yml").write_text("services: {}\n")
|
|
|
|
return Config(
|
|
compose_dir=compose_dir,
|
|
hosts={
|
|
"host1": Host(address="192.168.1.1"),
|
|
"host2": Host(address="192.168.1.2"),
|
|
},
|
|
stacks={
|
|
"single-host": "host1",
|
|
"multi-host": ["host1", "host2"],
|
|
},
|
|
)
|
|
|
|
|
|
def _make_result(stack: str) -> CommandResult:
|
|
"""Create a successful command result."""
|
|
return CommandResult(stack=stack, exit_code=0, success=True, stdout="", stderr="")
|
|
|
|
|
|
def _mock_run_async_factory(
|
|
stacks: list[str],
|
|
) -> tuple[Any, list[CommandResult]]:
|
|
"""Create a mock run_async that returns results for given stacks."""
|
|
results = [_make_result(s) for s in stacks]
|
|
|
|
def mock_run_async(_coro: Coroutine[Any, Any, Any]) -> list[CommandResult]:
|
|
return results
|
|
|
|
return mock_run_async, results
|
|
|
|
|
|
class TestLogsContextualDefault:
|
|
"""Tests for logs --tail contextual default behavior."""
|
|
|
|
def test_logs_all_stacks_defaults_to_20(self, tmp_path: Path) -> None:
|
|
"""When --all is specified, default tail should be 20."""
|
|
cfg = _make_config(tmp_path)
|
|
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2", "svc3"])
|
|
|
|
with (
|
|
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
|
|
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
|
|
):
|
|
mock_run.return_value = None
|
|
|
|
logs(stacks=None, all_stacks=True, host=None, follow=False, tail=None, config=None)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert call_args[0][2] == "logs --tail 20"
|
|
|
|
def test_logs_single_stack_defaults_to_100(self, tmp_path: Path) -> None:
|
|
"""When specific stacks are specified, default tail should be 100."""
|
|
cfg = _make_config(tmp_path)
|
|
mock_run_async, _ = _mock_run_async_factory(["svc1"])
|
|
|
|
with (
|
|
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
|
|
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
|
|
):
|
|
logs(
|
|
stacks=["svc1"],
|
|
all_stacks=False,
|
|
host=None,
|
|
follow=False,
|
|
tail=None,
|
|
config=None,
|
|
)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert call_args[0][2] == "logs --tail 100"
|
|
|
|
def test_logs_explicit_tail_overrides_default(self, tmp_path: Path) -> None:
|
|
"""When --tail is explicitly provided, it should override the default."""
|
|
cfg = _make_config(tmp_path)
|
|
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2", "svc3"])
|
|
|
|
with (
|
|
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
|
|
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
|
|
):
|
|
logs(
|
|
stacks=None,
|
|
all_stacks=True,
|
|
host=None,
|
|
follow=False,
|
|
tail=50,
|
|
config=None,
|
|
)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert call_args[0][2] == "logs --tail 50"
|
|
|
|
def test_logs_follow_appends_flag(self, tmp_path: Path) -> None:
|
|
"""When --follow is specified, -f should be appended to command."""
|
|
cfg = _make_config(tmp_path)
|
|
mock_run_async, _ = _mock_run_async_factory(["svc1"])
|
|
|
|
with (
|
|
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
|
|
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
|
|
):
|
|
logs(
|
|
stacks=["svc1"],
|
|
all_stacks=False,
|
|
host=None,
|
|
follow=True,
|
|
tail=None,
|
|
config=None,
|
|
)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert call_args[0][2] == "logs --tail 100 -f"
|
|
|
|
|
|
class TestLogsHostFilter:
|
|
"""Tests for logs --host filter behavior."""
|
|
|
|
def test_logs_host_filter_selects_stacks_on_host(self, tmp_path: Path) -> None:
|
|
"""When --host is specified, only stacks on that host are included."""
|
|
cfg = _make_config(tmp_path)
|
|
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"])
|
|
|
|
with (
|
|
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
|
|
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
|
|
):
|
|
logs(
|
|
stacks=None,
|
|
all_stacks=False,
|
|
host="local",
|
|
follow=False,
|
|
tail=None,
|
|
config=None,
|
|
)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
# svc1 and svc2 are on "local", svc3 is on "remote"
|
|
assert set(call_args[0][1]) == {"svc1", "svc2"}
|
|
|
|
def test_logs_host_filter_defaults_to_20_lines(self, tmp_path: Path) -> None:
|
|
"""When --host is specified, default tail should be 20 (multiple stacks)."""
|
|
cfg = _make_config(tmp_path)
|
|
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"])
|
|
|
|
with (
|
|
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
|
|
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
|
|
):
|
|
logs(
|
|
stacks=None,
|
|
all_stacks=False,
|
|
host="local",
|
|
follow=False,
|
|
tail=None,
|
|
config=None,
|
|
)
|
|
|
|
mock_run.assert_called_once()
|
|
call_args = mock_run.call_args
|
|
assert call_args[0][2] == "logs --tail 20"
|
|
|
|
def test_logs_all_and_host_mutually_exclusive(self) -> None:
|
|
"""Using --all and --host together should error."""
|
|
# No config mock needed - error is raised before config is loaded
|
|
with pytest.raises(typer.Exit) as exc_info:
|
|
logs(
|
|
stacks=None,
|
|
all_stacks=True,
|
|
host="local",
|
|
follow=False,
|
|
tail=None,
|
|
config=None,
|
|
)
|
|
|
|
assert exc_info.value.exit_code == 1
|
|
|
|
def test_logs_host_filter_passes_filter_host_to_run_on_stacks(self, tmp_path: Path) -> None:
|
|
"""--host should pass filter_host to run_on_stacks for multi-host stacks."""
|
|
cfg = _make_multi_host_config(tmp_path)
|
|
mock_run_async, _ = _mock_run_async_factory(["multi-host@host1"])
|
|
|
|
with (
|
|
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
|
|
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
|
|
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
|
|
):
|
|
logs(
|
|
stacks=None,
|
|
all_stacks=False,
|
|
host="host1",
|
|
follow=False,
|
|
tail=None,
|
|
config=None,
|
|
)
|
|
|
|
mock_run.assert_called_once()
|
|
call_kwargs = mock_run.call_args.kwargs
|
|
assert call_kwargs.get("filter_host") == "host1"
|