Compare commits

...

94 Commits

Author SHA1 Message Date
Bas Nijholt
cf94a62f37 docs: Clarify pull/build comments in migration 2025-12-16 14:26:48 -08:00
Bas Nijholt
81b4074827 Pre-build Dockerfile services during migration
After pulling images, also run build for services with Dockerfiles.
This ensures build-based services have their images ready before
stopping the old service, minimizing downtime.

If build fails, abort the migration and leave the service running
on the old host.

Extract _migrate_service helper to reduce function complexity.
2025-12-16 14:17:19 -08:00
Bas Nijholt
455657c8df Abort migration if pre-pull fails
If pulling images on the target host fails (e.g., rate limit),
abort the migration and leave the service running on the old host.
This prevents downtime when Docker Hub rate limits are hit.
2025-12-16 14:14:35 -08:00
Bas Nijholt
ee5a92788a Pre-pull images during migration to reduce downtime
When migrating a service to a new host, pull images on the target
host before stopping the service on the old host. This minimizes
downtime since images are cached when the up command runs.

Migration flow:
1. Pull images on new host (service still running on old)
2. Down on old host
3. Up on new host (fast, images already pulled)
2025-12-16 14:12:53 -08:00
Bas Nijholt
2ba396a419 docs: Move Compose Farm to first column in comparison table 2025-12-16 13:48:40 -08:00
Bas Nijholt
7144d58160 build: Include LICENSE file in package distribution 2025-12-16 13:37:15 -08:00
Bas Nijholt
279fa2e5ef Create LICENSE 2025-12-16 13:36:35 -08:00
Bas Nijholt
dbe0b8b597 docs: Add app.py to CLAUDE.md architecture diagram 2025-12-16 13:14:51 -08:00
Bas Nijholt
b7315d255a refactor: Split CLI into modular subpackage (#11) 2025-12-16 13:08:08 -08:00
renovate[bot]
f003d2931f ⬆️ Update actions/checkout action to v6 (#5)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:19:45 -08:00
renovate[bot]
6f7c557065 ⬆️ Update actions/setup-python action to v6 (#6)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:18:34 -08:00
renovate[bot]
ecb6ee46b1 ⬆️ Update astral-sh/setup-uv action to v7 (#8)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:18:28 -08:00
renovate[bot]
354967010f ⬆️ Update redis Docker tag to v8 (#9)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 12:18:22 -08:00
github-actions[bot]
57122f31a3 Update README.md 2025-12-16 20:01:03 +00:00
Bas Nijholt
cbbcec0d14 Add config subcommand for managing configuration files (#10) 2025-12-16 12:00:44 -08:00
Bas Nijholt
de38c35b8a docs: Add one-liner showing manual equivalent 2025-12-16 11:19:56 -08:00
github-actions[bot]
def996ddf4 Update README.md 2025-12-16 19:14:07 +00:00
Bas Nijholt
790e32e96b Fix test_load_config_not_found for CF_CONFIG env var 2025-12-16 11:13:44 -08:00
Bas Nijholt
fd75c4d87f Add CLI --help output to README 2025-12-16 11:12:43 -08:00
Bas Nijholt
411a99cbc4 Wait for PyPI propagation before Docker build
Also add Python 3.14 to classifiers.
2025-12-16 11:04:35 -08:00
Bas Nijholt
d2c6ab72b2 Add CF_CONFIG env var for simpler Docker workflow
Config search order is now:
1. --config CLI option
2. CF_CONFIG environment variable
3. ./compose-farm.yaml
4. ~/.config/compose-farm/compose-farm.yaml

Docker workflow simplified: mount compose_dir once, set CF_CONFIG
to config file within it. No more symlink issues or multiple mounts.
2025-12-16 10:12:55 -08:00
Bas Nijholt
3656584eda Friendly error when config path is a directory
Docker creates empty directories for missing file mounts,
causing confusing IsADirectoryError tracebacks. Now shows
a clear message explaining the likely cause.
2025-12-16 09:49:40 -08:00
Bas Nijholt
8be370098d Use env vars for docker-compose.yml mounts
- CF_CONFIG_DIR: config directory (default: ~/.config/compose-farm)
- CF_COMPOSE_DIR: compose directory (default: /opt/compose)

Mounts preserve paths so compose_dir in config works correctly.
2025-12-16 09:49:34 -08:00
Bas Nijholt
45057cb6df feat: Add docker-compose.yml for easier Docker usage
Example compose file that mounts SSH agent and config.
Users uncomment the compose_dir mount for their setup.
2025-12-16 09:40:18 -08:00
Bas Nijholt
3f24484d60 fix: Fix VERSION expansion in Dockerfile 2025-12-16 09:24:46 -08:00
Bas Nijholt
b6d50a22b4 fix: Wait for PyPI upload before building Docker image
Use workflow_run trigger to wait for "Upload Python Package" workflow
to complete successfully before building the Docker image. This ensures
the version is available on PyPI when uv tries to install it.
2025-12-16 09:21:35 -08:00
Bas Nijholt
8a658210e1 docs: Add Docker installation instructions with SSH agent 2025-12-16 09:16:43 -08:00
Bas Nijholt
583aaaa080 feat: Add Docker image and GitHub workflow
- Dockerfile using ghcr.io/astral-sh/uv:python3.14-alpine
- Installs compose-farm via uv tool install
- Includes openssh-client for remote host connections
- GitHub workflow builds and pushes to ghcr.io on release
- Supports manual workflow dispatch with version input
- Tags: semver (x.y.z, x.y, x) and latest
2025-12-16 09:11:09 -08:00
Bas Nijholt
22ca4f64e8 docs: Add command quick-reference table to Usage section 2025-12-16 08:30:15 -08:00
Bas Nijholt
32e798fcaa chore: Remove obsolete PLAN.md
The traefik-file feature described in this planning document has been
fully implemented. All open questions have been resolved.
2025-12-15 23:27:27 -08:00
Bas Nijholt
ced81c8b50 refactor: Make internal CLI symbols private
Rename module-internal type aliases, TypeVar, and constants with _ prefix:
- _T, _ServicesArg, _AllOption, _ConfigOption, _LogPathOption, _HostOption
- _MISSING_PATH_PREVIEW_LIMIT
- _DEFAULT_NETWORK_NAME, _DEFAULT_NETWORK_SUBNET, _DEFAULT_NETWORK_GATEWAY

These are only used within cli.py and should not be part of the public API.
2025-12-15 20:57:41 -08:00
Bas Nijholt
7ec4b71101 refactor: Remove unnecessary console aliasing in executor
Import console and err_console directly instead of aliasing to
_console and _err_console. Rename inner function variable to
'out' to avoid shadowing the module-level console import.
2025-12-15 20:36:39 -08:00
Bas Nijholt
94aa58d380 refactor: Make internal constants and classes private
Rename module-internal constants and classes with _ prefix:
- compose.py: SINGLE_PART, PUBLISHED_TARGET_PARTS, HOST_PUBLISHED_PARTS, MIN_VOLUME_PARTS
- logs.py: DIGEST_HEX_LENGTH
- traefik.py: LIST_VALUE_KEYS, MIN_ROUTER_PARTS, MIN_SERVICE_LABEL_PARTS,
  TraefikServiceSource, TRAEFIK_CONFIG_HEADER

These items are only used within their respective modules and should
not be part of the public API.
2025-12-15 20:33:48 -08:00
Bas Nijholt
f8d88e6f97 refactor: Remove run_compose_multi_host and rename report_preflight_failures to _report_preflight_failures
Eliminate the public run_compose_multi_host helper, which was a thin wrapper around the internal _run_sequential_commands_multi_host function, and mark the preflight failure reporting function as internal by prefixing it with an underscore.
Updated all internal calls accordingly.
2025-12-15 20:27:02 -08:00
Bas Nijholt
a95f6309b0 Remove dead code and make internal APIs public
Remove functions that were replaced by _with_progress variants in cli.py:
- discover_running_services, check_mounts_on_configured_hosts,
  check_networks_on_configured_hosts, _check_resources from operations.py
- snapshot_services from logs.py
- get_service_hosts from state.py

Make previously private functions public (remove underscore prefix):
- is_local in executor.py
- isoformat, collect_service_entries, load_existing_entries,
  merge_entries, write_toml in logs.py
- load_env, interpolate, parse_ports in compose.py

Update tests to use renamed public functions.
2025-12-15 20:19:28 -08:00
Bas Nijholt
502de018af docs: Add high availability row to comparison table 2025-12-15 19:51:57 -08:00
Bas Nijholt
a3e8daad33 docs: refine comparison table in README 2025-12-15 16:06:17 -08:00
Bas Nijholt
78a2f65c94 docs: Move comparison link after declarative setup line 2025-12-15 15:48:15 -08:00
Bas Nijholt
1689a6833a docs: Link to comparison section from Why Compose Farm 2025-12-15 15:46:26 -08:00
Bas Nijholt
6d2f32eadf docs: Add feature comparison table with emojis 2025-12-15 15:44:16 -08:00
Bas Nijholt
c549dd50c9 docs: Move comparison section to end, simplify format 2025-12-15 15:41:09 -08:00
Bas Nijholt
82312e9421 docs: add comparison with alternatives to README 2025-12-15 15:37:08 -08:00
Bas Nijholt
e13b367188 docs: Add shields to README 2025-12-15 15:31:30 -08:00
Bas Nijholt
d73049cc1b docs: Add declarative philosophy to Why Compose Farm 2025-12-15 15:17:04 -08:00
Bas Nijholt
4373b23cd3 docs: Simplify xkcd explanation, lead with simplicity 2025-12-15 14:54:29 -08:00
Bas Nijholt
73eb6ccf41 docs: Center xkcd image 2025-12-15 14:52:57 -08:00
Bas Nijholt
6ca48d0d56 docs: Add console.py to CLAUDE.md architecture 2025-12-15 14:52:40 -08:00
Bas Nijholt
b82599005e docs: Add xkcd reference and clarify this is not a new standard 2025-12-15 14:37:33 -08:00
Bas Nijholt
b044053674 docs: Emphasize zero changes required to compose files 2025-12-15 14:19:52 -08:00
Bas Nijholt
e4f03bcd94 docs: Clarify autokuma demonstrates multi-host feature 2025-12-15 14:14:47 -08:00
Bas Nijholt
ac3797912f Add AutoKuma labels to example services 2025-12-15 14:14:07 -08:00
Bas Nijholt
429a1f6e7e docs: Fix outdated .env instructions in examples README 2025-12-15 14:13:09 -08:00
Bas Nijholt
fab20e0796 Add header comment to generated traefik file-provider config
Includes repository link and explanation of what the file does.
Header is added automatically by render_traefik_config().
2025-12-15 14:11:49 -08:00
Bas Nijholt
1bc6baa0b0 Add realistic traefik file-provider example 2025-12-15 14:10:00 -08:00
Bas Nijholt
996e0748f8 style: Simplify compose-farm.yaml comments 2025-12-15 14:08:50 -08:00
Bas Nijholt
ca46fdfaa4 Replace trivial examples with real-world services
- traefik: Reverse proxy with Let's Encrypt DNS challenge
- mealie: Single container with resource limits
- uptime-kuma: Monitoring with Docker socket and user mapping
- paperless-ngx: Multi-container stack (Redis + SQLite)
- autokuma: Multi-host service (runs on all hosts)

Each example demonstrates dual Traefik routes:
- HTTPS (websecure): Custom domain with Let's Encrypt TLS
- HTTP (web): .local domain for LAN access without TLS

Includes compose-farm.yaml with multi-host config and
compose-farm-state.yaml showing deployed state.
2025-12-15 14:08:14 -08:00
Bas Nijholt
b480797e5b Add XDG_CONFIG_HOME support for config paths
Respect the XDG_CONFIG_HOME environment variable when looking for
config files and log paths. Falls back to ~/.config if not set.
2025-12-15 13:06:59 -08:00
Bas Nijholt
c47fdf847e Use _progress_bar helper for all progress bars
Standardize UI by using the same progress bar configuration
everywhere. Removes unused TaskProgressColumn import.
2025-12-15 13:03:50 -08:00
Bas Nijholt
3ca9562013 Consolidate console instances and progress bar patterns
- Add console.py module with shared Console instances
- Add _progress_bar() helper to reduce progress bar boilerplate
- Update cli.py, executor.py, operations.py to use shared console
2025-12-15 12:56:23 -08:00
Bas Nijholt
3104d5de28 Refactor state.py with context manager and cli.py with helper to reduce duplication 2025-12-15 11:10:44 -08:00
Bas Nijholt
fd141cbc8c Refactor executor and operations to eliminate code duplication 2025-12-15 11:07:58 -08:00
Bas Nijholt
aa0c15b6b3 Add project metadata to pyproject.toml
Add license, maintainers, keywords, classifiers, and project URLs
for better discoverability on PyPI.
2025-12-15 11:07:13 -08:00
Bas Nijholt
4630a3e551 Merge pull request #4 from basnijholt/feature/multi-host-services
Add multi-host service support
2025-12-15 10:58:36 -08:00
Bas Nijholt
b70d5c52f1 fix: Use strict=True in zip() for equal-length lists 2025-12-15 10:56:47 -08:00
Bas Nijholt
5d8635ba7b ci: Use prek in CI instead of separate ruff/mypy commands
prek is a faster, Rust-based alternative to pre-commit.
Also updates ruff-pre-commit to v0.14.9 to match project version.
2025-12-15 10:53:34 -08:00
Bas Nijholt
27dad9d9d5 style: Format cli.py 2025-12-15 10:51:51 -08:00
Bas Nijholt
abb4417b15 Add orphaned service detection in check command
Warns when services are in state but not in config. These are
services that were removed from config but may still be running.
Also refactors remote checks into helper function.
2025-12-15 10:43:10 -08:00
Bas Nijholt
388cca5591 Add summary output showing succeeded/failed service counts 2025-12-15 10:38:36 -08:00
Bas Nijholt
8aa019e25f fix: Move imports to top-level in test file 2025-12-15 10:29:29 -08:00
Bas Nijholt
e4061cfbde Fix down command for multi-host services
The result.service for multi-host services is 'svc@host' format.
Extract base service name before removing from state.
2025-12-15 10:09:15 -08:00
Bas Nijholt
9a1f20e2d4 Add per-host control and partial state tracking
- Track partial success: if some hosts succeed, state reflects
  only the hosts that actually started
- Add --host flag to up/down: operate on a specific host only
  - `cf up autokuma --host nuc` starts only on nuc
  - `cf down autokuma --host nuc` stops only on nuc
- Add state helpers: add_service_to_host, remove_service_from_host
- Validate --host is allowed for the service's configured hosts
2025-12-15 09:51:49 -08:00
Bas Nijholt
3b45736729 Validate multi-host config edge cases
- Block 'all' as a host name (reserved keyword)
- Reject empty host lists
- Reject duplicate hosts in explicit lists
2025-12-15 09:46:29 -08:00
Bas Nijholt
1d88fa450a docs: Explain why multi-host services are needed 2025-12-15 09:37:41 -08:00
Bas Nijholt
31ee6be163 Add multi-host service support
Allows services to run on multiple hosts using the "all" keyword
or an explicit list of hosts:

  services:
    autokuma: all              # Run on all configured hosts
    dozzle: [host1, host2]     # Run on specific hosts

Key changes:
- config.py: Added get_hosts() and is_multi_host() methods
- executor.py: Added run_compose_multi_host() for parallel execution
- operations.py: Added _up_multi_host_service() with pre-flight checks
- state.py: Track list of hosts for multi-host services
- cli.py: Updated discovery, stats, and check for multi-host
- README.md: Added documentation

Multi-host services:
- Run on all target hosts in parallel
- Skip migration logic (always run everywhere)
- Show [service@host] prefix in output
- Track all running hosts in state
2025-12-15 09:14:25 -08:00
Bas Nijholt
096a2ca5f4 sync: Remove extra spacing (transient bar already leaves one) 2025-12-14 20:41:38 -08:00
Bas Nijholt
fb04f6f64d sync: Add spacing before capturing progress bar 2025-12-14 20:39:02 -08:00
Bas Nijholt
d8e54aa347 check: Fix double newline spacing in output 2025-12-14 20:35:25 -08:00
Bas Nijholt
b2b6b421ba check: Add spacing before mounts/networks progress bar 2025-12-14 20:33:36 -08:00
Bas Nijholt
c6b35f02f0 check: Add spacing before SSH progress bar 2025-12-14 20:32:16 -08:00
Bas Nijholt
7e43b0a6b8 check: Fix spacing after transient progress bar 2025-12-14 20:31:20 -08:00
Bas Nijholt
2915b287ba check: Add SSH connectivity check as first remote step
- Check SSH connectivity to all remote hosts before mount/network checks
- Skip local hosts (no SSH needed)
- Show progress bar during SSH checks
- Report unreachable hosts with clear error messages
- Add newline spacing for better output formatting
2025-12-14 20:30:36 -08:00
Bas Nijholt
ae561db0c9 check: Add progress bar and parallelize mount/network checks
- Parallelize mount and network checking for all services
- Add Rich progress bar showing count, elapsed time, and service name
- Move all inline imports to top-level (contextlib, datetime, logs)
- Also sort state file entries alphabetically for consistency
2025-12-14 20:24:54 -08:00
Bas Nijholt
2d132747c4 sync: Enhance progress bars with count and elapsed time
Show "Discovering ━━━━ 32/65 • 0:00:05 • service-name" format with:
- M of N complete count
- Elapsed time
- Current service name
2025-12-14 20:15:39 -08:00
Bas Nijholt
2848163a04 sync: Add progress bars and parallelize operations
- Parallelize service discovery across all services
- Parallelize image digest capture
- Show transient Rich progress bars during both operations
- Significantly faster sync due to concurrent SSH connections
2025-12-14 20:13:42 -08:00
Bas Nijholt
76aa6e11d2 logs: Make --all and --host mutually exclusive
These options conflict conceptually - --all means all services across
all hosts, while --host means all services on a specific host.
2025-12-14 20:10:28 -08:00
Bas Nijholt
d377df15b4 logs: Add --host filter and contextual --tail default
- Add --host/-H option to filter logs to services on a specific host
- Default --tail to 20 lines when showing multiple services (--all, --host, or >1 service)
- Default to 100 lines for single service
- Add tests for contextual default and host filtering
2025-12-14 20:04:40 -08:00
Bas Nijholt
334c17cc28 logs: Use contextual default for --tail option
Default to 20 lines when --all is specified (quick overview),
100 lines otherwise (detailed view for specific services).
2025-12-14 19:59:12 -08:00
Bas Nijholt
f148b5bd3a docs: Add TrueNAS NFS root squash configuration guide
Explains how to set maproot_user/maproot_group to root/wheel
in TrueNAS to disable root squash, allowing Docker containers
running as root to write to NFS-mounted volumes.
2025-12-14 17:24:46 -08:00
Bas Nijholt
54af649d76 Make stats progress bar transient 2025-12-14 15:31:00 -08:00
Bas Nijholt
f6e5a5fa56 Add progress bar when querying hosts in stats --live 2025-12-14 15:30:37 -08:00
Bas Nijholt
01aa24d0db style: Add borders to stats summary table 2025-12-14 15:25:17 -08:00
Bas Nijholt
3e702ef72e Add stats command for overview of hosts and services
Shows hosts table (address, configured/running services) and summary
(total hosts, services, compose files on disk, pending migrations).
Use --live to query Docker for actual container counts.
2025-12-14 15:24:30 -08:00
Bas Nijholt
a31218f7e5 docs: Remove trivial progress counter detail 2025-12-14 15:16:45 -08:00
Bas Nijholt
5decb3ed95 docs: Add --migrate flag and hybrid SSH approach 2025-12-14 15:14:56 -08:00
55 changed files with 3563 additions and 1139 deletions

View File

@@ -16,10 +16,10 @@ jobs:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
@@ -39,10 +39,10 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
- name: Set up Python
run: uv python install 3.12
@@ -50,11 +50,5 @@ jobs:
- name: Install dependencies
run: uv sync --all-extras --dev
- name: Run ruff check
run: uv run ruff check .
- name: Run ruff format check
run: uv run ruff format --check .
- name: Run mypy
run: uv run mypy src/compose_farm
- name: Run pre-commit (via prek)
uses: j178/prek-action@v1

92
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
name: Build and Push Docker Image
on:
workflow_run:
workflows: ["Upload Python Package"]
types: [completed]
workflow_dispatch:
inputs:
version:
description: 'Version to build (leave empty for latest)'
required: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
# Only run if PyPI upload succeeded (or manual dispatch)
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
# Get version from the tag that triggered the release
VERSION="${{ github.event.workflow_run.head_branch }}"
# Strip 'v' prefix if present
VERSION="${VERSION#v}"
elif [ -n "${{ github.event.inputs.version }}" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION=""
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Wait for PyPI
if: steps.version.outputs.version != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Waiting for compose-farm==$VERSION on PyPI..."
for i in {1..30}; do
if curl -sf "https://pypi.org/pypi/compose-farm/$VERSION/json" > /dev/null; then
echo "✓ Version $VERSION available on PyPI"
exit 0
fi
echo "Attempt $i: not yet available, waiting 10s..."
sleep 10
done
echo "✗ Timeout waiting for PyPI"
exit 1
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},value=v${{ steps.version.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ steps.version.outputs.version }}
type=semver,pattern={{major}},value=v${{ steps.version.outputs.version }}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -13,9 +13,9 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
- name: Build
run: uv build
- name: Publish package distributions to PyPI

View File

@@ -11,16 +11,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
- name: Run markdown-code-runner
env:

View File

@@ -10,7 +10,7 @@ repos:
- id: debug-statements
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
rev: v0.14.9
hooks:
- id: ruff
args: [--fix]

View File

@@ -10,19 +10,27 @@
```
compose_farm/
├── cli.py # Typer commands (thin layer, delegates to operations)
├── config.py # Pydantic models, YAML loading
├── compose.py # Compose file parsing (.env, ports, volumes, networks)
├── 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)
├── logs.py # Image digest snapshots (dockerfarm-log.toml)
└── traefik.py # Traefik file-provider config generation from labels
├── 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 commands
│ ├── management.py # sync, check, init-network, traefik-file commands
│ └── monitoring.py # logs, ps, stats commands
├── 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)
├── logs.py # Image digest snapshots (dockerfarm-log.toml)
└── traefik.py # Traefik file-provider config generation from labels
```
## Key Design Decisions
1. **asyncssh over Paramiko/Fabric**: Native async support, built-in streaming
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
4. **SSH key auth only**: Uses ssh-agent, no password handling (YAGNI)
@@ -47,14 +55,16 @@ CLI available as `cf` or `compose-farm`.
| Command | Description |
|---------|-------------|
| `up` | Start services (`docker compose up -d`), auto-migrates if host changed |
| `up` | Start services (`docker compose up -d`), auto-migrates if host changed. Use `--migrate` for auto-detection |
| `down` | Stop services (`docker compose down`) |
| `pull` | Pull latest images |
| `restart` | `down` + `up -d` |
| `update` | `pull` + `down` + `up -d` |
| `logs` | Show service logs |
| `ps` | Show status of all services |
| `stats` | Show overview (hosts, services, pending migrations; `--live` for container counts) |
| `sync` | Discover running services, update state, 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) |

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.14-alpine
# Install SSH client (required for remote host connections)
RUN apk add --no-cache openssh-client
# Install compose-farm from PyPI
ARG VERSION
RUN uv tool install compose-farm${VERSION:+==$VERSION}
# Add uv tool bin to PATH
ENV PATH="/root/.local/bin:$PATH"
# Default entrypoint
ENTRYPOINT ["cf"]
CMD ["--help"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Bas Nijholt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

35
PLAN.md
View File

@@ -1,35 +0,0 @@
# Compose Farm Traefik Multihost Ingress Plan
## Goal
Generate a Traefik file-provider fragment from existing docker-compose Traefik labels (no config duplication) so a single front-door Traefik on 192.168.1.66 with wildcard `*.lab.mydomain.org` can route to services running on other hosts. Keep the current simplicity (SSH + docker compose); no Swarm/K8s.
## Requirements
- Traefik stays on main host; keep current `dynamic.yml` and Docker provider for local containers.
- Add a watched directory provider (any path works) and load a generated fragment (e.g., `compose-farm.generated.yml`).
- No edits to compose files: reuse existing `traefik.*` labels as the single source of truth; Compose Farm only reads them.
- Generator infers routing from labels and reachability from `ports:` mappings; prefer host-published ports so Traefik can reach services across hosts. Upstreams point to `<host address>:<published host port>`; warn if no published port is found.
- Only minimal data in `compose-farm.yaml`: hosts map and service→host mapping (already present).
- No new orchestration/discovery layers; respect KISS/YAGNI/DRY.
## Non-Goals
- No Swarm/Kubernetes adoption.
- No global Docker provider across hosts.
- No health checks/service discovery layer.
## Current State (Dec 2025)
- Compose Farm: Typer CLI wrapping `docker compose` over SSH; config in `compose-farm.yaml`; parallel by default; snapshot/log tooling present.
- Traefik: single instance on 192.168.1.66, wildcard `*.lab.mydomain.org`, Docker provider for local services, file provider via `dynamic.yml` already in use.
## Proposed Implementation Steps
1) Add generator command: `compose-farm traefik-file --output <path>`.
2) Resolve per-service host from `compose-farm.yaml`; read compose file at `{compose_dir}/{service}/docker-compose.yml`.
3) Parse `traefik.*` labels to build routers/services/middlewares as in compose; map container port to published host port (from `ports:`) to form upstream URLs with host address.
4) Emit file-provider YAML to the watched directory (recommended default: `/mnt/data/traefik/dynamic.d/compose-farm.generated.yml`, but user chooses via `--output`).
5) Warnings: if no published port is found, warn that cross-host reachability requires L3 reachability to container IPs.
6) Tests: label parsing, port mapping, YAML render; scenario with published port; scenario without published port.
7) Docs: update README/CLAUDE to describe directory provider flags and the generator workflow; note that compose files remain unchanged.
## Open Questions
- How to derive target host address: use `hosts.<name>.address` verbatim, or allow override per service? (Default: use host address.)
- Should we support multiple hosts/backends per service for LB/HA? (Start with single server.)
- Where to store generated file by default? (Default to user-specified `--output`; maybe fallback to `./compose-farm-traefik.yml`.)

195
README.md
View File

@@ -1,5 +1,10 @@
# Compose Farm
[![PyPI](https://img.shields.io/pypi/v/compose-farm)](https://pypi.org/project/compose-farm/)
[![Python](https://img.shields.io/pypi/pyversions/compose-farm)](https://pypi.org/project/compose-farm/)
[![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;" />
A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
@@ -19,21 +24,42 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
- [What Compose Farm doesn't do](#what-compose-farm-doesnt-do)
- [Installation](#installation)
- [Configuration](#configuration)
- [Multi-Host Services](#multi-host-services)
- [Config Command](#config-command)
- [Usage](#usage)
- [Auto-Migration](#auto-migration)
- [Traefik Multihost Ingress (File Provider)](#traefik-multihost-ingress-file-provider)
- [Comparison with Alternatives](#comparison-with-alternatives)
- [License](#license)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Why Compose Farm?
I run 100+ Docker Compose stacks on an LXC container that frequently runs 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 services 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.
**Compose Farm is intentionally simple**: one YAML config mapping services to hosts, and a CLI that runs `docker compose` commands over SSH. That's it.
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 `up`, and services migrate automatically. See [Comparison with Alternatives](#comparison-with-alternatives) for how this compares to other approaches.
<p align="center">
<a href="https://xkcd.com/927/">
<img src="https://imgs.xkcd.com/comics/standards.png" alt="xkcd: Standards" width="400" />
</a>
</p>
Before you say it—no, this is not a new standard. I changed nothing about my existing setup. When I added more hosts, I just mounted my drives at the same paths, and everything worked. You can do all of this manually today—SSH into a host and run `docker compose up`.
Compose Farm just automates what you'd do by hand:
- Runs `docker compose` commands over SSH
- Tracks which service runs on which host
- Auto-migrates services when you change the host assignment
- Generates Traefik file-provider config for cross-host routing
**It's a convenience wrapper, not a new paradigm.**
## How It Works
@@ -107,6 +133,23 @@ uv tool install compose-farm
pip install compose-farm
```
<details><summary>🐳 Docker</summary>
Using the provided `docker-compose.yml`:
```bash
docker compose run --rm cf up --all
```
Or directly:
```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
```
</details>
## Configuration
Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory):
@@ -128,18 +171,85 @@ services:
jellyfin: server-2
sonarr: server-1
radarr: local # Runs on the machine where you invoke compose-farm
# Multi-host services (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`).
### Multi-Host Services
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.
Common use cases:
- **AutoKuma** - auto-creates Uptime Kuma monitors from container labels (needs local Docker socket)
- **Dozzle** - real-time log viewer (needs local Docker socket)
- **Promtail/Alloy** - log shipping agents (needs local Docker socket and log files)
- **node-exporter** - Prometheus host metrics (needs access to host /proc, /sys)
This is the same pattern as Docker Swarm's `deploy.mode: global`.
Use the `all` keyword or an explicit list:
```yaml
services:
# Run on all configured hosts
autokuma: all
dozzle: all
# Run on specific hosts
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:
- Are excluded from migration logic (they always run everywhere)
- Show output with `[service@host]` prefix for each host
- Track all running hosts in state
### Config Command
Compose Farm includes a `config` subcommand to help manage configuration files:
```bash
cf config init # Create a new config file with documented example
cf config show # Display current config with syntax highlighting
cf config path # Print the config file path (useful for scripting)
cf config validate # Validate config syntax and schema
cf config edit # Open config in $EDITOR
```
Use `cf config init` to get started with a fully documented template.
## Usage
The CLI is available as both `compose-farm` and the shorter `cf` alias.
| Command | Description |
|---------|-------------|
| `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 sync` | Discover running services + capture image digests |
| `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) |
All commands support `--all` to operate on all services.
Each command replaces: look up host → SSH → find compose file → run `ssh host "cd /opt/compose/plex && docker compose up -d"`.
```bash
# Start services (auto-migrates if host changed in config)
cf up plex jellyfin
cf up --all
cf up --migrate # only services needing migration (state ≠ config)
# Stop services
cf down plex
@@ -174,6 +284,60 @@ cf logs -f plex # follow
cf ps
```
<details>
<summary>See the output of <code>cf --help</code></summary>
<!-- CODE:BASH:START -->
<!-- echo '```yaml' -->
<!-- export NO_COLOR=1 -->
<!-- export TERM=dumb -->
<!-- export TERMINAL_WIDTH=90 -->
<!-- cf --help -->
<!-- echo '```' -->
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
```yaml
Usage: cf [OPTIONS] COMMAND [ARGS]...
Compose Farm - run docker compose commands across multiple hosts
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --version -v Show version and exit │
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell, to │
│ copy it or customize the installation. │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ 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 + down + up). │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Configuration ──────────────────────────────────────────────────────────────╮
│ traefik-file Generate a Traefik file-provider fragment from compose │
│ Traefik labels. │
│ sync Sync local state with running services. │
│ check Validate configuration, traefik labels, mounts, and networks. │
│ init-network Create Docker network on hosts with consistent settings. │
│ config Manage compose-farm configuration files. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
│ logs Show service logs. │
│ ps Show status of all services. │
│ stats Show overview statistics for hosts and services. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- OUTPUT:END -->
</details>
### Auto-Migration
When you change a service's host assignment in config and run `up`, Compose Farm automatically:
@@ -182,6 +346,8 @@ When you change a service's host assignment in config and run `up`, Compose Farm
3. Runs `up -d` on the new host
4. Updates state tracking
Use `cf up --migrate` (or `-m`) to automatically find and migrate all services where the current state differs from config—no need to list them manually.
```yaml
# Before: plex runs on server-1
services:
@@ -292,6 +458,31 @@ Update your Traefik config to use directory watching instead of a single file:
- --providers.file.watch=true
```
## Comparison with Alternatives
There are many ways to run containers on multiple hosts. Here is where Compose Farm sits:
| | Compose Farm | Docker Contexts | K8s / Swarm | Ansible / Terraform | Portainer / Coolify |
|---|:---:|:---:|:---:|:---:|:---:|
| No compose rewrites | ✅ | ✅ | ❌ | ✅ | ✅ |
| Version controlled | ✅ | ✅ | ✅ | ✅ | ❌ |
| State tracking | ✅ | ❌ | ✅ | ✅ | ✅ |
| Auto-migration | ✅ | ❌ | ✅ | ❌ | ❌ |
| Interactive CLI | ✅ | ❌ | ❌ | ❌ | ❌ |
| Parallel execution | ✅ | ❌ | ✅ | ✅ | ✅ |
| 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.
**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.
**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.
**Compose Farm is the middle ground:** a robust CLI that productizes the manual SSH pattern. You get the "cluster feel" (unified commands, state tracking) without the "cluster cost" (complexity, agents, control planes).
## License
MIT

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
cf:
image: ghcr.io/basnijholt/compose-farm:latest
volumes:
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
# Compose directory (contains compose files AND compose-farm.yaml config)
- ${CF_COMPOSE_DIR:-/opt/compose}:${CF_COMPOSE_DIR:-/opt/compose}
environment:
- SSH_AUTH_SOCK=/ssh-agent
# Config file path (state stored alongside it)
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/compose}/compose-farm.yaml

View File

@@ -0,0 +1,65 @@
# TrueNAS NFS: Disabling Root Squash
When running Docker containers on NFS-mounted storage, containers that run as root will fail to write files unless root squash is disabled. This document explains the problem and solution.
## The Problem
By default, NFS uses "root squash" which maps the root user (UID 0) on clients to `nobody` on the server. This is a security feature to prevent remote root users from having root access to the NFS server's files.
However, many Docker containers run as root internally. When these containers try to write to NFS-mounted volumes, the writes fail with "Permission denied" because the NFS server sees them as `nobody`, not `root`.
Example error in container logs:
```
System.UnauthorizedAccessException: Access to the path '/data' is denied.
Error: EACCES: permission denied, mkdir '/app/data'
```
## The Solution
In TrueNAS, configure the NFS share to map remote root to local root:
### TrueNAS SCALE UI
1. Go to **Shares → NFS**
2. Edit your share
3. Under **Advanced Options**:
- **Maproot User**: `root`
- **Maproot Group**: `wheel`
4. Save
### Result in /etc/exports
```
"/mnt/pool/data"\
192.168.1.25(sec=sys,rw,no_root_squash,no_subtree_check)\
192.168.1.26(sec=sys,rw,no_root_squash,no_subtree_check)
```
The `no_root_squash` option means remote root is treated as root on the server.
## Why `wheel`?
On FreeBSD/TrueNAS, the root user's primary group is `wheel` (GID 0), not `root` like on Linux. So `root:wheel` = `0:0`.
## Security Considerations
Disabling root squash means any machine that can mount the NFS share has full root access to those files. This is acceptable when:
- The NFS clients are on a trusted private network
- Only known hosts (by IP) are allowed to mount the share
- The data isn't security-critical
For home lab setups with Docker containers, this is typically fine.
## Alternative: Run Containers as Non-Root
If you prefer to keep root squash enabled, you can run containers as a non-root user:
1. **LinuxServer.io images**: Set `PUID=1000` and `PGID=1000` environment variables
2. **Other images**: Add `user: "1000:1000"` to the compose service
However, not all containers support running as non-root (they may need to bind to privileged ports, create system directories, etc.).
## Tested On
- TrueNAS SCALE 24.10

View File

@@ -1,82 +1,171 @@
# Compose Farm Examples
This folder contains example Docker Compose services for testing Compose Farm locally.
Real-world examples demonstrating compose-farm patterns for multi-host Docker deployments.
## Services
| Service | Type | Demonstrates |
|---------|------|--------------|
| [traefik](traefik/) | Infrastructure | Reverse proxy, Let's Encrypt, file-provider |
| [mealie](mealie/) | Single container | Traefik labels, resource limits, environment vars |
| [uptime-kuma](uptime-kuma/) | Single container | Docker socket, user mapping, custom DNS |
| [paperless-ngx](paperless-ngx/) | Multi-container | Redis + App stack (SQLite) |
| [autokuma](autokuma/) | Multi-host | Demonstrates `all` keyword (runs on every host) |
## Key Patterns
### External Network
All services connect to a shared external network for inter-service communication:
```yaml
networks:
mynetwork:
external: true
```
Create it on each host with consistent settings:
```bash
compose-farm init-network --network mynetwork --subnet 172.20.0.0/16
```
### Traefik Labels (Dual Routes)
Services 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:
- `https://mealie.example.com` - from anywhere, with TLS
- `http://mealie.local` - from your local network, no TLS overhead
```yaml
labels:
# HTTPS route for custom domain (e.g., mealie.example.com)
- traefik.enable=true
- traefik.http.routers.myapp.rule=Host(`myapp.${DOMAIN}`)
- traefik.http.routers.myapp.entrypoints=websecure
- traefik.http.services.myapp.loadbalancer.server.port=8080
# HTTP route for .local domain (e.g., myapp.local)
- traefik.http.routers.myapp-local.rule=Host(`myapp.local`)
- traefik.http.routers.myapp-local.entrypoints=web
```
> **Note:** `.local` domains require local DNS (e.g., Pi-hole, Technitium) to resolve to your Traefik host.
### Environment Variables
Each service has a `.env` file for secrets and domain configuration.
Edit these files to set your domain and credentials:
```bash
# Example: set your domain
echo "DOMAIN=example.com" > mealie/.env
```
Variables like `${DOMAIN}` are substituted at runtime by Docker Compose.
### NFS Volume Mounts
All data is stored on shared NFS storage at `/mnt/data/`:
```yaml
volumes:
- /mnt/data/myapp:/app/data
```
This allows services to migrate between hosts without data loss.
### Multi-Host Services
Services that need to run on every host (e.g., monitoring agents):
```yaml
# In compose-farm.yaml
services:
autokuma: all # Runs on every configured host
```
### Multi-Container Stacks
Database-backed apps with multiple services:
```yaml
services:
redis:
image: redis:7
app:
depends_on:
- redis
```
> **NFS + PostgreSQL Warning:** PostgreSQL should NOT run on NFS storage due to
> fsync and file locking issues. Use SQLite (safe for single-writer on NFS) or
> keep PostgreSQL data on local volumes (non-migratable).
### 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.
[AutoKuma](https://github.com/BigBoot/AutoKuma) automatically creates Uptime Kuma monitors from Docker labels:
```yaml
labels:
- kuma.myapp.http.name=My App
- kuma.myapp.http.url=https://myapp.${DOMAIN}
```
## Quick Start
```bash
cd examples
# Check status of all services
compose-farm ps
# 1. Create the shared network on all hosts
compose-farm init-network
# Pull images
compose-farm pull --all
# Start hello-world (runs and exits)
compose-farm up hello
# Start nginx (stays running)
compose-farm up nginx
# Check nginx is running
curl localhost:8080
# View logs
compose-farm logs nginx
# Stop nginx
compose-farm down nginx
# Update all (pull + restart)
compose-farm update --all
```
## Traefik Example
Start Traefik and a sample service with Traefik labels:
```bash
cd examples
# Start Traefik (reverse proxy with dashboard)
# 2. Start Traefik first (the reverse proxy)
compose-farm up traefik
# Start whoami (test service with Traefik labels)
compose-farm up whoami
# 3. Start other services
compose-farm up mealie uptime-kuma
# Access the services
curl -H "Host: whoami.localhost" http://localhost # whoami via Traefik
curl http://localhost:8081 # Traefik dashboard
curl http://localhost:18082 # whoami direct
# 4. Check status
compose-farm ps
# Generate Traefik file-provider config (for multi-host setups)
# 5. Generate Traefik file-provider config for cross-host routing
compose-farm traefik-file --all
# Stop everything
# 6. View logs
compose-farm logs mealie
# 7. Stop everything
compose-farm down --all
```
The `whoami/docker-compose.yml` shows the standard Traefik label pattern:
## Configuration
```yaml
labels:
- traefik.enable=true
- traefik.http.routers.whoami.rule=Host(`whoami.localhost`)
- traefik.http.routers.whoami.entrypoints=web
- traefik.http.services.whoami.loadbalancer.server.port=80
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
- **autokuma**: Runs on ALL hosts to monitor local containers
When Traefik runs on `primary` and a service 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:
```bash
# Generate config for all services
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
```
## Services
| Service | Description | Ports |
|---------|-------------|-------|
| hello | Hello-world container (exits immediately) | - |
| nginx | Nginx web server | 8080 |
| traefik | Traefik reverse proxy with dashboard | 80, 8081 |
| whoami | Test service with Traefik labels | 18082 |
## Config
The `compose-farm.yaml` in this directory configures all services to run locally (no SSH).
It also demonstrates the `traefik_file` option for auto-regenerating Traefik file-provider config.
With `traefik_file` configured, compose-farm automatically regenerates the config after `up`, `down`, `restart`, and `update` commands.

4
examples/autokuma/.env Normal file
View File

@@ -0,0 +1,4 @@
# Copy to .env and fill in your values
DOMAIN=example.com
UPTIME_KUMA_USERNAME=admin
UPTIME_KUMA_PASSWORD=your-uptime-kuma-password

View File

@@ -0,0 +1,31 @@
# AutoKuma - Automatic Uptime Kuma monitor creation from Docker labels
# Demonstrates: Multi-host service (runs on ALL hosts)
#
# This service monitors Docker containers on each host and automatically
# creates Uptime Kuma monitors based on container labels.
#
# In compose-farm.yaml, configure as:
# autokuma: all
#
# This runs the same container on every host, so each host's local
# Docker socket is monitored.
name: autokuma
services:
autokuma:
image: ghcr.io/bigboot/autokuma:latest
container_name: autokuma
restart: unless-stopped
environment:
# Connect to your Uptime Kuma instance
AUTOKUMA__KUMA__URL: https://uptime.${DOMAIN}
AUTOKUMA__KUMA__USERNAME: ${UPTIME_KUMA_USERNAME}
AUTOKUMA__KUMA__PASSWORD: ${UPTIME_KUMA_PASSWORD}
# Tag for auto-created monitors
AUTOKUMA__TAG__NAME: autokuma
AUTOKUMA__TAG__COLOR: "#10B981"
volumes:
# Access local Docker socket to discover containers
- /var/run/docker.sock:/var/run/docker.sock:ro
# Custom DNS for resolving internal domains
dns:
- 192.168.1.1 # Your local DNS server

View File

@@ -1 +1,9 @@
deployed: {}
deployed:
autokuma:
- primary
- secondary
- local
mealie: secondary
paperless-ngx: primary
traefik: primary
uptime-kuma: secondary

View File

@@ -1,17 +1,40 @@
# Example Compose Farm config for local testing
# Run from the examples directory: cd examples && compose-farm ps
# Example Compose Farm configuration
# Demonstrates a multi-host setup with NFS shared storage
#
# To test locally: Update the host addresses and run from the examples directory
compose_dir: .
compose_dir: /opt/stacks/compose-farm/examples
# Auto-regenerate Traefik file-provider config after up/down/restart/update
traefik_file: ./traefik/dynamic.d/compose-farm.yml
traefik_service: traefik # Skip services on same host (docker provider handles them)
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)
hosts:
# Primary server - runs Traefik and most services
# Full form with all options
primary:
address: 192.168.1.10
user: deploy
port: 22
# Secondary server - runs some services 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:
hello: local
nginx: local
traefik: local
whoami: local
# Infrastructure (runs on primary where Traefik is)
traefik: primary
# Multi-host services (runs on ALL hosts)
# AutoKuma monitors Docker containers on each host
autokuma: all
# Primary server services
paperless-ngx: primary
# Secondary server services (distributed for performance)
mealie: secondary
uptime-kuma: secondary

View File

@@ -1,4 +0,0 @@
services:
hello:
image: hello-world
container_name: sdc-hello

2
examples/mealie/.env Normal file
View File

@@ -0,0 +1,2 @@
# Copy to .env and fill in your values
DOMAIN=example.com

View File

@@ -0,0 +1,47 @@
# Mealie - Recipe manager
# Simple single-container service with Traefik labels
#
# Demonstrates:
# - HTTPS route: mealie.${DOMAIN} (e.g., mealie.example.com) with Let's Encrypt
# - HTTP route: mealie.local for LAN access without TLS
# - External network, resource limits, environment variables
name: mealie
services:
mealie:
image: ghcr.io/mealie-recipes/mealie:latest
container_name: mealie
restart: unless-stopped
networks:
- mynetwork
ports:
- "9925:9000"
deploy:
resources:
limits:
memory: 1000M
volumes:
- /mnt/data/mealie:/app/data
environment:
ALLOW_SIGNUP: "false"
PUID: 1000
PGID: 1000
TZ: America/Los_Angeles
MAX_WORKERS: 1
WEB_CONCURRENCY: 1
BASE_URL: https://mealie.${DOMAIN}
labels:
# HTTPS route: mealie.example.com (requires DOMAIN in .env)
- traefik.enable=true
- traefik.http.routers.mealie.rule=Host(`mealie.${DOMAIN}`)
- traefik.http.routers.mealie.entrypoints=websecure
- traefik.http.services.mealie.loadbalancer.server.port=9000
# HTTP route: mealie.local (for LAN access, no TLS)
- traefik.http.routers.mealie-local.rule=Host(`mealie.local`)
- traefik.http.routers.mealie-local.entrypoints=web
# AutoKuma: automatically create Uptime Kuma monitor
- kuma.mealie.http.name=Mealie
- kuma.mealie.http.url=https://mealie.${DOMAIN}
networks:
mynetwork:
external: true

View File

@@ -1,6 +0,0 @@
services:
nginx:
image: nginx:alpine
container_name: sdc-nginx
ports:
- "8080:80"

View File

@@ -0,0 +1,3 @@
# Copy to .env and fill in your values
DOMAIN=example.com
PAPERLESS_SECRET_KEY=change-me-to-a-random-string

View File

@@ -0,0 +1,60 @@
# Paperless-ngx - Document management system
#
# Demonstrates:
# - HTTPS route: paperless.${DOMAIN} (e.g., paperless.example.com) with Let's Encrypt
# - HTTP route: paperless.local for LAN access without TLS
# - Multi-container stack (Redis + App with SQLite)
#
# NOTE: This example uses SQLite (the default) instead of PostgreSQL.
# PostgreSQL should NOT be used with NFS storage due to fsync/locking issues.
# If you need PostgreSQL, use local volumes for the database.
name: paperless-ngx
services:
redis:
image: redis:8
container_name: paperless-redis
restart: unless-stopped
networks:
- mynetwork
volumes:
- /mnt/data/paperless/redis:/data
paperless:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
container_name: paperless
restart: unless-stopped
depends_on:
- redis
networks:
- mynetwork
ports:
- "8000:8000"
volumes:
# SQLite database stored here (safe on NFS for single-writer)
- /mnt/data/paperless/data:/usr/src/paperless/data
- /mnt/data/paperless/media:/usr/src/paperless/media
- /mnt/data/paperless/export:/usr/src/paperless/export
- /mnt/data/paperless/consume:/usr/src/paperless/consume
environment:
PAPERLESS_REDIS: redis://redis:6379
PAPERLESS_URL: https://paperless.${DOMAIN}
PAPERLESS_SECRET_KEY: ${PAPERLESS_SECRET_KEY}
USERMAP_UID: 1000
USERMAP_GID: 1000
labels:
# HTTPS route: paperless.example.com (requires DOMAIN in .env)
- traefik.enable=true
- traefik.http.routers.paperless.rule=Host(`paperless.${DOMAIN}`)
- traefik.http.routers.paperless.entrypoints=websecure
- traefik.http.services.paperless.loadbalancer.server.port=8000
- traefik.docker.network=mynetwork
# HTTP route: paperless.local (for LAN access, no TLS)
- traefik.http.routers.paperless-local.rule=Host(`paperless.local`)
- traefik.http.routers.paperless-local.entrypoints=web
# AutoKuma: automatically create Uptime Kuma monitor
- kuma.paperless.http.name=Paperless
- kuma.paperless.http.url=https://paperless.${DOMAIN}
networks:
mynetwork:
external: true

5
examples/traefik/.env Normal file
View File

@@ -0,0 +1,5 @@
# Copy to .env and fill in your values
DOMAIN=example.com
ACME_EMAIL=you@example.com
CF_API_EMAIL=you@example.com
CF_API_KEY=your-cloudflare-api-key

View File

@@ -0,0 +1,58 @@
# Traefik reverse proxy with Let's Encrypt and file-provider support
# This is the foundation service - other services route through it
#
# Entrypoints:
# - web (port 80): HTTP for .local domains (no TLS needed on LAN)
# - websecure (port 443): HTTPS with Let's Encrypt for custom domains
name: traefik
services:
traefik:
image: traefik:v3.2
container_name: traefik
command:
- --api.dashboard=true
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=mynetwork
# File provider for routing to services on other hosts
- --providers.file.directory=/dynamic.d
- --providers.file.watch=true
# HTTP entrypoint for .local domains (LAN access, no TLS)
- --entrypoints.web.address=:80
# HTTPS entrypoint for custom domains (with Let's Encrypt TLS)
- --entrypoints.websecure.address=:443
- --entrypoints.websecure.asDefault=true
- --entrypoints.websecure.http.tls.certresolver=letsencrypt
# Let's Encrypt DNS challenge (using Cloudflare as example)
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53
environment:
# Cloudflare API token for DNS challenge
CF_API_EMAIL: ${CF_API_EMAIL}
CF_API_KEY: ${CF_API_KEY}
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "8080:8080" # Dashboard
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /mnt/data/traefik/letsencrypt:/letsencrypt
- ./dynamic.d:/dynamic.d:ro
networks:
- mynetwork
labels:
- traefik.enable=true
# Dashboard accessible at traefik.yourdomain.com
- traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)
- traefik.http.routers.traefik.entrypoints=websecure
- traefik.http.routers.traefik.service=api@internal
# AutoKuma: automatically create Uptime Kuma monitor
- kuma.traefik.http.name=Traefik
- kuma.traefik.http.url=https://traefik.${DOMAIN}
networks:
mynetwork:
external: true

View File

@@ -1,17 +0,0 @@
services:
traefik:
image: traefik:v3.2
container_name: traefik
command:
- --api.insecure=true
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.file.directory=/dynamic.d
- --providers.file.watch=true
- --entrypoints.web.address=:80
ports:
- "80:80"
- "8081:8080" # Traefik dashboard
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./dynamic.d:/dynamic.d

View File

@@ -1 +1,40 @@
{}
# 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.
# Services on Traefik's host use the Docker provider directly.
#
# Regenerate with: compose-farm traefik-file --all -o <this-file>
# Or configure traefik_file in compose-farm.yaml for automatic updates.
http:
routers:
mealie:
rule: Host(`mealie.example.com`)
entrypoints:
- websecure
service: mealie
mealie-local:
rule: Host(`mealie.local`)
entrypoints:
- web
service: mealie
uptime:
rule: Host(`uptime.example.com`)
entrypoints:
- websecure
service: uptime
uptime-local:
rule: Host(`uptime.local`)
entrypoints:
- web
service: uptime
services:
mealie:
loadbalancer:
servers:
- url: http://192.168.1.11:9925
uptime:
loadbalancer:
servers:
- url: http://192.168.1.11:3001

View File

@@ -0,0 +1,2 @@
# Copy to .env and fill in your values
DOMAIN=example.com

View File

@@ -0,0 +1,43 @@
# Uptime Kuma - Monitoring dashboard
#
# Demonstrates:
# - HTTPS route: uptime.${DOMAIN} (e.g., uptime.example.com) with Let's Encrypt
# - HTTP route: uptime.local for LAN access without TLS
# - Docker socket access, user mapping for NFS, custom DNS
name: uptime-kuma
services:
uptime-kuma:
image: louislam/uptime-kuma:2
container_name: uptime-kuma
restart: unless-stopped
# Run as non-root user (important for NFS volumes)
user: "1000:1000"
networks:
- mynetwork
ports:
- "3001:3001"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /mnt/data/uptime-kuma:/app/data
environment:
PUID: 1000
PGID: 1000
# Custom DNS for internal domain resolution
dns:
- 192.168.1.1 # Your local DNS server
labels:
# HTTPS route: uptime.example.com (requires DOMAIN in .env)
- traefik.enable=true
- traefik.http.routers.uptime.rule=Host(`uptime.${DOMAIN}`)
- traefik.http.routers.uptime.entrypoints=websecure
- traefik.http.services.uptime.loadbalancer.server.port=3001
# HTTP route: uptime.local (for LAN access, no TLS)
- traefik.http.routers.uptime-local.rule=Host(`uptime.local`)
- traefik.http.routers.uptime-local.entrypoints=web
# AutoKuma: automatically create Uptime Kuma monitor
- kuma.uptime.http.name=Uptime Kuma
- kuma.uptime.http.url=https://uptime.${DOMAIN}
networks:
mynetwork:
external: true

View File

@@ -1,11 +0,0 @@
services:
whoami:
image: traefik/whoami
container_name: whoami
ports:
- "18082:80"
labels:
- traefik.enable=true
- traefik.http.routers.whoami.rule=Host(`whoami.localhost`)
- traefik.http.routers.whoami.entrypoints=web
- traefik.http.services.whoami.loadbalancer.server.port=80

View File

@@ -3,10 +3,43 @@ name = "compose-farm"
dynamic = ["version"]
description = "Compose Farm - run docker compose commands across multiple hosts"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [
{ name = "Bas Nijholt", email = "bas@nijho.lt" }
]
maintainers = [
{ name = "Bas Nijholt", email = "bas@nijho.lt" }
]
requires-python = ">=3.11"
keywords = [
"docker",
"docker-compose",
"ssh",
"devops",
"deployment",
"container",
"orchestration",
"multi-host",
"homelab",
"self-hosted",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: System :: Systems Administration",
"Topic :: Utilities",
"Typing :: Typed",
]
dependencies = [
"typer>=0.9.0",
"pydantic>=2.0.0",
@@ -15,6 +48,13 @@ dependencies = [
"rich>=13.0.0",
]
[project.urls]
Homepage = "https://github.com/basnijholt/compose-farm"
Repository = "https://github.com/basnijholt/compose-farm"
Documentation = "https://github.com/basnijholt/compose-farm#readme"
Issues = "https://github.com/basnijholt/compose-farm/issues"
Changelog = "https://github.com/basnijholt/compose-farm/releases"
[project.scripts]
compose-farm = "compose_farm.cli:app"
cf = "compose_farm.cli:app"

View File

@@ -1,618 +0,0 @@
"""CLI interface using Typer."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, TypeVar
import typer
import yaml
from rich.console import Console
from . import __version__
from .config import Config, load_config
from .executor import CommandResult, run_command, run_on_services, run_sequential_on_services
from .logs import snapshot_services
from .operations import (
check_host_compatibility,
check_mounts_on_configured_hosts,
check_networks_on_configured_hosts,
discover_running_services,
up_services,
)
from .state import get_services_needing_migration, load_state, remove_service, save_state
from .traefik import generate_traefik_config
if TYPE_CHECKING:
from collections.abc import Coroutine
T = TypeVar("T")
console = Console(highlight=False)
err_console = Console(stderr=True, highlight=False)
def _load_config_or_exit(config_path: Path | None) -> Config:
"""Load config or exit with a friendly error message."""
try:
return load_config(config_path)
except FileNotFoundError as e:
err_console.print(f"[red]✗[/] {e}")
raise typer.Exit(1) from e
def _maybe_regenerate_traefik(cfg: Config) -> None:
"""Regenerate traefik config if traefik_file is configured."""
if cfg.traefik_file is None:
return
try:
dynamic, warnings = generate_traefik_config(cfg, list(cfg.services.keys()))
new_content = yaml.safe_dump(dynamic, sort_keys=False)
# Check if content changed
old_content = ""
if cfg.traefik_file.exists():
old_content = cfg.traefik_file.read_text()
if new_content != old_content:
cfg.traefik_file.parent.mkdir(parents=True, exist_ok=True)
cfg.traefik_file.write_text(new_content)
console.print() # Ensure we're on a new line after streaming output
console.print(f"[green]✓[/] Traefik config updated: {cfg.traefik_file}")
for warning in warnings:
err_console.print(f"[yellow]![/] {warning}")
except (FileNotFoundError, ValueError) as exc:
err_console.print(f"[yellow]![/] Failed to update traefik config: {exc}")
def _version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
typer.echo(f"compose-farm {__version__}")
raise typer.Exit
app = typer.Typer(
name="compose-farm",
help="Compose Farm - run docker compose commands across multiple hosts",
no_args_is_help=True,
context_settings={"help_option_names": ["-h", "--help"]},
)
@app.callback()
def main(
version: Annotated[
bool,
typer.Option(
"--version",
"-v",
help="Show version and exit",
callback=_version_callback,
is_eager=True,
),
] = False,
) -> None:
"""Compose Farm - run docker compose commands across multiple hosts."""
def _get_services(
services: list[str],
all_services: bool,
config_path: Path | None,
) -> tuple[list[str], Config]:
"""Resolve service list and load config."""
config = _load_config_or_exit(config_path)
if all_services:
return list(config.services.keys()), config
if not services:
err_console.print("[red]✗[/] Specify services or use --all")
raise typer.Exit(1)
return list(services), config
def _run_async(coro: Coroutine[None, None, T]) -> T:
"""Run async coroutine."""
return asyncio.run(coro)
def _report_results(results: list[CommandResult]) -> None:
"""Report command results and exit with appropriate code."""
failed = [r for r in results if not r.success]
if failed:
for r in failed:
err_console.print(
f"[cyan]\\[{r.service}][/] [red]Failed with exit code {r.exit_code}[/]"
)
raise typer.Exit(1)
ServicesArg = Annotated[
list[str] | None,
typer.Argument(help="Services to operate on"),
]
AllOption = Annotated[
bool,
typer.Option("--all", "-a", help="Run on all services"),
]
ConfigOption = Annotated[
Path | None,
typer.Option("--config", "-c", help="Path to config file"),
]
LogPathOption = Annotated[
Path | None,
typer.Option("--log-path", "-l", help="Path to Dockerfarm TOML log"),
]
MISSING_PATH_PREVIEW_LIMIT = 2
@app.command(rich_help_panel="Lifecycle")
def up(
services: ServicesArg = None,
all_services: AllOption = False,
migrate: Annotated[
bool, typer.Option("--migrate", "-m", help="Only services needing migration")
] = False,
config: ConfigOption = None,
) -> None:
"""Start services (docker compose up -d). Auto-migrates if host changed."""
if migrate:
cfg = _load_config_or_exit(config)
svc_list = get_services_needing_migration(cfg)
if not svc_list:
console.print("[green]✓[/] No services need migration")
return
console.print(f"[cyan]Migrating {len(svc_list)} service(s):[/] {', '.join(svc_list)}")
else:
svc_list, cfg = _get_services(services or [], all_services, config)
# Always use raw output - migrations are sequential anyway
results = _run_async(up_services(cfg, svc_list, raw=True))
_maybe_regenerate_traefik(cfg)
_report_results(results)
@app.command(rich_help_panel="Lifecycle")
def down(
services: ServicesArg = None,
all_services: AllOption = False,
config: ConfigOption = None,
) -> None:
"""Stop services (docker compose down)."""
svc_list, cfg = _get_services(services or [], all_services, config)
raw = len(svc_list) == 1
results = _run_async(run_on_services(cfg, svc_list, "down", raw=raw))
# Remove from state on success
for result in results:
if result.success:
remove_service(cfg, result.service)
_maybe_regenerate_traefik(cfg)
_report_results(results)
@app.command(rich_help_panel="Lifecycle")
def pull(
services: ServicesArg = None,
all_services: AllOption = False,
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))
_report_results(results)
@app.command(rich_help_panel="Lifecycle")
def restart(
services: ServicesArg = None,
all_services: AllOption = False,
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))
_maybe_regenerate_traefik(cfg)
_report_results(results)
@app.command(rich_help_panel="Lifecycle")
def update(
services: ServicesArg = None,
all_services: AllOption = False,
config: ConfigOption = None,
) -> None:
"""Update services (pull + 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", "down", "up -d"], raw=raw)
)
_maybe_regenerate_traefik(cfg)
_report_results(results)
@app.command(rich_help_panel="Monitoring")
def logs(
services: ServicesArg = None,
all_services: AllOption = False,
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs")] = False,
tail: Annotated[int, typer.Option("--tail", "-n", help="Number of lines")] = 100,
config: ConfigOption = None,
) -> None:
"""Show service logs."""
svc_list, cfg = _get_services(services or [], all_services, config)
cmd = f"logs --tail {tail}"
if follow:
cmd += " -f"
results = _run_async(run_on_services(cfg, svc_list, cmd))
_report_results(results)
@app.command(rich_help_panel="Monitoring")
def ps(
config: ConfigOption = None,
) -> None:
"""Show status of all services."""
cfg = _load_config_or_exit(config)
results = _run_async(run_on_services(cfg, list(cfg.services.keys()), "ps"))
_report_results(results)
@app.command("traefik-file", rich_help_panel="Configuration")
def traefik_file(
services: ServicesArg = None,
all_services: AllOption = False,
output: Annotated[
Path | None,
typer.Option(
"--output",
"-o",
help="Write Traefik file-provider YAML to this path (stdout if omitted)",
),
] = None,
config: ConfigOption = None,
) -> None:
"""Generate a Traefik file-provider fragment from compose Traefik labels."""
svc_list, cfg = _get_services(services or [], all_services, config)
try:
dynamic, warnings = generate_traefik_config(cfg, svc_list)
except (FileNotFoundError, ValueError) as exc:
err_console.print(f"[red]✗[/] {exc}")
raise typer.Exit(1) from exc
rendered = yaml.safe_dump(dynamic, sort_keys=False)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(rendered)
console.print(f"[green]✓[/] Traefik config written to {output}")
else:
console.print(rendered)
for warning in warnings:
err_console.print(f"[yellow]![/] {warning}")
def _report_sync_changes(
added: list[str],
removed: list[str],
changed: list[tuple[str, str, str]],
discovered: dict[str, str],
current_state: dict[str, str],
) -> None:
"""Report sync changes to the user."""
if added:
console.print(f"\nNew services found ({len(added)}):")
for service in sorted(added):
console.print(f" [green]+[/] [cyan]{service}[/] on [magenta]{discovered[service]}[/]")
if changed:
console.print(f"\nServices on different hosts ({len(changed)}):")
for service, old_host, new_host in sorted(changed):
console.print(
f" [yellow]~[/] [cyan]{service}[/]: "
f"[magenta]{old_host}[/] → [magenta]{new_host}[/]"
)
if removed:
console.print(f"\nServices no longer running ({len(removed)}):")
for service in sorted(removed):
console.print(
f" [red]-[/] [cyan]{service}[/] (was on [magenta]{current_state[service]}[/])"
)
@app.command(rich_help_panel="Configuration")
def sync(
config: ConfigOption = None,
log_path: LogPathOption = None,
dry_run: Annotated[
bool,
typer.Option("--dry-run", "-n", help="Show what would be synced without writing"),
] = False,
) -> None:
"""Sync local state with running services.
Discovers which services are running on which hosts, updates the state
file, and captures image digests. Combines service discovery with
image snapshot into a single command.
"""
cfg = _load_config_or_exit(config)
current_state = load_state(cfg)
console.print("Discovering running services...")
discovered = _run_async(discover_running_services(cfg))
# Calculate changes
added = [s for s in discovered if s not in current_state]
removed = [s for s in current_state if s not in discovered]
changed = [
(s, current_state[s], discovered[s])
for s in discovered
if s in current_state and current_state[s] != discovered[s]
]
# Report state changes
state_changed = bool(added or removed or changed)
if state_changed:
_report_sync_changes(added, removed, changed, discovered, current_state)
else:
console.print("[green]✓[/] State is already in sync.")
if dry_run:
console.print("\n[dim](dry-run: no changes made)[/]")
return
# Update state file
if state_changed:
save_state(cfg, discovered)
console.print(f"\n[green]✓[/] State updated: {len(discovered)} services tracked.")
# Capture image digests for running services
if discovered:
console.print("\nCapturing image digests...")
try:
path = _run_async(snapshot_services(cfg, list(discovered.keys()), log_path=log_path))
console.print(f"[green]✓[/] Digests written to {path}")
except RuntimeError as exc:
err_console.print(f"[yellow]![/] {exc}")
def _report_config_status(cfg: Config) -> bool:
"""Check and report config vs disk status. Returns True if errors found."""
configured = set(cfg.services.keys())
on_disk = cfg.discover_compose_dirs()
missing_from_config = sorted(on_disk - configured)
missing_from_disk = sorted(configured - on_disk)
if missing_from_config:
console.print(f"\n[yellow]On disk but not in config[/] ({len(missing_from_config)}):")
for name in missing_from_config:
console.print(f" [yellow]+[/] [cyan]{name}[/]")
if missing_from_disk:
console.print(f"\n[red]In config but no compose file[/] ({len(missing_from_disk)}):")
for name in missing_from_disk:
console.print(f" [red]-[/] [cyan]{name}[/]")
if not missing_from_config and not missing_from_disk:
console.print("[green]✓[/] Config matches disk")
return bool(missing_from_disk)
def _report_traefik_status(cfg: Config, services: list[str]) -> None:
"""Check and report traefik label status."""
try:
_, warnings = generate_traefik_config(cfg, services, check_all=True)
except (FileNotFoundError, ValueError):
return
if warnings:
console.print(f"\n[yellow]Traefik issues[/] ({len(warnings)}):")
for warning in warnings:
console.print(f" [yellow]![/] {warning}")
else:
console.print("[green]✓[/] Traefik labels valid")
def _report_mount_errors(mount_errors: list[tuple[str, str, str]]) -> None:
"""Report mount errors grouped by service."""
by_service: dict[str, list[tuple[str, str]]] = {}
for svc, host, path in mount_errors:
by_service.setdefault(svc, []).append((host, path))
console.print(f"\n[red]Missing mounts[/] ({len(mount_errors)}):")
for svc, items in sorted(by_service.items()):
host = items[0][0]
paths = [p for _, p in items]
console.print(f" [cyan]{svc}[/] on [magenta]{host}[/]:")
for path in paths:
console.print(f" [red]✗[/] {path}")
def _report_network_errors(network_errors: list[tuple[str, str, str]]) -> None:
"""Report network errors grouped by service."""
by_service: dict[str, list[tuple[str, str]]] = {}
for svc, host, net in network_errors:
by_service.setdefault(svc, []).append((host, net))
console.print(f"\n[red]Missing networks[/] ({len(network_errors)}):")
for svc, items in sorted(by_service.items()):
host = items[0][0]
networks = [n for _, n in items]
console.print(f" [cyan]{svc}[/] on [magenta]{host}[/]:")
for net in networks:
console.print(f" [red]✗[/] {net}")
def _report_host_compatibility(
compat: dict[str, tuple[int, int, list[str]]],
current_host: str,
) -> None:
"""Report host compatibility for a service."""
for host_name, (found, total, missing) in sorted(compat.items()):
is_current = host_name == current_host
marker = " [dim](assigned)[/]" if is_current else ""
if found == total:
console.print(f" [green]✓[/] [magenta]{host_name}[/] {found}/{total}{marker}")
else:
preview = ", ".join(missing[:MISSING_PATH_PREVIEW_LIMIT])
if len(missing) > MISSING_PATH_PREVIEW_LIMIT:
preview += f", +{len(missing) - MISSING_PATH_PREVIEW_LIMIT} more"
console.print(
f" [red]✗[/] [magenta]{host_name}[/] {found}/{total} "
f"[dim](missing: {preview})[/]{marker}"
)
@app.command(rich_help_panel="Configuration")
def check(
services: ServicesArg = None,
local: Annotated[
bool,
typer.Option("--local", help="Skip SSH-based checks (faster)"),
] = False,
config: ConfigOption = None,
) -> 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.
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)
invalid = [s for s in svc_list if s not in cfg.services]
if invalid:
for svc in invalid:
err_console.print(f"[red]✗[/] Service '{svc}' not found in config")
raise typer.Exit(1)
show_host_compat = True
else:
svc_list = list(cfg.services.keys())
show_host_compat = False
# Run checks
has_errors = _report_config_status(cfg)
_report_traefik_status(cfg, svc_list)
if not local:
console.print("\nChecking mounts and networks...")
mount_errors = _run_async(check_mounts_on_configured_hosts(cfg, svc_list))
network_errors = _run_async(check_networks_on_configured_hosts(cfg, svc_list))
if mount_errors:
_report_mount_errors(mount_errors)
has_errors = True
if network_errors:
_report_network_errors(network_errors)
has_errors = True
if not mount_errors and not network_errors:
console.print("[green]✓[/] All mounts and networks 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))
_report_host_compatibility(compat, cfg.services[service])
if has_errors:
raise typer.Exit(1)
# Default network settings for cross-host Docker networking
DEFAULT_NETWORK_NAME = "mynetwork"
DEFAULT_NETWORK_SUBNET = "172.20.0.0/16"
DEFAULT_NETWORK_GATEWAY = "172.20.0.1"
@app.command("init-network", rich_help_panel="Configuration")
def init_network(
hosts: Annotated[
list[str] | None,
typer.Argument(help="Hosts to create network on (default: all)"),
] = None,
network: Annotated[
str,
typer.Option("--network", "-n", help="Network name"),
] = DEFAULT_NETWORK_NAME,
subnet: Annotated[
str,
typer.Option("--subnet", "-s", help="Network subnet"),
] = DEFAULT_NETWORK_SUBNET,
gateway: Annotated[
str,
typer.Option("--gateway", "-g", help="Network gateway"),
] = DEFAULT_NETWORK_GATEWAY,
config: ConfigOption = None,
) -> None:
"""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.
"""
cfg = _load_config_or_exit(config)
target_hosts = list(hosts) if hosts else list(cfg.hosts.keys())
invalid = [h for h in target_hosts if h not in cfg.hosts]
if invalid:
for h in invalid:
err_console.print(f"[red]✗[/] Host '{h}' not found in config")
raise typer.Exit(1)
async def create_network_on_host(host_name: str) -> CommandResult:
host = cfg.hosts[host_name]
# Check if network already exists
check_cmd = f"docker network inspect '{network}' >/dev/null 2>&1"
check_result = await run_command(host, check_cmd, host_name, stream=False)
if check_result.success:
console.print(f"[cyan]\\[{host_name}][/] Network '{network}' already exists")
return CommandResult(service=host_name, exit_code=0, success=True)
# Create the network
create_cmd = (
f"docker network create "
f"--driver bridge "
f"--subnet '{subnet}' "
f"--gateway '{gateway}' "
f"'{network}'"
)
result = await run_command(host, create_cmd, host_name, stream=False)
if result.success:
console.print(f"[cyan]\\[{host_name}][/] [green]✓[/] Created network '{network}'")
else:
err_console.print(
f"[cyan]\\[{host_name}][/] [red]✗[/] Failed to create network: "
f"{result.stderr.strip()}"
)
return result
async def run_all() -> list[CommandResult]:
return await asyncio.gather(*[create_network_on_host(h) for h in target_hosts])
results = _run_async(run_all())
failed = [r for r in results if not r.success]
if failed:
raise typer.Exit(1)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,19 @@
"""CLI interface using Typer."""
from __future__ import annotations
# Import command modules to trigger registration via @app.command() decorators
from compose_farm.cli import (
config, # noqa: F401
lifecycle, # noqa: F401
management, # noqa: F401
monitoring, # noqa: F401
)
# Import the shared app instance
from compose_farm.cli.app import app
__all__ = ["app"]
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,42 @@
"""Shared Typer app instance."""
from __future__ import annotations
from typing import Annotated
import typer
from compose_farm import __version__
__all__ = ["app"]
def _version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
typer.echo(f"compose-farm {__version__}")
raise typer.Exit
app = typer.Typer(
name="compose-farm",
help="Compose Farm - run docker compose commands across multiple hosts",
no_args_is_help=True,
context_settings={"help_option_names": ["-h", "--help"]},
)
@app.callback()
def main(
version: Annotated[
bool,
typer.Option(
"--version",
"-v",
help="Show version and exit",
callback=_version_callback,
is_eager=True,
),
] = False,
) -> None:
"""Compose Farm - run docker compose commands across multiple hosts."""

View File

@@ -0,0 +1,203 @@
"""Shared CLI helpers, options, and utilities."""
from __future__ import annotations
import asyncio
import contextlib
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, TypeVar
import typer
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
SpinnerColumn,
TaskID,
TextColumn,
TimeElapsedColumn,
)
from compose_farm.config import Config, load_config
from compose_farm.console import console, err_console
from compose_farm.executor import CommandResult # noqa: TC001
from compose_farm.traefik import generate_traefik_config, render_traefik_config
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Generator
_T = TypeVar("_T")
# --- Shared CLI Options ---
ServicesArg = Annotated[
list[str] | None,
typer.Argument(help="Services to operate on"),
]
AllOption = Annotated[
bool,
typer.Option("--all", "-a", help="Run on all services"),
]
ConfigOption = Annotated[
Path | None,
typer.Option("--config", "-c", help="Path to config file"),
]
LogPathOption = Annotated[
Path | None,
typer.Option("--log-path", "-l", help="Path to Dockerfarm TOML log"),
]
HostOption = Annotated[
str | None,
typer.Option("--host", "-H", help="Filter to services on this host"),
]
# --- Constants (internal) ---
_MISSING_PATH_PREVIEW_LIMIT = 2
_STATS_PREVIEW_LIMIT = 3 # Max number of pending migrations to show by name
@contextlib.contextmanager
def progress_bar(label: str, total: int) -> Generator[tuple[Progress, TaskID], None, None]:
"""Create a standardized progress bar with consistent styling.
Yields (progress, task_id). Use progress.update(task_id, advance=1, description=...)
to advance.
"""
with Progress(
SpinnerColumn(),
TextColumn(f"[bold blue]{label}[/]"),
BarColumn(),
MofNCompleteColumn(),
TextColumn(""),
TimeElapsedColumn(),
TextColumn(""),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True,
) as progress:
task_id = progress.add_task("", total=total)
yield progress, task_id
def load_config_or_exit(config_path: Path | None) -> Config:
"""Load config or exit with a friendly error message."""
try:
return load_config(config_path)
except FileNotFoundError as e:
err_console.print(f"[red]✗[/] {e}")
raise typer.Exit(1) from e
def get_services(
services: list[str],
all_services: bool,
config_path: Path | None,
) -> tuple[list[str], Config]:
"""Resolve service list and load config."""
config = load_config_or_exit(config_path)
if all_services:
return list(config.services.keys()), config
if not services:
err_console.print("[red]✗[/] Specify services or use --all")
raise typer.Exit(1)
return list(services), config
def run_async(coro: Coroutine[None, None, _T]) -> _T:
"""Run async coroutine."""
return asyncio.run(coro)
def report_results(results: list[CommandResult]) -> None:
"""Report command results and exit with appropriate code."""
succeeded = [r for r in results if r.success]
failed = [r for r in results if not r.success]
# Always print summary when there are multiple results
if len(results) > 1:
console.print() # Blank line before summary
if failed:
for r in failed:
err_console.print(
f"[red]✗[/] [cyan]{r.service}[/] failed with exit code {r.exit_code}"
)
console.print()
console.print(
f"[green]✓[/] {len(succeeded)}/{len(results)} services succeeded, "
f"[red]✗[/] {len(failed)} failed"
)
else:
console.print(f"[green]✓[/] All {len(results)} services succeeded")
elif failed:
# Single service failed
r = failed[0]
err_console.print(f"[red]✗[/] [cyan]{r.service}[/] failed with exit code {r.exit_code}")
if failed:
raise typer.Exit(1)
def maybe_regenerate_traefik(cfg: Config) -> None:
"""Regenerate traefik config if traefik_file is configured."""
if cfg.traefik_file is None:
return
try:
dynamic, warnings = generate_traefik_config(cfg, list(cfg.services.keys()))
new_content = render_traefik_config(dynamic)
# Check if content changed
old_content = ""
if cfg.traefik_file.exists():
old_content = cfg.traefik_file.read_text()
if new_content != old_content:
cfg.traefik_file.parent.mkdir(parents=True, exist_ok=True)
cfg.traefik_file.write_text(new_content)
console.print() # Ensure we're on a new line after streaming output
console.print(f"[green]✓[/] Traefik config updated: {cfg.traefik_file}")
for warning in warnings:
err_console.print(f"[yellow]![/] {warning}")
except (FileNotFoundError, ValueError) as exc:
err_console.print(f"[yellow]![/] Failed to update traefik config: {exc}")
def validate_host_for_service(cfg: Config, service: str, host: str) -> None:
"""Validate that a host is valid for a service."""
if host not in cfg.hosts:
err_console.print(f"[red]✗[/] Host '{host}' not found in config")
raise typer.Exit(1)
allowed_hosts = cfg.get_hosts(service)
if host not in allowed_hosts:
err_console.print(
f"[red]✗[/] Service '{service}' is not configured for host '{host}' "
f"(configured: {', '.join(allowed_hosts)})"
)
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)
report_results(results)

View File

@@ -0,0 +1,265 @@
"""Configuration management commands for compose-farm."""
from __future__ import annotations
import os
import platform
import shlex
import shutil
import subprocess
from importlib import resources
from pathlib import Path
from typing import Annotated
import typer
from compose_farm.cli.app import app
from compose_farm.config import load_config, xdg_config_home
from compose_farm.console import console, err_console
config_app = typer.Typer(
name="config",
help="Manage compose-farm configuration files.",
no_args_is_help=True,
)
# Default config location (internal)
_USER_CONFIG_PATH = xdg_config_home() / "compose-farm" / "compose-farm.yaml"
# Search paths for existing config (internal)
_CONFIG_PATHS = [
Path("compose-farm.yaml"),
_USER_CONFIG_PATH,
]
# --- CLI Options (same pattern as cli.py) ---
_PathOption = Annotated[
Path | None,
typer.Option("--path", "-p", help="Path to config file. Uses auto-detection if not specified."),
]
_ForceOption = Annotated[
bool,
typer.Option("--force", "-f", help="Overwrite existing config without confirmation."),
]
_RawOption = Annotated[
bool,
typer.Option("--raw", "-r", help="Output raw file contents (for copy-paste)."),
]
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
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"
def _generate_template() -> str:
"""Generate a config template with documented schema."""
try:
template_file = resources.files("compose_farm") / "example-config.yaml"
return template_file.read_text(encoding="utf-8")
except FileNotFoundError as e:
err_console.print("[red]Example config template is missing from the package.[/red]")
err_console.print("Reinstall compose-farm or report this issue.")
raise typer.Exit(1) from e
def _get_config_file(path: Path | None) -> Path | None:
"""Resolve config path, or auto-detect from standard locations."""
if path:
return path.expanduser().resolve()
# Check environment variable
if env_path := os.environ.get("CF_CONFIG"):
p = Path(env_path)
if p.exists():
return p.resolve()
# Check standard locations
for p in _CONFIG_PATHS:
if p.exists():
return p.resolve()
return None
@config_app.command("init")
def config_init(
path: _PathOption = None,
force: _ForceOption = False,
) -> None:
"""Create a new config file with documented example.
The generated config file serves as a template showing all available
options with explanatory comments.
"""
target_path = (path.expanduser().resolve() if path else None) or _USER_CONFIG_PATH
if target_path.exists() and not force:
console.print(
f"[bold yellow]Config file already exists at:[/bold yellow] [cyan]{target_path}[/cyan]",
)
if not typer.confirm("Overwrite existing config file?"):
console.print("[dim]Aborted.[/dim]")
raise typer.Exit(0)
# Create parent directories
target_path.parent.mkdir(parents=True, exist_ok=True)
# Generate and write template
template_content = _generate_template()
target_path.write_text(template_content, encoding="utf-8")
console.print(f"[green]✓[/] Config file created at: {target_path}")
console.print("\n[dim]Edit the file to customize your settings:[/dim]")
console.print(" [cyan]cf config edit[/cyan]")
@config_app.command("edit")
def config_edit(
path: _PathOption = None,
) -> None:
"""Open the config file in your default editor.
The editor is determined by: $EDITOR > $VISUAL > platform default.
"""
config_file = _get_config_file(path)
if config_file is None:
console.print("[yellow]No config file found.[/yellow]")
console.print("\nRun [bold cyan]cf config init[/bold cyan] to create one.")
console.print("\nSearched locations:")
for p in _CONFIG_PATHS:
console.print(f" - {p}")
raise typer.Exit(1)
if not config_file.exists():
console.print("[yellow]Config file not found.[/yellow]")
console.print(f"\nProvided path does not exist: [cyan]{config_file}[/cyan]")
console.print("\nRun [bold cyan]cf config init[/bold cyan] to create one.")
raise typer.Exit(1)
editor = _get_editor()
console.print(f"[dim]Opening {config_file} with {editor}...[/dim]")
try:
editor_cmd = shlex.split(editor, posix=os.name != "nt")
except ValueError as e:
err_console.print("[red]Invalid editor command. Check $EDITOR/$VISUAL.[/red]")
raise typer.Exit(1) from e
if not editor_cmd:
err_console.print("[red]Editor command is empty.[/red]")
raise typer.Exit(1)
try:
subprocess.run([*editor_cmd, str(config_file)], check=True)
except FileNotFoundError:
err_console.print(f"[red]Editor '{editor_cmd[0]}' not found.[/red]")
err_console.print("Set $EDITOR environment variable to your preferred editor.")
raise typer.Exit(1) from None
except subprocess.CalledProcessError as e:
err_console.print(f"[red]Editor exited with error code {e.returncode}[/red]")
raise typer.Exit(e.returncode) from None
@config_app.command("show")
def config_show(
path: _PathOption = None,
raw: _RawOption = False,
) -> None:
"""Display the config file location and contents."""
config_file = _get_config_file(path)
if config_file is None:
console.print("[yellow]No config file found.[/yellow]")
console.print("\nSearched locations:")
for p in _CONFIG_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.")
raise typer.Exit(0)
if not config_file.exists():
console.print("[yellow]Config file not found.[/yellow]")
console.print(f"\nProvided path does not exist: [cyan]{config_file}[/cyan]")
console.print("\nRun [bold cyan]cf config init[/bold cyan] to create one.")
raise typer.Exit(1)
content = config_file.read_text(encoding="utf-8")
if raw:
print(content, end="")
return
from rich.syntax import Syntax # noqa: PLC0415
console.print(f"[bold green]Config file:[/bold green] [cyan]{config_file}[/cyan]")
console.print()
syntax = Syntax(content, "yaml", theme="monokai", line_numbers=True, word_wrap=True)
console.print(syntax)
console.print()
console.print("[dim]Tip: Use -r for copy-paste friendly output[/dim]")
@config_app.command("path")
def config_path(
path: _PathOption = None,
) -> None:
"""Print the config file path (useful for scripting)."""
config_file = _get_config_file(path)
if config_file is None:
console.print("[yellow]No config file found.[/yellow]")
console.print("\nSearched locations:")
for p in _CONFIG_PATHS:
status = "[green]exists[/green]" if p.exists() else "[dim]not found[/dim]"
console.print(f" - {p} ({status})")
raise typer.Exit(1)
# Just print the path for easy piping
print(config_file)
@config_app.command("validate")
def config_validate(
path: _PathOption = None,
) -> None:
"""Validate the config file syntax and schema."""
config_file = _get_config_file(path)
if config_file is None:
err_console.print("[red]✗[/] No config file found")
raise typer.Exit(1)
try:
cfg = load_config(config_file)
except FileNotFoundError as e:
err_console.print(f"[red]✗[/] {e}")
raise typer.Exit(1) from e
except Exception as e:
err_console.print(f"[red]✗[/] Invalid config: {e}")
raise typer.Exit(1) from e
console.print(f"[green]✓[/] Valid config: {config_file}")
console.print(f" Hosts: {len(cfg.hosts)}")
console.print(f" Services: {len(cfg.services)}")
# Register config subcommand on the shared app
app.add_typer(config_app, name="config", rich_help_panel="Configuration")

View File

@@ -0,0 +1,144 @@
"""Lifecycle commands: up, down, pull, restart, update."""
from __future__ import annotations
from typing import Annotated
import typer
from compose_farm.cli.app import app
from compose_farm.cli.common import (
AllOption,
ConfigOption,
HostOption,
ServicesArg,
get_services,
load_config_or_exit,
maybe_regenerate_traefik,
report_results,
run_async,
run_host_operation,
)
from compose_farm.console import console
from compose_farm.executor import run_on_services, run_sequential_on_services
from compose_farm.operations import up_services
from compose_farm.state import (
add_service_to_host,
get_services_needing_migration,
remove_service,
remove_service_from_host,
)
@app.command(rich_help_panel="Lifecycle")
def up(
services: ServicesArg = None,
all_services: AllOption = False,
migrate: Annotated[
bool, typer.Option("--migrate", "-m", help="Only services needing migration")
] = False,
host: HostOption = None,
config: ConfigOption = None,
) -> None:
"""Start services (docker compose up -d). Auto-migrates if host changed."""
from compose_farm.console import err_console # noqa: PLC0415
if migrate and host:
err_console.print("[red]✗[/] Cannot use --migrate and --host together")
raise typer.Exit(1)
if migrate:
cfg = load_config_or_exit(config)
svc_list = get_services_needing_migration(cfg)
if not svc_list:
console.print("[green]✓[/] No services need migration")
return
console.print(f"[cyan]Migrating {len(svc_list)} service(s):[/] {', '.join(svc_list)}")
else:
svc_list, cfg = get_services(services or [], all_services, config)
# Per-host operation: run on specific host only
if host:
run_host_operation(cfg, svc_list, host, "up -d", "Starting", add_service_to_host)
return
# Normal operation: use up_services with migration logic
results = run_async(up_services(cfg, svc_list, raw=True))
maybe_regenerate_traefik(cfg)
report_results(results)
@app.command(rich_help_panel="Lifecycle")
def down(
services: ServicesArg = None,
all_services: AllOption = False,
host: HostOption = None,
config: ConfigOption = None,
) -> None:
"""Stop services (docker compose down)."""
svc_list, cfg = get_services(services or [], all_services, config)
# Per-host operation: run on specific host only
if host:
run_host_operation(cfg, svc_list, host, "down", "Stopping", remove_service_from_host)
return
# Normal operation
raw = len(svc_list) == 1
results = run_async(run_on_services(cfg, svc_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 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)
maybe_regenerate_traefik(cfg)
report_results(results)
@app.command(rich_help_panel="Lifecycle")
def pull(
services: ServicesArg = None,
all_services: AllOption = False,
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))
report_results(results)
@app.command(rich_help_panel="Lifecycle")
def restart(
services: ServicesArg = None,
all_services: AllOption = False,
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))
maybe_regenerate_traefik(cfg)
report_results(results)
@app.command(rich_help_panel="Lifecycle")
def update(
services: ServicesArg = None,
all_services: AllOption = False,
config: ConfigOption = None,
) -> None:
"""Update services (pull + 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", "down", "up -d"], raw=raw)
)
maybe_regenerate_traefik(cfg)
report_results(results)

View File

@@ -0,0 +1,641 @@
"""Management commands: sync, check, init-network, traefik-file."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from pathlib import Path # noqa: TC003
from typing import Annotated
import typer
from rich.progress import Progress, TaskID # noqa: TC002
from compose_farm.cli.app import app
from compose_farm.cli.common import (
_MISSING_PATH_PREVIEW_LIMIT,
AllOption,
ConfigOption,
LogPathOption,
ServicesArg,
get_services,
load_config_or_exit,
progress_bar,
run_async,
)
from compose_farm.compose import parse_external_networks
from compose_farm.config import Config # noqa: TC001
from compose_farm.console import console, err_console
from compose_farm.executor import (
CommandResult,
check_networks_exist,
check_paths_exist,
check_service_running,
is_local,
run_command,
)
from compose_farm.logs import (
DEFAULT_LOG_PATH,
SnapshotEntry,
collect_service_entries,
isoformat,
load_existing_entries,
merge_entries,
write_toml,
)
from compose_farm.operations import check_host_compatibility, get_service_paths
from compose_farm.state import 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."""
async def check_service(service: str) -> tuple[str, str | list[str] | None]:
"""Check where a service is running.
For multi-host services, returns list of hosts where running.
For single-host, returns single host name or None.
"""
assigned_hosts = cfg.get_hosts(service)
if cfg.is_multi_host(service):
# Multi-host: find all hosts where running (check in parallel)
checks = await asyncio.gather(
*[check_service_running(cfg, service, h) for h in assigned_hosts]
)
running_hosts = [
h for h, running in zip(assigned_hosts, checks, strict=True) if running
]
return service, running_hosts if running_hosts else None
# Single-host: check assigned host first
assigned_host = assigned_hosts[0]
if await check_service_running(cfg, service, assigned_host):
return service, assigned_host
# Check other hosts
for host_name in cfg.hosts:
if host_name == assigned_host:
continue
if await check_service_running(cfg, service, host_name):
return service, host_name
return service, None
async def gather_with_progress(
progress: Progress, task_id: TaskID
) -> dict[str, str | list[str]]:
services = list(cfg.services.keys())
tasks = [asyncio.create_task(check_service(s)) for s in services]
discovered: dict[str, str | list[str]] = {}
for coro in asyncio.as_completed(tasks):
service, host = await coro
if host is not None:
discovered[service] = host
progress.update(task_id, advance=1, description=f"[cyan]{service}[/]")
return discovered
with progress_bar("Discovering", len(cfg.services)) as (progress, task_id):
return asyncio.run(gather_with_progress(progress, task_id))
def _snapshot_services(
cfg: Config,
services: list[str],
log_path: Path | None,
) -> Path:
"""Capture image digests with a progress bar."""
async def collect_service(service: str, now: datetime) -> list[SnapshotEntry]:
try:
return await collect_service_entries(cfg, service, now=now)
except RuntimeError:
return []
async def gather_with_progress(
progress: Progress, task_id: TaskID, now: datetime, svc_list: list[str]
) -> list[SnapshotEntry]:
# Map tasks to service names so we can update description
task_to_service = {asyncio.create_task(collect_service(s, now)): s for s in svc_list}
all_entries: list[SnapshotEntry] = []
for coro in asyncio.as_completed(list(task_to_service.keys())):
entries = await coro
all_entries.extend(entries)
# Find which service just completed (by checking done tasks)
for t, svc in task_to_service.items():
if t.done() and not hasattr(t, "_reported"):
t._reported = True # type: ignore[attr-defined]
progress.update(task_id, advance=1, description=f"[cyan]{svc}[/]")
break
return all_entries
effective_log_path = log_path or DEFAULT_LOG_PATH
now_dt = datetime.now(UTC)
now_iso = isoformat(now_dt)
with progress_bar("Capturing", len(services)) as (progress, task_id):
snapshot_entries = asyncio.run(gather_with_progress(progress, task_id, now_dt, services))
if not snapshot_entries:
msg = "No image digests were captured"
raise RuntimeError(msg)
existing_entries = load_existing_entries(effective_log_path)
merged_entries = merge_entries(existing_entries, snapshot_entries, now_iso=now_iso)
meta = {"generated_at": now_iso, "compose_dir": str(cfg.compose_dir)}
write_toml(effective_log_path, meta=meta, entries=merged_entries)
return effective_log_path
def _format_host(host: str | list[str]) -> str:
"""Format a host value for display."""
if isinstance(host, list):
return ", ".join(host)
return host
def _report_sync_changes(
added: list[str],
removed: list[str],
changed: list[tuple[str, str | list[str], str | list[str]]],
discovered: dict[str, str | list[str]],
current_state: dict[str, str | list[str]],
) -> 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}[/]")
if changed:
console.print(f"\nServices on different hosts ({len(changed)}):")
for service, 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}[/]"
)
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}[/])")
# --- Check helpers ---
def _check_ssh_connectivity(cfg: Config) -> list[str]:
"""Check SSH connectivity to all hosts. Returns list of unreachable hosts."""
# Filter out local hosts - no SSH needed
remote_hosts = [h for h in cfg.hosts if not is_local(cfg.hosts[h])]
if not remote_hosts:
return []
console.print() # Spacing before progress bar
async def check_host(host_name: str) -> tuple[str, bool]:
host = cfg.hosts[host_name]
result = await run_command(host, "echo ok", host_name, stream=False)
return host_name, result.success
async def gather_with_progress(progress: Progress, task_id: TaskID) -> list[str]:
tasks = [asyncio.create_task(check_host(h)) for h in remote_hosts]
unreachable: list[str] = []
for coro in asyncio.as_completed(tasks):
host_name, success = await coro
if not success:
unreachable.append(host_name)
progress.update(task_id, advance=1, description=f"[cyan]{host_name}[/]")
return unreachable
with progress_bar("Checking SSH connectivity", len(remote_hosts)) as (progress, task_id):
return asyncio.run(gather_with_progress(progress, task_id))
def _check_mounts_and_networks(
cfg: Config,
services: list[str],
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
"""Check mounts and networks for all services with a progress bar.
Returns (mount_errors, network_errors) where each is a list of
(service, host, missing_item) tuples.
"""
async def check_service(
service: str,
) -> tuple[str, list[tuple[str, str, str]], list[tuple[str, str, str]]]:
"""Check mounts and networks for a single service."""
host_names = cfg.get_hosts(service)
mount_errors: list[tuple[str, str, str]] = []
network_errors: list[tuple[str, str, str]] = []
# Check mounts on all hosts
paths = get_service_paths(cfg, service)
for host_name in host_names:
path_exists = await check_paths_exist(cfg, host_name, paths)
for path, found in path_exists.items():
if not found:
mount_errors.append((service, host_name, path))
# Check networks on all hosts
networks = parse_external_networks(cfg, service)
if networks:
for host_name in host_names:
net_exists = await check_networks_exist(cfg, host_name, networks)
for net, found in net_exists.items():
if not found:
network_errors.append((service, host_name, net))
return service, mount_errors, network_errors
async def gather_with_progress(
progress: Progress, task_id: TaskID
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
tasks = [asyncio.create_task(check_service(s)) for s in services]
all_mount_errors: list[tuple[str, str, str]] = []
all_network_errors: list[tuple[str, str, str]] = []
for coro in asyncio.as_completed(tasks):
service, mount_errs, net_errs = await coro
all_mount_errors.extend(mount_errs)
all_network_errors.extend(net_errs)
progress.update(task_id, advance=1, description=f"[cyan]{service}[/]")
return all_mount_errors, all_network_errors
with progress_bar("Checking mounts/networks", len(services)) as (progress, task_id):
return asyncio.run(gather_with_progress(progress, task_id))
def _report_config_status(cfg: Config) -> bool:
"""Check and report config vs disk status. Returns True if errors found."""
configured = set(cfg.services.keys())
on_disk = cfg.discover_compose_dirs()
missing_from_config = sorted(on_disk - configured)
missing_from_disk = sorted(configured - on_disk)
if missing_from_config:
console.print(f"\n[yellow]On disk but not in config[/] ({len(missing_from_config)}):")
for name in missing_from_config:
console.print(f" [yellow]+[/] [cyan]{name}[/]")
if missing_from_disk:
console.print(f"\n[red]In config but no compose file[/] ({len(missing_from_disk)}):")
for name in missing_from_disk:
console.print(f" [red]-[/] [cyan]{name}[/]")
if not missing_from_config and not missing_from_disk:
console.print("[green]✓[/] Config matches disk")
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."""
state = load_state(cfg)
configured = set(cfg.services.keys())
tracked = set(state.keys())
orphaned = sorted(tracked - configured)
if orphaned:
console.print("\n[yellow]Orphaned services[/] (in state but not in config):")
console.print("[dim]These may still be running. Use 'docker compose down' to stop them.[/]")
for name in orphaned:
host = state[name]
host_str = ", ".join(host) if isinstance(host, list) else host
console.print(f" [yellow]![/] [cyan]{name}[/] on [magenta]{host_str}[/]")
return True
return False
def _report_traefik_status(cfg: Config, services: list[str]) -> None:
"""Check and report traefik label status."""
try:
_, warnings = generate_traefik_config(cfg, services, check_all=True)
except (FileNotFoundError, ValueError):
return
if warnings:
console.print(f"\n[yellow]Traefik issues[/] ({len(warnings)}):")
for warning in warnings:
console.print(f" [yellow]![/] {warning}")
else:
console.print("[green]✓[/] Traefik labels valid")
def _report_mount_errors(mount_errors: list[tuple[str, str, str]]) -> None:
"""Report mount errors grouped by service."""
by_service: dict[str, list[tuple[str, str]]] = {}
for svc, host, path in mount_errors:
by_service.setdefault(svc, []).append((host, path))
console.print(f"[red]Missing mounts[/] ({len(mount_errors)}):")
for svc, items in sorted(by_service.items()):
host = items[0][0]
paths = [p for _, p in items]
console.print(f" [cyan]{svc}[/] on [magenta]{host}[/]:")
for path in paths:
console.print(f" [red]✗[/] {path}")
def _report_network_errors(network_errors: list[tuple[str, str, str]]) -> None:
"""Report network errors grouped by service."""
by_service: dict[str, list[tuple[str, str]]] = {}
for svc, host, net in network_errors:
by_service.setdefault(svc, []).append((host, net))
console.print(f"[red]Missing networks[/] ({len(network_errors)}):")
for svc, items in sorted(by_service.items()):
host = items[0][0]
networks = [n for _, n in items]
console.print(f" [cyan]{svc}[/] on [magenta]{host}[/]:")
for net in networks:
console.print(f" [red]✗[/] {net}")
def _report_ssh_status(unreachable_hosts: list[str]) -> bool:
"""Report SSH connectivity status. Returns True if there are errors."""
if unreachable_hosts:
console.print(f"[red]Unreachable hosts[/] ({len(unreachable_hosts)}):")
for host in sorted(unreachable_hosts):
console.print(f" [red]✗[/] [magenta]{host}[/]")
return True
console.print("[green]✓[/] All hosts reachable")
return False
def _report_host_compatibility(
compat: dict[str, tuple[int, int, list[str]]],
assigned_hosts: list[str],
) -> None:
"""Report host compatibility for a service."""
for host_name, (found, total, missing) in sorted(compat.items()):
is_assigned = host_name in assigned_hosts
marker = " [dim](assigned)[/]" if is_assigned else ""
if found == total:
console.print(f" [green]✓[/] [magenta]{host_name}[/] {found}/{total}{marker}")
else:
preview = ", ".join(missing[:_MISSING_PATH_PREVIEW_LIMIT])
if len(missing) > _MISSING_PATH_PREVIEW_LIMIT:
preview += f", +{len(missing) - _MISSING_PATH_PREVIEW_LIMIT} more"
console.print(
f" [red]✗[/] [magenta]{host_name}[/] {found}/{total} "
f"[dim](missing: {preview})[/]{marker}"
)
def _run_remote_checks(cfg: Config, svc_list: list[str], *, show_host_compat: bool) -> bool:
"""Run SSH-based checks for mounts, networks, and host compatibility.
Returns True if any errors were found.
"""
has_errors = False
# Check SSH connectivity first
if _report_ssh_status(_check_ssh_connectivity(cfg)):
has_errors = True
console.print() # Spacing before mounts/networks check
# Check mounts and networks
mount_errors, network_errors = _check_mounts_and_networks(cfg, svc_list)
if mount_errors:
_report_mount_errors(mount_errors)
has_errors = True
if network_errors:
_report_network_errors(network_errors)
has_errors = True
if not mount_errors and not network_errors:
console.print("[green]✓[/] All mounts and networks 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)
_report_host_compatibility(compat, assigned_hosts)
return has_errors
# Default network settings for cross-host Docker networking
_DEFAULT_NETWORK_NAME = "mynetwork"
_DEFAULT_NETWORK_SUBNET = "172.20.0.0/16"
_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,
output: Annotated[
Path | None,
typer.Option(
"--output",
"-o",
help="Write Traefik file-provider YAML to this path (stdout if omitted)",
),
] = None,
config: ConfigOption = None,
) -> None:
"""Generate a Traefik file-provider fragment from compose Traefik labels."""
svc_list, cfg = get_services(services or [], all_services, config)
try:
dynamic, warnings = generate_traefik_config(cfg, svc_list)
except (FileNotFoundError, ValueError) as exc:
err_console.print(f"[red]✗[/] {exc}")
raise typer.Exit(1) from exc
rendered = render_traefik_config(dynamic)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(rendered)
console.print(f"[green]✓[/] Traefik config written to {output}")
else:
console.print(rendered)
for warning in warnings:
err_console.print(f"[yellow]![/] {warning}")
@app.command(rich_help_panel="Configuration")
def sync(
config: ConfigOption = None,
log_path: LogPathOption = None,
dry_run: Annotated[
bool,
typer.Option("--dry-run", "-n", help="Show what would be synced without writing"),
] = False,
) -> None:
"""Sync local state with running services.
Discovers which services are running on which hosts, updates the state
file, and captures image digests. Combines service discovery with
image snapshot into a single command.
"""
cfg = load_config_or_exit(config)
current_state = load_state(cfg)
discovered = _discover_services(cfg)
# Calculate changes
added = [s for s in discovered if s not in current_state]
removed = [s for s in current_state if s not in discovered]
changed = [
(s, current_state[s], discovered[s])
for s in discovered
if s in current_state and current_state[s] != discovered[s]
]
# Report state changes
state_changed = bool(added or removed or changed)
if state_changed:
_report_sync_changes(added, removed, changed, discovered, current_state)
else:
console.print("[green]✓[/] State is already in sync.")
if dry_run:
console.print("\n[dim](dry-run: no changes made)[/]")
return
# Update state file
if state_changed:
save_state(cfg, discovered)
console.print(f"\n[green]✓[/] State updated: {len(discovered)} services tracked.")
# Capture image digests for running services
if discovered:
try:
path = _snapshot_services(cfg, list(discovered.keys()), log_path)
console.print(f"[green]✓[/] Digests written to {path}")
except RuntimeError as exc:
err_console.print(f"[yellow]![/] {exc}")
@app.command(rich_help_panel="Configuration")
def check(
services: ServicesArg = None,
local: Annotated[
bool,
typer.Option("--local", help="Skip SSH-based checks (faster)"),
] = False,
config: ConfigOption = None,
) -> 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.
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)
invalid = [s for s in svc_list if s not in cfg.services]
if invalid:
for svc in invalid:
err_console.print(f"[red]✗[/] Service '{svc}' not found in config")
raise typer.Exit(1)
show_host_compat = True
else:
svc_list = list(cfg.services.keys())
show_host_compat = False
# Run checks
has_errors = _report_config_status(cfg)
_report_traefik_status(cfg, svc_list)
if not local and _run_remote_checks(cfg, svc_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):
has_errors = True
if has_errors:
raise typer.Exit(1)
@app.command("init-network", rich_help_panel="Configuration")
def init_network(
hosts: Annotated[
list[str] | None,
typer.Argument(help="Hosts to create network on (default: all)"),
] = None,
network: Annotated[
str,
typer.Option("--network", "-n", help="Network name"),
] = _DEFAULT_NETWORK_NAME,
subnet: Annotated[
str,
typer.Option("--subnet", "-s", help="Network subnet"),
] = _DEFAULT_NETWORK_SUBNET,
gateway: Annotated[
str,
typer.Option("--gateway", "-g", help="Network gateway"),
] = _DEFAULT_NETWORK_GATEWAY,
config: ConfigOption = None,
) -> None:
"""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.
"""
cfg = load_config_or_exit(config)
target_hosts = list(hosts) if hosts else list(cfg.hosts.keys())
invalid = [h for h in target_hosts if h not in cfg.hosts]
if invalid:
for h in invalid:
err_console.print(f"[red]✗[/] Host '{h}' not found in config")
raise typer.Exit(1)
async def create_network_on_host(host_name: str) -> CommandResult:
host = cfg.hosts[host_name]
# Check if network already exists
check_cmd = f"docker network inspect '{network}' >/dev/null 2>&1"
check_result = await run_command(host, check_cmd, host_name, stream=False)
if check_result.success:
console.print(f"[cyan]\\[{host_name}][/] Network '{network}' already exists")
return CommandResult(service=host_name, exit_code=0, success=True)
# Create the network
create_cmd = (
f"docker network create "
f"--driver bridge "
f"--subnet '{subnet}' "
f"--gateway '{gateway}' "
f"'{network}'"
)
result = await run_command(host, create_cmd, host_name, stream=False)
if result.success:
console.print(f"[cyan]\\[{host_name}][/] [green]✓[/] Created network '{network}'")
else:
err_console.print(
f"[cyan]\\[{host_name}][/] [red]✗[/] Failed to create network: "
f"{result.stderr.strip()}"
)
return result
async def run_all() -> list[CommandResult]:
return await asyncio.gather(*[create_network_on_host(h) for h in target_hosts])
results = run_async(run_all())
failed = [r for r in results if not r.success]
if failed:
raise typer.Exit(1)

View File

@@ -0,0 +1,235 @@
"""Monitoring commands: logs, ps, stats."""
from __future__ import annotations
import asyncio
import contextlib
from typing import TYPE_CHECKING, Annotated
import typer
from rich.progress import Progress, TaskID # noqa: TC002
from rich.table import Table
from compose_farm.cli.app import app
from compose_farm.cli.common import (
_STATS_PREVIEW_LIMIT,
AllOption,
ConfigOption,
HostOption,
ServicesArg,
get_services,
load_config_or_exit,
progress_bar,
report_results,
run_async,
)
from compose_farm.config import Config # noqa: TC001
from compose_farm.console import console, err_console
from compose_farm.executor import run_command, run_on_services
from compose_farm.state import get_services_needing_migration, load_state
if TYPE_CHECKING:
from collections.abc import Mapping
def _group_services_by_host(
services: 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).
For multi-host services (list or "all"), the service appears in multiple host lists.
"""
by_host: dict[str, list[str]] = {h: [] for h in hosts}
for service, host_value in services.items():
if isinstance(host_value, list):
# Explicit list of hosts
for host_name in host_value:
if host_name in by_host:
by_host[host_name].append(service)
elif host_value == "all" and all_hosts:
# "all" keyword - add to all hosts
for host_name in all_hosts:
if host_name in by_host:
by_host[host_name].append(service)
elif host_value in by_host:
# Single host
by_host[host_value].append(service)
return by_host
def _get_container_counts(cfg: Config) -> dict[str, int]:
"""Get container counts from all hosts with a progress bar."""
async def get_count(host_name: str) -> tuple[str, int]:
host = cfg.hosts[host_name]
result = await run_command(host, "docker ps -q | wc -l", host_name, stream=False)
count = 0
if result.success:
with contextlib.suppress(ValueError):
count = int(result.stdout.strip())
return host_name, count
async def gather_with_progress(progress: Progress, task_id: TaskID) -> dict[str, int]:
hosts = list(cfg.hosts.keys())
tasks = [asyncio.create_task(get_count(h)) for h in hosts]
results: dict[str, int] = {}
for coro in asyncio.as_completed(tasks):
host_name, count = await coro
results[host_name] = count
progress.update(task_id, advance=1, description=f"[cyan]{host_name}[/]")
return results
with progress_bar("Querying hosts", len(cfg.hosts)) as (progress, task_id):
return asyncio.run(gather_with_progress(progress, task_id))
def _build_host_table(
cfg: Config,
services_by_host: dict[str, list[str]],
running_by_host: dict[str, list[str]],
container_counts: dict[str, int],
*,
show_containers: bool,
) -> Table:
"""Build the hosts table."""
table = Table(title="Hosts", show_header=True, header_style="bold cyan")
table.add_column("Host", style="magenta")
table.add_column("Address")
table.add_column("Configured", justify="right")
table.add_column("Running", justify="right")
if show_containers:
table.add_column("Containers", justify="right")
for host_name in sorted(cfg.hosts.keys()):
host = cfg.hosts[host_name]
configured = len(services_by_host[host_name])
running = len(running_by_host[host_name])
row = [
host_name,
host.address,
str(configured),
str(running) if running > 0 else "[dim]0[/]",
]
if show_containers:
count = container_counts.get(host_name, 0)
row.append(str(count) if count > 0 else "[dim]0[/]")
table.add_row(*row)
return table
def _build_summary_table(
cfg: Config, state: dict[str, str | list[str]], pending: list[str]
) -> Table:
"""Build the summary table."""
on_disk = cfg.discover_compose_dirs()
table = Table(title="Summary", show_header=False)
table.add_column("Label", style="dim")
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("Compose files on disk", str(len(on_disk)))
if pending:
preview = ", ".join(pending[:_STATS_PREVIEW_LIMIT])
suffix = "..." if len(pending) > _STATS_PREVIEW_LIMIT else ""
table.add_row("Pending migrations", f"[yellow]{len(pending)}[/] ({preview}{suffix})")
else:
table.add_row("Pending migrations", "[green]0[/]")
return table
# --- Command functions ---
@app.command(rich_help_panel="Monitoring")
def logs(
services: ServicesArg = None,
all_services: AllOption = False,
host: HostOption = None,
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs")] = False,
tail: Annotated[
int | None,
typer.Option("--tail", "-n", help="Number of lines (default: 20 for --all, 100 otherwise)"),
] = None,
config: ConfigOption = None,
) -> None:
"""Show service logs."""
if all_services and host is not None:
err_console.print("[red]✗[/] Cannot use --all and --host together")
raise typer.Exit(1)
cfg = load_config_or_exit(config)
# Determine service list based on options
if host is not None:
if host not in cfg.hosts:
err_console.print(f"[red]✗[/] Host '{host}' not found in config")
raise typer.Exit(1)
# Include services where host is in the list of configured hosts
svc_list = [s for s in cfg.services if host in cfg.get_hosts(s)]
if not svc_list:
err_console.print(f"[yellow]![/] No services configured for host '{host}'")
return
else:
svc_list, cfg = get_services(services or [], all_services, config)
# 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)
cmd = f"logs --tail {effective_tail}"
if follow:
cmd += " -f"
results = run_async(run_on_services(cfg, svc_list, cmd))
report_results(results)
@app.command(rich_help_panel="Monitoring")
def ps(
config: ConfigOption = None,
) -> None:
"""Show status of all services."""
cfg = load_config_or_exit(config)
results = run_async(run_on_services(cfg, list(cfg.services.keys()), "ps"))
report_results(results)
@app.command(rich_help_panel="Monitoring")
def stats(
live: Annotated[
bool,
typer.Option("--live", "-l", help="Query Docker for live container stats"),
] = False,
config: ConfigOption = None,
) -> None:
"""Show overview statistics for hosts and services.
Without --live: Shows config/state info (hosts, services, 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)
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)
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
)
console.print(host_table)
console.print()
console.print(_build_summary_table(cfg, state, pending))

View File

@@ -18,10 +18,10 @@ if TYPE_CHECKING:
from .config import Config
# Port parsing constants
SINGLE_PART = 1
PUBLISHED_TARGET_PARTS = 2
HOST_PUBLISHED_PARTS = 3
MIN_VOLUME_PARTS = 2
_SINGLE_PART = 1
_PUBLISHED_TARGET_PARTS = 2
_HOST_PUBLISHED_PARTS = 3
_MIN_VOLUME_PARTS = 2
_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}")
@@ -34,7 +34,7 @@ class PortMapping:
published: int | None
def load_env(compose_path: Path) -> dict[str, str]:
def _load_env(compose_path: Path) -> dict[str, str]:
"""Load environment variables for compose interpolation.
Reads from .env file in the same directory as compose file,
@@ -59,7 +59,7 @@ def load_env(compose_path: Path) -> dict[str, str]:
return env
def interpolate(value: str, env: dict[str, str]) -> str:
def _interpolate(value: str, env: dict[str, str]) -> str:
"""Perform ${VAR} and ${VAR:-default} interpolation."""
def replace(match: re.Match[str]) -> str:
@@ -73,7 +73,7 @@ def interpolate(value: str, env: dict[str, str]) -> str:
return _VAR_PATTERN.sub(replace, value)
def parse_ports(raw: Any, env: dict[str, str]) -> list[PortMapping]: # noqa: PLR0912
def _parse_ports(raw: Any, env: dict[str, str]) -> list[PortMapping]: # noqa: PLR0912
"""Parse port specifications from compose file.
Handles string formats like "8080", "8080:80", "0.0.0.0:8080:80",
@@ -87,18 +87,22 @@ def parse_ports(raw: Any, env: dict[str, str]) -> list[PortMapping]: # noqa: PL
for item in items:
if isinstance(item, str):
interpolated = interpolate(item, env)
interpolated = _interpolate(item, env)
port_spec, _, _ = interpolated.partition("/")
parts = port_spec.split(":")
published: int | None = None
target: int | None = None
if len(parts) == SINGLE_PART and parts[0].isdigit():
if len(parts) == _SINGLE_PART and parts[0].isdigit():
target = int(parts[0])
elif len(parts) == PUBLISHED_TARGET_PARTS and parts[0].isdigit() and parts[1].isdigit():
elif (
len(parts) == _PUBLISHED_TARGET_PARTS and parts[0].isdigit() and parts[1].isdigit()
):
published = int(parts[0])
target = int(parts[1])
elif len(parts) == HOST_PUBLISHED_PARTS and parts[-2].isdigit() and parts[-1].isdigit():
elif (
len(parts) == _HOST_PUBLISHED_PARTS and parts[-2].isdigit() and parts[-1].isdigit()
):
published = int(parts[-2])
target = int(parts[-1])
@@ -107,7 +111,7 @@ def parse_ports(raw: Any, env: dict[str, str]) -> list[PortMapping]: # noqa: PL
elif isinstance(item, dict):
target_raw = item.get("target")
if isinstance(target_raw, str):
target_raw = interpolate(target_raw, env)
target_raw = _interpolate(target_raw, env)
if target_raw is None:
continue
try:
@@ -117,7 +121,7 @@ def parse_ports(raw: Any, env: dict[str, str]) -> list[PortMapping]: # noqa: PL
published_raw = item.get("published")
if isinstance(published_raw, str):
published_raw = interpolate(published_raw, env)
published_raw = _interpolate(published_raw, env)
published_val: int | None
try:
published_val = int(str(published_raw)) if published_raw is not None else None
@@ -144,14 +148,14 @@ def _parse_volume_item(
) -> str | None:
"""Parse a single volume item and return host path if it's a bind mount."""
if isinstance(item, str):
interpolated = interpolate(item, env)
interpolated = _interpolate(item, env)
parts = interpolated.split(":")
if len(parts) >= MIN_VOLUME_PARTS:
if len(parts) >= _MIN_VOLUME_PARTS:
return _resolve_host_path(parts[0], compose_dir)
elif isinstance(item, dict) and item.get("type") == "bind":
source = item.get("source")
if source:
interpolated = interpolate(str(source), env)
interpolated = _interpolate(str(source), env)
return _resolve_host_path(interpolated, compose_dir)
return None
@@ -166,7 +170,7 @@ def parse_host_volumes(config: Config, service: str) -> list[str]:
if not compose_path.exists():
return []
env = load_env(compose_path)
env = _load_env(compose_path)
compose_data = yaml.safe_load(compose_path.read_text()) or {}
raw_services = compose_data.get("services", {})
if not isinstance(raw_services, dict):
@@ -234,7 +238,7 @@ def load_compose_services(
message = f"[{stack}] Compose file not found: {compose_path}"
raise FileNotFoundError(message)
env = load_env(compose_path)
env = _load_env(compose_path)
compose_data = yaml.safe_load(compose_path.read_text()) or {}
raw_services = compose_data.get("services", {})
if not isinstance(raw_services, dict):
@@ -248,7 +252,7 @@ def normalize_labels(raw: Any, env: dict[str, str]) -> dict[str, str]:
return {}
if isinstance(raw, dict):
return {
interpolate(str(k), env): interpolate(str(v), env)
_interpolate(str(k), env): _interpolate(str(v), env)
for k, v in raw.items()
if k is not None
}
@@ -258,8 +262,8 @@ def normalize_labels(raw: Any, env: dict[str, str]) -> dict[str, str]:
if not isinstance(item, str) or "=" not in item:
continue
key_raw, value_raw = item.split("=", 1)
key = interpolate(key_raw.strip(), env)
value = interpolate(value_raw.strip(), env)
key = _interpolate(key_raw.strip(), env)
value = _interpolate(value_raw.strip(), env)
labels[key] = value
return labels
return {}
@@ -278,5 +282,5 @@ def get_ports_for_service(
if ref_service in all_services:
ref_def = all_services[ref_service]
if isinstance(ref_def, dict):
return parse_ports(ref_def.get("ports"), env)
return parse_ports(definition.get("ports"), env)
return _parse_ports(ref_def.get("ports"), env)
return _parse_ports(definition.get("ports"), env)

View File

@@ -3,12 +3,18 @@
from __future__ import annotations
import getpass
import os
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, model_validator
def xdg_config_home() -> Path:
"""Get XDG config directory, respecting XDG_CONFIG_HOME env var."""
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
class Host(BaseModel):
"""SSH host configuration."""
@@ -22,7 +28,7 @@ class Config(BaseModel):
compose_dir: Path = Path("/opt/compose")
hosts: dict[str, Host]
services: dict[str, str] # service_name -> host_name
services: dict[str, str | list[str]] # service_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)
config_path: Path = Path() # Set by load_config()
@@ -32,20 +38,60 @@ class Config(BaseModel):
return self.config_path.parent / "compose-farm-state.yaml"
@model_validator(mode="after")
def validate_service_hosts(self) -> Config:
"""Ensure all services reference valid hosts."""
for service, host_name in self.services.items():
if host_name not in self.hosts:
msg = f"Service '{service}' references unknown host '{host_name}'"
raise ValueError(msg)
def validate_hosts_and_services(self) -> Config:
"""Validate host names and service 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():
# Validate list configurations
if isinstance(host_value, list):
if not host_value:
msg = f"Service '{service}' has empty host list"
raise ValueError(msg)
if len(host_value) != len(set(host_value)):
msg = f"Service '{service}' has duplicate hosts in list"
raise ValueError(msg)
# Validate all referenced hosts exist
host_names = self.get_hosts(service)
for host_name in host_names:
if host_name not in self.hosts:
msg = f"Service '{service}' references unknown host '{host_name}'"
raise ValueError(msg)
return self
def get_host(self, service: str) -> Host:
"""Get host config for a service."""
def get_hosts(self, service: str) -> list[str]:
"""Get list of host names for a service.
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}"
raise ValueError(msg)
return self.hosts[self.services[service]]
host_value = self.services[service]
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 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}"
raise ValueError(msg)
host_names = self.get_hosts(service)
return self.hosts[host_names[0]]
def get_compose_path(self, service: str) -> Path:
"""Get compose file path for a service.
@@ -102,17 +148,20 @@ def load_config(path: Path | None = None) -> Config:
"""Load configuration from YAML file.
Search order:
1. Explicit path if provided
2. ./compose-farm.yaml
3. ~/.config/compose-farm/compose-farm.yaml
1. Explicit path if provided via --config
2. CF_CONFIG environment variable
3. ./compose-farm.yaml
4. $XDG_CONFIG_HOME/compose-farm/compose-farm.yaml (defaults to ~/.config)
"""
search_paths = [
Path("compose-farm.yaml"),
Path.home() / ".config" / "compose-farm" / "compose-farm.yaml",
xdg_config_home() / "compose-farm" / "compose-farm.yaml",
]
if path:
config_path = path
elif env_path := os.environ.get("CF_CONFIG"):
config_path = Path(env_path)
else:
config_path = None
for p in search_paths:
@@ -124,6 +173,13 @@ def load_config(path: Path | None = None) -> Config:
msg = f"Config file not found. Searched: {', '.join(str(p) for p in search_paths)}"
raise FileNotFoundError(msg)
if config_path.is_dir():
msg = (
f"Config path is a directory, not a file: {config_path}\n"
"This often happens when Docker creates an empty directory for a missing mount."
)
raise FileNotFoundError(msg)
with config_path.open() as f:
raw = yaml.safe_load(f)

View File

@@ -0,0 +1,6 @@
"""Shared console instances for consistent output styling."""
from rich.console import Console
console = Console(highlight=False)
err_console = Console(stderr=True, highlight=False)

View File

@@ -0,0 +1,89 @@
# Compose Farm configuration
# Documentation: https://github.com/basnijholt/compose-farm
#
# This file configures compose-farm to manage Docker Compose services
# across multiple hosts via SSH.
#
# Place this file at:
# - ./compose-farm.yaml (current directory)
# - ~/.config/compose-farm/compose-farm.yaml
# - Or specify with: cf --config /path/to/config.yaml
# - Or set CF_CONFIG environment variable
# ------------------------------------------------------------------------------
# compose_dir: Directory containing service 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).
#
compose_dir: /opt/compose
# ------------------------------------------------------------------------------
# hosts: SSH connection details for each host
# ------------------------------------------------------------------------------
# Simple form:
# hostname: ip-or-fqdn
#
# Full form:
# hostname:
# address: ip-or-fqdn
# user: ssh-username # default: current user
# port: 22 # default: 22
#
# Note: "all" is a reserved keyword and cannot be used as a host name.
#
hosts:
# Example: simple form (uses current user, port 22)
server1: 192.168.1.10
# Example: full form with explicit user
server2:
address: 192.168.1.20
user: admin
# Example: full form with custom port
server3:
address: 192.168.1.30
user: root
port: 2222
# ------------------------------------------------------------------------------
# services: Map service names to their target host(s)
# ------------------------------------------------------------------------------
# Each service name must match a subdirectory in compose_dir.
#
# Single host:
# service-name: hostname
#
# Multiple hosts (explicit list):
# service-name: [host1, host2]
#
# All hosts:
# service-name: all
#
services:
# Example: service runs on a single host
nginx: server1
postgres: server2
# Example: service runs on multiple specific hosts
# prometheus: [server1, server2]
# Example: service runs on ALL hosts (e.g., monitoring agents)
# node-exporter: all
# ------------------------------------------------------------------------------
# traefik_file: (optional) Auto-generate Traefik file-provider config
# ------------------------------------------------------------------------------
# When set, compose-farm automatically regenerates this file after
# up/down/restart/update commands. Traefik watches this file for changes.
#
# traefik_file: /opt/compose/traefik/dynamic.d/compose-farm.yml
# ------------------------------------------------------------------------------
# traefik_service: (optional) Service name running Traefik
# ------------------------------------------------------------------------------
# When generating traefik_file, services on the same host as Traefik are
# skipped (they're handled by Traefik's Docker provider directly).
#
# traefik_service: traefik

View File

@@ -10,14 +10,14 @@ from functools import lru_cache
from typing import TYPE_CHECKING, Any
import asyncssh
from rich.console import Console
from rich.markup import escape
if TYPE_CHECKING:
from .config import Config, Host
from .console import console, err_console
_console = Console(highlight=False)
_err_console = Console(stderr=True, highlight=False)
if TYPE_CHECKING:
from collections.abc import Callable
from .config import Config, Host
LOCAL_ADDRESSES = frozenset({"local", "localhost", "127.0.0.1", "::1"})
_DEFAULT_SSH_PORT = 22
@@ -54,7 +54,7 @@ class CommandResult:
stderr: str = ""
def _is_local(host: Host) -> bool:
def is_local(host: Host) -> bool:
"""Check if host should run locally (no SSH)."""
addr = host.address.lower()
if addr in LOCAL_ADDRESSES:
@@ -100,14 +100,14 @@ async def _run_local_command(
*,
is_stderr: bool = False,
) -> None:
console = _err_console if is_stderr else _console
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
console.print(f"[cyan]\\[{prefix}][/] {escape(text)}", end="")
out.print(f"[cyan]\\[{prefix}][/] {escape(text)}", end="")
await asyncio.gather(
read_stream(proc.stdout, service),
@@ -129,7 +129,7 @@ async def _run_local_command(
stderr=stderr_data.decode() if stderr_data else "",
)
except OSError as e:
_err_console.print(f"[cyan]\\[{service}][/] [red]Local error:[/] {e}")
err_console.print(f"[cyan]\\[{service}][/] [red]Local error:[/] {e}")
return CommandResult(service=service, exit_code=1, success=False)
@@ -173,10 +173,10 @@ async def _run_ssh_command(
*,
is_stderr: bool = False,
) -> None:
console = _err_console if is_stderr else _console
out = err_console if is_stderr else console
async for line in reader:
if line.strip(): # Skip empty lines
console.print(f"[cyan]\\[{prefix}][/] {escape(line)}", end="")
out.print(f"[cyan]\\[{prefix}][/] {escape(line)}", end="")
await asyncio.gather(
read_stream(proc.stdout, service),
@@ -198,7 +198,7 @@ async def _run_ssh_command(
stderr=stderr_data,
)
except (OSError, asyncssh.Error) as e:
_err_console.print(f"[cyan]\\[{service}][/] [red]SSH error:[/] {e}")
err_console.print(f"[cyan]\\[{service}][/] [red]SSH error:[/] {e}")
return CommandResult(service=service, exit_code=1, success=False)
@@ -211,7 +211,7 @@ async def run_command(
raw: bool = False,
) -> CommandResult:
"""Run a command on a host (locally or via SSH)."""
if _is_local(host):
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)
@@ -262,15 +262,13 @@ async def run_on_services(
) -> list[CommandResult]:
"""Run a docker compose command on multiple services in parallel.
For multi-host services, runs on all configured hosts.
Note: raw=True only makes sense for single-service operations.
"""
tasks = [
run_compose(config, service, compose_cmd, stream=stream, raw=raw) for service in services
]
return await asyncio.gather(*tasks)
return await run_sequential_on_services(config, services, [compose_cmd], stream=stream, raw=raw)
async def run_sequential_commands(
async def _run_sequential_commands(
config: Config,
service: str,
commands: list[str],
@@ -286,6 +284,40 @@ async def run_sequential_commands(
return CommandResult(service=service, exit_code=0, success=True)
async def _run_sequential_commands_multi_host(
config: Config,
service: str,
commands: list[str],
*,
stream: bool = True,
raw: bool = False,
) -> list[CommandResult]:
"""Run multiple compose commands sequentially for a multi-host service.
Commands are run sequentially, but each command runs on all hosts in parallel.
"""
host_names = config.get_hosts(service)
compose_path = config.get_compose_path(service)
final_results: list[CommandResult] = []
for cmd in commands:
command = f"docker compose -f {compose_path} {cmd}"
tasks = []
for host_name in host_names:
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))
results = await asyncio.gather(*tasks)
final_results = list(results)
# Check if any failed
if any(not r.success for r in results):
return final_results
return final_results
async def run_sequential_on_services(
config: Config,
services: list[str],
@@ -296,13 +328,38 @@ async def run_sequential_on_services(
) -> list[CommandResult]:
"""Run sequential commands on multiple services in parallel.
For multi-host services, runs on all configured hosts.
Note: raw=True only makes sense for single-service operations.
"""
tasks = [
run_sequential_commands(config, service, commands, stream=stream, raw=raw)
for service in services
]
return await asyncio.gather(*tasks)
# Separate multi-host and single-host services for type-safe gathering
multi_host_tasks = []
single_host_tasks = []
for service in services:
if config.is_multi_host(service):
multi_host_tasks.append(
_run_sequential_commands_multi_host(
config, service, commands, stream=stream, raw=raw
)
)
else:
single_host_tasks.append(
_run_sequential_commands(config, service, commands, stream=stream, raw=raw)
)
# Gather results separately to maintain type safety
flat_results: list[CommandResult] = []
if multi_host_tasks:
multi_results = await asyncio.gather(*multi_host_tasks)
for result_list in multi_results:
flat_results.extend(result_list)
if single_host_tasks:
single_results = await asyncio.gather(*single_host_tasks)
flat_results.extend(single_results)
return flat_results
async def check_service_running(
@@ -322,6 +379,37 @@ async def check_service_running(
return result.success and bool(result.stdout.strip())
async def _batch_check_existence(
config: Config,
host_name: str,
items: list[str],
cmd_template: Callable[[str], str],
context: str,
) -> dict[str, bool]:
"""Check existence of multiple items on a host using a command template."""
if not items:
return {}
host = config.hosts[host_name]
checks = []
for item in items:
escaped = item.replace("'", "'\\''")
checks.append(cmd_template(escaped))
command = "; ".join(checks)
result = await run_command(host, command, context, stream=False)
exists: dict[str, bool] = dict.fromkeys(items, False)
for raw_line in result.stdout.splitlines():
line = raw_line.strip()
if line.startswith("Y:"):
exists[line[2:]] = True
elif line.startswith("N:"):
exists[line[2:]] = False
return exists
async def check_paths_exist(
config: Config,
host_name: str,
@@ -331,31 +419,13 @@ async def check_paths_exist(
Returns a dict mapping path -> exists.
"""
if not paths:
return {}
host = config.hosts[host_name]
# Build a command that checks all paths efficiently
# Using a subshell to check each path and report Y/N
checks = []
for p in paths:
# Escape single quotes in path
escaped = p.replace("'", "'\\''")
checks.append(f"test -e '{escaped}' && echo 'Y:{escaped}' || echo 'N:{escaped}'")
command = "; ".join(checks)
result = await run_command(host, command, "mount-check", stream=False)
exists: dict[str, bool] = dict.fromkeys(paths, False)
for raw_line in result.stdout.splitlines():
line = raw_line.strip()
if line.startswith("Y:"):
exists[line[2:]] = True
elif line.startswith("N:"):
exists[line[2:]] = False
return exists
return await _batch_check_existence(
config,
host_name,
paths,
lambda esc: f"test -e '{esc}' && echo 'Y:{esc}' || echo 'N:{esc}'",
"mount-check",
)
async def check_networks_exist(
@@ -367,29 +437,12 @@ async def check_networks_exist(
Returns a dict mapping network_name -> exists.
"""
if not networks:
return {}
host = config.hosts[host_name]
# Check each network via docker network inspect
checks = []
for net in networks:
escaped = net.replace("'", "'\\''")
checks.append(
f"docker network inspect '{escaped}' >/dev/null 2>&1 "
f"&& echo 'Y:{escaped}' || echo 'N:{escaped}'"
)
command = "; ".join(checks)
result = await run_command(host, command, "network-check", stream=False)
exists: dict[str, bool] = dict.fromkeys(networks, False)
for raw_line in result.stdout.splitlines():
line = raw_line.strip()
if line.startswith("Y:"):
exists[line[2:]] = True
elif line.startswith("N:"):
exists[line[2:]] = False
return exists
return await _batch_check_existence(
config,
host_name,
networks,
lambda esc: (
f"docker network inspect '{esc}' >/dev/null 2>&1 && echo 'Y:{esc}' || echo 'N:{esc}'"
),
"network-check",
)

View File

@@ -6,20 +6,21 @@ import json
import tomllib
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from .config import xdg_config_home
from .executor import run_compose
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterable
from pathlib import Path
from .config import Config
from .executor import CommandResult
DEFAULT_LOG_PATH = Path.home() / ".config" / "compose-farm" / "dockerfarm-log.toml"
DIGEST_HEX_LENGTH = 64
DEFAULT_LOG_PATH = xdg_config_home() / "compose-farm" / "dockerfarm-log.toml"
_DIGEST_HEX_LENGTH = 64
@dataclass(frozen=True)
@@ -46,7 +47,8 @@ class SnapshotEntry:
}
def _isoformat(dt: datetime) -> str:
def isoformat(dt: datetime) -> str:
"""Format a datetime as an ISO 8601 string with Z suffix for UTC."""
return dt.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -95,13 +97,13 @@ def _extract_image_fields(record: dict[str, Any]) -> tuple[str, str]:
or ""
)
if digest and not digest.startswith("sha256:") and len(digest) == DIGEST_HEX_LENGTH:
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_service_entries(
config: Config,
service: str,
*,
@@ -116,7 +118,8 @@ async def _collect_service_entries(
raise RuntimeError(error)
records = _parse_images_output(result.stdout)
host_name = config.services[service]
# 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)
entries: list[SnapshotEntry] = []
@@ -137,19 +140,21 @@ async def _collect_service_entries(
return entries
def _load_existing_entries(log_path: Path) -> list[dict[str, str]]:
def load_existing_entries(log_path: Path) -> list[dict[str, str]]:
"""Load existing snapshot entries from a TOML log file."""
if not log_path.exists():
return []
data = tomllib.loads(log_path.read_text())
return list(data.get("entries", []))
def _merge_entries(
def merge_entries(
existing: Iterable[dict[str, str]],
new_entries: Iterable[SnapshotEntry],
*,
now_iso: str,
) -> 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
}
@@ -162,7 +167,8 @@ def _merge_entries(
return list(merged.values())
def _write_toml(log_path: Path, *, meta: dict[str, str], entries: list[dict[str, str]]) -> None:
def write_toml(log_path: Path, *, meta: dict[str, str], entries: list[dict[str, str]]) -> None:
"""Write snapshot entries to a TOML log file."""
lines: list[str] = ["[meta]"]
lines.extend(f'{key} = "{_escape(meta[key])}"' for key in sorted(meta))
@@ -187,45 +193,3 @@ def _write_toml(log_path: Path, *, meta: dict[str, str], entries: list[dict[str,
content = "\n".join(lines).rstrip() + "\n"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(content)
async def snapshot_services(
config: Config,
services: list[str],
*,
log_path: Path | None = None,
now: datetime | None = None,
run_compose_fn: Callable[..., Awaitable[CommandResult]] = run_compose,
) -> Path:
"""Capture current image digests for services and write them to a TOML log.
- Preserves the earliest `first_seen` per (service, host, digest)
- Updates `last_seen` for digests observed in this snapshot
- Leaves untouched digests that were not part of this run (history is kept)
"""
if not services:
error = "No services specified for snapshot"
raise RuntimeError(error)
log_path = log_path or DEFAULT_LOG_PATH
now_dt = now or datetime.now(UTC)
now_iso = _isoformat(now_dt)
existing_entries = _load_existing_entries(log_path)
snapshot_entries: list[SnapshotEntry] = []
for service in services:
snapshot_entries.extend(
await _collect_service_entries(
config, service, now=now_dt, run_compose_fn=run_compose_fn
)
)
if not snapshot_entries:
error = "No image digests were captured"
raise RuntimeError(error)
merged_entries = _merge_entries(existing_entries, snapshot_entries, now_iso=now_iso)
meta = {"generated_at": now_iso, "compose_dir": str(config.compose_dir)}
_write_toml(log_path, meta=meta, entries=merged_entries)
return log_path

View File

@@ -8,25 +8,21 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from rich.console import Console
from .compose import parse_external_networks, parse_host_volumes
from .console import console, err_console
from .executor import (
CommandResult,
check_networks_exist,
check_paths_exist,
check_service_running,
run_command,
run_compose,
run_compose_on_host,
)
from .state import get_service_host, set_service_host
from .state import get_service_host, set_multi_host_service, set_service_host
if TYPE_CHECKING:
from .config import Config
console = Console(highlight=False)
err_console = Console(stderr=True, highlight=False)
def get_service_paths(cfg: Config, service: str) -> list[str]:
"""Get all required paths for a service (compose_dir + volumes)."""
@@ -35,7 +31,7 @@ def get_service_paths(cfg: Config, service: str) -> list[str]:
return paths
async def check_mounts_for_migration(
async def _check_mounts_for_migration(
cfg: Config,
service: str,
target_host: str,
@@ -46,7 +42,7 @@ async def check_mounts_for_migration(
return [p for p, found in exists.items() if not found]
async def check_networks_for_migration(
async def _check_networks_for_migration(
cfg: Config,
service: str,
target_host: str,
@@ -59,7 +55,7 @@ async def check_networks_for_migration(
return [n for n, found in exists.items() if not found]
async def preflight_check(
async def _preflight_check(
cfg: Config,
service: str,
target_host: str,
@@ -68,12 +64,12 @@ async def preflight_check(
Returns (missing_paths, missing_networks).
"""
missing_paths = await check_mounts_for_migration(cfg, service, target_host)
missing_networks = await check_networks_for_migration(cfg, service, target_host)
missing_paths = await _check_mounts_for_migration(cfg, service, target_host)
missing_networks = await _check_networks_for_migration(cfg, service, target_host)
return missing_paths, missing_networks
def report_preflight_failures(
def _report_preflight_failures(
service: str,
target_host: str,
missing_paths: list[str],
@@ -89,6 +85,97 @@ def report_preflight_failures(
err_console.print(f" [red]✗[/] missing network: {net}")
async def _up_multi_host_service(
cfg: Config,
service: str,
prefix: str,
*,
raw: bool = False,
) -> list[CommandResult]:
"""Start a multi-host service on all configured hosts."""
host_names = cfg.get_hosts(service)
results: list[CommandResult] = []
compose_path = cfg.get_compose_path(service)
command = f"docker compose -f {compose_path} up -d"
# Pre-flight checks on all hosts
for host_name in host_names:
missing_paths, missing_networks = await _preflight_check(cfg, service, host_name)
if missing_paths or missing_networks:
_report_preflight_failures(service, host_name, missing_paths, missing_networks)
results.append(
CommandResult(service=f"{service}@{host_name}", exit_code=1, success=False)
)
return results
# Start on all hosts
hosts_str = ", ".join(f"[magenta]{h}[/]" for h in host_names)
console.print(f"{prefix} Starting on {hosts_str}...")
succeeded_hosts: list[str] = []
for host_name in host_names:
host = cfg.hosts[host_name]
label = f"{service}@{host_name}"
result = await run_command(host, command, label, stream=not raw, raw=raw)
if raw:
print() # Ensure newline after raw output
results.append(result)
if result.success:
succeeded_hosts.append(host_name)
# Update state with hosts that succeeded (partial success is tracked)
if succeeded_hosts:
set_multi_host_service(cfg, service, succeeded_hosts)
return results
async def _migrate_service(
cfg: Config,
service: str,
current_host: str,
target_host: str,
prefix: str,
*,
raw: bool = False,
) -> CommandResult | None:
"""Migrate a service from current_host to target_host.
Pre-pulls/builds images on target, then stops service 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.
# Each command is a no-op for the other type (exit 0, no work done).
pull_result = await run_compose(cfg, service, "pull", raw=raw)
if raw:
print() # Ensure newline after raw output
if not pull_result.success:
err_console.print(
f"{prefix} [red]✗[/] Pull failed on [magenta]{target_host}[/], "
"leaving service on current host"
)
return pull_result
build_result = await run_compose(cfg, service, "build", raw=raw)
if raw:
print() # Ensure newline after raw output
if not build_result.success:
err_console.print(
f"{prefix} [red]✗[/] Build failed on [magenta]{target_host}[/], "
"leaving service on current host"
)
return build_result
down_result = await run_compose_on_host(cfg, service, current_host, "down", raw=raw)
if raw:
print() # Ensure newline after raw output
if not down_result.success:
return down_result
return None
async def up_services(
cfg: Config,
services: list[str],
@@ -101,28 +188,31 @@ async def up_services(
for idx, service in enumerate(services, 1):
prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{service}][/]"
target_host = cfg.services[service]
# Handle multi-host services separately (no migration)
if cfg.is_multi_host(service):
multi_results = await _up_multi_host_service(cfg, service, prefix, raw=raw)
results.extend(multi_results)
continue
target_host = cfg.get_hosts(service)[0]
current_host = get_service_host(cfg, service)
# Pre-flight check: verify paths and networks exist on target
missing_paths, missing_networks = await preflight_check(cfg, service, target_host)
missing_paths, missing_networks = await _preflight_check(cfg, service, target_host)
if missing_paths or missing_networks:
report_preflight_failures(service, target_host, missing_paths, missing_networks)
_report_preflight_failures(service, target_host, missing_paths, missing_networks)
results.append(CommandResult(service=service, exit_code=1, success=False))
continue
# If service is deployed elsewhere, migrate it
if current_host and current_host != target_host:
if current_host in cfg.hosts:
console.print(
f"{prefix} Migrating from "
f"[magenta]{current_host}[/] → [magenta]{target_host}[/]..."
failure = await _migrate_service(
cfg, service, current_host, target_host, prefix, raw=raw
)
down_result = await run_compose_on_host(cfg, service, current_host, "down", raw=raw)
if raw:
print() # Ensure newline after raw output
if not down_result.success:
results.append(down_result)
if failure:
results.append(failure)
continue
else:
err_console.print(
@@ -144,30 +234,6 @@ async def up_services(
return results
async def discover_running_services(cfg: Config) -> dict[str, str]:
"""Discover which services are running on which hosts.
Returns a dict mapping service names to host names for running services.
"""
discovered: dict[str, str] = {}
for service, assigned_host in cfg.services.items():
# Check assigned host first (most common case)
if await check_service_running(cfg, service, assigned_host):
discovered[service] = assigned_host
continue
# Check other hosts in case service was migrated but state is stale
for host_name in cfg.hosts:
if host_name == assigned_host:
continue
if await check_service_running(cfg, service, host_name):
discovered[service] = host_name
break
return discovered
async def check_host_compatibility(
cfg: Config,
service: str,
@@ -186,49 +252,3 @@ async def check_host_compatibility(
results[host_name] = (found, len(paths), missing)
return results
async def check_mounts_on_configured_hosts(
cfg: Config,
services: list[str],
) -> list[tuple[str, str, str]]:
"""Check mount paths exist on configured hosts.
Returns list of (service, host, missing_path) tuples.
"""
missing: list[tuple[str, str, str]] = []
for service in services:
host_name = cfg.services[service]
paths = get_service_paths(cfg, service)
exists = await check_paths_exist(cfg, host_name, paths)
for path, found in exists.items():
if not found:
missing.append((service, host_name, path))
return missing
async def check_networks_on_configured_hosts(
cfg: Config,
services: list[str],
) -> list[tuple[str, str, str]]:
"""Check Docker networks exist on configured hosts.
Returns list of (service, host, missing_network) tuples.
"""
missing: list[tuple[str, str, str]] = []
for service in services:
host_name = cfg.services[service]
networks = parse_external_networks(cfg, service)
if not networks:
continue
exists = await check_networks_exist(cfg, host_name, networks)
for net, found in exists.items():
if not found:
missing.append((service, host_name, net))
return missing

View File

@@ -2,18 +2,22 @@
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
import yaml
if TYPE_CHECKING:
from collections.abc import Generator
from .config import Config
def load_state(config: Config) -> dict[str, str]:
def load_state(config: Config) -> dict[str, str | list[str]]:
"""Load the current deployment state.
Returns a dict mapping service names to host names.
Returns a dict mapping service names to host name(s).
Multi-host services store a list of hosts.
"""
state_path = config.get_state_path()
if not state_path.exists():
@@ -22,43 +26,119 @@ def load_state(config: Config) -> dict[str, str]:
with state_path.open() as f:
data: dict[str, Any] = yaml.safe_load(f) or {}
deployed: dict[str, str] = data.get("deployed", {})
deployed: dict[str, str | list[str]] = data.get("deployed", {})
return deployed
def save_state(config: Config, deployed: dict[str, str]) -> None:
def _sorted_dict(d: dict[str, str | list[str]]) -> dict[str, str | list[str]]:
"""Return a dictionary sorted by keys."""
return dict(sorted(d.items(), key=lambda item: item[0]))
def save_state(config: Config, deployed: dict[str, str | list[str]]) -> None:
"""Save the deployment state."""
state_path = config.get_state_path()
with state_path.open("w") as f:
yaml.safe_dump({"deployed": deployed}, f, sort_keys=False)
yaml.safe_dump({"deployed": _sorted_dict(deployed)}, f, sort_keys=False)
@contextlib.contextmanager
def _modify_state(config: Config) -> Generator[dict[str, str | list[str]], None, None]:
"""Context manager to load, modify, and save state."""
state = load_state(config)
yield state
save_state(config, state)
def get_service_host(config: Config, service: str) -> str | None:
"""Get the host where a service is currently deployed."""
"""Get the host where a service is currently deployed.
For multi-host services, returns the first host or None.
"""
state = load_state(config)
return state.get(service)
value = state.get(service)
if value is None:
return None
if isinstance(value, list):
return value[0] if value else None
return value
def set_service_host(config: Config, service: str, host: str) -> None:
"""Record that a service is deployed on a host."""
state = load_state(config)
state[service] = host
save_state(config, state)
with _modify_state(config) as state:
state[service] = 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."""
with _modify_state(config) as state:
state[service] = hosts
def remove_service(config: Config, service: str) -> None:
"""Remove a service from the state (after down)."""
state = load_state(config)
state.pop(service, None)
save_state(config, state)
with _modify_state(config) as state:
state.pop(service, None)
def add_service_to_host(config: Config, service: str, host: str) -> None:
"""Add a specific host to a service's state.
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."""
state = load_state(config)
"""Get services where current host differs from configured host.
Multi-host services are never considered for migration.
"""
needs_migration = []
for service, configured_host in config.services.items():
current_host = state.get(service)
for service in config.services:
# Skip multi-host services
if config.is_multi_host(service):
continue
configured_host = config.get_hosts(service)[0]
current_host = get_service_host(config, service)
if current_host and current_host != configured_host:
needs_migration.append(service)
return needs_migration

View File

@@ -11,6 +11,8 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import yaml
from .compose import (
PortMapping,
get_ports_for_service,
@@ -24,7 +26,7 @@ if TYPE_CHECKING:
@dataclass
class TraefikServiceSource:
class _TraefikServiceSource:
"""Source information to build an upstream for a Traefik service."""
traefik_service: str
@@ -36,9 +38,9 @@ class TraefikServiceSource:
scheme: str | None = None
LIST_VALUE_KEYS = {"entrypoints", "middlewares"}
MIN_ROUTER_PARTS = 3
MIN_SERVICE_LABEL_PARTS = 6
_LIST_VALUE_KEYS = {"entrypoints", "middlewares"}
_MIN_ROUTER_PARTS = 3
_MIN_SERVICE_LABEL_PARTS = 6
def _parse_value(key: str, raw_value: str) -> Any:
@@ -49,7 +51,7 @@ def _parse_value(key: str, raw_value: str) -> Any:
if value.isdigit():
return int(value)
last_segment = key.rsplit(".", 1)[-1]
if last_segment in LIST_VALUE_KEYS:
if last_segment in _LIST_VALUE_KEYS:
parts = [v.strip() for v in value.split(",")] if "," in value else [value]
return [part for part in parts if part]
return value
@@ -100,7 +102,7 @@ def _insert(root: dict[str, Any], key_path: list[str], value: Any) -> None: # n
current = container_list[list_index]
def _resolve_published_port(source: TraefikServiceSource) -> tuple[int | None, str | None]:
def _resolve_published_port(source: _TraefikServiceSource) -> tuple[int | None, str | None]:
"""Resolve host-published port for a Traefik service.
Returns (published_port, warning_message).
@@ -138,7 +140,7 @@ def _resolve_published_port(source: TraefikServiceSource) -> tuple[int | None, s
def _finalize_http_services(
dynamic: dict[str, Any],
sources: dict[str, TraefikServiceSource],
sources: dict[str, _TraefikServiceSource],
warnings: list[str],
) -> None:
for traefik_service, source in sources.items():
@@ -209,7 +211,7 @@ def _process_router_label(
if not key_without_prefix.startswith("http.routers."):
return
router_parts = key_without_prefix.split(".")
if len(router_parts) < MIN_ROUTER_PARTS:
if len(router_parts) < _MIN_ROUTER_PARTS:
return
router_name = router_parts[2]
router_remainder = router_parts[3:]
@@ -227,12 +229,12 @@ def _process_service_label(
host_address: str,
ports: list[PortMapping],
service_names: set[str],
sources: dict[str, TraefikServiceSource],
sources: dict[str, _TraefikServiceSource],
) -> None:
if not key_without_prefix.startswith("http.services."):
return
parts = key_without_prefix.split(".")
if len(parts) < MIN_SERVICE_LABEL_PARTS:
if len(parts) < _MIN_SERVICE_LABEL_PARTS:
return
traefik_service = parts[2]
service_names.add(traefik_service)
@@ -240,7 +242,7 @@ def _process_service_label(
source = sources.get(traefik_service)
if source is None:
source = TraefikServiceSource(
source = _TraefikServiceSource(
traefik_service=traefik_service,
stack=stack,
compose_service=compose_service,
@@ -265,7 +267,7 @@ def _process_service_labels(
host_address: str,
env: dict[str, str],
dynamic: dict[str, Any],
sources: dict[str, TraefikServiceSource],
sources: dict[str, _TraefikServiceSource],
warnings: list[str],
) -> None:
labels = normalize_labels(definition.get("labels"), env)
@@ -326,7 +328,7 @@ def generate_traefik_config(
"""
dynamic: dict[str, Any] = {}
warnings: list[str] = []
sources: dict[str, TraefikServiceSource] = {}
sources: dict[str, _TraefikServiceSource] = {}
# Determine Traefik's host from service assignment
traefik_host = None
@@ -362,3 +364,22 @@ def generate_traefik_config(
_finalize_http_services(dynamic, sources, warnings)
return dynamic, warnings
_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.
# Services on Traefik's host use the Docker provider directly.
#
# Regenerate with: compose-farm traefik-file --all -o <this-file>
# Or configure traefik_file in compose-farm.yaml for automatic updates.
"""
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

207
tests/test_cli_logs.py Normal file
View File

@@ -0,0 +1,207 @@
"""Tests for CLI logs command."""
from collections.abc import Coroutine
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
import typer
from compose_farm.cli.monitoring import logs
from compose_farm.config import Config, Host
from compose_farm.executor import CommandResult
def _make_config(tmp_path: Path) -> Config:
"""Create a minimal config for testing."""
compose_dir = tmp_path / "compose"
compose_dir.mkdir()
for svc in ("svc1", "svc2", "svc3"):
svc_dir = compose_dir / svc
svc_dir.mkdir()
(svc_dir / "docker-compose.yml").write_text("services: {}\n")
return Config(
compose_dir=compose_dir,
hosts={"local": Host(address="localhost"), "remote": Host(address="192.168.1.10")},
services={"svc1": "local", "svc2": "local", "svc3": "remote"},
)
def _make_result(service: str) -> CommandResult:
"""Create a successful command result."""
return CommandResult(service=service, exit_code=0, success=True, stdout="", stderr="")
def _mock_run_async_factory(
services: list[str],
) -> tuple[Any, list[CommandResult]]:
"""Create a mock run_async that returns results for given services."""
results = [_make_result(s) for s in services]
def mock_run_async(_coro: Coroutine[Any, Any, Any]) -> list[CommandResult]:
return results
return mock_run_async, results
class TestLogsContextualDefault:
"""Tests for logs --tail contextual default behavior."""
def test_logs_all_services_defaults_to_20(self, tmp_path: Path) -> None:
"""When --all is specified, default tail should be 20."""
cfg = _make_config(tmp_path)
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2", "svc3"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):
mock_run.return_value = None
logs(services=None, all_services=True, host=None, follow=False, tail=None, config=None)
mock_run.assert_called_once()
call_args = mock_run.call_args
assert call_args[0][2] == "logs --tail 20"
def test_logs_single_service_defaults_to_100(self, tmp_path: Path) -> None:
"""When specific services are specified, default tail should be 100."""
cfg = _make_config(tmp_path)
mock_run_async, _ = _mock_run_async_factory(["svc1"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):
logs(
services=["svc1"],
all_services=False,
host=None,
follow=False,
tail=None,
config=None,
)
mock_run.assert_called_once()
call_args = mock_run.call_args
assert call_args[0][2] == "logs --tail 100"
def test_logs_explicit_tail_overrides_default(self, tmp_path: Path) -> None:
"""When --tail is explicitly provided, it should override the default."""
cfg = _make_config(tmp_path)
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2", "svc3"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):
logs(
services=None,
all_services=True,
host=None,
follow=False,
tail=50,
config=None,
)
mock_run.assert_called_once()
call_args = mock_run.call_args
assert call_args[0][2] == "logs --tail 50"
def test_logs_follow_appends_flag(self, tmp_path: Path) -> None:
"""When --follow is specified, -f should be appended to command."""
cfg = _make_config(tmp_path)
mock_run_async, _ = _mock_run_async_factory(["svc1"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.common.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):
logs(
services=["svc1"],
all_services=False,
host=None,
follow=True,
tail=None,
config=None,
)
mock_run.assert_called_once()
call_args = mock_run.call_args
assert call_args[0][2] == "logs --tail 100 -f"
class TestLogsHostFilter:
"""Tests for logs --host filter behavior."""
def test_logs_host_filter_selects_services_on_host(self, tmp_path: Path) -> None:
"""When --host is specified, only services on that host are included."""
cfg = _make_config(tmp_path)
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):
logs(
services=None,
all_services=False,
host="local",
follow=False,
tail=None,
config=None,
)
mock_run.assert_called_once()
call_args = mock_run.call_args
# svc1 and svc2 are on "local", svc3 is on "remote"
assert set(call_args[0][1]) == {"svc1", "svc2"}
def test_logs_host_filter_defaults_to_20_lines(self, tmp_path: Path) -> None:
"""When --host is specified, default tail should be 20 (multiple services)."""
cfg = _make_config(tmp_path)
mock_run_async, _ = _mock_run_async_factory(["svc1", "svc2"])
with (
patch("compose_farm.cli.monitoring.load_config_or_exit", return_value=cfg),
patch("compose_farm.cli.monitoring.run_async", side_effect=mock_run_async),
patch("compose_farm.cli.monitoring.run_on_services") as mock_run,
):
logs(
services=None,
all_services=False,
host="local",
follow=False,
tail=None,
config=None,
)
mock_run.assert_called_once()
call_args = mock_run.call_args
assert call_args[0][2] == "logs --tail 20"
def test_logs_all_and_host_mutually_exclusive(self) -> None:
"""Using --all and --host together should error."""
# No config mock needed - error is raised before config is loaded
with pytest.raises(typer.Exit) as exc_info:
logs(
services=None,
all_services=True,
host="local",
follow=False,
tail=None,
config=None,
)
assert exc_info.value.exit_code == 1

View File

@@ -128,6 +128,8 @@ class TestLoadConfig:
def test_load_config_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CF_CONFIG", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "empty_config"))
with pytest.raises(FileNotFoundError, match="Config file not found"):
load_config()

238
tests/test_config_cmd.py Normal file
View File

@@ -0,0 +1,238 @@
"""Tests for config command module."""
from pathlib import Path
from typing import Any
import pytest
import yaml
from typer.testing import CliRunner
import compose_farm.cli.config as config_cmd_module
from compose_farm.cli import app
from compose_farm.cli.config import (
_generate_template,
_get_config_file,
_get_editor,
)
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def valid_config_data() -> dict[str, Any]:
return {
"compose_dir": "/opt/compose",
"hosts": {"server1": "192.168.1.10"},
"services": {"nginx": "server1"},
}
class TestGetEditor:
"""Tests for _get_editor function."""
def test_uses_editor_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("EDITOR", "code")
monkeypatch.delenv("VISUAL", raising=False)
assert _get_editor() == "code"
def test_uses_visual_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("EDITOR", raising=False)
monkeypatch.setenv("VISUAL", "subl")
assert _get_editor() == "subl"
def test_editor_takes_precedence(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("EDITOR", "vim")
monkeypatch.setenv("VISUAL", "code")
assert _get_editor() == "vim"
class TestGetConfigFile:
"""Tests for _get_config_file function."""
def test_explicit_path(self, tmp_path: Path) -> None:
config_file = tmp_path / "my-config.yaml"
config_file.touch()
result = _get_config_file(config_file)
assert result == config_file.resolve()
def test_cf_config_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
config_file = tmp_path / "env-config.yaml"
config_file.touch()
monkeypatch.setenv("CF_CONFIG", str(config_file))
result = _get_config_file(None)
assert result == config_file.resolve()
def test_returns_none_when_not_found(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CF_CONFIG", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "nonexistent"))
# Monkeypatch _CONFIG_PATHS to avoid finding existing files
monkeypatch.setattr(
config_cmd_module,
"_CONFIG_PATHS",
[
tmp_path / "compose-farm.yaml",
tmp_path / "nonexistent" / "compose-farm" / "compose-farm.yaml",
],
)
result = _get_config_file(None)
assert result is None
class TestGenerateTemplate:
"""Tests for _generate_template function."""
def test_generates_valid_yaml(self) -> None:
template = _generate_template()
# Should be valid YAML
data = yaml.safe_load(template)
assert "compose_dir" in data
assert "hosts" in data
assert "services" in data
def test_has_documentation_comments(self) -> None:
template = _generate_template()
assert "# Compose Farm configuration" in template
assert "hosts:" in template
assert "services:" in template
class TestConfigInit:
"""Tests for cf config init command."""
def test_init_creates_file(
self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "new-config.yaml"
result = runner.invoke(app, ["config", "init", "-p", str(config_file)])
assert result.exit_code == 0
assert config_file.exists()
assert "Config file created" in result.stdout
def test_init_force_overwrites(
self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "existing.yaml"
config_file.write_text("old content")
result = runner.invoke(app, ["config", "init", "-p", str(config_file), "-f"])
assert result.exit_code == 0
content = config_file.read_text()
assert "old content" not in content
assert "compose_dir" in content
def test_init_prompts_on_existing(
self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "existing.yaml"
config_file.write_text("old content")
result = runner.invoke(app, ["config", "init", "-p", str(config_file)], input="n\n")
assert result.exit_code == 0
assert "Aborted" in result.stdout
assert config_file.read_text() == "old content"
class TestConfigPath:
"""Tests for cf config path command."""
def test_path_shows_config(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
config_file.write_text(yaml.dump(valid_config_data))
result = runner.invoke(app, ["config", "path"])
assert result.exit_code == 0
assert str(config_file) in result.stdout
def test_path_with_explicit_path(self, runner: CliRunner, tmp_path: Path) -> None:
# When explicitly provided, path is returned even if file doesn't exist
nonexistent = tmp_path / "nonexistent.yaml"
result = runner.invoke(app, ["config", "path", "-p", str(nonexistent)])
assert result.exit_code == 0
assert str(nonexistent) in result.stdout
class TestConfigShow:
"""Tests for cf config show command."""
def test_show_displays_content(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
config_file.write_text(yaml.dump(valid_config_data))
result = runner.invoke(app, ["config", "show"])
assert result.exit_code == 0
assert "Config file:" in result.stdout
def test_show_raw_output(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
content = yaml.dump(valid_config_data)
config_file.write_text(content)
result = runner.invoke(app, ["config", "show", "-r"])
assert result.exit_code == 0
assert content in result.stdout
class TestConfigValidate:
"""Tests for cf config validate command."""
def test_validate_valid_config(
self,
runner: CliRunner,
tmp_path: Path,
valid_config_data: dict[str, Any],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("CF_CONFIG", raising=False)
config_file = tmp_path / "compose-farm.yaml"
config_file.write_text(yaml.dump(valid_config_data))
result = runner.invoke(app, ["config", "validate"])
assert result.exit_code == 0
assert "Valid config" in result.stdout
assert "Hosts: 1" in result.stdout
assert "Services: 1" in result.stdout
def test_validate_invalid_config(self, runner: CliRunner, tmp_path: Path) -> None:
config_file = tmp_path / "invalid.yaml"
config_file.write_text("invalid: [yaml: content")
result = runner.invoke(app, ["config", "validate", "-p", str(config_file)])
assert result.exit_code == 1
# Error goes to stderr (captured in output when using CliRunner)
output = result.stdout + (result.stderr or "")
assert "Invalid config" in output or "" in output
def test_validate_missing_config(self, runner: CliRunner, tmp_path: Path) -> None:
nonexistent = tmp_path / "nonexistent.yaml"
result = runner.invoke(app, ["config", "validate", "-p", str(nonexistent)])
assert result.exit_code == 1
# Error goes to stderr
output = result.stdout + (result.stderr or "")
assert "Config file not found" in output or "not found" in output.lower()

View File

@@ -8,10 +8,10 @@ import pytest
from compose_farm.config import Config, Host
from compose_farm.executor import (
CommandResult,
_is_local,
_run_local_command,
check_networks_exist,
check_paths_exist,
is_local,
run_command,
run_compose,
run_on_services,
@@ -22,7 +22,7 @@ linux_only = pytest.mark.skipif(sys.platform != "linux", reason="Linux-only shel
class TestIsLocal:
"""Tests for _is_local function."""
"""Tests for is_local function."""
@pytest.mark.parametrize(
"address",
@@ -30,7 +30,7 @@ class TestIsLocal:
)
def test_local_addresses(self, address: str) -> None:
host = Host(address=address)
assert _is_local(host) is True
assert is_local(host) is True
@pytest.mark.parametrize(
"address",
@@ -38,7 +38,7 @@ class TestIsLocal:
)
def test_remote_addresses(self, address: str) -> None:
host = Host(address=address)
assert _is_local(host) is False
assert is_local(host) is False
class TestRunLocalCommand:

View File

@@ -9,7 +9,14 @@ import pytest
from compose_farm.config import Config, Host
from compose_farm.executor import CommandResult
from compose_farm.logs import _parse_images_output, snapshot_services
from compose_farm.logs import (
_parse_images_output,
collect_service_entries,
isoformat,
load_existing_entries,
merge_entries,
write_toml,
)
def test_parse_images_output_handles_list_and_lines() -> None:
@@ -55,26 +62,29 @@ async def test_snapshot_preserves_first_seen(tmp_path: Path) -> None:
log_path = tmp_path / "dockerfarm-log.toml"
# First snapshot
first_time = datetime(2025, 1, 1, tzinfo=UTC)
await snapshot_services(
config,
["svc"],
log_path=log_path,
now=first_time,
run_compose_fn=fake_run_compose,
first_entries = await collect_service_entries(
config, "svc", now=first_time, run_compose_fn=fake_run_compose
)
first_iso = isoformat(first_time)
merged = merge_entries([], first_entries, now_iso=first_iso)
meta = {"generated_at": first_iso, "compose_dir": str(config.compose_dir)}
write_toml(log_path, meta=meta, entries=merged)
after_first = tomllib.loads(log_path.read_text())
first_seen = after_first["entries"][0]["first_seen"]
# Second snapshot
second_time = datetime(2025, 2, 1, tzinfo=UTC)
await snapshot_services(
config,
["svc"],
log_path=log_path,
now=second_time,
run_compose_fn=fake_run_compose,
second_entries = await collect_service_entries(
config, "svc", now=second_time, run_compose_fn=fake_run_compose
)
second_iso = isoformat(second_time)
existing = load_existing_entries(log_path)
merged = merge_entries(existing, second_entries, now_iso=second_iso)
meta = {"generated_at": second_iso, "compose_dir": str(config.compose_dir)}
write_toml(log_path, meta=meta, entries=merged)
after_second = tomllib.loads(log_path.read_text())
entry = after_second["entries"][0]

View File

@@ -1,15 +1,13 @@
"""Tests for sync command and related functions."""
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from compose_farm import cli as cli_module
from compose_farm import executor as executor_module
from compose_farm import operations as operations_module
from compose_farm import state as state_module
from compose_farm.cli import management as cli_management_module
from compose_farm.config import Config, Host
from compose_farm.executor import CommandResult, check_service_running
@@ -95,48 +93,12 @@ class TestCheckServiceRunning:
assert result is False
class TestDiscoverRunningServices:
"""Tests for discover_running_services function."""
@pytest.mark.asyncio
async def test_discovers_on_assigned_host(self, mock_config: Config) -> None:
"""Discovers service running on its assigned host."""
with patch.object(
operations_module, "check_service_running", new_callable=AsyncMock
) as mock_check:
# plex running on nas01, jellyfin not running, sonarr on nas02
async def check_side_effect(_cfg: Any, service: str, host: str) -> bool:
return (service == "plex" and host == "nas01") or (
service == "sonarr" and host == "nas02"
)
mock_check.side_effect = check_side_effect
result = await operations_module.discover_running_services(mock_config)
assert result == {"plex": "nas01", "sonarr": "nas02"}
@pytest.mark.asyncio
async def test_discovers_on_different_host(self, mock_config: Config) -> None:
"""Discovers service running on non-assigned host (after migration)."""
with patch.object(
operations_module, "check_service_running", new_callable=AsyncMock
) as mock_check:
# plex migrated to nas02
async def check_side_effect(_cfg: Any, service: str, host: str) -> bool:
return service == "plex" and host == "nas02"
mock_check.side_effect = check_side_effect
result = await operations_module.discover_running_services(mock_config)
assert result == {"plex": "nas02"}
class TestReportSyncChanges:
"""Tests for _report_sync_changes function."""
def test_reports_added(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Reports newly discovered services."""
cli_module._report_sync_changes(
cli_management_module._report_sync_changes(
added=["plex", "jellyfin"],
removed=[],
changed=[],
@@ -150,7 +112,7 @@ class TestReportSyncChanges:
def test_reports_removed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Reports services that are no longer running."""
cli_module._report_sync_changes(
cli_management_module._report_sync_changes(
added=[],
removed=["sonarr"],
changed=[],
@@ -163,7 +125,7 @@ class TestReportSyncChanges:
def test_reports_changed(self, capsys: pytest.CaptureFixture[str]) -> None:
"""Reports services that moved to a different host."""
cli_module._report_sync_changes(
cli_management_module._report_sync_changes(
added=[],
removed=[],
changed=[("plex", "nas01", "nas02")],