diff --git a/src/compose_farm/cli/common.py b/src/compose_farm/cli/common.py index 573937b..90c8da7 100644 --- a/src/compose_farm/cli/common.py +++ b/src/compose_farm/cli/common.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import contextlib +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Annotated, TypeVar @@ -38,6 +39,28 @@ _T = TypeVar("_T") _R = TypeVar("_R") +@dataclass +class StackSelection: + """Result of stack selection with context for execution. + + Bundles together the selected stacks, config, and any filters applied. + This context flows through to execution and state management. + """ + + stacks: list[str] + config: Config + host_filter: str | None = None + + def is_instance_level(self, stack: str) -> bool: + """Check if this is an instance-level operation for a stack. + + Instance-level means we're operating on just one host of a multi-host + stack (via --host filter). This affects state management - we shouldn't + remove multi-host stacks from state when only one instance was affected. + """ + return self.host_filter is not None and self.config.is_multi_host(stack) + + # --- Shared CLI Options --- StacksArg = Annotated[ list[str] | None, @@ -154,7 +177,7 @@ def get_stacks( *, host: str | None = None, default_all: bool = False, -) -> tuple[list[str], Config]: +) -> StackSelection: """Resolve stack list and load config. Handles three mutually exclusive selection methods: @@ -171,6 +194,9 @@ def get_stacks( Supports "." as shorthand for the current directory name. + Returns: + StackSelection with stacks, config, and host_filter context. + """ validate_stack_selection(stacks, all_stacks, host) config = load_config_or_exit(config_path) @@ -181,14 +207,14 @@ def get_stacks( if not stack_list: print_warning(f"No stacks configured for host [magenta]{host}[/]") raise typer.Exit(0) - return stack_list, config + return StackSelection(stack_list, config, host_filter=host) if all_stacks: - return list(config.stacks.keys()), config + return StackSelection(list(config.stacks.keys()), config) if not stacks: if default_all: - return list(config.stacks.keys()), config + return StackSelection(list(config.stacks.keys()), config) print_error("Specify stacks or use [bold]--all[/] / [bold]--host[/]") raise typer.Exit(1) @@ -200,7 +226,7 @@ def get_stacks( config, resolved, hint="Add the stack to compose-farm.yaml or use [bold]--all[/]" ) - return resolved, config + return StackSelection(resolved, config) def run_async(coro: Coroutine[None, None, _T]) -> _T: diff --git a/src/compose_farm/cli/lifecycle.py b/src/compose_farm/cli/lifecycle.py index 9e4fc51..ed35567 100644 --- a/src/compose_farm/cli/lifecycle.py +++ b/src/compose_farm/cli/lifecycle.py @@ -62,32 +62,37 @@ def up( config: ConfigOption = None, ) -> None: """Start stacks (docker compose up -d). Auto-migrates if host changed.""" - stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host) + selection = get_stacks(stacks or [], all_stacks, config, host=host) if service: - if len(stack_list) != 1: + if len(selection.stacks) != 1: print_error("--service requires exactly one stack") raise typer.Exit(1) # For service-level up, use run_on_stacks directly (no migration logic) results = run_async( run_on_stacks( - cfg, stack_list, build_up_cmd(pull=pull, build=build, service=service), raw=True + selection.config, + selection.stacks, + build_up_cmd(pull=pull, build=build, service=service), + raw=True, ) ) - elif host: + elif selection.host_filter: # For host-filtered up, use run_on_stacks to only affect that host # (skips migration logic, which is intended when explicitly specifying a host) results = run_async( run_on_stacks( - cfg, - stack_list, + selection.config, + selection.stacks, build_up_cmd(pull=pull, build=build), raw=True, - filter_host=host, + filter_host=selection.host_filter, ) ) else: - results = run_async(up_stacks(cfg, stack_list, raw=True, pull=pull, build=build)) - maybe_regenerate_traefik(cfg, results) + results = run_async( + up_stacks(selection.config, selection.stacks, raw=True, pull=pull, build=build) + ) + maybe_regenerate_traefik(selection.config, results) report_results(results) @@ -126,26 +131,33 @@ def down( report_results(results) return - stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host) - raw = len(stack_list) == 1 - results = run_async(run_on_stacks(cfg, stack_list, "down", raw=raw, filter_host=host)) + selection = get_stacks(stacks or [], all_stacks, config, host=host) + raw = len(selection.stacks) == 1 + results = run_async( + run_on_stacks( + selection.config, + selection.stacks, + "down", + raw=raw, + filter_host=selection.host_filter, + ) + ) # 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 + # Skip state removal for instance-level operations (host-filtered multi-host) # because only one instance was stopped, the stack is still running elsewhere - if host and cfg.is_multi_host(base_stack): + if selection.is_instance_level(base_stack): continue - remove_stack(cfg, base_stack) + remove_stack(selection.config, base_stack) removed_stacks.add(base_stack) - maybe_regenerate_traefik(cfg, results) + maybe_regenerate_traefik(selection.config, results) report_results(results) @@ -157,13 +169,13 @@ def stop( config: ConfigOption = None, ) -> None: """Stop services without removing containers (docker compose stop).""" - stack_list, cfg = get_stacks(stacks or [], all_stacks, config) - if service and len(stack_list) != 1: + selection = get_stacks(stacks or [], all_stacks, config) + if service and len(selection.stacks) != 1: print_error("--service requires exactly one stack") raise typer.Exit(1) cmd = f"stop {service}" if service else "stop" - raw = len(stack_list) == 1 - results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw)) + raw = len(selection.stacks) == 1 + results = run_async(run_on_stacks(selection.config, selection.stacks, cmd, raw=raw)) report_results(results) @@ -175,13 +187,13 @@ def pull( config: ConfigOption = None, ) -> None: """Pull latest images (docker compose pull).""" - stack_list, cfg = get_stacks(stacks or [], all_stacks, config) - if service and len(stack_list) != 1: + selection = get_stacks(stacks or [], all_stacks, config) + if service and len(selection.stacks) != 1: print_error("--service requires exactly one stack") raise typer.Exit(1) cmd = f"pull --ignore-buildable {service}" if service else "pull --ignore-buildable" - raw = len(stack_list) == 1 - results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw)) + raw = len(selection.stacks) == 1 + results = run_async(run_on_stacks(selection.config, selection.stacks, cmd, raw=raw)) report_results(results) @@ -193,16 +205,16 @@ def restart( config: ConfigOption = None, ) -> None: """Restart running containers (docker compose restart).""" - stack_list, cfg = get_stacks(stacks or [], all_stacks, config) + selection = get_stacks(stacks or [], all_stacks, config) if service: - if len(stack_list) != 1: + if len(selection.stacks) != 1: print_error("--service requires exactly one stack") raise typer.Exit(1) cmd = f"restart {service}" else: cmd = "restart" - raw = len(stack_list) == 1 - results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw)) + raw = len(selection.stacks) == 1 + results = run_async(run_on_stacks(selection.config, selection.stacks, cmd, raw=raw)) report_results(results) diff --git a/src/compose_farm/cli/management.py b/src/compose_farm/cli/management.py index 417b37f..dc67e7f 100644 --- a/src/compose_farm/cli/management.py +++ b/src/compose_farm/cli/management.py @@ -453,9 +453,9 @@ def traefik_file( render_traefik_config, ) - stack_list, cfg = get_stacks(stacks or [], all_stacks, config) + selection = get_stacks(stacks or [], all_stacks, config) try: - dynamic, warnings = generate_traefik_config(cfg, stack_list) + dynamic, warnings = generate_traefik_config(selection.config, selection.stacks) except (FileNotFoundError, ValueError) as exc: print_error(str(exc)) raise typer.Exit(1) from exc @@ -495,22 +495,22 @@ def refresh( Use 'cf apply' to make reality match your config (stop orphans, migrate). """ - stack_list, cfg = get_stacks(stacks or [], all_stacks, config, default_all=True) + selection = get_stacks(stacks or [], all_stacks, config, default_all=True) # Partial refresh merges with existing state; full refresh replaces it # Partial = specific stacks provided (not --all, not default) partial_refresh = bool(stacks) and not all_stacks - current_state = load_state(cfg) + current_state = load_state(selection.config) - discovered, strays, duplicates = _discover_stacks_full(cfg, stack_list) + discovered, strays, duplicates = _discover_stacks_full(selection.config, selection.stacks) # Calculate changes (only for the stacks we're refreshing) added = [s for s in discovered if s not in current_state] # Only mark as "removed" if we're doing a full refresh if partial_refresh: # In partial refresh, a stack not running is just "not found" - removed = [s for s in stack_list if s in current_state and s not in discovered] + removed = [s for s in selection.stacks if s in current_state and s not in discovered] else: removed = [s for s in current_state if s not in discovered] changed = [ @@ -526,8 +526,8 @@ def refresh( else: print_success("State is already in sync.") - _report_stray_stacks(strays, cfg) - _report_duplicate_stacks(duplicates, cfg) + _report_stray_stacks(strays, selection.config) + _report_duplicate_stacks(duplicates, selection.config) if dry_run: console.print(f"\n{MSG_DRY_RUN}") @@ -538,13 +538,13 @@ def refresh( new_state = ( _merge_state(current_state, discovered, removed) if partial_refresh else discovered ) - save_state(cfg, new_state) + save_state(selection.config, new_state) print_success(f"State updated: {len(new_state)} stacks tracked.") # Capture image digests for running stacks (1 SSH call per host) if discovered: try: - path = _snapshot_stacks(cfg, discovered, log_path) + path = _snapshot_stacks(selection.config, discovered, log_path) print_success(f"Digests written to {path}") except RuntimeError as exc: print_warning(str(exc)) diff --git a/src/compose_farm/cli/monitoring.py b/src/compose_farm/cli/monitoring.py index 0512172..e1cbc7b 100644 --- a/src/compose_farm/cli/monitoring.py +++ b/src/compose_farm/cli/monitoring.py @@ -235,20 +235,22 @@ def logs( config: ConfigOption = None, ) -> None: """Show stack logs. With --service, shows logs for just that service.""" - stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host) - if service and len(stack_list) != 1: + selection = get_stacks(stacks or [], all_stacks, config, host=host) + if service and len(selection.stacks) != 1: print_error("--service requires exactly one stack") raise typer.Exit(1) # Default to fewer lines when showing multiple stacks - many_stacks = all_stacks or host is not None or len(stack_list) > 1 + many_stacks = all_stacks or selection.host_filter is not None or len(selection.stacks) > 1 effective_tail = tail if tail is not None else (20 if many_stacks else 100) cmd = f"logs --tail {effective_tail}" if follow: cmd += " -f" if service: cmd += f" {service}" - results = run_async(run_on_stacks(cfg, stack_list, cmd, filter_host=host)) + results = run_async( + run_on_stacks(selection.config, selection.stacks, cmd, filter_host=selection.host_filter) + ) report_results(results) @@ -267,12 +269,14 @@ def ps( With --host: shows stacks on that host. With --service: filters to a specific service within the stack. """ - stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host, default_all=True) - if service and len(stack_list) != 1: + selection = get_stacks(stacks or [], all_stacks, config, host=host, default_all=True) + if service and len(selection.stacks) != 1: print_error("--service requires exactly one stack") raise typer.Exit(1) cmd = f"ps {service}" if service else "ps" - results = run_async(run_on_stacks(cfg, stack_list, cmd, filter_host=host)) + results = run_async( + run_on_stacks(selection.config, selection.stacks, cmd, filter_host=selection.host_filter) + ) report_results(results) diff --git a/tests/test_cli_lifecycle.py b/tests/test_cli_lifecycle.py index baa20d4..e854a8d 100644 --- a/tests/test_cli_lifecycle.py +++ b/tests/test_cli_lifecycle.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest import typer +from compose_farm.cli.common import StackSelection from compose_farm.cli.lifecycle import apply, down from compose_farm.config import Config, Host from compose_farm.executor import CommandResult @@ -486,7 +487,7 @@ class TestHostFilterMultiHost: patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"), patch("compose_farm.cli.lifecycle.report_results"), ): - mock_get_stacks.return_value = (["multi-host"], cfg) + mock_get_stacks.return_value = StackSelection(["multi-host"], cfg, host_filter="host1") down( stacks=None, @@ -521,7 +522,7 @@ class TestHostFilterMultiHost: patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"), patch("compose_farm.cli.lifecycle.report_results"), ): - mock_get_stacks.return_value = (["multi-host"], cfg) + mock_get_stacks.return_value = StackSelection(["multi-host"], cfg, host_filter="host1") down( stacks=None, @@ -554,7 +555,7 @@ class TestHostFilterMultiHost: patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"), patch("compose_farm.cli.lifecycle.report_results"), ): - mock_get_stacks.return_value = (["multi-host"], cfg) + mock_get_stacks.return_value = StackSelection(["multi-host"], cfg, host_filter=None) down( stacks=None, diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 818a4a0..5180259 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -8,6 +8,7 @@ import pytest from compose_farm import executor as executor_module from compose_farm import state as state_module from compose_farm.cli import management as cli_management_module +from compose_farm.cli.common import StackSelection from compose_farm.config import Config, Host from compose_farm.executor import CommandResult, check_stack_running @@ -204,7 +205,7 @@ class TestRefreshCommand: with ( patch( "compose_farm.cli.management.get_stacks", - return_value=(["plex"], mock_config), + return_value=StackSelection(["plex"], mock_config), ), patch( "compose_farm.cli.management.load_state", @@ -240,7 +241,7 @@ class TestRefreshCommand: with ( patch( "compose_farm.cli.management.get_stacks", - return_value=(["plex", "jellyfin", "grafana"], mock_config), + return_value=StackSelection(["plex", "jellyfin", "grafana"], mock_config), ), patch( "compose_farm.cli.management.load_state", @@ -278,7 +279,7 @@ class TestRefreshCommand: with ( patch( "compose_farm.cli.management.get_stacks", - return_value=(["plex", "jellyfin", "grafana"], mock_config), + return_value=StackSelection(["plex", "jellyfin", "grafana"], mock_config), ), patch( "compose_farm.cli.management.load_state", @@ -312,7 +313,7 @@ class TestRefreshCommand: with ( patch( "compose_farm.cli.management.get_stacks", - return_value=(["plex", "jellyfin"], mock_config), + return_value=StackSelection(["plex", "jellyfin"], mock_config), ), patch( "compose_farm.cli.management.load_state", @@ -347,7 +348,7 @@ class TestRefreshCommand: with ( patch( "compose_farm.cli.management.get_stacks", - return_value=(["plex"], mock_config), + return_value=StackSelection(["plex"], mock_config), ), patch( "compose_farm.cli.management.load_state",