"""Tests for executor module."""
import sys
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from compose_farm.config import Config, Host
from compose_farm.executor import (
CommandResult,
RemoteCheckError,
_run_local_command,
_run_ssh_command,
_stream_output_lines,
build_ssh_command,
check_networks_exist,
check_paths_exist,
check_stack_running,
get_running_stacks_on_host,
is_local,
run_command,
run_compose,
run_compose_on_host,
run_on_stacks,
)
# These tests run actual shell commands that only work on Linux
linux_only = pytest.mark.skipif(sys.platform != "linux", reason="Linux-only shell commands")
async def _async_lines(lines: list[str | bytes]) -> AsyncIterator[str | bytes]:
for line in lines:
yield line
class _RecordingConsole:
def __init__(self) -> None:
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
def print(self, *args: Any, **kwargs: Any) -> None:
self.calls.append((args, kwargs))
class TestStreamOutputLines:
"""Tests for streaming command output formatting."""
def test_format_stack_prefix_uses_stable_distinct_colors(self) -> None:
from compose_farm.console import format_stack_prefix
assert format_stack_prefix("wakapi") == "[yellow]\\[wakapi][/]"
assert format_stack_prefix("ollama") == "[bright_green]\\[ollama][/]"
assert format_stack_prefix("openclaw") == "[bright_blue]\\[openclaw][/]"
assert format_stack_prefix("wakapi") == format_stack_prefix("wakapi")
async def test_stream_output_lines_uses_colored_prefix_and_escapes_output(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
from compose_farm import executor
from compose_farm.console import format_stack_prefix
output = _RecordingConsole()
monkeypatch.setattr(executor, "console", output)
await _stream_output_lines(_async_lines(["Downloading [ok]\n"]), "wakapi")
assert output.calls == [
((f"{format_stack_prefix('wakapi')} Downloading \\[ok]\n",), {"end": ""})
]
class TestIsLocal:
"""Tests for is_local function."""
@pytest.mark.parametrize(
"address",
["local", "localhost", "127.0.0.1", "::1", "LOCAL", "LOCALHOST"],
)
def test_local_addresses(self, address: str) -> None:
host = Host(address=address)
assert is_local(host) is True
@pytest.mark.parametrize(
"address",
["192.168.1.10", "nas01.local", "10.0.0.1", "example.com"],
)
def test_remote_addresses(self, address: str) -> None:
host = Host(address=address)
assert is_local(host) is False
class TestRunLocalCommand:
"""Tests for local command execution."""
async def test_run_local_command_success(self) -> None:
result = await _run_local_command("echo hello", "test-service")
assert result.success is True
assert result.exit_code == 0
assert result.stack == "test-service"
async def test_run_local_command_failure(self) -> None:
result = await _run_local_command("exit 1", "test-service")
assert result.success is False
assert result.exit_code == 1
async def test_run_local_command_not_found(self) -> None:
result = await _run_local_command("nonexistent_command_xyz", "test-service")
assert result.success is False
assert result.exit_code != 0
async def test_run_local_command_captures_output(self) -> None:
result = await _run_local_command("echo hello", "test-service", stream=False)
assert "hello" in result.stdout
class TestRunCommand:
"""Tests for run_command dispatcher."""
async def test_run_command_local(self) -> None:
host = Host(address="localhost")
result = await run_command(host, "echo test", "test-service")
assert result.success is True
async def test_run_command_result_structure(self) -> None:
host = Host(address="local")
result = await run_command(host, "true", "my-service")
assert isinstance(result, CommandResult)
assert result.stack == "my-service"
assert result.exit_code == 0
assert result.success is True
async def test_non_streaming_ssh_error_is_returned_not_printed(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Preflight checks run under progress bars and must not print mid-render."""
from compose_farm import executor
output = _RecordingConsole()
monkeypatch.setattr(executor, "err_console", output)
with patch("asyncssh.connect", side_effect=OSError("Connection lost")):
result = await _run_ssh_command(
Host(address="192.168.1.10"),
"true",
"mount-check",
stream=False,
)
assert result.success is False
assert result.stderr == "Connection lost"
assert output.calls == []
class TestBuildSshCommand:
"""Tests for native SSH command construction."""
def test_uses_only_compose_farm_key_when_present(self, tmp_path: Path) -> None:
"""Native ssh should match asyncssh by not falling back to agent keys."""
key_path = tmp_path / "id_ed25519"
host = Host(address="192.168.1.10", user="me")
with patch("compose_farm.executor.get_key_path", return_value=key_path):
args = build_ssh_command(host, "true")
assert args[0] == "ssh"
assert "-i" in args
assert str(key_path) in args
assert "-o" in args
assert "IdentitiesOnly=yes" in args
class TestRunCompose:
"""Tests for compose command execution."""
async def test_run_compose_builds_correct_command(self, tmp_path: Path) -> None:
# Create a minimal compose file
compose_dir = tmp_path / "compose"
stack_dir = compose_dir / "test-service"
stack_dir.mkdir(parents=True)
compose_file = stack_dir / "docker-compose.yml"
compose_file.write_text("services: {}")
config = Config(
compose_dir=compose_dir,
hosts={"local": Host(address="localhost")},
stacks={"test-service": "local"},
)
# This will fail because docker compose isn't running,
# but we can verify the command structure works
result = await run_compose(config, "test-service", "config", stream=False)
# Command may fail due to no docker, but structure is correct
assert result.stack == "test-service"
async def test_run_compose_uses_cd_pattern(self, tmp_path: Path) -> None:
"""Verify run_compose uses 'cd
&& docker compose' pattern."""
config = Config(
compose_dir=tmp_path,
hosts={"remote": Host(address="192.168.1.100")},
stacks={"mystack": "remote"},
)
mock_result = CommandResult(stack="mystack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
await run_compose(config, "mystack", "up -d", stream=False)
# Verify the command uses cd pattern with quoted path
mock_run.assert_called_once()
call_args = mock_run.call_args
command = call_args[0][1] # Second positional arg is command
assert command == f'cd "{tmp_path}/mystack" && docker compose up -d'
async def test_run_compose_works_without_local_compose_file(self, tmp_path: Path) -> None:
"""Verify compose works even when compose file doesn't exist locally.
This is the bug from issue #162 - when running cf from a machine without
NFS mounts, the compose file doesn't exist locally but should still work
on the remote host.
"""
config = Config(
compose_dir=tmp_path, # No compose files exist here
hosts={"remote": Host(address="192.168.1.100")},
stacks={"mystack": "remote"},
)
# Verify no compose file exists locally
assert not (tmp_path / "mystack" / "compose.yaml").exists()
assert not (tmp_path / "mystack" / "compose.yml").exists()
mock_result = CommandResult(stack="mystack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
result = await run_compose(config, "mystack", "ps", stream=False)
# Should succeed - docker compose on remote will find the file
assert result.success
# Command should use cd pattern, not -f with a specific file
command = mock_run.call_args[0][1]
assert "cd " in command
assert " && docker compose " in command
assert "-f " not in command # Should NOT use -f flag
assert mock_run.call_args.kwargs["host_name"] == "remote"
assert mock_run.call_args.kwargs["label"] == "mystack"
async def test_run_compose_on_host_uses_cd_pattern(self, tmp_path: Path) -> None:
"""Verify run_compose_on_host uses 'cd && docker compose' pattern."""
config = Config(
compose_dir=tmp_path,
hosts={"host1": Host(address="192.168.1.1")},
stacks={"mystack": "host1"},
)
mock_result = CommandResult(stack="mystack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
result = await run_compose_on_host(config, "mystack", "host1", "down", stream=False)
command = mock_run.call_args[0][1]
assert command == f'cd "{tmp_path}/mystack" && docker compose down'
assert result.stack == "mystack"
assert mock_run.call_args.kwargs["host_name"] == "host1"
assert mock_run.call_args.kwargs["label"] == "mystack@host1"
async def test_check_stack_running_uses_cd_pattern(self, tmp_path: Path) -> None:
"""Verify check_stack_running uses 'cd && docker compose' pattern."""
config = Config(
compose_dir=tmp_path,
hosts={"host1": Host(address="192.168.1.1")},
stacks={"mystack": "host1"},
)
mock_result = CommandResult(stack="mystack", exit_code=0, success=True, stdout="abc123\n")
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
result = await check_stack_running(config, "mystack", "host1")
assert result is True
command = mock_run.call_args[0][1]
assert command == f'cd "{tmp_path}/mystack" && docker compose ps --status running -q'
async def test_run_compose_quotes_paths_with_spaces(self, tmp_path: Path) -> None:
"""Verify paths with spaces are properly quoted."""
compose_dir = tmp_path / "my compose dir"
compose_dir.mkdir()
config = Config(
compose_dir=compose_dir,
hosts={"remote": Host(address="192.168.1.100")},
stacks={"my-stack": "remote"},
)
mock_result = CommandResult(stack="my-stack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
await run_compose(config, "my-stack", "up -d", stream=False)
command = mock_run.call_args[0][1]
# Path should be quoted to handle spaces
assert f'cd "{compose_dir}/my-stack"' in command
class TestRunOnStacks:
"""Tests for parallel stack execution."""
async def test_run_on_stacks_parallel(self) -> None:
config = Config(
compose_dir=Path("/tmp"),
hosts={"local": Host(address="localhost")},
stacks={"svc1": "local", "svc2": "local"},
)
# Use a simple command that will work without docker
# We'll test the parallelism structure
results = await run_on_stacks(config, ["svc1", "svc2"], "version", stream=False)
assert len(results) == 2
assert results[0].stack == "svc1"
assert results[1].stack == "svc2"
async def test_run_on_stacks_filter_host_limits_multi_host(self) -> None:
"""filter_host should only run on that host for multi-host stacks."""
config = Config(
compose_dir=Path("/tmp"),
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
"host3": Host(address="192.168.1.3"),
},
stacks={"multi-svc": ["host1", "host2", "host3"]}, # multi-host stack
)
mock_result = CommandResult(stack="multi-svc", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
results = await run_on_stacks(
config, ["multi-svc"], "down", stream=False, filter_host="host1"
)
# Should only call run_command once (for host1), not 3 times
assert mock_run.call_count == 1
assert mock_run.call_args.args[2] == "multi-svc"
# Result should be for the filtered host
assert len(results) == 1
assert results[0].stack == "multi-svc"
assert results[0].host == "host1"
assert results[0].label == "multi-svc@host1"
async def test_run_on_stacks_no_filter_runs_all_hosts(self) -> None:
"""Without filter_host, multi-host stacks run on all configured hosts."""
config = Config(
compose_dir=Path("/tmp"),
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
},
stacks={"multi-svc": ["host1", "host2"]}, # multi-host stack
)
mock_result = CommandResult(stack="multi-svc", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
results = await run_on_stacks(config, ["multi-svc"], "down", stream=False)
# Should call run_command twice (once per host)
assert mock_run.call_count == 2
# Results should be for both hosts
assert len(results) == 2
async def test_run_on_stacks_multi_host_disables_raw_output(self) -> None:
"""Raw TTY output is unsafe when one stack fans out to multiple hosts."""
config = Config(
compose_dir=Path("/tmp"),
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
},
stacks={"glances": ["host1", "host2"]},
)
mock_result = CommandResult(stack="glances", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
await run_on_stacks(config, ["glances"], "up -d --pull always", raw=True)
assert mock_run.call_count == 2
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)
async def test_run_on_stacks_multi_host_filter_keeps_raw_output(self) -> None:
"""A host filter reduces a multi-host stack to one compose process."""
config = Config(
compose_dir=Path("/tmp"),
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
},
stacks={"glances": ["host1", "host2"]},
)
mock_result = CommandResult(stack="glances", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
await run_on_stacks(
config,
["glances"],
"up -d --pull always",
raw=True,
filter_host="host1",
)
mock_run.assert_awaited_once()
assert mock_run.call_args.kwargs["raw"] is True
@linux_only
class TestCheckPathsExist:
"""Tests for check_paths_exist function (uses 'test -e' shell command)."""
async def test_check_existing_paths(self, tmp_path: Path) -> None:
"""Check paths that exist."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
# Create test paths
(tmp_path / "dir1").mkdir()
(tmp_path / "file1").touch()
result = await check_paths_exist(
config, "local", [str(tmp_path / "dir1"), str(tmp_path / "file1")]
)
assert result[str(tmp_path / "dir1")] is True
assert result[str(tmp_path / "file1")] is True
async def test_check_missing_paths(self, tmp_path: Path) -> None:
"""Check paths that don't exist."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await check_paths_exist(
config, "local", [str(tmp_path / "missing1"), str(tmp_path / "missing2")]
)
assert result[str(tmp_path / "missing1")] is False
assert result[str(tmp_path / "missing2")] is False
async def test_check_mixed_paths(self, tmp_path: Path) -> None:
"""Check mix of existing and missing paths."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
(tmp_path / "exists").mkdir()
result = await check_paths_exist(
config, "local", [str(tmp_path / "exists"), str(tmp_path / "missing")]
)
assert result[str(tmp_path / "exists")] is True
assert result[str(tmp_path / "missing")] is False
async def test_check_empty_paths(self, tmp_path: Path) -> None:
"""Empty path list returns empty dict."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await check_paths_exist(config, "local", [])
assert result == {}
async def test_remote_command_failure_raises(self, tmp_path: Path) -> None:
"""SSH/preflight failures should not look like every path is missing."""
config = Config(
compose_dir=tmp_path,
hosts={"remote": Host(address="192.168.1.10")},
stacks={},
)
failed = CommandResult(
stack="mount-check",
exit_code=1,
success=False,
stderr="Permission denied for user basnijholt",
)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = failed
with pytest.raises(RemoteCheckError) as exc_info:
await check_paths_exist(config, "remote", ["/opt/stacks"])
assert "mount-check failed on remote" in str(exc_info.value)
assert "Permission denied" in str(exc_info.value)
async def test_remote_command_failure_retries_once(self, tmp_path: Path) -> None:
"""Transient SSH failures should not fail the whole preflight immediately."""
config = Config(
compose_dir=tmp_path,
hosts={"remote": Host(address="192.168.1.10")},
stacks={},
)
failed = CommandResult(
stack="mount-check",
exit_code=1,
success=False,
stderr="Connection lost",
)
succeeded = CommandResult(
stack="mount-check",
exit_code=0,
success=True,
stdout="Y:/opt/stacks\n",
)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.side_effect = [failed, succeeded]
result = await check_paths_exist(config, "remote", ["/opt/stacks"])
assert result == {"/opt/stacks": True}
assert mock_run.await_count == 2
@linux_only
class TestCheckNetworksExist:
"""Tests for check_networks_exist function (requires Docker)."""
async def test_check_bridge_network_exists(self, tmp_path: Path) -> None:
"""The 'bridge' network always exists on Docker hosts."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await check_networks_exist(config, "local", ["bridge"])
assert result["bridge"] is True
async def test_check_nonexistent_network(self, tmp_path: Path) -> None:
"""Check a network that doesn't exist."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await check_networks_exist(config, "local", ["nonexistent_network_xyz_123"])
assert result["nonexistent_network_xyz_123"] is False
async def test_check_mixed_networks(self, tmp_path: Path) -> None:
"""Check mix of existing and non-existing networks."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await check_networks_exist(
config, "local", ["bridge", "nonexistent_network_xyz_123"]
)
assert result["bridge"] is True
assert result["nonexistent_network_xyz_123"] is False
async def test_check_empty_networks(self, tmp_path: Path) -> None:
"""Empty network list returns empty dict."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await check_networks_exist(config, "local", [])
assert result == {}
@linux_only
class TestGetRunningStacksOnHost:
"""Tests for get_running_stacks_on_host function (requires Docker)."""
async def test_returns_set_of_stacks(self, tmp_path: Path) -> None:
"""Function returns a set of stack names."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
result = await get_running_stacks_on_host(config, "local")
assert isinstance(result, set)
async def test_filters_empty_lines(self, tmp_path: Path) -> None:
"""Empty project names are filtered out."""
config = Config(
compose_dir=tmp_path,
hosts={"local": Host(address="localhost")},
stacks={},
)
# Result should not contain empty strings
result = await get_running_stacks_on_host(config, "local")
assert "" not in result