mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
* 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
368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""Tests for config command module."""
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import yaml
|
|
from typer.testing import CliRunner
|
|
|
|
from compose_farm.cli import app
|
|
from compose_farm.cli.config import (
|
|
_detect_domain,
|
|
_generate_template,
|
|
_get_config_file,
|
|
_get_editor,
|
|
)
|
|
from compose_farm.config import Config, Host
|
|
|
|
|
|
@pytest.fixture
|
|
def runner() -> CliRunner:
|
|
return CliRunner()
|
|
|
|
|
|
@pytest.fixture
|
|
def valid_config_data() -> dict[str, Any]:
|
|
return {
|
|
"compose_dir": "/opt/compose",
|
|
"hosts": {"server1": "192.168.1.10"},
|
|
"stacks": {"nginx": "server1"},
|
|
}
|
|
|
|
|
|
class TestGetEditor:
|
|
"""Tests for _get_editor function."""
|
|
|
|
def test_uses_editor_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("EDITOR", "code")
|
|
monkeypatch.delenv("VISUAL", raising=False)
|
|
assert _get_editor() == "code"
|
|
|
|
def test_uses_visual_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("EDITOR", raising=False)
|
|
monkeypatch.setenv("VISUAL", "subl")
|
|
assert _get_editor() == "subl"
|
|
|
|
def test_editor_takes_precedence(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("EDITOR", "vim")
|
|
monkeypatch.setenv("VISUAL", "code")
|
|
assert _get_editor() == "vim"
|
|
|
|
|
|
class TestGetConfigFile:
|
|
"""Tests for _get_config_file function."""
|
|
|
|
def test_explicit_path(self, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "my-config.yaml"
|
|
config_file.touch()
|
|
result = _get_config_file(config_file)
|
|
assert result == config_file.resolve()
|
|
|
|
def test_cf_config_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
config_file = tmp_path / "env-config.yaml"
|
|
config_file.touch()
|
|
monkeypatch.setenv("CF_CONFIG", str(config_file))
|
|
result = _get_config_file(None)
|
|
assert result == config_file.resolve()
|
|
|
|
def test_returns_none_when_not_found(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("CF_CONFIG", raising=False)
|
|
# Set XDG_CONFIG_HOME to a nonexistent path - config_search_paths() will
|
|
# now return paths that don't exist
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "nonexistent"))
|
|
result = _get_config_file(None)
|
|
assert result is None
|
|
|
|
|
|
class TestGenerateTemplate:
|
|
"""Tests for _generate_template function."""
|
|
|
|
def test_generates_valid_yaml(self) -> None:
|
|
template = _generate_template()
|
|
# Should be valid YAML
|
|
data = yaml.safe_load(template)
|
|
assert "compose_dir" in data
|
|
assert "hosts" in data
|
|
assert "stacks" in data
|
|
|
|
def test_has_documentation_comments(self) -> None:
|
|
template = _generate_template()
|
|
assert "# Compose Farm configuration" in template
|
|
assert "hosts:" in template
|
|
assert "stacks:" in template
|
|
|
|
|
|
class TestConfigInit:
|
|
"""Tests for cf config init command."""
|
|
|
|
def test_init_creates_file(
|
|
self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("CF_CONFIG", raising=False)
|
|
config_file = tmp_path / "new-config.yaml"
|
|
result = runner.invoke(app, ["config", "init", "-p", str(config_file)])
|
|
assert result.exit_code == 0
|
|
assert config_file.exists()
|
|
assert "Config file created" in result.stdout
|
|
|
|
def test_init_force_overwrites(
|
|
self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("CF_CONFIG", raising=False)
|
|
config_file = tmp_path / "existing.yaml"
|
|
config_file.write_text("old content")
|
|
result = runner.invoke(app, ["config", "init", "-p", str(config_file), "-f"])
|
|
assert result.exit_code == 0
|
|
content = config_file.read_text()
|
|
assert "old content" not in content
|
|
assert "compose_dir" in content
|
|
|
|
def test_init_prompts_on_existing(
|
|
self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("CF_CONFIG", raising=False)
|
|
config_file = tmp_path / "existing.yaml"
|
|
config_file.write_text("old content")
|
|
result = runner.invoke(app, ["config", "init", "-p", str(config_file)], input="n\n")
|
|
assert result.exit_code == 0
|
|
assert "Aborted" in result.stdout
|
|
assert config_file.read_text() == "old content"
|
|
|
|
|
|
class TestConfigPath:
|
|
"""Tests for cf config path command."""
|
|
|
|
def test_path_shows_config(
|
|
self,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
valid_config_data: dict[str, Any],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
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", "path"])
|
|
assert result.exit_code == 0
|
|
assert str(config_file) in result.stdout
|
|
|
|
def test_path_with_explicit_path(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
# When explicitly provided, path is returned even if file doesn't exist
|
|
nonexistent = tmp_path / "nonexistent.yaml"
|
|
result = runner.invoke(app, ["config", "path", "-p", str(nonexistent)])
|
|
assert result.exit_code == 0
|
|
assert str(nonexistent) in result.stdout
|
|
|
|
|
|
class TestConfigShow:
|
|
"""Tests for cf config show command."""
|
|
|
|
def test_show_displays_content(
|
|
self,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
valid_config_data: dict[str, Any],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
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", "show"])
|
|
assert result.exit_code == 0
|
|
assert "Config file:" in result.stdout
|
|
|
|
def test_show_raw_output(
|
|
self,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
valid_config_data: dict[str, Any],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("CF_CONFIG", raising=False)
|
|
config_file = tmp_path / "compose-farm.yaml"
|
|
content = yaml.dump(valid_config_data)
|
|
config_file.write_text(content)
|
|
result = runner.invoke(app, ["config", "show", "-r"])
|
|
assert result.exit_code == 0
|
|
assert content in result.stdout
|
|
|
|
|
|
class TestConfigValidate:
|
|
"""Tests for cf config validate command."""
|
|
|
|
def test_validate_valid_config(
|
|
self,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
valid_config_data: dict[str, Any],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
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", "validate"])
|
|
assert result.exit_code == 0
|
|
assert "Valid config" in result.stdout
|
|
assert "Hosts: 1" in result.stdout
|
|
assert "Stacks: 1" in result.stdout
|
|
|
|
def test_validate_invalid_config(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
config_file = tmp_path / "invalid.yaml"
|
|
config_file.write_text("invalid: [yaml: content")
|
|
result = runner.invoke(app, ["config", "validate", "-p", str(config_file)])
|
|
assert result.exit_code == 1
|
|
# Error goes to stderr (captured in output when using CliRunner)
|
|
output = result.stdout + (result.stderr or "")
|
|
assert "Invalid config" in output or "✗" in output
|
|
|
|
def test_validate_missing_config(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
nonexistent = tmp_path / "nonexistent.yaml"
|
|
result = runner.invoke(app, ["config", "validate", "-p", str(nonexistent)])
|
|
assert result.exit_code == 1
|
|
# Error goes to stderr
|
|
output = result.stdout + (result.stderr or "")
|
|
assert "Config file not found" in output or "not found" in output.lower()
|
|
|
|
|
|
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_current_dir(
|
|
self,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
valid_config_data: dict[str, Any],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("CF_CONFIG", raising=False)
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir()
|
|
config_file = config_dir / "compose-farm.yaml"
|
|
config_file.write_text(yaml.dump(valid_config_data))
|
|
|
|
# Create a separate working directory
|
|
work_dir = tmp_path / "workdir"
|
|
work_dir.mkdir()
|
|
monkeypatch.chdir(work_dir)
|
|
|
|
result = runner.invoke(app, ["config", "init-env", "-p", str(config_file)])
|
|
|
|
assert result.exit_code == 0
|
|
# Should create .env in current directory, not config directory
|
|
env_file = work_dir / ".env"
|
|
assert env_file.exists()
|
|
assert not (config_dir / ".env").exists()
|