mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-03-01 16:52:55 +00:00
Some checks failed
Update README.md / update_readme (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (macos-latest, 3.13) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.13) (push) Has been cancelled
CI / browser-tests (push) Has been cancelled
CI / lint (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
* fix: --host filter now limits multi-host stack operations to single host
Previously, when using `-H host` with a multi-host stack like `glances: all`,
the command would find the stack (correct) but then operate on ALL hosts for
that stack (incorrect). For example, `cf down -H nas` with `glances` would
stop glances on all 5 hosts instead of just nas.
Now, when `--host` is specified:
- `cf down -H nas` only stops stacks on nas, including only the nas instance
of multi-host stacks
- `cf up -H nas` only starts stacks on nas (skips migration logic since
host is explicitly specified)
Added tests for the new filter_host behavior in both executor and CLI.
* fix: Apply filter_host to logs and ps commands as well
Same bug as up/down: when using `-H host` with multi-host stacks,
logs and ps would show results from all hosts instead of just the
filtered host.
* 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.
* 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.
* Revert "refactor: Introduce StackSelection dataclass for cleaner context passing"
This reverts commit e6e9eed93e.
* feat: Proper per-host state tracking for multi-host stacks
- Add `remove_stack_host()` to remove a single host from a multi-host stack's state
- Add `add_stack_host()` to add a single host to a stack's state
- Update `down` command to use `remove_stack_host` for host-filtered multi-host stacks
- Update `up` command to use `add_stack_host` for host-filtered operations
This ensures the state file accurately reflects which hosts each stack is running on,
rather than just tracking if it's running at all.
* fix: Use set comparisons for host list tests
Host lists may be reordered during YAML save/load, so test for
set equality rather than list equality.
* refactor: Merge remove_stack_host into remove_stack as optional parameter
Instead of a separate function, `remove_stack` now takes an optional
`host` parameter. When specified, it removes only that host from
multi-host stacks. This reduces API surface and follows the existing
pattern.
* fix: Restore deterministic host list sorting and add filter_host test
- Restore sorting of list values in _sorted_dict for consistent YAML output
- Add test for logs --host passing filter_host to run_on_stacks
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Tests for state module."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from compose_farm.config import Config, Host
|
|
from compose_farm.state import (
|
|
add_stack_host,
|
|
get_orphaned_stacks,
|
|
get_stack_host,
|
|
get_stacks_not_in_state,
|
|
load_state,
|
|
remove_stack,
|
|
save_state,
|
|
set_stack_host,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def config(tmp_path: Path) -> Config:
|
|
"""Create a config with a temporary config path for state storage."""
|
|
config_path = tmp_path / "compose-farm.yaml"
|
|
config_path.write_text("") # Create empty file
|
|
return Config(
|
|
compose_dir=tmp_path / "compose",
|
|
hosts={"nas01": Host(address="192.168.1.10")},
|
|
stacks={"plex": "nas01"},
|
|
config_path=config_path,
|
|
)
|
|
|
|
|
|
class TestLoadState:
|
|
"""Tests for load_state function."""
|
|
|
|
def test_load_state_empty(self, config: Config) -> None:
|
|
"""Returns empty dict when state file doesn't exist."""
|
|
result = load_state(config)
|
|
assert result == {}
|
|
|
|
def test_load_state_with_data(self, config: Config) -> None:
|
|
"""Loads existing state from file."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n")
|
|
|
|
result = load_state(config)
|
|
assert result == {"plex": "nas01", "jellyfin": "nas02"}
|
|
|
|
def test_load_state_empty_file(self, config: Config) -> None:
|
|
"""Returns empty dict for empty file."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("")
|
|
|
|
result = load_state(config)
|
|
assert result == {}
|
|
|
|
|
|
class TestSaveState:
|
|
"""Tests for save_state function."""
|
|
|
|
def test_save_state(self, config: Config) -> None:
|
|
"""Saves state to file."""
|
|
save_state(config, {"plex": "nas01", "jellyfin": "nas02"})
|
|
|
|
state_file = config.get_state_path()
|
|
assert state_file.exists()
|
|
content = state_file.read_text()
|
|
assert "plex: nas01" in content
|
|
assert "jellyfin: nas02" in content
|
|
|
|
def test_save_state_sorts_host_lists(self, config: Config) -> None:
|
|
"""Saves state with sorted host lists for consistent output."""
|
|
# Pass hosts in unsorted order
|
|
save_state(config, {"glances": ["pc", "nas", "hp", "anton"]})
|
|
|
|
state_file = config.get_state_path()
|
|
content = state_file.read_text()
|
|
# Hosts should be sorted alphabetically
|
|
assert "- anton\n - hp\n - nas\n - pc" in content
|
|
|
|
|
|
class TestGetStackHost:
|
|
"""Tests for get_stack_host function."""
|
|
|
|
def test_get_existing_stack(self, config: Config) -> None:
|
|
"""Returns host for existing stack."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n")
|
|
|
|
host = get_stack_host(config, "plex")
|
|
assert host == "nas01"
|
|
|
|
def test_get_nonexistent_stack(self, config: Config) -> None:
|
|
"""Returns None for stack not in state."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n")
|
|
|
|
host = get_stack_host(config, "unknown")
|
|
assert host is None
|
|
|
|
|
|
class TestSetStackHost:
|
|
"""Tests for set_stack_host function."""
|
|
|
|
def test_set_new_stack(self, config: Config) -> None:
|
|
"""Adds new stack to state."""
|
|
set_stack_host(config, "plex", "nas01")
|
|
|
|
result = load_state(config)
|
|
assert result["plex"] == "nas01"
|
|
|
|
def test_update_existing_stack(self, config: Config) -> None:
|
|
"""Updates host for existing stack."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n")
|
|
|
|
set_stack_host(config, "plex", "nas02")
|
|
|
|
result = load_state(config)
|
|
assert result["plex"] == "nas02"
|
|
|
|
|
|
class TestRemoveStack:
|
|
"""Tests for remove_stack function."""
|
|
|
|
def test_remove_existing_stack(self, config: Config) -> None:
|
|
"""Removes stack from state."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n")
|
|
|
|
remove_stack(config, "plex")
|
|
|
|
result = load_state(config)
|
|
assert "plex" not in result
|
|
assert result["jellyfin"] == "nas02"
|
|
|
|
def test_remove_nonexistent_stack(self, config: Config) -> None:
|
|
"""Removing nonexistent stack doesn't error."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n")
|
|
|
|
remove_stack(config, "unknown") # Should not raise
|
|
|
|
result = load_state(config)
|
|
assert result["plex"] == "nas01"
|
|
|
|
def test_remove_host_from_list(self, config: Config) -> None:
|
|
"""Removes one host from a multi-host stack's list."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n - hp\n")
|
|
|
|
remove_stack(config, "glances", "nas")
|
|
|
|
result = load_state(config)
|
|
assert set(result["glances"]) == {"nuc", "hp"}
|
|
|
|
def test_remove_last_host_removes_stack(self, config: Config) -> None:
|
|
"""Removing the last host removes the stack entirely."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n glances:\n - nas\n")
|
|
|
|
remove_stack(config, "glances", "nas")
|
|
|
|
result = load_state(config)
|
|
assert "glances" not in result
|
|
|
|
def test_remove_host_from_single_host_stack(self, config: Config) -> None:
|
|
"""Removing host from single-host stack removes it if host matches."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas\n")
|
|
|
|
remove_stack(config, "plex", "nas")
|
|
|
|
result = load_state(config)
|
|
assert "plex" not in result
|
|
|
|
def test_remove_wrong_host_from_single_host_stack(self, config: Config) -> None:
|
|
"""Removing wrong host from single-host stack does nothing."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas\n")
|
|
|
|
remove_stack(config, "plex", "nuc")
|
|
|
|
result = load_state(config)
|
|
assert result["plex"] == "nas"
|
|
|
|
def test_remove_host_from_nonexistent_stack(self, config: Config) -> None:
|
|
"""Removing host from nonexistent stack doesn't error."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas\n")
|
|
|
|
remove_stack(config, "unknown", "nas") # Should not raise
|
|
|
|
result = load_state(config)
|
|
assert result["plex"] == "nas"
|
|
|
|
|
|
class TestAddStackHost:
|
|
"""Tests for add_stack_host function."""
|
|
|
|
def test_add_host_to_new_stack(self, config: Config) -> None:
|
|
"""Adding host to new stack creates single-host entry."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed: {}\n")
|
|
|
|
add_stack_host(config, "plex", "nas")
|
|
|
|
result = load_state(config)
|
|
assert result["plex"] == "nas"
|
|
|
|
def test_add_host_to_list(self, config: Config) -> None:
|
|
"""Adding host to existing list appends it."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n")
|
|
|
|
add_stack_host(config, "glances", "hp")
|
|
|
|
result = load_state(config)
|
|
assert set(result["glances"]) == {"nas", "nuc", "hp"}
|
|
|
|
def test_add_duplicate_host_to_list(self, config: Config) -> None:
|
|
"""Adding duplicate host to list does nothing."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n")
|
|
|
|
add_stack_host(config, "glances", "nas")
|
|
|
|
result = load_state(config)
|
|
assert set(result["glances"]) == {"nas", "nuc"}
|
|
|
|
def test_add_second_host_converts_to_list(self, config: Config) -> None:
|
|
"""Adding second host to single-host stack converts to list."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas\n")
|
|
|
|
add_stack_host(config, "plex", "nuc")
|
|
|
|
result = load_state(config)
|
|
assert set(result["plex"]) == {"nas", "nuc"}
|
|
|
|
def test_add_same_host_to_single_host_stack(self, config: Config) -> None:
|
|
"""Adding same host to single-host stack does nothing."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas\n")
|
|
|
|
add_stack_host(config, "plex", "nas")
|
|
|
|
result = load_state(config)
|
|
assert result["plex"] == "nas"
|
|
|
|
|
|
class TestGetOrphanedStacks:
|
|
"""Tests for get_orphaned_stacks function."""
|
|
|
|
def test_no_orphans(self, config: Config) -> None:
|
|
"""Returns empty dict when all stacks in state are in config."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n")
|
|
|
|
result = get_orphaned_stacks(config)
|
|
assert result == {}
|
|
|
|
def test_finds_orphaned_stack(self, config: Config) -> None:
|
|
"""Returns stacks in state but not in config."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n")
|
|
|
|
result = get_orphaned_stacks(config)
|
|
# plex is in config, jellyfin is not
|
|
assert result == {"jellyfin": "nas02"}
|
|
|
|
def test_finds_orphaned_multi_host_stack(self, config: Config) -> None:
|
|
"""Returns multi-host orphaned stacks with host list."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n dozzle:\n - nas01\n - nas02\n")
|
|
|
|
result = get_orphaned_stacks(config)
|
|
assert result == {"dozzle": ["nas01", "nas02"]}
|
|
|
|
def test_empty_state(self, config: Config) -> None:
|
|
"""Returns empty dict when state is empty."""
|
|
result = get_orphaned_stacks(config)
|
|
assert result == {}
|
|
|
|
def test_all_orphaned(self, tmp_path: Path) -> None:
|
|
"""Returns all stacks when none are in config."""
|
|
config_path = tmp_path / "compose-farm.yaml"
|
|
config_path.write_text("")
|
|
cfg = Config(
|
|
compose_dir=tmp_path / "compose",
|
|
hosts={"nas01": Host(address="192.168.1.10")},
|
|
stacks={}, # No stacks in config
|
|
config_path=config_path,
|
|
)
|
|
state_file = cfg.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n jellyfin: nas02\n")
|
|
|
|
result = get_orphaned_stacks(cfg)
|
|
assert result == {"plex": "nas01", "jellyfin": "nas02"}
|
|
|
|
|
|
class TestGetStacksNotInState:
|
|
"""Tests for get_stacks_not_in_state function."""
|
|
|
|
def test_all_in_state(self, config: Config) -> None:
|
|
"""Returns empty list when all stacks are in state."""
|
|
state_file = config.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n")
|
|
|
|
result = get_stacks_not_in_state(config)
|
|
assert result == []
|
|
|
|
def test_finds_missing_service(self, tmp_path: Path) -> None:
|
|
"""Returns stacks in config but not in state."""
|
|
config_path = tmp_path / "compose-farm.yaml"
|
|
config_path.write_text("")
|
|
cfg = Config(
|
|
compose_dir=tmp_path / "compose",
|
|
hosts={"nas01": Host(address="192.168.1.10")},
|
|
stacks={"plex": "nas01", "jellyfin": "nas01"},
|
|
config_path=config_path,
|
|
)
|
|
state_file = cfg.get_state_path()
|
|
state_file.write_text("deployed:\n plex: nas01\n")
|
|
|
|
result = get_stacks_not_in_state(cfg)
|
|
assert result == ["jellyfin"]
|
|
|
|
def test_empty_state(self, tmp_path: Path) -> None:
|
|
"""Returns all stacks when state is empty."""
|
|
config_path = tmp_path / "compose-farm.yaml"
|
|
config_path.write_text("")
|
|
cfg = Config(
|
|
compose_dir=tmp_path / "compose",
|
|
hosts={"nas01": Host(address="192.168.1.10")},
|
|
stacks={"plex": "nas01", "jellyfin": "nas01"},
|
|
config_path=config_path,
|
|
)
|
|
|
|
result = get_stacks_not_in_state(cfg)
|
|
assert set(result) == {"plex", "jellyfin"}
|
|
|
|
def test_empty_config(self, config: Config) -> None:
|
|
"""Returns empty list when config has no stacks."""
|
|
# config fixture has plex: nas01, but we need empty config
|
|
config_path = config.config_path
|
|
config_path.write_text("")
|
|
cfg = Config(
|
|
compose_dir=config.compose_dir,
|
|
hosts={"nas01": Host(address="192.168.1.10")},
|
|
stacks={},
|
|
config_path=config_path,
|
|
)
|
|
|
|
result = get_stacks_not_in_state(cfg)
|
|
assert result == []
|