Compare commits

...

13 Commits

Author SHA1 Message Date
Bas Nijholt
fd1b04297e fix: --host filter now limits multi-host stack operations to single host (#175)
Some checks failed
Update README.md / update_readme (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (macos-latest, 3.13) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.13) (push) Has been cancelled
CI / browser-tests (push) Has been cancelled
CI / lint (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
* fix: --host filter now limits multi-host stack operations to single host

Previously, when using `-H host` with a multi-host stack like `glances: all`,
the command would find the stack (correct) but then operate on ALL hosts for
that stack (incorrect). For example, `cf down -H nas` with `glances` would
stop glances on all 5 hosts instead of just nas.

Now, when `--host` is specified:
- `cf down -H nas` only stops stacks on nas, including only the nas instance
  of multi-host stacks
- `cf up -H nas` only starts stacks on nas (skips migration logic since
  host is explicitly specified)

Added tests for the new filter_host behavior in both executor and CLI.

* fix: Apply filter_host to logs and ps commands as well

Same bug as up/down: when using `-H host` with multi-host stacks,
logs and ps would show results from all hosts instead of just the
filtered host.

* fix: Don't remove multi-host stacks from state when host-filtered

When using `-H host` with a multi-host stack, we only stop one instance.
The stack is still running on other hosts, so we shouldn't remove it
from state entirely.

This prevents issues where:
- `cf apply` would try to re-start the stack
- `cf ps` would show incorrect running status
- Orphan detection would be confused

Added tests to verify state is preserved for host-filtered multi-host
operations and removed for full stack operations.

* refactor: Introduce StackSelection dataclass for cleaner context passing

Instead of passing filter_host separately through multiple layers,
bundle the selection context into a StackSelection dataclass:

- stacks: list of selected stack names
- config: the loaded Config
- host_filter: optional host filter from -H flag

This provides:
1. Cleaner APIs - context travels together instead of being scattered
2. is_instance_level() method - encapsulates the check for whether
   this is an instance-level operation (host-filtered multi-host stack)
3. Future extensibility - can add more context (dry_run, verbose, etc.)

Updated all callers of get_stacks() to use the new return type.

* Revert "refactor: Introduce StackSelection dataclass for cleaner context passing"

This reverts commit e6e9eed93e.

* feat: Proper per-host state tracking for multi-host stacks

- Add `remove_stack_host()` to remove a single host from a multi-host stack's state
- Add `add_stack_host()` to add a single host to a stack's state
- Update `down` command to use `remove_stack_host` for host-filtered multi-host stacks
- Update `up` command to use `add_stack_host` for host-filtered operations

This ensures the state file accurately reflects which hosts each stack is running on,
rather than just tracking if it's running at all.

* fix: Use set comparisons for host list tests

Host lists may be reordered during YAML save/load, so test for
set equality rather than list equality.

* refactor: Merge remove_stack_host into remove_stack as optional parameter

Instead of a separate function, `remove_stack` now takes an optional
`host` parameter. When specified, it removes only that host from
multi-host stacks. This reduces API surface and follows the existing
pattern.

* fix: Restore deterministic host list sorting and add filter_host test

- Restore sorting of list values in _sorted_dict for consistent YAML output
- Add test for logs --host passing filter_host to run_on_stacks
2026-02-01 13:43:17 -08:00
Bas Nijholt
4d65702868 Sort host lists in state file for consistent output (#174)
Some checks failed
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (macos-latest, 3.13) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.13) (push) Has been cancelled
CI / browser-tests (push) Has been cancelled
CI / lint (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Update README.md / update_readme (push) Has been cancelled
* Sort host lists in state file for consistent output

Multi-host stacks (like glances) now have their host lists sorted
alphabetically when saving state. This makes git diffs cleaner by
avoiding spurious reordering changes.
2026-01-30 15:29:13 -08:00
Bas Nijholt
596a05e39d web: Refresh dashboard after terminal task completes (#173)
Some checks failed
CI / browser-tests (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (macos-latest, 3.13) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.13) (push) Has been cancelled
CI / lint (push) Has been cancelled
Update README.md / update_readme (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
2026-01-20 01:01:34 +00:00
Bas Nijholt
e1a8ceb9e6 fix: Pass local_host to _get_glances_address in containers route (#172)
The get_containers_rows_by_host endpoint was missing the local_host
parameter when calling _get_glances_address, causing it to use the
external IP instead of the container name for the local host.

This caused "Connection timed out" errors on the live-stats page
when the web UI container couldn't reach its own host via the
external IP (hairpin NAT issue).
2026-01-18 20:23:21 +00:00
Bas Nijholt
ed450c65e5 web: Fix sidebar filter clear button not appearing (#171)
Change the filter input event handler from onkeyup to oninput.
The onkeyup event only fires when a key is physically pressed and
released, which means the clear button never appeared when:
- Pasting text
- Using browser autofill
- Setting value programmatically

The oninput event fires whenever the value changes regardless of
the input method, making the clear button work reliably.
2026-01-18 20:21:37 +00:00
Bas Nijholt
0f84864a06 web: Show host name in live-stats error messages (#170)
Error rows now include the host name (e.g., "nuc: Connection refused")
and have proper id/data-host attributes so they get replaced in place
instead of accumulating on each refresh interval.
2026-01-18 20:08:39 +00:00
Bas Nijholt
9c72e0937a web: Add clear button to sidebar filter (#168) 2026-01-18 00:11:58 +01:00
Bas Nijholt
74cc2f3245 fix: add COLUMNS and _TYPER_FORCE_DISABLE_TERMINAL for consistent output (#167) 2026-01-16 22:07:41 +01:00
Bas Nijholt
940bd9585a fix: Detect stale NFS mounts in path existence check (#166) 2026-01-15 12:53:21 +01:00
Bas Nijholt
dd60af61a8 docs: Add Plausible analytics (#165) 2026-01-12 17:23:08 +01:00
Bas Nijholt
2f3720949b Fix compose file resolution on remote hosts (#164) 2026-01-11 00:22:55 +01:00
Bas Nijholt
1e3b1d71ed 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
2026-01-10 10:48:35 +01:00
Bas Nijholt
c159549a9e web: Fix Glances connection for local host in container rows endpoint (#161) 2026-01-08 13:04:14 +01:00
34 changed files with 1144 additions and 393 deletions

View File

@@ -26,7 +26,9 @@ jobs:
env:
TERM: dumb
NO_COLOR: 1
TERMINAL_WIDTH: 90
COLUMNS: 90 # POSIX terminal width for Rich
TERMINAL_WIDTH: 90 # Typer MAX_WIDTH for help panels
_TYPER_FORCE_DISABLE_TERMINAL: 1 # Prevent Typer forcing terminal mode in CI
run: |
uvx --with . markdown-code-runner README.md
sed -i 's/[[:space:]]*$//' README.md

View File

@@ -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?

439
README.md
View File

@@ -478,46 +478,41 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Compose Farm - run docker compose commands across multiple hosts
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --version -v Show version and exit │
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell, to │
copy it or customize the installation. │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Configuration ──────────────────────────────────────────────────────────────╮
│ traefik-file Generate a Traefik file-provider fragment from compose
Traefik labels.
refresh Update local state from running stacks.
check Validate configuration, traefik labels, mounts, and networks.
init-network Create Docker network on hosts with consistent settings.
config Manage compose-farm configuration files.
│ ssh Manage SSH keys for passwordless authentication. │
──────────────────────────────────────────────────────────────────────────────╯
╭─ Lifecycle ──────────────────────────────────────────────────────────────────╮
up Start stacks (docker compose up -d). Auto-migrates if host
changed.
down Stop stacks (docker compose down).
stop Stop services without removing containers (docker compose │
stop).
pull Pull latest images (docker compose pull).
restart Restart running containers (docker compose restart).
update Update stacks (pull + build + up). Shorthand for 'up --pull
│ --build'. │
│ apply Make reality match config (start, migrate, stop │
strays/orphans as needed).
compose Run any docker compose command on a stack.
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
│ logs Show stack logs. With --service, shows logs for just that │
│ service. │
ps Show status of stacks.
│ stats Show overview statistics for hosts and stacks. │
│ list List all stacks and their assigned hosts. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Server ─────────────────────────────────────────────────────────────────────╮
│ web Start the web UI server. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --version -v Show version and exit
│ --install-completion Install completion for the current shell.
│ --show-completion Show completion for the current shell, to copy it or
│ customize the installation.
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Configuration ────────────────────────────────────────────────────────────────────────
│ traefik-file Generate a Traefik file-provider fragment from compose Traefik labels.
refresh Update local state from running stacks.
check Validate configuration, traefik labels, mounts, and networks.
init-network Create Docker network on hosts with consistent settings.
config Manage compose-farm configuration files.
ssh Manage SSH keys for passwordless authentication.
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Lifecycle ────────────────────────────────────────────────────────────────────────────
│ up Start stacks (docker compose up -d). Auto-migrates if host changed. │
down Stop stacks (docker compose down).
stop Stop services without removing containers (docker compose stop).
pull Pull latest images (docker compose pull).
restart Restart running containers (docker compose restart).
update Update stacks (pull + build + up). Shorthand for 'up --pull --build'.
apply Make reality match config (start, migrate, stop strays/orphans as
needed).
compose Run any docker compose command on a stack.
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Monitoring ───────────────────────────────────────────────────────────────────────────╮
logs Show stack logs. With --service, shows logs for just that service.
ps Show status of stacks.
│ stats Show overview statistics for hosts and stacks. │
│ list List all stacks and their assigned hosts. │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Server ───────────────────────────────────────────────────────────────────────────────╮
web Start the web UI server.
╰────────────────────────────────────────────────────────────────────────────────────────╯
```
@@ -546,18 +541,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Start stacks (docker compose up -d). Auto-migrates if host changed.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --host -H TEXT Filter to stacks on this host │
│ --service -s TEXT Target a specific service within the stack │
│ --pull Pull images before starting (--pull always) │
│ --build Build images before starting │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --host -H TEXT Filter to stacks on this host
│ --service -s TEXT Target a specific service within the stack
│ --pull Pull images before starting (--pull always)
│ --build Build images before starting
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -584,17 +579,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Stop stacks (docker compose down).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --orphaned Stop orphaned stacks (in state but removed from │
config)
│ --host -H TEXT Filter to stacks on this host
│ --config -c PATH Path to config file
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --orphaned Stop orphaned stacks (in state but removed from config)
--host -H TEXT Filter to stacks on this host
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────╯
```
@@ -621,15 +615,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Stop services without removing containers (docker compose stop).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --service -s TEXT Target a specific service within the stack │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --service -s TEXT Target a specific service within the stack
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -656,15 +650,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Pull latest images (docker compose pull).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --service -s TEXT Target a specific service within the stack │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --service -s TEXT Target a specific service within the stack
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -691,15 +685,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Restart running containers (docker compose restart).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --service -s TEXT Target a specific service within the stack │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --service -s TEXT Target a specific service within the stack
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -726,15 +720,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Update stacks (pull + build + up). Shorthand for 'up --pull --build'.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --service -s TEXT Target a specific service within the stack │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --service -s TEXT Target a specific service within the stack
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -774,15 +768,14 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Use --no-strays to skip stopping stray stacks.
Use --full to also run 'up' on all stacks (picks up compose/env changes).
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --dry-run -n Show what would change without executing │
│ --no-orphans Only migrate, don't stop orphaned stacks │
│ --no-strays Don't stop stray stacks (running on wrong host) │
│ --full -f Also run up on all stacks to apply config │
changes
│ --config -c PATH Path to config file
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --dry-run -n Show what would change without executing
│ --no-orphans Only migrate, don't stop orphaned stacks
│ --no-strays Don't stop stray stacks (running on wrong host)
│ --full -f Also run up on all stacks to apply config changes
--config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────╯
```
@@ -819,17 +812,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
cf compose mystack exec web bash - interactive shell
cf compose mystack config - view parsed config
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ * stack TEXT Stack to operate on (use '.' for current dir) │
[required]
* command TEXT Docker compose command [required]
│ args [ARGS]... Additional arguments │
──────────────────────────────────────────────────────────────────────────────
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --host -H TEXT Filter to stacks on this host
│ --config -c PATH Path to config file
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ * stack TEXT Stack to operate on (use '.' for current dir) [required]
* command TEXT Docker compose command [required]
args [ARGS]... Additional arguments
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --host -H TEXT Filter to stacks on this host │
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────╯
```
@@ -858,16 +850,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Generate a Traefik file-provider fragment from compose Traefik labels.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --output -o PATH Write Traefik file-provider YAML to this path │
(stdout if omitted)
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --output -o PATH Write Traefik file-provider YAML to this path (stdout if
omitted)
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -903,16 +895,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Use 'cf apply' to make reality match your config (stop orphans, migrate).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --config -c PATH Path to config file │
│ --log-path -l PATH Path to Dockerfarm TOML log │
│ --dry-run -n Show what would change without writing │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --config -c PATH Path to config file
│ --log-path -l PATH Path to Dockerfarm TOML log
│ --dry-run -n Show what would change without writing
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -945,14 +937,14 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Use --local to skip SSH-based checks for faster validation.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --local Skip SSH-based checks (faster) │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --local Skip SSH-based checks (faster)
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -984,16 +976,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
communication. Uses the same subnet/gateway on all hosts to ensure
consistent networking.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ hosts [HOSTS]... Hosts to create network on (default: all) │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --network -n TEXT Network name [default: mynetwork] │
│ --subnet -s TEXT Network subnet [default: 172.20.0.0/16] │
│ --gateway -g TEXT Network gateway [default: 172.20.0.1] │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ hosts [HOSTS]... Hosts to create network on (default: all)
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --network -n TEXT Network name [default: mynetwork]
│ --subnet -s TEXT Network subnet [default: 172.20.0.0/16]
│ --gateway -g TEXT Network gateway [default: 172.20.0.1]
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -1021,19 +1013,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Manage compose-farm configuration files.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ init Create a new config file with documented example. │
│ edit Open the config file in your default editor. │
│ show Display the config file location and contents. │
│ path Print the config file path (useful for scripting). │
│ 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. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Commands ─────────────────────────────────────────────────────────────────────────────
│ init Create a new config file with documented example.
│ edit Open the config file in your default editor.
│ show Display the config file location and contents.
│ path Print the config file path (useful for scripting).
│ 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.
╰────────────────────────────────────────────────────────────────────────────────────────╯
```
@@ -1061,14 +1052,14 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Manage SSH keys for passwordless authentication.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ keygen Generate SSH key (does not distribute to hosts). │
│ setup Generate SSH key and distribute to all configured hosts. │
│ status Show SSH key status and host connectivity. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Commands ─────────────────────────────────────────────────────────────────────────────
│ keygen Generate SSH key (does not distribute to hosts).
│ setup Generate SSH key and distribute to all configured hosts.
│ status Show SSH key status and host connectivity.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -1097,19 +1088,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Show stack logs. With --service, shows logs for just that service.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --host -H TEXT Filter to stacks on this host │
│ --service -s TEXT Target a specific service within the stack │
│ --follow -f Follow logs │
│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │
otherwise)
│ --config -c PATH Path to config file
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --host -H TEXT Filter to stacks on this host
│ --service -s TEXT Target a specific service within the stack
│ --follow -f Follow logs
│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 otherwise)
--config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────╯
```
@@ -1142,16 +1132,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
With --host: shows stacks on that host.
With --service: filters to a specific service within the stack.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks │
│ --host -H TEXT Filter to stacks on this host │
│ --service -s TEXT Target a specific service within the stack │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────────────────
│ stacks [STACKS]... Stacks to operate on
╰────────────────────────────────────────────────────────────────────────────────────────
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --all -a Run on all stacks
│ --host -H TEXT Filter to stacks on this host
│ --service -s TEXT Target a specific service within the stack
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -1183,14 +1173,13 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
With --live: Also queries Docker on each host for container counts.
With --containers: Shows per-container resource stats (requires Glances).
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --live -l Query Docker for live container stats │
│ --containers -C Show per-container resource stats (requires │
Glances)
│ --host -H TEXT Filter to stacks on this host
│ --config -c PATH Path to config file
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --live -l Query Docker for live container stats
│ --containers -C Show per-container resource stats (requires Glances)
--host -H TEXT Filter to stacks on this host
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────╯
```
@@ -1217,12 +1206,12 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
List all stacks and their assigned hosts.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --host -H TEXT Filter to stacks on this host │
│ --simple -s Plain output (one stack per line, for scripting) │
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --host -H TEXT Filter to stacks on this host
│ --simple -s Plain output (one stack per line, for scripting)
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -1251,12 +1240,12 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Start the web UI server.
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --host -H TEXT Host to bind to [default: 0.0.0.0] │
│ --port -p INTEGER Port to listen on [default: 8000] │
│ --reload -r Enable auto-reload for development │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────────────────
│ --host -H TEXT Host to bind to [default: 0.0.0.0]
│ --port -p INTEGER Port to listen on [default: 8000]
│ --reload -r Enable auto-reload for development
│ --help -h Show this message and exit.
╰────────────────────────────────────────────────────────────────────────────────────────
```
@@ -1429,13 +1418,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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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` |

View File

@@ -0,0 +1,6 @@
<!-- Privacy-friendly analytics by Plausible -->
<script async src="https://plausible.nijho.lt/js/pa-NRX7MolONWKTUREJpAjkB.js"></script>
<script>
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
plausible.init()
</script>

View File

@@ -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]")

View File

@@ -37,6 +37,7 @@ from compose_farm.operations import (
up_stacks,
)
from compose_farm.state import (
add_stack_host,
get_orphaned_stacks,
get_stack_host,
get_stacks_needing_migration,
@@ -73,6 +74,23 @@ def up(
cfg, stack_list, build_up_cmd(pull=pull, build=build, service=service), raw=True
)
)
elif host:
# For host-filtered up, use run_on_stacks to only affect that host
# (skips migration logic, which is intended when explicitly specifying a host)
results = run_async(
run_on_stacks(
cfg,
stack_list,
build_up_cmd(pull=pull, build=build),
raw=True,
filter_host=host,
)
)
# Update state for successful host-filtered operations
for result in results:
if result.success:
base_stack = result.stack.split("@")[0]
add_stack_host(cfg, base_stack, host)
else:
results = run_async(up_stacks(cfg, stack_list, raw=True, pull=pull, build=build))
maybe_regenerate_traefik(cfg, results)
@@ -116,17 +134,20 @@ def down(
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
raw = len(stack_list) == 1
results = run_async(run_on_stacks(cfg, stack_list, "down", raw=raw))
results = run_async(run_on_stacks(cfg, stack_list, "down", raw=raw, filter_host=host))
# Remove from state on success
# Update state on success
# For multi-host stacks, result.stack is "stack@host", extract base name
removed_stacks: set[str] = set()
updated_stacks: set[str] = set()
for result in results:
if result.success:
base_stack = result.stack.split("@")[0]
if base_stack not in removed_stacks:
remove_stack(cfg, base_stack)
removed_stacks.add(base_stack)
if base_stack not in updated_stacks:
# When host is specified for multi-host stack, removes just that host
# Otherwise removes entire stack from state
filter_host = host if host and cfg.is_multi_host(base_stack) else None
remove_stack(cfg, base_stack, filter_host)
updated_stacks.add(base_stack)
maybe_regenerate_traefik(cfg, results)
report_results(results)

View File

@@ -248,7 +248,7 @@ def logs(
cmd += " -f"
if service:
cmd += f" {service}"
results = run_async(run_on_stacks(cfg, stack_list, cmd))
results = run_async(run_on_stacks(cfg, stack_list, cmd, filter_host=host))
report_results(results)
@@ -272,7 +272,7 @@ def ps(
print_error("--service requires exactly one stack")
raise typer.Exit(1)
cmd = f"ps {service}" if service else "ps"
results = run_async(run_on_stacks(cfg, stack_list, cmd))
results = run_async(run_on_stacks(cfg, stack_list, cmd, filter_host=host))
report_results(results)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import getpass
import os
from pathlib import Path
from typing import Any
@@ -96,9 +97,17 @@ class Config(BaseModel, extra="forbid"):
host_names = self.get_hosts(stack)
return self.hosts[host_names[0]]
def get_stack_dir(self, stack: str) -> Path:
"""Get stack directory path."""
return self.compose_dir / stack
def get_compose_path(self, stack: str) -> Path:
"""Get compose file path for a stack (tries compose.yaml first)."""
stack_dir = self.compose_dir / stack
"""Get compose file path for a stack (tries compose.yaml first).
Note: This checks local filesystem. For remote execution, use
get_stack_dir() and let docker compose find the file.
"""
stack_dir = self.get_stack_dir(stack)
for filename in COMPOSE_FILENAMES:
candidate = stack_dir / filename
if candidate.exists():
@@ -116,6 +125,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."""

View File

@@ -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

View File

@@ -58,22 +58,12 @@ _compose_labels_cache = TTLCache(ttl_seconds=30.0)
def _print_compose_command(
host_name: str,
compose_dir: str,
compose_path: str,
stack: str,
compose_cmd: str,
) -> None:
"""Print the docker compose command being executed.
Shows the host and a simplified command with relative path from compose_dir.
"""
# Show relative path from compose_dir for cleaner output
if compose_path.startswith(compose_dir):
rel_path = compose_path[len(compose_dir) :].lstrip("/")
else:
rel_path = compose_path
"""Print the docker compose command being executed."""
console.print(
f"[dim][magenta]{host_name}[/magenta]: docker compose -f {rel_path} {compose_cmd}[/dim]"
f"[dim][magenta]{host_name}[/magenta]: ({stack}) docker compose {compose_cmd}[/dim]"
)
@@ -362,11 +352,12 @@ async def run_compose(
"""Run a docker compose command for a stack."""
host_name = config.get_hosts(stack)[0]
host = config.hosts[host_name]
compose_path = config.get_compose_path(stack)
stack_dir = config.get_stack_dir(stack)
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd)
_print_compose_command(host_name, stack, compose_cmd)
command = f"docker compose -f {compose_path} {compose_cmd}"
# Use cd to let docker compose find the compose file on the remote host
command = f'cd "{stack_dir}" && docker compose {compose_cmd}'
return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix)
@@ -385,11 +376,12 @@ async def run_compose_on_host(
Used for migration - running 'down' on the old host before 'up' on new host.
"""
host = config.hosts[host_name]
compose_path = config.get_compose_path(stack)
stack_dir = config.get_stack_dir(stack)
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd)
_print_compose_command(host_name, stack, compose_cmd)
command = f"docker compose -f {compose_path} {compose_cmd}"
# Use cd to let docker compose find the compose file on the remote host
command = f'cd "{stack_dir}" && docker compose {compose_cmd}'
return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix)
@@ -400,13 +392,17 @@ async def run_on_stacks(
*,
stream: bool = True,
raw: bool = False,
filter_host: str | None = None,
) -> list[CommandResult]:
"""Run a docker compose command on multiple stacks in parallel.
For multi-host stacks, runs on all configured hosts.
Note: raw=True only makes sense for single-stack operations.
For multi-host stacks, runs on all configured hosts unless filter_host is set,
in which case only the filtered host is affected. raw=True only makes sense
for single-stack operations.
"""
return await run_sequential_on_stacks(config, stacks, [compose_cmd], stream=stream, raw=raw)
return await run_sequential_on_stacks(
config, stacks, [compose_cmd], stream=stream, raw=raw, filter_host=filter_host
)
async def _run_sequential_stack_commands(
@@ -426,6 +422,33 @@ async def _run_sequential_stack_commands(
return CommandResult(stack=stack, exit_code=0, success=True)
async def _run_sequential_stack_commands_on_host(
config: Config,
stack: str,
host_name: str,
commands: list[str],
*,
stream: bool = True,
raw: bool = False,
prefix: str | None = None,
) -> CommandResult:
"""Run multiple compose commands sequentially for a stack on a specific host.
Used when --host filter is applied to a multi-host stack.
"""
stack_dir = config.get_stack_dir(stack)
host = config.hosts[host_name]
label = f"{stack}@{host_name}"
for cmd in commands:
_print_compose_command(host_name, stack, cmd)
command = f'cd "{stack_dir}" && docker compose {cmd}'
result = await run_command(host, command, label, stream=stream, raw=raw, prefix=prefix)
if not result.success:
return result
return CommandResult(stack=label, exit_code=0, success=True)
async def _run_sequential_stack_commands_multi_host(
config: Config,
stack: str,
@@ -441,14 +464,15 @@ async def _run_sequential_stack_commands_multi_host(
For multi-host stacks, prefix defaults to stack@host format.
"""
host_names = config.get_hosts(stack)
compose_path = config.get_compose_path(stack)
stack_dir = config.get_stack_dir(stack)
final_results: list[CommandResult] = []
for cmd in commands:
command = f"docker compose -f {compose_path} {cmd}"
# Use cd to let docker compose find the compose file on the remote host
command = f'cd "{stack_dir}" && docker compose {cmd}'
tasks = []
for host_name in host_names:
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), cmd)
_print_compose_command(host_name, stack, cmd)
host = config.hosts[host_name]
# For multi-host stacks, always use stack@host prefix to distinguish output
label = f"{stack}@{host_name}" if len(host_names) > 1 else stack
@@ -476,11 +500,13 @@ async def run_sequential_on_stacks(
*,
stream: bool = True,
raw: bool = False,
filter_host: str | None = None,
) -> list[CommandResult]:
"""Run sequential commands on multiple stacks in parallel.
For multi-host stacks, runs on all configured hosts.
Note: raw=True only makes sense for single-stack operations.
For multi-host stacks, runs on all configured hosts unless filter_host is set,
in which case only the filtered host is affected. raw=True only makes sense
for single-stack operations.
"""
# Skip prefix for single-stack operations (command line already shows context)
prefix: str | None = "" if len(stacks) == 1 else None
@@ -490,12 +516,20 @@ async def run_sequential_on_stacks(
single_host_tasks = []
for stack in stacks:
if config.is_multi_host(stack):
if config.is_multi_host(stack) and filter_host is None:
# Multi-host stack without filter: run on all hosts
multi_host_tasks.append(
_run_sequential_stack_commands_multi_host(
config, stack, commands, stream=stream, raw=raw, prefix=prefix
)
)
elif config.is_multi_host(stack) and filter_host is not None:
# Multi-host stack with filter: run only on filtered host
single_host_tasks.append(
_run_sequential_stack_commands_on_host(
config, stack, filter_host, commands, stream=stream, raw=raw, prefix=prefix
)
)
else:
single_host_tasks.append(
_run_sequential_stack_commands(
@@ -525,10 +559,11 @@ async def check_stack_running(
) -> bool:
"""Check if a stack has running containers on a specific host."""
host = config.hosts[host_name]
compose_path = config.get_compose_path(stack)
stack_dir = config.get_stack_dir(stack)
# Use ps --status running to check for running containers
command = f"docker compose -f {compose_path} ps --status running -q"
# Use cd to let docker compose find the compose file on the remote host
command = f'cd "{stack_dir}" && docker compose ps --status running -q'
result = await run_command(host, command, stack, stream=False)
# If command succeeded and has output, containers are running
@@ -637,18 +672,28 @@ async def check_paths_exist(
host_name: str,
paths: list[str],
) -> dict[str, bool]:
"""Check if multiple paths exist on a specific host.
"""Check if multiple paths exist and are accessible on a specific host.
Returns a dict mapping path -> exists.
Handles permission denied as "exists" (path is there, just not accessible).
Uses timeout to detect stale NFS mounts that would hang.
"""
# Only report missing if stat says "No such file", otherwise assume exists
# (handles permission denied correctly - path exists, just not accessible)
# Use timeout to detect stale NFS mounts (which hang on access)
# - First try ls with timeout to check accessibility
# - If ls succeeds: path exists and is accessible
# - If ls fails/times out: use stat (also with timeout) to distinguish
# "no such file" from "permission denied" or stale NFS
# - Timeout (exit code 124) is treated as inaccessible (stale NFS mount)
return await _batch_check_existence(
config,
host_name,
paths,
lambda esc: f"stat '{esc}' 2>&1 | grep -q 'No such file' && echo 'N:{esc}' || echo 'Y:{esc}'",
lambda esc: (
f"OUT=$(timeout 2 stat '{esc}' 2>&1); RC=$?; "
f"if [ $RC -eq 124 ]; then echo 'N:{esc}'; "
f"elif echo \"$OUT\" | grep -q 'No such file'; then echo 'N:{esc}'; "
f"else echo 'Y:{esc}'; fi"
),
"mount-check",
)

View File

@@ -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
]

View File

@@ -214,8 +214,9 @@ async def _up_multi_host_stack(
"""Start a multi-host stack on all configured hosts."""
host_names = cfg.get_hosts(stack)
results: list[CommandResult] = []
compose_path = cfg.get_compose_path(stack)
command = f"docker compose -f {compose_path} {build_up_cmd(pull=pull, build=build)}"
stack_dir = cfg.get_stack_dir(stack)
# Use cd to let docker compose find the compose file on the remote host
command = f'cd "{stack_dir}" && docker compose {build_up_cmd(pull=pull, build=build)}'
# Pre-flight checks on all hosts
for host_name in host_names:

View File

@@ -64,8 +64,11 @@ def load_state(config: Config) -> dict[str, str | list[str]]:
def _sorted_dict(d: dict[str, str | list[str]]) -> dict[str, str | list[str]]:
"""Return a dictionary sorted by keys."""
return dict(sorted(d.items(), key=lambda item: item[0]))
"""Return a dictionary sorted by keys, with list values also sorted."""
return {
k: sorted(v) if isinstance(v, list) else v
for k, v in sorted(d.items(), key=lambda item: item[0])
}
def save_state(config: Config, deployed: dict[str, str | list[str]]) -> None:
@@ -109,10 +112,46 @@ def set_multi_host_stack(config: Config, stack: str, hosts: list[str]) -> None:
state[stack] = hosts
def remove_stack(config: Config, stack: str) -> None:
"""Remove a stack from the state (after down)."""
def remove_stack(config: Config, stack: str, host: str | None = None) -> None:
"""Remove a stack from the state (after down).
If host is provided, only removes that host from a multi-host stack.
If the list becomes empty, removes the stack entirely.
For single-host stacks with host specified, removes only if host matches.
"""
with _modify_state(config) as state:
state.pop(stack, None)
if stack not in state:
return
if host is None:
state.pop(stack, None)
return
current = state[stack]
if isinstance(current, list):
new_hosts = [h for h in current if h != host]
if new_hosts:
state[stack] = new_hosts
else:
del state[stack]
elif current == host:
del state[stack]
def add_stack_host(config: Config, stack: str, host: str) -> None:
"""Add a single host to a stack's state.
For multi-host stacks, adds the host to the list if not present.
For single-host stacks or new entries, sets the host directly.
"""
with _modify_state(config) as state:
current = state.get(stack)
if current is None:
state[stack] = host
elif isinstance(current, list):
if host not in current:
state[stack] = [*current, host]
elif current != host:
# Convert single host to list
state[stack] = [current, host]
def get_stacks_needing_migration(config: Config) -> list[str]:

View File

@@ -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

View File

@@ -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))

View File

@@ -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:

View File

@@ -244,7 +244,7 @@ async def get_containers_rows_by_host(host_name: str) -> HTMLResponse:
import time # noqa: PLC0415
from compose_farm.executor import get_container_compose_labels # noqa: PLC0415
from compose_farm.glances import fetch_container_stats # noqa: PLC0415
from compose_farm.glances import _get_glances_address, fetch_container_stats # noqa: PLC0415
logger = logging.getLogger(__name__)
config = get_config()
@@ -253,9 +253,11 @@ async def get_containers_rows_by_host(host_name: str) -> HTMLResponse:
return HTMLResponse("")
host = config.hosts[host_name]
local_host = config.get_local_host_from_web_stack()
glances_address = _get_glances_address(host_name, host, config.glances_stack, local_host)
t0 = time.monotonic()
containers, error = await fetch_container_stats(host_name, host.address)
containers, error = await fetch_container_stats(host_name, glances_address)
t1 = time.monotonic()
fetch_ms = (t1 - t0) * 1000
@@ -267,7 +269,8 @@ async def get_containers_rows_by_host(host_name: str) -> HTMLResponse:
error,
)
return HTMLResponse(
f'<tr class="text-error"><td colspan="12" class="text-center py-2">Error: {error}</td></tr>'
f'<tr id="error-{host_name}" class="text-error" data-host="{host_name}">'
f'<td colspan="12" class="text-center py-2">{host_name}: {error}</td></tr>'
)
if not containers:

View File

@@ -194,6 +194,7 @@ function initTerminal(elementId, taskId) {
term.write(event.data);
if (event.data.includes('[Done]') || event.data.includes('[Failed]')) {
localStorage.removeItem(taskKey);
refreshDashboard();
}
};
ws.onclose = () => setTerminalLoading(false);
@@ -494,7 +495,9 @@ function refreshDashboard() {
* Filter sidebar stacks by name and host
*/
function sidebarFilter() {
const q = (document.getElementById('sidebar-filter')?.value || '').toLowerCase();
const input = document.getElementById('sidebar-filter');
const clearBtn = document.getElementById('sidebar-filter-clear');
const q = (input?.value || '').toLowerCase();
const h = document.getElementById('sidebar-host-select')?.value || '';
let n = 0;
document.querySelectorAll('#sidebar-stacks li').forEach(li => {
@@ -503,9 +506,26 @@ function sidebarFilter() {
if (show) n++;
});
document.getElementById('sidebar-count').textContent = '(' + n + ')';
// Show/hide clear button based on input value
if (clearBtn) {
clearBtn.classList.toggle('hidden', !q);
}
}
window.sidebarFilter = sidebarFilter;
/**
* Clear sidebar filter input and refresh list
*/
function clearSidebarFilter() {
const input = document.getElementById('sidebar-filter');
if (input) {
input.value = '';
input.focus();
}
sidebarFilter();
}
window.clearSidebarFilter = clearSidebarFilter;
// Play intro animation on command palette button
function playFabIntro() {
const fab = document.getElementById('cmd-fab');

View File

@@ -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)

View File

@@ -159,6 +159,12 @@
</svg>
{% endmacro %}
{% macro x(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
{% endmacro %}
{% macro alert_triangle(size=16) %}
<svg xmlns="http://www.w3.org/2000/svg" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>

View File

@@ -1,4 +1,4 @@
{% from "partials/icons.html" import home, search, terminal, box %}
{% from "partials/icons.html" import home, search, terminal, box, x %}
<!-- Navigation Links -->
<div class="mb-4">
<ul class="menu" hx-boost="true" hx-target="#main-content" hx-select="#main-content" hx-swap="outerHTML">
@@ -13,7 +13,7 @@
<h4 class="text-xs uppercase tracking-wide text-base-content/60 px-3 py-1">Stacks <span class="opacity-50" id="sidebar-count">({{ stacks | length }})</span></h4>
<div class="px-2 mb-2 flex flex-col gap-1">
<label class="input input-xs flex items-center gap-2 bg-base-200">
{{ search(14) }}<input type="text" id="sidebar-filter" placeholder="Filter..." onkeyup="sidebarFilter()" />
{{ search(14) }}<input type="text" id="sidebar-filter" placeholder="Filter..." oninput="sidebarFilter()" /><button type="button" id="sidebar-filter-clear" class="hidden opacity-50 hover:opacity-100 cursor-pointer" onclick="clearSidebarFilter()">{{ x(12) }}</button>
</label>
<select id="sidebar-host-select" class="select select-xs bg-base-200 w-full" onchange="sidebarFilter()">
<option value="">All hosts</option>

View File

@@ -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)

View File

@@ -437,3 +437,132 @@ class TestDownOrphaned:
)
assert exc_info.value.exit_code == 1
class TestHostFilterMultiHost:
"""Tests for --host filter with multi-host stacks."""
def _make_multi_host_config(self, tmp_path: Path) -> Config:
"""Create a config with a multi-host stack."""
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
# Create stack directories
for stack in ["single-host", "multi-host"]:
stack_dir = compose_dir / stack
stack_dir.mkdir()
(stack_dir / "docker-compose.yml").write_text("services: {}\n")
config_path = tmp_path / "compose-farm.yaml"
config_path.write_text("")
return Config(
compose_dir=compose_dir,
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
"host3": Host(address="192.168.1.3"),
},
stacks={
"single-host": "host1",
"multi-host": ["host1", "host2", "host3"], # runs on all 3 hosts
},
config_path=config_path,
)
def test_down_host_filter_limits_multi_host_stack(self, tmp_path: Path) -> None:
"""--host filter should only run down on that host for multi-host stacks."""
cfg = self._make_multi_host_config(tmp_path)
with (
patch("compose_farm.cli.lifecycle.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.lifecycle.get_stacks") as mock_get_stacks,
patch("compose_farm.cli.lifecycle.run_on_stacks") as mock_run,
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[_make_result("multi-host@host1")],
),
patch("compose_farm.cli.lifecycle.remove_stack"),
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
mock_get_stacks.return_value = (["multi-host"], cfg)
down(
stacks=None,
all_stacks=False,
orphaned=False,
host="host1",
config=None,
)
# Verify run_on_stacks was called with filter_host="host1"
mock_run.assert_called_once()
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs.get("filter_host") == "host1"
def test_down_host_filter_removes_host_from_state(self, tmp_path: Path) -> None:
"""--host filter should remove just that host from multi-host stack's state.
When stopping only one instance of a multi-host stack, we should update
state to remove just that host, not the entire stack.
"""
cfg = self._make_multi_host_config(tmp_path)
with (
patch("compose_farm.cli.lifecycle.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.lifecycle.get_stacks") as mock_get_stacks,
patch("compose_farm.cli.lifecycle.run_on_stacks"),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[_make_result("multi-host@host1")],
),
patch("compose_farm.cli.lifecycle.remove_stack") as mock_remove,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
mock_get_stacks.return_value = (["multi-host"], cfg)
down(
stacks=None,
all_stacks=False,
orphaned=False,
host="host1",
config=None,
)
# remove_stack should be called with the host parameter
mock_remove.assert_called_once_with(cfg, "multi-host", "host1")
def test_down_without_host_filter_removes_from_state(self, tmp_path: Path) -> None:
"""Without --host filter, multi-host stacks SHOULD be removed from state."""
cfg = self._make_multi_host_config(tmp_path)
with (
patch("compose_farm.cli.lifecycle.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.lifecycle.get_stacks") as mock_get_stacks,
patch("compose_farm.cli.lifecycle.run_on_stacks"),
patch(
"compose_farm.cli.lifecycle.run_async",
return_value=[
_make_result("multi-host@host1"),
_make_result("multi-host@host2"),
_make_result("multi-host@host3"),
],
),
patch("compose_farm.cli.lifecycle.remove_stack") as mock_remove,
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
patch("compose_farm.cli.lifecycle.report_results"),
):
mock_get_stacks.return_value = (["multi-host"], cfg)
down(
stacks=None,
all_stacks=False,
orphaned=False,
host=None, # No host filter
config=None,
)
# remove_stack SHOULD be called with host=None when stopping all instances
mock_remove.assert_called_once_with(cfg, "multi-host", None)

View File

@@ -29,6 +29,28 @@ def _make_config(tmp_path: Path) -> Config:
)
def _make_multi_host_config(tmp_path: Path) -> Config:
"""Create a config with a multi-host stack for testing."""
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
for svc in ("single-host", "multi-host"):
svc_dir = compose_dir / svc
svc_dir.mkdir()
(svc_dir / "docker-compose.yml").write_text("services: {}\n")
return Config(
compose_dir=compose_dir,
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
},
stacks={
"single-host": "host1",
"multi-host": ["host1", "host2"],
},
)
def _make_result(stack: str) -> CommandResult:
"""Create a successful command result."""
return CommandResult(stack=stack, exit_code=0, success=True, stdout="", stderr="")
@@ -205,3 +227,26 @@ class TestLogsHostFilter:
)
assert exc_info.value.exit_code == 1
def test_logs_host_filter_passes_filter_host_to_run_on_stacks(self, tmp_path: Path) -> None:
"""--host should pass filter_host to run_on_stacks for multi-host stacks."""
cfg = _make_multi_host_config(tmp_path)
mock_run_async, _ = _mock_run_async_factory(["multi-host@host1"])
with (
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_stacks") as mock_run,
):
logs(
stacks=None,
all_stacks=False,
host="host1",
follow=False,
tail=None,
config=None,
)
mock_run.assert_called_once()
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs.get("filter_host") == "host1"

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -2,6 +2,7 @@
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
@@ -11,10 +12,12 @@ from compose_farm.executor import (
_run_local_command,
check_networks_exist,
check_paths_exist,
check_stack_running,
get_running_stacks_on_host,
is_local,
run_command,
run_compose,
run_compose_on_host,
run_on_stacks,
)
@@ -106,6 +109,108 @@ class TestRunCompose:
# Command may fail due to no docker, but structure is correct
assert result.stack == "test-service"
async def test_run_compose_uses_cd_pattern(self, tmp_path: Path) -> None:
"""Verify run_compose uses 'cd <dir> && docker compose' pattern."""
config = Config(
compose_dir=tmp_path,
hosts={"remote": Host(address="192.168.1.100")},
stacks={"mystack": "remote"},
)
mock_result = CommandResult(stack="mystack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
await run_compose(config, "mystack", "up -d", stream=False)
# Verify the command uses cd pattern with quoted path
mock_run.assert_called_once()
call_args = mock_run.call_args
command = call_args[0][1] # Second positional arg is command
assert command == f'cd "{tmp_path}/mystack" && docker compose up -d'
async def test_run_compose_works_without_local_compose_file(self, tmp_path: Path) -> None:
"""Verify compose works even when compose file doesn't exist locally.
This is the bug from issue #162 - when running cf from a machine without
NFS mounts, the compose file doesn't exist locally but should still work
on the remote host.
"""
config = Config(
compose_dir=tmp_path, # No compose files exist here
hosts={"remote": Host(address="192.168.1.100")},
stacks={"mystack": "remote"},
)
# Verify no compose file exists locally
assert not (tmp_path / "mystack" / "compose.yaml").exists()
assert not (tmp_path / "mystack" / "compose.yml").exists()
mock_result = CommandResult(stack="mystack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
result = await run_compose(config, "mystack", "ps", stream=False)
# Should succeed - docker compose on remote will find the file
assert result.success
# Command should use cd pattern, not -f with a specific file
command = mock_run.call_args[0][1]
assert "cd " in command
assert " && docker compose " in command
assert "-f " not in command # Should NOT use -f flag
async def test_run_compose_on_host_uses_cd_pattern(self, tmp_path: Path) -> None:
"""Verify run_compose_on_host uses 'cd <dir> && docker compose' pattern."""
config = Config(
compose_dir=tmp_path,
hosts={"host1": Host(address="192.168.1.1")},
stacks={"mystack": "host1"},
)
mock_result = CommandResult(stack="mystack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
await run_compose_on_host(config, "mystack", "host1", "down", stream=False)
command = mock_run.call_args[0][1]
assert command == f'cd "{tmp_path}/mystack" && docker compose down'
async def test_check_stack_running_uses_cd_pattern(self, tmp_path: Path) -> None:
"""Verify check_stack_running uses 'cd <dir> && docker compose' pattern."""
config = Config(
compose_dir=tmp_path,
hosts={"host1": Host(address="192.168.1.1")},
stacks={"mystack": "host1"},
)
mock_result = CommandResult(stack="mystack", exit_code=0, success=True, stdout="abc123\n")
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
result = await check_stack_running(config, "mystack", "host1")
assert result is True
command = mock_run.call_args[0][1]
assert command == f'cd "{tmp_path}/mystack" && docker compose ps --status running -q'
async def test_run_compose_quotes_paths_with_spaces(self, tmp_path: Path) -> None:
"""Verify paths with spaces are properly quoted."""
compose_dir = tmp_path / "my compose dir"
compose_dir.mkdir()
config = Config(
compose_dir=compose_dir,
hosts={"remote": Host(address="192.168.1.100")},
stacks={"my-stack": "remote"},
)
mock_result = CommandResult(stack="my-stack", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
await run_compose(config, "my-stack", "up -d", stream=False)
command = mock_run.call_args[0][1]
# Path should be quoted to handle spaces
assert f'cd "{compose_dir}/my-stack"' in command
class TestRunOnStacks:
"""Tests for parallel stack execution."""
@@ -124,6 +229,52 @@ class TestRunOnStacks:
assert results[0].stack == "svc1"
assert results[1].stack == "svc2"
async def test_run_on_stacks_filter_host_limits_multi_host(self) -> None:
"""filter_host should only run on that host for multi-host stacks."""
config = Config(
compose_dir=Path("/tmp"),
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
"host3": Host(address="192.168.1.3"),
},
stacks={"multi-svc": ["host1", "host2", "host3"]}, # multi-host stack
)
mock_result = CommandResult(stack="multi-svc@host1", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
results = await run_on_stacks(
config, ["multi-svc"], "down", stream=False, filter_host="host1"
)
# Should only call run_command once (for host1), not 3 times
assert mock_run.call_count == 1
# Result should be for the filtered host
assert len(results) == 1
assert results[0].stack == "multi-svc@host1"
async def test_run_on_stacks_no_filter_runs_all_hosts(self) -> None:
"""Without filter_host, multi-host stacks run on all configured hosts."""
config = Config(
compose_dir=Path("/tmp"),
hosts={
"host1": Host(address="192.168.1.1"),
"host2": Host(address="192.168.1.2"),
},
stacks={"multi-svc": ["host1", "host2"]}, # multi-host stack
)
mock_result = CommandResult(stack="multi-svc", exit_code=0, success=True)
with patch("compose_farm.executor.run_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = mock_result
results = await run_on_stacks(config, ["multi-svc"], "down", stream=False)
# Should call run_command twice (once per host)
assert mock_run.call_count == 2
# Results should be for both hosts
assert len(results) == 2
@linux_only
class TestCheckPathsExist:

View File

@@ -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"

View File

@@ -6,6 +6,7 @@ import pytest
from compose_farm.config import Config, Host
from compose_farm.state import (
add_stack_host,
get_orphaned_stacks,
get_stack_host,
get_stacks_not_in_state,
@@ -67,6 +68,16 @@ class TestSaveState:
assert "plex: nas01" in content
assert "jellyfin: nas02" in content
def test_save_state_sorts_host_lists(self, config: Config) -> None:
"""Saves state with sorted host lists for consistent output."""
# Pass hosts in unsorted order
save_state(config, {"glances": ["pc", "nas", "hp", "anton"]})
state_file = config.get_state_path()
content = state_file.read_text()
# Hosts should be sorted alphabetically
assert "- anton\n - hp\n - nas\n - pc" in content
class TestGetStackHost:
"""Tests for get_stack_host function."""
@@ -133,6 +144,110 @@ class TestRemoveStack:
result = load_state(config)
assert result["plex"] == "nas01"
def test_remove_host_from_list(self, config: Config) -> None:
"""Removes one host from a multi-host stack's list."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n - hp\n")
remove_stack(config, "glances", "nas")
result = load_state(config)
assert set(result["glances"]) == {"nuc", "hp"}
def test_remove_last_host_removes_stack(self, config: Config) -> None:
"""Removing the last host removes the stack entirely."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n glances:\n - nas\n")
remove_stack(config, "glances", "nas")
result = load_state(config)
assert "glances" not in result
def test_remove_host_from_single_host_stack(self, config: Config) -> None:
"""Removing host from single-host stack removes it if host matches."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n plex: nas\n")
remove_stack(config, "plex", "nas")
result = load_state(config)
assert "plex" not in result
def test_remove_wrong_host_from_single_host_stack(self, config: Config) -> None:
"""Removing wrong host from single-host stack does nothing."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n plex: nas\n")
remove_stack(config, "plex", "nuc")
result = load_state(config)
assert result["plex"] == "nas"
def test_remove_host_from_nonexistent_stack(self, config: Config) -> None:
"""Removing host from nonexistent stack doesn't error."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n plex: nas\n")
remove_stack(config, "unknown", "nas") # Should not raise
result = load_state(config)
assert result["plex"] == "nas"
class TestAddStackHost:
"""Tests for add_stack_host function."""
def test_add_host_to_new_stack(self, config: Config) -> None:
"""Adding host to new stack creates single-host entry."""
state_file = config.get_state_path()
state_file.write_text("deployed: {}\n")
add_stack_host(config, "plex", "nas")
result = load_state(config)
assert result["plex"] == "nas"
def test_add_host_to_list(self, config: Config) -> None:
"""Adding host to existing list appends it."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n")
add_stack_host(config, "glances", "hp")
result = load_state(config)
assert set(result["glances"]) == {"nas", "nuc", "hp"}
def test_add_duplicate_host_to_list(self, config: Config) -> None:
"""Adding duplicate host to list does nothing."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n glances:\n - nas\n - nuc\n")
add_stack_host(config, "glances", "nas")
result = load_state(config)
assert set(result["glances"]) == {"nas", "nuc"}
def test_add_second_host_converts_to_list(self, config: Config) -> None:
"""Adding second host to single-host stack converts to list."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n plex: nas\n")
add_stack_host(config, "plex", "nuc")
result = load_state(config)
assert set(result["plex"]) == {"nas", "nuc"}
def test_add_same_host_to_single_host_stack(self, config: Config) -> None:
"""Adding same host to single-host stack does nothing."""
state_file = config.get_state_path()
state_file.write_text("deployed:\n plex: nas\n")
add_stack_host(config, "plex", "nas")
result = load_state(config)
assert result["plex"] == "nas"
class TestGetOrphanedStacks:
"""Tests for get_orphaned_stacks function."""

View File

@@ -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."""

View File

@@ -26,6 +26,7 @@ nav = [
]
[project.theme]
custom_dir = "docs/overrides"
language = "en"
features = [
@@ -81,6 +82,9 @@ repo = "lucide/github"
[project.extra]
generator = false
[project.extra.analytics]
provider = "custom"
[[project.extra.social]]
icon = "fontawesome/brands/github"
link = "https://github.com/basnijholt/compose-farm"