Compare commits

...

71 Commits

Author SHA1 Message Date
Bas Nijholt
2a923e6e81 fix: Include field name in config validation error messages (#131)
Previously, Pydantic validation errors like "Extra inputs are not
permitted" didn't show which field caused the error. Now the error
message includes the field location (e.g., "unknown_key: Extra inputs
are not permitted").
2025-12-22 22:35:19 -08:00
Bas Nijholt
5f2e081298 perf: Batch snapshot collection to 1 SSH call per host (#130)
## Summary

Optimize `cf refresh` SSH calls from O(stacks) to O(hosts):
- Discovery: 1 SSH call per host (unchanged)
- Snapshots: 1 SSH call per host (was 1 per stack)

For 50 stacks across 4 hosts: 54 → 8 SSH calls.

## Changes

**Performance:**
- Use `docker ps` + `docker image inspect` instead of `docker compose images` per stack
- Batch snapshot collection by host in `collect_stacks_entries_on_host()`

**Architecture:**
- Add `build_discovery_results()` to `operations.py` (business logic)
- Keep progress bar wrapper in `cli/management.py` (presentation)
- Remove dead code: `discover_all_stacks_on_all_hosts()`, `collect_all_stacks_entries()`
2025-12-22 22:19:32 -08:00
Bas Nijholt
6fbc7430cb perf: Optimize stray detection to use 1 SSH call per host (#129)
* perf: Optimize stray detection to use 1 SSH call per host

Previously, stray detection checked each stack on each host individually,
resulting in (stacks * hosts) SSH calls. For 50 stacks across 4 hosts,
this meant ~200 parallel SSH connections, causing "Connection lost" errors.

Now queries each host once for all running compose projects using:
  docker ps --format '{{.Label "com.docker.compose.project"}}' | sort -u

This reduces SSH calls from ~200 to just 4 (one per host).

Changes:
- Add get_running_stacks_on_host() in executor.py
- Add discover_all_stacks_on_all_hosts() in operations.py
- Update _discover_stacks_full() to use the batch approach

* Remove unused function and add tests

- Remove discover_stack_on_all_hosts() which is no longer used
- Add tests for get_running_stacks_on_host()
- Add tests for discover_all_stacks_on_all_hosts()
  - Verifies it returns correct StackDiscoveryResult
  - Verifies stray detection works
  - Verifies it makes only 1 call per host (not per stack)
2025-12-22 12:09:59 -08:00
Bas Nijholt
6fdb43e1e9 Add self-healing: detect and stop stray containers (#128)
* Add self-healing: detect and stop rogue containers

Adds the ability to detect and stop "rogue" containers - stacks running
on hosts they shouldn't be according to config.

Changes:
- `cf refresh`: Now scans ALL hosts and warns about rogues/duplicates
- `cf apply`: Stops rogue containers before migrations (new phase)
- New `--no-rogues` flag to skip rogue detection

Implementation:
- Add StackDiscoveryResult for full host scanning results
- Add discover_stack_on_all_hosts() to check all hosts in parallel
- Add stop_rogue_stacks() to stop containers on unauthorized hosts
- Update tests to include new no_rogues parameter

* Update README.md

* fix: Update refresh tests for _discover_stacks_full return type

The function now returns a tuple (discovered, rogues, duplicates)
for rogue/duplicate detection. Update test mocks accordingly.

* Rename "rogue" terminology to "stray" for consistency

Terminology update across the codebase:
- rogue_hosts -> stray_hosts
- is_rogue -> is_stray
- stop_rogue_stacks -> stop_stray_stacks
- _discover_rogues -> _discover_strays
- --no-rogues -> --no-strays
- _report_rogue_stacks -> _report_stray_stacks

"Stray" better complements "orphaned" (both evoke lost things)
while clearly indicating the stack is running somewhere it
shouldn't be.

* Update README.md

* Move asyncio import to top level

* Fix remaining rogue -> stray in docstrings and README

* Refactor: Extract shared helpers to reduce duplication

1. Extract _stop_stacks_on_hosts helper in operations.py
   - Shared by stop_orphaned_stacks and stop_stray_stacks
   - Reduces ~50 lines of duplicated code

2. Refactor _discover_strays to reuse _discover_stacks_full
   - Removes duplicate discovery logic from lifecycle.py
   - Calls management._discover_stacks_full and merges duplicates

* Add PR review prompt

* Fix typos in PR review prompt

* Move import to top level (no in-function imports)

* Update README.md

* Remove obvious comments
2025-12-22 10:22:09 -08:00
Bas Nijholt
620e797671 fix: Add entrypoint to create passwd entry for non-root users (#127) 2025-12-22 07:31:59 -08:00
Bas Nijholt
031a2af6f3 fix: Correct SSH key volume mount path in docker-compose.yml (#126) 2025-12-22 06:55:59 -08:00
Bas Nijholt
f69eed7721 docs(readme): position as Dockge for multi-host (#123)
* docs(readme): position as Dockge for multi-host

- Reference Dockge (which we've used) instead of Portainer
- Move Portainer mention to "Your files" bullet as contrast
- Link to Dockge repo

* docs(readme): add agentless bullet, link Dockge

- Add "Agentless" bullet highlighting SSH-only approach
- Link to Dockge as contrast (they require agents for multi-host)
- Update NOTE to focus on agentless, CLI-first positioning
2025-12-21 23:28:26 -08:00
Bas Nijholt
5a1fd4e29f docs(readme): add value propositions and fix image URL (#122)
- Add bullet points highlighting key benefits after NOTE block
- Update NOTE to position as file-based Portainer alternative
- Fix hero image URL from http to https
- Add alt text to hero image for accessibility
2025-12-21 23:17:18 -08:00
Bas Nijholt
26dea691ca feat(docker): make container user configurable via CF_UID/CF_GID (#118)
* feat(docker): make container user configurable via CF_UID/CF_GID

Add support for running compose-farm containers as a non-root user
to preserve file ownership on mounted volumes. This prevents files
like compose-farm-state.yaml and web UI config edits from being
owned by root on NFS mounts.

Set CF_UID, CF_GID, and CF_HOME environment variables to run as
your user. Defaults to root (0:0) for backwards compatibility.

* docs: document non-root user configuration for Docker

- Add CF_UID/CF_GID/CF_HOME documentation to README and getting-started
- Add XDG config volume mount for backup/log persistence across restarts
- Update SSH volume examples to use CF_HOME variable

* fix(docker): allow non-root user access and add USER env for SSH

- Add `chmod 755 /root` to Dockerfile so non-root users can access
  the installed tool at /root/.local/share/uv/tools/compose-farm
- Add USER environment variable to docker-compose.yml for SSH to work
  when running as non-root (UID not in /etc/passwd)
- Update docs to include CF_USER in the setup instructions
- Support building from local source with SETUPTOOLS_SCM_PRETEND_VERSION

* fix(docker): revert local build changes, keep only chmod 755 /root

Remove the local source build logic that was added during testing.
The only required change is `chmod 755 /root` to allow non-root users
to access the installed tool.

* docs: add .envrc.example for direnv users

* docs: mention direnv option in README and getting-started
2025-12-21 22:19:40 -08:00
Bas Nijholt
56d64bfe7a fix(web): exclude orphaned stacks from running count (#119)
The dashboard showed "stopped: -1" when orphaned stacks existed because
running_count included stacks in state but removed from config. Now only
stacks that are both in config AND deployed are counted as running.
2025-12-21 21:59:05 -08:00
Bas Nijholt
5ddbdcdf9e docs(demos): update recordings and fix demo scripts (#115) 2025-12-21 19:17:16 -08:00
Bas Nijholt
dd16becad1 feat(web): add Repo command to command palette (#117)
Adds a new "Repo" command that opens the GitHub repository in a new tab,
similar to the existing "Docs" command.
2025-12-21 15:25:04 -08:00
Bas Nijholt
df683a223f fix(web): wait for terminal expand transition before scrolling (#116)
- Extracts generic `expandCollapse(toggle, scrollTarget)` function for reuse with any DaisyUI collapse
- Fixes scrolling when clicking action buttons (pull, logs, etc.) while terminal is collapsed - now waits for CSS transition before scrolling
- Fixes shell via command palette - expands Container Shell and scrolls to actual terminal (not collapse header)
- Fixes scroll position not resetting when navigating via command palette
2025-12-21 15:17:59 -08:00
Bas Nijholt
fdb00e7655 refactor(web): store backups in XDG config directory (#113)
* refactor(web): store backups in XDG config directory

Move file backups from `.backups/` alongside the file to
`~/.config/compose-farm/backups/` (respecting XDG_CONFIG_HOME).
The original file path is mirrored inside to avoid name collisions.

* docs(web): document automatic backup location

* refactor(paths): extract shared config_dir() function

* fix(web): use path anchor for Windows compatibility
2025-12-21 15:08:15 -08:00
Bas Nijholt
90657a025f docs: fix missing CLI options and improve docs-review prompt (#114)
* docs: fix missing CLI options and improve docs-review prompt

- Add missing --config option docs for cf ssh setup and cf ssh status
- Enhance .prompts/docs-review.md with:
  - Quick reference table mapping docs to source files
  - Runnable bash commands for quick checks
  - Specific code paths instead of vague references
  - Web UI documentation section
  - Common gotchas section
  - Ready-to-apply fix template format
  - Post-fix verification steps

* docs: add self-review step to docs-review prompt

* docs: make docs-review prompt discovery-based and less brittle

- Use discovery commands (git ls-files, grep, find) instead of hardcoded lists
- Add 'What This Prompt Is For' section clarifying manual vs automated checks
- Simplify checklist to 10 sections focused on judgment-based review
- Remove hardcoded file paths in favor of search patterns
- Make commands dynamically discover CLI structure

* docs: simplify docs-review prompt, avoid duplicating automated checks

- Remove checks already handled by CI (README help output, command table)
- Focus on judgment-based review: accuracy, completeness, clarity
- Reduce from 270 lines to 117 lines
- Highlight that docs/commands.md options tables are manually maintained
2025-12-21 15:07:37 -08:00
Bas Nijholt
7ae8ea0229 feat(web): add tooltips to sidebar header icons (#111)
Use daisyUI tooltip component with bottom positioning for the docs,
GitHub, and theme switcher icons in the sidebar header, matching the
tooltip style used elsewhere in the web UI.
2025-12-21 14:16:57 -08:00
Bas Nijholt
612242eea9 feat(web): add Open Website button and command for stacks with Traefik labels (#110)
* feat(web): add Open Website button and command for stacks with Traefik labels

Parse traefik.http.routers.*.rule labels to extract Host() rules and
display "Open Website" button(s) on stack pages. Also adds the command
to the command palette.

- Add extract_website_urls() function to compose.py
- Determine scheme (http/https) from entrypoint (websecure/web)
- Prefer HTTPS when same host has both protocols
- Support environment variable interpolation
- Add external_link icon from Lucide
- Add comprehensive tests for URL extraction

* refactor: move extract_website_urls to traefik.py and reuse existing parsing

Instead of duplicating the Traefik label parsing logic in compose.py,
reuse generate_traefik_config() with check_all=True to get the parsed
router configuration, then extract Host() rules from it.

- Move extract_website_urls from compose.py to traefik.py
- Reuse generate_traefik_config for label parsing
- Move tests from test_compose.py to test_traefik.py
- Update import in pages.py

* test: add comprehensive tests for extract_website_urls

Cover real-world patterns found in stacks:
- Multiple Host() in one rule with || operator
- Host() combined with PathPrefix (e.g., && PathPrefix(`/api`))
- Multiple services in one stack (like arr stack)
- Labels in list format (- key=value)
- No entrypoints (defaults to http)
- Multiple entrypoints including websecure
2025-12-21 14:16:46 -08:00
Bas Nijholt
ea650bff8a fix: Skip buildable images in pull command (#109)
* fix: Skip buildable images in pull command

Add --ignore-buildable flag to pull command, matching the behavior
of the update command. This prevents pull from failing when a stack
contains services with local build directives (no remote image).

* test: Fix flaky command palette close detection

Use state="hidden" instead of :not([open]) selector when waiting
for the command palette to close. The old approach failed because
wait_for_selector defaults to waiting for visibility, but a closed
<dialog> element is hidden by design.
2025-12-21 10:28:10 -08:00
renovate[bot]
140bca4fd6 ⬆️ Update actions/upload-pages-artifact action to v4 (#108)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 10:27:58 -08:00
renovate[bot]
6dad6be8da ⬆️ Update actions/checkout action to v6 (#107)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 10:27:51 -08:00
Bas Nijholt
d7f931e301 feat(web): improve container row layout for mobile (#106)
- Stack container name/status above buttons on mobile screens
- Use card-like background for visual separation
- Buttons align right on desktop, full width on mobile
2025-12-21 10:27:36 -08:00
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
Bas Nijholt
7caf006e07 feat(web): add Rich logging for better error debugging (#90)
Add structured logging with Rich tracebacks to web UI components:
- Configure RichHandler in app.py for formatted output
- Log SSH/file operation failures in API routes with full tracebacks
- Log WebSocket exec/shell errors for connection issues
- Add warning logs for failed container state queries

Errors now show detailed tracebacks in container logs instead of
just returning 500 status codes.
2025-12-20 20:47:34 -08:00
Bas Nijholt
45040b75f1 feat(web): add Pull All and Update All buttons to dashboard (#89)
- Add "Pull All" and "Update All" buttons to dashboard for bulk operations
- Switch from native `title` attribute to DaisyUI tooltips for instant, styled tooltips
- Add tooltips to save buttons clarifying what they save
- Add tooltip to container shell button
- Fix tooltip z-index so they appear above sidebar
- Fix tooltip clipping by removing `overflow-y-auto` from main content
- Position container shell tooltip to the left to avoid clipping
2025-12-20 20:41:26 -08:00
Bas Nijholt
fa1c5c1044 docs: update theme to indigo with system preference support (#88)
Switch from teal to indigo primary color to match Zensical docs theme.
Add system preference detection and orange accent for dark mode.
2025-12-20 20:18:28 -08:00
Bas Nijholt
67e832f687 docs: clarify config file locations and update install URL (#86) 2025-12-20 20:12:06 -08:00
Bas Nijholt
da986fab6a fix: improve command palette theme filtering (#87)
- Normalize spaces after colons so "theme:dark" matches "theme: dark"
- Also handles multiple spaces like "theme:  dark"
2025-12-20 20:03:16 -08:00
Bas Nijholt
5dd6e2ca05 fix: improve theme picker usability in command palette (#85) 2025-12-20 20:00:05 -08:00
Bas Nijholt
16435065de fix: video autoplay for Safari and Chrome with instant navigation (#84) 2025-12-20 19:49:05 -08:00
Bas Nijholt
5921b5e405 docs: update web-workflow demo recording (#83) 2025-12-20 18:09:24 -08:00
Bas Nijholt
f0cd85b5f5 fix: prevent terminal reconnection to wrong page after navigation (#81) 2025-12-20 16:41:28 -08:00
Bas Nijholt
fe95443733 fix: Safari video autoplay on first page load (#82) 2025-12-20 16:41:04 -08:00
Bas Nijholt
8df9288156 docs: add Quick Demo GIFs to README (#80)
* docs: add Quick Demo GIFs to README

Add the same CLI and Web UI demo GIFs that appear on the docs homepage.

* docs: add Table of Contents header
2025-12-20 16:20:42 -08:00
Bas Nijholt
124bde7575 docs: improve Web UI workflow demo with comprehensive showcase (#78) 2025-12-20 16:14:33 -08:00
Bas Nijholt
350947ad12 Rename services to stacks terminology (#79) 2025-12-20 16:00:41 -08:00
Bas Nijholt
bb019bcae6 feat: add ty type checker alongside mypy (#77)
Add Astral's ty type checker (written in Rust, 10-100x faster than mypy)
as a second type checking layer. Both run in pre-commit and CI.

Fixed type issues caught by ty:
- config.py: explicit Host constructor to avoid dict unpacking issues
- executor.py: wrap subprocess.run in closure for asyncio.to_thread
- api.py: use getattr for Jinja TemplateModule macro access
- test files: fix playwright driver_path tuple handling, pytest rootpath typing
2025-12-20 15:43:51 -08:00
Bas Nijholt
6d50f90344 ci: run docs build on PRs (#76)
- Add pull_request trigger with same path filters
- Skip Pages setup and artifact upload on PRs (only build)
- Skip deploy job entirely on PRs
- Update concurrency to include ref for parallel PR builds
2025-12-20 15:16:25 -08:00
Bas Nijholt
474b7ca044 docs: add early Web UI links to homepage (#75) 2025-12-20 15:08:04 -08:00
Bas Nijholt
7555d8443b fix(docs): add Web UI to sidebar and fix video paths (#74)
- Add Web UI page to navigation in zensical.toml
- Use absolute paths for video assets in web-ui.md
- Add web-workflow demo video to homepage Quick Demo section
2025-12-20 15:05:10 -08:00
Bas Nijholt
de46c3ff0f feat: add web UI demo recording system (#69) 2025-12-20 15:00:03 -08:00
Bas Nijholt
fff064cf03 Clarify single-host vs multi-host docs (#73) 2025-12-20 14:15:43 -08:00
Bas Nijholt
187f83b61d feat: add service arguments to refresh command (#70) 2025-12-20 13:14:09 -08:00
Bas Nijholt
d2b9113b9d feat(web): add documentation link to sidebar and command palette (#72)
Adds a docs icon next to GitHub in the header and a "Docs" command
in the command palette (⌘K) that opens https://compose-farm.nijho.lt/
in a new tab.
2025-12-20 13:13:20 -08:00
Bas Nijholt
be77eb7c75 fix(docs): use absolute paths for video assets (#71)
Relative paths like `assets/install.webm` resolved incorrectly on
subpages (e.g., /getting-started/assets/install.webm instead of
/assets/install.webm), causing 404 errors for videos on those pages.
2025-12-20 12:51:34 -08:00
Bas Nijholt
81e1a482f4 fix(docs): use Nerd Font icon for emoji in quickstart demo (#68) 2025-12-20 12:36:29 -08:00
Bas Nijholt
435b014251 docs: move demo up and add Dockge comparison (#67) 2025-12-20 10:28:59 -08:00
Bas Nijholt
58585ac73c docs: fix inaccuracies and add missing documentation (#66) 2025-12-20 10:27:15 -08:00
Bas Nijholt
5a848ec416 fix(docs): fix video display on GitHub Pages (#65) 2025-12-20 10:14:51 -08:00
Bas Nijholt
b4595cb117 docs: add comprehensive Zensical-based documentation (#62) 2025-12-20 09:57:59 -08:00
Bas Nijholt
5f1c31b780 feat: show docker compose command before execution (#64) 2025-12-20 00:35:35 -08:00
Bas Nijholt
9974f87976 feat: add bootstrap script for one-liner installation (#63)
Adds a curl-able install script that installs uv (if needed) and
compose-farm as a uv tool. Updated README with the one-liner.
2025-12-19 23:54:00 -08:00
Bas Nijholt
8b16484ce2 feat(web): add theme switcher with 35 DaisyUI themes (#61) 2025-12-19 22:33:10 -08:00
Bas Nijholt
d75f9cca64 refactor(web): organize app.js into logical sections (#60)
Reorganize JavaScript into 8 clear sections for better maintainability:
- Constants (ANSI, theme, language map)
- State (all globals in one place)
- Utilities (createWebSocket, whenXtermReady, etc.)
- Terminal (all xterm.js functions together)
- Editor (all Monaco functions together)
- UI Helpers (dashboard refresh, sidebar filter)
- Command Palette (self-contained IIFE)
- Initialization (entry points and event handlers)

No functional changes - only reordering and section headers added.
2025-12-19 20:23:39 -08:00
Bas Nijholt
7ccb0734a2 refactor(web): consolidate JS patterns and use icon macros (#58) 2025-12-19 14:55:31 -08:00
Bas Nijholt
61a845fad8 test: add comprehensive browser tests for HTMX/JS functionality (#59) 2025-12-19 14:27:00 -08:00
Bas Nijholt
e7efae0153 refactor: remove dead code and reduce duplication (#57)
- Delete unused add_service_to_host/remove_service_from_host from state.py
  (42 lines of dead code never called anywhere)

- Extract _stream_output_lines helper in executor.py to deduplicate
  identical read_stream functions in _run_local_command and _run_ssh_command

- Simplify unique-list logic in compose.py using dict.fromkeys()
  instead of manual seen/unique set/list pattern

Total: -67 lines
2025-12-18 23:56:49 -08:00
Bas Nijholt
b4ebe15dd1 refactor: simplify codebase with reduced abstractions (#56)
- Remove dead code: `run_host_operation` in cli/common.py (never called)
- Inline `_report_*` helpers in lifecycle.py (each called once)
- Merge `validate_host` into `validate_hosts` with flexible str|list param
- Merge `_report_no_config_found` and `_report_config_path_not_exists`
  into single `_report_missing_config` function
- Simplify `_get_editor` from 18 lines to 6 using walrus operator
- Extract `COMPOSE_FILENAMES` constant to avoid duplication in config.py
- Extract `_stream_subprocess` helper to reduce duplication in streaming.py

Net reduction: ~130 lines of code with no functionality changes.
2025-12-18 23:45:34 -08:00
138 changed files with 11146 additions and 2487 deletions

6
.envrc.example Normal file
View File

@@ -0,0 +1,6 @@
# Run containers as current user (preserves file ownership on NFS mounts)
# Copy this file to .envrc and run: direnv allow
export CF_UID=$(id -u)
export CF_GID=$(id -g)
export CF_HOME=$HOME
export CF_USER=$USER

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.gif filter=lfs diff=lfs merge=lfs -text
*.webm filter=lfs diff=lfs merge=lfs -text

View File

@@ -24,7 +24,7 @@ def get_all_commands(typer_app: typer.Typer, prefix: str = "cf") -> set[str]:
continue
name = command.name
if not name and command.callback:
name = command.callback.__name__
name = getattr(command.callback, "__name__", None)
if name:
commands.add(f"{prefix} {name}")

View File

@@ -28,7 +28,7 @@ jobs:
run: uv sync --all-extras --dev
- name: Run tests (excluding browser tests)
run: uv run pytest --ignore=tests/web/test_htmx_browser.py
run: uv run pytest -m "not browser"
- name: Upload coverage reports to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
@@ -54,7 +54,7 @@ jobs:
run: uv run playwright install chromium --with-deps
- name: Run browser tests
run: uv run pytest tests/web/test_htmx_browser.py -v --no-cov
run: uv run pytest -m browser -n auto -v
lint:
runs-on: ubuntu-latest

66
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Docs
on:
push:
branches: [main]
paths:
- "docs/**"
- "zensical.toml"
- ".github/workflows/docs.yml"
pull_request:
paths:
- "docs/**"
- "zensical.toml"
- ".github/workflows/docs.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages-${{ github.ref }}"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
lfs: true
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set up Python
run: uv python install 3.12
- name: Install Zensical
run: uv tool install zensical
- name: Build docs
run: zensical build
- name: Setup Pages
if: github.event_name != 'pull_request'
uses: actions/configure-pages@v5
- name: Upload artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v4
with:
path: "./site"
deploy:
if: github.event_name != 'pull_request'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ compose-farm.yaml
coverage.xml
.env
homepage/
site/

View File

@@ -21,16 +21,22 @@ repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.9
hooks:
- id: ruff
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.0
- repo: local
hooks:
- id: mypy
additional_dependencies:
- pydantic>=2.0.0
- typer>=0.9.0
- asyncssh>=2.14.0
- types-PyYAML
name: mypy (type checker)
entry: uv run mypy src tests
language: system
types: [python]
pass_filenames: false
- id: ty
name: ty (type checker)
entry: uv run ty check
language: system
types: [python]
pass_filenames: false

117
.prompts/docs-review.md Normal file
View File

@@ -0,0 +1,117 @@
Review documentation for accuracy, completeness, and consistency. Focus on things that require judgment—automated checks handle the rest.
## What's Already Automated
Don't waste time on these—CI and pre-commit hooks handle them:
- **README help output**: `markdown-code-runner` regenerates `cf --help` blocks in CI
- **README command table**: Pre-commit hook verifies commands are listed
- **Linting/formatting**: Handled by pre-commit
## What This Review Is For
Focus on things that require judgment:
1. **Accuracy**: Does the documentation match what the code actually does?
2. **Completeness**: Are there undocumented features, options, or behaviors?
3. **Clarity**: Would a new user understand this? Are examples realistic?
4. **Consistency**: Do different docs contradict each other?
5. **Freshness**: Has the code changed in ways the docs don't reflect?
## Review Process
### 1. Check Recent Changes
```bash
# What changed recently that might need doc updates?
git log --oneline -20 | grep -iE "feat|fix|add|remove|change|option"
# What code files changed?
git diff --name-only HEAD~20 | grep "\.py$"
```
Look for new features, changed defaults, renamed options, or removed functionality.
### 2. Verify docs/commands.md Options Tables
The README auto-updates help output, but `docs/commands.md` has **manually maintained options tables**. These can drift.
For each command's options table, compare against `cf <command> --help`:
- Are all options listed?
- Are short flags correct?
- Are defaults accurate?
- Are descriptions accurate?
**Pay special attention to subcommands** (`cf config *`, `cf ssh *`)—these have their own options that are easy to miss.
### 3. Verify docs/configuration.md
Compare against Pydantic models in the source:
```bash
# Find the config models
grep -r "class.*BaseModel" src/ --include="*.py" -A 15
```
Check:
- All config keys documented
- Types and defaults match code
- Config file search order is accurate
- Example YAML would actually work
### 4. Verify docs/architecture.md
```bash
# What source files actually exist?
git ls-files "src/**/*.py"
```
Check:
- Listed files exist
- No files are missing from the list
- Descriptions match what the code does
### 5. Check Examples
For examples in any doc:
- Would the YAML/commands actually work?
- Are service names, paths, and options realistic?
- Do examples use current syntax (not deprecated options)?
### 6. Cross-Reference Consistency
The same info appears in multiple places. Check for conflicts:
- README.md vs docs/index.md
- docs/commands.md vs CLAUDE.md command tables
- Config examples across different docs
### 7. Self-Check This Prompt
This prompt can become outdated too. If you notice:
- New automated checks that should be listed above
- New doc files that need review guidelines
- Patterns that caused issues
Include prompt updates in your fixes.
## Output Format
Categorize findings:
1. **Critical**: Wrong info that would break user workflows
2. **Inaccuracy**: Technical errors (wrong defaults, paths, types)
3. **Missing**: Undocumented features or options
4. **Outdated**: Was true, no longer is
5. **Inconsistency**: Docs contradict each other
6. **Minor**: Typos, unclear wording
For each issue, provide a ready-to-apply fix:
```
### Issue: [Brief description]
- **File**: docs/commands.md:652
- **Problem**: `cf ssh setup` has `--config` option but it's not documented
- **Fix**: Add `--config, -c PATH` to the options table
- **Verify**: `cf ssh setup --help`
```

15
.prompts/pr-review.md Normal file
View File

@@ -0,0 +1,15 @@
Review the pull request for:
- **Code cleanliness**: Is the implementation clean and well-structured?
- **DRY principle**: Does it avoid duplication?
- **Code reuse**: Are there parts that should be reused from other places?
- **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.
- **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?
- **Live tests**: Test the changes in a REAL live environment to ensure they work as expected, use the config in `/opt/stacks/compose-farm.yaml`.
- **Rules**: Does the code follow the project's coding standards and guidelines as laid out in @CLAUDE.md?
Look at `git diff origin/main..HEAD` for the changes made in this pull request.

51
.prompts/update-demos.md Normal file
View File

@@ -0,0 +1,51 @@
Update demo recordings to match the current compose-farm.yaml configuration.
## Key Gotchas
1. **Never `git checkout` without asking** - check for uncommitted changes first
2. **Prefer `nas` stacks** - demos run locally on nas, SSH adds latency
3. **Terminal captures keyboard** - use `blur()` to release focus before command palette
4. **Clicking sidebar navigates away** - clicking h1 scrolls to top
5. **Buttons have icons, not text** - use `[data-tip="..."]` selectors
6. **`record.py` auto-restores config** - no manual cleanup needed after CLI demos
## Stacks Used in Demos
| Stack | CLI Demos | Web Demos | Notes |
|-------|-----------|-----------|-------|
| `audiobookshelf` | quickstart, migration, apply | - | Migrates nas→anton |
| `grocy` | update | navigation, stack, workflow, console | - |
| `immich` | logs, compose | shell | Multiple containers |
| `dozzle` | - | workflow | - |
## CLI Demos
**Files:** `docs/demos/cli/*.tape`
Check:
- `quickstart.tape`: `bat -r` line ranges match current config structure
- `migration.tape`: nvim keystrokes work, stack exists on nas
- `compose.tape`: exec commands produce meaningful output
Run: `python docs/demos/cli/record.py [demo]`
## Web Demos
**Files:** `docs/demos/web/demo_*.py`
Check:
- Stack names in demos still exist in config
- Selectors match current templates (grep for IDs in `templates/`)
- Shell demo uses command palette for ALL navigation
Run: `python docs/demos/web/record.py [demo]`
## Before Recording
```bash
# Check for uncommitted config changes
git -C /opt/stacks diff compose-farm.yaml
# Verify stacks are running
cf ps audiobookshelf grocy immich dozzle
```

View File

@@ -9,23 +9,28 @@
## Architecture
```
compose_farm/
src/compose_farm/
├── cli/ # CLI subpackage
│ ├── __init__.py # Imports modules to trigger command registration
│ ├── 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)
│ ├── lifecycle.py # up, down, pull, restart, update, apply commands
│ ├── config.py # Config subcommand (init, show, path, validate, edit, symlink)
│ ├── 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
── monitoring.py # logs, ps, stats commands
│ ├── ssh.py # SSH key management (setup, status, keygen)
│ └── web.py # Web UI server command
├── config.py # Pydantic models, YAML loading
├── compose.py # Compose file parsing (.env, ports, volumes, networks)
├── console.py # Shared Rich console instances
├── executor.py # SSH/local command execution, streaming output
├── operations.py # Business logic (up, migrate, discover, preflight checks)
├── state.py # Deployment state tracking (which service on which host)
├── state.py # Deployment state tracking (which stack on which host)
├── logs.py # Image digest snapshots (dockerfarm-log.toml)
── traefik.py # Traefik file-provider config generation from labels
── paths.py # Path utilities, config file discovery
├── ssh_keys.py # SSH key path constants and utilities
├── traefik.py # Traefik file-provider config generation from labels
└── web/ # Web UI (FastAPI + HTMX)
```
## Web UI Icons
@@ -41,18 +46,50 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
## Key Design Decisions
1. **Hybrid SSH approach**: asyncssh for parallel streaming with prefixes; native `ssh -t` for raw mode (progress bars)
2. **Parallel by default**: Multiple services run concurrently via `asyncio.gather`
3. **Streaming output**: Real-time stdout/stderr with `[service]` prefix using Rich
2. **Parallel by default**: Multiple stacks run concurrently via `asyncio.gather`
3. **Streaming output**: Real-time stdout/stderr with `[stack]` prefix using Rich
4. **SSH key auth only**: Uses ssh-agent, no password handling (YAGNI)
5. **NFS assumption**: Compose files at same path on all hosts
6. **Local IP auto-detection**: Skips SSH when target host matches local machine's IP
7. **State tracking**: Tracks where services are deployed for auto-migration
7. **State tracking**: Tracks where stacks are deployed for auto-migration
8. **Pre-flight checks**: Verifies NFS mounts and Docker networks exist before starting/migrating
## Code Style
- **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 `just test` or `uv run pytest`. Browser tests require Chromium (system-installed or via `playwright install chromium`):
```bash
# Unit tests only (parallel)
uv run pytest -m "not browser" -n auto
# Browser tests only (parallel)
uv run pytest -m browser -n auto
# All tests
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.
## Communication Notes
- Clarify ambiguous wording (e.g., homophones like "right"/"write", "their"/"there").
@@ -92,17 +129,21 @@ CLI available as `cf` or `compose-farm`.
| Command | Description |
|---------|-------------|
| `up` | Start services (`docker compose up -d`), auto-migrates if host changed |
| `down` | Stop services (`docker compose down`). Use `--orphaned` to stop services removed from config |
| `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` + `down` + `up -d` |
| `apply` | Make reality match config: migrate services + stop orphans. Use `--dry-run` to preview |
| `logs` | Show service logs |
| `ps` | Show status of all services |
| `stats` | Show overview (hosts, services, pending migrations; `--live` for container counts) |
| `refresh` | Update state from reality: discover running services, capture image digests |
| `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) |
| `refresh` | Update state from reality: discover running stacks, capture image digests |
| `check` | Validate config, traefik labels, mounts, networks; show host compatibility |
| `init-network` | Create Docker network on hosts with consistent subnet/gateway |
| `traefik-file` | Generate Traefik file-provider config from compose labels |
| `config` | Manage config files (init, show, path, validate, edit) |
| `config` | Manage config files (init, show, path, validate, edit, symlink) |
| `ssh` | Manage SSH keys (setup, status, keygen) |
| `web` | Start web UI server |

View File

@@ -16,5 +16,13 @@ RUN apk add --no-cache openssh-client
COPY --from=builder /root/.local/share/uv/tools/compose-farm /root/.local/share/uv/tools/compose-farm
COPY --from=builder /usr/local/bin/cf /usr/local/bin/compose-farm /usr/local/bin/
ENTRYPOINT ["cf"]
# Allow non-root users to access the installed tool
# (required when running with user: "${CF_UID:-0}:${CF_GID:-0}")
RUN chmod 755 /root
# Allow non-root users to add passwd entries (required for SSH)
RUN chmod 666 /etc/passwd
# Entrypoint creates /etc/passwd entry for non-root UIDs (required for SSH)
ENTRYPOINT ["sh", "-c", "[ $(id -u) != 0 ] && echo ${USER:-u}:x:$(id -u):$(id -g)::${HOME:-/}:/bin/sh >> /etc/passwd; exec cf \"$@\"", "--"]
CMD ["--help"]

532
README.md
View File

@@ -5,12 +5,31 @@
[![License](https://img.shields.io/github/license/basnijholt/compose-farm)](LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/basnijholt/compose-farm)](https://github.com/basnijholt/compose-farm/stargazers)
<img src="http://files.nijho.lt/compose-farm.png" align="right" style="width: 300px;" />
<img src="https://files.nijho.lt/compose-farm.png" alt="Compose Farm logo" align="right" style="width: 300px;" />
A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
> [!NOTE]
> Run `docker compose` commands across multiple hosts via SSH. One YAML maps services to hosts. Run `cf apply` and reality matches your config—services start, migrate, or stop as needed. No Kubernetes, no Swarm, no magic.
> Agentless multi-host Docker Compose. CLI-first with a web UI. Your files stay as plain folders—version-controllable, no lock-in. Run `cf apply` and reality matches your config.
**Why Compose Farm?**
- **Your files, your control** — Plain folders + YAML, not locked in Portainer. Version control everything.
- **Agentless** — Just SSH, no agents to deploy (unlike [Dockge](https://github.com/louislam/dockge)).
- **Zero changes required** — Existing compose files work as-is.
- **Grows with you** — Start single-host, scale to multi-host seamlessly.
- **Declarative** — Change config, run `cf apply`, reality matches.
## Quick Demo
**CLI:**
![CLI Demo](docs/assets/quickstart.gif)
**Web UI:**
![Web UI Demo](docs/assets/web-workflow.gif)
## Table of Contents
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
@@ -19,7 +38,7 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
- [How It Works](#how-it-works)
- [Requirements](#requirements)
- [Limitations & Best Practices](#limitations--best-practices)
- [What breaks when you move a service](#what-breaks-when-you-move-a-service)
- [What breaks when you move a stack](#what-breaks-when-you-move-a-stack)
- [Best practices](#best-practices)
- [What Compose Farm doesn't do](#what-compose-farm-doesnt-do)
- [Installation](#installation)
@@ -27,7 +46,9 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
- [SSH Agent (default)](#ssh-agent-default)
- [Dedicated SSH Key (recommended for Docker/Web UI)](#dedicated-ssh-key-recommended-for-dockerweb-ui)
- [Configuration](#configuration)
- [Multi-Host Services](#multi-host-services)
- [Single-host example](#single-host-example)
- [Multi-host example](#multi-host-example)
- [Multi-Host Stacks](#multi-host-stacks)
- [Config Command](#config-command)
- [Usage](#usage)
- [CLI `--help` Output](#cli---help-output)
@@ -40,14 +61,14 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
## Why Compose Farm?
I used to run 100+ Docker Compose stacks on a single machine that kept running out of memory. I needed a way to distribute services across multiple machines without the complexity of:
I used to run 100+ Docker Compose stacks on a single machine that kept running out of memory. I needed a way to distribute stacks across multiple machines without the complexity of:
- **Kubernetes**: Overkill for my use case. I don't need pods, services, ingress controllers, or YAML manifests 10x the size of my compose files.
- **Docker Swarm**: Effectively in maintenance mode—no longer being invested in by Docker.
Both require changes to your compose files. **Compose Farm requires zero changes**—your existing `docker-compose.yml` files work as-is.
I also wanted a declarative setup—one config file that defines where everything runs. Change the config, run `cf apply`, and everything reconciles—services start, migrate, or stop as needed. See [Comparison with Alternatives](#comparison-with-alternatives) for how this compares to other approaches.
I also wanted a declarative setup—one config file that defines where everything runs. Change the config, run `cf apply`, and everything reconciles—stacks start, migrate, or stop as needed. See [Comparison with Alternatives](#comparison-with-alternatives) for how this compares to other approaches.
<p align="center">
<a href="https://xkcd.com/927/">
@@ -59,8 +80,8 @@ Before you say it—no, this is not a new standard. I changed nothing about my e
Compose Farm just automates what you'd do by hand:
- Runs `docker compose` commands over SSH
- Tracks which service runs on which host
- **One command (`cf apply`) to reconcile everything**—start missing services, migrate moved ones, stop removed ones
- Tracks which stack runs on which host
- **One command (`cf apply`) to reconcile everything**—start missing stacks, migrate moved ones, stop removed ones
- Generates Traefik file-provider config for cross-host routing
**It's a convenience wrapper, not a new paradigm.**
@@ -70,13 +91,13 @@ Compose Farm just automates what you'd do by hand:
**The declarative way** — run `cf apply` and reality matches your config:
1. Compose Farm compares your config to what's actually running
2. Services in config but not running? **Starts them**
3. Services on the wrong host? **Migrates them** (stops on old host, starts on new)
4. Services running but removed from config? **Stops them**
2. Stacks in config but not running? **Starts them**
3. Stacks on the wrong host? **Migrates them** (stops on old host, starts on new)
4. Stacks running but removed from config? **Stops them**
**Under the hood** — each service operation is just SSH + docker compose:
**Under the hood** — each stack operation is just SSH + docker compose:
1. Look up which host runs the service (e.g., `plex``server-1`)
1. Look up which host runs the stack (e.g., `plex``server-1`)
2. SSH to `server-1` (or run locally if `localhost`)
3. Execute `docker compose -f /opt/compose/plex/docker-compose.yml up -d`
4. Stream output back with `[plex]` prefix
@@ -104,13 +125,13 @@ nas:/volume1/compose → /opt/compose (on server-2)
nas:/volume1/compose → /opt/compose (on server-3)
```
Compose Farm simply runs `docker compose -f /opt/compose/{service}/docker-compose.yml` on the appropriate host—it doesn't copy or sync files.
Compose Farm simply runs `docker compose -f /opt/compose/{stack}/docker-compose.yml` on the appropriate host—it doesn't copy or sync files.
## Limitations & Best Practices
Compose Farm moves containers between hosts but **does not provide cross-host networking**. Docker's internal DNS and networks don't span hosts.
### What breaks when you move a service
### What breaks when you move a stack
- **Docker DNS** - `http://redis:6379` won't resolve from another host
- **Docker networks** - Containers can't reach each other via network names
@@ -120,7 +141,7 @@ Compose Farm moves containers between hosts but **does not provide cross-host ne
1. **Keep dependent services together** - If an app needs a database, redis, or worker, keep them in the same compose file on the same host
2. **Only migrate standalone services** - Services that don't talk to other containers (or only talk to external APIs) are safe to move
2. **Only migrate standalone stacks** - Stacks whose services don't talk to other containers (or only talk to external APIs) are safe to move
3. **Expose ports for cross-host communication** - If services must communicate across hosts, publish ports and use IP addresses instead of container names:
```yaml
@@ -140,8 +161,11 @@ If you need containers on different hosts to communicate seamlessly, you need Do
## Installation
```bash
# One-liner (installs uv if needed)
curl -fsSL https://compose-farm.nijho.lt/install | sh
# Or if you already have uv/pip
uv tool install compose-farm
# or
pip install compose-farm
```
@@ -160,6 +184,24 @@ docker run --rm \
ghcr.io/basnijholt/compose-farm up --all
```
**Running as non-root user** (recommended for NFS mounts):
By default, containers run as root. To preserve file ownership on mounted volumes
(e.g., `compose-farm-state.yaml`, config edits), set these environment variables:
```bash
# Add to .env file (one-time setup)
echo "CF_UID=$(id -u)" >> .env
echo "CF_GID=$(id -g)" >> .env
echo "CF_HOME=$HOME" >> .env
echo "CF_USER=$USER" >> .env
```
Or use [direnv](https://direnv.net/) (copies `.envrc.example` to `.envrc`):
```bash
cp .envrc.example .envrc && direnv allow
```
</details>
## SSH Authentication
@@ -194,18 +236,18 @@ This creates `~/.ssh/compose-farm/id_ed25519` (ED25519, no passphrase) and copie
<details><summary>🐳 Docker volume options for SSH keys</summary>
When running in Docker, mount a volume to persist the SSH keys. Choose ONE option and use it for both `cf` and `web` services:
When running in Docker, mount a volume to persist the SSH keys. Choose ONE option and use it for both `cf` and `web` Compose services:
**Option 1: Host path (default)** - keys at `~/.ssh/compose-farm/id_ed25519`
```yaml
volumes:
- ~/.ssh/compose-farm:/root/.ssh
- ~/.ssh/compose-farm:${CF_HOME:-/root}/.ssh
```
**Option 2: Named volume** - managed by Docker
```yaml
volumes:
- cf-ssh:/root/.ssh
- cf-ssh:${CF_HOME:-/root}/.ssh
```
Run setup once after starting the container (while the SSH agent still works):
@@ -216,12 +258,31 @@ docker compose exec web cf ssh setup
The keys will persist across restarts.
**Note:** When running as non-root (with `CF_UID`/`CF_GID`), set `CF_HOME` to your home directory so SSH finds the keys at the correct path.
</details>
## Configuration
Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory):
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`). This keeps config near your stacks. Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config, or symlink from one to the other with `cf config symlink`.
### Single-host example
No SSH, shared storage, or Traefik file-provider required.
```yaml
compose_dir: /opt/stacks
hosts:
local: localhost # Run locally without SSH
stacks:
plex: local
jellyfin: local
traefik: local
```
### Multi-host example
```yaml
compose_dir: /opt/compose # Must be the same path on all hosts
@@ -232,24 +293,24 @@ hosts:
server-2:
address: 192.168.1.11
# user defaults to current user
local: localhost # Run locally without SSH
services:
stacks:
plex: server-1
jellyfin: server-2
sonarr: server-1
radarr: local # Runs on the machine where you invoke compose-farm
grafana: server-1
# Multi-host services (run on multiple/all hosts)
# Multi-host stacks (run on multiple/all hosts)
autokuma: all # Runs on ALL configured hosts
dozzle: [server-1, server-2] # Explicit list of hosts
```
Compose files are expected at `{compose_dir}/{service}/compose.yaml` (also supports `compose.yml`, `docker-compose.yml`, `docker-compose.yaml`).
For cross-host HTTP routing, add Traefik labels to your compose files and set `traefik_file` so Compose Farm can generate the file-provider config.
### Multi-Host Services
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file. Compose files are expected at `{compose_dir}/{stack}/compose.yaml` (also supports `compose.yml`, `docker-compose.yml`, `docker-compose.yaml`).
Some services need to run on every host. This is typically required for tools that access **host-local resources** like the Docker socket (`/var/run/docker.sock`), which cannot be accessed remotely without security risks.
### Multi-Host Stacks
Some stacks need to run on every host. This is typically required for tools that access **host-local resources** like the Docker socket (`/var/run/docker.sock`), which cannot be accessed remotely without security risks.
Common use cases:
- **AutoKuma** - auto-creates Uptime Kuma monitors from container labels (needs local Docker socket)
@@ -262,7 +323,7 @@ This is the same pattern as Docker Swarm's `deploy.mode: global`.
Use the `all` keyword or an explicit list:
```yaml
services:
stacks:
# Run on all configured hosts
autokuma: all
dozzle: all
@@ -271,9 +332,9 @@ services:
node-exporter: [server-1, server-2, server-3]
```
When you run `cf up autokuma`, it starts the service on all hosts in parallel. Multi-host services:
When you run `cf up autokuma`, it starts the stack on all hosts in parallel. Multi-host stacks:
- Are excluded from migration logic (they always run everywhere)
- Show output with `[service@host]` prefix for each host
- Show output with `[stack@host]` prefix for each host
- Track all running hosts in state
### Config Command
@@ -297,20 +358,21 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias.
| Command | Description |
|---------|-------------|
| **`cf apply`** | **Make reality match config (start + migrate + stop orphans)** |
| `cf up <svc>` | Start service (auto-migrates if host changed) |
| `cf down <svc>` | Stop service |
| `cf restart <svc>` | down + up |
| `cf update <svc>` | pull + down + up |
| `cf pull <svc>` | Pull latest images |
| `cf logs -f <svc>` | Follow logs |
| `cf ps` | Show status of all services |
| `cf refresh` | Update state from running services |
| `cf up <stack>` | Start stack (auto-migrates if host changed) |
| `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 |
| `cf logs -f <stack>` | Follow logs |
| `cf ps` | Show status of all stacks |
| `cf refresh` | Update state from running stacks |
| `cf check` | Validate config, mounts, networks |
| `cf init-network` | Create Docker network on hosts |
| `cf traefik-file` | Generate Traefik file-provider config |
| `cf config <cmd>` | Manage config files (init, show, path, validate, edit) |
| `cf config <cmd>` | Manage config files (init, show, path, validate, edit, symlink) |
All commands support `--all` to operate on all services.
All commands support `--all` to operate on all stacks.
Each command replaces: look up host → SSH → find compose file → run `ssh host "cd /opt/compose/plex && docker compose up -d"`.
@@ -318,14 +380,14 @@ Each command replaces: look up host → SSH → find compose file → run `ssh h
# The main command: make reality match your config
cf apply # start missing + migrate + stop orphans
cf apply --dry-run # preview what would change
cf apply --no-orphans # skip stopping orphaned services
cf apply --full # also refresh all services (picks up config changes)
cf apply --no-orphans # skip stopping orphaned stacks
cf apply --full # also refresh all stacks (picks up config changes)
# Or operate on individual services
cf up plex jellyfin # start services (auto-migrates if host changed)
# Or operate on individual stacks
cf up plex jellyfin # start stacks (auto-migrates if host changed)
cf up --all
cf down plex # stop services
cf down --orphaned # stop services removed from config
cf down plex # stop stacks
cf down --orphaned # stop stacks removed from config
# Pull latest images
cf pull --all
@@ -333,19 +395,19 @@ cf pull --all
# Restart (down + up)
cf restart plex
# Update (pull + down + up) - the end-to-end update command
# Update (pull + build + down + up) - the end-to-end update command
cf update --all
# Update state from reality (discovers running services + captures digests)
cf refresh # updates state.yaml and dockerfarm-log.toml
# Update state from reality (discovers running stacks + captures digests)
cf refresh # updates compose-farm-state.yaml and dockerfarm-log.toml
cf refresh --dry-run # preview without writing
# Validate config, traefik labels, mounts, and networks
cf check # full validation (includes SSH checks)
cf check --local # fast validation (skip SSH)
cf check jellyfin # check service + show which hosts can run it
cf check jellyfin # check stack + show which hosts can run it
# Create Docker network on new hosts (before migrating services)
# Create Docker network on new hosts (before migrating stacks)
cf init-network nuc hp # create mynetwork on specific hosts
cf init-network # create on all hosts
@@ -387,28 +449,35 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
│ copy it or customize the installation. │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Lifecycle ──────────────────────────────────────────────────────────────────╮
│ up Start services (docker compose up -d). Auto-migrates if host │
│ changed. │
│ down Stop services (docker compose down). │
│ pull Pull latest images (docker compose pull). │
│ restart Restart services (down + up). │
│ update Update services (pull + build + down + up). │
│ apply Make reality match config (start, migrate, stop as needed). │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Configuration ──────────────────────────────────────────────────────────────╮
│ traefik-file Generate a Traefik file-provider fragment from compose │
│ Traefik labels. │
│ refresh Update local state from running services.
│ 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 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 │
│ strays/orphans as needed). │
│ compose Run any docker compose command on a stack. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
│ logs Show service logs.
ps Show status of services.
stats Show overview statistics for hosts and services.
│ 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. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Server ─────────────────────────────────────────────────────────────────────╮
│ web Start the web UI server. │
@@ -437,18 +506,19 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf up [OPTIONS] [SERVICES]...
Usage: cf up [OPTIONS] [STACKS]...
Start services (docker compose up -d). Auto-migrates if host changed.
Start stacks (docker compose up -d). Auto-migrates if host changed.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --host -H TEXT Filter to services 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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -472,18 +542,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf down [OPTIONS] [SERVICES]...
Usage: cf down [OPTIONS] [STACKS]...
Stop services (docker compose down).
Stop stacks (docker compose down).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --orphaned Stop orphaned services (in state but removed from │
│ --all -a Run on all stacks
│ --orphaned Stop orphaned stacks (in state but removed from
│ config) │
│ --host -H TEXT Filter to services on this host │
│ --host -H TEXT Filter to stacks on this host
│ --config -c PATH Path to config file │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
@@ -494,6 +564,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>
@@ -509,17 +614,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf pull [OPTIONS] [SERVICES]...
Usage: cf pull [OPTIONS] [STACKS]...
Pull latest images (docker compose pull).
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -543,17 +649,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf restart [OPTIONS] [SERVICES]...
Usage: cf restart [OPTIONS] [STACKS]...
Restart services (down + up).
Restart stacks (down + up). With --service, restarts just that service.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -577,17 +684,19 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf update [OPTIONS] [SERVICES]...
Usage: cf update [OPTIONS] [STACKS]...
Update services (pull + build + down + up).
Update stacks (pull + build + down + up). With --service, updates just that
service.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --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,21 +722,26 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf apply [OPTIONS]
Make reality match config (start, migrate, stop as needed).
Make reality match config (start, migrate, stop strays/orphans as needed).
This is the "reconcile" command that ensures running services match your
This is the "reconcile" command that ensures running stacks match your
config file. It will:
1. Stop orphaned services (in state but removed from config) 2. Migrate
services on wrong host (host in state ≠ host in config) 3. Start missing
services (in config but not in state)
Use --dry-run to preview changes before applying. Use --no-orphans to only
migrate/start without stopping orphaned services. Use --full to also run 'up'
on all services (picks up compose/env changes).
1. Stop orphaned stacks (in state but removed from config)
2. Stop stray stacks (running on unauthorized hosts)
3. Migrate stacks on wrong host (host in state ≠ host in config)
4. Start missing stacks (in config but not in state)
Use --dry-run to preview changes before applying.
Use --no-orphans to skip stopping orphaned stacks.
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 services
│ --full -f Also run up on all services to apply config
│ --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. │
@@ -639,6 +753,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>
@@ -656,15 +817,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf traefik-file [OPTIONS] [SERVICES]...
Usage: cf traefik-file [OPTIONS] [STACKS]...
Generate a Traefik file-provider fragment from compose Traefik labels.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --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 │
@@ -692,16 +853,24 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf refresh [OPTIONS]
Usage: cf refresh [OPTIONS] [STACKS]...
Update local state from running services.
Update local state from running stacks.
Discovers which stacks are running on which hosts, updates the state
file, and captures image digests. This is a read operation - it updates
your local state to match reality, not the other way around.
Without arguments: refreshes all stacks (same as --all).
With stack names: refreshes only those stacks.
Discovers which services are running on which hosts, updates the state file,
and captures image digests. This is a read operation - it updates your local
state to match reality, not the other way around.
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 │
@@ -730,16 +899,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf check [OPTIONS] [SERVICES]...
Usage: cf check [OPTIONS] [STACKS]...
Validate configuration, traefik labels, mounts, and networks.
Without arguments: validates all services against configured hosts. With
service arguments: validates specific services and shows host compatibility.
Without arguments: validates all stacks against configured hosts.
With stack arguments: validates specific stacks and shows host compatibility.
Use --local to skip SSH-based checks for faster validation.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --local Skip SSH-based checks (faster) │
@@ -773,9 +943,9 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Create Docker network on hosts with consistent settings.
Creates an external Docker network that services can use for cross-host
communication. Uses the same subnet/gateway on all hosts to ensure consistent
networking.
Creates an external Docker network that stacks can use for cross-host
communication. Uses the same subnet/gateway on all hosts to ensure
consistent networking.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ hosts [HOSTS]... Hosts to create network on (default: all) │
@@ -845,6 +1015,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>
@@ -865,21 +1055,22 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf logs [OPTIONS] [SERVICES]...
Usage: cf logs [OPTIONS] [STACKS]...
Show service logs.
Show stack logs. With --service, shows logs for just that service.
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --host -H TEXT Filter to services 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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -904,21 +1095,24 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf ps [OPTIONS] [SERVICES]...
Usage: cf ps [OPTIONS] [STACKS]...
Show status of services.
Show status of stacks.
Without arguments: shows all services (same as --all). With service names:
shows only those services. With --host: shows services on that host.
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 ──────────────────────────────────────────────────────────────────╮
│ services [SERVICES]... Services to operate on │
│ stacks [STACKS]... Stacks to operate on
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --all -a Run on all services
│ --host -H TEXT Filter to services 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. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -945,9 +1139,9 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
Usage: cf stats [OPTIONS]
Show overview statistics for hosts and services.
Show overview statistics for hosts and stacks.
Without --live: Shows config/state info (hosts, services, pending migrations).
Without --live: Shows config/state info (hosts, stacks, pending migrations).
With --live: Also queries Docker on each host for container counts.
╭─ Options ────────────────────────────────────────────────────────────────────╮
@@ -975,41 +1169,59 @@ 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>
### Auto-Migration
When you change a service's host assignment in config and run `up`, Compose Farm automatically:
When you change a stack's host assignment in config and run `up`, Compose Farm automatically:
1. Checks that required mounts and networks exist on the new host (aborts if missing)
2. Runs `down` on the old host
3. Runs `up -d` on the new host
4. Updates state tracking
Use `cf apply` to automatically reconcile all services—it finds and migrates services on wrong hosts, stops orphaned services, and starts missing services.
Use `cf apply` to automatically reconcile all stacks—it finds and migrates stacks on wrong hosts, stops orphaned stacks, and starts missing stacks.
```yaml
# Before: plex runs on server-1
services:
stacks:
plex: server-1
# After: change to server-2, then run `cf up plex`
services:
stacks:
plex: server-2 # Compose Farm will migrate automatically
```
**Orphaned services**: When you remove (or comment out) a service from config, it becomes "orphaned"—tracked in state but no longer in config. Use these commands to handle orphans:
**Orphaned stacks**: When you remove (or comment out) a stack from config, it becomes "orphaned"—tracked in state but no longer in config. Use these commands to handle orphans:
- `cf apply` — Migrate services AND stop orphans (the full reconcile)
- `cf down --orphaned` — Only stop orphaned services
- `cf apply` — Migrate stacks AND stop orphans (the full reconcile)
- `cf down --orphaned` — Only stop orphaned stacks
- `cf apply --dry-run` — Preview what would change before applying
This makes the config truly declarative: comment out a service, run `cf apply`, and it stops.
This makes the config truly declarative: comment out a stack, run `cf apply`, and it stops.
## Traefik Multihost Ingress (File Provider)
If you run a single Traefik instance on one "frontdoor" host and want it to route to
Compose Farm services on other hosts, Compose Farm can generate a Traefik fileprovider
Compose Farm stacks on other hosts, Compose Farm can generate a Traefik fileprovider
fragment from your existing compose labels.
**How it works**
@@ -1019,11 +1231,11 @@ fragment from your existing compose labels.
- Labels and port specs may use `${VAR}` / `${VAR:-default}`; Compose Farm resolves these
using the stack's `.env` file and your current environment, just like Docker Compose.
- Publish a host port for that container (via `ports:`). The generator prefers
hostpublished ports so Traefik can reach the service across hosts; if none are found,
hostpublished ports so Traefik can reach the stack across hosts; if none are found,
it warns and you'd need L3 reachability to container IPs.
- If a router label doesn't specify `traefik.http.routers.<name>.service` and there's only
one Traefik service defined on that container, Compose Farm wires the router to it.
- `compose-farm.yaml` stays unchanged: just `hosts` and `services: service → host`.
- `compose-farm.yaml` stays unchanged: just `hosts` and `stacks: stack → host`.
Example `docker-compose.yml` pattern:
@@ -1057,7 +1269,7 @@ providers:
cf traefik-file --all --output /mnt/data/traefik/dynamic.d/compose-farm.yml
```
Rerun this after changing Traefik labels, moving a service to another host, or changing
Rerun this after changing Traefik labels, moving a stack to another host, or changing
published ports.
**Auto-regeneration**
@@ -1068,17 +1280,17 @@ add `traefik_file` to your config:
```yaml
compose_dir: /opt/compose
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml # auto-regenerate on up/down/restart/update
traefik_service: traefik # skip services on same host (docker provider handles them)
traefik_stack: traefik # skip stacks on same host (docker provider handles them)
hosts:
# ...
services:
stacks:
traefik: server-1 # Traefik runs here
plex: server-2 # Services on other hosts get file-provider entries
plex: server-2 # Stacks on other hosts get file-provider entries
# ...
```
The `traefik_service` option specifies which service runs Traefik. Services on the same host
The `traefik_stack` option specifies which stack runs Traefik. Stacks on the same host
are skipped in the file-provider config since Traefik's docker provider handles them directly.
Now `cf up plex` will update the Traefik config automatically—no separate
@@ -1121,11 +1333,11 @@ There are many ways to run containers on multiple hosts. Here is where Compose F
| Agentless | ✅ | ✅ | ❌ | ✅ | ❌ |
| High availability | ❌ | ❌ | ✅ | ❌ | ❌ |
**Docker Contexts** — You can use `docker context create remote ssh://...` and `docker compose --context remote up`. But it's manual: you must remember which host runs which service, there's no global view, no parallel execution, and no auto-migration.
**Docker Contexts** — You can use `docker context create remote ssh://...` and `docker compose --context remote up`. But it's manual: you must remember which host runs which stack, there's no global view, no parallel execution, and no auto-migration.
**Kubernetes / Docker Swarm** — Full orchestration that abstracts away the hardware. But they require cluster initialization, separate control planes, and often rewriting compose files. They introduce complexity (consensus, overlay networks) unnecessary for static "pet" servers.
**Ansible / Terraform** — Infrastructure-as-Code tools that can SSH in and deploy containers. But they're push-based configuration management, not interactive CLIs. Great for setting up state, clumsy for day-to-day operations like `cf logs -f` or quickly restarting a service.
**Ansible / Terraform** — Infrastructure-as-Code tools that can SSH in and deploy containers. But they're push-based configuration management, not interactive CLIs. Great for setting up state, clumsy for day-to-day operations like `cf logs -f` or quickly restarting a stack.
**Portainer / Coolify** — Web-based management UIs. But they're UI-first and often require agents on your servers. Compose Farm is CLI-first and agentless.

View File

@@ -5,7 +5,7 @@ compose_dir: /opt/compose
# Optional: Auto-regenerate Traefik file-provider config after up/down/restart/update
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
traefik_service: traefik # Skip services on same host (docker provider handles them)
traefik_stack: traefik # Skip stacks on same host (docker provider handles them)
hosts:
# Full form with all options
@@ -20,11 +20,11 @@ hosts:
# Local execution (no SSH)
local: localhost
services:
# Map service names to hosts
# Compose file expected at: {compose_dir}/{service}/compose.yaml
stacks:
# Map stack names to hosts
# Compose file expected at: {compose_dir}/{stack}/compose.yaml
traefik: server-1 # Traefik runs here
plex: server-2 # Services on other hosts get file-provider entries
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

@@ -1,6 +1,10 @@
services:
cf:
image: ghcr.io/basnijholt/compose-farm:latest
# Run as current user to preserve file ownership on mounted volumes
# Set CF_UID=$(id -u) CF_GID=$(id -g) in your environment or .env file
# Defaults to root (0:0) for backwards compatibility
user: "${CF_UID:-0}:${CF_GID:-0}"
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
# Compose directory (contains compose files AND compose-farm.yaml config)
@@ -8,31 +12,43 @@ services:
# SSH keys for passwordless auth (generated by `cf ssh setup`)
# Choose ONE option below (use the same option for both cf and web services):
# Option 1: Host path (default) - keys at ~/.ssh/compose-farm/id_ed25519
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:${CF_HOME:-/root}/.ssh/compose-farm
# Option 2: Named volume - managed by Docker, shared between services
# - cf-ssh:/root/.ssh
# - cf-ssh:${CF_HOME:-/root}/.ssh
environment:
- SSH_AUTH_SOCK=/ssh-agent
# Config file path (state stored alongside it)
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
# 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)
- USER=${CF_USER:-root}
web:
image: ghcr.io/basnijholt/compose-farm:latest
restart: unless-stopped
command: web --host 0.0.0.0 --port 9000
# Run as current user to preserve file ownership on mounted volumes
user: "${CF_UID:-0}:${CF_GID:-0}"
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
# SSH keys - use the SAME option as cf service above
# Option 1: Host path (default)
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:${CF_HOME:-/root}/.ssh/compose-farm
# Option 2: Named volume
# - cf-ssh:/root/.ssh
# - cf-ssh:${CF_HOME:-/root}/.ssh
# XDG config dir for backups and image digest logs (persists across restarts)
- ${CF_XDG_CONFIG:-~/.config/compose-farm}:${CF_HOME:-/root}/.config/compose-farm
environment:
- SSH_AUTH_SOCK=/ssh-agent
- 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_SERVICE=compose-farm
- CF_WEB_STACK=compose-farm
# 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)
- USER=${CF_USER:-root}
labels:
- traefik.enable=true
- traefik.http.routers.compose-farm.rule=Host(`compose-farm.${DOMAIN}`)

1
docs/CNAME Normal file
View File

@@ -0,0 +1 @@
compose-farm.nijho.lt

345
docs/architecture.md Normal file
View File

@@ -0,0 +1,345 @@
---
icon: lucide/layers
---
# Architecture
This document explains how Compose Farm works under the hood.
## Design Philosophy
Compose Farm follows three core principles:
1. **KISS** - Keep it simple. It's a thin wrapper around `docker compose` over SSH.
2. **YAGNI** - No orchestration, no service discovery, no health checks until needed.
3. **Zero changes** - Your existing compose files work unchanged.
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Compose Farm CLI │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Config │ │ State │ │Operations│ │ Executor │ │
│ │ Parser │ │ Tracker │ │ Logic │ │ (SSH/Local) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
└───────┼─────────────┼─────────────┼─────────────────┼───────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ SSH / Local │
└───────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Host: nuc │ │ Host: hp │
│ │ │ │
│ docker compose│ │ docker compose│
│ up -d │ │ up -d │
└───────────────┘ └───────────────┘
```
## Core Components
### Configuration (`src/compose_farm/config.py`)
Pydantic models for YAML configuration:
- **Config** - Root configuration with compose_dir, hosts, stacks
- **Host** - Host address, SSH user, and port
Key features:
- Validation with Pydantic
- Multi-host stack expansion (`all` → list of hosts)
- YAML loading with sensible defaults
### State Tracking (`src/compose_farm/state.py`)
Tracks deployment state in `compose-farm-state.yaml` (stored alongside the config file):
```yaml
deployed:
plex: nuc
grafana: nuc
```
Used for:
- Detecting migrations (stack moved to different host)
- Identifying orphans (stacks removed from config)
- `cf ps` status display
### Operations (`src/compose_farm/operations.py`)
Business logic for stack operations:
- **up** - Start stack, handle migration if needed
- **down** - Stop stack
- **preflight checks** - Verify mounts, networks exist before operations
- **discover** - Find running stacks on hosts
- **migrate** - Down on old host, up on new host
### Executor (`src/compose_farm/executor.py`)
SSH and local command execution:
- **Hybrid SSH approach**: asyncssh for parallel streaming, native `ssh -t` for raw mode
- **Parallel by default**: Multiple stacks via `asyncio.gather`
- **Streaming output**: Real-time stdout/stderr with `[stack]` prefix
- **Local detection**: Skips SSH when target matches local machine IP
### CLI (`src/compose_farm/cli/`)
Typer-based CLI with subcommand modules:
```
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, 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)
└── web.py # Web UI server command
```
## Command Flow
### cf up plex
```
1. Load configuration
└─► Parse compose-farm.yaml
└─► Validate stack exists
2. Check state
└─► Load state.yaml
└─► Is plex already running?
└─► Is it on a different host? (migration needed)
3. Pre-flight checks
└─► SSH to target host
└─► Check compose file exists
└─► Check required mounts exist
└─► Check required networks exist
4. Execute migration (if needed)
└─► SSH to old host
└─► Run: docker compose down
5. Start stack
└─► SSH to target host
└─► cd /opt/compose/plex
└─► Run: docker compose up -d
6. Update state
└─► Write new state to state.yaml
7. Generate Traefik config (if configured)
└─► Regenerate traefik file-provider
```
### cf apply
```
1. Load configuration and state
2. Compute diff
├─► Orphans: in state, not in config
├─► Migrations: in both, different host
└─► Missing: in config, not in state
3. Stop orphans
└─► For each orphan: cf down
4. Migrate stacks
└─► For each migration: down old, up new
5. Start missing
└─► For each missing: cf up
6. Update state
```
## SSH Execution
### Parallel Streaming (asyncssh)
For most operations, Compose Farm uses asyncssh:
```python
async def run_command(host, command):
async with asyncssh.connect(host) as conn:
result = await conn.run(command)
return result.stdout, result.stderr
```
Multiple stacks run concurrently via `asyncio.gather`.
### Raw Mode (native ssh)
For commands needing PTY (progress bars, interactive):
```bash
ssh -t user@host "docker compose pull"
```
### Local Detection
When target host IP matches local machine:
```python
if is_local(host_address):
# Run locally, no SSH
subprocess.run(command)
else:
# SSH to remote
ssh.run(command)
```
## State Management
### State File
Location: `compose-farm-state.yaml` (stored alongside the config file)
```yaml
deployed:
plex: nuc
grafana: nuc
```
Image digests are stored separately in `dockerfarm-log.toml` (also in the config directory).
### State Transitions
```
Config Change State Change Action
─────────────────────────────────────────────────────
Add stack Missing cf up
Remove stack Orphaned cf down
Change host Migration down old, up new
No change No change none (or refresh)
```
### cf refresh
Syncs state with reality by querying Docker on each host:
```bash
docker ps --format '{{.Names}}'
```
Updates state.yaml to match what's actually running.
## Compose File Discovery
For each stack, Compose Farm looks for compose files in:
```
{compose_dir}/{stack}/
├── compose.yaml # preferred
├── compose.yml
├── docker-compose.yml
└── docker-compose.yaml
```
First match wins.
## Traefik Integration
### Label Extraction
Compose Farm parses Traefik labels from compose files:
```yaml
stacks:
plex:
labels:
- traefik.enable=true
- traefik.http.routers.plex.rule=Host(`plex.example.com`)
- traefik.http.services.plex.loadbalancer.server.port=32400
```
### File Provider Generation
Converts labels to Traefik file-provider YAML:
```yaml
http:
routers:
plex:
rule: Host(`plex.example.com`)
service: plex
services:
plex:
loadBalancer:
servers:
- url: http://192.168.1.10:32400
```
### Variable Resolution
Supports `${VAR}` and `${VAR:-default}` from:
1. Service's `.env` file
2. Current environment
## Error Handling
### Pre-flight Failures
Before any operation, Compose Farm checks:
- SSH connectivity
- Compose file existence
- Required mounts
- Required networks
If checks fail, operation aborts with clear error.
### Partial Failures
When operating on multiple stacks:
- Each stack is independent
- Failures are logged, but other stacks continue
- Exit code reflects overall success/failure
## Performance Considerations
### Parallel Execution
Services are started/stopped in parallel:
```python
await asyncio.gather(*[
up_stack(stack) for stack in stacks
])
```
### SSH Multiplexing
For repeated connections to the same host, SSH reuses connections.
### Caching
- Config is parsed once per command
- State is loaded once, written once
- Host discovery results are cached during command
## Web UI Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Web UI │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ FastAPI │ │ Jinja │ │ HTMX │ │
│ │ Backend │ │ Templates │ │ Dynamic Updates │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ Pattern: Custom events, not hx-swap-oob │
│ Elements trigger on: cf:refresh from:body │
└─────────────────────────────────────────────────────────────┘
```
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html`.

3
docs/assets/apply.gif Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:01dabdd8f62773823ba2b8dc74f9931f1a1b88215117e6a080004096025491b0
size 901456

3
docs/assets/apply.webm Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:134c903a6b3acfb933617b33755b0cdb9bac2a59e5e35b64236e248a141d396d
size 206883

3
docs/assets/compose.gif Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8b3cdb3486ec79b3ddb2f7571c13d54ac9aed182edfe708eff76a966a90cfc7
size 1132310

3
docs/assets/compose.webm Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3c4d4a62f062f717df4e6752efced3caea29004dc90fe97fd7633e7f0ded9db
size 341057

3
docs/assets/install.gif Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c1bb48cc2f364681515a4d8bd0c586d133f5a32789b7bb64524ad7d9ed0a8e9
size 543135

3
docs/assets/install.webm Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5f82d96137f039f21964c15c1550aa1b1f0bb2d52c04d012d253dbfbd6fad096
size 268086

3
docs/assets/logs.gif Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2a4045b00d90928f42c7764b3c24751576cfb68a34c6e84d12b4e282d2e67378
size 146467

3
docs/assets/logs.webm Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1b94416ed3740853f863e19bf45f26241a203fb0d7d187160a537f79aa544fa
size 60353

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:848d9c48fb7511da7996149277c038589fad1ee406ff2f30c28f777fc441d919
size 1183641

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e747ee71bb38b19946005d5a4def4d423dadeaaade452dec875c4cb2d24a5b77
size 407373

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d32c9a3eec06e57df085ad347e6bf61e323f8bd8322d0c540f0b9d4834196dfd
size 3589776

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c54eda599389dac74c24c83527f95cd1399e653d7faf2972c2693d90e590597
size 1085344

3
docs/assets/update.gif Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62f9b5ec71496197a3f1c3e3bca8967d603838804279ea7dbf00a70d3391ff6c
size 127123

3
docs/assets/update.webm Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac2b93d3630af87b44a135723c5d10e8287529bed17c28301b2802cd9593e9e8
size 98748

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b50a7e9836c496c0989363d1440fa0a6ccdaa38ee16aae92b389b3cf3c3732f
size 2385110

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ccbb3d5366c7734377e12f98cca0b361028f5722124f1bb7efa231f6aeffc116
size 2208044

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:269993b52721ce70674d3aab2a4cd8c58aa621d4ba0739afedae661c90965b26
size 3678371

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0098b55bb6a52fa39f807a01fa352ce112bcb446e2a2acb963fb02d21b28c934
size 3088813

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4bf9d8c247d278799d1daea784fc662a22f12b1bd7883f808ef30f35025ebca6
size 4166443

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:02d5124217a94849bf2971d6d13d28da18c557195a81b9cca121fb7c07f0501b
size 3523244

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:412a0e68f8e52801cafbb9a703ca9577e7c14cc7c0e439160b9185961997f23c
size 4435697

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e600a1d3216b44497a889f91eac94d62ef7207b4ed0471465dcb72408caa28e
size 3764693

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3c07a283f4f70c4ab205b0f0acb5d6f55e3ced4c12caa7a8d5914ffe3548233a
size 5768166

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:562228841de976d70ee80999b930eadf3866a13ff2867d900279993744c44671
size 6667918

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:845746ac1cb101c3077d420c4f3fda3ca372492582dc123ac8a031a68ae9b6b1
size 12943150

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:189259558b5760c02583885168d7b0b47cf476cba81c7c028ec770f9d6033129
size 12415357

372
docs/best-practices.md Normal file
View File

@@ -0,0 +1,372 @@
---
icon: lucide/lightbulb
---
# Best Practices
Tips, limitations, and recommendations for using Compose Farm effectively.
## Limitations
### No Cross-Host Networking
Compose Farm moves containers between hosts but **does not provide cross-host networking**. Docker's internal DNS and networks don't span hosts.
**What breaks when you move a stack:**
| Feature | Works? | Why |
|---------|--------|-----|
| `http://redis:6379` | No | Docker DNS doesn't cross hosts |
| Docker network names | No | Networks are per-host |
| `DATABASE_URL=postgres://db:5432` | No | Container name won't resolve |
| Host IP addresses | Yes | Use `192.168.1.10:5432` |
### What Compose Farm Doesn't Do
- No overlay networking (use Swarm/Kubernetes)
- No service discovery across hosts
- No automatic dependency tracking between compose files
- No health checks or restart policies beyond Docker's
- No secrets management beyond Docker's
## Stack Organization
### Keep Dependencies Together
If services talk to each other, keep them in the same compose file on the same host:
```yaml
# /opt/compose/myapp/docker-compose.yml
services:
app:
image: myapp
depends_on:
- db
- redis
db:
image: postgres
redis:
image: redis
```
```yaml
# compose-farm.yaml
stacks:
myapp: nuc # All three containers stay together
```
### Separate Standalone Stacks
Stacks whose services don't talk to other containers can be anywhere:
```yaml
stacks:
# These can run on any host
plex: nuc
jellyfin: hp
homeassistant: nas
# These should stay together
myapp: nuc # includes app + db + redis
```
### Cross-Host Communication
If services MUST communicate across hosts, publish ports:
```yaml
# Instead of
DATABASE_URL=postgres://db:5432
# Use
DATABASE_URL=postgres://192.168.1.10:5432
```
```yaml
# And publish the port
services:
db:
ports:
- "5432:5432"
```
## Multi-Host Stacks
### When to Use `all`
Use `all` for stacks that need local access to each host:
```yaml
stacks:
# Need Docker socket
dozzle: all # Log viewer
portainer-agent: all # Portainer agents
autokuma: all # Auto-creates monitors
# Need host metrics
node-exporter: all # Prometheus metrics
promtail: all # Log shipping
```
### Host-Specific Lists
For stacks on specific hosts only:
```yaml
stacks:
# Only on compute nodes
gitlab-runner: [nuc, hp]
# Only on storage nodes
minio: [nas-1, nas-2]
```
## Migration Safety
### Pre-flight Checks
Before migrating, Compose Farm verifies:
- Compose file is accessible on new host
- Required mounts exist on new host
- Required networks exist on new host
### Data Considerations
**Compose Farm doesn't move data.** Ensure:
1. **Shared storage**: Data volumes on NFS/shared storage
2. **External databases**: Data in external DB, not container
3. **Backup first**: Always backup before migration
### Safe Migration Pattern
```bash
# 1. Preview changes
cf apply --dry-run
# 2. Verify target host can run the stack
cf check myservice
# 3. Apply changes
cf apply
```
## State Management
### When to Refresh
Run `cf refresh` after:
- Manual `docker compose` commands
- Container restarts
- Host reboots
- Any changes outside Compose Farm
```bash
cf refresh --dry-run # Preview
cf refresh # Sync
```
### State Conflicts
If state doesn't match reality:
```bash
# See what's actually running
cf refresh --dry-run
# Sync state
cf refresh
# Then apply config
cf apply
```
## Shared Storage
### NFS Best Practices
```bash
# Mount options for Docker compatibility
nas:/compose /opt/compose nfs rw,hard,intr,rsize=8192,wsize=8192 0 0
```
### Directory Ownership
Ensure consistent UID/GID across hosts:
```yaml
services:
myapp:
environment:
- PUID=1000
- PGID=1000
```
### Config vs Data
Keep config and data separate:
```
/opt/compose/ # Shared: compose files + config
├── plex/
│ ├── docker-compose.yml
│ └── config/ # Small config files OK
/mnt/data/ # Shared: large media files
├── movies/
├── tv/
└── music/
/opt/appdata/ # Local: per-host app data
├── plex/
└── grafana/
```
## Performance
### Parallel Operations
Compose Farm runs operations in parallel. For large deployments:
```bash
# Good: parallel by default
cf up --all
# Avoid: sequential updates when possible
for svc in plex grafana nextcloud; do
cf update $svc
done
```
### SSH Connection Reuse
SSH connections are reused within a command. For many operations:
```bash
# One command, one connection per host
cf update --all
# Multiple commands, multiple connections (slower)
cf update plex && cf update grafana && cf update nextcloud
```
## Traefik Setup
### Stack Placement
Put Traefik on a reliable host:
```yaml
stacks:
traefik: nuc # Primary host with good uptime
```
### Same-Host Stacks
Stacks on the same host as Traefik use Docker provider:
```yaml
traefik_stack: traefik
stacks:
traefik: nuc
portainer: nuc # Docker provider handles this
plex: hp # File provider handles this
```
### Middleware in Separate File
Define middlewares outside Compose Farm's generated file:
```yaml
# /opt/traefik/dynamic.d/middlewares.yml
http:
middlewares:
redirect-https:
redirectScheme:
scheme: https
```
## Backup Strategy
### What to Backup
| Item | Location | Method |
|------|----------|--------|
| Compose Farm config | `~/.config/compose-farm/` | Git or copy |
| Compose files | `/opt/compose/` | Git |
| State file | `~/.config/compose-farm/compose-farm-state.yaml` | Optional (can refresh) |
| App data | `/opt/appdata/` | Backup solution |
### Disaster Recovery
```bash
# Restore config
cp backup/compose-farm.yaml ~/.config/compose-farm/
# Refresh state from running containers
cf refresh
# Or start fresh
cf apply
```
## Troubleshooting
### Common Issues
**Stack won't start:**
```bash
cf check myservice # Verify mounts/networks
cf logs myservice # Check container logs
```
**Migration fails:**
```bash
cf check myservice # Verify new host is ready
cf init-network newhost # Create network if missing
```
**State out of sync:**
```bash
cf refresh --dry-run # See differences
cf refresh # Sync state
```
**SSH issues:**
```bash
cf ssh status # Check key status
cf ssh setup # Re-setup keys
```
## Security Considerations
### SSH Keys
- Use dedicated SSH key for Compose Farm
- Limit key to specific hosts if possible
- Don't store keys in Docker images
### Network Exposure
- Published ports are accessible from network
- Use firewalls for sensitive services
- Consider VPN for cross-host communication
### Secrets
- Don't commit `.env` files with secrets
- Use Docker secrets or external secret management
- Avoid secrets in compose file labels
## Comparison: When to Use Alternatives
| Scenario | Solution |
|----------|----------|
| 2-10 hosts, static stacks | **Compose Farm** |
| Cross-host container networking | Docker Swarm |
| Auto-scaling, self-healing | Kubernetes |
| Infrastructure as code | Ansible + Compose Farm |
| High availability requirements | Kubernetes or Swarm |

777
docs/commands.md Normal file
View File

@@ -0,0 +1,777 @@
---
icon: lucide/terminal
---
# Commands Reference
The Compose Farm CLI is available as both `compose-farm` and the shorter alias `cf`.
## Command Overview
| Category | Command | Description |
|----------|---------|-------------|
| **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 |
| **Configuration** | `check` | Validate config and mounts |
| | `refresh` | Sync state from reality |
| | `init-network` | Create Docker network |
| | `traefik-file` | Generate Traefik config |
| | `config` | Manage config files |
| | `ssh` | Manage SSH keys |
| **Server** | `web` | Start web UI |
## Global Options
```bash
cf --version, -v # Show version
cf --help, -h # Show help
```
---
## Lifecycle Commands
### cf apply
Make reality match your configuration. The primary reconciliation command.
<video autoplay loop muted playsinline>
<source src="/assets/apply.webm" type="video/webm">
</video>
```bash
cf apply [OPTIONS]
```
**Options:**
| Option | Description |
|--------|-------------|
| `--dry-run, -n` | Preview changes without executing |
| `--no-orphans` | Skip stopping orphaned stacks |
| `--full, -f` | Also refresh running stacks |
| `--config, -c PATH` | Path to config file |
**What it does:**
1. Stops orphaned stacks (in state but removed from config)
2. Migrates stacks on wrong host
3. Starts missing stacks (in config but not running)
**Examples:**
```bash
# Preview what would change
cf apply --dry-run
# Apply all changes
cf apply
# Only start/migrate, don't stop orphans
cf apply --no-orphans
# Also refresh all running stacks
cf apply --full
```
---
### cf up
Start stacks. Auto-migrates if host assignment changed.
```bash
cf up [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--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 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:**
If you change a stack's host in config and run `cf up`:
1. Verifies mounts/networks exist on new host
2. Runs `down` on old host
3. Runs `up -d` on new host
4. Updates state
---
### cf down
Stop stacks.
```bash
cf down [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--all, -a` | Stop all stacks |
| `--orphaned` | Stop orphaned stacks only |
| `--host, -H TEXT` | Filter to stacks on this host |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Stop specific stacks
cf down plex
# Stop all stacks
cf down --all
# Stop stacks removed from config
cf down --orphaned
# Stop all stacks on a host
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). With `--service`, restarts just that service.
```bash
cf restart [OPTIONS] [STACKS]...
```
**Options:**
| 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:**
```bash
cf restart plex
cf restart --all
# Restart a specific service
cf restart immich --service database
```
---
### cf update
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">
</video>
```bash
cf update [OPTIONS] [STACKS]...
```
**Options:**
| 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:**
```bash
# Update specific stack
cf update plex
# Update all stacks
cf update --all
# Update a specific service
cf update immich --service database
```
---
### cf pull
Pull latest images.
```bash
cf pull [OPTIONS] [STACKS]...
```
**Options:**
| 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:**
```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.
<video autoplay loop muted playsinline>
<source src="/assets/compose.webm" type="video/webm">
</video>
```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
```
---
## Monitoring Commands
### cf ps
Show status of stacks.
```bash
cf ps [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--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:**
```bash
# Show all stacks
cf ps
# Show specific stacks
cf ps plex grafana
# Filter by host
cf ps --host nuc
# Show status of a specific service
cf ps immich --service database
```
---
### cf logs
Show stack logs.
<video autoplay loop muted playsinline>
<source src="/assets/logs.webm" type="video/webm">
</video>
```bash
cf logs [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--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 |
**Examples:**
```bash
# Show last 100 lines
cf logs plex
# Follow logs
cf logs -f plex
# Show last 50 lines of multiple stacks
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
```
---
### cf stats
Show overview statistics.
```bash
cf stats [OPTIONS]
```
**Options:**
| Option | Description |
|--------|-------------|
| `--live, -l` | Query Docker for live container counts |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Config/state overview
cf stats
# Include live container counts
cf stats --live
```
---
## Configuration Commands
### cf check
Validate configuration, mounts, and networks.
```bash
cf check [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--local` | Skip SSH-based checks (faster) |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Full validation with SSH
cf check
# Fast local-only validation
cf check --local
# Check specific stack and show host compatibility
cf check jellyfin
```
---
### cf refresh
Update local state from running stacks.
```bash
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 (all stacks)
cf refresh
# Preview changes
cf refresh --dry-run
# Refresh specific stacks only
cf refresh plex sonarr
```
---
### cf init-network
Create Docker network on hosts with consistent settings.
```bash
cf init-network [OPTIONS] [HOSTS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--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 |
**Examples:**
```bash
# Create on all hosts
cf init-network
# Create on specific hosts
cf init-network nuc hp
# Custom network settings
cf init-network -n production -s 10.0.0.0/16 -g 10.0.0.1
```
---
### cf traefik-file
Generate Traefik file-provider config from compose labels.
```bash
cf traefik-file [OPTIONS] [STACKS]...
```
**Options:**
| Option | Description |
|--------|-------------|
| `--all, -a` | Generate for all stacks |
| `--output, -o PATH` | Output file (stdout if omitted) |
| `--config, -c PATH` | Path to config file |
**Examples:**
```bash
# Preview to stdout
cf traefik-file --all
# Write to file
cf traefik-file --all -o /opt/traefik/dynamic.d/cf.yml
# Specific stacks
cf traefik-file plex jellyfin -o /opt/traefik/cf.yml
```
---
### cf config
Manage configuration files.
```bash
cf config COMMAND
```
**Subcommands:**
| Command | Description |
|---------|-------------|
| `init` | Create new config with examples |
| `show` | Display config with highlighting |
| `path` | Print config file path |
| `validate` | Validate syntax and schema |
| `edit` | Open in $EDITOR |
| `symlink` | Create symlink from default location |
**Options by subcommand:**
| Subcommand | Options |
|------------|---------|
| `init` | `--path/-p PATH`, `--force/-f` |
| `show` | `--path/-p PATH`, `--raw/-r` |
| `edit` | `--path/-p PATH` |
| `path` | `--path/-p PATH` |
| `validate` | `--path/-p PATH` |
| `symlink` | `--force/-f` |
**Examples:**
```bash
# Create config at default location
cf config init
# Create config at custom path
cf config init --path /opt/compose-farm/config.yaml
# Show config with syntax highlighting
cf config show
# Show raw config (for copy-paste)
cf config show --raw
# Validate config
cf config validate
# Edit config in $EDITOR
cf config edit
# Print config path
cf config path
# Create symlink to local config
cf config symlink
# Create symlink to specific file
cf config symlink /opt/compose-farm/config.yaml
```
---
### cf ssh
Manage SSH keys for passwordless authentication.
```bash
cf ssh COMMAND
```
**Subcommands:**
| Command | Description |
|---------|-------------|
| `setup` | Generate key and copy to all hosts |
| `status` | Show SSH key status and host connectivity |
| `keygen` | Generate key without distributing |
**Options for `cf ssh setup`:**
| Option | Description |
|--------|-------------|
| `--config, -c PATH` | Path to config file |
| `--force, -f` | Regenerate key even if it exists |
**Options for `cf ssh status`:**
| Option | Description |
|--------|-------------|
| `--config, -c PATH` | Path to config file |
**Options for `cf ssh keygen`:**
| Option | Description |
|--------|-------------|
| `--force, -f` | Regenerate key even if it exists |
**Examples:**
```bash
# Set up SSH keys (generates and distributes)
cf ssh setup
# Check status and connectivity
cf ssh status
# Generate key only (don't distribute)
cf ssh keygen
```
---
## Server Commands
### cf web
Start the web UI server.
```bash
cf web [OPTIONS]
```
**Options:**
| Option | Description |
|--------|-------------|
| `--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 |
**Note:** Requires web dependencies: `pip install compose-farm[web]`
**Examples:**
```bash
# Start on default port
cf web
# Start on custom port
cf web --port 3000
# Development mode with auto-reload
cf web --reload
```
---
## Common Patterns
### Daily Operations
```bash
# Morning: check status
cf ps
cf stats --live
# Update a specific stack
cf update plex
# View logs
cf logs -f plex
```
### Maintenance
```bash
# Update all stacks
cf update --all
# Refresh state after manual changes
cf refresh
```
### Migration
```bash
# Preview what would change
cf apply --dry-run
# Move a stack: edit config, then
cf up plex # auto-migrates
# Or reconcile everything
cf apply
```
### Troubleshooting
```bash
# Validate config
cf check --local
cf check
# Check specific stack
cf check jellyfin
# Sync state
cf refresh --dry-run
cf refresh
```

418
docs/configuration.md Normal file
View File

@@ -0,0 +1,418 @@
---
icon: lucide/settings
---
# Configuration Reference
Compose Farm uses a YAML configuration file to define hosts and stack assignments.
## Config File Location
Compose Farm looks for configuration in this order:
1. `-c` / `--config` flag (if provided)
2. `CF_CONFIG` environment variable
3. `./compose-farm.yaml` (current directory)
4. `$XDG_CONFIG_HOME/compose-farm/compose-farm.yaml` (defaults to `~/.config`)
Use `-c` / `--config` to specify a custom path:
```bash
cf ps -c /path/to/config.yaml
```
Or set the environment variable:
```bash
export CF_CONFIG=/path/to/config.yaml
```
## Examples
### Single host (local-only)
```yaml
# Required: directory containing compose files
compose_dir: /opt/stacks
# Define local host
hosts:
local: localhost
# Map stacks to the local host
stacks:
plex: local
grafana: local
nextcloud: local
```
### Multi-host (full example)
```yaml
# Required: directory containing compose files (same path on all hosts)
compose_dir: /opt/compose
# Optional: auto-regenerate Traefik config
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
traefik_stack: traefik
# Define Docker hosts
hosts:
nuc:
address: 192.168.1.10
user: docker
hp:
address: 192.168.1.11
user: admin
# Map stacks to hosts
stacks:
# Single-host stacks
plex: nuc
grafana: nuc
nextcloud: hp
# Multi-host stacks
dozzle: all # Run on ALL hosts
node-exporter: [nuc, hp] # Run on specific hosts
```
## Settings Reference
### compose_dir (required)
Directory containing your compose stack folders. Must be the same path on all hosts.
```yaml
compose_dir: /opt/compose
```
**Directory structure:**
```
/opt/compose/
├── plex/
│ ├── docker-compose.yml # or compose.yaml
│ └── .env # optional environment file
├── grafana/
│ └── docker-compose.yml
└── ...
```
Supported compose file names (checked in order):
- `compose.yaml`
- `compose.yml`
- `docker-compose.yml`
- `docker-compose.yaml`
### traefik_file
Path to auto-generated Traefik file-provider config. When set, Compose Farm regenerates this file after `up`, `down`, `restart`, and `update` commands.
```yaml
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
```
### traefik_stack
Stack name running Traefik. Stacks on the same host are skipped in file-provider config (Traefik's docker provider handles them).
```yaml
traefik_stack: traefik
```
## Hosts Configuration
### Basic Host
```yaml
hosts:
myserver:
address: 192.168.1.10
```
### With SSH User
```yaml
hosts:
myserver:
address: 192.168.1.10
user: docker
```
If `user` is omitted, the current user is used.
### With Custom SSH Port
```yaml
hosts:
myserver:
address: 192.168.1.10
user: docker
port: 2222 # SSH port (default: 22)
```
### Localhost
For stacks running on the same machine where you invoke Compose Farm:
```yaml
hosts:
local: localhost
```
No SSH is used for localhost stacks.
### Multiple Hosts
```yaml
hosts:
nuc:
address: 192.168.1.10
user: docker
hp:
address: 192.168.1.11
user: admin
truenas:
address: 192.168.1.100
local: localhost
```
## Stacks Configuration
### Single-Host Stack
```yaml
stacks:
plex: nuc
grafana: nuc
nextcloud: hp
```
### Multi-Host Stack
For stacks that need to run on every host (e.g., log shippers, monitoring agents):
```yaml
stacks:
# Run on ALL configured hosts
dozzle: all
promtail: all
# Run on specific hosts
node-exporter: [nuc, hp, truenas]
```
**Common multi-host stacks:**
- **Dozzle** - Docker log viewer (needs local socket)
- **Promtail/Alloy** - Log shipping (needs local socket)
- **node-exporter** - Host metrics (needs /proc, /sys)
- **AutoKuma** - Uptime Kuma monitors (needs local socket)
### Stack Names
Stack names must match directory names in `compose_dir`:
```yaml
compose_dir: /opt/compose
stacks:
plex: nuc # expects /opt/compose/plex/docker-compose.yml
my-app: hp # expects /opt/compose/my-app/docker-compose.yml
```
## State File
Compose Farm tracks deployment state in `compose-farm-state.yaml`, stored alongside the config file.
For example, if your config is at `~/.config/compose-farm/compose-farm.yaml`, the state file will be at `~/.config/compose-farm/compose-farm-state.yaml`.
```yaml
deployed:
plex: nuc
grafana: nuc
```
This file records which stacks are deployed and on which host.
**Don't edit manually.** Use `cf refresh` to sync state with reality.
## Environment Variables
### In Compose Files
Your compose files can use `.env` files as usual:
```
/opt/compose/plex/
├── docker-compose.yml
└── .env
```
Compose Farm runs `docker compose` which handles `.env` automatically.
### In Traefik Labels
When generating Traefik config, Compose Farm resolves `${VAR}` and `${VAR:-default}` from:
1. The stack's `.env` file
2. Current environment
## Config Commands
### Initialize Config
```bash
cf config init
```
Creates a new config file with documented examples.
### Validate Config
```bash
cf config validate
```
Checks syntax and schema.
### Show Config
```bash
cf config show
```
Displays current config with syntax highlighting.
### Edit Config
```bash
cf config edit
```
Opens config in `$EDITOR`.
### Show Config Path
```bash
cf config path
```
Prints the config file location (useful for scripting).
### Create Symlink
```bash
cf config symlink # Link to ./compose-farm.yaml
cf config symlink /path/to/my-config.yaml # Link to specific file
```
Creates a symlink from the default location (`~/.config/compose-farm/compose-farm.yaml`) to your config file. Use `--force` to overwrite an existing symlink.
## Validation
### Local Validation
Fast validation without SSH:
```bash
cf check --local
```
Checks:
- Config syntax
- Stack-to-host mappings
- Compose file existence
### Full Validation
```bash
cf check
```
Additional SSH-based checks:
- Host connectivity
- Mount point existence
- Docker network existence
- Traefik label validation
### Stack-Specific Check
```bash
cf check jellyfin
```
Shows which hosts can run the stack (have required mounts/networks).
## Example Configurations
### Minimal
```yaml
compose_dir: /opt/compose
hosts:
server: 192.168.1.10
stacks:
myapp: server
```
### Home Lab
```yaml
compose_dir: /opt/compose
hosts:
nuc:
address: 192.168.1.10
user: docker
nas:
address: 192.168.1.100
user: admin
stacks:
# Media
plex: nuc
jellyfin: nuc
immich: nuc
# Infrastructure
traefik: nuc
portainer: nuc
# Monitoring (on all hosts)
dozzle: all
```
### Production
```yaml
compose_dir: /opt/compose
traefik_file: /opt/traefik/dynamic.d/cf.yml
traefik_stack: traefik
hosts:
web-1:
address: 10.0.1.10
user: deploy
web-2:
address: 10.0.1.11
user: deploy
db:
address: 10.0.1.20
user: deploy
stacks:
# Load balanced
api: [web-1, web-2]
# Single instance
postgres: db
redis: db
# Infrastructure
traefik: web-1
# Monitoring
promtail: all
```

17
docs/demos/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Demo Recordings
Demo recording infrastructure for Compose Farm documentation.
## Structure
```
docs/demos/
├── cli/ # VHS-based CLI terminal recordings
└── web/ # Playwright-based web UI recordings
```
## Output
All recordings output to `docs/assets/` as WebM (primary) and GIF (fallback).
See subdirectory READMEs for usage.

33
docs/demos/cli/README.md Normal file
View File

@@ -0,0 +1,33 @@
# CLI Demo Recordings
VHS-based terminal demo recordings for Compose Farm CLI.
## Requirements
- [VHS](https://github.com/charmbracelet/vhs): `go install github.com/charmbracelet/vhs@latest`
## Usage
```bash
# Record all demos
python docs/demos/cli/record.py
# Record specific demos
python docs/demos/cli/record.py quickstart migration
```
## Demos
| Tape | Description |
|------|-------------|
| `install.tape` | Installing with `uv tool install` |
| `quickstart.tape` | `cf ps`, `cf up`, `cf logs` |
| `logs.tape` | Viewing logs |
| `compose.tape` | `cf compose` passthrough (--help, images, exec) |
| `update.tape` | `cf update` |
| `migration.tape` | Service migration |
| `apply.tape` | `cf apply` |
## Output
GIF and WebM files saved to `docs/assets/`.

39
docs/demos/cli/apply.tape Normal file
View File

@@ -0,0 +1,39 @@
# Apply Demo
# Shows cf apply previewing and reconciling state
Output docs/assets/apply.gif
Output docs/assets/apply.webm
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 600
Set Theme "Catppuccin Mocha"
Set TypingSpeed 50ms
Type "# Preview what would change"
Enter
Sleep 500ms
Type "cf apply --dry-run"
Enter
Wait
Type "# Check current status"
Enter
Sleep 500ms
Type "cf stats"
Enter
Wait+Screen /Summary/
Sleep 2s
Type "# Apply the changes"
Enter
Sleep 500ms
Type "cf apply"
Enter
# Wait for shell prompt (command complete)
Wait
Sleep 4s

View File

@@ -0,0 +1,50 @@
# Compose Demo
# Shows that cf compose passes through ANY docker compose command
Output docs/assets/compose.gif
Output docs/assets/compose.webm
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 550
Set Theme "Catppuccin Mocha"
Set TypingSpeed 50ms
Type "# cf compose runs ANY docker compose command on the right host"
Enter
Sleep 500ms
Type "# See ALL available compose commands"
Enter
Sleep 500ms
Type "cf compose immich --help"
Enter
Sleep 4s
Type "# Show images"
Enter
Sleep 500ms
Type "cf compose immich images"
Enter
Wait+Screen /immich/
Sleep 2s
Type "# Open shell in a container"
Enter
Sleep 500ms
Type "cf compose immich exec immich-machine-learning sh"
Enter
Wait+Screen /#/
Sleep 1s
Type "python3 --version"
Enter
Sleep 1s
Type "exit"
Enter
Sleep 500ms

View File

@@ -0,0 +1,42 @@
# Installation Demo
# Shows installing compose-farm with uv
Output docs/assets/install.gif
Output docs/assets/install.webm
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 600
Set Theme "Catppuccin Mocha"
Set TypingSpeed 50ms
Env FORCE_COLOR "1"
Hide
Type "export PATH=$HOME/.local/bin:$PATH && uv tool uninstall compose-farm 2>/dev/null; clear"
Enter
Show
Type "# Install with uv (recommended)"
Enter
Sleep 500ms
Type "uv tool install compose-farm"
Enter
Wait+Screen /Installed|already installed/
Type "# Verify installation"
Enter
Sleep 500ms
Type "cf --version"
Enter
Wait+Screen /compose-farm/
Sleep 1s
Type "cf --help | less"
Enter
Sleep 2s
PageDown
Sleep 2s
Type "q"
Sleep 2s

21
docs/demos/cli/logs.tape Normal file
View File

@@ -0,0 +1,21 @@
# Logs Demo
# Shows viewing stack logs
Output docs/assets/logs.gif
Output docs/assets/logs.webm
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 550
Set Theme "Catppuccin Mocha"
Set TypingSpeed 50ms
Type "# View recent logs"
Enter
Sleep 500ms
Type "cf logs immich --tail 20"
Enter
Wait+Screen /immich/
Sleep 2s

View File

@@ -0,0 +1,71 @@
# Migration Demo
# Shows automatic stack migration when host changes
Output docs/assets/migration.gif
Output docs/assets/migration.webm
Set Shell "bash"
Set FontSize 14
Set Width 1000
Set Height 600
Set Theme "Catppuccin Mocha"
Set TypingSpeed 50ms
Type "# Current status: audiobookshelf on 'nas'"
Enter
Sleep 500ms
Type "cf ps audiobookshelf"
Enter
Wait+Screen /PORTS/
Type "# Edit config to move it to 'anton'"
Enter
Sleep 1s
Type "nvim /opt/stacks/compose-farm.yaml"
Enter
Wait+Screen /stacks:/
# Search for audiobookshelf
Type "/audiobookshelf"
Enter
Sleep 1s
# Move to the host value (nas) and change it
Type "f:"
Sleep 500ms
Type "w"
Sleep 500ms
Type "ciw"
Sleep 500ms
Type "anton"
Escape
Sleep 1s
# Save and quit
Type ":wq"
Enter
Sleep 1s
Type "# Run up - automatically migrates!"
Enter
Sleep 500ms
Type "cf up audiobookshelf"
Enter
# Wait for migration phases: first the stop on old host
Wait+Screen /Migrating|down/
# Then wait for start on new host
Wait+Screen /Starting|up/
# Finally wait for completion
Wait
Type "# Verify: audiobookshelf now on 'anton'"
Enter
Sleep 500ms
Type "cf ps audiobookshelf"
Enter
Wait+Screen /PORTS/
Sleep 3s

View File

@@ -0,0 +1,91 @@
# Quick Start Demo
# Shows basic cf commands
Output docs/assets/quickstart.gif
Output docs/assets/quickstart.webm
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 600
Set Theme "Catppuccin Mocha"
Set FontFamily "FiraCode Nerd Font"
Set TypingSpeed 50ms
Env BAT_PAGING "always"
Type "# Config is just: stack  host"
Enter
Sleep 500ms
Type "# First, define your hosts..."
Enter
Sleep 500ms
Type "bat -r 1:16 compose-farm.yaml"
Enter
Sleep 3s
Type "q"
Sleep 500ms
Type "# Then map each stack to a host"
Enter
Sleep 500ms
Type "bat -r 17:35 compose-farm.yaml"
Enter
Sleep 3s
Type "q"
Sleep 500ms
Type "# Check stack status"
Enter
Sleep 500ms
Type "cf ps immich"
Enter
Wait+Screen /PORTS/
Type "# Start a stack"
Enter
Sleep 500ms
Type "cf up immich"
Enter
Wait
Type "# View logs"
Enter
Sleep 500ms
Type "cf logs immich --tail 5"
Enter
Wait+Screen /immich/
Sleep 2s
Type "#  The magic: move between hosts (nas  anton)"
Enter
Sleep 500ms
Type "# Change host in config (using sed)"
Enter
Sleep 500ms
Type "sed -i 's/audiobookshelf: nas/audiobookshelf: anton/' compose-farm.yaml"
Enter
Sleep 500ms
Type "# Apply changes - auto-migrates!"
Enter
Sleep 500ms
Type "cf apply"
Enter
Sleep 15s
Type "# Verify: now on anton"
Enter
Sleep 500ms
Type "cf ps audiobookshelf"
Enter
Sleep 5s

134
docs/demos/cli/record.py Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Record CLI demos using VHS."""
import shutil
import subprocess
import sys
from pathlib import Path
from rich.console import Console
from compose_farm.config import load_config
from compose_farm.state import load_state
console = Console()
SCRIPT_DIR = Path(__file__).parent
STACKS_DIR = Path("/opt/stacks")
CONFIG_FILE = STACKS_DIR / "compose-farm.yaml"
OUTPUT_DIR = SCRIPT_DIR.parent.parent / "assets"
DEMOS = ["install", "quickstart", "logs", "compose", "update", "migration", "apply"]
def _run(cmd: list[str], **kw) -> bool:
return subprocess.run(cmd, check=False, **kw).returncode == 0
def _set_config(host: str) -> None:
"""Set audiobookshelf host in config file."""
_run(["sed", "-i", f"s/audiobookshelf: .*/audiobookshelf: {host}/", str(CONFIG_FILE)])
def _get_hosts() -> tuple[str | None, str | None]:
"""Return (config_host, state_host) for audiobookshelf."""
config = load_config()
state = load_state(config)
return config.stacks.get("audiobookshelf"), state.get("audiobookshelf")
def _setup_state(demo: str) -> bool:
"""Set up required state for demo. Returns False on failure."""
if demo not in ("migration", "apply"):
return True
config_host, state_host = _get_hosts()
if demo == "migration":
# Migration needs audiobookshelf on nas in BOTH config and state
if config_host != "nas":
console.print("[yellow]Setting up: config → nas[/yellow]")
_set_config("nas")
if state_host != "nas":
console.print("[yellow]Setting up: state → nas[/yellow]")
if not _run(["cf", "apply"], cwd=STACKS_DIR):
return False
elif demo == "apply":
# Apply needs config=nas, state=anton (so there's something to apply)
if config_host != "nas":
console.print("[yellow]Setting up: config → nas[/yellow]")
_set_config("nas")
if state_host == "nas":
console.print("[yellow]Setting up: state → anton[/yellow]")
_set_config("anton")
if not _run(["cf", "apply"], cwd=STACKS_DIR):
return False
_set_config("nas")
return True
def _record(name: str, index: int, total: int) -> bool:
"""Record a single demo."""
console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] {name}")
if _run(["vhs", str(SCRIPT_DIR / f"{name}.tape")], cwd=STACKS_DIR):
console.print("[green] ✓ Done[/green]")
return True
console.print("[red] ✗ Failed[/red]")
return False
def _reset_after(demo: str, next_demo: str | None) -> None:
"""Reset state after demos that modify audiobookshelf."""
if demo not in ("quickstart", "migration"):
return
_set_config("nas")
if next_demo != "apply": # Let apply demo show the migration
_run(["cf", "apply"], cwd=STACKS_DIR)
def _restore_config(original: str) -> None:
"""Restore original config and sync state."""
console.print("[yellow]Restoring original config...[/yellow]")
CONFIG_FILE.write_text(original)
_run(["cf", "apply"], cwd=STACKS_DIR)
def _main() -> int:
if not shutil.which("vhs"):
console.print("[red]VHS not found. Install: brew install vhs[/red]")
return 1
if not _run(["git", "-C", str(STACKS_DIR), "diff", "--quiet", "compose-farm.yaml"]):
console.print("[red]compose-farm.yaml has uncommitted changes[/red]")
return 1
demos = [d for d in sys.argv[1:] if d in DEMOS] or DEMOS
if sys.argv[1:] and not demos:
console.print(f"[red]Unknown demo. Available: {', '.join(DEMOS)}[/red]")
return 1
# Save original config to restore after recording
original_config = CONFIG_FILE.read_text()
try:
for i, demo in enumerate(demos, 1):
if not _setup_state(demo):
return 1
if not _record(demo, i, len(demos)):
return 1
_reset_after(demo, demos[i] if i < len(demos) else None)
finally:
_restore_config(original_config)
# Move outputs
OUTPUT_DIR.mkdir(exist_ok=True)
for f in (STACKS_DIR / "docs/assets").glob("*.[gw]*"):
shutil.move(str(f), str(OUTPUT_DIR / f.name))
console.print(f"\n[green]Done![/green] Saved to {OUTPUT_DIR}")
return 0
if __name__ == "__main__":
sys.exit(_main())

View File

@@ -0,0 +1,32 @@
# Update Demo
# Shows updating stacks (pull + build + down + up)
Output docs/assets/update.gif
Output docs/assets/update.webm
Set Shell "bash"
Set FontSize 14
Set Width 900
Set Height 500
Set Theme "Catppuccin Mocha"
Set TypingSpeed 50ms
Type "# Update a single stack"
Enter
Sleep 500ms
Type "cf update grocy"
Enter
# Wait for command to complete (chain waits for longer timeout)
Wait+Screen /pull/
Wait+Screen /grocy/
Wait@60s
Type "# Check current status"
Enter
Sleep 500ms
Type "cf ps grocy"
Enter
Wait+Screen /PORTS/
Sleep 1s

45
docs/demos/web/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Web UI Demo Recordings
Playwright-based demo recording for Compose Farm web UI.
## Requirements
- Chromium: `playwright install chromium`
- ffmpeg: `apt install ffmpeg` or `brew install ffmpeg`
## Usage
```bash
# Record all demos
python docs/demos/web/record.py
# Record specific demo
python docs/demos/web/record.py navigation
```
## Demos
| Demo | Description |
|------|-------------|
| `navigation` | Command palette fuzzy search and navigation |
| `stack` | Stack restart/logs via command palette |
| `themes` | Theme switching with arrow key preview |
| `workflow` | Full workflow: filter, navigate, logs, themes |
| `console` | Console terminal running cf commands |
| `shell` | Container shell exec with top |
## Output
WebM and GIF files saved to `docs/assets/web-{demo}.{webm,gif}`.
## Files
- `record.py` - Orchestration script
- `conftest.py` - Playwright fixtures, helper functions
- `demo_*.py` - Individual demo scripts
## Notes
- Uses real config at `/opt/stacks/compose-farm.yaml`
- Adjust `pause(page, ms)` calls to control timing
- Viewport: 1280x720

View File

@@ -0,0 +1 @@
"""Web UI demo recording scripts."""

224
docs/demos/web/conftest.py Normal file
View File

@@ -0,0 +1,224 @@
"""Shared fixtures for web UI demo recordings.
Based on tests/web/test_htmx_browser.py patterns for consistency.
"""
from __future__ import annotations
import os
import re
import shutil
import socket
import threading
import time
import urllib.request
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
import uvicorn
from compose_farm.config import Config as CFConfig
from compose_farm.config import load_config
from compose_farm.state import load_state as _original_load_state
from compose_farm.web.app import create_app
from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache
if TYPE_CHECKING:
from collections.abc import Generator
from playwright.sync_api import BrowserContext, Page, Route
# Stacks to exclude from demo recordings (exact match)
DEMO_EXCLUDE_STACKS = {"arr"}
def _get_filtered_config() -> CFConfig:
"""Load config but filter out excluded stacks."""
config = load_config()
filtered_stacks = {
name: host for name, host in config.stacks.items() if name not in DEMO_EXCLUDE_STACKS
}
return CFConfig(
compose_dir=config.compose_dir,
hosts=config.hosts,
stacks=filtered_stacks,
traefik_file=config.traefik_file,
traefik_stack=config.traefik_stack,
config_path=config.config_path,
)
def _get_filtered_state(config: CFConfig) -> dict[str, str | list[str]]:
"""Load state but filter out excluded stacks."""
state = _original_load_state(config)
return {name: host for name, host in state.items() if name not in DEMO_EXCLUDE_STACKS}
@pytest.fixture(scope="session")
def vendor_cache(request: pytest.FixtureRequest) -> Path:
"""Download CDN assets once and cache to disk for faster recordings."""
cache_dir = Path(str(request.config.rootdir)) / ".pytest_cache" / "vendor"
return ensure_vendor_cache(cache_dir)
@pytest.fixture(scope="session")
def browser_type_launch_args() -> dict[str, str]:
"""Configure Playwright to use system Chromium if available."""
for name in ["chromium", "chromium-browser", "google-chrome", "chrome"]:
path = shutil.which(name)
if path:
return {"executable_path": path}
return {}
# Path to real compose-farm config
REAL_CONFIG_PATH = Path("/opt/stacks/compose-farm.yaml")
@pytest.fixture(scope="module")
def server_url() -> Generator[str, None, None]:
"""Start demo server using real config (with filtered stacks) and return URL."""
os.environ["CF_CONFIG"] = str(REAL_CONFIG_PATH)
# Patch at source module level so all callers get filtered versions
patches = [
# Patch load_state at source - all functions calling it get filtered state
patch("compose_farm.state.load_state", _get_filtered_state),
# Patch get_config where imported
patch("compose_farm.web.routes.pages.get_config", _get_filtered_config),
patch("compose_farm.web.routes.api.get_config", _get_filtered_config),
patch("compose_farm.web.routes.actions.get_config", _get_filtered_config),
patch("compose_farm.web.app.get_config", _get_filtered_config),
patch("compose_farm.web.ws.get_config", _get_filtered_config),
]
for p in patches:
p.start()
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
app = create_app()
uvicorn_config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
server = uvicorn.Server(uvicorn_config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
url = f"http://127.0.0.1:{port}"
server_ready = False
for _ in range(50):
try:
urllib.request.urlopen(url, timeout=0.5) # noqa: S310
server_ready = True
break
except Exception:
time.sleep(0.1)
if not server_ready:
msg = f"Demo server failed to start on {url}"
raise RuntimeError(msg)
yield url
server.should_exit = True
thread.join(timeout=2)
os.environ.pop("CF_CONFIG", None)
for p in patches:
p.stop()
@pytest.fixture(scope="module")
def recording_output_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Directory for video recordings."""
return Path(tmp_path_factory.mktemp("recordings"))
@pytest.fixture
def recording_context(
browser: Any, # pytest-playwright's browser fixture
vendor_cache: Path,
recording_output_dir: Path,
) -> Generator[BrowserContext, None, None]:
"""Browser context with video recording enabled."""
context = browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir=str(recording_output_dir),
record_video_size={"width": 1280, "height": 720},
)
# Set up CDN interception
cache = {url: (vendor_cache / f, ct) for url, (f, ct) in CDN_ASSETS.items()}
def handle_cdn(route: Route) -> None:
url = route.request.url
for url_prefix, (filepath, content_type) in cache.items():
if url.startswith(url_prefix):
route.fulfill(status=200, content_type=content_type, body=filepath.read_bytes())
return
route.abort("failed")
context.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)
yield context
context.close()
@pytest.fixture
def recording_page(recording_context: BrowserContext) -> Generator[Page, None, None]:
"""Page with recording and slow motion enabled."""
page = recording_context.new_page()
yield page
page.close()
# Demo helper functions
def pause(page: Page, ms: int = 500) -> None:
"""Pause for visibility in recording."""
page.wait_for_timeout(ms)
def slow_type(page: Page, selector: str, text: str, delay: int = 100) -> None:
"""Type with visible delay between keystrokes."""
page.type(selector, text, delay=delay)
def open_command_palette(page: Page) -> None:
"""Open command palette with Ctrl+K."""
page.keyboard.press("Control+k")
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 300)
def close_command_palette(page: Page) -> None:
"""Close command palette with Escape."""
page.keyboard.press("Escape")
page.wait_for_selector("#cmd-palette:not([open])", timeout=2000)
pause(page, 200)
def wait_for_sidebar(page: Page) -> None:
"""Wait for sidebar to load with stacks."""
page.wait_for_selector("#sidebar-stacks", timeout=5000)
pause(page, 300)
def navigate_to_stack(page: Page, stack: str) -> None:
"""Navigate to a stack page via sidebar click."""
page.locator("#sidebar-stacks a", has_text=stack).click()
page.wait_for_url(f"**/stack/{stack}", timeout=5000)
pause(page, 500)
def select_command(page: Page, command: str) -> None:
"""Filter and select a command from the palette."""
page.locator("#cmd-input").fill(command)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 200)

View File

@@ -0,0 +1,77 @@
"""Demo: Console terminal.
Records a ~30 second demo showing:
- Navigating to Console page
- Running cf commands in the terminal
- Showing the Compose Farm config in Monaco editor
Run: pytest docs/demos/web/demo_console.py -v --no-cov
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from conftest import (
pause,
slow_type,
wait_for_sidebar,
)
if TYPE_CHECKING:
from playwright.sync_api import Page
@pytest.mark.browser # type: ignore[misc]
def test_demo_console(recording_page: Page, server_url: str) -> None:
"""Record console terminal demo."""
page = recording_page
# Start on dashboard
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 800)
# Navigate to Console page via sidebar menu
page.locator(".menu a", has_text="Console").click()
page.wait_for_url("**/console", timeout=5000)
pause(page, 1000)
# Wait for terminal to be ready (auto-connects)
page.wait_for_selector("#console-terminal .xterm", timeout=10000)
pause(page, 1500)
# Run fastfetch first
slow_type(page, "#console-terminal .xterm-helper-textarea", "fastfetch", delay=80)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 2500) # Wait for output
# Type cf stats command
slow_type(page, "#console-terminal .xterm-helper-textarea", "cf stats", delay=80)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 3000) # Wait for output
# Type cf ps command
slow_type(page, "#console-terminal .xterm-helper-textarea", "cf ps grocy", delay=80)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 2500) # Wait for output
# Smoothly scroll down to show the Editor section with Compose Farm config
page.evaluate("""
const editor = document.getElementById('console-editor');
if (editor) {
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200) # Wait for smooth scroll animation
# Wait for Monaco editor to load with config content
page.wait_for_selector("#console-editor .monaco-editor", timeout=10000)
pause(page, 2500) # Let viewer see the Compose Farm config file
# Final pause
pause(page, 800)

View File

@@ -0,0 +1,74 @@
"""Demo: Command palette navigation.
Records a ~15 second demo showing:
- Opening command palette with Ctrl+K
- Fuzzy search filtering
- Arrow key navigation
- Stack and page navigation
Run: pytest docs/demos/web/demo_navigation.py -v --no-cov
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from conftest import (
open_command_palette,
pause,
slow_type,
wait_for_sidebar,
)
if TYPE_CHECKING:
from playwright.sync_api import Page
@pytest.mark.browser # type: ignore[misc]
def test_demo_navigation(recording_page: Page, server_url: str) -> None:
"""Record command palette navigation demo."""
page = recording_page
# Start on dashboard
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 1000) # Let viewer see dashboard
# Open command palette with keyboard shortcut
open_command_palette(page)
pause(page, 500)
# Type partial stack name for fuzzy search
slow_type(page, "#cmd-input", "grocy", delay=120)
pause(page, 800)
# Arrow down to show selection movement
page.keyboard.press("ArrowDown")
pause(page, 400)
page.keyboard.press("ArrowUp")
pause(page, 400)
# Press Enter to navigate to stack
page.keyboard.press("Enter")
page.wait_for_url("**/stack/grocy", timeout=5000)
pause(page, 1500) # Show stack page
# Open palette again to navigate elsewhere
open_command_palette(page)
pause(page, 400)
# Navigate to another stack (immich) to show more navigation
slow_type(page, "#cmd-input", "imm", delay=120)
pause(page, 600)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/immich", timeout=5000)
pause(page, 1200) # Show immich stack page
# Open palette one more time, navigate back to dashboard
open_command_palette(page)
slow_type(page, "#cmd-input", "dashb", delay=120)
pause(page, 500)
page.keyboard.press("Enter")
page.wait_for_url(server_url, timeout=5000)
pause(page, 1000) # Final dashboard view

View File

@@ -0,0 +1,106 @@
"""Demo: Container shell exec via command palette.
Records a ~35 second demo showing:
- Navigating to immich stack (multiple containers)
- Using command palette with fuzzy matching ("sh mach") to open shell
- Running a command
- Using command palette to switch to server container shell
- Running another command
Run: pytest docs/demos/web/demo_shell.py -v --no-cov
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from conftest import (
open_command_palette,
pause,
slow_type,
wait_for_sidebar,
)
if TYPE_CHECKING:
from playwright.sync_api import Page
@pytest.mark.browser # type: ignore[misc]
def test_demo_shell(recording_page: Page, server_url: str) -> None:
"""Record container shell demo."""
page = recording_page
# Start on dashboard
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 800)
# Navigate to immich via command palette (has multiple containers)
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "immich", delay=100)
pause(page, 600)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/immich", timeout=5000)
pause(page, 1500)
# Wait for containers list to load (so shell commands are available)
page.wait_for_selector("#containers-list button", timeout=10000)
pause(page, 800)
# Use command palette with fuzzy matching: "sh mach" -> "Shell: immich-machine-learning"
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "sh mach", delay=100)
pause(page, 600)
page.keyboard.press("Enter")
pause(page, 1000)
# Wait for exec terminal to appear
page.wait_for_selector("#exec-terminal .xterm", timeout=10000)
# Smoothly scroll down to make the terminal visible
page.evaluate("""
const terminal = document.getElementById('exec-terminal');
if (terminal) {
terminal.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200)
# Run python version command
slow_type(page, "#exec-terminal .xterm-helper-textarea", "python3 --version", delay=60)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 1500)
# Blur the terminal to release focus (won't scroll)
page.evaluate("document.activeElement?.blur()")
pause(page, 500)
# Use command palette to switch to server container: "sh serv" -> "Shell: immich-server"
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "sh serv", delay=100)
pause(page, 600)
page.keyboard.press("Enter")
pause(page, 1000)
# Wait for new terminal
page.wait_for_selector("#exec-terminal .xterm", timeout=10000)
# Scroll to terminal
page.evaluate("""
const terminal = document.getElementById('exec-terminal');
if (terminal) {
terminal.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200)
# Run ls command
slow_type(page, "#exec-terminal .xterm-helper-textarea", "ls /usr/src/app", delay=60)
pause(page, 300)
page.keyboard.press("Enter")
pause(page, 2000)

View File

@@ -0,0 +1,101 @@
"""Demo: Stack actions.
Records a ~30 second demo showing:
- Navigating to a stack page
- Viewing compose file in Monaco editor
- Triggering Restart action via command palette
- Watching terminal output stream
- Triggering Logs action
Run: pytest docs/demos/web/demo_stack.py -v --no-cov
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from conftest import (
open_command_palette,
pause,
slow_type,
wait_for_sidebar,
)
if TYPE_CHECKING:
from playwright.sync_api import Page
@pytest.mark.browser # type: ignore[misc]
def test_demo_stack(recording_page: Page, server_url: str) -> None:
"""Record stack actions demo."""
page = recording_page
# Start on dashboard
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 800)
# Navigate to grocy via command palette
open_command_palette(page)
pause(page, 400)
slow_type(page, "#cmd-input", "grocy", delay=100)
pause(page, 500)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/grocy", timeout=5000)
pause(page, 1000) # Show stack page
# Click on Compose File collapse to show the Monaco editor
# The collapse uses a checkbox input, click it via the parent collapse div
compose_collapse = page.locator(".collapse", has_text="Compose File").first
compose_collapse.locator("input[type=checkbox]").click(force=True)
pause(page, 500)
# Wait for Monaco editor to load and show content
page.wait_for_selector("#compose-editor .monaco-editor", timeout=10000)
pause(page, 2000) # Let viewer see the compose file
# Smoothly scroll down to show more of the editor
page.evaluate("""
const editor = document.getElementById('compose-editor');
if (editor) {
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200) # Wait for smooth scroll animation
# Close the compose file section
compose_collapse.locator("input[type=checkbox]").click(force=True)
pause(page, 500)
# Open command palette for stack actions
open_command_palette(page)
pause(page, 400)
# Filter to Restart action
slow_type(page, "#cmd-input", "restart", delay=120)
pause(page, 600)
# Execute Restart
page.keyboard.press("Enter")
pause(page, 300)
# Wait for terminal to expand and show output
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500) # Let viewer see terminal streaming
# Open palette again for Logs
open_command_palette(page)
pause(page, 400)
# Filter to Logs action
slow_type(page, "#cmd-input", "logs", delay=120)
pause(page, 600)
# Execute Logs
page.keyboard.press("Enter")
pause(page, 300)
# Show log output
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500) # Final view of logs

View File

@@ -0,0 +1,81 @@
"""Demo: Theme switching.
Records a ~15 second demo showing:
- Opening theme picker via theme button
- Live theme preview on arrow navigation
- Selecting different themes
- Theme persistence
Run: pytest docs/demos/web/demo_themes.py -v --no-cov
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from conftest import (
pause,
slow_type,
wait_for_sidebar,
)
if TYPE_CHECKING:
from playwright.sync_api import Page
@pytest.mark.browser # type: ignore[misc]
def test_demo_themes(recording_page: Page, server_url: str) -> None:
"""Record theme switching demo."""
page = recording_page
# Start on dashboard
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 1000) # Show initial theme
# Click theme button to open theme picker
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 600)
# Arrow through many themes to show live preview effect
for _ in range(12):
page.keyboard.press("ArrowDown")
pause(page, 350) # Show each preview
# Go back up through a few (land on valentine, not cyberpunk)
for _ in range(4):
page.keyboard.press("ArrowUp")
pause(page, 350)
# Select current theme with Enter
page.keyboard.press("Enter")
pause(page, 1000)
# Close palette with Escape
page.keyboard.press("Escape")
pause(page, 800)
# Open again and use search to find specific theme
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 400)
# Type to filter to a light theme (theme button pre-populates "theme:")
slow_type(page, "#cmd-input", "cup", delay=100)
pause(page, 500)
page.keyboard.press("Enter")
pause(page, 1000)
# Close and return to dark
page.keyboard.press("Escape")
pause(page, 500)
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 300)
slow_type(page, "#cmd-input", "dark", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 800)

View File

@@ -0,0 +1,189 @@
"""Demo: Full workflow.
Records a comprehensive demo (~60 seconds) combining all major features:
1. Console page: terminal with fastfetch, cf pull command
2. Editor showing Compose Farm YAML config
3. Command palette navigation to grocy stack
4. Stack actions: up, logs
5. Switch to dozzle stack via command palette, run update
6. Dashboard overview
7. Theme cycling via command palette
This demo is used on the homepage and Web UI page as the main showcase.
Run: pytest docs/demos/web/demo_workflow.py -v --no-cov
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from conftest import open_command_palette, pause, slow_type, wait_for_sidebar
if TYPE_CHECKING:
from playwright.sync_api import Page
def _demo_console_terminal(page: Page, server_url: str) -> None:
"""Demo part 1: Console page with terminal and editor."""
# Start on dashboard briefly
page.goto(server_url)
wait_for_sidebar(page)
pause(page, 800)
# Navigate to Console page via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "cons", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url("**/console", timeout=5000)
pause(page, 800)
# Wait for terminal to be ready
page.wait_for_selector("#console-terminal .xterm", timeout=10000)
pause(page, 1000)
# Run fastfetch first
slow_type(page, "#console-terminal .xterm-helper-textarea", "fastfetch", delay=60)
pause(page, 200)
page.keyboard.press("Enter")
pause(page, 2000) # Wait for output
# Run cf pull on a stack to show Compose Farm in action
slow_type(page, "#console-terminal .xterm-helper-textarea", "cf pull grocy", delay=60)
pause(page, 200)
page.keyboard.press("Enter")
pause(page, 3000) # Wait for pull output
def _demo_config_editor(page: Page) -> None:
"""Demo part 2: Show the Compose Farm config in editor."""
# Smoothly scroll down to show the Editor section
# Use JavaScript for smooth scrolling animation
page.evaluate("""
const editor = document.getElementById('console-editor');
if (editor) {
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
""")
pause(page, 1200) # Wait for smooth scroll animation
# Wait for Monaco editor to load with config content
page.wait_for_selector("#console-editor .monaco-editor", timeout=10000)
pause(page, 2000) # Let viewer see the Compose Farm config file
def _demo_stack_actions(page: Page) -> None:
"""Demo part 3: Navigate to stack and run actions."""
# Click on sidebar to take focus away from terminal, then use command palette
page.locator("#sidebar-stacks").click()
pause(page, 300)
# Navigate to grocy via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "grocy", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/grocy", timeout=5000)
pause(page, 1000)
# Open Compose File editor to show the compose.yaml
compose_collapse = page.locator(".collapse", has_text="Compose File").first
compose_collapse.locator("input[type=checkbox]").click(force=True)
pause(page, 500)
# Wait for Monaco editor to load and show content
page.wait_for_selector("#compose-editor .monaco-editor", timeout=10000)
pause(page, 2000) # Let viewer see the compose file
# Close the compose file section
compose_collapse.locator("input[type=checkbox]").click(force=True)
pause(page, 500)
# Run Up action via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "up", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 200)
# Wait for terminal output
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500)
# Show logs
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "logs", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 200)
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500)
# Switch to dozzle via command palette (on nas for lower latency)
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "dozzle", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url("**/stack/dozzle", timeout=5000)
pause(page, 1000)
# Run update action
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "upda", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 200)
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
pause(page, 2500)
def _demo_dashboard_and_themes(page: Page, server_url: str) -> None:
"""Demo part 4: Dashboard and theme cycling."""
# Navigate to dashboard via command palette
open_command_palette(page)
pause(page, 300)
slow_type(page, "#cmd-input", "dash", delay=100)
pause(page, 400)
page.keyboard.press("Enter")
page.wait_for_url(server_url, timeout=5000)
pause(page, 800)
# Scroll to top of page to ensure dashboard is fully visible
page.evaluate("window.scrollTo(0, 0)")
pause(page, 600)
# Open theme picker and arrow down to Dracula (shows live preview)
page.locator("#theme-btn").click()
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
pause(page, 400)
# Arrow down through themes with live preview until we reach Dracula
for _ in range(19):
page.keyboard.press("ArrowDown")
pause(page, 180)
# Select Dracula theme and end on it
pause(page, 400)
page.keyboard.press("Enter")
pause(page, 1500)
@pytest.mark.browser # type: ignore[misc]
def test_demo_workflow(recording_page: Page, server_url: str) -> None:
"""Record full workflow demo."""
page = recording_page
_demo_console_terminal(page, server_url)
_demo_config_editor(page)
_demo_stack_actions(page)
_demo_dashboard_and_themes(page, server_url)

258
docs/demos/web/record.py Executable file
View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Record all web UI demos.
This script orchestrates recording of web UI demos using Playwright,
then converts the WebM recordings to GIF format.
Usage:
python docs/demos/web/record.py # Record all demos
python docs/demos/web/record.py navigation # Record specific demo
Requirements:
- Playwright with Chromium: playwright install chromium
- ffmpeg for GIF conversion: apt install ffmpeg / brew install ffmpeg
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from rich.console import Console
console = Console()
SCRIPT_DIR = Path(__file__).parent
REPO_DIR = SCRIPT_DIR.parent.parent.parent
OUTPUT_DIR = REPO_DIR / "docs" / "assets"
DEMOS = [
"navigation",
"stack",
"themes",
"workflow",
"console",
"shell",
]
# High-quality ffmpeg settings for VP8 encoding
# See: https://github.com/microsoft/playwright/issues/10855
# See: https://github.com/microsoft/playwright/issues/31424
#
# MAX_QUALITY: Lossless-like, largest files
# BALANCED_QUALITY: ~43% file size, nearly indistinguishable quality
MAX_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 0 -crf 0 -deadline best -speed 0 -b:v 0 -threads 0"
BALANCED_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 10 -crf 4 -deadline best -speed 0 -b:v 0 -threads 0"
# Choose which quality to use
VIDEO_QUALITY_ARGS = MAX_QUALITY_ARGS
def patch_playwright_video_quality() -> None:
"""Patch Playwright's videoRecorder.js to use high-quality encoding settings."""
from playwright._impl._driver import compute_driver_executable # noqa: PLC0415
# compute_driver_executable returns (node_path, cli_path)
result = compute_driver_executable()
node_path = result[0] if isinstance(result, tuple) else result
driver_path = Path(node_path).parent
video_recorder = driver_path / "package" / "lib" / "server" / "chromium" / "videoRecorder.js"
if not video_recorder.exists():
msg = f"videoRecorder.js not found at {video_recorder}"
raise FileNotFoundError(msg)
content = video_recorder.read_text()
# Check if already patched
if "deadline best" in content:
return # Already patched
# Pattern to match the ffmpeg args line
pattern = (
r"-c:v vp8 -qmin \d+ -qmax \d+ -crf \d+ -deadline \w+ -speed \d+ -b:v \w+ -threads \d+"
)
if not re.search(pattern, content):
msg = "Could not find ffmpeg args pattern in videoRecorder.js"
raise ValueError(msg)
# Replace with high-quality settings
new_content = re.sub(pattern, VIDEO_QUALITY_ARGS, content)
video_recorder.write_text(new_content)
console.print("[green]Patched Playwright for high-quality video recording[/green]")
def record_demo(name: str, index: int, total: int) -> Path | None:
"""Run a single demo and return the video path."""
console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] web-{name}")
demo_file = SCRIPT_DIR / f"demo_{name}.py"
if not demo_file.exists():
console.print(f"[red] Demo file not found: {demo_file}[/red]")
return None
# Create temp output dir for this recording
temp_dir = SCRIPT_DIR / ".recordings"
temp_dir.mkdir(exist_ok=True)
# Run pytest with video recording
# Set PYTHONPATH so conftest.py imports work
env = {**os.environ, "PYTHONPATH": str(SCRIPT_DIR)}
result = subprocess.run(
[
sys.executable,
"-m",
"pytest",
str(demo_file),
"-v",
"--no-cov",
"-x", # Stop on first failure
f"--basetemp={temp_dir}",
],
check=False,
cwd=REPO_DIR,
capture_output=True,
text=True,
env=env,
)
if result.returncode != 0:
console.print(f"[red] Failed to record {name}[/red]")
console.print(result.stdout)
console.print(result.stderr)
return None
# Find the recorded video
videos = list(temp_dir.rglob("*.webm"))
if not videos:
console.print(f"[red] No video found for {name}[/red]")
return None
# Use the most recent video
video = max(videos, key=lambda p: p.stat().st_mtime)
console.print(f"[green] Recorded: {video.name}[/green]")
return video
def convert_to_gif(webm_path: Path, output_name: str) -> Path:
"""Convert WebM to GIF using ffmpeg with palette optimization."""
gif_path = OUTPUT_DIR / f"{output_name}.gif"
palette_path = webm_path.parent / "palette.png"
# Two-pass approach for better quality
# Pass 1: Generate palette
subprocess.run(
[ # noqa: S607
"ffmpeg",
"-y",
"-i",
str(webm_path),
"-vf",
"fps=10,scale=1280:-1:flags=lanczos,palettegen=stats_mode=diff",
str(palette_path),
],
check=True,
capture_output=True,
)
# Pass 2: Generate GIF with palette
subprocess.run(
[ # noqa: S607
"ffmpeg",
"-y",
"-i",
str(webm_path),
"-i",
str(palette_path),
"-lavfi",
"fps=10,scale=1280:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
str(gif_path),
],
check=True,
capture_output=True,
)
palette_path.unlink(missing_ok=True)
return gif_path
def move_recording(video_path: Path, name: str) -> tuple[Path, Path]:
"""Move WebM and convert to GIF, returning both paths."""
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_name = f"web-{name}"
webm_dest = OUTPUT_DIR / f"{output_name}.webm"
shutil.copy2(video_path, webm_dest)
console.print(f"[blue] WebM: {webm_dest.relative_to(REPO_DIR)}[/blue]")
gif_path = convert_to_gif(video_path, output_name)
console.print(f"[blue] GIF: {gif_path.relative_to(REPO_DIR)}[/blue]")
return webm_dest, gif_path
def cleanup() -> None:
"""Clean up temporary recording files."""
temp_dir = SCRIPT_DIR / ".recordings"
if temp_dir.exists():
shutil.rmtree(temp_dir)
def main() -> int:
"""Record all web UI demos."""
console.print("[blue]Recording web UI demos...[/blue]")
console.print(f"Output directory: {OUTPUT_DIR}")
console.print()
# Patch Playwright for high-quality video recording
patch_playwright_video_quality()
# Determine which demos to record
if len(sys.argv) > 1:
demos_to_record = [d for d in sys.argv[1:] if d in DEMOS]
if not demos_to_record:
console.print(f"[red]Unknown demo(s). Available: {', '.join(DEMOS)}[/red]")
return 1
else:
demos_to_record = DEMOS
results: dict[str, tuple[Path | None, Path | None]] = {}
try:
for i, demo in enumerate(demos_to_record, 1):
video_path = record_demo(demo, i, len(demos_to_record))
if video_path:
webm, gif = move_recording(video_path, demo)
results[demo] = (webm, gif)
else:
results[demo] = (None, None)
console.print()
finally:
cleanup()
# Summary
console.print("[blue]=== Summary ===[/blue]")
success_count = sum(1 for w, _ in results.values() if w is not None)
console.print(f"Recorded: {success_count}/{len(demos_to_record)} demos")
console.print()
for demo, (webm, gif) in results.items(): # type: ignore[assignment]
status = "[green]OK[/green]" if webm else "[red]FAILED[/red]"
console.print(f" {demo}: {status}")
if webm:
console.print(f" {webm.relative_to(REPO_DIR)}")
if gif:
console.print(f" {gif.relative_to(REPO_DIR)}")
return 0 if success_count == len(demos_to_record) else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,90 +0,0 @@
# Docker Swarm Overlay Networks with Compose Farm
Notes from testing Docker Swarm's attachable overlay networks as a way to get cross-host container networking while still using `docker compose`.
## The Idea
Docker Swarm overlay networks can be made "attachable", allowing regular `docker compose` containers (not just swarm services) to join them. This would give us:
- Cross-host Docker DNS (containers find each other by name)
- No need to publish ports for inter-container communication
- Keep using `docker compose up` instead of `docker stack deploy`
## Setup Steps
```bash
# On manager node
docker swarm init --advertise-addr <manager-ip>
# On worker nodes (use token from init output)
docker swarm join --token <token> <manager-ip>:2377
# Create attachable overlay network (on manager)
docker network create --driver overlay --attachable my-network
# In compose files, add the network
networks:
my-network:
external: true
```
## Required Ports
Docker Swarm requires these ports open **bidirectionally** between all nodes:
| Port | Protocol | Purpose |
|------|----------|---------|
| 2377 | TCP | Cluster management |
| 7946 | TCP + UDP | Node communication |
| 4789 | UDP | Overlay network traffic (VXLAN) |
## Test Results (2024-12-13)
- docker-debian (192.168.1.66) as manager
- dev-lxc (192.168.1.167) as worker
### What worked
- Swarm init and join
- Overlay network creation
- Nodes showed as Ready
### What failed
- Container on dev-lxc couldn't attach to overlay network
- Error: `attaching to network failed... context deadline exceeded`
- Cause: Port 7946 blocked from docker-debian → dev-lxc
### Root cause
Firewall on dev-lxc wasn't configured to allow swarm ports. Opening these ports requires sudo access on each node.
## Conclusion
Docker Swarm overlay networks are **not plug-and-play**. Requirements:
1. Swarm init/join on all nodes
2. Firewall rules on all nodes (needs sudo/root)
3. All nodes must have bidirectional connectivity on 3 ports
For a simpler alternative, consider:
- **Tailscale**: VPN mesh, containers use host's Tailscale IP
- **Host networking + published ports**: What compose-farm does today
- **Keep dependent services together**: Avoid cross-host networking entirely
## Future Work
If we decide to support overlay networks:
1. Add a `compose-farm network create` command that:
- Initializes swarm if needed
- Creates attachable overlay network
- Documents required firewall rules
2. Add network config to compose-farm.yaml:
```yaml
overlay_network: compose-farm-net
```
3. Auto-inject network into compose files (or document manual setup)

View File

@@ -1,128 +0,0 @@
# Future Improvements
Low-priority improvements identified during code review. These are not currently causing issues but could be addressed if they become pain points.
## 1. State Module Efficiency (LOW)
**Current:** Every state operation reads and writes the entire file.
```python
def set_service_host(config, service, host):
state = load_state(config) # Read file
state[service] = host
save_state(config, state) # Write file
```
**Impact:** With 87 services, this is fine. With 1000+, it would be slow.
**Potential fix:** Add batch operations:
```python
def update_state(config, updates: dict[str, str | None]) -> None:
"""Batch update: set services to hosts, None means remove."""
state = load_state(config)
for service, host in updates.items():
if host is None:
state.pop(service, None)
else:
state[service] = host
save_state(config, state)
```
**When to do:** Only if state operations become noticeably slow.
---
## 2. Remote-Aware Compose Path Resolution (LOW)
**Current:** `config.get_compose_path()` checks if files exist on the local filesystem:
```python
def get_compose_path(self, service: str) -> Path:
for filename in ("compose.yaml", "compose.yml", ...):
candidate = service_dir / filename
if candidate.exists(): # Local check!
return candidate
```
**Why this works:** NFS/shared storage means local = remote.
**Why it could break:** If running compose-farm from a machine without the NFS mount, it returns `compose.yaml` (the default) even if `docker-compose.yml` exists on the remote host.
**Potential fix:** Query the remote host for file existence, or accept this limitation and document it.
**When to do:** Only if users need to run compose-farm from non-NFS machines.
---
## 3. Add Integration Tests for CLI Commands (MEDIUM)
**Current:** No integration tests for the actual CLI commands. Tests cover the underlying functions but not the Typer commands themselves.
**Potential fix:** Add integration tests using `CliRunner` from Typer:
```python
from typer.testing import CliRunner
from compose_farm.cli import app
runner = CliRunner()
def test_check_command_validates_config():
result = runner.invoke(app, ["check", "--local"])
assert result.exit_code == 0
```
**When to do:** When CLI behavior becomes complex enough to warrant dedicated testing.
---
## 4. Add Tests for operations.py (MEDIUM)
**Current:** Operations module has 30% coverage. Most logic is tested indirectly through test_sync.py.
**Potential fix:** Add dedicated tests for:
- `up_services()` with migration scenarios
- `preflight_check()`
- `check_host_compatibility()`
**When to do:** When adding new operations or modifying migration logic.
---
## 5. Consider Structured Logging (LOW)
**Current:** Operations print directly to console using Rich. This couples the operations module to the Rich library.
**Potential fix:** Use Python's logging module with a custom Rich handler:
```python
import logging
logger = logging.getLogger(__name__)
# In operations:
logger.info("Migrating %s from %s to %s", service, old_host, new_host)
# In cli.py - configure Rich handler:
from rich.logging import RichHandler
logging.basicConfig(handlers=[RichHandler()])
```
**Benefits:**
- Operations become testable without capturing stdout
- Logs can be redirected to files
- Log levels provide filtering
**When to do:** Only if console output coupling becomes a problem for testing or extensibility.
---
## Design Decisions to Keep
These patterns are working well and should be preserved:
1. **asyncio + asyncssh** - Solid async foundation
2. **Pydantic models** - Clean validation
3. **Rich for output** - Good UX
4. **Test structure** - Good coverage
5. **Module separation** - cli/operations/executor/compose pattern
6. **KISS principle** - Don't over-engineer

340
docs/getting-started.md Normal file
View File

@@ -0,0 +1,340 @@
---
icon: lucide/rocket
---
# Getting Started
This guide walks you through installing Compose Farm and setting up your first multi-host deployment.
## Prerequisites
Before you begin, ensure you have:
- **[uv](https://docs.astral.sh/uv/)** (recommended) or Python 3.11+
- **SSH key-based authentication** to your Docker hosts
- **Docker and Docker Compose** installed on all target hosts
- **Shared storage** for compose files (NFS, Syncthing, etc.)
## Installation
<video autoplay loop muted playsinline>
<source src="/assets/install.webm" type="video/webm">
</video>
### One-liner (recommended)
```bash
curl -fsSL https://compose-farm.nijho.lt/install | sh
```
This installs [uv](https://docs.astral.sh/uv/) if needed, then installs compose-farm.
### Using uv
If you already have [uv](https://docs.astral.sh/uv/) installed:
```bash
uv tool install compose-farm
```
### Using pip
If you already have Python 3.11+ installed:
```bash
pip install compose-farm
```
### Using Docker
```bash
docker run --rm \
-v $SSH_AUTH_SOCK:/ssh-agent -e SSH_AUTH_SOCK=/ssh-agent \
-v ./compose-farm.yaml:/root/.config/compose-farm/compose-farm.yaml:ro \
ghcr.io/basnijholt/compose-farm up --all
```
**Running as non-root user** (recommended for NFS mounts):
By default, containers run as root. To preserve file ownership on mounted volumes, set these environment variables in your `.env` file:
```bash
# Add to .env file (one-time setup)
echo "CF_UID=$(id -u)" >> .env
echo "CF_GID=$(id -g)" >> .env
echo "CF_HOME=$HOME" >> .env
echo "CF_USER=$USER" >> .env
```
Or use [direnv](https://direnv.net/) to auto-set these variables when entering the directory:
```bash
cp .envrc.example .envrc && direnv allow
```
This ensures files like `compose-farm-state.yaml` and web UI edits are owned by your user instead of root. The `CF_USER` variable is required for SSH to work when running as a non-root user.
### Verify Installation
```bash
cf --version
cf --help
```
## SSH Setup
Compose Farm uses SSH to run commands on remote hosts. You need passwordless SSH access.
### Option 1: SSH Agent (default)
If you already have SSH keys loaded in your agent:
```bash
# Verify keys are loaded
ssh-add -l
# Test connection
ssh user@192.168.1.10 "docker --version"
```
### Option 2: Dedicated Key (recommended for Docker)
For persistent access when running in Docker:
```bash
# Generate and distribute key to all hosts
cf ssh setup
# Check status
cf ssh status
```
This creates `~/.ssh/compose-farm/id_ed25519` and copies the public key to each host.
## Shared Storage Setup
Compose files must be accessible at the **same path** on all hosts. Common approaches:
### NFS Mount
```bash
# On each Docker host
sudo mount nas:/volume1/compose /opt/compose
# Or add to /etc/fstab
nas:/volume1/compose /opt/compose nfs defaults 0 0
```
### Directory Structure
```
/opt/compose/ # compose_dir in config
├── plex/
│ └── docker-compose.yml
├── grafana/
│ └── docker-compose.yml
├── nextcloud/
│ └── docker-compose.yml
└── jellyfin/
└── docker-compose.yml
```
## Configuration
### Create Config File
Create `compose-farm.yaml` in the directory where you'll run commands. For example, if your stacks are in `/opt/stacks`, place the config there too:
```bash
cd /opt/stacks
cf config init
```
Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config. You can also symlink a working directory config to the global location:
```bash
# Create config in your stacks directory, symlink to ~/.config
cf config symlink /opt/stacks/compose-farm.yaml
```
This way, `cf` commands work from anywhere while the config lives with your stacks.
#### Single host example
```yaml
# Where compose files are located (one folder per stack)
compose_dir: /opt/stacks
hosts:
local: localhost
stacks:
plex: local
grafana: local
nextcloud: local
```
#### Multi-host example
```yaml
# Where compose files are located (same path on all hosts)
compose_dir: /opt/compose
# Define your Docker hosts
hosts:
nuc:
address: 192.168.1.10
user: docker # SSH user
hp:
address: 192.168.1.11
# user defaults to current user
# Map stacks to hosts
stacks:
plex: nuc
grafana: nuc
nextcloud: hp
```
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file.
For cross-host HTTP routing, add Traefik labels and configure `traefik_file` (see [Traefik Integration](traefik.md)).
### Validate Configuration
```bash
cf check --local
```
This validates syntax without SSH connections. For full validation:
```bash
cf check
```
## First Commands
### Check Status
```bash
cf ps
```
Shows all configured stacks and their status.
### Start All Stacks
```bash
cf up --all
```
Starts all stacks on their assigned hosts.
### Start Specific Stacks
```bash
cf up plex grafana
```
### Apply Configuration
The most powerful command - reconciles reality with your config:
```bash
cf apply --dry-run # Preview changes
cf apply # Execute changes
```
This will:
1. Start stacks in config but not running
2. Migrate stacks on wrong host
3. Stop stacks removed from config
## Docker Network Setup
If your stacks use an external Docker network:
```bash
# Create network on all hosts
cf init-network
# Or specific hosts
cf init-network nuc hp
```
Default network: `mynetwork` with subnet `172.20.0.0/16`
## Example Workflow
### 1. Add a New Stack
Create the compose file:
```bash
# On any host (shared storage)
mkdir -p /opt/compose/gitea
cat > /opt/compose/gitea/docker-compose.yml << 'EOF'
services:
gitea:
image: docker.gitea.com/gitea:latest
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
volumes:
- /opt/config/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "2222:22"
restart: unless-stopped
EOF
```
Add to config:
```yaml
stacks:
# ... existing stacks
gitea: nuc
```
Start the stack:
```bash
cf up gitea
```
### 2. Move a Stack to Another Host
Edit `compose-farm.yaml`:
```yaml
stacks:
plex: hp # Changed from nuc
```
Apply the change:
```bash
cf up plex
# Automatically: down on nuc, up on hp
```
Or use apply to reconcile everything:
```bash
cf apply
```
### 3. Update All Stacks
```bash
cf update --all
# Runs: pull + build + down + up for each stack
```
## Next Steps
- [Configuration Reference](configuration.md) - All config options
- [Commands Reference](commands.md) - Full CLI documentation
- [Traefik Integration](traefik.md) - Multi-host routing
- [Best Practices](best-practices.md) - Tips and limitations

167
docs/index.md Normal file
View File

@@ -0,0 +1,167 @@
---
icon: lucide/server
---
# Compose Farm
A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
## What is Compose Farm?
Compose Farm lets you manage Docker Compose stacks across multiple machines from a single command line. Think [Dockge](https://dockge.kuma.pet/) but with a CLI and web interface, designed for multi-host deployments.
Define which stacks run where in one YAML file, then use `cf apply` to make reality match your configuration.
It also works great on a single host with one folder per stack; just map stacks to `localhost`.
## Quick Demo
**CLI:**
<video autoplay loop muted playsinline>
<source src="/assets/quickstart.webm" type="video/webm">
</video>
**[Web UI](web-ui.md):**
<video autoplay loop muted playsinline>
<source src="/assets/web-workflow.webm" type="video/webm">
</video>
## Why Compose Farm?
| Problem | Compose Farm Solution |
|---------|----------------------|
| 100+ containers on one machine | Distribute across multiple hosts |
| Kubernetes too complex | Just SSH + docker compose |
| Swarm in maintenance mode | Zero infrastructure changes |
| Manual SSH for each host | Single command for all |
**It's a convenience wrapper, not a new paradigm.** Your existing `docker-compose.yml` files work unchanged.
## Quick Start
### Single host
No SSH, shared storage, or Traefik file-provider required.
```yaml
# compose-farm.yaml
compose_dir: /opt/stacks
hosts:
local: localhost
stacks:
plex: local
jellyfin: local
traefik: local
```
```bash
cf apply # Start/stop stacks to match config
```
### Multi-host
Requires SSH plus a shared `compose_dir` path on all hosts (NFS or sync).
```yaml
# compose-farm.yaml
compose_dir: /opt/compose
hosts:
server-1:
address: 192.168.1.10
server-2:
address: 192.168.1.11
stacks:
plex: server-1
jellyfin: server-2
grafana: server-1
```
```bash
cf apply # Stacks start, migrate, or stop as needed
```
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file.
For cross-host HTTP routing, add Traefik labels and configure `traefik_file` to generate file-provider config.
### Installation
```bash
uv tool install compose-farm
# or
pip install compose-farm
```
### Configuration
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`), or in `~/.config/compose-farm/`:
```yaml
compose_dir: /opt/compose
hosts:
nuc:
address: 192.168.1.10
user: docker
hp:
address: 192.168.1.11
stacks:
plex: nuc
grafana: nuc
nextcloud: hp
```
See [Configuration](configuration.md) for all options and the full search order.
### Usage
```bash
# Make reality match config
cf apply
# Start specific stacks
cf up plex grafana
# Check status
cf ps
# View logs
cf logs -f plex
```
## Key Features
- **Declarative configuration**: One YAML defines where everything runs
- **Auto-migration**: Change a host assignment, run `cf up`, stack moves automatically
<video autoplay loop muted playsinline>
<source src="/assets/migration.webm" type="video/webm">
</video>
- **Parallel execution**: Multiple stacks start/stop concurrently
- **State tracking**: Knows which stacks are running where
- **Traefik integration**: Generate file-provider config for cross-host routing
- **Zero changes**: Your compose files work as-is
## Requirements
- [uv](https://docs.astral.sh/uv/) (recommended) or Python 3.11+
- SSH key-based authentication to your Docker hosts
- Docker and Docker Compose on all target hosts
- Shared storage (compose files at same path on all hosts)
## Documentation
- [Getting Started](getting-started.md) - Installation and first steps
- [Configuration](configuration.md) - All configuration options
- [Commands](commands.md) - CLI reference
- [Web UI](web-ui.md) - Browser-based management interface
- [Architecture](architecture.md) - How it works under the hood
- [Traefik Integration](traefik.md) - Multi-host routing setup
- [Best Practices](best-practices.md) - Tips and limitations
## License
MIT

29
docs/install Normal file
View File

@@ -0,0 +1,29 @@
#!/bin/sh
# Compose Farm bootstrap script
# Usage: curl -fsSL https://compose-farm.nijho.lt/install | sh
#
# This script installs uv (if needed) and then installs compose-farm as a uv tool.
set -e
if ! command -v uv >/dev/null 2>&1; then
echo "uv is not installed. Installing..."
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "uv installation complete!"
echo ""
if [ -x ~/.local/bin/uv ]; then
~/.local/bin/uv tool install compose-farm
else
echo "Please restart your shell and run this script again"
echo ""
exit 0
fi
else
uv tool install compose-farm
fi
echo ""
echo "compose-farm is installed!"
echo "Run 'cf --help' to get started."
echo "If 'cf' is not found, restart your shell or run: source ~/.bashrc"

View File

@@ -0,0 +1,21 @@
// Fix Safari video autoplay issues
(function() {
function initVideos() {
document.querySelectorAll('video[autoplay]').forEach(function(video) {
video.load();
video.play().catch(function() {});
});
}
// For initial page load (needed for Chrome)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initVideos);
} else {
initVideos();
}
// For MkDocs instant navigation (needed for Safari)
if (typeof document$ !== 'undefined') {
document$.subscribe(initVideos);
}
})();

View File

@@ -5,7 +5,7 @@
- I made a CLI to run Docker Compose across multiple hosts without Kubernetes or Swarm
---
I've been running 100+ Docker Compose stacks on a single machine, and it kept running out of memory. I needed to spread services across multiple hosts, but:
I've been running 100+ Docker Compose stacks on a single machine, and it kept running out of memory. I needed to spread stacks across multiple hosts, but:
- **Kubernetes** felt like overkill. I don't need pods, ingress controllers, or 10x more YAML.
- **Docker Swarm** is basically in maintenance mode.
@@ -15,7 +15,7 @@ So I built **Compose Farm**, a simple CLI that runs `docker compose` commands ov
## How it works
One YAML file maps services to hosts:
One YAML file maps stacks to hosts:
```yaml
compose_dir: /opt/stacks
@@ -24,11 +24,11 @@ hosts:
nuc: 192.168.1.10
hp: 192.168.1.11
services:
stacks:
plex: nuc
jellyfin: hp
sonarr: nuc
radarr: nuc
grafana: nuc
nextcloud: nuc
```
Then just:
@@ -43,7 +43,7 @@ cf ps # shows status across all hosts
## Auto-migration
Change a service's host in the config and run `cf up`. It stops the service on the old host and starts it on the new one. No manual SSH needed.
Change a stack's host in the config and run `cf up`. It stops the stack on the old host and starts it on the new one. No manual SSH needed.
```yaml
# Before
@@ -65,7 +65,7 @@ cf up plex # migrates automatically
## What it doesn't do
- No high availability (if a host goes down, services don't auto-migrate)
- No high availability (if a host goes down, stacks don't auto-migrate)
- No overlay networking (containers on different hosts can't talk via Docker DNS)
- No health checks or automatic restarts

385
docs/traefik.md Normal file
View File

@@ -0,0 +1,385 @@
---
icon: lucide/globe
---
# Traefik Integration
Compose Farm can generate Traefik file-provider configuration for routing traffic across multiple hosts.
## The Problem
When you run Traefik on one host but stacks on others, Traefik's docker provider can't see remote containers. The file provider bridges this gap.
```
Internet
┌─────────────────────────────────────────────────────────────┐
│ Host: nuc │
│ │
│ ┌─────────┐ │
│ │ Traefik │◄─── Docker provider sees local containers │
│ │ │ │
│ │ │◄─── File provider sees remote stacks │
│ └────┬────┘ (from compose-farm.yml) │
│ │ │
└───────┼─────────────────────────────────────────────────────┘
├────────────────────┐
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Host: hp │ │ Host: nas │
│ │ │ │
│ plex:32400 │ │ jellyfin:8096 │
└───────────────┘ └───────────────┘
```
## How It Works
1. Your compose files have standard Traefik labels
2. Compose Farm reads labels and generates file-provider config
3. Traefik watches the generated file
4. Traffic routes to remote stacks via host IP + published port
## Setup
### Step 1: Configure Traefik File Provider
Add directory watching to your Traefik config:
```yaml
# traefik.yml or docker-compose.yml command
providers:
file:
directory: /opt/traefik/dynamic.d
watch: true
```
Or via command line:
```yaml
services:
traefik:
command:
- --providers.file.directory=/dynamic.d
- --providers.file.watch=true
volumes:
- /opt/traefik/dynamic.d:/dynamic.d:ro
```
### Step 2: Add Traefik Labels to Services
Your compose files use standard Traefik labels:
```yaml
# /opt/compose/plex/docker-compose.yml
services:
plex:
image: lscr.io/linuxserver/plex
ports:
- "32400:32400" # IMPORTANT: Must publish port!
labels:
- traefik.enable=true
- traefik.http.routers.plex.rule=Host(`plex.example.com`)
- traefik.http.routers.plex.entrypoints=websecure
- traefik.http.routers.plex.tls.certresolver=letsencrypt
- traefik.http.services.plex.loadbalancer.server.port=32400
```
**Important:** Services must publish ports for cross-host routing. Traefik connects via `host_ip:published_port`.
### Step 3: Generate File Provider Config
```bash
cf traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
```
This generates:
```yaml
# /opt/traefik/dynamic.d/compose-farm.yml
http:
routers:
plex:
rule: Host(`plex.example.com`)
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: plex
services:
plex:
loadBalancer:
servers:
- url: http://192.168.1.11:32400
```
## Auto-Regeneration
Configure automatic regeneration in `compose-farm.yaml`:
```yaml
compose_dir: /opt/compose
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
traefik_stack: traefik
hosts:
nuc:
address: 192.168.1.10
hp:
address: 192.168.1.11
stacks:
traefik: nuc # Traefik runs here
plex: hp # Routed via file-provider
grafana: hp
```
With `traefik_file` set, these commands auto-regenerate the config:
- `cf up`
- `cf down`
- `cf restart`
- `cf update`
- `cf apply`
### traefik_stack Option
When set, stacks on the **same host as Traefik** are skipped in file-provider output. Traefik's docker provider handles them directly.
```yaml
traefik_stack: traefik # traefik runs on nuc
stacks:
traefik: nuc # NOT in file-provider (docker provider)
portainer: nuc # NOT in file-provider (docker provider)
plex: hp # IN file-provider (cross-host)
```
## Label Syntax
### Routers
```yaml
labels:
# Basic router
- traefik.http.routers.myapp.rule=Host(`app.example.com`)
- traefik.http.routers.myapp.entrypoints=websecure
# With TLS
- traefik.http.routers.myapp.tls=true
- traefik.http.routers.myapp.tls.certresolver=letsencrypt
# With middleware
- traefik.http.routers.myapp.middlewares=auth@file
```
### Services
```yaml
labels:
# Load balancer port
- traefik.http.services.myapp.loadbalancer.server.port=8080
# Health check
- traefik.http.services.myapp.loadbalancer.healthcheck.path=/health
```
### Middlewares
Middlewares should be defined in a separate file (not generated by Compose Farm):
```yaml
# /opt/traefik/dynamic.d/middlewares.yml
http:
middlewares:
auth:
basicAuth:
users:
- "user:$apr1$..."
```
Reference in labels:
```yaml
labels:
- traefik.http.routers.myapp.middlewares=auth@file
```
## Variable Substitution
Labels can use environment variables:
```yaml
labels:
- traefik.http.routers.myapp.rule=Host(`${DOMAIN}`)
```
Compose Farm resolves variables from:
1. Stack's `.env` file
2. Current environment
```bash
# /opt/compose/myapp/.env
DOMAIN=app.example.com
```
## Port Resolution
Compose Farm determines the target URL from published ports:
```yaml
ports:
- "8080:80" # Uses 8080
- "192.168.1.11:8080:80" # Uses 8080 on specific IP
```
If no suitable port is found, a warning is shown.
## Complete Example
### compose-farm.yaml
```yaml
compose_dir: /opt/compose
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
traefik_stack: traefik
hosts:
nuc:
address: 192.168.1.10
hp:
address: 192.168.1.11
nas:
address: 192.168.1.100
stacks:
traefik: nuc
plex: hp
jellyfin: nas
grafana: nuc
nextcloud: nuc
```
### /opt/compose/plex/docker-compose.yml
```yaml
services:
plex:
image: lscr.io/linuxserver/plex
container_name: plex
ports:
- "32400:32400"
labels:
- traefik.enable=true
- traefik.http.routers.plex.rule=Host(`plex.example.com`)
- traefik.http.routers.plex.entrypoints=websecure
- traefik.http.routers.plex.tls.certresolver=letsencrypt
- traefik.http.services.plex.loadbalancer.server.port=32400
# ... other config
```
### Generated compose-farm.yml
```yaml
http:
routers:
plex:
rule: Host(`plex.example.com`)
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: plex
jellyfin:
rule: Host(`jellyfin.example.com`)
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: jellyfin
services:
plex:
loadBalancer:
servers:
- url: http://192.168.1.11:32400
jellyfin:
loadBalancer:
servers:
- url: http://192.168.1.100:8096
```
Note: `grafana` and `nextcloud` are NOT in the file because they're on the same host as Traefik (`nuc`).
## Combining with Existing Config
If you have existing Traefik dynamic config:
```bash
# Move existing config to directory
mkdir -p /opt/traefik/dynamic.d
mv /opt/traefik/dynamic.yml /opt/traefik/dynamic.d/manual.yml
# Generate Compose Farm config
cf traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
# Update Traefik to watch directory
# --providers.file.directory=/dynamic.d
```
Traefik merges all YAML files in the directory.
## Troubleshooting
### Stack Not Accessible
1. **Check port is published:**
```yaml
ports:
- "8080:80" # Must be published, not just exposed
```
2. **Check label syntax:**
```bash
cf check mystack
```
3. **Verify generated config:**
```bash
cf traefik-file mystack
```
4. **Check Traefik logs:**
```bash
docker logs traefik
```
### Config Not Regenerating
1. **Verify traefik_file is set:**
```bash
cf config show | grep traefik
```
2. **Check file permissions:**
```bash
ls -la /opt/traefik/dynamic.d/
```
3. **Manually regenerate:**
```bash
cf traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
```
### Variable Not Resolved
1. **Check .env file exists:**
```bash
cat /opt/compose/myservice/.env
```
2. **Test variable resolution:**
```bash
cd /opt/compose/myservice
docker compose config
```

132
docs/web-ui.md Normal file
View File

@@ -0,0 +1,132 @@
---
icon: lucide/layout-dashboard
---
# Web UI
Compose Farm includes a web interface for managing stacks from your browser. Start it with:
```bash
cf web
```
Then open [http://localhost:8000](http://localhost:8000).
## Features
### Full Workflow
Console terminal, config editor, stack navigation, actions (up, logs, update), dashboard overview, and theme switching - all in one flow.
<video autoplay loop muted playsinline>
<source src="/assets/web-workflow.webm" type="video/webm">
</video>
### Stack Actions
Navigate to any stack and use the command palette to trigger actions like restart, pull, update, or view logs. Output streams in real-time via WebSocket.
<video autoplay loop muted playsinline>
<source src="/assets/web-stack.webm" type="video/webm">
</video>
### Theme Switching
35 themes available via the command palette. Type `theme:` to filter, then use arrow keys to preview themes live before selecting.
<video autoplay loop muted playsinline>
<source src="/assets/web-themes.webm" type="video/webm">
</video>
### Command Palette
Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy search to quickly navigate, trigger actions, or change themes.
<video autoplay loop muted playsinline>
<source src="/assets/web-navigation.webm" type="video/webm">
</video>
## Pages
### Dashboard (`/`)
- Stack overview with status indicators
- Host statistics
- Pending operations (migrations, orphaned stacks)
- Quick actions via command palette
### Stack Detail (`/stack/{name}`)
- Compose file editor (Monaco)
- Environment file editor
- Action buttons: Up, Down, Restart, Update, Pull, Logs
- Container shell access (exec into running containers)
- Terminal output for running commands
Files are automatically backed up before saving to `~/.config/compose-farm/backups/`.
### Console (`/console`)
- Full shell access to any host
- File editor for remote files
- Monaco editor with syntax highlighting
<video autoplay loop muted playsinline>
<source src="/assets/web-console.webm" type="video/webm">
</video>
### Container Shell
Click the Shell button on any running container to exec into it directly from the browser.
<video autoplay loop muted playsinline>
<source src="/assets/web-shell.webm" type="video/webm">
</video>
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+K` / `Cmd+K` | Open command palette |
| `Ctrl+S` / `Cmd+S` | Save editors |
| `Escape` | Close command palette |
| `Arrow keys` | Navigate command list |
| `Enter` | Execute selected command |
## Starting the Server
```bash
# Default: http://0.0.0.0:8000
cf web
# Custom port
cf web --port 3000
# Development mode with auto-reload
cf web --reload
# Bind to specific interface
cf web --host 127.0.0.1
```
## Requirements
The web UI requires additional dependencies:
```bash
# If installed via pip
pip install compose-farm[web]
# If installed via uv
uv tool install 'compose-farm[web]'
```
## Architecture
The web UI uses:
- **FastAPI** - Backend API and WebSocket handling
- **HTMX** - Dynamic page updates without full reloads
- **DaisyUI + Tailwind** - Theming and styling
- **Monaco Editor** - Code editing for compose/env files
- **xterm.js** - Terminal emulation for logs and shell access

View File

@@ -2,9 +2,9 @@
Real-world examples demonstrating compose-farm patterns for multi-host Docker deployments.
## Services
## Stacks
| Service | Type | Demonstrates |
| Stack | Type | Demonstrates |
|---------|------|--------------|
| [traefik](traefik/) | Infrastructure | Reverse proxy, Let's Encrypt, file-provider |
| [mealie](mealie/) | Single container | Traefik labels, resource limits, environment vars |
@@ -16,7 +16,7 @@ Real-world examples demonstrating compose-farm patterns for multi-host Docker de
### External Network
All services connect to a shared external network for inter-service communication:
All stacks connect to a shared external network for inter-service communication:
```yaml
networks:
@@ -32,12 +32,12 @@ compose-farm init-network --network mynetwork --subnet 172.20.0.0/16
### Traefik Labels (Dual Routes)
Services expose two routes for different access patterns:
Stacks expose two routes for different access patterns:
1. **HTTPS route** (`websecure` entrypoint): For your custom domain with Let's Encrypt TLS
2. **HTTP route** (`web` entrypoint): For `.local` domains on your LAN (no TLS needed)
This pattern allows accessing services via:
This pattern allows accessing stacks via:
- `https://mealie.example.com` - from anywhere, with TLS
- `http://mealie.local` - from your local network, no TLS overhead
@@ -57,7 +57,7 @@ labels:
### Environment Variables
Each service has a `.env` file for secrets and domain configuration.
Each stack has a `.env` file for secrets and domain configuration.
Edit these files to set your domain and credentials:
```bash
@@ -76,15 +76,15 @@ volumes:
- /mnt/data/myapp:/app/data
```
This allows services to migrate between hosts without data loss.
This allows stacks to migrate between hosts without data loss.
### Multi-Host Services
### Multi-Host Stacks
Services that need to run on every host (e.g., monitoring agents):
Stacks that need to run on every host (e.g., monitoring agents):
```yaml
# In compose-farm.yaml
services:
stacks:
autokuma: all # Runs on every configured host
```
@@ -107,7 +107,7 @@ services:
### AutoKuma Labels (Optional)
The autokuma example demonstrates compose-farm's **multi-host feature** - running the same service on all hosts using the `all` keyword. AutoKuma itself is not part of compose-farm; it's just a good example because it needs to run on every host to monitor local Docker containers.
The autokuma example demonstrates compose-farm's **multi-host feature** - running the same stack on all hosts using the `all` keyword. AutoKuma itself is not part of compose-farm; it's just a good example because it needs to run on every host to monitor local Docker containers.
[AutoKuma](https://github.com/BigBoot/AutoKuma) automatically creates Uptime Kuma monitors from Docker labels:
@@ -128,7 +128,7 @@ compose-farm init-network
# 2. Start Traefik first (the reverse proxy)
compose-farm up traefik
# 3. Start other services
# 3. Start other stacks
compose-farm up mealie uptime-kuma
# 4. Check status
@@ -148,24 +148,24 @@ compose-farm down --all
The `compose-farm.yaml` shows a multi-host setup:
- **primary** (192.168.1.10): Runs Traefik and heavy services
- **secondary** (192.168.1.11): Runs lighter services
- **primary** (192.168.1.10): Runs Traefik and heavy stacks
- **secondary** (192.168.1.11): Runs lighter stacks
- **autokuma**: Runs on ALL hosts to monitor local containers
When Traefik runs on `primary` and a service runs on `secondary`, compose-farm
When Traefik runs on `primary` and a stack runs on `secondary`, compose-farm
automatically generates file-provider config so Traefik can route to it.
## Traefik File-Provider
When services run on different hosts than Traefik, use `traefik-file` to generate routing config:
When stacks run on different hosts than Traefik, use `traefik-file` to generate routing config:
```bash
# Generate config for all services
# Generate config for all stacks
compose-farm traefik-file --all -o traefik/dynamic.d/compose-farm.yml
# Or configure auto-generation in compose-farm.yaml:
traefik_file: /opt/stacks/traefik/dynamic.d/compose-farm.yml
traefik_service: traefik
traefik_stack: traefik
```
With `traefik_file` configured, compose-farm automatically regenerates the config after `up`, `down`, `restart`, and `update` commands.

View File

@@ -7,34 +7,34 @@ compose_dir: /opt/stacks/compose-farm/examples
# Auto-regenerate Traefik file-provider config after up/down/restart/update
traefik_file: /opt/stacks/compose-farm/examples/traefik/dynamic.d/compose-farm.yml
traefik_service: traefik # Skip Traefik's host in file-provider (docker provider handles it)
traefik_stack: traefik # Skip Traefik's host in file-provider (docker provider handles it)
hosts:
# Primary server - runs Traefik and most services
# Primary server - runs Traefik and most stacks
# Full form with all options
primary:
address: 192.168.1.10
user: deploy
port: 22
# Secondary server - runs some services for load distribution
# Secondary server - runs some stacks for load distribution
# Short form (user defaults to current user, port defaults to 22)
secondary: 192.168.1.11
# Local execution (no SSH) - for testing or when running on the host itself
local: localhost
services:
stacks:
# Infrastructure (runs on primary where Traefik is)
traefik: primary
# Multi-host services (runs on ALL hosts)
# Multi-host stacks (runs on ALL hosts)
# AutoKuma monitors Docker containers on each host
autokuma: all
# Primary server services
# Primary server stacks
paperless-ngx: primary
# Secondary server services (distributed for performance)
# Secondary server stacks (distributed for performance)
mealie: secondary
uptime-kuma: secondary

60
justfile Normal file
View File

@@ -0,0 +1,60 @@
# 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
# Record CLI demos (all or specific: just record-cli quickstart)
record-cli *demos:
python docs/demos/cli/record.py {{demos}}
# Record web UI demos (all or specific: just record-web navigation)
record-web *demos:
python docs/demos/web/record.py {{demos}}
# 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

@@ -133,6 +133,10 @@ disallow_untyped_decorators = false
module = "compose_farm.web.*"
disallow_untyped_decorators = false
[[tool.mypy.overrides]]
module = "docs.demos.web.*"
disallow_untyped_decorators = false
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -145,6 +149,9 @@ addopts = [
"--no-cov-on-fail",
"-v",
]
markers = [
"browser: marks tests as browser tests (deselect with '-m \"not browser\"')",
]
[tool.coverage.run]
omit = []
@@ -157,9 +164,19 @@ exclude_lines = [
'if __name__ == "__main__":',
]
[tool.ty.environment]
python-version = "3.11"
[tool.ty.src]
exclude = [
"hatch_build.py", # Build-time only, hatchling not in dev deps
"docs/demos/**", # Demo scripts with local conftest imports
]
[dependency-groups]
dev = [
"mypy>=1.19.0",
"ty>=0.0.1a13",
"pre-commit>=4.5.0",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
@@ -176,4 +193,6 @@ dev = [
"httpx>=0.28.0",
# For browser tests (use system chromium via nix-shell -p chromium)
"pytest-playwright>=0.7.0",
# For parallel test execution
"pytest-xdist>=3.0.0",
]

View File

@@ -1,16 +0,0 @@
# Development shell with Chromium for browser tests
# Usage: nix-shell --run "uv run pytest tests/web/test_htmx_browser.py -v --no-cov"
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = [
pkgs.chromium
];
shellHook = ''
echo "Chromium available at: $(which chromium)"
echo ""
echo "Run browser tests with:"
echo " uv run pytest tests/web/test_htmx_browser.py -v --no-cov"
'';
}

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

@@ -20,7 +20,7 @@ from rich.progress import (
from compose_farm.console import (
MSG_HOST_NOT_FOUND,
MSG_SERVICE_NOT_FOUND,
MSG_STACK_NOT_FOUND,
console,
print_error,
print_hint,
@@ -39,13 +39,13 @@ _R = TypeVar("_R")
# --- Shared CLI Options ---
ServicesArg = Annotated[
StacksArg = Annotated[
list[str] | None,
typer.Argument(help="Services to operate on"),
typer.Argument(help="Stacks to operate on"),
]
AllOption = Annotated[
bool,
typer.Option("--all", "-a", help="Run on all services"),
typer.Option("--all", "-a", help="Run on all stacks"),
]
ConfigOption = Annotated[
Path | None,
@@ -57,7 +57,11 @@ LogPathOption = Annotated[
]
HostOption = Annotated[
str | None,
typer.Option("--host", "-H", help="Filter to services on this host"),
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) ---
@@ -140,57 +144,57 @@ def load_config_or_exit(config_path: Path | None) -> Config:
raise typer.Exit(1) from e
def get_services(
services: list[str],
all_services: bool,
def get_stacks(
stacks: list[str],
all_stacks: bool,
config_path: Path | None,
*,
host: str | None = None,
default_all: bool = False,
) -> tuple[list[str], Config]:
"""Resolve service list and load config.
"""Resolve stack list and load config.
Handles three mutually exclusive selection methods:
- Explicit service names
- Explicit stack names
- --all flag
- --host filter
Args:
services: Explicit service names
all_services: Whether --all was specified
stacks: Explicit stack names
all_stacks: Whether --all was specified
config_path: Path to config file
host: Filter to services on this host
default_all: If True, default to all services when nothing specified (for ps)
host: Filter to stacks on this host
default_all: If True, default to all stacks when nothing specified (for ps)
Supports "." as shorthand for the current directory name.
"""
validate_service_selection(services, all_services, host)
validate_stack_selection(stacks, all_stacks, host)
config = load_config_or_exit(config_path)
if host is not None:
validate_host(config, host)
svc_list = [s for s in config.services if host in config.get_hosts(s)]
if not svc_list:
print_warning(f"No services configured for host [magenta]{host}[/]")
validate_hosts(config, host)
stack_list = [s for s in config.stacks if host in config.get_hosts(s)]
if not stack_list:
print_warning(f"No stacks configured for host [magenta]{host}[/]")
raise typer.Exit(0)
return svc_list, config
return stack_list, config
if all_services:
return list(config.services.keys()), config
if all_stacks:
return list(config.stacks.keys()), config
if not services:
if not stacks:
if default_all:
return list(config.services.keys()), config
print_error("Specify services or use [bold]--all[/] / [bold]--host[/]")
return list(config.stacks.keys()), config
print_error("Specify stacks or use [bold]--all[/] / [bold]--host[/]")
raise typer.Exit(1)
# Resolve "." to current directory name
resolved = [Path.cwd().name if svc == "." else svc for svc in services]
resolved = [Path.cwd().name if stack == "." else stack for stack in stacks]
# Validate all services exist in config
validate_services(
config, resolved, hint="Add the service to compose-farm.yaml or use [bold]--all[/]"
# Validate all stacks exist in config
validate_stacks(
config, resolved, hint="Add the stack to compose-farm.yaml or use [bold]--all[/]"
)
return resolved, config
@@ -215,19 +219,19 @@ def report_results(results: list[CommandResult]) -> None:
console.print() # Blank line before summary
if failed:
for r in failed:
print_error(f"[cyan]{r.service}[/] failed with exit code {r.exit_code}")
print_error(f"[cyan]{r.stack}[/] failed with exit code {r.exit_code}")
console.print()
console.print(
f"[green]✓[/] {len(succeeded)}/{len(results)} services succeeded, "
f"[green]✓[/] {len(succeeded)}/{len(results)} stacks succeeded, "
f"[red]✗[/] {len(failed)} failed"
)
else:
print_success(f"All {len(results)} services succeeded")
print_success(f"All {len(results)} stacks succeeded")
elif failed:
# Single service failed
# Single stack failed
r = failed[0]
print_error(f"[cyan]{r.service}[/] failed with exit code {r.exit_code}")
print_error(f"[cyan]{r.stack}[/] failed with exit code {r.exit_code}")
if failed:
raise typer.Exit(1)
@@ -239,12 +243,12 @@ def maybe_regenerate_traefik(
) -> None:
"""Regenerate traefik config if traefik_file is configured.
If results are provided, skips regeneration if all services failed.
If results are provided, skips regeneration if all stacks failed.
"""
if cfg.traefik_file is None:
return
# Skip if all services failed
# Skip if all stacks failed
if results and not any(r.success for r in results):
return
@@ -255,7 +259,7 @@ def maybe_regenerate_traefik(
)
try:
dynamic, warnings = generate_traefik_config(cfg, list(cfg.services.keys()))
dynamic, warnings = generate_traefik_config(cfg, list(cfg.stacks.keys()))
new_content = render_traefik_config(dynamic)
# Check if content changed
@@ -275,80 +279,50 @@ def maybe_regenerate_traefik(
print_warning(f"Failed to update traefik config: {exc}")
def validate_services(cfg: Config, services: list[str], *, hint: str | None = None) -> None:
"""Validate that all services exist in config. Exits with error if any not found."""
invalid = [s for s in services if s not in cfg.services]
def validate_stacks(cfg: Config, stacks: list[str], *, hint: str | None = None) -> None:
"""Validate that all stacks exist in config. Exits with error if any not found."""
invalid = [s for s in stacks if s not in cfg.stacks]
if invalid:
for svc in invalid:
print_error(MSG_SERVICE_NOT_FOUND.format(name=svc))
print_error(MSG_STACK_NOT_FOUND.format(name=svc))
if hint:
print_hint(hint)
raise typer.Exit(1)
def validate_host(cfg: Config, host: str) -> None:
"""Validate that a host exists in config. Exits with error if not found."""
if host not in cfg.hosts:
print_error(MSG_HOST_NOT_FOUND.format(name=host))
raise typer.Exit(1)
def validate_hosts(cfg: Config, hosts: list[str]) -> None:
"""Validate that all hosts exist in config. Exits with error if any not found."""
invalid = [h for h in hosts if h not in cfg.hosts]
def validate_hosts(cfg: Config, hosts: str | list[str]) -> None:
"""Validate that host(s) exist in config. Exits with error if any not found."""
host_list = [hosts] if isinstance(hosts, str) else hosts
invalid = [h for h in host_list if h not in cfg.hosts]
if invalid:
for h in invalid:
print_error(MSG_HOST_NOT_FOUND.format(name=h))
raise typer.Exit(1)
def validate_host_for_service(cfg: Config, service: str, host: str) -> None:
"""Validate that a host is valid for a service."""
validate_host(cfg, host)
allowed_hosts = cfg.get_hosts(service)
def validate_host_for_stack(cfg: Config, stack: str, host: str) -> None:
"""Validate that a host is valid for a stack."""
validate_hosts(cfg, host)
allowed_hosts = cfg.get_hosts(stack)
if host not in allowed_hosts:
print_error(
f"Service [cyan]{service}[/] is not configured for host [magenta]{host}[/] "
f"Stack [cyan]{stack}[/] is not configured for host [magenta]{host}[/] "
f"(configured: {', '.join(allowed_hosts)})"
)
raise typer.Exit(1)
def validate_service_selection(
services: list[str] | None,
all_services: bool,
def validate_stack_selection(
stacks: list[str] | None,
all_stacks: bool,
host: str | None,
) -> None:
"""Validate that only one service selection method is used.
"""Validate that only one stack selection method is used.
The three selection methods (explicit services, --all, --host) are mutually
The three selection methods (explicit stacks, --all, --host) are mutually
exclusive. This ensures consistent behavior across all commands.
"""
methods = sum([bool(services), all_services, host is not None])
methods = sum([bool(stacks), all_stacks, host is not None])
if methods > 1:
print_error("Use only one of: service names, [bold]--all[/], or [bold]--host[/]")
print_error("Use only one of: stack names, [bold]--all[/], or [bold]--host[/]")
raise typer.Exit(1)
def run_host_operation(
cfg: Config,
svc_list: list[str],
host: str,
command: str,
action_verb: str,
state_callback: Callable[[Config, str, str], None],
) -> None:
"""Run an operation on a specific host for multiple services."""
from compose_farm.executor import run_compose_on_host # noqa: PLC0415
results: list[CommandResult] = []
for service in svc_list:
validate_host_for_service(cfg, service, host)
console.print(f"[cyan]\\[{service}][/] {action_verb} on [magenta]{host}[/]...")
result = run_async(run_compose_on_host(cfg, service, host, command, raw=True))
print() # Newline after raw output
results.append(result)
if result.success:
state_callback(cfg, service, host)
maybe_regenerate_traefik(cfg, results)
report_results(results)

View File

@@ -40,24 +40,12 @@ _RawOption = Annotated[
def _get_editor() -> str:
"""Get the user's preferred editor.
Checks $EDITOR, then $VISUAL, then falls back to platform defaults.
"""
for env_var in ("EDITOR", "VISUAL"):
editor = os.environ.get(env_var)
if editor:
return editor
"""Get the user's preferred editor ($EDITOR > $VISUAL > platform default)."""
if editor := os.environ.get("EDITOR") or os.environ.get("VISUAL"):
return editor
if platform.system() == "Windows":
return "notepad"
# Try common editors on Unix-like systems
for editor in ("nano", "vim", "vi"):
if shutil.which(editor):
return editor
return "vi"
return next((e for e in ("nano", "vim", "vi") if shutil.which(e)), "vi")
def _generate_template() -> str:
@@ -80,20 +68,16 @@ def _get_config_file(path: Path | None) -> Path | None:
return config_path.resolve() if config_path else None
def _report_no_config_found() -> None:
"""Report that no config file was found in search paths."""
console.print("[yellow]No config file found.[/yellow]")
console.print("\nSearched locations:")
for p in config_search_paths():
status = "[green]exists[/green]" if p.exists() else "[dim]not found[/dim]"
console.print(f" - {p} ({status})")
console.print("\nRun [bold cyan]cf config init[/bold cyan] to create one.")
def _report_config_path_not_exists(config_file: Path) -> None:
"""Report that an explicit config path doesn't exist."""
def _report_missing_config(explicit_path: Path | None = None) -> None:
"""Report that a config file was not found."""
console.print("[yellow]Config file not found.[/yellow]")
console.print(f"\nProvided path does not exist: [cyan]{config_file}[/cyan]")
if explicit_path:
console.print(f"\nProvided path does not exist: [cyan]{explicit_path}[/cyan]")
else:
console.print("\nSearched locations:")
for p in config_search_paths():
status = "[green]exists[/green]" if p.exists() else "[dim]not found[/dim]"
console.print(f" - {p} ({status})")
console.print("\nRun [bold cyan]cf config init[/bold cyan] to create one.")
@@ -140,11 +124,11 @@ def config_edit(
config_file = _get_config_file(path)
if config_file is None:
_report_no_config_found()
_report_missing_config()
raise typer.Exit(1)
if not config_file.exists():
_report_config_path_not_exists(config_file)
_report_missing_config(config_file)
raise typer.Exit(1)
editor = _get_editor()
@@ -180,11 +164,11 @@ def config_show(
config_file = _get_config_file(path)
if config_file is None:
_report_no_config_found()
_report_missing_config()
raise typer.Exit(0)
if not config_file.exists():
_report_config_path_not_exists(config_file)
_report_missing_config(config_file)
raise typer.Exit(1)
content = config_file.read_text(encoding="utf-8")
@@ -211,7 +195,7 @@ def config_path(
config_file = _get_config_file(path)
if config_file is None:
_report_no_config_found()
_report_missing_config()
raise typer.Exit(1)
# Just print the path for easy piping
@@ -243,7 +227,7 @@ def config_validate(
print_success(f"Valid config: {config_file}")
console.print(f" Hosts: {len(cfg.hosts)}")
console.print(f" Services: {len(cfg.services)}")
console.print(f" Stacks: {len(cfg.stacks)}")
@config_app.command("symlink")

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Annotated
import typer
@@ -14,228 +15,314 @@ from compose_farm.cli.common import (
AllOption,
ConfigOption,
HostOption,
ServicesArg,
ServiceOption,
StacksArg,
format_host,
get_services,
get_stacks,
load_config_or_exit,
maybe_regenerate_traefik,
report_results,
run_async,
validate_host_for_stack,
validate_stacks,
)
from compose_farm.cli.management import _discover_stacks_full
from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success
from compose_farm.executor import run_on_services, run_sequential_on_services
from compose_farm.operations import stop_orphaned_services, up_services
from compose_farm.executor import run_compose_on_host, run_on_stacks, run_sequential_on_stacks
from compose_farm.operations import (
stop_orphaned_stacks,
stop_stray_stacks,
up_stacks,
)
from compose_farm.state import (
get_orphaned_services,
get_service_host,
get_services_needing_migration,
get_services_not_in_state,
remove_service,
get_orphaned_stacks,
get_stack_host,
get_stacks_needing_migration,
get_stacks_not_in_state,
remove_stack,
)
@app.command(rich_help_panel="Lifecycle")
def up(
services: ServicesArg = None,
all_services: AllOption = False,
stacks: StacksArg = None,
all_stacks: AllOption = False,
host: HostOption = None,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Start services (docker compose up -d). Auto-migrates if host changed."""
svc_list, cfg = get_services(services or [], all_services, config, host=host)
results = run_async(up_services(cfg, svc_list, raw=True))
"""Start stacks (docker compose up -d). Auto-migrates if host changed."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
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)
@app.command(rich_help_panel="Lifecycle")
def down(
services: ServicesArg = None,
all_services: AllOption = False,
stacks: StacksArg = None,
all_stacks: AllOption = False,
orphaned: Annotated[
bool,
typer.Option(
"--orphaned", help="Stop orphaned services (in state but removed from config)"
),
typer.Option("--orphaned", help="Stop orphaned stacks (in state but removed from config)"),
] = False,
host: HostOption = None,
config: ConfigOption = None,
) -> None:
"""Stop services (docker compose down)."""
"""Stop stacks (docker compose down)."""
# Handle --orphaned flag (mutually exclusive with other selection methods)
if orphaned:
if services or all_services or host:
if stacks or all_stacks or host:
print_error(
"Cannot combine [bold]--orphaned[/] with services, [bold]--all[/], or [bold]--host[/]"
"Cannot combine [bold]--orphaned[/] with stacks, [bold]--all[/], or [bold]--host[/]"
)
raise typer.Exit(1)
cfg = load_config_or_exit(config)
orphaned_services = get_orphaned_services(cfg)
orphaned_stacks = get_orphaned_stacks(cfg)
if not orphaned_services:
print_success("No orphaned services to stop")
if not orphaned_stacks:
print_success("No orphaned stacks to stop")
return
console.print(
f"[yellow]Stopping {len(orphaned_services)} orphaned service(s):[/] "
f"{', '.join(orphaned_services.keys())}"
f"[yellow]Stopping {len(orphaned_stacks)} orphaned stack(s):[/] "
f"{', '.join(orphaned_stacks.keys())}"
)
results = run_async(stop_orphaned_services(cfg))
results = run_async(stop_orphaned_stacks(cfg))
report_results(results)
return
svc_list, cfg = get_services(services or [], all_services, config, host=host)
raw = len(svc_list) == 1
results = run_async(run_on_services(cfg, svc_list, "down", raw=raw))
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))
# Remove from state on success
# For multi-host services, result.service is "svc@host", extract base name
removed_services: set[str] = set()
# For multi-host stacks, result.stack is "stack@host", extract base name
removed_stacks: set[str] = set()
for result in results:
if result.success:
base_service = result.service.split("@")[0]
if base_service not in removed_services:
remove_service(cfg, base_service)
removed_services.add(base_service)
base_stack = result.stack.split("@")[0]
if base_stack not in removed_stacks:
remove_stack(cfg, base_stack)
removed_stacks.add(base_stack)
maybe_regenerate_traefik(cfg, results)
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(
services: ServicesArg = None,
all_services: AllOption = False,
stacks: StacksArg = None,
all_stacks: AllOption = False,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Pull latest images (docker compose pull)."""
svc_list, cfg = get_services(services or [], all_services, config)
raw = len(svc_list) == 1
results = run_async(run_on_services(cfg, svc_list, "pull", raw=raw))
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 --ignore-buildable {service}" if service else "pull --ignore-buildable"
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 restart(
services: ServicesArg = None,
all_services: AllOption = False,
stacks: StacksArg = None,
all_stacks: AllOption = False,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Restart services (down + up)."""
svc_list, cfg = get_services(services or [], all_services, config)
raw = len(svc_list) == 1
results = run_async(run_sequential_on_services(cfg, svc_list, ["down", "up -d"], raw=raw))
"""Restart stacks (down + up). With --service, restarts just that service."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
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)
@app.command(rich_help_panel="Lifecycle")
def update(
services: ServicesArg = None,
all_services: AllOption = False,
stacks: StacksArg = None,
all_stacks: AllOption = False,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Update services (pull + build + down + up)."""
svc_list, cfg = get_services(services or [], all_services, config)
raw = len(svc_list) == 1
results = run_async(
run_sequential_on_services(
cfg, svc_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
"""Update stacks (pull + build + down + up). With --service, updates just that service."""
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
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)
def _report_pending_migrations(cfg: Config, migrations: list[str]) -> None:
"""Report services that need migration."""
console.print(f"[cyan]Services to migrate ({len(migrations)}):[/]")
for svc in migrations:
current = get_service_host(cfg, svc)
target = cfg.get_hosts(svc)[0]
console.print(f" [cyan]{svc}[/]: [magenta]{current}[/] → [magenta]{target}[/]")
def _discover_strays(cfg: Config) -> dict[str, list[str]]:
"""Discover stacks running on unauthorized hosts by scanning all hosts."""
_, strays, duplicates = _discover_stacks_full(cfg)
# Merge duplicates into strays (for single-host stacks on multiple hosts,
# keep correct host and stop others)
for stack, running_hosts in duplicates.items():
configured = cfg.get_hosts(stack)[0]
stray_hosts = [h for h in running_hosts if h != configured]
if stray_hosts:
strays[stack] = stray_hosts
def _report_pending_orphans(orphaned: dict[str, str | list[str]]) -> None:
"""Report orphaned services that will be stopped."""
console.print(f"[yellow]Orphaned services to stop ({len(orphaned)}):[/]")
for svc, hosts in orphaned.items():
console.print(f" [cyan]{svc}[/] on [magenta]{format_host(hosts)}[/]")
def _report_pending_starts(cfg: Config, missing: list[str]) -> None:
"""Report services that will be started."""
console.print(f"[green]Services to start ({len(missing)}):[/]")
for svc in missing:
target = format_host(cfg.get_hosts(svc))
console.print(f" [cyan]{svc}[/] on [magenta]{target}[/]")
def _report_pending_refresh(cfg: Config, to_refresh: list[str]) -> None:
"""Report services that will be refreshed."""
console.print(f"[blue]Services to refresh ({len(to_refresh)}):[/]")
for svc in to_refresh:
target = format_host(cfg.get_hosts(svc))
console.print(f" [cyan]{svc}[/] on [magenta]{target}[/]")
return strays
@app.command(rich_help_panel="Lifecycle")
def apply(
def apply( # noqa: C901, PLR0912, PLR0915 (multi-phase reconciliation needs these branches)
dry_run: Annotated[
bool,
typer.Option("--dry-run", "-n", help="Show what would change without executing"),
] = False,
no_orphans: Annotated[
bool,
typer.Option("--no-orphans", help="Only migrate, don't stop orphaned services"),
typer.Option("--no-orphans", help="Only migrate, don't stop orphaned stacks"),
] = False,
no_strays: Annotated[
bool,
typer.Option("--no-strays", help="Don't stop stray stacks (running on wrong host)"),
] = False,
full: Annotated[
bool,
typer.Option("--full", "-f", help="Also run up on all services to apply config changes"),
typer.Option("--full", "-f", help="Also run up on all stacks to apply config changes"),
] = False,
config: ConfigOption = None,
) -> None:
"""Make reality match config (start, migrate, stop as needed).
"""Make reality match config (start, migrate, stop strays/orphans as needed).
This is the "reconcile" command that ensures running services match your
This is the "reconcile" command that ensures running stacks match your
config file. It will:
1. Stop orphaned services (in state but removed from config)
2. Migrate services on wrong host (host in state ≠ host in config)
3. Start missing services (in config but not in state)
1. Stop orphaned stacks (in state but removed from config)
2. Stop stray stacks (running on unauthorized hosts)
3. Migrate stacks on wrong host (host in state ≠ host in config)
4. Start missing stacks (in config but not in state)
Use --dry-run to preview changes before applying.
Use --no-orphans to only migrate/start without stopping orphaned services.
Use --full to also run 'up' on all services (picks up compose/env changes).
Use --no-orphans to skip stopping orphaned stacks.
Use --no-strays to skip stopping stray stacks.
Use --full to also run 'up' on all stacks (picks up compose/env changes).
"""
cfg = load_config_or_exit(config)
orphaned = get_orphaned_services(cfg)
migrations = get_services_needing_migration(cfg)
missing = get_services_not_in_state(cfg)
orphaned = get_orphaned_stacks(cfg)
migrations = get_stacks_needing_migration(cfg)
missing = get_stacks_not_in_state(cfg)
# For --full: refresh all services not already being started/migrated
strays: dict[str, list[str]] = {}
if not no_strays:
console.print("[dim]Scanning hosts for stray containers...[/]")
strays = _discover_strays(cfg)
# For --full: refresh all stacks not already being started/migrated
handled = set(migrations) | set(missing)
to_refresh = [svc for svc in cfg.services if svc not in handled] if full else []
to_refresh = [stack for stack in cfg.stacks if stack not in handled] if full else []
has_orphans = bool(orphaned) and not no_orphans
has_strays = bool(strays)
has_migrations = bool(migrations)
has_missing = bool(missing)
has_refresh = bool(to_refresh)
if not has_orphans and not has_migrations and not has_missing and not has_refresh:
if (
not has_orphans
and not has_strays
and not has_migrations
and not has_missing
and not has_refresh
):
print_success("Nothing to apply - reality matches config")
return
# Report what will be done
if has_orphans:
_report_pending_orphans(orphaned)
console.print(f"[yellow]Orphaned stacks to stop ({len(orphaned)}):[/]")
for svc, hosts in orphaned.items():
console.print(f" [cyan]{svc}[/] on [magenta]{format_host(hosts)}[/]")
if has_strays:
console.print(f"[red]Stray stacks to stop ({len(strays)}):[/]")
for stack, hosts in strays.items():
configured = cfg.get_hosts(stack)
console.print(
f" [cyan]{stack}[/] on [magenta]{', '.join(hosts)}[/] "
f"[dim](should be on {', '.join(configured)})[/]"
)
if has_migrations:
_report_pending_migrations(cfg, migrations)
console.print(f"[cyan]Stacks to migrate ({len(migrations)}):[/]")
for stack in migrations:
current = get_stack_host(cfg, stack)
target = cfg.get_hosts(stack)[0]
console.print(f" [cyan]{stack}[/]: [magenta]{current}[/] → [magenta]{target}[/]")
if has_missing:
_report_pending_starts(cfg, missing)
console.print(f"[green]Stacks to start ({len(missing)}):[/]")
for stack in missing:
console.print(f" [cyan]{stack}[/] on [magenta]{format_host(cfg.get_hosts(stack))}[/]")
if has_refresh:
_report_pending_refresh(cfg, to_refresh)
console.print(f"[blue]Stacks to refresh ({len(to_refresh)}):[/]")
for stack in to_refresh:
console.print(f" [cyan]{stack}[/] on [magenta]{format_host(cfg.get_hosts(stack))}[/]")
if dry_run:
console.print(f"\n{MSG_DRY_RUN}")
@@ -245,34 +332,96 @@ def apply(
console.print()
all_results = []
# 1. Stop orphaned services first
# 1. Stop orphaned stacks first
if has_orphans:
console.print("[yellow]Stopping orphaned services...[/]")
all_results.extend(run_async(stop_orphaned_services(cfg)))
console.print("[yellow]Stopping orphaned stacks...[/]")
all_results.extend(run_async(stop_orphaned_stacks(cfg)))
# 2. Migrate services on wrong host
# 2. Stop stray stacks (running on unauthorized hosts)
if has_strays:
console.print("[red]Stopping stray stacks...[/]")
all_results.extend(run_async(stop_stray_stacks(cfg, strays)))
# 3. Migrate stacks on wrong host
if has_migrations:
console.print("[cyan]Migrating services...[/]")
migrate_results = run_async(up_services(cfg, migrations, raw=True))
console.print("[cyan]Migrating stacks...[/]")
migrate_results = run_async(up_stacks(cfg, migrations, raw=True))
all_results.extend(migrate_results)
maybe_regenerate_traefik(cfg, migrate_results)
# 3. Start missing services (reuse up_services which handles state updates)
# 4. Start missing stacks (reuse up_stacks which handles state updates)
if has_missing:
console.print("[green]Starting missing services...[/]")
start_results = run_async(up_services(cfg, missing, raw=True))
console.print("[green]Starting missing stacks...[/]")
start_results = run_async(up_stacks(cfg, missing, raw=True))
all_results.extend(start_results)
maybe_regenerate_traefik(cfg, start_results)
# 4. Refresh remaining services (--full: run up to apply config changes)
# 5. Refresh remaining stacks (--full: run up to apply config changes)
if has_refresh:
console.print("[blue]Refreshing services...[/]")
refresh_results = run_async(up_services(cfg, to_refresh, raw=True))
console.print("[blue]Refreshing stacks...[/]")
refresh_results = run_async(up_stacks(cfg, to_refresh, raw=True))
all_results.extend(refresh_results)
maybe_regenerate_traefik(cfg, refresh_results)
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

@@ -15,14 +15,14 @@ from compose_farm.cli.common import (
AllOption,
ConfigOption,
LogPathOption,
ServicesArg,
StacksArg,
format_host,
get_services,
get_stacks,
load_config_or_exit,
run_async,
run_parallel_with_progress,
validate_hosts,
validate_services,
validate_stacks,
)
if TYPE_CHECKING:
@@ -37,60 +37,63 @@ from compose_farm.console import (
)
from compose_farm.executor import (
CommandResult,
get_running_stacks_on_host,
is_local,
run_command,
)
from compose_farm.logs import (
DEFAULT_LOG_PATH,
SnapshotEntry,
collect_service_entries,
collect_stacks_entries_on_host,
isoformat,
load_existing_entries,
merge_entries,
write_toml,
)
from compose_farm.operations import (
build_discovery_results,
check_host_compatibility,
check_service_requirements,
discover_service_host,
check_stack_requirements,
)
from compose_farm.state import get_orphaned_services, load_state, save_state
from compose_farm.state import get_orphaned_stacks, load_state, save_state
from compose_farm.traefik import generate_traefik_config, render_traefik_config
# --- Sync helpers ---
def _discover_services(cfg: Config) -> dict[str, str | list[str]]:
"""Discover running services with a progress bar."""
results = run_parallel_with_progress(
"Discovering",
list(cfg.services),
lambda s: discover_service_host(cfg, s),
)
return {svc: host for svc, host in results if host is not None}
def _snapshot_services(
def _snapshot_stacks(
cfg: Config,
services: list[str],
discovered: dict[str, str | list[str]],
log_path: Path | None,
) -> Path:
"""Capture image digests with a progress bar."""
"""Capture image digests using batched SSH calls (1 per host).
Args:
cfg: Configuration
discovered: Dict mapping stack -> host(s) where it's running
log_path: Optional path to write the log file
Returns:
Path to the written log file.
"""
effective_log_path = log_path or DEFAULT_LOG_PATH
now_dt = datetime.now(UTC)
now_iso = isoformat(now_dt)
async def collect_service(service: str) -> tuple[str, list[SnapshotEntry]]:
try:
return service, await collect_service_entries(cfg, service, now=now_dt)
except RuntimeError:
return service, []
# Group stacks by host for batched SSH calls
stacks_by_host: dict[str, set[str]] = {}
for stack, hosts in discovered.items():
# Use first host for multi-host stacks (they use the same images)
host = hosts[0] if isinstance(hosts, list) else hosts
stacks_by_host.setdefault(host, set()).add(stack)
results = run_parallel_with_progress(
"Capturing",
services,
collect_service,
)
# Collect entries with 1 SSH call per host (with progress bar)
async def collect_on_host(host: str) -> tuple[str, list[SnapshotEntry]]:
entries = await collect_stacks_entries_on_host(cfg, host, stacks_by_host[host], now=now_dt)
return host, entries
results = run_parallel_with_progress("Capturing", list(stacks_by_host.keys()), collect_on_host)
snapshot_entries = [entry for _, entries in results for entry in entries]
if not snapshot_entries:
@@ -104,6 +107,18 @@ def _snapshot_services(
return effective_log_path
def _merge_state(
current_state: dict[str, str | list[str]],
discovered: dict[str, str | list[str]],
removed: list[str],
) -> dict[str, str | list[str]]:
"""Merge discovered stacks into existing state for partial refresh."""
new_state = {**current_state, **discovered}
for svc in removed:
new_state.pop(svc, None)
return new_state
def _report_sync_changes(
added: list[str],
removed: list[str],
@@ -113,25 +128,80 @@ def _report_sync_changes(
) -> None:
"""Report sync changes to the user."""
if added:
console.print(f"\nNew services found ({len(added)}):")
for service in sorted(added):
host_str = format_host(discovered[service])
console.print(f" [green]+[/] [cyan]{service}[/] on [magenta]{host_str}[/]")
console.print(f"\nNew stacks found ({len(added)}):")
for stack in sorted(added):
host_str = format_host(discovered[stack])
console.print(f" [green]+[/] [cyan]{stack}[/] on [magenta]{host_str}[/]")
if changed:
console.print(f"\nServices on different hosts ({len(changed)}):")
for service, old_host, new_host in sorted(changed):
console.print(f"\nStacks on different hosts ({len(changed)}):")
for stack, old_host, new_host in sorted(changed):
old_str = format_host(old_host)
new_str = format_host(new_host)
console.print(
f" [yellow]~[/] [cyan]{service}[/]: [magenta]{old_str}[/] → [magenta]{new_str}[/]"
f" [yellow]~[/] [cyan]{stack}[/]: [magenta]{old_str}[/] → [magenta]{new_str}[/]"
)
if removed:
console.print(f"\nServices no longer running ({len(removed)}):")
for service in sorted(removed):
host_str = format_host(current_state[service])
console.print(f" [red]-[/] [cyan]{service}[/] (was on [magenta]{host_str}[/])")
console.print(f"\nStacks no longer running ({len(removed)}):")
for stack in sorted(removed):
host_str = format_host(current_state[stack])
console.print(f" [red]-[/] [cyan]{stack}[/] (was on [magenta]{host_str}[/])")
def _discover_stacks_full(
cfg: Config,
stacks: list[str] | None = None,
) -> tuple[dict[str, str | list[str]], dict[str, list[str]], dict[str, list[str]]]:
"""Discover running stacks with full host scanning for stray detection.
Queries each host once for all running stacks (with progress bar),
then delegates to build_discovery_results for categorization.
"""
all_hosts = list(cfg.hosts.keys())
# Query each host for running stacks (with progress bar)
async def get_stacks_on_host(host: str) -> tuple[str, set[str]]:
running = await get_running_stacks_on_host(cfg, host)
return host, running
host_results = run_parallel_with_progress("Discovering", all_hosts, get_stacks_on_host)
running_on_host: dict[str, set[str]] = dict(host_results)
return build_discovery_results(cfg, running_on_host, stacks)
def _report_stray_stacks(
strays: dict[str, list[str]],
cfg: Config,
) -> None:
"""Report stacks running on unauthorized hosts."""
if strays:
console.print(f"\n[red]Stray stacks[/] (running on wrong host, {len(strays)}):")
console.print("[dim]Run [bold]cf apply[/bold] to stop them.[/]")
for stack in sorted(strays):
stray_hosts = strays[stack]
configured = cfg.get_hosts(stack)
console.print(
f" [red]![/] [cyan]{stack}[/] on [magenta]{', '.join(stray_hosts)}[/] "
f"[dim](should be on {', '.join(configured)})[/]"
)
def _report_duplicate_stacks(duplicates: dict[str, list[str]], cfg: Config) -> None:
"""Report single-host stacks running on multiple hosts."""
if duplicates:
console.print(
f"\n[yellow]Duplicate stacks[/] (running on multiple hosts, {len(duplicates)}):"
)
console.print("[dim]Run [bold]cf apply[/bold] to stop extras.[/]")
for stack in sorted(duplicates):
hosts = duplicates[stack]
configured = cfg.get_hosts(stack)[0]
console.print(
f" [yellow]![/] [cyan]{stack}[/] on [magenta]{', '.join(hosts)}[/] "
f"[dim](should only be on {configured})[/]"
)
# --- Check helpers ---
@@ -166,44 +236,44 @@ def _check_ssh_connectivity(cfg: Config) -> list[str]:
return [host for host, success in results if not success]
def _check_service_requirements(
def _check_stack_requirements(
cfg: Config,
services: list[str],
stacks: list[str],
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]], list[tuple[str, str, str]]]:
"""Check mounts, networks, and devices for all services with a progress bar.
"""Check mounts, networks, and devices for all stacks with a progress bar.
Returns (mount_errors, network_errors, device_errors) where each is a list of
(service, host, missing_item) tuples.
(stack, host, missing_item) tuples.
"""
async def check_service(
service: str,
async def check_stack(
stack: str,
) -> tuple[
str,
list[tuple[str, str, str]],
list[tuple[str, str, str]],
list[tuple[str, str, str]],
]:
"""Check requirements for a single service on all its hosts."""
host_names = cfg.get_hosts(service)
"""Check requirements for a single stack on all its hosts."""
host_names = cfg.get_hosts(stack)
mount_errors: list[tuple[str, str, str]] = []
network_errors: list[tuple[str, str, str]] = []
device_errors: list[tuple[str, str, str]] = []
for host_name in host_names:
missing_paths, missing_nets, missing_devs = await check_service_requirements(
cfg, service, host_name
missing_paths, missing_nets, missing_devs = await check_stack_requirements(
cfg, stack, host_name
)
mount_errors.extend((service, host_name, p) for p in missing_paths)
network_errors.extend((service, host_name, n) for n in missing_nets)
device_errors.extend((service, host_name, d) for d in missing_devs)
mount_errors.extend((stack, host_name, p) for p in missing_paths)
network_errors.extend((stack, host_name, n) for n in missing_nets)
device_errors.extend((stack, host_name, d) for d in missing_devs)
return service, mount_errors, network_errors, device_errors
return stack, mount_errors, network_errors, device_errors
results = run_parallel_with_progress(
"Checking requirements",
services,
check_service,
stacks,
check_stack,
)
all_mount_errors: list[tuple[str, str, str]] = []
@@ -219,7 +289,7 @@ def _check_service_requirements(
def _report_config_status(cfg: Config) -> bool:
"""Check and report config vs disk status. Returns True if errors found."""
configured = set(cfg.services.keys())
configured = set(cfg.stacks.keys())
on_disk = cfg.discover_compose_dirs()
unmanaged = sorted(on_disk - configured)
missing_from_disk = sorted(configured - on_disk)
@@ -240,12 +310,12 @@ def _report_config_status(cfg: Config) -> bool:
return bool(missing_from_disk)
def _report_orphaned_services(cfg: Config) -> bool:
"""Check for services in state but not in config. Returns True if orphans found."""
orphaned = get_orphaned_services(cfg)
def _report_orphaned_stacks(cfg: Config) -> bool:
"""Check for stacks in state but not in config. Returns True if orphans found."""
orphaned = get_orphaned_stacks(cfg)
if orphaned:
console.print("\n[yellow]Orphaned services[/] (in state but not in config):")
console.print("\n[yellow]Orphaned stacks[/] (in state but not in config):")
console.print(
"[dim]Run [bold]cf apply[/bold] to stop them, or [bold]cf down --orphaned[/bold] for just orphans.[/]"
)
@@ -256,10 +326,10 @@ def _report_orphaned_services(cfg: Config) -> bool:
return False
def _report_traefik_status(cfg: Config, services: list[str]) -> None:
def _report_traefik_status(cfg: Config, stacks: list[str]) -> None:
"""Check and report traefik label status."""
try:
_, warnings = generate_traefik_config(cfg, services, check_all=True)
_, warnings = generate_traefik_config(cfg, stacks, check_all=True)
except (FileNotFoundError, ValueError):
return
@@ -272,16 +342,16 @@ def _report_traefik_status(cfg: Config, services: list[str]) -> None:
def _report_requirement_errors(errors: list[tuple[str, str, str]], category: str) -> None:
"""Report requirement errors (mounts, networks, devices) grouped by service."""
by_service: dict[str, list[tuple[str, str]]] = {}
for svc, host, item in errors:
by_service.setdefault(svc, []).append((host, item))
"""Report requirement errors (mounts, networks, devices) grouped by stack."""
by_stack: dict[str, list[tuple[str, str]]] = {}
for stack, host, item in errors:
by_stack.setdefault(stack, []).append((host, item))
console.print(f"[red]Missing {category}[/] ({len(errors)}):")
for svc, items in sorted(by_service.items()):
for stack, items in sorted(by_stack.items()):
host = items[0][0]
missing = [i for _, i in items]
console.print(f" [cyan]{svc}[/] on [magenta]{host}[/]:")
console.print(f" [cyan]{stack}[/] on [magenta]{host}[/]:")
for item in missing:
console.print(f" [red]✗[/] {item}")
@@ -301,7 +371,7 @@ def _report_host_compatibility(
compat: dict[str, tuple[int, int, list[str]]],
assigned_hosts: list[str],
) -> None:
"""Report host compatibility for a service."""
"""Report host compatibility for a stack."""
for host_name, (found, total, missing) in sorted(compat.items()):
is_assigned = host_name in assigned_hosts
marker = " [dim](assigned)[/]" if is_assigned else ""
@@ -332,7 +402,7 @@ def _run_remote_checks(cfg: Config, svc_list: list[str], *, show_host_compat: bo
console.print() # Spacing before mounts/networks check
# Check mounts, networks, and devices
mount_errors, network_errors, device_errors = _check_service_requirements(cfg, svc_list)
mount_errors, network_errors, device_errors = _check_stack_requirements(cfg, svc_list)
if mount_errors:
_report_requirement_errors(mount_errors, "mounts")
@@ -347,10 +417,10 @@ def _run_remote_checks(cfg: Config, svc_list: list[str], *, show_host_compat: bo
print_success("All mounts, networks, and devices exist")
if show_host_compat:
for service in svc_list:
console.print(f"\n[bold]Host compatibility for[/] [cyan]{service}[/]:")
compat = run_async(check_host_compatibility(cfg, service))
assigned_hosts = cfg.get_hosts(service)
for stack in svc_list:
console.print(f"\n[bold]Host compatibility for[/] [cyan]{stack}[/]:")
compat = run_async(check_host_compatibility(cfg, stack))
assigned_hosts = cfg.get_hosts(stack)
_report_host_compatibility(compat, assigned_hosts)
return has_errors
@@ -364,8 +434,8 @@ _DEFAULT_NETWORK_GATEWAY = "172.20.0.1"
@app.command("traefik-file", rich_help_panel="Configuration")
def traefik_file(
services: ServicesArg = None,
all_services: AllOption = False,
stacks: StacksArg = None,
all_stacks: AllOption = False,
output: Annotated[
Path | None,
typer.Option(
@@ -377,9 +447,9 @@ def traefik_file(
config: ConfigOption = None,
) -> None:
"""Generate a Traefik file-provider fragment from compose Traefik labels."""
svc_list, cfg = get_services(services or [], all_services, config)
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
try:
dynamic, warnings = generate_traefik_config(cfg, svc_list)
dynamic, warnings = generate_traefik_config(cfg, stack_list)
except (FileNotFoundError, ValueError) as exc:
print_error(str(exc))
raise typer.Exit(1) from exc
@@ -399,6 +469,8 @@ def traefik_file(
@app.command(rich_help_panel="Configuration")
def refresh(
stacks: StacksArg = None,
all_stacks: AllOption = False,
config: ConfigOption = None,
log_path: LogPathOption = None,
dry_run: Annotated[
@@ -406,22 +478,35 @@ def refresh(
typer.Option("--dry-run", "-n", help="Show what would change without writing"),
] = False,
) -> None:
"""Update local state from running services.
"""Update local state from running stacks.
Discovers which services are running on which hosts, updates the state
Discovers which stacks are running on which hosts, updates the state
file, and captures image digests. This is a read operation - it updates
your local state to match reality, not the other way around.
Without arguments: refreshes all stacks (same as --all).
With stack names: refreshes only those stacks.
Use 'cf apply' to make reality match your config (stop orphans, migrate).
"""
cfg = load_config_or_exit(config)
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, default_all=True)
# Partial refresh merges with existing state; full refresh replaces it
# Partial = specific stacks provided (not --all, not default)
partial_refresh = bool(stacks) and not all_stacks
current_state = load_state(cfg)
discovered = _discover_services(cfg)
discovered, strays, duplicates = _discover_stacks_full(cfg, stack_list)
# Calculate changes
# Calculate changes (only for the stacks we're refreshing)
added = [s for s in discovered if s not in current_state]
removed = [s for s in current_state if s not in discovered]
# Only mark as "removed" if we're doing a full refresh
if partial_refresh:
# In partial refresh, a stack not running is just "not found"
removed = [s for s in stack_list if s in current_state and s not in discovered]
else:
removed = [s for s in current_state if s not in discovered]
changed = [
(s, current_state[s], discovered[s])
for s in discovered
@@ -435,19 +520,25 @@ def refresh(
else:
print_success("State is already in sync.")
_report_stray_stacks(strays, cfg)
_report_duplicate_stacks(duplicates, cfg)
if dry_run:
console.print(f"\n{MSG_DRY_RUN}")
return
# Update state file
if state_changed:
save_state(cfg, discovered)
print_success(f"State updated: {len(discovered)} services tracked.")
new_state = (
_merge_state(current_state, discovered, removed) if partial_refresh else discovered
)
save_state(cfg, new_state)
print_success(f"State updated: {len(new_state)} stacks tracked.")
# Capture image digests for running services
# Capture image digests for running stacks (1 SSH call per host)
if discovered:
try:
path = _snapshot_services(cfg, list(discovered.keys()), log_path)
path = _snapshot_stacks(cfg, discovered, log_path)
print_success(f"Digests written to {path}")
except RuntimeError as exc:
print_warning(str(exc))
@@ -455,7 +546,7 @@ def refresh(
@app.command(rich_help_panel="Configuration")
def check(
services: ServicesArg = None,
stacks: StacksArg = None,
local: Annotated[
bool,
typer.Option("--local", help="Skip SSH-based checks (faster)"),
@@ -464,31 +555,31 @@ def check(
) -> None:
"""Validate configuration, traefik labels, mounts, and networks.
Without arguments: validates all services against configured hosts.
With service arguments: validates specific services and shows host compatibility.
Without arguments: validates all stacks against configured hosts.
With stack arguments: validates specific stacks and shows host compatibility.
Use --local to skip SSH-based checks for faster validation.
"""
cfg = load_config_or_exit(config)
# Determine which services to check and whether to show host compatibility
if services:
svc_list = list(services)
validate_services(cfg, svc_list)
# Determine which stacks to check and whether to show host compatibility
if stacks:
stack_list = list(stacks)
validate_stacks(cfg, stack_list)
show_host_compat = True
else:
svc_list = list(cfg.services.keys())
stack_list = list(cfg.stacks.keys())
show_host_compat = False
# Run checks
has_errors = _report_config_status(cfg)
_report_traefik_status(cfg, svc_list)
_report_traefik_status(cfg, stack_list)
if not local and _run_remote_checks(cfg, svc_list, show_host_compat=show_host_compat):
if not local and _run_remote_checks(cfg, stack_list, show_host_compat=show_host_compat):
has_errors = True
# Check for orphaned services (in state but removed from config)
if _report_orphaned_services(cfg):
# Check for orphaned stacks (in state but removed from config)
if _report_orphaned_stacks(cfg):
has_errors = True
if has_errors:
@@ -517,7 +608,7 @@ def init_network(
) -> None:
"""Create Docker network on hosts with consistent settings.
Creates an external Docker network that services can use for cross-host
Creates an external Docker network that stacks can use for cross-host
communication. Uses the same subnet/gateway on all hosts to ensure
consistent networking.
"""
@@ -534,7 +625,7 @@ def init_network(
if check_result.success:
console.print(f"[cyan]\\[{host_name}][/] Network '{network}' already exists")
return CommandResult(service=host_name, exit_code=0, success=True)
return CommandResult(stack=host_name, exit_code=0, success=True)
# Create the network
create_cmd = (

View File

@@ -14,16 +14,17 @@ from compose_farm.cli.common import (
AllOption,
ConfigOption,
HostOption,
ServicesArg,
get_services,
ServiceOption,
StacksArg,
get_stacks,
load_config_or_exit,
report_results,
run_async,
run_parallel_with_progress,
)
from compose_farm.console import console
from compose_farm.executor import run_command, run_on_services
from compose_farm.state import get_services_needing_migration, group_services_by_host, load_state
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
if TYPE_CHECKING:
from compose_farm.config import Config
@@ -51,7 +52,7 @@ def _get_container_counts(cfg: Config) -> dict[str, int]:
def _build_host_table(
cfg: Config,
services_by_host: dict[str, list[str]],
stacks_by_host: dict[str, list[str]],
running_by_host: dict[str, list[str]],
container_counts: dict[str, int],
*,
@@ -68,7 +69,7 @@ def _build_host_table(
for host_name in sorted(cfg.hosts.keys()):
host = cfg.hosts[host_name]
configured = len(services_by_host[host_name])
configured = len(stacks_by_host[host_name])
running = len(running_by_host[host_name])
row = [
@@ -96,8 +97,8 @@ def _build_summary_table(
table.add_column("Value", style="bold")
table.add_row("Total hosts", str(len(cfg.hosts)))
table.add_row("Services (configured)", str(len(cfg.services)))
table.add_row("Services (tracked)", str(len(state)))
table.add_row("Stacks (configured)", str(len(cfg.stacks)))
table.add_row("Stacks (tracked)", str(len(state)))
table.add_row("Compose files on disk", str(len(on_disk)))
if pending:
@@ -115,9 +116,10 @@ def _build_summary_table(
@app.command(rich_help_panel="Monitoring")
def logs(
services: ServicesArg = None,
all_services: AllOption = False,
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,34 +127,45 @@ def logs(
] = None,
config: ConfigOption = None,
) -> None:
"""Show service logs."""
svc_list, cfg = get_services(services or [], all_services, config, host=host)
"""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 services
many_services = all_services or host is not None or len(svc_list) > 1
effective_tail = tail if tail is not None else (20 if many_services else 100)
# Default to fewer lines when showing multiple stacks
many_stacks = all_stacks or host is not None or len(stack_list) > 1
effective_tail = tail if tail is not None else (20 if many_stacks else 100)
cmd = f"logs --tail {effective_tail}"
if follow:
cmd += " -f"
results = run_async(run_on_services(cfg, svc_list, cmd))
if service:
cmd += f" {service}"
results = run_async(run_on_stacks(cfg, stack_list, cmd))
report_results(results)
@app.command(rich_help_panel="Monitoring")
def ps(
services: ServicesArg = None,
all_services: AllOption = False,
stacks: StacksArg = None,
all_stacks: AllOption = False,
host: HostOption = None,
service: ServiceOption = None,
config: ConfigOption = None,
) -> None:
"""Show status of services.
"""Show status of stacks.
Without arguments: shows all services (same as --all).
With service names: shows only those services.
With --host: shows services on that host.
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.
"""
svc_list, cfg = get_services(services or [], all_services, config, host=host, default_all=True)
results = run_async(run_on_services(cfg, svc_list, "ps"))
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host, default_all=True)
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)
@@ -164,25 +177,25 @@ def stats(
] = False,
config: ConfigOption = None,
) -> None:
"""Show overview statistics for hosts and services.
"""Show overview statistics for hosts and stacks.
Without --live: Shows config/state info (hosts, services, pending migrations).
Without --live: Shows config/state info (hosts, stacks, pending migrations).
With --live: Also queries Docker on each host for container counts.
"""
cfg = load_config_or_exit(config)
state = load_state(cfg)
pending = get_services_needing_migration(cfg)
pending = get_stacks_needing_migration(cfg)
all_hosts = list(cfg.hosts.keys())
services_by_host = group_services_by_host(cfg.services, cfg.hosts, all_hosts)
running_by_host = group_services_by_host(state, cfg.hosts, all_hosts)
stacks_by_host = group_stacks_by_host(cfg.stacks, cfg.hosts, all_hosts)
running_by_host = group_stacks_by_host(state, cfg.hosts, all_hosts)
container_counts: dict[str, int] = {}
if live:
container_counts = _get_container_counts(cfg)
host_table = _build_host_table(
cfg, services_by_host, running_by_host, container_counts, show_containers=live
cfg, stacks_by_host, running_by_host, container_counts, show_containers=live
)
console.print(host_table)

View File

@@ -179,13 +179,13 @@ def _parse_volume_item(
return host_path
def parse_host_volumes(config: Config, service: str) -> list[str]:
"""Extract host bind mount paths from a service's compose file.
def parse_host_volumes(config: Config, stack: str) -> list[str]:
"""Extract host bind mount paths from a stack's compose file.
Returns a list of absolute host paths used as volume mounts.
Skips named volumes and resolves relative paths.
"""
compose_path = config.get_compose_path(service)
compose_path = config.get_compose_path(stack)
if not compose_path.exists():
return []
@@ -213,21 +213,15 @@ def parse_host_volumes(config: Config, service: str) -> list[str]:
paths.append(host_path)
# Return unique paths, preserving order
seen: set[str] = set()
unique: list[str] = []
for p in paths:
if p not in seen:
seen.add(p)
unique.append(p)
return unique
return list(dict.fromkeys(paths))
def parse_devices(config: Config, service: str) -> list[str]:
"""Extract host device paths from a service's compose file.
def parse_devices(config: Config, stack: str) -> list[str]:
"""Extract host device paths from a stack's compose file.
Returns a list of host device paths (e.g., /dev/dri, /dev/dri/renderD128).
"""
compose_path = config.get_compose_path(service)
compose_path = config.get_compose_path(stack)
if not compose_path.exists():
return []
@@ -258,21 +252,15 @@ def parse_devices(config: Config, service: str) -> list[str]:
devices.append(host_path)
# Return unique devices, preserving order
seen: set[str] = set()
unique: list[str] = []
for d in devices:
if d not in seen:
seen.add(d)
unique.append(d)
return unique
return list(dict.fromkeys(devices))
def parse_external_networks(config: Config, service: str) -> list[str]:
"""Extract external network names from a service's compose file.
def parse_external_networks(config: Config, stack: str) -> list[str]:
"""Extract external network names from a stack's compose file.
Returns a list of network names marked as external: true.
"""
compose_path = config.get_compose_path(service)
compose_path = config.get_compose_path(stack)
if not compose_path.exists():
return []
@@ -348,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,14 +4,18 @@ from __future__ import annotations
import getpass
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, Field, model_validator
from .paths import config_search_paths, find_config_path
# Supported compose filenames, in priority order
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
@@ -19,14 +23,14 @@ class Host(BaseModel):
port: int = 22
class Config(BaseModel):
class Config(BaseModel, extra="forbid"):
"""Main configuration."""
compose_dir: Path = Path("/opt/compose")
hosts: dict[str, Host]
services: dict[str, str | list[str]] # service_name -> host_name or list of hosts
stacks: dict[str, str | list[str]] # stack_name -> host_name or list of hosts
traefik_file: Path | None = None # Auto-regenerate traefik config after up/down
traefik_service: str | None = None # Service name for Traefik (skip its host in file-provider)
traefik_stack: str | None = None # Stack name for Traefik (skip its host in file-provider)
config_path: Path = Path() # Set by load_config()
def get_state_path(self) -> Path:
@@ -34,100 +38,83 @@ class Config(BaseModel):
return self.config_path.parent / "compose-farm-state.yaml"
@model_validator(mode="after")
def validate_hosts_and_services(self) -> Config:
"""Validate host names and service configurations."""
def validate_hosts_and_stacks(self) -> Config:
"""Validate host names and stack configurations."""
# "all" is reserved keyword, cannot be used as host name
if "all" in self.hosts:
msg = "'all' is a reserved keyword and cannot be used as a host name"
raise ValueError(msg)
for service, host_value in self.services.items():
for stack, host_value in self.stacks.items():
# Validate list configurations
if isinstance(host_value, list):
if not host_value:
msg = f"Service '{service}' has empty host list"
msg = f"Stack '{stack}' has empty host list"
raise ValueError(msg)
if len(host_value) != len(set(host_value)):
msg = f"Service '{service}' has duplicate hosts in list"
msg = f"Stack '{stack}' has duplicate hosts in list"
raise ValueError(msg)
# Validate all referenced hosts exist
host_names = self.get_hosts(service)
host_names = self.get_hosts(stack)
for host_name in host_names:
if host_name not in self.hosts:
msg = f"Service '{service}' references unknown host '{host_name}'"
msg = f"Stack '{stack}' references unknown host '{host_name}'"
raise ValueError(msg)
return self
def get_hosts(self, service: str) -> list[str]:
"""Get list of host names for a service.
def get_hosts(self, stack: str) -> list[str]:
"""Get list of host names for a stack.
Supports:
- Single host: "truenas-debian" -> ["truenas-debian"]
- All hosts: "all" -> list of all configured hosts
- Explicit list: ["host1", "host2"] -> ["host1", "host2"]
"""
if service not in self.services:
msg = f"Unknown service: {service}"
if stack not in self.stacks:
msg = f"Unknown stack: {stack}"
raise ValueError(msg)
host_value = self.services[service]
host_value = self.stacks[stack]
if isinstance(host_value, list):
return host_value
if host_value == "all":
return list(self.hosts.keys())
return [host_value]
def is_multi_host(self, service: str) -> bool:
"""Check if a service runs on multiple hosts."""
return len(self.get_hosts(service)) > 1
def is_multi_host(self, stack: str) -> bool:
"""Check if a stack runs on multiple hosts."""
return len(self.get_hosts(stack)) > 1
def get_host(self, service: str) -> Host:
"""Get host config for a service (first host if multi-host)."""
if service not in self.services:
msg = f"Unknown service: {service}"
def get_host(self, stack: str) -> Host:
"""Get host config for a stack (first host if multi-host)."""
if stack not in self.stacks:
msg = f"Unknown stack: {stack}"
raise ValueError(msg)
host_names = self.get_hosts(service)
host_names = self.get_hosts(stack)
return self.hosts[host_names[0]]
def get_compose_path(self, service: str) -> Path:
"""Get compose file path for a service.
Tries compose.yaml first, then docker-compose.yml.
"""
service_dir = self.compose_dir / service
for filename in (
"compose.yaml",
"compose.yml",
"docker-compose.yml",
"docker-compose.yaml",
):
candidate = service_dir / filename
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
for filename in COMPOSE_FILENAMES:
candidate = stack_dir / filename
if candidate.exists():
return candidate
# Default to compose.yaml if none exist (will error later)
return service_dir / "compose.yaml"
return stack_dir / "compose.yaml"
def discover_compose_dirs(self) -> set[str]:
"""Find all directories in compose_dir that contain a compose file."""
compose_filenames = {
"compose.yaml",
"compose.yml",
"docker-compose.yml",
"docker-compose.yaml",
}
found: set[str] = set()
if not self.compose_dir.exists():
return found
for subdir in self.compose_dir.iterdir():
if subdir.is_dir():
for filename in compose_filenames:
if (subdir / filename).exists():
found.add(subdir.name)
break
if subdir.is_dir() and any((subdir / f).exists() for f in COMPOSE_FILENAMES):
found.add(subdir.name)
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():

View File

@@ -9,7 +9,7 @@ err_console = Console(stderr=True, highlight=False)
# --- Message Constants ---
# Standardized message templates for consistent user-facing output
MSG_SERVICE_NOT_FOUND = "Service [cyan]{name}[/] not found in config"
MSG_STACK_NOT_FOUND = "Stack [cyan]{name}[/] not found in config"
MSG_HOST_NOT_FOUND = "Host [magenta]{name}[/] not found in config"
MSG_CONFIG_NOT_FOUND = "Config file not found"
MSG_DRY_RUN = "[dim](dry-run: no changes made)[/]"

View File

@@ -1,7 +1,7 @@
# Compose Farm configuration
# Documentation: https://github.com/basnijholt/compose-farm
#
# This file configures compose-farm to manage Docker Compose services
# This file configures compose-farm to manage Docker Compose stacks
# across multiple hosts via SSH.
#
# Place this file at:
@@ -11,7 +11,7 @@
# - Or set CF_CONFIG environment variable
# ------------------------------------------------------------------------------
# compose_dir: Directory containing service subdirectories with compose files
# compose_dir: Directory containing stack subdirectories with compose files
# ------------------------------------------------------------------------------
# Each subdirectory should contain a compose.yaml (or docker-compose.yml).
# This path must be the same on all hosts (NFS mount recommended).
@@ -48,28 +48,28 @@ hosts:
port: 2222
# ------------------------------------------------------------------------------
# services: Map service names to their target host(s)
# stacks: Map stack names to their target host(s)
# ------------------------------------------------------------------------------
# Each service name must match a subdirectory in compose_dir.
# Each stack name must match a subdirectory in compose_dir.
#
# Single host:
# service-name: hostname
# stack-name: hostname
#
# Multiple hosts (explicit list):
# service-name: [host1, host2]
# stack-name: [host1, host2]
#
# All hosts:
# service-name: all
# stack-name: all
#
services:
# Example: service runs on a single host
stacks:
# Example: stack runs on a single host
nginx: server1
postgres: server2
# Example: service runs on multiple specific hosts
# Example: stack runs on multiple specific hosts
# prometheus: [server1, server2]
# Example: service runs on ALL hosts (e.g., monitoring agents)
# Example: stack runs on ALL hosts (e.g., monitoring agents)
# node-exporter: all
# ------------------------------------------------------------------------------
@@ -81,9 +81,9 @@ services:
# traefik_file: /opt/compose/traefik/dynamic.d/compose-farm.yml
# ------------------------------------------------------------------------------
# traefik_service: (optional) Service name running Traefik
# traefik_stack: (optional) Stack name running Traefik
# ------------------------------------------------------------------------------
# When generating traefik_file, services on the same host as Traefik are
# When generating traefik_file, stacks on the same host as Traefik are
# skipped (they're handled by Traefik's Docker provider directly).
#
# traefik_service: traefik
# traefik_stack: traefik

View File

@@ -23,6 +23,48 @@ LOCAL_ADDRESSES = frozenset({"local", "localhost", "127.0.0.1", "::1"})
_DEFAULT_SSH_PORT = 22
def _print_compose_command(
host_name: str,
compose_dir: str,
compose_path: 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
console.print(
f"[dim][magenta]{host_name}[/magenta]: docker compose -f {rel_path} {compose_cmd}[/dim]"
)
async def _stream_output_lines(
reader: Any,
prefix: str,
*,
is_stderr: bool = False,
) -> None:
"""Stream lines from a reader to console with a stack prefix.
Works with both asyncio.StreamReader (bytes) and asyncssh readers (str).
If prefix is empty, output is printed without a prefix.
"""
out = err_console if is_stderr else console
async for line in reader:
text = line.decode() if isinstance(line, bytes) else line
if text.strip():
if prefix:
out.print(f"[cyan]\\[{prefix}][/] {escape(text)}", end="")
else:
out.print(escape(text), end="")
def build_ssh_command(host: Host, command: str, *, tty: bool = False) -> list[str]:
"""Build SSH command args for executing a command on a remote host.
@@ -84,7 +126,7 @@ def _get_local_ips() -> frozenset[str]:
class CommandResult:
"""Result of a command execution."""
service: str
stack: str
exit_code: int
success: bool
stdout: str = ""
@@ -130,10 +172,11 @@ def ssh_connect_kwargs(host: Host) -> dict[str, Any]:
async def _run_local_command(
command: str,
service: str,
stack: str,
*,
stream: bool = True,
raw: bool = False,
prefix: str = "",
) -> CommandResult:
"""Run a command locally with streaming output."""
try:
@@ -146,7 +189,7 @@ async def _run_local_command(
)
await proc.wait()
return CommandResult(
service=service,
stack=stack,
exit_code=proc.returncode or 0,
success=proc.returncode == 0,
)
@@ -158,25 +201,9 @@ async def _run_local_command(
)
if stream and proc.stdout and proc.stderr:
async def read_stream(
reader: asyncio.StreamReader,
prefix: str,
*,
is_stderr: bool = False,
) -> None:
out = err_console if is_stderr else console
while True:
line = await reader.readline()
if not line:
break
text = line.decode()
if text.strip(): # Skip empty lines
out.print(f"[cyan]\\[{prefix}][/] {escape(text)}", end="")
await asyncio.gather(
read_stream(proc.stdout, service),
read_stream(proc.stderr, service, is_stderr=True),
_stream_output_lines(proc.stdout, prefix),
_stream_output_lines(proc.stderr, prefix, is_stderr=True),
)
stdout_data = b""
@@ -187,34 +214,39 @@ async def _run_local_command(
await proc.wait()
return CommandResult(
service=service,
stack=stack,
exit_code=proc.returncode or 0,
success=proc.returncode == 0,
stdout=stdout_data.decode() if stdout_data else "",
stderr=stderr_data.decode() if stderr_data else "",
)
except OSError as e:
err_console.print(f"[cyan]\\[{service}][/] [red]Local error:[/] {e}")
return CommandResult(service=service, exit_code=1, success=False)
err_console.print(f"[cyan]\\[{stack}][/] [red]Local error:[/] {e}")
return CommandResult(stack=stack, exit_code=1, success=False)
async def _run_ssh_command(
host: Host,
command: str,
service: str,
stack: str,
*,
stream: bool = True,
raw: bool = False,
prefix: str = "",
) -> CommandResult:
"""Run a command on a remote host via SSH with streaming output."""
if raw:
# Use native ssh with TTY for proper progress bar rendering
ssh_args = build_ssh_command(host, command, tty=True)
def run_ssh() -> subprocess.CompletedProcess[bytes]:
return subprocess.run(ssh_args, check=False, env=get_ssh_env())
# Run in thread to avoid blocking the event loop
# Use get_ssh_env() to auto-detect SSH agent socket
result = await asyncio.to_thread(subprocess.run, ssh_args, check=False, env=get_ssh_env())
result = await asyncio.to_thread(run_ssh)
return CommandResult(
service=service,
stack=stack,
exit_code=result.returncode,
success=result.returncode == 0,
)
@@ -226,21 +258,9 @@ async def _run_ssh_command(
async with asyncssh.connect(**ssh_connect_kwargs(host)) as conn: # noqa: SIM117
async with conn.create_process(command) as proc:
if stream:
async def read_stream(
reader: Any,
prefix: str,
*,
is_stderr: bool = False,
) -> None:
out = err_console if is_stderr else console
async for line in reader:
if line.strip(): # Skip empty lines
out.print(f"[cyan]\\[{prefix}][/] {escape(line)}", end="")
await asyncio.gather(
read_stream(proc.stdout, service),
read_stream(proc.stderr, service, is_stderr=True),
_stream_output_lines(proc.stdout, prefix),
_stream_output_lines(proc.stderr, prefix, is_stderr=True),
)
stdout_data = ""
@@ -251,131 +271,155 @@ async def _run_ssh_command(
await proc.wait()
return CommandResult(
service=service,
stack=stack,
exit_code=proc.exit_status or 0,
success=proc.exit_status == 0,
stdout=stdout_data,
stderr=stderr_data,
)
except (OSError, asyncssh.Error) as e:
err_console.print(f"[cyan]\\[{service}][/] [red]SSH error:[/] {e}")
return CommandResult(service=service, exit_code=1, success=False)
err_console.print(f"[cyan]\\[{stack}][/] [red]SSH error:[/] {e}")
return CommandResult(stack=stack, exit_code=1, success=False)
async def run_command(
host: Host,
command: str,
service: str,
stack: str,
*,
stream: bool = True,
raw: bool = False,
prefix: str | None = None,
) -> CommandResult:
"""Run a command on a host (locally or via SSH).
Args:
host: Host configuration
command: Command to run
service: Service name (used as prefix in output)
stack: Stack name (stored in result)
stream: Whether to stream output (default True)
raw: Whether to use raw mode with TTY (default False)
prefix: Output prefix. None=use stack name, ""=no prefix.
"""
output_prefix = stack if prefix is None else prefix
if is_local(host):
return await _run_local_command(command, service, stream=stream, raw=raw)
return await _run_ssh_command(host, command, service, stream=stream, raw=raw)
return await _run_local_command(
command, stack, stream=stream, raw=raw, prefix=output_prefix
)
return await _run_ssh_command(
host, command, stack, stream=stream, raw=raw, prefix=output_prefix
)
async def run_compose(
config: Config,
service: str,
stack: str,
compose_cmd: str,
*,
stream: bool = True,
raw: bool = False,
prefix: str | None = None,
) -> CommandResult:
"""Run a docker compose command for a service."""
host = config.get_host(service)
compose_path = config.get_compose_path(service)
"""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)
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd)
command = f"docker compose -f {compose_path} {compose_cmd}"
return await run_command(host, command, service, stream=stream, raw=raw)
return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix)
async def run_compose_on_host(
config: Config,
service: str,
stack: str,
host_name: str,
compose_cmd: str,
*,
stream: bool = True,
raw: bool = False,
prefix: str | None = None,
) -> CommandResult:
"""Run a docker compose command for a service on a specific host.
"""Run a docker compose command for a stack on a specific 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(service)
compose_path = config.get_compose_path(stack)
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd)
command = f"docker compose -f {compose_path} {compose_cmd}"
return await run_command(host, command, service, stream=stream, raw=raw)
return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix)
async def run_on_services(
async def run_on_stacks(
config: Config,
services: list[str],
stacks: list[str],
compose_cmd: str,
*,
stream: bool = True,
raw: bool = False,
) -> list[CommandResult]:
"""Run a docker compose command on multiple services in parallel.
"""Run a docker compose command on multiple stacks in parallel.
For multi-host services, runs on all configured hosts.
Note: raw=True only makes sense for single-service operations.
For multi-host stacks, runs on all configured hosts.
Note: raw=True only makes sense for single-stack operations.
"""
return await run_sequential_on_services(config, services, [compose_cmd], stream=stream, raw=raw)
return await run_sequential_on_stacks(config, stacks, [compose_cmd], stream=stream, raw=raw)
async def _run_sequential_commands(
async def _run_sequential_stack_commands(
config: Config,
service: str,
stack: str,
commands: list[str],
*,
stream: bool = True,
raw: bool = False,
prefix: str | None = None,
) -> CommandResult:
"""Run multiple compose commands sequentially for a service."""
"""Run multiple compose commands sequentially for a stack."""
for cmd in commands:
result = await run_compose(config, service, cmd, stream=stream, raw=raw)
result = await run_compose(config, stack, cmd, stream=stream, raw=raw, prefix=prefix)
if not result.success:
return result
return CommandResult(service=service, exit_code=0, success=True)
return CommandResult(stack=stack, exit_code=0, success=True)
async def _run_sequential_commands_multi_host(
async def _run_sequential_stack_commands_multi_host(
config: Config,
service: str,
stack: str,
commands: list[str],
*,
stream: bool = True,
raw: bool = False,
prefix: str | None = None,
) -> list[CommandResult]:
"""Run multiple compose commands sequentially for a multi-host service.
"""Run multiple compose commands sequentially for a multi-host stack.
Commands are run sequentially, but each command runs on all hosts in parallel.
For multi-host stacks, prefix defaults to stack@host format.
"""
host_names = config.get_hosts(service)
compose_path = config.get_compose_path(service)
host_names = config.get_hosts(stack)
compose_path = config.get_compose_path(stack)
final_results: list[CommandResult] = []
for cmd in commands:
command = f"docker compose -f {compose_path} {cmd}"
tasks = []
for host_name in host_names:
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), cmd)
host = config.hosts[host_name]
label = f"{service}@{host_name}" if len(host_names) > 1 else service
tasks.append(run_command(host, command, label, stream=stream, raw=raw))
# For multi-host stacks, always use stack@host prefix to distinguish output
label = f"{stack}@{host_name}" if len(host_names) > 1 else stack
# Multi-host stacks always need prefixes to distinguish output from different hosts
# (ignore empty prefix from single-stack batches - we still need to distinguish hosts)
effective_prefix = label if len(host_names) > 1 else prefix
tasks.append(
run_command(host, command, label, stream=stream, raw=raw, prefix=effective_prefix)
)
results = await asyncio.gather(*tasks)
final_results = list(results)
@@ -387,33 +431,38 @@ async def _run_sequential_commands_multi_host(
return final_results
async def run_sequential_on_services(
async def run_sequential_on_stacks(
config: Config,
services: list[str],
stacks: list[str],
commands: list[str],
*,
stream: bool = True,
raw: bool = False,
) -> list[CommandResult]:
"""Run sequential commands on multiple services in parallel.
"""Run sequential commands on multiple stacks in parallel.
For multi-host services, runs on all configured hosts.
Note: raw=True only makes sense for single-service operations.
For multi-host stacks, runs on all configured hosts.
Note: raw=True only makes sense for single-stack operations.
"""
# Separate multi-host and single-host services for type-safe gathering
# Skip prefix for single-stack operations (command line already shows context)
prefix: str | None = "" if len(stacks) == 1 else None
# Separate multi-host and single-host stacks for type-safe gathering
multi_host_tasks = []
single_host_tasks = []
for service in services:
if config.is_multi_host(service):
for stack in stacks:
if config.is_multi_host(stack):
multi_host_tasks.append(
_run_sequential_commands_multi_host(
config, service, commands, stream=stream, raw=raw
_run_sequential_stack_commands_multi_host(
config, stack, commands, stream=stream, raw=raw, prefix=prefix
)
)
else:
single_host_tasks.append(
_run_sequential_commands(config, service, commands, stream=stream, raw=raw)
_run_sequential_stack_commands(
config, stack, commands, stream=stream, raw=raw, prefix=prefix
)
)
# Gather results separately to maintain type safety
@@ -431,23 +480,45 @@ async def run_sequential_on_services(
return flat_results
async def check_service_running(
async def check_stack_running(
config: Config,
service: str,
stack: str,
host_name: str,
) -> bool:
"""Check if a service has running containers on a specific host."""
"""Check if a stack has running containers on a specific host."""
host = config.hosts[host_name]
compose_path = config.get_compose_path(service)
compose_path = config.get_compose_path(stack)
# Use ps --status running to check for running containers
command = f"docker compose -f {compose_path} ps --status running -q"
result = await run_command(host, command, service, stream=False)
result = await run_command(host, command, stack, stream=False)
# If command succeeded and has output, containers are running
return result.success and bool(result.stdout.strip())
async def get_running_stacks_on_host(
config: Config,
host_name: str,
) -> set[str]:
"""Get all running compose stacks on a host in a single SSH call.
Uses docker ps with the compose.project label to identify running stacks.
Much more efficient than checking each stack individually.
"""
host = config.hosts[host_name]
# Get unique project names from running containers
command = "docker ps --format '{{.Label \"com.docker.compose.project\"}}' | sort -u"
result = await run_command(host, command, stack=host_name, stream=False, prefix="")
if not result.success:
return set()
# Filter out empty lines and return as set
return {line.strip() for line in result.stdout.splitlines() if line.strip()}
async def _batch_check_existence(
config: Config,
host_name: str,

View File

@@ -6,28 +6,29 @@ import json
import tomllib
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from .executor import run_compose
from .executor import run_command
from .paths import xdg_config_home
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterable
from collections.abc import Iterable
from pathlib import Path
from .config import Config
from .executor import CommandResult
# Separator used to split output sections
_SECTION_SEPARATOR = "---CF-SEP---"
DEFAULT_LOG_PATH = xdg_config_home() / "compose-farm" / "dockerfarm-log.toml"
_DIGEST_HEX_LENGTH = 64
@dataclass(frozen=True)
class SnapshotEntry:
"""Normalized image snapshot for a single service."""
"""Normalized image snapshot for a single stack."""
service: str
stack: str
host: str
compose_file: Path
image: str
@@ -37,7 +38,7 @@ class SnapshotEntry:
def as_dict(self, first_seen: str, last_seen: str) -> dict[str, str]:
"""Render snapshot as a TOML-friendly dict."""
return {
"service": self.service,
"stack": self.stack,
"host": self.host,
"compose_file": str(self.compose_file),
"image": self.image,
@@ -56,87 +57,97 @@ def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _parse_images_output(raw: str) -> list[dict[str, Any]]:
"""Parse `docker compose images --format json` output.
Handles both a JSON array and newline-separated JSON objects for robustness.
"""
raw = raw.strip()
if not raw:
return []
def _parse_image_digests(image_json: str) -> dict[str, str]:
"""Parse docker image inspect JSON to build image tag -> digest map."""
if not image_json:
return {}
try:
parsed = json.loads(raw)
image_data = json.loads(image_json)
except json.JSONDecodeError:
objects = []
for line in raw.splitlines():
if not line.strip():
continue
objects.append(json.loads(line))
return objects
return {}
if isinstance(parsed, list):
return parsed
if isinstance(parsed, dict):
return [parsed]
return []
image_digests: dict[str, str] = {}
for img in image_data:
tags = img.get("RepoTags") or []
digests = img.get("RepoDigests") or []
digest = digests[0].split("@")[-1] if digests else img.get("Id", "")
for tag in tags:
image_digests[tag] = digest
if img.get("Id"):
image_digests[img["Id"]] = digest
return image_digests
def _extract_image_fields(record: dict[str, Any]) -> tuple[str, str]:
"""Extract image name and digest with fallbacks."""
image = record.get("Image") or record.get("Repository") or record.get("Name") or ""
tag = record.get("Tag") or record.get("Version")
if tag and ":" not in image.rsplit("/", 1)[-1]:
image = f"{image}:{tag}"
digest = (
record.get("Digest")
or record.get("Image ID")
or record.get("ImageID")
or record.get("ID")
or ""
)
if digest and not digest.startswith("sha256:") and len(digest) == _DIGEST_HEX_LENGTH:
digest = f"sha256:{digest}"
return image, digest
async def collect_service_entries(
async def collect_stacks_entries_on_host(
config: Config,
service: str,
host_name: str,
stacks: set[str],
*,
now: datetime,
run_compose_fn: Callable[..., Awaitable[CommandResult]] = run_compose,
) -> list[SnapshotEntry]:
"""Run `docker compose images` for a service and normalize results."""
result = await run_compose_fn(config, service, "images --format json", stream=False)
"""Collect image entries for stacks on one host using 2 docker commands.
Uses `docker ps` to get running containers + their compose project labels,
then `docker image inspect` to get digests for all unique images.
Much faster than running N `docker compose images` commands.
"""
if not stacks:
return []
host = config.hosts[host_name]
# Single SSH call with 2 docker commands:
# 1. Get project|image pairs from running containers
# 2. Get image info (including digests) for all unique images
command = (
f"docker ps --format '{{{{.Label \"com.docker.compose.project\"}}}}|{{{{.Image}}}}' && "
f"echo '{_SECTION_SEPARATOR}' && "
"docker image inspect $(docker ps --format '{{.Image}}' | sort -u) 2>/dev/null || true"
)
result = await run_command(host, command, host_name, stream=False, prefix="")
if not result.success:
msg = result.stderr or f"compose images exited with {result.exit_code}"
error = f"[{service}] Unable to read images: {msg}"
raise RuntimeError(error)
return []
records = _parse_images_output(result.stdout)
# Use first host for snapshots (multi-host services use same images on all hosts)
host_name = config.get_hosts(service)[0]
compose_path = config.get_compose_path(service)
# Split output into two sections
parts = result.stdout.split(_SECTION_SEPARATOR)
if len(parts) != 2: # noqa: PLR2004
return []
entries: list[SnapshotEntry] = []
for record in records:
image, digest = _extract_image_fields(record)
if not digest:
container_lines, image_json = parts[0].strip(), parts[1].strip()
# Parse project|image pairs, filtering to only stacks we care about
stack_images: dict[str, set[str]] = {}
for line in container_lines.splitlines():
if "|" not in line:
continue
entries.append(
SnapshotEntry(
service=service,
host=host_name,
compose_file=compose_path,
image=image,
digest=digest,
captured_at=now,
)
)
project, image = line.split("|", 1)
if project in stacks:
stack_images.setdefault(project, set()).add(image)
if not stack_images:
return []
# Parse image inspect JSON to build image -> digest map
image_digests = _parse_image_digests(image_json)
# Build entries
entries: list[SnapshotEntry] = []
for stack, images in stack_images.items():
for image in images:
digest = image_digests.get(image, "")
if digest:
entries.append(
SnapshotEntry(
stack=stack,
host=host_name,
compose_file=config.get_compose_path(stack),
image=image,
digest=digest,
captured_at=now,
)
)
return entries
@@ -145,7 +156,14 @@ def load_existing_entries(log_path: Path) -> list[dict[str, str]]:
if not log_path.exists():
return []
data = tomllib.loads(log_path.read_text())
return list(data.get("entries", []))
entries = list(data.get("entries", []))
normalized: list[dict[str, str]] = []
for entry in entries:
normalized_entry = dict(entry)
if "stack" not in normalized_entry and "service" in normalized_entry:
normalized_entry["stack"] = normalized_entry.pop("service")
normalized.append(normalized_entry)
return normalized
def merge_entries(
@@ -156,11 +174,11 @@ def merge_entries(
) -> list[dict[str, str]]:
"""Merge new snapshot entries with existing ones, preserving first_seen timestamps."""
merged: dict[tuple[str, str, str], dict[str, str]] = {
(e["service"], e["host"], e["digest"]): dict(e) for e in existing
(e["stack"], e["host"], e["digest"]): dict(e) for e in existing
}
for entry in new_entries:
key = (entry.service, entry.host, entry.digest)
key = (entry.stack, entry.host, entry.digest)
first_seen = merged.get(key, {}).get("first_seen", now_iso)
merged[key] = entry.as_dict(first_seen, now_iso)
@@ -175,10 +193,10 @@ def write_toml(log_path: Path, *, meta: dict[str, str], entries: list[dict[str,
if entries:
lines.append("")
for entry in sorted(entries, key=lambda e: (e["service"], e["host"], e["digest"])):
for entry in sorted(entries, key=lambda e: (e["stack"], e["host"], e["digest"])):
lines.append("[[entries]]")
for field in [
"service",
"stack",
"host",
"compose_file",
"image",

View File

@@ -15,17 +15,17 @@ from .executor import (
CommandResult,
check_networks_exist,
check_paths_exist,
check_service_running,
check_stack_running,
run_command,
run_compose,
run_compose_on_host,
)
from .state import (
get_orphaned_services,
get_service_host,
remove_service,
set_multi_host_service,
set_service_host,
get_orphaned_stacks,
get_stack_host,
remove_stack,
set_multi_host_stack,
set_stack_host,
)
if TYPE_CHECKING:
@@ -37,7 +37,7 @@ class OperationInterruptedError(Exception):
class PreflightResult(NamedTuple):
"""Result of pre-flight checks for a service on a host."""
"""Result of pre-flight checks for a stack on a host."""
missing_paths: list[str]
missing_networks: list[str]
@@ -51,7 +51,7 @@ class PreflightResult(NamedTuple):
async def _run_compose_step(
cfg: Config,
service: str,
stack: str,
command: str,
*,
raw: bool,
@@ -59,9 +59,9 @@ async def _run_compose_step(
) -> CommandResult:
"""Run a compose command, handle raw output newline, and check for interrupts."""
if host:
result = await run_compose_on_host(cfg, service, host, command, raw=raw)
result = await run_compose_on_host(cfg, stack, host, command, raw=raw)
else:
result = await run_compose(cfg, service, command, raw=raw)
result = await run_compose(cfg, stack, command, raw=raw)
if raw:
print() # Ensure newline after raw output
if result.interrupted:
@@ -69,63 +69,69 @@ async def _run_compose_step(
return result
def get_service_paths(cfg: Config, service: str) -> list[str]:
"""Get all required paths for a service (compose_dir + volumes)."""
def get_stack_paths(cfg: Config, stack: str) -> list[str]:
"""Get all required paths for a stack (compose_dir + volumes)."""
paths = [str(cfg.compose_dir)]
paths.extend(parse_host_volumes(cfg, service))
paths.extend(parse_host_volumes(cfg, stack))
return paths
async def discover_service_host(cfg: Config, service: str) -> tuple[str, str | list[str] | None]:
"""Discover where a service is running.
class StackDiscoveryResult(NamedTuple):
"""Result of discovering where a stack is running across all hosts."""
For multi-host services, checks all assigned hosts in parallel.
For single-host, checks assigned host first, then others.
stack: str
configured_hosts: list[str] # From config (where it SHOULD run)
running_hosts: list[str] # From reality (where it IS running)
Returns (service_name, host_or_hosts_or_none).
"""
assigned_hosts = cfg.get_hosts(service)
@property
def is_multi_host(self) -> bool:
"""Check if this is a multi-host stack."""
return len(self.configured_hosts) > 1
if cfg.is_multi_host(service):
# Check all assigned hosts in parallel
checks = await asyncio.gather(
*[check_service_running(cfg, service, h) for h in assigned_hosts]
)
running = [h for h, is_running in zip(assigned_hosts, checks, strict=True) if is_running]
return service, running if running else None
@property
def stray_hosts(self) -> list[str]:
"""Hosts where stack is running but shouldn't be."""
return [h for h in self.running_hosts if h not in self.configured_hosts]
# Single-host: check assigned host first, then others
if await check_service_running(cfg, service, assigned_hosts[0]):
return service, assigned_hosts[0]
for host in cfg.hosts:
if host != assigned_hosts[0] and await check_service_running(cfg, service, host):
return service, host
return service, None
@property
def missing_hosts(self) -> list[str]:
"""Hosts where stack should be running but isn't."""
return [h for h in self.configured_hosts if h not in self.running_hosts]
@property
def is_stray(self) -> bool:
"""Stack is running on unauthorized host(s)."""
return len(self.stray_hosts) > 0
@property
def is_duplicate(self) -> bool:
"""Single-host stack running on multiple hosts."""
return not self.is_multi_host and len(self.running_hosts) > 1
async def check_service_requirements(
async def check_stack_requirements(
cfg: Config,
service: str,
stack: str,
host_name: str,
) -> PreflightResult:
"""Check if a service can run on a specific host.
"""Check if a stack can run on a specific host.
Verifies that all required paths (volumes), networks, and devices exist.
"""
# Check mount paths
paths = get_service_paths(cfg, service)
paths = get_stack_paths(cfg, stack)
path_exists = await check_paths_exist(cfg, host_name, paths)
missing_paths = [p for p, found in path_exists.items() if not found]
# Check external networks
networks = parse_external_networks(cfg, service)
networks = parse_external_networks(cfg, stack)
missing_networks: list[str] = []
if networks:
net_exists = await check_networks_exist(cfg, host_name, networks)
missing_networks = [n for n, found in net_exists.items() if not found]
# Check devices
devices = parse_devices(cfg, service)
devices = parse_devices(cfg, stack)
missing_devices: list[str] = []
if devices:
dev_exists = await check_paths_exist(cfg, host_name, devices)
@@ -136,7 +142,7 @@ async def check_service_requirements(
async def _cleanup_and_rollback(
cfg: Config,
service: str,
stack: str,
target_host: str,
current_host: str,
prefix: str,
@@ -146,29 +152,29 @@ async def _cleanup_and_rollback(
) -> None:
"""Clean up failed start and attempt rollback to old host if it was running."""
print_warning(f"{prefix} Cleaning up failed start on [magenta]{target_host}[/]")
await run_compose(cfg, service, "down", raw=raw)
await run_compose(cfg, stack, "down", raw=raw)
if not was_running:
err_console.print(
f"{prefix} [dim]Service was not running on [magenta]{current_host}[/], skipping rollback[/]"
f"{prefix} [dim]Stack was not running on [magenta]{current_host}[/], skipping rollback[/]"
)
return
print_warning(f"{prefix} Rolling back to [magenta]{current_host}[/]...")
rollback_result = await run_compose_on_host(cfg, service, current_host, "up -d", raw=raw)
rollback_result = await run_compose_on_host(cfg, stack, current_host, "up -d", raw=raw)
if rollback_result.success:
print_success(f"{prefix} Rollback succeeded on [magenta]{current_host}[/]")
else:
print_error(f"{prefix} Rollback failed - service is down")
print_error(f"{prefix} Rollback failed - stack is down")
def _report_preflight_failures(
service: str,
stack: str,
target_host: str,
preflight: PreflightResult,
) -> None:
"""Report pre-flight check failures."""
print_error(f"[cyan]\\[{service}][/] Cannot start on [magenta]{target_host}[/]:")
print_error(f"[cyan]\\[{stack}][/] Cannot start on [magenta]{target_host}[/]:")
for path in preflight.missing_paths:
print_error(f" missing path: {path}")
for net in preflight.missing_networks:
@@ -179,27 +185,25 @@ def _report_preflight_failures(
print_error(f" missing device: {dev}")
async def _up_multi_host_service(
async def _up_multi_host_stack(
cfg: Config,
service: str,
stack: str,
prefix: str,
*,
raw: bool = False,
) -> list[CommandResult]:
"""Start a multi-host service on all configured hosts."""
host_names = cfg.get_hosts(service)
"""Start a multi-host stack on all configured hosts."""
host_names = cfg.get_hosts(stack)
results: list[CommandResult] = []
compose_path = cfg.get_compose_path(service)
compose_path = cfg.get_compose_path(stack)
command = f"docker compose -f {compose_path} up -d"
# Pre-flight checks on all hosts
for host_name in host_names:
preflight = await check_service_requirements(cfg, service, host_name)
preflight = await check_stack_requirements(cfg, stack, host_name)
if not preflight.ok:
_report_preflight_failures(service, host_name, preflight)
results.append(
CommandResult(service=f"{service}@{host_name}", exit_code=1, success=False)
)
_report_preflight_failures(stack, host_name, preflight)
results.append(CommandResult(stack=f"{stack}@{host_name}", exit_code=1, success=False))
return results
# Start on all hosts
@@ -209,7 +213,7 @@ async def _up_multi_host_service(
succeeded_hosts: list[str] = []
for host_name in host_names:
host = cfg.hosts[host_name]
label = f"{service}@{host_name}"
label = f"{stack}@{host_name}"
result = await run_command(host, command, label, stream=not raw, raw=raw)
if raw:
print() # Ensure newline after raw output
@@ -219,72 +223,70 @@ async def _up_multi_host_service(
# Update state with hosts that succeeded (partial success is tracked)
if succeeded_hosts:
set_multi_host_service(cfg, service, succeeded_hosts)
set_multi_host_stack(cfg, stack, succeeded_hosts)
return results
async def _migrate_service(
async def _migrate_stack(
cfg: Config,
service: str,
stack: str,
current_host: str,
target_host: str,
prefix: str,
*,
raw: bool = False,
) -> CommandResult | None:
"""Migrate a service from current_host to target_host.
"""Migrate a stack from current_host to target_host.
Pre-pulls/builds images on target, then stops service on current host.
Pre-pulls/builds images on target, then stops stack on current host.
Returns failure result if migration prep fails, None on success.
"""
console.print(
f"{prefix} Migrating from [magenta]{current_host}[/] → [magenta]{target_host}[/]..."
)
# Prepare images on target host before stopping old service to minimize downtime.
# Pull handles image-based services; build handles Dockerfile-based services.
# Prepare images on target host before stopping old stack to minimize downtime.
# Pull handles image-based compose services; build handles Dockerfile-based ones.
# --ignore-buildable makes pull skip images that have build: defined.
for cmd, label in [("pull --ignore-buildable", "Pull"), ("build", "Build")]:
result = await _run_compose_step(cfg, service, cmd, raw=raw)
result = await _run_compose_step(cfg, stack, cmd, raw=raw)
if not result.success:
print_error(
f"{prefix} {label} failed on [magenta]{target_host}[/], "
"leaving service on current host"
"leaving stack on current host"
)
return result
# Stop on current host
down_result = await _run_compose_step(cfg, service, "down", raw=raw, host=current_host)
down_result = await _run_compose_step(cfg, stack, "down", raw=raw, host=current_host)
return down_result if not down_result.success else None
async def _up_single_service(
async def _up_single_stack(
cfg: Config,
service: str,
stack: str,
prefix: str,
*,
raw: bool,
) -> CommandResult:
"""Start a single-host service with migration support."""
target_host = cfg.get_hosts(service)[0]
current_host = get_service_host(cfg, service)
"""Start a single-host stack with migration support."""
target_host = cfg.get_hosts(stack)[0]
current_host = get_stack_host(cfg, stack)
# Pre-flight check: verify paths, networks, and devices exist on target
preflight = await check_service_requirements(cfg, service, target_host)
preflight = await check_stack_requirements(cfg, stack, target_host)
if not preflight.ok:
_report_preflight_failures(service, target_host, preflight)
return CommandResult(service=service, exit_code=1, success=False)
_report_preflight_failures(stack, target_host, preflight)
return CommandResult(stack=stack, exit_code=1, success=False)
# If service is deployed elsewhere, migrate it
# If stack is deployed elsewhere, migrate it
did_migration = False
was_running = False
if current_host and current_host != target_host:
if current_host in cfg.hosts:
was_running = await check_service_running(cfg, service, current_host)
failure = await _migrate_service(
cfg, service, current_host, target_host, prefix, raw=raw
)
was_running = await check_stack_running(cfg, stack, current_host)
failure = await _migrate_stack(cfg, stack, current_host, target_host, prefix, raw=raw)
if failure:
return failure
did_migration = True
@@ -295,15 +297,15 @@ async def _up_single_service(
# Start on target host
console.print(f"{prefix} Starting on [magenta]{target_host}[/]...")
up_result = await _run_compose_step(cfg, service, "up -d", raw=raw)
up_result = await _run_compose_step(cfg, stack, "up -d", raw=raw)
# Update state on success, or rollback on failure
if up_result.success:
set_service_host(cfg, service, target_host)
set_stack_host(cfg, stack, target_host)
elif did_migration and current_host:
await _cleanup_and_rollback(
cfg,
service,
stack,
target_host,
current_host,
prefix,
@@ -314,24 +316,24 @@ async def _up_single_service(
return up_result
async def up_services(
async def up_stacks(
cfg: Config,
services: list[str],
stacks: list[str],
*,
raw: bool = False,
) -> list[CommandResult]:
"""Start services with automatic migration if host changed."""
"""Start stacks with automatic migration if host changed."""
results: list[CommandResult] = []
total = len(services)
total = len(stacks)
try:
for idx, service in enumerate(services, 1):
prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{service}][/]"
for idx, stack in enumerate(stacks, 1):
prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{stack}][/]"
if cfg.is_multi_host(service):
results.extend(await _up_multi_host_service(cfg, service, prefix, raw=raw))
if cfg.is_multi_host(stack):
results.extend(await _up_multi_host_stack(cfg, stack, prefix, raw=raw))
else:
results.append(await _up_single_service(cfg, service, prefix, raw=raw))
results.append(await _up_single_stack(cfg, stack, prefix, raw=raw))
except OperationInterruptedError:
raise KeyboardInterrupt from None
@@ -340,22 +342,22 @@ async def up_services(
async def check_host_compatibility(
cfg: Config,
service: str,
stack: str,
) -> dict[str, tuple[int, int, list[str]]]:
"""Check which hosts can run a service based on paths, networks, and devices.
"""Check which hosts can run a stack based on paths, networks, and devices.
Returns dict of host_name -> (found_count, total_count, missing_items).
"""
# Get total requirements count
paths = get_service_paths(cfg, service)
networks = parse_external_networks(cfg, service)
devices = parse_devices(cfg, service)
paths = get_stack_paths(cfg, stack)
networks = parse_external_networks(cfg, stack)
devices = parse_devices(cfg, stack)
total = len(paths) + len(networks) + len(devices)
results: dict[str, tuple[int, int, list[str]]] = {}
for host_name in cfg.hosts:
preflight = await check_service_requirements(cfg, service, host_name)
preflight = await check_stack_requirements(cfg, stack, host_name)
all_missing = (
preflight.missing_paths + preflight.missing_networks + preflight.missing_devices
)
@@ -365,70 +367,167 @@ async def check_host_compatibility(
return results
async def stop_orphaned_services(cfg: Config) -> list[CommandResult]:
"""Stop orphaned services (in state but not in config).
async def _stop_stacks_on_hosts(
cfg: Config,
stacks_to_hosts: dict[str, list[str]],
label: str = "",
) -> list[CommandResult]:
"""Stop stacks on specific hosts.
Runs docker compose down on each service on its tracked host(s).
Only removes from state on successful stop.
Shared helper for stop_orphaned_stacks and stop_stray_stacks.
Args:
cfg: Config object.
stacks_to_hosts: Dict mapping stack name to list of hosts to stop on.
label: Optional label for success message (e.g., "stray", "orphaned").
Returns:
List of CommandResults for each stack@host.
Returns list of CommandResults for each service@host.
"""
orphaned = get_orphaned_services(cfg)
if not orphaned:
if not stacks_to_hosts:
return []
results: list[CommandResult] = []
tasks: list[tuple[str, str, asyncio.Task[CommandResult]]] = []
suffix = f" ({label})" if label else ""
# Build list of (service, host, task) for all orphaned services
for service, hosts in orphaned.items():
host_list = hosts if isinstance(hosts, list) else [hosts]
for host in host_list:
# Skip hosts no longer in config
for stack, hosts in stacks_to_hosts.items():
for host in hosts:
if host not in cfg.hosts:
print_warning(f"{service}@{host}: host no longer in config, skipping")
print_warning(f"{stack}@{host}: host no longer in config, skipping")
results.append(
CommandResult(
service=f"{service}@{host}",
stack=f"{stack}@{host}",
exit_code=1,
success=False,
stderr="host no longer in config",
)
)
continue
coro = run_compose_on_host(cfg, service, host, "down")
tasks.append((service, host, asyncio.create_task(coro)))
coro = run_compose_on_host(cfg, stack, host, "down")
tasks.append((stack, host, asyncio.create_task(coro)))
# Run all down commands in parallel
if tasks:
for service, host, task in tasks:
try:
result = await task
results.append(result)
if result.success:
print_success(f"{service}@{host}: stopped")
else:
print_error(f"{service}@{host}: {result.stderr or 'failed'}")
except Exception as e:
print_error(f"{service}@{host}: {e}")
results.append(
CommandResult(
service=f"{service}@{host}",
exit_code=1,
success=False,
stderr=str(e),
)
for stack, host, task in tasks:
try:
result = await task
results.append(result)
if result.success:
print_success(f"{stack}@{host}: stopped{suffix}")
else:
print_error(f"{stack}@{host}: {result.stderr or 'failed'}")
except Exception as e:
print_error(f"{stack}@{host}: {e}")
results.append(
CommandResult(
stack=f"{stack}@{host}",
exit_code=1,
success=False,
stderr=str(e),
)
# Remove from state only for services where ALL hosts succeeded
for service, hosts in orphaned.items():
host_list = hosts if isinstance(hosts, list) else [hosts]
all_succeeded = all(
r.success
for r in results
if r.service.startswith(f"{service}@") or r.service == service
)
if all_succeeded:
remove_service(cfg, service)
)
return results
async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
"""Stop orphaned stacks (in state but not in config).
Runs docker compose down on each stack on its tracked host(s).
Only removes from state on successful stop.
Returns list of CommandResults for each stack@host.
"""
orphaned = get_orphaned_stacks(cfg)
if not orphaned:
return []
normalized: dict[str, list[str]] = {
stack: (hosts if isinstance(hosts, list) else [hosts]) for stack, hosts in orphaned.items()
}
results = await _stop_stacks_on_hosts(cfg, normalized)
# Remove from state only for stacks where ALL hosts succeeded
for stack in normalized:
all_succeeded = all(
r.success for r in results if r.stack.startswith(f"{stack}@") or r.stack == stack
)
if all_succeeded:
remove_stack(cfg, stack)
return results
async def stop_stray_stacks(
cfg: Config,
strays: dict[str, list[str]],
) -> list[CommandResult]:
"""Stop stacks running on unauthorized hosts.
Args:
cfg: Config object.
strays: Dict mapping stack name to list of stray hosts.
Returns:
List of CommandResults for each stack@host stopped.
"""
return await _stop_stacks_on_hosts(cfg, strays, label="stray")
def build_discovery_results(
cfg: Config,
running_on_host: dict[str, set[str]],
stacks: list[str] | None = None,
) -> tuple[dict[str, str | list[str]], dict[str, list[str]], dict[str, list[str]]]:
"""Build discovery results from per-host running stacks.
Takes the raw data of which stacks are running on which hosts and
categorizes them into discovered (running correctly), strays (wrong host),
and duplicates (single-host stack on multiple hosts).
Args:
cfg: Config object.
running_on_host: Dict mapping host -> set of running stack names.
stacks: Optional list of stacks to check. Defaults to all configured stacks.
Returns:
Tuple of (discovered, strays, duplicates):
- discovered: stack -> host(s) where running correctly
- strays: stack -> list of unauthorized hosts
- duplicates: stack -> list of all hosts (for single-host stacks on multiple)
"""
stack_list = stacks if stacks is not None else list(cfg.stacks)
all_hosts = list(running_on_host.keys())
# Build StackDiscoveryResult for each stack
results: list[StackDiscoveryResult] = [
StackDiscoveryResult(
stack=stack,
configured_hosts=cfg.get_hosts(stack),
running_hosts=[h for h in all_hosts if stack in running_on_host[h]],
)
for stack in stack_list
]
discovered: dict[str, str | list[str]] = {}
strays: dict[str, list[str]] = {}
duplicates: dict[str, list[str]] = {}
for result in results:
correct_hosts = [h for h in result.running_hosts if h in result.configured_hosts]
if correct_hosts:
if result.is_multi_host:
discovered[result.stack] = correct_hosts
else:
discovered[result.stack] = correct_hosts[0]
if result.is_stray:
strays[result.stack] = result.stray_hosts
if result.is_duplicate:
duplicates[result.stack] = result.running_hosts
return discovered, strays, duplicates

View File

@@ -11,9 +11,19 @@ def xdg_config_home() -> Path:
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
def config_dir() -> Path:
"""Get the compose-farm config directory."""
return xdg_config_home() / "compose-farm"
def default_config_path() -> Path:
"""Get the default user config path."""
return xdg_config_home() / "compose-farm" / "compose-farm.yaml"
return config_dir() / "compose-farm.yaml"
def backup_dir() -> Path:
"""Get the backup directory for file edits."""
return config_dir() / "backups"
def config_search_paths() -> list[Path]:

View File

@@ -1,4 +1,4 @@
"""State tracking for deployed services."""
"""State tracking for deployed stacks."""
from __future__ import annotations
@@ -13,44 +13,44 @@ if TYPE_CHECKING:
from .config import Config
def group_services_by_host(
services: dict[str, str | list[str]],
def group_stacks_by_host(
stacks: dict[str, str | list[str]],
hosts: Mapping[str, object],
all_hosts: list[str] | None = None,
) -> dict[str, list[str]]:
"""Group services by their assigned host(s).
"""Group stacks by their assigned host(s).
For multi-host services (list or "all"), the service appears in multiple host lists.
For multi-host stacks (list or "all"), the stack appears in multiple host lists.
"""
by_host: dict[str, list[str]] = {h: [] for h in hosts}
for service, host_value in services.items():
for stack, host_value in stacks.items():
if isinstance(host_value, list):
for host_name in host_value:
if host_name in by_host:
by_host[host_name].append(service)
by_host[host_name].append(stack)
elif host_value == "all" and all_hosts:
for host_name in all_hosts:
if host_name in by_host:
by_host[host_name].append(service)
by_host[host_name].append(stack)
elif host_value in by_host:
by_host[host_value].append(service)
by_host[host_value].append(stack)
return by_host
def group_running_services_by_host(
def group_running_stacks_by_host(
state: dict[str, str | list[str]],
hosts: Mapping[str, object],
) -> dict[str, list[str]]:
"""Group running services by host, filtering out hosts with no services."""
by_host = group_services_by_host(state, hosts)
"""Group running stacks by host, filtering out hosts with no stacks."""
by_host = group_stacks_by_host(state, hosts)
return {h: svcs for h, svcs in by_host.items() if svcs}
def load_state(config: Config) -> dict[str, str | list[str]]:
"""Load the current deployment state.
Returns a dict mapping service names to host name(s).
Multi-host services store a list of hosts.
Returns a dict mapping stack names to host name(s).
Multi-host stacks store a list of hosts.
"""
state_path = config.get_state_path()
if not state_path.exists():
@@ -83,13 +83,13 @@ def _modify_state(config: Config) -> Generator[dict[str, str | list[str]], None,
save_state(config, state)
def get_service_host(config: Config, service: str) -> str | None:
"""Get the host where a service is currently deployed.
def get_stack_host(config: Config, stack: str) -> str | None:
"""Get the host where a stack is currently deployed.
For multi-host services, returns the first host or None.
For multi-host stacks, returns the first host or None.
"""
state = load_state(config)
value = state.get(service)
value = state.get(stack)
if value is None:
return None
if isinstance(value, list):
@@ -97,103 +97,59 @@ def get_service_host(config: Config, service: str) -> str | None:
return value
def set_service_host(config: Config, service: str, host: str) -> None:
"""Record that a service is deployed on a host."""
def set_stack_host(config: Config, stack: str, host: str) -> None:
"""Record that a stack is deployed on a host."""
with _modify_state(config) as state:
state[service] = host
state[stack] = host
def set_multi_host_service(config: Config, service: str, hosts: list[str]) -> None:
"""Record that a multi-host service is deployed on multiple hosts."""
def set_multi_host_stack(config: Config, stack: str, hosts: list[str]) -> None:
"""Record that a multi-host stack is deployed on multiple hosts."""
with _modify_state(config) as state:
state[service] = hosts
state[stack] = hosts
def remove_service(config: Config, service: str) -> None:
"""Remove a service from the state (after down)."""
def remove_stack(config: Config, stack: str) -> None:
"""Remove a stack from the state (after down)."""
with _modify_state(config) as state:
state.pop(service, None)
state.pop(stack, None)
def add_service_to_host(config: Config, service: str, host: str) -> None:
"""Add a specific host to a service's state.
def get_stacks_needing_migration(config: Config) -> list[str]:
"""Get stacks where current host differs from configured host.
For multi-host services, adds the host to the list if not present.
For single-host services, sets the host.
"""
with _modify_state(config) as state:
current = state.get(service)
if config.is_multi_host(service):
# Multi-host: add to list if not present
if isinstance(current, list):
if host not in current:
state[service] = [*current, host]
else:
state[service] = [host]
else:
# Single-host: just set it
state[service] = host
def remove_service_from_host(config: Config, service: str, host: str) -> None:
"""Remove a specific host from a service's state.
For multi-host services, removes just that host from the list.
For single-host services, removes the service entirely if host matches.
"""
with _modify_state(config) as state:
current = state.get(service)
if current is None:
return
if isinstance(current, list):
# Multi-host: remove this host from list
remaining = [h for h in current if h != host]
if remaining:
state[service] = remaining
else:
state.pop(service, None)
elif current == host:
# Single-host: remove if matches
state.pop(service, None)
def get_services_needing_migration(config: Config) -> list[str]:
"""Get services where current host differs from configured host.
Multi-host services are never considered for migration.
Multi-host stacks are never considered for migration.
"""
needs_migration = []
for service in config.services:
# Skip multi-host services
if config.is_multi_host(service):
for stack in config.stacks:
# Skip multi-host stacks
if config.is_multi_host(stack):
continue
configured_host = config.get_hosts(service)[0]
current_host = get_service_host(config, service)
configured_host = config.get_hosts(stack)[0]
current_host = get_stack_host(config, stack)
if current_host and current_host != configured_host:
needs_migration.append(service)
needs_migration.append(stack)
return needs_migration
def get_orphaned_services(config: Config) -> dict[str, str | list[str]]:
"""Get services that are in state but not in config.
def get_orphaned_stacks(config: Config) -> dict[str, str | list[str]]:
"""Get stacks that are in state but not in config.
These are services that were previously deployed but have been
These are stacks that were previously deployed but have been
removed from the config file (e.g., commented out).
Returns a dict mapping service name to host(s) where it's deployed.
Returns a dict mapping stack name to host(s) where it's deployed.
"""
state = load_state(config)
return {service: hosts for service, hosts in state.items() if service not in config.services}
return {stack: hosts for stack, hosts in state.items() if stack not in config.stacks}
def get_services_not_in_state(config: Config) -> list[str]:
"""Get services that are in config but not in state.
def get_stacks_not_in_state(config: Config) -> list[str]:
"""Get stacks that are in config but not in state.
These are services that should be running but aren't tracked
These are stacks that should be running but aren't tracked
(e.g., newly added to config, or previously stopped as orphans).
"""
state = load_state(config)
return [service for service in config.services if service not in state]
return [stack for stack in config.stacks if stack not in state]

View File

@@ -8,6 +8,7 @@ use host-published ports for cross-host reachability.
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
@@ -311,7 +312,7 @@ def _process_service_labels(
def generate_traefik_config(
config: Config,
services: list[str],
stacks: list[str],
*,
check_all: bool = False,
) -> tuple[dict[str, Any], list[str]]:
@@ -319,8 +320,8 @@ def generate_traefik_config(
Args:
config: The compose-farm config.
services: List of service names to process.
check_all: If True, check all services for warnings (ignore host filtering).
stacks: List of stack names to process.
check_all: If True, check all stacks for warnings (ignore host filtering).
Used by the check command to validate all traefik labels.
Returns (config_dict, warnings).
@@ -332,14 +333,14 @@ def generate_traefik_config(
# Determine Traefik's host from service assignment
traefik_host = None
if config.traefik_service and not check_all:
traefik_host = config.services.get(config.traefik_service)
if config.traefik_stack and not check_all:
traefik_host = config.stacks.get(config.traefik_stack)
for stack in services:
for stack in stacks:
raw_services, env, host_address = load_compose_services(config, stack)
stack_host = config.services.get(stack)
stack_host = config.stacks.get(stack)
# Skip services on Traefik's host - docker provider handles them directly
# Skip stacks on Traefik's host - docker provider handles them directly
# (unless check_all is True, for validation purposes)
if not check_all:
if host_address.lower() in LOCAL_ADDRESSES:
@@ -370,7 +371,7 @@ _TRAEFIK_CONFIG_HEADER = """\
# Auto-generated by compose-farm
# https://github.com/basnijholt/compose-farm
#
# This file routes traffic to services running on hosts other than Traefik's host.
# This file routes traffic to stacks running on hosts other than Traefik's host.
# Services on Traefik's host use the Docker provider directly.
#
# Regenerate with: compose-farm traefik-file --all -o <this-file>
@@ -383,3 +384,53 @@ def render_traefik_config(dynamic: dict[str, Any]) -> str:
"""Render Traefik dynamic config as YAML with a header comment."""
body = yaml.safe_dump(dynamic, sort_keys=False)
return _TRAEFIK_CONFIG_HEADER + body
_HOST_RULE_PATTERN = re.compile(r"Host\(`([^`]+)`\)")
def extract_website_urls(config: Config, stack: str) -> list[str]:
"""Extract website URLs from Traefik labels in a stack's compose file.
Reuses generate_traefik_config to parse labels, then extracts Host() rules
from router configurations.
Returns a list of unique URLs, preferring HTTPS over HTTP.
"""
try:
dynamic, _ = generate_traefik_config(config, [stack], check_all=True)
except FileNotFoundError:
return []
routers = dynamic.get("http", {}).get("routers", {})
if not routers:
return []
# Track URLs with their scheme preference (https > http)
urls: dict[str, str] = {} # host -> scheme
for router_info in routers.values():
if not isinstance(router_info, dict):
continue
rule = router_info.get("rule", "")
entrypoints = router_info.get("entrypoints", [])
# entrypoints can be a list or string
if isinstance(entrypoints, list):
entrypoints_str = ",".join(entrypoints)
else:
entrypoints_str = str(entrypoints)
# Determine scheme from entrypoint
scheme = "https" if "websecure" in entrypoints_str else "http"
# Extract host(s) from rule
for match in _HOST_RULE_PATTERN.finditer(str(rule)):
host = match.group(1)
# Prefer https over http
if host not in urls or scheme == "https":
urls[host] = scheme
# Build URL list, sorted for consistency
return sorted(f"{scheme}://{host}" for host, scheme in urls.items())

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import logging
import sys
from contextlib import asynccontextmanager, suppress
from typing import TYPE_CHECKING
@@ -10,11 +11,22 @@ from typing import TYPE_CHECKING
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError
from rich.logging import RichHandler
from compose_farm.web.deps import STATIC_DIR, get_config
from compose_farm.web.routes import actions, api, pages
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
# Configure logging with Rich handler for compose_farm.web modules
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
)
# Set our web modules to INFO level (uvicorn handles its own logging)
logging.getLogger("compose_farm.web").setLevel(logging.INFO)
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
@@ -48,7 +60,7 @@ def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(
title="Compose Farm",
description="Web UI for managing Docker Compose services across multiple hosts",
description="Web UI for managing Docker Compose stacks across multiple hosts",
lifespan=lifespan,
)

108
src/compose_farm/web/cdn.py Normal file
View File

@@ -0,0 +1,108 @@
"""CDN asset definitions and caching for tests and demo recordings.
This module provides a single source of truth for CDN asset URLs used in
browser tests and demo recordings. Assets are intercepted and served from
a local cache to eliminate network variability.
Note: The canonical list of CDN assets for production is in base.html
(with data-vendor attributes). This module includes those plus dynamically
loaded assets (like Monaco editor modules loaded by app.js).
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
# CDN assets to cache locally for tests/demos
# Format: URL -> (local_filename, content_type)
#
# If tests fail with "Uncached CDN request", add the URL here.
CDN_ASSETS: dict[str, tuple[str, str]] = {
# From base.html (data-vendor attributes)
"https://cdn.jsdelivr.net/npm/daisyui@5/themes.css": ("daisyui-themes.css", "text/css"),
"https://cdn.jsdelivr.net/npm/daisyui@5": ("daisyui.css", "text/css"),
"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": (
"tailwind.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css": ("xterm.css", "text/css"),
"https://unpkg.com/htmx.org@2.0.4": ("htmx.js", "application/javascript"),
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js": (
"xterm.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js": (
"xterm-fit.js",
"application/javascript",
),
# Monaco editor - dynamically loaded by app.js
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js": (
"monaco-loader.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js": (
"monaco-editor-main.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css": (
"monaco-editor-main.css",
"text/css",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js": (
"monaco-workerMain.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js": (
"monaco-yaml.js",
"application/javascript",
),
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf": (
"monaco-codicon.ttf",
"font/ttf",
),
}
def download_url(url: str) -> bytes | None:
"""Download URL content using curl."""
try:
result = subprocess.run(
["curl", "-fsSL", "--max-time", "30", url], # noqa: S607
capture_output=True,
check=True,
)
return bytes(result.stdout)
except Exception:
return None
def ensure_vendor_cache(cache_dir: Path) -> Path:
"""Download CDN assets to cache directory if not already present.
Args:
cache_dir: Directory to store cached assets.
Returns:
The cache directory path.
Raises:
RuntimeError: If any asset fails to download.
"""
cache_dir.mkdir(parents=True, exist_ok=True)
for url, (filename, _content_type) in CDN_ASSETS.items():
filepath = cache_dir / filename
if filepath.exists():
continue
content = download_url(url)
if not content:
msg = f"Failed to download {url} - check network/curl"
raise RuntimeError(msg)
filepath.write_bytes(content)
return cache_dir

View File

@@ -38,7 +38,17 @@ def get_templates() -> Jinja2Templates:
def extract_config_error(exc: Exception) -> str:
"""Extract a user-friendly error message from a config exception."""
if isinstance(exc, ValidationError):
return "; ".join(err.get("msg", str(err)) for err in exc.errors())
parts = []
for err in exc.errors():
msg = err.get("msg", str(err))
loc = err.get("loc", ())
if loc:
# Format location as dot-separated path (e.g., "hosts.nas.port")
loc_str = ".".join(str(part) for part in loc)
parts.append(f"{loc_str}: {msg}")
else:
parts.append(msg)
return "; ".join(parts)
return str(exc)

View File

@@ -1,4 +1,4 @@
"""Action routes for service operations."""
"""Action routes for stack operations."""
from __future__ import annotations
@@ -32,56 +32,47 @@ def _start_task(coro_factory: Callable[[str], Coroutine[Any, Any, None]]) -> str
return task_id
async def _run_service_action(name: str, command: str) -> dict[str, Any]:
"""Run a compose command for a service."""
config = get_config()
# Allowed stack commands
ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs", "stop"}
if name not in config.services:
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
# 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, stop)."""
if command not in ALLOWED_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")
task_id = _start_task(lambda tid: run_compose_streaming(config, name, command, tid))
return {"task_id": task_id, "service": name, "command": command}
return {"task_id": task_id, "stack": name, "command": command}
@router.post("/service/{name}/up")
async def up_service(name: str) -> dict[str, Any]:
"""Start a service."""
return await _run_service_action(name, "up")
@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")
@router.post("/service/{name}/down")
async def down_service(name: str) -> dict[str, Any]:
"""Stop a service."""
return await _run_service_action(name, "down")
@router.post("/service/{name}/restart")
async def restart_service(name: str) -> dict[str, Any]:
"""Restart a service (down + up)."""
return await _run_service_action(name, "restart")
@router.post("/service/{name}/pull")
async def pull_service(name: str) -> dict[str, Any]:
"""Pull latest images for a service."""
return await _run_service_action(name, "pull")
@router.post("/service/{name}/update")
async def update_service(name: str) -> dict[str, Any]:
"""Update a service (pull + build + down + up)."""
return await _run_service_action(name, "update")
@router.post("/service/{name}/logs")
async def logs_service(name: str) -> dict[str, Any]:
"""Show logs for a service."""
return await _run_service_action(name, "logs")
# 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 services."""
"""Run cf apply to reconcile all stacks."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["apply"], tid))
return {"task_id": task_id, "command": "apply"}
@@ -89,7 +80,23 @@ async def apply_all() -> dict[str, Any]:
@router.post("/refresh")
async def refresh_state() -> dict[str, Any]:
"""Refresh state from running services."""
"""Refresh state from running stacks."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["refresh"], tid))
return {"task_id": task_id, "command": "refresh"}
@router.post("/pull-all")
async def pull_all() -> dict[str, Any]:
"""Pull latest images for all stacks."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["pull", "--all"], tid))
return {"task_id": task_id, "command": "pull --all"}
@router.post("/update-all")
async def update_all() -> dict[str, Any]:
"""Update all stacks (pull + build + down + up)."""
config = get_config()
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", "--all"], tid))
return {"task_id": task_id, "command": "update --all"}

Some files were not shown because too many files have changed in this diff Show More