Fix Glances connectivity when web UI runs in Docker container (#135)

This commit is contained in:
Bas Nijholt
2025-12-30 05:35:24 +01:00
committed by GitHub
parent beb1630fcf
commit ffb7a32402
15 changed files with 553 additions and 38 deletions

View File

@@ -12,7 +12,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] os: [ubuntu-latest, macos-latest]
python-version: ["3.11", "3.12", "3.13"] python-version: ["3.11", "3.12", "3.13"]
steps: steps:

View File

@@ -144,6 +144,6 @@ CLI available as `cf` or `compose-farm`.
| `check` | Validate config, traefik labels, mounts, networks; show host compatibility | | `check` | Validate config, traefik labels, mounts, networks; show host compatibility |
| `init-network` | Create Docker network on hosts with consistent subnet/gateway | | `init-network` | Create Docker network on hosts with consistent subnet/gateway |
| `traefik-file` | Generate Traefik file-provider config from compose labels | | `traefik-file` | Generate Traefik file-provider config from compose labels |
| `config` | Manage config files (init, show, path, validate, edit, symlink) | | `config` | Manage config files (init, init-env, show, path, validate, edit, symlink) |
| `ssh` | Manage SSH keys (setup, status, keygen) | | `ssh` | Manage SSH keys (setup, status, keygen) |
| `web` | Start web UI server | | `web` | Start web UI server |

View File

@@ -1003,6 +1003,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ validate Validate the config file syntax and schema. │ │ validate Validate the config file syntax and schema. │
│ symlink Create a symlink from the default config location to a config │ │ symlink Create a symlink from the default config location to a config │
│ file. │ │ file. │
│ init-env Generate a .env file for Docker deployment. │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
``` ```
@@ -1363,6 +1364,14 @@ glances_stack: glances # Enables resource stats in web UI
3. Deploy: `cf up glances` 3. Deploy: `cf up glances`
4. **(Docker web UI only)** If running the web UI in a Docker container, set `CF_LOCAL_HOST` to your local hostname in `.env`:
```bash
echo "CF_LOCAL_HOST=nas" >> .env # Replace 'nas' with your local host name
```
This tells the web UI to reach the local Glances via container name instead of IP (required due to Docker network isolation).
The web UI dashboard will now show a "Host Resources" section with live stats from all hosts. Hosts where Glances is unreachable show an error indicator. The web UI dashboard will now show a "Host Resources" section with live stats from all hosts. Hosts where Glances is unreachable show an error indicator.
**Live Stats Page** **Live Stats Page**

View File

@@ -47,6 +47,8 @@ services:
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml - CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
# Used to detect self-updates and run via SSH to survive container restart # Used to detect self-updates and run via SSH to survive container restart
- CF_WEB_STACK=compose-farm - CF_WEB_STACK=compose-farm
# Local host for Glances (use container name instead of IP to avoid Docker network issues)
- CF_LOCAL_HOST=${CF_LOCAL_HOST:-}
# HOME must match the user running the container for SSH to find keys # HOME must match the user running the container for SSH to find keys
- HOME=${CF_HOME:-/root} - HOME=${CF_HOME:-/root}
# USER is required for SSH when running as non-root (UID not in /etc/passwd) # USER is required for SSH when running as non-root (UID not in /etc/passwd)

116
docs/docker-deployment.md Normal file
View File

@@ -0,0 +1,116 @@
---
icon: lucide/container
---
# Docker Deployment
Run the Compose Farm web UI in Docker.
## Quick Start
**1. Get the compose file:**
```bash
curl -O https://raw.githubusercontent.com/basnijholt/compose-farm/main/docker-compose.yml
```
**2. Generate `.env` file:**
```bash
cf config init-env -o .env
```
This auto-detects settings from your `compose-farm.yaml`:
- `DOMAIN` from existing traefik labels
- `CF_COMPOSE_DIR` from config
- `CF_UID/GID/HOME/USER` from current user
- `CF_LOCAL_HOST` by matching local IPs to config hosts
Review the output and edit if needed.
**3. Set up SSH keys:**
```bash
docker compose run --rm cf ssh setup
```
**4. Start the web UI:**
```bash
docker compose up -d web
```
Open `http://localhost:9000` (or `https://compose-farm.example.com` if using Traefik).
---
## Configuration
The `cf config init-env` command auto-detects most settings. After running it, review the generated `.env` file and edit if needed:
```bash
$EDITOR .env
```
### What init-env detects
| Variable | How it's detected |
|----------|-------------------|
| `DOMAIN` | Extracted from traefik labels in your stacks |
| `CF_COMPOSE_DIR` | From `compose_dir` in your config |
| `CF_UID/GID/HOME/USER` | From current user (for NFS compatibility) |
| `CF_LOCAL_HOST` | By matching local IPs to configured hosts |
If auto-detection fails for any value, edit the `.env` file manually.
### Glances Monitoring
To show host CPU/memory stats in the dashboard, deploy [Glances](https://nicolargo.github.io/glances/) on your hosts. If `CF_LOCAL_HOST` wasn't detected correctly, set it to your local hostname:
```bash
CF_LOCAL_HOST=nas # Replace with your local host name
```
See [Host Resource Monitoring](https://github.com/basnijholt/compose-farm#host-resource-monitoring-glances) in the README.
---
## Troubleshooting
### SSH "Permission denied" or "Host key verification failed"
Regenerate keys:
```bash
docker compose run --rm cf ssh setup
```
### Glances shows error for local host
Add your local hostname to `.env`:
```bash
echo "CF_LOCAL_HOST=nas" >> .env
docker compose restart web
```
### Files created as root
Add the non-root variables above and restart.
---
## All Environment Variables
For advanced users, here's the complete reference:
| Variable | Description | Default |
|----------|-------------|---------|
| `DOMAIN` | Domain for Traefik labels | *(required)* |
| `CF_COMPOSE_DIR` | Compose files directory | `/opt/stacks` |
| `CF_UID` / `CF_GID` | User/group ID | `0` (root) |
| `CF_HOME` | Home directory | `/root` |
| `CF_USER` | Username for SSH | `root` |
| `CF_LOCAL_HOST` | Local hostname for Glances | *(auto-detect)* |
| `CF_SSH_DIR` | SSH keys directory | `~/.ssh/compose-farm` |
| `CF_XDG_CONFIG` | Config/backup directory | `~/.config/compose-farm` |

View File

@@ -46,6 +46,7 @@ dependencies = [
"asyncssh>=2.14.0", "asyncssh>=2.14.0",
"pyyaml>=6.0", "pyyaml>=6.0",
"rich>=13.0.0", "rich>=13.0.0",
"python-dotenv>=1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -142,6 +142,9 @@ def load_config_or_exit(config_path: Path | None) -> Config:
except FileNotFoundError as e: except FileNotFoundError as e:
print_error(str(e)) print_error(str(e))
raise typer.Exit(1) from e raise typer.Exit(1) from e
except Exception as e:
print_error(f"Invalid config: {e}")
raise typer.Exit(1) from e
def get_stacks( def get_stacks(

View File

@@ -9,7 +9,7 @@ import shutil
import subprocess import subprocess
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import TYPE_CHECKING, Annotated
import typer import typer
@@ -17,6 +17,9 @@ from compose_farm.cli.app import app
from compose_farm.console import MSG_CONFIG_NOT_FOUND, console, print_error, print_success from compose_farm.console import MSG_CONFIG_NOT_FOUND, console, print_error, print_success
from compose_farm.paths import config_search_paths, default_config_path, find_config_path from compose_farm.paths import config_search_paths, default_config_path, find_config_path
if TYPE_CHECKING:
from compose_farm.config import Config
config_app = typer.Typer( config_app = typer.Typer(
name="config", name="config",
help="Manage compose-farm configuration files.", help="Manage compose-farm configuration files.",
@@ -68,6 +71,22 @@ def _get_config_file(path: Path | None) -> Path | None:
return config_path.resolve() if config_path else None return config_path.resolve() if config_path else None
def _load_config_with_path(path: Path | None) -> tuple[Path, Config]:
"""Load config and return both the resolved path and Config object.
Exits with error if config not found or invalid.
"""
from compose_farm.cli.common import load_config_or_exit # noqa: PLC0415
config_file = _get_config_file(path)
if config_file is None:
print_error(MSG_CONFIG_NOT_FOUND)
raise typer.Exit(1)
cfg = load_config_or_exit(config_file)
return config_file, cfg
def _report_missing_config(explicit_path: Path | None = None) -> None: def _report_missing_config(explicit_path: Path | None = None) -> None:
"""Report that a config file was not found.""" """Report that a config file was not found."""
console.print("[yellow]Config file not found.[/yellow]") console.print("[yellow]Config file not found.[/yellow]")
@@ -207,23 +226,7 @@ def config_validate(
path: _PathOption = None, path: _PathOption = None,
) -> None: ) -> None:
"""Validate the config file syntax and schema.""" """Validate the config file syntax and schema."""
config_file = _get_config_file(path) config_file, cfg = _load_config_with_path(path)
if config_file is None:
print_error(MSG_CONFIG_NOT_FOUND)
raise typer.Exit(1)
# Lazy import: pydantic adds ~50ms to startup, only load when actually needed
from compose_farm.config import load_config # noqa: PLC0415
try:
cfg = load_config(config_file)
except FileNotFoundError as e:
print_error(str(e))
raise typer.Exit(1) from e
except Exception as e:
print_error(f"Invalid config: {e}")
raise typer.Exit(1) from e
print_success(f"Valid config: {config_file}") print_success(f"Valid config: {config_file}")
console.print(f" Hosts: {len(cfg.hosts)}") console.print(f" Hosts: {len(cfg.hosts)}")
@@ -293,5 +296,129 @@ def config_symlink(
console.print(f" -> {target_path}") console.print(f" -> {target_path}")
def _detect_domain(cfg: Config) -> str | None:
"""Try to detect DOMAIN from traefik Host() rules in existing stacks.
Uses extract_website_urls from traefik module to get interpolated
URLs, then extracts the domain from the first valid URL.
Skips local domains (.local, localhost, etc.).
"""
from urllib.parse import urlparse # noqa: PLC0415
from compose_farm.traefik import extract_website_urls # noqa: PLC0415
max_stacks_to_check = 10
min_domain_parts = 2
subdomain_parts = 4
skip_tlds = {"local", "localhost", "internal", "lan", "home"}
for stack_name in list(cfg.stacks.keys())[:max_stacks_to_check]:
urls = extract_website_urls(cfg, stack_name)
for url in urls:
host = urlparse(url).netloc
parts = host.split(".")
# Skip local/internal domains
if parts[-1].lower() in skip_tlds:
continue
if len(parts) >= subdomain_parts:
# e.g., "app.lab.nijho.lt" -> "lab.nijho.lt"
return ".".join(parts[-3:])
if len(parts) >= min_domain_parts:
# e.g., "app.example.com" -> "example.com"
return ".".join(parts[-2:])
return None
def _detect_local_host(cfg: Config) -> str | None:
"""Find which config host matches local machine's IPs."""
from compose_farm.executor import is_local # noqa: PLC0415
for name, host in cfg.hosts.items():
if is_local(host):
return name
return None
@config_app.command("init-env")
def config_init_env(
path: _PathOption = None,
output: Annotated[
Path | None,
typer.Option(
"--output", "-o", help="Output .env file path. Defaults to .env in config directory."
),
] = None,
force: _ForceOption = False,
) -> None:
"""Generate a .env file for Docker deployment.
Reads the compose-farm.yaml config and auto-detects settings:
- CF_COMPOSE_DIR from compose_dir
- CF_LOCAL_HOST by detecting which config host matches local IPs
- CF_UID/GID/HOME/USER from current user
- DOMAIN from traefik labels in stacks (if found)
Example::
cf config init-env # Create .env next to config
cf config init-env -o .env # Create .env in current directory
"""
config_file, cfg = _load_config_with_path(path)
# Determine output path
env_path = output.expanduser().resolve() if output else config_file.parent / ".env"
if env_path.exists() and not force:
console.print(f"[yellow].env file already exists:[/] {env_path}")
if not typer.confirm("Overwrite?"):
console.print("[dim]Aborted.[/dim]")
raise typer.Exit(0)
# Auto-detect values
uid = os.getuid()
gid = os.getgid()
home = os.environ.get("HOME", "/root")
user = os.environ.get("USER", "root")
compose_dir = str(cfg.compose_dir)
local_host = _detect_local_host(cfg)
domain = _detect_domain(cfg)
# Generate .env content
lines = [
"# Generated by: cf config init-env",
f"# From config: {config_file}",
"",
"# Domain for Traefik labels",
f"DOMAIN={domain or 'example.com'}",
"",
"# Compose files location",
f"CF_COMPOSE_DIR={compose_dir}",
"",
"# Run as current user (recommended for NFS)",
f"CF_UID={uid}",
f"CF_GID={gid}",
f"CF_HOME={home}",
f"CF_USER={user}",
"",
"# Local hostname for Glances integration",
f"CF_LOCAL_HOST={local_host or '# auto-detect failed - set manually'}",
"",
]
env_path.write_text("\n".join(lines), encoding="utf-8")
print_success(f"Created .env file: {env_path}")
console.print()
console.print("[dim]Detected settings:[/dim]")
console.print(f" DOMAIN: {domain or '[yellow]example.com[/] (edit this)'}")
console.print(f" CF_COMPOSE_DIR: {compose_dir}")
console.print(f" CF_UID/GID: {uid}:{gid}")
console.print(f" CF_LOCAL_HOST: {local_host or '[yellow]not detected[/] (set manually)'}")
console.print()
console.print("[dim]Review and edit as needed:[/dim]")
console.print(f" [cyan]$EDITOR {env_path}[/cyan]")
# Register config subcommand on the shared app # Register config subcommand on the shared app
app.add_typer(config_app, name="config", rich_help_panel="Configuration") app.add_typer(config_app, name="config", rich_help_panel="Configuration")

View File

@@ -56,7 +56,6 @@ from compose_farm.operations import (
check_stack_requirements, check_stack_requirements,
) )
from compose_farm.state import get_orphaned_stacks, load_state, save_state from compose_farm.state import get_orphaned_stacks, load_state, save_state
from compose_farm.traefik import generate_traefik_config, render_traefik_config
# --- Sync helpers --- # --- Sync helpers ---
@@ -328,6 +327,8 @@ def _report_orphaned_stacks(cfg: Config) -> bool:
def _report_traefik_status(cfg: Config, stacks: list[str]) -> None: def _report_traefik_status(cfg: Config, stacks: list[str]) -> None:
"""Check and report traefik label status.""" """Check and report traefik label status."""
from compose_farm.traefik import generate_traefik_config # noqa: PLC0415
try: try:
_, warnings = generate_traefik_config(cfg, stacks, check_all=True) _, warnings = generate_traefik_config(cfg, stacks, check_all=True)
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError):
@@ -447,6 +448,11 @@ def traefik_file(
config: ConfigOption = None, config: ConfigOption = None,
) -> None: ) -> None:
"""Generate a Traefik file-provider fragment from compose Traefik labels.""" """Generate a Traefik file-provider fragment from compose Traefik labels."""
from compose_farm.traefik import ( # noqa: PLC0415
generate_traefik_config,
render_traefik_config,
)
stack_list, cfg = get_stacks(stacks or [], all_stacks, config) stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
try: try:
dynamic, warnings = generate_traefik_config(cfg, stack_list) dynamic, warnings = generate_traefik_config(cfg, stack_list)

View File

@@ -13,6 +13,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import yaml import yaml
from dotenv import dotenv_values
if TYPE_CHECKING: if TYPE_CHECKING:
from .config import Config from .config import Config
@@ -40,21 +41,8 @@ def _load_env(compose_path: Path) -> dict[str, str]:
Reads from .env file in the same directory as compose file, Reads from .env file in the same directory as compose file,
then overlays current environment variables. then overlays current environment variables.
""" """
env: dict[str, str] = {}
env_path = compose_path.parent / ".env" env_path = compose_path.parent / ".env"
if env_path.exists(): env: dict[str, str] = {k: v for k, v in dotenv_values(env_path).items() if v is not None}
for line in env_path.read_text().splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
env[key] = value
env.update({k: v for k, v in os.environ.items() if isinstance(v, str)}) env.update({k: v for k, v in os.environ.items() if isinstance(v, str)})
return env return env

View File

@@ -3,16 +3,48 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from .executor import is_local
if TYPE_CHECKING: if TYPE_CHECKING:
from .config import Config from .config import Config, Host
# Default Glances REST API port # Default Glances REST API port
DEFAULT_GLANCES_PORT = 61208 DEFAULT_GLANCES_PORT = 61208
def _get_glances_address(
host_name: str,
host: Host,
glances_container: str | None,
) -> str:
"""Get the address to use for Glances API requests.
When running in a Docker container (CF_WEB_STACK set), the local host's Glances
may not be reachable via its LAN IP due to Docker network isolation. In this case,
we use the Glances container name for the local host.
Set CF_LOCAL_HOST=<hostname> to explicitly specify which host is local.
"""
# Only use container name when running inside a Docker container
in_container = os.environ.get("CF_WEB_STACK") is not None
if not in_container or not glances_container:
return host.address
# CF_LOCAL_HOST explicitly tells us which host to reach via container name
explicit_local = os.environ.get("CF_LOCAL_HOST")
if explicit_local and host_name == explicit_local:
return glances_container
# Fall back to is_local detection (may not work in container)
if is_local(host):
return glances_container
return host.address
@dataclass @dataclass
class HostStats: class HostStats:
"""Resource statistics for a host.""" """Resource statistics for a host."""
@@ -112,7 +144,11 @@ async def fetch_all_host_stats(
port: int = DEFAULT_GLANCES_PORT, port: int = DEFAULT_GLANCES_PORT,
) -> dict[str, HostStats]: ) -> dict[str, HostStats]:
"""Fetch stats from all hosts in parallel.""" """Fetch stats from all hosts in parallel."""
tasks = [fetch_host_stats(name, host.address, port) for name, host in config.hosts.items()] glances_container = config.glances_stack
tasks = [
fetch_host_stats(name, _get_glances_address(name, host, glances_container), port)
for name, host in config.hosts.items()
]
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
return {stats.host: stats for stats in results} return {stats.host: stats for stats in results}
@@ -212,6 +248,8 @@ async def fetch_all_container_stats(
"""Fetch container stats from all hosts in parallel, enriched with compose labels.""" """Fetch container stats from all hosts in parallel, enriched with compose labels."""
from .executor import get_container_compose_labels # noqa: PLC0415 from .executor import get_container_compose_labels # noqa: PLC0415
glances_container = config.glances_stack
async def fetch_host_data( async def fetch_host_data(
host_name: str, host_name: str,
host_address: str, host_address: str,
@@ -230,7 +268,10 @@ async def fetch_all_container_stats(
c.stack, c.service = labels.get(c.name, ("", "")) c.stack, c.service = labels.get(c.name, ("", ""))
return containers return containers
tasks = [fetch_host_data(name, host.address) for name, host in config.hosts.items()] tasks = [
fetch_host_data(name, _get_glances_address(name, host, glances_container))
for name, host in config.hosts.items()
]
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
# Flatten list of lists # Flatten list of lists
return [container for host_containers in results for container in host_containers] return [container for host_containers in results for container in host_containers]

View File

@@ -9,10 +9,13 @@ from typer.testing import CliRunner
from compose_farm.cli import app from compose_farm.cli import app
from compose_farm.cli.config import ( from compose_farm.cli.config import (
_detect_domain,
_detect_local_host,
_generate_template, _generate_template,
_get_config_file, _get_config_file,
_get_editor, _get_editor,
) )
from compose_farm.config import Config, Host
@pytest.fixture @pytest.fixture
@@ -228,3 +231,159 @@ class TestConfigValidate:
# Error goes to stderr # Error goes to stderr
output = result.stdout + (result.stderr or "") output = result.stdout + (result.stderr or "")
assert "Config file not found" in output or "not found" in output.lower() assert "Config file not found" in output or "not found" in output.lower()
class TestDetectLocalHost:
"""Tests for _detect_local_host function."""
def test_detects_localhost(self) -> None:
cfg = Config(
compose_dir=Path("/opt/compose"),
hosts={
"local": Host(address="localhost"),
"remote": Host(address="192.168.1.100"),
},
stacks={"test": "local"},
)
result = _detect_local_host(cfg)
assert result == "local"
def test_returns_none_for_remote_only(self) -> None:
cfg = Config(
compose_dir=Path("/opt/compose"),
hosts={
"remote1": Host(address="192.168.1.100"),
"remote2": Host(address="192.168.1.200"),
},
stacks={"test": "remote1"},
)
result = _detect_local_host(cfg)
# Remote IPs won't match local machine
assert result is None or result in cfg.hosts
class TestDetectDomain:
"""Tests for _detect_domain function."""
def test_returns_none_for_empty_stacks(self) -> None:
cfg = Config(
compose_dir=Path("/opt/compose"),
hosts={"nas": Host(address="192.168.1.6")},
stacks={},
)
result = _detect_domain(cfg)
assert result is None
def test_skips_local_domains(self, tmp_path: Path) -> None:
# Create a minimal compose file with .local domain
stack_dir = tmp_path / "test"
stack_dir.mkdir()
compose = stack_dir / "compose.yaml"
compose.write_text(
"""
name: test
services:
web:
image: nginx
labels:
- "traefik.http.routers.test-local.rule=Host(`test.local`)"
"""
)
cfg = Config(
compose_dir=tmp_path,
hosts={"nas": Host(address="192.168.1.6")},
stacks={"test": "nas"},
)
result = _detect_domain(cfg)
# .local should be skipped
assert result is None
class TestConfigInitEnv:
"""Tests for cf config init-env command."""
def test_init_env_creates_file(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
config_file.write_text(yaml.dump(valid_config_data))
env_file = tmp_path / ".env"
result = runner.invoke(
app, ["config", "init-env", "-p", str(config_file), "-o", str(env_file)]
)
assert result.exit_code == 0
assert env_file.exists()
content = env_file.read_text()
assert "CF_COMPOSE_DIR=/opt/compose" in content
assert "CF_UID=" in content
assert "CF_GID=" in content
def test_init_env_force_overwrites(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
config_file.write_text(yaml.dump(valid_config_data))
env_file = tmp_path / ".env"
env_file.write_text("OLD_CONTENT=true")
result = runner.invoke(
app, ["config", "init-env", "-p", str(config_file), "-o", str(env_file), "-f"]
)
assert result.exit_code == 0
content = env_file.read_text()
assert "OLD_CONTENT" not in content
assert "CF_COMPOSE_DIR" in content
def test_init_env_prompts_on_existing(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
config_file.write_text(yaml.dump(valid_config_data))
env_file = tmp_path / ".env"
env_file.write_text("KEEP_THIS=true")
result = runner.invoke(
app,
["config", "init-env", "-p", str(config_file), "-o", str(env_file)],
input="n\n",
)
assert result.exit_code == 0
assert "Aborted" in result.stdout
assert env_file.read_text() == "KEEP_THIS=true"
def test_init_env_defaults_to_config_dir(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
config_file.write_text(yaml.dump(valid_config_data))
result = runner.invoke(app, ["config", "init-env", "-p", str(config_file)])
assert result.exit_code == 0
# Should create .env in same directory as config
env_file = tmp_path / ".env"
assert env_file.exists()

View File

@@ -11,6 +11,7 @@ from compose_farm.glances import (
DEFAULT_GLANCES_PORT, DEFAULT_GLANCES_PORT,
ContainerStats, ContainerStats,
HostStats, HostStats,
_get_glances_address,
fetch_all_container_stats, fetch_all_container_stats,
fetch_all_host_stats, fetch_all_host_stats,
fetch_container_stats, fetch_container_stats,
@@ -347,3 +348,62 @@ class TestFetchAllContainerStats:
hosts = {c.host for c in containers} hosts = {c.host for c in containers}
assert "nas" in hosts assert "nas" in hosts
assert "nuc" 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)
monkeypatch.delenv("CF_LOCAL_HOST", 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")
monkeypatch.delenv("CF_LOCAL_HOST", raising=False)
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_explicit_local_host(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""CF_LOCAL_HOST explicitly marks which host uses container name."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
monkeypatch.setenv("CF_LOCAL_HOST", "nas")
host = Host(address="192.168.1.6")
result = _get_glances_address("nas", host, "glances")
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")
monkeypatch.setenv("CF_LOCAL_HOST", "nas")
host = Host(address="192.168.1.2")
result = _get_glances_address("nuc", host, "glances")
assert result == "192.168.1.2"
def test_fallback_to_is_local_detection(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without CF_LOCAL_HOST, falls back to is_local detection."""
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
monkeypatch.delenv("CF_LOCAL_HOST", raising=False)
# 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")
monkeypatch.delenv("CF_LOCAL_HOST", raising=False)
host = Host(address="192.168.1.100")
result = _get_glances_address("remote", host, "glances")
assert result == "192.168.1.100"

2
uv.lock generated
View File

@@ -234,6 +234,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncssh" }, { name = "asyncssh" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
@@ -274,6 +275,7 @@ requires-dist = [
{ name = "humanize", marker = "extra == 'web'", specifier = ">=4.0.0" }, { name = "humanize", marker = "extra == 'web'", specifier = ">=4.0.0" },
{ name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.0" }, { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.0" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "rich", specifier = ">=13.0.0" }, { name = "rich", specifier = ">=13.0.0" },
{ name = "typer", specifier = ">=0.9.0" }, { name = "typer", specifier = ">=0.9.0" },

View File

@@ -16,6 +16,7 @@ extra_javascript = ["javascripts/video-fix.js"]
nav = [ nav = [
{ "Home" = "index.md" }, { "Home" = "index.md" },
{ "Getting Started" = "getting-started.md" }, { "Getting Started" = "getting-started.md" },
{ "Docker Deployment" = "docker-deployment.md" },
{ "Configuration" = "configuration.md" }, { "Configuration" = "configuration.md" },
{ "Commands" = "commands.md" }, { "Commands" = "commands.md" },
{ "Web UI" = "web-ui.md" }, { "Web UI" = "web-ui.md" },