Files
compose-farm/tests/test_glances.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

404 lines
14 KiB
Python

"""Tests for Glances integration."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from compose_farm.config import Config, Host
from compose_farm.glances import (
DEFAULT_GLANCES_PORT,
ContainerStats,
HostStats,
_get_glances_address,
fetch_all_container_stats,
fetch_all_host_stats,
fetch_container_stats,
fetch_host_stats,
)
class TestHostStats:
"""Tests for HostStats dataclass."""
def test_host_stats_creation(self) -> None:
stats = HostStats(
host="nas",
cpu_percent=25.5,
mem_percent=50.0,
swap_percent=10.0,
load=2.5,
disk_percent=75.0,
)
assert stats.host == "nas"
assert stats.cpu_percent == 25.5
assert stats.mem_percent == 50.0
assert stats.disk_percent == 75.0
assert stats.error is None
def test_host_stats_from_error(self) -> None:
stats = HostStats.from_error("nas", "Connection refused")
assert stats.host == "nas"
assert stats.cpu_percent == 0
assert stats.mem_percent == 0
assert stats.error == "Connection refused"
class TestFetchHostStats:
"""Tests for fetch_host_stats function."""
@pytest.mark.asyncio
async def test_fetch_host_stats_success(self) -> None:
quicklook_response = httpx.Response(
200,
json={
"cpu": 25.5,
"mem": 50.0,
"swap": 5.0,
"load": 2.5,
},
)
fs_response = httpx.Response(
200,
json=[
{"mnt_point": "/", "percent": 65.0},
{"mnt_point": "/mnt/data", "percent": 80.0},
],
)
async def mock_get(url: str) -> httpx.Response:
if "quicklook" in url:
return quicklook_response
return fs_response
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=mock_get)
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.cpu_percent == 25.5
assert stats.mem_percent == 50.0
assert stats.swap_percent == 5.0
assert stats.load == 2.5
assert stats.disk_percent == 65.0 # Root filesystem
assert stats.error is None
@pytest.mark.asyncio
async def test_fetch_host_stats_http_error(self) -> None:
mock_response = httpx.Response(500)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.error == "HTTP 500"
assert stats.cpu_percent == 0
@pytest.mark.asyncio
async def test_fetch_host_stats_timeout(self) -> None:
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.error == "timeout"
@pytest.mark.asyncio
async def test_fetch_host_stats_connection_error(self) -> None:
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
stats = await fetch_host_stats("nas", "192.168.1.6")
assert stats.host == "nas"
assert stats.error is not None
assert "Connection refused" in stats.error
class TestFetchAllHostStats:
"""Tests for fetch_all_host_stats function."""
@pytest.mark.asyncio
async def test_fetch_all_host_stats(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={
"nas": Host(address="192.168.1.6"),
"nuc": Host(address="192.168.1.2"),
},
stacks={"test": "nas"},
)
quicklook_response = httpx.Response(
200,
json={
"cpu": 25.5,
"mem": 50.0,
"swap": 5.0,
"load": 2.5,
},
)
fs_response = httpx.Response(
200,
json=[{"mnt_point": "/", "percent": 70.0}],
)
async def mock_get(url: str) -> httpx.Response:
if "quicklook" in url:
return quicklook_response
return fs_response
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=mock_get)
stats = await fetch_all_host_stats(config)
assert "nas" in stats
assert "nuc" in stats
assert stats["nas"].cpu_percent == 25.5
assert stats["nuc"].cpu_percent == 25.5
assert stats["nas"].disk_percent == 70.0
class TestDefaultPort:
"""Tests for default Glances port constant."""
def test_default_port(self) -> None:
assert DEFAULT_GLANCES_PORT == 61208
class TestContainerStats:
"""Tests for ContainerStats dataclass."""
def test_container_stats_creation(self) -> None:
stats = ContainerStats(
name="nginx",
host="nas",
status="running",
image="nginx:latest",
cpu_percent=5.5,
memory_usage=104857600, # 100MB
memory_limit=1073741824, # 1GB
memory_percent=9.77,
network_rx=1000000,
network_tx=500000,
uptime="2 hours",
ports="80->80/tcp",
engine="docker",
)
assert stats.name == "nginx"
assert stats.host == "nas"
assert stats.cpu_percent == 5.5
class TestFetchContainerStats:
"""Tests for fetch_container_stats function."""
@pytest.mark.asyncio
async def test_fetch_container_stats_success(self) -> None:
mock_response = httpx.Response(
200,
json=[
{
"name": "nginx",
"status": "running",
"image": ["nginx:latest"],
"cpu_percent": 5.5,
"memory_usage": 104857600,
"memory_limit": 1073741824,
"network": {"cumulative_rx": 1000, "cumulative_tx": 500},
"uptime": "2 hours",
"ports": "80->80/tcp",
"engine": "docker",
},
{
"name": "redis",
"status": "running",
"image": ["redis:7"],
"cpu_percent": 1.2,
"memory_usage": 52428800,
"memory_limit": 1073741824,
"network": {},
"uptime": "3 hours",
"ports": "",
"engine": "docker",
},
],
)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
containers, error = await fetch_container_stats("nas", "192.168.1.6")
assert error is None
assert containers is not None
assert len(containers) == 2
assert containers[0].name == "nginx"
assert containers[0].host == "nas"
assert containers[0].cpu_percent == 5.5
assert containers[1].name == "redis"
@pytest.mark.asyncio
async def test_fetch_container_stats_empty_on_error(self) -> None:
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
containers, error = await fetch_container_stats("nas", "192.168.1.6")
assert containers is None
assert error == "Connection timed out"
@pytest.mark.asyncio
async def test_fetch_container_stats_handles_string_image(self) -> None:
"""Test that image field works as string (not just list)."""
mock_response = httpx.Response(
200,
json=[
{
"name": "test",
"status": "running",
"image": "myimage:v1", # String instead of list
"cpu_percent": 0,
"memory_usage": 0,
"memory_limit": 1,
"network": {},
"uptime": "",
"ports": "",
"engine": "docker",
},
],
)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
containers, error = await fetch_container_stats("nas", "192.168.1.6")
assert error is None
assert containers is not None
assert len(containers) == 1
assert containers[0].image == "myimage:v1"
class TestFetchAllContainerStats:
"""Tests for fetch_all_container_stats function."""
@pytest.mark.asyncio
async def test_fetch_all_container_stats(self) -> None:
config = Config(
compose_dir=Path("/opt/compose"),
hosts={
"nas": Host(address="192.168.1.6"),
"nuc": Host(address="192.168.1.2"),
},
stacks={"test": "nas"},
)
mock_response = httpx.Response(
200,
json=[
{
"name": "nginx",
"status": "running",
"image": ["nginx:latest"],
"cpu_percent": 5.5,
"memory_usage": 104857600,
"memory_limit": 1073741824,
"network": {},
"uptime": "2 hours",
"ports": "",
"engine": "docker",
},
],
)
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_client.return_value)
mock_client.return_value.__aexit__ = AsyncMock(return_value=None)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
containers = await fetch_all_container_stats(config)
# 2 hosts x 1 container each = 2 containers
assert len(containers) == 2
hosts = {c.host for c in containers}
assert "nas" in hosts
assert "nuc" in hosts
class TestGetGlancesAddress:
"""Tests for _get_glances_address function."""
def test_returns_host_address_outside_container(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without CF_WEB_STACK, always return host address."""
monkeypatch.delenv("CF_WEB_STACK", raising=False)
host = Host(address="192.168.1.6")
result = _get_glances_address("nas", host, "glances")
assert result == "192.168.1.6"
def test_returns_host_address_without_glances_container(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""In container without glances_stack config, return host address."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
host = Host(address="192.168.1.6")
result = _get_glances_address("nas", host, None)
assert result == "192.168.1.6"
def test_returns_container_name_for_web_stack_host(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Local host uses container name in container mode."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
host = Host(address="192.168.1.6")
result = _get_glances_address("nas", host, "glances", local_host="nas")
assert result == "glances"
def test_returns_host_address_for_non_local_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Non-local hosts use their IP address even in container mode."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
host = Host(address="192.168.1.2")
result = _get_glances_address("nuc", host, "glances", local_host="nas")
assert result == "192.168.1.2"
def test_fallback_to_is_local_detection(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without explicit local host, falls back to is_local detection."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
# Use localhost which should be detected as local
host = Host(address="localhost")
result = _get_glances_address("local", host, "glances")
assert result == "glances"
def test_remote_host_not_affected_by_container_mode(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Remote hosts always use their IP, even in container mode."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
host = Host(address="192.168.1.100")
result = _get_glances_address("remote", host, "glances")
assert result == "192.168.1.100"