mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd1b04297e | ||
|
|
4d65702868 | ||
|
|
596a05e39d | ||
|
|
e1a8ceb9e6 | ||
|
|
ed450c65e5 | ||
|
|
0f84864a06 | ||
|
|
9c72e0937a | ||
|
|
74cc2f3245 | ||
|
|
940bd9585a | ||
|
|
dd60af61a8 | ||
|
|
2f3720949b | ||
|
|
1e3b1d71ed | ||
|
|
c159549a9e |
4
.github/workflows/update-readme.yml
vendored
4
.github/workflows/update-readme.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
439
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ services:
|
||||
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
|
||||
# Used to detect self-updates and run via SSH to survive container restart
|
||||
- CF_WEB_STACK=compose-farm
|
||||
# Local host for Glances (use container name instead of IP to avoid Docker network issues)
|
||||
- CF_LOCAL_HOST=${CF_LOCAL_HOST:-}
|
||||
# HOME must match the user running the container for SSH to find keys
|
||||
- HOME=${CF_HOME:-/root}
|
||||
# USER is required for SSH when running as non-root (UID not in /etc/passwd)
|
||||
|
||||
@@ -123,7 +123,7 @@ traefik_stack: traefik
|
||||
|
||||
### glances_stack
|
||||
|
||||
Stack name running [Glances](https://nicolargo.github.io/glances/) for host resource monitoring. When set, the web UI displays CPU, memory, and load stats for all hosts.
|
||||
Stack name running [Glances](https://nicolargo.github.io/glances/) for host resource monitoring. When set, the CLI (`cf stats --containers`) and web UI display CPU, memory, and container stats for all hosts.
|
||||
|
||||
```yaml
|
||||
glances_stack: glances
|
||||
@@ -267,6 +267,25 @@ When generating Traefik config, Compose Farm resolves `${VAR}` and `${VAR:-defau
|
||||
1. The stack's `.env` file
|
||||
2. Current environment
|
||||
|
||||
### Compose Farm Environment Variables
|
||||
|
||||
These environment variables configure Compose Farm itself:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CF_CONFIG` | Path to config file |
|
||||
| `CF_WEB_STACK` | Web UI stack name (Docker only, enables self-update detection and local host inference) |
|
||||
|
||||
**Docker deployment variables** (used in docker-compose.yml):
|
||||
|
||||
| Variable | Description | Generated by |
|
||||
|----------|-------------|--------------|
|
||||
| `CF_COMPOSE_DIR` | Compose files directory | `cf config init-env` |
|
||||
| `CF_UID` / `CF_GID` | User/group ID for containers | `cf config init-env` |
|
||||
| `CF_HOME` / `CF_USER` | Home directory and username | `cf config init-env` |
|
||||
| `CF_SSH_DIR` | SSH keys volume mount | Manual |
|
||||
| `CF_XDG_CONFIG` | Config backup volume mount | Manual |
|
||||
|
||||
## Config Commands
|
||||
|
||||
### Initialize Config
|
||||
|
||||
@@ -24,7 +24,6 @@ This auto-detects settings from your `compose-farm.yaml`:
|
||||
- `DOMAIN` from existing traefik labels
|
||||
- `CF_COMPOSE_DIR` from config
|
||||
- `CF_UID/GID/HOME/USER` from current user
|
||||
- `CF_LOCAL_HOST` by matching local IPs to config hosts
|
||||
|
||||
Review the output and edit if needed.
|
||||
|
||||
@@ -59,17 +58,12 @@ $EDITOR .env
|
||||
| `DOMAIN` | Extracted from traefik labels in your stacks |
|
||||
| `CF_COMPOSE_DIR` | From `compose_dir` in your config |
|
||||
| `CF_UID/GID/HOME/USER` | From current user (for NFS compatibility) |
|
||||
| `CF_LOCAL_HOST` | By matching local IPs to configured hosts |
|
||||
|
||||
If auto-detection fails for any value, edit the `.env` file manually.
|
||||
|
||||
### Glances Monitoring
|
||||
|
||||
To show host CPU/memory stats in the dashboard, deploy [Glances](https://nicolargo.github.io/glances/) on your hosts. If `CF_LOCAL_HOST` wasn't detected correctly, set it to your local hostname:
|
||||
|
||||
```bash
|
||||
CF_LOCAL_HOST=nas # Replace with your local host name
|
||||
```
|
||||
To show host CPU/memory stats in the dashboard, deploy [Glances](https://nicolargo.github.io/glances/) on your hosts. When running the web UI container, Compose Farm infers the local host from `CF_WEB_STACK` and uses the Glances container name for that host.
|
||||
|
||||
See [Host Resource Monitoring](https://github.com/basnijholt/compose-farm#host-resource-monitoring-glances) in the README.
|
||||
|
||||
@@ -85,15 +79,6 @@ Regenerate keys:
|
||||
docker compose run --rm cf ssh setup
|
||||
```
|
||||
|
||||
### Glances shows error for local host
|
||||
|
||||
Add your local hostname to `.env`:
|
||||
|
||||
```bash
|
||||
echo "CF_LOCAL_HOST=nas" >> .env
|
||||
docker compose restart web
|
||||
```
|
||||
|
||||
### Files created as root
|
||||
|
||||
Add the non-root variables above and restart.
|
||||
@@ -111,6 +96,6 @@ For advanced users, here's the complete reference:
|
||||
| `CF_UID` / `CF_GID` | User/group ID | `0` (root) |
|
||||
| `CF_HOME` | Home directory | `/root` |
|
||||
| `CF_USER` | Username for SSH | `root` |
|
||||
| `CF_LOCAL_HOST` | Local hostname for Glances | *(auto-detect)* |
|
||||
| `CF_WEB_STACK` | Web UI stack name (enables self-update, local host inference) | *(none)* |
|
||||
| `CF_SSH_DIR` | SSH keys directory | `~/.ssh/compose-farm` |
|
||||
| `CF_XDG_CONFIG` | Config/backup directory | `~/.config/compose-farm` |
|
||||
|
||||
@@ -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>
|
||||
@@ -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]")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -15,7 +15,7 @@ from pydantic import ValidationError
|
||||
from compose_farm.executor import is_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
from compose_farm.config import Config, Host
|
||||
|
||||
# Paths
|
||||
WEB_DIR = Path(__file__).parent
|
||||
@@ -52,8 +52,35 @@ def extract_config_error(exc: Exception) -> str:
|
||||
return str(exc)
|
||||
|
||||
|
||||
def is_local_host(host_name: str, host: Host, config: Config) -> bool:
|
||||
"""Check if a host should be treated as local.
|
||||
|
||||
When running in a Docker container, is_local() may not work correctly because
|
||||
the container has different network IPs. This function first checks if the
|
||||
host matches the web stack host (container only), then falls back to is_local().
|
||||
|
||||
This affects:
|
||||
- Container exec (local docker exec vs SSH)
|
||||
- File read/write (local filesystem vs SSH)
|
||||
- Shell sessions (local shell vs SSH)
|
||||
"""
|
||||
local_host = config.get_local_host_from_web_stack()
|
||||
if local_host and host_name == local_host:
|
||||
return True
|
||||
return is_local(host)
|
||||
|
||||
|
||||
def get_local_host(config: Config) -> str | None:
|
||||
"""Find the local host name from config, if any."""
|
||||
"""Find the local host name from config, if any.
|
||||
|
||||
First checks the web stack host (container only), then falls back to is_local()
|
||||
detection.
|
||||
"""
|
||||
# Web stack host takes precedence in container mode
|
||||
local_host = config.get_local_host_from_web_stack()
|
||||
if local_host and local_host in config.hosts:
|
||||
return local_host
|
||||
# Fall back to auto-detection
|
||||
for name, host in config.hosts.items():
|
||||
if is_local(host):
|
||||
return name
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -15,9 +14,6 @@ if TYPE_CHECKING:
|
||||
from compose_farm.web.deps import get_config
|
||||
from compose_farm.web.streaming import run_cli_streaming, run_compose_streaming, tasks
|
||||
|
||||
# Environment variable to identify the web stack (for exclusion from bulk updates)
|
||||
CF_WEB_STACK = os.environ.get("CF_WEB_STACK", "")
|
||||
|
||||
router = APIRouter(tags=["actions"])
|
||||
|
||||
# Store task references to prevent garbage collection
|
||||
@@ -107,7 +103,8 @@ async def update_all() -> dict[str, Any]:
|
||||
"""
|
||||
config = get_config()
|
||||
# Get all stacks except the web stack to avoid self-shutdown
|
||||
stacks = [s for s in config.stacks if s != CF_WEB_STACK]
|
||||
web_stack = config.get_web_stack()
|
||||
stacks = [s for s in config.stacks if s != web_stack]
|
||||
if not stacks:
|
||||
return {"task_id": "", "command": "update (no stacks)", "skipped": True}
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", *stacks], tid))
|
||||
|
||||
@@ -20,11 +20,11 @@ from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from compose_farm.compose import extract_services, get_container_name, load_compose_data_for_stack
|
||||
from compose_farm.executor import is_local, run_compose_on_host, ssh_connect_kwargs
|
||||
from compose_farm.executor import run_compose_on_host, ssh_connect_kwargs
|
||||
from compose_farm.glances import fetch_all_host_stats
|
||||
from compose_farm.paths import backup_dir, find_config_path
|
||||
from compose_farm.state import load_state
|
||||
from compose_farm.web.deps import get_config, get_templates
|
||||
from compose_farm.web.deps import get_config, get_templates, is_local_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -344,10 +344,11 @@ async def read_console_file(
|
||||
path: Annotated[str, Query(description="File path")],
|
||||
) -> dict[str, Any]:
|
||||
"""Read a file from a host for the console editor."""
|
||||
config = get_config()
|
||||
host_config = _get_console_host(host, path)
|
||||
|
||||
try:
|
||||
if is_local(host_config):
|
||||
if is_local_host(host, host_config, config):
|
||||
content = await _read_file_local(path)
|
||||
else:
|
||||
content = await _read_file_remote(host_config, path)
|
||||
@@ -368,10 +369,11 @@ async def write_console_file(
|
||||
content: Annotated[str, Body(media_type="text/plain")],
|
||||
) -> dict[str, Any]:
|
||||
"""Write a file to a host from the console editor."""
|
||||
config = get_config()
|
||||
host_config = _get_console_host(host, path)
|
||||
|
||||
try:
|
||||
if is_local(host_config):
|
||||
if is_local_host(host, host_config, config):
|
||||
saved = await _write_file_local(path, content)
|
||||
msg = f"Saved: {path}" if saved else "No changes to save"
|
||||
else:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -78,6 +78,76 @@ class TestConfig:
|
||||
# Defaults to compose.yaml when no file exists
|
||||
assert path == Path("/opt/compose/plex/compose.yaml")
|
||||
|
||||
def test_get_web_stack_returns_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_web_stack returns CF_WEB_STACK env var."""
|
||||
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
|
||||
config = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={"nas": Host(address="192.168.1.6")},
|
||||
stacks={"compose-farm": "nas"},
|
||||
)
|
||||
assert config.get_web_stack() == "compose-farm"
|
||||
|
||||
def test_get_web_stack_returns_empty_when_not_set(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""get_web_stack returns empty string when env var not set."""
|
||||
monkeypatch.delenv("CF_WEB_STACK", raising=False)
|
||||
config = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={"nas": Host(address="192.168.1.6")},
|
||||
stacks={"compose-farm": "nas"},
|
||||
)
|
||||
assert config.get_web_stack() == ""
|
||||
|
||||
def test_get_local_host_from_web_stack_returns_host(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""get_local_host_from_web_stack returns the web stack host in container."""
|
||||
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
|
||||
config = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={"nas": Host(address="192.168.1.6"), "nuc": Host(address="192.168.1.2")},
|
||||
stacks={"compose-farm": "nas"},
|
||||
)
|
||||
assert config.get_local_host_from_web_stack() == "nas"
|
||||
|
||||
def test_get_local_host_from_web_stack_returns_none_outside_container(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""get_local_host_from_web_stack returns None when not in container."""
|
||||
monkeypatch.delenv("CF_WEB_STACK", raising=False)
|
||||
config = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={"nas": Host(address="192.168.1.6")},
|
||||
stacks={"compose-farm": "nas"},
|
||||
)
|
||||
assert config.get_local_host_from_web_stack() is None
|
||||
|
||||
def test_get_local_host_from_web_stack_returns_none_for_unknown_stack(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""get_local_host_from_web_stack returns None if web stack not in stacks."""
|
||||
monkeypatch.setenv("CF_WEB_STACK", "unknown-stack")
|
||||
config = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={"nas": Host(address="192.168.1.6")},
|
||||
stacks={"plex": "nas"},
|
||||
)
|
||||
assert config.get_local_host_from_web_stack() is None
|
||||
|
||||
def test_get_local_host_from_web_stack_returns_none_for_multi_host(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""get_local_host_from_web_stack returns None if web stack runs on multiple hosts."""
|
||||
monkeypatch.setenv("CF_WEB_STACK", "compose-farm")
|
||||
config = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={"nas": Host(address="192.168.1.6"), "nuc": Host(address="192.168.1.2")},
|
||||
stacks={"compose-farm": ["nas", "nuc"]},
|
||||
)
|
||||
assert config.get_local_host_from_web_stack() is None
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Tests for load_config function."""
|
||||
|
||||
@@ -10,7 +10,6 @@ from typer.testing import CliRunner
|
||||
from compose_farm.cli import app
|
||||
from compose_farm.cli.config import (
|
||||
_detect_domain,
|
||||
_detect_local_host,
|
||||
_generate_template,
|
||||
_get_config_file,
|
||||
_get_editor,
|
||||
@@ -233,35 +232,6 @@ class TestConfigValidate:
|
||||
assert "Config file not found" in output or "not found" in output.lower()
|
||||
|
||||
|
||||
class TestDetectLocalHost:
|
||||
"""Tests for _detect_local_host function."""
|
||||
|
||||
def test_detects_localhost(self) -> None:
|
||||
cfg = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={
|
||||
"local": Host(address="localhost"),
|
||||
"remote": Host(address="192.168.1.100"),
|
||||
},
|
||||
stacks={"test": "local"},
|
||||
)
|
||||
result = _detect_local_host(cfg)
|
||||
assert result == "local"
|
||||
|
||||
def test_returns_none_for_remote_only(self) -> None:
|
||||
cfg = Config(
|
||||
compose_dir=Path("/opt/compose"),
|
||||
hosts={
|
||||
"remote1": Host(address="192.168.1.100"),
|
||||
"remote2": Host(address="192.168.1.200"),
|
||||
},
|
||||
stacks={"test": "remote1"},
|
||||
)
|
||||
result = _detect_local_host(cfg)
|
||||
# Remote IPs won't match local machine
|
||||
assert result is None or result in cfg.hosts
|
||||
|
||||
|
||||
class TestDetectDomain:
|
||||
"""Tests for _detect_domain function."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user