mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
refactor: Introduce StackSelection dataclass for cleaner context passing
Instead of passing filter_host separately through multiple layers, bundle the selection context into a StackSelection dataclass: - stacks: list of selected stack names - config: the loaded Config - host_filter: optional host filter from -H flag This provides: 1. Cleaner APIs - context travels together instead of being scattered 2. is_instance_level() method - encapsulates the check for whether this is an instance-level operation (host-filtered multi-host stack) 3. Future extensibility - can add more context (dry_run, verbose, etc.) Updated all callers of get_stacks() to use the new return type.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user