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.
This commit is contained in:
Bas Nijholt
2026-02-01 12:37:47 -08:00
parent a0f4696bb5
commit 6b802106a9
2 changed files with 71 additions and 0 deletions

View File

@@ -132,11 +132,16 @@ def down(
# Remove from state on success
# For multi-host stacks, result.stack is "stack@host", extract base name
# Skip state removal for host-filtered multi-host stacks (only one instance was stopped)
removed_stacks: set[str] = set()
for result in results:
if result.success:
base_stack = result.stack.split("@")[0]
if base_stack not in removed_stacks:
# Don't remove multi-host stacks from state when host-filtered
# because only one instance was stopped, the stack is still running elsewhere
if host and cfg.is_multi_host(base_stack):
continue
remove_stack(cfg, base_stack)
removed_stacks.add(base_stack)

View File

@@ -500,3 +500,69 @@ class TestHostFilterMultiHost:
mock_run.assert_called_once()
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs.get("filter_host") == "host1"
def test_down_host_filter_skips_state_removal_for_multi_host(self, tmp_path: Path) -> None:
"""--host filter should NOT remove multi-host stacks from state.
When stopping only one instance of a multi-host stack, the stack is still
running on other hosts, so it shouldn't be removed from state.
"""
cfg = self._make_multi_host_config(tmp_path)
with (
patch("compose_farm.cli.lifecycle.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.lifecycle.get_stacks") as mock_get_stacks,
patch("compose_farm.cli.lifecycle.run_on_stacks"),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[_make_result("multi-host@host1")],
),
patch("compose_farm.cli.lifecycle.remove_stack") as mock_remove,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
mock_get_stacks.return_value = (["multi-host"], cfg)
down(
stacks=None,
all_stacks=False,
orphaned=False,
host="host1",
config=None,
)
# remove_stack should NOT be called for multi-host stacks with host filter
mock_remove.assert_not_called()
def test_down_without_host_filter_removes_from_state(self, tmp_path: Path) -> None:
"""Without --host filter, multi-host stacks SHOULD be removed from state."""
cfg = self._make_multi_host_config(tmp_path)
with (
patch("compose_farm.cli.lifecycle.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.lifecycle.get_stacks") as mock_get_stacks,
patch("compose_farm.cli.lifecycle.run_on_stacks"),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[
_make_result("multi-host@host1"),
_make_result("multi-host@host2"),
_make_result("multi-host@host3"),
],
),
patch("compose_farm.cli.lifecycle.remove_stack") as mock_remove,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
mock_get_stacks.return_value = (["multi-host"], cfg)
down(
stacks=None,
all_stacks=False,
orphaned=False,
host=None, # No host filter
config=None,
)
# remove_stack SHOULD be called when stopping all instances
mock_remove.assert_called_once_with(cfg, "multi-host")