mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
Fix Glances connectivity when web UI runs in Docker container (#135)
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
|
||||
@@ -144,6 +144,6 @@ CLI available as `cf` or `compose-farm`.
|
||||
| `check` | Validate config, traefik labels, mounts, networks; show host compatibility |
|
||||
| `init-network` | Create Docker network on hosts with consistent subnet/gateway |
|
||||
| `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) |
|
||||
| `web` | Start web UI server |
|
||||
|
||||
@@ -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. │
|
||||
│ symlink Create a symlink from the default config location to a config │
|
||||
│ 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`
|
||||
|
||||
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.
|
||||
|
||||
**Live Stats Page**
|
||||
|
||||
@@ -47,6 +47,8 @@ services:
|
||||
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
|
||||
# Used to detect self-updates and run via SSH to survive container restart
|
||||
- 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=${CF_HOME:-/root}
|
||||
# USER is required for SSH when running as non-root (UID not in /etc/passwd)
|
||||
|
||||
116
docs/docker-deployment.md
Normal file
116
docs/docker-deployment.md
Normal 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` |
|
||||
@@ -46,6 +46,7 @@ dependencies = [
|
||||
"asyncssh>=2.14.0",
|
||||
"pyyaml>=6.0",
|
||||
"rich>=13.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -142,6 +142,9 @@ def load_config_or_exit(config_path: Path | None) -> Config:
|
||||
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
|
||||
|
||||
|
||||
def get_stacks(
|
||||
|
||||
@@ -9,7 +9,7 @@ import shutil
|
||||
import subprocess
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
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.paths import config_search_paths, default_config_path, find_config_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
|
||||
config_app = typer.Typer(
|
||||
name="config",
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Report that a config file was not found."""
|
||||
console.print("[yellow]Config file not found.[/yellow]")
|
||||
@@ -207,23 +226,7 @@ def config_validate(
|
||||
path: _PathOption = None,
|
||||
) -> None:
|
||||
"""Validate the config file syntax and schema."""
|
||||
config_file = _get_config_file(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
|
||||
config_file, cfg = _load_config_with_path(path)
|
||||
|
||||
print_success(f"Valid config: {config_file}")
|
||||
console.print(f" Hosts: {len(cfg.hosts)}")
|
||||
@@ -293,5 +296,129 @@ def config_symlink(
|
||||
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
|
||||
app.add_typer(config_app, name="config", rich_help_panel="Configuration")
|
||||
|
||||
@@ -56,7 +56,6 @@ from compose_farm.operations import (
|
||||
check_stack_requirements,
|
||||
)
|
||||
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 ---
|
||||
|
||||
@@ -328,6 +327,8 @@ def _report_orphaned_stacks(cfg: Config) -> bool:
|
||||
|
||||
def _report_traefik_status(cfg: Config, stacks: list[str]) -> None:
|
||||
"""Check and report traefik label status."""
|
||||
from compose_farm.traefik import generate_traefik_config # noqa: PLC0415
|
||||
|
||||
try:
|
||||
_, warnings = generate_traefik_config(cfg, stacks, check_all=True)
|
||||
except (FileNotFoundError, ValueError):
|
||||
@@ -447,6 +448,11 @@ def traefik_file(
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""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)
|
||||
try:
|
||||
dynamic, warnings = generate_traefik_config(cfg, stack_list)
|
||||
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
from dotenv import dotenv_values
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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,
|
||||
then overlays current environment variables.
|
||||
"""
|
||||
env: dict[str, str] = {}
|
||||
env_path = compose_path.parent / ".env"
|
||||
if env_path.exists():
|
||||
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: dict[str, str] = {k: v for k, v in dotenv_values(env_path).items() if v is not None}
|
||||
env.update({k: v for k, v in os.environ.items() if isinstance(v, str)})
|
||||
return env
|
||||
|
||||
|
||||
@@ -3,16 +3,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .executor import is_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
from .config import Config, Host
|
||||
|
||||
# Default Glances REST API port
|
||||
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
|
||||
class HostStats:
|
||||
"""Resource statistics for a host."""
|
||||
@@ -112,7 +144,11 @@ async def fetch_all_host_stats(
|
||||
port: int = DEFAULT_GLANCES_PORT,
|
||||
) -> dict[str, HostStats]:
|
||||
"""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)
|
||||
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."""
|
||||
from .executor import get_container_compose_labels # noqa: PLC0415
|
||||
|
||||
glances_container = config.glances_stack
|
||||
|
||||
async def fetch_host_data(
|
||||
host_name: str,
|
||||
host_address: str,
|
||||
@@ -230,7 +268,10 @@ async def fetch_all_container_stats(
|
||||
c.stack, c.service = labels.get(c.name, ("", ""))
|
||||
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)
|
||||
# Flatten list of lists
|
||||
return [container for host_containers in results for container in host_containers]
|
||||
|
||||
@@ -9,10 +9,13 @@ from typer.testing import CliRunner
|
||||
|
||||
from compose_farm.cli import app
|
||||
from compose_farm.cli.config import (
|
||||
_detect_domain,
|
||||
_detect_local_host,
|
||||
_generate_template,
|
||||
_get_config_file,
|
||||
_get_editor,
|
||||
)
|
||||
from compose_farm.config import Config, Host
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -228,3 +231,159 @@ class TestConfigValidate:
|
||||
# Error goes to stderr
|
||||
output = result.stdout + (result.stderr or "")
|
||||
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()
|
||||
|
||||
@@ -11,6 +11,7 @@ from compose_farm.glances import (
|
||||
DEFAULT_GLANCES_PORT,
|
||||
ContainerStats,
|
||||
HostStats,
|
||||
_get_glances_address,
|
||||
fetch_all_container_stats,
|
||||
fetch_all_host_stats,
|
||||
fetch_container_stats,
|
||||
@@ -347,3 +348,62 @@ class TestFetchAllContainerStats:
|
||||
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)
|
||||
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
2
uv.lock
generated
@@ -234,6 +234,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncssh" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
@@ -274,6 +275,7 @@ requires-dist = [
|
||||
{ name = "humanize", marker = "extra == 'web'", specifier = ">=4.0.0" },
|
||||
{ name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "rich", specifier = ">=13.0.0" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
|
||||
@@ -16,6 +16,7 @@ extra_javascript = ["javascripts/video-fix.js"]
|
||||
nav = [
|
||||
{ "Home" = "index.md" },
|
||||
{ "Getting Started" = "getting-started.md" },
|
||||
{ "Docker Deployment" = "docker-deployment.md" },
|
||||
{ "Configuration" = "configuration.md" },
|
||||
{ "Commands" = "commands.md" },
|
||||
{ "Web UI" = "web-ui.md" },
|
||||
|
||||
Reference in New Issue
Block a user