diff --git a/src/compose_farm/config.py b/src/compose_farm/config.py index f651a0b..7bec467 100644 --- a/src/compose_farm/config.py +++ b/src/compose_farm/config.py @@ -97,9 +97,17 @@ class Config(BaseModel, extra="forbid"): host_names = self.get_hosts(stack) return self.hosts[host_names[0]] + def get_stack_dir(self, stack: str) -> Path: + """Get stack directory path.""" + return self.compose_dir / stack + def get_compose_path(self, stack: str) -> Path: - """Get compose file path for a stack (tries compose.yaml first).""" - stack_dir = self.compose_dir / stack + """Get compose file path for a stack (tries compose.yaml first). + + Note: This checks local filesystem. For remote execution, use + get_stack_dir() and let docker compose find the file. + """ + stack_dir = self.get_stack_dir(stack) for filename in COMPOSE_FILENAMES: candidate = stack_dir / filename if candidate.exists(): diff --git a/src/compose_farm/executor.py b/src/compose_farm/executor.py index 51a033b..3178e09 100644 --- a/src/compose_farm/executor.py +++ b/src/compose_farm/executor.py @@ -58,22 +58,12 @@ _compose_labels_cache = TTLCache(ttl_seconds=30.0) def _print_compose_command( host_name: str, - compose_dir: str, - compose_path: str, + stack: str, compose_cmd: str, ) -> None: - """Print the docker compose command being executed. - - Shows the host and a simplified command with relative path from compose_dir. - """ - # Show relative path from compose_dir for cleaner output - if compose_path.startswith(compose_dir): - rel_path = compose_path[len(compose_dir) :].lstrip("/") - else: - rel_path = compose_path - + """Print the docker compose command being executed.""" console.print( - f"[dim][magenta]{host_name}[/magenta]: docker compose -f {rel_path} {compose_cmd}[/dim]" + f"[dim][magenta]{host_name}[/magenta]: ({stack}) docker compose {compose_cmd}[/dim]" ) @@ -362,11 +352,12 @@ async def run_compose( """Run a docker compose command for a stack.""" host_name = config.get_hosts(stack)[0] host = config.hosts[host_name] - compose_path = config.get_compose_path(stack) + stack_dir = config.get_stack_dir(stack) - _print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd) + _print_compose_command(host_name, stack, compose_cmd) - command = f"docker compose -f {compose_path} {compose_cmd}" + # Use cd to let docker compose find the compose file on the remote host + command = f'cd "{stack_dir}" && docker compose {compose_cmd}' return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix) @@ -385,11 +376,12 @@ async def run_compose_on_host( Used for migration - running 'down' on the old host before 'up' on new host. """ host = config.hosts[host_name] - compose_path = config.get_compose_path(stack) + stack_dir = config.get_stack_dir(stack) - _print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd) + _print_compose_command(host_name, stack, compose_cmd) - command = f"docker compose -f {compose_path} {compose_cmd}" + # Use cd to let docker compose find the compose file on the remote host + command = f'cd "{stack_dir}" && docker compose {compose_cmd}' return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix) @@ -441,14 +433,15 @@ async def _run_sequential_stack_commands_multi_host( For multi-host stacks, prefix defaults to stack@host format. """ host_names = config.get_hosts(stack) - compose_path = config.get_compose_path(stack) + stack_dir = config.get_stack_dir(stack) final_results: list[CommandResult] = [] for cmd in commands: - command = f"docker compose -f {compose_path} {cmd}" + # Use cd to let docker compose find the compose file on the remote host + command = f'cd "{stack_dir}" && docker compose {cmd}' tasks = [] for host_name in host_names: - _print_compose_command(host_name, str(config.compose_dir), str(compose_path), cmd) + _print_compose_command(host_name, stack, cmd) host = config.hosts[host_name] # For multi-host stacks, always use stack@host prefix to distinguish output label = f"{stack}@{host_name}" if len(host_names) > 1 else stack @@ -525,10 +518,11 @@ async def check_stack_running( ) -> bool: """Check if a stack has running containers on a specific host.""" host = config.hosts[host_name] - compose_path = config.get_compose_path(stack) + stack_dir = config.get_stack_dir(stack) # Use ps --status running to check for running containers - command = f"docker compose -f {compose_path} ps --status running -q" + # Use cd to let docker compose find the compose file on the remote host + command = f'cd "{stack_dir}" && docker compose ps --status running -q' result = await run_command(host, command, stack, stream=False) # If command succeeded and has output, containers are running diff --git a/src/compose_farm/operations.py b/src/compose_farm/operations.py index b9fd1e7..c10d4ea 100644 --- a/src/compose_farm/operations.py +++ b/src/compose_farm/operations.py @@ -214,8 +214,9 @@ async def _up_multi_host_stack( """Start a multi-host stack on all configured hosts.""" host_names = cfg.get_hosts(stack) results: list[CommandResult] = [] - compose_path = cfg.get_compose_path(stack) - command = f"docker compose -f {compose_path} {build_up_cmd(pull=pull, build=build)}" + stack_dir = cfg.get_stack_dir(stack) + # Use cd to let docker compose find the compose file on the remote host + command = f'cd "{stack_dir}" && docker compose {build_up_cmd(pull=pull, build=build)}' # Pre-flight checks on all hosts for host_name in host_names: diff --git a/tests/test_executor.py b/tests/test_executor.py index c638d3a..d1371d8 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -2,6 +2,7 @@ import sys from pathlib import Path +from unittest.mock import AsyncMock, patch import pytest @@ -11,10 +12,12 @@ from compose_farm.executor import ( _run_local_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, ) @@ -106,6 +109,108 @@ class TestRunCompose: # 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 + + 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 + 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' + + 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."""