mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
feat: add service arguments to refresh command (#70)
This commit is contained in:
@@ -697,7 +697,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf refresh [OPTIONS]
|
||||
Usage: cf refresh [OPTIONS] [SERVICES]...
|
||||
|
||||
Update local state from running services.
|
||||
|
||||
@@ -705,9 +705,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
file, and captures image digests. This is a read operation - it updates
|
||||
your local state to match reality, not the other way around.
|
||||
|
||||
Without arguments: refreshes all services (same as --all).
|
||||
With service names: refreshes only those services.
|
||||
|
||||
Use 'cf apply' to make reality match your config (stop orphans, migrate).
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --log-path -l PATH Path to Dockerfarm TOML log │
|
||||
│ --dry-run -n Show what would change without writing │
|
||||
|
||||
@@ -60,11 +60,14 @@ from compose_farm.traefik import generate_traefik_config, render_traefik_config
|
||||
# --- Sync helpers ---
|
||||
|
||||
|
||||
def _discover_services(cfg: Config) -> dict[str, str | list[str]]:
|
||||
def _discover_services(
|
||||
cfg: Config, services: list[str] | None = None
|
||||
) -> dict[str, str | list[str]]:
|
||||
"""Discover running services with a progress bar."""
|
||||
svc_list = services if services is not None else list(cfg.services)
|
||||
results = run_parallel_with_progress(
|
||||
"Discovering",
|
||||
list(cfg.services),
|
||||
svc_list,
|
||||
lambda s: discover_service_host(cfg, s),
|
||||
)
|
||||
return {svc: host for svc, host in results if host is not None}
|
||||
@@ -104,6 +107,18 @@ def _snapshot_services(
|
||||
return effective_log_path
|
||||
|
||||
|
||||
def _merge_state(
|
||||
current_state: dict[str, str | list[str]],
|
||||
discovered: dict[str, str | list[str]],
|
||||
removed: list[str],
|
||||
) -> dict[str, str | list[str]]:
|
||||
"""Merge discovered services into existing state for partial refresh."""
|
||||
new_state = {**current_state, **discovered}
|
||||
for svc in removed:
|
||||
new_state.pop(svc, None)
|
||||
return new_state
|
||||
|
||||
|
||||
def _report_sync_changes(
|
||||
added: list[str],
|
||||
removed: list[str],
|
||||
@@ -399,6 +414,8 @@ def traefik_file(
|
||||
|
||||
@app.command(rich_help_panel="Configuration")
|
||||
def refresh(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
config: ConfigOption = None,
|
||||
log_path: LogPathOption = None,
|
||||
dry_run: Annotated[
|
||||
@@ -412,16 +429,29 @@ def refresh(
|
||||
file, and captures image digests. This is a read operation - it updates
|
||||
your local state to match reality, not the other way around.
|
||||
|
||||
Without arguments: refreshes all services (same as --all).
|
||||
With service names: refreshes only those services.
|
||||
|
||||
Use 'cf apply' to make reality match your config (stop orphans, migrate).
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
svc_list, cfg = get_services(services or [], all_services, config, default_all=True)
|
||||
|
||||
# Partial refresh merges with existing state; full refresh replaces it
|
||||
# Partial = specific services provided (not --all, not default)
|
||||
partial_refresh = bool(services) and not all_services
|
||||
|
||||
current_state = load_state(cfg)
|
||||
|
||||
discovered = _discover_services(cfg)
|
||||
discovered = _discover_services(cfg, svc_list)
|
||||
|
||||
# Calculate changes
|
||||
# Calculate changes (only for the services we're refreshing)
|
||||
added = [s for s in discovered if s not in current_state]
|
||||
removed = [s for s in current_state if s not in discovered]
|
||||
# Only mark as "removed" if we're doing a full refresh
|
||||
if partial_refresh:
|
||||
# In partial refresh, a service not running is just "not found"
|
||||
removed = [s for s in svc_list if s in current_state and s not in discovered]
|
||||
else:
|
||||
removed = [s for s in current_state if s not in discovered]
|
||||
changed = [
|
||||
(s, current_state[s], discovered[s])
|
||||
for s in discovered
|
||||
@@ -441,8 +471,11 @@ def refresh(
|
||||
|
||||
# Update state file
|
||||
if state_changed:
|
||||
save_state(cfg, discovered)
|
||||
print_success(f"State updated: {len(discovered)} services tracked.")
|
||||
new_state = (
|
||||
_merge_state(current_state, discovered, removed) if partial_refresh else discovered
|
||||
)
|
||||
save_state(cfg, new_state)
|
||||
print_success(f"State updated: {len(new_state)} services tracked.")
|
||||
|
||||
# Capture image digests for running services
|
||||
if discovered:
|
||||
|
||||
@@ -19,7 +19,7 @@ def mock_config(tmp_path: Path) -> Config:
|
||||
compose_dir.mkdir()
|
||||
|
||||
# Create service directories with compose files
|
||||
for service in ["plex", "jellyfin", "sonarr"]:
|
||||
for service in ["plex", "jellyfin", "grafana"]:
|
||||
svc_dir = compose_dir / service
|
||||
svc_dir.mkdir()
|
||||
(svc_dir / "compose.yaml").write_text(f"# {service} compose file\n")
|
||||
@@ -33,7 +33,7 @@ def mock_config(tmp_path: Path) -> Config:
|
||||
services={
|
||||
"plex": "nas01",
|
||||
"jellyfin": "nas01",
|
||||
"sonarr": "nas02",
|
||||
"grafana": "nas02",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -93,6 +93,60 @@ class TestCheckServiceRunning:
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestMergeState:
|
||||
"""Tests for _merge_state helper function."""
|
||||
|
||||
def test_merge_adds_new_services(self) -> None:
|
||||
"""Merging adds newly discovered services to existing state."""
|
||||
current: dict[str, str | list[str]] = {"plex": "nas01"}
|
||||
discovered: dict[str, str | list[str]] = {"jellyfin": "nas02"}
|
||||
removed: list[str] = []
|
||||
|
||||
result = cli_management_module._merge_state(current, discovered, removed)
|
||||
|
||||
assert result == {"plex": "nas01", "jellyfin": "nas02"}
|
||||
|
||||
def test_merge_updates_existing_services(self) -> None:
|
||||
"""Merging updates services that changed hosts."""
|
||||
current: dict[str, str | list[str]] = {"plex": "nas01", "jellyfin": "nas01"}
|
||||
discovered: dict[str, str | list[str]] = {"plex": "nas02"} # plex moved to nas02
|
||||
removed: list[str] = []
|
||||
|
||||
result = cli_management_module._merge_state(current, discovered, removed)
|
||||
|
||||
assert result == {"plex": "nas02", "jellyfin": "nas01"}
|
||||
|
||||
def test_merge_removes_stopped_services(self) -> None:
|
||||
"""Merging removes services that were checked but not found."""
|
||||
current: dict[str, str | list[str]] = {
|
||||
"plex": "nas01",
|
||||
"jellyfin": "nas01",
|
||||
"grafana": "nas02",
|
||||
}
|
||||
discovered: dict[str, str | list[str]] = {"plex": "nas01"} # only plex still running
|
||||
removed = ["jellyfin"] # jellyfin was checked and not found
|
||||
|
||||
result = cli_management_module._merge_state(current, discovered, removed)
|
||||
|
||||
# jellyfin removed, grafana untouched (wasn't in the refresh scope)
|
||||
assert result == {"plex": "nas01", "grafana": "nas02"}
|
||||
|
||||
def test_merge_preserves_unrelated_services(self) -> None:
|
||||
"""Merging preserves services that weren't part of the refresh."""
|
||||
current: dict[str, str | list[str]] = {
|
||||
"plex": "nas01",
|
||||
"jellyfin": "nas01",
|
||||
"grafana": "nas02",
|
||||
}
|
||||
discovered: dict[str, str | list[str]] = {"plex": "nas02"} # only refreshed plex
|
||||
removed: list[str] = [] # nothing was removed
|
||||
|
||||
result = cli_management_module._merge_state(current, discovered, removed)
|
||||
|
||||
# plex updated, others preserved
|
||||
assert result == {"plex": "nas02", "jellyfin": "nas01", "grafana": "nas02"}
|
||||
|
||||
|
||||
class TestReportSyncChanges:
|
||||
"""Tests for _report_sync_changes function."""
|
||||
|
||||
@@ -114,14 +168,14 @@ class TestReportSyncChanges:
|
||||
"""Reports services that are no longer running."""
|
||||
cli_management_module._report_sync_changes(
|
||||
added=[],
|
||||
removed=["sonarr"],
|
||||
removed=["grafana"],
|
||||
changed=[],
|
||||
discovered={},
|
||||
current_state={"sonarr": "nas01"},
|
||||
current_state={"grafana": "nas01"},
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "Services no longer running (1)" in captured.out
|
||||
assert "- sonarr (was on nas01)" in captured.out
|
||||
assert "- grafana (was on nas01)" in captured.out
|
||||
|
||||
def test_reports_changed(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Reports services that moved to a different host."""
|
||||
@@ -135,3 +189,182 @@ class TestReportSyncChanges:
|
||||
captured = capsys.readouterr()
|
||||
assert "Services on different hosts (1)" in captured.out
|
||||
assert "~ plex: nas01 → nas02" in captured.out
|
||||
|
||||
|
||||
class TestRefreshCommand:
|
||||
"""Tests for the refresh command with service arguments."""
|
||||
|
||||
def test_refresh_specific_service_partial_merge(
|
||||
self, mock_config: Config, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Refreshing specific services merges with existing state."""
|
||||
# Mock existing state
|
||||
existing_state = {"plex": "nas01", "jellyfin": "nas01", "grafana": "nas02"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"compose_farm.cli.management.get_services",
|
||||
return_value=(["plex"], mock_config),
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management.load_state",
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_services",
|
||||
return_value={"plex": "nas02"}, # plex moved to nas02
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_services"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
):
|
||||
# services=["plex"], all_services=False -> partial refresh
|
||||
cli_management_module.refresh(
|
||||
services=["plex"],
|
||||
all_services=False,
|
||||
config=None,
|
||||
log_path=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
# Should have merged: plex updated, others preserved
|
||||
mock_save.assert_called_once()
|
||||
saved_state = mock_save.call_args[0][1]
|
||||
assert saved_state == {"plex": "nas02", "jellyfin": "nas01", "grafana": "nas02"}
|
||||
|
||||
def test_refresh_all_replaces_state(
|
||||
self, mock_config: Config, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Refreshing all services replaces the entire state."""
|
||||
existing_state = {"plex": "nas01", "jellyfin": "nas01", "old-service": "nas02"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"compose_farm.cli.management.get_services",
|
||||
return_value=(["plex", "jellyfin", "grafana"], mock_config),
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management.load_state",
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_services",
|
||||
return_value={"plex": "nas01", "grafana": "nas02"}, # jellyfin not running
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_services"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
):
|
||||
# services=None, all_services=False -> defaults to all (full refresh)
|
||||
cli_management_module.refresh(
|
||||
services=None,
|
||||
all_services=False,
|
||||
config=None,
|
||||
log_path=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
# Should have replaced: only discovered services remain
|
||||
mock_save.assert_called_once()
|
||||
saved_state = mock_save.call_args[0][1]
|
||||
assert saved_state == {"plex": "nas01", "grafana": "nas02"}
|
||||
|
||||
def test_refresh_with_all_flag_full_refresh(self, mock_config: Config) -> None:
|
||||
"""Using --all flag forces full refresh even with service names."""
|
||||
existing_state = {"plex": "nas01", "jellyfin": "nas01"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"compose_farm.cli.management.get_services",
|
||||
return_value=(["plex", "jellyfin", "grafana"], mock_config),
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management.load_state",
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_services",
|
||||
return_value={"plex": "nas01"}, # only plex running
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_services"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
):
|
||||
# all_services=True -> full refresh (replaces state)
|
||||
cli_management_module.refresh(
|
||||
services=["plex"], # ignored when --all is set
|
||||
all_services=True,
|
||||
config=None,
|
||||
log_path=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
mock_save.assert_called_once()
|
||||
saved_state = mock_save.call_args[0][1]
|
||||
# Full refresh: only discovered services
|
||||
assert saved_state == {"plex": "nas01"}
|
||||
|
||||
def test_refresh_partial_removes_stopped_service(self, mock_config: Config) -> None:
|
||||
"""Partial refresh removes a service if it was checked but not found."""
|
||||
existing_state = {"plex": "nas01", "jellyfin": "nas01", "grafana": "nas02"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"compose_farm.cli.management.get_services",
|
||||
return_value=(["plex", "jellyfin"], mock_config),
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management.load_state",
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_services",
|
||||
return_value={"plex": "nas01"}, # jellyfin not running
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_services"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
):
|
||||
cli_management_module.refresh(
|
||||
services=["plex", "jellyfin"],
|
||||
all_services=False,
|
||||
config=None,
|
||||
log_path=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
mock_save.assert_called_once()
|
||||
saved_state = mock_save.call_args[0][1]
|
||||
# jellyfin removed (was checked), grafana preserved (wasn't checked)
|
||||
assert saved_state == {"plex": "nas01", "grafana": "nas02"}
|
||||
|
||||
def test_refresh_dry_run_no_state_change(
|
||||
self, mock_config: Config, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Dry run shows changes but doesn't modify state."""
|
||||
existing_state = {"plex": "nas01"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"compose_farm.cli.management.get_services",
|
||||
return_value=(["plex"], mock_config),
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management.load_state",
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_services",
|
||||
return_value={"plex": "nas02"}, # would change
|
||||
),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
):
|
||||
cli_management_module.refresh(
|
||||
services=["plex"],
|
||||
all_services=False,
|
||||
config=None,
|
||||
log_path=None,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
# Should not save state in dry run
|
||||
mock_save.assert_not_called()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "dry-run" in captured.out
|
||||
|
||||
Reference in New Issue
Block a user