Files
compose-farm/tests/test_config.py
Bas Nijholt 1e3b1d71ed Drop CF_LOCAL_HOST; limit web-stack inference to containers (#163)
* config: Add local_host and web_stack options

Allow configuring local_host and web_stack in compose-farm.yaml instead
of requiring environment variables. This makes it easier to deploy the
web UI with just a config file mount.

- local_host: specifies which host is "local" for Glances connectivity
- web_stack: identifies the web UI stack for self-update detection

Environment variables (CF_LOCAL_HOST, CF_WEB_STACK) still work as
fallback for backwards compatibility.

Closes #152

* docs: Clarify glances_stack is used by CLI and web UI

* config: Env vars override config, add docs

- Change precedence: environment variables now override config values
  (follows 12-factor app pattern)
- Document all CF_* environment variables in configuration.md
- Update example-config.yaml to mention env var overrides

* config: Consolidate env vars, prefer config options

- Update docker-compose.yml to comment out CF_WEB_STACK and CF_LOCAL_HOST
  (now prefer setting in compose-farm.yaml)
- Update init-env to comment out CF_LOCAL_HOST (can be set in config)
- Update docker-deployment.md with new "Config option" column
- Simplify troubleshooting to prefer config over env vars

* config: Generate CF_LOCAL_HOST with config alternative note

Instead of commenting out CF_LOCAL_HOST, generate it normally but add
a note in the comment that it can also be set as 'local_host' in config.

* config: Extend local_host to all web UI operations

When running the web UI in a Docker container, is_local() can't detect
which host the container is on due to different network namespaces.

Previously local_host/CF_LOCAL_HOST only affected Glances connectivity.
Now it also affects:
- Container exec/shell (runs locally instead of via SSH)
- File editing (uses local filesystem instead of SSH)

Added is_local_host() helper that checks CF_LOCAL_HOST/config.local_host
first, then falls back to is_local() detection.

* refactor: DRY get_web_stack helper, add tests

- Move get_web_stack to deps.py to avoid duplication in streaming.py
  and actions.py
- Add tests for config.local_host and config.web_stack parsing
- Add tests for is_local_host, get_web_stack, and get_local_host helpers
- Tests verify env var precedence over config values

* glances: rely on CF_WEB_STACK for container mode

Restore docker-compose env defaults and document local_host scope.

* web: ignore local_host outside container

Document container-only behavior and adjust tests.

* web: infer local host from web_stack

Drop local_host config option and update docs/tests.

* Remove CF_LOCAL_HOST override

* refactor: move web_stack helpers to Config class

- Add get_web_stack() and get_local_host_from_web_stack() as Config methods
- Remove duplicate _get_local_host_from_web_stack() from glances.py and deps.py
- Update deps.py get_web_stack() to delegate to Config method
- Add comprehensive tests for the new Config methods

* config: remove web_stack config option

The web_stack config option was redundant since:
- In Docker, CF_WEB_STACK env var is always set
- Outside Docker, the container-specific behavior is disabled anyway

Simplify by only using the CF_WEB_STACK environment variable.

* refactor: remove get_web_stack wrapper from deps

Callers now use config.get_web_stack() directly instead of
going through a pointless wrapper function.

* prompts: add rule to identify pointless wrapper functions
2026-01-10 10:48:35 +01:00

217 lines
8.0 KiB
Python

"""Tests for config module."""
from pathlib import Path
import pytest
import yaml
from compose_farm.config import Config, Host, load_config
class TestHost:
"""Tests for Host model."""
def test_host_with_all_fields(self) -> None:
host = Host(address="192.168.1.10", user="docker", port=2222)
assert host.address == "192.168.1.10"
assert host.user == "docker"
assert host.port == 2222
def test_host_defaults(self) -> None:
host = Host(address="192.168.1.10")
assert host.address == "192.168.1.10"
assert host.port == 22
# user defaults to current user, just check it's set
assert host.user
def test_local_host(self) -> None:
host = Host(address="local")
assert host.address == "local"
class TestConfig:
"""Tests for Config model."""
def test_config_validation(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas01": Host(address="192.168.1.10")},
stacks={"plex": "nas01"},
)
assert config.compose_dir == Path("/opt/compose")
assert "nas01" in config.hosts
assert config.stacks["plex"] == "nas01"
def test_config_invalid_stack_host(self) -> None:
with pytest.raises(ValueError, match="unknown host"):
Config(
compose_dir=Path("/opt/compose"),
hosts={"nas01": Host(address="192.168.1.10")},
stacks={"plex": "nonexistent"},
)
def test_get_host(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas01": Host(address="192.168.1.10")},
stacks={"plex": "nas01"},
)
host = config.get_host("plex")
assert host.address == "192.168.1.10"
def test_get_host_unknown_stack(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas01": Host(address="192.168.1.10")},
stacks={"plex": "nas01"},
)
with pytest.raises(ValueError, match="Unknown stack"):
config.get_host("unknown")
def test_get_compose_path(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas01": Host(address="192.168.1.10")},
stacks={"plex": "nas01"},
)
path = config.get_compose_path("plex")
# Defaults to compose.yaml when no file exists
assert path == Path("/opt/compose/plex/compose.yaml")
def test_get_web_stack_returns_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""get_web_stack returns CF_WEB_STACK env var."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"compose-farm": "nas"},
)
assert config.get_web_stack() == "compose-farm"
def test_get_web_stack_returns_empty_when_not_set(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_web_stack returns empty string when env var not set."""
monkeypatch.delenv("CF_WEB_STACK", raising=False)
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"compose-farm": "nas"},
)
assert config.get_web_stack() == ""
def test_get_local_host_from_web_stack_returns_host(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_local_host_from_web_stack returns the web stack host in container."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6"), "nuc": Host(address="192.168.1.2")},
stacks={"compose-farm": "nas"},
)
assert config.get_local_host_from_web_stack() == "nas"
def test_get_local_host_from_web_stack_returns_none_outside_container(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_local_host_from_web_stack returns None when not in container."""
monkeypatch.delenv("CF_WEB_STACK", raising=False)
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"compose-farm": "nas"},
)
assert config.get_local_host_from_web_stack() is None
def test_get_local_host_from_web_stack_returns_none_for_unknown_stack(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_local_host_from_web_stack returns None if web stack not in stacks."""
monkeypatch.setenv("CF_WEB_STACK", "unknown-stack")
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={"plex": "nas"},
)
assert config.get_local_host_from_web_stack() is None
def test_get_local_host_from_web_stack_returns_none_for_multi_host(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_local_host_from_web_stack returns None if web stack runs on multiple hosts."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
config = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6"), "nuc": Host(address="192.168.1.2")},
stacks={"compose-farm": ["nas", "nuc"]},
)
assert config.get_local_host_from_web_stack() is None
class TestLoadConfig:
"""Tests for load_config function."""
def test_load_config_full_host_format(self, tmp_path: Path) -> None:
config_data = {
"compose_dir": "/opt/compose",
"hosts": {
"nas01": {"address": "192.168.1.10", "user": "docker", "port": 2222},
},
"stacks": {"plex": "nas01"},
}
config_file = tmp_path / "sdc.yaml"
config_file.write_text(yaml.dump(config_data))
config = load_config(config_file)
assert config.hosts["nas01"].address == "192.168.1.10"
assert config.hosts["nas01"].user == "docker"
assert config.hosts["nas01"].port == 2222
def test_load_config_simple_host_format(self, tmp_path: Path) -> None:
config_data = {
"compose_dir": "/opt/compose",
"hosts": {"nas01": "192.168.1.10"},
"stacks": {"plex": "nas01"},
}
config_file = tmp_path / "sdc.yaml"
config_file.write_text(yaml.dump(config_data))
config = load_config(config_file)
assert config.hosts["nas01"].address == "192.168.1.10"
def test_load_config_mixed_host_formats(self, tmp_path: Path) -> None:
config_data = {
"compose_dir": "/opt/compose",
"hosts": {
"nas01": {"address": "192.168.1.10", "user": "docker"},
"nas02": "192.168.1.11",
},
"stacks": {"plex": "nas01", "jellyfin": "nas02"},
}
config_file = tmp_path / "sdc.yaml"
config_file.write_text(yaml.dump(config_data))
config = load_config(config_file)
assert config.hosts["nas01"].user == "docker"
assert config.hosts["nas02"].address == "192.168.1.11"
def test_load_config_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CF_CONFIG", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "empty_config"))
with pytest.raises(FileNotFoundError, match="Config file not found"):
load_config()
def test_load_config_local_host(self, tmp_path: Path) -> None:
config_data = {
"compose_dir": "/opt/compose",
"hosts": {"local": "localhost"},
"stacks": {"test": "local"},
}
config_file = tmp_path / "sdc.yaml"
config_file.write_text(yaml.dump(config_data))
config = load_config(config_file)
assert config.hosts["local"].address == "localhost"