Compare commits

..

15 Commits

Author SHA1 Message Date
Bas Nijholt
471936439e feat(web): add Edit Config command to command palette (#105)
- Added "Edit Config" command to the command palette (Cmd/Ctrl+K)
- Navigates to console page, focuses the Monaco editor, and scrolls to it
- Uses `#editor` URL hash to signal editor focus instead of terminal focus
2025-12-21 01:24:03 -08:00
Bas Nijholt
36e4bef46d feat(web): add shell command to command palette for services (#104)
- Add "Shell: {service}" commands to the command palette when on a stack page
- Allows quick shell access to containers via `Cmd+K` → type "shell" → select service
- Add `get_container_name()` helper in `compose.py` for consistent container name resolution (used by both api.py and pages.py)
2025-12-21 01:23:54 -08:00
Bas Nijholt
2cac0bf263 feat(web): add Pull All and Update All to command palette (#103)
The dashboard buttons for Pull All and Update All are now also
available in the command palette (Cmd/Ctrl+K) for keyboard access.
2025-12-21 01:00:57 -08:00
Bas Nijholt
3d07cbdff0 fix(web): show stderr in console shell sessions (#102)
- Remove `2>/dev/null` from shell command that was suppressing all stderr output
- Command errors like "command not found" are now properly displayed to users
2025-12-21 00:50:58 -08:00
Bas Nijholt
0f67c17281 test: parallel execution and timeout constants (#101)
- Enable `-n auto` for all test commands in justfile (parallel execution)
- Add redis stack to test fixtures (missing stack was causing test failure)
- Replace hardcoded timeouts with constants: `TIMEOUT` (10s) and `SHORT_TIMEOUT` (5s)
- Rename `test-unit` → `test-cli` and `test-browser` → `test-web`
- Skip CLI startup test when running in parallel mode (`-n auto`)
- Update test assertions for 5 stacks (was 4)
2025-12-21 00:48:52 -08:00
Bas Nijholt
bd22a1a55e fix: Reject unknown keys in config with Pydantic strict mode (#100)
Add extra="forbid" to Host and Config models so typos like
`username` instead of `user` raise an error instead of being
silently ignored. Also simplify _parse_hosts to pass dicts
directly to Pydantic instead of manual field extraction.
2025-12-21 00:19:18 -08:00
Bas Nijholt
cc54e89b33 feat: add justfile for development commands (#99)
- Adds a justfile with common development commands for easier workflow
- Commands: `install`, `test`, `test-unit`, `test-browser`, `lint`, `web`, `kill-web`, `doc`, `kill-doc`, `clean`
2025-12-20 23:24:30 -08:00
Bas Nijholt
f71e5cffd6 feat(web): add service commands to command palette with fuzzy matching (#95)
- Add service-level commands to the command palette when viewing a stack detail page
- Services are extracted from the compose file and exposed via a `data-services` attribute
- Commands are grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically
- Service commands appear with a teal indicator to distinguish from stack-level commands (green)
- Implement word-boundary fuzzy matching for better filtering UX:
  - `rest plex` matches `Restart: plex-server`
  - `server` matches `plex-server` (hyphenated names split into words)
  - Query words must match the START of command words (prevents false positives like `r ba` matching `Logs: bazarr`)

Available service commands:
- `Restart: <service>` - Restart a specific service
- `Pull: <service>` - Pull image for a service
- `Logs: <service>` - View logs for a service
- `Stop: <service>` - Stop a service
- `Up: <service>` - Start a service
2025-12-20 23:23:53 -08:00
Bas Nijholt
0e32729763 fix(web): add tooltips to console page buttons (#98)
* fix(web): add tooltips to console page buttons

Add descriptive tooltips to Connect, Open, and Save buttons on the
console page, matching the tooltip style used on dashboard and stack
pages.

* fix(web): show platform-appropriate keyboard shortcuts

Detect Mac vs other platforms and display ⌘ or Ctrl accordingly for
keyboard shortcuts. The command palette FAB dynamically updates, and
tooltips use ⌘/Ctrl notation to cover both platforms.
2025-12-20 22:59:50 -08:00
Bas Nijholt
b0b501fa98 docs: update example services in documentation and tests (#96) 2025-12-20 22:45:13 -08:00
Bas Nijholt
7e00596046 docs: fix inaccuracies and add missing command documentation (#97)
- Add missing --service option docs for up, stop, restart, update, pull, ps, logs
- Add stop command to command overview table
- Add compose passthrough command documentation
- Add --all option and [STACKS] argument to refresh command
- Fix ServiceConfig reference to Host in architecture.md
- Update lifecycle.py description to include stop and compose commands
- Fix uv installation syntax in web-ui.md (--with web -> [web])
- Add missing cf ssh --help and cf web --help output blocks in README
2025-12-20 22:37:26 -08:00
Bas Nijholt
d1e4d9b05c docs: update documentation for new CLI features (#94) 2025-12-20 21:36:47 -08:00
Bas Nijholt
3fbae630f9 feat(cli): add compose passthrough command (#93)
Adds `cf compose <stack> <command> [args...]` to run any docker compose
command on a stack without needing dedicated wrappers. Useful for
commands like top, images, exec, run, config, etc.

Multi-host stacks require --host to specify which host to run on.
2025-12-20 21:26:05 -08:00
Bas Nijholt
3e3c919714 fix(web): service action buttons fixes and additions (#92)
* fix(web): use --service flag in service action endpoint

* feat(web): add Start button to service actions

* feat(web): add Pull button to service actions
2025-12-20 21:11:44 -08:00
Bas Nijholt
59b797a89d feat: add service-level commands with --service flag (#91)
Add support for targeting specific services within a stack:

CLI:
- New `stop` command for stopping services without removing containers
- Add `--service` / `-s` flag to: up, pull, restart, update, stop, logs, ps
- Service flag requires exactly one stack to be specified

Web API:
- Add `stop` to allowed stack commands
- New endpoint: POST /api/stack/{name}/service/{service}/{command}
- Supports: logs, pull, restart, up, stop

Web UI:
- Add action buttons to container rows: logs, restart, stop, shell
- Add rotate_ccw and scroll_text icons for new buttons
2025-12-20 20:56:48 -08:00
37 changed files with 1443 additions and 380 deletions

View File

@@ -54,7 +54,7 @@ jobs:
run: uv run playwright install chromium --with-deps
- name: Run browser tests
run: uv run pytest -m browser -v --no-cov
run: uv run pytest -m browser -n auto -v
lint:
runs-on: ubuntu-latest

View File

@@ -75,6 +75,30 @@ Check for conflicts between documentation files:
- Command tables match across files
- Config examples are consistent
### 8. Recent Changes Check
Before starting the review:
- Run `git log --oneline -20` to see recent commits
- Look for commits with `feat:`, `fix:`, or that mention new options/commands
- Cross-reference these against the documentation to catch undocumented features
### 9. Auto-Generated Content
For README.md or docs with `<!-- CODE:BASH:START -->` blocks:
- Run `uv run markdown-code-runner <file>` to regenerate outputs
- Check for missing `<!-- OUTPUT:START -->` markers (blocks that never ran)
- Verify help output matches current CLI behavior
### 10. CLI Options Completeness
For each command, run `cf <command> --help` and verify:
- Every option shown in help is documented
- Short flags (-x) are listed alongside long flags (--xxx)
- Default values in help match documented defaults
## Output Format
Provide findings in these categories:

View File

@@ -15,7 +15,7 @@ src/compose_farm/
│ ├── app.py # Shared Typer app instance, version callback
│ ├── common.py # Shared helpers, options, progress bar utilities
│ ├── config.py # Config subcommand (init, show, path, validate, edit, symlink)
│ ├── lifecycle.py # up, down, pull, restart, update, apply commands
│ ├── lifecycle.py # up, down, stop, pull, restart, update, apply, compose commands
│ ├── management.py # refresh, check, init-network, traefik-file commands
│ ├── monitoring.py # logs, ps, stats commands
│ ├── ssh.py # SSH key management (setup, status, keygen)
@@ -58,22 +58,37 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
- **Imports at top level**: Never add imports inside functions unless they are explicitly marked with `# noqa: PLC0415` and a comment explaining it speeds up CLI startup. Heavy modules like `pydantic`, `yaml`, and `rich.table` are lazily imported to keep `cf --help` fast.
## Development Commands
Use `just` for common tasks. Run `just` to list available commands:
| Command | Description |
|---------|-------------|
| `just install` | Install dev dependencies |
| `just test` | Run all tests |
| `just test-cli` | Run CLI tests (parallel) |
| `just test-web` | Run web UI tests (parallel) |
| `just lint` | Lint, format, and type check |
| `just web` | Start web UI (port 9001) |
| `just doc` | Build and serve docs (port 9002) |
| `just clean` | Clean build artifacts |
## Testing
Run tests with `uv run pytest`. Browser tests require Chromium (system-installed or via `playwright install chromium`):
Run tests with `just test` or `uv run pytest`. Browser tests require Chromium (system-installed or via `playwright install chromium`):
```bash
# Unit tests only (skip browser tests, can parallelize)
# Unit tests only (parallel)
uv run pytest -m "not browser" -n auto
# Browser tests only (run sequentially, no coverage)
uv run pytest -m browser --no-cov
# Browser tests only (parallel)
uv run pytest -m browser -n auto
# All tests
uv run pytest --no-cov
uv run pytest
```
Browser tests are marked with `@pytest.mark.browser`. They use Playwright to test HTMX behavior, JavaScript functionality (sidebar filter, command palette, terminals), and content stability during navigation. Run sequentially (no `-n`) to avoid resource contention.
Browser tests are marked with `@pytest.mark.browser`. They use Playwright to test HTMX behavior, JavaScript functionality (sidebar filter, command palette, terminals), and content stability during navigation.
## Communication Notes
@@ -116,10 +131,12 @@ CLI available as `cf` or `compose-farm`.
|---------|-------------|
| `up` | Start stacks (`docker compose up -d`), auto-migrates if host changed |
| `down` | Stop stacks (`docker compose down`). Use `--orphaned` to stop stacks removed from config |
| `stop` | Stop services without removing containers (`docker compose stop`) |
| `pull` | Pull latest images |
| `restart` | `down` + `up -d` |
| `update` | `pull` + `build` + `down` + `up -d` |
| `apply` | Make reality match config: migrate stacks + stop orphans. Use `--dry-run` to preview |
| `compose` | Run any docker compose command on a stack (passthrough) |
| `logs` | Show stack logs |
| `ps` | Show status of all stacks |
| `stats` | Show overview (hosts, stacks, pending migrations; `--live` for container counts) |

199
README.md
View File

@@ -270,7 +270,7 @@ hosts:
stacks:
plex: server-1
jellyfin: server-2
sonarr: server-1
grafana: server-1
# Multi-host stacks (run on multiple/all hosts)
autokuma: all # Runs on ALL configured hosts
@@ -332,7 +332,8 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias.
|---------|-------------|
| **`cf apply`** | **Make reality match config (start + migrate + stop orphans)** |
| `cf up <stack>` | Start stack (auto-migrates if host changed) |
| `cf down <stack>` | Stop stack |
| `cf down <stack>` | Stop and remove stack containers |
| `cf stop <stack>` | Stop stack without removing containers |
| `cf restart <stack>` | down + up |
| `cf update <stack>` | pull + build + down + up |
| `cf pull <stack>` | Pull latest images |
@@ -425,10 +426,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ 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 stacks (down + up).
update Update stacks (pull + build + down + up).
│ restart Restart stacks (down + up). With --service, restarts just
that service.
│ update Update stacks (pull + build + down + up). With --service, │
│ updates just that service. │
│ apply Make reality match config (start, migrate, stop as needed). │
│ compose Run any docker compose command on a stack. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Configuration ──────────────────────────────────────────────────────────────╮
│ traefik-file Generate a Traefik file-provider fragment from compose │
@@ -440,7 +446,8 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ ssh Manage SSH keys for passwordless authentication. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
│ logs Show stack logs.
│ 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. │
╰──────────────────────────────────────────────────────────────────────────────╯
@@ -479,10 +486,11 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks
│ --host -H TEXT Filter to stacks on this host
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -528,6 +536,41 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
</details>
<details>
<summary>See the output of <code>cf stop --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf stop --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf stop [OPTIONS] [STACKS]...
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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
<details>
<summary>See the output of <code>cf pull --help</code></summary>
@@ -551,9 +594,10 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -579,15 +623,16 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf restart [OPTIONS] [STACKS]...
Restart stacks (down + up).
Restart stacks (down + up). With --service, restarts just that service.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -613,15 +658,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf update [OPTIONS] [STACKS]...
Update stacks (pull + build + down + up).
Update stacks (pull + build + down + up). With --service, updates just that
service.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ stacks [STACKS]... Stacks to operate on │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all stacks
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -675,6 +722,53 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
</details>
<details>
<summary>See the output of <code>cf compose --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf compose --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf compose [OPTIONS] STACK COMMAND [ARGS]...
Run any docker compose command on a stack.
Passthrough to docker compose for commands not wrapped by cf.
Options after COMMAND are passed to docker compose, not cf.
Examples:
cf compose mystack --help - show docker compose help
cf compose mystack top - view running processes
cf compose mystack images - list images
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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
**Configuration**
<details>
@@ -890,6 +984,26 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- cf ssh --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf ssh [OPTIONS] COMMAND [ARGS]...
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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
@@ -912,19 +1026,20 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf logs [OPTIONS] [STACKS]...
Show stack logs.
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
│ --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.
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -956,15 +1071,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Without arguments: shows all stacks (same as --all).
With stack names: shows only those stacks.
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
│ --config -c PATH Path to config file
│ --help -h Show this message and exit.
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -1021,6 +1138,24 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- cf web --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf web [OPTIONS]
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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>

View File

@@ -26,5 +26,5 @@ stacks:
traefik: server-1 # Traefik runs here
plex: server-2 # Stacks on other hosts get file-provider entries
jellyfin: server-2
sonarr: server-1
radarr: local
grafana: server-1
nextcloud: local

View File

@@ -47,8 +47,7 @@ Compose Farm follows three core principles:
Pydantic models for YAML configuration:
- **Config** - Root configuration with compose_dir, hosts, stacks
- **HostConfig** - Host address and SSH user
- **ServiceConfig** - Service-to-host mappings
- **Host** - Host address, SSH user, and port
Key features:
- Validation with Pydantic
@@ -62,7 +61,7 @@ Tracks deployment state in `compose-farm-state.yaml` (stored alongside the confi
```yaml
deployed:
plex: nuc
sonarr: nuc
grafana: nuc
```
Used for:
@@ -98,7 +97,7 @@ cli/
├── app.py # Shared Typer app, version callback
├── common.py # Shared helpers, options, progress utilities
├── config.py # config subcommand (init, show, path, validate, edit, symlink)
├── lifecycle.py # up, down, pull, restart, update, apply
├── lifecycle.py # up, down, stop, pull, restart, update, apply, compose
├── management.py # refresh, check, init-network, traefik-file
├── monitoring.py # logs, ps, stats
├── ssh.py # SSH key management (setup, status, keygen)
@@ -208,7 +207,7 @@ Location: `compose-farm-state.yaml` (stored alongside the config file)
```yaml
deployed:
plex: nuc
sonarr: nuc
grafana: nuc
```
Image digests are stored separately in `dockerfarm-log.toml` (also in the config directory).

View File

@@ -221,7 +221,7 @@ Keep config and data separate:
/opt/appdata/ # Local: per-host app data
├── plex/
└── sonarr/
└── grafana/
```
## Performance
@@ -235,7 +235,7 @@ Compose Farm runs operations in parallel. For large deployments:
cf up --all
# Avoid: sequential updates when possible
for svc in plex sonarr radarr; do
for svc in plex grafana nextcloud; do
cf update $svc
done
```
@@ -249,7 +249,7 @@ SSH connections are reused within a command. For many operations:
cf update --all
# Multiple commands, multiple connections (slower)
cf update plex && cf update sonarr && cf update radarr
cf update plex && cf update grafana && cf update nextcloud
```
## Traefik Setup
@@ -297,7 +297,7 @@ http:
|------|----------|--------|
| Compose Farm config | `~/.config/compose-farm/` | Git or copy |
| Compose files | `/opt/compose/` | Git |
| State file | `~/.config/compose-farm/state.yaml` | Optional (can refresh) |
| State file | `~/.config/compose-farm/compose-farm-state.yaml` | Optional (can refresh) |
| App data | `/opt/appdata/` | Backup solution |
### Disaster Recovery

View File

@@ -13,9 +13,11 @@ The Compose Farm CLI is available as both `compose-farm` and the shorter alias `
| **Lifecycle** | `apply` | Make reality match config |
| | `up` | Start stacks |
| | `down` | Stop stacks |
| | `stop` | Stop services without removing containers |
| | `restart` | Restart stacks (down + up) |
| | `update` | Update stacks (pull + build + down + up) |
| | `pull` | Pull latest images |
| | `compose` | Run any docker compose command |
| **Monitoring** | `ps` | Show stack status |
| | `logs` | Show stack logs |
| | `stats` | Show overview statistics |
@@ -97,19 +99,23 @@ cf up [OPTIONS] [STACKS]...
|--------|-------------|
| `--all, -a` | Start 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 |
**Examples:**
```bash
# Start specific stacks
cf up plex sonarr
cf up plex grafana
# Start all stacks
cf up --all
# Start all stacks on a specific host
cf up --all --host nuc
# Start a specific service within a stack
cf up immich --service database
```
**Auto-migration:**
@@ -158,9 +164,40 @@ cf down --all --host nuc
---
### cf stop
Stop services without removing containers.
```bash
cf stop [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--all, -a` | Stop all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Stop specific stacks
cf stop plex
# Stop all stacks
cf stop --all
# Stop a specific service within a stack
cf stop immich --service database
```
---
### cf restart
Restart stacks (down + up).
Restart stacks (down + up). With `--service`, restarts just that service.
```bash
cf restart [OPTIONS] [STACKS]...
@@ -171,6 +208,7 @@ cf restart [OPTIONS] [STACKS]...
| Option | Description |
|--------|-------------|
| `--all, -a` | Restart all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
@@ -178,13 +216,16 @@ cf restart [OPTIONS] [STACKS]...
```bash
cf restart plex
cf restart --all
# Restart a specific service
cf restart immich --service database
```
---
### cf update
Update stacks (pull + build + down + up).
Update stacks (pull + build + down + up). With `--service`, updates just that service.
<video autoplay loop muted playsinline>
<source src="/assets/update.webm" type="video/webm">
@@ -199,6 +240,7 @@ cf update [OPTIONS] [STACKS]...
| Option | Description |
|--------|-------------|
| `--all, -a` | Update all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
@@ -209,6 +251,9 @@ cf update plex
# Update all stacks
cf update --all
# Update a specific service
cf update immich --service database
```
---
@@ -226,6 +271,7 @@ cf pull [OPTIONS] [STACKS]...
| Option | Description |
|--------|-------------|
| `--all, -a` | Pull for all stacks |
| `--service, -s TEXT` | Target a specific service within the stack |
| `--config, -c PATH` | Path to config file |
**Examples:**
@@ -233,6 +279,56 @@ cf pull [OPTIONS] [STACKS]...
```bash
cf pull plex
cf pull --all
# Pull a specific service
cf pull immich --service database
```
---
### cf compose
Run any docker compose command on a stack. This is a passthrough to docker compose for commands not wrapped by cf.
```bash
cf compose [OPTIONS] STACK COMMAND [ARGS]...
```
**Arguments:**
| Argument | Description |
|----------|-------------|
| `STACK` | Stack to operate on (use `.` for current dir) |
| `COMMAND` | Docker compose command to run |
| `ARGS` | Additional arguments passed to docker compose |
**Options:**
| Option | Description |
|--------|-------------|
| `--host, -H TEXT` | Filter to stacks on this host (required for multi-host stacks) |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Show docker compose help
cf compose mystack --help
# View running processes
cf compose mystack top
# List images
cf compose mystack images
# Interactive shell
cf compose mystack exec web bash
# View parsed config
cf compose mystack config
# Use current directory as stack
cf compose . ps
```
---
@@ -253,6 +349,7 @@ cf ps [OPTIONS] [STACKS]...
|--------|-------------|
| `--all, -a` | Show all stacks (default) |
| `--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 |
**Examples:**
@@ -262,10 +359,13 @@ cf ps [OPTIONS] [STACKS]...
cf ps
# Show specific stacks
cf ps plex sonarr
cf ps plex grafana
# Filter by host
cf ps --host nuc
# Show status of a specific service
cf ps immich --service database
```
---
@@ -288,6 +388,7 @@ cf logs [OPTIONS] [STACKS]...
|--------|-------------|
| `--all, -a` | Show logs for 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 (live stream) |
| `--tail, -n INTEGER` | Number of lines (default: 20 for --all, 100 otherwise) |
| `--config, -c PATH` | Path to config file |
@@ -302,10 +403,13 @@ cf logs plex
cf logs -f plex
# Show last 50 lines of multiple stacks
cf logs -n 50 plex sonarr
cf logs -n 50 plex grafana
# Show last 20 lines of all stacks
cf logs --all
# Show logs for a specific service
cf logs immich --service database
```
---
@@ -374,25 +478,31 @@ cf check jellyfin
Update local state from running stacks.
```bash
cf refresh [OPTIONS]
cf refresh [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--all, -a` | Refresh all stacks |
| `--dry-run, -n` | Show what would change |
| `--log-path, -l PATH` | Path to Dockerfarm TOML log |
| `--config, -c PATH` | Path to config file |
Without arguments, refreshes all stacks (same as `--all`). With stack names, refreshes only those stacks.
**Examples:**
```bash
# Sync state with reality
# Sync state with reality (all stacks)
cf refresh
# Preview changes
cf refresh --dry-run
# Refresh specific stacks only
cf refresh plex sonarr
```
---

View File

@@ -42,8 +42,8 @@ hosts:
# Map stacks to the local host
stacks:
plex: local
sonarr: local
radarr: local
grafana: local
nextcloud: local
```
### Multi-host (full example)
@@ -69,8 +69,8 @@ hosts:
stacks:
# Single-host stacks
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
# Multi-host stacks
dozzle: all # Run on ALL hosts
@@ -94,7 +94,7 @@ compose_dir: /opt/compose
├── plex/
│ ├── docker-compose.yml # or compose.yaml
│ └── .env # optional environment file
├── sonarr/
├── grafana/
│ └── docker-compose.yml
└── ...
```
@@ -185,8 +185,8 @@ hosts:
```yaml
stacks:
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
```
### Multi-Host Stack
@@ -229,7 +229,7 @@ For example, if your config is at `~/.config/compose-farm/compose-farm.yaml`, th
```yaml
deployed:
plex: nuc
sonarr: nuc
grafana: nuc
```
This file records which stacks are deployed and on which host.
@@ -373,8 +373,8 @@ hosts:
stacks:
# Media
plex: nuc
sonarr: nuc
radarr: nuc
jellyfin: nuc
immich: nuc
# Infrastructure
traefik: nuc
@@ -388,7 +388,6 @@ stacks:
```yaml
compose_dir: /opt/compose
network: production
traefik_file: /opt/traefik/dynamic.d/cf.yml
traefik_stack: traefik

View File

@@ -111,9 +111,9 @@ nas:/volume1/compose /opt/compose nfs defaults 0 0
/opt/compose/ # compose_dir in config
├── plex/
│ └── docker-compose.yml
├── sonarr/
├── grafana/
│ └── docker-compose.yml
├── radarr/
├── nextcloud/
│ └── docker-compose.yml
└── jellyfin/
└── docker-compose.yml
@@ -150,8 +150,8 @@ hosts:
stacks:
plex: local
sonarr: local
radarr: local
grafana: local
nextcloud: local
```
#### Multi-host example
@@ -171,8 +171,8 @@ hosts:
# Map stacks to hosts
stacks:
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
```
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file.
@@ -211,7 +211,7 @@ Starts all stacks on their assigned hosts.
### Start Specific Stacks
```bash
cf up plex sonarr
cf up plex grafana
```
### Apply Configuration
@@ -250,19 +250,22 @@ Create the compose file:
```bash
# On any host (shared storage)
mkdir -p /opt/compose/prowlarr
cat > /opt/compose/prowlarr/docker-compose.yml << 'EOF'
mkdir -p /opt/compose/gitea
cat > /opt/compose/gitea/docker-compose.yml << 'EOF'
services:
prowlarr:
image: lscr.io/linuxserver/prowlarr:latest
container_name: prowlarr
gitea:
image: docker.gitea.com/gitea:latest
container_name: gitea
environment:
- PUID=1000
- PGID=1000
- USER_UID=1000
- USER_GID=1000
volumes:
- /opt/config/prowlarr:/config
- /opt/config/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "9696:9696"
- "3000:3000"
- "2222:22"
restart: unless-stopped
EOF
```
@@ -272,13 +275,13 @@ Add to config:
```yaml
stacks:
# ... existing stacks
prowlarr: nuc
gitea: nuc
```
Start the stack:
```bash
cf up prowlarr
cf up gitea
```
### 2. Move a Stack to Another Host

View File

@@ -76,7 +76,7 @@ hosts:
stacks:
plex: server-1
jellyfin: server-2
sonarr: server-1
grafana: server-1
```
```bash
@@ -110,8 +110,8 @@ hosts:
stacks:
plex: nuc
sonarr: nuc
radarr: hp
grafana: nuc
nextcloud: hp
```
See [Configuration](configuration.md) for all options and the full search order.
@@ -123,7 +123,7 @@ See [Configuration](configuration.md) for all options and the full search order.
cf apply
# Start specific stacks
cf up plex sonarr
cf up plex grafana
# Check status
cf ps

View File

@@ -27,8 +27,8 @@ hosts:
stacks:
plex: nuc
jellyfin: hp
sonarr: nuc
radarr: nuc
grafana: nuc
nextcloud: nuc
```
Then just:

View File

@@ -133,7 +133,7 @@ hosts:
stacks:
traefik: nuc # Traefik runs here
plex: hp # Routed via file-provider
sonarr: hp
grafana: hp
```
With `traefik_file` set, these commands auto-regenerate the config:
@@ -256,8 +256,8 @@ stacks:
traefik: nuc
plex: hp
jellyfin: nas
sonarr: nuc
radarr: nuc
grafana: nuc
nextcloud: nuc
```
### /opt/compose/plex/docker-compose.yml
@@ -309,7 +309,7 @@ http:
- url: http://192.168.1.100:8096
```
Note: `sonarr` and `radarr` are NOT in the file because they're on the same host as Traefik (`nuc`).
Note: `grafana` and `nextcloud` are NOT in the file because they're on the same host as Traefik (`nuc`).
## Combining with Existing Config

View File

@@ -116,7 +116,7 @@ The web UI requires additional dependencies:
pip install compose-farm[web]
# If installed via uv
uv tool install compose-farm --with web
uv tool install 'compose-farm[web]'
```
## Architecture

52
justfile Normal file
View File

@@ -0,0 +1,52 @@
# Compose Farm Development Commands
# Run `just` to see available commands
# Default: list available commands
default:
@just --list
# Install development dependencies
install:
uv sync --all-extras --dev
# Run all tests (parallel)
test:
uv run pytest -n auto
# Run CLI tests only (parallel, with coverage)
test-cli:
uv run pytest -m "not browser" -n auto
# Run web UI tests only (parallel)
test-web:
uv run pytest -m browser -n auto
# Lint, format, and type check
lint:
uv run ruff check --fix .
uv run ruff format .
uv run mypy src
uv run ty check src
# Start web UI in development mode with auto-reload
web:
uv run cf web --reload --port 9001
# Kill the web server
kill-web:
lsof -ti :9001 | xargs kill -9 2>/dev/null || true
# Build docs and serve locally
doc:
uvx zensical build
python -m http.server -d site 9002
# Kill the docs server
kill-doc:
lsof -ti :9002 | xargs kill -9 2>/dev/null || true
# Clean up build artifacts and caches
clean:
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true

View File

@@ -23,6 +23,7 @@ app = typer.Typer(
help="Compose Farm - run docker compose commands across multiple hosts",
no_args_is_help=True,
context_settings={"help_option_names": ["-h", "--help"]},
rich_markup_mode="rich",
)

View File

@@ -59,6 +59,10 @@ HostOption = Annotated[
str | None,
typer.Option("--host", "-H", help="Filter to stacks on this host"),
]
ServiceOption = Annotated[
str | None,
typer.Option("--service", "-s", help="Target a specific service within the stack"),
]
# --- Constants (internal) ---
_MISSING_PATH_PREVIEW_LIMIT = 2

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated
import typer
@@ -11,6 +12,7 @@ from compose_farm.cli.common import (
AllOption,
ConfigOption,
HostOption,
ServiceOption,
StacksArg,
format_host,
get_stacks,
@@ -18,9 +20,11 @@ from compose_farm.cli.common import (
maybe_regenerate_traefik,
report_results,
run_async,
validate_host_for_stack,
validate_stacks,
)
from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success
from compose_farm.executor import run_on_stacks, run_sequential_on_stacks
from compose_farm.executor import run_compose_on_host, run_on_stacks, run_sequential_on_stacks
from compose_farm.operations import stop_orphaned_stacks, up_stacks
from compose_farm.state import (
get_orphaned_stacks,
@@ -36,11 +40,19 @@ def up(
stacks: StacksArg = None,
all_stacks: AllOption = False,
host: HostOption = None,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Start stacks (docker compose up -d). Auto-migrates if host changed."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
results = run_async(up_stacks(cfg, stack_list, raw=True))
if service:
if len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
# For service-level up, use run_on_stacks directly (no migration logic)
results = run_async(run_on_stacks(cfg, stack_list, f"up -d {service}", raw=True))
else:
results = run_async(up_stacks(cfg, stack_list, raw=True))
maybe_regenerate_traefik(cfg, results)
report_results(results)
@@ -98,16 +110,39 @@ def down(
report_results(results)
@app.command(rich_help_panel="Lifecycle")
def stop(
stacks: StacksArg = None,
all_stacks: AllOption = False,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Stop services without removing containers (docker compose stop)."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
if service and len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
cmd = f"stop {service}" if service else "stop"
raw = len(stack_list) == 1
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
report_results(results)
@app.command(rich_help_panel="Lifecycle")
def pull(
stacks: StacksArg = None,
all_stacks: AllOption = False,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Pull latest images (docker compose pull)."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
if service and len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
cmd = f"pull {service}" if service else "pull"
raw = len(stack_list) == 1
results = run_async(run_on_stacks(cfg, stack_list, "pull", raw=raw))
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
report_results(results)
@@ -115,12 +150,21 @@ def pull(
def restart(
stacks: StacksArg = None,
all_stacks: AllOption = False,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Restart stacks (down + up)."""
"""Restart stacks (down + up). With --service, restarts just that service."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
raw = len(stack_list) == 1
results = run_async(run_sequential_on_stacks(cfg, stack_list, ["down", "up -d"], raw=raw))
if service:
if len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
# For service-level restart, use docker compose restart (more efficient)
raw = True
results = run_async(run_on_stacks(cfg, stack_list, f"restart {service}", raw=raw))
else:
raw = len(stack_list) == 1
results = run_async(run_sequential_on_stacks(cfg, stack_list, ["down", "up -d"], raw=raw))
maybe_regenerate_traefik(cfg, results)
report_results(results)
@@ -129,16 +173,37 @@ def restart(
def update(
stacks: StacksArg = None,
all_stacks: AllOption = False,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Update stacks (pull + build + down + up)."""
"""Update stacks (pull + build + down + up). With --service, updates just that service."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
raw = len(stack_list) == 1
results = run_async(
run_sequential_on_stacks(
cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
if service:
if len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
# For service-level update: pull + build + stop + up (stop instead of down)
raw = True
results = run_async(
run_sequential_on_stacks(
cfg,
stack_list,
[
f"pull --ignore-buildable {service}",
f"build {service}",
f"stop {service}",
f"up -d {service}",
],
raw=raw,
)
)
else:
raw = len(stack_list) == 1
results = run_async(
run_sequential_on_stacks(
cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
)
)
)
maybe_regenerate_traefik(cfg, results)
report_results(results)
@@ -247,5 +312,62 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
report_results(all_results)
@app.command(
rich_help_panel="Lifecycle",
context_settings={"allow_interspersed_args": False},
)
def compose(
stack: Annotated[str, typer.Argument(help="Stack to operate on (use '.' for current dir)")],
command: Annotated[str, typer.Argument(help="Docker compose command")],
args: Annotated[list[str] | None, typer.Argument(help="Additional arguments")] = None,
host: HostOption = None,
config: ConfigOption = None,
) -> None:
"""Run any docker compose command on a stack.
Passthrough to docker compose for commands not wrapped by cf.
Options after COMMAND are passed to docker compose, not cf.
Examples:
cf compose mystack --help - show docker compose help
cf compose mystack top - view running processes
cf compose mystack images - list images
cf compose mystack exec web bash - interactive shell
cf compose mystack config - view parsed config
"""
cfg = load_config_or_exit(config)
# Resolve "." to current directory name
resolved_stack = Path.cwd().name if stack == "." else stack
validate_stacks(cfg, [resolved_stack])
# Handle multi-host stacks
hosts = cfg.get_hosts(resolved_stack)
if len(hosts) > 1:
if host is None:
print_error(
f"Stack [cyan]{resolved_stack}[/] runs on multiple hosts: {', '.join(hosts)}\n"
f"Use [bold]--host[/] to specify which host"
)
raise typer.Exit(1)
validate_host_for_stack(cfg, resolved_stack, host)
target_host = host
else:
target_host = hosts[0]
# Build the full compose command
full_cmd = command
if args:
full_cmd += " " + " ".join(args)
# Run with raw=True for proper TTY handling (progress bars, interactive)
result = run_async(run_compose_on_host(cfg, resolved_stack, target_host, full_cmd, raw=True))
print() # Ensure newline after raw output
if not result.success:
raise typer.Exit(result.exit_code)
# Alias: cf a = cf apply
app.command("a", hidden=True)(apply)

View File

@@ -14,6 +14,7 @@ from compose_farm.cli.common import (
AllOption,
ConfigOption,
HostOption,
ServiceOption,
StacksArg,
get_stacks,
load_config_or_exit,
@@ -21,7 +22,7 @@ from compose_farm.cli.common import (
run_async,
run_parallel_with_progress,
)
from compose_farm.console import console
from compose_farm.console import console, print_error
from compose_farm.executor import run_command, run_on_stacks
from compose_farm.state import get_stacks_needing_migration, group_stacks_by_host, load_state
@@ -118,6 +119,7 @@ def logs(
stacks: StacksArg = None,
all_stacks: AllOption = False,
host: HostOption = None,
service: ServiceOption = None,
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs")] = False,
tail: Annotated[
int | None,
@@ -125,8 +127,11 @@ def logs(
] = None,
config: ConfigOption = None,
) -> None:
"""Show stack logs."""
"""Show stack logs. With --service, shows logs for just that service."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
if service and len(stack_list) != 1:
print_error("--service requires exactly one stack")
raise typer.Exit(1)
# Default to fewer lines when showing multiple stacks
many_stacks = all_stacks or host is not None or len(stack_list) > 1
@@ -134,6 +139,8 @@ def logs(
cmd = f"logs --tail {effective_tail}"
if follow:
cmd += " -f"
if service:
cmd += f" {service}"
results = run_async(run_on_stacks(cfg, stack_list, cmd))
report_results(results)
@@ -143,6 +150,7 @@ def ps(
stacks: StacksArg = None,
all_stacks: AllOption = False,
host: HostOption = None,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Show status of stacks.
@@ -150,9 +158,14 @@ def ps(
Without arguments: shows all stacks (same as --all).
With stack names: shows only those stacks.
With --host: shows stacks on that host.
With --service: filters to a specific service within the stack.
"""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host, default_all=True)
results = run_async(run_on_stacks(cfg, stack_list, "ps"))
if service and len(stack_list) != 1:
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))
report_results(results)

View File

@@ -336,3 +336,18 @@ def get_ports_for_service(
if isinstance(ref_def, dict):
return _parse_ports(ref_def.get("ports"), env)
return _parse_ports(definition.get("ports"), env)
def get_container_name(
service_name: str,
service_def: dict[str, Any] | None,
project_name: str,
) -> str:
"""Get the container name for a service.
Uses container_name from compose if set, otherwise defaults to {project}-{service}-1.
This matches Docker Compose's default naming convention.
"""
if isinstance(service_def, dict) and service_def.get("container_name"):
return str(service_def["container_name"])
return f"{project_name}-{service_name}-1"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import getpass
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, Field, model_validator
@@ -14,7 +15,7 @@ from .paths import config_search_paths, find_config_path
COMPOSE_FILENAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
class Host(BaseModel):
class Host(BaseModel, extra="forbid"):
"""SSH host configuration."""
address: str
@@ -22,7 +23,7 @@ class Host(BaseModel):
port: int = 22
class Config(BaseModel):
class Config(BaseModel, extra="forbid"):
"""Main configuration."""
compose_dir: Path = Path("/opt/compose")
@@ -113,7 +114,7 @@ class Config(BaseModel):
return found
def _parse_hosts(raw_hosts: dict[str, str | dict[str, str | int]]) -> dict[str, Host]:
def _parse_hosts(raw_hosts: dict[str, Any]) -> dict[str, Host]:
"""Parse hosts from config, handling both simple and full forms."""
hosts = {}
for name, value in raw_hosts.items():
@@ -122,11 +123,7 @@ def _parse_hosts(raw_hosts: dict[str, str | dict[str, str | int]]) -> dict[str,
hosts[name] = Host(address=value)
else:
# Full form: hostname: {address: ..., user: ..., port: ...}
hosts[name] = Host(
address=str(value.get("address", "")),
user=str(value["user"]) if "user" in value else getpass.getuser(),
port=int(value["port"]) if "port" in value else 22,
)
hosts[name] = Host(**value)
return hosts

View File

@@ -33,12 +33,15 @@ def _start_task(coro_factory: Callable[[str], Coroutine[Any, Any, None]]) -> str
# Allowed stack commands
ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs"}
ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs", "stop"}
# Allowed service-level commands (no 'down' - use 'stop' for individual services)
ALLOWED_SERVICE_COMMANDS = {"logs", "pull", "restart", "up", "stop"}
@router.post("/stack/{name}/{command}")
async def stack_action(name: str, command: str) -> dict[str, Any]:
"""Run a compose command for a stack (up, down, restart, pull, update, logs)."""
"""Run a compose command for a stack (up, down, restart, pull, update, logs, stop)."""
if command not in ALLOWED_COMMANDS:
raise HTTPException(status_code=404, detail=f"Unknown command '{command}'")
@@ -50,6 +53,23 @@ async def stack_action(name: str, command: str) -> dict[str, Any]:
return {"task_id": task_id, "stack": name, "command": command}
@router.post("/stack/{name}/service/{service}/{command}")
async def service_action(name: str, service: str, command: str) -> dict[str, Any]:
"""Run a compose command for a specific service within a stack."""
if command not in ALLOWED_SERVICE_COMMANDS:
raise HTTPException(status_code=404, detail=f"Unknown command '{command}'")
config = get_config()
if name not in config.stacks:
raise HTTPException(status_code=404, detail=f"Stack '{name}' not found")
# Use --service flag to target specific service
task_id = _start_task(
lambda tid: run_compose_streaming(config, name, f"{command} --service {service}", tid)
)
return {"task_id": task_id, "stack": name, "service": service, "command": command}
@router.post("/apply")
async def apply_all() -> dict[str, Any]:
"""Run cf apply to reconcile all stacks."""

View File

@@ -19,6 +19,7 @@ import yaml
from fastapi import APIRouter, Body, HTTPException, Query
from fastapi.responses import HTMLResponse
from compose_farm.compose import get_container_name
from compose_farm.executor import is_local, run_compose_on_host, ssh_connect_kwargs
from compose_farm.paths import find_config_path
from compose_farm.state import load_state
@@ -116,14 +117,9 @@ def _get_compose_services(config: Any, stack: str, hosts: list[str]) -> list[dic
containers = []
for host in hosts:
for svc_name, svc_def in raw_services.items():
# Use container_name if set, otherwise default to {project}-{service}-1
if isinstance(svc_def, dict) and svc_def.get("container_name"):
container_name = svc_def["container_name"]
else:
container_name = f"{project_name}-{svc_name}-1"
containers.append(
{
"Name": container_name,
"Name": get_container_name(svc_name, svc_def, project_name),
"Service": svc_name,
"Host": host,
"State": "unknown", # Status requires Docker query

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from pydantic import ValidationError
from compose_farm.compose import get_container_name
from compose_farm.paths import find_config_path
from compose_farm.state import (
get_orphaned_stacks,
@@ -159,6 +160,26 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
# Get state
current_host = get_stack_host(config, name)
# Get service names and container info from compose file
services: list[str] = []
containers: dict[str, dict[str, str]] = {}
shell_host = current_host[0] if isinstance(current_host, list) else current_host
if compose_content:
compose_data = yaml.safe_load(compose_content) or {}
raw_services = compose_data.get("services", {})
if isinstance(raw_services, dict):
services = list(raw_services.keys())
# Build container info for shell access (only if stack is running)
if shell_host:
project_name = compose_path.parent.name if compose_path else name
containers = {
svc: {
"container": get_container_name(svc, svc_def, project_name),
"host": shell_host,
}
for svc, svc_def in raw_services.items()
}
return templates.TemplateResponse(
"stack.html",
{
@@ -170,6 +191,8 @@ async def stack_detail(request: Request, name: str) -> HTMLResponse:
"compose_path": str(compose_path) if compose_path else None,
"env_content": env_content,
"env_path": str(env_path) if env_path else None,
"services": services,
"containers": containers,
},
)

View File

@@ -57,6 +57,10 @@ const LANGUAGE_MAP = {
'env': 'plaintext'
};
// Detect Mac for keyboard shortcut display
const IS_MAC = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const MOD_KEY = IS_MAC ? '⌘' : 'Ctrl';
// ============================================================================
// STATE
// ============================================================================
@@ -512,16 +516,22 @@ function playFabIntro() {
const THEMES = ['light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade', 'night', 'coffee', 'winter', 'dim', 'nord', 'sunset', 'caramellatte', 'abyss', 'silk'];
const THEME_KEY = 'cf_theme';
const colors = { stack: '#22c55e', action: '#eab308', nav: '#3b82f6', app: '#a855f7', theme: '#ec4899' };
const colors = { stack: '#22c55e', action: '#eab308', nav: '#3b82f6', app: '#a855f7', theme: '#ec4899', service: '#14b8a6' };
let commands = [];
let filtered = [];
let selected = 0;
let originalTheme = null; // Store theme when palette opens for preview/restore
const post = (url) => () => htmx.ajax('POST', url, {swap: 'none'});
const nav = (url) => () => {
const nav = (url, afterNav) => () => {
// Set hash before HTMX swap so inline scripts can read it
const hashIndex = url.indexOf('#');
if (hashIndex !== -1) {
window.location.hash = url.substring(hashIndex);
}
htmx.ajax('GET', url, {target: '#main-content', select: '#main-content', swap: 'outerHTML'}).then(() => {
history.pushState({}, '', url);
afterNav?.();
});
};
// Navigate to dashboard (if needed) and trigger action
@@ -564,9 +574,12 @@ function playFabIntro() {
const actions = [
cmd('action', 'Apply', 'Make reality match config', dashboardAction('apply'), icons.check),
cmd('action', 'Refresh', 'Update state from reality', dashboardAction('refresh'), icons.refresh_cw),
cmd('action', 'Pull All', 'Pull latest images for all stacks', dashboardAction('pull-all'), icons.cloud_download),
cmd('action', 'Update All', 'Update all stacks', dashboardAction('update-all'), icons.refresh_cw),
cmd('app', 'Theme', 'Change color theme', openThemePicker, icons.palette),
cmd('app', 'Dashboard', 'Go to dashboard', nav('/'), icons.home),
cmd('app', 'Console', 'Go to console', nav('/console'), icons.terminal),
cmd('app', 'Edit Config', 'Edit compose-farm.yaml', nav('/console#editor'), icons.file_code),
cmd('app', 'Docs', 'Open documentation', openExternal('https://compose-farm.nijho.lt/'), icons.book_open),
];
@@ -583,6 +596,39 @@ function playFabIntro() {
stackCmd('Update', 'Pull + restart', 'update', icons.refresh_cw),
stackCmd('Logs', 'View logs for', 'logs', icons.file_text),
);
// Add service-specific commands from data-services and data-containers attributes
// Grouped by action (all Logs together, all Pull together, etc.) with services sorted alphabetically
const servicesAttr = document.querySelector('[data-services]')?.getAttribute('data-services');
const containersAttr = document.querySelector('[data-containers]')?.getAttribute('data-containers');
if (servicesAttr) {
const services = servicesAttr.split(',').filter(s => s).sort();
// Parse container info for shell access: {service: {container, host}}
const containers = containersAttr ? JSON.parse(containersAttr) : {};
const svcCmd = (action, service, desc, endpoint, icon) =>
cmd('service', `${action}: ${service}`, desc, post(`/api/stack/${stack}/service/${service}/${endpoint}`), icon);
const svcActions = [
['Logs', 'View logs for service', 'logs', icons.file_text],
['Pull', 'Pull image for service', 'pull', icons.cloud_download],
['Restart', 'Restart service', 'restart', icons.rotate_cw],
['Stop', 'Stop service', 'stop', icons.square],
['Up', 'Start service', 'up', icons.play],
];
for (const [action, desc, endpoint, icon] of svcActions) {
for (const service of services) {
actions.push(svcCmd(action, service, desc, endpoint, icon));
}
}
// Add Shell commands if container info is available
for (const service of services) {
const info = containers[service];
if (info?.container && info?.host) {
actions.push(cmd('service', `Shell: ${service}`, 'Open interactive shell',
() => initExecTerminal(stack, info.container, info.host), icons.terminal));
}
}
}
}
// Add nav commands for all stacks from sidebar
@@ -601,10 +647,21 @@ function playFabIntro() {
}
function filter() {
// Normalize: collapse spaces and ensure space after colon for matching
// This allows "theme:dark", "theme: dark", "theme: dark" to all match "theme: dark"
const q = input.value.toLowerCase().replace(/\s+/g, ' ').replace(/:(\S)/g, ': $1');
filtered = commands.filter(c => c.name.toLowerCase().includes(q));
// Fuzzy matching: all query words must match the START of a word in the command name
// Examples: "r ba" matches "Restart: bazarr" but NOT "Logs: bazarr"
const q = input.value.toLowerCase().trim();
// Split query into words and strip non-alphanumeric chars
const queryWords = q.split(/[^a-z0-9]+/).filter(w => w);
filtered = commands.filter(c => {
const name = c.name.toLowerCase();
// Split command name into words (split on non-alphanumeric)
const nameWords = name.split(/[^a-z0-9]+/).filter(w => w);
// Each query word must match the start of some word in the command name
return queryWords.every(qw =>
nameWords.some(nw => nw.startsWith(qw))
);
});
selected = Math.max(0, Math.min(selected, filtered.length - 1));
}
@@ -751,12 +808,26 @@ function initKeyboardShortcuts() {
});
}
/**
* Update keyboard shortcut display based on OS
* Replaces ⌘ with Ctrl on non-Mac platforms
*/
function updateShortcutKeys() {
// Update elements with class 'shortcut-key' that contain ⌘
document.querySelectorAll('.shortcut-key').forEach(el => {
if (el.textContent === '⌘') {
el.textContent = MOD_KEY;
}
});
}
/**
* Initialize page components
*/
function initPage() {
initMonacoEditors();
initSaveButton();
updateShortcutKeys();
}
/**

View File

@@ -15,7 +15,7 @@
<option value="{{ name }}">{{ name }}{% if name == local_host %} (local){% endif %}</option>
{% endfor %}
</select>
<button id="console-connect-btn" class="btn btn-sm btn-primary" onclick="connectConsole()">Connect</button>
<div class="tooltip" data-tip="Connect to host via SSH"><button id="console-connect-btn" class="btn btn-sm btn-primary" onclick="connectConsole()">Connect</button></div>
<span id="console-status" class="text-sm opacity-60"></span>
</div>
@@ -29,11 +29,11 @@
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-4">
<input type="text" id="console-file-path" class="input input-sm input-bordered w-96" placeholder="Enter file path (e.g., ~/docker-compose.yaml)" value="{{ config_path }}">
<button class="btn btn-sm btn-outline" onclick="loadFile()">Open</button>
<div class="tooltip" data-tip="Load file from host"><button class="btn btn-sm btn-outline" onclick="loadFile()">Open</button></div>
</div>
<div class="flex items-center gap-2">
<span id="editor-status" class="text-sm opacity-60"></span>
<button id="console-save-btn" class="btn btn-sm btn-primary" onclick="saveFile()">{{ save() }} Save</button>
<div class="tooltip" data-tip="Save file to host (⌘/Ctrl+S)"><button id="console-save-btn" class="btn btn-sm btn-primary" onclick="saveFile()">{{ save() }} Save</button></div>
</div>
</div>
<div id="console-editor" class="resize-y overflow-hidden rounded-lg" style="height: 512px; min-height: 200px;"></div>
@@ -97,7 +97,10 @@ function connectConsole() {
consoleWs.onopen = () => {
statusEl.textContent = `Connected to ${host}`;
sendSize(term.cols, term.rows);
term.focus();
// Focus terminal unless #editor hash is present (command palette Edit Config)
if (window.location.hash !== '#editor') {
term.focus();
}
// Auto-load the default file once editor is ready
const pathInput = document.getElementById('console-file-path');
if (pathInput && pathInput.value) {
@@ -133,6 +136,14 @@ function initConsoleEditor() {
loadMonaco(() => {
consoleEditor = createEditor(editorEl, '', 'plaintext', { onSave: saveFile });
// Focus editor if #editor hash is present (command palette Edit Config)
if (window.location.hash === '#editor') {
// Small delay for Monaco to fully initialize before focusing
setTimeout(() => {
consoleEditor.focus();
editorEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
});
}

View File

@@ -1,4 +1,4 @@
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, check, home, terminal, box, palette, book_open %}
{% from "partials/icons.html" import search, play, square, rotate_cw, cloud_download, refresh_cw, file_text, file_code, check, home, terminal, box, palette, book_open %}
<!-- Icons for command palette (referenced by JS) -->
<template id="cmd-icons">
@@ -14,6 +14,7 @@
<span data-icon="box">{{ box() }}</span>
<span data-icon="palette">{{ palette() }}</span>
<span data-icon="book_open">{{ book_open() }}</span>
<span data-icon="file_code">{{ file_code() }}</span>
</template>
<dialog id="cmd-palette" class="modal">
<div class="modal-box max-w-lg p-0">
@@ -30,8 +31,8 @@
</dialog>
<!-- Floating button to open command palette -->
<button id="cmd-fab" class="fixed bottom-6 right-6 z-50" title="Command Palette (⌘K)">
<button id="cmd-fab" class="fixed bottom-6 right-6 z-50" title="Command Palette (⌘/Ctrl+K)">
<div class="cmd-fab-inner">
<span>⌘ + K</span>
<span class="shortcut-key"></span><span class="shortcut-plus"> + </span><span class="shortcut-key">K</span>
</div>
</button>

View File

@@ -1,5 +1,5 @@
{# Container list for a stack on a single host #}
{% from "partials/icons.html" import terminal %}
{% from "partials/icons.html" import terminal, rotate_ccw, scroll_text, square, play, cloud_download %}
{% macro container_row(stack, container, host) %}
<div class="flex items-center gap-2 mb-2">
{% if container.State == "running" %}
@@ -18,11 +18,48 @@
<span class="badge badge-warning">{{ container.State }}</span>
{% endif %}
<code class="text-sm flex-1">{{ container.Name }}</code>
<div class="tooltip tooltip-left" data-tip="Open shell in container">
<button class="btn btn-sm btn-outline"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }} Shell
</button>
<div class="join">
<div class="tooltip tooltip-top" data-tip="View logs">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/logs"
hx-swap="none">
{{ scroll_text() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Restart service">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/restart"
hx-swap="none">
{{ rotate_ccw() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Pull image">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/pull"
hx-swap="none">
{{ cloud_download() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Start service">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/up"
hx-swap="none">
{{ play() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Stop service">
<button class="btn btn-sm btn-outline join-item"
hx-post="/api/stack/{{ stack }}/service/{{ container.Service }}/stop"
hx-swap="none">
{{ square() }}
</button>
</div>
<div class="tooltip tooltip-top" data-tip="Open shell">
<button class="btn btn-sm btn-outline join-item"
onclick="initExecTerminal('{{ stack }}', '{{ container.Name }}', '{{ host }}')">
{{ terminal() }}
</button>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -43,6 +43,18 @@
</svg>
{% endmacro %}
{% macro rotate_ccw(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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>
</svg>
{% endmacro %}
{% macro scroll_text(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="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>
</svg>
{% endmacro %}
{% macro download(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 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>

View File

@@ -4,7 +4,7 @@
{% block title %}{{ name }} - Compose Farm{% endblock %}
{% block content %}
<div class="max-w-5xl">
<div class="max-w-5xl" data-services="{{ services | join(',') }}" data-containers='{{ containers | tojson }}'>
<div class="mb-6">
<h1 class="text-3xl font-bold rainbow-hover">{{ name }}</h1>
<div class="flex flex-wrap items-center gap-2 mt-2">

View File

@@ -236,8 +236,8 @@ async def _run_shell_session(
await websocket.send_text(f"{RED}Host '{host_name}' not found{RESET}{CRLF}")
return
# Start interactive shell in home directory (avoid login shell to prevent job control warnings)
shell_cmd = "cd ~ && exec bash -i 2>/dev/null || exec sh -i"
# Start interactive shell in home directory
shell_cmd = "cd ~ && exec bash -i || exec sh -i"
if is_local(host):
# Local: use argv list with shell -c to interpret the command

View File

@@ -2,11 +2,14 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import time
import pytest
# Thresholds in seconds, per OS
if sys.platform == "win32":
CLI_STARTUP_THRESHOLD = 2.0
@@ -16,6 +19,10 @@ else: # Linux
CLI_STARTUP_THRESHOLD = 0.25
@pytest.mark.skipif(
"PYTEST_XDIST_WORKER" in os.environ,
reason="Skip in parallel mode due to resource contention",
)
def test_cli_startup_time() -> None:
"""Verify CLI startup time stays within acceptable bounds.

60
tests/test_compose.py Normal file
View File

@@ -0,0 +1,60 @@
"""Tests for compose file parsing utilities."""
from __future__ import annotations
import pytest
from compose_farm.compose import get_container_name
class TestGetContainerName:
"""Test get_container_name helper function."""
def test_explicit_container_name(self) -> None:
"""Uses container_name from service definition when set."""
service_def = {"image": "nginx", "container_name": "my-custom-name"}
result = get_container_name("web", service_def, "myproject")
assert result == "my-custom-name"
def test_default_naming_pattern(self) -> None:
"""Falls back to {project}-{service}-1 pattern."""
service_def = {"image": "nginx"}
result = get_container_name("web", service_def, "myproject")
assert result == "myproject-web-1"
def test_none_service_def(self) -> None:
"""Handles None service definition gracefully."""
result = get_container_name("web", None, "myproject")
assert result == "myproject-web-1"
def test_empty_service_def(self) -> None:
"""Handles empty service definition."""
result = get_container_name("web", {}, "myproject")
assert result == "myproject-web-1"
def test_container_name_none_value(self) -> None:
"""Handles container_name set to None."""
service_def = {"image": "nginx", "container_name": None}
result = get_container_name("web", service_def, "myproject")
assert result == "myproject-web-1"
def test_container_name_empty_string(self) -> None:
"""Handles container_name set to empty string."""
service_def = {"image": "nginx", "container_name": ""}
result = get_container_name("web", service_def, "myproject")
assert result == "myproject-web-1"
@pytest.mark.parametrize(
("service_name", "project_name", "expected"),
[
("redis", "plex", "plex-redis-1"),
("plex-server", "media", "media-plex-server-1"),
("db", "my-app", "my-app-db-1"),
],
)
def test_various_naming_combinations(
self, service_name: str, project_name: str, expected: str
) -> None:
"""Test various service/project name combinations."""
result = get_container_name(service_name, {"image": "test"}, project_name)
assert result == expected

View File

@@ -212,22 +212,22 @@ def test_generate_follows_network_mode_service_for_ports(tmp_path: Path) -> None
"image": "gluetun",
"ports": ["5080:5080", "9696:9696"],
},
"qbittorrent": {
"image": "qbittorrent",
"syncthing": {
"image": "syncthing",
"network_mode": "service:vpn",
"labels": [
"traefik.enable=true",
"traefik.http.routers.torrent.rule=Host(`torrent.example.com`)",
"traefik.http.services.torrent.loadbalancer.server.port=5080",
"traefik.http.routers.sync.rule=Host(`sync.example.com`)",
"traefik.http.services.sync.loadbalancer.server.port=5080",
],
},
"prowlarr": {
"image": "prowlarr",
"searxng": {
"image": "searxng",
"network_mode": "service:vpn",
"labels": [
"traefik.enable=true",
"traefik.http.routers.prowlarr.rule=Host(`prowlarr.example.com`)",
"traefik.http.services.prowlarr.loadbalancer.server.port=9696",
"traefik.http.routers.searxng.rule=Host(`searxng.example.com`)",
"traefik.http.services.searxng.loadbalancer.server.port=9696",
],
},
}
@@ -238,10 +238,10 @@ def test_generate_follows_network_mode_service_for_ports(tmp_path: Path) -> None
assert warnings == []
# Both services should get their ports from the vpn service
torrent_servers = dynamic["http"]["services"]["torrent"]["loadbalancer"]["servers"]
assert torrent_servers == [{"url": "http://192.168.1.10:5080"}]
prowlarr_servers = dynamic["http"]["services"]["prowlarr"]["loadbalancer"]["servers"]
assert prowlarr_servers == [{"url": "http://192.168.1.10:9696"}]
sync_servers = dynamic["http"]["services"]["sync"]["loadbalancer"]["servers"]
assert sync_servers == [{"url": "http://192.168.1.10:5080"}]
searxng_servers = dynamic["http"]["services"]["searxng"]["loadbalancer"]["servers"]
assert searxng_servers == [{"url": "http://192.168.1.10:9696"}]
def test_parse_external_networks_single(tmp_path: Path) -> None:

View File

@@ -31,12 +31,21 @@ services:
(plex_dir / ".env").write_text("PLEX_CLAIM=claim-xxx\n")
# Create another stack
sonarr_dir = compose_path / "sonarr"
sonarr_dir.mkdir()
(sonarr_dir / "compose.yaml").write_text("""
grafana_dir = compose_path / "grafana"
grafana_dir.mkdir()
(grafana_dir / "compose.yaml").write_text("""
services:
sonarr:
image: linuxserver/sonarr
grafana:
image: grafana/grafana
""")
# Create a single-service stack for testing service commands
redis_dir = compose_path / "redis"
redis_dir.mkdir()
(redis_dir / "compose.yaml").write_text("""
services:
redis:
image: redis:alpine
""")
return compose_path
@@ -58,7 +67,8 @@ hosts:
stacks:
plex: server-1
sonarr: server-2
grafana: server-2
redis: server-1
""")
# State file must be alongside config file

File diff suppressed because it is too large Load Diff

View File

@@ -84,6 +84,15 @@ class TestPageTemplatesRender:
assert response.status_code == 200
assert "test-service" in response.text
def test_stack_detail_has_containers_data(self, client: TestClient) -> None:
"""Test stack detail page includes data-containers for command palette shell."""
response = client.get("/stack/test-service")
assert response.status_code == 200
# Should have data-containers attribute with JSON
assert "data-containers=" in response.text
# Container name should follow {project}-{service}-1 pattern
assert "test-service-app-1" in response.text
class TestPartialTemplatesRender:
"""Test that partial templates render without missing variables."""