From 1e3b1d71ed799f2f23d2870c3d6876184cc0b1b3 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 10 Jan 2026 10:48:35 +0100 Subject: [PATCH] Drop CF_LOCAL_HOST; limit web-stack inference to containers (#163) * config: Add local_host and web_stack options Allow configuring local_host and web_stack in compose-farm.yaml instead of requiring environment variables. This makes it easier to deploy the web UI with just a config file mount. - local_host: specifies which host is "local" for Glances connectivity - web_stack: identifies the web UI stack for self-update detection Environment variables (CF_LOCAL_HOST, CF_WEB_STACK) still work as fallback for backwards compatibility. Closes #152 * docs: Clarify glances_stack is used by CLI and web UI * config: Env vars override config, add docs - Change precedence: environment variables now override config values (follows 12-factor app pattern) - Document all CF_* environment variables in configuration.md - Update example-config.yaml to mention env var overrides * config: Consolidate env vars, prefer config options - Update docker-compose.yml to comment out CF_WEB_STACK and CF_LOCAL_HOST (now prefer setting in compose-farm.yaml) - Update init-env to comment out CF_LOCAL_HOST (can be set in config) - Update docker-deployment.md with new "Config option" column - Simplify troubleshooting to prefer config over env vars * config: Generate CF_LOCAL_HOST with config alternative note Instead of commenting out CF_LOCAL_HOST, generate it normally but add a note in the comment that it can also be set as 'local_host' in config. * config: Extend local_host to all web UI operations When running the web UI in a Docker container, is_local() can't detect which host the container is on due to different network namespaces. Previously local_host/CF_LOCAL_HOST only affected Glances connectivity. Now it also affects: - Container exec/shell (runs locally instead of via SSH) - File editing (uses local filesystem instead of SSH) Added is_local_host() helper that checks CF_LOCAL_HOST/config.local_host first, then falls back to is_local() detection. * refactor: DRY get_web_stack helper, add tests - Move get_web_stack to deps.py to avoid duplication in streaming.py and actions.py - Add tests for config.local_host and config.web_stack parsing - Add tests for is_local_host, get_web_stack, and get_local_host helpers - Tests verify env var precedence over config values * glances: rely on CF_WEB_STACK for container mode Restore docker-compose env defaults and document local_host scope. * web: ignore local_host outside container Document container-only behavior and adjust tests. * web: infer local host from web_stack Drop local_host config option and update docs/tests. * Remove CF_LOCAL_HOST override * refactor: move web_stack helpers to Config class - Add get_web_stack() and get_local_host_from_web_stack() as Config methods - Remove duplicate _get_local_host_from_web_stack() from glances.py and deps.py - Update deps.py get_web_stack() to delegate to Config method - Add comprehensive tests for the new Config methods * config: remove web_stack config option The web_stack config option was redundant since: - In Docker, CF_WEB_STACK env var is always set - Outside Docker, the container-specific behavior is disabled anyway Simplify by only using the CF_WEB_STACK environment variable. * refactor: remove get_web_stack wrapper from deps Callers now use config.get_web_stack() directly instead of going through a pointless wrapper function. * prompts: add rule to identify pointless wrapper functions --- .prompts/pr-review.md | 1 + README.md | 8 +-- docker-compose.yml | 2 - docs/configuration.md | 21 ++++++- docs/docker-deployment.md | 19 +------ src/compose_farm/cli/config.py | 17 +----- src/compose_farm/config.py | 26 +++++++++ src/compose_farm/example-config.yaml | 10 ++++ src/compose_farm/glances.py | 30 +++++++--- src/compose_farm/web/deps.py | 31 ++++++++++- src/compose_farm/web/routes/actions.py | 7 +-- src/compose_farm/web/routes/api.py | 10 ++-- src/compose_farm/web/streaming.py | 12 ++-- src/compose_farm/web/ws.py | 8 +-- tests/test_config.py | 70 +++++++++++++++++++++++ tests/test_config_cmd.py | 30 ---------- tests/test_glances.py | 16 ++---- tests/web/test_helpers.py | 77 ++++++++++++++++++++++++++ 18 files changed, 281 insertions(+), 114 deletions(-) diff --git a/.prompts/pr-review.md b/.prompts/pr-review.md index b0614e1..5c8ef9c 100644 --- a/.prompts/pr-review.md +++ b/.prompts/pr-review.md @@ -6,6 +6,7 @@ Review the pull request for: - **Organization**: Is everything in the right place? - **Consistency**: Is it in the same style as other parts of the codebase? - **Simplicity**: Is it not over-engineered? Remember KISS and YAGNI. No dead code paths and NO defensive programming. +- **No pointless wrappers**: Identify functions/methods that just call another function and return its result. Callers should call the underlying function directly instead of going through unnecessary indirection. - **User experience**: Does it provide a good user experience? - **PR**: Is the PR description and title clear and informative? - **Tests**: Are there tests, and do they cover the changes adequately? Are they testing something meaningful or are they just trivial? diff --git a/README.md b/README.md index bef7f59..46ce00f 100644 --- a/README.md +++ b/README.md @@ -1429,13 +1429,7 @@ 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). +4. **(Docker web UI only)** The web UI container infers the local host from `CF_WEB_STACK` and reaches Glances via the container name to avoid Docker network isolation issues. 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. diff --git a/docker-compose.yml b/docker-compose.yml index bc4a3d7..8eaaf59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,8 +47,6 @@ 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/configuration.md b/docs/configuration.md index e6139ea..a7421c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,7 +123,7 @@ traefik_stack: traefik ### glances_stack -Stack name running [Glances](https://nicolargo.github.io/glances/) for host resource monitoring. When set, the web UI displays CPU, memory, and load stats for all hosts. +Stack name running [Glances](https://nicolargo.github.io/glances/) for host resource monitoring. When set, the CLI (`cf stats --containers`) and web UI display CPU, memory, and container stats for all hosts. ```yaml glances_stack: glances @@ -267,6 +267,25 @@ When generating Traefik config, Compose Farm resolves `${VAR}` and `${VAR:-defau 1. The stack's `.env` file 2. Current environment +### Compose Farm Environment Variables + +These environment variables configure Compose Farm itself: + +| Variable | Description | +|----------|-------------| +| `CF_CONFIG` | Path to config file | +| `CF_WEB_STACK` | Web UI stack name (Docker only, enables self-update detection and local host inference) | + +**Docker deployment variables** (used in docker-compose.yml): + +| Variable | Description | Generated by | +|----------|-------------|--------------| +| `CF_COMPOSE_DIR` | Compose files directory | `cf config init-env` | +| `CF_UID` / `CF_GID` | User/group ID for containers | `cf config init-env` | +| `CF_HOME` / `CF_USER` | Home directory and username | `cf config init-env` | +| `CF_SSH_DIR` | SSH keys volume mount | Manual | +| `CF_XDG_CONFIG` | Config backup volume mount | Manual | + ## Config Commands ### Initialize Config diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index eb638c3..62ddd8d 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -24,7 +24,6 @@ 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. @@ -59,17 +58,12 @@ $EDITOR .env | `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 -``` +To show host CPU/memory stats in the dashboard, deploy [Glances](https://nicolargo.github.io/glances/) on your hosts. When running the web UI container, Compose Farm infers the local host from `CF_WEB_STACK` and uses the Glances container name for that host. See [Host Resource Monitoring](https://github.com/basnijholt/compose-farm#host-resource-monitoring-glances) in the README. @@ -85,15 +79,6 @@ Regenerate keys: 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. @@ -111,6 +96,6 @@ For advanced users, here's the complete reference: | `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_WEB_STACK` | Web UI stack name (enables self-update, local host inference) | *(none)* | | `CF_SSH_DIR` | SSH keys directory | `~/.ssh/compose-farm` | | `CF_XDG_CONFIG` | Config/backup directory | `~/.config/compose-farm` | diff --git a/src/compose_farm/cli/config.py b/src/compose_farm/cli/config.py index 2dc7cc2..25cf3d2 100644 --- a/src/compose_farm/cli/config.py +++ b/src/compose_farm/cli/config.py @@ -326,16 +326,6 @@ def _detect_domain(cfg: Config) -> str | None: 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, @@ -350,8 +340,8 @@ def config_init_env( """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) @@ -378,7 +368,6 @@ def config_init_env( 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 @@ -398,9 +387,6 @@ def config_init_env( 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") @@ -411,7 +397,6 @@ def config_init_env( 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]") diff --git a/src/compose_farm/config.py b/src/compose_farm/config.py index 6715a83..f651a0b 100644 --- a/src/compose_farm/config.py +++ b/src/compose_farm/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import getpass +import os from pathlib import Path from typing import Any @@ -116,6 +117,31 @@ class Config(BaseModel, extra="forbid"): found.add(subdir.name) return found + def get_web_stack(self) -> str: + """Get web stack name from CF_WEB_STACK environment variable.""" + return os.environ.get("CF_WEB_STACK", "") + + def get_local_host_from_web_stack(self) -> str | None: + """Resolve the local host from the web stack configuration (container only). + + When running in the web UI container (CF_WEB_STACK is set), this returns + the host that the web stack runs on. This is used for: + - Glances connectivity (use container name instead of IP) + - Container exec (local docker exec vs SSH) + - File read/write (local filesystem vs SSH) + + Returns None if not in container mode or web stack is not configured. + """ + if os.environ.get("CF_WEB_STACK") is None: + return None + web_stack = self.get_web_stack() + if not web_stack or web_stack not in self.stacks: + return None + host_names = self.get_hosts(web_stack) + if len(host_names) != 1: + return None + return host_names[0] + def _parse_hosts(raw_hosts: dict[str, Any]) -> dict[str, Host]: """Parse hosts from config, handling both simple and full forms.""" diff --git a/src/compose_farm/example-config.yaml b/src/compose_farm/example-config.yaml index c074c54..76df997 100644 --- a/src/compose_farm/example-config.yaml +++ b/src/compose_farm/example-config.yaml @@ -87,3 +87,13 @@ stacks: # skipped (they're handled by Traefik's Docker provider directly). # # traefik_stack: traefik + +# ------------------------------------------------------------------------------ +# glances_stack: (optional) Stack/container name for Glances +# ------------------------------------------------------------------------------ +# When set, enables host resource monitoring via the Glances API. Used by: +# - CLI: `cf stats --containers` shows container stats from all hosts +# - Web UI: displays host resource graphs and container metrics +# This should be the container name that runs Glances on the same Docker network. +# +# glances_stack: glances diff --git a/src/compose_farm/glances.py b/src/compose_farm/glances.py index c2c2aa8..8ed6353 100644 --- a/src/compose_farm/glances.py +++ b/src/compose_farm/glances.py @@ -27,22 +27,20 @@ def _get_glances_address( host_name: str, host: Host, glances_container: str | None, + local_host: str | None = 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. + 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. """ - # Only use container name when running inside a Docker container + # CF_WEB_STACK indicates we're running in the web UI 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: + if local_host and host_name == local_host: return glances_container # Fall back to is_local detection (may not work in container) @@ -152,8 +150,13 @@ async def fetch_all_host_stats( ) -> dict[str, HostStats]: """Fetch stats from all hosts in parallel.""" glances_container = config.glances_stack + local_host = config.get_local_host_from_web_stack() tasks = [ - fetch_host_stats(name, _get_glances_address(name, host, glances_container), port) + fetch_host_stats( + name, + _get_glances_address(name, host, glances_container, local_host), + port, + ) for name, host in config.hosts.items() ] results = await asyncio.gather(*tasks) @@ -258,6 +261,7 @@ async def fetch_all_container_stats( glances_container = config.glances_stack host_names = hosts if hosts is not None else list(config.hosts.keys()) + local_host = config.get_local_host_from_web_stack() async def fetch_host_data( host_name: str, @@ -278,7 +282,15 @@ async def fetch_all_container_stats( return containers tasks = [ - fetch_host_data(name, _get_glances_address(name, config.hosts[name], glances_container)) + fetch_host_data( + name, + _get_glances_address( + name, + config.hosts[name], + glances_container, + local_host, + ), + ) for name in host_names if name in config.hosts ] diff --git a/src/compose_farm/web/deps.py b/src/compose_farm/web/deps.py index 9b01c47..efe050a 100644 --- a/src/compose_farm/web/deps.py +++ b/src/compose_farm/web/deps.py @@ -15,7 +15,7 @@ from pydantic import ValidationError from compose_farm.executor import is_local if TYPE_CHECKING: - from compose_farm.config import Config + from compose_farm.config import Config, Host # Paths WEB_DIR = Path(__file__).parent @@ -52,8 +52,35 @@ def extract_config_error(exc: Exception) -> str: return str(exc) +def is_local_host(host_name: str, host: Host, config: Config) -> bool: + """Check if a host should be treated as local. + + When running in a Docker container, is_local() may not work correctly because + the container has different network IPs. This function first checks if the + host matches the web stack host (container only), then falls back to is_local(). + + This affects: + - Container exec (local docker exec vs SSH) + - File read/write (local filesystem vs SSH) + - Shell sessions (local shell vs SSH) + """ + local_host = config.get_local_host_from_web_stack() + if local_host and host_name == local_host: + return True + return is_local(host) + + def get_local_host(config: Config) -> str | None: - """Find the local host name from config, if any.""" + """Find the local host name from config, if any. + + First checks the web stack host (container only), then falls back to is_local() + detection. + """ + # Web stack host takes precedence in container mode + local_host = config.get_local_host_from_web_stack() + if local_host and local_host in config.hosts: + return local_host + # Fall back to auto-detection for name, host in config.hosts.items(): if is_local(host): return name diff --git a/src/compose_farm/web/routes/actions.py b/src/compose_farm/web/routes/actions.py index 455324e..b6b5572 100644 --- a/src/compose_farm/web/routes/actions.py +++ b/src/compose_farm/web/routes/actions.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import os import uuid from typing import TYPE_CHECKING, Any @@ -15,9 +14,6 @@ if TYPE_CHECKING: from compose_farm.web.deps import get_config from compose_farm.web.streaming import run_cli_streaming, run_compose_streaming, tasks -# Environment variable to identify the web stack (for exclusion from bulk updates) -CF_WEB_STACK = os.environ.get("CF_WEB_STACK", "") - router = APIRouter(tags=["actions"]) # Store task references to prevent garbage collection @@ -107,7 +103,8 @@ async def update_all() -> dict[str, Any]: """ config = get_config() # Get all stacks except the web stack to avoid self-shutdown - stacks = [s for s in config.stacks if s != CF_WEB_STACK] + web_stack = config.get_web_stack() + stacks = [s for s in config.stacks if s != web_stack] if not stacks: return {"task_id": "", "command": "update (no stacks)", "skipped": True} task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", *stacks], tid)) diff --git a/src/compose_farm/web/routes/api.py b/src/compose_farm/web/routes/api.py index 2b3bdb1..89a489d 100644 --- a/src/compose_farm/web/routes/api.py +++ b/src/compose_farm/web/routes/api.py @@ -20,11 +20,11 @@ from fastapi import APIRouter, Body, HTTPException, Query from fastapi.responses import HTMLResponse from compose_farm.compose import extract_services, get_container_name, load_compose_data_for_stack -from compose_farm.executor import is_local, run_compose_on_host, ssh_connect_kwargs +from compose_farm.executor import run_compose_on_host, ssh_connect_kwargs from compose_farm.glances import fetch_all_host_stats from compose_farm.paths import backup_dir, find_config_path from compose_farm.state import load_state -from compose_farm.web.deps import get_config, get_templates +from compose_farm.web.deps import get_config, get_templates, is_local_host logger = logging.getLogger(__name__) @@ -344,10 +344,11 @@ async def read_console_file( path: Annotated[str, Query(description="File path")], ) -> dict[str, Any]: """Read a file from a host for the console editor.""" + config = get_config() host_config = _get_console_host(host, path) try: - if is_local(host_config): + if is_local_host(host, host_config, config): content = await _read_file_local(path) else: content = await _read_file_remote(host_config, path) @@ -368,10 +369,11 @@ async def write_console_file( content: Annotated[str, Body(media_type="text/plain")], ) -> dict[str, Any]: """Write a file to a host from the console editor.""" + config = get_config() host_config = _get_console_host(host, path) try: - if is_local(host_config): + if is_local_host(host, host_config, config): saved = await _write_file_local(path, content) msg = f"Saved: {path}" if saved else "No changes to save" else: diff --git a/src/compose_farm/web/streaming.py b/src/compose_farm/web/streaming.py index 9b25ebd..6c7c3e8 100644 --- a/src/compose_farm/web/streaming.py +++ b/src/compose_farm/web/streaming.py @@ -13,8 +13,6 @@ from compose_farm.ssh_keys import get_ssh_auth_sock if TYPE_CHECKING: from compose_farm.config import Config -# Environment variable to identify the web stack (for self-update detection) -CF_WEB_STACK = os.environ.get("CF_WEB_STACK", "") # ANSI escape codes for terminal output RED = "\x1b[31m" @@ -95,13 +93,14 @@ async def run_cli_streaming( tasks[task_id]["completed_at"] = time.time() -def _is_self_update(stack: str, command: str) -> bool: +def _is_self_update(config: Config, stack: str, command: str) -> bool: """Check if this is a self-update (updating the web stack itself). Self-updates need special handling because running 'down' on the container we're running in would kill the process before 'up' can execute. """ - if not CF_WEB_STACK or stack != CF_WEB_STACK: + web_stack = config.get_web_stack() + if not web_stack or stack != web_stack: return False # Commands that involve 'down' need SSH: update, down return command in ("update", "down") @@ -114,7 +113,8 @@ async def _run_cli_via_ssh( ) -> None: """Run a cf CLI command via SSH for self-updates (survives container restart).""" try: - host = config.get_host(CF_WEB_STACK) + web_stack = config.get_web_stack() + host = config.get_host(web_stack) cf_cmd = f"cf {' '.join(args)} --config={config.config_path}" # Include task_id to prevent collision with concurrent updates log_file = f"/tmp/cf-self-update-{task_id}.log" # noqa: S108 @@ -170,7 +170,7 @@ async def run_compose_streaming( cli_args = [cli_cmd, stack, *extra_args] # Use SSH for self-updates to survive container restart - if _is_self_update(stack, cli_cmd): + if _is_self_update(config, stack, cli_cmd): await _run_cli_via_ssh(config, cli_args, task_id) else: await run_cli_streaming(config, cli_args, task_id) diff --git a/src/compose_farm/web/ws.py b/src/compose_farm/web/ws.py index 826511b..99bb0a2 100644 --- a/src/compose_farm/web/ws.py +++ b/src/compose_farm/web/ws.py @@ -18,8 +18,8 @@ from typing import TYPE_CHECKING, Any import asyncssh from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from compose_farm.executor import is_local, ssh_connect_kwargs -from compose_farm.web.deps import get_config +from compose_farm.executor import ssh_connect_kwargs +from compose_farm.web.deps import get_config, is_local_host from compose_farm.web.streaming import CRLF, DIM, GREEN, RED, RESET, tasks logger = logging.getLogger(__name__) @@ -188,7 +188,7 @@ async def _run_exec_session( await websocket.send_text(f"{RED}Host '{host_name}' not found{RESET}{CRLF}") return - if is_local(host): + if is_local_host(host_name, host, config): # Local: use argv list (no shell interpretation) argv = ["docker", "exec", "-it", container, "/bin/sh", "-c", SHELL_FALLBACK] await _run_local_exec(websocket, argv) @@ -239,7 +239,7 @@ async def _run_shell_session( # Start interactive shell in home directory shell_cmd = "cd ~ && exec bash -i || exec sh -i" - if is_local(host): + if is_local_host(host_name, host, config): # Local: use argv list with shell -c to interpret the command argv = ["/bin/sh", "-c", shell_cmd] await _run_local_exec(websocket, argv) diff --git a/tests/test_config.py b/tests/test_config.py index f45d5d1..97fe4c0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -78,6 +78,76 @@ class TestConfig: # Defaults to compose.yaml when no file exists assert path == Path("/opt/compose/plex/compose.yaml") + def test_get_web_stack_returns_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: + """get_web_stack returns CF_WEB_STACK env var.""" + monkeypatch.setenv("CF_WEB_STACK", "compose-farm") + config = Config( + compose_dir=Path("/opt/compose"), + hosts={"nas": Host(address="192.168.1.6")}, + stacks={"compose-farm": "nas"}, + ) + assert config.get_web_stack() == "compose-farm" + + def test_get_web_stack_returns_empty_when_not_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_web_stack returns empty string when env var not set.""" + monkeypatch.delenv("CF_WEB_STACK", raising=False) + config = Config( + compose_dir=Path("/opt/compose"), + hosts={"nas": Host(address="192.168.1.6")}, + stacks={"compose-farm": "nas"}, + ) + assert config.get_web_stack() == "" + + def test_get_local_host_from_web_stack_returns_host( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_local_host_from_web_stack returns the web stack host in container.""" + monkeypatch.setenv("CF_WEB_STACK", "compose-farm") + config = Config( + compose_dir=Path("/opt/compose"), + hosts={"nas": Host(address="192.168.1.6"), "nuc": Host(address="192.168.1.2")}, + stacks={"compose-farm": "nas"}, + ) + assert config.get_local_host_from_web_stack() == "nas" + + def test_get_local_host_from_web_stack_returns_none_outside_container( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_local_host_from_web_stack returns None when not in container.""" + monkeypatch.delenv("CF_WEB_STACK", raising=False) + config = Config( + compose_dir=Path("/opt/compose"), + hosts={"nas": Host(address="192.168.1.6")}, + stacks={"compose-farm": "nas"}, + ) + assert config.get_local_host_from_web_stack() is None + + def test_get_local_host_from_web_stack_returns_none_for_unknown_stack( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_local_host_from_web_stack returns None if web stack not in stacks.""" + monkeypatch.setenv("CF_WEB_STACK", "unknown-stack") + config = Config( + compose_dir=Path("/opt/compose"), + hosts={"nas": Host(address="192.168.1.6")}, + stacks={"plex": "nas"}, + ) + assert config.get_local_host_from_web_stack() is None + + def test_get_local_host_from_web_stack_returns_none_for_multi_host( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_local_host_from_web_stack returns None if web stack runs on multiple hosts.""" + monkeypatch.setenv("CF_WEB_STACK", "compose-farm") + config = Config( + compose_dir=Path("/opt/compose"), + hosts={"nas": Host(address="192.168.1.6"), "nuc": Host(address="192.168.1.2")}, + stacks={"compose-farm": ["nas", "nuc"]}, + ) + assert config.get_local_host_from_web_stack() is None + class TestLoadConfig: """Tests for load_config function.""" diff --git a/tests/test_config_cmd.py b/tests/test_config_cmd.py index ad0a44f..03290bc 100644 --- a/tests/test_config_cmd.py +++ b/tests/test_config_cmd.py @@ -10,7 +10,6 @@ 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, @@ -233,35 +232,6 @@ class TestConfigValidate: 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.""" diff --git a/tests/test_glances.py b/tests/test_glances.py index 84565a1..4d22b03 100644 --- a/tests/test_glances.py +++ b/tests/test_glances.py @@ -356,7 +356,6 @@ class TestGetGlancesAddress: 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" @@ -366,33 +365,29 @@ class TestGetGlancesAddress: ) -> 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( + def test_returns_container_name_for_web_stack_host( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """CF_LOCAL_HOST explicitly marks which host uses container name.""" + """Local host uses container name in container mode.""" 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") + 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") - monkeypatch.setenv("CF_LOCAL_HOST", "nas") host = Host(address="192.168.1.2") - result = _get_glances_address("nuc", host, "glances") + 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 CF_LOCAL_HOST, falls back to is_local detection.""" + """Without explicit 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") @@ -403,7 +398,6 @@ class TestGetGlancesAddress: ) -> 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/tests/web/test_helpers.py b/tests/web/test_helpers.py index 6f7791c..eddd25c 100644 --- a/tests/web/test_helpers.py +++ b/tests/web/test_helpers.py @@ -101,6 +101,83 @@ class TestGetStackComposePath: assert "not found" in exc_info.value.detail +class TestIsLocalHost: + """Tests for is_local_host helper.""" + + def test_returns_true_when_web_stack_host_matches( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """is_local_host returns True when host matches web stack host.""" + from compose_farm.config import Config, Host + from compose_farm.web.deps import is_local_host + + monkeypatch.setenv("CF_WEB_STACK", "compose-farm") + config = Config( + hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")}, + stacks={"compose-farm": "nas"}, + ) + host = config.hosts["nas"] + assert is_local_host("nas", host, config) is True + + def test_returns_false_when_web_stack_host_differs( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """is_local_host returns False when host does not match web stack host.""" + from compose_farm.config import Config, Host + from compose_farm.web.deps import is_local_host + + monkeypatch.setenv("CF_WEB_STACK", "compose-farm") + config = Config( + hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")}, + stacks={"compose-farm": "nas"}, + ) + host = config.hosts["nuc"] + # nuc is not local, and not matching the web stack host + assert is_local_host("nuc", host, config) is False + + +class TestGetLocalHost: + """Tests for get_local_host helper.""" + + def test_returns_web_stack_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + """get_local_host returns the web stack host when in container.""" + from compose_farm.config import Config, Host + from compose_farm.web.deps import get_local_host + + monkeypatch.setenv("CF_WEB_STACK", "compose-farm") + config = Config( + hosts={"nas": Host(address="10.99.99.1"), "nuc": Host(address="10.99.99.2")}, + stacks={"compose-farm": "nas"}, + ) + assert get_local_host(config) == "nas" + + def test_ignores_unknown_web_stack(self, monkeypatch: pytest.MonkeyPatch) -> None: + """get_local_host ignores web stack if it's not in stacks.""" + from compose_farm.config import Config, Host + from compose_farm.web.deps import get_local_host + + monkeypatch.setenv("CF_WEB_STACK", "unknown-stack") + # Use address that won't match local machine to avoid is_local() fallback + config = Config( + hosts={"nas": Host(address="10.99.99.1")}, + stacks={"test": "nas"}, + ) + # Should fall back to auto-detection (which won't match anything here) + assert get_local_host(config) is None + + def test_returns_none_outside_container(self, monkeypatch: pytest.MonkeyPatch) -> None: + """get_local_host returns None when CF_WEB_STACK not set.""" + from compose_farm.config import Config, Host + from compose_farm.web.deps import get_local_host + + monkeypatch.delenv("CF_WEB_STACK", raising=False) + config = Config( + hosts={"nas": Host(address="10.99.99.1")}, + stacks={"compose-farm": "nas"}, + ) + assert get_local_host(config) is None + + class TestRenderContainers: """Tests for container template rendering."""