Compare commits

...

10 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
16 changed files with 725 additions and 250 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

431
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.
╰────────────────────────────────────────────────────────────────────────────────────────
```

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

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

@@ -392,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(
@@ -418,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,
@@ -469,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
@@ -483,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(
@@ -631,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

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

@@ -253,7 +253,8 @@ async def get_containers_rows_by_host(host_name: str) -> HTMLResponse:
return HTMLResponse("")
host = config.hosts[host_name]
glances_address = _get_glances_address(host_name, host, config.glances_stack)
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, glances_address)
@@ -268,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

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

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

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

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

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