mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
* 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
404 lines
14 KiB
Python
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"
|