mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
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
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<hostname> 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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user