From ffb7a324025dd3d0ee1534a855de355fb3db315b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 30 Dec 2025 05:35:24 +0100 Subject: [PATCH] Fix Glances connectivity when web UI runs in Docker container (#135) --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- README.md | 9 ++ docker-compose.yml | 2 + docs/docker-deployment.md | 116 ++++++++++++++++++++ pyproject.toml | 1 + src/compose_farm/cli/common.py | 3 + src/compose_farm/cli/config.py | 163 +++++++++++++++++++++++++---- src/compose_farm/cli/management.py | 8 +- src/compose_farm/compose.py | 16 +-- src/compose_farm/glances.py | 47 ++++++++- tests/test_config_cmd.py | 159 ++++++++++++++++++++++++++++ tests/test_glances.py | 60 +++++++++++ uv.lock | 2 + zensical.toml | 1 + 15 files changed, 553 insertions(+), 38 deletions(-) create mode 100644 docs/docker-deployment.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 399e6fa..3c2dbc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 35df7d8..d220201 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/README.md b/README.md index 1bad13b..a6e0a8e 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/docker-compose.yml b/docker-compose.yml index 8eaaf59..bc4a3d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md new file mode 100644 index 0000000..839a5c3 --- /dev/null +++ b/docs/docker-deployment.md @@ -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` | diff --git a/pyproject.toml b/pyproject.toml index 39c4a08..36edd20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "asyncssh>=2.14.0", "pyyaml>=6.0", "rich>=13.0.0", + "python-dotenv>=1.0.0", ] [project.optional-dependencies] diff --git a/src/compose_farm/cli/common.py b/src/compose_farm/cli/common.py index 6d136fc..573937b 100644 --- a/src/compose_farm/cli/common.py +++ b/src/compose_farm/cli/common.py @@ -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( diff --git a/src/compose_farm/cli/config.py b/src/compose_farm/cli/config.py index 0580b2f..a8d3d99 100644 --- a/src/compose_farm/cli/config.py +++ b/src/compose_farm/cli/config.py @@ -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") diff --git a/src/compose_farm/cli/management.py b/src/compose_farm/cli/management.py index c9a6429..5f8976c 100644 --- a/src/compose_farm/cli/management.py +++ b/src/compose_farm/cli/management.py @@ -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) diff --git a/src/compose_farm/compose.py b/src/compose_farm/compose.py index a404667..e390b2d 100644 --- a/src/compose_farm/compose.py +++ b/src/compose_farm/compose.py @@ -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 diff --git a/src/compose_farm/glances.py b/src/compose_farm/glances.py index 5298481..ca7b741 100644 --- a/src/compose_farm/glances.py +++ b/src/compose_farm/glances.py @@ -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= 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] diff --git a/tests/test_config_cmd.py b/tests/test_config_cmd.py index 470959b..cac1f00 100644 --- a/tests/test_config_cmd.py +++ b/tests/test_config_cmd.py @@ -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() diff --git a/tests/test_glances.py b/tests/test_glances.py index cfc92c5..84565a1 100644 --- a/tests/test_glances.py +++ b/tests/test_glances.py @@ -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" diff --git a/uv.lock b/uv.lock index 52533d3..99795e2 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, diff --git a/zensical.toml b/zensical.toml index ebf7a8b..087e5dd 100644 --- a/zensical.toml +++ b/zensical.toml @@ -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" },