Fix compose file resolution on remote hosts (#164)

This commit is contained in:
Bas Nijholt
2026-01-11 00:22:55 +01:00
committed by GitHub
parent 1e3b1d71ed
commit 2f3720949b
4 changed files with 136 additions and 28 deletions

View File

@@ -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():

View File

@@ -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

View File

@@ -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:

View File

@@ -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 <dir> && 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 <dir> && 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 <dir> && 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."""