mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
* up: Add --pull and --build flags for Docker Compose parity Add `--pull` and `--build` options to `cf up` to match Docker Compose naming conventions. This allows users to pull images or rebuild before starting without using the separate `update` command. - `cf up --pull` adds `--pull always` to the compose command - `cf up --build` adds `--build` to the compose command - Both flags work together: `cf up --pull --build` The `update` command remains unchanged as a convenience wrapper. * Update README.md * up: Run stacks in parallel when no migration needed Refactor up_stacks to categorize stacks and run them appropriately: - Simple stacks (no migration): run in parallel via asyncio.gather - Multi-host stacks: run in parallel - Migration stacks: run sequentially for clear output and rollback This makes `cf up --all` as fast as `cf update --all` for typical use. * refactor: DRY up command building with build_up_cmd helper Consolidate all 'up -d' command construction into a single helper function. Now used by up, update, and operations module. Added tests for the helper function. * update: Delegate to up --pull --build Simplify update command to just call up with pull=True and build=True. This removes duplication and ensures consistent behavior.
220 lines
7.2 KiB
Python
220 lines
7.2 KiB
Python
"""Tests for operations module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from compose_farm.cli import lifecycle
|
|
from compose_farm.config import Config, Host
|
|
from compose_farm.executor import CommandResult
|
|
from compose_farm.operations import (
|
|
_migrate_stack,
|
|
build_discovery_results,
|
|
build_up_cmd,
|
|
)
|
|
|
|
|
|
@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 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
|